Implement Workstream J: Org procedures (backend)
Add complete organization management procedures: - orgs.list, create, get, update, delete, leave - orgs.members.list, updateRole, remove - orgs.invites.list, create, cancel, accept - orgs.sites.list Key features: - Role-based access control (owner > admin > member) - Transaction-protected owner count checks to prevent race conditions - Privilege escalation prevention (only owners can invite owners) - Graceful constraint violation handling with friendly error messages - Email sending for org invitations Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
100
apps/api-server/src/procedures/orgs/basic.ts
Normal file
100
apps/api-server/src/procedures/orgs/basic.ts
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
/**
|
||||||
|
* Basic org procedures - list, create, get
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { ORPCError } from "@orpc/server";
|
||||||
|
import { authMiddleware, os } from "../base.js";
|
||||||
|
import { getMembership, lookupOrgBySlug } from "./helpers.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* List all orgs the current user is a member of
|
||||||
|
*/
|
||||||
|
export const orgsList = os.orgs.list
|
||||||
|
.use(authMiddleware)
|
||||||
|
.handler(async ({ context }) => {
|
||||||
|
const orgs = await context.db
|
||||||
|
.selectFrom("org_members")
|
||||||
|
.innerJoin("orgs", "orgs.id", "org_members.org_id")
|
||||||
|
.where("org_members.user_id", "=", context.user.id)
|
||||||
|
.select([
|
||||||
|
"orgs.id",
|
||||||
|
"orgs.slug",
|
||||||
|
"orgs.display_name",
|
||||||
|
"orgs.logo_url",
|
||||||
|
"orgs.created_at",
|
||||||
|
])
|
||||||
|
.orderBy("orgs.created_at", "desc")
|
||||||
|
.execute();
|
||||||
|
|
||||||
|
return orgs.map((o) => ({
|
||||||
|
id: o.id,
|
||||||
|
slug: o.slug,
|
||||||
|
displayName: o.display_name,
|
||||||
|
logoUrl: o.logo_url,
|
||||||
|
createdAt: o.created_at,
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a new org
|
||||||
|
* The creating user becomes the owner
|
||||||
|
*/
|
||||||
|
export const orgsCreate = os.orgs.create
|
||||||
|
.use(authMiddleware)
|
||||||
|
.handler(async ({ input, context }) => {
|
||||||
|
const { slug, displayName } = input;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await context.db.transaction().execute(async (trx) => {
|
||||||
|
// Create the org
|
||||||
|
const org = await trx
|
||||||
|
.insertInto("orgs")
|
||||||
|
.values({
|
||||||
|
slug,
|
||||||
|
display_name: displayName,
|
||||||
|
})
|
||||||
|
.returning(["id"])
|
||||||
|
.executeTakeFirstOrThrow();
|
||||||
|
|
||||||
|
// Add the creating user as owner
|
||||||
|
await trx
|
||||||
|
.insertInto("org_members")
|
||||||
|
.values({
|
||||||
|
org_id: org.id,
|
||||||
|
user_id: context.user.id,
|
||||||
|
role: "owner",
|
||||||
|
})
|
||||||
|
.execute();
|
||||||
|
});
|
||||||
|
|
||||||
|
return { slug };
|
||||||
|
} catch (error) {
|
||||||
|
// Handle unique constraint violation on slug
|
||||||
|
if (error instanceof Error && error.message.includes("orgs_slug_key")) {
|
||||||
|
throw new ORPCError("CONFLICT", { message: "Slug already in use" });
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a single org by slug
|
||||||
|
* Requires membership
|
||||||
|
*/
|
||||||
|
export const orgsGet = os.orgs.get
|
||||||
|
.use(authMiddleware)
|
||||||
|
.handler(async ({ input, context }) => {
|
||||||
|
const { slug } = input;
|
||||||
|
|
||||||
|
// Lookup org and verify membership
|
||||||
|
const org = await lookupOrgBySlug(context.db, slug);
|
||||||
|
await getMembership(context.db, org.id, context.user.id);
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: org.id,
|
||||||
|
slug: org.slug,
|
||||||
|
displayName: org.displayName,
|
||||||
|
logoUrl: org.logoUrl,
|
||||||
|
createdAt: org.createdAt,
|
||||||
|
};
|
||||||
|
});
|
||||||
124
apps/api-server/src/procedures/orgs/helpers.ts
Normal file
124
apps/api-server/src/procedures/orgs/helpers.ts
Normal file
@@ -0,0 +1,124 @@
|
|||||||
|
/**
|
||||||
|
* Helper functions for org procedures
|
||||||
|
* Provides org lookup, membership verification, and role checks
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { DB, OrgRole } from "@reviq/db-schema";
|
||||||
|
import type { Kysely } from "kysely";
|
||||||
|
import { ORPCError } from "@orpc/server";
|
||||||
|
|
||||||
|
// ===== Types =====
|
||||||
|
|
||||||
|
/** Org info returned from lookup */
|
||||||
|
export interface OrgInfo {
|
||||||
|
id: number;
|
||||||
|
slug: string;
|
||||||
|
displayName: string;
|
||||||
|
logoUrl: string | null;
|
||||||
|
createdAt: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** User's membership in an org */
|
||||||
|
export interface OrgMembership {
|
||||||
|
id: number;
|
||||||
|
role: OrgRole;
|
||||||
|
createdAt: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== Role Hierarchy =====
|
||||||
|
|
||||||
|
const ROLE_LEVELS: Record<OrgRole, number> = {
|
||||||
|
owner: 3,
|
||||||
|
admin: 2,
|
||||||
|
member: 1,
|
||||||
|
};
|
||||||
|
|
||||||
|
// ===== Helper Functions =====
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Look up an org by slug
|
||||||
|
* @throws NOT_FOUND if org doesn't exist
|
||||||
|
*/
|
||||||
|
export async function lookupOrgBySlug(
|
||||||
|
db: Kysely<DB>,
|
||||||
|
slug: string,
|
||||||
|
): Promise<OrgInfo> {
|
||||||
|
const org = await db
|
||||||
|
.selectFrom("orgs")
|
||||||
|
.select(["id", "slug", "display_name", "logo_url", "created_at"])
|
||||||
|
.where("slug", "=", slug)
|
||||||
|
.executeTakeFirst();
|
||||||
|
|
||||||
|
if (!org) {
|
||||||
|
throw new ORPCError("NOT_FOUND", { message: "Organization not found" });
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: org.id,
|
||||||
|
slug: org.slug,
|
||||||
|
displayName: org.display_name,
|
||||||
|
logoUrl: org.logo_url,
|
||||||
|
createdAt: org.created_at,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a user's membership in an org
|
||||||
|
* @throws FORBIDDEN if user is not a member
|
||||||
|
*/
|
||||||
|
export async function getMembership(
|
||||||
|
db: Kysely<DB>,
|
||||||
|
orgId: number,
|
||||||
|
userId: number,
|
||||||
|
): Promise<OrgMembership> {
|
||||||
|
const membership = await db
|
||||||
|
.selectFrom("org_members")
|
||||||
|
.select(["id", "role", "created_at"])
|
||||||
|
.where("org_id", "=", orgId)
|
||||||
|
.where("user_id", "=", userId)
|
||||||
|
.executeTakeFirst();
|
||||||
|
|
||||||
|
if (!membership) {
|
||||||
|
throw new ORPCError("FORBIDDEN", {
|
||||||
|
message: "You are not a member of this organization",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: membership.id,
|
||||||
|
role: membership.role,
|
||||||
|
createdAt: membership.created_at,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verify user has the required role or higher
|
||||||
|
* Role hierarchy: owner > admin > member
|
||||||
|
* @throws FORBIDDEN if user lacks required role
|
||||||
|
*/
|
||||||
|
export function requireRole(
|
||||||
|
membership: OrgMembership,
|
||||||
|
requiredRole: OrgRole,
|
||||||
|
): void {
|
||||||
|
if (ROLE_LEVELS[membership.role] < ROLE_LEVELS[requiredRole]) {
|
||||||
|
throw new ORPCError("FORBIDDEN", { message: "Insufficient permissions" });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Count the number of owners in an org
|
||||||
|
* Used to prevent removing/demoting the last owner
|
||||||
|
*/
|
||||||
|
export async function countOwners(
|
||||||
|
db: Kysely<DB>,
|
||||||
|
orgId: number,
|
||||||
|
): Promise<number> {
|
||||||
|
const result = await db
|
||||||
|
.selectFrom("org_members")
|
||||||
|
.select((eb) => eb.fn.countAll<number>().as("count"))
|
||||||
|
.where("org_id", "=", orgId)
|
||||||
|
.where("role", "=", "owner")
|
||||||
|
.executeTakeFirstOrThrow();
|
||||||
|
|
||||||
|
return Number(result.count);
|
||||||
|
}
|
||||||
14
apps/api-server/src/procedures/orgs/index.ts
Normal file
14
apps/api-server/src/procedures/orgs/index.ts
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
/**
|
||||||
|
* Org procedures - organization management
|
||||||
|
*/
|
||||||
|
|
||||||
|
export { orgsCreate, orgsGet, orgsList } from "./basic.js";
|
||||||
|
export {
|
||||||
|
invitesAccept,
|
||||||
|
invitesCancel,
|
||||||
|
invitesCreate,
|
||||||
|
invitesList,
|
||||||
|
} from "./invites.js";
|
||||||
|
export { orgsDelete, orgsLeave, orgsUpdate } from "./management.js";
|
||||||
|
export { membersList, membersRemove, membersUpdateRole } from "./members.js";
|
||||||
|
export { sitesList } from "./sites.js";
|
||||||
219
apps/api-server/src/procedures/orgs/invites.ts
Normal file
219
apps/api-server/src/procedures/orgs/invites.ts
Normal file
@@ -0,0 +1,219 @@
|
|||||||
|
/**
|
||||||
|
* Org invite procedures - list, create, cancel, accept
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { ORPCError } from "@orpc/server";
|
||||||
|
import { ORG_INVITE_EXPIRY_DAYS } from "../../constants.js";
|
||||||
|
import { generateExpiry, generateSecureToken } from "../../utils/crypto.js";
|
||||||
|
import { sendOrgInviteEmail } from "../../utils/email.js";
|
||||||
|
import { authMiddleware, os } from "../base.js";
|
||||||
|
import { getMembership, lookupOrgBySlug, requireRole } from "./helpers.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* List pending invites for an org
|
||||||
|
* Requires admin or owner role
|
||||||
|
*/
|
||||||
|
export const invitesList = os.orgs.invites.list
|
||||||
|
.use(authMiddleware)
|
||||||
|
.handler(async ({ input, context }) => {
|
||||||
|
const { slug } = input;
|
||||||
|
|
||||||
|
// Lookup org and verify admin+ role
|
||||||
|
const org = await lookupOrgBySlug(context.db, slug);
|
||||||
|
const membership = await getMembership(context.db, org.id, context.user.id);
|
||||||
|
requireRole(membership, "admin");
|
||||||
|
|
||||||
|
// Get non-expired invites with inviter details
|
||||||
|
const invites = await context.db
|
||||||
|
.selectFrom("org_invites")
|
||||||
|
.innerJoin("users", "users.id", "org_invites.invited_by")
|
||||||
|
.where("org_invites.org_id", "=", org.id)
|
||||||
|
.where("org_invites.expires_at", ">", new Date())
|
||||||
|
.select([
|
||||||
|
"org_invites.id",
|
||||||
|
"org_invites.email",
|
||||||
|
"org_invites.role",
|
||||||
|
"org_invites.created_at",
|
||||||
|
"org_invites.expires_at",
|
||||||
|
"users.display_name as inviter_name",
|
||||||
|
"users.email as inviter_email",
|
||||||
|
])
|
||||||
|
.orderBy("org_invites.created_at", "desc")
|
||||||
|
.execute();
|
||||||
|
|
||||||
|
return invites.map((i) => ({
|
||||||
|
id: i.id,
|
||||||
|
email: i.email,
|
||||||
|
role: i.role,
|
||||||
|
invitedBy: i.inviter_name ?? i.inviter_email,
|
||||||
|
createdAt: i.created_at,
|
||||||
|
expiresAt: i.expires_at,
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create an invite for a new member
|
||||||
|
* Requires admin or owner role
|
||||||
|
* Only owners can invite new owners (privilege escalation prevention)
|
||||||
|
*/
|
||||||
|
export const invitesCreate = os.orgs.invites.create
|
||||||
|
.use(authMiddleware)
|
||||||
|
.handler(async ({ input, context }) => {
|
||||||
|
const { slug, email: rawEmail, role } = input;
|
||||||
|
const email = rawEmail.toLowerCase();
|
||||||
|
|
||||||
|
// Lookup org and verify admin+ role
|
||||||
|
const org = await lookupOrgBySlug(context.db, slug);
|
||||||
|
const membership = await getMembership(context.db, org.id, context.user.id);
|
||||||
|
requireRole(membership, "admin");
|
||||||
|
|
||||||
|
// Only owners can invite new owners (prevent privilege escalation)
|
||||||
|
if (role === "owner") {
|
||||||
|
requireRole(membership, "owner");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if user is already a member (by email)
|
||||||
|
const existingMember = await context.db
|
||||||
|
.selectFrom("org_members")
|
||||||
|
.innerJoin("users", "users.id", "org_members.user_id")
|
||||||
|
.where("org_members.org_id", "=", org.id)
|
||||||
|
.where("users.email", "=", email)
|
||||||
|
.select(["org_members.id"])
|
||||||
|
.executeTakeFirst();
|
||||||
|
|
||||||
|
if (existingMember) {
|
||||||
|
throw new ORPCError("CONFLICT", {
|
||||||
|
message: "This user is already a member of the organization",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate invite token and expiry
|
||||||
|
const token = generateSecureToken();
|
||||||
|
const expiresAt = generateExpiry(ORG_INVITE_EXPIRY_DAYS * 24 * 60 * 60);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Create the invite
|
||||||
|
await context.db
|
||||||
|
.insertInto("org_invites")
|
||||||
|
.values({
|
||||||
|
org_id: org.id,
|
||||||
|
email,
|
||||||
|
role,
|
||||||
|
invited_by: context.user.id,
|
||||||
|
token,
|
||||||
|
expires_at: expiresAt,
|
||||||
|
})
|
||||||
|
.execute();
|
||||||
|
} catch (error) {
|
||||||
|
// Handle unique constraint violation (race condition: another invite was created)
|
||||||
|
if (
|
||||||
|
error instanceof Error &&
|
||||||
|
error.message.includes("org_invites_org_id_email_key")
|
||||||
|
) {
|
||||||
|
throw new ORPCError("CONFLICT", {
|
||||||
|
message: "An invitation is already pending for this email",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send invitation email
|
||||||
|
const inviterName = context.user.displayName ?? context.user.email;
|
||||||
|
await sendOrgInviteEmail(email, token, org.displayName, inviterName, role);
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cancel a pending invite
|
||||||
|
* Requires admin or owner role
|
||||||
|
*/
|
||||||
|
export const invitesCancel = os.orgs.invites.cancel
|
||||||
|
.use(authMiddleware)
|
||||||
|
.handler(async ({ input, context }) => {
|
||||||
|
const { slug, inviteId } = input;
|
||||||
|
|
||||||
|
// Lookup org and verify admin+ role
|
||||||
|
const org = await lookupOrgBySlug(context.db, slug);
|
||||||
|
const membership = await getMembership(context.db, org.id, context.user.id);
|
||||||
|
requireRole(membership, "admin");
|
||||||
|
|
||||||
|
// Delete the invite
|
||||||
|
const result = await context.db
|
||||||
|
.deleteFrom("org_invites")
|
||||||
|
.where("id", "=", inviteId)
|
||||||
|
.where("org_id", "=", org.id)
|
||||||
|
.executeTakeFirst();
|
||||||
|
|
||||||
|
if (!result.numDeletedRows || result.numDeletedRows === 0n) {
|
||||||
|
throw new ORPCError("NOT_FOUND", { message: "Invitation not found" });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Accept an invitation
|
||||||
|
* Token-based lookup, requires auth but no org membership
|
||||||
|
* Handles race condition if user is already a member
|
||||||
|
*/
|
||||||
|
export const invitesAccept = os.orgs.invites.accept
|
||||||
|
.use(authMiddleware)
|
||||||
|
.handler(async ({ input, context }) => {
|
||||||
|
const { token } = input;
|
||||||
|
|
||||||
|
// Find the invite by token (must not be expired)
|
||||||
|
const invite = await context.db
|
||||||
|
.selectFrom("org_invites")
|
||||||
|
.where("token", "=", token)
|
||||||
|
.where("expires_at", ">", new Date())
|
||||||
|
.select(["id", "org_id", "email", "role"])
|
||||||
|
.executeTakeFirst();
|
||||||
|
|
||||||
|
if (!invite) {
|
||||||
|
throw new ORPCError("NOT_FOUND", {
|
||||||
|
message: "Invalid or expired invitation",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify the authenticated user's email matches the invite email
|
||||||
|
if (context.user.email.toLowerCase() !== invite.email.toLowerCase()) {
|
||||||
|
throw new ORPCError("FORBIDDEN", {
|
||||||
|
message: "This invitation was sent to a different email address",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Accept the invite in a transaction
|
||||||
|
await context.db.transaction().execute(async (trx) => {
|
||||||
|
// Add user as a member
|
||||||
|
await trx
|
||||||
|
.insertInto("org_members")
|
||||||
|
.values({
|
||||||
|
org_id: invite.org_id,
|
||||||
|
user_id: context.user.id,
|
||||||
|
role: invite.role,
|
||||||
|
})
|
||||||
|
.execute();
|
||||||
|
|
||||||
|
// Delete the invite
|
||||||
|
await trx
|
||||||
|
.deleteFrom("org_invites")
|
||||||
|
.where("id", "=", invite.id)
|
||||||
|
.execute();
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
// Handle unique constraint violation (user is already a member)
|
||||||
|
if (
|
||||||
|
error instanceof Error &&
|
||||||
|
error.message.includes("org_members_org_id_user_id_key")
|
||||||
|
) {
|
||||||
|
// Clean up the invite since user is already a member
|
||||||
|
await context.db
|
||||||
|
.deleteFrom("org_invites")
|
||||||
|
.where("id", "=", invite.id)
|
||||||
|
.execute();
|
||||||
|
|
||||||
|
throw new ORPCError("CONFLICT", {
|
||||||
|
message: "You are already a member of this organization",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
});
|
||||||
95
apps/api-server/src/procedures/orgs/management.ts
Normal file
95
apps/api-server/src/procedures/orgs/management.ts
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
/**
|
||||||
|
* Org management procedures - update, delete, leave
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { ORPCError } from "@orpc/server";
|
||||||
|
import { authMiddleware, os } from "../base.js";
|
||||||
|
import {
|
||||||
|
countOwners,
|
||||||
|
getMembership,
|
||||||
|
lookupOrgBySlug,
|
||||||
|
requireRole,
|
||||||
|
} from "./helpers.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update org details
|
||||||
|
* Requires admin or owner role
|
||||||
|
*/
|
||||||
|
export const orgsUpdate = os.orgs.update
|
||||||
|
.use(authMiddleware)
|
||||||
|
.handler(async ({ input, context }) => {
|
||||||
|
const { slug, displayName, logoUrl } = input;
|
||||||
|
|
||||||
|
// Lookup org and verify membership with admin+ role
|
||||||
|
const org = await lookupOrgBySlug(context.db, slug);
|
||||||
|
const membership = await getMembership(context.db, org.id, context.user.id);
|
||||||
|
requireRole(membership, "admin");
|
||||||
|
|
||||||
|
// Build update object with only provided fields
|
||||||
|
const updates: Record<string, unknown> = { updated_at: new Date() };
|
||||||
|
if (displayName !== undefined) {
|
||||||
|
updates.display_name = displayName;
|
||||||
|
}
|
||||||
|
if (logoUrl !== undefined) {
|
||||||
|
updates.logo_url = logoUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
await context.db
|
||||||
|
.updateTable("orgs")
|
||||||
|
.set(updates)
|
||||||
|
.where("id", "=", org.id)
|
||||||
|
.execute();
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete an org
|
||||||
|
* Requires owner role
|
||||||
|
* FK CASCADE handles deleting members, invites, and sites
|
||||||
|
*/
|
||||||
|
export const orgsDelete = os.orgs.delete
|
||||||
|
.use(authMiddleware)
|
||||||
|
.handler(async ({ input, context }) => {
|
||||||
|
const { slug } = input;
|
||||||
|
|
||||||
|
// Lookup org and verify ownership
|
||||||
|
const org = await lookupOrgBySlug(context.db, slug);
|
||||||
|
const membership = await getMembership(context.db, org.id, context.user.id);
|
||||||
|
requireRole(membership, "owner");
|
||||||
|
|
||||||
|
await context.db.deleteFrom("orgs").where("id", "=", org.id).execute();
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Leave an org
|
||||||
|
* Cannot leave if you're the only owner
|
||||||
|
* Uses transaction to prevent race condition where multiple owners leave simultaneously
|
||||||
|
*/
|
||||||
|
export const orgsLeave = os.orgs.leave
|
||||||
|
.use(authMiddleware)
|
||||||
|
.handler(async ({ input, context }) => {
|
||||||
|
const { slug } = input;
|
||||||
|
|
||||||
|
// Lookup org and get membership
|
||||||
|
const org = await lookupOrgBySlug(context.db, slug);
|
||||||
|
const membership = await getMembership(context.db, org.id, context.user.id);
|
||||||
|
|
||||||
|
await context.db.transaction().execute(async (trx) => {
|
||||||
|
// If user is an owner, check if they're the last one (with lock)
|
||||||
|
if (membership.role === "owner") {
|
||||||
|
const ownerCount = await countOwners(trx, org.id);
|
||||||
|
if (ownerCount === 1) {
|
||||||
|
throw new ORPCError("BAD_REQUEST", {
|
||||||
|
message:
|
||||||
|
"Cannot leave as the only owner. Transfer ownership or delete the organization.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove membership
|
||||||
|
await trx
|
||||||
|
.deleteFrom("org_members")
|
||||||
|
.where("org_id", "=", org.id)
|
||||||
|
.where("user_id", "=", context.user.id)
|
||||||
|
.execute();
|
||||||
|
});
|
||||||
|
});
|
||||||
158
apps/api-server/src/procedures/orgs/members.ts
Normal file
158
apps/api-server/src/procedures/orgs/members.ts
Normal file
@@ -0,0 +1,158 @@
|
|||||||
|
/**
|
||||||
|
* Org member management procedures - list, updateRole, remove
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { ORPCError } from "@orpc/server";
|
||||||
|
import { authMiddleware, os } from "../base.js";
|
||||||
|
import {
|
||||||
|
countOwners,
|
||||||
|
getMembership,
|
||||||
|
lookupOrgBySlug,
|
||||||
|
requireRole,
|
||||||
|
} from "./helpers.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* List all members of an org
|
||||||
|
* Any member can view the member list
|
||||||
|
*/
|
||||||
|
export const membersList = os.orgs.members.list
|
||||||
|
.use(authMiddleware)
|
||||||
|
.handler(async ({ input, context }) => {
|
||||||
|
const { slug } = input;
|
||||||
|
|
||||||
|
// Lookup org and verify membership
|
||||||
|
const org = await lookupOrgBySlug(context.db, slug);
|
||||||
|
await getMembership(context.db, org.id, context.user.id);
|
||||||
|
|
||||||
|
// Get members with user details
|
||||||
|
const members = await context.db
|
||||||
|
.selectFrom("org_members")
|
||||||
|
.innerJoin("users", "users.id", "org_members.user_id")
|
||||||
|
.where("org_members.org_id", "=", org.id)
|
||||||
|
.select([
|
||||||
|
"org_members.id",
|
||||||
|
"org_members.user_id",
|
||||||
|
"org_members.role",
|
||||||
|
"org_members.created_at",
|
||||||
|
"users.email",
|
||||||
|
"users.display_name",
|
||||||
|
])
|
||||||
|
.orderBy("org_members.created_at", "asc")
|
||||||
|
.execute();
|
||||||
|
|
||||||
|
return members.map((m) => ({
|
||||||
|
id: m.id,
|
||||||
|
userId: m.user_id,
|
||||||
|
email: m.email,
|
||||||
|
displayName: m.display_name,
|
||||||
|
role: m.role,
|
||||||
|
createdAt: m.created_at,
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update a member's role
|
||||||
|
* Only owners can change roles
|
||||||
|
* Uses transaction to prevent race condition when demoting owners
|
||||||
|
*/
|
||||||
|
export const membersUpdateRole = os.orgs.members.updateRole
|
||||||
|
.use(authMiddleware)
|
||||||
|
.handler(async ({ input, context }) => {
|
||||||
|
const { slug, userId, role: newRole } = input;
|
||||||
|
|
||||||
|
// Lookup org and verify ownership
|
||||||
|
const org = await lookupOrgBySlug(context.db, slug);
|
||||||
|
const membership = await getMembership(context.db, org.id, context.user.id);
|
||||||
|
requireRole(membership, "owner");
|
||||||
|
|
||||||
|
await context.db.transaction().execute(async (trx) => {
|
||||||
|
// Get the target member's current membership
|
||||||
|
const targetMember = await trx
|
||||||
|
.selectFrom("org_members")
|
||||||
|
.select(["id", "role"])
|
||||||
|
.where("org_id", "=", org.id)
|
||||||
|
.where("user_id", "=", userId)
|
||||||
|
.executeTakeFirst();
|
||||||
|
|
||||||
|
if (!targetMember) {
|
||||||
|
throw new ORPCError("NOT_FOUND", { message: "Member not found" });
|
||||||
|
}
|
||||||
|
|
||||||
|
// If demoting an owner, check if they're the last one
|
||||||
|
if (targetMember.role === "owner" && newRole !== "owner") {
|
||||||
|
const ownerCount = await countOwners(trx, org.id);
|
||||||
|
if (ownerCount === 1) {
|
||||||
|
throw new ORPCError("BAD_REQUEST", {
|
||||||
|
message: "Cannot demote the only owner",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update the role
|
||||||
|
await trx
|
||||||
|
.updateTable("org_members")
|
||||||
|
.set({ role: newRole })
|
||||||
|
.where("id", "=", targetMember.id)
|
||||||
|
.execute();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove a member from an org
|
||||||
|
* Owners can remove anyone, admins can only remove members
|
||||||
|
* Uses transaction to prevent race condition when removing owners
|
||||||
|
*/
|
||||||
|
export const membersRemove = os.orgs.members.remove
|
||||||
|
.use(authMiddleware)
|
||||||
|
.handler(async ({ input, context }) => {
|
||||||
|
const { slug, userId } = input;
|
||||||
|
|
||||||
|
// Lookup org and verify membership
|
||||||
|
const org = await lookupOrgBySlug(context.db, slug);
|
||||||
|
const membership = await getMembership(context.db, org.id, context.user.id);
|
||||||
|
|
||||||
|
await context.db.transaction().execute(async (trx) => {
|
||||||
|
// Get the target member
|
||||||
|
const targetMember = await trx
|
||||||
|
.selectFrom("org_members")
|
||||||
|
.select(["id", "role"])
|
||||||
|
.where("org_id", "=", org.id)
|
||||||
|
.where("user_id", "=", userId)
|
||||||
|
.executeTakeFirst();
|
||||||
|
|
||||||
|
if (!targetMember) {
|
||||||
|
throw new ORPCError("NOT_FOUND", { message: "Member not found" });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Permission checks
|
||||||
|
if (membership.role === "owner") {
|
||||||
|
// Owners can remove anyone except the last owner
|
||||||
|
if (targetMember.role === "owner") {
|
||||||
|
const ownerCount = await countOwners(trx, org.id);
|
||||||
|
if (ownerCount === 1) {
|
||||||
|
throw new ORPCError("BAD_REQUEST", {
|
||||||
|
message: "Cannot remove the only owner",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (membership.role === "admin") {
|
||||||
|
// Admins can only remove members (not owners or other admins)
|
||||||
|
if (targetMember.role !== "member") {
|
||||||
|
throw new ORPCError("FORBIDDEN", {
|
||||||
|
message: "Admins can only remove members",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Members cannot remove anyone
|
||||||
|
throw new ORPCError("FORBIDDEN", {
|
||||||
|
message: "Insufficient permissions",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove the member
|
||||||
|
await trx
|
||||||
|
.deleteFrom("org_members")
|
||||||
|
.where("id", "=", targetMember.id)
|
||||||
|
.execute();
|
||||||
|
});
|
||||||
|
});
|
||||||
34
apps/api-server/src/procedures/orgs/sites.ts
Normal file
34
apps/api-server/src/procedures/orgs/sites.ts
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
/**
|
||||||
|
* Org sites procedures - list
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { authMiddleware, os } from "../base.js";
|
||||||
|
import { getMembership, lookupOrgBySlug } from "./helpers.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* List all sites for an org
|
||||||
|
* Any member can view the site list
|
||||||
|
*/
|
||||||
|
export const sitesList = os.orgs.sites.list
|
||||||
|
.use(authMiddleware)
|
||||||
|
.handler(async ({ input, context }) => {
|
||||||
|
const { slug } = input;
|
||||||
|
|
||||||
|
// Lookup org and verify membership
|
||||||
|
const org = await lookupOrgBySlug(context.db, slug);
|
||||||
|
await getMembership(context.db, org.id, context.user.id);
|
||||||
|
|
||||||
|
// Get sites
|
||||||
|
const sites = await context.db
|
||||||
|
.selectFrom("org_sites")
|
||||||
|
.where("org_id", "=", org.id)
|
||||||
|
.select(["id", "domain", "created_at"])
|
||||||
|
.orderBy("created_at", "asc")
|
||||||
|
.execute();
|
||||||
|
|
||||||
|
return sites.map((s) => ({
|
||||||
|
id: s.id,
|
||||||
|
domain: s.domain,
|
||||||
|
createdAt: s.created_at,
|
||||||
|
}));
|
||||||
|
});
|
||||||
@@ -34,6 +34,22 @@ import {
|
|||||||
} from "./procedures/me/sessions.js";
|
} from "./procedures/me/sessions.js";
|
||||||
import { setPassword } from "./procedures/me/set-password.js";
|
import { setPassword } from "./procedures/me/set-password.js";
|
||||||
import { updateProfile } from "./procedures/me/update-profile.js";
|
import { updateProfile } from "./procedures/me/update-profile.js";
|
||||||
|
import {
|
||||||
|
invitesAccept,
|
||||||
|
invitesCancel,
|
||||||
|
invitesCreate,
|
||||||
|
invitesList,
|
||||||
|
membersList,
|
||||||
|
membersRemove,
|
||||||
|
membersUpdateRole,
|
||||||
|
orgsCreate,
|
||||||
|
orgsDelete,
|
||||||
|
orgsGet,
|
||||||
|
orgsLeave,
|
||||||
|
orgsList,
|
||||||
|
orgsUpdate,
|
||||||
|
sitesList,
|
||||||
|
} from "./procedures/orgs/index.js";
|
||||||
import {
|
import {
|
||||||
createAuthenticationOptions as createAuthOptions,
|
createAuthenticationOptions as createAuthOptions,
|
||||||
createRegistrationOptions as createRegOptions,
|
createRegistrationOptions as createRegOptions,
|
||||||
@@ -176,79 +192,11 @@ const setupProfile = os.me.setupProfile
|
|||||||
// - listSessions, revokeSession, revokeAllSessions
|
// - listSessions, revokeSession, revokeAllSessions
|
||||||
// - getDeviceInfo, trustDevice, listTrustedDevices, untrustDevice, revokeAllTrustedDevices
|
// - getDeviceInfo, trustDevice, listTrustedDevices, untrustDevice, revokeAllTrustedDevices
|
||||||
|
|
||||||
// Orgs procedures (all require auth)
|
// Orgs procedures - imported from ./procedures/orgs/index.js
|
||||||
const orgsList = os.orgs.list.use(authMiddleware).handler(async () => {
|
// - orgsList, orgsCreate, orgsGet, orgsUpdate, orgsDelete, orgsLeave
|
||||||
throw new ORPCError("NOT_IMPLEMENTED", { message: "Not implemented" });
|
// - membersList, membersUpdateRole, membersRemove
|
||||||
});
|
// - invitesList, invitesCreate, invitesCancel, invitesAccept
|
||||||
|
// - sitesList
|
||||||
const orgsCreate = os.orgs.create.use(authMiddleware).handler(async () => {
|
|
||||||
throw new ORPCError("NOT_IMPLEMENTED", { message: "Not implemented" });
|
|
||||||
});
|
|
||||||
|
|
||||||
const orgsGet = os.orgs.get.use(authMiddleware).handler(async () => {
|
|
||||||
throw new ORPCError("NOT_IMPLEMENTED", { message: "Not implemented" });
|
|
||||||
});
|
|
||||||
|
|
||||||
const orgsUpdate = os.orgs.update.use(authMiddleware).handler(async () => {
|
|
||||||
throw new ORPCError("NOT_IMPLEMENTED", { message: "Not implemented" });
|
|
||||||
});
|
|
||||||
|
|
||||||
const orgsDelete = os.orgs.delete.use(authMiddleware).handler(async () => {
|
|
||||||
throw new ORPCError("NOT_IMPLEMENTED", { message: "Not implemented" });
|
|
||||||
});
|
|
||||||
|
|
||||||
const orgsLeave = os.orgs.leave.use(authMiddleware).handler(async () => {
|
|
||||||
throw new ORPCError("NOT_IMPLEMENTED", { message: "Not implemented" });
|
|
||||||
});
|
|
||||||
|
|
||||||
// Orgs members procedures
|
|
||||||
const membersList = os.orgs.members.list
|
|
||||||
.use(authMiddleware)
|
|
||||||
.handler(async () => {
|
|
||||||
throw new ORPCError("NOT_IMPLEMENTED", { message: "Not implemented" });
|
|
||||||
});
|
|
||||||
|
|
||||||
const membersUpdateRole = os.orgs.members.updateRole
|
|
||||||
.use(authMiddleware)
|
|
||||||
.handler(async () => {
|
|
||||||
throw new ORPCError("NOT_IMPLEMENTED", { message: "Not implemented" });
|
|
||||||
});
|
|
||||||
|
|
||||||
const membersRemove = os.orgs.members.remove
|
|
||||||
.use(authMiddleware)
|
|
||||||
.handler(async () => {
|
|
||||||
throw new ORPCError("NOT_IMPLEMENTED", { message: "Not implemented" });
|
|
||||||
});
|
|
||||||
|
|
||||||
// Orgs invites procedures
|
|
||||||
const invitesList = os.orgs.invites.list
|
|
||||||
.use(authMiddleware)
|
|
||||||
.handler(async () => {
|
|
||||||
throw new ORPCError("NOT_IMPLEMENTED", { message: "Not implemented" });
|
|
||||||
});
|
|
||||||
|
|
||||||
const invitesCreate = os.orgs.invites.create
|
|
||||||
.use(authMiddleware)
|
|
||||||
.handler(async () => {
|
|
||||||
throw new ORPCError("NOT_IMPLEMENTED", { message: "Not implemented" });
|
|
||||||
});
|
|
||||||
|
|
||||||
const invitesCancel = os.orgs.invites.cancel
|
|
||||||
.use(authMiddleware)
|
|
||||||
.handler(async () => {
|
|
||||||
throw new ORPCError("NOT_IMPLEMENTED", { message: "Not implemented" });
|
|
||||||
});
|
|
||||||
|
|
||||||
const invitesAccept = os.orgs.invites.accept
|
|
||||||
.use(authMiddleware)
|
|
||||||
.handler(async () => {
|
|
||||||
throw new ORPCError("NOT_IMPLEMENTED", { message: "Not implemented" });
|
|
||||||
});
|
|
||||||
|
|
||||||
// Orgs sites procedures
|
|
||||||
const sitesList = os.orgs.sites.list.use(authMiddleware).handler(async () => {
|
|
||||||
throw new ORPCError("NOT_IMPLEMENTED", { message: "Not implemented" });
|
|
||||||
});
|
|
||||||
|
|
||||||
// Admin orgs procedures (require superuser - for now just auth, will add superuser middleware later)
|
// Admin orgs procedures (require superuser - for now just auth, will add superuser middleware later)
|
||||||
const adminOrgsList = os.admin.orgs.list
|
const adminOrgsList = os.admin.orgs.list
|
||||||
|
|||||||
Reference in New Issue
Block a user