From 472528f42976e9b0e5483b3c39ee2a214616ac9b Mon Sep 17 00:00:00 2001 From: JB Date: Fri, 24 Jul 2026 23:21:28 -0400 Subject: [PATCH] Add ISAC user operations and access roles --- database/schema.sql | 38 +++++++++ docs/DESIGN_SYSTEM.md | 11 +++ src/app.css | 2 + src/lib/components/AppShell.svelte | 7 +- src/lib/server/auth/authorization.ts | 35 +++++++-- src/lib/server/auth/session.ts | 22 +++++- src/routes/+layout.server.ts | 8 +- src/routes/+layout.svelte | 2 +- src/routes/admin/+layout.server.ts | 6 +- src/routes/admin/+layout.svelte | 30 +++---- src/routes/admin/+page.server.ts | 11 ++- src/routes/admin/brands/+page.server.ts | 9 ++- src/routes/admin/gear/+page.server.ts | 9 ++- src/routes/admin/stats/+page.server.ts | 9 ++- src/routes/admin/talents/+page.server.ts | 9 ++- src/routes/admin/users/+page.server.ts | 32 ++++++++ src/routes/admin/users/+page.svelte | 12 +++ src/routes/admin/users/[id]/+page.server.ts | 86 +++++++++++++++++++++ src/routes/admin/users/[id]/+page.svelte | 41 ++++++++++ src/routes/auth/discord/callback/+server.ts | 9 ++- src/routes/sign-in/+page.server.ts | 16 +++- 21 files changed, 355 insertions(+), 49 deletions(-) create mode 100644 src/routes/admin/users/+page.server.ts create mode 100644 src/routes/admin/users/+page.svelte create mode 100644 src/routes/admin/users/[id]/+page.server.ts create mode 100644 src/routes/admin/users/[id]/+page.svelte diff --git a/database/schema.sql b/database/schema.sql index c255c77..f643713 100644 --- a/database/schema.sql +++ b/database/schema.sql @@ -14,6 +14,44 @@ CREATE TABLE IF NOT EXISTS roles (id TEXT PRIMARY KEY, name TEXT NOT NULL UNIQUE CREATE TABLE IF NOT EXISTS permissions (id TEXT PRIMARY KEY, name TEXT NOT NULL UNIQUE, description TEXT); CREATE TABLE IF NOT EXISTS user_roles (user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, role_id TEXT NOT NULL REFERENCES roles(id) ON DELETE CASCADE, PRIMARY KEY(user_id, role_id)); CREATE TABLE IF NOT EXISTS role_permissions (role_id TEXT NOT NULL REFERENCES roles(id) ON DELETE CASCADE, permission_id TEXT NOT NULL REFERENCES permissions(id) ON DELETE CASCADE, scope_json TEXT, PRIMARY KEY(role_id, permission_id)); +CREATE TABLE IF NOT EXISTS user_permissions (user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, permission_id TEXT NOT NULL REFERENCES permissions(id) ON DELETE CASCADE, PRIMARY KEY(user_id, permission_id)); +CREATE TABLE IF NOT EXISTS user_status ( + user_id TEXT PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE, + last_active_at TEXT, + blocked_at TEXT, + blocked_by TEXT REFERENCES users(id), + banned_until TEXT, + moderation_reason TEXT, + updated_at TEXT NOT NULL +); +CREATE TABLE IF NOT EXISTS login_events ( + id TEXT PRIMARY KEY, + user_id TEXT REFERENCES users(id) ON DELETE SET NULL, + provider TEXT NOT NULL CHECK(provider IN ('password', 'discord')), + succeeded INTEGER NOT NULL CHECK(succeeded IN (0, 1)), + created_at TEXT NOT NULL +); +CREATE INDEX IF NOT EXISTS login_events_user_created_idx ON login_events(user_id, created_at DESC); +INSERT OR IGNORE INTO roles(id,name,description) VALUES('role-data-editor','Data Editor','Access to ISAC game-data management'); +INSERT OR IGNORE INTO roles(id,name,description) VALUES('role-user-operator','User Operator','View and moderate SHD.build users'); +INSERT OR IGNORE INTO roles(id,name,description) VALUES('role-access-manager','Access Manager','Manage user roles and direct permissions'); +INSERT OR IGNORE INTO roles(id,name,description) VALUES('role-admin','Admin','Full administrative access to all current and future ISAC capabilities'); +INSERT OR IGNORE INTO roles(id,name,description) VALUES('role-moderator','Moderator','Community moderation role reserved for build, guide, and comment tools'); +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-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'); +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-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'); +INSERT OR IGNORE INTO role_permissions(role_id,permission_id) VALUES('role-access-manager','permission-isac-access'); +INSERT OR IGNORE INTO role_permissions(role_id,permission_id) VALUES('role-access-manager','permission-users-view'); +INSERT OR IGNORE INTO role_permissions(role_id,permission_id) VALUES('role-access-manager','permission-users-roles'); +INSERT OR IGNORE INTO role_permissions(role_id,permission_id) VALUES('role-admin','permission-system-admin'); 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 UNIQUE INDEX IF NOT EXISTS users_username_nocase_idx ON users(username COLLATE NOCASE); CREATE TABLE IF NOT EXISTS stat_attributes ( diff --git a/docs/DESIGN_SYSTEM.md b/docs/DESIGN_SYSTEM.md index 2b66449..43ee35c 100644 --- a/docs/DESIGN_SYSTEM.md +++ b/docs/DESIGN_SYSTEM.md @@ -83,10 +83,21 @@ Use the spacing tokens for new components. Prefer `8px`, `12px`, and `20px` gaps ### Admin dashboard - Use `admin-page`, `admin-command`, `admin-dashboard`, `admin-sidebar`, and `admin-content`. +- The administrative system is named ISAC. Separate navigation into explicit + operational domains such as User Operations and Data Management; visibility + must reflect server-provided capabilities. - Each module starts with `module-toolbar`, followed by `module-kpis`. - Editors use `dashboard-editor` and appear before the affected table. - Data collections use `dashboard-table`, `dashboard-row`, and `table-heading`. - Keep tables dense and horizontally scrollable rather than wrapping columns unpredictably. +- User dossiers pair access controls and moderation in a two-column console, + followed by compact login and content-history streams. Restriction state must + be textual and use the danger color; inactivity remains a separate activity + classification rather than a punishment state. +- Role names describe durable organizational responsibility. `Admin` is the + unrestricted administrative role; `Moderator` is reserved for community + content moderation and does not imply ISAC access. Operational ISAC access is + delegated separately through scoped roles and permissions. - Threshold-based set bonuses use compact `effect-band` groups. Each threshold is displayed as a fixed piece-count marker followed by its mapped stat and value, keeping progression readable in editors and table summaries. diff --git a/src/app.css b/src/app.css index 616f95f..599c3f3 100644 --- a/src/app.css +++ b/src/app.css @@ -66,5 +66,7 @@ .gear-identity{display:grid;grid-template-columns:2fr 1fr .65fr .65fr;gap:8px}.gear-ranges{display:grid;grid-template-columns:1fr 1fr;gap:8px}.range-band{grid-template-columns:58px 1fr 1fr}.effect-band.unavailable{opacity:.4}.selection-head{display:flex;justify-content:space-between;align-items:end;margin:14px 0 6px}.selection-head>div{display:grid}.selection-head span{font-size:8px;font-weight:bold;letter-spacing:1.2px}.selection-head small{font-size:7px;color:#697174;margin-top:3px}.selection-head b{color:#f6a821;font-size:8px;letter-spacing:1px}.selection-grid{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));border:1px solid #303536}.selection-grid label{min-height:38px;display:grid;grid-template-columns:20px 1fr auto;align-items:center;gap:5px;padding:6px 8px;border:1px solid transparent;border-right-color:#282d2e;border-bottom-color:#282d2e;cursor:pointer;font-size:9px}.selection-grid label:hover{background:#171b1c;border-color:#4b4f50}.selection-grid input{width:auto;margin:0;accent-color:#f6a821}.selection-grid small{font-size:6px;color:#646c6e;letter-spacing:.8px}.selection-grid input:checked~small{color:#75c08c}.gear-row{min-width:1050px;grid-template-columns:1.2fr .8fr .8fr 1.35fr .8fr .6fr .3fr}.gear-row>span small{font-size:8px;color:#858b8d} .unit-field{min-width:0;margin:8px 0;padding:0;border:0}.unit-field legend{font-size:8px;font-weight:bold;letter-spacing:1px;color:#858b8d}.unit-picker{display:grid;grid-template-columns:repeat(5,1fr);height:32px;margin-top:5px;border:1px solid #424849}.unit-picker label{display:grid;place-items:center;border-left:1px solid #303536;cursor:pointer}.unit-picker label:first-child{border-left:0}.unit-picker input{position:absolute;opacity:0;pointer-events:none}.unit-picker span{width:100%;height:100%;display:grid;place-items:center;color:#777f81;font-size:8px;font-weight:bold}.unit-picker label:hover span{background:#191d1e;color:#ecebe5}.unit-picker input:checked+span{background:#2b2111;color:#f6a821;box-shadow:inset 0 -2px #f6a821}.unit-picker input:focus-visible+span{outline:2px solid #f6a821;outline-offset:2px} .template-field{display:grid;margin-top:10px}.template-field small{margin-top:5px;color:#697174;font-size:7px}.tier-preview{display:grid;grid-template-columns:1fr 1fr;gap:8px;margin:8px 0 12px}.tier-preview>div{padding:9px 11px;border:1px solid #343a3b;background:#101314}.tier-preview span,.effect-heading{color:#697174;font-size:7px;letter-spacing:1.1px}.tier-preview p{min-height:30px;margin:5px 0 0;color:#d9d9d3;font-size:10px;line-height:1.45}.shared-trigger-list{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));max-height:170px}.shared-effects{border:1px solid #303536}.shared-effect{display:grid;grid-template-columns:minmax(180px,1fr) 120px 120px 30px;gap:6px;align-items:center;padding:6px;border-top:1px solid #292f30}.shared-effect:first-child{border-top:0}.shared-effect select,.shared-effect input{min-width:0;margin:0;padding:7px;font-size:9px}.shared-effect button{height:30px;border:1px solid #563734;background:#1d1212;color:#da746c}.effect-heading{min-height:28px;background:#0d1011}.effect-heading span:last-child{display:none} +.section-label{margin-top:10px;border-top:1px solid #2c3132}.user-search{display:flex;gap:6px}.user-search input{height:40px;min-width:240px;margin:0;padding:0 10px;font-size:9px}.user-search button{height:40px}.user-row{min-width:1020px;grid-template-columns:1.1fr .75fr 1.1fr 1.1fr 1fr .3fr}.restricted{color:#df8069!important}.row-action{justify-self:end;color:#f6a821;font-size:8px;font-weight:bold}.button-link{display:grid;place-items:center;height:40px;padding:0 14px;border:1px solid #424849;color:#d7d9d5;font-size:8px;font-weight:bold}.metric-date{font-size:11px!important}.user-console-grid{display:grid;grid-template-columns:minmax(0,1fr) 320px;gap:10px}.user-console-grid .dashboard-editor{margin-top:0}.permission-grid{grid-template-columns:repeat(2,minmax(0,1fr))}.moderation-panel .field{display:block}.console-note{padding:20px;color:#747c7e;font-size:9px}.user-history-grid{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:10px}.user-history-grid>section{min-width:0;border:1px solid #303536;background:#111415}.history-head{display:flex;justify-content:space-between;padding:10px;background:#0d1011;color:#727a7c;font-size:7px;letter-spacing:1px}.history-list{max-height:300px;overflow:auto}.history-list>div{display:grid;grid-template-columns:1fr auto auto;gap:8px;padding:9px 10px;border-top:1px solid #292e2f;font-size:8px}.history-list span{color:#767e80}.history-list b{font-size:7px}.history-list>p{padding:28px;text-align:center;color:#626a6c;font-size:8px}.comment-history>div{grid-template-columns:1fr}.comment-history span{white-space:normal;line-height:1.4} +@media(max-width:1000px){.user-console-grid,.user-history-grid{grid-template-columns:1fr}.permission-grid{grid-template-columns:1fr 1fr}}@media(max-width:560px){.user-search{width:100%}.user-search input{min-width:0;flex:1}.permission-grid{grid-template-columns:1fr}} @media(max-width:1100px){.effect-editor{grid-template-columns:1fr}.selection-grid{grid-template-columns:repeat(2,minmax(0,1fr))}}@media(max-width:900px){.gear-identity,.gear-ranges{grid-template-columns:1fr 1fr}}@media(max-width:560px){.effect-band{grid-template-columns:48px 1fr}.effect-band .field:last-child{grid-column:2}.split-actions{align-items:stretch}.split-actions,.split-actions>div{width:100%}.split-actions>div:last-child button{flex:1}.gear-identity,.gear-ranges,.selection-grid{grid-template-columns:1fr}.range-band{grid-template-columns:48px 1fr}.range-band .field:last-child{grid-column:2}} diff --git a/src/lib/components/AppShell.svelte b/src/lib/components/AppShell.svelte index ba55523..edb640c 100644 --- a/src/lib/components/AppShell.svelte +++ b/src/lib/components/AppShell.svelte @@ -3,8 +3,9 @@ import PendingForm from '$lib/components/PendingForm.svelte'; import type { Snippet } from 'svelte'; - let { user, children }: { + let { user, isacAccess, children }: { user: { username: string; role: string } | null; + isacAccess: boolean; children: Snippet; } = $props(); @@ -26,8 +27,8 @@ {#if user} My Profile {/if} - {#if user?.role === 'admin'} - Admin + {#if isacAccess} + ISAC {/if} {#if user} diff --git a/src/lib/server/auth/authorization.ts b/src/lib/server/auth/authorization.ts index da7c310..18e6784 100644 --- a/src/lib/server/auth/authorization.ts +++ b/src/lib/server/auth/authorization.ts @@ -1,11 +1,36 @@ +import { getDatabase } from '$lib/server/database'; import type { AuthUser } from './session'; import { error } from '@sveltejs/kit'; -/** - * Every admin page, action, and endpoint must call this server-side. - * The capability-based permission checks will replace the broad admin role - * as the permission editor is introduced. - */ +export async function getPermissions(user: AuthUser | null): Promise { + if (!user) return []; + if (user.role === 'admin') return ['*']; + const database = await getDatabase(); + const rows = await (await database.prepare(` + SELECT permissions.name FROM permissions + JOIN role_permissions ON role_permissions.permission_id=permissions.id + JOIN user_roles ON user_roles.role_id=role_permissions.role_id + WHERE user_roles.user_id=? + UNION + SELECT permissions.name FROM permissions + JOIN user_permissions ON user_permissions.permission_id=permissions.id + WHERE user_permissions.user_id=? + `)).all(user.id, user.id); + const permissions = rows.map((row) => String(row.name)); + return permissions.includes('system.admin') ? ['*'] : permissions; +} + +export async function hasPermission(user: AuthUser | null, permission: string): Promise { + const permissions = await getPermissions(user); + return permissions.includes('*') || permissions.includes(permission); +} + +export async function requirePermission(user: AuthUser | null, permission: string): Promise { + if (!user) error(401, 'Sign in required'); + if (!(await hasPermission(user, permission))) error(403, 'ISAC permission 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'); diff --git a/src/lib/server/auth/session.ts b/src/lib/server/auth/session.ts index da44fc1..b189114 100644 --- a/src/lib/server/auth/session.ts +++ b/src/lib/server/auth/session.ts @@ -43,15 +43,23 @@ export async function createSession(cookies: Cookies, userId: string): Promise { + const database = await getDatabase(); + await (await database.prepare('INSERT INTO login_events(id,user_id,provider,succeeded,created_at) VALUES(?,?,?,?,?)')) + .run(randomUUID(), userId, provider, succeeded ? 1 : 0, new Date().toISOString()); +} + export async function getSessionUser(cookies: Cookies): Promise { const token = cookies.get(COOKIE_NAME); if (!token) return null; const database = await getDatabase(); const statement = await database.prepare(` - SELECT users.id, users.username, users.role, users.discord_id + SELECT users.id, users.username, users.role, users.discord_id, + user_status.last_active_at,user_status.blocked_at,user_status.banned_until FROM sessions JOIN users ON users.id = sessions.user_id + LEFT JOIN user_status ON user_status.user_id=users.id WHERE sessions.token_hash = ? AND sessions.expires_at > ? `); const user = await statement.get(hashToken(token), new Date().toISOString()); @@ -60,6 +68,18 @@ export async function getSessionUser(cookies: Cookies): Promise cookies.delete(COOKIE_NAME, { path: '/' }); return null; } + const now = new Date(); + const banned = typeof user.banned_until === 'string' && new Date(user.banned_until) > now; + if (user.blocked_at || banned) { + await (await database.prepare('DELETE FROM sessions WHERE token_hash=?')).run(hashToken(token)); + cookies.delete(COOKIE_NAME, { path: '/' }); + return null; + } + if (!user.last_active_at || now.getTime() - new Date(String(user.last_active_at)).getTime() > 5 * 60 * 1000) { + await (await database.prepare(`INSERT INTO user_status(user_id,last_active_at,updated_at) VALUES(?,?,?) + ON CONFLICT(user_id) DO UPDATE SET last_active_at=excluded.last_active_at,updated_at=excluded.updated_at`)) + .run(String(user.id), now.toISOString(), now.toISOString()); + } return { id: String(user.id), diff --git a/src/routes/+layout.server.ts b/src/routes/+layout.server.ts index 966b5f0..53b8e61 100644 --- a/src/routes/+layout.server.ts +++ b/src/routes/+layout.server.ts @@ -1,6 +1,8 @@ import { getSessionUser } from '$lib/server/auth/session'; +import { hasPermission } from '$lib/server/auth/authorization'; import type { LayoutServerLoad } from './$types'; -export const load: LayoutServerLoad = async ({ cookies }) => ({ - user: await getSessionUser(cookies) -}); +export const load: LayoutServerLoad = async ({ cookies }) => { + const user = await getSessionUser(cookies); + return { user, isacAccess: await hasPermission(user, 'isac.access') }; +}; diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte index 00f57b8..c893481 100644 --- a/src/routes/+layout.svelte +++ b/src/routes/+layout.svelte @@ -8,4 +8,4 @@ -{@render children()} +{@render children()} diff --git a/src/routes/admin/+layout.server.ts b/src/routes/admin/+layout.server.ts index f15d54c..49bd9f3 100644 --- a/src/routes/admin/+layout.server.ts +++ b/src/routes/admin/+layout.server.ts @@ -1,9 +1,11 @@ import { getSessionUser } from '$lib/server/auth/session'; +import { getPermissions } from '$lib/server/auth/authorization'; import { redirect } from '@sveltejs/kit'; import type { LayoutServerLoad } from './$types'; export const load: LayoutServerLoad = async ({ cookies }) => { const user = await getSessionUser(cookies); if (!user) redirect(303, '/sign-in'); - if (user.role !== 'admin') redirect(303, '/account'); - return {}; + const permissions = await getPermissions(user); + if (!permissions.includes('*') && !permissions.includes('isac.access')) redirect(303, '/account'); + return { permissions }; }; diff --git a/src/routes/admin/+layout.svelte b/src/routes/admin/+layout.svelte index 42dc964..6d75b67 100644 --- a/src/routes/admin/+layout.svelte +++ b/src/routes/admin/+layout.svelte @@ -1,31 +1,35 @@
-

SHD DATA OPERATIONS

ADMIN CONSOLE

Embedded game-data control surface

-
DATABASEONLINE
ACCESSADMIN
ENVIRONMENTLOCAL
+

INTELLIGENT SYSTEM ANALYTIC COMPUTER

ISAC

SHD.build operations and intelligence control surface

+
SYSTEMONLINE
ACCESSAUTHORIZED
ENVIRONMENTLOCAL
{@render children()}
diff --git a/src/routes/admin/+page.server.ts b/src/routes/admin/+page.server.ts index 75e333c..16cd2aa 100644 --- a/src/routes/admin/+page.server.ts +++ b/src/routes/admin/+page.server.ts @@ -1,2 +1,11 @@ +import { getPermissions } from '$lib/server/auth/authorization'; +import { getSessionUser } from '$lib/server/auth/session'; import { redirect } from '@sveltejs/kit'; -export const load = () => redirect(303, '/admin/stats'); +import type { PageServerLoad } from './$types'; + +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('data.manage')) redirect(303, '/admin/stats'); + redirect(303, '/account'); +}; diff --git a/src/routes/admin/brands/+page.server.ts b/src/routes/admin/brands/+page.server.ts index b8f41dc..786e7d6 100644 --- a/src/routes/admin/brands/+page.server.ts +++ b/src/routes/admin/brands/+page.server.ts @@ -1,4 +1,4 @@ -import { requireAdmin } from '$lib/server/auth/authorization'; +import { requirePermission } from '$lib/server/auth/authorization'; import { getSessionUser } from '$lib/server/auth/session'; import { getDatabase } from '$lib/server/database'; import { fail } from '@sveltejs/kit'; @@ -7,7 +7,8 @@ import type { Actions, PageServerLoad } from './$types'; const thresholds = [2, 3, 4] as const; -export const load: PageServerLoad = async () => { +export const load: PageServerLoad = async ({ cookies }) => { + await requirePermission(await getSessionUser(cookies), 'data.manage'); const database = await getDatabase(); const [brands, effects, stats] = await Promise.all([ (await database.prepare('SELECT * FROM brand_sets ORDER BY active DESC, name COLLATE NOCASE')).all(), @@ -22,7 +23,7 @@ export const load: PageServerLoad = async () => { export const actions: Actions = { save: async ({ request, cookies }) => { - requireAdmin(await getSessionUser(cookies)); + await requirePermission(await getSessionUser(cookies), 'data.manage'); const form = await request.formData(); const id = String(form.get('id') ?? ''); const name = String(form.get('name') ?? '').normalize('NFKC').trim(); @@ -49,7 +50,7 @@ export const actions: Actions = { return { brandSaved: true, brandMode: id ? 'edit' : 'add', resultToken: randomUUID() }; }, archive: async ({ request, cookies }) => { - requireAdmin(await getSessionUser(cookies)); + await requirePermission(await getSessionUser(cookies), 'data.manage'); const id = String((await request.formData()).get('id') ?? ''); await (await (await getDatabase()).prepare('UPDATE brand_sets SET active=0,updated_at=? WHERE id=?')).run(new Date().toISOString(), id); return { brandArchived: true }; diff --git a/src/routes/admin/gear/+page.server.ts b/src/routes/admin/gear/+page.server.ts index 91356e2..633d237 100644 --- a/src/routes/admin/gear/+page.server.ts +++ b/src/routes/admin/gear/+page.server.ts @@ -1,4 +1,4 @@ -import { requireAdmin } from '$lib/server/auth/authorization'; +import { requirePermission } from '$lib/server/auth/authorization'; import { getSessionUser } from '$lib/server/auth/session'; import { getDatabase } from '$lib/server/database'; import { fail } from '@sveltejs/kit'; @@ -8,7 +8,8 @@ import type { Actions, PageServerLoad } from './$types'; const slots = ['Holster', 'Gloves', 'Backpack', 'Knee Pads', 'Body Armor', 'Mask']; const armoredSlots = new Set(['Gloves', 'Knee Pads', 'Body Armor']); -export const load: PageServerLoad = async () => { +export const load: PageServerLoad = async ({ cookies }) => { + await requirePermission(await getSessionUser(cookies), 'data.manage'); const database = await getDatabase(); const [gear, brandLinks, talentLinks, brands, talents] = await Promise.all([ (await database.prepare('SELECT * FROM gear ORDER BY active DESC, slot, name COLLATE NOCASE')).all(), @@ -37,7 +38,7 @@ const readRange = (form: FormData, prefix: string) => { export const actions: Actions = { save: async ({ request, cookies }) => { - requireAdmin(await getSessionUser(cookies)); + await requirePermission(await getSessionUser(cookies), 'data.manage'); const form = await request.formData(); const id = String(form.get('id') ?? ''); const name = String(form.get('name') ?? '').normalize('NFKC').trim(); @@ -87,7 +88,7 @@ export const actions: Actions = { return { gearSaved: true, gearMode: id ? 'edit' : 'add', resultToken: randomUUID() }; }, archive: async ({ request, cookies }) => { - requireAdmin(await getSessionUser(cookies)); + await requirePermission(await getSessionUser(cookies), 'data.manage'); const id = String((await request.formData()).get('id') ?? ''); await (await (await getDatabase()).prepare('UPDATE gear SET active=0,updated_at=? WHERE id=?')).run(new Date().toISOString(), id); return { gearArchived: true }; diff --git a/src/routes/admin/stats/+page.server.ts b/src/routes/admin/stats/+page.server.ts index 2e4d795..52ef69e 100644 --- a/src/routes/admin/stats/+page.server.ts +++ b/src/routes/admin/stats/+page.server.ts @@ -1,18 +1,19 @@ import { getSessionUser } from '$lib/server/auth/session'; -import { requireAdmin } from '$lib/server/auth/authorization'; +import { requirePermission } from '$lib/server/auth/authorization'; import { getDatabase } from '$lib/server/database'; import { fail } from '@sveltejs/kit'; import { randomUUID } from 'node:crypto'; import type { Actions, PageServerLoad } from './$types'; -export const load: PageServerLoad = async () => { +export const load: PageServerLoad = async ({ cookies }) => { + await requirePermission(await getSessionUser(cookies), 'data.manage'); const database = await getDatabase(); const stats = await (await database.prepare(`SELECT * FROM stat_attributes ORDER BY active DESC, name COLLATE NOCASE`)).all(); return { stats: stats.map((stat) => ({ ...stat, unit: stat.unit === 'flat' ? '' : stat.unit })) }; }; export const actions: Actions = { save: async ({ request, cookies }) => { - requireAdmin(await getSessionUser(cookies)); + await requirePermission(await getSessionUser(cookies), 'data.manage'); const form = await request.formData(); const id = String(form.get('id') ?? ''), name = String(form.get('name') ?? '').normalize('NFKC').trim(); const type = String(form.get('type') ?? ''), category = String(form.get('category') ?? ''), calculation = String(form.get('calculationType') ?? ''), unit = String(form.get('unit') ?? ''); @@ -31,7 +32,7 @@ export const actions: Actions = { return { saved: true }; }, archive: async ({ request, cookies }) => { - requireAdmin(await getSessionUser(cookies)); const id = String((await request.formData()).get('id') ?? ''); + await requirePermission(await getSessionUser(cookies), 'data.manage'); const id = String((await request.formData()).get('id') ?? ''); await (await (await getDatabase()).prepare('UPDATE stat_attributes SET active=0,updated_at=? WHERE id=?')).run(new Date().toISOString(),id); return { archived: true }; } }; diff --git a/src/routes/admin/talents/+page.server.ts b/src/routes/admin/talents/+page.server.ts index fe6902c..2d17cf7 100644 --- a/src/routes/admin/talents/+page.server.ts +++ b/src/routes/admin/talents/+page.server.ts @@ -1,5 +1,5 @@ import { getSessionUser } from '$lib/server/auth/session'; -import { requireAdmin } from '$lib/server/auth/authorization'; +import { requirePermission } from '$lib/server/auth/authorization'; import { getDatabase } from '$lib/server/database'; import { fail } from '@sveltejs/kit'; import { randomUUID } from 'node:crypto'; @@ -7,7 +7,8 @@ import type { Actions, PageServerLoad } from './$types'; type SharedEffect = { statId: string; valueI: number; valueII: number }; -export const load: PageServerLoad = async () => { +export const load: PageServerLoad = async ({ cookies }) => { + await requirePermission(await getSessionUser(cookies), 'data.manage'); const db = await getDatabase(); const [talents, tiers, triggers, links, effects, stats, templates] = await Promise.all([ (await db.prepare('SELECT * FROM talents ORDER BY active DESC,name COLLATE NOCASE')).all(), @@ -62,7 +63,7 @@ const renderDescription = ( export const actions: Actions = { saveTrigger: async ({ request, cookies }) => { - requireAdmin(await getSessionUser(cookies)); + await requirePermission(await getSessionUser(cookies), 'data.manage'); const form = await request.formData(); const id = String(form.get('id') ?? ''), name = String(form.get('name') ?? '').trim(), description = String(form.get('description') ?? '').trim(), uptime = form.get('supportsUptime') === 'on'; const min = uptime ? Number(form.get('minUptime')) : null, max = uptime ? Number(form.get('maxUptime')) : null; @@ -76,7 +77,7 @@ export const actions: Actions = { return { triggerSaved: true }; }, saveTalent: async ({ request, cookies }) => { - requireAdmin(await getSessionUser(cookies)); + await requirePermission(await getSessionUser(cookies), 'data.manage'); const form = await request.formData(); const id = String(form.get('id') ?? ''), name = String(form.get('name') ?? '').trim(), category = String(form.get('category') ?? ''); const descriptionTemplate = String(form.get('descriptionTemplate') ?? '').trim(); diff --git a/src/routes/admin/users/+page.server.ts b/src/routes/admin/users/+page.server.ts new file mode 100644 index 0000000..a83200b --- /dev/null +++ b/src/routes/admin/users/+page.server.ts @@ -0,0 +1,32 @@ +import { requirePermission } from '$lib/server/auth/authorization'; +import { getSessionUser } from '$lib/server/auth/session'; +import { getDatabase } from '$lib/server/database'; +import type { PageServerLoad } from './$types'; + +export const load: PageServerLoad = async ({ cookies, url }) => { + await requirePermission(await getSessionUser(cookies), 'users.view'); + const query = url.searchParams.get('q')?.trim() ?? ''; + const pattern = `%${query.replaceAll('%', '\\%').replaceAll('_', '\\_')}%`; + const database = await getDatabase(); + const users = await (await database.prepare(` + SELECT users.id,users.username,users.role,users.discord_id,users.created_at, + user_status.last_active_at,user_status.blocked_at,user_status.banned_until, + (SELECT MAX(created_at) FROM login_events WHERE user_id=users.id AND succeeded=1) AS last_login_at, + (SELECT COUNT(*) FROM builds WHERE owner_id=users.id) AS build_count, + (SELECT COUNT(*) FROM comments WHERE user_id=users.id) AS comment_count + FROM users LEFT JOIN user_status ON user_status.user_id=users.id + WHERE users.username LIKE ? ESCAPE '\\' COLLATE NOCASE + ORDER BY COALESCE(user_status.last_active_at,users.created_at) DESC + LIMIT 200 + `)).all(pattern); + const now = Date.now(); + return { + users: users.map((user) => { + const activity = user.last_active_at ?? user.last_login_at; + const inactive = !activity || now - new Date(String(activity)).getTime() > 30 * 24 * 60 * 60 * 1000; + const banned = typeof user.banned_until === 'string' && new Date(user.banned_until) > new Date(); + return { ...user, inactive, accessState: user.blocked_at ? 'BLOCKED' : banned ? 'BANNED' : 'ACTIVE' }; + }), + query + }; +}; diff --git a/src/routes/admin/users/+page.svelte b/src/routes/admin/users/+page.svelte new file mode 100644 index 0000000..e235639 --- /dev/null +++ b/src/routes/admin/users/+page.svelte @@ -0,0 +1,12 @@ + +User operations — ISAC +

USER OPERATIONS

AGENT REGISTRY

Identity, access state, recent activity, and community contribution overview.

+
RESULTS{data.users.length}Current query
ACTIVE{data.users.filter((user) => user.accessState === 'ACTIVE' && !user.inactive).length}Active within 30 days
INACTIVE{data.users.filter((user) => user.inactive).length}No activity for 30 days
RESTRICTED{data.users.filter((user) => user.accessState !== 'ACTIVE').length}Blocked or banned
+
+
USERACCESSLAST LOGINLAST ACTIVECONTENT
+ {#each data.users as user}
{user.username}{user.discord_id ? 'DISCORD + LOCAL IDENTITY' : 'LOCAL IDENTITY'}
{user.accessState}{user.inactive && user.accessState === 'ACTIVE' ? ' · INACTIVE' : ''}{date(user.last_login_at)}{date(user.last_active_at)}{user.build_count} BUILDS · {user.comment_count} COMMENTSVIEW
+ {:else}
NO USERS FOUNDAdjust the username search and try again.
{/each} +
diff --git a/src/routes/admin/users/[id]/+page.server.ts b/src/routes/admin/users/[id]/+page.server.ts new file mode 100644 index 0000000..07b5ae5 --- /dev/null +++ b/src/routes/admin/users/[id]/+page.server.ts @@ -0,0 +1,86 @@ +import { getPermissions, requirePermission } from '$lib/server/auth/authorization'; +import { getSessionUser } from '$lib/server/auth/session'; +import { getDatabase } from '$lib/server/database'; +import { error, fail } from '@sveltejs/kit'; +import { randomUUID } from 'node:crypto'; +import type { Actions, PageServerLoad } from './$types'; + +export const load: PageServerLoad = async ({ cookies, params }) => { + const operator = await requirePermission(await getSessionUser(cookies), 'users.view'); + const database = await getDatabase(); + const [profile, roles, permissions, assignedRoles, directPermissions, logins, builds, comments] = await Promise.all([ + (await database.prepare(`SELECT users.id,users.username,users.role,users.discord_id,users.created_at,user_status.* + FROM users LEFT JOIN user_status ON user_status.user_id=users.id WHERE users.id=?`)).get(params.id), + (await database.prepare('SELECT * FROM roles ORDER BY name')).all(), + (await database.prepare('SELECT * FROM permissions ORDER BY name')).all(), + (await database.prepare('SELECT role_id FROM user_roles WHERE user_id=?')).all(params.id), + (await database.prepare('SELECT permission_id FROM user_permissions WHERE user_id=?')).all(params.id), + (await database.prepare('SELECT provider,succeeded,created_at FROM login_events WHERE user_id=? ORDER BY created_at DESC LIMIT 30')).all(params.id), + (await database.prepare('SELECT id,title,slug,visibility,updated_at FROM builds WHERE owner_id=? ORDER BY updated_at DESC LIMIT 50')).all(params.id), + (await database.prepare(`SELECT comments.id,comments.body,comments.created_at,builds.title,builds.slug + FROM comments JOIN builds ON builds.id=comments.build_id WHERE comments.user_id=? ORDER BY comments.created_at DESC LIMIT 50`)).all(params.id) + ]); + if (!profile) error(404, 'User not found'); + return { + profile, + roles, + permissions, + assignedRoleIds: assignedRoles.map((row) => String(row.role_id)), + directPermissionIds: directPermissions.map((row) => String(row.permission_id)), + logins, builds, comments, + operatorPermissions: await getPermissions(operator), + isSelf: operator.id === params.id + }; +}; + +export const actions: Actions = { + setAccess: async ({ cookies, request, params }) => { + const operator = await requirePermission(await getSessionUser(cookies), 'users.roles'); + const form = await request.formData(); + const roleIds = [...new Set(form.getAll('roleIds').map(String))]; + const permissionIds = [...new Set(form.getAll('permissionIds').map(String))]; + const database = await getDatabase(); + const target = await (await database.prepare('SELECT role FROM users WHERE id=?')).get(params.id); + if (!target) return fail(404, { accessError: 'User not found.' }); + const operatorPermissions = await getPermissions(operator); + const targetAdminRole = await (await database.prepare(`SELECT 1 FROM user_roles WHERE user_id=? AND role_id='role-admin'`)).get(params.id); + const changesAdminRole = roleIds.includes('role-admin') !== Boolean(targetAdminRole); + if ((target.role === 'admin' || changesAdminRole) && !operatorPermissions.includes('*')) { + return fail(403, { accessError: 'Only an administrator can grant, remove, or modify administrator access.' }); + } + const validRoles = roleIds.length ? Number((await (await database.prepare(`SELECT COUNT(*) AS count FROM roles WHERE id IN (${roleIds.map(() => '?').join(',')})`)).get(...roleIds))?.count) : 0; + const validPermissions = permissionIds.length ? Number((await (await database.prepare(`SELECT COUNT(*) AS count FROM permissions WHERE id IN (${permissionIds.map(() => '?').join(',')})`)).get(...permissionIds))?.count) : 0; + if (validRoles !== roleIds.length || validPermissions !== permissionIds.length) return fail(400, { accessError: 'One or more access selections are invalid.' }); + await (await database.prepare('DELETE FROM user_roles WHERE user_id=?')).run(params.id); + await (await database.prepare('DELETE FROM user_permissions WHERE user_id=?')).run(params.id); + for (const roleId of roleIds) await (await database.prepare('INSERT INTO user_roles(user_id,role_id) VALUES(?,?)')).run(params.id, roleId); + for (const permissionId of permissionIds) await (await database.prepare('INSERT INTO user_permissions(user_id,permission_id) VALUES(?,?)')).run(params.id, permissionId); + await (await database.prepare('INSERT INTO audit_log(id,actor_id,entity_type,entity_id,action,after_json,created_at) VALUES(?,?,?,?,?,?,?)')) + .run(randomUUID(), operator.id, 'user', params.id, 'access.updated', JSON.stringify({ roleIds, permissionIds }), new Date().toISOString()); + return { accessSaved: true }; + }, + moderate: async ({ cookies, request, params }) => { + const operator = await requirePermission(await getSessionUser(cookies), 'users.moderate'); + if (operator.id === params.id) return fail(400, { moderationError: 'You cannot restrict your own account.' }); + const form = await request.formData(), state = String(form.get('state') ?? ''), reason = String(form.get('reason') ?? '').trim(); + if (!['active', 'blocked', 'banned'].includes(state)) return fail(400, { moderationError: 'Choose a valid access state.' }); + if (state !== 'active' && reason.length < 3) return fail(400, { moderationError: 'Provide a moderation reason.' }); + const untilRaw = String(form.get('bannedUntil') ?? ''); + const bannedUntil = state === 'banned' ? new Date(untilRaw) : null; + if (state === 'banned' && (!untilRaw || Number.isNaN(bannedUntil?.getTime()) || bannedUntil! <= new Date())) return fail(400, { moderationError: 'Choose a future ban expiration.' }); + const now = new Date().toISOString(), database = await getDatabase(); + const target = await (await database.prepare('SELECT role FROM users WHERE id=?')).get(params.id); + if (!target) return fail(404, { moderationError: 'User not found.' }); + const targetAdminRole = await (await database.prepare(`SELECT 1 FROM user_roles WHERE user_id=? AND role_id='role-admin'`)).get(params.id); + if ((target.role === 'admin' || targetAdminRole) && !(await getPermissions(operator)).includes('*')) { + return fail(403, { moderationError: 'Only an administrator can moderate another administrator.' }); + } + await (await database.prepare(`INSERT INTO user_status(user_id,blocked_at,blocked_by,banned_until,moderation_reason,updated_at) VALUES(?,?,?,?,?,?) + ON CONFLICT(user_id) DO UPDATE SET blocked_at=excluded.blocked_at,blocked_by=excluded.blocked_by,banned_until=excluded.banned_until,moderation_reason=excluded.moderation_reason,updated_at=excluded.updated_at`)) + .run(params.id, state === 'blocked' ? now : null, state === 'active' ? null : operator.id, state === 'banned' ? bannedUntil!.toISOString() : null, state === 'active' ? null : reason, now); + if (state !== 'active') await (await database.prepare('DELETE FROM sessions WHERE user_id=?')).run(params.id); + await (await database.prepare('INSERT INTO audit_log(id,actor_id,entity_type,entity_id,action,after_json,created_at) VALUES(?,?,?,?,?,?,?)')) + .run(randomUUID(), operator.id, 'user', params.id, `moderation.${state}`, JSON.stringify({ reason, bannedUntil: bannedUntil?.toISOString() }), now); + return { moderationSaved: true }; + } +}; diff --git a/src/routes/admin/users/[id]/+page.svelte b/src/routes/admin/users/[id]/+page.svelte new file mode 100644 index 0000000..07d1b91 --- /dev/null +++ b/src/routes/admin/users/[id]/+page.svelte @@ -0,0 +1,41 @@ + +{data.profile.username} — ISAC user operations +

AGENT DOSSIER

{data.profile.username}

Identity created {date(data.profile.created_at)} · {data.profile.discord_id ? 'Discord linked' : 'Local credentials'}

← USER REGISTRY
+
ACCESS STATE{accountState()}{data.profile.moderation_reason ?? 'No restrictions'}
LAST ACTIVE{date(data.profile.last_active_at)}Site activity
BUILDS{data.builds.length}Recent records loaded
COMMENTS{data.comments.length}Recent records loaded
+ +
+
+

ACCESS CONTROL

ROLES & PERMISSIONS

+ {#if can('users.roles')} + +
ASSIGNED ROLESPERMISSION BUNDLES
{roleIds.length} SELECTED
+
{#each data.roles as role}{/each}
+
DIRECT PERMISSIONSUSER-SPECIFIC CAPABILITIES
{permissionIds.length} SELECTED
+
{#each data.permissions as permission}{/each}
+ {#if form?.accessError}

{form.accessError}

{/if}
+
+ {:else}

Your ISAC access permits viewing assignments but not changing them.

{/if} +
+
+

ACCOUNT CONTROL

MODERATION

+ {#if can('users.moderate')} + {#if data.isSelf}

ISAC will not permit self-restriction.

{/if}{#if form?.moderationError}

{form.moderationError}

{/if}
+ {:else}

Moderation permission is required to restrict this account.

{/if} +
+
+ +
+
LOGIN HISTORY{data.logins.length} EVENTS
{#each data.logins as login}
{login.provider.toUpperCase()}{date(login.created_at)}{Number(login.succeeded) ? 'SUCCESS' : 'FAILED'}
{:else}

NO LOGIN EVENTS RECORDED

{/each}
+
BUILD & GUIDE HISTORY{data.builds.length} RECORDS
{#each data.builds as build}
{build.title}{date(build.updated_at)}{build.visibility.toUpperCase()}
{:else}

NO BUILDS CREATED

{/each}
+
COMMENT HISTORY{data.comments.length} RECORDS
{#each data.comments as comment}
{comment.title}{comment.body}{date(comment.created_at)}
{:else}

NO COMMENTS POSTED

{/each}
+
diff --git a/src/routes/auth/discord/callback/+server.ts b/src/routes/auth/discord/callback/+server.ts index e2e9e66..2566661 100644 --- a/src/routes/auth/discord/callback/+server.ts +++ b/src/routes/auth/discord/callback/+server.ts @@ -1,6 +1,6 @@ import { dev } from '$app/environment'; import { env } from '$env/dynamic/private'; -import { createSession, getSessionUser } from '$lib/server/auth/session'; +import { createSession, getSessionUser, recordLogin } from '$lib/server/auth/session'; import { ensureBootstrapAdmin } from '$lib/server/auth/bootstrap'; import { getDatabase } from '$lib/server/database'; import { redirect } from '@sveltejs/kit'; @@ -120,6 +120,13 @@ export const GET: RequestHandler = async ({ cookies, url, fetch }) => { } await ensureBootstrapAdmin(database, String(user.id)); + const status = await (await database.prepare('SELECT blocked_at,banned_until FROM user_status WHERE user_id=?')).get(String(user.id)); + const banned = typeof status?.banned_until === 'string' && new Date(status.banned_until) > new Date(); + if (status?.blocked_at || banned) { + await recordLogin(String(user.id), 'discord', false); + redirect(303, '/sign-in?oauth_error=account_blocked'); + } await createSession(cookies, String(user.id)); + await recordLogin(String(user.id), 'discord', true); redirect(303, created ? '/account?choose_username=1' : '/account'); }; diff --git a/src/routes/sign-in/+page.server.ts b/src/routes/sign-in/+page.server.ts index 766e2fb..1566fa4 100644 --- a/src/routes/sign-in/+page.server.ts +++ b/src/routes/sign-in/+page.server.ts @@ -1,4 +1,4 @@ -import { createSession, destroySession } from '$lib/server/auth/session'; +import { createSession, destroySession, recordLogin } from '$lib/server/auth/session'; import { verifyPassword } from '$lib/server/auth/password'; import { getDatabase } from '$lib/server/database'; import { ensureBootstrapAdmin } from '$lib/server/auth/bootstrap'; @@ -12,11 +12,21 @@ export const actions: Actions = { const password = String(form.get('password') ?? ''); if (!username || !password) return fail(400, { loginError: 'Enter your username and password.', username }); const database = await getDatabase(); - const user = await (await database.prepare('SELECT id, password_hash FROM users WHERE username = ? COLLATE NOCASE')).get(username); + const user = await (await database.prepare(`SELECT users.id,users.password_hash,user_status.blocked_at,user_status.banned_until + FROM users LEFT JOIN user_status ON user_status.user_id=users.id WHERE username = ? COLLATE NOCASE`)).get(username); const valid = typeof user?.password_hash === 'string' && await verifyPassword(user.password_hash, password); - if (!valid) return fail(400, { loginError: 'Invalid username or password.', username }); + if (!valid) { + if (user?.id) await recordLogin(String(user.id), 'password', false); + return fail(400, { loginError: 'Invalid username or password.', username }); + } + const banned = typeof user.banned_until === 'string' && new Date(user.banned_until) > new Date(); + if (user.blocked_at || banned) { + await recordLogin(String(user.id), 'password', false); + return fail(403, { loginError: 'This account is not permitted to sign in.', username }); + } await ensureBootstrapAdmin(database, String(user.id)); await createSession(cookies, String(user.id)); + await recordLogin(String(user.id), 'password', true); redirect(303, '/account'); }, logout: async ({ cookies }) => {