/** * Settings → API Keys panel (Wave 2 AUTH-03 UI half). * * Consumes the Wave 1 resolver state endpoint at * GET /api/settings/hf-token/state * POST /api/settings/hf-token * DELETE /api/settings/hf-token?also_clear_hf_cli={bool} * * Renders one row per source (App / Env / HF CLI) with set/unset indicator, * masked token preview, whoami username + green check on success, and an * "Active" badge on whichever row is currently serving the cascade. * * Threat T-02-02: the panel never displays the full token. The masked * value comes from the resolver state endpoint; the full token only * crosses the IPC boundary on Save (POST) and is cleared from local * state on success. * * Note: the GET + POST go through `apiJson` / `apiPost` from * `../../api/client` (the canonical base-URL site). The DELETE uses raw * fetch with the same `API` base so query params can be appended cleanly. */ import React, { useCallback, useEffect, useState } from 'react'; import { CheckCircle2, KeyRound, RefreshCw, Save, Trash2, XCircle } from 'lucide-react'; import { apiJson, apiPost, API } from '../../api/client'; import './ApiKeysPanel.css'; const SOURCE_LABELS = { app: 'OmniVoice (encrypted, recommended)', env: 'Environment variable', 'hf-cli': 'HuggingFace CLI', }; const SOURCE_HELP = { app: 'Stored encrypted in OmniVoice\'s local SQLite store. Set or clear here.', env: 'Set via HF_TOKEN in your shell. Read-only from the UI.', 'hf-cli': 'Written by `huggingface-cli login`. Read-only from the UI.', }; const EMPTY_STATE = { sources: [ { source: 'app', set: false, masked: null, whoami_user: null, whoami_ok: false }, { source: 'env', set: false, masked: null, whoami_user: null, whoami_ok: false }, { source: 'hf-cli', set: false, masked: null, whoami_user: null, whoami_ok: false }, ], active: null, }; export default function ApiKeysPanel() { const [state, setState] = useState(EMPTY_STATE); const [loading, setLoading] = useState(false); const [tokenInput, setTokenInput] = useState(''); const [saving, setSaving] = useState(false); const [clearOpen, setClearOpen] = useState(false); const [alsoClearCli, setAlsoClearCli] = useState(false); const [error, setError] = useState(null); const refresh = useCallback(async () => { setLoading(true); setError(null); try { const data = await apiJson('/api/settings/hf-token/state'); setState(data); } catch (e) { setError(e?.message || 'Failed to load token state'); } finally { setLoading(false); } }, []); useEffect(() => { refresh(); }, [refresh]); const onSave = async () => { const token = tokenInput.trim(); if (!token) return; setSaving(true); setError(null); try { await apiPost('/api/settings/hf-token', { token }); setTokenInput(''); await refresh(); } catch (e) { setError(e?.message || 'Failed to save token'); } finally { setSaving(false); } }; const onClear = async () => { setSaving(true); setError(null); try { const qs = alsoClearCli ? '?also_clear_hf_cli=true' : ''; const url = `${API}/api/settings/hf-token${qs}`; const res = await fetch(url, { method: 'DELETE' }); if (!res.ok) throw new Error(`HTTP ${res.status}`); setClearOpen(false); setAlsoClearCli(false); await refresh(); } catch (e) { setError(e?.message || 'Failed to clear token'); } finally { setSaving(false); } }; return (

HuggingFace token

OmniVoice walks three sources in priority order (App → Env → HF CLI). The first source with a token that survives a live whoami check is the Active source.

{error && (
{error}
)}
{state.sources.map((row) => { const isActive = state.active === row.source; return (
{SOURCE_LABELS[row.source]} {isActive && ( Active )}
{row.set ? ( <> set {row.masked && ( {row.masked} )} {row.whoami_ok ? ( {row.whoami_user || 'verified'} ) : ( whoami failed )} ) : ( not set )}

{SOURCE_HELP[row.source]}

{row.source === 'app' && (
setTokenInput(e.target.value)} onKeyDown={(e) => { if (e.key === 'Enter') onSave(); }} autoComplete="off" spellCheck={false} /> {row.set && ( )}
)}
); })}
{clearOpen && (

Clear the App-source HuggingFace token?

)}
); }