mirror of
https://github.com/fosrl/pangolin.git
synced 2026-02-07 21:46:38 +00:00
Merge branch 'dev'
This commit is contained in:
14
.github/dependabot.yml
vendored
14
.github/dependabot.yml
vendored
@@ -44,19 +44,9 @@ updates:
|
||||
schedule:
|
||||
interval: "daily"
|
||||
groups:
|
||||
dev-patch-updates:
|
||||
dependency-type: "development"
|
||||
patch-updates:
|
||||
update-types:
|
||||
- "patch"
|
||||
dev-minor-updates:
|
||||
dependency-type: "development"
|
||||
update-types:
|
||||
- "minor"
|
||||
prod-patch-updates:
|
||||
dependency-type: "production"
|
||||
update-types:
|
||||
- "patch"
|
||||
prod-minor-updates:
|
||||
dependency-type: "production"
|
||||
minor-updates:
|
||||
update-types:
|
||||
- "minor"
|
||||
2
.github/workflows/linting.yml
vendored
2
.github/workflows/linting.yml
vendored
@@ -26,7 +26,7 @@ jobs:
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
|
||||
with:
|
||||
node-version: '22'
|
||||
node-version: '24'
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
10
.github/workflows/test.yml
vendored
10
.github/workflows/test.yml
vendored
@@ -19,7 +19,7 @@ jobs:
|
||||
- name: Install Node
|
||||
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
|
||||
with:
|
||||
node-version: '22'
|
||||
node-version: '24'
|
||||
|
||||
- name: Copy config file
|
||||
run: cp config/config.example.yml config/config.yml
|
||||
@@ -34,7 +34,7 @@ jobs:
|
||||
run: npm run set:oss
|
||||
|
||||
- name: Generate database migrations
|
||||
run: npm run db:sqlite:generate
|
||||
run: npm run db:generate
|
||||
|
||||
- name: Apply database migrations
|
||||
run: npm run db:sqlite:push
|
||||
@@ -64,9 +64,6 @@ jobs:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
|
||||
- name: Copy config file
|
||||
run: cp config/config.example.yml config/config.yml
|
||||
|
||||
- name: Build Docker image sqlite
|
||||
run: make dev-build-sqlite
|
||||
|
||||
@@ -76,8 +73,5 @@ jobs:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
|
||||
- name: Copy config file
|
||||
run: cp config/config.example.yml config/config.yml
|
||||
|
||||
- name: Build Docker image pg
|
||||
run: make dev-build-pg
|
||||
|
||||
57
Dockerfile
57
Dockerfile
@@ -1,21 +1,11 @@
|
||||
FROM node:24-alpine AS builder
|
||||
|
||||
# OCI Image Labels - Build Args for dynamic values
|
||||
ARG VERSION="dev"
|
||||
ARG REVISION=""
|
||||
ARG CREATED=""
|
||||
ARG LICENSE="AGPL-3.0"
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
ARG BUILD=oss
|
||||
ARG DATABASE=sqlite
|
||||
|
||||
# Derive title and description based on BUILD type
|
||||
ARG IMAGE_TITLE="Pangolin"
|
||||
ARG IMAGE_DESCRIPTION="Identity-aware VPN and proxy for remote access to anything, anywhere"
|
||||
|
||||
RUN apk add --no-cache curl tzdata python3 make g++
|
||||
RUN apk add --no-cache python3 make g++
|
||||
|
||||
# COPY package.json package-lock.json ./
|
||||
COPY package*.json ./
|
||||
@@ -23,41 +13,31 @@ RUN npm ci
|
||||
|
||||
COPY . .
|
||||
|
||||
RUN echo "export * from \"./$DATABASE\";" > server/db/index.ts
|
||||
RUN echo "export const driver: \"pg\" | \"sqlite\" = \"$DATABASE\";" >> server/db/index.ts
|
||||
|
||||
RUN echo "export const build = \"$BUILD\" as \"saas\" | \"enterprise\" | \"oss\";" > server/build.ts
|
||||
|
||||
# Copy the appropriate TypeScript configuration based on build type
|
||||
RUN if [ "$BUILD" = "oss" ]; then cp tsconfig.oss.json tsconfig.json; \
|
||||
elif [ "$BUILD" = "saas" ]; then cp tsconfig.saas.json tsconfig.json; \
|
||||
elif [ "$BUILD" = "enterprise" ]; then cp tsconfig.enterprise.json tsconfig.json; \
|
||||
fi
|
||||
|
||||
# if the build is oss then remove the server/private directory
|
||||
RUN if [ "$BUILD" = "oss" ]; then rm -rf server/private; fi
|
||||
|
||||
RUN if [ "$DATABASE" = "pg" ]; then npx drizzle-kit generate --dialect postgresql --schema ./server/db/pg/schema --out init; else npx drizzle-kit generate --dialect $DATABASE --schema ./server/db/$DATABASE/schema --out init; fi
|
||||
|
||||
RUN mkdir -p dist
|
||||
RUN npm run next:build
|
||||
RUN node esbuild.mjs -e server/index.ts -o dist/server.mjs -b $BUILD
|
||||
RUN if [ "$DATABASE" = "pg" ]; then \
|
||||
node esbuild.mjs -e server/setup/migrationsPg.ts -o dist/migrations.mjs; \
|
||||
else \
|
||||
node esbuild.mjs -e server/setup/migrationsSqlite.ts -o dist/migrations.mjs; \
|
||||
fi
|
||||
RUN if [ "$BUILD" = "oss" ]; then rm -rf server/private; fi && \
|
||||
npm run set:$DATABASE && \
|
||||
npm run set:$BUILD && \
|
||||
npm run db:generate && \
|
||||
npm run build && \
|
||||
npm run build:cli
|
||||
|
||||
# test to make sure the build output is there and error if not
|
||||
RUN test -f dist/server.mjs
|
||||
|
||||
RUN npm run build:cli
|
||||
|
||||
# Prune dev dependencies and clean up to prepare for copy to runner
|
||||
RUN npm prune --omit=dev && npm cache clean --force
|
||||
|
||||
FROM node:24-alpine AS runner
|
||||
|
||||
# OCI Image Labels - Build Args for dynamic values
|
||||
ARG VERSION="dev"
|
||||
ARG REVISION=""
|
||||
ARG CREATED=""
|
||||
ARG LICENSE="AGPL-3.0"
|
||||
|
||||
# Derive title and description based on BUILD type
|
||||
ARG IMAGE_TITLE="Pangolin"
|
||||
ARG IMAGE_DESCRIPTION="Identity-aware VPN and proxy for remote access to anything, anywhere"
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Only curl and tzdata needed at runtime - no build tools!
|
||||
@@ -66,11 +46,10 @@ RUN apk add --no-cache curl tzdata
|
||||
# Copy pre-built node_modules from builder (already pruned to production only)
|
||||
# This includes the compiled native modules like better-sqlite3
|
||||
COPY --from=builder /app/node_modules ./node_modules
|
||||
|
||||
COPY --from=builder /app/.next/standalone ./
|
||||
COPY --from=builder /app/.next/static ./.next/static
|
||||
COPY --from=builder /app/dist ./dist
|
||||
COPY --from=builder /app/init ./dist/init
|
||||
COPY --from=builder /app/server/migrations ./dist/init
|
||||
COPY --from=builder /app/package.json ./package.json
|
||||
|
||||
COPY ./cli/wrapper.sh /usr/local/bin/pangctl
|
||||
|
||||
@@ -6,6 +6,12 @@ import path from "path";
|
||||
import fs from "fs";
|
||||
// import { glob } from "glob";
|
||||
|
||||
// Read default build type from server/build.ts
|
||||
let build = "oss";
|
||||
const buildFile = fs.readFileSync(path.resolve("server/build.ts"), "utf8");
|
||||
const m = buildFile.match(/export\s+const\s+build\s*=\s*["'](oss|saas|enterprise)["']/);
|
||||
if (m) build = m[1];
|
||||
|
||||
const banner = `
|
||||
// patch __dirname
|
||||
// import { fileURLToPath } from "url";
|
||||
@@ -37,7 +43,7 @@ const argv = yargs(hideBin(process.argv))
|
||||
describe: "Build type (oss, saas, enterprise)",
|
||||
type: "string",
|
||||
choices: ["oss", "saas", "enterprise"],
|
||||
default: "oss"
|
||||
default: build
|
||||
})
|
||||
.help()
|
||||
.alias("help", "h").argv;
|
||||
|
||||
@@ -6,7 +6,8 @@ import (
|
||||
"fmt"
|
||||
"io"
|
||||
"io/fs"
|
||||
"math/rand"
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
@@ -592,17 +593,12 @@ func showSetupTokenInstructions(containerType SupportedContainer, dashboardDomai
|
||||
}
|
||||
|
||||
func generateRandomSecretKey() string {
|
||||
const charset = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
|
||||
const length = 32
|
||||
|
||||
var seededRand *rand.Rand = rand.New(
|
||||
rand.NewSource(time.Now().UnixNano()))
|
||||
|
||||
b := make([]byte, length)
|
||||
for i := range b {
|
||||
b[i] = charset[seededRand.Intn(len(charset))]
|
||||
secret := make([]byte, 32)
|
||||
_, err := rand.Read(secret)
|
||||
if err != nil {
|
||||
panic(fmt.Sprintf("Failed to generate random secret key: %v", err))
|
||||
}
|
||||
return string(b)
|
||||
return base64.StdEncoding.EncodeToString(secret)
|
||||
}
|
||||
|
||||
func getPublicIP() string {
|
||||
|
||||
@@ -1436,6 +1436,15 @@
|
||||
"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.",
|
||||
"billingLicenseKeys": "License Keys",
|
||||
"billingLicenseKeysDescription": "Manage your license key subscriptions",
|
||||
"billingLicenseSubscription": "License Subscription",
|
||||
"billingInactive": "Inactive",
|
||||
"billingLicenseItem": "License Item",
|
||||
"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",
|
||||
@@ -2113,6 +2122,32 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"newPricingLicenseForm": {
|
||||
"title": "Get a license",
|
||||
"description": "Choose a plan and tell us how you plan to use Pangolin.",
|
||||
"chooseTier": "Choose your plan",
|
||||
"viewPricingLink": "See pricing, features, and limits",
|
||||
"tiers": {
|
||||
"starter": {
|
||||
"title": "Starter",
|
||||
"description": "Enterprise features, 25 users, 25 sites, and community support."
|
||||
},
|
||||
"scale": {
|
||||
"title": "Scale",
|
||||
"description": "Enterprise features, 50 users, 50 sites, and priority support."
|
||||
}
|
||||
},
|
||||
"personalUseOnly": "Personal use only (free license — no checkout)",
|
||||
"buttons": {
|
||||
"continueToCheckout": "Continue to Checkout"
|
||||
},
|
||||
"toasts": {
|
||||
"checkoutError": {
|
||||
"title": "Checkout error",
|
||||
"description": "Could not start checkout. Please try again."
|
||||
}
|
||||
}
|
||||
},
|
||||
"priority": "Priority",
|
||||
"priorityDescription": "Higher priority routes are evaluated first. Priority = 100 means automatic ordering (system decides). Use another number to enforce manual priority.",
|
||||
"instanceName": "Instance Name",
|
||||
|
||||
2194
package-lock.json
generated
2194
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
25
package.json
25
package.json
@@ -12,24 +12,24 @@
|
||||
"license": "SEE LICENSE IN LICENSE AND README.md",
|
||||
"scripts": {
|
||||
"dev": "NODE_ENV=development ENVIRONMENT=dev tsx watch server/index.ts",
|
||||
"db:pg:generate": "drizzle-kit generate --config=./drizzle.pg.config.ts",
|
||||
"db:sqlite:generate": "drizzle-kit generate --config=./drizzle.sqlite.config.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:push": "npx tsx server/db/pg/migrate.ts",
|
||||
"db:sqlite:push": "npx tsx server/db/sqlite/migrate.ts",
|
||||
"db:sqlite:studio": "drizzle-kit studio --config=./drizzle.sqlite.config.ts",
|
||||
"db:pg:studio": "drizzle-kit studio --config=./drizzle.pg.config.ts",
|
||||
"db:studio": "drizzle-kit studio --config=./drizzle.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",
|
||||
"set:enterprise": "echo 'export const build = \"enterprise\" as \"saas\" | \"enterprise\" | \"oss\";' > server/build.ts && cp tsconfig.enterprise.json tsconfig.json",
|
||||
"set:sqlite": "echo 'export * from \"./sqlite\";\nexport const driver: \"pg\" | \"sqlite\" = \"sqlite\";' > server/db/index.ts",
|
||||
"set:pg": "echo 'export * from \"./pg\";\nexport const driver: \"pg\" | \"sqlite\" = \"pg\";' > server/db/index.ts",
|
||||
"next:build": "next build",
|
||||
"build:sqlite": "mkdir -p dist && next build && node esbuild.mjs -e server/index.ts -o dist/server.mjs && node esbuild.mjs -e server/setup/migrationsSqlite.ts -o dist/migrations.mjs",
|
||||
"build:pg": "mkdir -p dist && next build && node esbuild.mjs -e server/index.ts -o dist/server.mjs && node esbuild.mjs -e server/setup/migrationsPg.ts -o dist/migrations.mjs",
|
||||
"set:sqlite": "echo 'export * from \"./sqlite\";\nexport const driver: \"pg\" | \"sqlite\" = \"sqlite\";' > server/db/index.ts && cp drizzle.sqlite.config.ts drizzle.config.ts && cp server/setup/migrationsSqlite.ts server/setup/migrations.ts",
|
||||
"set:pg": "echo 'export * from \"./pg\";\nexport const driver: \"pg\" | \"sqlite\" = \"pg\";' > server/db/index.ts && cp drizzle.pg.config.ts drizzle.config.ts && cp server/setup/migrationsPg.ts server/setup/migrations.ts",
|
||||
"build:next": "next build",
|
||||
"build": "mkdir -p dist && next build && node esbuild.mjs -e server/index.ts -o dist/server.mjs && node esbuild.mjs -e server/setup/migrations.ts -o dist/migrations.mjs",
|
||||
"start": "ENVIRONMENT=prod node dist/migrations.mjs && ENVIRONMENT=prod NODE_ENV=development node --enable-source-maps dist/server.mjs",
|
||||
"email": "email dev --dir server/emails/templates --port 3005",
|
||||
"build:cli": "node esbuild.mjs -e cli/index.ts -o dist/cli.mjs",
|
||||
"format:check": "prettier --check .",
|
||||
"format": "prettier --write ."
|
||||
},
|
||||
"dependencies": {
|
||||
@@ -75,9 +75,7 @@
|
||||
"class-variance-authority": "0.7.1",
|
||||
"clsx": "2.1.1",
|
||||
"cmdk": "1.1.1",
|
||||
"cookie": "1.1.1",
|
||||
"cookie-parser": "1.4.7",
|
||||
"cookies": "0.9.1",
|
||||
"cors": "2.8.5",
|
||||
"crypto-js": "4.2.0",
|
||||
"d3": "7.9.0",
|
||||
@@ -90,7 +88,6 @@
|
||||
"glob": "13.0.0",
|
||||
"helmet": "8.1.0",
|
||||
"http-errors": "2.0.1",
|
||||
"i": "0.3.7",
|
||||
"input-otp": "1.4.2",
|
||||
"ioredis": "5.9.2",
|
||||
"jmespath": "0.16.0",
|
||||
@@ -104,10 +101,7 @@
|
||||
"next-themes": "0.4.6",
|
||||
"nextjs-toploader": "3.9.17",
|
||||
"node-cache": "5.1.2",
|
||||
"node-fetch": "3.3.2",
|
||||
"nodemailer": "7.0.11",
|
||||
"npm": "11.7.0",
|
||||
"nprogress": "0.2.0",
|
||||
"oslo": "1.2.1",
|
||||
"pg": "8.17.1",
|
||||
"posthog-node": "5.23.0",
|
||||
@@ -118,7 +112,6 @@
|
||||
"react-easy-sort": "1.8.0",
|
||||
"react-hook-form": "7.71.1",
|
||||
"react-icons": "5.5.0",
|
||||
"rebuild": "0.1.2",
|
||||
"recharts": "2.15.4",
|
||||
"reodotdev": "1.0.0",
|
||||
"resend": "6.8.0",
|
||||
|
||||
118
server/emails/templates/EnterpriseEditionKeyGenerated.tsx
Normal file
118
server/emails/templates/EnterpriseEditionKeyGenerated.tsx
Normal file
@@ -0,0 +1,118 @@
|
||||
import React from "react";
|
||||
import { Body, Head, Html, Preview, Tailwind } from "@react-email/components";
|
||||
import { themeColors } from "./lib/theme";
|
||||
import {
|
||||
EmailContainer,
|
||||
EmailFooter,
|
||||
EmailGreeting,
|
||||
EmailHeading,
|
||||
EmailInfoSection,
|
||||
EmailLetterHead,
|
||||
EmailSection,
|
||||
EmailSignature,
|
||||
EmailText
|
||||
} from "./components/Email";
|
||||
import CopyCodeBox from "./components/CopyCodeBox";
|
||||
import ButtonLink from "./components/ButtonLink";
|
||||
|
||||
type EnterpriseEditionKeyGeneratedProps = {
|
||||
keyValue: string;
|
||||
personalUseOnly: boolean;
|
||||
users: number;
|
||||
sites: number;
|
||||
modifySubscriptionLink?: string;
|
||||
};
|
||||
|
||||
export const EnterpriseEditionKeyGenerated = ({
|
||||
keyValue,
|
||||
personalUseOnly,
|
||||
users,
|
||||
sites,
|
||||
modifySubscriptionLink
|
||||
}: EnterpriseEditionKeyGeneratedProps) => {
|
||||
const previewText = personalUseOnly
|
||||
? "Your Enterprise Edition key for personal use is ready"
|
||||
: "Thank you for your purchase — your Enterprise Edition key is ready";
|
||||
|
||||
return (
|
||||
<Html>
|
||||
<Head />
|
||||
<Preview>{previewText}</Preview>
|
||||
<Tailwind config={themeColors}>
|
||||
<Body className="font-sans bg-gray-50">
|
||||
<EmailContainer>
|
||||
<EmailLetterHead />
|
||||
|
||||
<EmailGreeting>Hi there,</EmailGreeting>
|
||||
|
||||
{personalUseOnly ? (
|
||||
<EmailText>
|
||||
Your Enterprise Edition license key has been
|
||||
generated. Qualifying users can use the
|
||||
Enterprise Edition for free for{" "}
|
||||
<strong>personal use only</strong>.
|
||||
</EmailText>
|
||||
) : (
|
||||
<>
|
||||
<EmailText>
|
||||
Thank you for your purchase. Your Enterprise
|
||||
Edition license key is ready. Below are the
|
||||
terms of your license.
|
||||
</EmailText>
|
||||
<EmailInfoSection
|
||||
title="License details"
|
||||
items={[
|
||||
{
|
||||
label: "Licensed users",
|
||||
value: users
|
||||
},
|
||||
{
|
||||
label: "Licensed sites",
|
||||
value: sites
|
||||
}
|
||||
]}
|
||||
/>
|
||||
{modifySubscriptionLink && (
|
||||
<EmailSection>
|
||||
<ButtonLink
|
||||
href={modifySubscriptionLink}
|
||||
>
|
||||
Modify subscription
|
||||
</ButtonLink>
|
||||
</EmailSection>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
<EmailSection>
|
||||
<EmailText>Your license key:</EmailText>
|
||||
<CopyCodeBox
|
||||
text={keyValue}
|
||||
hint="Copy this key and use it when activating Enterprise Edition on your Pangolin host."
|
||||
/>
|
||||
</EmailSection>
|
||||
|
||||
<EmailText>
|
||||
If you need to purchase additional license keys or
|
||||
modify your existing license, please reach out to
|
||||
our support team at{" "}
|
||||
<a
|
||||
href="mailto:support@pangolin.net"
|
||||
className="text-primary font-medium"
|
||||
>
|
||||
support@pangolin.net
|
||||
</a>
|
||||
.
|
||||
</EmailText>
|
||||
|
||||
<EmailFooter>
|
||||
<EmailSignature />
|
||||
</EmailFooter>
|
||||
</EmailContainer>
|
||||
</Body>
|
||||
</Tailwind>
|
||||
</Html>
|
||||
);
|
||||
};
|
||||
|
||||
export default EnterpriseEditionKeyGenerated;
|
||||
@@ -1,6 +1,14 @@
|
||||
import React from "react";
|
||||
|
||||
export default function CopyCodeBox({ text }: { text: string }) {
|
||||
const DEFAULT_HINT = "Copy and paste this code when prompted";
|
||||
|
||||
export default function CopyCodeBox({
|
||||
text,
|
||||
hint
|
||||
}: {
|
||||
text: string;
|
||||
hint?: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="inline-block">
|
||||
<div className="bg-gray-50 border border-gray-200 rounded-lg px-6 py-4 mx-auto">
|
||||
@@ -8,9 +16,7 @@ export default function CopyCodeBox({ text }: { text: string }) {
|
||||
{text}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-xs text-gray-500 mt-2">
|
||||
Copy and paste this code when prompted
|
||||
</p>
|
||||
<p className="text-xs text-gray-500 mt-2">{hint ?? DEFAULT_HINT}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
37
server/lib/billing/licenses.ts
Normal file
37
server/lib/billing/licenses.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
export enum LicenseId {
|
||||
SMALL_LICENSE = "small_license",
|
||||
BIG_LICENSE = "big_license"
|
||||
}
|
||||
|
||||
export type LicensePriceSet = {
|
||||
[key in LicenseId]: string;
|
||||
};
|
||||
|
||||
export const licensePriceSet: LicensePriceSet = {
|
||||
// Free license matches the freeLimitSet
|
||||
[LicenseId.SMALL_LICENSE]: "price_1SxKHiD3Ee2Ir7WmvtEh17A8",
|
||||
[LicenseId.BIG_LICENSE]: "price_1SxKHiD3Ee2Ir7WmMUiP0H6Y"
|
||||
};
|
||||
|
||||
export const licensePriceSetSandbox: LicensePriceSet = {
|
||||
// Free license matches the freeLimitSet
|
||||
// when matching license the keys closer to 0 index are matched first so list the licenses in descending order of value
|
||||
[LicenseId.SMALL_LICENSE]: "price_1SxDwuDCpkOb237Bz0yTiOgN",
|
||||
[LicenseId.BIG_LICENSE]: "price_1SxDy0DCpkOb237BWJxrxYkl"
|
||||
};
|
||||
|
||||
export function getLicensePriceSet(
|
||||
environment?: string,
|
||||
sandbox_mode?: boolean
|
||||
): LicensePriceSet {
|
||||
if (
|
||||
(process.env.ENVIRONMENT == "prod" &&
|
||||
process.env.SANDBOX_MODE !== "true") ||
|
||||
(environment === "prod" && sandbox_mode !== true)
|
||||
) {
|
||||
// THIS GETS LOADED CLIENT SIDE AND SERVER SIDE
|
||||
return licensePriceSet;
|
||||
} else {
|
||||
return licensePriceSetSandbox;
|
||||
}
|
||||
}
|
||||
3
server/lib/getEnvOrYaml.ts
Normal file
3
server/lib/getEnvOrYaml.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export const getEnvOrYaml = (envVar: string) => (valFromYaml: any) => {
|
||||
return process.env[envVar] ?? valFromYaml;
|
||||
};
|
||||
@@ -3,13 +3,10 @@ import yaml from "js-yaml";
|
||||
import { configFilePath1, configFilePath2 } from "./consts";
|
||||
import { z } from "zod";
|
||||
import stoi from "./stoi";
|
||||
import { getEnvOrYaml } from "./getEnvOrYaml";
|
||||
|
||||
const portSchema = z.number().positive().gt(0).lte(65535);
|
||||
|
||||
const getEnvOrYaml = (envVar: string) => (valFromYaml: any) => {
|
||||
return process.env[envVar] ?? valFromYaml;
|
||||
};
|
||||
|
||||
export const configSchema = z
|
||||
.object({
|
||||
app: z
|
||||
@@ -311,7 +308,10 @@ export const configSchema = z
|
||||
.object({
|
||||
smtp_host: z.string().optional(),
|
||||
smtp_port: portSchema.optional(),
|
||||
smtp_user: z.string().optional(),
|
||||
smtp_user: z
|
||||
.string()
|
||||
.optional()
|
||||
.transform(getEnvOrYaml("EMAIL_SMTP_USER")),
|
||||
smtp_pass: z
|
||||
.string()
|
||||
.optional()
|
||||
|
||||
@@ -12,6 +12,10 @@ export type LicenseStatus = {
|
||||
isLicenseValid: boolean; // Is the license key valid?
|
||||
hostId: string; // Host ID
|
||||
tier?: LicenseKeyTier;
|
||||
maxSites?: number;
|
||||
usedSites?: number;
|
||||
maxUsers?: number;
|
||||
usedUsers?: number;
|
||||
};
|
||||
|
||||
export type LicenseKeyCache = {
|
||||
@@ -22,6 +26,8 @@ export type LicenseKeyCache = {
|
||||
type?: LicenseKeyType;
|
||||
tier?: LicenseKeyTier;
|
||||
terminateAt?: Date;
|
||||
quantity?: number;
|
||||
quantity_2?: number;
|
||||
};
|
||||
|
||||
export class License {
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
*/
|
||||
|
||||
import { getTierPriceSet } from "@server/lib/billing/tiers";
|
||||
import { getOrgSubscriptionData } from "#private/routers/billing/getOrgSubscription";
|
||||
import { getOrgSubscriptionsData } from "@server/private/routers/billing/getOrgSubscriptions";
|
||||
import { build } from "@server/build";
|
||||
|
||||
export async function getOrgTierData(
|
||||
@@ -25,8 +25,11 @@ export async function getOrgTierData(
|
||||
return { tier, active };
|
||||
}
|
||||
|
||||
const { subscription, items } = await getOrgSubscriptionData(orgId);
|
||||
// TODO: THIS IS INEFFICIENT!!! WE SHOULD IMPROVE HOW WE STORE TIERS WITH SUBSCRIPTIONS AND RETRIEVE THEM
|
||||
|
||||
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)
|
||||
@@ -39,8 +42,15 @@ export async function getOrgTierData(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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 };
|
||||
}
|
||||
|
||||
@@ -19,7 +19,6 @@ import * as fs from "fs";
|
||||
import logger from "@server/logger";
|
||||
import cache from "@server/lib/cache";
|
||||
|
||||
let encryptionKeyPath = "";
|
||||
let encryptionKeyHex = "";
|
||||
let encryptionKey: Buffer;
|
||||
function loadEncryptData() {
|
||||
@@ -27,15 +26,7 @@ function loadEncryptData() {
|
||||
return; // already loaded
|
||||
}
|
||||
|
||||
encryptionKeyPath = config.getRawPrivateConfig().server.encryption_key_path;
|
||||
|
||||
if (!fs.existsSync(encryptionKeyPath)) {
|
||||
throw new Error(
|
||||
"Encryption key file not found. Please generate one first."
|
||||
);
|
||||
}
|
||||
|
||||
encryptionKeyHex = fs.readFileSync(encryptionKeyPath, "utf8").trim();
|
||||
encryptionKeyHex = config.getRawPrivateConfig().server.encryption_key;
|
||||
encryptionKey = Buffer.from(encryptionKeyHex, "hex");
|
||||
}
|
||||
|
||||
|
||||
@@ -17,6 +17,7 @@ import { privateConfigFilePath1 } from "@server/lib/consts";
|
||||
import { z } from "zod";
|
||||
import { colorsSchema } from "@server/lib/colorsSchema";
|
||||
import { build } from "@server/build";
|
||||
import { getEnvOrYaml } from "@server/lib/getEnvOrYaml";
|
||||
|
||||
const portSchema = z.number().positive().gt(0).lte(65535);
|
||||
|
||||
@@ -32,19 +33,29 @@ export const privateConfigSchema = z.object({
|
||||
}),
|
||||
server: z
|
||||
.object({
|
||||
encryption_key_path: z
|
||||
encryption_key: z
|
||||
.string()
|
||||
.optional()
|
||||
.default("./config/encryption.pem")
|
||||
.pipe(z.string().min(8)),
|
||||
resend_api_key: z.string().optional(),
|
||||
reo_client_id: z.string().optional(),
|
||||
fossorial_api_key: z.string().optional()
|
||||
.transform(getEnvOrYaml("SERVER_ENCRYPTION_KEY")),
|
||||
resend_api_key: z
|
||||
.string()
|
||||
.optional()
|
||||
.transform(getEnvOrYaml("RESEND_API_KEY")),
|
||||
reo_client_id: z
|
||||
.string()
|
||||
.optional()
|
||||
.transform(getEnvOrYaml("REO_CLIENT_ID")),
|
||||
fossorial_api: z
|
||||
.string()
|
||||
.optional()
|
||||
.default("https://api.fossorial.io"),
|
||||
fossorial_api_key: z
|
||||
.string()
|
||||
.optional()
|
||||
.transform(getEnvOrYaml("FOSSORIAL_API_KEY"))
|
||||
})
|
||||
.optional()
|
||||
.default({
|
||||
encryption_key_path: "./config/encryption.pem"
|
||||
}),
|
||||
.prefault({}),
|
||||
redis: z
|
||||
.object({
|
||||
host: z.string(),
|
||||
@@ -157,8 +168,14 @@ export const privateConfigSchema = z.object({
|
||||
.optional(),
|
||||
stripe: z
|
||||
.object({
|
||||
secret_key: z.string(),
|
||||
webhook_secret: z.string(),
|
||||
secret_key: z
|
||||
.string()
|
||||
.optional()
|
||||
.transform(getEnvOrYaml("STRIPE_SECRET_KEY")),
|
||||
webhook_secret: z
|
||||
.string()
|
||||
.optional()
|
||||
.transform(getEnvOrYaml("STRIPE_WEBHOOK_SECRET")),
|
||||
s3Bucket: z.string(),
|
||||
s3Region: z.string().default("us-east-1"),
|
||||
localFilePath: z.string()
|
||||
|
||||
@@ -11,12 +11,12 @@
|
||||
* This file is not licensed under the AGPLv3.
|
||||
*/
|
||||
|
||||
import { db, HostMeta } from "@server/db";
|
||||
import { db, HostMeta, sites, users } from "@server/db";
|
||||
import { hostMeta, licenseKey } from "@server/db";
|
||||
import logger from "@server/logger";
|
||||
import NodeCache from "node-cache";
|
||||
import { validateJWT } from "./licenseJwt";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { count, eq } from "drizzle-orm";
|
||||
import moment from "moment";
|
||||
import { encrypt, decrypt } from "@server/lib/crypto";
|
||||
import {
|
||||
@@ -54,6 +54,7 @@ type TokenPayload = {
|
||||
type: LicenseKeyType;
|
||||
tier: LicenseKeyTier;
|
||||
quantity: number;
|
||||
quantity_2: number;
|
||||
terminateAt: string; // ISO
|
||||
iat: number; // Issued at
|
||||
};
|
||||
@@ -140,10 +141,20 @@ LQIDAQAB
|
||||
};
|
||||
}
|
||||
|
||||
// Count used sites and users for license comparison
|
||||
const [siteCountRes] = await db
|
||||
.select({ value: count() })
|
||||
.from(sites);
|
||||
const [userCountRes] = await db
|
||||
.select({ value: count() })
|
||||
.from(users);
|
||||
|
||||
const status: LicenseStatus = {
|
||||
hostId: this.hostMeta.hostMetaId,
|
||||
isHostLicensed: true,
|
||||
isLicenseValid: false
|
||||
isLicenseValid: false,
|
||||
usedSites: siteCountRes?.value ?? 0,
|
||||
usedUsers: userCountRes?.value ?? 0
|
||||
};
|
||||
|
||||
this.checkInProgress = true;
|
||||
@@ -151,6 +162,8 @@ LQIDAQAB
|
||||
try {
|
||||
if (!this.doRecheck && this.statusCache.has(this.statusKey)) {
|
||||
const res = this.statusCache.get("status") as LicenseStatus;
|
||||
res.usedSites = status.usedSites;
|
||||
res.usedUsers = status.usedUsers;
|
||||
return res;
|
||||
}
|
||||
logger.debug("Checking license status...");
|
||||
@@ -193,7 +206,9 @@ LQIDAQAB
|
||||
type: payload.type,
|
||||
tier: payload.tier,
|
||||
iat: new Date(payload.iat * 1000),
|
||||
terminateAt: new Date(payload.terminateAt)
|
||||
terminateAt: new Date(payload.terminateAt),
|
||||
quantity: payload.quantity,
|
||||
quantity_2: payload.quantity_2
|
||||
});
|
||||
|
||||
if (payload.type === "host") {
|
||||
@@ -292,6 +307,8 @@ LQIDAQAB
|
||||
cached.tier = payload.tier;
|
||||
cached.iat = new Date(payload.iat * 1000);
|
||||
cached.terminateAt = new Date(payload.terminateAt);
|
||||
cached.quantity = payload.quantity;
|
||||
cached.quantity_2 = payload.quantity_2;
|
||||
|
||||
// Encrypt the updated token before storing
|
||||
const encryptedKey = encrypt(
|
||||
@@ -317,7 +334,7 @@ LQIDAQAB
|
||||
}
|
||||
}
|
||||
|
||||
// Compute host status
|
||||
// Compute host status: quantity = users, quantity_2 = sites
|
||||
for (const key of keys) {
|
||||
const cached = newCache.get(key.licenseKey)!;
|
||||
|
||||
@@ -329,6 +346,28 @@ LQIDAQAB
|
||||
if (!cached.valid) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Only consider quantity if defined and >= 0 (quantity = users, quantity_2 = sites)
|
||||
if (
|
||||
cached.quantity_2 !== undefined &&
|
||||
cached.quantity_2 >= 0
|
||||
) {
|
||||
status.maxSites =
|
||||
(status.maxSites ?? 0) + cached.quantity_2;
|
||||
}
|
||||
if (cached.quantity !== undefined && cached.quantity >= 0) {
|
||||
status.maxUsers = (status.maxUsers ?? 0) + cached.quantity;
|
||||
}
|
||||
}
|
||||
|
||||
// Invalidate license if over user or site limits
|
||||
if (
|
||||
(status.maxSites !== undefined &&
|
||||
(status.usedSites ?? 0) > status.maxSites) ||
|
||||
(status.maxUsers !== undefined &&
|
||||
(status.usedUsers ?? 0) > status.maxUsers)
|
||||
) {
|
||||
status.isLicenseValid = false;
|
||||
}
|
||||
|
||||
// Invalidate old cache and set new cache
|
||||
|
||||
@@ -29,7 +29,7 @@ const createCheckoutSessionSchema = z.strictObject({
|
||||
orgId: z.string()
|
||||
});
|
||||
|
||||
export async function createCheckoutSession(
|
||||
export async function createCheckoutSessionSAAS(
|
||||
req: Request,
|
||||
res: Response,
|
||||
next: NextFunction
|
||||
@@ -87,7 +87,7 @@ export async function createCheckoutSession(
|
||||
data: session.url,
|
||||
success: true,
|
||||
error: false,
|
||||
message: "Organization created successfully",
|
||||
message: "Checkout session created successfully",
|
||||
status: HttpCode.CREATED
|
||||
});
|
||||
} catch (error) {
|
||||
@@ -37,18 +37,7 @@ const getOrgSchema = z.strictObject({
|
||||
orgId: z.string()
|
||||
});
|
||||
|
||||
registry.registerPath({
|
||||
method: "get",
|
||||
path: "/org/{orgId}/billing/subscription",
|
||||
description: "Get an organization",
|
||||
tags: [OpenAPITags.Org],
|
||||
request: {
|
||||
params: getOrgSchema
|
||||
},
|
||||
responses: {}
|
||||
});
|
||||
|
||||
export async function getOrgSubscription(
|
||||
export async function getOrgSubscriptions(
|
||||
req: Request,
|
||||
res: Response,
|
||||
next: NextFunction
|
||||
@@ -66,12 +55,9 @@ export async function getOrgSubscription(
|
||||
|
||||
const { orgId } = parsedParams.data;
|
||||
|
||||
let subscriptionData = null;
|
||||
let itemsData: SubscriptionItem[] = [];
|
||||
let subscriptions = null;
|
||||
try {
|
||||
const { subscription, items } = await getOrgSubscriptionData(orgId);
|
||||
subscriptionData = subscription;
|
||||
itemsData = items;
|
||||
subscriptions = await getOrgSubscriptionsData(orgId);
|
||||
} catch (err) {
|
||||
if ((err as Error).message === "Not found") {
|
||||
return next(
|
||||
@@ -86,8 +72,7 @@ export async function getOrgSubscription(
|
||||
|
||||
return response<GetOrgSubscriptionResponse>(res, {
|
||||
data: {
|
||||
subscription: subscriptionData,
|
||||
items: itemsData
|
||||
subscriptions
|
||||
},
|
||||
success: true,
|
||||
error: false,
|
||||
@@ -102,9 +87,9 @@ export async function getOrgSubscription(
|
||||
}
|
||||
}
|
||||
|
||||
export async function getOrgSubscriptionData(
|
||||
export async function getOrgSubscriptionsData(
|
||||
orgId: string
|
||||
): Promise<{ subscription: Subscription | null; items: SubscriptionItem[] }> {
|
||||
): Promise<Array<{ subscription: Subscription; items: SubscriptionItem[] }>> {
|
||||
const org = await db
|
||||
.select()
|
||||
.from(orgs)
|
||||
@@ -122,21 +107,21 @@ export async function getOrgSubscriptionData(
|
||||
.where(eq(customers.orgId, orgId))
|
||||
.limit(1);
|
||||
|
||||
let subscription = null;
|
||||
let items: SubscriptionItem[] = [];
|
||||
const subscriptionsWithItems: Array<{
|
||||
subscription: Subscription;
|
||||
items: SubscriptionItem[];
|
||||
}> = [];
|
||||
|
||||
if (customer.length > 0) {
|
||||
// Get subscription for customer
|
||||
// Get all subscriptions for customer
|
||||
const subs = await db
|
||||
.select()
|
||||
.from(subscriptions)
|
||||
.where(eq(subscriptions.customerId, customer[0].customerId))
|
||||
.limit(1);
|
||||
.where(eq(subscriptions.customerId, customer[0].customerId));
|
||||
|
||||
if (subs.length > 0) {
|
||||
subscription = subs[0];
|
||||
// Get subscription items
|
||||
items = await db
|
||||
for (const subscription of subs) {
|
||||
// Get subscription items for each subscription
|
||||
const items = await db
|
||||
.select()
|
||||
.from(subscriptionItems)
|
||||
.where(
|
||||
@@ -145,8 +130,13 @@ export async function getOrgSubscriptionData(
|
||||
subscription.subscriptionId
|
||||
)
|
||||
);
|
||||
|
||||
subscriptionsWithItems.push({
|
||||
subscription,
|
||||
items
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return { subscription, items };
|
||||
return subscriptionsWithItems;
|
||||
}
|
||||
35
server/private/routers/billing/hooks/getSubType.ts
Normal file
35
server/private/routers/billing/hooks/getSubType.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import {
|
||||
getLicensePriceSet,
|
||||
} from "@server/lib/billing/licenses";
|
||||
import {
|
||||
getTierPriceSet,
|
||||
} from "@server/lib/billing/tiers";
|
||||
import Stripe from "stripe";
|
||||
|
||||
export function getSubType(fullSubscription: Stripe.Response<Stripe.Subscription>): "saas" | "license" {
|
||||
// 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;
|
||||
|
||||
// Check if price ID matches any license price
|
||||
const licensePrices = Object.values(getLicensePriceSet());
|
||||
|
||||
if (licensePrices.includes(priceId)) {
|
||||
type = "license";
|
||||
break;
|
||||
}
|
||||
|
||||
// Check if price ID matches any tier price (saas)
|
||||
const tierPrices = Object.values(getTierPriceSet());
|
||||
|
||||
if (tierPrices.includes(priceId)) {
|
||||
type = "saas";
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return type;
|
||||
}
|
||||
@@ -25,6 +25,12 @@ import logger from "@server/logger";
|
||||
import stripe from "#private/lib/stripe";
|
||||
import { handleSubscriptionLifesycle } from "../subscriptionLifecycle";
|
||||
import { AudienceIds, moveEmailToAudience } from "#private/lib/resend";
|
||||
import { getSubType } from "./getSubType";
|
||||
import privateConfig from "#private/lib/config";
|
||||
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";
|
||||
|
||||
export async function handleSubscriptionCreated(
|
||||
subscription: Stripe.Subscription
|
||||
@@ -123,7 +129,16 @@ export async function handleSubscriptionCreated(
|
||||
return;
|
||||
}
|
||||
|
||||
await handleSubscriptionLifesycle(customer.orgId, subscription.status);
|
||||
const type = getSubType(fullSubscription);
|
||||
if (type === "saas") {
|
||||
logger.debug(
|
||||
`Handling SAAS subscription lifecycle for org ${customer.orgId}`
|
||||
);
|
||||
// we only need to handle the limit lifecycle for saas subscriptions not for the licenses
|
||||
await handleSubscriptionLifesycle(
|
||||
customer.orgId,
|
||||
subscription.status
|
||||
);
|
||||
|
||||
const [orgUserRes] = await db
|
||||
.select()
|
||||
@@ -143,6 +158,115 @@ export async function handleSubscriptionCreated(
|
||||
moveEmailToAudience(email, AudienceIds.Subscribed);
|
||||
}
|
||||
}
|
||||
} else if (type === "license") {
|
||||
logger.debug(
|
||||
`License subscription created for org ${customer.orgId}, no lifecycle handling needed.`
|
||||
);
|
||||
|
||||
// Retrieve the client_reference_id from the checkout session
|
||||
let licenseId: string | null = null;
|
||||
|
||||
try {
|
||||
const sessions = await stripe!.checkout.sessions.list({
|
||||
subscription: subscription.id,
|
||||
limit: 1
|
||||
});
|
||||
if (sessions.data.length > 0) {
|
||||
licenseId = sessions.data[0].client_reference_id || null;
|
||||
}
|
||||
|
||||
if (!licenseId) {
|
||||
logger.error(
|
||||
`No client_reference_id found for subscription ${subscription.id}`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
logger.debug(
|
||||
`Retrieved licenseId ${licenseId} from checkout session for subscription ${subscription.id}`
|
||||
);
|
||||
|
||||
// Determine users and sites based on license type
|
||||
const priceSet = getLicensePriceSet();
|
||||
const subscriptionPriceId =
|
||||
fullSubscription.items.data[0]?.price.id;
|
||||
|
||||
let numUsers: number;
|
||||
let numSites: number;
|
||||
|
||||
if (subscriptionPriceId === priceSet[LicenseId.SMALL_LICENSE]) {
|
||||
numUsers = 25;
|
||||
numSites = 25;
|
||||
} else if (
|
||||
subscriptionPriceId === priceSet[LicenseId.BIG_LICENSE]
|
||||
) {
|
||||
numUsers = 50;
|
||||
numSites = 50;
|
||||
} else {
|
||||
logger.error(
|
||||
`Unknown price ID ${subscriptionPriceId} for subscription ${subscription.id}`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
logger.debug(
|
||||
`License type determined: ${numUsers} users, ${numSites} sites for subscription ${subscription.id}`
|
||||
);
|
||||
|
||||
const response = await fetch(
|
||||
`${privateConfig.getRawPrivateConfig().server.fossorial_api}/api/v1/license-internal/enterprise/paid-for`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
"api-key":
|
||||
privateConfig.getRawPrivateConfig().server
|
||||
.fossorial_api_key!,
|
||||
"Content-Type": "application/json"
|
||||
},
|
||||
body: JSON.stringify({
|
||||
licenseId: parseInt(licenseId),
|
||||
paidFor: true,
|
||||
users: numUsers,
|
||||
sites: numSites
|
||||
})
|
||||
}
|
||||
);
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
logger.debug(`Fossorial API response: ${JSON.stringify(data)}`);
|
||||
|
||||
if (customer.email) {
|
||||
logger.debug(
|
||||
`Sending license key email to ${customer.email} for subscription ${subscription.id}`
|
||||
);
|
||||
await sendEmail(
|
||||
EnterpriseEditionKeyGenerated({
|
||||
keyValue: data.data.licenseKey,
|
||||
personalUseOnly: false,
|
||||
users: numUsers,
|
||||
sites: numSites,
|
||||
modifySubscriptionLink: `${config.getRawConfig().app.dashboard_url}/${customer.orgId}/settings/billing`
|
||||
}),
|
||||
{
|
||||
to: customer.email,
|
||||
from: config.getNoReplyEmail(),
|
||||
subject:
|
||||
"Your Enterprise Edition license key is ready"
|
||||
}
|
||||
);
|
||||
} else {
|
||||
logger.error(
|
||||
`No email found for customer ${customer.customerId} to send license key.`
|
||||
);
|
||||
}
|
||||
|
||||
return data;
|
||||
} catch (error) {
|
||||
console.error("Error creating new license:", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
`Error handling subscription created event for ID ${subscription.id}:`,
|
||||
|
||||
@@ -24,11 +24,22 @@ import { eq, and } from "drizzle-orm";
|
||||
import logger from "@server/logger";
|
||||
import { handleSubscriptionLifesycle } from "../subscriptionLifecycle";
|
||||
import { AudienceIds, moveEmailToAudience } from "#private/lib/resend";
|
||||
import { getSubType } from "./getSubType";
|
||||
import stripe from "#private/lib/stripe";
|
||||
import privateConfig from "#private/lib/config";
|
||||
|
||||
export async function handleSubscriptionDeleted(
|
||||
subscription: Stripe.Subscription
|
||||
): Promise<void> {
|
||||
try {
|
||||
// Fetch the subscription from Stripe with expanded price.tiers
|
||||
const fullSubscription = await stripe!.subscriptions.retrieve(
|
||||
subscription.id,
|
||||
{
|
||||
expand: ["items.data.price.tiers"]
|
||||
}
|
||||
);
|
||||
|
||||
const [existingSubscription] = await db
|
||||
.select()
|
||||
.from(subscriptions)
|
||||
@@ -64,7 +75,16 @@ export async function handleSubscriptionDeleted(
|
||||
return;
|
||||
}
|
||||
|
||||
await handleSubscriptionLifesycle(customer.orgId, subscription.status);
|
||||
const type = getSubType(fullSubscription);
|
||||
if (type === "saas") {
|
||||
logger.debug(
|
||||
`Handling SaaS subscription deletion for orgId ${customer.orgId} and subscription ID ${subscription.id}`
|
||||
);
|
||||
|
||||
await handleSubscriptionLifesycle(
|
||||
customer.orgId,
|
||||
subscription.status
|
||||
);
|
||||
|
||||
const [orgUserRes] = await db
|
||||
.select()
|
||||
@@ -84,6 +104,35 @@ export async function handleSubscriptionDeleted(
|
||||
moveEmailToAudience(email, AudienceIds.Churned);
|
||||
}
|
||||
}
|
||||
} else if (type === "license") {
|
||||
logger.debug(
|
||||
`Handling license subscription deletion for orgId ${customer.orgId} and subscription ID ${subscription.id}`
|
||||
);
|
||||
try {
|
||||
// WARNING:
|
||||
// this invalidates ALL OF THE ENTERPRISE LICENSES for this orgId
|
||||
await fetch(
|
||||
`${privateConfig.getRawPrivateConfig().server.fossorial_api}/api/v1/license-internal/enterprise/invalidate`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
"api-key":
|
||||
privateConfig.getRawPrivateConfig().server
|
||||
.fossorial_api_key!,
|
||||
"Content-Type": "application/json"
|
||||
},
|
||||
body: JSON.stringify({
|
||||
orgId: customer.orgId,
|
||||
})
|
||||
}
|
||||
);
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
`Error notifying Fossorial API of license subscription deletion for orgId ${customer.orgId} and subscription ID ${subscription.id}:`,
|
||||
error
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
`Error handling subscription updated event for ID ${subscription.id}:`,
|
||||
|
||||
@@ -26,6 +26,8 @@ import logger from "@server/logger";
|
||||
import { getFeatureIdByMetricId } from "@server/lib/billing/features";
|
||||
import stripe from "#private/lib/stripe";
|
||||
import { handleSubscriptionLifesycle } from "../subscriptionLifecycle";
|
||||
import { getSubType } from "./getSubType";
|
||||
import privateConfig from "#private/lib/config";
|
||||
|
||||
export async function handleSubscriptionUpdated(
|
||||
subscription: Stripe.Subscription,
|
||||
@@ -56,7 +58,7 @@ export async function handleSubscriptionUpdated(
|
||||
}
|
||||
|
||||
// get the customer
|
||||
const [existingCustomer] = await db
|
||||
const [customer] = await db
|
||||
.select()
|
||||
.from(customers)
|
||||
.where(eq(customers.customerId, subscription.customer as string))
|
||||
@@ -74,11 +76,6 @@ export async function handleSubscriptionUpdated(
|
||||
})
|
||||
.where(eq(subscriptions.subscriptionId, subscription.id));
|
||||
|
||||
await handleSubscriptionLifesycle(
|
||||
existingCustomer.orgId,
|
||||
subscription.status
|
||||
);
|
||||
|
||||
// Upsert subscription items
|
||||
if (Array.isArray(fullSubscription.items?.data)) {
|
||||
const itemsToUpsert = fullSubscription.items.data.map((item) => ({
|
||||
@@ -141,20 +138,20 @@ export async function handleSubscriptionUpdated(
|
||||
// This item has cycled
|
||||
const meterId = item.plan.meter;
|
||||
if (!meterId) {
|
||||
logger.warn(
|
||||
logger.debug(
|
||||
`No meterId found for subscription item ${item.id}. Skipping usage reset.`
|
||||
);
|
||||
continue;
|
||||
}
|
||||
const featureId = getFeatureIdByMetricId(meterId);
|
||||
if (!featureId) {
|
||||
logger.warn(
|
||||
logger.debug(
|
||||
`No featureId found for meterId ${meterId}. Skipping usage reset.`
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
const orgId = existingCustomer.orgId;
|
||||
const orgId = customer.orgId;
|
||||
|
||||
if (!orgId) {
|
||||
logger.warn(
|
||||
@@ -236,6 +233,45 @@ export async function handleSubscriptionUpdated(
|
||||
}
|
||||
}
|
||||
// --- end usage update ---
|
||||
|
||||
const type = getSubType(fullSubscription);
|
||||
if (type === "saas") {
|
||||
logger.debug(
|
||||
`Handling SAAS subscription lifecycle for org ${customer.orgId}`
|
||||
);
|
||||
// we only need to handle the limit lifecycle for saas subscriptions not for the licenses
|
||||
await handleSubscriptionLifesycle(
|
||||
customer.orgId,
|
||||
subscription.status
|
||||
);
|
||||
} else {
|
||||
if (subscription.status === "canceled" || subscription.status == "unpaid" || subscription.status == "incomplete_expired") {
|
||||
try {
|
||||
// WARNING:
|
||||
// this invalidates ALL OF THE ENTERPRISE LICENSES for this orgId
|
||||
await fetch(
|
||||
`${privateConfig.getRawPrivateConfig().server.fossorial_api}/api/v1/license-internal/enterprise/invalidate`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
"api-key":
|
||||
privateConfig.getRawPrivateConfig()
|
||||
.server.fossorial_api_key!,
|
||||
"Content-Type": "application/json"
|
||||
},
|
||||
body: JSON.stringify({
|
||||
orgId: customer.orgId
|
||||
})
|
||||
}
|
||||
);
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
`Error notifying Fossorial API of license subscription deletion for orgId ${customer.orgId} and subscription ID ${subscription.id}:`,
|
||||
error
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
|
||||
@@ -11,8 +11,8 @@
|
||||
* This file is not licensed under the AGPLv3.
|
||||
*/
|
||||
|
||||
export * from "./createCheckoutSession";
|
||||
export * from "./createCheckoutSessionSAAS";
|
||||
export * from "./createPortalSession";
|
||||
export * from "./getOrgSubscription";
|
||||
export * from "./getOrgSubscriptions";
|
||||
export * from "./getOrgUsage";
|
||||
export * from "./internalGetOrgTier";
|
||||
|
||||
@@ -159,11 +159,11 @@ if (build === "saas") {
|
||||
);
|
||||
|
||||
authenticated.post(
|
||||
"/org/:orgId/billing/create-checkout-session",
|
||||
"/org/:orgId/billing/create-checkout-session-saas",
|
||||
verifyOrgAccess,
|
||||
verifyUserHasAction(ActionsEnum.billing),
|
||||
logActionAudit(ActionsEnum.billing),
|
||||
billing.createCheckoutSession
|
||||
billing.createCheckoutSessionSAAS
|
||||
);
|
||||
|
||||
authenticated.post(
|
||||
@@ -175,10 +175,10 @@ if (build === "saas") {
|
||||
);
|
||||
|
||||
authenticated.get(
|
||||
"/org/:orgId/billing/subscription",
|
||||
"/org/:orgId/billing/subscriptions",
|
||||
verifyOrgAccess,
|
||||
verifyUserHasAction(ActionsEnum.billing),
|
||||
billing.getOrgSubscription
|
||||
billing.getOrgSubscriptions
|
||||
);
|
||||
|
||||
authenticated.get(
|
||||
@@ -200,6 +200,14 @@ if (build === "saas") {
|
||||
generateLicense.generateNewLicense
|
||||
);
|
||||
|
||||
authenticated.put(
|
||||
"/org/:orgId/license/enterprise",
|
||||
verifyOrgAccess,
|
||||
verifyUserHasAction(ActionsEnum.billing),
|
||||
logActionAudit(ActionsEnum.billing),
|
||||
generateLicense.generateNewEnterpriseLicense
|
||||
);
|
||||
|
||||
authenticated.post(
|
||||
"/send-support-request",
|
||||
rateLimit({
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
/*
|
||||
* 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 HttpCode from "@server/types/HttpCode";
|
||||
import createHttpError from "http-errors";
|
||||
import logger from "@server/logger";
|
||||
import { response as sendResponse } from "@server/lib/response";
|
||||
import privateConfig from "#private/lib/config";
|
||||
import { createNewLicense } from "./generateNewLicense";
|
||||
import config from "@server/lib/config";
|
||||
import { getLicensePriceSet, LicenseId } from "@server/lib/billing/licenses";
|
||||
import stripe from "#private/lib/stripe";
|
||||
import { customers, db } from "@server/db";
|
||||
import { fromError } from "zod-validation-error";
|
||||
import z from "zod";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { log } from "winston";
|
||||
|
||||
const generateNewEnterpriseLicenseParamsSchema = z.strictObject({
|
||||
orgId: z.string()
|
||||
});
|
||||
|
||||
export async function generateNewEnterpriseLicense(
|
||||
req: Request,
|
||||
res: Response,
|
||||
next: NextFunction
|
||||
): Promise<any> {
|
||||
try {
|
||||
|
||||
const parsedParams = generateNewEnterpriseLicenseParamsSchema.safeParse(req.params);
|
||||
if (!parsedParams.success) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
fromError(parsedParams.error).toString()
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const { orgId } = parsedParams.data;
|
||||
|
||||
if (!orgId) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
"Organization ID is required"
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
logger.debug(`Generating new license for orgId: ${orgId}`);
|
||||
|
||||
const licenseData = req.body;
|
||||
|
||||
if (licenseData.tier != "big_license" && licenseData.tier != "small_license") {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
"Invalid tier specified. Must be either 'big_license' or 'small_license'."
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const apiResponse = await createNewLicense(orgId, licenseData);
|
||||
|
||||
// Check if the API call was successful
|
||||
if (!apiResponse.success || apiResponse.error) {
|
||||
return next(
|
||||
createHttpError(
|
||||
apiResponse.status || HttpCode.BAD_REQUEST,
|
||||
apiResponse.message || "Failed to create license from Fossorial API"
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const keyId = apiResponse?.data?.licenseKey?.id;
|
||||
if (!keyId) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.INTERNAL_SERVER_ERROR,
|
||||
"Fossorial API did not return a valid license key ID"
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
// check if we already have a customer for this org
|
||||
const [customer] = await db
|
||||
.select()
|
||||
.from(customers)
|
||||
.where(eq(customers.orgId, orgId))
|
||||
.limit(1);
|
||||
|
||||
// If we don't have a customer, create one
|
||||
if (!customer) {
|
||||
// error
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
"No customer found for this organization"
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const tier = licenseData.tier === "big_license" ? LicenseId.BIG_LICENSE : LicenseId.SMALL_LICENSE;
|
||||
const tierPrice = getLicensePriceSet()[tier]
|
||||
|
||||
const session = await stripe!.checkout.sessions.create({
|
||||
client_reference_id: keyId.toString(),
|
||||
billing_address_collection: "required",
|
||||
line_items: [
|
||||
{
|
||||
price: tierPrice, // Use the standard tier
|
||||
quantity: 1
|
||||
},
|
||||
], // Start with the standard feature set that matches the free limits
|
||||
customer: customer.customerId,
|
||||
mode: "subscription",
|
||||
success_url: `${config.getRawConfig().app.dashboard_url}/${orgId}/settings/license?success=true&session_id={CHECKOUT_SESSION_ID}`,
|
||||
cancel_url: `${config.getRawConfig().app.dashboard_url}/${orgId}/settings/license?canceled=true`
|
||||
});
|
||||
|
||||
return sendResponse<string>(res, {
|
||||
data: session.url,
|
||||
success: true,
|
||||
error: false,
|
||||
message: "License and checkout session created successfully",
|
||||
status: HttpCode.CREATED
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error(error);
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.INTERNAL_SERVER_ERROR,
|
||||
"An error occurred while generating new license."
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -19,10 +19,40 @@ import { response as sendResponse } from "@server/lib/response";
|
||||
import privateConfig from "#private/lib/config";
|
||||
import { GenerateNewLicenseResponse } from "@server/routers/generatedLicense/types";
|
||||
|
||||
async function createNewLicense(orgId: string, licenseData: any): Promise<any> {
|
||||
export interface CreateNewLicenseResponse {
|
||||
data: Data
|
||||
success: boolean
|
||||
error: boolean
|
||||
message: string
|
||||
status: number
|
||||
}
|
||||
|
||||
export interface Data {
|
||||
licenseKey: LicenseKey
|
||||
}
|
||||
|
||||
export interface LicenseKey {
|
||||
id: number
|
||||
instanceName: any
|
||||
instanceId: string
|
||||
licenseKey: string
|
||||
tier: string
|
||||
type: string
|
||||
quantity: number
|
||||
quantity_2: number
|
||||
isValid: boolean
|
||||
updatedAt: string
|
||||
createdAt: string
|
||||
expiresAt: string
|
||||
paidFor: boolean
|
||||
orgId: string
|
||||
metadata: string
|
||||
}
|
||||
|
||||
export async function createNewLicense(orgId: string, licenseData: any): Promise<CreateNewLicenseResponse> {
|
||||
try {
|
||||
const response = await fetch(
|
||||
`https://api.fossorial.io/api/v1/license-internal/enterprise/${orgId}/create`,
|
||||
`${privateConfig.getRawPrivateConfig().server.fossorial_api}/api/v1/license-internal/enterprise/${orgId}/create`, // this says enterprise but it does both
|
||||
{
|
||||
method: "PUT",
|
||||
headers: {
|
||||
@@ -35,9 +65,8 @@ async function createNewLicense(orgId: string, licenseData: any): Promise<any> {
|
||||
}
|
||||
);
|
||||
|
||||
const data = await response.json();
|
||||
const data: CreateNewLicenseResponse = await response.json();
|
||||
|
||||
logger.debug("Fossorial API response:", { data });
|
||||
return data;
|
||||
} catch (error) {
|
||||
console.error("Error creating new license:", error);
|
||||
|
||||
@@ -13,3 +13,4 @@
|
||||
|
||||
export * from "./listGeneratedLicenses";
|
||||
export * from "./generateNewLicense";
|
||||
export * from "./generateNewEnterpriseLicense";
|
||||
|
||||
@@ -25,7 +25,7 @@ import {
|
||||
async function fetchLicenseKeys(orgId: string): Promise<any> {
|
||||
try {
|
||||
const response = await fetch(
|
||||
`https://api.fossorial.io/api/v1/license-internal/enterprise/${orgId}/list`,
|
||||
`${privateConfig.getRawPrivateConfig().server.fossorial_api}/api/v1/license-internal/enterprise/${orgId}/list`,
|
||||
{
|
||||
method: "GET",
|
||||
headers: {
|
||||
|
||||
@@ -186,7 +186,7 @@ export type ResourceWithAuth = {
|
||||
password: ResourcePassword | null;
|
||||
headerAuth: ResourceHeaderAuth | null;
|
||||
headerAuthExtendedCompatibility: ResourceHeaderAuthExtendedCompatibility | null;
|
||||
org: Org
|
||||
org: Org;
|
||||
};
|
||||
|
||||
export type UserSessionWithUser = {
|
||||
@@ -270,7 +270,6 @@ hybridRouter.get(
|
||||
}
|
||||
);
|
||||
|
||||
let encryptionKeyPath = "";
|
||||
let encryptionKeyHex = "";
|
||||
let encryptionKey: Buffer;
|
||||
function loadEncryptData() {
|
||||
@@ -278,16 +277,8 @@ function loadEncryptData() {
|
||||
return; // already loaded
|
||||
}
|
||||
|
||||
encryptionKeyPath =
|
||||
privateConfig.getRawPrivateConfig().server.encryption_key_path;
|
||||
|
||||
if (!fs.existsSync(encryptionKeyPath)) {
|
||||
throw new Error(
|
||||
"Encryption key file not found. Please generate one first."
|
||||
);
|
||||
}
|
||||
|
||||
encryptionKeyHex = fs.readFileSync(encryptionKeyPath, "utf8").trim();
|
||||
encryptionKeyHex =
|
||||
privateConfig.getRawPrivateConfig().server.encryption_key;
|
||||
encryptionKey = Buffer.from(encryptionKeyHex, "hex");
|
||||
}
|
||||
|
||||
|
||||
@@ -37,27 +37,55 @@ const paramsSchema = z.strictObject({
|
||||
const bodySchema = z.strictObject({
|
||||
logoUrl: z
|
||||
.union([
|
||||
z.string().length(0),
|
||||
z.url().refine(
|
||||
async (url) => {
|
||||
z.literal(""),
|
||||
z
|
||||
.url("Must be a valid URL")
|
||||
.superRefine(async (url, ctx) => {
|
||||
try {
|
||||
const response = await fetch(url);
|
||||
return (
|
||||
response.status === 200 &&
|
||||
(
|
||||
response.headers.get("content-type") ?? ""
|
||||
).startsWith("image/")
|
||||
);
|
||||
const response = await fetch(url, {
|
||||
method: "HEAD"
|
||||
}).catch(() => {
|
||||
// If HEAD fails (CORS or method not allowed), try GET
|
||||
return fetch(url, { method: "GET" });
|
||||
});
|
||||
|
||||
if (response.status !== 200) {
|
||||
ctx.addIssue({
|
||||
code: "custom",
|
||||
message: `Failed to load image. Please check that the URL is accessible.`
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const contentType =
|
||||
response.headers.get("content-type") ?? "";
|
||||
if (!contentType.startsWith("image/")) {
|
||||
ctx.addIssue({
|
||||
code: "custom",
|
||||
message: `URL does not point to an image. Please provide a URL to an image file (e.g., .png, .jpg, .svg).`
|
||||
});
|
||||
return;
|
||||
}
|
||||
} catch (error) {
|
||||
return false;
|
||||
let errorMessage =
|
||||
"Unable to verify image URL. Please check that the URL is accessible and points to an image file.";
|
||||
|
||||
if (error instanceof TypeError && error.message.includes("fetch")) {
|
||||
errorMessage =
|
||||
"Network error: Unable to reach the URL. Please check your internet connection and verify the URL is correct.";
|
||||
} else if (error instanceof Error) {
|
||||
errorMessage = `Error verifying URL: ${error.message}`;
|
||||
}
|
||||
},
|
||||
{
|
||||
error: "Invalid logo URL, must be a valid image URL"
|
||||
|
||||
ctx.addIssue({
|
||||
code: "custom",
|
||||
message: errorMessage
|
||||
});
|
||||
}
|
||||
)
|
||||
})
|
||||
])
|
||||
.optional(),
|
||||
.transform((val) => (val === "" ? null : val))
|
||||
.nullish(),
|
||||
logoWidth: z.coerce.number<number>().min(1),
|
||||
logoHeight: z.coerce.number<number>().min(1),
|
||||
resourceTitle: z.string(),
|
||||
@@ -117,9 +145,8 @@ export async function upsertLoginPageBranding(
|
||||
typeof loginPageBranding
|
||||
>;
|
||||
|
||||
if ((updateData.logoUrl ?? "").trim().length === 0) {
|
||||
updateData.logoUrl = undefined;
|
||||
}
|
||||
// Empty strings are transformed to null by the schema, which will clear the logo URL in the database
|
||||
// We keep it as null (not undefined) because undefined fields are omitted from Drizzle updates
|
||||
|
||||
if (
|
||||
build !== "saas" &&
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import { Limit, Subscription, SubscriptionItem, Usage } from "@server/db";
|
||||
|
||||
export type GetOrgSubscriptionResponse = {
|
||||
subscription: Subscription | null;
|
||||
items: SubscriptionItem[];
|
||||
subscriptions: Array<{ subscription: Subscription; items: SubscriptionItem[] }>;
|
||||
};
|
||||
|
||||
export type GetOrgUsageResponse = {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Request, Response, NextFunction } from "express";
|
||||
import { z } from "zod";
|
||||
import { db, olms } from "@server/db";
|
||||
import { db, olms, users } from "@server/db";
|
||||
import { clients, currentFingerprint } from "@server/db";
|
||||
import { eq, and } from "drizzle-orm";
|
||||
import response from "@server/lib/response";
|
||||
@@ -36,6 +36,7 @@ async function query(clientId?: number, niceId?: string, orgId?: string) {
|
||||
currentFingerprint,
|
||||
eq(olms.olmId, currentFingerprint.olmId)
|
||||
)
|
||||
.leftJoin(users, eq(clients.userId, users.userId))
|
||||
.limit(1);
|
||||
return res;
|
||||
} else if (niceId && orgId) {
|
||||
@@ -48,6 +49,7 @@ async function query(clientId?: number, niceId?: string, orgId?: string) {
|
||||
currentFingerprint,
|
||||
eq(olms.olmId, currentFingerprint.olmId)
|
||||
)
|
||||
.leftJoin(users, eq(clients.userId, users.userId))
|
||||
.limit(1);
|
||||
return res;
|
||||
}
|
||||
@@ -207,6 +209,9 @@ export type GetClientResponse = NonNullable<
|
||||
olmId: string | null;
|
||||
agent: string | null;
|
||||
olmVersion: string | null;
|
||||
userEmail: string | null;
|
||||
userName: string | null;
|
||||
userUsername: string | null;
|
||||
fingerprint: {
|
||||
username: string | null;
|
||||
hostname: string | null;
|
||||
@@ -322,6 +327,9 @@ export async function getClient(
|
||||
olmId: client.olms ? client.olms.olmId : null,
|
||||
agent: client.olms?.agent || null,
|
||||
olmVersion: client.olms?.version || null,
|
||||
userEmail: client.user?.email ?? null,
|
||||
userName: client.user?.name ?? null,
|
||||
userUsername: client.user?.username ?? null,
|
||||
fingerprint: fingerprintData,
|
||||
posture: postureData
|
||||
};
|
||||
|
||||
@@ -6,6 +6,8 @@ export type GeneratedLicenseKey = {
|
||||
createdAt: string;
|
||||
tier: string;
|
||||
type: string;
|
||||
users: number;
|
||||
sites: number;
|
||||
};
|
||||
|
||||
export type ListGeneratedLicenseKeysResponse = GeneratedLicenseKey[];
|
||||
@@ -19,6 +21,7 @@ export type NewLicenseKey = {
|
||||
tier: string;
|
||||
type: string;
|
||||
quantity: number;
|
||||
quantity_2: number;
|
||||
isValid: boolean;
|
||||
updatedAt: string;
|
||||
createdAt: string;
|
||||
|
||||
@@ -13,6 +13,7 @@ import { checkOrgAccessPolicy } from "#dynamic/lib/checkOrgAccessPolicy";
|
||||
import { validateSessionToken } from "@server/auth/sessions/app";
|
||||
import { encodeHexLowerCase } from "@oslojs/encoding";
|
||||
import { sha256 } from "@oslojs/crypto/sha2";
|
||||
import { getUserDeviceName } from "@server/db/names";
|
||||
import { buildSiteConfigurationForOlmClient } from "./buildConfiguration";
|
||||
import { OlmErrorCodes, sendOlmError } from "./error";
|
||||
import { handleFingerprintInsertion } from "./fingerprintingUtils";
|
||||
@@ -97,6 +98,21 @@ export const handleOlmRegisterMessage: MessageHandler = async (context) => {
|
||||
return;
|
||||
}
|
||||
|
||||
const deviceModel = fingerprint?.deviceModel ?? null;
|
||||
const computedName = getUserDeviceName(deviceModel, client.name);
|
||||
if (computedName && computedName !== client.name) {
|
||||
await db
|
||||
.update(clients)
|
||||
.set({ name: computedName })
|
||||
.where(eq(clients.clientId, client.clientId));
|
||||
}
|
||||
if (computedName && computedName !== olm.name) {
|
||||
await db
|
||||
.update(olms)
|
||||
.set({ name: computedName })
|
||||
.where(eq(olms.olmId, olm.olmId));
|
||||
}
|
||||
|
||||
const [org] = await db
|
||||
.select()
|
||||
.from(orgs)
|
||||
|
||||
@@ -18,6 +18,7 @@ import { build } from "@server/build";
|
||||
import OrgPolicyResult from "@app/components/OrgPolicyResult";
|
||||
import UserProvider from "@app/providers/UserProvider";
|
||||
import { Layout } from "@app/components/Layout";
|
||||
import ApplyInternalRedirect from "@app/components/ApplyInternalRedirect";
|
||||
|
||||
export default async function OrgLayout(props: {
|
||||
children: React.ReactNode;
|
||||
@@ -70,6 +71,7 @@ export default async function OrgLayout(props: {
|
||||
} catch (e) {}
|
||||
return (
|
||||
<UserProvider user={user}>
|
||||
<ApplyInternalRedirect orgId={orgId} />
|
||||
<Layout orgId={orgId} navItems={[]} orgs={orgs}>
|
||||
<OrgPolicyResult
|
||||
orgId={orgId}
|
||||
@@ -86,7 +88,7 @@ export default async function OrgLayout(props: {
|
||||
try {
|
||||
const getSubscription = cache(() =>
|
||||
internal.get<AxiosResponse<GetOrgSubscriptionResponse>>(
|
||||
`/org/${orgId}/billing/subscription`,
|
||||
`/org/${orgId}/billing/subscriptions`,
|
||||
cookie
|
||||
)
|
||||
);
|
||||
@@ -104,6 +106,7 @@ export default async function OrgLayout(props: {
|
||||
env={env.app.environment}
|
||||
sandbox_mode={env.app.sandbox_mode}
|
||||
>
|
||||
<ApplyInternalRedirect orgId={orgId} />
|
||||
{props.children}
|
||||
<SetLastOrgCookie orgId={orgId} />
|
||||
</SubscriptionStatusProvider>
|
||||
|
||||
@@ -43,15 +43,18 @@ import Link from "next/link";
|
||||
|
||||
export default function GeneralPage() {
|
||||
const { org } = useOrgContext();
|
||||
const api = createApiClient(useEnvContext());
|
||||
const envContext = useEnvContext();
|
||||
const api = createApiClient(envContext);
|
||||
const t = useTranslations();
|
||||
|
||||
// Subscription state
|
||||
const [subscription, setSubscription] =
|
||||
useState<GetOrgSubscriptionResponse["subscription"]>(null);
|
||||
const [subscriptionItems, setSubscriptionItems] = useState<
|
||||
GetOrgSubscriptionResponse["items"]
|
||||
// Subscription state - now handling multiple subscriptions
|
||||
const [allSubscriptions, setAllSubscriptions] = useState<
|
||||
GetOrgSubscriptionResponse["subscriptions"]
|
||||
>([]);
|
||||
const [tierSubscription, setTierSubscription] =
|
||||
useState<GetOrgSubscriptionResponse["subscriptions"][0] | null>(null);
|
||||
const [licenseSubscription, setLicenseSubscription] =
|
||||
useState<GetOrgSubscriptionResponse["subscriptions"][0] | null>(null);
|
||||
const [subscriptionLoading, setSubscriptionLoading] = useState(true);
|
||||
|
||||
// Example usage data (replace with real usage data if available)
|
||||
@@ -68,12 +71,41 @@ export default function GeneralPage() {
|
||||
try {
|
||||
const res = await api.get<
|
||||
AxiosResponse<GetOrgSubscriptionResponse>
|
||||
>(`/org/${org.org.orgId}/billing/subscription`);
|
||||
const { subscription, items } = res.data.data;
|
||||
setSubscription(subscription);
|
||||
setSubscriptionItems(items);
|
||||
>(`/org/${org.org.orgId}/billing/subscriptions`);
|
||||
const { subscriptions } = res.data.data;
|
||||
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(
|
||||
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)
|
||||
)
|
||||
);
|
||||
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)
|
||||
)
|
||||
);
|
||||
setLicenseSubscription(licenseSub || null);
|
||||
|
||||
setHasSubscription(
|
||||
!!subscription && subscription.status === "active"
|
||||
!!tierSub?.subscription && tierSub.subscription.status === "active"
|
||||
);
|
||||
} catch (error) {
|
||||
toast({
|
||||
@@ -121,7 +153,7 @@ export default function GeneralPage() {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const response = await api.post<AxiosResponse<string>>(
|
||||
`/org/${org.org.orgId}/billing/create-checkout-session`,
|
||||
`/org/${org.org.orgId}/billing/create-checkout-session-saas`,
|
||||
{}
|
||||
);
|
||||
console.log("Checkout session response:", response.data);
|
||||
@@ -302,6 +334,10 @@ export default function GeneralPage() {
|
||||
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;
|
||||
@@ -388,15 +424,15 @@ export default function GeneralPage() {
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<Badge
|
||||
variant={
|
||||
subscription?.status === "active" ? "green" : "outline"
|
||||
tierSubscriptionData?.status === "active" ? "green" : "outline"
|
||||
}
|
||||
>
|
||||
{subscription?.status === "active" && (
|
||||
{tierSubscriptionData?.status === "active" && (
|
||||
<CheckCircle className="h-3 w-3 mr-1" />
|
||||
)}
|
||||
{subscription
|
||||
? subscription.status.charAt(0).toUpperCase() +
|
||||
subscription.status.slice(1)
|
||||
{tierSubscriptionData
|
||||
? tierSubscriptionData.status.charAt(0).toUpperCase() +
|
||||
tierSubscriptionData.status.slice(1)
|
||||
: t("billingFreeTier")}
|
||||
</Badge>
|
||||
<Link
|
||||
@@ -413,7 +449,7 @@ export default function GeneralPage() {
|
||||
{usageTypes.some((type) => {
|
||||
const { usage, limit } = getUsageItemAndLimit(
|
||||
usageData,
|
||||
subscriptionItems,
|
||||
tierSubscriptionItems,
|
||||
limitsData,
|
||||
type.id
|
||||
);
|
||||
@@ -441,7 +477,7 @@ export default function GeneralPage() {
|
||||
{usageTypes.map((type) => {
|
||||
const { usage, limit } = getUsageItemAndLimit(
|
||||
usageData,
|
||||
subscriptionItems,
|
||||
tierSubscriptionItems,
|
||||
limitsData,
|
||||
type.id
|
||||
);
|
||||
@@ -530,7 +566,7 @@ export default function GeneralPage() {
|
||||
{usageTypes.map((type) => {
|
||||
const { item, limit } = getUsageItemAndLimit(
|
||||
usageData,
|
||||
subscriptionItems,
|
||||
tierSubscriptionItems,
|
||||
limitsData,
|
||||
type.id
|
||||
);
|
||||
@@ -614,7 +650,7 @@ export default function GeneralPage() {
|
||||
const { usage, item } =
|
||||
getUsageItemAndLimit(
|
||||
usageData,
|
||||
subscriptionItems,
|
||||
tierSubscriptionItems,
|
||||
limitsData,
|
||||
type.id
|
||||
);
|
||||
@@ -636,7 +672,7 @@ export default function GeneralPage() {
|
||||
);
|
||||
})}
|
||||
{/* Show recurring charges (items with unitAmount but no tiers/meterId) */}
|
||||
{subscriptionItems
|
||||
{tierSubscriptionItems
|
||||
.filter(
|
||||
(item) =>
|
||||
item.unitAmount &&
|
||||
@@ -672,7 +708,7 @@ export default function GeneralPage() {
|
||||
const { usage, item } =
|
||||
getUsageItemAndLimit(
|
||||
usageData,
|
||||
subscriptionItems,
|
||||
tierSubscriptionItems,
|
||||
limitsData,
|
||||
type.id
|
||||
);
|
||||
@@ -687,7 +723,7 @@ export default function GeneralPage() {
|
||||
return sum + cost;
|
||||
}, 0) +
|
||||
// Add recurring charges
|
||||
subscriptionItems
|
||||
tierSubscriptionItems
|
||||
.filter(
|
||||
(item) =>
|
||||
item.unitAmount &&
|
||||
@@ -749,6 +785,56 @@ export default function GeneralPage() {
|
||||
</SettingsSectionBody>
|
||||
</SettingsSection>
|
||||
)}
|
||||
|
||||
{/* License Keys Section */}
|
||||
{licenseSubscription && (
|
||||
<SettingsSection>
|
||||
<SettingsSectionHeader>
|
||||
<SettingsSectionTitle>
|
||||
{t("billingLicenseKeys") || "License Keys"}
|
||||
</SettingsSectionTitle>
|
||||
<SettingsSectionDescription>
|
||||
{t("billingLicenseKeysDescription") || "Manage your license key subscriptions"}
|
||||
</SettingsSectionDescription>
|
||||
</SettingsSectionHeader>
|
||||
<SettingsSectionBody>
|
||||
<div className="flex items-center justify-between p-4 border rounded-lg">
|
||||
<div className="flex items-center gap-2">
|
||||
<CreditCard className="h-5 w-5 text-primary" />
|
||||
<span className="font-semibold">
|
||||
{t("billingLicenseSubscription") || "License Subscription"}
|
||||
</span>
|
||||
</div>
|
||||
<Badge
|
||||
variant={
|
||||
licenseSubscription.subscription?.status === "active"
|
||||
? "green"
|
||||
: "outline"
|
||||
}
|
||||
>
|
||||
{licenseSubscription.subscription?.status === "active" && (
|
||||
<CheckCircle className="h-3 w-3 mr-1" />
|
||||
)}
|
||||
{licenseSubscription.subscription?.status
|
||||
? licenseSubscription.subscription.status
|
||||
.charAt(0)
|
||||
.toUpperCase() +
|
||||
licenseSubscription.subscription.status.slice(1)
|
||||
: t("billingInactive") || "Inactive"}
|
||||
</Badge>
|
||||
</div>
|
||||
<SettingsSectionFooter>
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => handleModifySubscription()}
|
||||
disabled={isLoading}
|
||||
>
|
||||
{t("billingModifyLicenses") || "Modify License Subscription"}
|
||||
</Button>
|
||||
</SettingsSectionFooter>
|
||||
</SettingsSectionBody>
|
||||
</SettingsSection>
|
||||
)}
|
||||
</SettingsContainer>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -23,6 +23,7 @@ import Script from "next/script";
|
||||
import { TanstackQueryProvider } from "@app/components/TanstackQueryProvider";
|
||||
import { TailwindIndicator } from "@app/components/TailwindIndicator";
|
||||
import { ViewportHeightFix } from "@app/components/ViewportHeightFix";
|
||||
import StoreInternalRedirect from "@app/components/StoreInternalRedirect";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: `Dashboard - ${process.env.BRANDING_APP_NAME || "Pangolin"}`,
|
||||
@@ -79,6 +80,7 @@ export default async function RootLayout({
|
||||
return (
|
||||
<html suppressHydrationWarning lang={locale}>
|
||||
<body className={`${font.className} h-screen-safe overflow-hidden`}>
|
||||
<StoreInternalRedirect />
|
||||
<TopLoader />
|
||||
{build === "saas" && (
|
||||
<Script
|
||||
|
||||
@@ -10,6 +10,7 @@ import OrganizationLanding from "@app/components/OrganizationLanding";
|
||||
import { pullEnv } from "@app/lib/pullEnv";
|
||||
import { cleanRedirect } from "@app/lib/cleanRedirect";
|
||||
import { Layout } from "@app/components/Layout";
|
||||
import RedirectToOrg from "@app/components/RedirectToOrg";
|
||||
import { InitialSetupCompleteResponse } from "@server/routers/auth";
|
||||
import { cookies } from "next/headers";
|
||||
import { build } from "@server/build";
|
||||
@@ -80,15 +81,16 @@ export default async function Page(props: {
|
||||
const lastOrgCookie = allCookies.get("pangolin-last-org")?.value;
|
||||
|
||||
const lastOrgExists = orgs.some((org) => org.orgId === lastOrgCookie);
|
||||
let targetOrgId: string | null = null;
|
||||
if (lastOrgExists && lastOrgCookie) {
|
||||
redirect(`/${lastOrgCookie}`);
|
||||
targetOrgId = lastOrgCookie;
|
||||
} else {
|
||||
let ownedOrg = orgs.find((org) => org.isOwner);
|
||||
if (!ownedOrg) {
|
||||
ownedOrg = orgs[0];
|
||||
}
|
||||
if (ownedOrg) {
|
||||
redirect(`/${ownedOrg.orgId}`);
|
||||
targetOrgId = ownedOrg.orgId;
|
||||
} else {
|
||||
if (!env.flags.disableUserCreateOrg || user.serverAdmin) {
|
||||
redirect("/setup");
|
||||
@@ -96,6 +98,10 @@ export default async function Page(props: {
|
||||
}
|
||||
}
|
||||
|
||||
if (targetOrgId) {
|
||||
return <RedirectToOrg targetOrgId={targetOrgId} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<UserProvider user={user}>
|
||||
<Layout orgs={orgs} navItems={[]}>
|
||||
|
||||
24
src/components/ApplyInternalRedirect.tsx
Normal file
24
src/components/ApplyInternalRedirect.tsx
Normal file
@@ -0,0 +1,24 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { consumeInternalRedirectPath } from "@app/lib/internalRedirect";
|
||||
|
||||
type ApplyInternalRedirectProps = {
|
||||
orgId: string;
|
||||
};
|
||||
|
||||
export default function ApplyInternalRedirect({
|
||||
orgId
|
||||
}: ApplyInternalRedirectProps) {
|
||||
const router = useRouter();
|
||||
|
||||
useEffect(() => {
|
||||
const path = consumeInternalRedirectPath();
|
||||
if (path) {
|
||||
router.replace(`/${orgId}${path}`);
|
||||
}
|
||||
}, [orgId, router]);
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -43,25 +43,52 @@ export type AuthPageCustomizationProps = {
|
||||
|
||||
const AuthPageFormSchema = z.object({
|
||||
logoUrl: z.union([
|
||||
z.string().length(0),
|
||||
z.url().refine(
|
||||
async (url) => {
|
||||
z.literal(""),
|
||||
z.url("Must be a valid URL").superRefine(async (url, ctx) => {
|
||||
try {
|
||||
const response = await fetch(url);
|
||||
return (
|
||||
response.status === 200 &&
|
||||
(response.headers.get("content-type") ?? "").startsWith(
|
||||
"image/"
|
||||
)
|
||||
);
|
||||
const response = await fetch(url, {
|
||||
method: "HEAD"
|
||||
}).catch(() => {
|
||||
// If HEAD fails (CORS or method not allowed), try GET
|
||||
return fetch(url, { method: "GET" });
|
||||
});
|
||||
|
||||
if (response.status !== 200) {
|
||||
ctx.addIssue({
|
||||
code: "custom",
|
||||
message: `Failed to load image. Please check that the URL is accessible.`
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const contentType = response.headers.get("content-type") ?? "";
|
||||
if (!contentType.startsWith("image/")) {
|
||||
ctx.addIssue({
|
||||
code: "custom",
|
||||
message: `URL does not point to an image. Please provide a URL to an image file (e.g., .png, .jpg, .svg).`
|
||||
});
|
||||
return;
|
||||
}
|
||||
} catch (error) {
|
||||
return false;
|
||||
let errorMessage =
|
||||
"Unable to verify image URL. Please check that the URL is accessible and points to an image file.";
|
||||
|
||||
if (
|
||||
error instanceof TypeError &&
|
||||
error.message.includes("fetch")
|
||||
) {
|
||||
errorMessage =
|
||||
"Network error: Unable to reach the URL. Please check your internet connection and verify the URL is correct.";
|
||||
} else if (error instanceof Error) {
|
||||
errorMessage = `Error verifying URL: ${error.message}`;
|
||||
}
|
||||
},
|
||||
{
|
||||
error: "Invalid logo URL, must be a valid image URL"
|
||||
|
||||
ctx.addIssue({
|
||||
code: "custom",
|
||||
message: errorMessage
|
||||
});
|
||||
}
|
||||
)
|
||||
})
|
||||
]),
|
||||
logoWidth: z.coerce.number<number>().min(1),
|
||||
logoHeight: z.coerce.number<number>().min(1),
|
||||
@@ -405,9 +432,7 @@ export default function AuthPageBrandingForm({
|
||||
<Button
|
||||
variant="destructive"
|
||||
type="submit"
|
||||
loading={
|
||||
isUpdatingBranding || isDeletingBranding
|
||||
}
|
||||
loading={isDeletingBranding}
|
||||
disabled={
|
||||
isUpdatingBranding ||
|
||||
isDeletingBranding ||
|
||||
@@ -422,7 +447,7 @@ export default function AuthPageBrandingForm({
|
||||
<Button
|
||||
type="submit"
|
||||
form="auth-page-branding-form"
|
||||
loading={isUpdatingBranding || isDeletingBranding}
|
||||
loading={isUpdatingBranding}
|
||||
disabled={
|
||||
isUpdatingBranding ||
|
||||
isDeletingBranding ||
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
InfoSections,
|
||||
InfoSectionTitle
|
||||
} from "@app/components/InfoSection";
|
||||
import { getUserDisplayName } from "@app/lib/getUserDisplayName";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
type ClientInfoCardProps = {};
|
||||
@@ -16,6 +17,12 @@ export default function SiteInfoCard({}: ClientInfoCardProps) {
|
||||
const { client, updateClient } = useClientContext();
|
||||
const t = useTranslations();
|
||||
|
||||
const userDisplayName = getUserDisplayName({
|
||||
email: client.userEmail,
|
||||
name: client.userName,
|
||||
username: client.userUsername
|
||||
});
|
||||
|
||||
return (
|
||||
<Alert>
|
||||
<AlertDescription>
|
||||
@@ -25,8 +32,12 @@ export default function SiteInfoCard({}: ClientInfoCardProps) {
|
||||
<InfoSectionContent>{client.name}</InfoSectionContent>
|
||||
</InfoSection>
|
||||
<InfoSection>
|
||||
<InfoSectionTitle>{t("identifier")}</InfoSectionTitle>
|
||||
<InfoSectionContent>{client.niceId}</InfoSectionContent>
|
||||
<InfoSectionTitle>
|
||||
{userDisplayName ? t("user") : t("identifier")}
|
||||
</InfoSectionTitle>
|
||||
<InfoSectionContent>
|
||||
{userDisplayName || client.niceId}
|
||||
</InfoSectionContent>
|
||||
</InfoSection>
|
||||
<InfoSection>
|
||||
<InfoSectionTitle>{t("status")}</InfoSectionTitle>
|
||||
|
||||
@@ -250,6 +250,26 @@ export default function GenerateLicenseKeyForm({
|
||||
const submitLicenseRequest = async (payload: any) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
// Check if this is a business/enterprise license request
|
||||
if (payload.useCaseType === "business") {
|
||||
const response = await api.put<
|
||||
AxiosResponse<string>
|
||||
>(`/org/${orgId}/license/enterprise`, { ...payload, tier: "big_license" } );
|
||||
|
||||
console.log("Checkout session response:", response.data);
|
||||
const checkoutUrl = response.data.data;
|
||||
if (checkoutUrl) {
|
||||
window.location.href = checkoutUrl;
|
||||
} else {
|
||||
toast({
|
||||
title: "Failed to get checkout URL",
|
||||
description: "Please try again later",
|
||||
variant: "destructive"
|
||||
});
|
||||
setLoading(false);
|
||||
}
|
||||
} else {
|
||||
// Personal license flow
|
||||
const response = await api.put<
|
||||
AxiosResponse<GenerateNewLicenseResponse>
|
||||
>(`/org/${orgId}/license`, payload);
|
||||
@@ -265,6 +285,7 @@ export default function GenerateLicenseKeyForm({
|
||||
variant: "default"
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
toast({
|
||||
|
||||
@@ -10,12 +10,12 @@ import { Badge } from "./ui/badge";
|
||||
import moment from "moment";
|
||||
import { DataTable } from "./ui/data-table";
|
||||
import { GeneratedLicenseKey } from "@server/routers/generatedLicense/types";
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useState, useEffect } from "react";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { toast } from "@app/hooks/useToast";
|
||||
import { createApiClient, formatAxiosError } from "@app/lib/api";
|
||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||
import GenerateLicenseKeyForm from "./GenerateLicenseKeyForm";
|
||||
import NewPricingLicenseForm from "./NewPricingLicenseForm";
|
||||
|
||||
type GnerateLicenseKeysTableProps = {
|
||||
licenseKeys: GeneratedLicenseKey[];
|
||||
@@ -29,12 +29,15 @@ function obfuscateLicenseKey(key: string): string {
|
||||
return `${firstPart}••••••••••••••••••••${lastPart}`;
|
||||
}
|
||||
|
||||
const GENERATE_QUERY = "generate";
|
||||
|
||||
export default function GenerateLicenseKeysTable({
|
||||
licenseKeys,
|
||||
orgId
|
||||
}: GnerateLicenseKeysTableProps) {
|
||||
const t = useTranslations();
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
|
||||
const { env } = useEnvContext();
|
||||
const api = createApiClient({ env });
|
||||
@@ -42,6 +45,19 @@ export default function GenerateLicenseKeysTable({
|
||||
const [isRefreshing, setIsRefreshing] = useState(false);
|
||||
const [showGenerateForm, setShowGenerateForm] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (searchParams.get(GENERATE_QUERY) !== null) {
|
||||
setShowGenerateForm(true);
|
||||
const next = new URLSearchParams(searchParams);
|
||||
next.delete(GENERATE_QUERY);
|
||||
const qs = next.toString();
|
||||
const url = qs
|
||||
? `${window.location.pathname}?${qs}`
|
||||
: window.location.pathname;
|
||||
window.history.replaceState(null, "", url);
|
||||
}
|
||||
}, [searchParams]);
|
||||
|
||||
const handleLicenseGenerated = () => {
|
||||
// Refresh the data after license is generated
|
||||
refreshData();
|
||||
@@ -158,6 +174,48 @@ export default function GenerateLicenseKeysTable({
|
||||
: t("licenseTierPersonal");
|
||||
}
|
||||
},
|
||||
{
|
||||
accessorKey: "users",
|
||||
friendlyName: t("users"),
|
||||
header: ({ column }) => {
|
||||
return (
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() =>
|
||||
column.toggleSorting(column.getIsSorted() === "asc")
|
||||
}
|
||||
>
|
||||
{t("users")}
|
||||
<ArrowUpDown className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
);
|
||||
},
|
||||
cell: ({ row }) => {
|
||||
const users = row.original.users;
|
||||
return users === -1 ? "∞" : users;
|
||||
}
|
||||
},
|
||||
{
|
||||
accessorKey: "sites",
|
||||
friendlyName: t("sites"),
|
||||
header: ({ column }) => {
|
||||
return (
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() =>
|
||||
column.toggleSorting(column.getIsSorted() === "asc")
|
||||
}
|
||||
>
|
||||
{t("sites")}
|
||||
<ArrowUpDown className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
);
|
||||
},
|
||||
cell: ({ row }) => {
|
||||
const sites = row.original.sites;
|
||||
return sites === -1 ? "∞" : sites;
|
||||
}
|
||||
},
|
||||
{
|
||||
accessorKey: "terminateAt",
|
||||
friendlyName: t("licenseTableValidUntil"),
|
||||
@@ -198,7 +256,7 @@ export default function GenerateLicenseKeysTable({
|
||||
}}
|
||||
/>
|
||||
|
||||
<GenerateLicenseKeyForm
|
||||
<NewPricingLicenseForm
|
||||
open={showGenerateForm}
|
||||
setOpen={setShowGenerateForm}
|
||||
orgId={orgId}
|
||||
|
||||
913
src/components/NewPricingLicenseForm.tsx
Normal file
913
src/components/NewPricingLicenseForm.tsx
Normal file
@@ -0,0 +1,913 @@
|
||||
"use client";
|
||||
|
||||
import { Button } from "@app/components/ui/button";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage
|
||||
} from "@app/components/ui/form";
|
||||
import { Input } from "@app/components/ui/input";
|
||||
import { Checkbox } from "@app/components/ui/checkbox";
|
||||
import { toast } from "@app/hooks/useToast";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { AxiosResponse } from "axios";
|
||||
import { useState } from "react";
|
||||
import { useForm, type Resolver } from "react-hook-form";
|
||||
import { z } from "zod";
|
||||
import CopyTextBox from "@app/components/CopyTextBox";
|
||||
import {
|
||||
Credenza,
|
||||
CredenzaBody,
|
||||
CredenzaClose,
|
||||
CredenzaContent,
|
||||
CredenzaDescription,
|
||||
CredenzaFooter,
|
||||
CredenzaHeader,
|
||||
CredenzaTitle
|
||||
} from "@app/components/Credenza";
|
||||
import { formatAxiosError } from "@app/lib/api";
|
||||
import { createApiClient } from "@app/lib/api";
|
||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||
import { GenerateNewLicenseResponse } from "@server/routers/generatedLicense/types";
|
||||
import { useTranslations } from "next-intl";
|
||||
import React from "react";
|
||||
import { StrategySelect, StrategyOption } from "./StrategySelect";
|
||||
import { Alert, AlertDescription, AlertTitle } from "./ui/alert";
|
||||
import { InfoIcon } from "lucide-react";
|
||||
import { useUserContext } from "@app/hooks/useUserContext";
|
||||
|
||||
const TIER_TO_LICENSE_ID = {
|
||||
starter: "small_license",
|
||||
scale: "big_license"
|
||||
} as const;
|
||||
|
||||
type FormProps = {
|
||||
open: boolean;
|
||||
setOpen: (open: boolean) => void;
|
||||
orgId: string;
|
||||
onGenerated?: () => void;
|
||||
};
|
||||
|
||||
export default function NewPricingLicenseForm({
|
||||
open,
|
||||
setOpen,
|
||||
orgId,
|
||||
onGenerated
|
||||
}: FormProps) {
|
||||
const t = useTranslations();
|
||||
const { env } = useEnvContext();
|
||||
const api = createApiClient({ env });
|
||||
const { user } = useUserContext();
|
||||
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [generatedKey, setGeneratedKey] = useState<string | null>(null);
|
||||
const [personalUseOnly, setPersonalUseOnly] = useState(false);
|
||||
const [selectedTier, setSelectedTier] = useState<"starter" | "scale">(
|
||||
"starter"
|
||||
);
|
||||
|
||||
const personalFormSchema = z.object({
|
||||
email: z.email(),
|
||||
firstName: z.string().min(1),
|
||||
lastName: z.string().min(1),
|
||||
primaryUse: z.string().min(1),
|
||||
country: z.string().min(1),
|
||||
phoneNumber: z.string().optional(),
|
||||
agreedToTerms: z.boolean().refine((val) => val === true),
|
||||
complianceConfirmed: z.boolean().refine((val) => val === true)
|
||||
});
|
||||
|
||||
const businessFormSchema = z.object({
|
||||
email: z.email(),
|
||||
firstName: z.string().min(1),
|
||||
lastName: z.string().min(1),
|
||||
primaryUse: z.string().min(1),
|
||||
industry: z.string().min(1),
|
||||
companyName: z.string().min(1),
|
||||
companyWebsite: z.string().optional(),
|
||||
companyPhoneNumber: z.string().optional(),
|
||||
agreedToTerms: z.boolean().refine((val) => val === true),
|
||||
complianceConfirmed: z.boolean().refine((val) => val === true)
|
||||
});
|
||||
|
||||
type PersonalFormData = z.infer<typeof personalFormSchema>;
|
||||
type BusinessFormData = z.infer<typeof businessFormSchema>;
|
||||
|
||||
const personalForm = useForm<PersonalFormData>({
|
||||
resolver: zodResolver(personalFormSchema) as Resolver<PersonalFormData>,
|
||||
defaultValues: {
|
||||
email: user?.email || "",
|
||||
firstName: "",
|
||||
lastName: "",
|
||||
primaryUse: "",
|
||||
country: "",
|
||||
phoneNumber: "",
|
||||
agreedToTerms: false,
|
||||
complianceConfirmed: false
|
||||
}
|
||||
});
|
||||
|
||||
const businessForm = useForm<BusinessFormData>({
|
||||
resolver: zodResolver(businessFormSchema) as Resolver<BusinessFormData>,
|
||||
defaultValues: {
|
||||
email: user?.email || "",
|
||||
firstName: "",
|
||||
lastName: "",
|
||||
primaryUse: "",
|
||||
industry: "",
|
||||
companyName: "",
|
||||
companyWebsite: "",
|
||||
companyPhoneNumber: "",
|
||||
agreedToTerms: false,
|
||||
complianceConfirmed: false
|
||||
}
|
||||
});
|
||||
|
||||
React.useEffect(() => {
|
||||
if (open) {
|
||||
resetForm();
|
||||
setGeneratedKey(null);
|
||||
setPersonalUseOnly(false);
|
||||
setSelectedTier("starter");
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
function resetForm() {
|
||||
personalForm.reset({
|
||||
email: user?.email || "",
|
||||
firstName: "",
|
||||
lastName: "",
|
||||
primaryUse: "",
|
||||
country: "",
|
||||
phoneNumber: "",
|
||||
agreedToTerms: false,
|
||||
complianceConfirmed: false
|
||||
});
|
||||
businessForm.reset({
|
||||
email: user?.email || "",
|
||||
firstName: "",
|
||||
lastName: "",
|
||||
primaryUse: "",
|
||||
industry: "",
|
||||
companyName: "",
|
||||
companyWebsite: "",
|
||||
companyPhoneNumber: "",
|
||||
agreedToTerms: false,
|
||||
complianceConfirmed: false
|
||||
});
|
||||
}
|
||||
|
||||
const tierOptions: StrategyOption<"starter" | "scale">[] = [
|
||||
{
|
||||
id: "starter",
|
||||
title: t("newPricingLicenseForm.tiers.starter.title"),
|
||||
description: t("newPricingLicenseForm.tiers.starter.description")
|
||||
},
|
||||
{
|
||||
id: "scale",
|
||||
title: t("newPricingLicenseForm.tiers.scale.title"),
|
||||
description: t("newPricingLicenseForm.tiers.scale.description")
|
||||
}
|
||||
];
|
||||
|
||||
const submitLicenseRequest = async (
|
||||
payload: Record<string, unknown>
|
||||
): Promise<void> => {
|
||||
setLoading(true);
|
||||
try {
|
||||
// Check if this is a business/enterprise license request
|
||||
if (!personalUseOnly) {
|
||||
const response = await api.put<AxiosResponse<string>>(
|
||||
`/org/${orgId}/license/enterprise`,
|
||||
{ ...payload, tier: TIER_TO_LICENSE_ID[selectedTier] }
|
||||
);
|
||||
|
||||
console.log("Checkout session response:", response.data);
|
||||
const checkoutUrl = response.data.data;
|
||||
if (checkoutUrl) {
|
||||
window.location.href = checkoutUrl;
|
||||
} else {
|
||||
toast({
|
||||
title: "Failed to get checkout URL",
|
||||
description: "Please try again later",
|
||||
variant: "destructive"
|
||||
});
|
||||
setLoading(false);
|
||||
}
|
||||
} else {
|
||||
// Personal license flow
|
||||
const response = await api.put<
|
||||
AxiosResponse<GenerateNewLicenseResponse>
|
||||
>(`/org/${orgId}/license`, payload);
|
||||
|
||||
if (response.data.data?.licenseKey?.licenseKey) {
|
||||
setGeneratedKey(response.data.data.licenseKey.licenseKey);
|
||||
onGenerated?.();
|
||||
toast({
|
||||
title: t("generateLicenseKeyForm.toasts.success.title"),
|
||||
description: t(
|
||||
"generateLicenseKeyForm.toasts.success.description"
|
||||
),
|
||||
variant: "default"
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
toast({
|
||||
title: t("generateLicenseKeyForm.toasts.error.title"),
|
||||
description: formatAxiosError(
|
||||
e,
|
||||
t("generateLicenseKeyForm.toasts.error.description")
|
||||
),
|
||||
variant: "destructive"
|
||||
});
|
||||
}
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
const onSubmitPersonal = async (values: PersonalFormData) => {
|
||||
await submitLicenseRequest({
|
||||
email: values.email,
|
||||
useCaseType: "personal",
|
||||
personal: {
|
||||
firstName: values.firstName,
|
||||
lastName: values.lastName,
|
||||
aboutYou: { primaryUse: values.primaryUse },
|
||||
personalInfo: {
|
||||
country: values.country,
|
||||
phoneNumber: values.phoneNumber || ""
|
||||
}
|
||||
},
|
||||
business: undefined,
|
||||
consent: {
|
||||
agreedToTerms: values.agreedToTerms,
|
||||
acknowledgedPrivacyPolicy: values.agreedToTerms,
|
||||
complianceConfirmed: values.complianceConfirmed
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const onSubmitBusiness = async (values: BusinessFormData) => {
|
||||
const payload = {
|
||||
email: values.email,
|
||||
useCaseType: "business",
|
||||
personal: undefined,
|
||||
business: {
|
||||
firstName: values.firstName,
|
||||
lastName: values.lastName,
|
||||
jobTitle: "N/A",
|
||||
aboutYou: {
|
||||
primaryUse: values.primaryUse,
|
||||
industry: values.industry,
|
||||
prospectiveUsers: 100,
|
||||
prospectiveSites: 100
|
||||
},
|
||||
companyInfo: {
|
||||
companyName: values.companyName,
|
||||
countryOfResidence: "N/A",
|
||||
stateProvinceRegion: "N/A",
|
||||
postalZipCode: "N/A",
|
||||
companyWebsite: values.companyWebsite || "",
|
||||
companyPhoneNumber: values.companyPhoneNumber || ""
|
||||
}
|
||||
},
|
||||
consent: {
|
||||
agreedToTerms: values.agreedToTerms,
|
||||
acknowledgedPrivacyPolicy: values.agreedToTerms,
|
||||
complianceConfirmed: values.complianceConfirmed
|
||||
}
|
||||
};
|
||||
|
||||
await submitLicenseRequest(payload);
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
setOpen(false);
|
||||
setGeneratedKey(null);
|
||||
resetForm();
|
||||
};
|
||||
|
||||
return (
|
||||
<Credenza open={open} onOpenChange={handleClose}>
|
||||
<CredenzaContent className="max-w-4xl">
|
||||
<CredenzaHeader>
|
||||
<CredenzaTitle>
|
||||
{t("newPricingLicenseForm.title")}
|
||||
</CredenzaTitle>
|
||||
<CredenzaDescription>
|
||||
{t("newPricingLicenseForm.description")}
|
||||
</CredenzaDescription>
|
||||
</CredenzaHeader>
|
||||
<CredenzaBody>
|
||||
<div className="space-y-6">
|
||||
{generatedKey ? (
|
||||
<div className="space-y-4">
|
||||
<CopyTextBox
|
||||
text={generatedKey}
|
||||
wrapText={false}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* Tier selection - required when not personal use */}
|
||||
{!personalUseOnly && (
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">
|
||||
{t(
|
||||
"newPricingLicenseForm.chooseTier"
|
||||
)}
|
||||
</label>
|
||||
<StrategySelect
|
||||
options={tierOptions}
|
||||
defaultValue={selectedTier}
|
||||
onChange={(value) =>
|
||||
setSelectedTier(value)
|
||||
}
|
||||
cols={2}
|
||||
/>
|
||||
<a
|
||||
href="https://pangolin.net/pricing"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-sm text-primary hover:underline"
|
||||
>
|
||||
{t(
|
||||
"newPricingLicenseForm.viewPricingLink"
|
||||
)}
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Personal use only checkbox at the bottom of options */}
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id="personal-use-only"
|
||||
checked={personalUseOnly}
|
||||
onCheckedChange={(checked) => {
|
||||
setPersonalUseOnly(
|
||||
checked === true
|
||||
);
|
||||
if (checked) {
|
||||
businessForm.reset();
|
||||
} else {
|
||||
personalForm.reset();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<label
|
||||
htmlFor="personal-use-only"
|
||||
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
|
||||
>
|
||||
{t(
|
||||
"newPricingLicenseForm.personalUseOnly"
|
||||
)}
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{/* License disclosure - only when personal use */}
|
||||
{personalUseOnly && (
|
||||
<Alert variant="neutral">
|
||||
<InfoIcon className="h-4 w-4" />
|
||||
<AlertTitle>
|
||||
{t(
|
||||
"generateLicenseKeyForm.alerts.commercialUseDisclosure.title"
|
||||
)}
|
||||
</AlertTitle>
|
||||
<AlertDescription>
|
||||
{t(
|
||||
"generateLicenseKeyForm.alerts.commercialUseDisclosure.description"
|
||||
)
|
||||
.split(
|
||||
"Fossorial Commercial License Terms"
|
||||
)
|
||||
.map((part, index) => (
|
||||
<span key={index}>
|
||||
{part}
|
||||
{index === 0 && (
|
||||
<a
|
||||
href="https://pangolin.net/fcl.html"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-primary hover:underline"
|
||||
>
|
||||
Fossorial
|
||||
Commercial
|
||||
License Terms
|
||||
</a>
|
||||
)}
|
||||
</span>
|
||||
))}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* Personal form: only when personal use only is checked */}
|
||||
{personalUseOnly && (
|
||||
<Form {...personalForm}>
|
||||
<form
|
||||
onSubmit={personalForm.handleSubmit(
|
||||
onSubmitPersonal
|
||||
)}
|
||||
className="space-y-4"
|
||||
id="new-pricing-license-personal-form"
|
||||
>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<FormField
|
||||
control={
|
||||
personalForm.control
|
||||
}
|
||||
name="firstName"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t(
|
||||
"generateLicenseKeyForm.form.firstName"
|
||||
)}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={
|
||||
personalForm.control
|
||||
}
|
||||
name="lastName"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t(
|
||||
"generateLicenseKeyForm.form.lastName"
|
||||
)}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<FormField
|
||||
control={personalForm.control}
|
||||
name="primaryUse"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t(
|
||||
"generateLicenseKeyForm.form.primaryUseQuestion"
|
||||
)}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<FormField
|
||||
control={
|
||||
personalForm.control
|
||||
}
|
||||
name="country"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t(
|
||||
"generateLicenseKeyForm.form.country"
|
||||
)}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={
|
||||
personalForm.control
|
||||
}
|
||||
name="phoneNumber"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t(
|
||||
"generateLicenseKeyForm.form.phoneNumberOptional"
|
||||
)}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4 pt-4">
|
||||
<FormField
|
||||
control={
|
||||
personalForm.control
|
||||
}
|
||||
name="agreedToTerms"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex flex-row items-start space-x-3 space-y-0">
|
||||
<FormControl>
|
||||
<Checkbox
|
||||
checked={
|
||||
field.value
|
||||
}
|
||||
onCheckedChange={
|
||||
field.onChange
|
||||
}
|
||||
/>
|
||||
</FormControl>
|
||||
<div className="space-y-1 leading-none">
|
||||
<FormLabel className="text-sm font-normal">
|
||||
<div>
|
||||
{t(
|
||||
"signUpTerms.IAgreeToThe"
|
||||
)}{" "}
|
||||
<a
|
||||
href="https://pangolin.net/terms-of-service.html"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-primary hover:underline"
|
||||
>
|
||||
{t(
|
||||
"signUpTerms.termsOfService"
|
||||
)}{" "}
|
||||
</a>
|
||||
{t(
|
||||
"signUpTerms.and"
|
||||
)}{" "}
|
||||
<a
|
||||
href="https://pangolin.net/privacy-policy.html"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-primary hover:underline"
|
||||
>
|
||||
{t(
|
||||
"signUpTerms.privacyPolicy"
|
||||
)}
|
||||
</a>
|
||||
</div>
|
||||
</FormLabel>
|
||||
<FormMessage />
|
||||
</div>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={
|
||||
personalForm.control
|
||||
}
|
||||
name="complianceConfirmed"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex flex-row items-start space-x-3 space-y-0">
|
||||
<FormControl>
|
||||
<Checkbox
|
||||
checked={
|
||||
field.value
|
||||
}
|
||||
onCheckedChange={
|
||||
field.onChange
|
||||
}
|
||||
/>
|
||||
</FormControl>
|
||||
<div className="space-y-1 leading-none">
|
||||
<FormLabel className="text-sm font-normal">
|
||||
<div>
|
||||
{t(
|
||||
"generateLicenseKeyForm.form.complianceConfirmation"
|
||||
)}{" "}
|
||||
<a
|
||||
href="https://pangolin.net/fcl.html"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-primary hover:underline"
|
||||
>
|
||||
https://pangolin.net/fcl.html
|
||||
</a>
|
||||
</div>
|
||||
</FormLabel>
|
||||
<FormMessage />
|
||||
</div>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
)}
|
||||
|
||||
{/* Business form: when not personal use - enter business info then continue to checkout */}
|
||||
{!personalUseOnly && (
|
||||
<Form {...businessForm}>
|
||||
<form
|
||||
onSubmit={businessForm.handleSubmit(
|
||||
onSubmitBusiness
|
||||
)}
|
||||
className="space-y-4"
|
||||
id="new-pricing-license-business-form"
|
||||
>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<FormField
|
||||
control={
|
||||
businessForm.control
|
||||
}
|
||||
name="firstName"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t(
|
||||
"generateLicenseKeyForm.form.firstName"
|
||||
)}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={
|
||||
businessForm.control
|
||||
}
|
||||
name="lastName"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t(
|
||||
"generateLicenseKeyForm.form.lastName"
|
||||
)}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<FormField
|
||||
control={businessForm.control}
|
||||
name="primaryUse"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t(
|
||||
"generateLicenseKeyForm.form.primaryUseQuestion"
|
||||
)}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={businessForm.control}
|
||||
name="industry"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t(
|
||||
"generateLicenseKeyForm.form.industryQuestion"
|
||||
)}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={businessForm.control}
|
||||
name="companyName"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t(
|
||||
"generateLicenseKeyForm.form.companyName"
|
||||
)}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<FormField
|
||||
control={
|
||||
businessForm.control
|
||||
}
|
||||
name="companyWebsite"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t(
|
||||
"generateLicenseKeyForm.form.companyWebsite"
|
||||
)}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={
|
||||
businessForm.control
|
||||
}
|
||||
name="companyPhoneNumber"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t(
|
||||
"generateLicenseKeyForm.form.companyPhoneNumber"
|
||||
)}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4 pt-4">
|
||||
<FormField
|
||||
control={
|
||||
businessForm.control
|
||||
}
|
||||
name="agreedToTerms"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex flex-row items-start space-x-3 space-y-0">
|
||||
<FormControl>
|
||||
<Checkbox
|
||||
checked={
|
||||
field.value
|
||||
}
|
||||
onCheckedChange={
|
||||
field.onChange
|
||||
}
|
||||
/>
|
||||
</FormControl>
|
||||
<div className="space-y-1 leading-none">
|
||||
<FormLabel className="text-sm font-normal">
|
||||
<div>
|
||||
{t(
|
||||
"signUpTerms.IAgreeToThe"
|
||||
)}{" "}
|
||||
<a
|
||||
href="https://pangolin.net/terms-of-service.html"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-primary hover:underline"
|
||||
>
|
||||
{t(
|
||||
"signUpTerms.termsOfService"
|
||||
)}{" "}
|
||||
</a>
|
||||
{t(
|
||||
"signUpTerms.and"
|
||||
)}{" "}
|
||||
<a
|
||||
href="https://pangolin.net/privacy-policy.html"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-primary hover:underline"
|
||||
>
|
||||
{t(
|
||||
"signUpTerms.privacyPolicy"
|
||||
)}
|
||||
</a>
|
||||
</div>
|
||||
</FormLabel>
|
||||
<FormMessage />
|
||||
</div>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={
|
||||
businessForm.control
|
||||
}
|
||||
name="complianceConfirmed"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex flex-row items-start space-x-3 space-y-0">
|
||||
<FormControl>
|
||||
<Checkbox
|
||||
checked={
|
||||
field.value
|
||||
}
|
||||
onCheckedChange={
|
||||
field.onChange
|
||||
}
|
||||
/>
|
||||
</FormControl>
|
||||
<div className="space-y-1 leading-none">
|
||||
<FormLabel className="text-sm font-normal">
|
||||
<div>
|
||||
{t(
|
||||
"generateLicenseKeyForm.form.complianceConfirmation"
|
||||
)}{" "}
|
||||
<a
|
||||
href="https://pangolin.net/fcl.html"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-primary hover:underline"
|
||||
>
|
||||
https://pangolin.net/fcl.html
|
||||
</a>
|
||||
</div>
|
||||
</FormLabel>
|
||||
<FormMessage />
|
||||
</div>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</CredenzaBody>
|
||||
<CredenzaFooter>
|
||||
<CredenzaClose asChild>
|
||||
<Button variant="outline">
|
||||
{t("generateLicenseKeyForm.buttons.close")}
|
||||
</Button>
|
||||
</CredenzaClose>
|
||||
|
||||
{!generatedKey && personalUseOnly && (
|
||||
<Button
|
||||
type="submit"
|
||||
form="new-pricing-license-personal-form"
|
||||
disabled={loading}
|
||||
loading={loading}
|
||||
>
|
||||
{t(
|
||||
"generateLicenseKeyForm.buttons.generateLicenseKey"
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{!generatedKey && !personalUseOnly && (
|
||||
<Button
|
||||
type="submit"
|
||||
form="new-pricing-license-business-form"
|
||||
disabled={loading}
|
||||
loading={loading}
|
||||
>
|
||||
{t(
|
||||
"newPricingLicenseForm.buttons.continueToCheckout"
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
</CredenzaFooter>
|
||||
</CredenzaContent>
|
||||
</Credenza>
|
||||
);
|
||||
}
|
||||
24
src/components/RedirectToOrg.tsx
Normal file
24
src/components/RedirectToOrg.tsx
Normal file
@@ -0,0 +1,24 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { getInternalRedirectTarget } from "@app/lib/internalRedirect";
|
||||
|
||||
type RedirectToOrgProps = {
|
||||
targetOrgId: string;
|
||||
};
|
||||
|
||||
export default function RedirectToOrg({ targetOrgId }: RedirectToOrgProps) {
|
||||
const router = useRouter();
|
||||
|
||||
useEffect(() => {
|
||||
try {
|
||||
const target = getInternalRedirectTarget(targetOrgId);
|
||||
router.replace(target);
|
||||
} catch {
|
||||
router.replace(`/${targetOrgId}`);
|
||||
}
|
||||
}, [targetOrgId, router]);
|
||||
|
||||
return null;
|
||||
}
|
||||
27
src/components/StoreInternalRedirect.tsx
Normal file
27
src/components/StoreInternalRedirect.tsx
Normal file
@@ -0,0 +1,27 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { INTERNAL_REDIRECT_KEY } from "@app/lib/internalRedirect";
|
||||
|
||||
const TTL_MS = 10 * 60 * 1000; // 10 minutes
|
||||
|
||||
export default function StoreInternalRedirect() {
|
||||
useEffect(() => {
|
||||
if (typeof window === "undefined") return;
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const value = params.get("internal_redirect");
|
||||
if (value != null && value !== "") {
|
||||
try {
|
||||
const payload = JSON.stringify({
|
||||
path: value,
|
||||
expiresAt: Date.now() + TTL_MS
|
||||
});
|
||||
window.localStorage.setItem(INTERNAL_REDIRECT_KEY, payload);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -226,6 +226,21 @@ export default function SupporterStatus({
|
||||
</Link>
|
||||
</p>
|
||||
|
||||
<div className="my-4 p-4 border border-blue-500/50 bg-blue-500/10 rounded-lg">
|
||||
<p className="text-sm">
|
||||
<strong>Business & Enterprise Users:</strong> For larger organizations or teams requiring advanced features, consider our self-serve enterprise license and Enterprise Edition.{" "}
|
||||
<Link
|
||||
href="https://pangolin.net/pricing?hosting=self-host"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="underline inline-flex items-center gap-1"
|
||||
>
|
||||
Learn more
|
||||
<ExternalLink className="h-3 w-3" />
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="py-6">
|
||||
<p className="mb-3 text-center">
|
||||
{t("supportKeyOptions")}
|
||||
|
||||
@@ -1,41 +1,13 @@
|
||||
"use client";
|
||||
import * as React from "react";
|
||||
import * as NProgress from "nprogress";
|
||||
|
||||
import NextTopLoader from "nextjs-toploader";
|
||||
import { usePathname, useRouter, useSearchParams } from "next/navigation";
|
||||
|
||||
export function TopLoader() {
|
||||
return (
|
||||
<>
|
||||
<NextTopLoader showSpinner={false} color="var(--color-primary)" />
|
||||
<FinishingLoader />
|
||||
</>
|
||||
<NextTopLoader
|
||||
color="var(--color-primary)"
|
||||
showSpinner={false}
|
||||
height={2}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function FinishingLoader() {
|
||||
const pathname = usePathname();
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
React.useEffect(() => {
|
||||
NProgress.done();
|
||||
}, [pathname, router, searchParams]);
|
||||
React.useEffect(() => {
|
||||
const linkClickListener = (ev: MouseEvent) => {
|
||||
const element = ev.target as HTMLElement;
|
||||
const closestlink = element.closest("a");
|
||||
const isOpenToNewTabClick =
|
||||
ev.ctrlKey ||
|
||||
ev.shiftKey ||
|
||||
ev.metaKey || // apple
|
||||
(ev.button && ev.button == 1); // middle click, >IE9 + everyone else
|
||||
|
||||
if (closestlink && isOpenToNewTabClick) {
|
||||
NProgress.done();
|
||||
}
|
||||
};
|
||||
window.addEventListener("click", linkClickListener);
|
||||
return () => window.removeEventListener("click", linkClickListener);
|
||||
}, []);
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -28,7 +28,6 @@ import {
|
||||
TableRow
|
||||
} from "@app/components/ui/table";
|
||||
import { Tabs, TabsList, TabsTrigger, TabsContent } from "@app/components/ui/tabs";
|
||||
import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog";
|
||||
import { Loader2, RefreshCw } from "lucide-react";
|
||||
import moment from "moment";
|
||||
import { useUserContext } from "@app/hooks/useUserContext";
|
||||
@@ -59,8 +58,6 @@ export default function ViewDevicesDialog({
|
||||
|
||||
const [devices, setDevices] = useState<Device[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [isArchiveModalOpen, setIsArchiveModalOpen] = useState(false);
|
||||
const [selectedDevice, setSelectedDevice] = useState<Device | null>(null);
|
||||
const [activeTab, setActiveTab] = useState<"available" | "archived">("available");
|
||||
|
||||
const fetchDevices = async () => {
|
||||
@@ -108,8 +105,6 @@ export default function ViewDevicesDialog({
|
||||
d.olmId === olmId ? { ...d, archived: true } : d
|
||||
)
|
||||
);
|
||||
setIsArchiveModalOpen(false);
|
||||
setSelectedDevice(null);
|
||||
} catch (error: any) {
|
||||
console.error("Error archiving device:", error);
|
||||
toast({
|
||||
@@ -153,8 +148,6 @@ export default function ViewDevicesDialog({
|
||||
|
||||
function reset() {
|
||||
setDevices([]);
|
||||
setSelectedDevice(null);
|
||||
setIsArchiveModalOpen(false);
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -263,12 +256,7 @@ export default function ViewDevicesDialog({
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
setSelectedDevice(
|
||||
device
|
||||
);
|
||||
setIsArchiveModalOpen(
|
||||
true
|
||||
);
|
||||
archiveDevice(device.olmId);
|
||||
}}
|
||||
>
|
||||
{t(
|
||||
@@ -361,34 +349,6 @@ export default function ViewDevicesDialog({
|
||||
</CredenzaFooter>
|
||||
</CredenzaContent>
|
||||
</Credenza>
|
||||
|
||||
{selectedDevice && (
|
||||
<ConfirmDeleteDialog
|
||||
open={isArchiveModalOpen}
|
||||
setOpen={(val) => {
|
||||
setIsArchiveModalOpen(val);
|
||||
if (!val) {
|
||||
setSelectedDevice(null);
|
||||
}
|
||||
}}
|
||||
dialog={
|
||||
<div className="space-y-2">
|
||||
<p>
|
||||
{t("deviceQuestionArchive") ||
|
||||
"Are you sure you want to archive this device?"}
|
||||
</p>
|
||||
<p>
|
||||
{t("deviceMessageArchive") ||
|
||||
"The device will be archived and removed from your active devices list."}
|
||||
</p>
|
||||
</div>
|
||||
}
|
||||
buttonText={t("deviceArchiveConfirm") || "Archive Device"}
|
||||
onConfirm={async () => archiveDevice(selectedDevice.olmId)}
|
||||
string={selectedDevice.name || selectedDevice.olmId}
|
||||
title={t("archiveDevice") || "Archive Device"}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -91,7 +91,7 @@ export function NewtSiteInstallCommands({
|
||||
- NEWT_SECRET=${secret}${acceptClientsEnv}`
|
||||
],
|
||||
"Docker Run": [
|
||||
`docker run -dit fosrl/newt --id ${id} --secret ${secret} --endpoint ${endpoint}${acceptClientsFlag}`
|
||||
`docker run -dit --network host fosrl/newt --id ${id} --secret ${secret} --endpoint ${endpoint}${acceptClientsFlag}`
|
||||
]
|
||||
},
|
||||
kubernetes: {
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
type CleanRedirectOptions = {
|
||||
fallback?: string;
|
||||
maxRedirectDepth?: number;
|
||||
/** When true, preserve all query params on the path (for internal redirects). Default false. */
|
||||
allowAllQueryParams?: boolean;
|
||||
};
|
||||
|
||||
const ALLOWED_QUERY_PARAMS = new Set([
|
||||
@@ -16,14 +18,18 @@ export function cleanRedirect(
|
||||
input: string,
|
||||
options: CleanRedirectOptions = {}
|
||||
): string {
|
||||
const { fallback = "/", maxRedirectDepth = 2 } = options;
|
||||
const {
|
||||
fallback = "/",
|
||||
maxRedirectDepth = 2,
|
||||
allowAllQueryParams = false
|
||||
} = options;
|
||||
|
||||
if (!input || typeof input !== "string") {
|
||||
return fallback;
|
||||
}
|
||||
|
||||
try {
|
||||
return sanitizeUrl(input, fallback, maxRedirectDepth);
|
||||
return sanitizeUrl(input, fallback, maxRedirectDepth, allowAllQueryParams);
|
||||
} catch {
|
||||
return fallback;
|
||||
}
|
||||
@@ -32,7 +38,8 @@ export function cleanRedirect(
|
||||
function sanitizeUrl(
|
||||
input: string,
|
||||
fallback: string,
|
||||
remainingRedirectDepth: number
|
||||
remainingRedirectDepth: number,
|
||||
allowAllQueryParams: boolean = false
|
||||
): string {
|
||||
if (
|
||||
input.startsWith("javascript:") ||
|
||||
@@ -56,7 +63,7 @@ function sanitizeUrl(
|
||||
const cleanParams = new URLSearchParams();
|
||||
|
||||
for (const [key, value] of url.searchParams.entries()) {
|
||||
if (!ALLOWED_QUERY_PARAMS.has(key)) {
|
||||
if (!allowAllQueryParams && !ALLOWED_QUERY_PARAMS.has(key)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -68,7 +75,8 @@ function sanitizeUrl(
|
||||
const cleanedRedirect = sanitizeUrl(
|
||||
value,
|
||||
"",
|
||||
remainingRedirectDepth - 1
|
||||
remainingRedirectDepth - 1,
|
||||
allowAllQueryParams
|
||||
);
|
||||
|
||||
if (cleanedRedirect) {
|
||||
|
||||
51
src/lib/internalRedirect.ts
Normal file
51
src/lib/internalRedirect.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import { cleanRedirect } from "@app/lib/cleanRedirect";
|
||||
|
||||
export const INTERNAL_REDIRECT_KEY = "internal_redirect";
|
||||
|
||||
/**
|
||||
* Consumes the internal_redirect value from localStorage if present and valid
|
||||
* (within TTL). Removes it from storage. Returns the path segment (with leading
|
||||
* slash) to append to an orgId, or null if none/expired/invalid.
|
||||
*/
|
||||
export function consumeInternalRedirectPath(): string | null {
|
||||
if (typeof window === "undefined") return null;
|
||||
try {
|
||||
const raw = window.localStorage.getItem(INTERNAL_REDIRECT_KEY);
|
||||
if (raw == null || raw === "") return null;
|
||||
|
||||
window.localStorage.removeItem(INTERNAL_REDIRECT_KEY);
|
||||
|
||||
const { path: storedPath, expiresAt } = JSON.parse(raw) as {
|
||||
path?: string;
|
||||
expiresAt?: number;
|
||||
};
|
||||
if (
|
||||
typeof storedPath !== "string" ||
|
||||
storedPath === "" ||
|
||||
typeof expiresAt !== "number" ||
|
||||
Date.now() > expiresAt
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const cleaned = cleanRedirect(storedPath, {
|
||||
fallback: "",
|
||||
allowAllQueryParams: true
|
||||
});
|
||||
if (!cleaned) return null;
|
||||
|
||||
return cleaned.startsWith("/") ? cleaned : `/${cleaned}`;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the full redirect target for an org: either `/${orgId}` or
|
||||
* `/${orgId}${path}` if a valid internal_redirect was stored. Consumes the
|
||||
* stored value.
|
||||
*/
|
||||
export function getInternalRedirectTarget(orgId: string): string {
|
||||
const path = consumeInternalRedirectPath();
|
||||
return path ? `/${orgId}${path}` : `/${orgId}`;
|
||||
}
|
||||
@@ -33,8 +33,11 @@ export function SubscriptionStatusProvider({
|
||||
};
|
||||
|
||||
const isActive = () => {
|
||||
if (subscriptionStatus?.subscription?.status === "active") {
|
||||
return true;
|
||||
if (subscriptionStatus?.subscriptions) {
|
||||
// Check if any subscription is active
|
||||
return subscriptionStatus.subscriptions.some(
|
||||
(sub) => sub.subscription?.status === "active"
|
||||
);
|
||||
}
|
||||
return false;
|
||||
};
|
||||
@@ -42,11 +45,14 @@ export function SubscriptionStatusProvider({
|
||||
const getTier = () => {
|
||||
const tierPriceSet = getTierPriceSet(env, sandbox_mode);
|
||||
|
||||
if (subscriptionStatus?.items && subscriptionStatus.items.length > 0) {
|
||||
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 = subscriptionStatus.items.find(
|
||||
const matchingItem = items.find(
|
||||
(item) => item.priceId === priceId
|
||||
);
|
||||
if (matchingItem) {
|
||||
@@ -54,6 +60,8 @@ export function SubscriptionStatusProvider({
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user