diff --git a/bun.lock b/bun.lock index ea57514..c37927e 100644 --- a/bun.lock +++ b/bun.lock @@ -10,6 +10,7 @@ "@google-cloud/storage": "^8.0.1", "@lucide/svelte": "^1.34.0", "chart.js": "^4.5.1", + "nodemailer": "^9.0.6", }, "devDependencies": { "@eslint/js": "^10.0.1", @@ -19,6 +20,7 @@ "@tailwindcss/postcss": "^4.3.3", "@types/better-sqlite3": "^9.6.0", "@types/node": "^26.4.0", + "@types/nodemailer": "^8.0.1", "@typescript-eslint/eslint-plugin": "^8.68.0", "@typescript-eslint/parser": "^8.68.0", "@vitest/coverage-v8": "^4.1.11", @@ -294,6 +296,8 @@ "@types/node": ["@types/node@26.4.0", "", { "dependencies": { "undici-types": "~8.3.0" } }, "sha512-faiGnoIrLH/V8cibOMEAZ8pMw6oXqSukl29ra4mN8GdaB2ZewzeaLj+INpV5N+Z1eKWzY+IzaIZH2EIR6YZRNQ=="], + "@types/nodemailer": ["@types/nodemailer@8.0.1", "", { "dependencies": { "@types/node": "*" } }, "sha512-PxpaInm8V1JQDd4j0ds5HfvWQk8JupS1C0Picb96QJsrrRDjBH+DlK7L4ZdNSqNULhiZRQHc40nLVShaGxXAMw=="], + "@types/trusted-types": ["@types/trusted-types@2.0.7", "", {}, "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw=="], "@typescript-eslint/eslint-plugin": ["@typescript-eslint/eslint-plugin@8.68.0", "", { "dependencies": { "@eslint-community/regexpp": "^4.12.2", "@typescript-eslint/scope-manager": "8.68.0", "@typescript-eslint/type-utils": "8.68.0", "@typescript-eslint/utils": "8.68.0", "@typescript-eslint/visitor-keys": "8.68.0", "ignore": "^7.0.5", "natural-compare": "^1.4.0", "ts-api-utils": "^2.5.0" }, "peerDependencies": { "@typescript-eslint/parser": "^8.68.0", "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-WASHDpCm6qO5jj9g1a+8NiW5+GCkAyLReR56/4VruYmNgfUmqpxOfZ2Yfb8xGfJPWv5Qi6LSD8sXdces3vbp/Q=="], @@ -652,6 +656,8 @@ "node-releases": ["node-releases@2.0.53", "", {}, "sha512-D9UOmYG3UH1V+ENW56t5QXBwJw1YEY18ruVeus89Rw+SyIgjPkCO84bRzO3uNIYosJbNwiabWVn48o3uJLjxFQ=="], + "nodemailer": ["nodemailer@9.0.6", "", {}, "sha512-IQUGFdhdGwI9+AWX+FpUt4DLmvFaOjTMEoneTIWX/RXxuy1TdenPwWrvFMSfLkPKl+HQEXWuSAxEMMbPYXtBmg=="], + "obug": ["obug@2.1.4", "", {}, "sha512-4a+OsYv9UktOJKE+l1A4OufDgdRF9PifWj+tJnHURo/P+WOxpG4GzUFL9qCalmWauao6ogiG+QvnCovwPoyAWA=="], "once": ["once@1.4.0", "", { "dependencies": { "wrappy": "1" } }, "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w=="], diff --git a/package.json b/package.json index d0d765d..6aedd0b 100644 --- a/package.json +++ b/package.json @@ -23,6 +23,7 @@ "@tailwindcss/postcss": "^4.3.3", "@types/better-sqlite3": "^9.6.0", "@types/node": "^26.4.0", + "@types/nodemailer": "^8.0.1", "@typescript-eslint/eslint-plugin": "^8.68.0", "@typescript-eslint/parser": "^8.68.0", "@vitest/coverage-v8": "^4.1.11", @@ -48,6 +49,7 @@ "@getgitops/gitdb": "^0.8.0", "@google-cloud/storage": "^8.0.1", "@lucide/svelte": "^1.34.0", - "chart.js": "^4.5.1" + "chart.js": "^4.5.1", + "nodemailer": "^9.0.6" } } diff --git a/src/hooks.server.ts b/src/hooks.server.ts index 983b494..9da25bc 100644 --- a/src/hooks.server.ts +++ b/src/hooks.server.ts @@ -1,6 +1,7 @@ import type { Handle } from '@sveltejs/kit'; import { authService, cancanService, ensureAuthReady } from '$modules/auth'; import { organizationService } from '$modules/organization'; +import { projectService } from '$modules/projects'; import { isBootstrapCompleted, refreshBootstrapState } from '$lib/server/bootstrap'; import { startGitDb } from '$lib/server/gitdb'; import { isServerReady, markServerFailed, markServerReady } from '$lib/server/server-ready'; @@ -58,8 +59,12 @@ export const handle: Handle = async ({ event, resolve }) => { return new Response(null, { status: 302, headers: { location: '/bootstrap' } }); } - // sign-in and sign-out must work without (or with a broken) session - if (pathname === '/auth/login' || pathname === '/auth/logout') { + // sign-in, sign-out and invitation acceptance must work without (or with a broken) session + if ( + pathname === '/auth/login' || + pathname === '/auth/logout' || + pathname === '/auth/invitation' + ) { return resolve(event); } diff --git a/src/lib/components/Users.svelte b/src/lib/components/Users.svelte index 20f83b6..61707c7 100644 --- a/src/lib/components/Users.svelte +++ b/src/lib/components/Users.svelte @@ -4,6 +4,7 @@ import { ChevronDown, CheckCircle, + Mail, Plus, Search, Send, @@ -51,6 +52,11 @@ let success = ''; let addModalOpen = false; let adding = false; + let addError = ''; + let inviteModalOpen = false; + let inviting = false; + let inviteEmail = ''; + let inviteError = ''; let savingAccessId: string | null = null; let removingAccessId: string | null = null; let resendingAccessId: string | null = null; @@ -85,7 +91,7 @@ ); function openAddModal() { - error = ''; + addError = ''; newUsername = ''; newEmail = ''; newPassword = ''; @@ -94,6 +100,12 @@ addModalOpen = true; } + function openInviteModal() { + inviteError = ''; + inviteEmail = ''; + inviteModalOpen = true; + } + function flashSuccess(message: string) { success = message; setTimeout(() => { @@ -115,21 +127,21 @@ } async function addUser() { - error = ''; + addError = ''; success = ''; if (!selectedRoleId) { - error = 'Role is required.'; + addError = 'Role is required.'; return; } if (scope !== 'project' && (!newUsername.trim() || !newPassword.trim())) { - error = 'Username and password are required.'; + addError = 'Username and password are required.'; return; } if (scope === 'project' && !selectedUserId) { - error = 'User is required.'; + addError = 'User is required.'; return; } @@ -145,12 +157,33 @@ addModalOpen = false; flashSuccess(scope === 'project' ? 'User assigned.' : 'User created.'); } catch (err: unknown) { - error = err instanceof Error ? err.message : 'Failed to add user.'; + addError = err instanceof Error ? err.message : 'Failed to add user.'; } finally { adding = false; } } + async function inviteUser() { + inviteError = ''; + success = ''; + + if (!inviteEmail.trim()) { + inviteError = 'Email is required.'; + return; + } + + inviting = true; + try { + await submitAction('inviteUser', { email: inviteEmail.trim() }); + inviteModalOpen = false; + flashSuccess('Invitation sent.'); + } catch (err: unknown) { + inviteError = err instanceof Error ? err.message : 'Failed to invite user.'; + } finally { + inviting = false; + } + } + async function selectRole(user: AccessUserRow, role: RoleRow) { if (user.role?.id === role.id) { openRoleMenuId = null; @@ -243,14 +276,26 @@

{title}

{description}

- +
+ {#if scope === 'organization'} + + {/if} + +
@@ -439,6 +484,12 @@
+ {#if addError} +
+ {addError} +
+ {/if} + {#if scope !== 'project'}
@@ -531,6 +582,69 @@
{/if} +{#if inviteModalOpen} + +
+ +
+{/if} + {#if removeModalUser} + + {/if} +
+
diff --git a/src/routes/auth/login/+page.server.ts b/src/routes/auth/login/+page.server.ts index 93d1af5..ed5beac 100644 --- a/src/routes/auth/login/+page.server.ts +++ b/src/routes/auth/login/+page.server.ts @@ -15,17 +15,17 @@ export async function load({ cookies }) { export const actions = { async login({ request, cookies }) { const form = await request.formData(); - const username = String(form.get('username') ?? '').trim(); + const email = String(form.get('email') ?? '').trim().toLowerCase(); const password = String(form.get('password') ?? ''); - if (!username || !password) { - return fail(400, { username, error: 'Username and password are required.' }); + if (!email || !password) { + return fail(400, { email, error: 'Email and password are required.' }); } - const user = await authService.authenticate(username, password); + const user = await authService.authenticate(email, password); if (!user) { - // same message for unknown user and wrong password, to avoid leaking valid usernames - return fail(401, { username, error: 'Invalid username or password.' }); + // same message for unknown email and wrong password, to avoid leaking valid emails + return fail(401, { email, error: 'Invalid email or password.' }); } cookies.set(SESSION_COOKIE, authService.createSessionToken(user.id), { diff --git a/src/routes/auth/login/+page.svelte b/src/routes/auth/login/+page.svelte index 163f7b3..96faad4 100644 --- a/src/routes/auth/login/+page.svelte +++ b/src/routes/auth/login/+page.svelte @@ -8,7 +8,7 @@ let isSubmitting = false; - $: username = form?.username ?? ''; + $: email = form?.email ?? ''; $: loggedOut = $page.url.searchParams.has('loggedOut'); @@ -65,15 +65,15 @@ class="space-y-4" >
- Email
diff --git a/src/routes/login/+page.svelte b/src/routes/login/+page.svelte index 163f7b3..96faad4 100644 --- a/src/routes/login/+page.svelte +++ b/src/routes/login/+page.svelte @@ -8,7 +8,7 @@ let isSubmitting = false; - $: username = form?.username ?? ''; + $: email = form?.email ?? ''; $: loggedOut = $page.url.searchParams.has('loggedOut'); @@ -65,15 +65,15 @@ class="space-y-4" >
- Email
diff --git a/src/routes/org/[org]/settings/users/+page.server.ts b/src/routes/org/[org]/settings/users/+page.server.ts index 48b40f7..a78a974 100644 --- a/src/routes/org/[org]/settings/users/+page.server.ts +++ b/src/routes/org/[org]/settings/users/+page.server.ts @@ -37,6 +37,27 @@ export const actions = { } }, + async inviteUser({ request, locals, params, url }) { + const organization = await organizationService.findBySlug(params.org); + if (!(await cancanService.canManageOrganization(locals.user, organization.id))) { + return fail(403, { error: 'Forbidden' }); + } + + try { + const form = await request.formData(); + const user = await userAccessService.inviteOrganizationUser({ + organizationId: organization.id, + organizationName: organization.name, + email: String(form.get('email') ?? ''), + inviteUrl: `${url.origin}/auth/invitation`, + invitedBy: locals.user?.username ?? null, + }); + return { success: true, user }; + } catch (error: unknown) { + return errorResponse(error); + } + }, + async updateUserAccess({ request, locals, params }) { const organization = await organizationService.findBySlug(params.org); if (!(await cancanService.canManageOrganization(locals.user, organization.id))) { @@ -77,7 +98,7 @@ export const actions = { } }, - async resendInvitation({ request, locals, params }) { + async resendInvitation({ request, locals, params, url }) { const organization = await organizationService.findBySlug(params.org); if (!(await cancanService.canManageOrganization(locals.user, organization.id))) { return fail(403, { error: 'Forbidden' }); @@ -89,6 +110,9 @@ export const actions = { accessId: String(form.get('accessId') ?? ''), scope: 'organization', scopeId: organization.id, + organizationName: organization.name, + inviteUrl: `${url.origin}/auth/invitation`, + invitedBy: locals.user?.username ?? null, }); return { success: true, user }; } catch (error: unknown) { diff --git a/test/env-dynamic-private.ts b/test/env-dynamic-private.ts new file mode 100644 index 0000000..b8b4aad --- /dev/null +++ b/test/env-dynamic-private.ts @@ -0,0 +1,2 @@ +/** Stand-in for SvelteKit's $env/dynamic/private, which is only available through the Vite plugin. */ +export const env = process.env as Record; diff --git a/vitest.config.ts b/vitest.config.ts index aea1a28..d5206db 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -5,6 +5,8 @@ export default defineConfig({ resolve: { alias: { $lib: path.resolve(import.meta.dirname, 'src/lib'), + $modules: path.resolve(import.meta.dirname, 'src/modules'), + '$env/dynamic/private': path.resolve(import.meta.dirname, 'test/env-dynamic-private.ts'), }, }, test: {