Add ISAC user operations and access roles
This commit is contained in:
@@ -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 (
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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}}
|
||||
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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') };
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 };
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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');
|
||||
};
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
|
||||
};
|
||||
};
|
||||
@@ -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 };
|
||||
}
|
||||
};
|
||||
@@ -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>
|
||||
@@ -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');
|
||||
};
|
||||
|
||||
@@ -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 }) => {
|
||||
|
||||
Reference in New Issue
Block a user