From f2ba4b270f96bb49b0aa01e4e89b642abc186635 Mon Sep 17 00:00:00 2001 From: Owen Date: Thu, 29 Jan 2026 20:56:46 -0800 Subject: [PATCH 01/49] Dont write stripe to files anymore --- server/integrationApiServer.ts | 12 +- server/lib/billing/usageService.ts | 378 +++++---------------------- server/private/lib/config.ts | 5 +- server/private/lib/readConfigFile.ts | 2 +- 4 files changed, 71 insertions(+), 326 deletions(-) diff --git a/server/integrationApiServer.ts b/server/integrationApiServer.ts index 0ef0c0af..6d513cf6 100644 --- a/server/integrationApiServer.ts +++ b/server/integrationApiServer.ts @@ -105,11 +105,13 @@ function getOpenApiDocumentation() { servers: [{ url: "/v1" }] }); - // convert to yaml and save to file - const outputPath = path.join(APP_PATH, "openapi.yaml"); - const yamlOutput = yaml.dump(generated); - fs.writeFileSync(outputPath, yamlOutput, "utf8"); - logger.info(`OpenAPI documentation saved to ${outputPath}`); + if (!process.env.DISABLE_GEN_OPENAPI) { + // convert to yaml and save to file + const outputPath = path.join(APP_PATH, "openapi.yaml"); + const yamlOutput = yaml.dump(generated); + fs.writeFileSync(outputPath, yamlOutput, "utf8"); + logger.info(`OpenAPI documentation saved to ${outputPath}`); + } return generated; } diff --git a/server/lib/billing/usageService.ts b/server/lib/billing/usageService.ts index 0fde8eba..4d52ee69 100644 --- a/server/lib/billing/usageService.ts +++ b/server/lib/billing/usageService.ts @@ -1,8 +1,6 @@ import { eq, sql, and } from "drizzle-orm"; import { v4 as uuidv4 } from "uuid"; import { PutObjectCommand } from "@aws-sdk/client-s3"; -import * as fs from "fs/promises"; -import * as path from "path"; import { db, usage, @@ -34,8 +32,7 @@ interface StripeEvent { export function noop() { if ( build !== "saas" || - !process.env.S3_BUCKET || - !process.env.LOCAL_FILE_PATH + !process.env.S3_BUCKET ) { return true; } @@ -44,31 +41,37 @@ export function noop() { export class UsageService { private bucketName: string | undefined; - private currentEventFile: string | null = null; - private currentFileStartTime: number = 0; - private eventsDir: string | undefined; - private uploadingFiles: Set = new Set(); + private events: StripeEvent[] = []; + private lastUploadTime: number = Date.now(); + private isUploading: boolean = false; constructor() { if (noop()) { return; } - // this.bucketName = privateConfig.getRawPrivateConfig().stripe?.s3Bucket; - // this.eventsDir = privateConfig.getRawPrivateConfig().stripe?.localFilePath; this.bucketName = process.env.S3_BUCKET || undefined; - this.eventsDir = process.env.LOCAL_FILE_PATH || undefined; - // Ensure events directory exists - this.initializeEventsDirectory().then(() => { - this.uploadPendingEventFilesOnStartup(); - }); - - // Periodically check for old event files to upload + // Periodically check and upload events setInterval(() => { - this.uploadOldEventFiles().catch((err) => { - logger.error("Error in periodic event file upload:", err); + 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 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); + }); } /** @@ -78,85 +81,6 @@ export class UsageService { return Math.round(value * 100000000000) / 100000000000; // 11 decimal places } - private async initializeEventsDirectory(): Promise { - if (!this.eventsDir) { - logger.warn( - "Stripe local file path is not configured, skipping events directory initialization." - ); - return; - } - try { - await fs.mkdir(this.eventsDir, { recursive: true }); - } catch (error) { - logger.error("Failed to create events directory:", error); - } - } - - private async uploadPendingEventFilesOnStartup(): Promise { - if (!this.eventsDir || !this.bucketName) { - logger.warn( - "Stripe local file path or bucket name is not configured, skipping leftover event file upload." - ); - return; - } - try { - const files = await fs.readdir(this.eventsDir); - for (const file of files) { - if (file.endsWith(".json")) { - const filePath = path.join(this.eventsDir, file); - try { - const fileContent = await fs.readFile( - filePath, - "utf-8" - ); - const events = JSON.parse(fileContent); - if (Array.isArray(events) && events.length > 0) { - // Upload to S3 - const uploadCommand = new PutObjectCommand({ - Bucket: this.bucketName, - Key: file, - Body: fileContent, - ContentType: "application/json" - }); - await s3Client.send(uploadCommand); - - // Check if file still exists before unlinking - try { - await fs.access(filePath); - await fs.unlink(filePath); - } catch (unlinkError) { - logger.debug( - `Startup file ${file} was already deleted` - ); - } - - logger.info( - `Uploaded leftover event file ${file} to S3 with ${events.length} events` - ); - } else { - // Remove empty file - try { - await fs.access(filePath); - await fs.unlink(filePath); - } catch (unlinkError) { - logger.debug( - `Empty startup file ${file} was already deleted` - ); - } - } - } catch (err) { - logger.error( - `Error processing leftover event file ${file}:`, - err - ); - } - } - } - } catch (error) { - logger.error("Failed to scan for leftover event files"); - } - } - public async add( orgId: string, featureId: FeatureId, @@ -450,121 +374,58 @@ export class UsageService { } }; - await this.writeEventToFile(event); - await this.checkAndUploadFile(); + this.addEventToMemory(event); + await this.checkAndUploadEvents(); } - private async writeEventToFile(event: StripeEvent): Promise { - if (!this.eventsDir || !this.bucketName) { + private addEventToMemory(event: StripeEvent): void { + if (!this.bucketName) { logger.warn( - "Stripe local file path or bucket name is not configured, skipping event file write." + "S3 bucket name is not configured, skipping event storage." ); return; } - if (!this.currentEventFile) { - this.currentEventFile = this.generateEventFileName(); - this.currentFileStartTime = Date.now(); - } - - const filePath = path.join(this.eventsDir, this.currentEventFile); - - try { - let events: StripeEvent[] = []; - - // Try to read existing file - try { - const fileContent = await fs.readFile(filePath, "utf-8"); - events = JSON.parse(fileContent); - } catch (error) { - // File doesn't exist or is empty, start with empty array - events = []; - } - - // Add new event - events.push(event); - - // Write back to file - await fs.writeFile(filePath, JSON.stringify(events, null, 2)); - } catch (error) { - logger.error("Failed to write event to file:", error); - } + this.events.push(event); } - private async checkAndUploadFile(): Promise { - if (!this.currentEventFile) { - return; - } - + private async checkAndUploadEvents(): Promise { const now = Date.now(); - const fileAge = now - this.currentFileStartTime; + const timeSinceLastUpload = now - this.lastUploadTime; - // Check if file is at least 1 minute old - if (fileAge >= 60000) { - // 60 seconds - await this.uploadFileToS3(); + // Check if at least 1 minute has passed since last upload + if (timeSinceLastUpload >= 60000 && this.events.length > 0) { + await this.uploadEventsToS3(); } } - private async uploadFileToS3(): Promise { - if (!this.bucketName || !this.eventsDir) { + private async uploadEventsToS3(): Promise { + if (!this.bucketName) { logger.warn( - "Stripe local file path or bucket name is not configured, skipping S3 upload." - ); - return; - } - if (!this.currentEventFile) { - return; - } - - const fileName = this.currentEventFile; - const filePath = path.join(this.eventsDir, fileName); - - // Check if this file is already being uploaded - if (this.uploadingFiles.has(fileName)) { - logger.debug( - `File ${fileName} is already being uploaded, skipping` + "S3 bucket name is not configured, skipping S3 upload." ); return; } - // Mark file as being uploaded - this.uploadingFiles.add(fileName); + if (this.events.length === 0) { + return; + } + + // Check if already uploading + if (this.isUploading) { + logger.debug("Already uploading events, skipping"); + return; + } + + this.isUploading = true; try { - // Check if file exists before trying to read it - try { - await fs.access(filePath); - } catch (error) { - logger.debug( - `File ${fileName} does not exist, may have been already processed` - ); - this.uploadingFiles.delete(fileName); - // Reset current file if it was this file - if (this.currentEventFile === fileName) { - this.currentEventFile = null; - this.currentFileStartTime = 0; - } - return; - } + // Take a snapshot of current events and clear the array + const eventsToUpload = [...this.events]; + this.events = []; + this.lastUploadTime = Date.now(); - // Check if file exists and has content - const fileContent = await fs.readFile(filePath, "utf-8"); - const events = JSON.parse(fileContent); - - if (events.length === 0) { - // No events to upload, just clean up - try { - await fs.unlink(filePath); - } catch (unlinkError) { - // File may have been already deleted - logger.debug( - `File ${fileName} was already deleted during cleanup` - ); - } - this.currentEventFile = null; - this.uploadingFiles.delete(fileName); - return; - } + const fileName = this.generateEventFileName(); + const fileContent = JSON.stringify(eventsToUpload, null, 2); // Upload to S3 const uploadCommand = new PutObjectCommand({ @@ -576,29 +437,15 @@ export class UsageService { await s3Client.send(uploadCommand); - // Clean up local file - check if it still exists before unlinking - try { - await fs.access(filePath); - await fs.unlink(filePath); - } catch (unlinkError) { - // File may have been already deleted by another process - logger.debug( - `File ${fileName} was already deleted during upload` - ); - } - logger.info( - `Uploaded ${fileName} to S3 with ${events.length} events` + `Uploaded ${fileName} to S3 with ${eventsToUpload.length} events` ); - - // Reset for next file - this.currentEventFile = null; - this.currentFileStartTime = 0; } catch (error) { - logger.error(`Failed to upload ${fileName} to S3:`, error); + logger.error("Failed to upload events to S3:", error); + // Note: Events are lost if upload fails. In a production system, + // you might want to add the events back to the array or implement retry logic } finally { - // Always remove from uploading set - this.uploadingFiles.delete(fileName); + this.isUploading = false; } } @@ -695,111 +542,10 @@ export class UsageService { } public async forceUpload(): Promise { - await this.uploadFileToS3(); - } - - /** - * Scan the events directory for files older than 1 minute and upload them if not empty. - */ - private async uploadOldEventFiles(): Promise { - if (!this.eventsDir || !this.bucketName) { - logger.warn( - "Stripe local file path or bucket name is not configured, skipping old event file upload." - ); - return; - } - try { - const files = await fs.readdir(this.eventsDir); - const now = Date.now(); - for (const file of files) { - if (!file.endsWith(".json")) continue; - - // Skip files that are already being uploaded - if (this.uploadingFiles.has(file)) { - logger.debug( - `Skipping file ${file} as it's already being uploaded` - ); - continue; - } - - const filePath = path.join(this.eventsDir, file); - - try { - // Check if file still exists before processing - try { - await fs.access(filePath); - } catch (accessError) { - logger.debug(`File ${file} does not exist, skipping`); - continue; - } - - const stat = await fs.stat(filePath); - const age = now - stat.mtimeMs; - if (age >= 90000) { - // 1.5 minutes - Mark as being uploaded - this.uploadingFiles.add(file); - - try { - const fileContent = await fs.readFile( - filePath, - "utf-8" - ); - const events = JSON.parse(fileContent); - if (Array.isArray(events) && events.length > 0) { - // Upload to S3 - const uploadCommand = new PutObjectCommand({ - Bucket: this.bucketName, - Key: file, - Body: fileContent, - ContentType: "application/json" - }); - await s3Client.send(uploadCommand); - - // Check if file still exists before unlinking - try { - await fs.access(filePath); - await fs.unlink(filePath); - } catch (unlinkError) { - logger.debug( - `File ${file} was already deleted during interval upload` - ); - } - - logger.info( - `Interval: Uploaded event file ${file} to S3 with ${events.length} events` - ); - // If this was the current event file, reset it - if (this.currentEventFile === file) { - this.currentEventFile = null; - this.currentFileStartTime = 0; - } - } else { - // Remove empty file - try { - await fs.access(filePath); - await fs.unlink(filePath); - } catch (unlinkError) { - logger.debug( - `Empty file ${file} was already deleted` - ); - } - } - } finally { - // Always remove from uploading set - this.uploadingFiles.delete(file); - } - } - } catch (err) { - logger.error( - `Interval: Error processing event file ${file}:`, - err - ); - // Remove from uploading set on error - this.uploadingFiles.delete(file); - } - } - } catch (err) { - logger.error("Interval: Failed to scan for event files:", err); + if (this.events.length > 0) { + // Force upload regardless of time + this.lastUploadTime = 0; // Reset to force upload + await this.uploadEventsToS3(); } } diff --git a/server/private/lib/config.ts b/server/private/lib/config.ts index ae9ca5c7..f37ba2c1 100644 --- a/server/private/lib/config.ts +++ b/server/private/lib/config.ts @@ -128,10 +128,7 @@ export class PrivateConfig { if (this.rawPrivateConfig.stripe?.s3Bucket) { process.env.S3_BUCKET = this.rawPrivateConfig.stripe.s3Bucket; } - if (this.rawPrivateConfig.stripe?.localFilePath) { - process.env.LOCAL_FILE_PATH = - this.rawPrivateConfig.stripe.localFilePath; - } + if (this.rawPrivateConfig.stripe?.s3Region) { process.env.S3_REGION = this.rawPrivateConfig.stripe.s3Region; } diff --git a/server/private/lib/readConfigFile.ts b/server/private/lib/readConfigFile.ts index 374dee7c..34eccf29 100644 --- a/server/private/lib/readConfigFile.ts +++ b/server/private/lib/readConfigFile.ts @@ -161,7 +161,7 @@ export const privateConfigSchema = z.object({ webhook_secret: z.string(), s3Bucket: z.string(), s3Region: z.string().default("us-east-1"), - localFilePath: z.string() + localFilePath: z.string().optional() }) .optional() }); From f8993261891a2ff90d27056eecadcce6f93aa292 Mon Sep 17 00:00:00 2001 From: Owen Date: Thu, 5 Feb 2026 14:56:07 -0800 Subject: [PATCH 02/49] Change features, remove site uptime --- messages/en-US.json | 11 ++- server/lib/billing/features.ts | 71 +++++++++---------- server/lib/billing/limitSet.ts | 33 +++++++-- server/private/routers/billing/getOrgUsage.ts | 8 +-- server/routers/gerbil/receiveBandwidth.ts | 39 +--------- .../routers/newt/handleNewtRegisterMessage.ts | 8 +-- server/routers/site/createSite.ts | 71 ++++++++++++++++--- server/routers/site/deleteSite.ts | 17 ++++- .../settings/(private)/billing/page.tsx | 11 ++- 9 files changed, 160 insertions(+), 109 deletions(-) diff --git a/messages/en-US.json b/messages/en-US.json index e9d8cc37..b08c1cf8 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -1404,7 +1404,7 @@ "billingUsageLimitsOverview": "Usage Limits Overview", "billingMonitorUsage": "Monitor your usage against configured limits. If you need limits increased please contact us support@pangolin.net.", "billingDataUsage": "Data Usage", - "billingOnlineTime": "Site Online Time", + "billingSites": "Sites", "billingUsers": "Active Users", "billingDomains": "Active Domains", "billingRemoteExitNodes": "Active Self-hosted Nodes", @@ -1432,10 +1432,10 @@ "billingFailedToGetPortalUrl": "Failed to get portal URL", "billingPortalError": "Portal Error", "billingDataUsageInfo": "You're charged for all data transferred through your secure tunnels when connected to the cloud. This includes both incoming and outgoing traffic across all your sites. When you reach your limit, your sites will disconnect until you upgrade your plan or reduce usage. Data is not charged when using nodes.", - "billingOnlineTimeInfo": "You're charged based on how long your sites stay connected to the cloud. For example, 44,640 minutes equals one site running 24/7 for a full month. When you reach your limit, your sites will disconnect until you upgrade your plan or reduce usage. Time is not charged when using nodes.", - "billingUsersInfo": "You're charged for each user in the organization. Billing is calculated daily based on the number of active user accounts in your org.", - "billingDomainInfo": "You're charged for each domain in the organization. Billing is calculated daily based on the number of active domain accounts in your org.", - "billingRemoteExitNodesInfo": "You're charged for each managed Node in the organization. Billing is calculated daily based on the number of active managed Nodes in your org.", + "billingSInfo": "How many sites you can use", + "billingUsersInfo": "How many users you can use", + "billingDomainInfo": "How many domains you can use", + "billingRemoteExitNodesInfo": "How many remote nodes you can use", "billingLicenseKeys": "License Keys", "billingLicenseKeysDescription": "Manage your license key subscriptions", "billingLicenseSubscription": "License Subscription", @@ -1444,7 +1444,6 @@ "billingQuantity": "Quantity", "billingTotal": "total", "billingModifyLicenses": "Modify License Subscription", - "billingPricingCalculatorLink": "View Pricing Calculator", "domainNotFound": "Domain Not Found", "domainNotFoundDescription": "This resource is disabled because the domain no longer exists our system. Please set a new domain for this resource.", "failed": "Failed", diff --git a/server/lib/billing/features.ts b/server/lib/billing/features.ts index d074894a..1215a829 100644 --- a/server/lib/billing/features.ts +++ b/server/lib/billing/features.ts @@ -1,30 +1,22 @@ import Stripe from "stripe"; export enum FeatureId { - SITE_UPTIME = "siteUptime", USERS = "users", + SITES = "sites", EGRESS_DATA_MB = "egressDataMb", DOMAINS = "domains", REMOTE_EXIT_NODES = "remoteExitNodes" } -export const FeatureMeterIds: Record = { - [FeatureId.SITE_UPTIME]: "mtr_61Srrej5wUJuiTWgo41D3Ee2Ir7WmDLU", - [FeatureId.USERS]: "mtr_61SrreISyIWpwUNGR41D3Ee2Ir7WmQro", - [FeatureId.EGRESS_DATA_MB]: "mtr_61Srreh9eWrExDSCe41D3Ee2Ir7Wm5YW", - [FeatureId.DOMAINS]: "mtr_61Ss9nIKDNMw0LDRU41D3Ee2Ir7WmRPU", - [FeatureId.REMOTE_EXIT_NODES]: "mtr_61T86UXnfxTVXy9sD41D3Ee2Ir7WmFTE" +export const FeatureMeterIds: Partial> = { + [FeatureId.EGRESS_DATA_MB]: "mtr_61Srreh9eWrExDSCe41D3Ee2Ir7Wm5YW" }; -export const FeatureMeterIdsSandbox: Record = { - [FeatureId.SITE_UPTIME]: "mtr_test_61Snh3cees4w60gv841DCpkOb237BDEu", - [FeatureId.USERS]: "mtr_test_61Sn5fLtq1gSfRkyA41DCpkOb237B6au", - [FeatureId.EGRESS_DATA_MB]: "mtr_test_61Snh2a2m6qome5Kv41DCpkOb237B3dQ", - [FeatureId.DOMAINS]: "mtr_test_61SsA8qrdAlgPpFRQ41DCpkOb237BGts", - [FeatureId.REMOTE_EXIT_NODES]: "mtr_test_61T86Vqmwa3D9ra3341DCpkOb237B94K" +export const FeatureMeterIdsSandbox: Partial> = { + [FeatureId.EGRESS_DATA_MB]: "mtr_test_61Snh2a2m6qome5Kv41DCpkOb237B3dQ" }; -export function getFeatureMeterId(featureId: FeatureId): string { +export function getFeatureMeterId(featureId: FeatureId): string | undefined { if ( process.env.ENVIRONMENT == "prod" && process.env.SANDBOX_MODE !== "true" @@ -43,38 +35,43 @@ export function getFeatureIdByMetricId( )?.[0]; } -export type FeaturePriceSet = { - [key in Exclude]: string; -} & { - [FeatureId.DOMAINS]?: string; // Optional since domains are not billed +export type FeaturePriceSet = Partial>; + +export const starterFeaturePriceSet: FeaturePriceSet = { + [FeatureId.USERS]: "price_1RrQeJD3Ee2Ir7WmgveP3xea" }; -export const standardFeaturePriceSet: FeaturePriceSet = { - // Free tier matches the freeLimitSet - [FeatureId.SITE_UPTIME]: "price_1RrQc4D3Ee2Ir7WmaJGZ3MtF", - [FeatureId.USERS]: "price_1RrQeJD3Ee2Ir7WmgveP3xea", - [FeatureId.EGRESS_DATA_MB]: "price_1RrQXFD3Ee2Ir7WmvGDlgxQk", - // [FeatureId.DOMAINS]: "price_1Rz3tMD3Ee2Ir7Wm5qLeASzC", - [FeatureId.REMOTE_EXIT_NODES]: "price_1S46weD3Ee2Ir7Wm94KEHI4h" +export const starterFeaturePriceSetSandbox: FeaturePriceSet = { + [FeatureId.USERS]: "price_1ReNa4DCpkOb237Bc67G5muF" }; -export const standardFeaturePriceSetSandbox: FeaturePriceSet = { - // Free tier matches the freeLimitSet - [FeatureId.SITE_UPTIME]: "price_1RefFBDCpkOb237BPrKZ8IEU", - [FeatureId.USERS]: "price_1ReNa4DCpkOb237Bc67G5muF", - [FeatureId.EGRESS_DATA_MB]: "price_1Rfp9LDCpkOb237BwuN5Oiu0", - // [FeatureId.DOMAINS]: "price_1Ryi88DCpkOb237B2D6DM80b", - [FeatureId.REMOTE_EXIT_NODES]: "price_1RyiZvDCpkOb237BXpmoIYJL" -}; - -export function getStandardFeaturePriceSet(): FeaturePriceSet { +export function getStarterFeaturePriceSet(): FeaturePriceSet { if ( process.env.ENVIRONMENT == "prod" && process.env.SANDBOX_MODE !== "true" ) { - return standardFeaturePriceSet; + return starterFeaturePriceSet; } else { - return standardFeaturePriceSetSandbox; + return starterFeaturePriceSetSandbox; + } +} + +export const scaleFeaturePriceSet: FeaturePriceSet = { + [FeatureId.USERS]: "price_1RrQeJD3Ee2Ir7WmgveP3xea" +}; + +export const scaleFeaturePriceSetSandbox: FeaturePriceSet = { + [FeatureId.USERS]: "price_1ReNa4DCpkOb237Bc67G5muF" +}; + +export function getScaleFeaturePriceSet(): FeaturePriceSet { + if ( + process.env.ENVIRONMENT == "prod" && + process.env.SANDBOX_MODE !== "true" + ) { + return scaleFeaturePriceSet; + } else { + return scaleFeaturePriceSetSandbox; } } diff --git a/server/lib/billing/limitSet.ts b/server/lib/billing/limitSet.ts index fdd077d9..a7a21809 100644 --- a/server/lib/billing/limitSet.ts +++ b/server/lib/billing/limitSet.ts @@ -8,7 +8,7 @@ export type LimitSet = { }; export const sandboxLimitSet: LimitSet = { - [FeatureId.SITE_UPTIME]: { value: 2880, description: "Sandbox limit" }, // 1 site up for 2 days + [FeatureId.SITES]: { value: 1, description: "Sandbox limit" }, // 1 site up for 2 days [FeatureId.USERS]: { value: 1, description: "Sandbox limit" }, [FeatureId.EGRESS_DATA_MB]: { value: 1000, description: "Sandbox limit" }, // 1 GB [FeatureId.DOMAINS]: { value: 0, description: "Sandbox limit" }, @@ -16,7 +16,7 @@ export const sandboxLimitSet: LimitSet = { }; export const freeLimitSet: LimitSet = { - [FeatureId.SITE_UPTIME]: { value: 46080, description: "Free tier limit" }, // 1 site up for 32 days + [FeatureId.SITES]: { value: 3, description: "Free tier limit" }, // 1 site up for 32 days [FeatureId.USERS]: { value: 3, description: "Free tier limit" }, [FeatureId.EGRESS_DATA_MB]: { value: 25000, @@ -26,9 +26,32 @@ export const freeLimitSet: LimitSet = { [FeatureId.REMOTE_EXIT_NODES]: { value: 1, description: "Free tier limit" } }; -export const subscribedLimitSet: LimitSet = { - [FeatureId.SITE_UPTIME]: { - value: 2232000, +export const starterLimitSet: LimitSet = { + [FeatureId.SITES]: { + value: 10, + description: "Contact us to increase soft limit." + }, // 50 sites up for 31 days + [FeatureId.USERS]: { + value: 150, + description: "Contact us to increase soft limit." + }, + [FeatureId.EGRESS_DATA_MB]: { + value: 12000000, + description: "Contact us to increase soft limit." + }, // 12000 GB + [FeatureId.DOMAINS]: { + value: 250, + description: "Contact us to increase soft limit." + }, + [FeatureId.REMOTE_EXIT_NODES]: { + value: 5, + description: "Contact us to increase soft limit." + } +}; + +export const scaleLimitSet: LimitSet = { + [FeatureId.SITES]: { + value: 10, description: "Contact us to increase soft limit." }, // 50 sites up for 31 days [FeatureId.USERS]: { diff --git a/server/private/routers/billing/getOrgUsage.ts b/server/private/routers/billing/getOrgUsage.ts index 1a343730..9d65e98b 100644 --- a/server/private/routers/billing/getOrgUsage.ts +++ b/server/private/routers/billing/getOrgUsage.ts @@ -78,9 +78,9 @@ export async function getOrgUsage( // Get usage for org const usageData = []; - const siteUptime = await usageService.getUsage( + const sites = await usageService.getUsage( orgId, - FeatureId.SITE_UPTIME + FeatureId.SITES ); const users = await usageService.getUsageDaily(orgId, FeatureId.USERS); const domains = await usageService.getUsageDaily( @@ -96,8 +96,8 @@ export async function getOrgUsage( FeatureId.EGRESS_DATA_MB ); - if (siteUptime) { - usageData.push(siteUptime); + if (sites) { + usageData.push(sites); } if (users) { usageData.push(users); diff --git a/server/routers/gerbil/receiveBandwidth.ts b/server/routers/gerbil/receiveBandwidth.ts index 5c9cacb2..a2306d27 100644 --- a/server/routers/gerbil/receiveBandwidth.ts +++ b/server/routers/gerbil/receiveBandwidth.ts @@ -114,7 +114,6 @@ export async function updateSiteBandwidth( // Aggregate usage data by organization (collected outside transaction) const orgUsageMap = new Map(); - const orgUptimeMap = new Map(); if (activePeers.length > 0) { // Remove any active peers from offline tracking since they're sending data @@ -166,14 +165,6 @@ export async function updateSiteBandwidth( updatedSite.orgId, currentOrgUsage + totalBandwidth ); - - // Add 10 seconds of uptime for each active site - const currentOrgUptime = - orgUptimeMap.get(updatedSite.orgId) || 0; - orgUptimeMap.set( - updatedSite.orgId, - currentOrgUptime + 10 / 60 - ); } } catch (error) { logger.error( @@ -187,10 +178,10 @@ export async function updateSiteBandwidth( // Process usage updates outside of site update transactions // This separates the concerns and reduces lock contention - if (calcUsageAndLimits && (orgUsageMap.size > 0 || orgUptimeMap.size > 0)) { + if (calcUsageAndLimits && (orgUsageMap.size > 0)) { // Sort org IDs to ensure consistent lock ordering const allOrgIds = [ - ...new Set([...orgUsageMap.keys(), ...orgUptimeMap.keys()]) + ...new Set([...orgUsageMap.keys()]) ].sort(); for (const orgId of allOrgIds) { @@ -220,32 +211,6 @@ export async function updateSiteBandwidth( }); } } - - // Process uptime usage for this org - const totalUptime = orgUptimeMap.get(orgId); - if (totalUptime) { - const uptimeUsage = await usageService.add( - orgId, - FeatureId.SITE_UPTIME, - totalUptime - ); - if (uptimeUsage) { - // Fire and forget - don't block on limit checking - usageService - .checkLimitSet( - orgId, - true, - FeatureId.SITE_UPTIME, - uptimeUsage - ) - .catch((error: any) => { - logger.error( - `Error checking uptime limits for org ${orgId}:`, - error - ); - }); - } - } } catch (error) { logger.error(`Error processing usage for org ${orgId}:`, error); // Continue with other orgs diff --git a/server/routers/newt/handleNewtRegisterMessage.ts b/server/routers/newt/handleNewtRegisterMessage.ts index 9ffee919..3a018fdc 100644 --- a/server/routers/newt/handleNewtRegisterMessage.ts +++ b/server/routers/newt/handleNewtRegisterMessage.ts @@ -96,10 +96,10 @@ export const handleNewtRegisterMessage: MessageHandler = async (context) => { fetchContainers(newt.newtId); } - const rejectSiteUptime = await usageService.checkLimitSet( + const rejectSites = await usageService.checkLimitSet( oldSite.orgId, false, - FeatureId.SITE_UPTIME + FeatureId.SITES ); const rejectEgressDataMb = await usageService.checkLimitSet( oldSite.orgId, @@ -111,8 +111,8 @@ export const handleNewtRegisterMessage: MessageHandler = async (context) => { // const rejectUsers = await usageService.checkLimitSet(oldSite.orgId, false, FeatureId.USERS); // const rejectDomains = await usageService.checkLimitSet(oldSite.orgId, false, FeatureId.DOMAINS); - // if (rejectEgressDataMb || rejectSiteUptime || rejectUsers || rejectDomains) { - if (rejectEgressDataMb || rejectSiteUptime) { + // if (rejectEgressDataMb || rejectSites || rejectUsers || rejectDomains) { + if (rejectEgressDataMb || rejectSites) { logger.info( `Usage limits exceeded for org ${oldSite.orgId}. Rejecting newt registration.` ); diff --git a/server/routers/site/createSite.ts b/server/routers/site/createSite.ts index c798ea30..ece97d1d 100644 --- a/server/routers/site/createSite.ts +++ b/server/routers/site/createSite.ts @@ -18,6 +18,8 @@ import { isValidIP } from "@server/lib/validators"; import { isIpInCidr } from "@server/lib/ip"; import { verifyExitNodeOrgAccess } from "#dynamic/lib/exitNodes"; import { build } from "@server/build"; +import { usageService } from "@server/lib/billing/usageService"; +import { FeatureId } from "@server/lib/billing"; const createSiteParamsSchema = z.strictObject({ orgId: z.string() @@ -126,6 +128,35 @@ export async function createSite( ); } + if (build == "saas") { + const usage = await usageService.getUsage(orgId, FeatureId.SITES); + if (!usage) { + return next( + createHttpError( + HttpCode.NOT_FOUND, + "No usage data found for this organization" + ) + ); + } + const rejectSites = await usageService.checkLimitSet( + orgId, + false, + FeatureId.SITES, + { + ...usage, + instantaneousValue: (usage.instantaneousValue || 0) + 1 + } // We need to add one to know if we are violating the limit + ); + if (rejectSites) { + return next( + createHttpError( + HttpCode.FORBIDDEN, + "Sites limit exceeded. Please upgrade your plan." + ) + ); + } + } + let updatedAddress = null; if (address) { if (!org.subnet) { @@ -256,8 +287,8 @@ export async function createSite( const niceId = await getUniqueSiteName(orgId); - let newSite: Site; - + let newSite: Site | undefined; + let numSites: Site[] | undefined; await db.transaction(async (trx) => { if (type == "wireguard" || type == "newt") { // we are creating a site with an exit node (tunneled) @@ -402,13 +433,35 @@ export async function createSite( }); } - return response(res, { - data: newSite, - success: true, - error: false, - message: "Site created successfully", - status: HttpCode.CREATED - }); + numSites = await trx + .select() + .from(sites) + .where(eq(sites.orgId, orgId)); + }); + + if (numSites) { + await usageService.updateDaily( + orgId, + FeatureId.SITES, + numSites.length + ); + } + + if (!newSite) { + return next( + createHttpError( + HttpCode.INTERNAL_SERVER_ERROR, + "Failed to create site" + ) + ); + } + + return response(res, { + data: newSite, + success: true, + error: false, + message: "Site created successfully", + status: HttpCode.CREATED }); } catch (error) { logger.error(error); diff --git a/server/routers/site/deleteSite.ts b/server/routers/site/deleteSite.ts index 94d9d920..29159352 100644 --- a/server/routers/site/deleteSite.ts +++ b/server/routers/site/deleteSite.ts @@ -1,6 +1,6 @@ import { Request, Response, NextFunction } from "express"; import { z } from "zod"; -import { db, siteResources } from "@server/db"; +import { db, Site, siteResources } from "@server/db"; import { newts, newtSessions, sites } from "@server/db"; import { eq } from "drizzle-orm"; import response from "@server/lib/response"; @@ -12,6 +12,8 @@ import { fromError } from "zod-validation-error"; import { sendToClient } from "#dynamic/routers/ws"; import { OpenAPITags, registry } from "@server/openApi"; import { rebuildClientAssociationsFromSiteResource } from "@server/lib/rebuildClientAssociations"; +import { usageService } from "@server/lib/billing/usageService"; +import { FeatureId } from "@server/lib/billing"; const deleteSiteSchema = z.strictObject({ siteId: z.string().transform(Number).pipe(z.int().positive()) @@ -62,6 +64,7 @@ export async function deleteSite( } let deletedNewtId: string | null = null; + let numSites: Site[] | undefined; await db.transaction(async (trx) => { if (site.type == "wireguard") { @@ -99,8 +102,20 @@ export async function deleteSite( } await trx.delete(sites).where(eq(sites.siteId, siteId)); + + numSites = await trx + .select() + .from(sites) + .where(eq(sites.orgId, site.orgId)); }); + if (numSites) { + await usageService.updateDaily( + site.orgId, + FeatureId.SITES, + numSites.length + ); + } // Send termination message outside of transaction to prevent blocking if (deletedNewtId) { const payload = { diff --git a/src/app/[orgId]/settings/(private)/billing/page.tsx b/src/app/[orgId]/settings/(private)/billing/page.tsx index e1879aa6..7b10dc4c 100644 --- a/src/app/[orgId]/settings/(private)/billing/page.tsx +++ b/src/app/[orgId]/settings/(private)/billing/page.tsx @@ -207,7 +207,7 @@ export default function GeneralPage() { }; // Usage IDs - const SITE_UPTIME = "siteUptime"; + const SITES = "sites"; const USERS = "users"; const EGRESS_DATA_MB = "egressDataMb"; const DOMAINS = "domains"; @@ -362,12 +362,11 @@ export default function GeneralPage() { getLimitUsage: (v: any) => v.latestValue }, { - id: SITE_UPTIME, - label: t("billingOnlineTime"), + id: SITES, + label: t("billingSites"), icon: , - unit: "min", - info: t("billingOnlineTimeInfo"), - note: "Not counted on self-hosted nodes", + unit: "", + info: t("billingSitesInfo"), getDisplay: (v: any) => v.latestValue, getLimitDisplay: (v: any) => v.value, getUsage: (v: any) => v.latestValue, From a8f6b6c1daee1e2af0d1931b2bedeef32f062b99 Mon Sep 17 00:00:00 2001 From: miloschwartz Date: Thu, 5 Feb 2026 16:55:00 -0800 Subject: [PATCH 03/49] prefill username in login --- src/app/auth/login/device/page.tsx | 28 ++++++++++++++++++++------- src/app/auth/login/page.tsx | 4 ++++ src/components/DashboardLoginForm.tsx | 5 ++++- src/components/DeviceLoginForm.tsx | 13 +++++++++---- src/components/LoginForm.tsx | 6 ++++-- src/components/SmartLoginForm.tsx | 16 ++++++++++++--- 6 files changed, 55 insertions(+), 17 deletions(-) diff --git a/src/app/auth/login/device/page.tsx b/src/app/auth/login/device/page.tsx index 9b6b2bd2..7d2ed4e3 100644 --- a/src/app/auth/login/device/page.tsx +++ b/src/app/auth/login/device/page.tsx @@ -7,22 +7,35 @@ import { cache } from "react"; export const dynamic = "force-dynamic"; type Props = { - searchParams: Promise<{ code?: string }>; + searchParams: Promise<{ code?: string; user?: string }>; }; +function deviceRedirectSearchParams(params: { + code?: string; + user?: string; +}): string { + const search = new URLSearchParams(); + if (params.code) search.set("code", params.code); + if (params.user) search.set("user", params.user); + const q = search.toString(); + return q ? `?${q}` : ""; +} + export default async function DeviceLoginPage({ searchParams }: Props) { const user = await verifySession({ forceLogin: true }); const params = await searchParams; const code = params.code || ""; + const defaultUser = params.user; if (!user) { - const redirectDestination = code - ? `/auth/login/device?code=${encodeURIComponent(code)}` - : "/auth/login/device"; - redirect( - `/auth/login?forceLogin=true&redirect=${encodeURIComponent(redirectDestination)}` - ); + const redirectDestination = `/auth/login/device${deviceRedirectSearchParams({ code, user: params.user })}`; + const loginUrl = new URL("/auth/login", "http://x"); + loginUrl.searchParams.set("forceLogin", "true"); + loginUrl.searchParams.set("redirect", redirectDestination); + if (defaultUser) loginUrl.searchParams.set("user", defaultUser); + console.log("loginUrl", loginUrl.pathname + loginUrl.search); + redirect(loginUrl.pathname + loginUrl.search); } const userName = user @@ -37,6 +50,7 @@ export default async function DeviceLoginPage({ searchParams }: Props) { userEmail={user?.email || ""} userName={userName} initialCode={code} + userQueryParam={defaultUser} /> ); } diff --git a/src/app/auth/login/page.tsx b/src/app/auth/login/page.tsx index 071020cd..2ba4d7f8 100644 --- a/src/app/auth/login/page.tsx +++ b/src/app/auth/login/page.tsx @@ -72,6 +72,8 @@ export default async function Page(props: { searchParams.redirect = redirectUrl; } + const defaultUser = searchParams.user as string | undefined; + // Only use SmartLoginForm if NOT (OSS build OR org-only IdP enabled) const useSmartLogin = build === "saas" || (build === "enterprise" && env.flags.useOrgOnlyIdp); @@ -151,6 +153,7 @@ export default async function Page(props: { @@ -165,6 +168,7 @@ export default async function Page(props: { (build === "saas" || env.flags.useOrgOnlyIdp) } searchParams={searchParams} + defaultUser={defaultUser} /> )} diff --git a/src/components/DashboardLoginForm.tsx b/src/components/DashboardLoginForm.tsx index 8a4c611e..4484ba69 100644 --- a/src/components/DashboardLoginForm.tsx +++ b/src/components/DashboardLoginForm.tsx @@ -29,6 +29,7 @@ type DashboardLoginFormProps = { searchParams?: { [key: string]: string | string[] | undefined; }; + defaultUser?: string; }; export default function DashboardLoginForm({ @@ -36,7 +37,8 @@ export default function DashboardLoginForm({ idps, forceLogin, showOrgLogin, - searchParams + searchParams, + defaultUser }: DashboardLoginFormProps) { const router = useRouter(); const { env } = useEnvContext(); @@ -75,6 +77,7 @@ export default function DashboardLoginForm({ redirect={redirect} idps={idps} forceLogin={forceLogin} + defaultEmail={defaultUser} onLogin={(redirectUrl) => { if (redirectUrl) { const safe = cleanRedirect(redirectUrl); diff --git a/src/components/DeviceLoginForm.tsx b/src/components/DeviceLoginForm.tsx index cadeb230..16e7f2e1 100644 --- a/src/components/DeviceLoginForm.tsx +++ b/src/components/DeviceLoginForm.tsx @@ -55,12 +55,14 @@ type DeviceLoginFormProps = { userEmail: string; userName?: string; initialCode?: string; + userQueryParam?: string; }; export default function DeviceLoginForm({ userEmail, userName, - initialCode = "" + initialCode = "", + userQueryParam }: DeviceLoginFormProps) { const router = useRouter(); const { env } = useEnvContext(); @@ -219,9 +221,12 @@ export default function DeviceLoginForm({ const currentSearch = typeof window !== "undefined" ? window.location.search : ""; const redirectTarget = `/auth/login/device${currentSearch || ""}`; - router.push( - `/auth/login?forceLogin=true&redirect=${encodeURIComponent(redirectTarget)}` - ); + const loginUrl = new URL("/auth/login", "http://x"); + loginUrl.searchParams.set("forceLogin", "true"); + loginUrl.searchParams.set("redirect", redirectTarget); + if (userQueryParam) + loginUrl.searchParams.set("user", userQueryParam); + router.push(loginUrl.pathname + loginUrl.search); router.refresh(); } } diff --git a/src/components/LoginForm.tsx b/src/components/LoginForm.tsx index 5497826c..c3b1fc38 100644 --- a/src/components/LoginForm.tsx +++ b/src/components/LoginForm.tsx @@ -54,6 +54,7 @@ type LoginFormProps = { idps?: LoginFormIDP[]; orgId?: string; forceLogin?: boolean; + defaultEmail?: string; }; export default function LoginForm({ @@ -61,7 +62,8 @@ export default function LoginForm({ onLogin, idps, orgId, - forceLogin + forceLogin, + defaultEmail }: LoginFormProps) { const router = useRouter(); @@ -116,7 +118,7 @@ export default function LoginForm({ const form = useForm({ resolver: zodResolver(formSchema), defaultValues: { - email: "", + email: defaultEmail ?? "", password: "" } }); diff --git a/src/components/SmartLoginForm.tsx b/src/components/SmartLoginForm.tsx index 5e1498ff..24f2acb7 100644 --- a/src/components/SmartLoginForm.tsx +++ b/src/components/SmartLoginForm.tsx @@ -1,6 +1,6 @@ "use client"; -import { useState } from "react"; +import { useEffect, useRef, useState } from "react"; import { useForm } from "react-hook-form"; import { zodResolver } from "@hookform/resolvers/zod"; import * as z from "zod"; @@ -42,6 +42,7 @@ const isValidEmail = (str: string): boolean => { type SmartLoginFormProps = { redirect?: string; forceLogin?: boolean; + defaultUser?: string; }; type ViewState = @@ -59,7 +60,8 @@ type ViewState = export default function SmartLoginForm({ redirect, - forceLogin + forceLogin, + defaultUser }: SmartLoginFormProps) { const router = useRouter(); const { lookup, loading, error } = useUserLookup(); @@ -72,10 +74,18 @@ export default function SmartLoginForm({ const form = useForm>({ resolver: zodResolver(identifierSchema), defaultValues: { - identifier: "" + identifier: defaultUser ?? "" } }); + const hasAutoLookedUp = useRef(false); + useEffect(() => { + if (defaultUser?.trim() && !hasAutoLookedUp.current) { + hasAutoLookedUp.current = true; + void handleLookup({ identifier: defaultUser.trim() }); + } + }, [defaultUser]); + const handleLookup = async (values: z.infer) => { const identifier = values.identifier.trim(); const isEmail = isValidEmail(identifier); From ac09e3aaf99bf16c3a65ad64f10d34193f76bab1 Mon Sep 17 00:00:00 2001 From: Owen Date: Fri, 6 Feb 2026 10:47:19 -0800 Subject: [PATCH 04/49] Wrap insert in transaction Ref #2222 --- server/routers/badger/logRequestAudit.ts | 28 ++++++++++++++++-------- 1 file changed, 19 insertions(+), 9 deletions(-) diff --git a/server/routers/badger/logRequestAudit.ts b/server/routers/badger/logRequestAudit.ts index 026ee4bb..5975d8f3 100644 --- a/server/routers/badger/logRequestAudit.ts +++ b/server/routers/badger/logRequestAudit.ts @@ -1,6 +1,6 @@ import { db, orgs, requestAuditLog } from "@server/db"; import logger from "@server/logger"; -import { and, eq, lt } from "drizzle-orm"; +import { and, eq, lt, sql } from "drizzle-orm"; import cache from "@server/lib/cache"; import { calculateCutoffTimestamp } from "@server/lib/cleanupLogs"; import { stripPortFromHost } from "@server/lib/ip"; @@ -67,17 +67,27 @@ async function flushAuditLogs() { const logsToWrite = auditLogBuffer.splice(0, auditLogBuffer.length); try { - // Batch insert logs in groups of 25 to avoid overwhelming the database - const BATCH_DB_SIZE = 25; - for (let i = 0; i < logsToWrite.length; i += BATCH_DB_SIZE) { - const batch = logsToWrite.slice(i, i + BATCH_DB_SIZE); - await db.insert(requestAuditLog).values(batch); - } + // Use a transaction to ensure all inserts succeed or fail together + // This prevents index corruption from partial writes + await db.transaction(async (tx) => { + // Batch insert logs in groups of 25 to avoid overwhelming the database + const BATCH_DB_SIZE = 25; + for (let i = 0; i < logsToWrite.length; i += BATCH_DB_SIZE) { + const batch = logsToWrite.slice(i, i + BATCH_DB_SIZE); + await tx.insert(requestAuditLog).values(batch); + } + }); logger.debug(`Flushed ${logsToWrite.length} audit logs to database`); } catch (error) { logger.error("Error flushing audit logs:", error); - // On error, we lose these logs - consider a fallback strategy if needed - // (e.g., write to file, or put back in buffer with retry limit) + // On transaction error, put logs back at the front of the buffer to retry + // but only if buffer isn't too large + if (auditLogBuffer.length < MAX_BUFFER_SIZE - logsToWrite.length) { + auditLogBuffer.unshift(...logsToWrite); + logger.info(`Re-queued ${logsToWrite.length} audit logs for retry`); + } else { + logger.error(`Buffer full, dropped ${logsToWrite.length} audit logs`); + } } finally { isFlushInProgress = false; // If buffer filled up while we were flushing, flush again From 34cced872fd6429b6e15bc525c52900a6ebe9c97 Mon Sep 17 00:00:00 2001 From: Owen Date: Fri, 6 Feb 2026 10:47:43 -0800 Subject: [PATCH 05/49] Switching to new pricing - remove old feature tracking --- server/db/pg/schema/privateSchema.ts | 3 +- server/db/sqlite/schema/privateSchema.ts | 3 +- server/lib/billing/features.ts | 36 +- server/lib/billing/limitSet.ts | 31 +- server/lib/billing/tiers.ts | 34 - server/private/lib/billing/getOrgTierData.ts | 23 - server/private/routers/auth/index.ts | 1 - server/private/routers/auth/quickStart.ts | 585 ------------------ server/private/routers/billing/changeTier.ts | 263 ++++++++ ...essionSAAS.ts => createCheckoutSession.ts} | 40 +- .../routers/billing/hooks/getSubType.ts | 68 +- .../hooks/handleSubscriptionCreated.ts | 10 +- .../hooks/handleSubscriptionUpdated.ts | 12 +- server/private/routers/billing/index.ts | 2 +- server/private/routers/external.ts | 19 +- .../settings/(private)/billing/page.tsx | 2 +- 16 files changed, 409 insertions(+), 723 deletions(-) delete mode 100644 server/lib/billing/tiers.ts delete mode 100644 server/private/routers/auth/quickStart.ts create mode 100644 server/private/routers/billing/changeTier.ts rename server/private/routers/billing/{createCheckoutSessionSAAS.ts => createCheckoutSession.ts} (72%) diff --git a/server/db/pg/schema/privateSchema.ts b/server/db/pg/schema/privateSchema.ts index 0512af22..2ebb145b 100644 --- a/server/db/pg/schema/privateSchema.ts +++ b/server/db/pg/schema/privateSchema.ts @@ -82,7 +82,8 @@ export const subscriptions = pgTable("subscriptions", { canceledAt: bigint("canceledAt", { mode: "number" }), createdAt: bigint("createdAt", { mode: "number" }).notNull(), updatedAt: bigint("updatedAt", { mode: "number" }), - billingCycleAnchor: bigint("billingCycleAnchor", { mode: "number" }) + billingCycleAnchor: bigint("billingCycleAnchor", { mode: "number" }), + type: varchar("type", { length: 50 }) // home_lab, starter, scale, or license }); export const subscriptionItems = pgTable("subscriptionItems", { diff --git a/server/db/sqlite/schema/privateSchema.ts b/server/db/sqlite/schema/privateSchema.ts index 2661ccdd..27979460 100644 --- a/server/db/sqlite/schema/privateSchema.ts +++ b/server/db/sqlite/schema/privateSchema.ts @@ -70,7 +70,8 @@ export const subscriptions = sqliteTable("subscriptions", { canceledAt: integer("canceledAt"), createdAt: integer("createdAt").notNull(), updatedAt: integer("updatedAt"), - billingCycleAnchor: integer("billingCycleAnchor") + billingCycleAnchor: integer("billingCycleAnchor"), + type: text("type") // home_lab, starter, scale, or license }); export const subscriptionItems = sqliteTable("subscriptionItems", { diff --git a/server/lib/billing/features.ts b/server/lib/billing/features.ts index 1215a829..a3ab0cc8 100644 --- a/server/lib/billing/features.ts +++ b/server/lib/billing/features.ts @@ -5,15 +5,16 @@ export enum FeatureId { SITES = "sites", EGRESS_DATA_MB = "egressDataMb", DOMAINS = "domains", - REMOTE_EXIT_NODES = "remoteExitNodes" + REMOTE_EXIT_NODES = "remoteExitNodes", + HOME_LAB = "home_lab" } -export const FeatureMeterIds: Partial> = { - [FeatureId.EGRESS_DATA_MB]: "mtr_61Srreh9eWrExDSCe41D3Ee2Ir7Wm5YW" +export const FeatureMeterIds: Partial> = { // right now we are not charging for any data + // [FeatureId.EGRESS_DATA_MB]: "mtr_61Srreh9eWrExDSCe41D3Ee2Ir7Wm5YW" }; export const FeatureMeterIdsSandbox: Partial> = { - [FeatureId.EGRESS_DATA_MB]: "mtr_test_61Snh2a2m6qome5Kv41DCpkOb237B3dQ" + // [FeatureId.EGRESS_DATA_MB]: "mtr_test_61Snh2a2m6qome5Kv41DCpkOb237B3dQ" }; export function getFeatureMeterId(featureId: FeatureId): string | undefined { @@ -37,12 +38,31 @@ export function getFeatureIdByMetricId( export type FeaturePriceSet = Partial>; +export const homeLabFeaturePriceSet: FeaturePriceSet = { + [FeatureId.HOME_LAB]: "price_1SxgpPDCpkOb237Bfo4rIsoT" +}; + +export const homeLabFeaturePriceSetSandbox: FeaturePriceSet = { + [FeatureId.HOME_LAB]: "price_1SxgpPDCpkOb237Bfo4rIsoT" +}; + +export function getHomeLabFeaturePriceSet(): FeaturePriceSet { + if ( + process.env.ENVIRONMENT == "prod" && + process.env.SANDBOX_MODE !== "true" + ) { + return homeLabFeaturePriceSet; + } else { + return homeLabFeaturePriceSetSandbox; + } +} + export const starterFeaturePriceSet: FeaturePriceSet = { - [FeatureId.USERS]: "price_1RrQeJD3Ee2Ir7WmgveP3xea" + [FeatureId.USERS]: "price_1SxaEHDCpkOb237BD9lBkPiR" }; export const starterFeaturePriceSetSandbox: FeaturePriceSet = { - [FeatureId.USERS]: "price_1ReNa4DCpkOb237Bc67G5muF" + [FeatureId.USERS]: "price_1SxaEHDCpkOb237BD9lBkPiR" }; export function getStarterFeaturePriceSet(): FeaturePriceSet { @@ -57,11 +77,11 @@ export function getStarterFeaturePriceSet(): FeaturePriceSet { } export const scaleFeaturePriceSet: FeaturePriceSet = { - [FeatureId.USERS]: "price_1RrQeJD3Ee2Ir7WmgveP3xea" + [FeatureId.USERS]: "price_1SxaEODCpkOb237BiXdCBSfs" }; export const scaleFeaturePriceSetSandbox: FeaturePriceSet = { - [FeatureId.USERS]: "price_1ReNa4DCpkOb237Bc67G5muF" + [FeatureId.USERS]: "price_1SxaEODCpkOb237BiXdCBSfs" }; export function getScaleFeaturePriceSet(): FeaturePriceSet { diff --git a/server/lib/billing/limitSet.ts b/server/lib/billing/limitSet.ts index a7a21809..12aea306 100644 --- a/server/lib/billing/limitSet.ts +++ b/server/lib/billing/limitSet.ts @@ -26,48 +26,59 @@ export const freeLimitSet: LimitSet = { [FeatureId.REMOTE_EXIT_NODES]: { value: 1, description: "Free tier limit" } }; +export const homeLabLimitSet: LimitSet = { + [FeatureId.SITES]: { value: 3, description: "Home lab limit" }, // 1 site up for 32 days + [FeatureId.USERS]: { value: 3, description: "Home lab limit" }, + [FeatureId.EGRESS_DATA_MB]: { + value: 25000, + description: "Home lab limit" + }, // 25 GB + [FeatureId.DOMAINS]: { value: 3, description: "Home lab limit" }, + [FeatureId.REMOTE_EXIT_NODES]: { value: 1, description: "Home lab limit" } +}; + export const starterLimitSet: LimitSet = { [FeatureId.SITES]: { value: 10, - description: "Contact us to increase soft limit." + description: "Starter limit" }, // 50 sites up for 31 days [FeatureId.USERS]: { value: 150, - description: "Contact us to increase soft limit." + description: "Starter limit" }, [FeatureId.EGRESS_DATA_MB]: { value: 12000000, - description: "Contact us to increase soft limit." + description: "Starter limit" }, // 12000 GB [FeatureId.DOMAINS]: { value: 250, - description: "Contact us to increase soft limit." + description: "Starter limit" }, [FeatureId.REMOTE_EXIT_NODES]: { value: 5, - description: "Contact us to increase soft limit." + description: "Starter limit" } }; export const scaleLimitSet: LimitSet = { [FeatureId.SITES]: { value: 10, - description: "Contact us to increase soft limit." + description: "Scale limit" }, // 50 sites up for 31 days [FeatureId.USERS]: { value: 150, - description: "Contact us to increase soft limit." + description: "Scale limit" }, [FeatureId.EGRESS_DATA_MB]: { value: 12000000, - description: "Contact us to increase soft limit." + description: "Scale limit" }, // 12000 GB [FeatureId.DOMAINS]: { value: 250, - description: "Contact us to increase soft limit." + description: "Scale limit" }, [FeatureId.REMOTE_EXIT_NODES]: { value: 5, - description: "Contact us to increase soft limit." + description: "Scale limit" } }; diff --git a/server/lib/billing/tiers.ts b/server/lib/billing/tiers.ts deleted file mode 100644 index ae49a48f..00000000 --- a/server/lib/billing/tiers.ts +++ /dev/null @@ -1,34 +0,0 @@ -export enum TierId { - STANDARD = "standard" -} - -export type TierPriceSet = { - [key in TierId]: string; -}; - -export const tierPriceSet: TierPriceSet = { - // Free tier matches the freeLimitSet - [TierId.STANDARD]: "price_1RrQ9cD3Ee2Ir7Wmqdy3KBa0" -}; - -export const tierPriceSetSandbox: TierPriceSet = { - // Free tier matches the freeLimitSet - // when matching tier the keys closer to 0 index are matched first so list the tiers in descending order of value - [TierId.STANDARD]: "price_1RrAYJDCpkOb237By2s1P32m" -}; - -export function getTierPriceSet( - environment?: string, - sandbox_mode?: boolean -): TierPriceSet { - if ( - (process.env.ENVIRONMENT == "prod" && - process.env.SANDBOX_MODE !== "true") || - (environment === "prod" && sandbox_mode !== true) - ) { - // THIS GETS LOADED CLIENT SIDE AND SERVER SIDE - return tierPriceSet; - } else { - return tierPriceSetSandbox; - } -} diff --git a/server/private/lib/billing/getOrgTierData.ts b/server/private/lib/billing/getOrgTierData.ts index adda2414..68e7ea2c 100644 --- a/server/private/lib/billing/getOrgTierData.ts +++ b/server/private/lib/billing/getOrgTierData.ts @@ -29,28 +29,5 @@ export async function getOrgTierData( const subscriptionsWithItems = await getOrgSubscriptionsData(orgId); - for (const { subscription, items } of subscriptionsWithItems) { - if (items && items.length > 0) { - const tierPriceSet = getTierPriceSet(); - // Iterate through tiers in order (earlier keys are higher tiers) - for (const [tierId, priceId] of Object.entries(tierPriceSet)) { - // Check if any subscription item matches this tier's price ID - const matchingItem = items.find((item) => item.priceId === priceId); - if (matchingItem) { - tier = tierId; - break; - } - } - } - - if (subscription && subscription.status === "active") { - active = true; - } - - // If we found a tier and active subscription, we can stop - if (tier && active) { - break; - } - } return { tier, active }; } diff --git a/server/private/routers/auth/index.ts b/server/private/routers/auth/index.ts index 535d5887..25adfa78 100644 --- a/server/private/routers/auth/index.ts +++ b/server/private/routers/auth/index.ts @@ -13,4 +13,3 @@ export * from "./transferSession"; export * from "./getSessionTransferToken"; -export * from "./quickStart"; diff --git a/server/private/routers/auth/quickStart.ts b/server/private/routers/auth/quickStart.ts deleted file mode 100644 index 612a3951..00000000 --- a/server/private/routers/auth/quickStart.ts +++ /dev/null @@ -1,585 +0,0 @@ -/* - * This file is part of a proprietary work. - * - * Copyright (c) 2025 Fossorial, Inc. - * All rights reserved. - * - * This file is licensed under the Fossorial Commercial License. - * You may not use this file except in compliance with the License. - * Unauthorized use, copying, modification, or distribution is strictly prohibited. - * - * This file is not licensed under the AGPLv3. - */ - -import { NextFunction, Request, Response } from "express"; -import { - account, - db, - domainNamespaces, - domains, - exitNodes, - newts, - newtSessions, - orgs, - passwordResetTokens, - Resource, - resourcePassword, - resourcePincode, - resources, - resourceWhitelist, - roleResources, - roles, - roleSites, - sites, - targetHealthCheck, - targets, - userResources, - userSites -} from "@server/db"; -import HttpCode from "@server/types/HttpCode"; -import { z } from "zod"; -import { users } from "@server/db"; -import { fromError } from "zod-validation-error"; -import createHttpError from "http-errors"; -import response from "@server/lib/response"; -import { SqliteError } from "better-sqlite3"; -import { eq, and, sql } from "drizzle-orm"; -import moment from "moment"; -import { generateId } from "@server/auth/sessions/app"; -import config from "@server/lib/config"; -import logger from "@server/logger"; -import { hashPassword } from "@server/auth/password"; -import { UserType } from "@server/types/UserTypes"; -import { createUserAccountOrg } from "@server/lib/createUserAccountOrg"; -import { sendEmail } from "@server/emails"; -import WelcomeQuickStart from "@server/emails/templates/WelcomeQuickStart"; -import { alphabet, generateRandomString } from "oslo/crypto"; -import { createDate, TimeSpan } from "oslo"; -import { getUniqueResourceName, getUniqueSiteName } from "@server/db/names"; -import { pickPort } from "@server/routers/target/helpers"; -import { addTargets } from "@server/routers/newt/targets"; -import { isTargetValid } from "@server/lib/validators"; -import { listExitNodes } from "#private/lib/exitNodes"; - -const bodySchema = z.object({ - email: z.email().toLowerCase(), - ip: z.string().refine(isTargetValid), - method: z.enum(["http", "https"]), - port: z.int().min(1).max(65535), - pincode: z - .string() - .regex(/^\d{6}$/) - .optional(), - password: z.string().min(4).max(100).optional(), - enableWhitelist: z.boolean().optional().default(true), - animalId: z.string() // This is actually the secret key for the backend -}); - -export type QuickStartBody = z.infer; - -export type QuickStartResponse = { - newtId: string; - newtSecret: string; - resourceUrl: string; - completeSignUpLink: string; -}; - -const DEMO_UBO_KEY = "b460293f-347c-4b30-837d-4e06a04d5a22"; - -export async function quickStart( - req: Request, - res: Response, - next: NextFunction -): Promise { - const parsedBody = bodySchema.safeParse(req.body); - - if (!parsedBody.success) { - return next( - createHttpError( - HttpCode.BAD_REQUEST, - fromError(parsedBody.error).toString() - ) - ); - } - - const { - email, - ip, - method, - port, - pincode, - password, - enableWhitelist, - animalId - } = parsedBody.data; - - try { - const tokenValidation = validateTokenOnApi(animalId); - - if (!tokenValidation.isValid) { - logger.warn( - `Quick start failed for ${email} token ${animalId}: ${tokenValidation.message}` - ); - return next( - createHttpError( - HttpCode.BAD_REQUEST, - "Invalid or expired token" - ) - ); - } - - if (animalId === DEMO_UBO_KEY) { - if (email !== "mehrdad@getubo.com") { - return next( - createHttpError( - HttpCode.BAD_REQUEST, - "Invalid email for demo Ubo key" - ) - ); - } - - const [existing] = await db - .select() - .from(users) - .where( - and( - eq(users.email, email), - eq(users.type, UserType.Internal) - ) - ); - - if (existing) { - // delete the user if it already exists - await db.delete(users).where(eq(users.userId, existing.userId)); - const orgId = `org_${existing.userId}`; - await db.delete(orgs).where(eq(orgs.orgId, orgId)); - } - } - - const tempPassword = generateId(15); - const passwordHash = await hashPassword(tempPassword); - const userId = generateId(15); - - // TODO: see if that user already exists? - - // Create the sandbox user - const existing = await db - .select() - .from(users) - .where( - and(eq(users.email, email), eq(users.type, UserType.Internal)) - ); - - if (existing && existing.length > 0) { - return next( - createHttpError( - HttpCode.BAD_REQUEST, - "A user with that email address already exists" - ) - ); - } - - let newtId: string; - let secret: string; - let fullDomain: string; - let resource: Resource; - let completeSignUpLink: string; - - await db.transaction(async (trx) => { - await trx.insert(users).values({ - userId: userId, - type: UserType.Internal, - username: email, - email: email, - passwordHash, - dateCreated: moment().toISOString() - }); - - // create user"s account - await trx.insert(account).values({ - userId - }); - }); - - const { success, error, org } = await createUserAccountOrg( - userId, - email - ); - if (!success) { - if (error) { - throw new Error(error); - } - throw new Error("Failed to create user account and organization"); - } - if (!org) { - throw new Error("Failed to create user account and organization"); - } - - const orgId = org.orgId; - - await db.transaction(async (trx) => { - const token = generateRandomString( - 8, - alphabet("0-9", "A-Z", "a-z") - ); - - await trx - .delete(passwordResetTokens) - .where(eq(passwordResetTokens.userId, userId)); - - const tokenHash = await hashPassword(token); - - await trx.insert(passwordResetTokens).values({ - userId: userId, - email: email, - tokenHash, - expiresAt: createDate(new TimeSpan(7, "d")).getTime() - }); - - // // Create the sandbox newt - // const newClientAddress = await getNextAvailableClientSubnet(orgId); - // if (!newClientAddress) { - // throw new Error("No available subnet found"); - // } - - // const clientAddress = newClientAddress.split("/")[0]; - - newtId = generateId(15); - secret = generateId(48); - - // Create the sandbox site - const siteNiceId = await getUniqueSiteName(orgId); - const siteName = `First Site`; - - // pick a random exit node - const exitNodesList = await listExitNodes(orgId); - - // select a random exit node - const randomExitNode = - exitNodesList[Math.floor(Math.random() * exitNodesList.length)]; - - if (!randomExitNode) { - throw new Error("No exit nodes available"); - } - - const [newSite] = await trx - .insert(sites) - .values({ - orgId, - exitNodeId: randomExitNode.exitNodeId, - name: siteName, - niceId: siteNiceId, - // address: clientAddress, - type: "newt", - dockerSocketEnabled: true - }) - .returning(); - - const siteId = newSite.siteId; - - const adminRole = await trx - .select() - .from(roles) - .where(and(eq(roles.isAdmin, true), eq(roles.orgId, orgId))) - .limit(1); - - if (adminRole.length === 0) { - throw new Error("Admin role not found"); - } - - await trx.insert(roleSites).values({ - roleId: adminRole[0].roleId, - siteId: newSite.siteId - }); - - if (req.user && req.userOrgRoleId != adminRole[0].roleId) { - // make sure the user can access the site - await trx.insert(userSites).values({ - userId: req.user?.userId!, - siteId: newSite.siteId - }); - } - - // add the peer to the exit node - const secretHash = await hashPassword(secret!); - - await trx.insert(newts).values({ - newtId: newtId!, - secretHash, - siteId: newSite.siteId, - dateCreated: moment().toISOString() - }); - - const [randomNamespace] = await trx - .select() - .from(domainNamespaces) - .orderBy(sql`RANDOM()`) - .limit(1); - - if (!randomNamespace) { - throw new Error("No domain namespace available"); - } - - const [randomNamespaceDomain] = await trx - .select() - .from(domains) - .where(eq(domains.domainId, randomNamespace.domainId)) - .limit(1); - - if (!randomNamespaceDomain) { - throw new Error("No domain found for the namespace"); - } - - const resourceNiceId = await getUniqueResourceName(orgId); - - // Create sandbox resource - const subdomain = `${resourceNiceId}-${generateId(5)}`; - fullDomain = `${subdomain}.${randomNamespaceDomain.baseDomain}`; - - const resourceName = `First Resource`; - - const newResource = await trx - .insert(resources) - .values({ - niceId: resourceNiceId, - fullDomain, - domainId: randomNamespaceDomain.domainId, - orgId, - name: resourceName, - subdomain, - http: true, - protocol: "tcp", - ssl: true, - sso: false, - emailWhitelistEnabled: enableWhitelist - }) - .returning(); - - await trx.insert(roleResources).values({ - roleId: adminRole[0].roleId, - resourceId: newResource[0].resourceId - }); - - if (req.user && req.userOrgRoleId != adminRole[0].roleId) { - // make sure the user can access the resource - await trx.insert(userResources).values({ - userId: req.user?.userId!, - resourceId: newResource[0].resourceId - }); - } - - resource = newResource[0]; - - // Create the sandbox target - const { internalPort, targetIps } = await pickPort(siteId!, trx); - - if (!internalPort) { - throw new Error("No available internal port"); - } - - const newTarget = await trx - .insert(targets) - .values({ - resourceId: resource.resourceId, - siteId: siteId!, - internalPort, - ip, - method, - port, - enabled: true - }) - .returning(); - - const newHealthcheck = await trx - .insert(targetHealthCheck) - .values({ - targetId: newTarget[0].targetId, - hcEnabled: false - }) - .returning(); - - // add the new target to the targetIps array - targetIps.push(`${ip}/32`); - - const [newt] = await trx - .select() - .from(newts) - .where(eq(newts.siteId, siteId!)) - .limit(1); - - await addTargets( - newt.newtId, - newTarget, - newHealthcheck, - resource.protocol - ); - - // Set resource pincode if provided - if (pincode) { - await trx - .delete(resourcePincode) - .where( - eq(resourcePincode.resourceId, resource!.resourceId) - ); - - const pincodeHash = await hashPassword(pincode); - - await trx.insert(resourcePincode).values({ - resourceId: resource!.resourceId, - pincodeHash, - digitLength: 6 - }); - } - - // Set resource password if provided - if (password) { - await trx - .delete(resourcePassword) - .where( - eq(resourcePassword.resourceId, resource!.resourceId) - ); - - const passwordHash = await hashPassword(password); - - await trx.insert(resourcePassword).values({ - resourceId: resource!.resourceId, - passwordHash - }); - } - - // Set resource OTP if whitelist is enabled - if (enableWhitelist) { - await trx.insert(resourceWhitelist).values({ - email, - resourceId: resource!.resourceId - }); - } - - completeSignUpLink = `${config.getRawConfig().app.dashboard_url}/auth/reset-password?quickstart=true&email=${email}&token=${token}`; - - // Store token for email outside transaction - await sendEmail( - WelcomeQuickStart({ - username: email, - link: completeSignUpLink, - fallbackLink: `${config.getRawConfig().app.dashboard_url}/auth/reset-password?quickstart=true&email=${email}`, - resourceMethod: method, - resourceHostname: ip, - resourcePort: port, - resourceUrl: `https://${fullDomain}`, - cliCommand: `newt --id ${newtId} --secret ${secret}` - }), - { - to: email, - from: config.getNoReplyEmail(), - subject: `Access your Pangolin dashboard and resources` - } - ); - }); - - return response(res, { - data: { - newtId: newtId!, - newtSecret: secret!, - resourceUrl: `https://${fullDomain!}`, - completeSignUpLink: completeSignUpLink! - }, - success: true, - error: false, - message: "Quick start completed successfully", - status: HttpCode.OK - }); - } catch (e) { - if (e instanceof SqliteError && e.code === "SQLITE_CONSTRAINT_UNIQUE") { - if (config.getRawConfig().app.log_failed_attempts) { - logger.info( - `Account already exists with that email. Email: ${email}. IP: ${req.ip}.` - ); - } - return next( - createHttpError( - HttpCode.BAD_REQUEST, - "A user with that email address already exists" - ) - ); - } else { - logger.error(e); - return next( - createHttpError( - HttpCode.INTERNAL_SERVER_ERROR, - "Failed to do quick start" - ) - ); - } - } -} - -const BACKEND_SECRET_KEY = "4f9b6000-5d1a-11f0-9de7-ff2cc032f501"; - -/** - * Validates a token received from the frontend. - * @param {string} token The validation token from the request. - * @returns {{ isValid: boolean; message: string }} An object indicating if the token is valid. - */ -const validateTokenOnApi = ( - token: string -): { isValid: boolean; message: string } => { - if (token === DEMO_UBO_KEY) { - // Special case for demo UBO key - return { isValid: true, message: "Demo UBO key is valid." }; - } - - if (!token) { - return { isValid: false, message: "Error: No token provided." }; - } - - try { - // 1. Decode the base64 string - const decodedB64 = atob(token); - - // 2. Reverse the character code manipulation - const deobfuscated = decodedB64 - .split("") - .map((char) => String.fromCharCode(char.charCodeAt(0) - 5)) // Reverse the shift - .join(""); - - // 3. Split the data to get the original secret and timestamp - const parts = deobfuscated.split("|"); - if (parts.length !== 2) { - throw new Error("Invalid token format."); - } - const receivedKey = parts[0]; - const tokenTimestamp = parseInt(parts[1], 10); - - // 4. Check if the secret key matches - if (receivedKey !== BACKEND_SECRET_KEY) { - return { isValid: false, message: "Invalid token: Key mismatch." }; - } - - // 5. Check if the timestamp is recent (e.g., within 30 seconds) to prevent replay attacks - const now = Date.now(); - const timeDifference = now - tokenTimestamp; - - if (timeDifference > 30000) { - // 30 seconds - return { isValid: false, message: "Invalid token: Expired." }; - } - - if (timeDifference < 0) { - // Timestamp is in the future - return { - isValid: false, - message: "Invalid token: Timestamp is in the future." - }; - } - - // If all checks pass, the token is valid - return { isValid: true, message: "Token is valid!" }; - } catch (error) { - // This will catch errors from atob (if not valid base64) or other issues. - return { - isValid: false, - message: `Error: ${(error as Error).message}` - }; - } -}; diff --git a/server/private/routers/billing/changeTier.ts b/server/private/routers/billing/changeTier.ts new file mode 100644 index 00000000..68a90c92 --- /dev/null +++ b/server/private/routers/billing/changeTier.ts @@ -0,0 +1,263 @@ +/* + * This file is part of a proprietary work. + * + * Copyright (c) 2025 Fossorial, Inc. + * All rights reserved. + * + * This file is licensed under the Fossorial Commercial License. + * You may not use this file except in compliance with the License. + * Unauthorized use, copying, modification, or distribution is strictly prohibited. + * + * This file is not licensed under the AGPLv3. + */ + +import { Request, Response, NextFunction } from "express"; +import { z } from "zod"; +import { customers, db, subscriptions, subscriptionItems } from "@server/db"; +import { eq, and, or } from "drizzle-orm"; +import response from "@server/lib/response"; +import HttpCode from "@server/types/HttpCode"; +import createHttpError from "http-errors"; +import logger from "@server/logger"; +import { fromError } from "zod-validation-error"; +import stripe from "#private/lib/stripe"; +import { + getHomeLabFeaturePriceSet, + getScaleFeaturePriceSet, + getStarterFeaturePriceSet, + FeatureId, + type FeaturePriceSet +} from "@server/lib/billing"; + +const changeTierSchema = z.strictObject({ + orgId: z.string() +}); + +const changeTierBodySchema = z.strictObject({ + tier: z.enum(["home_lab", "starter", "scale"]) +}); + +export async function changeTier( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const parsedParams = changeTierSchema.safeParse(req.params); + if (!parsedParams.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedParams.error).toString() + ) + ); + } + + const { orgId } = parsedParams.data; + + const parsedBody = changeTierBodySchema.safeParse(req.body); + if (!parsedBody.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedBody.error).toString() + ) + ); + } + + const { tier } = parsedBody.data; + + // Get the customer for this org + const [customer] = await db + .select() + .from(customers) + .where(eq(customers.orgId, orgId)) + .limit(1); + + if (!customer) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + "No customer found for this organization" + ) + ); + } + + // Get the active subscription for this customer + const [subscription] = await db + .select() + .from(subscriptions) + .where( + and( + eq(subscriptions.customerId, customer.customerId), + eq(subscriptions.status, "active"), + or( + eq(subscriptions.type, "home_lab"), + eq(subscriptions.type, "starter"), + eq(subscriptions.type, "scale") + ) + ) + ) + .limit(1); + + if (!subscription) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + "No active subscription found for this organization" + ) + ); + } + + // Get the target tier's price set + let targetPriceSet: FeaturePriceSet; + if (tier === "home_lab") { + targetPriceSet = getHomeLabFeaturePriceSet(); + } else if (tier === "starter") { + targetPriceSet = getStarterFeaturePriceSet(); + } else if (tier === "scale") { + targetPriceSet = getScaleFeaturePriceSet(); + } else { + return next(createHttpError(HttpCode.BAD_REQUEST, "Invalid tier")); + } + + // Get current subscription items from our database + const currentItems = await db + .select() + .from(subscriptionItems) + .where( + eq( + subscriptionItems.subscriptionId, + subscription.subscriptionId + ) + ); + + if (currentItems.length === 0) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + "No subscription items found" + ) + ); + } + + // Retrieve the full subscription from Stripe to get item IDs + const stripeSubscription = await stripe!.subscriptions.retrieve( + subscription.subscriptionId + ); + + // Determine if we're switching between different products + // home_lab uses HOME_LAB product, starter/scale use USERS product + const currentTier = subscription.type; + const switchingProducts = + (currentTier === "home_lab" && (tier === "starter" || tier === "scale")) || + ((currentTier === "starter" || currentTier === "scale") && tier === "home_lab"); + + let updatedSubscription; + + if (switchingProducts) { + // When switching between different products, we need to: + // 1. Delete old subscription items + // 2. Add new subscription items + logger.info( + `Switching products from ${currentTier} to ${tier} for subscription ${subscription.subscriptionId}` + ); + + // Build array to delete all existing items and add new ones + const itemsToUpdate: any[] = []; + + // Mark all existing items for deletion + for (const stripeItem of stripeSubscription.items.data) { + itemsToUpdate.push({ + id: stripeItem.id, + deleted: true + }); + } + + // Add new items for the target tier + for (const [featureId, priceId] of Object.entries(targetPriceSet)) { + itemsToUpdate.push({ + price: priceId + }); + } + + updatedSubscription = await stripe!.subscriptions.update( + subscription.subscriptionId, + { + items: itemsToUpdate, + proration_behavior: "create_prorations" + } + ); + } else { + // Same product, different price tier (starter <-> scale) + // We can simply update the price + logger.info( + `Updating price from ${currentTier} to ${tier} for subscription ${subscription.subscriptionId}` + ); + + const itemsToUpdate = stripeSubscription.items.data.map( + (stripeItem) => { + // Find the corresponding item in our database + const dbItem = currentItems.find( + (item) => item.priceId === stripeItem.price.id + ); + + if (!dbItem) { + // Keep the existing item unchanged if we can't find it + return { + id: stripeItem.id, + price: stripeItem.price.id + }; + } + + // Map to the corresponding feature in the new tier + const newPriceId = targetPriceSet[FeatureId.USERS]; + + if (newPriceId) { + return { + id: stripeItem.id, + price: newPriceId + }; + } + + // If no mapping found, keep existing + return { + id: stripeItem.id, + price: stripeItem.price.id + }; + } + ); + + updatedSubscription = await stripe!.subscriptions.update( + subscription.subscriptionId, + { + items: itemsToUpdate, + proration_behavior: "create_prorations" + } + ); + } + + logger.info( + `Successfully changed tier to ${tier} for org ${orgId}, subscription ${subscription.subscriptionId}` + ); + + return response<{ subscriptionId: string; newTier: string }>(res, { + data: { + subscriptionId: updatedSubscription.id, + newTier: tier + }, + success: true, + error: false, + message: "Tier change successful", + status: HttpCode.OK + }); + } catch (error) { + logger.error("Error changing tier:", error); + return next( + createHttpError( + HttpCode.INTERNAL_SERVER_ERROR, + "An error occurred while changing tier" + ) + ); + } +} diff --git a/server/private/routers/billing/createCheckoutSessionSAAS.ts b/server/private/routers/billing/createCheckoutSession.ts similarity index 72% rename from server/private/routers/billing/createCheckoutSessionSAAS.ts rename to server/private/routers/billing/createCheckoutSession.ts index 0f9b783e..7c79b8fb 100644 --- a/server/private/routers/billing/createCheckoutSessionSAAS.ts +++ b/server/private/routers/billing/createCheckoutSession.ts @@ -22,13 +22,16 @@ import logger from "@server/logger"; import config from "@server/lib/config"; import { fromError } from "zod-validation-error"; import stripe from "#private/lib/stripe"; -import { getLineItems, getStandardFeaturePriceSet } from "@server/lib/billing"; -import { getTierPriceSet, TierId } from "@server/lib/billing/tiers"; +import { getHomeLabFeaturePriceSet, getLineItems, getScaleFeaturePriceSet, getStarterFeaturePriceSet } from "@server/lib/billing"; const createCheckoutSessionSchema = z.strictObject({ orgId: z.string() }); +const createCheckoutSessionBodySchema = z.strictObject({ + tier: z.enum(["home_lab", "starter", "scale"]), +}); + export async function createCheckoutSessionSAAS( req: Request, res: Response, @@ -47,6 +50,18 @@ export async function createCheckoutSessionSAAS( const { orgId } = parsedParams.data; + const parsedBody = createCheckoutSessionBodySchema.safeParse(req.body); + if (!parsedBody.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedBody.error).toString() + ) + ); + } + + const { tier } = parsedBody.data; + // check if we already have a customer for this org const [customer] = await db .select() @@ -65,18 +80,23 @@ export async function createCheckoutSessionSAAS( ); } - const standardTierPrice = getTierPriceSet()[TierId.STANDARD]; + let lineItems; + if (tier === "home_lab") { + lineItems = getLineItems(getHomeLabFeaturePriceSet()); + } else if (tier === "starter") { + lineItems = getLineItems(getStarterFeaturePriceSet()); + } else if (tier === "scale") { + lineItems = getLineItems(getScaleFeaturePriceSet()); + } else { + return next( + createHttpError(HttpCode.BAD_REQUEST, "Invalid plan") + ); + } const session = await stripe!.checkout.sessions.create({ client_reference_id: orgId, // So we can look it up the org later on the webhook billing_address_collection: "required", - line_items: [ - { - price: standardTierPrice, // Use the standard tier - quantity: 1 - }, - ...getLineItems(getStandardFeaturePriceSet()) - ], // Start with the standard feature set that matches the free limits + line_items: lineItems, customer: customer.customerId, mode: "subscription", success_url: `${config.getRawConfig().app.dashboard_url}/${orgId}/settings/billing?success=true&session_id={CHECKOUT_SESSION_ID}`, diff --git a/server/private/routers/billing/hooks/getSubType.ts b/server/private/routers/billing/hooks/getSubType.ts index 8cd07713..3618747d 100644 --- a/server/private/routers/billing/hooks/getSubType.ts +++ b/server/private/routers/billing/hooks/getSubType.ts @@ -1,35 +1,61 @@ +/* + * This file is part of a proprietary work. + * + * Copyright (c) 2025 Fossorial, Inc. + * All rights reserved. + * + * This file is licensed under the Fossorial Commercial License. + * You may not use this file except in compliance with the License. + * Unauthorized use, copying, modification, or distribution is strictly prohibited. + * + * This file is not licensed under the AGPLv3. + */ + import { getLicensePriceSet, } from "@server/lib/billing/licenses"; import { - getTierPriceSet, -} from "@server/lib/billing/tiers"; + getHomeLabFeaturePriceSet, + getStarterFeaturePriceSet, + getScaleFeaturePriceSet, +} from "@server/lib/billing/features"; import Stripe from "stripe"; -export function getSubType(fullSubscription: Stripe.Response): "saas" | "license" { +export type SubscriptionType = "home_lab" | "starter" | "scale" | "license"; + +export function getSubType(fullSubscription: Stripe.Response): SubscriptionType | null { // Determine subscription type by checking subscription items - let type: "saas" | "license" = "saas"; - if (Array.isArray(fullSubscription.items?.data)) { - for (const item of fullSubscription.items.data) { - const priceId = item.price.id; + if (!Array.isArray(fullSubscription.items?.data) || fullSubscription.items.data.length === 0) { + return null; + } - // Check if price ID matches any license price - const licensePrices = Object.values(getLicensePriceSet()); + for (const item of fullSubscription.items.data) { + const priceId = item.price.id; - if (licensePrices.includes(priceId)) { - type = "license"; - break; - } + // Check if price ID matches any license price + const licensePrices = Object.values(getLicensePriceSet()); + if (licensePrices.includes(priceId)) { + return "license"; + } - // Check if price ID matches any tier price (saas) - const tierPrices = Object.values(getTierPriceSet()); + // Check if price ID matches home lab tier + const homeLabPrices = Object.values(getHomeLabFeaturePriceSet()); + if (homeLabPrices.includes(priceId)) { + return "home_lab"; + } - if (tierPrices.includes(priceId)) { - type = "saas"; - break; - } + // Check if price ID matches starter tier + const starterPrices = Object.values(getStarterFeaturePriceSet()); + if (starterPrices.includes(priceId)) { + return "starter"; + } + + // Check if price ID matches scale tier + const scalePrices = Object.values(getScaleFeaturePriceSet()); + if (scalePrices.includes(priceId)) { + return "scale"; } } - return type; -} + return null; +} \ No newline at end of file diff --git a/server/private/routers/billing/hooks/handleSubscriptionCreated.ts b/server/private/routers/billing/hooks/handleSubscriptionCreated.ts index a51f825f..6238e65c 100644 --- a/server/private/routers/billing/hooks/handleSubscriptionCreated.ts +++ b/server/private/routers/billing/hooks/handleSubscriptionCreated.ts @@ -59,6 +59,8 @@ export async function handleSubscriptionCreated( return; } + const type = getSubType(fullSubscription); + const newSubscription = { subscriptionId: subscription.id, customerId: subscription.customer as string, @@ -66,7 +68,8 @@ export async function handleSubscriptionCreated( canceledAt: subscription.canceled_at ? subscription.canceled_at : null, - createdAt: subscription.created + createdAt: subscription.created, + type: type }; await db.insert(subscriptions).values(newSubscription); @@ -129,10 +132,9 @@ export async function handleSubscriptionCreated( return; } - const type = getSubType(fullSubscription); - if (type === "saas") { + if (type === "home_lab" || type === "starter" || type === "scale") { logger.debug( - `Handling SAAS subscription lifecycle for org ${customer.orgId}` + `Handling SAAS subscription lifecycle for org ${customer.orgId} with type ${type}` ); // we only need to handle the limit lifecycle for saas subscriptions not for the licenses await handleSubscriptionLifesycle( diff --git a/server/private/routers/billing/hooks/handleSubscriptionUpdated.ts b/server/private/routers/billing/hooks/handleSubscriptionUpdated.ts index 21943354..6b21fb26 100644 --- a/server/private/routers/billing/hooks/handleSubscriptionUpdated.ts +++ b/server/private/routers/billing/hooks/handleSubscriptionUpdated.ts @@ -64,6 +64,8 @@ export async function handleSubscriptionUpdated( .where(eq(customers.customerId, subscription.customer as string)) .limit(1); + const type = getSubType(fullSubscription); + await db .update(subscriptions) .set({ @@ -72,7 +74,8 @@ export async function handleSubscriptionUpdated( ? subscription.canceled_at : null, updatedAt: Math.floor(Date.now() / 1000), - billingCycleAnchor: subscription.billing_cycle_anchor + billingCycleAnchor: subscription.billing_cycle_anchor, + type: type }) .where(eq(subscriptions.subscriptionId, subscription.id)); @@ -234,17 +237,16 @@ export async function handleSubscriptionUpdated( } // --- end usage update --- - const type = getSubType(fullSubscription); - if (type === "saas") { + if (type === "home_lab" || type === "starter" || type === "scale") { logger.debug( - `Handling SAAS subscription lifecycle for org ${customer.orgId}` + `Handling SAAS subscription lifecycle for org ${customer.orgId} with type ${type}` ); // we only need to handle the limit lifecycle for saas subscriptions not for the licenses await handleSubscriptionLifesycle( customer.orgId, subscription.status ); - } else { + } else if (type === "license") { if (subscription.status === "canceled" || subscription.status == "unpaid" || subscription.status == "incomplete_expired") { try { // WARNING: diff --git a/server/private/routers/billing/index.ts b/server/private/routers/billing/index.ts index e7770ec2..b7bf02d4 100644 --- a/server/private/routers/billing/index.ts +++ b/server/private/routers/billing/index.ts @@ -11,7 +11,7 @@ * This file is not licensed under the AGPLv3. */ -export * from "./createCheckoutSessionSAAS"; +export * from "./createCheckoutSession"; export * from "./createPortalSession"; export * from "./getOrgSubscriptions"; export * from "./getOrgUsage"; diff --git a/server/private/routers/external.ts b/server/private/routers/external.ts index ddc2afe0..962cede9 100644 --- a/server/private/routers/external.ts +++ b/server/private/routers/external.ts @@ -141,25 +141,8 @@ authenticated.post( ); if (build === "saas") { - unauthenticated.post( - "/quick-start", - rateLimit({ - windowMs: 15 * 60 * 1000, - max: 100, - keyGenerator: (req) => req.path, - handler: (req, res, next) => { - const message = `We're too busy right now. Please try again later.`; - return next( - createHttpError(HttpCode.TOO_MANY_REQUESTS, message) - ); - }, - store: createStore() - }), - auth.quickStart - ); - authenticated.post( - "/org/:orgId/billing/create-checkout-session-saas", + "/org/:orgId/billing/create-checkout-session", verifyOrgAccess, verifyUserHasAction(ActionsEnum.billing), logActionAudit(ActionsEnum.billing), diff --git a/src/app/[orgId]/settings/(private)/billing/page.tsx b/src/app/[orgId]/settings/(private)/billing/page.tsx index 7b10dc4c..75611f35 100644 --- a/src/app/[orgId]/settings/(private)/billing/page.tsx +++ b/src/app/[orgId]/settings/(private)/billing/page.tsx @@ -153,7 +153,7 @@ export default function GeneralPage() { setIsLoading(true); try { const response = await api.post>( - `/org/${org.org.orgId}/billing/create-checkout-session-saas`, + `/org/${org.org.orgId}/billing/create-checkout-session`, {} ); console.log("Checkout session response:", response.data); From 313acabc86fdac4851ea9d1da549af622a3e6c7c Mon Sep 17 00:00:00 2001 From: Owen Date: Fri, 6 Feb 2026 10:47:19 -0800 Subject: [PATCH 06/49] Wrap insert in transaction Ref #2222 --- server/routers/badger/logRequestAudit.ts | 28 ++++++++++++++++-------- 1 file changed, 19 insertions(+), 9 deletions(-) diff --git a/server/routers/badger/logRequestAudit.ts b/server/routers/badger/logRequestAudit.ts index 026ee4bb..5975d8f3 100644 --- a/server/routers/badger/logRequestAudit.ts +++ b/server/routers/badger/logRequestAudit.ts @@ -1,6 +1,6 @@ import { db, orgs, requestAuditLog } from "@server/db"; import logger from "@server/logger"; -import { and, eq, lt } from "drizzle-orm"; +import { and, eq, lt, sql } from "drizzle-orm"; import cache from "@server/lib/cache"; import { calculateCutoffTimestamp } from "@server/lib/cleanupLogs"; import { stripPortFromHost } from "@server/lib/ip"; @@ -67,17 +67,27 @@ async function flushAuditLogs() { const logsToWrite = auditLogBuffer.splice(0, auditLogBuffer.length); try { - // Batch insert logs in groups of 25 to avoid overwhelming the database - const BATCH_DB_SIZE = 25; - for (let i = 0; i < logsToWrite.length; i += BATCH_DB_SIZE) { - const batch = logsToWrite.slice(i, i + BATCH_DB_SIZE); - await db.insert(requestAuditLog).values(batch); - } + // Use a transaction to ensure all inserts succeed or fail together + // This prevents index corruption from partial writes + await db.transaction(async (tx) => { + // Batch insert logs in groups of 25 to avoid overwhelming the database + const BATCH_DB_SIZE = 25; + for (let i = 0; i < logsToWrite.length; i += BATCH_DB_SIZE) { + const batch = logsToWrite.slice(i, i + BATCH_DB_SIZE); + await tx.insert(requestAuditLog).values(batch); + } + }); logger.debug(`Flushed ${logsToWrite.length} audit logs to database`); } catch (error) { logger.error("Error flushing audit logs:", error); - // On error, we lose these logs - consider a fallback strategy if needed - // (e.g., write to file, or put back in buffer with retry limit) + // On transaction error, put logs back at the front of the buffer to retry + // but only if buffer isn't too large + if (auditLogBuffer.length < MAX_BUFFER_SIZE - logsToWrite.length) { + auditLogBuffer.unshift(...logsToWrite); + logger.info(`Re-queued ${logsToWrite.length} audit logs for retry`); + } else { + logger.error(`Buffer full, dropped ${logsToWrite.length} audit logs`); + } } finally { isFlushInProgress = false; // If buffer filled up while we were flushing, flush again From 6cfc7b7c699b45d6a3d982b136727a66a9410e57 Mon Sep 17 00:00:00 2001 From: Owen Date: Fri, 6 Feb 2026 16:27:31 -0800 Subject: [PATCH 07/49] Switch to the new tier system and clean up checks --- server/lib/billing/limitSet.ts | 4 +- server/private/lib/billing/getOrgTierData.ts | 47 ++++++++++++++++--- server/private/lib/checkOrgAccessPolicy.ts | 2 - server/private/lib/isLicencedOrSubscribed.ts | 7 ++- server/private/lib/isSubscribed.ts | 24 ++++++++++ .../private/middlewares/verifySubscription.ts | 5 +- .../routers/approvals/listApprovals.ts | 16 +------ .../approvals/processPendingApproval.ts | 17 ------- .../routers/billing/createCheckoutSession.ts | 2 +- .../hooks/handleSubscriptionCreated.ts | 3 +- .../hooks/handleSubscriptionDeleted.ts | 5 +- .../hooks/handleSubscriptionUpdated.ts | 3 +- .../routers/billing/subscriptionLifecycle.ts | 38 ++++++++++++--- server/private/routers/external.ts | 11 ++++- .../routers/loginPage/createLoginPage.ts | 17 +------ .../loginPage/deleteLoginPageBranding.ts | 16 +------ .../routers/loginPage/getLoginPageBranding.ts | 17 +------ .../routers/loginPage/updateLoginPage.ts | 16 +------ .../loginPage/upsertLoginPageBranding.ts | 15 ------ .../routers/orgIdp/createOrgOidcIdp.ts | 16 ------- .../routers/orgIdp/updateOrgOidcIdp.ts | 16 ------- server/routers/badger/verifySession.ts | 12 ++--- server/routers/idp/generateOidcUrl.ts | 6 +-- server/routers/org/updateOrg.ts | 8 ++-- server/routers/user/createOrgUser.ts | 8 ++-- .../settings/(private)/billing/page.tsx | 1 - .../settings/access/users/create/page.tsx | 1 - src/app/auth/resource/[resourceGuid]/page.tsx | 2 - src/contexts/subscriptionStatusContext.ts | 2 +- src/lib/api/isOrgSubscribed.ts | 3 +- src/providers/SubscriptionStatusProvider.tsx | 38 ++++++++------- 31 files changed, 163 insertions(+), 215 deletions(-) create mode 100644 server/private/lib/isSubscribed.ts diff --git a/server/lib/billing/limitSet.ts b/server/lib/billing/limitSet.ts index 12aea306..0419262c 100644 --- a/server/lib/billing/limitSet.ts +++ b/server/lib/billing/limitSet.ts @@ -1,11 +1,11 @@ import { FeatureId } from "./features"; -export type LimitSet = { +export type LimitSet = Partial<{ [key in FeatureId]: { value: number | null; // null indicates no limit description?: string; }; -}; +}>; export const sandboxLimitSet: LimitSet = { [FeatureId.SITES]: { value: 1, description: "Sandbox limit" }, // 1 site up for 2 days diff --git a/server/private/lib/billing/getOrgTierData.ts b/server/private/lib/billing/getOrgTierData.ts index 68e7ea2c..174148f6 100644 --- a/server/private/lib/billing/getOrgTierData.ts +++ b/server/private/lib/billing/getOrgTierData.ts @@ -11,23 +11,58 @@ * This file is not licensed under the AGPLv3. */ -import { getTierPriceSet } from "@server/lib/billing/tiers"; -import { getOrgSubscriptionsData } from "@server/private/routers/billing/getOrgSubscriptions"; import { build } from "@server/build"; +import { db, customers, subscriptions } from "@server/db"; +import { eq, and, ne } from "drizzle-orm"; export async function getOrgTierData( orgId: string -): Promise<{ tier: string | null; active: boolean }> { - let tier = null; +): Promise<{ tier: "home_lab" | "starter" | "scale" | null; active: boolean }> { + let tier: "home_lab" | "starter" | "scale" | null = null; let active = false; if (build !== "saas") { return { tier, active }; } - // TODO: THIS IS INEFFICIENT!!! WE SHOULD IMPROVE HOW WE STORE TIERS WITH SUBSCRIPTIONS AND RETRIEVE THEM + try { + // Get customer for org + const [customer] = await db + .select() + .from(customers) + .where(eq(customers.orgId, orgId)) + .limit(1); - const subscriptionsWithItems = await getOrgSubscriptionsData(orgId); + if (customer) { + // Query for active subscriptions that are not license type + const [subscription] = await db + .select() + .from(subscriptions) + .where( + and( + eq(subscriptions.customerId, customer.customerId), + eq(subscriptions.status, "active"), + ne(subscriptions.type, "license") + ) + ) + .limit(1); + + if (subscription) { + // Validate that subscription.type is one of the expected tier values + if ( + subscription.type === "home_lab" || + subscription.type === "starter" || + subscription.type === "scale" + ) { + tier = subscription.type; + active = true; + } + } + } + } catch (error) { + // If org not found or error occurs, return null tier and inactive + // This is acceptable behavior as per the function signature + } return { tier, active }; } diff --git a/server/private/lib/checkOrgAccessPolicy.ts b/server/private/lib/checkOrgAccessPolicy.ts index cb40c8b8..af318ce0 100644 --- a/server/private/lib/checkOrgAccessPolicy.ts +++ b/server/private/lib/checkOrgAccessPolicy.ts @@ -13,8 +13,6 @@ import { build } from "@server/build"; import { db, Org, orgs, ResourceSession, sessions, users } from "@server/db"; -import { getOrgTierData } from "#private/lib/billing"; -import { TierId } from "@server/lib/billing/tiers"; import license from "#private/license/license"; import { eq } from "drizzle-orm"; import { diff --git a/server/private/lib/isLicencedOrSubscribed.ts b/server/private/lib/isLicencedOrSubscribed.ts index 494deb7a..2e8c04fa 100644 --- a/server/private/lib/isLicencedOrSubscribed.ts +++ b/server/private/lib/isLicencedOrSubscribed.ts @@ -14,7 +14,6 @@ import { build } from "@server/build"; import license from "#private/license/license"; import { getOrgTierData } from "#private/lib/billing"; -import { TierId } from "@server/lib/billing/tiers"; export async function isLicensedOrSubscribed(orgId: string): Promise { if (build === "enterprise") { @@ -22,9 +21,9 @@ export async function isLicensedOrSubscribed(orgId: string): Promise { } if (build === "saas") { - const { tier } = await getOrgTierData(orgId); - return tier === TierId.STANDARD; + const { tier, active } = await getOrgTierData(orgId); + return (tier == "home_lab" || tier == "starter" || tier == "scale") && active; } return false; -} \ No newline at end of file +} diff --git a/server/private/lib/isSubscribed.ts b/server/private/lib/isSubscribed.ts new file mode 100644 index 00000000..9ff71bca --- /dev/null +++ b/server/private/lib/isSubscribed.ts @@ -0,0 +1,24 @@ +/* + * This file is part of a proprietary work. + * + * Copyright (c) 2025 Fossorial, Inc. + * All rights reserved. + * + * This file is licensed under the Fossorial Commercial License. + * You may not use this file except in compliance with the License. + * Unauthorized use, copying, modification, or distribution is strictly prohibited. + * + * This file is not licensed under the AGPLv3. + */ + +import { build } from "@server/build"; +import { getOrgTierData } from "#private/lib/billing"; + +export async function isSubscribed(orgId: string): Promise { + if (build === "saas") { + const { tier, active } = await getOrgTierData(orgId); + return (tier == "home_lab" || tier == "starter" || tier == "scale") && active; + } + + return false; +} diff --git a/server/private/middlewares/verifySubscription.ts b/server/private/middlewares/verifySubscription.ts index 8cda737e..f1b7a0ce 100644 --- a/server/private/middlewares/verifySubscription.ts +++ b/server/private/middlewares/verifySubscription.ts @@ -38,9 +38,8 @@ export async function verifyValidSubscription( ); } - const tier = await getOrgTierData(orgId); - - if (!tier.active) { + const { tier, active } = await getOrgTierData(orgId); + if ((tier == "home_lab" || tier == "starter" || tier == "scale") && active) { return next( createHttpError( HttpCode.FORBIDDEN, diff --git a/server/private/routers/approvals/listApprovals.ts b/server/private/routers/approvals/listApprovals.ts index 600eec87..509df5eb 100644 --- a/server/private/routers/approvals/listApprovals.ts +++ b/server/private/routers/approvals/listApprovals.ts @@ -19,8 +19,6 @@ import { fromError } from "zod-validation-error"; import type { Request, Response, NextFunction } from "express"; import { build } from "@server/build"; -import { getOrgTierData } from "#private/lib/billing"; -import { TierId } from "@server/lib/billing/tiers"; import { approvals, clients, @@ -33,6 +31,7 @@ import { import { eq, isNull, sql, not, and, desc } from "drizzle-orm"; import response from "@server/lib/response"; import { getUserDeviceName } from "@server/db/names"; +import { isLicensedOrSubscribed } from "@server/private/lib/isLicencedOrSubscribed"; const paramsSchema = z.strictObject({ orgId: z.string() @@ -221,19 +220,6 @@ export async function listApprovals( const { orgId } = parsedParams.data; - if (build === "saas") { - const { tier } = await getOrgTierData(orgId); - const subscribed = tier === TierId.STANDARD; - if (!subscribed) { - return next( - createHttpError( - HttpCode.FORBIDDEN, - "This organization's current plan does not support this feature." - ) - ); - } - } - const approvalsList = await queryApprovals( orgId.toString(), limit, diff --git a/server/private/routers/approvals/processPendingApproval.ts b/server/private/routers/approvals/processPendingApproval.ts index d4988ac5..fa60445f 100644 --- a/server/private/routers/approvals/processPendingApproval.ts +++ b/server/private/routers/approvals/processPendingApproval.ts @@ -17,10 +17,7 @@ import createHttpError from "http-errors"; import { z } from "zod"; import { fromError } from "zod-validation-error"; -import { build } from "@server/build"; import { approvals, clients, db, orgs, type Approval } from "@server/db"; -import { getOrgTierData } from "#private/lib/billing"; -import { TierId } from "@server/lib/billing/tiers"; import response from "@server/lib/response"; import { and, eq, type InferInsertModel } from "drizzle-orm"; import type { NextFunction, Request, Response } from "express"; @@ -64,20 +61,6 @@ export async function processPendingApproval( } const { orgId, approvalId } = parsedParams.data; - - if (build === "saas") { - const { tier } = await getOrgTierData(orgId); - const subscribed = tier === TierId.STANDARD; - if (!subscribed) { - return next( - createHttpError( - HttpCode.FORBIDDEN, - "This organization's current plan does not support this feature." - ) - ); - } - } - const updateData = parsedBody.data; const approval = await db diff --git a/server/private/routers/billing/createCheckoutSession.ts b/server/private/routers/billing/createCheckoutSession.ts index 7c79b8fb..a90c5e86 100644 --- a/server/private/routers/billing/createCheckoutSession.ts +++ b/server/private/routers/billing/createCheckoutSession.ts @@ -32,7 +32,7 @@ const createCheckoutSessionBodySchema = z.strictObject({ tier: z.enum(["home_lab", "starter", "scale"]), }); -export async function createCheckoutSessionSAAS( +export async function createCheckoutSession( req: Request, res: Response, next: NextFunction diff --git a/server/private/routers/billing/hooks/handleSubscriptionCreated.ts b/server/private/routers/billing/hooks/handleSubscriptionCreated.ts index 6238e65c..4553c986 100644 --- a/server/private/routers/billing/hooks/handleSubscriptionCreated.ts +++ b/server/private/routers/billing/hooks/handleSubscriptionCreated.ts @@ -139,7 +139,8 @@ export async function handleSubscriptionCreated( // we only need to handle the limit lifecycle for saas subscriptions not for the licenses await handleSubscriptionLifesycle( customer.orgId, - subscription.status + subscription.status, + type ); const [orgUserRes] = await db diff --git a/server/private/routers/billing/hooks/handleSubscriptionDeleted.ts b/server/private/routers/billing/hooks/handleSubscriptionDeleted.ts index 003110aa..a35b3be6 100644 --- a/server/private/routers/billing/hooks/handleSubscriptionDeleted.ts +++ b/server/private/routers/billing/hooks/handleSubscriptionDeleted.ts @@ -76,14 +76,15 @@ export async function handleSubscriptionDeleted( } const type = getSubType(fullSubscription); - if (type === "saas") { + if (type == "home_lab" || type == "starter" || type == "scale") { logger.debug( `Handling SaaS subscription deletion for orgId ${customer.orgId} and subscription ID ${subscription.id}` ); await handleSubscriptionLifesycle( customer.orgId, - subscription.status + subscription.status, + type ); const [orgUserRes] = await db diff --git a/server/private/routers/billing/hooks/handleSubscriptionUpdated.ts b/server/private/routers/billing/hooks/handleSubscriptionUpdated.ts index 6b21fb26..8f808944 100644 --- a/server/private/routers/billing/hooks/handleSubscriptionUpdated.ts +++ b/server/private/routers/billing/hooks/handleSubscriptionUpdated.ts @@ -244,7 +244,8 @@ export async function handleSubscriptionUpdated( // we only need to handle the limit lifecycle for saas subscriptions not for the licenses await handleSubscriptionLifesycle( customer.orgId, - subscription.status + subscription.status, + type ); } else if (type === "license") { if (subscription.status === "canceled" || subscription.status == "unpaid" || subscription.status == "incomplete_expired") { diff --git a/server/private/routers/billing/subscriptionLifecycle.ts b/server/private/routers/billing/subscriptionLifecycle.ts index 0fc75835..73a58748 100644 --- a/server/private/routers/billing/subscriptionLifecycle.ts +++ b/server/private/routers/billing/subscriptionLifecycle.ts @@ -13,36 +13,62 @@ import { freeLimitSet, + homeLabLimitSet, + starterLimitSet, + scaleLimitSet, limitsService, - subscribedLimitSet + LimitSet } from "@server/lib/billing"; import { usageService } from "@server/lib/billing/usageService"; -import logger from "@server/logger"; +import { SubscriptionType } from "./hooks/getSubType"; + +function getLimitSetForSubscriptionType(subType: SubscriptionType | null): LimitSet { + switch (subType) { + case "home_lab": + return homeLabLimitSet; + case "starter": + return starterLimitSet; + case "scale": + return scaleLimitSet; + case "license": + // License subscriptions use starter limits by default + // This can be adjusted based on your business logic + return starterLimitSet; + default: + return freeLimitSet; + } +} export async function handleSubscriptionLifesycle( orgId: string, - status: string + status: string, + subType: SubscriptionType | null ) { switch (status) { case "active": - await limitsService.applyLimitSetToOrg(orgId, subscribedLimitSet); + const activeLimitSet = getLimitSetForSubscriptionType(subType); + await limitsService.applyLimitSetToOrg(orgId, activeLimitSet); await usageService.checkLimitSet(orgId, true); break; case "canceled": + // Subscription canceled - revert to free tier await limitsService.applyLimitSetToOrg(orgId, freeLimitSet); await usageService.checkLimitSet(orgId, true); break; case "past_due": - // Optionally handle past due status, e.g., notify customer + // Payment past due - keep current limits but notify customer + // Limits will revert to free tier if it becomes unpaid break; case "unpaid": + // Subscription unpaid - revert to free tier await limitsService.applyLimitSetToOrg(orgId, freeLimitSet); await usageService.checkLimitSet(orgId, true); break; case "incomplete": - // Optionally handle incomplete status, e.g., notify customer + // Payment incomplete - give them time to complete payment break; case "incomplete_expired": + // Payment never completed - revert to free tier await limitsService.applyLimitSetToOrg(orgId, freeLimitSet); await usageService.checkLimitSet(orgId, true); break; diff --git a/server/private/routers/external.ts b/server/private/routers/external.ts index 962cede9..0ef9077d 100644 --- a/server/private/routers/external.ts +++ b/server/private/routers/external.ts @@ -76,6 +76,7 @@ unauthenticated.post( authenticated.put( "/org/:orgId/idp/oidc", verifyValidLicense, + verifyValidSubscription, verifyOrgAccess, verifyUserHasAction(ActionsEnum.createIdp), logActionAudit(ActionsEnum.createIdp), @@ -85,6 +86,7 @@ authenticated.put( authenticated.post( "/org/:orgId/idp/:idpId/oidc", verifyValidLicense, + verifyValidSubscription, verifyOrgAccess, verifyIdpAccess, verifyUserHasAction(ActionsEnum.updateIdp), @@ -146,7 +148,7 @@ if (build === "saas") { verifyOrgAccess, verifyUserHasAction(ActionsEnum.billing), logActionAudit(ActionsEnum.billing), - billing.createCheckoutSessionSAAS + billing.createCheckoutSession ); authenticated.post( @@ -269,6 +271,7 @@ authenticated.delete( authenticated.put( "/org/:orgId/login-page", verifyValidLicense, + verifyValidSubscription, verifyOrgAccess, verifyUserHasAction(ActionsEnum.createLoginPage), logActionAudit(ActionsEnum.createLoginPage), @@ -278,6 +281,7 @@ authenticated.put( authenticated.post( "/org/:orgId/login-page/:loginPageId", verifyValidLicense, + verifyValidSubscription, verifyOrgAccess, verifyLoginPageAccess, verifyUserHasAction(ActionsEnum.updateLoginPage), @@ -306,6 +310,7 @@ authenticated.get( authenticated.get( "/org/:orgId/approvals", verifyValidLicense, + verifyValidSubscription, verifyOrgAccess, verifyUserHasAction(ActionsEnum.listApprovals), logActionAudit(ActionsEnum.listApprovals), @@ -322,6 +327,7 @@ authenticated.get( authenticated.put( "/org/:orgId/approvals/:approvalId", verifyValidLicense, + verifyValidSubscription, verifyOrgAccess, verifyUserHasAction(ActionsEnum.updateApprovals), logActionAudit(ActionsEnum.updateApprovals), @@ -331,6 +337,7 @@ authenticated.put( authenticated.get( "/org/:orgId/login-page-branding", verifyValidLicense, + verifyValidSubscription, verifyOrgAccess, verifyUserHasAction(ActionsEnum.getLoginPage), logActionAudit(ActionsEnum.getLoginPage), @@ -340,6 +347,7 @@ authenticated.get( authenticated.put( "/org/:orgId/login-page-branding", verifyValidLicense, + verifyValidSubscription, verifyOrgAccess, verifyUserHasAction(ActionsEnum.updateLoginPage), logActionAudit(ActionsEnum.updateLoginPage), @@ -349,6 +357,7 @@ authenticated.put( authenticated.delete( "/org/:orgId/login-page-branding", verifyValidLicense, + verifyValidSubscription, verifyOrgAccess, verifyUserHasAction(ActionsEnum.deleteLoginPage), logActionAudit(ActionsEnum.deleteLoginPage), diff --git a/server/private/routers/loginPage/createLoginPage.ts b/server/private/routers/loginPage/createLoginPage.ts index b5e8ccff..72b8a28f 100644 --- a/server/private/routers/loginPage/createLoginPage.ts +++ b/server/private/routers/loginPage/createLoginPage.ts @@ -30,9 +30,7 @@ import { fromError } from "zod-validation-error"; import { eq, and } from "drizzle-orm"; import { validateAndConstructDomain } from "@server/lib/domainUtils"; import { createCertificate } from "#private/routers/certificates/createCertificate"; -import { getOrgTierData } from "#private/lib/billing"; -import { TierId } from "@server/lib/billing/tiers"; -import { build } from "@server/build"; + import { CreateLoginPageResponse } from "@server/routers/loginPage/types"; const paramsSchema = z.strictObject({ @@ -76,19 +74,6 @@ export async function createLoginPage( const { orgId } = parsedParams.data; - if (build === "saas") { - const { tier } = await getOrgTierData(orgId); - const subscribed = tier === TierId.STANDARD; - if (!subscribed) { - return next( - createHttpError( - HttpCode.FORBIDDEN, - "This organization's current plan does not support this feature." - ) - ); - } - } - const [existing] = await db .select() .from(loginPageOrg) diff --git a/server/private/routers/loginPage/deleteLoginPageBranding.ts b/server/private/routers/loginPage/deleteLoginPageBranding.ts index 1fb243b0..0a59ce4e 100644 --- a/server/private/routers/loginPage/deleteLoginPageBranding.ts +++ b/server/private/routers/loginPage/deleteLoginPageBranding.ts @@ -25,9 +25,7 @@ import createHttpError from "http-errors"; import logger from "@server/logger"; import { fromError } from "zod-validation-error"; import { eq } from "drizzle-orm"; -import { getOrgTierData } from "#private/lib/billing"; -import { TierId } from "@server/lib/billing/tiers"; -import { build } from "@server/build"; + const paramsSchema = z .object({ @@ -53,18 +51,6 @@ export async function deleteLoginPageBranding( const { orgId } = parsedParams.data; - if (build === "saas") { - const { tier } = await getOrgTierData(orgId); - const subscribed = tier === TierId.STANDARD; - if (!subscribed) { - return next( - createHttpError( - HttpCode.FORBIDDEN, - "This organization's current plan does not support this feature." - ) - ); - } - } const [existingLoginPageBranding] = await db .select() diff --git a/server/private/routers/loginPage/getLoginPageBranding.ts b/server/private/routers/loginPage/getLoginPageBranding.ts index 8fd0772d..ce133c7c 100644 --- a/server/private/routers/loginPage/getLoginPageBranding.ts +++ b/server/private/routers/loginPage/getLoginPageBranding.ts @@ -25,9 +25,7 @@ import createHttpError from "http-errors"; import logger from "@server/logger"; import { fromError } from "zod-validation-error"; import { eq } from "drizzle-orm"; -import { getOrgTierData } from "#private/lib/billing"; -import { TierId } from "@server/lib/billing/tiers"; -import { build } from "@server/build"; + const paramsSchema = z.strictObject({ orgId: z.string() @@ -51,19 +49,6 @@ export async function getLoginPageBranding( const { orgId } = parsedParams.data; - if (build === "saas") { - const { tier } = await getOrgTierData(orgId); - const subscribed = tier === TierId.STANDARD; - if (!subscribed) { - return next( - createHttpError( - HttpCode.FORBIDDEN, - "This organization's current plan does not support this feature." - ) - ); - } - } - const [existingLoginPageBranding] = await db .select() .from(loginPageBranding) diff --git a/server/private/routers/loginPage/updateLoginPage.ts b/server/private/routers/loginPage/updateLoginPage.ts index bda614d3..6226dda2 100644 --- a/server/private/routers/loginPage/updateLoginPage.ts +++ b/server/private/routers/loginPage/updateLoginPage.ts @@ -23,9 +23,7 @@ import { eq, and } from "drizzle-orm"; import { validateAndConstructDomain } from "@server/lib/domainUtils"; import { subdomainSchema } from "@server/lib/schemas"; import { createCertificate } from "#private/routers/certificates/createCertificate"; -import { getOrgTierData } from "#private/lib/billing"; -import { TierId } from "@server/lib/billing/tiers"; -import { build } from "@server/build"; + import { UpdateLoginPageResponse } from "@server/routers/loginPage/types"; const paramsSchema = z @@ -87,18 +85,6 @@ export async function updateLoginPage( const { loginPageId, orgId } = parsedParams.data; - if (build === "saas") { - const { tier } = await getOrgTierData(orgId); - const subscribed = tier === TierId.STANDARD; - if (!subscribed) { - return next( - createHttpError( - HttpCode.FORBIDDEN, - "This organization's current plan does not support this feature." - ) - ); - } - } const [existingLoginPage] = await db .select() diff --git a/server/private/routers/loginPage/upsertLoginPageBranding.ts b/server/private/routers/loginPage/upsertLoginPageBranding.ts index e6e365be..e81628dc 100644 --- a/server/private/routers/loginPage/upsertLoginPageBranding.ts +++ b/server/private/routers/loginPage/upsertLoginPageBranding.ts @@ -25,8 +25,6 @@ import createHttpError from "http-errors"; import logger from "@server/logger"; import { fromError } from "zod-validation-error"; import { eq, InferInsertModel } from "drizzle-orm"; -import { getOrgTierData } from "#private/lib/billing"; -import { TierId } from "@server/lib/billing/tiers"; import { build } from "@server/build"; import config from "@server/private/lib/config"; @@ -128,19 +126,6 @@ export async function upsertLoginPageBranding( const { orgId } = parsedParams.data; - if (build === "saas") { - const { tier } = await getOrgTierData(orgId); - const subscribed = tier === TierId.STANDARD; - if (!subscribed) { - return next( - createHttpError( - HttpCode.FORBIDDEN, - "This organization's current plan does not support this feature." - ) - ); - } - } - let updateData = parsedBody.data satisfies InferInsertModel< typeof loginPageBranding >; diff --git a/server/private/routers/orgIdp/createOrgOidcIdp.ts b/server/private/routers/orgIdp/createOrgOidcIdp.ts index 998a159f..bee04340 100644 --- a/server/private/routers/orgIdp/createOrgOidcIdp.ts +++ b/server/private/routers/orgIdp/createOrgOidcIdp.ts @@ -24,9 +24,6 @@ import { idp, idpOidcConfig, idpOrg, orgs } from "@server/db"; import { generateOidcRedirectUrl } from "@server/lib/idp/generateRedirectUrl"; import { encrypt } from "@server/lib/crypto"; import config from "@server/lib/config"; -import { build } from "@server/build"; -import { getOrgTierData } from "#private/lib/billing"; -import { TierId } from "@server/lib/billing/tiers"; import { CreateOrgIdpResponse } from "@server/routers/orgIdp/types"; const paramsSchema = z.strictObject({ orgId: z.string().nonempty() }); @@ -109,19 +106,6 @@ export async function createOrgOidcIdp( tags } = parsedBody.data; - if (build === "saas") { - const { tier, active } = await getOrgTierData(orgId); - const subscribed = tier === TierId.STANDARD; - if (!subscribed) { - return next( - createHttpError( - HttpCode.FORBIDDEN, - "This organization's current plan does not support this feature." - ) - ); - } - } - const key = config.getRawConfig().server.secret!; const encryptedSecret = encrypt(clientSecret, key); diff --git a/server/private/routers/orgIdp/updateOrgOidcIdp.ts b/server/private/routers/orgIdp/updateOrgOidcIdp.ts index d8ef415c..e01bdba0 100644 --- a/server/private/routers/orgIdp/updateOrgOidcIdp.ts +++ b/server/private/routers/orgIdp/updateOrgOidcIdp.ts @@ -24,9 +24,6 @@ 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 { build } from "@server/build"; -import { getOrgTierData } from "#private/lib/billing"; -import { TierId } from "@server/lib/billing/tiers"; const paramsSchema = z .object({ @@ -114,19 +111,6 @@ export async function updateOrgOidcIdp( tags } = parsedBody.data; - if (build === "saas") { - const { tier, active } = await getOrgTierData(orgId); - const subscribed = tier === TierId.STANDARD; - if (!subscribed) { - return next( - createHttpError( - HttpCode.FORBIDDEN, - "This organization's current plan does not support this feature." - ) - ); - } - } - // Check if IDP exists and is of type OIDC const [existingIdp] = await db .select() diff --git a/server/routers/badger/verifySession.ts b/server/routers/badger/verifySession.ts index 3226755d..fa81b6f9 100644 --- a/server/routers/badger/verifySession.ts +++ b/server/routers/badger/verifySession.ts @@ -18,7 +18,6 @@ import { ResourcePassword, ResourcePincode, ResourceRule, - resourceSessions } from "@server/db"; import config from "@server/lib/config"; import { isIpInCidr, stripPortFromHost } from "@server/lib/ip"; @@ -32,7 +31,6 @@ import { fromError } from "zod-validation-error"; import { getCountryCodeForIp } from "@server/lib/geoip"; import { getAsnForIp } from "@server/lib/asn"; import { getOrgTierData } from "#dynamic/lib/billing"; -import { TierId } from "@server/lib/billing/tiers"; import { verifyPassword } from "@server/auth/password"; import { checkOrgAccessPolicy, @@ -40,8 +38,8 @@ import { } from "#dynamic/lib/checkOrgAccessPolicy"; import { logRequestAudit } from "./logRequestAudit"; import cache from "@server/lib/cache"; -import semver from "semver"; import { APP_VERSION } from "@server/lib/consts"; +import { isSubscribed } from "#private/lib/isSubscribed"; const verifyResourceSessionSchema = z.object({ sessions: z.record(z.string(), z.string()).optional(), @@ -798,8 +796,8 @@ async function notAllowed( ) { let loginPage: LoginPage | null = null; if (orgId) { - const { tier } = await getOrgTierData(orgId); // returns null in oss - if (tier === TierId.STANDARD) { + const subscribed = await isSubscribed(orgId); + if (subscribed) { loginPage = await getOrgLoginPage(orgId); } } @@ -852,8 +850,8 @@ async function headerAuthChallenged( ) { let loginPage: LoginPage | null = null; if (orgId) { - const { tier } = await getOrgTierData(orgId); // returns null in oss - if (tier === TierId.STANDARD) { + const subscribed = await isSubscribed(orgId); + if (subscribed) { loginPage = await getOrgLoginPage(orgId); } } diff --git a/server/routers/idp/generateOidcUrl.ts b/server/routers/idp/generateOidcUrl.ts index 50b63ee5..5743631b 100644 --- a/server/routers/idp/generateOidcUrl.ts +++ b/server/routers/idp/generateOidcUrl.ts @@ -14,8 +14,7 @@ import jsonwebtoken from "jsonwebtoken"; import config from "@server/lib/config"; import { decrypt } from "@server/lib/crypto"; import { build } from "@server/build"; -import { getOrgTierData } from "#dynamic/lib/billing"; -import { TierId } from "@server/lib/billing/tiers"; +import { isSubscribed } from "@server/private/lib/isSubscribed"; const paramsSchema = z .object({ @@ -113,8 +112,7 @@ export async function generateOidcUrl( } if (build === "saas") { - const { tier } = await getOrgTierData(orgId); - const subscribed = tier === TierId.STANDARD; + const subscribed = await isSubscribed(orgId); if (!subscribed) { return next( createHttpError( diff --git a/server/routers/org/updateOrg.ts b/server/routers/org/updateOrg.ts index 44ff9190..38ffab18 100644 --- a/server/routers/org/updateOrg.ts +++ b/server/routers/org/updateOrg.ts @@ -10,10 +10,10 @@ import logger from "@server/logger"; import { fromError } from "zod-validation-error"; import { OpenAPITags, registry } from "@server/openApi"; import { build } from "@server/build"; -import { getOrgTierData } from "#dynamic/lib/billing"; -import { TierId } from "@server/lib/billing/tiers"; import { cache } from "@server/lib/cache"; import { isLicensedOrSubscribed } from "#dynamic/lib/isLicencedOrSubscribed"; +import { subscribe } from "node:diagnostics_channel"; +import { isSubscribed } from "@server/private/lib/isSubscribed"; const updateOrgParamsSchema = z.strictObject({ orgId: z.string() @@ -95,10 +95,10 @@ export async function updateOrg( parsedBody.data.passwordExpiryDays = undefined; } - const { tier } = await getOrgTierData(orgId); + const subscribed = await isSubscribed(orgId); if ( build == "saas" && - tier != TierId.STANDARD && + subscribed && parsedBody.data.settingsLogRetentionDaysRequest && parsedBody.data.settingsLogRetentionDaysRequest > 30 ) { diff --git a/server/routers/user/createOrgUser.ts b/server/routers/user/createOrgUser.ts index b9a1abc9..3fe72a35 100644 --- a/server/routers/user/createOrgUser.ts +++ b/server/routers/user/createOrgUser.ts @@ -13,9 +13,8 @@ import { generateId } from "@server/auth/sessions/app"; import { usageService } from "@server/lib/billing/usageService"; import { FeatureId } from "@server/lib/billing"; import { build } from "@server/build"; -import { getOrgTierData } from "#dynamic/lib/billing"; -import { TierId } from "@server/lib/billing/tiers"; import { calculateUserClientsForOrgs } from "@server/lib/calculateUserClientsForOrgs"; +import { isSubscribed } from "@server/private/lib/isSubscribed"; const paramsSchema = z.strictObject({ orgId: z.string().nonempty() @@ -132,9 +131,8 @@ export async function createOrgUser( ); } else if (type === "oidc") { if (build === "saas") { - const { tier } = await getOrgTierData(orgId); - const subscribed = tier === TierId.STANDARD; - if (!subscribed) { + const subscribed = await isSubscribed(orgId); + if (subscribed) { return next( createHttpError( HttpCode.FORBIDDEN, diff --git a/src/app/[orgId]/settings/(private)/billing/page.tsx b/src/app/[orgId]/settings/(private)/billing/page.tsx index 75611f35..259d8a66 100644 --- a/src/app/[orgId]/settings/(private)/billing/page.tsx +++ b/src/app/[orgId]/settings/(private)/billing/page.tsx @@ -76,7 +76,6 @@ export default function GeneralPage() { setAllSubscriptions(subscriptions); // Import tier and license price sets - const { getTierPriceSet } = await import("@server/lib/billing/tiers"); const { getLicensePriceSet } = await import("@server/lib/billing/licenses"); const tierPriceSet = getTierPriceSet( diff --git a/src/app/[orgId]/settings/access/users/create/page.tsx b/src/app/[orgId]/settings/access/users/create/page.tsx index 0e55ffeb..7d8ad2cd 100644 --- a/src/app/[orgId]/settings/access/users/create/page.tsx +++ b/src/app/[orgId]/settings/access/users/create/page.tsx @@ -48,7 +48,6 @@ import { useTranslations } from "next-intl"; import { build } from "@server/build"; import Image from "next/image"; import { useSubscriptionStatusContext } from "@app/hooks/useSubscriptionStatusContext"; -import { TierId } from "@server/lib/billing/tiers"; type UserType = "internal" | "oidc"; diff --git a/src/app/auth/resource/[resourceGuid]/page.tsx b/src/app/auth/resource/[resourceGuid]/page.tsx index e2e0330c..5f9b1183 100644 --- a/src/app/auth/resource/[resourceGuid]/page.tsx +++ b/src/app/auth/resource/[resourceGuid]/page.tsx @@ -23,8 +23,6 @@ import type { LoadLoginPageBrandingResponse, LoadLoginPageResponse } from "@server/routers/loginPage/types"; -import { GetOrgTierResponse } from "@server/routers/billing/types"; -import { TierId } from "@server/lib/billing/tiers"; import { CheckOrgUserAccessResponse } from "@server/routers/org"; import OrgPolicyRequired from "@app/components/OrgPolicyRequired"; import { isOrgSubscribed } from "@app/lib/api/isOrgSubscribed"; diff --git a/src/contexts/subscriptionStatusContext.ts b/src/contexts/subscriptionStatusContext.ts index 5b333db4..71fe7004 100644 --- a/src/contexts/subscriptionStatusContext.ts +++ b/src/contexts/subscriptionStatusContext.ts @@ -5,7 +5,7 @@ type SubscriptionStatusContextType = { subscriptionStatus: GetOrgSubscriptionResponse | null; updateSubscriptionStatus: (updatedSite: GetOrgSubscriptionResponse) => void; isActive: () => boolean; - getTier: () => string | null; + getTier: () => { tier: string | null; active: boolean }; isSubscribed: () => boolean; subscribed: boolean; }; diff --git a/src/lib/api/isOrgSubscribed.ts b/src/lib/api/isOrgSubscribed.ts index 9440330b..8eb4b8e8 100644 --- a/src/lib/api/isOrgSubscribed.ts +++ b/src/lib/api/isOrgSubscribed.ts @@ -1,5 +1,4 @@ import { build } from "@server/build"; -import { TierId } from "@server/lib/billing/tiers"; import { cache } from "react"; import { getCachedSubscription } from "./getCachedSubscription"; import { priv } from "."; @@ -21,7 +20,7 @@ export const isOrgSubscribed = cache(async (orgId: string) => { try { const subRes = await getCachedSubscription(orgId); subscribed = - subRes.data.data.tier === TierId.STANDARD && + (subRes.data.data.tier == "home_lab" || subRes.data.data.tier == "starter" || subRes.data.data.tier == "scale") && subRes.data.data.active; } catch {} } diff --git a/src/providers/SubscriptionStatusProvider.tsx b/src/providers/SubscriptionStatusProvider.tsx index eecafce8..f9d8ef8b 100644 --- a/src/providers/SubscriptionStatusProvider.tsx +++ b/src/providers/SubscriptionStatusProvider.tsx @@ -1,7 +1,6 @@ "use client"; import SubscriptionStatusContext from "@app/contexts/subscriptionStatusContext"; -import { getTierPriceSet, TierId } from "@server/lib/billing/tiers"; import { GetOrgSubscriptionResponse } from "@server/routers/billing/types"; import { useState } from "react"; import { build } from "@server/build"; @@ -43,34 +42,37 @@ export function SubscriptionStatusProvider({ }; const getTier = () => { - const tierPriceSet = getTierPriceSet(env, sandbox_mode); - if (subscriptionStatus?.subscriptions) { // Iterate through all subscriptions - for (const { subscription, items } of subscriptionStatus.subscriptions) { - if (items && items.length > 0) { - // Iterate through tiers in order (earlier keys are higher tiers) - for (const [tierId, priceId] of Object.entries(tierPriceSet)) { - // Check if any subscription item matches this tier's price ID - const matchingItem = items.find( - (item) => item.priceId === priceId - ); - if (matchingItem) { - return tierId; - } - } + for (const { subscription } of subscriptionStatus.subscriptions) { + if ( + subscription.type == "home_lab" || + subscription.type == "starter" || + subscription.type == "scale" + ) { + return { + tier: subscription.type, + active: subscription.status === "active" + }; } } } - return null; + return { + tier: null, + active: false + }; }; const isSubscribed = () => { if (build === "enterprise") { return true; } - return getTier() === TierId.STANDARD; + const { tier, active } = getTier(); + return ( + (tier == "home_lab" || tier == "starter" || tier == "scale") && + active + ); }; const [subscribed, setSubscribed] = useState(isSubscribed()); @@ -91,4 +93,4 @@ export function SubscriptionStatusProvider({ ); } -export default SubscriptionStatusProvider; \ No newline at end of file +export default SubscriptionStatusProvider; From e101ac341b8fa4a9c5b698f1c36cf3d1f5bfc16b Mon Sep 17 00:00:00 2001 From: Owen Date: Fri, 6 Feb 2026 17:41:20 -0800 Subject: [PATCH 08/49] Basic billing page is working --- messages/en-US.json | 13 + package.json | 3 +- server/lib/billing/features.ts | 27 +- server/lib/isSubscribed.ts | 3 + .../routers/approvals/listApprovals.ts | 1 - server/private/routers/billing/changeTier.ts | 10 +- .../routers/billing/createCheckoutSession.ts | 12 +- server/private/routers/billing/index.ts | 1 + server/private/routers/external.ts | 8 + .../loginPage/upsertLoginPageBranding.ts | 2 +- server/routers/idp/generateOidcUrl.ts | 2 +- server/routers/org/updateOrg.ts | 3 +- server/routers/user/createOrgUser.ts | 2 +- .../settings/(private)/billing/page.tsx | 1008 +++++++---------- 14 files changed, 451 insertions(+), 644 deletions(-) create mode 100644 server/lib/isSubscribed.ts diff --git a/messages/en-US.json b/messages/en-US.json index b08c1cf8..5616c666 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -1520,6 +1520,19 @@ "resourcePortRequired": "Port number is required for non-HTTP resources", "resourcePortNotAllowed": "Port number should not be set for HTTP resources", "billingPricingCalculatorLink": "Pricing Calculator", + "billingYourPlan": "Your Plan", + "billingViewOrModifyPlan": "View or modify your current plan", + "billingViewPlanDetails": "View Plan Details", + "billingUsageAndLimits": "Usage and Limits", + "billingViewUsageAndLimits": "View your plan's limits and current usage", + "billingCurrentUsage": "Current Usage", + "billingMaximumLimits": "Maximum Limits", + "billingRemoteNodes": "Remote Nodes", + "billingUnlimited": "Unlimited", + "billingPaidLicenseKeys": "Paid License Keys", + "billingManageLicenseSubscription": "Manage your subscription for paid self-hosted license keys", + "billingCurrentKeys": "Current Keys", + "billingModifyCurrentPlan": "Modify Current Plan", "signUpTerms": { "IAgreeToThe": "I agree to the", "termsOfService": "terms of service", diff --git a/package.json b/package.json index 63d844bc..69da4cdf 100644 --- a/package.json +++ b/package.json @@ -14,7 +14,8 @@ "dev": "NODE_ENV=development ENVIRONMENT=dev tsx watch server/index.ts", "dev:check": "npx tsc --noEmit && npm run format:check", "dev:setup": "cp config/config.example.yml config/config.yml && npm run set:oss && npm run set:sqlite && npm run db:generate && npm run db:sqlite:push", - "db:generate": "drizzle-kit generate --config=./drizzle.config.ts", + "db:pg:generate": "drizzle-kit generate --config=./drizzle.pg.config.ts", + "db:sqlite:generate": "drizzle-kit generate --config=./drizzle.sqlite.config.ts", "db:pg:push": "npx tsx server/db/pg/migrate.ts", "db:sqlite:push": "npx tsx server/db/sqlite/migrate.ts", "db:studio": "drizzle-kit studio --config=./drizzle.config.ts", diff --git a/server/lib/billing/features.ts b/server/lib/billing/features.ts index a3ab0cc8..b2eb2f0a 100644 --- a/server/lib/billing/features.ts +++ b/server/lib/billing/features.ts @@ -1,4 +1,5 @@ import Stripe from "stripe"; +import { usageService } from "./usageService"; export enum FeatureId { USERS = "users", @@ -95,10 +96,24 @@ export function getScaleFeaturePriceSet(): FeaturePriceSet { } } -export function getLineItems( - featurePriceSet: FeaturePriceSet -): Stripe.Checkout.SessionCreateParams.LineItem[] { - return Object.entries(featurePriceSet).map(([featureId, priceId]) => ({ - price: priceId - })); +export async function getLineItems( + featurePriceSet: FeaturePriceSet, + orgId: string, +): Promise { + const users = await usageService.getUsageDaily(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.HOME_LAB) { + quantity = 1; + } + + return { + price: priceId, + quantity: quantity + }; + }); } diff --git a/server/lib/isSubscribed.ts b/server/lib/isSubscribed.ts new file mode 100644 index 00000000..44a4c0b3 --- /dev/null +++ b/server/lib/isSubscribed.ts @@ -0,0 +1,3 @@ +export async function isSubscribed(orgId: string): Promise { + return false; +} diff --git a/server/private/routers/approvals/listApprovals.ts b/server/private/routers/approvals/listApprovals.ts index 509df5eb..753a2f1a 100644 --- a/server/private/routers/approvals/listApprovals.ts +++ b/server/private/routers/approvals/listApprovals.ts @@ -31,7 +31,6 @@ import { import { eq, isNull, sql, not, and, desc } from "drizzle-orm"; import response from "@server/lib/response"; import { getUserDeviceName } from "@server/db/names"; -import { isLicensedOrSubscribed } from "@server/private/lib/isLicencedOrSubscribed"; const paramsSchema = z.strictObject({ orgId: z.string() diff --git a/server/private/routers/billing/changeTier.ts b/server/private/routers/billing/changeTier.ts index 68a90c92..0d966346 100644 --- a/server/private/routers/billing/changeTier.ts +++ b/server/private/routers/billing/changeTier.ts @@ -25,6 +25,7 @@ import { getHomeLabFeaturePriceSet, getScaleFeaturePriceSet, getStarterFeaturePriceSet, + getLineItems, FeatureId, type FeaturePriceSet } from "@server/lib/billing"; @@ -149,7 +150,7 @@ export async function changeTier( // Determine if we're switching between different products // home_lab uses HOME_LAB product, starter/scale use USERS product const currentTier = subscription.type; - const switchingProducts = + const switchingProducts = (currentTier === "home_lab" && (tier === "starter" || tier === "scale")) || ((currentTier === "starter" || currentTier === "scale") && tier === "home_lab"); @@ -175,10 +176,9 @@ export async function changeTier( } // Add new items for the target tier - for (const [featureId, priceId] of Object.entries(targetPriceSet)) { - itemsToUpdate.push({ - price: priceId - }); + const newLineItems = await getLineItems(targetPriceSet, orgId); + for (const lineItem of newLineItems) { + itemsToUpdate.push(lineItem); } updatedSubscription = await stripe!.subscriptions.update( diff --git a/server/private/routers/billing/createCheckoutSession.ts b/server/private/routers/billing/createCheckoutSession.ts index a90c5e86..1a1c5c41 100644 --- a/server/private/routers/billing/createCheckoutSession.ts +++ b/server/private/routers/billing/createCheckoutSession.ts @@ -23,6 +23,8 @@ 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 Stripe from "stripe"; const createCheckoutSessionSchema = z.strictObject({ orgId: z.string() @@ -80,19 +82,21 @@ export async function createCheckoutSession( ); } - let lineItems; + let lineItems: Stripe.Checkout.SessionCreateParams.LineItem[]; if (tier === "home_lab") { - lineItems = getLineItems(getHomeLabFeaturePriceSet()); + lineItems = await getLineItems(getHomeLabFeaturePriceSet(), orgId); } else if (tier === "starter") { - lineItems = getLineItems(getStarterFeaturePriceSet()); + lineItems = await getLineItems(getStarterFeaturePriceSet(), orgId); } else if (tier === "scale") { - lineItems = getLineItems(getScaleFeaturePriceSet()); + lineItems = await getLineItems(getScaleFeaturePriceSet(), orgId); } else { return next( createHttpError(HttpCode.BAD_REQUEST, "Invalid plan") ); } + 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 billing_address_collection: "required", diff --git a/server/private/routers/billing/index.ts b/server/private/routers/billing/index.ts index b7bf02d4..6555f549 100644 --- a/server/private/routers/billing/index.ts +++ b/server/private/routers/billing/index.ts @@ -16,3 +16,4 @@ export * from "./createPortalSession"; export * from "./getOrgSubscriptions"; export * from "./getOrgUsage"; export * from "./internalGetOrgTier"; +export * from "./changeTier"; diff --git a/server/private/routers/external.ts b/server/private/routers/external.ts index 0ef9077d..bef493ca 100644 --- a/server/private/routers/external.ts +++ b/server/private/routers/external.ts @@ -151,6 +151,14 @@ if (build === "saas") { billing.createCheckoutSession ); + authenticated.post( + "/org/:orgId/billing/change-tier", + verifyOrgAccess, + verifyUserHasAction(ActionsEnum.billing), + logActionAudit(ActionsEnum.billing), + billing.changeTier + ); + authenticated.post( "/org/:orgId/billing/create-portal-session", verifyOrgAccess, diff --git a/server/private/routers/loginPage/upsertLoginPageBranding.ts b/server/private/routers/loginPage/upsertLoginPageBranding.ts index e81628dc..bc93bfc0 100644 --- a/server/private/routers/loginPage/upsertLoginPageBranding.ts +++ b/server/private/routers/loginPage/upsertLoginPageBranding.ts @@ -26,7 +26,7 @@ import logger from "@server/logger"; import { fromError } from "zod-validation-error"; import { eq, InferInsertModel } from "drizzle-orm"; import { build } from "@server/build"; -import config from "@server/private/lib/config"; +import config from "#private/lib/config"; const paramsSchema = z.strictObject({ orgId: z.string() diff --git a/server/routers/idp/generateOidcUrl.ts b/server/routers/idp/generateOidcUrl.ts index 5743631b..46f34ef5 100644 --- a/server/routers/idp/generateOidcUrl.ts +++ b/server/routers/idp/generateOidcUrl.ts @@ -14,7 +14,7 @@ import jsonwebtoken from "jsonwebtoken"; import config from "@server/lib/config"; import { decrypt } from "@server/lib/crypto"; import { build } from "@server/build"; -import { isSubscribed } from "@server/private/lib/isSubscribed"; +import { isSubscribed } from "#dynamic/lib/isSubscribed"; const paramsSchema = z .object({ diff --git a/server/routers/org/updateOrg.ts b/server/routers/org/updateOrg.ts index 38ffab18..4762c32f 100644 --- a/server/routers/org/updateOrg.ts +++ b/server/routers/org/updateOrg.ts @@ -12,8 +12,7 @@ import { OpenAPITags, registry } from "@server/openApi"; import { build } from "@server/build"; import { cache } from "@server/lib/cache"; import { isLicensedOrSubscribed } from "#dynamic/lib/isLicencedOrSubscribed"; -import { subscribe } from "node:diagnostics_channel"; -import { isSubscribed } from "@server/private/lib/isSubscribed"; +import { isSubscribed } from "#dynamic/lib/isSubscribed"; const updateOrgParamsSchema = z.strictObject({ orgId: z.string() diff --git a/server/routers/user/createOrgUser.ts b/server/routers/user/createOrgUser.ts index 3fe72a35..c061ef27 100644 --- a/server/routers/user/createOrgUser.ts +++ b/server/routers/user/createOrgUser.ts @@ -14,7 +14,7 @@ import { usageService } from "@server/lib/billing/usageService"; import { FeatureId } from "@server/lib/billing"; import { build } from "@server/build"; import { calculateUserClientsForOrgs } from "@server/lib/calculateUserClientsForOrgs"; -import { isSubscribed } from "@server/private/lib/isSubscribed"; +import { isSubscribed } from "#dynamic/lib/isSubscribed"; const paramsSchema = z.strictObject({ orgId: z.string().nonempty() diff --git a/src/app/[orgId]/settings/(private)/billing/page.tsx b/src/app/[orgId]/settings/(private)/billing/page.tsx index 259d8a66..51424d3b 100644 --- a/src/app/[orgId]/settings/(private)/billing/page.tsx +++ b/src/app/[orgId]/settings/(private)/billing/page.tsx @@ -17,23 +17,21 @@ import { SettingsSectionBody, SettingsSectionFooter } from "@app/components/Settings"; -import { Badge } from "@/components/ui/badge"; -import { Separator } from "@/components/ui/separator"; -import { Alert, AlertDescription } from "@/components/ui/alert"; -import { Progress } from "@/components/ui/progress"; +import { + InfoSection, + InfoSectionContent, + InfoSections, + InfoSectionTitle +} from "@app/components/InfoSection"; +import { cn } from "@app/lib/cn"; import { CreditCard, - Database, - Clock, - AlertCircle, - CheckCircle, - Users, - Calculator, ExternalLink, - Gift, - Server + Users, + Globe, + Server, + Layout } from "lucide-react"; -import { InfoPopup } from "@/components/ui/info-popup"; import { GetOrgSubscriptionResponse, GetOrgUsageResponse @@ -41,13 +39,60 @@ import { import { useTranslations } from "use-intl"; import Link from "next/link"; -export default function GeneralPage() { +// Plan tier definitions matching the mockup +type PlanId = "starter" | "homelab" | "team" | "business" | "enterprise"; + +interface PlanOption { + id: PlanId; + name: string; + price: string; + priceDetail?: string; + tierType: "home_lab" | "starter" | "scale" | null; // Maps to backend tier types +} + +const planOptions: PlanOption[] = [ + { + id: "starter", + name: "Starter", + price: "Free", + tierType: null + }, + { + id: "homelab", + name: "Homelab", + price: "$15", + priceDetail: "/ month", + tierType: "home_lab" + }, + { + id: "team", + name: "Team", + price: "$5", + priceDetail: "per user / month", + tierType: "starter" + }, + { + id: "business", + name: "Business", + price: "$10", + priceDetail: "per user / month", + tierType: "scale" + }, + { + id: "enterprise", + name: "Enterprise", + price: "Custom", + tierType: null + } +]; + +export default function BillingPage() { const { org } = useOrgContext(); const envContext = useEnvContext(); const api = createApiClient(envContext); const t = useTranslations(); - // Subscription state - now handling multiple subscriptions + // Subscription state const [allSubscriptions, setAllSubscriptions] = useState< GetOrgSubscriptionResponse["subscriptions"] >([]); @@ -57,7 +102,7 @@ export default function GeneralPage() { useState(null); const [subscriptionLoading, setSubscriptionLoading] = useState(true); - // Example usage data (replace with real usage data if available) + // Usage and limits data const [usageData, setUsageData] = useState( [] ); @@ -65,6 +110,18 @@ export default function GeneralPage() { [] ); + const [hasSubscription, setHasSubscription] = useState(false); + const [isLoading, setIsLoading] = useState(false); + const [currentTier, setCurrentTier] = useState< + "home_lab" | "starter" | "scale" | null + >(null); + + // Usage IDs + const SITES = "sites"; + const USERS = "users"; + const DOMAINS = "domains"; + const REMOTE_EXIT_NODES = "remoteExitNodes"; + useEffect(() => { async function fetchSubscription() { setSubscriptionLoading(true); @@ -75,37 +132,31 @@ export default function GeneralPage() { const { subscriptions } = res.data.data; setAllSubscriptions(subscriptions); - // Import tier and license price sets - const { getLicensePriceSet } = await import("@server/lib/billing/licenses"); - - const tierPriceSet = getTierPriceSet( - envContext.env.app.environment, - envContext.env.app.sandbox_mode - ); - const licensePriceSet = getLicensePriceSet( - envContext.env.app.environment, - envContext.env.app.sandbox_mode - ); - - // Find tier subscription (subscription with items matching tier prices) - const tierSub = subscriptions.find(({ items }) => - items.some((item) => - item.priceId && Object.values(tierPriceSet).includes(item.priceId) - ) + // Find tier subscription + const tierSub = subscriptions.find(({ subscription }) => + subscription?.type === "home_lab" || + subscription?.type === "starter" || + subscription?.type === "scale" ); setTierSubscription(tierSub || null); - // Find license subscription (subscription with items matching license prices) - const licenseSub = subscriptions.find(({ items }) => - items.some((item) => - item.priceId && Object.values(licensePriceSet).includes(item.priceId) - ) + if (tierSub?.subscription) { + setCurrentTier( + tierSub.subscription.type as + | "home_lab" + | "starter" + | "scale" + ); + setHasSubscription( + tierSub.subscription.status === "active" + ); + } + + // Find license subscription + const licenseSub = subscriptions.find( + ({ subscription }) => subscription?.type === "license" ); setLicenseSubscription(licenseSub || null); - - setHasSubscription( - !!tierSub?.subscription && tierSub.subscription.status === "active" - ); } catch (error) { toast({ title: t("billingFailedToLoadSubscription"), @@ -126,7 +177,6 @@ export default function GeneralPage() { `/org/${org.org.orgId}/billing/usage` ); const { usage, limits } = res.data.data; - setUsageData(usage); setLimitsData(limits); } catch (error) { @@ -135,27 +185,18 @@ export default function GeneralPage() { description: formatAxiosError(error), variant: "destructive" }); - } finally { } } fetchUsage(); }, [org.org.orgId]); - const [hasSubscription, setHasSubscription] = useState(true); - const [isLoading, setIsLoading] = useState(false); - // const [newPricing, setNewPricing] = useState({ - // pricePerGB: mockSubscription.pricePerGB, - // pricePerMinute: mockSubscription.pricePerMinute, - // }) - - const handleStartSubscription = async () => { + const handleStartSubscription = async (tier: "home_lab" | "starter" | "scale") => { setIsLoading(true); try { const response = await api.post>( `/org/${org.org.orgId}/billing/create-checkout-session`, - {} + { tier } ); - console.log("Checkout session response:", response.data); const checkoutUrl = response.data.data; if (checkoutUrl) { window.location.href = checkoutUrl; @@ -205,209 +246,127 @@ export default function GeneralPage() { } }; - // Usage IDs - const SITES = "sites"; - const USERS = "users"; - const EGRESS_DATA_MB = "egressDataMb"; - const DOMAINS = "domains"; - const REMOTE_EXIT_NODES = "remoteExitNodes"; - - // Helper to calculate tiered price - function calculateTieredPrice( - usage: number, - tiersRaw: string | null | undefined - ) { - if (!tiersRaw) return 0; - let tiers: any[] = []; - try { - tiers = JSON.parse(tiersRaw); - } catch { - return 0; + const handleChangeTier = async (tier: "home_lab" | "starter" | "scale") => { + if (!hasSubscription) { + // If no subscription, start a new one + handleStartSubscription(tier); + return; } - let total = 0; - let remaining = usage; - for (const tier of tiers) { - const upTo = tier.up_to === null ? Infinity : Number(tier.up_to); - const unitAmount = - tier.unit_amount !== null - ? Number(tier.unit_amount / 100) - : tier.unit_amount_decimal - ? Number(tier.unit_amount_decimal / 100) - : 0; - const tierQty = Math.min( - remaining, - upTo === Infinity ? remaining : upTo - (usage - remaining) - ); - if (tierQty > 0) { - total += tierQty * unitAmount; - remaining -= tierQty; + + setIsLoading(true); + try { + await api.post(`/org/${org.org.orgId}/billing/change-tier`, { + tier + }); + // Refresh subscription data + window.location.reload(); + } catch (error) { + toast({ + title: "Failed to change tier", + description: formatAxiosError(error), + variant: "destructive" + }); + setIsLoading(false); + } + }; + + const handleContactUs = () => { + window.open("mailto:sales@pangolin.net", "_blank"); + }; + + // Get current plan ID from tier + const getCurrentPlanId = (): PlanId => { + if (!hasSubscription || !currentTier) return "starter"; + const plan = planOptions.find((p) => p.tierType === currentTier); + return plan?.id || "starter"; + }; + + const currentPlanId = getCurrentPlanId(); + + // Get button label and action for each plan + const getPlanAction = (plan: PlanOption) => { + if (plan.id === "enterprise") { + return { + label: "Contact Us", + action: handleContactUs, + variant: "outline" as const, + disabled: false + }; + } + + if (plan.id === currentPlanId) { + // If it's the free plan (starter with no subscription), show as current but disabled + if (plan.id === "starter" && !hasSubscription) { + return { + label: "Current Plan", + action: () => {}, + variant: "default" as const, + disabled: true + }; } - if (remaining <= 0) break; - } - return total; - } - - function getDisplayPrice(tiersRaw: string | null | undefined) { - //find the first non-zero tier price - if (!tiersRaw) return "$0.00"; - let tiers: any[] = []; - try { - tiers = JSON.parse(tiersRaw); - } catch { - return "$0.00"; - } - if (tiers.length === 0) return "$0.00"; - - // find the first tier with a non-zero price - const firstTier = - tiers.find( - (t) => - t.unit_amount > 0 || - (t.unit_amount_decimal && Number(t.unit_amount_decimal) > 0) - ) || tiers[0]; - const unitAmount = - firstTier.unit_amount !== null - ? Number(firstTier.unit_amount / 100) - : firstTier.unit_amount_decimal - ? Number(firstTier.unit_amount_decimal / 100) - : 0; - return `$${unitAmount.toFixed(4)}`; // ${firstTier.up_to === null ? "per unit" : `per ${firstTier.up_to} units`}`; - } - - // Helper to get included usage amount from subscription tier - function getIncludedUsage(tiersRaw: string | null | undefined) { - if (!tiersRaw) return 0; - let tiers: any[] = []; - try { - tiers = JSON.parse(tiersRaw); - } catch { - return 0; - } - if (tiers.length === 0) return 0; - - // Find the first tier (which represents included usage) - const firstTier = tiers[0]; - if (!firstTier) return 0; - - // If the first tier has a unit_amount of 0, it represents included usage - const isIncludedTier = - (firstTier.unit_amount === 0 || firstTier.unit_amount === null) && - (!firstTier.unit_amount_decimal || - Number(firstTier.unit_amount_decimal) === 0); - - if (isIncludedTier && firstTier.up_to !== null) { - return Number(firstTier.up_to); + return { + label: "Modify Current Plan", + action: handleModifySubscription, + variant: "default" as const, + disabled: false + }; } + const currentIndex = planOptions.findIndex( + (p) => p.id === currentPlanId + ); + const planIndex = planOptions.findIndex((p) => p.id === plan.id); + + if (planIndex < currentIndex) { + return { + label: "Downgrade", + action: () => + plan.tierType + ? handleChangeTier(plan.tierType) + : handleModifySubscription(), + variant: "outline" as const, + disabled: false + }; + } + + return { + label: "Upgrade", + action: () => + plan.tierType + ? hasSubscription + ? handleChangeTier(plan.tierType) + : handleStartSubscription(plan.tierType) + : handleModifySubscription(), + variant: "outline" as const, + disabled: false + }; + }; + + // Get usage value by feature ID + const getUsageValue = (featureId: string): number => { + const usage = usageData.find((u) => u.featureId === featureId); + return usage?.instantaneousValue || usage?.latestValue || 0; + }; + + // Get limit value by feature ID + const getLimitValue = (featureId: string): number | null => { + const limit = limitsData.find((l) => l.featureId === featureId); + return limit?.value ?? null; + }; + + // Calculate current usage cost for display + const getUserCount = () => getUsageValue(USERS); + const getPricePerUser = () => { + if (currentTier === "starter") return 5; + if (currentTier === "scale") return 10; return 0; - } + }; - // Helper to get display value for included usage - function getIncludedUsageDisplay(includedAmount: number, usageType: any) { - if (includedAmount === 0) return "0"; - - if (usageType.id === EGRESS_DATA_MB) { - // Convert MB to GB for data usage - return (includedAmount / 1000).toFixed(2); - } - - if (usageType.id === USERS || usageType.id === DOMAINS) { - // divide by 32 days - return (includedAmount / 32).toFixed(2); - } - - return includedAmount.toString(); - } - - // Helper to get usage, subscription item, and limit by usageId - function getUsageItemAndLimit( - usageData: any[], - subscriptionItems: any[], - limitsData: any[], - usageId: string - ) { - const usage = usageData.find((u) => u.featureId === usageId); - if (!usage) return { usage: 0, item: undefined, limit: undefined }; - const item = subscriptionItems.find((i) => i.meterId === usage.meterId); - const limit = limitsData.find((l) => l.featureId === usageId); - return { usage: usage ?? 0, item, limit }; - } - - // Get tier subscription items - const tierSubscriptionItems = tierSubscription?.items || []; - const tierSubscriptionData = tierSubscription?.subscription || null; - - // Helper to check if usage exceeds limit - function isOverLimit(usage: any, limit: any, usageType: any) { - if (!limit || !usage) return false; - const currentUsage = usageType.getLimitUsage(usage); - return currentUsage > limit.value; - } - - // Map usage and pricing for each usage type - const usageTypes = [ - { - id: EGRESS_DATA_MB, - label: t("billingDataUsage"), - icon: , - unit: "GB", - unitRaw: "MB", - info: t("billingDataUsageInfo"), - note: "Not counted on self-hosted nodes", - // Convert MB to GB for display and pricing - getDisplay: (v: any) => (v.latestValue / 1000).toFixed(2), - getLimitDisplay: (v: any) => (v.value / 1000).toFixed(2), - getUsage: (v: any) => v.latestValue, - getLimitUsage: (v: any) => v.latestValue - }, - { - id: SITES, - label: t("billingSites"), - icon: , - unit: "", - info: t("billingSitesInfo"), - getDisplay: (v: any) => v.latestValue, - getLimitDisplay: (v: any) => v.value, - getUsage: (v: any) => v.latestValue, - getLimitUsage: (v: any) => v.latestValue - }, - { - id: USERS, - label: t("billingUsers"), - icon: , - unit: "", - unitRaw: "user days", - info: t("billingUsersInfo"), - getDisplay: (v: any) => v.instantaneousValue, - getLimitDisplay: (v: any) => v.value, - getUsage: (v: any) => v.latestValue, - getLimitUsage: (v: any) => v.instantaneousValue - }, - { - id: DOMAINS, - label: t("billingDomains"), - icon: , - unit: "", - unitRaw: "domain days", - info: t("billingDomainInfo"), - getDisplay: (v: any) => v.instantaneousValue, - getLimitDisplay: (v: any) => v.value, - getUsage: (v: any) => v.latestValue, - getLimitUsage: (v: any) => v.instantaneousValue - }, - { - id: REMOTE_EXIT_NODES, - label: t("billingRemoteExitNodes"), - icon: , - unit: "", - unitRaw: "node days", - info: t("billingRemoteExitNodesInfo"), - getDisplay: (v: any) => v.instantaneousValue, - getLimitDisplay: (v: any) => v.value, - getUsage: (v: any) => v.latestValue, - getLimitUsage: (v: any) => v.instantaneousValue - } - ]; + // Get license key count + const getLicenseKeyCount = (): number => { + if (!licenseSubscription?.items) return 0; + return licenseSubscription.items.length; + }; if (subscriptionLoading) { return ( @@ -419,420 +378,225 @@ export default function GeneralPage() { return ( -
- - {tierSubscriptionData?.status === "active" && ( - - )} - {tierSubscriptionData - ? tierSubscriptionData.status.charAt(0).toUpperCase() + - tierSubscriptionData.status.slice(1) - : t("billingFreeTier")} - - - {t("billingPricingCalculatorLink")} - - -
- - {usageTypes.some((type) => { - const { usage, limit } = getUsageItemAndLimit( - usageData, - tierSubscriptionItems, - limitsData, - type.id - ); - return isOverLimit(usage, limit, type); - }) && ( - - - - {t("billingWarningOverLimit")} - - - )} - + {/* Your Plan Section */} - {t("billingUsageLimitsOverview")} + {t("billingYourPlan") || "Your Plan"} - {t("billingMonitorUsage")} + {t("billingViewOrModifyPlan") || + "View or modify your current plan"} -
- {usageTypes.map((type) => { - const { usage, limit } = getUsageItemAndLimit( - usageData, - tierSubscriptionItems, - limitsData, - type.id - ); - const displayUsage = type.getDisplay(usage); - const usageForPricing = type.getLimitUsage(usage); - const overLimit = isOverLimit(usage, limit, type); - const percentage = limit - ? Math.min( - (usageForPricing / limit.value) * 100, - 100 - ) - : 0; + {/* Plan Cards Grid */} +
+ {planOptions.map((plan) => { + const isCurrentPlan = plan.id === currentPlanId; + const planAction = getPlanAction(plan); return ( -
-
-
- {type.icon} - - {type.label} - - +
+
+
+ {plan.name}
-
- - {displayUsage} {type.unit} +
+ + {plan.price} - {limit && ( - - {" "} - /{" "} - {type.getLimitDisplay( - limit - )}{" "} - {type.unit} + {plan.priceDetail && ( + + {plan.priceDetail} )}
- {type.note && ( -
- {type.note} -
- )} - {limit && ( - + +
); })}
+ + + + + - {(hasSubscription || - (!hasSubscription && limitsData.length > 0)) && ( + {/* Usage and Limits Section */} + + + + {t("billingUsageAndLimits") || "Usage and Limits"} + + + {t("billingViewUsageAndLimits") || + "View your plan's limits and current usage"} + + + +
+ {/* Current Usage */} +
+
+ {t("billingCurrentUsage") || "Current Usage"} +
+
+ + {getUserCount()} + + + {t("billingUsers") || "Users"} + +
+ {hasSubscription && getPricePerUser() > 0 && ( +
+ x ${getPricePerUser()} / month = $ + {getUserCount() * getPricePerUser()} / month +
+ )} +
+ + {/* Maximum Limits */} +
+
+ {t("billingMaximumLimits") || "Maximum Limits"} +
+ + + + + {t("billingUsers") || "Users"} + + + {getLimitValue(USERS) ?? + t("billingUnlimited") ?? + "∞"}{" "} + {getLimitValue(USERS) !== null && + "users"} + + + + + + {t("billingSites") || "Sites"} + + + {getLimitValue(SITES) ?? + t("billingUnlimited") ?? + "∞"}{" "} + {getLimitValue(SITES) !== null && + "sites"} + + + + + + {t("billingDomains") || "Domains"} + + + {getLimitValue(DOMAINS) ?? + t("billingUnlimited") ?? + "∞"}{" "} + {getLimitValue(DOMAINS) !== null && + "domains"} + + + + + + {t("billingRemoteNodes") || + "Remote Nodes"} + + + {getLimitValue(REMOTE_EXIT_NODES) ?? + t("billingUnlimited") ?? + "∞"}{" "} + {getLimitValue(REMOTE_EXIT_NODES) !== + null && "remote nodes"} + + + +
+
+
+
+ + {/* Paid License Keys Section */} + {(licenseSubscription || getLicenseKeyCount() > 0) && ( - {t("billingIncludedUsage")} + {t("billingPaidLicenseKeys") || + "Paid License Keys"} - {hasSubscription - ? t("billingIncludedUsageDescription") - : t("billingFreeTierIncludedUsage")} + {t("billingManageLicenseSubscription") || + "Manage your subscription for paid self-hosted license keys"} -
- {usageTypes.map((type) => { - const { item, limit } = getUsageItemAndLimit( - usageData, - tierSubscriptionItems, - limitsData, - type.id - ); - - // For subscribed users, show included usage from tiers - // For free users, show the limit as "included" - let includedAmount = 0; - let displayIncluded = "0"; - - if (hasSubscription && item) { - includedAmount = getIncludedUsage( - item.tiers - ); - displayIncluded = getIncludedUsageDisplay( - includedAmount, - type - ); - } else if ( - !hasSubscription && - limit && - limit.value > 0 - ) { - // Show free tier limits as "included" - includedAmount = limit.value; - displayIncluded = - type.getLimitDisplay(limit); - } - - if (includedAmount === 0) return null; - - return ( -
-
- {type.icon} - - {type.label} - -
-
-
- {hasSubscription ? ( - - ) : ( - - )} - - {displayIncluded}{" "} - {type.unit} - -
-
- {hasSubscription - ? t("billingIncluded") - : t("billingFreeTier")} -
-
-
- ); - })} -
-
-
- )} - - {hasSubscription && ( - - - - {t("billingEstimatedPeriod")} - - - -
-
- {usageTypes.map((type) => { - const { usage, item } = - getUsageItemAndLimit( - usageData, - tierSubscriptionItems, - limitsData, - type.id - ); - const displayPrice = getDisplayPrice( - item?.tiers - ); - return ( -
- {type.label}: - - {type.getUsage(usage)}{" "} - {type.unitRaw || type.unit} x{" "} - {displayPrice} - -
- ); - })} - {/* Show recurring charges (items with unitAmount but no tiers/meterId) */} - {tierSubscriptionItems - .filter( - (item) => - item.unitAmount && - item.unitAmount > 0 && - !item.tiers && - !item.meterId - ) - .map((item, index) => ( -
- - {item.name || - t("billingRecurringCharge")} - : - - - $ - {( - (item.unitAmount || 0) / 100 - ).toFixed(2)} - -
- ))} - -
- {t("billingEstimatedTotal")} - - $ - {( - usageTypes.reduce((sum, type) => { - const { usage, item } = - getUsageItemAndLimit( - usageData, - tierSubscriptionItems, - limitsData, - type.id - ); - const usageForPricing = - type.getUsage(usage); - const cost = item - ? calculateTieredPrice( - usageForPricing, - item.tiers - ) - : 0; - return sum + cost; - }, 0) + - // Add recurring charges - tierSubscriptionItems - .filter( - (item) => - item.unitAmount && - item.unitAmount > 0 && - !item.tiers && - !item.meterId - ) - .reduce( - (sum, item) => - sum + - (item.unitAmount || 0) / - 100, - 0 - ) - ).toFixed(2)} +
+
+
+ {t("billingCurrentKeys") || "Current Keys"} +
+
+ + {getLicenseKeyCount()} + + + {getLicenseKeyCount() === 1 + ? "key" + : "keys"}
-
-

- {t("billingNotes")} -

-
-

{t("billingEstimateNote")}

-

{t("billingActualChargesMayVary")}

-

{t("billingBilledAtEnd")}

-
-
-
- - - - - - )} - - {!hasSubscription && ( - - -
- -

- {t("billingNoActiveSubscription")} -

-
)} - - {/* License Keys Section */} - {licenseSubscription && ( - - - - {t("billingLicenseKeys") || "License Keys"} - - - {t("billingLicenseKeysDescription") || "Manage your license key subscriptions"} - - - -
-
- - - {t("billingLicenseSubscription") || "License Subscription"} - -
- - {licenseSubscription.subscription?.status === "active" && ( - - )} - {licenseSubscription.subscription?.status - ? licenseSubscription.subscription.status - .charAt(0) - .toUpperCase() + - licenseSubscription.subscription.status.slice(1) - : t("billingInactive") || "Inactive"} - -
- - - -
-
- )} ); -} +} \ No newline at end of file From efc6ef3075a7706b3411fddd603d661b16d4c34e Mon Sep 17 00:00:00 2001 From: miloschwartz Date: Sat, 7 Feb 2026 17:00:44 -0800 Subject: [PATCH 09/49] show features in ce --- messages/en-US.json | 1 + server/routers/client/getClient.ts | 74 +-- .../settings/(private)/idp/create/page.tsx | 4 +- .../[orgId]/settings/(private)/idp/layout.tsx | 10 - .../[remoteExitNodeId]/credentials/page.tsx | 44 +- .../machine/[niceId]/credentials/page.tsx | 48 +- .../clients/user/[niceId]/general/page.tsx | 432 +++++++++--------- .../settings/general/auth-page/page.tsx | 5 - src/app/[orgId]/settings/general/layout.tsx | 10 +- .../settings/general/security/page.tsx | 253 +++++----- src/app/[orgId]/settings/logs/access/page.tsx | 23 +- src/app/[orgId]/settings/logs/action/page.tsx | 23 +- .../[orgId]/settings/logs/request/page.tsx | 4 + .../resources/proxy/[niceId]/general/page.tsx | 16 +- .../sites/[niceId]/credentials/page.tsx | 66 ++- src/app/navigation.tsx | 52 +-- src/components/ApprovalFeed.tsx | 41 +- src/components/CreateRoleForm.tsx | 87 ++-- src/components/EditRoleForm.tsx | 86 ++-- src/components/PaidFeaturesAlert.tsx | 63 ++- src/components/UserDevicesTable.tsx | 13 +- src/middleware.ts | 23 - 22 files changed, 671 insertions(+), 707 deletions(-) diff --git a/messages/en-US.json b/messages/en-US.json index e9d8cc37..07025a58 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -2247,6 +2247,7 @@ "actionLogsDescription": "View a history of actions performed in this organization", "accessLogsDescription": "View access auth requests for resources in this organization", "licenseRequiredToUse": "An Enterprise license is required to use this feature.", + "ossEnterpriseEditionRequired": "The Enterprise Edition is required to use this feature.", "certResolver": "Certificate Resolver", "certResolverDescription": "Select the certificate resolver to use for this resource.", "selectCertResolver": "Select Certificate Resolver", diff --git a/server/routers/client/getClient.ts b/server/routers/client/getClient.ts index 66a6432f..12901d0c 100644 --- a/server/routers/client/getClient.ts +++ b/server/routers/client/getClient.ts @@ -56,19 +56,29 @@ async function query(clientId?: number, niceId?: string, orgId?: string) { } type PostureData = { - biometricsEnabled?: boolean | null; - diskEncrypted?: boolean | null; - firewallEnabled?: boolean | null; - autoUpdatesEnabled?: boolean | null; - tpmAvailable?: boolean | null; - windowsAntivirusEnabled?: boolean | null; - macosSipEnabled?: boolean | null; - macosGatekeeperEnabled?: boolean | null; - macosFirewallStealthMode?: boolean | null; - linuxAppArmorEnabled?: boolean | null; - linuxSELinuxEnabled?: boolean | null; + biometricsEnabled?: boolean | null | "-"; + diskEncrypted?: boolean | null | "-"; + firewallEnabled?: boolean | null | "-"; + autoUpdatesEnabled?: boolean | null | "-"; + tpmAvailable?: boolean | null | "-"; + windowsAntivirusEnabled?: boolean | null | "-"; + macosSipEnabled?: boolean | null | "-"; + macosGatekeeperEnabled?: boolean | null | "-"; + macosFirewallStealthMode?: boolean | null | "-"; + linuxAppArmorEnabled?: boolean | null | "-"; + linuxSELinuxEnabled?: boolean | null | "-"; }; +function maskPostureDataWithPlaceholder(posture: PostureData): PostureData { + const masked: PostureData = {}; + for (const key of Object.keys(posture) as (keyof PostureData)[]) { + if (posture[key] !== undefined && posture[key] !== null) { + (masked as Record)[key] = "-"; + } + } + return masked; +} + function getPlatformPostureData( platform: string | null | undefined, fingerprint: typeof currentFingerprint.$inferSelect | null @@ -294,32 +304,34 @@ export async function getClient( // Build fingerprint data if available const fingerprintData = client.currentFingerprint ? { - username: client.currentFingerprint.username || null, - hostname: client.currentFingerprint.hostname || null, - platform: client.currentFingerprint.platform || null, - osVersion: client.currentFingerprint.osVersion || null, - kernelVersion: - client.currentFingerprint.kernelVersion || null, - arch: client.currentFingerprint.arch || null, - deviceModel: client.currentFingerprint.deviceModel || null, - serialNumber: client.currentFingerprint.serialNumber || null, - firstSeen: client.currentFingerprint.firstSeen || null, - lastSeen: client.currentFingerprint.lastSeen || null - } + username: client.currentFingerprint.username || null, + hostname: client.currentFingerprint.hostname || null, + platform: client.currentFingerprint.platform || null, + osVersion: client.currentFingerprint.osVersion || null, + kernelVersion: + client.currentFingerprint.kernelVersion || null, + arch: client.currentFingerprint.arch || null, + deviceModel: client.currentFingerprint.deviceModel || null, + serialNumber: client.currentFingerprint.serialNumber || null, + firstSeen: client.currentFingerprint.firstSeen || null, + lastSeen: client.currentFingerprint.lastSeen || null + } : null; // Build posture data if available (platform-specific) - // Only return posture data if org is licensed/subscribed - let postureData: PostureData | null = null; + // Licensed: real values; not licensed: same keys but values set to "-" + const rawPosture = getPlatformPostureData( + client.currentFingerprint?.platform || null, + client.currentFingerprint + ); const isOrgLicensed = await isLicensedOrSubscribed( client.clients.orgId ); - if (isOrgLicensed) { - postureData = getPlatformPostureData( - client.currentFingerprint?.platform || null, - client.currentFingerprint - ); - } + const postureData: PostureData | null = rawPosture + ? isOrgLicensed + ? rawPosture + : maskPostureDataWithPlaceholder(rawPosture) + : null; const data: GetClientResponse = { ...client.clients, diff --git a/src/app/[orgId]/settings/(private)/idp/create/page.tsx b/src/app/[orgId]/settings/(private)/idp/create/page.tsx index 5ae4f237..2f248077 100644 --- a/src/app/[orgId]/settings/(private)/idp/create/page.tsx +++ b/src/app/[orgId]/settings/(private)/idp/create/page.tsx @@ -27,6 +27,7 @@ import { import { Input } from "@app/components/ui/input"; import { useEnvContext } from "@app/hooks/useEnvContext"; import { useLicenseStatusContext } from "@app/hooks/useLicenseStatusContext"; +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"; @@ -51,6 +52,7 @@ export default function Page() { >("role"); const { isUnlocked } = useLicenseStatusContext(); const t = useTranslations(); + const { isPaidUser } = usePaidStatus(); const params = useParams(); @@ -806,7 +808,7 @@ export default function Page() { - - - )} + + + + diff --git a/src/app/[orgId]/settings/clients/machine/[niceId]/credentials/page.tsx b/src/app/[orgId]/settings/clients/machine/[niceId]/credentials/page.tsx index 9c4bf2bf..e6b5ff20 100644 --- a/src/app/[orgId]/settings/clients/machine/[niceId]/credentials/page.tsx +++ b/src/app/[orgId]/settings/clients/machine/[niceId]/credentials/page.tsx @@ -61,7 +61,9 @@ export default function CredentialsPage() { const isEnterpriseNotLicensed = build === "enterprise" && !isUnlocked(); const isSaasNotSubscribed = build === "saas" && !subscription?.isSubscribed(); - return isEnterpriseNotLicensed || isSaasNotSubscribed; + return ( + isEnterpriseNotLicensed || isSaasNotSubscribed || build === "oss" + ); }; const handleConfirmRegenerate = async () => { @@ -181,29 +183,27 @@ export default function CredentialsPage() { )} - {build !== "oss" && ( - - - - - )} + + + + @@ -559,217 +567,231 @@ export default function GeneralPage() { )} - {/* Device Security Section */} - {build !== "oss" && ( - - - - {t("deviceSecurity")} - - - {t("deviceSecurityDescription")} - - + + + + {t("deviceSecurity")} + + + {t("deviceSecurityDescription")} + + - - {client.posture && Object.keys(client.posture).length > 0 ? ( - <> - {!isPaidUser && } - - {client.posture.biometricsEnabled !== null && - client.posture.biometricsEnabled !== undefined && ( - - - {t("biometricsEnabled")} - - - {isPaidUser - ? formatPostureValue( - client.posture.biometricsEnabled - ) - : "-"} - - - )} + + + {client.posture && + Object.keys(client.posture).length > 0 ? ( + <> + + {client.posture.biometricsEnabled !== null && + client.posture.biometricsEnabled !== + undefined && ( + + + {t("biometricsEnabled")} + + + {isPaidUser + ? formatPostureValue( + client.posture + .biometricsEnabled + ) + : "-"} + + + )} - {client.posture.diskEncrypted !== null && - client.posture.diskEncrypted !== undefined && ( - - - {t("diskEncrypted")} - - - {isPaidUser - ? formatPostureValue( - client.posture.diskEncrypted - ) - : "-"} - - - )} + {client.posture.diskEncrypted !== null && + client.posture.diskEncrypted !== + undefined && ( + + + {t("diskEncrypted")} + + + {isPaidUser + ? formatPostureValue( + client.posture + .diskEncrypted + ) + : "-"} + + + )} - {client.posture.firewallEnabled !== null && - client.posture.firewallEnabled !== undefined && ( - - - {t("firewallEnabled")} - - - {isPaidUser - ? formatPostureValue( - client.posture.firewallEnabled - ) - : "-"} - - - )} + {client.posture.firewallEnabled !== null && + client.posture.firewallEnabled !== + undefined && ( + + + {t("firewallEnabled")} + + + {isPaidUser + ? formatPostureValue( + client.posture + .firewallEnabled + ) + : "-"} + + + )} - {client.posture.autoUpdatesEnabled !== null && - client.posture.autoUpdatesEnabled !== undefined && ( - - - {t("autoUpdatesEnabled")} - - - {isPaidUser - ? formatPostureValue( - client.posture.autoUpdatesEnabled - ) - : "-"} - - - )} + {client.posture.autoUpdatesEnabled !== null && + client.posture.autoUpdatesEnabled !== + undefined && ( + + + {t("autoUpdatesEnabled")} + + + {isPaidUser + ? formatPostureValue( + client.posture + .autoUpdatesEnabled + ) + : "-"} + + + )} - {client.posture.tpmAvailable !== null && - client.posture.tpmAvailable !== undefined && ( - - - {t("tpmAvailable")} - - - {isPaidUser - ? formatPostureValue( - client.posture.tpmAvailable - ) - : "-"} - - - )} + {client.posture.tpmAvailable !== null && + client.posture.tpmAvailable !== + undefined && ( + + + {t("tpmAvailable")} + + + {isPaidUser + ? formatPostureValue( + client.posture + .tpmAvailable + ) + : "-"} + + + )} - {client.posture.windowsAntivirusEnabled !== null && - client.posture.windowsAntivirusEnabled !== undefined && ( - - - {t("windowsAntivirusEnabled")} - - - {isPaidUser - ? formatPostureValue( - client.posture - .windowsAntivirusEnabled - ) - : "-"} - - - )} + {client.posture.windowsAntivirusEnabled !== + null && + client.posture.windowsAntivirusEnabled !== + undefined && ( + + + {t("windowsAntivirusEnabled")} + + + {isPaidUser + ? formatPostureValue( + client.posture + .windowsAntivirusEnabled + ) + : "-"} + + + )} - {client.posture.macosSipEnabled !== null && - client.posture.macosSipEnabled !== undefined && ( - - - {t("macosSipEnabled")} - - - {isPaidUser - ? formatPostureValue( - client.posture.macosSipEnabled - ) - : "-"} - - - )} + {client.posture.macosSipEnabled !== null && + client.posture.macosSipEnabled !== + undefined && ( + + + {t("macosSipEnabled")} + + + {isPaidUser + ? formatPostureValue( + client.posture + .macosSipEnabled + ) + : "-"} + + + )} - {client.posture.macosGatekeeperEnabled !== null && - client.posture.macosGatekeeperEnabled !== - undefined && ( - - - {t("macosGatekeeperEnabled")} - - - {isPaidUser - ? formatPostureValue( - client.posture - .macosGatekeeperEnabled - ) - : "-"} - - - )} + {client.posture.macosGatekeeperEnabled !== + null && + client.posture.macosGatekeeperEnabled !== + undefined && ( + + + {t("macosGatekeeperEnabled")} + + + {isPaidUser + ? formatPostureValue( + client.posture + .macosGatekeeperEnabled + ) + : "-"} + + + )} - {client.posture.macosFirewallStealthMode !== null && - client.posture.macosFirewallStealthMode !== - undefined && ( - - - {t("macosFirewallStealthMode")} - - - {isPaidUser - ? formatPostureValue( - client.posture - .macosFirewallStealthMode - ) - : "-"} - - - )} + {client.posture.macosFirewallStealthMode !== + null && + client.posture.macosFirewallStealthMode !== + undefined && ( + + + {t("macosFirewallStealthMode")} + + + {isPaidUser + ? formatPostureValue( + client.posture + .macosFirewallStealthMode + ) + : "-"} + + + )} - {client.posture.linuxAppArmorEnabled !== null && - client.posture.linuxAppArmorEnabled !== - undefined && ( - - - {t("linuxAppArmorEnabled")} - - - {isPaidUser - ? formatPostureValue( - client.posture - .linuxAppArmorEnabled - ) - : "-"} - - - )} + {client.posture.linuxAppArmorEnabled !== null && + client.posture.linuxAppArmorEnabled !== + undefined && ( + + + {t("linuxAppArmorEnabled")} + + + {isPaidUser + ? formatPostureValue( + client.posture + .linuxAppArmorEnabled + ) + : "-"} + + + )} - {client.posture.linuxSELinuxEnabled !== null && - client.posture.linuxSELinuxEnabled !== - undefined && ( - - - {t("linuxSELinuxEnabled")} - - - {isPaidUser - ? formatPostureValue( - client.posture - .linuxSELinuxEnabled - ) - : "-"} - - - )} - - - ) : ( -
- {t("noData")} -
- )} -
-
- )} + {client.posture.linuxSELinuxEnabled !== null && + client.posture.linuxSELinuxEnabled !== + undefined && ( + + + {t("linuxSELinuxEnabled")} + + + {isPaidUser + ? formatPostureValue( + client.posture + .linuxSELinuxEnabled + ) + : "-"} + + + )} + + + ) : ( +
+ {t("noData")} +
+ )} + +
); } diff --git a/src/app/[orgId]/settings/general/auth-page/page.tsx b/src/app/[orgId]/settings/general/auth-page/page.tsx index 73c54827..9ffa8e04 100644 --- a/src/app/[orgId]/settings/general/auth-page/page.tsx +++ b/src/app/[orgId]/settings/general/auth-page/page.tsx @@ -20,11 +20,6 @@ export interface AuthPageProps { export default async function AuthPage(props: AuthPageProps) { const orgId = (await props.params).orgId; - // custom auth branding is only available in enterprise and saas - if (build === "oss") { - redirect(`/${orgId}/settings/general/`); - } - let subscriptionStatus: GetOrgTierResponse | null = null; try { const subRes = await getCachedSubscription(orgId); diff --git a/src/app/[orgId]/settings/general/layout.tsx b/src/app/[orgId]/settings/general/layout.tsx index 53d03918..a3f7264f 100644 --- a/src/app/[orgId]/settings/general/layout.tsx +++ b/src/app/[orgId]/settings/general/layout.tsx @@ -55,14 +55,12 @@ export default async function GeneralSettingsPage({ { title: t("security"), href: `/{orgId}/settings/general/security` - } - ]; - if (build !== "oss") { - navItems.push({ + }, + { title: t("authPage"), href: `/{orgId}/settings/general/auth-page` - }); - } + } + ]; return ( <> diff --git a/src/app/[orgId]/settings/general/security/page.tsx b/src/app/[orgId]/settings/general/security/page.tsx index 716e35d6..47946415 100644 --- a/src/app/[orgId]/settings/general/security/page.tsx +++ b/src/app/[orgId]/settings/general/security/page.tsx @@ -3,12 +3,7 @@ import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog"; import { Button } from "@app/components/ui/button"; import { useOrgContext } from "@app/hooks/useOrgContext"; import { toast } from "@app/hooks/useToast"; -import { - useState, - useRef, - useActionState, - type ComponentRef -} from "react"; +import { useState, useRef, useActionState, type ComponentRef } from "react"; import { Form, FormControl, @@ -110,7 +105,7 @@ export default function SecurityPage() { return ( - {build !== "oss" && } + ); } @@ -243,144 +238,120 @@ function LogRetentionSectionForm({ org }: SectionFormProps) { )} /> - {build !== "oss" && ( - <> - + - { - const isDisabled = !isPaidUser; + { + const isDisabled = !isPaidUser; - return ( - - - {t( - "logRetentionAccessLabel" - )} - - - { + if (!isDisabled) { + field.onChange( + parseInt( + value, + 10 + ) + ); + } + }} + disabled={isDisabled} + > + + + + + {LOG_RETENTION_OPTIONS.map( + (option) => ( + + {t( + option.label )} - /> - - - {LOG_RETENTION_OPTIONS.map( - ( - option - ) => ( - - {t( - option.label - )} - - ) - )} - - - - - - ); - }} - /> - { - const isDisabled = !isPaidUser; - - return ( - - - {t( - "logRetentionActionLabel" + + ) )} - - - + + + + ); + }} + /> + { + const isDisabled = !isPaidUser; + + return ( + + + {t("logRetentionActionLabel")} + + + - - - - ); - }} - /> - - )} + + ) + )} + + + + + + ); + }} + /> @@ -740,7 +711,7 @@ function SecuritySettingsSectionForm({ org }: SectionFormProps) { type="submit" form="security-settings-section-form" loading={loadingSave} - disabled={loadingSave} + disabled={loadingSave || !isPaidUser} > {t("saveSettings")} diff --git a/src/app/[orgId]/settings/logs/access/page.tsx b/src/app/[orgId]/settings/logs/access/page.tsx index d5b12ddb..6da993ba 100644 --- a/src/app/[orgId]/settings/logs/access/page.tsx +++ b/src/app/[orgId]/settings/logs/access/page.tsx @@ -20,6 +20,7 @@ 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"; export default function GeneralPage() { const router = useRouter(); @@ -209,7 +210,8 @@ export default function GeneralPage() { console.log("Date range changed:", { startDate, endDate, page, size }); if ( (build == "saas" && !subscription?.subscribed) || - (build == "enterprise" && !isUnlocked()) + (build == "enterprise" && !isUnlocked()) || + build === "oss" ) { console.log( "Access denied: subscription inactive or license locked" @@ -611,21 +613,7 @@ export default function GeneralPage() { description={t("accessLogsDescription")} /> - {build == "saas" && !subscription?.subscribed ? ( - - - {t("subscriptionRequiredToUse")} - - - ) : null} - - {build == "enterprise" && !isUnlocked() ? ( - - - {t("licenseRequiredToUse")} - - - ) : null} + diff --git a/src/app/[orgId]/settings/logs/action/page.tsx b/src/app/[orgId]/settings/logs/action/page.tsx index 344866bb..040a1920 100644 --- a/src/app/[orgId]/settings/logs/action/page.tsx +++ b/src/app/[orgId]/settings/logs/action/page.tsx @@ -2,6 +2,7 @@ import { ColumnFilter } from "@app/components/ColumnFilter"; 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"; @@ -92,6 +93,9 @@ export default function GeneralPage() { // Trigger search with default values on component mount useEffect(() => { + if (build === "oss") { + return; + } const defaultRange = getDefaultDateRange(); queryDateTime( defaultRange.startDate, @@ -461,21 +465,7 @@ export default function GeneralPage() { description={t("actionLogsDescription")} /> - {build == "saas" && !subscription?.subscribed ? ( - - - {t("subscriptionRequiredToUse")} - - - ) : null} - - {build == "enterprise" && !isUnlocked() ? ( - - - {t("licenseRequiredToUse")} - - - ) : null} + diff --git a/src/app/[orgId]/settings/logs/request/page.tsx b/src/app/[orgId]/settings/logs/request/page.tsx index 741dd994..4a1fe3cd 100644 --- a/src/app/[orgId]/settings/logs/request/page.tsx +++ b/src/app/[orgId]/settings/logs/request/page.tsx @@ -16,6 +16,7 @@ import Link from "next/link"; import { useParams, useRouter, useSearchParams } from "next/navigation"; import { useEffect, useState, useTransition } from "react"; import { useStoredPageSize } from "@app/hooks/useStoredPageSize"; +import { build } from "@server/build"; export default function GeneralPage() { const router = useRouter(); @@ -110,6 +111,9 @@ export default function GeneralPage() { // Trigger search with default values on component mount useEffect(() => { + if (build === "oss") { + return; + } const defaultRange = getDefaultDateRange(); queryDateTime( defaultRange.startDate, diff --git a/src/app/[orgId]/settings/resources/proxy/[niceId]/general/page.tsx b/src/app/[orgId]/settings/resources/proxy/[niceId]/general/page.tsx index d8f050d3..b4dc3ad8 100644 --- a/src/app/[orgId]/settings/resources/proxy/[niceId]/general/page.tsx +++ b/src/app/[orgId]/settings/resources/proxy/[niceId]/general/page.tsx @@ -63,6 +63,7 @@ import { 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"; type MaintenanceSectionFormProps = { resource: GetResourceResponse; @@ -78,6 +79,7 @@ function MaintenanceSectionForm({ const api = createApiClient({ env }); const { isUnlocked } = useLicenseStatusContext(); const subscription = useSubscriptionStatusContext(); + const { isPaidUser } = usePaidStatus(); const MaintenanceFormSchema = z.object({ maintenanceModeEnabled: z.boolean().optional(), @@ -161,7 +163,7 @@ function MaintenanceSectionForm({ const isEnterpriseNotLicensed = build === "enterprise" && !isUnlocked(); const isSaasNotSubscribed = build === "saas" && !subscription?.isSubscribed(); - return isEnterpriseNotLicensed || isSaasNotSubscribed; + return isEnterpriseNotLicensed || isSaasNotSubscribed || build === "oss"; }; if (!resource.http) { @@ -413,7 +415,7 @@ function MaintenanceSectionForm({ - - - )} + + + + )} - {build === "enterprise" && ( - - - - )} + + + )} diff --git a/src/app/navigation.tsx b/src/app/navigation.tsx index 5d1285da..854f6e19 100644 --- a/src/app/navigation.tsx +++ b/src/app/navigation.tsx @@ -121,24 +121,16 @@ export const orgNavSections = (env?: Env): SidebarNavSection[] => [ href: "/{orgId}/settings/access/roles", icon: }, - ...(build === "saas" || env?.flags.useOrgOnlyIdp - ? [ - { - title: "sidebarIdentityProviders", - href: "/{orgId}/settings/idp", - icon: - } - ] - : []), - ...(build !== "oss" - ? [ - { - title: "sidebarApprovals", - href: "/{orgId}/settings/access/approvals", - icon: - } - ] - : []), + { + title: "sidebarIdentityProviders", + href: "/{orgId}/settings/idp", + icon: + }, + { + title: "sidebarApprovals", + href: "/{orgId}/settings/access/approvals", + icon: + }, { title: "sidebarShareableLinks", href: "/{orgId}/settings/share-links", @@ -155,20 +147,16 @@ export const orgNavSections = (env?: Env): SidebarNavSection[] => [ href: "/{orgId}/settings/logs/request", icon: }, - ...(build != "oss" - ? [ - { - title: "sidebarLogsAccess", - href: "/{orgId}/settings/logs/access", - icon: - }, - { - title: "sidebarLogsAction", - href: "/{orgId}/settings/logs/action", - icon: - } - ] - : []) + { + title: "sidebarLogsAccess", + href: "/{orgId}/settings/logs/access", + icon: + }, + { + title: "sidebarLogsAction", + href: "/{orgId}/settings/logs/action", + icon: + } ]; const analytics = { diff --git a/src/components/ApprovalFeed.tsx b/src/components/ApprovalFeed.tsx index 4c6122c6..e587354b 100644 --- a/src/components/ApprovalFeed.tsx +++ b/src/components/ApprovalFeed.tsx @@ -30,6 +30,7 @@ import { import { Separator } from "./ui/separator"; import { InfoPopup } from "./ui/info-popup"; import { ApprovalsEmptyState } from "./ApprovalsEmptyState"; +import { usePaidStatus } from "@app/hooks/usePaidStatus"; export type ApprovalFeedProps = { orgId: string; @@ -50,9 +51,12 @@ export function ApprovalFeed({ Object.fromEntries(searchParams.entries()) ); - const { data, isFetching, refetch } = useQuery( - approvalQueries.listApprovals(orgId, filters) - ); + const { isPaidUser } = usePaidStatus(); + + const { data, isFetching, refetch } = useQuery({ + ...approvalQueries.listApprovals(orgId, filters), + enabled: isPaidUser + }); const approvals = data?.approvals ?? []; @@ -209,19 +213,19 @@ function ApprovalRequest({ approval, orgId, onSuccess }: ApprovalRequestProps) {   {approval.type === "user_device" && ( - {approval.deviceName ? ( - <> - {t("requestingNewDeviceApproval")}:{" "} - {approval.niceId ? ( - - {approval.deviceName} - - ) : ( - {approval.deviceName} - )} + {approval.deviceName ? ( + <> + {t("requestingNewDeviceApproval")}:{" "} + {approval.niceId ? ( + + {approval.deviceName} + + ) : ( + {approval.deviceName} + )} {approval.fingerprint && (
@@ -229,7 +233,10 @@ function ApprovalRequest({ approval, orgId, onSuccess }: ApprovalRequestProps) { {t("deviceInformation")}
- {formatFingerprintInfo(approval.fingerprint, t)} + {formatFingerprintInfo( + approval.fingerprint, + t + )}
diff --git a/src/components/CreateRoleForm.tsx b/src/components/CreateRoleForm.tsx index ba9863b5..3ea56c53 100644 --- a/src/components/CreateRoleForm.tsx +++ b/src/components/CreateRoleForm.tsx @@ -160,56 +160,51 @@ export default function CreateRoleForm({ )} /> - {build !== "oss" && ( -
- - ( - - - + + ( + + + { + if ( + checked !== + "indeterminate" + ) { + form.setValue( + "requireDeviceApproval", checked - ) => { - if ( - checked !== - "indeterminate" - ) { - form.setValue( - "requireDeviceApproval", - checked - ); - } - }} - label={t( - "requireDeviceApproval" - )} - /> - + ); + } + }} + label={t( + "requireDeviceApproval" + )} + /> + - - {t( - "requireDeviceApprovalDescription" - )} - + + {t( + "requireDeviceApprovalDescription" + )} + - - - )} - /> -
- )} + + + )} + /> diff --git a/src/components/EditRoleForm.tsx b/src/components/EditRoleForm.tsx index 4e36fb27..81b5bef5 100644 --- a/src/components/EditRoleForm.tsx +++ b/src/components/EditRoleForm.tsx @@ -168,56 +168,50 @@ export default function EditRoleForm({ )} /> - {build !== "oss" && ( -
- + - ( - - - ( + + + { + if ( + checked !== + "indeterminate" + ) { + form.setValue( + "requireDeviceApproval", checked - ) => { - if ( - checked !== - "indeterminate" - ) { - form.setValue( - "requireDeviceApproval", - checked - ); - } - }} - label={t( - "requireDeviceApproval" - )} - /> - + ); + } + }} + label={t( + "requireDeviceApproval" + )} + /> + - - {t( - "requireDeviceApprovalDescription" - )} - + + {t( + "requireDeviceApprovalDescription" + )} + - - - )} - /> -
- )} + + + )} + /> diff --git a/src/components/PaidFeaturesAlert.tsx b/src/components/PaidFeaturesAlert.tsx index 30ba7d76..4fd1d0de 100644 --- a/src/components/PaidFeaturesAlert.tsx +++ b/src/components/PaidFeaturesAlert.tsx @@ -1,8 +1,16 @@ "use client"; -import { Alert, AlertDescription } from "@app/components/ui/alert"; +import { Card, CardContent } from "@app/components/ui/card"; import { build } from "@server/build"; -import { useTranslations } from "next-intl"; import { usePaidStatus } from "@app/hooks/usePaidStatus"; +import { ExternalLink, KeyRound, Sparkles } from "lucide-react"; +import { useTranslations } from "next-intl"; +import Link from "next/link"; + +const bannerClassName = + "mb-6 border-primary/30 bg-linear-to-br from-primary/10 via-background to-background overflow-hidden"; +const bannerContentClassName = "py-3 px-4"; +const bannerRowClassName = + "flex items-center gap-2.5 text-sm text-muted-foreground"; export function PaidFeaturesAlert() { const t = useTranslations(); @@ -10,19 +18,50 @@ export function PaidFeaturesAlert() { return ( <> {build === "saas" && !hasSaasSubscription ? ( - - - {t("subscriptionRequiredToUse")} - - + + +
+ + {t("subscriptionRequiredToUse")} +
+
+
) : null} {build === "enterprise" && !hasEnterpriseLicense ? ( - - - {t("licenseRequiredToUse")} - - + + +
+ + {t("licenseRequiredToUse")} +
+
+
+ ) : null} + + {build === "oss" && !hasEnterpriseLicense ? ( + + +
+ + + {t.rich("ossEnterpriseEditionRequired", { + enterpriseEditionLink: (chunks) => ( + + {chunks} + + + ) + })} + +
+
+
) : null} ); diff --git a/src/components/UserDevicesTable.tsx b/src/components/UserDevicesTable.tsx index 9d1469f1..0a1cf287 100644 --- a/src/components/UserDevicesTable.tsx +++ b/src/components/UserDevicesTable.tsx @@ -190,7 +190,7 @@ export default function UserDevicesTable({ userClients }: ClientTableProps) { const approvalsRes = await api.get<{ data: { approvals: Array<{ approvalId: number; clientId: number }> }; }>(`/org/${clientRow.orgId}/approvals?approvalState=pending&clientId=${clientRow.id}`); - + const approval = approvalsRes.data.data.approvals[0]; if (!approval) { @@ -232,7 +232,7 @@ export default function UserDevicesTable({ userClients }: ClientTableProps) { const approvalsRes = await api.get<{ data: { approvals: Array<{ approvalId: number; clientId: number }> }; }>(`/org/${clientRow.orgId}/approvals?approvalState=pending&clientId=${clientRow.id}`); - + const approval = approvalsRes.data.data.approvals[0]; if (!approval) { @@ -548,7 +548,7 @@ export default function UserDevicesTable({ userClients }: ClientTableProps) { - {clientRow.approvalState === "pending" && build !== "oss" && ( + {clientRow.approvalState === "pending" && ( <> approveDevice(clientRow)} @@ -652,17 +652,10 @@ export default function UserDevicesTable({ userClients }: ClientTableProps) { } ]; - if (build === "oss") { - return allOptions.filter((option) => option.value !== "pending" && option.value !== "denied"); - } - return allOptions; }, [t]); const statusFilterDefaultValues = useMemo(() => { - if (build === "oss") { - return ["active"]; - } return ["active", "pending"]; }, []); diff --git a/src/middleware.ts b/src/middleware.ts index 727e2579..f3fbb930 100644 --- a/src/middleware.ts +++ b/src/middleware.ts @@ -2,29 +2,6 @@ import { NextRequest, NextResponse } from "next/server"; import { build } from "@server/build"; export function middleware(request: NextRequest) { - // If build is OSS, block access to private routes - if (build === "oss") { - const pathname = request.nextUrl.pathname; - - // Define private route patterns that should be blocked in OSS build - const privateRoutes = [ - "/settings/billing", - "/settings/remote-exit-nodes", - "/settings/idp", - "/auth/org" - ]; - - // Check if current path matches any private route pattern - const isPrivateRoute = privateRoutes.some((route) => - pathname.includes(route) - ); - - if (isPrivateRoute) { - // Return 404 to make it seem like the route doesn't exist - return new NextResponse(null, { status: 404 }); - } - } - return NextResponse.next(); } From 410ed3949bb152579f59e14d2934237b9dbc5dea Mon Sep 17 00:00:00 2001 From: miloschwartz Date: Sat, 7 Feb 2026 17:13:55 -0800 Subject: [PATCH 10/49] use pangolin cli in machine client commands --- messages/en-US.json | 10 +++++----- src/components/olm-install-commands.tsx | 4 ++-- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/messages/en-US.json b/messages/en-US.json index 07025a58..d767d2fe 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -55,7 +55,7 @@ "siteDescription": "Create and manage sites to enable connectivity to private networks", "sitesBannerTitle": "Connect Any Network", "sitesBannerDescription": "A site is a connection to a remote network that allows Pangolin to provide access to resources, whether public or private, to users anywhere. Install the site network connector (Newt) anywhere you can run a binary or container to establish the connection.", - "sitesBannerButtonText": "Install Site", + "sitesBannerButtonText": "Install Site Connector", "approvalsBannerTitle": "Approve or Deny Device Access", "approvalsBannerDescription": "Review and approve or deny device access requests from users. When device approvals are required, users must get admin approval before their devices can connect to your organization's resources.", "approvalsBannerButtonText": "Learn More", @@ -79,8 +79,8 @@ "siteConfirmCopy": "I have copied the config", "searchSitesProgress": "Search sites...", "siteAdd": "Add Site", - "siteInstallNewt": "Install Newt", - "siteInstallNewtDescription": "Get Newt running on your system", + "siteInstallNewt": "Install Site", + "siteInstallNewtDescription": "Install the site connector for your system", "WgConfiguration": "WireGuard Configuration", "WgConfigurationDescription": "Use the following configuration to connect to the network", "operatingSystem": "Operating System", @@ -1545,8 +1545,8 @@ "addressDescription": "The internal address of the client. Must fall within the organization's subnet.", "selectSites": "Select sites", "sitesDescription": "The client will have connectivity to the selected sites", - "clientInstallOlm": "Install Olm", - "clientInstallOlmDescription": "Get Olm running on your system", + "clientInstallOlm": "Install Machine Client", + "clientInstallOlmDescription": "Install the machine client for your system", "clientOlmCredentials": "Credentials", "clientOlmCredentialsDescription": "This is how the client will authenticate with the server", "olmEndpoint": "Endpoint", diff --git a/src/components/olm-install-commands.tsx b/src/components/olm-install-commands.tsx index d4c27ae2..1728f528 100644 --- a/src/components/olm-install-commands.tsx +++ b/src/components/olm-install-commands.tsx @@ -43,11 +43,11 @@ export function OlmInstallCommands({ All: [ { title: t("install"), - command: `curl -fsSL https://static.pangolin.net/get-olm.sh | bash` + command: `curl -fsSL https://static.pangolin.net/get-cli.sh | bash` }, { title: t("run"), - command: `sudo olm --id ${id} --secret ${secret} --endpoint ${endpoint}` + command: `sudo pangolin up --id ${id} --secret ${secret} --endpoint ${endpoint} --attach` } ] }, From 1cca69ad2302cf127e9e3cd0a3a67236def68b23 Mon Sep 17 00:00:00 2001 From: Owen Date: Sun, 8 Feb 2026 11:08:23 -0800 Subject: [PATCH 11/49] Further billing --- messages/en-US.json | 14 +- server/lib/billing/limitSet.ts | 2 +- server/lib/billing/usageService.ts | 4 +- .../settings/(private)/billing/page.tsx | 309 +++++++++++++++--- 4 files changed, 269 insertions(+), 60 deletions(-) diff --git a/messages/en-US.json b/messages/en-US.json index 5616c666..88829759 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -1405,9 +1405,9 @@ "billingMonitorUsage": "Monitor your usage against configured limits. If you need limits increased please contact us support@pangolin.net.", "billingDataUsage": "Data Usage", "billingSites": "Sites", - "billingUsers": "Active Users", - "billingDomains": "Active Domains", - "billingRemoteExitNodes": "Active Self-hosted Nodes", + "billingUsers": "Users", + "billingDomains": "Domains", + "billingRemoteExitNodes": "Remote Nodes", "billingNoLimitConfigured": "No limit configured", "billingEstimatedPeriod": "Estimated Billing Period", "billingIncludedUsage": "Included Usage", @@ -1533,6 +1533,14 @@ "billingManageLicenseSubscription": "Manage your subscription for paid self-hosted license keys", "billingCurrentKeys": "Current Keys", "billingModifyCurrentPlan": "Modify Current Plan", + "billingConfirmUpgrade": "Confirm Upgrade", + "billingConfirmDowngrade": "Confirm Downgrade", + "billingConfirmUpgradeDescription": "You are about to upgrade your plan. Review the new limits and pricing below.", + "billingConfirmDowngradeDescription": "You are about to downgrade your plan. Review the new limits and pricing below.", + "billingPlanIncludes": "Plan Includes", + "billingProcessing": "Processing...", + "billingConfirmUpgradeButton": "Confirm Upgrade", + "billingConfirmDowngradeButton": "Confirm Downgrade", "signUpTerms": { "IAgreeToThe": "I agree to the", "termsOfService": "terms of service", diff --git a/server/lib/billing/limitSet.ts b/server/lib/billing/limitSet.ts index 0419262c..a95b607f 100644 --- a/server/lib/billing/limitSet.ts +++ b/server/lib/billing/limitSet.ts @@ -23,7 +23,7 @@ export const freeLimitSet: LimitSet = { description: "Free tier limit" }, // 25 GB [FeatureId.DOMAINS]: { value: 3, description: "Free tier limit" }, - [FeatureId.REMOTE_EXIT_NODES]: { value: 1, description: "Free tier limit" } + [FeatureId.REMOTE_EXIT_NODES]: { value: 0, description: "Free tier limit" } }; export const homeLabLimitSet: LimitSet = { diff --git a/server/lib/billing/usageService.ts b/server/lib/billing/usageService.ts index 0fde8eba..b043e05e 100644 --- a/server/lib/billing/usageService.ts +++ b/server/lib/billing/usageService.ts @@ -33,9 +33,7 @@ interface StripeEvent { export function noop() { if ( - build !== "saas" || - !process.env.S3_BUCKET || - !process.env.LOCAL_FILE_PATH + build !== "saas" ) { return true; } diff --git a/src/app/[orgId]/settings/(private)/billing/page.tsx b/src/app/[orgId]/settings/(private)/billing/page.tsx index 51424d3b..803e8199 100644 --- a/src/app/[orgId]/settings/(private)/billing/page.tsx +++ b/src/app/[orgId]/settings/(private)/billing/page.tsx @@ -23,6 +23,16 @@ import { InfoSections, InfoSectionTitle } from "@app/components/InfoSection"; +import { + Credenza, + CredenzaBody, + CredenzaClose, + CredenzaContent, + CredenzaDescription, + CredenzaFooter, + CredenzaHeader, + CredenzaTitle +} from "@app/components/Credenza"; import { cn } from "@app/lib/cn"; import { CreditCard, @@ -30,7 +40,8 @@ import { Users, Globe, Server, - Layout + Layout, + Check } from "lucide-react"; import { GetOrgSubscriptionResponse, @@ -40,7 +51,7 @@ import { useTranslations } from "use-intl"; import Link from "next/link"; // Plan tier definitions matching the mockup -type PlanId = "starter" | "homelab" | "team" | "business" | "enterprise"; +type PlanId = "free" | "homelab" | "team" | "business" | "enterprise"; interface PlanOption { id: PlanId; @@ -50,10 +61,39 @@ interface PlanOption { tierType: "home_lab" | "starter" | "scale" | null; // Maps to backend tier types } +// Tier limits for display in confirmation dialog +interface TierLimits { + sites: number; + users: number; + domains: number; + remoteNodes: number; +} + +const tierLimits: Record<"home_lab" | "starter" | "scale", TierLimits> = { + home_lab: { + sites: 3, + users: 3, + domains: 3, + remoteNodes: 1 + }, + starter: { + sites: 10, + users: 150, + domains: 250, + remoteNodes: 5 + }, + scale: { + sites: 10, + users: 150, + domains: 250, + remoteNodes: 5 + } +}; + const planOptions: PlanOption[] = [ { - id: "starter", - name: "Starter", + id: "free", + name: "Free", price: "Free", tierType: null }, @@ -96,10 +136,12 @@ export default function BillingPage() { const [allSubscriptions, setAllSubscriptions] = useState< GetOrgSubscriptionResponse["subscriptions"] >([]); - const [tierSubscription, setTierSubscription] = - useState(null); - const [licenseSubscription, setLicenseSubscription] = - useState(null); + const [tierSubscription, setTierSubscription] = useState< + GetOrgSubscriptionResponse["subscriptions"][0] | null + >(null); + const [licenseSubscription, setLicenseSubscription] = useState< + GetOrgSubscriptionResponse["subscriptions"][0] | null + >(null); const [subscriptionLoading, setSubscriptionLoading] = useState(true); // Usage and limits data @@ -122,6 +164,15 @@ export default function BillingPage() { const DOMAINS = "domains"; const REMOTE_EXIT_NODES = "remoteExitNodes"; + // Confirmation dialog state + const [showConfirmDialog, setShowConfirmDialog] = useState(false); + const [pendingTier, setPendingTier] = useState<{ + tier: "home_lab" | "starter" | "scale"; + action: "upgrade" | "downgrade"; + planName: string; + price: string; + } | null>(null); + useEffect(() => { async function fetchSubscription() { setSubscriptionLoading(true); @@ -133,10 +184,11 @@ export default function BillingPage() { setAllSubscriptions(subscriptions); // Find tier subscription - const tierSub = subscriptions.find(({ subscription }) => - subscription?.type === "home_lab" || - subscription?.type === "starter" || - subscription?.type === "scale" + const tierSub = subscriptions.find( + ({ subscription }) => + subscription?.type === "home_lab" || + subscription?.type === "starter" || + subscription?.type === "scale" ); setTierSubscription(tierSub || null); @@ -190,7 +242,9 @@ export default function BillingPage() { fetchUsage(); }, [org.org.orgId]); - const handleStartSubscription = async (tier: "home_lab" | "starter" | "scale") => { + const handleStartSubscription = async ( + tier: "home_lab" | "starter" | "scale" + ) => { setIsLoading(true); try { const response = await api.post>( @@ -270,15 +324,43 @@ export default function BillingPage() { } }; + const confirmTierChange = () => { + if (!pendingTier) return; + + if ( + pendingTier.action === "upgrade" || + pendingTier.action === "downgrade" + ) { + if (hasSubscription) { + handleChangeTier(pendingTier.tier); + } else { + handleStartSubscription(pendingTier.tier); + } + } + + setShowConfirmDialog(false); + setPendingTier(null); + }; + + const showTierConfirmation = ( + tier: "home_lab" | "starter" | "scale", + action: "upgrade" | "downgrade", + planName: string, + price: string + ) => { + setPendingTier({ tier, action, planName, price }); + setShowConfirmDialog(true); + }; + const handleContactUs = () => { window.open("mailto:sales@pangolin.net", "_blank"); }; // Get current plan ID from tier const getCurrentPlanId = (): PlanId => { - if (!hasSubscription || !currentTier) return "starter"; + if (!hasSubscription || !currentTier) return "free"; const plan = planOptions.find((p) => p.tierType === currentTier); - return plan?.id || "starter"; + return plan?.id || "free"; }; const currentPlanId = getCurrentPlanId(); @@ -295,8 +377,8 @@ export default function BillingPage() { } if (plan.id === currentPlanId) { - // If it's the free plan (starter with no subscription), show as current but disabled - if (plan.id === "starter" && !hasSubscription) { + // If it's the free plan (free with no subscription), show as current but disabled + if (plan.id === "free" && !hasSubscription) { return { label: "Current Plan", action: () => {}, @@ -320,10 +402,18 @@ export default function BillingPage() { if (planIndex < currentIndex) { return { label: "Downgrade", - action: () => - plan.tierType - ? handleChangeTier(plan.tierType) - : handleModifySubscription(), + action: () => { + if (plan.tierType) { + showTierConfirmation( + plan.tierType, + "downgrade", + plan.name, + plan.price + (plan.priceDetail || "") + ); + } else { + handleModifySubscription(); + } + }, variant: "outline" as const, disabled: false }; @@ -331,12 +421,18 @@ export default function BillingPage() { return { label: "Upgrade", - action: () => - plan.tierType - ? hasSubscription - ? handleChangeTier(plan.tierType) - : handleStartSubscription(plan.tierType) - : handleModifySubscription(), + action: () => { + if (plan.tierType) { + showTierConfirmation( + plan.tierType, + "upgrade", + plan.name, + plan.price + (plan.priceDetail || "") + ); + } else { + handleModifySubscription(); + } + }, variant: "outline" as const, disabled: false }; @@ -407,11 +503,11 @@ export default function BillingPage() { )} >
-
+
{plan.name}
- + {plan.price} {plan.priceDetail && ( @@ -431,7 +527,9 @@ export default function BillingPage() { size="sm" className="w-full" onClick={planAction.action} - disabled={isLoading || planAction.disabled} + disabled={ + isLoading || planAction.disabled + } > {planAction.label} @@ -469,7 +567,7 @@ export default function BillingPage() {
{/* Current Usage */} -
+
{t("billingCurrentUsage") || "Current Usage"}
@@ -480,27 +578,27 @@ export default function BillingPage() { {t("billingUsers") || "Users"} + {hasSubscription && getPricePerUser() > 0 && ( +
+ x ${getPricePerUser()} / month = $ + {getUserCount() * getPricePerUser()} / + month +
+ )}
- {hasSubscription && getPricePerUser() > 0 && ( -
- x ${getPricePerUser()} / month = $ - {getUserCount() * getPricePerUser()} / month -
- )}
{/* Maximum Limits */} -
+
{t("billingMaximumLimits") || "Maximum Limits"}
- - + {t("billingUsers") || "Users"} - + {getLimitValue(USERS) ?? t("billingUnlimited") ?? "∞"}{" "} @@ -509,11 +607,10 @@ export default function BillingPage() { - - + {t("billingSites") || "Sites"} - + {getLimitValue(SITES) ?? t("billingUnlimited") ?? "∞"}{" "} @@ -522,11 +619,10 @@ export default function BillingPage() { - - + {t("billingDomains") || "Domains"} - + {getLimitValue(DOMAINS) ?? t("billingUnlimited") ?? "∞"}{" "} @@ -535,12 +631,11 @@ export default function BillingPage() { - - + {t("billingRemoteNodes") || "Remote Nodes"} - + {getLimitValue(REMOTE_EXIT_NODES) ?? t("billingUnlimited") ?? "∞"}{" "} @@ -559,8 +654,7 @@ export default function BillingPage() { - {t("billingPaidLicenseKeys") || - "Paid License Keys"} + {t("billingPaidLicenseKeys") || "Paid License Keys"} {t("billingManageLicenseSubscription") || @@ -597,6 +691,115 @@ export default function BillingPage() { )} + + {/* Tier Change Confirmation Dialog */} + + + + + {pendingTier?.action === "upgrade" + ? t("billingConfirmUpgrade") || + "Confirm Upgrade" + : t("billingConfirmDowngrade") || + "Confirm Downgrade"} + + + {pendingTier?.action === "upgrade" + ? t("billingConfirmUpgradeDescription") || + `You are about to upgrade to the ${pendingTier?.planName} plan.` + : t("billingConfirmDowngradeDescription") || + `You are about to downgrade to the ${pendingTier?.planName} plan.`} + + + + {pendingTier && pendingTier.tier && ( +
+
+
+ {pendingTier.planName} +
+
+ {pendingTier.price} +
+
+ +
+

+ {t("billingPlanIncludes") || + "Plan Includes:"} +

+
+
+ + + { + tierLimits[pendingTier.tier] + .sites + }{" "} + {t("billingSites") || "Sites"} + +
+
+ + + { + tierLimits[pendingTier.tier] + .users + }{" "} + {t("billingUsers") || "Users"} + +
+
+ + + { + tierLimits[pendingTier.tier] + .domains + }{" "} + {t("billingDomains") || + "Domains"} + +
+
+ + + { + tierLimits[pendingTier.tier] + .remoteNodes + }{" "} + {t("billingRemoteNodes") || + "Remote Nodes"} + +
+
+
+
+ )} +
+ + + + + + +
+
); -} \ No newline at end of file +} From 41bab0ce0bb1b834f27583ba05db56a200dd5923 Mon Sep 17 00:00:00 2001 From: Owen Date: Sun, 8 Feb 2026 11:13:09 -0800 Subject: [PATCH 12/49] Dont log to stripe --- server/lib/billing/usageService.ts | 61 +++++++++++++++++----------- server/private/lib/readConfigFile.ts | 3 +- 2 files changed, 39 insertions(+), 25 deletions(-) diff --git a/server/lib/billing/usageService.ts b/server/lib/billing/usageService.ts index 6b3a67a7..2a50737b 100644 --- a/server/lib/billing/usageService.ts +++ b/server/lib/billing/usageService.ts @@ -18,6 +18,7 @@ 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; @@ -30,9 +31,7 @@ interface StripeEvent { } export function noop() { - if ( - build !== "saas" - ) { + if (build !== "saas") { return true; } return false; @@ -48,29 +47,39 @@ export class UsageService { if (noop()) { return; } + this.bucketName = process.env.S3_BUCKET || undefined; - // Periodically check and upload events - setInterval(() => { - this.checkAndUploadEvents().catch((err) => { - logger.error("Error in periodic event upload:", err); + 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 + + // 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"); }); - }, 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 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); + }); + } } /** @@ -129,7 +138,9 @@ export class UsageService { } // Log event for Stripe - await this.logStripeEvent(featureId, value, customerId); + if (privateConfig.getRawPrivateConfig().flags.usage_reporting) { + await this.logStripeEvent(featureId, value, customerId); + } return usage || null; } catch (error: any) { @@ -306,7 +317,9 @@ export class UsageService { } }); - 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 daily usage for ${orgId}/${featureId}:`, diff --git a/server/private/lib/readConfigFile.ts b/server/private/lib/readConfigFile.ts index 1051d36f..3a5597a0 100644 --- a/server/private/lib/readConfigFile.ts +++ b/server/private/lib/readConfigFile.ts @@ -95,7 +95,8 @@ export const privateConfigSchema = z.object({ .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) + use_org_only_idp: z.boolean().optional().default(false), + usage_reporting: z.boolean().optional().default(false) }) .optional() .prefault({}), From c41e8be3e8a2cdb3f5a070dcfa281a3304fd8c07 Mon Sep 17 00:00:00 2001 From: Owen Date: Sun, 8 Feb 2026 11:55:24 -0800 Subject: [PATCH 13/49] Dont accept invite if over the limits --- package.json | 3 +- .../remoteExitNode/createRemoteExitNode.ts | 2 +- server/routers/site/createSite.ts | 2 +- server/routers/user/acceptInvite.ts | 30 +++++++++++++++++++ src/components/InviteStatusCard.tsx | 28 ++++++++++++++++- 5 files changed, 61 insertions(+), 4 deletions(-) diff --git a/package.json b/package.json index 69da4cdf..f60d52e5 100644 --- a/package.json +++ b/package.json @@ -18,7 +18,8 @@ "db:sqlite:generate": "drizzle-kit generate --config=./drizzle.sqlite.config.ts", "db:pg:push": "npx tsx server/db/pg/migrate.ts", "db:sqlite:push": "npx tsx server/db/sqlite/migrate.ts", - "db:studio": "drizzle-kit studio --config=./drizzle.config.ts", + "db:pg:studio": "drizzle-kit studio --config=./drizzle.pg.config.ts", + "db:sqlite:studio": "drizzle-kit studio --config=./drizzle.sqlite.config.ts", "db:clear-migrations": "rm -rf server/migrations", "set:oss": "echo 'export const build = \"oss\" as \"saas\" | \"enterprise\" | \"oss\";' > server/build.ts && cp tsconfig.oss.json tsconfig.json", "set:saas": "echo 'export const build = \"saas\" as \"saas\" | \"enterprise\" | \"oss\";' > server/build.ts && cp tsconfig.saas.json tsconfig.json", diff --git a/server/private/routers/remoteExitNode/createRemoteExitNode.ts b/server/private/routers/remoteExitNode/createRemoteExitNode.ts index f734813e..ed7b82a6 100644 --- a/server/private/routers/remoteExitNode/createRemoteExitNode.ts +++ b/server/private/routers/remoteExitNode/createRemoteExitNode.ts @@ -97,7 +97,7 @@ export async function createRemoteExitNode( return next( createHttpError( HttpCode.FORBIDDEN, - "Remote exit node limit exceeded. Please upgrade your plan or contact us at support@pangolin.net" + "Remote node limit exceeded. Please upgrade your plan." ) ); } diff --git a/server/routers/site/createSite.ts b/server/routers/site/createSite.ts index ece97d1d..d9837f30 100644 --- a/server/routers/site/createSite.ts +++ b/server/routers/site/createSite.ts @@ -151,7 +151,7 @@ export async function createSite( return next( createHttpError( HttpCode.FORBIDDEN, - "Sites limit exceeded. Please upgrade your plan." + "Site limit exceeded. Please upgrade your plan." ) ); } diff --git a/server/routers/user/acceptInvite.ts b/server/routers/user/acceptInvite.ts index d64ccfb5..fd79d849 100644 --- a/server/routers/user/acceptInvite.ts +++ b/server/routers/user/acceptInvite.ts @@ -13,6 +13,7 @@ import { verifySession } from "@server/auth/sessions/verifySession"; import { usageService } from "@server/lib/billing/usageService"; import { FeatureId } from "@server/lib/billing"; import { calculateUserClientsForOrgs } from "@server/lib/calculateUserClientsForOrgs"; +import { build } from "@server/build"; const acceptInviteBodySchema = z.strictObject({ token: z.string(), @@ -92,6 +93,35 @@ export async function acceptInvite( ); } + if (build == "saas") { + const usage = await usageService.getUsage(existingInvite.orgId, FeatureId.USERS); + if (!usage) { + return next( + createHttpError( + HttpCode.NOT_FOUND, + "No usage data found for this organization" + ) + ); + } + const rejectUsers = await usageService.checkLimitSet( + existingInvite.orgId, + false, + FeatureId.USERS, + { + ...usage, + instantaneousValue: (usage.instantaneousValue || 0) + 1 + } // We need to add one to know if we are violating the limit + ); + if (rejectUsers) { + return next( + createHttpError( + HttpCode.FORBIDDEN, + "Can not accept because this org's user limit is exceeded. Please contact your administrator to upgrade their plan." + ) + ); + } + } + let roleId: number; let totalUsers: UserOrg[] | undefined; // get the role to make sure it exists diff --git a/src/components/InviteStatusCard.tsx b/src/components/InviteStatusCard.tsx index d394bd57..417fa989 100644 --- a/src/components/InviteStatusCard.tsx +++ b/src/components/InviteStatusCard.tsx @@ -39,7 +39,7 @@ export default function InviteStatusCard({ const [loading, setLoading] = useState(true); const [error, setError] = useState(""); const [type, setType] = useState< - "rejected" | "wrong_user" | "user_does_not_exist" | "not_logged_in" + "rejected" | "wrong_user" | "user_does_not_exist" | "not_logged_in" | "user_limit_exceeded" >("rejected"); useEffect(() => { @@ -75,6 +75,11 @@ export default function InviteStatusCard({ error.includes("You must be logged in to accept an invite") ) { return "not_logged_in"; + } else if ( + error.includes("user limit is exceeded") || + error.includes("Can not accept") + ) { + return "user_limit_exceeded"; } else { return "rejected"; } @@ -145,6 +150,17 @@ export default function InviteStatusCard({

{t("inviteCreateUser")}

); + } else if (type === "user_limit_exceeded") { + return ( +
+

+ Cannot Accept Invite +

+

+ This organization has reached its user limit. Please contact the organization administrator to upgrade their plan before accepting this invite. +

+
+ ); } } @@ -165,6 +181,16 @@ export default function InviteStatusCard({ ); } else if (type === "user_does_not_exist") { return ; + } else if (type === "user_limit_exceeded") { + return ( + + ); } } From 81ef2db7f8aa4e6c4ad401899e7975ca206a8800 Mon Sep 17 00:00:00 2001 From: Owen Date: Sun, 8 Feb 2026 17:55:26 -0800 Subject: [PATCH 14/49] Rename tiers and get working --- docker-compose.pgr.yml | 4 +- drizzle.config.ts | 14 ++ server/db/pg/schema/privateSchema.ts | 4 +- server/db/sqlite/schema/privateSchema.ts | 3 +- server/lib/billing/features.ts | 46 +++-- server/lib/billing/limitSet.ts | 4 +- server/lib/billing/usageService.ts | 72 ++------ server/lib/createUserAccountOrg.ts | 2 +- server/private/lib/billing/getOrgTierData.ts | 10 +- server/private/lib/isLicencedOrSubscribed.ts | 2 +- server/private/lib/isSubscribed.ts | 2 +- .../private/middlewares/verifySubscription.ts | 2 +- server/private/routers/billing/changeTier.ts | 22 +-- .../routers/billing/createCheckoutSession.ts | 8 +- server/private/routers/billing/getOrgUsage.ts | 14 +- .../routers/billing/hooks/getSubType.ts | 22 +-- .../hooks/handleSubscriptionCreated.ts | 3 +- .../hooks/handleSubscriptionDeleted.ts | 2 +- .../hooks/handleSubscriptionUpdated.ts | 3 +- .../routers/billing/subscriptionLifecycle.ts | 18 +- .../remoteExitNode/createRemoteExitNode.ts | 2 +- .../remoteExitNode/deleteRemoteExitNode.ts | 2 +- server/routers/domain/createOrgDomain.ts | 2 +- server/routers/domain/deleteOrgDomain.ts | 2 +- server/routers/idp/validateOidcCallback.ts | 2 +- .../routers/newt/handleNewtRegisterMessage.ts | 2 +- server/routers/org/createOrg.ts | 2 +- server/routers/site/createSite.ts | 2 +- server/routers/site/deleteSite.ts | 2 +- server/routers/user/acceptInvite.ts | 8 +- server/routers/user/createOrgUser.ts | 2 +- server/routers/user/removeUserOrg.ts | 2 +- server/setup/migrations.ts | 162 ++++++++++++++++++ .../settings/(private)/billing/page.tsx | 42 ++--- src/lib/api/isOrgSubscribed.ts | 2 +- src/providers/SubscriptionStatusProvider.tsx | 8 +- 36 files changed, 326 insertions(+), 175 deletions(-) create mode 100644 drizzle.config.ts create mode 100644 server/setup/migrations.ts diff --git a/docker-compose.pgr.yml b/docker-compose.pgr.yml index 764c0915..9e6b2c5a 100644 --- a/docker-compose.pgr.yml +++ b/docker-compose.pgr.yml @@ -7,8 +7,8 @@ services: POSTGRES_DB: postgres # Default database name POSTGRES_USER: postgres # Default user POSTGRES_PASSWORD: password # Default password (change for production!) - volumes: - - ./config/postgres:/var/lib/postgresql/data + # volumes: + # - ./config/postgres:/var/lib/postgresql/data ports: - "5432:5432" # Map host port 5432 to container port 5432 restart: no diff --git a/drizzle.config.ts b/drizzle.config.ts new file mode 100644 index 00000000..ba4ca8fe --- /dev/null +++ b/drizzle.config.ts @@ -0,0 +1,14 @@ +import { defineConfig } from "drizzle-kit"; +import path from "path"; + +const schema = [path.join("server", "db", "pg", "schema")]; + +export default defineConfig({ + dialect: "postgresql", + schema: schema, + out: path.join("server", "migrations"), + verbose: true, + dbCredentials: { + url: process.env.DATABASE_URL as string + } +}); diff --git a/server/db/pg/schema/privateSchema.ts b/server/db/pg/schema/privateSchema.ts index 2ebb145b..de5bb1ca 100644 --- a/server/db/pg/schema/privateSchema.ts +++ b/server/db/pg/schema/privateSchema.ts @@ -82,12 +82,14 @@ export const subscriptions = pgTable("subscriptions", { canceledAt: bigint("canceledAt", { mode: "number" }), createdAt: bigint("createdAt", { mode: "number" }).notNull(), updatedAt: bigint("updatedAt", { mode: "number" }), + version: integer("version"), billingCycleAnchor: bigint("billingCycleAnchor", { mode: "number" }), - type: varchar("type", { length: 50 }) // home_lab, starter, scale, or license + type: varchar("type", { length: 50 }) // tier1, tier2, tier3, or license }); export const subscriptionItems = pgTable("subscriptionItems", { subscriptionItemId: serial("subscriptionItemId").primaryKey(), + stripeSubscriptionItemId: varchar("stripeSubscriptionItemId", { length: 255 }), subscriptionId: varchar("subscriptionId", { length: 255 }) .notNull() .references(() => subscriptions.subscriptionId, { diff --git a/server/db/sqlite/schema/privateSchema.ts b/server/db/sqlite/schema/privateSchema.ts index 27979460..1fa8654b 100644 --- a/server/db/sqlite/schema/privateSchema.ts +++ b/server/db/sqlite/schema/privateSchema.ts @@ -70,8 +70,9 @@ export const subscriptions = sqliteTable("subscriptions", { canceledAt: integer("canceledAt"), createdAt: integer("createdAt").notNull(), updatedAt: integer("updatedAt"), + version: integer("version"), billingCycleAnchor: integer("billingCycleAnchor"), - type: text("type") // home_lab, starter, scale, or license + type: text("type") // tier1, tier2, tier3, or license }); export const subscriptionItems = sqliteTable("subscriptionItems", { diff --git a/server/lib/billing/features.ts b/server/lib/billing/features.ts index b2eb2f0a..a9b652a9 100644 --- a/server/lib/billing/features.ts +++ b/server/lib/billing/features.ts @@ -7,9 +7,29 @@ export enum FeatureId { EGRESS_DATA_MB = "egressDataMb", DOMAINS = "domains", REMOTE_EXIT_NODES = "remoteExitNodes", - HOME_LAB = "home_lab" + TIER1 = "tier1" } +export async function getFeatureDisplayName(featureId: FeatureId): Promise { + switch (featureId) { + case FeatureId.USERS: + return "Users"; + case FeatureId.SITES: + return "Sites"; + case FeatureId.EGRESS_DATA_MB: + return "Egress Data (MB)"; + case FeatureId.DOMAINS: + return "Domains"; + case FeatureId.REMOTE_EXIT_NODES: + return "Remote Exit Nodes"; + case FeatureId.TIER1: + return "Home Lab"; + default: + return featureId; + } +} + +// this is from the old system export const FeatureMeterIds: Partial> = { // right now we are not charging for any data // [FeatureId.EGRESS_DATA_MB]: "mtr_61Srreh9eWrExDSCe41D3Ee2Ir7Wm5YW" }; @@ -40,11 +60,11 @@ export function getFeatureIdByMetricId( export type FeaturePriceSet = Partial>; export const homeLabFeaturePriceSet: FeaturePriceSet = { - [FeatureId.HOME_LAB]: "price_1SxgpPDCpkOb237Bfo4rIsoT" + [FeatureId.TIER1]: "price_1SxgpPDCpkOb237Bfo4rIsoT" }; export const homeLabFeaturePriceSetSandbox: FeaturePriceSet = { - [FeatureId.HOME_LAB]: "price_1SxgpPDCpkOb237Bfo4rIsoT" + [FeatureId.TIER1]: "price_1SxgpPDCpkOb237Bfo4rIsoT" }; export function getHomeLabFeaturePriceSet(): FeaturePriceSet { @@ -58,11 +78,11 @@ export function getHomeLabFeaturePriceSet(): FeaturePriceSet { } } -export const starterFeaturePriceSet: FeaturePriceSet = { +export const tier2FeaturePriceSet: FeaturePriceSet = { [FeatureId.USERS]: "price_1SxaEHDCpkOb237BD9lBkPiR" }; -export const starterFeaturePriceSetSandbox: FeaturePriceSet = { +export const tier2FeaturePriceSetSandbox: FeaturePriceSet = { [FeatureId.USERS]: "price_1SxaEHDCpkOb237BD9lBkPiR" }; @@ -71,17 +91,17 @@ export function getStarterFeaturePriceSet(): FeaturePriceSet { process.env.ENVIRONMENT == "prod" && process.env.SANDBOX_MODE !== "true" ) { - return starterFeaturePriceSet; + return tier2FeaturePriceSet; } else { - return starterFeaturePriceSetSandbox; + return tier2FeaturePriceSetSandbox; } } -export const scaleFeaturePriceSet: FeaturePriceSet = { +export const tier3FeaturePriceSet: FeaturePriceSet = { [FeatureId.USERS]: "price_1SxaEODCpkOb237BiXdCBSfs" }; -export const scaleFeaturePriceSetSandbox: FeaturePriceSet = { +export const tier3FeaturePriceSetSandbox: FeaturePriceSet = { [FeatureId.USERS]: "price_1SxaEODCpkOb237BiXdCBSfs" }; @@ -90,9 +110,9 @@ export function getScaleFeaturePriceSet(): FeaturePriceSet { process.env.ENVIRONMENT == "prod" && process.env.SANDBOX_MODE !== "true" ) { - return scaleFeaturePriceSet; + return tier3FeaturePriceSet; } else { - return scaleFeaturePriceSetSandbox; + return tier3FeaturePriceSetSandbox; } } @@ -100,14 +120,14 @@ export async function getLineItems( featurePriceSet: FeaturePriceSet, orgId: string, ): Promise { - const users = await usageService.getUsageDaily(orgId, FeatureId.USERS); + 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.HOME_LAB) { + } else if (featureId === FeatureId.TIER1) { quantity = 1; } diff --git a/server/lib/billing/limitSet.ts b/server/lib/billing/limitSet.ts index a95b607f..47dbe1b8 100644 --- a/server/lib/billing/limitSet.ts +++ b/server/lib/billing/limitSet.ts @@ -37,7 +37,7 @@ export const homeLabLimitSet: LimitSet = { [FeatureId.REMOTE_EXIT_NODES]: { value: 1, description: "Home lab limit" } }; -export const starterLimitSet: LimitSet = { +export const tier2LimitSet: LimitSet = { [FeatureId.SITES]: { value: 10, description: "Starter limit" @@ -60,7 +60,7 @@ export const starterLimitSet: LimitSet = { } }; -export const scaleLimitSet: LimitSet = { +export const tier3LimitSet: LimitSet = { [FeatureId.SITES]: { value: 10, description: "Scale limit" diff --git a/server/lib/billing/usageService.ts b/server/lib/billing/usageService.ts index 2a50737b..50519a68 100644 --- a/server/lib/billing/usageService.ts +++ b/server/lib/billing/usageService.ts @@ -50,7 +50,8 @@ export class UsageService { this.bucketName = process.env.S3_BUCKET || undefined; - if ( // Only set up event uploading if usage reporting is enabled and bucket name is configured + if ( + // Only set up event uploading if usage reporting is enabled and bucket name is configured privateConfig.getRawPrivateConfig().flags.usage_reporting && this.bucketName ) { @@ -220,7 +221,7 @@ export class UsageService { return new Date(date * 1000).toISOString().split("T")[0]; } - async updateDaily( + async updateCount( orgId: string, featureId: FeatureId, value?: number, @@ -246,8 +247,6 @@ export class UsageService { value = this.truncateValue(value); } - const today = this.getTodayDateString(); - let currentUsage: Usage | null = null; await db.transaction(async (trx) => { @@ -261,57 +260,23 @@ export class UsageService { .limit(1); if (currentUsage) { - const lastUpdateDate = this.getDateString( - currentUsage.updatedAt - ); - const currentRunningTotal = currentUsage.latestValue; - const lastDailyValue = currentUsage.instantaneousValue || 0; - - if (value == undefined || value === null) { - value = currentUsage.instantaneousValue || 0; - } - - if (lastUpdateDate === today) { - // Same day update: replace the daily value - // Remove old daily value from running total, add new value - const newRunningTotal = this.truncateValue( - currentRunningTotal - lastDailyValue + value - ); - - await trx - .update(usage) - .set({ - latestValue: newRunningTotal, - instantaneousValue: value, - updatedAt: Math.floor(Date.now() / 1000) - }) - .where(eq(usage.usageId, usageId)); - } else { - // New day: add to running total - const newRunningTotal = this.truncateValue( - currentRunningTotal + value - ); - - await trx - .update(usage) - .set({ - latestValue: newRunningTotal, - instantaneousValue: value, - updatedAt: Math.floor(Date.now() / 1000) - }) - .where(eq(usage.usageId, usageId)); - } + await trx + .update(usage) + .set({ + instantaneousValue: value, + updatedAt: Math.floor(Date.now() / 1000) + }) + .where(eq(usage.usageId, usageId)); } else { // First record for this meter const meterId = getFeatureMeterId(featureId); - const truncatedValue = this.truncateValue(value || 0); await trx.insert(usage).values({ usageId, featureId, orgId, meterId, - instantaneousValue: truncatedValue, - latestValue: truncatedValue, + instantaneousValue: value || 0, + latestValue: value || 0, updatedAt: Math.floor(Date.now() / 1000) }); } @@ -322,7 +287,7 @@ export class UsageService { } } catch (error) { logger.error( - `Failed to update daily usage for ${orgId}/${featureId}:`, + `Failed to update count usage for ${orgId}/${featureId}:`, error ); } @@ -542,17 +507,6 @@ export class UsageService { } } - public async getUsageDaily( - orgId: string, - featureId: FeatureId - ): Promise { - if (noop()) { - return null; - } - await this.updateDaily(orgId, featureId); // Ensure daily usage is updated - return this.getUsage(orgId, featureId); - } - public async forceUpload(): Promise { if (this.events.length > 0) { // Force upload regardless of time diff --git a/server/lib/createUserAccountOrg.ts b/server/lib/createUserAccountOrg.ts index 11f4e247..53f2ea3d 100644 --- a/server/lib/createUserAccountOrg.ts +++ b/server/lib/createUserAccountOrg.ts @@ -182,7 +182,7 @@ export async function createUserAccountOrg( const customerId = await createCustomer(orgId, userEmail); if (customerId) { - await usageService.updateDaily(orgId, FeatureId.USERS, 1, customerId); // Only 1 because we are crating the org + await usageService.updateCount(orgId, FeatureId.USERS, 1, customerId); // Only 1 because we are crating the org } return { diff --git a/server/private/lib/billing/getOrgTierData.ts b/server/private/lib/billing/getOrgTierData.ts index 174148f6..89734fcd 100644 --- a/server/private/lib/billing/getOrgTierData.ts +++ b/server/private/lib/billing/getOrgTierData.ts @@ -17,8 +17,8 @@ import { eq, and, ne } from "drizzle-orm"; export async function getOrgTierData( orgId: string -): Promise<{ tier: "home_lab" | "starter" | "scale" | null; active: boolean }> { - let tier: "home_lab" | "starter" | "scale" | null = null; +): Promise<{ tier: "tier1" | "tier2" | "tier3" | null; active: boolean }> { + let tier: "tier1" | "tier2" | "tier3" | null = null; let active = false; if (build !== "saas") { @@ -50,9 +50,9 @@ export async function getOrgTierData( if (subscription) { // Validate that subscription.type is one of the expected tier values if ( - subscription.type === "home_lab" || - subscription.type === "starter" || - subscription.type === "scale" + subscription.type === "tier1" || + subscription.type === "tier2" || + subscription.type === "tier3" ) { tier = subscription.type; active = true; diff --git a/server/private/lib/isLicencedOrSubscribed.ts b/server/private/lib/isLicencedOrSubscribed.ts index 2e8c04fa..3b2f6592 100644 --- a/server/private/lib/isLicencedOrSubscribed.ts +++ b/server/private/lib/isLicencedOrSubscribed.ts @@ -22,7 +22,7 @@ export async function isLicensedOrSubscribed(orgId: string): Promise { if (build === "saas") { const { tier, active } = await getOrgTierData(orgId); - return (tier == "home_lab" || tier == "starter" || tier == "scale") && active; + return (tier == "tier1" || tier == "tier2" || tier == "tier3") && active; } return false; diff --git a/server/private/lib/isSubscribed.ts b/server/private/lib/isSubscribed.ts index 9ff71bca..12bbb965 100644 --- a/server/private/lib/isSubscribed.ts +++ b/server/private/lib/isSubscribed.ts @@ -17,7 +17,7 @@ import { getOrgTierData } from "#private/lib/billing"; export async function isSubscribed(orgId: string): Promise { if (build === "saas") { const { tier, active } = await getOrgTierData(orgId); - return (tier == "home_lab" || tier == "starter" || tier == "scale") && active; + return (tier == "tier1" || tier == "tier2" || tier == "tier3") && active; } return false; diff --git a/server/private/middlewares/verifySubscription.ts b/server/private/middlewares/verifySubscription.ts index f1b7a0ce..fec9241a 100644 --- a/server/private/middlewares/verifySubscription.ts +++ b/server/private/middlewares/verifySubscription.ts @@ -39,7 +39,7 @@ export async function verifyValidSubscription( } const { tier, active } = await getOrgTierData(orgId); - if ((tier == "home_lab" || tier == "starter" || tier == "scale") && active) { + if ((tier == "tier1" || tier == "tier2" || tier == "tier3") && active) { return next( createHttpError( HttpCode.FORBIDDEN, diff --git a/server/private/routers/billing/changeTier.ts b/server/private/routers/billing/changeTier.ts index 0d966346..a33a9164 100644 --- a/server/private/routers/billing/changeTier.ts +++ b/server/private/routers/billing/changeTier.ts @@ -35,7 +35,7 @@ const changeTierSchema = z.strictObject({ }); const changeTierBodySchema = z.strictObject({ - tier: z.enum(["home_lab", "starter", "scale"]) + tier: z.enum(["tier1", "tier2", "tier3"]) }); export async function changeTier( @@ -93,9 +93,9 @@ export async function changeTier( eq(subscriptions.customerId, customer.customerId), eq(subscriptions.status, "active"), or( - eq(subscriptions.type, "home_lab"), - eq(subscriptions.type, "starter"), - eq(subscriptions.type, "scale") + eq(subscriptions.type, "tier1"), + eq(subscriptions.type, "tier2"), + eq(subscriptions.type, "tier3") ) ) ) @@ -112,11 +112,11 @@ export async function changeTier( // Get the target tier's price set let targetPriceSet: FeaturePriceSet; - if (tier === "home_lab") { + if (tier === "tier1") { targetPriceSet = getHomeLabFeaturePriceSet(); - } else if (tier === "starter") { + } else if (tier === "tier2") { targetPriceSet = getStarterFeaturePriceSet(); - } else if (tier === "scale") { + } else if (tier === "tier3") { targetPriceSet = getScaleFeaturePriceSet(); } else { return next(createHttpError(HttpCode.BAD_REQUEST, "Invalid tier")); @@ -148,11 +148,11 @@ export async function changeTier( ); // Determine if we're switching between different products - // home_lab uses HOME_LAB product, starter/scale use USERS product + // tier1 uses TIER1 product, tier2/tier3 use USERS product const currentTier = subscription.type; const switchingProducts = - (currentTier === "home_lab" && (tier === "starter" || tier === "scale")) || - ((currentTier === "starter" || currentTier === "scale") && tier === "home_lab"); + (currentTier === "tier1" && (tier === "tier2" || tier === "tier3")) || + ((currentTier === "tier2" || currentTier === "tier3") && tier === "tier1"); let updatedSubscription; @@ -189,7 +189,7 @@ export async function changeTier( } ); } else { - // Same product, different price tier (starter <-> scale) + // Same product, different price tier (tier2 <-> tier3) // We can simply update the price logger.info( `Updating price from ${currentTier} to ${tier} for subscription ${subscription.subscriptionId}` diff --git a/server/private/routers/billing/createCheckoutSession.ts b/server/private/routers/billing/createCheckoutSession.ts index 1a1c5c41..67eaa37e 100644 --- a/server/private/routers/billing/createCheckoutSession.ts +++ b/server/private/routers/billing/createCheckoutSession.ts @@ -31,7 +31,7 @@ const createCheckoutSessionSchema = z.strictObject({ }); const createCheckoutSessionBodySchema = z.strictObject({ - tier: z.enum(["home_lab", "starter", "scale"]), + tier: z.enum(["tier1", "tier2", "tier3"]), }); export async function createCheckoutSession( @@ -83,11 +83,11 @@ export async function createCheckoutSession( } let lineItems: Stripe.Checkout.SessionCreateParams.LineItem[]; - if (tier === "home_lab") { + if (tier === "tier1") { lineItems = await getLineItems(getHomeLabFeaturePriceSet(), orgId); - } else if (tier === "starter") { + } else if (tier === "tier2") { lineItems = await getLineItems(getStarterFeaturePriceSet(), orgId); - } else if (tier === "scale") { + } else if (tier === "tier3") { lineItems = await getLineItems(getScaleFeaturePriceSet(), orgId); } else { return next( diff --git a/server/private/routers/billing/getOrgUsage.ts b/server/private/routers/billing/getOrgUsage.ts index 9d65e98b..cf4e7585 100644 --- a/server/private/routers/billing/getOrgUsage.ts +++ b/server/private/routers/billing/getOrgUsage.ts @@ -78,16 +78,10 @@ export async function getOrgUsage( // Get usage for org const usageData = []; - const sites = await usageService.getUsage( - orgId, - FeatureId.SITES - ); - const users = await usageService.getUsageDaily(orgId, FeatureId.USERS); - const domains = await usageService.getUsageDaily( - orgId, - FeatureId.DOMAINS - ); - const remoteExitNodes = await usageService.getUsageDaily( + const sites = await usageService.getUsage(orgId, FeatureId.SITES); + const users = await usageService.getUsage(orgId, FeatureId.USERS); + const domains = await usageService.getUsage(orgId, FeatureId.DOMAINS); + const remoteExitNodes = await usageService.getUsage( orgId, FeatureId.REMOTE_EXIT_NODES ); diff --git a/server/private/routers/billing/hooks/getSubType.ts b/server/private/routers/billing/hooks/getSubType.ts index 3618747d..fbaab23c 100644 --- a/server/private/routers/billing/hooks/getSubType.ts +++ b/server/private/routers/billing/hooks/getSubType.ts @@ -21,7 +21,7 @@ import { } from "@server/lib/billing/features"; import Stripe from "stripe"; -export type SubscriptionType = "home_lab" | "starter" | "scale" | "license"; +export type SubscriptionType = "tier1" | "tier2" | "tier3" | "license"; export function getSubType(fullSubscription: Stripe.Response): SubscriptionType | null { // Determine subscription type by checking subscription items @@ -41,21 +41,21 @@ export function getSubType(fullSubscription: Stripe.Response ({ + stripeSubscriptionItemId: item.id, subscriptionId: subscription.id, planId: item.plan.id, priceId: item.price.id, @@ -237,7 +238,7 @@ export async function handleSubscriptionUpdated( } // --- end usage update --- - if (type === "home_lab" || type === "starter" || type === "scale") { + if (type === "tier1" || type === "tier2" || type === "tier3") { logger.debug( `Handling SAAS subscription lifecycle for org ${customer.orgId} with type ${type}` ); diff --git a/server/private/routers/billing/subscriptionLifecycle.ts b/server/private/routers/billing/subscriptionLifecycle.ts index 73a58748..6ac0fe2b 100644 --- a/server/private/routers/billing/subscriptionLifecycle.ts +++ b/server/private/routers/billing/subscriptionLifecycle.ts @@ -14,8 +14,8 @@ import { freeLimitSet, homeLabLimitSet, - starterLimitSet, - scaleLimitSet, + tier2LimitSet, + tier3LimitSet, limitsService, LimitSet } from "@server/lib/billing"; @@ -24,16 +24,16 @@ import { SubscriptionType } from "./hooks/getSubType"; function getLimitSetForSubscriptionType(subType: SubscriptionType | null): LimitSet { switch (subType) { - case "home_lab": + case "tier1": return homeLabLimitSet; - case "starter": - return starterLimitSet; - case "scale": - return scaleLimitSet; + case "tier2": + return tier2LimitSet; + case "tier3": + return tier3LimitSet; case "license": - // License subscriptions use starter limits by default + // License subscriptions use tier2 limits by default // This can be adjusted based on your business logic - return starterLimitSet; + return tier2LimitSet; default: return freeLimitSet; } diff --git a/server/private/routers/remoteExitNode/createRemoteExitNode.ts b/server/private/routers/remoteExitNode/createRemoteExitNode.ts index ed7b82a6..ba09d8e5 100644 --- a/server/private/routers/remoteExitNode/createRemoteExitNode.ts +++ b/server/private/routers/remoteExitNode/createRemoteExitNode.ts @@ -224,7 +224,7 @@ export async function createRemoteExitNode( }); if (numExitNodeOrgs) { - await usageService.updateDaily( + await usageService.updateCount( orgId, FeatureId.REMOTE_EXIT_NODES, numExitNodeOrgs.length diff --git a/server/private/routers/remoteExitNode/deleteRemoteExitNode.ts b/server/private/routers/remoteExitNode/deleteRemoteExitNode.ts index a23363fc..8337f05d 100644 --- a/server/private/routers/remoteExitNode/deleteRemoteExitNode.ts +++ b/server/private/routers/remoteExitNode/deleteRemoteExitNode.ts @@ -106,7 +106,7 @@ export async function deleteRemoteExitNode( }); if (numExitNodeOrgs) { - await usageService.updateDaily( + await usageService.updateCount( orgId, FeatureId.REMOTE_EXIT_NODES, numExitNodeOrgs.length diff --git a/server/routers/domain/createOrgDomain.ts b/server/routers/domain/createOrgDomain.ts index 6558d748..0bd18f41 100644 --- a/server/routers/domain/createOrgDomain.ts +++ b/server/routers/domain/createOrgDomain.ts @@ -354,7 +354,7 @@ export async function createOrgDomain( }); if (numOrgDomains) { - await usageService.updateDaily( + await usageService.updateCount( orgId, FeatureId.DOMAINS, numOrgDomains.length diff --git a/server/routers/domain/deleteOrgDomain.ts b/server/routers/domain/deleteOrgDomain.ts index fa916beb..04829a13 100644 --- a/server/routers/domain/deleteOrgDomain.ts +++ b/server/routers/domain/deleteOrgDomain.ts @@ -86,7 +86,7 @@ export async function deleteAccountDomain( }); if (numOrgDomains) { - await usageService.updateDaily( + await usageService.updateCount( orgId, FeatureId.DOMAINS, numOrgDomains.length diff --git a/server/routers/idp/validateOidcCallback.ts b/server/routers/idp/validateOidcCallback.ts index e87fe3ce..22c49f42 100644 --- a/server/routers/idp/validateOidcCallback.ts +++ b/server/routers/idp/validateOidcCallback.ts @@ -587,7 +587,7 @@ export async function validateOidcCallback( }); for (const orgCount of orgUserCounts) { - await usageService.updateDaily( + await usageService.updateCount( orgCount.orgId, FeatureId.USERS, orgCount.userCount diff --git a/server/routers/newt/handleNewtRegisterMessage.ts b/server/routers/newt/handleNewtRegisterMessage.ts index 3a018fdc..4355b98d 100644 --- a/server/routers/newt/handleNewtRegisterMessage.ts +++ b/server/routers/newt/handleNewtRegisterMessage.ts @@ -107,7 +107,7 @@ export const handleNewtRegisterMessage: MessageHandler = async (context) => { FeatureId.EGRESS_DATA_MB ); - // Do we need to check the users and domains daily limits here? + // Do we need to check the users and domains count limits here? // const rejectUsers = await usageService.checkLimitSet(oldSite.orgId, false, FeatureId.USERS); // const rejectDomains = await usageService.checkLimitSet(oldSite.orgId, false, FeatureId.DOMAINS); diff --git a/server/routers/org/createOrg.ts b/server/routers/org/createOrg.ts index e93af889..29468ca1 100644 --- a/server/routers/org/createOrg.ts +++ b/server/routers/org/createOrg.ts @@ -271,7 +271,7 @@ export async function createOrg( // make sure we have the stripe customer const customerId = await createCustomer(orgId, req.user?.email); if (customerId) { - await usageService.updateDaily( + await usageService.updateCount( orgId, FeatureId.USERS, 1, diff --git a/server/routers/site/createSite.ts b/server/routers/site/createSite.ts index d9837f30..e150ddec 100644 --- a/server/routers/site/createSite.ts +++ b/server/routers/site/createSite.ts @@ -440,7 +440,7 @@ export async function createSite( }); if (numSites) { - await usageService.updateDaily( + await usageService.updateCount( orgId, FeatureId.SITES, numSites.length diff --git a/server/routers/site/deleteSite.ts b/server/routers/site/deleteSite.ts index 29159352..2ce900fd 100644 --- a/server/routers/site/deleteSite.ts +++ b/server/routers/site/deleteSite.ts @@ -110,7 +110,7 @@ export async function deleteSite( }); if (numSites) { - await usageService.updateDaily( + await usageService.updateCount( site.orgId, FeatureId.SITES, numSites.length diff --git a/server/routers/user/acceptInvite.ts b/server/routers/user/acceptInvite.ts index fd79d849..34b8be15 100644 --- a/server/routers/user/acceptInvite.ts +++ b/server/routers/user/acceptInvite.ts @@ -155,17 +155,19 @@ export async function acceptInvite( .delete(userInvites) .where(eq(userInvites.inviteId, inviteId)); + await calculateUserClientsForOrgs(existingUser[0].userId, trx); + // Get the total number of users in the org now - totalUsers = await db + totalUsers = await trx .select() .from(userOrgs) .where(eq(userOrgs.orgId, existingInvite.orgId)); - await calculateUserClientsForOrgs(existingUser[0].userId, trx); + logger.debug(`User ${existingUser[0].userId} accepted invite to org ${existingInvite.orgId}. Total users in org: ${totalUsers.length}`); }); if (totalUsers) { - await usageService.updateDaily( + await usageService.updateCount( existingInvite.orgId, FeatureId.USERS, totalUsers.length diff --git a/server/routers/user/createOrgUser.ts b/server/routers/user/createOrgUser.ts index c061ef27..04282ea0 100644 --- a/server/routers/user/createOrgUser.ts +++ b/server/routers/user/createOrgUser.ts @@ -254,7 +254,7 @@ export async function createOrgUser( }); if (orgUsers) { - await usageService.updateDaily( + await usageService.updateCount( orgId, FeatureId.USERS, orgUsers.length diff --git a/server/routers/user/removeUserOrg.ts b/server/routers/user/removeUserOrg.ts index 97045e92..768d5fff 100644 --- a/server/routers/user/removeUserOrg.ts +++ b/server/routers/user/removeUserOrg.ts @@ -140,7 +140,7 @@ export async function removeUserOrg( }); if (userCount) { - await usageService.updateDaily( + await usageService.updateCount( orgId, FeatureId.USERS, userCount.length diff --git a/server/setup/migrations.ts b/server/setup/migrations.ts new file mode 100644 index 00000000..7ae21836 --- /dev/null +++ b/server/setup/migrations.ts @@ -0,0 +1,162 @@ +#! /usr/bin/env node +import { migrate } from "drizzle-orm/node-postgres/migrator"; +import { db } from "../db/pg"; +import semver from "semver"; +import { versionMigrations } from "../db/pg"; +import { __DIRNAME, APP_VERSION } from "@server/lib/consts"; +import path from "path"; +import m1 from "./scriptsPg/1.6.0"; +import m2 from "./scriptsPg/1.7.0"; +import m3 from "./scriptsPg/1.8.0"; +import m4 from "./scriptsPg/1.9.0"; +import m5 from "./scriptsPg/1.10.0"; +import m6 from "./scriptsPg/1.10.2"; +import m7 from "./scriptsPg/1.11.0"; +import m8 from "./scriptsPg/1.11.1"; +import m9 from "./scriptsPg/1.12.0"; +import m10 from "./scriptsPg/1.13.0"; +import m11 from "./scriptsPg/1.14.0"; +import m12 from "./scriptsPg/1.15.0"; + +// THIS CANNOT IMPORT ANYTHING FROM THE SERVER +// EXCEPT FOR THE DATABASE AND THE SCHEMA + +// Define the migration list with versions and their corresponding functions +const migrations = [ + { version: "1.6.0", run: m1 }, + { version: "1.7.0", run: m2 }, + { version: "1.8.0", run: m3 }, + { version: "1.9.0", run: m4 }, + { version: "1.10.0", run: m5 }, + { version: "1.10.2", run: m6 }, + { version: "1.11.0", run: m7 }, + { version: "1.11.1", run: m8 }, + { version: "1.12.0", run: m9 }, + { version: "1.13.0", run: m10 }, + { version: "1.14.0", run: m11 }, + { version: "1.15.0", run: m12 } + // Add new migrations here as they are created +] as { + version: string; + run: () => Promise; +}[]; + +await run(); + +async function run() { + // run the migrations + await runMigrations(); +} + +export async function runMigrations() { + if (process.env.DISABLE_MIGRATIONS) { + console.log("Migrations are disabled. Skipping..."); + return; + } + try { + const appVersion = APP_VERSION; + + // determine if the migrations table exists + const exists = await db + .select() + .from(versionMigrations) + .limit(1) + .execute() + .then((res) => res.length > 0) + .catch(() => false); + + if (exists) { + console.log("Migrations table exists, running scripts..."); + await executeScripts(); + } else { + console.log("Migrations table does not exist, creating it..."); + console.log("Running migrations..."); + try { + await migrate(db, { + migrationsFolder: path.join(__DIRNAME, "init") // put here during the docker build + }); + console.log("Migrations completed successfully."); + } catch (error) { + console.error("Error running migrations:", error); + } + + await db + .insert(versionMigrations) + .values({ + version: appVersion, + executedAt: Date.now() + }) + .execute(); + } + } catch (e) { + console.error("Error running migrations:", e); + await new Promise((resolve) => + setTimeout(resolve, 1000 * 60 * 60 * 24 * 1) + ); + } +} + +async function executeScripts() { + try { + // Get the last executed version from the database + const lastExecuted = await db.select().from(versionMigrations); + + // Filter and sort migrations + const pendingMigrations = lastExecuted + .map((m) => m) + .sort((a, b) => semver.compare(b.version, a.version)); + const startVersion = pendingMigrations[0]?.version ?? "0.0.0"; + console.log(`Starting migrations from version ${startVersion}`); + + const migrationsToRun = migrations.filter((migration) => + semver.gt(migration.version, startVersion) + ); + + console.log( + "Migrations to run:", + migrationsToRun.map((m) => m.version).join(", ") + ); + + // Run migrations in order + for (const migration of migrationsToRun) { + console.log(`Running migration ${migration.version}`); + + try { + await migration.run(); + + // Update version in database + await db + .insert(versionMigrations) + .values({ + version: migration.version, + executedAt: Date.now() + }) + .execute(); + + console.log( + `Successfully completed migration ${migration.version}` + ); + } catch (e) { + if ( + e instanceof Error && + typeof (e as any).code === "string" && + (e as any).code === "23505" + ) { + console.error("Migration has already run! Skipping..."); + continue; // or return, depending on context + } + + console.error( + `Failed to run migration ${migration.version}:`, + e + ); + throw e; + } + } + + console.log("All migrations completed successfully"); + } catch (error) { + console.error("Migration process failed:", error); + throw error; + } +} diff --git a/src/app/[orgId]/settings/(private)/billing/page.tsx b/src/app/[orgId]/settings/(private)/billing/page.tsx index 803e8199..d0002cba 100644 --- a/src/app/[orgId]/settings/(private)/billing/page.tsx +++ b/src/app/[orgId]/settings/(private)/billing/page.tsx @@ -58,7 +58,7 @@ interface PlanOption { name: string; price: string; priceDetail?: string; - tierType: "home_lab" | "starter" | "scale" | null; // Maps to backend tier types + tierType: "tier1" | "tier2" | "tier3" | null; // Maps to backend tier types } // Tier limits for display in confirmation dialog @@ -69,20 +69,20 @@ interface TierLimits { remoteNodes: number; } -const tierLimits: Record<"home_lab" | "starter" | "scale", TierLimits> = { - home_lab: { +const tierLimits: Record<"tier1" | "tier2" | "tier3", TierLimits> = { + tier1: { sites: 3, users: 3, domains: 3, remoteNodes: 1 }, - starter: { + tier2: { sites: 10, users: 150, domains: 250, remoteNodes: 5 }, - scale: { + tier3: { sites: 10, users: 150, domains: 250, @@ -102,21 +102,21 @@ const planOptions: PlanOption[] = [ name: "Homelab", price: "$15", priceDetail: "/ month", - tierType: "home_lab" + tierType: "tier1" }, { id: "team", name: "Team", price: "$5", priceDetail: "per user / month", - tierType: "starter" + tierType: "tier2" }, { id: "business", name: "Business", price: "$10", priceDetail: "per user / month", - tierType: "scale" + tierType: "tier3" }, { id: "enterprise", @@ -155,7 +155,7 @@ export default function BillingPage() { const [hasSubscription, setHasSubscription] = useState(false); const [isLoading, setIsLoading] = useState(false); const [currentTier, setCurrentTier] = useState< - "home_lab" | "starter" | "scale" | null + "tier1" | "tier2" | "tier3" | null >(null); // Usage IDs @@ -167,7 +167,7 @@ export default function BillingPage() { // Confirmation dialog state const [showConfirmDialog, setShowConfirmDialog] = useState(false); const [pendingTier, setPendingTier] = useState<{ - tier: "home_lab" | "starter" | "scale"; + tier: "tier1" | "tier2" | "tier3"; action: "upgrade" | "downgrade"; planName: string; price: string; @@ -186,18 +186,18 @@ export default function BillingPage() { // Find tier subscription const tierSub = subscriptions.find( ({ subscription }) => - subscription?.type === "home_lab" || - subscription?.type === "starter" || - subscription?.type === "scale" + subscription?.type === "tier1" || + subscription?.type === "tier2" || + subscription?.type === "tier3" ); setTierSubscription(tierSub || null); if (tierSub?.subscription) { setCurrentTier( tierSub.subscription.type as - | "home_lab" - | "starter" - | "scale" + | "tier1" + | "tier2" + | "tier3" ); setHasSubscription( tierSub.subscription.status === "active" @@ -243,7 +243,7 @@ export default function BillingPage() { }, [org.org.orgId]); const handleStartSubscription = async ( - tier: "home_lab" | "starter" | "scale" + tier: "tier1" | "tier2" | "tier3" ) => { setIsLoading(true); try { @@ -300,7 +300,7 @@ export default function BillingPage() { } }; - const handleChangeTier = async (tier: "home_lab" | "starter" | "scale") => { + const handleChangeTier = async (tier: "tier1" | "tier2" | "tier3") => { if (!hasSubscription) { // If no subscription, start a new one handleStartSubscription(tier); @@ -343,7 +343,7 @@ export default function BillingPage() { }; const showTierConfirmation = ( - tier: "home_lab" | "starter" | "scale", + tier: "tier1" | "tier2" | "tier3", action: "upgrade" | "downgrade", planName: string, price: string @@ -453,8 +453,8 @@ export default function BillingPage() { // Calculate current usage cost for display const getUserCount = () => getUsageValue(USERS); const getPricePerUser = () => { - if (currentTier === "starter") return 5; - if (currentTier === "scale") return 10; + if (currentTier === "tier2") return 5; + if (currentTier === "tier3") return 10; return 0; }; diff --git a/src/lib/api/isOrgSubscribed.ts b/src/lib/api/isOrgSubscribed.ts index 8eb4b8e8..b57810cb 100644 --- a/src/lib/api/isOrgSubscribed.ts +++ b/src/lib/api/isOrgSubscribed.ts @@ -20,7 +20,7 @@ export const isOrgSubscribed = cache(async (orgId: string) => { try { const subRes = await getCachedSubscription(orgId); subscribed = - (subRes.data.data.tier == "home_lab" || subRes.data.data.tier == "starter" || subRes.data.data.tier == "scale") && + (subRes.data.data.tier == "tier1" || subRes.data.data.tier == "tier2" || subRes.data.data.tier == "tier3") && subRes.data.data.active; } catch {} } diff --git a/src/providers/SubscriptionStatusProvider.tsx b/src/providers/SubscriptionStatusProvider.tsx index f9d8ef8b..fad6469d 100644 --- a/src/providers/SubscriptionStatusProvider.tsx +++ b/src/providers/SubscriptionStatusProvider.tsx @@ -46,9 +46,9 @@ export function SubscriptionStatusProvider({ // Iterate through all subscriptions for (const { subscription } of subscriptionStatus.subscriptions) { if ( - subscription.type == "home_lab" || - subscription.type == "starter" || - subscription.type == "scale" + subscription.type == "tier1" || + subscription.type == "tier2" || + subscription.type == "tier3" ) { return { tier: subscription.type, @@ -70,7 +70,7 @@ export function SubscriptionStatusProvider({ } const { tier, active } = getTier(); return ( - (tier == "home_lab" || tier == "starter" || tier == "scale") && + (tier == "tier1" || tier == "tier2" || tier == "tier3") && active ); }; From 300b4a3706e9f90fec9c1cea078910ddb67552e8 Mon Sep 17 00:00:00 2001 From: Owen Date: Sun, 8 Feb 2026 17:56:50 -0800 Subject: [PATCH 15/49] Set version when creating sub --- .../private/routers/billing/hooks/handleSubscriptionCreated.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/server/private/routers/billing/hooks/handleSubscriptionCreated.ts b/server/private/routers/billing/hooks/handleSubscriptionCreated.ts index 05b0c739..16b64145 100644 --- a/server/private/routers/billing/hooks/handleSubscriptionCreated.ts +++ b/server/private/routers/billing/hooks/handleSubscriptionCreated.ts @@ -69,7 +69,8 @@ export async function handleSubscriptionCreated( ? subscription.canceled_at : null, createdAt: subscription.created, - type: type + type: type, + version: 1 // we are hardcoding the initial version when the subscription is created, and then we will increment it on every update }; await db.insert(subscriptions).values(newSubscription); From 122053939d75028e4ac4c8924f130894819d739c Mon Sep 17 00:00:00 2001 From: miloschwartz Date: Mon, 9 Feb 2026 14:41:40 -0800 Subject: [PATCH 16/49] dont fingerprint machine clients --- server/routers/client/getClient.ts | 4 +++- server/routers/client/listClients.ts | 5 ++++- server/routers/olm/handleOlmPingMessage.ts | 6 +++++- server/routers/olm/handleOlmRegisterMessage.ts | 6 +++++- 4 files changed, 17 insertions(+), 4 deletions(-) diff --git a/server/routers/client/getClient.ts b/server/routers/client/getClient.ts index 12901d0c..b8b5594e 100644 --- a/server/routers/client/getClient.ts +++ b/server/routers/client/getClient.ts @@ -294,9 +294,11 @@ export async function getClient( ); } + const isUserDevice = client.user !== null && client.user !== undefined; + // Replace name with device name if OLM exists let clientName = client.clients.name; - if (client.olms) { + if (client.olms && isUserDevice) { const model = client.currentFingerprint?.deviceModel || null; clientName = getUserDeviceName(model, client.clients.name); } diff --git a/server/routers/client/listClients.ts b/server/routers/client/listClients.ts index b4e2eb56..09560c6d 100644 --- a/server/routers/client/listClients.ts +++ b/server/routers/client/listClients.ts @@ -320,7 +320,10 @@ export async function listClients( // Merge clients with their site associations and replace name with device name const clientsWithSites = clientsList.map((client) => { const model = client.deviceModel || null; - const newName = getUserDeviceName(model, client.name); + let newName = client.name; + if (filter === "user") { + newName = getUserDeviceName(model, client.name); + } return { ...client, name: newName, diff --git a/server/routers/olm/handleOlmPingMessage.ts b/server/routers/olm/handleOlmPingMessage.ts index b87f49d2..efcbf169 100644 --- a/server/routers/olm/handleOlmPingMessage.ts +++ b/server/routers/olm/handleOlmPingMessage.ts @@ -117,6 +117,8 @@ export const handleOlmPingMessage: MessageHandler = async (context) => { return; } + const isUserDevice = olm.userId !== null && olm.userId !== undefined; + try { // get the client const [client] = await db @@ -219,7 +221,9 @@ export const handleOlmPingMessage: MessageHandler = async (context) => { logger.error("Error handling ping message", { error }); } - await handleFingerprintInsertion(olm, fingerprint, postures); + if (isUserDevice) { + await handleFingerprintInsertion(olm, fingerprint, postures); + } return { message: { diff --git a/server/routers/olm/handleOlmRegisterMessage.ts b/server/routers/olm/handleOlmRegisterMessage.ts index e4bb6f4f..7fa43c9c 100644 --- a/server/routers/olm/handleOlmRegisterMessage.ts +++ b/server/routers/olm/handleOlmRegisterMessage.ts @@ -53,7 +53,11 @@ export const handleOlmRegisterMessage: MessageHandler = async (context) => { postures }); - await handleFingerprintInsertion(olm, fingerprint, postures); + const isUserDevice = olm.userId !== null && olm.userId !== undefined; + + if (isUserDevice) { + await handleFingerprintInsertion(olm, fingerprint, postures); + } if ( (olmVersion && olm.version !== olmVersion) || From dff45748bd7bac6def2aca8ba2699841aba009ac Mon Sep 17 00:00:00 2001 From: miloschwartz Date: Mon, 9 Feb 2026 16:57:41 -0800 Subject: [PATCH 17/49] refactor is licensed and subscribed util functions --- server/lib/config.ts | 5 + server/lib/isLicencedOrSubscribed.ts | 7 +- server/lib/isSubscribed.ts | 5 +- server/lib/readConfigFile.ts | 3 +- server/private/lib/checkOrgAccessPolicy.ts | 2 + server/private/lib/isLicencedOrSubscribed.ts | 10 +- server/private/lib/isSubscribed.ts | 8 +- .../private/middlewares/verifySubscription.ts | 75 +-- server/private/routers/external.ts | 2 +- .../[remoteExitNodeId]/credentials/page.tsx | 44 +- .../machine/[niceId]/credentials/page.tsx | 44 +- .../clients/user/[niceId]/general/page.tsx | 444 +++++++++--------- src/app/[orgId]/settings/general/layout.tsx | 15 +- .../settings/general/security/page.tsx | 250 +++++----- .../resources/proxy/[niceId]/general/page.tsx | 21 +- .../sites/[niceId]/credentials/page.tsx | 62 +-- src/app/navigation.tsx | 55 ++- src/components/CreateRoleForm.tsx | 87 ++-- src/components/EditRoleForm.tsx | 88 ++-- src/components/PaidFeaturesAlert.tsx | 7 + src/contexts/subscriptionStatusContext.ts | 1 - src/hooks/usePaidStatus.ts | 25 +- src/lib/pullEnv.ts | 6 +- src/lib/types/env.ts | 1 + src/providers/SubscriptionStatusProvider.tsx | 14 - 25 files changed, 707 insertions(+), 574 deletions(-) diff --git a/server/lib/config.ts b/server/lib/config.ts index d3931ec3..4cd8bbfd 100644 --- a/server/lib/config.ts +++ b/server/lib/config.ts @@ -107,6 +107,11 @@ export class Config { process.env.MAXMIND_ASN_PATH = parsedConfig.server.maxmind_asn_path; } + process.env.DISABLE_ENTERPRISE_FEATURES = parsedConfig.flags + ?.disable_enterprise_features + ? "true" + : "false"; + this.rawConfig = parsedConfig; } diff --git a/server/lib/isLicencedOrSubscribed.ts b/server/lib/isLicencedOrSubscribed.ts index 748bb1b1..a04d44aa 100644 --- a/server/lib/isLicencedOrSubscribed.ts +++ b/server/lib/isLicencedOrSubscribed.ts @@ -1,3 +1,6 @@ -export async function isLicensedOrSubscribed(orgId: string): Promise { +export async function isLicensedOrSubscribed( + orgId: string, + tiers: string[] +): Promise { return false; -} \ No newline at end of file +} diff --git a/server/lib/isSubscribed.ts b/server/lib/isSubscribed.ts index 44a4c0b3..306ab871 100644 --- a/server/lib/isSubscribed.ts +++ b/server/lib/isSubscribed.ts @@ -1,3 +1,6 @@ -export async function isSubscribed(orgId: string): Promise { +export async function isSubscribed( + orgId: string, + tiers: string[] +): Promise { return false; } diff --git a/server/lib/readConfigFile.ts b/server/lib/readConfigFile.ts index 362210ae..bfca5970 100644 --- a/server/lib/readConfigFile.ts +++ b/server/lib/readConfigFile.ts @@ -331,7 +331,8 @@ export const configSchema = z disable_local_sites: z.boolean().optional(), disable_basic_wireguard_sites: z.boolean().optional(), disable_config_managed_domains: z.boolean().optional(), - disable_product_help_banners: z.boolean().optional() + disable_product_help_banners: z.boolean().optional(), + disable_enterprise_features: z.boolean().optional() }) .optional(), dns: z diff --git a/server/private/lib/checkOrgAccessPolicy.ts b/server/private/lib/checkOrgAccessPolicy.ts index af318ce0..fee07a62 100644 --- a/server/private/lib/checkOrgAccessPolicy.ts +++ b/server/private/lib/checkOrgAccessPolicy.ts @@ -78,6 +78,8 @@ export async function checkOrgAccessPolicy( } } + // TODO: check that the org is subscribed + // get the needed data if (!props.org) { diff --git a/server/private/lib/isLicencedOrSubscribed.ts b/server/private/lib/isLicencedOrSubscribed.ts index 3b2f6592..3f8d2a6d 100644 --- a/server/private/lib/isLicencedOrSubscribed.ts +++ b/server/private/lib/isLicencedOrSubscribed.ts @@ -13,16 +13,18 @@ import { build } from "@server/build"; import license from "#private/license/license"; -import { getOrgTierData } from "#private/lib/billing"; +import { isSubscribed } from "#private/lib/isSubscribed"; -export async function isLicensedOrSubscribed(orgId: string): Promise { +export async function isLicensedOrSubscribed( + orgId: string, + tiers: string[] +): Promise { if (build === "enterprise") { return await license.isUnlocked(); } if (build === "saas") { - const { tier, active } = await getOrgTierData(orgId); - return (tier == "tier1" || tier == "tier2" || tier == "tier3") && active; + return isSubscribed(orgId, tiers); } return false; diff --git a/server/private/lib/isSubscribed.ts b/server/private/lib/isSubscribed.ts index 12bbb965..23ffc698 100644 --- a/server/private/lib/isSubscribed.ts +++ b/server/private/lib/isSubscribed.ts @@ -14,10 +14,14 @@ import { build } from "@server/build"; import { getOrgTierData } from "#private/lib/billing"; -export async function isSubscribed(orgId: string): Promise { +export async function isSubscribed( + orgId: string, + tiers: string[] +): Promise { if (build === "saas") { const { tier, active } = await getOrgTierData(orgId); - return (tier == "tier1" || tier == "tier2" || tier == "tier3") && active; + const isTier = (tier && tiers.includes(tier)) || false; + return active && isTier; } return false; diff --git a/server/private/middlewares/verifySubscription.ts b/server/private/middlewares/verifySubscription.ts index fec9241a..3ab351a1 100644 --- a/server/private/middlewares/verifySubscription.ts +++ b/server/private/middlewares/verifySubscription.ts @@ -17,44 +17,51 @@ import HttpCode from "@server/types/HttpCode"; import { build } from "@server/build"; import { getOrgTierData } from "#private/lib/billing"; -export async function verifyValidSubscription( - req: Request, - res: Response, - next: NextFunction -) { - try { - if (build != "saas") { +export function verifyValidSubscription(tiers: string[]) { + return async function ( + req: Request, + res: Response, + next: NextFunction + ): Promise { + try { + if (build != "saas") { + return next(); + } + + const orgId = + req.params.orgId || + req.body.orgId || + req.query.orgId || + req.userOrgId; + + if (!orgId) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + "Organization ID is required to verify subscription" + ) + ); + } + + const { tier, active } = await getOrgTierData(orgId); + const isTier = tiers.includes(tier || ""); + if (!isTier || !active) { + return next( + createHttpError( + HttpCode.FORBIDDEN, + "Organization does not have an active subscription" + ) + ); + } + return next(); - } - - const orgId = req.params.orgId || req.body.orgId || req.query.orgId || req.userOrgId; - - if (!orgId) { + } catch (e) { return next( createHttpError( - HttpCode.BAD_REQUEST, - "Organization ID is required to verify subscription" + HttpCode.INTERNAL_SERVER_ERROR, + "Error verifying subscription" ) ); } - - const { tier, active } = await getOrgTierData(orgId); - if ((tier == "tier1" || tier == "tier2" || tier == "tier3") && active) { - return next( - createHttpError( - HttpCode.FORBIDDEN, - "Organization does not have an active subscription" - ) - ); - } - - return next(); - } catch (e) { - return next( - createHttpError( - HttpCode.INTERNAL_SERVER_ERROR, - "Error verifying subscription" - ) - ); - } + }; } diff --git a/server/private/routers/external.ts b/server/private/routers/external.ts index bef493ca..37048c34 100644 --- a/server/private/routers/external.ts +++ b/server/private/routers/external.ts @@ -86,7 +86,7 @@ authenticated.put( authenticated.post( "/org/:orgId/idp/:idpId/oidc", verifyValidLicense, - verifyValidSubscription, + verifyValidSubscription(), verifyOrgAccess, verifyIdpAccess, verifyUserHasAction(ActionsEnum.updateIdp), diff --git a/src/app/[orgId]/settings/(private)/remote-exit-nodes/[remoteExitNodeId]/credentials/page.tsx b/src/app/[orgId]/settings/(private)/remote-exit-nodes/[remoteExitNodeId]/credentials/page.tsx index aff31f46..2fa2b753 100644 --- a/src/app/[orgId]/settings/(private)/remote-exit-nodes/[remoteExitNodeId]/credentials/page.tsx +++ b/src/app/[orgId]/settings/(private)/remote-exit-nodes/[remoteExitNodeId]/credentials/page.tsx @@ -195,27 +195,29 @@ export default function CredentialsPage() { )} - - - - + {!env.flags.disableEnterpriseFeatures && ( + + + + + )} diff --git a/src/app/[orgId]/settings/clients/machine/[niceId]/credentials/page.tsx b/src/app/[orgId]/settings/clients/machine/[niceId]/credentials/page.tsx index e6b5ff20..024f4cd7 100644 --- a/src/app/[orgId]/settings/clients/machine/[niceId]/credentials/page.tsx +++ b/src/app/[orgId]/settings/clients/machine/[niceId]/credentials/page.tsx @@ -183,27 +183,29 @@ export default function CredentialsPage() { )} - - - - + {!env.flags.disableEnterpriseFeatures && ( + + + + + )} (null); const [isRefreshing, setIsRefreshing] = useState(false); const [, startTransition] = useTransition(); + const { env } = useEnvContext(); const showApprovalFeatures = build !== "oss" && isPaidUser; @@ -567,231 +568,246 @@ export default function GeneralPage() { )} - - - - {t("deviceSecurity")} - - - {t("deviceSecurityDescription")} - - + {!env.flags.disableEnterpriseFeatures && ( + + + + {t("deviceSecurity")} + + + {t("deviceSecurityDescription")} + + - - - {client.posture && - Object.keys(client.posture).length > 0 ? ( - <> - - {client.posture.biometricsEnabled !== null && - client.posture.biometricsEnabled !== - undefined && ( - - - {t("biometricsEnabled")} - - - {isPaidUser - ? formatPostureValue( - client.posture - .biometricsEnabled - ) - : "-"} - - - )} + + + {client.posture && + Object.keys(client.posture).length > 0 ? ( + <> + + {client.posture.biometricsEnabled !== + null && + client.posture.biometricsEnabled !== + undefined && ( + + + {t("biometricsEnabled")} + + + {isPaidUser + ? formatPostureValue( + client.posture + .biometricsEnabled + ) + : "-"} + + + )} - {client.posture.diskEncrypted !== null && - client.posture.diskEncrypted !== - undefined && ( - - - {t("diskEncrypted")} - - - {isPaidUser - ? formatPostureValue( - client.posture - .diskEncrypted - ) - : "-"} - - - )} + {client.posture.diskEncrypted !== null && + client.posture.diskEncrypted !== + undefined && ( + + + {t("diskEncrypted")} + + + {isPaidUser + ? formatPostureValue( + client.posture + .diskEncrypted + ) + : "-"} + + + )} - {client.posture.firewallEnabled !== null && - client.posture.firewallEnabled !== - undefined && ( - - - {t("firewallEnabled")} - - - {isPaidUser - ? formatPostureValue( - client.posture - .firewallEnabled - ) - : "-"} - - - )} + {client.posture.firewallEnabled !== null && + client.posture.firewallEnabled !== + undefined && ( + + + {t("firewallEnabled")} + + + {isPaidUser + ? formatPostureValue( + client.posture + .firewallEnabled + ) + : "-"} + + + )} - {client.posture.autoUpdatesEnabled !== null && - client.posture.autoUpdatesEnabled !== - undefined && ( - - - {t("autoUpdatesEnabled")} - - - {isPaidUser - ? formatPostureValue( - client.posture - .autoUpdatesEnabled - ) - : "-"} - - - )} + {client.posture.autoUpdatesEnabled !== + null && + client.posture.autoUpdatesEnabled !== + undefined && ( + + + {t("autoUpdatesEnabled")} + + + {isPaidUser + ? formatPostureValue( + client.posture + .autoUpdatesEnabled + ) + : "-"} + + + )} - {client.posture.tpmAvailable !== null && - client.posture.tpmAvailable !== - undefined && ( - - - {t("tpmAvailable")} - - - {isPaidUser - ? formatPostureValue( - client.posture - .tpmAvailable - ) - : "-"} - - - )} + {client.posture.tpmAvailable !== null && + client.posture.tpmAvailable !== + undefined && ( + + + {t("tpmAvailable")} + + + {isPaidUser + ? formatPostureValue( + client.posture + .tpmAvailable + ) + : "-"} + + + )} - {client.posture.windowsAntivirusEnabled !== - null && - client.posture.windowsAntivirusEnabled !== - undefined && ( - - - {t("windowsAntivirusEnabled")} - - - {isPaidUser - ? formatPostureValue( - client.posture - .windowsAntivirusEnabled - ) - : "-"} - - - )} + {client.posture.windowsAntivirusEnabled !== + null && + client.posture + .windowsAntivirusEnabled !== + undefined && ( + + + {t( + "windowsAntivirusEnabled" + )} + + + {isPaidUser + ? formatPostureValue( + client.posture + .windowsAntivirusEnabled + ) + : "-"} + + + )} - {client.posture.macosSipEnabled !== null && - client.posture.macosSipEnabled !== - undefined && ( - - - {t("macosSipEnabled")} - - - {isPaidUser - ? formatPostureValue( - client.posture - .macosSipEnabled - ) - : "-"} - - - )} + {client.posture.macosSipEnabled !== null && + client.posture.macosSipEnabled !== + undefined && ( + + + {t("macosSipEnabled")} + + + {isPaidUser + ? formatPostureValue( + client.posture + .macosSipEnabled + ) + : "-"} + + + )} - {client.posture.macosGatekeeperEnabled !== - null && - client.posture.macosGatekeeperEnabled !== - undefined && ( - - - {t("macosGatekeeperEnabled")} - - - {isPaidUser - ? formatPostureValue( - client.posture - .macosGatekeeperEnabled - ) - : "-"} - - - )} + {client.posture.macosGatekeeperEnabled !== + null && + client.posture + .macosGatekeeperEnabled !== + undefined && ( + + + {t( + "macosGatekeeperEnabled" + )} + + + {isPaidUser + ? formatPostureValue( + client.posture + .macosGatekeeperEnabled + ) + : "-"} + + + )} - {client.posture.macosFirewallStealthMode !== - null && - client.posture.macosFirewallStealthMode !== - undefined && ( - - - {t("macosFirewallStealthMode")} - - - {isPaidUser - ? formatPostureValue( - client.posture - .macosFirewallStealthMode - ) - : "-"} - - - )} + {client.posture.macosFirewallStealthMode !== + null && + client.posture + .macosFirewallStealthMode !== + undefined && ( + + + {t( + "macosFirewallStealthMode" + )} + + + {isPaidUser + ? formatPostureValue( + client.posture + .macosFirewallStealthMode + ) + : "-"} + + + )} - {client.posture.linuxAppArmorEnabled !== null && - client.posture.linuxAppArmorEnabled !== - undefined && ( - - - {t("linuxAppArmorEnabled")} - - - {isPaidUser - ? formatPostureValue( - client.posture - .linuxAppArmorEnabled - ) - : "-"} - - - )} + {client.posture.linuxAppArmorEnabled !== + null && + client.posture.linuxAppArmorEnabled !== + undefined && ( + + + {t("linuxAppArmorEnabled")} + + + {isPaidUser + ? formatPostureValue( + client.posture + .linuxAppArmorEnabled + ) + : "-"} + + + )} - {client.posture.linuxSELinuxEnabled !== null && - client.posture.linuxSELinuxEnabled !== - undefined && ( - - - {t("linuxSELinuxEnabled")} - - - {isPaidUser - ? formatPostureValue( - client.posture - .linuxSELinuxEnabled - ) - : "-"} - - - )} - - - ) : ( -
- {t("noData")} -
- )} -
-
+ {client.posture.linuxSELinuxEnabled !== + null && + client.posture.linuxSELinuxEnabled !== + undefined && ( + + + {t("linuxSELinuxEnabled")} + + + {isPaidUser + ? formatPostureValue( + client.posture + .linuxSELinuxEnabled + ) + : "-"} + + + )} + + + ) : ( +
+ {t("noData")} +
+ )} + +
+ )} ); } diff --git a/src/app/[orgId]/settings/general/layout.tsx b/src/app/[orgId]/settings/general/layout.tsx index a3f7264f..b69969f4 100644 --- a/src/app/[orgId]/settings/general/layout.tsx +++ b/src/app/[orgId]/settings/general/layout.tsx @@ -10,6 +10,7 @@ import { getTranslations } from "next-intl/server"; import { getCachedOrg } from "@app/lib/api/getCachedOrg"; import { getCachedOrgUser } from "@app/lib/api/getCachedOrgUser"; import { build } from "@server/build"; +import { pullEnv } from "@app/lib/pullEnv"; type GeneralSettingsProps = { children: React.ReactNode; @@ -23,6 +24,7 @@ export default async function GeneralSettingsPage({ const { orgId } = await params; const user = await verifySession(); + const env = pullEnv(); if (!user) { redirect(`/`); @@ -56,10 +58,15 @@ export default async function GeneralSettingsPage({ title: t("security"), href: `/{orgId}/settings/general/security` }, - { - title: t("authPage"), - href: `/{orgId}/settings/general/auth-page` - } + // PaidFeaturesAlert + ...(!env.flags.disableEnterpriseFeatures + ? [ + { + title: t("authPage"), + href: `/{orgId}/settings/general/auth-page` + } + ] + : []) ]; return ( diff --git a/src/app/[orgId]/settings/general/security/page.tsx b/src/app/[orgId]/settings/general/security/page.tsx index 47946415..55aa9d57 100644 --- a/src/app/[orgId]/settings/general/security/page.tsx +++ b/src/app/[orgId]/settings/general/security/page.tsx @@ -102,10 +102,13 @@ type SectionFormProps = { export default function SecurityPage() { const { org } = useOrgContext(); + const { env } = useEnvContext(); return ( - + {!env.flags.disableEnterpriseFeatures && ( + + )} ); } @@ -135,7 +138,8 @@ function LogRetentionSectionForm({ org }: SectionFormProps) { const { isPaidUser, hasSaasSubscription } = usePaidStatus(); const [, formAction, loadingSave] = useActionState(performSave, null); - const api = createApiClient(useEnvContext()); + const { env } = useEnvContext(); + const api = createApiClient({ env }); async function performSave() { const isValid = await form.trigger(); @@ -238,120 +242,144 @@ function LogRetentionSectionForm({ org }: SectionFormProps) { )} /> - + {!env.flags.disableEnterpriseFeatures && ( + <> + - { - const isDisabled = !isPaidUser; + { + const isDisabled = !isPaidUser; - return ( - - - {t("logRetentionAccessLabel")} - - - - - - - ); - }} - /> - { - const isDisabled = !isPaidUser; - - return ( - - - {t("logRetentionActionLabel")} - - - { + if ( + !isDisabled + ) { + field.onChange( + parseInt( + value, + 10 + ) + ); + } + }} + disabled={ + isDisabled + } + > + + - ) + /> + + + {LOG_RETENTION_OPTIONS.map( + ( + option + ) => ( + + {t( + option.label + )} + + ) + )} + + + + + + ); + }} + /> + { + const isDisabled = !isPaidUser; + + return ( + + + {t( + "logRetentionActionLabel" )} - - - - - - ); - }} - /> + + + + + + + ); + }} + /> + + )} diff --git a/src/app/[orgId]/settings/resources/proxy/[niceId]/general/page.tsx b/src/app/[orgId]/settings/resources/proxy/[niceId]/general/page.tsx index b4dc3ad8..1ed8eb17 100644 --- a/src/app/[orgId]/settings/resources/proxy/[niceId]/general/page.tsx +++ b/src/app/[orgId]/settings/resources/proxy/[niceId]/general/page.tsx @@ -163,7 +163,9 @@ function MaintenanceSectionForm({ const isEnterpriseNotLicensed = build === "enterprise" && !isUnlocked(); const isSaasNotSubscribed = build === "saas" && !subscription?.isSubscribed(); - return isEnterpriseNotLicensed || isSaasNotSubscribed || build === "oss"; + return ( + isEnterpriseNotLicensed || isSaasNotSubscribed || build === "oss" + ); }; if (!resource.http) { @@ -189,13 +191,14 @@ function MaintenanceSectionForm({ className="space-y-4" id="maintenance-settings-form" > - + { const isDisabled = - isSecurityFeatureDisabled() || resource.http === false; + isSecurityFeatureDisabled() || + resource.http === false; return ( @@ -415,7 +418,7 @@ function MaintenanceSectionForm({ - - + {!env.flags.disableEnterpriseFeatures && ( + + + + + )} )} - - - + {!env.flags.disableEnterpriseFeatures && ( + + + + )} )} diff --git a/src/app/navigation.tsx b/src/app/navigation.tsx index 854f6e19..d74ef30b 100644 --- a/src/app/navigation.tsx +++ b/src/app/navigation.tsx @@ -121,16 +121,27 @@ export const orgNavSections = (env?: Env): SidebarNavSection[] => [ href: "/{orgId}/settings/access/roles", icon: }, - { - title: "sidebarIdentityProviders", - href: "/{orgId}/settings/idp", - icon: - }, - { - title: "sidebarApprovals", - href: "/{orgId}/settings/access/approvals", - icon: - }, + // PaidFeaturesAlert + ...((build === "oss" && !env?.flags.disableEnterpriseFeatures) || + build === "saas" || + env?.flags.useOrgOnlyIdp + ? [ + { + title: "sidebarIdentityProviders", + href: "/{orgId}/settings/idp", + icon: + } + ] + : []), + ...(!env?.flags.disableEnterpriseFeatures + ? [ + { + title: "sidebarApprovals", + href: "/{orgId}/settings/access/approvals", + icon: + } + ] + : []), { title: "sidebarShareableLinks", href: "/{orgId}/settings/share-links", @@ -147,16 +158,20 @@ export const orgNavSections = (env?: Env): SidebarNavSection[] => [ href: "/{orgId}/settings/logs/request", icon: }, - { - title: "sidebarLogsAccess", - href: "/{orgId}/settings/logs/access", - icon: - }, - { - title: "sidebarLogsAction", - href: "/{orgId}/settings/logs/action", - icon: - } + ...(!env?.flags.disableEnterpriseFeatures + ? [ + { + title: "sidebarLogsAccess", + href: "/{orgId}/settings/logs/access", + icon: + }, + { + title: "sidebarLogsAction", + href: "/{orgId}/settings/logs/action", + icon: + } + ] + : []) ]; const analytics = { diff --git a/src/components/CreateRoleForm.tsx b/src/components/CreateRoleForm.tsx index 3ea56c53..2642cf64 100644 --- a/src/components/CreateRoleForm.tsx +++ b/src/components/CreateRoleForm.tsx @@ -51,6 +51,7 @@ export default function CreateRoleForm({ const { org } = useOrgContext(); const t = useTranslations(); const { isPaidUser } = usePaidStatus(); + const { env } = useEnvContext(); const formSchema = z.object({ name: z @@ -161,50 +162,56 @@ export default function CreateRoleForm({ )} /> - + {!env.flags.disableEnterpriseFeatures && ( + <> + - ( - - - { - if ( - checked !== - "indeterminate" - ) { - form.setValue( - "requireDeviceApproval", + ( + + + - + ) => { + if ( + checked !== + "indeterminate" + ) { + form.setValue( + "requireDeviceApproval", + checked + ); + } + }} + label={t( + "requireDeviceApproval" + )} + /> + - - {t( - "requireDeviceApprovalDescription" - )} - + + {t( + "requireDeviceApprovalDescription" + )} + - - - )} - /> + + + )} + /> + + )} diff --git a/src/components/EditRoleForm.tsx b/src/components/EditRoleForm.tsx index 81b5bef5..1feb95c6 100644 --- a/src/components/EditRoleForm.tsx +++ b/src/components/EditRoleForm.tsx @@ -59,6 +59,7 @@ export default function EditRoleForm({ const { org } = useOrgContext(); const t = useTranslations(); const { isPaidUser } = usePaidStatus(); + const { env } = useEnvContext(); const formSchema = z.object({ name: z @@ -168,50 +169,57 @@ export default function EditRoleForm({ )} /> - - ( - - - { - if ( - checked !== - "indeterminate" - ) { - form.setValue( - "requireDeviceApproval", + {!env.flags.disableEnterpriseFeatures && ( + <> + + + ( + + + - + ) => { + if ( + checked !== + "indeterminate" + ) { + form.setValue( + "requireDeviceApproval", + checked + ); + } + }} + label={t( + "requireDeviceApproval" + )} + /> + - - {t( - "requireDeviceApprovalDescription" - )} - + + {t( + "requireDeviceApprovalDescription" + )} + - - - )} - /> + + + )} + /> + + )} diff --git a/src/components/PaidFeaturesAlert.tsx b/src/components/PaidFeaturesAlert.tsx index 4fd1d0de..b2c96ab8 100644 --- a/src/components/PaidFeaturesAlert.tsx +++ b/src/components/PaidFeaturesAlert.tsx @@ -5,6 +5,7 @@ import { usePaidStatus } from "@app/hooks/usePaidStatus"; import { ExternalLink, KeyRound, Sparkles } from "lucide-react"; import { useTranslations } from "next-intl"; import Link from "next/link"; +import { useEnvContext } from "@app/hooks/useEnvContext"; const bannerClassName = "mb-6 border-primary/30 bg-linear-to-br from-primary/10 via-background to-background overflow-hidden"; @@ -15,6 +16,12 @@ const bannerRowClassName = export function PaidFeaturesAlert() { const t = useTranslations(); const { hasSaasSubscription, hasEnterpriseLicense } = usePaidStatus(); + const { env } = useEnvContext(); + + if (env.flags.disableEnterpriseFeatures) { + return null; + } + return ( <> {build === "saas" && !hasSaasSubscription ? ( diff --git a/src/contexts/subscriptionStatusContext.ts b/src/contexts/subscriptionStatusContext.ts index 71fe7004..a3efc67f 100644 --- a/src/contexts/subscriptionStatusContext.ts +++ b/src/contexts/subscriptionStatusContext.ts @@ -4,7 +4,6 @@ import { createContext } from "react"; type SubscriptionStatusContextType = { subscriptionStatus: GetOrgSubscriptionResponse | null; updateSubscriptionStatus: (updatedSite: GetOrgSubscriptionResponse) => void; - isActive: () => boolean; getTier: () => { tier: string | null; active: boolean }; isSubscribed: () => boolean; subscribed: boolean; diff --git a/src/hooks/usePaidStatus.ts b/src/hooks/usePaidStatus.ts index d8173e6e..88423853 100644 --- a/src/hooks/usePaidStatus.ts +++ b/src/hooks/usePaidStatus.ts @@ -8,14 +8,29 @@ export function usePaidStatus() { // Check if features are disabled due to licensing/subscription const hasEnterpriseLicense = build === "enterprise" && isUnlocked(); - const hasSaasSubscription = - build === "saas" && - subscription?.isSubscribed() && - subscription.isActive(); + const tierData = subscription?.getTier(); + const hasSaasSubscription = build === "saas" && tierData?.active; + + function isPaidUser(tiers: string[]): boolean { + if (hasEnterpriseLicense) { + return true; + } + + if ( + hasSaasSubscription && + tierData?.tier && + tiers.includes(tierData.tier) + ) { + return true; + } + + return false; + } return { hasEnterpriseLicense, hasSaasSubscription, - isPaidUser: hasEnterpriseLicense || hasSaasSubscription + isPaidUser, + subscriptionTier: tierData?.tier }; } diff --git a/src/lib/pullEnv.ts b/src/lib/pullEnv.ts index 319e08f0..298745b9 100644 --- a/src/lib/pullEnv.ts +++ b/src/lib/pullEnv.ts @@ -65,7 +65,11 @@ export function pullEnv(): Env { ? true : false, useOrgOnlyIdp: - process.env.USE_ORG_ONLY_IDP === "true" ? true : false + process.env.USE_ORG_ONLY_IDP === "true" ? true : false, + disableEnterpriseFeatures: + process.env.DISABLE_ENTERPRISE_FEATURES === "true" + ? true + : false }, branding: { diff --git a/src/lib/types/env.ts b/src/lib/types/env.ts index f99e1994..925e4348 100644 --- a/src/lib/types/env.ts +++ b/src/lib/types/env.ts @@ -35,6 +35,7 @@ export type Env = { usePangolinDns: boolean; disableProductHelpBanners: boolean; useOrgOnlyIdp: boolean; + disableEnterpriseFeatures: boolean; }; branding: { appName?: string; diff --git a/src/providers/SubscriptionStatusProvider.tsx b/src/providers/SubscriptionStatusProvider.tsx index fad6469d..be5a7b5a 100644 --- a/src/providers/SubscriptionStatusProvider.tsx +++ b/src/providers/SubscriptionStatusProvider.tsx @@ -31,16 +31,6 @@ export function SubscriptionStatusProvider({ }); }; - const isActive = () => { - if (subscriptionStatus?.subscriptions) { - // Check if any subscription is active - return subscriptionStatus.subscriptions.some( - (sub) => sub.subscription?.status === "active" - ); - } - return false; - }; - const getTier = () => { if (subscriptionStatus?.subscriptions) { // Iterate through all subscriptions @@ -65,9 +55,6 @@ export function SubscriptionStatusProvider({ }; const isSubscribed = () => { - if (build === "enterprise") { - return true; - } const { tier, active } = getTier(); return ( (tier == "tier1" || tier == "tier2" || tier == "tier3") && @@ -82,7 +69,6 @@ export function SubscriptionStatusProvider({ value={{ subscriptionStatus: subscriptionStatusState, updateSubscriptionStatus, - isActive, getTier, isSubscribed, subscribed From 7d8185e0ee08f03082fdef891ba6371f6032ac04 Mon Sep 17 00:00:00 2001 From: Owen Date: Mon, 9 Feb 2026 17:05:14 -0800 Subject: [PATCH 18/49] Getting swtiching tiers to work --- server/db/pg/schema/privateSchema.ts | 1 + server/db/sqlite/schema/privateSchema.ts | 1 + server/lib/billing/features.ts | 20 ++++++++ server/private/routers/billing/changeTier.ts | 9 ++-- .../hooks/handleSubscriptionCreated.ts | 5 ++ .../hooks/handleSubscriptionUpdated.ts | 50 +++++++++++++------ .../settings/(private)/billing/page.tsx | 15 +++++- 7 files changed, 81 insertions(+), 20 deletions(-) diff --git a/server/db/pg/schema/privateSchema.ts b/server/db/pg/schema/privateSchema.ts index de5bb1ca..9d493ed9 100644 --- a/server/db/pg/schema/privateSchema.ts +++ b/server/db/pg/schema/privateSchema.ts @@ -97,6 +97,7 @@ export const subscriptionItems = pgTable("subscriptionItems", { }), planId: varchar("planId", { length: 255 }).notNull(), priceId: varchar("priceId", { length: 255 }), + featureId: varchar("featureId", { length: 255 }), meterId: varchar("meterId", { length: 255 }), unitAmount: real("unitAmount"), tiers: text("tiers"), diff --git a/server/db/sqlite/schema/privateSchema.ts b/server/db/sqlite/schema/privateSchema.ts index 1fa8654b..2571a65a 100644 --- a/server/db/sqlite/schema/privateSchema.ts +++ b/server/db/sqlite/schema/privateSchema.ts @@ -86,6 +86,7 @@ export const subscriptionItems = sqliteTable("subscriptionItems", { }), planId: text("planId").notNull(), priceId: text("priceId"), + featureId: text("featureId"), meterId: text("meterId"), unitAmount: real("unitAmount"), tiers: text("tiers"), diff --git a/server/lib/billing/features.ts b/server/lib/billing/features.ts index a9b652a9..82ba0676 100644 --- a/server/lib/billing/features.ts +++ b/server/lib/billing/features.ts @@ -116,6 +116,26 @@ export function getScaleFeaturePriceSet(): FeaturePriceSet { } } +export function getFeatureIdByPriceId(priceId: string): FeatureId | undefined { + // Check all feature price sets + const allPriceSets = [ + getHomeLabFeaturePriceSet(), + getStarterFeaturePriceSet(), + getScaleFeaturePriceSet() + ]; + + for (const priceSet of allPriceSets) { + const entry = (Object.entries(priceSet) as [FeatureId, string][]).find( + ([_, price]) => price === priceId + ); + if (entry) { + return entry[0]; + } + } + + return undefined; +} + export async function getLineItems( featurePriceSet: FeaturePriceSet, orgId: string, diff --git a/server/private/routers/billing/changeTier.ts b/server/private/routers/billing/changeTier.ts index a33a9164..5d67b7e8 100644 --- a/server/private/routers/billing/changeTier.ts +++ b/server/private/routers/billing/changeTier.ts @@ -206,7 +206,8 @@ export async function changeTier( // Keep the existing item unchanged if we can't find it return { id: stripeItem.id, - price: stripeItem.price.id + price: stripeItem.price.id, + quantity: stripeItem.quantity }; } @@ -216,14 +217,16 @@ export async function changeTier( if (newPriceId) { return { id: stripeItem.id, - price: newPriceId + price: newPriceId, + quantity: stripeItem.quantity }; } // If no mapping found, keep existing return { id: stripeItem.id, - price: stripeItem.price.id + price: stripeItem.price.id, + quantity: stripeItem.quantity }; } ); diff --git a/server/private/routers/billing/hooks/handleSubscriptionCreated.ts b/server/private/routers/billing/hooks/handleSubscriptionCreated.ts index 16b64145..773ffbae 100644 --- a/server/private/routers/billing/hooks/handleSubscriptionCreated.ts +++ b/server/private/routers/billing/hooks/handleSubscriptionCreated.ts @@ -31,6 +31,7 @@ import { getLicensePriceSet, LicenseId } from "@server/lib/billing/licenses"; import { sendEmail } from "@server/emails"; import EnterpriseEditionKeyGenerated from "@server/emails/templates/EnterpriseEditionKeyGenerated"; import config from "@server/lib/config"; +import { getFeatureIdByPriceId } from "@server/lib/billing/features"; export async function handleSubscriptionCreated( subscription: Stripe.Subscription @@ -91,11 +92,15 @@ export async function handleSubscriptionCreated( name = product.name || null; } + // Get the feature ID from the price ID + const featureId = getFeatureIdByPriceId(item.price.id); + return { stripeSubscriptionItemId: item.id, subscriptionId: subscription.id, planId: item.plan.id, priceId: item.price.id, + featureId: featureId || null, meterId: item.plan.meter, unitAmount: item.price.unit_amount || 0, currentPeriodStart: item.current_period_start, diff --git a/server/private/routers/billing/hooks/handleSubscriptionUpdated.ts b/server/private/routers/billing/hooks/handleSubscriptionUpdated.ts index 4288d3c4..83472ac0 100644 --- a/server/private/routers/billing/hooks/handleSubscriptionUpdated.ts +++ b/server/private/routers/billing/hooks/handleSubscriptionUpdated.ts @@ -23,7 +23,7 @@ import { } from "@server/db"; import { eq, and } from "drizzle-orm"; import logger from "@server/logger"; -import { getFeatureIdByMetricId } from "@server/lib/billing/features"; +import { getFeatureIdByMetricId, getFeatureIdByPriceId } from "@server/lib/billing/features"; import stripe from "#private/lib/stripe"; import { handleSubscriptionLifesycle } from "../subscriptionLifecycle"; import { getSubType } from "./getSubType"; @@ -81,20 +81,40 @@ export async function handleSubscriptionUpdated( // Upsert subscription items if (Array.isArray(fullSubscription.items?.data)) { - const itemsToUpsert = fullSubscription.items.data.map((item) => ({ - stripeSubscriptionItemId: item.id, - subscriptionId: subscription.id, - planId: item.plan.id, - priceId: item.price.id, - meterId: item.plan.meter, - unitAmount: item.price.unit_amount || 0, - currentPeriodStart: item.current_period_start, - currentPeriodEnd: item.current_period_end, - tiers: item.price.tiers - ? JSON.stringify(item.price.tiers) - : null, - interval: item.plan.interval - })); + // First, get existing items to preserve featureId when there's no match + const existingItems = await db + .select() + .from(subscriptionItems) + .where(eq(subscriptionItems.subscriptionId, subscription.id)); + + 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( + (ei) => ei.stripeSubscriptionItemId === item.id + ); + featureId = existingItem?.featureId || null; + } + + return { + stripeSubscriptionItemId: item.id, + subscriptionId: subscription.id, + planId: item.plan.id, + priceId: item.price.id, + featureId: featureId, + meterId: item.plan.meter, + unitAmount: item.price.unit_amount || 0, + currentPeriodStart: item.current_period_start, + currentPeriodEnd: item.current_period_end, + tiers: item.price.tiers + ? JSON.stringify(item.price.tiers) + : null, + interval: item.plan.interval + }; + }); if (itemsToUpsert.length > 0) { await db.transaction(async (trx) => { await trx diff --git a/src/app/[orgId]/settings/(private)/billing/page.tsx b/src/app/[orgId]/settings/(private)/billing/page.tsx index d0002cba..0716aa60 100644 --- a/src/app/[orgId]/settings/(private)/billing/page.tsx +++ b/src/app/[orgId]/settings/(private)/billing/page.tsx @@ -453,8 +453,19 @@ export default function BillingPage() { // Calculate current usage cost for display const getUserCount = () => getUsageValue(USERS); const getPricePerUser = () => { - if (currentTier === "tier2") return 5; - if (currentTier === "tier3") return 10; + 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 + ); + + // unitAmount is in cents, convert to dollars + if (usersItem?.unitAmount) { + return usersItem.unitAmount / 100; + } + return 0; }; From 431e6ffaaeeafa05994164f85da710fd82d06516 Mon Sep 17 00:00:00 2001 From: Owen Date: Mon, 9 Feb 2026 17:23:48 -0800 Subject: [PATCH 19/49] Remove site kick --- server/lib/billing/limitSet.ts | 83 ++++++++----------- server/lib/billing/usageService.ts | 53 ------------ .../routers/billing/subscriptionLifecycle.ts | 16 ++-- .../remoteExitNode/createRemoteExitNode.ts | 2 +- server/routers/domain/createOrgDomain.ts | 2 +- server/routers/gerbil/receiveBandwidth.ts | 8 +- .../routers/newt/handleNewtRegisterMessage.ts | 48 +---------- server/routers/site/createSite.ts | 2 +- server/routers/user/acceptInvite.ts | 11 ++- server/routers/user/createOrgUser.ts | 8 +- server/routers/user/inviteUser.ts | 1 - 11 files changed, 61 insertions(+), 173 deletions(-) diff --git a/server/lib/billing/limitSet.ts b/server/lib/billing/limitSet.ts index 47dbe1b8..b47b5681 100644 --- a/server/lib/billing/limitSet.ts +++ b/server/lib/billing/limitSet.ts @@ -8,77 +8,60 @@ export type LimitSet = Partial<{ }>; export const sandboxLimitSet: LimitSet = { - [FeatureId.SITES]: { value: 1, description: "Sandbox limit" }, // 1 site up for 2 days [FeatureId.USERS]: { value: 1, description: "Sandbox limit" }, - [FeatureId.EGRESS_DATA_MB]: { value: 1000, description: "Sandbox limit" }, // 1 GB + [FeatureId.SITES]: { value: 1, description: "Sandbox limit" }, [FeatureId.DOMAINS]: { value: 0, description: "Sandbox limit" }, - [FeatureId.REMOTE_EXIT_NODES]: { value: 0, description: "Sandbox limit" } + [FeatureId.REMOTE_EXIT_NODES]: { value: 0, description: "Sandbox limit" }, }; export const freeLimitSet: LimitSet = { - [FeatureId.SITES]: { value: 3, description: "Free tier limit" }, // 1 site up for 32 days - [FeatureId.USERS]: { value: 3, description: "Free tier limit" }, - [FeatureId.EGRESS_DATA_MB]: { - value: 25000, - description: "Free tier limit" - }, // 25 GB - [FeatureId.DOMAINS]: { value: 3, description: "Free tier limit" }, - [FeatureId.REMOTE_EXIT_NODES]: { value: 0, description: "Free tier limit" } + [FeatureId.USERS]: { value: 5, description: "Starter limit" }, + [FeatureId.SITES]: { value: 5, description: "Starter limit" }, + [FeatureId.DOMAINS]: { value: 5, description: "Starter limit" }, + [FeatureId.REMOTE_EXIT_NODES]: { value: 1, description: "Starter limit" }, }; -export const homeLabLimitSet: LimitSet = { - [FeatureId.SITES]: { value: 3, description: "Home lab limit" }, // 1 site up for 32 days - [FeatureId.USERS]: { value: 3, description: "Home lab limit" }, - [FeatureId.EGRESS_DATA_MB]: { - value: 25000, - description: "Home lab limit" - }, // 25 GB - [FeatureId.DOMAINS]: { value: 3, description: "Home lab limit" }, - [FeatureId.REMOTE_EXIT_NODES]: { value: 1, description: "Home lab limit" } +export const tier1LimitSet: LimitSet = { + [FeatureId.USERS]: { value: 7, description: "Home limit" }, + [FeatureId.SITES]: { value: 10, description: "Home limit" }, + [FeatureId.DOMAINS]: { value: 10, description: "Home limit" }, + [FeatureId.REMOTE_EXIT_NODES]: { value: 1, description: "Home limit" }, }; export const tier2LimitSet: LimitSet = { - [FeatureId.SITES]: { - value: 10, - description: "Starter limit" - }, // 50 sites up for 31 days [FeatureId.USERS]: { - value: 150, - description: "Starter limit" + value: 100, + description: "Team limit" + }, + [FeatureId.SITES]: { + value: 50, + description: "Team limit" }, - [FeatureId.EGRESS_DATA_MB]: { - value: 12000000, - description: "Starter limit" - }, // 12000 GB [FeatureId.DOMAINS]: { - value: 250, - description: "Starter limit" + value: 50, + description: "Team limit" }, [FeatureId.REMOTE_EXIT_NODES]: { - value: 5, - description: "Starter limit" - } + value: 3, + description: "Team limit" + }, }; export const tier3LimitSet: LimitSet = { - [FeatureId.SITES]: { - value: 10, - description: "Scale limit" - }, // 50 sites up for 31 days [FeatureId.USERS]: { - value: 150, - description: "Scale limit" + value: 500, + description: "Business limit" }, - [FeatureId.EGRESS_DATA_MB]: { - value: 12000000, - description: "Scale limit" - }, // 12000 GB - [FeatureId.DOMAINS]: { + [FeatureId.SITES]: { value: 250, - description: "Scale limit" + description: "Business limit" + }, + [FeatureId.DOMAINS]: { + value: 100, + description: "Business limit" }, [FeatureId.REMOTE_EXIT_NODES]: { - value: 5, - description: "Scale limit" - } + value: 20, + description: "Business limit" + }, }; diff --git a/server/lib/billing/usageService.ts b/server/lib/billing/usageService.ts index 50519a68..6f95b96a 100644 --- a/server/lib/billing/usageService.ts +++ b/server/lib/billing/usageService.ts @@ -517,7 +517,6 @@ export class UsageService { public async checkLimitSet( orgId: string, - kickSites = false, featureId?: FeatureId, usage?: Usage, trx: Transaction | typeof db = db @@ -591,58 +590,6 @@ export class UsageService { break; // Exit early if any limit is exceeded } } - - // If any limits are exceeded, disconnect all sites for this organization - if (hasExceededLimits && kickSites) { - logger.warn( - `Disconnecting all sites for org ${orgId} due to exceeded limits` - ); - - // Get all sites for this organization - const orgSites = await trx - .select() - .from(sites) - .where(eq(sites.orgId, orgId)); - - // Mark all sites as offline and send termination messages - const siteUpdates = orgSites.map((site) => site.siteId); - - if (siteUpdates.length > 0) { - // Send termination messages to newt sites - for (const site of orgSites) { - if (site.type === "newt") { - const [newt] = await trx - .select() - .from(newts) - .where(eq(newts.siteId, site.siteId)) - .limit(1); - - if (newt) { - const payload = { - type: `newt/wg/terminate`, - data: { - reason: "Usage limits exceeded" - } - }; - - // Don't await to prevent blocking - await sendToClient(newt.newtId, payload).catch( - (error: any) => { - logger.error( - `Failed to send termination message to newt ${newt.newtId}:`, - error - ); - } - ); - } - } - } - - logger.info( - `Disconnected ${orgSites.length} sites for org ${orgId} due to exceeded limits` - ); - } - } } catch (error) { logger.error(`Error checking limits for org ${orgId}:`, error); } diff --git a/server/private/routers/billing/subscriptionLifecycle.ts b/server/private/routers/billing/subscriptionLifecycle.ts index 6ac0fe2b..a80f64c0 100644 --- a/server/private/routers/billing/subscriptionLifecycle.ts +++ b/server/private/routers/billing/subscriptionLifecycle.ts @@ -13,7 +13,7 @@ import { freeLimitSet, - homeLabLimitSet, + tier1LimitSet, tier2LimitSet, tier3LimitSet, limitsService, @@ -22,10 +22,12 @@ import { import { usageService } from "@server/lib/billing/usageService"; import { SubscriptionType } from "./hooks/getSubType"; -function getLimitSetForSubscriptionType(subType: SubscriptionType | null): LimitSet { +function getLimitSetForSubscriptionType( + subType: SubscriptionType | null +): LimitSet { switch (subType) { case "tier1": - return homeLabLimitSet; + return tier1LimitSet; case "tier2": return tier2LimitSet; case "tier3": @@ -48,12 +50,12 @@ export async function handleSubscriptionLifesycle( case "active": const activeLimitSet = getLimitSetForSubscriptionType(subType); await limitsService.applyLimitSetToOrg(orgId, activeLimitSet); - await usageService.checkLimitSet(orgId, true); + await usageService.checkLimitSet(orgId); break; case "canceled": // Subscription canceled - revert to free tier await limitsService.applyLimitSetToOrg(orgId, freeLimitSet); - await usageService.checkLimitSet(orgId, true); + await usageService.checkLimitSet(orgId); break; case "past_due": // Payment past due - keep current limits but notify customer @@ -62,7 +64,7 @@ export async function handleSubscriptionLifesycle( case "unpaid": // Subscription unpaid - revert to free tier await limitsService.applyLimitSetToOrg(orgId, freeLimitSet); - await usageService.checkLimitSet(orgId, true); + await usageService.checkLimitSet(orgId); break; case "incomplete": // Payment incomplete - give them time to complete payment @@ -70,7 +72,7 @@ export async function handleSubscriptionLifesycle( case "incomplete_expired": // Payment never completed - revert to free tier await limitsService.applyLimitSetToOrg(orgId, freeLimitSet); - await usageService.checkLimitSet(orgId, true); + await usageService.checkLimitSet(orgId); break; default: break; diff --git a/server/private/routers/remoteExitNode/createRemoteExitNode.ts b/server/private/routers/remoteExitNode/createRemoteExitNode.ts index ba09d8e5..14541736 100644 --- a/server/private/routers/remoteExitNode/createRemoteExitNode.ts +++ b/server/private/routers/remoteExitNode/createRemoteExitNode.ts @@ -85,7 +85,7 @@ export async function createRemoteExitNode( if (usage) { const rejectRemoteExitNodes = await usageService.checkLimitSet( orgId, - false, + FeatureId.REMOTE_EXIT_NODES, { ...usage, diff --git a/server/routers/domain/createOrgDomain.ts b/server/routers/domain/createOrgDomain.ts index 0bd18f41..35fb305f 100644 --- a/server/routers/domain/createOrgDomain.ts +++ b/server/routers/domain/createOrgDomain.ts @@ -131,7 +131,7 @@ export async function createOrgDomain( } const rejectDomains = await usageService.checkLimitSet( orgId, - false, + FeatureId.DOMAINS, { ...usage, diff --git a/server/routers/gerbil/receiveBandwidth.ts b/server/routers/gerbil/receiveBandwidth.ts index a2306d27..937fa271 100644 --- a/server/routers/gerbil/receiveBandwidth.ts +++ b/server/routers/gerbil/receiveBandwidth.ts @@ -178,11 +178,9 @@ export async function updateSiteBandwidth( // Process usage updates outside of site update transactions // This separates the concerns and reduces lock contention - if (calcUsageAndLimits && (orgUsageMap.size > 0)) { + if (calcUsageAndLimits && orgUsageMap.size > 0) { // Sort org IDs to ensure consistent lock ordering - const allOrgIds = [ - ...new Set([...orgUsageMap.keys()]) - ].sort(); + const allOrgIds = [...new Set([...orgUsageMap.keys()])].sort(); for (const orgId of allOrgIds) { try { @@ -199,7 +197,7 @@ export async function updateSiteBandwidth( usageService .checkLimitSet( orgId, - true, + FeatureId.EGRESS_DATA_MB, bandwidthUsage ) diff --git a/server/routers/newt/handleNewtRegisterMessage.ts b/server/routers/newt/handleNewtRegisterMessage.ts index 4355b98d..595430df 100644 --- a/server/routers/newt/handleNewtRegisterMessage.ts +++ b/server/routers/newt/handleNewtRegisterMessage.ts @@ -1,17 +1,13 @@ -import { db, ExitNode, exitNodeOrgs, newts, Transaction } from "@server/db"; +import { db, ExitNode, newts, Transaction } from "@server/db"; import { MessageHandler } from "@server/routers/ws"; -import { exitNodes, Newt, resources, sites, Target, targets } from "@server/db"; -import { targetHealthCheck } from "@server/db"; -import { eq, and, sql, inArray, ne } from "drizzle-orm"; +import { exitNodes, Newt, sites } from "@server/db"; +import { eq } from "drizzle-orm"; import { addPeer, deletePeer } from "../gerbil/peers"; import logger from "@server/logger"; import config from "@server/lib/config"; import { findNextAvailableCidr, - getNextAvailableClientSubnet } from "@server/lib/ip"; -import { usageService } from "@server/lib/billing/usageService"; -import { FeatureId } from "@server/lib/billing"; import { selectBestExitNode, verifyExitNodeOrgAccess @@ -30,8 +26,6 @@ export type ExitNodePingResult = { wasPreviouslyConnected: boolean; }; -const numTimesLimitExceededForId: Record = {}; - export const handleNewtRegisterMessage: MessageHandler = async (context) => { const { message, client, sendToClient } = context; const newt = client as Newt; @@ -96,42 +90,6 @@ export const handleNewtRegisterMessage: MessageHandler = async (context) => { fetchContainers(newt.newtId); } - const rejectSites = await usageService.checkLimitSet( - oldSite.orgId, - false, - FeatureId.SITES - ); - const rejectEgressDataMb = await usageService.checkLimitSet( - oldSite.orgId, - false, - FeatureId.EGRESS_DATA_MB - ); - - // Do we need to check the users and domains count limits here? - // const rejectUsers = await usageService.checkLimitSet(oldSite.orgId, false, FeatureId.USERS); - // const rejectDomains = await usageService.checkLimitSet(oldSite.orgId, false, FeatureId.DOMAINS); - - // if (rejectEgressDataMb || rejectSites || rejectUsers || rejectDomains) { - if (rejectEgressDataMb || rejectSites) { - logger.info( - `Usage limits exceeded for org ${oldSite.orgId}. Rejecting newt registration.` - ); - - // PREVENT FURTHER REGISTRATION ATTEMPTS SO WE DON'T SPAM - - // Increment the limit exceeded count for this site - numTimesLimitExceededForId[newt.newtId] = - (numTimesLimitExceededForId[newt.newtId] || 0) + 1; - - if (numTimesLimitExceededForId[newt.newtId] > 15) { - logger.debug( - `Newt ${newt.newtId} has exceeded usage limits 15 times. Terminating...` - ); - } - - return; - } - let siteSubnet = oldSite.subnet; let exitNodeIdToQuery = oldSite.exitNodeId; if (exitNodeId && (oldSite.exitNodeId !== exitNodeId || !oldSite.subnet)) { diff --git a/server/routers/site/createSite.ts b/server/routers/site/createSite.ts index e150ddec..d742a236 100644 --- a/server/routers/site/createSite.ts +++ b/server/routers/site/createSite.ts @@ -140,7 +140,7 @@ export async function createSite( } const rejectSites = await usageService.checkLimitSet( orgId, - false, + FeatureId.SITES, { ...usage, diff --git a/server/routers/user/acceptInvite.ts b/server/routers/user/acceptInvite.ts index 34b8be15..74f025ae 100644 --- a/server/routers/user/acceptInvite.ts +++ b/server/routers/user/acceptInvite.ts @@ -94,7 +94,10 @@ export async function acceptInvite( } if (build == "saas") { - const usage = await usageService.getUsage(existingInvite.orgId, FeatureId.USERS); + const usage = await usageService.getUsage( + existingInvite.orgId, + FeatureId.USERS + ); if (!usage) { return next( createHttpError( @@ -105,7 +108,7 @@ export async function acceptInvite( } const rejectUsers = await usageService.checkLimitSet( existingInvite.orgId, - false, + FeatureId.USERS, { ...usage, @@ -163,7 +166,9 @@ export async function acceptInvite( .from(userOrgs) .where(eq(userOrgs.orgId, existingInvite.orgId)); - logger.debug(`User ${existingUser[0].userId} accepted invite to org ${existingInvite.orgId}. Total users in org: ${totalUsers.length}`); + logger.debug( + `User ${existingUser[0].userId} accepted invite to org ${existingInvite.orgId}. Total users in org: ${totalUsers.length}` + ); }); if (totalUsers) { diff --git a/server/routers/user/createOrgUser.ts b/server/routers/user/createOrgUser.ts index 04282ea0..cd57943b 100644 --- a/server/routers/user/createOrgUser.ts +++ b/server/routers/user/createOrgUser.ts @@ -21,11 +21,7 @@ const paramsSchema = z.strictObject({ }); const bodySchema = z.strictObject({ - email: z - .string() - .email() - .toLowerCase() - .optional(), + email: z.string().email().toLowerCase().optional(), username: z.string().nonempty().toLowerCase(), name: z.string().optional(), type: z.enum(["internal", "oidc"]).optional(), @@ -94,7 +90,7 @@ export async function createOrgUser( } const rejectUsers = await usageService.checkLimitSet( orgId, - false, + FeatureId.USERS, { ...usage, diff --git a/server/routers/user/inviteUser.ts b/server/routers/user/inviteUser.ts index 6a778868..693ef3b9 100644 --- a/server/routers/user/inviteUser.ts +++ b/server/routers/user/inviteUser.ts @@ -133,7 +133,6 @@ export async function inviteUser( } const rejectUsers = await usageService.checkLimitSet( orgId, - false, FeatureId.USERS, { ...usage, From 0be8fb79315cc2e2eb3a0cc9669000e1894933ab Mon Sep 17 00:00:00 2001 From: miloschwartz Date: Mon, 9 Feb 2026 17:42:35 -0800 Subject: [PATCH 20/49] add tier type --- server/types/Tiers.ts | 1 + src/hooks/usePaidStatus.ts | 3 ++- src/providers/SubscriptionStatusProvider.tsx | 6 +++++- 3 files changed, 8 insertions(+), 2 deletions(-) create mode 100644 server/types/Tiers.ts diff --git a/server/types/Tiers.ts b/server/types/Tiers.ts new file mode 100644 index 00000000..f81e94e2 --- /dev/null +++ b/server/types/Tiers.ts @@ -0,0 +1 @@ +export type Tier = "tier1" | "tier2" | "tier3" | "enterprise"; diff --git a/src/hooks/usePaidStatus.ts b/src/hooks/usePaidStatus.ts index 88423853..db36519a 100644 --- a/src/hooks/usePaidStatus.ts +++ b/src/hooks/usePaidStatus.ts @@ -1,6 +1,7 @@ import { build } from "@server/build"; import { useLicenseStatusContext } from "./useLicenseStatusContext"; import { useSubscriptionStatusContext } from "./useSubscriptionStatusContext"; +import { Tier } from "@server/lib/tiers"; export function usePaidStatus() { const { isUnlocked } = useLicenseStatusContext(); @@ -11,7 +12,7 @@ export function usePaidStatus() { const tierData = subscription?.getTier(); const hasSaasSubscription = build === "saas" && tierData?.active; - function isPaidUser(tiers: string[]): boolean { + function isPaidUser(tiers: Tier): boolean { if (hasEnterpriseLicense) { return true; } diff --git a/src/providers/SubscriptionStatusProvider.tsx b/src/providers/SubscriptionStatusProvider.tsx index be5a7b5a..9a5050f6 100644 --- a/src/providers/SubscriptionStatusProvider.tsx +++ b/src/providers/SubscriptionStatusProvider.tsx @@ -4,6 +4,7 @@ import SubscriptionStatusContext from "@app/contexts/subscriptionStatusContext"; import { GetOrgSubscriptionResponse } from "@server/routers/billing/types"; import { useState } from "react"; import { build } from "@server/build"; +import { Tier } from "@server/types/Tiers"; interface ProviderProps { children: React.ReactNode; @@ -31,7 +32,10 @@ export function SubscriptionStatusProvider({ }); }; - const getTier = () => { + const getTier = (): { + tier: Tier | null; + active: boolean; + } => { if (subscriptionStatus?.subscriptions) { // Iterate through all subscriptions for (const { subscription } of subscriptionStatus.subscriptions) { From 66f3fabbae96a60d5d122f6ab0e9ab6f8dbb56fc Mon Sep 17 00:00:00 2001 From: miloschwartz Date: Mon, 9 Feb 2026 17:52:28 -0800 Subject: [PATCH 21/49] add rest of tier types --- server/private/lib/billing/getOrgTierData.ts | 5 +++-- .../routers/billing/hooks/getSubType.ts | 3 ++- .../settings/(private)/billing/page.tsx | 21 +++++++++---------- 3 files changed, 15 insertions(+), 14 deletions(-) diff --git a/server/private/lib/billing/getOrgTierData.ts b/server/private/lib/billing/getOrgTierData.ts index 89734fcd..24d658c0 100644 --- a/server/private/lib/billing/getOrgTierData.ts +++ b/server/private/lib/billing/getOrgTierData.ts @@ -13,12 +13,13 @@ import { build } from "@server/build"; import { db, customers, subscriptions } from "@server/db"; +import { Tier } from "@server/types/Tiers"; import { eq, and, ne } from "drizzle-orm"; export async function getOrgTierData( orgId: string -): Promise<{ tier: "tier1" | "tier2" | "tier3" | null; active: boolean }> { - let tier: "tier1" | "tier2" | "tier3" | null = null; +): Promise<{ tier: Tier | null; active: boolean }> { + let tier: Tier | null = null; let active = false; if (build !== "saas") { diff --git a/server/private/routers/billing/hooks/getSubType.ts b/server/private/routers/billing/hooks/getSubType.ts index fbaab23c..a38290eb 100644 --- a/server/private/routers/billing/hooks/getSubType.ts +++ b/server/private/routers/billing/hooks/getSubType.ts @@ -20,8 +20,9 @@ import { getScaleFeaturePriceSet, } from "@server/lib/billing/features"; import Stripe from "stripe"; +import { Tier } from "@server/types/Tiers"; -export type SubscriptionType = "tier1" | "tier2" | "tier3" | "license"; +export type SubscriptionType = Tier | "license"; export function getSubType(fullSubscription: Stripe.Response): SubscriptionType | null { // Determine subscription type by checking subscription items diff --git a/src/app/[orgId]/settings/(private)/billing/page.tsx b/src/app/[orgId]/settings/(private)/billing/page.tsx index 0716aa60..134ec204 100644 --- a/src/app/[orgId]/settings/(private)/billing/page.tsx +++ b/src/app/[orgId]/settings/(private)/billing/page.tsx @@ -49,6 +49,8 @@ import { } from "@server/routers/billing/types"; 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"; // Plan tier definitions matching the mockup type PlanId = "free" | "homelab" | "team" | "business" | "enterprise"; @@ -58,7 +60,7 @@ interface PlanOption { name: string; price: string; priceDetail?: string; - tierType: "tier1" | "tier2" | "tier3" | null; // Maps to backend tier types + tierType: Tier | null; } // Tier limits for display in confirmation dialog @@ -69,7 +71,7 @@ interface TierLimits { remoteNodes: number; } -const tierLimits: Record<"tier1" | "tier2" | "tier3", TierLimits> = { +const tierLimits: Record = { tier1: { sites: 3, users: 3, @@ -155,7 +157,7 @@ export default function BillingPage() { const [hasSubscription, setHasSubscription] = useState(false); const [isLoading, setIsLoading] = useState(false); const [currentTier, setCurrentTier] = useState< - "tier1" | "tier2" | "tier3" | null + Tier | null >(null); // Usage IDs @@ -167,7 +169,7 @@ export default function BillingPage() { // Confirmation dialog state const [showConfirmDialog, setShowConfirmDialog] = useState(false); const [pendingTier, setPendingTier] = useState<{ - tier: "tier1" | "tier2" | "tier3"; + tier: Tier, action: "upgrade" | "downgrade"; planName: string; price: string; @@ -194,10 +196,7 @@ export default function BillingPage() { if (tierSub?.subscription) { setCurrentTier( - tierSub.subscription.type as - | "tier1" - | "tier2" - | "tier3" + tierSub.subscription.type as Tier ); setHasSubscription( tierSub.subscription.status === "active" @@ -243,7 +242,7 @@ export default function BillingPage() { }, [org.org.orgId]); const handleStartSubscription = async ( - tier: "tier1" | "tier2" | "tier3" + tier: Tier ) => { setIsLoading(true); try { @@ -300,7 +299,7 @@ export default function BillingPage() { } }; - const handleChangeTier = async (tier: "tier1" | "tier2" | "tier3") => { + const handleChangeTier = async (tier: Tier) => { if (!hasSubscription) { // If no subscription, start a new one handleStartSubscription(tier); @@ -343,7 +342,7 @@ export default function BillingPage() { }; const showTierConfirmation = ( - tier: "tier1" | "tier2" | "tier3", + tier: Tier, action: "upgrade" | "downgrade", planName: string, price: string From 1b5cfaa49b177b25c7c2fa1226321e8ec1e6b4c9 Mon Sep 17 00:00:00 2001 From: Owen Date: Mon, 9 Feb 2026 18:04:18 -0800 Subject: [PATCH 22/49] Add pricing matrix --- server/lib/billing/tierMatrix.ts | 36 +++++++ server/lib/blueprints/proxyResources.ts | 7 +- server/lib/calculateUserClientsForOrgs.ts | 4 +- .../private/middlewares/verifySubscription.ts | 10 +- server/private/routers/external.ts | 32 +++---- server/routers/client/getClient.ts | 4 +- server/routers/newt/getNewtToken.ts | 2 +- server/routers/org/updateOrg.ts | 94 +++++++++++++++---- 8 files changed, 148 insertions(+), 41 deletions(-) create mode 100644 server/lib/billing/tierMatrix.ts diff --git a/server/lib/billing/tierMatrix.ts b/server/lib/billing/tierMatrix.ts new file mode 100644 index 00000000..3e5eb0f6 --- /dev/null +++ b/server/lib/billing/tierMatrix.ts @@ -0,0 +1,36 @@ +export enum TierFeature { + OrgOidc = "orgOidc", + CustomAuthenticationDomain = "customAuthenticationDomain", + DeviceApprovals = "deviceApprovals", + LoginPageBranding = "loginPageBranding", + LogExport = "logExport", + AccessLogs = "accessLogs", + ActionLogs = "actionLogs", + RotateCredentials = "rotateCredentials", + MaintencePage = "maintencePage", + DevicePosture = "devicePosture", + TwoFactorEnforcement = "twoFactorEnforcement", + SessionDurationPolicies = "sessionDurationPolicies", + PasswordExpirationPolicies = "passwordExpirationPolicies" +} + +export const tierMatrix: Record = { + [TierFeature.OrgOidc]: ["tier1", "tier2", "tier3", "enterprise"], + [TierFeature.CustomAuthenticationDomain]: [ + "tier1", + "tier2", + "tier3", + "enterprise" + ], + [TierFeature.DeviceApprovals]: ["tier1", "tier3", "enterprise"], + [TierFeature.LoginPageBranding]: ["tier1", "tier3", "enterprise"], + [TierFeature.LogExport]: ["tier3", "enterprise"], + [TierFeature.AccessLogs]: ["tier2", "tier3", "enterprise"], + [TierFeature.ActionLogs]: ["tier2", "tier3", "enterprise"], + [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"] +}; diff --git a/server/lib/blueprints/proxyResources.ts b/server/lib/blueprints/proxyResources.ts index c0faad63..93ddfdfb 100644 --- a/server/lib/blueprints/proxyResources.ts +++ b/server/lib/blueprints/proxyResources.ts @@ -32,7 +32,8 @@ import { resourcePassword } from "@server/db"; import { hashPassword } from "@server/auth/password"; import { isValidCIDR, isValidIP, isValidUrlGlobPattern } from "../validators"; import { isLicensedOrSubscribed } from "#dynamic/lib/isLicencedOrSubscribed"; -import { build } from "@server/build"; +import { tierMatrix } from "../billing/tierMatrix"; +import { t } from "@faker-js/faker/dist/airline-DF6RqYmq"; export type ProxyResourcesResults = { proxyResource: Resource; @@ -212,7 +213,7 @@ export async function updateProxyResources( } else { // Update existing resource - const isLicensed = await isLicensedOrSubscribed(orgId); + const isLicensed = await isLicensedOrSubscribed(orgId, tierMatrix.maintencePage); if (!isLicensed) { resourceData.maintenance = undefined; } @@ -648,7 +649,7 @@ export async function updateProxyResources( ); } - const isLicensed = await isLicensedOrSubscribed(orgId); + const isLicensed = await isLicensedOrSubscribed(orgId, tierMatrix.maintencePage); if (!isLicensed) { resourceData.maintenance = undefined; } diff --git a/server/lib/calculateUserClientsForOrgs.ts b/server/lib/calculateUserClientsForOrgs.ts index 15837890..4be76ddd 100644 --- a/server/lib/calculateUserClientsForOrgs.ts +++ b/server/lib/calculateUserClientsForOrgs.ts @@ -20,6 +20,7 @@ import { sendTerminateClient } from "@server/routers/client/terminate"; import { and, eq, notInArray, type InferInsertModel } from "drizzle-orm"; import { rebuildClientAssociationsFromClient } from "./rebuildClientAssociations"; import { OlmErrorCodes } from "@server/routers/olm/error"; +import { tierMatrix } from "./billing/tierMatrix"; export async function calculateUserClientsForOrgs( userId: string, @@ -189,7 +190,8 @@ export async function calculateUserClientsForOrgs( const niceId = await getUniqueClientName(orgId); const isOrgLicensed = await isLicensedOrSubscribed( - userOrg.orgId + userOrg.orgId, + tierMatrix.deviceApprovals ); const requireApproval = build !== "oss" && diff --git a/server/private/middlewares/verifySubscription.ts b/server/private/middlewares/verifySubscription.ts index 3ab351a1..0c28f7aa 100644 --- a/server/private/middlewares/verifySubscription.ts +++ b/server/private/middlewares/verifySubscription.ts @@ -45,7 +45,7 @@ export function verifyValidSubscription(tiers: string[]) { const { tier, active } = await getOrgTierData(orgId); const isTier = tiers.includes(tier || ""); - if (!isTier || !active) { + if (!active) { return next( createHttpError( HttpCode.FORBIDDEN, @@ -53,6 +53,14 @@ export function verifyValidSubscription(tiers: string[]) { ) ); } + if (!isTier) { + return next( + createHttpError( + HttpCode.FORBIDDEN, + "Organization subscription tier does not have access to this feature" + ) + ); + } return next(); } catch (e) { diff --git a/server/private/routers/external.ts b/server/private/routers/external.ts index 37048c34..a2ffae05 100644 --- a/server/private/routers/external.ts +++ b/server/private/routers/external.ts @@ -52,6 +52,7 @@ import { authenticated as a, authRouter as aa } from "@server/routers/external"; +import { tierMatrix } from "@server/lib/billing/tierMatrix"; export const authenticated = a; export const unauthenticated = ua; @@ -76,7 +77,7 @@ unauthenticated.post( authenticated.put( "/org/:orgId/idp/oidc", verifyValidLicense, - verifyValidSubscription, + verifyValidSubscription(tierMatrix.orgOidc), verifyOrgAccess, verifyUserHasAction(ActionsEnum.createIdp), logActionAudit(ActionsEnum.createIdp), @@ -86,7 +87,7 @@ authenticated.put( authenticated.post( "/org/:orgId/idp/:idpId/oidc", verifyValidLicense, - verifyValidSubscription(), + verifyValidSubscription(tierMatrix.orgOidc), verifyOrgAccess, verifyIdpAccess, verifyUserHasAction(ActionsEnum.updateIdp), @@ -279,7 +280,7 @@ authenticated.delete( authenticated.put( "/org/:orgId/login-page", verifyValidLicense, - verifyValidSubscription, + verifyValidSubscription(tierMatrix.customAuthenticationDomain), verifyOrgAccess, verifyUserHasAction(ActionsEnum.createLoginPage), logActionAudit(ActionsEnum.createLoginPage), @@ -289,7 +290,7 @@ authenticated.put( authenticated.post( "/org/:orgId/login-page/:loginPageId", verifyValidLicense, - verifyValidSubscription, + verifyValidSubscription(tierMatrix.customAuthenticationDomain), verifyOrgAccess, verifyLoginPageAccess, verifyUserHasAction(ActionsEnum.updateLoginPage), @@ -318,7 +319,7 @@ authenticated.get( authenticated.get( "/org/:orgId/approvals", verifyValidLicense, - verifyValidSubscription, + verifyValidSubscription(tierMatrix.deviceApprovals), verifyOrgAccess, verifyUserHasAction(ActionsEnum.listApprovals), logActionAudit(ActionsEnum.listApprovals), @@ -335,7 +336,7 @@ authenticated.get( authenticated.put( "/org/:orgId/approvals/:approvalId", verifyValidLicense, - verifyValidSubscription, + verifyValidSubscription(tierMatrix.deviceApprovals), verifyOrgAccess, verifyUserHasAction(ActionsEnum.updateApprovals), logActionAudit(ActionsEnum.updateApprovals), @@ -345,7 +346,7 @@ authenticated.put( authenticated.get( "/org/:orgId/login-page-branding", verifyValidLicense, - verifyValidSubscription, + verifyValidSubscription(tierMatrix.loginPageBranding), verifyOrgAccess, verifyUserHasAction(ActionsEnum.getLoginPage), logActionAudit(ActionsEnum.getLoginPage), @@ -355,7 +356,7 @@ authenticated.get( authenticated.put( "/org/:orgId/login-page-branding", verifyValidLicense, - verifyValidSubscription, + verifyValidSubscription(tierMatrix.loginPageBranding), verifyOrgAccess, verifyUserHasAction(ActionsEnum.updateLoginPage), logActionAudit(ActionsEnum.updateLoginPage), @@ -365,7 +366,6 @@ authenticated.put( authenticated.delete( "/org/:orgId/login-page-branding", verifyValidLicense, - verifyValidSubscription, verifyOrgAccess, verifyUserHasAction(ActionsEnum.deleteLoginPage), logActionAudit(ActionsEnum.deleteLoginPage), @@ -433,7 +433,7 @@ authenticated.post( authenticated.get( "/org/:orgId/logs/action", verifyValidLicense, - verifyValidSubscription, + verifyValidSubscription(tierMatrix.actionLogs), verifyOrgAccess, verifyUserHasAction(ActionsEnum.exportLogs), logs.queryActionAuditLogs @@ -442,7 +442,7 @@ authenticated.get( authenticated.get( "/org/:orgId/logs/action/export", verifyValidLicense, - verifyValidSubscription, + verifyValidSubscription(tierMatrix.logExport), verifyOrgAccess, verifyUserHasAction(ActionsEnum.exportLogs), logActionAudit(ActionsEnum.exportLogs), @@ -452,7 +452,7 @@ authenticated.get( authenticated.get( "/org/:orgId/logs/access", verifyValidLicense, - verifyValidSubscription, + verifyValidSubscription(tierMatrix.accessLogs), verifyOrgAccess, verifyUserHasAction(ActionsEnum.exportLogs), logs.queryAccessAuditLogs @@ -461,7 +461,7 @@ authenticated.get( authenticated.get( "/org/:orgId/logs/access/export", verifyValidLicense, - verifyValidSubscription, + verifyValidSubscription(tierMatrix.logExport), verifyOrgAccess, verifyUserHasAction(ActionsEnum.exportLogs), logActionAudit(ActionsEnum.exportLogs), @@ -472,7 +472,7 @@ authenticated.post( "/re-key/:clientId/regenerate-client-secret", verifyClientAccess, // this is first to set the org id verifyValidLicense, - verifyValidSubscription, + verifyValidSubscription(tierMatrix.rotateCredentials), verifyUserHasAction(ActionsEnum.reGenerateSecret), reKey.reGenerateClientSecret ); @@ -481,7 +481,7 @@ authenticated.post( "/re-key/:siteId/regenerate-site-secret", verifySiteAccess, // this is first to set the org id verifyValidLicense, - verifyValidSubscription, + verifyValidSubscription(tierMatrix.rotateCredentials), verifyUserHasAction(ActionsEnum.reGenerateSecret), reKey.reGenerateSiteSecret ); @@ -489,7 +489,7 @@ authenticated.post( authenticated.put( "/re-key/:orgId/regenerate-remote-exit-node-secret", verifyValidLicense, - verifyValidSubscription, + verifyValidSubscription(tierMatrix.rotateCredentials), verifyOrgAccess, verifyUserHasAction(ActionsEnum.reGenerateSecret), reKey.reGenerateExitNodeSecret diff --git a/server/routers/client/getClient.ts b/server/routers/client/getClient.ts index b8b5594e..bb2ff8fd 100644 --- a/server/routers/client/getClient.ts +++ b/server/routers/client/getClient.ts @@ -13,6 +13,7 @@ import { OpenAPITags, registry } from "@server/openApi"; import { getUserDeviceName } from "@server/db/names"; import { build } from "@server/build"; import { isLicensedOrSubscribed } from "#dynamic/lib/isLicencedOrSubscribed"; +import { tierMatrix } from "@server/lib/billing/tierMatrix"; const getClientSchema = z.strictObject({ clientId: z @@ -327,7 +328,8 @@ export async function getClient( client.currentFingerprint ); const isOrgLicensed = await isLicensedOrSubscribed( - client.clients.orgId + client.clients.orgId, + tierMatrix.devicePosture ); const postureData: PostureData | null = rawPosture ? isOrgLicensed diff --git a/server/routers/newt/getNewtToken.ts b/server/routers/newt/getNewtToken.ts index 63797358..8f7e01d2 100644 --- a/server/routers/newt/getNewtToken.ts +++ b/server/routers/newt/getNewtToken.ts @@ -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() }); diff --git a/server/routers/org/updateOrg.ts b/server/routers/org/updateOrg.ts index 4762c32f..707691f5 100644 --- a/server/routers/org/updateOrg.ts +++ b/server/routers/org/updateOrg.ts @@ -12,7 +12,8 @@ import { OpenAPITags, registry } from "@server/openApi"; import { build } from "@server/build"; import { cache } from "@server/lib/cache"; import { isLicensedOrSubscribed } from "#dynamic/lib/isLicencedOrSubscribed"; -import { isSubscribed } from "#dynamic/lib/isSubscribed"; +import { TierFeature, tierMatrix } from "@server/lib/billing/tierMatrix"; +import { getOrgTierData } from "#dynamic/lib/billing"; const updateOrgParamsSchema = z.strictObject({ orgId: z.string() @@ -87,26 +88,83 @@ export async function updateOrg( const { orgId } = parsedParams.data; - const isLicensed = await isLicensedOrSubscribed(orgId); - if (!isLicensed) { + // Check 2FA enforcement feature + const has2FAFeature = await isLicensedOrSubscribed( + orgId, + tierMatrix[TierFeature.TwoFactorEnforcement] + ); + if (!has2FAFeature) { parsedBody.data.requireTwoFactor = undefined; - parsedBody.data.maxSessionLengthHours = undefined; - parsedBody.data.passwordExpiryDays = undefined; } - const subscribed = await isSubscribed(orgId); - if ( - build == "saas" && - subscribed && - parsedBody.data.settingsLogRetentionDaysRequest && - parsedBody.data.settingsLogRetentionDaysRequest > 30 - ) { - return next( - createHttpError( - HttpCode.FORBIDDEN, - "You are not allowed to set log retention days greater than 30 with your current subscription" - ) - ); + // Check session duration policies feature + const hasSessionDurationFeature = await isLicensedOrSubscribed( + orgId, + tierMatrix[TierFeature.SessionDurationPolicies] + ); + if (!hasSessionDurationFeature) { + parsedBody.data.maxSessionLengthHours = undefined; + } + + // Check password expiration policies feature + const hasPasswordExpirationFeature = await isLicensedOrSubscribed( + orgId, + tierMatrix[TierFeature.PasswordExpirationPolicies] + ); + if (!hasPasswordExpirationFeature) { + parsedBody.data.passwordExpiryDays = undefined; + } + if (build == "saas") { + const { tier } = await getOrgTierData(orgId); + + // Determine max allowed retention days based on tier + let maxRetentionDays: number | null = null; + if (!tier) { + maxRetentionDays = 0; + } else if (tier === "tier1") { + maxRetentionDays = 7; + } else if (tier === "tier2") { + maxRetentionDays = 30; + } else if (tier === "tier3") { + maxRetentionDays = 90; + } + // For enterprise tier, no check (maxRetentionDays remains null) + + if (maxRetentionDays !== null) { + if ( + parsedBody.data.settingsLogRetentionDaysRequest !== undefined && + parsedBody.data.settingsLogRetentionDaysRequest > maxRetentionDays + ) { + return next( + createHttpError( + HttpCode.FORBIDDEN, + `You are not allowed to set log retention days greater than ${maxRetentionDays} with your current subscription` + ) + ); + } + if ( + parsedBody.data.settingsLogRetentionDaysAccess !== undefined && + parsedBody.data.settingsLogRetentionDaysAccess > maxRetentionDays + ) { + return next( + createHttpError( + HttpCode.FORBIDDEN, + `You are not allowed to set log retention days greater than ${maxRetentionDays} with your current subscription` + ) + ); + } + if ( + parsedBody.data.settingsLogRetentionDaysAction !== undefined && + parsedBody.data.settingsLogRetentionDaysAction > maxRetentionDays + ) { + return next( + createHttpError( + HttpCode.FORBIDDEN, + `You are not allowed to set log retention days greater than ${maxRetentionDays} with your current subscription` + ) + ); + } + } } const updatedOrg = await db From a095dddd01ba4e22cdbdd48b3efbee780f02adf3 Mon Sep 17 00:00:00 2001 From: miloschwartz Date: Mon, 9 Feb 2026 18:17:18 -0800 Subject: [PATCH 23/49] use pricing matrix in existing usePaidStatus funcitons --- server/lib/billing/tierMatrix.ts | 4 +++- server/lib/isLicencedOrSubscribed.ts | 4 +++- server/lib/isSubscribed.ts | 4 +++- server/private/lib/isLicencedOrSubscribed.ts | 3 ++- server/private/lib/isSubscribed.ts | 3 ++- server/private/middlewares/verifySubscription.ts | 3 ++- .../settings/clients/user/[niceId]/general/page.tsx | 10 +++++++--- .../resources/proxy/[niceId]/authentication/page.tsx | 3 ++- src/components/ApprovalFeed.tsx | 3 ++- src/components/PaidFeaturesAlert.tsx | 6 +++--- src/contexts/subscriptionStatusContext.ts | 3 ++- src/hooks/usePaidStatus.ts | 4 ++-- 12 files changed, 33 insertions(+), 17 deletions(-) diff --git a/server/lib/billing/tierMatrix.ts b/server/lib/billing/tierMatrix.ts index 3e5eb0f6..ad1f33c4 100644 --- a/server/lib/billing/tierMatrix.ts +++ b/server/lib/billing/tierMatrix.ts @@ -1,3 +1,5 @@ +import { Tier } from "@server/types/Tiers"; + export enum TierFeature { OrgOidc = "orgOidc", CustomAuthenticationDomain = "customAuthenticationDomain", @@ -14,7 +16,7 @@ export enum TierFeature { PasswordExpirationPolicies = "passwordExpirationPolicies" } -export const tierMatrix: Record = { +export const tierMatrix: Record = { [TierFeature.OrgOidc]: ["tier1", "tier2", "tier3", "enterprise"], [TierFeature.CustomAuthenticationDomain]: [ "tier1", diff --git a/server/lib/isLicencedOrSubscribed.ts b/server/lib/isLicencedOrSubscribed.ts index a04d44aa..9ebe1922 100644 --- a/server/lib/isLicencedOrSubscribed.ts +++ b/server/lib/isLicencedOrSubscribed.ts @@ -1,6 +1,8 @@ +import { Tier } from "@server/types/Tiers"; + export async function isLicensedOrSubscribed( orgId: string, - tiers: string[] + tiers: Tier[] ): Promise { return false; } diff --git a/server/lib/isSubscribed.ts b/server/lib/isSubscribed.ts index 306ab871..533eec91 100644 --- a/server/lib/isSubscribed.ts +++ b/server/lib/isSubscribed.ts @@ -1,6 +1,8 @@ +import { Tier } from "@server/types/Tiers"; + export async function isSubscribed( orgId: string, - tiers: string[] + tiers: Tier[] ): Promise { return false; } diff --git a/server/private/lib/isLicencedOrSubscribed.ts b/server/private/lib/isLicencedOrSubscribed.ts index 3f8d2a6d..d6063c6c 100644 --- a/server/private/lib/isLicencedOrSubscribed.ts +++ b/server/private/lib/isLicencedOrSubscribed.ts @@ -14,10 +14,11 @@ import { build } from "@server/build"; import license from "#private/license/license"; import { isSubscribed } from "#private/lib/isSubscribed"; +import { Tier } from "@server/types/Tiers"; export async function isLicensedOrSubscribed( orgId: string, - tiers: string[] + tiers: Tier[] ): Promise { if (build === "enterprise") { return await license.isUnlocked(); diff --git a/server/private/lib/isSubscribed.ts b/server/private/lib/isSubscribed.ts index 23ffc698..e6e4c877 100644 --- a/server/private/lib/isSubscribed.ts +++ b/server/private/lib/isSubscribed.ts @@ -13,10 +13,11 @@ import { build } from "@server/build"; import { getOrgTierData } from "#private/lib/billing"; +import { Tier } from "@server/types/Tiers"; export async function isSubscribed( orgId: string, - tiers: string[] + tiers: Tier[] ): Promise { if (build === "saas") { const { tier, active } = await getOrgTierData(orgId); diff --git a/server/private/middlewares/verifySubscription.ts b/server/private/middlewares/verifySubscription.ts index 0c28f7aa..9673f8f5 100644 --- a/server/private/middlewares/verifySubscription.ts +++ b/server/private/middlewares/verifySubscription.ts @@ -16,8 +16,9 @@ import createHttpError from "http-errors"; import HttpCode from "@server/types/HttpCode"; import { build } from "@server/build"; import { getOrgTierData } from "#private/lib/billing"; +import { Tier } from "@server/types/Tiers"; -export function verifyValidSubscription(tiers: string[]) { +export function verifyValidSubscription(tiers: Tier[]) { return async function ( req: Request, res: Response, diff --git a/src/app/[orgId]/settings/clients/user/[niceId]/general/page.tsx b/src/app/[orgId]/settings/clients/user/[niceId]/general/page.tsx index 013bdd47..e5b9de66 100644 --- a/src/app/[orgId]/settings/clients/user/[niceId]/general/page.tsx +++ b/src/app/[orgId]/settings/clients/user/[niceId]/general/page.tsx @@ -40,6 +40,7 @@ import { import { useParams } from "next/navigation"; import { FaApple, FaWindows, FaLinux } from "react-icons/fa"; import { SiAndroid } from "react-icons/si"; +import { tierMatrix } from "@server/lib/billing/tierMatrix"; function formatTimestamp(timestamp: number | null | undefined): string { if (!timestamp) return "-"; @@ -156,8 +157,11 @@ export default function GeneralPage() { const showApprovalFeatures = build !== "oss" && isPaidUser; - const formatPostureValue = (value: boolean | null | undefined) => { - if (value === null || value === undefined) return "-"; + const formatPostureValue = ( + value: boolean | null | undefined | "-" + ) => { + if (value === null || value === undefined || value === "-") + return "-"; return (
{value ? ( @@ -594,7 +598,7 @@ export default function GeneralPage() { {t("biometricsEnabled")} - {isPaidUser + {isPaidUser(tierMatrix.devicePosture) ? formatPostureValue( client.posture .biometricsEnabled diff --git a/src/app/[orgId]/settings/resources/proxy/[niceId]/authentication/page.tsx b/src/app/[orgId]/settings/resources/proxy/[niceId]/authentication/page.tsx index 3dedea05..121e7196 100644 --- a/src/app/[orgId]/settings/resources/proxy/[niceId]/authentication/page.tsx +++ b/src/app/[orgId]/settings/resources/proxy/[niceId]/authentication/page.tsx @@ -44,6 +44,7 @@ import { getUserDisplayName } from "@app/lib/getUserDisplayName"; import { orgQueries, resourceQueries } from "@app/lib/queries"; import { zodResolver } from "@hookform/resolvers/zod"; import { build } from "@server/build"; +import { tierMatrix } from "@server/lib/billing/tierMatrix"; import { UserType } from "@server/types/UserTypes"; import { useQuery, useQueryClient } from "@tanstack/react-query"; import SetResourcePasswordForm from "components/SetResourcePasswordForm"; @@ -164,7 +165,7 @@ export default function ResourceAuthenticationPage() { const allIdps = useMemo(() => { if (build === "saas") { - if (isPaidUser) { + if (isPaidUser(tierMatrix.orgOidc)) { return orgIdps.map((idp) => ({ id: idp.idpId, text: idp.name diff --git a/src/components/ApprovalFeed.tsx b/src/components/ApprovalFeed.tsx index e587354b..87a9d11a 100644 --- a/src/components/ApprovalFeed.tsx +++ b/src/components/ApprovalFeed.tsx @@ -31,6 +31,7 @@ import { Separator } from "./ui/separator"; import { InfoPopup } from "./ui/info-popup"; import { ApprovalsEmptyState } from "./ApprovalsEmptyState"; import { usePaidStatus } from "@app/hooks/usePaidStatus"; +import { tierMatrix } from "@server/lib/billing/tierMatrix"; export type ApprovalFeedProps = { orgId: string; @@ -55,7 +56,7 @@ export function ApprovalFeed({ const { data, isFetching, refetch } = useQuery({ ...approvalQueries.listApprovals(orgId, filters), - enabled: isPaidUser + enabled: isPaidUser(tierMatrix.deviceApprovals) }); const approvals = data?.approvals ?? []; diff --git a/src/components/PaidFeaturesAlert.tsx b/src/components/PaidFeaturesAlert.tsx index b2c96ab8..11b88fbb 100644 --- a/src/components/PaidFeaturesAlert.tsx +++ b/src/components/PaidFeaturesAlert.tsx @@ -47,10 +47,10 @@ export function PaidFeaturesAlert() { ) : null} {build === "oss" && !hasEnterpriseLicense ? ( - +
- + {t.rich("ossEnterpriseEditionRequired", { enterpriseEditionLink: (chunks) => ( @@ -58,7 +58,7 @@ export function PaidFeaturesAlert() { href="https://docs.pangolin.net/self-host/enterprise-edition" target="_blank" rel="noopener noreferrer" - className="inline-flex items-center gap-1 font-medium text-foreground underline" + className="inline-flex items-center gap-1 font-medium text-purple-600 underline" > {chunks} diff --git a/src/contexts/subscriptionStatusContext.ts b/src/contexts/subscriptionStatusContext.ts index a3efc67f..95946350 100644 --- a/src/contexts/subscriptionStatusContext.ts +++ b/src/contexts/subscriptionStatusContext.ts @@ -1,10 +1,11 @@ import { GetOrgSubscriptionResponse } from "@server/routers/billing/types"; +import { Tier } from "@server/types/Tiers"; import { createContext } from "react"; type SubscriptionStatusContextType = { subscriptionStatus: GetOrgSubscriptionResponse | null; updateSubscriptionStatus: (updatedSite: GetOrgSubscriptionResponse) => void; - getTier: () => { tier: string | null; active: boolean }; + getTier: () => { tier: Tier | null; active: boolean }; isSubscribed: () => boolean; subscribed: boolean; }; diff --git a/src/hooks/usePaidStatus.ts b/src/hooks/usePaidStatus.ts index db36519a..41244a07 100644 --- a/src/hooks/usePaidStatus.ts +++ b/src/hooks/usePaidStatus.ts @@ -1,7 +1,7 @@ import { build } from "@server/build"; import { useLicenseStatusContext } from "./useLicenseStatusContext"; import { useSubscriptionStatusContext } from "./useSubscriptionStatusContext"; -import { Tier } from "@server/lib/tiers"; +import { Tier } from "@server/types/Tiers"; export function usePaidStatus() { const { isUnlocked } = useLicenseStatusContext(); @@ -12,7 +12,7 @@ export function usePaidStatus() { const tierData = subscription?.getTier(); const hasSaasSubscription = build === "saas" && tierData?.active; - function isPaidUser(tiers: Tier): boolean { + function isPaidUser(tiers: Tier[]): boolean { if (hasEnterpriseLicense) { return true; } From 13b4fc67252211713b94f275731654622d18ded0 Mon Sep 17 00:00:00 2001 From: Owen Date: Mon, 9 Feb 2026 19:52:28 -0800 Subject: [PATCH 24/49] Add more tier matrix checks --- server/lib/billing/tierMatrix.ts | 4 ++-- server/lib/blueprints/proxyResources.ts | 1 - server/routers/badger/verifySession.ts | 22 +++++++++++++++++----- server/routers/idp/generateOidcUrl.ts | 6 +++++- server/routers/resource/updateResource.ts | 3 ++- server/routers/role/createRole.ts | 3 ++- server/routers/role/updateRole.ts | 3 ++- server/routers/user/createOrgUser.ts | 6 +++++- 8 files changed, 35 insertions(+), 13 deletions(-) diff --git a/server/lib/billing/tierMatrix.ts b/server/lib/billing/tierMatrix.ts index ad1f33c4..bc5cb950 100644 --- a/server/lib/billing/tierMatrix.ts +++ b/server/lib/billing/tierMatrix.ts @@ -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", @@ -18,7 +18,7 @@ export enum TierFeature { export const tierMatrix: Record = { [TierFeature.OrgOidc]: ["tier1", "tier2", "tier3", "enterprise"], - [TierFeature.CustomAuthenticationDomain]: [ + [TierFeature.LoginPageDomain]: [ "tier1", "tier2", "tier3", diff --git a/server/lib/blueprints/proxyResources.ts b/server/lib/blueprints/proxyResources.ts index 93ddfdfb..55a7712b 100644 --- a/server/lib/blueprints/proxyResources.ts +++ b/server/lib/blueprints/proxyResources.ts @@ -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; diff --git a/server/routers/badger/verifySession.ts b/server/routers/badger/verifySession.ts index fa81b6f9..828960d1 100644 --- a/server/routers/badger/verifySession.ts +++ b/server/routers/badger/verifySession.ts @@ -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( diff --git a/server/routers/idp/generateOidcUrl.ts b/server/routers/idp/generateOidcUrl.ts index 46f34ef5..646dc2c4 100644 --- a/server/routers/idp/generateOidcUrl.ts +++ b/server/routers/idp/generateOidcUrl.ts @@ -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( diff --git a/server/routers/resource/updateResource.ts b/server/routers/resource/updateResource.ts index 62a466d7..79b59a2a 100644 --- a/server/routers/resource/updateResource.ts +++ b/server/routers/resource/updateResource.ts @@ -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; diff --git a/server/routers/role/createRole.ts b/server/routers/role/createRole.ts index 666eb756..edb8f1bd 100644 --- a/server/routers/role/createRole.ts +++ b/server/routers/role/createRole.ts @@ -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; } diff --git a/server/routers/role/updateRole.ts b/server/routers/role/updateRole.ts index 6724d622..51a33e32 100644 --- a/server/routers/role/updateRole.ts +++ b/server/routers/role/updateRole.ts @@ -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; } diff --git a/server/routers/user/createOrgUser.ts b/server/routers/user/createOrgUser.ts index cd57943b..cac912f9 100644 --- a/server/routers/user/createOrgUser.ts +++ b/server/routers/user/createOrgUser.ts @@ -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( From 0a6301697ee5337396f89b85b2ad6bea49393b6d Mon Sep 17 00:00:00 2001 From: Owen Date: Mon, 9 Feb 2026 20:11:24 -0800 Subject: [PATCH 25/49] Handle auto provisioning --- server/lib/billing/tierMatrix.ts | 32 +++++++++++++------ .../routers/orgIdp/createOrgOidcIdp.ts | 13 +++++++- .../routers/orgIdp/updateOrgOidcIdp.ts | 13 +++++++- server/routers/idp/validateOidcCallback.ts | 29 +++++++++++++++++ 4 files changed, 75 insertions(+), 12 deletions(-) diff --git a/server/lib/billing/tierMatrix.ts b/server/lib/billing/tierMatrix.ts index bc5cb950..e878f8fe 100644 --- a/server/lib/billing/tierMatrix.ts +++ b/server/lib/billing/tierMatrix.ts @@ -13,17 +13,13 @@ export enum TierFeature { DevicePosture = "devicePosture", TwoFactorEnforcement = "twoFactorEnforcement", SessionDurationPolicies = "sessionDurationPolicies", - PasswordExpirationPolicies = "passwordExpirationPolicies" + PasswordExpirationPolicies = "passwordExpirationPolicies", + AutoProvisioning = "autoProvisioning" } export const tierMatrix: Record = { [TierFeature.OrgOidc]: ["tier1", "tier2", "tier3", "enterprise"], - [TierFeature.LoginPageDomain]: [ - "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.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"] }; diff --git a/server/private/routers/orgIdp/createOrgOidcIdp.ts b/server/private/routers/orgIdp/createOrgOidcIdp.ts index bee04340..23fa4b7c 100644 --- a/server/private/routers/orgIdp/createOrgOidcIdp.ts +++ b/server/private/routers/orgIdp/createOrgOidcIdp.ts @@ -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); diff --git a/server/private/routers/orgIdp/updateOrgOidcIdp.ts b/server/private/routers/orgIdp/updateOrgOidcIdp.ts index e01bdba0..d83ff569 100644 --- a/server/private/routers/orgIdp/updateOrgOidcIdp.ts +++ b/server/private/routers/orgIdp/updateOrgOidcIdp.ts @@ -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() diff --git a/server/routers/idp/validateOidcCallback.ts b/server/routers/idp/validateOidcCallback.ts index 22c49f42..7831e560 100644 --- a/server/routers/idp/validateOidcCallback.ts +++ b/server/routers/idp/validateOidcCallback.ts @@ -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); } From ba5ae6ed04e3d6dce0f83fb8dc9d12931d1873db Mon Sep 17 00:00:00 2001 From: Owen Date: Mon, 9 Feb 2026 20:17:14 -0800 Subject: [PATCH 26/49] Fix errors --- server/private/routers/external.ts | 4 ++-- server/routers/newt/getNewtToken.ts | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/server/private/routers/external.ts b/server/private/routers/external.ts index a2ffae05..74ca7872 100644 --- a/server/private/routers/external.ts +++ b/server/private/routers/external.ts @@ -280,7 +280,7 @@ authenticated.delete( authenticated.put( "/org/:orgId/login-page", verifyValidLicense, - verifyValidSubscription(tierMatrix.customAuthenticationDomain), + verifyValidSubscription(tierMatrix.loginPageDomain), verifyOrgAccess, verifyUserHasAction(ActionsEnum.createLoginPage), logActionAudit(ActionsEnum.createLoginPage), @@ -290,7 +290,7 @@ authenticated.put( authenticated.post( "/org/:orgId/login-page/:loginPageId", verifyValidLicense, - verifyValidSubscription(tierMatrix.customAuthenticationDomain), + verifyValidSubscription(tierMatrix.loginPageDomain), verifyOrgAccess, verifyLoginPageAccess, verifyUserHasAction(ActionsEnum.updateLoginPage), diff --git a/server/routers/newt/getNewtToken.ts b/server/routers/newt/getNewtToken.ts index 8f7e01d2..63797358 100644 --- a/server/routers/newt/getNewtToken.ts +++ b/server/routers/newt/getNewtToken.ts @@ -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() }); From ed40eae655818bc867d9ed4e9e5c1001fd775192 Mon Sep 17 00:00:00 2001 From: miloschwartz Date: Mon, 9 Feb 2026 20:23:55 -0800 Subject: [PATCH 27/49] fix some errors --- .../private/middlewares/verifySubscription.ts | 2 +- .../hooks/handleSubscriptionUpdated.ts | 2 +- .../settings/(private)/billing/page.tsx | 34 ------------------- .../clients/user/[niceId]/general/page.tsx | 20 +++++------ 4 files changed, 12 insertions(+), 46 deletions(-) diff --git a/server/private/middlewares/verifySubscription.ts b/server/private/middlewares/verifySubscription.ts index 9673f8f5..8a8f8e3b 100644 --- a/server/private/middlewares/verifySubscription.ts +++ b/server/private/middlewares/verifySubscription.ts @@ -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( diff --git a/server/private/routers/billing/hooks/handleSubscriptionUpdated.ts b/server/private/routers/billing/hooks/handleSubscriptionUpdated.ts index 83472ac0..9b36b55e 100644 --- a/server/private/routers/billing/hooks/handleSubscriptionUpdated.ts +++ b/server/private/routers/billing/hooks/handleSubscriptionUpdated.ts @@ -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( diff --git a/src/app/[orgId]/settings/(private)/billing/page.tsx b/src/app/[orgId]/settings/(private)/billing/page.tsx index 134ec204..458a9269 100644 --- a/src/app/[orgId]/settings/(private)/billing/page.tsx +++ b/src/app/[orgId]/settings/(private)/billing/page.tsx @@ -37,10 +37,6 @@ import { cn } from "@app/lib/cn"; import { CreditCard, ExternalLink, - Users, - Globe, - Server, - Layout, Check } from "lucide-react"; import { @@ -50,7 +46,6 @@ 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"; // Plan tier definitions matching the mockup type PlanId = "free" | "homelab" | "team" | "business" | "enterprise"; @@ -63,35 +58,6 @@ interface PlanOption { tierType: Tier | null; } -// Tier limits for display in confirmation dialog -interface TierLimits { - sites: number; - users: number; - domains: number; - remoteNodes: number; -} - -const tierLimits: Record = { - 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", diff --git a/src/app/[orgId]/settings/clients/user/[niceId]/general/page.tsx b/src/app/[orgId]/settings/clients/user/[niceId]/general/page.tsx index e5b9de66..d39b5c5b 100644 --- a/src/app/[orgId]/settings/clients/user/[niceId]/general/page.tsx +++ b/src/app/[orgId]/settings/clients/user/[niceId]/general/page.tsx @@ -616,7 +616,7 @@ export default function GeneralPage() { {t("diskEncrypted")} - {isPaidUser + {isPaidUser(tierMatrix.devicePosture) ? formatPostureValue( client.posture .diskEncrypted @@ -634,7 +634,7 @@ export default function GeneralPage() { {t("firewallEnabled")} - {isPaidUser + {isPaidUser(tierMatrix.devicePosture) ? formatPostureValue( client.posture .firewallEnabled @@ -653,7 +653,7 @@ export default function GeneralPage() { {t("autoUpdatesEnabled")} - {isPaidUser + {isPaidUser(tierMatrix.devicePosture) ? formatPostureValue( client.posture .autoUpdatesEnabled @@ -671,7 +671,7 @@ export default function GeneralPage() { {t("tpmAvailable")} - {isPaidUser + {isPaidUser(tierMatrix.devicePosture) ? formatPostureValue( client.posture .tpmAvailable @@ -693,7 +693,7 @@ export default function GeneralPage() { )} - {isPaidUser + {isPaidUser(tierMatrix.devicePosture) ? formatPostureValue( client.posture .windowsAntivirusEnabled @@ -711,7 +711,7 @@ export default function GeneralPage() { {t("macosSipEnabled")} - {isPaidUser + {isPaidUser(tierMatrix.devicePosture) ? formatPostureValue( client.posture .macosSipEnabled @@ -733,7 +733,7 @@ export default function GeneralPage() { )} - {isPaidUser + {isPaidUser(tierMatrix.devicePosture) ? formatPostureValue( client.posture .macosGatekeeperEnabled @@ -755,7 +755,7 @@ export default function GeneralPage() { )} - {isPaidUser + {isPaidUser(tierMatrix.devicePosture) ? formatPostureValue( client.posture .macosFirewallStealthMode @@ -774,7 +774,7 @@ export default function GeneralPage() { {t("linuxAppArmorEnabled")} - {isPaidUser + {isPaidUser(tierMatrix.devicePosture) ? formatPostureValue( client.posture .linuxAppArmorEnabled @@ -793,7 +793,7 @@ export default function GeneralPage() { {t("linuxSELinuxEnabled")} - {isPaidUser + {isPaidUser(tierMatrix.devicePosture) ? formatPostureValue( client.posture .linuxSELinuxEnabled From f531def0d261f7b8a9b7555c3b487169e5c2ced9 Mon Sep 17 00:00:00 2001 From: Owen Date: Mon, 9 Feb 2026 20:30:28 -0800 Subject: [PATCH 28/49] Comment out stripe usage reporting --- server/lib/billing/usageService.ts | 65 ++++++++++++--------------- server/private/lib/readConfigFile.ts | 1 - server/private/routers/integration.ts | 14 +++--- 3 files changed, 35 insertions(+), 45 deletions(-) diff --git a/server/lib/billing/usageService.ts b/server/lib/billing/usageService.ts index 6f95b96a..c4ae2925 100644 --- a/server/lib/billing/usageService.ts +++ b/server/lib/billing/usageService.ts @@ -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}:`, diff --git a/server/private/lib/readConfigFile.ts b/server/private/lib/readConfigFile.ts index 3a5597a0..6ddd3b99 100644 --- a/server/private/lib/readConfigFile.ts +++ b/server/private/lib/readConfigFile.ts @@ -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({}), diff --git a/server/private/routers/integration.ts b/server/private/routers/integration.ts index 25861a54..8109bd35 100644 --- a/server/private/routers/integration.ts +++ b/server/private/routers/integration.ts @@ -26,14 +26,12 @@ import { 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 +55,7 @@ authenticated.delete( authenticated.get( "/org/:orgId/logs/action", verifyValidLicense, - verifyValidSubscription, + verifyValidSubscription(tierMatrix.actionLogs), verifyApiKeyOrgAccess, verifyApiKeyHasAction(ActionsEnum.exportLogs), logs.queryActionAuditLogs @@ -66,7 +64,7 @@ authenticated.get( authenticated.get( "/org/:orgId/logs/action/export", verifyValidLicense, - verifyValidSubscription, + verifyValidSubscription(tierMatrix.logExport), verifyApiKeyOrgAccess, verifyApiKeyHasAction(ActionsEnum.exportLogs), logActionAudit(ActionsEnum.exportLogs), @@ -76,7 +74,7 @@ authenticated.get( authenticated.get( "/org/:orgId/logs/access", verifyValidLicense, - verifyValidSubscription, + verifyValidSubscription(tierMatrix.accessLogs), verifyApiKeyOrgAccess, verifyApiKeyHasAction(ActionsEnum.exportLogs), logs.queryAccessAuditLogs @@ -85,7 +83,7 @@ authenticated.get( authenticated.get( "/org/:orgId/logs/access/export", verifyValidLicense, - verifyValidSubscription, + verifyValidSubscription(tierMatrix.logExport), verifyApiKeyOrgAccess, verifyApiKeyHasAction(ActionsEnum.exportLogs), logActionAudit(ActionsEnum.exportLogs), @@ -95,6 +93,7 @@ authenticated.get( authenticated.put( "/org/:orgId/idp/oidc", verifyValidLicense, + verifyValidSubscription(tierMatrix.orgOidc), verifyApiKeyOrgAccess, verifyApiKeyHasAction(ActionsEnum.createIdp), logActionAudit(ActionsEnum.createIdp), @@ -104,6 +103,7 @@ authenticated.put( authenticated.post( "/org/:orgId/idp/:idpId/oidc", verifyValidLicense, + verifyValidSubscription(tierMatrix.orgOidc), verifyApiKeyOrgAccess, verifyApiKeyIdpAccess, verifyApiKeyHasAction(ActionsEnum.updateIdp), From 10be9bcd561cc8af799f94e4b86dd11ce11c4330 Mon Sep 17 00:00:00 2001 From: Owen Date: Mon, 9 Feb 2026 20:39:26 -0800 Subject: [PATCH 29/49] Fix to use the limits file --- .../settings/(private)/billing/page.tsx | 85 ++++++++++++------- 1 file changed, 54 insertions(+), 31 deletions(-) diff --git a/src/app/[orgId]/settings/(private)/billing/page.tsx b/src/app/[orgId]/settings/(private)/billing/page.tsx index 458a9269..75747d23 100644 --- a/src/app/[orgId]/settings/(private)/billing/page.tsx +++ b/src/app/[orgId]/settings/(private)/billing/page.tsx @@ -34,11 +34,7 @@ import { CredenzaTitle } from "@app/components/Credenza"; import { cn } from "@app/lib/cn"; -import { - CreditCard, - ExternalLink, - Check -} from "lucide-react"; +import { CreditCard, ExternalLink, Check } from "lucide-react"; import { GetOrgSubscriptionResponse, GetOrgUsageResponse @@ -46,43 +42,45 @@ import { import { useTranslations } from "use-intl"; import Link from "next/link"; import { Tier } from "@server/types/Tiers"; +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; -} +}; 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" }, @@ -94,6 +92,34 @@ const planOptions: PlanOption[] = [ } ]; +// Tier limits mapping derived from limit sets +const tierLimits: Record = { + 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(); @@ -122,9 +148,7 @@ 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(null); // Usage IDs const SITES = "sites"; @@ -135,7 +159,7 @@ export default function BillingPage() { // Confirmation dialog state const [showConfirmDialog, setShowConfirmDialog] = useState(false); const [pendingTier, setPendingTier] = useState<{ - tier: Tier, + tier: Tier; action: "upgrade" | "downgrade"; planName: string; price: string; @@ -161,9 +185,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" ); @@ -207,9 +229,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>( @@ -323,9 +343,9 @@ export default function BillingPage() { // 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(); @@ -342,8 +362,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: () => {}, @@ -418,7 +438,10 @@ export default function BillingPage() { // Calculate current usage cost for display const getUserCount = () => getUsageValue(USERS); const getPricePerUser = () => { - console.log("Calculating price per user, tierSubscription:", tierSubscription); + console.log( + "Calculating price per user, tierSubscription:", + tierSubscription + ); if (!tierSubscription?.items) return 0; // Find the subscription item for USERS feature From 69c2212ea0634140e276c5352b6628aa45808c75 Mon Sep 17 00:00:00 2001 From: miloschwartz Date: Mon, 9 Feb 2026 20:50:36 -0800 Subject: [PATCH 30/49] refactor front end hooks --- .../(private)/idp/[idpId]/general/page.tsx | 2 +- .../settings/(private)/idp/create/page.tsx | 2 +- .../[orgId]/settings/(private)/idp/page.tsx | 2 +- .../[remoteExitNodeId]/credentials/page.tsx | 21 +++----- .../settings/access/users/create/page.tsx | 7 +-- .../machine/[niceId]/credentials/page.tsx | 21 ++------ .../settings/general/auth-page/page.tsx | 2 +- src/app/[orgId]/settings/logs/access/page.tsx | 21 +++----- src/app/[orgId]/settings/logs/action/page.tsx | 20 +++---- src/app/[orgId]/settings/logs/page.tsx | 53 +------------------ .../resources/proxy/[niceId]/general/page.tsx | 25 +++------ .../sites/[niceId]/credentials/page.tsx | 23 +++----- src/app/auth/org/page.tsx | 2 +- src/app/layout.tsx | 2 +- .../{private => }/AuthPageSettings.tsx | 31 +++++++---- .../AutoProvisionConfigWidget.tsx | 0 .../{private => }/CertificateStatus.tsx | 0 .../{private => }/IdpLoginButtons.tsx | 0 src/components/LoginOrgSelector.tsx | 4 +- .../{private => }/OrgIdpDataTable.tsx | 0 src/components/{private => }/OrgIdpTable.tsx | 2 +- src/components/OrgLoginPage.tsx | 2 +- .../{private => }/RegionSelector.tsx | 0 src/components/ResourceInfoBox.tsx | 2 +- src/components/{private => }/SplashImage.tsx | 0 .../ValidateSessionTransferToken.tsx | 0 src/hooks/usePaidStatus.ts | 21 ++++---- 27 files changed, 82 insertions(+), 183 deletions(-) rename src/components/{private => }/AuthPageSettings.tsx (94%) rename src/components/{private => }/AutoProvisionConfigWidget.tsx (100%) rename src/components/{private => }/CertificateStatus.tsx (100%) rename src/components/{private => }/IdpLoginButtons.tsx (100%) rename src/components/{private => }/OrgIdpDataTable.tsx (100%) rename src/components/{private => }/OrgIdpTable.tsx (99%) rename src/components/{private => }/RegionSelector.tsx (100%) rename src/components/{private => }/SplashImage.tsx (100%) rename src/components/{private => }/ValidateSessionTransferToken.tsx (100%) diff --git a/src/app/[orgId]/settings/(private)/idp/[idpId]/general/page.tsx b/src/app/[orgId]/settings/(private)/idp/[idpId]/general/page.tsx index 2bd3ef13..cdbe5f0e 100644 --- a/src/app/[orgId]/settings/(private)/idp/[idpId]/general/page.tsx +++ b/src/app/[orgId]/settings/(private)/idp/[idpId]/general/page.tsx @@ -46,7 +46,7 @@ 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"; export default function GeneralPage() { const { env } = useEnvContext(); diff --git a/src/app/[orgId]/settings/(private)/idp/create/page.tsx b/src/app/[orgId]/settings/(private)/idp/create/page.tsx index 2f248077..6bae6075 100644 --- a/src/app/[orgId]/settings/(private)/idp/create/page.tsx +++ b/src/app/[orgId]/settings/(private)/idp/create/page.tsx @@ -1,6 +1,6 @@ "use client"; -import AutoProvisionConfigWidget from "@app/components/private/AutoProvisionConfigWidget"; +import AutoProvisionConfigWidget from "@app/components/AutoProvisionConfigWidget"; import { SettingsContainer, SettingsSection, diff --git a/src/app/[orgId]/settings/(private)/idp/page.tsx b/src/app/[orgId]/settings/(private)/idp/page.tsx index f8d888c0..7ff12d64 100644 --- a/src/app/[orgId]/settings/(private)/idp/page.tsx +++ b/src/app/[orgId]/settings/(private)/idp/page.tsx @@ -2,7 +2,7 @@ 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"; diff --git a/src/app/[orgId]/settings/(private)/remote-exit-nodes/[remoteExitNodeId]/credentials/page.tsx b/src/app/[orgId]/settings/(private)/remote-exit-nodes/[remoteExitNodeId]/credentials/page.tsx index 2fa2b753..7ee84129 100644 --- a/src/app/[orgId]/settings/(private)/remote-exit-nodes/[remoteExitNodeId]/credentials/page.tsx +++ b/src/app/[orgId]/settings/(private)/remote-exit-nodes/[remoteExitNodeId]/credentials/page.tsx @@ -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(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< @@ -203,7 +194,7 @@ export default function CredentialsPage() { setShouldDisconnect(false); setModalOpen(true); }} - disabled={isSecurityFeatureDisabled()} + disabled={isPaidUser(tierMatrix.rotateCredentials)} > {t("regenerateCredentialsButton")} @@ -212,7 +203,7 @@ export default function CredentialsPage() { setShouldDisconnect(true); setModalOpen(true); }} - disabled={isSecurityFeatureDisabled()} + disabled={isPaidUser(tierMatrix.rotateCredentials)} > {t("remoteExitNodeRegenerateAndDisconnect")} diff --git a/src/app/[orgId]/settings/access/users/create/page.tsx b/src/app/[orgId]/settings/access/users/create/page.tsx index 7d8ad2cd..08737f5e 100644 --- a/src/app/[orgId]/settings/access/users/create/page.tsx +++ b/src/app/[orgId]/settings/access/users/create/page.tsx @@ -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( "internal" @@ -237,7 +238,7 @@ export default function Page() { } async function fetchIdps() { - if (build === "saas" && !subscription?.subscribed) { + if (build === "saas" && !hasSaasSubscription(tierMatrix.orgOidc)) { return; } diff --git a/src/app/[orgId]/settings/clients/machine/[niceId]/credentials/page.tsx b/src/app/[orgId]/settings/clients/machine/[niceId]/credentials/page.tsx index 024f4cd7..17c958ce 100644 --- a/src/app/[orgId]/settings/clients/machine/[niceId]/credentials/page.tsx +++ b/src/app/[orgId]/settings/clients/machine/[niceId]/credentials/page.tsx @@ -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 { @@ -191,7 +180,7 @@ export default function CredentialsPage() { setShouldDisconnect(false); setModalOpen(true); }} - disabled={isSecurityFeatureDisabled()} + disabled={isPaidUser(tierMatrix.rotateCredentials)} > {t("regenerateCredentialsButton")} @@ -200,7 +189,7 @@ export default function CredentialsPage() { setShouldDisconnect(true); setModalOpen(true); }} - disabled={isSecurityFeatureDisabled()} + disabled={isPaidUser(tierMatrix.rotateCredentials)} > {t("clientRegenerateAndDisconnect")} diff --git a/src/app/[orgId]/settings/general/auth-page/page.tsx b/src/app/[orgId]/settings/general/auth-page/page.tsx index 9ffa8e04..0bd48286 100644 --- a/src/app/[orgId]/settings/general/auth-page/page.tsx +++ b/src/app/[orgId]/settings/general/auth-page/page.tsx @@ -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"; diff --git a/src/app/[orgId]/settings/logs/access/page.tsx b/src/app/[orgId]/settings/logs/access/page.tsx index 6da993ba..13f31952 100644 --- a/src/app/[orgId]/settings/logs/access/page.tsx +++ b/src/app/[orgId]/settings/logs/access/page.tsx @@ -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([]); 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" ); @@ -642,11 +637,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"} /> ); diff --git a/src/app/[orgId]/settings/logs/action/page.tsx b/src/app/[orgId]/settings/logs/action/page.tsx index 040a1920..515fd2b9 100644 --- a/src/app/[orgId]/settings/logs/action/page.tsx +++ b/src/app/[orgId]/settings/logs/action/page.tsx @@ -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([]); 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" ); @@ -496,11 +492,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"} /> ); diff --git a/src/app/[orgId]/settings/logs/page.tsx b/src/app/[orgId]/settings/logs/page.tsx index 45b5a7de..d9663e72 100644 --- a/src/app/[orgId]/settings/logs/page.tsx +++ b/src/app/[orgId]/settings/logs/page.tsx @@ -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

dfas

; + return null; } diff --git a/src/app/[orgId]/settings/resources/proxy/[niceId]/general/page.tsx b/src/app/[orgId]/settings/resources/proxy/[niceId]/general/page.tsx index 1ed8eb17..303b3c46 100644 --- a/src/app/[orgId]/settings/resources/proxy/[niceId]/general/page.tsx +++ b/src/app/[orgId]/settings/resources/proxy/[niceId]/general/page.tsx @@ -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; } @@ -197,7 +184,7 @@ function MaintenanceSectionForm({ name="maintenanceModeEnabled" render={({ field }) => { const isDisabled = - isSecurityFeatureDisabled() || + isPaidUser(tierMatrix.maintencePage) || resource.http === false; return ( @@ -264,7 +251,7 @@ function MaintenanceSectionForm({ defaultValue={ field.value } - disabled={isSecurityFeatureDisabled()} + disabled={isPaidUser(tierMatrix.maintencePage)} className="flex flex-col space-y-1" > @@ -337,7 +324,7 @@ function MaintenanceSectionForm({ @@ -363,7 +350,7 @@ function MaintenanceSectionForm({