Newer
Older
hard-panel / panel / frontend / src / pages / McpKeysPage.vue
<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;
}
/* у китовского GnInput контроль обёрнут в <label class="label">: у form-group
   запас margin-bottom 15px, у вложенного label — ещё 5px (входят в высоту
   flex-item'а: form-group держит собственный BFC). Без зануления кнопка
   при flex-end висит ниже контрола. */
.issue-row :deep(.form-group) {
  margin-bottom: 0;
}
.issue-row :deep(.form-group .label) {
  margin-bottom: 0;
}
.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>