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}
+
+
+
+
+
Invite user
+
+
+ {#if inviteError}
+
+ {inviteError}
+
+ {/if}
+
+
+ If the account does not exist yet we will create it and email the invitation. Existing
+ active accounts are granted access straight away.
+
+
+
+
+
+
+
+
+
+
+
+
+{/if}
+
{#if removeModalUser}