mirror of
https://github.com/tiennm99/ccs.git
synced 2026-10-14 08:18:04 +00:00
feat(codex-auth): add dashboard Auth Profiles tab + GET /api/codex/profiles
Adds a read-only dashboard surface for the codex-auth profile registry. Power users can see which profile is active and its decoded email/plan without leaving the browser; mutations stay CLI-only. - codex-auth-dashboard-service: builds the response shape (active + default + profiles[]), reads each profile's auth.json, decodes id_token via Phase 1 decoder (nested URI claims per C1), 5s in-memory single-key cache plus exported invalidateCodexAuthProfilesCache() hook for in-process Phase 2 callers (D7); strict field whitelist — id_token / access_token / refresh_token NEVER appear in response body or logs - GET /api/codex/profiles registered INSIDE the requireLocalAccessWhenAuthDisabled middleware (H6) — emails are PII and must not leak when dashboard is exposed remotely; integration test asserts 403 from non-localhost origin - NEW Auth Profiles tab (D5) in ui/src/pages/codex.tsx — distinct from the existing codex-profiles-card (which edits config.toml [profiles], a different concept); active profile + email + plan tier highlighted, table of all profiles below, disabled Switch/Remove buttons redirect to terminal commands - accountId returned by API for power users (curl) but hidden from the default UI (D6) - 11 service unit tests + 4 endpoint integration tests, all green
This commit is contained in:
1 parent
8c604a040f
commit
e99c4612a8
7 files changed
+1173
-1
No files matched your search
@@ -0,0 +1,285 @@
|
||||
/**
|
||||
* Read-only dashboard card displaying codex-auth profile state.
|
||||
*
|
||||
* Distinct from codex-profiles-card.tsx (which edits config.toml [profiles]).
|
||||
* This card shows CCS-side shell profiles: active account, email, plan tier,
|
||||
* last-used timestamp, and auth validity.
|
||||
*
|
||||
* All mutating actions (switch, remove) are disabled with a terminal redirect
|
||||
* tooltip per the read-only dashboard spec (D5).
|
||||
*/
|
||||
|
||||
import * as React from 'react';
|
||||
import { Loader2 } from 'lucide-react';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table';
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
|
||||
import { useCodexAuthProfiles } from '@/hooks/use-codex-auth-profiles';
|
||||
import type { CodexAuthProfileEntry } from '@/hooks/use-codex-auth-profiles';
|
||||
|
||||
// ── Helpers ─────────────────────────────────────────────────────────────────
|
||||
|
||||
function formatLastUsed(iso: string | null): string {
|
||||
if (!iso) return 'never';
|
||||
try {
|
||||
const d = new Date(iso);
|
||||
const diffMs = Date.now() - d.getTime();
|
||||
const diffMin = Math.floor(diffMs / 60_000);
|
||||
if (diffMin < 2) return 'just now';
|
||||
if (diffMin < 60) return `${diffMin} min ago`;
|
||||
const diffH = Math.floor(diffMin / 60);
|
||||
if (diffH < 24) return `${diffH}h ago`;
|
||||
const diffD = Math.floor(diffH / 24);
|
||||
if (diffD === 1) return 'yesterday';
|
||||
return `${diffD}d ago`;
|
||||
} catch {
|
||||
return iso;
|
||||
}
|
||||
}
|
||||
|
||||
function sourceLabel(source: 'default' | 'env' | 'explicit-codex-home'): string {
|
||||
switch (source) {
|
||||
case 'default':
|
||||
return 'default';
|
||||
case 'env':
|
||||
return '$CCS_CODEX_PROFILE';
|
||||
case 'explicit-codex-home':
|
||||
return '$CODEX_HOME';
|
||||
}
|
||||
}
|
||||
|
||||
// ── Disabled action button with terminal-redirect tooltip ───────────────────
|
||||
|
||||
function TerminalOnlyButton({ label }: { label: string }) {
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
{/* span wrapper needed — disabled buttons don't trigger mouse events */}
|
||||
<span tabIndex={0} className="inline-block">
|
||||
<Button variant="outline" size="sm" disabled className="pointer-events-none">
|
||||
{label}
|
||||
</Button>
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{/* TODO i18n: missing key codex.auth.terminalOnlyTooltip */}
|
||||
Use <code>ccsx auth switch <name></code> or{' '}
|
||||
<code>ccsx auth remove <name></code> in terminal.
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Profile table row ────────────────────────────────────────────────────────
|
||||
|
||||
function ProfileRow({
|
||||
entry,
|
||||
isActive,
|
||||
activeSource,
|
||||
}: {
|
||||
entry: CodexAuthProfileEntry;
|
||||
isActive: boolean;
|
||||
activeSource?: 'default' | 'env' | 'explicit-codex-home';
|
||||
}) {
|
||||
return (
|
||||
<TableRow className={isActive ? 'bg-muted/40' : undefined}>
|
||||
<TableCell className="font-medium">
|
||||
<span className="flex items-center gap-2">
|
||||
{entry.name}
|
||||
{isActive && activeSource && (
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{/* TODO i18n: missing key codex.auth.activeSourceBadge */}
|
||||
{sourceLabel(activeSource)}
|
||||
</Badge>
|
||||
)}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell>{entry.email ?? '—'}</TableCell>
|
||||
<TableCell>{entry.plan ?? '—'}</TableCell>
|
||||
<TableCell>{formatLastUsed(entry.lastUsed)}</TableCell>
|
||||
<TableCell>
|
||||
{entry.authValid ? (
|
||||
<Badge variant="secondary" className="text-xs text-green-700 dark:text-green-400">
|
||||
{/* TODO i18n: missing key codex.auth.statusOk */}
|
||||
OK
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge variant="destructive" className="text-xs">
|
||||
{/* TODO i18n: missing key codex.auth.statusInvalid */}
|
||||
[!] auth invalid
|
||||
</Badge>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<span className="flex gap-1">
|
||||
<TerminalOnlyButton label="Switch" />
|
||||
<TerminalOnlyButton label="Remove" />
|
||||
</span>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Main card ────────────────────────────────────────────────────────────────
|
||||
|
||||
export function CodexAuthProfilesCard() {
|
||||
const { data, isLoading, error } = useCodexAuthProfiles();
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground p-4">
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
{/* TODO i18n: missing key codex.auth.loading */}
|
||||
Loading auth profiles...
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error || !data) {
|
||||
return (
|
||||
<div className="rounded-md border border-destructive/40 bg-destructive/10 px-4 py-3 text-sm text-destructive">
|
||||
{/* TODO i18n: missing key codex.auth.loadError */}
|
||||
[!] Failed to load codex-auth profiles.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Empty registry — no profiles at all
|
||||
if (data.profiles.length === 0) {
|
||||
return (
|
||||
<div className="rounded-md border bg-muted/30 px-4 py-3 text-sm text-muted-foreground space-y-1">
|
||||
<p>
|
||||
{/* TODO i18n: missing key codex.auth.emptyRegistry */}
|
||||
[i] No codex-auth profiles. Run{' '}
|
||||
<code className="rounded bg-muted px-1">ccsx auth create <name></code> to create
|
||||
one.
|
||||
</p>
|
||||
<p>Codex will use the default ~/.codex location.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Legacy mode — profiles exist but none active
|
||||
if (!data.active) {
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="rounded-md border bg-muted/30 px-4 py-3 text-sm text-muted-foreground">
|
||||
{/* TODO i18n: missing key codex.auth.legacyMode */}
|
||||
[i] No active profile. Using ~/.codex (legacy). Run{' '}
|
||||
<code className="rounded bg-muted px-1">ccsx auth switch <name></code> in terminal
|
||||
to activate one.
|
||||
</div>
|
||||
<ProfileTable data={data} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// External CODEX_HOME with no registry match
|
||||
if (data.active.source === 'explicit-codex-home' && data.active.name === null) {
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="rounded-md border bg-muted/30 px-4 py-3 text-sm text-muted-foreground">
|
||||
{/* TODO i18n: missing key codex.auth.externalCodexHome */}
|
||||
[i] $CODEX_HOME set externally to{' '}
|
||||
<code className="rounded bg-muted px-1">{data.active.codexHome}</code>. Profile registry
|
||||
not in use for this session.
|
||||
</div>
|
||||
<ProfileTable data={data} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<ActiveBanner name={data.active.name} source={data.active.source} profiles={data.profiles} />
|
||||
<ProfileTable data={data} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Active profile highlight banner ─────────────────────────────────────────
|
||||
|
||||
function ActiveBanner({
|
||||
name,
|
||||
source,
|
||||
profiles,
|
||||
}: {
|
||||
name: string | null;
|
||||
source: 'default' | 'env' | 'explicit-codex-home';
|
||||
profiles: CodexAuthProfileEntry[];
|
||||
}) {
|
||||
const activeEntry = profiles.find((p) => p.name === name);
|
||||
|
||||
return (
|
||||
<div className="rounded-md border bg-muted/20 px-4 py-3 text-sm space-y-1">
|
||||
<div className="flex items-center gap-2 font-medium">
|
||||
{/* TODO i18n: missing key codex.auth.activeProfile */}
|
||||
Active profile:
|
||||
<span>{name ?? '(unknown)'}</span>
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{sourceLabel(source)}
|
||||
</Badge>
|
||||
</div>
|
||||
{activeEntry && (
|
||||
<div className="text-muted-foreground text-xs space-x-3">
|
||||
{activeEntry.email && <span>{activeEntry.email}</span>}
|
||||
{activeEntry.plan && (
|
||||
<span>
|
||||
Plan: <strong>{activeEntry.plan}</strong>
|
||||
</span>
|
||||
)}
|
||||
{!activeEntry.authValid && <span className="text-destructive">[!] auth invalid</span>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Profile table ────────────────────────────────────────────────────────────
|
||||
|
||||
function ProfileTable({
|
||||
data,
|
||||
}: {
|
||||
data: {
|
||||
active: { name: string | null; source: 'default' | 'env' | 'explicit-codex-home' } | null;
|
||||
profiles: CodexAuthProfileEntry[];
|
||||
};
|
||||
}) {
|
||||
return (
|
||||
<div className="rounded-md border overflow-auto">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
{/* TODO i18n: missing keys codex.auth.col.name/email/plan/lastUsed/status/actions */}
|
||||
<TableHead>Name</TableHead>
|
||||
<TableHead>Email</TableHead>
|
||||
<TableHead>Plan</TableHead>
|
||||
<TableHead>Last used</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
<TableHead>Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{data.profiles.map((entry) => (
|
||||
<ProfileRow
|
||||
key={entry.name}
|
||||
entry={entry}
|
||||
isActive={data.active?.name === entry.name}
|
||||
activeSource={data.active?.name === entry.name ? data.active.source : undefined}
|
||||
/>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user