Files
TREK/client/src/components/Admin/AdminMcpTokensPanel.tsx
T
Maurice 7da3a1724c refactor(admin): share the MCP token and OAuth session admin between desktop and phone
useMcpTokensAdmin now loads both lists, revokes sessions, deletes tokens and keeps the expanded scope chips. Both panels keep their own cards and confirm dialogs.
2026-10-09 15:19:45 +02:00

173 lines
7.7 KiB
TypeScript

import { type ReactNode } from 'react'
import { Key, Trash2, User, Loader2, Shield, KeyRound, Bot } from 'lucide-react'
import { useTranslation } from '../../i18n'
import ConfirmDialog from '../shared/ConfirmDialog'
import { Tooltip } from '../shared/Tooltip'
import { fs } from '../shared/DialogShell'
import { SETTINGS_ICON_BUTTON, SettingRows, SettingsCard, SettingsHint, StatusPill } from '../Settings/settingsKit'
import { SCOPES_PREVIEW, useMcpTokensAdmin } from './useMcpTokensAdmin'
const ROW = 'flex flex-wrap items-center gap-x-4 gap-y-2 px-3.5 py-3'
const TILE = 'grid h-9 w-9 flex-none place-items-center rounded-[10px] bg-surface-tertiary text-content-secondary'
const CHIP = 'inline-flex items-center rounded-full border border-edge-faint bg-surface-secondary px-2 py-[1px] font-geist text-content-muted'
/** The spinner or the empty line a list shows in place of its rows. */
function ListState({ loading, icon, text }: { loading: boolean; icon: ReactNode; text: string }) {
if (loading) {
return (
<div className="flex justify-center py-8">
<Loader2 size={18} className="animate-spin text-content-faint" />
</div>
)
}
return (
<div className="flex flex-col items-center gap-2 py-6 text-content-faint">
{icon}
<SettingsHint>{text}</SettingsHint>
</div>
)
}
/** One fact on the right of a row: an eyebrow over its value. */
function Meta({ label, children }: { label: string; children: ReactNode }) {
return (
<div className="min-w-0 text-end">
<div className="font-geist font-bold uppercase tracking-[.08em] text-content-faint" style={fs(9.5)}>{label}</div>
<div className="whitespace-nowrap font-geist tabular-nums text-content-muted" style={fs(12, 'body')}>{children}</div>
</div>
)
}
/** The owner of a session or token, with the person glyph in front. */
function Owner({ name }: { name: string }) {
return (
<span className="inline-flex min-w-0 max-w-[180px] items-center gap-1.5 text-content-secondary" style={fs(12.5, 'body')}>
<User size={13} className="flex-none text-content-faint" />
<span className="truncate">{name}</span>
</span>
)
}
/** The trash button of a row, named by its tooltip. */
function DeleteAction({ label, onClick }: { label: string; onClick: () => void }) {
return (
<Tooltip label={label} placement="left">
<button type="button" onClick={onClick} aria-label={label} className={`${SETTINGS_ICON_BUTTON} hover:text-danger`}>
<Trash2 size={14} />
</button>
</Tooltip>
)
}
export default function AdminMcpTokensPanel() {
const {
sessions, sessionsLoading, tokens, tokensLoading, expandedScopes, toggleScopes,
revokeConfirmId, setRevokeConfirmId, deleteConfirmId, setDeleteConfirmId, handleRevoke, handleDelete,
} = useMcpTokensAdmin()
const { t, locale } = useTranslation()
const date = (iso: string) => new Date(iso).toLocaleDateString(locale)
return (
<div>
{/* OAuth Sessions. The tab's own subtitle sits in its band: the sidebar
already names the tab, so a bare heading over the cards only repeated it. */}
<SettingsCard
icon={Bot}
title={t('admin.oauthSessions.sectionTitle')}
hint={t('admin.mcpTokens.subtitle')}
badge={!sessionsLoading && sessions.length > 0 ? <StatusPill>{sessions.length}</StatusPill> : undefined}
>
{sessionsLoading || sessions.length === 0 ? (
<ListState loading={sessionsLoading} icon={<Shield size={24} strokeWidth={1.6} />} text={t('admin.oauthSessions.empty')} />
) : (
<SettingRows>
{sessions.map(session => {
const expanded = expandedScopes.has(session.id)
const visible = expanded ? session.scopes : session.scopes.slice(0, SCOPES_PREVIEW)
const hidden = session.scopes.length - SCOPES_PREVIEW
return (
<div key={session.id} className={`${ROW} items-start`}>
<span className={TILE}><Bot size={16} strokeWidth={1.9} /></span>
<div className="min-w-0 flex-1 basis-60">
<p className="m-0 truncate font-semibold text-content" style={fs(13, 'body')}>{session.client_name}</p>
<div className="mt-1.5 flex flex-wrap gap-1" style={fs(11)}>
{visible.map(scope => (
<span key={scope} className={CHIP}>{scope}</span>
))}
{hidden > 0 && (
<button type="button" onClick={() => toggleScopes(session.id)}
className="inline-flex items-center rounded-full bg-surface-tertiary px-2 py-[1px] font-semibold text-content-secondary hover:text-content">
{expanded ? 'show less' : `+${hidden} more`}
</button>
)}
</div>
</div>
<div className="flex flex-none items-center gap-4 pt-0.5">
<Owner name={session.username} />
<Meta label={t('admin.oauthSessions.created')}>{date(session.created_at)}</Meta>
<DeleteAction label={t('common.delete')} onClick={() => setRevokeConfirmId(session.id)} />
</div>
</div>
)
})}
</SettingRows>
)}
</SettingsCard>
{/* MCP Tokens */}
<SettingsCard
icon={KeyRound}
title={t('admin.mcpTokens.sectionTitle')}
badge={!tokensLoading && tokens.length > 0 ? <StatusPill>{tokens.length}</StatusPill> : undefined}
>
{tokensLoading || tokens.length === 0 ? (
<ListState loading={tokensLoading} icon={<Key size={24} strokeWidth={1.6} />} text={t('admin.mcpTokens.empty')} />
) : (
<SettingRows>
{tokens.map(token => (
<div key={token.id} className={ROW}>
<span className={TILE}><KeyRound size={16} strokeWidth={1.9} /></span>
<div className="min-w-0 flex-1 basis-48">
<p className="m-0 truncate font-semibold text-content" style={fs(13, 'body')}>{token.name}</p>
<p className="m-0 mt-0.5 truncate font-geist text-content-faint" style={fs(11)}>{token.token_prefix}...</p>
</div>
<div className="flex flex-none items-center gap-4">
<Owner name={token.username} />
<Meta label={t('admin.mcpTokens.created')}>{date(token.created_at)}</Meta>
<Meta label={t('admin.mcpTokens.lastUsed')}>
{token.last_used_at ? date(token.last_used_at) : t('admin.mcpTokens.never')}
</Meta>
<DeleteAction label={t('common.delete')} onClick={() => setDeleteConfirmId(token.id)} />
</div>
</div>
))}
</SettingRows>
)}
</SettingsCard>
{/* Revoke OAuth session */}
<ConfirmDialog
isOpen={revokeConfirmId !== null}
onClose={() => setRevokeConfirmId(null)}
onConfirm={() => { if (revokeConfirmId !== null) void handleRevoke(revokeConfirmId) }}
title={t('admin.oauthSessions.revokeTitle')}
message={t('admin.oauthSessions.revokeMessage')}
confirmLabel={t('common.delete')}
danger
/>
{/* Delete MCP token */}
<ConfirmDialog
isOpen={deleteConfirmId !== null}
onClose={() => setDeleteConfirmId(null)}
onConfirm={() => { if (deleteConfirmId !== null) void handleDelete(deleteConfirmId) }}
title={t('admin.mcpTokens.deleteTitle')}
message={t('admin.mcpTokens.deleteMessage')}
confirmLabel={t('common.delete')}
danger
/>
</div>
)
}