"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 }; } 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 }; }