A Users tab: own password, sessions and accounts in one place

Settings keeps appearance, rates, tokens and About.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Mitchell-Johnson
2026-09-22 19:45:01 -07:00
co-authored by Claude Fable 5.1
parent 2b54a06821
commit 027a2a575c
5 changed files with 210 additions and 185 deletions
+5 -3
View File
@@ -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
+5 -5
View File
@@ -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 <user>` 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 <user>` 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:
+2 -173
View File
@@ -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<TokenInfo[] | null>(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 }) {
</div>
</section>
<div className="dash-bar"><h2 className="section">Account · {me.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">Your login on this host only. Other machines keep their own accounts. Your sessions stay signed in.</span></div>
</form>
<Sessions />
<Users me={me} />
<div className="dash-bar"><h2 className="section">Access tokens</h2></div>
<section className="card">
@@ -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<Session[] | null>(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 (
<>
<div className="dash-bar"><h2 className="section">Sessions</h2><span className="spacer" /><span className="muted small">where you are signed in</span></div>
<section className="card">
{list === null && <Loading label="Loading…" />}
{list && list.map((s) => (
<div className="row" key={s.id}>
<span className="k">{device(s.agent)}{s.current && <span className="badge" style={{ marginLeft: 6 }}>this device</span>}</span>
<span className="muted small">{s.ip ?? '?'} · seen {ago(s.last_seen)} · since {new Date(s.created).toLocaleDateString()}</span>
<Btn className={'btn' + (s.current ? '' : ' danger')} onClick={() => revoke(s)}>{s.current ? 'Sign out' : 'Revoke'}</Btn>
</div>
))}
{others > 0 && <div className="svc-actions" style={{ marginTop: 8 }}><Btn className="btn danger" onClick={revokeOthers}>Sign out everywhere else</Btn></div>}
</section>
</>
)
}
// 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<User[] | null>(null)
const [name, setName] = useState('')
const [pw, setPw] = useState('')
const [err, setErr] = useState('')
const [reset, setReset] = useState<User | null>(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 (
<>
<div className="dash-bar"><h2 className="section">Users · {me.host}</h2><span className="spacer" /><span className="muted small">each with their own look and dashboard</span></div>
<section className="card">
{list === null && <Loading label="Loading…" />}
{list && list.map((u) => (
<div className="row" key={u.name}>
<span className="k mono">{u.name}{u.self && <span className="badge" style={{ marginLeft: 6 }}>you</span>}</span>
<span className="muted small">{u.sessions} {u.sessions === 1 ? 'session' : 'sessions'} · seen {ago(u.last_seen)} · since {new Date(u.created).toLocaleDateString()}</span>
<span style={{ display: 'flex', gap: 6 }}>
{!u.self && <Btn className="btn" onClick={() => setReset(u)}>Reset password</Btn>}
{!u.self && <Btn className="btn danger" onClick={() => remove(u)}>Remove</Btn>}
</span>
</div>
))}
</section>
<form className="card addform" onSubmit={add} style={{ marginTop: 8 }}>
<label>New user <input value={name} onChange={(e) => setName(e.target.value)} pattern="[a-z0-9][a-z0-9._\-]{0,31}" title="lower-case letters, digits, . _ and -" autoComplete="off" spellCheck={false} autoCapitalize="off" required /></label>
<label>Their password <input type="password" value={pw} onChange={(e) => setPw(e.target.value)} autoComplete="new-password" required /></label>
{err && <div className="error">{err}</div>}
<div className="svc-actions"><button className="btn on" disabled={!name || !pw}>Add user</button><span className="muted small">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.</span></div>
</form>
{reset && <ResetDialog user={reset} onClose={() => 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 (
<div className="modal-back" onClick={onClose}>
<form className="modal" onClick={(e) => e.stopPropagation()} onSubmit={(e) => { e.preventDefault(); go() }}>
<h3>Reset the password of {user.name}</h3>
<label className="muted">Their new password<input type="password" autoFocus value={pw} onChange={(e) => setPw(e.target.value)} autoComplete="new-password" /></label>
<label className="muted">Your password, to confirm<input type="password" value={cur} onChange={(e) => setCur(e.target.value)} autoComplete="current-password" /></label>
<div className="muted small">They are signed out everywhere and sign in again with the new one.</div>
{err && <div className="error">{err}</div>}
<div className="svc-actions"><button type="submit" className="btn on" disabled={!cur || !pw}>Reset</button><span className="spacer" /><button type="button" className="btn" onClick={onClose}>Cancel</button></div>
</form>
</div>
)
}
// A password typed into a modal; null when cancelled.
function askPassword(prompt: string): Promise<string | null> {
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(<PasswordDialog prompt={prompt} onDone={done} />)
})
}
function PasswordDialog({ prompt, onDone }: { prompt: string; onDone: (v: string | null) => void }) {
const [v, setV] = useState('')
useEscape(() => onDone(null))
return (
<div className="modal-back" onClick={() => onDone(null)}>
<form className="modal" onClick={(e) => e.stopPropagation()} onSubmit={(e) => { e.preventDefault(); onDone(v) }}>
<div>{prompt}</div>
<input type="password" autoFocus value={v} onChange={(e) => setV(e.target.value)} autoComplete="current-password" />
<div className="svc-actions"><button type="submit" className="btn danger" disabled={!v}>Confirm</button><span className="spacer" /><button type="button" className="btn" onClick={() => onDone(null)}>Cancel</button></div>
</form>
</div>
)
}
// 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 }) {
+7 -4
View File
@@ -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: #/<machine|hub>/<view>. 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:
<main className="main" key={machine ?? 'hub'}>
{view === 'settings' && <Settings me={hubMe} />}
{view === 'users' && <Users me={hubMe} />}
{view === 'machines' && <Machines hub={hubMe} onChanged={loadMachines} onOpen={(n) => { switchMachine(n); setView('dashboard') }} />}
{view === 'dashboard' && <Dashboard />}
{view === 'widgets' && <Gallery demoUrl={hubMe.demo_url} />}
@@ -298,7 +301,7 @@ export default function Shell({ me: hubMe, onLoggedOut }: { me: Me; onLoggedOut:
<span>{machine ? `${title} · ` : ''}{me.version.startsWith('v') ? me.version : 'v' + me.version}</span>
<span>up {fmtUptime(me.uptime)}</span>
<span className="spacer" />
<span className="muted">{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 : ''}</span>
<span className="muted">{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 : ''}</span>
</footer>
</div>
)
+191
View File
@@ -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 (
<div className="dash settings">
<Account me={me} />
<Sessions />
<Accounts me={me} />
</div>
)
}
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 (
<>
<div className="dash-bar"><h2 className="section">Account · {me.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">Your login on this host only. Other machines keep their own accounts. Your sessions stay signed in.</span></div>
</form>
</>
)
}
// Where this account is signed in. Revoking one signs that device out at its next request.
function Sessions() {
const [list, setList] = useState<Session[] | null>(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 (
<>
<div className="dash-bar"><h2 className="section">Sessions</h2><span className="spacer" /><span className="muted small">where you are signed in</span></div>
<section className="card">
{list === null && <Loading label="Loading…" />}
{list && list.map((s) => (
<div className="row" key={s.id}>
<span className="k">{device(s.agent)}{s.current && <span className="badge" style={{ marginLeft: 6 }}>this device</span>}</span>
<span className="muted small">{s.ip ?? '?'} · seen {ago(s.last_seen)} · since {new Date(s.created).toLocaleDateString()}</span>
<Btn className={'btn' + (s.current ? '' : ' danger')} onClick={() => revoke(s)}>{s.current ? 'Sign out' : 'Revoke'}</Btn>
</div>
))}
{others > 0 && <div className="svc-actions" style={{ marginTop: 8 }}><Btn className="btn danger" onClick={revokeOthers}>Sign out everywhere else</Btn></div>}
</section>
</>
)
}
// 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<User[] | null>(null)
const [name, setName] = useState('')
const [pw, setPw] = useState('')
const [err, setErr] = useState('')
const [reset, setReset] = useState<User | null>(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 (
<>
<div className="dash-bar"><h2 className="section">Users · {me.host}</h2><span className="spacer" /><span className="muted small">each with their own look and dashboard</span></div>
<section className="card">
{list === null && <Loading label="Loading…" />}
{list && list.map((u) => (
<div className="row" key={u.name}>
<span className="k mono">{u.name}{u.self && <span className="badge" style={{ marginLeft: 6 }}>you</span>}</span>
<span className="muted small">{u.sessions} {u.sessions === 1 ? 'session' : 'sessions'} · seen {ago(u.last_seen)} · since {new Date(u.created).toLocaleDateString()}</span>
<span style={{ display: 'flex', gap: 6 }}>
{!u.self && <Btn className="btn" onClick={() => setReset(u)}>Reset password</Btn>}
{!u.self && <Btn className="btn danger" onClick={() => remove(u)}>Remove</Btn>}
</span>
</div>
))}
</section>
<form className="card addform" onSubmit={add} style={{ marginTop: 8 }}>
<label>New user <input value={name} onChange={(e) => setName(e.target.value)} pattern="[a-z0-9][a-z0-9._\-]{0,31}" title="lower-case letters, digits, . _ and -" autoComplete="off" spellCheck={false} autoCapitalize="off" required /></label>
<label>Their password <input type="password" value={pw} onChange={(e) => setPw(e.target.value)} autoComplete="new-password" required /></label>
{err && <div className="error">{err}</div>}
<div className="svc-actions"><button className="btn on" disabled={!name || !pw}>Add user</button><span className="muted small">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.</span></div>
</form>
{reset && <ResetDialog user={reset} onClose={() => 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 (
<div className="modal-back" onClick={onClose}>
<form className="modal" onClick={(e) => e.stopPropagation()} onSubmit={(e) => { e.preventDefault(); go() }}>
<h3>Reset the password of {user.name}</h3>
<label className="muted">Their new password<input type="password" autoFocus value={pw} onChange={(e) => setPw(e.target.value)} autoComplete="new-password" /></label>
<label className="muted">Your password, to confirm<input type="password" value={cur} onChange={(e) => setCur(e.target.value)} autoComplete="current-password" /></label>
<div className="muted small">They are signed out everywhere and sign in again with the new one.</div>
{err && <div className="error">{err}</div>}
<div className="svc-actions"><button type="submit" className="btn on" disabled={!cur || !pw}>Reset</button><span className="spacer" /><button type="button" className="btn" onClick={onClose}>Cancel</button></div>
</form>
</div>
)
}
// A password typed into a modal; null when cancelled.
function askPassword(prompt: string): Promise<string | null> {
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(<PasswordDialog prompt={prompt} onDone={done} />)
})
}
function PasswordDialog({ prompt, onDone }: { prompt: string; onDone: (v: string | null) => void }) {
const [v, setV] = useState('')
useEscape(() => onDone(null))
return (
<div className="modal-back" onClick={() => onDone(null)}>
<form className="modal" onClick={(e) => e.stopPropagation()} onSubmit={(e) => { e.preventDefault(); onDone(v) }}>
<div>{prompt}</div>
<input type="password" autoFocus value={v} onChange={(e) => setV(e.target.value)} autoComplete="current-password" />
<div className="svc-actions"><button type="submit" className="btn danger" disabled={!v}>Confirm</button><span className="spacer" /><button type="button" className="btn" onClick={() => onDone(null)}>Cancel</button></div>
</form>
</div>
)
}
// 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`
}