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
+20 -13
View File
@@ -31,16 +31,23 @@ guessable token. An unset setup token holds setup closed.
## Roles
| Capability | Viewer | Operator | Administrator |
| --------------------------------------------------------------------------------------------- | -------------- | ----------- | ------------- |
| Fleet, metrics, matches and operation summaries | Yes | Yes | Yes |
| Raw configs, file diffs, logs, RCON and telemetry diagnostics | No | Yes | Yes |
| Deploy/start/stop/restart/rebuild/remove servers | No | Yes | Yes |
| Scripts, plugins, configs, campaigns, betas and collections | Read summaries | Manage | Manage |
| Host setup, deletion, enrollment, credentials, runtime preparation and game-file upload/cache | Read status | Read status | Manage |
| Server keys and global settings/maintenance policies | No | No | Manage |
| Create users, change roles, disable accounts and reset passwords | No | No | Yes |
| Own password and sign out | Yes | Yes | Yes |
| Capability | Viewer | Super Viewer | Operator | Administrator |
| --------------------------------------------------------------------------------------------- | -------------- | ------------ | ----------- | ------------- |
| Fleet, metrics, matches and operation summaries | Yes | Yes | Yes | Yes |
| Raw configs, file diffs, logs, saved RCON and telemetry diagnostics | No | Read | Read | Read |
| Deploy/start/stop/restart/rebuild/remove servers and run diagnostic probes | No | No | Yes | Yes |
| Scripts, plugins, configs, campaigns, betas and collections | Read summaries | Read | Manage | Manage |
| Host setup, deletion, enrollment, credentials, runtime preparation and game-file upload/cache | Read status | Read status | Read status | Manage |
| Server keys and global settings/maintenance policies | No | Read | No | Manage |
| Users and account audit activity | No | Read | No | Manage |
| Create users, change roles, disable accounts and reset passwords | No | No | No | Yes |
| Own password and sign out | Yes | Yes | Yes | Yes |
Super Viewer is a read-only administrator. It includes the Admin navigation and
access to sensitive configuration and diagnostic content, but cannot change fleet
state, settings, keys, or other accounts. Cleanup previews remain available because
they do not save settings or run cleanup. Assign it only to people trusted to read
administrative information. Existing accounts and sessions are preserved on upgrade.
Operators can launch using available game-pool keys already saved by an administrator. They
cannot save keys indirectly through a launch request. An operator's content
@@ -50,9 +57,9 @@ people trusted to operate the game fleet.
Drift previews (`/api/drift`) and GitHub release lookups
(`/api/plugins/github/release`) require operator or administrator access, including
`GET` and `HEAD` requests. These endpoints initiate fleet file scans or use GitHub
API quota; viewers cannot trigger them.
API quota; Viewers and Super Viewers cannot trigger them.
RCON failure replies are available only to operators and administrators in the
Saved RCON failure replies are available to Super Viewers, operators and administrators in the
server's Diagnostics tab. Player addresses are redacted before diagnostics are
saved; successful checks clear the saved reply. Fleet snapshots and live status
streams contain no reply snippets. Upgrades discard older saved RCON snippets.
@@ -79,7 +86,7 @@ revocation every 15 seconds and then close.
Login, initial setup and password verification are rate-limited with persistent
15-minute windows. The database stores audit entries for login results, account
changes and successful management requests without recording passwords, tokens
or request bodies. Administrators can review recent entries in Users.
or request bodies. Administrators and Super Viewers can review recent entries in Users.
The password service runs at most two scrypt operations at once and queues up to
16 more for up to two seconds. If that queue fills or the wait expires, the
+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;
}
+46 -7
View File
@@ -1,4 +1,6 @@
import { describe, expect, it } from 'vitest';
import { readdirSync } from 'node:fs';
import { relative, resolve } from 'node:path';
import type { UserRole } from '../../src/lib/auth.js';
import {
roleAllows,
@@ -6,11 +8,42 @@ import {
type RoutePermission
} from '../../src/lib/server/auth/access.js';
const roles: UserRole[] = ['viewer', 'operator', 'admin'];
const roles: UserRole[] = ['viewer', 'super_viewer', 'operator', 'admin'];
const allows = (role: UserRole, route: string | null, method = 'GET') =>
roleAllows(role, routePermission(route, method));
describe('management route permissions', () => {
it('denies Super Viewer writes across every registered management route', () => {
const routes = resolve('src/routes');
const entries = readdirSync(routes, { recursive: true, withFileTypes: true });
const accountRoutes = new Set([
'/api/auth/login',
'/api/auth/setup',
'/api/auth/logout',
'/api/auth/password',
'/api/auth/me',
'/account'
]);
for (const entry of entries) {
if (!/^\+(server|page)(\.server)?\.(ts|svelte)$/.test(entry.name)) continue;
const route = '/' + relative(routes, entry.parentPath).split('\\').join('/');
if (accountRoutes.has(route) || route.startsWith('/api/agent/')) continue;
for (const method of ['POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS']) {
const preview = route === '/api/settings/retention/preview' && method === 'POST';
expect(allows('super_viewer', route, method), `${method} ${route}`).toBe(preview);
}
}
});
it('allows cleanup previews without permitting retention changes or operational reads', () => {
expect(allows('super_viewer', '/api/settings/retention/preview', 'POST')).toBe(true);
expect(allows('operator', '/api/settings/retention/preview', 'POST')).toBe(false);
expect(allows('super_viewer', '/api/settings/retention', 'POST')).toBe(false);
for (const route of ['/api/drift', '/api/plugins/github/release'])
for (const method of ['GET', 'HEAD'])
expect(allows('super_viewer', route, method)).toBe(false);
});
it.each([
'/',
'/api/fleet',
@@ -77,6 +110,7 @@ describe('management route permissions', () => {
])('requires an operator for %s %s', (route, method) => {
expect(routePermission(route, method)).toBe('operator');
expect(allows('viewer', route, method)).toBe(false);
expect(allows('super_viewer', route, method)).toBe(false);
expect(allows('operator', route, method)).toBe(true);
expect(allows('admin', route, method)).toBe(true);
});
@@ -89,14 +123,14 @@ describe('management route permissions', () => {
'/settings/users',
'/api/settings',
'/api/settings/retention',
'/api/settings/retention/preview',
'/server-keys',
'/keys',
'/api/server-keys',
'/api/provisioning/[id]/override'
])('reserves all methods on %s for administrators', (route) => {
'/api/server-keys'
])('allows Super Viewers to read admin route %s without changing it', (route) => {
for (const method of ['GET', 'HEAD', 'POST', 'PUT', 'PATCH', 'DELETE']) {
expect(routePermission(route, method)).toBe('admin');
const reading = method === 'GET' || method === 'HEAD';
expect(routePermission(route, method)).toBe(reading ? 'admin-read' : 'admin');
expect(allows('super_viewer', route, method)).toBe(reading);
expect(allows('viewer', route, method)).toBe(false);
expect(allows('operator', route, method)).toBe(false);
expect(allows('admin', route, method)).toBe(true);
@@ -104,6 +138,7 @@ describe('management route permissions', () => {
});
it.each([
['/api/provisioning/[id]/override', 'POST'],
['/api/hosts', 'POST'],
['/api/hosts/[id]', 'PATCH'],
['/api/hosts/[id]', 'DELETE'],
@@ -120,6 +155,7 @@ describe('management route permissions', () => {
['/api/plutonium', 'POST']
])('protects host, game-file, and global policy mutations at %s', (route, method) => {
expect(routePermission(route, method)).toBe('admin');
expect(allows('super_viewer', route, method)).toBe(false);
expect(allows('operator', route, method)).toBe(false);
expect(allows('admin', route, method)).toBe(true);
});
@@ -136,7 +172,8 @@ describe('management route permissions', () => {
'/api/servers/[name]/rcon-diagnostics'
])('prevents viewer access to secret-bearing raw content at %s', (route) => {
for (const method of ['GET', 'HEAD']) {
expect(routePermission(route, method)).toBe('operator');
expect(routePermission(route, method)).toBe('operator-read');
expect(allows('super_viewer', route, method)).toBe(true);
expect(allows('viewer', route, method)).toBe(false);
expect(allows('operator', route, method)).toBe(true);
}
@@ -243,6 +280,8 @@ describe('authentication and machine boundaries', () => {
'authenticated',
'viewer',
'operator',
'operator-read',
'admin-read',
'admin',
'machine'
] as RoutePermission[]) {
+48 -11
View File
@@ -234,10 +234,32 @@ describe('browser authentication guard', () => {
['operator', '/api/hosts/[id]', 'PATCH', 403],
['operator', '/api/users', 'GET', 403],
['admin', '/api/hosts/[id]', 'PATCH', 200],
['admin', '/api/users', 'GET', 200]
['admin', '/api/users', 'GET', 200],
['super_viewer', '/api/users', 'GET', 200],
['super_viewer', '/api/users/audit', 'GET', 200],
['super_viewer', '/api/server-keys', 'HEAD', 200],
['super_viewer', '/api/configs/[id]', 'GET', 200],
['super_viewer', '/api/servers/[name]/rcon-diagnostics', 'GET', 200],
['super_viewer', '/api/servers/[name]/logs/[file]', 'GET', 200],
['super_viewer', '/api/servers/[name]/telemetry-requests', 'GET', 200],
['super_viewer', '/api/users', 'POST', 403],
['super_viewer', '/api/users/[id]', 'PATCH', 403],
['super_viewer', '/api/users/[id]/revoke-sessions', 'POST', 403],
['super_viewer', '/api/server-keys', 'POST', 403],
['super_viewer', '/api/servers', 'POST', 403],
['super_viewer', '/api/provisioning/[id]/override', 'POST', 403],
['super_viewer', '/api/hosts/[id]', 'PATCH', 403],
['super_viewer', '/api/servers/[name]/probe-rcon', 'POST', 403],
['super_viewer', '/api/configs/inspect', 'POST', 403],
['super_viewer', '/api/settings/retention/preview', 'POST', 200],
['super_viewer', '/api/auth/password', 'POST', 200],
['super_viewer', '/api/auth/logout', 'POST', 200]
] as const)('enforces %s role for %s %s', async (role, routeId, method, expectedStatus) => {
const session = signIn(role);
const call = requestFor(routeId, { method, body: method === 'GET' ? undefined : {} });
const call = requestFor(routeId, {
method,
body: ['GET', 'HEAD'].includes(method) ? undefined : {}
});
const response = await call.invoke();
expect(response.status).toBe(expectedStatus);
if (expectedStatus === 200) {
@@ -245,14 +267,28 @@ describe('browser authentication guard', () => {
expect(call.event.locals.user).toEqual(session.user);
} else expect(call.resolve).not.toHaveBeenCalled();
});
it.each(['GET', 'HEAD'])(
'restricts global settings %s requests to administrators',
it.each(['/settings', '/settings/users', '/server-keys', '/configs/[id]'])(
'allows Super Viewer pages and page-data requests at %s',
async (route) => {
signIn('super_viewer');
for (const path of [route, `${route}/__data.json`]) {
const call = requestFor(route, { path });
expect((await call.invoke()).status).toBe(200);
expect(call.resolve).toHaveBeenCalledOnce();
}
}
);
it.each(['GET', 'HEAD', 'POST', 'PATCH', 'DELETE'])(
'permits only administrators to write settings and Super Viewers to read them',
async (method) => {
for (const role of ['viewer', 'operator', 'admin'] as const) {
for (const role of ['viewer', 'super_viewer', 'operator', 'admin'] as const) {
signIn(role);
const call = requestFor('/api/settings', { method });
expect((await call.invoke()).status).toBe(role === 'admin' ? 200 : 403);
if (role === 'admin') expect(call.resolve).toHaveBeenCalledOnce();
const allowed =
role === 'admin' ||
(role === 'super_viewer' && ['GET', 'HEAD'].includes(method));
expect((await call.invoke()).status).toBe(allowed ? 200 : 403);
if (allowed) expect(call.resolve).toHaveBeenCalledOnce();
else expect(call.resolve).not.toHaveBeenCalled();
}
}
@@ -260,7 +296,7 @@ describe('browser authentication guard', () => {
it.each(['GET', 'HEAD'])(
'restricts raw beta file %s requests to operators and administrators',
async (method) => {
for (const role of ['viewer', 'operator', 'admin'] as const) {
for (const role of ['viewer', 'super_viewer', 'operator', 'admin'] as const) {
signIn(role);
const call = requestFor('/api/betas/[id]/revisions/[revision]/files/[scriptId]', {
method,
@@ -282,11 +318,12 @@ describe('browser authentication guard', () => {
expect((await anonymous.invoke()).status).toBe(401);
expect(anonymous.resolve).not.toHaveBeenCalled();
for (const role of ['viewer', 'operator', 'admin'] as const) {
for (const role of ['viewer', 'super_viewer', 'operator', 'admin'] as const) {
signIn(role);
const call = requestFor(route, { method });
expect((await call.invoke()).status).toBe(role === 'viewer' ? 403 : 200);
if (role === 'viewer') expect(call.resolve).not.toHaveBeenCalled();
const allowed = role === 'operator' || role === 'admin';
expect((await call.invoke()).status).toBe(allowed ? 200 : 403);
if (!allowed) expect(call.resolve).not.toHaveBeenCalled();
else expect(call.resolve).toHaveBeenCalledOnce();
}
});
+119
View File
@@ -0,0 +1,119 @@
import Database from 'better-sqlite3';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { migrateSuperViewerRole } from '../../src/lib/server/auth/schema.js';
import { runMigrations } from '../../src/lib/server/db/migration-runner.js';
import { migrations } from '../../src/lib/server/db/migrations.js';
let handle: Database.Database;
const migration = migrations.find((entry) => entry.name === 'super viewer authentication role')!;
beforeEach(() => {
handle = new Database(':memory:');
handle.pragma('foreign_keys = ON');
runMigrations(handle, migrations.slice(0, migration.version - 1));
handle.exec(`
INSERT INTO auth_users VALUES
('admin','Administrator','Admin','admin-password-hash','admin',0,0,10,20,30),
('operator','Operator','Op','operator-password-hash','operator',0,1,40,50,NULL),
('viewer','Viewer','View','viewer-password-hash','viewer',1,0,60,70,80);
INSERT INTO auth_sessions VALUES
('admin-token-hash','admin',10,1000,100),
('operator-token-hash','operator',20,2000,200);
INSERT INTO auth_rate_limits VALUES ('limit-key',100,3);
INSERT INTO auth_audit(actor_id,action,target_id,details,created_at)
VALUES ('admin','user.created','viewer','{"role":"viewer"}',100);
INSERT INTO servers(name,last_reply_snippet) VALUES ('saved','keep diagnostic');
`);
});
afterEach(() => handle.close());
function rows(table: string) {
return handle.prepare(`SELECT * FROM ${table} ORDER BY rowid`).all();
}
describe('super viewer role migration', () => {
it('preserves existing accounts, sessions, audit, rate limits and fleet data while extending the role constraint', () => {
const tables = ['auth_users', 'auth_sessions', 'auth_audit', 'auth_rate_limits', 'servers'];
const original = tables.map(rows);
expect(() =>
handle.exec("UPDATE auth_users SET role='super_viewer' WHERE id='viewer'")
).toThrow('CHECK constraint failed');
runMigrations(handle, migrations);
expect(handle.pragma('user_version', { simple: true })).toBe(migrations.length);
expect(tables.map(rows)).toEqual(original);
expect(handle.pragma('foreign_keys', { simple: true })).toBe(1);
expect(handle.pragma('foreign_key_check')).toEqual([]);
expect(handle.pragma('foreign_key_list(auth_sessions)')).toEqual([
expect.objectContaining({ table: 'auth_users', from: 'user_id', on_delete: 'CASCADE' })
]);
handle.exec("UPDATE auth_users SET role='super_viewer' WHERE id='viewer'");
expect(handle.prepare("SELECT role FROM auth_users WHERE id='viewer'").get()).toEqual({
role: 'super_viewer'
});
expect(() => handle.exec("UPDATE auth_users SET role='unknown' WHERE id='viewer'")).toThrow(
'CHECK constraint failed'
);
expect(() =>
handle.exec("UPDATE auth_users SET username='ADMINISTRATOR' WHERE id='viewer'")
).toThrow('UNIQUE constraint failed');
expect(() => handle.exec("UPDATE auth_sessions SET user_id='missing'")).toThrow(
'FOREIGN KEY constraint failed'
);
handle.exec("DELETE FROM auth_users WHERE id='operator'");
expect(handle.prepare('SELECT user_id FROM auth_sessions').all()).toEqual([
{ user_id: 'admin' }
]);
});
it('does not rebuild an already upgraded table or remove super viewers when replayed', () => {
runMigrations(handle, migrations);
handle.exec("UPDATE auth_users SET role='super_viewer' WHERE id='viewer'");
const users = rows('auth_users');
const sessions = rows('auth_sessions');
const schemaVersion = handle.pragma('schema_version', { simple: true });
migrateSuperViewerRole(handle);
handle.pragma(`user_version = ${migration.version - 1}`);
runMigrations(handle, migrations);
expect(rows('auth_users')).toEqual(users);
expect(rows('auth_sessions')).toEqual(sessions);
expect(handle.pragma('schema_version', { simple: true })).toBe(schemaVersion);
expect(handle.pragma('foreign_keys', { simple: true })).toBe(1);
});
it('rejects an unsafe direct rebuild and rolls back a failed reference check before retrying', () => {
const users = rows('auth_users');
const sessions = rows('auth_sessions');
expect(() => migrateSuperViewerRole(handle)).toThrow('foreign keys to be disabled');
expect(rows('auth_users')).toEqual(users);
expect(rows('auth_sessions')).toEqual(sessions);
handle.pragma('foreign_keys = OFF');
handle.exec("INSERT INTO auth_sessions VALUES ('orphan','missing',10,1000,100)");
handle.pragma('foreign_keys = ON');
expect(() => runMigrations(handle, migrations)).toThrow('left broken references');
expect(handle.pragma('user_version', { simple: true })).toBe(migration.version - 1);
expect(handle.pragma('foreign_keys', { simple: true })).toBe(1);
expect(rows('auth_users')).toEqual(users);
expect(rows('auth_sessions')).toHaveLength(sessions.length + 1);
expect(() =>
handle.exec("UPDATE auth_users SET role='super_viewer' WHERE id='viewer'")
).toThrow('CHECK constraint failed');
expect(
handle
.prepare("SELECT name FROM sqlite_master WHERE name='auth_users_super_viewer'")
.get()
).toBeUndefined();
handle.exec("DELETE FROM auth_sessions WHERE token_hash='orphan'");
runMigrations(handle, migrations);
expect(rows('auth_users')).toEqual(users);
expect(rows('auth_sessions')).toEqual(sessions);
expect(handle.pragma('foreign_key_check')).toEqual([]);
});
});
+13 -6
View File
@@ -256,7 +256,7 @@ describe('password storage and verification', () => {
describe('administrator user management', () => {
it('rejects the reserved system username for every role while allowing distinct usernames', async () => {
const admin = seedUser();
for (const role of ['admin', 'operator', 'viewer'] as const) {
for (const role of ['admin', 'super_viewer', 'operator', 'viewer'] as const) {
for (const username of ['Maxis', 'maxis', 'MAXIS', 'mAxIs']) {
await expect(
auth.createUser(admin.id, { username, role, password })
@@ -275,7 +275,7 @@ describe('administrator user management', () => {
it('creates each supported role with a forced initial password change and rejects case-insensitive duplicate names', async () => {
const admin = seedUser();
for (const role of ['admin', 'operator', 'viewer'] as const) {
for (const role of ['admin', 'super_viewer', 'operator', 'viewer'] as const) {
const user = await auth.createUser(admin.id, {
username: role + 'User',
displayName: ' ' + role + ' user ',
@@ -295,13 +295,14 @@ describe('administrator user management', () => {
await expect(
auth.createUser(admin.id, { username: 'OPERATORUSER', role: 'viewer', password })
).rejects.toMatchObject({ status: 409 });
expect(auth.listUsers()).toHaveLength(4);
expect(auth.listUsers()).toHaveLength(5);
expect(JSON.stringify(auth.listAuthAudit())).not.toContain(password);
});
it('rejects administrator mutations by operators, viewers, disabled administrators and forced-change administrators', async () => {
it('rejects administrator mutations by read-only roles, operators, disabled administrators and forced-change administrators', async () => {
const target = seedUser('target', 'viewer');
const actors = [
seedUser('superviewer', 'super_viewer'),
seedUser('operator', 'operator'),
seedUser('viewer', 'viewer'),
seedUser('disabledadmin', 'admin', { disabled: true }),
@@ -324,7 +325,11 @@ describe('administrator user management', () => {
it('preserves at least one enabled administrator and permits demotion only when another remains', async () => {
const first = seedUser();
seedUser('superviewer', 'super_viewer');
const session = auth.createSession(first.id);
await expect(
auth.updateUser(first.id, first.id, { role: 'super_viewer' })
).rejects.toMatchObject({ status: 409 });
await expect(
auth.updateUser(first.id, first.id, { role: 'operator' })
).rejects.toMatchObject({ status: 409 });
@@ -333,7 +338,8 @@ describe('administrator user management', () => {
);
expect(auth.readSession(session.token)?.user.role).toBe('admin');
const second = seedUser('secondadmin');
await auth.updateUser(first.id, first.id, { role: 'viewer' });
await auth.updateUser(first.id, first.id, { role: 'super_viewer' });
expect(auth.getUser(first.id)?.role).toBe('super_viewer');
expect(auth.readSession(session.token)).toBeNull();
await expect(
auth.updateUser(second.id, second.id, { disabled: true })
@@ -347,7 +353,8 @@ describe('administrator user management', () => {
const administratorSession = auth.createSession(admin.id);
const first = auth.createSession(target.id),
second = auth.createSession(target.id);
await auth.updateUser(admin.id, target.id, { role: 'operator' });
await auth.updateUser(admin.id, target.id, { role: 'super_viewer' });
expect(auth.getUser(target.id)?.role).toBe('super_viewer');
expect(auth.readSession(first.token)).toBeNull();
expect(auth.readSession(second.token)).toBeNull();
const third = auth.createSession(target.id);
+10
View File
@@ -8,6 +8,16 @@ import {
} from '../../src/lib/ui/command-palette.js';
describe('command palette destinations', () => {
it('lets Super Viewers search administration pages without offering operations', () => {
const results = palettePageResults('super_viewer', '');
expect(results.filter((item) => item.group === 'Actions')).toEqual([]);
expect(results.map((item) => item.href)).toEqual(
palettePageResults('admin', '')
.filter((item) => item.group === 'Pages')
.map((item) => item.href)
);
});
it('keeps administrator destinations and operations out of inappropriate roles', () => {
const viewer = palettePageResults('viewer', '');
expect(
+6
View File
@@ -54,6 +54,12 @@ describe('application navigation', () => {
}
});
it('gives Super Viewers all administrator destinations and shortcuts', () => {
expect(visibleNavigation('super_viewer')).toEqual(visibleNavigation('admin'));
expect(navigationLocation('/settings/users', 'super_viewer')?.group.id).toBe('admin');
expect(navigationLocation('/server-keys', 'super_viewer')?.item.shortcut).toBe('k');
});
it('groups file releases together and retains Campaigns as its own destination', () => {
expect(navigationGroups.map((group) => group.label)).toEqual([
'Fleet',
+4 -3
View File
@@ -377,7 +377,7 @@ it('never removes the calibration window when choosing a shorter resource retent
n: 0
});
});
it('previews draft retention without saving it and restricts retention tools to administrators', async () => {
it('previews draft retention without saving it and distinguishes admin reads from writes', async () => {
const a = await api();
a.db
.prepare(
@@ -401,8 +401,9 @@ it('previews draft retention without saving it and restricts retention tools to
expect(a.fleetSettings().deployKeepDays).toBe(0);
expect(a.db.prepare('SELECT id FROM deployments').get()).toEqual({ id: 'preview' });
const { routePermission } = await import('../../src/lib/server/auth/access.js');
expect(routePermission('/api/settings/retention', 'GET')).toBe('admin');
expect(routePermission('/api/settings/retention/preview', 'POST')).toBe('admin');
expect(routePermission('/api/settings/retention', 'GET')).toBe('admin-read');
expect(routePermission('/api/settings/retention', 'POST')).toBe('admin');
expect(routePermission('/api/settings/retention/preview', 'POST')).toBe('admin-read');
});
it('protects cancelled workflows and linked file history through ambiguous and unreconciled receipts', async () => {
const a = await api();
+87
View File
@@ -0,0 +1,87 @@
import { describe, expect, it, vi } from 'vitest';
import { render } from 'svelte/server';
import { writable } from 'svelte/store';
import UsersPage from '../../src/routes/settings/users/+page.svelte';
import ServerKeysPage from '../../src/routes/server-keys/+page.svelte';
import MaintenancePage from '../../src/routes/maintenance/+page.svelte';
import AccountPage from '../../src/routes/account/+page.svelte';
import type { UserRole } from '../../src/lib/auth.js';
const currentPage = writable({
url: new URL('http://localhost/settings/users'),
data: { user: { role: 'admin' as UserRole } }
});
vi.mock('$app/stores', () => ({
page: {
subscribe: (run: Parameters<typeof currentPage.subscribe>[0]) => currentPage.subscribe(run)
}
}));
vi.mock('$app/navigation', () => ({ goto: vi.fn(), invalidateAll: vi.fn() }));
function asRole(role: UserRole) {
currentPage.update((value) => ({ ...value, data: { user: { role } } }));
}
describe('administrator pages', () => {
it('shows the Super Viewer label while preserving personal password and sign-out controls', () => {
asRole('super_viewer');
const body = render(AccountPage).body;
expect(body).toContain('Super Viewer');
expect(body).not.toContain('super_viewer');
expect(body).toContain('Change password');
expect(body).toContain('Sign out');
});
it('lets Super Viewers inspect account roles and activity without account mutation controls', () => {
asRole('super_viewer');
const body = render(UsersPage).body;
expect(body).toContain('User');
expect(body).toContain('Super Viewer');
expect(body).toContain('Account activity');
expect(body).toContain('Refresh activity');
expect(body).not.toContain('Add user');
expect(body).not.toContain('Edit access');
expect(body).not.toContain('Sign out sessions');
expect(body).not.toContain('You do not have access');
});
it.each(['viewer', 'operator'] as const)('keeps user accounts unavailable to %s', (role) => {
asRole(role);
const body = render(UsersPage).body;
expect(body).toContain('You do not have access');
expect(body).not.toContain('Account activity');
});
it('retains administrator user creation and describes the assignable Super Viewer role', () => {
asRole('admin');
const body = render(UsersPage).body;
expect(body).toContain('Add user');
expect(body).toContain('Super Viewer');
expect(body).toContain('Read all fleet and administrator pages without making changes.');
});
it('keeps key searches available while hiding key creation from Super Viewers', () => {
asRole('super_viewer');
const body = render(ServerKeysPage).body;
expect(body).toContain('Saved keys');
expect(body).toContain('Search keys');
expect(body).toContain('Filter keys by game');
expect(body).not.toContain('Add key');
expect(body).not.toContain('Save key');
asRole('admin');
expect(render(ServerKeysPage).body).toContain('Add key');
});
it('keeps maintenance views available without offering a fleet scan to Super Viewers', () => {
asRole('super_viewer');
const body = render(MaintenancePage).body;
expect(body).toContain('Maintenance views');
expect(body).toContain('Policies &amp; schedules');
expect(body).toContain('Activity');
expect(body).not.toContain('Check now');
asRole('admin');
expect(render(MaintenancePage).body).toContain('Check now');
});
});
+63 -2
View File
@@ -1,11 +1,27 @@
import { expect, it, vi } from 'vitest';
import { beforeEach, expect, it, vi } from 'vitest';
import { render } from 'svelte/server';
import { writable } from 'svelte/store';
import { defaultPlan, requirement, type BetaTestView } from '../../src/lib/beta-plan.js';
import { betaStatus, type BetaDeploymentServer } from '../../src/lib/ui/betas.js';
import BetaTestProgress from '../../src/lib/ui/components/BetaTestProgress.svelte';
import BetaList from '../../src/routes/betas/+page.svelte';
import NewBeta from '../../src/routes/betas/new/+page.svelte';
import type { UserRole } from '../../src/lib/auth.js';
vi.mock('$app/stores', () => ({ page: writable({ url: new URL('http://localhost/betas/75') }) }));
const currentPage = writable({
url: new URL('http://localhost/betas/75'),
data: { user: { role: 'admin' as UserRole } }
});
vi.mock('$app/stores', () => ({
page: {
subscribe: (run: Parameters<typeof currentPage.subscribe>[0]) => currentPage.subscribe(run)
}
}));
vi.mock('$app/navigation', () => ({ goto: vi.fn(), beforeNavigate: vi.fn() }));
function asRole(role: UserRole) {
currentPage.update((value) => ({ ...value, data: { user: { role } } }));
}
beforeEach(() => asRole('admin'));
it('prioritizes lifecycle, failures and pauses over readiness without treating stage text as failure', () => {
expect(betaStatus({ state: 'promoted', testOutcome: 'paused', servers: [] })).toMatchObject({
@@ -153,3 +169,48 @@ it('shows missing historical evidence as not recorded, without claiming zero ser
expect(body).not.toContain('Awaiting test run');
expect(body).not.toContain('0 / 0');
});
it.each(['super_viewer', 'viewer'] as const)(
'keeps beta evidence available to %s without pause, retry or sign-off controls',
(role) => {
asRole(role);
const test: BetaTestView = {
plan: { ...defaultPlan(), requirements: [requirement('manual', 'health')] },
outcome: 'active',
reason: null,
servers: [testRun('manual', false)],
evidence: []
};
for (const outcome of ['active', 'paused', 'failed'] as const) {
const body = render(BetaTestProgress, {
props: {
test: { ...test, outcome },
betaId: 75,
closed: false,
onUpdate: async () => {}
}
}).body;
expect(body).toContain('Test results');
expect(body).toContain('Test policy');
expect(body).toContain('View relevant evidence');
expect(body).not.toContain('Tester name and observation');
expect(body).not.toContain('Sign off</button>');
expect(body).not.toContain('Pause testing');
expect(body).not.toContain('Resume testing');
expect(body).not.toContain('Start fresh attempt');
}
}
);
it('hides new-beta entry points from Super Viewers and explains directly opened creation pages', () => {
asRole('super_viewer');
expect(render(BetaList).body).not.toContain('href="/betas/new"');
const creation = render(NewBeta).body;
expect(creation).toContain('Beta creation requires operator access');
expect(creation).toContain('View betas');
expect(creation).not.toContain('<form');
asRole('operator');
expect(render(BetaList).body).toContain('href="/betas/new"');
expect(render(NewBeta).body).not.toContain('Beta creation requires operator access');
});
+10
View File
@@ -26,3 +26,13 @@ it('keeps the navigation palette useful for viewers without offering write actio
expect(body).not.toContain('Create server');
expect(body).not.toContain('Add host');
});
it('includes administration pages for Super Viewers without write actions', () => {
const body = render(CommandPalette, { props: { open: true, role: 'super_viewer' } }).body;
expect(body).toContain('Settings');
expect(body).toContain('Users');
expect(body).toContain('Keys');
expect(body).not.toContain('Create server');
expect(body).not.toContain('Manage servers');
expect(body).not.toContain('Add host');
});
+10
View File
@@ -99,6 +99,16 @@ const shell = (overrides: Partial<AuthUser> = {}) =>
}).body;
describe('application shell', () => {
it('explains Super Viewer access while exposing administrator navigation and shortcuts', () => {
const body = shell({ role: 'super_viewer' });
expect(body).toContain('Super Viewer access');
expect(body).toContain('href="/settings/users"');
expect(body).toContain('href="/server-keys"');
expect(body).toContain('href="/settings"');
expect(body).toContain('g h');
expect(body).toContain('g k');
});
it('offers collapsible desktop navigation, a labelled mobile drawer, search and help', () => {
const body = shell();
expect(body).toContain('id="desktop-navigation"');
+34
View File
@@ -1,6 +1,7 @@
import { describe, expect, it, vi } from 'vitest';
import { render } from 'svelte/server';
import { writable } from 'svelte/store';
import type { AuthUser } from '../../src/lib/auth.js';
import type { ProvisioningDetail, RecoveryObservation } from '../../src/lib/provisioning-detail.js';
import { provisioningNeedsRefresh } from '../../src/lib/ui/provisioning-presentation.js';
import ProvisioningPage from '../../src/routes/provisioning/[id]/+page.svelte';
@@ -79,6 +80,39 @@ function pageBody(detail: ProvisioningDetail) {
props: { data: { detail, user: null, setupRequired: false } }
}).body;
}
it('lets Super Viewers inspect actionable operations without mutation controls', () => {
const detail = fixture('failed');
Object.assign(detail, {
canResumeVerification: true,
canResumeCleanup: true,
canResumeRebuild: true,
canRetryWithRebuild: true,
canCancel: true,
canCancelCreation: true,
canOverrideSafety: true
});
const bodyFor = (role: AuthUser['role']) =>
render(ProvisioningPage, {
props: { data: { detail, user: { role } as AuthUser, setupRequired: false } }
}).body;
const readOnly = bodyFor('super_viewer');
const admin = bodyFor('admin');
expect(readOnly).toContain('View server');
expect(readOnly).toContain('operation-a');
for (const action of [
'Resume health verification',
'Resume rebuild cleanup',
'Resume cleanup',
'Retry with rebuild',
'Cancel operation',
'Cancel creation and remove server',
'Admin override…'
]) {
expect(readOnly).not.toContain(action);
expect(admin).toContain(action);
}
});
function recovery(state: RecoveryObservation['state']): RecoveryObservation {
return {
state,
+84
View File
@@ -0,0 +1,84 @@
import { afterEach, expect, it, vi } from 'vitest';
import { render } from 'svelte/server';
import { writable } from 'svelte/store';
import type { UserRole } from '../../src/lib/auth.js';
import Scripts from '../../src/routes/scripts/+page.svelte';
import Plugins from '../../src/routes/plugins/+page.svelte';
import ConfigLibrary from '../../src/lib/ui/components/ConfigLibrary.svelte';
import ConfigEditor from '../../src/lib/ui/components/ConfigEditor.svelte';
import ConfigTextEditor from '../../src/lib/ui/components/ConfigTextEditor.svelte';
const currentPage = writable({
url: new URL('http://localhost/scripts'),
data: { user: { role: 'super_viewer' as UserRole } }
});
vi.mock('$app/stores', () => ({
page: {
subscribe: (run: Parameters<typeof currentPage.subscribe>[0]) => currentPage.subscribe(run)
}
}));
vi.mock('$app/navigation', () => ({
goto: vi.fn(),
beforeNavigate: vi.fn(),
afterNavigate: vi.fn()
}));
afterEach(() =>
currentPage.update((value) => ({
...value,
data: { user: { role: 'super_viewer' } }
}))
);
it.each(['super_viewer', 'viewer'] as const)(
'keeps release libraries browsable without upload or creation controls for %s',
(role) => {
currentPage.update((value) => ({ ...value, data: { user: { role } } }));
const scripts = render(Scripts).body;
expect(scripts).toContain('Search scripts');
expect(scripts).not.toContain('Upload scripts');
const plugins = render(Plugins).body;
expect(plugins).toContain('Filter plugins by game');
expect(plugins).not.toContain('Add plugin');
const configs = render(ConfigLibrary).body;
expect(configs).toContain('Inspect a server');
expect(configs).toContain('Refresh');
expect(configs).not.toContain('New config');
}
);
it.each(['operator', 'admin'] as const)('preserves release creation controls for %s', (role) => {
currentPage.update((value) => ({ ...value, data: { user: { role } } }));
expect(render(Scripts).body).toContain('Upload scripts');
expect(render(Plugins).body).toContain('Add plugin');
expect(render(ConfigLibrary).body).toContain('New config');
});
it('shows a configuration viewer and version selection without importing or restoring edits', () => {
const body = render(ConfigEditor, {
props: {
id: 1,
kind: 'config',
versions: [{ id: 1, version: 1, notes: null }],
currentVersionId: 1,
runningBetaId: null,
scopeType: 'game',
scopeValue: 't5',
onsaved: vi.fn()
}
}).body;
expect(body).toContain('Configuration viewer');
expect(body).toContain('Starting version');
expect(body).not.toContain('Import .cfg');
expect(body).not.toContain('Save new version');
});
it('keeps readonly configuration text selectable and its view switch enabled', () => {
const body = render(ConfigTextEditor, {
props: { text: 'set g_inactivity "0"', readonly: true }
}).body;
expect(body).toMatch(/<textarea[^>]*readonly/);
expect(body).not.toMatch(/<textarea[^>]*disabled/);
expect(body).not.toMatch(/<button[^>]*disabled/);
expect(body).toContain('Raw .cfg');
expect(body).toContain('Settings');
});
+10
View File
@@ -152,6 +152,16 @@ it('keeps connection summaries visible to viewers without raw diagnostics or pro
expect(body).not.toContain('legacy private reply');
});
it('keeps Super Viewer diagnostics readable without offering probes or lifecycle actions', () => {
const body = pageBody('diagnostics', 'super_viewer');
expect(body).toContain('Connection details');
expect(body).toContain('Inspect requests');
expect(body).not.toContain('Test connection');
expect(body).not.toContain('More actions');
expect(body).not.toContain('legacy private reply');
expect(pageBody('configuration', 'super_viewer')).toContain('Promoted configuration');
});
it('uses the shared Activity view for server history', () => {
const body = pageBody('history');
expect(body).toContain('Open in Activity');
+19
View File
@@ -44,4 +44,23 @@ describe('grouped Settings editor', () => {
expect(body).toContain('Expired data cannot be restored by increasing retention.');
expect(body).toContain('Player safety checks still apply.');
});
it('keeps settings navigation and cleanup previews available without offering mutations in read-only mode', () => {
const body = render(SettingsEditor, {
props: {
initial: { ...defaultFleetSettings, preparationConcurrency: 7 },
readOnly: true
}
}).body.replaceAll(/<!--.*?-->/g, '');
expect(body).toContain('aria-label="Settings sections"');
expect(body.match(/<input[^>]*id="settings-search"[^>]*>/)?.[0]).not.toContain('disabled');
expect(body).toMatch(/<fieldset[^>]*disabled[^>]*><legend[^>]*>Fleet settings/);
expect(body).toMatch(/<input[^>]*id="setting-preparationConcurrency"[^>]*value="7"/);
expect(body).toContain('Refresh cleanup preview');
expect(body).not.toContain('Save changes');
expect(body).not.toContain('Discard');
expect(body).not.toContain('Protect record');
expect(body).not.toContain('Mark failure resolved');
expect(body).not.toContain('All changes saved');
});
});