feat: scope organization access by memberships

This commit is contained in:
2026-06-24 18:04:43 +01:00
parent 92f4e54cf4
commit f0ec055725
14 changed files with 299 additions and 126 deletions
@@ -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 @@
/>
</Field.Field>
<Field.Field>
<Field.Label for={`${prefix}-workspace`}>Workspace</Field.Label>
<Field.Label for={`${prefix}-address-line-1`}>Address line 1</Field.Label>
<Input
id={`${prefix}-workspace`}
name="workspace"
id={`${prefix}-address-line-1`}
name="addressLine1"
type="text"
value={organization?.workspace ?? 'Blueprint Workspace'}
value={organization?.addressLine1 ?? ''}
required
/>
</Field.Field>
</div>
<Field.Field>
<Field.Label for={`${prefix}-address-line-1`}>Address</Field.Label>
<Input
id={`${prefix}-address-line-1`}
name="addressLine1"
type="text"
value={organization?.addressLine1 ?? ''}
/>
</Field.Field>
<Field.Field>
<Field.Label for={`${prefix}-address-line-2`}>Address line 2</Field.Label>
<Input
@@ -72,6 +63,15 @@
value={organization?.addressLine2 ?? ''}
/>
</Field.Field>
<Field.Field>
<Field.Label for={`${prefix}-address-line-3`}>Address line 3</Field.Label>
<Input
id={`${prefix}-address-line-3`}
name="addressLine3"
type="text"
value={organization?.addressLine3 ?? ''}
/>
</Field.Field>
<div class="grid gap-2 sm:grid-cols-2">
<Field.Field>
<Field.Label for={`${prefix}-city`}>City</Field.Label>
@@ -15,7 +15,8 @@
type Organization = {
id: string;
name: string;
workspace: string;
addressLine1?: string | null;
membershipRole?: string;
};
let {
@@ -48,7 +49,9 @@
</div>
<div class="grid flex-1 text-left text-sm leading-tight">
<span class="truncate font-semibold">{activeOrganization.name}</span>
<span class="truncate text-xs">{activeOrganization.workspace}</span>
<span class="truncate text-xs"
>{activeOrganization.addressLine1 || 'Address not set'}</span
>
</div>
<ChevronsUpDownIcon class="ms-auto" />
</Sidebar.MenuButton>
@@ -73,7 +76,7 @@
<div class="grid flex-1 text-left text-sm leading-tight">
<span class="truncate font-semibold">{organization.name}</span>
<span class="truncate text-xs text-muted-foreground"
>{organization.workspace}</span
>{organization.addressLine1 || 'Address not set'}</span
>
</div>
{#if organization.id === activeOrganization.id}
@@ -116,7 +119,9 @@
</div>
<div class="grid flex-1 text-left text-sm leading-tight">
<span class="truncate font-semibold">Create organization</span>
<span class="truncate text-xs text-muted-foreground">Add a new workspace</span>
<span class="truncate text-xs text-muted-foreground"
>Add address and payment details</span
>
</div>
</button>
</DropdownMenu.Item>
+1 -1
View File
@@ -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')
});
+80 -37
View File
@@ -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;
}
@@ -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.
</p>
</div>
<OrganizationEditDialog organization={data.activeOrganization} bind:open={editOpen} />
<OrganizationEditDialog organization={data.activeOrganization} />
</div>
</div>
@@ -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.');
}
};
@@ -105,7 +105,7 @@
<div>
<h2 class="text-xl font-semibold tracking-tight">Administrators</h2>
<p class="text-sm text-muted-foreground">
Manage users who can access and administer Clearity.
Manage users who can access and administer this organization.
</p>
</div>
@@ -65,7 +65,7 @@
<DropdownMenu.Content align="end" class="w-36">
<DropdownMenu.Item onclick={() => openEdit(user)}>Edit</DropdownMenu.Item>
<DropdownMenu.Item variant="destructive" onclick={() => openDelete(user)}>
Delete
Remove access
</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu.Root>
@@ -32,7 +32,9 @@
<Dialog.Content class="sm:max-w-md">
<Dialog.Header>
<Dialog.Title>Create user</Dialog.Title>
<Dialog.Description>Add a manually managed Better Auth user.</Dialog.Description>
<Dialog.Description>
Create a user or add an existing email to this organization.
</Dialog.Description>
</Dialog.Header>
<form method="POST" action="?/create" class="grid gap-2" use:enhanceCreate>
<FormField form={createForm} name="name">
@@ -66,6 +68,7 @@
{...props}
type="password"
autocomplete="new-password"
placeholder="Required for new users"
bind:value={$createData.password}
/>
{/snippet}
@@ -17,9 +17,9 @@
<AlertDialog.Root open={!!deletingUser} onOpenChange={(open) => !open && onClose()}>
<AlertDialog.Content>
<AlertDialog.Header>
<AlertDialog.Title>Delete user?</AlertDialog.Title>
<AlertDialog.Title>Remove access?</AlertDialog.Title>
<AlertDialog.Description>
This will permanently delete {deletingUser?.name ?? 'this user'} and revoke their sessions.
This will remove {deletingUser?.name ?? 'this user'} from this organization.
</AlertDialog.Description>
</AlertDialog.Header>
<AlertDialog.Footer>
@@ -27,7 +27,7 @@
{#if deletingUser}
<form method="POST" action="?/delete" use:enhanceDelete>
<input type="hidden" name="id" value={$deleteData.id} />
<AlertDialog.Action type="submit" variant="destructive">Delete user</AlertDialog.Action>
<AlertDialog.Action type="submit" variant="destructive">Remove access</AlertDialog.Action>
</form>
{/if}
</AlertDialog.Footer>
@@ -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 },
@@ -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);
</script>
<Dialog.Root bind:open>
+29 -19
View File
@@ -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);
@@ -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,