feat: migrate admins to better auth roles

This commit is contained in:
2026-06-05 23:55:45 +01:00
parent dd7eb028db
commit ee98b7147c
12 changed files with 1457 additions and 72 deletions
+6
View File
@@ -0,0 +1,6 @@
ALTER TABLE `session` ADD `impersonated_by` text;--> statement-breakpoint
ALTER TABLE `user` ADD `role` text;--> statement-breakpoint
ALTER TABLE `user` ADD `banned` integer DEFAULT false;--> statement-breakpoint
ALTER TABLE `user` ADD `ban_reason` text;--> statement-breakpoint
ALTER TABLE `user` ADD `ban_expires` integer;--> statement-breakpoint
UPDATE `user` SET `role` = 'admin' WHERE `role` IS NULL OR `role` = 'user';
File diff suppressed because it is too large Load Diff
+7
View File
@@ -36,6 +36,13 @@
"when": 1780609661307, "when": 1780609661307,
"tag": "0004_orange_ultimatum", "tag": "0004_orange_ultimatum",
"breakpoints": true "breakpoints": true
},
{
"idx": 5,
"version": "6",
"when": 1780699922786,
"tag": "0005_bright_wiccan",
"breakpoints": true
} }
] ]
} }
+5 -2
View File
@@ -4,18 +4,21 @@ const passwordMessage = 'Password must be at least 8 characters.';
const optionalPassword = z.string().refine((value) => value.length === 0 || value.length >= 8, { const optionalPassword = z.string().refine((value) => value.length === 0 || value.length >= 8, {
message: passwordMessage message: passwordMessage
}); });
const roleSchema = z.enum(['admin', 'user'], 'Select a role.');
export const createAdminSchema = z.object({ export const createAdminSchema = z.object({
name: z.string().trim().min(1, 'Enter a name.'), name: z.string().trim().min(1, 'Enter a name.'),
email: z.email('Enter a valid email address.').transform((value) => value.toLowerCase()), email: z.email('Enter a valid email address.').transform((value) => value.toLowerCase()),
password: z.string().min(8, passwordMessage) password: z.string().min(8, passwordMessage),
role: roleSchema.default('admin')
}); });
export const editAdminSchema = z.object({ export const editAdminSchema = z.object({
id: z.string().min(1, 'Admin id is required.'), id: z.string().min(1, 'Admin id is required.'),
name: z.string().trim().min(1, 'Enter a name.'), name: z.string().trim().min(1, 'Enter a name.'),
email: z.email('Enter a valid email address.').transform((value) => value.toLowerCase()), email: z.email('Enter a valid email address.').transform((value) => value.toLowerCase()),
password: optionalPassword password: optionalPassword,
role: roleSchema
}); });
export const deleteAdminSchema = z.object({ export const deleteAdminSchema = z.object({
+2
View File
@@ -1,5 +1,6 @@
import { betterAuth } from 'better-auth/minimal'; import { betterAuth } from 'better-auth/minimal';
import { drizzleAdapter } from 'better-auth/adapters/drizzle'; import { drizzleAdapter } from 'better-auth/adapters/drizzle';
import { admin } from 'better-auth/plugins';
import { sveltekitCookies } from 'better-auth/svelte-kit'; import { sveltekitCookies } from 'better-auth/svelte-kit';
import { env } from '$env/dynamic/private'; import { env } from '$env/dynamic/private';
import { getRequestEvent } from '$app/server'; import { getRequestEvent } from '$app/server';
@@ -14,6 +15,7 @@ export const auth = betterAuth({
disableSignUp: true disableSignUp: true
}, },
plugins: [ plugins: [
admin(),
sveltekitCookies(getRequestEvent) // make sure this is the last plugin in the array sveltekitCookies(getRequestEvent) // make sure this is the last plugin in the array
] ]
}); });
+7 -2
View File
@@ -13,7 +13,11 @@ export const user = sqliteTable('user', {
updatedAt: integer('updated_at', { mode: 'timestamp_ms' }) updatedAt: integer('updated_at', { mode: 'timestamp_ms' })
.default(sql`(cast(unixepoch('subsecond') * 1000 as integer))`) .default(sql`(cast(unixepoch('subsecond') * 1000 as integer))`)
.$onUpdate(() => /* @__PURE__ */ new Date()) .$onUpdate(() => /* @__PURE__ */ new Date())
.notNull() .notNull(),
role: text('role'),
banned: integer('banned', { mode: 'boolean' }).default(false),
banReason: text('ban_reason'),
banExpires: integer('ban_expires', { mode: 'timestamp_ms' })
}); });
export const session = sqliteTable( export const session = sqliteTable(
@@ -32,7 +36,8 @@ export const session = sqliteTable(
userAgent: text('user_agent'), userAgent: text('user_agent'),
userId: text('user_id') userId: text('user_id')
.notNull() .notNull()
.references(() => user.id, { onDelete: 'cascade' }) .references(() => user.id, { onDelete: 'cascade' }),
impersonatedBy: text('impersonated_by')
}, },
(table) => [index('session_userId_idx').on(table.userId)] (table) => [index('session_userId_idx').on(table.userId)]
); );
+36 -55
View File
@@ -1,22 +1,20 @@
import { hashPassword } from 'better-auth/crypto';
import { asc, eq } from 'drizzle-orm'; import { asc, eq } from 'drizzle-orm';
import { message, superValidate } from 'sveltekit-superforms/server'; import { message, superValidate } from 'sveltekit-superforms/server';
import { zod4 } from 'sveltekit-superforms/adapters'; import { zod4 } from 'sveltekit-superforms/adapters';
import { auth } from '$lib/server/auth';
import { db } from '$lib/server/db'; import { db } from '$lib/server/db';
import { account, user } from '$lib/server/db/schema'; import { user } from '$lib/server/db/schema';
import { createAdminSchema, deleteAdminSchema, editAdminSchema } from '$lib/schemas/admin'; import { createAdminSchema, deleteAdminSchema, editAdminSchema } from '$lib/schemas/admin';
import type { Actions, PageServerLoad } from './$types'; import type { Actions, PageServerLoad } from './$types';
function now() {
return new Date();
}
export const load: PageServerLoad = async ({ locals }) => { export const load: PageServerLoad = async ({ locals }) => {
const users = await db const users = await db
.select({ .select({
id: user.id, id: user.id,
name: user.name, name: user.name,
email: user.email, email: user.email,
role: user.role,
banned: user.banned,
emailVerified: user.emailVerified, emailVerified: user.emailVerified,
createdAt: user.createdAt, createdAt: user.createdAt,
updatedAt: user.updatedAt updatedAt: user.updatedAt
@@ -41,29 +39,15 @@ export const actions: Actions = {
return message(form, 'Check the highlighted fields.', { status: 400 }); return message(form, 'Check the highlighted fields.', { status: 400 });
} }
const userId = crypto.randomUUID();
const accountId = crypto.randomUUID();
const createdAt = now();
const passwordHash = await hashPassword(form.data.password);
try { try {
await db.insert(user).values({ await auth.api.createUser({
id: userId, headers: event.request.headers,
name: form.data.name, body: {
email: form.data.email, name: form.data.name,
emailVerified: true, email: form.data.email,
createdAt, password: form.data.password,
updatedAt: createdAt role: form.data.role
}); }
await db.insert(account).values({
id: accountId,
accountId: userId,
providerId: 'credential',
userId,
password: passwordHash,
createdAt,
updatedAt: createdAt
}); });
} catch { } catch {
return message(form, 'Unable to create admin. Check whether that email already exists.', { return message(form, 'Unable to create admin. Check whether that email already exists.', {
@@ -82,36 +66,33 @@ export const actions: Actions = {
} }
try { try {
await db await auth.api.adminUpdateUser({
.update(user) headers: event.request.headers,
.set({ body: {
name: form.data.name, userId: form.data.id,
email: form.data.email, data: {
updatedAt: now() name: form.data.name,
}) email: form.data.email
.where(eq(user.id, form.data.id)); }
}
});
await auth.api.setRole({
headers: event.request.headers,
body: {
userId: form.data.id,
role: form.data.role
}
});
if (form.data.password) { if (form.data.password) {
const passwordHash = await hashPassword(form.data.password); await auth.api.setUserPassword({
const updated = await db headers: event.request.headers,
.update(account) body: {
.set({
password: passwordHash,
updatedAt: now()
})
.where(eq(account.userId, form.data.id));
if (updated.rowsAffected === 0) {
await db.insert(account).values({
id: crypto.randomUUID(),
accountId: form.data.id,
providerId: 'credential',
userId: form.data.id, userId: form.data.id,
password: passwordHash, newPassword: form.data.password
createdAt: now(), }
updatedAt: now() });
});
}
} }
} catch { } catch {
return message(form, 'Unable to update admin. Check whether that email already exists.', { return message(form, 'Unable to update admin. Check whether that email already exists.', {
+2 -3
View File
@@ -79,6 +79,7 @@
id: user.id, id: user.id,
name: user.name, name: user.name,
email: user.email, email: user.email,
role: user.role === 'admin' ? 'admin' : 'user',
password: '' password: ''
} }
}); });
@@ -103,9 +104,7 @@
<div class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between"> <div class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div> <div>
<h1 class="text-2xl font-semibold tracking-tight">Admins</h1> <h1 class="text-2xl font-semibold tracking-tight">Admins</h1>
<p class="text-sm text-muted-foreground"> <p class="text-sm text-muted-foreground">Manage Better Auth users and admin roles.</p>
Manage Better Auth users with administrator access.
</p>
</div> </div>
<CreateAdminDialog bind:open={createOpen} {createForm} {createData} {enhanceCreate} /> <CreateAdminDialog bind:open={createOpen} {createForm} {createData} {enhanceCreate} />
@@ -1,6 +1,7 @@
<script lang="ts"> <script lang="ts">
/* eslint-disable @typescript-eslint/no-explicit-any */ /* eslint-disable @typescript-eslint/no-explicit-any */
import EllipsisVerticalIcon from '@lucide/svelte/icons/ellipsis-vertical'; import EllipsisVerticalIcon from '@lucide/svelte/icons/ellipsis-vertical';
import { Badge } from '$lib/components/ui/badge/index.js';
import { Button } from '$lib/components/ui/button/index.js'; import { Button } from '$lib/components/ui/button/index.js';
import * as DropdownMenu from '$lib/components/ui/dropdown-menu/index.js'; import * as DropdownMenu from '$lib/components/ui/dropdown-menu/index.js';
import * as Table from '$lib/components/ui/table/index.js'; import * as Table from '$lib/components/ui/table/index.js';
@@ -23,6 +24,7 @@
<Table.Row> <Table.Row>
<Table.Head>Name</Table.Head> <Table.Head>Name</Table.Head>
<Table.Head>Email</Table.Head> <Table.Head>Email</Table.Head>
<Table.Head>Role</Table.Head>
<Table.Head>Status</Table.Head> <Table.Head>Status</Table.Head>
<Table.Head>Created</Table.Head> <Table.Head>Created</Table.Head>
<Table.Head class="w-12"> <Table.Head class="w-12">
@@ -36,7 +38,14 @@
<Table.Cell class="font-medium">{user.name}</Table.Cell> <Table.Cell class="font-medium">{user.name}</Table.Cell>
<Table.Cell>{user.email}</Table.Cell> <Table.Cell>{user.email}</Table.Cell>
<Table.Cell> <Table.Cell>
{user.emailVerified ? 'Verified' : 'Unverified'} <Badge variant="secondary" class="capitalize">{user.role ?? 'user'}</Badge>
</Table.Cell>
<Table.Cell>
{#if user.banned}
<Badge variant="destructive">Banned</Badge>
{:else}
{user.emailVerified ? 'Verified' : 'Unverified'}
{/if}
</Table.Cell> </Table.Cell>
<Table.Cell>{formatDate(user.createdAt)}</Table.Cell> <Table.Cell>{formatDate(user.createdAt)}</Table.Cell>
<Table.Cell> <Table.Cell>
@@ -69,6 +78,6 @@
{#if data.users.length === 0} {#if data.users.length === 0}
<div class="rounded-lg border p-8 text-center text-sm text-muted-foreground"> <div class="rounded-lg border p-8 text-center text-sm text-muted-foreground">
No admin users have been created yet. No users have been created yet.
</div> </div>
{/if} {/if}
@@ -6,6 +6,7 @@
import { Button } from '$lib/components/ui/button/index.js'; import { Button } from '$lib/components/ui/button/index.js';
import * as Dialog from '$lib/components/ui/dialog/index.js'; import * as Dialog from '$lib/components/ui/dialog/index.js';
import { Input } from '$lib/components/ui/input/index.js'; import { Input } from '$lib/components/ui/input/index.js';
import * as NativeSelect from '$lib/components/ui/native-select/index.js';
let { let {
open = $bindable(false), open = $bindable(false),
createForm, createForm,
@@ -24,14 +25,14 @@
{#snippet child({ props })} {#snippet child({ props })}
<Button {...props}> <Button {...props}>
<PlusIcon data-icon="inline-start" /> <PlusIcon data-icon="inline-start" />
Create admin Create user
</Button> </Button>
{/snippet} {/snippet}
</Dialog.Trigger> </Dialog.Trigger>
<Dialog.Content class="sm:max-w-md"> <Dialog.Content class="sm:max-w-md">
<Dialog.Header> <Dialog.Header>
<Dialog.Title>Create admin</Dialog.Title> <Dialog.Title>Create user</Dialog.Title>
<Dialog.Description>Add a manually managed admin account.</Dialog.Description> <Dialog.Description>Add a manually managed Better Auth user.</Dialog.Description>
</Dialog.Header> </Dialog.Header>
<form method="POST" action="?/create" class="grid gap-2" use:enhanceCreate> <form method="POST" action="?/create" class="grid gap-2" use:enhanceCreate>
<FormField form={createForm} name="name"> <FormField form={createForm} name="name">
@@ -72,13 +73,27 @@
<Field.Error><FieldErrors /></Field.Error> <Field.Error><FieldErrors /></Field.Error>
</Field.Field> </Field.Field>
</FormField> </FormField>
<FormField form={createForm} name="role">
<Field.Field>
<Control id="create-role">
{#snippet children({ props })}
<Field.Label>Role</Field.Label>
<NativeSelect.Root {...props} bind:value={$createData.role} class="w-full">
<NativeSelect.Option value="admin">Admin</NativeSelect.Option>
<NativeSelect.Option value="user">User</NativeSelect.Option>
</NativeSelect.Root>
{/snippet}
</Control>
<Field.Error><FieldErrors /></Field.Error>
</Field.Field>
</FormField>
<Dialog.Footer> <Dialog.Footer>
<Dialog.Close> <Dialog.Close>
{#snippet child({ props })} {#snippet child({ props })}
<Button variant="outline" {...props}>Cancel</Button> <Button variant="outline" {...props}>Cancel</Button>
{/snippet} {/snippet}
</Dialog.Close> </Dialog.Close>
<Button type="submit">Create admin</Button> <Button type="submit">Create user</Button>
</Dialog.Footer> </Dialog.Footer>
</form> </form>
</Dialog.Content> </Dialog.Content>
@@ -17,9 +17,9 @@
<AlertDialog.Root open={!!deletingUser} onOpenChange={(open) => !open && onClose()}> <AlertDialog.Root open={!!deletingUser} onOpenChange={(open) => !open && onClose()}>
<AlertDialog.Content> <AlertDialog.Content>
<AlertDialog.Header> <AlertDialog.Header>
<AlertDialog.Title>Delete admin?</AlertDialog.Title> <AlertDialog.Title>Delete user?</AlertDialog.Title>
<AlertDialog.Description> <AlertDialog.Description>
This will permanently delete {deletingUser?.name ?? 'this admin'} and revoke their sessions. This will permanently delete {deletingUser?.name ?? 'this user'} and revoke their sessions.
</AlertDialog.Description> </AlertDialog.Description>
</AlertDialog.Header> </AlertDialog.Header>
<AlertDialog.Footer> <AlertDialog.Footer>
@@ -27,7 +27,7 @@
{#if deletingUser} {#if deletingUser}
<form method="POST" action="?/delete" use:enhanceDelete> <form method="POST" action="?/delete" use:enhanceDelete>
<input type="hidden" name="id" value={$deleteData.id} /> <input type="hidden" name="id" value={$deleteData.id} />
<AlertDialog.Action type="submit" variant="destructive">Delete admin</AlertDialog.Action> <AlertDialog.Action type="submit" variant="destructive">Delete user</AlertDialog.Action>
</form> </form>
{/if} {/if}
</AlertDialog.Footer> </AlertDialog.Footer>
@@ -5,6 +5,7 @@
import { Button } from '$lib/components/ui/button/index.js'; import { Button } from '$lib/components/ui/button/index.js';
import * as Dialog from '$lib/components/ui/dialog/index.js'; import * as Dialog from '$lib/components/ui/dialog/index.js';
import { Input } from '$lib/components/ui/input/index.js'; import { Input } from '$lib/components/ui/input/index.js';
import * as NativeSelect from '$lib/components/ui/native-select/index.js';
let { let {
editingUser, editingUser,
editForm, editForm,
@@ -23,7 +24,7 @@
<Dialog.Root open={!!editingUser} onOpenChange={(open) => !open && onClose()}> <Dialog.Root open={!!editingUser} onOpenChange={(open) => !open && onClose()}>
<Dialog.Content class="sm:max-w-md"> <Dialog.Content class="sm:max-w-md">
<Dialog.Header> <Dialog.Header>
<Dialog.Title>Edit admin</Dialog.Title> <Dialog.Title>Edit user</Dialog.Title>
<Dialog.Description>Update account details or set a new password.</Dialog.Description> <Dialog.Description>Update account details or set a new password.</Dialog.Description>
</Dialog.Header> </Dialog.Header>
{#if editingUser} {#if editingUser}
@@ -68,6 +69,20 @@
<Field.Error><FieldErrors /></Field.Error> <Field.Error><FieldErrors /></Field.Error>
</Field.Field> </Field.Field>
</FormField> </FormField>
<FormField form={editForm} name="role">
<Field.Field>
<Control id="edit-role">
{#snippet children({ props })}
<Field.Label>Role</Field.Label>
<NativeSelect.Root {...props} bind:value={$editData.role} class="w-full">
<NativeSelect.Option value="admin">Admin</NativeSelect.Option>
<NativeSelect.Option value="user">User</NativeSelect.Option>
</NativeSelect.Root>
{/snippet}
</Control>
<Field.Error><FieldErrors /></Field.Error>
</Field.Field>
</FormField>
<Dialog.Footer> <Dialog.Footer>
<Dialog.Close> <Dialog.Close>
{#snippet child({ props })} {#snippet child({ props })}