Settings page: themes (Dark, Light, Cyberpunk, Retro) with xterm palettes and hub-synced choice, change password, access tokens, about

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Mitchell-Johnson
2026-09-18 13:14:01 -07:00
co-authored by Claude Fable 5.1
parent 254e0cdd58
commit a4f39859b6
11 changed files with 319 additions and 22 deletions
+107
View File
@@ -0,0 +1,107 @@
import { FormEvent, useCallback, useEffect, useState } from 'react'
import { api, Me } from './api'
import { applyTheme, currentThemeId, THEMES } from './themes'
import { Btn, Loading, toast } from './ui'
const fmtUptime = (s: number) => { const d = Math.floor(s / 86400), h = Math.floor((s % 86400) / 3600), m = Math.floor((s % 3600) / 60); return d > 0 ? `${d}d ${h}h` : h > 0 ? `${h}h ${m}m` : `${m}m` }
// Small painted preview of a theme: rail, header, a tile and a bar, in its own colours.
function Preview({ vars }: { vars: Record<string, string> }) {
const v = (k: string) => vars[k]
return (
<div className="tprev" style={{ background: v('--bg'), borderColor: v('--border') }}>
<div className="tprev-head" style={{ background: v('--bg2'), borderColor: v('--border') }}><span style={{ color: v('--accent') }}>Ham Lab</span><span className="tprev-dot" style={{ background: v('--ok') }} /></div>
<div className="tprev-body">
<div className="tprev-rail" style={{ background: v('--bg2'), borderColor: v('--border') }}>
<span style={{ background: v('--bg3'), boxShadow: `inset 2px 0 0 ${v('--accent')}` }} /><span /><span />
</div>
<div className="tprev-main">
<div className="tprev-tile" style={{ background: v('--bg2'), borderColor: v('--border') }}>
<i style={{ color: v('--muted') }}>CPU</i><b style={{ color: v('--fg') }}>12%</b>
<div className="tprev-bar" style={{ background: v('--track') }}><div style={{ width: '40%', background: v('--s1') }} /></div>
</div>
<div className="tprev-term" style={{ background: v('--term-bg'), color: v('--accent2') }}>$ _</div>
</div>
</div>
</div>
)
}
export default function Settings({ me }: { me: Me }) {
const [theme, setTheme] = useState(currentThemeId())
const [tokens, setTokens] = useState<{ name: string; created: string }[] | null>(null)
const [cur, setCur] = useState('')
const [pw1, setPw1] = useState('')
const [pw2, setPw2] = useState('')
const [pwErr, setPwErr] = useState('')
const loadTokens = useCallback(async () => { try { setTokens(await api.tokens()) } catch { setTokens([]) } }, [])
useEffect(() => { loadTokens() }, [loadTokens])
const pick = async (id: string) => {
applyTheme(id); setTheme(id)
try { await api.saveUIPrefs({ theme: id }) } catch { /* local still applied */ }
}
async function changePassword(e: FormEvent) {
e.preventDefault()
setPwErr('')
if (pw1 !== pw2) { setPwErr('New passwords differ.'); return }
if (pw1.length < 12) { setPwErr('At least 12 characters.'); return }
try { await api.changePassword(cur, pw1); toast('password changed'); setCur(''); setPw1(''); setPw2('') }
catch (err) { setPwErr(err instanceof Error ? err.message : 'failed') }
}
async function revoke(name: string) {
if (!window.confirm(`Revoke the token "${name}"? The hub holding it loses access to this host until it re-adds it.`)) return
try { await api.revokeToken(name); toast(`${name} revoked`); loadTokens() } catch (err) { toast(err instanceof Error ? err.message : 'failed', 'err') }
}
return (
<div className="dash settings">
<div className="dash-bar"><h2 className="section">Appearance</h2><span className="spacer" /><span className="muted small">follows you to every device you sign in on</span></div>
<div className="themes">
{THEMES.map((t) => (
<button key={t.id} className={'theme-card' + (theme === t.id ? ' on' : '')} onClick={() => pick(t.id)}>
<Preview vars={t.vars} />
<div className="theme-name">{t.name}{theme === t.id && <span className="badge">active</span>}</div>
<div className="muted small">{t.blurb}</div>
</button>
))}
</div>
<div className="dash-bar"><h2 className="section">Account · {me.host}</h2></div>
<form className="card addform" onSubmit={changePassword}>
<label>Current password <input type="password" value={cur} onChange={(e) => setCur(e.target.value)} autoComplete="current-password" required /></label>
<label>New password <input type="password" value={pw1} onChange={(e) => setPw1(e.target.value)} autoComplete="new-password" required /></label>
<label>New password again <input type="password" value={pw2} onChange={(e) => setPw2(e.target.value)} autoComplete="new-password" required /></label>
{pwErr && <div className="error">{pwErr}</div>}
<div className="svc-actions"><button className="btn on" disabled={!cur || !pw1 || !pw2}>Change password</button><span className="muted small">Changes the login for this host only. Other machines keep their own password. Existing sessions stay signed in.</span></div>
</form>
<div className="dash-bar"><h2 className="section">Access tokens</h2></div>
<section className="card">
<div className="muted small" style={{ marginBottom: 8 }}>Tokens let another Ham Lab (a hub) manage this host. They're issued when a hub adds this machine and revoked when it removes it.</div>
{tokens === null && <Loading label="Loading…" />}
{tokens && tokens.length === 0 && <div className="empty">No tokens. No hub manages this host.</div>}
{tokens && tokens.map((t) => (
<div className="row" key={t.name}>
<span className="k mono">{t.name}</span>
<span className="muted small">issued {new Date(t.created).toLocaleString()}</span>
<Btn className="btn danger" onClick={() => revoke(t.name)}>Revoke</Btn>
</div>
))}
</section>
<div className="dash-bar"><h2 className="section">About</h2></div>
<section className="card">
<div className="props">
<div><span className="k">Version</span><span className="mono">{me.version}</span></div>
<div><span className="k">Host</span><span>{me.host} · {me.arch}</span></div>
<div><span className="k">Runs as</span><span>{me.user}</span></div>
<div><span className="k">Uptime</span><span>{fmtUptime(me.uptime)}</span></div>
{me.fingerprint && <div><span className="k">Certificate</span><span className="mono small">{me.fingerprint}</span></div>}
</div>
</section>
</div>
)
}