mirror of
https://github.com/fosrl/pangolin.git
synced 2026-03-01 16:26:39 +00:00
support creating multiple orgs in saas
This commit is contained in:
@@ -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",
|
||||||
|
|||||||
@@ -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", {
|
||||||
|
|||||||
@@ -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", {
|
||||||
|
|||||||
@@ -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"
|
|
||||||
)
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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";
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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[] => [
|
||||||
|
|||||||
@@ -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];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
|
||||||
</>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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" && (
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user