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:
@@ -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));
|
||||
|
||||
@@ -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()
|
||||
},
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user