/** * Access tokens card (mcp-ai-spec §6.2.3): * token table (label, prefix, scope, created, last used, revoke), create modal * with show-once secret, legacy WP-CLI token migration nag. */ import React, { useState } from 'react'; import { AlertTriangle, KeyRound, Loader2 } from 'lucide-react'; import type { McpCreatedToken, McpTokenInfo, McpTokenScope } from './types'; import { createToken, revokeToken } from './api'; import { McpCard } from './card'; import { ConfirmDialog, McpModal } from './modal'; import { CopyButton } from './copy-button'; import { Button } from '@/components/ui/button'; import { trackMcpTokenCreated, trackMcpTokenRevoked } from './tracking'; import { __ } from '../../lib/i18n'; function formatTs(ts: number): string { if (!ts) return '—'; return new Date(ts * 1000).toLocaleString(); } export function TokenManager({ tokens, hasLegacyToken, onTokensChanged, onTokenCreated, }: { tokens: McpTokenInfo[]; hasLegacyToken: boolean; /** Refetch tokens + status after any mutation. */ onTokensChanged: () => Promise; /** Hands the plaintext secret up so connect guides can offer "copy with token". */ onTokenCreated: (secret: string) => void; }) { const [createOpen, setCreateOpen] = useState(false); const [revokeTarget, setRevokeTarget] = useState(null); const [legacyDeleteOpen, setLegacyDeleteOpen] = useState(false); const [busy, setBusy] = useState(false); const [error, setError] = useState(null); const handleRevoke = async (id: string, legacy: boolean) => { setBusy(true); setError(null); try { await revokeToken(id); trackMcpTokenRevoked(legacy); await onTokensChanged(); setRevokeTarget(null); setLegacyDeleteOpen(false); } catch (e) { setError( e instanceof Error ? e.message : __('Failed to revoke token.', 'llmagnet-llm-txt-generator'), ); } finally { setBusy(false); } }; return ( setCreateOpen(true)}> {__('Create token', 'llmagnet-llm-txt-generator')} } > {hasLegacyToken && (

{__('Legacy token (set via WP-CLI) is active', 'llmagnet-llm-txt-generator')}

{__( 'It has full access and is stored in plaintext. Replace it with a managed token: create a named token above, update your clients, then delete the legacy one.', 'llmagnet-llm-txt-generator', )}

)} {error && (

{error}

)} {tokens.length === 0 && !hasLegacyToken ? (

{__('No tokens yet. Create one to connect an AI client.', 'llmagnet-llm-txt-generator')}

) : (
{tokens.map((token) => ( ))}
{__('Label', 'llmagnet-llm-txt-generator')} {__('Token', 'llmagnet-llm-txt-generator')} {__('Scope', 'llmagnet-llm-txt-generator')} {__('Created', 'llmagnet-llm-txt-generator')} {__('Last used', 'llmagnet-llm-txt-generator')} {__('Actions', 'llmagnet-llm-txt-generator')}
{token.label} {token.prefix}… {formatTs(token.created_at)} {token.last_used ? formatTs(token.last_used) : __('Never', 'llmagnet-llm-txt-generator')}
)} setCreateOpen(false)} onCreated={async (created) => { trackMcpTokenCreated(created.scope); onTokenCreated(created.secret); await onTokensChanged(); }} /> {__('Clients using', 'llmagnet-llm-txt-generator')}{' '} {revokeTarget.label} ( {revokeTarget.prefix}…){' '} {__( 'will immediately lose access. This cannot be undone.', 'llmagnet-llm-txt-generator', )}

) : null } confirmLabel={__('Revoke token', 'llmagnet-llm-txt-generator')} destructive busy={busy} onConfirm={() => revokeTarget && void handleRevoke(revokeTarget.id, false)} onCancel={() => setRevokeTarget(null)} /> {__( 'The WP-CLI legacy token will stop working immediately. Make sure every client has been switched to a managed token first.', 'llmagnet-llm-txt-generator', )}

} confirmLabel={__('Delete legacy token', 'llmagnet-llm-txt-generator')} destructive busy={busy} onConfirm={() => void handleRevoke('legacy', true)} onCancel={() => setLegacyDeleteOpen(false)} />
); } function ScopeChip({ scope }: { scope: McpTokenScope }) { return ( {scope === 'write' ? __('Read-write', 'llmagnet-llm-txt-generator') : __('Read', 'llmagnet-llm-txt-generator')} ); } /** * Create flow: label + scope → POST → secret shown ONCE with copy button and * an explicit "I've saved my token" confirmation before the modal can close. */ function CreateTokenModal({ open, onClose, onCreated, }: { open: boolean; onClose: () => void; onCreated: (created: McpCreatedToken) => Promise; }) { const [label, setLabel] = useState(''); const [scope, setScope] = useState('read'); const [submitting, setSubmitting] = useState(false); const [error, setError] = useState(null); const [created, setCreated] = useState(null); const reset = () => { setLabel(''); setScope('read'); setError(null); setCreated(null); setSubmitting(false); }; const handleClose = () => { reset(); onClose(); }; const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); if (!label.trim()) { setError(__('Please enter a label (e.g. "Claude Desktop").', 'llmagnet-llm-txt-generator')); return; } setSubmitting(true); setError(null); try { const result = await createToken(label.trim(), scope); setCreated(result); await onCreated(result); } catch (err) { setError( err instanceof Error ? err.message : __('Failed to create token.', 'llmagnet-llm-txt-generator'), ); } finally { setSubmitting(false); } }; return ( {created ? (

{__('Copy your new token now —', 'llmagnet-llm-txt-generator')}{' '} {__("you won't be able to see it again.", 'llmagnet-llm-txt-generator')}

e.currentTarget.select()} data-autofocus className="flex-1 min-w-0 rounded-md border border-gray-300 bg-gray-50 px-3 py-2 text-xs font-mono text-gray-800 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#9333ea]" />

{created.label} ·{' '} {created.scope === 'write' ? __('Read-write', 'llmagnet-llm-txt-generator') : __('Read', 'llmagnet-llm-txt-generator')}{' '} {__('scope · stored hashed on the server.', 'llmagnet-llm-txt-generator')}

) : (
setLabel(e.target.value)} placeholder={__('e.g. Claude Desktop', 'llmagnet-llm-txt-generator')} maxLength={80} data-autofocus className="mt-1 w-full rounded-md border border-gray-300 px-3 py-2 text-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#9333ea]" />
{__('Scope', 'llmagnet-llm-txt-generator')}
{error && (

{error}

)}
)}
); }