Lê Phi Nam
Deploy to HF Space
94004a2
Raw
History Blame Contribute Delete
8.88 kB
/**
* 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 (
<section className="apikeys-panel" aria-labelledby="apikeys-heading">
<h3 id="apikeys-heading" className="apikeys-panel__title">
<KeyRound size={14} /> HuggingFace token
</h3>
<p className="apikeys-panel__intro">
OmniVoice walks three sources in priority order (App → Env → HF CLI).
The first source with a token that survives a live <code>whoami</code> check
is the <strong>Active</strong> source.
</p>
{error && (
<div className="apikeys-panel__error" role="alert">
{error}
</div>
)}
<div className="apikeys-rows" role="table" aria-label="HF token sources">
{state.sources.map((row) => {
const isActive = state.active === row.source;
return (
<div
key={row.source}
className={`apikeys-row ${isActive ? 'apikeys-row--active' : ''}`}
role="row"
data-source={row.source}
>
<div className="apikeys-row__head">
<span className="apikeys-row__name">{SOURCE_LABELS[row.source]}</span>
{isActive && (
<span className="apikeys-badge apikeys-badge--active">Active</span>
)}
</div>
<div className="apikeys-row__meta">
{row.set ? (
<>
<span className="apikeys-row__set" aria-label="set">
<CheckCircle2 size={12} /> set
</span>
{row.masked && (
<code className="apikeys-row__masked">{row.masked}</code>
)}
{row.whoami_ok ? (
<span className="apikeys-row__whoami apikeys-row__whoami--ok">
<CheckCircle2 size={12} /> {row.whoami_user || 'verified'}
</span>
) : (
<span className="apikeys-row__whoami apikeys-row__whoami--bad">
<XCircle size={12} /> whoami failed
</span>
)}
</>
) : (
<span className="apikeys-row__unset">
<XCircle size={12} /> not set
</span>
)}
</div>
<p className="apikeys-row__help">{SOURCE_HELP[row.source]}</p>
{row.source === 'app' && (
<div className="apikeys-row__actions">
<input
type="password"
className="apikeys-input"
placeholder="hf_…"
aria-label="HuggingFace token"
value={tokenInput}
onChange={(e) => setTokenInput(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') onSave();
}}
autoComplete="off"
spellCheck={false}
/>
<button
type="button"
className="apikeys-btn apikeys-btn--save"
onClick={onSave}
disabled={!tokenInput.trim() || saving}
>
<Save size={12} /> Save
</button>
{row.set && (
<button
type="button"
className="apikeys-btn apikeys-btn--danger"
onClick={() => setClearOpen(true)}
disabled={saving}
>
<Trash2 size={12} /> Clear
</button>
)}
</div>
)}
</div>
);
})}
</div>
<div className="apikeys-panel__footer">
<button
type="button"
className="apikeys-btn apikeys-btn--ghost"
onClick={refresh}
disabled={loading}
aria-label="Test now"
title="Re-run whoami for every source"
>
<RefreshCw size={12} /> Test now
</button>
</div>
{clearOpen && (
<div className="apikeys-clear-dialog" role="dialog" aria-label="Clear token">
<p>Clear the App-source HuggingFace token?</p>
<label className="apikeys-checkbox">
<input
type="checkbox"
checked={alsoClearCli}
onChange={(e) => setAlsoClearCli(e.target.checked)}
/>{' '}
Also clear <code>~/.cache/huggingface/token</code>
</label>
<div className="apikeys-clear-dialog__actions">
<button
type="button"
className="apikeys-btn apikeys-btn--ghost"
onClick={() => {
setClearOpen(false);
setAlsoClearCli(false);
}}
>
Cancel
</button>
<button
type="button"
className="apikeys-btn apikeys-btn--danger"
onClick={onClear}
disabled={saving}
>
<Trash2 size={12} /> Clear token
</button>
</div>
</div>
)}
</section>
);
}