Add native host management and role-based operator authentication

Introduce an outbound Python host agent, Debian installer, durable remote
operations, immutable game-file libraries, host-aware deployments and ports,
and occupancy-safe campaigns and draining. Native hosts do not require Komodo.

Add administrator, operator and viewer accounts, protected management routes,
secure sessions, password recovery, audit history and separate telemetry tokens.
Document first-admin setup, reporter migration and live-host acceptance checks.

Validation: 1,343 TypeScript tests and 39 agent tests passed, along with type
checking, the production build, compiled SSR/API smoke tests and administrator
recovery. Real Debian/Docker T5/T6 acceptance remains unrun.
This commit is contained in:
2026-10-03 11:42:36 -04:00
parent a2e2311aa9
commit e5c0fda1ed
127 changed files with 8485 additions and 657 deletions
+154
View File
@@ -0,0 +1,154 @@
<script lang="ts">
import { onMount } from 'svelte';
import { page } from '$app/stores';
import { invalidateAll } from '$app/navigation';
import type { AuthUser, UserRole } from '$lib/auth.js';
import { confirmAction } from '$ui/confirm.js';
import Button from '$ui/components/primitives/Button.svelte';
import Dialog from '$ui/components/primitives/Dialog.svelte';
import SectionHeader from '$ui/components/primitives/SectionHeader.svelte';
let users = $state<AuthUser[]>([]); let loading = $state(true); let busy = $state(false);
let error = $state(''); let notice = $state(''); let formError = $state('');
let createOpen = $state(false); let editOpen = $state(false); let editing = $state<AuthUser | null>(null);
let username = $state(''); let displayName = $state(''); let password = $state('');
let role = $state<UserRole>('viewer'); let enabled = $state(true); let showPassword = $state(false);
interface AuditEvent { id: number; actorId: string | null; actor: string | null; action: string; targetId: string | null; details: string; createdAt: number }
let events = $state<AuditEvent[]>([]); let auditLoading = $state(true); let auditError = $state('');
const roles: { value: UserRole; label: string; description: string }[] = [
{ value: 'viewer', label: 'Viewer', description: 'Read fleet status and operation summaries.' },
{ value: 'operator', label: 'Operator', description: 'Operate servers, campaigns, scripts, and configs.' },
{ value: 'admin', label: 'Administrator', description: 'Manage users, access, settings, hosts, and the fleet.' }
];
const roleDescription = $derived(roles.find(item => item.value === role)?.description);
const administrator = $derived($page.data.user?.role === 'admin');
async function load() {
loading = true;
try {
const response = await fetch('/api/users'); const body = await response.json();
if (!response.ok) throw new Error(body.error ?? 'Could not load users.');
users = body.users;
} catch (cause) { error = (cause as Error).message; }
finally { loading = false; }
}
async function loadAudit() {
auditLoading = true; auditError = '';
try {
const response = await fetch('/api/users/audit'); const body = await response.json();
if (!response.ok) throw new Error(body.error ?? 'Could not load account activity.');
events = body.events;
} catch (cause) { auditError = (cause as Error).message; }
finally { auditLoading = false; }
}
const actionLabels: Record<string, string> = { setup: 'Administrator created', login: 'Signed in', 'login.failed': 'Sign-in failed', logout: 'Signed out', 'sessions.revoked': 'Sessions ended', 'user.created': 'User created', 'user.updated': 'Access updated', 'password.changed': 'Password changed' };
function auditDetails(value: string): string { try { const parsed = JSON.parse(value); return Object.keys(parsed).length ? JSON.stringify(parsed, null, 2) : ''; } catch { return value; } }
onMount(() => { if (administrator) void Promise.all([load(), loadAudit()]); else { loading = false; auditLoading = false; } });
function openCreate() {
username = ''; displayName = ''; password = ''; role = 'viewer'; enabled = true; showPassword = false;
formError = ''; editing = null; createOpen = true;
}
function openEdit(user: AuthUser) {
editing = user; username = user.username; displayName = user.displayName; password = ''; role = user.role;
enabled = !user.disabled; showPassword = false; formError = ''; editOpen = true;
}
async function create(event: SubmitEvent) {
event.preventDefault(); if (busy) return; busy = true; formError = ''; error = ''; notice = '';
try {
const response = await fetch('/api/users', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ username, displayName, password, role }) });
const body = await response.json(); if (!response.ok) throw new Error(body.error ?? 'Could not create this user.');
password = ''; createOpen = false; notice = `${body.user.displayName || body.user.username} was added. They must change the temporary password at sign-in.`;
await Promise.all([load(), loadAudit()]);
} catch (cause) { formError = (cause as Error).message; } finally { busy = false; }
}
async function save(event: SubmitEvent) {
event.preventDefault(); if (busy || !editing) return; busy = true; formError = ''; error = ''; notice = '';
const ownAccessChanged = editing.id === $page.data.user?.id && (role !== editing.role || enabled === editing.disabled || !!password);
try {
const response = await fetch(`/api/users/${encodeURIComponent(editing.id)}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ displayName, role, disabled: !enabled, ...(password ? { password } : {}) }) });
const body = await response.json(); if (!response.ok) throw new Error(body.error ?? 'Could not save user changes.');
password = ''; editOpen = false;
if (ownAccessChanged) { window.location.assign('/login'); return; }
notice = `Changes saved for ${body.user.displayName || body.user.username}.`; await Promise.all([load(), loadAudit()]); await invalidateAll();
} catch (cause) { formError = (cause as Error).message; } finally { busy = false; }
}
async function revoke(user: AuthUser) {
const own = user.id === $page.data.user?.id;
if (!await confirmAction({ title: `Sign out ${user.displayName || user.username}?`, message: own ? 'This will end all of your sessions, including this one. You can sign in again with your password.' : 'This ends all active sessions for this user. Their account and password remain available for a new sign-in.', confirmLabel: 'Sign out sessions', danger: true })) return;
busy = true; error = ''; notice = '';
try {
const response = await fetch(`/api/users/${encodeURIComponent(user.id)}/revoke-sessions`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: '{}' });
const body = await response.json(); if (!response.ok) throw new Error(body.error ?? 'Could not end these sessions.');
if (own) { window.location.assign('/login'); return; }
notice = `All sessions ended for ${user.displayName || user.username}.`;
await loadAudit();
} catch (cause) { error = (cause as Error).message; } finally { busy = false; }
}
</script>
<svelte:head><title>Users · Electric Cherry</title></svelte:head>
<section class="page">
<SectionHeader title="Users" subtitle="Create accounts and manage who can operate your fleet.">
{#snippet actions()}{#if administrator}<Button variant="primary" disabled={busy} onclick={openCreate}>Add user</Button>{/if}{/snippet}
</SectionHeader>
{#if !administrator}<p class="error" role="alert">Only administrators can manage users.</p><a href="/account">Go to your account</a>
{:else}
{#if error}<p class="error" role="alert">{error}</p>{/if}{#if notice}<p class="notice" role="status">{notice}</p>{/if}
<div class="roles" aria-label="Access roles">{#each roles as item}<div><h2>{item.label}</h2><p>{item.description}</p></div>{/each}</div>
{#if loading}<p class="muted" role="status">Loading users…</p>
{:else if !users.length}<section class="panel"><p>No users could be loaded.</p><Button onclick={load}>Try again</Button></section>
{:else}
<div class="user-list" aria-label="User accounts">{#each users as user (user.id)}<article class="user-row">
<div class="user-name"><h2>{user.displayName} {#if user.id === $page.data.user?.id}<span class="you">You</span>{/if}</h2><p>@{user.username}</p></div>
<div class="access"><span class="role">{user.role === 'admin' ? 'Administrator' : user.role}</span><span class:disabled={user.disabled}>{user.disabled ? 'Disabled' : 'Enabled'}</span>{#if user.mustChangePassword && !user.disabled}<small>Password change required</small>{/if}</div>
<div class="last-login"><span>Last sign-in</span><p>{user.lastLoginAt ? new Date(user.lastLoginAt).toLocaleString() : 'Never'}</p></div>
<div class="actions"><Button size="sm" disabled={busy} onclick={() => openEdit(user)}>Edit access</Button><Button size="sm" variant="ghost" disabled={busy} onclick={() => revoke(user)}>Sign out sessions</Button></div>
</article>{/each}</div>
{/if}
<section class="panel audit" aria-labelledby="audit-title">
<div class="audit-heading"><div><h2 id="audit-title">Account activity</h2><p class="muted">The latest 200 sign-in and account events.</p></div><Button size="sm" variant="ghost" disabled={auditLoading} onclick={loadAudit}>Refresh activity</Button></div>
{#if auditError}<p class="error" role="alert">{auditError}</p>{/if}
{#if auditLoading && !events.length}<p class="muted" role="status">Loading account activity…</p>
{:else if !events.length}<p class="muted">No account events recorded.</p>
{:else}<div class="table-wrap"><table><thead><tr><th>Time</th><th>Event</th><th>By</th><th>Account</th><th>Details</th></tr></thead><tbody>
{#each events as event (event.id)}{@const details = auditDetails(event.details)}<tr><td class="event-time">{new Date(event.createdAt).toLocaleString()}</td><td>{actionLabels[event.action] ?? event.action}</td><td>{event.actor ? `@${event.actor}` : 'Unauthenticated'}</td><td>{event.targetId ? users.find(user => user.id === event.targetId)?.username ?? event.targetId : '—'}</td><td>{#if details}<details><summary>View details</summary><pre>{details}</pre></details>{:else}—{/if}</td></tr>{/each}
</tbody></table></div>{/if}
</section>
{/if}
</section>
<Dialog bind:open={createOpen} title="Add user" description="Share the temporary password privately. The user must choose a new password when they first sign in." {busy}>
<form onsubmit={create} aria-busy={busy}><fieldset disabled={busy}>
{#if formError}<p class="error" role="alert">{formError}</p>{/if}
<label for="new-username">Username<input id="new-username" name="username" required minlength="3" maxlength="64" pattern={'[a-zA-Z0-9][a-zA-Z0-9_.\\-]{2,63}'} autocomplete="off" autocapitalize="none" spellcheck="false" bind:value={username} aria-describedby="new-username-help" /></label>
<p class="hint" id="new-username-help">3–64 characters: letters, numbers, periods, underscores, and hyphens. Start with a letter or number.</p>
<label for="new-name">Display name<input id="new-name" name="displayName" required maxlength="100" autocomplete="off" bind:value={displayName} /></label>
<label for="new-role">Role<select id="new-role" name="role" bind:value={role} aria-describedby="new-role-help">{#each roles as item}<option value={item.value}>{item.label}</option>{/each}</select></label><p class="hint" id="new-role-help">{roleDescription}</p>
<label for="temporary-password">Temporary password<input id="temporary-password" name="password" type={showPassword ? 'text' : 'password'} required minlength="15" maxlength="128" autocomplete="new-password" bind:value={password} aria-describedby="temporary-help" /></label>
<p class="hint" id="temporary-help">15–128 characters. Save it before adding the user; it will not be displayed again.</p>
<label class="checkbox"><input type="checkbox" bind:checked={showPassword} />Show temporary password</label>
<div class="form-actions"><Button disabled={busy} variant="ghost" onclick={() => { createOpen = false; password = ''; }}>Cancel</Button><Button type="submit" variant="primary" loading={busy} disabled={busy}>Add user</Button></div>
</fieldset></form>
</Dialog>
<Dialog bind:open={editOpen} title={`Edit ${editing?.username ?? 'user'}`} description="Update access or set a new temporary password." {busy}>
<form onsubmit={save} aria-busy={busy}><fieldset disabled={busy}>
{#if formError}<p class="error" role="alert">{formError}</p>{/if}
<label for="edit-name">Display name<input id="edit-name" name="displayName" required maxlength="100" autocomplete="off" bind:value={displayName} /></label>
<label for="edit-role">Role<select id="edit-role" name="role" bind:value={role} aria-describedby="edit-role-help">{#each roles as item}<option value={item.value}>{item.label}</option>{/each}</select></label><p class="hint" id="edit-role-help">{roleDescription}</p>
<label class="checkbox"><input type="checkbox" bind:checked={enabled} />Account enabled</label><p class="hint">Disabled users cannot sign in. Running game servers continue operating.</p>
{#if editing?.id === $page.data.user?.id}<p class="hint">Use <a href="/account">My account</a> to change your own password. Changes to your access can sign you out.</p>
{:else}<label for="reset-password">New temporary password <span class="optional">(optional)</span><input id="reset-password" name="password" type={showPassword ? 'text' : 'password'} minlength="15" maxlength="128" autocomplete="new-password" bind:value={password} aria-describedby="reset-help" /></label><p class="hint" id="reset-help">Leave blank to keep the current password. A reset requires the user to choose a new password at their next sign-in.</p><label class="checkbox"><input type="checkbox" bind:checked={showPassword} />Show temporary password</label>{/if}
<div class="form-actions"><Button disabled={busy} variant="ghost" onclick={() => { editOpen = false; password = ''; }}>Cancel</Button><Button type="submit" variant="primary" loading={busy} disabled={busy}>Save changes</Button></div>
</fieldset></form>
</Dialog>
<style>
.page { display:grid; gap:24px; } .panel,.roles,.user-list { border:1px solid var(--border-strong); border-radius:var(--radius-md); background:var(--surface); }
.roles { display:grid; grid-template-columns:repeat(3,minmax(0,1fr)); gap:24px; padding:24px; } h2 { margin:0; font-size:var(--text-md); } .roles p,.muted { color:var(--text-secondary); font-size:var(--text-sm); line-height:1.6; } .roles p { margin:8px 0 0; } .panel { padding:24px; }
.user-row { display:grid; grid-template-columns:minmax(180px,1.3fr) minmax(120px,.8fr) minmax(140px,1fr) auto; gap:24px; align-items:center; padding:24px; } .user-row+.user-row { border-top:1px solid var(--border-subtle); } .user-name p { color:var(--text-secondary); font-size:var(--text-sm); margin:6px 0 0; } .you { padding:3px 7px; margin-left:6px; background:var(--surface-raised); border-radius:var(--radius-sm); color:var(--text-secondary); font-size:var(--text-xs); font-weight:normal; }
.access { display:grid; gap:5px; font-size:var(--text-sm); } .access .role { color:var(--text-primary); text-transform:capitalize; } .access span:not(.role),.access small { color:var(--text-secondary); font-size:var(--text-xs); } .access span.disabled { color:var(--state-danger); } .last-login>span { font-size:var(--text-xs); color:var(--text-secondary); } .last-login p { font-size:var(--text-sm); margin:5px 0 0; } .actions { display:flex; gap:8px; flex-wrap:wrap; justify-content:end; }
.audit { min-width:0; } .audit-heading { display:flex; gap:16px; align-items:start; justify-content:space-between; flex-wrap:wrap; } .audit-heading p { margin:6px 0 20px; } .table-wrap { overflow-x:auto; } table { width:100%; border-collapse:collapse; text-align:left; font-size:var(--text-sm); } th,td { padding:12px; border-bottom:1px solid var(--border-subtle); vertical-align:top; } th { color:var(--text-secondary); font-weight:500; } td { overflow-wrap:anywhere; } .event-time { min-width:165px; } details { min-width:120px; } summary { cursor:pointer; color:var(--accent-primary-hover); } pre { white-space:pre-wrap; font-size:var(--text-xs); max-width:320px; }
fieldset { display:grid; gap:16px; border:0; padding:0; margin:0; min-width:0; } label { display:grid; gap:8px; font-size:var(--text-sm); } input:not([type='checkbox']),select { width:100%; padding:11px; color:var(--text-primary); background:var(--surface-sunken); border:1px solid var(--border-control); border-radius:var(--radius-md); font:inherit; } .checkbox { display:flex; align-items:center; gap:10px; } .checkbox input { width:18px; height:18px; accent-color:var(--accent-primary); } .hint { font-size:var(--text-xs); color:var(--text-secondary); line-height:1.6; margin:-8px 0 0; } .optional { color:var(--text-secondary); } .form-actions { display:flex; gap:12px; justify-content:end; margin-top:8px; } .error { color:var(--state-danger); font-size:var(--text-sm); line-height:1.6; margin:0; } .notice { color:var(--state-online); font-size:var(--text-sm); margin:0; }
@media(max-width:1100px) { .user-row { grid-template-columns:minmax(140px,1.3fr) minmax(100px,1fr) minmax(140px,1fr); } .actions { grid-column:1/-1; justify-content:start; } }
@media(max-width:640px) { .roles { grid-template-columns:1fr; gap:20px; } .user-row { grid-template-columns:minmax(0,1fr) minmax(0,1fr); gap:20px; padding:20px; } .user-name { grid-column:1/-1; } .roles,.panel { padding:20px; } }
</style>