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
@@ -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,