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

@@ -1266,6 +1266,7 @@
"sidebarLogAndAnalytics": "Log & Analytics", "sidebarLogAndAnalytics": "Log & Analytics",
"sidebarBluePrints": "Blueprints", "sidebarBluePrints": "Blueprints",
"sidebarOrganization": "Organization", "sidebarOrganization": "Organization",
"sidebarBillingAndLicenses": "Billing & Licenses",
"sidebarLogsAnalytics": "Analytics", "sidebarLogsAnalytics": "Analytics",
"blueprints": "Blueprints", "blueprints": "Blueprints",
"blueprintsDescription": "Apply declarative configurations and view previous runs", "blueprintsDescription": "Apply declarative configurations and view previous runs",
@@ -1469,6 +1470,7 @@
"failed": "Failed", "failed": "Failed",
"createNewOrgDescription": "Create a new organization", "createNewOrgDescription": "Create a new organization",
"organization": "Organization", "organization": "Organization",
"primary": "Primary",
"port": "Port", "port": "Port",
"securityKeyManage": "Manage Security Keys", "securityKeyManage": "Manage Security Keys",
"securityKeyDescription": "Add or remove security keys for passwordless authentication", "securityKeyDescription": "Add or remove security keys for passwordless authentication",

View File

@@ -55,7 +55,9 @@ export const orgs = pgTable("orgs", {
.notNull() .notNull()
.default(0), .default(0),
sshCaPrivateKey: text("sshCaPrivateKey"), // Encrypted SSH CA private key (PEM format) sshCaPrivateKey: text("sshCaPrivateKey"), // Encrypted SSH CA private key (PEM format)
sshCaPublicKey: text("sshCaPublicKey") // SSH CA public key (OpenSSH format) sshCaPublicKey: text("sshCaPublicKey"), // SSH CA public key (OpenSSH format)
isBillingOrg: boolean("isBillingOrg"),
billingOrgId: varchar("billingOrgId")
}); });
export const orgDomains = pgTable("orgDomains", { export const orgDomains = pgTable("orgDomains", {

View File

@@ -47,7 +47,9 @@ export const orgs = sqliteTable("orgs", {
.notNull() .notNull()
.default(0), .default(0),
sshCaPrivateKey: text("sshCaPrivateKey"), // Encrypted SSH CA private key (PEM format) sshCaPrivateKey: text("sshCaPrivateKey"), // Encrypted SSH CA private key (PEM format)
sshCaPublicKey: text("sshCaPublicKey") // SSH CA public key (OpenSSH format) sshCaPublicKey: text("sshCaPublicKey"), // SSH CA public key (OpenSSH format)
isBillingOrg: integer("isBillingOrg", { mode: "boolean" }),
billingOrgId: text("billingOrgId")
}); });
export const userDomains = sqliteTable("userDomains", { export const userDomains = sqliteTable("userDomains", {

View File

@@ -15,11 +15,10 @@ import {
import { verifyPassword } from "@server/auth/password"; import { verifyPassword } from "@server/auth/password";
import { verifyTotpCode } from "@server/auth/totp"; import { verifyTotpCode } from "@server/auth/totp";
import { calculateUserClientsForOrgs } from "@server/lib/calculateUserClientsForOrgs"; import { calculateUserClientsForOrgs } from "@server/lib/calculateUserClientsForOrgs";
import { import { deleteOrgById, sendTerminationMessages } from "@server/lib/deleteOrg";
deleteOrgById,
sendTerminationMessages
} from "@server/lib/deleteOrg";
import { UserType } from "@server/types/UserTypes"; import { UserType } from "@server/types/UserTypes";
import { build } from "@server/build";
import { getOrgTierData } from "#dynamic/lib/billing";
const deleteMyAccountBody = z.strictObject({ const deleteMyAccountBody = z.strictObject({
password: z.string().optional(), password: z.string().optional(),
@@ -40,11 +39,6 @@ export type DeleteMyAccountSuccessResponse = {
success: true; success: true;
}; };
/**
* Self-service account deletion (saas only). Returns preview when no password;
* requires password and optional 2FA code to perform deletion. Uses shared
* deleteOrgById for each owned org (delete-my-account may delete multiple orgs).
*/
export async function deleteMyAccount( export async function deleteMyAccount(
req: Request, req: Request,
res: Response, res: Response,
@@ -91,18 +85,35 @@ export async function deleteMyAccount(
const ownedOrgsRows = await db const ownedOrgsRows = await db
.select({ .select({
orgId: userOrgs.orgId orgId: userOrgs.orgId,
isOwner: userOrgs.isOwner,
isBillingOrg: orgs.isBillingOrg
}) })
.from(userOrgs) .from(userOrgs)
.innerJoin(orgs, eq(userOrgs.orgId, orgs.orgId))
.where( .where(
and( and(eq(userOrgs.userId, userId), eq(userOrgs.isOwner, true))
eq(userOrgs.userId, userId),
eq(userOrgs.isOwner, true)
)
); );
const orgIds = ownedOrgsRows.map((r) => r.orgId); const orgIds = ownedOrgsRows.map((r) => r.orgId);
if (build === "saas" && orgIds.length > 0) {
const primaryOrgId = ownedOrgsRows.find(
(r) => r.isBillingOrg && r.isOwner
)?.orgId;
if (primaryOrgId) {
const { tier, active } = await getOrgTierData(primaryOrgId);
if (active && tier) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
"You must cancel your subscription before deleting your account"
)
);
}
}
}
if (!password) { if (!password) {
const orgsWithNames = const orgsWithNames =
orgIds.length > 0 orgIds.length > 0
@@ -219,10 +230,7 @@ export async function deleteMyAccount(
} catch (error) { } catch (error) {
logger.error(error); logger.error(error);
return next( return next(
createHttpError( createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
HttpCode.INTERNAL_SERVER_ERROR,
"An error occurred"
)
); );
} }
} }

View File

@@ -21,7 +21,6 @@ import { hashPassword } from "@server/auth/password";
import { checkValidInvite } from "@server/auth/checkValidInvite"; import { checkValidInvite } from "@server/auth/checkValidInvite";
import { passwordSchema } from "@server/auth/passwordSchema"; import { passwordSchema } from "@server/auth/passwordSchema";
import { UserType } from "@server/types/UserTypes"; import { UserType } from "@server/types/UserTypes";
import { createUserAccountOrg } from "@server/lib/createUserAccountOrg";
import { build } from "@server/build"; import { build } from "@server/build";
import resend, { AudienceIds, moveEmailToAudience } from "#dynamic/lib/resend"; import resend, { AudienceIds, moveEmailToAudience } from "#dynamic/lib/resend";
@@ -198,26 +197,6 @@ export async function signup(
// orgId: null, // orgId: null,
// }); // });
if (build == "saas") {
const { success, error, org } = await createUserAccountOrg(
userId,
email
);
if (!success) {
if (error) {
return next(
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, error)
);
}
return next(
createHttpError(
HttpCode.INTERNAL_SERVER_ERROR,
"Failed to create user account and organization"
)
);
}
}
const token = generateSessionToken(); const token = generateSessionToken();
const sess = await createSession(token, userId); const sess = await createSession(token, userId);
const isSecure = req.protocol === "https"; const isSecure = req.protocol === "https";

View File

@@ -65,9 +65,8 @@ authenticated.use(verifySessionUserMiddleware);
authenticated.get("/pick-org-defaults", org.pickOrgDefaults); authenticated.get("/pick-org-defaults", org.pickOrgDefaults);
authenticated.get("/org/checkId", org.checkId); authenticated.get("/org/checkId", org.checkId);
if (build === "oss" || build === "enterprise") {
authenticated.put("/org", getUserOrgs, org.createOrg); authenticated.put("/org", getUserOrgs, org.createOrg);
}
authenticated.get("/orgs", verifyUserIsServerAdmin, org.listOrgs); authenticated.get("/orgs", verifyUserIsServerAdmin, org.listOrgs);
authenticated.get("/user/:userId/orgs", verifyIsLoggedInUser, org.listUserOrgs); authenticated.get("/user/:userId/orgs", verifyIsLoggedInUser, org.listUserOrgs);

View File

@@ -1,7 +1,7 @@
import { Request, Response, NextFunction } from "express"; import { Request, Response, NextFunction } from "express";
import { z } from "zod"; import { z } from "zod";
import { db } from "@server/db"; import { db } from "@server/db";
import { eq } from "drizzle-orm"; import { and, eq } from "drizzle-orm";
import { import {
domains, domains,
Org, Org,
@@ -24,7 +24,11 @@ import { OpenAPITags, registry } from "@server/openApi";
import { isValidCIDR } from "@server/lib/validators"; import { isValidCIDR } from "@server/lib/validators";
import { createCustomer } from "#dynamic/lib/billing"; import { createCustomer } from "#dynamic/lib/billing";
import { usageService } from "@server/lib/billing/usageService"; import { usageService } from "@server/lib/billing/usageService";
import { FeatureId } from "@server/lib/billing"; import {
FeatureId,
limitsService,
sandboxLimitSet
} from "@server/lib/billing";
import { build } from "@server/build"; import { build } from "@server/build";
import { calculateUserClientsForOrgs } from "@server/lib/calculateUserClientsForOrgs"; import { calculateUserClientsForOrgs } from "@server/lib/calculateUserClientsForOrgs";
import { doCidrsOverlap } from "@server/lib/ip"; import { doCidrsOverlap } from "@server/lib/ip";
@@ -136,6 +140,40 @@ export async function createOrg(
); );
} }
let isFirstOrg: boolean | null = null;
let billingOrgIdForNewOrg: string | null = null;
if (build === "saas" && req.user) {
const ownedOrgs = await db
.select()
.from(userOrgs)
.where(
and(
eq(userOrgs.userId, req.user.userId),
eq(userOrgs.isOwner, true)
)
);
if (ownedOrgs.length === 0) {
isFirstOrg = true;
} else {
isFirstOrg = false;
const [billingOrg] = await db
.select({ orgId: orgs.orgId })
.from(orgs)
.innerJoin(userOrgs, eq(orgs.orgId, userOrgs.orgId))
.where(
and(
eq(userOrgs.userId, req.user.userId),
eq(userOrgs.isOwner, true),
eq(orgs.isBillingOrg, true)
)
)
.limit(1);
if (billingOrg) {
billingOrgIdForNewOrg = billingOrg.orgId;
}
}
}
let error = ""; let error = "";
let org: Org | null = null; let org: Org | null = null;
@@ -150,6 +188,16 @@ export async function createOrg(
const encryptionKey = config.getRawConfig().server.secret!; const encryptionKey = config.getRawConfig().server.secret!;
const encryptedCaPrivateKey = encrypt(ca.privateKeyPem, encryptionKey); const encryptedCaPrivateKey = encrypt(ca.privateKeyPem, encryptionKey);
const saasBillingFields =
build === "saas" && req.user && isFirstOrg !== null
? isFirstOrg
? { isBillingOrg: true as const, billingOrgId: null }
: {
isBillingOrg: false as const,
billingOrgId: billingOrgIdForNewOrg
}
: {};
const newOrg = await trx const newOrg = await trx
.insert(orgs) .insert(orgs)
.values({ .values({
@@ -159,7 +207,8 @@ export async function createOrg(
utilitySubnet, utilitySubnet,
createdAt: new Date().toISOString(), createdAt: new Date().toISOString(),
sshCaPrivateKey: encryptedCaPrivateKey, sshCaPrivateKey: encryptedCaPrivateKey,
sshCaPublicKey: ca.publicKeyOpenSSH sshCaPublicKey: ca.publicKeyOpenSSH,
...saasBillingFields
}) })
.returning(); .returning();
@@ -276,8 +325,8 @@ export async function createOrg(
return next(createHttpError(HttpCode.INTERNAL_SERVER_ERROR, error)); return next(createHttpError(HttpCode.INTERNAL_SERVER_ERROR, error));
} }
if (build == "saas") { if (build === "saas" && isFirstOrg === true) {
// make sure we have the stripe customer await limitsService.applyLimitSetToOrg(orgId, sandboxLimitSet);
const customerId = await createCustomer(orgId, req.user?.email); const customerId = await createCustomer(orgId, req.user?.email);
if (customerId) { if (customerId) {
await usageService.updateCount( await usageService.updateCount(

View File

@@ -40,7 +40,11 @@ const listOrgsSchema = z.object({
// responses: {} // responses: {}
// }); // });
type ResponseOrg = Org & { isOwner?: boolean; isAdmin?: boolean }; type ResponseOrg = Org & {
isOwner?: boolean;
isAdmin?: boolean;
isPrimaryOrg?: boolean;
};
export type ListUserOrgsResponse = { export type ListUserOrgsResponse = {
orgs: ResponseOrg[]; orgs: ResponseOrg[];
@@ -132,6 +136,9 @@ export async function listUserOrgs(
if (val.roles && val.roles.isAdmin) { if (val.roles && val.roles.isAdmin) {
res.isAdmin = val.roles.isAdmin; res.isAdmin = val.roles.isAdmin;
} }
if (val.userOrgs?.isOwner && val.orgs?.isBillingOrg) {
res.isPrimaryOrg = val.orgs.isBillingOrg;
}
return res; return res;
}); });

View File

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

View File

@@ -4,6 +4,8 @@ import { redirect } from "next/navigation";
import { cache } from "react"; import { cache } from "react";
import { getTranslations } from "next-intl/server"; import { getTranslations } from "next-intl/server";
import { build } from "@server/build"; import { build } from "@server/build";
import { getCachedOrgUser } from "@app/lib/api/getCachedOrgUser";
import { getCachedOrg } from "@app/lib/api/getCachedOrg";
type LicensesSettingsProps = { type LicensesSettingsProps = {
children: React.ReactNode; children: React.ReactNode;
@@ -27,6 +29,26 @@ export default async function LicensesSetingsLayoutProps({
redirect(`/`); 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(); const t = await getTranslations();
return ( return (

View File

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

View File

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

View File

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

View File

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

View File

@@ -189,10 +189,12 @@ export function LayoutSidebar({
<div className="w-full border-t border-border" /> <div className="w-full border-t border-border" />
<div className="p-4 pt-1 flex flex-col shrink-0"> <div className="p-4 pt-1 flex flex-col shrink-0">
{canShowProductUpdates && ( {canShowProductUpdates ? (
<div className="mb-3"> <div className="mb-3">
<ProductUpdates isCollapsed={isSidebarCollapsed} /> <ProductUpdates isCollapsed={isSidebarCollapsed} />
</div> </div>
) : (
<div className="mb-3"></div>
)} )}
{build === "enterprise" && ( {build === "enterprise" && (

View File

@@ -20,12 +20,13 @@ import {
TooltipProvider, TooltipProvider,
TooltipTrigger TooltipTrigger
} from "@app/components/ui/tooltip"; } from "@app/components/ui/tooltip";
import { Badge } from "@app/components/ui/badge";
import { useEnvContext } from "@app/hooks/useEnvContext"; import { useEnvContext } from "@app/hooks/useEnvContext";
import { cn } from "@app/lib/cn"; import { cn } from "@app/lib/cn";
import { ListUserOrgsResponse } from "@server/routers/org"; import { ListUserOrgsResponse } from "@server/routers/org";
import { Check, ChevronsUpDown, Plus, Building2, Users } from "lucide-react"; import { Check, ChevronsUpDown, Plus, Building2, Users } from "lucide-react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { useState } from "react"; import { useMemo, useState } from "react";
import { useUserContext } from "@app/hooks/useUserContext"; import { useUserContext } from "@app/hooks/useUserContext";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
@@ -48,6 +49,17 @@ export function OrgSelector({
const selectedOrg = orgs?.find((org) => org.orgId === orgId); const selectedOrg = orgs?.find((org) => org.orgId === orgId);
const sortedOrgs = useMemo(() => {
if (!orgs?.length) return orgs ?? [];
return [...orgs].sort((a, b) => {
const aPrimary = Boolean(a.isPrimaryOrg);
const bPrimary = Boolean(b.isPrimaryOrg);
if (aPrimary && !bPrimary) return -1;
if (!aPrimary && bPrimary) return 1;
return 0;
});
}, [orgs]);
const orgSelectorContent = ( const orgSelectorContent = (
<Popover open={open} onOpenChange={setOpen}> <Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild> <PopoverTrigger asChild>
@@ -124,7 +136,7 @@ export function OrgSelector({
)} )}
<CommandGroup heading={t("orgs")} className="py-2"> <CommandGroup heading={t("orgs")} className="py-2">
<CommandList> <CommandList>
{orgs?.map((org) => ( {sortedOrgs.map((org) => (
<CommandItem <CommandItem
key={org.orgId} key={org.orgId}
onSelect={() => { onSelect={() => {
@@ -136,12 +148,22 @@ export function OrgSelector({
<div className="flex items-center justify-center w-8 h-8 rounded-lg bg-muted mr-3"> <div className="flex items-center justify-center w-8 h-8 rounded-lg bg-muted mr-3">
<Users className="h-4 w-4 text-muted-foreground" /> <Users className="h-4 w-4 text-muted-foreground" />
</div> </div>
<div className="flex flex-col flex-1"> <div className="flex flex-col flex-1 min-w-0">
<span className="font-medium"> <div className="flex items-center gap-2 flex-wrap">
{org.name} <span className="font-medium truncate">
</span> {org.name}
<span className="text-xs text-muted-foreground"> </span>
{t("organization")} {org.isPrimaryOrg && (
<Badge
variant="outline"
className="shrink-0 text-[10px] px-1.5 py-0 font-medium"
>
{t("primary")}
</Badge>
)}
</div>
<span className="text-xs text-muted-foreground font-mono">
{org.orgId}
</span> </span>
</div> </div>
<Check <Check