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. +
@@ -66,6 +68,7 @@ {...props} type="password" autocomplete="new-password" + placeholder="Required for new users" bind:value={$createData.password} /> {/snippet} diff --git a/src/routes/dashboard/organization/admins/delete-admin-dialog.svelte b/src/routes/dashboard/organization/admins/delete-admin-dialog.svelte index 2c01098..615664a 100644 --- a/src/routes/dashboard/organization/admins/delete-admin-dialog.svelte +++ b/src/routes/dashboard/organization/admins/delete-admin-dialog.svelte @@ -17,9 +17,9 @@ !open && onClose()}> - Delete user? + Remove access? - This will permanently delete {deletingUser?.name ?? 'this user'} and revoke their sessions. + This will remove {deletingUser?.name ?? 'this user'} from this organization. @@ -27,7 +27,7 @@ {#if deletingUser} - Delete user + Remove access {/if} diff --git a/src/routes/dashboard/organization/details/+page.svelte b/src/routes/dashboard/organization/details/+page.svelte index 9be35d5..60d9bee 100644 --- a/src/routes/dashboard/organization/details/+page.svelte +++ b/src/routes/dashboard/organization/details/+page.svelte @@ -13,9 +13,9 @@ icon: MapPinIcon, items: [ { label: 'Name', value: organization.name }, - { label: 'Workspace', value: organization.workspace }, - { label: 'Address', value: organization.addressLine1 }, + { label: 'Address line 1', value: organization.addressLine1 }, { label: 'Address line 2', value: organization.addressLine2 }, + { label: 'Address line 3', value: organization.addressLine3 }, { label: 'City', value: organization.city }, { label: 'Region', value: organization.region }, { label: 'Postcode', value: organization.postcode }, diff --git a/src/routes/dashboard/organization/organization-edit-dialog.svelte b/src/routes/dashboard/organization/organization-edit-dialog.svelte index 85705d9..7e8c8e9 100644 --- a/src/routes/dashboard/organization/organization-edit-dialog.svelte +++ b/src/routes/dashboard/organization/organization-edit-dialog.svelte @@ -6,9 +6,9 @@ type Organization = { id: string; name: string; - workspace: string; addressLine1?: string | null; addressLine2?: string | null; + addressLine3?: string | null; city?: string | null; region?: string | null; postcode?: string | null; @@ -21,13 +21,8 @@ bankSwift?: string | null; }; - let { - organization, - open = $bindable(false) - }: { - organization: Organization; - open: boolean; - } = $props(); + let { organization }: { organization: Organization } = $props(); + let open = $state(false); diff --git a/src/routes/dashboard/organizations/+server.ts b/src/routes/dashboard/organizations/+server.ts index b27b278..9f2b9be 100644 --- a/src/routes/dashboard/organizations/+server.ts +++ b/src/routes/dashboard/organizations/+server.ts @@ -1,6 +1,6 @@ import { redirect, error } from '@sveltejs/kit'; import { db } from '$lib/server/db'; -import { organizations } from '$lib/server/db/schema'; +import { organizationMemberships, organizations } from '$lib/server/db/schema'; import { organizationCreateSchema } from '$lib/schemas/organizations.schema'; import { setActiveOrganization } from '$lib/server/organizations'; import type { RequestHandler } from './$types'; @@ -24,24 +24,34 @@ export const POST: RequestHandler = async ({ locals, request }) => { const id = crypto.randomUUID(); const now = new Date(); - await db.insert(organizations).values({ - id, - name: parsed.data.name, - workspace: parsed.data.workspace, - addressLine1: parsed.data.addressLine1 || null, - addressLine2: parsed.data.addressLine2 || null, - city: parsed.data.city || null, - region: parsed.data.region || null, - postcode: parsed.data.postcode || null, - country: parsed.data.country, - bankName: parsed.data.bankName || null, - bankAccountName: parsed.data.bankAccountName || null, - bankAccountNumber: parsed.data.bankAccountNumber || null, - bankSortCode: parsed.data.bankSortCode || null, - bankIban: parsed.data.bankIban || null, - bankSwift: parsed.data.bankSwift || null, - createdAt: now, - updatedAt: now + await db.transaction(async (tx) => { + await tx.insert(organizations).values({ + id, + name: parsed.data.name, + addressLine1: parsed.data.addressLine1, + addressLine2: parsed.data.addressLine2 || null, + addressLine3: parsed.data.addressLine3 || null, + city: parsed.data.city || null, + region: parsed.data.region || null, + postcode: parsed.data.postcode || null, + country: parsed.data.country, + bankName: parsed.data.bankName || null, + bankAccountName: parsed.data.bankAccountName || null, + bankAccountNumber: parsed.data.bankAccountNumber || null, + bankSortCode: parsed.data.bankSortCode || null, + bankIban: parsed.data.bankIban || null, + bankSwift: parsed.data.bankSwift || null, + createdAt: now, + updatedAt: now + }); + + await tx.insert(organizationMemberships).values({ + organizationId: id, + userId: locals.user.id, + role: 'admin', + createdAt: now, + updatedAt: now + }); }); await setActiveOrganization(locals, id); diff --git a/src/routes/dashboard/organizations/current/+server.ts b/src/routes/dashboard/organizations/current/+server.ts index b854ecc..d7d6f76 100644 --- a/src/routes/dashboard/organizations/current/+server.ts +++ b/src/routes/dashboard/organizations/current/+server.ts @@ -2,7 +2,7 @@ import { and, eq, isNull } from 'drizzle-orm'; import { error, redirect } from '@sveltejs/kit'; import { db } from '$lib/server/db'; import { organizations } from '$lib/server/db/schema'; -import { loadOrganizationContext } from '$lib/server/organizations'; +import { requireActiveOrganizationAdmin } from '$lib/server/organizations'; import { organizationEditSchema } from '$lib/schemas/organizations.schema'; import type { RequestHandler } from './$types'; @@ -17,7 +17,7 @@ export const POST: RequestHandler = async ({ locals, request }) => { redirect(303, '/login'); } - const { activeOrganizationId } = await loadOrganizationContext(locals); + const { activeOrganizationId } = await requireActiveOrganizationAdmin(locals); const formData = await request.formData(); const parsed = organizationEditSchema.safeParse(Object.fromEntries(formData)); @@ -29,9 +29,9 @@ export const POST: RequestHandler = async ({ locals, request }) => { .update(organizations) .set({ name: parsed.data.name, - workspace: parsed.data.workspace, - addressLine1: parsed.data.addressLine1 || null, + addressLine1: parsed.data.addressLine1, addressLine2: parsed.data.addressLine2 || null, + addressLine3: parsed.data.addressLine3 || null, city: parsed.data.city || null, region: parsed.data.region || null, postcode: parsed.data.postcode || null,