<!-- MCP-ключи (/mcp-keys, docs/07): персональные токены доступа к /mcp.
Любая роль выпускает себе ключ (plaintext один раз — модель API-ключей
источников); админ дополнительно видит все ключи всех пользователей и
может отозвать любой (например, после смены роли пользователя). Ключ
действует ролью владельца на момент выпуска (снейпшот). -->
<script setup>
import { computed, onMounted, ref } from "vue";
import {
GnAlert,
GnBadge,
GnButton,
GnConfirmDialog,
GnCopyButton,
GnIconButton,
GnInput,
GnLoader,
GnModal,
GnPageHeader,
GnStatusCard,
GnTable,
} from "gnexus-ui-kit/vue";
import { useToast } from "gnexus-ui-kit/vue";
import { api, getSession } from "../api.js";
import { fmtTime, th } from "../ui.js";
import { t } from "../i18n/index.js";
const toast = useToast();
const tokens = ref([]);
const allTokens = ref([]);
const loading = ref(true);
const error = ref("");
const isAdmin = computed(() => {
const role = session.value?.system_role || "";
return role === "admin" || role === "superadmin";
});
const session = ref(null);
// Форма выпуска: имя ключа («агент Navi rei», «CI-прогон»…)
const newName = ref("");
const busy = ref(false);
// Подключение клиента к /mcp: тот же origin, ключ в заголовке
const connectExample = JSON.stringify(
{
mcpServers: {
synapse: {
url: window.location.origin + "/mcp",
headers: { Authorization: "Bearer mcp_..." },
},
},
},
null,
2,
);
async function load() {
loading.value = true;
error.value = "";
try {
tokens.value = await api.mcpTokens();
if (!isAdmin.value) return;
try {
allTokens.value = await api.adminMcpTokens();
} catch {
// не админ (сессия изменилась) — не ломаем личную половину
}
} catch (ex) {
error.value = ex.message;
} finally {
loading.value = false;
}
}
async function createToken() {
busy.value = true;
try {
const created = await api.createMcpToken({ name: newName.value.trim() || t("mcpKeys.defaultName") });
tokenModal.value = created;
newName.value = "";
tokens.value = await api.mcpTokens();
} catch (ex) {
toast.error(ex.message);
} finally {
busy.value = false;
}
}
// Токен показывается один раз
const tokenModal = ref(null);
async function revokeToken(row) {
try {
await api.revokeMcpToken(row.id);
tokens.value = await api.mcpTokens();
if (isAdmin.value) allTokens.value = await api.adminMcpTokens();
toast.success(t("mcpKeys.toastRevoked"));
} catch (ex) {
toast.error(ex.message);
}
}
// Админ: отзыв чужого (confirm — действие на другого пользователя)
const adminRevokeTarget = ref(null);
async function adminRevoke() {
const row = adminRevokeTarget.value;
adminRevokeTarget.value = null;
try {
await api.adminRevokeMcpToken(row.id);
tokens.value = await api.mcpTokens();
allTokens.value = await api.adminMcpTokens();
toast.success(t("mcpKeys.toastRevoked"));
} catch (ex) {
toast.error(ex.message);
}
}
function roleVariant(role) {
if (role === "superadmin") return "info";
if (role === "admin") return "warning";
return "secondary";
}
onMounted(async () => {
session.value = await getSession().catch(() => null);
await load();
});
</script>
<template>
<main class="container page">
<GnPageHeader
kicker="Synapse"
:title="t('mcpKeys.title')"
:subtitle="t('mcpKeys.subtitle')"
/>
<GnLoader v-if="loading" circle :label="t('mcpKeys.loading')" />
<GnStatusCard v-else-if="error" variant="error" icon="ph-warning-circle" :text="error" />
<template v-else>
<!-- Свои ключи -->
<section class="panel">
<div class="panel-head">
<h3 class="panel-title">{{ t("mcpKeys.myKeys") }}</h3>
</div>
<p class="panel-note">
{{ t("mcpKeys.tokenWord") }} <code>mcp_*</code> {{ t("mcpKeys.myKeysNote") }}
</p>
<div class="issue-row">
<GnInput
v-model="newName"
class="issue-name"
:placeholder="t('mcpKeys.namePlaceholder')"
maxlength="120"
@keyup.enter="createToken"
/>
<GnButton variant="primary" size="sm" icon="ph-key" :loading="busy" @click="createToken">
{{ t("mcpKeys.generate") }}
</GnButton>
</div>
<GnTable
:rows="tokens"
:empty-text="t('mcpKeys.emptyOwn')"
:columns="[
{ key: 'name', label: t('mcpKeys.colKey') },
{ key: 'token_hint', label: t('mcpKeys.colTail') },
{ key: 'system_role', label: t('mcpKeys.colRole') },
{ key: 'created_at', label: th(t('mcpKeys.colCreated'), t('mcpKeys.colCreatedTip')) },
{ key: 'last_used_at', label: th(t('mcpKeys.colUsed'), t('mcpKeys.colUsedTip')) },
{ key: 'revoked_at', label: t('mcpKeys.colStatus') },
{ key: 'actions', label: '' },
]"
>
<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 }">{{ fmtTime(value) }}</template>
<template #cell-last_used_at="{ value }">{{ fmtTime(value) }}</template>
<template #cell-revoked_at="{ value }">
<GnBadge :variant="value ? 'danger' : 'success'" outline>
{{ value ? t("mcpKeys.badgeRevoked") : t("mcpKeys.badgeActive") }}
</GnBadge>
</template>
<template #cell-actions="{ row }">
<GnIconButton
v-if="!row.revoked_at"
icon="ph-x-circle"
size="sm"
:label="t('mcpKeys.revoke')"
@click="revokeToken(row)"
/>
</template>
</GnTable>
</section>
<!-- Как подключить агент -->
<section class="panel">
<h3 class="panel-title">{{ t("mcpKeys.howToConnect") }}</h3>
<p class="panel-note">
{{ t("mcpKeys.connectNoteBefore") }}
<code>/mcp</code> {{ t("mcpKeys.connectNoteAfter") }}
</p>
<pre class="snippet"><code>{{ connectExample }}</code></pre>
<div><GnCopyButton :text="connectExample" :label="t('mcpKeys.copyConfig')" icon="ph-copy" /></div>
</section>
<!-- Админ: все ключи всех пользователей -->
<section v-if="isAdmin" class="panel">
<div class="panel-head">
<h3 class="panel-title">{{ t("mcpKeys.allKeysAdmin") }}</h3>
</div>
<p class="panel-note">{{ t("mcpKeys.allKeysNote") }}</p>
<GnTable
:rows="allTokens"
:empty-text="t('mcpKeys.emptyAll')"
:columns="[
{ key: 'user_email', label: t('mcpKeys.colOwner') },
{ key: 'name', label: t('mcpKeys.colKey') },
{ key: 'token_hint', label: t('mcpKeys.colTail') },
{ key: 'system_role', label: t('mcpKeys.colRole') },
{ key: 'created_at', label: th(t('mcpKeys.colCreated'), t('mcpKeys.colCreatedTip')) },
{ key: 'last_used_at', label: th(t('mcpKeys.colUsed'), t('mcpKeys.colUsedTip')) },
{ key: 'revoked_at', label: t('mcpKeys.colStatus') },
{ key: 'actions', label: '' },
]"
>
<template #cell-user_email="{ row }">
<span :title="row.user_id">{{ row.user_email || row.user_id }}</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 }">{{ fmtTime(value) }}</template>
<template #cell-last_used_at="{ value }">{{ fmtTime(value) }}</template>
<template #cell-revoked_at="{ value }">
<GnBadge :variant="value ? 'danger' : 'success'" outline>
{{ value ? t("mcpKeys.badgeRevoked") : t("mcpKeys.badgeActive") }}
</GnBadge>
</template>
<template #cell-actions="{ row }">
<GnIconButton
v-if="!row.revoked_at"
icon="ph-x-circle"
size="sm"
:label="t('mcpKeys.revoke')"
@click="adminRevokeTarget = row"
/>
</template>
</GnTable>
</section>
</template>
<!-- Токен показывается один раз -->
<GnModal
v-if="tokenModal"
:open="tokenModal !== null" @update:open="(v) => (v ? null : (tokenModal = null))"
:title="t('mcpKeys.modalTitle')"
:close-on-backdrop="false"
>
<GnAlert variant="warning">
<i class="ph ph-warning"></i>
{{ t("mcpKeys.modalAlert") }}
</GnAlert>
<p class="token-wrap"><code>{{ tokenModal.token }}</code></p>
<template #actions>
<GnCopyButton class="token-copy" :text="tokenModal.token" :label="t('mcpKeys.copy')" icon="ph-copy" />
<GnButton variant="primary" @click="tokenModal = null">{{ t("mcpKeys.done") }}</GnButton>
</template>
</GnModal>
<!-- Отзыв чужого ключа (админ) -->
<GnConfirmDialog
v-model:open="adminRevokeTarget"
:title="t('mcpKeys.revokeConfirmTitle')"
:message="t('mcpKeys.revokeConfirmMessage', { name: adminRevokeTarget?.name, owner: adminRevokeTarget?.user_email || adminRevokeTarget?.user_id })"
confirm-variant="danger"
:confirm-text="t('mcpKeys.revoke')"
:cancel-text="t('mcpKeys.cancel')"
@confirm="adminRevoke"
/>
</main>
</template>
<style scoped>
.page {
max-width: 1440px;
margin-inline: auto;
padding-block: var(--gn-space-6);
display: flex;
flex-direction: column;
gap: var(--gn-space-4);
}
.panel {
background: var(--gn-surface-panel);
border: 1px solid var(--gn-border-color-muted);
border-radius: 6px;
padding: var(--gn-space-4) var(--gn-space-5);
display: flex;
flex-direction: column;
gap: var(--gn-space-3);
}
.panel-title {
margin: 0;
text-transform: uppercase;
letter-spacing: 0.04em;
font-size: 0.8em;
}
.panel-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--gn-space-3);
}
.panel-note {
margin: 0;
font-size: 0.82em;
opacity: 0.75;
}
.issue-row {
display: flex;
align-items: center;
gap: var(--gn-space-3);
}
.issue-name {
max-width: 320px;
}
.snippet {
margin: 0;
padding: var(--gn-space-3);
background: var(--gn-surface-panel-muted);
border-radius: 6px;
overflow-x: auto;
font-size: 0.85em;
}
.token-wrap {
word-break: break-all;
background: var(--gn-surface-panel-muted);
padding: var(--gn-space-3);
border-radius: 6px;
overflow-wrap: anywhere;
}
.token-copy {
margin-right: auto;
}
</style>