A guest login is a full account with a clock on it
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>
This commit is contained in:
co-authored by
Claude Opus 5
parent
8a7167bf03
commit
179f342a2c
+9
-15
@@ -26,7 +26,7 @@ import { LIST_FLOOR, useRefreshMs } from './prefs'
|
||||
import Power from './Power'
|
||||
import { applyAccent } from './accents'
|
||||
import { Btn, Toasts, askText, askYes, hasUnsaved, okToLeave, toast } from './ui'
|
||||
import { fmtLeft, useCountdown } from './guest'
|
||||
import { fmtLeft, useTimeLeft } from './guest'
|
||||
|
||||
// Inline outline icon so it matches the monochrome glyphs in the rail (emoji would be full colour).
|
||||
const FolderIcon = (
|
||||
@@ -76,10 +76,6 @@ const GROUPS: Group[] = [
|
||||
type View = 'terminal' | 'widgets' | 'docker' | 'alerts' | 'services' | 'jobs' | 'updates' | 'disks' | 'hardware' | 'zfs' | 'usage' | 'files' | 'procs' | 'machines' | 'kvm' | 'users' | 'settings'
|
||||
const VIEWS: View[] = ['terminal', 'widgets', 'docker', 'alerts', 'services', 'jobs', 'updates', 'disks', 'hardware', 'zfs', 'usage', 'files', 'procs', 'machines', 'kvm', 'users', 'settings']
|
||||
const HUB_ONLY: View[] = ['machines', 'users', 'settings', 'alerts']
|
||||
// What a guest login is shown: the pages whose reads the server allows it (guestRoutes in
|
||||
// internal/api/guests.go). Every other page would be a wall of 403s, and the two that are
|
||||
// missing on purpose — Terminal and Files — are the ones a look-around login must not have.
|
||||
const GUEST_VIEWS: View[] = ['procs', 'docker', 'disks', 'usage', 'zfs', 'hardware', 'widgets']
|
||||
|
||||
// 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.
|
||||
@@ -109,10 +105,11 @@ export default function Shell({ me: hubMe, onLoggedOut }: { me: Me; onLoggedOut:
|
||||
const [machines, setMachines] = useState<MachineStatus[]>([])
|
||||
const [remoteMe, setRemoteMe] = useState<Me | null>(null)
|
||||
const [remoteErr, setRemoteErr] = useState('')
|
||||
// A guest login is an account with a clock on it: nothing is hidden from it, but the header
|
||||
// says how long it has, for whoever is holding the phone.
|
||||
const leftMs = useTimeLeft(hubMe.guest_until)
|
||||
// A machine is titled by its own hostname; the name it was added under stays its identity
|
||||
// (the URL, its accent, what the hub's API calls it). See titleOf.
|
||||
const guest = !!hubMe.guest
|
||||
const leftMs = useCountdown(guest)
|
||||
const title = titleOf(machine, machines, hubMe.host)
|
||||
const me: Me = machine ? (remoteMe ?? { ...hubMe, host: title, load: '', version: '…', uptime: 0, reboot_needed: false }) : hubMe
|
||||
const listRate = useRefreshMs(LIST_FLOOR)
|
||||
@@ -125,13 +122,12 @@ export default function Shell({ me: hubMe, onLoggedOut }: { me: Me; onLoggedOut:
|
||||
const handles = useRef<Record<string, TermHandle>>({})
|
||||
|
||||
const reload = useCallback(async () => {
|
||||
if (guest) { setTerms([]); return } // a guest has no shell, here or anywhere
|
||||
try {
|
||||
const list = await api.terminals()
|
||||
setTerms(list)
|
||||
setActive((a) => (a && list.some((t) => t.id === a) ? a : list[0]?.id ?? null))
|
||||
} catch (e) { setTerms([]); if (machine) setRemoteErr(e instanceof Error ? e.message : 'unreachable') }
|
||||
}, [machine, guest])
|
||||
}, [machine])
|
||||
|
||||
useEffect(() => { reload() }, [reload])
|
||||
|
||||
@@ -164,18 +160,16 @@ export default function Shell({ me: hubMe, onLoggedOut }: { me: Me; onLoggedOut:
|
||||
// keep the hash in step, and follow it when the browser navigates
|
||||
const [firing, setFiring] = useState(0)
|
||||
useEffect(() => {
|
||||
if (guest) return
|
||||
const tick = () => { if (document.visibilityState === 'visible') api.alerts().then((a) => setFiring(a.firing.length)).catch(() => {}) }
|
||||
tick(); const t = setInterval(tick, 15000); window.addEventListener('hamlab:alerts', tick)
|
||||
return () => { clearInterval(t); window.removeEventListener('hamlab:alerts', tick) }
|
||||
}, [guest])
|
||||
}, [])
|
||||
// The machine's colour: set on <html>, so the header stripe and the switcher pick it up.
|
||||
// Hub-only pages make no sense on a remote machine. Judged once both have settled, not while
|
||||
// the machine is changing: the URL sets the machine and the page in one go, and deciding from
|
||||
// inside the machine change read the page we were leaving — so a link from Settings to a
|
||||
// machine's Disks arrived at its Dashboard.
|
||||
useEffect(() => { if (machine && HUB_ONLY.includes(view)) setView('procs') }, [machine, view])
|
||||
useEffect(() => { if (guest && !GUEST_VIEWS.includes(view)) setView('procs') }, [guest, view])
|
||||
|
||||
useEffect(() => { const paint = () => applyAccent(machine ?? hubMe.host); paint(); window.addEventListener('hamlab:accents', paint); return () => window.removeEventListener('hamlab:accents', paint) }, [machine, hubMe.host])
|
||||
// The tab's title says where you are, so history entries and open tabs can be told apart.
|
||||
@@ -283,14 +277,14 @@ export default function Shell({ me: hubMe, onLoggedOut }: { me: Me; onLoggedOut:
|
||||
<span className="spacer" />
|
||||
<span className="stat" title="load average, 1 · 5 · 15 min">load {me.load}</span>
|
||||
<span className={'dot ' + (online && !remoteErr ? 'ok' : 'bad')} title={remoteErr ? `${title} unreachable` : online ? 'connected' : 'disconnected'} />
|
||||
{!guest && <Power host={me.host} label={title} rebootNeeded={me.reboot_needed} />}
|
||||
{guest && <span className="badge guest" title={`this login expires ${new Date(hubMe.guest_until ?? '').toLocaleTimeString()}`}>guest · {fmtLeft(leftMs)}</span>}
|
||||
<Power host={me.host} label={title} rebootNeeded={me.reboot_needed} />
|
||||
{hubMe.guest && <span className="badge guest" title={`this guest login expires ${new Date(hubMe.guest_until ?? '').toLocaleTimeString()}`}>guest · {fmtLeft(leftMs)}</span>}
|
||||
{hubMe.account && <span className="host account" title="signed in as">{hubMe.account}</span>}
|
||||
<Btn className="link" onClick={logout}>logout</Btn>
|
||||
</header>
|
||||
|
||||
<div className="body">
|
||||
<Rail groups={GROUPS.filter((g) => (guest ? GUEST_VIEWS.includes(g.id as View) : !machine || !HUB_ONLY.includes(g.id as View)))} view={view} badges={{ alerts: firing, settings: hubMe.update_available ? 1 : 0 }}
|
||||
<Rail groups={machine ? GROUPS.filter((g) => !HUB_ONLY.includes(g.id as View)) : GROUPS} view={view} badges={{ alerts: firing, settings: hubMe.update_available ? 1 : 0 }}
|
||||
header={<MachineSwitcher hub={hubMe.host} hubAddr={hubMe.addr} current={machine} machines={machines} onChange={switchMachine} onManage={() => { setView('machines'); setDrawer(false) }} />}
|
||||
onView={async (id) => { if (id !== view && !(await okToLeave())) return; setView(id as View); setDrawer(false) }} />
|
||||
<div className="backdrop" onClick={() => setDrawer(false)} />
|
||||
|
||||
Reference in New Issue
Block a user