mirror of
https://github.com/fosrl/pangolin.git
synced 2026-02-18 10:56:38 +00:00
support creating multiple orgs in saas
This commit is contained in:
@@ -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", {
|
||||
|
||||
@@ -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", {
|
||||
|
||||
@@ -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")
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user