Compare commits

...

13 Commits

Author SHA1 Message Date
miloschwartz
94ac3ec76e set default org mapping on create global idp 2026-02-10 10:58:01 -08:00
Owen
af7263a0b1 Finish adding limits checks to all put and post 2026-02-10 10:53:02 -08:00
Owen
035396f95c Fix error response 2026-02-10 10:53:02 -08:00
Owen
f318f6304b Adding limit checks 2026-02-10 10:53:02 -08:00
miloschwartz
9d0ff472e5 add tier matrix to branding page 2026-02-10 10:42:39 -08:00
miloschwartz
d27482e812 refactor and add tiers 2026-02-10 10:27:10 -08:00
miloschwartz
69c2212ea0 refactor front end hooks 2026-02-09 20:50:44 -08:00
Owen
10be9bcd56 Fix to use the limits file 2026-02-09 20:39:26 -08:00
Owen
f531def0d2 Comment out stripe usage reporting 2026-02-09 20:30:44 -08:00
miloschwartz
ed40eae655 fix some errors 2026-02-09 20:23:55 -08:00
Owen
ba5ae6ed04 Fix errors 2026-02-09 20:17:14 -08:00
Owen
0a6301697e Handle auto provisioning 2026-02-09 20:11:24 -08:00
Owen
13b4fc6725 Add more tier matrix checks 2026-02-09 19:52:44 -08:00
66 changed files with 771 additions and 457 deletions

View File

@@ -1,6 +1,3 @@
import Stripe from "stripe";
import { usageService } from "./usageService";
export enum FeatureId {
USERS = "users",
SITES = "sites",
@@ -135,25 +132,3 @@ export function getFeatureIdByPriceId(priceId: string): FeatureId | undefined {
return undefined;
}
export async function getLineItems(
featurePriceSet: FeaturePriceSet,
orgId: string,
): Promise<Stripe.Checkout.SessionCreateParams.LineItem[]> {
const users = await usageService.getUsage(orgId, FeatureId.USERS);
return Object.entries(featurePriceSet).map(([featureId, priceId]) => {
let quantity: number | undefined;
if (featureId === FeatureId.USERS) {
quantity = users?.instantaneousValue || 1;
} else if (featureId === FeatureId.TIER1) {
quantity = 1;
}
return {
price: priceId,
quantity: quantity
};
});
}

View File

@@ -0,0 +1,25 @@
import Stripe from "stripe";
import { FeatureId, FeaturePriceSet } from "./features";
import { usageService } from "./usageService";
export async function getLineItems(
featurePriceSet: FeaturePriceSet,
orgId: string,
): Promise<Stripe.Checkout.SessionCreateParams.LineItem[]> {
const users = await usageService.getUsage(orgId, FeatureId.USERS);
return Object.entries(featurePriceSet).map(([featureId, priceId]) => {
let quantity: number | undefined;
if (featureId === FeatureId.USERS) {
quantity = users?.instantaneousValue || 1;
} else if (featureId === FeatureId.TIER1) {
quantity = 1;
}
return {
price: priceId,
quantity: quantity
};
});
}

View File

@@ -2,7 +2,7 @@ import { Tier } from "@server/types/Tiers";
export enum TierFeature {
OrgOidc = "orgOidc",
CustomAuthenticationDomain = "customAuthenticationDomain",
LoginPageDomain = "loginPageDomain",
DeviceApprovals = "deviceApprovals",
LoginPageBranding = "loginPageBranding",
LogExport = "logExport",
@@ -13,17 +13,13 @@ export enum TierFeature {
DevicePosture = "devicePosture",
TwoFactorEnforcement = "twoFactorEnforcement",
SessionDurationPolicies = "sessionDurationPolicies",
PasswordExpirationPolicies = "passwordExpirationPolicies"
PasswordExpirationPolicies = "passwordExpirationPolicies",
AutoProvisioning = "autoProvisioning"
}
export const tierMatrix: Record<TierFeature, Tier[]> = {
[TierFeature.OrgOidc]: ["tier1", "tier2", "tier3", "enterprise"],
[TierFeature.CustomAuthenticationDomain]: [
"tier1",
"tier2",
"tier3",
"enterprise"
],
[TierFeature.LoginPageDomain]: ["tier1", "tier2", "tier3", "enterprise"],
[TierFeature.DeviceApprovals]: ["tier1", "tier3", "enterprise"],
[TierFeature.LoginPageBranding]: ["tier1", "tier3", "enterprise"],
[TierFeature.LogExport]: ["tier3", "enterprise"],
@@ -32,7 +28,23 @@ export const tierMatrix: Record<TierFeature, Tier[]> = {
[TierFeature.RotateCredentials]: ["tier1", "tier2", "tier3", "enterprise"],
[TierFeature.MaintencePage]: ["tier1", "tier2", "tier3", "enterprise"],
[TierFeature.DevicePosture]: ["tier2", "tier3", "enterprise"],
[TierFeature.TwoFactorEnforcement]: ["tier1", "tier2", "tier3", "enterprise"],
[TierFeature.SessionDurationPolicies]: ["tier1", "tier2", "tier3", "enterprise"],
[TierFeature.PasswordExpirationPolicies]: ["tier1", "tier2", "tier3", "enterprise"]
[TierFeature.TwoFactorEnforcement]: [
"tier1",
"tier2",
"tier3",
"enterprise"
],
[TierFeature.SessionDurationPolicies]: [
"tier1",
"tier2",
"tier3",
"enterprise"
],
[TierFeature.PasswordExpirationPolicies]: [
"tier1",
"tier2",
"tier3",
"enterprise"
],
[TierFeature.AutoProvisioning]: ["tier1", "tier3", "enterprise"]
};

View File

@@ -18,7 +18,6 @@ import { sendToClient } from "#dynamic/routers/ws";
import { build } from "@server/build";
import { s3Client } from "@server/lib/s3";
import cache from "@server/lib/cache";
import privateConfig from "@server/private/lib/config";
interface StripeEvent {
identifier?: string;
@@ -48,39 +47,31 @@ export class UsageService {
return;
}
this.bucketName = process.env.S3_BUCKET || undefined;
// this.bucketName = process.env.S3_BUCKET || undefined;
if (
// Only set up event uploading if usage reporting is enabled and bucket name is configured
privateConfig.getRawPrivateConfig().flags.usage_reporting &&
this.bucketName
) {
// Periodically check and upload events
setInterval(() => {
this.checkAndUploadEvents().catch((err) => {
logger.error("Error in periodic event upload:", err);
});
}, 30000); // every 30 seconds
// // Periodically check and upload events
// setInterval(() => {
// this.checkAndUploadEvents().catch((err) => {
// logger.error("Error in periodic event upload:", err);
// });
// }, 30000); // every 30 seconds
// Handle graceful shutdown on SIGTERM
process.on("SIGTERM", async () => {
logger.info(
"SIGTERM received, uploading events before shutdown..."
);
await this.forceUpload();
logger.info("Events uploaded, proceeding with shutdown");
});
// // Handle graceful shutdown on SIGTERM
// process.on("SIGTERM", async () => {
// logger.info(
// "SIGTERM received, uploading events before shutdown..."
// );
// await this.forceUpload();
// logger.info("Events uploaded, proceeding with shutdown");
// });
// Handle SIGINT as well (Ctrl+C)
process.on("SIGINT", async () => {
logger.info(
"SIGINT received, uploading events before shutdown..."
);
await this.forceUpload();
logger.info("Events uploaded, proceeding with shutdown");
process.exit(0);
});
}
// // Handle SIGINT as well (Ctrl+C)
// process.on("SIGINT", async () => {
// logger.info("SIGINT received, uploading events before shutdown...");
// await this.forceUpload();
// logger.info("Events uploaded, proceeding with shutdown");
// process.exit(0);
// });
}
/**
@@ -139,9 +130,9 @@ export class UsageService {
}
// Log event for Stripe
if (privateConfig.getRawPrivateConfig().flags.usage_reporting) {
await this.logStripeEvent(featureId, value, customerId);
}
// if (privateConfig.getRawPrivateConfig().flags.usage_reporting) {
// await this.logStripeEvent(featureId, value, customerId);
// }
return usage || null;
} catch (error: any) {
@@ -282,9 +273,9 @@ export class UsageService {
}
});
if (privateConfig.getRawPrivateConfig().flags.usage_reporting) {
await this.logStripeEvent(featureId, value || 0, customerId);
}
// if (privateConfig.getRawPrivateConfig().flags.usage_reporting) {
// await this.logStripeEvent(featureId, value || 0, customerId);
// }
} catch (error) {
logger.error(
`Failed to update count usage for ${orgId}/${featureId}:`,

View File

@@ -33,7 +33,6 @@ import { hashPassword } from "@server/auth/password";
import { isValidCIDR, isValidIP, isValidUrlGlobPattern } from "../validators";
import { isLicensedOrSubscribed } from "#dynamic/lib/isLicencedOrSubscribed";
import { tierMatrix } from "../billing/tierMatrix";
import { t } from "@faker-js/faker/dist/airline-DF6RqYmq";
export type ProxyResourcesResults = {
proxyResource: Resource;

View File

@@ -29,3 +29,4 @@ export * from "./verifyUserIsOrgOwner";
export * from "./verifySiteResourceAccess";
export * from "./logActionAudit";
export * from "./verifyOlmAccess";
export * from "./verifyLimits";

View File

@@ -4,7 +4,6 @@ import { apiKeyOrg } from "@server/db";
import { and, eq } from "drizzle-orm";
import createHttpError from "http-errors";
import HttpCode from "@server/types/HttpCode";
import logger from "@server/logger";
export async function verifyApiKeyOrgAccess(
req: Request,

View File

@@ -0,0 +1,47 @@
import { Request, Response, NextFunction } from "express";
import { db, orgs } from "@server/db";
import { userOrgs } from "@server/db";
import { and, eq } from "drizzle-orm";
import createHttpError from "http-errors";
import HttpCode from "@server/types/HttpCode";
import { checkOrgAccessPolicy } from "#dynamic/lib/checkOrgAccessPolicy";
import { usageService } from "@server/lib/billing/usageService";
import { build } from "@server/build";
export async function verifyLimits(
req: Request,
res: Response,
next: NextFunction
) {
if (build != "saas") {
return next();
}
const orgId = req.userOrgId || req.apiKeyOrg?.orgId || req.params.orgId;
if (!orgId) {
return next(); // its fine if we silently fail here because this is not critical to operation or security and its better user experience if we dont fail
}
try {
const reject = await usageService.checkLimitSet(orgId);
if (reject) {
return next(
createHttpError(
HttpCode.PAYMENT_REQUIRED,
"Organization has exceeded its usage limits. Please upgrade your plan or contact support."
)
);
}
return next();
} catch (e) {
return next(
createHttpError(
HttpCode.INTERNAL_SERVER_ERROR,
"Error checking limits"
)
);
}
}

View File

@@ -96,7 +96,6 @@ export const privateConfigSchema = z.object({
enable_redis: z.boolean().optional().default(false),
use_pangolin_dns: z.boolean().optional().default(false),
use_org_only_idp: z.boolean().optional().default(false),
usage_reporting: z.boolean().optional().default(false)
})
.optional()
.prefault({}),

View File

@@ -45,7 +45,7 @@ export function verifyValidSubscription(tiers: Tier[]) {
}
const { tier, active } = await getOrgTierData(orgId);
const isTier = tiers.includes(tier || "");
const isTier = tiers.includes(tier as Tier);
if (!active) {
return next(
createHttpError(

View File

@@ -25,10 +25,10 @@ import {
getHomeLabFeaturePriceSet,
getScaleFeaturePriceSet,
getStarterFeaturePriceSet,
getLineItems,
FeatureId,
type FeaturePriceSet
} from "@server/lib/billing";
import { getLineItems } from "@server/lib/billing/getLineItems";
const changeTierSchema = z.strictObject({
orgId: z.string()
@@ -151,8 +151,10 @@ export async function changeTier(
// tier1 uses TIER1 product, tier2/tier3 use USERS product
const currentTier = subscription.type;
const switchingProducts =
(currentTier === "tier1" && (tier === "tier2" || tier === "tier3")) ||
((currentTier === "tier2" || currentTier === "tier3") && tier === "tier1");
(currentTier === "tier1" &&
(tier === "tier2" || tier === "tier3")) ||
((currentTier === "tier2" || currentTier === "tier3") &&
tier === "tier1");
let updatedSubscription;

View File

@@ -22,8 +22,12 @@ import logger from "@server/logger";
import config from "@server/lib/config";
import { fromError } from "zod-validation-error";
import stripe from "#private/lib/stripe";
import { getHomeLabFeaturePriceSet, getLineItems, getScaleFeaturePriceSet, getStarterFeaturePriceSet } from "@server/lib/billing";
import { usageService } from "@server/lib/billing/usageService";
import {
getHomeLabFeaturePriceSet,
getScaleFeaturePriceSet,
getStarterFeaturePriceSet
} from "@server/lib/billing";
import { getLineItems } from "@server/lib/billing/getLineItems";
import Stripe from "stripe";
const createCheckoutSessionSchema = z.strictObject({
@@ -31,7 +35,7 @@ const createCheckoutSessionSchema = z.strictObject({
});
const createCheckoutSessionBodySchema = z.strictObject({
tier: z.enum(["tier1", "tier2", "tier3"]),
tier: z.enum(["tier1", "tier2", "tier3"])
});
export async function createCheckoutSession(
@@ -90,12 +94,10 @@ export async function createCheckoutSession(
} else if (tier === "tier3") {
lineItems = await getLineItems(getScaleFeaturePriceSet(), orgId);
} else {
return next(
createHttpError(HttpCode.BAD_REQUEST, "Invalid plan")
);
return next(createHttpError(HttpCode.BAD_REQUEST, "Invalid plan"));
}
logger.debug(`Line items: ${JSON.stringify(lineItems)}`)
logger.debug(`Line items: ${JSON.stringify(lineItems)}`);
const session = await stripe!.checkout.sessions.create({
client_reference_id: orgId, // So we can look it up the org later on the webhook

View File

@@ -90,7 +90,7 @@ export async function handleSubscriptionUpdated(
const itemsToUpsert = fullSubscription.items.data.map((item) => {
// Try to get featureId from price
let featureId: string | null = getFeatureIdByPriceId(item.price.id) || null;
// If no match, try to preserve existing featureId
if (!featureId) {
const existingItem = existingItems.find(

View File

@@ -31,7 +31,8 @@ import {
verifyUserHasAction,
verifyUserIsServerAdmin,
verifySiteAccess,
verifyClientAccess
verifyClientAccess,
verifyLimits
} from "@server/middlewares";
import { ActionsEnum } from "@server/auth/actions";
import {
@@ -79,6 +80,7 @@ authenticated.put(
verifyValidLicense,
verifyValidSubscription(tierMatrix.orgOidc),
verifyOrgAccess,
verifyLimits,
verifyUserHasAction(ActionsEnum.createIdp),
logActionAudit(ActionsEnum.createIdp),
orgIdp.createOrgOidcIdp
@@ -90,6 +92,7 @@ authenticated.post(
verifyValidSubscription(tierMatrix.orgOidc),
verifyOrgAccess,
verifyIdpAccess,
verifyLimits,
verifyUserHasAction(ActionsEnum.updateIdp),
logActionAudit(ActionsEnum.updateIdp),
orgIdp.updateOrgOidcIdp
@@ -138,6 +141,7 @@ authenticated.post(
verifyValidLicense,
verifyOrgAccess,
verifyCertificateAccess,
verifyLimits,
verifyUserHasAction(ActionsEnum.restartCertificate),
logActionAudit(ActionsEnum.restartCertificate),
certificates.restartCertificate
@@ -237,6 +241,7 @@ authenticated.put(
"/org/:orgId/remote-exit-node",
verifyValidLicense,
verifyOrgAccess,
verifyLimits,
verifyUserHasAction(ActionsEnum.createRemoteExitNode),
logActionAudit(ActionsEnum.createRemoteExitNode),
remoteExitNode.createRemoteExitNode
@@ -280,8 +285,9 @@ authenticated.delete(
authenticated.put(
"/org/:orgId/login-page",
verifyValidLicense,
verifyValidSubscription(tierMatrix.customAuthenticationDomain),
verifyValidSubscription(tierMatrix.loginPageDomain),
verifyOrgAccess,
verifyLimits,
verifyUserHasAction(ActionsEnum.createLoginPage),
logActionAudit(ActionsEnum.createLoginPage),
loginPage.createLoginPage
@@ -290,9 +296,10 @@ authenticated.put(
authenticated.post(
"/org/:orgId/login-page/:loginPageId",
verifyValidLicense,
verifyValidSubscription(tierMatrix.customAuthenticationDomain),
verifyValidSubscription(tierMatrix.loginPageDomain),
verifyOrgAccess,
verifyLoginPageAccess,
verifyLimits,
verifyUserHasAction(ActionsEnum.updateLoginPage),
logActionAudit(ActionsEnum.updateLoginPage),
loginPage.updateLoginPage
@@ -338,6 +345,7 @@ authenticated.put(
verifyValidLicense,
verifyValidSubscription(tierMatrix.deviceApprovals),
verifyOrgAccess,
verifyLimits,
verifyUserHasAction(ActionsEnum.updateApprovals),
logActionAudit(ActionsEnum.updateApprovals),
approval.processPendingApproval
@@ -358,6 +366,7 @@ authenticated.put(
verifyValidLicense,
verifyValidSubscription(tierMatrix.loginPageBranding),
verifyOrgAccess,
verifyLimits,
verifyUserHasAction(ActionsEnum.updateLoginPage),
logActionAudit(ActionsEnum.updateLoginPage),
loginPage.upsertLoginPageBranding
@@ -470,18 +479,20 @@ authenticated.get(
authenticated.post(
"/re-key/:clientId/regenerate-client-secret",
verifyClientAccess, // this is first to set the org id
verifyValidLicense,
verifyValidSubscription(tierMatrix.rotateCredentials),
verifyClientAccess, // this is first to set the org id
verifyLimits,
verifyUserHasAction(ActionsEnum.reGenerateSecret),
reKey.reGenerateClientSecret
);
authenticated.post(
"/re-key/:siteId/regenerate-site-secret",
verifySiteAccess, // this is first to set the org id
verifyValidLicense,
verifyValidSubscription(tierMatrix.rotateCredentials),
verifySiteAccess, // this is first to set the org id
verifyLimits,
verifyUserHasAction(ActionsEnum.reGenerateSecret),
reKey.reGenerateSiteSecret
);
@@ -491,6 +502,7 @@ authenticated.put(
verifyValidLicense,
verifyValidSubscription(tierMatrix.rotateCredentials),
verifyOrgAccess,
verifyLimits,
verifyUserHasAction(ActionsEnum.reGenerateSecret),
reKey.reGenerateExitNodeSecret
);

View File

@@ -19,21 +19,20 @@ import {
verifyApiKeyHasAction,
verifyApiKeyIsRoot,
verifyApiKeyOrgAccess,
verifyApiKeyIdpAccess
verifyApiKeyIdpAccess,
verifyLimits
} from "@server/middlewares";
import {
verifyValidSubscription,
verifyValidLicense
} from "#private/middlewares";
import { ActionsEnum } from "@server/auth/actions";
import {
unauthenticated as ua,
authenticated as a
} from "@server/routers/integration";
import { logActionAudit } from "#private/middlewares";
import config from "#private/lib/config";
import { build } from "@server/build";
import { tierMatrix } from "@server/lib/billing/tierMatrix";
export const unauthenticated = ua;
export const authenticated = a;
@@ -57,7 +56,7 @@ authenticated.delete(
authenticated.get(
"/org/:orgId/logs/action",
verifyValidLicense,
verifyValidSubscription,
verifyValidSubscription(tierMatrix.actionLogs),
verifyApiKeyOrgAccess,
verifyApiKeyHasAction(ActionsEnum.exportLogs),
logs.queryActionAuditLogs
@@ -66,7 +65,7 @@ authenticated.get(
authenticated.get(
"/org/:orgId/logs/action/export",
verifyValidLicense,
verifyValidSubscription,
verifyValidSubscription(tierMatrix.logExport),
verifyApiKeyOrgAccess,
verifyApiKeyHasAction(ActionsEnum.exportLogs),
logActionAudit(ActionsEnum.exportLogs),
@@ -76,7 +75,7 @@ authenticated.get(
authenticated.get(
"/org/:orgId/logs/access",
verifyValidLicense,
verifyValidSubscription,
verifyValidSubscription(tierMatrix.accessLogs),
verifyApiKeyOrgAccess,
verifyApiKeyHasAction(ActionsEnum.exportLogs),
logs.queryAccessAuditLogs
@@ -85,7 +84,7 @@ authenticated.get(
authenticated.get(
"/org/:orgId/logs/access/export",
verifyValidLicense,
verifyValidSubscription,
verifyValidSubscription(tierMatrix.logExport),
verifyApiKeyOrgAccess,
verifyApiKeyHasAction(ActionsEnum.exportLogs),
logActionAudit(ActionsEnum.exportLogs),
@@ -95,7 +94,9 @@ authenticated.get(
authenticated.put(
"/org/:orgId/idp/oidc",
verifyValidLicense,
verifyValidSubscription(tierMatrix.orgOidc),
verifyApiKeyOrgAccess,
verifyLimits,
verifyApiKeyHasAction(ActionsEnum.createIdp),
logActionAudit(ActionsEnum.createIdp),
orgIdp.createOrgOidcIdp
@@ -104,8 +105,10 @@ authenticated.put(
authenticated.post(
"/org/:orgId/idp/:idpId/oidc",
verifyValidLicense,
verifyValidSubscription(tierMatrix.orgOidc),
verifyApiKeyOrgAccess,
verifyApiKeyIdpAccess,
verifyLimits,
verifyApiKeyHasAction(ActionsEnum.updateIdp),
logActionAudit(ActionsEnum.updateIdp),
orgIdp.updateOrgOidcIdp

View File

@@ -25,6 +25,8 @@ import { generateOidcRedirectUrl } from "@server/lib/idp/generateRedirectUrl";
import { encrypt } from "@server/lib/crypto";
import config from "@server/lib/config";
import { CreateOrgIdpResponse } from "@server/routers/orgIdp/types";
import { isSubscribed } from "#dynamic/lib/isSubscribed";
import { tierMatrix } from "@server/lib/billing/tierMatrix";
const paramsSchema = z.strictObject({ orgId: z.string().nonempty() });
@@ -100,12 +102,21 @@ export async function createOrgOidcIdp(
emailPath,
namePath,
name,
autoProvision,
variant,
roleMapping,
tags
} = parsedBody.data;
let { autoProvision } = parsedBody.data;
const subscribed = await isSubscribed(
orgId,
tierMatrix.deviceApprovals
);
if (!subscribed) {
autoProvision = false;
}
const key = config.getRawConfig().server.secret!;
const encryptedSecret = encrypt(clientSecret, key);

View File

@@ -24,6 +24,8 @@ import { idp, idpOidcConfig } from "@server/db";
import { eq, and } from "drizzle-orm";
import { encrypt } from "@server/lib/crypto";
import config from "@server/lib/config";
import { isSubscribed } from "#dynamic/lib/isSubscribed";
import { tierMatrix } from "@server/lib/billing/tierMatrix";
const paramsSchema = z
.object({
@@ -106,11 +108,20 @@ export async function updateOrgOidcIdp(
emailPath,
namePath,
name,
autoProvision,
roleMapping,
tags
} = parsedBody.data;
let { autoProvision } = parsedBody.data;
const subscribed = await isSubscribed(
orgId,
tierMatrix.deviceApprovals
);
if (!subscribed) {
autoProvision = false;
}
// Check if IDP exists and is of type OIDC
const [existingIdp] = await db
.select()

View File

@@ -17,7 +17,7 @@ import {
ResourceHeaderAuthExtendedCompatibility,
ResourcePassword,
ResourcePincode,
ResourceRule,
ResourceRule
} from "@server/db";
import config from "@server/lib/config";
import { isIpInCidr, stripPortFromHost } from "@server/lib/ip";
@@ -40,6 +40,7 @@ import { logRequestAudit } from "./logRequestAudit";
import cache from "@server/lib/cache";
import { APP_VERSION } from "@server/lib/consts";
import { isSubscribed } from "#private/lib/isSubscribed";
import { tierMatrix } from "@server/lib/billing/tierMatrix";
const verifyResourceSessionSchema = z.object({
sessions: z.record(z.string(), z.string()).optional(),
@@ -796,7 +797,10 @@ async function notAllowed(
) {
let loginPage: LoginPage | null = null;
if (orgId) {
const subscribed = await isSubscribed(orgId);
const subscribed = await isSubscribed(
orgId,
tierMatrix.loginPageDomain
);
if (subscribed) {
loginPage = await getOrgLoginPage(orgId);
}
@@ -850,7 +854,7 @@ async function headerAuthChallenged(
) {
let loginPage: LoginPage | null = null;
if (orgId) {
const subscribed = await isSubscribed(orgId);
const subscribed = await isSubscribed(orgId, tierMatrix.loginPageDomain);
if (subscribed) {
loginPage = await getOrgLoginPage(orgId);
}
@@ -1037,7 +1041,11 @@ export function isPathAllowed(pattern: string, path: string): boolean {
const MAX_RECURSION_DEPTH = 100;
// Recursive function to try different wildcard matches
function matchSegments(patternIndex: number, pathIndex: number, depth: number = 0): boolean {
function matchSegments(
patternIndex: number,
pathIndex: number,
depth: number = 0
): boolean {
// Check recursion depth limit
if (depth > MAX_RECURSION_DEPTH) {
logger.warn(
@@ -1123,7 +1131,11 @@ export function isPathAllowed(pattern: string, path: string): boolean {
logger.debug(
`${indent}Segment with wildcard matches: "${currentPatternPart}" matches "${currentPathPart}"`
);
return matchSegments(patternIndex + 1, pathIndex + 1, depth + 1);
return matchSegments(
patternIndex + 1,
pathIndex + 1,
depth + 1
);
}
logger.debug(

View File

@@ -101,7 +101,7 @@ export async function createClient(
return next(
createHttpError(
HttpCode.BAD_REQUEST,
"Invalid subnet format. Please provide a valid CIDR notation."
"Invalid subnet format. Please provide a valid IP."
)
);
}

View File

@@ -41,7 +41,8 @@ import {
verifyUserHasAction,
verifyUserIsOrgOwner,
verifySiteResourceAccess,
verifyOlmAccess
verifyOlmAccess,
verifyLimits
} from "@server/middlewares";
import { ActionsEnum } from "@server/auth/actions";
import rateLimit, { ipKeyGenerator } from "express-rate-limit";
@@ -79,6 +80,7 @@ authenticated.get(
authenticated.post(
"/org/:orgId",
verifyOrgAccess,
verifyLimits,
verifyUserHasAction(ActionsEnum.updateOrg),
logActionAudit(ActionsEnum.updateOrg),
org.updateOrg
@@ -161,6 +163,7 @@ authenticated.get(
authenticated.put(
"/org/:orgId/client",
verifyOrgAccess,
verifyLimits,
verifyUserHasAction(ActionsEnum.createClient),
logActionAudit(ActionsEnum.createClient),
client.createClient
@@ -178,6 +181,7 @@ authenticated.delete(
authenticated.post(
"/client/:clientId/archive",
verifyClientAccess,
verifyLimits,
verifyUserHasAction(ActionsEnum.archiveClient),
logActionAudit(ActionsEnum.archiveClient),
client.archiveClient
@@ -186,6 +190,7 @@ authenticated.post(
authenticated.post(
"/client/:clientId/unarchive",
verifyClientAccess,
verifyLimits,
verifyUserHasAction(ActionsEnum.unarchiveClient),
logActionAudit(ActionsEnum.unarchiveClient),
client.unarchiveClient
@@ -194,6 +199,7 @@ authenticated.post(
authenticated.post(
"/client/:clientId/block",
verifyClientAccess,
verifyLimits,
verifyUserHasAction(ActionsEnum.blockClient),
logActionAudit(ActionsEnum.blockClient),
client.blockClient
@@ -202,6 +208,7 @@ authenticated.post(
authenticated.post(
"/client/:clientId/unblock",
verifyClientAccess,
verifyLimits,
verifyUserHasAction(ActionsEnum.unblockClient),
logActionAudit(ActionsEnum.unblockClient),
client.unblockClient
@@ -210,6 +217,7 @@ authenticated.post(
authenticated.post(
"/client/:clientId",
verifyClientAccess, // this will check if the user has access to the client
verifyLimits,
verifyUserHasAction(ActionsEnum.updateClient), // this will check if the user has permission to update the client
logActionAudit(ActionsEnum.updateClient),
client.updateClient
@@ -224,6 +232,7 @@ authenticated.post(
authenticated.post(
"/site/:siteId",
verifySiteAccess,
verifyLimits,
verifyUserHasAction(ActionsEnum.updateSite),
logActionAudit(ActionsEnum.updateSite),
site.updateSite
@@ -273,6 +282,7 @@ authenticated.get(
authenticated.put(
"/org/:orgId/site-resource",
verifyOrgAccess,
verifyLimits,
verifyUserHasAction(ActionsEnum.createSiteResource),
logActionAudit(ActionsEnum.createSiteResource),
siteResource.createSiteResource
@@ -303,6 +313,7 @@ authenticated.get(
authenticated.post(
"/site-resource/:siteResourceId",
verifySiteResourceAccess,
verifyLimits,
verifyUserHasAction(ActionsEnum.updateSiteResource),
logActionAudit(ActionsEnum.updateSiteResource),
siteResource.updateSiteResource
@@ -341,6 +352,7 @@ authenticated.post(
"/site-resource/:siteResourceId/roles",
verifySiteResourceAccess,
verifyRoleAccess,
verifyLimits,
verifyUserHasAction(ActionsEnum.setResourceRoles),
logActionAudit(ActionsEnum.setResourceRoles),
siteResource.setSiteResourceRoles
@@ -350,6 +362,7 @@ authenticated.post(
"/site-resource/:siteResourceId/users",
verifySiteResourceAccess,
verifySetResourceUsers,
verifyLimits,
verifyUserHasAction(ActionsEnum.setResourceUsers),
logActionAudit(ActionsEnum.setResourceUsers),
siteResource.setSiteResourceUsers
@@ -359,6 +372,7 @@ authenticated.post(
"/site-resource/:siteResourceId/clients",
verifySiteResourceAccess,
verifySetResourceClients,
verifyLimits,
verifyUserHasAction(ActionsEnum.setResourceUsers),
logActionAudit(ActionsEnum.setResourceUsers),
siteResource.setSiteResourceClients
@@ -368,6 +382,7 @@ authenticated.post(
"/site-resource/:siteResourceId/clients/add",
verifySiteResourceAccess,
verifySetResourceClients,
verifyLimits,
verifyUserHasAction(ActionsEnum.setResourceUsers),
logActionAudit(ActionsEnum.setResourceUsers),
siteResource.addClientToSiteResource
@@ -377,6 +392,7 @@ authenticated.post(
"/site-resource/:siteResourceId/clients/remove",
verifySiteResourceAccess,
verifySetResourceClients,
verifyLimits,
verifyUserHasAction(ActionsEnum.setResourceUsers),
logActionAudit(ActionsEnum.setResourceUsers),
siteResource.removeClientFromSiteResource
@@ -385,6 +401,7 @@ authenticated.post(
authenticated.put(
"/org/:orgId/resource",
verifyOrgAccess,
verifyLimits,
verifyUserHasAction(ActionsEnum.createResource),
logActionAudit(ActionsEnum.createResource),
resource.createResource
@@ -499,6 +516,7 @@ authenticated.get(
authenticated.post(
"/resource/:resourceId",
verifyResourceAccess,
verifyLimits,
verifyUserHasAction(ActionsEnum.updateResource),
logActionAudit(ActionsEnum.updateResource),
resource.updateResource
@@ -514,6 +532,7 @@ authenticated.delete(
authenticated.put(
"/resource/:resourceId/target",
verifyResourceAccess,
verifyLimits,
verifyUserHasAction(ActionsEnum.createTarget),
logActionAudit(ActionsEnum.createTarget),
target.createTarget
@@ -528,6 +547,7 @@ authenticated.get(
authenticated.put(
"/resource/:resourceId/rule",
verifyResourceAccess,
verifyLimits,
verifyUserHasAction(ActionsEnum.createResourceRule),
logActionAudit(ActionsEnum.createResourceRule),
resource.createResourceRule
@@ -541,6 +561,7 @@ authenticated.get(
authenticated.post(
"/resource/:resourceId/rule/:ruleId",
verifyResourceAccess,
verifyLimits,
verifyUserHasAction(ActionsEnum.updateResourceRule),
logActionAudit(ActionsEnum.updateResourceRule),
resource.updateResourceRule
@@ -562,6 +583,7 @@ authenticated.get(
authenticated.post(
"/target/:targetId",
verifyTargetAccess,
verifyLimits,
verifyUserHasAction(ActionsEnum.updateTarget),
logActionAudit(ActionsEnum.updateTarget),
target.updateTarget
@@ -577,6 +599,7 @@ authenticated.delete(
authenticated.put(
"/org/:orgId/role",
verifyOrgAccess,
verifyLimits,
verifyUserHasAction(ActionsEnum.createRole),
logActionAudit(ActionsEnum.createRole),
role.createRole
@@ -591,6 +614,7 @@ authenticated.get(
authenticated.post(
"/role/:roleId",
verifyRoleAccess,
verifyLimits,
verifyUserHasAction(ActionsEnum.updateRole),
logActionAudit(ActionsEnum.updateRole),
role.updateRole
@@ -619,6 +643,7 @@ authenticated.post(
"/role/:roleId/add/:userId",
verifyRoleAccess,
verifyUserAccess,
verifyLimits,
verifyUserHasAction(ActionsEnum.addUserRole),
logActionAudit(ActionsEnum.addUserRole),
user.addUserRole
@@ -628,6 +653,7 @@ authenticated.post(
"/resource/:resourceId/roles",
verifyResourceAccess,
verifyRoleAccess,
verifyLimits,
verifyUserHasAction(ActionsEnum.setResourceRoles),
logActionAudit(ActionsEnum.setResourceRoles),
resource.setResourceRoles
@@ -637,6 +663,7 @@ authenticated.post(
"/resource/:resourceId/users",
verifyResourceAccess,
verifySetResourceUsers,
verifyLimits,
verifyUserHasAction(ActionsEnum.setResourceUsers),
logActionAudit(ActionsEnum.setResourceUsers),
resource.setResourceUsers
@@ -645,6 +672,7 @@ authenticated.post(
authenticated.post(
`/resource/:resourceId/password`,
verifyResourceAccess,
verifyLimits,
verifyUserHasAction(ActionsEnum.setResourcePassword),
logActionAudit(ActionsEnum.setResourcePassword),
resource.setResourcePassword
@@ -653,6 +681,7 @@ authenticated.post(
authenticated.post(
`/resource/:resourceId/pincode`,
verifyResourceAccess,
verifyLimits,
verifyUserHasAction(ActionsEnum.setResourcePincode),
logActionAudit(ActionsEnum.setResourcePincode),
resource.setResourcePincode
@@ -661,6 +690,7 @@ authenticated.post(
authenticated.post(
`/resource/:resourceId/header-auth`,
verifyResourceAccess,
verifyLimits,
verifyUserHasAction(ActionsEnum.setResourceHeaderAuth),
logActionAudit(ActionsEnum.setResourceHeaderAuth),
resource.setResourceHeaderAuth
@@ -669,6 +699,7 @@ authenticated.post(
authenticated.post(
`/resource/:resourceId/whitelist`,
verifyResourceAccess,
verifyLimits,
verifyUserHasAction(ActionsEnum.setResourceWhitelist),
logActionAudit(ActionsEnum.setResourceWhitelist),
resource.setResourceWhitelist
@@ -684,6 +715,7 @@ authenticated.get(
authenticated.post(
`/resource/:resourceId/access-token`,
verifyResourceAccess,
verifyLimits,
verifyUserHasAction(ActionsEnum.generateAccessToken),
logActionAudit(ActionsEnum.generateAccessToken),
accessToken.generateAccessToken
@@ -774,6 +806,7 @@ authenticated.delete(
authenticated.put(
"/org/:orgId/user",
verifyOrgAccess,
verifyLimits,
verifyUserHasAction(ActionsEnum.createOrgUser),
logActionAudit(ActionsEnum.createOrgUser),
user.createOrgUser
@@ -783,6 +816,7 @@ authenticated.post(
"/org/:orgId/user/:userId",
verifyOrgAccess,
verifyUserAccess,
verifyLimits,
verifyUserHasAction(ActionsEnum.updateOrgUser),
logActionAudit(ActionsEnum.updateOrgUser),
user.updateOrgUser
@@ -855,6 +889,7 @@ authenticated.post(
"/user/:userId/olm/:olmId/archive",
verifyIsLoggedInUser,
verifyOlmAccess,
verifyLimits,
olm.archiveUserOlm
);
@@ -969,6 +1004,7 @@ authenticated.post(
`/org/:orgId/api-key/:apiKeyId/actions`,
verifyOrgAccess,
verifyApiKeyAccess,
verifyLimits,
verifyUserHasAction(ActionsEnum.setApiKeyActions),
logActionAudit(ActionsEnum.setApiKeyActions),
apiKeys.setApiKeyActions
@@ -985,6 +1021,7 @@ authenticated.get(
authenticated.put(
`/org/:orgId/api-key`,
verifyOrgAccess,
verifyLimits,
verifyUserHasAction(ActionsEnum.createApiKey),
logActionAudit(ActionsEnum.createApiKey),
apiKeys.createOrgApiKey
@@ -1010,6 +1047,7 @@ authenticated.get(
authenticated.put(
`/org/:orgId/domain`,
verifyOrgAccess,
verifyLimits,
verifyUserHasAction(ActionsEnum.createOrgDomain),
logActionAudit(ActionsEnum.createOrgDomain),
domain.createOrgDomain
@@ -1019,6 +1057,7 @@ authenticated.post(
`/org/:orgId/domain/:domainId/restart`,
verifyOrgAccess,
verifyDomainAccess,
verifyLimits,
verifyUserHasAction(ActionsEnum.restartOrgDomain),
logActionAudit(ActionsEnum.restartOrgDomain),
domain.restartOrgDomain
@@ -1065,6 +1104,7 @@ authenticated.get(
authenticated.put(
"/org/:orgId/blueprint",
verifyOrgAccess,
verifyLimits,
verifyUserHasAction(ActionsEnum.applyBlueprint),
blueprints.applyYAMLBlueprint
);

View File

@@ -93,7 +93,9 @@ export async function createOidcIdp(
name,
autoProvision,
type: "oidc",
tags
tags,
defaultOrgMapping: `'{{orgId}}'`,
defaultRoleMapping: `'Member'`
})
.returning();

View File

@@ -15,6 +15,7 @@ import config from "@server/lib/config";
import { decrypt } from "@server/lib/crypto";
import { build } from "@server/build";
import { isSubscribed } from "#dynamic/lib/isSubscribed";
import { tierMatrix } from "@server/lib/billing/tierMatrix";
const paramsSchema = z
.object({
@@ -112,7 +113,10 @@ export async function generateOidcUrl(
}
if (build === "saas") {
const subscribed = await isSubscribed(orgId);
const subscribed = await isSubscribed(
orgId,
tierMatrix.orgOidc
);
if (!subscribed) {
return next(
createHttpError(

View File

@@ -34,6 +34,8 @@ import { FeatureId } from "@server/lib/billing";
import { usageService } from "@server/lib/billing/usageService";
import { build } from "@server/build";
import { calculateUserClientsForOrgs } from "@server/lib/calculateUserClientsForOrgs";
import { isSubscribed } from "#dynamic/lib/isSubscribed";
import { tierMatrix } from "@server/lib/billing/tierMatrix";
const ensureTrailingSlash = (url: string): string => {
return url;
@@ -326,6 +328,33 @@ export async function validateOidcCallback(
.where(eq(idpOrg.idpId, existingIdp.idp.idpId))
.innerJoin(orgs, eq(orgs.orgId, idpOrg.orgId));
allOrgs = idpOrgs.map((o) => o.orgs);
// TODO: when there are multiple orgs we need to do this better!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!1
if (allOrgs.length > 1) {
// for some reason there is more than one org
logger.error(
"More than one organization linked to this IdP. This should not happen with auto-provisioning enabled."
);
return next(
createHttpError(
HttpCode.INTERNAL_SERVER_ERROR,
"Multiple organizations linked to this IdP. Please contact support."
)
);
}
const subscribed = await isSubscribed(
allOrgs[0].orgId,
tierMatrix.autoProvisioning
);
if (subscribed) {
return next(
createHttpError(
HttpCode.FORBIDDEN,
"This organization's current plan does not support this feature."
)
);
}
} else {
allOrgs = await db.select().from(orgs);
}

View File

@@ -26,7 +26,8 @@ import {
verifyApiKeyIsRoot,
verifyApiKeyClientAccess,
verifyApiKeySiteResourceAccess,
verifyApiKeySetResourceClients
verifyApiKeySetResourceClients,
verifyLimits
} from "@server/middlewares";
import HttpCode from "@server/types/HttpCode";
import { Router } from "express";
@@ -74,6 +75,7 @@ authenticated.get(
authenticated.post(
"/org/:orgId",
verifyApiKeyOrgAccess,
verifyLimits,
verifyApiKeyHasAction(ActionsEnum.updateOrg),
logActionAudit(ActionsEnum.updateOrg),
org.updateOrg
@@ -90,6 +92,7 @@ authenticated.delete(
authenticated.put(
"/org/:orgId/site",
verifyApiKeyOrgAccess,
verifyLimits,
verifyApiKeyHasAction(ActionsEnum.createSite),
logActionAudit(ActionsEnum.createSite),
site.createSite
@@ -126,6 +129,7 @@ authenticated.get(
authenticated.post(
"/site/:siteId",
verifyApiKeySiteAccess,
verifyLimits,
verifyApiKeyHasAction(ActionsEnum.updateSite),
logActionAudit(ActionsEnum.updateSite),
site.updateSite
@@ -146,8 +150,9 @@ authenticated.get(
);
// Site Resource endpoints
authenticated.put(
"/org/:orgId/private-resource",
"/org/:orgId/site-resource",
verifyApiKeyOrgAccess,
verifyLimits,
verifyApiKeyHasAction(ActionsEnum.createSiteResource),
logActionAudit(ActionsEnum.createSiteResource),
siteResource.createSiteResource
@@ -178,6 +183,7 @@ authenticated.get(
authenticated.post(
"/site-resource/:siteResourceId",
verifyApiKeySiteResourceAccess,
verifyLimits,
verifyApiKeyHasAction(ActionsEnum.updateSiteResource),
logActionAudit(ActionsEnum.updateSiteResource),
siteResource.updateSiteResource
@@ -216,6 +222,7 @@ authenticated.post(
"/site-resource/:siteResourceId/roles",
verifyApiKeySiteResourceAccess,
verifyApiKeyRoleAccess,
verifyLimits,
verifyApiKeyHasAction(ActionsEnum.setResourceRoles),
logActionAudit(ActionsEnum.setResourceRoles),
siteResource.setSiteResourceRoles
@@ -225,6 +232,7 @@ authenticated.post(
"/site-resource/:siteResourceId/users",
verifyApiKeySiteResourceAccess,
verifyApiKeySetResourceUsers,
verifyLimits,
verifyApiKeyHasAction(ActionsEnum.setResourceUsers),
logActionAudit(ActionsEnum.setResourceUsers),
siteResource.setSiteResourceUsers
@@ -234,6 +242,7 @@ authenticated.post(
"/site-resource/:siteResourceId/roles/add",
verifyApiKeySiteResourceAccess,
verifyApiKeyRoleAccess,
verifyLimits,
verifyApiKeyHasAction(ActionsEnum.setResourceRoles),
logActionAudit(ActionsEnum.setResourceRoles),
siteResource.addRoleToSiteResource
@@ -243,6 +252,7 @@ authenticated.post(
"/site-resource/:siteResourceId/roles/remove",
verifyApiKeySiteResourceAccess,
verifyApiKeyRoleAccess,
verifyLimits,
verifyApiKeyHasAction(ActionsEnum.setResourceRoles),
logActionAudit(ActionsEnum.setResourceRoles),
siteResource.removeRoleFromSiteResource
@@ -252,6 +262,7 @@ authenticated.post(
"/site-resource/:siteResourceId/users/add",
verifyApiKeySiteResourceAccess,
verifyApiKeySetResourceUsers,
verifyLimits,
verifyApiKeyHasAction(ActionsEnum.setResourceUsers),
logActionAudit(ActionsEnum.setResourceUsers),
siteResource.addUserToSiteResource
@@ -261,6 +272,7 @@ authenticated.post(
"/site-resource/:siteResourceId/users/remove",
verifyApiKeySiteResourceAccess,
verifyApiKeySetResourceUsers,
verifyLimits,
verifyApiKeyHasAction(ActionsEnum.setResourceUsers),
logActionAudit(ActionsEnum.setResourceUsers),
siteResource.removeUserFromSiteResource
@@ -270,6 +282,7 @@ authenticated.post(
"/site-resource/:siteResourceId/clients",
verifyApiKeySiteResourceAccess,
verifyApiKeySetResourceClients,
verifyLimits,
verifyApiKeyHasAction(ActionsEnum.setResourceUsers),
logActionAudit(ActionsEnum.setResourceUsers),
siteResource.setSiteResourceClients
@@ -279,6 +292,7 @@ authenticated.post(
"/site-resource/:siteResourceId/clients/add",
verifyApiKeySiteResourceAccess,
verifyApiKeySetResourceClients,
verifyLimits,
verifyApiKeyHasAction(ActionsEnum.setResourceUsers),
logActionAudit(ActionsEnum.setResourceUsers),
siteResource.addClientToSiteResource
@@ -288,6 +302,7 @@ authenticated.post(
"/site-resource/:siteResourceId/clients/remove",
verifyApiKeySiteResourceAccess,
verifyApiKeySetResourceClients,
verifyLimits,
verifyApiKeyHasAction(ActionsEnum.setResourceUsers),
logActionAudit(ActionsEnum.setResourceUsers),
siteResource.removeClientFromSiteResource
@@ -296,6 +311,7 @@ authenticated.post(
authenticated.put(
"/org/:orgId/resource",
verifyApiKeyOrgAccess,
verifyLimits,
verifyApiKeyHasAction(ActionsEnum.createResource),
logActionAudit(ActionsEnum.createResource),
resource.createResource
@@ -304,6 +320,7 @@ authenticated.put(
authenticated.put(
"/org/:orgId/site/:siteId/resource",
verifyApiKeyOrgAccess,
verifyLimits,
verifyApiKeyHasAction(ActionsEnum.createResource),
logActionAudit(ActionsEnum.createResource),
resource.createResource
@@ -340,6 +357,7 @@ authenticated.get(
authenticated.post(
"/org/:orgId/create-invite",
verifyApiKeyOrgAccess,
verifyLimits,
verifyApiKeyHasAction(ActionsEnum.inviteUser),
logActionAudit(ActionsEnum.inviteUser),
user.inviteUser
@@ -377,6 +395,7 @@ authenticated.get(
authenticated.post(
"/resource/:resourceId",
verifyApiKeyResourceAccess,
verifyLimits,
verifyApiKeyHasAction(ActionsEnum.updateResource),
logActionAudit(ActionsEnum.updateResource),
resource.updateResource
@@ -393,6 +412,7 @@ authenticated.delete(
authenticated.put(
"/resource/:resourceId/target",
verifyApiKeyResourceAccess,
verifyLimits,
verifyApiKeyHasAction(ActionsEnum.createTarget),
logActionAudit(ActionsEnum.createTarget),
target.createTarget
@@ -408,6 +428,7 @@ authenticated.get(
authenticated.put(
"/resource/:resourceId/rule",
verifyApiKeyResourceAccess,
verifyLimits,
verifyApiKeyHasAction(ActionsEnum.createResourceRule),
logActionAudit(ActionsEnum.createResourceRule),
resource.createResourceRule
@@ -423,6 +444,7 @@ authenticated.get(
authenticated.post(
"/resource/:resourceId/rule/:ruleId",
verifyApiKeyResourceAccess,
verifyLimits,
verifyApiKeyHasAction(ActionsEnum.updateResourceRule),
logActionAudit(ActionsEnum.updateResourceRule),
resource.updateResourceRule
@@ -446,6 +468,7 @@ authenticated.get(
authenticated.post(
"/target/:targetId",
verifyApiKeyTargetAccess,
verifyLimits,
verifyApiKeyHasAction(ActionsEnum.updateTarget),
logActionAudit(ActionsEnum.updateTarget),
target.updateTarget
@@ -462,6 +485,7 @@ authenticated.delete(
authenticated.put(
"/org/:orgId/role",
verifyApiKeyOrgAccess,
verifyLimits,
verifyApiKeyHasAction(ActionsEnum.createRole),
logActionAudit(ActionsEnum.createRole),
role.createRole
@@ -470,6 +494,7 @@ authenticated.put(
authenticated.post(
"/role/:roleId",
verifyApiKeyRoleAccess,
verifyLimits,
verifyApiKeyHasAction(ActionsEnum.updateRole),
logActionAudit(ActionsEnum.updateRole),
role.updateRole
@@ -501,6 +526,7 @@ authenticated.post(
"/role/:roleId/add/:userId",
verifyApiKeyRoleAccess,
verifyApiKeyUserAccess,
verifyLimits,
verifyApiKeyHasAction(ActionsEnum.addUserRole),
logActionAudit(ActionsEnum.addUserRole),
user.addUserRole
@@ -510,6 +536,7 @@ authenticated.post(
"/resource/:resourceId/roles",
verifyApiKeyResourceAccess,
verifyApiKeyRoleAccess,
verifyLimits,
verifyApiKeyHasAction(ActionsEnum.setResourceRoles),
logActionAudit(ActionsEnum.setResourceRoles),
resource.setResourceRoles
@@ -519,6 +546,7 @@ authenticated.post(
"/resource/:resourceId/users",
verifyApiKeyResourceAccess,
verifyApiKeySetResourceUsers,
verifyLimits,
verifyApiKeyHasAction(ActionsEnum.setResourceUsers),
logActionAudit(ActionsEnum.setResourceUsers),
resource.setResourceUsers
@@ -528,6 +556,7 @@ authenticated.post(
"/resource/:resourceId/roles/add",
verifyApiKeyResourceAccess,
verifyApiKeyRoleAccess,
verifyLimits,
verifyApiKeyHasAction(ActionsEnum.setResourceRoles),
logActionAudit(ActionsEnum.setResourceRoles),
resource.addRoleToResource
@@ -537,6 +566,7 @@ authenticated.post(
"/resource/:resourceId/roles/remove",
verifyApiKeyResourceAccess,
verifyApiKeyRoleAccess,
verifyLimits,
verifyApiKeyHasAction(ActionsEnum.setResourceRoles),
logActionAudit(ActionsEnum.setResourceRoles),
resource.removeRoleFromResource
@@ -546,6 +576,7 @@ authenticated.post(
"/resource/:resourceId/users/add",
verifyApiKeyResourceAccess,
verifyApiKeySetResourceUsers,
verifyLimits,
verifyApiKeyHasAction(ActionsEnum.setResourceUsers),
logActionAudit(ActionsEnum.setResourceUsers),
resource.addUserToResource
@@ -555,6 +586,7 @@ authenticated.post(
"/resource/:resourceId/users/remove",
verifyApiKeyResourceAccess,
verifyApiKeySetResourceUsers,
verifyLimits,
verifyApiKeyHasAction(ActionsEnum.setResourceUsers),
logActionAudit(ActionsEnum.setResourceUsers),
resource.removeUserFromResource
@@ -563,6 +595,7 @@ authenticated.post(
authenticated.post(
`/resource/:resourceId/password`,
verifyApiKeyResourceAccess,
verifyLimits,
verifyApiKeyHasAction(ActionsEnum.setResourcePassword),
logActionAudit(ActionsEnum.setResourcePassword),
resource.setResourcePassword
@@ -571,6 +604,7 @@ authenticated.post(
authenticated.post(
`/resource/:resourceId/pincode`,
verifyApiKeyResourceAccess,
verifyLimits,
verifyApiKeyHasAction(ActionsEnum.setResourcePincode),
logActionAudit(ActionsEnum.setResourcePincode),
resource.setResourcePincode
@@ -579,6 +613,7 @@ authenticated.post(
authenticated.post(
`/resource/:resourceId/header-auth`,
verifyApiKeyResourceAccess,
verifyLimits,
verifyApiKeyHasAction(ActionsEnum.setResourceHeaderAuth),
logActionAudit(ActionsEnum.setResourceHeaderAuth),
resource.setResourceHeaderAuth
@@ -587,6 +622,7 @@ authenticated.post(
authenticated.post(
`/resource/:resourceId/whitelist`,
verifyApiKeyResourceAccess,
verifyLimits,
verifyApiKeyHasAction(ActionsEnum.setResourceWhitelist),
logActionAudit(ActionsEnum.setResourceWhitelist),
resource.setResourceWhitelist
@@ -595,6 +631,7 @@ authenticated.post(
authenticated.post(
`/resource/:resourceId/whitelist/add`,
verifyApiKeyResourceAccess,
verifyLimits,
verifyApiKeyHasAction(ActionsEnum.setResourceWhitelist),
resource.addEmailToResourceWhitelist
);
@@ -602,6 +639,7 @@ authenticated.post(
authenticated.post(
`/resource/:resourceId/whitelist/remove`,
verifyApiKeyResourceAccess,
verifyLimits,
verifyApiKeyHasAction(ActionsEnum.setResourceWhitelist),
resource.removeEmailFromResourceWhitelist
);
@@ -616,6 +654,7 @@ authenticated.get(
authenticated.post(
`/resource/:resourceId/access-token`,
verifyApiKeyResourceAccess,
verifyLimits,
verifyApiKeyHasAction(ActionsEnum.generateAccessToken),
logActionAudit(ActionsEnum.generateAccessToken),
accessToken.generateAccessToken
@@ -653,6 +692,7 @@ authenticated.get(
authenticated.post(
"/user/:userId/2fa",
verifyApiKeyIsRoot,
verifyLimits,
verifyApiKeyHasAction(ActionsEnum.updateUser),
logActionAudit(ActionsEnum.updateUser),
user.updateUser2FA
@@ -675,6 +715,7 @@ authenticated.get(
authenticated.put(
"/org/:orgId/user",
verifyApiKeyOrgAccess,
verifyLimits,
verifyApiKeyHasAction(ActionsEnum.createOrgUser),
logActionAudit(ActionsEnum.createOrgUser),
user.createOrgUser
@@ -684,6 +725,7 @@ authenticated.post(
"/org/:orgId/user/:userId",
verifyApiKeyOrgAccess,
verifyApiKeyUserAccess,
verifyLimits,
verifyApiKeyHasAction(ActionsEnum.updateOrgUser),
logActionAudit(ActionsEnum.updateOrgUser),
user.updateOrgUser
@@ -714,6 +756,7 @@ authenticated.get(
authenticated.post(
`/org/:orgId/api-key/:apiKeyId/actions`,
verifyApiKeyIsRoot,
verifyLimits,
verifyApiKeyHasAction(ActionsEnum.setApiKeyActions),
logActionAudit(ActionsEnum.setApiKeyActions),
apiKeys.setApiKeyActions
@@ -729,6 +772,7 @@ authenticated.get(
authenticated.put(
`/org/:orgId/api-key`,
verifyApiKeyIsRoot,
verifyLimits,
verifyApiKeyHasAction(ActionsEnum.createApiKey),
logActionAudit(ActionsEnum.createApiKey),
apiKeys.createOrgApiKey
@@ -745,6 +789,7 @@ authenticated.delete(
authenticated.put(
"/idp/oidc",
verifyApiKeyIsRoot,
verifyLimits,
verifyApiKeyHasAction(ActionsEnum.createIdp),
logActionAudit(ActionsEnum.createIdp),
idp.createOidcIdp
@@ -753,6 +798,7 @@ authenticated.put(
authenticated.post(
"/idp/:idpId/oidc",
verifyApiKeyIsRoot,
verifyLimits,
verifyApiKeyHasAction(ActionsEnum.updateIdp),
logActionAudit(ActionsEnum.updateIdp),
idp.updateOidcIdp
@@ -776,6 +822,7 @@ authenticated.get(
authenticated.put(
"/idp/:idpId/org/:orgId",
verifyApiKeyIsRoot,
verifyLimits,
verifyApiKeyHasAction(ActionsEnum.createIdpOrg),
logActionAudit(ActionsEnum.createIdpOrg),
idp.createIdpOrgPolicy
@@ -784,6 +831,7 @@ authenticated.put(
authenticated.post(
"/idp/:idpId/org/:orgId",
verifyApiKeyIsRoot,
verifyLimits,
verifyApiKeyHasAction(ActionsEnum.updateIdpOrg),
logActionAudit(ActionsEnum.updateIdpOrg),
idp.updateIdpOrgPolicy
@@ -828,6 +876,7 @@ authenticated.get(
authenticated.put(
"/org/:orgId/client",
verifyApiKeyOrgAccess,
verifyLimits,
verifyApiKeyHasAction(ActionsEnum.createClient),
logActionAudit(ActionsEnum.createClient),
client.createClient
@@ -854,6 +903,7 @@ authenticated.delete(
authenticated.post(
"/client/:clientId/archive",
verifyApiKeyClientAccess,
verifyLimits,
verifyApiKeyHasAction(ActionsEnum.archiveClient),
logActionAudit(ActionsEnum.archiveClient),
client.archiveClient
@@ -862,6 +912,7 @@ authenticated.post(
authenticated.post(
"/client/:clientId/unarchive",
verifyApiKeyClientAccess,
verifyLimits,
verifyApiKeyHasAction(ActionsEnum.unarchiveClient),
logActionAudit(ActionsEnum.unarchiveClient),
client.unarchiveClient
@@ -870,6 +921,7 @@ authenticated.post(
authenticated.post(
"/client/:clientId/block",
verifyApiKeyClientAccess,
verifyLimits,
verifyApiKeyHasAction(ActionsEnum.blockClient),
logActionAudit(ActionsEnum.blockClient),
client.blockClient
@@ -878,6 +930,7 @@ authenticated.post(
authenticated.post(
"/client/:clientId/unblock",
verifyApiKeyClientAccess,
verifyLimits,
verifyApiKeyHasAction(ActionsEnum.unblockClient),
logActionAudit(ActionsEnum.unblockClient),
client.unblockClient
@@ -886,6 +939,7 @@ authenticated.post(
authenticated.post(
"/client/:clientId",
verifyApiKeyClientAccess,
verifyLimits,
verifyApiKeyHasAction(ActionsEnum.updateClient),
logActionAudit(ActionsEnum.updateClient),
client.updateClient
@@ -894,6 +948,7 @@ authenticated.post(
authenticated.put(
"/org/:orgId/blueprint",
verifyApiKeyOrgAccess,
verifyLimits,
verifyApiKeyHasAction(ActionsEnum.applyBlueprint),
logActionAudit(ActionsEnum.applyBlueprint),
blueprints.applyJSONBlueprint

View File

@@ -18,7 +18,7 @@ import config from "@server/lib/config";
import { APP_VERSION } from "@server/lib/consts";
export const newtGetTokenBodySchema = z.object({
// newtId: z.string(),
newtId: z.string(),
secret: z.string(),
token: z.string().optional()
});

View File

@@ -24,6 +24,7 @@ import { createCertificate } from "#dynamic/routers/certificates/createCertifica
import { validateAndConstructDomain } from "@server/lib/domainUtils";
import { build } from "@server/build";
import { isLicensedOrSubscribed } from "#dynamic/lib/isLicencedOrSubscribed";
import { tierMatrix } from "@server/lib/billing/tierMatrix";
const updateResourceParamsSchema = z.strictObject({
resourceId: z.string().transform(Number).pipe(z.int().positive())
@@ -341,7 +342,7 @@ async function updateHttpResource(
headers = null;
}
const isLicensed = await isLicensedOrSubscribed(resource.orgId);
const isLicensed = await isLicensedOrSubscribed(resource.orgId, tierMatrix.maintencePage);
if (!isLicensed) {
updateData.maintenanceModeEnabled = undefined;
updateData.maintenanceModeType = undefined;

View File

@@ -12,6 +12,7 @@ import { eq, and } from "drizzle-orm";
import { OpenAPITags, registry } from "@server/openApi";
import { build } from "@server/build";
import { isLicensedOrSubscribed } from "#dynamic/lib/isLicencedOrSubscribed";
import { tierMatrix } from "@server/lib/billing/tierMatrix";
const createRoleParamsSchema = z.strictObject({
orgId: z.string()
@@ -100,7 +101,7 @@ export async function createRole(
);
}
const isLicensed = await isLicensedOrSubscribed(orgId);
const isLicensed = await isLicensedOrSubscribed(orgId, tierMatrix.deviceApprovals);
if (!isLicensed) {
roleData.requireDeviceApproval = undefined;
}

View File

@@ -10,6 +10,7 @@ import logger from "@server/logger";
import { fromError } from "zod-validation-error";
import { isLicensedOrSubscribed } from "#dynamic/lib/isLicencedOrSubscribed";
import { OpenAPITags, registry } from "@server/openApi";
import { tierMatrix } from "@server/lib/billing/tierMatrix";
const updateRoleParamsSchema = z.strictObject({
roleId: z.string().transform(Number).pipe(z.int().positive())
@@ -110,7 +111,7 @@ export async function updateRole(
);
}
const isLicensed = await isLicensedOrSubscribed(orgId);
const isLicensed = await isLicensedOrSubscribed(orgId, tierMatrix.deviceApprovals);
if (!isLicensed) {
updateData.requireDeviceApproval = undefined;
}

View File

@@ -15,6 +15,7 @@ import { FeatureId } from "@server/lib/billing";
import { build } from "@server/build";
import { calculateUserClientsForOrgs } from "@server/lib/calculateUserClientsForOrgs";
import { isSubscribed } from "#dynamic/lib/isSubscribed";
import { tierMatrix } from "@server/lib/billing/tierMatrix";
const paramsSchema = z.strictObject({
orgId: z.string().nonempty()
@@ -127,7 +128,10 @@ export async function createOrgUser(
);
} else if (type === "oidc") {
if (build === "saas") {
const subscribed = await isSubscribed(orgId);
const subscribed = await isSubscribed(
orgId,
tierMatrix.orgOidc
);
if (subscribed) {
return next(
createHttpError(

View File

@@ -11,6 +11,7 @@ import type { GetOrgResponse } from "@server/routers/org";
import type { ListRolesResponse } from "@server/routers/role";
import type { AxiosResponse } from "axios";
import { getTranslations } from "next-intl/server";
import { tierMatrix } from "@server/lib/billing/tierMatrix";
export interface ApprovalFeedPageProps {
params: Promise<{ orgId: string }>;
@@ -29,10 +30,9 @@ export default async function ApprovalFeedPage(props: ApprovalFeedPageProps) {
// Fetch roles to check if approvals are enabled
let hasApprovalsEnabled = false;
const rolesRes = await internal
.get<AxiosResponse<ListRolesResponse>>(
`/org/${params.orgId}/roles`,
await authCookieHeader()
)
.get<
AxiosResponse<ListRolesResponse>
>(`/org/${params.orgId}/roles`, await authCookieHeader())
.catch((e) => {});
if (rolesRes && rolesRes.status === 200) {
@@ -52,7 +52,7 @@ export default async function ApprovalFeedPage(props: ApprovalFeedPageProps) {
<ApprovalsBanner />
<PaidFeaturesAlert />
<PaidFeaturesAlert tiers={tierMatrix.deviceApprovals} />
<OrgProvider org={org}>
<div className="container mx-auto max-w-12xl">

View File

@@ -34,15 +34,7 @@ import {
CredenzaTitle
} from "@app/components/Credenza";
import { cn } from "@app/lib/cn";
import {
CreditCard,
ExternalLink,
Users,
Globe,
Server,
Layout,
Check
} from "lucide-react";
import { CreditCard, ExternalLink, Check } from "lucide-react";
import {
GetOrgSubscriptionResponse,
GetOrgUsageResponse
@@ -50,73 +42,49 @@ import {
import { useTranslations } from "use-intl";
import Link from "next/link";
import { Tier } from "@server/types/Tiers";
import { w } from "@faker-js/faker/dist/airline-DF6RqYmq";
import {
tier1LimitSet,
tier2LimitSet,
tier3LimitSet
} from "@server/lib/billing/limitSet";
import { FeatureId } from "@server/lib/billing/features";
// Plan tier definitions matching the mockup
type PlanId = "free" | "homelab" | "team" | "business" | "enterprise";
type PlanId = "starter" | "home" | "team" | "business" | "enterprise";
interface PlanOption {
type PlanOption = {
id: PlanId;
name: string;
price: string;
priceDetail?: string;
tierType: Tier | null;
}
// Tier limits for display in confirmation dialog
interface TierLimits {
sites: number;
users: number;
domains: number;
remoteNodes: number;
}
const tierLimits: Record<Tier, TierLimits> = {
tier1: {
sites: 3,
users: 3,
domains: 3,
remoteNodes: 1
},
tier2: {
sites: 10,
users: 150,
domains: 250,
remoteNodes: 5
},
tier3: {
sites: 10,
users: 150,
domains: 250,
remoteNodes: 5
}
};
const planOptions: PlanOption[] = [
{
id: "free",
name: "Free",
price: "Free",
id: "starter",
name: "Starter",
price: "Starter",
tierType: null
},
{
id: "homelab",
name: "Homelab",
price: "$15",
id: "home",
name: "Home",
price: "$12.50",
priceDetail: "/ month",
tierType: "tier1"
},
{
id: "team",
name: "Team",
price: "$5",
price: "$4",
priceDetail: "per user / month",
tierType: "tier2"
},
{
id: "business",
name: "Business",
price: "$10",
price: "$9",
priceDetail: "per user / month",
tierType: "tier3"
},
@@ -128,6 +96,37 @@ const planOptions: PlanOption[] = [
}
];
// Tier limits mapping derived from limit sets
const tierLimits: Record<
Tier,
{ users: number; sites: number; domains: number; remoteNodes: number }
> = {
tier1: {
users: tier1LimitSet[FeatureId.USERS]?.value ?? 0,
sites: tier1LimitSet[FeatureId.SITES]?.value ?? 0,
domains: tier1LimitSet[FeatureId.DOMAINS]?.value ?? 0,
remoteNodes: tier1LimitSet[FeatureId.REMOTE_EXIT_NODES]?.value ?? 0
},
tier2: {
users: tier2LimitSet[FeatureId.USERS]?.value ?? 0,
sites: tier2LimitSet[FeatureId.SITES]?.value ?? 0,
domains: tier2LimitSet[FeatureId.DOMAINS]?.value ?? 0,
remoteNodes: tier2LimitSet[FeatureId.REMOTE_EXIT_NODES]?.value ?? 0
},
tier3: {
users: tier3LimitSet[FeatureId.USERS]?.value ?? 0,
sites: tier3LimitSet[FeatureId.SITES]?.value ?? 0,
domains: tier3LimitSet[FeatureId.DOMAINS]?.value ?? 0,
remoteNodes: tier3LimitSet[FeatureId.REMOTE_EXIT_NODES]?.value ?? 0
},
enterprise: {
users: 0, // Custom for enterprise
sites: 0, // Custom for enterprise
domains: 0, // Custom for enterprise
remoteNodes: 0 // Custom for enterprise
}
};
export default function BillingPage() {
const { org } = useOrgContext();
const envContext = useEnvContext();
@@ -156,20 +155,18 @@ export default function BillingPage() {
const [hasSubscription, setHasSubscription] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const [currentTier, setCurrentTier] = useState<
Tier | null
>(null);
const [currentTier, setCurrentTier] = useState<Tier | null>(null);
// Usage IDs
const SITES = "sites";
const USERS = "users";
const SITES = "sites";
const DOMAINS = "domains";
const REMOTE_EXIT_NODES = "remoteExitNodes";
// Confirmation dialog state
const [showConfirmDialog, setShowConfirmDialog] = useState(false);
const [pendingTier, setPendingTier] = useState<{
tier: Tier,
tier: Tier;
action: "upgrade" | "downgrade";
planName: string;
price: string;
@@ -195,9 +192,7 @@ export default function BillingPage() {
setTierSubscription(tierSub || null);
if (tierSub?.subscription) {
setCurrentTier(
tierSub.subscription.type as Tier
);
setCurrentTier(tierSub.subscription.type as Tier);
setHasSubscription(
tierSub.subscription.status === "active"
);
@@ -241,9 +236,7 @@ export default function BillingPage() {
fetchUsage();
}, [org.org.orgId]);
const handleStartSubscription = async (
tier: Tier
) => {
const handleStartSubscription = async (tier: Tier) => {
setIsLoading(true);
try {
const response = await api.post<AxiosResponse<string>>(
@@ -311,8 +304,70 @@ export default function BillingPage() {
await api.post(`/org/${org.org.orgId}/billing/change-tier`, {
tier
});
// Refresh subscription data
window.location.reload();
// Poll the API to check if the tier change has been reflected
const pollForTierChange = async (targetTier: Tier) => {
const maxAttempts = 30; // 30 seconds with 1 second interval
let attempts = 0;
const poll = async (): Promise<boolean> => {
try {
const res = await api.get<
AxiosResponse<GetOrgSubscriptionResponse>
>(`/org/${org.org.orgId}/billing/subscriptions`);
const { subscriptions } = res.data.data;
// Find tier subscription
const tierSub = subscriptions.find(
({ subscription }) =>
subscription?.type === "tier1" ||
subscription?.type === "tier2" ||
subscription?.type === "tier3"
);
// Check if the tier has changed to the target tier
if (tierSub?.subscription?.type === targetTier) {
return true;
}
return false;
} catch (error) {
console.error("Error polling subscription:", error);
return false;
}
};
while (attempts < maxAttempts) {
const success = await poll();
if (success) {
// Tier change reflected, refresh the page
window.location.reload();
return;
}
attempts++;
if (attempts < maxAttempts) {
// Wait 1 second before next poll
await new Promise((resolve) =>
setTimeout(resolve, 1000)
);
}
}
// If we've exhausted all attempts, show an error
toast({
title: "Tier change processing",
description:
"Your tier change is taking longer than expected. Please refresh the page in a moment to see the changes.",
variant: "destructive"
});
setIsLoading(false);
};
// Start polling for the tier change
pollForTierChange(tier);
} catch (error) {
toast({
title: "Failed to change tier",
@@ -337,8 +392,8 @@ export default function BillingPage() {
}
}
setShowConfirmDialog(false);
setPendingTier(null);
// setShowConfirmDialog(false);
// setPendingTier(null);
};
const showTierConfirmation = (
@@ -352,14 +407,14 @@ export default function BillingPage() {
};
const handleContactUs = () => {
window.open("mailto:sales@pangolin.net", "_blank");
window.open("https://pangolin.net/talk-to-us", "_blank");
};
// Get current plan ID from tier
const getCurrentPlanId = (): PlanId => {
if (!hasSubscription || !currentTier) return "free";
if (!hasSubscription || !currentTier) return "starter";
const plan = planOptions.find((p) => p.tierType === currentTier);
return plan?.id || "free";
return plan?.id || "starter";
};
const currentPlanId = getCurrentPlanId();
@@ -376,8 +431,8 @@ export default function BillingPage() {
}
if (plan.id === currentPlanId) {
// If it's the free plan (free with no subscription), show as current but disabled
if (plan.id === "free" && !hasSubscription) {
// If it's the starter plan (starter with no subscription), show as current but disabled
if (plan.id === "starter" && !hasSubscription) {
return {
label: "Current Plan",
action: () => {},
@@ -407,7 +462,7 @@ export default function BillingPage() {
plan.tierType,
"downgrade",
plan.name,
plan.price + (plan.priceDetail || "")
plan.price + (" " + plan.priceDetail || "")
);
} else {
handleModifySubscription();
@@ -426,7 +481,7 @@ export default function BillingPage() {
plan.tierType,
"upgrade",
plan.name,
plan.price + (plan.priceDetail || "")
plan.price + (" " + plan.priceDetail || "")
);
} else {
handleModifySubscription();
@@ -452,14 +507,15 @@ export default function BillingPage() {
// Calculate current usage cost for display
const getUserCount = () => getUsageValue(USERS);
const getPricePerUser = () => {
console.log("Calculating price per user, tierSubscription:", tierSubscription);
if (!tierSubscription?.items) return 0;
// Find the subscription item for USERS feature
const usersItem = tierSubscription.items.find(
(item) => item.planId === USERS
(item) => item.featureId === USERS
);
console.log("Users subscription item:", usersItem);
// unitAmount is in cents, convert to dollars
if (usersItem?.unitAmount) {
return usersItem.unitAmount / 100;
@@ -540,6 +596,7 @@ export default function BillingPage() {
disabled={
isLoading || planAction.disabled
}
loading={isLoading && isCurrentPlan}
>
{planAction.label}
</Button>
@@ -747,9 +804,9 @@ export default function BillingPage() {
<span>
{
tierLimits[pendingTier.tier]
.sites
.users
}{" "}
{t("billingSites") || "Sites"}
{t("billingUsers") || "Users"}
</span>
</div>
<div className="flex items-center gap-2">
@@ -757,9 +814,9 @@ export default function BillingPage() {
<span>
{
tierLimits[pendingTier.tier]
.users
.sites
}{" "}
{t("billingUsers") || "Users"}
{t("billingSites") || "Sites"}
</span>
</div>
<div className="flex items-center gap-2">
@@ -798,14 +855,13 @@ export default function BillingPage() {
<Button
onClick={confirmTierChange}
disabled={isLoading}
loading={isLoading}
>
{isLoading
? t("billingProcessing") || "Processing..."
: pendingTier?.action === "upgrade"
? t("billingConfirmUpgradeButton") ||
"Confirm Upgrade"
: t("billingConfirmDowngradeButton") ||
"Confirm Downgrade"}
{pendingTier?.action === "upgrade"
? t("billingConfirmUpgradeButton") ||
"Confirm Upgrade"
: t("billingConfirmDowngradeButton") ||
"Confirm Downgrade"}
</Button>
</CredenzaFooter>
</CredenzaContent>

View File

@@ -31,7 +31,6 @@ import { formatAxiosError } from "@app/lib/api";
import { createApiClient } from "@app/lib/api";
import { useEnvContext } from "@app/hooks/useEnvContext";
import { useState, useEffect } from "react";
import { SwitchInput } from "@app/components/SwitchInput";
import { Alert, AlertDescription, AlertTitle } from "@app/components/ui/alert";
import { InfoIcon, ExternalLink } from "lucide-react";
import {
@@ -41,12 +40,13 @@ import {
InfoSectionTitle
} from "@app/components/InfoSection";
import CopyToClipboard from "@app/components/CopyToClipboard";
import { useLicenseStatusContext } from "@app/hooks/useLicenseStatusContext";
import IdpTypeBadge from "@app/components/IdpTypeBadge";
import { useTranslations } from "next-intl";
import { AxiosResponse } from "axios";
import { ListRolesResponse } from "@server/routers/role";
import AutoProvisionConfigWidget from "@app/components/private/AutoProvisionConfigWidget";
import AutoProvisionConfigWidget from "@app/components/AutoProvisionConfigWidget";
import { PaidFeaturesAlert } from "@app/components/PaidFeaturesAlert";
import { tierMatrix } from "@server/lib/billing/tierMatrix";
export default function GeneralPage() {
const { env } = useEnvContext();
@@ -60,7 +60,6 @@ export default function GeneralPage() {
"role" | "expression"
>("role");
const [variant, setVariant] = useState<"oidc" | "google" | "azure">("oidc");
const { isUnlocked } = useLicenseStatusContext();
const dashboardRedirectUrl = `${env.app.dashboardUrl}/auth/idp/${idpId}/oidc/callback`;
const [redirectUrl, setRedirectUrl] = useState(
@@ -499,6 +498,10 @@ export default function GeneralPage() {
</SettingsSectionHeader>
<SettingsSectionBody>
<SettingsSectionForm>
<PaidFeaturesAlert
tiers={tierMatrix.autoProvisioning}
/>
<Form {...form}>
<form
onSubmit={form.handleSubmit(onSubmit)}

View File

@@ -1,6 +1,7 @@
"use client";
import AutoProvisionConfigWidget from "@app/components/private/AutoProvisionConfigWidget";
import AutoProvisionConfigWidget from "@app/components/AutoProvisionConfigWidget";
import { PaidFeaturesAlert } from "@app/components/PaidFeaturesAlert";
import {
SettingsContainer,
SettingsSection,
@@ -31,6 +32,7 @@ import { usePaidStatus } from "@app/hooks/usePaidStatus";
import { toast } from "@app/hooks/useToast";
import { createApiClient, formatAxiosError } from "@app/lib/api";
import { zodResolver } from "@hookform/resolvers/zod";
import { tierMatrix } from "@server/lib/billing/tierMatrix";
import { ListRolesResponse } from "@server/routers/role";
import { AxiosResponse } from "axios";
import { InfoIcon } from "lucide-react";
@@ -50,7 +52,6 @@ export default function Page() {
const [roleMappingMode, setRoleMappingMode] = useState<
"role" | "expression"
>("role");
const { isUnlocked } = useLicenseStatusContext();
const t = useTranslations();
const { isPaidUser } = usePaidStatus();
@@ -363,6 +364,9 @@ export default function Page() {
</SettingsSectionHeader>
<SettingsSectionBody>
<SettingsSectionForm>
<PaidFeaturesAlert
tiers={tierMatrix.autoProvisioning}
/>
<Form {...form}>
<form
className="space-y-4"
@@ -808,7 +812,7 @@ export default function Page() {
</Button>
<Button
type="submit"
disabled={createLoading || !isPaidUser}
disabled={createLoading || !isPaidUser(tierMatrix.orgOidc)}
loading={createLoading}
onClick={() => {
// log any issues with the form

View File

@@ -2,9 +2,10 @@ import { internal } from "@app/lib/api";
import { authCookieHeader } from "@app/lib/api/cookies";
import { AxiosResponse } from "axios";
import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
import IdpTable, { IdpRow } from "@app/components/private/OrgIdpTable";
import IdpTable, { IdpRow } from "@app/components/OrgIdpTable";
import { getTranslations } from "next-intl/server";
import { PaidFeaturesAlert } from "@app/components/PaidFeaturesAlert";
import { tierMatrix } from "@server/lib/billing/tierMatrix";
type OrgIdpPageProps = {
params: Promise<{ orgId: string }>;
@@ -35,7 +36,7 @@ export default async function OrgIdpPage(props: OrgIdpPageProps) {
description={t("idpManageDescription")}
/>
<PaidFeaturesAlert />
<PaidFeaturesAlert tiers={tierMatrix.orgOidc} />
<IdpTable idps={idps} orgId={params.orgId} />
</>

View File

@@ -23,9 +23,6 @@ import {
} from "@server/routers/remoteExitNode/types";
import { useRemoteExitNodeContext } from "@app/hooks/useRemoteExitNodeContext";
import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog";
import { useSubscriptionStatusContext } from "@app/hooks/useSubscriptionStatusContext";
import { useLicenseStatusContext } from "@app/hooks/useLicenseStatusContext";
import { build } from "@server/build";
import {
InfoSection,
InfoSectionContent,
@@ -36,6 +33,8 @@ import CopyToClipboard from "@app/components/CopyToClipboard";
import { Alert, AlertDescription, AlertTitle } from "@app/components/ui/alert";
import { InfoIcon } from "lucide-react";
import { PaidFeaturesAlert } from "@app/components/PaidFeaturesAlert";
import { usePaidStatus } from "@app/hooks/usePaidStatus";
import { tierMatrix } from "@server/lib/billing/tierMatrix";
export default function CredentialsPage() {
const { env } = useEnvContext();
@@ -45,6 +44,8 @@ export default function CredentialsPage() {
const t = useTranslations();
const { remoteExitNode } = useRemoteExitNodeContext();
const { isPaidUser } = usePaidStatus();
const [modalOpen, setModalOpen] = useState(false);
const [credentials, setCredentials] =
useState<PickRemoteExitNodeDefaultsResponse | null>(null);
@@ -57,16 +58,6 @@ export default function CredentialsPage() {
const [showCredentialsAlert, setShowCredentialsAlert] = useState(false);
const [shouldDisconnect, setShouldDisconnect] = useState(true);
const { licenseStatus, isUnlocked } = useLicenseStatusContext();
const subscription = useSubscriptionStatusContext();
const isSecurityFeatureDisabled = () => {
const isEnterpriseNotLicensed = build === "enterprise" && !isUnlocked();
const isSaasNotSubscribed =
build === "saas" && !subscription?.isSubscribed();
return isEnterpriseNotLicensed || isSaasNotSubscribed;
};
const handleConfirmRegenerate = async () => {
try {
const response = await api.get<
@@ -138,7 +129,9 @@ export default function CredentialsPage() {
</SettingsSectionDescription>
</SettingsSectionHeader>
<SettingsSectionBody>
<PaidFeaturesAlert />
<PaidFeaturesAlert
tiers={tierMatrix.rotateCredentials}
/>
<InfoSections cols={3}>
<InfoSection>
@@ -203,7 +196,9 @@ export default function CredentialsPage() {
setShouldDisconnect(false);
setModalOpen(true);
}}
disabled={isSecurityFeatureDisabled()}
disabled={
!isPaidUser(tierMatrix.rotateCredentials)
}
>
{t("regenerateCredentialsButton")}
</Button>
@@ -212,7 +207,9 @@ export default function CredentialsPage() {
setShouldDisconnect(true);
setModalOpen(true);
}}
disabled={isSecurityFeatureDisabled()}
disabled={
!isPaidUser(tierMatrix.rotateCredentials)
}
>
{t("remoteExitNodeRegenerateAndDisconnect")}
</Button>

View File

@@ -47,7 +47,8 @@ import { ListIdpsResponse } from "@server/routers/idp";
import { useTranslations } from "next-intl";
import { build } from "@server/build";
import Image from "next/image";
import { useSubscriptionStatusContext } from "@app/hooks/useSubscriptionStatusContext";
import { usePaidStatus } from "@app/hooks/usePaidStatus";
import { tierMatrix } from "@server/lib/billing/tierMatrix";
type UserType = "internal" | "oidc";
@@ -75,7 +76,7 @@ export default function Page() {
const api = createApiClient({ env });
const t = useTranslations();
const subscription = useSubscriptionStatusContext();
const { hasSaasSubscription } = usePaidStatus();
const [selectedOption, setSelectedOption] = useState<string | null>(
"internal"
@@ -237,7 +238,7 @@ export default function Page() {
}
async function fetchIdps() {
if (build === "saas" && !subscription?.subscribed) {
if (build === "saas" && !hasSaasSubscription(tierMatrix.orgOidc)) {
return;
}

View File

@@ -19,9 +19,6 @@ import { useTranslations } from "next-intl";
import { PickClientDefaultsResponse } from "@server/routers/client";
import { useClientContext } from "@app/hooks/useClientContext";
import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog";
import { useLicenseStatusContext } from "@app/hooks/useLicenseStatusContext";
import { useSubscriptionStatusContext } from "@app/hooks/useSubscriptionStatusContext";
import { build } from "@server/build";
import {
InfoSection,
InfoSectionContent,
@@ -33,6 +30,8 @@ import { Alert, AlertDescription, AlertTitle } from "@app/components/ui/alert";
import { InfoIcon } from "lucide-react";
import { PaidFeaturesAlert } from "@app/components/PaidFeaturesAlert";
import { OlmInstallCommands } from "@app/components/olm-install-commands";
import { usePaidStatus } from "@app/hooks/usePaidStatus";
import { tierMatrix } from "@server/lib/billing/tierMatrix";
export default function CredentialsPage() {
const { env } = useEnvContext();
@@ -54,17 +53,7 @@ export default function CredentialsPage() {
const [showCredentialsAlert, setShowCredentialsAlert] = useState(false);
const [shouldDisconnect, setShouldDisconnect] = useState(true);
const { licenseStatus, isUnlocked } = useLicenseStatusContext();
const subscription = useSubscriptionStatusContext();
const isSecurityFeatureDisabled = () => {
const isEnterpriseNotLicensed = build === "enterprise" && !isUnlocked();
const isSaasNotSubscribed =
build === "saas" && !subscription?.isSubscribed();
return (
isEnterpriseNotLicensed || isSaasNotSubscribed || build === "oss"
);
};
const { isPaidUser } = usePaidStatus();
const handleConfirmRegenerate = async () => {
try {
@@ -130,7 +119,9 @@ export default function CredentialsPage() {
</SettingsSectionDescription>
</SettingsSectionHeader>
<SettingsSectionBody>
<PaidFeaturesAlert />
<PaidFeaturesAlert
tiers={tierMatrix.rotateCredentials}
/>
<InfoSections cols={3}>
<InfoSection>
@@ -191,7 +182,9 @@ export default function CredentialsPage() {
setShouldDisconnect(false);
setModalOpen(true);
}}
disabled={isSecurityFeatureDisabled()}
disabled={
!isPaidUser(tierMatrix.rotateCredentials)
}
>
{t("regenerateCredentialsButton")}
</Button>
@@ -200,7 +193,9 @@ export default function CredentialsPage() {
setShouldDisconnect(true);
setModalOpen(true);
}}
disabled={isSecurityFeatureDisabled()}
disabled={
!isPaidUser(tierMatrix.rotateCredentials)
}
>
{t("clientRegenerateAndDisconnect")}
</Button>

View File

@@ -155,13 +155,11 @@ export default function GeneralPage() {
const [, startTransition] = useTransition();
const { env } = useEnvContext();
const showApprovalFeatures = build !== "oss" && isPaidUser;
const showApprovalFeatures =
build !== "oss" && isPaidUser(tierMatrix.deviceApprovals);
const formatPostureValue = (
value: boolean | null | undefined | "-"
) => {
if (value === null || value === undefined || value === "-")
return "-";
const formatPostureValue = (value: boolean | null | undefined | "-") => {
if (value === null || value === undefined || value === "-") return "-";
return (
<div className="flex items-center gap-2">
{value ? (
@@ -584,7 +582,8 @@ export default function GeneralPage() {
</SettingsSectionHeader>
<SettingsSectionBody>
<PaidFeaturesAlert />
<PaidFeaturesAlert tiers={tierMatrix.devicePosture} />
{client.posture &&
Object.keys(client.posture).length > 0 ? (
<>
@@ -598,7 +597,9 @@ export default function GeneralPage() {
{t("biometricsEnabled")}
</InfoSectionTitle>
<InfoSectionContent>
{isPaidUser(tierMatrix.devicePosture)
{isPaidUser(
tierMatrix.devicePosture
)
? formatPostureValue(
client.posture
.biometricsEnabled
@@ -616,7 +617,9 @@ export default function GeneralPage() {
{t("diskEncrypted")}
</InfoSectionTitle>
<InfoSectionContent>
{isPaidUser
{isPaidUser(
tierMatrix.devicePosture
)
? formatPostureValue(
client.posture
.diskEncrypted
@@ -634,7 +637,9 @@ export default function GeneralPage() {
{t("firewallEnabled")}
</InfoSectionTitle>
<InfoSectionContent>
{isPaidUser
{isPaidUser(
tierMatrix.devicePosture
)
? formatPostureValue(
client.posture
.firewallEnabled
@@ -653,7 +658,9 @@ export default function GeneralPage() {
{t("autoUpdatesEnabled")}
</InfoSectionTitle>
<InfoSectionContent>
{isPaidUser
{isPaidUser(
tierMatrix.devicePosture
)
? formatPostureValue(
client.posture
.autoUpdatesEnabled
@@ -671,7 +678,9 @@ export default function GeneralPage() {
{t("tpmAvailable")}
</InfoSectionTitle>
<InfoSectionContent>
{isPaidUser
{isPaidUser(
tierMatrix.devicePosture
)
? formatPostureValue(
client.posture
.tpmAvailable
@@ -693,7 +702,9 @@ export default function GeneralPage() {
)}
</InfoSectionTitle>
<InfoSectionContent>
{isPaidUser
{isPaidUser(
tierMatrix.devicePosture
)
? formatPostureValue(
client.posture
.windowsAntivirusEnabled
@@ -711,7 +722,9 @@ export default function GeneralPage() {
{t("macosSipEnabled")}
</InfoSectionTitle>
<InfoSectionContent>
{isPaidUser
{isPaidUser(
tierMatrix.devicePosture
)
? formatPostureValue(
client.posture
.macosSipEnabled
@@ -733,7 +746,9 @@ export default function GeneralPage() {
)}
</InfoSectionTitle>
<InfoSectionContent>
{isPaidUser
{isPaidUser(
tierMatrix.devicePosture
)
? formatPostureValue(
client.posture
.macosGatekeeperEnabled
@@ -755,7 +770,9 @@ export default function GeneralPage() {
)}
</InfoSectionTitle>
<InfoSectionContent>
{isPaidUser
{isPaidUser(
tierMatrix.devicePosture
)
? formatPostureValue(
client.posture
.macosFirewallStealthMode
@@ -774,7 +791,9 @@ export default function GeneralPage() {
{t("linuxAppArmorEnabled")}
</InfoSectionTitle>
<InfoSectionContent>
{isPaidUser
{isPaidUser(
tierMatrix.devicePosture
)
? formatPostureValue(
client.posture
.linuxAppArmorEnabled
@@ -793,7 +812,9 @@ export default function GeneralPage() {
{t("linuxSELinuxEnabled")}
</InfoSectionTitle>
<InfoSectionContent>
{isPaidUser
{isPaidUser(
tierMatrix.devicePosture
)
? formatPostureValue(
client.posture
.linuxSELinuxEnabled

View File

@@ -1,5 +1,5 @@
import AuthPageBrandingForm from "@app/components/AuthPageBrandingForm";
import AuthPageSettings from "@app/components/private/AuthPageSettings";
import AuthPageSettings from "@app/components/AuthPageSettings";
import { SettingsContainer } from "@app/components/Settings";
import { internal } from "@app/lib/api";
import { authCookieHeader } from "@app/lib/api/cookies";

View File

@@ -43,6 +43,8 @@ import { SwitchInput } from "@app/components/SwitchInput";
import { PaidFeaturesAlert } from "@app/components/PaidFeaturesAlert";
import { usePaidStatus } from "@app/hooks/usePaidStatus";
import type { OrgContextType } from "@app/contexts/orgContext";
import { tierMatrix } from "@server/lib/billing/tierMatrix";
import { isAppPageRouteDefinition } from "next/dist/server/route-definitions/app-page-route-definition";
// Session length options in hours
const SESSION_LENGTH_OPTIONS = [
@@ -244,13 +246,17 @@ function LogRetentionSectionForm({ org }: SectionFormProps) {
{!env.flags.disableEnterpriseFeatures && (
<>
<PaidFeaturesAlert />
<PaidFeaturesAlert
tiers={tierMatrix.accessLogs}
/>
<FormField
control={form.control}
name="settingsLogRetentionDaysAccess"
render={({ field }) => {
const isDisabled = !isPaidUser;
const isDisabled = !isPaidUser(
tierMatrix.accessLogs
);
return (
<FormItem>
@@ -316,7 +322,9 @@ function LogRetentionSectionForm({ org }: SectionFormProps) {
control={form.control}
name="settingsLogRetentionDaysAction"
render={({ field }) => {
const isDisabled = !isPaidUser;
const isDisabled = !isPaidUser(
tierMatrix.actionLogs
);
return (
<FormItem>
@@ -521,12 +529,17 @@ function SecuritySettingsSectionForm({ org }: SectionFormProps) {
id="security-settings-section-form"
className="space-y-4"
>
<PaidFeaturesAlert />
<PaidFeaturesAlert
tiers={tierMatrix.twoFactorEnforcement}
/>
<FormField
control={form.control}
name="requireTwoFactor"
render={({ field }) => {
const isDisabled = !isPaidUser;
const isDisabled = !isPaidUser(
tierMatrix.twoFactorEnforcement
);
return (
<FormItem className="col-span-2">
@@ -573,7 +586,9 @@ function SecuritySettingsSectionForm({ org }: SectionFormProps) {
control={form.control}
name="maxSessionLengthHours"
render={({ field }) => {
const isDisabled = !isPaidUser;
const isDisabled = !isPaidUser(
tierMatrix.sessionDurationPolicies
);
return (
<FormItem className="col-span-2">
@@ -653,7 +668,9 @@ function SecuritySettingsSectionForm({ org }: SectionFormProps) {
control={form.control}
name="passwordExpiryDays"
render={({ field }) => {
const isDisabled = !isPaidUser;
const isDisabled = !isPaidUser(
tierMatrix.passwordExpirationPolicies
);
return (
<FormItem className="col-span-2">
@@ -739,7 +756,12 @@ function SecuritySettingsSectionForm({ org }: SectionFormProps) {
type="submit"
form="security-settings-section-form"
loading={loadingSave}
disabled={loadingSave || !isPaidUser}
disabled={
loadingSave ||
!isPaidUser(tierMatrix.twoFactorEnforcement) ||
!isPaidUser(tierMatrix.sessionDurationPolicies) ||
!isPaidUser(tierMatrix.passwordExpirationPolicies)
}
>
{t("saveSettings")}
</Button>

View File

@@ -13,14 +13,13 @@ import { ArrowUpRight, Key, User } from "lucide-react";
import Link from "next/link";
import { ColumnFilter } from "@app/components/ColumnFilter";
import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
import { useSubscriptionStatusContext } from "@app/hooks/useSubscriptionStatusContext";
import { useLicenseStatusContext } from "@app/hooks/useLicenseStatusContext";
import { build } from "@server/build";
import { Alert, AlertDescription } from "@app/components/ui/alert";
import { getSevenDaysAgo } from "@app/lib/getSevenDaysAgo";
import axios from "axios";
import { useStoredPageSize } from "@app/hooks/useStoredPageSize";
import { PaidFeaturesAlert } from "@app/components/PaidFeaturesAlert";
import { usePaidStatus } from "@app/hooks/usePaidStatus";
import { tierMatrix } from "@server/lib/billing/tierMatrix";
export default function GeneralPage() {
const router = useRouter();
@@ -28,8 +27,8 @@ export default function GeneralPage() {
const api = createApiClient(useEnvContext());
const t = useTranslations();
const { orgId } = useParams();
const subscription = useSubscriptionStatusContext();
const { isUnlocked } = useLicenseStatusContext();
const { isPaidUser } = usePaidStatus();
const [rows, setRows] = useState<any[]>([]);
const [isRefreshing, setIsRefreshing] = useState(false);
@@ -208,11 +207,7 @@ export default function GeneralPage() {
}
) => {
console.log("Date range changed:", { startDate, endDate, page, size });
if (
(build == "saas" && !subscription?.subscribed) ||
(build == "enterprise" && !isUnlocked()) ||
build === "oss"
) {
if (!isPaidUser(tierMatrix.accessLogs) || build === "oss") {
console.log(
"Access denied: subscription inactive or license locked"
);
@@ -613,7 +608,7 @@ export default function GeneralPage() {
description={t("accessLogsDescription")}
/>
<PaidFeaturesAlert />
<PaidFeaturesAlert tiers={tierMatrix.accessLogs} />
<LogDataTable
columns={columns}
@@ -623,6 +618,9 @@ export default function GeneralPage() {
isRefreshing={isRefreshing}
onExport={() => startTransition(exportData)}
isExporting={isExporting}
isExportDisabled={
!isPaidUser(tierMatrix.accessLogs) || build === "oss"
}
onDateRangeChange={handleDateRangeChange}
dateRange={{
start: dateRange.startDate,
@@ -642,11 +640,7 @@ export default function GeneralPage() {
// Row expansion props
expandable={true}
renderExpandedRow={renderExpandedRow}
disabled={
(build == "saas" && !subscription?.subscribed) ||
(build == "enterprise" && !isUnlocked()) ||
build === "oss"
}
disabled={!isPaidUser(tierMatrix.accessLogs) || build === "oss"}
/>
</>
);

View File

@@ -4,15 +4,14 @@ import { DateTimeValue } from "@app/components/DateTimePicker";
import { LogDataTable } from "@app/components/LogDataTable";
import { PaidFeaturesAlert } from "@app/components/PaidFeaturesAlert";
import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
import { Alert, AlertDescription } from "@app/components/ui/alert";
import { useEnvContext } from "@app/hooks/useEnvContext";
import { useLicenseStatusContext } from "@app/hooks/useLicenseStatusContext";
import { usePaidStatus } from "@app/hooks/usePaidStatus";
import { useStoredPageSize } from "@app/hooks/useStoredPageSize";
import { useSubscriptionStatusContext } from "@app/hooks/useSubscriptionStatusContext";
import { toast } from "@app/hooks/useToast";
import { createApiClient } from "@app/lib/api";
import { getSevenDaysAgo } from "@app/lib/getSevenDaysAgo";
import { build } from "@server/build";
import { tierMatrix } from "@server/lib/billing/tierMatrix";
import { ColumnDef } from "@tanstack/react-table";
import axios from "axios";
import { Key, User } from "lucide-react";
@@ -26,8 +25,8 @@ export default function GeneralPage() {
const t = useTranslations();
const { orgId } = useParams();
const searchParams = useSearchParams();
const subscription = useSubscriptionStatusContext();
const { isUnlocked } = useLicenseStatusContext();
const { isPaidUser } = usePaidStatus();
const [rows, setRows] = useState<any[]>([]);
const [isRefreshing, setIsRefreshing] = useState(false);
@@ -195,10 +194,7 @@ export default function GeneralPage() {
}
) => {
console.log("Date range changed:", { startDate, endDate, page, size });
if (
(build == "saas" && !subscription?.subscribed) ||
(build == "enterprise" && !isUnlocked())
) {
if (!isPaidUser(tierMatrix.actionLogs)) {
console.log(
"Access denied: subscription inactive or license locked"
);
@@ -465,7 +461,7 @@ export default function GeneralPage() {
description={t("actionLogsDescription")}
/>
<PaidFeaturesAlert />
<PaidFeaturesAlert tiers={tierMatrix.actionLogs} />
<LogDataTable
columns={columns}
@@ -476,6 +472,9 @@ export default function GeneralPage() {
onRefresh={refreshData}
isRefreshing={isRefreshing}
onExport={() => startTransition(exportData)}
isExportDisabled={
!isPaidUser(tierMatrix.logExport) || build === "oss"
}
isExporting={isExporting}
onDateRangeChange={handleDateRangeChange}
dateRange={{
@@ -496,11 +495,7 @@ export default function GeneralPage() {
// Row expansion props
expandable={true}
renderExpandedRow={renderExpandedRow}
disabled={
(build == "saas" && !subscription?.subscribed) ||
(build == "enterprise" && !isUnlocked()) ||
build === "oss"
}
disabled={!isPaidUser(tierMatrix.actionLogs) || build === "oss"}
/>
</>
);

View File

@@ -1,54 +1,3 @@
"use client";
import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog";
import AuthPageSettings, {
AuthPageSettingsRef
} from "@app/components/private/AuthPageSettings";
import { Button } from "@app/components/ui/button";
import { useOrgContext } from "@app/hooks/useOrgContext";
import { userOrgUserContext } from "@app/hooks/useOrgUserContext";
import { toast } from "@app/hooks/useToast";
import { useState, useRef } from "react";
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { z } from "zod";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { createApiClient } from "@app/lib/api";
import { useEnvContext } from "@app/hooks/useEnvContext";
import { formatAxiosError } from "@app/lib/api";
import { AxiosResponse } from "axios";
import { DeleteOrgResponse, ListUserOrgsResponse } from "@server/routers/org";
import { useRouter } from "next/navigation";
import {
SettingsContainer,
SettingsSection,
SettingsSectionHeader,
SettingsSectionTitle,
SettingsSectionDescription,
SettingsSectionBody,
SettingsSectionForm,
SettingsSectionFooter
} from "@app/components/Settings";
import { useUserContext } from "@app/hooks/useUserContext";
import { useTranslations } from "next-intl";
import { build } from "@server/build";
export default function GeneralPage() {
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
const router = useRouter();
const api = createApiClient(useEnvContext());
const t = useTranslations();
const { env } = useEnvContext();
return <p>dfas</p>;
return null;
}

View File

@@ -50,9 +50,6 @@ import { useActionState, useMemo, useState } from "react";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import z from "zod";
import { build } from "@server/build";
import { useLicenseStatusContext } from "@app/hooks/useLicenseStatusContext";
import { useSubscriptionStatusContext } from "@app/hooks/useSubscriptionStatusContext";
import { Alert, AlertDescription } from "@app/components/ui/alert";
import { RadioGroup, RadioGroupItem } from "@app/components/ui/radio-group";
import {
@@ -64,6 +61,7 @@ import { PaidFeaturesAlert } from "@app/components/PaidFeaturesAlert";
import { GetResourceResponse } from "@server/routers/resource/getResource";
import type { ResourceContextType } from "@app/contexts/resourceContext";
import { usePaidStatus } from "@app/hooks/usePaidStatus";
import { tierMatrix } from "@server/lib/billing/tierMatrix";
type MaintenanceSectionFormProps = {
resource: GetResourceResponse;
@@ -77,8 +75,6 @@ function MaintenanceSectionForm({
const { env } = useEnvContext();
const t = useTranslations();
const api = createApiClient({ env });
const { isUnlocked } = useLicenseStatusContext();
const subscription = useSubscriptionStatusContext();
const { isPaidUser } = usePaidStatus();
const MaintenanceFormSchema = z.object({
@@ -159,15 +155,6 @@ function MaintenanceSectionForm({
}
}
const isSecurityFeatureDisabled = () => {
const isEnterpriseNotLicensed = build === "enterprise" && !isUnlocked();
const isSaasNotSubscribed =
build === "saas" && !subscription?.isSubscribed();
return (
isEnterpriseNotLicensed || isSaasNotSubscribed || build === "oss"
);
};
if (!resource.http) {
return null;
}
@@ -191,13 +178,15 @@ function MaintenanceSectionForm({
className="space-y-4"
id="maintenance-settings-form"
>
<PaidFeaturesAlert />
<PaidFeaturesAlert
tiers={tierMatrix.maintencePage}
/>
<FormField
control={maintenanceForm.control}
name="maintenanceModeEnabled"
render={({ field }) => {
const isDisabled =
isSecurityFeatureDisabled() ||
!isPaidUser(tierMatrix.maintencePage) ||
resource.http === false;
return (
@@ -264,7 +253,11 @@ function MaintenanceSectionForm({
defaultValue={
field.value
}
disabled={isSecurityFeatureDisabled()}
disabled={
!isPaidUser(
tierMatrix.maintencePage
)
}
className="flex flex-col space-y-1"
>
<FormItem className="flex items-start space-x-3 space-y-0">
@@ -337,7 +330,11 @@ function MaintenanceSectionForm({
<FormControl>
<Input
{...field}
disabled={isSecurityFeatureDisabled()}
disabled={
!isPaidUser(
tierMatrix.maintencePage
)
}
placeholder="We'll be back soon!"
/>
</FormControl>
@@ -363,7 +360,11 @@ function MaintenanceSectionForm({
<Textarea
{...field}
rows={4}
disabled={isSecurityFeatureDisabled()}
disabled={
!isPaidUser(
tierMatrix.maintencePage
)
}
placeholder={t(
"maintenancePageMessagePlaceholder"
)}
@@ -392,7 +393,11 @@ function MaintenanceSectionForm({
<FormControl>
<Input
{...field}
disabled={isSecurityFeatureDisabled()}
disabled={
!isPaidUser(
tierMatrix.maintencePage
)
}
placeholder={t(
"maintenanceTime"
)}
@@ -418,7 +423,10 @@ function MaintenanceSectionForm({
<Button
type="submit"
loading={maintenanceSaveLoading}
disabled={maintenanceSaveLoading || !isPaidUser}
disabled={
maintenanceSaveLoading ||
!isPaidUser(tierMatrix.maintencePage)
}
form="maintenance-settings-form"
>
{t("saveSettings")}

View File

@@ -20,9 +20,6 @@ import { PickSiteDefaultsResponse } from "@server/routers/site";
import { useSiteContext } from "@app/hooks/useSiteContext";
import { generateKeypair } from "../wireguardConfig";
import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog";
import { useLicenseStatusContext } from "@app/hooks/useLicenseStatusContext";
import { useSubscriptionStatusContext } from "@app/hooks/useSubscriptionStatusContext";
import { build } from "@server/build";
import {
InfoSection,
InfoSectionContent,
@@ -40,6 +37,8 @@ import {
import { QRCodeCanvas } from "qrcode.react";
import { PaidFeaturesAlert } from "@app/components/PaidFeaturesAlert";
import { NewtSiteInstallCommands } from "@app/components/newt-install-commands";
import { usePaidStatus } from "@app/hooks/usePaidStatus";
import { tierMatrix } from "@server/lib/billing/tierMatrix";
export default function CredentialsPage() {
const { env } = useEnvContext();
@@ -65,17 +64,7 @@ export default function CredentialsPage() {
const [loadingDefaults, setLoadingDefaults] = useState(false);
const [shouldDisconnect, setShouldDisconnect] = useState(true);
const { licenseStatus, isUnlocked } = useLicenseStatusContext();
const subscription = useSubscriptionStatusContext();
const isSecurityFeatureDisabled = () => {
const isEnterpriseNotLicensed = build === "enterprise" && !isUnlocked();
const isSaasNotSubscribed =
build === "saas" && !subscription?.isSubscribed();
return (
isEnterpriseNotLicensed || isSaasNotSubscribed || build === "oss"
);
};
const { isPaidUser } = usePaidStatus();
// Fetch site defaults for wireguard sites to show in obfuscated config
useEffect(() => {
@@ -207,7 +196,9 @@ export default function CredentialsPage() {
</SettingsSectionDescription>
</SettingsSectionHeader>
<PaidFeaturesAlert />
<PaidFeaturesAlert
tiers={tierMatrix.rotateCredentials}
/>
<SettingsSectionBody>
<InfoSections cols={3}>
@@ -279,7 +270,11 @@ export default function CredentialsPage() {
setShouldDisconnect(false);
setModalOpen(true);
}}
disabled={isSecurityFeatureDisabled()}
disabled={
!isPaidUser(
tierMatrix.rotateCredentials
)
}
>
{t("regenerateCredentialsButton")}
</Button>
@@ -288,7 +283,11 @@ export default function CredentialsPage() {
setShouldDisconnect(true);
setModalOpen(true);
}}
disabled={isSecurityFeatureDisabled()}
disabled={
!isPaidUser(
tierMatrix.rotateCredentials
)
}
>
{t("siteRegenerateAndDisconnect")}
</Button>
@@ -315,7 +314,9 @@ export default function CredentialsPage() {
</SettingsSectionDescription>
</SettingsSectionHeader>
<PaidFeaturesAlert />
<PaidFeaturesAlert
tiers={tierMatrix.rotateCredentials}
/>
<SettingsSectionBody>
{!loadingDefaults && (
@@ -389,7 +390,11 @@ export default function CredentialsPage() {
<SettingsSectionFooter>
<Button
onClick={() => setModalOpen(true)}
disabled={isSecurityFeatureDisabled()}
disabled={
!isPaidUser(
tierMatrix.rotateCredentials
)
}
>
{t("siteRegenerateAndDisconnect")}
</Button>

View File

@@ -14,7 +14,7 @@ import {
LoadLoginPageResponse
} from "@server/routers/loginPage/types";
import { GetSessionTransferTokenRenponse } from "@server/routers/auth/types";
import ValidateSessionTransferToken from "@app/components/private/ValidateSessionTransferToken";
import ValidateSessionTransferToken from "@app/components/ValidateSessionTransferToken";
import { isOrgSubscribed } from "@app/lib/api/isOrgSubscribed";
import { OrgSelectionForm } from "@app/components/OrgSelectionForm";
import OrgLoginPage from "@app/components/OrgLoginPage";

View File

@@ -5,7 +5,7 @@ import { ThemeProvider } from "@app/providers/ThemeProvider";
import EnvProvider from "@app/providers/EnvProvider";
import { pullEnv } from "@app/lib/pullEnv";
import ThemeDataProvider from "@app/providers/ThemeDataProvider";
import SplashImage from "@app/components/private/SplashImage";
import SplashImage from "@app/components/SplashImage";
import SupportStatusProvider from "@app/providers/SupporterStatusProvider";
import { priv } from "@app/lib/api";
import { AxiosResponse } from "axios";

View File

@@ -35,6 +35,7 @@ import { usePaidStatus } from "@app/hooks/usePaidStatus";
import { build } from "@server/build";
import { PaidFeaturesAlert } from "./PaidFeaturesAlert";
import { Alert, AlertDescription, AlertTitle } from "./ui/alert";
import { tierMatrix } from "@server/lib/billing/tierMatrix";
export type AuthPageCustomizationProps = {
orgId: string;
@@ -139,14 +140,14 @@ export default function AuthPageBrandingForm({
`Choose your preferred authentication method for {{resourceName}}`,
primaryColor: branding?.primaryColor ?? `#f36117` // default pangolin primary color
},
disabled: !isPaidUser
disabled: !isPaidUser(tierMatrix.loginPageBranding)
});
async function updateBranding() {
const isValid = await form.trigger();
const brandingData = form.getValues();
if (!isValid || !isPaidUser) return;
if (!isValid || !isPaidUser(tierMatrix.loginPageBranding)) return;
try {
const updateRes = await api.put(
@@ -177,8 +178,6 @@ export default function AuthPageBrandingForm({
}
async function deleteBranding() {
if (!isPaidUser) return;
try {
const updateRes = await api.delete(
`/org/${orgId}/login-page-branding`
@@ -221,7 +220,9 @@ export default function AuthPageBrandingForm({
<SettingsSectionBody>
<SettingsSectionForm>
<PaidFeaturesAlert />
<PaidFeaturesAlert
tiers={tierMatrix.loginPageBranding}
/>
<Form {...form}>
<form
@@ -436,7 +437,7 @@ export default function AuthPageBrandingForm({
disabled={
isUpdatingBranding ||
isDeletingBranding ||
!isPaidUser
!isPaidUser(tierMatrix.loginPageBranding)
}
className="gap-1"
>
@@ -451,7 +452,7 @@ export default function AuthPageBrandingForm({
disabled={
isUpdatingBranding ||
isDeletingBranding ||
!isPaidUser
!isPaidUser(tierMatrix.loginPageBranding)
}
>
{t("saveAuthPageBranding")}

View File

@@ -28,7 +28,7 @@ import { ListDomainsResponse } from "@server/routers/domain";
import { DomainRow } from "@app/components/DomainsTable";
import { toUnicode } from "punycode";
import { Globe, Trash2 } from "lucide-react";
import CertificateStatus from "@app/components/private/CertificateStatus";
import CertificateStatus from "@app/components/CertificateStatus";
import {
Credenza,
CredenzaBody,
@@ -42,10 +42,10 @@ import {
import DomainPicker from "@app/components/DomainPicker";
import { finalizeSubdomainSanitize } from "@app/lib/subdomain-utils";
import { InfoPopup } from "@app/components/ui/info-popup";
import { Alert, AlertDescription } from "@app/components/ui/alert";
import { build } from "@server/build";
import { usePaidStatus } from "@app/hooks/usePaidStatus";
import { PaidFeaturesAlert } from "../PaidFeaturesAlert";
import { PaidFeaturesAlert } from "@app/components/PaidFeaturesAlert";
import { tierMatrix } from "@server/lib/billing/tierMatrix";
// Auth page form schema
const AuthPageFormSchema = z.object({
@@ -75,7 +75,7 @@ function AuthPageSettings({
const t = useTranslations();
const { env } = useEnvContext();
const { hasSaasSubscription } = usePaidStatus();
const { isPaidUser } = usePaidStatus();
// Auth page domain state
const [loginPage, setLoginPage] = useState(defaultLoginPage);
@@ -177,7 +177,7 @@ function AuthPageSettings({
try {
// Handle auth page domain
if (data.authPageDomainId) {
if (build === "enterprise" || hasSaasSubscription) {
if (isPaidUser(tierMatrix.loginPageDomain)) {
const sanitizedSubdomain = data.authPageSubdomain
? finalizeSubdomainSanitize(data.authPageSubdomain)
: "";
@@ -285,7 +285,7 @@ function AuthPageSettings({
</SettingsSectionHeader>
<SettingsSectionBody>
<SettingsSectionForm>
<PaidFeaturesAlert />
<PaidFeaturesAlert tiers={tierMatrix.loginPageDomain} />
<Form {...form}>
<form
@@ -361,7 +361,11 @@ function AuthPageSettings({
onClick={() =>
setEditDomainOpen(true)
}
disabled={!hasSaasSubscription}
disabled={
!isPaidUser(
tierMatrix.loginPageDomain
)
}
>
{form.watch("authPageDomainId")
? t("changeDomain")
@@ -376,7 +380,9 @@ function AuthPageSettings({
clearAuthPageDomain
}
disabled={
!hasSaasSubscription
!isPaidUser(
tierMatrix.loginPageDomain
)
}
>
<Trash2 size="14" />
@@ -395,7 +401,9 @@ function AuthPageSettings({
{env.flags.usePangolinDns &&
(build === "enterprise" ||
!hasSaasSubscription) &&
!isPaidUser(
tierMatrix.loginPageDomain
)) &&
loginPage?.domainId &&
loginPage?.fullDomain &&
!hasUnsavedChanges && (
@@ -424,7 +432,7 @@ function AuthPageSettings({
disabled={
isSubmitting ||
!hasUnsavedChanges ||
!hasSaasSubscription
!isPaidUser(tierMatrix.loginPageDomain)
}
>
{t("saveAuthPageDomain")}
@@ -477,7 +485,10 @@ function AuthPageSettings({
handleDomainSelection(selectedDomain);
}
}}
disabled={!selectedDomain || !hasSaasSubscription}
disabled={
!selectedDomain ||
!isPaidUser(tierMatrix.loginPageDomain)
}
>
{t("selectDomain")}
</Button>

View File

@@ -20,6 +20,8 @@ import {
import { Input } from "@app/components/ui/input";
import { useTranslations } from "next-intl";
import { Control, FieldValues, Path } from "react-hook-form";
import { usePaidStatus } from "@app/hooks/usePaidStatus";
import { tierMatrix } from "@server/lib/billing/tierMatrix";
type Role = {
roleId: number;
@@ -49,6 +51,8 @@ export default function AutoProvisionConfigWidget<T extends FieldValues>({
}: AutoProvisionConfigWidgetProps<T>) {
const t = useTranslations();
const { isPaidUser } = usePaidStatus();
return (
<div className="space-y-4">
<div className="mb-4">
@@ -57,6 +61,7 @@ export default function AutoProvisionConfigWidget<T extends FieldValues>({
label={t("idpAutoProvisionUsers")}
defaultChecked={autoProvision}
onCheckedChange={onAutoProvisionChange}
disabled={!isPaidUser(tierMatrix.autoProvisioning)}
/>
<span className="text-sm text-muted-foreground">
{t("idpAutoProvisionUsersDescription")}

View File

@@ -36,6 +36,7 @@ import { useForm } from "react-hook-form";
import { z } from "zod";
import { PaidFeaturesAlert } from "./PaidFeaturesAlert";
import { CheckboxWithLabel } from "./ui/checkbox";
import { tierMatrix } from "@server/lib/billing/tierMatrix";
type CreateRoleFormProps = {
open: boolean;
@@ -164,7 +165,9 @@ export default function CreateRoleForm({
{!env.flags.disableEnterpriseFeatures && (
<>
<PaidFeaturesAlert />
<PaidFeaturesAlert
tiers={tierMatrix.deviceApprovals}
/>
<FormField
control={form.control}
@@ -175,7 +178,9 @@ export default function CreateRoleForm({
<CheckboxWithLabel
{...field}
disabled={
!isPaidUser
!isPaidUser(
tierMatrix.deviceApprovals
)
}
value="on"
checked={form.watch(

View File

@@ -42,6 +42,7 @@ import { useForm } from "react-hook-form";
import { z } from "zod";
import { PaidFeaturesAlert } from "./PaidFeaturesAlert";
import { CheckboxWithLabel } from "./ui/checkbox";
import { tierMatrix } from "@server/lib/billing/tierMatrix";
type CreateRoleFormProps = {
role: Role;
@@ -172,7 +173,9 @@ export default function EditRoleForm({
{!env.flags.disableEnterpriseFeatures && (
<>
<PaidFeaturesAlert />
<PaidFeaturesAlert
tiers={tierMatrix.deviceApprovals}
/>
<FormField
control={form.control}
@@ -183,7 +186,9 @@ export default function EditRoleForm({
<CheckboxWithLabel
{...field}
disabled={
!isPaidUser
!isPaidUser(
tierMatrix.deviceApprovals
)
}
value="on"
checked={form.watch(

View File

@@ -120,6 +120,7 @@ type DataTableProps<TData, TValue> = {
// Row expansion props
expandable?: boolean;
renderExpandedRow?: (row: TData) => React.ReactNode;
isExportDisabled?: boolean;
};
export function LogDataTable<TData, TValue>({
@@ -145,7 +146,8 @@ export function LogDataTable<TData, TValue>({
isLoading = false,
expandable = false,
disabled = false,
renderExpandedRow
renderExpandedRow,
isExportDisabled
}: DataTableProps<TData, TValue>) {
const t = useTranslations();
@@ -403,7 +405,7 @@ export function LogDataTable<TData, TValue>({
onClick={() =>
!disabled && onExport()
}
disabled={isExporting || disabled}
disabled={isExporting || disabled || isExportDisabled}
>
{isExporting ? (
<Loader className="mr-2 size-4 animate-spin" />

View File

@@ -2,11 +2,9 @@
import { useState } from "react";
import { Button } from "@app/components/ui/button";
import { Alert, AlertDescription } from "@app/components/ui/alert";
import { useTranslations } from "next-intl";
import { Separator } from "./ui/separator";
import LoginPasswordForm from "./LoginPasswordForm";
import IdpLoginButtons from "./private/IdpLoginButtons";
import IdpLoginButtons from "./IdpLoginButtons";
import { LookupUserResponse } from "@server/routers/auth/lookupUser";
import UserProfileCard from "./UserProfileCard";

View File

@@ -2,7 +2,7 @@
import { ColumnDef } from "@tanstack/react-table";
import { ExtendedColumnDef } from "@app/components/ui/data-table";
import { IdpDataTable } from "@app/components/private/OrgIdpDataTable";
import { IdpDataTable } from "@app/components/OrgIdpDataTable";
import { Button } from "@app/components/ui/button";
import { ArrowRight, ArrowUpDown, MoreHorizontal } from "lucide-react";
import { useState } from "react";

View File

@@ -3,7 +3,7 @@ import {
LoadLoginPageBrandingResponse,
LoadLoginPageResponse
} from "@server/routers/loginPage/types";
import IdpLoginButtons from "@app/components/private/IdpLoginButtons";
import IdpLoginButtons from "@app/components/IdpLoginButtons";
import {
Card,
CardContent,

View File

@@ -1,4 +1,5 @@
"use client";
import { Card, CardContent } from "@app/components/ui/card";
import { build } from "@server/build";
import { usePaidStatus } from "@app/hooks/usePaidStatus";
@@ -6,6 +7,7 @@ import { ExternalLink, KeyRound, Sparkles } from "lucide-react";
import { useTranslations } from "next-intl";
import Link from "next/link";
import { useEnvContext } from "@app/hooks/useEnvContext";
import { Tier } from "@server/types/Tiers";
const bannerClassName =
"mb-6 border-primary/30 bg-linear-to-br from-primary/10 via-background to-background overflow-hidden";
@@ -13,7 +15,11 @@ const bannerContentClassName = "py-3 px-4";
const bannerRowClassName =
"flex items-center gap-2.5 text-sm text-muted-foreground";
export function PaidFeaturesAlert() {
type Props = {
tiers: Tier[];
};
export function PaidFeaturesAlert({ tiers }: Props) {
const t = useTranslations();
const { hasSaasSubscription, hasEnterpriseLicense } = usePaidStatus();
const { env } = useEnvContext();
@@ -24,7 +30,7 @@ export function PaidFeaturesAlert() {
return (
<>
{build === "saas" && !hasSaasSubscription ? (
{build === "saas" && !hasSaasSubscription(tiers) ? (
<Card className={bannerClassName}>
<CardContent className={bannerContentClassName}>
<div className={bannerRowClassName}>

View File

@@ -11,7 +11,7 @@ import {
InfoSectionTitle
} from "@app/components/InfoSection";
import { useTranslations } from "next-intl";
import CertificateStatus from "@app/components/private/CertificateStatus";
import CertificateStatus from "@app/components/CertificateStatus";
import { toUnicode } from "punycode";
import { useEnvContext } from "@app/hooks/useEnvContext";

View File

@@ -5,16 +5,12 @@ import DeleteRoleForm from "@app/components/DeleteRoleForm";
import { RolesDataTable } from "@app/components/RolesDataTable";
import { Button } from "@app/components/ui/button";
import { ExtendedColumnDef } from "@app/components/ui/data-table";
import { useEnvContext } from "@app/hooks/useEnvContext";
import { useOrgContext } from "@app/hooks/useOrgContext";
import { toast } from "@app/hooks/useToast";
import { createApiClient } from "@app/lib/api";
import { Role } from "@server/db";
import { ArrowRight, ArrowUpDown, Link, MoreHorizontal } from "lucide-react";
import { ArrowUpDown, MoreHorizontal } from "lucide-react";
import { useTranslations } from "next-intl";
import { useRouter } from "next/navigation";
import { useState, useTransition } from "react";
import { usePaidStatus } from "@app/hooks/usePaidStatus";
import {
DropdownMenu,
DropdownMenuTrigger,
@@ -38,11 +34,6 @@ export default function UsersTable({ roles }: RolesTableProps) {
const [roleToRemove, setRoleToRemove] = useState<RoleRow | null>(null);
const api = createApiClient(useEnvContext());
const { org } = useOrgContext();
const { isPaidUser } = usePaidStatus();
const t = useTranslations();
const [isRefreshing, startTransition] = useTransition();

View File

@@ -10,22 +10,23 @@ export function usePaidStatus() {
// Check if features are disabled due to licensing/subscription
const hasEnterpriseLicense = build === "enterprise" && isUnlocked();
const tierData = subscription?.getTier();
const hasSaasSubscription = build === "saas" && tierData?.active;
function hasSaasSubscription(tiers: Tier[]): boolean {
return (
(build === "saas" &&
tierData?.active &&
tierData?.tier &&
tiers.includes(tierData.tier)) ||
false
);
}
function isPaidUser(tiers: Tier[]): boolean {
if (hasEnterpriseLicense) {
return true;
}
if (
hasSaasSubscription &&
tierData?.tier &&
tiers.includes(tierData.tier)
) {
return true;
}
return false;
return hasSaasSubscription(tiers);
}
return {