Mitchell, on the read-only version built earlier today: scratch it. He hands these to people he trusts and revokes them otherwise, so a console with half its tabs missing solved a problem he doesn't have. So the allow-list, the middleware check and every "not for a guest" branch in the UI are gone. What is left is the part that was the point: a throwaway account that expires by itself, is checked on every request, swept once a minute, and can be ended in one tap. A guest now gets the whole console on every machine, terminal included. Said once, since it is his call and he has made it: that means a guest has a root-capable shell on every host, and can make an account or a token of its own that outlives the guest login. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
271 lines
16 KiB
TypeScript
271 lines
16 KiB
TypeScript
// 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 (
|
|
<div className="dash settings">
|
|
<Account me={me} />
|
|
<Sessions />
|
|
<Guests />
|
|
<Accounts me={me} />
|
|
</div>
|
|
)
|
|
}
|
|
|
|
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<Guest[] | null>(null)
|
|
const [minutes, setMinutes] = useState<number>(30)
|
|
const [fresh, setFresh] = useState<FreshGuest | null>(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 (
|
|
<>
|
|
<div className="dash-bar"><h2 className="section">Guest logins</h2><span className="spacer" /><span className="muted small">temporary, and can be revoked</span></div>
|
|
<section className="card">
|
|
{list === null && <Loading label="Loading…" />}
|
|
{list?.length === 0 && <div className="empty">None. One lets somebody in for a while without giving them an account.</div>}
|
|
{list?.map((g) => {
|
|
const left = new Date(g.expires).getTime() - Date.now()
|
|
return (
|
|
<div className="row" key={g.name}>
|
|
<span className="k mono">{g.name}</span>
|
|
<span className="muted small">{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}` : ''}</span>
|
|
<Btn className="btn danger" onClick={() => revoke(g)}>Revoke</Btn>
|
|
</div>
|
|
)
|
|
})}
|
|
</section>
|
|
{fresh && <FreshCard g={fresh} onClose={() => setFresh(null)} />}
|
|
<div className="card addform" style={{ marginTop: 8 }}>
|
|
<label>Lasts
|
|
<select value={minutes} onChange={(e) => setMinutes(Number(e.target.value))}>
|
|
{GUEST_MINUTES.map((m) => <option key={m} value={m}>{m < 60 ? `${m} minutes` : `${m / 60} hour${m === 60 ? '' : 's'}`}</option>)}
|
|
</select>
|
|
</label>
|
|
<div className="svc-actions"><Btn className="btn on" busy={busy} onClick={create}>Create guest login</Btn><span className="muted small">A 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.</span></div>
|
|
</div>
|
|
</>
|
|
)
|
|
}
|
|
|
|
// 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 (
|
|
<section className="card fresh" style={{ marginTop: 8 }}>
|
|
<div className="row"><span className="k">User</span><span className="mono">{g.name}</span><Btn className="btn" onClick={() => copy(g.name, 'user')}>Copy</Btn></div>
|
|
<div className="row"><span className="k">Password</span><span className="mono">{g.password}</span><Btn className="btn" onClick={() => copy(g.password, 'password')}>Copy</Btn></div>
|
|
<div className="svc-actions" style={{ marginTop: 8 }}>
|
|
<Btn className="btn on" onClick={() => copy(invite, 'invitation')}>Copy invitation</Btn>
|
|
<span className="muted small">Shown once. Expires {new Date(g.expires).toLocaleTimeString()}.</span>
|
|
<span className="spacer" />
|
|
<button className="btn" onClick={onClose}>Done</button>
|
|
</div>
|
|
</section>
|
|
)
|
|
}
|
|
|
|
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`
|
|
}
|