Newer
Older
tgclient-mcp / frontend / src / pages / AdminPage.vue
<script setup>
// Админка (role admin/superadmin): пользователи, все аккаунты, все ключи.
// revoke_session — управленческий disconnect клиента (не логаут Telegram).
import { onMounted, ref } from 'vue'
import {
  GnAlert, GnBadge, GnButton, GnCard,
  GnConfirmDialog, GnIconButton, GnLoader, 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 data = ref(null)
const loading = ref(true)
const error = ref('')
const notAllowed = ref(false)
const tab = ref('accounts') // accounts | users | tokens

const users = () => data.value?.users ?? []
const accounts = () => data.value?.accounts ?? []
const tokens = () => data.value?.tokens ?? []
const counters = () => data.value?.counters ?? {}

const usersColumns = computedUsers()
function computedUsers() {
  // computed-колонки: t() внутри module-массива застывает (грабля hard-panel) —
  // собираем в onMounted после загрузки локали
  return null
}

const usersCols = ref([])
const accountsCols = ref([])
const tokensCols = ref([])

function buildColumns() {
  usersCols.value = [
    { key: 'user_id', label: t('admin.colUser') },
    { key: 'email', label: t('admin.colEmail') },
    { key: 'display_name', label: t('admin.colName') },
    { key: 'system_role', label: t('admin.colRole') },
    { key: 'blocked', label: t('admin.colBlocked') },
    { key: 'created_at', label: t('admin.colCreatedAt') },
  ]
  accountsCols.value = [
    { key: 'id', label: 'ID' },
    { key: 'owner_email', label: t('admin.colOwner') },
    { key: 'phone', label: t('admin.colPhone') },
    { key: 'label', label: t('admin.colPendingLabel') },
    { key: 'username', label: t('admin.colName') },
    { key: 'status', label: t('admin.colStatus') },
    { key: 'connected', label: t('admin.colConnected') },
    { key: 'last_used_at', label: t('admin.colLastUsed') },
    { key: 'actions', label: '' },
  ]
  tokensCols.value = [
    { key: 'name', label: t('mcpKeys.colKey') },
    { key: 'user_email', label: t('admin.colOwner') },
    { key: 'token_hint', label: t('admin.colHint') },
    { key: 'system_role', label: t('admin.colRole') },
    { key: 'created_at', label: t('mcpKeys.colCreated') },
    { key: 'last_used_at', label: t('mcpKeys.colUsed') },
    { key: 'revoked_at', label: t('mcpKeys.colStatus') },
  ]
}

function statusVariant(status) {
  if (status === 'active') return 'success'
  if (status === 'logged_out') return 'warning'
  return 'danger'
}

function roleVariant(role) {
  if (role === 'superadmin') return 'info'
  if (role === 'admin') return 'warning'
  return 'secondary'
}

async function load() {
  try {
    data.value = await api.adminOverview()
    error.value = ''
  } catch (e) {
    if (e.status === 403) notAllowed.value = true
    else if (e.status !== 401) error.value = e.message
  } finally {
    loading.value = false
  }
}

onMounted(async () => {
  buildColumns()
  // роль из /me решает, дёргать ли API: не админам 403 без лишнего запроса
  const me = await api.me().catch(() => null)
  if (me?.auth_enabled && me.user && !['admin', 'superadmin'].includes(me.user.role)) {
    notAllowed.value = true
    loading.value = false
    return
  }
  await load()
})

// --- revoke_session -----------------------------------------------------------

const revokeTarget = ref(null)
const revokeOpen = ref(false)

function askRevokeSession(row) {
  revokeTarget.value = row
  revokeOpen.value = true
}

async function doRevokeSession() {
  if (!revokeTarget.value) return
  try {
    await api.adminRevokeSession(revokeTarget.value.id)
    toast.success({ title: t('admin.revokeSessionDone') })
    await load()
  } catch (e) {
    toast.danger({ title: t('common.failed'), text: e.message })
  } finally {
    revokeTarget.value = null
  }
}
</script>

<template>
  <GnPageHeader :title="t('admin.title')" kicker="tgclient" />

  <GnLoader v-if="loading" circle :label="t('common.loading')" />
  <GnAlert v-else-if="notAllowed" variant="warning">{{ t('admin.notAllowed') }}</GnAlert>
  <GnAlert v-else-if="error" variant="danger" style="margin-bottom: 16px">{{ error }}</GnAlert>

  <template v-else>
    <p class="card-note overview-note">
      {{ t('admin.pendingLogins', { n: counters().pending_logins ?? 0, max: counters().max_active_accounts ?? 0 }) }}
    </p>

    <!-- табы: простые кнопки (GnTabs API кита не использую — проще и надёжнее) -->
    <div class="tab-row">
      <GnButton
        size="sm" :variant="tab === 'users' ? 'primary' : 'secondary'"
        icon="ph-users" @click="tab = 'users'"
      >
        {{ t('admin.tabUsers') }}
      </GnButton>
      <GnButton
        size="sm" :variant="tab === 'accounts' ? 'primary' : 'secondary'"
        icon="ph-chat-circle-dots" @click="tab = 'accounts'"
      >
        {{ t('admin.tabAccounts') }}
      </GnButton>
      <GnButton
        size="sm" :variant="tab === 'tokens' ? 'primary' : 'secondary'"
        icon="ph-key" @click="tab = 'tokens'"
      >
        {{ t('admin.tabTokens') }}
      </GnButton>
    </div>

    <!-- Пользователи -->
    <GnCard v-if="tab === 'users'" :title="t('admin.tabUsers')" class="gap-card">
      <GnTable :columns="usersCols" :rows="users()" :empty-text="t('admin.colUser')">
        <template #cell-system_role="{ value }">
          <GnBadge :variant="roleVariant(value)" outline>{{ value || 'user' }}</GnBadge>
        </template>
        <template #cell-blocked="{ value }">{{ value ? t('admin.yes') : t('admin.no') }}</template>
        <template #cell-created_at="{ value }">{{ fmtAgo(value) }}</template>
      </GnTable>
    </GnCard>

    <!-- Аккаунты (все) -->
    <GnCard v-if="tab === 'accounts'" :title="t('admin.tabAccounts')" class="gap-card">
      <GnTable :columns="accountsCols" :rows="accounts()" :empty-text="t('admin.colPhone')">
        <template #cell-status="{ value }">
          <GnBadge :variant="statusVariant(value)" outline>{{ value }}</GnBadge>
        </template>
        <template #cell-connected="{ value }">
          <GnBadge :variant="value ? 'success' : 'secondary'" outline>
            {{ value ? t('accounts.connectedYes') : t('accounts.connectedNo') }}
          </GnBadge>
        </template>
        <template #cell-last_used_at="{ value }">{{ fmtAgo(value) }}</template>
        <template #cell-actions="{ row }">
          <GnIconButton
            v-if="row.connected"
            icon="ph ph-plug-slash"
            size="sm"
            :label="t('admin.revokeSession')"
            @click="askRevokeSession(row)"
          />
        </template>
      </GnTable>
    </GnCard>

    <!-- Все ключи -->
    <GnCard v-if="tab === 'tokens'" :title="t('admin.tabTokens')" class="gap-card">
      <GnTable :columns="tokensCols" :rows="tokens()" :empty-text="t('mcpKeys.emptyAll')">
        <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>
      </GnTable>
    </GnCard>
  </template>

  <GnConfirmDialog
    v-model:open="revokeOpen"
    :title="t('admin.revokeSessionTitle')"
    :message="t('admin.revokeSessionMessage')"
    :confirm-text="t('admin.revokeSession')"
    :cancel-text="t('common.cancel')"
    confirm-variant="danger"
    @confirm="doRevokeSession"
  />
</template>

<style scoped>
/* GnCard по умолчанию — fit-content; страницы тянут карты на всю ширину */
.gap-card {
  max-width: none;
  width: 100%;
  margin-top: 20px;
}
.card-note {
  margin: 0;
  opacity: 0.75;
  font-size: 14px;
}
.overview-note {
  margin-bottom: 12px;
}
.tab-row {
  display: flex;
  gap: 10px;
  flex-wrap: wrap;
  margin-bottom: 16px;
}
</style>