mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-06-19 13:21:46 +00:00
4b1286d53c
Show active OAuth sessions (first) and static API tokens (second) in the admin MCP Access tab. Admins can revoke any OAuth session, which immediately terminates the live MCP transport for that client. - Add admin-level listOAuthSessions / revokeOAuthSession in adminService - Add GET /admin/oauth-sessions and DELETE /admin/oauth-sessions/:id routes - Restructure AdminMcpTokensPanel into two sections; rename tab to MCP Access - Fix stale writeAudit call in rotate-jwt-secret route (user_id → userId) - Add admin.oauthSessions.* i18n keys across all 14 locale files
230 lines
12 KiB
TypeScript
230 lines
12 KiB
TypeScript
import { useState, useEffect } from 'react'
|
|
import { adminApi } from '../../api/client'
|
|
import { useToast } from '../shared/Toast'
|
|
import { Key, Trash2, User, Loader2, Shield } from 'lucide-react'
|
|
import { useTranslation } from '../../i18n'
|
|
|
|
interface AdminOAuthSession {
|
|
id: number
|
|
client_id: string
|
|
client_name: string
|
|
user_id: number
|
|
username: string
|
|
scopes: string[]
|
|
access_token_expires_at: string
|
|
refresh_token_expires_at: string
|
|
created_at: string
|
|
}
|
|
|
|
interface AdminMcpToken {
|
|
id: number
|
|
name: string
|
|
token_prefix: string
|
|
created_at: string
|
|
last_used_at: string | null
|
|
user_id: number
|
|
username: string
|
|
}
|
|
|
|
export default function AdminMcpTokensPanel() {
|
|
const [sessions, setSessions] = useState<AdminOAuthSession[]>([])
|
|
const [sessionsLoading, setSessionsLoading] = useState(true)
|
|
const [tokens, setTokens] = useState<AdminMcpToken[]>([])
|
|
const [tokensLoading, setTokensLoading] = useState(true)
|
|
const [revokeConfirmId, setRevokeConfirmId] = useState<number | null>(null)
|
|
const [deleteConfirmId, setDeleteConfirmId] = useState<number | null>(null)
|
|
const toast = useToast()
|
|
const { t, locale } = useTranslation()
|
|
|
|
useEffect(() => {
|
|
adminApi.oauthSessions()
|
|
.then(d => setSessions(d.sessions || []))
|
|
.catch(() => toast.error(t('admin.oauthSessions.loadError')))
|
|
.finally(() => setSessionsLoading(false))
|
|
|
|
adminApi.mcpTokens()
|
|
.then(d => setTokens(d.tokens || []))
|
|
.catch(() => toast.error(t('admin.mcpTokens.loadError')))
|
|
.finally(() => setTokensLoading(false))
|
|
}, [])
|
|
|
|
const handleRevoke = async (id: number) => {
|
|
try {
|
|
await adminApi.revokeOAuthSession(id)
|
|
setSessions(prev => prev.filter(s => s.id !== id))
|
|
setRevokeConfirmId(null)
|
|
toast.success(t('admin.oauthSessions.revokeSuccess'))
|
|
} catch {
|
|
toast.error(t('admin.oauthSessions.revokeError'))
|
|
}
|
|
}
|
|
|
|
const handleDelete = async (id: number) => {
|
|
try {
|
|
await adminApi.deleteMcpToken(id)
|
|
setTokens(prev => prev.filter(tk => tk.id !== id))
|
|
setDeleteConfirmId(null)
|
|
toast.success(t('admin.mcpTokens.deleteSuccess'))
|
|
} catch {
|
|
toast.error(t('admin.mcpTokens.deleteError'))
|
|
}
|
|
}
|
|
|
|
return (
|
|
<div className="space-y-6">
|
|
<div>
|
|
<h2 className="text-lg font-semibold" style={{ color: 'var(--text-primary)' }}>{t('admin.mcpTokens.title')}</h2>
|
|
<p className="text-sm mt-0.5" style={{ color: 'var(--text-tertiary)' }}>{t('admin.mcpTokens.subtitle')}</p>
|
|
</div>
|
|
|
|
{/* OAuth Sessions */}
|
|
<div>
|
|
<h3 className="text-sm font-semibold mb-2" style={{ color: 'var(--text-secondary)' }}>{t('admin.oauthSessions.sectionTitle')}</h3>
|
|
<div className="rounded-xl border overflow-hidden" style={{ borderColor: 'var(--border-primary)', background: 'var(--bg-card)' }}>
|
|
{sessionsLoading ? (
|
|
<div className="flex items-center justify-center py-12">
|
|
<Loader2 className="w-5 h-5 animate-spin" style={{ color: 'var(--text-tertiary)' }} />
|
|
</div>
|
|
) : sessions.length === 0 ? (
|
|
<div className="flex flex-col items-center justify-center py-12 gap-2">
|
|
<Shield className="w-8 h-8" style={{ color: 'var(--text-tertiary)' }} />
|
|
<p className="text-sm" style={{ color: 'var(--text-tertiary)' }}>{t('admin.oauthSessions.empty')}</p>
|
|
</div>
|
|
) : (
|
|
<>
|
|
<div className="grid grid-cols-[1fr_auto_auto_auto_auto] gap-x-4 px-4 py-2.5 text-xs font-medium border-b"
|
|
style={{ color: 'var(--text-tertiary)', borderColor: 'var(--border-primary)', background: 'var(--bg-secondary)' }}>
|
|
<span>{t('admin.oauthSessions.clientName')}</span>
|
|
<span>{t('admin.oauthSessions.owner')}</span>
|
|
<span>{t('admin.oauthSessions.scopes')}</span>
|
|
<span className="text-right">{t('admin.oauthSessions.created')}</span>
|
|
<span></span>
|
|
</div>
|
|
{sessions.map((session, i) => (
|
|
<div key={session.id}
|
|
className="grid grid-cols-[1fr_auto_auto_auto_auto] items-center gap-x-4 px-4 py-3"
|
|
style={{ borderBottom: i < sessions.length - 1 ? '1px solid var(--border-primary)' : undefined }}>
|
|
<div className="min-w-0">
|
|
<p className="text-sm font-medium truncate" style={{ color: 'var(--text-primary)' }}>{session.client_name}</p>
|
|
<p className="text-xs font-mono mt-0.5" style={{ color: 'var(--text-tertiary)' }}>{session.client_id}</p>
|
|
</div>
|
|
<div className="flex items-center gap-1.5 text-sm" style={{ color: 'var(--text-secondary)' }}>
|
|
<User className="w-3.5 h-3.5 flex-shrink-0" />
|
|
<span className="whitespace-nowrap">{session.username}</span>
|
|
</div>
|
|
<span className="text-xs whitespace-nowrap" style={{ color: 'var(--text-tertiary)' }}>
|
|
{session.scopes.join(', ')}
|
|
</span>
|
|
<span className="text-xs whitespace-nowrap text-right" style={{ color: 'var(--text-tertiary)' }}>
|
|
{new Date(session.created_at).toLocaleDateString(locale)}
|
|
</span>
|
|
<button onClick={() => setRevokeConfirmId(session.id)}
|
|
className="p-1.5 rounded-lg transition-colors hover:bg-red-50 hover:text-red-600 dark:hover:bg-red-900/20"
|
|
style={{ color: 'var(--text-tertiary)' }} title={t('common.delete')}>
|
|
<Trash2 className="w-4 h-4" />
|
|
</button>
|
|
</div>
|
|
))}
|
|
</>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* MCP Tokens */}
|
|
<div>
|
|
<h3 className="text-sm font-semibold mb-2" style={{ color: 'var(--text-secondary)' }}>{t('admin.mcpTokens.sectionTitle')}</h3>
|
|
<div className="rounded-xl border overflow-hidden" style={{ borderColor: 'var(--border-primary)', background: 'var(--bg-card)' }}>
|
|
{tokensLoading ? (
|
|
<div className="flex items-center justify-center py-12">
|
|
<Loader2 className="w-5 h-5 animate-spin" style={{ color: 'var(--text-tertiary)' }} />
|
|
</div>
|
|
) : tokens.length === 0 ? (
|
|
<div className="flex flex-col items-center justify-center py-12 gap-2">
|
|
<Key className="w-8 h-8" style={{ color: 'var(--text-tertiary)' }} />
|
|
<p className="text-sm" style={{ color: 'var(--text-tertiary)' }}>{t('admin.mcpTokens.empty')}</p>
|
|
</div>
|
|
) : (
|
|
<>
|
|
<div className="grid grid-cols-[1fr_auto_auto_auto_auto] gap-x-4 px-4 py-2.5 text-xs font-medium border-b"
|
|
style={{ color: 'var(--text-tertiary)', borderColor: 'var(--border-primary)', background: 'var(--bg-secondary)' }}>
|
|
<span>{t('admin.mcpTokens.tokenName')}</span>
|
|
<span>{t('admin.mcpTokens.owner')}</span>
|
|
<span className="text-right">{t('admin.mcpTokens.created')}</span>
|
|
<span className="text-right">{t('admin.mcpTokens.lastUsed')}</span>
|
|
<span></span>
|
|
</div>
|
|
{tokens.map((token, i) => (
|
|
<div key={token.id}
|
|
className="grid grid-cols-[1fr_auto_auto_auto_auto] items-center gap-x-4 px-4 py-3"
|
|
style={{ borderBottom: i < tokens.length - 1 ? '1px solid var(--border-primary)' : undefined }}>
|
|
<div className="min-w-0">
|
|
<p className="text-sm font-medium truncate" style={{ color: 'var(--text-primary)' }}>{token.name}</p>
|
|
<p className="text-xs font-mono mt-0.5" style={{ color: 'var(--text-tertiary)' }}>{token.token_prefix}...</p>
|
|
</div>
|
|
<div className="flex items-center gap-1.5 text-sm" style={{ color: 'var(--text-secondary)' }}>
|
|
<User className="w-3.5 h-3.5 flex-shrink-0" />
|
|
<span className="whitespace-nowrap">{token.username}</span>
|
|
</div>
|
|
<span className="text-xs whitespace-nowrap text-right" style={{ color: 'var(--text-tertiary)' }}>
|
|
{new Date(token.created_at).toLocaleDateString(locale)}
|
|
</span>
|
|
<span className="text-xs whitespace-nowrap text-right" style={{ color: 'var(--text-tertiary)' }}>
|
|
{token.last_used_at ? new Date(token.last_used_at).toLocaleDateString(locale) : t('admin.mcpTokens.never')}
|
|
</span>
|
|
<button onClick={() => setDeleteConfirmId(token.id)}
|
|
className="p-1.5 rounded-lg transition-colors hover:bg-red-50 hover:text-red-600 dark:hover:bg-red-900/20"
|
|
style={{ color: 'var(--text-tertiary)' }} title={t('common.delete')}>
|
|
<Trash2 className="w-4 h-4" />
|
|
</button>
|
|
</div>
|
|
))}
|
|
</>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Revoke OAuth session modal */}
|
|
{revokeConfirmId !== null && (
|
|
<div className="fixed inset-0 z-50 flex items-center justify-center p-4" style={{ background: 'rgba(0,0,0,0.5)' }}
|
|
onClick={e => { if (e.target === e.currentTarget) setRevokeConfirmId(null) }}>
|
|
<div className="rounded-xl shadow-xl w-full max-w-sm p-6 space-y-4" style={{ background: 'var(--bg-card)' }}>
|
|
<h3 className="text-base font-semibold" style={{ color: 'var(--text-primary)' }}>{t('admin.oauthSessions.revokeTitle')}</h3>
|
|
<p className="text-sm" style={{ color: 'var(--text-secondary)' }}>{t('admin.oauthSessions.revokeMessage')}</p>
|
|
<div className="flex gap-2 justify-end">
|
|
<button onClick={() => setRevokeConfirmId(null)}
|
|
className="px-4 py-2 rounded-lg text-sm border" style={{ borderColor: 'var(--border-primary)', color: 'var(--text-secondary)' }}>
|
|
{t('common.cancel')}
|
|
</button>
|
|
<button onClick={() => handleRevoke(revokeConfirmId)}
|
|
className="px-4 py-2 rounded-lg text-sm font-medium text-white bg-red-600 hover:bg-red-700">
|
|
{t('common.delete')}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Delete MCP token modal */}
|
|
{deleteConfirmId !== null && (
|
|
<div className="fixed inset-0 z-50 flex items-center justify-center p-4" style={{ background: 'rgba(0,0,0,0.5)' }}
|
|
onClick={e => { if (e.target === e.currentTarget) setDeleteConfirmId(null) }}>
|
|
<div className="rounded-xl shadow-xl w-full max-w-sm p-6 space-y-4" style={{ background: 'var(--bg-card)' }}>
|
|
<h3 className="text-base font-semibold" style={{ color: 'var(--text-primary)' }}>{t('admin.mcpTokens.deleteTitle')}</h3>
|
|
<p className="text-sm" style={{ color: 'var(--text-secondary)' }}>{t('admin.mcpTokens.deleteMessage')}</p>
|
|
<div className="flex gap-2 justify-end">
|
|
<button onClick={() => setDeleteConfirmId(null)}
|
|
className="px-4 py-2 rounded-lg text-sm border" style={{ borderColor: 'var(--border-primary)', color: 'var(--text-secondary)' }}>
|
|
{t('common.cancel')}
|
|
</button>
|
|
<button onClick={() => handleDelete(deleteConfirmId)}
|
|
className="px-4 py-2 rounded-lg text-sm font-medium text-white bg-red-600 hover:bg-red-700">
|
|
{t('common.delete')}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|