diff --git a/messages/en-US.json b/messages/en-US.json index 3e825711..ecef7605 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -1266,6 +1266,7 @@ "sidebarLogAndAnalytics": "Log & Analytics", "sidebarBluePrints": "Blueprints", "sidebarOrganization": "Organization", + "sidebarBillingAndLicenses": "Billing & Licenses", "sidebarLogsAnalytics": "Analytics", "blueprints": "Blueprints", "blueprintsDescription": "Apply declarative configurations and view previous runs", @@ -1469,6 +1470,7 @@ "failed": "Failed", "createNewOrgDescription": "Create a new organization", "organization": "Organization", + "primary": "Primary", "port": "Port", "securityKeyManage": "Manage Security Keys", "securityKeyDescription": "Add or remove security keys for passwordless authentication", diff --git a/server/db/pg/schema/schema.ts b/server/db/pg/schema/schema.ts index ca46e207..7c252b8b 100644 --- a/server/db/pg/schema/schema.ts +++ b/server/db/pg/schema/schema.ts @@ -55,7 +55,9 @@ export const orgs = pgTable("orgs", { .notNull() .default(0), 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", { diff --git a/server/db/sqlite/schema/schema.ts b/server/db/sqlite/schema/schema.ts index ce08dea1..04d4338a 100644 --- a/server/db/sqlite/schema/schema.ts +++ b/server/db/sqlite/schema/schema.ts @@ -47,7 +47,9 @@ export const orgs = sqliteTable("orgs", { .notNull() .default(0), 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", { diff --git a/server/routers/auth/deleteMyAccount.ts b/server/routers/auth/deleteMyAccount.ts index 2c37cd09..8df11243 100644 --- a/server/routers/auth/deleteMyAccount.ts +++ b/server/routers/auth/deleteMyAccount.ts @@ -15,11 +15,10 @@ import { import { verifyPassword } from "@server/auth/password"; import { verifyTotpCode } from "@server/auth/totp"; import { calculateUserClientsForOrgs } from "@server/lib/calculateUserClientsForOrgs"; -import { - deleteOrgById, - sendTerminationMessages -} from "@server/lib/deleteOrg"; +import { deleteOrgById, sendTerminationMessages } from "@server/lib/deleteOrg"; import { UserType } from "@server/types/UserTypes"; +import { build } from "@server/build"; +import { getOrgTierData } from "#dynamic/lib/billing"; const deleteMyAccountBody = z.strictObject({ password: z.string().optional(), @@ -40,11 +39,6 @@ export type DeleteMyAccountSuccessResponse = { 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( req: Request, res: Response, @@ -91,18 +85,35 @@ export async function deleteMyAccount( const ownedOrgsRows = await db .select({ - orgId: userOrgs.orgId + orgId: userOrgs.orgId, + isOwner: userOrgs.isOwner, + isBillingOrg: orgs.isBillingOrg }) .from(userOrgs) + .innerJoin(orgs, eq(userOrgs.orgId, orgs.orgId)) .where( - and( - eq(userOrgs.userId, userId), - eq(userOrgs.isOwner, true) - ) + and(eq(userOrgs.userId, userId), eq(userOrgs.isOwner, true)) ); 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) { const orgsWithNames = orgIds.length > 0 @@ -219,10 +230,7 @@ export async function deleteMyAccount( } catch (error) { logger.error(error); return next( - createHttpError( - HttpCode.INTERNAL_SERVER_ERROR, - "An error occurred" - ) + createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") ); } } diff --git a/server/routers/auth/signup.ts b/server/routers/auth/signup.ts index 2605a026..c1c344d8 100644 --- a/server/routers/auth/signup.ts +++ b/server/routers/auth/signup.ts @@ -21,7 +21,6 @@ import { hashPassword } from "@server/auth/password"; import { checkValidInvite } from "@server/auth/checkValidInvite"; import { passwordSchema } from "@server/auth/passwordSchema"; import { UserType } from "@server/types/UserTypes"; -import { createUserAccountOrg } from "@server/lib/createUserAccountOrg"; import { build } from "@server/build"; import resend, { AudienceIds, moveEmailToAudience } from "#dynamic/lib/resend"; @@ -198,26 +197,6 @@ export async function signup( // 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 sess = await createSession(token, userId); const isSecure = req.protocol === "https"; diff --git a/server/routers/external.ts b/server/routers/external.ts index a9d075a6..51cd51f9 100644 --- a/server/routers/external.ts +++ b/server/routers/external.ts @@ -65,9 +65,8 @@ authenticated.use(verifySessionUserMiddleware); authenticated.get("/pick-org-defaults", org.pickOrgDefaults); 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("/user/:userId/orgs", verifyIsLoggedInUser, org.listUserOrgs); diff --git a/server/routers/org/createOrg.ts b/server/routers/org/createOrg.ts index b8e2d625..0c135e5b 100644 --- a/server/routers/org/createOrg.ts +++ b/server/routers/org/createOrg.ts @@ -1,7 +1,7 @@ import { Request, Response, NextFunction } from "express"; import { z } from "zod"; import { db } from "@server/db"; -import { eq } from "drizzle-orm"; +import { and, eq } from "drizzle-orm"; import { domains, Org, @@ -24,7 +24,11 @@ import { OpenAPITags, registry } from "@server/openApi"; import { isValidCIDR } from "@server/lib/validators"; import { createCustomer } from "#dynamic/lib/billing"; 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 { calculateUserClientsForOrgs } from "@server/lib/calculateUserClientsForOrgs"; 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 org: Org | null = null; @@ -150,6 +188,16 @@ export async function createOrg( const encryptionKey = config.getRawConfig().server.secret!; 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 .insert(orgs) .values({ @@ -159,7 +207,8 @@ export async function createOrg( utilitySubnet, createdAt: new Date().toISOString(), sshCaPrivateKey: encryptedCaPrivateKey, - sshCaPublicKey: ca.publicKeyOpenSSH + sshCaPublicKey: ca.publicKeyOpenSSH, + ...saasBillingFields }) .returning(); @@ -276,8 +325,8 @@ export async function createOrg( return next(createHttpError(HttpCode.INTERNAL_SERVER_ERROR, error)); } - if (build == "saas") { - // make sure we have the stripe customer + if (build === "saas" && isFirstOrg === true) { + await limitsService.applyLimitSetToOrg(orgId, sandboxLimitSet); const customerId = await createCustomer(orgId, req.user?.email); if (customerId) { await usageService.updateCount( diff --git a/server/routers/org/listUserOrgs.ts b/server/routers/org/listUserOrgs.ts index 103b1023..301d0203 100644 --- a/server/routers/org/listUserOrgs.ts +++ b/server/routers/org/listUserOrgs.ts @@ -40,7 +40,11 @@ const listOrgsSchema = z.object({ // responses: {} // }); -type ResponseOrg = Org & { isOwner?: boolean; isAdmin?: boolean }; +type ResponseOrg = Org & { + isOwner?: boolean; + isAdmin?: boolean; + isPrimaryOrg?: boolean; +}; export type ListUserOrgsResponse = { orgs: ResponseOrg[]; @@ -132,6 +136,9 @@ export async function listUserOrgs( if (val.roles && val.roles.isAdmin) { res.isAdmin = val.roles.isAdmin; } + if (val.userOrgs?.isOwner && val.orgs?.isBillingOrg) { + res.isPrimaryOrg = val.orgs.isBillingOrg; + } return res; }); diff --git a/src/app/[orgId]/settings/(private)/billing/layout.tsx b/src/app/[orgId]/settings/(private)/billing/layout.tsx index c4048bcc..69c3da48 100644 --- a/src/app/[orgId]/settings/(private)/billing/layout.tsx +++ b/src/app/[orgId]/settings/(private)/billing/layout.tsx @@ -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 ( diff --git a/src/app/[orgId]/settings/(private)/license/layout.tsx b/src/app/[orgId]/settings/(private)/license/layout.tsx index 9083bb81..453b3372 100644 --- a/src/app/[orgId]/settings/(private)/license/layout.tsx +++ b/src/app/[orgId]/settings/(private)/license/layout.tsx @@ -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 ( diff --git a/src/app/[orgId]/settings/layout.tsx b/src/app/[orgId]/settings/layout.tsx index 34ed3ac2..8ee7b1dc 100644 --- a/src/app/[orgId]/settings/layout.tsx +++ b/src/app/[orgId]/settings/layout.tsx @@ -77,12 +77,16 @@ export default async function SettingsLayout(props: SettingsLayoutProps) { } } catch (e) {} + const primaryOrg = orgs.find((o) => o.orgId === params.orgId)?.isPrimaryOrg; + return ( {children} diff --git a/src/app/navigation.tsx b/src/app/navigation.tsx index 7df4364a..be3ad7d3 100644 --- a/src/app/navigation.tsx +++ b/src/app/navigation.tsx @@ -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: - }, - - ...(build == "saas" - ? [ + } + ] + }, + ...(build == "saas" && options?.isPrimaryOrg + ? [ + { + heading: "sidebarBillingAndLicenses", + items: [ { title: "sidebarBilling", href: "/{orgId}/settings/billing", icon: - } - ] - : []), - ...(build == "saas" - ? [ + }, { title: "sidebarEnterpriseLicenses", href: "/{orgId}/settings/license", icon: } ] - : []) - ] - } + } + ] + : []) ]; export const adminNavSections = (env?: Env): SidebarNavSection[] => [ diff --git a/src/app/page.tsx b/src/app/page.tsx index df1a81df..f6f30276 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -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]; } diff --git a/src/app/setup/page.tsx b/src/app/setup/page.tsx index c8b2af19..dc505b67 100644 --- a/src/app/setup/page.tsx +++ b/src/app/setup/page.tsx @@ -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 ( - <> - - - {t("setupNewOrg")} - {t("setupCreate")} - - -
-
-
-
- 1 -
- - {t("setupCreateOrg")} - -
-
-
- 2 -
- - {t("siteCreate")} - -
-
-
- 3 -
- - {t("setupCreateResources")} - -
-
+
+
+

+ {t("setupNewOrg")} +

+

+ {t("setupCreate")} +

+
+
+
+
+ 1 +
+ + {t("setupCreateOrg")} + +
+
+
+ 2 +
+ + {t("siteCreate")} + +
+
+
+ 3 +
+ + {t("setupCreateResources")} + +
+
- + - {currentStep === "org" && ( -
- - ( - - - {t("setupOrgName")} - - - { - // 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, - "-" - )} - /> - - - - {t("orgDisplayName")} - - - )} - /> - ( - - - {t("orgId")} - - - - - - - {t( - "setupIdentifierMessage" - )} - - - )} - /> + {currentStep === "org" && ( + + + ( + + {t("setupOrgName")} + + { + // 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, + "-" + )} + /> + + + + {t("orgDisplayName")} + + + )} + /> + ( + + {t("orgId")} + + + + + + {t("setupIdentifierMessage")} + + + )} + /> - +
+ + - +

+ {t("advancedSettings")} +

+
+ + + {t("toggle")} +
- - ( - - - {t( - "setupSubnetAdvanced" - )} - - - - - - - {t( - "setupSubnetDescription" - )} - - + + +
+ + ( + + + {t("setupSubnetAdvanced")} + + + + + + + {t("setupSubnetDescription")} + + + )} + /> + + ( + + + {t("setupUtilitySubnet")} + + + + + + + {t( + "setupUtilitySubnetDescription" )} - /> + + + )} + /> + +
- ( - - - {t( - "setupUtilitySubnet" - )} - - - - - - - {t( - "setupUtilitySubnetDescription" - )} - - - )} - /> - - + {orgIdTaken && !orgCreated ? ( + + + {t("setupErrorIdentifier")} + + + ) : null} - {orgIdTaken && !orgCreated ? ( - - - {t("setupErrorIdentifier")} - - - ) : null} + {/* Error Alert removed, errors now shown as toast */} - {/* Error Alert removed, errors now shown as toast */} - -
- -
- - - )} -
- - - +
+ +
+ + + )} +
); } diff --git a/src/components/LayoutSidebar.tsx b/src/components/LayoutSidebar.tsx index 15951402..3095b1fd 100644 --- a/src/components/LayoutSidebar.tsx +++ b/src/components/LayoutSidebar.tsx @@ -189,10 +189,12 @@ export function LayoutSidebar({
- {canShowProductUpdates && ( + {canShowProductUpdates ? (
+ ) : ( +
)} {build === "enterprise" && ( diff --git a/src/components/OrgSelector.tsx b/src/components/OrgSelector.tsx index e139e43a..45fed43c 100644 --- a/src/components/OrgSelector.tsx +++ b/src/components/OrgSelector.tsx @@ -20,12 +20,13 @@ import { TooltipProvider, TooltipTrigger } from "@app/components/ui/tooltip"; +import { Badge } from "@app/components/ui/badge"; import { useEnvContext } from "@app/hooks/useEnvContext"; import { cn } from "@app/lib/cn"; import { ListUserOrgsResponse } from "@server/routers/org"; import { Check, ChevronsUpDown, Plus, Building2, Users } from "lucide-react"; import { useRouter } from "next/navigation"; -import { useState } from "react"; +import { useMemo, useState } from "react"; import { useUserContext } from "@app/hooks/useUserContext"; import { useTranslations } from "next-intl"; @@ -48,6 +49,17 @@ export function OrgSelector({ 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 = ( @@ -124,7 +136,7 @@ export function OrgSelector({ )} - {orgs?.map((org) => ( + {sortedOrgs.map((org) => ( { @@ -136,12 +148,22 @@ export function OrgSelector({
-
- - {org.name} - - - {t("organization")} +
+
+ + {org.name} + + {org.isPrimaryOrg && ( + + {t("primary")} + + )} +
+ + {org.orgId}