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 ## Roles
| Capability | Viewer | Operator | Administrator | | Capability | Viewer | Super Viewer | Operator | Administrator |
| --------------------------------------------------------------------------------------------- | -------------- | ----------- | ------------- | | --------------------------------------------------------------------------------------------- | -------------- | ------------ | ----------- | ------------- |
| Fleet, metrics, matches and operation summaries | Yes | Yes | Yes | | Fleet, metrics, matches and operation summaries | Yes | Yes | Yes | Yes |
| Raw configs, file diffs, logs, RCON and telemetry diagnostics | No | Yes | Yes | | Raw configs, file diffs, logs, saved RCON and telemetry diagnostics | No | Read | Read | Read |
| Deploy/start/stop/restart/rebuild/remove servers | No | Yes | Yes | | Deploy/start/stop/restart/rebuild/remove servers and run diagnostic probes | No | No | Yes | Yes |
| Scripts, plugins, configs, campaigns, betas and collections | Read summaries | Manage | Manage | | 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 | 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 | No | Manage | | Server keys and global settings/maintenance policies | No | Read | No | Manage |
| Create users, change roles, disable accounts and reset passwords | No | No | Yes | | Users and account audit activity | No | Read | No | Manage |
| Own password and sign out | Yes | Yes | Yes | | 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 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 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 Drift previews (`/api/drift`) and GitHub release lookups
(`/api/plugins/github/release`) require operator or administrator access, including (`/api/plugins/github/release`) require operator or administrator access, including
`GET` and `HEAD` requests. These endpoints initiate fleet file scans or use GitHub `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 server's Diagnostics tab. Player addresses are redacted before diagnostics are
saved; successful checks clear the saved reply. Fleet snapshots and live status saved; successful checks clear the saved reply. Fleet snapshots and live status
streams contain no reply snippets. Upgrades discard older saved RCON snippets. 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 Login, initial setup and password verification are rate-limited with persistent
15-minute windows. The database stores audit entries for login results, account 15-minute windows. The database stores audit entries for login results, account
changes and successful management requests without recording passwords, tokens 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 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 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. */ /** Reserved identity for work requested by the controller. */
export const systemUsername = 'Maxis'; export const systemUsername = 'Maxis';
@@ -17,6 +17,19 @@ export interface AuthUser {
export const roleLabels: Record<UserRole, string> = { export const roleLabels: Record<UserRole, string> = {
admin: 'Administrator', admin: 'Administrator',
super_viewer: 'Super Viewer',
operator: 'Operator', operator: 'Operator',
viewer: 'Viewer' 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 { export interface NavigationItem {
label: string; label: string;
@@ -99,7 +99,7 @@ export const navigationGroups: NavigationGroup[] = [
]; ];
export function visibleNavigation(role: UserRole): 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 { export function navigationItemActive(item: NavigationItem, path: string): boolean {
+18 -5
View File
@@ -1,7 +1,14 @@
import type { UserRole } from '../../auth.js'; import type { UserRole } from '../../auth.js';
export type RoutePermission = 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 => const within = (route: string, base: string): boolean =>
route === base || route.startsWith(base + '/'); route === base || route.startsWith(base + '/');
@@ -41,16 +48,19 @@ export function routePermission(routeId: string | null, method: string): RoutePe
) )
return 'authenticated'; 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 ( if (
within(routeId, '/api/users') || within(routeId, '/api/users') ||
within(routeId, '/settings') || within(routeId, '/settings') ||
within(routeId, '/api/settings') || within(routeId, '/api/settings') ||
within(routeId, '/api/auth') ||
within(routeId, '/api/server-keys') || within(routeId, '/api/server-keys') ||
within(routeId, '/server-keys') || within(routeId, '/server-keys') ||
within(routeId, '/keys') within(routeId, '/keys')
) )
return 'admin'; return reading ? 'admin-read' : 'admin';
if (!reading && ['/api/hosts', '/api/game-files'].some((base) => within(routeId, base))) if (!reading && ['/api/hosts', '/api/game-files'].some((base) => within(routeId, base)))
return 'admin'; 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]/telemetry-requests' ||
routeId === '/api/servers/[name]/rcon-diagnostics' routeId === '/api/servers/[name]/rcon-diagnostics'
) )
return 'operator'; return reading ? 'operator-read' : 'operator';
return reading ? 'viewer' : 'operator'; return reading ? 'viewer' : 'operator';
} }
/** A browser role never substitutes for a machine credential. */ /** A browser role never substitutes for a machine credential. */
export function roleAllows(role: UserRole, permission: RoutePermission): boolean { 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 === 'machine') return false;
if (permission === 'public' || permission === 'authenticated' || permission === 'viewer') if (permission === 'public' || permission === 'authenticated' || permission === 'viewer')
return true; return true;
if (permission === 'operator') return role === 'operator' || role === 'admin'; 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'; return permission === 'admin' && role === 'admin';
} }
+5 -1
View File
@@ -165,7 +165,11 @@ export const authenticationHandle: Handle = async ({ event, resolve }) => {
const message = const message =
permission === 'admin' permission === 'admin'
? 'Administrator access is required.' ? '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) if (api)
return json( return json(
{ error: message }, { error: message },
+2 -2
View File
@@ -81,8 +81,8 @@ function displayName(value: unknown, fallback: string): string {
return name.trim(); return name.trim();
} }
function role(value: unknown): UserRole { function role(value: unknown): UserRole {
if (!['admin', 'operator', 'viewer'].includes(value as string)) if (!['admin', 'super_viewer', 'operator', 'viewer'].includes(value as string))
throw new AuthError('Choose administrator, operator or viewer.'); throw new AuthError('Choose administrator, super viewer, operator or viewer.');
return value as UserRole; return value as UserRole;
} }
export function recordAudit( 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); 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'; } from '../reports/revisions.js';
import { enableIncrementalVacuum, retentionClocks } from './retention-clocks.js'; import { enableIncrementalVacuum, retentionClocks } from './retention-clocks.js';
import { migrateHostSchema } from '../hosts/schema.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 { migrateGameFileSchema } from '../game-files/schema.js';
import type { Migration } from './migration-runner.js'; import type { Migration } from './migration-runner.js';
@@ -91,6 +91,12 @@ export const migrations: readonly Migration[] = [
version: 16, version: 16,
name: 'metrics player history and report revisions', name: 'metrics player history and report revisions',
run: installMetricsPlayerRevisions 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 { visibleNavigation } from '../navigation.js';
import { playerProfileHref } from '../leaderboard.js'; import { playerProfileHref } from '../leaderboard.js';
import { serverTitle } from './server-labels.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[] { export function palettePageResults(role: UserRole, query: string): PaletteItem[] {
const actions: PaletteItem[] = const actions: PaletteItem[] = !canOperate(role)
role === 'viewer' ? []
? [] : [
: [ {
{ key: 'action:create',
key: 'action:create', group: 'Actions',
group: 'Actions', label: 'Create server',
label: 'Create server', detail: 'Choose a map and host',
detail: 'Choose a map and host', href: '/?action=create'
href: '/?action=create' },
}, {
{ key: 'action:manage',
key: 'action:manage', group: 'Actions',
group: 'Actions', label: 'Manage servers',
label: 'Manage servers', detail: 'Server queues and lifecycle actions',
detail: 'Server queues and lifecycle actions', href: '/provisioning'
href: '/provisioning' }
} ];
];
if (role === 'admin') if (role === 'admin')
actions.push({ actions.push({
key: 'action:add-host', key: 'action:add-host',
+184 -176
View File
@@ -2,6 +2,7 @@
import { serverTitle } from '$ui/server-labels.js'; import { serverTitle } from '$ui/server-labels.js';
import RelativeTime from '$ui/components/RelativeTime.svelte'; import RelativeTime from '$ui/components/RelativeTime.svelte';
import { page } from '$app/stores'; import { page } from '$app/stores';
import { canOperate } from '$lib/auth.js';
import { contextHref } from '$lib/navigation-context.js'; import { contextHref } from '$lib/navigation-context.js';
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import { pollVisible } from '$ui/poll.js'; import { pollVisible } from '$ui/poll.js';
@@ -33,6 +34,7 @@
family: Family; family: Family;
} }
let { id, family }: Props = $props(); let { id, family }: Props = $props();
const editable = $derived(canOperate($page.data?.user?.role));
const basePath = $derived(`/${family}s`); const basePath = $derived(`/${family}s`);
const crumbLabel = $derived(family.charAt(0).toUpperCase() + family.slice(1) + 's'); const crumbLabel = $derived(family.charAt(0).toUpperCase() + family.slice(1) + 's');
@@ -228,7 +230,7 @@
onMount(() => pollVisible(() => load({ seedForm: false }), 10000, false)); onMount(() => pollVisible(() => load({ seedForm: false }), 10000, false));
function openScopeModal(): void { function openScopeModal(): void {
if (runningBetaId !== null) return; if (!editable || runningBetaId !== null) return;
seedScopeForm(); seedScopeForm();
flash = null; flash = null;
scopeReviewOpen = true; scopeReviewOpen = true;
@@ -240,7 +242,7 @@
scopeDialog?.close(); scopeDialog?.close();
} }
function openUploadModal(): void { function openUploadModal(): void {
if (runningBetaId !== null) return; if (!editable || runningBetaId !== null) return;
releaseVersion = ''; releaseVersion = '';
versionFile = null; versionFile = null;
versionNotes = ''; versionNotes = '';
@@ -273,7 +275,7 @@
async function uploadVersion(e: Event): Promise<void> { async function uploadVersion(e: Event): Promise<void> {
e.preventDefault(); e.preventDefault();
if (!versionFile || uploadingVersion) return; if (!editable || !versionFile || uploadingVersion) return;
uploadingVersion = true; uploadingVersion = true;
flash = null; flash = null;
const form = new FormData(); const form = new FormData();
@@ -304,7 +306,7 @@
async function saveScope(e: Event): Promise<void> { async function saveScope(e: Event): Promise<void> {
e.preventDefault(); e.preventDefault();
if (updatingScope || !scopeImpact || scopePreviewLoading) return; if (!editable || updatingScope || !scopeImpact || scopePreviewLoading) return;
if (scopeType !== 'none' && !editedScopeValue) { if (scopeType !== 'none' && !editedScopeValue) {
flash = `Pick a ${scopeType}`; flash = `Pick a ${scopeType}`;
return; return;
@@ -351,6 +353,7 @@
} }
async function deleteArtifact(): Promise<void> { async function deleteArtifact(): Promise<void> {
if (!editable) return;
if ( if (
!(await confirmAction({ !(await confirmAction({
title: 'Delete ' + family + '?', title: 'Delete ' + family + '?',
@@ -428,13 +431,15 @@
variant="primary" variant="primary"
href={contextHref(`/betas/${runningBetaId}`, $page.url)} href={contextHref(`/betas/${runningBetaId}`, $page.url)}
>View active beta</Button >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" variant="primary"
href={contextHref( href={contextHref(
betaSelectionHref([{ scriptId: id, versionId: latestVersion.id }]), betaSelectionHref([{ scriptId: id, versionId: latestVersion.id }]),
$page.url $page.url
)}>Test latest version</Button )}>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 </div></Card
>{/if} >{/if}
@@ -450,24 +455,24 @@
<Card padding="lg"> <Card padding="lg">
<div class="panel-head"> <div class="panel-head">
<h2>Details</h2> <h2>Details</h2>
<div class="head-actions"> {#if editable}<div class="head-actions">
<Button <Button
variant="secondary" variant="secondary"
size="sm" size="sm"
onclick={openScopeModal} onclick={openScopeModal}
disabled={runningBetaId !== null} disabled={runningBetaId !== null}
> >
Edit scope Edit scope
</Button> </Button>
<Button <Button
variant="secondary" variant="secondary"
size="sm" size="sm"
onclick={openUploadModal} onclick={openUploadModal}
disabled={runningBetaId !== null} disabled={runningBetaId !== null}
> >
Upload new version Upload new version
</Button> </Button>
</div> </div>{/if}
</div> </div>
<details class="file-settings"> <details class="file-settings">
@@ -584,7 +589,7 @@
<Badge variant="online" size="sm">Promoted</Badge> <Badge variant="online" size="sm">Promoted</Badge>
{:else} {:else}
<StatusBadge state={v.state} size="sm" /> <StatusBadge state={v.state} size="sm" />
{#if runningBetaId === null}<Button {#if editable && runningBetaId === null}<Button
size="sm" size="sm"
href={contextHref( href={contextHref(
betaSelectionHref([{ scriptId: id, versionId: v.id }]), betaSelectionHref([{ scriptId: id, versionId: v.id }]),
@@ -604,7 +609,7 @@
<ListFooter total={versions.length} bind:limit label="versions" /> <ListFooter total={versions.length} bind:limit label="versions" />
</Card> </Card>
{#if family === 'plugin'} {#if editable && family === 'plugin'}
<div id="github-releases"> <div id="github-releases">
<PluginGithubImport <PluginGithubImport
pluginId={id} pluginId={id}
@@ -615,170 +620,173 @@
</div> </div>
{/if} {/if}
<Card padding="lg"> {#if editable}<Card padding="lg">
<div class="panel-head"><h2>Danger zone</h2></div> <div class="panel-head"><h2>Danger zone</h2></div>
<Button variant="danger" onclick={deleteArtifact}>Delete {family}</Button> <Button variant="danger" onclick={deleteArtifact}>Delete {family}</Button>
</Card> </Card>{/if}
{#if flash}<div class="alert" role="alert">{flash}</div>{/if} {#if flash}<div class="alert" role="alert">{flash}</div>{/if}
<!-- Scope dialog --> <!-- Scope dialog -->
<dialog {#if editable}<dialog
aria-label="Edit scope" aria-label="Edit scope"
onclose={() => (scopeReviewOpen = false)} onclose={() => (scopeReviewOpen = false)}
oncancel={(event) => { oncancel={(event) => {
if (updatingScope) event.preventDefault(); if (updatingScope) event.preventDefault();
}} }}
bind:this={scopeDialog} bind:this={scopeDialog}
onclick={(e) => onDialogClick(scopeDialog, e)} onclick={(e) => onDialogClick(scopeDialog, e)}
> >
<form class="dlg" onsubmit={saveScope}> <form class="dlg" onsubmit={saveScope}>
<header class="dlg-head"> <header class="dlg-head">
<h3>Edit scope</h3> <h3>Edit scope</h3>
<button <button
type="button" type="button"
class="close" 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}
disabled={updatingScope} disabled={updatingScope}
/> onclick={cancelScopeModal}
{#if scopeType === 'game' || scopeType === 'map'} aria-label="Close">×</button
>
</header>
<Field label="Scope">
<div class="scope-row">
<Select <Select
ariaLabel="Scope game" ariaLabel="Scope type"
bind:value={scopeGame} bind:value={scopeType}
onchange={() => (scopeMap = '')} options={scopeTypeOptions}
options={gameOptions}
disabled={updatingScope} disabled={updatingScope}
/> />
{/if} {#if scopeType === 'game' || scopeType === 'map'}
{#if scopeType === 'map'} <Select
<Select ariaLabel="Scope game"
ariaLabel="Scope map" bind:value={scopeGame}
bind:value={scopeMap} onchange={() => (scopeMap = '')}
options={mapOptions} options={gameOptions}
disabled={updatingScope || !scopeGame} disabled={updatingScope}
/> />
{/if} {/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> </div>
</Field> {#if flash}<div class="alert alert-danger" role="alert">{flash}</div>{/if}
{#if scopePreviewLoading}<p role="status"> </form>
Reviewing eligible servers… </dialog>
</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>
<!-- Upload dialog --> <!-- Upload dialog -->
<dialog <dialog
aria-label="Upload new version" aria-label="Upload new version"
oncancel={(event) => { oncancel={(event) => {
if (uploadingVersion) event.preventDefault(); if (uploadingVersion) event.preventDefault();
}} }}
bind:this={uploadDialog} bind:this={uploadDialog}
onclick={(e) => onDialogClick(uploadDialog, e)} onclick={(e) => onDialogClick(uploadDialog, e)}
> >
<form class="dlg" onsubmit={uploadVersion}> <form class="dlg" onsubmit={uploadVersion}>
<header class="dlg-head"> <header class="dlg-head">
<h3>Upload new version</h3> <h3>Upload new version</h3>
<button <button
type="button" type="button"
class="close" class="close"
disabled={uploadingVersion} disabled={uploadingVersion}
onclick={cancelUploadModal} onclick={cancelUploadModal}
aria-label="Close">×</button aria-label="Close">×</button
> >
</header> </header>
<Field label="File"> <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."
>
<Input <Input
bind:value={releaseVersion} type="file"
placeholder="v3.1.0" accept={family === 'plugin' ? '.dll' : undefined}
onchange={pickVersionFile}
disabled={uploadingVersion} disabled={uploadingVersion}
/> />
</Field> </Field>
{/if} {#if family === 'plugin'}
<Field label="Notes" hint="Optional. What changed since the previous version."> <Field
<Input label="Release version"
bind:value={versionNotes} hint="Optional. Use the publisher’s version or release tag, such as v3.1.0."
placeholder="what changed" >
disabled={uploadingVersion} <Input
/> bind:value={releaseVersion}
</Field> placeholder="v3.1.0"
<div class="dlg-actions"> disabled={uploadingVersion}
<Button variant="ghost" onclick={cancelUploadModal} disabled={uploadingVersion} />
>Cancel</Button </Field>
> {/if}
<Button <Field label="Notes" hint="Optional. What changed since the previous version.">
type="submit" <Input
variant="primary" bind:value={versionNotes}
disabled={uploadingVersion || !versionFile} placeholder="what changed"
loading={uploadingVersion} disabled={uploadingVersion}
> />
Upload version </Field>
</Button> <div class="dlg-actions">
</div> <Button
{#if flash}<div class="alert alert-danger" role="alert">{flash}</div>{/if} variant="ghost"
</form> onclick={cancelUploadModal}
</dialog> 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> </div>
{/if} {/if}
@@ -7,6 +7,7 @@
import { timeFormat } from '$ui/stores/time-zone.js'; import { timeFormat } from '$ui/stores/time-zone.js';
import { tick } from 'svelte'; import { tick } from 'svelte';
import { page } from '$app/stores'; import { page } from '$app/stores';
import { canOperate } from '$lib/auth.js';
import { contextHref } from '$lib/navigation-context.js'; import { contextHref } from '$lib/navigation-context.js';
import type { BetaDeploymentServer } from '$ui/betas.js'; import type { BetaDeploymentServer } from '$ui/betas.js';
import Badge from './primitives/Badge.svelte'; import Badge from './primitives/Badge.svelte';
@@ -52,13 +53,14 @@
evidenceKind = $state(''), evidenceKind = $state(''),
evidenceQuery = $state(''); evidenceQuery = $state('');
let evidenceSection = $state<HTMLDetailsElement>(); let evidenceSection = $state<HTMLDetailsElement>();
const editable = $derived(canOperate($page.data?.user?.role));
const hasDeletedServers = $derived( const hasDeletedServers = $derived(
deletedParticipants || deletedParticipants ||
test.servers.some((server) => server.server_deleted_at) || test.servers.some((server) => server.server_deleted_at) ||
deployments?.some((server) => server.server_deleted_at) deployments?.some((server) => server.server_deleted_at)
); );
const canSign = $derived( const canSign = $derived(
!closed && !hasDeletedServers && ['active', 'passed'].includes(test.outcome) editable && !closed && !hasDeletedServers && ['active', 'passed'].includes(test.outcome)
); );
const expired = $derived( const expired = $derived(
test.outcome === 'inconclusive' || test.outcome === 'inconclusive' ||
@@ -139,6 +141,7 @@
evidenceLimit = 25; evidenceLimit = 25;
}); });
export async function action(action: string, extra: Record<string, string> = {}) { export async function action(action: string, extra: Record<string, string> = {}) {
if (!editable) return;
if ( if (
action === 'retry' && action === 'retry' &&
!(await confirmAction({ !(await confirmAction({
@@ -197,7 +200,7 @@
{#if !deployments}<StatusBadge state={test.outcome} />{/if} {#if !deployments}<StatusBadge state={test.outcome} />{/if}
</div> </div>
{#if !closed && !deployments} {#if !closed && !deployments}
{#if !hasDeletedServers}<div class="actions"> {#if editable && !hasDeletedServers}<div class="actions">
{#if test.outcome === 'paused' && !expired}<Button {#if test.outcome === 'paused' && !expired}<Button
size="sm" size="sm"
disabled={busy} disabled={busy}
@@ -502,7 +505,7 @@
value={Math.min(progress.current, progress.target)} value={Math.min(progress.current, progress.target)}
max={progress.target} max={progress.target}
></progress>{/if} ></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"> {#if canSign && server.observingAt}<div class="signoff">
<label <label
>Tester name and observation<textarea >Tester name and observation<textarea
+51 -44
View File
@@ -5,6 +5,8 @@
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import { confirmAction } from '$ui/confirm.js'; import { confirmAction } from '$ui/confirm.js';
import { beforeNavigate, goto } from '$app/navigation'; import { beforeNavigate, goto } from '$app/navigation';
import { page } from '$app/stores';
import { canOperate } from '$lib/auth.js';
import ConfigTextEditor from './ConfigTextEditor.svelte'; import ConfigTextEditor from './ConfigTextEditor.svelte';
import ConfigInspector from './ConfigInspector.svelte'; import ConfigInspector from './ConfigInspector.svelte';
import Button from './primitives/Button.svelte'; import Button from './primitives/Button.svelte';
@@ -33,6 +35,7 @@
scopeValue: string | null; scopeValue: string | null;
onsaved: () => Promise<void>; onsaved: () => Promise<void>;
} = $props(); } = $props();
const editable = $derived(canOperate($page.data?.user?.role));
let selected = $state(''); let selected = $state('');
let text = $state(''); let text = $state('');
let original = $state(''); let original = $state('');
@@ -45,7 +48,7 @@
let retry = $state(0); let retry = $state(0);
let loadedVersion = $state(''); let loadedVersion = $state('');
let recovered = $state<{ version: string; text: string; notes: string } | null>(null); 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( const ignoredWarnings = $derived(
configWarnings(text, kind === 'config').filter( configWarnings(text, kind === 'config').filter(
(warning) => warning.includes('ignored') || warning.includes('no value') (warning) => warning.includes('ignored') || warning.includes('no value')
@@ -55,6 +58,7 @@
onMount(() => { onMount(() => {
selected = String(versions[0]?.id ?? ''); selected = String(versions[0]?.id ?? '');
ready = true; ready = true;
if (!editable) return;
try { try {
const saved = JSON.parse(localStorage.getItem(storageKey) ?? 'null'); const saved = JSON.parse(localStorage.getItem(storageKey) ?? 'null');
if ( if (
@@ -155,7 +159,7 @@
})) }))
) )
return; return;
clearDraft(); if (editable) clearDraft();
selected = next; selected = next;
success = ''; success = '';
} }
@@ -194,7 +198,7 @@
input.value = ''; input.value = '';
} }
async function save() { async function save() {
if (saving || loading || runningBetaId !== null || !text.trim()) return; if (!editable || saving || loading || runningBetaId !== null || !text.trim()) return;
saving = true; saving = true;
error = ''; error = '';
success = ''; success = '';
@@ -223,12 +227,14 @@
</script> </script>
<svelte:window onbeforeunload={beforeUnload} /> <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="editor-panel">
<div class="heading"> <div class="heading">
<div> <div>
<span class="eyebrow">Edit · preview · version</span> <span class="eyebrow"
<h2>Configuration editor</h2> >{editable ? 'Edit · preview · version' : 'Read-only configuration'}</span
>
<h2>{editable ? 'Configuration editor' : 'Configuration viewer'}</h2>
</div> </div>
{#if dirty}<span class="draft-tag">Unsaved draft</span>{/if} {#if dirty}<span class="draft-tag">Unsaved draft</span>{/if}
</div> </div>
@@ -246,16 +252,16 @@
>{/each}</Select >{/each}</Select
></Field ></Field
> >
<label class="file-button" {#if editable}<label class="file-button"
>Import .cfg<input >Import .cfg<input
type="file" type="file"
accept=".cfg,text/plain" accept=".cfg,text/plain"
onchange={importFile} onchange={importFile}
disabled={saving || loading || runningBetaId !== null} disabled={saving || loading || runningBetaId !== null}
/></label /></label
> >{/if}
</div> </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 <span>A previous draft is available in this browser.</span><Button
size="sm" size="sm"
onclick={restoreDraft} onclick={restoreDraft}
@@ -275,41 +281,42 @@
<ConfigTextEditor <ConfigTextEditor
bind:text bind:text
overlay={kind === 'config'} overlay={kind === 'config'}
disabled={saving || runningBetaId !== null} readonly={!editable}
disabled={editable && (saving || runningBetaId !== null)}
/> />
<Field class="notes-label" label="Version notes"> {#if editable}<Field class="notes-label" label="Version notes">
<Input <Input
bind:value={notes} bind:value={notes}
placeholder="What changed, and why?" placeholder="What changed, and why?"
disabled={saving || runningBetaId !== null} disabled={saving || runningBetaId !== null}
/></Field /></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
> >
<Button variant="ghost" size="sm" onclick={discard} disabled={!dirty || saving} <div class="actions ui-sticky-actions">
>Discard</Button <span class="muted"
> >{runningBetaId !== null
<Button ? 'Finish the active beta to save another version.'
variant="primary" : 'A new version must pass through a beta before promotion.'}{#if ignoredWarnings.length}<strong
onclick={save} class="save-warning"
loading={saving} >{ignoredWarnings.length} ignored or incomplete lines — review the open
disabled={!dirty || !text.trim() || runningBetaId !== null} configuration notes before saving.</strong
>Save new version</Button >{/if}</span
> >
</div> <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} {/if}
</div> </div>
{#if loadedVersion && !loading}<ConfigInspector {#if loadedVersion && !loading}<ConfigInspector
artifactId={id} artifactId={id}
draftText={text} draftText={editable ? text : undefined}
{scopeType} {scopeType}
{scopeValue} {scopeValue}
/>{/if} />{/if}
+5 -3
View File
@@ -171,7 +171,7 @@
const url = URL.createObjectURL(new Blob([inspection.text], { type: 'text/plain' })); const url = URL.createObjectURL(new Blob([inspection.text], { type: 'text/plain' }));
const anchor = document.createElement('a'); const anchor = document.createElement('a');
anchor.href = url; anchor.href = url;
anchor.download = `${selected}${artifactId ? '-draft' : ''}.cfg`; anchor.download = `${selected}${draftText !== undefined ? '-draft' : ''}.cfg`;
anchor.click(); anchor.click();
setTimeout(() => URL.revokeObjectURL(url), 1000); setTimeout(() => URL.revokeObjectURL(url), 1000);
} }
@@ -181,7 +181,9 @@
<div class="heading"> <div class="heading">
<div> <div>
{#if !fixedServer}<span class="eyebrow" {#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} >{/if}
<h2>{artifactId ? 'On this server' : 'Promoted configuration'}</h2> <h2>{artifactId ? 'On this server' : 'Promoted configuration'}</h2>
</div> </div>
@@ -190,7 +192,7 @@
> >
</div> </div>
<p class="intro"> <p class="intro">
{artifactId {draftText !== undefined
? 'Your draft composed with the other promoted configs. Saving creates an untested version.' ? '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.'} : 'The intended file from promoted configs and the selected map. Live files and active betas may differ.'}
</p> </p>
+121 -113
View File
@@ -3,6 +3,7 @@
import Select from '$ui/components/primitives/Select.svelte'; import Select from '$ui/components/primitives/Select.svelte';
import Input from '$ui/components/primitives/Input.svelte'; import Input from '$ui/components/primitives/Input.svelte';
import { page } from '$app/stores'; import { page } from '$app/stores';
import { canOperate } from '$lib/auth.js';
import { contextHref } from '$lib/navigation-context.js'; import { contextHref } from '$lib/navigation-context.js';
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import Tabs from './primitives/Tabs.svelte'; import Tabs from './primitives/Tabs.svelte';
@@ -18,6 +19,7 @@
import ConfigInspector from './ConfigInspector.svelte'; import ConfigInspector from './ConfigInspector.svelte';
import { libraryLimit, saveLibraryContext } from '$ui/library-context.js'; import { libraryLimit, saveLibraryContext } from '$ui/library-context.js';
import { gameTitle } from '$ui/server-labels.js'; import { gameTitle } from '$ui/server-labels.js';
const editable = $derived(canOperate($page.data?.user?.role));
interface Version { interface Version {
id: number; id: number;
@@ -69,7 +71,7 @@
{ view: 'library', limit: 25 } { view: 'library', limit: 25 }
); );
}); });
let dialog: HTMLDialogElement; let dialog = $state<HTMLDialogElement>();
let kind = $state<'baseline' | 'config'>('config'); let kind = $state<'baseline' | 'config'>('config');
let name = $state(''); let name = $state('');
let scopeType = $state<'game' | 'map'>('game'); let scopeType = $state<'game' | 'map'>('game');
@@ -164,9 +166,10 @@
void load(); void load();
}); });
function openCreate(type: 'config' | 'baseline' = 'config') { function openCreate(type: 'config' | 'baseline' = 'config') {
if (!editable) return;
kind = type; kind = type;
createError = ''; createError = '';
dialog.showModal(); dialog?.showModal();
} }
function resetFilters() { function resetFilters() {
query = ''; query = '';
@@ -189,6 +192,7 @@
} }
async function create(event: SubmitEvent) { async function create(event: SubmitEvent) {
event.preventDefault(); event.preventDefault();
if (!editable) return;
if (saving || baselineExists || !name.trim() || !selectedScope || !text.trim()) return; if (saving || baselineExists || !name.trim() || !selectedScope || !text.trim()) return;
if (kind === 'config' && !/^\s*set\s+\S+\s+/m.test(text)) { if (kind === 'config' && !/^\s*set\s+\S+\s+/m.test(text)) {
createError = 'Add at least one set KEY VALUE line.'; createError = 'Add at least one set KEY VALUE line.';
@@ -207,7 +211,7 @@
const response = await fetch('/api/configs', { method: 'POST', body: form }); const response = await fetch('/api/configs', { method: 'POST', body: form });
const body = await response.json(); const body = await response.json();
if (!response.ok) throw new Error(body.error ?? 'Could not create this config.'); if (!response.ok) throw new Error(body.error ?? 'Could not create this config.');
dialog.close(); dialog?.close();
await goto(`/configs/${body.script.id}`); await goto(`/configs/${body.script.id}`);
} catch (err) { } catch (err) {
createError = (err as Error).message; createError = (err as Error).message;
@@ -219,7 +223,7 @@
<div class="page"> <div class="page">
<SectionHeader title="Configs" subtitle="Manage game baselines and scoped changes."> <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" variant="primary"
onclick={() => openCreate()}>New config</Button onclick={() => openCreate()}>New config</Button
>{/if}{/snippet} >{/if}{/snippet}
@@ -292,10 +296,10 @@
title="No configs yet" title="No configs yet"
description="Configs store game baselines and scoped changes. Create a baseline to start managing configuration versions." description="Configs store game baselines and scoped changes. Create a baseline to start managing configuration versions."
> >
{#snippet actions()}<Button {#snippet actions()}{#if editable}<Button
variant="primary" variant="primary"
onclick={() => openCreate('baseline')}>Create baseline</Button onclick={() => openCreate('baseline')}>Create baseline</Button
>{/snippet} >{/if}{/snippet}
</EmptyState> </EmptyState>
{:else if !filtered.length && !error}<EmptyState {:else if !filtered.length && !error}<EmptyState
title="No matching configs" title="No matching configs"
@@ -362,117 +366,121 @@
</div> </div>
{/snippet} {/snippet}
<dialog {#if editable}<dialog
bind:this={dialog} bind:this={dialog}
aria-labelledby="new-config-title" aria-labelledby="new-config-title"
oncancel={(event) => { oncancel={(event) => {
if (saving) event.preventDefault(); if (saving) event.preventDefault();
}} }}
> >
<form onsubmit={create}> <form onsubmit={create}>
<div class="dialog-heading"> <div class="dialog-heading">
<div> <div>
<span class="eyebrow">Create version 1</span> <span class="eyebrow">Create version 1</span>
<h2 id="new-config-title">New config</h2> <h2 id="new-config-title">New config</h2>
</div>
<Button
variant="ghost"
onclick={() => dialog?.close()}
disabled={saving}
ariaLabel="Close new config">✕</Button
>
</div> </div>
<Button <div class="kind-picker">
variant="ghost" <button
onclick={() => dialog.close()} type="button"
disabled={saving} class:active={kind === 'config'}
ariaLabel="Close new config">✕</Button 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> <ConfigTextEditor bind:text overlay={kind === 'config'} disabled={saving} />
<div class="kind-picker"> <Field class="notes" label="Notes">
<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 <Input
bind:value={name} bind:value={notes}
placeholder={kind === 'config' ? 'No AFK timeout' : 'Black Ops I baseline'} placeholder="What is this config for?"
required
disabled={saving} disabled={saving}
/></Field /></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"> {#if createError}<div class="error" role="alert">{createError}</div>{/if}
<Select aria-label="Apply to" bind:value={scopeType} disabled={saving} <div class="dialog-actions">
><option value="game">Whole game</option><option value="map">One map</option <p>Saved as untested. Test in a beta before promotion.</p>
></Select <Button variant="ghost" onclick={() => dialog?.close()} disabled={saving}
></Field >Cancel</Button
>{#if scopeType === 'map'}<Field label="Map"> ><Button
<Select type="submit"
aria-label="Map" variant="primary"
bind:value={scopeMap} loading={saving}
required disabled={baselineExists || !name.trim() || !selectedScope || !text.trim()}
disabled={saving || !scopeGame} >Create {kind === 'baseline' ? 'baseline' : 'overlay'}</Button
><option value="" disabled>Choose a map</option >
>{#each scopeOptions.mapsByGame[scopeGame] ?? [] as option}<option </div>
value={option}>{option}</option </form>
>{/each}</Select </dialog>{/if}
></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>
<style> <style>
.page { .page {
+41 -30
View File
@@ -11,14 +11,16 @@
let { let {
text = $bindable(''), text = $bindable(''),
overlay = false, overlay = false,
readonly = false,
disabled = 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 mode = $state<'raw' | 'settings'>('raw');
let search = $state(''); let search = $state('');
let newKey = $state(''); let newKey = $state('');
let newValue = $state(''); let newValue = $state('');
let addError = $state(''); let addError = $state('');
function addSetting() { function addSetting() {
if (readonly || disabled) return;
try { try {
text = addConfigSetting(text, newKey, newValue); text = addConfigSetting(text, newKey, newValue);
search = ''; search = '';
@@ -64,6 +66,7 @@
<textarea <textarea
aria-label="Config contents" aria-label="Config contents"
bind:value={text} bind:value={text}
{readonly}
{disabled} {disabled}
spellcheck="false" spellcheck="false"
rows="12" rows="12"
@@ -78,9 +81,10 @@
placeholder="Find a setting…" placeholder="Find a setting…"
/></Field /></Field
> >
<p class="hint"> {#if !readonly}<p class="hint">
Edit values and inline comments. Other commands and formatting stay in the raw file. Edit values and inline comments. Other commands and formatting stay in the raw
</p> file.
</p>{/if}
{#each visible as entry (entry.index)} {#each visible as entry (entry.index)}
<Field class="setting" <Field class="setting"
>{#snippet label()}<span >{#snippet label()}<span
@@ -89,6 +93,7 @@
<Input <Input
mono mono
value={entry.value} value={entry.value}
{readonly}
{disabled} {disabled}
aria-label={`${entry.key}, line ${entry.index + 1}`} aria-label={`${entry.key}, line ${entry.index + 1}`}
oninput={(e) => oninput={(e) =>
@@ -96,31 +101,36 @@
/> />
</Field> </Field>
{:else}<p class="hint"> {: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} </p>{/each}
<div class="add-setting"> {#if !readonly}<div class="add-setting">
<Field label="Setting name"> <Field label="Setting name">
<Input <Input
mono mono
bind:value={newKey} bind:value={newKey}
onkeydown={addOnEnter} onkeydown={addOnEnter}
placeholder="g_inactivity" placeholder="g_inactivity"
{disabled} {disabled}
/></Field /></Field
><Field label="Value"> ><Field label="Value">
<Input <Input
mono mono
bind:value={newValue} bind:value={newValue}
onkeydown={addOnEnter} onkeydown={addOnEnter}
placeholder="0" placeholder="0"
{disabled} {disabled}
/></Field /></Field
><Button ><Button
size="sm" size="sm"
onclick={addSetting} onclick={addSetting}
disabled={disabled || !newKey.trim() || !newValue.trim()}>Add setting</Button disabled={disabled || !newKey.trim() || !newValue.trim()}
> >Add setting</Button
</div> >
</div>{/if}
{#if addError}<p class="warnings" role="alert">{addError}</p>{/if} {#if addError}<p class="warnings" role="alert">{addError}</p>{/if}
</div> </div>
{/if} {/if}
@@ -131,8 +141,9 @@
open={warnings.some((w) => w.includes('ignored') || w.includes('no value'))} open={warnings.some((w) => w.includes('ignored') || w.includes('no value'))}
> >
<summary <summary
>{warnings.length} configuration {warnings.length === 1 ? 'note' : 'notes'} — review before >{warnings.length} configuration {warnings.length === 1 ? 'note' : 'notes'}{readonly
saving</summary ? ''
: ' — review before saving'}</summary
> >
<ul> <ul>
{#each warnings as warning}<li>{warning}</li>{/each} {#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 Select from '$ui/components/primitives/Select.svelte';
import Field from '$ui/components/primitives/Field.svelte'; import Field from '$ui/components/primitives/Field.svelte';
import type { FleetSettings } from '$lib/fleet-settings.js'; 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'; import Button from './primitives/Button.svelte';
let busy = $state(false); let busy = $state(false);
let error = $state(''); let error = $state('');
@@ -58,6 +58,7 @@
selectedCategory = category, selectedCategory = category,
id = recordId id = recordId
) { ) {
if (readOnly || busy) return;
busy = true; busy = true;
error = ''; error = '';
notice = ''; notice = '';
@@ -116,11 +117,12 @@
<details> <details>
<summary>{policy.label}: eligible IDs (up to 20)</summary> <summary>{policy.label}: eligible IDs (up to 20)</summary>
{#each policy.records as record}<div class="record"> {#each policy.records as record}<div class="record">
<code>{record.id}</code><Button <code>{record.id}</code>{#if !readOnly}<Button
disabled={busy} disabled={busy}
onclick={() => control({ protected: true }, policy.category, record.id)} onclick={() =>
>Protect</Button control({ protected: true }, policy.category, record.id)}
> >Protect</Button
>{/if}
</div>{/each} </div>{/each}
</details> </details>
{/each} {/each}
@@ -131,35 +133,35 @@
? ' · Resolved' ? ' · Resolved'
: ''}</span : ''}</span
> >
{#if record.protected}<Button {#if record.protected && !readOnly}<Button
disabled={busy} disabled={busy}
onclick={() => control({ protected: false }, record.category, record.id)} onclick={() => control({ protected: false }, record.category, record.id)}
>Remove protection</Button >Remove protection</Button
>{/if} >{/if}
</div>{:else}<p>No explicit record protections or resolutions.</p>{/each} </div>{:else}<p>No explicit record protections or resolutions.</p>{/each}
{/if} {/if}
<h3>Protect a record or resolve a failure</h3> {#if !readOnly}<h3>Protect a record or resolve a failure</h3>
<p> <p>
Use the ID from its detail URL or the preview. Resolving a failed campaign stops automatic Use the ID from its detail URL or the preview. Resolving a failed campaign stops
retry requests for that campaign; unfinished recovery still prevents cleanup. automatic retry requests for that campaign; unfinished recovery still prevents cleanup.
</p> </p>
<div class="record"> <div class="record">
<Field label="Category"> <Field label="Category">
<Select bind:value={category} <Select bind:value={category}
>{#each categories as value}<option {value}>{value}</option>{/each}</Select >{#each categories as value}<option {value}>{value}</option>{/each}</Select
></Field ></Field
> >
<Field label="Record ID"> <Field label="Record ID">
<Input bind:value={recordId} maxlength={200} /></Field <Input bind:value={recordId} maxlength={200} /></Field
> >
<Button disabled={busy || !recordId} onclick={() => control({ protected: true })} <Button disabled={busy || !recordId} onclick={() => control({ protected: true })}
>Protect record</Button >Protect record</Button
> >
{#if ['campaign', 'provisioning', 'operation'].includes(category)}<Button {#if ['campaign', 'provisioning', 'operation'].includes(category)}<Button
disabled={busy || !recordId} disabled={busy || !recordId}
onclick={() => control({ resolve: true })}>Mark failure resolved</Button onclick={() => control({ resolve: true })}>Mark failure resolved</Button
>{/if} >{/if}
</div> </div>{/if}
</details> </details>
<style> <style>
+39 -38
View File
@@ -22,7 +22,7 @@
type Key = keyof FleetSettings; type Key = keyof FleetSettings;
type Draft = { [K in Key]: number | undefined }; 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 id = $props.id();
const keys = Object.keys(defaultFleetSettings) as Key[]; const keys = Object.keys(defaultFleetSettings) as Key[];
let baseline = $state<FleetSettings>({ ...untrack(() => initial) }); let baseline = $state<FleetSettings>({ ...untrack(() => initial) });
@@ -69,7 +69,7 @@
return ''; return '';
} }
function toggleMode(key: Key, enabled: boolean) { function toggleMode(key: Key, enabled: boolean) {
if (saving) return; if (readOnly || saving) return;
const current = draft[key]; const current = draft[key];
if (enabled) { if (enabled) {
if (current !== undefined && Number.isSafeInteger(current) && current > 0) if (current !== undefined && Number.isSafeInteger(current) && current > 0)
@@ -88,7 +88,7 @@
reviewInvalid = false; reviewInvalid = false;
} }
function discard() { function discard() {
if (saving) return; if (readOnly || saving) return;
draft = { ...baseline }; draft = { ...baseline };
remembered = Object.fromEntries( remembered = Object.fromEntries(
keys.filter((key) => baseline[key] > 0).map((key) => [key, baseline[key]]) keys.filter((key) => baseline[key] > 0).map((key) => [key, baseline[key]])
@@ -98,7 +98,7 @@
reviewInvalid = false; reviewInvalid = false;
} }
async function save() { async function save() {
if (saving || !changed.length || invalid.length) return; if (readOnly || saving || !changed.length || invalid.length) return;
saving = true; saving = true;
error = ''; error = '';
notice = ''; notice = '';
@@ -124,6 +124,7 @@
beforeNavigate(({ cancel, to, from, type }) => { beforeNavigate(({ cancel, to, from, type }) => {
if ( if (
readOnly ||
!changed.length || !changed.length ||
allowNavigation || allowNavigation ||
type === 'leave' || type === 'leave' ||
@@ -148,7 +149,7 @@
}); });
}); });
function beforeUnload(event: BeforeUnloadEvent) { function beforeUnload(event: BeforeUnloadEvent) {
if (changed.length) { if (!readOnly && changed.length) {
event.preventDefault(); event.preventDefault();
event.returnValue = ''; event.returnValue = '';
} }
@@ -227,7 +228,7 @@
void save(); void save();
}} }}
> >
<fieldset disabled={saving}> <fieldset disabled={readOnly || saving}>
<legend class="sr-only">Fleet settings</legend> <legend class="sr-only">Fleet settings</legend>
{#each visibleGroups as group (group.id)} {#each visibleGroups as group (group.id)}
<section <section
@@ -352,42 +353,42 @@
<fieldset disabled={saving || invalid.length > 0}> <fieldset disabled={saving || invalid.length > 0}>
<legend class="sr-only">Retention preview and protections</legend> <legend class="sr-only">Retention preview and protections</legend>
{#if invalid.length}<p>Correct invalid values to preview cleanup.</p>{/if} {#if invalid.length}<p>Correct invalid values to preview cleanup.</p>{/if}
<RetentionTools settings={draft as FleetSettings} /> <RetentionTools settings={draft as FleetSettings} {readOnly} />
</fieldset> </fieldset>
</div> </div>
</div> </div>
</div> </div>
<div class="save-bar"> {#if !readOnly}<div class="save-bar">
<div class="save-summary"> <div class="save-summary">
<strong class:has-changes={changed.length > 0} <strong class:has-changes={changed.length > 0}
>{changed.length >{changed.length
? `${changed.length} unsaved ${changed.length === 1 ? 'change' : 'changes'}` ? `${changed.length} unsaved ${changed.length === 1 ? 'change' : 'changes'}`
: 'All changes saved'}</strong : 'All changes saved'}</strong
> >
<span>Applies to everyone. No restart needed.</span> <span>Applies to everyone. No restart needed.</span>
{#if invalid.length}<button {#if invalid.length}<button
type="button" type="button"
class="invalid-summary text-button" class="invalid-summary text-button"
onclick={() => { onclick={() => {
query = ''; query = '';
reviewInvalid = true; reviewInvalid = true;
}} }}
>{invalid.length} invalid {invalid.length === 1 ? 'setting' : 'settings'} · Review invalid >{invalid.length} invalid {invalid.length === 1 ? 'setting' : 'settings'} · Review
settings</button invalid settings</button
>{/if} >{/if}
</div> </div>
<div class="save-actions"> <div class="save-actions">
<Button variant="ghost" disabled={saving || !changed.length} onclick={discard} <Button variant="ghost" disabled={saving || !changed.length} onclick={discard}
>Discard</Button >Discard</Button
><button ><button
class="save-button" class="save-button"
type="submit" type="submit"
form={`${id}-form`} form={`${id}-form`}
disabled={saving || !changed.length || invalid.length > 0} disabled={saving || !changed.length || invalid.length > 0}
>{saving ? 'Saving…' : 'Save changes'}</button >{saving ? 'Saving…' : 'Save changes'}</button
> >
</div> </div>
</div> </div>{/if}
<style> <style>
.settings-layout { .settings-layout {
+13 -4
View File
@@ -15,7 +15,7 @@
import { resolveConfirmation } from '$ui/confirm.js'; import { resolveConfirmation } from '$ui/confirm.js';
import '$ui/styles/components.css'; import '$ui/styles/components.css';
import { enableViewTransitions } from '$ui/motion/viewTransition.js'; 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 // Side-effect import — puts @fontsource CSS on Vite's module graph so
// the referenced woff/woff2 files get emitted into the build. // the referenced woff/woff2 files get emitted into the build.
import '$ui/styles/fonts.js'; import '$ui/styles/fonts.js';
@@ -172,7 +172,6 @@
armTimer = null; armTimer = null;
} }
const dest = shortcuts[e.key]; const dest = shortcuts[e.key];
if ((e.key === 'h' || e.key === 'k') && data.user?.role !== 'admin') return;
if (dest) { if (dest) {
e.preventDefault(); e.preventDefault();
goto(dest); goto(dest);
@@ -299,8 +298,11 @@
>Keyboard shortcuts <span aria-hidden="true">?</span >Keyboard shortcuts <span aria-hidden="true">?</span
></button ></button
>{/if} >{/if}
{#if data.user.role === 'admin' && sessionReady}<a {#if canReadAdmin(data.user.role) && sessionReady}<a
href="/settings/users">Manage users</a href="/settings/users"
>{data.user.role === 'admin'
? 'Manage users'
: 'View users'}</a
>{/if} >{/if}
<button disabled={signingOut} onclick={signOut} <button disabled={signingOut} onclick={signOut}
>{signingOut ? 'Signing out…' : 'Sign out'}</button >{signingOut ? 'Signing out…' : 'Sign out'}</button
@@ -319,6 +321,13 @@
>You can read fleet status and summaries. Operating servers or viewing raw >You can read fleet status and summaries. Operating servers or viewing raw
configs, file changes, logs, and telemetry requires an operator account.</span 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} </aside>{/if}
{@render children()} {@render children()}
</main> </main>
+3 -2
View File
@@ -3,6 +3,7 @@
import Field from '$ui/components/primitives/Field.svelte'; import Field from '$ui/components/primitives/Field.svelte';
import Input from '$ui/components/primitives/Input.svelte'; import Input from '$ui/components/primitives/Input.svelte';
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import { roleLabels, type AuthUser } from '$lib/auth.js';
import { timeFormat, timeZone, timeZonePersistenceError } from '$ui/stores/time-zone.js'; import { timeFormat, timeZone, timeZonePersistenceError } from '$ui/stores/time-zone.js';
import { availableTimeZones, DEFAULT_TIME_ZONE } from '$lib/time-zone.js'; import { availableTimeZones, DEFAULT_TIME_ZONE } from '$lib/time-zone.js';
let exampleDate = $state<Date | null>(null); let exampleDate = $state<Date | null>(null);
@@ -21,7 +22,7 @@
let signingOut = $state(false); let signingOut = $state(false);
let error = $state(''); let error = $state('');
let notice = $state(''); let notice = $state('');
const user = $derived($page.data.user); const user = $derived<AuthUser | null>($page.data.user ?? null);
async function changePassword(event: SubmitEvent) { async function changePassword(event: SubmitEvent) {
event.preventDefault(); event.preventDefault();
if (busy || signingOut) return; if (busy || signingOut) return;
@@ -88,7 +89,7 @@
<section class="panel identity"> <section class="panel identity">
<div> <div>
<h2>{user.displayName}</h2> <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> </div>
<Button disabled={busy || signingOut} loading={signingOut} onclick={logout} <Button disabled={busy || signingOut} loading={signingOut} onclick={logout}
>Sign out</Button >Sign out</Button
+6 -3
View File
@@ -4,6 +4,7 @@
import Field from '$ui/components/primitives/Field.svelte'; import Field from '$ui/components/primitives/Field.svelte';
import RelativeTime from '$ui/components/RelativeTime.svelte'; import RelativeTime from '$ui/components/RelativeTime.svelte';
import { page } from '$app/stores'; import { page } from '$app/stores';
import { canOperate } from '$lib/auth.js';
import { contextHref } from '$lib/navigation-context.js'; import { contextHref } from '$lib/navigation-context.js';
import { timeFormat } from '$ui/stores/time-zone.js'; import { timeFormat } from '$ui/stores/time-zone.js';
import { onMount } from 'svelte'; import { onMount } from 'svelte';
@@ -49,6 +50,7 @@
} }
let betas = $state<BetaListRow[]>([]); let betas = $state<BetaListRow[]>([]);
const editable = $derived(canOperate($page.data?.user?.role));
let loading = $state(true); let loading = $state(true);
let error = $state(''); let error = $state('');
let tab = $state('active'); let tab = $state('active');
@@ -122,7 +124,7 @@
} }
onMount(() => { onMount(() => {
const query = new URLSearchParams(window.location.search); 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 }); void goto('/betas/new' + window.location.search, { replaceState: true });
return; return;
} }
@@ -141,7 +143,7 @@
<svelte:head><title>Betas · Electric Cherry</title></svelte:head> <svelte:head><title>Betas · Electric Cherry</title></svelte:head>
<div class="page"> <div class="page">
<SectionHeader title="Betas"> <SectionHeader title="Betas">
{#snippet actions()}{#if loading || betas.length || error}<Button {#snippet actions()}{#if editable && (loading || betas.length || error)}<Button
variant="primary" variant="primary"
href="/betas/new">New beta</Button href="/betas/new">New beta</Button
>{/if}{/snippet} >{/if}{/snippet}
@@ -230,7 +232,8 @@
pageNumber = 1; pageNumber = 1;
saveContext(); saveContext();
}}>Clear filters</Button }}>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} >{/if}{/snippet}
</EmptyState> </EmptyState>
{:else} {:else}
+27 -21
View File
@@ -4,6 +4,7 @@
import { timeFormat } from '$ui/stores/time-zone.js'; import { timeFormat } from '$ui/stores/time-zone.js';
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import { page } from '$app/stores'; import { page } from '$app/stores';
import { canOperate } from '$lib/auth.js';
import { artifactVersionLabel } from '$lib/artifact-version.js'; import { artifactVersionLabel } from '$lib/artifact-version.js';
import type { BetaTestView } from '$lib/beta-plan.js'; import type { BetaTestView } from '$lib/beta-plan.js';
import type { BetaPromotionPreview } from '$lib/beta-promotion.js'; import type { BetaPromotionPreview } from '$lib/beta-promotion.js';
@@ -92,7 +93,9 @@
let updateOpen = $state(false), let updateOpen = $state(false),
updateRevision = $state(1), updateRevision = $state(1),
updateFiles = $state<BetaFile[]>([]); updateFiles = $state<BetaFile[]>([]);
const editable = $derived(canOperate($page.data?.user?.role));
function openUpdate() { function openUpdate() {
if (!editable) return;
updateRevision = revision.revision; updateRevision = revision.revision;
updateFiles = [...scripts]; updateFiles = [...scripts];
updateOpen = true; updateOpen = true;
@@ -157,6 +160,7 @@
); );
function testAction(action: string) { function testAction(action: string) {
if (!editable) return;
moreActionsOpen = false; moreActionsOpen = false;
void testProgress?.action(action); void testProgress?.action(action);
} }
@@ -260,7 +264,7 @@
} }
} }
async function promote() { async function promote() {
if (!preview) return; if (!editable || !preview) return;
acting = 'promote'; acting = 'promote';
reviewError = ''; reviewError = '';
flash = ''; flash = '';
@@ -288,6 +292,7 @@
} }
} }
async function rollback() { async function rollback() {
if (!editable) return;
const reject = beta?.state === 'ready-promote'; const reject = beta?.state === 'ready-promote';
const restarts = const restarts =
test?.plan.rollbackRestart === 'manual' test?.plan.rollbackRestart === 'manual'
@@ -318,6 +323,7 @@
} }
} }
async function retry(server: string) { async function retry(server: string) {
if (!editable) return;
acting = `retry:${server}`; acting = `retry:${server}`;
flash = ''; flash = '';
try { try {
@@ -392,7 +398,7 @@
$page.url $page.url
)}>View rollback campaign</Button )}>View rollback campaign</Button
> >
{:else if closed}<Button {:else if closed && editable}<Button
variant="primary" variant="primary"
href={contextHref(`/betas/new?reuse=${id}`, $page.url)} href={contextHref(`/betas/new?reuse=${id}`, $page.url)}
>Reuse files and test plan</Button >Reuse files and test plan</Button
@@ -410,12 +416,12 @@
? 'failures' ? 'failures'
: 'issues'}</Button : 'issues'}</Button
> >
{:else if presentation.key === 'paused' && !testExpired}<Button {:else if editable && presentation.key === 'paused' && !testExpired}<Button
variant="primary" variant="primary"
onclick={() => testAction('resume')} onclick={() => testAction('resume')}
disabled={busy}>Resume testing</Button disabled={busy}>Resume testing</Button
>{/if} >{/if}
{#if !closed || promotion || rollbackCampaign}<details {#if editable && (!closed || promotion || rollbackCampaign)}<details
class="more-actions" class="more-actions"
bind:open={moreActionsOpen} bind:open={moreActionsOpen}
> >
@@ -583,7 +589,7 @@
$page.url $page.url
)}>View provisioning</a )}>View provisioning</a
>{/if} >{/if}
{#if server.stage === 'failed' && !server.server_deleted_at && !closed}<Button {#if editable && server.stage === 'failed' && !server.server_deleted_at && !closed}<Button
size="sm" size="sm"
onclick={() => retry(server.server)} onclick={() => retry(server.server)}
disabled={busy} disabled={busy}
@@ -694,15 +700,15 @@
</div> </div>
{/if} {/if}
<BetaRevisionDialog {#if editable}<BetaRevisionDialog
bind:open={updateOpen} bind:open={updateOpen}
betaId={id} betaId={id}
revision={updateRevision} revision={updateRevision}
files={updateFiles} files={updateFiles}
servers={servers.map((server) => server.server)} servers={servers.map((server) => server.server)}
timeoutHours={test?.plan.timeoutHours} timeoutHours={test?.plan.timeoutHours}
onUpdate={load} onUpdate={load}
/> />{/if}
<Dialog <Dialog
bind:open={reviewOpen} bind:open={reviewOpen}
@@ -763,13 +769,13 @@
>Cancel</Button >Cancel</Button
>{#if preview}<Button onclick={reviewPromotion} disabled={acting !== null} >{#if preview}<Button onclick={reviewPromotion} disabled={acting !== null}
>Refresh review</Button >Refresh review</Button
><Button >{#if editable}<Button
variant="primary" variant="primary"
onclick={promote} onclick={promote}
loading={acting === 'promote'} loading={acting === 'promote'}
disabled={acting !== null || !!preview.blockers.length} disabled={acting !== null || !!preview.blockers.length}
>Promote to {preview.servers.length} servers</Button >Promote to {preview.servers.length} servers</Button
>{/if}{/snippet} >{/if}{/if}{/snippet}
</Dialog> </Dialog>
<style> <style>
+18 -8
View File
@@ -4,6 +4,7 @@
import Field from '$ui/components/primitives/Field.svelte'; import Field from '$ui/components/primitives/Field.svelte';
import { countLabel } from '$ui/plural.js'; import { countLabel } from '$ui/plural.js';
import { page } from '$app/stores'; import { page } from '$app/stores';
import { canOperate } from '$lib/auth.js';
import { contextHref, safeReturnHref } from '$lib/navigation-context.js'; import { contextHref, safeReturnHref } from '$lib/navigation-context.js';
import Breadcrumbs from '$ui/components/primitives/Breadcrumbs.svelte'; import Breadcrumbs from '$ui/components/primitives/Breadcrumbs.svelte';
import { timeFormat } from '$ui/stores/time-zone.js'; import { timeFormat } from '$ui/stores/time-zone.js';
@@ -91,6 +92,7 @@
availability = $state(''), availability = $state(''),
now = $state(Date.now()); now = $state(Date.now());
const steps = ['Files', 'Servers', 'Test plan', 'Review']; const steps = ['Files', 'Servers', 'Test plan', 'Review'];
const editable = $derived(canOperate($page.data?.user?.role));
const fleetServers = $derived($sharedFleet); const fleetServers = $derived($sharedFleet);
const eligibleFiles = $derived( const eligibleFiles = $derived(
scripts.filter((file) => file.runningBetaId === null && file.latestVersion !== null) scripts.filter((file) => file.runningBetaId === null && file.latestVersion !== null)
@@ -307,6 +309,7 @@
} }
} }
async function initialize(ignoreDraft = false) { async function initialize(ignoreDraft = false) {
if (!editable) return;
loading = true; loading = true;
flash = ''; flash = '';
draftReady = false; draftReady = false;
@@ -413,7 +416,7 @@
if (useLinked) void initialize(true); if (useLinked) void initialize(true);
} }
$effect(() => { $effect(() => {
if (!draftReady || loading || openingBeta) return; if (!editable || !draftReady || loading || openingBeta) return;
const draft: BetaDraft = { const draft: BetaDraft = {
version: 1, version: 1,
savedAt: new Date().toISOString(), savedAt: new Date().toISOString(),
@@ -467,6 +470,7 @@
} }
}); });
onMount(() => { onMount(() => {
if (!editable) return;
void initialize(); void initialize();
const timer = setInterval(() => (now = serverTimeNow()), 10000); const timer = setInterval(() => (now = serverTimeNow()), 10000);
const unload = (event: BeforeUnloadEvent) => { const unload = (event: BeforeUnloadEvent) => {
@@ -547,7 +551,7 @@
} }
async function openBeta(event: Event) { async function openBeta(event: Event) {
event.preventDefault(); event.preventDefault();
if (step !== 3 || forwardReason || openingBeta) return; if (!editable || step !== 3 || forwardReason || openingBeta) return;
if (betaPlanIssues(testPlan, serverCount).length) { if (betaPlanIssues(testPlan, serverCount).length) {
await move(2); await move(2);
await planBuilder?.validate(); await planBuilder?.validate();
@@ -606,15 +610,21 @@
title="New beta" title="New beta"
subtitle="Test files across games in one beta. Choose one instance count for every game." 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 {flash}{#if !scripts.length}<Button size="sm" onclick={() => initialize()}>Retry</Button
>{/if} >{/if}
</div>{/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> {$fleetError}<Button size="sm" onclick={() => refreshFleet(true)}>Retry servers</Button>
</div>{/if} </div>{/if}
{#if notice}<p role="status" class="notice">{notice}</p>{/if} {#if editable && notice}<p role="status" class="notice">{notice}</p>{/if}
{#if loading}<EmptyState title="Loading beta setup…" /> {#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"> {:else if availableDraft}<section class="ui-panel draft">
<h2>Resume unfinished beta?</h2> <h2>Resume unfinished beta?</h2>
<p> <p>
@@ -776,8 +786,8 @@
</p> </p>
</div> </div>
{:else}<p> {:else}<p>
<strong>{pickedServers.size} selected</strong> · {candidateServers.length} compatible <strong>{pickedServers.size} selected</strong> · {candidateServers.length}
servers. Restarts wait for confirmed empty lobbies. compatible servers. Restarts wait for confirmed empty lobbies.
</p>{/if} </p>{/if}
<details> <details>
<summary>Selected file scopes</summary> <summary>Selected file scopes</summary>
+6 -2
View File
@@ -1,4 +1,6 @@
<script lang="ts"> <script lang="ts">
import { canOperate } from '$lib/auth.js';
const writable = $derived(canOperate($page.data?.user?.role));
import { plural } from '$ui/plural.js'; import { plural } from '$ui/plural.js';
import RelativeTime from '$ui/components/RelativeTime.svelte'; import RelativeTime from '$ui/components/RelativeTime.svelte';
import { contextHref } from '$lib/navigation-context.js'; import { contextHref } from '$lib/navigation-context.js';
@@ -170,6 +172,7 @@
onMount(() => pollVisible(loadDetail, 5000, false)); onMount(() => pollVisible(loadDetail, 5000, false));
async function resume() { async function resume() {
if (!writable) return;
resuming = true; resuming = true;
resumeError = ''; resumeError = '';
try { try {
@@ -188,6 +191,7 @@
} }
async function rollback() { async function rollback() {
if (!writable) return;
if (!detail || rollingBack) return; if (!detail || rollingBack) return;
const id = detail.deployment.id; const id = detail.deployment.id;
const servers = detail.servers.filter((server) => !server.server_deleted_at).length; const servers = detail.servers.filter((server) => !server.server_deleted_at).length;
@@ -299,7 +303,7 @@
$page.url $page.url
)}>View rollback</Button )}>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 <Button
variant="danger" variant="danger"
size="sm" size="sm"
@@ -380,7 +384,7 @@
>All servers activated</span >All servers activated</span
>{/if} >{/if}
</div> </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} <Button size="sm" onclick={resume} loading={resuming} disabled={resuming}
>Resume when empty</Button >Resume when empty</Button
> >
+41 -27
View File
@@ -32,6 +32,7 @@
} from '$ui/maintenance-presentation.js'; } from '$ui/maintenance-presentation.js';
import { countLabel, plural } from '$ui/plural.js'; import { countLabel, plural } from '$ui/plural.js';
const administrator = $derived($page.data.user?.role === 'admin');
let data = $state<MaintenanceOverview | null>(null); let data = $state<MaintenanceOverview | null>(null);
let error = $state(''); let error = $state('');
let notice = $state(''); let notice = $state('');
@@ -262,7 +263,7 @@
} }
} }
async function act(body: Record<string, unknown>) { async function act(body: Record<string, unknown>) {
if (busy) return; if (!administrator || busy) return;
busy = true; busy = true;
notice = ''; notice = '';
error = ''; error = '';
@@ -296,8 +297,10 @@
subtitle="Keep servers healthy, current, and ready for the next match." subtitle="Keep servers healthy, current, and ready for the next match."
> >
{#snippet actions()} {#snippet actions()}
<Button disabled={busy} onclick={() => act({ action: 'scan' })}>Check now</Button> {#if administrator}<Button disabled={busy} onclick={() => act({ action: 'scan' })}
{#if data}<Button >Check now</Button
>{/if}
{#if administrator && data}<Button
variant="ghost" variant="ghost"
disabled={busy} disabled={busy}
onclick={() => act({ action: 'pause', paused: !data!.policies.paused })} onclick={() => act({ action: 'pause', paused: !data!.policies.paused })}
@@ -349,17 +352,17 @@
> >
</div> </div>
<p>{policy.description}</p> <p>{policy.description}</p>
<Button {#if administrator}<Button
disabled={busy} disabled={busy}
onclick={() => onclick={() =>
act({ act({
action: 'policy', action: 'policy',
policy: policy.id, policy: policy.id,
enabled: !data!.policies[policy.id] enabled: !data!.policies[policy.id]
})} })}
>{data.policies[policy.id] ? 'Disable' : 'Enable'} >{data.policies[policy.id] ? 'Disable' : 'Enable'}
{policy.title.toLowerCase()}</Button {policy.title.toLowerCase()}</Button
> >{/if}
</section> </section>
{/each} {/each}
</div> </div>
@@ -382,13 +385,24 @@
}} }}
> >
<label class="toggle" <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 <Field
>{#snippet label()}Queue time ({$timeZone}){/snippet} >{#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> </form>
<p class="muted"> <p class="muted">
Saving follows daylight saving changes in your selected timezone. Skipped Saving follows daylight saving changes in your selected timezone. Skipped
@@ -444,11 +458,11 @@
<section class="scan-errors" aria-labelledby="scan-errors-heading"> <section class="scan-errors" aria-labelledby="scan-errors-heading">
<div class="row-heading"> <div class="row-heading">
<h2 id="scan-errors-heading">Checks need attention</h2> <h2 id="scan-errors-heading">Checks need attention</h2>
<Button {#if administrator}<Button
size="sm" size="sm"
disabled={busy} disabled={busy}
onclick={() => act({ action: 'scan' })}>Retry checks</Button onclick={() => act({ action: 'scan' })}>Retry checks</Button
> >{/if}
</div> </div>
<ul class="error-groups"> <ul class="error-groups">
{#each scanErrors as group (group.key)} {#each scanErrors as group (group.key)}
@@ -520,7 +534,7 @@
<section class="card" id="updates"> <section class="card" id="updates">
<div class="row-heading"> <div class="row-heading">
<h2>Plutonium updates</h2> <h2>Plutonium updates</h2>
{#if updatableServers.length}<Button {#if administrator && updatableServers.length}<Button
disabled={busy || data.policies.paused} disabled={busy || data.policies.paused}
onclick={() => onclick={() =>
act({ action: 'update', servers: updatableServers })} act({ action: 'update', servers: updatableServers })}
@@ -575,13 +589,13 @@
{#if group.pending.length}<span {#if group.pending.length}<span
>{countLabel(group.pending.length, 'restart')} pending</span >{countLabel(group.pending.length, 'restart')} pending</span
>{/if} >{/if}
{#if group.status === 'unknown'}<Button {#if administrator && group.status === 'unknown'}<Button
size="sm" size="sm"
disabled={busy} disabled={busy}
onclick={() => act({ action: 'scan' })} onclick={() => act({ action: 'scan' })}
>Retry version check</Button >Retry version check</Button
> >
{:else if group.updatable.length}<Button {:else if administrator && group.updatable.length}<Button
size="sm" size="sm"
disabled={busy || data.policies.paused} disabled={busy || data.policies.paused}
onclick={() => onclick={() =>
@@ -692,14 +706,14 @@
? 'View restart operation' ? 'View restart operation'
: 'View evidence and recovery'}</a : 'View evidence and recovery'}</a
>{/if} >{/if}
{#if item.job?.state === 'queued' && !item.server_deleted_at}<Button {#if administrator && item.job?.state === 'queued' && !item.server_deleted_at}<Button
size="sm" size="sm"
disabled={busy} disabled={busy}
onclick={() => onclick={() =>
act({ action: 'cancel', id: item.job!.id })} act({ action: 'cancel', id: item.job!.id })}
>Cancel restart</Button >Cancel restart</Button
>{/if} >{/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" size="sm"
disabled={busy || data.policies.paused} disabled={busy || data.policies.paused}
onclick={() => onclick={() =>
+7 -3
View File
@@ -3,6 +3,7 @@
import { contextHref } from '$lib/navigation-context.js'; import { contextHref } from '$lib/navigation-context.js';
import { onMount, untrack } from 'svelte'; import { onMount, untrack } from 'svelte';
import { page } from '$app/stores'; import { page } from '$app/stores';
import { canOperate } from '$lib/auth.js';
import type { WatchdogDetail, WatchdogEvidence } from '$lib/watchdog.js'; import type { WatchdogDetail, WatchdogEvidence } from '$lib/watchdog.js';
import { pollVisible } from '$ui/poll.js'; import { pollVisible } from '$ui/poll.js';
import { timeFormat } from '$ui/stores/time-zone.js'; import { timeFormat } from '$ui/stores/time-zone.js';
@@ -24,6 +25,7 @@
let eventLimit = $state(25); let eventLimit = $state(25);
let fileLimit = $state(25); let fileLimit = $state(25);
let request = 0; let request = 0;
const operator = $derived(canOperate($page.data.user?.role));
const incident = $derived(detail?.incident); const incident = $derived(detail?.incident);
const evidence = $derived.by((): WatchdogEvidence => { const evidence = $derived.by((): WatchdogEvidence => {
try { try {
@@ -44,11 +46,13 @@
) )
); );
const canRetry = $derived( const canRetry = $derived(
!incident?.server_deleted_at && operator &&
!incident?.server_deleted_at &&
(incident?.state === 'attention' || incident?.state === 'dismissed') (incident?.state === 'attention' || incident?.state === 'dismissed')
); );
const canDismiss = $derived( const canDismiss = $derived(
!incident?.server_deleted_at && operator &&
!incident?.server_deleted_at &&
(incident?.state === 'open' || incident?.state === 'attention') (incident?.state === 'open' || incident?.state === 'attention')
); );
const explanation = $derived.by(() => { const explanation = $derived.by(() => {
@@ -92,7 +96,7 @@
} }
async function update(action: 'retry' | 'dismiss') { async function update(action: 'retry' | 'dismiss') {
if (busy || !incident) return; if (!operator || busy || !incident) return;
const id = incident.id; const id = incident.id;
const current = ++request; const current = ++request;
busy = action; busy = action;
+91 -84
View File
@@ -1,5 +1,6 @@
<script lang="ts"> <script lang="ts">
import { page } from '$app/stores'; import { page } from '$app/stores';
import { canOperate } from '$lib/auth.js';
import { contextHref } from '$lib/navigation-context.js'; import { contextHref } from '$lib/navigation-context.js';
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import { afterNavigate } from '$app/navigation'; import { afterNavigate } from '$app/navigation';
@@ -20,6 +21,7 @@
import Input from '$ui/components/primitives/Input.svelte'; import Input from '$ui/components/primitives/Input.svelte';
import Select from '$ui/components/primitives/Select.svelte'; import Select from '$ui/components/primitives/Select.svelte';
import { libraryLimit, saveLibraryContext } from '$ui/library-context.js'; import { libraryLimit, saveLibraryContext } from '$ui/library-context.js';
const editable = $derived(canOperate($page.data?.user?.role));
type ArtifactKind = 'script' | 'config' | 'plugin' | 'baseline'; type ArtifactKind = 'script' | 'config' | 'plugin' | 'baseline';
interface ArtifactRow { interface ArtifactRow {
@@ -266,11 +268,11 @@
title="Plugins" title="Plugins"
subtitle="Manage plugin versions and test changes before promotion." subtitle="Manage plugin versions and test changes before promotion."
> >
{#snippet actions()}{#if plugins.length}<Button {#snippet actions()}{#if editable && plugins.length}<Button
variant="ghost" variant="ghost"
onclick={() => (updatesOpen = !updatesOpen)} onclick={() => (updatesOpen = !updatesOpen)}
>{updatesOpen ? 'Hide update details' : 'Check for updates'}</Button >{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" variant="primary"
onclick={() => (addOpen = true)}>Add plugin</Button onclick={() => (addOpen = true)}>Add plugin</Button
>{/if}{/snippet} >{/if}{/snippet}
@@ -339,7 +341,7 @@
{#if game}<p class="filter-hint"> {#if game}<p class="filter-hint">
Game results include unscoped plugins that can use explicitly selected beta servers. Game results include unscoped plugins that can use explicitly selected beta servers.
</p>{/if} </p>{/if}
{#if updatesOpen}<details {#if editable && updatesOpen}<details
open open
ontoggle={(event) => { ontoggle={(event) => {
if (!event.currentTarget.open) updatesOpen = false; if (!event.currentTarget.open) updatesOpen = false;
@@ -377,8 +379,9 @@
game = ''; game = '';
status = ''; status = '';
}}>Clear filters</Button }}>Clear filters</Button
>{:else}<Button variant="primary" onclick={() => (addOpen = true)} >{:else if editable}<Button
>Add plugin</Button variant="primary"
onclick={() => (addOpen = true)}>Add plugin</Button
>{/if}{/snippet} >{/if}{/snippet}
</EmptyState> </EmptyState>
{:else} {:else}
@@ -449,91 +452,95 @@
</section> </section>
</div> </div>
<Dialog bind:open={addOpen} title="Add plugin" busy={uploading || githubBusy} wide> {#if editable}<Dialog bind:open={addOpen} title="Add plugin" busy={uploading || githubBusy} wide>
<Tabs <Tabs
tabs={[ tabs={[
{ id: 'upload', label: 'Upload file', disabled: uploading || githubBusy }, { id: 'upload', label: 'Upload file', disabled: uploading || githubBusy },
{ id: 'github', label: 'GitHub release', disabled: uploading || githubBusy } { id: 'github', label: 'GitHub release', disabled: uploading || githubBusy }
]} ]}
bind:value={method} bind:value={method}
ariaLabel="Plugin source" ariaLabel="Plugin source"
idPrefix="plugin-source" idPrefix="plugin-source"
panelId="plugin-source-panel" panelId="plugin-source-panel"
/> />
<div id="plugin-source-panel" role="tabpanel" aria-labelledby={'plugin-source-' + method}> <div id="plugin-source-panel" role="tabpanel" aria-labelledby={'plugin-source-' + method}>
{#if method === 'github'}<PluginGithubImport {#if method === 'github'}<PluginGithubImport
bind:busy={githubBusy} bind:busy={githubBusy}
onimport={async (result) => { onimport={async (result) => {
importComplete = result; importComplete = result;
updates = updates.filter((update) => update.pluginId !== result.pluginId); updates = updates.filter((update) => update.pluginId !== result.pluginId);
importCount++; importCount++;
addOpen = false; addOpen = false;
await load(); await load();
}} }}
/> />
{:else} {:else}
<form onsubmit={createPlugin} class="upload-form"> <form onsubmit={createPlugin} class="upload-form">
<Field label="Name"> <Field label="Name">
<Input bind:value={newName} placeholder="my plugin" disabled={uploading} /> <Input bind:value={newName} placeholder="my plugin" disabled={uploading} />
</Field> </Field>
<Field <Field
label="Release version" label="Release version"
hint="Optional. Use the publisher’s version or release tag, such as v3.1.0." hint="Optional. Use the publisher’s version or release tag, such as v3.1.0."
> >
<Input <Input
bind:value={newReleaseVersion} bind:value={newReleaseVersion}
placeholder="v3.1.0" 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}
disabled={uploading} 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 <Select
ariaLabel="Scope game" ariaLabel="Scope type"
bind:value={newScopeGame} bind:value={newScopeType}
onchange={() => (newScopeMap = '')} options={scopeTypeOptions}
options={gameOptions}
disabled={uploading} disabled={uploading}
/> />
{/if} {#if newScopeType === 'game' || newScopeType === 'map'}
{#if newScopeType === 'map'} <Select
<Select ariaLabel="Scope game"
ariaLabel="Scope map" bind:value={newScopeGame}
bind:value={newScopeMap} onchange={() => (newScopeMap = '')}
options={mapOptions} options={gameOptions}
disabled={uploading || !newScopeGame} disabled={uploading}
/> />
{/if} {/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> </div>
</Field> </form>
<Field label="Plugin file (.dll)"> {/if}
<Input type="file" accept=".dll" onchange={pickFile} disabled={uploading} /> </div>
</Field> </Dialog>{/if}
<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>
<style> <style>
.page { .page {
+19 -17
View File
@@ -1,4 +1,6 @@
<script lang="ts"> <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 Select from '$ui/components/primitives/Select.svelte';
import Input from '$ui/components/primitives/Input.svelte'; import Input from '$ui/components/primitives/Input.svelte';
import Field from '$ui/components/primitives/Field.svelte'; import Field from '$ui/components/primitives/Field.svelte';
@@ -100,7 +102,7 @@
void goto(url, { replaceState: true, noScroll: true, keepFocus: true }); void goto(url, { replaceState: true, noScroll: true, keepFocus: true });
} }
async function cancelRequests(rows: Deployment[], bulk = false) { 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); const ids = rows.map((row) => row.id);
if ( if (
!(await confirmAction({ !(await confirmAction({
@@ -288,10 +290,10 @@
<svelte:head><title>Provisioning | Electric Cherry</title></svelte:head> <svelte:head><title>Provisioning | Electric Cherry</title></svelte:head>
<div class="page"> <div class="page">
<SectionHeader title="Provisioning"> <SectionHeader title="Provisioning">
{#snippet actions()} {#snippet actions()}{#if writable}
<Button onclick={() => (lifecycleOpen = true)}>Server actions</Button> <Button onclick={() => (lifecycleOpen = true)}>Server actions</Button>
<Button onclick={() => (fleetActionsOpen = true)}>Fleet actions</Button> <Button onclick={() => (fleetActionsOpen = true)}>Fleet actions</Button>
{/snippet} {/if}{/snippet}
</SectionHeader> </SectionHeader>
{#if $page.url.searchParams.has('returnTo')}<BackLink fallback="/" />{/if} {#if $page.url.searchParams.has('returnTo')}<BackLink fallback="/" />{/if}
<div class="toolbar"> <div class="toolbar">
@@ -368,16 +370,16 @@
<Button variant="primary" onclick={() => (filtersOpen = false)}>Show results</Button> <Button variant="primary" onclick={() => (filtersOpen = false)}>Show results</Button>
{/snippet} {/snippet}
</Dialog> </Dialog>
<LifecycleDialog bind:open={lifecycleOpen} /> {#if writable}<LifecycleDialog bind:open={lifecycleOpen} />
<FleetActionsDialog <FleetActionsDialog
bind:open={fleetActionsOpen} bind:open={fleetActionsOpen}
onqueued={(count, action) => { onqueued={(count, action) => {
fleetNotice = `${actionLabels[action]} queued for ${count} server${count === 1 ? '' : 's'}.`; fleetNotice = `${actionLabels[action]} queued for ${count} server${count === 1 ? '' : 's'}.`;
tab = 'active'; tab = 'active';
clearFilters(); clearFilters();
void load(); void load();
}} }}
/> />{/if}
{#if fleetNotice}<p role="status">{fleetNotice}</p>{/if} {#if fleetNotice}<p role="status">{fleetNotice}</p>{/if}
{#if cancellationNotice}<p role="status">{cancellationNotice}</p>{/if} {#if cancellationNotice}<p role="status">{cancellationNotice}</p>{/if}
<div <div
@@ -398,7 +400,7 @@
</p> </p>
{:else}<h2>History <span>{historyTotal}</span></h2>{/if} {:else}<h2>History <span>{historyTotal}</span></h2>{/if}
<div class="queue-tools"> <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" size="sm"
disabled={cancelling} disabled={cancelling}
onclick={() => onclick={() =>
@@ -595,7 +597,7 @@
`/servers/${encodeURIComponent(row.server)}?tab=logs`, `/servers/${encodeURIComponent(row.server)}?tab=logs`,
$page.url $page.url
)}>Open logs</a )}>Open logs</a
>{/if}{#if row.canCancel && !row.server_deleted_at}<Button >{/if}{#if writable && row.canCancel && !row.server_deleted_at}<Button
variant="danger" variant="danger"
size="sm" size="sm"
disabled={cancelling} disabled={cancelling}
+6 -1
View File
@@ -1,4 +1,5 @@
<script lang="ts"> <script lang="ts">
import { canOperate } from '$lib/auth.js';
import { contextHref } from '$lib/navigation-context.js'; import { contextHref } from '$lib/navigation-context.js';
import { onDestroy, untrack } from 'svelte'; import { onDestroy, untrack } from 'svelte';
import { page } from '$app/stores'; import { page } from '$app/stores';
@@ -35,6 +36,7 @@
import { timeFormat } from '$ui/stores/time-zone.js'; import { timeFormat } from '$ui/stores/time-zone.js';
let { data }: { data: PageData } = $props(); let { data }: { data: PageData } = $props();
const writable = $derived(canOperate(data.user?.role));
const initial = untrack(() => data.detail); const initial = untrack(() => data.detail);
let view = $state<ProvisioningRefreshState<ProvisioningDetail>>({ let view = $state<ProvisioningRefreshState<ProvisioningDetail>>({
id: initial.deployment.id, id: initial.deployment.id,
@@ -104,6 +106,7 @@
}); });
onDestroy(() => controller.dispose()); onDestroy(() => controller.dispose());
async function retryWithRebuild() { async function retryWithRebuild() {
if (!writable) return;
if (!deployment || !detail?.canRetryWithRebuild || view.busy) return; if (!deployment || !detail?.canRetryWithRebuild || view.busy) return;
const id = deployment.id; const id = deployment.id;
const confirmed = await confirmAction({ const confirmed = await confirmAction({
@@ -138,6 +141,7 @@
); );
} }
async function cancelOperation() { async function cancelOperation() {
if (!writable) return;
if (!deployment || !detail?.canCancel || view.busy) return; if (!deployment || !detail?.canCancel || view.busy) return;
const id = deployment.id; const id = deployment.id;
const confirmed = await confirmAction({ const confirmed = await confirmAction({
@@ -153,6 +157,7 @@
); );
} }
async function cancelCreation() { async function cancelCreation() {
if (!writable) return;
if (!deployment || !detail?.canCancelCreation || view.busy) return; if (!deployment || !detail?.canCancelCreation || view.busy) return;
const id = deployment.id; const id = deployment.id;
const confirmed = await confirmAction({ const confirmed = await confirmAction({
@@ -305,7 +310,7 @@
)}. )}.
</p> </p>
</div>{/if} </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"> <div class="recovery-actions">
{#if canOverride}<div> {#if canOverride}<div>
<Button <Button
+38 -31
View File
@@ -3,6 +3,7 @@
import Input from '$ui/components/primitives/Input.svelte'; import Input from '$ui/components/primitives/Input.svelte';
import Field from '$ui/components/primitives/Field.svelte'; import Field from '$ui/components/primitives/Field.svelte';
import { page } from '$app/stores'; import { page } from '$app/stores';
import { canOperate } from '$lib/auth.js';
import { contextHref } from '$lib/navigation-context.js'; import { contextHref } from '$lib/navigation-context.js';
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import { afterNavigate } from '$app/navigation'; import { afterNavigate } from '$app/navigation';
@@ -24,6 +25,7 @@
} from '$lib/script-library.js'; } from '$lib/script-library.js';
import { zombieMaps } from '$lib/map-aliases.js'; import { zombieMaps } from '$lib/map-aliases.js';
import { libraryLimit, saveLibraryContext } from '$ui/library-context.js'; import { libraryLimit, saveLibraryContext } from '$ui/library-context.js';
const editable = $derived(canOperate($page.data?.user?.role));
let limit = $state(25); let limit = $state(25);
let mounted = $state(false); let mounted = $state(false);
@@ -53,7 +55,7 @@
let map = $state(''); let map = $state('');
let sort = $state('attention'); let sort = $state('attention');
let selected = $state<number[]>([]); let selected = $state<number[]>([]);
let uploader: ScriptUploadDrawer; let uploader = $state<ScriptUploadDrawer>();
let dragDepth = $state(0); let dragDepth = $state(0);
const tabs: Array<{ value: ScriptStatus | 'all'; label: string }> = [ const tabs: Array<{ value: ScriptStatus | 'all'; label: string }> = [
{ value: 'all', label: 'All scripts' }, { value: 'all', label: 'All scripts' },
@@ -179,13 +181,15 @@
map = ''; map = '';
} }
function onDrag(event: DragEvent) { function onDrag(event: DragEvent) {
if (!editable) return;
if (event.dataTransfer?.types.includes('Files')) event.preventDefault(); if (event.dataTransfer?.types.includes('Files')) event.preventDefault();
} }
function onDrop(event: DragEvent) { function onDrop(event: DragEvent) {
if (!editable) return;
if (!event.dataTransfer?.types.includes('Files')) return; if (!event.dataTransfer?.types.includes('Files')) return;
event.preventDefault(); event.preventDefault();
dragDepth = 0; dragDepth = 0;
void uploader.addFiles([...event.dataTransfer.files]); void uploader?.addFiles([...event.dataTransfer.files]);
} }
</script> </script>
@@ -195,7 +199,7 @@
ondragover={onDrag} ondragover={onDrag}
ondrop={onDrop} ondrop={onDrop}
ondragenter={(event) => { ondragenter={(event) => {
if (event.dataTransfer?.types.includes('Files')) { if (editable && event.dataTransfer?.types.includes('Files')) {
event.preventDefault(); event.preventDefault();
dragDepth++; dragDepth++;
} }
@@ -209,9 +213,9 @@
title="Scripts" title="Scripts"
subtitle="Review versions, prepare a beta, and keep releases moving." 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" variant="primary"
onclick={() => uploader.open()}>Upload scripts</Button onclick={() => uploader?.open()}>Upload scripts</Button
>{/if}{/snippet} >{/if}{/snippet}
</SectionHeader> </SectionHeader>
<section class="library" aria-label="Script library"> <section class="library" aria-label="Script library">
@@ -277,9 +281,10 @@
title="No scripts yet" title="No scripts yet"
description="Upload script files to keep versions and test changes in a beta before promotion." description="Upload script files to keep versions and test changes in a beta before promotion."
> >
{#snippet actions()}<Button variant="primary" onclick={() => uploader.open()} {#snippet actions()}{#if editable}<Button
>Upload scripts</Button variant="primary"
>{/snippet} onclick={() => uploader?.open()}>Upload scripts</Button
>{/if}{/snippet}
</EmptyState> </EmptyState>
{:else if !visible.length && !error} {:else if !visible.length && !error}
<EmptyState <EmptyState
@@ -293,31 +298,32 @@
<table> <table>
<thead <thead
><tr ><tr
><th class="select-cell" >{#if editable}<th class="select-cell"
><input ><input
type="checkbox" type="checkbox"
aria-label="Select visible scripts eligible for a beta" aria-label="Select visible scripts eligible for a beta"
checked={allSelected} checked={allSelected}
disabled={!selectable.length} disabled={!selectable.length}
onchange={selectVisible} onchange={selectVisible}
/></th /></th
><th>Script</th><th>Game / scope</th><th>Version / status</th><th >{/if}<th>Script</th><th>Game / scope</th><th>Version / status</th
>Eligible servers</th ><th>Eligible servers</th><th
><th><span class="sr-only">Next action</span></th></tr ><span class="sr-only">Next action</span></th
></tr
></thead ></thead
> >
<tbody <tbody
>{#each visible.slice(0, limit) as s (s.id)} >{#each visible.slice(0, limit) as s (s.id)}
<tr class:selected={selected.includes(s.id)}> <tr class:selected={selected.includes(s.id)}>
<td class="select-cell" {#if editable}<td class="select-cell"
><input ><input
type="checkbox" type="checkbox"
aria-label={`Select ${s.name} for a beta`} aria-label={`Select ${s.name} for a beta`}
checked={selected.includes(s.id)} checked={selected.includes(s.id)}
disabled={!canTest(s)} disabled={!canTest(s)}
onchange={() => toggle(s.id)} onchange={() => toggle(s.id)}
/></td /></td
> >{/if}
<td <td
><a ><a
class="name" class="name"
@@ -349,7 +355,8 @@
href={contextHref(`/scripts/${s.id}`, $page.url)} href={contextHref(`/scripts/${s.id}`, $page.url)}
>Review failure</a >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 >{/if}</td
> >
</tr> </tr>
@@ -360,7 +367,7 @@
{/if} {/if}
<ListFooter total={visible.length} bind:limit label="scripts" /> <ListFooter total={visible.length} bind:limit label="scripts" />
</section> </section>
{#if selectedScripts.length} {#if editable && selectedScripts.length}
<div class="selection-bar" role="region" aria-label="Selected scripts"> <div class="selection-bar" role="region" aria-label="Selected scripts">
<strong <strong
title={hiddenSelected title={hiddenSelected
@@ -398,7 +405,7 @@
>We’ll recognize existing scripts and check for duplicate versions.</span >We’ll recognize existing scripts and check for duplicate versions.</span
> >
</div>{/if} </div>{/if}
<ScriptUploadDrawer bind:this={uploader} {scripts} onuploaded={load} /> {#if editable}<ScriptUploadDrawer bind:this={uploader} {scripts} onuploaded={load} />{/if}
<style> <style>
.page { .page {
+57 -52
View File
@@ -24,6 +24,7 @@
updated_at: string; updated_at: string;
} }
const games: ZombieGame[] = ['t5', 't6']; const games: ZombieGame[] = ['t5', 't6'];
const administrator = $derived($page.data.user?.role === 'admin');
let open = $state(false); let open = $state(false);
let search = $state(''); let search = $state('');
let gameFilter = $state(''); let gameFilter = $state('');
@@ -85,6 +86,7 @@
void reload(); void reload();
}); });
function addKey() { function addKey() {
if (!administrator) return;
plutoKey = ''; plutoKey = '';
label = ''; label = '';
error = ''; error = '';
@@ -93,7 +95,7 @@
} }
async function save(e: SubmitEvent) { async function save(e: SubmitEvent) {
e.preventDefault(); e.preventDefault();
if (!plutoKey.trim() || busy) return; if (!administrator || !plutoKey.trim() || busy) return;
busy = true; busy = true;
error = ''; error = '';
notice = ''; notice = '';
@@ -117,7 +119,7 @@
} }
} }
async function remove(key: SavedKey) { async function remove(key: SavedKey) {
if (key.assigned_servers.length) return; if (!administrator || busy || key.assigned_servers.length) return;
if ( if (
!(await confirmAction({ !(await confirmAction({
title: 'Remove saved key?', title: 'Remove saved key?',
@@ -154,7 +156,9 @@
title="Keys" title="Keys"
subtitle="Pool Plutonium keys by game. Any available key can launch any map or lobby in that game." 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> </SectionHeader>
<p class="key-help"> <p class="key-help">
A key stays assigned while its server exists, including when stopped, and becomes available A key stays assigned while its server exists, including when stopped, and becomes available
@@ -209,16 +213,17 @@
{#if loading}<p>Loading keys…</p> {#if loading}<p>Loading keys…</p>
{:else if !filtered.length}<EmptyState {:else if !filtered.length}<EmptyState
title={keys.length ? 'No matching keys' : 'No saved keys'} 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} {:else}
<div class="table-wrap"> <div class="table-wrap">
<table> <table>
<thead <thead
><tr ><tr
><th>Game / label</th><th>Availability</th><th>Last saved</th><th ><th>Game / label</th><th>Availability</th><th>Last saved</th
>Actions</th >{#if administrator}<th>Actions</th>{/if}</tr
></tr
></thead ></thead
> >
<tbody <tbody
@@ -237,13 +242,13 @@
>{/if}</td >{/if}</td
> >
<td><RelativeTime value={key.updated_at} /></td> <td><RelativeTime value={key.updated_at} /></td>
<td {#if administrator}<td
><Button ><Button
ariaLabel={`Remove ${key.label || key.game.toUpperCase() + ' key'}`} ariaLabel={`Remove ${key.label || key.game.toUpperCase() + ' key'}`}
disabled={busy || !!key.assigned_servers.length} disabled={busy || !!key.assigned_servers.length}
onclick={() => remove(key)}>Remove</Button onclick={() => remove(key)}>Remove</Button
>{#if key.assigned_servers.length}<small>In use</small>{/if}</td >{#if key.assigned_servers.length}<small>In use</small>{/if}</td
> >{/if}
</tr> </tr>
{/each}</tbody {/each}</tbody
> >
@@ -252,44 +257,44 @@
{/if} {/if}
<ListFooter total={filtered.length} bind:limit onchange={saveSearch} label="keys" /> <ListFooter total={filtered.length} bind:limit onchange={saveSearch} label="keys" />
</div> </div>
<Dialog bind:open title="Add key" {busy}> {#if administrator}<Dialog bind:open title="Add key" {busy}>
{#if error}<p role="alert" class="error">{error}</p>{/if} {#if error}<p role="alert" class="error">{error}</p>{/if}
<form onsubmit={save}> <form onsubmit={save}>
<Field label="Game"> <Field label="Game">
<Select bind:value={game} disabled={busy} <Select bind:value={game} disabled={busy}
><option value="t5">Black Ops I</option><option value="t6">Black Ops II</option ><option value="t5">Black Ops I</option><option value="t6">Black Ops II</option
></Select ></Select
></Field ></Field
> >
<Field <Field
label="Label (optional)" label="Label (optional)"
hint="Record the hostname you chose in Plutonium, or another name that helps identify this key." 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} /> <Input bind:value={label} placeholder="e.g. Electric Cherry 5" disabled={busy} />
</Field> </Field>
<Field <Field
label="Plutonium server key" label="Plutonium server key"
hint="Available to any new lobby in this game. Existing credentials are never displayed." hint="Available to any new lobby in this game. Existing credentials are never displayed."
> >
<Input <Input
type="password" type="password"
bind:value={plutoKey} bind:value={plutoKey}
placeholder="Paste key" placeholder="Paste key"
autocomplete="off" autocomplete="off"
required required
disabled={busy} disabled={busy}
/> />
</Field> </Field>
<div class="actions"> <div class="actions">
<Button <Button
type="submit" type="submit"
variant="primary" variant="primary"
disabled={busy || loading || !plutoKey.trim()} disabled={busy || loading || !plutoKey.trim()}
loading={busy}>Save key</Button loading={busy}>Save key</Button
><Button onclick={() => (open = false)} disabled={busy}>Cancel</Button> ><Button onclick={() => (open = false)} disabled={busy}>Cancel</Button>
</div> </div>
</form> </form>
</Dialog> </Dialog>{/if}
<style> <style>
.page { .page {
+8 -6
View File
@@ -20,6 +20,7 @@
import StatusBadge from '$ui/components/primitives/StatusBadge.svelte'; import StatusBadge from '$ui/components/primitives/StatusBadge.svelte';
import ListFooter from '$ui/components/primitives/ListFooter.svelte'; import ListFooter from '$ui/components/primitives/ListFooter.svelte';
import { page } from '$app/stores'; import { page } from '$app/stores';
import { canReadSensitive } from '$lib/auth.js';
import { goto } from '$app/navigation'; import { goto } from '$app/navigation';
import LifecycleDialog from '$ui/components/LifecycleDialog.svelte'; import LifecycleDialog from '$ui/components/LifecycleDialog.svelte';
import { actionLabels, type RuntimeAction } from '$lib/provisioning.js'; import { actionLabels, type RuntimeAction } from '$lib/provisioning.js';
@@ -120,6 +121,7 @@
const canOperate = $derived( const canOperate = $derived(
$page.data?.user?.role === 'admin' || $page.data?.user?.role === 'operator' $page.data?.user?.role === 'admin' || $page.data?.user?.role === 'operator'
); );
const canInspect = $derived(canReadSensitive($page.data?.user?.role));
const availableActions = $derived( const availableActions = $derived(
serverContext ? applicableRuntimeActions(serverContext, $uiNow) : [] serverContext ? applicableRuntimeActions(serverContext, $uiNow) : []
); );
@@ -207,7 +209,7 @@
let tabSequence = 0; let tabSequence = 0;
async function loadTab(currentName: string, tab: string, signal?: AbortSignal) { 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; const sequence = ++tabSequence;
try { try {
const response = await fetch( const response = await fetch(
@@ -231,7 +233,7 @@
) )
return; return;
if (tab === 'diagnostics') { if (tab === 'diagnostics') {
if (!canOperate) return; if (!canInspect) return;
savedRconSnippet = body.replySnippet; savedRconSnippet = body.replySnippet;
} else inventory = body.scripts; } else inventory = body.scripts;
tabError = ''; tabError = '';
@@ -262,8 +264,8 @@
inventory = []; inventory = [];
inventoryLimit = 25; inventoryLimit = 25;
savedRconSnippet = null; savedRconSnippet = null;
tabLoading = tab === 'configuration' || (tab === 'diagnostics' && canOperate); tabLoading = tab === 'configuration' || (tab === 'diagnostics' && canInspect);
if (tab !== 'configuration' && (tab !== 'diagnostics' || !canOperate)) return; if (tab !== 'configuration' && (tab !== 'diagnostics' || !canInspect)) return;
return pollVisible((signal) => loadTab(currentName, tab, signal), 10000); return pollVisible((signal) => loadTab(currentName, tab, signal), 10000);
}); });
$effect(() => { $effect(() => {
@@ -290,7 +292,7 @@
let effectiveReason = $derived<RconFailure | null>(decoded?.reason ?? null); let effectiveReason = $derived<RconFailure | null>(decoded?.reason ?? null);
let errorDetail = $derived(decoded?.detail ?? effectiveErr ?? ''); let errorDetail = $derived(decoded?.detail ?? effectiveErr ?? '');
let effectiveSnippet = $derived<string | null>( 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 effectiveActiveSince = $derived<string | null>(server?.active_since ?? null);
let effectiveInactiveSince = $derived<string | null>(server?.inactive_since ?? null); let effectiveInactiveSince = $derived<string | null>(server?.inactive_since ?? null);
@@ -705,7 +707,7 @@
</div> </div>
{/if} {/if}
{#if canOperate && effectiveSnippet} {#if canInspect && effectiveSnippet}
<details class="snippet-details" open={effectiveReason === 'bad-reply'}> <details class="snippet-details" open={effectiveReason === 'bad-reply'}>
<summary>Failure reply (addresses redacted)</summary> <summary>Failure reply (addresses redacted)</summary>
<pre class="snippet">{effectiveSnippet}</pre> <pre class="snippet">{effectiveSnippet}</pre>
+5 -1
View File
@@ -1,5 +1,6 @@
<script lang="ts"> <script lang="ts">
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import { page } from '$app/stores';
import SectionHeader from '$ui/components/primitives/SectionHeader.svelte'; import SectionHeader from '$ui/components/primitives/SectionHeader.svelte';
import SettingsEditor from '$ui/components/SettingsEditor.svelte'; import SettingsEditor from '$ui/components/SettingsEditor.svelte';
import Button from '$ui/components/primitives/Button.svelte'; import Button from '$ui/components/primitives/Button.svelte';
@@ -33,7 +34,10 @@
subtitle="Shared fleet limits and history retention. Changes apply without restarting." subtitle="Shared fleet limits and history retention. Changes apply without restarting."
/> />
{#if loading}<p role="status">Loading fleet settings…</p> {#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> {:else}<div>
<p class="ui-error" role="alert">{error}</p> <p class="ui-error" role="alert">{error}</p>
<Button onclick={loadSettings}>Retry loading</Button> <Button onclick={loadSettings}>Retry loading</Button>
+182 -161
View File
@@ -44,6 +44,11 @@
label: 'Viewer', label: 'Viewer',
description: 'Read fleet status and operation summaries.' 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', value: 'operator',
label: 'Operator', label: 'Operator',
@@ -57,6 +62,7 @@
]; ];
const roleDescription = $derived(roles.find((item) => item.value === role)?.description); const roleDescription = $derived(roles.find((item) => item.value === role)?.description);
const administrator = $derived($page.data.user?.role === 'admin'); const administrator = $derived($page.data.user?.role === 'admin');
const canViewUsers = $derived(administrator || $page.data.user?.role === 'super_viewer');
async function load() { async function load() {
loading = true; loading = true;
@@ -104,13 +110,14 @@
} }
} }
onMount(() => { onMount(() => {
if (administrator) void Promise.all([load(), loadAudit()]); if (canViewUsers) void Promise.all([load(), loadAudit()]);
else { else {
loading = false; loading = false;
auditLoading = false; auditLoading = false;
} }
}); });
function openCreate() { function openCreate() {
if (!administrator) return;
username = ''; username = '';
displayName = ''; displayName = '';
password = ''; password = '';
@@ -122,6 +129,7 @@
createOpen = true; createOpen = true;
} }
function openEdit(user: AuthUser) { function openEdit(user: AuthUser) {
if (!administrator) return;
editing = user; editing = user;
username = user.username; username = user.username;
displayName = user.displayName; displayName = user.displayName;
@@ -134,7 +142,7 @@
} }
async function create(event: SubmitEvent) { async function create(event: SubmitEvent) {
event.preventDefault(); event.preventDefault();
if (busy) return; if (!administrator || busy) return;
busy = true; busy = true;
formError = ''; formError = '';
error = ''; error = '';
@@ -159,7 +167,7 @@
} }
async function save(event: SubmitEvent) { async function save(event: SubmitEvent) {
event.preventDefault(); event.preventDefault();
if (busy || !editing) return; if (!administrator || busy || !editing) return;
busy = true; busy = true;
formError = ''; formError = '';
error = ''; error = '';
@@ -196,6 +204,7 @@
} }
} }
async function revoke(user: AuthUser) { async function revoke(user: AuthUser) {
if (!administrator || busy) return;
const own = user.id === $page.data.user?.id; const own = user.id === $page.data.user?.id;
if ( if (
!(await confirmAction({ !(await confirmAction({
@@ -234,14 +243,19 @@
<svelte:head><title>Users · Electric Cherry</title></svelte:head> <svelte:head><title>Users · Electric Cherry</title></svelte:head>
<section class="page"> <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 {#snippet actions()}{#if administrator}<Button
variant="primary" variant="primary"
disabled={busy} disabled={busy}
onclick={openCreate}>Add user</Button onclick={openCreate}>Add user</Button
>{/if}{/snippet} >{/if}{/snippet}
</SectionHeader> </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> <a href="/account">Go to your account</a>
{:else} {:else}
{#if error}<p class="error" role="alert">{error}</p>{/if}{#if notice}<p {#if error}<p class="error" role="alert">{error}</p>{/if}{#if notice}<p
@@ -274,7 +288,8 @@
</div> </div>
<div class="access"> <div class="access">
<span class="role" <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} ><span class:disabled={user.disabled}
>{user.disabled ? 'Disabled' : 'Enabled'}</span >{user.disabled ? 'Disabled' : 'Enabled'}</span
>{#if user.mustChangePassword && !user.disabled}<small >{#if user.mustChangePassword && !user.disabled}<small
@@ -292,16 +307,16 @@
/> />
</p> </p>
</div> </div>
<div class="actions"> {#if administrator}<div class="actions">
<Button size="sm" disabled={busy} onclick={() => openEdit(user)} <Button size="sm" disabled={busy} onclick={() => openEdit(user)}
>Edit access</Button >Edit access</Button
><Button ><Button
size="sm" size="sm"
variant="ghost" variant="ghost"
disabled={busy} disabled={busy}
onclick={() => revoke(user)}>Sign out sessions</Button onclick={() => revoke(user)}>Sign out sessions</Button
> >
</div> </div>{/if}
</article>{/each} </article>{/each}
</div> </div>
{/if} {/if}
@@ -355,163 +370,169 @@
{/if} {/if}
</section> </section>
<Dialog {#if administrator}<Dialog
bind:open={createOpen} bind:open={createOpen}
title="Add user" title="Add user"
description="Share the temporary password privately. The user must choose a new password when they first sign in." description="Share the temporary password privately. The user must choose a new password when they first sign in."
{busy} {busy}
> >
<form onsubmit={create} aria-busy={busy}> <form onsubmit={create} aria-busy={busy}>
<fieldset disabled={busy}> <fieldset disabled={busy}>
{#if formError}<p class="error" role="alert">{formError}</p>{/if} {#if formError}<p class="error" role="alert">{formError}</p>{/if}
<Field forId="new-username" label="Username"> <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}
<Input <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" name="password"
type={showPassword ? 'text' : 'password'} type={showPassword ? 'text' : 'password'}
required
minlength={8} minlength={8}
maxlength={128} maxlength={128}
autocomplete="new-password" autocomplete="new-password"
bind:value={password} bind:value={password}
aria-describedby="reset-help" aria-describedby="temporary-help"
/></Field /></Field
> >
<p class="hint" id="reset-help"> <p class="hint" id="temporary-help">
Leave blank to keep the current password. A reset requires the user to choose a 8–128 characters. Save it before adding the user; it will not be displayed
new password at their next sign-in. again.
</p> </p>
<label class="checkbox" <label class="checkbox"
><input type="checkbox" bind:checked={showPassword} />Show temporary password</label ><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> <div class="form-actions">
</fieldset> <Button
</form> disabled={busy}
</Dialog> 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> <style>
.page { .page {
@@ -527,7 +548,7 @@
} }
.roles { .roles {
display: grid; display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr)); grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 24px; gap: 24px;
padding: 24px; padding: 24px;
} }
+46 -7
View File
@@ -1,4 +1,6 @@
import { describe, expect, it } from 'vitest'; 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 type { UserRole } from '../../src/lib/auth.js';
import { import {
roleAllows, roleAllows,
@@ -6,11 +8,42 @@ import {
type RoutePermission type RoutePermission
} from '../../src/lib/server/auth/access.js'; } 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') => const allows = (role: UserRole, route: string | null, method = 'GET') =>
roleAllows(role, routePermission(route, method)); roleAllows(role, routePermission(route, method));
describe('management route permissions', () => { 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([ it.each([
'/', '/',
'/api/fleet', '/api/fleet',
@@ -77,6 +110,7 @@ describe('management route permissions', () => {
])('requires an operator for %s %s', (route, method) => { ])('requires an operator for %s %s', (route, method) => {
expect(routePermission(route, method)).toBe('operator'); expect(routePermission(route, method)).toBe('operator');
expect(allows('viewer', route, method)).toBe(false); expect(allows('viewer', route, method)).toBe(false);
expect(allows('super_viewer', route, method)).toBe(false);
expect(allows('operator', route, method)).toBe(true); expect(allows('operator', route, method)).toBe(true);
expect(allows('admin', route, method)).toBe(true); expect(allows('admin', route, method)).toBe(true);
}); });
@@ -89,14 +123,14 @@ describe('management route permissions', () => {
'/settings/users', '/settings/users',
'/api/settings', '/api/settings',
'/api/settings/retention', '/api/settings/retention',
'/api/settings/retention/preview',
'/server-keys', '/server-keys',
'/keys', '/keys',
'/api/server-keys', '/api/server-keys'
'/api/provisioning/[id]/override' ])('allows Super Viewers to read admin route %s without changing it', (route) => {
])('reserves all methods on %s for administrators', (route) => {
for (const method of ['GET', 'HEAD', 'POST', 'PUT', 'PATCH', 'DELETE']) { 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('viewer', route, method)).toBe(false);
expect(allows('operator', route, method)).toBe(false); expect(allows('operator', route, method)).toBe(false);
expect(allows('admin', route, method)).toBe(true); expect(allows('admin', route, method)).toBe(true);
@@ -104,6 +138,7 @@ describe('management route permissions', () => {
}); });
it.each([ it.each([
['/api/provisioning/[id]/override', 'POST'],
['/api/hosts', 'POST'], ['/api/hosts', 'POST'],
['/api/hosts/[id]', 'PATCH'], ['/api/hosts/[id]', 'PATCH'],
['/api/hosts/[id]', 'DELETE'], ['/api/hosts/[id]', 'DELETE'],
@@ -120,6 +155,7 @@ describe('management route permissions', () => {
['/api/plutonium', 'POST'] ['/api/plutonium', 'POST']
])('protects host, game-file, and global policy mutations at %s', (route, method) => { ])('protects host, game-file, and global policy mutations at %s', (route, method) => {
expect(routePermission(route, method)).toBe('admin'); expect(routePermission(route, method)).toBe('admin');
expect(allows('super_viewer', route, method)).toBe(false);
expect(allows('operator', route, method)).toBe(false); expect(allows('operator', route, method)).toBe(false);
expect(allows('admin', route, method)).toBe(true); expect(allows('admin', route, method)).toBe(true);
}); });
@@ -136,7 +172,8 @@ describe('management route permissions', () => {
'/api/servers/[name]/rcon-diagnostics' '/api/servers/[name]/rcon-diagnostics'
])('prevents viewer access to secret-bearing raw content at %s', (route) => { ])('prevents viewer access to secret-bearing raw content at %s', (route) => {
for (const method of ['GET', 'HEAD']) { 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('viewer', route, method)).toBe(false);
expect(allows('operator', route, method)).toBe(true); expect(allows('operator', route, method)).toBe(true);
} }
@@ -243,6 +280,8 @@ describe('authentication and machine boundaries', () => {
'authenticated', 'authenticated',
'viewer', 'viewer',
'operator', 'operator',
'operator-read',
'admin-read',
'admin', 'admin',
'machine' 'machine'
] as RoutePermission[]) { ] as RoutePermission[]) {
+48 -11
View File
@@ -234,10 +234,32 @@ describe('browser authentication guard', () => {
['operator', '/api/hosts/[id]', 'PATCH', 403], ['operator', '/api/hosts/[id]', 'PATCH', 403],
['operator', '/api/users', 'GET', 403], ['operator', '/api/users', 'GET', 403],
['admin', '/api/hosts/[id]', 'PATCH', 200], ['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) => { ] as const)('enforces %s role for %s %s', async (role, routeId, method, expectedStatus) => {
const session = signIn(role); 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(); const response = await call.invoke();
expect(response.status).toBe(expectedStatus); expect(response.status).toBe(expectedStatus);
if (expectedStatus === 200) { if (expectedStatus === 200) {
@@ -245,14 +267,28 @@ describe('browser authentication guard', () => {
expect(call.event.locals.user).toEqual(session.user); expect(call.event.locals.user).toEqual(session.user);
} else expect(call.resolve).not.toHaveBeenCalled(); } else expect(call.resolve).not.toHaveBeenCalled();
}); });
it.each(['GET', 'HEAD'])( it.each(['/settings', '/settings/users', '/server-keys', '/configs/[id]'])(
'restricts global settings %s requests to administrators', '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) => { async (method) => {
for (const role of ['viewer', 'operator', 'admin'] as const) { for (const role of ['viewer', 'super_viewer', 'operator', 'admin'] as const) {
signIn(role); signIn(role);
const call = requestFor('/api/settings', { method }); const call = requestFor('/api/settings', { method });
expect((await call.invoke()).status).toBe(role === 'admin' ? 200 : 403); const allowed =
if (role === 'admin') expect(call.resolve).toHaveBeenCalledOnce(); 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(); else expect(call.resolve).not.toHaveBeenCalled();
} }
} }
@@ -260,7 +296,7 @@ describe('browser authentication guard', () => {
it.each(['GET', 'HEAD'])( it.each(['GET', 'HEAD'])(
'restricts raw beta file %s requests to operators and administrators', 'restricts raw beta file %s requests to operators and administrators',
async (method) => { async (method) => {
for (const role of ['viewer', 'operator', 'admin'] as const) { for (const role of ['viewer', 'super_viewer', 'operator', 'admin'] as const) {
signIn(role); signIn(role);
const call = requestFor('/api/betas/[id]/revisions/[revision]/files/[scriptId]', { const call = requestFor('/api/betas/[id]/revisions/[revision]/files/[scriptId]', {
method, method,
@@ -282,11 +318,12 @@ describe('browser authentication guard', () => {
expect((await anonymous.invoke()).status).toBe(401); expect((await anonymous.invoke()).status).toBe(401);
expect(anonymous.resolve).not.toHaveBeenCalled(); 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); signIn(role);
const call = requestFor(route, { method }); const call = requestFor(route, { method });
expect((await call.invoke()).status).toBe(role === 'viewer' ? 403 : 200); const allowed = role === 'operator' || role === 'admin';
if (role === 'viewer') expect(call.resolve).not.toHaveBeenCalled(); expect((await call.invoke()).status).toBe(allowed ? 200 : 403);
if (!allowed) expect(call.resolve).not.toHaveBeenCalled();
else expect(call.resolve).toHaveBeenCalledOnce(); 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', () => { describe('administrator user management', () => {
it('rejects the reserved system username for every role while allowing distinct usernames', async () => { it('rejects the reserved system username for every role while allowing distinct usernames', async () => {
const admin = seedUser(); 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']) { for (const username of ['Maxis', 'maxis', 'MAXIS', 'mAxIs']) {
await expect( await expect(
auth.createUser(admin.id, { username, role, password }) 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 () => { it('creates each supported role with a forced initial password change and rejects case-insensitive duplicate names', async () => {
const admin = seedUser(); 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, { const user = await auth.createUser(admin.id, {
username: role + 'User', username: role + 'User',
displayName: ' ' + role + ' user ', displayName: ' ' + role + ' user ',
@@ -295,13 +295,14 @@ describe('administrator user management', () => {
await expect( await expect(
auth.createUser(admin.id, { username: 'OPERATORUSER', role: 'viewer', password }) auth.createUser(admin.id, { username: 'OPERATORUSER', role: 'viewer', password })
).rejects.toMatchObject({ status: 409 }); ).rejects.toMatchObject({ status: 409 });
expect(auth.listUsers()).toHaveLength(4); expect(auth.listUsers()).toHaveLength(5);
expect(JSON.stringify(auth.listAuthAudit())).not.toContain(password); 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 target = seedUser('target', 'viewer');
const actors = [ const actors = [
seedUser('superviewer', 'super_viewer'),
seedUser('operator', 'operator'), seedUser('operator', 'operator'),
seedUser('viewer', 'viewer'), seedUser('viewer', 'viewer'),
seedUser('disabledadmin', 'admin', { disabled: true }), 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 () => { it('preserves at least one enabled administrator and permits demotion only when another remains', async () => {
const first = seedUser(); const first = seedUser();
seedUser('superviewer', 'super_viewer');
const session = auth.createSession(first.id); const session = auth.createSession(first.id);
await expect(
auth.updateUser(first.id, first.id, { role: 'super_viewer' })
).rejects.toMatchObject({ status: 409 });
await expect( await expect(
auth.updateUser(first.id, first.id, { role: 'operator' }) auth.updateUser(first.id, first.id, { role: 'operator' })
).rejects.toMatchObject({ status: 409 }); ).rejects.toMatchObject({ status: 409 });
@@ -333,7 +338,8 @@ describe('administrator user management', () => {
); );
expect(auth.readSession(session.token)?.user.role).toBe('admin'); expect(auth.readSession(session.token)?.user.role).toBe('admin');
const second = seedUser('secondadmin'); 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(); expect(auth.readSession(session.token)).toBeNull();
await expect( await expect(
auth.updateUser(second.id, second.id, { disabled: true }) auth.updateUser(second.id, second.id, { disabled: true })
@@ -347,7 +353,8 @@ describe('administrator user management', () => {
const administratorSession = auth.createSession(admin.id); const administratorSession = auth.createSession(admin.id);
const first = auth.createSession(target.id), const first = auth.createSession(target.id),
second = 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(first.token)).toBeNull();
expect(auth.readSession(second.token)).toBeNull(); expect(auth.readSession(second.token)).toBeNull();
const third = auth.createSession(target.id); const third = auth.createSession(target.id);
+10
View File
@@ -8,6 +8,16 @@ import {
} from '../../src/lib/ui/command-palette.js'; } from '../../src/lib/ui/command-palette.js';
describe('command palette destinations', () => { 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', () => { it('keeps administrator destinations and operations out of inappropriate roles', () => {
const viewer = palettePageResults('viewer', ''); const viewer = palettePageResults('viewer', '');
expect( 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', () => { it('groups file releases together and retains Campaigns as its own destination', () => {
expect(navigationGroups.map((group) => group.label)).toEqual([ expect(navigationGroups.map((group) => group.label)).toEqual([
'Fleet', 'Fleet',
+4 -3
View File
@@ -377,7 +377,7 @@ it('never removes the calibration window when choosing a shorter resource retent
n: 0 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(); const a = await api();
a.db a.db
.prepare( .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.fleetSettings().deployKeepDays).toBe(0);
expect(a.db.prepare('SELECT id FROM deployments').get()).toEqual({ id: 'preview' }); expect(a.db.prepare('SELECT id FROM deployments').get()).toEqual({ id: 'preview' });
const { routePermission } = await import('../../src/lib/server/auth/access.js'); const { routePermission } = await import('../../src/lib/server/auth/access.js');
expect(routePermission('/api/settings/retention', 'GET')).toBe('admin'); expect(routePermission('/api/settings/retention', 'GET')).toBe('admin-read');
expect(routePermission('/api/settings/retention/preview', 'POST')).toBe('admin'); 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 () => { it('protects cancelled workflows and linked file history through ambiguous and unreconciled receipts', async () => {
const a = await api(); 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 { render } from 'svelte/server';
import { writable } from 'svelte/store'; import { writable } from 'svelte/store';
import { defaultPlan, requirement, type BetaTestView } from '../../src/lib/beta-plan.js'; import { defaultPlan, requirement, type BetaTestView } from '../../src/lib/beta-plan.js';
import { betaStatus, type BetaDeploymentServer } from '../../src/lib/ui/betas.js'; import { betaStatus, type BetaDeploymentServer } from '../../src/lib/ui/betas.js';
import BetaTestProgress from '../../src/lib/ui/components/BetaTestProgress.svelte'; 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', () => { it('prioritizes lifecycle, failures and pauses over readiness without treating stage text as failure', () => {
expect(betaStatus({ state: 'promoted', testOutcome: 'paused', servers: [] })).toMatchObject({ 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('Awaiting test run');
expect(body).not.toContain('0 / 0'); 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('Create server');
expect(body).not.toContain('Add host'); 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; }).body;
describe('application shell', () => { 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', () => { it('offers collapsible desktop navigation, a labelled mobile drawer, search and help', () => {
const body = shell(); const body = shell();
expect(body).toContain('id="desktop-navigation"'); expect(body).toContain('id="desktop-navigation"');
+34
View File
@@ -1,6 +1,7 @@
import { describe, expect, it, vi } from 'vitest'; import { describe, expect, it, vi } from 'vitest';
import { render } from 'svelte/server'; import { render } from 'svelte/server';
import { writable } from 'svelte/store'; import { writable } from 'svelte/store';
import type { AuthUser } from '../../src/lib/auth.js';
import type { ProvisioningDetail, RecoveryObservation } from '../../src/lib/provisioning-detail.js'; import type { ProvisioningDetail, RecoveryObservation } from '../../src/lib/provisioning-detail.js';
import { provisioningNeedsRefresh } from '../../src/lib/ui/provisioning-presentation.js'; import { provisioningNeedsRefresh } from '../../src/lib/ui/provisioning-presentation.js';
import ProvisioningPage from '../../src/routes/provisioning/[id]/+page.svelte'; import ProvisioningPage from '../../src/routes/provisioning/[id]/+page.svelte';
@@ -79,6 +80,39 @@ function pageBody(detail: ProvisioningDetail) {
props: { data: { detail, user: null, setupRequired: false } } props: { data: { detail, user: null, setupRequired: false } }
}).body; }).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 { function recovery(state: RecoveryObservation['state']): RecoveryObservation {
return { return {
state, 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'); 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', () => { it('uses the shared Activity view for server history', () => {
const body = pageBody('history'); const body = pageBody('history');
expect(body).toContain('Open in Activity'); 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('Expired data cannot be restored by increasing retention.');
expect(body).toContain('Player safety checks still apply.'); 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');
});
}); });