admin: Superforms + Zod validation, three-address-defaults schema, profiles toast fix

- Add Superforms + Zod 4 for all admin form validation
- Replace address isDefault with three separate boolean defaults (Mailing, Billing, Shipping)
- Update profiles page with three-checkbox address form and toast improvements
- Convert all admin page server actions to pass request directly to crud
- Export superFormErrorsToFieldErrors helper for profiles server actions
- Regenerate Prisma client for new schema fields
This commit is contained in:
NeilClack
2026-07-07 13:36:23 -05:00
parent 38347f5f24
commit 38d1752f5b
98 changed files with 3408 additions and 1758 deletions
@@ -37,12 +37,16 @@
<h2 class="mb-4 text-lg font-medium text-gray-900 dark:text-gray-100">Profile</h2>
{#if form?.action === 'updateProfile' && form?.success}
<div class="mb-4 rounded-lg border border-green-200 bg-green-50 p-3 text-green-700 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-300">
<div
class="mb-4 rounded-lg border border-green-200 bg-green-50 p-3 text-green-700 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-300"
>
Profile updated successfully.
</div>
{/if}
{#if form?.action === 'updateProfile' && form?.error}
<div class="mb-4 rounded-lg border border-red-200 bg-red-50 p-3 text-red-700 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-300">
<div
class="mb-4 rounded-lg border border-red-200 bg-red-50 p-3 text-red-700 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-300"
>
{form.error}
</div>
{/if}
@@ -115,12 +119,16 @@
</div>
{#if form?.action === 'addAddress' && form?.success}
<div class="mb-4 rounded-lg border border-green-200 bg-green-50 p-3 text-green-700 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-300">
<div
class="mb-4 rounded-lg border border-green-200 bg-green-50 p-3 text-green-700 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-300"
>
Address added successfully.
</div>
{/if}
{#if form?.action === 'addAddress' && form?.error}
<div class="mb-4 rounded-lg border border-red-200 bg-red-50 p-3 text-red-700 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-300">
<div
class="mb-4 rounded-lg border border-red-200 bg-red-50 p-3 text-red-700 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-300"
>
{form.error}
</div>
{/if}
@@ -133,7 +141,9 @@
>
<div class="flex flex-col gap-3">
<div>
<label for="street" class="block text-sm font-medium text-gray-700 dark:text-gray-300">Street</label>
<label for="street" class="block text-sm font-medium text-gray-700 dark:text-gray-300"
>Street</label
>
<input
type="text"
id="street"
@@ -144,7 +154,9 @@
</div>
<div class="grid grid-cols-2 gap-3">
<div>
<label for="city" class="block text-sm font-medium text-gray-700 dark:text-gray-300">City</label>
<label for="city" class="block text-sm font-medium text-gray-700 dark:text-gray-300"
>City</label
>
<input
type="text"
id="city"
@@ -154,7 +166,9 @@
/>
</div>
<div>
<label for="state" class="block text-sm font-medium text-gray-700 dark:text-gray-300">State</label>
<label for="state" class="block text-sm font-medium text-gray-700 dark:text-gray-300"
>State</label
>
<input
type="text"
id="state"
@@ -166,7 +180,10 @@
</div>
<div class="grid grid-cols-2 gap-3">
<div>
<label for="postalCode" class="block text-sm font-medium text-gray-700 dark:text-gray-300">
<label
for="postalCode"
class="block text-sm font-medium text-gray-700 dark:text-gray-300"
>
Postal Code
</label>
<input
@@ -178,7 +195,10 @@
/>
</div>
<div>
<label for="country" class="block text-sm font-medium text-gray-700 dark:text-gray-300">Country</label>
<label
for="country"
class="block text-sm font-medium text-gray-700 dark:text-gray-300">Country</label
>
<input
type="text"
id="country"
@@ -204,10 +224,13 @@
{#if data.user.profile?.addresses && data.user.profile.addresses.length > 0}
<div class="flex flex-col gap-3">
{#each data.user.profile.addresses as address}
<div class="rounded-lg border border-gray-200 bg-white p-4 dark:border-gray-700 dark:bg-gray-900">
<div
class="rounded-lg border border-gray-200 bg-white p-4 dark:border-gray-700 dark:bg-gray-900"
>
<p class="text-sm text-gray-900 dark:text-gray-100">{address.street}</p>
<p class="text-sm text-gray-600 dark:text-gray-400">
{address.city}, {address.state} {address.postalCode}
{address.city}, {address.state}
{address.postalCode}
</p>
<p class="text-sm text-gray-600 dark:text-gray-400">{address.country}</p>
</div>
@@ -168,9 +168,9 @@ describe('(protected)/account page server', () => {
country: 'US'
});
const err = await Promise.resolve(
invokeAction('addAddress', request, createLocals())
).catch((e: unknown) => e);
const err = await Promise.resolve(invokeAction('addAddress', request, createLocals())).catch(
(e: unknown) => e
);
expect(isHttpError(err, 403)).toBe(true);
});
@@ -201,9 +201,9 @@ describe('(protected)/account page server', () => {
});
const locals = createLocals(mockUser() as any);
const err = await Promise.resolve(
invokeAction('addAddress', request, locals)
).catch((e: unknown) => e);
const err = await Promise.resolve(invokeAction('addAddress', request, locals)).catch(
(e: unknown) => e
);
expect(isHttpError(err, 400)).toBe(true);
});
@@ -1,4 +1,3 @@
<!--
There should be a form in the middle
rounded corners
@@ -44,17 +43,29 @@
<Window>
<form method="POST" class="border p-6 dark:border-gray-700 dark:bg-gray-900 dark:text-gray-100">
<p class="mx-auto text-center my-4 italic">Please finish setting up your profile</p>
<p class="mx-auto my-4 text-center italic">Please finish setting up your profile</p>
{#if data.user?.image}
<img src={data.user?.image} class="rounded-full w-[8rem] mx-auto" alt="Profile" />
<img src={data.user?.image} class="mx-auto w-[8rem] rounded-full" alt="Profile" />
{:else}
<div class="mx-auto aspect-square w-[8rem] rounded-full bg-slate-900"></div>
{/if}
<p class="mx-auto text-center my-4">{data.user?.email}</p>
<p class="mx-auto my-4 text-center">{data.user?.email}</p>
<div class="flex flex-col">
<input type="text" name="userId" value={data.user?.id} class="hidden"/>
<input type="text" name="firstName" placeholder="First Name" class="my-2" bind:value={firstName} />
<input type="text" name="lastName" placeholder="Last Name" class="my-2" bind:value={lastName} />
<input type="text" name="userId" value={data.user?.id} class="hidden" />
<input
type="text"
name="firstName"
placeholder="First Name"
class="my-2"
bind:value={firstName}
/>
<input
type="text"
name="lastName"
placeholder="Last Name"
class="my-2"
bind:value={lastName}
/>
<input
type="tel"
name="phoneNumber"
@@ -64,6 +75,8 @@
oninput={handlePhoneInput}
/>
</div>
<button type="submit" class="p-2 px-4 bg-blue-300 dark:bg-gray-700 dark:text-gray-100">Submit</button>
<button type="submit" class="bg-blue-300 p-2 px-4 dark:bg-gray-700 dark:text-gray-100"
>Submit</button
>
</form>
</Window>
@@ -33,9 +33,9 @@ describe('account setup page server load', () => {
});
it('throws 403 when user is not logged in', async () => {
const err = await Promise.resolve(
load({ locals: createLocals() } as any)
).catch((e: unknown) => e);
const err = await Promise.resolve(load({ locals: createLocals() } as any)).catch(
(e: unknown) => e
);
expect(isHttpError(err, 403)).toBe(true);
});
@@ -5,7 +5,9 @@
</script>
<div class="flex h-full min-h-0 flex-col gap-6 p-6 dark:text-gray-100">
<h1 class="text-2xl font-semibold text-gray-900 dark:text-gray-100">Welcome, {data.firstName}!</h1>
<h1 class="text-2xl font-semibold text-gray-900 dark:text-gray-100">
Welcome, {data.firstName}!
</h1>
<div class="grid grid-cols-1 gap-4 sm:grid-cols-3">
<a
@@ -22,7 +24,9 @@
class="rounded-lg border border-gray-200 bg-white p-6 shadow-sm transition-shadow hover:shadow-md dark:border-gray-700 dark:bg-gray-900"
>
<p class="text-sm font-medium text-gray-500 dark:text-gray-400">Proclamations</p>
<p class="mt-2 text-3xl font-bold text-gray-900 dark:text-gray-100">{data.proclamationCount}</p>
<p class="mt-2 text-3xl font-bold text-gray-900 dark:text-gray-100">
{data.proclamationCount}
</p>
<p class="mt-1 text-sm text-gray-400 dark:text-gray-500">Browse all</p>
</a>
@@ -12,13 +12,19 @@
<table class="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
<thead class="bg-gray-50 dark:bg-gray-800">
<tr>
<th class="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-gray-400">
<th
class="px-6 py-3 text-left text-xs font-medium tracking-wider text-gray-500 uppercase dark:text-gray-400"
>
Key
</th>
<th class="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-gray-400">
<th
class="px-6 py-3 text-left text-xs font-medium tracking-wider text-gray-500 uppercase dark:text-gray-400"
>
Level
</th>
<th class="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-gray-400">
<th
class="px-6 py-3 text-left text-xs font-medium tracking-wider text-gray-500 uppercase dark:text-gray-400"
>
Status
</th>
</tr>
@@ -26,15 +32,17 @@
<tbody class="divide-y divide-gray-200 bg-white dark:divide-gray-700 dark:bg-gray-900">
{#each data.licenseKeys as key}
<tr>
<td class="whitespace-nowrap px-6 py-4 font-mono text-sm text-gray-900 dark:text-gray-100">
<td
class="px-6 py-4 font-mono text-sm whitespace-nowrap text-gray-900 dark:text-gray-100"
>
{key.key}
</td>
<td class="whitespace-nowrap px-6 py-4 text-sm text-gray-700 dark:text-gray-300">
<td class="px-6 py-4 text-sm whitespace-nowrap text-gray-700 dark:text-gray-300">
{key.level}
</td>
<td class="whitespace-nowrap px-6 py-4 text-sm">
<td class="px-6 py-4 text-sm whitespace-nowrap">
<span
class="inline-flex rounded-full px-2 text-xs font-semibold leading-5 {key.active
class="inline-flex rounded-full px-2 text-xs leading-5 font-semibold {key.active
? 'bg-green-100 text-green-800 dark:bg-gray-700 dark:text-gray-200'
: 'bg-red-100 text-red-800 dark:bg-gray-700 dark:text-gray-200'}"
>
@@ -23,7 +23,7 @@ export const load: PageServerLoad = async ({ url }) => {
const [proclamations, categories] = await Promise.all([
prisma.proclamation.findMany({
where,
include: { solfeggioFrequency: true, category: { include: { parent: true } } },
include: { solfeggioFrequency: true, category: { include: { parent: true } }, subscriptionLevel: true },
orderBy: { name: 'asc' }
}),
prisma.proclamationCategory.findMany({ include: { parent: true }, orderBy: { name: 'asc' } })
@@ -40,7 +40,9 @@
<!-- Filters -->
<div class="flex flex-wrap items-end gap-4">
<div>
<label for="search" class="block text-sm font-medium text-gray-700 dark:text-gray-300">Search</label>
<label for="search" class="block text-sm font-medium text-gray-700 dark:text-gray-300"
>Search</label
>
<input
type="text"
id="search"
@@ -51,7 +53,9 @@
/>
</div>
<div>
<label for="category" class="block text-sm font-medium text-gray-700 dark:text-gray-300">Category</label>
<label for="category" class="block text-sm font-medium text-gray-700 dark:text-gray-300"
>Category</label
>
<select
id="category"
bind:value={categoryId}
@@ -60,7 +64,7 @@
>
<option value={null}>All Categories</option>
{#each data.categories as cat}
<option value={cat.id}>{displayCategoryName(cat)}</option>
<option value={cat.id}>{displayCategoryName(cat)}</option>
{/each}
</select>
</div>
@@ -91,14 +95,16 @@
class="flex flex-col rounded-lg border border-gray-200 bg-white p-5 shadow-sm transition-shadow hover:shadow-md dark:border-gray-700 dark:bg-gray-900"
>
<h2 class="text-lg font-medium text-gray-900 dark:text-gray-100">{proclamation.name}</h2>
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">{displayCategoryName(proclamation.category)}</p>
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
{displayCategoryName(proclamation.category)}
</p>
<div class="mt-auto flex items-center justify-between pt-4">
<span class="text-sm text-purple-600 dark:text-gray-400">
{proclamation.solfeggioFrequency.value} Hz &mdash;
{proclamation.solfeggioFrequency.name}
</span>
<span class="font-semibold text-gray-900 dark:text-gray-100">
${proclamation.price.toFixed(2)}
<span class="text-xs text-gray-500 dark:text-gray-400">
{proclamation.subscriptionLevel?.name ?? 'Standard'}
</span>
</div>
</a>
@@ -14,7 +14,8 @@ export const load: PageServerLoad = async ({ params, parent }) => {
where: { id: params.id },
include: {
solfeggioFrequency: true,
category: { include: { parent: true } }
category: { include: { parent: true } },
subscriptionLevel: true
}
});
@@ -19,27 +19,35 @@
</a>
<div>
<h1 class="text-2xl font-semibold text-gray-900 dark:text-gray-100">{data.proclamation.name}</h1>
<h1 class="text-2xl font-semibold text-gray-900 dark:text-gray-100">
{data.proclamation.name}
</h1>
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">{categoryBreadcrumb}</p>
</div>
<div class="flex flex-wrap gap-4">
<div class="rounded-lg border border-purple-200 bg-purple-50 px-4 py-2 dark:border-gray-700 dark:bg-gray-800">
<div
class="rounded-lg border border-purple-200 bg-purple-50 px-4 py-2 dark:border-gray-700 dark:bg-gray-800"
>
<p class="text-xs font-medium text-purple-600 dark:text-gray-400">Frequency</p>
<p class="text-sm font-semibold text-purple-900 dark:text-gray-100">
{data.proclamation.solfeggioFrequency.value} Hz &mdash;
{data.proclamation.solfeggioFrequency.name}
</p>
</div>
<div class="rounded-lg border border-gray-200 bg-gray-50 px-4 py-2 dark:border-gray-700 dark:bg-gray-800">
<p class="text-xs font-medium text-gray-500 dark:text-gray-400">Price</p>
<div
class="rounded-lg border border-gray-200 bg-gray-50 px-4 py-2 dark:border-gray-700 dark:bg-gray-800"
>
<p class="text-xs font-medium text-gray-500 dark:text-gray-400">Level</p>
<p class="text-sm font-semibold text-gray-900 dark:text-gray-100">
${data.proclamation.price.toFixed(2)}
{data.proclamation.subscriptionLevel?.name ?? 'Standard'}
</p>
</div>
</div>
<div class="prose max-w-none rounded-lg border border-gray-200 bg-white p-6 dark:border-gray-700 dark:bg-gray-900 dark:prose-invert">
<div
class="prose max-w-none rounded-lg border border-gray-200 bg-white p-6 dark:border-gray-700 dark:bg-gray-900 dark:prose-invert"
>
{@html data.proclamation.content}
</div>
</div>
@@ -21,9 +21,9 @@ describe('(protected)/admin/proclamations/[id] page server load', () => {
it('throws 404 when proclamation is not found', async () => {
findUnique.mockResolvedValue(null);
const err = await Promise.resolve(
load({ params: { id: 'nonexistent' } } as any)
).catch((e: unknown) => e);
const err = await Promise.resolve(load({ params: { id: 'nonexistent' } } as any)).catch(
(e: unknown) => e
);
expect(isHttpError(err, 404)).toBe(true);
});
@@ -44,7 +44,8 @@ describe('(protected)/admin/proclamations/[id] page server load', () => {
where: { id: 'proc-1' },
include: {
solfeggioFrequency: true,
category: { include: { parent: true } }
category: { include: { parent: true } },
subscriptionLevel: true
}
});
});
@@ -14,7 +14,7 @@ import { load } from './+page.server';
const proclamationFindMany = prisma.proclamation.findMany as ReturnType<typeof vi.fn>;
const categoryFindMany = prisma.proclamationCategory.findMany as ReturnType<typeof vi.fn>;
describe('(protected)/admin/proclamations page server load', () => {
describe('(protected)/admin/proclamations page server load', () => {
beforeEach(() => {
proclamationFindMany.mockReset();
categoryFindMany.mockReset();
@@ -29,10 +29,12 @@ const categoryFindMany = prisma.proclamationCategory.findMany as ReturnType<type
const url = createUrl('/admin/proclamations');
const result = (await load({ url } as any)) as any;
expect(result.proclamations).toEqual(proclamations);
expect(result.categories[0]).toEqual(expect.objectContaining({
...categories[0],
displayName: categories[0].name
}));
expect(result.categories[0]).toEqual(
expect.objectContaining({
...categories[0],
displayName: categories[0].name
})
);
expect(result.search).toBe('');
expect(result.categoryId).toBeNull();
});
@@ -109,7 +111,7 @@ const categoryFindMany = prisma.proclamationCategory.findMany as ReturnType<type
expect(proclamationFindMany).toHaveBeenCalledWith(
expect.objectContaining({
include: { solfeggioFrequency: true, category: { include: { parent: true } } }
include: { solfeggioFrequency: true, category: { include: { parent: true } }, subscriptionLevel: true }
})
);
});
+1 -3
View File
@@ -48,9 +48,7 @@
{#each adminModels as model}
<a
href="/admin/{model.slug}"
class="rounded px-3 py-1 text-sm transition-colors {isActive(
'/admin/' + model.slug
)
class="rounded px-3 py-1 text-sm transition-colors {isActive('/admin/' + model.slug)
? 'bg-blue-50 text-blue-700 dark:bg-gray-800 dark:text-gray-100'
: 'text-blue-600 hover:bg-gray-50 hover:text-blue-800 dark:text-gray-400 dark:hover:bg-gray-800 dark:hover:text-gray-100'}"
>
@@ -3,10 +3,10 @@ import { error } from '@sveltejs/kit';
import { prisma } from '$lib/db';
import { canAccessAdmin } from '$lib/svelte-admin/roles';
/**
/**
* This Layout Server-Side page checks if the user is logged in
* and if that user has a sufficient permission level to view any pages
* rendering this layout, redirecting them to a 403 error page if not.
* rendering this layout, redirecting them to a 403 error page if not.
* It also makes the `adminUser` (the currently logged in user) available
* to all of those pages via page data.
*/
@@ -18,7 +18,9 @@ export const load: LayoutServerLoad = async ({ locals }) => {
});
if (!user || !canAccessAdmin(user.roleId)) {
console.log(`Attempted to access /admin via ${user?.id} : ${user?.name} with a role of ${user?.role}`);
console.log(
`Attempted to access /admin via ${user?.id} : ${user?.name} with a role of ${user?.role}`
);
throw error(403, 'not authorized');
}
@@ -0,0 +1,7 @@
import type { PageServerLoad } from './$types';
import { getAllModelConfigs } from '$lib/svelte-admin/registry';
export const load: PageServerLoad = async () => {
const models = getAllModelConfigs().filter((m) => m.slug !== 'license-keys');
return { models };
};
+21 -1
View File
@@ -1 +1,21 @@
<h1 class="p-6 dark:text-gray-100">Admin Dashboard</h1>
<script lang="ts">
import type { PageProps } from './$types';
let { data }: PageProps = $props();
let models = $derived(data.models);
</script>
<h1 class="p-6 text-xl font-bold text-gray-900 dark:text-gray-100">Admin Dashboard</h1>
<div class="grid grid-cols-1 gap-4 px-6 pb-6 sm:grid-cols-2 lg:grid-cols-3">
{#each models as model (model.slug)}
<a
href={'/admin/' + model.slug}
class="block border border-gray-200 p-4 hover:bg-gray-50 dark:border-gray-700 dark:hover:bg-gray-800"
>
<h2 class="text-lg font-semibold text-gray-900 dark:text-gray-100">{model.displayName}</h2>
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
{model.listFields.length} fields
</p>
</a>
{/each}
</div>
@@ -1,10 +0,0 @@
import type { PageServerLoad } from "./$types";
import { prisma } from "$lib/db";
export const load: PageServerLoad = async ({ locals }) => {
console.log(locals.user?.name);
// Load proclamation categories
const categories = await prisma.proclamationCategory.findMany();
console.log(categories);
return { categories };
}
@@ -1,41 +0,0 @@
<script lang="ts">
import type { PageProps } from './$types';
let { data }: PageProps = $props();
function getCategoryNameFromId(id: number): string {
if (data.categories) {
const target = data.categories.find((category) => category.id === id);
return target ? target.name : '';
}
return '';
}
</script>
<!-- Display a list of categories -->
<div class="p-6 dark:text-gray-100">
<table class="w-full">
<thead>
<tr class="border-b border-gray-200 dark:border-gray-700">
<th class="hidden">ID</th>
<th class="px-4 py-2 text-left text-sm font-medium text-gray-500 dark:text-gray-400">Name</th>
<th class="px-4 py-2 text-left text-sm font-medium text-gray-500 dark:text-gray-400">Parent Category</th>
</tr>
</thead>
<tbody>
{#if data.categories}
{#each data.categories as category, i (category.id)}
<tr class="border-b border-gray-200 hover:bg-gray-300 dark:border-gray-700 dark:hover:bg-gray-800">
<td class="hidden">{i}</td>
<td class="px-4 py-2 text-gray-900 dark:text-gray-100">{category.name}</td>
{#if category.parentId}
<td class="px-4 py-2 text-gray-700 dark:text-gray-300">{getCategoryNameFromId(category.parentId)}</td>
{:else}
<td class="px-4 py-2"></td>
{/if}
</tr>
{/each}
{/if}
</tbody>
</table>
</div>
@@ -0,0 +1,68 @@
import type { PageServerLoad } from './$types';
import { getModelConfig } from '$lib/svelte-admin/registry';
import { loadRecords } from '$lib/svelte-admin/crud';
import { prisma } from '$lib/db';
import { sendMail } from '$lib/server/resend';
import { error } from '@sveltejs/kit';
const config = getModelConfig('license-keys')!;
export const load: PageServerLoad = async ({ locals }) => {
return loadRecords(locals, config);
};
export const actions = {
createKey: async ({ request, locals }) => {
if (!locals.user) throw error(401, 'Unauthorized');
const formData = await request.formData();
const email = formData.get('email')?.toString();
const deactivateOld = formData.get('deactivateOld') === 'on';
if (!email) {
return { success: false, error: 'Email is required' };
}
const user = await prisma.user.findUnique({ where: { email } });
if (!user) {
return { success: false, error: 'No user found with that email' };
}
// Deactivate old keys if requested
if (deactivateOld) {
await prisma.licenseKey.updateMany({
where: { userId: user.id, active: true },
data: { active: false }
});
}
// Generate 16-char hex UUID grouped as 4-4-4-4
const hex = crypto.randomUUID().replace(/-/g, '').slice(0, 16).toUpperCase();
const key = `${hex.slice(0, 4)}-${hex.slice(4, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}`;
const licenseKey = await prisma.licenseKey.create({
data: {
key,
active: true,
level: 'standard',
userId: user.id
}
});
// Send email with license key
try {
await sendMail({
to: email,
subject: 'Your License Key',
data: {
firstName: user.name ?? 'Valued Customer',
licenseKey: key
}
});
} catch (e) {
console.error('Failed to send license key email:', e);
}
return { success: true, error: undefined };
}
};
@@ -0,0 +1,100 @@
<script lang="ts">
import type { PageProps } from './$types';
import type { ActionResult } from '$lib/svelte-admin/types';
import AdminTable from '$lib/components/admin/AdminTable.svelte';
import Toast from '$lib/components/admin/Toast.svelte';
import { getModelConfig } from '$lib/svelte-admin/registry';
import { enhance } from '$app/forms';
let { data, form }: PageProps = $props();
const config = getModelConfig('license-keys')!;
let showCreateForm = $state(false);
let toastMessage = $state('');
let toastType = $state<'success' | 'error'>('success');
let createError = $state('');
$effect(() => {
if (form && 'success' in (form as ActionResult)) {
const result = form as ActionResult;
if (result.success) {
toastMessage = 'License key created and emailed';
toastType = 'success';
showCreateForm = false;
} else {
createError = result.error ?? 'Failed to create license key';
}
}
});
function toggleForm() {
showCreateForm = !showCreateForm;
createError = '';
}
</script>
<div class="p-6">
<div class="mb-4 flex items-center justify-between">
<h1 class="text-xl font-bold text-gray-900 dark:text-gray-100">{config.displayName}</h1>
<button
type="button"
onclick={toggleForm}
class="border border-gray-300 px-3 py-1.5 text-sm font-medium dark:border-gray-600 dark:text-gray-300"
>
{showCreateForm ? 'Cancel' : 'Create Key +'}
</button>
</div>
{#if showCreateForm}
<form method="POST" action="?/createKey" use:enhance class="mb-6 border border-gray-200 p-4 dark:border-gray-700">
<h2 class="mb-3 text-sm font-semibold text-gray-700 dark:text-gray-300">
Create New License Key
</h2>
{#if createError}
<div class="mb-3 border border-red-300 bg-red-50 px-3 py-2 text-sm text-red-700">
{createError}
</div>
{/if}
<div class="flex flex-col gap-3">
<div>
<label for="email" class="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">
User Email
</label>
<input
type="email"
id="email"
name="email"
required
class="w-full border border-gray-300 px-3 py-1.5 text-sm dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100"
placeholder="user@example.com"
/>
</div>
<label class="flex items-center gap-2 text-sm text-gray-700 dark:text-gray-300">
<input type="checkbox" name="deactivateOld" class="h-4 w-4" />
Deactivate existing active keys for this user
</label>
<div class="flex justify-end">
<button
type="submit"
class="border border-gray-300 bg-gray-100 px-4 py-1.5 text-sm font-medium hover:bg-gray-200 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-300 dark:hover:bg-gray-700"
>
Create & Email Key
</button>
</div>
</div>
</form>
{/if}
<AdminTable
records={data.records}
listFields={config.listFields}
searchFields={config.searchFields}
fieldTypes={config.fieldTypes}
singular={config.displayNameSingular}
/>
</div>
{#if toastMessage}
<Toast message={toastMessage} type={toastType} />
{/if}
@@ -0,0 +1,18 @@
import type { PageServerLoad } from './$types';
import { getModelConfig } from '$lib/svelte-admin/registry';
import { loadRecords, upsertRecord, deleteRecord } from '$lib/svelte-admin/crud';
const config = getModelConfig('proclamation-categories')!;
export const load: PageServerLoad = async ({ locals }) => {
return loadRecords(locals, config);
};
export const actions = {
upsert: async ({ request, locals }) => {
return upsertRecord(request, config, locals);
},
delete: async ({ request, locals }) => {
return deleteRecord(request, config, locals);
}
};
@@ -0,0 +1,8 @@
<script lang="ts">
import type { PageProps } from './$types';
import AdminPage from '$lib/components/admin/AdminPage.svelte';
let { data, form }: PageProps = $props();
</script>
<AdminPage modelSlug="proclamation-categories" {data} {form} adminRoleId={data.adminUser?.roleId ?? 4} />
@@ -0,0 +1,18 @@
import type { PageServerLoad } from './$types';
import { getModelConfig } from '$lib/svelte-admin/registry';
import { loadRecords, upsertRecord, deleteRecord } from '$lib/svelte-admin/crud';
const config = getModelConfig('proclamations')!;
export const load: PageServerLoad = async ({ locals }) => {
return loadRecords(locals, config);
};
export const actions = {
upsert: async ({ request, locals }) => {
return upsertRecord(request, config, locals);
},
delete: async ({ request, locals }) => {
return deleteRecord(request, config, locals);
}
};
@@ -0,0 +1,8 @@
<script lang="ts">
import type { PageProps } from './$types';
import AdminPage from '$lib/components/admin/AdminPage.svelte';
let { data, form }: PageProps = $props();
</script>
<AdminPage modelSlug="proclamations" {data} {form} adminRoleId={data.adminUser?.roleId ?? 4} />
@@ -1,8 +1,16 @@
import type { PageServerLoad } from './$types';
import { getModelConfig } from '$lib/svelte-admin/registry';
import { loadRecords, upsertRecord, deleteRecord } from '$lib/svelte-admin/crud';
import {
loadRecords,
upsertRecord,
deleteRecord,
superFormErrorsToFieldErrors
} from '$lib/svelte-admin/crud';
import { prisma } from '$lib/db';
import { error } from '@sveltejs/kit';
import { superValidate } from 'sveltekit-superforms';
import { zod4 } from 'sveltekit-superforms/adapters';
import * as Schemas from '$lib/svelte-admin/schemas';
const config = getModelConfig('profiles')!;
@@ -12,67 +20,73 @@ export const load: PageServerLoad = async ({ locals }) => {
export const actions = {
upsert: async ({ request, locals }) => {
const formData = await request.formData();
return upsertRecord(formData, config, locals);
return upsertRecord(request, config, locals);
},
delete: async ({ request, locals }) => {
const formData = await request.formData();
return deleteRecord(formData, config, locals);
return deleteRecord(request, config, locals);
},
upsertAddress: async ({ request, locals }) => {
if (!locals.user) throw error(401, 'Unauthorized');
const formData = await request.formData();
const id = formData.get('id')?.toString();
const profileId = formData.get('profileId')?.toString();
const form = await superValidate(request, zod4(Schemas.Address));
if (!form.valid) {
const { error: addrError, fieldErrors } = superFormErrorsToFieldErrors(
form.errors as Record<string, unknown>
);
return { success: false, addressError: addrError, addressFieldErrors: fieldErrors };
}
const { profileId, isDefaultMailing, isDefaultBilling, isDefaultShipping, ...addressData } =
form.data;
if (!profileId) {
return { success: false, addressError: 'Missing profile ID' };
}
const type = formData.get('type')?.toString() || 'Mailing';
const street = formData.get('street')?.toString() || '';
const city = formData.get('city')?.toString() || '';
const state = formData.get('state')?.toString() || '';
const postalCode = formData.get('postalCode')?.toString() || '';
const country = formData.get('country')?.toString() || '';
const isDefault = formData.get('isDefault') === 'on';
if (!street || !city || !state || !postalCode || !country) {
return {
success: false,
addressError: 'All address fields are required'
};
}
if (isDefault) {
// Unique-default enforcement: clear defaults of same type before setting
if (isDefaultMailing) {
await prisma.address.updateMany({
where: { profileId, type, isDefault: true },
data: { isDefault: false }
where: { profileId, isDefaultMailing: true },
data: { isDefaultMailing: false }
});
}
if (isDefaultBilling) {
await prisma.address.updateMany({
where: { profileId, isDefaultBilling: true },
data: { isDefaultBilling: false }
});
}
if (isDefaultShipping) {
await prisma.address.updateMany({
where: { profileId, isDefaultShipping: true },
data: { isDefaultShipping: false }
});
}
if (id) {
const updated = await prisma.address.update({
where: { id },
data: { type, street, city, state, postalCode, country, isDefault }
});
return { success: true, address: updated };
}
const payload = {
...addressData,
profileId,
isDefaultMailing,
isDefaultBilling,
isDefaultShipping
};
const created = await prisma.address.create({
data: { profileId, type, street, city, state, postalCode, country, isDefault }
});
const created = await prisma.address.create({ data: payload });
return { success: true, address: created };
},
updateUser: async ({ request, locals }) => {
if (!locals.user) throw error(401, 'Unauthorized');
const formData = await request.formData();
const userId = formData.get('id')?.toString();
const email = formData.get('email')?.toString();
const name = formData.get('name')?.toString();
const form = await superValidate(request, zod4(Schemas.UserUpdate));
if (!form.valid) {
const { error: userErr, fieldErrors } = superFormErrorsToFieldErrors(
form.errors as Record<string, unknown>
);
return { success: false, userError: userErr, userFieldErrors: fieldErrors };
}
if (!userId) {
const { id, email, name } = form.data;
if (!id) {
return { success: false, userError: 'Missing user ID' };
}
@@ -83,7 +97,7 @@ export const actions = {
return { success: false, userError: 'No fields to update' };
}
await prisma.user.update({ where: { id: userId }, data });
await prisma.user.update({ where: { id }, data });
return { success: true };
}
};
@@ -30,8 +30,9 @@
state: '',
postalCode: '',
country: '',
type: 'Mailing',
isDefault: 'false'
isDefaultMailing: 'false',
isDefaultBilling: 'false',
isDefaultShipping: 'false'
});
let userEmail = $state('');
@@ -44,11 +45,9 @@
} else if ((form as any).userError) {
userError = (form as any).userError;
} else if (form.success) {
toastMessage = (form as any).addressError
? 'Address saved'
: 'Saved successfully';
toastType = 'success';
if ((form as any).address) {
toastMessage = 'Address saved';
const addr = (form as any).address as Record<string, unknown>;
if (editingRecord) {
const addrs = (editingRecord.addresses as Record<string, unknown>[]) ?? [];
@@ -59,7 +58,8 @@
}
addressFormOpen = false;
addressError = '';
} else if (!(form as any).addressError && !(form as any).userError) {
} else {
toastMessage = 'Saved successfully';
closeModal();
}
} else if (!(form as any).addressError && !(form as any).userError) {
@@ -86,8 +86,8 @@
userFormOpen = false;
addressError = '';
userError = '';
userEmail = (record.user as Record<string, unknown>)?.email as string ?? '';
userName = (record.user as Record<string, unknown>)?.name as string ?? '';
userEmail = ((record.user as Record<string, unknown>)?.email as string) ?? '';
userName = ((record.user as Record<string, unknown>)?.name as string) ?? '';
modalOpen = true;
}
@@ -162,11 +162,16 @@
</button>
</div>
<p class="mb-1 text-sm text-gray-600 dark:text-gray-400">
{user?.name as string ?? ''} ({user?.email as string ?? ''})
{(user?.name as string) ?? ''} ({(user?.email as string) ?? ''})
</p>
{#if userFormOpen}
<form method="POST" action="?/updateUser" use:enhance class="mt-3 rounded border border-gray-200 p-3 dark:border-gray-700">
<form
method="POST"
action="?/updateUser"
use:enhance
class="mt-3 rounded border border-gray-200 p-3 dark:border-gray-700"
>
<input type="hidden" name="id" value={String(user?.id ?? '')} />
{#if userError}
<div class="mb-2 border border-red-300 bg-red-50 px-3 py-1.5 text-sm text-red-700">
@@ -175,7 +180,10 @@
{/if}
<div class="flex flex-col gap-2">
<div>
<label for="user-name" class="mb-0.5 block text-xs font-medium text-gray-600 dark:text-gray-400">
<label
for="user-name"
class="mb-0.5 block text-xs font-medium text-gray-600 dark:text-gray-400"
>
Name
</label>
<input
@@ -187,7 +195,10 @@
/>
</div>
<div>
<label for="user-email" class="mb-0.5 block text-xs font-medium text-gray-600 dark:text-gray-400">
<label
for="user-email"
class="mb-0.5 block text-xs font-medium text-gray-600 dark:text-gray-400"
>
Email
</label>
<input
@@ -227,7 +238,12 @@
</div>
{#if addressFormOpen}
<form method="POST" action="?/upsertAddress" use:enhance class="mb-3 rounded border border-gray-200 p-3 dark:border-gray-700">
<form
method="POST"
action="?/upsertAddress"
use:enhance
class="mb-3 rounded border border-gray-200 p-3 dark:border-gray-700"
>
<input type="hidden" name="profileId" value={String(editingRecord.id)} />
{#if addressError}
<div class="mb-2 border border-red-300 bg-red-50 px-3 py-1.5 text-sm text-red-700">
@@ -236,21 +252,10 @@
{/if}
<div class="grid grid-cols-2 gap-2">
<div>
<label for="addr-type" class="mb-0.5 block text-xs font-medium text-gray-600 dark:text-gray-400">
Type
</label>
<select
id="addr-type"
name="type"
class="w-full border border-gray-300 px-2 py-1 text-sm dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100"
<label
for="addr-street"
class="mb-0.5 block text-xs font-medium text-gray-600 dark:text-gray-400"
>
<option value="Mailing">Mailing</option>
<option value="Billing">Billing</option>
<option value="Shipping">Shipping</option>
</select>
</div>
<div>
<label for="addr-street" class="mb-0.5 block text-xs font-medium text-gray-600 dark:text-gray-400">
Street
</label>
<input
@@ -262,7 +267,10 @@
/>
</div>
<div>
<label for="addr-city" class="mb-0.5 block text-xs font-medium text-gray-600 dark:text-gray-400">
<label
for="addr-city"
class="mb-0.5 block text-xs font-medium text-gray-600 dark:text-gray-400"
>
City
</label>
<input
@@ -274,7 +282,10 @@
/>
</div>
<div>
<label for="addr-state" class="mb-0.5 block text-xs font-medium text-gray-600 dark:text-gray-400">
<label
for="addr-state"
class="mb-0.5 block text-xs font-medium text-gray-600 dark:text-gray-400"
>
State
</label>
<input
@@ -286,7 +297,10 @@
/>
</div>
<div>
<label for="addr-postal" class="mb-0.5 block text-xs font-medium text-gray-600 dark:text-gray-400">
<label
for="addr-postal"
class="mb-0.5 block text-xs font-medium text-gray-600 dark:text-gray-400"
>
Postal Code
</label>
<input
@@ -298,7 +312,10 @@
/>
</div>
<div>
<label for="addr-country" class="mb-0.5 block text-xs font-medium text-gray-600 dark:text-gray-400">
<label
for="addr-country"
class="mb-0.5 block text-xs font-medium text-gray-600 dark:text-gray-400"
>
Country
</label>
<input
@@ -310,10 +327,20 @@
/>
</div>
</div>
<label class="mt-2 flex items-center gap-2 text-xs text-gray-600 dark:text-gray-400">
<input type="checkbox" name="isDefault" class="h-3.5 w-3.5" />
Set as default for this type
</label>
<div class="mt-2 flex flex-wrap gap-4">
<label class="flex items-center gap-1.5 text-xs text-gray-600 dark:text-gray-400">
<input type="checkbox" name="isDefaultMailing" class="h-3.5 w-3.5" />
Default Mailing
</label>
<label class="flex items-center gap-1.5 text-xs text-gray-600 dark:text-gray-400">
<input type="checkbox" name="isDefaultBilling" class="h-3.5 w-3.5" />
Default Billing
</label>
<label class="flex items-center gap-1.5 text-xs text-gray-600 dark:text-gray-400">
<input type="checkbox" name="isDefaultShipping" class="h-3.5 w-3.5" />
Default Shipping
</label>
</div>
<div class="mt-2 flex justify-end">
<button
type="submit"
@@ -329,25 +356,30 @@
<table class="w-full text-sm">
<thead>
<tr class="border-b border-gray-200 dark:border-gray-700">
<th class="px-2 py-1 text-left text-xs font-medium text-gray-500 uppercase">Type</th>
<th class="px-2 py-1 text-left text-xs font-medium text-gray-500 uppercase">Street</th>
<th class="px-2 py-1 text-left text-xs font-medium text-gray-500 uppercase">Street</th
>
<th class="px-2 py-1 text-left text-xs font-medium text-gray-500 uppercase">City</th>
<th class="px-2 py-1 text-left text-xs font-medium text-gray-500 uppercase">State</th>
<th class="px-2 py-1 text-left text-xs font-medium text-gray-500 uppercase">Zip</th>
<th class="px-2 py-1 text-left text-xs font-medium text-gray-500 uppercase">Country</th>
<th class="px-2 py-1 text-left text-xs font-medium text-gray-500 uppercase">Default</th>
<th class="px-2 py-1 text-left text-xs font-medium text-gray-500 uppercase"
>Country</th
>
<th class="px-2 py-1 text-center text-xs font-medium text-gray-500 uppercase">M</th>
<th class="px-2 py-1 text-center text-xs font-medium text-gray-500 uppercase">B</th>
<th class="px-2 py-1 text-center text-xs font-medium text-gray-500 uppercase">S</th>
</tr>
</thead>
<tbody>
{#each addresses as addr}
<tr class="border-b border-gray-100 dark:border-gray-800">
<td class="px-2 py-1 text-xs">{addr.type as string}</td>
<td class="px-2 py-1">{addr.street as string}</td>
<td class="px-2 py-1">{addr.city as string}</td>
<td class="px-2 py-1">{addr.state as string}</td>
<td class="px-2 py-1">{addr.postalCode as string}</td>
<td class="px-2 py-1">{addr.country as string}</td>
<td class="px-2 py-1">{addr.isDefault ? 'Yes' : ''}</td>
<td class="px-2 py-1 text-center text-xs">{addr.isDefaultMailing ? '✓' : ''}</td>
<td class="px-2 py-1 text-center text-xs">{addr.isDefaultBilling ? '✓' : ''}</td>
<td class="px-2 py-1 text-center text-xs">{addr.isDefaultShipping ? '✓' : ''}</td>
</tr>
{/each}
</tbody>
@@ -0,0 +1,18 @@
import type { PageServerLoad } from './$types';
import { getModelConfig } from '$lib/svelte-admin/registry';
import { loadRecords, upsertRecord, deleteRecord } from '$lib/svelte-admin/crud';
const config = getModelConfig('solfeggio-frequencies')!;
export const load: PageServerLoad = async ({ locals }) => {
return loadRecords(locals, config);
};
export const actions = {
upsert: async ({ request, locals }) => {
return upsertRecord(request, config, locals);
},
delete: async ({ request, locals }) => {
return deleteRecord(request, config, locals);
}
};
@@ -0,0 +1,8 @@
<script lang="ts">
import type { PageProps } from './$types';
import AdminPage from '$lib/components/admin/AdminPage.svelte';
let { data, form }: PageProps = $props();
</script>
<AdminPage modelSlug="solfeggio-frequencies" {data} {form} adminRoleId={data.adminUser?.roleId ?? 4} />
@@ -0,0 +1,18 @@
import type { PageServerLoad } from './$types';
import { getModelConfig } from '$lib/svelte-admin/registry';
import { loadRecords, upsertRecord, deleteRecord } from '$lib/svelte-admin/crud';
const config = getModelConfig('subscription-levels')!;
export const load: PageServerLoad = async ({ locals }) => {
return loadRecords(locals, config);
};
export const actions = {
upsert: async ({ request, locals }) => {
return upsertRecord(request, config, locals);
},
delete: async ({ request, locals }) => {
return deleteRecord(request, config, locals);
}
};
@@ -0,0 +1,8 @@
<script lang="ts">
import type { PageProps } from './$types';
import AdminPage from '$lib/components/admin/AdminPage.svelte';
let { data, form }: PageProps = $props();
</script>
<AdminPage modelSlug="subscription-levels" {data} {form} adminRoleId={data.adminUser?.roleId ?? 4} />
@@ -0,0 +1,18 @@
import type { PageServerLoad } from './$types';
import { getModelConfig } from '$lib/svelte-admin/registry';
import { loadRecords, upsertRecord, deleteRecord } from '$lib/svelte-admin/crud';
const config = getModelConfig('user-roles')!;
export const load: PageServerLoad = async ({ locals }) => {
return loadRecords(locals, config);
};
export const actions = {
upsert: async ({ request, locals }) => {
return upsertRecord(request, config, locals);
},
delete: async ({ request, locals }) => {
return deleteRecord(request, config, locals);
}
};
@@ -0,0 +1,8 @@
<script lang="ts">
import type { PageProps } from './$types';
import AdminPage from '$lib/components/admin/AdminPage.svelte';
let { data, form }: PageProps = $props();
</script>
<AdminPage modelSlug="user-roles" {data} {form} adminRoleId={data.adminUser?.roleId ?? 4} />
@@ -0,0 +1,18 @@
import type { PageServerLoad, Actions } from './$types';
import { getModelConfig } from '$lib/svelte-admin/registry';
import { loadRecords, upsertRecord, deleteRecord } from '$lib/svelte-admin/crud';
const config = getModelConfig('users')!;
export const load: PageServerLoad = async ({ locals }) => {
return loadRecords(locals, config);
};
export const actions: Actions = {
upsert: async ({ request, locals }) => {
return upsertRecord(request, config, locals);
},
delete: async ({ request, locals }) => {
return deleteRecord(request, config, locals);
}
};
@@ -0,0 +1,8 @@
<script lang="ts">
import type { PageProps } from './$types';
import AdminPage from '$lib/components/admin/AdminPage.svelte';
let { data, form }: PageProps = $props();
</script>
<AdminPage modelSlug="users" {data} {form} adminRoleId={data.adminUser?.roleId ?? 4} />
+3 -1
View File
@@ -25,7 +25,9 @@ describe('(protected) layout server load', () => {
it('throws 403 when locals.user is undefined', async () => {
findUnique.mockResolvedValue(null);
const err = await Promise.resolve(load({ locals: createLocals() } as any)).catch((e: unknown) => e);
const err = await Promise.resolve(load({ locals: createLocals() } as any)).catch(
(e: unknown) => e
);
expect(isHttpError(err, 403)).toBe(true);
});