feat(isac): add moderated data contributions
This commit is contained in:
@@ -117,6 +117,9 @@ INSERT OR IGNORE INTO roles(id,name,description) VALUES('role-moderator','Modera
|
||||
INSERT OR IGNORE INTO permissions(id,name,description) VALUES('permission-system-admin','system.admin','Unrestricted access to all administrative capabilities');
|
||||
INSERT OR IGNORE INTO permissions(id,name,description) VALUES('permission-isac-access','isac.access','Open the ISAC interface');
|
||||
INSERT OR IGNORE INTO permissions(id,name,description) VALUES('permission-data-manage','data.manage','Create and edit builder source data');
|
||||
INSERT OR IGNORE INTO permissions(id,name,description) VALUES('permission-data-view','data.view','View ISAC builder source data without changing live records');
|
||||
INSERT OR IGNORE INTO permissions(id,name,description) VALUES('permission-data-propose','data.propose','Suggest builder source-data additions and edits for approval');
|
||||
INSERT OR IGNORE INTO permissions(id,name,description) VALUES('permission-data-approve','data.approve','Review, approve, and reject proposed builder source-data changes');
|
||||
INSERT OR IGNORE INTO permissions(id,name,description) VALUES('permission-users-view','users.view','View users, activity, and content history');
|
||||
INSERT OR IGNORE INTO permissions(id,name,description) VALUES('permission-users-moderate','users.moderate','Block, ban, and reactivate users');
|
||||
INSERT OR IGNORE INTO permissions(id,name,description) VALUES('permission-users-roles','users.roles','Assign roles and direct permissions');
|
||||
@@ -127,6 +130,19 @@ INSERT OR IGNORE INTO permissions(id,name,description) VALUES('permission-audit-
|
||||
INSERT OR IGNORE INTO permissions(id,name,description) VALUES('permission-metrics-view','metrics.view','View read-only application, runtime, and database health metrics');
|
||||
INSERT OR IGNORE INTO role_permissions(role_id,permission_id) VALUES('role-data-editor','permission-isac-access');
|
||||
INSERT OR IGNORE INTO role_permissions(role_id,permission_id) VALUES('role-data-editor','permission-data-manage');
|
||||
INSERT OR IGNORE INTO role_permissions(role_id,permission_id) VALUES('role-data-editor','permission-data-view');
|
||||
INSERT OR IGNORE INTO roles(id,name,description) VALUES('role-data-viewer','Data Viewer','Read-only access to ISAC game-data registries');
|
||||
INSERT OR IGNORE INTO roles(id,name,description) VALUES('role-data-contributor','Data Contributor','View game data and submit moderated change proposals');
|
||||
INSERT OR IGNORE INTO roles(id,name,description) VALUES('role-data-approver','Data Approver','Review proposals and apply approved changes to live game data');
|
||||
INSERT OR IGNORE INTO role_permissions(role_id,permission_id) VALUES('role-data-viewer','permission-isac-access');
|
||||
INSERT OR IGNORE INTO role_permissions(role_id,permission_id) VALUES('role-data-viewer','permission-data-view');
|
||||
INSERT OR IGNORE INTO role_permissions(role_id,permission_id) VALUES('role-data-contributor','permission-isac-access');
|
||||
INSERT OR IGNORE INTO role_permissions(role_id,permission_id) VALUES('role-data-contributor','permission-data-view');
|
||||
INSERT OR IGNORE INTO role_permissions(role_id,permission_id) VALUES('role-data-contributor','permission-data-propose');
|
||||
INSERT OR IGNORE INTO role_permissions(role_id,permission_id) VALUES('role-data-approver','permission-isac-access');
|
||||
INSERT OR IGNORE INTO role_permissions(role_id,permission_id) VALUES('role-data-approver','permission-data-view');
|
||||
INSERT OR IGNORE INTO role_permissions(role_id,permission_id) VALUES('role-data-approver','permission-data-manage');
|
||||
INSERT OR IGNORE INTO role_permissions(role_id,permission_id) VALUES('role-data-approver','permission-data-approve');
|
||||
INSERT OR IGNORE INTO role_permissions(role_id,permission_id) VALUES('role-user-operator','permission-isac-access');
|
||||
INSERT OR IGNORE INTO role_permissions(role_id,permission_id) VALUES('role-user-operator','permission-users-view');
|
||||
INSERT OR IGNORE INTO role_permissions(role_id,permission_id) VALUES('role-user-operator','permission-users-moderate');
|
||||
@@ -140,6 +156,23 @@ INSERT OR IGNORE INTO role_permissions(role_id,permission_id) VALUES('role-audit
|
||||
INSERT OR IGNORE INTO role_permissions(role_id,permission_id) VALUES('role-auditor','permission-audit-view');
|
||||
INSERT OR IGNORE INTO role_permissions(role_id,permission_id) VALUES('role-auditor','permission-metrics-view');
|
||||
CREATE TABLE IF NOT EXISTS oauth_states (state_hash TEXT PRIMARY KEY, provider TEXT NOT NULL, mode TEXT NOT NULL, user_id TEXT REFERENCES users(id) ON DELETE CASCADE, expires_at TEXT NOT NULL, created_at TEXT NOT NULL);
|
||||
CREATE TABLE IF NOT EXISTS data_change_proposals (
|
||||
id TEXT PRIMARY KEY,
|
||||
module TEXT NOT NULL CHECK(module IN ('stats','talents','brands','gear','weapons','protocols')),
|
||||
action TEXT NOT NULL,
|
||||
entity_id TEXT,
|
||||
summary TEXT NOT NULL,
|
||||
payload_json TEXT NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'pending' CHECK(status IN ('pending','approved','rejected','failed')),
|
||||
proposed_by TEXT NOT NULL REFERENCES users(id),
|
||||
reviewed_by TEXT REFERENCES users(id),
|
||||
review_note TEXT,
|
||||
created_at TEXT NOT NULL,
|
||||
reviewed_at TEXT,
|
||||
applied_at TEXT
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS data_change_proposals_status_created_idx ON data_change_proposals(status,created_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS data_change_proposals_module_status_idx ON data_change_proposals(module,status);
|
||||
CREATE TABLE IF NOT EXISTS discord_profiles (
|
||||
user_id TEXT PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,
|
||||
discord_id TEXT NOT NULL UNIQUE,
|
||||
|
||||
@@ -503,3 +503,13 @@ All image uploads use the shared SHD drop-zone treatment and expose three equiva
|
||||
selection paths: a file picker, drag and drop, and clipboard paste. The control shows
|
||||
the selected filename and a local preview, but server-side format, size, transparency,
|
||||
and normalization checks remain authoritative.
|
||||
|
||||
### Moderated data states
|
||||
|
||||
ISAC must clearly distinguish direct editing, contributor proposals, and read-only
|
||||
access. Use a compact access banner above the module rather than changing the
|
||||
registry layout. Pending proposal counts appear as orange numeric indicators in
|
||||
data-navigation items. Proposal lifecycle states use text as well as color:
|
||||
pending is orange/actionable, approved is green/configured, and rejected or
|
||||
failed is red/archived. Review decisions always use the standard ISAC confirmation
|
||||
surface and must never apply a proposal from a table-row click alone.
|
||||
|
||||
@@ -107,6 +107,27 @@ mutation rather than deletion or modification of the original event:
|
||||
administrative access.
|
||||
6. The server restores the previous state and appends an `audit.reverted` event
|
||||
whose target is the original audit-event ID.
|
||||
|
||||
# Moderated game-data contributions
|
||||
|
||||
ISAC data access is split into `data.view`, `data.propose`, `data.manage`, and
|
||||
`data.approve`. Data Viewers can inspect registries. Data Contributors can submit
|
||||
the same editor forms, but the request is serialized into `data_change_proposals`
|
||||
instead of mutating canonical tables. Data Approvers review the queue and must
|
||||
explicitly confirm approval or rejection.
|
||||
|
||||
Approval replays the stored form through its original SvelteKit server action.
|
||||
This preserves canonical validation, relational updates, icon processing, and the
|
||||
normal game-data audit event. Proposal creation and review decisions also produce
|
||||
separate `data_moderation` audit events. Failed canonical validation marks a
|
||||
proposal as failed without changing live data. Pending counts appear alongside
|
||||
each data registry in ISAC so collaborators can see concurrent suggestions.
|
||||
|
||||
The protected seeded roles are Data Viewer, Data Contributor, Data Editor, and
|
||||
Data Approver. Data Editor retains direct-write access for trusted operators;
|
||||
Data Approver combines direct-write access with queue review. New ISAC data
|
||||
modules must use the shared moderation interception path and audit both the final
|
||||
mutation and proposal lifecycle.
|
||||
7. A successfully reverted event cannot be reverted again.
|
||||
|
||||
Unsupported destructive identity operations, account deletion, incomplete
|
||||
|
||||
@@ -63,6 +63,7 @@
|
||||
.avatar-picker{display:grid;grid-template-columns:repeat(4,1fr);gap:6px;margin-bottom:10px}.avatar-preset-form fieldset{height:100%}.avatar-preset{display:grid;gap:5px;width:100%;height:100%;padding:4px;background:#0b0e0f;border:1px solid #303536;color:#747c7e}.avatar-preset:hover,.avatar-preset:focus-visible{border-color:#8a672b;color:#f6a821}.avatar-preset.selected{border-color:#f6a821;color:#f6a821;box-shadow:inset 0 -2px #f6a821}.avatar-preset img{display:block;width:100%;aspect-ratio:1;object-fit:cover}.avatar-preset span{font-size:6px;line-height:1.25;text-transform:uppercase;letter-spacing:.4px}.avatar-upload{margin-top:8px}
|
||||
.image-drop-input{display:grid;grid-template-columns:64px 1fr auto;align-items:center;gap:10px;min-height:82px;padding:8px;border:1px dashed #4b5152;background:#0b0e0f;cursor:pointer;transition:border-color .15s,background .15s,transform .1s}.image-drop-input:hover,.image-drop-input:focus-visible,.image-drop-input.dragging{border-color:var(--shd-orange);background:#15130e;outline:none}.image-drop-input.dragging{transform:translateY(1px);box-shadow:inset 0 0 0 1px #f6a82155}.image-file-input{position:absolute!important;width:1px!important;height:1px!important;overflow:hidden!important;clip:rect(0 0 0 0)!important;white-space:nowrap!important}.image-upload-preview{display:grid;place-items:center;width:64px;height:64px;border:1px solid #343a3b;background:#070909;color:#62696b;font-size:6px;text-align:center}.image-upload-preview img{display:block;width:100%;height:100%;object-fit:contain}.image-drop-copy{display:grid;gap:4px;min-width:0}.image-drop-copy b{color:#c6cbcc;font-size:8px;letter-spacing:1px}.image-drop-copy span{overflow:hidden;color:#747c7e;font-size:7px;text-overflow:ellipsis;white-space:nowrap}.image-upload-actions{display:flex;gap:5px}.image-upload-actions button{min-width:92px;padding:8px}.image-upload-error{display:block;margin-top:5px;color:var(--shd-danger)}@media(max-width:700px){.image-drop-input{grid-template-columns:56px 1fr}.image-upload-preview{width:56px;height:56px}.image-upload-actions{grid-column:1/-1}.image-upload-actions button{flex:1}}
|
||||
.icon-upload-field .image-drop-input{grid-template-columns:1fr;gap:6px;text-align:center}.icon-upload-field .image-upload-preview{width:100%;height:72px}.icon-upload-field .image-upload-preview img{width:64px;height:64px}.icon-upload-field .image-upload-actions{display:grid;grid-template-columns:1fr 1fr;width:100%}.icon-upload-field .image-upload-actions button{min-width:0;padding:7px 3px;font-size:6px}
|
||||
.proposal-submitted,.data-access-banner{display:flex;align-items:center;gap:12px;margin-bottom:9px;padding:9px 11px;border-left:3px solid var(--shd-success);background:#101713;color:#a6cbb0}.proposal-submitted b,.data-access-banner b{font-size:8px;letter-spacing:1px}.proposal-submitted span,.data-access-banner span{font-size:7px;color:#7e9d86}.data-access-banner{border-left-color:#657174;background:#111617;color:#aeb7b9}.data-access-banner.contributor{border-left-color:var(--shd-orange);background:#19150e;color:#d8bc87}.data-access-error{margin:-3px 0 9px}.data-readonly form[method=post] button[type=submit],.data-readonly form[method=post] button:not([type]){filter:grayscale(1);opacity:.45}.admin-sidebar nav a em{min-width:18px;padding:3px 5px;background:#8a5b16;color:#fff1d0;font-size:6px;font-style:normal;text-align:center}.moderation-row{grid-template-columns:minmax(240px,1.5fr) minmax(140px,.8fr) 150px 80px 75px}.moderation-review{width:min(760px,100%)}.moderation-metadata{display:grid;grid-template-columns:repeat(3,1fr);gap:6px;margin:10px 0}.moderation-metadata span{display:grid;gap:4px;padding:8px;border:1px solid var(--shd-border);color:var(--shd-text-muted);font-size:6px}.moderation-metadata b{color:var(--shd-text);font-size:8px}.moderation-review details{margin:10px 0;border:1px solid var(--shd-border);background:#0b0e0f}.moderation-review summary{padding:9px;color:var(--shd-orange);font-size:7px;cursor:pointer}.moderation-review pre{max-height:260px;overflow:auto;margin:0;padding:10px;border-top:1px solid var(--shd-border);color:#9ca6a8;font-size:7px;white-space:pre-wrap}.decision-tabs{display:grid;grid-template-columns:1fr 1fr;gap:5px;margin:10px 0}.decision-tabs button{padding:9px;border:1px solid var(--shd-border);background:#111415;color:var(--shd-text-muted)}.decision-tabs button.active{border-color:var(--shd-orange);color:var(--shd-orange)}@media(max-width:800px){.moderation-row{grid-template-columns:1fr}.moderation-metadata{grid-template-columns:1fr}}
|
||||
.account-tabs{width:100%;height:auto;margin:0 0 16px;display:grid;grid-template-columns:repeat(3,1fr);border:1px solid var(--shd-border)}.account-tabs button,.account-subtabs button{min-width:0;height:34px;padding:0 5px;border:0;border-left:1px solid var(--shd-border);border-bottom:2px solid transparent;background:#0d1011;color:#6f7779;font-size:7px;font-weight:900;letter-spacing:.8px;cursor:pointer;transition:var(--shd-transition)}.account-tabs button:first-child,.account-subtabs button:first-child{border-left:0}.account-tabs button:hover,.account-tabs button:focus-visible,.account-subtabs button:hover,.account-subtabs button:focus-visible{color:var(--shd-text);background:#171b1c}.account-tabs button.active,.account-subtabs button.active{border-bottom-color:var(--shd-orange);background:#211a0e;color:var(--shd-orange)}.account-tabs button:active,.account-subtabs button:active{transform:translateY(1px);filter:brightness(.85)}.account-tabs button:focus-visible,.account-subtabs button:focus-visible{position:relative;z-index:1;outline:2px solid var(--shd-orange);outline-offset:2px}.account-panel{min-height:290px}.account-subtabs{display:grid;grid-template-columns:1fr 1fr;margin:0 0 10px;border:1px solid var(--shd-border)}.account-subtabs button{height:31px}.access-subtabs{display:grid;grid-template-columns:repeat(4,1fr);margin:-2px 0 17px;border:1px solid var(--shd-border);background:#0b0e0f}.access-subtabs button{min-width:0;height:42px;display:grid;place-content:center;gap:3px;padding:4px;border:0;border-left:1px solid var(--shd-border);border-bottom:2px solid transparent;background:transparent;color:var(--shd-text-dim);cursor:pointer;transition:var(--shd-transition)}.access-subtabs button:first-child{border-left:0}.access-subtabs button span{font-size:6px;font-weight:900;letter-spacing:.7px}.access-subtabs button b{font-size:9px}.access-subtabs button:hover,.access-subtabs button:focus-visible{background:#171b1c;color:var(--shd-text)}.access-subtabs button.active{border-bottom-color:var(--shd-orange);background:#211a0e;color:var(--shd-orange)}.access-subtabs button:active{transform:translateY(1px);filter:brightness(.85)}.access-subtabs button:focus-visible{position:relative;z-index:1;outline:2px solid var(--shd-orange);outline-offset:2px}.account-retention{color:var(--shd-text-muted);font:12px/1.55 Georgia,serif}.danger-eyebrow{color:var(--shd-danger)}
|
||||
.account-alert.error-alert{background:#261513;border-color:#75443b;border-left-color:#df8069}.account-alert.error-alert b{color:#df8069}.account-alert.error-alert span{color:#d2aaa1}
|
||||
.danger-zone{margin-top:12px;border:1px solid #563734;background:#181112}.danger-zone summary{padding:10px;color:#df8069;cursor:pointer;font-size:8px;font-weight:bold;letter-spacing:1px}.danger-zone>p,.danger-zone>form{margin:0;padding:0 10px 10px}.danger-zone>p{color:#9f8580;font-size:8px;line-height:1.5}.danger-zone form{padding-bottom:10px}
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
import type { Handle } from '@sveltejs/kit';
|
||||
import { getSessionUser } from '$lib/server/auth/session';
|
||||
import { DATA_MODULES, queueDataProposal, shouldModerateDataMutation, type DataModule } from '$lib/server/data-moderation';
|
||||
|
||||
export const handle: Handle = async ({ event, resolve }) => {
|
||||
if (event.request.method === 'POST') {
|
||||
const match = event.url.pathname.match(/^\/admin\/([^/]+)$/);
|
||||
const module = match?.[1] as DataModule | undefined;
|
||||
const action = event.url.search.startsWith('?/') ? event.url.search.slice(2) : '';
|
||||
if (module && DATA_MODULES.includes(module) && action) {
|
||||
const user = await getSessionUser(event.cookies);
|
||||
const access = await shouldModerateDataMutation(user);
|
||||
if (!access.canManage && access.canPropose && user) {
|
||||
const proposalId = await queueDataProposal(user, module, action, await event.request.formData());
|
||||
const target = new URL(event.url.pathname, event.url);
|
||||
target.searchParams.set('proposal', proposalId);
|
||||
return new Response(null, { status: 303, headers: { location: `${target.pathname}${target.search}` } });
|
||||
}
|
||||
}
|
||||
}
|
||||
return resolve(event);
|
||||
};
|
||||
@@ -31,6 +31,15 @@ export async function requirePermission(user: AuthUser | null, permission: strin
|
||||
return user;
|
||||
}
|
||||
|
||||
export async function requireDataView(user: AuthUser | null): Promise<AuthUser> {
|
||||
if (!user) error(401, 'Sign in required');
|
||||
const permissions = await getPermissions(user);
|
||||
if (!permissions.includes('*') && !['data.view', 'data.manage', 'data.propose', 'data.approve'].some((permission) => permissions.includes(permission))) {
|
||||
error(403, 'ISAC data access required');
|
||||
}
|
||||
return user;
|
||||
}
|
||||
|
||||
export function requireAdmin(user: AuthUser | null): asserts user is AuthUser {
|
||||
if (!user) error(401, 'Sign in required');
|
||||
if (user.role !== 'admin') error(403, 'Administrator permission required');
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { getDatabase } from '$lib/server/database';
|
||||
import { getPermissions } from '$lib/server/auth/authorization';
|
||||
import type { AuthUser } from '$lib/server/auth/session';
|
||||
import { recordIsacAudit } from '$lib/server/audit';
|
||||
|
||||
export const DATA_MODULES = ['stats', 'talents', 'brands', 'gear', 'weapons', 'protocols'] as const;
|
||||
export type DataModule = typeof DATA_MODULES[number];
|
||||
|
||||
type SerializedEntry =
|
||||
| { name: string; kind: 'text'; value: string }
|
||||
| { name: string; kind: 'file'; value: string; fileName: string; type: string };
|
||||
|
||||
async function serialize(form: FormData): Promise<SerializedEntry[]> {
|
||||
const entries: SerializedEntry[] = [];
|
||||
for (const [name, value] of form.entries()) {
|
||||
if (typeof value === 'string') entries.push({ name, kind: 'text', value });
|
||||
else if (value.size > 0) entries.push({
|
||||
name,
|
||||
kind: 'file',
|
||||
value: Buffer.from(await value.arrayBuffer()).toString('base64'),
|
||||
fileName: value.name,
|
||||
type: value.type
|
||||
});
|
||||
}
|
||||
return entries;
|
||||
}
|
||||
|
||||
export function deserializeProposal(payload: string): FormData {
|
||||
const form = new FormData();
|
||||
for (const entry of JSON.parse(payload) as SerializedEntry[]) {
|
||||
if (entry.kind === 'text') form.append(entry.name, entry.value);
|
||||
else form.append(entry.name, new Blob([Buffer.from(entry.value, 'base64')], { type: entry.type }), entry.fileName);
|
||||
}
|
||||
return form;
|
||||
}
|
||||
|
||||
export async function queueDataProposal(user: AuthUser, module: DataModule, action: string, form: FormData) {
|
||||
const id = randomUUID();
|
||||
const entityId = String(form.get('id') ?? '').trim() || null;
|
||||
const name = String(form.get('name') ?? '').trim();
|
||||
const summary = `${action.replace(/([A-Z])/g, ' $1').toUpperCase()} · ${name || entityId || 'NEW RECORD'}`;
|
||||
const now = new Date().toISOString();
|
||||
const payload = JSON.stringify(await serialize(form));
|
||||
const database = await getDatabase();
|
||||
await (await database.prepare(`INSERT INTO data_change_proposals
|
||||
(id,module,action,entity_id,summary,payload_json,status,proposed_by,created_at)
|
||||
VALUES(?,?,?,?,?,?,'pending',?,?)`)).run(id, module, action, entityId, summary, payload, user.id, now);
|
||||
await recordIsacAudit({
|
||||
actorId: user.id,
|
||||
category: 'data_moderation',
|
||||
action: 'proposal.created',
|
||||
entityId: id,
|
||||
before: null,
|
||||
after: { module, action, entityId, summary }
|
||||
});
|
||||
return id;
|
||||
}
|
||||
|
||||
export async function shouldModerateDataMutation(user: AuthUser | null) {
|
||||
const permissions = await getPermissions(user);
|
||||
return {
|
||||
canManage: permissions.includes('*') || permissions.includes('data.manage'),
|
||||
canPropose: permissions.includes('*') || permissions.includes('data.propose')
|
||||
};
|
||||
}
|
||||
@@ -1,11 +1,20 @@
|
||||
import { getSessionUser } from '$lib/server/auth/session';
|
||||
import { getPermissions } from '$lib/server/auth/authorization';
|
||||
import { redirect } from '@sveltejs/kit';
|
||||
import { getDatabase } from '$lib/server/database';
|
||||
import type { LayoutServerLoad } from './$types';
|
||||
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');
|
||||
return { permissions };
|
||||
const canSeeData = permissions.includes('*') || ['data.view','data.manage','data.propose','data.approve'].some((permission) => permissions.includes(permission));
|
||||
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,
|
||||
pendingProposals: Object.fromEntries(pendingRows.map((row) => [String(row.module), Number(row.count)])),
|
||||
pendingProposalTotal: pendingRows.reduce((total, row) => total + Number(row.count), 0)
|
||||
};
|
||||
};
|
||||
|
||||
@@ -2,13 +2,16 @@
|
||||
import { page } from '$app/state';
|
||||
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'));
|
||||
let readOnlyBlocked = $state(false);
|
||||
const dataSections = [
|
||||
{ href: '/admin/stats', label: 'Stats & attributes', code: 'ATTR' },
|
||||
{ href: '/admin/talents', label: 'Talents & triggers', code: 'TLNT' },
|
||||
{ href: '/admin/brands', label: 'Brand sets', code: 'BRND' },
|
||||
{ href: '/admin/gear', label: 'Gear', code: 'GEAR' },
|
||||
{ href: '/admin/weapons', label: 'Weapons', code: 'WPN' },
|
||||
{ href: '/admin/protocols', label: 'OS Protocols', code: 'OSPR' }
|
||||
{ href: '/admin/stats', label: 'Stats & attributes', code: 'ATTR', module: 'stats' },
|
||||
{ href: '/admin/talents', label: 'Talents & triggers', code: 'TLNT', module: 'talents' },
|
||||
{ href: '/admin/brands', label: 'Brand sets', code: 'BRND', module: 'brands' },
|
||||
{ href: '/admin/gear', label: 'Gear', code: 'GEAR', module: 'gear' },
|
||||
{ href: '/admin/weapons', label: 'Weapons', code: 'WPN', module: 'weapons' },
|
||||
{ href: '/admin/protocols', label: 'OS Protocols', code: 'OSPR', module: 'protocols' }
|
||||
];
|
||||
const userSections = [
|
||||
{ href: '/admin/users', label: 'User operations', code: 'USER', permission: 'users.view' },
|
||||
@@ -16,6 +19,7 @@
|
||||
];
|
||||
const visibleUserSections = $derived(userSections.filter((section) => can(section.permission)));
|
||||
const oversightSections = [
|
||||
{ href: '/admin/data-moderation', label: 'Data change queue', code: 'CHNG', permission: 'data.approve' },
|
||||
{ href: '/admin/audit', label: 'Audit history', code: 'AUDT', permission: 'audit.view' },
|
||||
{ href: '/admin/metrics', label: 'System metrics', code: 'MTRX', permission: 'metrics.view' }
|
||||
];
|
||||
@@ -33,10 +37,10 @@
|
||||
<div class="sidebar-label"><span>USER OPERATIONS</span><b>{String(visibleUserSections.length).padStart(2, '0')} MODULE{visibleUserSections.length === 1 ? '' : 'S'}</b></div>
|
||||
<nav aria-label="User operations">{#each visibleUserSections as section}<a class:active={page.url.pathname.startsWith(section.href)} href={section.href}><small>{section.code}</small><span>{section.label}</span><b>›</b></a>{/each}</nav>
|
||||
{/if}
|
||||
{#if can('data.manage')}
|
||||
{#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><b>›</b></a>{/each}
|
||||
{#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}
|
||||
</nav>
|
||||
{/if}
|
||||
{#if visibleOversightSections.length}
|
||||
@@ -45,6 +49,17 @@
|
||||
{/if}
|
||||
<div class="sidebar-foot"><span>ISAC NODE</span><strong>NYC-01</strong><small>Access policy synchronized</small></div>
|
||||
</aside>
|
||||
<section class="admin-content">{@render children()}</section>
|
||||
<section class="admin-content" class:data-readonly={dataReadOnly} onsubmit={(event) => {
|
||||
if (dataReadOnly && (event.target as HTMLFormElement).method.toLowerCase() === 'post') {
|
||||
event.preventDefault();
|
||||
readOnlyBlocked = true;
|
||||
}
|
||||
}}>
|
||||
{#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 readOnlyBlocked}<p class="error data-access-error" role="alert">This account has read-only game-data access.</p>{/if}
|
||||
{@render children()}
|
||||
</section>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
@@ -7,7 +7,7 @@ export const load: PageServerLoad = async ({ cookies }) => {
|
||||
const permissions = await getPermissions(await getSessionUser(cookies));
|
||||
if (permissions.includes('*') || permissions.includes('users.view')) redirect(303, '/admin/users');
|
||||
if (permissions.includes('users.roles')) redirect(303, '/admin/roles');
|
||||
if (permissions.includes('data.manage')) redirect(303, '/admin/stats');
|
||||
if (permissions.includes('*') || ['data.view','data.manage','data.propose','data.approve'].some((permission) => permissions.includes(permission))) redirect(303, '/admin/stats');
|
||||
if (permissions.includes('audit.view')) redirect(303, '/admin/audit');
|
||||
if (permissions.includes('metrics.view')) redirect(303, '/admin/metrics');
|
||||
redirect(303, '/account');
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { requirePermission } from '$lib/server/auth/authorization';
|
||||
import { requireDataView, requirePermission } from '$lib/server/auth/authorization';
|
||||
import { getSessionUser } from '$lib/server/auth/session';
|
||||
import { getDatabase } from '$lib/server/database';
|
||||
import { fail } from '@sveltejs/kit';
|
||||
@@ -11,7 +11,7 @@ import { applyIconMutation, gameDataIconMetadata, gameDataIconUrl, GameDataIconV
|
||||
const thresholds = [2, 3, 4] as const;
|
||||
|
||||
export const load: PageServerLoad = async ({ cookies, url }) => {
|
||||
await requirePermission(await getSessionUser(cookies), 'data.manage');
|
||||
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,
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
import { fail } from '@sveltejs/kit';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { getSessionUser } from '$lib/server/auth/session';
|
||||
import { requirePermission } from '$lib/server/auth/authorization';
|
||||
import { getDatabase } from '$lib/server/database';
|
||||
import { deserializeProposal } from '$lib/server/data-moderation';
|
||||
import { getPagination } from '$lib/server/pagination';
|
||||
import { recordIsacAudit } from '$lib/server/audit';
|
||||
import type { Actions, PageServerLoad } from './$types';
|
||||
|
||||
const statuses = ['pending', 'approved', 'rejected', 'failed'];
|
||||
const modules = ['stats', 'talents', 'brands', 'gear', 'weapons', 'protocols'];
|
||||
|
||||
export const load: PageServerLoad = async ({ cookies, url }) => {
|
||||
await requirePermission(await getSessionUser(cookies), 'data.approve');
|
||||
const database = await getDatabase();
|
||||
const status = statuses.includes(url.searchParams.get('status') ?? '') ? String(url.searchParams.get('status')) : 'pending';
|
||||
const module = modules.includes(url.searchParams.get('module') ?? '') ? String(url.searchParams.get('module')) : '';
|
||||
const search = (url.searchParams.get('search') ?? '').trim().slice(0, 100);
|
||||
const conditions = ['p.status=?'];
|
||||
const args: unknown[] = [status];
|
||||
if (module) { conditions.push('p.module=?'); args.push(module); }
|
||||
if (search) { conditions.push("(p.summary LIKE ? ESCAPE '\\' COLLATE NOCASE OR proposer.username LIKE ? ESCAPE '\\' COLLATE NOCASE OR profile.display_name LIKE ? ESCAPE '\\' COLLATE NOCASE)"); const value = `%${search.replaceAll('\\','\\\\').replaceAll('%','\\%').replaceAll('_','\\_')}%`; args.push(value, value, value); }
|
||||
const where = `WHERE ${conditions.join(' AND ')}`;
|
||||
const total = Number((await (await database.prepare(`SELECT COUNT(*) AS total FROM data_change_proposals p JOIN users proposer ON proposer.id=p.proposed_by LEFT JOIN user_profiles profile ON profile.user_id=proposer.id ${where}`)).get(...args))?.total ?? 0);
|
||||
const pagination = getPagination(url, total);
|
||||
const proposals = await (await database.prepare(`SELECT p.*,COALESCE(profile.display_name,proposer.username) AS proposer_name,
|
||||
COALESCE(reviewer_profile.display_name,reviewer.username) AS reviewer_name
|
||||
FROM data_change_proposals p
|
||||
JOIN users proposer ON proposer.id=p.proposed_by
|
||||
LEFT JOIN user_profiles profile ON profile.user_id=proposer.id
|
||||
LEFT JOIN users reviewer ON reviewer.id=p.reviewed_by
|
||||
LEFT JOIN user_profiles reviewer_profile ON reviewer_profile.user_id=reviewer.id
|
||||
${where} ORDER BY p.created_at DESC LIMIT ? OFFSET ?`)).all(...args, pagination.perPage, pagination.offset);
|
||||
const summaryRows = await (await database.prepare('SELECT status,COUNT(*) AS count FROM data_change_proposals GROUP BY status')).all();
|
||||
return { proposals, pagination, status, module, search, summary: Object.fromEntries(summaryRows.map((row) => [String(row.status), Number(row.count)])), modules };
|
||||
};
|
||||
|
||||
export const actions: Actions = {
|
||||
approve: async ({ cookies, request, fetch, url }) => {
|
||||
const reviewer = await requirePermission(await getSessionUser(cookies), 'data.approve');
|
||||
const form = await request.formData();
|
||||
const id = String(form.get('id') ?? '');
|
||||
const note = String(form.get('note') ?? '').trim().slice(0, 500);
|
||||
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 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 now = new Date().toISOString();
|
||||
if (!response.ok) {
|
||||
const details = (await response.text()).slice(0, 1000);
|
||||
await (await database.prepare("UPDATE data_change_proposals SET status='failed',reviewed_by=?,review_note=?,reviewed_at=? WHERE id=?")).run(reviewer.id, details || note || 'Canonical validation failed.', now, id);
|
||||
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 } });
|
||||
return { moderationSaved: true, moderationToken: randomUUID() };
|
||||
},
|
||||
reject: async ({ cookies, request }) => {
|
||||
const reviewer = await requirePermission(await getSessionUser(cookies), 'data.approve');
|
||||
const form = await request.formData();
|
||||
const id = String(form.get('id') ?? '');
|
||||
const note = String(form.get('note') ?? '').trim().slice(0, 500);
|
||||
if (note.length < 3) return fail(400, { moderationError: 'Add a brief reason for rejecting this proposal.' });
|
||||
const database = await getDatabase();
|
||||
const proposal = await (await database.prepare("SELECT module,action,proposed_by FROM data_change_proposals WHERE id=? AND status='pending'")).get(id);
|
||||
if (!proposal) return fail(404, { moderationError: 'That pending proposal no longer exists.' });
|
||||
const now = new Date().toISOString();
|
||||
await (await database.prepare("UPDATE data_change_proposals SET status='rejected',reviewed_by=?,review_note=?,reviewed_at=? WHERE id=?")).run(reviewer.id, note, now, id);
|
||||
await recordIsacAudit({ actorId: reviewer.id, category: 'data_moderation', action: 'proposal.rejected', entityId: id, before: { status: 'pending' }, after: { status: 'rejected', note, module: proposal.module, action: proposal.action, proposedBy: proposal.proposed_by } });
|
||||
return { moderationSaved: true, moderationToken: randomUUID() };
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,75 @@
|
||||
<script lang="ts">
|
||||
import PendingForm from '$lib/components/PendingForm.svelte';
|
||||
import RegistryPagination from '$lib/components/RegistryPagination.svelte';
|
||||
import KpiFilters from '$lib/components/KpiFilters.svelte';
|
||||
let { data, form } = $props();
|
||||
let reviewing = $state<any>(null);
|
||||
let decision = $state<'approve' | 'reject'>('approve');
|
||||
let handledToken = $state('');
|
||||
const date = (value: string) => new Date(value).toLocaleString();
|
||||
const evidence = (payload: string) => (JSON.parse(payload) as any[]).map((entry) =>
|
||||
entry.kind === 'file' ? { name: entry.name, kind: 'file', fileName: entry.fileName, type: entry.type, encodedBytes: entry.value.length } : entry
|
||||
);
|
||||
const operationLabel = (value: string) => value.replace(/([A-Z])/g, ' $1').replaceAll('_', ' ').trim().toUpperCase();
|
||||
$effect(() => {
|
||||
if (form?.moderationSaved && form.moderationToken && form.moderationToken !== handledToken) {
|
||||
handledToken = form.moderationToken;
|
||||
reviewing = null;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<svelte:head><title>Data change queue — ISAC</title></svelte:head>
|
||||
<div class="module-toolbar moderation-toolbar">
|
||||
<div><p class="eyebrow">DATA OVERSIGHT / CONTRIBUTION CONTROL</p><h2>CHANGE MODERATION</h2><p>Validate contributor intelligence before deployment to the live builder registry.</p></div>
|
||||
<div class="queue-readout"><span>PENDING REVIEW</span><strong>{data.summary.pending ?? 0}</strong><small>CHANGE REQUESTS</small></div>
|
||||
</div>
|
||||
<KpiFilters parameter="status" active={data.status} items={[
|
||||
{value:'pending',label:'PENDING',count:data.summary.pending ?? 0,description:'Awaiting operator decision'},
|
||||
{value:'approved',label:'APPROVED',count:data.summary.approved ?? 0,description:'Applied to live data'},
|
||||
{value:'rejected',label:'REJECTED',count:data.summary.rejected ?? 0,description:'Declined by oversight'},
|
||||
{value:'failed',label:'FAILED',count:data.summary.failed ?? 0,description:'Blocked by validation'}
|
||||
]}/>
|
||||
<form class="audit-filter moderation-filter">
|
||||
<input name="search" value={data.search} placeholder="CHANGE, RECORD, OR CONTRIBUTOR"/>
|
||||
<select name="module"><option value="">ALL DATA MODULES</option>{#each data.modules as module}<option value={module} selected={data.module === module}>{module.toUpperCase()}</option>{/each}</select>
|
||||
<input type="hidden" name="status" value={data.status}/>
|
||||
<button class="secondary">SEARCH QUEUE →</button>
|
||||
</form>
|
||||
<RegistryPagination pagination={data.pagination}/>
|
||||
<div class="dashboard-table moderation-queue">
|
||||
<div class="dashboard-row moderation-row table-heading"><span>CHANGE</span><span>CONTRIBUTOR</span><span>SUBMITTED</span><span>STATUS</span><span></span></div>
|
||||
{#each data.proposals as proposal}
|
||||
<div class="dashboard-row moderation-row">
|
||||
<div class="change-identity"><span>{proposal.module.slice(0,4).toUpperCase()}</span><div><strong>{proposal.summary}</strong><small>{proposal.module.toUpperCase()} / {operationLabel(proposal.action)}</small></div></div>
|
||||
<div><strong>{proposal.proposer_name}</strong><small>AGENT {String(proposal.proposed_by).slice(0,8).toUpperCase()}</small></div>
|
||||
<span>{date(proposal.created_at)}</span>
|
||||
<span class="proposal-state" class:pending={proposal.status === 'pending'} class:approved={proposal.status === 'approved'} class:declined={proposal.status === 'rejected' || proposal.status === 'failed'}><i></i>{proposal.status.toUpperCase()}</span>
|
||||
{#if proposal.status === 'pending'}<button onclick={() => { reviewing = proposal; decision = 'approve'; }}>REVIEW →</button>{:else}<button onclick={() => reviewing = proposal}>EVIDENCE →</button>{/if}
|
||||
</div>
|
||||
{:else}<div class="empty-dashboard"><strong>NO {data.status.toUpperCase()} CHANGE REQUESTS</strong><span>Adjust the module or search filters to inspect another part of the queue.</span></div>{/each}
|
||||
</div>
|
||||
|
||||
{#if reviewing}
|
||||
<div class="isac-modal-backdrop" role="presentation">
|
||||
<div class="isac-confirm-modal moderation-review" role="dialog" aria-modal="true" aria-labelledby="moderation-title">
|
||||
<div class="editor-title"><div><p class="eyebrow">CHANGE CONTROL / {reviewing.status.toUpperCase()}</p><h3 id="moderation-title">{reviewing.summary}</h3></div><button aria-label="Close review" onclick={() => reviewing = null}>×</button></div>
|
||||
<div class="moderation-metadata"><span>DATA MODULE <b>{reviewing.module.toUpperCase()}</b></span><span>REQUESTED ACTION <b>{operationLabel(reviewing.action)}</b></span><span>CONTRIBUTOR <b>{reviewing.proposer_name}</b></span><span>SUBMITTED <b>{date(reviewing.created_at)}</b></span></div>
|
||||
<div class="evidence-header"><div><span>PROPOSED FORM PAYLOAD</span><small>CANONICAL VALIDATION RUNS WHEN APPROVED</small></div><b>{evidence(reviewing.payload_json).length} FIELDS</b></div>
|
||||
<pre class="moderation-evidence">{JSON.stringify(evidence(reviewing.payload_json), null, 2)}</pre>
|
||||
{#if reviewing.review_note}<div class="review-note"><span>OVERSIGHT NOTE</span><p>{reviewing.review_note}</p></div>{/if}
|
||||
{#if reviewing.status === 'pending'}
|
||||
<div class="decision-tabs"><button class:active={decision === 'approve'} onclick={() => decision = 'approve'}><b>✓</b><span>APPROVE<small>APPLY TO LIVE DATA</small></span></button><button class:active={decision === 'reject'} onclick={() => decision = 'reject'}><b>×</b><span>REJECT<small>RETURN WITHOUT CHANGES</small></span></button></div>
|
||||
<PendingForm action={decision === 'approve' ? '?/approve' : '?/reject'}>
|
||||
{#snippet children(pending)}
|
||||
<input type="hidden" name="id" value={reviewing.id}/>
|
||||
<label class="field">{decision === 'approve' ? 'OPTIONAL OVERSIGHT NOTE' : 'REJECTION REASON — REQUIRED'}<textarea name="note" required={decision === 'reject'} placeholder={decision === 'approve' ? 'Record context for the audit trail…' : 'Explain what the contributor should revise…'}></textarea></label>
|
||||
{#if form?.moderationError}<p class="error">{form.moderationError}</p>{/if}
|
||||
<div class="moderation-warning" class:rejecting={decision === 'reject'}><b>{decision === 'approve' ? '!' : '×'}</b><span>{decision === 'approve' ? 'APPROVAL WILL RUN VALIDATION AND MUTATE THE LIVE BUILDER DATA SET.' : 'REJECTION WILL PRESERVE THIS REQUEST AND YOUR REASON IN THE AUDIT TRAIL.'}</span></div>
|
||||
<div class="form-actions"><button type="button" class="secondary" onclick={() => reviewing = null} disabled={pending}>CANCEL</button><button class={decision === 'approve' ? 'primary' : 'danger'} disabled={pending} aria-busy={pending}>{pending ? 'PROCESSING…' : decision === 'approve' ? 'CONFIRM & DEPLOY →' : 'CONFIRM REJECTION →'}</button></div>
|
||||
{/snippet}
|
||||
</PendingForm>
|
||||
{:else}<div class="form-actions"><button class="primary" onclick={() => reviewing = null}>CLOSE EVIDENCE</button></div>{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { requirePermission } from '$lib/server/auth/authorization';
|
||||
import { requireDataView, requirePermission } from '$lib/server/auth/authorization';
|
||||
import { getSessionUser } from '$lib/server/auth/session';
|
||||
import { getDatabase } from '$lib/server/database';
|
||||
import { fail } from '@sveltejs/kit';
|
||||
@@ -11,7 +11,7 @@ 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 requirePermission(await getSessionUser(cookies), 'data.manage');
|
||||
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,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { auditRecord, recordIsacAudit } from '$lib/server/audit';
|
||||
import { requirePermission } from '$lib/server/auth/authorization';
|
||||
import { requireDataView, requirePermission } from '$lib/server/auth/authorization';
|
||||
import { getSessionUser } from '$lib/server/auth/session';
|
||||
import { getDatabase } from '$lib/server/database';
|
||||
import { getPagination } from '$lib/server/pagination';
|
||||
@@ -48,7 +48,7 @@ async function profileSnapshot(id: string) {
|
||||
}
|
||||
|
||||
export const load: PageServerLoad = async ({ cookies, url }) => {
|
||||
await requirePermission(await getSessionUser(cookies), 'data.manage');
|
||||
await requireDataView(await getSessionUser(cookies));
|
||||
const database = await getDatabase();
|
||||
const requestedFilter = url.searchParams.get('filter') ?? '';
|
||||
const filter = [...qualities.map((quality) => quality.toLowerCase()), ...attributeTypes.map((type) => type.toLowerCase()), 'active', 'archived'].includes(requestedFilter)
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { getSessionUser } from '$lib/server/auth/session';
|
||||
import { requirePermission } from '$lib/server/auth/authorization';
|
||||
import { requireDataView, requirePermission } from '$lib/server/auth/authorization';
|
||||
import { getDatabase } from '$lib/server/database';
|
||||
import { fail } from '@sveltejs/kit';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
@@ -8,7 +8,7 @@ import { getPagination } from '$lib/server/pagination';
|
||||
import { auditRecord, recordIsacAudit } from '$lib/server/audit';
|
||||
|
||||
export const load: PageServerLoad = async ({ cookies, url }) => {
|
||||
await requirePermission(await getSessionUser(cookies), 'data.manage');
|
||||
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,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { getSessionUser } from '$lib/server/auth/session';
|
||||
import { requirePermission } from '$lib/server/auth/authorization';
|
||||
import { requireDataView, requirePermission } from '$lib/server/auth/authorization';
|
||||
import { getDatabase } from '$lib/server/database';
|
||||
import { fail } from '@sveltejs/kit';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
@@ -11,7 +11,7 @@ import { applyIconMutation, gameDataIconMetadata, gameDataIconUrl, GameDataIconV
|
||||
type SharedEffect = { statId: string; valueI: number; valueII: number };
|
||||
|
||||
export const load: PageServerLoad = async ({ cookies, url }) => {
|
||||
await requirePermission(await getSessionUser(cookies), 'data.manage');
|
||||
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')) : '';
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { requirePermission } from '$lib/server/auth/authorization';
|
||||
import { requireDataView, requirePermission } from '$lib/server/auth/authorization';
|
||||
import { getSessionUser } from '$lib/server/auth/session';
|
||||
import { getDatabase } from '$lib/server/database';
|
||||
import { fail } from '@sveltejs/kit';
|
||||
@@ -23,7 +23,7 @@ const renderTalentDescription = (template: string, effects: ExoticEffect[], unit
|
||||
});
|
||||
|
||||
export const load: PageServerLoad = async ({ cookies, url }) => {
|
||||
await requirePermission(await getSessionUser(cookies), 'data.manage');
|
||||
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,
|
||||
|
||||
Reference in New Issue
Block a user