diff --git a/PLAN.md b/PLAN.md index bd4164d..8e5819b 100644 --- a/PLAN.md +++ b/PLAN.md @@ -297,14 +297,16 @@ something long in one, switch away and back, refresh — it's all still there. `install.sh` (the Gitea repo is private, so the one-liner needs `HAMLAB_TOKEN`). - **Users, sessions and settings that follow the account** — **shipped 2026-09-22** (`internal/api/users.go`, `config.User`, `store.Session`/`UserState`, `Login.tsx`, - `Settings.tsx`). The login is a user name and a password. `config.json` holds `users` + `Users.tsx` — a hub-only **Users** tab in the rail, added the same day at Mitchell's + request, holds everything about accounts: your own password, your sessions, the + accounts; Settings keeps appearance, rates, tokens and About). The login is a user name and a password. `config.json` holds `users` (name, argon2id hash, created, last seen); the old single `password_hash` becomes the account named after the OS user the console runs as on first boot, its signed-in sessions become that account's, and the machine-wide theme/accents/dashboard become theirs — nothing to redo after the upgrade. A session carries its user, an ID, when it - was made and last seen, the IP and the browser; **Settings → Sessions** lists yours + was made and last seen, the IP and the browser; **Users → Sessions** lists yours ("Safari on iPhone · 10.0.0.5 · seen 2 min ago"), revokes one, or signs out everywhere - else. **Settings → Users** lists accounts with their session counts, adds one, removes + else. **Users → Users** lists accounts with their session counts, adds one, removes one (their sessions and settings go too; never yourself, never the last), and resets another's password (they are signed out); removing and resetting take your own password first. Everyone signed in can do everything — a household console, not a diff --git a/README.md b/README.md index 5e314e0..ec49949 100644 --- a/README.md +++ b/README.md @@ -46,11 +46,11 @@ Re-running it upgrades in place and keeps config, user and accounts. `hamlab uni [--purge]` reverses it. Accounts: every person has their own user name and password, and the theme, accent -colours and dashboard layout follow the account. Manage them under **Settings → Users** -(add, remove, reset a password; your own password confirms the last two) or from the -shell: `hamlab passwd [user]` sets a password and makes the account if it is new, -`hamlab users` lists them, `hamlab user-rm ` removes one. **Settings → Sessions** -shows where you are signed in and signs out any device. Machine settings — refresh rate, +colours and dashboard layout follow the account. The **Users** tab has it all: your own +password, where you are signed in (sign out any device), and the accounts (add, remove, +reset a password; your own password confirms the last two). From the shell: +`hamlab passwd [user]` sets a password and makes the account if it is new, `hamlab +users` lists them, `hamlab user-rm ` removes one. Machine settings — refresh rate, graph history, alert rules — stay the machine's and are shared by everyone. What adapts to the host, rather than assuming Debian: diff --git a/web/src/Settings.tsx b/web/src/Settings.tsx index bad1300..c41ca50 100644 --- a/web/src/Settings.tsx +++ b/web/src/Settings.tsx @@ -1,7 +1,6 @@ -import { FormEvent, useCallback, useEffect, useState } from 'react' -import { createRoot } from 'react-dom/client' +import { useCallback, useEffect, useState } from 'react' import { fetchCatalog } from './sensors' -import { api, Me, Session, TokenInfo, User } from './api' +import { api, Me, TokenInfo } from './api' import { savePrefs } from './accents' import { applyTheme, currentThemeId, THEMES } from './themes' import { Btn, Loading, askYes, toast, useEscape } from './ui' @@ -39,10 +38,6 @@ export default function Settings({ me }: { me: Me }) { const [tokens, setTokens] = useState(null) const [fresh, setFresh] = useState<{ name: string; token: string } | null>(null) const [making, setMaking] = useState(false) - 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]) @@ -64,14 +59,6 @@ export default function Settings({ me }: { me: Me }) { catch (e) { setHistory(was); setHistorySec(was); setHistErr(e instanceof Error ? e.message : 'failed') } } - 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 (!await askYes(`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') } @@ -117,17 +104,6 @@ export default function Settings({ me }: { me: Me }) { -

Account · {me.account ?? '?'}@{me.host}

-
- - - - {pwErr &&
{pwErr}
} -
Your login on this host only. Other machines keep their own accounts. Your sessions stay signed in.
-
- - -

Access tokens

@@ -171,153 +147,6 @@ export default function Settings({ me }: { me: Me }) { ) } -// Where this account is signed in. Revoking one signs that device out at its next request. -function Sessions() { - const [list, setList] = useState(null) - const load = useCallback(async () => { try { setList(await api.sessions()) } catch { setList([]) } }, []) - useEffect(() => { load() }, [load]) - const revoke = async (s: Session) => { - if (s.current && !await askYes('Sign out this device? You will be back at the login form.')) return - try { await api.revokeSession(s.id); toast(s.current ? 'signed out' : 'session revoked'); if (s.current) window.dispatchEvent(new Event('hamlab:unauthenticated')); else load() } - catch (e) { toast(e instanceof Error ? e.message : 'failed', 'err') } - } - const others = list?.filter((s) => !s.current).length ?? 0 - const revokeOthers = async () => { - if (!await askYes(`Sign out ${others} other ${others === 1 ? 'device' : 'devices'}? This one stays signed in.`)) return - try { const r = await api.revokeOtherSessions(); toast(`${r.revoked} signed out`); load() } catch (e) { toast(e instanceof Error ? e.message : 'failed', 'err') } - } - return ( - <> -

Sessions

where you are signed in
-
- {list === null && } - {list && list.map((s) => ( -
- {device(s.agent)}{s.current && this device} - {s.ip ?? '?'} · seen {ago(s.last_seen)} · since {new Date(s.created).toLocaleDateString()} - revoke(s)}>{s.current ? 'Sign out' : 'Revoke'} -
- ))} - {others > 0 &&
Sign out everywhere else
} -
- - ) -} - -// The accounts on this host. Anyone signed in may add one; removing one, or resetting a -// password, asks for your own password first. -function Users({ me }: { me: Me }) { - const [list, setList] = useState(null) - const [name, setName] = useState('') - const [pw, setPw] = useState('') - const [err, setErr] = useState('') - const [reset, setReset] = useState(null) - const load = useCallback(async () => { try { setList(await api.users()) } catch { setList([]) } }, []) - useEffect(() => { load() }, [load]) - const add = async (e: FormEvent) => { - e.preventDefault(); setErr('') - if (pw.length < 12) { setErr('At least 12 characters.'); return } - try { await api.addUser(name.trim(), pw); toast(`${name.trim()} added`); setName(''); setPw(''); load() } - catch (e) { setErr(e instanceof Error ? e.message : 'failed') } - } - const remove = async (u: User) => { - const cur = await askPassword(`Remove the account "${u.name}"? Their sessions end and their settings are gone. Your password to confirm:`) - if (cur === null) return - try { await api.removeUser(u.name, cur); toast(`${u.name} removed`); load() } catch (e) { toast(e instanceof Error ? e.message : 'failed', 'err') } - } - return ( - <> -

Users · {me.host}

each with their own look and dashboard
-
- {list === null && } - {list && list.map((u) => ( -
- {u.name}{u.self && you} - {u.sessions} {u.sessions === 1 ? 'session' : 'sessions'} · seen {ago(u.last_seen)} · since {new Date(u.created).toLocaleDateString()} - - {!u.self && setReset(u)}>Reset password} - {!u.self && remove(u)}>Remove} - -
- ))} -
-
- - - {err &&
{err}
} -
Every user can do everything a signed-in console can. Accounts are this host's own; a hub reaches other machines with its token, not with these.
-
- {reset && setReset(null)} onDone={() => { setReset(null); load() }} />} - - ) -} - -function ResetDialog({ user, onClose, onDone }: { user: User; onClose: () => void; onDone: () => void }) { - const [cur, setCur] = useState('') - const [pw, setPw] = useState('') - const [err, setErr] = useState('') - useEscape(onClose) - const go = async () => { - setErr('') - if (pw.length < 12) { setErr('At least 12 characters.'); return } - try { await api.resetPassword(user.name, cur, pw); toast(`password of ${user.name} reset`); onDone() } catch (e) { setErr(e instanceof Error ? e.message : 'failed') } - } - return ( -
-
e.stopPropagation()} onSubmit={(e) => { e.preventDefault(); go() }}> -

Reset the password of {user.name}

- - -
They are signed out everywhere and sign in again with the new one.
- {err &&
{err}
} -
-
-
- ) -} - -// A password typed into a modal; null when cancelled. -function askPassword(prompt: string): Promise { - return new Promise((resolve) => { - const host = document.createElement('div') - document.body.appendChild(host) - const done = (v: string | null) => { root.unmount(); host.remove(); resolve(v) } - const root = createRoot(host) - root.render() - }) -} - -function PasswordDialog({ prompt, onDone }: { prompt: string; onDone: (v: string | null) => void }) { - const [v, setV] = useState('') - useEscape(() => onDone(null)) - return ( -
onDone(null)}> -
e.stopPropagation()} onSubmit={(e) => { e.preventDefault(); onDone(v) }}> -
{prompt}
- setV(e.target.value)} autoComplete="current-password" /> -
-
-
- ) -} - -// A short name for a browser from its user agent: "Safari on iPhone", "Firefox on Linux". -function device(ua?: string): string { - if (!ua) return 'unknown device' - const os = /iPhone/.test(ua) ? 'iPhone' : /iPad/.test(ua) ? 'iPad' : /Android/.test(ua) ? 'Android' : /Mac OS X/.test(ua) ? 'Mac' : /Windows/.test(ua) ? 'Windows' : /CrOS/.test(ua) ? 'ChromeOS' : /Linux/.test(ua) ? 'Linux' : '' - const br = /Edg\//.test(ua) ? 'Edge' : /OPR\//.test(ua) ? 'Opera' : /Firefox\//.test(ua) ? 'Firefox' : /Chrome\//.test(ua) ? 'Chrome' : /Safari\//.test(ua) ? 'Safari' : /curl\//.test(ua) ? 'curl' : ua.split(/[\s/]/)[0] || 'browser' - return os ? `${br} on ${os}` : br -} - -function ago(iso?: string): string { - if (!iso) return 'never' - const s = Math.max(0, (Date.now() - new Date(iso).getTime()) / 1000) - if (s < 60) return 'just now' - if (s < 3600) return `${Math.floor(s / 60)} min ago` - if (s < 86400) return `${Math.floor(s / 3600)} h ago` - return `${Math.floor(s / 86400)} d ago` -} - // One dialog for a read-only token: what will use it, and (optionally) which sensor // families it may see — picked from this machine's own catalog, or typed as patterns. function TokenDialog({ onClose, onMade }: { onClose: () => void; onMade: (t: { name: string; token: string }) => void }) { diff --git a/web/src/Shell.tsx b/web/src/Shell.tsx index cac7c08..bfccd76 100644 --- a/web/src/Shell.tsx +++ b/web/src/Shell.tsx @@ -17,6 +17,7 @@ import Procs from './Procs' import Top from './Top' import Machines from './Machines' import Settings from './Settings' +import Users from './Users' import MachineSwitcher from './MachineSwitcher' import { MachineStatus, setMachine } from './api' import { LIST_FLOOR, useRefreshMs } from './prefs' @@ -62,13 +63,14 @@ const GROUPS: Group[] = [ { id: 'files', label: 'Files', icon: FolderIcon }, { id: 'alerts', label: 'Alerts', icon: BellIcon }, { id: 'machines', label: 'Machines', icon: '⌂' }, + { id: 'users', label: 'Users', icon: '⚇' }, { id: 'widgets', label: 'Widgets', icon: '▦' }, { id: 'settings', label: 'Settings', icon: '⚙' }, ] -type View = 'terminal' | 'dashboard' | 'top' | 'widgets' | 'docker' | 'alerts' | 'services' | 'cron' | 'updates' | 'disks' | 'files' | 'procs' | 'machines' | 'settings' -const VIEWS: View[] = ['terminal', 'dashboard', 'top', 'widgets', 'docker', 'alerts', 'services', 'cron', 'updates', 'disks', 'files', 'procs', 'machines', 'settings'] -const HUB_ONLY: View[] = ['machines', 'settings', 'alerts'] +type View = 'terminal' | 'dashboard' | 'top' | 'widgets' | 'docker' | 'alerts' | 'services' | 'cron' | 'updates' | 'disks' | 'files' | 'procs' | 'machines' | 'users' | 'settings' +const VIEWS: View[] = ['terminal', 'dashboard', 'top', 'widgets', 'docker', 'alerts', 'services', 'cron', 'updates', 'disks', 'files', 'procs', 'machines', 'users', 'settings'] +const HUB_ONLY: View[] = ['machines', 'users', 'settings', 'alerts'] // URL hash is the source of truth for where you are: #//. Back and // forward work, reload restores the tab, and links can point at a page on a machine. @@ -261,6 +263,7 @@ export default function Shell({ me: hubMe, onLoggedOut }: { me: Me; onLoggedOut:
{view === 'settings' && } + {view === 'users' && } {view === 'machines' && { switchMachine(n); setView('dashboard') }} />} {view === 'dashboard' && } {view === 'widgets' && } @@ -298,7 +301,7 @@ export default function Shell({ me: hubMe, onLoggedOut }: { me: Me; onLoggedOut: {machine ? `${title} · ` : ''}{me.version.startsWith('v') ? me.version : 'v' + me.version} up {fmtUptime(me.uptime)} - {view === 'top' ? 'Top' : view === 'dashboard' ? 'Dashboard' : view === 'widgets' ? 'Widgets' : view === 'docker' ? 'Docker' : view === 'alerts' ? 'Alerts' : view === 'services' ? 'Services' : view === 'cron' ? 'Cron' : view === 'updates' ? 'Updates' : view === 'disks' ? 'Disks' : view === 'files' ? 'Files' : view === 'procs' ? 'Processes' : view === 'machines' ? 'Machines' : view === 'settings' ? 'Settings' : activeTerm ? activeTerm.name : ''} + {view === 'top' ? 'Top' : view === 'dashboard' ? 'Dashboard' : view === 'widgets' ? 'Widgets' : view === 'docker' ? 'Docker' : view === 'alerts' ? 'Alerts' : view === 'services' ? 'Services' : view === 'cron' ? 'Cron' : view === 'updates' ? 'Updates' : view === 'disks' ? 'Disks' : view === 'files' ? 'Files' : view === 'procs' ? 'Processes' : view === 'machines' ? 'Machines' : view === 'users' ? 'Users' : view === 'settings' ? 'Settings' : activeTerm ? activeTerm.name : ''} ) diff --git a/web/src/Users.tsx b/web/src/Users.tsx new file mode 100644 index 0000000..b92f0cd --- /dev/null +++ b/web/src/Users.tsx @@ -0,0 +1,191 @@ +// The Users page: your own account, where it is signed in, and the accounts on this host. +// Hub-only, like Machines: accounts are each machine's own, and a hub reaches a node with +// a token rather than a login, so a node's accounts are managed on that node's console. +import { FormEvent, useCallback, useEffect, useState } from 'react' +import { createRoot } from 'react-dom/client' +import { api, Me, Session, User } from './api' +import { Btn, Loading, askYes, toast, useEscape } from './ui' + +export default function Users({ me }: { me: Me }) { + return ( +
+ + + +
+ ) +} + +function Account({ me }: { me: Me }) { + const [cur, setCur] = useState('') + const [pw1, setPw1] = useState('') + const [pw2, setPw2] = useState('') + const [pwErr, setPwErr] = useState('') + 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') } + } + return ( + <> +

Account · {me.account ?? '?'}@{me.host}

+
+ + + + {pwErr &&
{pwErr}
} +
Your login on this host only. Other machines keep their own accounts. Your sessions stay signed in.
+
+ + ) +} + +// Where this account is signed in. Revoking one signs that device out at its next request. +function Sessions() { + const [list, setList] = useState(null) + const load = useCallback(async () => { try { setList(await api.sessions()) } catch { setList([]) } }, []) + useEffect(() => { load() }, [load]) + const revoke = async (s: Session) => { + if (s.current && !await askYes('Sign out this device? You will be back at the login form.')) return + try { await api.revokeSession(s.id); toast(s.current ? 'signed out' : 'session revoked'); if (s.current) window.dispatchEvent(new Event('hamlab:unauthenticated')); else load() } + catch (e) { toast(e instanceof Error ? e.message : 'failed', 'err') } + } + const others = list?.filter((s) => !s.current).length ?? 0 + const revokeOthers = async () => { + if (!await askYes(`Sign out ${others} other ${others === 1 ? 'device' : 'devices'}? This one stays signed in.`)) return + try { const r = await api.revokeOtherSessions(); toast(`${r.revoked} signed out`); load() } catch (e) { toast(e instanceof Error ? e.message : 'failed', 'err') } + } + return ( + <> +

Sessions

where you are signed in
+
+ {list === null && } + {list && list.map((s) => ( +
+ {device(s.agent)}{s.current && this device} + {s.ip ?? '?'} · seen {ago(s.last_seen)} · since {new Date(s.created).toLocaleDateString()} + revoke(s)}>{s.current ? 'Sign out' : 'Revoke'} +
+ ))} + {others > 0 &&
Sign out everywhere else
} +
+ + ) +} + +// The accounts on this host. Anyone signed in may add one; removing one, or resetting a +// password, asks for your own password first. +function Accounts({ me }: { me: Me }) { + const [list, setList] = useState(null) + const [name, setName] = useState('') + const [pw, setPw] = useState('') + const [err, setErr] = useState('') + const [reset, setReset] = useState(null) + const load = useCallback(async () => { try { setList(await api.users()) } catch { setList([]) } }, []) + useEffect(() => { load() }, [load]) + const add = async (e: FormEvent) => { + e.preventDefault(); setErr('') + if (pw.length < 12) { setErr('At least 12 characters.'); return } + try { await api.addUser(name.trim(), pw); toast(`${name.trim()} added`); setName(''); setPw(''); load() } + catch (e) { setErr(e instanceof Error ? e.message : 'failed') } + } + const remove = async (u: User) => { + const cur = await askPassword(`Remove the account "${u.name}"? Their sessions end and their settings are gone. Your password to confirm:`) + if (cur === null) return + try { await api.removeUser(u.name, cur); toast(`${u.name} removed`); load() } catch (e) { toast(e instanceof Error ? e.message : 'failed', 'err') } + } + return ( + <> +

Users · {me.host}

each with their own look and dashboard
+
+ {list === null && } + {list && list.map((u) => ( +
+ {u.name}{u.self && you} + {u.sessions} {u.sessions === 1 ? 'session' : 'sessions'} · seen {ago(u.last_seen)} · since {new Date(u.created).toLocaleDateString()} + + {!u.self && setReset(u)}>Reset password} + {!u.self && remove(u)}>Remove} + +
+ ))} +
+
+ + + {err &&
{err}
} +
Every user can do everything a signed-in console can. Accounts are this host's own; a hub reaches other machines with its token, not with these.
+
+ {reset && setReset(null)} onDone={() => { setReset(null); load() }} />} + + ) +} + +function ResetDialog({ user, onClose, onDone }: { user: User; onClose: () => void; onDone: () => void }) { + const [cur, setCur] = useState('') + const [pw, setPw] = useState('') + const [err, setErr] = useState('') + useEscape(onClose) + const go = async () => { + setErr('') + if (pw.length < 12) { setErr('At least 12 characters.'); return } + try { await api.resetPassword(user.name, cur, pw); toast(`password of ${user.name} reset`); onDone() } catch (e) { setErr(e instanceof Error ? e.message : 'failed') } + } + return ( +
+
e.stopPropagation()} onSubmit={(e) => { e.preventDefault(); go() }}> +

Reset the password of {user.name}

+ + +
They are signed out everywhere and sign in again with the new one.
+ {err &&
{err}
} +
+
+
+ ) +} + +// A password typed into a modal; null when cancelled. +function askPassword(prompt: string): Promise { + return new Promise((resolve) => { + const host = document.createElement('div') + document.body.appendChild(host) + const done = (v: string | null) => { root.unmount(); host.remove(); resolve(v) } + const root = createRoot(host) + root.render() + }) +} + +function PasswordDialog({ prompt, onDone }: { prompt: string; onDone: (v: string | null) => void }) { + const [v, setV] = useState('') + useEscape(() => onDone(null)) + return ( +
onDone(null)}> +
e.stopPropagation()} onSubmit={(e) => { e.preventDefault(); onDone(v) }}> +
{prompt}
+ setV(e.target.value)} autoComplete="current-password" /> +
+
+
+ ) +} + +// A short name for a browser from its user agent: "Safari on iPhone", "Firefox on Linux". +function device(ua?: string): string { + if (!ua) return 'unknown device' + const os = /iPhone/.test(ua) ? 'iPhone' : /iPad/.test(ua) ? 'iPad' : /Android/.test(ua) ? 'Android' : /Mac OS X/.test(ua) ? 'Mac' : /Windows/.test(ua) ? 'Windows' : /CrOS/.test(ua) ? 'ChromeOS' : /Linux/.test(ua) ? 'Linux' : '' + const br = /Edg\//.test(ua) ? 'Edge' : /OPR\//.test(ua) ? 'Opera' : /Firefox\//.test(ua) ? 'Firefox' : /Chrome\//.test(ua) ? 'Chrome' : /Safari\//.test(ua) ? 'Safari' : /curl\//.test(ua) ? 'curl' : ua.split(/[\s/]/)[0] || 'browser' + return os ? `${br} on ${os}` : br +} + +function ago(iso?: string): string { + if (!iso) return 'never' + const s = Math.max(0, (Date.now() - new Date(iso).getTime()) / 1000) + if (s < 60) return 'just now' + if (s < 3600) return `${Math.floor(s / 60)} min ago` + if (s < 86400) return `${Math.floor(s / 3600)} h ago` + return `${Math.floor(s / 86400)} d ago` +}