Rebuild admin pages around DataTable/AdminModal; restructure categories/groups and v2 groups API

Remove the generic $lib/svelte-admin CRUD layer and AdminTable.svelte; each
admin route now owns its own form/modal logic. Add DataTable.svelte,
superform-errors.ts, and profile-specific modal/field components.

Flatten proclamation categories/groups and drop subscription levels; add the
v2 groups API; update v1/v2 endpoints, serializers, auth, and regenerated
Prisma client with the new migrations.

Document the overhaul plan in docs/ADMIN_OVERHAUL.md.
This commit is contained in:
NeilClack
2026-09-10 09:38:01 -05:00
parent f4e736c92d
commit 91d162c746
127 changed files with 7182 additions and 9785 deletions
@@ -8,20 +8,18 @@ export const load: PageServerLoad = async ({ locals }) => {
throw error(403, 'not authorized');
}
const user = await prisma.user.findUnique({
where: { id: locals.user.id },
include: {
profile: {
include: { addresses: true }
}
}
});
const user = await prisma.user.findUnique({ where: { id: locals.user.id } });
if (!user) {
throw error(403, 'not authorized');
}
return { user };
const profile = await prisma.profile.findFirst({
where: { userId: user.id },
include: { addresses: true }
});
return { user: { ...user, profile } };
};
export const actions: Actions = {
@@ -40,7 +38,7 @@ export const actions: Actions = {
}
try {
await prisma.profile.update({
await prisma.profile.updateMany({
where: { userId: locals.user.id },
data: { firstName, lastName, phoneNumber }
});
@@ -67,7 +65,7 @@ export const actions: Actions = {
return fail(400, { error: 'All address fields are required.', action: 'addAddress' });
}
const profile = await prisma.profile.findUnique({
const profile = await prisma.profile.findFirst({
where: { userId: locals.user.id },
select: { id: true }
});
@@ -23,67 +23,58 @@
}
</script>
<div class="flex h-full min-h-0 flex-col gap-8 p-6 dark:text-gray-100">
<h1 class="text-2xl font-semibold text-gray-900 dark:text-gray-100">Account</h1>
<div class="mx-auto w-full max-w-3xl">
<h1 class="text-2xl font-bold tracking-tight text-slate-900 sm:text-3xl">Account</h1>
<p class="mt-1 text-sm text-slate-500 sm:text-base">Manage your profile and addresses.</p>
<!-- Email (read-only) -->
<div>
<p class="text-sm font-medium text-gray-700 dark:text-gray-300">Email</p>
<p class="mt-1 text-gray-900 dark:text-gray-100">{data.user.email}</p>
<div class="mt-6 rounded-2xl border border-slate-200 bg-white p-5 shadow-sm sm:p-6">
<p class="text-xs tracking-wide text-slate-500 uppercase">Email</p>
<p class="mt-1 text-base break-words text-slate-900">{data.user.email}</p>
</div>
<!-- Profile Form -->
<section>
<h2 class="mb-4 text-lg font-medium text-gray-900 dark:text-gray-100">Profile</h2>
<section class="mt-6 rounded-2xl border border-slate-200 bg-white p-5 shadow-sm sm:p-6">
<h2 class="text-lg font-semibold text-slate-900">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="mt-4 rounded-lg bg-green-50 px-4 py-3 text-sm text-green-800" role="status">
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="mt-4 rounded-lg bg-red-50 px-4 py-3 text-sm text-red-700" role="alert">
{form.error}
</div>
{/if}
<form method="POST" action="?/updateProfile" class="flex max-w-lg flex-col gap-4">
<div class="grid grid-cols-2 gap-4">
<div>
<label for="firstName" class="block text-sm font-medium text-gray-700 dark:text-gray-300">
First Name
</label>
<form method="POST" action="?/updateProfile" class="mt-5 flex flex-col gap-4">
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div class="flex flex-col gap-1.5">
<label for="firstName" class="text-sm font-medium text-slate-700"> First Name </label>
<input
type="text"
id="firstName"
name="firstName"
value={data.user.profile?.firstName ?? ''}
required
class="mt-1 w-full rounded-lg border border-gray-300 px-3 py-2 shadow-sm focus:border-blue-500 focus:ring-1 focus:ring-blue-500 dark:border-gray-600"
class="h-11 w-full rounded-lg border-slate-300 px-3 text-sm text-slate-900 focus:border-indigo-500 focus:ring-indigo-500"
/>
</div>
<div>
<label for="lastName" class="block text-sm font-medium text-gray-700 dark:text-gray-300">
Last Name
</label>
<div class="flex flex-col gap-1.5">
<label for="lastName" class="text-sm font-medium text-slate-700"> Last Name </label>
<input
type="text"
id="lastName"
name="lastName"
value={data.user.profile?.lastName ?? ''}
required
class="mt-1 w-full rounded-lg border border-gray-300 px-3 py-2 shadow-sm focus:border-blue-500 focus:ring-1 focus:ring-blue-500 dark:border-gray-600"
class="h-11 w-full rounded-lg border-slate-300 px-3 text-sm text-slate-900 focus:border-indigo-500 focus:ring-indigo-500"
/>
</div>
</div>
<div>
<label for="phoneNumber" class="block text-sm font-medium text-gray-700 dark:text-gray-300">
Phone Number
</label>
<div class="flex flex-col gap-1.5">
<label for="phoneNumber" class="text-sm font-medium text-slate-700"> Phone Number </label>
<input
type="tel"
id="phoneNumber"
@@ -91,13 +82,13 @@
bind:value={phoneNumber}
oninput={handlePhoneInput}
required
class="mt-1 w-full max-w-xs rounded-lg border border-gray-300 px-3 py-2 shadow-sm focus:border-blue-500 focus:ring-1 focus:ring-blue-500 dark:border-gray-600"
class="h-11 w-full rounded-lg border-slate-300 px-3 text-sm text-slate-900 focus:border-indigo-500 focus:ring-indigo-500"
/>
</div>
<div>
<button
type="submit"
class="rounded-lg bg-blue-600 px-4 py-2 font-medium text-white transition-colors hover:bg-blue-700 dark:bg-gray-700 dark:text-gray-100 dark:hover:bg-gray-600"
class="flex h-11 w-full items-center justify-center rounded-lg bg-indigo-600 text-base font-medium text-white hover:bg-indigo-700 sm:w-auto sm:px-8"
>
Save Profile
</button>
@@ -106,138 +97,112 @@
</section>
<!-- Addresses -->
<section>
<div class="mb-4 flex items-center justify-between">
<h2 class="text-lg font-medium text-gray-900 dark:text-gray-100">Addresses</h2>
<section class="mt-6 rounded-2xl border border-slate-200 bg-white p-5 shadow-sm sm:p-6">
<div class="flex items-center justify-between gap-4">
<h2 class="text-lg font-semibold text-slate-900">Addresses</h2>
<button
type="button"
class="rounded-lg bg-green-600 px-3 py-1.5 text-sm font-medium text-white transition-colors hover:bg-green-700 dark:bg-gray-700 dark:text-gray-100 dark:hover:bg-gray-600"
onclick={() => (showAddressForm = !showAddressForm)}
class="rounded-lg border border-slate-300 bg-white px-4 py-2 text-sm font-medium text-slate-700 hover:bg-slate-50"
>
{showAddressForm ? 'Cancel' : 'Add Address'}
</button>
</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="mt-4 rounded-lg bg-green-50 px-4 py-3 text-sm text-green-800" role="status">
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="mt-4 rounded-lg bg-red-50 px-4 py-3 text-sm text-red-700" role="alert">
{form.error}
</div>
{/if}
{#if showAddressForm}
<form
method="POST"
action="?/addAddress"
class="mb-6 max-w-lg rounded-lg border border-gray-200 bg-gray-50 p-4 dark:border-gray-700 dark:bg-gray-800"
>
<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
>
<form method="POST" action="?/addAddress" class="mt-5 flex flex-col gap-4">
<div class="flex flex-col gap-1.5">
<label for="street" class="text-sm font-medium text-slate-700">Street</label>
<input
type="text"
id="street"
name="street"
required
class="h-11 w-full rounded-lg border-slate-300 px-3 text-sm text-slate-900 focus:border-indigo-500 focus:ring-indigo-500"
/>
</div>
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div class="flex flex-col gap-1.5">
<label for="city" class="text-sm font-medium text-slate-700">City</label>
<input
type="text"
id="street"
name="street"
id="city"
name="city"
required
class="mt-1 w-full rounded-lg border border-gray-300 px-3 py-2 shadow-sm focus:border-blue-500 focus:ring-1 focus:ring-blue-500 dark:border-gray-600"
class="h-11 w-full rounded-lg border-slate-300 px-3 text-sm text-slate-900 focus:border-indigo-500 focus:ring-indigo-500"
/>
</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
>
<input
type="text"
id="city"
name="city"
required
class="mt-1 w-full rounded-lg border border-gray-300 px-3 py-2 shadow-sm focus:border-blue-500 focus:ring-1 focus:ring-blue-500 dark:border-gray-600"
/>
</div>
<div>
<label for="state" class="block text-sm font-medium text-gray-700 dark:text-gray-300"
>State</label
>
<input
type="text"
id="state"
name="state"
required
class="mt-1 w-full rounded-lg border border-gray-300 px-3 py-2 shadow-sm focus:border-blue-500 focus:ring-1 focus:ring-blue-500 dark:border-gray-600"
/>
</div>
<div class="flex flex-col gap-1.5">
<label for="state" class="text-sm font-medium text-slate-700">State</label>
<input
type="text"
id="state"
name="state"
required
class="h-11 w-full rounded-lg border-slate-300 px-3 text-sm text-slate-900 focus:border-indigo-500 focus:ring-indigo-500"
/>
</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"
>
Postal Code
</label>
<input
type="text"
id="postalCode"
name="postalCode"
required
class="mt-1 w-full rounded-lg border border-gray-300 px-3 py-2 shadow-sm focus:border-blue-500 focus:ring-1 focus:ring-blue-500 dark:border-gray-600"
/>
</div>
<div>
<label
for="country"
class="block text-sm font-medium text-gray-700 dark:text-gray-300">Country</label
>
<input
type="text"
id="country"
name="country"
required
value="US"
class="mt-1 w-full rounded-lg border border-gray-300 px-3 py-2 shadow-sm focus:border-blue-500 focus:ring-1 focus:ring-blue-500 dark:border-gray-600"
/>
</div>
</div>
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div class="flex flex-col gap-1.5">
<label for="postalCode" class="text-sm font-medium text-slate-700"> Postal Code </label>
<input
type="text"
id="postalCode"
name="postalCode"
required
class="h-11 w-full rounded-lg border-slate-300 px-3 text-sm text-slate-900 focus:border-indigo-500 focus:ring-indigo-500"
/>
</div>
<div>
<button
type="submit"
class="rounded-lg bg-blue-600 px-4 py-2 font-medium text-white transition-colors hover:bg-blue-700 dark:bg-gray-700 dark:text-gray-100 dark:hover:bg-gray-600"
>
Save Address
</button>
<div class="flex flex-col gap-1.5">
<label for="country" class="text-sm font-medium text-slate-700">Country</label>
<input
type="text"
id="country"
name="country"
required
value="US"
class="h-11 w-full rounded-lg border-slate-300 px-3 text-sm text-slate-900 focus:border-indigo-500 focus:ring-indigo-500"
/>
</div>
</div>
<div>
<button
type="submit"
class="flex h-11 w-full items-center justify-center rounded-lg bg-indigo-600 text-base font-medium text-white hover:bg-indigo-700 sm:w-auto sm:px-8"
>
Save Address
</button>
</div>
</form>
{/if}
{#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"
>
<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">
<div class="mt-5 grid grid-cols-1 gap-4 sm:grid-cols-2">
{#each data.user.profile.addresses as address (address.id)}
<div class="rounded-lg border border-slate-200 bg-slate-50 p-4">
<p class="text-sm font-medium text-slate-900">{address.street}</p>
<p class="mt-1 text-sm text-slate-600">
{address.city}, {address.state}
{address.postalCode}
</p>
<p class="text-sm text-gray-600 dark:text-gray-400">{address.country}</p>
<p class="text-sm text-slate-600">{address.country}</p>
</div>
{/each}
</div>
{:else if !showAddressForm}
<p class="text-sm text-gray-500 dark:text-gray-400">No addresses on file.</p>
<p class="mt-5 text-sm text-slate-500">No addresses on file.</p>
{/if}
</section>
</div>
@@ -10,7 +10,7 @@ import {
vi.mock('$lib/db', () => ({
prisma: {
user: { findUnique: vi.fn() },
profile: { update: vi.fn(), findUnique: vi.fn() },
profile: { findFirst: vi.fn(), updateMany: vi.fn() },
address: { create: vi.fn() }
}
}));
@@ -19,8 +19,8 @@ import { prisma } from '$lib/db';
import { load, actions } from './+page.server';
const userFindUnique = prisma.user.findUnique as ReturnType<typeof vi.fn>;
const profileUpdate = prisma.profile.update as ReturnType<typeof vi.fn>;
const profileFindUnique = prisma.profile.findUnique as ReturnType<typeof vi.fn>;
const profileFindFirst = prisma.profile.findFirst as ReturnType<typeof vi.fn>;
const profileUpdateMany = prisma.profile.updateMany as ReturnType<typeof vi.fn>;
const addressCreate = prisma.address.create as ReturnType<typeof vi.fn>;
const invokeAction = (name: string, request: Request, locals: App.Locals) =>
@@ -29,8 +29,8 @@ const invokeAction = (name: string, request: Request, locals: App.Locals) =>
describe('(protected)/account page server', () => {
beforeEach(() => {
userFindUnique.mockReset();
profileUpdate.mockReset();
profileFindUnique.mockReset();
profileFindFirst.mockReset();
profileUpdateMany.mockReset();
addressCreate.mockReset();
});
@@ -51,24 +51,26 @@ describe('(protected)/account page server', () => {
});
it('returns user with profile and addresses', async () => {
const user = mockUserWithProfileAndAddresses();
const { profile, ...user } = mockUserWithProfileAndAddresses() as any;
userFindUnique.mockResolvedValue(user);
profileFindFirst.mockResolvedValue(profile);
const locals = createLocals(mockUser() as any);
const result = (await load({ locals } as any)) as any;
expect(result.user).toEqual(user);
expect(result.user).toEqual({ ...user, profile });
expect(result.user.profile.addresses).toHaveLength(1);
});
it('includes addresses in the query', async () => {
userFindUnique.mockResolvedValue(mockUserWithProfileAndAddresses());
it('loads the profile with addresses by user id', async () => {
userFindUnique.mockResolvedValue(mockUser());
profileFindFirst.mockResolvedValue({ id: 'profile-1' });
const locals = createLocals(mockUser() as any);
await load({ locals } as any);
expect(userFindUnique).toHaveBeenCalledWith({
where: { id: 'user-1' },
include: { profile: { include: { addresses: true } } }
expect(profileFindFirst).toHaveBeenCalledWith({
where: { userId: 'user-1' },
include: { addresses: true }
});
});
});
@@ -125,7 +127,7 @@ describe('(protected)/account page server', () => {
});
it('updates profile with server-formatted phone number on success', async () => {
profileUpdate.mockResolvedValue({});
profileUpdateMany.mockResolvedValue({});
const request = createFormRequest({
firstName: 'Jane',
@@ -136,14 +138,14 @@ describe('(protected)/account page server', () => {
const locals = createLocals(mockUser() as any);
const result = await invokeAction('updateProfile', request, locals);
expect(result).toEqual({ success: true, action: 'updateProfile' });
expect(profileUpdate).toHaveBeenCalledWith({
expect(profileUpdateMany).toHaveBeenCalledWith({
where: { userId: 'user-1' },
data: { firstName: 'Jane', lastName: 'Smith', phoneNumber: '8175551234' }
});
});
it('returns fail(500) when prisma update throws', async () => {
profileUpdate.mockRejectedValue(new Error('db error'));
profileUpdateMany.mockRejectedValue(new Error('db error'));
const request = createFormRequest({
firstName: 'Jane',
@@ -190,7 +192,7 @@ describe('(protected)/account page server', () => {
});
it('throws 400 when profile is not found', async () => {
profileFindUnique.mockResolvedValue(null);
profileFindFirst.mockResolvedValue(null);
const request = createFormRequest({
street: '123 Main St',
@@ -208,7 +210,7 @@ describe('(protected)/account page server', () => {
});
it('creates address linked to profile on success', async () => {
profileFindUnique.mockResolvedValue({ id: 'profile-1' });
profileFindFirst.mockResolvedValue({ id: 'profile-1' });
addressCreate.mockResolvedValue({});
const request = createFormRequest({
@@ -235,7 +237,7 @@ describe('(protected)/account page server', () => {
});
it('returns fail(500) when prisma create throws', async () => {
profileFindUnique.mockResolvedValue({ id: 'profile-1' });
profileFindFirst.mockResolvedValue({ id: 'profile-1' });
addressCreate.mockRejectedValue(new Error('db error'));
const request = createFormRequest({
@@ -42,41 +42,74 @@
</script>
<Window>
<form method="POST" class="border p-6 dark:border-gray-700 dark:bg-gray-900 dark:text-gray-100">
<p class="mx-auto my-4 text-center italic">Please finish setting up your profile</p>
<form method="POST" class="flex flex-col items-center gap-5">
<h1 class="text-center text-2xl font-bold text-slate-900">
Please finish setting up your profile
</h1>
{#if data.user?.image}
<img src={data.user?.image} class="mx-auto w-[8rem] rounded-full" alt="Profile" />
<img
src={data.user?.image}
alt="Profile"
class="h-24 w-24 rounded-full object-cover shadow-sm sm:h-28 sm:w-28"
/>
{:else}
<div class="mx-auto aspect-square w-[8rem] rounded-full bg-slate-900"></div>
<div
class="flex h-24 w-24 items-center justify-center rounded-full bg-indigo-100 text-3xl font-semibold text-indigo-700 sm:h-28 sm:w-28"
>
{data.user?.name?.charAt(0)?.toUpperCase() ?? '?'}
</div>
{/if}
<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="tel"
name="phoneNumber"
placeholder="Phone Number"
class="my-2"
bind:value={phoneNumber}
oninput={handlePhoneInput}
/>
<p class="text-base text-slate-900">{data.user?.email}</p>
<div class="flex w-full flex-col gap-4">
<input type="hidden" name="userId" value={data.user?.id} />
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div class="flex flex-col gap-1.5">
<label for="firstName" class="text-sm font-medium text-slate-700"> First Name </label>
<input
id="firstName"
type="text"
name="firstName"
placeholder="First Name"
bind:value={firstName}
class="h-11 w-full rounded-lg border-slate-300 px-3 text-sm text-slate-900 focus:border-indigo-500 focus:ring-indigo-500"
/>
</div>
<div class="flex flex-col gap-1.5">
<label for="lastName" class="text-sm font-medium text-slate-700"> Last Name </label>
<input
id="lastName"
type="text"
name="lastName"
placeholder="Last Name"
bind:value={lastName}
class="h-11 w-full rounded-lg border-slate-300 px-3 text-sm text-slate-900 focus:border-indigo-500 focus:ring-indigo-500"
/>
</div>
</div>
<div class="flex flex-col gap-1.5">
<label for="phoneNumber" class="text-sm font-medium text-slate-700"> Phone Number </label>
<input
id="phoneNumber"
type="tel"
name="phoneNumber"
placeholder="(555) 555-5555"
bind:value={phoneNumber}
oninput={handlePhoneInput}
class="h-11 w-full rounded-lg border-slate-300 px-3 text-sm text-slate-900 focus:border-indigo-500 focus:ring-indigo-500"
/>
</div>
</div>
<button type="submit" class="bg-blue-300 p-2 px-4 dark:bg-gray-700 dark:text-gray-100"
>Submit</button
<button
type="submit"
class="mt-1 flex h-11 w-full items-center justify-center rounded-lg bg-indigo-600 text-base font-medium text-white hover:bg-indigo-700"
>
Submit
</button>
</form>
</Window>
@@ -7,19 +7,16 @@ export const load: PageServerLoad = async ({ locals }) => {
throw error(403, 'not authorized');
}
const profile = await prisma.profile.findUnique({
const profile = await prisma.profile.findFirst({
where: { userId: locals.user.id },
select: { firstName: true }
});
const [keyStats, proclamationCount] = await Promise.all([
prisma.licenseKey.groupBy({
by: ['active'],
where: { userId: locals.user.id },
_count: true
}),
prisma.proclamation.count()
]);
const keyStats = await prisma.licenseKey.groupBy({
by: ['active'],
where: { userId: locals.user.id },
_count: true
});
const activeKeys = keyStats.find((s) => s.active)?._count ?? 0;
const totalKeys = keyStats.reduce((sum, s) => sum + s._count, 0);
@@ -27,7 +24,6 @@ export const load: PageServerLoad = async ({ locals }) => {
return {
firstName: profile?.firstName ?? 'User',
activeKeys,
totalKeys,
proclamationCount
totalKeys
};
};
@@ -1,42 +1,33 @@
<script lang="ts">
import { resolve } from '$app/paths';
import type { PageProps } from './$types';
let { data }: PageProps = $props();
</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">
<div>
<h1 class="text-2xl font-bold tracking-tight text-slate-900 sm:text-3xl">
Welcome, {data.firstName}!
</h1>
<p class="mt-1 text-sm text-slate-500 sm:text-base">Here's an overview of your account.</p>
<div class="grid grid-cols-1 gap-4 sm:grid-cols-3">
<div class="mt-6 grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
<a
href="/license-keys"
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"
href={resolve('/license-keys')}
class="group flex flex-col gap-1 rounded-2xl border border-slate-200 bg-white p-5 shadow-sm transition hover:border-indigo-200 hover:shadow"
>
<p class="text-sm font-medium text-gray-500 dark:text-gray-400">Active License Keys</p>
<p class="mt-2 text-3xl font-bold text-gray-900 dark:text-gray-100">{data.activeKeys}</p>
<p class="mt-1 text-sm text-gray-400 dark:text-gray-500">{data.totalKeys} total</p>
<span class="text-sm font-medium text-slate-600">Active License Keys</span>
<span class="text-3xl font-bold text-indigo-600">{data.activeKeys}</span>
<span class="text-sm text-slate-500">{data.totalKeys} total</span>
</a>
<a
href="/admin/proclamations"
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"
href={resolve('/account')}
class="group flex flex-col gap-1 rounded-2xl border border-slate-200 bg-white p-5 shadow-sm transition hover:border-indigo-200 hover:shadow"
>
<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-1 text-sm text-gray-400 dark:text-gray-500">Browse all</p>
</a>
<a
href="/account"
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">Account</p>
<p class="mt-2 text-lg font-semibold text-gray-900 dark:text-gray-100">Manage Profile</p>
<p class="mt-1 text-sm text-gray-400 dark:text-gray-500">Edit your details</p>
<span class="text-sm font-medium text-slate-600">Account</span>
<span class="text-2xl font-semibold text-slate-900">Manage Profile</span>
<span class="text-sm text-slate-500">Edit your details</span>
</a>
</div>
</div>
@@ -4,24 +4,21 @@ import { createLocals, mockUser } from '$lib/test-utils';
vi.mock('$lib/db', () => ({
prisma: {
profile: { findUnique: vi.fn() },
licenseKey: { groupBy: vi.fn() },
proclamation: { count: vi.fn() }
profile: { findFirst: vi.fn() },
licenseKey: { groupBy: vi.fn() }
}
}));
import { prisma } from '$lib/db';
import { load } from './+page.server';
const profileFindUnique = prisma.profile.findUnique as ReturnType<typeof vi.fn>;
const profileFindFirst = prisma.profile.findFirst as ReturnType<typeof vi.fn>;
const licenseKeyGroupBy = prisma.licenseKey.groupBy as ReturnType<typeof vi.fn>;
const proclamationCount = prisma.proclamation.count as ReturnType<typeof vi.fn>;
describe('(protected)/dashboard page server load', () => {
beforeEach(() => {
profileFindUnique.mockReset();
profileFindFirst.mockReset();
licenseKeyGroupBy.mockReset();
proclamationCount.mockReset();
});
it('throws 403 when locals.user is undefined', async () => {
@@ -32,9 +29,8 @@ describe('(protected)/dashboard page server load', () => {
});
it('returns firstName from profile', async () => {
profileFindUnique.mockResolvedValue({ firstName: 'Jane' });
profileFindFirst.mockResolvedValue({ firstName: 'Jane' });
licenseKeyGroupBy.mockResolvedValue([]);
proclamationCount.mockResolvedValue(0);
const locals = createLocals(mockUser() as any);
const result = (await load({ locals } as any)) as any;
@@ -42,9 +38,8 @@ describe('(protected)/dashboard page server load', () => {
});
it('defaults firstName to "User" when profile is null', async () => {
profileFindUnique.mockResolvedValue(null);
profileFindFirst.mockResolvedValue(null);
licenseKeyGroupBy.mockResolvedValue([]);
proclamationCount.mockResolvedValue(0);
const locals = createLocals(mockUser() as any);
const result = (await load({ locals } as any)) as any;
@@ -52,12 +47,11 @@ describe('(protected)/dashboard page server load', () => {
});
it('computes activeKeys and totalKeys from groupBy results', async () => {
profileFindUnique.mockResolvedValue({ firstName: 'Jane' });
profileFindFirst.mockResolvedValue({ firstName: 'Jane' });
licenseKeyGroupBy.mockResolvedValue([
{ active: true, _count: 3 },
{ active: false, _count: 2 }
]);
proclamationCount.mockResolvedValue(10);
const locals = createLocals(mockUser() as any);
const result = (await load({ locals } as any)) as any;
@@ -66,23 +60,12 @@ describe('(protected)/dashboard page server load', () => {
});
it('returns 0 for activeKeys when none are active', async () => {
profileFindUnique.mockResolvedValue({ firstName: 'Jane' });
profileFindFirst.mockResolvedValue({ firstName: 'Jane' });
licenseKeyGroupBy.mockResolvedValue([{ active: false, _count: 4 }]);
proclamationCount.mockResolvedValue(0);
const locals = createLocals(mockUser() as any);
const result = (await load({ locals } as any)) as any;
expect(result.activeKeys).toBe(0);
expect(result.totalKeys).toBe(4);
});
it('returns proclamationCount', async () => {
profileFindUnique.mockResolvedValue({ firstName: 'Jane' });
licenseKeyGroupBy.mockResolvedValue([]);
proclamationCount.mockResolvedValue(42);
const locals = createLocals(mockUser() as any);
const result = (await load({ locals } as any)) as any;
expect(result.proclamationCount).toBe(42);
});
});
@@ -4,47 +4,35 @@
let { data }: PageProps = $props();
</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">License Keys</h1>
<div>
<div class="flex items-center justify-between">
<div>
<h1 class="text-2xl font-bold tracking-tight text-slate-900 sm:text-3xl">License Keys</h1>
<p class="mt-1 text-sm text-slate-500">Your active and inactive license keys.</p>
</div>
</div>
{#if data.licenseKeys.length > 0}
<div class="overflow-x-auto rounded-lg border border-gray-200 dark:border-gray-700">
<table class="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
<thead class="bg-gray-50 dark:bg-gray-800">
<div class="mt-6 overflow-hidden rounded-2xl border border-slate-200 bg-white shadow-sm">
<!-- Desktop / tablet: table -->
<table class="hidden w-full text-left text-sm sm:table">
<thead
class="border-b border-slate-200 bg-slate-50 text-xs tracking-wide text-slate-500 uppercase"
>
<tr>
<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 tracking-wider text-gray-500 uppercase dark:text-gray-400"
>
Level
</th>
<th
class="px-6 py-3 text-left text-xs font-medium tracking-wider text-gray-500 uppercase dark:text-gray-400"
>
Status
</th>
<th class="px-6 py-3 font-semibold">Key</th>
<th class="px-6 py-3 font-semibold">Status</th>
</tr>
</thead>
<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="px-6 py-4 font-mono text-sm whitespace-nowrap text-gray-900 dark:text-gray-100"
>
{key.key}
</td>
<td class="px-6 py-4 text-sm whitespace-nowrap text-gray-700 dark:text-gray-300">
{key.level}
</td>
<td class="px-6 py-4 text-sm whitespace-nowrap">
<tbody class="divide-y divide-slate-100">
{#each data.licenseKeys as key (key.key)}
<tr class="hover:bg-slate-50">
<td class="px-6 py-4 font-mono text-slate-800">{key.key}</td>
<td class="px-6 py-4">
<span
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'}"
class="inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium {key.active
? 'bg-green-100 text-green-700'
: 'bg-slate-100 text-slate-600'}"
>
{key.active ? 'Active' : 'Inactive'}
</span>
@@ -53,10 +41,26 @@
{/each}
</tbody>
</table>
<!-- Mobile: card list -->
<ul class="divide-y divide-slate-100 sm:hidden">
{#each data.licenseKeys as key (key.key)}
<li class="flex items-center justify-between gap-4 px-4 py-4">
<span class="min-w-0 font-mono text-sm break-all text-slate-800">{key.key}</span>
<span
class="shrink-0 rounded-full px-2.5 py-0.5 text-xs font-medium {key.active
? 'bg-green-100 text-green-700'
: 'bg-slate-100 text-slate-600'}"
>
{key.active ? 'Active' : 'Inactive'}
</span>
</li>
{/each}
</ul>
</div>
{:else}
<div class="flex flex-1 items-center justify-center">
<p class="text-lg text-gray-500 dark:text-gray-400">You don't have any license keys yet.</p>
<div class="mt-6 rounded-2xl border border-dashed border-slate-300 bg-white p-8 text-center">
<p class="text-sm text-slate-600">You don't have any license keys yet.</p>
</div>
{/if}
</div>
+2 -2
View File
@@ -9,12 +9,12 @@ export const load: LayoutServerLoad = async ({ locals, url }) => {
const user = await prisma.user.findUnique({
where: { id: locals.user.id },
select: { id: true, roleId: true, profile: { select: { id: true } } }
select: { id: true, roleId: true, profiles: { select: { id: true } } }
});
if (!user) {
throw error(403, 'not authorized');
}
if (!user.profile && !url.pathname.startsWith('/account/setup')) {
if (user.profiles.length === 0 && !url.pathname.startsWith('/account/setup')) {
// Skip redirect when already on /account/setup to avoid redirect loop
throw redirect(303, '/account/setup');
}
+5 -68
View File
@@ -1,74 +1,11 @@
<script lang="ts">
import { page } from '$app/state';
import { canAccessAdmin } from '$lib/svelte-admin/roles';
import { getAllModelConfigs } from '$lib/svelte-admin/registry';
import type { LayoutData } from './$types';
import type { Snippet } from 'svelte';
let { children, data } = $props<{ children: any; data: LayoutData }>();
const isAdmin = $derived(canAccessAdmin(data.userRoleId));
const adminModels = getAllModelConfigs();
const navLinks = [
{ href: '/dashboard', label: 'Dashboard' },
{ href: '/account', label: 'Account' },
{ href: '/proclamations', label: 'Proclamations' },
{ href: '/license-keys', label: 'License Keys' }
];
function isActive(href: string): boolean {
return page.url.pathname === href || page.url.pathname.startsWith(href + '/');
}
let { children }: { children: Snippet } = $props();
</script>
<div class="flex min-h-0 flex-1">
<!-- Sidebar -->
<nav class="mt-12 mr-12 flex flex-col gap-1">
{#each navLinks as link}
<a
href={link.href}
class="rounded px-3 py-1.5 text-sm font-medium transition-colors {isActive(link.href)
? 'bg-blue-50 text-blue-700 dark:bg-gray-800 dark:text-gray-100'
: 'text-gray-600 hover:bg-gray-50 hover:text-gray-900 dark:text-gray-400 dark:hover:bg-gray-800 dark:hover:text-gray-100'}"
>
{link.label}
</a>
{/each}
{#if isAdmin}
<div class="mt-4 flex flex-col gap-1">
<a
href="/admin"
class="rounded px-3 py-1.5 text-sm font-medium transition-colors {isActive('/admin')
? 'bg-blue-50 text-blue-700 dark:bg-gray-800 dark:text-gray-100'
: 'text-gray-700 hover:bg-gray-50 hover:text-gray-900 dark:text-gray-400 dark:hover:bg-gray-800 dark:hover:text-gray-100'}"
>
Admin Panel
</a>
<div class="ml-3 flex flex-col gap-0.5">
{#each adminModels as model}
<a
href="/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'}"
>
{model.displayName}
</a>
{/each}
<a
href="/admin/api-keys"
class="rounded px-3 py-1 text-sm transition-colors {isActive('/admin/api-keys')
? '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'}"
>
API Keys
</a>
</div>
</div>
{/if}
</nav>
<!-- Page content -->
<div class="min-h-0 flex-1 overflow-auto">
<div class="min-h-screen bg-slate-50 text-slate-900">
<main class="mx-auto w-full max-w-6xl px-4 py-6 sm:px-6 sm:py-8">
{@render children()}
</div>
</main>
</div>
+4 -9
View File
@@ -4,22 +4,17 @@
</script>
<Window>
<div class="flex flex-col items-center gap-4 p-6 text-center">
<h1 class="text-xl font-bold text-gray-900 dark:text-gray-100">
<div>
<h1>
Admin Error ({$page.status})
</h1>
<p class="max-w-md text-sm text-gray-600 dark:text-gray-400">
<p>
{#if $page.error?.message}
{$page.error.message}
{:else}
An unexpected error occurred while loading this admin page.
{/if}
</p>
<a
href="/admin"
class="mt-2 border border-gray-200 px-4 py-2 text-sm font-medium text-gray-900 hover:bg-gray-50 dark:border-gray-700 dark:text-gray-100 dark:hover:bg-gray-800"
>
Back to Admin
</a>
<a href="/admin"> Back to Admin </a>
</div>
</Window>
@@ -1,7 +1,6 @@
import type { LayoutServerLoad } from './$types';
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
@@ -17,7 +16,7 @@ export const load: LayoutServerLoad = async ({ locals }) => {
select: { id: true, name: true, roleId: true, role: { select: { name: true } } }
});
if (!user || !canAccessAdmin(user.roleId)) {
if (!user || !user.roleId || user.roleId > 3) {
console.log(
`Attempted to access /admin via ${user?.id} : ${user?.name} with a role of ${user?.role}`
);
+3 -6
View File
@@ -1,8 +1,5 @@
import type { PageServerLoad } from './$types';
import { getSerializableModelConfigs } from '$lib/svelte-admin/registry';
import { redirect } from '@sveltejs/kit';
export const load: PageServerLoad = async ({ locals }) => {
const viewerRoleId = (locals.user as { roleId?: number } | null)?.roleId ?? 4;
const models = getSerializableModelConfigs(viewerRoleId, ['license-keys']);
return { models };
export const load = () => {
redirect(307, '/admin/profiles');
};
-21
View File
@@ -1,21 +0,0 @@
<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>
@@ -2,11 +2,12 @@ import type { PageServerLoad } from './$types';
import { error } from '@sveltejs/kit';
import { prisma } from '$lib/db';
import { rotateApiKey } from '$lib/server/api-key';
import { ROLES } from '$lib/svelte-admin/roles';
const SUPER = 1;
export const load: PageServerLoad = async ({ locals, parent }) => {
const { adminUser } = await parent();
if (adminUser.roleId > ROLES.SUPER) {
if (adminUser.roleId > SUPER) {
throw error(403, 'Only Super admins can manage API keys');
}
@@ -49,7 +50,7 @@ async function viewerRoleId(locals: App.Locals): Promise<number> {
export const actions = {
rotate: async ({ locals }) => {
if ((await viewerRoleId(locals)) > ROLES.SUPER) {
if ((await viewerRoleId(locals)) > SUPER) {
return { success: false, error: 'Not authorized' };
}
@@ -62,7 +63,7 @@ export const actions = {
};
},
rotateEmergency: async ({ locals }) => {
if ((await viewerRoleId(locals)) > ROLES.SUPER) {
if ((await viewerRoleId(locals)) > SUPER) {
return { success: false, error: 'Not authorized' };
}
@@ -8,7 +8,6 @@
let mode = $state<'normal' | 'emergency' | null>(null);
let expiryDate = $state<string | null>(null);
let toastMessage = $state('');
let toastType = $state<'success' | 'error'>('success');
function handleRotate(emergency: boolean) {
return ({ cancel }: any) => {
@@ -29,7 +28,6 @@
}
} else {
toastMessage = 'Rotation failed';
toastType = 'error';
}
};
};
@@ -38,7 +36,6 @@
function copyKey() {
navigator.clipboard.writeText(newKey);
toastMessage = 'Copied to clipboard';
toastType = 'success';
}
function dismissKey() {
@@ -48,44 +45,45 @@
}
</script>
<div class="p-6">
<h1 class="mb-4 text-xl font-bold text-gray-900 dark:text-gray-100">API Key Management</h1>
<div class="mx-auto w-full">
<div class="mb-4 flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<h2 class="text-xl font-semibold text-slate-900">API Key Management</h2>
{#if data.activeKey}
<span
class="inline-flex items-center gap-1.5 rounded-full bg-green-100 px-3 py-1 text-xs font-medium text-green-700"
>
Active
</span>
{/if}
</div>
{#if newKey}
<div
class="mb-6 border border-amber-300 bg-amber-50 p-4 dark:border-amber-700 dark:bg-amber-900/30"
>
<p class="mb-2 text-sm font-semibold text-amber-800 dark:text-amber-300">
<div class="mb-4 rounded-lg border border-slate-200 bg-white p-4 shadow-sm">
<p class="text-sm font-medium text-slate-900">
{mode === 'emergency' ? 'Emergency Rotation Complete' : 'New API Key Generated'}
</p>
<p class="mb-1 text-xs text-amber-700 dark:text-amber-400">
This key will not be shown again. Copy it now.
</p>
<div class="mb-3">
<code
class="rounded border border-amber-300 bg-white px-3 py-2 font-mono text-sm break-all dark:border-amber-600 dark:bg-gray-900 dark:text-gray-100"
>
{newKey}
</code>
<p class="mt-1 text-sm text-slate-600">This key will not be shown again. Copy it now.</p>
<div class="mt-3 rounded-md bg-slate-100 px-3 py-2">
<code class="text-sm break-all text-slate-800">{newKey}</code>
</div>
<div class="flex gap-2">
<div class="mt-3 flex gap-2">
<button
type="button"
onclick={copyKey}
class="border border-amber-300 bg-white px-3 py-1.5 text-sm font-medium hover:bg-amber-100 dark:border-amber-600 dark:bg-gray-800 dark:text-amber-300 dark:hover:bg-amber-900/50"
class="inline-flex h-10 items-center justify-center gap-1.5 rounded-md bg-indigo-600 px-4 text-sm font-medium text-white shadow-sm transition hover:bg-indigo-700"
>
Copy to Clipboard
</button>
<button
type="button"
onclick={dismissKey}
class="border border-gray-300 px-3 py-1.5 text-sm dark:border-gray-600 dark:text-gray-300"
class="inline-flex h-10 items-center justify-center rounded-md border border-slate-300 bg-white px-4 text-sm font-medium text-slate-700 shadow-sm transition hover:bg-slate-50"
>
Dismiss
</button>
</div>
{#if mode === 'normal' && expiryDate}
<p class="mt-3 text-xs text-amber-700 dark:text-amber-400">
<p class="mt-3 text-xs text-slate-500">
The previous key will remain valid until {new Date(expiryDate).toLocaleString()}
to give in-field devices time to update.
</p>
@@ -95,22 +93,18 @@
{#if toastMessage}
<div
class="mb-4 border px-4 py-2 text-sm {toastType === 'success'
? 'border-green-300 bg-green-50 text-green-700'
: 'border-red-300 bg-red-50 text-red-700'}"
class="mb-4 rounded-md border border-green-200 bg-green-50 px-3 py-2 text-sm text-green-700"
>
{toastMessage}
</div>
{/if}
<div class="mb-6 rounded 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">Current Key Status</h2>
<div class="mb-4 rounded-lg border border-slate-200 bg-white p-4 shadow-sm">
<h3 class="text-base font-semibold text-slate-900">Current Key Status</h3>
{#if data.activeKey}
<div class="space-y-1 text-sm text-gray-600 dark:text-gray-400">
<div class="mt-3 space-y-1 text-sm text-slate-700">
<div>
Prefix: <code class="font-mono font-medium text-gray-900 dark:text-gray-100"
>{data.activeKey.prefix}</code
>
Prefix: <code class="rounded bg-slate-100 px-1.5 py-0.5">{data.activeKey.prefix}</code>
</div>
<div>Created: {new Date(data.activeKey.createdAt).toLocaleString()}</div>
{#if data.activeKey.expiresAt}
@@ -118,20 +112,20 @@
{/if}
</div>
{:else}
<p class="text-sm text-gray-500 dark:text-gray-400">No active API key. Generate one below.</p>
<p class="mt-2 text-sm text-slate-600">No active API key. Generate one below.</p>
{/if}
{#if data.totalRotated > 0}
<p class="mt-2 text-xs text-gray-400">
<p class="mt-2 text-sm text-slate-600">
{data.totalRotated} previous key(s) have been rotated.
</p>
{/if}
</div>
<div class="flex flex-wrap gap-3">
<div class="flex flex-col gap-3 sm:flex-row">
<form method="POST" action="?/rotate" use:enhance={handleRotate(false)}>
<button
type="submit"
class="border border-gray-300 bg-gray-100 px-4 py-2 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"
class="inline-flex h-10 items-center justify-center gap-1.5 rounded-md bg-indigo-600 px-4 text-sm font-medium text-white shadow-sm transition hover:bg-indigo-700"
>
Generate New API Key
</button>
@@ -139,7 +133,7 @@
<form method="POST" action="?/rotateEmergency" use:enhance={handleRotate(true)}>
<button
type="submit"
class="border border-red-300 bg-red-50 px-4 py-2 text-sm font-medium text-red-700 hover:bg-red-100 dark:border-red-700 dark:bg-red-900/30 dark:text-red-400 dark:hover:bg-red-900/50"
class="inline-flex h-10 items-center justify-center gap-1.5 rounded-md bg-red-600 px-4 text-sm font-medium text-white shadow-sm transition hover:bg-red-700"
>
Emergency Rotation
</button>
@@ -1,27 +1,19 @@
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')!;
function generateLicenseKey(): string {
const hex = crypto.randomUUID().replace(/-/g, '').slice(0, 16).toUpperCase();
return `${hex.slice(0, 4)}-${hex.slice(4, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}`;
}
export const load: PageServerLoad = async ({ locals }) => {
const records = await loadRecords(locals, config);
const subscriptionLevels = await prisma.subscriptionLevel.findMany({
select: { id: true, name: true },
orderBy: { name: 'asc' }
export const load: PageServerLoad = async () => {
const records = await prisma.licenseKey.findMany({
include: { user: true },
orderBy: { key: 'asc' }
});
return {
...records,
subscriptionLevels: subscriptionLevels.map((s: { id: number; name: string }) => ({
label: s.name,
value: s.name
}))
};
return { records: records as unknown as Record<string, unknown>[] };
};
export const actions = {
@@ -29,41 +21,35 @@ export const actions = {
if (!locals.user) throw error(401, 'Unauthorized');
const formData = await request.formData();
const email = formData.get('email')?.toString();
const level = formData.get('level')?.toString();
const email = formData.get('email')?.toString()?.trim().toLowerCase();
if (!email) {
return { success: false, error: 'Email is required' };
}
if (!level) {
return { success: false, error: 'Subscription level is required' };
return { success: false, action: 'createKey', error: 'Email is required' };
}
const user = await prisma.user.findUnique({ where: { email } });
if (!user) {
return { success: false, error: 'No user found with that email' };
}
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 key = generateLicenseKey();
await prisma.licenseKey.create({
data: {
key,
active: true,
level,
userId: user.id
userId: user?.id,
email: user ? undefined : email
}
});
try {
const profile = await prisma.profile.findUnique({ where: { userId: user.id } });
const profile = await prisma.profile.findFirst({
where: user ? { userId: user.id } : { email }
});
await sendMail({
to: email,
subject: 'Your Rhemawave License Key',
subject: 'Your Radiant Wave License Key',
templateId: 'license-key-delivery',
data: {
firstName: profile?.firstName ?? user.name ?? 'Valued Customer',
firstName: profile?.firstName ?? user?.name ?? 'Valued Customer',
license_key: key
}
});
@@ -71,32 +57,29 @@ export const actions = {
console.error('Failed to send license key email:', e);
}
return { success: true, error: undefined };
return { success: true, action: 'createKey' };
},
replace: async ({ request, locals }) => {
if (!locals.user) throw error(401, 'Unauthorized');
const formData = await request.formData();
const keyId = formData.get('id')?.toString();
if (!keyId) return { success: false, error: 'Missing key id' };
if (!keyId) return { success: false, action: 'replace', error: 'Missing key id' };
const existing = await prisma.licenseKey.findUnique({ where: { id: keyId } });
if (!existing) return { success: false, error: 'License key not found' };
if (!existing) return { success: false, action: 'replace', error: 'License key not found' };
await prisma.licenseKey.delete({ where: { id: keyId } });
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)}`;
await prisma.licenseKey.create({
data: {
key,
key: generateLicenseKey(),
active: true,
level: existing.level,
userId: existing.userId
userId: existing.userId,
email: existing.email
}
});
return { success: true, error: undefined };
return { success: true, action: 'replace' };
}
};
@@ -1,28 +1,90 @@
<script lang="ts">
import type { PageProps } from './$types';
import { enhance } from '$app/forms';
import AdminTable from '$lib/components/admin/AdminTable.svelte';
import type { PageProps } from './$types';
import DataTable, { type DataTableColumn } from '$lib/components/DataTable.svelte';
import AdminModal from '$lib/components/admin/AdminModal.svelte';
import Toast from '$lib/components/admin/Toast.svelte';
import { getModelConfig } from '$lib/svelte-admin/registry';
import LicenseKeyForm from './LicenseKeyForm.svelte';
let { data }: PageProps = $props();
type ActionResult = {
success?: boolean;
action?: string;
error?: string;
} | null;
const config = getModelConfig('license-keys')!;
let { data, form }: PageProps = $props();
const columns: DataTableColumn[] = [
{ label: 'Key', value: (r) => r.key },
{
label: 'User',
value: (r) => (r.user as Record<string, unknown> | undefined)?.name ?? ''
},
{
label: 'Email',
value: (r) => (r.user as Record<string, unknown> | undefined)?.email ?? r.email ?? ''
},
{ label: 'Active', value: (r) => (r.active ? 'Yes' : 'No') }
];
let createOpen = $state(false);
let replaceOpen = $state(false);
let replaceRecord = $state<Record<string, unknown> | null>(null);
let toastMessage = $state('');
let toastType = $state<'success' | 'error'>('success');
let email = $state('');
let createError = $state('');
let createSuccess = $state('');
let replaceError = $state('');
let replaceSuccess = $state('');
let lastCreateForm = $state<ActionResult>(null);
let lastReplaceForm = $state<ActionResult>(null);
$effect(() => {
if (!form) return;
if (form.action === 'createKey') {
if (form === lastCreateForm) return;
lastCreateForm = form;
if (form.success) {
email = '';
createError = '';
createSuccess = 'License key created and emailed';
setTimeout(() => {
createOpen = false;
createSuccess = '';
}, 1200);
} else {
createSuccess = '';
createError = form.error ?? '';
}
} else if (form.action === 'replace') {
if (form === lastReplaceForm) return;
lastReplaceForm = form;
if (form.success) {
replaceError = '';
replaceSuccess = 'License key replaced';
setTimeout(() => {
replaceOpen = false;
replaceSuccess = '';
}, 1200);
} else {
replaceSuccess = '';
replaceError = form.error ?? '';
}
}
});
function openCreate() {
lastCreateForm = null;
createError = '';
createSuccess = '';
createOpen = true;
}
function openReplace(record: Record<string, unknown>) {
replaceRecord = record;
lastReplaceForm = null;
replaceError = '';
replaceSuccess = '';
replaceOpen = true;
}
@@ -34,98 +96,111 @@
replaceOpen = false;
replaceRecord = null;
}
function replaceEnhance() {
return async ({ result, update }: any) => {
if (result.type === 'success' && result.status === 200) {
closeReplace();
toastMessage = 'License key replaced';
toastType = 'success';
} else {
toastMessage = 'Failed to replace license key';
toastType = 'error';
}
await update();
};
}
</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={openCreate}
class="border border-gray-300 px-3 py-1.5 text-sm font-medium dark:border-gray-600 dark:text-gray-300"
>
New License Key +
</button>
</div>
<AdminTable
<div>
<DataTable
title="License Keys"
{columns}
records={data.records}
listFields={config.listFields}
searchFields={config.searchFields}
fieldTypes={config.fieldTypes}
singular={config.displayNameSingular}
newLabel="New License Key"
searchPlaceholder="Search license keys..."
copyableFields={['key']}
onRowClick={openReplace}
onNew={openCreate}
/>
</div>
<AdminModal title="Create License Key" open={createOpen} onClose={closeCreate}>
<LicenseKeyForm subscriptionLevels={data.subscriptionLevels} onSuccess={closeCreate} />
{#if createSuccess}
<div
class="mb-4 rounded-md border border-green-200 bg-green-50 px-3 py-2 text-sm text-green-700"
>
{createSuccess}
</div>
{/if}
{#if createError}
<div class="mb-4 rounded-md border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600">
{createError}
</div>
{/if}
<form method="POST" action="?/createKey" use:enhance class="flex flex-col gap-4">
<div>
<label for="field-email" class="mb-1 block text-sm font-medium text-slate-700">Email</label>
<input
id="field-email"
type="email"
name="email"
required
bind:value={email}
placeholder="user@example.com"
class="w-full rounded-md border border-slate-300 bg-white px-3 py-2 text-sm text-slate-900 shadow-sm transition focus:border-indigo-500 focus:outline-none"
/>
<p class="mt-1 text-sm text-slate-500">
If no account exists for this email yet, the key will be attached when they register.
</p>
</div>
<div class="flex justify-end gap-3">
<button
type="button"
onclick={closeCreate}
class="rounded-md border border-slate-300 bg-white px-4 py-2 text-sm font-medium text-slate-700 transition hover:bg-slate-50"
>
Cancel
</button>
<button
type="submit"
class="rounded-md bg-indigo-600 px-4 py-2 text-sm font-medium text-white transition hover:bg-indigo-700"
>
Create &amp; Email Key
</button>
</div>
</form>
</AdminModal>
<AdminModal title="Replace License Key" open={replaceOpen} onClose={closeReplace}>
{#if replaceSuccess}
<div
class="mb-4 rounded-md border border-green-200 bg-green-50 px-3 py-2 text-sm text-green-700"
>
{replaceSuccess}
</div>
{/if}
{#if replaceError}
<div class="mb-4 rounded-md border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600">
{replaceError}
</div>
{/if}
{#if replaceRecord}
<div class="flex flex-col gap-4">
<p class="text-sm text-gray-700 dark:text-gray-300">
<p class="text-sm text-slate-700">
Generate a new key to replace <strong>{String(replaceRecord.key)}</strong>?
</p>
<div
class="rounded border border-gray-200 bg-gray-50 p-3 text-sm dark:border-gray-700 dark:bg-gray-800"
>
<div class="mb-1 text-gray-500 dark:text-gray-400">
User: <span class="font-medium text-gray-700 dark:text-gray-300"
>{String(replaceRecord['user.name'] ?? '')}</span
>
</div>
<div class="text-gray-500 dark:text-gray-400">
Level: <span class="font-medium text-gray-700 dark:text-gray-300"
>{String(replaceRecord.level ?? '')}</span
>
</div>
</div>
<p class="text-xs text-gray-500 dark:text-gray-400">
The existing key will be deleted and a new one with the same subscription level will be
created and emailed.
<p class="text-sm text-slate-500">
The existing key will be deleted and a new one will be created.
</p>
<form
method="POST"
action="?/replace"
use:enhance={replaceEnhance}
class="flex justify-end gap-2 pt-2"
>
<form method="POST" action="?/replace" use:enhance>
<input type="hidden" name="id" value={String(replaceRecord.id)} />
<button
type="button"
onclick={closeReplace}
class="border border-gray-300 px-3 py-1.5 text-sm dark:border-gray-600 dark:text-gray-300"
>
Cancel
</button>
<button
type="submit"
class="border border-amber-300 bg-amber-50 px-4 py-1.5 text-sm font-medium hover:bg-amber-100 dark:border-amber-700 dark:bg-amber-900/30 dark:text-amber-300 dark:hover:bg-amber-900/50"
>
Generate Replacement
</button>
<div class="flex justify-end gap-3">
<button
type="button"
onclick={closeReplace}
class="rounded-md border border-slate-300 bg-white px-4 py-2 text-sm font-medium text-slate-700 transition hover:bg-slate-50"
>
Cancel
</button>
<button
type="submit"
class="rounded-md bg-red-600 px-4 py-2 text-sm font-medium text-white transition hover:bg-red-700"
>
Generate Replacement
</button>
</div>
</form>
</div>
{/if}
</AdminModal>
{#if toastMessage}
<Toast message={toastMessage} type={toastType} />
{/if}
@@ -1,91 +0,0 @@
<script lang="ts">
import { enhance } from '$app/forms';
let {
subscriptionLevels = [] as { label: string; value: string }[],
onSuccess
}: {
subscriptionLevels?: { label: string; value: string }[];
onSuccess?: () => void;
} = $props();
let email = $state('');
let level = $state('');
let formError = $state('');
function handleEnhance() {
return async ({ result, update }: any) => {
if (result.type === 'success' && result.status === 200) {
if (result.data?.error) {
formError = result.data.error;
} else {
email = '';
level = '';
formError = '';
await update();
onSuccess?.();
}
} else {
await update();
}
};
}
</script>
<form method="POST" action="?/createKey" use:enhance={handleEnhance}>
{#if formError}
<div class="mb-4 border border-red-300 bg-red-50 px-4 py-2 text-sm text-red-700">
{formError}
</div>
{/if}
<div class="flex flex-col gap-4">
<div>
<label
for="field-email"
class="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300"
>
User Email
</label>
<input
type="email"
id="field-email"
name="email"
required
bind:value={email}
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>
<div>
<label
for="field-level"
class="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300"
>
Subscription Level
</label>
<select
name="level"
id="field-level"
required
onchange={(e) => (level = (e.target as HTMLSelectElement).value)}
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"
>
<option value="" disabled selected={!level}>-- Select --</option>
{#each subscriptionLevels as opt (opt.value)}
<option value={opt.value} selected={opt.value === level}>{opt.label}</option>
{/each}
</select>
</div>
<div class="flex justify-end gap-2 pt-2">
<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>
@@ -8,38 +8,44 @@ const { sendMail } = vi.hoisted(() => ({
vi.mock('$lib/server/resend', () => ({ sendMail }));
vi.mock('$lib/svelte-admin/registry', () => ({
getModelConfig: vi.fn(() => ({ slug: 'license-keys', listFields: [], searchFields: [] }))
}));
vi.mock('$lib/svelte-admin/crud', () => ({
loadRecords: vi.fn().mockResolvedValue({ records: [], relationOptionsData: {} })
}));
vi.mock('$lib/db', () => ({
prisma: {
user: { findUnique: vi.fn() },
profile: { findUnique: vi.fn() },
licenseKey: { create: vi.fn() },
subscriptionLevel: { findMany: vi.fn() }
profile: { findFirst: vi.fn() },
licenseKey: { findMany: vi.fn(), findUnique: vi.fn(), create: vi.fn(), delete: vi.fn() }
}
}));
import { prisma } from '$lib/db';
import { actions } from './+page.server';
import { load, actions } from './+page.server';
const mockFindUnique = prisma.user.findUnique as ReturnType<typeof vi.fn>;
const mockProfileFindUnique = prisma.profile.findUnique as ReturnType<typeof vi.fn>;
const mockProfileFindFirst = prisma.profile.findFirst as ReturnType<typeof vi.fn>;
const mockLicenseKeyCreate = prisma.licenseKey.create as ReturnType<typeof vi.fn>;
const mockLicenseKeyFindUnique = prisma.licenseKey.findUnique as ReturnType<typeof vi.fn>;
const mockLicenseKeyDelete = prisma.licenseKey.delete as ReturnType<typeof vi.fn>;
describe('admin license-keys createKey action', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('returns records via load', async () => {
const records = [{ id: 'k1', key: 'AAAA-BBBB-CCCC-DDDD' }];
(prisma.licenseKey.findMany as ReturnType<typeof vi.fn>).mockResolvedValue(records);
const result = await load({} as any);
expect(prisma.licenseKey.findMany).toHaveBeenCalledWith({
include: { user: true },
orderBy: { key: 'asc' }
});
expect(result).toEqual({ records });
});
it('rejects unauthenticated requests', async () => {
const locals = createLocals();
const req = createFormRequest({ email: 'jane@example.com', level: 'standard' });
const req = createFormRequest({ email: 'jane@example.com' });
const err = await actions.createKey({ request: req, locals } as any).catch((e) => e);
expect(isHttpError(err, 401)).toBe(true);
@@ -47,27 +53,36 @@ describe('admin license-keys createKey action', () => {
it('returns error when email is missing', async () => {
const locals = createLocals(mockUser() as any);
const req = createFormRequest({ level: 'standard' });
const req = createFormRequest({});
const result = await actions.createKey({ request: req, locals } as any);
expect(result).toEqual({ success: false, error: 'Email is required' });
expect(result).toEqual({ success: false, action: 'createKey', error: 'Email is required' });
});
it('returns error when level is missing', async () => {
const locals = createLocals(mockUser() as any);
const req = createFormRequest({ email: 'jane@example.com' });
const result = await actions.createKey({ request: req, locals } as any);
expect(result).toEqual({ success: false, error: 'Subscription level is required' });
});
it('returns error when user is not found', async () => {
it('creates a key with only an email when no user exists yet', async () => {
mockFindUnique.mockResolvedValue(null);
mockProfileFindFirst.mockResolvedValue(null);
mockLicenseKeyCreate.mockResolvedValue({});
sendMail.mockResolvedValue({ id: 'email-1' });
const locals = createLocals(mockUser() as any);
const req = createFormRequest({ email: 'nobody@example.com', level: 'standard' });
const req = createFormRequest({ email: 'nobody@example.com' });
const result = await actions.createKey({ request: req, locals } as any);
expect(result).toEqual({ success: false, error: 'No user found with that email' });
expect(result).toEqual({ success: true, action: 'createKey' });
expect(mockLicenseKeyCreate).toHaveBeenCalledOnce();
const createCall = mockLicenseKeyCreate.mock.calls[0][0];
expect(createCall.data.active).toBe(true);
expect(createCall.data.userId).toBeUndefined();
expect(createCall.data.email).toBe('nobody@example.com');
expect(sendMail).toHaveBeenCalledOnce();
const mailCall = sendMail.mock.calls[0][0];
expect(mailCall.to).toBe('nobody@example.com');
expect(mailCall.templateId).toBe('license-key-delivery');
expect(mailCall.data.firstName).toBe('Valued Customer');
});
it('creates a license key and sends email with license_key variable', async () => {
@@ -75,22 +90,21 @@ describe('admin license-keys createKey action', () => {
const profile = { firstName: 'Jane' };
mockFindUnique.mockResolvedValue(user);
mockProfileFindUnique.mockResolvedValue(profile);
mockProfileFindFirst.mockResolvedValue(profile);
mockLicenseKeyCreate.mockResolvedValue({});
sendMail.mockResolvedValue({ id: 'email-1' });
const locals = createLocals(user as any);
const req = createFormRequest({ email: user.email, level: 'standard' });
const req = createFormRequest({ email: user.email });
const result = await actions.createKey({ request: req, locals } as any);
expect(result).toEqual({ success: true, error: undefined });
expect(result).toEqual({ success: true, action: 'createKey' });
expect(mockLicenseKeyCreate).toHaveBeenCalledOnce();
const createCall = mockLicenseKeyCreate.mock.calls[0][0];
expect(createCall.data.key).toMatch(/^[A-Z0-9]{4}-[A-Z0-9]{4}-[A-Z0-9]{4}-[A-Z0-9]{4}$/);
expect(createCall.data.active).toBe(true);
expect(createCall.data.level).toBe('standard');
expect(createCall.data.userId).toBe('user-1');
expect(sendMail).toHaveBeenCalledOnce();
@@ -106,12 +120,12 @@ describe('admin license-keys createKey action', () => {
const user = mockUser();
mockFindUnique.mockResolvedValue(user);
mockProfileFindUnique.mockResolvedValue(null);
mockProfileFindFirst.mockResolvedValue(null);
mockLicenseKeyCreate.mockResolvedValue({});
sendMail.mockResolvedValue({ id: 'email-1' });
const locals = createLocals(user as any);
const req = createFormRequest({ email: user.email, level: 'premium' });
const req = createFormRequest({ email: user.email });
await actions.createKey({ request: req, locals } as any);
@@ -125,19 +139,76 @@ describe('admin license-keys createKey action', () => {
const user = mockUser();
mockFindUnique.mockResolvedValue(user);
mockProfileFindUnique.mockResolvedValue({ firstName: 'Jane' });
mockProfileFindFirst.mockResolvedValue({ firstName: 'Jane' });
mockLicenseKeyCreate.mockResolvedValue({});
sendMail.mockRejectedValue(new Error('Resend API down'));
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
const locals = createLocals(user as any);
const req = createFormRequest({ email: user.email, level: 'standard' });
const req = createFormRequest({ email: user.email });
const result = await actions.createKey({ request: req, locals } as any);
expect(result).toEqual({ success: true, error: undefined });
expect(result).toEqual({ success: true, action: 'createKey' });
expect(mockLicenseKeyCreate).toHaveBeenCalledOnce();
consoleSpy.mockRestore();
});
});
describe('admin license-keys replace action', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('rejects unauthenticated requests', async () => {
const locals = createLocals();
const req = createFormRequest({ id: 'k1' });
const err = await actions.replace({ request: req, locals } as any).catch((e) => e);
expect(isHttpError(err, 401)).toBe(true);
});
it('returns error when id is missing', async () => {
const locals = createLocals(mockUser() as any);
const req = createFormRequest({});
const result = await actions.replace({ request: req, locals } as any);
expect(result).toEqual({ success: false, action: 'replace', error: 'Missing key id' });
});
it('returns error when the key does not exist', async () => {
mockLicenseKeyFindUnique.mockResolvedValue(null);
const locals = createLocals(mockUser() as any);
const req = createFormRequest({ id: 'missing' });
const result = await actions.replace({ request: req, locals } as any);
expect(result).toEqual({ success: false, action: 'replace', error: 'License key not found' });
});
it('deletes the old key and creates a replacement preserving the owner', async () => {
mockLicenseKeyFindUnique.mockResolvedValue({
id: 'k1',
key: 'AAAA-BBBB-CCCC-DDDD',
userId: 'user-1',
email: null
});
mockLicenseKeyDelete.mockResolvedValue({});
mockLicenseKeyCreate.mockResolvedValue({});
const locals = createLocals(mockUser() as any);
const req = createFormRequest({ id: 'k1' });
const result = await actions.replace({ request: req, locals } as any);
expect(result).toEqual({ success: true, action: 'replace' });
expect(mockLicenseKeyDelete).toHaveBeenCalledWith({ where: { id: 'k1' } });
expect(mockLicenseKeyCreate).toHaveBeenCalledOnce();
const createCall = mockLicenseKeyCreate.mock.calls[0][0];
expect(createCall.data.key).toMatch(/^[A-Z0-9]{4}-[A-Z0-9]{4}-[A-Z0-9]{4}-[A-Z0-9]{4}$/);
expect(createCall.data.active).toBe(true);
expect(createCall.data.userId).toBe('user-1');
expect(createCall.data.email).toBeNull();
});
});
@@ -1,107 +0,0 @@
import { describe, it, expect, vi } from 'vitest';
import { getSerializableModelConfigs } from '$lib/svelte-admin/registry';
import { createLocals, mockUser } from '$lib/test-utils';
vi.mock('$lib/db', () => ({}));
import { load } from './+page.server';
function localsWithRole(roleId: number) {
return createLocals(mockUser({ roleId }) as any);
}
describe('getSerializableModelConfigs', () => {
it('returns only serializable fields (no functions)', () => {
const models = getSerializableModelConfigs(1);
const serialized = JSON.stringify(models);
expect(serialized).toBeDefined();
for (const model of models) {
expect(typeof model.slug).toBe('string');
expect(typeof model.displayName).toBe('string');
expect(Array.isArray(model.listFields)).toBe(true);
expect(
typeof model.accessFilterResult === 'object' || model.accessFilterResult === undefined
).toBe(true);
}
});
it('evaluates accessFilter for SUPER (roleId 1) — returns undefined for users', () => {
const models = getSerializableModelConfigs(1);
const users = models.find((m) => m.slug === 'users')!;
expect(users.accessFilterResult).toBeUndefined();
});
it('evaluates accessFilter for ADMIN (roleId 2) — restricts users to STAFF+', () => {
const models = getSerializableModelConfigs(2);
const users = models.find((m) => m.slug === 'users')!;
expect(users.accessFilterResult).toEqual({ roleId: { gte: 3 } });
});
it('evaluates accessFilter for STAFF (roleId 3) — restricts users to USER-only', () => {
const models = getSerializableModelConfigs(3);
const users = models.find((m) => m.slug === 'users')!;
expect(users.accessFilterResult).toEqual({ roleId: { gt: 3 } });
});
it('models without accessFilter have undefined accessFilterResult', () => {
const models = getSerializableModelConfigs(1);
const roles = models.find((m) => m.slug === 'user-roles')!;
expect(roles.accessFilterResult).toBeUndefined();
});
it('excludes specified slugs', () => {
const models = getSerializableModelConfigs(1, ['license-keys', 'users']);
expect(models.find((m) => m.slug === 'license-keys')).toBeUndefined();
expect(models.find((m) => m.slug === 'users')).toBeUndefined();
expect(models.length).toBeGreaterThan(0);
});
});
describe('admin index page load', () => {
it('returns models with evaluated accessFilter results', async () => {
const result = (await load({ locals: localsWithRole(1) } as any)) as any;
expect(result.models).toBeDefined();
expect(Array.isArray(result.models)).toBe(true);
const users = result.models.find((m: any) => m.slug === 'users');
expect(users).toBeDefined();
expect(users.accessFilterResult).toBeUndefined();
});
it('returns different accessFilter results per role', async () => {
const superResult = (await load({ locals: localsWithRole(1) } as any)) as any;
const adminResult = (await load({ locals: localsWithRole(2) } as any)) as any;
const staffResult = (await load({ locals: localsWithRole(3) } as any)) as any;
const superUsers = superResult.models.find((m: any) => m.slug === 'users');
const adminUsers = adminResult.models.find((m: any) => m.slug === 'users');
const staffUsers = staffResult.models.find((m: any) => m.slug === 'users');
expect(superUsers.accessFilterResult).toBeUndefined();
expect(adminUsers.accessFilterResult).toEqual({ roleId: { gte: 3 } });
expect(staffUsers.accessFilterResult).toEqual({ roleId: { gt: 3 } });
});
it('excludes license-keys model', async () => {
const result = (await load({ locals: localsWithRole(1) } as any)) as any;
const licenseKeys = result.models.find((m: any) => m.slug === 'license-keys');
expect(licenseKeys).toBeUndefined();
});
it('returns fully JSON-serializable data (no functions)', async () => {
const result = (await load({ locals: localsWithRole(2) } as any)) as any;
const serialized = JSON.stringify(result);
const parsed = JSON.parse(serialized);
expect(parsed.models).toBeDefined();
expect(parsed.models.length).toBeGreaterThan(0);
});
it('defaults to roleId 4 when user has no roleId', async () => {
const result = (await load({
locals: createLocals(mockUser({ roleId: null }) as any)
} as any)) as any;
const users = result.models.find((m: any) => m.slug === 'users');
expect(users.accessFilterResult).toEqual({ roleId: { gt: 3 } });
});
});
@@ -1,146 +1,81 @@
import type { PageServerLoad } from './$types';
import { getModelConfig } from '$lib/svelte-admin/registry';
import { getDelegate } from '$lib/svelte-admin/prisma-delegate';
import { prisma } from '$lib/db';
import { superValidate } from 'sveltekit-superforms/server';
import { zod4 } from 'sveltekit-superforms/adapters';
import { z } from 'zod';
import { superFormErrorsToFieldErrors } from '$lib/superform-errors';
const config = getModelConfig('proclamation-categories')!;
const CategorySchema = z.object({
name: z.string().min(1, 'Required').max(100, 'Too long')
});
export const load: PageServerLoad = async ({ locals }) => {
const delegate = getDelegate(config);
const records = await delegate.findMany({
where: { parentId: null },
include: config.include,
orderBy: { sortOrder: 'asc' }
export const load: PageServerLoad = async () => {
const records = await prisma.proclamationCategory.findMany({
orderBy: [{ sortOrder: 'asc' }, { name: 'asc' }]
});
const relationOptionsData: Record<string, { label: string; value: string | number }[]> = {};
for (const [field, relConfig] of Object.entries(config.relationOptions ?? {})) {
const relDelegate = getDelegate({ ...config, modelName: relConfig.model });
const selectFields: Record<string, boolean> = {
[relConfig.valueField]: true,
[relConfig.labelField]: true
};
const options = await relDelegate.findMany({
select: selectFields,
orderBy: { [relConfig.labelField]: 'asc' }
});
const mapped = (options as Record<string, unknown>[]).map((opt: Record<string, unknown>) => ({
label: String(opt[relConfig.labelField]),
value: opt[relConfig.valueField] as string | number
}));
relationOptionsData[field] = mapped;
}
return {
records: records as Record<string, unknown>[],
relationOptionsData
};
return { records: records as unknown as Record<string, unknown>[] };
};
export const actions = {
upsert: async ({ request, locals }) => {
upsert: async ({ request }) => {
const formData = await request.formData();
const name = (formData.get('name')?.toString() ?? '').trim();
const rawId = formData.get('id')?.toString();
const childIds = formData.getAll('childId').map((v) => v.toString());
const childNames = formData
.getAll('childName')
.map((v) => v.toString().trim())
.filter(Boolean);
if (!name) {
return { success: false, error: 'Name is required', fieldErrors: { name: 'Required' } };
const form = await superValidate(formData, zod4(CategorySchema));
if (!form.valid) {
const { error: formError, fieldErrors } = superFormErrorsToFieldErrors(
form.errors as Record<string, unknown>
);
return { success: false, action: 'upsert', error: formError, fieldErrors };
}
const delegate = getDelegate(config);
const rawId = formData.get('id')?.toString();
const name = form.data.name;
try {
let categoryId: number;
if (rawId) {
const id = parseInt(rawId, 10);
await delegate.update({
where: { id },
await prisma.proclamationCategory.update({
where: { id: Number(rawId) },
data: { name }
});
categoryId = id;
} else {
const maxSort = await delegate.findFirst({
where: { parentId: null },
const maxSort = await prisma.proclamationCategory.findFirst({
orderBy: { sortOrder: 'desc' },
select: { sortOrder: true }
});
const sortOrder = (maxSort?.sortOrder ?? -1) + 1;
const created = await delegate.create({
data: { name, sortOrder }
await prisma.proclamationCategory.create({
data: { name, sortOrder: (maxSort?.sortOrder ?? -1) + 1 }
});
categoryId = created.id;
}
const existingChildren = await delegate.findMany({
where: { parentId: categoryId },
select: { id: true }
});
const existingChildIdSet = new Set(existingChildren.map((c: { id: number }) => c.id));
const submittedIdSet = new Set<number>();
for (let i = 0; i < childNames.length; i++) {
const childName = childNames[i];
if (!childName) continue;
const childIdRaw = childIds[i] ? parseInt(childIds[i], 10) : null;
if (childIdRaw && existingChildIdSet.has(childIdRaw)) {
submittedIdSet.add(childIdRaw);
await delegate.update({
where: { id: childIdRaw },
data: { name: childName }
});
} else {
await delegate.create({
data: { name: childName, parentId: categoryId }
});
}
}
for (const child of existingChildren) {
if (!submittedIdSet.has(child.id)) {
await delegate.delete({ where: { id: child.id } });
}
}
return { success: true };
} catch (e: unknown) {
if (e && typeof e === 'object' && 'code' in e && (e as { code: string }).code === 'P2002') {
return {
success: false,
error: 'A sub-category with this name already exists under this parent.'
action: 'upsert',
error: 'A category with this name already exists.'
};
}
throw e;
}
return { success: true, action: 'upsert' };
},
delete: async ({ request, locals }) => {
delete: async ({ request }) => {
const formData = await request.formData();
const rawId = formData.get('id')?.toString();
if (!rawId) return { success: false, error: 'Missing id' };
const id = parseInt(rawId, 10);
const delegate = getDelegate(config);
const viewerRoleId = (locals.user as { roleId?: number } | null)?.roleId ?? 4;
const where = config.accessFilter?.(viewerRoleId);
if (where) {
const match = await delegate.findFirst({ where: { id, ...where } });
if (!match) return { success: false, error: 'Access denied' };
try {
await prisma.proclamationCategory.delete({ where: { id: Number(rawId) } });
} catch (e: unknown) {
if (e && typeof e === 'object' && 'code' in e && (e as { code: string }).code === 'P2003') {
return {
success: false,
error: 'Cannot delete: one or more proclamations use this category.'
};
}
throw e;
}
await delegate.deleteMany({ where: { parentId: id } });
await delegate.delete({ where: { id } });
return { success: true };
return { success: true, action: 'delete' };
},
reorder: async ({ request }) => {
const formData = await request.formData();
@@ -148,16 +83,13 @@ export const actions = {
if (!raw) return { success: false, error: 'Missing ids' };
const ids = JSON.parse(raw) as number[];
const delegate = getDelegate(config);
for (let i = 0; i < ids.length; i++) {
await delegate.update({
await prisma.proclamationCategory.update({
where: { id: ids[i] },
data: { sortOrder: i }
});
}
return { success: true };
return { success: true, action: 'reorder' };
}
};
@@ -1,36 +1,57 @@
<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 AdminModal from '$lib/components/admin/AdminModal.svelte';
import Toast from '$lib/components/admin/Toast.svelte';
import { getModelConfig } from '$lib/svelte-admin/registry';
import { enhance } from '$app/forms';
import { invalidateAll } from '$app/navigation';
import CategoryForm from './CategoryForm.svelte';
import type { PageProps } from './$types';
import DataTable, { type DataTableColumn } from '$lib/components/DataTable.svelte';
import AdminModal from '$lib/components/admin/AdminModal.svelte';
type ActionResult = {
success?: boolean;
action?: string;
error?: string;
fieldErrors?: Record<string, string>;
} | null;
let { data, form }: PageProps = $props();
let canDelete = $derived((data.adminUser?.roleId ?? 4) <= 2);
const config = getModelConfig('proclamation-categories')!;
const canDelete = $derived((data.adminUser?.roleId ?? 4) <= 2);
const columns: DataTableColumn[] = [{ label: 'Name', value: (r) => r.name }];
let modalOpen = $state(false);
let editingRecord = $state<Record<string, unknown> | null>(null);
let toastMessage = $state('');
let toastType = $state<'success' | 'error'>('success');
let fieldErrors = $state<Record<string, string>>({});
let name = $state('');
let formError = $state('');
let fieldErrors = $state<Record<string, string>>({});
let successMessage = $state('');
let lastForm = $state<ActionResult>(null);
$effect(() => {
if (form && 'success' in (form as any)) {
const result = form as any;
if (result.success) {
toastMessage = 'Saved successfully';
toastType = 'success';
closeModal();
} else {
formError = result.error ?? '';
fieldErrors = result.fieldErrors ?? {};
}
const rec = editingRecord ?? {};
name = String(rec.name ?? '');
if (!editingRecord) {
formError = '';
fieldErrors = {};
successMessage = '';
}
});
$effect(() => {
if (!form || form === lastForm) return;
lastForm = form;
if (form.success) {
formError = '';
fieldErrors = {};
successMessage = editingRecord ? 'Category updated' : 'Category created';
setTimeout(() => {
modalOpen = false;
successMessage = '';
}, 1200);
} else {
successMessage = '';
formError = form.error ?? '';
fieldErrors = form.fieldErrors ?? {};
}
});
@@ -43,34 +64,29 @@
function openCreate() {
editingRecord = null;
formError = '';
fieldErrors = {};
lastForm = null;
modalOpen = true;
}
function openEdit(record: Record<string, unknown>) {
editingRecord = record;
formError = '';
fieldErrors = {};
lastForm = null;
modalOpen = true;
}
function closeModal() {
modalOpen = false;
editingRecord = null;
formError = '';
fieldErrors = {};
}
</script>
<div class="p-6">
<h1 class="mb-4 text-xl font-bold text-gray-900 dark:text-gray-100">{config.displayName}</h1>
<AdminTable
<div>
<DataTable
title="Proclamation Categories"
{columns}
records={data.records}
listFields={config.listFields}
searchFields={config.searchFields}
fieldTypes={config.fieldTypes}
singular={config.displayNameSingular}
newLabel="New Category"
searchPlaceholder="Search categories..."
draggable={true}
onReorder={handleReorder}
onRowClick={openEdit}
@@ -78,23 +94,62 @@
/>
</div>
<AdminModal
title={editingRecord
? `Edit ${config.displayNameSingular}`
: `Create ${config.displayNameSingular}`}
open={modalOpen}
onClose={closeModal}
{canDelete}
recordId={editingRecord?.id as string | undefined}
>
<CategoryForm
record={editingRecord ?? {}}
{fieldErrors}
{formError}
submitLabel={editingRecord ? 'Save' : 'Create'}
/>
</AdminModal>
{#if modalOpen}
<AdminModal
title={editingRecord ? 'Edit Category' : 'Create Category'}
open={modalOpen}
onClose={closeModal}
{canDelete}
recordId={editingRecord?.id as string | undefined}
>
{#if successMessage}
<div
class="mb-4 rounded-md border border-green-200 bg-green-50 px-3 py-2 text-sm text-green-700"
>
{successMessage}
</div>
{/if}
{#if formError}
<div class="mb-4 rounded-md border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600">
{formError}
</div>
{/if}
{#if toastMessage}
<Toast message={toastMessage} type={toastType} />
<form method="POST" action="?/upsert" use:enhance class="flex flex-col gap-4">
{#if editingRecord}
<input type="hidden" name="id" value={String(editingRecord.id)} />
{/if}
<div>
<label for="field-name" class="mb-1 block text-sm font-medium text-slate-700">Name</label>
<input
id="field-name"
type="text"
name="name"
required
bind:value={name}
class="w-full rounded-md border border-slate-300 bg-white px-3 py-2 text-sm text-slate-900 shadow-sm transition focus:border-indigo-500 focus:outline-none"
/>
{#if fieldErrors.name}
<p class="mt-1 text-sm text-red-600">{fieldErrors.name}</p>
{/if}
</div>
<div class="mt-2 flex justify-end gap-3">
<button
type="button"
onclick={closeModal}
class="rounded-md border border-slate-300 bg-white px-4 py-2 text-sm font-medium text-slate-700 transition hover:bg-slate-50"
>
Cancel
</button>
<button
type="submit"
class="rounded-md bg-indigo-600 px-4 py-2 text-sm font-medium text-white transition hover:bg-indigo-700"
>
{editingRecord ? 'Save Category' : 'Create Category'}
</button>
</div>
</form>
</AdminModal>
{/if}
@@ -1,124 +0,0 @@
<script lang="ts">
import { enhance } from '$app/forms';
let {
record = {} as Record<string, unknown>,
fieldErrors = {} as Record<string, string>,
formError = '',
action = '?/upsert',
submitLabel = 'Save'
}: {
record?: Record<string, unknown>;
fieldErrors?: Record<string, string>;
formError?: string;
action?: string;
submitLabel?: string;
} = $props();
let name = $state('');
let children = $state<{ id: number | null; name: string }[]>([]);
let dirty = $state(false);
$effect(() => {
dirty = false;
});
$effect(() => {
name = String(record.name ?? '');
const existingChildren = (record.children as Array<{ id: number; name: string }>) ?? [];
children = existingChildren.map((c) => ({ id: c.id, name: c.name }));
});
function markDirty() {
if (record.id) dirty = true;
}
function addChild() {
children = [...children, { id: null, name: '' }];
if (record.id) dirty = true;
}
function removeChild(index: number) {
children = children.filter((_, i) => i !== index);
if (record.id) dirty = true;
}
</script>
<form {action} method="POST" use:enhance oninput={markDirty}>
{#if record?.id}
<input type="hidden" name="id" value={String(record.id)} />
{/if}
{#if formError}
<div class="mb-4 border border-red-300 bg-red-50 px-4 py-2 text-sm text-red-700">
{formError}
</div>
{/if}
<div class="flex flex-col gap-4">
<div>
<label
for="field-name"
class="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300"
>
Name
</label>
<input
type="text"
id="field-name"
name="name"
bind:value={name}
class="w-full border px-3 py-1.5 text-sm {fieldErrors.name
? 'border-red-400'
: 'border-gray-300 dark:border-gray-600'} dark:bg-gray-800 dark:text-gray-100"
/>
{#if fieldErrors.name}
<p class="mt-1 text-xs text-red-600">{fieldErrors.name}</p>
{/if}
</div>
<div>
<label class="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">
Sub-categories
</label>
{#each children as child, i}
<div class="mb-2 flex items-center gap-2">
<input type="hidden" name="childId" value={child.id ?? ''} />
<input
type="text"
name="childName"
bind:value={child.name}
placeholder="Sub-category name"
class="flex-1 border px-3 py-1.5 text-sm {fieldErrors[`childName.${i}`]
? 'border-red-400'
: 'border-gray-300 dark:border-gray-600'} dark:bg-gray-800 dark:text-gray-100"
/>
<button
type="button"
onclick={() => removeChild(i)}
class="shrink-0 px-2 py-1 text-sm text-red-600 hover:bg-red-50 dark:text-red-400 dark:hover:bg-red-900/20"
>
Remove
</button>
</div>
{/each}
<button
type="button"
onclick={addChild}
class="mt-1 text-sm text-blue-600 hover:text-blue-800 dark:text-blue-400 dark:hover:text-blue-300"
>
+ Add Sub-category
</button>
</div>
<div class="flex justify-end gap-2 pt-2">
<button
type="submit"
disabled={!!record?.id && !dirty}
class="border border-gray-300 bg-gray-100 px-4 py-1.5 text-sm font-medium hover:bg-gray-200 disabled:cursor-not-allowed disabled:opacity-50 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-300 dark:hover:bg-gray-700"
>
{submitLabel}
</button>
</div>
</div>
</form>
@@ -0,0 +1,95 @@
import type { PageServerLoad } from './$types';
import { prisma } from '$lib/db';
import { superValidate } from 'sveltekit-superforms/server';
import { zod4 } from 'sveltekit-superforms/adapters';
import { z } from 'zod';
import { superFormErrorsToFieldErrors } from '$lib/superform-errors';
const GroupSchema = z.object({
name: z.string().min(1, 'Required').max(100, 'Too long')
});
export const load: PageServerLoad = async () => {
const records = await prisma.proclamationGroup.findMany({
orderBy: [{ sortOrder: 'asc' }, { name: 'asc' }]
});
return { records: records as unknown as Record<string, unknown>[] };
};
export const actions = {
upsert: async ({ request }) => {
const formData = await request.formData();
const form = await superValidate(formData, zod4(GroupSchema));
if (!form.valid) {
const { error: formError, fieldErrors } = superFormErrorsToFieldErrors(
form.errors as Record<string, unknown>
);
return { success: false, action: 'upsert', error: formError, fieldErrors };
}
const rawId = formData.get('id')?.toString();
const name = form.data.name;
try {
if (rawId) {
await prisma.proclamationGroup.update({
where: { id: Number(rawId) },
data: { name }
});
} else {
const maxSort = await prisma.proclamationGroup.findFirst({
orderBy: { sortOrder: 'desc' },
select: { sortOrder: true }
});
await prisma.proclamationGroup.create({
data: { name, sortOrder: (maxSort?.sortOrder ?? -1) + 1 }
});
}
} catch (e: unknown) {
if (e && typeof e === 'object' && 'code' in e && (e as { code: string }).code === 'P2002') {
return {
success: false,
action: 'upsert',
error: 'A group with this name already exists.'
};
}
throw e;
}
return { success: true, action: 'upsert' };
},
delete: async ({ request }) => {
const formData = await request.formData();
const rawId = formData.get('id')?.toString();
if (!rawId) return { success: false, error: 'Missing id' };
try {
await prisma.proclamationGroup.delete({ where: { id: Number(rawId) } });
} catch (e: unknown) {
if (e && typeof e === 'object' && 'code' in e && (e as { code: string }).code === 'P2003') {
return {
success: false,
error: 'Cannot delete: one or more proclamations use this group.'
};
}
throw e;
}
return { success: true, action: 'delete' };
},
reorder: async ({ request }) => {
const formData = await request.formData();
const raw = formData.get('ids')?.toString();
if (!raw) return { success: false, error: 'Missing ids' };
const ids = JSON.parse(raw) as number[];
for (let i = 0; i < ids.length; i++) {
await prisma.proclamationGroup.update({
where: { id: ids[i] },
data: { sortOrder: i }
});
}
return { success: true, action: 'reorder' };
}
};
@@ -0,0 +1,155 @@
<script lang="ts">
import { enhance } from '$app/forms';
import { invalidateAll } from '$app/navigation';
import type { PageProps } from './$types';
import DataTable, { type DataTableColumn } from '$lib/components/DataTable.svelte';
import AdminModal from '$lib/components/admin/AdminModal.svelte';
type ActionResult = {
success?: boolean;
action?: string;
error?: string;
fieldErrors?: Record<string, string>;
} | null;
let { data, form }: PageProps = $props();
let canDelete = $derived((data.adminUser?.roleId ?? 4) <= 2);
const columns: DataTableColumn[] = [{ label: 'Name', value: (r) => r.name }];
let modalOpen = $state(false);
let editingRecord = $state<Record<string, unknown> | null>(null);
let name = $state('');
let formError = $state('');
let fieldErrors = $state<Record<string, string>>({});
let successMessage = $state('');
let lastForm = $state<ActionResult>(null);
$effect(() => {
const rec = editingRecord ?? {};
name = String(rec.name ?? '');
if (!editingRecord) {
formError = '';
fieldErrors = {};
successMessage = '';
}
});
$effect(() => {
if (!form || form === lastForm) return;
lastForm = form;
if (form.success) {
formError = '';
fieldErrors = {};
successMessage = editingRecord ? 'Group updated' : 'Group created';
setTimeout(() => {
modalOpen = false;
successMessage = '';
}, 1200);
} else {
successMessage = '';
formError = form.error ?? '';
fieldErrors = form.fieldErrors ?? {};
}
});
async function handleReorder(ids: (string | number)[]) {
const formData = new FormData();
formData.set('ids', JSON.stringify(ids));
await fetch('?/reorder', { method: 'POST', body: formData });
await invalidateAll();
}
function openCreate() {
editingRecord = null;
lastForm = null;
modalOpen = true;
}
function openEdit(record: Record<string, unknown>) {
editingRecord = record;
lastForm = null;
modalOpen = true;
}
function closeModal() {
modalOpen = false;
editingRecord = null;
}
</script>
<div>
<DataTable
title="Proclamation Groups"
{columns}
records={data.records}
newLabel="New Group"
searchPlaceholder="Search groups..."
draggable={true}
onReorder={handleReorder}
onRowClick={openEdit}
onNew={openCreate}
/>
</div>
{#if modalOpen}
<AdminModal
title={editingRecord ? 'Edit Group' : 'Create Group'}
open={modalOpen}
onClose={closeModal}
{canDelete}
recordId={editingRecord?.id as string | undefined}
>
{#if successMessage}
<div
class="mb-4 rounded-md border border-green-200 bg-green-50 px-3 py-2 text-sm text-green-700"
>
{successMessage}
</div>
{/if}
{#if formError}
<div class="mb-4 rounded-md border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600">
{formError}
</div>
{/if}
<form method="POST" action="?/upsert" use:enhance class="flex flex-col gap-4">
{#if editingRecord}
<input type="hidden" name="id" value={String(editingRecord.id)} />
{/if}
<div>
<label for="field-name" class="mb-1 block text-sm font-medium text-slate-700">Name</label>
<input
id="field-name"
type="text"
name="name"
required
bind:value={name}
class="w-full rounded-md border border-slate-300 bg-white px-3 py-2 text-sm text-slate-900 shadow-sm transition focus:border-indigo-500 focus:outline-none"
/>
{#if fieldErrors.name}
<p class="mt-1 text-sm text-red-600">{fieldErrors.name}</p>
{/if}
</div>
<div class="mt-2 flex justify-end gap-3">
<button
type="button"
onclick={closeModal}
class="rounded-md border border-slate-300 bg-white px-4 py-2 text-sm font-medium text-slate-700 transition hover:bg-slate-50"
>
Cancel
</button>
<button
type="submit"
class="rounded-md bg-indigo-600 px-4 py-2 text-sm font-medium text-white transition hover:bg-indigo-700"
>
{editingRecord ? 'Save Group' : 'Create Group'}
</button>
</div>
</form>
</AdminModal>
{/if}
@@ -1,78 +1,87 @@
import type { PageServerLoad } from './$types';
import { getModelConfig } from '$lib/svelte-admin/registry';
import { loadRecords, deleteRecord, superFormErrorsToFieldErrors } from '$lib/svelte-admin/crud';
import { prisma } from '$lib/db';
import { superValidate } from 'sveltekit-superforms/server';
import { zod4 } from 'sveltekit-superforms/adapters';
import * as Schemas from '$lib/svelte-admin/schemas';
import { z } from 'zod';
import { superFormErrorsToFieldErrors } from '$lib/superform-errors';
const config = getModelConfig('proclamations')!;
function viewerRoleId(locals: App.Locals): number {
return (locals.user as { roleId?: number } | null)?.roleId ?? 4;
}
const ProclamationSchema = z.object({
name: z.string().min(1, 'Required'),
solfeggioFrequencyId: z.coerce.number().int(),
categoryId: z.coerce.number().int(),
groupId: z.coerce.number().int(),
content: z.string().optional().default('')
});
function isSuper(locals: App.Locals): boolean {
return viewerRoleId(locals) <= 1;
return ((locals.user as { roleId?: number } | null)?.roleId ?? 4) <= 1;
}
export const load: PageServerLoad = async ({ locals }) => {
const base = await loadRecords(locals, config);
const [proclamations, frequencies, categories, groups] = await Promise.all([
prisma.proclamation.findMany({
include: { category: true, group: true, solfeggioFrequency: true },
orderBy: { name: 'asc' }
}),
prisma.solfeggioFrequency.findMany({
select: { id: true, name: true, value: true },
orderBy: { name: 'asc' }
}),
prisma.proclamationCategory.findMany({
select: { id: true, name: true },
orderBy: { name: 'asc' }
}),
prisma.proclamationGroup.findMany({
select: { id: true, name: true },
orderBy: { sortOrder: 'asc' }
})
]);
const records = proclamations as unknown as Record<string, unknown>[];
// Strip content from non-Super users
if (!isSuper(locals)) {
for (const record of base.records) {
for (const record of records) {
delete record.content;
}
}
const categories = await prisma.proclamationCategory.findMany({
select: { id: true, name: true, parentId: true },
orderBy: { name: 'asc' }
});
return { ...base, categories };
return { records, frequencies, categories, groups };
};
export const actions = {
upsert: async ({ request, locals }) => {
if (!locals.user) {
return { success: false, error: 'Unauthorized' };
return { success: false, action: 'upsert', error: 'Unauthorized' };
}
if (!isSuper(locals)) {
return { success: false, error: 'Only Super admins can edit proclamations' };
return {
success: false,
action: 'upsert',
error: 'Only Super admins can edit proclamations'
};
}
const formData = await request.formData();
const form = await superValidate(formData, zod4(Schemas.Proclamation));
const form = await superValidate(formData, zod4(ProclamationSchema));
if (!form.valid) {
const { error, fieldErrors } = superFormErrorsToFieldErrors(
const { error: formError, fieldErrors } = superFormErrorsToFieldErrors(
form.errors as Record<string, unknown>
);
return { success: false, error, fieldErrors };
return { success: false, action: 'upsert', error: formError, fieldErrors };
}
const rawId = formData.get('id')?.toString();
const { parentCategoryId, ...rest } = form.data;
const data: Record<string, unknown> = {
...rest,
categoryId: rest.categoryId ?? parentCategoryId
};
const data: Record<string, unknown> = { ...form.data };
try {
if (rawId) {
const existing = await prisma.proclamation.findUnique({
where: { id: rawId }
});
const existing = await prisma.proclamation.findUnique({ where: { id: rawId } });
if (!existing) {
return { success: false, error: 'Record not found' };
return { success: false, action: 'upsert', error: 'Record not found' };
}
await prisma.proclamation.update({
where: { id: rawId },
data: data as any
});
await prisma.proclamation.update({ where: { id: rawId }, data: data as any });
} else {
await prisma.proclamation.create({ data: data as any });
}
@@ -80,18 +89,41 @@ export const actions = {
if (e && typeof e === 'object' && 'code' in e && (e as { code: string }).code === 'P2002') {
return {
success: false,
action: 'upsert',
error: 'A record with this name already exists.'
};
}
throw e;
}
return { success: true };
return { success: true, action: 'upsert' };
},
delete: async ({ request, locals }) => {
if (!isSuper(locals)) {
return { success: false, error: 'Only Super admins can delete proclamations' };
return {
success: false,
action: 'delete',
error: 'Only Super admins can delete proclamations'
};
}
return deleteRecord(request, config, locals);
const formData = await request.formData();
const rawId = formData.get('id')?.toString();
if (!rawId) return { success: false, action: 'delete', error: 'Missing id' };
try {
await prisma.proclamation.delete({ where: { id: rawId } });
} catch (e: unknown) {
if (e && typeof e === 'object' && 'code' in e && (e as { code: string }).code === 'P2003') {
return {
success: false,
action: 'delete',
error: 'Cannot delete: this proclamation is assigned to users.'
};
}
throw e;
}
return { success: true, action: 'delete' };
}
};
@@ -1,95 +1,339 @@
<script lang="ts">
import { enhance } from '$app/forms';
import type { PageProps } from './$types';
import type { ActionResult } from '$lib/svelte-admin/types';
import AdminTable from '$lib/components/admin/AdminTable.svelte';
import { Editor } from '@tiptap/core';
import StarterKit from '@tiptap/starter-kit';
import Highlight from '@tiptap/extension-highlight';
import { TextStyle } from '@tiptap/extension-text-style';
import { Color } from '@tiptap/extension-color';
import DataTable, { type DataTableColumn } from '$lib/components/DataTable.svelte';
import AdminModal from '$lib/components/admin/AdminModal.svelte';
import Toast from '$lib/components/admin/Toast.svelte';
import { getModelConfig } from '$lib/svelte-admin/registry';
import ProclamationForm from './ProclamationForm.svelte';
type ActionResult = {
success?: boolean;
action?: string;
error?: string;
fieldErrors?: Record<string, string>;
} | null;
let { data, form }: PageProps = $props();
const config = getModelConfig('proclamations')!;
const viewerRoleId = $derived(data.adminUser?.roleId ?? 4);
const isSuper = $derived(viewerRoleId <= 1);
const canDelete = $derived(viewerRoleId <= 2);
const columns: DataTableColumn[] = [
{ label: 'Name', value: (r) => r.name },
{
label: 'Category',
value: (r) => (r.category as Record<string, unknown> | undefined)?.name ?? ''
},
{ label: 'Group', value: (r) => (r.group as Record<string, unknown> | undefined)?.name ?? '' },
{
label: 'Frequency',
value: (r) => (r.solfeggioFrequency as Record<string, unknown> | undefined)?.name ?? ''
}
];
let modalOpen = $state(false);
let editingRecord = $state<Record<string, unknown> | null>(null);
let toastMessage = $state('');
let toastType = $state<'success' | 'error'>('success');
let fieldErrors = $state<Record<string, string>>({});
let name = $state('');
let solfeggioFrequencyId = $state('');
let categoryId = $state('');
let groupId = $state('');
let content = $state('');
let formError = $state('');
let fieldErrors = $state<Record<string, string>>({});
let successMessage = $state('');
let lastForm = $state<ActionResult>(null);
let element = $state<HTMLElement>();
let editor: Editor | null = null;
let contentKey = $derived(editingRecord?.id ?? 'new');
$effect(() => {
if (form && 'success' in (form as any)) {
const result = form as any;
if (result.success) {
toastMessage = 'Saved successfully';
toastType = 'success';
closeModal();
} else {
formError = result.error ?? '';
fieldErrors = result.fieldErrors ?? {};
}
const record = editingRecord ?? {};
name = String(record.name ?? '');
solfeggioFrequencyId = String(record.solfeggioFrequencyId ?? '');
categoryId = String(record.categoryId ?? '');
groupId = String(record.groupId ?? '');
content = typeof record.content === 'string' ? record.content : '';
if (!editingRecord) {
formError = '';
fieldErrors = {};
successMessage = '';
}
});
$effect(() => {
if (!form || form === lastForm) return;
lastForm = form;
if (form.success) {
formError = '';
fieldErrors = {};
successMessage = editingRecord ? 'Proclamation updated' : 'Proclamation created';
} else {
successMessage = '';
formError = form.error ?? '';
fieldErrors = form.fieldErrors ?? {};
}
});
$effect(() => {
if (!isSuper || !modalOpen || !element) return;
const initialContent =
typeof (editingRecord ?? {})['content'] === 'string'
? ((editingRecord ?? {})['content'] as string)
: '';
editor = new Editor({
element,
extensions: [
StarterKit.configure({
bulletList: false,
orderedList: false
}),
TextStyle,
Color,
Highlight.configure({ multicolor: true })
],
content: initialContent,
onUpdate: ({ editor }) => {
content = editor.getHTML();
}
});
const instance = editor;
instance.setEditable(true);
return () => {
instance.destroy();
};
});
function toggleBold() {
editor?.chain().focus().toggleBold().run();
}
function toggleItalic() {
editor?.chain().focus().toggleItalic().run();
}
function toggleHighlight() {
editor?.chain().focus().toggleHighlight().run();
}
function setColor() {
const current = editor?.getAttributes('textStyle').color ?? '#000000';
const colors = ['#000000', '#FF0000', '#0000FF', '#008000', '#FFA500', '#800080', '#808080'];
const idx = colors.indexOf(current);
const next = colors[(idx + 1) % colors.length];
editor?.chain().focus().setColor(next).run();
}
function openCreate() {
editingRecord = null;
formError = '';
fieldErrors = {};
lastForm = null;
modalOpen = true;
}
function openEdit(record: Record<string, unknown>) {
editingRecord = record;
formError = '';
fieldErrors = {};
lastForm = null;
modalOpen = true;
}
function closeModal() {
modalOpen = false;
editingRecord = null;
formError = '';
fieldErrors = {};
}
</script>
<div class="p-6">
<h1 class="mb-4 text-xl font-bold text-gray-900 dark:text-gray-100">{config.displayName}</h1>
<AdminTable
<div>
<DataTable
title="Proclamations"
{columns}
records={data.records}
listFields={config.listFields}
searchFields={config.searchFields}
fieldTypes={config.fieldTypes}
singular={config.displayNameSingular}
newLabel="New Proclamation"
searchPlaceholder="Search proclamations..."
onRowClick={isSuper ? openEdit : undefined}
onNew={isSuper ? openCreate : undefined}
/>
</div>
<AdminModal
title={editingRecord
? `Edit ${config.displayNameSingular}`
: `Create ${config.displayNameSingular}`}
open={modalOpen}
onClose={closeModal}
{canDelete}
recordId={editingRecord?.id as string | undefined}
>
<ProclamationForm
record={editingRecord ?? {}}
relationOptionsData={data.relationOptionsData}
categories={data.categories}
{fieldErrors}
{formError}
{isSuper}
submitLabel={editingRecord ? 'Update' : 'Create'}
/>
</AdminModal>
{#if modalOpen}
<AdminModal
title={editingRecord ? 'Edit Proclamation' : 'Create Proclamation'}
open={modalOpen}
onClose={closeModal}
canDelete={isSuper}
recordId={editingRecord?.id as string | undefined}
>
{#if successMessage}
<div
class="mb-4 rounded-md border border-green-200 bg-green-50 px-3 py-2 text-sm text-green-700"
>
{successMessage}
</div>
{/if}
{#if formError}
<div class="mb-4 rounded-md border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600">
{formError}
</div>
{/if}
{#if toastMessage}
<Toast message={toastMessage} type={toastType} />
<form method="POST" action="?/upsert" use:enhance class="flex flex-col gap-4">
{#if editingRecord}
<input type="hidden" name="id" value={String(editingRecord.id)} />
{/if}
<div>
<label for="field-name" class="mb-1 block text-sm font-medium text-slate-700">Name</label>
<input
id="field-name"
type="text"
name="name"
required
bind:value={name}
class="w-full rounded-md border border-slate-300 bg-white px-3 py-2 text-sm text-slate-900 shadow-sm transition focus:border-indigo-500 focus:outline-none"
/>
{#if fieldErrors.name}
<p class="mt-1 text-sm text-red-600">{fieldErrors.name}</p>
{/if}
</div>
<div>
<label
for="field-solfeggioFrequencyId"
class="mb-1 block text-sm font-medium text-slate-700">Frequency</label
>
<select
id="field-solfeggioFrequencyId"
name="solfeggioFrequencyId"
required
bind:value={solfeggioFrequencyId}
class="w-full rounded-md border border-slate-300 bg-white px-3 py-2 text-sm text-slate-900 shadow-sm transition focus:border-indigo-500 focus:outline-none"
>
<option value="">-- Select Frequency --</option>
{#each data.frequencies as freq (String(freq.id))}
<option value={String(freq.id)}>{freq.name}</option>
{/each}
</select>
{#if fieldErrors.solfeggioFrequencyId}
<p class="mt-1 text-sm text-red-600">{fieldErrors.solfeggioFrequencyId}</p>
{/if}
</div>
<div>
<label for="field-categoryId" class="mb-1 block text-sm font-medium text-slate-700"
>Category</label
>
<select
id="field-categoryId"
name="categoryId"
required
bind:value={categoryId}
class="w-full rounded-md border border-slate-300 bg-white px-3 py-2 text-sm text-slate-900 shadow-sm transition focus:border-indigo-500 focus:outline-none"
>
<option value="">-- Select Category --</option>
{#each data.categories as cat (String(cat.id))}
<option value={String(cat.id)}>{cat.name}</option>
{/each}
</select>
{#if fieldErrors.categoryId}
<p class="mt-1 text-sm text-red-600">{fieldErrors.categoryId}</p>
{/if}
</div>
<div>
<label for="field-groupId" class="mb-1 block text-sm font-medium text-slate-700"
>Group</label
>
<select
id="field-groupId"
name="groupId"
required
bind:value={groupId}
class="w-full rounded-md border border-slate-300 bg-white px-3 py-2 text-sm text-slate-900 shadow-sm transition focus:border-indigo-500 focus:outline-none"
>
<option value="">-- Select Group --</option>
{#each data.groups as group (String(group.id))}
<option value={String(group.id)}>{group.name}</option>
{/each}
</select>
{#if fieldErrors.groupId}
<p class="mt-1 text-sm text-red-600">{fieldErrors.groupId}</p>
{/if}
</div>
<div>
<label for="field-content" class="mb-1 block text-sm font-medium text-slate-700"
>Content</label
>
<div class="rounded-md border border-slate-300 bg-white">
<div class="flex gap-1 border-b border-slate-200 px-2 py-1.5">
<button
type="button"
onclick={toggleBold}
title="Bold"
class="rounded px-2 py-1 text-sm font-bold text-slate-600 transition hover:bg-slate-100"
>B</button
>
<button
type="button"
onclick={toggleItalic}
title="Italic"
class="rounded px-2 py-1 text-sm text-slate-600 italic transition hover:bg-slate-100"
>I</button
>
<button
type="button"
onclick={toggleHighlight}
title="Highlight"
class="rounded px-2 py-1 text-sm text-slate-600 transition hover:bg-slate-100"
>H</button
>
<button
type="button"
onclick={setColor}
title="Cycle text color"
class="rounded px-2 py-1 text-sm text-slate-600 transition hover:bg-slate-100"
>A</button
>
</div>
{#key contentKey}
<div
bind:this={element}
data-testid="tiptap-editor"
role="textbox"
tabindex="0"
contenteditable="true"
class="px-3 py-2 text-sm text-slate-900 focus:outline-none"
></div>
{/key}
<input type="hidden" name="content" value={content} />
</div>
{#if fieldErrors.content}
<p class="mt-1 text-sm text-red-600">{fieldErrors.content}</p>
{/if}
</div>
<div class="mt-2 flex justify-end gap-3">
<button
type="button"
onclick={closeModal}
class="rounded-md border border-slate-300 bg-white px-4 py-2 text-sm font-medium text-slate-700 transition hover:bg-slate-50"
>
Cancel
</button>
<button
type="submit"
class="rounded-md bg-indigo-600 px-4 py-2 text-sm font-medium text-white transition hover:bg-indigo-700"
>
{editingRecord ? 'Save Proclamation' : 'Create Proclamation'}
</button>
</div>
</form>
</AdminModal>
{/if}
@@ -1,237 +0,0 @@
<script lang="ts">
import { enhance } from '$app/forms';
import TipTap from '$lib/components/tiptap/TipTap.svelte';
let {
record = {} as Record<string, unknown>,
fieldErrors = {} as Record<string, string>,
formError = '',
relationOptionsData = {} as Record<string, { label: string; value: string | number }[]>,
categories = [] as { id: number; name: string; parentId: number | null }[],
isSuper = false,
action = '?/upsert',
submitLabel = 'Save'
}: {
record?: Record<string, unknown>;
fieldErrors?: Record<string, string>;
formError?: string;
relationOptionsData?: Record<string, { label: string; value: string | number }[]>;
categories?: { id: number; name: string; parentId: number | null }[];
isSuper?: boolean;
action?: string;
submitLabel?: string;
} = $props();
let dirty = $state(false);
$effect(() => {
dirty = false;
});
function markDirty() {
if (record.id) dirty = true;
}
let name = $state('');
let solfeggioFrequencyId = $state('');
let subscriptionLevelId = $state('');
let parentCategoryId = $state('');
let categoryId = $state('');
$effect(() => {
name = String(record.name ?? '');
solfeggioFrequencyId = String(record.solfeggioFrequencyId ?? '');
subscriptionLevelId = String(record.subscriptionLevelId ?? '');
parentCategoryId = String(
record.parentCategoryId ??
(record.category as Record<string, unknown> | undefined)?.parentId ??
''
);
categoryId = String(record.categoryId ?? '');
});
let parentOptions = $derived(categories.filter((c) => c.parentId === null));
let childOptions = $derived(
parentCategoryId ? categories.filter((c) => c.parentId === Number(parentCategoryId)) : []
);
function handleParentChange(e: Event) {
const target = e.target as HTMLSelectElement;
parentCategoryId = target.value;
categoryId = '';
}
let contentKey = $derived(record.id ?? 'new');
</script>
<form {action} method="POST" use:enhance oninput={markDirty}>
{#if record?.id}
<input type="hidden" name="id" value={String(record.id)} />
{/if}
{#if formError}
<div class="mb-4 border border-red-300 bg-red-50 px-4 py-2 text-sm text-red-700">
{formError}
</div>
{/if}
<div class="flex flex-col gap-4">
<div>
<label
for="field-name"
class="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300"
>
Name
</label>
<input
type="text"
id="field-name"
name="name"
bind:value={name}
class="w-full border px-3 py-1.5 text-sm {fieldErrors.name
? 'border-red-400'
: 'border-gray-300 dark:border-gray-600'} dark:bg-gray-800 dark:text-gray-100"
/>
{#if fieldErrors.name}
<p class="mt-1 text-xs text-red-600">{fieldErrors.name}</p>
{/if}
</div>
<div>
<label
for="field-solfeggioFrequencyId"
class="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300"
>
Frequency
</label>
<select
id="field-solfeggioFrequencyId"
name="solfeggioFrequencyId"
onchange={(e) => (solfeggioFrequencyId = (e.target as HTMLSelectElement).value)}
class="w-full border px-3 py-1.5 text-sm {fieldErrors.solfeggioFrequencyId
? 'border-red-400'
: 'border-gray-300 dark:border-gray-600'} dark:bg-gray-800 dark:text-gray-100"
>
<option value="">-- Select Frequency --</option>
{#each relationOptionsData.solfeggioFrequencyId ?? [] as opt (String(opt.value))}
<option value={opt.value} selected={String(opt.value) === solfeggioFrequencyId}
>{opt.label}</option
>
{/each}
</select>
{#if fieldErrors.solfeggioFrequencyId}
<p class="mt-1 text-xs text-red-600">{fieldErrors.solfeggioFrequencyId}</p>
{/if}
</div>
<div>
<label
for="field-parentCategoryId"
class="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300"
>
Category
</label>
<select
id="field-parentCategoryId"
name="parentCategoryId"
onchange={handleParentChange}
class="w-full border px-3 py-1.5 text-sm {fieldErrors.parentCategoryId
? 'border-red-400'
: 'border-gray-300 dark:border-gray-600'} dark:bg-gray-800 dark:text-gray-100"
>
<option value="">-- Select Category --</option>
{#each parentOptions as cat (String(cat.id))}
<option value={cat.id} selected={String(cat.id) === parentCategoryId}>{cat.name}</option>
{/each}
</select>
{#if fieldErrors.parentCategoryId}
<p class="mt-1 text-xs text-red-600">{fieldErrors.parentCategoryId}</p>
{/if}
</div>
<div>
<label
for="field-categoryId"
class="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300"
>
Sub-Category
</label>
<select
id="field-categoryId"
name="categoryId"
onchange={(e) => (categoryId = (e.target as HTMLSelectElement).value)}
class="w-full border px-3 py-1.5 text-sm {fieldErrors.categoryId
? 'border-red-400'
: 'border-gray-300 dark:border-gray-600'} dark:bg-gray-800 dark:text-gray-100"
>
<option value="">-- Select Sub-Category --</option>
{#each childOptions as cat (String(cat.id))}
<option value={cat.id} selected={String(cat.id) === categoryId}>{cat.name}</option>
{/each}
</select>
{#if fieldErrors.categoryId}
<p class="mt-1 text-xs text-red-600">{fieldErrors.categoryId}</p>
{/if}
</div>
<div>
<label
for="field-subscriptionLevelId"
class="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300"
>
Subscription Level
</label>
<select
id="field-subscriptionLevelId"
name="subscriptionLevelId"
onchange={(e) => (subscriptionLevelId = (e.target as HTMLSelectElement).value)}
class="w-full border px-3 py-1.5 text-sm {fieldErrors.subscriptionLevelId
? 'border-red-400'
: 'border-gray-300 dark:border-gray-600'} dark:bg-gray-800 dark:text-gray-100"
>
<option value="">-- Select Level --</option>
{#each relationOptionsData.subscriptionLevelId ?? [] as opt (String(opt.value))}
<option value={opt.value} selected={String(opt.value) === subscriptionLevelId}
>{opt.label}</option
>
{/each}
</select>
{#if fieldErrors.subscriptionLevelId}
<p class="mt-1 text-xs text-red-600">{fieldErrors.subscriptionLevelId}</p>
{/if}
</div>
{#if isSuper}
<div>
<label
for="field-content"
class="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300"
>
Content
</label>
<div id="field-content">
{#key record.id}
<TipTap
initialContent={typeof record.content === 'string' ? record.content : ''}
name="content"
/>
{/key}
</div>
{#if fieldErrors.content}
<p class="mt-1 text-xs text-red-600">{fieldErrors.content}</p>
{/if}
</div>
{/if}
<div class="flex justify-end gap-2 pt-2">
<button
type="submit"
disabled={!!record?.id && !dirty}
class="border border-gray-300 bg-gray-100 px-4 py-1.5 text-sm font-medium hover:bg-gray-200 disabled:cursor-not-allowed disabled:opacity-50 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-300 dark:hover:bg-gray-700"
>
{submitLabel}
</button>
</div>
</div>
</form>
@@ -1,152 +0,0 @@
import { page } from 'vitest/browser';
import { describe, it, expect, vi } from 'vitest';
import { render } from 'vitest-browser-svelte';
vi.mock('$app/forms', () => ({
enhance: () => () => {}
}));
vi.mock('$lib/components/tiptap/TipTap.svelte', () => ({
default: ($anchor: Element) => {
const div = document.createElement('div');
div.setAttribute('data-testid', 'tiptap-mock');
$anchor.replaceWith(div);
}
}));
import ProclamationForm from './ProclamationForm.svelte';
const relationOptionsData = {
solfeggioFrequencyId: [
{ label: 'UT - 396', value: 1 },
{ label: 'RE - 417', value: 2 }
],
subscriptionLevelId: [
{ label: 'Free', value: 1 },
{ label: 'Premium', value: 2 }
]
};
const categories = [
{ id: 1, name: 'Healing', parentId: null },
{ id: 2, name: 'Manifestation', parentId: null },
{ id: 3, name: 'Physical Healing', parentId: 1 },
{ id: 4, name: 'Emotional Healing', parentId: 1 }
];
describe('ProclamationForm', () => {
it('renders all basic fields in create mode', async () => {
render(ProclamationForm, {
relationOptionsData,
categories,
isSuper: true
});
await expect.element(page.getByRole('textbox', { name: 'Name' })).toBeInTheDocument();
await expect.element(page.getByRole('combobox', { name: /^Frequency$/ })).toBeInTheDocument();
await expect.element(page.getByRole('combobox', { name: /^Category$/ })).toBeInTheDocument();
await expect
.element(page.getByRole('combobox', { name: /^Sub-Category$/ }))
.toBeInTheDocument();
await expect
.element(page.getByRole('combobox', { name: /^Subscription Level$/ }))
.toBeInTheDocument();
await expect.element(page.getByRole('button', { name: 'Save' })).toBeInTheDocument();
});
it('pre-fills name input when editing an existing record', async () => {
render(ProclamationForm, {
record: {
id: 'proc-1',
name: 'Test Proclamation',
solfeggioFrequencyId: 2,
subscriptionLevelId: 2,
categoryId: 3,
parentCategoryId: 1
},
relationOptionsData,
categories,
isSuper: true,
submitLabel: 'Update'
});
await expect
.element(page.getByRole('textbox', { name: 'Name' }))
.toHaveValue('Test Proclamation');
});
it('pre-fills select values when editing an existing record', async () => {
render(ProclamationForm, {
record: {
id: 'proc-1',
name: 'Test Proclamation',
solfeggioFrequencyId: 2,
subscriptionLevelId: 2,
categoryId: 3,
parentCategoryId: 1
},
relationOptionsData,
categories,
isSuper: true
});
const freqEl = (await page
.getByRole('combobox', { name: /^Frequency$/ })
.element()) as HTMLSelectElement;
const levelEl = (await page
.getByRole('combobox', { name: /^Subscription Level$/ })
.element()) as HTMLSelectElement;
expect(freqEl.value).toBe('2');
expect(levelEl.value).toBe('2');
});
it('shows content field when isSuper is true', async () => {
render(ProclamationForm, {
record: { id: 'proc-1', name: 'Test', content: '<p>Secret</p>' },
relationOptionsData,
categories,
isSuper: true
});
await expect.element(page.getByText('Content')).toBeInTheDocument();
await expect.element(page.getByTestId('tiptap-mock')).toBeInTheDocument();
});
it('hides content field when isSuper is false', async () => {
render(ProclamationForm, {
record: { id: 'proc-1', name: 'Test', content: '<p>Secret</p>' },
relationOptionsData,
categories,
isSuper: false
});
await expect.element(page.getByText('Content')).not.toBeInTheDocument();
});
it('disables submit button when editing and not dirty', async () => {
render(ProclamationForm, {
record: { id: 'proc-1', name: 'Test' },
relationOptionsData,
categories,
isSuper: true,
submitLabel: 'Update'
});
await expect.element(page.getByRole('button', { name: 'Update' })).toBeDisabled();
});
it('enables submit button when editing and user modifies a field', async () => {
render(ProclamationForm, {
record: { id: 'proc-1', name: 'Test' },
relationOptionsData,
categories,
isSuper: true,
submitLabel: 'Update'
});
await page.getByRole('textbox', { name: 'Name' }).fill('New Name');
await expect.element(page.getByRole('button', { name: 'Update' })).toBeEnabled();
});
});
@@ -1,6 +1,5 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { isHttpError } from '@sveltejs/kit';
import { createLocals, mockUser, createFormRequest } from '$lib/test-utils';
import { createLocals, mockUser, createFormRequest, mockProclamation } from '$lib/test-utils';
vi.mock('$lib/db', () => ({
prisma: {
@@ -11,29 +10,44 @@ vi.mock('$lib/db', () => ({
update: vi.fn(),
delete: vi.fn()
},
solfeggioFrequency: {
findMany: vi.fn()
},
proclamationCategory: {
findMany: vi.fn()
},
proclamationGroup: {
findMany: vi.fn()
}
}
}));
vi.mock('$lib/svelte-admin/crud', () => ({
loadRecords: vi.fn(),
deleteRecord: vi.fn(),
superFormErrorsToFieldErrors: vi.fn(() => ({ error: '', fieldErrors: {} }))
}));
import { prisma } from '$lib/db';
import { loadRecords, deleteRecord, superFormErrorsToFieldErrors } from '$lib/svelte-admin/crud';
import { load, actions } from './+page.server';
const mockLoadRecords = loadRecords as ReturnType<typeof vi.fn>;
const mockDeleteRecord = deleteRecord as ReturnType<typeof vi.fn>;
const mockSuperFormErrorsToFieldErrors = superFormErrorsToFieldErrors as ReturnType<typeof vi.fn>;
const mockProclamationFindMany = prisma.proclamation.findMany as ReturnType<typeof vi.fn>;
const mockProclamationFindUnique = prisma.proclamation.findUnique as ReturnType<typeof vi.fn>;
const mockProclamationCreate = prisma.proclamation.create as ReturnType<typeof vi.fn>;
const mockProclamationUpdate = prisma.proclamation.update as ReturnType<typeof vi.fn>;
const mockProclamationDelete = prisma.proclamation.delete as ReturnType<typeof vi.fn>;
const mockFreqFindMany = prisma.solfeggioFrequency.findMany as ReturnType<typeof vi.fn>;
const mockCatFindMany = prisma.proclamationCategory.findMany as ReturnType<typeof vi.fn>;
const mockFindUnique = prisma.proclamation.findUnique as ReturnType<typeof vi.fn>;
const mockCreate = prisma.proclamation.create as ReturnType<typeof vi.fn>;
const mockUpdate = prisma.proclamation.update as ReturnType<typeof vi.fn>;
const mockGroupFindMany = prisma.proclamationGroup.findMany as ReturnType<typeof vi.fn>;
const frequencies = [{ id: 1, name: 'UT', value: 396 }];
const categories = [{ id: 1, name: 'Healing' }];
const groups = [{ id: 1, name: 'General' }];
function validRequest(overrides: Record<string, string> = {}) {
return createFormRequest({
name: 'Test Proclamation',
solfeggioFrequencyId: '1',
categoryId: '1',
groupId: '1',
content: '<p>Hello</p>',
...overrides
});
}
describe('proclamations admin page server', () => {
beforeEach(() => {
@@ -45,37 +59,42 @@ describe('proclamations admin page server', () => {
{ id: '1', name: 'A', content: '<p>secret</p>' },
{ id: '2', name: 'B', content: '<p>secret2</p>' }
];
const relationOptionsData = {};
const categories = [{ id: 1, name: 'Healing', parentId: null }];
beforeEach(() => {
mockProclamationFindMany.mockResolvedValue(recordsWithContent);
mockFreqFindMany.mockResolvedValue(frequencies);
mockCatFindMany.mockResolvedValue(categories);
mockGroupFindMany.mockResolvedValue(groups);
});
it('strips content for non-Super users', async () => {
mockLoadRecords.mockResolvedValue({ records: recordsWithContent, relationOptionsData });
mockCatFindMany.mockResolvedValue(categories);
const locals = createLocals(mockUser({ roleId: 4 }) as any);
const result = await load({ locals } as any);
expect(mockProclamationFindMany).toHaveBeenCalledWith({
include: { category: true, group: true, solfeggioFrequency: true },
orderBy: { name: 'asc' }
});
expect(result).toEqual({
records: [
{ id: '1', name: 'A' },
{ id: '2', name: 'B' }
],
relationOptionsData,
categories
frequencies,
categories,
groups
});
});
it('keeps content for Super users', async () => {
mockLoadRecords.mockResolvedValue({ records: recordsWithContent, relationOptionsData });
mockCatFindMany.mockResolvedValue(categories);
const locals = createLocals(mockUser({ roleId: 1 }) as any);
const result = await load({ locals } as any);
expect(result).toEqual({
records: recordsWithContent,
relationOptionsData,
categories
frequencies,
categories,
groups
});
});
});
@@ -86,7 +105,7 @@ describe('proclamations admin page server', () => {
const req = createFormRequest({ name: 'test' });
const result = await actions.upsert({ request: req, locals } as any);
expect(result).toEqual({ success: false, error: 'Unauthorized' });
expect(result).toEqual({ success: false, action: 'upsert', error: 'Unauthorized' });
});
it('rejects non-Super users', async () => {
@@ -96,40 +115,133 @@ describe('proclamations admin page server', () => {
const result = await actions.upsert({ request: req, locals } as any);
expect(result).toEqual({
success: false,
action: 'upsert',
error: 'Only Super admins can edit proclamations'
});
});
it('returns field errors for invalid input', async () => {
const locals = createLocals(mockUser({ roleId: 1 }) as any);
const req = validRequest({ solfeggioFrequencyId: 'not-a-number' });
const result = await actions.upsert({ request: req, locals } as any);
expect(result.success).toBe(false);
expect(
(result as { fieldErrors: Record<string, string> }).fieldErrors.solfeggioFrequencyId
).toBeDefined();
});
it('creates a proclamation for a Super user', async () => {
mockProclamationCreate.mockResolvedValue({ id: 'p1' });
const locals = createLocals(mockUser({ roleId: 1 }) as any);
const result = await actions.upsert({ request: validRequest(), locals } as any);
expect(result).toEqual({ success: true, action: 'upsert' });
expect(mockProclamationCreate).toHaveBeenCalledWith({
data: {
name: 'Test Proclamation',
solfeggioFrequencyId: 1,
categoryId: 1,
groupId: 1,
content: '<p>Hello</p>'
}
});
});
it('updates an existing proclamation for a Super user', async () => {
mockProclamationFindUnique.mockResolvedValue(mockProclamation());
mockProclamationUpdate.mockResolvedValue({});
const locals = createLocals(mockUser({ roleId: 1 }) as any);
const result = await actions.upsert({
request: validRequest({ id: 'proc-1', name: 'Renamed' }),
locals
} as any);
expect(result).toEqual({ success: true, action: 'upsert' });
expect(mockProclamationUpdate).toHaveBeenCalledWith({
where: { id: 'proc-1' },
data: {
name: 'Renamed',
solfeggioFrequencyId: 1,
categoryId: 1,
groupId: 1,
content: '<p>Hello</p>'
}
});
});
it('returns record-not-found for a missing id', async () => {
mockProclamationFindUnique.mockResolvedValue(null);
const locals = createLocals(mockUser({ roleId: 1 }) as any);
const result = await actions.upsert({
request: validRequest({ id: 'missing' }),
locals
} as any);
expect(result).toEqual({ success: false, action: 'upsert', error: 'Record not found' });
});
it('returns a friendly error on duplicate name (P2002)', async () => {
mockProclamationCreate.mockRejectedValue({ code: 'P2002' });
const locals = createLocals(mockUser({ roleId: 1 }) as any);
const result = await actions.upsert({ request: validRequest(), locals } as any);
expect(result).toEqual({
success: false,
action: 'upsert',
error: 'A record with this name already exists.'
});
});
});
describe('delete action', () => {
it('rejects non-Super users', async () => {
const locals = createLocals(mockUser({ roleId: 4 }) as any);
const req = new Request('http://localhost', {
method: 'POST',
headers: { 'content-type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({ id: '1' })
});
const req = createFormRequest({ id: '1' });
const result = await actions.delete({ request: req, locals } as any);
expect(result).toEqual({
success: false,
action: 'delete',
error: 'Only Super admins can delete proclamations'
});
});
it('delegates to deleteRecord for Super users', async () => {
mockDeleteRecord.mockResolvedValue({ success: true });
it('returns an error when id is missing', async () => {
const locals = createLocals(mockUser({ roleId: 1 }) as any);
const req = new Request('http://localhost', {
method: 'POST',
headers: { 'content-type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({ id: '1' })
});
const req = createFormRequest({});
const result = await actions.delete({ request: req, locals } as any);
expect(mockDeleteRecord).toHaveBeenCalled();
expect(result).toEqual({ success: true });
expect(result).toEqual({ success: false, action: 'delete', error: 'Missing id' });
});
it('deletes a proclamation for a Super user', async () => {
mockProclamationDelete.mockResolvedValue({});
const locals = createLocals(mockUser({ roleId: 1 }) as any);
const req = createFormRequest({ id: 'proc-1' });
const result = await actions.delete({ request: req, locals } as any);
expect(result).toEqual({ success: true, action: 'delete' });
expect(mockProclamationDelete).toHaveBeenCalledWith({ where: { id: 'proc-1' } });
});
it('returns a friendly error when the proclamation is assigned to users (P2003)', async () => {
mockProclamationDelete.mockRejectedValue({ code: 'P2003' });
const locals = createLocals(mockUser({ roleId: 1 }) as any);
const req = createFormRequest({ id: 'proc-1' });
const result = await actions.delete({ request: req, locals } as any);
expect(result).toEqual({
success: false,
action: 'delete',
error: 'Cannot delete: this proclamation is assigned to users.'
});
});
});
});
@@ -0,0 +1,168 @@
import { page } from 'vitest/browser';
import { describe, it, expect, vi } from 'vitest';
import { render } from 'vitest-browser-svelte';
import type { PageProps } from './$types';
vi.mock('$app/forms', () => ({
enhance: () => () => {}
}));
import Page from './+page.svelte';
const frequencies = [
{ id: 1, name: 'UT', value: 396 },
{ id: 2, name: 'RE', value: 417 }
];
const categories = [
{ id: 1, name: 'Healing' },
{ id: 2, name: 'Manifestation' }
];
const groups = [
{ id: 1, name: 'General' },
{ id: 2, name: 'Specific' }
];
const superAdmin = {
id: 'admin-1',
name: 'Super Admin',
roleId: 1,
roleName: 'SUPER'
};
function makeData(overrides: Partial<PageProps['data']> = {}): PageProps['data'] {
return {
records: [],
frequencies,
categories,
groups,
adminUser: superAdmin,
userRoleId: 1,
...overrides
};
}
function mountPage(dataOverrides: Partial<PageProps['data']> = {}) {
return render(Page, { params: {}, data: makeData(dataOverrides), form: null });
}
const editRecord = {
id: 'proc-1',
name: 'Test Proclamation',
content: '<p>Secret</p>',
solfeggioFrequencyId: 2,
categoryId: 1,
groupId: 2,
category: { name: 'Healing' },
group: { name: 'Specific' },
solfeggioFrequency: { name: 'RE' }
};
async function clickButton(name: string) {
const loc = page.getByRole('button', { name });
const el = (await loc.element()) as HTMLElement;
el.click();
}
async function clickCell(name: string) {
const loc = page.getByRole('cell', { name });
const el = (await loc.element()) as HTMLElement;
el.click();
}
async function openCreateModal() {
await clickButton('New Proclamation');
}
async function openEditModal() {
await clickCell('Test Proclamation');
}
describe('proclamations admin page (super user)', () => {
it('renders all basic fields in create mode', async () => {
mountPage();
await openCreateModal();
await expect.element(page.getByRole('textbox', { name: 'Name' })).toBeInTheDocument();
await expect.element(page.getByRole('combobox', { name: /^Frequency$/ })).toBeInTheDocument();
await expect.element(page.getByRole('combobox', { name: /^Category$/ })).toBeInTheDocument();
await expect.element(page.getByRole('combobox', { name: /^Group$/ })).toBeInTheDocument();
await expect
.element(page.getByRole('button', { name: 'Create Proclamation' }))
.toBeInTheDocument();
});
it('pre-fills name input when editing an existing record', async () => {
mountPage({ records: [editRecord] });
await openEditModal();
await expect
.element(page.getByRole('textbox', { name: 'Name' }))
.toHaveValue('Test Proclamation');
});
it('pre-fills select values when editing an existing record', async () => {
mountPage({ records: [editRecord] });
await openEditModal();
const freqEl = (await page
.getByRole('combobox', { name: /^Frequency$/ })
.element()) as HTMLSelectElement;
const categoryEl = (await page
.getByRole('combobox', { name: /^Category$/ })
.element()) as HTMLSelectElement;
const groupEl = (await page
.getByRole('combobox', { name: /^Group$/ })
.element()) as HTMLSelectElement;
expect(freqEl.value).toBe('2');
expect(categoryEl.value).toBe('1');
expect(groupEl.value).toBe('2');
});
it('keeps group selection independent of category selection (movable filter)', async () => {
mountPage({ records: [editRecord] });
await openEditModal();
const categoryEl = (await page
.getByRole('combobox', { name: /^Category$/ })
.element()) as HTMLSelectElement;
const groupEl = (await page
.getByRole('combobox', { name: /^Group$/ })
.element()) as HTMLSelectElement;
categoryEl.value = '2';
categoryEl.dispatchEvent(new Event('change', { bubbles: true }));
expect(categoryEl.value).toBe('2');
expect(groupEl.value).toBe('2');
});
it('shows content editor when isSuper is true', async () => {
mountPage({ records: [editRecord] });
await openEditModal();
await expect.element(page.getByText('Content')).toBeInTheDocument();
await expect.element(page.getByTestId('tiptap-editor')).toBeInTheDocument();
});
});
describe('proclamations admin page (non-super user)', () => {
it('hides create/edit entry points and content field from non-super users', async () => {
mountPage({
records: [editRecord],
adminUser: { ...superAdmin, id: 'admin-2', roleId: 2, roleName: 'ADMIN' }
});
await expect
.element(page.getByRole('button', { name: 'New Proclamation' }))
.not.toBeInTheDocument();
await expect.element(page.getByText('Content')).not.toBeInTheDocument();
});
});
@@ -1,49 +1,146 @@
import type { PageServerLoad } from './$types';
import { getModelConfig } from '$lib/svelte-admin/registry';
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/server';
import { zod4 } from 'sveltekit-superforms/adapters';
import * as Schemas from '$lib/svelte-admin/schemas';
import { z } from 'zod';
const config = getModelConfig('profiles')!;
import { superFormErrorsToFieldErrors } from '$lib/superform-errors';
export const load: PageServerLoad = async ({ locals }) => {
return loadRecords(locals, config);
const email = z.string().email('Invalid email address');
const phone = z
.string()
.min(10, 'Phone number must be at least 10 digits')
.regex(/^[\d\-\+\(\) ]+$/, 'Invalid phone number format');
const name = z.string().min(1, 'Required').max(100, 'Too long');
const ProfileSchema = z.object({
email,
firstName: name,
lastName: name,
phoneNumber: phone
});
const AddressSchema = z.object({
profileId: z.string().min(1),
street: z.string().min(1, 'Street is required'),
city: z.string().min(1, 'City is required'),
state: z.string().min(1, 'State is required'),
postalCode: z.string().min(1, 'Postal code is required'),
country: z.string().min(1, 'Country is required'),
type: z.enum(['Mailing', 'Billing', 'Shipping']).default('Mailing'),
isDefaultMailing: z.coerce.boolean().default(false),
isDefaultBilling: z.coerce.boolean().default(false),
isDefaultShipping: z.coerce.boolean().default(false)
});
export const load: PageServerLoad = async () => {
const records = await prisma.profile.findMany({
include: { user: true, addresses: true },
orderBy: { id: 'asc' }
});
return {
records: records as unknown as Record<string, unknown>[]
};
};
export const actions = {
upsert: async ({ request, locals }) => {
return upsertRecord(request, config, locals);
},
delete: async ({ request, locals }) => {
return deleteRecord(request, config, locals);
},
upsertAddress: async ({ request, locals }) => {
if (!locals.user) throw error(401, 'Unauthorized');
const form = await superValidate(request, zod4(Schemas.Address));
upsert: async ({ request }) => {
const formData = await request.formData();
const form = await superValidate(formData, zod4(ProfileSchema));
if (!form.valid) {
const { error: addrError, fieldErrors } = superFormErrorsToFieldErrors(
const { error: formError, fieldErrors } = superFormErrorsToFieldErrors(
form.errors as Record<string, unknown>
);
return { success: false, addressError: addrError, addressFieldErrors: fieldErrors };
return { success: false, action: 'upsert', error: formError, fieldErrors };
}
const rawId = formData.get('id')?.toString();
const data = {
email: form.data.email,
firstName: form.data.firstName,
lastName: form.data.lastName,
phoneNumber: form.data.phoneNumber
};
if (rawId) {
const existing = await prisma.profile.findUnique({
where: { id: rawId },
include: { user: true }
});
if (!existing) {
return { success: false, action: 'upsert', error: 'Record not found' };
}
const updateData: Record<string, unknown> = { ...data };
if (existing.userId) {
if (existing.user?.email !== data.email) {
const conflict = await prisma.user.findFirst({
where: { email: data.email, id: { not: existing.userId } }
});
if (conflict) {
return {
success: false,
action: 'upsert',
error: 'That email already belongs to another user.'
};
}
await prisma.user.update({ where: { id: existing.userId }, data: { email: data.email } });
}
} else {
const match = await prisma.user.findUnique({ where: { email: data.email } });
if (match) updateData.userId = match.id;
}
await prisma.profile.update({ where: { id: rawId }, data: updateData });
return { success: true, action: 'upsert' };
}
try {
const user = await prisma.user.findUnique({ where: { email: data.email } });
await prisma.profile.create({ data: { ...data, ...(user ? { userId: user.id } : {}) } });
} catch (e: unknown) {
if (e && typeof e === 'object' && 'code' in e && (e as { code: string }).code === 'P2002') {
return {
success: false,
action: 'upsert',
error: 'A profile for this email already exists.'
};
}
throw e;
}
return { success: true, action: 'upsert' };
},
delete: async ({ request }) => {
const formData = await request.formData();
const rawId = formData.get('id')?.toString();
if (!rawId) return { success: false, error: 'Missing id' };
await prisma.profile.delete({ where: { id: rawId } });
return { success: true, action: 'delete' };
},
upsertAddress: async ({ request }) => {
const formData = await request.formData();
const form = await superValidate(formData, zod4(AddressSchema));
if (!form.valid) {
const { error: addressError, fieldErrors } = superFormErrorsToFieldErrors(
form.errors as Record<string, unknown>
);
return {
success: false,
action: 'upsertAddress',
addressError,
addressFieldErrors: fieldErrors
};
}
const { profileId, isDefaultMailing, isDefaultBilling, isDefaultShipping, ...addressData } =
form.data;
if (!profileId) {
return { success: false, addressError: 'Missing profile ID' };
return { success: false, action: 'upsertAddress', addressError: 'Missing profile ID' };
}
// Unique-default enforcement: clear defaults of same type before setting
if (isDefaultMailing) {
await prisma.address.updateMany({
where: { profileId, isDefaultMailing: true },
@@ -72,32 +169,6 @@ export const actions = {
};
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 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 };
}
const { id, email, name } = form.data;
if (!id) {
return { success: false, userError: 'Missing user ID' };
}
const data: Record<string, string> = {};
if (email) data.email = email;
if (name) data.name = name;
if (Object.keys(data).length === 0) {
return { success: false, userError: 'No fields to update' };
}
await prisma.user.update({ where: { id }, data });
return { success: true };
return { success: true, action: 'upsertAddress', address: created };
}
};
@@ -1,487 +1,59 @@
<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 AdminModal from '$lib/components/admin/AdminModal.svelte';
import Toast from '$lib/components/admin/Toast.svelte';
import { enhance } from '$app/forms';
import { getModelConfig } from '$lib/svelte-admin/registry';
import DataTable, { type DataTableColumn } from '$lib/components/DataTable.svelte';
import CreateProfileModal from './CreateProfileModal.svelte';
import EditProfileModal from './EditProfileModal.svelte';
let { data, form }: PageProps = $props();
const config = getModelConfig('profiles')!;
let canDelete = $derived((data.adminUser?.roleId ?? 4) <= 2);
let modalOpen = $state(false);
let editingRecord = $state<Record<string, unknown> | null>(null);
let toastMessage = $state('');
let toastType = $state<'success' | 'error'>('success');
let fieldErrors = $state<Record<string, string>>({});
let formError = $state('');
let addressFormOpen = $state(false);
let userFormOpen = $state(false);
let addressError = $state('');
let userError = $state('');
const columns: DataTableColumn[] = [
{ label: 'First Name', value: (r) => r.firstName },
{ label: 'Last Name', value: (r) => r.lastName },
{
label: 'Email',
value: (r) => (r.user as Record<string, unknown> | undefined)?.email ?? r.email ?? ''
},
{ label: 'Phone', value: (r) => r.phoneNumber },
{ label: 'User', value: (r) => (r.user as Record<string, unknown> | undefined)?.name ?? '' }
];
let addressFields = $state<Record<string, string>>({
street: '',
city: '',
state: '',
postalCode: '',
country: '',
isDefaultMailing: 'false',
isDefaultBilling: 'false',
isDefaultShipping: 'false'
});
let userEmail = $state('');
let userName = $state('');
$effect(() => {
if (form) {
if ((form as any).addressError) {
addressError = (form as any).addressError;
} else if ((form as any).userError) {
userError = (form as any).userError;
} else if (form.success) {
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>[]) ?? [];
editingRecord = {
...editingRecord,
addresses: [...addrs, addr]
};
}
addressFormOpen = false;
addressError = '';
} else {
toastMessage = 'Saved successfully';
closeModal();
}
} else if (!(form as any).addressError && !(form as any).userError) {
formError = (form as ActionResult).error ?? '';
fieldErrors = (form as ActionResult).fieldErrors ?? {};
}
}
});
let createOpen = $state(false);
let editRecord = $state<Record<string, unknown> | null>(null);
function openCreate() {
editingRecord = null;
formError = '';
fieldErrors = {};
addressFormOpen = false;
userFormOpen = false;
modalOpen = true;
createOpen = true;
}
function openEdit(record: Record<string, unknown>) {
editingRecord = record;
formError = '';
fieldErrors = {};
addressFormOpen = false;
userFormOpen = false;
addressError = '';
userError = '';
userEmail = ((record.user as Record<string, unknown>)?.email as string) ?? '';
userName = ((record.user as Record<string, unknown>)?.name as string) ?? '';
modalOpen = true;
editRecord = record;
}
function closeModal() {
modalOpen = false;
editingRecord = null;
formError = '';
fieldErrors = {};
addressFormOpen = false;
userFormOpen = false;
addressError = '';
userError = '';
function closeCreate() {
createOpen = false;
}
function toggleAddressForm() {
addressFormOpen = !addressFormOpen;
addressError = '';
}
function toggleUserForm() {
userFormOpen = !userFormOpen;
userError = '';
function closeEdit() {
editRecord = null;
}
</script>
<div class="p-6">
<h1 class="mb-4 text-xl font-bold text-gray-900 dark:text-gray-100">{config.displayName}</h1>
<AdminTable
<div>
<DataTable
title="User Profiles"
{columns}
records={data.records}
listFields={config.listFields}
searchFields={config.searchFields}
fieldTypes={config.fieldTypes}
singular={config.displayNameSingular}
newLabel="New Profile"
searchPlaceholder="Search profiles..."
onRowClick={openEdit}
onNew={openCreate}
/>
</div>
<AdminModal
title={editingRecord
? `Edit ${config.displayNameSingular}`
: `Create ${config.displayNameSingular}`}
open={modalOpen}
onClose={closeModal}
{canDelete}
recordId={editingRecord?.id as string | undefined}
>
<form method="POST" action="?/upsert" use:enhance>
{#if editingRecord?.id}
<input type="hidden" name="id" value={String(editingRecord.id)} />
{/if}
{#if formError}
<div class="mb-4 border border-red-300 bg-red-50 px-4 py-2 text-sm text-red-700">
{formError}
</div>
{/if}
<div class="flex flex-col gap-4">
<div>
<label
for="field-userId"
class="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300"
>
User
</label>
<select
id="field-userId"
name="userId"
value={String(editingRecord?.userId ?? '')}
class="w-full border px-3 py-1.5 text-sm {fieldErrors.userId
? 'border-red-400'
: 'border-gray-300 dark:border-gray-600'} dark:bg-gray-800 dark:text-gray-100"
>
<option value="">-- Select User --</option>
{#each data.relationOptionsData.userId ?? [] as opt (String(opt.value))}
<option value={opt.value}>{opt.label}</option>
{/each}
</select>
{#if fieldErrors.userId}
<p class="mt-1 text-xs text-red-600">{fieldErrors.userId}</p>
{/if}
</div>
<div>
<label
for="field-firstName"
class="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300"
>
First Name
</label>
<input
type="text"
id="field-firstName"
name="firstName"
value={String(editingRecord?.firstName ?? '')}
class="w-full border px-3 py-1.5 text-sm {fieldErrors.firstName
? 'border-red-400'
: 'border-gray-300 dark:border-gray-600'} dark:bg-gray-800 dark:text-gray-100"
/>
{#if fieldErrors.firstName}
<p class="mt-1 text-xs text-red-600">{fieldErrors.firstName}</p>
{/if}
</div>
<div>
<label
for="field-lastName"
class="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300"
>
Last Name
</label>
<input
type="text"
id="field-lastName"
name="lastName"
value={String(editingRecord?.lastName ?? '')}
class="w-full border px-3 py-1.5 text-sm {fieldErrors.lastName
? 'border-red-400'
: 'border-gray-300 dark:border-gray-600'} dark:bg-gray-800 dark:text-gray-100"
/>
{#if fieldErrors.lastName}
<p class="mt-1 text-xs text-red-600">{fieldErrors.lastName}</p>
{/if}
</div>
<div>
<label
for="field-phoneNumber"
class="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300"
>
Phone Number
</label>
<input
type="text"
id="field-phoneNumber"
name="phoneNumber"
value={String(editingRecord?.phoneNumber ?? '')}
class="w-full border px-3 py-1.5 text-sm {fieldErrors.phoneNumber
? 'border-red-400'
: 'border-gray-300 dark:border-gray-600'} dark:bg-gray-800 dark:text-gray-100"
/>
{#if fieldErrors.phoneNumber}
<p class="mt-1 text-xs text-red-600">{fieldErrors.phoneNumber}</p>
{/if}
</div>
<div class="flex justify-end gap-2 pt-2">
<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"
>
{editingRecord ? 'Update' : 'Create'}
</button>
</div>
</div>
</form>
<CreateProfileModal open={createOpen} onClose={closeCreate} {form} />
{#if editingRecord}
{@const user = editingRecord.user as Record<string, unknown> | undefined}
<div class="mt-6 border-t border-gray-200 pt-4 dark:border-gray-700">
<div class="mb-2 flex items-center justify-between">
<h3 class="text-sm font-semibold text-gray-700 dark:text-gray-300">User</h3>
<button
type="button"
onclick={toggleUserForm}
class="text-xs font-medium text-blue-600 hover:text-blue-800 dark:text-blue-400"
>
{userFormOpen ? 'Cancel' : 'Edit User'}
</button>
</div>
<p class="mb-1 text-sm text-gray-600 dark:text-gray-400">
{(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"
>
<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">
{userError}
</div>
{/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"
>
Name
</label>
<input
type="text"
id="user-name"
name="name"
value={userName}
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"
/>
</div>
<div>
<label
for="user-email"
class="mb-0.5 block text-xs font-medium text-gray-600 dark:text-gray-400"
>
Email
</label>
<input
type="email"
id="user-email"
name="email"
value={userEmail}
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"
/>
</div>
<div class="flex justify-end">
<button
type="submit"
class="border border-gray-300 bg-gray-100 px-3 py-1 text-xs font-medium hover:bg-gray-200 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-300 dark:hover:bg-gray-700"
>
Save User
</button>
</div>
</div>
</form>
{/if}
</div>
{/if}
{#if editingRecord}
{@const addresses = (editingRecord.addresses as Record<string, unknown>[]) ?? []}
<div class="mt-6 border-t border-gray-200 pt-4 dark:border-gray-700">
<div class="mb-2 flex items-center justify-between">
<h3 class="text-sm font-semibold text-gray-700 dark:text-gray-300">Addresses</h3>
<button
type="button"
onclick={toggleAddressForm}
class="text-xs font-medium text-blue-600 hover:text-blue-800 dark:text-blue-400"
>
{addressFormOpen ? 'Cancel' : 'Add Address +'}
</button>
</div>
{#if addressFormOpen}
<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">
{addressError}
</div>
{/if}
<div class="grid grid-cols-2 gap-2">
<div>
<label
for="addr-street"
class="mb-0.5 block text-xs font-medium text-gray-600 dark:text-gray-400"
>
Street
</label>
<input
type="text"
id="addr-street"
name="street"
required
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"
/>
</div>
<div>
<label
for="addr-city"
class="mb-0.5 block text-xs font-medium text-gray-600 dark:text-gray-400"
>
City
</label>
<input
type="text"
id="addr-city"
name="city"
required
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"
/>
</div>
<div>
<label
for="addr-state"
class="mb-0.5 block text-xs font-medium text-gray-600 dark:text-gray-400"
>
State
</label>
<input
type="text"
id="addr-state"
name="state"
required
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"
/>
</div>
<div>
<label
for="addr-postal"
class="mb-0.5 block text-xs font-medium text-gray-600 dark:text-gray-400"
>
Postal Code
</label>
<input
type="text"
id="addr-postal"
name="postalCode"
required
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"
/>
</div>
<div>
<label
for="addr-country"
class="mb-0.5 block text-xs font-medium text-gray-600 dark:text-gray-400"
>
Country
</label>
<input
type="text"
id="addr-country"
name="country"
required
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"
/>
</div>
</div>
<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"
class="border border-gray-300 bg-gray-100 px-3 py-1 text-xs font-medium hover:bg-gray-200 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-300 dark:hover:bg-gray-700"
>
Save Address
</button>
</div>
</form>
{/if}
{#if addresses.length > 0}
<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">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-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">{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 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>
</table>
{:else}
<p class="text-sm text-gray-500">No addresses on file.</p>
{/if}
</div>
{/if}
</AdminModal>
{#if toastMessage}
<Toast message={toastMessage} type={toastType} />
{#if editRecord}
{#key editRecord.id}
<EditProfileModal record={editRecord} open={true} onClose={closeEdit} {canDelete} {form} />
{/key}
{/if}
@@ -0,0 +1,86 @@
<script lang="ts">
import { enhance } from '$app/forms';
import AdminModal from '$lib/components/admin/AdminModal.svelte';
import ProfileFields from './ProfileFields.svelte';
type ActionResult = {
success?: boolean;
action?: string;
error?: string;
fieldErrors?: Record<string, string>;
} | null;
let {
open = false,
onClose,
form
}: {
open?: boolean;
onClose?: () => void;
form?: ActionResult;
} = $props();
let formError = $state('');
let fieldErrors = $state<Record<string, string>>({});
let successMessage = $state('');
let lastForm = $state<ActionResult>(null);
$effect(() => {
if (open) {
formError = '';
fieldErrors = {};
successMessage = '';
}
});
$effect(() => {
if (!form || form === lastForm) return;
lastForm = form;
if (form.success) {
formError = '';
fieldErrors = {};
successMessage = 'Profile created';
setTimeout(() => onClose?.(), 1200);
} else {
successMessage = '';
formError = form.error ?? '';
fieldErrors = form.fieldErrors ?? {};
}
});
</script>
<AdminModal title="Create Profile" {open} {onClose}>
{#if successMessage}
<div
class="mb-4 rounded-md border border-green-200 bg-green-50 px-3 py-2 text-sm text-green-700"
>
{successMessage}
</div>
{/if}
{#if formError}
<div class="mb-4 rounded-md border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600">
{formError}
</div>
{/if}
<form method="POST" action="?/upsert" use:enhance>
<ProfileFields record={null} {fieldErrors} />
<div class="mt-6 flex justify-end gap-3">
<button
type="button"
onclick={onClose}
class="rounded-md border border-slate-300 bg-white px-4 py-2 text-sm font-medium text-slate-700 transition hover:bg-slate-50"
>
Cancel
</button>
<button
type="submit"
class="rounded-md bg-indigo-600 px-4 py-2 text-sm font-medium text-white transition hover:bg-indigo-700"
>
Create Profile
</button>
</div>
</form>
</AdminModal>
@@ -0,0 +1,305 @@
<script lang="ts">
import { enhance } from '$app/forms';
import AdminModal from '$lib/components/admin/AdminModal.svelte';
import ProfileFields from './ProfileFields.svelte';
type ActionResult = {
success?: boolean;
action?: string;
error?: string;
fieldErrors?: Record<string, string>;
addressError?: string;
address?: Record<string, unknown>;
} | null;
let {
record,
open = false,
onClose,
canDelete = false,
form
}: {
record: Record<string, unknown>;
open?: boolean;
onClose?: () => void;
canDelete?: boolean;
form?: ActionResult;
} = $props();
let formError = $state('');
let fieldErrors = $state<Record<string, string>>({});
let addressError = $state('');
let successMessage = $state('');
let addressFormOpen = $state(false);
let addresses = $state<Record<string, unknown>[]>([]);
let addrStreet = $state('');
let addrCity = $state('');
let addrState = $state('');
let addrPostal = $state('');
let addrCountry = $state('');
let addrMailing = $state(false);
let addrBilling = $state(false);
let addrShipping = $state(false);
let lastRecordId = $state<string | null>(null);
let lastForm = $state<ActionResult>(null);
function resetAddressFields() {
addrStreet = '';
addrCity = '';
addrState = '';
addrPostal = '';
addrCountry = '';
addrMailing = false;
addrBilling = false;
addrShipping = false;
}
function seedRecord() {
addresses = (record.addresses as Record<string, unknown>[]) ?? [];
addressFormOpen = false;
addressError = '';
formError = '';
fieldErrors = {};
successMessage = '';
resetAddressFields();
}
$effect(() => {
if (open) {
seedRecord();
}
});
$effect(() => {
const id = String(record.id ?? '');
if (id === lastRecordId) return;
lastRecordId = id;
seedRecord();
});
$effect(() => {
if (!form || form === lastForm) return;
lastForm = form;
switch (form.action) {
case 'upsertAddress':
if (form.success && form.address) {
addresses = [...addresses, form.address];
addressFormOpen = false;
addressError = '';
successMessage = 'Address saved';
resetAddressFields();
} else {
successMessage = '';
addressError = form.addressError ?? '';
}
break;
case 'upsert':
default:
if (form.success) {
formError = '';
fieldErrors = {};
successMessage = 'Profile updated';
} else {
successMessage = '';
formError = form.error ?? '';
fieldErrors = form.fieldErrors ?? {};
}
break;
}
});
function toggleAddressForm() {
addressFormOpen = !addressFormOpen;
addressError = '';
if (addressFormOpen) resetAddressFields();
}
</script>
<AdminModal title="Edit Profile" {open} {onClose} {canDelete} recordId={String(record.id ?? '')}>
{#if successMessage}
<div
class="mb-4 rounded-md border border-green-200 bg-green-50 px-3 py-2 text-sm text-green-700"
>
{successMessage}
</div>
{/if}
{#if formError}
<div class="mb-4 rounded-md border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600">
{formError}
</div>
{/if}
<form method="POST" action="?/upsert" use:enhance>
<input type="hidden" name="id" value={String(record.id ?? '')} />
<ProfileFields {record} {fieldErrors} />
<div class="mt-6 flex justify-end gap-3">
<button
type="button"
onclick={onClose}
class="rounded-md border border-slate-300 bg-white px-4 py-2 text-sm font-medium text-slate-700 transition hover:bg-slate-50"
>
Cancel
</button>
<button
type="submit"
class="rounded-md bg-indigo-600 px-4 py-2 text-sm font-medium text-white transition hover:bg-indigo-700"
>
Save Profile
</button>
</div>
</form>
<div class="mt-8 border-t border-slate-200 pt-6">
<div class="flex items-center justify-between">
<h3 class="text-sm font-semibold text-slate-900">Addresses</h3>
<button
type="button"
onclick={toggleAddressForm}
class="rounded-md border border-slate-300 bg-white px-3 py-1.5 text-xs font-medium text-slate-700 transition hover:bg-slate-50"
>
{addressFormOpen ? 'Cancel' : 'Add Address +'}
</button>
</div>
{#if addressFormOpen}
<form method="POST" action="?/upsertAddress" use:enhance class="mt-4 flex flex-col gap-4">
<input type="hidden" name="profileId" value={String(record.id ?? '')} />
{#if addressError}
<div class="rounded-md border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600">
{addressError}
</div>
{/if}
<div class="grid gap-4 sm:grid-cols-2">
<div>
<label for="addr-street" class="mb-1 block text-sm font-medium text-slate-700"
>Street</label
>
<input
id="addr-street"
type="text"
name="street"
required
bind:value={addrStreet}
class="w-full rounded-md border border-slate-300 bg-white px-3 py-2 text-sm text-slate-900 shadow-sm transition focus:border-indigo-500 focus:outline-none"
/>
</div>
<div>
<label for="addr-city" class="mb-1 block text-sm font-medium text-slate-700">City</label
>
<input
id="addr-city"
type="text"
name="city"
required
bind:value={addrCity}
class="w-full rounded-md border border-slate-300 bg-white px-3 py-2 text-sm text-slate-900 shadow-sm transition focus:border-indigo-500 focus:outline-none"
/>
</div>
<div>
<label for="addr-state" class="mb-1 block text-sm font-medium text-slate-700"
>State</label
>
<input
id="addr-state"
type="text"
name="state"
required
bind:value={addrState}
class="w-full rounded-md border border-slate-300 bg-white px-3 py-2 text-sm text-slate-900 shadow-sm transition focus:border-indigo-500 focus:outline-none"
/>
</div>
<div>
<label for="addr-postal" class="mb-1 block text-sm font-medium text-slate-700"
>Postal Code</label
>
<input
id="addr-postal"
type="text"
name="postalCode"
required
bind:value={addrPostal}
class="w-full rounded-md border border-slate-300 bg-white px-3 py-2 text-sm text-slate-900 shadow-sm transition focus:border-indigo-500 focus:outline-none"
/>
</div>
<div>
<label for="addr-country" class="mb-1 block text-sm font-medium text-slate-700"
>Country</label
>
<input
id="addr-country"
type="text"
name="country"
required
bind:value={addrCountry}
class="w-full rounded-md border border-slate-300 bg-white px-3 py-2 text-sm text-slate-900 shadow-sm transition focus:border-indigo-500 focus:outline-none"
/>
</div>
</div>
<div class="flex flex-wrap gap-4 text-sm text-slate-700">
<label class="flex items-center gap-2">
<input type="checkbox" name="isDefaultMailing" bind:checked={addrMailing} />
Default Mailing
</label>
<label class="flex items-center gap-2">
<input type="checkbox" name="isDefaultBilling" bind:checked={addrBilling} />
Default Billing
</label>
<label class="flex items-center gap-2">
<input type="checkbox" name="isDefaultShipping" bind:checked={addrShipping} />
Default Shipping
</label>
</div>
<div class="flex justify-end">
<button
type="submit"
class="rounded-md bg-slate-800 px-4 py-2 text-sm font-medium text-white transition hover:bg-slate-700"
>
Save Address
</button>
</div>
</form>
{/if}
{#if addresses.length > 0}
<div class="mt-4 overflow-x-auto rounded-md border border-slate-200">
<table class="w-full min-w-max text-left text-sm text-slate-700">
<thead>
<tr
class="border-b border-slate-200 bg-slate-100 text-xs font-semibold tracking-wide text-slate-500"
>
<th scope="col" class="px-3 py-2">Street</th>
<th scope="col" class="px-3 py-2">City</th>
<th scope="col" class="px-3 py-2">State</th>
<th scope="col" class="px-3 py-2">Zip</th>
<th scope="col" class="px-3 py-2">Country</th>
<th scope="col" class="px-3 py-2">M</th>
<th scope="col" class="px-3 py-2">B</th>
<th scope="col" class="px-3 py-2">S</th>
</tr>
</thead>
<tbody>
{#each addresses as addr, i (String(addr.id ?? i))}
<tr class="bg-white">
<td class="px-3 py-2 whitespace-nowrap">{addr.street as string}</td>
<td class="px-3 py-2 whitespace-nowrap">{addr.city as string}</td>
<td class="px-3 py-2 whitespace-nowrap">{addr.state as string}</td>
<td class="px-3 py-2 whitespace-nowrap">{addr.postalCode as string}</td>
<td class="px-3 py-2 whitespace-nowrap">{addr.country as string}</td>
<td class="px-3 py-2 whitespace-nowrap">{addr.isDefaultMailing ? '✓' : ''}</td>
<td class="px-3 py-2 whitespace-nowrap">{addr.isDefaultBilling ? '✓' : ''}</td>
<td class="px-3 py-2 whitespace-nowrap">{addr.isDefaultShipping ? '✓' : ''}</td>
</tr>
{/each}
</tbody>
</table>
</div>
{:else}
<p class="mt-2 text-sm text-slate-600">No addresses on file.</p>
{/if}
</div>
</AdminModal>
@@ -0,0 +1,92 @@
<script lang="ts">
let {
record = null,
fieldErrors = {} as Record<string, string>
}: {
record?: Record<string, unknown> | null;
fieldErrors?: Record<string, string>;
} = $props();
let email = $state('');
let firstName = $state('');
let lastName = $state('');
let phoneNumber = $state('');
$effect(() => {
const rec = record ?? {};
const user = rec.user as Record<string, unknown> | undefined;
email = String((user?.email as string) ?? rec.email ?? '');
firstName = String(rec.firstName ?? '');
lastName = String(rec.lastName ?? '');
phoneNumber = String(rec.phoneNumber ?? '');
});
</script>
<div class="flex flex-col gap-4">
<div>
<label for="field-email" class="mb-1 block text-sm font-medium text-slate-700">Email</label>
<input
id="field-email"
type="email"
name="email"
required
bind:value={email}
placeholder="user@example.com"
class="w-full rounded-md border border-slate-300 bg-white px-3 py-2 text-sm text-slate-900 shadow-sm transition focus:border-indigo-500 focus:outline-none"
/>
{#if fieldErrors.email}
<p class="mt-1 text-sm text-red-600">{fieldErrors.email}</p>
{/if}
</div>
<div>
<label for="field-firstName" class="mb-1 block text-sm font-medium text-slate-700"
>First Name</label
>
<input
id="field-firstName"
type="text"
name="firstName"
required
bind:value={firstName}
class="w-full rounded-md border border-slate-300 bg-white px-3 py-2 text-sm text-slate-900 shadow-sm transition focus:border-indigo-500 focus:outline-none"
/>
{#if fieldErrors.firstName}
<p class="mt-1 text-sm text-red-600">{fieldErrors.firstName}</p>
{/if}
</div>
<div>
<label for="field-lastName" class="mb-1 block text-sm font-medium text-slate-700"
>Last Name</label
>
<input
id="field-lastName"
type="text"
name="lastName"
required
bind:value={lastName}
class="w-full rounded-md border border-slate-300 bg-white px-3 py-2 text-sm text-slate-900 shadow-sm transition focus:border-indigo-500 focus:outline-none"
/>
{#if fieldErrors.lastName}
<p class="mt-1 text-sm text-red-600">{fieldErrors.lastName}</p>
{/if}
</div>
<div>
<label for="field-phoneNumber" class="mb-1 block text-sm font-medium text-slate-700">
Phone Number
</label>
<input
id="field-phoneNumber"
type="text"
name="phoneNumber"
required
bind:value={phoneNumber}
class="w-full rounded-md border border-slate-300 bg-white px-3 py-2 text-sm text-slate-900 shadow-sm transition focus:border-indigo-500 focus:outline-none"
/>
{#if fieldErrors.phoneNumber}
<p class="mt-1 text-sm text-red-600">{fieldErrors.phoneNumber}</p>
{/if}
</div>
</div>
@@ -0,0 +1,233 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { createLocals, createFormRequest } from '$lib/test-utils';
vi.mock('$lib/db', () => ({
prisma: {
profile: {
findMany: vi.fn(),
findUnique: vi.fn(),
create: vi.fn(),
update: vi.fn(),
delete: vi.fn()
},
user: {
findUnique: vi.fn(),
findFirst: vi.fn(),
update: vi.fn()
}
}
}));
import { prisma } from '$lib/db';
import { load, actions } from './+page.server';
const mockProfileFindMany = prisma.profile.findMany as ReturnType<typeof vi.fn>;
const mockProfileFindUnique = prisma.profile.findUnique as ReturnType<typeof vi.fn>;
const mockProfileCreate = prisma.profile.create as ReturnType<typeof vi.fn>;
const mockProfileUpdate = prisma.profile.update as ReturnType<typeof vi.fn>;
const mockProfileDelete = prisma.profile.delete as ReturnType<typeof vi.fn>;
const mockUserFindUnique = prisma.user.findUnique as ReturnType<typeof vi.fn>;
const mockUserFindFirst = prisma.user.findFirst as ReturnType<typeof vi.fn>;
const mockUserUpdate = prisma.user.update as ReturnType<typeof vi.fn>;
const validFields = {
email: 'jane@example.com',
firstName: 'Jane',
lastName: 'Doe',
phoneNumber: '555-010-1234'
};
function makeProfileRequest(overrides: Record<string, string> = {}) {
return createFormRequest({ ...validFields, ...overrides });
}
describe('profiles admin page server', () => {
beforeEach(() => {
vi.clearAllMocks();
});
describe('load', () => {
it('returns profiles with user and addresses', async () => {
const records = [
{
id: 'p1',
email: null,
user: { email: 'jane@example.com', name: 'Jane Doe' },
addresses: []
}
];
mockProfileFindMany.mockResolvedValue(records);
const result = await load({} as any);
expect(mockProfileFindMany).toHaveBeenCalledWith({
include: { user: true, addresses: true },
orderBy: { id: 'asc' }
});
expect(result).toEqual({ records });
});
});
describe('upsert - create', () => {
it('links the profile to an existing user with the same email', async () => {
mockUserFindUnique.mockResolvedValue({ id: 'user-1', email: validFields.email });
mockProfileCreate.mockResolvedValue({ id: 'p-new' });
const result = await actions.upsert({ request: makeProfileRequest() } as any);
expect(result).toEqual({ success: true, action: 'upsert' });
expect(mockUserFindUnique).toHaveBeenCalledWith({ where: { email: validFields.email } });
expect(mockProfileCreate).toHaveBeenCalledWith({
data: { ...validFields, userId: 'user-1' }
});
});
it('stores the email on the profile when no user matches', async () => {
mockUserFindUnique.mockResolvedValue(null);
mockProfileCreate.mockResolvedValue({ id: 'p-new' });
const result = await actions.upsert({ request: makeProfileRequest() } as any);
expect(result).toEqual({ success: true, action: 'upsert' });
expect(mockProfileCreate).toHaveBeenCalledWith({ data: validFields });
});
it('returns a friendly error when the email already exists (P2002)', async () => {
mockUserFindUnique.mockResolvedValue(null);
mockProfileCreate.mockRejectedValue({ code: 'P2002' });
const result = await actions.upsert({ request: makeProfileRequest() } as any);
expect(result).toEqual({
success: false,
action: 'upsert',
error: 'A profile for this email already exists.'
});
});
});
describe('upsert - update', () => {
it('syncs the linked user email when it changes', async () => {
mockProfileFindUnique.mockResolvedValue({
id: 'p1',
userId: 'user-1',
email: 'jane@example.com',
user: { email: 'old@example.com' }
});
mockUserFindFirst.mockResolvedValue(null);
mockUserUpdate.mockResolvedValue({});
mockProfileUpdate.mockResolvedValue({});
const request = makeProfileRequest({ email: 'new@example.com', id: 'p1' });
const result = await actions.upsert({ request } as any);
expect(result).toEqual({ success: true, action: 'upsert' });
expect(mockUserUpdate).toHaveBeenCalledWith({
where: { id: 'user-1' },
data: { email: 'new@example.com' }
});
expect(mockProfileUpdate).toHaveBeenCalledWith({
where: { id: 'p1' },
data: { ...validFields, email: 'new@example.com' }
});
});
it('does not touch the user when the email is unchanged', async () => {
mockProfileFindUnique.mockResolvedValue({
id: 'p1',
userId: 'user-1',
email: 'jane@example.com',
user: { email: 'jane@example.com' }
});
mockProfileUpdate.mockResolvedValue({});
const request = makeProfileRequest({ id: 'p1' });
const result = await actions.upsert({ request } as any);
expect(result).toEqual({ success: true, action: 'upsert' });
expect(mockUserFindFirst).not.toHaveBeenCalled();
expect(mockUserUpdate).not.toHaveBeenCalled();
});
it('rejects a change to an email owned by another user', async () => {
mockProfileFindUnique.mockResolvedValue({
id: 'p1',
userId: 'user-1',
email: 'jane@example.com',
user: { email: 'old@example.com' }
});
mockUserFindFirst.mockResolvedValue({ id: 'user-2' });
const request = makeProfileRequest({ email: 'taken@example.com', id: 'p1' });
const result = await actions.upsert({ request } as any);
expect(result).toEqual({
success: false,
action: 'upsert',
error: 'That email already belongs to another user.'
});
expect(mockUserUpdate).not.toHaveBeenCalled();
expect(mockProfileUpdate).not.toHaveBeenCalled();
});
it('links the unlinked profile when a user matches the new email', async () => {
mockProfileFindUnique.mockResolvedValue({
id: 'p1',
userId: null,
email: 'jane@example.com',
user: null
});
mockUserFindUnique.mockResolvedValue({ id: 'user-9' });
mockProfileUpdate.mockResolvedValue({});
const request = makeProfileRequest({ id: 'p1' });
const result = await actions.upsert({ request } as any);
expect(result).toEqual({ success: true, action: 'upsert' });
expect(mockProfileUpdate).toHaveBeenCalledWith({
where: { id: 'p1' },
data: { ...validFields, userId: 'user-9' }
});
});
});
describe('upsert - validation', () => {
it('returns field errors for invalid input', async () => {
const request = makeProfileRequest({ email: 'not-an-email' });
const result = await actions.upsert({ request } as any);
expect(result.success).toBe(false);
expect(result.action).toBe('upsert');
expect((result as { fieldErrors: Record<string, string> }).fieldErrors.email).toBeDefined();
});
it('returns a record-not-found error for a missing profile', async () => {
mockProfileFindUnique.mockResolvedValue(null);
const request = makeProfileRequest({ id: 'missing' });
const result = await actions.upsert({ request } as any);
expect(result).toEqual({ success: false, action: 'upsert', error: 'Record not found' });
});
});
describe('delete', () => {
it('deletes the profile by id', async () => {
mockProfileDelete.mockResolvedValue({});
const locals = createLocals();
const req = createFormRequest({ id: 'p1' });
const result = await actions.delete({ request: req, locals } as any);
expect(result).toEqual({ success: true, action: 'delete' });
expect(mockProfileDelete).toHaveBeenCalledWith({ where: { id: 'p1' } });
});
it('returns an error when id is missing', async () => {
const locals = createLocals();
const result = await actions.delete({ request: createFormRequest({}), locals } as any);
expect(result).toEqual({ success: false, error: 'Missing id' });
});
});
});
@@ -1,18 +1,70 @@
import type { PageServerLoad } from './$types';
import { getModelConfig } from '$lib/svelte-admin/registry';
import { loadRecords, upsertRecord, deleteRecord } from '$lib/svelte-admin/crud';
import { prisma } from '$lib/db';
import { superValidate } from 'sveltekit-superforms/server';
import { zod4 } from 'sveltekit-superforms/adapters';
import { z } from 'zod';
import { superFormErrorsToFieldErrors } from '$lib/superform-errors';
const config = getModelConfig('solfeggio-frequencies')!;
const SolfeggioSchema = z.object({
name: z.string().min(1, 'Required').max(100, 'Too long'),
value: z.coerce.number().int().min(1, 'Value must be a positive integer')
});
export const load: PageServerLoad = async ({ locals }) => {
return loadRecords(locals, config);
export const load: PageServerLoad = async () => {
const records = await prisma.solfeggioFrequency.findMany({ orderBy: { id: 'asc' } });
return { records: records as unknown as Record<string, unknown>[] };
};
export const actions = {
upsert: async ({ request, locals }) => {
return upsertRecord(request, config, locals);
upsert: async ({ request }) => {
const formData = await request.formData();
const form = await superValidate(formData, zod4(SolfeggioSchema));
if (!form.valid) {
const { error: formError, fieldErrors } = superFormErrorsToFieldErrors(
form.errors as Record<string, unknown>
);
return { success: false, action: 'upsert', error: formError, fieldErrors };
}
const rawId = formData.get('id')?.toString();
const data = { name: form.data.name, value: form.data.value };
try {
if (rawId) {
await prisma.solfeggioFrequency.update({ where: { id: Number(rawId) }, data });
} else {
await prisma.solfeggioFrequency.create({ data });
}
} catch (e: unknown) {
if (e && typeof e === 'object' && 'code' in e && (e as { code: string }).code === 'P2002') {
return {
success: false,
action: 'upsert',
error: 'A frequency with that name or value already exists.'
};
}
throw e;
}
return { success: true, action: 'upsert' };
},
delete: async ({ request, locals }) => {
return deleteRecord(request, config, locals);
delete: async ({ request }) => {
const formData = await request.formData();
const rawId = formData.get('id')?.toString();
if (!rawId) return { success: false, error: 'Missing id' };
try {
await prisma.solfeggioFrequency.delete({ where: { id: Number(rawId) } });
} catch (e: unknown) {
if (e && typeof e === 'object' && 'code' in e && (e as { code: string }).code === 'P2003') {
return {
success: false,
error: 'Cannot delete: this frequency is used by proclamations.'
};
}
throw e;
}
return { success: true, action: 'delete' };
}
};
@@ -1,90 +1,167 @@
<script lang="ts">
import { enhance } from '$app/forms';
import type { PageProps } from './$types';
import type { ActionResult } from '$lib/svelte-admin/types';
import AdminTable from '$lib/components/admin/AdminTable.svelte';
import DataTable, { type DataTableColumn } from '$lib/components/DataTable.svelte';
import AdminModal from '$lib/components/admin/AdminModal.svelte';
import Toast from '$lib/components/admin/Toast.svelte';
import { getModelConfig } from '$lib/svelte-admin/registry';
import FreqForm from './FreqForm.svelte';
type ActionResult = {
success?: boolean;
action?: string;
error?: string;
fieldErrors?: Record<string, string>;
} | null;
let { data, form }: PageProps = $props();
let canDelete = $derived((data.adminUser?.roleId ?? 4) <= 2);
const config = getModelConfig('solfeggio-frequencies')!;
const canDelete = $derived((data.adminUser?.roleId ?? 4) <= 2);
const columns: DataTableColumn[] = [
{ label: 'Name', value: (r) => r.name },
{ label: 'Value', value: (r) => r.value }
];
let modalOpen = $state(false);
let editingRecord = $state<Record<string, unknown> | null>(null);
let toastMessage = $state('');
let toastType = $state<'success' | 'error'>('success');
let fieldErrors = $state<Record<string, string>>({});
let name = $state('');
let value = $state('');
let formError = $state('');
let fieldErrors = $state<Record<string, string>>({});
let successMessage = $state('');
let lastForm = $state<ActionResult>(null);
$effect(() => {
if (form && 'success' in (form as any)) {
const result = form as any;
if (result.success) {
toastMessage = 'Saved successfully';
toastType = 'success';
closeModal();
} else {
formError = result.error ?? '';
fieldErrors = result.fieldErrors ?? {};
}
const rec = editingRecord ?? {};
name = String(rec.name ?? '');
value = String(rec.value ?? '');
if (!editingRecord) {
formError = '';
fieldErrors = {};
successMessage = '';
}
});
$effect(() => {
if (!form || form === lastForm) return;
lastForm = form;
if (form.success) {
formError = '';
fieldErrors = {};
successMessage = editingRecord ? 'Frequency updated' : 'Frequency created';
setTimeout(() => {
modalOpen = false;
successMessage = '';
}, 1200);
} else {
successMessage = '';
formError = form.error ?? '';
fieldErrors = form.fieldErrors ?? {};
}
});
function openCreate() {
editingRecord = null;
formError = '';
fieldErrors = {};
lastForm = null;
modalOpen = true;
}
function openEdit(record: Record<string, unknown>) {
editingRecord = record;
formError = '';
fieldErrors = {};
lastForm = null;
modalOpen = true;
}
function closeModal() {
modalOpen = false;
editingRecord = null;
formError = '';
fieldErrors = {};
}
</script>
<div class="p-6">
<h1 class="mb-4 text-xl font-bold text-gray-900 dark:text-gray-100">{config.displayName}</h1>
<AdminTable
<div>
<DataTable
title="Solfeggio Frequencies"
{columns}
records={data.records}
listFields={config.listFields}
searchFields={config.searchFields}
fieldTypes={config.fieldTypes}
singular={config.displayNameSingular}
newLabel="New Frequency"
searchPlaceholder="Search frequencies..."
onRowClick={openEdit}
onNew={openCreate}
/>
</div>
<AdminModal
title={editingRecord
? `Edit ${config.displayNameSingular}`
: `Create ${config.displayNameSingular}`}
open={modalOpen}
onClose={closeModal}
{canDelete}
recordId={editingRecord?.id as string | undefined}
>
<FreqForm
record={editingRecord ?? {}}
{fieldErrors}
{formError}
submitLabel={editingRecord ? 'Update' : 'Create'}
/>
</AdminModal>
{#if modalOpen}
<AdminModal
title={editingRecord ? 'Edit Frequency' : 'Create Frequency'}
open={modalOpen}
onClose={closeModal}
{canDelete}
recordId={editingRecord?.id as string | undefined}
>
{#if successMessage}
<div
class="mb-4 rounded-md border border-green-200 bg-green-50 px-3 py-2 text-sm text-green-700"
>
{successMessage}
</div>
{/if}
{#if formError}
<div class="mb-4 rounded-md border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600">
{formError}
</div>
{/if}
{#if toastMessage}
<Toast message={toastMessage} type={toastType} />
<form method="POST" action="?/upsert" use:enhance class="flex flex-col gap-4">
{#if editingRecord}
<input type="hidden" name="id" value={String(editingRecord.id)} />
{/if}
<div>
<label for="field-name" class="mb-1 block text-sm font-medium text-slate-700">Name</label>
<input
id="field-name"
type="text"
name="name"
required
bind:value={name}
class="w-full rounded-md border border-slate-300 bg-white px-3 py-2 text-sm text-slate-900 shadow-sm transition focus:border-indigo-500 focus:outline-none"
/>
{#if fieldErrors.name}
<p class="mt-1 text-sm text-red-600">{fieldErrors.name}</p>
{/if}
</div>
<div>
<label for="field-value" class="mb-1 block text-sm font-medium text-slate-700">Value</label>
<input
id="field-value"
type="number"
name="value"
required
min="1"
step="1"
bind:value
class="w-full rounded-md border border-slate-300 bg-white px-3 py-2 text-sm text-slate-900 shadow-sm transition focus:border-indigo-500 focus:outline-none"
/>
{#if fieldErrors.value}
<p class="mt-1 text-sm text-red-600">{fieldErrors.value}</p>
{/if}
</div>
<div class="mt-2 flex justify-end gap-3">
<button
type="button"
onclick={closeModal}
class="rounded-md border border-slate-300 bg-white px-4 py-2 text-sm font-medium text-slate-700 transition hover:bg-slate-50"
>
Cancel
</button>
<button
type="submit"
class="rounded-md bg-indigo-600 px-4 py-2 text-sm font-medium text-white transition hover:bg-indigo-700"
>
{editingRecord ? 'Save Frequency' : 'Create Frequency'}
</button>
</div>
</form>
</AdminModal>
{/if}
@@ -1,96 +0,0 @@
<script lang="ts">
import { enhance } from '$app/forms';
let {
record = {} as Record<string, unknown>,
fieldErrors = {} as Record<string, string>,
formError = '',
action = '?/upsert',
submitLabel = 'Save'
}: {
record?: Record<string, unknown>;
fieldErrors?: Record<string, string>;
formError?: string;
action?: string;
submitLabel?: string;
} = $props();
let name = $state(String(record.name ?? ''));
let value = $state(String(record.value ?? ''));
let dirty = $state(false);
$effect(() => {
dirty = false;
});
function markDirty() {
if (record.id) dirty = true;
}
</script>
<form {action} method="POST" use:enhance oninput={markDirty}>
{#if record?.id}
<input type="hidden" name="id" value={String(record.id)} />
{/if}
{#if formError}
<div class="mb-4 border border-red-300 bg-red-50 px-4 py-2 text-sm text-red-700">
{formError}
</div>
{/if}
<div class="flex flex-col gap-4">
<div>
<label
for="field-name"
class="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300"
>
Name
</label>
<input
type="text"
id="field-name"
name="name"
bind:value={name}
class="w-full border px-3 py-1.5 text-sm {fieldErrors.name
? 'border-red-400'
: 'border-gray-300 dark:border-gray-600'} dark:bg-gray-800 dark:text-gray-100"
/>
{#if fieldErrors.name}
<p class="mt-1 text-xs text-red-600">{fieldErrors.name}</p>
{/if}
</div>
<div>
<label
for="field-value"
class="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300"
>
Value
</label>
<input
type="number"
id="field-value"
name="value"
bind:value
step="1"
class="w-full border px-3 py-1.5 text-sm {fieldErrors.value
? 'border-red-400'
: 'border-gray-300 dark:border-gray-600'} dark:bg-gray-800 dark:text-gray-100"
/>
{#if fieldErrors.value}
<p class="mt-1 text-xs text-red-600">{fieldErrors.value}</p>
{/if}
</div>
<div class="flex justify-end gap-2 pt-2">
<button
type="submit"
disabled={!!record?.id && !dirty}
class="border border-gray-300 bg-gray-100 px-4 py-1.5 text-sm font-medium hover:bg-gray-200 disabled:cursor-not-allowed disabled:opacity-50 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-300 dark:hover:bg-gray-700"
>
{submitLabel}
</button>
</div>
</div>
</form>
@@ -1,18 +0,0 @@
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);
}
};
@@ -1,90 +0,0 @@
<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 AdminModal from '$lib/components/admin/AdminModal.svelte';
import Toast from '$lib/components/admin/Toast.svelte';
import { getModelConfig } from '$lib/svelte-admin/registry';
import LevelForm from './LevelForm.svelte';
let { data, form }: PageProps = $props();
const config = getModelConfig('subscription-levels')!;
const canDelete = $derived((data.adminUser?.roleId ?? 4) <= 2);
let modalOpen = $state(false);
let editingRecord = $state<Record<string, unknown> | null>(null);
let toastMessage = $state('');
let toastType = $state<'success' | 'error'>('success');
let fieldErrors = $state<Record<string, string>>({});
let formError = $state('');
$effect(() => {
if (form && 'success' in (form as any)) {
const result = form as any;
if (result.success) {
toastMessage = 'Saved successfully';
toastType = 'success';
closeModal();
} else {
formError = result.error ?? '';
fieldErrors = result.fieldErrors ?? {};
}
}
});
function openCreate() {
editingRecord = null;
formError = '';
fieldErrors = {};
modalOpen = true;
}
function openEdit(record: Record<string, unknown>) {
editingRecord = record;
formError = '';
fieldErrors = {};
modalOpen = true;
}
function closeModal() {
modalOpen = false;
editingRecord = null;
formError = '';
fieldErrors = {};
}
</script>
<div class="p-6">
<h1 class="mb-4 text-xl font-bold text-gray-900 dark:text-gray-100">{config.displayName}</h1>
<AdminTable
records={data.records}
listFields={config.listFields}
searchFields={config.searchFields}
fieldTypes={config.fieldTypes}
singular={config.displayNameSingular}
onRowClick={openEdit}
onNew={openCreate}
/>
</div>
<AdminModal
title={editingRecord
? `Edit ${config.displayNameSingular}`
: `Create ${config.displayNameSingular}`}
open={modalOpen}
onClose={closeModal}
{canDelete}
recordId={editingRecord?.id as string | undefined}
>
<LevelForm
record={editingRecord ?? {}}
{fieldErrors}
{formError}
submitLabel={editingRecord ? 'Update' : 'Create'}
/>
</AdminModal>
{#if toastMessage}
<Toast message={toastMessage} type={toastType} />
{/if}
@@ -1,95 +0,0 @@
<script lang="ts">
import { enhance } from '$app/forms';
let {
record = {} as Record<string, unknown>,
fieldErrors = {} as Record<string, string>,
formError = '',
action = '?/upsert',
submitLabel = 'Save'
}: {
record?: Record<string, unknown>;
fieldErrors?: Record<string, string>;
formError?: string;
action?: string;
submitLabel?: string;
} = $props();
let name = $state(String(record.name ?? ''));
let description = $state(String(record.description ?? ''));
let dirty = $state(false);
$effect(() => {
dirty = false;
});
function markDirty() {
if (record.id) dirty = true;
}
</script>
<form {action} method="POST" use:enhance oninput={markDirty}>
{#if record?.id}
<input type="hidden" name="id" value={String(record.id)} />
{/if}
{#if formError}
<div class="mb-4 border border-red-300 bg-red-50 px-4 py-2 text-sm text-red-700">
{formError}
</div>
{/if}
<div class="flex flex-col gap-4">
<div>
<label
for="field-name"
class="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300"
>
Name
</label>
<input
type="text"
id="field-name"
name="name"
bind:value={name}
class="w-full border px-3 py-1.5 text-sm {fieldErrors.name
? 'border-red-400'
: 'border-gray-300 dark:border-gray-600'} dark:bg-gray-800 dark:text-gray-100"
/>
{#if fieldErrors.name}
<p class="mt-1 text-xs text-red-600">{fieldErrors.name}</p>
{/if}
</div>
<div>
<label
for="field-description"
class="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300"
>
Description
</label>
<input
type="text"
id="field-description"
name="description"
bind:value={description}
class="w-full border px-3 py-1.5 text-sm {fieldErrors.description
? 'border-red-400'
: 'border-gray-300 dark:border-gray-600'} dark:bg-gray-800 dark:text-gray-100"
/>
{#if fieldErrors.description}
<p class="mt-1 text-xs text-red-600">{fieldErrors.description}</p>
{/if}
</div>
<div class="flex justify-end gap-2 pt-2">
<button
type="submit"
disabled={!!record?.id && !dirty}
class="border border-gray-300 bg-gray-100 px-4 py-1.5 text-sm font-medium hover:bg-gray-200 disabled:cursor-not-allowed disabled:opacity-50 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-300 dark:hover:bg-gray-700"
>
{submitLabel}
</button>
</div>
</div>
</form>
@@ -1,18 +1,62 @@
import type { PageServerLoad } from './$types';
import { getModelConfig } from '$lib/svelte-admin/registry';
import { loadRecords, upsertRecord, deleteRecord } from '$lib/svelte-admin/crud';
import { prisma } from '$lib/db';
import { superValidate } from 'sveltekit-superforms/server';
import { zod4 } from 'sveltekit-superforms/adapters';
import { z } from 'zod';
import { superFormErrorsToFieldErrors } from '$lib/superform-errors';
const config = getModelConfig('user-roles')!;
const UserRoleSchema = z.object({
name: z.string().min(1, 'Required').max(100, 'Too long')
});
export const load: PageServerLoad = async ({ locals }) => {
return loadRecords(locals, config);
export const load: PageServerLoad = async () => {
const records = await prisma.userRole.findMany({ orderBy: { id: 'asc' } });
return { records: records as unknown as Record<string, unknown>[] };
};
export const actions = {
upsert: async ({ request, locals }) => {
return upsertRecord(request, config, locals);
upsert: async ({ request }) => {
const formData = await request.formData();
const form = await superValidate(formData, zod4(UserRoleSchema));
if (!form.valid) {
const { error: formError, fieldErrors } = superFormErrorsToFieldErrors(
form.errors as Record<string, unknown>
);
return { success: false, action: 'upsert', error: formError, fieldErrors };
}
const rawId = formData.get('id')?.toString();
const data = { name: form.data.name };
try {
if (rawId) {
await prisma.userRole.update({ where: { id: Number(rawId) }, data });
} else {
await prisma.userRole.create({ data });
}
} catch (e: unknown) {
if (e && typeof e === 'object' && 'code' in e && (e as { code: string }).code === 'P2002') {
return { success: false, action: 'upsert', error: 'A role with that name already exists.' };
}
throw e;
}
return { success: true, action: 'upsert' };
},
delete: async ({ request, locals }) => {
return deleteRecord(request, config, locals);
delete: async ({ request }) => {
const formData = await request.formData();
const rawId = formData.get('id')?.toString();
if (!rawId) return { success: false, error: 'Missing id' };
try {
await prisma.userRole.delete({ where: { id: Number(rawId) } });
} catch (e: unknown) {
if (e && typeof e === 'object' && 'code' in e && (e as { code: string }).code === 'P2003') {
return { success: false, error: 'Cannot delete: this role is assigned to users.' };
}
throw e;
}
return { success: true, action: 'delete' };
}
};
@@ -1,90 +1,148 @@
<script lang="ts">
import { enhance } from '$app/forms';
import type { PageProps } from './$types';
import type { ActionResult } from '$lib/svelte-admin/types';
import AdminTable from '$lib/components/admin/AdminTable.svelte';
import DataTable, { type DataTableColumn } from '$lib/components/DataTable.svelte';
import AdminModal from '$lib/components/admin/AdminModal.svelte';
import Toast from '$lib/components/admin/Toast.svelte';
import { getModelConfig } from '$lib/svelte-admin/registry';
import UserRoleForm from './UserRoleForm.svelte';
type ActionResult = {
success?: boolean;
action?: string;
error?: string;
fieldErrors?: Record<string, string>;
} | null;
let { data, form }: PageProps = $props();
let canDelete = $derived((data.adminUser?.roleId ?? 4) <= 2);
const config = getModelConfig('user-roles')!;
const canDelete = $derived((data.adminUser?.roleId ?? 4) <= 2);
const columns: DataTableColumn[] = [
{ label: 'Id', value: (r) => r.id },
{ label: 'Name', value: (r) => r.name }
];
let modalOpen = $state(false);
let editingRecord = $state<Record<string, unknown> | null>(null);
let toastMessage = $state('');
let toastType = $state<'success' | 'error'>('success');
let fieldErrors = $state<Record<string, string>>({});
let name = $state('');
let formError = $state('');
let fieldErrors = $state<Record<string, string>>({});
let successMessage = $state('');
let lastForm = $state<ActionResult>(null);
$effect(() => {
if (form && 'success' in (form as any)) {
const result = form as any;
if (result.success) {
toastMessage = 'Saved successfully';
toastType = 'success';
closeModal();
} else {
formError = result.error ?? '';
fieldErrors = result.fieldErrors ?? {};
}
const rec = editingRecord ?? {};
name = String(rec.name ?? '');
if (!editingRecord) {
formError = '';
fieldErrors = {};
successMessage = '';
}
});
$effect(() => {
if (!form || form === lastForm) return;
lastForm = form;
if (form.success) {
formError = '';
fieldErrors = {};
successMessage = editingRecord ? 'Role updated' : 'Role created';
setTimeout(() => {
modalOpen = false;
successMessage = '';
}, 1200);
} else {
successMessage = '';
formError = form.error ?? '';
fieldErrors = form.fieldErrors ?? {};
}
});
function openCreate() {
editingRecord = null;
formError = '';
fieldErrors = {};
lastForm = null;
modalOpen = true;
}
function openEdit(record: Record<string, unknown>) {
editingRecord = record;
formError = '';
fieldErrors = {};
lastForm = null;
modalOpen = true;
}
function closeModal() {
modalOpen = false;
editingRecord = null;
formError = '';
fieldErrors = {};
}
</script>
<div class="p-6">
<h1 class="mb-4 text-xl font-bold text-gray-900 dark:text-gray-100">{config.displayName}</h1>
<AdminTable
<div>
<DataTable
title="User Roles"
{columns}
records={data.records}
listFields={config.listFields}
searchFields={config.searchFields}
fieldTypes={config.fieldTypes}
singular={config.displayNameSingular}
newLabel="New Role"
searchPlaceholder="Search roles..."
onRowClick={openEdit}
onNew={openCreate}
/>
</div>
<AdminModal
title={editingRecord
? `Edit ${config.displayNameSingular}`
: `Create ${config.displayNameSingular}`}
open={modalOpen}
onClose={closeModal}
{canDelete}
recordId={editingRecord?.id as string | undefined}
>
<UserRoleForm
record={editingRecord ?? {}}
{fieldErrors}
{formError}
submitLabel={editingRecord ? 'Update' : 'Create'}
/>
</AdminModal>
{#if modalOpen}
<AdminModal
title={editingRecord ? 'Edit User Role' : 'Create User Role'}
open={modalOpen}
onClose={closeModal}
{canDelete}
recordId={editingRecord?.id as string | undefined}
>
{#if successMessage}
<div
class="mb-4 rounded-md border border-green-200 bg-green-50 px-3 py-2 text-sm text-green-700"
>
{successMessage}
</div>
{/if}
{#if formError}
<div class="mb-4 rounded-md border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600">
{formError}
</div>
{/if}
{#if toastMessage}
<Toast message={toastMessage} type={toastType} />
<form method="POST" action="?/upsert" use:enhance class="flex flex-col gap-4">
{#if editingRecord}
<input type="hidden" name="id" value={String(editingRecord.id)} />
{/if}
<div>
<label for="field-name" class="mb-1 block text-sm font-medium text-slate-700">Name</label>
<input
id="field-name"
type="text"
name="name"
required
bind:value={name}
class="w-full rounded-md border border-slate-300 bg-white px-3 py-2 text-sm text-slate-900 shadow-sm transition focus:border-indigo-500 focus:outline-none"
/>
{#if fieldErrors.name}
<p class="mt-1 text-sm text-red-600">{fieldErrors.name}</p>
{/if}
</div>
<div class="mt-2 flex justify-end gap-3">
<button
type="button"
onclick={closeModal}
class="rounded-md border border-slate-300 bg-white px-4 py-2 text-sm font-medium text-slate-700 transition hover:bg-slate-50"
>
Cancel
</button>
<button
type="submit"
class="rounded-md bg-indigo-600 px-4 py-2 text-sm font-medium text-white transition hover:bg-indigo-700"
>
{editingRecord ? 'Save Role' : 'Create Role'}
</button>
</div>
</form>
</AdminModal>
{/if}
@@ -1,73 +0,0 @@
<script lang="ts">
import { enhance } from '$app/forms';
let {
record = {} as Record<string, unknown>,
fieldErrors = {} as Record<string, string>,
formError = '',
action = '?/upsert',
submitLabel = 'Save'
}: {
record?: Record<string, unknown>;
fieldErrors?: Record<string, string>;
formError?: string;
action?: string;
submitLabel?: string;
} = $props();
let name = $state(String(record.name ?? ''));
let dirty = $state(false);
$effect(() => {
dirty = false;
});
function markDirty() {
if (record.id) dirty = true;
}
</script>
<form {action} method="POST" use:enhance oninput={markDirty}>
{#if record?.id}
<input type="hidden" name="id" value={String(record.id)} />
{/if}
{#if formError}
<div class="mb-4 border border-red-300 bg-red-50 px-4 py-2 text-sm text-red-700">
{formError}
</div>
{/if}
<div class="flex flex-col gap-4">
<div>
<label
for="field-name"
class="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300"
>
Name
</label>
<input
type="text"
id="field-name"
name="name"
bind:value={name}
class="w-full border px-3 py-1.5 text-sm {fieldErrors.name
? 'border-red-400'
: 'border-gray-300 dark:border-gray-600'} dark:bg-gray-800 dark:text-gray-100"
/>
{#if fieldErrors.name}
<p class="mt-1 text-xs text-red-600">{fieldErrors.name}</p>
{/if}
</div>
<div class="flex justify-end gap-2 pt-2">
<button
type="submit"
disabled={!!record?.id && !dirty}
class="border border-gray-300 bg-gray-100 px-4 py-1.5 text-sm font-medium hover:bg-gray-200 disabled:cursor-not-allowed disabled:opacity-50 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-300 dark:hover:bg-gray-700"
>
{submitLabel}
</button>
</div>
</div>
</form>
@@ -1,18 +1,113 @@
import type { PageServerLoad, Actions } from './$types';
import { getModelConfig } from '$lib/svelte-admin/registry';
import { loadRecords, upsertRecord, deleteRecord } from '$lib/svelte-admin/crud';
import { prisma } from '$lib/db';
import { superValidate } from 'sveltekit-superforms/server';
import { zod4 } from 'sveltekit-superforms/adapters';
import { z } from 'zod';
import { superFormErrorsToFieldErrors } from '$lib/superform-errors';
const config = getModelConfig('users')!;
const UserSchema = z.object({
name: z.string().min(1, 'Required').max(100, 'Too long'),
email: z.string().email('Invalid email address'),
roleId: z.coerce.number().int().min(1)
});
async function myRoleId(locals: App.Locals): Promise<number> {
const user = await prisma.user.findUnique({
where: { id: locals.user?.id },
select: { roleId: true }
});
return user?.roleId ?? 4;
}
export const load: PageServerLoad = async ({ locals }) => {
return loadRecords(locals, config);
const roleId = await myRoleId(locals);
const [users, roles] = await Promise.all([
prisma.user.findMany({
include: { role: true },
orderBy: { createdAt: 'desc' }
}),
prisma.userRole.findMany({ orderBy: { id: 'asc' } })
]);
const roleOptions = roles.filter((role) => role.id >= roleId);
return {
records: users as unknown as Record<string, unknown>[],
roleOptions: roleOptions as unknown as Record<string, unknown>[]
};
};
export const actions: Actions = {
upsert: async ({ request, locals }) => {
return upsertRecord(request, config, locals);
const formData = await request.formData();
const form = await superValidate(formData, zod4(UserSchema));
if (!form.valid) {
const { error: formError, fieldErrors } = superFormErrorsToFieldErrors(
form.errors as Record<string, unknown>
);
return { success: false, action: 'upsert', error: formError, fieldErrors };
}
const rawId = formData.get('id')?.toString();
const data = {
name: form.data.name,
email: form.data.email,
roleId: form.data.roleId
};
const myRole = await myRoleId(locals);
if (myRole > 1 && data.roleId < myRole) {
return {
success: false,
action: 'upsert',
error: 'You cannot assign a role with more privileges than your own.'
};
}
try {
if (rawId) {
await prisma.user.update({ where: { id: rawId }, data });
} else {
await prisma.user.create({ data: { id: crypto.randomUUID(), ...data } });
}
} catch (e: unknown) {
if (e && typeof e === 'object' && 'code' in e && (e as { code: string }).code === 'P2002') {
return {
success: false,
action: 'upsert',
error: 'A user with that email already exists.'
};
}
if (e && typeof e === 'object' && 'code' in e && (e as { code: string }).code === 'P2003') {
return {
success: false,
action: 'upsert',
error: 'That role does not exist.'
};
}
throw e;
}
return { success: true, action: 'upsert' };
},
delete: async ({ request, locals }) => {
return deleteRecord(request, config, locals);
delete: async ({ request }) => {
const formData = await request.formData();
const rawId = formData.get('id')?.toString();
if (!rawId) return { success: false, error: 'Missing id' };
try {
await prisma.user.delete({ where: { id: rawId } });
} catch (e: unknown) {
if (e && typeof e === 'object' && 'code' in e && (e as { code: string }).code === 'P2003') {
return {
success: false,
error: 'Cannot delete: this user still has related records.'
};
}
throw e;
}
return { success: true, action: 'delete' };
}
};
+164 -53
View File
@@ -1,91 +1,202 @@
<script lang="ts">
import { enhance } from '$app/forms';
import type { PageProps } from './$types';
import type { ActionResult } from '$lib/svelte-admin/types';
import AdminTable from '$lib/components/admin/AdminTable.svelte';
import DataTable, { type DataTableColumn } from '$lib/components/DataTable.svelte';
import AdminModal from '$lib/components/admin/AdminModal.svelte';
import Toast from '$lib/components/admin/Toast.svelte';
import { getModelConfig } from '$lib/svelte-admin/registry';
import UserForm from './UserForm.svelte';
type ActionResult = {
success?: boolean;
action?: string;
error?: string;
fieldErrors?: Record<string, string>;
} | null;
let { data, form }: PageProps = $props();
let canDelete = $derived((data.adminUser?.roleId ?? 4) <= 2);
const config = getModelConfig('users')!;
const canDelete = $derived((data.adminUser?.roleId ?? 4) <= 2);
const columns: DataTableColumn[] = [
{ label: 'Name', value: (r) => r.name },
{ label: 'Email', value: (r) => r.email },
{
label: 'Role',
value: (r) => (r.role as Record<string, unknown> | undefined)?.name ?? (r.roleId as string)
},
{
label: 'Created',
value: (r) =>
r.createdAt
? new Date(r.createdAt as string).toLocaleDateString(undefined, {
year: 'numeric',
month: 'short',
day: 'numeric'
})
: ''
}
];
let modalOpen = $state(false);
let editingRecord = $state<Record<string, unknown> | null>(null);
let toastMessage = $state('');
let toastType = $state<'success' | 'error'>('success');
let fieldErrors = $state<Record<string, string>>({});
let name = $state('');
let email = $state('');
let roleId = $state('');
let formError = $state('');
let fieldErrors = $state<Record<string, string>>({});
let successMessage = $state('');
let lastForm = $state<ActionResult>(null);
$effect(() => {
if (form && 'success' in (form as any)) {
const result = form as any;
if (result.success) {
toastMessage = 'Saved successfully';
toastType = 'success';
closeModal();
} else {
formError = result.error ?? '';
fieldErrors = result.fieldErrors ?? {};
}
const rec = editingRecord ?? {};
name = String(rec.name ?? '');
email = String(rec.email ?? '');
roleId = String(rec.roleId ?? '');
if (!editingRecord) {
formError = '';
fieldErrors = {};
successMessage = '';
}
});
$effect(() => {
if (!form || form === lastForm) return;
lastForm = form;
if (form.success) {
formError = '';
fieldErrors = {};
successMessage = editingRecord ? 'User updated' : 'User created';
setTimeout(() => {
modalOpen = false;
successMessage = '';
}, 1200);
} else {
successMessage = '';
formError = form.error ?? '';
fieldErrors = form.fieldErrors ?? {};
}
});
function openCreate() {
editingRecord = null;
formError = '';
fieldErrors = {};
lastForm = null;
modalOpen = true;
}
function openEdit(record: Record<string, unknown>) {
editingRecord = record;
formError = '';
fieldErrors = {};
lastForm = null;
modalOpen = true;
}
function closeModal() {
modalOpen = false;
editingRecord = null;
formError = '';
fieldErrors = {};
}
</script>
<div class="p-6">
<h1 class="mb-4 text-xl font-bold text-gray-900 dark:text-gray-100">{config.displayName}</h1>
<AdminTable
<div>
<DataTable
title="Users"
{columns}
records={data.records}
listFields={config.listFields}
searchFields={config.searchFields}
fieldTypes={config.fieldTypes}
singular={config.displayNameSingular}
newLabel="New User"
searchPlaceholder="Search users..."
onRowClick={openEdit}
onNew={config.listOnly ? undefined : openCreate}
onNew={openCreate}
/>
</div>
<AdminModal
title={editingRecord
? `Edit ${config.displayNameSingular}`
: `Create ${config.displayNameSingular}`}
open={modalOpen}
onClose={closeModal}
{canDelete}
recordId={editingRecord?.id as string | undefined}
>
<UserForm
record={editingRecord ?? {}}
relationOptionsData={data.relationOptionsData}
{fieldErrors}
{formError}
submitLabel={editingRecord ? 'Update' : 'Create'}
/>
</AdminModal>
{#if modalOpen}
<AdminModal
title={editingRecord ? 'Edit User' : 'Create User'}
open={modalOpen}
onClose={closeModal}
{canDelete}
recordId={editingRecord?.id as string | undefined}
>
{#if successMessage}
<div
class="mb-4 rounded-md border border-green-200 bg-green-50 px-3 py-2 text-sm text-green-700"
>
{successMessage}
</div>
{/if}
{#if formError}
<div class="mb-4 rounded-md border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600">
{formError}
</div>
{/if}
{#if toastMessage}
<Toast message={toastMessage} type={toastType} />
<form method="POST" action="?/upsert" use:enhance class="flex flex-col gap-4">
{#if editingRecord}
<input type="hidden" name="id" value={String(editingRecord.id)} />
{/if}
<div>
<label for="field-name" class="mb-1 block text-sm font-medium text-slate-700">Name</label>
<input
id="field-name"
type="text"
name="name"
required
bind:value={name}
class="w-full rounded-md border border-slate-300 bg-white px-3 py-2 text-sm text-slate-900 shadow-sm transition focus:border-indigo-500 focus:outline-none"
/>
{#if fieldErrors.name}
<p class="mt-1 text-sm text-red-600">{fieldErrors.name}</p>
{/if}
</div>
<div>
<label for="field-email" class="mb-1 block text-sm font-medium text-slate-700">Email</label>
<input
id="field-email"
type="email"
name="email"
required
bind:value={email}
class="w-full rounded-md border border-slate-300 bg-white px-3 py-2 text-sm text-slate-900 shadow-sm transition focus:border-indigo-500 focus:outline-none"
/>
{#if fieldErrors.email}
<p class="mt-1 text-sm text-red-600">{fieldErrors.email}</p>
{/if}
</div>
<div>
<label for="field-roleId" class="mb-1 block text-sm font-medium text-slate-700">Role</label>
<select
id="field-roleId"
name="roleId"
required
bind:value={roleId}
class="w-full rounded-md border border-slate-300 bg-white px-3 py-2 text-sm text-slate-900 shadow-sm transition focus:border-indigo-500 focus:outline-none"
>
<option value="">-- Select Role --</option>
{#each data.roleOptions as role (String(role.id))}
<option value={String(role.id)}>{role.name}</option>
{/each}
</select>
{#if fieldErrors.roleId}
<p class="mt-1 text-sm text-red-600">{fieldErrors.roleId}</p>
{/if}
</div>
<div class="mt-2 flex justify-end gap-3">
<button
type="button"
onclick={closeModal}
class="rounded-md border border-slate-300 bg-white px-4 py-2 text-sm font-medium text-slate-700 transition hover:bg-slate-50"
>
Cancel
</button>
<button
type="submit"
class="rounded-md bg-indigo-600 px-4 py-2 text-sm font-medium text-white transition hover:bg-indigo-700"
>
{editingRecord ? 'Save User' : 'Create User'}
</button>
</div>
</form>
</AdminModal>
{/if}
@@ -1,79 +0,0 @@
<script lang="ts">
import { enhance } from '$app/forms';
let {
record = {} as Record<string, unknown>,
fieldErrors = {} as Record<string, string>,
formError = '',
relationOptionsData = {} as Record<string, { label: string; value: string | number }[]>,
action = '?/upsert',
submitLabel = 'Save'
}: {
record?: Record<string, unknown>;
fieldErrors?: Record<string, string>;
formError?: string;
relationOptionsData?: Record<string, { label: string; value: string | number }[]>;
action?: string;
submitLabel?: string;
} = $props();
let roleId = $state(String(record.roleId ?? ''));
let dirty = $state(false);
$effect(() => {
dirty = false;
});
function markDirty() {
if (record.id) dirty = true;
}
</script>
<form {action} method="POST" use:enhance oninput={markDirty}>
{#if record?.id}
<input type="hidden" name="id" value={String(record.id)} />
{/if}
{#if formError}
<div class="mb-4 border border-red-300 bg-red-50 px-4 py-2 text-sm text-red-700">
{formError}
</div>
{/if}
<div class="flex flex-col gap-4">
<div>
<label
for="field-roleId"
class="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300"
>
Role
</label>
<select
id="field-roleId"
name="roleId"
bind:value={roleId}
class="w-full border px-3 py-1.5 text-sm {fieldErrors.roleId
? 'border-red-400'
: 'border-gray-300 dark:border-gray-600'} dark:bg-gray-800 dark:text-gray-100"
>
<option value="">-- Select Role --</option>
{#each relationOptionsData.roleId ?? [] as opt (String(opt.value))}
<option value={opt.value}>{opt.label}</option>
{/each}
</select>
{#if fieldErrors.roleId}
<p class="mt-1 text-xs text-red-600">{fieldErrors.roleId}</p>
{/if}
</div>
<div class="flex justify-end gap-2 pt-2">
<button
type="submit"
disabled={!!record?.id && !dirty}
class="border border-gray-300 bg-gray-100 px-4 py-1.5 text-sm font-medium hover:bg-gray-200 disabled:cursor-not-allowed disabled:opacity-50 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-300 dark:hover:bg-gray-700"
>
{submitLabel}
</button>
</div>
</div>
</form>
+2 -2
View File
@@ -40,7 +40,7 @@ describe('(protected) layout server load', () => {
});
it('redirects to /account/setup when user has no profile', async () => {
findUnique.mockResolvedValue({ id: 'user-1', roleId: null, profile: null });
findUnique.mockResolvedValue({ id: 'user-1', roleId: null, profiles: [] });
const locals = createLocals(mockUser() as any);
const url = new URL('http://localhost/dashboard');
@@ -53,7 +53,7 @@ describe('(protected) layout server load', () => {
});
it('skips redirect when already on /account/setup', async () => {
findUnique.mockResolvedValue({ id: 'user-1', roleId: null, profile: null });
findUnique.mockResolvedValue({ id: 'user-1', roleId: null, profiles: [] });
const locals = createLocals(mockUser() as any);
const url = new URL('http://localhost/account/setup');
+3 -6
View File
@@ -5,12 +5,9 @@
<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
>.
<h1>Not Authorized</h1>
<p>
You don't have permission to view this page. <a href="/">Go home</a>.
</p>
{/if}
</Window>
+355 -57
View File
@@ -1,79 +1,377 @@
<script lang="ts">
import { onMount } from 'svelte';
import { authClient } from '$lib/auth-client';
import { canAccessAdmin } from '$lib/svelte-admin/roles';
import './layout.css';
import favicon from '$lib/assets/favicon.svg';
import { page } from '$app/state';
import { resolve } from '$app/paths';
import type { Snippet } from 'svelte';
import type { LayoutData } from './$types';
import './layout.css';
let { data, children } = $props();
let { children, data }: { children: Snippet; data: LayoutData } = $props();
const session = authClient.useSession();
let isAdmin = $derived(canAccessAdmin(data.userRoleId));
let dark = $state(false);
let menuOpen = $state(false);
let adminOpen = $state(false);
onMount(() => {
dark = document.documentElement.classList.contains('dark');
});
const isAdmin = $derived((data.userRoleId ?? 4) <= 3);
const adminModels = [
{ slug: 'users', displayName: 'Users' },
{ slug: 'profiles', displayName: 'Profiles' },
{ slug: 'user-roles', displayName: 'User Roles' },
{ slug: 'proclamations', displayName: 'Proclamations' },
{ slug: 'solfeggio-frequencies', displayName: 'Solfeggio Frequencies' },
{ slug: 'proclamation-categories', displayName: 'Proclamation Categories' },
{ slug: 'proclamation-groups', displayName: 'Proclamation Groups' },
{ slug: 'license-keys', displayName: 'License Keys' }
];
const currentPath = $derived(page.url.pathname);
function toggleTheme() {
dark = !dark;
if (dark) {
document.documentElement.classList.add('dark');
localStorage.setItem('theme', 'dark');
} else {
document.documentElement.classList.remove('dark');
localStorage.setItem('theme', 'light');
}
function isActive(path: string): boolean {
if (path === '/') return currentPath === '/';
return currentPath === path || currentPath.startsWith(`${path}/`);
}
function activeClass(path: string) {
return isActive(path)
? 'bg-indigo-50 text-indigo-700'
: 'text-slate-600 hover:bg-slate-100 hover:text-slate-900';
}
function closeMenu() {
menuOpen = false;
adminOpen = false;
}
async function handleSignOut() {
await authClient.signOut();
closeMenu();
window.location.href = resolve('/');
}
</script>
<svelte:head>
<link rel="icon" href={favicon} />
<title>Rhema Wave Technologies</title>
<meta
name="description"
content="Rhema Wave Technologies User Portal — manage proclamations, audio content, and solfeggio frequencies."
/>
<meta name="theme-color" content="#6b7280" />
<meta property="og:title" content="Rhema Wave Technologies" />
<meta
property="og:description"
content="Manage proclamations, audio content, and solfeggio frequencies."
/>
<title>Radiant Wave User Portal</title>
<meta name="description" content="Radiant Wave Technologies User Portal" />
<meta property="og:title" content="Radiant Wave User Portal" />
<meta property="og:type" content="website" />
<meta property="og:site_name" content="Rhema Wave Technologies" />
<meta property="og:site_name" content="Radiant Wave User Portal" />
<meta name="twitter:card" content="summary" />
</svelte:head>
<nav
class="flex w-full items-center justify-between border-b border-gray-200 px-8 py-6 dark:border-gray-700 dark:bg-gray-950 dark:text-gray-100"
>
<span><a href="/" class="dark:text-gray-100">Rhema Wave Technologies</a></span>
<div class="flex items-center gap-4">
<button
onclick={toggleTheme}
class="border border-gray-300 px-3 py-1 text-sm dark:border-gray-600 dark:text-gray-300"
>
{dark ? 'Light' : 'Dark'}
</button>
{#if $session.data}
<a href="/dashboard" class="dark:text-gray-300 dark:hover:text-gray-100">Dashboard</a>
<button
class="dark:text-gray-300 dark:hover:text-gray-100"
onclick={async () => {
await authClient.signOut();
window.location.href = '/';
}}>Log Out</button
<header class="sticky top-0 z-40 border-b border-slate-200 bg-white/90 backdrop-blur">
<div class="mx-auto flex h-14 max-w-6xl items-center justify-between gap-4 px-4">
<a href={resolve('/')} class="flex shrink-0 items-center gap-2">
<span
class="flex h-8 w-8 items-center justify-center rounded-lg bg-gradient-to-br from-indigo-600 to-indigo-400"
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="white"
stroke-width="2.5"
stroke-linecap="round"
stroke-linejoin="round"
><path d="M2 12c1.5 0 1.5-2 3-2s1.5 2 3 2 1.5-2 3-2 1.5 2 3 2 1.5-2 3-2 1.5 2 3 2" /><path
d="m2 17 1.5-2M22 12c-1.5 0-1.5-2-3-2M22 17l-1.5-2"
/></svg
>
</span>
<span class="text-base font-semibold tracking-tight text-slate-900">Radiant Wave</span>
</a>
{#if $session.data?.user}
<nav aria-label="Primary" class="hidden items-center gap-1 md:flex">
<a
href={resolve('/')}
class={`rounded-md px-3 py-2 text-sm font-medium transition ${activeClass('/')}`}
>
Home
</a>
<a
href={resolve('/dashboard')}
class={`rounded-md px-3 py-2 text-sm font-medium transition ${activeClass('/dashboard')}`}
>
Dashboard
</a>
<a
href={resolve('/account')}
class={`rounded-md px-3 py-2 text-sm font-medium transition ${activeClass('/account')}`}
>
Account
</a>
<a
href={resolve('/license-keys')}
class={`rounded-md px-3 py-2 text-sm font-medium transition ${activeClass('/license-keys')}`}
>
License Keys
</a>
{#if isAdmin}
<div class="relative">
<button
type="button"
aria-expanded={adminOpen}
onclick={() => (adminOpen = !adminOpen)}
class={`flex items-center gap-1 rounded-md px-3 py-2 text-sm font-medium transition ${
isActive('/admin')
? 'bg-indigo-50 text-indigo-700'
: 'text-slate-600 hover:bg-slate-100 hover:text-slate-900'
}`}
>
Admin
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
class="transition-transform"
class:rotate-180={adminOpen}><path d="m6 9 6 6 6-6" /></svg
>
</button>
{#if adminOpen}
<div
class="absolute top-full left-0 mt-1 min-w-56 rounded-lg border border-slate-200 bg-white p-1 shadow-lg"
>
{#each adminModels as model (model.slug)}
<a
href="/admin/{model.slug}"
onclick={() => (adminOpen = false)}
class={`block rounded-md px-3 py-2 text-sm transition ${
isActive('/admin/' + model.slug)
? 'bg-indigo-50 text-indigo-700'
: 'text-slate-600 hover:bg-slate-100 hover:text-slate-900'
}`}
>
{model.displayName}
</a>
{/each}
<a
href={resolve('/admin/api-keys')}
onclick={() => (adminOpen = false)}
class={`block rounded-md px-3 py-2 text-sm transition ${
isActive('/admin/api-keys')
? 'bg-indigo-50 text-indigo-700'
: 'text-slate-600 hover:bg-slate-100 hover:text-slate-900'
}`}
>
API Keys
</a>
</div>
{/if}
</div>
{/if}
<span class="mx-2 h-5 w-px bg-slate-200" aria-hidden="true"></span>
<button
type="button"
onclick={handleSignOut}
class="rounded-md px-3 py-2 text-sm font-medium text-slate-500 transition hover:bg-slate-100 hover:text-slate-900"
>
Sign Out
</button>
</nav>
{:else}
<span class="dark:text-gray-300"
><a href="/login" class="dark:text-gray-300 dark:hover:text-gray-100">Log In</a> |
<a href="/register" class="dark:text-gray-300 dark:hover:text-gray-100">Register</a></span
>
<nav aria-label="Primary" class="hidden items-center gap-1 md:flex">
<a
href={resolve('/')}
class={`rounded-md px-3 py-2 text-sm font-medium transition ${activeClass('/')}`}
>
Home
</a>
<a
href={resolve('/login')}
class="rounded-md bg-indigo-600 px-4 py-2 text-sm font-medium text-white transition hover:bg-indigo-700"
>
Sign In
</a>
</nav>
{/if}
<button
type="button"
aria-label="Toggle navigation menu"
aria-expanded={menuOpen}
onclick={() => (menuOpen = !menuOpen)}
class="flex h-10 w-10 items-center justify-center rounded-lg text-slate-700 hover:bg-slate-100 md:hidden"
>
{#if menuOpen}
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"><path d="M18 6 6 18" /><path d="m6 6 12 12" /></svg
>
{:else}
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
><line x1="4" x2="20" y1="6" y2="6" /><line x1="4" x2="20" y1="12" y2="12" /><line
x1="4"
x2="20"
y1="18"
y2="18"
/></svg
>
{/if}
</button>
</div>
</nav>
<div class="flex min-h-0 flex-1 px-8 dark:bg-gray-950">
{@render children()}
</header>
{#if menuOpen}
<div
class="fixed inset-0 top-14 z-50 overflow-y-auto border-t border-slate-200 bg-white shadow-lg md:hidden"
>
<nav class="mx-auto max-w-6xl px-4 py-4">
{#if $session.data?.user}
<div class="flex flex-col gap-1">
<a
href={resolve('/')}
onclick={closeMenu}
class={`rounded-lg px-4 py-3 text-base font-medium transition ${activeClass('/')}`}
>
Home
</a>
<a
href={resolve('/dashboard')}
onclick={closeMenu}
class={`rounded-lg px-4 py-3 text-base font-medium transition ${activeClass('/dashboard')}`}
>
Dashboard
</a>
<a
href={resolve('/account')}
onclick={closeMenu}
class={`rounded-lg px-4 py-3 text-base font-medium transition ${activeClass('/account')}`}
>
Account
</a>
<a
href={resolve('/license-keys')}
onclick={closeMenu}
class={`rounded-lg px-4 py-3 text-base font-medium transition ${activeClass('/license-keys')}`}
>
License Keys
</a>
{#if isAdmin}
<div class="mt-2 border-t border-slate-200 pt-2">
<button
type="button"
aria-expanded={adminOpen}
onclick={() => (adminOpen = !adminOpen)}
class={`flex w-full items-center justify-between rounded-lg px-4 py-3 text-base font-semibold transition ${
isActive('/admin')
? 'bg-indigo-50 text-indigo-700'
: 'text-slate-700 hover:bg-indigo-50'
}`}
>
<span>Admin</span>
<svg
xmlns="http://www.w3.org/2000/svg"
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
class="transition-transform"
class:rotate-180={adminOpen}><path d="m6 9 6 6 6-6" /></svg
>
</button>
{#if adminOpen}
<div class="mt-1 flex flex-col gap-1">
{#each adminModels as model (model.slug)}
<a
href="/admin/{model.slug}"
onclick={closeMenu}
class={`rounded-lg px-4 py-3 text-base transition ${activeClass('/admin/' + model.slug)}`}
>
{model.displayName}
</a>
{/each}
<a
href={resolve('/admin/api-keys')}
onclick={closeMenu}
class={`rounded-lg px-4 py-3 text-base transition ${activeClass('/admin/api-keys')}`}
>
API Keys
</a>
</div>
{/if}
</div>
{/if}
<button
type="button"
onclick={handleSignOut}
class="mt-2 rounded-lg border-t border-slate-200 px-4 py-3 text-base font-medium text-slate-500 hover:bg-slate-100"
>
Sign Out
</button>
</div>
{:else}
<div class="flex flex-col gap-1">
<a
href={resolve('/')}
onclick={closeMenu}
class={`rounded-lg px-4 py-3 text-base font-medium transition ${activeClass('/')}`}
>
Home
</a>
<a
href={resolve('/login')}
onclick={closeMenu}
class="mt-1 rounded-lg bg-indigo-600 px-4 py-3 text-center text-base font-medium text-white hover:bg-indigo-700"
>
Sign In
</a>
</div>
{/if}
</nav>
</div>
{/if}
<div class="flex min-h-[calc(100dvh-3.5rem)] flex-col bg-slate-50 text-slate-900">
<main class="flex-1">
{@render children()}
</main>
<footer class="sticky bottom-0 z-10 border-t border-slate-200 bg-white">
<div
class="mx-auto flex max-w-6xl flex-col items-center justify-between gap-2 px-4 py-6 text-sm text-slate-500 sm:flex-row"
>
<p>&copy; {new Date().getFullYear()} Radiant Wave Technologies. All rights reserved.</p>
{#if $session.data?.user}
<a href={resolve('/account')} class="transition hover:text-slate-900">
Manage your account
</a>
{:else}
<a href={resolve('/login')} class="transition hover:text-slate-900">Sign in</a>
{/if}
</div>
</footer>
</div>
+41 -3
View File
@@ -1,3 +1,41 @@
<div class="flex flex-1 flex-col items-center justify-center">
<h1 class="dark:text-gray-100">Hello</h1>
</div>
<script lang="ts">
import { authClient } from '$lib/auth-client';
import { resolve } from '$app/paths';
const session = authClient.useSession();
const signedIn = $derived(!!$session.data?.user);
</script>
<section class="py-16 sm:py-24">
<div class="mx-auto w-full max-w-6xl px-4">
<div class="mx-auto flex max-w-3xl flex-col items-center text-center">
<h1 class="mt-6 text-4xl font-bold tracking-tight text-slate-900 sm:text-5xl">
Welcome to the Radiant Wave Portal
</h1>
<div class="mt-8 flex flex-col items-center gap-3 sm:flex-row">
{#if signedIn}
<a
href={resolve('/dashboard')}
class="inline-flex h-12 items-center justify-center rounded-lg bg-indigo-600 px-8 text-base font-medium text-white shadow-sm transition hover:bg-indigo-700"
>
Go to Dashboard
</a>
{:else}
<a
href={resolve('/login')}
class="inline-flex h-12 w-full items-center justify-center rounded-lg bg-indigo-600 px-8 text-base font-medium text-white shadow-sm transition hover:bg-indigo-700 sm:w-auto"
>
Get Started
</a>
<a
href={resolve('/register')}
class="inline-flex h-12 w-full items-center justify-center rounded-lg border border-slate-300 bg-white px-8 text-base font-medium text-slate-700 transition hover:bg-slate-50 sm:w-auto"
>
Create Account
</a>
{/if}
</div>
</div>
</div>
</section>
@@ -12,21 +12,11 @@ function generateLicenseKey(): string {
export const POST: RequestHandler = async ({ request }) => {
const body = await request.json();
const { email, name, subscription_level } = body;
const { email, name } = body;
if (!email || typeof email !== 'string') {
error(400, 'email is required');
}
if (!subscription_level || typeof subscription_level !== 'string') {
error(400, 'subscription_level is required');
}
const level = await prisma.subscriptionLevel.findFirst({
where: { name: subscription_level }
});
if (!level) {
error(400, `subscription_level "${subscription_level}" not found`);
}
let user = await prisma.user.findUnique({ where: { email } });
@@ -83,7 +73,6 @@ export const POST: RequestHandler = async ({ request }) => {
data: {
key,
active: true,
level: subscription_level,
userId: user.id
}
});
@@ -91,7 +80,7 @@ export const POST: RequestHandler = async ({ request }) => {
try {
await sendMail({
to: email,
subject: 'Your Rhemawave License Key',
subject: 'Your Radiant Wave License Key',
templateId: 'license-key-delivery',
data: {
firstName: name ?? 'Valued Customer',
+1 -2
View File
@@ -39,8 +39,7 @@ export const POST: RequestHandler = async ({ request }) => {
return new Response(
JSON.stringify({
key: storedKey.key,
email_address: email,
subscription_level: storedKey.level
email_address: email
}),
{ headers: { 'content-type': 'application/json' } }
);
@@ -61,7 +61,7 @@ export const POST: RequestHandler = async ({ request, url }) => {
throw error(400, 'invalid JSON body');
}
const { email, name, subscription_level } = body as Record<string, unknown>;
const { email, name } = body as Record<string, unknown>;
if (typeof email !== 'string' || !email.trim()) {
error(400, 'email is required');
@@ -71,10 +71,6 @@ export const POST: RequestHandler = async ({ request, url }) => {
error(400, 'email is required');
}
if (typeof subscription_level !== 'string' || !subscription_level.trim()) {
error(400, 'subscription_level is required');
}
const emailLimit = rateLimit({
key: `onboarding:email:${cleanEmail}`,
limit: 3,
@@ -84,12 +80,6 @@ export const POST: RequestHandler = async ({ request, url }) => {
return rateLimited(emailLimit.retryAfterMs);
}
const levelName = subscription_level.trim();
const level = await prisma.subscriptionLevel.findFirst({ where: { name: levelName } });
if (!level) {
error(400, `subscription_level "${levelName}" not found`);
}
const displayName =
typeof name === 'string' && name.trim() ? name.trim().slice(0, 120) : cleanEmail.split('@')[0];
@@ -148,7 +138,6 @@ export const POST: RequestHandler = async ({ request, url }) => {
data: {
key,
active: true,
level: levelName,
userId: user.id
}
});
@@ -156,7 +145,7 @@ export const POST: RequestHandler = async ({ request, url }) => {
try {
await sendMail({
to: cleanEmail,
subject: 'Your Rhemawave License Key',
subject: 'Your Radiant Wave License Key',
templateId: 'license-key-delivery',
data: {
firstName: displayName,
@@ -25,7 +25,6 @@ vi.mock('$lib/server/resend', () => ({
vi.mock('$lib/db', () => ({
prisma: {
subscriptionLevel: { findFirst: vi.fn() },
user: { findUnique: vi.fn() },
verification: { findFirst: vi.fn() },
licenseKey: { create: vi.fn() }
@@ -40,7 +39,6 @@ type POSTEvent = Parameters<typeof POST>[0];
const SECRET = 'test-onboarding-secret';
const PATH = '/api/v2/automations/create/account';
const mockLevelFindFirst = prisma.subscriptionLevel.findFirst as ReturnType<typeof vi.fn>;
const mockUserFindUnique = prisma.user.findUnique as ReturnType<typeof vi.fn>;
const mockVerification = prisma.verification.findFirst as ReturnType<typeof vi.fn>;
const mockLicenseCreate = prisma.licenseKey.create as ReturnType<typeof vi.fn>;
@@ -88,13 +86,12 @@ describe('POST /api/v2/automations/create/account', () => {
});
it('creates a license key for an existing user without a new signup', async () => {
mockLevelFindFirst.mockResolvedValue({ id: 1, name: 'Basic', description: 'General only' });
mockUserFindUnique.mockResolvedValue({ id: 'user-1', email: 'user@example.com' });
mockLicenseCreate.mockResolvedValue({ id: 'lic-1' });
sendMailMock.mockResolvedValue({});
const res = await POST({
request: signedOnboardingRequest({ email: 'user@example.com', subscription_level: 'Basic' }),
request: signedOnboardingRequest({ email: 'user@example.com' }),
url: new URL(`http://localhost${PATH}`)
} as POSTEvent);
@@ -105,12 +102,15 @@ describe('POST /api/v2/automations/create/account', () => {
user_id: 'user-1'
});
expect(authApi.signUpEmail).not.toHaveBeenCalled();
expect(mockLicenseCreate).toHaveBeenCalledTimes(1);
expect(mockLicenseCreate).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({ active: true })
})
);
expect(sendMailMock).toHaveBeenCalledTimes(1);
});
it('signs up a new user and sends registration + license emails', async () => {
mockLevelFindFirst.mockResolvedValue({ id: 1, name: 'Basic', description: 'General only' });
mockUserFindUnique
.mockResolvedValueOnce(null)
.mockResolvedValueOnce({ id: 'user-2', email: 'new@example.com' });
@@ -120,7 +120,7 @@ describe('POST /api/v2/automations/create/account', () => {
authApi.signUpEmail.mockResolvedValue({});
const res = await POST({
request: signedOnboardingRequest({ email: 'new@example.com', subscription_level: 'Elite' }),
request: signedOnboardingRequest({ email: 'new@example.com' }),
url: new URL(`http://localhost${PATH}`)
} as POSTEvent);
@@ -142,7 +142,7 @@ describe('POST /api/v2/automations/create/account', () => {
const req = new Request(`http://localhost${PATH}`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ email: 'user@example.com', subscription_level: 'Basic' })
body: JSON.stringify({ email: 'user@example.com' })
});
const res = await POST({ request: req, url: new URL(`http://localhost${PATH}`) } as POSTEvent);
@@ -152,10 +152,7 @@ describe('POST /api/v2/automations/create/account', () => {
it('rejects a request signed with the wrong secret', async () => {
const res = await POST({
request: signedOnboardingRequest(
{ email: 'user@example.com', subscription_level: 'Basic' },
'wrong-secret'
),
request: signedOnboardingRequest({ email: 'user@example.com' }, 'wrong-secret'),
url: new URL(`http://localhost${PATH}`)
} as POSTEvent);
expect(res.status).toBe(401);
@@ -165,7 +162,7 @@ describe('POST /api/v2/automations/create/account', () => {
it('rejects a missing email', async () => {
await expectHttpError(
POST({
request: signedOnboardingRequest({ subscription_level: 'Basic' }),
request: signedOnboardingRequest({}),
url: new URL(`http://localhost${PATH}`)
} as POSTEvent),
400,
@@ -176,7 +173,6 @@ describe('POST /api/v2/automations/create/account', () => {
it('rejects an oversized body with 413', async () => {
const rawBody = JSON.stringify({
email: 'user@example.com',
subscription_level: 'Basic',
name: 'x'.repeat(64 * 1024 + 1)
});
expect(rawBody.length).toBeGreaterThan(64 * 1024);
@@ -194,40 +190,21 @@ describe('POST /api/v2/automations/create/account', () => {
);
});
it('rejects an unknown subscription level', async () => {
mockLevelFindFirst.mockResolvedValue(null);
await expectHttpError(
POST({
request: signedOnboardingRequest({
email: 'user@example.com',
subscription_level: 'Titanium'
}),
url: new URL(`http://localhost${PATH}`)
} as POSTEvent),
400,
'subscription_level "Titanium" not found'
);
});
it('rate limits repeated requests for the same email', async () => {
mockLevelFindFirst.mockResolvedValue({ id: 1, name: 'Basic', description: 'General only' });
mockUserFindUnique.mockResolvedValue({ id: 'user-1', email: 'user@example.com' });
mockLicenseCreate.mockResolvedValue({ id: 'lic-1' });
sendMailMock.mockResolvedValue({});
for (let i = 0; i < 3; i++) {
const res = await POST({
request: signedOnboardingRequest({
email: 'user@example.com',
subscription_level: 'Basic'
}),
request: signedOnboardingRequest({ email: 'user@example.com' }),
url: new URL(`http://localhost${PATH}`)
} as POSTEvent);
expect(res.status).toBe(200);
}
const res = await POST({
request: signedOnboardingRequest({ email: 'user@example.com', subscription_level: 'Basic' }),
request: signedOnboardingRequest({ email: 'user@example.com' }),
url: new URL(`http://localhost${PATH}`)
} as POSTEvent);
expect(res.status).toBe(429);
+11
View File
@@ -0,0 +1,11 @@
import type { RequestHandler } from './$types';
import { prisma } from '$lib/db';
import { jsonOk } from '$lib/server/v2-helpers';
import { serializeGroup } from '$lib/server/v2-serializers';
export const GET: RequestHandler = async () => {
const groups = await prisma.proclamationGroup.findMany({
orderBy: { sortOrder: 'asc' }
});
return jsonOk(groups.map(serializeGroup));
};
+47
View File
@@ -0,0 +1,47 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
vi.mock('$lib/db', () => ({
prisma: {
proclamationGroup: { findMany: vi.fn() }
}
}));
import { prisma } from '$lib/db';
import { GET } from './+server';
type GETEvent = Parameters<typeof GET>[0];
const mockFindMany = prisma.proclamationGroup.findMany as ReturnType<typeof vi.fn>;
describe('GET /api/v2/groups', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('returns groups ordered by sortOrder', async () => {
mockFindMany.mockResolvedValue([
{ id: 1, name: 'General', sortOrder: 0 },
{ id: 2, name: 'Specific', sortOrder: 1 },
{ id: 3, name: 'Precise', sortOrder: 2 }
]);
const res = await GET({} as GETEvent);
expect(res.status).toBe(200);
expect(await res.json()).toEqual([
{ id: 1, name: 'General', sortOrder: 0 },
{ id: 2, name: 'Specific', sortOrder: 1 },
{ id: 3, name: 'Precise', sortOrder: 2 }
]);
expect(mockFindMany).toHaveBeenCalledWith({ orderBy: { sortOrder: 'asc' } });
});
it('returns an empty list when no groups exist', async () => {
mockFindMany.mockResolvedValue([]);
const res = await GET({} as GETEvent);
expect(res.status).toBe(200);
expect(await res.json()).toEqual([]);
});
});
+1 -7
View File
@@ -50,16 +50,10 @@ export const POST: RequestHandler = async ({ request }) => {
throw error(400, 'key does not belong to specified user');
}
const level = await prisma.subscriptionLevel.findFirst({
where: { name: storedKey.level }
});
return jsonOk(
serializeLicense({
key: storedKey.key,
emailAddress: email,
levelName: storedKey.level,
level
emailAddress: email
})
);
};
+6 -30
View File
@@ -5,8 +5,7 @@ import { _clearRateLimits } from '$lib/server/v2-rate-limit';
vi.mock('$lib/db', () => ({
prisma: {
licenseKey: { findFirst: vi.fn() },
user: { findUnique: vi.fn() },
subscriptionLevel: { findFirst: vi.fn() }
user: { findUnique: vi.fn() }
}
}));
@@ -17,7 +16,6 @@ type POSTEvent = Parameters<typeof POST>[0];
const mockKeyFindFirst = prisma.licenseKey.findFirst as ReturnType<typeof vi.fn>;
const mockUserFindUnique = prisma.user.findUnique as ReturnType<typeof vi.fn>;
const mockLevelFindFirst = prisma.subscriptionLevel.findFirst as ReturnType<typeof vi.fn>;
function licenseRequest(body: unknown): Request {
return new Request('http://localhost/api/v2/license', {
@@ -44,7 +42,6 @@ const storedKey = {
id: 'key-1',
key: 'ABCD-EFGH-IJKL-MNOP',
active: true,
level: 'Advanced',
userId: 'user-1'
};
@@ -54,42 +51,21 @@ describe('POST /api/v2/license', () => {
_clearRateLimits();
});
it('returns the subscription_level object for a valid key', async () => {
it('returns key and email_address for a valid key', async () => {
mockKeyFindFirst.mockResolvedValue(storedKey);
mockUserFindUnique.mockResolvedValue({ id: 'user-1', email: 'user@example.com' });
mockLevelFindFirst.mockResolvedValue({
id: 2,
name: 'Advanced',
description: 'General + Specific'
});
const res = await POST({
request: licenseRequest({ license_key: storedKey.key, email_address: 'user@example.com' })
} as POSTEvent);
expect(res.status).toBe(200);
expect(await res.json()).toEqual({
const body = (await res.json()) as Record<string, unknown>;
expect(body).toEqual({
key: storedKey.key,
email_address: 'user@example.com',
subscription_level: { id: 2, name: 'Advanced', description: 'General + Specific' }
});
});
it('falls back to a structurally consistent subscription_level for an unknown level name', async () => {
mockKeyFindFirst.mockResolvedValue({ ...storedKey, level: 'SomeCustomLevel' });
mockUserFindUnique.mockResolvedValue({ id: 'user-1', email: 'user@example.com' });
mockLevelFindFirst.mockResolvedValue(null);
const res = await POST({
request: licenseRequest({ license_key: storedKey.key, email_address: 'user@example.com' })
} as POSTEvent);
expect(res.status).toBe(200);
expect(await res.json()).toEqual({
key: storedKey.key,
email_address: 'user@example.com',
subscription_level: { id: null, name: 'SomeCustomLevel', description: null }
email_address: 'user@example.com'
});
expect(Object.keys(body)).not.toContain('subscription_level');
});
it('rejects a missing license_key', async () => {
@@ -8,6 +8,7 @@ import {
type ProclamationWithFrequency
} from '$lib/server/v2-serializers';
// groupId is a scalar column; no relation include needed for serialization.
const PROCLAMATION_INCLUDE = { solfeggioFrequency: true } as const;
export const GET: RequestHandler = async ({ request }) => {
@@ -38,7 +38,8 @@ const peaceProclamation = {
name: 'Peace & Relaxation',
content: '<p>The text content</p>',
categoryId: 5,
subscriptionLevelId: 1,
groupId: 1,
solfeggioFrequencyId: 5,
solfeggioFrequency: { id: 5, name: 'Solar Plexus', value: 528 }
};
@@ -48,7 +49,7 @@ const crown936 = {
name: 'Crown 936',
content: '<p>no canonical id</p>',
categoryId: 4,
subscriptionLevelId: 1,
solfeggioFrequencyId: 1,
solfeggioFrequency: { id: 1, name: 'Crown', value: 936 }
};
@@ -88,8 +89,8 @@ describe('GET /api/v2/proclamations', () => {
name: peaceProclamation.name,
content: peaceProclamation.content,
categoryId: 5,
groupId: 1,
solfeggioFrequencyId: 5,
subscriptionLevelId: 1,
colorPalette: palettes[0].colors
}
]);
@@ -1,11 +0,0 @@
import type { RequestHandler } from './$types';
import { prisma } from '$lib/db';
import { jsonOk } from '$lib/server/v2-helpers';
import { serializeSubscriptionLevel } from '$lib/server/v2-serializers';
export const GET: RequestHandler = async () => {
const levels = await prisma.subscriptionLevel.findMany({
orderBy: { id: 'asc' }
});
return jsonOk(levels.map(serializeSubscriptionLevel));
};
+45 -37
View File
@@ -1,49 +1,57 @@
<script lang="ts">
import { resolve } from '$app/paths';
import type { PageProps } from './$types';
import Window from '$lib/components/containers/Window.svelte';
let { form }: PageProps = $props();
</script>
<div class="mx-auto mt-12 max-w-md dark:text-gray-100">
<h1 class="mb-6 text-2xl font-semibold text-gray-900 dark:text-gray-100">Forgot Password</h1>
<Window>
{#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"
>
If an account with that email exists, we've sent a password reset link.
<div class="rounded-lg bg-green-50 px-4 py-6 text-center">
<h1 class="text-xl font-bold text-slate-900">Check your email</h1>
<p class="mt-3 text-sm text-slate-600">
If an account with that email exists, we've sent a password reset link.
</p>
</div>
{/if}
{:else}
{#if form?.error && !form?.success}
<div class="mb-4 rounded-lg bg-red-50 px-4 py-3 text-sm text-red-700" role="alert">
{form.error}
</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"
<form method="POST" class="flex flex-col gap-4">
<h1 class="text-2xl font-bold text-slate-900">Forgot Password</h1>
<p class="text-sm text-slate-600">
Enter your email and we'll send you a link to reset your password.
</p>
<div class="flex flex-col gap-1.5">
<label for="email" class="text-sm font-medium text-slate-700"> Email Address </label>
<input
id="email"
type="email"
name="email"
placeholder="you@example.com"
required
class="h-11 w-full rounded-lg border-slate-300 px-3 text-sm text-slate-900 focus:border-indigo-500 focus:ring-indigo-500"
/>
</div>
<button
type="submit"
class="mt-1 flex h-11 w-full items-center justify-center rounded-lg bg-indigo-600 text-base font-medium text-white hover:bg-indigo-700"
>
Send Reset Link
</button>
</form>
<a
href={resolve('/login')}
class="mt-6 block text-center text-sm text-indigo-600 hover:text-indigo-700"
>
{form.error}
</div>
Back to Login
</a>
{/if}
<form method="POST" class="flex flex-col gap-4">
<input
type="email"
name="email"
placeholder="Email Address"
required
class="rounded-lg border border-gray-300 px-3 py-2 shadow-sm focus:border-blue-500 focus:ring-1 focus:ring-blue-500 dark:border-gray-600"
/>
<button
type="submit"
class="rounded-lg bg-blue-600 px-4 py-2 font-medium text-white transition-colors hover:bg-blue-700 dark:bg-gray-700 dark:text-gray-100 dark:hover:bg-gray-600"
>
Send Reset Link
</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"
>
Back to Login
</a>
</div>
</Window>
+14 -91
View File
@@ -2,95 +2,18 @@
@plugin '@tailwindcss/forms';
@plugin '@tailwindcss/typography';
/* Use class-based dark mode instead of prefers-color-scheme */
@custom-variant dark (&:where(.dark, .dark *));
/* ------------------------------------------------------------------ */
/* Wireframe base — remap all non-gray colors to grayscale */
/* 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);
--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;
}
/* ------------------------------------------------------------------ */
/* Dark mode form elements */
/* ------------------------------------------------------------------ */
.dark input,
.dark select,
.dark textarea {
background-color: #1f2937;
color: #f9fafb;
border-color: #374151;
}
/* ------------------------------------------------------------------ */
/* Force-disable transitions, animations, and remaining decoration */
/* ------------------------------------------------------------------ */
*,
::before,
::after {
transition: none !important;
animation: none !important;
@layer base {
body {
font-family:
ui-sans-serif,
system-ui,
-apple-system,
'Segoe UI',
Roboto,
'Helvetica Neue',
Arial,
sans-serif;
-webkit-font-smoothing: antialiased;
text-rendering: optimizeLegibility;
}
}
+83 -41
View File
@@ -1,6 +1,7 @@
<script lang="ts">
import { authClient } from '$lib/auth-client';
import { page } from '$app/state';
import { resolve } from '$app/paths';
import Window from '$lib/components/containers/Window.svelte';
let email = $state('');
let password = $state('');
@@ -10,7 +11,7 @@
let verified = $derived(page.url.searchParams.get('verified') === 'true');
async function handleSubmit() {
const { data, error: authError } = await authClient.signIn.email({
const { error: authError } = await authClient.signIn.email({
email,
password,
rememberMe
@@ -26,47 +27,88 @@
</script>
<Window>
<div class="dark:text-gray-100">
{#if verified}
<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"
>
Your email has been verified. You can now log in.
</div>
{/if}
{#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">
<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
>
</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"
>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"
>Don't have an account? Register</a
>
{#if verified}
<div class="mb-4 rounded-lg bg-green-50 px-4 py-3 text-sm text-green-800" role="status">
Your email has been verified. You can now log in.
</div>
<div class="mt-4 flex flex-col items-center justify-center">
<span>Or</span>
<button
onclick={async () => {
await authClient.signIn.social({ provider: 'google', callbackURL: '/account' });
}}
>
<img src="light/web_light_sq_SI.svg" alt="Sign In with Google" />
</button>
{/if}
{#if error}
<div class="mb-4 rounded-lg bg-red-50 px-4 py-3 text-sm text-red-700" role="alert">
{error}
</div>
{/if}
<form onsubmit={handleSubmit} class="flex flex-col gap-4">
<h1 class="text-2xl font-bold text-slate-900">Login</h1>
<div class="flex flex-col gap-1.5">
<label for="email" class="text-sm font-medium text-slate-700"> Email Address </label>
<input
id="email"
type="email"
placeholder="Email Address"
bind:value={email}
required
class="h-11 w-full rounded-lg border-slate-300 px-3 text-sm text-slate-900 focus:border-indigo-500 focus:ring-indigo-500"
/>
</div>
<div class="flex flex-col gap-1.5">
<label for="password" class="text-sm font-medium text-slate-700"> Password </label>
<input
id="password"
type="password"
placeholder="Password"
bind:value={password}
required
class="h-11 w-full rounded-lg border-slate-300 px-3 text-sm text-slate-900 focus:border-indigo-500 focus:ring-indigo-500"
/>
</div>
<label class="flex items-center gap-2 text-sm text-slate-600">
<input
type="checkbox"
bind:checked={rememberMe}
class="h-4 w-4 rounded border-slate-300 text-indigo-600 focus:ring-indigo-500"
/>
Remember me
</label>
<button
type="submit"
class="mt-1 flex h-11 w-full items-center justify-center rounded-lg bg-indigo-600 text-base font-medium text-white hover:bg-indigo-700"
>
Sign In
</button>
</form>
<div class="mt-6 flex flex-col gap-2 text-center text-sm">
<a href={resolve('/forgot-password')} class="text-indigo-600 hover:text-indigo-700">
Forgot Password?
</a>
<span class="text-slate-500">
Don't have an account?
<a href={resolve('/register')} class="font-medium text-indigo-600 hover:text-indigo-700">
Register
</a>
</span>
</div>
<div class="mt-6 border-t border-slate-200 pt-6">
<div class="flex items-center gap-3">
<div class="h-px flex-1 bg-slate-200"></div>
<span class="text-sm text-slate-500">Or</span>
<div class="h-px flex-1 bg-slate-200"></div>
</div>
<button
type="button"
onclick={async () => {
await authClient.signIn.social({ provider: 'google', callbackURL: '/account' });
}}
class="mx-auto mt-4 flex h-11 items-center justify-center rounded-lg bg-white hover:bg-slate-50"
>
<img src="light/web_light_sq_SI.svg" alt="Sign In with Google" class="" />
</button>
</div>
</Window>
+44 -1
View File
@@ -1,13 +1,56 @@
import { page } from 'vitest/browser';
import { describe, expect, it } from 'vitest';
import { describe, expect, it, vi } from 'vitest';
import { render } from 'vitest-browser-svelte';
vi.mock('$lib/auth-client', () => ({
authClient: { useSession: vi.fn(), signOut: vi.fn() }
}));
import { authClient } from '$lib/auth-client';
import Page from './+page.svelte';
type UseSessionStore = ReturnType<typeof authClient.useSession>;
function sessionStore(data: unknown = null) {
return {
data,
error: null,
isPending: false,
isRefetching: false,
refetch: () => Promise.resolve(),
subscribe(callback: (value: unknown) => void) {
callback({ data, error: null, isPending: false, isRefetching: false });
return () => {};
}
} as unknown as UseSessionStore;
}
describe('/+page.svelte', () => {
it('should render h1', async () => {
vi.mocked(authClient.useSession).mockReturnValue(sessionStore());
render(Page);
const heading = page.getByRole('heading', { level: 1 });
await expect.element(heading).toBeInTheDocument();
});
it('shows sign-in CTAs when signed out', async () => {
vi.mocked(authClient.useSession).mockReturnValue(sessionStore(null));
render(Page);
await expect.element(page.getByRole('link', { name: 'Get Started' })).toBeInTheDocument();
await expect.element(page.getByRole('link', { name: 'Create Account' })).toBeInTheDocument();
});
it('shows dashboard CTA when signed in', async () => {
vi.mocked(authClient.useSession).mockReturnValue(
sessionStore({ user: { id: 'u1' }, session: { id: 's1' } })
);
render(Page);
await expect.element(page.getByRole('link', { name: 'Go to Dashboard' })).toBeInTheDocument();
});
});
+19 -1
View File
@@ -1,5 +1,6 @@
import type { Actions } from './$types';
import { auth } from '$lib/auth';
import { prisma } from '$lib/db';
import { fail } from '@sveltejs/kit';
export const actions: Actions = {
@@ -8,7 +9,8 @@ export const actions: Actions = {
const firstName = data.get('firstName') as string;
const lastName = data.get('lastName') as string;
const email = data.get('email') as string;
const rawEmail = data.get('email') as string;
const email = rawEmail?.trim().toLowerCase() ?? '';
const password = data.get('password') as string;
const password2 = data.get('password2') as string;
@@ -25,6 +27,22 @@ export const actions: Actions = {
}
});
// Link any profile / license key that was created for this person
// before they had an account (matched by email).
const user = await prisma.user.findUnique({ where: { email } });
if (user) {
await prisma.$transaction([
prisma.profile.updateMany({
where: { email, userId: null },
data: { userId: user.id }
}),
prisma.licenseKey.updateMany({
where: { email, userId: null },
data: { userId: user.id }
})
]);
}
return { success: true };
} catch (err) {
const message = err instanceof Error ? err.message : 'An unexpected error occurred';
+116 -48
View File
@@ -1,60 +1,128 @@
<script lang="ts">
import Window from '$lib/components/containers/Window.svelte';
import { authClient } from '$lib/auth-client';
import { resolve } from '$app/paths';
import type { PageProps } from './$types';
let { form }: PageProps = $props();
</script>
<Window>
<div class="dark:text-gray-100">
{#if form?.success}
<div class="mx-auto max-w-md text-center">
<div
class="rounded-lg border border-green-200 bg-green-50 p-6 text-green-700 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-300"
>
<p class="text-lg font-medium">Account created!</p>
<p class="mt-2">
We've sent a verification email. Check your inbox and click the link to verify your
email address, then log in.
</p>
</div>
</div>
{: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"
>
{form.error}
</div>
{/if}
<form method="POST" class="flex flex-col items-start">
<input type="text" name="firstName" placeholder="First Name" required class="mb-2" />
<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"
>Submit</button
>
</form>
<p class=" mx-auto my-6 text-center">Or</p>
<button
class="mx-auto"
onclick={async () => {
await authClient.signIn.social({ provider: 'google', callbackURL: '/account/setup' });
}}><img src="light/web_light_sq_SU.svg" alt="Sign Up with Google" /></button
>
{#if form?.success}
<div class="rounded-lg bg-green-50 px-4 py-6 text-center">
<h1 class="text-xl font-bold text-slate-900">Account created!</h1>
<p class="mt-3 text-sm text-slate-600">
We've sent a verification email. Check your inbox and click the link to verify your email
address, then log in.
</p>
<a
href={resolve('/login')}
class="mt-6 inline-flex h-11 items-center justify-center rounded-lg bg-indigo-600 px-6 text-sm font-medium text-white hover:bg-indigo-700"
>
Go to Login
</a>
</div>
{:else}
{#if form?.error}
<div class="mb-4 rounded-lg bg-red-50 px-4 py-3 text-sm text-red-700" role="alert">
{form.error}
</div>
{/if}
</div>
<form method="POST" class="flex flex-col gap-4">
<h1 class="text-2xl font-bold text-slate-900">Create Account</h1>
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div class="flex flex-col gap-1.5">
<label for="firstName" class="text-sm font-medium text-slate-700"> First Name </label>
<input
id="firstName"
type="text"
name="firstName"
placeholder="First Name"
required
class="h-11 w-full rounded-lg border-slate-300 px-3 text-sm text-slate-900 focus:border-indigo-500 focus:ring-indigo-500"
/>
</div>
<div class="flex flex-col gap-1.5">
<label for="lastName" class="text-sm font-medium text-slate-700"> Last Name </label>
<input
id="lastName"
type="text"
name="lastName"
placeholder="Last Name"
required
class="h-11 w-full rounded-lg border-slate-300 px-3 text-sm text-slate-900 focus:border-indigo-500 focus:ring-indigo-500"
/>
</div>
</div>
<div class="flex flex-col gap-1.5">
<label for="email" class="text-sm font-medium text-slate-700"> Email Address </label>
<input
id="email"
type="email"
name="email"
placeholder="Email Address"
required
class="h-11 w-full rounded-lg border-slate-300 px-3 text-sm text-slate-900 focus:border-indigo-500 focus:ring-indigo-500"
/>
</div>
<div class="flex flex-col gap-1.5">
<label for="password" class="text-sm font-medium text-slate-700"> Password </label>
<input
id="password"
type="password"
name="password"
placeholder="Password"
required
class="h-11 w-full rounded-lg border-slate-300 px-3 text-sm text-slate-900 focus:border-indigo-500 focus:ring-indigo-500"
/>
</div>
<div class="flex flex-col gap-1.5">
<label for="password2" class="text-sm font-medium text-slate-700"> Verify Password </label>
<input
id="password2"
type="password"
name="password2"
placeholder="Verify Password"
required
class="h-11 w-full rounded-lg border-slate-300 px-3 text-sm text-slate-900 focus:border-indigo-500 focus:ring-indigo-500"
/>
</div>
<button
type="submit"
class="mt-1 flex h-11 w-full items-center justify-center rounded-lg bg-indigo-600 text-base font-medium text-white hover:bg-indigo-700"
>
Submit
</button>
</form>
<div class="mt-6 border-t border-slate-200 pt-6">
<div class="flex items-center gap-3">
<div class="h-px flex-1 bg-slate-200"></div>
<span class="text-sm text-slate-500">Or</span>
<div class="h-px flex-1 bg-slate-200"></div>
</div>
<button
type="button"
onclick={async () => {
await authClient.signIn.social({ provider: 'google', callbackURL: '/account/setup' });
}}
class="mt-4 flex h-11 w-full items-center justify-center rounded-lg border border-slate-300 bg-white hover:bg-slate-50"
>
<img src="light/web_light_sq_SU.svg" alt="Sign Up with Google" class="h-6" />
</button>
</div>
<p class="mt-6 text-center text-sm text-slate-500">
Already have an account?
<a href={resolve('/login')} class="font-medium text-indigo-600 hover:text-indigo-700">
Login
</a>
</p>
{/if}
</Window>
+45 -4
View File
@@ -2,12 +2,22 @@ import { describe, it, expect, vi, beforeEach } from 'vitest';
import { isActionFailure } from '@sveltejs/kit';
import { createFormRequest } from '$lib/test-utils';
const { signUpEmail } = vi.hoisted(() => ({
signUpEmail: vi.fn()
}));
const { signUpEmail, userFindUnique, profileUpdateMany, licenseKeyUpdateMany, $transaction } =
vi.hoisted(() => ({
signUpEmail: vi.fn(),
userFindUnique: vi.fn(),
profileUpdateMany: vi.fn(),
licenseKeyUpdateMany: vi.fn(),
$transaction: vi.fn((ops: Promise<unknown>[]) => Promise.all(ops))
}));
vi.mock('$lib/db', () => ({
prisma: {}
prisma: {
user: { findUnique: userFindUnique },
profile: { updateMany: profileUpdateMany },
licenseKey: { updateMany: licenseKeyUpdateMany },
$transaction
}
}));
vi.mock('$lib/auth', () => ({
@@ -25,6 +35,10 @@ const invokeDefault = (request: Request) => actions.default({ request } as any);
describe('register page server action', () => {
beforeEach(() => {
signUpEmail.mockReset();
userFindUnique.mockReset();
profileUpdateMany.mockReset();
licenseKeyUpdateMany.mockReset();
$transaction.mockClear();
});
it('returns fail(400) when passwords do not match', async () => {
@@ -46,6 +60,7 @@ describe('register page server action', () => {
it('returns success on successful signup', async () => {
signUpEmail.mockResolvedValue({ user: { id: 'user-1' } });
userFindUnique.mockResolvedValue(null);
const request = createFormRequest({
firstName: 'Jane',
@@ -66,6 +81,32 @@ describe('register page server action', () => {
});
});
it('links existing profile and license keys by email after signup', async () => {
const user = { id: 'user-1', email: 'jane@example.com' };
signUpEmail.mockResolvedValue({ user });
userFindUnique.mockResolvedValue(user);
const request = createFormRequest({
firstName: 'Jane',
lastName: 'Doe',
email: 'jane@example.com',
password: 'secret123',
password2: 'secret123'
});
const result = await invokeDefault(request);
expect(result).toEqual({ success: true });
expect(profileUpdateMany).toHaveBeenCalledWith({
where: { email: 'jane@example.com', userId: null },
data: { userId: 'user-1' }
});
expect(licenseKeyUpdateMany).toHaveBeenCalledWith({
where: { email: 'jane@example.com', userId: null },
data: { userId: 'user-1' }
});
expect($transaction).toHaveBeenCalledTimes(1);
});
it('returns fail(400) with error message when signUpEmail throws', async () => {
signUpEmail.mockRejectedValue(new Error('email already in use'));
+35 -24
View File
@@ -1,42 +1,53 @@
<script lang="ts">
import type { PageProps } from './$types';
import Window from '$lib/components/containers/Window.svelte';
let { form }: PageProps = $props();
</script>
<div class="mx-auto mt-12 max-w-md dark:text-gray-100">
<h1 class="mb-6 text-2xl font-semibold text-gray-900 dark:text-gray-100">Reset Password</h1>
<Window>
{#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 bg-red-50 px-4 py-3 text-sm text-red-700" role="alert">
{form.error}
</div>
{/if}
<form method="POST" class="flex flex-col gap-4">
<input
type="password"
name="password"
placeholder="New Password"
required
minlength="8"
class="rounded-lg border border-gray-300 px-3 py-2 shadow-sm focus:border-blue-500 focus:ring-1 focus:ring-blue-500 dark:border-gray-600"
/>
<input
type="password"
name="password2"
placeholder="Confirm New Password"
required
minlength="8"
class="rounded-lg border border-gray-300 px-3 py-2 shadow-sm focus:border-blue-500 focus:ring-1 focus:ring-blue-500 dark:border-gray-600"
/>
<h1 class="text-2xl font-bold text-slate-900">Reset Password</h1>
<div class="flex flex-col gap-1.5">
<label for="password" class="text-sm font-medium text-slate-700"> New Password </label>
<input
id="password"
type="password"
name="password"
placeholder="••••••••"
required
minlength="8"
class="h-11 w-full rounded-lg border-slate-300 px-3 text-sm text-slate-900 focus:border-indigo-500 focus:ring-indigo-500"
/>
</div>
<div class="flex flex-col gap-1.5">
<label for="password2" class="text-sm font-medium text-slate-700">
Confirm New Password
</label>
<input
id="password2"
type="password"
name="password2"
placeholder="••••••••"
required
minlength="8"
class="h-11 w-full rounded-lg border-slate-300 px-3 text-sm text-slate-900 focus:border-indigo-500 focus:ring-indigo-500"
/>
</div>
<button
type="submit"
class="rounded-lg bg-blue-600 px-4 py-2 font-medium text-white transition-colors hover:bg-blue-700 dark:bg-gray-700 dark:text-gray-100 dark:hover:bg-gray-600"
class="mt-1 flex h-11 w-full items-center justify-center rounded-lg bg-indigo-600 text-base font-medium text-white hover:bg-indigo-700"
>
Reset Password
</button>
</form>
</div>
</Window>