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
+4
View File
@@ -0,0 +1,4 @@
import { setupRequired } from '$lib/server/auth/index.js';
import type { LayoutServerLoad } from './$types';
export const load: LayoutServerLoad = ({ locals }) => ({ user: locals.user ?? null, setupRequired: setupRequired() });
+74 -26
View File
@@ -15,17 +15,24 @@
import { resolveConfirmation } from '$ui/confirm.js';
import '$ui/styles/components.css';
import { enableViewTransitions } from '$ui/motion/viewTransition.js';
import type { AuthUser } from '$lib/auth.js';
// Side-effect import — puts @fontsource CSS on Vite's module graph so
// the referenced woff/woff2 files get emitted into the build.
import '$ui/styles/fonts.js';
interface Props { children: import('svelte').Snippet }
let { children }: Props = $props();
interface Props { children: import('svelte').Snippet; data: { user: AuthUser | null; setupRequired: boolean } }
let { children, data }: Props = $props();
const publicPage = $derived(['/login', '/setup'].includes($page.url.pathname));
const sessionReady = $derived(!!data.user && !data.user.mustChangePassword && !publicPage);
let menuOpen = $state(false);
let accountOpen = $state(false);
let signingOut = $state(false);
let signOutError = $state('');
let notificationsOpen = $state(false);
let helpOpen = $state(false);
const unread = $derived($notificationHistory.filter(item => !item.read).length);
let menuButton: HTMLButtonElement;
let menuButton = $state<HTMLButtonElement>();
let accountButton = $state<HTMLButtonElement>();
function openUtility(panel: 'notifications' | 'help') {
if (menuOpen) { menuButton?.focus(); menuOpen = false; }
@@ -33,32 +40,51 @@
else helpOpen = true;
}
afterNavigate(() => { menuOpen = false; resolveConfirmation(false); });
afterNavigate(() => { menuOpen = false; accountOpen = false; resolveConfirmation(false); });
function closeMenu(event: KeyboardEvent) {
if (event.key === 'Escape' && accountOpen) { accountOpen = false; accountButton?.focus(); }
if (event.key === 'Escape' && menuOpen) {
menuOpen = false;
menuButton?.focus();
}
}
onMount(() => {
const stopTimeZone = startTimeZonePreference();
async function signOut() {
if (signingOut) return;
signingOut = true; signOutError = '';
try {
const response = await fetch('/api/auth/logout', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: '{}' });
if (!response.ok) { const body = await response.json(); throw new Error(body.error ?? 'Could not sign out. Try again.'); }
try { sessionStorage.removeItem('electric-cherry:notifications'); } catch { /* Optional local history. */ }
notificationHistory.set([]);
window.location.assign('/login');
} catch (cause) { signOutError = (cause as Error).message; signingOut = false; }
}
$effect(() => {
if (!sessionReady) return;
const stopNotifications = startNotificationHistory();
const stopPipeline = startPipelineStream();
const stopServers = startServerStream();
const stopFleetMonitor = startFleetMonitor();
const stopFleet = startFleetMonitor();
return () => { stopNotifications(); stopFleet(); stopPipeline?.(); stopServers?.(); };
});
onMount(() => {
const stopTimeZone = startTimeZonePreference();
// Two-key nav shortcuts (Gmail-style): `g f` fleet, `g d` provisioning,
// `g b` betas, `g s` scripts. Ignored while typing in inputs.
const shortcuts: Record<string, string> = {
f: '/', d: '/provisioning', a: '/campaigns', w: '/maintenance', b: '/betas',
k: '/server-keys', s: '/scripts', c: '/configs', p: '/plugins', l: '/leaderboard', h: '/settings', m: '/metrics'
k: '/server-keys', s: '/scripts', c: '/configs', p: '/plugins', l: '/leaderboard', h: '/settings', m: '/metrics', o: '/hosts'
};
let armed = false;
let armTimer: ReturnType<typeof setTimeout> | null = null;
const onKey = (e: KeyboardEvent) => {
if (!sessionReady) return;
const target = e.target as HTMLElement;
if (target && (['INPUT', 'TEXTAREA', 'SELECT'].includes(target.tagName) || target.isContentEditable || target.closest('dialog'))) return;
if (e.metaKey || e.ctrlKey || e.altKey) return;
@@ -74,6 +100,7 @@
armed = false;
if (armTimer) { clearTimeout(armTimer); armTimer = null; }
const dest = shortcuts[e.key];
if ((e.key === 'h' || e.key === 'k') && data.user?.role !== 'admin') return;
if (dest) {
e.preventDefault();
goto(dest);
@@ -83,10 +110,6 @@
window.addEventListener('keydown', onKey);
return () => {
stopTimeZone();
stopNotifications();
stopFleetMonitor();
stopPipeline?.();
stopServers?.();
window.removeEventListener('keydown', onKey);
if (armTimer) clearTimeout(armTimer);
};
@@ -98,7 +121,8 @@
const tabs = [
{ href: '/', label: 'Fleet', match: (p: string) => p === '/' || p.startsWith('/servers/') },
{ href: '/scripts', label: 'Library', match: (p: string) => /^\/(scripts|configs|plugins)(\/|$)/.test(p) },
{ href: '/hosts', label: 'Hosts', match: (p: string) => /^\/hosts(\/|$)/.test(p) },
{ href: '/scripts', label: 'Library', match: (p: string) => /^\/(scripts|configs|plugins|game-files)(\/|$)/.test(p) },
{ href: '/provisioning', label: 'Operations', match: (p: string) => /^\/(provisioning|deployments|collections|campaigns|maintenance|watchdog|server-keys)(\/|$)/.test(p) },
{ href: '/betas', label: 'Betas', match: (p: string) => p.startsWith('/betas') },
{ href: '/settings', label: 'Settings', match: (p: string) => p.startsWith('/settings') },
@@ -106,29 +130,33 @@
{ href: '/matches', label: 'Match History', match: (p: string) => p.startsWith('/matches') },
{ href: '/metrics', label: 'Metrics', match: (p: string) => p === '/metrics' }
];
const secondary = $derived(tabs[1].match($page.url.pathname)
? [{ href: '/scripts', label: 'Scripts' }, { href: '/configs', label: 'Configs' }, { href: '/plugins', label: 'Plugins' }]
: tabs[2].match($page.url.pathname)
? [{ href: '/provisioning', label: 'Provisioning' }, { href: '/collections', label: 'Collections' }, { href: '/campaigns', label: 'Campaigns' }, { href: '/maintenance', label: 'Maintenance' }, { href: '/server-keys', label: 'Keys' }]
: tabs[4].match($page.url.pathname) ? [{ href: '/settings', label: 'Preferences' }] : []);
const visibleTabs = $derived(tabs.filter(tab => tab.label !== 'Settings' || data.user?.role === 'admin'));
const secondary = $derived(tabs.find(tab => tab.label === 'Library')!.match($page.url.pathname)
? [{ href: '/scripts', label: 'Scripts' }, { href: '/configs', label: 'Configs' }, { href: '/plugins', label: 'Plugins' }, { href: '/game-files', label: 'Game Files' }]
: tabs.find(tab => tab.label === 'Operations')!.match($page.url.pathname)
? [{ href: '/provisioning', label: 'Provisioning' }, { href: '/collections', label: 'Collections' }, { href: '/campaigns', label: 'Campaigns' }, { href: '/maintenance', label: 'Maintenance' }, ...(data.user?.role === 'admin' ? [{ href: '/server-keys', label: 'Keys' }] : [])]
: tabs.find(tab => tab.label === 'Settings')!.match($page.url.pathname) ? [{ href: '/settings', label: 'Preferences' }, ...(data.user?.role === 'admin' ? [{ href: '/settings/users', label: 'Users' }] : [])] : []);
</script>
<svelte:window onkeydown={closeMenu} />
<div class="shell">
<a class="skip-link" href="#main-content">Skip to content</a>
{#if publicPage}
<header class="auth-brand"><BrandMark size="md" /></header>
{:else}
<nav aria-label="Primary">
<a class="brand-link" href="/" aria-label="Electric Cherry home">
<a class="brand-link" href={sessionReady ? '/' : '/account'} aria-label="Electric Cherry home">
<BrandMark size="sm" />
</a>
<button class="menu-toggle" bind:this={menuButton} type="button"
{#if sessionReady}<button class="menu-toggle" bind:this={menuButton} type="button"
aria-expanded={menuOpen} aria-controls="primary-links"
onclick={() => { menuOpen = !menuOpen; }}>
{menuOpen ? 'Close' : 'Menu'}
<span aria-hidden="true">{menuOpen ? '×' : '☰'}</span>
</button>
<div class="tabs" id="primary-links" class:expanded={menuOpen}>
{#each tabs as t (t.href)}
{#each visibleTabs as t (t.href)}
{@const on = t.match($page.url.pathname)}
<a
href={t.href}
@@ -138,18 +166,25 @@
<span class="lbl">{t.label}</span>
</a>
{/each}
</div>{/if}
<div class="nav-tools" class:expanded={menuOpen} class:account-only={!sessionReady}>
{#if sessionReady}<button class="utility" aria-label={`Recent notifications${unread ? `, ${unread} unread` : ''}`} onclick={() => openUtility('notifications')}>Activity{unread ? ` · ${unread}` : ''}</button><button class="utility" aria-label="Keyboard shortcuts" title="Keyboard shortcuts (?)" onclick={() => openUtility('help')}>?</button>{/if}
{#if data.user}<div class="account-menu"><button bind:this={accountButton} class="utility account-button" aria-expanded={accountOpen} aria-controls="account-links" onclick={() => accountOpen = !accountOpen}>{data.user.displayName || data.user.username}<span class="role">{data.user.role}</span></button>
{#if accountOpen}<div class="account-links" id="account-links"><a href="/account">My account</a>{#if data.user.role === 'admin' && sessionReady}<a href="/settings/users">Manage users</a>{/if}<button disabled={signingOut} onclick={signOut}>{signingOut ? 'Signing out…' : 'Sign out'}</button>{#if signOutError}<p role="alert">{signOutError}</p>{/if}</div>{/if}
</div>{/if}
</div>
<div class="nav-tools" class:expanded={menuOpen}><button class="utility" aria-label={`Recent notifications${unread ? `, ${unread} unread` : ''}`} onclick={() => openUtility('notifications')}>Activity{unread ? ` · ${unread}` : ''}</button><button class="utility" aria-label="Keyboard shortcuts" title="Keyboard shortcuts (?)" onclick={() => openUtility('help')}>?</button></div>
</nav>
{#if secondary.length}<div class="subnav"><div role="navigation" aria-label="Section">{#each secondary as item}<a href={item.href} aria-current={$page.url.pathname.startsWith(item.href) ? 'page' : undefined}>{item.label}</a>{/each}</div></div>{/if}
<main id="main-content" tabindex="-1">
{#if sessionReady && secondary.length}<div class="subnav"><div role="navigation" aria-label="Section">{#each secondary as item}<a href={item.href} aria-current={item.href === '/settings' ? $page.url.pathname === item.href ? 'page' : undefined : $page.url.pathname.startsWith(item.href) ? 'page' : undefined}>{item.label}</a>{/each}</div></div>{/if}
{/if}
<main id="main-content" tabindex="-1" class:auth-main={publicPage}>
{#if sessionReady && data.user?.role === 'viewer'}<aside class="viewer-note"><strong>Viewer access</strong><span>You can read fleet status and summaries. Operating servers or viewing raw configs, file changes, logs, and telemetry requires an operator account.</span></aside>{/if}
{@render children()}
</main>
<Toaster />
<Confirmation />
<NotificationHistory bind:open={notificationsOpen} />
<Dialog bind:open={helpOpen} title="Keyboard shortcuts" description="Press g, then the destination key. Shortcuts are inactive while typing or using a dialog.">
<dl class="shortcuts">{#each [['g f','Fleet'],['g s','Scripts'],['g c','Configs'],['g p','Plugins'],['g d','Provisioning'],['g a','Campaigns'],['g w','Maintenance'],['g b','Betas'],['g h','Settings'],['g k','Keys'],['g l','Leaderboard'],['g m','Metrics'],['?','This help']] as [keys,label]}<div><dt><kbd>{keys}</kbd></dt><dd>{label}</dd></div>{/each}</dl>
<dl class="shortcuts">{#each [['g f','Fleet'],['g o','Hosts'],['g s','Scripts'],['g c','Configs'],['g p','Plugins'],['g d','Provisioning'],['g a','Campaigns'],['g w','Maintenance'],['g b','Betas'],...(data.user?.role === 'admin' ? [['g h','Settings'],['g k','Keys']] : []),['g l','Leaderboard'],['g m','Metrics'],['?','This help']] as [keys,label]}<div><dt><kbd>{keys}</kbd></dt><dd>{label}</dd></div>{/each}</dl>
</Dialog>
</div>
@@ -193,6 +228,16 @@
}
.shell { min-height: 100vh; }
.auth-brand { display:flex; justify-content:center; padding:48px 20px 20px; }
main.auth-main { max-width:520px; padding-top:16px; }
.viewer-note { display:flex; gap:8px 16px; flex-wrap:wrap; padding:14px 18px; margin-bottom:24px; border:1px solid var(--border-subtle); border-radius:var(--radius-md); background:var(--surface); font-size:var(--text-sm); color:var(--text-secondary); } .viewer-note strong { color:var(--text-primary); }
.account-menu { position:relative; }
.account-button { display:flex; align-items:center; gap:8px; max-width:220px; overflow:hidden; text-overflow:ellipsis; }
.role { text-transform:capitalize; color:var(--text-tertiary); font-size:var(--text-xs); }
.account-links { position:absolute; right:0; top:calc(100% + 8px); min-width:200px; z-index:30; display:grid; padding:8px; background:var(--surface); border:1px solid var(--border-strong); border-radius:var(--radius-md); box-shadow:0 12px 32px #0005; }
.account-links a,.account-links button { text-align:left; padding:10px 12px; color:var(--text-primary); background:none; border:0; border-radius:var(--radius-sm); font:inherit; font-size:var(--text-sm); cursor:pointer; text-decoration:none; }
.account-links a:hover,.account-links button:hover { background:var(--surface-raised); }
.account-links p { padding:0 12px; font-size:var(--text-xs); color:var(--state-danger); }
nav {
position: sticky;
@@ -272,7 +317,7 @@
}
/* The full navigation needs more room than a phone or portrait tablet. */
@media (max-width: 1200px) {
@media (max-width: 1500px) {
nav {
flex-wrap: wrap;
justify-content: space-between;
@@ -303,6 +348,9 @@
.tabs a { display: flex; align-items: center; min-height: 44px; border-radius: var(--radius-md); }
.nav-tools { display: none; }
.nav-tools.expanded { display: flex; width: 100%; padding-top: 8px; }
.nav-tools.account-only { display:flex; }
.account-menu { margin-left:auto; }
.account-links { position:static; margin-top:8px; }
}
@media (max-width: 720px) {
main { padding: var(--space-6) max(var(--space-4), env(safe-area-inset-right)) calc(var(--space-16) + env(safe-area-inset-bottom)) max(var(--space-4), env(safe-area-inset-left)); }
+8 -5
View File
@@ -31,6 +31,7 @@
let deployOpen = $state(false);
let initialGame = $state<ZombieGame>('t5');
let initialMapSlug = $state('');
const canOperate = $derived($page.data.user?.role === 'admin' || $page.data.user?.role === 'operator');
const filters: { id: FleetFilter; label: string }[] = [
{ id: 'all', label: 'All' }, { id: 'occupied', label: 'Occupied' }, { id: 'empty', label: 'Empty' },
{ id: 'attention', label: 'Needs attention' }, { id: 'testing', label: 'Testing' }, { id: 'pinned', label: 'Pinned' }
@@ -128,10 +129,10 @@
<SectionHeader title="Fleet" subtitle="Live health, players, and server operations.">
{#snippet actions()}
<Button variant="ghost" size="sm" loading={$fleetRefreshing} onclick={() => void refreshFleet()}>Refresh</Button>
<Button variant="primary" onclick={() => launch()}>Create server</Button>
{#if canOperate}<Button variant="primary" onclick={() => launch()}>Create server</Button>{/if}
{/snippet}
</SectionHeader>
<DeployServerDialog bind:open={deployOpen} {initialGame} {initialMapSlug} />
{#if canOperate}<DeployServerDialog bind:open={deployOpen} {initialGame} {initialMapSlug} />{/if}
{#if $fleetError}
<div class="error" role="alert"><strong>{$fleet.receivedAt ? 'Showing the last available snapshot.' : 'Fleet is unavailable.'}</strong> {$fleetError}
@@ -148,7 +149,9 @@
<a class="metric-link" class:no-campaigns={!campaigns} href="/campaigns"><strong>{campaigns}</strong> active campaigns</a>
<span class="freshness">{$fleetError ? 'Reconnecting' : 'Updated'} · {snapshotAge < 60 ? `${snapshotAge}s` : `${Math.floor(snapshotAge / 60)}m`} ago</span>
</div>{/if}
{#if $fleetIntegrations && (!$fleetIntegrations.komodo || !$fleetIntegrations.rcon)}
{#if $fleetIntegrations?.native && !$fleetIntegrations.hostsReady}
<div class="setup-note"><span><strong>Prepare a game host</strong> · {$fleetIntegrations.hostsTotal ? 'Hosts are connected or enrolled but need setup before new deployments.' : 'Add a host, prepare its runtime, and download game files.'}</span><a href="/hosts">Review hosts →</a></div>
{:else if $page.data.user?.role === 'admin' && $fleetIntegrations && !$fleetIntegrations.native && (!$fleetIntegrations.komodo || !$fleetIntegrations.rcon)}
<div class="setup-note"><span><strong>Finish system setup</strong> · {!$fleetIntegrations.komodo ? 'Komodo' : ''}{!$fleetIntegrations.komodo && !$fleetIntegrations.rcon ? ' and ' : ''}{!$fleetIntegrations.rcon ? 'RCON' : ''} not configured.</span><a href="/settings">Review setup →</a></div>
{/if}
@@ -176,7 +179,7 @@
{#if servers.length === 0}
<EmptyState title="No servers deployed">
{#snippet actions()}
<Button variant="primary" onclick={() => launch()}>Create a server</Button>
{#if canOperate}<Button variant="primary" onclick={() => launch()}>Create a server</Button>{/if}
<Button href="/collections" variant="ghost">Browse collections</Button>
{/snippet}
</EmptyState>
@@ -205,7 +208,7 @@
{#each groups as [key, group] (key)}
<section class="map-group" aria-label={group.title}>
{#if grouping !== 'none'}<div class="group-heading"><div>{#if grouping === 'map'}<span class="eyebrow">{group.game.toUpperCase()}</span>{/if}<h2>{group.title}</h2><p>{group.servers.length} {group.servers.length === 1 ? 'lobby' : 'lobbies'} · {group.servers.some(s => freshServer(s, $uiNow)) ? group.servers.reduce((sum, s) => sum + (freshServer(s, $uiNow) ? s.last_players ?? 0 : 0), 0) : '—'} players · {group.servers.filter(s => freshServer(s, $uiNow) && s.last_players === 0).length} empty</p></div>
{#if group.launch}<Button size="sm" onclick={() => launch(group.launch)}>Add another lobby</Button>{/if}
{#if canOperate && group.launch}<Button size="sm" onclick={() => launch(group.launch)}>Add another lobby</Button>{/if}
</div>{/if}
{#if view === 'cards'}
<div class="cards">{#each group.servers as server (server.name)}
+57
View File
@@ -0,0 +1,57 @@
<script lang="ts">
import { page } from '$app/stores';
import { invalidateAll } from '$app/navigation';
import Button from '$ui/components/primitives/Button.svelte';
import SectionHeader from '$ui/components/primitives/SectionHeader.svelte';
let currentPassword = $state(''); let newPassword = $state(''); let confirmPassword = $state('');
let busy = $state(false); let signingOut = $state(false); let error = $state(''); let notice = $state('');
const user = $derived($page.data.user);
async function changePassword(event: SubmitEvent) {
event.preventDefault(); if (busy || signingOut) return; error = ''; notice = '';
if (newPassword !== confirmPassword) { error = 'The new passwords do not match.'; return; }
const required = user?.mustChangePassword;
busy = true;
try {
const response = await fetch('/api/auth/password', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ currentPassword, newPassword }) });
const body = await response.json(); if (!response.ok) throw new Error(body.error ?? 'Could not change your password.');
currentPassword = ''; newPassword = ''; confirmPassword = '';
if (required) { window.location.assign('/'); return; }
await invalidateAll(); notice = 'Your password has been changed.';
} catch (cause) { error = (cause as Error).message; } finally { busy = false; }
}
async function logout() {
if (busy || signingOut) return; signingOut = true; error = '';
try {
const response = await fetch('/api/auth/logout', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: '{}' });
if (!response.ok) { const body = await response.json(); throw new Error(body.error ?? 'Could not sign out.'); }
try { sessionStorage.removeItem('electric-cherry:notifications'); } catch { /* Optional history. */ }
window.location.assign('/login');
} catch (cause) { error = (cause as Error).message; signingOut = false; }
}
</script>
<svelte:head><title>My account · Electric Cherry</title></svelte:head>
<section class="page"><SectionHeader title="My account" subtitle="Manage your password and current session." />
{#if user}
<section class="panel identity"><div><h2>{user.displayName}</h2><p>@{user.username} · <span class="role">{user.role}</span></p></div><Button disabled={busy || signingOut} loading={signingOut} onclick={logout}>Sign out</Button></section>
{#if user.mustChangePassword}<p class="required" role="status">Your account uses a temporary password. Choose a new password before continuing to Electric Cherry.</p>{/if}
<section class="panel"><h2>{user.mustChangePassword ? 'Choose your password' : 'Change password'}</h2>
{#if error}<p class="error" role="alert">{error}</p>{/if}{#if notice}<p class="notice" role="status">{notice}</p>{/if}
<form onsubmit={changePassword} aria-busy={busy}><fieldset disabled={busy || signingOut}>
<input type="text" class="password-username" value={user.username} autocomplete="username" aria-label="Username" readonly tabindex="-1" />
<label for="current-password">{user.mustChangePassword ? 'Temporary password' : 'Current password'}<input id="current-password" name="currentPassword" type="password" autocomplete="current-password" required maxlength="128" bind:value={currentPassword} /></label>
<label for="new-password">New password<input id="new-password" name="newPassword" type="password" autocomplete="new-password" required minlength="15" maxlength="128" bind:value={newPassword} aria-describedby="new-password-help" /></label>
<p id="new-password-help" class="hint">Use 15–128 characters and a password you do not use elsewhere.</p>
<label for="confirm-password">Confirm new password<input id="confirm-password" name="confirmPassword" type="password" autocomplete="new-password" required minlength="15" maxlength="128" bind:value={confirmPassword} /></label>
<div><Button type="submit" variant="primary" loading={busy} disabled={busy || signingOut}>{user.mustChangePassword ? 'Save password and continue' : 'Change password'}</Button></div>
</fieldset></form>
</section>
{/if}
</section>
<style>
.page { max-width:760px; display:grid; gap:24px; } .panel { padding:24px; background:var(--surface); border:1px solid var(--border-strong); border-radius:var(--radius-md); } h2 { font-size:var(--text-md); margin:0; } .identity { display:flex; align-items:center; justify-content:space-between; gap:20px; } .identity p { margin:8px 0 0; color:var(--text-secondary); font-size:var(--text-sm); } .role { text-transform:capitalize; }
.required { padding:16px; margin:0; color:var(--text-primary); background:var(--surface-raised); border-left:3px solid var(--accent-primary); font-size:var(--text-sm); line-height:1.6; }
form { margin-top:24px; max-width:460px; } fieldset { border:0; margin:0; padding:0; display:grid; gap:18px; min-width:0; } label { display:grid; gap:8px; font-size:var(--text-sm); } input { width:100%; padding:12px; color:var(--text-primary); background:var(--surface-sunken); border:1px solid var(--border-control); border-radius:var(--radius-md); font:inherit; } .password-username { position:absolute; width:1px; height:1px; clip-path:inset(50%); overflow:hidden; padding:0; border:0; } .hint { margin:-8px 0 0; color:var(--text-secondary); font-size:var(--text-xs); line-height:1.6; } .error { color:var(--state-danger); font-size:var(--text-sm); } .notice { color:var(--state-online); font-size:var(--text-sm); }
@media(max-width:540px) { .identity { align-items:flex-start; flex-direction:column; } }
</style>
@@ -0,0 +1,12 @@
import { json } from '@sveltejs/kit';
import { rotateCredential } from '$lib/server/hosts/index.js';
import { authenticateAgent, hostApiError, objectBody } from '$lib/server/hosts/http.js';
export async function POST({ request }: { request: Request }) {
const host = authenticateAgent(request);
try {
const body = await objectBody(request);
if (typeof body.credential !== 'string') throw new Error('Persist a randomly generated replacement credential before requesting rotation.');
rotateCredential(host.id, body.credential);
return json({ ok: true }, { headers: { 'Cache-Control': 'no-store' } });
} catch (cause) { return hostApiError(cause); }
}
+8
View File
@@ -0,0 +1,8 @@
import { json } from '@sveltejs/kit';
import { enrollHost } from '$lib/server/hosts/index.js';
import { hostApiError, objectBody, requireAgentTransport } from '$lib/server/hosts/http.js';
export async function POST({ request }: { request: Request }) {
requireAgentTransport(request);
try { const body = await objectBody(request); return json(enrollHost(body.token, body.inventory), { headers: { 'Cache-Control': 'no-store' } }); }
catch (cause) { return hostApiError(cause); }
}
+16
View File
@@ -0,0 +1,16 @@
import { json } from '@sveltejs/kit';
import { heartbeat } from '$lib/server/hosts/index.js';
import { authenticateAgent, hostApiError, objectBody } from '$lib/server/hosts/http.js';
export async function POST({ request }: { request: Request }) {
const authenticated = authenticateAgent(request);
try {
const body = await objectBody(request);
const host = heartbeat(authenticated.id, body.inventory);
const inventory = body.inventory as Record<string, unknown>;
if (Array.isArray(inventory.libraryCaches)) {
const { updateHostLibraryCaches } = await import('$lib/server/game-files/index.js');
updateHostLibraryCaches(host.id, inventory.libraryCaches, { snapshot: true });
}
return json({ host, heartbeatSeconds: 15, serverTime: new Date().toISOString() });
} catch (cause) { return hostApiError(cause); }
}
@@ -0,0 +1,15 @@
import { json } from '@sveltejs/kit';
import { authenticateAgent } from '$lib/server/hosts/http.js';
import { requireImportOwner, setImportManifest } from '$lib/server/game-files/index.js';
import { gameFileError, readGameJson } from '$lib/server/game-files/http.js';
export function GET({ request, params }: { request: Request; params: { id: string } }) {
try { return json(requireImportOwner(authenticateAgent(request).id, params.id)); }
catch (cause) { return gameFileError(cause); }
}
export async function PUT({ request, params }: { request: Request; params: { id: string } }) {
try {
const host = authenticateAgent(request);
const body = await readGameJson(request, 64 * 1024 * 1024);
return json(setImportManifest(host.id, params.id, body.files));
} catch (cause) { return gameFileError(cause); }
}
@@ -0,0 +1,15 @@
import { json } from '@sveltejs/kit';
import { authenticateAgent } from '$lib/server/hosts/http.js';
import { importPublicationStatus, requestImportPublication } from '$lib/server/game-files/index.js';
import { gameFileError } from '$lib/server/game-files/http.js';
export function POST({ request, params }: { request: Request; params: { id: string } }) {
try {
const result = requestImportPublication(authenticateAgent(request).id, params.id);
return json(result, { status: result.status === 'verifying' || result.status === 'pending' ? 202 : 200 });
}
catch (cause) { return gameFileError(cause); }
}
export function GET({ request, params }: { request: Request; params: { id: string } }) {
try { return json(importPublicationStatus(authenticateAgent(request).id, params.id)); }
catch (cause) { return gameFileError(cause); }
}
@@ -0,0 +1,11 @@
import { json } from '@sveltejs/kit';
import { authenticateAgent } from '$lib/server/hosts/http.js';
import { uploadImportFile } from '$lib/server/game-files/index.js';
import { gameFileError } from '$lib/server/game-files/http.js';
export async function PUT({ request, params }: { request: Request; params: { id: string; path: string } }) {
try {
const host = authenticateAgent(request);
await uploadImportFile(host.id, params.id, params.path, request.body);
return json({ uploaded: true });
} catch (cause) { return gameFileError(cause); }
}
@@ -0,0 +1,2 @@
import { agentReleaseAsset } from '$lib/server/hosts/releases.js';
export async function GET() { return new Response(await agentReleaseAsset('install.sh'), { headers: { 'Content-Type': 'text/x-shellscript', 'Cache-Control': 'no-cache' } }); }
@@ -0,0 +1,7 @@
import { authenticateAgent } from '$lib/server/hosts/http.js';
import { gameFileError, serveGameFile } from '$lib/server/game-files/http.js';
export async function GET({ request, params }: { request: Request; params: { id: string; path: string } }) {
try { authenticateAgent(request); return await serveGameFile(request, params.id, params.path); }
catch (cause) { return gameFileError(cause); }
}
export const HEAD = GET;
@@ -0,0 +1,12 @@
import { json } from '@sveltejs/kit';
import { authenticateAgent } from '$lib/server/hosts/http.js';
import { getLibraryVersion } from '$lib/server/game-files/index.js';
import { gameFileError } from '$lib/server/game-files/http.js';
export function GET({ request, params }: { request: Request; params: { id: string } }) {
try {
authenticateAgent(request);
const version = getLibraryVersion(params.id);
if (!version || version.state !== 'ready') return json({ error: 'Published library not found.' }, { status: 404 });
return json({ id: version.id, game: version.game, files: version.files, sizeBytes: version.sizeBytes, manifestSha256: version.manifestSha256 });
} catch (cause) { return gameFileError(cause); }
}
@@ -0,0 +1,11 @@
import { json } from '@sveltejs/kit';
import { authenticateAgent } from '$lib/server/hosts/http.js';
import { updateHostLibraryCaches } from '$lib/server/game-files/index.js';
import { gameFileError, readGameJson } from '$lib/server/game-files/http.js';
export async function POST({ request, params }: { request: Request; params: { id: string } }) {
try {
const host = authenticateAgent(request); const body = await readGameJson(request);
updateHostLibraryCaches(host.id, [{ ...body, libraryVersionId: params.id }]);
return json({ ok: true });
} catch (cause) { return gameFileError(cause); }
}
@@ -0,0 +1,7 @@
import { json } from '@sveltejs/kit';
import { pendingHostOperations } from '$lib/server/hosts/index.js';
import { authenticateAgent } from '$lib/server/hosts/http.js';
export function GET({ request }: { request: Request }) {
const host = authenticateAgent(request);
return json({ operations: pendingHostOperations(host.id), serverTime: new Date().toISOString() }, { headers: { 'Cache-Control': 'no-store' } });
}
@@ -0,0 +1,8 @@
import { json } from '@sveltejs/kit';
import { reportHostOperation } from '$lib/server/hosts/index.js';
import { authenticateAgent, hostApiError, objectBody } from '$lib/server/hosts/http.js';
export async function POST({ request, params }: { request: Request; params: { id: string } }) {
const host = authenticateAgent(request);
try { const body = await objectBody(request, 128 * 1024 * 1024); const operation = reportHostOperation(host.id, params.id, { state: body.state, result: body.result, error: body.error }); return json({ operation: { id: operation.id, state: operation.state } }); }
catch (cause) { return hostApiError(cause); }
}
@@ -0,0 +1,6 @@
import { error } from '@sveltejs/kit';
import { agentReleaseAsset } from '$lib/server/hosts/releases.js';
export async function GET({ params }: { params: { file: string } }) {
if (!['ec-agent.py','manifest.json'].includes(params.file)) error(404, 'Release asset not found.');
return new Response(await agentReleaseAsset(params.file as 'ec-agent.py' | 'manifest.json'), { headers: { 'Content-Type': params.file.endsWith('.json') ? 'application/json' : 'text/plain', 'Cache-Control': 'no-cache' } });
}
+10
View File
@@ -0,0 +1,10 @@
import type { RequestHandler } from './$types';
import { authenticatePassword, revokeSession } from '$lib/server/auth/index.js';
import { authBody, authError, sessionCookie, sessionResponse } from '$lib/server/auth/http.js';
export const POST: RequestHandler = async ({ request, cookies, url, getClientAddress }) => {
try {
const result = await authenticatePassword(await authBody(request),getClientAddress());
revokeSession(cookies.get(sessionCookie(url)));
return sessionResponse(result,cookies,url);
} catch (cause) { return authError(cause); }
};
+9
View File
@@ -0,0 +1,9 @@
import { json, type RequestHandler } from '@sveltejs/kit';
import { recordAudit, revokeSession } from '$lib/server/auth/index.js';
import { sessionCookie } from '$lib/server/auth/http.js';
export const POST: RequestHandler = ({ cookies, url, locals }) => {
revokeSession(cookies.get(sessionCookie(url)));
cookies.delete(sessionCookie(url),{path:'/'});
recordAudit(locals.user?.id??null,'logout',locals.user?.id??null);
return json({ ok:true });
};
+2
View File
@@ -0,0 +1,2 @@
import { json, type RequestHandler } from '@sveltejs/kit';
export const GET: RequestHandler = ({ locals }) => json({ user:locals.user },{headers:{'Cache-Control':'no-store'}});
+11
View File
@@ -0,0 +1,11 @@
import type { RequestHandler } from './$types';
import { changePassword, consumeAuthAttempt } from '$lib/server/auth/index.js';
import { authBody, authError, requireUser, sessionResponse } from '$lib/server/auth/http.js';
export const POST: RequestHandler = async ({ request, cookies, locals, url, getClientAddress }) => {
try {
const user = requireUser(locals.user);
consumeAuthAttempt(getClientAddress(),user.id,'password');
const body = await authBody(request);
return sessionResponse(await changePassword(user.id,body.currentPassword,body.newPassword),cookies,url);
} catch (cause) { return authError(cause); }
};
+7
View File
@@ -0,0 +1,7 @@
import type { RequestHandler } from './$types';
import { bootstrapAdmin } from '$lib/server/auth/index.js';
import { authBody, authError, sessionResponse } from '$lib/server/auth/http.js';
export const POST: RequestHandler = async ({ request, cookies, url, getClientAddress }) => {
try { return sessionResponse(await bootstrapAdmin(await authBody(request),getClientAddress()),cookies,url); }
catch (cause) { return authError(cause); }
};
@@ -4,6 +4,6 @@ import { previewCollection } from '$lib/server/fleet/collections.js';
export async function POST({ request }: { request: Request }) {
try {
const body = await request.json();
return json(previewCollection(body?.blueprintId, body?.mode));
return json(previewCollection(body?.blueprintId, body?.mode, body));
} catch (err) { return json({ error: (err as Error).message }, { status: 400 }); }
}
+10
View File
@@ -0,0 +1,10 @@
import { json } from '@sveltejs/kit';
import { createLibraryImport, libraryOverview } from '$lib/server/game-files/index.js';
import { gameFileError, readGameJson } from '$lib/server/game-files/http.js';
import { requireManagementOrigin } from '$lib/server/hosts/http.js';
export function GET() { return json(libraryOverview()); }
export async function POST({ request }: { request: Request }) {
try { requireManagementOrigin(request); return json({ version: createLibraryImport(await readGameJson(request)) }, { status: 202 }); }
catch (cause) { return gameFileError(cause); }
}
@@ -0,0 +1,6 @@
import { json } from '@sveltejs/kit';
import { getLibraryVersion } from '$lib/server/game-files/index.js';
export function GET({ params }: { params: { id: string } }) {
const version = getLibraryVersion(params.id);
return version ? json({ version }) : json({ error: 'Library version not found.' }, { status: 404 });
}
@@ -0,0 +1,12 @@
import { json } from '@sveltejs/kit';
import { queueLibraryDownload } from '$lib/server/game-files/index.js';
import { gameFileError, readGameJson } from '$lib/server/game-files/http.js';
import { requireManagementOrigin } from '$lib/server/hosts/http.js';
export async function POST({ request, params }: { request: Request; params: { id: string } }) {
try {
requireManagementOrigin(request);
const body = await readGameJson(request);
if (typeof body.hostId !== 'string') throw new Error('Choose a target host.');
return json({ operation: queueLibraryDownload(body.hostId, params.id) }, { status: 202 });
} catch (cause) { return gameFileError(cause); }
}
+13
View File
@@ -0,0 +1,13 @@
import { json } from '@sveltejs/kit';
import { createEnrollment, createHost, listHosts } from '$lib/server/hosts/index.js';
import { hostApiError, installerCommand, objectBody, requireManagementOrigin } from '$lib/server/hosts/http.js';
export function GET({ request }: { request: Request }) { requireManagementOrigin(request); return json({ hosts: listHosts() }); }
export async function POST({ request }: { request: Request }) {
requireManagementOrigin(request);
try {
const host = createHost(await objectBody(request));
const enrollment = createEnrollment(host.id);
return json({ host, enrollment: { ...enrollment, command: installerCommand(request.url, enrollment.token) } }, { status: 201, headers: { 'Cache-Control': 'no-store' } });
} catch (cause) { return hostApiError(cause); }
}
+14
View File
@@ -0,0 +1,14 @@
import { error, json } from '@sveltejs/kit';
import { getHost, listHostOperations, updateHost } from '$lib/server/hosts/index.js';
import { hostApiError, objectBody, requireManagementOrigin } from '$lib/server/hosts/http.js';
type Event = { request: Request; params: { id: string } };
export function GET({ request, params }: Event) {
requireManagementOrigin(request); const host = getHost(params.id); if (!host) error(404, 'Host not found.');
// Payloads and results can contain RCON keys or staged credentials. History
// exposes metadata only; agent APIs remain the only command delivery path.
return json({ host, operations: listHostOperations(host.id).map(({ payload: _payload, result: _result, ...operation }) => operation) });
}
export async function PATCH({ request, params }: Event) {
requireManagementOrigin(request);
try { return json({ host: updateHost(params.id, await objectBody(request)) }); } catch (cause) { return hostApiError(cause); }
}
@@ -0,0 +1,12 @@
import { json } from '@sveltejs/kit';
import { queueHostOperation, revokeCredentials } from '$lib/server/hosts/index.js';
import { hostApiError, requireManagementOrigin } from '$lib/server/hosts/http.js';
type Event = { request: Request; params: { id: string } };
export function DELETE({ request, params }: Event) {
requireManagementOrigin(request);
try { revokeCredentials(params.id); return json({ ok: true }); } catch (cause) { return hostApiError(cause); }
}
export function POST({ request, params }: Event) {
requireManagementOrigin(request);
try { const operation = queueHostOperation(params.id, 'credentials.rotate', {}); return json({ operationId: operation.id }, { status: 202 }); } catch (cause) { return hostApiError(cause); }
}
@@ -0,0 +1,8 @@
import { json } from '@sveltejs/kit';
import { createEnrollment } from '$lib/server/hosts/index.js';
import { hostApiError, installerCommand, requireManagementOrigin } from '$lib/server/hosts/http.js';
export function POST({ request, params }: { request: Request; params: { id: string } }) {
requireManagementOrigin(request);
try { const enrollment = createEnrollment(params.id); return json({ enrollment: { ...enrollment, command: installerCommand(request.url, enrollment.token) } }, { headers: { 'Cache-Control': 'no-store' } }); }
catch (cause) { return hostApiError(cause); }
}
@@ -0,0 +1,12 @@
import { json } from '@sveltejs/kit';
import { queueHostOperation } from '$lib/server/hosts/index.js';
import { hostApiError, objectBody, requireManagementOrigin } from '$lib/server/hosts/http.js';
export async function POST({ request, params }: { request: Request; params: { id: string } }) {
requireManagementOrigin(request);
try {
const body = await objectBody(request);
if (body.kind !== 'runtime.pull' || typeof body.image !== 'string' || !/^[a-zA-Z0-9][a-zA-Z0-9./_:@-]{0,511}$/.test(body.image)) throw new Error('Provide a runtime.pull request and a valid container image.');
const operation = queueHostOperation(params.id, 'runtime.pull', { image: body.image });
return json({ operationId: operation.id }, { status: 202 });
} catch (cause) { return hostApiError(cause); }
}
+13 -2
View File
@@ -7,17 +7,24 @@ import { queueCreateServer } from '$lib/server/provisioning/index.js';
import { config, komodoConfigured, rconConfigured } from '$lib/server/config.js';
import type { GameMode, ZombieGame } from '$lib/server/fleet/map-aliases.js';
import { runningVersions } from '$lib/server/integrations/plutonium.js';
import { listHosts } from '$lib/server/hosts/index.js';
import { nativeInstance } from '$lib/server/backends/native.js';
export async function GET() {
const rconHost = config.rcon.host;
const hosts = listHosts();
const hostsById = new Map(hosts.map(host => [host.id, host]));
const versions = new Map(runningVersions().map(version => [version.server, version]));
const servers = listServers().map((s) => {
const instance = s.native_instance_id ? nativeInstance(s.name) : null;
return {
...serverObservation(s),
host: s.host_id ? hostsById.get(s.host_id) ?? null : null,
network: instance ? { bindAddress: instance.bind_address, localPort: instance.port, publicAddress: instance.public_address, publicPort: instance.public_port } : null,
plutonium: versions.get(s.name),
// Derived endpoint — same logic as endpointFor() but without asking
// Komodo. If we don't have a port yet, endpoint is null.
endpoint: s.game_port ? { host: s.rcon_host ?? rconHost, port: s.game_port } : null,
endpoint: instance ? { host: `agent:${instance.host_id}`, port: instance.port } : s.game_port ? { host: s.rcon_host ?? rconHost, port: s.game_port } : null,
held: [...heldByBeta(s.name).entries()].map(([target, betaId]) => ({ target, betaId }))
};
});
@@ -26,6 +33,9 @@ export async function GET() {
serverNow: Date.now(),
reservedNames: (db().prepare("SELECT j.server FROM server_creation_jobs j JOIN server_deployments d ON d.id = j.deployment_id WHERE d.state IN ('queued', 'running', 'awaiting-health')").all() as { server: string }[]).map(row => row.server),
integrations: {
native: hosts.length > 0 || !komodoConfigured(),
hostsReady: hosts.filter(host => host.readiness === 'ready').length,
hostsTotal: hosts.length,
rcon: rconConfigured(),
komodo: komodoConfigured(),
rconTimeoutMs: config.rcon.timeoutMs,
@@ -36,6 +46,7 @@ export async function GET() {
export async function POST({ request }: { request: Request }) {
const body = (await request.json().catch(() => ({}))) as {
hostId?: string; libraryId?: string; image?: string; bindAddress?: string; publicAddress?: string; publicPort?: number;
game?: string;
gameMode?: string;
mapSlug?: string;
@@ -56,7 +67,7 @@ export async function POST({ request }: { request: Request }) {
plutoKey: body.plutoKey,
expectedName: body.expectedName,
previewId: body.previewId,
createdBy: 'ui'
createdBy: 'ui', hostId: body.hostId, libraryId: body.libraryId, image: body.image, bindAddress: body.bindAddress, publicAddress: body.publicAddress, publicPort: body.publicPort
});
if (!result.ok) {
const status = result.kind === 'bad-request' ? 400 : result.kind === 'unconfigured' ? 503
+19
View File
@@ -6,12 +6,31 @@ import { serverObservation } from '$lib/server/fleet/status.js';
import { freshStatusTimestamp } from '$lib/server-freshness.js';
import { tcpProbe } from '$lib/server/integrations/netProbe.js';
import type { SetupCheck, SystemStatus } from '$lib/system-status.js';
import { listHosts } from '$lib/server/hosts/index.js';
import { listLibraries } from '$lib/server/game-files/index.js';
export async function GET({ url }: { url: URL }) {
const servers = listServers();
const readyServers = servers.filter(server => serverObservation(server).health === 'online').length;
const rconServers = servers.filter(server => !server.last_error && freshStatusTimestamp(server.last_seen_at, Date.now()) && server.container_state === 'running').length;
const savedKeys = listServerKeys().length;
const hosts = listHosts();
if (hosts.length || !komodoConfigured()) {
const readyHosts = hosts.filter(host => host.readiness === 'ready');
const connected = hosts.filter(host => host.connected);
const versions = listLibraries().flatMap(library => library.versions).filter(version => version.state === 'ready');
const cached = versions.filter(version => version.caches.some(cache => cache.status === 'ready' && readyHosts.some(host => host.id === cache.hostId)));
const runtimes = connected.filter(host => host.inventory.runtimeImages?.length);
const checks: SetupCheck[] = [
{ id: 'hosts', title: 'Host agents', state: readyHosts.length ? 'ready' : hosts.length ? 'attention' : 'unconfigured', detail: `${connected.length} connected, ${readyHosts.length} deployment-ready, ${hosts.length} enrolled or awaiting enrollment.`, href: '/hosts', action: hosts.length ? 'Review hosts' : 'Add host' },
{ id: 'runtime', title: 'Plutainer runtime', state: runtimes.length ? 'ready' : 'attention', detail: runtimes.length ? `${runtimes.length} connected hosts report cached runtime images.` : 'Prepare a Plutainer image on the host before creating a server.', href: '/hosts', action: 'Prepare runtime' },
{ id: 'game-files', title: 'Verified game-file libraries', state: cached.length ? 'ready' : 'attention', detail: `${versions.length} published versions; ${cached.length} have a verified cache on a ready host.`, href: '/game-files', action: 'Manage game files' },
{ id: 'rcon', title: 'Agent-local game connection', state: rconServers ? 'ready' : 'unverified', detail: rconServers ? `${rconServers} servers have fresh RCON responses.` : 'The host agent checks RCON locally after deployment. Controller access to game ports is not required.', href: servers.length ? '/' : '/hosts', action: servers.length ? 'Review fleet' : 'Review hosts' },
{ id: 'keys', title: 'Saved server keys', state: savedKeys ? 'ready' : 'attention', detail: `${savedKeys} saved ${savedKeys === 1 ? 'key' : 'keys'}. Validity is checked with Plutonium at startup.`, href: '/server-keys', action: 'Manage keys' },
{ id: 'first-server', title: 'First server ready', state: readyServers ? 'ready' : 'attention', detail: readyServers ? `${readyServers} of ${servers.length} servers are responding.` : servers.length ? 'Registered servers are awaiting fresh healthy reports. Inspect their host and diagnostics.' : 'Choose a ready host and a game library when creating your first server.', href: servers.length ? '/' : '/provisioning', action: servers.length ? 'Review fleet' : 'Create a server' }
];
return json({ checkedAt: new Date().toISOString(), checks, readyServers, totalServers: servers.length, savedKeys } satisfies SystemStatus, { headers: { 'cache-control': 'no-store' } });
}
const missing = [!config.komodo.address && 'KOMODO_ADDRESS', !config.komodo.apiKey && 'KOMODO_API_KEY', !config.komodo.apiSecret && 'KOMODO_API_SECRET'].filter(Boolean);
const connection: SetupCheck = { id: 'komodo', title: 'Komodo connection', state: komodoConfigured() ? 'unverified' : 'unconfigured', detail: komodoConfigured() ? 'Credentials configured. Run connection check to verify host reachability.' : `Set ${missing.join(', ')} in the app environment.`, href: '#setup-guide', action: 'Setup instructions' };
if (komodoConfigured() && url.searchParams.get('probe') === '1') {
+10
View File
@@ -6,8 +6,18 @@ import { recordTelemetryRequest } from '$lib/server/fleet/telemetry-requests.js'
import { recordGameEvent } from '$lib/server/fleet/beta-tests.js';
import { db } from '$lib/server/db/index.js';
import { emit } from '$lib/server/pipeline/bus.js';
import { authenticateTelemetry } from '$lib/server/auth/telemetry.js';
export async function POST({ request }: { request: Request }) {
const authentication = authenticateTelemetry(request);
if (!authentication.ok) {
// Unauthenticated bodies are never parsed, attributed to a server, or
// retained in telemetry diagnostics; they cannot affect game evidence.
return json({ ok: false, error: authentication.error }, {
status: authentication.status,
headers: { 'Cache-Control': 'no-store' }
});
}
const receivedAt = new Date().toISOString();
const contentType = request.headers.get('content-type');
let value: unknown;
+8
View File
@@ -0,0 +1,8 @@
import { json, type RequestHandler } from '@sveltejs/kit';
import { createUser, listUsers } from '$lib/server/auth/index.js';
import { authBody, authError, requireUser } from '$lib/server/auth/http.js';
export const GET: RequestHandler = () => json({ users:listUsers() });
export const POST: RequestHandler = async ({ request, locals }) => {
try { return json({ user:await createUser(requireUser(locals.user).id,await authBody(request)) },{status:201}); }
catch (cause) { return authError(cause); }
};
+8
View File
@@ -0,0 +1,8 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { updateUser } from '$lib/server/auth/index.js';
import { authBody, authError, requireUser } from '$lib/server/auth/http.js';
export const PATCH: RequestHandler = async ({ request, params, locals }) => {
try { return json({ user:await updateUser(requireUser(locals.user).id,params.id,await authBody(request)) }); }
catch (cause) { return authError(cause); }
};
@@ -0,0 +1,8 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { revokeUserSessions } from '$lib/server/auth/index.js';
import { authError, requireUser } from '$lib/server/auth/http.js';
export const POST: RequestHandler = ({ params, locals }) => {
try { revokeUserSessions(requireUser(locals.user).id,params.id); return json({ok:true}); }
catch (cause) { return authError(cause); }
};
+3
View File
@@ -0,0 +1,3 @@
import { json } from '@sveltejs/kit';
import { listAuthAudit } from '$lib/server/auth/index.js';
export function GET() { return json({ events:listAuthAudit() }); }
+7 -6
View File
@@ -29,6 +29,7 @@
let tab = $state('collections');
let limit = $state(25);
let launchLimit = $state(10);
const canOperate = $derived($page.data.user?.role === 'admin' || $page.data.user?.role === 'operator');
const activeLaunches = $derived(launches.filter(launch => launch.active > 0));
const launchHistory = $derived(launches.filter(launch => launch.active === 0));
const visibleLaunches = $derived(tab === 'active' ? activeLaunches : launchHistory);
@@ -92,10 +93,10 @@
<svelte:head><title>Collections | Electric Cherry</title></svelte:head>
<div class="page">
<SectionHeader title="Collections" subtitle="Save your lineup, preview fleet changes, and follow launch progress and history.">
{#snippet actions()}<Button variant="primary" onclick={() => { editingBlueprint = null; builderOpen = true; }}>Create collection</Button>{/snippet}
{#snippet actions()}{#if canOperate}<Button variant="primary" onclick={() => { editingBlueprint = null; builderOpen = true; }}>Create collection</Button>{/if}{/snippet}
</SectionHeader>
<BlueprintBuilder bind:open={builderOpen} blueprint={editingBlueprint} onsaved={blueprintSaved} />
<DeployCollectionDialog bind:open={collectionOpen} {blueprintId} />
{#if canOperate}<BlueprintBuilder bind:open={builderOpen} blueprint={editingBlueprint} onsaved={blueprintSaved} />
<DeployCollectionDialog bind:open={collectionOpen} {blueprintId} />{/if}
<Tabs {tabs} bind:value={tab} onchange={() => { launchLimit = 10; saveContext(); }} ariaLabel="Collection views" idPrefix="collection-tabs" panelId="collection-panel" />
<div id="collection-panel" role="tabpanel" aria-labelledby={"collection-tabs-" + tab} class="panel">
{#if tab === 'collections'}
@@ -103,7 +104,7 @@
<h2>Your collections <span>{blueprints.length}</span></h2>
{#if blueprintError}<p role="alert" class="error">{blueprintError} <button onclick={loadBlueprints}>Retry</button></p>{/if}
{#if blueprintsLoading}<p>Loading collections…</p>
{:else if !blueprints.length}<div class="blueprint-empty"><strong>Plan the whole lineup.</strong><p>Add maps from either game, choose the total lobbies each needs, and fill any gaps from one collection.</p><button onclick={() => { editingBlueprint = null; builderOpen = true; }}>Create your first collection →</button></div>
{:else if !blueprints.length}<div class="blueprint-empty"><strong>No collections yet.</strong><p>Collections define the maps and lobby counts for a fleet. Operators can create and launch them.</p>{#if canOperate}<button onclick={() => { editingBlueprint = null; builderOpen = true; }}>Create your first collection →</button>{/if}</div>
{:else}
<div class="blueprints">
{#each blueprints.slice(0,limit) as blueprint (blueprint.id)}
@@ -115,11 +116,11 @@
<h3>{blueprint.name}</h3>
<p>{blueprint.description || 'Fill missing lobbies or match your fleet to this lineup exactly.'}</p>
<ul class="blueprint-lineup">{#each blueprint.entries as entry}<li><strong>{entry.count}×</strong> {entry.mapName} <small>{entry.game.toUpperCase()}</small></li>{/each}</ul>
<div class="blueprint-footer">
{#if canOperate}<div class="blueprint-footer">
<Button onclick={() => { blueprintId = blueprint.id; collectionOpen = true; }}>Preview fleet changes →</Button>
<button class="edit" onclick={() => { editingBlueprint = blueprint; builderOpen = true; }}>Edit</button>
<button class="remove" disabled={!!removing} onclick={() => removeBlueprint(blueprint.id)}>Remove</button>
</div>
</div>{/if}
</article>
{/each}
</div>
+120
View File
@@ -0,0 +1,120 @@
<script lang="ts">
import { onMount } from 'svelte';
import { page } from '$app/stores';
import type { GameLibrary, GameLibraryVersion } from '$lib/game-files.js';
import { formatGameBytes } from '$lib/game-files.js';
import type { Host } from '$lib/hosts.js';
import { pollVisible } from '$ui/poll.js';
import SectionHeader from '$ui/components/primitives/SectionHeader.svelte';
import Button from '$ui/components/primitives/Button.svelte';
import Badge from '$ui/components/primitives/Badge.svelte';
import EmptyState from '$ui/components/primitives/EmptyState.svelte';
type OverviewLibrary = Omit<GameLibrary, 'versions'> & { versions: Omit<GameLibraryVersion, 'files'>[] };
let libraries = $state<OverviewLibrary[]>([]);
let hosts = $state<Host[]>([]);
let loaded = $state(false);
let error = $state(''); let notice = $state(''); let busy = $state(false);
let showImport = $state(false); let game = $state<'t5' | 't6'>('t5');
let name = $state(''); let version = $state(''); let description = $state(''); let dlc = $state(''); let hostId = $state('');
let filterGame = $state('all');
let targets = $state<Record<string, string>>({});
const canManage = $derived($page.data.user?.role === 'admin');
const onlineHosts = $derived(hosts.filter(host => host.connected && host.credentialActive && host.mode !== 'disabled'));
const matching = $derived(libraries.filter(library => filterGame === 'all' || library.game === filterGame));
const hostName = (id: string) => hosts.find(host => host.id === id)?.name ?? id;
async function load() {
try {
const response = await fetch('/api/game-files');
const body = await response.json();
if (!response.ok) throw new Error(body.error ?? 'Could not load game libraries.');
libraries = body.libraries; hosts = body.hosts; loaded = true;
} catch (cause) { error = (cause as Error).message; }
}
async function act(url: string, payload: Record<string, unknown>, message: string) {
busy = true; error = ''; notice = '';
try {
const response = await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload) });
const result = await response.json();
if (!response.ok) throw new Error(result.error ?? 'Could not start this operation.');
notice = message; await load(); return true;
} catch (cause) { error = (cause as Error).message; return false; }
finally { busy = false; }
}
async function startImport(event: SubmitEvent) {
event.preventDefault();
if (await act('/api/game-files', { game, name, version, description, dlc: dlc.split(',').map(value => value.trim()).filter(Boolean), hostId }, 'Import queued. The source host will scan and upload files, then verify and publish this version.')) showImport = false;
}
function newVersion(library: OverviewLibrary) {
showImport = true; game = library.game; name = library.name; description = library.description;
dlc = library.versions[0]?.dlc.join(', ') ?? ''; version = '';
document.getElementById('library-import')?.scrollIntoView({ behavior: 'smooth' });
}
onMount(() => pollVisible(load, 5000));
</script>
<svelte:head><title>Game Files | Electric Cherry</title></svelte:head>
<div class="page">
<SectionHeader title="Game Files" subtitle="Import immutable base-game versions and distribute verified copies to your hosts.">
{#snippet actions()}{#if canManage}<Button onclick={() => showImport = !showImport}>{showImport ? 'Close import' : 'Import game files'}</Button>{/if}{/snippet}
</SectionHeader>
{#if error}<p class="ui-error" role="alert">{error}</p>{/if}
{#if notice}<p role="status">{notice}</p>{/if}
{#if !canManage}<p class="muted">Administrators import game files and distribute verified copies to hosts.</p>{/if}
{#if canManage && showImport}
<section class="card" id="library-import">
<h2>Import an existing game volume</h2>
<p>The selected host scans its {game}_game_files Docker volume, calculates SHA256 checksums, and streams a copy into controller storage. Publication requires every file to pass verification.</p>
<form onsubmit={startImport}>
<div class="fields">
<label>Game<select bind:value={game}><option value="t5">Black Ops I (T5)</option><option value="t6">Black Ops II (T6)</option></select></label>
<label>Source host<select required bind:value={hostId}><option value="">Select an online host</option>{#each onlineHosts as host}<option value={host.id}>{host.name}</option>{/each}</select></label>
<label>Library name<input required maxlength="160" bind:value={name} placeholder="Black Ops II — Complete Server Files" /></label>
<label>New version label<input required maxlength="120" bind:value={version} placeholder="2026-10-03" /></label>
<label class="wide">Description<textarea maxlength="4000" bind:value={description} rows="2"></textarea></label>
<label class="wide">Content / DLC (comma separated)<input bind:value={dlc} placeholder="Base game, Nuketown, Die Rise, Mob of the Dead, Buried, Origins" /></label>
</div>
<Button type="submit" disabled={busy || !onlineHosts.length}>Start import</Button>
{#if !onlineHosts.length}<p>Enroll an online source host in <a href="/hosts">Hosts</a> before importing.</p>{/if}
</form>
</section>
{/if}
<div class="filters"><label>Game<select bind:value={filterGame}><option value="all">All games</option><option value="t5">Black Ops I</option><option value="t6">Black Ops II</option></select></label><span class="muted">{libraries.length} libraries · Base-game files and runtime images are versioned separately.</span></div>
{#each matching as library (library.id)}
<section class="card">
<div class="heading"><div><h2>{library.name}</h2><p class="muted">{library.game.toUpperCase()} · {library.description}</p></div>{#if canManage}<Button size="sm" onclick={() => newVersion(library)}>Import new version</Button>{/if}</div>
{#each library.versions as item (item.id)}
<article class="version">
<div class="heading"><h3>{item.version}</h3><Badge variant={item.state === 'failed' ? 'danger' : item.state === 'ready' ? 'online' : 'info'}>{item.state === 'ready' ? 'Published' : item.state === 'importing' ? 'Importing' : 'Failed'}</Badge></div>
<p>{formatGameBytes(item.sizeBytes)} · {item.fileCount.toLocaleString()} files · Created {new Date(item.createdAt).toLocaleString()}</p>
{#if item.dlc.length}<p>Content: {item.dlc.join(', ')}</p>{/if}
{#if item.error}<p class="ui-error">{item.error}</p>{/if}
<details><summary>Version details</summary><dl><dt>Immutable version ID</dt><dd>{item.id}</dd><dt>Manifest SHA256</dt><dd>{item.manifestSha256 ?? 'Waiting for source scan'}</dd><dt>Controller storage</dt><dd>{item.storageLocation}</dd><dt>Imported from</dt><dd>{hostName(item.sourceHostId)} / {item.sourceVolume}</dd></dl><a href={`/api/game-files/${item.id}`} target="_blank" rel="noreferrer">View complete file manifest</a></details>
{#if item.state === 'ready'}
{#if canManage}<div class="download"><label>Download to host<select bind:value={targets[item.id]}><option value="">Select host</option>{#each onlineHosts as host}<option value={host.id}>{host.name} · {formatGameBytes(Number(host.inventory.storageAvailable ?? 0))} free</option>{/each}</select></label><Button size="sm" disabled={busy || !targets[item.id]} onclick={() => act(`/api/game-files/${item.id}/download`, { hostId: targets[item.id] }, 'Library download queued. Progress will update below.')}>Download / verify</Button></div>{/if}
<div class="table-wrap"><table><thead><tr><th>Host</th><th>Cache state</th><th>Progress</th><th>Detail</th></tr></thead><tbody>
{#each item.caches as cache}<tr><td><a href={`/hosts/${cache.hostId}`}>{hostName(cache.hostId)}</a></td><td>{cache.status}{!hosts.find(host => host.id === cache.hostId)?.connected ? ' · offline' : ''}</td><td>{#if cache.status === 'downloading' || cache.status === 'verifying'}<progress max={cache.bytesTotal || 1} value={cache.bytesDownloaded}></progress><small>{formatGameBytes(cache.bytesDownloaded)} / {formatGameBytes(cache.bytesTotal)}</small>{:else if cache.status === 'ready' || cache.status === 'outdated'}Verified{cache.status === 'outdated' ? ' · older version' : ''}{:else}—{/if}</td><td>{cache.error ?? ''}</td></tr>{:else}<tr><td colspan="4">Enroll a host to cache this version.</td></tr>{/each}
</tbody></table></div>
{:else if item.state === 'importing'}<p class="muted">The agent operation continues independently of this page. An interrupted import is reconciled when the host reconnects.</p>{/if}
</article>
{/each}
</section>
{:else}{#if loaded}<EmptyState title="No game libraries yet" description="Administrators can import T5 or T6 game files from an enrolled host. Each published version can be shared by many instances." />{:else}<p role="status">Loading game libraries…</p>{/if}{/each}
</div>
<style>
.page, .card, .version, form { display: grid; gap: 20px; }
.card { padding: 24px; border: 1px solid var(--border-subtle); background: var(--surface); border-radius: var(--radius-md); }
.version { padding-top: 20px; border-top: 1px solid var(--border-subtle); }
.heading, .filters, .download { display: flex; gap: 16px; align-items: center; justify-content: space-between; flex-wrap: wrap; }
.download { justify-content: flex-start; align-items: end; }
.fields { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 16px; }
.wide { grid-column: 1 / -1; } label { display: grid; gap: 6px; font-size: var(--text-sm); }
input, select, textarea { width: 100%; min-width: 0; box-sizing: border-box; padding: 10px; background: var(--surface); color: inherit; border: 1px solid var(--border-strong); border-radius: var(--radius-sm); }
h2, h3, p { margin: 0; } h2 { font-size: var(--text-md); } h3 { font-size: var(--text-sm); }
p, td, th, details { font-size: var(--text-sm); line-height: 1.6; } .muted, small { color: var(--text-secondary); }
small { display: block; } a { color: var(--accent-primary-hover); } summary { cursor: pointer; }
dl { display: grid; grid-template-columns: auto 1fr; gap: 6px 16px; } dd { margin: 0; overflow-wrap: anywhere; }
.table-wrap { overflow-x: auto; } table { width: 100%; border-collapse: collapse; text-align: left; } th, td { padding: 10px; border-bottom: 1px solid var(--border-subtle); }
@media(max-width: 640px) { .fields { grid-template-columns: 1fr; } .card { padding: 16px; } dl { grid-template-columns: 1fr; } }
</style>
+52
View File
@@ -0,0 +1,52 @@
<script lang="ts">
import { onMount } from 'svelte';
import { page } from '$app/stores';
import type { Host } from '$lib/hosts.js';
import SectionHeader from '$ui/components/primitives/SectionHeader.svelte';
import Button from '$ui/components/primitives/Button.svelte';
let hosts = $state<Host[]>([]);
let loading = $state(true); let busy = $state(false); let error = $state('');
let name = $state(''); let portMin = $state(28960); let portMax = $state(28980);
let enrollment = $state<{ command: string; expiresAt: string; hostId: string } | null>(null);
const canManage = $derived($page.data.user?.role === 'admin');
async function load() {
try { const response = await fetch('/api/hosts'); const body = await response.json(); if (!response.ok) throw new Error(body.error ?? 'Could not load hosts.'); hosts = body.hosts; }
catch (cause) { error = (cause as Error).message; } finally { loading = false; }
}
async function add(event: SubmitEvent) {
event.preventDefault(); busy = true; error = ''; enrollment = null;
try {
const response = await fetch('/api/hosts', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name, portMin, portMax }) });
const body = await response.json(); if (!response.ok) throw new Error(body.error ?? 'Could not add host.');
enrollment = body.enrollment; name = ''; await load();
} catch (cause) { error = (cause as Error).message; } finally { busy = false; }
}
onMount(() => { void load(); const timer = setInterval(() => { void load(); }, 15000); return () => clearInterval(timer); });
</script>
<svelte:head><title>Hosts · Electric Cherry</title></svelte:head>
<section class="page">
<SectionHeader title="Hosts" subtitle="Connect game-server hosts and prepare them for deployment." />
{#if error}<p role="alert" class="ui-error">{error}</p>{/if}
{#if canManage}<section class="panel">
<h2>Add host</h2><p>Run the installation command on a Debian 12 or 13 amd64 machine. Each host connects to this controller over HTTPS.</p>
<form onsubmit={add}><fieldset disabled={busy}>
<label>Friendly name<input required maxlength="200" bind:value={name} placeholder="Game host 1" /></label>
<label>First game port<input type="number" required min="1" max="65535" bind:value={portMin} /></label>
<label>Last game port<input type="number" required min={portMin} max="65535" bind:value={portMax} /></label>
<Button type="submit" disabled={busy}>{busy ? 'Creating…' : 'Add host'}</Button>
</fieldset></form>
{#if enrollment}<div class="enrollment" role="status"><h3>Install the agent</h3><p>This single-use command expires {new Date(enrollment.expiresAt).toLocaleString()}. Run it with administrator access on your game host.</p><pre>{enrollment.command}</pre><a href={`/hosts/${enrollment.hostId}`}>Open host setup →</a></div>{/if}
</section>{:else}<p>Administrators manage host setup, runtime preparation, and credentials.</p>{/if}
{#if loading}<p role="status">Loading hosts…</p>{:else if !hosts.length}<p>No hosts connected yet.{canManage ? ' Add a host to begin.' : ''}</p>{:else}
<div class="host-grid">{#each hosts as host}<a class="panel host" href={`/hosts/${host.id}`}><div class="row"><h2>{host.name}</h2><span class:ready={host.readiness === 'ready'} class="state">{host.readiness}</span></div><p>{host.hostname || 'Awaiting enrollment'} · {host.connectionState}</p><p>{host.inventory.os ?? 'OS pending'} · {host.inventory.architecture ?? 'Architecture pending'}</p><p>Game ports {host.portMin}–{host.portMax}</p>{#if host.readinessReasons.length}<p class="reason">{host.readinessReasons[0]}</p>{/if}</a>{/each}</div>
{/if}
</section>
<style>
.page { display:grid; gap:24px; } .panel { border:1px solid var(--border-strong); border-radius:var(--radius-md); background:var(--surface); padding:24px; }
h2 { font-size:var(--text-md); margin:0; } h3 { font-size:var(--text-sm); } p { color:var(--text-secondary); line-height:1.6; font-size:var(--text-sm); }
fieldset { display:flex; gap:16px; flex-wrap:wrap; align-items:end; border:0; padding:0; margin-top:20px; } label { display:grid; gap:8px; font-size:var(--text-sm); } input { width:100%; max-width:280px; padding:10px; color:var(--text-primary); background:var(--surface-sunken); border:1px solid var(--border-control); border-radius:var(--radius-sm); }
pre { white-space:pre-wrap; overflow-wrap:anywhere; padding:16px; background:var(--surface-sunken); font-size:var(--text-sm); } .enrollment { border-top:1px solid var(--border-subtle); margin-top:24px; padding-top:16px; }
.host-grid { display:grid; grid-template-columns:repeat(auto-fit,minmax(300px,1fr)); gap:16px; } .host { text-decoration:none; color:inherit; } .host:hover { border-color:var(--accent-primary); } .row { display:flex; justify-content:space-between; gap:16px; } .state { font-size:var(--text-xs); text-transform:capitalize; color:var(--text-secondary); } .ready { color:var(--accent-primary); } .reason { margin-bottom:0; }
</style>
+55
View File
@@ -0,0 +1,55 @@
<script lang="ts">
import { onMount } from 'svelte';
import { page } from '$app/stores';
import type { Host, HostOperation, HostMode } from '$lib/hosts.js';
import SectionHeader from '$ui/components/primitives/SectionHeader.svelte';
import Button from '$ui/components/primitives/Button.svelte';
let host = $state<Host | null>(null); let operations = $state<HostOperation[]>([]);
let loading = $state(true); let busy = $state(false); let error = $state(''); let notice = $state('');
let name = $state(''); let portMin = $state(28960); let portMax = $state(28980); let bindAddress = $state('0.0.0.0'); let publicAddress = $state(''); let minimumFreeGB = $state(1);
let runtimeImage = $state('ghcr.io/ayymoss/plutainer:latest'); let enrollment = $state<{ command: string; expiresAt: string } | null>(null);
const endpoint = $derived(`/api/hosts/${$page.params.id}`);
const canManage = $derived($page.data.user?.role === 'admin');
function bytes(value: unknown) { return typeof value === 'number' ? `${(value / 1073741824).toFixed(1)} GiB` : 'Unknown'; }
async function load(initialize = false) {
try {
const response = await fetch(endpoint); const body = await response.json(); if (!response.ok) throw new Error(body.error ?? body.message ?? 'Could not load host.'); host = body.host; operations = body.operations;
if (initialize && host) { name = host.name; portMin = host.portMin; portMax = host.portMax; bindAddress = host.bindAddress; publicAddress = host.publicAddress; minimumFreeGB = host.minimumFreeBytes / 1073741824; }
} catch (cause) { error = (cause as Error).message; } finally { loading = false; }
}
async function action(path: string, method: string, body?: unknown, message?: string) {
busy = true; error = ''; notice = '';
try { const response = await fetch(endpoint + path, { method, headers: { 'Content-Type': 'application/json' }, ...(body === undefined ? {} : { body: JSON.stringify(body) }) }); const result = await response.json(); if (!response.ok) throw new Error(result.error ?? 'Host request failed.'); if (result.enrollment) enrollment = result.enrollment; notice = message ?? ''; await load(); }
catch (cause) { error = (cause as Error).message; } finally { busy = false; }
}
async function save(event: SubmitEvent) { event.preventDefault(); await action('', 'PATCH', { name, portMin, portMax, bindAddress, publicAddress, minimumFreeBytes: Math.round(minimumFreeGB * 1073741824) }, 'Host settings saved.'); }
async function mode(value: HostMode) { await action('', 'PATCH', { mode: value }, value === 'draining' ? 'Drain enabled. Existing games continue until safe retirement.' : `Host ${value === 'active' ? 'enabled' : 'disabled'}.`); }
onMount(() => { void load(true); const timer = setInterval(() => { void load(); }, 10000); return () => clearInterval(timer); });
</script>
<svelte:head><title>{host?.name ?? 'Host'} · Electric Cherry</title></svelte:head>
<section class="page"><a href="/hosts">← All hosts</a><SectionHeader title={host?.name ?? 'Host'} subtitle="Readiness, inventory, credentials, and operation history." />
{#if error}<p class="ui-error" role="alert">{error}</p>{/if}{#if notice}<p role="status">{notice}</p>{/if}
{#if loading}<p role="status">Loading host…</p>{:else if host}
<section class="panel"><div class="row"><h2>{host.connectionState} · {host.readiness}</h2><span>Last heartbeat: {host.lastHeartbeat ? new Date(host.lastHeartbeat).toLocaleString() : 'Never'}</span></div>
{#if host.readinessReasons.length}<ul>{#each host.readinessReasons as reason}<li>{reason}</li>{/each}</ul>{:else}<p>Connected and ready. Each deployment also checks its game library, runtime, and ports.</p>{/if}
{#if canManage}<div class="actions"><Button disabled={busy || host.mode === 'active'} onclick={() => mode('active')}>Enable</Button><Button disabled={busy || host.mode === 'draining'} onclick={() => mode('draining')}>Drain host</Button><Button disabled={busy || host.mode === 'disabled'} onclick={() => mode('disabled')}>Disable host</Button></div>{/if}
<p>Draining prevents new deployments and retires instances as they become safe to remove. Disabled hosts receive no new commands; running game containers continue.</p>
</section>
<div class="grid"><section class="panel"><h2>Inventory</h2><dl>
<dt>Hostname</dt><dd>{host.hostname || 'Pending'}</dd><dt>Agent</dt><dd>{host.inventory.agentVersion ?? 'Unknown'}</dd><dt>Platform</dt><dd>{host.inventory.os ?? 'Unknown'} · {host.inventory.architecture ?? 'Unknown'}</dd><dt>CPU</dt><dd>{host.inventory.cpu ?? 'Unknown'} ({host.inventory.cpuCount ?? '?'} cores)</dd><dt>Memory available / total</dt><dd>{bytes(host.inventory.memoryAvailable)} / {bytes(host.inventory.memoryTotal)}</dd><dt>Game storage available / total</dt><dd>{bytes(host.inventory.storageAvailable)} / {bytes(host.inventory.storageTotal)}</dd><dt>Docker</dt><dd>{host.inventory.dockerVersion ?? 'Unknown'} · {host.inventory.dockerRunning ? 'Running' : 'Unavailable'}</dd><dt>Current operation</dt><dd>{host.inventory.currentOperation ?? 'None reported'}</dd>
</dl></section><section class="panel"><h2>Host configuration</h2>{#if canManage}<form onsubmit={save}><fieldset disabled={busy}>
<label>Friendly name<input required maxlength="200" bind:value={name} /></label><div class="fields"><label>First game port<input type="number" min="1" max="65535" required bind:value={portMin} /></label><label>Last game port<input type="number" min={portMin} max="65535" required bind:value={portMax} /></label></div><label>Local bind address<input required bind:value={bindAddress} /></label><label>Public / NAT address<input bind:value={publicAddress} placeholder="Optional public hostname or address" /></label><label>Minimum free game storage (GiB)<input type="number" min="0" step="0.1" required bind:value={minimumFreeGB} /></label><Button type="submit" disabled={busy}>Save settings</Button>
</fieldset></form>{:else}<dl><dt>Friendly name</dt><dd>{host.name}</dd><dt>Game ports</dt><dd>{host.portMin}–{host.portMax}</dd><dt>Local bind address</dt><dd>{host.bindAddress}</dd><dt>Public / NAT address</dt><dd>{host.publicAddress || 'Not configured'}</dd><dt>Minimum free storage</dt><dd>{bytes(host.minimumFreeBytes)}</dd></dl><p>Host configuration is managed by administrators.</p>{/if}<p>Public addressing is separate from local port reservations. Configure your firewall and NAT for the game-port range.</p></section></div>
<div class="grid"><section class="panel"><h2>Game files and runtime</h2><p><a href="/game-files">Manage game-file libraries →</a></p>{#if host.inventory.libraries?.length}<ul>{#each host.inventory.libraries as library}<li>{library.id}: {library.state}{library.progress !== undefined ? ` (${library.progress}%)` : ''}</li>{/each}</ul>{:else}<p>No cached game libraries reported.</p>{/if}
{#if canManage}<form onsubmit={(event) => { event.preventDefault(); void action('/operations', 'POST', { kind: 'runtime.pull', image: runtimeImage }, 'Runtime download queued.'); }}><fieldset disabled={busy}><label>Plutainer image<input required bind:value={runtimeImage} /></label><Button type="submit" disabled={busy || !host.connected}>Prepare runtime</Button></fieldset></form>{/if}<p>Cached images: {host.inventory.runtimeImages?.join(', ') || 'None reported'}</p>
</section><section class="panel"><h2>Agent access</h2><p>Credentials: {host.credentialActive ? 'Active' : 'Not active'}. Enrollment links expire after 15 minutes and work once.</p>{#if canManage}<div class="actions"><Button disabled={busy} onclick={() => action('/enrollment', 'POST')}>Generate installer</Button><Button disabled={busy || !host.credentialActive || !host.connected} onclick={() => action('/credentials', 'POST', undefined, 'Credential rotation queued.')}>Rotate credential</Button><Button disabled={busy || !host.credentialActive} onclick={() => action('/credentials', 'DELETE', undefined, 'Host credentials revoked. Generate a new installer to enroll again.')}>Revoke credential</Button></div>
{#if enrollment}<p>Expires {new Date(enrollment.expiresAt).toLocaleString()}</p><pre>{enrollment.command}</pre>{/if}{:else}<p>Administrators manage enrollment and agent credentials.</p>{/if}
</section></div>
<section class="panel"><h2>Operation history</h2>{#if !operations.length}<p>No remote operations yet.</p>{:else}<div class="table-wrap"><table><thead><tr><th>Operation</th><th>State</th><th>Updated</th><th>Result</th></tr></thead><tbody>{#each operations as operation}<tr><td>{operation.kind}<small>{operation.id}</small></td><td>{operation.state}</td><td>{new Date(operation.updatedAt).toLocaleString()}</td><td>{operation.error ?? '—'}</td></tr>{/each}</tbody></table></div>{/if}</section>
{/if}
</section>
<style>
.page { display:grid; gap:24px; } .panel { border:1px solid var(--border-strong); border-radius:var(--radius-md); background:var(--surface); padding:24px; min-width:0; } h2 { margin:0 0 16px; font-size:var(--text-md); text-transform:capitalize; } p, li, dd, dt, span { font-size:var(--text-sm); line-height:1.65; } p,li,dt,span { color:var(--text-secondary); } .grid { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:24px; } .row,.actions,.fields { display:flex; gap:12px; flex-wrap:wrap; } .row { justify-content:space-between; } .actions { margin-top:20px; } dl { display:grid; grid-template-columns:1fr 1.3fr; gap:12px; margin:0; } dd { margin:0; overflow-wrap:anywhere; } fieldset { display:grid; gap:16px; border:0; padding:0; margin:0; } label { display:grid; gap:8px; font-size:var(--text-sm); } input { width:100%; min-width:0; padding:10px; color:var(--text-primary); background:var(--surface-sunken); border:1px solid var(--border-control); border-radius:var(--radius-sm); } .fields>* { flex:1; min-width:120px; } pre { white-space:pre-wrap; overflow-wrap:anywhere; padding:16px; background:var(--surface-sunken); font-size:var(--text-xs); } .table-wrap { overflow:auto; } table { width:100%; border-collapse:collapse; text-align:left; font-size:var(--text-sm); } td,th { padding:12px; border-bottom:1px solid var(--border-subtle); } small { display:block; color:var(--text-secondary); font-size:var(--text-xs); margin-top:4px; } @media(max-width:900px) { .grid { grid-template-columns:1fr; } }
</style>
+46
View File
@@ -0,0 +1,46 @@
<script lang="ts">
import { page } from '$app/stores';
import Button from '$ui/components/primitives/Button.svelte';
import SectionHeader from '$ui/components/primitives/SectionHeader.svelte';
let username = $state(''); let password = $state(''); let busy = $state(false); let error = $state('');
function destination(): string {
const next = $page.url.searchParams.get('next');
if (!next?.startsWith('/') || next.startsWith('//') || /[\\\x00-\x1f]/.test(next)) return '/';
const target = new URL(next, window.location.origin);
return target.origin === window.location.origin && !['/login', '/setup'].includes(target.pathname) ? target.pathname + target.search + target.hash : '/';
}
async function login(event: SubmitEvent) {
event.preventDefault(); if (busy) return; busy = true; error = '';
try {
const response = await fetch('/api/auth/login', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ username, password }) });
const body = await response.json();
if (!response.ok) throw new Error(body.error ?? 'Could not sign in. Try again.');
password = '';
window.location.assign(body.user?.mustChangePassword ? '/account' : destination());
} catch (cause) { error = (cause as Error).message; password = ''; busy = false; }
}
</script>
<svelte:head><title>Sign in · Electric Cherry</title><meta name="robots" content="noindex" /></svelte:head>
<section class="auth-card">
<SectionHeader title="Sign in" subtitle="Your game servers, hosts, and operations in one place." />
{#if error}<p class="error" role="alert">{error}</p>{/if}
<form onsubmit={login} aria-busy={busy}>
<fieldset disabled={busy}>
<label for="username">Username<input id="username" name="username" autocomplete="username" autocapitalize="none" spellcheck="false" required maxlength="64" bind:value={username} /></label>
<label for="password">Password<input id="password" name="password" type="password" autocomplete="current-password" required maxlength="128" bind:value={password} /></label>
<Button type="submit" variant="primary" loading={busy} disabled={busy}>Sign in</Button>
</fieldset>
</form>
<p class="help">Need access or a password reset? Contact an Electric Cherry administrator.</p>
{#if $page.data.setupRequired}<a class="setup-link" href="/setup">Set up the first administrator →</a>{/if}
</section>
<style>
.auth-card { padding:32px; border:1px solid var(--border-strong); border-radius:var(--radius-lg); background:var(--surface); box-shadow:0 18px 50px #0002; }
form { margin-top:28px; } fieldset { display:grid; gap:20px; border:0; margin:0; padding:0; min-width:0; }
label { display:grid; gap:8px; font-size:var(--text-sm); } input { width:100%; padding:12px; background:var(--surface-sunken); color:var(--text-primary); border:1px solid var(--border-control); border-radius:var(--radius-md); font:inherit; }
.help,.setup-link { color:var(--text-secondary); font-size:var(--text-sm); line-height:1.6; } .help { margin:24px 0 0; } .setup-link { display:block; margin-top:16px; color:var(--accent-primary-hover); }
.error { color:var(--state-danger); font-size:var(--text-sm); padding:12px; border:1px solid var(--state-danger); border-radius:var(--radius-md); overflow-wrap:anywhere; }
@media(max-width:540px) { .auth-card { padding:24px; } }
</style>
+1 -1
View File
@@ -117,7 +117,7 @@
<div><dt>First detected</dt><dd>{$timeFormat.dateTime(incident.first_seen_at)}</dd></div>
<div><dt>Last observed</dt><dd>{$timeFormat.dateTime(incident.last_seen_at)}</dd></div>
{#if incident.finished_at}<div><dt>Finished</dt><dd>{$timeFormat.dateTime(incident.finished_at)}</dd></div>{/if}
{#if evidence.containerState}<div><dt>Observed Komodo state</dt><dd>{evidence.containerState}</dd></div>{/if}
{#if evidence.containerState}<div><dt>Observed container state</dt><dd>{evidence.containerState}</dd></div>{/if}
</dl>
{#if evidence.reason && evidence.reason !== incident.summary}<p class="observed-reason">Observed reason: {evidence.reason}</p>{/if}
{#if !incident.server_deleted_at}<div class="links"><a href={`/servers/${encodeURIComponent(incident.server)}?tab=diagnostics`}>Diagnose connection</a><a href={`/servers/${encodeURIComponent(incident.server)}?tab=logs`}>Open server logs</a></div>{/if}
+19 -13
View File
@@ -77,6 +77,9 @@
rcon_status?: import('$lib/server-observation.js').GameObservation;
telemetry?: import('$lib/telemetry.js').GameTelemetry;
name: string;
native_instance_id?: string | null; host_id?: string | null; instance_generation?: number | null; game_library_id?: string | null;
host?: import('$lib/hosts.js').Host | null;
network?: { bindAddress: string; localPort: number; publicAddress: string | null; publicPort: number | null } | null;
komodo_type: string; komodo_name: string; komodo_server: string | null;
game_port: number | null; rcon_host: string | null;
game: string | null; map: string | null;
@@ -103,6 +106,7 @@
let name = $derived($page.params.name ?? '');
let server = $derived(($fleetServers.find(s => s.name === name) as Server | undefined) ?? null);
const native = $derived(!!server?.native_instance_id);
let integrations = $derived($fleetIntegrations);
let history = $state<DeploymentRow[]>([]);
let inventory = $state<InventoryEntry[]>([]);
@@ -311,8 +315,8 @@
{#if $fleetError}<div class="ui-error" role="alert">{$fleetError}<Button size="sm" onclick={refreshFleet}>Retry</Button></div>{/if}
{#if hasDiagnosticIssue}
<section class="connection-issue" aria-label="Server connection issue">
<div><strong>{effectiveHealth === 'stale' ? 'Server report is outdated' : 'Server is not responding'}</strong><p>Player count is unknown. {effectiveSeen ? `Last successful report ${ago(effectiveSeen, $uiNow)}.` : 'No successful report has been received.'} {integrations && !integrations.rcon ? 'RCON is not configured.' : ''}</p></div>
<div class="issue-actions"><Button size="sm" onclick={() => changeTab('diagnostics')}>Diagnose connection</Button><Button size="sm" variant="ghost" onclick={() => changeTab('logs')}>Open logs</Button>{#if integrations && (!integrations.rcon || !integrations.komodo)}<Button size="sm" href="/settings">Check system setup</Button>{/if}</div>
<div><strong>{effectiveHealth === 'stale' ? 'Server report is outdated' : 'Server is not responding'}</strong><p>Player count is unknown. {effectiveSeen ? `Last successful report ${ago(effectiveSeen, $uiNow)}.` : 'No successful report has been received.'} {native && !server.host?.connected ? 'The host agent is offline; the game may still be running.' : !native && integrations && !integrations.rcon ? 'RCON is not configured.' : ''}</p></div>
<div class="issue-actions"><Button size="sm" onclick={() => changeTab('diagnostics')}>Diagnose connection</Button><Button size="sm" variant="ghost" onclick={() => changeTab('logs')}>Open logs</Button>{#if native && server.host_id}<Button size="sm" href={`/hosts/${server.host_id}`}>Check host</Button>{:else if integrations && (!integrations.rcon || !integrations.komodo)}<Button size="sm" href="/settings">Check system setup</Button>{/if}</div>
</section>
{/if}
<div class="tabs-row">
@@ -382,7 +386,9 @@
</div>
<p class="sub">Current player count: {server.status_source === 'telemetry' ? 'game telemetry' : server.status_source === 'rcon' ? 'RCON' : 'unavailable'}. RCON probes below show that connection's separate results.</p>
{#if integrations}
{#if native}
<div class="diag-grid"><div class="kv"><span class="k">Game connection</span><span class="v">RCON runs locally through the host agent</span></div><div class="kv"><span class="k">Host agent</span><span class="v {server.host?.connected ? 'ok' : 'bad'}">{server.host?.connectionState ?? 'unknown'}</span></div><div class="kv"><span class="k">Last heartbeat</span><span class="v">{server.host?.lastHeartbeat ? ago(server.host.lastHeartbeat, $uiNow) : 'Not received'}</span></div></div>
{:else if integrations}
<div class="diag-grid">
<div class="kv"><span class="k">Endpoint</span><span class="v mono">{endpointLabel(effectiveEndpoint)}</span></div>
<div class="kv"><span class="k">RCON password</span><span class="v mono {integrations.rcon ? 'ok' : 'bad'}">{integrations.rcon ? 'configured' : 'missing'}</span></div>
@@ -396,7 +402,7 @@
<Badge variant="danger" uppercase>{effectiveReason}</Badge>
<span class="alert-detail">{errorDetail}</span>
</div>
<div class="hint">{rconFixHint(effectiveReason)}</div>
<div class="hint">{native ? 'Inspect the host agent connection and game logs. RCON is sent locally by the agent; public game-port forwarding does not affect this check.' : rconFixHint(effectiveReason)}</div>
{:else if effectiveErr}
<div class="alert alert-danger">
<Badge variant="neutral" uppercase>Error</Badge>
@@ -412,19 +418,19 @@
{/if}
<div class="probe-actions">
{#if effectiveEndpoint}
{#if effectiveEndpoint || native}
<Button variant="secondary" size="sm" onclick={probeRcon} disabled={probingRcon} loading={probingRcon}>
Probe RCON
{native ? 'Probe RCON through agent' : 'Probe RCON'}
</Button>
{/if}
{#if !effectiveEndpoint && integrations?.komodo}
{#if !native && !effectiveEndpoint && integrations?.komodo}
<Button variant="secondary" size="sm" onclick={probeKomodo} disabled={probing} loading={probing}>
Probe Komodo
</Button>
{/if}
<Button variant="secondary" size="sm" onclick={diagnoseNetwork} disabled={diagnosing} loading={diagnosing}>
{#if !native}<Button variant="secondary" size="sm" onclick={diagnoseNetwork} disabled={diagnosing} loading={diagnosing}>
Diagnose network
</Button>
</Button>{:else if server.host_id}<Button variant="secondary" size="sm" href={`/hosts/${server.host_id}`}>Inspect host</Button>{/if}
</div>
{#if rconProbe?.ok}
@@ -438,7 +444,7 @@
</div>
{/if}
{#if netDiagnosis}
{#if !native && netDiagnosis}
{#if netDiagnosis.ok && netDiagnosis.results}
<!-- svelte-ignore a11y_no_noninteractive_tabindex (Keyboard users need to scroll the table.) -->
<div class="table-scroll" role="region" aria-label="Network diagnostics" tabindex="0">
@@ -484,7 +490,7 @@
{/if}
{/if}
{#if komodoProbe}
{#if !native && komodoProbe}
{#if komodoProbe.ok}
<div class="alert alert-ok">
<Badge variant="online" uppercase>Resolved</Badge>
@@ -521,8 +527,8 @@
<h2>Configuration</h2>
</div>
<div class="diag-grid">
<div class="kv"><span class="k">Komodo</span><span class="v mono">{server.komodo_type} · {server.komodo_name}</span></div>
<div class="kv"><span class="k">RCON override</span><span class="v mono">{server.rcon_host ?? '(default)'}</span></div>
{#if native}<div class="kv"><span class="k">Host</span><span class="v"><a href={`/hosts/${server.host_id}`}>{server.host?.name ?? server.host_id}</a></span></div><div class="kv"><span class="k">Instance</span><span class="v mono">{server.native_instance_id}</span></div><div class="kv"><span class="k">Generation</span><span class="v mono">{server.instance_generation}</span></div><div class="kv"><span class="k">Game-file version</span><span class="v mono"><a href="/game-files">{server.game_library_id}</a></span></div>{#if server.network}<div class="kv"><span class="k">Local UDP binding</span><span class="v mono">{server.network.bindAddress}:{server.network.localPort}</span></div><div class="kv"><span class="k">Public / NAT endpoint</span><span class="v mono">{server.network.publicAddress || server.host?.publicAddress || server.host?.hostname || 'Not configured'}:{server.network.publicPort ?? server.network.localPort}</span></div>{/if}
{:else}<div class="kv"><span class="k">Komodo</span><span class="v mono">{server.komodo_type} · {server.komodo_name}</span></div><div class="kv"><span class="k">RCON override</span><span class="v mono">{server.rcon_host ?? '(default)'}</span></div>{/if}
<div class="kv"><span class="k">Game port</span><span class="v mono">{server.game_port ?? '(unresolved)'}</span></div>
<div class="kv"><span class="k">Game</span><span class="v mono">{server.game ?? '(unset)'}</span></div>
<div class="kv"><span class="k">Map</span><span class="v mono">{server.map ?? '(unset)'}</span></div>
+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>
+44
View File
@@ -0,0 +1,44 @@
<script lang="ts">
import Button from '$ui/components/primitives/Button.svelte';
import SectionHeader from '$ui/components/primitives/SectionHeader.svelte';
let token = $state(''); let username = $state(''); let displayName = $state('');
let password = $state(''); let confirmPassword = $state(''); let busy = $state(false); let error = $state('');
async function setup(event: SubmitEvent) {
event.preventDefault(); if (busy) return; error = '';
if (password !== confirmPassword) { error = 'The passwords do not match.'; return; }
busy = true;
try {
const response = await fetch('/api/auth/setup', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ token, username, displayName, password }) });
const body = await response.json(); if (!response.ok) throw new Error(body.error ?? 'Could not create the administrator.');
token = ''; password = ''; confirmPassword = '';
window.location.assign(body.user?.mustChangePassword ? '/account' : '/');
} catch (cause) { error = (cause as Error).message; busy = false; }
}
</script>
<svelte:head><title>Initial setup · Electric Cherry</title><meta name="robots" content="noindex" /></svelte:head>
<section class="auth-card">
<SectionHeader title="Create the first administrator" subtitle="Secure this controller and invite your team." />
<p class="help">Use the setup token configured by the controller owner. This step is available only until the first administrator is created.</p>
{#if error}<p class="error" role="alert">{error}</p>{/if}
<form onsubmit={setup} aria-busy={busy}><fieldset disabled={busy}>
<label for="setup-token">Setup token<input id="setup-token" name="token" type="password" required minlength="32" autocomplete="off" spellcheck="false" bind:value={token} aria-describedby="token-help" /></label>
<p id="token-help" class="hint">Provided through the controller’s <code>EC_SETUP_TOKEN</code> setting.</p>
<label for="setup-username">Username<input id="setup-username" name="username" required minlength="3" maxlength="64" pattern={'[a-zA-Z0-9][a-zA-Z0-9_.\\-]{2,63}'} autocomplete="username" autocapitalize="none" spellcheck="false" bind:value={username} aria-describedby="username-help" /></label>
<p id="username-help" class="hint">3–64 characters. Start with a letter or number; periods, underscores, and hyphens are allowed.</p>
<label for="setup-name">Display name<input id="setup-name" name="displayName" required maxlength="100" autocomplete="name" bind:value={displayName} /></label>
<label for="setup-password">Password<input id="setup-password" name="password" type="password" required minlength="15" maxlength="128" autocomplete="new-password" bind:value={password} aria-describedby="password-help" /></label>
<p id="password-help" class="hint">Use 15–128 characters. A long, unique passphrase works well.</p>
<label for="setup-confirm">Confirm password<input id="setup-confirm" name="confirmPassword" type="password" required minlength="15" maxlength="128" autocomplete="new-password" bind:value={confirmPassword} /></label>
<Button type="submit" variant="primary" disabled={busy} loading={busy}>Create administrator</Button>
</fieldset></form>
<a class="login-link" href="/login">Already have an account? Sign in</a>
</section>
<style>
.auth-card { padding:32px; border:1px solid var(--border-strong); border-radius:var(--radius-lg); background:var(--surface); box-shadow:0 18px 50px #0002; }
form { margin-top:24px; } fieldset { display:grid; gap:16px; border:0; margin:0; padding:0; min-width:0; } label { display:grid; gap:8px; font-size:var(--text-sm); } input { width:100%; padding:12px; background:var(--surface-sunken); color:var(--text-primary); border:1px solid var(--border-control); border-radius:var(--radius-md); font:inherit; }
.help,.hint,.login-link { font-size:var(--text-sm); color:var(--text-secondary); line-height:1.6; } .help { margin:20px 0 0; } .hint { margin:-8px 0 0; font-size:var(--text-xs); } .login-link { display:block; margin-top:24px; color:var(--accent-primary-hover); }
.error { color:var(--state-danger); font-size:var(--text-sm); padding:12px; border:1px solid var(--state-danger); border-radius:var(--radius-md); overflow-wrap:anywhere; }
@media(max-width:540px) { .auth-card { padding:24px; } }
</style>