diff --git a/apps/web/src/lib/auth/__tests__/policies.test.ts b/apps/web/src/lib/auth/__tests__/policies.test.ts index abb762bb..0a76133a 100644 --- a/apps/web/src/lib/auth/__tests__/policies.test.ts +++ b/apps/web/src/lib/auth/__tests__/policies.test.ts @@ -17,6 +17,7 @@ import { evaluateAdminRouteGuard, evaluateApiKeyAuth, evaluateAuthedRouteGuard, + evaluateBrandAccess, evaluateBrandRouteGuard, evaluateDeploymentPolicy, evaluateOrgScope, @@ -452,6 +453,33 @@ describe("evaluateOrgScope", () => { }); }); +// Umbrella-org model (issue #432): a brand's owning org is resolved via +// `brands.organizationId`, not assumed equal to the brand id. +describe("evaluateBrandAccess", () => { + const ORG_A = "org-a"; + const ORG_B = "org-b"; + + it("allows a member of the brand's owning org", () => { + expect(evaluateBrandAccess([ORG_A], ORG_A)).toBe("allow"); + }); + + it("denies a member of a different org", () => { + expect(evaluateBrandAccess([ORG_A], ORG_B)).toBe("deny"); + }); + + it("allows a multi-org member whose set includes the brand's org", () => { + expect(evaluateBrandAccess([ORG_A, ORG_B], ORG_B)).toBe("allow"); + }); + + it("denies when the brand does not exist (brandOrgId is null)", () => { + expect(evaluateBrandAccess([ORG_A], null)).toBe("deny"); + }); + + it("denies when the user has no memberships", () => { + expect(evaluateBrandAccess([], ORG_A)).toBe("deny"); + }); +}); + describe("evaluateReadOnly", () => { it("denies writes when read-only is enabled", () => { expect(evaluateReadOnly(true)).toBe("deny"); diff --git a/apps/web/src/lib/auth/helpers.ts b/apps/web/src/lib/auth/helpers.ts index 2eee9f46..a4aca464 100644 --- a/apps/web/src/lib/auth/helpers.ts +++ b/apps/web/src/lib/auth/helpers.ts @@ -3,7 +3,7 @@ */ import { getRequestHeaders } from "@tanstack/react-start/server"; import { db } from "@workspace/lib/db/db"; -import { member, organization } from "@workspace/lib/db/schema"; +import { member, organization, brands } from "@workspace/lib/db/schema"; import { eq, and } from "drizzle-orm"; import { getDeployment } from "@/lib/config/server"; import { auth } from "./server"; @@ -47,6 +47,43 @@ export async function requireOrgAccess(userId: string, orgId: string): Promise { + const [row] = await db + .select({ id: member.id }) + .from(brands) + .innerJoin( + member, + and(eq(member.organizationId, brands.organizationId), eq(member.userId, userId)), + ) + .where(eq(brands.id, brandId)) + .limit(1); + return !!row; +} + +export async function requireBrandAccess(userId: string, brandId: string): Promise { + if (!(await checkBrandAccess(userId, brandId))) { + throw new Error("Forbidden: No access to this brand"); + } +} + +/** + * Resolve a brand's owning org id — the umbrella org whose membership gates + * team management for that brand. Null when the brand doesn't exist. + */ +export async function getBrandOrganizationId(brandId: string): Promise { + const [row] = await db + .select({ organizationId: brands.organizationId }) + .from(brands) + .where(eq(brands.id, brandId)) + .limit(1); + return row?.organizationId ?? null; +} + export async function listUserOrganizations(userId: string): Promise<{ id: string; name: string }[]> { return db .select({ id: organization.id, name: organization.name }) diff --git a/apps/web/src/lib/auth/policies.ts b/apps/web/src/lib/auth/policies.ts index effac7a7..522ce36f 100644 --- a/apps/web/src/lib/auth/policies.ts +++ b/apps/web/src/lib/auth/policies.ts @@ -285,6 +285,20 @@ export function evaluateOrgScope( return memberOrgIds.includes(resourceOrgId) ? "allow" : "deny"; } +/** + * Brand-scoped access rule for the umbrella-org model: a user may act on a + * brand only if they are a member of the brand's owning org. `brandOrgId` is + * null when the brand does not exist — which must deny, never fall through to + * a brand-id-as-org-id match. + */ +export function evaluateBrandAccess( + memberOrgIds: readonly string[], + brandOrgId: string | null, +): "allow" | "deny" { + if (brandOrgId === null) return "deny"; + return memberOrgIds.includes(brandOrgId) ? "allow" : "deny"; +} + /** * Evaluate read-only mode enforcement. * Used by `readOnlyMiddleware` for server functions. diff --git a/apps/web/src/lib/auth/server.ts b/apps/web/src/lib/auth/server.ts index 31486993..c55ca8e2 100644 --- a/apps/web/src/lib/auth/server.ts +++ b/apps/web/src/lib/auth/server.ts @@ -10,7 +10,7 @@ import { getCloudAuthOptions } from "@workspace/cloud/auth-hooks"; import { createAuth, type CreateAuthOptions } from "@workspace/lib/auth/server"; import { getWhitelabelAuthOptions } from "@workspace/whitelabel/auth-hooks"; -import { countUsers, provisionLocalOrg } from "@workspace/lib/db/provisioning"; +import { countUsers, provisionLocalOrg, provisionUmbrellaOrg } from "@workspace/lib/db/provisioning"; import { evaluateSignupAllowed, getSignupAllowlist } from "./policies"; /** @@ -60,8 +60,9 @@ function getDeploymentAuthOptions(): CreateAuthOptions | undefined { // unlike the UI's canRegister flag. Set CLOUD_SIGNUP_ALLOWLIST to admit // people ("@elmohq.com,alice@x.com"); empty denies everyone (fails // closed); "*" opens it up at launch. Sign-in is unaffected — create - // hooks don't fire for existing users. Each user provisions their own - // org via the create-brand flow (canCreateBrands), so no after hook. + // hooks don't fire for existing users. The after hook provisions the + // user's umbrella org (mirroring local's user.create.after below); + // brands attach to it via the create-brand flow (canCreateBrands). const cloudOptions = getCloudAuthOptions(); const rejectDisposableEmail = cloudOptions.databaseHooks?.user?.create?.before; return { @@ -78,6 +79,12 @@ function getDeploymentAuthOptions(): CreateAuthOptions | undefined { } await rejectDisposableEmail?.(user, context); }, + after: async (user) => { + await provisionUmbrellaOrg({ + userId: user.id, + name: user.name?.trim() ? `${user.name.trim()}'s workspace` : "My workspace", + }); + }, }, }, }, diff --git a/apps/web/src/routes/_authed/accept-invitation/$invitationId.tsx b/apps/web/src/routes/_authed/accept-invitation/$invitationId.tsx index 2b73b31d..85f230de 100644 --- a/apps/web/src/routes/_authed/accept-invitation/$invitationId.tsx +++ b/apps/web/src/routes/_authed/accept-invitation/$invitationId.tsx @@ -57,8 +57,8 @@ function AcceptInvitationPage() { setAcceptError(null); setAccepting(true); try { - const { orgId } = await acceptInvitationFn({ data: { invitationId } }); - navigate({ to: "/app/$brand", params: { brand: orgId } }); + await acceptInvitationFn({ data: { invitationId } }); + navigate({ to: "/app" }); } catch (err) { setAcceptError(err instanceof Error ? err.message : "Failed to accept the invitation"); setAccepting(false); diff --git a/apps/web/src/routes/_authed/app/$brand.tsx b/apps/web/src/routes/_authed/app/$brand.tsx index 3831d72d..d5f83ac3 100644 --- a/apps/web/src/routes/_authed/app/$brand.tsx +++ b/apps/web/src/routes/_authed/app/$brand.tsx @@ -32,46 +32,49 @@ const getBrandData = createServerFn({ method: "GET" }) }> => { const session = await requireAuthSession(); - // Verify access + const brand = await db.query.brands.findFirst({ where: eq(brands.id, data.brandId) }); + + if (brand) { + const hasAccess = await checkOrgAccess(session.user.id, brand.organizationId); + if (!hasAccess) { + return { brand: null, brandName: null, isAdmin: false, hasReportAccess: false, hasAccess: false }; + } + + const brandPrompts = await db.query.prompts.findMany({ + where: eq(prompts.brandId, data.brandId), + }); + const brandCompetitors = await db.query.competitors.findMany({ + where: eq(competitors.brandId, data.brandId), + }); + + return { + brand: { + ...brand, + prompts: brandPrompts, + competitors: brandCompetitors, + }, + brandName: brand.name, + isAdmin: isAdmin(session), + hasReportAccess: hasReportAccess(session), + hasAccess: true, + }; + } + + // No brand row: legacy onboarding path where the URL param is an org id + // (brand.id === org.id). Whitelabel empty-org onboarding depends on this. const hasAccess = await checkOrgAccess(session.user.id, data.brandId); if (!hasAccess) { return { brand: null, brandName: null, isAdmin: false, hasReportAccess: false, hasAccess: false }; } - // Get brand metadata (name from org membership — org exists even if not in DB yet) const orgs = await listUserOrganizations(session.user.id); const orgMeta = orgs.find((o) => o.id === data.brandId); - const brandName = orgMeta?.name || data.brandId; - - const admin = isAdmin(session); - const reportAccess = hasReportAccess(session); - - // Get brand data from DB - const brand = await db.query.brands.findFirst({ - where: eq(brands.id, data.brandId), - }); - - if (!brand) { - return { brand: null, brandName, isAdmin: admin, hasReportAccess: reportAccess, hasAccess: true }; - } - - const brandPrompts = await db.query.prompts.findMany({ - where: eq(prompts.brandId, data.brandId), - }); - - const brandCompetitors = await db.query.competitors.findMany({ - where: eq(competitors.brandId, data.brandId), - }); return { - brand: { - ...brand, - prompts: brandPrompts, - competitors: brandCompetitors, - }, - brandName: brand.name, - isAdmin: admin, - hasReportAccess: reportAccess, + brand: null, + brandName: orgMeta?.name || data.brandId, + isAdmin: isAdmin(session), + hasReportAccess: hasReportAccess(session), hasAccess: true, }; }); diff --git a/apps/web/src/routes/_authed/app/$brand/settings/members.tsx b/apps/web/src/routes/_authed/app/$brand/settings/members.tsx index 4fad4fc9..f826bf15 100644 --- a/apps/web/src/routes/_authed/app/$brand/settings/members.tsx +++ b/apps/web/src/routes/_authed/app/$brand/settings/members.tsx @@ -17,7 +17,14 @@ import { useState } from "react"; import { getDeployment } from "@/lib/config/server"; import { trackEvent } from "@/lib/posthog"; import { getAppName, getBrandName, buildTitle } from "@/lib/route-head"; -import { cancelInvitationFn, inviteTeamMemberFn, listTeamFn, removeTeamMemberFn, type TeamData } from "@/server/team"; +import { + cancelInvitationFn, + inviteTeamMemberFn, + listTeamFn, + removeTeamMemberFn, + updateOrganizationFn, + type TeamData, +} from "@/server/team"; const getTeamInvitesEnabled = createServerFn({ method: "GET" }).handler(async () => { return { teamInvites: getDeployment().features.teamInvites }; @@ -46,12 +53,28 @@ export const Route = createFileRoute("/_authed/app/$brand/settings/members")({ function TeamSettingsPage() { const { brand: brandId } = Route.useParams(); - const { members, invitations, currentUserId } = Route.useLoaderData(); + const { members, invitations, currentUserId, organization } = Route.useLoaderData(); const router = useRouter(); const [inviteEmail, setInviteEmail] = useState(""); const [inviteRole, setInviteRole] = useState<"member" | "admin">("member"); const [inviting, setInviting] = useState(false); const [error, setError] = useState(null); + const [workspaceName, setWorkspaceName] = useState(organization.name); + const [savingWorkspace, setSavingWorkspace] = useState(false); + + async function handleSaveWorkspace(e: React.FormEvent) { + e.preventDefault(); + setError(null); + setSavingWorkspace(true); + try { + await updateOrganizationFn({ data: { brandId, name: workspaceName } }); + await router.invalidate(); + } catch (err) { + setError(err instanceof Error ? err.message : "Failed to update workspace name"); + } finally { + setSavingWorkspace(false); + } + } async function handleInvite(e: React.FormEvent) { e.preventDefault(); @@ -94,7 +117,7 @@ function TeamSettingsPage() {

Team

-

Invite teammates and manage who has access to this brand.

+

Invite teammates and manage who has access to your workspace.

{error && ( @@ -103,6 +126,25 @@ function TeamSettingsPage() { )} +
+

Workspace

+
+
+ + setWorkspaceName(e.target.value)} + required + className="w-64" + /> +
+ +
+
+
diff --git a/apps/web/src/routes/_authed/app/$brand/settings/prompts.tsx b/apps/web/src/routes/_authed/app/$brand/settings/prompts.tsx index ab2d90b9..788d39be 100644 --- a/apps/web/src/routes/_authed/app/$brand/settings/prompts.tsx +++ b/apps/web/src/routes/_authed/app/$brand/settings/prompts.tsx @@ -7,7 +7,7 @@ import { createFileRoute } from "@tanstack/react-router"; import { getAppName, getBrandName, buildTitle } from "@/lib/route-head"; import { createServerFn } from "@tanstack/react-start"; import { z } from "zod"; -import { requireAuthSession, requireOrgAccess } from "@/lib/auth/helpers"; +import { requireAuthSession, requireBrandAccess } from "@/lib/auth/helpers"; import { db } from "@workspace/lib/db/db"; import { prompts } from "@workspace/lib/db/schema"; import { eq, desc } from "drizzle-orm"; @@ -18,7 +18,7 @@ const getPromptsForEditing = createServerFn({ method: "GET" }) .validator(z.object({ brandId: z.string() })) .handler(async ({ data }) => { const session = await requireAuthSession(); - await requireOrgAccess(session.user.id, data.brandId); + await requireBrandAccess(session.user.id, data.brandId); // Fetch all prompts (including disabled) for editing const brandPrompts = await db diff --git a/apps/web/src/routes/_authed/app/index.tsx b/apps/web/src/routes/_authed/app/index.tsx index 46838c4c..42986be3 100644 --- a/apps/web/src/routes/_authed/app/index.tsx +++ b/apps/web/src/routes/_authed/app/index.tsx @@ -1,11 +1,13 @@ /** * /app - Brand switcher page * - * In single-org mode (local/demo): redirects to the default org - * In multi-org mode (whitelabel): shows brand switcher + * Lists every brand the user's organization(s) own. Most modes have exactly + * one org, but whitelabel users can belong to several Auth0-synced orgs, so + * this is a brand list scoped across all of the user's orgs, not a 1:1 org + * list. */ -import { createFileRoute, Link, redirect } from "@tanstack/react-router"; +import { createFileRoute, Link } from "@tanstack/react-router"; import { createServerFn } from "@tanstack/react-start"; import { Button } from "@workspace/ui/components/button"; import { Skeleton } from "@workspace/ui/components/skeleton"; @@ -13,11 +15,13 @@ import { syncAuth0UserById } from "@workspace/whitelabel/auth-hooks"; import FullPageCard from "@/components/full-page-card"; import { listUserOrganizations, requireAuthSession } from "@/lib/auth/helpers"; import { getDeployment } from "@/lib/config/server"; +import { db } from "@workspace/lib/db/db"; +import { brands } from "@workspace/lib/db/schema"; +import { inArray } from "drizzle-orm"; -const getOrganizations = createServerFn({ method: "GET" }).handler( +const getBrandSwitcherData = createServerFn({ method: "GET" }).handler( async (): Promise<{ - organizations: { id: string; name: string }[]; - supportsMultiOrg: boolean; + brands: { id: string; name: string }[]; canCreateBrands: boolean; }> => { const session = await requireAuthSession(); @@ -32,10 +36,18 @@ const getOrganizations = createServerFn({ method: "GET" }).handler( } } - const organizations = await listUserOrganizations(session.user.id); + const orgs = await listUserOrganizations(session.user.id); + const orgIds = orgs.map((o) => o.id); + + const scopedBrands = + orgIds.length === 0 + ? [] + : await db.query.brands.findMany({ + where: inArray(brands.organizationId, orgIds), + }); + return { - organizations, - supportsMultiOrg: deployment.features.supportsMultiOrg, + brands: scopedBrands.map((brand) => ({ id: brand.id, name: brand.name })), canCreateBrands: deployment.features.canCreateBrands, }; }, @@ -55,30 +67,21 @@ function OrgSwitcherSkeleton() { export const Route = createFileRoute("/_authed/app/")({ pendingComponent: OrgSwitcherSkeleton, - loader: async () => { - const result = await getOrganizations(); - - // Single-org mode: redirect to the user's one org (created on signup). - if (!result.supportsMultiOrg && result.organizations.length > 0) { - throw redirect({ to: "/app/$brand", params: { brand: result.organizations[0].id } }); - } - - return result; - }, + loader: async () => getBrandSwitcherData(), component: BrandSwitcherPage, }); function BrandSwitcherPage() { - const { organizations, canCreateBrands } = Route.useLoaderData(); + const { brands: brandList, canCreateBrands } = Route.useLoaderData(); return (
- {organizations.length > 0 ? ( - organizations.map((org: { id: string; name: string }) => ( - )) diff --git a/apps/web/src/routes/_authed/app/new.tsx b/apps/web/src/routes/_authed/app/new.tsx index ef47d53c..c5baecb2 100644 --- a/apps/web/src/routes/_authed/app/new.tsx +++ b/apps/web/src/routes/_authed/app/new.tsx @@ -1,10 +1,10 @@ /** - * /app/new - Create a new brand (local mode only). + * /app/new - Create a new brand. * - * Provisions a new organization + admin membership for the current user - * and seeds the brand row with the supplied name + website. Whitelabel and - * demo are blocked at both the loader (redirect to /app) and the server - * function (canCreateBrands policy). + * Attaches a new brand to the current user's existing organization and seeds + * the brand row with the supplied name + website. Gated by the + * canCreateBrands deployment feature (local, cloud) at both the loader + * (redirect to /app) and the server function. */ import { useState } from "react"; import { createFileRoute, redirect, useNavigate, useRouter } from "@tanstack/react-router"; @@ -14,7 +14,7 @@ import { Input } from "@workspace/ui/components/input"; import { Label } from "@workspace/ui/components/label"; import FullPageCard from "@/components/full-page-card"; import { trackEvent } from "@/lib/posthog"; -import { createBrandWithOrgFn } from "@/server/brands"; +import { createBrandInOrgFn } from "@/server/brands"; import { getDeployment } from "@/lib/config/server"; const getCanCreateBrands = createServerFn({ method: "GET" }).handler(async () => { @@ -46,7 +46,7 @@ function NewBrandPage() { const brandName = (formData.get("brandName") as string)?.trim() ?? ""; const website = (formData.get("website") as string)?.trim() ?? ""; - const { brandId } = await createBrandWithOrgFn({ + const { brandId } = await createBrandInOrgFn({ data: { brandName, website }, }); trackEvent("brand_created", { has_website: Boolean(website) }); diff --git a/apps/web/src/server/analysis.ts b/apps/web/src/server/analysis.ts index 3663de28..e846fded 100644 --- a/apps/web/src/server/analysis.ts +++ b/apps/web/src/server/analysis.ts @@ -15,7 +15,7 @@ import { db } from "@workspace/lib/db/db"; import { brands } from "@workspace/lib/db/schema"; import { eq } from "drizzle-orm"; import { z } from "zod"; -import { requireAuthSession, requireOrgAccess } from "@/lib/auth/helpers"; +import { requireAuthSession, requireBrandAccess } from "@/lib/auth/helpers"; import { generateDateRange, type LookbackPeriod } from "@/lib/chart-utils"; import { getBrandMentionTotals, @@ -74,7 +74,7 @@ export const getShareOfVoiceFn = createServerFn({ method: "GET" }) ) .handler(async ({ data }): Promise => { const session = await requireAuthSession(); - await requireOrgAccess(session.user.id, data.brandId); + await requireBrandAccess(session.user.id, data.brandId); const { timezone, fromDateStr, toDateStr } = resolveRange(data.lookback as LookbackPeriod, data.timezone); diff --git a/apps/web/src/server/brands.ts b/apps/web/src/server/brands.ts index 35ba0f52..2552f869 100644 --- a/apps/web/src/server/brands.ts +++ b/apps/web/src/server/brands.ts @@ -4,12 +4,12 @@ */ import { createServerFn } from "@tanstack/react-start"; import { z } from "zod"; -import { requireAuthSession, requireOrgAccess, listUserOrganizations } from "@/lib/auth/helpers"; +import { requireAuthSession, requireOrgAccess, requireBrandAccess, listUserOrganizations } from "@/lib/auth/helpers"; import { evaluateRequireCanCreateBrands } from "@/lib/auth/policies"; import { getDeployment } from "@/lib/config/server"; import { db } from "@workspace/lib/db/db"; import { brands, prompts, competitors, type BrandWithPrompts, type Brand } from "@workspace/lib/db/schema"; -import { provisionAdditionalLocalOrg } from "@workspace/lib/db/provisioning"; +import { findUniqueBrandId, slugify } from "@workspace/lib/db/provisioning"; import { eq, and, count, sql, inArray } from "drizzle-orm"; import { MAX_COMPETITORS } from "@workspace/lib/constants"; import { cleanAndValidateDomain } from "@/lib/domain-categories"; @@ -135,7 +135,7 @@ export const getBrand = createServerFn({ method: "GET" }) .validator(z.object({ brandId: z.string() })) .handler(async ({ data }) => { const session = await requireAuthSession(); - await requireOrgAccess(session.user.id, data.brandId); + await requireBrandAccess(session.user.id, data.brandId); const brand = await getBrandWithPromptsFromDb(data.brandId); if (!brand) { @@ -196,12 +196,12 @@ export const createBrandFn = createServerFn({ method: "POST" }) }); /** - * Create a new organization + admin membership + brand in one shot for the - * current user. Used by the local-mode multi-brand "create new brand" flow on - * the brand switcher. Gated by the canCreateBrands deployment feature so - * whitelabel (orgs come from Auth0) and demo (read-only) reject it. + * Attach a new brand to the current user's existing organization, with a + * fresh id decoupled from the org id. Used by the multi-brand "create new + * brand" flow on the brand switcher. Gated by the canCreateBrands deployment + * feature so whitelabel (orgs come from Auth0) and demo (read-only) reject it. */ -export const createBrandWithOrgFn = createServerFn({ method: "POST" }) +export const createBrandInOrgFn = createServerFn({ method: "POST" }) .validator( z.object({ brandName: z.string().min(1).max(100), @@ -226,15 +226,22 @@ export const createBrandWithOrgFn = createServerFn({ method: "POST" }) throw new Error("Brand name must be a non-empty string"); } - const { orgId } = await provisionAdditionalLocalOrg({ - userId: session.user.id, - name: trimmedName, - }); + // Attach to the caller's active org (resolved in customSession); fall + // back to their first membership if the active org isn't one they belong + // to. Supports users in more than one org (e.g. an accepted team invite). + const orgs = await listUserOrganizations(session.user.id); + if (orgs.length === 0) { + throw new Error("No organization for the current user"); + } + const activeOrgId = + (session.session as { activeOrganizationId?: string | null } | undefined)?.activeOrganizationId ?? null; + const orgId = activeOrgId && orgs.some((o) => o.id === activeOrgId) ? activeOrgId : orgs[0].id; + const brandId = await findUniqueBrandId(slugify(trimmedName)); const defaultDomains = getDefaultBrandDomains(); await db.insert(brands).values({ - id: orgId, + id: brandId, organizationId: orgId, name: trimmedName, website: urlValidation.formattedUrl, @@ -242,7 +249,7 @@ export const createBrandWithOrgFn = createServerFn({ method: "POST" }) ...(defaultDomains.length > 0 && { additionalDomains: defaultDomains }), }); - return { brandId: orgId }; + return { brandId }; }); /** @@ -260,7 +267,7 @@ export const updateBrandFn = createServerFn({ method: "POST" }) ) .handler(async ({ data }) => { const session = await requireAuthSession(); - await requireOrgAccess(session.user.id, data.brandId); + await requireBrandAccess(session.user.id, data.brandId); const normalized = normalizeBrandUpdate({ name: data.name, @@ -293,7 +300,7 @@ export const getCompetitors = createServerFn({ method: "GET" }) .validator(z.object({ brandId: z.string() })) .handler(async ({ data }) => { const session = await requireAuthSession(); - await requireOrgAccess(session.user.id, data.brandId); + await requireBrandAccess(session.user.id, data.brandId); return db.query.competitors.findMany({ where: eq(competitors.brandId, data.brandId), @@ -318,7 +325,7 @@ export const updateCompetitors = createServerFn({ method: "POST" }) ) .handler(async ({ data }) => { const session = await requireAuthSession(); - await requireOrgAccess(session.user.id, data.brandId); + await requireBrandAccess(session.user.id, data.brandId); // Validate and clean domains const cleanedCompetitors = data.competitors.map((c) => { @@ -366,7 +373,7 @@ export const addDomainToBrandFn = createServerFn({ method: "POST" }) ) .handler(async ({ data }) => { const session = await requireAuthSession(); - await requireOrgAccess(session.user.id, data.brandId); + await requireBrandAccess(session.user.id, data.brandId); const domain = cleanAndValidateDomain(data.domain); if (!domain) throw new Error(`Invalid domain: ${data.domain}`); @@ -407,7 +414,7 @@ export const addDomainToCompetitorFn = createServerFn({ method: "POST" }) ) .handler(async ({ data }) => { const session = await requireAuthSession(); - await requireOrgAccess(session.user.id, data.brandId); + await requireBrandAccess(session.user.id, data.brandId); const existing = await db.query.competitors.findFirst({ where: and(eq(competitors.id, data.competitorId), eq(competitors.brandId, data.brandId)), @@ -441,7 +448,7 @@ export const createCompetitorFromDomainFn = createServerFn({ method: "POST" }) ) .handler(async ({ data }) => { const session = await requireAuthSession(); - await requireOrgAccess(session.user.id, data.brandId); + await requireBrandAccess(session.user.id, data.brandId); const domain = cleanAndValidateDomain(data.domain); if (!domain) throw new Error(`Invalid domain: ${data.domain}`); diff --git a/apps/web/src/server/citations.ts b/apps/web/src/server/citations.ts index 2b40f656..40b1d053 100644 --- a/apps/web/src/server/citations.ts +++ b/apps/web/src/server/citations.ts @@ -4,7 +4,7 @@ */ import { createServerFn } from "@tanstack/react-start"; import { z } from "zod"; -import { requireAuthSession, requireOrgAccess } from "@/lib/auth/helpers"; +import { requireAuthSession, requireBrandAccess } from "@/lib/auth/helpers"; import { db } from "@workspace/lib/db/db"; import { brands, competitors, prompts, SYSTEM_TAGS } from "@workspace/lib/db/schema"; import { eq, and } from "drizzle-orm"; @@ -41,7 +41,7 @@ export const getCitationsFn = createServerFn({ method: "GET" }) ) .handler(async ({ data }) => { const session = await requireAuthSession(); - await requireOrgAccess(session.user.id, data.brandId); + await requireBrandAccess(session.user.id, data.brandId); // Window: `data.days` calendar days ending today (inclusive), plus the // contiguous equal-length previous window — all UTC (server-TZ independent). diff --git a/apps/web/src/server/dashboard.ts b/apps/web/src/server/dashboard.ts index 1f4690f4..45ff063c 100644 --- a/apps/web/src/server/dashboard.ts +++ b/apps/web/src/server/dashboard.ts @@ -4,7 +4,7 @@ */ import { createServerFn } from "@tanstack/react-start"; import { z } from "zod"; -import { requireAuthSession, requireOrgAccess } from "@/lib/auth/helpers"; +import { requireAuthSession, requireBrandAccess } from "@/lib/auth/helpers"; import { db } from "@workspace/lib/db/db"; import { prompts, competitors, brands } from "@workspace/lib/db/schema"; import { eq, and, count } from "drizzle-orm"; @@ -56,7 +56,7 @@ export const getDashboardSummaryFn = createServerFn({ method: "GET" }) ) .handler(async ({ data }): Promise => { const session = await requireAuthSession(); - await requireOrgAccess(session.user.id, data.brandId); + await requireBrandAccess(session.user.id, data.brandId); const lookbackParam = data.lookback as LookbackPeriod; const timezone = resolveTimezone(data.timezone); diff --git a/apps/web/src/server/onboarding.ts b/apps/web/src/server/onboarding.ts index 08074dd8..4f26b989 100644 --- a/apps/web/src/server/onboarding.ts +++ b/apps/web/src/server/onboarding.ts @@ -13,7 +13,7 @@ */ import { createServerFn } from "@tanstack/react-start"; import { z } from "zod"; -import { requireAuthSession, requireOrgAccess } from "@/lib/auth/helpers"; +import { requireAuthSession, requireBrandAccess } from "@/lib/auth/helpers"; import { cancelAnalyzeBrand, enqueueAnalyzeBrand, @@ -30,9 +30,9 @@ import { saveWizardOnboarding, wizardOnboardingInputSchema } from "@/server/onbo * gets a 504 even though the work succeeds). The worker processes the job; the * client polls `getAnalyzeBrandStatusFn` (by brand) for the result. * - * Scoped to the brand (== org): the caller must have access to the brand both - * to start an analysis and to read it back, so a job's output never leaks - * outside the org that requested it. + * Scoped to the brand's owning org: the caller must have access to the brand + * both to start an analysis and to read it back, so a job's output never + * leaks outside the org that requested it. */ export const startAnalyzeBrandFn = createServerFn({ method: "POST" }) .validator( @@ -44,7 +44,7 @@ export const startAnalyzeBrandFn = createServerFn({ method: "POST" }) ) .handler(async ({ data }) => { const session = await requireAuthSession(); - await requireOrgAccess(session.user.id, data.brandId); + await requireBrandAccess(session.user.id, data.brandId); await enqueueAnalyzeBrand(data); return { ok: true }; }); @@ -60,7 +60,7 @@ export const getAnalyzeBrandStatusFn = createServerFn({ method: "POST" }) .validator(z.object({ brandId: z.string().min(1) })) .handler(async ({ data }): Promise => { const session = await requireAuthSession(); - await requireOrgAccess(session.user.id, data.brandId); + await requireBrandAccess(session.user.id, data.brandId); return getAnalyzeBrandStatus(data.brandId); }); @@ -69,7 +69,7 @@ export const cancelAnalyzeBrandFn = createServerFn({ method: "POST" }) .validator(z.object({ brandId: z.string().min(1) })) .handler(async ({ data }) => { const session = await requireAuthSession(); - await requireOrgAccess(session.user.id, data.brandId); + await requireBrandAccess(session.user.id, data.brandId); await cancelAnalyzeBrand(data.brandId); return { ok: true }; }); @@ -82,6 +82,6 @@ export const updateOnboardedBrandFn = createServerFn({ method: "POST" }) .validator(wizardOnboardingInputSchema) .handler(async ({ data }) => { const session = await requireAuthSession(); - await requireOrgAccess(session.user.id, data.brandId); + await requireBrandAccess(session.user.id, data.brandId); return saveWizardOnboarding(data); }); diff --git a/apps/web/src/server/opportunities.ts b/apps/web/src/server/opportunities.ts index edb25603..d0b8b2b5 100644 --- a/apps/web/src/server/opportunities.ts +++ b/apps/web/src/server/opportunities.ts @@ -22,7 +22,7 @@ import { brandOpportunities, brands, competitors } from "@workspace/lib/db/schem import { runStructuredCompletionPrompt } from "@workspace/lib/onboarding"; import { desc, eq } from "drizzle-orm"; import { z } from "zod"; -import { requireAuthSession, requireOrgAccess } from "@/lib/auth/helpers"; +import { requireAuthSession, requireBrandAccess } from "@/lib/auth/helpers"; import { extractDomain } from "@/lib/domain-categories"; import { categorizeDomain } from "@/lib/domain-categories.server"; import { @@ -473,7 +473,7 @@ export const getOpportunitiesFn = createServerFn({ method: "GET" }) .validator(z.object({ brandId: z.string(), timezone: z.string().default("UTC") })) .handler(async ({ data }): Promise => { const session = await requireAuthSession(); - await requireOrgAccess(session.user.id, data.brandId); + await requireBrandAccess(session.user.id, data.brandId); // Serve the most recent stored report while it's fresh. Every generation is // kept (append-only); we regenerate only when the latest is stale. diff --git a/apps/web/src/server/prompts.ts b/apps/web/src/server/prompts.ts index b045cad4..3eb7fea4 100644 --- a/apps/web/src/server/prompts.ts +++ b/apps/web/src/server/prompts.ts @@ -4,7 +4,7 @@ */ import { createServerFn } from "@tanstack/react-start"; import { z } from "zod"; -import { requireAuthSession, requireOrgAccess } from "@/lib/auth/helpers"; +import { requireAuthSession, requireBrandAccess } from "@/lib/auth/helpers"; import { db } from "@workspace/lib/db/db"; import { prompts, promptRuns, brands, competitors, SYSTEM_TAGS } from "@workspace/lib/db/schema"; import { eq, and, desc, gte, count, sql } from "drizzle-orm"; @@ -42,7 +42,7 @@ export const getPromptMetadataFn = createServerFn({ method: "GET" }) .validator(z.object({ brandId: z.string(), promptId: z.string() })) .handler(async ({ data }) => { const session = await requireAuthSession(); - await requireOrgAccess(session.user.id, data.brandId); + await requireBrandAccess(session.user.id, data.brandId); const prompt = await db.query.prompts.findFirst({ where: and(eq(prompts.id, data.promptId), eq(prompts.brandId, data.brandId)), @@ -98,7 +98,7 @@ export const getPromptsSummaryFn = createServerFn({ method: "GET" }) ) .handler(async ({ data }) => { const session = await requireAuthSession(); - await requireOrgAccess(session.user.id, data.brandId); + await requireBrandAccess(session.user.id, data.brandId); // Get all prompts for the brand from DB const allPrompts = await db @@ -255,7 +255,7 @@ export const getPromptStatsFn = createServerFn({ method: "GET" }) .limit(1); if (prompt.length === 0) throw new Error("Prompt not found"); - await requireOrgAccess(session.user.id, prompt[0].brandId); + await requireBrandAccess(session.user.id, prompt[0].brandId); const fromDate = new Date(); fromDate.setDate(fromDate.getDate() - data.days); @@ -476,7 +476,7 @@ export const getPromptRunsFn = createServerFn({ method: "GET" }) if (!prompt) throw new Error("Prompt not found"); const session = await requireAuthSession(); - await requireOrgAccess(session.user.id, prompt.brandId); + await requireBrandAccess(session.user.id, prompt.brandId); const fromDate = new Date(); fromDate.setDate(fromDate.getDate() - data.days); @@ -527,7 +527,7 @@ export const updatePromptsFn = createServerFn({ method: "POST" }) ) .handler(async ({ data }) => { const session = await requireAuthSession(); - await requireOrgAccess(session.user.id, data.brandId); + await requireBrandAccess(session.user.id, data.brandId); const brand = await db.query.brands.findFirst({ where: eq(brands.id, data.brandId), @@ -599,7 +599,7 @@ export const getPromptChartDataFn = createServerFn({ method: "GET" }) ) .handler(async ({ data }) => { const session = await requireAuthSession(); - await requireOrgAccess(session.user.id, data.brandId); + await requireBrandAccess(session.user.id, data.brandId); const timezone = data.timezone || Intl.DateTimeFormat().resolvedOptions().timeZone; const lookbackParam = (data.lookback || "1m") as LookbackPeriod; @@ -773,7 +773,7 @@ export const getPromptWebQueryFn = createServerFn({ method: "GET" }) ) .handler(async ({ data }) => { const session = await requireAuthSession(); - await requireOrgAccess(session.user.id, data.brandId); + await requireBrandAccess(session.user.id, data.brandId); const timezone = data.timezone || "UTC"; const now = new Date(); diff --git a/apps/web/src/server/query-fanout.ts b/apps/web/src/server/query-fanout.ts index d7fdc2af..80fe3941 100644 --- a/apps/web/src/server/query-fanout.ts +++ b/apps/web/src/server/query-fanout.ts @@ -13,7 +13,7 @@ import { db } from "@workspace/lib/db/db"; import { brands } from "@workspace/lib/db/schema"; import { eq } from "drizzle-orm"; import { z } from "zod"; -import { requireAuthSession, requireOrgAccess } from "@/lib/auth/helpers"; +import { requireAuthSession, requireBrandAccess } from "@/lib/auth/helpers"; import type { LookbackPeriod } from "@/lib/chart-utils"; import { LOOKBACK, resolveRange } from "@/server/analysis"; import { getFanoutBreakdown, getFanoutModelTotals, getFanoutPromptTotals } from "@/lib/postgres-read"; @@ -60,7 +60,7 @@ export const getQueryFanoutFn = createServerFn({ method: "GET" }) ) .handler(async ({ data }): Promise => { const session = await requireAuthSession(); - await requireOrgAccess(session.user.id, data.brandId); + await requireBrandAccess(session.user.id, data.brandId); const { timezone, fromDateStr, toDateStr } = resolveRange(data.lookback as LookbackPeriod, data.timezone); const model = data.model; diff --git a/apps/web/src/server/team.ts b/apps/web/src/server/team.ts index c6e8e57c..e950ea02 100644 --- a/apps/web/src/server/team.ts +++ b/apps/web/src/server/team.ts @@ -9,10 +9,10 @@ import { createServerFn } from "@tanstack/react-start"; import { getRequestHeaders } from "@tanstack/react-start/server"; import { db } from "@workspace/lib/db/db"; -import { invitation, member, user } from "@workspace/lib/db/schema"; +import { invitation, member, organization, user } from "@workspace/lib/db/schema"; import { and, eq } from "drizzle-orm"; import { z } from "zod"; -import { requireAuthSession, requireOrgAccess } from "@/lib/auth/helpers"; +import { requireAuthSession, requireBrandAccess, getBrandOrganizationId } from "@/lib/auth/helpers"; import { auth } from "@/lib/auth/server"; import { getDeployment } from "@/lib/config/server"; @@ -26,17 +26,25 @@ export type TeamData = { members: { id: string; role: string; userId: string; name: string; email: string; createdAt: Date }[]; invitations: { id: string; email: string; role: string | null; expiresAt: Date }[]; currentUserId: string; + organization: { id: string; name: string }; }; export const listTeamFn = createServerFn({ method: "GET" }) .validator(z.object({ brandId: z.string() })) // The explicit return type breaks the type-inference cycle between this // fn and route loaders that both consume it and redirect to typed routes - // (same pattern as getOrganizations in routes/_authed/app/index.tsx). + // (same pattern as getBrandSwitcherData in routes/_authed/app/index.tsx). .handler(async ({ data }): Promise => { requireTeamInvites(); const session = await requireAuthSession(); - await requireOrgAccess(session.user.id, data.brandId); + await requireBrandAccess(session.user.id, data.brandId); + const orgId = await getBrandOrganizationId(data.brandId); + if (!orgId) throw new Error("Brand not found"); + + const org = await db.query.organization.findFirst({ + where: eq(organization.id, orgId), + }); + if (!org) throw new Error("Organization not found"); const members = await db .select({ @@ -49,7 +57,7 @@ export const listTeamFn = createServerFn({ method: "GET" }) }) .from(member) .innerJoin(user, eq(member.userId, user.id)) - .where(eq(member.organizationId, data.brandId)); + .where(eq(member.organizationId, orgId)); const invitations = await db .select({ @@ -59,9 +67,35 @@ export const listTeamFn = createServerFn({ method: "GET" }) expiresAt: invitation.expiresAt, }) .from(invitation) - .where(and(eq(invitation.organizationId, data.brandId), eq(invitation.status, "pending"))); + .where(and(eq(invitation.organizationId, orgId), eq(invitation.status, "pending"))); + + return { + members, + invitations, + currentUserId: session.user.id, + organization: { id: org.id, name: org.name }, + }; + }); - return { members, invitations, currentUserId: session.user.id }; +export const updateOrganizationFn = createServerFn({ method: "POST" }) + .validator(z.object({ brandId: z.string(), name: z.string().min(1).max(100) })) + .handler(async ({ data }) => { + requireTeamInvites(); + const session = await requireAuthSession(); + await requireBrandAccess(session.user.id, data.brandId); + const orgId = await getBrandOrganizationId(data.brandId); + if (!orgId) throw new Error("Brand not found"); + + // Org rename is an admin action. + const [m] = await db + .select({ role: member.role }) + .from(member) + .where(and(eq(member.userId, session.user.id), eq(member.organizationId, orgId))) + .limit(1); + if (m?.role !== "admin") throw new Error("Only admins can rename the workspace"); + + await db.update(organization).set({ name: data.name.trim() }).where(eq(organization.id, orgId)); + return { success: true }; }); export const inviteTeamMemberFn = createServerFn({ method: "POST" }) @@ -75,10 +109,12 @@ export const inviteTeamMemberFn = createServerFn({ method: "POST" }) .handler(async ({ data }) => { requireTeamInvites(); const session = await requireAuthSession(); - await requireOrgAccess(session.user.id, data.brandId); + await requireBrandAccess(session.user.id, data.brandId); + const orgId = await getBrandOrganizationId(data.brandId); + if (!orgId) throw new Error("Brand not found"); await auth.api.createInvitation({ - body: { email: data.email, role: data.role, organizationId: data.brandId }, + body: { email: data.email, role: data.role, organizationId: orgId }, headers: getRequestHeaders(), }); @@ -90,7 +126,9 @@ export const cancelInvitationFn = createServerFn({ method: "POST" }) .handler(async ({ data }) => { requireTeamInvites(); const session = await requireAuthSession(); - await requireOrgAccess(session.user.id, data.brandId); + await requireBrandAccess(session.user.id, data.brandId); + const orgId = await getBrandOrganizationId(data.brandId); + if (!orgId) throw new Error("Brand not found"); await auth.api.cancelInvitation({ body: { invitationId: data.invitationId }, @@ -105,19 +143,21 @@ export const removeTeamMemberFn = createServerFn({ method: "POST" }) .handler(async ({ data }) => { requireTeamInvites(); const session = await requireAuthSession(); - await requireOrgAccess(session.user.id, data.brandId); + await requireBrandAccess(session.user.id, data.brandId); + const orgId = await getBrandOrganizationId(data.brandId); + if (!orgId) throw new Error("Brand not found"); const [row] = await db .select({ userId: member.userId }) .from(member) - .where(and(eq(member.id, data.memberId), eq(member.organizationId, data.brandId))) + .where(and(eq(member.id, data.memberId), eq(member.organizationId, orgId))) .limit(1); if (row?.userId === session.user.id) { throw new Error("You cannot remove yourself from the team"); } await auth.api.removeMember({ - body: { memberIdOrEmail: data.memberId, organizationId: data.brandId }, + body: { memberIdOrEmail: data.memberId, organizationId: orgId }, headers: getRequestHeaders(), }); diff --git a/apps/web/src/server/visibility.ts b/apps/web/src/server/visibility.ts index 7c31eb6c..79681a4f 100644 --- a/apps/web/src/server/visibility.ts +++ b/apps/web/src/server/visibility.ts @@ -6,7 +6,7 @@ */ import { createServerFn } from "@tanstack/react-start"; import { z } from "zod"; -import { requireAuthSession, requireOrgAccess } from "@/lib/auth/helpers"; +import { requireAuthSession, requireBrandAccess } from "@/lib/auth/helpers"; import { db } from "@workspace/lib/db/db"; import { brands, competitors } from "@workspace/lib/db/schema"; import { eq } from "drizzle-orm"; @@ -72,7 +72,7 @@ export const getBatchChartDataFn = createServerFn({ method: "GET" }) ) .handler(async ({ data }): Promise => { const session = await requireAuthSession(); - await requireOrgAccess(session.user.id, data.brandId); + await requireBrandAccess(session.user.id, data.brandId); const timezone = resolveTimezone(data.timezone); const lookbackParam = data.lookback as LookbackPeriod; @@ -158,7 +158,7 @@ export const getFilteredVisibilityFn = createServerFn({ method: "GET" }) const session = await requireAuthSession(); const lookbackParam = data.lookback as LookbackPeriod; - await requireOrgAccess(session.user.id, data.brandId); + await requireBrandAccess(session.user.id, data.brandId); // Resolve the in-scope prompts server-side from the filter criteria so // the client never ships the full prompt-id list (issue #68). diff --git a/packages/lib/src/auth/server.ts b/packages/lib/src/auth/server.ts index 894c4b75..810feff1 100644 --- a/packages/lib/src/auth/server.ts +++ b/packages/lib/src/auth/server.ts @@ -11,6 +11,7 @@ import { type BetterAuthOptions, betterAuth } from "better-auth"; import { drizzleAdapter } from "better-auth/adapters/drizzle"; import { admin, customSession, organization } from "better-auth/plugins"; import { tanstackStartCookies } from "better-auth/tanstack-start"; +import { eq } from "drizzle-orm"; import { db } from "../db/db"; import * as schema from "../db/schema"; import { ac, adminRole, userRole } from "./permissions"; @@ -115,13 +116,23 @@ export function createAuth(options?: CreateAuthOptions) { sso(options?.sso), customSession(async ({ user, session }) => { const u = user as Record; + const s = session as Record; + let activeOrganizationId = (s.activeOrganizationId as string | null | undefined) ?? null; + if (!activeOrganizationId) { + const [m] = await db + .select({ orgId: schema.member.organizationId }) + .from(schema.member) + .where(eq(schema.member.userId, user.id)) + .limit(1); + activeOrganizationId = m?.orgId ?? null; + } return { user: { ...user, role: (u.role as string) ?? "user", hasReportGeneratorAccess: u.hasReportGeneratorAccess === true, }, - session, + session: { ...session, activeOrganizationId }, }; }), tanstackStartCookies(), diff --git a/packages/lib/src/db/provisioning.test.ts b/packages/lib/src/db/provisioning.test.ts index 826059a5..2935579d 100644 --- a/packages/lib/src/db/provisioning.test.ts +++ b/packages/lib/src/db/provisioning.test.ts @@ -1,27 +1,34 @@ import { describe, expect, it } from "vitest"; -import { slugifyOrgName } from "./provisioning"; +import { slugify } from "./provisioning"; -describe("slugifyOrgName", () => { +describe("slugify", () => { it("lowercases", () => { - expect(slugifyOrgName("Acme")).toBe("acme"); + expect(slugify("Acme")).toBe("acme"); }); it("replaces runs of non-alphanumerics with single hyphens", () => { - expect(slugifyOrgName("Acme Co!")).toBe("acme-co"); - expect(slugifyOrgName("Foo Bar")).toBe("foo-bar"); + expect(slugify("Acme Co!")).toBe("acme-co"); + expect(slugify("Foo Bar")).toBe("foo-bar"); }); it("trims leading and trailing hyphens", () => { - expect(slugifyOrgName(" hello world ")).toBe("hello-world"); - expect(slugifyOrgName("!!!brand!!!")).toBe("brand"); + expect(slugify(" hello world ")).toBe("hello-world"); + expect(slugify("!!!brand!!!")).toBe("brand"); }); it("falls back to 'brand' for empty / non-alphanumeric input", () => { - expect(slugifyOrgName("")).toBe("brand"); - expect(slugifyOrgName("!!!")).toBe("brand"); + expect(slugify("")).toBe("brand"); + expect(slugify("!!!")).toBe("brand"); }); it("preserves digits", () => { - expect(slugifyOrgName("Acme 2")).toBe("acme-2"); + expect(slugify("Acme 2")).toBe("acme-2"); + }); + + it("does not itself reserve route-colliding slugs (that's findUniqueBrandId's job)", () => { + // "new" collides with /app/new, but slugify is a pure string transform — + // only findUniqueBrandId (which needs a database) applies the reserved-slug + // suffix rule. + expect(slugify("new")).toBe("new"); }); }); diff --git a/packages/lib/src/db/provisioning.ts b/packages/lib/src/db/provisioning.ts index 3edfb842..811c968b 100644 --- a/packages/lib/src/db/provisioning.ts +++ b/packages/lib/src/db/provisioning.ts @@ -14,9 +14,9 @@ * call is a bug and should fail at the database layer rather than * silently rewriting rows. */ -import { count, eq, or } from "drizzle-orm"; +import { count, eq } from "drizzle-orm"; import { db } from "./db"; -import { member, organization, user } from "./schema"; +import { brands, member, organization, user } from "./schema"; /** * Number of users in the database. @@ -70,14 +70,15 @@ export async function provisionLocalOrg(input: { userId: string }): Promise<{ or } /** - * Slugify a brand name into the URL/id form used for new local-mode orgs. - * Exported so the slug rules can be unit-tested directly without a database. + * Slugify a brand or org name into the URL/id form used for brand ids and + * org ids/slugs. Exported so the slug rules can be unit-tested directly + * without a database. * * Note: leading/trailing hyphens are trimmed via index walks instead of an * `^-+|-+$` alternation regex — the alternation form trips ReDoS scanners * on inputs like `"---"` even though the JS engine handles it linearly. */ -export function slugifyOrgName(name: string): string { +export function slugify(name: string): string { const cleaned = name.toLowerCase().replace(/[^a-z0-9]+/g, "-"); let start = 0; while (start < cleaned.length && cleaned[start] === "-") start++; @@ -94,24 +95,47 @@ export function slugifyOrgName(name: string): string { */ const RESERVED_ORG_SLUGS = new Set(["new"]); -async function findUniqueOrgId(baseSlug: string): Promise { +/** + * Find a brand id that doesn't collide with an existing brand row or a + * reserved route slug, appending -2, -3, … on collision. Brand ids are + * globally unique — they appear directly in `/app/$brand` URLs — and, unlike + * the legacy org-per-brand convention, are independent of any organization id. + */ +export async function findUniqueBrandId(baseSlug: string): Promise { let candidate = baseSlug; let suffix = 2; for (;;) { const isReserved = RESERVED_ORG_SLUGS.has(candidate); const conflict = isReserved ? [{ id: candidate }] - : await db - .select({ id: organization.id }) - .from(organization) - .where(or(eq(organization.id, candidate), eq(organization.slug, candidate))) - .limit(1); + : await db.select({ id: brands.id }).from(brands).where(eq(brands.id, candidate)).limit(1); if (conflict.length === 0) return candidate; candidate = `${baseSlug}-${suffix}`; suffix++; } } +/** + * Find an organization slug that doesn't collide with an existing org, + * appending -2, -3, … on collision. Used by `provisionUmbrellaOrg`, where the + * org id itself is a random uuid (decoupled from any brand) but the slug + * still needs to be unique and human-readable. + */ +async function findUniqueOrgSlug(baseSlug: string): Promise { + let candidate = baseSlug; + let suffix = 2; + for (;;) { + const [conflict] = await db + .select({ id: organization.id }) + .from(organization) + .where(eq(organization.slug, candidate)) + .limit(1); + if (!conflict) return candidate; + candidate = `${baseSlug}-${suffix}`; + suffix++; + } +} + /** * Ensure an organization row exists for a brand created outside the normal * signup / Auth0 flows — specifically the admin API (`POST /api/v1/brands`), @@ -131,7 +155,7 @@ export async function ensureOrganization(input: { id: string; name: string }): P .limit(1); if (existing) return; - const baseSlug = slugifyOrgName(input.name); + const baseSlug = slugify(input.name); let slug = baseSlug; for (let suffix = 2; ; suffix++) { const [conflict] = await db @@ -155,29 +179,17 @@ export async function ensureOrganization(input: { id: string; name: string }): P } /** - * Provision an additional organization for an existing local-mode user — used - * by the multi-brand "create new brand" flow. The id is a slug derived from - * `name`, with a numeric suffix on collision, and is reused as the org slug - * so that URLs and the org row stay in sync. - * - * The brand row itself is the caller's responsibility; provisioning only - * handles the auth-level (org + admin membership) bits. + * Create the single customer ("umbrella") org + admin membership for a new + * user. The org id is decoupled from any brand (a random id), so brands can be + * attached later with their own ids. Used by the cloud user.create.after hook. */ -export async function provisionAdditionalLocalOrg(input: { - userId: string; - name: string; -}): Promise<{ orgId: string }> { - const baseSlug = slugifyOrgName(input.name); - const orgId = await findUniqueOrgId(baseSlug); +export async function provisionUmbrellaOrg(input: { userId: string; name: string }): Promise<{ orgId: string }> { + const orgId = crypto.randomUUID(); + const baseSlug = slugify(input.name); + const slug = await findUniqueOrgSlug(baseSlug); await db.transaction(async (tx) => { - await tx.insert(organization).values({ - id: orgId, - name: input.name, - slug: orgId, - createdAt: new Date(), - }); - + await tx.insert(organization).values({ id: orgId, name: input.name, slug, createdAt: new Date() }); await tx.insert(member).values({ id: crypto.randomUUID(), organizationId: orgId,