This commit is contained in:
@@ -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
@@ -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';
|
||||||
|
}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -165,6 +165,10 @@ export const authenticationHandle: Handle = async ({ event, resolve }) => {
|
|||||||
const message =
|
const message =
|
||||||
permission === 'admin'
|
permission === 'admin'
|
||||||
? 'Administrator access is required.'
|
? 'Administrator access is required.'
|
||||||
|
: 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.';
|
: 'Operator access is required for this action.';
|
||||||
if (api)
|
if (api)
|
||||||
return json(
|
return json(
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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;
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -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,8 +48,7 @@ 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'
|
|
||||||
? []
|
? []
|
||||||
: [
|
: [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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,7 +455,7 @@
|
|||||||
<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"
|
||||||
@@ -467,7 +472,7 @@
|
|||||||
>
|
>
|
||||||
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,15 +620,15 @@
|
|||||||
</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) => {
|
||||||
@@ -679,7 +684,8 @@
|
|||||||
aria-label="Scope change review"
|
aria-label="Scope change review"
|
||||||
>
|
>
|
||||||
<strong
|
<strong
|
||||||
>{scopeImpact.after.length} eligible · {scopeImpact.added.length} added ·
|
>{scopeImpact.after.length} eligible · {scopeImpact.added.length} added
|
||||||
|
·
|
||||||
{scopeImpact.removed.length} removed</strong
|
{scopeImpact.removed.length} removed</strong
|
||||||
>{#if scopeImpact.added.length}<p>
|
>{#if scopeImpact.added.length}<p>
|
||||||
Added: {scopeImpact.added.join(', ')}
|
Added: {scopeImpact.added.join(', ')}
|
||||||
@@ -764,8 +770,10 @@
|
|||||||
/>
|
/>
|
||||||
</Field>
|
</Field>
|
||||||
<div class="dlg-actions">
|
<div class="dlg-actions">
|
||||||
<Button variant="ghost" onclick={cancelUploadModal} disabled={uploadingVersion}
|
<Button
|
||||||
>Cancel</Button
|
variant="ghost"
|
||||||
|
onclick={cancelUploadModal}
|
||||||
|
disabled={uploadingVersion}>Cancel</Button
|
||||||
>
|
>
|
||||||
<Button
|
<Button
|
||||||
type="submit"
|
type="submit"
|
||||||
@@ -778,7 +786,7 @@
|
|||||||
</div>
|
</div>
|
||||||
{#if flash}<div class="alert alert-danger" role="alert">{flash}</div>{/if}
|
{#if flash}<div class="alert alert-danger" role="alert">{flash}</div>{/if}
|
||||||
</form>
|
</form>
|
||||||
</dialog>
|
</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
|
||||||
|
|||||||
@@ -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,9 +281,10 @@
|
|||||||
<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?"
|
||||||
@@ -290,8 +297,8 @@
|
|||||||
? 'Finish the active beta to save another version.'
|
? 'Finish the active beta to save another version.'
|
||||||
: 'A new version must pass through a beta before promotion.'}{#if ignoredWarnings.length}<strong
|
: 'A new version must pass through a beta before promotion.'}{#if ignoredWarnings.length}<strong
|
||||||
class="save-warning"
|
class="save-warning"
|
||||||
>{ignoredWarnings.length} ignored or incomplete lines — review the open configuration
|
>{ignoredWarnings.length} ignored or incomplete lines — review the open
|
||||||
notes before saving.</strong
|
configuration notes before saving.</strong
|
||||||
>{/if}</span
|
>{/if}</span
|
||||||
>
|
>
|
||||||
<Button variant="ghost" size="sm" onclick={discard} disabled={!dirty || saving}
|
<Button variant="ghost" size="sm" onclick={discard} disabled={!dirty || saving}
|
||||||
@@ -304,12 +311,12 @@
|
|||||||
disabled={!dirty || !text.trim() || runningBetaId !== null}
|
disabled={!dirty || !text.trim() || runningBetaId !== null}
|
||||||
>Save new version</Button
|
>Save new version</Button
|
||||||
>
|
>
|
||||||
</div>
|
</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}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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,13 +366,13 @@
|
|||||||
</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>
|
||||||
@@ -377,7 +381,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
onclick={() => dialog.close()}
|
onclick={() => dialog?.close()}
|
||||||
disabled={saving}
|
disabled={saving}
|
||||||
ariaLabel="Close new config">✕</Button
|
ariaLabel="Close new config">✕</Button
|
||||||
>
|
>
|
||||||
@@ -415,13 +419,15 @@
|
|||||||
required
|
required
|
||||||
disabled={saving}
|
disabled={saving}
|
||||||
><option value="" disabled>Choose a game</option
|
><option value="" disabled>Choose a game</option
|
||||||
>{#each gameOptions as option}<option value={option}>{gameTitle(option)}</option
|
>{#each gameOptions as option}<option value={option}
|
||||||
|
>{gameTitle(option)}</option
|
||||||
>{/each}</Select
|
>{/each}</Select
|
||||||
></Field
|
></Field
|
||||||
>
|
>
|
||||||
{#if kind === 'config'}<Field label="Apply to">
|
{#if kind === 'config'}<Field label="Apply to">
|
||||||
<Select aria-label="Apply to" bind:value={scopeType} disabled={saving}
|
<Select aria-label="Apply to" bind:value={scopeType} disabled={saving}
|
||||||
><option value="game">Whole game</option><option value="map">One map</option
|
><option value="game">Whole game</option><option value="map"
|
||||||
|
>One map</option
|
||||||
></Select
|
></Select
|
||||||
></Field
|
></Field
|
||||||
>{#if scopeType === 'map'}<Field label="Map">
|
>{#if scopeType === 'map'}<Field label="Map">
|
||||||
@@ -438,7 +444,8 @@
|
|||||||
>{/if}{/if}
|
>{/if}{/if}
|
||||||
</div>
|
</div>
|
||||||
{#if baselineExists}<p class="warning">
|
{#if baselineExists}<p class="warning">
|
||||||
This game already has a baseline. Open it from the library to save a new version.
|
This game already has a baseline. Open it from the library to save a new
|
||||||
|
version.
|
||||||
</p>{/if}
|
</p>{/if}
|
||||||
{#if !gameOptions.length}<p class="warning">
|
{#if !gameOptions.length}<p class="warning">
|
||||||
Register a server first to make its game and map available here.
|
Register a server first to make its game and map available here.
|
||||||
@@ -462,7 +469,8 @@
|
|||||||
{#if createError}<div class="error" role="alert">{createError}</div>{/if}
|
{#if createError}<div class="error" role="alert">{createError}</div>{/if}
|
||||||
<div class="dialog-actions">
|
<div class="dialog-actions">
|
||||||
<p>Saved as untested. Test in a beta before promotion.</p>
|
<p>Saved as untested. Test in a beta before promotion.</p>
|
||||||
<Button variant="ghost" onclick={() => dialog.close()} disabled={saving}>Cancel</Button
|
<Button variant="ghost" onclick={() => dialog?.close()} disabled={saving}
|
||||||
|
>Cancel</Button
|
||||||
><Button
|
><Button
|
||||||
type="submit"
|
type="submit"
|
||||||
variant="primary"
|
variant="primary"
|
||||||
@@ -472,7 +480,7 @@
|
|||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</dialog>
|
</dialog>{/if}
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
.page {
|
.page {
|
||||||
|
|||||||
@@ -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,9 +101,13 @@
|
|||||||
/>
|
/>
|
||||||
</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
|
||||||
@@ -118,9 +127,10 @@
|
|||||||
><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}
|
||||||
|
|||||||
@@ -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={() =>
|
||||||
|
control({ protected: true }, policy.category, record.id)}
|
||||||
>Protect</Button
|
>Protect</Button
|
||||||
>
|
>{/if}
|
||||||
</div>{/each}
|
</div>{/each}
|
||||||
</details>
|
</details>
|
||||||
{/each}
|
{/each}
|
||||||
@@ -131,17 +133,17 @@
|
|||||||
? ' · 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">
|
||||||
@@ -159,7 +161,7 @@
|
|||||||
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>
|
||||||
|
|||||||
@@ -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,12 +353,12 @@
|
|||||||
<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
|
||||||
@@ -372,8 +373,8 @@
|
|||||||
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">
|
||||||
@@ -387,7 +388,7 @@
|
|||||||
>{saving ? 'Saving…' : 'Save changes'}</button
|
>{saving ? 'Saving…' : 'Save changes'}</button
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>{/if}
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
.settings-layout {
|
.settings-layout {
|
||||||
|
|||||||
@@ -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,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
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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,7 +700,7 @@
|
|||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<BetaRevisionDialog
|
{#if editable}<BetaRevisionDialog
|
||||||
bind:open={updateOpen}
|
bind:open={updateOpen}
|
||||||
betaId={id}
|
betaId={id}
|
||||||
revision={updateRevision}
|
revision={updateRevision}
|
||||||
@@ -702,7 +708,7 @@
|
|||||||
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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -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,7 +352,7 @@
|
|||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
<p>{policy.description}</p>
|
<p>{policy.description}</p>
|
||||||
<Button
|
{#if administrator}<Button
|
||||||
disabled={busy}
|
disabled={busy}
|
||||||
onclick={() =>
|
onclick={() =>
|
||||||
act({
|
act({
|
||||||
@@ -359,7 +362,7 @@
|
|||||||
})}
|
})}
|
||||||
>{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={() =>
|
||||||
|
|||||||
@@ -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,10 +46,12 @@
|
|||||||
)
|
)
|
||||||
);
|
);
|
||||||
const canRetry = $derived(
|
const canRetry = $derived(
|
||||||
|
operator &&
|
||||||
!incident?.server_deleted_at &&
|
!incident?.server_deleted_at &&
|
||||||
(incident?.state === 'attention' || incident?.state === 'dismissed')
|
(incident?.state === 'attention' || incident?.state === 'dismissed')
|
||||||
);
|
);
|
||||||
const canDismiss = $derived(
|
const canDismiss = $derived(
|
||||||
|
operator &&
|
||||||
!incident?.server_deleted_at &&
|
!incident?.server_deleted_at &&
|
||||||
(incident?.state === 'open' || incident?.state === 'attention')
|
(incident?.state === 'open' || incident?.state === 'attention')
|
||||||
);
|
);
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -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,7 +452,7 @@
|
|||||||
</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 },
|
||||||
@@ -487,7 +490,11 @@
|
|||||||
/>
|
/>
|
||||||
</Field>
|
</Field>
|
||||||
<Field label="Notes" hint="Optional. What changed in this version.">
|
<Field label="Notes" hint="Optional. What changed in this version.">
|
||||||
<Input bind:value={newNotes} placeholder="what changed" disabled={uploading} />
|
<Input
|
||||||
|
bind:value={newNotes}
|
||||||
|
placeholder="what changed"
|
||||||
|
disabled={uploading}
|
||||||
|
/>
|
||||||
</Field>
|
</Field>
|
||||||
<Field label="Scope" hint="Where this plugin deploys.">
|
<Field label="Scope" hint="Where this plugin deploys.">
|
||||||
<div class="scope-row">
|
<div class="scope-row">
|
||||||
@@ -533,7 +540,7 @@
|
|||||||
</form>
|
</form>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</Dialog>
|
</Dialog>{/if}
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
.page {
|
.page {
|
||||||
|
|||||||
@@ -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,7 +370,7 @@
|
|||||||
<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) => {
|
||||||
@@ -377,7 +379,7 @@
|
|||||||
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}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,7 +298,7 @@
|
|||||||
<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"
|
||||||
@@ -301,15 +306,16 @@
|
|||||||
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`}
|
||||||
@@ -317,7 +323,7 @@
|
|||||||
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 {
|
||||||
|
|||||||
@@ -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,7 +257,7 @@
|
|||||||
{/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">
|
||||||
@@ -289,7 +294,7 @@
|
|||||||
><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 {
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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,7 +307,7 @@
|
|||||||
/>
|
/>
|
||||||
</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
|
||||||
@@ -301,7 +316,7 @@
|
|||||||
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,12 +370,12 @@
|
|||||||
{/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}
|
||||||
@@ -380,8 +395,8 @@
|
|||||||
/></Field
|
/></Field
|
||||||
>
|
>
|
||||||
<p class="hint" id="new-username-help">
|
<p class="hint" id="new-username-help">
|
||||||
3–64 characters: letters, numbers, periods, underscores, and hyphens. Start with a
|
3–64 characters: letters, numbers, periods, underscores, and hyphens. Start with
|
||||||
letter or number.
|
a letter or number.
|
||||||
</p>
|
</p>
|
||||||
<Field forId="new-name" label="Display name">
|
<Field forId="new-name" label="Display name">
|
||||||
<Input
|
<Input
|
||||||
@@ -394,7 +409,11 @@
|
|||||||
/></Field
|
/></Field
|
||||||
>
|
>
|
||||||
<Field forId="new-role" label="Role">
|
<Field forId="new-role" label="Role">
|
||||||
<Select id="new-role" name="role" bind:value={role} aria-describedby="new-role-help"
|
<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 roles as item}<option value={item.value}>{item.label}</option
|
||||||
>{/each}</Select
|
>{/each}</Select
|
||||||
></Field
|
></Field
|
||||||
@@ -414,7 +433,8 @@
|
|||||||
/></Field
|
/></Field
|
||||||
>
|
>
|
||||||
<p class="hint" id="temporary-help">
|
<p class="hint" id="temporary-help">
|
||||||
8–128 characters. Save it before adding the user; it will not be displayed again.
|
8–128 characters. Save it before adding the user; it will not be displayed
|
||||||
|
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
|
||||||
@@ -433,14 +453,14 @@
|
|||||||
</div>
|
</div>
|
||||||
</fieldset>
|
</fieldset>
|
||||||
</form>
|
</form>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|
||||||
<Dialog
|
<Dialog
|
||||||
bind:open={editOpen}
|
bind:open={editOpen}
|
||||||
title={`Edit ${editing?.username ?? 'user'}`}
|
title={`Edit ${editing?.username ?? 'user'}`}
|
||||||
description="Update access or set a new temporary password."
|
description="Update access or set a new temporary password."
|
||||||
{busy}
|
{busy}
|
||||||
>
|
>
|
||||||
<form onsubmit={save} aria-busy={busy}>
|
<form onsubmit={save} 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}
|
||||||
@@ -472,8 +492,8 @@
|
|||||||
Disabled users cannot sign in. Running game servers continue operating.
|
Disabled users cannot sign in. Running game servers continue operating.
|
||||||
</p>
|
</p>
|
||||||
{#if editing?.id === $page.data.user?.id}<p class="hint">
|
{#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
|
Use <a href="/account">My account</a> to change your own password. Changes to
|
||||||
access can sign you out.
|
your access can sign you out.
|
||||||
</p>
|
</p>
|
||||||
{:else}<Field forId="reset-password"
|
{:else}<Field forId="reset-password"
|
||||||
>{#snippet label()}New temporary password <span class="optional"
|
>{#snippet label()}New temporary password <span class="optional"
|
||||||
@@ -491,11 +511,12 @@
|
|||||||
/></Field
|
/></Field
|
||||||
>
|
>
|
||||||
<p class="hint" id="reset-help">
|
<p class="hint" id="reset-help">
|
||||||
Leave blank to keep the current password. A reset requires the user to choose a
|
Leave blank to keep the current password. A reset requires the user to
|
||||||
new password at their next sign-in.
|
choose a new password at their next sign-in.
|
||||||
</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}
|
>{/if}
|
||||||
<div class="form-actions">
|
<div class="form-actions">
|
||||||
<Button
|
<Button
|
||||||
@@ -511,7 +532,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</fieldset>
|
</fieldset>
|
||||||
</form>
|
</form>
|
||||||
</Dialog>
|
</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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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[]) {
|
||||||
|
|||||||
@@ -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();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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 & schedules');
|
||||||
|
expect(body).toContain('Activity');
|
||||||
|
expect(body).not.toContain('Check now');
|
||||||
|
|
||||||
|
asRole('admin');
|
||||||
|
expect(render(MaintenancePage).body).toContain('Check now');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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');
|
||||||
|
});
|
||||||
|
|||||||
@@ -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');
|
||||||
|
});
|
||||||
|
|||||||
@@ -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"');
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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');
|
||||||
|
});
|
||||||
@@ -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');
|
||||||
|
|||||||
@@ -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');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user