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,
|
email: users.email,
|
||||||
username: users.username,
|
username: users.username,
|
||||||
displayName: users.displayName,
|
displayName: users.displayName,
|
||||||
isSystemAdmin: users.isSystemAdmin
|
isSystemAdmin: users.isSystemAdmin,
|
||||||
|
disabledAt: users.disabledAt
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.from(sessions)
|
.from(sessions)
|
||||||
@@ -74,6 +75,12 @@ export async function validateSession(
|
|||||||
|
|
||||||
const { session, user } = result[0];
|
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
|
// Session expired
|
||||||
if (Date.now() >= session.expiresAt.getTime()) {
|
if (Date.now() >= session.expiresAt.getTime()) {
|
||||||
await db.delete(sessions).where(eq(sessions.id, sessionId));
|
await db.delete(sessions).where(eq(sessions.id, sessionId));
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ export const users = pgTable(
|
|||||||
oidcProvider: text('oidc_provider'),
|
oidcProvider: text('oidc_provider'),
|
||||||
oidcSubject: text('oidc_subject'),
|
oidcSubject: text('oidc_subject'),
|
||||||
isSystemAdmin: boolean('is_system_admin').notNull().default(false),
|
isSystemAdmin: boolean('is_system_admin').notNull().default(false),
|
||||||
|
disabledAt: timestamp('disabled_at', { withTimezone: true }),
|
||||||
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||||||
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow()
|
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow()
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { fail } from '@sveltejs/kit';
|
import { fail } from '@sveltejs/kit';
|
||||||
import type { Actions, PageServerLoad } from './$types';
|
import type { Actions, PageServerLoad } from './$types';
|
||||||
import { db } from '$lib/server/db/index.js';
|
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';
|
import { eq, sql } from 'drizzle-orm';
|
||||||
|
|
||||||
export const load: PageServerLoad = async () => {
|
export const load: PageServerLoad = async () => {
|
||||||
@@ -12,6 +12,7 @@ export const load: PageServerLoad = async () => {
|
|||||||
username: users.username,
|
username: users.username,
|
||||||
displayName: users.displayName,
|
displayName: users.displayName,
|
||||||
isSystemAdmin: users.isSystemAdmin,
|
isSystemAdmin: users.isSystemAdmin,
|
||||||
|
disabledAt: users.disabledAt,
|
||||||
oidcProvider: users.oidcProvider,
|
oidcProvider: users.oidcProvider,
|
||||||
createdAt: users.createdAt
|
createdAt: users.createdAt
|
||||||
})
|
})
|
||||||
@@ -22,11 +23,12 @@ export const load: PageServerLoad = async () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const actions: Actions = {
|
export const actions: Actions = {
|
||||||
toggleAdmin: async ({ request }) => {
|
toggleAdmin: async ({ request, locals }) => {
|
||||||
const formData = await request.formData();
|
const formData = await request.formData();
|
||||||
const userId = formData.get('userId')?.toString();
|
const userId = formData.get('userId')?.toString();
|
||||||
|
|
||||||
if (!userId) return fail(400, { error: 'User ID required' });
|
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
|
await db
|
||||||
.update(users)
|
.update(users)
|
||||||
@@ -34,5 +36,54 @@ export const actions: Actions = {
|
|||||||
.where(eq(users.id, userId));
|
.where(eq(users.id, userId));
|
||||||
|
|
||||||
return { success: true };
|
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">
|
<script lang="ts">
|
||||||
import { enhance } from '$app/forms';
|
import { enhance } from '$app/forms';
|
||||||
import { formatDateTime } from '$lib/utils/date.js';
|
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>
|
</script>
|
||||||
|
|
||||||
<svelte:head>
|
<svelte:head>
|
||||||
<title>Users - Admin</title>
|
<title>Users - Admin</title>
|
||||||
</svelte:head>
|
</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>
|
<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">
|
<div class="overflow-x-auto rounded-lg border border-gray-200 bg-white">
|
||||||
<table class="w-full text-sm">
|
<table class="w-full text-sm">
|
||||||
<thead class="bg-gray-50">
|
<thead class="bg-gray-50">
|
||||||
<tr class="text-left text-gray-500">
|
<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">Name</th>
|
||||||
<th class="px-4 py-3 font-medium">Email</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">Auth</th>
|
||||||
<th class="px-4 py-3 font-medium">Admin</th>
|
<th class="px-4 py-3 font-medium">Admin</th>
|
||||||
<th class="px-4 py-3 font-medium">Joined</th>
|
<th class="px-4 py-3 font-medium">Joined</th>
|
||||||
@@ -27,9 +37,16 @@
|
|||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{#each data.users as user}
|
{#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 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 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">
|
<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'}">
|
<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'}
|
{user.oidcProvider ? 'SSO' : 'Local'}
|
||||||
@@ -37,20 +54,39 @@
|
|||||||
</td>
|
</td>
|
||||||
<td class="px-4 py-3">
|
<td class="px-4 py-3">
|
||||||
{#if user.isSystemAdmin}
|
{#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}
|
{/if}
|
||||||
</td>
|
</td>
|
||||||
<td class="px-4 py-3 text-gray-400">{formatDateTime(user.createdAt)}</td>
|
<td class="px-4 py-3 text-gray-400">{formatDateTime(user.createdAt)}</td>
|
||||||
<td class="px-4 py-3">
|
<td class="px-4 py-3">
|
||||||
<form method="POST" action="?/toggleAdmin" use:enhance>
|
<div class="flex items-center gap-2">
|
||||||
<input type="hidden" name="userId" value={user.id} />
|
<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
|
<button
|
||||||
type="submit"
|
onclick={() => { confirmDeleteId = user.id; confirmDeleteName = user.displayName ?? user.email; }}
|
||||||
class="text-xs {user.isSystemAdmin ? 'text-red-600 hover:text-red-800' : 'text-blue-600 hover:text-blue-800'}"
|
class="rounded px-2 py-1 text-xs text-red-600 hover:bg-red-50"
|
||||||
>
|
>
|
||||||
{user.isSystemAdmin ? 'Remove Admin' : 'Make Admin'}
|
Delete
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
{/each}
|
{/each}
|
||||||
@@ -58,3 +94,41 @@
|
|||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
</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];
|
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);
|
const valid = await verifyPassword(user.passwordHash!, password);
|
||||||
|
|
||||||
if (!valid) {
|
if (!valid) {
|
||||||
|
|||||||
Reference in New Issue
Block a user