feat: add prelaunch site controls and refresh branding
Validate and deploy production / validate (push) Successful in 23s
Validate and deploy staging / validate (push) Successful in 19s
Validate and deploy production / deploy (push) Successful in 1m0s
Validate and deploy staging / deploy (push) Successful in 36s

This commit is contained in:
JB
2026-07-27 02:24:13 -04:00
parent 7b3aff30d8
commit 5a67b252aa
37 changed files with 1738 additions and 100 deletions
+1
View File
@@ -40,6 +40,7 @@
];
const visibleOversightSections = $derived(oversightSections.filter((section) => can(section.permission)));
const systemSections = [
{ href: '/isac/site-controls', label: 'Site controls', permission: 'site.settings.manage' },
{ href: '/isac/images', label: 'Image library', permission: 'images.manage' },
{ href: '/isac/system-messages', label: 'Automated messages', permission: 'system.notifications.manage' },
{ href: '/isac/metrics', label: 'System metrics', permission: 'metrics.view' }
+1
View File
@@ -9,6 +9,7 @@ export const load: PageServerLoad = async ({ cookies }) => {
if (permissions.includes('users.roles')) redirect(303, '/isac/roles');
if (permissions.includes('*') || ['data.view','data.manage','data.propose','data.approve'].some((permission) => permissions.includes(permission))) redirect(303, '/isac/configuration');
if (permissions.includes('audit.view')) redirect(303, '/isac/audit');
if (permissions.includes('site.settings.manage')) redirect(303, '/isac/site-controls');
if (permissions.includes('images.manage')) redirect(303, '/isac/images');
if (permissions.includes('system.notifications.manage')) redirect(303, '/isac/system-messages');
if (permissions.includes('metrics.view')) redirect(303, '/isac/metrics');
+7 -2
View File
@@ -30,10 +30,15 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
}
}, 'name');
const permissionPrefix = filter === 'isac' ? 'isac.%' : filter === 'data' ? 'data.%' : filter === 'users' ? 'users.%' : filter === 'audit' ? 'audit.%' : filter === 'system' ? 'system.%' : '';
const relatedPermissionFilter = filter === 'data'
? " OR p.name LIKE 'calculation.%'"
: filter === 'system'
? " OR p.name LIKE 'site.%' OR p.name LIKE 'metrics.%' OR p.name LIKE 'images.%'"
: '';
const conditions = [
...(permissionPrefix ? [`EXISTS (
SELECT 1 FROM role_permissions rp JOIN permissions p ON p.id=rp.permission_id
WHERE rp.role_id=roles.id AND (p.name LIKE ?${filter === 'data' ? " OR p.name LIKE 'calculation.%'" : ''})
WHERE rp.role_id=roles.id AND (p.name LIKE ?${relatedPermissionFilter})
)`] : []),
...(search ? ["roles.name LIKE ? ESCAPE '\\' COLLATE NOCASE"] : [])
];
@@ -45,7 +50,7 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
SUM(CASE WHEN EXISTS (SELECT 1 FROM role_permissions rp JOIN permissions p ON p.id=rp.permission_id WHERE rp.role_id=roles.id AND (p.name LIKE 'data.%' OR p.name LIKE 'calculation.%')) THEN 1 ELSE 0 END) AS data,
SUM(CASE WHEN EXISTS (SELECT 1 FROM role_permissions rp JOIN permissions p ON p.id=rp.permission_id WHERE rp.role_id=roles.id AND p.name LIKE 'users.%') THEN 1 ELSE 0 END) AS users,
SUM(CASE WHEN EXISTS (SELECT 1 FROM role_permissions rp JOIN permissions p ON p.id=rp.permission_id WHERE rp.role_id=roles.id AND p.name LIKE 'audit.%') THEN 1 ELSE 0 END) AS audit
,SUM(CASE WHEN EXISTS (SELECT 1 FROM role_permissions rp JOIN permissions p ON p.id=rp.permission_id WHERE rp.role_id=roles.id AND p.name LIKE 'system.%') THEN 1 ELSE 0 END) AS system
,SUM(CASE WHEN EXISTS (SELECT 1 FROM role_permissions rp JOIN permissions p ON p.id=rp.permission_id WHERE rp.role_id=roles.id AND (p.name LIKE 'system.%' OR p.name LIKE 'site.%' OR p.name LIKE 'metrics.%' OR p.name LIKE 'images.%')) THEN 1 ELSE 0 END) AS system
FROM roles`)).get(),
(await database.prepare(`SELECT COUNT(*) AS total FROM roles ${where}`)).get(...filterArgs),
(await database.prepare(`SELECT id,name,description FROM permissions
@@ -0,0 +1,83 @@
import { randomUUID } from 'node:crypto';
import { fail, error } from '@sveltejs/kit';
import { requirePermission } from '$lib/server/auth/authorization';
import { getSessionUser } from '$lib/server/auth/session';
import { getDatabase } from '$lib/server/database';
import { recordIsacAudit } from '$lib/server/audit';
import { invalidateSiteAccessSettings } from '$lib/server/site-access';
import type { Actions, PageServerLoad } from './$types';
const SETTINGS_ID = 'primary';
const CONFIRMATION = 'apply-site-access-settings';
const settingSnapshot = async () => {
const database = await getDatabase();
return await (await database.prepare(
'SELECT * FROM site_launch_settings WHERE id=?'
)).get(SETTINGS_ID) ?? null;
};
const flagNames = [
'comingSoonEnabled', 'accountCreationEnabled', 'newsEnabled', 'databaseEnabled',
'buildsEnabled', 'guidesEnabled', 'builderEnabled'
] as const;
const enabled = (form: FormData, name: (typeof flagNames)[number]) =>
String(form.get(name)) === '1' ? 1 : 0;
export const load: PageServerLoad = async ({ cookies }) => {
await requirePermission(await getSessionUser(cookies), 'site.settings.manage');
const database = await getDatabase();
const settings = await (await database.prepare(`SELECT settings.*,
COALESCE(profile.display_name,user.username) AS updated_by_name
FROM site_launch_settings settings
LEFT JOIN users user ON user.id=settings.updated_by
LEFT JOIN user_profiles profile ON profile.user_id=user.id
WHERE settings.id=?`)).get(SETTINGS_ID);
if (!settings) error(500, 'Site launch settings are not initialized.');
return { settings };
};
export const actions: Actions = {
save: async ({ cookies, request }) => {
const operator = await requirePermission(await getSessionUser(cookies), 'site.settings.manage');
const form = await request.formData();
if (String(form.get('confirmation') ?? '') !== CONFIRMATION) {
return fail(400, { siteSettingsError: 'Review and confirm the complete site access profile before applying it.' });
}
if (flagNames.some((name) => !['0', '1'].includes(String(form.get(name))))) {
return fail(400, { siteSettingsError: 'Every site access control must have an explicit enabled or disabled state.' });
}
const before = await settingSnapshot();
if (!before) return fail(404, { siteSettingsError: 'Site launch settings are not initialized.' });
const next = {
comingSoonEnabled: enabled(form, 'comingSoonEnabled'),
accountCreationEnabled: enabled(form, 'accountCreationEnabled'),
newsEnabled: enabled(form, 'newsEnabled'),
databaseEnabled: enabled(form, 'databaseEnabled'),
buildsEnabled: enabled(form, 'buildsEnabled'),
guidesEnabled: enabled(form, 'guidesEnabled'),
builderEnabled: enabled(form, 'builderEnabled')
};
const database = await getDatabase();
const updatedAt = new Date().toISOString();
await (await database.prepare(`UPDATE site_launch_settings SET
coming_soon_enabled=?,account_creation_enabled=?,news_enabled=?,database_enabled=?,
builds_enabled=?,guides_enabled=?,builder_enabled=?,updated_at=?,updated_by=?
WHERE id=?`)).run(
next.comingSoonEnabled, next.accountCreationEnabled, next.newsEnabled, next.databaseEnabled,
next.buildsEnabled, next.guidesEnabled, next.builderEnabled, updatedAt, operator.id, SETTINGS_ID
);
invalidateSiteAccessSettings();
const after = await settingSnapshot();
await recordIsacAudit({
actorId: operator.id,
category: 'system_configuration',
action: 'site_access.updated',
entityId: SETTINGS_ID,
before,
after
});
return { siteSettingsSaved: true, resultToken: randomUUID() };
}
};
+163
View File
@@ -0,0 +1,163 @@
<script lang="ts">
import PendingForm from '$lib/components/PendingForm.svelte';
import SegmentedChoice from '$lib/components/SegmentedChoice.svelte';
let { data, form } = $props();
const binaryOptions = [
{ value: '1', label: 'ENABLED' },
{ value: '0', label: 'DISABLED' }
];
const launchOptions = [
{ value: '1', label: 'COMING SOON' },
{ value: '0', label: 'PUBLIC LAUNCH' }
];
const sectionDefinitions = [
{ key: 'news', label: 'NEWS', description: 'Field reports, updates, and community reporting.' },
{ key: 'database', label: 'DATABASE', description: 'Public game-data registries and tactical lookup.' },
{ key: 'builds', label: 'BUILDS', description: 'Published loadout discovery and shared build views.' },
{ key: 'guides', label: 'GUIDES', description: 'Long-form field manuals and build walkthroughs.' },
{ key: 'builder', label: 'BUILDER', description: 'Loadout creation, calculation, and account saves.' }
] as const;
const settingValue = (key: string) => Number(data.settings[key]) ? '1' : '0';
let comingSoonEnabled = $state(settingValue('coming_soon_enabled'));
let accountCreationEnabled = $state(settingValue('account_creation_enabled'));
let sectionValues = $state<Record<string, string>>(Object.fromEntries(
sectionDefinitions.map((section) => [section.key, settingValue(`${section.key}_enabled`)])
));
let reviewing = $state(false);
let synchronizedAt = $state('');
let handledResultToken = $state('');
const synchronize = () => {
comingSoonEnabled = settingValue('coming_soon_enabled');
accountCreationEnabled = settingValue('account_creation_enabled');
sectionValues = Object.fromEntries(sectionDefinitions.map((section) => [
section.key,
settingValue(`${section.key}_enabled`)
]));
};
$effect(() => {
const updatedAt = String(data.settings.updated_at);
if (updatedAt === synchronizedAt) return;
synchronizedAt = updatedAt;
synchronize();
});
$effect(() => {
if (!form?.siteSettingsSaved || !form.resultToken || form.resultToken === handledResultToken) return;
handledResultToken = form.resultToken;
reviewing = false;
});
const currentValue = (key: string) => Number(data.settings[key]) ? 'ENABLED' : 'DISABLED';
const proposedValue = (value: string) => value === '1' ? 'ENABLED' : 'DISABLED';
const changes = $derived.by(() => {
const rows = [
{
label: 'LAUNCH GATE',
before: Number(data.settings.coming_soon_enabled) ? 'COMING SOON' : 'PUBLIC LAUNCH',
after: comingSoonEnabled === '1' ? 'COMING SOON' : 'PUBLIC LAUNCH'
},
{
label: 'ACCOUNT CREATION',
before: currentValue('account_creation_enabled'),
after: proposedValue(accountCreationEnabled)
},
...sectionDefinitions.map((section) => ({
label: section.label,
before: currentValue(`${section.key}_enabled`),
after: proposedValue(sectionValues[section.key] ?? '0')
}))
];
return rows.filter((row) => row.before !== row.after);
});
const date = (value: unknown) => value
? new Intl.DateTimeFormat(undefined, { dateStyle: 'medium', timeStyle: 'short' }).format(new Date(String(value)))
: 'INITIAL CONFIGURATION';
</script>
<svelte:head><title>Site controls — ISAC</title></svelte:head>
<div class="module-toolbar site-control-toolbar">
<div>
<p class="eyebrow">SYSTEM CONFIGURATION / RELEASE CONTROL</p>
<h2>SITE CONTROLS</h2>
<p>Coordinate public launch state, account registration, and access to major SHD.build sections.</p>
</div>
<div class="site-control-state">
<span>NETWORK STATE</span>
<strong class:restricted={comingSoonEnabled === '1'}>{comingSoonEnabled === '1' ? 'COMING SOON' : 'PUBLIC'}</strong>
<small>LAST UPDATED {date(data.settings.updated_at)}</small>
</div>
</div>
<div class="site-control-grid">
<section class="site-control-panel launch-control">
<header><div><span>01 / LAUNCH GATE</span><h3>PUBLIC NETWORK STATE</h3></div><b class:restricted={comingSoonEnabled === '1'}>{comingSoonEnabled === '1' ? 'GATED' : 'LIVE'}</b></header>
<p>The Coming Soon gate takes priority over individual sections for signed-out visitors. Signed-in agents bypass the launch gate, but section availability still applies.</p>
<SegmentedChoice legend="LAUNCH STATE" name="launchStateDraft" options={launchOptions} bind:value={comingSoonEnabled}/>
</section>
<section class="site-control-panel account-control">
<header><div><span>02 / IDENTITY INTAKE</span><h3>ACCOUNT CREATION</h3></div><b class:restricted={accountCreationEnabled === '0'}>{accountCreationEnabled === '1' ? 'OPEN' : 'CLOSED'}</b></header>
<p>Controls creation of new accounts across supported registration methods. Existing agents retain their configured sign-in methods.</p>
<SegmentedChoice legend="REGISTRATION STATE" name="accountCreationDraft" options={binaryOptions} bind:value={accountCreationEnabled}/>
</section>
<section class="site-control-panel public-sections">
<header><div><span>03 / PUBLIC SECTIONS</span><h3>SECTION AVAILABILITY</h3></div><b>{sectionDefinitions.filter((section) => sectionValues[section.key] === '1').length} / {sectionDefinitions.length} ONLINE</b></header>
<p>Section controls apply after the Launch Gate. Disabled sections disappear from navigation and reject direct access and mutations for every agent.</p>
<div class="section-control-list">
{#each sectionDefinitions as section}
<article>
<div><strong>{section.label}</strong><small>{section.description}</small></div>
<SegmentedChoice legend={`${section.label} STATE`} name={`${section.key}StateDraft`} options={binaryOptions} bind:value={sectionValues[section.key]}/>
</article>
{/each}
</div>
</section>
</div>
{#if form?.siteSettingsError}<p class="error">{form.siteSettingsError}</p>{/if}
<div class="site-control-actions">
<div><span>CHANGE SET</span><b>{changes.length ? `${changes.length} PENDING` : 'SYNCHRONIZED'}</b><small>UPDATED BY {data.settings.updated_by_name ?? 'SYSTEM BOOTSTRAP'}</small></div>
<div class="site-control-action-buttons">
<a class="secondary" href="/coming-soon">PREVIEW PAGE ↗</a>
<button class="primary" disabled={!changes.length} onclick={() => reviewing = true}>REVIEW CHANGES →</button>
</div>
</div>
{#if reviewing}
<div class="isac-modal-backdrop" role="presentation">
<div class="isac-confirm-modal site-control-review" role="dialog" aria-modal="true" aria-labelledby="site-control-review-title">
<div class="editor-title">
<div><p class="eyebrow">CONFIRM SITE ACCESS PROFILE</p><h3 id="site-control-review-title">{changes.length} OPERATIONAL CHANGE{changes.length === 1 ? '' : 'S'}</h3></div>
<button type="button" aria-label="Close site control review" onclick={() => reviewing = false}>×</button>
</div>
<p>These settings change which public SHD.build services agents can reach. Review the full change set before deployment.</p>
<div class="site-control-change-list">
{#each changes as change}
<div><strong>{change.label}</strong><span>{change.before}</span><b>→</b><span class:restricted={change.after === 'DISABLED' || change.after === 'COMING SOON'}>{change.after}</span></div>
{/each}
</div>
<PendingForm action="?/save" successMessage="Site access settings updated.">
{#snippet children(pending)}
<input type="hidden" name="confirmation" value="apply-site-access-settings"/>
<input type="hidden" name="comingSoonEnabled" value={comingSoonEnabled}/>
<input type="hidden" name="accountCreationEnabled" value={accountCreationEnabled}/>
{#each sectionDefinitions as section}<input type="hidden" name={`${section.key}Enabled`} value={sectionValues[section.key]}/>{/each}
<div class="form-actions">
<button type="button" class="secondary" onclick={() => reviewing = false} disabled={pending}>CANCEL</button>
<button class="primary" disabled={pending || !changes.length} aria-busy={pending}>{pending ? 'APPLYING PROFILE…' : 'CONFIRM SITE CONTROLS →'}</button>
</div>
{/snippet}
</PendingForm>
</div>
</div>
{/if}
<style>
.site-control-toolbar{align-items:stretch}.site-control-state{display:grid;align-content:center;min-width:230px;padding:10px 14px;border:1px solid var(--shd-border);border-left:2px solid var(--shd-orange);background:var(--shd-surface)}.site-control-state span,.site-control-state small,.site-control-panel header span,.site-control-actions span,.site-control-actions small{color:var(--shd-text-muted);font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.8px}.site-control-state strong{color:var(--shd-success);font-size:21px}.site-control-state strong.restricted,.site-control-panel b.restricted,.site-control-change-list span.restricted{color:var(--shd-danger)}.site-control-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:8px}.site-control-panel{display:grid;align-content:start;gap:10px;padding:12px;border:1px solid var(--shd-border);background:var(--shd-surface)}.site-control-panel>header{display:flex;justify-content:space-between;gap:12px;align-items:start;padding-bottom:9px;border-bottom:1px solid var(--shd-border)}.site-control-panel h3,.site-control-panel p{margin:0}.site-control-panel>p{color:var(--shd-text-muted);font-size:var(--shd-type-supporting)}.site-control-panel header b{color:var(--shd-success);font-size:var(--shd-type-supporting);letter-spacing:.8px}.public-sections{grid-column:1/-1}.section-control-list{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:7px}.section-control-list article{display:grid;grid-template-columns:minmax(0,1fr) minmax(220px,.8fr);gap:10px;align-items:center;padding:9px;border:1px solid var(--shd-border);background:#0d1011}.section-control-list article>div{display:grid;gap:2px}.section-control-list small{color:var(--shd-text-muted)}.site-control-actions{display:flex;justify-content:space-between;align-items:center;gap:12px;margin-top:9px;padding:10px;border:1px solid var(--shd-border);background:var(--shd-surface)}.site-control-actions>div:first-child{display:grid}.site-control-action-buttons{display:flex;gap:7px}.site-control-action-buttons>a{display:flex;align-items:center}.site-control-actions b{color:var(--shd-orange)}.site-control-review>p{margin:0;padding:12px;color:var(--shd-text-muted)}.site-control-change-list{display:grid;gap:4px;padding:0 12px 12px}.site-control-change-list>div{display:grid;grid-template-columns:minmax(140px,1fr) minmax(110px,.7fr) 28px minmax(110px,.7fr);gap:7px;align-items:center;padding:8px;border:1px solid var(--shd-border);background:#0d1011}.site-control-change-list>div>b{text-align:center;color:var(--shd-orange)}.site-control-change-list span:last-child{font-weight:900}.site-control-review .form-actions{padding:12px;border-top:1px solid var(--shd-border)}
@media(max-width:900px){.site-control-grid{grid-template-columns:1fr}.public-sections{grid-column:auto}.section-control-list{grid-template-columns:1fr}}
@media(max-width:650px){.site-control-state{min-width:0}.section-control-list article{grid-template-columns:1fr}.site-control-actions{display:grid}.site-control-action-buttons{display:grid;grid-template-columns:1fr}.site-control-action-buttons>*{width:100%;justify-content:center}.site-control-change-list>div{grid-template-columns:1fr auto 1fr}.site-control-change-list strong{grid-column:1/-1}.site-control-change-list span:last-child{text-align:right}}
</style>