Merge main into dev
This commit is contained in:
+4
-3
@@ -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
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 ?? {}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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>
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user