<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>