feat(web-ui): Settings → Account (change password) + API Tokens sections
This commit is contained in:
parent
cfe21e315e
commit
2aec4636cb
1 changed files with 134 additions and 1 deletions
|
|
@ -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() {
|
||||
const [section, setSection] = React.useState('storage');
|
||||
const [section, setSection] = React.useState('account');
|
||||
|
||||
const SECTIONS = [
|
||||
{ id: 'account', label: 'Account', icon: 'user' },
|
||||
{ id: 'storage', label: 'Storage', icon: 'hdd' },
|
||||
{ id: 'proxy', label: 'Proxy encoding', icon: 'gpu' },
|
||||
{ id: 'sdk', label: 'Capture SDKs', icon: 'video' },
|
||||
|
|
@ -1435,6 +1562,12 @@ function Settings() {
|
|||
))}
|
||||
</nav>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
|
||||
{section === 'account' && (
|
||||
<>
|
||||
<AccountSection />
|
||||
<ApiTokensSection />
|
||||
</>
|
||||
)}
|
||||
{section === 'storage' && <StorageSection />}
|
||||
{section === 'proxy' && <GpuSettingsCard />}
|
||||
{section === 'sdk' && <SdkSettingsCard />}
|
||||
|
|
|
|||
Loading…
Reference in a new issue