support creating multiple orgs in saas

This commit is contained in:
miloschwartz
2026-02-17 14:37:46 -08:00
parent d00262dc31
commit b8c3cc751a
16 changed files with 439 additions and 331 deletions

View File

@@ -6,6 +6,7 @@ import { redirect } from "next/navigation";
import { getTranslations } from "next-intl/server";
import { getCachedOrgUser } from "@app/lib/api/getCachedOrgUser";
import { getCachedOrg } from "@app/lib/api/getCachedOrg";
import { build } from "@server/build";
type BillingSettingsProps = {
children: React.ReactNode;
@@ -17,6 +18,9 @@ export default async function BillingSettingsPage({
params
}: BillingSettingsProps) {
const { orgId } = await params;
if (build !== "saas") {
redirect(`/${orgId}/settings`);
}
const user = await verifySession();
@@ -40,6 +44,10 @@ export default async function BillingSettingsPage({
redirect(`/${orgId}`);
}
if (!(org?.org?.isBillingOrg && orgUser?.isOwner)) {
redirect(`/${orgId}`);
}
const t = await getTranslations();
return (

View File

@@ -4,6 +4,8 @@ import { redirect } from "next/navigation";
import { cache } from "react";
import { getTranslations } from "next-intl/server";
import { build } from "@server/build";
import { getCachedOrgUser } from "@app/lib/api/getCachedOrgUser";
import { getCachedOrg } from "@app/lib/api/getCachedOrg";
type LicensesSettingsProps = {
children: React.ReactNode;
@@ -27,6 +29,26 @@ export default async function LicensesSetingsLayoutProps({
redirect(`/`);
}
let orgUser = null;
try {
const res = await getCachedOrgUser(orgId, user.userId);
orgUser = res.data.data;
} catch {
redirect(`/${orgId}`);
}
let org = null;
try {
const res = await getCachedOrg(orgId);
org = res.data.data;
} catch {
redirect(`/${orgId}`);
}
if (!org?.org?.isBillingOrg || !orgUser?.isOwner) {
redirect(`/${orgId}`);
}
const t = await getTranslations();
return (

View File

@@ -77,12 +77,16 @@ export default async function SettingsLayout(props: SettingsLayoutProps) {
}
} catch (e) {}
const primaryOrg = orgs.find((o) => o.orgId === params.orgId)?.isPrimaryOrg;
return (
<UserProvider user={user}>
<Layout
orgId={params.orgId}
orgs={orgs}
navItems={orgNavSections(env)}
navItems={orgNavSections(env, {
isPrimaryOrg: primaryOrg
})}
>
{children}
</Layout>

View File

@@ -31,6 +31,10 @@ export type SidebarNavSection = {
items: SidebarNavItem[];
};
export type OrgNavSectionsOptions = {
isPrimaryOrg?: boolean;
};
// Merged from 'user-management-and-resources' branch
export const orgLangingNavItems: SidebarNavItem[] = [
{
@@ -40,7 +44,10 @@ export const orgLangingNavItems: SidebarNavItem[] = [
}
];
export const orgNavSections = (env?: Env): SidebarNavSection[] => [
export const orgNavSections = (
env?: Env,
options?: OrgNavSectionsOptions
): SidebarNavSection[] => [
{
heading: "sidebarGeneral",
items: [
@@ -214,28 +221,28 @@ export const orgNavSections = (env?: Env): SidebarNavSection[] => [
title: "sidebarSettings",
href: "/{orgId}/settings/general",
icon: <Settings className="size-4 flex-none" />
},
...(build == "saas"
? [
}
]
},
...(build == "saas" && options?.isPrimaryOrg
? [
{
heading: "sidebarBillingAndLicenses",
items: [
{
title: "sidebarBilling",
href: "/{orgId}/settings/billing",
icon: <CreditCard className="size-4 flex-none" />
}
]
: []),
...(build == "saas"
? [
},
{
title: "sidebarEnterpriseLicenses",
href: "/{orgId}/settings/license",
icon: <TicketCheck className="size-4 flex-none" />
}
]
: [])
]
}
}
]
: [])
];
export const adminNavSections = (env?: Env): SidebarNavSection[] => [

View File

@@ -73,7 +73,7 @@ export default async function Page(props: {
if (!orgs.length) {
if (!env.flags.disableUserCreateOrg || user.serverAdmin) {
redirect("/setup");
redirect("/setup?firstOrg");
}
}
@@ -86,6 +86,14 @@ export default async function Page(props: {
targetOrgId = lastOrgCookie;
} else {
let ownedOrg = orgs.find((org) => org.isOwner);
let primaryOrg = orgs.find((org) => org.isPrimaryOrg);
if (!ownedOrg) {
if (primaryOrg) {
ownedOrg = primaryOrg;
} else {
ownedOrg = orgs[0];
}
}
if (!ownedOrg) {
ownedOrg = orgs[0];
}

View File

@@ -4,19 +4,14 @@ import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { toast } from "@app/hooks/useToast";
import { useCallback, useEffect, useState } from "react";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle
} from "@app/components/ui/card";
import { formatAxiosError } from "@app/lib/api";
import { createApiClient } from "@app/lib/api";
import { useEnvContext } from "@app/hooks/useEnvContext";
import { useUserContext } from "@app/hooks/useUserContext";
import { build } from "@server/build";
import { Separator } from "@/components/ui/separator";
import { z } from "zod";
import { useRouter } from "next/navigation";
import { useRouter, useSearchParams } from "next/navigation";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import {
@@ -35,7 +30,7 @@ import {
CollapsibleContent,
CollapsibleTrigger
} from "@app/components/ui/collapsible";
import { ChevronsUpDown } from "lucide-react";
import { ArrowRight, ChevronsUpDown } from "lucide-react";
import { cn } from "@app/lib/cn";
type Step = "org" | "site" | "resources";
@@ -45,6 +40,7 @@ export default function StepperForm() {
const [orgIdTaken, setOrgIdTaken] = useState(false);
const t = useTranslations();
const { env } = useEnvContext();
const { user } = useUserContext();
const [loading, setLoading] = useState(false);
const [isChecked, setIsChecked] = useState(false);
@@ -71,12 +67,27 @@ export default function StepperForm() {
const api = createApiClient(useEnvContext());
const router = useRouter();
const searchParams = useSearchParams();
const isFirstOrg = searchParams.get("firstOrg") != null;
// Fetch default subnet on component mount
useEffect(() => {
fetchDefaultSubnet();
}, []);
// Prefill org name and id when build is saas and firstOrg query param is set
useEffect(() => {
if (build !== "saas" || !user || !isFirstOrg) return;
const orgName = user.email
? `${user.email}'s Organization`
: "My Organization";
const orgId = `org_${user.userId}`;
orgForm.setValue("orgName", orgName);
orgForm.setValue("orgId", orgId);
debouncedCheckOrgIdAvailability(orgId);
}, []);
const fetchDefaultSubnet = async () => {
try {
const res = await api.get(`/pick-org-defaults`);
@@ -161,263 +172,239 @@ export default function StepperForm() {
}
return (
<>
<Card>
<CardHeader>
<CardTitle>{t("setupNewOrg")}</CardTitle>
<CardDescription>{t("setupCreate")}</CardDescription>
</CardHeader>
<CardContent>
<section className="space-y-6">
<div className="flex justify-between mb-2">
<div className="flex flex-col items-center">
<div
className={`w-8 h-8 rounded-full flex items-center justify-center mb-2 ${
currentStep === "org"
? "bg-primary text-primary-foreground"
: "bg-muted text-muted-foreground"
}`}
>
1
</div>
<span
className={`text-sm font-medium ${
currentStep === "org"
? "text-primary"
: "text-muted-foreground"
}`}
>
{t("setupCreateOrg")}
</span>
</div>
<div className="flex flex-col items-center">
<div
className={`w-8 h-8 rounded-full flex items-center justify-center mb-2 ${
currentStep === "site"
? "bg-primary text-primary-foreground"
: "bg-muted text-muted-foreground"
}`}
>
2
</div>
<span
className={`text-sm font-medium ${
currentStep === "site"
? "text-primary"
: "text-muted-foreground"
}`}
>
{t("siteCreate")}
</span>
</div>
<div className="flex flex-col items-center">
<div
className={`w-8 h-8 rounded-full flex items-center justify-center mb-2 ${
currentStep === "resources"
? "bg-primary text-primary-foreground"
: "bg-muted text-muted-foreground"
}`}
>
3
</div>
<span
className={`text-sm font-medium ${
currentStep === "resources"
? "text-primary"
: "text-muted-foreground"
}`}
>
{t("setupCreateResources")}
</span>
</div>
</div>
<section className="space-y-6">
<div>
<h1 className="text-2xl font-semibold tracking-tight">
{t("setupNewOrg")}
</h1>
<p className="text-muted-foreground text-sm mt-1">
{t("setupCreate")}
</p>
</div>
<div className="flex justify-between mb-2">
<div className="flex flex-col items-center">
<div
className={`w-8 h-8 rounded-full flex items-center justify-center mb-2 ${
currentStep === "org"
? "bg-primary text-primary-foreground"
: "bg-muted text-muted-foreground"
}`}
>
1
</div>
<span
className={`text-sm font-medium ${
currentStep === "org"
? "text-primary"
: "text-muted-foreground"
}`}
>
{t("setupCreateOrg")}
</span>
</div>
<div className="flex flex-col items-center">
<div
className={`w-8 h-8 rounded-full flex items-center justify-center mb-2 ${
currentStep === "site"
? "bg-primary text-primary-foreground"
: "bg-muted text-muted-foreground"
}`}
>
2
</div>
<span
className={`text-sm font-medium ${
currentStep === "site"
? "text-primary"
: "text-muted-foreground"
}`}
>
{t("siteCreate")}
</span>
</div>
<div className="flex flex-col items-center">
<div
className={`w-8 h-8 rounded-full flex items-center justify-center mb-2 ${
currentStep === "resources"
? "bg-primary text-primary-foreground"
: "bg-muted text-muted-foreground"
}`}
>
3
</div>
<span
className={`text-sm font-medium ${
currentStep === "resources"
? "text-primary"
: "text-muted-foreground"
}`}
>
{t("setupCreateResources")}
</span>
</div>
</div>
<Separator />
<Separator />
{currentStep === "org" && (
<Form {...orgForm}>
<form
onSubmit={orgForm.handleSubmit(orgSubmit)}
className="space-y-4"
>
<FormField
control={orgForm.control}
name="orgName"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("setupOrgName")}
</FormLabel>
<FormControl>
<Input
type="text"
{...field}
onChange={(e) => {
// Prevent "/" in orgName input
const sanitizedValue =
e.target.value.replace(
/\//g,
"-"
);
const orgId =
generateId(
sanitizedValue
);
orgForm.setValue(
"orgId",
orgId
);
orgForm.setValue(
"orgName",
sanitizedValue
);
debouncedCheckOrgIdAvailability(
orgId
);
}}
value={field.value.replace(
/\//g,
"-"
)}
/>
</FormControl>
<FormMessage />
<FormDescription>
{t("orgDisplayName")}
</FormDescription>
</FormItem>
)}
/>
<FormField
control={orgForm.control}
name="orgId"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("orgId")}
</FormLabel>
<FormControl>
<Input
type="text"
{...field}
/>
</FormControl>
<FormMessage />
<FormDescription>
{t(
"setupIdentifierMessage"
)}
</FormDescription>
</FormItem>
)}
/>
{currentStep === "org" && (
<Form {...orgForm}>
<form
onSubmit={orgForm.handleSubmit(orgSubmit)}
className="space-y-4"
>
<FormField
control={orgForm.control}
name="orgName"
render={({ field }) => (
<FormItem>
<FormLabel>{t("setupOrgName")}</FormLabel>
<FormControl>
<Input
type="text"
{...field}
onChange={(e) => {
// Prevent "/" in orgName input
const sanitizedValue =
e.target.value.replace(
/\//g,
"-"
);
const orgId =
generateId(sanitizedValue);
orgForm.setValue(
"orgId",
orgId
);
orgForm.setValue(
"orgName",
sanitizedValue
);
debouncedCheckOrgIdAvailability(
orgId
);
}}
value={field.value.replace(
/\//g,
"-"
)}
/>
</FormControl>
<FormMessage />
<FormDescription>
{t("orgDisplayName")}
</FormDescription>
</FormItem>
)}
/>
<FormField
control={orgForm.control}
name="orgId"
render={({ field }) => (
<FormItem>
<FormLabel>{t("orgId")}</FormLabel>
<FormControl>
<Input type="text" {...field} />
</FormControl>
<FormMessage />
<FormDescription>
{t("setupIdentifierMessage")}
</FormDescription>
</FormItem>
)}
/>
<Collapsible
open={isAdvancedOpen}
onOpenChange={setIsAdvancedOpen}
className="space-y-2"
<Collapsible
open={isAdvancedOpen}
onOpenChange={setIsAdvancedOpen}
className="space-y-2"
>
<div className="flex items-center justify-between space-x-4">
<CollapsibleTrigger asChild>
<Button
type="button"
variant="text"
size="sm"
className="p-0 flex items-center justify-between w-full"
>
<div className="flex items-center justify-between space-x-4">
<CollapsibleTrigger asChild>
<Button
type="button"
variant="text"
size="sm"
className="p-0 flex items-center justify-between w-full"
>
<h4 className="text-sm">
{t("advancedSettings")}
</h4>
<div>
<ChevronsUpDown className="h-4 w-4" />
<span className="sr-only">
{t("toggle")}
</span>
</div>
</Button>
</CollapsibleTrigger>
<h4 className="text-sm">
{t("advancedSettings")}
</h4>
<div>
<ChevronsUpDown className="h-4 w-4" />
<span className="sr-only">
{t("toggle")}
</span>
</div>
<CollapsibleContent className="space-y-4">
<FormField
control={orgForm.control}
name="subnet"
render={({ field }) => (
<FormItem>
<FormLabel>
{t(
"setupSubnetAdvanced"
)}
</FormLabel>
<FormControl>
<Input
type="text"
{...field}
/>
</FormControl>
<FormMessage />
<FormDescription>
{t(
"setupSubnetDescription"
)}
</FormDescription>
</FormItem>
</Button>
</CollapsibleTrigger>
</div>
<CollapsibleContent className="space-y-4">
<FormField
control={orgForm.control}
name="subnet"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("setupSubnetAdvanced")}
</FormLabel>
<FormControl>
<Input type="text" {...field} />
</FormControl>
<FormMessage />
<FormDescription>
{t("setupSubnetDescription")}
</FormDescription>
</FormItem>
)}
/>
<FormField
control={orgForm.control}
name="utilitySubnet"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("setupUtilitySubnet")}
</FormLabel>
<FormControl>
<Input type="text" {...field} />
</FormControl>
<FormMessage />
<FormDescription>
{t(
"setupUtilitySubnetDescription"
)}
/>
</FormDescription>
</FormItem>
)}
/>
</CollapsibleContent>
</Collapsible>
<FormField
control={orgForm.control}
name="utilitySubnet"
render={({ field }) => (
<FormItem>
<FormLabel>
{t(
"setupUtilitySubnet"
)}
</FormLabel>
<FormControl>
<Input
type="text"
{...field}
/>
</FormControl>
<FormMessage />
<FormDescription>
{t(
"setupUtilitySubnetDescription"
)}
</FormDescription>
</FormItem>
)}
/>
</CollapsibleContent>
</Collapsible>
{orgIdTaken && !orgCreated ? (
<Alert variant="destructive">
<AlertDescription>
{t("setupErrorIdentifier")}
</AlertDescription>
</Alert>
) : null}
{orgIdTaken && !orgCreated ? (
<Alert variant="destructive">
<AlertDescription>
{t("setupErrorIdentifier")}
</AlertDescription>
</Alert>
) : null}
{/* Error Alert removed, errors now shown as toast */}
{/* Error Alert removed, errors now shown as toast */}
<div className="flex justify-end">
<Button
type="submit"
loading={loading}
disabled={loading || orgIdTaken}
>
{t("setupCreateOrg")}
</Button>
</div>
</form>
</Form>
)}
</section>
</CardContent>
</Card>
</>
<div className="flex justify-end">
<Button
type="submit"
loading={loading}
disabled={loading || orgIdTaken}
>
{t("setupCreateOrg")}
<ArrowRight className="ml-2 h-4 w-4" />
</Button>
</div>
</form>
</Form>
)}
</section>
);
}