"use server"; import { revalidatePath } from "next/cache"; import { headers } from "next/headers"; import { z } from "zod"; import { auth } from "@/lib/auth/auth"; import { getServerSession } from "@/lib/auth/guards"; import { getEffectivePlan } from "@/lib/billing/subscription"; import { prisma } from "@/lib/db"; /** http(s)-only URL guard: rejects javascript:/data: and other schemes. */ const httpUrl = z .string() .url() .refine( (v) => { try { const proto = new URL(v).protocol; return proto === "http:" || proto === "https:"; } catch { return false; } }, { message: "Logo URL must be an http(s) URL." } ); const brandingSchema = z.object({ brandName: z.string().max(60).optional(), primaryColor: z .string() .regex(/^#([0-9a-fA-F]{6})$/, "Use a hex colour like #7c3aed") .optional() .or(z.literal("")), logoUrl: httpUrl.optional().or(z.literal("")), removePoweredBy: z.boolean().optional(), }); const inviteSchema = z.object({ email: z.string().email("Enter a valid email address."), }); /** * Invite a member — the server is the authority on the seat check. * * Better Auth only enforces `membershipLimit: 5` (see lib/auth/auth.ts), which is * NOT the same as the plan's `seats`. So we enforce the plan seat count here before * delegating to Better Auth's server API (`auth.api.createInvitation`), which itself * re-verifies that the caller is allowed to invite. The client-side check in * team-client.tsx remains only as a fast UX guard. */ export async function inviteMemberAction( organizationId: string, email: string ): Promise<{ ok: boolean; error?: string }> { const session = await getServerSession(); if (!session) return { ok: false, error: "You must be signed in." }; const parsed = inviteSchema.safeParse({ email }); if (!parsed.success) return { ok: false, error: parsed.error.issues[0]?.message ?? "Invalid email." }; // Caller must be an owner/admin of this organization. const member = await prisma.member.findFirst({ where: { organizationId, userId: session.user.id }, select: { role: true }, }); if (!member || !["owner", "admin"].includes(member.role)) { return { ok: false, error: "Only workspace owners can invite members." }; } // Seat enforcement against the effective plan, counting members + pending invites. const { plan } = await getEffectivePlan(session.user.id, organizationId); const [memberCount, pendingInvites] = await Promise.all([ prisma.member.count({ where: { organizationId } }), prisma.invitation.count({ where: { organizationId, status: "pending" } }), ]); if (memberCount + pendingInvites >= plan.limits.seats) { return { ok: false, error: `Your plan includes ${plan.limits.seats} seats.` }; } // Server-side invite via Better Auth's organization plugin API. Passing the // request headers authenticates the call; Better Auth also re-checks permissions. try { await auth.api.createInvitation({ body: { email: parsed.data.email, role: "member", organizationId }, headers: await headers(), }); } catch (err) { const message = err instanceof Error ? err.message : "Could not send invitation."; return { ok: false, error: message }; } revalidatePath("/team"); return { ok: true }; } /** * Shared authorization for every workspace mutation: the caller must be an * owner/admin of THIS organization. Returns the caller's role, or an error. */ async function requireOrgAdmin( organizationId: string ): Promise<{ ok: true; userId: string; role: string } | { ok: false; error: string }> { const session = await getServerSession(); if (!session) return { ok: false, error: "You must be signed in." }; const member = await prisma.member.findFirst({ where: { organizationId, userId: session.user.id }, select: { role: true }, }); if (!member || !["owner", "admin"].includes(member.role)) { return { ok: false, error: "Only workspace owners can manage members." }; } return { ok: true, userId: session.user.id, role: member.role }; } /** * Remove a member, freeing their seat. * * Guards: only owner/admin may remove; nobody may remove themselves (that would * orphan the workspace from the UI); and the last remaining owner is protected, * otherwise a workspace can be left with no one able to administer it. */ export async function removeMemberAction( organizationId: string, memberId: string ): Promise<{ ok: boolean; error?: string }> { const auth = await requireOrgAdmin(organizationId); if (!auth.ok) return { ok: false, error: auth.error }; const target = await prisma.member.findFirst({ where: { id: memberId, organizationId }, select: { id: true, userId: true, role: true }, }); if (!target) return { ok: false, error: "Member not found." }; if (target.userId === auth.userId) { return { ok: false, error: "You can't remove yourself from the workspace." }; } if (target.role === "owner") { const owners = await prisma.member.count({ where: { organizationId, role: "owner" } }); if (owners <= 1) return { ok: false, error: "The workspace must keep at least one owner." }; } await prisma.member.delete({ where: { id: target.id } }); revalidatePath("/team"); return { ok: true }; } /** Change a member's workspace role. Same last-owner protection as removal. */ export async function updateMemberRoleAction( organizationId: string, memberId: string, role: "owner" | "admin" | "member" ): Promise<{ ok: boolean; error?: string }> { const auth = await requireOrgAdmin(organizationId); if (!auth.ok) return { ok: false, error: auth.error }; // `role` is client-supplied and only TS-typed — validate it at runtime. const parsedRole = z.enum(["owner", "admin", "member"]).safeParse(role); if (!parsedRole.success) return { ok: false, error: "Invalid role." }; const target = await prisma.member.findFirst({ where: { id: memberId, organizationId }, select: { id: true, userId: true, role: true }, }); if (!target) return { ok: false, error: "Member not found." }; if (target.role === "owner" && parsedRole.data !== "owner") { const owners = await prisma.member.count({ where: { organizationId, role: "owner" } }); if (owners <= 1) return { ok: false, error: "The workspace must keep at least one owner." }; } await prisma.member.update({ where: { id: target.id }, data: { role: parsedRole.data } }); revalidatePath("/team"); return { ok: true }; } /** * Revoke a pending invitation, freeing the seat it was holding. * Scoped to the organization so an id from another workspace can't be cancelled. */ export async function revokeInvitationAction( organizationId: string, invitationId: string ): Promise<{ ok: boolean; error?: string }> { const auth = await requireOrgAdmin(organizationId); if (!auth.ok) return { ok: false, error: auth.error }; const invite = await prisma.invitation.findFirst({ where: { id: invitationId, organizationId, status: "pending" }, select: { id: true }, }); if (!invite) return { ok: false, error: "Invitation not found." }; await prisma.invitation.update({ where: { id: invite.id }, data: { status: "canceled" } }); revalidatePath("/team"); return { ok: true }; } export async function saveBrandingAction( organizationId: string, data: z.infer ): Promise<{ ok: boolean; error?: string }> { const session = await getServerSession(); if (!session) return { ok: false, error: "You must be signed in." }; const member = await prisma.member.findFirst({ where: { organizationId, userId: session.user.id }, select: { role: true }, }); if (!member || !["owner", "admin"].includes(member.role)) { return { ok: false, error: "Only workspace owners can edit branding." }; } const parsed = brandingSchema.safeParse(data); if (!parsed.success) return { ok: false, error: parsed.error.issues[0]?.message ?? "Invalid input." }; const payload = { brandName: parsed.data.brandName || null, primaryColor: parsed.data.primaryColor || null, logoUrl: parsed.data.logoUrl || null, removePoweredBy: parsed.data.removePoweredBy ?? false, }; await prisma.orgBranding.upsert({ where: { organizationId }, create: { organizationId, ...payload }, update: payload, }); revalidatePath("/team"); return { ok: true }; }