Merge main into dev

This commit is contained in:
NeilClack
2026-07-18 15:51:43 -05:00
21 changed files with 366 additions and 31 deletions
+4 -3
View File
@@ -5,9 +5,10 @@ import { sveltekitCookies } from 'better-auth/svelte-kit';
import { getRequestEvent } from '$app/server';
import { sendMail } from './server/resend';
import { sendVerificationEmail } from './server/email-verification';
import { env } from '$env/dynamic/private';
export const auth = betterAuth({
baseURL: process.env.BETTER_AUTH_URL,
baseURL: env.BETTER_AUTH_URL,
database: prismaAdapter(prisma, {
provider: 'postgresql'
}),
@@ -41,8 +42,8 @@ export const auth = betterAuth({
socialProviders: {
google: {
prompt: 'select_account',
clientId: process.env.GOOGLE_CLIENT_ID as string,
clientSecret: process.env.GOOGLE_CLIENT_SECRET as string
clientId: env.GOOGLE_CLIENT_ID as string,
clientSecret: env.GOOGLE_CLIENT_SECRET as string
}
},
rateLimit: {
+2 -11
View File
@@ -1,23 +1,14 @@
import 'dotenv/config';
import fs from 'fs';
import pg from 'pg';
import { PrismaPg } from '@prisma/adapter-pg';
import { PrismaClient } from '$lib/generated/prisma/client';
import { env } from '$env/dynamic/private';
let _prisma: PrismaClient | undefined;
function getPrisma(): PrismaClient {
if (!_prisma) {
const ca_cert = process.env.DO_CA_CERTIFICATE ?? '';
const pool = new pg.Pool({
connectionString: process.env.DATABASE_URL,
ssl: ca_cert
? {
rejectUnauthorized: true,
ca: fs.readFileSync(ca_cert).toString()
}
: undefined
connectionString: env.DATABASE_URL
});
const adapter = new PrismaPg(pool);
+2 -2
View File
@@ -1,8 +1,8 @@
import { BETTER_AUTH_URL } from '$env/static/private';
import { env } from '$env/dynamic/private';
import { sendMail } from './resend';
export async function sendVerificationEmail(options: { email: string; token: string }) {
const verificationUrl = `${BETTER_AUTH_URL}/api/auth/verify-email?token=${options.token}&callbackURL=/login?verified=true`;
const verificationUrl = `${env.BETTER_AUTH_URL}/api/auth/verify-email?token=${options.token}&callbackURL=/login?verified=true`;
await sendMail({
to: options.email,
+12 -5
View File
@@ -9,9 +9,16 @@
*
*/
import { Resend } from 'resend';
import { RESEND_API_KEY, EMAIL_FROM, RESEND_TEMPLATE_ID } from '$env/static/private';
import { env } from '$env/dynamic/private';
export const resend = new Resend(RESEND_API_KEY);
let _resend: Resend;
function getResend(): Resend {
if (!_resend) {
_resend = new Resend(env.RESEND_API_KEY);
}
return _resend;
}
/**
* Send a transactional email using a Resend-hosted template.
@@ -38,12 +45,12 @@ export async function sendMail(options: {
}) {
const { to, subject, templateId, data } = options;
const result = await resend.emails.send({
from: EMAIL_FROM,
const result = await getResend().emails.send({
from: env.EMAIL_FROM,
to: [to],
subject: subject ?? 'Template Email',
template: {
id: templateId ?? RESEND_TEMPLATE_ID,
id: templateId ?? env.RESEND_TEMPLATE_ID,
variables: data ?? {}
}
});
+2 -1
View File
@@ -3,4 +3,5 @@ export { ROLES, hasRole, canAccessAdmin } from './roles';
export { fieldNameToLabel, resolveFieldValue } from './introspect';
export { getDelegate, parseId } from './prisma-delegate';
export { extractFormData } from './form-data';
export { getModelConfig, getAllModelConfigs } from './registry';
export { getModelConfig, getAllModelConfigs, getSerializableModelConfigs } from './registry';
export type { SerializableModelConfig } from './registry';
+21
View File
@@ -267,3 +267,24 @@ export function getModelConfig(slug: string): AdminModelConfig | undefined {
export function getAllModelConfigs(): AdminModelConfig[] {
return models;
}
export type SerializableModelConfig = {
slug: string;
displayName: string;
listFields: string[];
accessFilterResult: Record<string, unknown> | undefined;
};
export function getSerializableModelConfigs(
viewerRoleId: number,
excludeSlugs?: string[]
): SerializableModelConfig[] {
return models
.filter((m) => !excludeSlugs?.includes(m.slug))
.map((m) => ({
slug: m.slug,
displayName: m.displayName,
listFields: m.listFields,
accessFilterResult: m.accessFilter?.(viewerRoleId)
}));
}
@@ -0,0 +1,25 @@
<script lang="ts">
import { page } from '$app/stores';
import Window from '$lib/components/containers/Window.svelte';
</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">
Admin Error ({$page.status})
</h1>
<p class="max-w-md text-sm text-gray-600 dark:text-gray-400">
{#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>
</div>
</Window>
+4 -3
View File
@@ -1,7 +1,8 @@
import type { PageServerLoad } from './$types';
import { getAllModelConfigs } from '$lib/svelte-admin/registry';
import { getSerializableModelConfigs } from '$lib/svelte-admin/registry';
export const load: PageServerLoad = async () => {
const models = getAllModelConfigs().filter((m) => m.slug !== 'license-keys');
export const load: PageServerLoad = async ({ locals }) => {
const viewerRoleId = (locals.user as { roleId?: number } | null)?.roleId ?? 4;
const models = getSerializableModelConfigs(viewerRoleId, ['license-keys']);
return { models };
};
@@ -0,0 +1,107 @@
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 } });
});
});
@@ -3,7 +3,7 @@ import { json, error } from '@sveltejs/kit';
import { auth } from '$lib/auth';
import { prisma } from '$lib/db';
import { sendMail } from '$lib/server/resend';
import { BETTER_AUTH_URL } from '$env/static/private';
import { env } from '$env/dynamic/private';
function generateLicenseKey(): string {
const hex = crypto.randomUUID().replace(/-/g, '').slice(0, 16).toUpperCase();
@@ -53,8 +53,8 @@ export const POST: RequestHandler = async ({ request }) => {
});
const verifyUrl = verification
? `${BETTER_AUTH_URL}/api/auth/verify-email?token=${verification.value}&callbackURL=/login?verified=true`
: `${BETTER_AUTH_URL}/login`;
? `${env.BETTER_AUTH_URL}/api/auth/verify-email?token=${verification.value}&callbackURL=/login?verified=true`
: `${env.BETTER_AUTH_URL}/login`;
await sendMail({
to: email,
@@ -64,7 +64,7 @@ export const POST: RequestHandler = async ({ request }) => {
firstName: name ?? email.split('@')[0],
tempPassword,
verifyUrl,
loginUrl: `${BETTER_AUTH_URL}/login`
loginUrl: `${env.BETTER_AUTH_URL}/login`
}
});
} catch (err) {