Add Super Viewer read-only administrator role
CI / checks (push) Successful in 5m40s

This commit is contained in:
2026-10-11 23:30:40 -04:00
parent 253d47300c
commit 4ea291d305
48 changed files with 1684 additions and 933 deletions
+14 -1
View File
@@ -1,4 +1,4 @@
export type UserRole = 'admin' | 'operator' | 'viewer';
export type UserRole = 'admin' | 'super_viewer' | 'operator' | 'viewer';
/** Reserved identity for work requested by the controller. */
export const systemUsername = 'Maxis';
@@ -17,6 +17,19 @@ export interface AuthUser {
export const roleLabels: Record<UserRole, string> = {
admin: 'Administrator',
super_viewer: 'Super Viewer',
operator: 'Operator',
viewer: 'Viewer'
};
export function canOperate(role: UserRole | null | undefined): boolean {
return role === 'admin' || role === 'operator';
}
export function canReadAdmin(role: UserRole | null | undefined): boolean {
return role === 'admin' || role === 'super_viewer';
}
export function canReadSensitive(role: UserRole | null | undefined): boolean {
return canOperate(role) || role === 'super_viewer';
}
+2 -2
View File
@@ -1,4 +1,4 @@
import type { UserRole } from './auth.js';
import { canReadAdmin, type UserRole } from './auth.js';
export interface NavigationItem {
label: string;
@@ -99,7 +99,7 @@ export const navigationGroups: NavigationGroup[] = [
];
export function visibleNavigation(role: UserRole): NavigationGroup[] {
return navigationGroups.filter((group) => !group.admin || role === 'admin');
return navigationGroups.filter((group) => !group.admin || canReadAdmin(role));
}
export function navigationItemActive(item: NavigationItem, path: string): boolean {
+18 -5
View File
@@ -1,7 +1,14 @@
import type { UserRole } from '../../auth.js';
export type RoutePermission =
'public' | 'machine' | 'authenticated' | 'viewer' | 'operator' | 'admin';
| 'public'
| 'machine'
| 'authenticated'
| 'viewer'
| 'operator-read'
| 'admin-read'
| 'operator'
| 'admin';
const within = (route: string, base: string): boolean =>
route === base || route.startsWith(base + '/');
@@ -41,16 +48,19 @@ export function routePermission(routeId: string | null, method: string): RoutePe
)
return 'authenticated';
// This POST only calculates a preview; it never saves settings or runs cleanup.
if (routeId === '/api/settings/retention/preview' && method === 'POST') return 'admin-read';
if (within(routeId, '/api/auth')) return 'admin';
if (
within(routeId, '/api/users') ||
within(routeId, '/settings') ||
within(routeId, '/api/settings') ||
within(routeId, '/api/auth') ||
within(routeId, '/api/server-keys') ||
within(routeId, '/server-keys') ||
within(routeId, '/keys')
)
return 'admin';
return reading ? 'admin-read' : 'admin';
if (!reading && ['/api/hosts', '/api/game-files'].some((base) => within(routeId, base)))
return 'admin';
@@ -76,17 +86,20 @@ export function routePermission(routeId: string | null, method: string): RoutePe
routeId === '/api/servers/[name]/telemetry-requests' ||
routeId === '/api/servers/[name]/rcon-diagnostics'
)
return 'operator';
return reading ? 'operator-read' : 'operator';
return reading ? 'viewer' : 'operator';
}
/** A browser role never substitutes for a machine credential. */
export function roleAllows(role: UserRole, permission: RoutePermission): boolean {
if (!['viewer', 'operator', 'admin'].includes(role)) return false;
if (!['viewer', 'super_viewer', 'operator', 'admin'].includes(role)) return false;
if (permission === 'machine') return false;
if (permission === 'public' || permission === 'authenticated' || permission === 'viewer')
return true;
if (permission === 'operator') return role === 'operator' || role === 'admin';
if (permission === 'operator-read')
return role === 'super_viewer' || role === 'operator' || role === 'admin';
if (permission === 'admin-read') return role === 'super_viewer' || role === 'admin';
return permission === 'admin' && role === 'admin';
}
+5 -1
View File
@@ -165,7 +165,11 @@ export const authenticationHandle: Handle = async ({ event, resolve }) => {
const message =
permission === 'admin'
? 'Administrator access is required.'
: 'Operator access is required for this action.';
: permission === 'admin-read'
? 'Administrator or Super Viewer access is required.'
: permission === 'operator-read'
? 'Operator, Administrator or Super Viewer access is required.'
: 'Operator access is required for this action.';
if (api)
return json(
{ error: message },
+2 -2
View File
@@ -81,8 +81,8 @@ function displayName(value: unknown, fallback: string): string {
return name.trim();
}
function role(value: unknown): UserRole {
if (!['admin', 'operator', 'viewer'].includes(value as string))
throw new AuthError('Choose administrator, operator or viewer.');
if (!['admin', 'super_viewer', 'operator', 'viewer'].includes(value as string))
throw new AuthError('Choose administrator, super viewer, operator or viewer.');
return value as UserRole;
}
export function recordAudit(
+29
View File
@@ -23,3 +23,32 @@ export function migrateAuthSchema(handle: Database.Database): void {
CREATE INDEX IF NOT EXISTS auth_audit_created_at ON auth_audit(created_at);
`);
}
/** Run with foreign keys disabled by the migration runner before its transaction. */
export function migrateSuperViewerRole(handle: Database.Database): void {
const schema = handle
.prepare("SELECT sql FROM sqlite_master WHERE type='table' AND name='auth_users'")
.get() as { sql: string };
if (schema.sql.includes("'super_viewer'")) return;
if (handle.pragma('foreign_keys', { simple: true }))
throw new Error('The auth role migration requires foreign keys to be disabled');
// Rebuild the parent table without renaming it first, so session references
// keep pointing to auth_users and existing sessions are not cascade-deleted.
handle.exec(`
CREATE TABLE auth_users_super_viewer (
id TEXT PRIMARY KEY, username TEXT NOT NULL COLLATE NOCASE UNIQUE,
display_name TEXT NOT NULL, password_hash TEXT NOT NULL,
role TEXT NOT NULL CHECK(role IN ('admin','super_viewer','operator','viewer')),
disabled INTEGER NOT NULL DEFAULT 0, must_change_password INTEGER NOT NULL DEFAULT 0,
created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL, last_login_at INTEGER
);
INSERT INTO auth_users_super_viewer (
id,username,display_name,password_hash,role,disabled,must_change_password,
created_at,updated_at,last_login_at
) SELECT id,username,display_name,password_hash,role,disabled,must_change_password,
created_at,updated_at,last_login_at FROM auth_users;
DROP TABLE auth_users;
ALTER TABLE auth_users_super_viewer RENAME TO auth_users;
`);
}
+7 -1
View File
@@ -14,7 +14,7 @@ import {
} from '../reports/revisions.js';
import { enableIncrementalVacuum, retentionClocks } from './retention-clocks.js';
import { migrateHostSchema } from '../hosts/schema.js';
import { migrateAuthSchema } from '../auth/schema.js';
import { migrateAuthSchema, migrateSuperViewerRole } from '../auth/schema.js';
import { migrateGameFileSchema } from '../game-files/schema.js';
import type { Migration } from './migration-runner.js';
@@ -91,6 +91,12 @@ export const migrations: readonly Migration[] = [
version: 16,
name: 'metrics player history and report revisions',
run: installMetricsPlayerRevisions
},
{
version: 17,
name: 'super viewer authentication role',
mode: 'foreign-keys-off',
run: migrateSuperViewerRole
}
];
+19 -20
View File
@@ -1,4 +1,4 @@
import type { UserRole } from '../auth.js';
import { canOperate, type UserRole } from '../auth.js';
import { visibleNavigation } from '../navigation.js';
import { playerProfileHref } from '../leaderboard.js';
import { serverTitle } from './server-labels.js';
@@ -48,25 +48,24 @@ function matches(query: string, ...values: Array<string | null | undefined>) {
}
export function palettePageResults(role: UserRole, query: string): PaletteItem[] {
const actions: PaletteItem[] =
role === 'viewer'
? []
: [
{
key: 'action:create',
group: 'Actions',
label: 'Create server',
detail: 'Choose a map and host',
href: '/?action=create'
},
{
key: 'action:manage',
group: 'Actions',
label: 'Manage servers',
detail: 'Server queues and lifecycle actions',
href: '/provisioning'
}
];
const actions: PaletteItem[] = !canOperate(role)
? []
: [
{
key: 'action:create',
group: 'Actions',
label: 'Create server',
detail: 'Choose a map and host',
href: '/?action=create'
},
{
key: 'action:manage',
group: 'Actions',
label: 'Manage servers',
detail: 'Server queues and lifecycle actions',
href: '/provisioning'
}
];
if (role === 'admin')
actions.push({
key: 'action:add-host',
+184 -176
View File
@@ -2,6 +2,7 @@
import { serverTitle } from '$ui/server-labels.js';
import RelativeTime from '$ui/components/RelativeTime.svelte';
import { page } from '$app/stores';
import { canOperate } from '$lib/auth.js';
import { contextHref } from '$lib/navigation-context.js';
import { onMount } from 'svelte';
import { pollVisible } from '$ui/poll.js';
@@ -33,6 +34,7 @@
family: Family;
}
let { id, family }: Props = $props();
const editable = $derived(canOperate($page.data?.user?.role));
const basePath = $derived(`/${family}s`);
const crumbLabel = $derived(family.charAt(0).toUpperCase() + family.slice(1) + 's');
@@ -228,7 +230,7 @@
onMount(() => pollVisible(() => load({ seedForm: false }), 10000, false));
function openScopeModal(): void {
if (runningBetaId !== null) return;
if (!editable || runningBetaId !== null) return;
seedScopeForm();
flash = null;
scopeReviewOpen = true;
@@ -240,7 +242,7 @@
scopeDialog?.close();
}
function openUploadModal(): void {
if (runningBetaId !== null) return;
if (!editable || runningBetaId !== null) return;
releaseVersion = '';
versionFile = null;
versionNotes = '';
@@ -273,7 +275,7 @@
async function uploadVersion(e: Event): Promise<void> {
e.preventDefault();
if (!versionFile || uploadingVersion) return;
if (!editable || !versionFile || uploadingVersion) return;
uploadingVersion = true;
flash = null;
const form = new FormData();
@@ -304,7 +306,7 @@
async function saveScope(e: Event): Promise<void> {
e.preventDefault();
if (updatingScope || !scopeImpact || scopePreviewLoading) return;
if (!editable || updatingScope || !scopeImpact || scopePreviewLoading) return;
if (scopeType !== 'none' && !editedScopeValue) {
flash = `Pick a ${scopeType}`;
return;
@@ -351,6 +353,7 @@
}
async function deleteArtifact(): Promise<void> {
if (!editable) return;
if (
!(await confirmAction({
title: 'Delete ' + family + '?',
@@ -428,13 +431,15 @@
variant="primary"
href={contextHref(`/betas/${runningBetaId}`, $page.url)}
>View active beta</Button
>{:else if latestVersion.id !== a.current_version_id}<Button
>{:else if editable && latestVersion.id !== a.current_version_id}<Button
variant="primary"
href={contextHref(
betaSelectionHref([{ scriptId: id, versionId: latestVersion.id }]),
$page.url
)}>Test latest version</Button
>{:else}<span class="dim">Latest version is promoted</span>{/if}
>{:else if latestVersion.id === a.current_version_id}<span class="dim"
>Latest version is promoted</span
>{/if}
</div></Card
>{/if}
@@ -450,24 +455,24 @@
<Card padding="lg">
<div class="panel-head">
<h2>Details</h2>
<div class="head-actions">
<Button
variant="secondary"
size="sm"
onclick={openScopeModal}
disabled={runningBetaId !== null}
>
Edit scope
</Button>
<Button
variant="secondary"
size="sm"
onclick={openUploadModal}
disabled={runningBetaId !== null}
>
Upload new version
</Button>
</div>
{#if editable}<div class="head-actions">
<Button
variant="secondary"
size="sm"
onclick={openScopeModal}
disabled={runningBetaId !== null}
>
Edit scope
</Button>
<Button
variant="secondary"
size="sm"
onclick={openUploadModal}
disabled={runningBetaId !== null}
>
Upload new version
</Button>
</div>{/if}
</div>
<details class="file-settings">
@@ -584,7 +589,7 @@
<Badge variant="online" size="sm">Promoted</Badge>
{:else}
<StatusBadge state={v.state} size="sm" />
{#if runningBetaId === null}<Button
{#if editable && runningBetaId === null}<Button
size="sm"
href={contextHref(
betaSelectionHref([{ scriptId: id, versionId: v.id }]),
@@ -604,7 +609,7 @@
<ListFooter total={versions.length} bind:limit label="versions" />
</Card>
{#if family === 'plugin'}
{#if editable && family === 'plugin'}
<div id="github-releases">
<PluginGithubImport
pluginId={id}
@@ -615,170 +620,173 @@
</div>
{/if}
<Card padding="lg">
<div class="panel-head"><h2>Danger zone</h2></div>
<Button variant="danger" onclick={deleteArtifact}>Delete {family}</Button>
</Card>
{#if editable}<Card padding="lg">
<div class="panel-head"><h2>Danger zone</h2></div>
<Button variant="danger" onclick={deleteArtifact}>Delete {family}</Button>
</Card>{/if}
{#if flash}<div class="alert" role="alert">{flash}</div>{/if}
<!-- Scope dialog -->
<dialog
aria-label="Edit scope"
onclose={() => (scopeReviewOpen = false)}
oncancel={(event) => {
if (updatingScope) event.preventDefault();
}}
bind:this={scopeDialog}
onclick={(e) => onDialogClick(scopeDialog, e)}
>
<form class="dlg" onsubmit={saveScope}>
<header class="dlg-head">
<h3>Edit scope</h3>
<button
type="button"
class="close"
disabled={updatingScope}
onclick={cancelScopeModal}
aria-label="Close">×</button
>
</header>
<Field label="Scope">
<div class="scope-row">
<Select
ariaLabel="Scope type"
bind:value={scopeType}
options={scopeTypeOptions}
{#if editable}<dialog
aria-label="Edit scope"
onclose={() => (scopeReviewOpen = false)}
oncancel={(event) => {
if (updatingScope) event.preventDefault();
}}
bind:this={scopeDialog}
onclick={(e) => onDialogClick(scopeDialog, e)}
>
<form class="dlg" onsubmit={saveScope}>
<header class="dlg-head">
<h3>Edit scope</h3>
<button
type="button"
class="close"
disabled={updatingScope}
/>
{#if scopeType === 'game' || scopeType === 'map'}
onclick={cancelScopeModal}
aria-label="Close">×</button
>
</header>
<Field label="Scope">
<div class="scope-row">
<Select
ariaLabel="Scope game"
bind:value={scopeGame}
onchange={() => (scopeMap = '')}
options={gameOptions}
ariaLabel="Scope type"
bind:value={scopeType}
options={scopeTypeOptions}
disabled={updatingScope}
/>
{/if}
{#if scopeType === 'map'}
<Select
ariaLabel="Scope map"
bind:value={scopeMap}
options={mapOptions}
disabled={updatingScope || !scopeGame}
/>
{/if}
{#if scopeType === 'game' || scopeType === 'map'}
<Select
ariaLabel="Scope game"
bind:value={scopeGame}
onchange={() => (scopeMap = '')}
options={gameOptions}
disabled={updatingScope}
/>
{/if}
{#if scopeType === 'map'}
<Select
ariaLabel="Scope map"
bind:value={scopeMap}
options={mapOptions}
disabled={updatingScope || !scopeGame}
/>
{/if}
</div>
</Field>
{#if scopePreviewLoading}<p role="status">
Reviewing eligible servers…
</p>{:else if scopePreviewError}<p role="alert">
{scopePreviewError}
</p>{:else if scopeImpact}<section
class="scope-impact"
aria-label="Scope change review"
>
<strong
>{scopeImpact.after.length} eligible · {scopeImpact.added.length} added
·
{scopeImpact.removed.length} removed</strong
>{#if scopeImpact.added.length}<p>
Added: {scopeImpact.added.join(', ')}
</p>{/if}{#if scopeImpact.removed.length}<p>
Removed: {scopeImpact.removed.join(', ')}
</p>{/if}
<p>
{scopeImpact.composed
? 'This changes intended configuration composition.'
: scopeType === 'none'
? `${scopeImpact.detached.length} recorded installations will become untracked. Maintenance will detect orphaned files as drift.`
: 'Scope describes eligibility for promoted files.'}
</p>
<p>
When automation is enabled, Maintenance rebuilds servers with drift
after verifying a safe server state. Rebuilds delete the existing
instance and all of its files.
</p>
</section>{/if}
<div class="dlg-actions">
<Button variant="ghost" onclick={cancelScopeModal} disabled={updatingScope}
>Cancel</Button
>
<Button
type="submit"
variant="primary"
disabled={updatingScope || scopePreviewLoading || !scopeImpact}
loading={updatingScope}
>
Save reviewed scope
</Button>
</div>
</Field>
{#if scopePreviewLoading}<p role="status">
Reviewing eligible servers…
</p>{:else if scopePreviewError}<p role="alert">
{scopePreviewError}
</p>{:else if scopeImpact}<section
class="scope-impact"
aria-label="Scope change review"
>
<strong
>{scopeImpact.after.length} eligible · {scopeImpact.added.length} added ·
{scopeImpact.removed.length} removed</strong
>{#if scopeImpact.added.length}<p>
Added: {scopeImpact.added.join(', ')}
</p>{/if}{#if scopeImpact.removed.length}<p>
Removed: {scopeImpact.removed.join(', ')}
</p>{/if}
<p>
{scopeImpact.composed
? 'This changes intended configuration composition.'
: scopeType === 'none'
? `${scopeImpact.detached.length} recorded installations will become untracked. Maintenance will detect orphaned files as drift.`
: 'Scope describes eligibility for promoted files.'}
</p>
<p>
When automation is enabled, Maintenance rebuilds servers with drift
after verifying a safe server state. Rebuilds delete the existing
instance and all of its files.
</p>
</section>{/if}
<div class="dlg-actions">
<Button variant="ghost" onclick={cancelScopeModal} disabled={updatingScope}
>Cancel</Button
>
<Button
type="submit"
variant="primary"
disabled={updatingScope || scopePreviewLoading || !scopeImpact}
loading={updatingScope}
>
Save reviewed scope
</Button>
</div>
{#if flash}<div class="alert alert-danger" role="alert">{flash}</div>{/if}
</form>
</dialog>
{#if flash}<div class="alert alert-danger" role="alert">{flash}</div>{/if}
</form>
</dialog>
<!-- Upload dialog -->
<dialog
aria-label="Upload new version"
oncancel={(event) => {
if (uploadingVersion) event.preventDefault();
}}
bind:this={uploadDialog}
onclick={(e) => onDialogClick(uploadDialog, e)}
>
<form class="dlg" onsubmit={uploadVersion}>
<header class="dlg-head">
<h3>Upload new version</h3>
<button
type="button"
class="close"
disabled={uploadingVersion}
onclick={cancelUploadModal}
aria-label="Close">×</button
>
</header>
<Field label="File">
<Input
type="file"
accept={family === 'plugin' ? '.dll' : undefined}
onchange={pickVersionFile}
disabled={uploadingVersion}
/>
</Field>
{#if family === 'plugin'}
<Field
label="Release version"
hint="Optional. Use the publisher’s version or release tag, such as v3.1.0."
>
<!-- Upload dialog -->
<dialog
aria-label="Upload new version"
oncancel={(event) => {
if (uploadingVersion) event.preventDefault();
}}
bind:this={uploadDialog}
onclick={(e) => onDialogClick(uploadDialog, e)}
>
<form class="dlg" onsubmit={uploadVersion}>
<header class="dlg-head">
<h3>Upload new version</h3>
<button
type="button"
class="close"
disabled={uploadingVersion}
onclick={cancelUploadModal}
aria-label="Close">×</button
>
</header>
<Field label="File">
<Input
bind:value={releaseVersion}
placeholder="v3.1.0"
type="file"
accept={family === 'plugin' ? '.dll' : undefined}
onchange={pickVersionFile}
disabled={uploadingVersion}
/>
</Field>
{/if}
<Field label="Notes" hint="Optional. What changed since the previous version.">
<Input
bind:value={versionNotes}
placeholder="what changed"
disabled={uploadingVersion}
/>
</Field>
<div class="dlg-actions">
<Button variant="ghost" onclick={cancelUploadModal} disabled={uploadingVersion}
>Cancel</Button
>
<Button
type="submit"
variant="primary"
disabled={uploadingVersion || !versionFile}
loading={uploadingVersion}
>
Upload version
</Button>
</div>
{#if flash}<div class="alert alert-danger" role="alert">{flash}</div>{/if}
</form>
</dialog>
{#if family === 'plugin'}
<Field
label="Release version"
hint="Optional. Use the publisher’s version or release tag, such as v3.1.0."
>
<Input
bind:value={releaseVersion}
placeholder="v3.1.0"
disabled={uploadingVersion}
/>
</Field>
{/if}
<Field label="Notes" hint="Optional. What changed since the previous version.">
<Input
bind:value={versionNotes}
placeholder="what changed"
disabled={uploadingVersion}
/>
</Field>
<div class="dlg-actions">
<Button
variant="ghost"
onclick={cancelUploadModal}
disabled={uploadingVersion}>Cancel</Button
>
<Button
type="submit"
variant="primary"
disabled={uploadingVersion || !versionFile}
loading={uploadingVersion}
>
Upload version
</Button>
</div>
{#if flash}<div class="alert alert-danger" role="alert">{flash}</div>{/if}
</form>
</dialog>{/if}
</div>
{/if}
@@ -7,6 +7,7 @@
import { timeFormat } from '$ui/stores/time-zone.js';
import { tick } from 'svelte';
import { page } from '$app/stores';
import { canOperate } from '$lib/auth.js';
import { contextHref } from '$lib/navigation-context.js';
import type { BetaDeploymentServer } from '$ui/betas.js';
import Badge from './primitives/Badge.svelte';
@@ -52,13 +53,14 @@
evidenceKind = $state(''),
evidenceQuery = $state('');
let evidenceSection = $state<HTMLDetailsElement>();
const editable = $derived(canOperate($page.data?.user?.role));
const hasDeletedServers = $derived(
deletedParticipants ||
test.servers.some((server) => server.server_deleted_at) ||
deployments?.some((server) => server.server_deleted_at)
);
const canSign = $derived(
!closed && !hasDeletedServers && ['active', 'passed'].includes(test.outcome)
editable && !closed && !hasDeletedServers && ['active', 'passed'].includes(test.outcome)
);
const expired = $derived(
test.outcome === 'inconclusive' ||
@@ -139,6 +141,7 @@
evidenceLimit = 25;
});
export async function action(action: string, extra: Record<string, string> = {}) {
if (!editable) return;
if (
action === 'retry' &&
!(await confirmAction({
@@ -197,7 +200,7 @@
{#if !deployments}<StatusBadge state={test.outcome} />{/if}
</div>
{#if !closed && !deployments}
{#if !hasDeletedServers}<div class="actions">
{#if editable && !hasDeletedServers}<div class="actions">
{#if test.outcome === 'paused' && !expired}<Button
size="sm"
disabled={busy}
@@ -502,7 +505,7 @@
value={Math.min(progress.current, progress.target)}
max={progress.target}
></progress>{/if}
{#if requirement.kind === 'manual' && !progress.passed && !closed && !hasDeletedServers}
{#if editable && requirement.kind === 'manual' && !progress.passed && !closed && !hasDeletedServers}
{#if canSign && server.observingAt}<div class="signoff">
<label
>Tester name and observation<textarea
+51 -44
View File
@@ -5,6 +5,8 @@
import { onMount } from 'svelte';
import { confirmAction } from '$ui/confirm.js';
import { beforeNavigate, goto } from '$app/navigation';
import { page } from '$app/stores';
import { canOperate } from '$lib/auth.js';
import ConfigTextEditor from './ConfigTextEditor.svelte';
import ConfigInspector from './ConfigInspector.svelte';
import Button from './primitives/Button.svelte';
@@ -33,6 +35,7 @@
scopeValue: string | null;
onsaved: () => Promise<void>;
} = $props();
const editable = $derived(canOperate($page.data?.user?.role));
let selected = $state('');
let text = $state('');
let original = $state('');
@@ -45,7 +48,7 @@
let retry = $state(0);
let loadedVersion = $state('');
let recovered = $state<{ version: string; text: string; notes: string } | null>(null);
const dirty = $derived(text !== original || notes.trim().length > 0);
const dirty = $derived(editable && (text !== original || notes.trim().length > 0));
const ignoredWarnings = $derived(
configWarnings(text, kind === 'config').filter(
(warning) => warning.includes('ignored') || warning.includes('no value')
@@ -55,6 +58,7 @@
onMount(() => {
selected = String(versions[0]?.id ?? '');
ready = true;
if (!editable) return;
try {
const saved = JSON.parse(localStorage.getItem(storageKey) ?? 'null');
if (
@@ -155,7 +159,7 @@
}))
)
return;
clearDraft();
if (editable) clearDraft();
selected = next;
success = '';
}
@@ -194,7 +198,7 @@
input.value = '';
}
async function save() {
if (saving || loading || runningBetaId !== null || !text.trim()) return;
if (!editable || saving || loading || runningBetaId !== null || !text.trim()) return;
saving = true;
error = '';
success = '';
@@ -223,12 +227,14 @@
</script>
<svelte:window onbeforeunload={beforeUnload} />
<section class="workbench" aria-label="Config editor">
<section class="workbench" aria-label={editable ? 'Config editor' : 'Config viewer'}>
<div class="editor-panel">
<div class="heading">
<div>
<span class="eyebrow">Edit · preview · version</span>
<h2>Configuration editor</h2>
<span class="eyebrow"
>{editable ? 'Edit · preview · version' : 'Read-only configuration'}</span
>
<h2>{editable ? 'Configuration editor' : 'Configuration viewer'}</h2>
</div>
{#if dirty}<span class="draft-tag">Unsaved draft</span>{/if}
</div>
@@ -246,16 +252,16 @@
>{/each}</Select
></Field
>
<label class="file-button"
>Import .cfg<input
type="file"
accept=".cfg,text/plain"
onchange={importFile}
disabled={saving || loading || runningBetaId !== null}
/></label
>
{#if editable}<label class="file-button"
>Import .cfg<input
type="file"
accept=".cfg,text/plain"
onchange={importFile}
disabled={saving || loading || runningBetaId !== null}
/></label
>{/if}
</div>
{#if recovered && !loading}<div class="recovery">
{#if editable && recovered && !loading}<div class="recovery">
<span>A previous draft is available in this browser.</span><Button
size="sm"
onclick={restoreDraft}
@@ -275,41 +281,42 @@
<ConfigTextEditor
bind:text
overlay={kind === 'config'}
disabled={saving || runningBetaId !== null}
readonly={!editable}
disabled={editable && (saving || runningBetaId !== null)}
/>
<Field class="notes-label" label="Version notes">
<Input
bind:value={notes}
placeholder="What changed, and why?"
disabled={saving || runningBetaId !== null}
/></Field
>
<div class="actions ui-sticky-actions">
<span class="muted"
>{runningBetaId !== null
? 'Finish the active beta to save another version.'
: 'A new version must pass through a beta before promotion.'}{#if ignoredWarnings.length}<strong
class="save-warning"
>{ignoredWarnings.length} ignored or incomplete lines — review the open configuration
notes before saving.</strong
>{/if}</span
{#if editable}<Field class="notes-label" label="Version notes">
<Input
bind:value={notes}
placeholder="What changed, and why?"
disabled={saving || runningBetaId !== null}
/></Field
>
<Button variant="ghost" size="sm" onclick={discard} disabled={!dirty || saving}
>Discard</Button
>
<Button
variant="primary"
onclick={save}
loading={saving}
disabled={!dirty || !text.trim() || runningBetaId !== null}
>Save new version</Button
>
</div>
<div class="actions ui-sticky-actions">
<span class="muted"
>{runningBetaId !== null
? 'Finish the active beta to save another version.'
: 'A new version must pass through a beta before promotion.'}{#if ignoredWarnings.length}<strong
class="save-warning"
>{ignoredWarnings.length} ignored or incomplete lines — review the open
configuration notes before saving.</strong
>{/if}</span
>
<Button variant="ghost" size="sm" onclick={discard} disabled={!dirty || saving}
>Discard</Button
>
<Button
variant="primary"
onclick={save}
loading={saving}
disabled={!dirty || !text.trim() || runningBetaId !== null}
>Save new version</Button
>
</div>{/if}
{/if}
</div>
{#if loadedVersion && !loading}<ConfigInspector
artifactId={id}
draftText={text}
draftText={editable ? text : undefined}
{scopeType}
{scopeValue}
/>{/if}
+5 -3
View File
@@ -171,7 +171,7 @@
const url = URL.createObjectURL(new Blob([inspection.text], { type: 'text/plain' }));
const anchor = document.createElement('a');
anchor.href = url;
anchor.download = `${selected}${artifactId ? '-draft' : ''}.cfg`;
anchor.download = `${selected}${draftText !== undefined ? '-draft' : ''}.cfg`;
anchor.click();
setTimeout(() => URL.revokeObjectURL(url), 1000);
}
@@ -181,7 +181,9 @@
<div class="heading">
<div>
{#if !fixedServer}<span class="eyebrow"
>{artifactId ? 'Draft preview' : 'Follow every value to its source'}</span
>{draftText !== undefined
? 'Draft preview'
: 'Follow every value to its source'}</span
>{/if}
<h2>{artifactId ? 'On this server' : 'Promoted configuration'}</h2>
</div>
@@ -190,7 +192,7 @@
>
</div>
<p class="intro">
{artifactId
{draftText !== undefined
? 'Your draft composed with the other promoted configs. Saving creates an untested version.'
: 'The intended file from promoted configs and the selected map. Live files and active betas may differ.'}
</p>
+121 -113
View File
@@ -3,6 +3,7 @@
import Select from '$ui/components/primitives/Select.svelte';
import Input from '$ui/components/primitives/Input.svelte';
import { page } from '$app/stores';
import { canOperate } from '$lib/auth.js';
import { contextHref } from '$lib/navigation-context.js';
import { onMount } from 'svelte';
import Tabs from './primitives/Tabs.svelte';
@@ -18,6 +19,7 @@
import ConfigInspector from './ConfigInspector.svelte';
import { libraryLimit, saveLibraryContext } from '$ui/library-context.js';
import { gameTitle } from '$ui/server-labels.js';
const editable = $derived(canOperate($page.data?.user?.role));
interface Version {
id: number;
@@ -69,7 +71,7 @@
{ view: 'library', limit: 25 }
);
});
let dialog: HTMLDialogElement;
let dialog = $state<HTMLDialogElement>();
let kind = $state<'baseline' | 'config'>('config');
let name = $state('');
let scopeType = $state<'game' | 'map'>('game');
@@ -164,9 +166,10 @@
void load();
});
function openCreate(type: 'config' | 'baseline' = 'config') {
if (!editable) return;
kind = type;
createError = '';
dialog.showModal();
dialog?.showModal();
}
function resetFilters() {
query = '';
@@ -189,6 +192,7 @@
}
async function create(event: SubmitEvent) {
event.preventDefault();
if (!editable) return;
if (saving || baselineExists || !name.trim() || !selectedScope || !text.trim()) return;
if (kind === 'config' && !/^\s*set\s+\S+\s+/m.test(text)) {
createError = 'Add at least one set KEY VALUE line.';
@@ -207,7 +211,7 @@
const response = await fetch('/api/configs', { method: 'POST', body: form });
const body = await response.json();
if (!response.ok) throw new Error(body.error ?? 'Could not create this config.');
dialog.close();
dialog?.close();
await goto(`/configs/${body.script.id}`);
} catch (err) {
createError = (err as Error).message;
@@ -219,7 +223,7 @@
<div class="page">
<SectionHeader title="Configs" subtitle="Manage game baselines and scoped changes.">
{#snippet actions()}{#if view === 'inspect' || loading || artifacts.length || error}<Button
{#snippet actions()}{#if editable && (view === 'inspect' || loading || artifacts.length || error)}<Button
variant="primary"
onclick={() => openCreate()}>New config</Button
>{/if}{/snippet}
@@ -292,10 +296,10 @@
title="No configs yet"
description="Configs store game baselines and scoped changes. Create a baseline to start managing configuration versions."
>
{#snippet actions()}<Button
variant="primary"
onclick={() => openCreate('baseline')}>Create baseline</Button
>{/snippet}
{#snippet actions()}{#if editable}<Button
variant="primary"
onclick={() => openCreate('baseline')}>Create baseline</Button
>{/if}{/snippet}
</EmptyState>
{:else if !filtered.length && !error}<EmptyState
title="No matching configs"
@@ -362,117 +366,121 @@
</div>
{/snippet}
<dialog
bind:this={dialog}
aria-labelledby="new-config-title"
oncancel={(event) => {
if (saving) event.preventDefault();
}}
>
<form onsubmit={create}>
<div class="dialog-heading">
<div>
<span class="eyebrow">Create version 1</span>
<h2 id="new-config-title">New config</h2>
{#if editable}<dialog
bind:this={dialog}
aria-labelledby="new-config-title"
oncancel={(event) => {
if (saving) event.preventDefault();
}}
>
<form onsubmit={create}>
<div class="dialog-heading">
<div>
<span class="eyebrow">Create version 1</span>
<h2 id="new-config-title">New config</h2>
</div>
<Button
variant="ghost"
onclick={() => dialog?.close()}
disabled={saving}
ariaLabel="Close new config">✕</Button
>
</div>
<Button
variant="ghost"
onclick={() => dialog.close()}
disabled={saving}
ariaLabel="Close new config">✕</Button
<div class="kind-picker">
<button
type="button"
class:active={kind === 'config'}
aria-pressed={kind === 'config'}
onclick={() => (kind = 'config')}
disabled={saving}
><strong>Overlay</strong><span>Override selected settings</span></button
><button
type="button"
class:active={kind === 'baseline'}
aria-pressed={kind === 'baseline'}
onclick={() => (kind = 'baseline')}
disabled={saving}
><strong>Baseline</strong><span>A complete config for a game</span></button
>
</div>
<div class="form-grid">
<Field label="Name">
<Input
bind:value={name}
placeholder={kind === 'config' ? 'No AFK timeout' : 'Black Ops I baseline'}
required
disabled={saving}
/></Field
><Field label="Game">
<Select
aria-label="Game"
bind:value={scopeGame}
onchange={() => (scopeMap = '')}
required
disabled={saving}
><option value="" disabled>Choose a game</option
>{#each gameOptions as option}<option value={option}
>{gameTitle(option)}</option
>{/each}</Select
></Field
>
{#if kind === 'config'}<Field label="Apply to">
<Select aria-label="Apply to" bind:value={scopeType} disabled={saving}
><option value="game">Whole game</option><option value="map"
>One map</option
></Select
></Field
>{#if scopeType === 'map'}<Field label="Map">
<Select
aria-label="Map"
bind:value={scopeMap}
required
disabled={saving || !scopeGame}
><option value="" disabled>Choose a map</option
>{#each scopeOptions.mapsByGame[scopeGame] ?? [] as option}<option
value={option}>{option}</option
>{/each}</Select
></Field
>{/if}{/if}
</div>
{#if baselineExists}<p class="warning">
This game already has a baseline. Open it from the library to save a new
version.
</p>{/if}
{#if !gameOptions.length}<p class="warning">
Register a server first to make its game and map available here.
</p>{/if}
<label class="import"
>Start from a file <input
type="file"
accept=".cfg,text/plain"
onchange={importFile}
disabled={saving}
/></label
>
</div>
<div class="kind-picker">
<button
type="button"
class:active={kind === 'config'}
aria-pressed={kind === 'config'}
onclick={() => (kind = 'config')}
disabled={saving}
><strong>Overlay</strong><span>Override selected settings</span></button
><button
type="button"
class:active={kind === 'baseline'}
aria-pressed={kind === 'baseline'}
onclick={() => (kind = 'baseline')}
disabled={saving}
><strong>Baseline</strong><span>A complete config for a game</span></button
>
</div>
<div class="form-grid">
<Field label="Name">
<ConfigTextEditor bind:text overlay={kind === 'config'} disabled={saving} />
<Field class="notes" label="Notes">
<Input
bind:value={name}
placeholder={kind === 'config' ? 'No AFK timeout' : 'Black Ops I baseline'}
required
bind:value={notes}
placeholder="What is this config for?"
disabled={saving}
/></Field
><Field label="Game">
<Select
aria-label="Game"
bind:value={scopeGame}
onchange={() => (scopeMap = '')}
required
disabled={saving}
><option value="" disabled>Choose a game</option
>{#each gameOptions as option}<option value={option}>{gameTitle(option)}</option
>{/each}</Select
></Field
>
{#if kind === 'config'}<Field label="Apply to">
<Select aria-label="Apply to" bind:value={scopeType} disabled={saving}
><option value="game">Whole game</option><option value="map">One map</option
></Select
></Field
>{#if scopeType === 'map'}<Field label="Map">
<Select
aria-label="Map"
bind:value={scopeMap}
required
disabled={saving || !scopeGame}
><option value="" disabled>Choose a map</option
>{#each scopeOptions.mapsByGame[scopeGame] ?? [] as option}<option
value={option}>{option}</option
>{/each}</Select
></Field
>{/if}{/if}
</div>
{#if baselineExists}<p class="warning">
This game already has a baseline. Open it from the library to save a new version.
</p>{/if}
{#if !gameOptions.length}<p class="warning">
Register a server first to make its game and map available here.
</p>{/if}
<label class="import"
>Start from a file <input
type="file"
accept=".cfg,text/plain"
onchange={importFile}
disabled={saving}
/></label
>
<ConfigTextEditor bind:text overlay={kind === 'config'} disabled={saving} />
<Field class="notes" label="Notes">
<Input
bind:value={notes}
placeholder="What is this config for?"
disabled={saving}
/></Field
>
{#if createError}<div class="error" role="alert">{createError}</div>{/if}
<div class="dialog-actions">
<p>Saved as untested. Test in a beta before promotion.</p>
<Button variant="ghost" onclick={() => dialog.close()} disabled={saving}>Cancel</Button
><Button
type="submit"
variant="primary"
loading={saving}
disabled={baselineExists || !name.trim() || !selectedScope || !text.trim()}
>Create {kind === 'baseline' ? 'baseline' : 'overlay'}</Button
>
</div>
</form>
</dialog>
{#if createError}<div class="error" role="alert">{createError}</div>{/if}
<div class="dialog-actions">
<p>Saved as untested. Test in a beta before promotion.</p>
<Button variant="ghost" onclick={() => dialog?.close()} disabled={saving}
>Cancel</Button
><Button
type="submit"
variant="primary"
loading={saving}
disabled={baselineExists || !name.trim() || !selectedScope || !text.trim()}
>Create {kind === 'baseline' ? 'baseline' : 'overlay'}</Button
>
</div>
</form>
</dialog>{/if}
<style>
.page {
+41 -30
View File
@@ -11,14 +11,16 @@
let {
text = $bindable(''),
overlay = false,
readonly = false,
disabled = false
}: { text?: string; overlay?: boolean; disabled?: boolean } = $props();
}: { text?: string; overlay?: boolean; readonly?: boolean; disabled?: boolean } = $props();
let mode = $state<'raw' | 'settings'>('raw');
let search = $state('');
let newKey = $state('');
let newValue = $state('');
let addError = $state('');
function addSetting() {
if (readonly || disabled) return;
try {
text = addConfigSetting(text, newKey, newValue);
search = '';
@@ -64,6 +66,7 @@
<textarea
aria-label="Config contents"
bind:value={text}
{readonly}
{disabled}
spellcheck="false"
rows="12"
@@ -78,9 +81,10 @@
placeholder="Find a setting…"
/></Field
>
<p class="hint">
Edit values and inline comments. Other commands and formatting stay in the raw file.
</p>
{#if !readonly}<p class="hint">
Edit values and inline comments. Other commands and formatting stay in the raw
file.
</p>{/if}
{#each visible as entry (entry.index)}
<Field class="setting"
>{#snippet label()}<span
@@ -89,6 +93,7 @@
<Input
mono
value={entry.value}
{readonly}
{disabled}
aria-label={`${entry.key}, line ${entry.index + 1}`}
oninput={(e) =>
@@ -96,31 +101,36 @@
/>
</Field>
{:else}<p class="hint">
{entries.length ? 'No matching settings.' : 'Add your first setting below.'}
{entries.length
? 'No matching settings.'
: readonly
? 'No settings in this version.'
: 'Add your first setting below.'}
</p>{/each}
<div class="add-setting">
<Field label="Setting name">
<Input
mono
bind:value={newKey}
onkeydown={addOnEnter}
placeholder="g_inactivity"
{disabled}
/></Field
><Field label="Value">
<Input
mono
bind:value={newValue}
onkeydown={addOnEnter}
placeholder="0"
{disabled}
/></Field
><Button
size="sm"
onclick={addSetting}
disabled={disabled || !newKey.trim() || !newValue.trim()}>Add setting</Button
>
</div>
{#if !readonly}<div class="add-setting">
<Field label="Setting name">
<Input
mono
bind:value={newKey}
onkeydown={addOnEnter}
placeholder="g_inactivity"
{disabled}
/></Field
><Field label="Value">
<Input
mono
bind:value={newValue}
onkeydown={addOnEnter}
placeholder="0"
{disabled}
/></Field
><Button
size="sm"
onclick={addSetting}
disabled={disabled || !newKey.trim() || !newValue.trim()}
>Add setting</Button
>
</div>{/if}
{#if addError}<p class="warnings" role="alert">{addError}</p>{/if}
</div>
{/if}
@@ -131,8 +141,9 @@
open={warnings.some((w) => w.includes('ignored') || w.includes('no value'))}
>
<summary
>{warnings.length} configuration {warnings.length === 1 ? 'note' : 'notes'} — review before
saving</summary
>{warnings.length} configuration {warnings.length === 1 ? 'note' : 'notes'}{readonly
? ''
: ' — review before saving'}</summary
>
<ul>
{#each warnings as warning}<li>{warning}</li>{/each}
+31 -29
View File
@@ -3,7 +3,7 @@
import Select from '$ui/components/primitives/Select.svelte';
import Field from '$ui/components/primitives/Field.svelte';
import type { FleetSettings } from '$lib/fleet-settings.js';
let { settings }: { settings: FleetSettings } = $props();
let { settings, readOnly = false }: { settings: FleetSettings; readOnly?: boolean } = $props();
import Button from './primitives/Button.svelte';
let busy = $state(false);
let error = $state('');
@@ -58,6 +58,7 @@
selectedCategory = category,
id = recordId
) {
if (readOnly || busy) return;
busy = true;
error = '';
notice = '';
@@ -116,11 +117,12 @@
<details>
<summary>{policy.label}: eligible IDs (up to 20)</summary>
{#each policy.records as record}<div class="record">
<code>{record.id}</code><Button
disabled={busy}
onclick={() => control({ protected: true }, policy.category, record.id)}
>Protect</Button
>
<code>{record.id}</code>{#if !readOnly}<Button
disabled={busy}
onclick={() =>
control({ protected: true }, policy.category, record.id)}
>Protect</Button
>{/if}
</div>{/each}
</details>
{/each}
@@ -131,35 +133,35 @@
? ' · Resolved'
: ''}</span
>
{#if record.protected}<Button
{#if record.protected && !readOnly}<Button
disabled={busy}
onclick={() => control({ protected: false }, record.category, record.id)}
>Remove protection</Button
>{/if}
</div>{:else}<p>No explicit record protections or resolutions.</p>{/each}
{/if}
<h3>Protect a record or resolve a failure</h3>
<p>
Use the ID from its detail URL or the preview. Resolving a failed campaign stops automatic
retry requests for that campaign; unfinished recovery still prevents cleanup.
</p>
<div class="record">
<Field label="Category">
<Select bind:value={category}
>{#each categories as value}<option {value}>{value}</option>{/each}</Select
></Field
>
<Field label="Record ID">
<Input bind:value={recordId} maxlength={200} /></Field
>
<Button disabled={busy || !recordId} onclick={() => control({ protected: true })}
>Protect record</Button
>
{#if ['campaign', 'provisioning', 'operation'].includes(category)}<Button
disabled={busy || !recordId}
onclick={() => control({ resolve: true })}>Mark failure resolved</Button
>{/if}
</div>
{#if !readOnly}<h3>Protect a record or resolve a failure</h3>
<p>
Use the ID from its detail URL or the preview. Resolving a failed campaign stops
automatic retry requests for that campaign; unfinished recovery still prevents cleanup.
</p>
<div class="record">
<Field label="Category">
<Select bind:value={category}
>{#each categories as value}<option {value}>{value}</option>{/each}</Select
></Field
>
<Field label="Record ID">
<Input bind:value={recordId} maxlength={200} /></Field
>
<Button disabled={busy || !recordId} onclick={() => control({ protected: true })}
>Protect record</Button
>
{#if ['campaign', 'provisioning', 'operation'].includes(category)}<Button
disabled={busy || !recordId}
onclick={() => control({ resolve: true })}>Mark failure resolved</Button
>{/if}
</div>{/if}
</details>
<style>
+39 -38
View File
@@ -22,7 +22,7 @@
type Key = keyof FleetSettings;
type Draft = { [K in Key]: number | undefined };
let { initial }: { initial: FleetSettings } = $props();
let { initial, readOnly = false }: { initial: FleetSettings; readOnly?: boolean } = $props();
const id = $props.id();
const keys = Object.keys(defaultFleetSettings) as Key[];
let baseline = $state<FleetSettings>({ ...untrack(() => initial) });
@@ -69,7 +69,7 @@
return '';
}
function toggleMode(key: Key, enabled: boolean) {
if (saving) return;
if (readOnly || saving) return;
const current = draft[key];
if (enabled) {
if (current !== undefined && Number.isSafeInteger(current) && current > 0)
@@ -88,7 +88,7 @@
reviewInvalid = false;
}
function discard() {
if (saving) return;
if (readOnly || saving) return;
draft = { ...baseline };
remembered = Object.fromEntries(
keys.filter((key) => baseline[key] > 0).map((key) => [key, baseline[key]])
@@ -98,7 +98,7 @@
reviewInvalid = false;
}
async function save() {
if (saving || !changed.length || invalid.length) return;
if (readOnly || saving || !changed.length || invalid.length) return;
saving = true;
error = '';
notice = '';
@@ -124,6 +124,7 @@
beforeNavigate(({ cancel, to, from, type }) => {
if (
readOnly ||
!changed.length ||
allowNavigation ||
type === 'leave' ||
@@ -148,7 +149,7 @@
});
});
function beforeUnload(event: BeforeUnloadEvent) {
if (changed.length) {
if (!readOnly && changed.length) {
event.preventDefault();
event.returnValue = '';
}
@@ -227,7 +228,7 @@
void save();
}}
>
<fieldset disabled={saving}>
<fieldset disabled={readOnly || saving}>
<legend class="sr-only">Fleet settings</legend>
{#each visibleGroups as group (group.id)}
<section
@@ -352,42 +353,42 @@
<fieldset disabled={saving || invalid.length > 0}>
<legend class="sr-only">Retention preview and protections</legend>
{#if invalid.length}<p>Correct invalid values to preview cleanup.</p>{/if}
<RetentionTools settings={draft as FleetSettings} />
<RetentionTools settings={draft as FleetSettings} {readOnly} />
</fieldset>
</div>
</div>
</div>
<div class="save-bar">
<div class="save-summary">
<strong class:has-changes={changed.length > 0}
>{changed.length
? `${changed.length} unsaved ${changed.length === 1 ? 'change' : 'changes'}`
: 'All changes saved'}</strong
>
<span>Applies to everyone. No restart needed.</span>
{#if invalid.length}<button
type="button"
class="invalid-summary text-button"
onclick={() => {
query = '';
reviewInvalid = true;
}}
>{invalid.length} invalid {invalid.length === 1 ? 'setting' : 'settings'} · Review invalid
settings</button
>{/if}
</div>
<div class="save-actions">
<Button variant="ghost" disabled={saving || !changed.length} onclick={discard}
>Discard</Button
><button
class="save-button"
type="submit"
form={`${id}-form`}
disabled={saving || !changed.length || invalid.length > 0}
>{saving ? 'Saving…' : 'Save changes'}</button
>
</div>
</div>
{#if !readOnly}<div class="save-bar">
<div class="save-summary">
<strong class:has-changes={changed.length > 0}
>{changed.length
? `${changed.length} unsaved ${changed.length === 1 ? 'change' : 'changes'}`
: 'All changes saved'}</strong
>
<span>Applies to everyone. No restart needed.</span>
{#if invalid.length}<button
type="button"
class="invalid-summary text-button"
onclick={() => {
query = '';
reviewInvalid = true;
}}
>{invalid.length} invalid {invalid.length === 1 ? 'setting' : 'settings'} · Review
invalid settings</button
>{/if}
</div>
<div class="save-actions">
<Button variant="ghost" disabled={saving || !changed.length} onclick={discard}
>Discard</Button
><button
class="save-button"
type="submit"
form={`${id}-form`}
disabled={saving || !changed.length || invalid.length > 0}
>{saving ? 'Saving…' : 'Save changes'}</button
>
</div>
</div>{/if}
<style>
.settings-layout {
+13 -4
View File
@@ -15,7 +15,7 @@
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';
import { canReadAdmin, 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';
@@ -172,7 +172,6 @@
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);
@@ -299,8 +298,11 @@
>Keyboard shortcuts <span aria-hidden="true">?</span
></button
>{/if}
{#if data.user.role === 'admin' && sessionReady}<a
href="/settings/users">Manage users</a
{#if canReadAdmin(data.user.role) && sessionReady}<a
href="/settings/users"
>{data.user.role === 'admin'
? 'Manage users'
: 'View users'}</a
>{/if}
<button disabled={signingOut} onclick={signOut}
>{signingOut ? 'Signing out…' : 'Sign out'}</button
@@ -319,6 +321,13 @@
>You can read fleet status and summaries. Operating servers or viewing raw
configs, file changes, logs, and telemetry requires an operator account.</span
>
</aside>{:else if sessionReady && data.user?.role === 'super_viewer'}<aside
class="viewer-note"
>
<strong>Super Viewer access</strong><span
>You can view all administration pages, configs, logs, and diagnostics. Changes
and server operations are unavailable.</span
>
</aside>{/if}
{@render children()}
</main>
+3 -2
View File
@@ -3,6 +3,7 @@
import Field from '$ui/components/primitives/Field.svelte';
import Input from '$ui/components/primitives/Input.svelte';
import { onMount } from 'svelte';
import { roleLabels, type AuthUser } from '$lib/auth.js';
import { timeFormat, timeZone, timeZonePersistenceError } from '$ui/stores/time-zone.js';
import { availableTimeZones, DEFAULT_TIME_ZONE } from '$lib/time-zone.js';
let exampleDate = $state<Date | null>(null);
@@ -21,7 +22,7 @@
let signingOut = $state(false);
let error = $state('');
let notice = $state('');
const user = $derived($page.data.user);
const user = $derived<AuthUser | null>($page.data.user ?? null);
async function changePassword(event: SubmitEvent) {
event.preventDefault();
if (busy || signingOut) return;
@@ -88,7 +89,7 @@
<section class="panel identity">
<div>
<h2>{user.displayName}</h2>
<p>@{user.username} · <span class="role">{user.role}</span></p>
<p>@{user.username} · <span class="role">{roleLabels[user.role]}</span></p>
</div>
<Button disabled={busy || signingOut} loading={signingOut} onclick={logout}
>Sign out</Button
+6 -3
View File
@@ -4,6 +4,7 @@
import Field from '$ui/components/primitives/Field.svelte';
import RelativeTime from '$ui/components/RelativeTime.svelte';
import { page } from '$app/stores';
import { canOperate } from '$lib/auth.js';
import { contextHref } from '$lib/navigation-context.js';
import { timeFormat } from '$ui/stores/time-zone.js';
import { onMount } from 'svelte';
@@ -49,6 +50,7 @@
}
let betas = $state<BetaListRow[]>([]);
const editable = $derived(canOperate($page.data?.user?.role));
let loading = $state(true);
let error = $state('');
let tab = $state('active');
@@ -122,7 +124,7 @@
}
onMount(() => {
const query = new URLSearchParams(window.location.search);
if (query.has('artifact') || query.has('pluginId')) {
if (editable && (query.has('artifact') || query.has('pluginId'))) {
void goto('/betas/new' + window.location.search, { replaceState: true });
return;
}
@@ -141,7 +143,7 @@
<svelte:head><title>Betas · Electric Cherry</title></svelte:head>
<div class="page">
<SectionHeader title="Betas">
{#snippet actions()}{#if loading || betas.length || error}<Button
{#snippet actions()}{#if editable && (loading || betas.length || error)}<Button
variant="primary"
href="/betas/new">New beta</Button
>{/if}{/snippet}
@@ -230,7 +232,8 @@
pageNumber = 1;
saveContext();
}}>Clear filters</Button
>{:else}<Button variant="primary" href="/betas/new">New beta</Button
>{:else if editable}<Button variant="primary" href="/betas/new"
>New beta</Button
>{/if}{/snippet}
</EmptyState>
{:else}
+27 -21
View File
@@ -4,6 +4,7 @@
import { timeFormat } from '$ui/stores/time-zone.js';
import { onMount } from 'svelte';
import { page } from '$app/stores';
import { canOperate } from '$lib/auth.js';
import { artifactVersionLabel } from '$lib/artifact-version.js';
import type { BetaTestView } from '$lib/beta-plan.js';
import type { BetaPromotionPreview } from '$lib/beta-promotion.js';
@@ -92,7 +93,9 @@
let updateOpen = $state(false),
updateRevision = $state(1),
updateFiles = $state<BetaFile[]>([]);
const editable = $derived(canOperate($page.data?.user?.role));
function openUpdate() {
if (!editable) return;
updateRevision = revision.revision;
updateFiles = [...scripts];
updateOpen = true;
@@ -157,6 +160,7 @@
);
function testAction(action: string) {
if (!editable) return;
moreActionsOpen = false;
void testProgress?.action(action);
}
@@ -260,7 +264,7 @@
}
}
async function promote() {
if (!preview) return;
if (!editable || !preview) return;
acting = 'promote';
reviewError = '';
flash = '';
@@ -288,6 +292,7 @@
}
}
async function rollback() {
if (!editable) return;
const reject = beta?.state === 'ready-promote';
const restarts =
test?.plan.rollbackRestart === 'manual'
@@ -318,6 +323,7 @@
}
}
async function retry(server: string) {
if (!editable) return;
acting = `retry:${server}`;
flash = '';
try {
@@ -392,7 +398,7 @@
$page.url
)}>View rollback campaign</Button
>
{:else if closed}<Button
{:else if closed && editable}<Button
variant="primary"
href={contextHref(`/betas/new?reuse=${id}`, $page.url)}
>Reuse files and test plan</Button
@@ -410,12 +416,12 @@
? 'failures'
: 'issues'}</Button
>
{:else if presentation.key === 'paused' && !testExpired}<Button
{:else if editable && presentation.key === 'paused' && !testExpired}<Button
variant="primary"
onclick={() => testAction('resume')}
disabled={busy}>Resume testing</Button
>{/if}
{#if !closed || promotion || rollbackCampaign}<details
{#if editable && (!closed || promotion || rollbackCampaign)}<details
class="more-actions"
bind:open={moreActionsOpen}
>
@@ -583,7 +589,7 @@
$page.url
)}>View provisioning</a
>{/if}
{#if server.stage === 'failed' && !server.server_deleted_at && !closed}<Button
{#if editable && server.stage === 'failed' && !server.server_deleted_at && !closed}<Button
size="sm"
onclick={() => retry(server.server)}
disabled={busy}
@@ -694,15 +700,15 @@
</div>
{/if}
<BetaRevisionDialog
bind:open={updateOpen}
betaId={id}
revision={updateRevision}
files={updateFiles}
servers={servers.map((server) => server.server)}
timeoutHours={test?.plan.timeoutHours}
onUpdate={load}
/>
{#if editable}<BetaRevisionDialog
bind:open={updateOpen}
betaId={id}
revision={updateRevision}
files={updateFiles}
servers={servers.map((server) => server.server)}
timeoutHours={test?.plan.timeoutHours}
onUpdate={load}
/>{/if}
<Dialog
bind:open={reviewOpen}
@@ -763,13 +769,13 @@
>Cancel</Button
>{#if preview}<Button onclick={reviewPromotion} disabled={acting !== null}
>Refresh review</Button
><Button
variant="primary"
onclick={promote}
loading={acting === 'promote'}
disabled={acting !== null || !!preview.blockers.length}
>Promote to {preview.servers.length} servers</Button
>{/if}{/snippet}
>{#if editable}<Button
variant="primary"
onclick={promote}
loading={acting === 'promote'}
disabled={acting !== null || !!preview.blockers.length}
>Promote to {preview.servers.length} servers</Button
>{/if}{/if}{/snippet}
</Dialog>
<style>
+18 -8
View File
@@ -4,6 +4,7 @@
import Field from '$ui/components/primitives/Field.svelte';
import { countLabel } from '$ui/plural.js';
import { page } from '$app/stores';
import { canOperate } from '$lib/auth.js';
import { contextHref, safeReturnHref } from '$lib/navigation-context.js';
import Breadcrumbs from '$ui/components/primitives/Breadcrumbs.svelte';
import { timeFormat } from '$ui/stores/time-zone.js';
@@ -91,6 +92,7 @@
availability = $state(''),
now = $state(Date.now());
const steps = ['Files', 'Servers', 'Test plan', 'Review'];
const editable = $derived(canOperate($page.data?.user?.role));
const fleetServers = $derived($sharedFleet);
const eligibleFiles = $derived(
scripts.filter((file) => file.runningBetaId === null && file.latestVersion !== null)
@@ -307,6 +309,7 @@
}
}
async function initialize(ignoreDraft = false) {
if (!editable) return;
loading = true;
flash = '';
draftReady = false;
@@ -413,7 +416,7 @@
if (useLinked) void initialize(true);
}
$effect(() => {
if (!draftReady || loading || openingBeta) return;
if (!editable || !draftReady || loading || openingBeta) return;
const draft: BetaDraft = {
version: 1,
savedAt: new Date().toISOString(),
@@ -467,6 +470,7 @@
}
});
onMount(() => {
if (!editable) return;
void initialize();
const timer = setInterval(() => (now = serverTimeNow()), 10000);
const unload = (event: BeforeUnloadEvent) => {
@@ -547,7 +551,7 @@
}
async function openBeta(event: Event) {
event.preventDefault();
if (step !== 3 || forwardReason || openingBeta) return;
if (!editable || step !== 3 || forwardReason || openingBeta) return;
if (betaPlanIssues(testPlan, serverCount).length) {
await move(2);
await planBuilder?.validate();
@@ -606,15 +610,21 @@
title="New beta"
subtitle="Test files across games in one beta. Choose one instance count for every game."
/>
{#if flash}<div class="ui-error" role="alert">
{#if editable && flash}<div class="ui-error" role="alert">
{flash}{#if !scripts.length}<Button size="sm" onclick={() => initialize()}>Retry</Button
>{/if}
</div>{/if}
{#if $fleetError}<div class="ui-error" role="alert">
{#if editable && $fleetError}<div class="ui-error" role="alert">
{$fleetError}<Button size="sm" onclick={() => refreshFleet(true)}>Retry servers</Button>
</div>{/if}
{#if notice}<p role="status" class="notice">{notice}</p>{/if}
{#if loading}<EmptyState title="Loading beta setup…" />
{#if editable && notice}<p role="status" class="notice">{notice}</p>{/if}
{#if !editable}<EmptyState
title="Beta creation requires operator access"
description="You can review existing betas, test results, and saved evidence."
>
{#snippet actions()}<Button href="/betas">View betas</Button>{/snippet}
</EmptyState>
{:else if loading}<EmptyState title="Loading beta setup…" />
{:else if availableDraft}<section class="ui-panel draft">
<h2>Resume unfinished beta?</h2>
<p>
@@ -776,8 +786,8 @@
</p>
</div>
{:else}<p>
<strong>{pickedServers.size} selected</strong> · {candidateServers.length} compatible
servers. Restarts wait for confirmed empty lobbies.
<strong>{pickedServers.size} selected</strong> · {candidateServers.length}
compatible servers. Restarts wait for confirmed empty lobbies.
</p>{/if}
<details>
<summary>Selected file scopes</summary>
+6 -2
View File
@@ -1,4 +1,6 @@
<script lang="ts">
import { canOperate } from '$lib/auth.js';
const writable = $derived(canOperate($page.data?.user?.role));
import { plural } from '$ui/plural.js';
import RelativeTime from '$ui/components/RelativeTime.svelte';
import { contextHref } from '$lib/navigation-context.js';
@@ -170,6 +172,7 @@
onMount(() => pollVisible(loadDetail, 5000, false));
async function resume() {
if (!writable) return;
resuming = true;
resumeError = '';
try {
@@ -188,6 +191,7 @@
}
async function rollback() {
if (!writable) return;
if (!detail || rollingBack) return;
const id = detail.deployment.id;
const servers = detail.servers.filter((server) => !server.server_deleted_at).length;
@@ -299,7 +303,7 @@
$page.url
)}>View rollback</Button
>
{:else if detail && action === 'promote' && !detail.fileContentsExpired && detail.servers.some((server) => !server.server_deleted_at)}
{:else if writable && detail && action === 'promote' && !detail.fileContentsExpired && detail.servers.some((server) => !server.server_deleted_at)}
<Button
variant="danger"
size="sm"
@@ -380,7 +384,7 @@
>All servers activated</span
>{/if}
</div>
{#if detail.servers.some((server) => !server.server_deleted_at && ['held', 'manual'].includes(server.restart_state))}
{#if writable && detail.servers.some((server) => !server.server_deleted_at && ['held', 'manual'].includes(server.restart_state))}
<Button size="sm" onclick={resume} loading={resuming} disabled={resuming}
>Resume when empty</Button
>
+41 -27
View File
@@ -32,6 +32,7 @@
} from '$ui/maintenance-presentation.js';
import { countLabel, plural } from '$ui/plural.js';
const administrator = $derived($page.data.user?.role === 'admin');
let data = $state<MaintenanceOverview | null>(null);
let error = $state('');
let notice = $state('');
@@ -262,7 +263,7 @@
}
}
async function act(body: Record<string, unknown>) {
if (busy) return;
if (!administrator || busy) return;
busy = true;
notice = '';
error = '';
@@ -296,8 +297,10 @@
subtitle="Keep servers healthy, current, and ready for the next match."
>
{#snippet actions()}
<Button disabled={busy} onclick={() => act({ action: 'scan' })}>Check now</Button>
{#if data}<Button
{#if administrator}<Button disabled={busy} onclick={() => act({ action: 'scan' })}
>Check now</Button
>{/if}
{#if administrator && data}<Button
variant="ghost"
disabled={busy}
onclick={() => act({ action: 'pause', paused: !data!.policies.paused })}
@@ -349,17 +352,17 @@
>
</div>
<p>{policy.description}</p>
<Button
disabled={busy}
onclick={() =>
act({
action: 'policy',
policy: policy.id,
enabled: !data!.policies[policy.id]
})}
>{data.policies[policy.id] ? 'Disable' : 'Enable'}
{policy.title.toLowerCase()}</Button
>
{#if administrator}<Button
disabled={busy}
onclick={() =>
act({
action: 'policy',
policy: policy.id,
enabled: !data!.policies[policy.id]
})}
>{data.policies[policy.id] ? 'Disable' : 'Enable'}
{policy.title.toLowerCase()}</Button
>{/if}
</section>
{/each}
</div>
@@ -382,13 +385,24 @@
}}
>
<label class="toggle"
><input type="checkbox" bind:checked={scheduleEnabled} /> Daily fleet restart</label
><input
type="checkbox"
bind:checked={scheduleEnabled}
disabled={!administrator || busy}
/> Daily fleet restart</label
>
<Field
>{#snippet label()}Queue time ({$timeZone}){/snippet}
<Input type="time" bind:value={scheduleTime} required /></Field
<Input
type="time"
bind:value={scheduleTime}
required
disabled={!administrator || busy}
/></Field
>
<Button type="submit" disabled={busy}>Save schedule</Button>
{#if administrator}<Button type="submit" disabled={busy}
>Save schedule</Button
>{/if}
</form>
<p class="muted">
Saving follows daylight saving changes in your selected timezone. Skipped
@@ -444,11 +458,11 @@
<section class="scan-errors" aria-labelledby="scan-errors-heading">
<div class="row-heading">
<h2 id="scan-errors-heading">Checks need attention</h2>
<Button
size="sm"
disabled={busy}
onclick={() => act({ action: 'scan' })}>Retry checks</Button
>
{#if administrator}<Button
size="sm"
disabled={busy}
onclick={() => act({ action: 'scan' })}>Retry checks</Button
>{/if}
</div>
<ul class="error-groups">
{#each scanErrors as group (group.key)}
@@ -520,7 +534,7 @@
<section class="card" id="updates">
<div class="row-heading">
<h2>Plutonium updates</h2>
{#if updatableServers.length}<Button
{#if administrator && updatableServers.length}<Button
disabled={busy || data.policies.paused}
onclick={() =>
act({ action: 'update', servers: updatableServers })}
@@ -575,13 +589,13 @@
{#if group.pending.length}<span
>{countLabel(group.pending.length, 'restart')} pending</span
>{/if}
{#if group.status === 'unknown'}<Button
{#if administrator && group.status === 'unknown'}<Button
size="sm"
disabled={busy}
onclick={() => act({ action: 'scan' })}
>Retry version check</Button
>
{:else if group.updatable.length}<Button
{:else if administrator && group.updatable.length}<Button
size="sm"
disabled={busy || data.policies.paused}
onclick={() =>
@@ -692,14 +706,14 @@
? 'View restart operation'
: 'View evidence and recovery'}</a
>{/if}
{#if item.job?.state === 'queued' && !item.server_deleted_at}<Button
{#if administrator && item.job?.state === 'queued' && !item.server_deleted_at}<Button
size="sm"
disabled={busy}
onclick={() =>
act({ action: 'cancel', id: item.job!.id })}
>Cancel restart</Button
>{/if}
{#if item.job?.state === 'failed' && !item.server_deleted_at && item.job.operation_state === 'done'}<Button
{#if administrator && item.job?.state === 'failed' && !item.server_deleted_at && item.job.operation_state === 'done'}<Button
size="sm"
disabled={busy || data.policies.paused}
onclick={() =>
+7 -3
View File
@@ -3,6 +3,7 @@
import { contextHref } from '$lib/navigation-context.js';
import { onMount, untrack } from 'svelte';
import { page } from '$app/stores';
import { canOperate } from '$lib/auth.js';
import type { WatchdogDetail, WatchdogEvidence } from '$lib/watchdog.js';
import { pollVisible } from '$ui/poll.js';
import { timeFormat } from '$ui/stores/time-zone.js';
@@ -24,6 +25,7 @@
let eventLimit = $state(25);
let fileLimit = $state(25);
let request = 0;
const operator = $derived(canOperate($page.data.user?.role));
const incident = $derived(detail?.incident);
const evidence = $derived.by((): WatchdogEvidence => {
try {
@@ -44,11 +46,13 @@
)
);
const canRetry = $derived(
!incident?.server_deleted_at &&
operator &&
!incident?.server_deleted_at &&
(incident?.state === 'attention' || incident?.state === 'dismissed')
);
const canDismiss = $derived(
!incident?.server_deleted_at &&
operator &&
!incident?.server_deleted_at &&
(incident?.state === 'open' || incident?.state === 'attention')
);
const explanation = $derived.by(() => {
@@ -92,7 +96,7 @@
}
async function update(action: 'retry' | 'dismiss') {
if (busy || !incident) return;
if (!operator || busy || !incident) return;
const id = incident.id;
const current = ++request;
busy = action;
+91 -84
View File
@@ -1,5 +1,6 @@
<script lang="ts">
import { page } from '$app/stores';
import { canOperate } from '$lib/auth.js';
import { contextHref } from '$lib/navigation-context.js';
import { onMount } from 'svelte';
import { afterNavigate } from '$app/navigation';
@@ -20,6 +21,7 @@
import Input from '$ui/components/primitives/Input.svelte';
import Select from '$ui/components/primitives/Select.svelte';
import { libraryLimit, saveLibraryContext } from '$ui/library-context.js';
const editable = $derived(canOperate($page.data?.user?.role));
type ArtifactKind = 'script' | 'config' | 'plugin' | 'baseline';
interface ArtifactRow {
@@ -266,11 +268,11 @@
title="Plugins"
subtitle="Manage plugin versions and test changes before promotion."
>
{#snippet actions()}{#if plugins.length}<Button
{#snippet actions()}{#if editable && plugins.length}<Button
variant="ghost"
onclick={() => (updatesOpen = !updatesOpen)}
>{updatesOpen ? 'Hide update details' : 'Check for updates'}</Button
>{/if}{#if loading || plugins.length || flash}<Button
>{/if}{#if editable && (loading || plugins.length || flash)}<Button
variant="primary"
onclick={() => (addOpen = true)}>Add plugin</Button
>{/if}{/snippet}
@@ -339,7 +341,7 @@
{#if game}<p class="filter-hint">
Game results include unscoped plugins that can use explicitly selected beta servers.
</p>{/if}
{#if updatesOpen}<details
{#if editable && updatesOpen}<details
open
ontoggle={(event) => {
if (!event.currentTarget.open) updatesOpen = false;
@@ -377,8 +379,9 @@
game = '';
status = '';
}}>Clear filters</Button
>{:else}<Button variant="primary" onclick={() => (addOpen = true)}
>Add plugin</Button
>{:else if editable}<Button
variant="primary"
onclick={() => (addOpen = true)}>Add plugin</Button
>{/if}{/snippet}
</EmptyState>
{:else}
@@ -449,91 +452,95 @@
</section>
</div>
<Dialog bind:open={addOpen} title="Add plugin" busy={uploading || githubBusy} wide>
<Tabs
tabs={[
{ id: 'upload', label: 'Upload file', disabled: uploading || githubBusy },
{ id: 'github', label: 'GitHub release', disabled: uploading || githubBusy }
]}
bind:value={method}
ariaLabel="Plugin source"
idPrefix="plugin-source"
panelId="plugin-source-panel"
/>
<div id="plugin-source-panel" role="tabpanel" aria-labelledby={'plugin-source-' + method}>
{#if method === 'github'}<PluginGithubImport
bind:busy={githubBusy}
onimport={async (result) => {
importComplete = result;
updates = updates.filter((update) => update.pluginId !== result.pluginId);
importCount++;
addOpen = false;
await load();
}}
/>
{:else}
<form onsubmit={createPlugin} class="upload-form">
<Field label="Name">
<Input bind:value={newName} placeholder="my plugin" disabled={uploading} />
</Field>
<Field
label="Release version"
hint="Optional. Use the publisher’s version or release tag, such as v3.1.0."
>
<Input
bind:value={newReleaseVersion}
placeholder="v3.1.0"
disabled={uploading}
/>
</Field>
<Field label="Notes" hint="Optional. What changed in this version.">
<Input bind:value={newNotes} placeholder="what changed" disabled={uploading} />
</Field>
<Field label="Scope" hint="Where this plugin deploys.">
<div class="scope-row">
<Select
ariaLabel="Scope type"
bind:value={newScopeType}
options={scopeTypeOptions}
{#if editable}<Dialog bind:open={addOpen} title="Add plugin" busy={uploading || githubBusy} wide>
<Tabs
tabs={[
{ id: 'upload', label: 'Upload file', disabled: uploading || githubBusy },
{ id: 'github', label: 'GitHub release', disabled: uploading || githubBusy }
]}
bind:value={method}
ariaLabel="Plugin source"
idPrefix="plugin-source"
panelId="plugin-source-panel"
/>
<div id="plugin-source-panel" role="tabpanel" aria-labelledby={'plugin-source-' + method}>
{#if method === 'github'}<PluginGithubImport
bind:busy={githubBusy}
onimport={async (result) => {
importComplete = result;
updates = updates.filter((update) => update.pluginId !== result.pluginId);
importCount++;
addOpen = false;
await load();
}}
/>
{:else}
<form onsubmit={createPlugin} class="upload-form">
<Field label="Name">
<Input bind:value={newName} placeholder="my plugin" disabled={uploading} />
</Field>
<Field
label="Release version"
hint="Optional. Use the publisher’s version or release tag, such as v3.1.0."
>
<Input
bind:value={newReleaseVersion}
placeholder="v3.1.0"
disabled={uploading}
/>
{#if newScopeType === 'game' || newScopeType === 'map'}
</Field>
<Field label="Notes" hint="Optional. What changed in this version.">
<Input
bind:value={newNotes}
placeholder="what changed"
disabled={uploading}
/>
</Field>
<Field label="Scope" hint="Where this plugin deploys.">
<div class="scope-row">
<Select
ariaLabel="Scope game"
bind:value={newScopeGame}
onchange={() => (newScopeMap = '')}
options={gameOptions}
ariaLabel="Scope type"
bind:value={newScopeType}
options={scopeTypeOptions}
disabled={uploading}
/>
{/if}
{#if newScopeType === 'map'}
<Select
ariaLabel="Scope map"
bind:value={newScopeMap}
options={mapOptions}
disabled={uploading || !newScopeGame}
/>
{/if}
{#if newScopeType === 'game' || newScopeType === 'map'}
<Select
ariaLabel="Scope game"
bind:value={newScopeGame}
onchange={() => (newScopeMap = '')}
options={gameOptions}
disabled={uploading}
/>
{/if}
{#if newScopeType === 'map'}
<Select
ariaLabel="Scope map"
bind:value={newScopeMap}
options={mapOptions}
disabled={uploading || !newScopeGame}
/>
{/if}
</div>
</Field>
<Field label="Plugin file (.dll)">
<Input type="file" accept=".dll" onchange={pickFile} disabled={uploading} />
</Field>
<div class="form-actions ui-sticky-actions">
<Button
type="submit"
variant="primary"
disabled={uploading || !newFile || !newName}
loading={uploading}
>
Upload plugin
</Button>
{#if flash}<span class="flash" role="alert">{flash}</span>{/if}
</div>
</Field>
<Field label="Plugin file (.dll)">
<Input type="file" accept=".dll" onchange={pickFile} disabled={uploading} />
</Field>
<div class="form-actions ui-sticky-actions">
<Button
type="submit"
variant="primary"
disabled={uploading || !newFile || !newName}
loading={uploading}
>
Upload plugin
</Button>
{#if flash}<span class="flash" role="alert">{flash}</span>{/if}
</div>
</form>
{/if}
</div>
</Dialog>
</form>
{/if}
</div>
</Dialog>{/if}
<style>
.page {
+19 -17
View File
@@ -1,4 +1,6 @@
<script lang="ts">
import { canOperate } from '$lib/auth.js';
const writable = $derived(canOperate($page.data?.user?.role));
import Select from '$ui/components/primitives/Select.svelte';
import Input from '$ui/components/primitives/Input.svelte';
import Field from '$ui/components/primitives/Field.svelte';
@@ -100,7 +102,7 @@
void goto(url, { replaceState: true, noScroll: true, keepFocus: true });
}
async function cancelRequests(rows: Deployment[], bulk = false) {
if (cancelling || !rows.length) return;
if (!writable || cancelling || !rows.length) return;
const ids = rows.map((row) => row.id);
if (
!(await confirmAction({
@@ -288,10 +290,10 @@
<svelte:head><title>Provisioning | Electric Cherry</title></svelte:head>
<div class="page">
<SectionHeader title="Provisioning">
{#snippet actions()}
<Button onclick={() => (lifecycleOpen = true)}>Server actions</Button>
<Button onclick={() => (fleetActionsOpen = true)}>Fleet actions</Button>
{/snippet}
{#snippet actions()}{#if writable}
<Button onclick={() => (lifecycleOpen = true)}>Server actions</Button>
<Button onclick={() => (fleetActionsOpen = true)}>Fleet actions</Button>
{/if}{/snippet}
</SectionHeader>
{#if $page.url.searchParams.has('returnTo')}<BackLink fallback="/" />{/if}
<div class="toolbar">
@@ -368,16 +370,16 @@
<Button variant="primary" onclick={() => (filtersOpen = false)}>Show results</Button>
{/snippet}
</Dialog>
<LifecycleDialog bind:open={lifecycleOpen} />
<FleetActionsDialog
bind:open={fleetActionsOpen}
onqueued={(count, action) => {
fleetNotice = `${actionLabels[action]} queued for ${count} server${count === 1 ? '' : 's'}.`;
tab = 'active';
clearFilters();
void load();
}}
/>
{#if writable}<LifecycleDialog bind:open={lifecycleOpen} />
<FleetActionsDialog
bind:open={fleetActionsOpen}
onqueued={(count, action) => {
fleetNotice = `${actionLabels[action]} queued for ${count} server${count === 1 ? '' : 's'}.`;
tab = 'active';
clearFilters();
void load();
}}
/>{/if}
{#if fleetNotice}<p role="status">{fleetNotice}</p>{/if}
{#if cancellationNotice}<p role="status">{cancellationNotice}</p>{/if}
<div
@@ -398,7 +400,7 @@
</p>
{:else}<h2>History <span>{historyTotal}</span></h2>{/if}
<div class="queue-tools">
{#if tab === 'active' && active.some((row) => row.canCancel)}<Button
{#if writable && tab === 'active' && active.some((row) => row.canCancel)}<Button
size="sm"
disabled={cancelling}
onclick={() =>
@@ -595,7 +597,7 @@
`/servers/${encodeURIComponent(row.server)}?tab=logs`,
$page.url
)}>Open logs</a
>{/if}{#if row.canCancel && !row.server_deleted_at}<Button
>{/if}{#if writable && row.canCancel && !row.server_deleted_at}<Button
variant="danger"
size="sm"
disabled={cancelling}
+6 -1
View File
@@ -1,4 +1,5 @@
<script lang="ts">
import { canOperate } from '$lib/auth.js';
import { contextHref } from '$lib/navigation-context.js';
import { onDestroy, untrack } from 'svelte';
import { page } from '$app/stores';
@@ -35,6 +36,7 @@
import { timeFormat } from '$ui/stores/time-zone.js';
let { data }: { data: PageData } = $props();
const writable = $derived(canOperate(data.user?.role));
const initial = untrack(() => data.detail);
let view = $state<ProvisioningRefreshState<ProvisioningDetail>>({
id: initial.deployment.id,
@@ -104,6 +106,7 @@
});
onDestroy(() => controller.dispose());
async function retryWithRebuild() {
if (!writable) return;
if (!deployment || !detail?.canRetryWithRebuild || view.busy) return;
const id = deployment.id;
const confirmed = await confirmAction({
@@ -138,6 +141,7 @@
);
}
async function cancelOperation() {
if (!writable) return;
if (!deployment || !detail?.canCancel || view.busy) return;
const id = deployment.id;
const confirmed = await confirmAction({
@@ -153,6 +157,7 @@
);
}
async function cancelCreation() {
if (!writable) return;
if (!deployment || !detail?.canCancelCreation || view.busy) return;
const id = deployment.id;
const confirmed = await confirmAction({
@@ -305,7 +310,7 @@
)}.
</p>
</div>{/if}
{#if detail.canResumeVerification || detail.canResumeCleanup || detail.canResumeRebuild || detail.canRetryWithRebuild || detail.canCancel || detail.canCancelCreation || canOverride}
{#if writable && (detail.canResumeVerification || detail.canResumeCleanup || detail.canResumeRebuild || detail.canRetryWithRebuild || detail.canCancel || detail.canCancelCreation || canOverride)}
<div class="recovery-actions">
{#if canOverride}<div>
<Button
+38 -31
View File
@@ -3,6 +3,7 @@
import Input from '$ui/components/primitives/Input.svelte';
import Field from '$ui/components/primitives/Field.svelte';
import { page } from '$app/stores';
import { canOperate } from '$lib/auth.js';
import { contextHref } from '$lib/navigation-context.js';
import { onMount } from 'svelte';
import { afterNavigate } from '$app/navigation';
@@ -24,6 +25,7 @@
} from '$lib/script-library.js';
import { zombieMaps } from '$lib/map-aliases.js';
import { libraryLimit, saveLibraryContext } from '$ui/library-context.js';
const editable = $derived(canOperate($page.data?.user?.role));
let limit = $state(25);
let mounted = $state(false);
@@ -53,7 +55,7 @@
let map = $state('');
let sort = $state('attention');
let selected = $state<number[]>([]);
let uploader: ScriptUploadDrawer;
let uploader = $state<ScriptUploadDrawer>();
let dragDepth = $state(0);
const tabs: Array<{ value: ScriptStatus | 'all'; label: string }> = [
{ value: 'all', label: 'All scripts' },
@@ -179,13 +181,15 @@
map = '';
}
function onDrag(event: DragEvent) {
if (!editable) return;
if (event.dataTransfer?.types.includes('Files')) event.preventDefault();
}
function onDrop(event: DragEvent) {
if (!editable) return;
if (!event.dataTransfer?.types.includes('Files')) return;
event.preventDefault();
dragDepth = 0;
void uploader.addFiles([...event.dataTransfer.files]);
void uploader?.addFiles([...event.dataTransfer.files]);
}
</script>
@@ -195,7 +199,7 @@
ondragover={onDrag}
ondrop={onDrop}
ondragenter={(event) => {
if (event.dataTransfer?.types.includes('Files')) {
if (editable && event.dataTransfer?.types.includes('Files')) {
event.preventDefault();
dragDepth++;
}
@@ -209,9 +213,9 @@
title="Scripts"
subtitle="Review versions, prepare a beta, and keep releases moving."
>
{#snippet actions()}{#if loading || scripts.length || error}<Button
{#snippet actions()}{#if editable && (loading || scripts.length || error)}<Button
variant="primary"
onclick={() => uploader.open()}>Upload scripts</Button
onclick={() => uploader?.open()}>Upload scripts</Button
>{/if}{/snippet}
</SectionHeader>
<section class="library" aria-label="Script library">
@@ -277,9 +281,10 @@
title="No scripts yet"
description="Upload script files to keep versions and test changes in a beta before promotion."
>
{#snippet actions()}<Button variant="primary" onclick={() => uploader.open()}
>Upload scripts</Button
>{/snippet}
{#snippet actions()}{#if editable}<Button
variant="primary"
onclick={() => uploader?.open()}>Upload scripts</Button
>{/if}{/snippet}
</EmptyState>
{:else if !visible.length && !error}
<EmptyState
@@ -293,31 +298,32 @@
<table>
<thead
><tr
><th class="select-cell"
><input
type="checkbox"
aria-label="Select visible scripts eligible for a beta"
checked={allSelected}
disabled={!selectable.length}
onchange={selectVisible}
/></th
><th>Script</th><th>Game / scope</th><th>Version / status</th><th
>Eligible servers</th
><th><span class="sr-only">Next action</span></th></tr
>{#if editable}<th class="select-cell"
><input
type="checkbox"
aria-label="Select visible scripts eligible for a beta"
checked={allSelected}
disabled={!selectable.length}
onchange={selectVisible}
/></th
>{/if}<th>Script</th><th>Game / scope</th><th>Version / status</th
><th>Eligible servers</th><th
><span class="sr-only">Next action</span></th
></tr
></thead
>
<tbody
>{#each visible.slice(0, limit) as s (s.id)}
<tr class:selected={selected.includes(s.id)}>
<td class="select-cell"
><input
type="checkbox"
aria-label={`Select ${s.name} for a beta`}
checked={selected.includes(s.id)}
disabled={!canTest(s)}
onchange={() => toggle(s.id)}
/></td
>
{#if editable}<td class="select-cell"
><input
type="checkbox"
aria-label={`Select ${s.name} for a beta`}
checked={selected.includes(s.id)}
disabled={!canTest(s)}
onchange={() => toggle(s.id)}
/></td
>{/if}
<td
><a
class="name"
@@ -349,7 +355,8 @@
href={contextHref(`/scripts/${s.id}`, $page.url)}
>Review failure</a
>
{:else if canTest(s)}<a href={betaHref([s])}>Create beta</a
{:else if editable && canTest(s)}<a href={betaHref([s])}
>Create beta</a
>{/if}</td
>
</tr>
@@ -360,7 +367,7 @@
{/if}
<ListFooter total={visible.length} bind:limit label="scripts" />
</section>
{#if selectedScripts.length}
{#if editable && selectedScripts.length}
<div class="selection-bar" role="region" aria-label="Selected scripts">
<strong
title={hiddenSelected
@@ -398,7 +405,7 @@
>We’ll recognize existing scripts and check for duplicate versions.</span
>
</div>{/if}
<ScriptUploadDrawer bind:this={uploader} {scripts} onuploaded={load} />
{#if editable}<ScriptUploadDrawer bind:this={uploader} {scripts} onuploaded={load} />{/if}
<style>
.page {
+57 -52
View File
@@ -24,6 +24,7 @@
updated_at: string;
}
const games: ZombieGame[] = ['t5', 't6'];
const administrator = $derived($page.data.user?.role === 'admin');
let open = $state(false);
let search = $state('');
let gameFilter = $state('');
@@ -85,6 +86,7 @@
void reload();
});
function addKey() {
if (!administrator) return;
plutoKey = '';
label = '';
error = '';
@@ -93,7 +95,7 @@
}
async function save(e: SubmitEvent) {
e.preventDefault();
if (!plutoKey.trim() || busy) return;
if (!administrator || !plutoKey.trim() || busy) return;
busy = true;
error = '';
notice = '';
@@ -117,7 +119,7 @@
}
}
async function remove(key: SavedKey) {
if (key.assigned_servers.length) return;
if (!administrator || busy || key.assigned_servers.length) return;
if (
!(await confirmAction({
title: 'Remove saved key?',
@@ -154,7 +156,9 @@
title="Keys"
subtitle="Pool Plutonium keys by game. Any available key can launch any map or lobby in that game."
>
{#snippet actions()}<Button variant="primary" onclick={addKey}>Add key</Button>{/snippet}
{#snippet actions()}{#if administrator}<Button variant="primary" onclick={addKey}
>Add key</Button
>{/if}{/snippet}
</SectionHeader>
<p class="key-help">
A key stays assigned while its server exists, including when stopped, and becomes available
@@ -209,16 +213,17 @@
{#if loading}<p>Loading keys…</p>
{:else if !filtered.length}<EmptyState
title={keys.length ? 'No matching keys' : 'No saved keys'}
description="Add keys here. Launches automatically use an available key from their game’s pool."
description={administrator
? 'Add keys here. Launches automatically use an available key from their game’s pool.'
: 'Launches automatically use an available key from their game’s pool.'}
/>
{:else}
<div class="table-wrap">
<table>
<thead
><tr
><th>Game / label</th><th>Availability</th><th>Last saved</th><th
>Actions</th
></tr
><th>Game / label</th><th>Availability</th><th>Last saved</th
>{#if administrator}<th>Actions</th>{/if}</tr
></thead
>
<tbody
@@ -237,13 +242,13 @@
>{/if}</td
>
<td><RelativeTime value={key.updated_at} /></td>
<td
><Button
ariaLabel={`Remove ${key.label || key.game.toUpperCase() + ' key'}`}
disabled={busy || !!key.assigned_servers.length}
onclick={() => remove(key)}>Remove</Button
>{#if key.assigned_servers.length}<small>In use</small>{/if}</td
>
{#if administrator}<td
><Button
ariaLabel={`Remove ${key.label || key.game.toUpperCase() + ' key'}`}
disabled={busy || !!key.assigned_servers.length}
onclick={() => remove(key)}>Remove</Button
>{#if key.assigned_servers.length}<small>In use</small>{/if}</td
>{/if}
</tr>
{/each}</tbody
>
@@ -252,44 +257,44 @@
{/if}
<ListFooter total={filtered.length} bind:limit onchange={saveSearch} label="keys" />
</div>
<Dialog bind:open title="Add key" {busy}>
{#if error}<p role="alert" class="error">{error}</p>{/if}
<form onsubmit={save}>
<Field label="Game">
<Select bind:value={game} disabled={busy}
><option value="t5">Black Ops I</option><option value="t6">Black Ops II</option
></Select
></Field
>
<Field
label="Label (optional)"
hint="Record the hostname you chose in Plutonium, or another name that helps identify this key."
>
<Input bind:value={label} placeholder="e.g. Electric Cherry 5" disabled={busy} />
</Field>
<Field
label="Plutonium server key"
hint="Available to any new lobby in this game. Existing credentials are never displayed."
>
<Input
type="password"
bind:value={plutoKey}
placeholder="Paste key"
autocomplete="off"
required
disabled={busy}
/>
</Field>
<div class="actions">
<Button
type="submit"
variant="primary"
disabled={busy || loading || !plutoKey.trim()}
loading={busy}>Save key</Button
><Button onclick={() => (open = false)} disabled={busy}>Cancel</Button>
</div>
</form>
</Dialog>
{#if administrator}<Dialog bind:open title="Add key" {busy}>
{#if error}<p role="alert" class="error">{error}</p>{/if}
<form onsubmit={save}>
<Field label="Game">
<Select bind:value={game} disabled={busy}
><option value="t5">Black Ops I</option><option value="t6">Black Ops II</option
></Select
></Field
>
<Field
label="Label (optional)"
hint="Record the hostname you chose in Plutonium, or another name that helps identify this key."
>
<Input bind:value={label} placeholder="e.g. Electric Cherry 5" disabled={busy} />
</Field>
<Field
label="Plutonium server key"
hint="Available to any new lobby in this game. Existing credentials are never displayed."
>
<Input
type="password"
bind:value={plutoKey}
placeholder="Paste key"
autocomplete="off"
required
disabled={busy}
/>
</Field>
<div class="actions">
<Button
type="submit"
variant="primary"
disabled={busy || loading || !plutoKey.trim()}
loading={busy}>Save key</Button
><Button onclick={() => (open = false)} disabled={busy}>Cancel</Button>
</div>
</form>
</Dialog>{/if}
<style>
.page {
+8 -6
View File
@@ -20,6 +20,7 @@
import StatusBadge from '$ui/components/primitives/StatusBadge.svelte';
import ListFooter from '$ui/components/primitives/ListFooter.svelte';
import { page } from '$app/stores';
import { canReadSensitive } from '$lib/auth.js';
import { goto } from '$app/navigation';
import LifecycleDialog from '$ui/components/LifecycleDialog.svelte';
import { actionLabels, type RuntimeAction } from '$lib/provisioning.js';
@@ -120,6 +121,7 @@
const canOperate = $derived(
$page.data?.user?.role === 'admin' || $page.data?.user?.role === 'operator'
);
const canInspect = $derived(canReadSensitive($page.data?.user?.role));
const availableActions = $derived(
serverContext ? applicableRuntimeActions(serverContext, $uiNow) : []
);
@@ -207,7 +209,7 @@
let tabSequence = 0;
async function loadTab(currentName: string, tab: string, signal?: AbortSignal) {
if (tab !== 'configuration' && (tab !== 'diagnostics' || !canOperate)) return;
if (tab !== 'configuration' && (tab !== 'diagnostics' || !canInspect)) return;
const sequence = ++tabSequence;
try {
const response = await fetch(
@@ -231,7 +233,7 @@
)
return;
if (tab === 'diagnostics') {
if (!canOperate) return;
if (!canInspect) return;
savedRconSnippet = body.replySnippet;
} else inventory = body.scripts;
tabError = '';
@@ -262,8 +264,8 @@
inventory = [];
inventoryLimit = 25;
savedRconSnippet = null;
tabLoading = tab === 'configuration' || (tab === 'diagnostics' && canOperate);
if (tab !== 'configuration' && (tab !== 'diagnostics' || !canOperate)) return;
tabLoading = tab === 'configuration' || (tab === 'diagnostics' && canInspect);
if (tab !== 'configuration' && (tab !== 'diagnostics' || !canInspect)) return;
return pollVisible((signal) => loadTab(currentName, tab, signal), 10000);
});
$effect(() => {
@@ -290,7 +292,7 @@
let effectiveReason = $derived<RconFailure | null>(decoded?.reason ?? null);
let errorDetail = $derived(decoded?.detail ?? effectiveErr ?? '');
let effectiveSnippet = $derived<string | null>(
canOperate ? (rconProbe ? (rconProbe.rawReply ?? null) : savedRconSnippet) : null
canInspect ? (rconProbe ? (rconProbe.rawReply ?? null) : savedRconSnippet) : null
);
let effectiveActiveSince = $derived<string | null>(server?.active_since ?? null);
let effectiveInactiveSince = $derived<string | null>(server?.inactive_since ?? null);
@@ -705,7 +707,7 @@
</div>
{/if}
{#if canOperate && effectiveSnippet}
{#if canInspect && effectiveSnippet}
<details class="snippet-details" open={effectiveReason === 'bad-reply'}>
<summary>Failure reply (addresses redacted)</summary>
<pre class="snippet">{effectiveSnippet}</pre>
+5 -1
View File
@@ -1,5 +1,6 @@
<script lang="ts">
import { onMount } from 'svelte';
import { page } from '$app/stores';
import SectionHeader from '$ui/components/primitives/SectionHeader.svelte';
import SettingsEditor from '$ui/components/SettingsEditor.svelte';
import Button from '$ui/components/primitives/Button.svelte';
@@ -33,7 +34,10 @@
subtitle="Shared fleet limits and history retention. Changes apply without restarting."
/>
{#if loading}<p role="status">Loading fleet settings…</p>
{:else if settings}<SettingsEditor initial={settings} />
{:else if settings}<SettingsEditor
initial={settings}
readOnly={$page.data.user?.role !== 'admin'}
/>
{:else}<div>
<p class="ui-error" role="alert">{error}</p>
<Button onclick={loadSettings}>Retry loading</Button>
+182 -161
View File
@@ -44,6 +44,11 @@
label: 'Viewer',
description: 'Read fleet status and operation summaries.'
},
{
value: 'super_viewer',
label: 'Super Viewer',
description: 'Read all fleet and administrator pages without making changes.'
},
{
value: 'operator',
label: 'Operator',
@@ -57,6 +62,7 @@
];
const roleDescription = $derived(roles.find((item) => item.value === role)?.description);
const administrator = $derived($page.data.user?.role === 'admin');
const canViewUsers = $derived(administrator || $page.data.user?.role === 'super_viewer');
async function load() {
loading = true;
@@ -104,13 +110,14 @@
}
}
onMount(() => {
if (administrator) void Promise.all([load(), loadAudit()]);
if (canViewUsers) void Promise.all([load(), loadAudit()]);
else {
loading = false;
auditLoading = false;
}
});
function openCreate() {
if (!administrator) return;
username = '';
displayName = '';
password = '';
@@ -122,6 +129,7 @@
createOpen = true;
}
function openEdit(user: AuthUser) {
if (!administrator) return;
editing = user;
username = user.username;
displayName = user.displayName;
@@ -134,7 +142,7 @@
}
async function create(event: SubmitEvent) {
event.preventDefault();
if (busy) return;
if (!administrator || busy) return;
busy = true;
formError = '';
error = '';
@@ -159,7 +167,7 @@
}
async function save(event: SubmitEvent) {
event.preventDefault();
if (busy || !editing) return;
if (!administrator || busy || !editing) return;
busy = true;
formError = '';
error = '';
@@ -196,6 +204,7 @@
}
}
async function revoke(user: AuthUser) {
if (!administrator || busy) return;
const own = user.id === $page.data.user?.id;
if (
!(await confirmAction({
@@ -234,14 +243,19 @@
<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.">
<SectionHeader
title="Users"
subtitle={administrator
? 'Create accounts and manage who can operate your fleet.'
: 'View fleet accounts, access roles, and account activity.'}
>
{#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>
{#if !canViewUsers}<p class="error" role="alert">You do not have access to view users.</p>
<a href="/account">Go to your account</a>
{:else}
{#if error}<p class="error" role="alert">{error}</p>{/if}{#if notice}<p
@@ -274,7 +288,8 @@
</div>
<div class="access">
<span class="role"
>{user.role === 'admin' ? 'Administrator' : user.role}</span
>{roles.find((item) => item.value === user.role)?.label ??
user.role}</span
><span class:disabled={user.disabled}
>{user.disabled ? 'Disabled' : 'Enabled'}</span
>{#if user.mustChangePassword && !user.disabled}<small
@@ -292,16 +307,16 @@
/>
</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>
{#if administrator}<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>{/if}
</article>{/each}
</div>
{/if}
@@ -355,163 +370,169 @@
{/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}
<Field forId="new-username" label="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"
/></Field
>
<p class="hint" id="new-username-help">
3–64 characters: letters, numbers, periods, underscores, and hyphens. Start with a
letter or number.
</p>
<Field forId="new-name" label="Display name">
<Input
id="new-name"
name="displayName"
required
maxlength={100}
autocomplete="off"
bind:value={displayName}
/></Field
>
<Field forId="new-role" label="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
></Field
>
<p class="hint" id="new-role-help">{roleDescription}</p>
<Field forId="temporary-password" label="Temporary password">
<Input
id="temporary-password"
name="password"
type={showPassword ? 'text' : 'password'}
required
minlength={8}
maxlength={128}
autocomplete="new-password"
bind:value={password}
aria-describedby="temporary-help"
/></Field
>
<p class="hint" id="temporary-help">
8–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}
<Field forId="edit-name" label="Display name">
<Input
id="edit-name"
name="displayName"
required
maxlength={100}
autocomplete="off"
bind:value={displayName}
/></Field
>
<Field forId="edit-role" label="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
></Field
>
<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}<Field forId="reset-password"
>{#snippet label()}New temporary password <span class="optional"
>(optional)</span
>{/snippet}
{#if administrator}<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}
<Field forId="new-username" label="Username">
<Input
id="reset-password"
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"
/></Field
>
<p class="hint" id="new-username-help">
3–64 characters: letters, numbers, periods, underscores, and hyphens. Start with
a letter or number.
</p>
<Field forId="new-name" label="Display name">
<Input
id="new-name"
name="displayName"
required
maxlength={100}
autocomplete="off"
bind:value={displayName}
/></Field
>
<Field forId="new-role" label="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
></Field
>
<p class="hint" id="new-role-help">{roleDescription}</p>
<Field forId="temporary-password" label="Temporary password">
<Input
id="temporary-password"
name="password"
type={showPassword ? 'text' : 'password'}
required
minlength={8}
maxlength={128}
autocomplete="new-password"
bind:value={password}
aria-describedby="reset-help"
aria-describedby="temporary-help"
/></Field
>
<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 class="hint" id="temporary-help">
8–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
>{/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>
<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}
<Field forId="edit-name" label="Display name">
<Input
id="edit-name"
name="displayName"
required
maxlength={100}
autocomplete="off"
bind:value={displayName}
/></Field
>
<Field forId="edit-role" label="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
></Field
>
<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}<Field forId="reset-password"
>{#snippet label()}New temporary password <span class="optional"
>(optional)</span
>{/snippet}
<Input
id="reset-password"
name="password"
type={showPassword ? 'text' : 'password'}
minlength={8}
maxlength={128}
autocomplete="new-password"
bind:value={password}
aria-describedby="reset-help"
/></Field
>
<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>{/if}
<style>
.page {
@@ -527,7 +548,7 @@
}
.roles {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 24px;
padding: 24px;
}