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