A guest login: temporary, look-only, revocable
Users -> Guest logins makes a throwaway user name and password, good for half an hour by default, to hand to somebody who wants to see the console. It is a normal account with `guest: true` and an expiry, so login, sessions and the saved look need no special case, and it is left out of the account list, out of "the last account can't be removed", and out of the one account an empty user name means. What a guest may do is one allow-list, checked in the auth middleware, rather than a flag ~190 handlers have to remember. The list is matched by asking the router which handler a request would reach, so it cannot drift from the routes it names, and it is checked against the scoped path — /api/nodes/grid/procs is /api/procs on grid — because the hub proxies to a node with a token that is full control of that node. The node cannot tell a guest from its owner, so the only place it can be stopped is here, before the request leaves. A guest gets the dashboard, containers, stacks, disks, hardware, ZFS and usage, on this machine and on any managed one, and nothing else: no terminal (attaching is a GET, and it is a root shell), no files, no container logs or environments, no stack files, no alert channels, no cron, no tokens, no settings, no power. Expiry is enforced on every request and swept once a minute, so a console left open on a visitor's phone stops within the minute rather than at their next click; revoking signs them out at once. The console hides what a guest cannot use and says how long is left where the power button would be. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
4450f1285d
commit
8a7167bf03
+80
-1
@@ -3,19 +3,98 @@
|
||||
// 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 { 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 so they can look at the console: it expires on its own, it can be
|
||||
// ended here at any moment, and it can only read. Making one is the only way to see its
|
||||
// password, 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.`, '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 only look</span></div>
|
||||
<section className="card">
|
||||
{list === null && <Loading label="Loading…" />}
|
||||
{list?.length === 0 && <div className="empty">None. One lets somebody look around without 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 sees the dashboard, containers, disks and hardware, read-only. No terminal, no files, no settings, nothing it can change. It stops working when the time is up, or when you revoke it.</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('')
|
||||
|
||||
Reference in New Issue
Block a user