feat(isac): add moderated contributor mode
This commit is contained in:
@@ -3,17 +3,22 @@ import { getPermissions } from '$lib/server/auth/authorization';
|
||||
import { redirect } from '@sveltejs/kit';
|
||||
import { getDatabase } from '$lib/server/database';
|
||||
import type { LayoutServerLoad } from './$types';
|
||||
import { getContributorMode } from '$lib/server/contributor-mode';
|
||||
export const load: LayoutServerLoad = async ({ cookies }) => {
|
||||
const user = await getSessionUser(cookies);
|
||||
if (!user) redirect(303, '/sign-in');
|
||||
const permissions = await getPermissions(user);
|
||||
if (!permissions.includes('*') && !permissions.includes('isac.access')) redirect(303, '/account');
|
||||
const canSeeData = permissions.includes('*') || ['data.view','data.manage','data.propose','data.approve'].some((permission) => permissions.includes(permission));
|
||||
const canManageData = permissions.includes('*') || permissions.includes('data.manage');
|
||||
const canProposeData = permissions.includes('*') || permissions.includes('data.propose');
|
||||
const contributorMode = !canManageData && canProposeData ? await getContributorMode(user.id) : null;
|
||||
const pendingRows = canSeeData
|
||||
? await (await (await getDatabase()).prepare("SELECT module,COUNT(*) AS count FROM data_change_proposals WHERE status='pending' GROUP BY module")).all()
|
||||
: [];
|
||||
return {
|
||||
permissions,
|
||||
contributorMode,
|
||||
pendingProposals: Object.fromEntries(pendingRows.map((row) => [String(row.module), Number(row.count)])),
|
||||
pendingProposalTotal: pendingRows.reduce((total, row) => total + Number(row.count), 0)
|
||||
};
|
||||
|
||||
@@ -3,7 +3,11 @@
|
||||
let { data, children } = $props();
|
||||
const can = (permission: string) => data.permissions.includes('*') || data.permissions.includes(permission);
|
||||
const canSeeData = $derived(['data.view', 'data.manage', 'data.propose', 'data.approve'].some(can));
|
||||
const dataReadOnly = $derived(can('data.view') && !can('data.manage') && !can('data.propose'));
|
||||
const contributorEnabled = $derived(Boolean(data.contributorMode?.enabled));
|
||||
const guidelinesPage = $derived(page.url.pathname === '/admin/contributor-guidelines');
|
||||
const dataReadOnly = $derived(can('data.view') && !can('data.manage') && (!can('data.propose') || !contributorEnabled) && !guidelinesPage);
|
||||
const dataMode = $derived(can('*') ? 'ADMIN' : can('data.manage') ? 'DATA MANAGER' : can('data.approve') ? 'APPROVER' : contributorEnabled ? 'CONTRIBUTOR' : 'READ-ONLY');
|
||||
const dataModeClass = $derived(dataMode.toLowerCase().replace(' ', '-'));
|
||||
let readOnlyBlocked = $state(false);
|
||||
const dataSections = [
|
||||
{ href: '/admin/stats', label: 'Stats & attributes', code: 'ATTR', module: 'stats' },
|
||||
@@ -29,7 +33,7 @@
|
||||
<main class="admin-page">
|
||||
<header class="admin-command">
|
||||
<div class="command-title"><p class="eyebrow"><span></span> INTELLIGENT SYSTEM ANALYTIC COMPUTER</p><h1>ISAC</h1><p>SHD.build operations and intelligence control surface</p></div>
|
||||
<div class="command-status"><div><i></i><span>SYSTEM</span><strong>ONLINE</strong></div><div><span>ACCESS</span><strong>AUTHORIZED</strong></div><div><span>ENVIRONMENT</span><strong>LOCAL</strong></div></div>
|
||||
<div class="command-status"><div><i></i><span>SYSTEM</span><strong>ONLINE</strong></div><div><span>ACCESS</span><strong>AUTHORIZED</strong></div><div><span>ENVIRONMENT</span><strong>LOCAL</strong></div><div class="data-mode-status" class:admin={dataModeClass === 'admin'} class:data-manager={dataModeClass === 'data-manager'} class:approver={dataModeClass === 'approver'} class:contributor={dataModeClass === 'contributor'} class:read-only={dataModeClass === 'read-only'}><span>DATA MODE</span><strong>{dataMode}</strong></div></div>
|
||||
</header>
|
||||
<div class="admin-dashboard">
|
||||
<aside class="admin-sidebar">
|
||||
@@ -40,7 +44,7 @@
|
||||
{#if canSeeData}
|
||||
<div class="sidebar-label" class:section-label={visibleUserSections.length > 0}><span>DATA MANAGEMENT</span><b>{String(dataSections.length).padStart(2, '0')} MODULES</b></div>
|
||||
<nav aria-label="Data management">
|
||||
{#each dataSections as section}<a class:active={page.url.pathname === section.href} href={section.href}><small>{section.code}</small><span>{section.label}</span>{#if data.pendingProposals[section.module]}<em>{data.pendingProposals[section.module]}</em>{/if}<b>›</b></a>{/each}
|
||||
{#each dataSections as section}<a class:active={page.url.pathname === section.href} class:has-pending={Boolean(data.pendingProposals[section.module])} href={section.href}><small>{section.code}</small><span>{section.label}</span>{#if data.pendingProposals[section.module]}<em aria-label={`${data.pendingProposals[section.module]} pending change proposal${data.pendingProposals[section.module] === 1 ? '' : 's'}`} title="Pending change proposals">{data.pendingProposals[section.module]}</em>{/if}<b>›</b></a>{/each}
|
||||
</nav>
|
||||
{/if}
|
||||
{#if visibleOversightSections.length}
|
||||
@@ -56,8 +60,8 @@
|
||||
}
|
||||
}}>
|
||||
{#if page.url.searchParams.has('proposal')}<div class="proposal-submitted" role="status"><b>CHANGE QUEUED</b><span>Your suggestion is awaiting data moderation and has not changed the live data set.</span></div>{/if}
|
||||
{#if dataReadOnly}<div class="data-access-banner"><b>READ-ONLY DATA ACCESS</b><span>You can inspect the registries but cannot submit or apply changes.</span></div>
|
||||
{:else if can('data.propose') && !can('data.manage')}<div class="data-access-banner contributor"><b>CONTRIBUTOR MODE</b><span>Saved edits and additions enter the approval queue; live builder data is not changed immediately.</span></div>{/if}
|
||||
{#if dataReadOnly}<div class="data-access-banner"><div><b>READ-ONLY DATA ACCESS</b><span>You can inspect registries without submitting changes.</span></div>{#if can('data.propose')}<a class="mode-toggle" href={`/admin/contributor-guidelines?returnTo=${encodeURIComponent(page.url.pathname + page.url.search)}`}>ENABLE CONTRIBUTOR MODE →</a>{/if}</div>
|
||||
{:else if contributorEnabled && !guidelinesPage && can('data.propose') && !can('data.manage')}<div class="data-access-banner contributor"><div><b>CONTRIBUTOR MODE</b><span>Suggestions enter moderation and do not immediately change live data.</span></div><form method="POST" action="/admin/contributor-guidelines?/disable"><input type="hidden" name="returnTo" value={page.url.pathname + page.url.search}/><button class="mode-toggle" type="submit">RETURN TO READ-ONLY</button></form></div>{/if}
|
||||
{#if readOnlyBlocked}<p class="error data-access-error" role="alert">This account has read-only game-data access.</p>{/if}
|
||||
{@render children()}
|
||||
</section>
|
||||
|
||||
@@ -62,7 +62,7 @@ export const actions: Actions = {
|
||||
const uniqueStatIds = [...new Set(effects.map((effect) => effect.statId))];
|
||||
const validStats = await (await database.prepare(`SELECT COUNT(*) AS count FROM stat_attributes WHERE active=1 AND id IN (${uniqueStatIds.map(() => '?').join(',')})`)).get(...uniqueStatIds);
|
||||
if (Number(validStats?.count) !== uniqueStatIds.length) return fail(400, { brandError: 'One or more selected attributes are unavailable.' });
|
||||
const brandId = id || randomUUID(), now = new Date().toISOString();
|
||||
const brandId = id || String(form.get('_proposedEntityId') ?? '') || randomUUID(), now = new Date().toISOString();
|
||||
const before = id ? { record: await auditRecord('brand_sets', id), icon: await gameDataIconMetadata(database, 'brand_set', id), effects: await (await database.prepare('SELECT * FROM brand_set_effects WHERE brand_set_id=? ORDER BY pieces_required')).all(id) } : null;
|
||||
if (id) await (await database.prepare('UPDATE brand_sets SET name=?,active=1,updated_at=? WHERE id=?')).run(name, now, id);
|
||||
else await (await database.prepare('INSERT INTO brand_sets(id,name,created_at,updated_at) VALUES(?,?,?,?)')).run(brandId, name, now, now);
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
import { fail, redirect } from '@sveltejs/kit';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { getSessionUser } from '$lib/server/auth/session';
|
||||
import { getPermissions } from '$lib/server/auth/authorization';
|
||||
import { getDatabase } from '$lib/server/database';
|
||||
import { CONTRIBUTOR_GUIDELINES_VERSION, getContributorMode } from '$lib/server/contributor-mode';
|
||||
import { recordIsacAudit } from '$lib/server/audit';
|
||||
import type { Actions, PageServerLoad } from './$types';
|
||||
|
||||
const safeReturn = (value: FormDataEntryValue | null) => {
|
||||
const path = String(value ?? '/admin/stats');
|
||||
return path.startsWith('/admin/') && !path.startsWith('//') ? path : '/admin/stats';
|
||||
};
|
||||
|
||||
export const load: PageServerLoad = async ({ cookies, url }) => {
|
||||
const user = await getSessionUser(cookies);
|
||||
if (!user) redirect(303, '/sign-in');
|
||||
const permissions = await getPermissions(user);
|
||||
const eligible = permissions.includes('*') || permissions.includes('data.propose');
|
||||
if (!eligible) redirect(303, '/admin/stats');
|
||||
return {
|
||||
contributorMode: await getContributorMode(user.id),
|
||||
returnTo: safeReturn(url.searchParams.get('returnTo'))
|
||||
};
|
||||
};
|
||||
|
||||
export const actions: Actions = {
|
||||
enable: async ({ cookies, request }) => {
|
||||
const user = await getSessionUser(cookies);
|
||||
if (!user) redirect(303, '/sign-in');
|
||||
const permissions = await getPermissions(user);
|
||||
if (!permissions.includes('*') && !permissions.includes('data.propose')) return fail(403, { contributorError: 'This account is not eligible to contribute data.' });
|
||||
const form = await request.formData();
|
||||
if (form.get('acceptGuidelines') !== 'on') return fail(400, { contributorError: 'Accept the contributor guidelines to enable contributor mode.' });
|
||||
const returnTo = safeReturn(form.get('returnTo'));
|
||||
const database = await getDatabase();
|
||||
const before = await getContributorMode(user.id);
|
||||
const now = new Date().toISOString();
|
||||
await (await database.prepare(`INSERT INTO contributor_agreements(user_id,guidelines_version,accepted_at,enabled,enabled_at,disabled_at)
|
||||
VALUES(?,?,?,1,?,NULL)
|
||||
ON CONFLICT(user_id) DO UPDATE SET guidelines_version=excluded.guidelines_version,accepted_at=excluded.accepted_at,
|
||||
enabled=1,enabled_at=excluded.enabled_at,disabled_at=NULL`)).run(user.id, CONTRIBUTOR_GUIDELINES_VERSION, now, now);
|
||||
await recordIsacAudit({
|
||||
actorId: user.id,
|
||||
category: 'data_moderation',
|
||||
action: before.accepted ? 'contributor.enabled' : 'contributor.guidelines_accepted',
|
||||
entityId: user.id,
|
||||
before,
|
||||
after: await getContributorMode(user.id)
|
||||
});
|
||||
redirect(303, returnTo);
|
||||
},
|
||||
disable: async ({ cookies, request }) => {
|
||||
const user = await getSessionUser(cookies);
|
||||
if (!user) redirect(303, '/sign-in');
|
||||
const returnTo = safeReturn((await request.formData()).get('returnTo'));
|
||||
const database = await getDatabase();
|
||||
const before = await getContributorMode(user.id);
|
||||
if (before.enabled) {
|
||||
await (await database.prepare('UPDATE contributor_agreements SET enabled=0,disabled_at=? WHERE user_id=?')).run(new Date().toISOString(), user.id);
|
||||
await recordIsacAudit({
|
||||
actorId: user.id,
|
||||
category: 'data_moderation',
|
||||
action: 'contributor.disabled',
|
||||
entityId: user.id,
|
||||
before,
|
||||
after: await getContributorMode(user.id)
|
||||
});
|
||||
}
|
||||
redirect(303, returnTo);
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,54 @@
|
||||
<script lang="ts">
|
||||
import PendingForm from '$lib/components/PendingForm.svelte';
|
||||
let { data, form } = $props();
|
||||
</script>
|
||||
|
||||
<svelte:head><title>Contributor guidelines — ISAC</title></svelte:head>
|
||||
|
||||
<div class="module-toolbar contributor-guidelines-toolbar">
|
||||
<div><p class="eyebrow">DATA CONTRIBUTION / OPERATING AGREEMENT</p><h2>CONTRIBUTOR GUIDELINES</h2><p>Review the requirements for suggesting changes to SHD.build source data.</p></div>
|
||||
<div class="guideline-version"><span>REVISION</span><strong>{data.contributorMode.version}</strong></div>
|
||||
</div>
|
||||
|
||||
<div class="contributor-guidelines">
|
||||
<section>
|
||||
<span>01</span>
|
||||
<div><h3>ACCURATE, VERIFIABLE DATA</h3><p>Enter information as it appears in The Division Resurgence. Check spelling, categories, tiers, units, ranges, and effect values before submitting. Do not guess when a value is unknown; explain uncertainty in the proposal context or leave the contribution until it can be verified.</p></div>
|
||||
</section>
|
||||
<section>
|
||||
<span>02</span>
|
||||
<div><h3>CLEAR, CONSISTENT CONTENT</h3><p>Use concise names and descriptions that match established game terminology. Avoid jokes, advertising, personal messages, copied community commentary, or unrelated content. Search existing live and pending records first to avoid duplicate work.</p></div>
|
||||
</section>
|
||||
<section>
|
||||
<span>03</span>
|
||||
<div><h3>APPROPRIATE IMAGES</h3><p>Upload only relevant game-data icons or assets you are permitted to share. Images must be safe for a general audience and must not contain harassment, explicit material, personal information, watermarks, advertisements, or misleading edits. Prefer clean transparent PNG, WebP, or SVG assets that remain legible at builder-icon sizes.</p></div>
|
||||
</section>
|
||||
<section>
|
||||
<span>04</span>
|
||||
<div><h3>HOW PROPOSALS WORK</h3><p>Contributor saves do not immediately change the live builder. Each addition or edit enters the moderation queue with your account, timestamp, submitted values, and dependencies. Approvers may accept, reject, or ask for correction. Actions are audit logged.</p></div>
|
||||
</section>
|
||||
<section>
|
||||
<span>05</span>
|
||||
<div><h3>COLLABORATIVE DEPENDENCIES</h3><p>You may reference pending records submitted by any contributor. The interface marks them as pending and identifies their proposer. Dependent proposals must be approved in order; rejection or validation failure upstream blocks dependent work from deployment.</p></div>
|
||||
</section>
|
||||
<section>
|
||||
<span>06</span>
|
||||
<div><h3>RESPONSIBLE ACCESS</h3><p>Use contributor mode only while preparing suggestions. Repeated low-quality, abusive, deceptive, or rights-infringing submissions may be rejected and contribution access may be restricted. You can return to read-only mode at any time without affecting proposals already submitted.</p></div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
{#if form?.contributorError}<p class="error" role="alert">{form.contributorError}</p>{/if}
|
||||
|
||||
<PendingForm action="?/enable">
|
||||
{#snippet children(pending)}
|
||||
<input type="hidden" name="returnTo" value={data.returnTo}/>
|
||||
<label class="contributor-acceptance">
|
||||
<input type="checkbox" name="acceptGuidelines" required/>
|
||||
<span><b>I ACCEPT THE CONTRIBUTOR GUIDELINES</b><small>I understand that submissions are attributed, moderated, dependency checked, and audit logged.</small></span>
|
||||
</label>
|
||||
<div class="form-actions">
|
||||
<a class="button secondary" href={data.returnTo}>KEEP READ-ONLY MODE</a>
|
||||
<button class="primary" disabled={pending} aria-busy={pending}>{pending ? 'ENABLING…' : data.contributorMode.accepted ? 'ENABLE CONTRIBUTOR MODE →' : 'ACCEPT & ENABLE →'}</button>
|
||||
</div>
|
||||
{/snippet}
|
||||
</PendingForm>
|
||||
@@ -45,8 +45,18 @@ export const actions: Actions = {
|
||||
const database = await getDatabase();
|
||||
const proposal = await (await database.prepare("SELECT * FROM data_change_proposals WHERE id=? AND status='pending'")).get(id);
|
||||
if (!proposal) return fail(404, { moderationError: 'That pending proposal no longer exists.' });
|
||||
const unresolved = await (await database.prepare(`SELECT dependency.id,dependency.summary,dependency.status
|
||||
FROM data_change_proposal_dependencies link
|
||||
JOIN data_change_proposals dependency ON dependency.id=link.depends_on_proposal_id
|
||||
WHERE link.proposal_id=? AND dependency.status<>'approved'`)).all(id);
|
||||
if (unresolved.length) {
|
||||
const names = unresolved.map((item) => `${item.summary} (${item.status})`).join(', ');
|
||||
return fail(409, { moderationError: `Approve this proposal's dependencies first: ${names}.` });
|
||||
}
|
||||
const target = new URL(`/admin/${proposal.module}?/${proposal.action}`, url.origin);
|
||||
const response = await fetch(target, { method: 'POST', body: deserializeProposal(String(proposal.payload_json)), headers: { origin: url.origin } });
|
||||
const proposalForm = deserializeProposal(String(proposal.payload_json));
|
||||
if (proposal.draft_entity_id && !proposal.entity_id) proposalForm.set('_proposedEntityId', String(proposal.draft_entity_id));
|
||||
const response = await fetch(target, { method: 'POST', body: proposalForm, headers: { origin: url.origin } });
|
||||
const now = new Date().toISOString();
|
||||
if (!response.ok) {
|
||||
const details = (await response.text()).slice(0, 1000);
|
||||
@@ -54,8 +64,8 @@ export const actions: Actions = {
|
||||
await recordIsacAudit({ actorId: reviewer.id, category: 'data_moderation', action: 'proposal.failed', entityId: id, before: { status: 'pending' }, after: { status: 'failed', responseStatus: response.status } });
|
||||
return fail(409, { moderationError: 'The proposal did not pass the current canonical validation and was marked failed.' });
|
||||
}
|
||||
await (await database.prepare("UPDATE data_change_proposals SET status='approved',reviewed_by=?,review_note=?,reviewed_at=?,applied_at=? WHERE id=?")).run(reviewer.id, note || null, now, now, id);
|
||||
await recordIsacAudit({ actorId: reviewer.id, category: 'data_moderation', action: 'proposal.approved', entityId: id, before: { status: 'pending' }, after: { status: 'approved', module: proposal.module, action: proposal.action, proposedBy: proposal.proposed_by } });
|
||||
await (await database.prepare("UPDATE data_change_proposals SET status='approved',applied_entity_id=COALESCE(entity_id,draft_entity_id),reviewed_by=?,review_note=?,reviewed_at=?,applied_at=? WHERE id=?")).run(reviewer.id, note || null, now, now, id);
|
||||
await recordIsacAudit({ actorId: reviewer.id, category: 'data_moderation', action: 'proposal.approved', entityId: id, before: { status: 'pending' }, after: { status: 'approved', module: proposal.module, action: proposal.action, proposedBy: proposal.proposed_by, appliedEntityId: proposal.entity_id ?? proposal.draft_entity_id } });
|
||||
return { moderationSaved: true, moderationToken: randomUUID() };
|
||||
},
|
||||
reject: async ({ cookies, request }) => {
|
||||
|
||||
@@ -6,12 +6,13 @@ import { randomUUID } from 'node:crypto';
|
||||
import type { Actions, PageServerLoad } from './$types';
|
||||
import { getPagination } from '$lib/server/pagination';
|
||||
import { auditRecord, recordIsacAudit } from '$lib/server/audit';
|
||||
import { getPendingEntities } from '$lib/server/data-moderation';
|
||||
|
||||
const slots = ['Holster', 'Gloves', 'Backpack', 'Knee Pads', 'Body Armor', 'Mask'];
|
||||
const armoredSlots = new Set(['Gloves', 'Knee Pads', 'Body Armor']);
|
||||
|
||||
export const load: PageServerLoad = async ({ cookies, url }) => {
|
||||
await requireDataView(await getSessionUser(cookies));
|
||||
const user = await requireDataView(await getSessionUser(cookies));
|
||||
const database = await getDatabase();
|
||||
const summary = await (await database.prepare(`SELECT COUNT(*) AS total,
|
||||
SUM(CASE WHEN active=1 THEN 1 ELSE 0 END) AS active,
|
||||
@@ -22,21 +23,27 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
|
||||
: filter === 'talented' ? 'WHERE EXISTS (SELECT 1 FROM gear_talents WHERE gear_id=gear.id)' : '';
|
||||
const filtered = await (await database.prepare(`SELECT COUNT(*) AS total FROM gear ${where}`)).get();
|
||||
const pagination = getPagination(url, Number(filtered?.total ?? 0));
|
||||
const [gear, brandLinks, talentLinks, brands, talents] = await Promise.all([
|
||||
const [gear, brandLinks, talentLinks, brands, talents, pending] = await Promise.all([
|
||||
(await database.prepare(`SELECT * FROM gear ${where} ORDER BY active DESC, slot, name COLLATE NOCASE LIMIT ? OFFSET ?`)).all(pagination.perPage, pagination.offset),
|
||||
(await database.prepare('SELECT gear_id,brand_set_id FROM gear_brand_sets')).all(),
|
||||
(await database.prepare('SELECT gear_id,talent_id FROM gear_talents')).all(),
|
||||
(await database.prepare('SELECT id,name FROM brand_sets WHERE active=1 ORDER BY name COLLATE NOCASE')).all(),
|
||||
(await database.prepare(`SELECT id,name,category FROM talents WHERE active=1 AND category IN ('Backpack','Vest','Body Armor') ORDER BY name COLLATE NOCASE`)).all()
|
||||
(await database.prepare(`SELECT id,name,category FROM talents WHERE active=1 AND category IN ('Backpack','Vest','Body Armor') ORDER BY name COLLATE NOCASE`)).all(),
|
||||
getPendingEntities(user.id, [
|
||||
{ module: 'brands', action: 'save', kind: 'brand' },
|
||||
{ module: 'talents', action: 'saveTalent', kind: 'talent', fields: ['category'] }
|
||||
])
|
||||
]);
|
||||
const pendingBrands = pending.filter((item) => item.kind === 'brand');
|
||||
const pendingTalents = pending.filter((item) => item.kind === 'talent' && ['Backpack', 'Body Armor'].includes(String(item.category)));
|
||||
return {
|
||||
gear: gear.map((item) => ({
|
||||
...item,
|
||||
brandIds: brandLinks.filter((link) => link.gear_id === item.id).map((link) => String(link.brand_set_id)),
|
||||
talentIds: talentLinks.filter((link) => link.gear_id === item.id).map((link) => String(link.talent_id))
|
||||
})),
|
||||
brands,
|
||||
talents: talents.map((talent) => ({ ...talent, category: talent.category === 'Vest' ? 'Body Armor' : talent.category })),
|
||||
brands: [...pendingBrands, ...brands],
|
||||
talents: [...pendingTalents, ...talents.map((talent) => ({ ...talent, category: talent.category === 'Vest' ? 'Body Armor' : talent.category }))],
|
||||
summary,
|
||||
pagination,
|
||||
filter
|
||||
@@ -87,7 +94,7 @@ export const actions: Actions = {
|
||||
const validTalents = await (await database.prepare(`SELECT COUNT(*) AS count FROM talents WHERE active=1 AND category IN (${allowedCategories.map(() => '?').join(',')}) AND id IN (${talentIds.map(() => '?').join(',')})`)).get(...allowedCategories, ...talentIds);
|
||||
if (Number(validTalents?.count) !== talentIds.length) return fail(400, { gearError: `Every selected talent must be a ${slot} talent.` });
|
||||
}
|
||||
const gearId = id || randomUUID(), now = new Date().toISOString();
|
||||
const gearId = id || String(form.get('_proposedEntityId') ?? '') || randomUUID(), now = new Date().toISOString();
|
||||
const before = id ? { record: await auditRecord('gear', id), brandIds: await (await database.prepare('SELECT brand_set_id FROM gear_brand_sets WHERE gear_id=?')).all(id), talentIds: await (await database.prepare('SELECT talent_id FROM gear_talents WHERE gear_id=?')).all(id) } : null;
|
||||
if (id) {
|
||||
await (await database.prepare(`UPDATE gear SET name=?,slot=?,tier=?,level=?,base_health_min=?,base_health_max=?,base_armor_min=?,base_armor_max=?,active=1,updated_at=? WHERE id=?`))
|
||||
|
||||
@@ -41,12 +41,12 @@
|
||||
</div>
|
||||
<div class="selection-head"><div><span>COMPATIBLE BRAND SETS</span><small>SELECT AT LEAST TWO</small></div><b>{editing.brandIds.length} SELECTED</b></div>
|
||||
<div class="selection-grid">
|
||||
{#each data.brands as brand}<label><input type="checkbox" name="brandIds" value={brand.id} bind:group={editing.brandIds} /><span>{brand.name}</span><small>{editing.brandIds.includes(brand.id) ? 'LINKED' : 'AVAILABLE'}</small></label>{/each}
|
||||
{#each data.brands as brand}<label class:pending-reference={brand.pending}><input type="checkbox" name="brandIds" value={brand.id} bind:group={editing.brandIds} /><span>{brand.name}</span><small>{brand.pending ? `PENDING · ${brand.proposerName}` : editing.brandIds.includes(brand.id) ? 'LINKED' : 'AVAILABLE'}</small></label>{/each}
|
||||
</div>
|
||||
{#if editing.slot === 'Backpack' || editing.slot === 'Body Armor'}
|
||||
<div class="selection-head"><div><span>{editing.slot.toUpperCase()} TALENTS</span><small>SELECT AT LEAST ONE · REQUIRED</small></div><b>{editing.talentIds.length} SELECTED</b></div>
|
||||
<div class="selection-grid talent-selection">
|
||||
{#each gearTalents(editing.slot) as talent}<label><input type="checkbox" name="talentIds" value={talent.id} bind:group={editing.talentIds} /><span>{talent.name}</span><small>{editing.talentIds.includes(talent.id) ? 'EQUIPPED' : 'AVAILABLE'}</small></label>{/each}
|
||||
{#each gearTalents(editing.slot) as talent}<label class:pending-reference={talent.pending}><input type="checkbox" name="talentIds" value={talent.id} bind:group={editing.talentIds} /><span>{talent.name}</span><small>{talent.pending ? `PENDING · ${talent.proposerName}` : editing.talentIds.includes(talent.id) ? 'EQUIPPED' : 'AVAILABLE'}</small></label>{/each}
|
||||
</div>
|
||||
{#if gearTalents(editing.slot).length === 0}<p class="error">Create an active {editing.slot} talent before adding gear for this slot.</p>{/if}
|
||||
{/if}
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
|
||||
<svelte:head><title>Roles & permissions — ISAC</title></svelte:head>
|
||||
<div class="module-toolbar"><div><p class="eyebrow">ACCESS POLICY REGISTRY</p><h2>ROLES & PERMISSIONS</h2><p>Compose reusable access roles from discrete SHD.build capabilities.</p></div><button class="primary" onclick={() => editing = blank()}>ADD ROLE <span>+</span></button></div>
|
||||
<p class="access-baseline-note"><b>REGISTERED AGENT BASELINE</b><span>Every active account inherently receives isac.access, data.view, and data.propose. Roles add elevated capabilities and cannot remove this baseline.</span></p>
|
||||
<p class="access-baseline-note"><b>REGISTERED AGENT BASELINE</b><span>Every active account inherently receives isac.access, data.view, and data.propose eligibility. Submitting proposals additionally requires accepted guidelines and enabled contributor mode. Roles add elevated capabilities and cannot remove this baseline.</span></p>
|
||||
<KpiFilters className="role-kpis" active={data.filter} items={[
|
||||
{value:'',label:'TOTAL ROLES',count:data.summary.total,description:'Show all role definitions'},
|
||||
{value:'isac',label:'ISAC ACCESS',count:data.summary.isac,description:'Can open the console'},
|
||||
|
||||
@@ -36,7 +36,7 @@ export const actions: Actions = {
|
||||
for (let index = 0; index < 6; index += 2) if ((values[index] === null) !== (values[index+1] === null) || (values[index] !== null && Number(values[index]) > Number(values[index+1]))) return fail(400, { error: 'Each enabled source needs a valid minimum and maximum.' });
|
||||
const database = await getDatabase();
|
||||
if (await (await database.prepare('SELECT id FROM stat_attributes WHERE name = ? COLLATE NOCASE AND id <> ?')).get(name, id || 'new')) return fail(409, { error: 'That attribute already exists.' });
|
||||
const now = new Date().toISOString(), statId = id || randomUUID();
|
||||
const now = new Date().toISOString(), statId = id || String(form.get('_proposedEntityId') ?? '') || randomUUID();
|
||||
const before = id ? await auditRecord('stat_attributes', id) : null;
|
||||
if (id) await (await database.prepare(`UPDATE stat_attributes SET name=?,type=?,category=?,calculation_type=?,unit=?,gear_min=?,gear_max=?,weapon_min=?,weapon_max=?,skill_chip_min=?,skill_chip_max=?,cap_value=?,updated_at=? WHERE id=?`)).run(name,type,category,calculation,unit,...values,now,id);
|
||||
else await (await database.prepare(`INSERT INTO stat_attributes(id,name,type,category,calculation_type,unit,gear_min,gear_max,weapon_min,weapon_max,skill_chip_min,skill_chip_max,cap_value,created_at,updated_at) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`)).run(statId,name,type,category,calculation,unit,...values,now,now);
|
||||
|
||||
@@ -7,11 +7,12 @@ import type { Actions, PageServerLoad } from './$types';
|
||||
import { getPagination } from '$lib/server/pagination';
|
||||
import { auditRecord, recordIsacAudit } from '$lib/server/audit';
|
||||
import { applyIconMutation, gameDataIconMetadata, gameDataIconUrl, GameDataIconValidationError, readIconMutation } from '$lib/server/game-data-icons';
|
||||
import { getPendingEntities } from '$lib/server/data-moderation';
|
||||
|
||||
type SharedEffect = { statId: string; valueI: number; valueII: number };
|
||||
|
||||
export const load: PageServerLoad = async ({ cookies, url }) => {
|
||||
await requireDataView(await getSessionUser(cookies));
|
||||
const user = await requireDataView(await getSessionUser(cookies));
|
||||
const db = await getDatabase();
|
||||
const totalRow = await (await db.prepare('SELECT COUNT(*) AS total FROM talents')).get();
|
||||
const filter = ['slot1', 'slot2', 'exotic', 'backpack', 'body-armor'].includes(url.searchParams.get('filter') ?? '') ? String(url.searchParams.get('filter')) : '';
|
||||
@@ -35,7 +36,7 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
|
||||
(await db.prepare('SELECT id,name,exotic_talent_id FROM weapons WHERE exotic_talent_id IS NOT NULL')).all(),
|
||||
(await db.prepare("SELECT entity_id,updated_at FROM game_data_icons WHERE entity_type='talent'")).all()
|
||||
]);
|
||||
const [triggerSummary, slotSummary, exoticSummary, gearSummary] = await Promise.all([
|
||||
const [triggerSummary, slotSummary, exoticSummary, gearSummary, pending] = await Promise.all([
|
||||
(await db.prepare('SELECT COUNT(*) AS total FROM talent_triggers')).get(),
|
||||
(await db.prepare(`SELECT
|
||||
SUM(CASE WHEN slot=1 THEN 1 ELSE 0 END) AS slot1,
|
||||
@@ -44,8 +45,14 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
|
||||
(await db.prepare(`SELECT
|
||||
SUM(CASE WHEN category='Backpack' THEN 1 ELSE 0 END) AS backpack,
|
||||
SUM(CASE WHEN category IN ('Vest','Body Armor') THEN 1 ELSE 0 END) AS body_armor
|
||||
FROM talents`)).get()
|
||||
FROM talents`)).get(),
|
||||
getPendingEntities(user.id, [
|
||||
{ module: 'talents', action: 'saveTrigger', kind: 'trigger', fields: ['description'] },
|
||||
{ module: 'stats', action: 'save', kind: 'stat', fields: ['unit'] }
|
||||
])
|
||||
]);
|
||||
const pendingTriggers = pending.filter((item) => item.kind === 'trigger');
|
||||
const pendingStats = pending.filter((item) => item.kind === 'stat');
|
||||
return {
|
||||
talents: talents.map((talent) => ({
|
||||
...talent,
|
||||
@@ -70,8 +77,8 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
|
||||
effects: effects.filter((effect) => effect.talent_tier_id === tier.id)
|
||||
}))
|
||||
})),
|
||||
triggers,
|
||||
stats,
|
||||
triggers: [...pendingTriggers, ...triggers],
|
||||
stats: [...pendingStats, ...stats],
|
||||
summary: {
|
||||
total: Number(totalRow?.total ?? 0),
|
||||
triggers: Number(triggerSummary?.total ?? 0),
|
||||
@@ -122,7 +129,7 @@ export const actions: Actions = {
|
||||
if (uptime && (!Number.isFinite(min) || !Number.isFinite(max) || Number(min) < 0 || Number(max) > 100 || Number(min) > Number(max))) return fail(400, { triggerError: 'Uptime must be a valid 0–100% range.' });
|
||||
const db = await getDatabase();
|
||||
if (await (await db.prepare('SELECT id FROM talent_triggers WHERE name=? COLLATE NOCASE AND id<>?')).get(name, id || 'new')) return fail(409, { triggerError: 'That trigger already exists.' });
|
||||
const now = new Date().toISOString(), triggerId = id || randomUUID();
|
||||
const now = new Date().toISOString(), triggerId = id || String(form.get('_proposedEntityId') ?? '') || randomUUID();
|
||||
const before = id ? await auditRecord('talent_triggers', id) : null;
|
||||
if (id) await (await db.prepare('UPDATE talent_triggers SET name=?,description=?,supports_uptime=?,min_uptime=?,max_uptime=?,updated_at=? WHERE id=?')).run(name, description, uptime ? 1 : 0, min, max, now, id);
|
||||
else await (await db.prepare('INSERT INTO talent_triggers(id,name,description,supports_uptime,min_uptime,max_uptime,created_at,updated_at) VALUES(?,?,?,?,?,?,?,?)')).run(triggerId, name, description, uptime ? 1 : 0, min, max, now, now);
|
||||
@@ -152,7 +159,7 @@ export const actions: Actions = {
|
||||
const statRows = await (await db.prepare(`SELECT id,unit FROM stat_attributes WHERE active=1 AND id IN (${sharedEffects.map(() => '?').join(',')})`)).all(...sharedEffects.map((effect) => effect.statId));
|
||||
if (statRows.length !== sharedEffects.length) return fail(400, { talentError: 'One or more selected attributes are unavailable.' });
|
||||
const units = new Map(statRows.map((stat) => [String(stat.id), stat.unit]));
|
||||
const talentId = id || randomUUID(), now = new Date().toISOString();
|
||||
const talentId = id || String(form.get('_proposedEntityId') ?? '') || randomUUID(), now = new Date().toISOString();
|
||||
const before = id ? { record: await auditRecord('talents', id), icon: await gameDataIconMetadata(db, 'talent', id), template: await (await db.prepare('SELECT template FROM talent_description_templates WHERE talent_id=?')).get(id), tiers: await (await db.prepare('SELECT tier,description FROM talent_tiers WHERE talent_id=? ORDER BY tier')).all(id) } : null;
|
||||
if (id) await (await db.prepare('UPDATE talents SET name=?,category=?,updated_at=? WHERE id=?')).run(name, storedCategory, now, id);
|
||||
else await (await db.prepare('INSERT INTO talents(id,name,category,created_at,updated_at) VALUES(?,?,?,?,?)')).run(talentId, name, storedCategory, now, now);
|
||||
|
||||
@@ -73,9 +73,9 @@
|
||||
<label class="field template-field">SHARED DESCRIPTION TEMPLATE<textarea name="descriptionTemplate" bind:value={talent.descriptionTemplate} placeholder="Hitting an enemy {1} times grants +{2}..." required></textarea><small>Use {'{1}'}, {'{2}'}, and so on to insert each effect value with its unit.</small></label>
|
||||
<div class="tier-preview"><div><span>TIER I PREVIEW</span><p>{preview('I') || 'Description preview'}</p></div><div><span>TIER II PREVIEW</span><p>{preview('II') || 'Description preview'}</p></div></div>
|
||||
<p class="label">SHARED ACTIVATION TRIGGERS</p>
|
||||
<div class="trigger-list shared-trigger-list">{#each data.triggers.filter((item) => Number(item.active) === 1) as item}<div><label><input name="triggerIds" type="checkbox" value={item.id} checked={talent.triggerIds.includes(String(item.id))} onchange={(event) => toggleTrigger(String(item.id), event.currentTarget.checked)}/> <span>{item.name}</span></label><button type="button" onclick={() => editTrigger(item)}>EDIT</button></div>{/each}</div>
|
||||
<div class="trigger-list shared-trigger-list">{#each data.triggers.filter((item) => Number(item.active) === 1) as item}<div class:pending-reference={item.pending}><label><input name="triggerIds" type="checkbox" value={item.id} checked={talent.triggerIds.includes(String(item.id))} onchange={(event) => toggleTrigger(String(item.id), event.currentTarget.checked)}/> <span>{item.name}</span></label>{#if item.pending}<b class="pending-reference-label">PENDING · {item.proposerName}</b>{:else}<button type="button" onclick={() => editTrigger(item)}>EDIT</button>{/if}</div>{/each}</div>
|
||||
<div class="effects-head"><p class="label">SHARED ATTRIBUTE EFFECTS · TIER VALUES</p><button type="button" onclick={addEffect}>ADD EFFECT +</button></div>
|
||||
<div class="shared-effects"><div class="shared-effect effect-heading"><span>ATTRIBUTE</span><span>TIER I VALUE</span><span>TIER II VALUE</span><span></span></div>{#each talent.effects as effect, index}<div class="shared-effect"><select bind:value={effect.statId} required><option value="">Select attribute…</option>{#each data.stats as stat}<option value={stat.id}>{stat.name} ({stat.unit || 'no unit'})</option>{/each}</select><input type="number" step="any" bind:value={effect.valueI} placeholder="TIER I" required/><input type="number" step="any" bind:value={effect.valueII} placeholder="TIER II" required/><button type="button" aria-label={`Remove effect ${index + 1}`} onclick={() => removeEffect(index)}>×</button></div>{/each}</div>
|
||||
<div class="shared-effects"><div class="shared-effect effect-heading"><span>ATTRIBUTE</span><span>TIER I VALUE</span><span>TIER II VALUE</span><span></span></div>{#each talent.effects as effect, index}<div class="shared-effect"><select bind:value={effect.statId} required><option value="">Select attribute…</option>{#each data.stats as stat}<option value={stat.id}>{stat.pending ? `PENDING · ${stat.proposerName} · ` : ''}{stat.name} ({stat.unit || 'no unit'})</option>{/each}</select><input type="number" step="any" bind:value={effect.valueI} placeholder="TIER I" required/><input type="number" step="any" bind:value={effect.valueII} placeholder="TIER II" required/><button type="button" aria-label={`Remove effect ${index + 1}`} onclick={() => removeEffect(index)}>×</button></div>{/each}</div>
|
||||
{#if form?.talentError}<p class="error">{form.talentError}</p>{/if}
|
||||
<div class="form-actions"><button type="button" class="secondary" onclick={() => talent = null}>CANCEL</button><button class="primary">SAVE TALENT →</button></div>
|
||||
</PendingForm>
|
||||
|
||||
@@ -40,7 +40,7 @@
|
||||
<PendingForm action="?/setAccess">
|
||||
<div class="selection-head"><div><span>ASSIGNED ROLES</span><small>PERMISSION BUNDLES</small></div><b>{roleIds.length} SELECTED</b></div>
|
||||
<div class="selection-grid">{#each data.roles as role}<label><input type="checkbox" name="roleIds" value={role.id} bind:group={roleIds}/><span>{role.name}</span><small>{roleIds.includes(role.id) ? 'ASSIGNED' : 'AVAILABLE'}</small></label>{/each}</div>
|
||||
<p class="access-baseline-note compact"><b>REGISTERED AGENT BASELINE</b><span>isac.access · data.view · data.propose are inherent and are not removable here.</span></p>
|
||||
<p class="access-baseline-note compact"><b>REGISTERED AGENT BASELINE</b><span>isac.access · data.view · data.propose eligibility are inherent. Proposal submission also requires accepted guidelines and enabled contributor mode.</span></p>
|
||||
<div class="selection-head"><div><span>DIRECT PERMISSIONS</span><small>USER-SPECIFIC CAPABILITIES</small></div><b>{permissionIds.length} SELECTED</b></div>
|
||||
<div class="selection-grid permission-grid">{#each data.permissions as permission}<label title={permission.description}><input type="checkbox" name="permissionIds" value={permission.id} bind:group={permissionIds}/><span>{permission.name}</span><small>{permissionIds.includes(permission.id) ? 'GRANTED' : 'AVAILABLE'}</small></label>{/each}</div>
|
||||
{#if form?.accessError}<p class="error">{form.accessError}</p>{/if}{#if form?.accessSaved}<p class="success">Roles and direct permissions updated.</p>{/if}<div class="form-actions"><button type="button" class="primary" onclick={() => accessConfirmationOpen = true}>REVIEW ACCESS →</button></div>
|
||||
|
||||
Reference in New Issue
Block a user