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:
@@ -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 —
|
||||
{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 —
|
||||
{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 }
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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 };
|
||||
};
|
||||
@@ -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} />
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
|
||||
@@ -6,8 +6,11 @@
|
||||
<Window>
|
||||
{#if $page.status === 403}
|
||||
<h1 class="dark:text-gray-100">Not Authorized</h1>
|
||||
<p class="dark:text-gray-300">
|
||||
You don't have permission to view this page. <a href="/" class="dark:text-gray-400 dark:hover:text-gray-200">Go home</a>.
|
||||
</p>
|
||||
<p class="dark:text-gray-300">
|
||||
You don't have permission to view this page. <a
|
||||
href="/"
|
||||
class="dark:text-gray-400 dark:hover:text-gray-200">Go home</a
|
||||
>.
|
||||
</p>
|
||||
{/if}
|
||||
</Window>
|
||||
|
||||
@@ -1,21 +1,21 @@
|
||||
import type { RequestHandler } from "./$types";
|
||||
import type { RequestHandler } from './$types';
|
||||
|
||||
export const POST: RequestHandler = async () => {
|
||||
// Request validation (400)
|
||||
// 1. Validate and extract information
|
||||
// Request validation (400)
|
||||
// 1. Validate and extract information
|
||||
|
||||
// Business rule validation (409)
|
||||
// 2. Check for existing entries
|
||||
// Business rule validation (409)
|
||||
// 2. Check for existing entries
|
||||
|
||||
// Resource creation (500 on unexpected failures)
|
||||
// 3. Create new entries
|
||||
// 5. Create license key
|
||||
// Resource creation (500 on unexpected failures)
|
||||
// 3. Create new entries
|
||||
// 5. Create license key
|
||||
|
||||
// External operations (502/503/504 or 500, depending on your API)
|
||||
// 4. Send profile setup email
|
||||
// 6. Send license key email
|
||||
// External operations (502/503/504 or 500, depending on your API)
|
||||
// 4. Send profile setup email
|
||||
// 6. Send license key email
|
||||
|
||||
// Success (200/201)
|
||||
// 7. Send response
|
||||
return new Response(JSON.stringify({ "msg": "success" }))
|
||||
}
|
||||
// Success (200/201)
|
||||
// 7. Send response
|
||||
return new Response(JSON.stringify({ msg: 'success' }));
|
||||
};
|
||||
|
||||
@@ -2,6 +2,6 @@ import type { RequestHandler } from './$types';
|
||||
import { prisma } from '$lib/db';
|
||||
|
||||
export const GET: RequestHandler = async () => {
|
||||
const categories = await prisma.proclamationCategory.findMany()
|
||||
return new Response(JSON.stringify(categories))
|
||||
}
|
||||
const categories = await prisma.proclamationCategory.findMany();
|
||||
return new Response(JSON.stringify(categories));
|
||||
};
|
||||
|
||||
@@ -2,6 +2,6 @@ import type { RequestHandler } from './$types';
|
||||
import { prisma } from '$lib/db';
|
||||
|
||||
export const GET: RequestHandler = async () => {
|
||||
const frequencies = await prisma.solfeggioFrequency.findMany()
|
||||
return new Response(JSON.stringify(frequencies))
|
||||
}
|
||||
const frequencies = await prisma.solfeggioFrequency.findMany();
|
||||
return new Response(JSON.stringify(frequencies));
|
||||
};
|
||||
|
||||
@@ -9,13 +9,17 @@
|
||||
<h1 class="mb-6 text-2xl font-semibold text-gray-900 dark:text-gray-100">Forgot Password</h1>
|
||||
|
||||
{#if form?.success}
|
||||
<div class="mb-4 rounded-lg border border-green-200 bg-green-50 p-4 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-4 text-green-700 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-300"
|
||||
>
|
||||
If an account with that email exists, we've sent a password reset link.
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if form?.error && !form?.success}
|
||||
<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}
|
||||
@@ -36,7 +40,10 @@
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<a href={resolve('/login')} class="mt-4 block text-sm text-blue-600 hover:text-blue-800 dark:text-gray-400 dark:hover:text-gray-200">
|
||||
<a
|
||||
href={resolve('/login')}
|
||||
class="mt-4 block text-sm text-blue-600 hover:text-blue-800 dark:text-gray-400 dark:hover:text-gray-200"
|
||||
>
|
||||
Back to Login
|
||||
</a>
|
||||
</div>
|
||||
|
||||
+65
-65
@@ -10,68 +10,68 @@
|
||||
/* Use var(--color-gray-N) so dark-mode inversion flows through */
|
||||
/* ------------------------------------------------------------------ */
|
||||
@theme {
|
||||
--color-blue-50: var(--color-gray-100);
|
||||
--color-blue-100: var(--color-gray-200);
|
||||
--color-blue-200: var(--color-gray-300);
|
||||
--color-blue-300: var(--color-gray-400);
|
||||
--color-blue-400: var(--color-gray-500);
|
||||
--color-blue-500: var(--color-gray-500);
|
||||
--color-blue-600: var(--color-gray-600);
|
||||
--color-blue-700: var(--color-gray-700);
|
||||
--color-blue-800: var(--color-gray-800);
|
||||
--color-green-50: var(--color-gray-100);
|
||||
--color-green-100: var(--color-gray-200);
|
||||
--color-green-200: var(--color-gray-300);
|
||||
--color-green-300: var(--color-gray-400);
|
||||
--color-green-400: var(--color-gray-500);
|
||||
--color-green-500: var(--color-gray-500);
|
||||
--color-green-600: var(--color-gray-600);
|
||||
--color-green-700: var(--color-gray-700);
|
||||
--color-green-800: var(--color-gray-800);
|
||||
--color-red-50: var(--color-gray-100);
|
||||
--color-red-100: var(--color-gray-200);
|
||||
--color-red-200: var(--color-gray-300);
|
||||
--color-red-300: var(--color-gray-400);
|
||||
--color-red-400: var(--color-gray-500);
|
||||
--color-red-500: var(--color-gray-500);
|
||||
--color-red-600: var(--color-gray-600);
|
||||
--color-red-700: var(--color-gray-700);
|
||||
--color-red-800: var(--color-gray-800);
|
||||
--color-purple-50: var(--color-gray-100);
|
||||
--color-purple-100: var(--color-gray-200);
|
||||
--color-purple-200: var(--color-gray-300);
|
||||
--color-purple-300: var(--color-gray-400);
|
||||
--color-purple-400: var(--color-gray-500);
|
||||
--color-purple-500: var(--color-gray-500);
|
||||
--color-purple-600: var(--color-gray-600);
|
||||
--color-purple-700: var(--color-gray-700);
|
||||
--color-purple-800: var(--color-gray-800);
|
||||
--color-purple-900: var(--color-gray-900);
|
||||
--color-slate-50: var(--color-gray-50);
|
||||
--color-slate-100: var(--color-gray-100);
|
||||
--color-slate-200: var(--color-gray-200);
|
||||
--color-slate-300: var(--color-gray-300);
|
||||
--color-slate-400: var(--color-gray-400);
|
||||
--color-slate-500: var(--color-gray-500);
|
||||
--color-slate-600: var(--color-gray-600);
|
||||
--color-slate-700: var(--color-gray-700);
|
||||
--color-slate-800: var(--color-gray-800);
|
||||
--color-slate-900: var(--color-gray-900);
|
||||
--color-blue-50: var(--color-gray-100);
|
||||
--color-blue-100: var(--color-gray-200);
|
||||
--color-blue-200: var(--color-gray-300);
|
||||
--color-blue-300: var(--color-gray-400);
|
||||
--color-blue-400: var(--color-gray-500);
|
||||
--color-blue-500: var(--color-gray-500);
|
||||
--color-blue-600: var(--color-gray-600);
|
||||
--color-blue-700: var(--color-gray-700);
|
||||
--color-blue-800: var(--color-gray-800);
|
||||
--color-green-50: var(--color-gray-100);
|
||||
--color-green-100: var(--color-gray-200);
|
||||
--color-green-200: var(--color-gray-300);
|
||||
--color-green-300: var(--color-gray-400);
|
||||
--color-green-400: var(--color-gray-500);
|
||||
--color-green-500: var(--color-gray-500);
|
||||
--color-green-600: var(--color-gray-600);
|
||||
--color-green-700: var(--color-gray-700);
|
||||
--color-green-800: var(--color-gray-800);
|
||||
--color-red-50: var(--color-gray-100);
|
||||
--color-red-100: var(--color-gray-200);
|
||||
--color-red-200: var(--color-gray-300);
|
||||
--color-red-300: var(--color-gray-400);
|
||||
--color-red-400: var(--color-gray-500);
|
||||
--color-red-500: var(--color-gray-500);
|
||||
--color-red-600: var(--color-gray-600);
|
||||
--color-red-700: var(--color-gray-700);
|
||||
--color-red-800: var(--color-gray-800);
|
||||
--color-purple-50: var(--color-gray-100);
|
||||
--color-purple-100: var(--color-gray-200);
|
||||
--color-purple-200: var(--color-gray-300);
|
||||
--color-purple-300: var(--color-gray-400);
|
||||
--color-purple-400: var(--color-gray-500);
|
||||
--color-purple-500: var(--color-gray-500);
|
||||
--color-purple-600: var(--color-gray-600);
|
||||
--color-purple-700: var(--color-gray-700);
|
||||
--color-purple-800: var(--color-gray-800);
|
||||
--color-purple-900: var(--color-gray-900);
|
||||
--color-slate-50: var(--color-gray-50);
|
||||
--color-slate-100: var(--color-gray-100);
|
||||
--color-slate-200: var(--color-gray-200);
|
||||
--color-slate-300: var(--color-gray-300);
|
||||
--color-slate-400: var(--color-gray-400);
|
||||
--color-slate-500: var(--color-gray-500);
|
||||
--color-slate-600: var(--color-gray-600);
|
||||
--color-slate-700: var(--color-gray-700);
|
||||
--color-slate-800: var(--color-gray-800);
|
||||
--color-slate-900: var(--color-gray-900);
|
||||
|
||||
--radius: 0px;
|
||||
--radius-sm: 0px;
|
||||
--radius-md: 0px;
|
||||
--radius-lg: 0px;
|
||||
--radius-xl: 0px;
|
||||
--radius-2xl: 0px;
|
||||
--radius-3xl: 0px;
|
||||
--radius: 0px;
|
||||
--radius-sm: 0px;
|
||||
--radius-md: 0px;
|
||||
--radius-lg: 0px;
|
||||
--radius-xl: 0px;
|
||||
--radius-2xl: 0px;
|
||||
--radius-3xl: 0px;
|
||||
|
||||
--shadow-sm: 0 0 #0000;
|
||||
--shadow: 0 0 #0000;
|
||||
--shadow-md: 0 0 #0000;
|
||||
--shadow-lg: 0 0 #0000;
|
||||
--shadow-xl: 0 0 #0000;
|
||||
--shadow-2xl: 0 0 #0000;
|
||||
--shadow-sm: 0 0 #0000;
|
||||
--shadow: 0 0 #0000;
|
||||
--shadow-md: 0 0 #0000;
|
||||
--shadow-lg: 0 0 #0000;
|
||||
--shadow-xl: 0 0 #0000;
|
||||
--shadow-2xl: 0 0 #0000;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
@@ -80,9 +80,9 @@
|
||||
.dark input,
|
||||
.dark select,
|
||||
.dark textarea {
|
||||
background-color: #1f2937;
|
||||
color: #f9fafb;
|
||||
border-color: #374151;
|
||||
background-color: #1f2937;
|
||||
color: #f9fafb;
|
||||
border-color: #374151;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
@@ -91,6 +91,6 @@
|
||||
*,
|
||||
::before,
|
||||
::after {
|
||||
transition: none !important;
|
||||
animation: none !important;
|
||||
transition: none !important;
|
||||
animation: none !important;
|
||||
}
|
||||
|
||||
@@ -37,20 +37,24 @@
|
||||
{#if error}
|
||||
<span class="text-red-600 dark:text-gray-300">{error}</span>
|
||||
{/if}
|
||||
<form
|
||||
onsubmit={handleSubmit}
|
||||
class="my-6 flex flex-col items-center justify-center"
|
||||
>
|
||||
<form onsubmit={handleSubmit} class="my-6 flex flex-col items-center justify-center">
|
||||
<p>Login</p>
|
||||
<input type="email" placeholder="Email Address" bind:value={email} required class="my-2" />
|
||||
<input type="password" placeholder="Password" bind:value={password} required class="my-2" />
|
||||
<button class="mt-4 rounded bg-blue-200 px-4 py-2 dark:bg-gray-700 dark:text-gray-100" type="submit">Sign In</button>
|
||||
<button
|
||||
class="mt-4 rounded bg-blue-200 px-4 py-2 dark:bg-gray-700 dark:text-gray-100"
|
||||
type="submit">Sign In</button
|
||||
>
|
||||
</form>
|
||||
<div class="mt-2 flex flex-col items-center gap-2">
|
||||
<a href="/forgot-password" class="text-sm text-blue-600 hover:text-blue-800 dark:text-gray-400 dark:hover:text-gray-200"
|
||||
<a
|
||||
href="/forgot-password"
|
||||
class="text-sm text-blue-600 hover:text-blue-800 dark:text-gray-400 dark:hover:text-gray-200"
|
||||
>Forgot Password?</a
|
||||
>
|
||||
<a href="/register" class="text-sm text-blue-600 hover:text-blue-800 dark:text-gray-400 dark:hover:text-gray-200"
|
||||
<a
|
||||
href="/register"
|
||||
class="text-sm text-blue-600 hover:text-blue-800 dark:text-gray-400 dark:hover:text-gray-200"
|
||||
>Don't have an account? Register</a
|
||||
>
|
||||
</div>
|
||||
|
||||
@@ -23,7 +23,9 @@
|
||||
{:else}
|
||||
<div class="flex flex-col items-start justify-center">
|
||||
{#if form?.error}
|
||||
<div class="mb-4 w-full 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 w-full 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}
|
||||
@@ -32,8 +34,16 @@
|
||||
<input type="text" name="lastName" placeholder="Last Name" required class="mb-2" />
|
||||
<input type="email" name="email" placeholder="Email Address" required class="mb-2" />
|
||||
<input type="password" name="password" placeholder="Password" required class="mb-2" />
|
||||
<input type="password" name="password2" placeholder="Verify Password" required class="mb-2" />
|
||||
<button type="submit" class="mx-auto rounded-full bg-blue-600 px-6 py-2 text-white dark:bg-gray-700 dark:text-gray-100"
|
||||
<input
|
||||
type="password"
|
||||
name="password2"
|
||||
placeholder="Verify Password"
|
||||
required
|
||||
class="mb-2"
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
class="mx-auto rounded-full bg-blue-600 px-6 py-2 text-white dark:bg-gray-700 dark:text-gray-100"
|
||||
>Submit</button
|
||||
>
|
||||
</form>
|
||||
|
||||
@@ -8,7 +8,9 @@
|
||||
<h1 class="mb-6 text-2xl font-semibold text-gray-900 dark:text-gray-100">Reset Password</h1>
|
||||
|
||||
{#if 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}
|
||||
|
||||
@@ -89,8 +89,7 @@ describe('reset-password page server action', () => {
|
||||
resetPassword.mockReset();
|
||||
});
|
||||
|
||||
const invokeDefault = (url: URL, request: Request) =>
|
||||
actions.default({ request, url } as any);
|
||||
const invokeDefault = (url: URL, request: Request) => actions.default({ request, url } as any);
|
||||
|
||||
it('returns fail(400) when token is missing', async () => {
|
||||
const url = createUrl('/reset-password');
|
||||
@@ -120,10 +119,7 @@ describe('reset-password page server action', () => {
|
||||
it('returns fail(400) when password is empty', async () => {
|
||||
const url = createUrl('/reset-password', { token: 'token-1' });
|
||||
|
||||
const result = await invokeDefault(
|
||||
url,
|
||||
createFormRequest({ password: '', password2: '' })
|
||||
);
|
||||
const result = await invokeDefault(url, createFormRequest({ password: '', password2: '' }));
|
||||
expect(isActionFailure(result)).toBe(true);
|
||||
if (isActionFailure(result)) {
|
||||
expect(result.data).toEqual({ error: 'Password must be at least 8 characters.' });
|
||||
@@ -181,10 +177,7 @@ describe('reset-password page server action', () => {
|
||||
const url = createUrl('/reset-password', { token: 'token-1' });
|
||||
|
||||
const err = await Promise.resolve(
|
||||
invokeDefault(
|
||||
url,
|
||||
createFormRequest({ password: 'longenough1', password2: 'longenough1' })
|
||||
)
|
||||
invokeDefault(url, createFormRequest({ password: 'longenough1', password2: 'longenough1' }))
|
||||
).catch((e: unknown) => e);
|
||||
expect(isRedirect(err)).toBe(true);
|
||||
if (isRedirect(err)) {
|
||||
|
||||
+11
-11
@@ -2,11 +2,11 @@
|
||||
* AUTHOR: Neil Clack
|
||||
* EMAIL: nclack@radiantwavetech.com
|
||||
* DATE: 07/06/2027
|
||||
*
|
||||
*
|
||||
* An example endpoint, temporarily enabled, to describe
|
||||
* how to send an email with the Resend API and our custom
|
||||
* how to send an email with the Resend API and our custom
|
||||
* sendMail function.
|
||||
*
|
||||
*
|
||||
*/
|
||||
|
||||
import { json } from '@sveltejs/kit';
|
||||
@@ -14,12 +14,12 @@ import { sendMail } from '$lib/server/resend';
|
||||
import type { RequestHandler } from './$types';
|
||||
|
||||
export const POST: RequestHandler = async ({ request }) => {
|
||||
const { to, subject, templateId, data } = await request.json();
|
||||
const { to, subject, templateId, data } = await request.json();
|
||||
|
||||
try {
|
||||
const result = await sendMail({ to, subject, templateId, data });
|
||||
return json(result);
|
||||
} catch (e) {
|
||||
return json({ error: (e as Error).message }, { status: 400 });
|
||||
}
|
||||
};
|
||||
try {
|
||||
const result = await sendMail({ to, subject, templateId, data });
|
||||
return json(result);
|
||||
} catch (e) {
|
||||
return json({ error: (e as Error).message }, { status: 400 });
|
||||
}
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user