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) <noreply@anthropic.com>
This commit is contained in:
2026-04-06 13:14:03 +07:00
parent d58443ed73
commit 1c7166adc5
5 changed files with 152 additions and 14 deletions
+8 -1
View File
@@ -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));
+1
View File
@@ -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()
},
+53 -2
View File
@@ -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 };
}
};
+85 -11
View File
@@ -1,24 +1,34 @@
<script lang="ts">
import { enhance } from '$app/forms';
import { formatDateTime } from '$lib/utils/date.js';
import type { PageData } from './$types';
import type { PageData, ActionData } from './$types';
let { data } = $props();
let { data, form } = $props();
let confirmDeleteId = $state<string | null>(null);
let confirmDeleteName = $state('');
</script>
<svelte:head>
<title>Users - Admin</title>
</svelte:head>
<div class="mx-auto max-w-4xl">
<div class="mx-auto max-w-5xl">
<h1 class="mb-6 text-2xl font-bold text-gray-900">Manage Users</h1>
{#if form?.error}
<div class="mb-4 rounded-md bg-red-50 p-3 text-sm text-red-700">{form.error}</div>
{/if}
{#if form?.deleted}
<div class="mb-4 rounded-md bg-green-50 p-3 text-sm text-green-700">User permanently deleted.</div>
{/if}
<div class="overflow-x-auto rounded-lg border border-gray-200 bg-white">
<table class="w-full text-sm">
<thead class="bg-gray-50">
<tr class="text-left text-gray-500">
<th class="px-4 py-3 font-medium">Name</th>
<th class="px-4 py-3 font-medium">Email</th>
<th class="px-4 py-3 font-medium">Status</th>
<th class="px-4 py-3 font-medium">Auth</th>
<th class="px-4 py-3 font-medium">Admin</th>
<th class="px-4 py-3 font-medium">Joined</th>
@@ -27,9 +37,16 @@
</thead>
<tbody>
{#each data.users as user}
<tr class="border-t border-gray-100">
<tr class="border-t border-gray-100 {user.disabledAt ? 'bg-red-50/50 opacity-70' : ''}">
<td class="px-4 py-3 font-medium text-gray-900">{user.displayName ?? '—'}</td>
<td class="px-4 py-3 text-gray-500">{user.email}</td>
<td class="px-4 py-3">
{#if user.disabledAt}
<span class="rounded-full bg-red-100 px-2 py-0.5 text-xs font-medium text-red-700">Disabled</span>
{:else}
<span class="rounded-full bg-green-100 px-2 py-0.5 text-xs font-medium text-green-700">Active</span>
{/if}
</td>
<td class="px-4 py-3">
<span class="rounded-full px-2 py-0.5 text-xs font-medium {user.oidcProvider ? 'bg-purple-100 text-purple-700' : 'bg-gray-100 text-gray-700'}">
{user.oidcProvider ? 'SSO' : 'Local'}
@@ -37,20 +54,39 @@
</td>
<td class="px-4 py-3">
{#if user.isSystemAdmin}
<span class="rounded-full bg-green-100 px-2 py-0.5 text-xs font-medium text-green-700">Yes</span>
<span class="rounded-full bg-blue-100 px-2 py-0.5 text-xs font-medium text-blue-700">Admin</span>
{/if}
</td>
<td class="px-4 py-3 text-gray-400">{formatDateTime(user.createdAt)}</td>
<td class="px-4 py-3">
<form method="POST" action="?/toggleAdmin" use:enhance>
<input type="hidden" name="userId" value={user.id} />
<div class="flex items-center gap-2">
<form method="POST" action="?/toggleAdmin" use:enhance>
<input type="hidden" name="userId" value={user.id} />
<button
type="submit"
class="rounded px-2 py-1 text-xs {user.isSystemAdmin ? 'text-red-600 hover:bg-red-50' : 'text-blue-600 hover:bg-blue-50'}"
>
{user.isSystemAdmin ? 'Remove Admin' : 'Make Admin'}
</button>
</form>
<form method="POST" action="?/toggleDisable" use:enhance>
<input type="hidden" name="userId" value={user.id} />
<button
type="submit"
class="rounded px-2 py-1 text-xs {user.disabledAt ? 'text-green-600 hover:bg-green-50' : 'text-amber-600 hover:bg-amber-50'}"
>
{user.disabledAt ? 'Enable' : 'Disable'}
</button>
</form>
<button
type="submit"
class="text-xs {user.isSystemAdmin ? 'text-red-600 hover:text-red-800' : 'text-blue-600 hover:text-blue-800'}"
onclick={() => { confirmDeleteId = user.id; confirmDeleteName = user.displayName ?? user.email; }}
class="rounded px-2 py-1 text-xs text-red-600 hover:bg-red-50"
>
{user.isSystemAdmin ? 'Remove Admin' : 'Make Admin'}
Delete
</button>
</form>
</div>
</td>
</tr>
{/each}
@@ -58,3 +94,41 @@
</table>
</div>
</div>
<!-- Permanent delete confirmation modal -->
{#if confirmDeleteId}
<div class="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
<div class="w-full max-w-sm rounded-lg bg-white p-6 shadow-xl">
<h2 class="text-lg font-semibold text-red-600">Permanently Delete User</h2>
<p class="mt-2 text-sm text-gray-500">
This will permanently delete <strong>{confirmDeleteName}</strong> and remove them from all companies. This action cannot be undone.
</p>
<p class="mt-2 text-sm text-gray-500">
Their submitted expenses will be preserved but no longer linked to their account.
</p>
<div class="mt-4 flex justify-end gap-2">
<button
type="button"
onclick={() => (confirmDeleteId = null)}
class="rounded-md px-4 py-2 text-sm text-gray-700 hover:bg-gray-100"
>
Cancel
</button>
<form method="POST" action="?/permanentDelete" use:enhance={() => {
return async ({ update }) => {
confirmDeleteId = null;
await update();
};
}}>
<input type="hidden" name="userId" value={confirmDeleteId} />
<button
type="submit"
class="rounded-md bg-red-600 px-4 py-2 text-sm font-medium text-white hover:bg-red-700"
>
Delete Permanently
</button>
</form>
</div>
</div>
</div>
{/if}
+5
View File
@@ -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) {