feat: migrate admins to better auth roles
This commit is contained in:
@@ -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
@@ -36,6 +36,13 @@
|
||||
"when": 1780609661307,
|
||||
"tag": "0004_orange_ultimatum",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 5,
|
||||
"version": "6",
|
||||
"when": 1780699922786,
|
||||
"tag": "0005_bright_wiccan",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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, {
|
||||
message: passwordMessage
|
||||
});
|
||||
const roleSchema = z.enum(['admin', 'user'], 'Select a role.');
|
||||
|
||||
export const createAdminSchema = z.object({
|
||||
name: z.string().trim().min(1, 'Enter a name.'),
|
||||
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({
|
||||
id: z.string().min(1, 'Admin id is required.'),
|
||||
name: z.string().trim().min(1, 'Enter a name.'),
|
||||
email: z.email('Enter a valid email address.').transform((value) => value.toLowerCase()),
|
||||
password: optionalPassword
|
||||
password: optionalPassword,
|
||||
role: roleSchema
|
||||
});
|
||||
|
||||
export const deleteAdminSchema = z.object({
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { betterAuth } from 'better-auth/minimal';
|
||||
import { drizzleAdapter } from 'better-auth/adapters/drizzle';
|
||||
import { admin } from 'better-auth/plugins';
|
||||
import { sveltekitCookies } from 'better-auth/svelte-kit';
|
||||
import { env } from '$env/dynamic/private';
|
||||
import { getRequestEvent } from '$app/server';
|
||||
@@ -14,6 +15,7 @@ export const auth = betterAuth({
|
||||
disableSignUp: true
|
||||
},
|
||||
plugins: [
|
||||
admin(),
|
||||
sveltekitCookies(getRequestEvent) // make sure this is the last plugin in the array
|
||||
]
|
||||
});
|
||||
|
||||
@@ -13,7 +13,11 @@ export const user = sqliteTable('user', {
|
||||
updatedAt: integer('updated_at', { mode: 'timestamp_ms' })
|
||||
.default(sql`(cast(unixepoch('subsecond') * 1000 as integer))`)
|
||||
.$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(
|
||||
@@ -32,7 +36,8 @@ export const session = sqliteTable(
|
||||
userAgent: text('user_agent'),
|
||||
userId: text('user_id')
|
||||
.notNull()
|
||||
.references(() => user.id, { onDelete: 'cascade' })
|
||||
.references(() => user.id, { onDelete: 'cascade' }),
|
||||
impersonatedBy: text('impersonated_by')
|
||||
},
|
||||
(table) => [index('session_userId_idx').on(table.userId)]
|
||||
);
|
||||
|
||||
@@ -1,22 +1,20 @@
|
||||
import { hashPassword } from 'better-auth/crypto';
|
||||
import { asc, eq } from 'drizzle-orm';
|
||||
import { message, superValidate } from 'sveltekit-superforms/server';
|
||||
import { zod4 } from 'sveltekit-superforms/adapters';
|
||||
import { auth } from '$lib/server/auth';
|
||||
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 type { Actions, PageServerLoad } from './$types';
|
||||
|
||||
function now() {
|
||||
return new Date();
|
||||
}
|
||||
|
||||
export const load: PageServerLoad = async ({ locals }) => {
|
||||
const users = await db
|
||||
.select({
|
||||
id: user.id,
|
||||
name: user.name,
|
||||
email: user.email,
|
||||
role: user.role,
|
||||
banned: user.banned,
|
||||
emailVerified: user.emailVerified,
|
||||
createdAt: user.createdAt,
|
||||
updatedAt: user.updatedAt
|
||||
@@ -41,29 +39,15 @@ export const actions: Actions = {
|
||||
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 {
|
||||
await db.insert(user).values({
|
||||
id: userId,
|
||||
await auth.api.createUser({
|
||||
headers: event.request.headers,
|
||||
body: {
|
||||
name: form.data.name,
|
||||
email: form.data.email,
|
||||
emailVerified: true,
|
||||
createdAt,
|
||||
updatedAt: createdAt
|
||||
});
|
||||
|
||||
await db.insert(account).values({
|
||||
id: accountId,
|
||||
accountId: userId,
|
||||
providerId: 'credential',
|
||||
userId,
|
||||
password: passwordHash,
|
||||
createdAt,
|
||||
updatedAt: createdAt
|
||||
password: form.data.password,
|
||||
role: form.data.role
|
||||
}
|
||||
});
|
||||
} catch {
|
||||
return message(form, 'Unable to create admin. Check whether that email already exists.', {
|
||||
@@ -82,36 +66,33 @@ export const actions: Actions = {
|
||||
}
|
||||
|
||||
try {
|
||||
await db
|
||||
.update(user)
|
||||
.set({
|
||||
await auth.api.adminUpdateUser({
|
||||
headers: event.request.headers,
|
||||
body: {
|
||||
userId: form.data.id,
|
||||
data: {
|
||||
name: form.data.name,
|
||||
email: form.data.email,
|
||||
updatedAt: now()
|
||||
})
|
||||
.where(eq(user.id, form.data.id));
|
||||
email: form.data.email
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
await auth.api.setRole({
|
||||
headers: event.request.headers,
|
||||
body: {
|
||||
userId: form.data.id,
|
||||
role: form.data.role
|
||||
}
|
||||
});
|
||||
|
||||
if (form.data.password) {
|
||||
const passwordHash = await hashPassword(form.data.password);
|
||||
const updated = await db
|
||||
.update(account)
|
||||
.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',
|
||||
await auth.api.setUserPassword({
|
||||
headers: event.request.headers,
|
||||
body: {
|
||||
userId: form.data.id,
|
||||
password: passwordHash,
|
||||
createdAt: now(),
|
||||
updatedAt: now()
|
||||
});
|
||||
newPassword: form.data.password
|
||||
}
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
return message(form, 'Unable to update admin. Check whether that email already exists.', {
|
||||
|
||||
@@ -79,6 +79,7 @@
|
||||
id: user.id,
|
||||
name: user.name,
|
||||
email: user.email,
|
||||
role: user.role === 'admin' ? 'admin' : 'user',
|
||||
password: ''
|
||||
}
|
||||
});
|
||||
@@ -103,9 +104,7 @@
|
||||
<div class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<h1 class="text-2xl font-semibold tracking-tight">Admins</h1>
|
||||
<p class="text-sm text-muted-foreground">
|
||||
Manage Better Auth users with administrator access.
|
||||
</p>
|
||||
<p class="text-sm text-muted-foreground">Manage Better Auth users and admin roles.</p>
|
||||
</div>
|
||||
|
||||
<CreateAdminDialog bind:open={createOpen} {createForm} {createData} {enhanceCreate} />
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<script lang="ts">
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
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 * as DropdownMenu from '$lib/components/ui/dropdown-menu/index.js';
|
||||
import * as Table from '$lib/components/ui/table/index.js';
|
||||
@@ -23,6 +24,7 @@
|
||||
<Table.Row>
|
||||
<Table.Head>Name</Table.Head>
|
||||
<Table.Head>Email</Table.Head>
|
||||
<Table.Head>Role</Table.Head>
|
||||
<Table.Head>Status</Table.Head>
|
||||
<Table.Head>Created</Table.Head>
|
||||
<Table.Head class="w-12">
|
||||
@@ -36,7 +38,14 @@
|
||||
<Table.Cell class="font-medium">{user.name}</Table.Cell>
|
||||
<Table.Cell>{user.email}</Table.Cell>
|
||||
<Table.Cell>
|
||||
<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>{formatDate(user.createdAt)}</Table.Cell>
|
||||
<Table.Cell>
|
||||
@@ -69,6 +78,6 @@
|
||||
|
||||
{#if data.users.length === 0}
|
||||
<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>
|
||||
{/if}
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
import { Button } from '$lib/components/ui/button/index.js';
|
||||
import * as Dialog from '$lib/components/ui/dialog/index.js';
|
||||
import { Input } from '$lib/components/ui/input/index.js';
|
||||
import * as NativeSelect from '$lib/components/ui/native-select/index.js';
|
||||
let {
|
||||
open = $bindable(false),
|
||||
createForm,
|
||||
@@ -24,14 +25,14 @@
|
||||
{#snippet child({ props })}
|
||||
<Button {...props}>
|
||||
<PlusIcon data-icon="inline-start" />
|
||||
Create admin
|
||||
Create user
|
||||
</Button>
|
||||
{/snippet}
|
||||
</Dialog.Trigger>
|
||||
<Dialog.Content class="sm:max-w-md">
|
||||
<Dialog.Header>
|
||||
<Dialog.Title>Create admin</Dialog.Title>
|
||||
<Dialog.Description>Add a manually managed admin account.</Dialog.Description>
|
||||
<Dialog.Title>Create user</Dialog.Title>
|
||||
<Dialog.Description>Add a manually managed Better Auth user.</Dialog.Description>
|
||||
</Dialog.Header>
|
||||
<form method="POST" action="?/create" class="grid gap-2" use:enhanceCreate>
|
||||
<FormField form={createForm} name="name">
|
||||
@@ -72,13 +73,27 @@
|
||||
<Field.Error><FieldErrors /></Field.Error>
|
||||
</Field.Field>
|
||||
</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.Close>
|
||||
{#snippet child({ props })}
|
||||
<Button variant="outline" {...props}>Cancel</Button>
|
||||
{/snippet}
|
||||
</Dialog.Close>
|
||||
<Button type="submit">Create admin</Button>
|
||||
<Button type="submit">Create user</Button>
|
||||
</Dialog.Footer>
|
||||
</form>
|
||||
</Dialog.Content>
|
||||
|
||||
@@ -17,9 +17,9 @@
|
||||
<AlertDialog.Root open={!!deletingUser} onOpenChange={(open) => !open && onClose()}>
|
||||
<AlertDialog.Content>
|
||||
<AlertDialog.Header>
|
||||
<AlertDialog.Title>Delete admin?</AlertDialog.Title>
|
||||
<AlertDialog.Title>Delete user?</AlertDialog.Title>
|
||||
<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.Header>
|
||||
<AlertDialog.Footer>
|
||||
@@ -27,7 +27,7 @@
|
||||
{#if deletingUser}
|
||||
<form method="POST" action="?/delete" use:enhanceDelete>
|
||||
<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>
|
||||
{/if}
|
||||
</AlertDialog.Footer>
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
import { Button } from '$lib/components/ui/button/index.js';
|
||||
import * as Dialog from '$lib/components/ui/dialog/index.js';
|
||||
import { Input } from '$lib/components/ui/input/index.js';
|
||||
import * as NativeSelect from '$lib/components/ui/native-select/index.js';
|
||||
let {
|
||||
editingUser,
|
||||
editForm,
|
||||
@@ -23,7 +24,7 @@
|
||||
<Dialog.Root open={!!editingUser} onOpenChange={(open) => !open && onClose()}>
|
||||
<Dialog.Content class="sm:max-w-md">
|
||||
<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.Header>
|
||||
{#if editingUser}
|
||||
@@ -68,6 +69,20 @@
|
||||
<Field.Error><FieldErrors /></Field.Error>
|
||||
</Field.Field>
|
||||
</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.Close>
|
||||
{#snippet child({ props })}
|
||||
|
||||
Reference in New Issue
Block a user