From 6905d1c172aa2758e4ede0e52792b1e56ef6a685 Mon Sep 17 00:00:00 2001 From: LudoLoops Date: Mon, 12 Oct 2026 22:01:54 +0200 Subject: [PATCH] fix(dashboard): clarify dialog trigger affordances --- .changeset/bright-dialog-cursors.md | 4 + graphify-out/graph.json | 91 +++++++++++++++++++ graphify-out/manifest.json | 14 ++- packages/svforge/src/templates.ts | 4 +- .../svforge/layout/AdminLayout.svelte | 4 +- .../src/routes/(app)/admin/users/+page.svelte | 8 +- tests/dashboard-trigger-affordance.test.ts | 22 +++++ 7 files changed, 136 insertions(+), 11 deletions(-) create mode 100644 .changeset/bright-dialog-cursors.md create mode 100644 tests/dashboard-trigger-affordance.test.ts diff --git a/.changeset/bright-dialog-cursors.md b/.changeset/bright-dialog-cursors.md new file mode 100644 index 00000000..f85a360f --- /dev/null +++ b/.changeset/bright-dialog-cursors.md @@ -0,0 +1,4 @@ +--- +'svforge': patch +--- +Show a pointer cursor on enabled dashboard dialog triggers. diff --git a/graphify-out/graph.json b/graphify-out/graph.json index 65b5f17b..065b5d9f 100644 --- a/graphify-out/graph.json +++ b/graphify-out/graph.json @@ -22848,6 +22848,23 @@ "_callable": true, "_origin": "ast" }, + { + "id": "tests_dashboard_trigger_affordance_test", + "label": "dashboard-trigger-affordance.test.ts", + "file_type": "code", + "source_file": "tests/dashboard-trigger-affordance.test.ts", + "source_location": "L1", + "_origin": "ast" + }, + { + "id": "tests_dashboard_trigger_affordance_test_triggerclasses", + "label": "triggerClasses()", + "file_type": "code", + "source_file": "tests/dashboard-trigger-affordance.test.ts", + "source_location": "L5", + "_callable": true, + "_origin": "ast" + }, { "id": "tests_dashboard_vitest_test", "label": "dashboard-vitest.test.ts", @@ -26252,6 +26269,15 @@ "source_location": "L1", "_origin": "ast" }, + { + "id": "changeset_bright_dialog_cursors", + "label": "bright-dialog-cursors.md", + "file_type": "document", + "node_kind": "page", + "source_file": ".changeset/bright-dialog-cursors.md", + "source_location": "L1", + "_origin": "ast" + }, { "id": "changeset_tidy_button_classes", "label": "tidy-button-classes.md", @@ -77897,6 +77923,71 @@ "weight": 1, "_origin": "ast" }, + { + "source": "tests_dashboard_trigger_affordance_test", + "target": "ref_vitest", + "relation": "imports_from", + "context": "import", + "confidence": "EXTRACTED", + "source_file": "tests/dashboard-trigger-affordance.test.ts", + "source_location": "L1", + "weight": 1, + "_origin": "ast" + }, + { + "source": "tests_dashboard_trigger_affordance_test", + "target": "ref_node_fs", + "relation": "imports_from", + "context": "import", + "confidence": "EXTRACTED", + "source_file": "tests/dashboard-trigger-affordance.test.ts", + "source_location": "L2", + "weight": 1, + "_origin": "ast" + }, + { + "source": "tests_dashboard_trigger_affordance_test", + "target": "tests_helpers", + "relation": "imports_from", + "context": "import", + "confidence": "EXTRACTED", + "source_file": "tests/dashboard-trigger-affordance.test.ts", + "source_location": "L3", + "weight": 1, + "_origin": "ast" + }, + { + "source": "tests_dashboard_trigger_affordance_test", + "target": "tests_helpers_dashboardtemplatefile", + "relation": "imports", + "context": "import", + "confidence": "EXTRACTED", + "source_file": "tests/dashboard-trigger-affordance.test.ts", + "source_location": "L3", + "weight": 1, + "_origin": "ast" + }, + { + "source": "tests_dashboard_trigger_affordance_test", + "target": "tests_dashboard_trigger_affordance_test_triggerclasses", + "relation": "contains", + "confidence": "EXTRACTED", + "source_file": "tests/dashboard-trigger-affordance.test.ts", + "source_location": "L5", + "weight": 1, + "_origin": "ast" + }, + { + "source": "tests_dashboard_trigger_affordance_test", + "target": "tests_dashboard_trigger_affordance_test_triggerclasses", + "relation": "calls", + "context": "call", + "confidence": "EXTRACTED", + "source_file": "tests/dashboard-trigger-affordance.test.ts", + "source_location": "L17", + "weight": 1, + "_origin": "ast" + }, { "source": "tests_dashboard_vitest_test", "target": "ref_vitest", diff --git a/graphify-out/manifest.json b/graphify-out/manifest.json index 2de377f8..eeba4901 100644 --- a/graphify-out/manifest.json +++ b/graphify-out/manifest.json @@ -488,7 +488,7 @@ "semantic_hash": "" }, "packages/svforge/src/templates.ts": { - "ast_hash": "0a59e8810af269c5b766dd59df9db3f2", + "ast_hash": "8ea3c6d025e49ef0678e7d2994cc08a3", "semantic_hash": "" }, "packages/svforge/src/upgrade.ts": { @@ -736,7 +736,7 @@ "semantic_hash": "" }, "packages/svforge/templates/dashboard/src/lib/components/svforge/layout/AdminLayout.svelte": { - "ast_hash": "da6a353749cb2cc6c04c7d42420f15c3", + "ast_hash": "11f5ca893c77243145fd2c1ca6347103", "semantic_hash": "" }, "packages/svforge/templates/dashboard/src/lib/components/svforge/ui/AvatarInitial.svelte": { @@ -852,7 +852,7 @@ "semantic_hash": "" }, "packages/svforge/templates/dashboard/src/routes/(app)/admin/users/+page.svelte": { - "ast_hash": "69d51410d7d665e6350d2aa80036f62a", + "ast_hash": "bb95a5c9bc497e80cca0ddbb777d1088", "semantic_hash": "" }, "packages/svforge/templates/dashboard/src/routes/(app)/admin/users/page.server.test.ts": { @@ -2074,5 +2074,13 @@ ".changeset/tidy-button-classes.md": { "ast_hash": "6f46a25079bb34d5ee7eb6aa6f14acab", "semantic_hash": "" + }, + "tests/dashboard-trigger-affordance.test.ts": { + "ast_hash": "517752d0f4e57b837cf0a8367ca7335a", + "semantic_hash": "" + }, + ".changeset/bright-dialog-cursors.md": { + "ast_hash": "fdd08c0666fe945dc1f0baf093f852f8", + "semantic_hash": "" } } diff --git a/packages/svforge/src/templates.ts b/packages/svforge/src/templates.ts index f2d00499..745a011e 100644 --- a/packages/svforge/src/templates.ts +++ b/packages/svforge/src/templates.ts @@ -54,7 +54,7 @@ export const dashboardFiles = { "/hooks.server.test.ts": "import { describe, expect, it, vi } from 'vitest';\n\nconst query = {\n\tselect: vi.fn(() => query),\n\tfrom: vi.fn(() => query),\n\twhere: vi.fn(() => query),\n\tlimit: vi.fn<() => any[]>()\n};\n\nvi.mock('$app/env', () => ({ building: false }));\nvi.mock('$lib/paraglide/runtime', () => ({ getTextDirection: vi.fn(() => 'ltr') }));\nvi.mock('$lib/paraglide/server', () => ({\n\tparaglideMiddleware: vi.fn(async (request, next) => next({ request, locale: 'en' }))\n}));\nvi.mock('$lib/server/auth', () => ({\n\tauth: {\n\t\tapi: {\n\t\t\tgetSession: vi.fn(async () => ({\n\t\t\t\tsession: { id: 'session-1', userId: 'disabled-user' },\n\t\t\t\tuser: { id: 'disabled-user', email: 'disabled@example.com' }\n\t\t\t}))\n\t\t}\n\t}\n}));\nvi.mock('$lib/server/db', () => ({ db: query }));\nvi.mock('$lib/server/db/schema', () => ({ user: { id: 'id', disabled: 'disabled' } }));\nvi.mock('drizzle-orm', () => ({ eq: vi.fn() }));\nvi.mock('better-auth/svelte-kit', () => ({\n\t// Faithful to the real handler: resolve is called with the transform object.\n\tsvelteKitHandler: vi.fn(({ event, resolve }) => resolve(event, { transformPageChunk: (f: any) => f.html ?? f }))\n}));\n\ndescribe('dashboard auth hook', () => {\n\tit('does not populate locals for a session owned by a disabled user', async () => {\n\t\tquery.limit.mockReturnValueOnce([{ disabled: true }]);\n\t\tconst { handle } = await import('./hooks.server');\n\t\tconst event: any = {\n\t\t\trequest: new Request('http://localhost/admin'),\n\t\t\tlocals: {}\n\t\t};\n\t\tconst resolve = vi.fn(async () => new Response('ok'));\n\n\t\tawait handle({ event, resolve } as any);\n\n\t\texpect(event.locals.session).toBeUndefined();\n\t\texpect(event.locals.user).toBeUndefined();\n\t\texpect(resolve).toHaveBeenCalledWith(\n\t\t\texpect.objectContaining({ request: event.request, locals: event.locals }),\n\t\t\texpect.any(Object)\n\t\t);\n\t});\n});\n", "/hooks.server.ts": "import type { Handle } from '@sveltejs/kit/hooks';\n// `building` is used to skip auth during prerender/build (SvelteKit builds pages statically).\nimport { building } from '$app/env';\nimport { getTextDirection } from '$lib/paraglide/runtime';\nimport { paraglideMiddleware } from '$lib/paraglide/server';\nimport { auth } from '$lib/server/auth';\nimport { db } from '$lib/server/db';\nimport { user } from '$lib/server/db/schema';\nimport { eq } from 'drizzle-orm';\nimport { svelteKitHandler } from 'better-auth/svelte-kit';\n\n/**\n * Single composed handle (#280).\n *\n * Order matters:\n * 1. `paraglideMiddleware` negotiates the locale and rewrites `event.request`\n * (cookie / accept-language handling).\n * 2. Better Auth reads the session from the REWRITTEN request and populates\n * `locals.session` / `locals.user`; auth paths are routed to the auth\n * handler, every other request resolves through the app.\n * 3. The Paraglide transform rewrites the `%paraglide.lang%` /\n * `%paraglide.dir%` placeholders of app.html in the FINAL html — this must\n * survive the composition: the #268 fix (composing handleBetterAuth) had\n * dropped it, leaving the placeholders in the rendered HTML (#280).\n */\nexport const handle: Handle = async ({ event, resolve }) =>\n\tparaglideMiddleware(event.request, async ({ request, locale }) => {\n\t\tconst localizedEvent = { ...event, request };\n\n\t\tconst session = await auth.api.getSession({ headers: localizedEvent.request.headers });\n\t\tif (session) {\n\t\t\t// A disabled identity may be retained for domain/audit references, but\n\t\t\t// it must never retain access through an old or newly-created session.\n\t\t\tconst [currentUser] = await db\n\t\t\t\t.select({ disabled: user.disabled })\n\t\t\t\t.from(user)\n\t\t\t\t.where(eq(user.id, session.user.id))\n\t\t\t\t.limit(1);\n\t\t\tif (!currentUser?.disabled) {\n\t\t\t\tevent.locals.session = session.session;\n\t\t\t\tevent.locals.user = session.user;\n\t\t\t}\n\t\t}\n\n\t\treturn svelteKitHandler({\n\t\t\tevent: localizedEvent,\n\t\t\tauth,\n\t\t\tbuilding,\n\t\t\t// svelteKitHandler calls resolve(event) with a single argument — the\n\t\t\t// Paraglide transform is applied here so the i18n placeholders of\n\t\t\t// app.html are rewritten in the final html.\n\t\t\tresolve: (event) =>\n\t\t\t\tresolve(event, {\n\t\t\t\t\ttransformPageChunk: ({ html }) =>\n\t\t\t\t\t\thtml\n\t\t\t\t\t\t\t.replace('%paraglide.lang%', locale)\n\t\t\t\t\t\t\t.replace('%paraglide.dir%', getTextDirection(locale))\n\t\t\t\t})\n\t\t});\n\t});\n", "/lib/client/auth.ts": "import { createAuthClient } from 'better-auth/svelte';\n\nexport const authClient = createAuthClient();\n", - "/lib/components/svforge/layout/AdminLayout.svelte": "\n\n (mobileOpen = details.open)}\n\tcloseOnInteractOutside={true}\n\trestoreFocus={true}\n>\n
\n\t\n\t\n\n\t\n\t
\n\t\t\n\t\t
\n\t\t\t
\n\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\n\t\t\t\t

{m.layout_admin()}

\n\t\t\t
\n\t\t\t
\n\t\t\t\t\n\t\t\t\t{#if user}\n\t\t\t\t\t{user.name}\n\t\t\t\t\t{#if onSignOut}\n\t\t\t\t\t\t\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t{/if}\n\t\t\t\t{/if}\n\t\t\t
\n\t\t
\n\n\n\t\t\n\t\t
\n\t\t\t{@render children()}\n\t\t
\n\t
\n
\n\t\n\t\t\n\t\t\n\t\t\t\n\t\t\t\t
\n\t\t\t\t\t{m.layout_admin()}\n\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t
\n\t\t\t\t\n\t\t\t\n\t\t
\n\t
\n\n", + "/lib/components/svforge/layout/AdminLayout.svelte": "\n\n (mobileOpen = details.open)}\n\tcloseOnInteractOutside={true}\n\trestoreFocus={true}\n>\n
\n\t\n\t\n\n\t\n\t
\n\t\t\n\t\t
\n\t\t\t
\n\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\n\t\t\t\t

{m.layout_admin()}

\n\t\t\t
\n\t\t\t
\n\t\t\t\t\n\t\t\t\t{#if user}\n\t\t\t\t\t{user.name}\n\t\t\t\t\t{#if onSignOut}\n\t\t\t\t\t\t\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t{/if}\n\t\t\t\t{/if}\n\t\t\t
\n\t\t
\n\n\n\t\t\n\t\t
\n\t\t\t{@render children()}\n\t\t
\n\t
\n
\n\t\n\t\t\n\t\t\n\t\t\t\n\t\t\t\t
\n\t\t\t\t\t{m.layout_admin()}\n\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t
\n\t\t\t\t\n\t\t\t\n\t\t
\n\t
\n\n", "/lib/components/svforge/ui/AvatarInitial.svelte": "\n\n
\n {initial}\n
\n", "/lib/components/svforge/ui/Feedback.svelte": "\n\n{#if message}\n\t\n\t
\n\t\t{#if type === 'success'}{:else}{/if}\n\t\t{message}\n\t\t{#if ondismiss}\n\t\t\t\n\t\t{/if}\n\t
\n{/if}\n", "/lib/components/svforge/ui/index.ts": "export { default as Alert } from './Alert.svelte';\nexport { default as Card } from './Card.svelte';\nexport { default as Logo } from './Logo.svelte';\nexport { default as Seo } from './Seo.svelte';\nexport { default as Table } from './Table.svelte';\nexport { default as ThemeToggle } from './ThemeToggle.svelte';\nexport { generateSitemap } from './Sitemap';\nexport { default as AvatarInitial } from './AvatarInitial.svelte';\nexport { default as Feedback } from './Feedback.svelte';\n", @@ -85,7 +85,7 @@ export const dashboardFiles = { "/routes/(app)/admin/settings/+page.server.ts": "import { auth } from '$lib/server/auth';\nimport { changePasswordSchema } from '$lib/server/schemas';\nimport { fail, type Actions } from '@sveltejs/kit';\nimport type { PageServerLoad } from './$types';\n\nexport const load: PageServerLoad = async ({ locals }) => {\n\treturn {\n\t\tuser: locals.user!\n\t};\n};\n\nexport const actions: Actions = {\n\tchangePassword: async ({ request }) => {\n\t\tconst formData = await request.formData();\n\t\tconst parsed = changePasswordSchema.safeParse({\n\t\t\tcurrentPassword: formData.get('currentPassword'),\n\t\t\tnewPassword: formData.get('newPassword')\n\t\t});\n\n\t\tif (!parsed.success) {\n\t\t\treturn fail(400, { message: parsed.error.issues[0]?.message ?? 'Invalid input' });\n\t\t}\n\n\t\tconst { currentPassword, newPassword } = parsed.data;\n\n\t\ttry {\n\t\t\tawait auth.api.changePassword({\n\t\t\t\theaders: request.headers,\n\t\t\t\tbody: { currentPassword, newPassword }\n\t\t\t});\n\t\t\treturn { success: true };\n\t\t} catch {\n\t\t\t// Generic message — never leak e.message internals to the UI (#188).\n\t\t\treturn fail(400, { message: 'Failed to change password' });\n\t\t}\n\t}\n};\n", "/routes/(app)/admin/settings/+page.svelte": "\n\n\n\t{m.settings_title()}\n\n\n
\n\t

{m.settings_heading()}

\n\n\t\n\t\n\t\t{#snippet header()}\n\t\t\t

{m.settings_profile()}

\n\t\t{/snippet}\n\t\t
\n\t\t\t
\n\t\t\t\t\n\t\t\t\t
\n\t\t\t\t\t

{data.user?.name}

\n\t\t\t\t\t

{data.user?.email}

\n\t\t\t\t
\n\t\t\t
\n\t\t
\n\t
\n\n\t\n\t\n\t\t{#snippet header()}\n\t\t\t
\n\t\t\t\t\n\t\t\t\t

{m.settings_change_password()}

\n\t\t\t
\n\t\t{/snippet}\n\n\t\t{#if form?.message}\n\t\t\t\n\t\t{/if}\n\t\t{#if validationError}\n\t\t\t\n\t\t\t\n\t\t{/if}\n\n\t\t
{\n\t\t\t\tif (newPassword !== confirmPassword) {\n\t\t\t\t\te.preventDefault();\n\t\t\t\t\tvalidationError = m.settings_password_mismatch();\n\t\t\t\t} else {\n\t\t\t\t\tvalidationError = '';\n\t\t\t\t}\n\t\t\t}} use:enhance class=\"space-y-4\">\n\t\t\t\n\t\t\t\n\t\t\t\n\t\t\t
\n\t\t\t\t\n\t\t\t
\n\t\t
\n\t
\n
\n", "/routes/(app)/admin/users/+page.server.ts": "import { db } from '$lib/server/db';\nimport { user, session } from '$lib/server/db/schema';\nimport { desc, eq } from 'drizzle-orm';\nimport { fail, redirect, error, type Actions } from '@sveltejs/kit';\nimport { isAdmin } from '$lib/server/admin';\nimport { createCredentialUser, DuplicateEmailError } from '$lib/server/admin-users';\nimport { createInvitation, DuplicateInvitationError } from '$lib/server/invitations';\nimport {\n\tcreateUserSchema,\n\tupdateUserSchema,\n\ttoggleUserStatusSchema,\n\ttoggleVerifySchema,\n\tinviteSchema\n} from '$lib/server/schemas';\nimport type { PageServerLoad } from './$types';\nimport type { RequestEvent } from '@sveltejs/kit';\n\n/**\n * Require an authenticated admin user for a form action.\n * Returns the user id on success, or throws a 401/403 error.\n *\n * Every admin action must call this BEFORE reading formData or touching the db.\n */\nasync function requireAdmin(event: RequestEvent): Promise {\n\tif (!event.locals.user) {\n\t\tthrow error(401, { message: 'Authentication required' });\n\t}\n\tif (!(await isAdmin(event.locals.user.id))) {\n\t\tthrow error(403, { message: 'Admin access required' });\n\t}\n\treturn event.locals.user.id;\n}\n\nexport const load: PageServerLoad = async ({ locals }) => {\n\tif (!locals.user || !(await isAdmin(locals.user.id))) {\n\t\tthrow redirect(302, '/login');\n\t}\n\n\tconst users = await db\n\t\t.select({\n\t\t\tid: user.id,\n\t\t\tname: user.name,\n\t\t\temail: user.email,\n\t\t\temailVerified: user.emailVerified,\n\t\t\timage: user.image,\n\t\t\tdisabled: user.disabled,\n\t\t\tcreatedAt: user.createdAt\n\t\t})\n\t\t.from(user)\n\t\t.orderBy(desc(user.createdAt));\n\n\treturn { users };\n};\n\n/**\n * Form actions (#295) — golden reference for SvelteKit mutations.\n *\n * Every action returns a STABLE machine-readable `code` instead of English\n * copy: the UI maps codes to Paraglide FR/EN messages, so no visible text\n * comes from the server and the copy can never leak internal details.\n */\n\nexport const actions: Actions = {\n\tcreate: async (event) => {\n\t\tawait requireAdmin(event);\n\t\tconst { request } = event;\n\t\tconst formData = await request.formData();\n\t\tconst parsed = createUserSchema.safeParse({\n\t\t\tname: formData.get('name'),\n\t\t\temail: formData.get('email'),\n\t\t\tpassword: formData.get('password')\n\t\t});\n\n\t\tif (!parsed.success) {\n\t\t\treturn fail(400, { code: 'invalid_input' });\n\t\t}\n\n\t\tconst { name, email, password } = parsed.data;\n\n\t\ttry {\n\t\t\t// Contract-compliant credential user: lowercase email, accountId = userId,\n\t\t\t// user + credential account created atomically, admin session untouched.\n\t\t\t// (signUpEmail is NOT used: it sets a session cookie for the new user,\n\t\t\t// which would silently replace the admin's own session.)\n\t\t\tawait createCredentialUser({ name, email, password });\n\t\t} catch (error) {\n\t\t\tif (error instanceof DuplicateEmailError) {\n\t\t\t\treturn fail(400, { code: 'email_exists' });\n\t\t\t}\n\t\t\t// Generic code — never leak e.message internals to the UI (#188).\n\t\t\treturn fail(500, { code: 'create_failed' });\n\t\t}\n\n\t\treturn { success: true, code: 'created' };\n\t},\n\n\tupdate: async (event) => {\n\t\tawait requireAdmin(event);\n\t\tconst { request } = event;\n\t\tconst formData = await request.formData();\n\t\tconst parsed = updateUserSchema.safeParse({\n\t\t\tid: formData.get('id'),\n\t\t\tname: formData.get('name'),\n\t\t\temail: formData.get('email')\n\t\t});\n\n\t\tif (!parsed.success) {\n\t\t\treturn fail(400, { code: 'invalid_input' });\n\t\t}\n\n\t\tconst { id, name, email } = parsed.data;\n\t\tconst normalizedEmail = email.toLowerCase();\n\n\t\t// Check email not taken by another user (case-insensitive, matching the\n\t\t// lowercase credential contract of the create helper — #292).\n\t\tconst [existing] = await db\n\t\t\t.select({ id: user.id })\n\t\t\t.from(user)\n\t\t\t.where(eq(user.email, normalizedEmail))\n\t\t\t.limit(1);\n\t\tif (existing && existing.id !== id) {\n\t\t\treturn fail(400, { code: 'email_taken' });\n\t\t}\n\n\t\ttry {\n\t\t\tawait db\n\t\t\t\t.update(user)\n\t\t\t\t.set({\n\t\t\t\t\tname,\n\t\t\t\t\temail: normalizedEmail,\n\t\t\t\t\tupdatedAt: new Date()\n\t\t\t\t})\n\t\t\t\t.where(eq(user.id, id));\n\t\t} catch {\n\t\t\t// Generic code — never leak e.message internals to the UI (#188).\n\t\t\treturn fail(500, { code: 'update_failed' });\n\t\t}\n\n\t\treturn { success: true, code: 'updated' };\n\t},\n\n\ttoggleStatus: async (event) => {\n\t\tconst adminId = await requireAdmin(event);\n\t\tconst formData = await event.request.formData();\n\t\tconst parsed = toggleUserStatusSchema.safeParse({\n\t\t\tid: formData.get('id'),\n\t\t\tdisabled: formData.get('disabled') === 'true'\n\t\t});\n\n\t\tif (!parsed.success) return fail(400, { code: 'invalid_input' });\n\n\t\tconst { id, disabled } = parsed.data;\n\t\t// The only administrator must remain able to administer the project.\n\t\tif (disabled && id === adminId) return fail(400, { code: 'self_deactivate' });\n\n\t\tconst [target] = await db\n\t\t\t.select({ id: user.id, disabled: user.disabled })\n\t\t\t.from(user)\n\t\t\t.where(eq(user.id, id))\n\t\t\t.limit(1);\n\t\tif (!target) return fail(404, { code: 'not_found' });\n\n\t\ttry {\n\t\t\tawait db.transaction(async (tx) => {\n\t\t\t\tawait tx.update(user).set({ disabled, updatedAt: new Date() }).where(eq(user.id, id));\n\t\t\t\t// Revoking sessions immediately prevents a newly disabled user from\n\t\t\t\t// continuing to access protected routes. Accounts and the identity row stay.\n\t\t\t\tif (disabled) await tx.delete(session).where(eq(session.userId, id));\n\t\t\t});\n\t\t} catch {\n\t\t\treturn fail(500, { code: 'status_failed' });\n\t\t}\n\n\t\treturn { success: true, code: disabled ? 'deactivated' : 'reactivated' };\n\t},\n\n\ttoggleVerify: async (event) => {\n\t\tawait requireAdmin(event);\n\t\tconst { request } = event;\n\t\tconst formData = await request.formData();\n\t\tconst parsed = toggleVerifySchema.safeParse({\n\t\t\tid: formData.get('id'),\n\t\t\tverified: formData.get('verified') === 'true'\n\t\t});\n\n\t\tif (!parsed.success) {\n\t\t\treturn fail(400, { code: 'invalid_input' });\n\t\t}\n\n\t\tconst { id, verified } = parsed.data;\n\n\t\ttry {\n\t\t\tawait db\n\t\t\t\t.update(user)\n\t\t\t\t.set({\n\t\t\t\t\temailVerified: !verified,\n\t\t\t\t\tupdatedAt: new Date()\n\t\t\t\t})\n\t\t\t\t.where(eq(user.id, id));\n\t\t} catch {\n\t\t\t// Generic code — never leak e.message internals to the UI (#188).\n\t\t\treturn fail(500, { code: 'verify_failed' });\n\t\t}\n\n\t\treturn { success: true, code: verified ? 'unverified' : 'verified' };\n\t},\n\n\t/**\n\t * Pre-approve one email for self-registration (#318) — the `invite-only`\n\t * sign-up mode. In `closed` mode (the default) the invitation is inert:\n\t * the sign-up endpoint is disabled entirely. Either way an invited user\n\t * can never hold the admin role — only bootstrapFirstAdmin grants it.\n\t */\n\tinvite: async (event) => {\n\t\tawait requireAdmin(event);\n\t\tconst { request } = event;\n\t\tconst formData = await request.formData();\n\t\tconst parsed = inviteSchema.safeParse({\n\t\t\temail: formData.get('email')\n\t\t});\n\n\t\tif (!parsed.success) {\n\t\t\treturn fail(400, { code: 'invalid_input' });\n\t\t}\n\n\t\ttry {\n\t\t\tawait createInvitation({ email: parsed.data.email });\n\t\t} catch (error) {\n\t\t\tif (error instanceof DuplicateInvitationError) {\n\t\t\t\treturn fail(400, { code: 'already_invited' });\n\t\t\t}\n\t\t\t// Generic code — never leak e.message internals to the UI (#188).\n\t\t\treturn fail(500, { code: 'invite_failed' });\n\t\t}\n\n\t\treturn { success: true, code: 'invited' };\n\t}\n};\n", - "/routes/(app)/admin/users/+page.svelte": "\n\n\n\t{m.users_title()}\n\n\n
\n\t
\n\t\t

{m.users_heading()}

\n\t\t
\n\t\t\t\n\t\t\t\n\t\t
\n\t
\n\n\t{#if feedback}\n\t\t (feedback = null)} />\n\t{/if}\n\n\t\n\n\t\n\t\n\t\t{#snippet children({ row, col })}\n\t\t\t{#if col.key === 'name'}\n\t\t\t\t
\n\t\t\t\t\t\n\t\t\t\t\t
\n\t\t\t\t\t\t

{row.name}

\n\t\t\t\t\t\t

{row.email}

\n\t\t\t\t\t
\n\t\t\t\t
\n\t\t\t{:else if col.key === 'status'}\n\t\t\t\t{#if row.disabled}\n\t\t\t\t\t{m.users_disabled()}\n\t\t\t\t{:else}\n\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t{row.emailVerified ? m.users_verified() : m.users_pending()}\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t{/if}\n\t\t\t{:else if col.key === 'actions'}\n\t\t\t\t
\n\t\t\t\t\t openEdit(row)}\n\t\t\t\t\t\taria-label={m.users_edit()}\n\t\t\t\t\t>\n\t\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t openStatus(row)}\n\t\t\t\t\t\tdisabled={row.id === currentUserId}\n\t\t\t\t\t\taria-label={row.disabled ? m.users_reactivate() : m.users_deactivate()}\n\t\t\t\t\t>\n\t\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t
\n\t\t\t{/if}\n\t\t{/snippet}\n\t
\n\n\t{#if filtered.length === 0}\n\t\t{m.users_none()}\n\t{/if}\n
\n\n {\n\t\tif (!details.open) closeModal();\n\t}}\n>\n\t\n\t\t\n\t\t\n\t\t\t\n\t\t\t\t
\n\t\t\t\t\t\n\t\t\t\t\t\t{modal === 'create'\n\t\t\t\t\t\t\t? m.users_modal_create()\n\t\t\t\t\t\t\t: modal === 'edit'\n\t\t\t\t\t\t\t\t? m.users_modal_edit()\n\t\t\t\t\t\t\t\t: modal === 'invite'\n\t\t\t\t\t\t\t\t\t? m.users_invite_title()\n\t\t\t\t\t\t\t\t\t: statusTarget?.disabled\n\t\t\t\t\t\t\t\t\t\t? m.users_modal_reactivate()\n\t\t\t\t\t\t\t\t\t\t: m.users_modal_deactivate()}\n\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t
\n\n\t\t\t\t{#if modal === 'create' || modal === 'edit'}\n\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\t{#if modal === 'edit' && editUser}\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t{/if}\n\t\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t\t{#if modal === 'create'}\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t{/if}\n\t\t\t\t\t\t
\n\t\t\t\t\t\t\t{m.common_cancel()}\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t
\n\t\t\t\t\t\n\t\t\t\t{:else if modal === 'invite'}\n\t\t\t\t\t{m.users_invite_desc()}\n\t\t\t\t\t
\n\t\t\t\t\t\t\n\t\t\t\t\t\t
\n\t\t\t\t\t\t\t{m.common_cancel()}\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t
\n\t\t\t\t\t\n\t\t\t\t{:else if modal === 'status' && statusTarget}\n\t\t\t\t\t\n\t\t\t\t\t\t{statusTarget.disabled\n\t\t\t\t\t\t\t? m.users_reactivate_confirm({ name: statusTarget.name })\n\t\t\t\t\t\t\t: m.users_deactivate_confirm({ name: statusTarget.name })}\n\t\t\t\t\t\n\t\t\t\t\t
\n\t\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t\t
\n\t\t\t\t\t\t\t{m.common_cancel()}\n\t\t\t\t\t\t\t
\n\t\t\t\t\t
\n\t\t\t\t{/if}\n\t\t\t
\n\t\t
\n\t
\n\n", + "/routes/(app)/admin/users/+page.svelte": "\n\n\n\t{m.users_title()}\n\n\n
\n\t
\n\t\t

{m.users_heading()}

\n\t\t
\n\t\t\t\n\t\t\t\n\t\t
\n\t
\n\n\t{#if feedback}\n\t\t (feedback = null)} />\n\t{/if}\n\n\t\n\n\t\n\t\n\t\t{#snippet children({ row, col })}\n\t\t\t{#if col.key === 'name'}\n\t\t\t\t
\n\t\t\t\t\t\n\t\t\t\t\t
\n\t\t\t\t\t\t

{row.name}

\n\t\t\t\t\t\t

{row.email}

\n\t\t\t\t\t
\n\t\t\t\t
\n\t\t\t{:else if col.key === 'status'}\n\t\t\t\t{#if row.disabled}\n\t\t\t\t\t{m.users_disabled()}\n\t\t\t\t{:else}\n\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t{row.emailVerified ? m.users_verified() : m.users_pending()}\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t{/if}\n\t\t\t{:else if col.key === 'actions'}\n\t\t\t\t
\n\t\t\t\t\t openEdit(row)}\n\t\t\t\t\t\taria-label={m.users_edit()}\n\t\t\t\t\t>\n\t\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t openStatus(row)}\n\t\t\t\t\t\tdisabled={row.id === currentUserId}\n\t\t\t\t\t\taria-label={row.disabled ? m.users_reactivate() : m.users_deactivate()}\n\t\t\t\t\t>\n\t\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t
\n\t\t\t{/if}\n\t\t{/snippet}\n\t
\n\n\t{#if filtered.length === 0}\n\t\t{m.users_none()}\n\t{/if}\n
\n\n {\n\t\tif (!details.open) closeModal();\n\t}}\n>\n\t\n\t\t\n\t\t\n\t\t\t\n\t\t\t\t
\n\t\t\t\t\t\n\t\t\t\t\t\t{modal === 'create'\n\t\t\t\t\t\t\t? m.users_modal_create()\n\t\t\t\t\t\t\t: modal === 'edit'\n\t\t\t\t\t\t\t\t? m.users_modal_edit()\n\t\t\t\t\t\t\t\t: modal === 'invite'\n\t\t\t\t\t\t\t\t\t? m.users_invite_title()\n\t\t\t\t\t\t\t\t\t: statusTarget?.disabled\n\t\t\t\t\t\t\t\t\t\t? m.users_modal_reactivate()\n\t\t\t\t\t\t\t\t\t\t: m.users_modal_deactivate()}\n\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t
\n\n\t\t\t\t{#if modal === 'create' || modal === 'edit'}\n\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\t{#if modal === 'edit' && editUser}\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t{/if}\n\t\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t\t{#if modal === 'create'}\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t{/if}\n\t\t\t\t\t\t
\n\t\t\t\t\t\t\t{m.common_cancel()}\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t
\n\t\t\t\t\t\n\t\t\t\t{:else if modal === 'invite'}\n\t\t\t\t\t{m.users_invite_desc()}\n\t\t\t\t\t
\n\t\t\t\t\t\t\n\t\t\t\t\t\t
\n\t\t\t\t\t\t\t{m.common_cancel()}\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t
\n\t\t\t\t\t\n\t\t\t\t{:else if modal === 'status' && statusTarget}\n\t\t\t\t\t\n\t\t\t\t\t\t{statusTarget.disabled\n\t\t\t\t\t\t\t? m.users_reactivate_confirm({ name: statusTarget.name })\n\t\t\t\t\t\t\t: m.users_deactivate_confirm({ name: statusTarget.name })}\n\t\t\t\t\t\n\t\t\t\t\t
\n\t\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t\t
\n\t\t\t\t\t\t\t{m.common_cancel()}\n\t\t\t\t\t\t\t
\n\t\t\t\t\t
\n\t\t\t\t{/if}\n\t\t\t
\n\t\t
\n\t
\n\n", "/routes/(app)/admin/users/page.server.test.ts": "import { describe, it, expect, vi, beforeEach } from 'vitest';\n\n// Mock the server modules\nvi.mock('$lib/server/db', () => {\n\tconst mockQuery = {\n\t\tselect: vi.fn(() => mockQuery),\n\t\tfrom: vi.fn(() => mockQuery),\n\t\twhere: vi.fn(() => mockQuery),\n\t\tlimit: vi.fn(() => []),\n\t\torderBy: vi.fn(() => []),\n\t\tinsert: vi.fn(() => ({ values: vi.fn() })),\n\t\tupdate: vi.fn(() => ({ set: vi.fn(() => ({ where: vi.fn() })) })),\n\t\tdelete: vi.fn(() => ({ where: vi.fn() })),\n\t\ttransaction: vi.fn(async (cb: Function) => cb({\n\t\t\tdelete: vi.fn(() => ({ where: vi.fn() })),\n\t\t\tupdate: vi.fn(() => ({ set: vi.fn(() => ({ where: vi.fn() })) }))\n\t\t}))\n\t};\n\treturn { db: mockQuery };\n});\n\nvi.mock('$lib/server/db/schema', () => ({\n\tuser: { id: 'id', email: 'email', name: 'name', disabled: 'disabled' },\n\taccount: { userId: 'userId' },\n\tsession: { userId: 'userId' }\n}));\n\nvi.mock('$lib/server/admin', () => ({\n\tisAdmin: vi.fn()\n}));\n\nvi.mock('better-auth/crypto', () => ({\n\thashPassword: vi.fn(() => 'hashed-pw')\n}));\n\nvi.mock('drizzle-orm', () => ({\n\tdesc: vi.fn(),\n\teq: vi.fn()\n}));\n\n/**\n * Tests for the admin users page server — authorization, validation,\n * and user management actions.\n */\ndescribe('admin users +page.server — authorization', () => {\n\tbeforeEach(() => {\n\t\tvi.clearAllMocks();\n\t});\n\n\tit('rejects non-admin users with 403', async () => {\n\t\tconst { isAdmin } = await import('$lib/server/admin');\n\t\tvi.mocked(isAdmin).mockResolvedValue(false);\n\n\t\tconst mod = await import('./+page.server');\n\t\tawait expect(\n\t\t\tmod.actions.create({\n\t\t\t\tlocals: { user: { id: 'nonadmin' } },\n\t\t\t\trequest: new Request('http://test', { method: 'POST' })\n\t\t\t} as any)\n\t\t).rejects.toThrow();\n\t});\n\n\tit('rejects anonymous users with 401', async () => {\n\t\tconst mod = await import('./+page.server');\n\t\tawait expect(\n\t\t\tmod.actions.update({\n\t\t\t\tlocals: { user: null },\n\t\t\t\trequest: new Request('http://test', { method: 'POST' })\n\t\t\t} as any)\n\t\t).rejects.toThrow();\n\t});\n});\n\ndescribe('admin users +page.server — validation', () => {\n\tbeforeEach(() => {\n\t\tvi.clearAllMocks();\n\t});\n\n\tit('returns 400 for missing fields on create', async () => {\n\t\tconst { isAdmin } = await import('$lib/server/admin');\n\t\tvi.mocked(isAdmin).mockResolvedValue(true);\n\n\t\tconst mod = await import('./+page.server');\n\t\tconst formData = new FormData();\n\t\t// Missing name and password\n\t\tformData.set('email', 'test@test.com');\n\n\t\tconst result = await mod.actions.create({\n\t\t\tlocals: { user: { id: 'admin1' } },\n\t\t\trequest: { formData: async () => formData }\n\t\t} as any);\n\n\t\texpect((result as { status: number }).status).toBe(400);\n\t});\n});\n\ndescribe('admin users +page.server — user management', () => {\n\tbeforeEach(() => {\n\t\tvi.clearAllMocks();\n\t});\n\n\tit('deactivates a user without deleting their identity and revokes every session', async () => {\n\t\tconst { isAdmin } = await import('$lib/server/admin');\n\t\tvi.mocked(isAdmin).mockResolvedValue(true);\n\n\t\tconst { db } = await import('$lib/server/db');\n\t\tconst { user } = await import('$lib/server/db/schema');\n\t\tconst { eq } = await import('drizzle-orm');\n\t\tvi.mocked(eq).mockImplementation((column: unknown, value: unknown) => ({ column, value }) as never);\n\t\tconst updateWhere = vi.fn();\n\t\tconst setUser = vi.fn(() => ({ where: updateWhere }));\n\t\tconst updateUser = vi.fn(() => ({ set: setUser }));\n\t\tconst deleteSession = vi.fn(() => ({ where: vi.fn() }));\n\t\t(db.select as any).mockReturnValueOnce({\n\t\t\tfrom: vi.fn(() => ({ where: vi.fn(() => ({ limit: vi.fn(() => [{ id: 'user-1', disabled: false }]) })) }))\n\t\t});\n\t\t(db.transaction as any).mockImplementationOnce(async (callback: Function) =>\n\t\t\tcallback({ delete: deleteSession, update: updateUser })\n\t\t);\n\n\t\tconst mod = await import('./+page.server');\n\t\tconst formData = new FormData();\n\t\tformData.set('id', 'user-1');\n\t\tformData.set('disabled', 'true');\n\t\tconst result = await mod.actions.toggleStatus({\n\t\t\tlocals: { user: { id: 'admin1' } },\n\t\t\trequest: { formData: async () => formData }\n\t\t} as any);\n\n\t\texpect(result).toMatchObject({ success: true, code: 'deactivated' });\n\t\texpect(updateUser).toHaveBeenCalledWith(user);\n\t\texpect(setUser).toHaveBeenCalledWith(expect.objectContaining({ disabled: true }));\n\t\texpect(updateWhere).toHaveBeenCalledWith({ column: user.id, value: 'user-1' });\n\t\texpect(deleteSession).toHaveBeenCalledTimes(1);\n\t});\n\n\tit('refuses self-deactivation before updating the user or deleting sessions', async () => {\n\t\tconst { isAdmin } = await import('$lib/server/admin');\n\t\tvi.mocked(isAdmin).mockResolvedValue(true);\n\n\t\tconst { db } = await import('$lib/server/db');\n\t\tconst mod = await import('./+page.server');\n\t\tconst formData = new FormData();\n\t\tformData.set('id', 'admin1');\n\t\tformData.set('disabled', 'true');\n\t\tconst result = await mod.actions.toggleStatus({\n\t\t\tlocals: { user: { id: 'admin1' } },\n\t\t\trequest: { formData: async () => formData }\n\t\t} as any);\n\n\t\texpect(result).toMatchObject({ status: 400, data: { code: 'self_deactivate' } });\n\t\texpect(db.update).not.toHaveBeenCalled();\n\t\texpect(db.delete).not.toHaveBeenCalled();\n\t\texpect(db.transaction).not.toHaveBeenCalled();\n\t});\n\n\tit('reactivates a user without revoking sessions', async () => {\n\t\tconst { isAdmin } = await import('$lib/server/admin');\n\t\tvi.mocked(isAdmin).mockResolvedValue(true);\n\n\t\tconst { db } = await import('$lib/server/db');\n\t\tconst deleteSession = vi.fn(() => ({ where: vi.fn() }));\n\t\tconst updateUser = vi.fn(() => ({ set: vi.fn(() => ({ where: vi.fn() })) }));\n\t\t(db.select as any).mockReturnValueOnce({\n\t\t\tfrom: vi.fn(() => ({ where: vi.fn(() => ({ limit: vi.fn(() => [{ id: 'user-1', disabled: true }]) })) }))\n\t\t});\n\t\t(db.transaction as any).mockImplementationOnce(async (callback: Function) =>\n\t\t\tcallback({ delete: deleteSession, update: updateUser })\n\t\t);\n\n\t\tconst mod = await import('./+page.server');\n\t\tconst formData = new FormData();\n\t\tformData.set('id', 'user-1');\n\t\tformData.set('disabled', 'false');\n\t\tconst result = await mod.actions.toggleStatus({\n\t\t\tlocals: { user: { id: 'admin1' } },\n\t\t\trequest: { formData: async () => formData }\n\t\t} as any);\n\n\t\texpect(result).toMatchObject({ success: true, code: 'reactivated' });\n\t\texpect(deleteSession).not.toHaveBeenCalled();\n\t});\n\n\tit('allows admin to toggle verification (success)', async () => {\n\t\tconst { isAdmin } = await import('$lib/server/admin');\n\t\tvi.mocked(isAdmin).mockResolvedValue(true);\n\n\t\tconst { db } = await import('$lib/server/db');\n\t\t// Mock that user exists\n\t\t(db.select as any).mockReturnValueOnce({\n\t\t\tfrom: vi.fn(() => ({\n\t\t\t\twhere: vi.fn(() => ({ limit: vi.fn(() => []) })),\n\t\t\t\torderBy: vi.fn(() => [])\n\t\t\t}))\n\t\t});\n\n\t\tconst mod = await import('./+page.server');\n\t\tconst formData = new FormData();\n\t\tformData.set('id', 'user-1');\n\t\tformData.set('verified', 'false');\n\n\t\tconst result = await mod.actions.toggleVerify({\n\t\t\tlocals: { user: { id: 'admin1' } },\n\t\t\trequest: { formData: async () => formData }\n\t\t} as any);\n\n\t\texpect(result).toHaveProperty('success', true);\n\t});\n});\n", "/routes/(app)/layout.server.test.ts": "import { describe, it, expect, vi } from 'vitest';\n\n// Mock the server modules so the test can run without a real database.\nvi.mock('$lib/server/db', () => ({\n\tdb: {\n\t\tselect: vi.fn(() => ({ from: vi.fn(() => ({ orderBy: vi.fn(() => []) })) })),\n\t\tinsert: vi.fn(() => ({ values: vi.fn() })),\n\t\tupdate: vi.fn(() => ({ set: vi.fn(() => ({ where: vi.fn() })) })),\n\t\tdelete: vi.fn(() => ({ where: vi.fn() }))\n\t}\n}));\n\nvi.mock('$lib/server/admin', () => ({\n\tisAdmin: vi.fn()\n}));\n\n/**\n * Tests for the (app) layout auth guard.\n * Anonymous users must be redirected to /login.\n */\ndescribe('(app) layout auth guard', () => {\n\tit('redirects anonymous users to /login', async () => {\n\t\tconst { isAdmin } = await import('$lib/server/admin');\n\t\tvi.mocked(isAdmin).mockResolvedValue(false);\n\n\t\t// Simulate an unauthenticated request\n\t\tconst load = (await import('./+layout.server')).load;\n\n\t\tawait expect(\n\t\t\tload({\n\t\t\t\tlocals: { user: null, session: null },\n\t\t\t\turl: new URL('http://localhost/dashboard')\n\t\t\t} as any)\n\t\t).rejects.toThrow();\n\n\t\ttry {\n\t\t\tawait load({\n\t\t\t\tlocals: { user: null, session: null },\n\t\t\t\turl: new URL('http://localhost/dashboard')\n\t\t\t} as any);\n\t\t} catch (e) {\n\t\t\tconst err = e as { status: number; location: string };\n\t\t\texpect(err.status).toBe(302);\n\t\t\texpect(err.location).toMatch(/\\/login/);\n\t\t}\n\t});\n\n\tit('returns user data for authenticated users', async () => {\n\t\tconst { isAdmin } = await import('$lib/server/admin');\n\t\tvi.mocked(isAdmin).mockResolvedValue(true);\n\n\t\tconst load = (await import('./+layout.server')).load;\n\t\tconst result = await load({\n\t\t\tlocals: {\n\t\t\t\tuser: { id: 'u1', name: 'Admin', email: 'admin@test.com' },\n\t\t\t\tsession: { id: 's1', userId: 'u1' }\n\t\t\t},\n\t\t\turl: new URL('http://localhost/dashboard')\n\t\t} as any);\n\n\t\texpect(result).toHaveProperty('user');\n\t\texpect(result).toHaveProperty('session');\n\t});\n});\n", "/routes/+layout.svelte": "\n\n{@render children()}\n\n
\n\t{#each locales as locale (locale)}\n\t\t{locale}\n\t{/each}\n
\n", diff --git a/packages/svforge/templates/dashboard/src/lib/components/svforge/layout/AdminLayout.svelte b/packages/svforge/templates/dashboard/src/lib/components/svforge/layout/AdminLayout.svelte index 62465d6f..ceb2f117 100644 --- a/packages/svforge/templates/dashboard/src/lib/components/svforge/layout/AdminLayout.svelte +++ b/packages/svforge/templates/dashboard/src/lib/components/svforge/layout/AdminLayout.svelte @@ -96,7 +96,7 @@
@@ -142,7 +142,7 @@ {m.layout_admin()} diff --git a/packages/svforge/templates/dashboard/src/routes/(app)/admin/users/+page.svelte b/packages/svforge/templates/dashboard/src/routes/(app)/admin/users/+page.svelte index e78c4a64..be2bfe74 100644 --- a/packages/svforge/templates/dashboard/src/routes/(app)/admin/users/+page.svelte +++ b/packages/svforge/templates/dashboard/src/routes/(app)/admin/users/+page.svelte @@ -274,7 +274,7 @@ : m.users_modal_deactivate()} @@ -320,7 +320,7 @@ /> {/if}
- {m.common_cancel()} + {m.common_cancel()}
@@ -336,7 +336,7 @@ required />
- {m.common_cancel()} + {m.common_cancel()}
@@ -350,7 +350,7 @@
- {m.common_cancel()} + {m.common_cancel()} diff --git a/tests/dashboard-trigger-affordance.test.ts b/tests/dashboard-trigger-affordance.test.ts new file mode 100644 index 00000000..e96c3603 --- /dev/null +++ b/tests/dashboard-trigger-affordance.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, it } from 'vitest'; +import { readFileSync } from 'node:fs'; +import { dashboardTemplateFile } from './helpers'; + +function triggerClasses(source: string): string[] { + return [...source.matchAll(/).)*\bclass="([^"]*)"(?:(?!>).)*>/gs)].map( + (match) => match[1] + ); +} + +describe('dashboard dialog trigger affordances (#594)', () => { + it('gives enabled dialog triggers a pointer cursor without replacing Skeleton triggers', () => { + const files = [ + dashboardTemplateFile('lib', 'components', 'svforge', 'layout', 'AdminLayout.svelte'), + dashboardTemplateFile('routes', '(app)', 'admin', 'users', '+page.svelte') + ]; + const classes = files.flatMap((file) => triggerClasses(readFileSync(file, 'utf-8'))); + + expect(classes.length).toBeGreaterThan(0); + expect(classes.every((className) => className.split(/\s+/).includes('cursor-pointer'))).toBe(true); + }); +});