Add ISAC user operations and access roles

This commit is contained in:
JB
2026-07-24 23:21:28 -04:00
parent 8ecdfaa319
commit 472528f429
21 changed files with 355 additions and 49 deletions
+38
View File
@@ -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 (
+11
View File
@@ -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.
+2
View File
@@ -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}}
+4 -3
View File
@@ -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}
<a class:active={page.url.pathname.startsWith('/account')} href="/account">My Profile</a>
{/if}
{#if user?.role === 'admin'}
<a class:active={page.url.pathname.startsWith('/admin')} href="/admin/stats">Admin</a>
{#if isacAccess}
<a class:active={page.url.pathname.startsWith('/admin')} href="/admin">ISAC</a>
{/if}
</nav>
{#if user}
+30 -5
View File
@@ -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<string[]> {
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<boolean> {
const permissions = await getPermissions(user);
return permissions.includes('*') || permissions.includes(permission);
}
export async function requirePermission(user: AuthUser | null, permission: string): Promise<AuthUser> {
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');
+21 -1
View File
@@ -43,15 +43,23 @@ export async function createSession(cookies: Cookies, userId: string): Promise<v
});
}
export async function recordLogin(userId: string | null, provider: 'password' | 'discord', succeeded: boolean): Promise<void> {
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<AuthUser | null> {
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<AuthUser | null>
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),
+5 -3
View File
@@ -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') };
};
+1 -1
View File
@@ -8,4 +8,4 @@
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
</svelte:head>
<AppShell user={data.user}>{@render children()}</AppShell>
<AppShell user={data.user} isacAccess={data.isacAccess}>{@render children()}</AppShell>
+4 -2
View File
@@ -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 };
};
+17 -13
View File
@@ -1,31 +1,35 @@
<script lang="ts">
import { page } from '$app/state';
let { children } = $props();
const sections = [
let { data, children } = $props();
const can = (permission: string) => data.permissions.includes('*') || data.permissions.includes(permission);
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', pending: true }
];
const userSections = [{ href: '/admin/users', label: 'User operations', code: 'USER' }];
</script>
<main class="admin-page">
<header class="admin-command">
<div class="command-title"><p class="eyebrow"><span></span> SHD DATA OPERATIONS</p><h1>ADMIN CONSOLE</h1><p>Embedded game-data control surface</p></div>
<div class="command-status"><div><i></i><span>DATABASE</span><strong>ONLINE</strong></div><div><span>ACCESS</span><strong>ADMIN</strong></div><div><span>ENVIRONMENT</span><strong>LOCAL</strong></div></div>
<div class="command-title"><p class="eyebrow"><span></span> INTELLIGENT SYSTEM ANALYTIC COMPUTER</p><h1>ISAC</h1><p>SHD.build operations and intelligence control surface</p></div>
<div class="command-status"><div><i></i><span>SYSTEM</span><strong>ONLINE</strong></div><div><span>ACCESS</span><strong>AUTHORIZED</strong></div><div><span>ENVIRONMENT</span><strong>LOCAL</strong></div></div>
</header>
<div class="admin-dashboard">
<aside class="admin-sidebar">
<div class="sidebar-label"><span>DATA MANAGEMENT</span><b>05 MODULES</b></div>
<nav aria-label="Admin sections">
{#each sections as section}
<a class:active={page.url.pathname === section.href} class:pending={section.pending} href={section.href}>
<small>{section.code}</small><span>{section.label}</span>{#if section.pending}<em>SOON</em>{:else}<b>›</b>{/if}
</a>
{/each}
</nav>
<div class="sidebar-foot"><span>SHD NODE</span><strong>NYC-01</strong><small>Schema synchronized</small></div>
{#if can('users.view')}
<div class="sidebar-label"><span>USER OPERATIONS</span><b>01 MODULE</b></div>
<nav aria-label="User operations">{#each userSections 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')}
<div class="sidebar-label section-label"><span>DATA MANAGEMENT</span><b>05 MODULES</b></div>
<nav aria-label="Data management">
{#each dataSections as section}<a class:active={page.url.pathname === section.href} class:pending={section.pending} href={section.href}><small>{section.code}</small><span>{section.label}</span>{#if section.pending}<em>SOON</em>{:else}<b>›</b>{/if}</a>{/each}
</nav>
{/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>
</div>
+10 -1
View File
@@ -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');
};
+5 -4
View File
@@ -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 };
+5 -4
View File
@@ -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 };
+5 -4
View File
@@ -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 };
}
};
+5 -4
View File
@@ -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();
+32
View File
@@ -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
};
};
+12
View File
@@ -0,0 +1,12 @@
<script lang="ts">
let { data } = $props();
const date = (value: unknown) => value ? new Intl.DateTimeFormat(undefined, { dateStyle: 'medium', timeStyle: 'short' }).format(new Date(String(value))) : 'NEVER';
</script>
<svelte:head><title>User operations — ISAC</title></svelte:head>
<div class="module-toolbar"><div><p class="eyebrow">USER OPERATIONS</p><h2>AGENT REGISTRY</h2><p>Identity, access state, recent activity, and community contribution overview.</p></div><form class="user-search"><input name="q" value={data.query} placeholder="SEARCH USERNAME"/><button class="secondary">SEARCH</button></form></div>
<div class="module-kpis"><div><span>RESULTS</span><strong>{data.users.length}</strong><small>Current query</small></div><div><span>ACTIVE</span><strong>{data.users.filter((user) => user.accessState === 'ACTIVE' && !user.inactive).length}</strong><small>Active within 30 days</small></div><div><span>INACTIVE</span><strong>{data.users.filter((user) => user.inactive).length}</strong><small>No activity for 30 days</small></div><div><span>RESTRICTED</span><strong>{data.users.filter((user) => user.accessState !== 'ACTIVE').length}</strong><small>Blocked or banned</small></div></div>
<div class="dashboard-table">
<div class="dashboard-row user-row table-heading"><span>USER</span><span>ACCESS</span><span>LAST LOGIN</span><span>LAST ACTIVE</span><span>CONTENT</span><span></span></div>
{#each data.users as user}<div class="dashboard-row user-row"><div><strong>{user.username}</strong><small>{user.discord_id ? 'DISCORD + LOCAL IDENTITY' : 'LOCAL IDENTITY'}</small></div><span class:restricted={user.accessState !== 'ACTIVE'}>{user.accessState}{user.inactive && user.accessState === 'ACTIVE' ? ' · INACTIVE' : ''}</span><span>{date(user.last_login_at)}</span><span>{date(user.last_active_at)}</span><span>{user.build_count} BUILDS · {user.comment_count} COMMENTS</span><a class="row-action" href={`/admin/users/${user.id}`}>VIEW</a></div>
{:else}<div class="empty-dashboard"><strong>NO USERS FOUND</strong><span>Adjust the username search and try again.</span></div>{/each}
</div>
@@ -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 };
}
};
+41
View File
@@ -0,0 +1,41 @@
<script lang="ts">
import PendingForm from '$lib/components/PendingForm.svelte';
import { onMount } from 'svelte';
let { data, form } = $props();
let roleIds = $state<string[]>([]);
let permissionIds = $state<string[]>([]);
onMount(() => { roleIds = [...data.assignedRoleIds]; permissionIds = [...data.directPermissionIds]; });
const can = (permission: string) => data.operatorPermissions.includes('*') || data.operatorPermissions.includes(permission);
const date = (value: unknown) => value ? new Intl.DateTimeFormat(undefined, { dateStyle: 'medium', timeStyle: 'short' }).format(new Date(String(value))) : 'NEVER';
const accountState = () => data.profile.blocked_at ? 'BLOCKED' : data.profile.banned_until && new Date(data.profile.banned_until) > new Date() ? 'BANNED' : 'ACTIVE';
</script>
<svelte:head><title>{data.profile.username} — ISAC user operations</title></svelte:head>
<div class="module-toolbar"><div><p class="eyebrow">AGENT DOSSIER</p><h2>{data.profile.username}</h2><p>Identity created {date(data.profile.created_at)} · {data.profile.discord_id ? 'Discord linked' : 'Local credentials'}</p></div><a class="secondary button-link" href="/admin/users">← USER REGISTRY</a></div>
<div class="module-kpis"><div><span>ACCESS STATE</span><strong class:restricted={accountState() !== 'ACTIVE'}>{accountState()}</strong><small>{data.profile.moderation_reason ?? 'No restrictions'}</small></div><div><span>LAST ACTIVE</span><strong class="metric-date">{date(data.profile.last_active_at)}</strong><small>Site activity</small></div><div><span>BUILDS</span><strong>{data.builds.length}</strong><small>Recent records loaded</small></div><div><span>COMMENTS</span><strong>{data.comments.length}</strong><small>Recent records loaded</small></div></div>
<div class="user-console-grid">
<section class="dashboard-editor">
<div class="editor-title"><div><p class="eyebrow">ACCESS CONTROL</p><h3>ROLES & PERMISSIONS</h3></div></div>
{#if can('users.roles')}
<PendingForm action="?/setAccess">
<div class="selection-head"><div><span>ASSIGNED ROLES</span><small>PERMISSION BUNDLES</small></div><b>{roleIds.length} SELECTED</b></div>
<div class="selection-grid">{#each data.roles as role}<label><input type="checkbox" name="roleIds" value={role.id} bind:group={roleIds}/><span>{role.name}</span><small>{roleIds.includes(role.id) ? 'ASSIGNED' : 'AVAILABLE'}</small></label>{/each}</div>
<div class="selection-head"><div><span>DIRECT PERMISSIONS</span><small>USER-SPECIFIC CAPABILITIES</small></div><b>{permissionIds.length} SELECTED</b></div>
<div class="selection-grid permission-grid">{#each data.permissions as permission}<label title={permission.description}><input type="checkbox" name="permissionIds" value={permission.id} bind:group={permissionIds}/><span>{permission.name}</span><small>{permissionIds.includes(permission.id) ? 'GRANTED' : 'AVAILABLE'}</small></label>{/each}</div>
{#if form?.accessError}<p class="error">{form.accessError}</p>{/if}<div class="form-actions"><button class="primary">SAVE ACCESS →</button></div>
</PendingForm>
{:else}<p class="console-note">Your ISAC access permits viewing assignments but not changing them.</p>{/if}
</section>
<section class="dashboard-editor moderation-panel">
<div class="editor-title"><div><p class="eyebrow">ACCOUNT CONTROL</p><h3>MODERATION</h3></div></div>
{#if can('users.moderate')}
<PendingForm action="?/moderate"><label class="field">ACCESS STATE<select name="state"><option value="active">Active</option><option value="blocked">Blocked indefinitely</option><option value="banned">Temporarily banned</option></select></label><label class="field">BAN EXPIRATION<input name="bannedUntil" type="datetime-local"/></label><label class="field">REASON<textarea name="reason" placeholder="Required for blocks and bans"></textarea></label>{#if data.isSelf}<p class="error">ISAC will not permit self-restriction.</p>{/if}{#if form?.moderationError}<p class="error">{form.moderationError}</p>{/if}<div class="form-actions"><button class="danger" disabled={data.isSelf}>APPLY ACCOUNT STATE</button></div></PendingForm>
{:else}<p class="console-note">Moderation permission is required to restrict this account.</p>{/if}
</section>
</div>
<div class="user-history-grid">
<section><div class="history-head"><span>LOGIN HISTORY</span><b>{data.logins.length} EVENTS</b></div><div class="history-list">{#each data.logins as login}<div><strong>{login.provider.toUpperCase()}</strong><span>{date(login.created_at)}</span><b class:restricted={!Number(login.succeeded)}>{Number(login.succeeded) ? 'SUCCESS' : 'FAILED'}</b></div>{:else}<p>NO LOGIN EVENTS RECORDED</p>{/each}</div></section>
<section><div class="history-head"><span>BUILD & GUIDE HISTORY</span><b>{data.builds.length} RECORDS</b></div><div class="history-list">{#each data.builds as build}<div><strong>{build.title}</strong><span>{date(build.updated_at)}</span><b>{build.visibility.toUpperCase()}</b></div>{:else}<p>NO BUILDS CREATED</p>{/each}</div></section>
<section><div class="history-head"><span>COMMENT HISTORY</span><b>{data.comments.length} RECORDS</b></div><div class="history-list comment-history">{#each data.comments as comment}<div><strong>{comment.title}</strong><span>{comment.body}</span><b>{date(comment.created_at)}</b></div>{:else}<p>NO COMMENTS POSTED</p>{/each}</div></section>
</div>
+8 -1
View File
@@ -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');
};
+13 -3
View File
@@ -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 }) => {