// 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 (
>
)
}
// 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
// a day unused is the line between a device that is used and a script that signed in once
const IDLE_H = 24
const idle = list?.filter((s) => !s.current && Date.now() - new Date(s.last_seen).getTime() > IDLE_H * 3600e3).length ?? 0
const revokeIdle = async () => {
if (!await askYes(`Sign out ${idle} ${idle === 1 ? 'session' : 'sessions'} not used in the last ${IDLE_H} hours? Devices used since then, and this one, stay signed in.`, 'Revoke')) return
try { const r = await api.revokeIdleSessions(IDLE_H); toast(`${r.revoked} signed out`); load() } catch (e) { toast(e instanceof Error ? e.message : 'failed', 'err') }
}
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()}{!s.current && Date.now() - new Date(s.last_seen).getTime() > IDLE_H * 3600e3 ? idle : null} revoke(s)}>{s.current ? 'Sign out' : 'Revoke'}
))}
{others > 0 &&
{idle > 0 && Sign out {idle} idle}
Sign out everywhere elseA session unused for a week signs itself out.
}
>
)
}
// 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 (
<>