feat: scope organization access by memberships
This commit is contained in:
@@ -5,9 +5,9 @@
|
|||||||
|
|
||||||
type Organization = {
|
type Organization = {
|
||||||
name: string;
|
name: string;
|
||||||
workspace: string;
|
|
||||||
addressLine1?: string | null;
|
addressLine1?: string | null;
|
||||||
addressLine2?: string | null;
|
addressLine2?: string | null;
|
||||||
|
addressLine3?: string | null;
|
||||||
city?: string | null;
|
city?: string | null;
|
||||||
region?: string | null;
|
region?: string | null;
|
||||||
postcode?: string | null;
|
postcode?: string | null;
|
||||||
@@ -43,26 +43,17 @@
|
|||||||
/>
|
/>
|
||||||
</Field.Field>
|
</Field.Field>
|
||||||
<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"
|
|
||||||
type="text"
|
|
||||||
value={organization?.workspace ?? 'Blueprint Workspace'}
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
</Field.Field>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Field.Field>
|
|
||||||
<Field.Label for={`${prefix}-address-line-1`}>Address</Field.Label>
|
|
||||||
<Input
|
<Input
|
||||||
id={`${prefix}-address-line-1`}
|
id={`${prefix}-address-line-1`}
|
||||||
name="addressLine1"
|
name="addressLine1"
|
||||||
type="text"
|
type="text"
|
||||||
value={organization?.addressLine1 ?? ''}
|
value={organization?.addressLine1 ?? ''}
|
||||||
|
required
|
||||||
/>
|
/>
|
||||||
</Field.Field>
|
</Field.Field>
|
||||||
|
</div>
|
||||||
|
|
||||||
<Field.Field>
|
<Field.Field>
|
||||||
<Field.Label for={`${prefix}-address-line-2`}>Address line 2</Field.Label>
|
<Field.Label for={`${prefix}-address-line-2`}>Address line 2</Field.Label>
|
||||||
<Input
|
<Input
|
||||||
@@ -72,6 +63,15 @@
|
|||||||
value={organization?.addressLine2 ?? ''}
|
value={organization?.addressLine2 ?? ''}
|
||||||
/>
|
/>
|
||||||
</Field.Field>
|
</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">
|
<div class="grid gap-2 sm:grid-cols-2">
|
||||||
<Field.Field>
|
<Field.Field>
|
||||||
<Field.Label for={`${prefix}-city`}>City</Field.Label>
|
<Field.Label for={`${prefix}-city`}>City</Field.Label>
|
||||||
|
|||||||
@@ -15,7 +15,8 @@
|
|||||||
type Organization = {
|
type Organization = {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
workspace: string;
|
addressLine1?: string | null;
|
||||||
|
membershipRole?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
let {
|
let {
|
||||||
@@ -48,7 +49,9 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="grid flex-1 text-left text-sm leading-tight">
|
<div class="grid flex-1 text-left text-sm leading-tight">
|
||||||
<span class="truncate font-semibold">{activeOrganization.name}</span>
|
<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>
|
</div>
|
||||||
<ChevronsUpDownIcon class="ms-auto" />
|
<ChevronsUpDownIcon class="ms-auto" />
|
||||||
</Sidebar.MenuButton>
|
</Sidebar.MenuButton>
|
||||||
@@ -73,7 +76,7 @@
|
|||||||
<div class="grid flex-1 text-left text-sm leading-tight">
|
<div class="grid flex-1 text-left text-sm leading-tight">
|
||||||
<span class="truncate font-semibold">{organization.name}</span>
|
<span class="truncate font-semibold">{organization.name}</span>
|
||||||
<span class="truncate text-xs text-muted-foreground"
|
<span class="truncate text-xs text-muted-foreground"
|
||||||
>{organization.workspace}</span
|
>{organization.addressLine1 || 'Address not set'}</span
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
{#if organization.id === activeOrganization.id}
|
{#if organization.id === activeOrganization.id}
|
||||||
@@ -116,7 +119,9 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="grid flex-1 text-left text-sm leading-tight">
|
<div class="grid flex-1 text-left text-sm leading-tight">
|
||||||
<span class="truncate font-semibold">Create organization</span>
|
<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>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
</DropdownMenu.Item>
|
</DropdownMenu.Item>
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ const roleSchema = z.enum(['admin', 'user'], 'Select a role.');
|
|||||||
export const createAdminSchema = z.object({
|
export const createAdminSchema = z.object({
|
||||||
name: z.string().trim().min(1, 'Enter a name.'),
|
name: z.string().trim().min(1, 'Enter a name.'),
|
||||||
email: z.email('Enter a valid email address.').transform((value) => value.toLowerCase()),
|
email: z.email('Enter a valid email address.').transform((value) => value.toLowerCase()),
|
||||||
password: z.string().min(8, passwordMessage),
|
password: optionalPassword,
|
||||||
role: roleSchema.default('admin')
|
role: roleSchema.default('admin')
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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 { db } from '$lib/server/db';
|
||||||
import { organizations, session } from '$lib/server/db/schema';
|
import { organizationMemberships, organizations, session } from '$lib/server/db/schema';
|
||||||
|
|
||||||
const DEFAULT_ORGANIZATION_ID = 'default-organization';
|
|
||||||
|
|
||||||
type SessionWithOrganization = {
|
type SessionWithOrganization = {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -10,43 +9,46 @@ type SessionWithOrganization = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export async function loadOrganizationContext(locals: App.Locals) {
|
export async function loadOrganizationContext(locals: App.Locals) {
|
||||||
|
if (!locals.user) {
|
||||||
|
error(401, 'Sign in before opening the dashboard.');
|
||||||
|
}
|
||||||
|
|
||||||
const rows = await db
|
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)
|
.from(organizations)
|
||||||
|
.innerJoin(
|
||||||
|
organizationMemberships,
|
||||||
|
and(
|
||||||
|
eq(organizationMemberships.organizationId, organizations.id),
|
||||||
|
eq(organizationMemberships.userId, locals.user.id),
|
||||||
|
isNull(organizationMemberships.archivedAt)
|
||||||
|
)
|
||||||
|
)
|
||||||
.where(isNull(organizations.archivedAt))
|
.where(isNull(organizations.archivedAt))
|
||||||
.orderBy(asc(organizations.name));
|
.orderBy(asc(organizations.name));
|
||||||
|
|
||||||
if (rows.length === 0) {
|
if (rows.length === 0) {
|
||||||
const now = new Date();
|
error(403, 'You have not been added to an organization.');
|
||||||
|
|
||||||
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
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const currentSession = locals.session as SessionWithOrganization | undefined;
|
const currentSession = locals.session as SessionWithOrganization | undefined;
|
||||||
@@ -66,14 +68,45 @@ export async function loadOrganizationContext(locals: App.Locals) {
|
|||||||
return {
|
return {
|
||||||
organizations: rows,
|
organizations: rows,
|
||||||
activeOrganization,
|
activeOrganization,
|
||||||
activeOrganizationId: activeOrganization.id
|
activeOrganizationId: activeOrganization.id,
|
||||||
|
activeMembershipRole: activeOrganization.membershipRole
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function setActiveOrganization(locals: App.Locals, organizationId: string) {
|
export async function setActiveOrganization(locals: App.Locals, organizationId: string) {
|
||||||
|
if (!locals.user) return null;
|
||||||
|
|
||||||
const [organization] = await db
|
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)
|
.from(organizations)
|
||||||
|
.innerJoin(
|
||||||
|
organizationMemberships,
|
||||||
|
and(
|
||||||
|
eq(organizationMemberships.organizationId, organizations.id),
|
||||||
|
eq(organizationMemberships.userId, locals.user.id),
|
||||||
|
isNull(organizationMemberships.archivedAt)
|
||||||
|
)
|
||||||
|
)
|
||||||
.where(eq(organizations.id, organizationId))
|
.where(eq(organizations.id, organizationId))
|
||||||
.limit(1);
|
.limit(1);
|
||||||
|
|
||||||
@@ -92,3 +125,13 @@ export async function setActiveOrganization(locals: App.Locals, organizationId:
|
|||||||
|
|
||||||
return organization;
|
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;
|
children: import('svelte').Snippet;
|
||||||
} = $props();
|
} = $props();
|
||||||
|
|
||||||
let editOpen = $state(false);
|
|
||||||
|
|
||||||
const tabs = [
|
const tabs = [
|
||||||
{ value: 'details', label: 'Details' },
|
{ value: 'details', label: 'Details' },
|
||||||
{ value: 'admins', label: 'Admins' }
|
{ value: 'admins', label: 'Admins' }
|
||||||
@@ -46,7 +44,7 @@
|
|||||||
Manage organization details, invoice payment information, and administrators.
|
Manage organization details, invoice payment information, and administrators.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<OrganizationEditDialog organization={data.activeOrganization} bind:open={editOpen} />
|
<OrganizationEditDialog organization={data.activeOrganization} />
|
||||||
</div>
|
</div>
|
||||||
</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 { message, superValidate } from 'sveltekit-superforms/server';
|
||||||
import { zod4 } from 'sveltekit-superforms/adapters';
|
import { zod4 } from 'sveltekit-superforms/adapters';
|
||||||
import { auth } from '$lib/server/auth';
|
import { auth } from '$lib/server/auth';
|
||||||
import { db } from '$lib/server/db';
|
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 { createAdminSchema, deleteAdminSchema, editAdminSchema } from '$lib/schemas/admin';
|
||||||
import type { Actions, PageServerLoad } from './$types';
|
import type { Actions, PageServerLoad } from './$types';
|
||||||
|
|
||||||
export const load: PageServerLoad = async ({ locals }) => {
|
export const load: PageServerLoad = async ({ locals }) => {
|
||||||
|
const { activeOrganizationId } = await requireActiveOrganizationAdmin(locals);
|
||||||
const users = await db
|
const users = await db
|
||||||
.select({
|
.select({
|
||||||
id: user.id,
|
id: user.id,
|
||||||
name: user.name,
|
name: user.name,
|
||||||
email: user.email,
|
email: user.email,
|
||||||
role: user.role,
|
role: organizationMemberships.role,
|
||||||
banned: user.banned,
|
banned: user.banned,
|
||||||
emailVerified: user.emailVerified,
|
emailVerified: user.emailVerified,
|
||||||
createdAt: user.createdAt,
|
createdAt: user.createdAt,
|
||||||
updatedAt: user.updatedAt
|
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));
|
.orderBy(asc(user.name), asc(user.email));
|
||||||
|
|
||||||
return {
|
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 = {
|
export const actions: Actions = {
|
||||||
create: async (event) => {
|
create: async (event) => {
|
||||||
|
const { activeOrganizationId } = await requireActiveOrganizationAdmin(event.locals);
|
||||||
const form = await superValidate(event, zod4(createAdminSchema), { id: 'create-admin' });
|
const form = await superValidate(event, zod4(createAdminSchema), { id: 'create-admin' });
|
||||||
|
|
||||||
if (!form.valid) {
|
if (!form.valid) {
|
||||||
@@ -46,8 +71,20 @@ export const actions: Actions = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
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({
|
await auth.api.createUser({
|
||||||
headers: event.request.headers,
|
|
||||||
body: {
|
body: {
|
||||||
name: form.data.name,
|
name: form.data.name,
|
||||||
email: form.data.email,
|
email: form.data.email,
|
||||||
@@ -55,25 +92,87 @@ export const actions: Actions = {
|
|||||||
role: form.data.role
|
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 {
|
} 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
|
status: 400
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return message(form, 'Admin created.');
|
return message(form, 'User added to this organization.');
|
||||||
},
|
},
|
||||||
|
|
||||||
edit: async (event) => {
|
edit: async (event) => {
|
||||||
|
const { activeOrganizationId } = await requireActiveOrganizationAdmin(event.locals);
|
||||||
const form = await superValidate(event, zod4(editAdminSchema), { id: 'edit-admin' });
|
const form = await superValidate(event, zod4(editAdminSchema), { id: 'edit-admin' });
|
||||||
|
|
||||||
if (!form.valid) {
|
if (!form.valid) {
|
||||||
return message(form, 'Check the highlighted fields.', { status: 400 });
|
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 {
|
try {
|
||||||
await auth.api.adminUpdateUser({
|
await auth.api.adminUpdateUser({
|
||||||
headers: event.request.headers,
|
|
||||||
body: {
|
body: {
|
||||||
userId: form.data.id,
|
userId: form.data.id,
|
||||||
data: {
|
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) {
|
if (form.data.password) {
|
||||||
await auth.api.setUserPassword({
|
await auth.api.setUserPassword({
|
||||||
headers: event.request.headers,
|
|
||||||
body: {
|
body: {
|
||||||
userId: form.data.id,
|
userId: form.data.id,
|
||||||
newPassword: form.data.password
|
newPassword: form.data.password
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await db
|
||||||
|
.update(organizationMemberships)
|
||||||
|
.set({ role: form.data.role, updatedAt: new Date() })
|
||||||
|
.where(eq(organizationMemberships.id, membership.id));
|
||||||
} catch {
|
} 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
|
status: 400
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return message(form, 'Admin updated.');
|
return message(form, 'User updated.');
|
||||||
},
|
},
|
||||||
|
|
||||||
delete: async (event) => {
|
delete: async (event) => {
|
||||||
|
const { activeOrganizationId } = await requireActiveOrganizationAdmin(event.locals);
|
||||||
const form = await superValidate(event, zod4(deleteAdminSchema), { id: 'delete-admin' });
|
const form = await superValidate(event, zod4(deleteAdminSchema), { id: 'delete-admin' });
|
||||||
|
|
||||||
if (!form.valid) {
|
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>
|
<div>
|
||||||
<h2 class="text-xl font-semibold tracking-tight">Administrators</h2>
|
<h2 class="text-xl font-semibold tracking-tight">Administrators</h2>
|
||||||
<p class="text-sm text-muted-foreground">
|
<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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -65,7 +65,7 @@
|
|||||||
<DropdownMenu.Content align="end" class="w-36">
|
<DropdownMenu.Content align="end" class="w-36">
|
||||||
<DropdownMenu.Item onclick={() => openEdit(user)}>Edit</DropdownMenu.Item>
|
<DropdownMenu.Item onclick={() => openEdit(user)}>Edit</DropdownMenu.Item>
|
||||||
<DropdownMenu.Item variant="destructive" onclick={() => openDelete(user)}>
|
<DropdownMenu.Item variant="destructive" onclick={() => openDelete(user)}>
|
||||||
Delete
|
Remove access
|
||||||
</DropdownMenu.Item>
|
</DropdownMenu.Item>
|
||||||
</DropdownMenu.Content>
|
</DropdownMenu.Content>
|
||||||
</DropdownMenu.Root>
|
</DropdownMenu.Root>
|
||||||
|
|||||||
@@ -32,7 +32,9 @@
|
|||||||
<Dialog.Content class="sm:max-w-md">
|
<Dialog.Content class="sm:max-w-md">
|
||||||
<Dialog.Header>
|
<Dialog.Header>
|
||||||
<Dialog.Title>Create user</Dialog.Title>
|
<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>
|
</Dialog.Header>
|
||||||
<form method="POST" action="?/create" class="grid gap-2" use:enhanceCreate>
|
<form method="POST" action="?/create" class="grid gap-2" use:enhanceCreate>
|
||||||
<FormField form={createForm} name="name">
|
<FormField form={createForm} name="name">
|
||||||
@@ -66,6 +68,7 @@
|
|||||||
{...props}
|
{...props}
|
||||||
type="password"
|
type="password"
|
||||||
autocomplete="new-password"
|
autocomplete="new-password"
|
||||||
|
placeholder="Required for new users"
|
||||||
bind:value={$createData.password}
|
bind:value={$createData.password}
|
||||||
/>
|
/>
|
||||||
{/snippet}
|
{/snippet}
|
||||||
|
|||||||
@@ -17,9 +17,9 @@
|
|||||||
<AlertDialog.Root open={!!deletingUser} onOpenChange={(open) => !open && onClose()}>
|
<AlertDialog.Root open={!!deletingUser} onOpenChange={(open) => !open && onClose()}>
|
||||||
<AlertDialog.Content>
|
<AlertDialog.Content>
|
||||||
<AlertDialog.Header>
|
<AlertDialog.Header>
|
||||||
<AlertDialog.Title>Delete user?</AlertDialog.Title>
|
<AlertDialog.Title>Remove access?</AlertDialog.Title>
|
||||||
<AlertDialog.Description>
|
<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.Description>
|
||||||
</AlertDialog.Header>
|
</AlertDialog.Header>
|
||||||
<AlertDialog.Footer>
|
<AlertDialog.Footer>
|
||||||
@@ -27,7 +27,7 @@
|
|||||||
{#if deletingUser}
|
{#if deletingUser}
|
||||||
<form method="POST" action="?/delete" use:enhanceDelete>
|
<form method="POST" action="?/delete" use:enhanceDelete>
|
||||||
<input type="hidden" name="id" value={$deleteData.id} />
|
<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>
|
</form>
|
||||||
{/if}
|
{/if}
|
||||||
</AlertDialog.Footer>
|
</AlertDialog.Footer>
|
||||||
|
|||||||
@@ -13,9 +13,9 @@
|
|||||||
icon: MapPinIcon,
|
icon: MapPinIcon,
|
||||||
items: [
|
items: [
|
||||||
{ label: 'Name', value: organization.name },
|
{ label: 'Name', value: organization.name },
|
||||||
{ label: 'Workspace', value: organization.workspace },
|
{ label: 'Address line 1', value: organization.addressLine1 },
|
||||||
{ label: 'Address', value: organization.addressLine1 },
|
|
||||||
{ label: 'Address line 2', value: organization.addressLine2 },
|
{ label: 'Address line 2', value: organization.addressLine2 },
|
||||||
|
{ label: 'Address line 3', value: organization.addressLine3 },
|
||||||
{ label: 'City', value: organization.city },
|
{ label: 'City', value: organization.city },
|
||||||
{ label: 'Region', value: organization.region },
|
{ label: 'Region', value: organization.region },
|
||||||
{ label: 'Postcode', value: organization.postcode },
|
{ label: 'Postcode', value: organization.postcode },
|
||||||
|
|||||||
@@ -6,9 +6,9 @@
|
|||||||
type Organization = {
|
type Organization = {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
workspace: string;
|
|
||||||
addressLine1?: string | null;
|
addressLine1?: string | null;
|
||||||
addressLine2?: string | null;
|
addressLine2?: string | null;
|
||||||
|
addressLine3?: string | null;
|
||||||
city?: string | null;
|
city?: string | null;
|
||||||
region?: string | null;
|
region?: string | null;
|
||||||
postcode?: string | null;
|
postcode?: string | null;
|
||||||
@@ -21,13 +21,8 @@
|
|||||||
bankSwift?: string | null;
|
bankSwift?: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
let {
|
let { organization }: { organization: Organization } = $props();
|
||||||
organization,
|
let open = $state(false);
|
||||||
open = $bindable(false)
|
|
||||||
}: {
|
|
||||||
organization: Organization;
|
|
||||||
open: boolean;
|
|
||||||
} = $props();
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<Dialog.Root bind:open>
|
<Dialog.Root bind:open>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { redirect, error } from '@sveltejs/kit';
|
import { redirect, error } from '@sveltejs/kit';
|
||||||
import { db } from '$lib/server/db';
|
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 { organizationCreateSchema } from '$lib/schemas/organizations.schema';
|
||||||
import { setActiveOrganization } from '$lib/server/organizations';
|
import { setActiveOrganization } from '$lib/server/organizations';
|
||||||
import type { RequestHandler } from './$types';
|
import type { RequestHandler } from './$types';
|
||||||
@@ -24,12 +24,13 @@ export const POST: RequestHandler = async ({ locals, request }) => {
|
|||||||
const id = crypto.randomUUID();
|
const id = crypto.randomUUID();
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
|
|
||||||
await db.insert(organizations).values({
|
await db.transaction(async (tx) => {
|
||||||
|
await tx.insert(organizations).values({
|
||||||
id,
|
id,
|
||||||
name: parsed.data.name,
|
name: parsed.data.name,
|
||||||
workspace: parsed.data.workspace,
|
addressLine1: parsed.data.addressLine1,
|
||||||
addressLine1: parsed.data.addressLine1 || null,
|
|
||||||
addressLine2: parsed.data.addressLine2 || null,
|
addressLine2: parsed.data.addressLine2 || null,
|
||||||
|
addressLine3: parsed.data.addressLine3 || null,
|
||||||
city: parsed.data.city || null,
|
city: parsed.data.city || null,
|
||||||
region: parsed.data.region || null,
|
region: parsed.data.region || null,
|
||||||
postcode: parsed.data.postcode || null,
|
postcode: parsed.data.postcode || null,
|
||||||
@@ -44,6 +45,15 @@ export const POST: RequestHandler = async ({ locals, request }) => {
|
|||||||
updatedAt: now
|
updatedAt: now
|
||||||
});
|
});
|
||||||
|
|
||||||
|
await tx.insert(organizationMemberships).values({
|
||||||
|
organizationId: id,
|
||||||
|
userId: locals.user.id,
|
||||||
|
role: 'admin',
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
await setActiveOrganization(locals, id);
|
await setActiveOrganization(locals, id);
|
||||||
|
|
||||||
redirect(303, safeRedirect(parsed.data.redirectTo));
|
redirect(303, safeRedirect(parsed.data.redirectTo));
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { and, eq, isNull } from 'drizzle-orm';
|
|||||||
import { error, redirect } from '@sveltejs/kit';
|
import { error, redirect } from '@sveltejs/kit';
|
||||||
import { db } from '$lib/server/db';
|
import { db } from '$lib/server/db';
|
||||||
import { organizations } from '$lib/server/db/schema';
|
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 { organizationEditSchema } from '$lib/schemas/organizations.schema';
|
||||||
import type { RequestHandler } from './$types';
|
import type { RequestHandler } from './$types';
|
||||||
|
|
||||||
@@ -17,7 +17,7 @@ export const POST: RequestHandler = async ({ locals, request }) => {
|
|||||||
redirect(303, '/login');
|
redirect(303, '/login');
|
||||||
}
|
}
|
||||||
|
|
||||||
const { activeOrganizationId } = await loadOrganizationContext(locals);
|
const { activeOrganizationId } = await requireActiveOrganizationAdmin(locals);
|
||||||
const formData = await request.formData();
|
const formData = await request.formData();
|
||||||
const parsed = organizationEditSchema.safeParse(Object.fromEntries(formData));
|
const parsed = organizationEditSchema.safeParse(Object.fromEntries(formData));
|
||||||
|
|
||||||
@@ -29,9 +29,9 @@ export const POST: RequestHandler = async ({ locals, request }) => {
|
|||||||
.update(organizations)
|
.update(organizations)
|
||||||
.set({
|
.set({
|
||||||
name: parsed.data.name,
|
name: parsed.data.name,
|
||||||
workspace: parsed.data.workspace,
|
addressLine1: parsed.data.addressLine1,
|
||||||
addressLine1: parsed.data.addressLine1 || null,
|
|
||||||
addressLine2: parsed.data.addressLine2 || null,
|
addressLine2: parsed.data.addressLine2 || null,
|
||||||
|
addressLine3: parsed.data.addressLine3 || null,
|
||||||
city: parsed.data.city || null,
|
city: parsed.data.city || null,
|
||||||
region: parsed.data.region || null,
|
region: parsed.data.region || null,
|
||||||
postcode: parsed.data.postcode || null,
|
postcode: parsed.data.postcode || null,
|
||||||
|
|||||||
Reference in New Issue
Block a user