From 1c7166adc5c430936e68aec91a5787402b7724f8 Mon Sep 17 00:00:00 2001 From: grabowski Date: Mon, 6 Apr 2026 13:14:03 +0700 Subject: [PATCH] Add user disable and permanent delete for system admins - Added disabledAt column to users table - Disabled users are blocked at login and session validation (immediate logout) - Admin users page shows Active/Disabled status badges - Disable/Enable toggle button per user (kills all sessions on disable) - Permanent delete with confirmation modal (removes user, sessions, memberships) - Self-protection: admins cannot disable or delete themselves Co-Authored-By: Claude Opus 4.6 (1M context) --- src/lib/server/auth/index.ts | 9 +- src/lib/server/db/schema.ts | 1 + src/routes/(app)/admin/users/+page.server.ts | 55 ++++++++++- src/routes/(app)/admin/users/+page.svelte | 96 +++++++++++++++++--- src/routes/(auth)/login/+page.server.ts | 5 + 5 files changed, 152 insertions(+), 14 deletions(-) diff --git a/src/lib/server/auth/index.ts b/src/lib/server/auth/index.ts index 43e0173..27bb6d3 100644 --- a/src/lib/server/auth/index.ts +++ b/src/lib/server/auth/index.ts @@ -60,7 +60,8 @@ export async function validateSession( email: users.email, username: users.username, displayName: users.displayName, - isSystemAdmin: users.isSystemAdmin + isSystemAdmin: users.isSystemAdmin, + disabledAt: users.disabledAt } }) .from(sessions) @@ -74,6 +75,12 @@ export async function validateSession( const { session, user } = result[0]; + // Disabled user — kill session + if (user.disabledAt) { + await db.delete(sessions).where(eq(sessions.id, sessionId)); + return { session: null, user: null }; + } + // Session expired if (Date.now() >= session.expiresAt.getTime()) { await db.delete(sessions).where(eq(sessions.id, sessionId)); diff --git a/src/lib/server/db/schema.ts b/src/lib/server/db/schema.ts index e0dbd0f..264cf38 100644 --- a/src/lib/server/db/schema.ts +++ b/src/lib/server/db/schema.ts @@ -31,6 +31,7 @@ export const users = pgTable( oidcProvider: text('oidc_provider'), oidcSubject: text('oidc_subject'), isSystemAdmin: boolean('is_system_admin').notNull().default(false), + disabledAt: timestamp('disabled_at', { withTimezone: true }), createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(), updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow() }, diff --git a/src/routes/(app)/admin/users/+page.server.ts b/src/routes/(app)/admin/users/+page.server.ts index 7cbda74..10c76c2 100644 --- a/src/routes/(app)/admin/users/+page.server.ts +++ b/src/routes/(app)/admin/users/+page.server.ts @@ -1,7 +1,7 @@ import { fail } from '@sveltejs/kit'; import type { Actions, PageServerLoad } from './$types'; import { db } from '$lib/server/db/index.js'; -import { users } from '$lib/server/db/schema.js'; +import { users, sessions, companyMembers, expenses } from '$lib/server/db/schema.js'; import { eq, sql } from 'drizzle-orm'; export const load: PageServerLoad = async () => { @@ -12,6 +12,7 @@ export const load: PageServerLoad = async () => { username: users.username, displayName: users.displayName, isSystemAdmin: users.isSystemAdmin, + disabledAt: users.disabledAt, oidcProvider: users.oidcProvider, createdAt: users.createdAt }) @@ -22,11 +23,12 @@ export const load: PageServerLoad = async () => { }; export const actions: Actions = { - toggleAdmin: async ({ request }) => { + toggleAdmin: async ({ request, locals }) => { const formData = await request.formData(); const userId = formData.get('userId')?.toString(); if (!userId) return fail(400, { error: 'User ID required' }); + if (userId === locals.user!.id) return fail(400, { error: 'Cannot change your own admin status' }); await db .update(users) @@ -34,5 +36,54 @@ export const actions: Actions = { .where(eq(users.id, userId)); return { success: true }; + }, + + toggleDisable: async ({ request, locals }) => { + const formData = await request.formData(); + const userId = formData.get('userId')?.toString(); + + if (!userId) return fail(400, { error: 'User ID required' }); + if (userId === locals.user!.id) return fail(400, { error: 'Cannot disable yourself' }); + + const [user] = await db + .select({ disabledAt: users.disabledAt }) + .from(users) + .where(eq(users.id, userId)) + .limit(1); + + if (!user) return fail(404, { error: 'User not found' }); + + if (user.disabledAt) { + // Re-enable + await db + .update(users) + .set({ disabledAt: null, updatedAt: new Date() }) + .where(eq(users.id, userId)); + } else { + // Disable + kill all sessions + await db + .update(users) + .set({ disabledAt: new Date(), updatedAt: new Date() }) + .where(eq(users.id, userId)); + await db.delete(sessions).where(eq(sessions.userId, userId)); + } + + return { success: true }; + }, + + permanentDelete: async ({ request, locals }) => { + const formData = await request.formData(); + const userId = formData.get('userId')?.toString(); + + if (!userId) return fail(400, { error: 'User ID required' }); + if (userId === locals.user!.id) return fail(400, { error: 'Cannot delete yourself' }); + + // Delete sessions, company memberships, then the user + // Expenses are preserved (submittedBy FK won't cascade) — set to null or keep reference + await db.delete(sessions).where(eq(sessions.userId, userId)); + await db.delete(companyMembers).where(eq(companyMembers.userId, userId)); + await db.delete(users).where(eq(users.id, userId)); + + return { success: true, deleted: true }; } }; diff --git a/src/routes/(app)/admin/users/+page.svelte b/src/routes/(app)/admin/users/+page.svelte index a864913..1c6a9fc 100644 --- a/src/routes/(app)/admin/users/+page.svelte +++ b/src/routes/(app)/admin/users/+page.svelte @@ -1,24 +1,34 @@ Users - Admin -
+

Manage Users

+ {#if form?.error} +
{form.error}
+ {/if} + {#if form?.deleted} +
User permanently deleted.
+ {/if} +
+ @@ -27,9 +37,16 @@ {#each data.users as user} - + + {/each} @@ -58,3 +94,41 @@
Name EmailStatus Auth Admin Joined
{user.displayName ?? '—'} {user.email} + {#if user.disabledAt} + Disabled + {:else} + Active + {/if} + {user.oidcProvider ? 'SSO' : 'Local'} @@ -37,20 +54,39 @@ {#if user.isSystemAdmin} - Yes + Admin {/if} {formatDateTime(user.createdAt)} -
- +
+ + + + + +
+ + +
+ - +
+ + +{#if confirmDeleteId} +
+
+

Permanently Delete User

+

+ This will permanently delete {confirmDeleteName} and remove them from all companies. This action cannot be undone. +

+

+ Their submitted expenses will be preserved but no longer linked to their account. +

+
+ +
{ + return async ({ update }) => { + confirmDeleteId = null; + await update(); + }; + }}> + + +
+
+
+
+{/if} diff --git a/src/routes/(auth)/login/+page.server.ts b/src/routes/(auth)/login/+page.server.ts index be85da3..81d770a 100644 --- a/src/routes/(auth)/login/+page.server.ts +++ b/src/routes/(auth)/login/+page.server.ts @@ -39,6 +39,11 @@ export const actions: Actions = { } const user = result[0]; + + if (user.disabledAt) { + return fail(400, { error: 'This account has been disabled. Contact an administrator.', email }); + } + const valid = await verifyPassword(user.passwordHash!, password); if (!valid) {