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'}
NO COMMENTS POSTED
{/each}