mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-06-19 13:21:46 +00:00
4670d4914c
Show first 6 scope badges per session with a clickable "+N more" pill that expands to all scopes; a "show less" pill collapses them again. Also fix column alignment to items-start so Owner/Created stay at the top of tall rows.
262 lines
13 KiB
TypeScript
262 lines
13 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
|
|
}
|
|
|
|
const SCOPES_PREVIEW = 6
|
|
|
|
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 [expandedScopes, setExpandedScopes] = useState<Set<number>>(new Set())
|
|
const [revokeConfirmId, setRevokeConfirmId] = useState<number | null>(null)
|
|
const [deleteConfirmId, setDeleteConfirmId] = useState<number | null>(null)
|
|
|
|
const toggleScopes = (id: number) =>
|
|
setExpandedScopes(prev => {
|
|
const next = new Set(prev)
|
|
next.has(id) ? next.delete(id) : next.add(id)
|
|
return next
|
|
})
|
|
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] gap-x-6 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 className="text-right">{t('admin.oauthSessions.created')}</span>
|
|
<span></span>
|
|
</div>
|
|
{sessions.map((session, i) => {
|
|
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="grid grid-cols-[1fr_auto_auto_auto] items-start gap-x-6 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>
|
|
<div className="flex flex-wrap gap-1 mt-1.5">
|
|
{visible.map(scope => (
|
|
<span key={scope} className="inline-flex items-center px-1.5 py-0.5 rounded text-xs font-mono"
|
|
style={{ background: 'var(--bg-secondary)', color: 'var(--text-tertiary)', border: '1px solid var(--border-primary)' }}>
|
|
{scope}
|
|
</span>
|
|
))}
|
|
{!expanded && hidden > 0 && (
|
|
<button onClick={() => toggleScopes(session.id)}
|
|
className="inline-flex items-center px-1.5 py-0.5 rounded text-xs font-medium transition-colors hover:opacity-80"
|
|
style={{ background: 'var(--bg-secondary)', color: 'var(--text-secondary)', border: '1px solid var(--border-primary)' }}>
|
|
+{hidden} more
|
|
</button>
|
|
)}
|
|
{expanded && hidden > 0 && (
|
|
<button onClick={() => toggleScopes(session.id)}
|
|
className="inline-flex items-center px-1.5 py-0.5 rounded text-xs font-medium transition-colors hover:opacity-80"
|
|
style={{ background: 'var(--bg-secondary)', color: 'var(--text-secondary)', border: '1px solid var(--border-primary)' }}>
|
|
show less
|
|
</button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
<div className="flex items-center gap-1.5 text-sm pt-0.5" 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 text-right pt-0.5" 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>
|
|
)
|
|
}
|