// 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, FreshGuest, Guest, Me, Session, User } from './api' import { Btn, Loading, askYes, toast, useEscape } from './ui' import { fmtLeft } from './guest' export default function Users({ me }: { me: Me }) { return (
) } const GUEST_MINUTES = [15, 30, 60, 120, 480] as const // A login to hand somebody you are showing the console to: an ordinary account with a clock // on it, which expires on its own and can be ended here at any moment. Making one is the only // time its password is readable, so it is shown until dismissed rather than in a toast. function Guests() { const [list, setList] = useState(null) const [minutes, setMinutes] = useState(30) const [fresh, setFresh] = useState(null) const [busy, setBusy] = useState(false) const load = useCallback(async () => { try { setList(await api.guests()) } catch { setList([]) } }, []) useEffect(() => { load() }, [load]) // The clock is the point of the thing, so it keeps up without a reload. useEffect(() => { const t = setInterval(load, 30000); return () => clearInterval(t) }, [load]) const create = async () => { setBusy(true) try { setFresh(await api.createGuest(minutes)); load() } catch (e) { toast(e instanceof Error ? e.message : 'failed', 'err') } finally { setBusy(false) } } const revoke = async (g: Guest) => { if (!await askYes(`End the guest login ${g.name}? Whoever has it is signed out at once, everywhere.`, 'Revoke')) return try { await api.revokeGuest(g.name); toast(`${g.name} revoked`); if (fresh?.name === g.name) setFresh(null); load() } catch (e) { toast(e instanceof Error ? e.message : 'failed', 'err') } } return ( <>

Guest logins

temporary, and can be revoked
{list === null && } {list?.length === 0 &&
None. One lets somebody in for a while without giving them an account.
} {list?.map((g) => { const left = new Date(g.expires).getTime() - Date.now() return (
{g.name} {left > 0 ? `expires in ${fmtLeft(left)}` : 'expiring'} · {g.sessions > 0 ? `${g.sessions} signed in` : g.used ? 'signed in earlier' : 'not used yet'}{g.issued_by ? ` · by ${g.issued_by}` : ''} revoke(g)}>Revoke
) })}
{fresh && setFresh(null)} />}
Create guest loginA guest can do everything you can, on every machine — including the terminal — until the time is up or you revoke it. Give one out accordingly.
) } // The credentials, once. Copy takes the whole invitation, because that is what gets pasted // into a message. function FreshCard({ g, onClose }: { g: FreshGuest; onClose: () => void }) { const invite = `${location.origin}\nuser: ${g.name}\npassword: ${g.password}\nexpires ${new Date(g.expires).toLocaleTimeString()}` const copy = async (text: string, what: string) => { try { await navigator.clipboard.writeText(text); toast(`${what} copied`) } catch { toast('select and copy it by hand', 'err') } } return (
User{g.name} copy(g.name, 'user')}>Copy
Password{g.password} copy(g.password, 'password')}>Copy
copy(invite, 'invitation')}>Copy invitation Shown once. Expires {new Date(g.expires).toLocaleTimeString()}.
) } 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` }