Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 3 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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"
}
}
9 changes: 7 additions & 2 deletions src/hooks.server.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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);
}

Expand Down
142 changes: 128 additions & 14 deletions src/lib/components/Users.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
import {
ChevronDown,
CheckCircle,
Mail,
Plus,
Search,
Send,
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -85,7 +91,7 @@
);

function openAddModal() {
error = '';
addError = '';
newUsername = '';
newEmail = '';
newPassword = '';
Expand All @@ -94,6 +100,12 @@
addModalOpen = true;
}

function openInviteModal() {
inviteError = '';
inviteEmail = '';
inviteModalOpen = true;
}

function flashSuccess(message: string) {
success = message;
setTimeout(() => {
Expand All @@ -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;
}

Expand All @@ -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;
Expand Down Expand Up @@ -243,14 +276,26 @@
<h3 class="text-xl font-semibold text-slate-900">{title}</h3>
<p class="mt-2 text-sm text-slate-600">{description}</p>
</div>
<button
type="button"
on:click={openAddModal}
class="btn-primary inline-flex shrink-0 items-center justify-center gap-2 rounded-md px-3 py-2 text-sm font-medium"
>
<Plus class="h-4 w-4" />
{scope === 'project' ? 'Add user' : 'New user'}
</button>
<div class="flex shrink-0 items-center gap-2">
{#if scope === 'organization'}
<button
type="button"
on:click={openInviteModal}
class="btn-secondary inline-flex items-center justify-center gap-2 rounded-md px-3 py-2 text-sm font-medium"
>
<Mail class="h-4 w-4" />
Invite user
</button>
{/if}
<button
type="button"
on:click={openAddModal}
class="btn-primary inline-flex items-center justify-center gap-2 rounded-md px-3 py-2 text-sm font-medium"
>
<Plus class="h-4 w-4" />
{scope === 'project' ? 'Add user' : 'New user'}
</button>
</div>
</section>

<section class="flex flex-col gap-3 sm:flex-row sm:items-center">
Expand Down Expand Up @@ -439,6 +484,12 @@
</h5>
</div>
<div class="space-y-4 px-4 py-4">
{#if addError}
<div class="rounded-md border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700">
{addError}
</div>
{/if}

{#if scope !== 'project'}
<div class="grid gap-4 sm:grid-cols-2">
<div>
Expand Down Expand Up @@ -531,6 +582,69 @@
</div>
{/if}

{#if inviteModalOpen}
<button
type="button"
class="fixed inset-0 z-40 bg-slate-900/50"
on:click={() => (inviteModalOpen = false)}
aria-label="Close invite user modal"
></button>
<div class="fixed inset-0 z-50 flex items-center justify-center p-4">
<div
class="w-full max-w-md rounded-md border border-slate-200 bg-white shadow-xl"
role="dialog"
aria-modal="true"
aria-label="Invite user modal"
>
<div class="border-b border-slate-200 px-4 py-3">
<h5 class="text-sm font-semibold text-slate-900">Invite user</h5>
</div>
<div class="space-y-4 px-4 py-4">
{#if inviteError}
<div class="rounded-md border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700">
{inviteError}
</div>
{/if}

<p class="text-sm text-slate-600">
If the account does not exist yet we will create it and email the invitation. Existing
active accounts are granted access straight away.
</p>
<div>
<label class="block text-sm font-medium text-slate-700" for="invite-user-email">
Email
</label>
<input
id="invite-user-email"
type="email"
bind:value={inviteEmail}
placeholder="user@example.com"
class="field-input mt-2 w-full rounded-md border px-3 py-2 text-sm outline-none transition"
/>
</div>
</div>
<div class="flex items-center justify-end gap-2 border-t border-slate-200 px-4 py-3">
<button
type="button"
on:click={() => (inviteModalOpen = false)}
class="btn-secondary rounded-md px-3 py-2 text-sm font-medium"
>
Cancel
</button>
<button
type="button"
on:click={inviteUser}
disabled={inviting}
class="btn-primary inline-flex items-center gap-2 rounded-md px-3 py-2 text-sm font-medium"
>
<Mail class="h-4 w-4" />
{inviting ? 'Sending...' : 'Send invitation'}
</button>
</div>
</div>
</div>
{/if}

{#if removeModalUser}
<button
type="button"
Expand Down
2 changes: 2 additions & 0 deletions src/lib/database/schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ export const UserEntity = entity('users', {
passwordHash: text().notNull(),
roleId: uuid().notNull(),
status: text().notNull().default('active'),
invitationTokenHash: text(),
invitationExpiresAt: timestamp(),
authProviders: json()
.notNull()
.$defaultFn(() => []),
Expand Down
Loading
Loading