Newer
Older
gn-synapse / frontend / src / views / McpKeysView.vue
<!-- 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";

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() || "ключ" });
    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("Ключ отозван");
  } 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("Ключ отозван");
  } 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="MCP-ключи"
      subtitle="Личные токены доступа к MCP-серверу Synapse: агент с ключом действует вашей ролью"
    />

    <GnLoader v-if="loading" circle label="Загрузка…" />
    <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">Мои ключи</h3>
        </div>
        <p class="panel-note">
          Токен <code>mcp_*</code> показывается один раз при выпуске — в Synapse хранится только
          sha256-хэш. Действует ролью владельца на момент выпуска: после смены роли выпустите ключ
          заново, а старый отзовите. Одновременно активно до 10 ключей.
        </p>
        <div class="issue-row">
          <GnInput
            v-model="newName"
            class="issue-name"
            placeholder="Имя ключа: «агент Navi rei»"
            maxlength="120"
            @keyup.enter="createToken"
          />
          <GnButton variant="primary" size="sm" icon="ph-key" :loading="busy" @click="createToken">
            Сгенерировать ключ
          </GnButton>
        </div>
        <GnTable
          :rows="tokens"
          empty-text="Ключей нет — сгенерируйте первый и отдайте его своему ИИ-агенту"
          :columns="[
            { key: 'name', label: 'Ключ' },
            { key: 'token_hint', label: 'Хвост' },
            { key: 'system_role', label: 'Роль' },
            { key: 'created_at', label: th('Создан', 'Момент выпуска ключа') },
            { key: 'last_used_at', label: th('Использован', 'Последний MCP-запрос с этим ключом') },
            { key: 'revoked_at', label: 'Статус' },
            { 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 ? "отозван" : "активен" }}
            </GnBadge>
          </template>
          <template #cell-actions="{ row }">
            <GnIconButton
              v-if="!row.revoked_at"
              icon="ph-x-circle"
              size="sm"
              label="Отозвать"
              @click="revokeToken(row)"
            />
          </template>
        </GnTable>
      </section>

      <!-- Как подключить агент -->
      <section class="panel">
        <h3 class="panel-title">Как подключить агент</h3>
        <p class="panel-note">
          Клиенту MCP (Claude Code, IDE и др.) нужен URL эндпоинта
          <code>/mcp</code> этого Synapse и Bearer-ключ из выпуска: тот, кто
          получил ключ, действует ролью владельца (админ-тулы — только ролей admin+).
        </p>
        <pre class="snippet"><code>{{ connectExample }}</code></pre>
        <div><GnCopyButton :text="connectExample" label="Скопировать конфиг" icon="ph-copy" /></div>
      </section>

      <!-- Админ: все ключи всех пользователей -->
      <section v-if="isAdmin" class="panel">
        <div class="panel-head">
          <h3 class="panel-title">Все ключи (админ)</h3>
        </div>
        <p class="panel-note">
          Выпуск — самопользовательский (каждый на своей странице /mcp-keys); здесь — обзор всех
          и отзыв, например после смены роли пользователя на gnexus-auth.
        </p>
        <GnTable
          :rows="allTokens"
          empty-text="Персональных ключей пока нет — они появятся, когда пользователи выпустят себе их"
          :columns="[
            { key: 'user_email', label: 'Владелец' },
            { key: 'name', label: 'Ключ' },
            { key: 'token_hint', label: 'Хвост' },
            { key: 'system_role', label: 'Роль' },
            { key: 'created_at', label: th('Создан', 'Момент выпуска ключа') },
            { key: 'last_used_at', label: th('Использован', 'Последний MCP-запрос с этим ключом') },
            { key: 'revoked_at', label: 'Статус' },
            { 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 ? "отозван" : "активен" }}
            </GnBadge>
          </template>
          <template #cell-actions="{ row }">
            <GnIconButton
              v-if="!row.revoked_at"
              icon="ph-x-circle"
              size="sm"
              label="Отозвать"
              @click="adminRevokeTarget = row"
            />
          </template>
        </GnTable>
      </section>
    </template>

    <!-- Токен показывается один раз -->
    <GnModal
      v-if="tokenModal"
      :open="tokenModal !== null" @update:open="(v) => (v ? null : (tokenModal = null))"
      title="Токен — показывается один раз"
      :close-on-backdrop="false"
    >
      <GnAlert variant="warning">
        <i class="ph ph-warning"></i>
        Настройте агент сейчас: в Synapse хранится только sha256-хэш, токен больше не будет показан.
      </GnAlert>
      <p class="token-wrap"><code>{{ tokenModal.token }}</code></p>
      <template #actions>
        <GnCopyButton class="token-copy" :text="tokenModal.token" label="Скопировать" icon="ph-copy" />
        <GnButton variant="primary" @click="tokenModal = null">Готово</GnButton>
      </template>
    </GnModal>

    <!-- Отзыв чужого ключа (админ) -->
    <GnConfirmDialog
      v-model:open="adminRevokeTarget"
      title="Отозвать чужой ключ?"
      :message="`Ключ «${adminRevokeTarget?.name}» (${adminRevokeTarget?.user_email || adminRevokeTarget?.user_id}) перестанет работать у его агента. Пользователь выпустит себе новый по необходимости.`"
      confirm-variant="danger"
      confirm-text="Отозвать"
      cancel-text="Отмена"
      @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>