From f0ec055725f2d183c736051b9566313e1738fd12 Mon Sep 17 00:00:00 2001
From: Daniel Kirby
Date: Wed, 24 Jun 2026 18:04:43 +0100
Subject: [PATCH] feat: scope organization access by memberships
---
.../organization-form-fields.svelte | 28 +--
.../components/organization-switcher.svelte | 13 +-
src/lib/schemas/admin.ts | 2 +-
src/lib/server/organizations.ts | 117 ++++++++----
.../dashboard/organization/+layout.svelte | 4 +-
.../organization/admins/+page.server.ts | 175 +++++++++++++++---
.../organization/admins/+page.svelte | 2 +-
.../organization/admins/admins-table.svelte | 2 +-
.../admins/create-admin-dialog.svelte | 5 +-
.../admins/delete-admin-dialog.svelte | 6 +-
.../organization/details/+page.svelte | 4 +-
.../organization-edit-dialog.svelte | 11 +-
src/routes/dashboard/organizations/+server.ts | 48 +++--
.../organizations/current/+server.ts | 8 +-
14 files changed, 299 insertions(+), 126 deletions(-)
diff --git a/src/lib/components/organization-form-fields.svelte b/src/lib/components/organization-form-fields.svelte
index c57226a..55d861a 100644
--- a/src/lib/components/organization-form-fields.svelte
+++ b/src/lib/components/organization-form-fields.svelte
@@ -5,9 +5,9 @@
type Organization = {
name: string;
- workspace: string;
addressLine1?: string | null;
addressLine2?: string | null;
+ addressLine3?: string | null;
city?: string | null;
region?: string | null;
postcode?: string | null;
@@ -43,26 +43,17 @@
/>
- Workspace
+ Address line 1
-
- Address
-
-
Address line 2
+
+ Address line 3
+
+
City
diff --git a/src/lib/components/organization-switcher.svelte b/src/lib/components/organization-switcher.svelte
index 536babd..3c91adc 100644
--- a/src/lib/components/organization-switcher.svelte
+++ b/src/lib/components/organization-switcher.svelte
@@ -15,7 +15,8 @@
type Organization = {
id: string;
name: string;
- workspace: string;
+ addressLine1?: string | null;
+ membershipRole?: string;
};
let {
@@ -48,7 +49,9 @@
{activeOrganization.name}
- {activeOrganization.workspace}
+ {activeOrganization.addressLine1 || 'Address not set'}
@@ -73,7 +76,7 @@
{organization.name}
{organization.workspace}{organization.addressLine1 || 'Address not set'}
{#if organization.id === activeOrganization.id}
@@ -116,7 +119,9 @@
Create organization
- Add a new workspace
+ Add address and payment details
diff --git a/src/lib/schemas/admin.ts b/src/lib/schemas/admin.ts
index d25321f..1be4bc9 100644
--- a/src/lib/schemas/admin.ts
+++ b/src/lib/schemas/admin.ts
@@ -9,7 +9,7 @@ const roleSchema = z.enum(['admin', 'user'], 'Select a role.');
export const createAdminSchema = z.object({
name: z.string().trim().min(1, 'Enter a name.'),
email: z.email('Enter a valid email address.').transform((value) => value.toLowerCase()),
- password: z.string().min(8, passwordMessage),
+ password: optionalPassword,
role: roleSchema.default('admin')
});
diff --git a/src/lib/server/organizations.ts b/src/lib/server/organizations.ts
index b708558..da6c331 100644
--- a/src/lib/server/organizations.ts
+++ b/src/lib/server/organizations.ts
@@ -1,8 +1,7 @@
-import { asc, eq, isNull } from 'drizzle-orm';
+import { error } from '@sveltejs/kit';
+import { and, asc, eq, isNull } from 'drizzle-orm';
import { db } from '$lib/server/db';
-import { organizations, session } from '$lib/server/db/schema';
-
-const DEFAULT_ORGANIZATION_ID = 'default-organization';
+import { organizationMemberships, organizations, session } from '$lib/server/db/schema';
type SessionWithOrganization = {
id: string;
@@ -10,43 +9,46 @@ type SessionWithOrganization = {
};
export async function loadOrganizationContext(locals: App.Locals) {
+ if (!locals.user) {
+ error(401, 'Sign in before opening the dashboard.');
+ }
+
const rows = await db
- .select()
+ .select({
+ id: organizations.id,
+ name: organizations.name,
+ addressLine1: organizations.addressLine1,
+ addressLine2: organizations.addressLine2,
+ addressLine3: organizations.addressLine3,
+ city: organizations.city,
+ region: organizations.region,
+ postcode: organizations.postcode,
+ country: organizations.country,
+ bankName: organizations.bankName,
+ bankAccountName: organizations.bankAccountName,
+ bankAccountNumber: organizations.bankAccountNumber,
+ bankSortCode: organizations.bankSortCode,
+ bankIban: organizations.bankIban,
+ bankSwift: organizations.bankSwift,
+ createdAt: organizations.createdAt,
+ updatedAt: organizations.updatedAt,
+ archivedAt: organizations.archivedAt,
+ membershipRole: organizationMemberships.role
+ })
.from(organizations)
+ .innerJoin(
+ organizationMemberships,
+ and(
+ eq(organizationMemberships.organizationId, organizations.id),
+ eq(organizationMemberships.userId, locals.user.id),
+ isNull(organizationMemberships.archivedAt)
+ )
+ )
.where(isNull(organizations.archivedAt))
.orderBy(asc(organizations.name));
if (rows.length === 0) {
- const now = new Date();
-
- await db.insert(organizations).values({
- id: DEFAULT_ORGANIZATION_ID,
- name: 'Steel City Stadium',
- workspace: 'Blueprint Workspace',
- createdAt: now,
- updatedAt: now
- });
-
- rows.push({
- id: DEFAULT_ORGANIZATION_ID,
- name: 'Steel City Stadium',
- workspace: 'Blueprint Workspace',
- addressLine1: null,
- addressLine2: null,
- city: null,
- region: null,
- postcode: null,
- country: 'United Kingdom',
- bankName: null,
- bankAccountName: null,
- bankAccountNumber: null,
- bankSortCode: null,
- bankIban: null,
- bankSwift: null,
- createdAt: now,
- updatedAt: now,
- archivedAt: null
- });
+ error(403, 'You have not been added to an organization.');
}
const currentSession = locals.session as SessionWithOrganization | undefined;
@@ -66,14 +68,45 @@ export async function loadOrganizationContext(locals: App.Locals) {
return {
organizations: rows,
activeOrganization,
- activeOrganizationId: activeOrganization.id
+ activeOrganizationId: activeOrganization.id,
+ activeMembershipRole: activeOrganization.membershipRole
};
}
export async function setActiveOrganization(locals: App.Locals, organizationId: string) {
+ if (!locals.user) return null;
+
const [organization] = await db
- .select()
+ .select({
+ id: organizations.id,
+ name: organizations.name,
+ addressLine1: organizations.addressLine1,
+ addressLine2: organizations.addressLine2,
+ addressLine3: organizations.addressLine3,
+ city: organizations.city,
+ region: organizations.region,
+ postcode: organizations.postcode,
+ country: organizations.country,
+ bankName: organizations.bankName,
+ bankAccountName: organizations.bankAccountName,
+ bankAccountNumber: organizations.bankAccountNumber,
+ bankSortCode: organizations.bankSortCode,
+ bankIban: organizations.bankIban,
+ bankSwift: organizations.bankSwift,
+ createdAt: organizations.createdAt,
+ updatedAt: organizations.updatedAt,
+ archivedAt: organizations.archivedAt,
+ membershipRole: organizationMemberships.role
+ })
.from(organizations)
+ .innerJoin(
+ organizationMemberships,
+ and(
+ eq(organizationMemberships.organizationId, organizations.id),
+ eq(organizationMemberships.userId, locals.user.id),
+ isNull(organizationMemberships.archivedAt)
+ )
+ )
.where(eq(organizations.id, organizationId))
.limit(1);
@@ -92,3 +125,13 @@ export async function setActiveOrganization(locals: App.Locals, organizationId:
return organization;
}
+
+export async function requireActiveOrganizationAdmin(locals: App.Locals) {
+ const context = await loadOrganizationContext(locals);
+
+ if (context.activeMembershipRole !== 'admin') {
+ error(403, 'You must be an organization admin to manage these settings.');
+ }
+
+ return context;
+}
diff --git a/src/routes/dashboard/organization/+layout.svelte b/src/routes/dashboard/organization/+layout.svelte
index cddb6d9..0193877 100644
--- a/src/routes/dashboard/organization/+layout.svelte
+++ b/src/routes/dashboard/organization/+layout.svelte
@@ -14,8 +14,6 @@
children: import('svelte').Snippet;
} = $props();
- let editOpen = $state(false);
-
const tabs = [
{ value: 'details', label: 'Details' },
{ value: 'admins', label: 'Admins' }
@@ -46,7 +44,7 @@
Manage organization details, invoice payment information, and administrators.
-
+
diff --git a/src/routes/dashboard/organization/admins/+page.server.ts b/src/routes/dashboard/organization/admins/+page.server.ts
index 2797850..4f5c8df 100644
--- a/src/routes/dashboard/organization/admins/+page.server.ts
+++ b/src/routes/dashboard/organization/admins/+page.server.ts
@@ -1,25 +1,34 @@
-import { asc, eq } from 'drizzle-orm';
+import { and, asc, count, eq, isNull } from 'drizzle-orm';
import { message, superValidate } from 'sveltekit-superforms/server';
import { zod4 } from 'sveltekit-superforms/adapters';
import { auth } from '$lib/server/auth';
import { db } from '$lib/server/db';
-import { user } from '$lib/server/db/schema';
+import { organizationMemberships, user } from '$lib/server/db/schema';
+import { requireActiveOrganizationAdmin } from '$lib/server/organizations';
import { createAdminSchema, deleteAdminSchema, editAdminSchema } from '$lib/schemas/admin';
import type { Actions, PageServerLoad } from './$types';
export const load: PageServerLoad = async ({ locals }) => {
+ const { activeOrganizationId } = await requireActiveOrganizationAdmin(locals);
const users = await db
.select({
id: user.id,
name: user.name,
email: user.email,
- role: user.role,
+ role: organizationMemberships.role,
banned: user.banned,
emailVerified: user.emailVerified,
createdAt: user.createdAt,
updatedAt: user.updatedAt
})
- .from(user)
+ .from(organizationMemberships)
+ .innerJoin(user, eq(organizationMemberships.userId, user.id))
+ .where(
+ and(
+ eq(organizationMemberships.organizationId, activeOrganizationId),
+ isNull(organizationMemberships.archivedAt)
+ )
+ )
.orderBy(asc(user.name), asc(user.email));
return {
@@ -37,8 +46,24 @@ export const load: PageServerLoad = async ({ locals }) => {
};
};
+async function countOrganizationAdmins(organizationId: string) {
+ const [result] = await db
+ .select({ total: count() })
+ .from(organizationMemberships)
+ .where(
+ and(
+ eq(organizationMemberships.organizationId, organizationId),
+ eq(organizationMemberships.role, 'admin'),
+ isNull(organizationMemberships.archivedAt)
+ )
+ );
+
+ return result.total;
+}
+
export const actions: Actions = {
create: async (event) => {
+ const { activeOrganizationId } = await requireActiveOrganizationAdmin(event.locals);
const form = await superValidate(event, zod4(createAdminSchema), { id: 'create-admin' });
if (!form.valid) {
@@ -46,34 +71,108 @@ export const actions: Actions = {
}
try {
- await auth.api.createUser({
- headers: event.request.headers,
- body: {
- name: form.data.name,
- email: form.data.email,
- password: form.data.password,
- role: form.data.role
+ const [existingUser] = await db
+ .select({ id: user.id })
+ .from(user)
+ .where(eq(user.email, form.data.email))
+ .limit(1);
+
+ let userId = existingUser?.id;
+
+ if (!userId) {
+ if (!form.data.password) {
+ return message(form, 'Enter a password when creating a new user.', { status: 400 });
}
+
+ await auth.api.createUser({
+ body: {
+ name: form.data.name,
+ email: form.data.email,
+ password: form.data.password,
+ role: form.data.role
+ }
+ });
+
+ const [createdUser] = await db
+ .select({ id: user.id })
+ .from(user)
+ .where(eq(user.email, form.data.email))
+ .limit(1);
+
+ userId = createdUser?.id;
+ }
+
+ if (!userId) {
+ return message(form, 'Unable to create user.', { status: 400 });
+ }
+
+ const [existingMembership] = await db
+ .select({ id: organizationMemberships.id })
+ .from(organizationMemberships)
+ .where(
+ and(
+ eq(organizationMemberships.organizationId, activeOrganizationId),
+ eq(organizationMemberships.userId, userId),
+ isNull(organizationMemberships.archivedAt)
+ )
+ )
+ .limit(1);
+
+ if (existingMembership) {
+ return message(form, 'That user already has access to this organization.', {
+ status: 409
+ });
+ }
+
+ await db.insert(organizationMemberships).values({
+ organizationId: activeOrganizationId,
+ userId,
+ role: form.data.role,
+ createdAt: new Date(),
+ updatedAt: new Date()
});
} catch {
- return message(form, 'Unable to create admin. Check whether that email already exists.', {
+ return message(form, 'Unable to add user. Check whether that email is valid.', {
status: 400
});
}
- return message(form, 'Admin created.');
+ return message(form, 'User added to this organization.');
},
edit: async (event) => {
+ const { activeOrganizationId } = await requireActiveOrganizationAdmin(event.locals);
const form = await superValidate(event, zod4(editAdminSchema), { id: 'edit-admin' });
if (!form.valid) {
return message(form, 'Check the highlighted fields.', { status: 400 });
}
+ const [membership] = await db
+ .select({ id: organizationMemberships.id, role: organizationMemberships.role })
+ .from(organizationMemberships)
+ .where(
+ and(
+ eq(organizationMemberships.organizationId, activeOrganizationId),
+ eq(organizationMemberships.userId, form.data.id),
+ isNull(organizationMemberships.archivedAt)
+ )
+ )
+ .limit(1);
+
+ if (!membership) {
+ return message(form, 'That user does not belong to this organization.', { status: 404 });
+ }
+
+ if (membership.role === 'admin' && form.data.role !== 'admin') {
+ const adminCount = await countOrganizationAdmins(activeOrganizationId);
+ if (adminCount <= 1) {
+ return message(form, 'Each organization must keep at least one admin.', { status: 400 });
+ }
+ }
+
try {
await auth.api.adminUpdateUser({
- headers: event.request.headers,
body: {
userId: form.data.id,
data: {
@@ -83,41 +182,61 @@ export const actions: Actions = {
}
});
- await auth.api.setRole({
- headers: event.request.headers,
- body: {
- userId: form.data.id,
- role: form.data.role
- }
- });
-
if (form.data.password) {
await auth.api.setUserPassword({
- headers: event.request.headers,
body: {
userId: form.data.id,
newPassword: form.data.password
}
});
}
+
+ await db
+ .update(organizationMemberships)
+ .set({ role: form.data.role, updatedAt: new Date() })
+ .where(eq(organizationMemberships.id, membership.id));
} catch {
- return message(form, 'Unable to update admin. Check whether that email already exists.', {
+ return message(form, 'Unable to update user. Check whether that email already exists.', {
status: 400
});
}
- return message(form, 'Admin updated.');
+ return message(form, 'User updated.');
},
delete: async (event) => {
+ const { activeOrganizationId } = await requireActiveOrganizationAdmin(event.locals);
const form = await superValidate(event, zod4(deleteAdminSchema), { id: 'delete-admin' });
if (!form.valid) {
- return message(form, 'Admin id is required.', { status: 400 });
+ return message(form, 'User id is required.', { status: 400 });
}
- await db.delete(user).where(eq(user.id, form.data.id));
+ const [membership] = await db
+ .select({ id: organizationMemberships.id, role: organizationMemberships.role })
+ .from(organizationMemberships)
+ .where(
+ and(
+ eq(organizationMemberships.organizationId, activeOrganizationId),
+ eq(organizationMemberships.userId, form.data.id),
+ isNull(organizationMemberships.archivedAt)
+ )
+ )
+ .limit(1);
- return message(form, 'Admin deleted.');
+ if (!membership) {
+ return message(form, 'That user does not belong to this organization.', { status: 404 });
+ }
+
+ if (membership.role === 'admin') {
+ const adminCount = await countOrganizationAdmins(activeOrganizationId);
+ if (adminCount <= 1) {
+ return message(form, 'Each organization must keep at least one admin.', { status: 400 });
+ }
+ }
+
+ await db.delete(organizationMemberships).where(eq(organizationMemberships.id, membership.id));
+
+ return message(form, 'User removed from this organization.');
}
};
diff --git a/src/routes/dashboard/organization/admins/+page.svelte b/src/routes/dashboard/organization/admins/+page.svelte
index c26f590..d46c579 100644
--- a/src/routes/dashboard/organization/admins/+page.svelte
+++ b/src/routes/dashboard/organization/admins/+page.svelte
@@ -105,7 +105,7 @@
Administrators
- Manage users who can access and administer Clearity.
+ Manage users who can access and administer this organization.
diff --git a/src/routes/dashboard/organization/admins/admins-table.svelte b/src/routes/dashboard/organization/admins/admins-table.svelte
index 700416c..038552d 100644
--- a/src/routes/dashboard/organization/admins/admins-table.svelte
+++ b/src/routes/dashboard/organization/admins/admins-table.svelte
@@ -65,7 +65,7 @@
openEdit(user)}>Edit
openDelete(user)}>
- Delete
+ Remove access
diff --git a/src/routes/dashboard/organization/admins/create-admin-dialog.svelte b/src/routes/dashboard/organization/admins/create-admin-dialog.svelte
index 7feea44..f4bebb3 100644
--- a/src/routes/dashboard/organization/admins/create-admin-dialog.svelte
+++ b/src/routes/dashboard/organization/admins/create-admin-dialog.svelte
@@ -32,7 +32,9 @@
Create user
- Add a manually managed Better Auth user.
+
+ Create a user or add an existing email to this organization.
+