feat(web-ui): Settings → Account (change password) + API Tokens sections

This commit is contained in:
Zac Gaetano 2026-05-27 15:20:57 -04:00
parent cfe21e315e
commit 2aec4636cb

View file

@ -1406,10 +1406,137 @@ function DetailRow({ k, v, mono }) {
); );
} }
function AccountSection() {
const [current, setCurrent] = React.useState('');
const [next, setNext] = React.useState('');
const [confirm, setConfirm] = React.useState('');
const [msg, setMsg] = React.useState(null); // { kind: 'ok'|'err', text }
const [busy, setBusy] = React.useState(false);
const submit = async () => {
setMsg(null);
if (next !== confirm) { setMsg({ kind: 'err', text: 'Passwords do not match' }); return; }
if (next.length < 12) { setMsg({ kind: 'err', text: 'New password must be at least 12 characters' }); return; }
setBusy(true);
try {
const r = await fetch('/api/v1/auth/password', {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json', 'X-Requested-With': 'dragonflight-ui' },
body: JSON.stringify({ current_password: current, new_password: next }),
});
if (r.status === 204) {
setMsg({ kind: 'ok', text: 'Password updated' });
setCurrent(''); setNext(''); setConfirm('');
} else {
const body = await r.json().catch(() => ({}));
setMsg({ kind: 'err', text: body.error || 'Failed (' + r.status + ')' });
}
} finally { setBusy(false); }
};
return (
<section className="panel" style={{ padding: 16, marginBottom: 16 }}>
<h3 style={{ fontSize: 10.5, fontWeight: 600, color: 'var(--text-3)', letterSpacing: '0.08em', textTransform: 'uppercase', marginBottom: 12 }}>Account</h3>
<div style={{ display: 'grid', gridTemplateColumns: '160px 1fr', gap: 10, alignItems: 'center', maxWidth: 480 }}>
<label>Current password</label>
<input type="password" autoComplete="current-password" className="field-input" value={current} onChange={e => setCurrent(e.target.value)} />
<label>New password</label>
<input type="password" autoComplete="new-password" className="field-input" value={next} onChange={e => setNext(e.target.value)} />
<label>Confirm new password</label>
<input type="password" autoComplete="new-password" className="field-input" value={confirm} onChange={e => setConfirm(e.target.value)} />
</div>
{msg && (
<div style={{ marginTop: 10, fontSize: 11.5, color: msg.kind === 'ok' ? 'var(--success)' : 'var(--danger)' }}>{msg.text}</div>
)}
<button className="btn primary sm" style={{ marginTop: 12 }} disabled={busy || !current || !next || !confirm} onClick={submit}>
Change password
</button>
</section>
);
}
function ApiTokensSection() {
const [tokens, setTokens] = React.useState([]);
const [name, setName] = React.useState('');
const [justCreated, setJustCreated] = React.useState(null); // { token, prefix, name }
const [busy, setBusy] = React.useState(false);
const load = React.useCallback(async () => {
const r = await fetch('/api/v1/auth/tokens', { credentials: 'include' });
if (r.status === 200) setTokens(await r.json());
}, []);
React.useEffect(() => { load(); }, [load]);
const create = async () => {
if (!name.trim()) return;
setBusy(true);
try {
const r = await fetch('/api/v1/auth/tokens', {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json', 'X-Requested-With': 'dragonflight-ui' },
body: JSON.stringify({ name: name.trim() }),
});
if (r.status === 201) {
const created = await r.json();
setJustCreated(created);
setName('');
await load();
}
} finally { setBusy(false); }
};
const revoke = async (id) => {
await fetch('/api/v1/auth/tokens/' + id, {
method: 'DELETE',
credentials: 'include',
headers: { 'X-Requested-With': 'dragonflight-ui' },
});
await load();
};
return (
<section className="panel" style={{ padding: 16, marginBottom: 16 }}>
<h3 style={{ fontSize: 10.5, fontWeight: 600, color: 'var(--text-3)', letterSpacing: '0.08em', textTransform: 'uppercase', marginBottom: 12 }}>API Tokens</h3>
{justCreated && (
<div style={{ marginBottom: 12, padding: 10, border: '1px solid var(--accent)', background: 'var(--accent-soft)', borderRadius: 6 }}>
<div style={{ fontSize: 11, fontWeight: 600, color: 'var(--accent-text)', marginBottom: 6 }}>
Save this token now it will not be shown again
</div>
<div style={{ fontFamily: 'var(--font-mono)', fontSize: 12, color: 'var(--text-1)', wordBreak: 'break-all', marginBottom: 6 }}>{justCreated.token}</div>
<button className="btn sm" onClick={() => navigator.clipboard.writeText(justCreated.token)}>Copy</button>
<button className="btn sm" style={{ marginLeft: 8 }} onClick={() => setJustCreated(null)}>Dismiss</button>
</div>
)}
<div style={{ display: 'flex', gap: 8, marginBottom: 12 }}>
<input className="field-input" placeholder="Token name (e.g. Premiere panel)" value={name} onChange={e => setName(e.target.value)} style={{ flex: 1 }} />
<button className="btn primary sm" disabled={busy || !name.trim()} onClick={create}>New token</button>
</div>
<div className="token-list">
{tokens.length === 0 && <div style={{ fontSize: 11.5, color: 'var(--text-3)' }}>No tokens yet.</div>}
{tokens.map(t => (
<div key={t.id} className="token-row" style={{ display: 'grid', gridTemplateColumns: '1fr 120px 140px 80px', gap: 10, alignItems: 'center', padding: '8px 0', borderBottom: '1px solid var(--border)' }}>
<div>{t.name}</div>
<div style={{ fontFamily: 'var(--font-mono)', fontSize: 11, color: 'var(--text-2)' }}>{t.prefix}</div>
<div style={{ fontSize: 11, color: 'var(--text-3)' }}>{t.last_used_at ? new Date(t.last_used_at).toLocaleString() : 'never used'}</div>
<button className="btn sm" onClick={() => revoke(t.id)}>Revoke</button>
</div>
))}
</div>
</section>
);
}
function Settings() { function Settings() {
const [section, setSection] = React.useState('storage'); const [section, setSection] = React.useState('account');
const SECTIONS = [ const SECTIONS = [
{ id: 'account', label: 'Account', icon: 'user' },
{ id: 'storage', label: 'Storage', icon: 'hdd' }, { id: 'storage', label: 'Storage', icon: 'hdd' },
{ id: 'proxy', label: 'Proxy encoding', icon: 'gpu' }, { id: 'proxy', label: 'Proxy encoding', icon: 'gpu' },
{ id: 'sdk', label: 'Capture SDKs', icon: 'video' }, { id: 'sdk', label: 'Capture SDKs', icon: 'video' },
@ -1435,6 +1562,12 @@ function Settings() {
))} ))}
</nav> </nav>
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}> <div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
{section === 'account' && (
<>
<AccountSection />
<ApiTokensSection />
</>
)}
{section === 'storage' && <StorageSection />} {section === 'storage' && <StorageSection />}
{section === 'proxy' && <GpuSettingsCard />} {section === 'proxy' && <GpuSettingsCard />}
{section === 'sdk' && <SdkSettingsCard />} {section === 'sdk' && <SdkSettingsCard />}