<script setup>
// Персональные MCP-ключи (канон handbook 10-platform/mcp.md): plaintext
// показывается один раз в модальном окне, в списках — только хвост-хинт.
import { computed, onMounted, ref } from 'vue'
import {
GnAlert, GnBadge, GnButton, GnCard, GnConfirmDialog, GnCopyButton,
GnIconButton, GnInput, GnLoader, GnModal, GnPageHeader, GnTable,
} from 'gnexus-ui-kit/vue'
import { useToast } from 'gnexus-ui-kit/vue'
import { api } from '../api.js'
import { fmtAgo } from '../format.js'
import { t } from '../i18n/index.js'
const toast = useToast()
const me = ref(null)
const tokens = ref([])
const allTokens = ref([])
const info = ref(null) // { active_count, limit }
const loading = ref(true)
const error = ref('')
const isAdmin = computed(() => {
const role = me.value?.user?.role
return role === 'admin' || role === 'superadmin'
})
// --- Списки ---------------------------------------------------------------------
async function load() {
try {
const data = await api.mcpTokens()
tokens.value = data.tokens
info.value = data
error.value = ''
if (isAdmin.value) allTokens.value = await api.adminMcpTokens()
} catch (e) {
if (e.status !== 401) error.value = e.message
} finally {
loading.value = false
}
}
onMounted(async () => {
me.value = await api.me().catch(() => null)
if (me.value && me.value.auth_enabled === false) {
// открытая панель: персональных личностей нет — не дёргаем API (401 бы
// поднял событие ghard:unauthenticated и выкинул SPA на экран входа)
loading.value = false
return
}
await load()
})
// Колонки — computed: module-level массивы с t() застывают (грабля из Settings)
const columns = computed(() => [
{ key: 'name', label: t('mcpKeys.colKey') },
{ key: 'token_hint', label: t('mcpKeys.colTail') },
{ key: 'system_role', label: t('mcpKeys.colRole') },
{ key: 'created_at', label: t('mcpKeys.colCreated') },
{ key: 'last_used_at', label: t('mcpKeys.colUsed') },
{ key: 'revoked_at', label: t('mcpKeys.colStatus') },
{ key: 'actions', label: '' },
])
const adminColumns = computed(() => [
{ key: 'user_email', label: t('mcpKeys.colOwner') },
...columns.value,
])
function roleVariant(role) {
if (role === 'superadmin') return 'info'
if (role === 'admin') return 'warning'
return 'secondary'
}
// --- Создание (plaintext ровно один раз — паттерн дашборда) --------------------
const modalOpen = ref(false)
const newName = ref('')
const creating = ref(false)
const createdToken = ref(null)
function openModal() {
newName.value = ''
createdToken.value = null
modalOpen.value = true
}
async function create() {
if (creating.value) return
creating.value = true
try {
createdToken.value = await api.createMcpToken(newName.value.trim() || t('mcpKeys.defaultName'))
await load()
} catch (e) {
toast.danger({ title: t('mcpKeys.failed'), text: e.message })
} finally {
creating.value = false
}
}
// --- Ревок ----------------------------------------------------------------------
const revokeTarget = ref(null)
const revokeOpen = ref(false)
function askRevoke(row) {
revokeTarget.value = row
revokeOpen.value = true
}
async function doRevoke() {
if (!revokeTarget.value) return
try {
await api.revokeMcpToken(revokeTarget.value.id)
toast.success({ title: t('mcpKeys.revokedTitle') })
await load()
} catch (e) {
toast.danger({ title: t('mcpKeys.failed'), text: e.message })
} finally {
revokeTarget.value = null
}
}
// --- Как подключить агент ---------------------------------------------------------
const connectExample = computed(() =>
`claude mcp add --transport http ghard ${window.location.origin}/mcp `
+ `--header "Authorization: Bearer ${createdToken.value?.token || 'mcp_…'}"`)
</script>
<template>
<GnPageHeader :title="t('mcpKeys.title')" kicker="GHard Monitor" />
<!-- открытая панель: персональные ключи не работают -->
<GnAlert v-if="me && me.auth_enabled === false" variant="info">
{{ t('mcpKeys.authOff') }}
</GnAlert>
<GnLoader v-else-if="loading" circle :label="t('mcpKeys.loading')" />
<GnAlert v-else-if="error" variant="danger" style="margin-bottom: 16px">{{ error }}</GnAlert>
<template v-else>
<GnCard :title="t('mcpKeys.myKeys')" class="gap-card">
<div class="head-row">
<p class="card-note">{{ t('mcpKeys.myKeysNote') }}</p>
<p v-if="info" class="card-note">
{{ t('mcpKeys.limits', { count: info.active_count, limit: info.limit }) }}
</p>
</div>
<div class="issue-row">
<GnInput
v-model="newName"
class="issue-name"
:placeholder="t('mcpKeys.namePlaceholder')"
maxlength="120"
@keydown.enter="openModal"
/>
<GnButton variant="primary" size="sm" icon="ph-key" @click="openModal">
{{ t('mcpKeys.generate') }}
</GnButton>
</div>
<GnTable :columns="columns" :rows="tokens" :empty-text="t('mcpKeys.emptyOwn')">
<template #cell-token_hint="{ value }"><code>{{ value }}</code></template>
<template #cell-system_role="{ value }">
<GnBadge :variant="roleVariant(value)" outline>{{ value }}</GnBadge>
</template>
<template #cell-created_at="{ value }">{{ fmtAgo(value) }}</template>
<template #cell-last_used_at="{ value }">{{ fmtAgo(value) }}</template>
<template #cell-revoked_at="{ row }">
<GnBadge :variant="row.revoked_at ? 'danger' : 'success'" outline>
{{ row.revoked_at ? t('mcpKeys.badgeRevoked') : t('mcpKeys.badgeActive') }}
</GnBadge>
</template>
<template #cell-actions="{ row }">
<GnIconButton
v-if="!row.revoked_at"
icon="ph ph-x-circle"
size="sm"
:label="t('mcpKeys.revoke')"
@click="askRevoke(row)"
/>
</template>
</GnTable>
</GnCard>
<!-- Как подключить агент -->
<GnCard :title="t('mcpKeys.howToConnect')" class="gap-card">
<p class="card-note">{{ t('mcpKeys.connectNote') }}</p>
<div class="snippet-row">
<pre class="snippet"><code>{{ connectExample }}</code></pre>
<GnCopyButton :text="connectExample" :label="t('common.copy')" />
</div>
</GnCard>
<!-- Админ: все ключи всех пользователей -->
<GnCard v-if="isAdmin" :title="t('mcpKeys.allKeysAdmin')" class="gap-card">
<p class="card-note">{{ t('mcpKeys.allKeysNote') }}</p>
<GnTable :columns="adminColumns" :rows="allTokens" :empty-text="t('mcpKeys.emptyAll')">
<template #cell-user_email="{ row }">
<span :title="row.user_id">{{ row.user_email }}</span>
</template>
<template #cell-token_hint="{ value }"><code>{{ value }}</code></template>
<template #cell-system_role="{ value }">
<GnBadge :variant="roleVariant(value)" outline>{{ value }}</GnBadge>
</template>
<template #cell-created_at="{ value }">{{ fmtAgo(value) }}</template>
<template #cell-last_used_at="{ value }">{{ fmtAgo(value) }}</template>
<template #cell-revoked_at="{ row }">
<GnBadge :variant="row.revoked_at ? 'danger' : 'success'" outline>
{{ row.revoked_at ? t('mcpKeys.badgeRevoked') : t('mcpKeys.badgeActive') }}
</GnBadge>
</template>
<template #cell-actions="{ row }">
<GnIconButton
v-if="!row.revoked_at"
icon="ph ph-x-circle"
size="sm"
:label="t('mcpKeys.revoke')"
@click="askRevoke(row)"
/>
</template>
</GnTable>
</GnCard>
</template>
<!-- Создание: форма → plaintext -->
<GnModal v-model:open="modalOpen" :title="createdToken ? t('mcpKeys.createdTitle') : t('mcpKeys.newKey')">
<template v-if="!createdToken">
<p class="modal-hint">{{ t('mcpKeys.plaintextOnceName') }}</p>
<GnInput v-model="newName" :label="t('mcpKeys.keyName')" icon="ph-key" placeholder="agent" />
</template>
<template v-else>
<p class="modal-hint">{{ t('mcpKeys.plaintextHint') }}</p>
<div class="key-row">
<code class="key-value">{{ createdToken.token }}</code>
<GnCopyButton :text="createdToken.token" :label="t('common.copy')" />
</div>
</template>
<template #actions="{ close }">
<GnButton variant="primary" @click="close">{{ t('common.close') }}</GnButton>
<GnButton
v-if="!createdToken"
variant="secondary"
:loading="creating"
@click="create"
>
{{ t('mcpKeys.generate') }}
</GnButton>
</template>
</GnModal>
<GnConfirmDialog
v-model:open="revokeOpen"
:title="t('mcpKeys.revokeTitle')"
:message="t('mcpKeys.revokeMessage', { name: revokeTarget?.name || '' })"
:confirm-text="t('mcpKeys.revoke')"
:cancel-text="t('common.cancel')"
confirm-variant="danger"
@confirm="doRevoke"
/>
</template>
<style scoped>
/* GnCard по умолчанию — fit-content; страницы панели тянут карты на всю ширину */
.gap-card {
max-width: none;
width: 100%;
}
.head-row {
margin-bottom: 14px;
}
.card-note {
margin: 0;
opacity: 0.75;
font-size: 14px;
}
.issue-row {
display: flex;
gap: 12px;
align-items: flex-end;
flex-wrap: wrap;
margin-bottom: 18px;
}
.issue-name {
flex: 1;
min-width: 240px;
}
.gap-card {
margin-top: 20px;
}
.snippet-row {
display: flex;
align-items: flex-start;
gap: 12px;
flex-wrap: wrap;
}
.snippet {
margin: 0;
padding: 12px 14px;
border: 1px solid rgba(163, 184, 170, 0.24);
border-radius: 6px;
background: rgba(163, 184, 170, 0.08);
overflow-x: auto;
flex: 1;
min-width: 240px;
white-space: pre-wrap;
word-break: break-all;
}
.modal-hint {
margin: 0 0 14px;
opacity: 0.75;
font-size: 14px;
}
.key-row {
display: flex;
align-items: center;
gap: 12px;
flex-wrap: wrap;
}
.key-value {
padding: 8px 12px;
border: 1px solid rgba(163, 184, 170, 0.24);
border-radius: 6px;
background: rgba(163, 184, 170, 0.08);
word-break: break-all;
}
</style>