Merge branch 'dev' into refactor/save-button-positions

This commit is contained in:
Fred KISSIE
2025-12-18 01:46:13 +01:00
100 changed files with 5856 additions and 1149 deletions

View File

@@ -12,11 +12,12 @@ on:
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
- name: Checkout repository
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
- uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0
- name: Install Node
uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0
with:
node-version: '22'
@@ -57,8 +58,26 @@ jobs:
echo "App failed to start"
exit 1
build-sqlite:
runs-on: ubuntu-latest
steps:
- 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 build-sqlite
run: make dev-build-sqlite
build-postgres:
runs-on: ubuntu-latest
steps:
- 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 build-pg
run: make dev-build-pg

View File

@@ -43,23 +43,25 @@ 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
WORKDIR /app
# Curl used for the health checks
# Python and build tools needed for better-sqlite3 native compilation
RUN apk add --no-cache curl tzdata python3 make g++
# Only curl and tzdata needed at runtime - no build tools!
RUN apk add --no-cache curl tzdata
# COPY package.json package-lock.json ./
COPY package*.json ./
RUN npm ci --omit=dev && npm cache clean --force
# 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/package.json ./package.json
COPY ./cli/wrapper.sh /usr/local/bin/pangctl
RUN chmod +x /usr/local/bin/pangctl ./dist/cli.mjs

View File

@@ -0,0 +1,36 @@
import { CommandModule } from "yargs";
import { db, exitNodes } from "@server/db";
import { eq } from "drizzle-orm";
type ClearExitNodesArgs = { };
export const clearExitNodes: CommandModule<
{},
ClearExitNodesArgs
> = {
command: "clear-exit-nodes",
describe:
"Clear all exit nodes from the database",
// no args
builder: (yargs) => {
return yargs;
},
handler: async (argv: {}) => {
try {
console.log(`Clearing all exit nodes from the database`);
// Delete all exit nodes
const deletedCount = await db
.delete(exitNodes)
.where(eq(exitNodes.exitNodeId, exitNodes.exitNodeId)); // delete all
console.log(`Deleted ${deletedCount.changes} exit node(s) from the database`);
process.exit(0);
} catch (error) {
console.error("Error:", error);
process.exit(1);
}
}
};

View File

@@ -0,0 +1,284 @@
import { CommandModule } from "yargs";
import { db, idpOidcConfig, licenseKey } from "@server/db";
import { encrypt, decrypt } from "@server/lib/crypto";
import { configFilePath1, configFilePath2 } from "@server/lib/consts";
import { eq } from "drizzle-orm";
import fs from "fs";
import yaml from "js-yaml";
type RotateServerSecretArgs = {
oldSecret: string;
newSecret: string;
force?: boolean;
};
export const rotateServerSecret: CommandModule<
{},
RotateServerSecretArgs
> = {
command: "rotate-server-secret",
describe:
"Rotate the server secret by decrypting all encrypted values with the old secret and re-encrypting with a new secret",
builder: (yargs) => {
return yargs
.option("oldSecret", {
type: "string",
demandOption: true,
describe: "The current server secret (for verification)"
})
.option("newSecret", {
type: "string",
demandOption: true,
describe: "The new server secret to use"
})
.option("force", {
type: "boolean",
default: false,
describe:
"Force rotation even if the old secret doesn't match the config file. " +
"Use this if you know the old secret is correct but the config file is out of sync. " +
"WARNING: This will attempt to decrypt all values with the provided old secret. " +
"If the old secret is incorrect, the rotation will fail or corrupt data."
});
},
handler: async (argv: {
oldSecret: string;
newSecret: string;
force?: boolean;
}) => {
try {
// Determine which config file exists
const configPath = fs.existsSync(configFilePath1)
? configFilePath1
: fs.existsSync(configFilePath2)
? configFilePath2
: null;
if (!configPath) {
console.error(
"Error: Config file not found. Expected config.yml or config.yaml in the config directory."
);
process.exit(1);
}
// Read current config
const configContent = fs.readFileSync(configPath, "utf8");
const config = yaml.load(configContent) as any;
if (!config?.server?.secret) {
console.error(
"Error: No server secret found in config file. Cannot rotate."
);
process.exit(1);
}
const configSecret = config.server.secret;
const oldSecret = argv.oldSecret;
const newSecret = argv.newSecret;
const force = argv.force || false;
// Verify that the provided old secret matches the one in config
if (configSecret !== oldSecret) {
if (!force) {
console.error(
"Error: The provided old secret does not match the secret in the config file."
);
console.error(
"\nIf you are certain the old secret is correct and the config file is out of sync,"
);
console.error(
"you can use the --force flag to bypass this check."
);
console.error(
"\nWARNING: Using --force with an incorrect old secret will cause the rotation to fail"
);
console.error(
"or corrupt encrypted data. Only use --force if you are absolutely certain."
);
process.exit(1);
} else {
console.warn(
"\nWARNING: Using --force flag. Bypassing old secret verification."
);
console.warn(
"The provided old secret does not match the config file, but proceeding anyway."
);
console.warn(
"If the old secret is incorrect, this operation will fail or corrupt data.\n"
);
}
}
// Validate new secret
if (newSecret.length < 8) {
console.error(
"Error: New secret must be at least 8 characters long"
);
process.exit(1);
}
if (oldSecret === newSecret) {
console.error("Error: New secret must be different from old secret");
process.exit(1);
}
console.log("Starting server secret rotation...");
console.log("This will decrypt and re-encrypt all encrypted values in the database.");
// Read all data first
console.log("\nReading encrypted data from database...");
const idpConfigs = await db.select().from(idpOidcConfig);
const licenseKeys = await db.select().from(licenseKey);
console.log(`Found ${idpConfigs.length} OIDC IdP configuration(s)`);
console.log(`Found ${licenseKeys.length} license key(s)`);
// Prepare all decrypted and re-encrypted values
console.log("\nDecrypting and re-encrypting values...");
type IdpUpdate = {
idpOauthConfigId: number;
encryptedClientId: string;
encryptedClientSecret: string;
};
type LicenseKeyUpdate = {
oldLicenseKeyId: string;
newLicenseKeyId: string;
encryptedToken: string;
encryptedInstanceId: string;
};
const idpUpdates: IdpUpdate[] = [];
const licenseKeyUpdates: LicenseKeyUpdate[] = [];
// Process idpOidcConfig entries
for (const idpConfig of idpConfigs) {
try {
// Decrypt with old secret
const decryptedClientId = decrypt(idpConfig.clientId, oldSecret);
const decryptedClientSecret = decrypt(
idpConfig.clientSecret,
oldSecret
);
// Re-encrypt with new secret
const encryptedClientId = encrypt(decryptedClientId, newSecret);
const encryptedClientSecret = encrypt(
decryptedClientSecret,
newSecret
);
idpUpdates.push({
idpOauthConfigId: idpConfig.idpOauthConfigId,
encryptedClientId,
encryptedClientSecret
});
} catch (error) {
console.error(
`Error processing IdP config ${idpConfig.idpOauthConfigId}:`,
error
);
throw error;
}
}
// Process licenseKey entries
for (const key of licenseKeys) {
try {
// Decrypt with old secret
const decryptedLicenseKeyId = decrypt(key.licenseKeyId, oldSecret);
const decryptedToken = decrypt(key.token, oldSecret);
const decryptedInstanceId = decrypt(key.instanceId, oldSecret);
// Re-encrypt with new secret
const encryptedLicenseKeyId = encrypt(
decryptedLicenseKeyId,
newSecret
);
const encryptedToken = encrypt(decryptedToken, newSecret);
const encryptedInstanceId = encrypt(
decryptedInstanceId,
newSecret
);
licenseKeyUpdates.push({
oldLicenseKeyId: key.licenseKeyId,
newLicenseKeyId: encryptedLicenseKeyId,
encryptedToken,
encryptedInstanceId
});
} catch (error) {
console.error(
`Error processing license key ${key.licenseKeyId}:`,
error
);
throw error;
}
}
// Perform all database updates in a single transaction
console.log("\nUpdating database in transaction...");
await db.transaction(async (trx) => {
// Update idpOidcConfig entries
for (const update of idpUpdates) {
await trx
.update(idpOidcConfig)
.set({
clientId: update.encryptedClientId,
clientSecret: update.encryptedClientSecret
})
.where(
eq(
idpOidcConfig.idpOauthConfigId,
update.idpOauthConfigId
)
);
}
// Update licenseKey entries (delete old, insert new)
for (const update of licenseKeyUpdates) {
// Delete old entry
await trx
.delete(licenseKey)
.where(eq(licenseKey.licenseKeyId, update.oldLicenseKeyId));
// Insert new entry with re-encrypted values
await trx.insert(licenseKey).values({
licenseKeyId: update.newLicenseKeyId,
token: update.encryptedToken,
instanceId: update.encryptedInstanceId
});
}
});
console.log(`Rotated ${idpUpdates.length} OIDC IdP configuration(s)`);
console.log(`Rotated ${licenseKeyUpdates.length} license key(s)`);
// Update config file with new secret
console.log("\nUpdating config file...");
config.server.secret = newSecret;
const newConfigContent = yaml.dump(config, {
indent: 2,
lineWidth: -1
});
fs.writeFileSync(configPath, newConfigContent, "utf8");
console.log(`Updated config file: ${configPath}`);
console.log("\nServer secret rotation completed successfully!");
console.log(`\nSummary:`);
console.log(` - OIDC IdP configurations: ${idpUpdates.length}`);
console.log(` - License keys: ${licenseKeyUpdates.length}`);
console.log(
`\n IMPORTANT: Restart the server for the new secret to take effect.`
);
process.exit(0);
} catch (error) {
console.error("Error rotating server secret:", error);
process.exit(1);
}
}
};

View File

@@ -4,10 +4,14 @@ import yargs from "yargs";
import { hideBin } from "yargs/helpers";
import { setAdminCredentials } from "@cli/commands/setAdminCredentials";
import { resetUserSecurityKeys } from "@cli/commands/resetUserSecurityKeys";
import { clearExitNodes } from "./commands/clearExitNodes";
import { rotateServerSecret } from "./commands/rotateServerSecret";
yargs(hideBin(process.argv))
.scriptName("pangctl")
.command(setAdminCredentials)
.command(resetUserSecurityKeys)
.command(clearExitNodes)
.command(rotateServerSecret)
.demandCommand()
.help().argv;

View File

@@ -100,6 +100,7 @@
"siteTunnelDescription": "Determine how you want to connect to the site",
"siteNewtCredentials": "Credentials",
"siteNewtCredentialsDescription": "This is how the site will authenticate with the server",
"remoteNodeCredentialsDescription": "This is how the remote node will authenticate with the server",
"siteCredentialsSave": "Save the Credentials",
"siteCredentialsSaveDescription": "You will only be able to see this once. Make sure to copy it to a secure place.",
"siteInfo": "Site Information",
@@ -419,7 +420,7 @@
"userErrorExistsDescription": "This user is already a member of the organization.",
"inviteError": "Failed to invite user",
"inviteErrorDescription": "An error occurred while inviting the user",
"userInvited": "User invited",
"userInvited": "User Invited",
"userInvitedDescription": "The user has been successfully invited.",
"userErrorCreate": "Failed to create user",
"userErrorCreateDescription": "An error occurred while creating the user",
@@ -1035,6 +1036,7 @@
"updateOrgUser": "Update Org User",
"createOrgUser": "Create Org User",
"actionUpdateOrg": "Update Organization",
"actionRemoveInvitation": "Remove Invitation",
"actionUpdateUser": "Update User",
"actionGetUser": "Get User",
"actionGetOrgUser": "Get Organization User",
@@ -1673,7 +1675,7 @@
"autoLoginErrorNoRedirectUrl": "No redirect URL received from the identity provider.",
"autoLoginErrorGeneratingUrl": "Failed to generate authentication URL.",
"remoteExitNodeManageRemoteExitNodes": "Remote Nodes",
"remoteExitNodeDescription": "Self-host one or more remote nodes to extend network connectivity and reduce reliance on the cloud",
"remoteExitNodeDescription": "Self-host your own remote relay and proxy server nodes",
"remoteExitNodes": "Nodes",
"searchRemoteExitNodes": "Search nodes...",
"remoteExitNodeAdd": "Add Node",
@@ -1683,20 +1685,22 @@
"remoteExitNodeConfirmDelete": "Confirm Delete Node",
"remoteExitNodeDelete": "Delete Node",
"sidebarRemoteExitNodes": "Remote Nodes",
"remoteExitNodeId": "ID",
"remoteExitNodeSecretKey": "Secret",
"remoteExitNodeCreate": {
"title": "Create Node",
"description": "Create a new node to extend network connectivity",
"title": "Create Remote Node",
"description": "Create a new self-hosted remote relay and proxy server node",
"viewAllButton": "View All Nodes",
"strategy": {
"title": "Creation Strategy",
"description": "Choose this to manually configure the node or generate new credentials.",
"description": "Select how you want to create the remote node",
"adopt": {
"title": "Adopt Node",
"description": "Choose this if you already have the credentials for the node."
},
"generate": {
"title": "Generate Keys",
"description": "Choose this if you want to generate new keys for the node"
"description": "Choose this if you want to generate new keys for the node."
}
},
"adopt": {
@@ -1809,9 +1813,30 @@
"idpAzureDescription": "Microsoft Azure OAuth2/OIDC provider",
"subnet": "Subnet",
"subnetDescription": "The subnet for this organization's network configuration.",
"authPage": "Auth Page",
"authPageDescription": "Configure the auth page for the organization",
"customDomain": "Custom Domain",
"authPage": "Authentication Pages",
"authPageDescription": "Set a custom domain for the organization's authentication pages",
"authPageDomain": "Auth Page Domain",
"authPageBranding": "Custom Branding",
"authPageBrandingDescription": "Configure the branding that appears on authentication pages for this organization",
"authPageBrandingUpdated": "Auth page Branding updated successfully",
"authPageBrandingRemoved": "Auth page Branding removed successfully",
"authPageBrandingRemoveTitle": "Remove Auth Page Branding",
"authPageBrandingQuestionRemove": "Are you sure you want to remove the branding for Auth Pages ?",
"authPageBrandingDeleteConfirm": "Confirm Delete Branding",
"brandingLogoURL": "Logo URL",
"brandingPrimaryColor": "Primary Color",
"brandingLogoWidth": "Width (px)",
"brandingLogoHeight": "Height (px)",
"brandingOrgTitle": "Title for Organization Auth Page",
"brandingOrgDescription": "{orgName} will be replaced with the organization's name",
"brandingOrgSubtitle": "Subtitle for Organization Auth Page",
"brandingResourceTitle": "Title for Resource Auth Page",
"brandingResourceSubtitle": "Subtitle for Resource Auth Page",
"brandingResourceDescription": "{resourceName} will be replaced with the organization's name",
"saveAuthPageDomain": "Save Domain",
"saveAuthPageBranding": "Save Branding",
"removeAuthPageBranding": "Remove Branding",
"noDomainSet": "No domain set",
"changeDomain": "Change Domain",
"selectDomain": "Select Domain",
@@ -1820,7 +1845,7 @@
"setAuthPageDomain": "Set Auth Page Domain",
"failedToFetchCertificate": "Failed to fetch certificate",
"failedToRestartCertificate": "Failed to restart certificate",
"addDomainToEnableCustomAuthPages": "Add a domain to enable custom authentication pages for the organization",
"addDomainToEnableCustomAuthPages": "Users will be able to access the organization's login page and complete resource authentication using this domain.",
"selectDomainForOrgAuthPage": "Select a domain for the organization's authentication page",
"domainPickerProvidedDomain": "Provided Domain",
"domainPickerFreeProvidedDomain": "Free Provided Domain",
@@ -1892,7 +1917,7 @@
"securityPolicyChangeWarningText": "This will affect all users in the organization",
"authPageErrorUpdateMessage": "An error occurred while updating the auth page settings",
"authPageErrorUpdate": "Unable to update auth page",
"authPageUpdated": "Auth page updated successfully",
"authPageDomainUpdated": "Auth page Domain updated successfully",
"healthCheckNotAvailable": "Local",
"rewritePath": "Rewrite Path",
"rewritePathDescription": "Optionally rewrite the path before forwarding to the target.",
@@ -2280,5 +2305,17 @@
"agent": "Agent",
"personalUseOnly": "Personal Use Only",
"loginPageLicenseWatermark": "This instance is licensed for personal use only.",
"instanceIsUnlicensed": "This instance is unlicensed."
"instanceIsUnlicensed": "This instance is unlicensed.",
"portRestrictions": "Port Restrictions",
"allPorts": "All",
"custom": "Custom",
"allPortsAllowed": "All Ports Allowed",
"allPortsBlocked": "All Ports Blocked",
"tcpPortsDescription": "Specify which TCP ports are allowed for this resource. Use '*' for all ports, leave empty to block all, or enter a comma-separated list of ports and ranges (e.g., 80,443,8000-9000).",
"udpPortsDescription": "Specify which UDP ports are allowed for this resource. Use '*' for all ports, leave empty to block all, or enter a comma-separated list of ports and ranges (e.g., 53,123,500-600).",
"organizationLoginPageTitle": "Organization Login Page",
"organizationLoginPageDescription": "Customize the login page for this organization",
"resourceLoginPageTitle": "Resource Login Page",
"resourceLoginPageDescription": "Customize the login page for individual resources",
"enterConfirmation": "Enter confirmation"
}

2272
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -19,9 +19,9 @@
"db:sqlite:studio": "drizzle-kit studio --config=./drizzle.sqlite.config.ts",
"db:pg:studio": "drizzle-kit studio --config=./drizzle.pg.config.ts",
"db:clear-migrations": "rm -rf server/migrations",
"set:oss": "echo 'export const build = \"oss\" as any;' > server/build.ts && cp tsconfig.oss.json tsconfig.json",
"set:saas": "echo 'export const build = \"saas\" as any;' > server/build.ts && cp tsconfig.saas.json tsconfig.json",
"set:enterprise": "echo 'export const build = \"enterprise\" as any;' > server/build.ts && cp tsconfig.enterprise.json tsconfig.json",
"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",

View File

@@ -6,28 +6,28 @@ import { withReplicas } from "drizzle-orm/pg-core";
function createDb() {
const config = readConfigFile();
if (!config.postgres) {
// check the environment variables for postgres config
if (process.env.POSTGRES_CONNECTION_STRING) {
config.postgres = {
connection_string: process.env.POSTGRES_CONNECTION_STRING
};
if (process.env.POSTGRES_REPLICA_CONNECTION_STRINGS) {
const replicas =
process.env.POSTGRES_REPLICA_CONNECTION_STRINGS.split(
","
).map((conn) => ({
// check the environment variables for postgres config first before the config file
if (process.env.POSTGRES_CONNECTION_STRING) {
config.postgres = {
connection_string: process.env.POSTGRES_CONNECTION_STRING
};
if (process.env.POSTGRES_REPLICA_CONNECTION_STRINGS) {
const replicas =
process.env.POSTGRES_REPLICA_CONNECTION_STRINGS.split(",").map(
(conn) => ({
connection_string: conn.trim()
}));
config.postgres.replicas = replicas;
}
} else {
throw new Error(
"Postgres configuration is missing in the configuration file."
);
})
);
config.postgres.replicas = replicas;
}
}
if (!config.postgres) {
throw new Error(
"Postgres configuration is missing in the configuration file."
);
}
const connectionString = config.postgres?.connection_string;
const replicaConnections = config.postgres?.replicas || [];

View File

@@ -10,7 +10,7 @@ const runMigrations = async () => {
await migrate(db as any, {
migrationsFolder: migrationsFolder
});
console.log("Migrations completed successfully.");
console.log("Migrations completed successfully.");
process.exit(0);
} catch (error) {
console.error("Error running migrations:", error);

View File

@@ -204,6 +204,29 @@ export const loginPageOrg = pgTable("loginPageOrg", {
.references(() => orgs.orgId, { onDelete: "cascade" })
});
export const loginPageBranding = pgTable("loginPageBranding", {
loginPageBrandingId: serial("loginPageBrandingId").primaryKey(),
logoUrl: text("logoUrl").notNull(),
logoWidth: integer("logoWidth").notNull(),
logoHeight: integer("logoHeight").notNull(),
primaryColor: text("primaryColor"),
resourceTitle: text("resourceTitle").notNull(),
resourceSubtitle: text("resourceSubtitle"),
orgTitle: text("orgTitle"),
orgSubtitle: text("orgSubtitle")
});
export const loginPageBrandingOrg = pgTable("loginPageBrandingOrg", {
loginPageBrandingId: integer("loginPageBrandingId")
.notNull()
.references(() => loginPageBranding.loginPageBrandingId, {
onDelete: "cascade"
}),
orgId: varchar("orgId")
.notNull()
.references(() => orgs.orgId, { onDelete: "cascade" })
});
export const sessionTransferToken = pgTable("sessionTransferToken", {
token: varchar("token").primaryKey(),
sessionId: varchar("sessionId")
@@ -283,5 +306,6 @@ export type RemoteExitNodeSession = InferSelectModel<
>;
export type ExitNodeOrg = InferSelectModel<typeof exitNodeOrgs>;
export type LoginPage = InferSelectModel<typeof loginPage>;
export type LoginPageBranding = InferSelectModel<typeof loginPageBranding>;
export type ActionAuditLog = InferSelectModel<typeof actionAuditLog>;
export type AccessAuditLog = InferSelectModel<typeof accessAuditLog>;

View File

@@ -7,7 +7,8 @@ import {
bigint,
real,
text,
index
index,
uniqueIndex
} from "drizzle-orm/pg-core";
import { InferSelectModel } from "drizzle-orm";
import { randomUUID } from "crypto";
@@ -213,7 +214,10 @@ export const siteResources = pgTable("siteResources", {
destination: varchar("destination").notNull(), // ip, cidr, hostname; validate against the mode
enabled: boolean("enabled").notNull().default(true),
alias: varchar("alias"),
aliasAddress: varchar("aliasAddress")
aliasAddress: varchar("aliasAddress"),
tcpPortRangeString: varchar("tcpPortRangeString"),
udpPortRangeString: varchar("udpPortRangeString"),
disableIcmp: boolean("disableIcmp").notNull().default(false)
});
export const clientSiteResources = pgTable("clientSiteResources", {

View File

@@ -1,13 +1,12 @@
import {
sqliteTable,
integer,
text,
real,
index
} from "drizzle-orm/sqlite-core";
import { InferSelectModel } from "drizzle-orm";
import { domains, orgs, targets, users, exitNodes, sessions } from "./schema";
import { metadata } from "@app/app/[orgId]/settings/layout";
import {
index,
integer,
real,
sqliteTable,
text
} from "drizzle-orm/sqlite-core";
import { domains, exitNodes, orgs, sessions, users } from "./schema";
export const certificates = sqliteTable("certificates", {
certId: integer("certId").primaryKey({ autoIncrement: true }),
@@ -203,6 +202,31 @@ export const loginPageOrg = sqliteTable("loginPageOrg", {
.references(() => orgs.orgId, { onDelete: "cascade" })
});
export const loginPageBranding = sqliteTable("loginPageBranding", {
loginPageBrandingId: integer("loginPageBrandingId").primaryKey({
autoIncrement: true
}),
logoUrl: text("logoUrl").notNull(),
logoWidth: integer("logoWidth").notNull(),
logoHeight: integer("logoHeight").notNull(),
primaryColor: text("primaryColor"),
resourceTitle: text("resourceTitle").notNull(),
resourceSubtitle: text("resourceSubtitle"),
orgTitle: text("orgTitle"),
orgSubtitle: text("orgSubtitle")
});
export const loginPageBrandingOrg = sqliteTable("loginPageBrandingOrg", {
loginPageBrandingId: integer("loginPageBrandingId")
.notNull()
.references(() => loginPageBranding.loginPageBrandingId, {
onDelete: "cascade"
}),
orgId: text("orgId")
.notNull()
.references(() => orgs.orgId, { onDelete: "cascade" })
});
export const sessionTransferToken = sqliteTable("sessionTransferToken", {
token: text("token").primaryKey(),
sessionId: text("sessionId")
@@ -282,5 +306,6 @@ export type RemoteExitNodeSession = InferSelectModel<
>;
export type ExitNodeOrg = InferSelectModel<typeof exitNodeOrgs>;
export type LoginPage = InferSelectModel<typeof loginPage>;
export type LoginPageBranding = InferSelectModel<typeof loginPageBranding>;
export type ActionAuditLog = InferSelectModel<typeof actionAuditLog>;
export type AccessAuditLog = InferSelectModel<typeof accessAuditLog>;

View File

@@ -1,6 +1,12 @@
import { randomUUID } from "crypto";
import { InferSelectModel } from "drizzle-orm";
import { sqliteTable, text, integer, index } from "drizzle-orm/sqlite-core";
import {
sqliteTable,
text,
integer,
index,
uniqueIndex
} from "drizzle-orm/sqlite-core";
import { no } from "zod/v4/locales";
export const domains = sqliteTable("domains", {
@@ -234,7 +240,10 @@ export const siteResources = sqliteTable("siteResources", {
destination: text("destination").notNull(), // ip, cidr, hostname
enabled: integer("enabled", { mode: "boolean" }).notNull().default(true),
alias: text("alias"),
aliasAddress: text("aliasAddress")
aliasAddress: text("aliasAddress"),
tcpPortRangeString: text("tcpPortRangeString"),
udpPortRangeString: text("udpPortRangeString"),
disableIcmp: integer("disableIcmp", { mode: "boolean" })
});
export const clientSiteResources = sqliteTable("clientSiteResources", {

View File

@@ -10,6 +10,7 @@ export async function sendEmail(
from: string | undefined;
to: string | undefined;
subject: string;
replyTo?: string;
}
) {
if (!emailClient) {
@@ -32,6 +33,7 @@ export async function sendEmail(
address: opts.from
},
to: opts.to,
replyTo: opts.replyTo,
subject: opts.subject,
html: emailHtml
});

View File

@@ -90,7 +90,10 @@ export async function updateClientResources(
destination: resourceData.destination,
enabled: true, // hardcoded for now
// enabled: resourceData.enabled ?? true,
alias: resourceData.alias || null
alias: resourceData.alias || null,
disableIcmp: resourceData["disable-icmp"],
tcpPortRangeString: resourceData["tcp-ports"],
udpPortRangeString: resourceData["udp-ports"]
})
.where(
eq(
@@ -217,7 +220,10 @@ export async function updateClientResources(
destination: resourceData.destination,
enabled: true, // hardcoded for now
// enabled: resourceData.enabled ?? true,
alias: resourceData.alias || null
alias: resourceData.alias || null,
disableIcmp: resourceData["disable-icmp"],
tcpPortRangeString: resourceData["tcp-ports"],
udpPortRangeString: resourceData["udp-ports"]
})
.returning();

View File

@@ -1,4 +1,5 @@
import { z } from "zod";
import { portRangeStringSchema } from "@server/lib/ip";
export const SiteSchema = z.object({
name: z.string().min(1).max(100),
@@ -222,6 +223,9 @@ export const ClientResourceSchema = z
// destinationPort: z.int().positive().optional(),
destination: z.string().min(1),
// enabled: z.boolean().default(true),
"tcp-ports": portRangeStringSchema.optional().default("*"),
"udp-ports": portRangeStringSchema.optional().default("*"),
"disable-icmp": z.boolean().optional().default(false),
alias: z
.string()
.regex(

View File

@@ -1,10 +1,4 @@
import {
clientSitesAssociationsCache,
db,
SiteResource,
siteResources,
Transaction
} from "@server/db";
import { db, SiteResource, siteResources, Transaction } from "@server/db";
import { clients, orgs, sites } from "@server/db";
import { and, eq, isNotNull } from "drizzle-orm";
import config from "@server/lib/config";
@@ -472,10 +466,12 @@ export function generateAliasConfig(allSiteResources: SiteResource[]): Alias[] {
export type SubnetProxyTarget = {
sourcePrefix: string; // must be a cidr
destPrefix: string; // must be a cidr
disableIcmp?: boolean;
rewriteTo?: string; // must be a cidr
portRange?: {
min: number;
max: number;
protocol: "tcp" | "udp";
}[];
};
@@ -505,6 +501,11 @@ export function generateSubnetProxyTargets(
}
const clientPrefix = `${clientSite.subnet.split("/")[0]}/32`;
const portRange = [
...parsePortRangeString(siteResource.tcpPortRangeString, "tcp"),
...parsePortRangeString(siteResource.udpPortRangeString, "udp")
];
const disableIcmp = siteResource.disableIcmp ?? false;
if (siteResource.mode == "host") {
let destination = siteResource.destination;
@@ -515,7 +516,9 @@ export function generateSubnetProxyTargets(
targets.push({
sourcePrefix: clientPrefix,
destPrefix: destination
destPrefix: destination,
portRange,
disableIcmp
});
}
@@ -524,13 +527,17 @@ export function generateSubnetProxyTargets(
targets.push({
sourcePrefix: clientPrefix,
destPrefix: `${siteResource.aliasAddress}/32`,
rewriteTo: destination
rewriteTo: destination,
portRange,
disableIcmp
});
}
} else if (siteResource.mode == "cidr") {
targets.push({
sourcePrefix: clientPrefix,
destPrefix: siteResource.destination
destPrefix: siteResource.destination,
portRange,
disableIcmp
});
}
}
@@ -542,3 +549,117 @@ export function generateSubnetProxyTargets(
return targets;
}
// Custom schema for validating port range strings
// Format: "80,443,8000-9000" or "*" for all ports, or empty string
export const portRangeStringSchema = z
.string()
.optional()
.refine(
(val) => {
if (!val || val.trim() === "" || val.trim() === "*") {
return true;
}
// Split by comma and validate each part
const parts = val.split(",").map((p) => p.trim());
for (const part of parts) {
if (part === "") {
return false; // empty parts not allowed
}
// Check if it's a range (contains dash)
if (part.includes("-")) {
const [start, end] = part.split("-").map((p) => p.trim());
// Both parts must be present
if (!start || !end) {
return false;
}
const startPort = parseInt(start, 10);
const endPort = parseInt(end, 10);
// Must be valid numbers
if (isNaN(startPort) || isNaN(endPort)) {
return false;
}
// Must be valid port range (1-65535)
if (
startPort < 1 ||
startPort > 65535 ||
endPort < 1 ||
endPort > 65535
) {
return false;
}
// Start must be <= end
if (startPort > endPort) {
return false;
}
} else {
// Single port
const port = parseInt(part, 10);
// Must be a valid number
if (isNaN(port)) {
return false;
}
// Must be valid port range (1-65535)
if (port < 1 || port > 65535) {
return false;
}
}
}
return true;
},
{
message:
'Port range must be "*" for all ports, or a comma-separated list of ports and ranges (e.g., "80,443,8000-9000"). Ports must be between 1 and 65535, and ranges must have start <= end.'
}
);
/**
* Parses a port range string into an array of port range objects
* @param portRangeStr - Port range string (e.g., "80,443,8000-9000", "*", or "")
* @param protocol - Protocol to use for all ranges (default: "tcp")
* @returns Array of port range objects with min, max, and protocol fields
*/
export function parsePortRangeString(
portRangeStr: string | undefined | null,
protocol: "tcp" | "udp" = "tcp"
): { min: number; max: number; protocol: "tcp" | "udp" }[] {
// Handle undefined or empty string - insert dummy value with port 0
if (!portRangeStr || portRangeStr.trim() === "") {
return [{ min: 0, max: 0, protocol }];
}
// Handle wildcard - return empty array (all ports allowed)
if (portRangeStr.trim() === "*") {
return [];
}
const result: { min: number; max: number; protocol: "tcp" | "udp" }[] = [];
const parts = portRangeStr.split(",").map((p) => p.trim());
for (const part of parts) {
if (part.includes("-")) {
// Range
const [start, end] = part.split("-").map((p) => p.trim());
const startPort = parseInt(start, 10);
const endPort = parseInt(end, 10);
result.push({ min: startPort, max: endPort, protocol });
} else {
// Single port
const port = parseInt(part, 10);
result.push({ min: port, max: port, protocol });
}
}
return result;
}

View File

@@ -311,6 +311,33 @@ authenticated.get(
loginPage.getLoginPage
);
authenticated.get(
"/org/:orgId/login-page-branding",
verifyValidLicense,
verifyOrgAccess,
verifyUserHasAction(ActionsEnum.getLoginPage),
logActionAudit(ActionsEnum.getLoginPage),
loginPage.getLoginPageBranding
);
authenticated.put(
"/org/:orgId/login-page-branding",
verifyValidLicense,
verifyOrgAccess,
verifyUserHasAction(ActionsEnum.updateLoginPage),
logActionAudit(ActionsEnum.updateLoginPage),
loginPage.upsertLoginPageBranding
);
authenticated.delete(
"/org/:orgId/login-page-branding",
verifyValidLicense,
verifyOrgAccess,
verifyUserHasAction(ActionsEnum.deleteLoginPage),
logActionAudit(ActionsEnum.deleteLoginPage),
loginPage.deleteLoginPageBranding
);
authRouter.post(
"/remoteExitNode/get-token",
verifyValidLicense,

View File

@@ -28,6 +28,7 @@ internalRouter.get("/org/:orgId/idp", orgIdp.listOrgIdps);
internalRouter.get("/org/:orgId/billing/tier", billing.getOrgTier);
internalRouter.get("/login-page", loginPage.loadLoginPage);
internalRouter.get("/login-page-branding", loginPage.loadLoginPageBranding);
internalRouter.post(
"/get-session-transfer-token",

View File

@@ -0,0 +1,113 @@
/*
* This file is part of a proprietary work.
*
* Copyright (c) 2025 Fossorial, Inc.
* All rights reserved.
*
* This file is licensed under the Fossorial Commercial License.
* You may not use this file except in compliance with the License.
* Unauthorized use, copying, modification, or distribution is strictly prohibited.
*
* This file is not licensed under the AGPLv3.
*/
import { Request, Response, NextFunction } from "express";
import { z } from "zod";
import {
db,
LoginPageBranding,
loginPageBranding,
loginPageBrandingOrg
} from "@server/db";
import response from "@server/lib/response";
import HttpCode from "@server/types/HttpCode";
import createHttpError from "http-errors";
import logger from "@server/logger";
import { fromError } from "zod-validation-error";
import { eq } from "drizzle-orm";
import { getOrgTierData } from "#private/lib/billing";
import { TierId } from "@server/lib/billing/tiers";
import { build } from "@server/build";
const paramsSchema = z
.object({
orgId: z.string()
})
.strict();
export async function deleteLoginPageBranding(
req: Request,
res: Response,
next: NextFunction
): Promise<any> {
try {
const parsedParams = paramsSchema.safeParse(req.params);
if (!parsedParams.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedParams.error).toString()
)
);
}
const { orgId } = parsedParams.data;
if (build === "saas") {
const { tier } = await getOrgTierData(orgId);
const subscribed = tier === TierId.STANDARD;
if (!subscribed) {
return next(
createHttpError(
HttpCode.FORBIDDEN,
"This organization's current plan does not support this feature."
)
);
}
}
const [existingLoginPageBranding] = await db
.select()
.from(loginPageBranding)
.innerJoin(
loginPageBrandingOrg,
eq(
loginPageBrandingOrg.loginPageBrandingId,
loginPageBranding.loginPageBrandingId
)
)
.where(eq(loginPageBrandingOrg.orgId, orgId));
if (!existingLoginPageBranding) {
return next(
createHttpError(
HttpCode.NOT_FOUND,
"Login page branding not found"
)
);
}
await db
.delete(loginPageBranding)
.where(
eq(
loginPageBranding.loginPageBrandingId,
existingLoginPageBranding.loginPageBranding
.loginPageBrandingId
)
);
return response<LoginPageBranding>(res, {
data: existingLoginPageBranding.loginPageBranding,
success: true,
error: false,
message: "Login page branding deleted successfully",
status: HttpCode.OK
});
} catch (error) {
logger.error(error);
return next(
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
);
}
}

View File

@@ -0,0 +1,103 @@
/*
* This file is part of a proprietary work.
*
* Copyright (c) 2025 Fossorial, Inc.
* All rights reserved.
*
* This file is licensed under the Fossorial Commercial License.
* You may not use this file except in compliance with the License.
* Unauthorized use, copying, modification, or distribution is strictly prohibited.
*
* This file is not licensed under the AGPLv3.
*/
import { Request, Response, NextFunction } from "express";
import { z } from "zod";
import {
db,
LoginPageBranding,
loginPageBranding,
loginPageBrandingOrg
} from "@server/db";
import response from "@server/lib/response";
import HttpCode from "@server/types/HttpCode";
import createHttpError from "http-errors";
import logger from "@server/logger";
import { fromError } from "zod-validation-error";
import { eq } from "drizzle-orm";
import { getOrgTierData } from "#private/lib/billing";
import { TierId } from "@server/lib/billing/tiers";
import { build } from "@server/build";
const paramsSchema = z
.object({
orgId: z.string()
})
.strict();
export async function getLoginPageBranding(
req: Request,
res: Response,
next: NextFunction
): Promise<any> {
try {
const parsedParams = paramsSchema.safeParse(req.params);
if (!parsedParams.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedParams.error).toString()
)
);
}
const { orgId } = parsedParams.data;
if (build === "saas") {
const { tier } = await getOrgTierData(orgId);
const subscribed = tier === TierId.STANDARD;
if (!subscribed) {
return next(
createHttpError(
HttpCode.FORBIDDEN,
"This organization's current plan does not support this feature."
)
);
}
}
const [existingLoginPageBranding] = await db
.select()
.from(loginPageBranding)
.innerJoin(
loginPageBrandingOrg,
eq(
loginPageBrandingOrg.loginPageBrandingId,
loginPageBranding.loginPageBrandingId
)
)
.where(eq(loginPageBrandingOrg.orgId, orgId));
if (!existingLoginPageBranding) {
return next(
createHttpError(
HttpCode.NOT_FOUND,
"Login page branding not found"
)
);
}
return response<LoginPageBranding>(res, {
data: existingLoginPageBranding.loginPageBranding,
success: true,
error: false,
message: "Login page branding retrieved successfully",
status: HttpCode.OK
});
} catch (error) {
logger.error(error);
return next(
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
);
}
}

View File

@@ -17,3 +17,7 @@ export * from "./getLoginPage";
export * from "./loadLoginPage";
export * from "./updateLoginPage";
export * from "./deleteLoginPage";
export * from "./upsertLoginPageBranding";
export * from "./deleteLoginPageBranding";
export * from "./getLoginPageBranding";
export * from "./loadLoginPageBranding";

View File

@@ -0,0 +1,100 @@
/*
* This file is part of a proprietary work.
*
* Copyright (c) 2025 Fossorial, Inc.
* All rights reserved.
*
* This file is licensed under the Fossorial Commercial License.
* You may not use this file except in compliance with the License.
* Unauthorized use, copying, modification, or distribution is strictly prohibited.
*
* This file is not licensed under the AGPLv3.
*/
import { Request, Response, NextFunction } from "express";
import { z } from "zod";
import { db, loginPageBranding, loginPageBrandingOrg, orgs } from "@server/db";
import { eq, and } from "drizzle-orm";
import response from "@server/lib/response";
import HttpCode from "@server/types/HttpCode";
import createHttpError from "http-errors";
import logger from "@server/logger";
import { fromError } from "zod-validation-error";
import type { LoadLoginPageBrandingResponse } from "@server/routers/loginPage/types";
const querySchema = z.object({
orgId: z.string().min(1)
});
async function query(orgId: string) {
const [orgLink] = await db
.select()
.from(loginPageBrandingOrg)
.where(eq(loginPageBrandingOrg.orgId, orgId))
.innerJoin(orgs, eq(loginPageBrandingOrg.orgId, orgs.orgId));
if (!orgLink) {
return null;
}
const [res] = await db
.select()
.from(loginPageBranding)
.where(
and(
eq(
loginPageBranding.loginPageBrandingId,
orgLink.loginPageBrandingOrg.loginPageBrandingId
)
)
)
.limit(1);
return {
...res,
orgId: orgLink.orgs.orgId,
orgName: orgLink.orgs.name
};
}
export async function loadLoginPageBranding(
req: Request,
res: Response,
next: NextFunction
): Promise<any> {
try {
const parsedQuery = querySchema.safeParse(req.query);
if (!parsedQuery.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedQuery.error).toString()
)
);
}
const { orgId } = parsedQuery.data;
const branding = await query(orgId);
if (!branding) {
return next(
createHttpError(
HttpCode.NOT_FOUND,
"Branding for Login page not found"
)
);
}
return response<LoadLoginPageBrandingResponse>(res, {
data: branding,
success: true,
error: false,
message: "Login page branding retrieved successfully",
status: HttpCode.OK
});
} catch (error) {
logger.error(error);
return next(
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
);
}
}

View File

@@ -0,0 +1,162 @@
/*
* This file is part of a proprietary work.
*
* Copyright (c) 2025 Fossorial, Inc.
* All rights reserved.
*
* This file is licensed under the Fossorial Commercial License.
* You may not use this file except in compliance with the License.
* Unauthorized use, copying, modification, or distribution is strictly prohibited.
*
* This file is not licensed under the AGPLv3.
*/
import { Request, Response, NextFunction } from "express";
import { z } from "zod";
import {
db,
LoginPageBranding,
loginPageBranding,
loginPageBrandingOrg
} from "@server/db";
import response from "@server/lib/response";
import HttpCode from "@server/types/HttpCode";
import createHttpError from "http-errors";
import logger from "@server/logger";
import { fromError } from "zod-validation-error";
import { eq, InferInsertModel } from "drizzle-orm";
import { getOrgTierData } from "#private/lib/billing";
import { TierId } from "@server/lib/billing/tiers";
import { build } from "@server/build";
const paramsSchema = z.strictObject({
orgId: z.string()
});
const bodySchema = z.strictObject({
logoUrl: z.url(),
logoWidth: z.coerce.number<number>().min(1),
logoHeight: z.coerce.number<number>().min(1),
resourceTitle: z.string(),
resourceSubtitle: z.string().optional(),
orgTitle: z.string().optional(),
orgSubtitle: z.string().optional(),
primaryColor: z
.string()
.regex(/^#([0-9a-f]{6}|[0-9a-f]{3})$/i)
.optional()
});
export type UpdateLoginPageBrandingBody = z.infer<typeof bodySchema>;
export async function upsertLoginPageBranding(
req: Request,
res: Response,
next: NextFunction
): Promise<any> {
try {
const parsedBody = bodySchema.safeParse(req.body);
if (!parsedBody.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedBody.error).toString()
)
);
}
const parsedParams = paramsSchema.safeParse(req.params);
if (!parsedParams.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedParams.error).toString()
)
);
}
const { orgId } = parsedParams.data;
if (build === "saas") {
const { tier } = await getOrgTierData(orgId);
const subscribed = tier === TierId.STANDARD;
if (!subscribed) {
return next(
createHttpError(
HttpCode.FORBIDDEN,
"This organization's current plan does not support this feature."
)
);
}
}
let updateData = parsedBody.data satisfies InferInsertModel<
typeof loginPageBranding
>;
if (build !== "saas") {
// org branding settings are only considered in the saas build
const { orgTitle, orgSubtitle, ...rest } = updateData;
updateData = rest;
}
const [existingLoginPageBranding] = await db
.select()
.from(loginPageBranding)
.innerJoin(
loginPageBrandingOrg,
eq(
loginPageBrandingOrg.loginPageBrandingId,
loginPageBranding.loginPageBrandingId
)
)
.where(eq(loginPageBrandingOrg.orgId, orgId));
let updatedLoginPageBranding: LoginPageBranding;
if (existingLoginPageBranding) {
updatedLoginPageBranding = await db.transaction(async (tx) => {
const [branding] = await tx
.update(loginPageBranding)
.set({ ...updateData })
.where(
eq(
loginPageBranding.loginPageBrandingId,
existingLoginPageBranding.loginPageBranding
.loginPageBrandingId
)
)
.returning();
return branding;
});
} else {
updatedLoginPageBranding = await db.transaction(async (tx) => {
const [branding] = await tx
.insert(loginPageBranding)
.values({ ...updateData })
.returning();
await tx.insert(loginPageBrandingOrg).values({
loginPageBrandingId: branding.loginPageBrandingId,
orgId: orgId
});
return branding;
});
}
return response<LoginPageBranding>(res, {
data: updatedLoginPageBranding,
success: true,
error: false,
message: existingLoginPageBranding
? "Login page branding updated successfully"
: "Login page branding created successfully",
status: existingLoginPageBranding ? HttpCode.OK : HttpCode.CREATED
});
} catch (error) {
logger.error(error);
return next(
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
);
}
}

View File

@@ -66,6 +66,7 @@ export async function sendSupportEmail(
{
name: req.user?.email || "Support User",
to: "support@pangolin.net",
replyTo: req.user?.email || undefined,
from: config.getNoReplyEmail(),
subject: `Support Request: ${subject}`
}

View File

@@ -4,21 +4,48 @@ import { Alias, SubnetProxyTarget } from "@server/lib/ip";
import logger from "@server/logger";
import { eq } from "drizzle-orm";
const BATCH_SIZE = 50;
const BATCH_DELAY_MS = 50;
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
function chunkArray<T>(array: T[], size: number): T[][] {
const chunks: T[][] = [];
for (let i = 0; i < array.length; i += size) {
chunks.push(array.slice(i, i + size));
}
return chunks;
}
export async function addTargets(newtId: string, targets: SubnetProxyTarget[]) {
await sendToClient(newtId, {
type: `newt/wg/targets/add`,
data: targets
});
const batches = chunkArray(targets, BATCH_SIZE);
for (let i = 0; i < batches.length; i++) {
if (i > 0) {
await sleep(BATCH_DELAY_MS);
}
await sendToClient(newtId, {
type: `newt/wg/targets/add`,
data: batches[i]
});
}
}
export async function removeTargets(
newtId: string,
targets: SubnetProxyTarget[]
) {
await sendToClient(newtId, {
type: `newt/wg/targets/remove`,
data: targets
});
const batches = chunkArray(targets, BATCH_SIZE);
for (let i = 0; i < batches.length; i++) {
if (i > 0) {
await sleep(BATCH_DELAY_MS);
}
await sendToClient(newtId, {
type: `newt/wg/targets/remove`,
data: batches[i]
});
}
}
export async function updateTargets(
@@ -28,12 +55,24 @@ export async function updateTargets(
newTargets: SubnetProxyTarget[];
}
) {
await sendToClient(newtId, {
type: `newt/wg/targets/update`,
data: targets
}).catch((error) => {
logger.warn(`Error sending message:`, error);
});
const oldBatches = chunkArray(targets.oldTargets, BATCH_SIZE);
const newBatches = chunkArray(targets.newTargets, BATCH_SIZE);
const maxBatches = Math.max(oldBatches.length, newBatches.length);
for (let i = 0; i < maxBatches; i++) {
if (i > 0) {
await sleep(BATCH_DELAY_MS);
}
await sendToClient(newtId, {
type: `newt/wg/targets/update`,
data: {
oldTargets: oldBatches[i] || [],
newTargets: newBatches[i] || []
}
}).catch((error) => {
logger.warn(`Error sending message:`, error);
});
}
}
export async function addPeerData(

View File

@@ -192,11 +192,71 @@ export async function validateOidcCallback(
state
});
const tokens = await client.validateAuthorizationCode(
ensureTrailingSlash(existingIdp.idpOidcConfig.tokenUrl),
code,
codeVerifier
);
let tokens: arctic.OAuth2Tokens;
try {
tokens = await client.validateAuthorizationCode(
ensureTrailingSlash(existingIdp.idpOidcConfig.tokenUrl),
code,
codeVerifier
);
} catch (err: unknown) {
if (err instanceof arctic.OAuth2RequestError) {
logger.warn("OIDC provider rejected the authorization code", {
error: err.code,
description: err.description,
uri: err.uri,
state: err.state
});
return next(
createHttpError(
HttpCode.UNAUTHORIZED,
err.description ||
`OIDC provider rejected the request (${err.code})`
)
);
}
if (err instanceof arctic.UnexpectedResponseError) {
logger.error(
"OIDC provider returned an unexpected response during token exchange",
{ status: err.status }
);
return next(
createHttpError(
HttpCode.BAD_GATEWAY,
"Received an unexpected response from the identity provider while exchanging the authorization code."
)
);
}
if (err instanceof arctic.UnexpectedErrorResponseBodyError) {
logger.error(
"OIDC provider returned an unexpected error payload during token exchange",
{ status: err.status, data: err.data }
);
return next(
createHttpError(
HttpCode.BAD_GATEWAY,
"Identity provider returned an unexpected error payload while exchanging the authorization code."
)
);
}
if (err instanceof arctic.ArcticFetchError) {
logger.error(
"Failed to reach OIDC provider while exchanging authorization code",
{ error: err.message }
);
return next(
createHttpError(
HttpCode.BAD_GATEWAY,
"Unable to reach the identity provider while exchanging the authorization code. Please try again."
)
);
}
throw err;
}
const idToken = tokens.idToken();
logger.debug("ID token", { idToken });

View File

@@ -352,6 +352,14 @@ authenticated.post(
user.inviteUser
);
authenticated.delete(
"/org/:orgId/invitations/:inviteId",
verifyApiKeyOrgAccess,
verifyApiKeyHasAction(ActionsEnum.removeInvitation),
logActionAudit(ActionsEnum.removeInvitation),
user.removeInvitation
);
authenticated.get(
"/resource/:resourceId/roles",
verifyApiKeyResourceAccess,

View File

@@ -1,4 +1,4 @@
import { LoginPage } from "@server/db";
import type { LoginPage, LoginPageBranding } from "@server/db";
export type CreateLoginPageResponse = LoginPage;
@@ -9,3 +9,10 @@ export type GetLoginPageResponse = LoginPage;
export type UpdateLoginPageResponse = LoginPage;
export type LoadLoginPageResponse = LoginPage & { orgId: string };
export type LoadLoginPageBrandingResponse = LoginPageBranding & {
orgId: string;
orgName: string;
};
export type GetLoginPageBrandingResponse = LoginPageBranding;

View File

@@ -346,6 +346,7 @@ export const handleNewtRegisterMessage: MessageHandler = async (context) => {
type: "newt/wg/connect",
data: {
endpoint: `${exitNode.endpoint}:${exitNode.listenPort}`,
relayPort: config.getRawConfig().gerbil.clients_start_port,
publicKey: exitNode.publicKey,
serverIP: exitNode.address.split("/")[0],
tunnelIP: siteSubnet.split("/")[0],

View File

@@ -197,6 +197,7 @@ export async function getOlmToken(
const exitNodesHpData = allExitNodes.map((exitNode: ExitNode) => {
return {
publicKey: exitNode.publicKey,
relayPort: config.getRawConfig().gerbil.clients_start_port,
endpoint: exitNode.endpoint
};
});

View File

@@ -4,6 +4,7 @@ import { clients, clientSitesAssociationsCache, Olm } from "@server/db";
import { and, eq } from "drizzle-orm";
import { updatePeer as newtUpdatePeer } from "../newt/peers";
import logger from "@server/logger";
import config from "@server/lib/config";
export const handleOlmRelayMessage: MessageHandler = async (context) => {
const { message, client: c, sendToClient } = context;
@@ -88,7 +89,8 @@ export const handleOlmRelayMessage: MessageHandler = async (context) => {
type: "olm/wg/peer/relay",
data: {
siteId: siteId,
relayEndpoint: exitNode.endpoint
relayEndpoint: exitNode.endpoint,
relayPort: config.getRawConfig().gerbil.clients_start_port
}
},
broadcast: false,

View File

@@ -1,5 +1,6 @@
import { sendToClient } from "#dynamic/routers/ws";
import { db, olms } from "@server/db";
import config from "@server/lib/config";
import logger from "@server/logger";
import { eq } from "drizzle-orm";
import { Alias } from "yaml";
@@ -156,6 +157,7 @@ export async function initPeerAddHandshake(
siteId: peer.siteId,
exitNode: {
publicKey: peer.exitNode.publicKey,
relayPort: config.getRawConfig().gerbil.clients_start_port,
endpoint: peer.exitNode.endpoint
}
}

View File

@@ -89,7 +89,6 @@ export async function getResourceAuthInfo(
resourcePassword,
eq(resourcePassword.resourceId, resources.resourceId)
)
.leftJoin(
resourceHeaderAuth,
eq(

View File

@@ -1,5 +1,6 @@
import { db, exitNodes, newts } from "@server/db";
import { orgs, roleSites, sites, userSites } from "@server/db";
import { remoteExitNodes } from "@server/db";
import logger from "@server/logger";
import HttpCode from "@server/types/HttpCode";
import response from "@server/lib/response";
@@ -104,12 +105,17 @@ function querySites(orgId: string, accessibleSiteIds: number[]) {
newtVersion: newts.version,
exitNodeId: sites.exitNodeId,
exitNodeName: exitNodes.name,
exitNodeEndpoint: exitNodes.endpoint
exitNodeEndpoint: exitNodes.endpoint,
remoteExitNodeId: remoteExitNodes.remoteExitNodeId
})
.from(sites)
.leftJoin(orgs, eq(sites.orgId, orgs.orgId))
.leftJoin(newts, eq(newts.siteId, sites.siteId))
.leftJoin(exitNodes, eq(exitNodes.exitNodeId, sites.exitNodeId))
.leftJoin(
remoteExitNodes,
eq(remoteExitNodes.exitNodeId, sites.exitNodeId)
)
.where(
and(
inArray(sites.siteId, accessibleSiteIds),

View File

@@ -10,7 +10,7 @@ import {
userSiteResources
} from "@server/db";
import { getUniqueSiteResourceName } from "@server/db/names";
import { getNextAvailableAliasAddress } from "@server/lib/ip";
import { getNextAvailableAliasAddress, portRangeStringSchema } from "@server/lib/ip";
import { rebuildClientAssociationsFromSiteResource } from "@server/lib/rebuildClientAssociations";
import response from "@server/lib/response";
import logger from "@server/logger";
@@ -39,13 +39,16 @@ const createSiteResourceSchema = z
alias: z
.string()
.regex(
/^(?:[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?$/,
"Alias must be a fully qualified domain name (e.g., example.com)"
/^(?:[a-zA-Z0-9*?](?:[a-zA-Z0-9*?-]{0,61}[a-zA-Z0-9*?])?\.)+[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?$/,
"Alias must be a fully qualified domain name with optional wildcards (e.g., example.com, *.example.com, host-0?.example.internal)"
)
.optional(),
userIds: z.array(z.string()),
roleIds: z.array(z.int()),
clientIds: z.array(z.int())
clientIds: z.array(z.int()),
tcpPortRangeString: portRangeStringSchema,
udpPortRangeString: portRangeStringSchema,
disableIcmp: z.boolean().optional()
})
.strict()
.refine(
@@ -65,7 +68,7 @@ const createSiteResourceSchema = z
const domainRegex =
/^(?:[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)*[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?$/;
const isValidDomain = domainRegex.test(data.destination);
const isValidAlias = data.alias && domainRegex.test(data.alias);
const isValidAlias = data.alias !== undefined && data.alias !== null && data.alias.trim() !== "";
return isValidDomain && isValidAlias; // require the alias to be set in the case of domain
}
@@ -154,7 +157,10 @@ export async function createSiteResource(
alias,
userIds,
roleIds,
clientIds
clientIds,
tcpPortRangeString,
udpPortRangeString,
disableIcmp
} = parsedBody.data;
// Verify the site exists and belongs to the org
@@ -239,7 +245,10 @@ export async function createSiteResource(
destination,
enabled,
alias,
aliasAddress
aliasAddress,
tcpPortRangeString,
udpPortRangeString,
disableIcmp
})
.returning();

View File

@@ -97,6 +97,9 @@ export async function listAllSiteResourcesByOrg(
destination: siteResources.destination,
enabled: siteResources.enabled,
alias: siteResources.alias,
tcpPortRangeString: siteResources.tcpPortRangeString,
udpPortRangeString: siteResources.udpPortRangeString,
disableIcmp: siteResources.disableIcmp,
siteName: sites.name,
siteNiceId: sites.niceId,
siteAddress: sites.address

View File

@@ -23,7 +23,8 @@ import { updatePeerData, updateTargets } from "@server/routers/client/targets";
import {
generateAliasConfig,
generateRemoteSubnets,
generateSubnetProxyTargets
generateSubnetProxyTargets,
portRangeStringSchema
} from "@server/lib/ip";
import {
getClientSiteResourceAccess,
@@ -49,13 +50,16 @@ const updateSiteResourceSchema = z
alias: z
.string()
.regex(
/^(?:[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?$/,
"Alias must be a fully qualified domain name (e.g., example.internal)"
/^(?:[a-zA-Z0-9*?](?:[a-zA-Z0-9*?-]{0,61}[a-zA-Z0-9*?])?\.)+[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?$/,
"Alias must be a fully qualified domain name with optional wildcards (e.g., example.internal, *.example.internal, host-0?.example.internal)"
)
.nullish(),
userIds: z.array(z.string()),
roleIds: z.array(z.int()),
clientIds: z.array(z.int())
clientIds: z.array(z.int()),
tcpPortRangeString: portRangeStringSchema,
udpPortRangeString: portRangeStringSchema,
disableIcmp: z.boolean().optional()
})
.strict()
.refine(
@@ -74,7 +78,7 @@ const updateSiteResourceSchema = z
const domainRegex =
/^(?:[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)*[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?$/;
const isValidDomain = domainRegex.test(data.destination);
const isValidAlias = data.alias && domainRegex.test(data.alias);
const isValidAlias = data.alias !== undefined && data.alias !== null && data.alias.trim() !== "";
return isValidDomain && isValidAlias; // require the alias to be set in the case of domain
}
@@ -160,7 +164,10 @@ export async function updateSiteResource(
enabled,
userIds,
roleIds,
clientIds
clientIds,
tcpPortRangeString,
udpPortRangeString,
disableIcmp
} = parsedBody.data;
const [site] = await db
@@ -226,7 +233,10 @@ export async function updateSiteResource(
mode: mode,
destination: destination,
enabled: enabled,
alias: alias && alias.trim() ? alias : null
alias: alias && alias.trim() ? alias : null,
tcpPortRangeString: tcpPortRangeString,
udpPortRangeString: udpPortRangeString,
disableIcmp: disableIcmp
})
.where(
and(
@@ -348,10 +358,18 @@ export async function handleMessagingForUpdatedSiteResource(
const aliasChanged =
existingSiteResource &&
existingSiteResource.alias !== updatedSiteResource.alias;
const portRangesChanged =
existingSiteResource &&
(existingSiteResource.tcpPortRangeString !==
updatedSiteResource.tcpPortRangeString ||
existingSiteResource.udpPortRangeString !==
updatedSiteResource.udpPortRangeString ||
existingSiteResource.disableIcmp !==
updatedSiteResource.disableIcmp);
// if the existingSiteResource is undefined (new resource) we don't need to do anything here, the rebuild above handled it all
if (destinationChanged || aliasChanged) {
if (destinationChanged || aliasChanged || portRangesChanged) {
const [newt] = await trx
.select()
.from(newts)
@@ -365,7 +383,7 @@ export async function handleMessagingForUpdatedSiteResource(
}
// Only update targets on newt if destination changed
if (destinationChanged) {
if (destinationChanged || portRangesChanged) {
const oldTargets = generateSubnetProxyTargets(
existingSiteResource,
mergedAllClients

View File

@@ -8,12 +8,24 @@ import HttpCode from "@server/types/HttpCode";
import createHttpError from "http-errors";
import logger from "@server/logger";
import { fromError } from "zod-validation-error";
import { OpenAPITags, registry } from "@server/openApi";
const removeInvitationParamsSchema = z.strictObject({
orgId: z.string(),
inviteId: z.string()
});
registry.registerPath({
method: "delete",
path: "/org/{orgId}/invitations/{inviteId}",
description: "Remove an open invitation from an organization",
tags: [OpenAPITags.Org],
request: {
params: removeInvitationParamsSchema
},
responses: {}
});
export async function removeInvitation(
req: Request,
res: Response,

View File

@@ -16,11 +16,23 @@ function generateToken(): string {
return generateRandomString(random, alphabet, 32);
}
function validateToken(token: string): boolean {
const tokenRegex = /^[a-z0-9]{32}$/;
return tokenRegex.test(token);
}
function generateId(length: number): string {
const alphabet = "abcdefghijklmnopqrstuvwxyz0123456789";
return generateRandomString(random, alphabet, length);
}
function showSetupToken(token: string, source: string): void {
console.log(`=== SETUP TOKEN ${source} ===`);
console.log("Token:", token);
console.log("Use this token on the initial setup page");
console.log("================================");
}
export async function ensureSetupToken() {
try {
// Check if a server admin already exists
@@ -38,17 +50,48 @@ export async function ensureSetupToken() {
}
// Check if a setup token already exists
const existingTokens = await db
const [existingToken] = await db
.select()
.from(setupTokens)
.where(eq(setupTokens.used, false));
const envSetupToken = process.env.PANGOLIN_SETUP_TOKEN;
console.debug("PANGOLIN_SETUP_TOKEN:", envSetupToken);
if (envSetupToken) {
if (!validateToken(envSetupToken)) {
throw new Error(
"invalid token format for PANGOLIN_SETUP_TOKEN"
);
}
if (existingToken?.token !== envSetupToken) {
console.warn(
"Overwriting existing token in DB since PANGOLIN_SETUP_TOKEN is set"
);
await db
.update(setupTokens)
.set({ token: envSetupToken })
.where(eq(setupTokens.tokenId, existingToken.tokenId));
} else {
const tokenId = generateId(15);
await db.insert(setupTokens).values({
tokenId: tokenId,
token: envSetupToken,
used: false,
dateCreated: moment().toISOString(),
dateUsed: null
});
}
showSetupToken(envSetupToken, "FROM ENVIRONMENT");
return;
}
// If unused token exists, display it instead of creating a new one
if (existingTokens.length > 0) {
console.log("=== SETUP TOKEN EXISTS ===");
console.log("Token:", existingTokens[0].token);
console.log("Use this token on the initial setup page");
console.log("================================");
if (existingToken) {
showSetupToken(existingToken.token, "EXISTS");
return;
}
@@ -64,10 +107,7 @@ export async function ensureSetupToken() {
dateUsed: null
});
console.log("=== SETUP TOKEN GENERATED ===");
console.log("Token:", token);
console.log("Use this token on the initial setup page");
console.log("================================");
showSetupToken(token, "GENERATED");
} catch (error) {
console.error("Failed to ensure setup token:", error);
throw error;

View File

@@ -1,16 +1,11 @@
import { internal } from "@app/lib/api";
import { authCookieHeader } from "@app/lib/api/cookies";
import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
import { HorizontalTabs } from "@app/components/HorizontalTabs";
import { verifySession } from "@app/lib/auth/verifySession";
import OrgProvider from "@app/providers/OrgProvider";
import OrgUserProvider from "@app/providers/OrgUserProvider";
import { GetOrgResponse } from "@server/routers/org";
import { GetOrgUserResponse } from "@server/routers/user";
import { AxiosResponse } from "axios";
import { redirect } from "next/navigation";
import { cache } from "react";
import { getTranslations } from "next-intl/server";
import { getCachedOrgUser } from "@app/lib/api/getCachedOrgUser";
import { getCachedOrg } from "@app/lib/api/getCachedOrg";
type BillingSettingsProps = {
children: React.ReactNode;
@@ -23,8 +18,7 @@ export default async function BillingSettingsPage({
}: BillingSettingsProps) {
const { orgId } = await params;
const getUser = cache(verifySession);
const user = await getUser();
const user = await verifySession();
if (!user) {
redirect(`/`);
@@ -32,13 +26,7 @@ export default async function BillingSettingsPage({
let orgUser = null;
try {
const getOrgUser = cache(async () =>
internal.get<AxiosResponse<GetOrgUserResponse>>(
`/org/${orgId}/user/${user.userId}`,
await authCookieHeader()
)
);
const res = await getOrgUser();
const res = await getCachedOrgUser(orgId, user.userId);
orgUser = res.data.data;
} catch {
redirect(`/${orgId}`);
@@ -46,13 +34,7 @@ export default async function BillingSettingsPage({
let org = null;
try {
const getOrg = cache(async () =>
internal.get<AxiosResponse<GetOrgResponse>>(
`/org/${orgId}`,
await authCookieHeader()
)
);
const res = await getOrg();
const res = await getCachedOrg(orgId);
org = res.data.data;
} catch {
redirect(`/${orgId}`);

View File

@@ -3,7 +3,7 @@ import { GetIdpResponse as GetOrgIdpResponse } from "@server/routers/idp";
import { AxiosResponse } from "axios";
import { redirect } from "next/navigation";
import { authCookieHeader } from "@app/lib/api/cookies";
import { HorizontalTabs } from "@app/components/HorizontalTabs";
import { HorizontalTabs, TabItem } from "@app/components/HorizontalTabs";
import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
import { getTranslations } from "next-intl/server";
@@ -28,7 +28,7 @@ export default async function SettingsLayout(props: SettingsLayoutProps) {
redirect(`/${params.orgId}/settings/idp`);
}
const navItems: HorizontalTabs = [
const navItems: TabItem[] = [
{
title: t("general"),
href: `/${params.orgId}/settings/idp/${params.idpId}/general`

View File

@@ -331,29 +331,24 @@ export default function Page() {
</form>
</Form>
</SettingsSectionForm>
</SettingsSectionBody>
</SettingsSection>
<SettingsSection>
<SettingsSectionHeader>
<SettingsSectionTitle>
{t("idpType")}
</SettingsSectionTitle>
<SettingsSectionDescription>
{t("idpTypeDescription")}
</SettingsSectionDescription>
</SettingsSectionHeader>
<SettingsSectionBody>
<StrategySelect
options={providerTypes}
defaultValue={form.getValues("type")}
onChange={(value) => {
handleProviderChange(
value as "oidc" | "google" | "azure"
);
}}
cols={3}
/>
<div>
<div className="mb-2">
<span className="text-sm font-medium">
{t("idpType")}
</span>
</div>
<StrategySelect
options={providerTypes}
defaultValue={form.getValues("type")}
onChange={(value) => {
handleProviderChange(
value as "oidc" | "google" | "azure"
);
}}
cols={3}
/>
</div>
</SettingsSectionBody>
</SettingsSection>

View File

@@ -26,7 +26,6 @@ import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog";
import { useSubscriptionStatusContext } from "@app/hooks/useSubscriptionStatusContext";
import { useLicenseStatusContext } from "@app/hooks/useLicenseStatusContext";
import { build } from "@server/build";
import { SecurityFeaturesAlert } from "@app/components/SecurityFeaturesAlert";
import {
InfoSection,
InfoSectionContent,
@@ -36,6 +35,7 @@ import {
import CopyToClipboard from "@app/components/CopyToClipboard";
import { Alert, AlertDescription, AlertTitle } from "@app/components/ui/alert";
import { InfoIcon } from "lucide-react";
import { PaidFeaturesAlert } from "@app/components/PaidFeaturesAlert";
export default function CredentialsPage() {
const { env } = useEnvContext();
@@ -131,19 +131,19 @@ export default function CredentialsPage() {
<SettingsSection>
<SettingsSectionHeader>
<SettingsSectionTitle>
{t("generatedcredentials")}
{t("credentials")}
</SettingsSectionTitle>
<SettingsSectionDescription>
{t("regenerateCredentials")}
{t("remoteNodeCredentialsDescription")}
</SettingsSectionDescription>
</SettingsSectionHeader>
<SettingsSectionBody>
<SecurityFeaturesAlert />
<PaidFeaturesAlert />
<InfoSections cols={3}>
<InfoSection>
<InfoSectionTitle>
{t("endpoint") || "Endpoint"}
{t("endpoint")}
</InfoSectionTitle>
<InfoSectionContent>
<CopyToClipboard
@@ -153,8 +153,7 @@ export default function CredentialsPage() {
</InfoSection>
<InfoSection>
<InfoSectionTitle>
{t("remoteExitNodeId") ||
"Remote Exit Node ID"}
{t("remoteExitNodeId")}
</InfoSectionTitle>
<InfoSectionContent>
{displayRemoteExitNodeId ? (
@@ -168,7 +167,7 @@ export default function CredentialsPage() {
</InfoSection>
<InfoSection>
<InfoSectionTitle>
{t("secretKey") || "Secret Key"}
{t("remoteExitNodeSecretKey")}
</InfoSectionTitle>
<InfoSectionContent>
{displaySecret ? (

View File

@@ -43,7 +43,7 @@ export default async function SettingsLayout(props: SettingsLayoutProps) {
return (
<>
<SettingsSectionTitle
title={`Remote Exit Node ${remoteExitNode?.name || "Unknown"}`}
title={`Remote Node ${remoteExitNode?.name || "Unknown"}`}
description="Manage your remote exit node settings and configuration"
/>

View File

@@ -2,7 +2,9 @@ import { internal } from "@app/lib/api";
import { authCookieHeader } from "@app/lib/api/cookies";
import { ListRemoteExitNodesResponse } from "@server/routers/remoteExitNode/types";
import { AxiosResponse } from "axios";
import ExitNodesTable, { RemoteExitNodeRow } from "./ExitNodesTable";
import ExitNodesTable, {
RemoteExitNodeRow
} from "@app/components/ExitNodesTable";
import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
import { getTranslations } from "next-intl/server";

View File

@@ -22,7 +22,6 @@ import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog";
import { useLicenseStatusContext } from "@app/hooks/useLicenseStatusContext";
import { useSubscriptionStatusContext } from "@app/hooks/useSubscriptionStatusContext";
import { build } from "@server/build";
import { SecurityFeaturesAlert } from "@app/components/SecurityFeaturesAlert";
import {
InfoSection,
InfoSectionContent,
@@ -32,6 +31,7 @@ import {
import CopyToClipboard from "@app/components/CopyToClipboard";
import { Alert, AlertDescription, AlertTitle } from "@app/components/ui/alert";
import { InfoIcon } from "lucide-react";
import { PaidFeaturesAlert } from "@app/components/PaidFeaturesAlert";
export default function CredentialsPage() {
const { env } = useEnvContext();
@@ -127,7 +127,7 @@ export default function CredentialsPage() {
</SettingsSectionDescription>
</SettingsSectionHeader>
<SettingsSectionBody>
<SecurityFeaturesAlert />
<PaidFeaturesAlert />
<InfoSections cols={3}>
<InfoSection>

View File

@@ -0,0 +1,56 @@
import AuthPageBrandingForm from "@app/components/AuthPageBrandingForm";
import AuthPageSettings from "@app/components/private/AuthPageSettings";
import { SettingsContainer } from "@app/components/Settings";
import { internal } from "@app/lib/api";
import { authCookieHeader } from "@app/lib/api/cookies";
import { getCachedSubscription } from "@app/lib/api/getCachedSubscription";
import { build } from "@server/build";
import type { GetOrgTierResponse } from "@server/routers/billing/types";
import {
GetLoginPageBrandingResponse,
GetLoginPageResponse
} from "@server/routers/loginPage/types";
import { AxiosResponse } from "axios";
export interface AuthPageProps {
params: Promise<{ orgId: string }>;
}
export default async function AuthPage(props: AuthPageProps) {
const orgId = (await props.params).orgId;
let subscriptionStatus: GetOrgTierResponse | null = null;
try {
const subRes = await getCachedSubscription(orgId);
subscriptionStatus = subRes.data.data;
} catch {}
let loginPage: GetLoginPageResponse | null = null;
try {
if (build === "saas") {
const res = await internal.get<AxiosResponse<GetLoginPageResponse>>(
`/org/${orgId}/login-page`,
await authCookieHeader()
);
if (res.status === 200) {
loginPage = res.data.data;
}
}
} catch (error) {}
let loginPageBranding: GetLoginPageBrandingResponse | null = null;
try {
const res = await internal.get<
AxiosResponse<GetLoginPageBrandingResponse>
>(`/org/${orgId}/login-page-branding`, await authCookieHeader());
if (res.status === 200) {
loginPageBranding = res.data.data;
}
} catch (error) {}
return (
<SettingsContainer>
{build === "saas" && <AuthPageSettings loginPage={loginPage} />}
<AuthPageBrandingForm orgId={orgId} branding={loginPageBranding} />
</SettingsContainer>
);
}

View File

@@ -1,16 +1,14 @@
import { internal } from "@app/lib/api";
import { authCookieHeader } from "@app/lib/api/cookies";
import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
import { HorizontalTabs } from "@app/components/HorizontalTabs";
import { HorizontalTabs, type TabItem } from "@app/components/HorizontalTabs";
import { verifySession } from "@app/lib/auth/verifySession";
import OrgProvider from "@app/providers/OrgProvider";
import OrgUserProvider from "@app/providers/OrgUserProvider";
import { GetOrgResponse } from "@server/routers/org";
import { GetOrgUserResponse } from "@server/routers/user";
import { AxiosResponse } from "axios";
import { redirect } from "next/navigation";
import { cache } from "react";
import { getTranslations } from "next-intl/server";
import { getCachedOrg } from "@app/lib/api/getCachedOrg";
import { getCachedOrgUser } from "@app/lib/api/getCachedOrgUser";
import { build } from "@server/build";
type GeneralSettingsProps = {
children: React.ReactNode;
@@ -23,8 +21,7 @@ export default async function GeneralSettingsPage({
}: GeneralSettingsProps) {
const { orgId } = await params;
const getUser = cache(verifySession);
const user = await getUser();
const user = await verifySession();
if (!user) {
redirect(`/`);
@@ -32,13 +29,7 @@ export default async function GeneralSettingsPage({
let orgUser = null;
try {
const getOrgUser = cache(async () =>
internal.get<AxiosResponse<GetOrgUserResponse>>(
`/org/${orgId}/user/${user.userId}`,
await authCookieHeader()
)
);
const res = await getOrgUser();
const res = await getCachedOrgUser(orgId, user.userId);
orgUser = res.data.data;
} catch {
redirect(`/${orgId}`);
@@ -46,13 +37,7 @@ export default async function GeneralSettingsPage({
let org = null;
try {
const getOrg = cache(async () =>
internal.get<AxiosResponse<GetOrgResponse>>(
`/org/${orgId}`,
await authCookieHeader()
)
);
const res = await getOrg();
const res = await getCachedOrg(orgId);
org = res.data.data;
} catch {
redirect(`/${orgId}`);
@@ -60,12 +45,19 @@ export default async function GeneralSettingsPage({
const t = await getTranslations();
const navItems = [
const navItems: TabItem[] = [
{
title: t("general"),
href: `/{orgId}/settings/general`
href: `/{orgId}/settings/general`,
exact: true
}
];
if (build !== "oss") {
navItems.push({
title: t("authPage"),
href: `/{orgId}/settings/general/auth-page`
});
}
return (
<>

View File

@@ -43,14 +43,13 @@ import {
SettingsSectionTitle,
SettingsSectionDescription,
SettingsSectionBody,
SettingsSectionForm,
SettingsSectionFooter
SettingsSectionForm
} from "@app/components/Settings";
import { useUserContext } from "@app/hooks/useUserContext";
import { useTranslations } from "next-intl";
import { build } from "@server/build";
import { SwitchInput } from "@app/components/SwitchInput";
import { SecurityFeaturesAlert } from "@app/components/SecurityFeaturesAlert";
import { PaidFeaturesAlert } from "@app/components/PaidFeaturesAlert";
import { useLicenseStatusContext } from "@app/hooks/useLicenseStatusContext";
import { useSubscriptionStatusContext } from "@app/hooks/useSubscriptionStatusContext";
import { usePaidStatus } from "@app/hooks/usePaidStatus";
@@ -113,29 +112,18 @@ const LOG_RETENTION_OPTIONS = [
export default function GeneralPage() {
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
const { orgUser } = userOrgUserContext();
const router = useRouter();
const { org } = useOrgContext();
const api = createApiClient(useEnvContext());
const { user } = useUserContext();
const t = useTranslations();
const { env } = useEnvContext();
const { licenseStatus, isUnlocked } = useLicenseStatusContext();
const subscription = useSubscriptionStatusContext();
// Check if security features are disabled due to licensing/subscription
const isSecurityFeatureDisabled = () => {
const isEnterpriseNotLicensed = build === "enterprise" && !isUnlocked();
const isSaasNotSubscribed =
build === "saas" && !subscription?.isSubscribed();
return isEnterpriseNotLicensed || isSaasNotSubscribed;
};
const { isPaidUser, hasSaasSubscription } = usePaidStatus();
const [loadingDelete, setLoadingDelete] = useState(false);
const [loadingSave, setLoadingSave] = useState(false);
const [isSecurityPolicyConfirmOpen, setIsSecurityPolicyConfirmOpen] =
useState(false);
const authPageSettingsRef = useRef<AuthPageSettingsRef>(null);
const form = useForm({
resolver: zodResolver(GeneralFormSchema),
@@ -258,14 +246,6 @@ export default function GeneralPage() {
// Update organization
await api.post(`/org/${org?.org.orgId}`, reqData);
// Also save auth page settings if they have unsaved changes
if (
build === "saas" &&
authPageSettingsRef.current?.hasUnsavedChanges()
) {
await authPageSettingsRef.current.saveAuthSettings();
}
toast({
title: t("orgUpdated"),
description: t("orgUpdatedDescription")
@@ -410,9 +390,7 @@ export default function GeneralPage() {
{LOG_RETENTION_OPTIONS.filter(
(option) => {
if (
build ==
"saas" &&
!subscription?.subscribed &&
hasSaasSubscription &&
option.value >
30
) {
@@ -440,19 +418,15 @@ export default function GeneralPage() {
)}
/>
{build != "oss" && (
{build !== "oss" && (
<>
<SecurityFeaturesAlert />
<PaidFeaturesAlert />
<FormField
control={form.control}
name="settingsLogRetentionDaysAccess"
render={({ field }) => {
const isDisabled =
(build == "saas" &&
!subscription?.subscribed) ||
(build == "enterprise" &&
!isUnlocked());
const isDisabled = !isPaidUser;
return (
<FormItem>
@@ -518,11 +492,7 @@ export default function GeneralPage() {
control={form.control}
name="settingsLogRetentionDaysAction"
render={({ field }) => {
const isDisabled =
(build == "saas" &&
!subscription?.subscribed) ||
(build == "enterprise" &&
!isUnlocked());
const isDisabled = !isPaidUser;
return (
<FormItem>
@@ -590,8 +560,7 @@ export default function GeneralPage() {
</SettingsSectionBody>
{build !== "oss" && (
<>
<hr className="my-10 max-w-xl" />
<SettingsSection>
<SettingsSectionHeader>
<SettingsSectionTitle>
{t("securitySettings")}
@@ -601,14 +570,13 @@ export default function GeneralPage() {
</SettingsSectionDescription>
</SettingsSectionHeader>
<SettingsSectionBody>
<SettingsSectionForm className="mb-4">
<SecurityFeaturesAlert />
<SettingsSectionForm>
<PaidFeaturesAlert />
<FormField
control={form.control}
name="requireTwoFactor"
render={({ field }) => {
const isDisabled =
isSecurityFeatureDisabled();
const isDisabled = !isPaidUser;
return (
<FormItem className="col-span-2">
@@ -655,8 +623,7 @@ export default function GeneralPage() {
control={form.control}
name="maxSessionLengthHours"
render={({ field }) => {
const isDisabled =
isSecurityFeatureDisabled();
const isDisabled = !isPaidUser;
return (
<FormItem className="col-span-2">
@@ -744,8 +711,7 @@ export default function GeneralPage() {
control={form.control}
name="passwordExpiryDays"
render={({ field }) => {
const isDisabled =
isSecurityFeatureDisabled();
const isDisabled = !isPaidUser;
return (
<FormItem className="col-span-2">
@@ -831,7 +797,7 @@ export default function GeneralPage() {
/>
</SettingsSectionForm>
</SettingsSectionBody>
</>
</SettingsSection>
)}
<div className="flex justify-end gap-2 mt-4">
@@ -848,8 +814,6 @@ export default function GeneralPage() {
</form>
</Form>
{build === "saas" && <AuthPageSettings ref={authPageSettingsRef} />}
{build !== "saas" && (
<SettingsSection>
<SettingsSectionHeader>

View File

@@ -67,7 +67,10 @@ export default async function ClientResourcesPage(
// destinationPort: siteResource.destinationPort,
alias: siteResource.alias || null,
siteNiceId: siteResource.siteNiceId,
niceId: siteResource.niceId
niceId: siteResource.niceId,
tcpPortRangeString: siteResource.tcpPortRangeString || null,
udpPortRangeString: siteResource.udpPortRangeString || null,
disableIcmp: siteResource.disableIcmp || false,
};
}
);

View File

@@ -12,10 +12,6 @@ import {
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { useResourceContext } from "@app/hooks/useResourceContext";
import { formatAxiosError } from "@app/lib/api";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import {
Credenza,
CredenzaBody,
@@ -41,7 +37,7 @@ import { SwitchInput } from "@app/components/SwitchInput";
import { Label } from "@app/components/ui/label";
import { useEnvContext } from "@app/hooks/useEnvContext";
import { toast } from "@app/hooks/useToast";
import { createApiClient } from "@app/lib/api";
import { createApiClient, formatAxiosError } from "@app/lib/api";
import { finalizeSubdomainSanitize } from "@app/lib/subdomain-utils";
import { UpdateResourceResponse } from "@server/routers/resource";
import { AxiosResponse } from "axios";
@@ -51,6 +47,8 @@ import { useParams, useRouter } from "next/navigation";
import { toASCII, toUnicode } from "punycode";
import { useActionState, useMemo, useState } from "react";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import z from "zod";
export default function GeneralForm() {
const params = useParams();
@@ -69,28 +67,14 @@ export default function GeneralForm() {
`${resource.ssl ? "https" : "http"}://${toUnicode(resource.fullDomain || "")}`
);
console.log({ resource });
const [defaultSubdomain, defaultBaseDomain] = useMemo(() => {
const resourceUrl = new URL(resourceFullDomain);
const domain = resourceUrl.hostname;
const allDomainParts = domain.split(".");
let sub = undefined;
let base = domain;
if (allDomainParts.length >= 3) {
// 3 parts: [subdomain, domain, tld]
const [first, ...rest] = allDomainParts;
sub = first;
base = rest.join(".");
}
return [sub, base];
const resourceFullDomainName = useMemo(() => {
const url = new URL(resourceFullDomain);
return url.hostname;
}, [resourceFullDomain]);
const [selectedDomain, setSelectedDomain] = useState<{
domainId: string;
domainNamespaceId?: string;
subdomain?: string;
fullDomain: string;
baseDomain: string;
@@ -177,7 +161,11 @@ export default function GeneralForm() {
niceId: data.niceId,
subdomain: data.subdomain,
fullDomain: updated.fullDomain,
proxyPort: data.proxyPort
proxyPort: data.proxyPort,
domainId: data.domainId
// ...(!resource.http && {
// enableProxy: data.enableProxy
// })
});
toast({
@@ -359,9 +347,6 @@ export default function GeneralForm() {
<SettingsSectionFooter>
<Button
type="submit"
onClick={() => {
console.log(form.getValues());
}}
loading={saveLoading}
disabled={saveLoading}
form="general-settings-form"
@@ -387,15 +372,26 @@ export default function GeneralForm() {
<DomainPicker
orgId={orgId as string}
cols={1}
defaultSubdomain={defaultSubdomain}
defaultBaseDomain={defaultBaseDomain}
defaultSubdomain={
form.watch("subdomain") ?? resource.subdomain
}
defaultDomainId={
form.watch("domainId") ?? resource.domainId
}
defaultFullDomain={resourceFullDomainName}
onDomainChange={(res) => {
const selected = {
domainId: res.domainId,
subdomain: res.subdomain,
fullDomain: res.fullDomain,
baseDomain: res.baseDomain
};
const selected =
res === null
? null
: {
domainId: res.domainId,
subdomain: res.subdomain,
fullDomain: res.fullDomain,
baseDomain: res.baseDomain,
domainNamespaceId:
res.domainNamespaceId
};
setSelectedDomain(selected);
}}
/>

View File

@@ -1396,6 +1396,8 @@ export default function Page() {
<DomainPicker
orgId={orgId as string}
onDomainChange={(res) => {
if (!res) return;
httpForm.setValue(
"subdomain",
res.subdomain
@@ -1848,7 +1850,7 @@ export default function Page() {
<Link
className="text-sm text-primary flex items-center gap-1"
href="https://docs.pangolin.net/manage/resources/tcp-udp-resources"
href="https://docs.pangolin.net/manage/resources/public/raw-resources"
target="_blank"
rel="noopener noreferrer"
>

View File

@@ -23,7 +23,6 @@ import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog";
import { useLicenseStatusContext } from "@app/hooks/useLicenseStatusContext";
import { useSubscriptionStatusContext } from "@app/hooks/useSubscriptionStatusContext";
import { build } from "@server/build";
import { SecurityFeaturesAlert } from "@app/components/SecurityFeaturesAlert";
import {
InfoSection,
InfoSectionContent,
@@ -39,6 +38,7 @@ import {
generateObfuscatedWireGuardConfig
} from "@app/lib/wireguard";
import { QRCodeCanvas } from "qrcode.react";
import { PaidFeaturesAlert } from "@app/components/PaidFeaturesAlert";
export default function CredentialsPage() {
const { env } = useEnvContext();
@@ -203,7 +203,7 @@ export default function CredentialsPage() {
</SettingsSectionDescription>
</SettingsSectionHeader>
<SecurityFeaturesAlert />
<PaidFeaturesAlert />
<SettingsSectionBody>
<InfoSections cols={3}>
@@ -300,7 +300,7 @@ export default function CredentialsPage() {
</SettingsSectionDescription>
</SettingsSectionHeader>
<SecurityFeaturesAlert />
<PaidFeaturesAlert />
<SettingsSectionBody>
{!loadingDefaults && (

View File

@@ -53,7 +53,8 @@ export default async function SitesPage(props: SitesPageProps) {
newtVersion: site.newtVersion || undefined,
newtUpdateAvailable: site.newtUpdateAvailable || false,
exitNodeName: site.exitNodeName || undefined,
exitNodeEndpoint: site.exitNodeEndpoint || undefined
exitNodeEndpoint: site.exitNodeEndpoint || undefined,
remoteExitNodeId: (site as any).remoteExitNodeId || undefined
};
});

View File

@@ -3,7 +3,7 @@ import { GetIdpResponse } from "@server/routers/idp";
import { AxiosResponse } from "axios";
import { redirect } from "next/navigation";
import { authCookieHeader } from "@app/lib/api/cookies";
import { HorizontalTabs } from "@app/components/HorizontalTabs";
import { HorizontalTabs, TabItem } from "@app/components/HorizontalTabs";
import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
import { getTranslations } from "next-intl/server";
@@ -28,7 +28,7 @@ export default async function SettingsLayout(props: SettingsLayoutProps) {
redirect("/admin/idp");
}
const navItems: HorizontalTabs = [
const navItems: TabItem[] = [
{
title: t("general"),
href: `/admin/idp/${params.idpId}/general`

View File

@@ -208,27 +208,23 @@ export default function Page() {
</form>
</Form>
</SettingsSectionForm>
</SettingsSectionBody>
</SettingsSection>
<SettingsSection>
<SettingsSectionHeader>
<SettingsSectionTitle>
{t("idpType")}
</SettingsSectionTitle>
<SettingsSectionDescription>
{t("idpTypeDescription")}
</SettingsSectionDescription>
</SettingsSectionHeader>
<SettingsSectionBody>
<StrategySelect
options={providerTypes}
defaultValue={form.getValues("type")}
onChange={(value) => {
form.setValue("type", value as "oidc");
}}
cols={3}
/>
<div>
<div className="mb-2">
<span className="text-sm font-medium">
{t("idpType")}
</span>
</div>
<StrategySelect
options={providerTypes}
defaultValue={form.getValues("type")}
onChange={(value) => {
form.setValue("type", value as "oidc");
}}
cols={3}
/>
</div>
</SettingsSectionBody>
</SettingsSection>

View File

@@ -9,7 +9,10 @@ import { LoginFormIDP } from "@app/components/LoginForm";
import { ListOrgIdpsResponse } from "@server/routers/orgIdp/types";
import { build } from "@server/build";
import { headers } from "next/headers";
import { LoadLoginPageResponse } from "@server/routers/loginPage/types";
import {
LoadLoginPageBrandingResponse,
LoadLoginPageResponse
} from "@server/routers/loginPage/types";
import IdpLoginButtons from "@app/components/private/IdpLoginButtons";
import {
Card,
@@ -23,8 +26,8 @@ import Link from "next/link";
import { getTranslations } from "next-intl/server";
import { GetSessionTransferTokenRenponse } from "@server/routers/auth/types";
import ValidateSessionTransferToken from "@app/components/private/ValidateSessionTransferToken";
import { GetOrgTierResponse } from "@server/routers/billing/types";
import { TierId } from "@server/lib/billing/tiers";
import { replacePlaceholder } from "@app/lib/replacePlaceholder";
import { isOrgSubscribed } from "@app/lib/api/isOrgSubscribed";
export const dynamic = "force-dynamic";
@@ -32,7 +35,6 @@ export default async function OrgAuthPage(props: {
params: Promise<{}>;
searchParams: Promise<{ token?: string }>;
}) {
const params = await props.params;
const searchParams = await props.searchParams;
const env = pullEnv();
@@ -73,22 +75,7 @@ export default async function OrgAuthPage(props: {
redirect(env.app.dashboardUrl);
}
let subscriptionStatus: GetOrgTierResponse | null = null;
if (build === "saas") {
try {
const getSubscription = cache(() =>
priv.get<AxiosResponse<GetOrgTierResponse>>(
`/org/${loginPage!.orgId}/billing/tier`
)
);
const subRes = await getSubscription();
subscriptionStatus = subRes.data.data;
} catch {}
}
const subscribed =
build === "enterprise"
? true
: subscriptionStatus?.tier === TierId.STANDARD;
const subscribed = await isOrgSubscribed(loginPage.orgId);
if (build === "saas" && !subscribed) {
console.log(
@@ -126,12 +113,10 @@ export default async function OrgAuthPage(props: {
let loginIdps: LoginFormIDP[] = [];
if (build === "saas") {
const idpsRes = await cache(
async () =>
await priv.get<AxiosResponse<ListOrgIdpsResponse>>(
`/org/${loginPage!.orgId}/idp`
)
)();
const idpsRes = await priv.get<AxiosResponse<ListOrgIdpsResponse>>(
`/org/${loginPage.orgId}/idp`
);
loginIdps = idpsRes.data.data.idps.map((idp) => ({
idpId: idp.idpId,
name: idp.name,
@@ -139,6 +124,18 @@ export default async function OrgAuthPage(props: {
})) as LoginFormIDP[];
}
let branding: LoadLoginPageBrandingResponse | null = null;
if (build === "saas") {
try {
const res = await priv.get<
AxiosResponse<LoadLoginPageBrandingResponse>
>(`/login-page-branding?orgId=${loginPage.orgId}`);
if (res.status === 200) {
branding = res.data.data;
}
} catch (error) {}
}
return (
<div>
<div className="text-center mb-2">
@@ -156,11 +153,30 @@ export default async function OrgAuthPage(props: {
</div>
<Card className="w-full max-w-md">
<CardHeader>
<CardTitle>{t("orgAuthSignInTitle")}</CardTitle>
{branding?.logoUrl && (
<div className="flex flex-row items-center justify-center mb-3">
<img
src={branding.logoUrl}
height={branding.logoHeight}
width={branding.logoWidth}
/>
</div>
)}
<CardTitle>
{branding?.orgTitle
? replacePlaceholder(branding.orgTitle, {
orgName: branding.orgName
})
: t("orgAuthSignInTitle")}
</CardTitle>
<CardDescription>
{loginIdps.length > 0
? t("orgAuthChooseIdpDescription")
: ""}
{branding?.orgSubtitle
? replacePlaceholder(branding.orgSubtitle, {
orgName: branding.orgName
})
: loginIdps.length > 0
? t("orgAuthChooseIdpDescription")
: ""}
</CardDescription>
</CardHeader>
<CardContent>

View File

@@ -14,8 +14,11 @@ export const dynamic = "force-dynamic";
export default async function Page(props: {
params: Promise<{ orgId: string; idpId: string }>;
searchParams: Promise<{
code: string;
state: string;
code?: string;
state?: string;
error?: string;
error_description?: string;
error_uri?: string;
}>;
}) {
const params = await props.params;
@@ -61,6 +64,14 @@ export default async function Page(props: {
}
}
const providerError = searchParams.error
? {
error: searchParams.error,
description: searchParams.error_description,
uri: searchParams.error_uri
}
: undefined;
return (
<>
<ValidateOidcToken
@@ -71,6 +82,7 @@ export default async function Page(props: {
expectedState={searchParams.state}
stateCookie={stateCookie}
idp={{ name: foundIdp.name }}
providerError={providerError}
/>
</>
);

View File

@@ -19,11 +19,15 @@ import { ListOrgIdpsResponse } from "@server/routers/orgIdp/types";
import AutoLoginHandler from "@app/components/AutoLoginHandler";
import { build } from "@server/build";
import { headers } from "next/headers";
import { GetLoginPageResponse } from "@server/routers/loginPage/types";
import type {
LoadLoginPageBrandingResponse,
LoadLoginPageResponse
} from "@server/routers/loginPage/types";
import { GetOrgTierResponse } from "@server/routers/billing/types";
import { TierId } from "@server/lib/billing/tiers";
import { CheckOrgUserAccessResponse } from "@server/routers/org";
import OrgPolicyRequired from "@app/components/OrgPolicyRequired";
import { isOrgSubscribed } from "@app/lib/api/isOrgSubscribed";
export const dynamic = "force-dynamic";
@@ -52,8 +56,7 @@ export default async function ResourceAuthPage(props: {
}
} catch (e) {}
const getUser = cache(verifySession);
const user = await getUser({ skipCheckVerifyEmail: true });
const user = await verifySession({ skipCheckVerifyEmail: true });
if (!authInfo) {
return (
@@ -63,22 +66,7 @@ export default async function ResourceAuthPage(props: {
);
}
let subscriptionStatus: GetOrgTierResponse | null = null;
if (build == "saas") {
try {
const getSubscription = cache(() =>
priv.get<AxiosResponse<GetOrgTierResponse>>(
`/org/${authInfo.orgId}/billing/tier`
)
);
const subRes = await getSubscription();
subscriptionStatus = subRes.data.data;
} catch {}
}
const subscribed =
build === "enterprise"
? true
: subscriptionStatus?.tier === TierId.STANDARD;
const subscribed = await isOrgSubscribed(authInfo.orgId);
const allHeaders = await headers();
const host = allHeaders.get("host");
@@ -89,9 +77,9 @@ export default async function ResourceAuthPage(props: {
redirect(env.app.dashboardUrl);
}
let loginPage: GetLoginPageResponse | undefined;
let loginPage: LoadLoginPageResponse | undefined;
try {
const res = await priv.get<AxiosResponse<GetLoginPageResponse>>(
const res = await priv.get<AxiosResponse<LoadLoginPageResponse>>(
`/login-page?resourceId=${authInfo.resourceId}&fullDomain=${host}`
);
@@ -106,6 +94,7 @@ export default async function ResourceAuthPage(props: {
}
let redirectUrl = authInfo.url;
if (searchParams.redirect) {
try {
const serverResourceHost = new URL(authInfo.url).host;
@@ -230,9 +219,7 @@ export default async function ResourceAuthPage(props: {
})) as LoginFormIDP[];
}
} else {
const idpsRes = await cache(
async () => await priv.get<AxiosResponse<ListIdpsResponse>>("/idp")
)();
const idpsRes = await priv.get<AxiosResponse<ListIdpsResponse>>("/idp");
loginIdps = idpsRes.data.data.idps.map((idp) => ({
idpId: idp.idpId,
name: idp.name,
@@ -253,12 +240,24 @@ export default async function ResourceAuthPage(props: {
resourceId={authInfo.resourceId}
skipToIdpId={authInfo.skipToIdpId}
redirectUrl={redirectUrl}
orgId={build == "saas" ? authInfo.orgId : undefined}
orgId={build === "saas" ? authInfo.orgId : undefined}
/>
);
}
}
let branding: LoadLoginPageBrandingResponse | null = null;
try {
if (subscribed) {
const res = await priv.get<
AxiosResponse<LoadLoginPageBrandingResponse>
>(`/login-page-branding?orgId=${authInfo.orgId}`);
if (res.status === 200) {
branding = res.data.data;
}
}
} catch (error) {}
return (
<>
{userIsUnauthorized && isSSOOnly ? (
@@ -281,6 +280,19 @@ export default async function ResourceAuthPage(props: {
redirect={redirectUrl}
idps={loginIdps}
orgId={build === "saas" ? authInfo.orgId : undefined}
branding={
!branding || build === "oss"
? undefined
: {
logoHeight: branding.logoHeight,
logoUrl: branding.logoUrl,
logoWidth: branding.logoWidth,
primaryColor: branding.primaryColor,
resourceTitle: branding.resourceTitle,
resourceSubtitle:
branding.resourceSubtitle
}
}
/>
</div>
)}

View File

@@ -4,161 +4,124 @@
@custom-variant dark (&:is(.dark *));
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--font-sans: var(--font-sans);
--font-mono: var(--font-geist-mono);
--color-sidebar-ring: var(--sidebar-ring);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar: var(--sidebar);
--color-chart-5: var(--chart-5);
--color-chart-4: var(--chart-4);
--color-chart-3: var(--chart-3);
--color-chart-2: var(--chart-2);
--color-chart-1: var(--chart-1);
--color-ring: var(--ring);
--color-input: var(--input);
--color-border: var(--border);
--color-destructive: var(--destructive);
--color-accent-foreground: var(--accent-foreground);
--color-accent: var(--accent);
--color-muted-foreground: var(--muted-foreground);
--color-muted: var(--muted);
--color-secondary-foreground: var(--secondary-foreground);
--color-secondary: var(--secondary);
--color-primary-foreground: var(--primary-foreground);
--color-primary: var(--primary);
--color-popover-foreground: var(--popover-foreground);
--color-popover: var(--popover);
--color-card-foreground: var(--card-foreground);
--color-card: var(--card);
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);
--radius-2xl: calc(var(--radius) + 8px);
--radius-3xl: calc(var(--radius) + 12px);
--radius-4xl: calc(var(--radius) + 16px);
}
:root {
--radius: 0.65rem;
--background: oklch(0.99 0 0);
--foreground: oklch(0.141 0.005 285.823);
--card: oklch(1 0 0);
--card-foreground: oklch(0.141 0.005 285.823);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.141 0.005 285.823);
--primary: oklch(0.6717 0.1946 41.93);
--primary-foreground: oklch(0.98 0.016 73.684);
--secondary: oklch(0.967 0.001 286.375);
--secondary-foreground: oklch(0.21 0.006 285.885);
--muted: oklch(0.967 0.001 286.375);
--muted-foreground: oklch(0.552 0.016 285.938);
--accent: oklch(0.967 0.001 286.375);
--accent-foreground: oklch(0.21 0.006 285.885);
--destructive: oklch(0.577 0.245 27.325);
--border: oklch(0.92 0.004 286.32);
--input: oklch(0.92 0.004 286.32);
--ring: oklch(0.705 0.213 47.604);
--chart-1: oklch(0.646 0.222 41.116);
--chart-2: oklch(0.6 0.118 184.704);
--chart-3: oklch(0.398 0.07 227.392);
--chart-4: oklch(0.828 0.189 84.429);
--chart-5: oklch(0.769 0.188 70.08);
--sidebar: oklch(0.985 0 0);
--sidebar-foreground: oklch(0.141 0.005 285.823);
--sidebar-primary: oklch(0.705 0.213 47.604);
--sidebar-primary-foreground: oklch(0.98 0.016 73.684);
--sidebar-accent: oklch(0.967 0.001 286.375);
--sidebar-accent-foreground: oklch(0.21 0.006 285.885);
--sidebar-border: oklch(0.92 0.004 286.32);
--sidebar-ring: oklch(0.705 0.213 47.604);
--background: oklch(0.99 0 0);
--foreground: oklch(0.145 0 0);
--card: oklch(1 0 0);
--card-foreground: oklch(0.145 0 0);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.145 0 0);
--primary: oklch(0.6734 0.195 41.36);
--primary-foreground: oklch(0.98 0.016 73.684);
--secondary: oklch(0.967 0.001 286.375);
--secondary-foreground: oklch(0.21 0.006 285.885);
--muted: oklch(0.97 0 0);
--muted-foreground: oklch(0.556 0 0);
--accent: oklch(0.97 0 0);
--accent-foreground: oklch(0.205 0 0);
--destructive: oklch(0.58 0.22 27);
--border: oklch(0.922 0 0);
--input: oklch(0.922 0 0);
--ring: oklch(0.6734 0.195 41.36);
--chart-1: oklch(0.837 0.128 66.29);
--chart-2: oklch(0.705 0.213 47.604);
--chart-3: oklch(0.646 0.222 41.116);
--chart-4: oklch(0.553 0.195 38.402);
--chart-5: oklch(0.47 0.157 37.304);
--radius: 0.75rem;
--sidebar: oklch(0.985 0 0);
--sidebar-foreground: oklch(0.145 0 0);
--sidebar-primary: oklch(0.646 0.222 41.116);
--sidebar-primary-foreground: oklch(0.98 0.016 73.684);
--sidebar-accent: oklch(0.97 0 0);
--sidebar-accent-foreground: oklch(0.205 0 0);
--sidebar-border: oklch(0.922 0 0);
--sidebar-ring: oklch(0.708 0 0);
}
.dark {
--background: oklch(0.2 0.006 285.885);
--foreground: oklch(0.985 0 0);
--card: oklch(0.21 0.006 285.885);
--card-foreground: oklch(0.985 0 0);
--popover: oklch(0.21 0.006 285.885);
--popover-foreground: oklch(0.985 0 0);
--primary: oklch(0.6717 0.1946 41.93);
--primary-foreground: oklch(0.98 0.016 73.684);
--secondary: oklch(0.274 0.006 286.033);
--secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.274 0.006 286.033);
--muted-foreground: oklch(0.705 0.015 286.067);
--accent: oklch(0.274 0.006 286.033);
--accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.5382 0.1949 22.216);
--border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%);
--ring: oklch(0.646 0.222 41.116);
--chart-1: oklch(0.488 0.243 264.376);
--chart-2: oklch(0.696 0.17 162.48);
--chart-3: oklch(0.769 0.188 70.08);
--chart-4: oklch(0.627 0.265 303.9);
--chart-5: oklch(0.645 0.246 16.439);
--sidebar: oklch(0.21 0.006 285.885);
--sidebar-foreground: oklch(0.985 0 0);
--sidebar-primary: oklch(0.646 0.222 41.116);
--sidebar-primary-foreground: oklch(0.98 0.016 73.684);
--sidebar-accent: oklch(0.274 0.006 286.033);
--sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.646 0.222 41.116);
}
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-card: var(--card);
--color-card-foreground: var(--card-foreground);
--color-popover: var(--popover);
--color-popover-foreground: var(--popover-foreground);
--color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground);
--color-secondary: var(--secondary);
--color-secondary-foreground: var(--secondary-foreground);
--color-muted: var(--muted);
--color-muted-foreground: var(--muted-foreground);
--color-accent: var(--accent);
--color-accent-foreground: var(--accent-foreground);
--color-destructive: var(--destructive);
--color-destructive-foreground: var(--destructive-foreground);
--color-border: var(--border);
--color-input: var(--input);
--color-ring: var(--ring);
--color-chart-1: var(--chart-1);
--color-chart-2: var(--chart-2);
--color-chart-3: var(--chart-3);
--color-chart-4: var(--chart-4);
--color-chart-5: var(--chart-5);
--radius-lg: var(--radius);
--radius-md: calc(var(--radius) - 2px);
--radius-sm: calc(var(--radius) - 4px);
--shadow-2xs: 0 1px 1px rgba(0, 0, 0, 0.03);
--inset-shadow-2xs: inset 0 1px 1px rgba(0, 0, 1, 0.03);
--background: oklch(0.160 0 0);
--foreground: oklch(0.985 0 0);
--card: oklch(0.205 0 0);
--card-foreground: oklch(0.985 0 0);
--popover: oklch(0.205 0 0);
--popover-foreground: oklch(0.985 0 0);
--primary: oklch(0.6734 0.195 41.36);
--primary-foreground: oklch(0.98 0.016 73.684);
--secondary: oklch(0.274 0.006 286.033);
--secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.269 0 0);
--muted-foreground: oklch(0.708 0 0);
--accent: oklch(0.371 0 0);
--accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.704 0.191 22.216);
--border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%);
--ring: oklch(0.6734 0.195 41.36);
--chart-1: oklch(0.837 0.128 66.29);
--chart-2: oklch(0.705 0.213 47.604);
--chart-3: oklch(0.646 0.222 41.116);
--chart-4: oklch(0.553 0.195 38.402);
--chart-5: oklch(0.47 0.157 37.304);
--sidebar: oklch(0.205 0 0);
--sidebar-foreground: oklch(0.985 0 0);
--sidebar-primary: oklch(0.705 0.213 47.604);
--sidebar-primary-foreground: oklch(0.98 0.016 73.684);
--sidebar-accent: oklch(0.269 0 0);
--sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.556 0 0);
}
@layer base {
*,
::after,
::before,
::backdrop,
::file-selector-button {
border-color: var(--color-gray-200, currentcolor);
}
* {
@apply border-border outline-ring/50;
}
body {
@apply bg-background text-foreground;
}
}
@layer base {
* {
@apply border-border;
}
body {
@apply bg-background text-foreground;
}
}
@layer base {
:root {
--chart-1: oklch(0.646 0.222 41.116);
--chart-2: oklch(0.6 0.118 184.704);
--chart-3: oklch(0.398 0.07 227.392);
--chart-4: oklch(0.828 0.189 84.429);
--chart-5: oklch(0.769 0.188 70.08);
}
.dark {
--chart-1: oklch(0.488 0.243 264.376);
--chart-2: oklch(0.696 0.17 162.48);
--chart-3: oklch(0.769 0.188 70.08);
--chart-4: oklch(0.627 0.265 303.9);
--chart-5: oklch(0.645 0.246 16.439);
}
}
p {
word-break: keep-all;
white-space: normal;
}
#nprogress .bar {
background: var(--color-primary) !important;
}

View File

@@ -1,6 +1,6 @@
import type { Metadata } from "next";
import "./globals.css";
import { Inter } from "next/font/google";
import { Geist, Inter, Manrope, Open_Sans } from "next/font/google";
import { ThemeProvider } from "@app/providers/ThemeProvider";
import EnvProvider from "@app/providers/EnvProvider";
import { pullEnv } from "@app/lib/pullEnv";
@@ -30,7 +30,9 @@ export const metadata: Metadata = {
export const dynamic = "force-dynamic";
const font = Inter({ subsets: ["latin"] });
const font = Inter({
subsets: ["latin"]
});
export default async function RootLayout({
children

View File

@@ -269,7 +269,7 @@ export default function UsersTable({ users }: Props) {
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
{r.type !== "internal" && (
{r.type === "internal" && (
<DropdownMenuItem
onClick={() => {
generatePasswordResetCode(r.id);

View File

@@ -0,0 +1,432 @@
"use client";
import { zodResolver } from "@hookform/resolvers/zod";
import { useActionState, useState } from "react";
import { useForm } from "react-hook-form";
import z from "zod";
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage
} from "@app/components/ui/form";
import {
SettingsSection,
SettingsSectionBody,
SettingsSectionDescription,
SettingsSectionForm,
SettingsSectionHeader,
SettingsSectionTitle
} from "./Settings";
import { useTranslations } from "next-intl";
import type { GetLoginPageBrandingResponse } from "@server/routers/loginPage/types";
import { Input } from "./ui/input";
import { ExternalLink, InfoIcon, XIcon } from "lucide-react";
import { Button } from "./ui/button";
import { createApiClient, formatAxiosError } from "@app/lib/api";
import { useEnvContext } from "@app/hooks/useEnvContext";
import { useRouter } from "next/navigation";
import { toast } from "@app/hooks/useToast";
import { usePaidStatus } from "@app/hooks/usePaidStatus";
import { build } from "@server/build";
import { PaidFeaturesAlert } from "./PaidFeaturesAlert";
import { Alert, AlertDescription, AlertTitle } from "./ui/alert";
export type AuthPageCustomizationProps = {
orgId: string;
branding: GetLoginPageBrandingResponse | null;
};
const AuthPageFormSchema = z.object({
logoUrl: z.url().refine(
async (url) => {
try {
const response = await fetch(url);
return (
response.status === 200 &&
(response.headers.get("content-type") ?? "").startsWith(
"image/"
)
);
} catch (error) {
return false;
}
},
{
error: "Invalid logo URL, must be a valid image URL"
}
),
logoWidth: z.coerce.number<number>().min(1),
logoHeight: z.coerce.number<number>().min(1),
orgTitle: z.string().optional(),
orgSubtitle: z.string().optional(),
resourceTitle: z.string(),
resourceSubtitle: z.string().optional(),
primaryColor: z
.string()
.regex(/^#([0-9a-f]{6}|[0-9a-f]{3})$/i)
.optional()
});
export default function AuthPageBrandingForm({
orgId,
branding
}: AuthPageCustomizationProps) {
const env = useEnvContext();
const api = createApiClient(env);
const { isPaidUser } = usePaidStatus();
const router = useRouter();
const [, updateFormAction, isUpdatingBranding] = useActionState(
updateBranding,
null
);
const [, deleteFormAction, isDeletingBranding] = useActionState(
deleteBranding,
null
);
const [setIsDeleteModalOpen] = useState(false);
const t = useTranslations();
const form = useForm({
resolver: zodResolver(AuthPageFormSchema),
defaultValues: {
logoUrl: branding?.logoUrl ?? "",
logoWidth: branding?.logoWidth ?? 100,
logoHeight: branding?.logoHeight ?? 100,
orgTitle: branding?.orgTitle ?? `Log in to {{orgName}}`,
orgSubtitle: branding?.orgSubtitle ?? `Log in to {{orgName}}`,
resourceTitle:
branding?.resourceTitle ??
`Authenticate to access {{resourceName}}`,
resourceSubtitle:
branding?.resourceSubtitle ??
`Choose your preferred authentication method for {{resourceName}}`,
primaryColor: branding?.primaryColor ?? `#f36117` // default pangolin primary color
},
disabled: !isPaidUser
});
async function updateBranding() {
const isValid = await form.trigger();
const brandingData = form.getValues();
if (!isValid || !isPaidUser) return;
try {
const updateRes = await api.put(
`/org/${orgId}/login-page-branding`,
{
...brandingData
}
);
if (updateRes.status === 200 || updateRes.status === 201) {
router.refresh();
toast({
variant: "default",
title: t("success"),
description: t("authPageBrandingUpdated")
});
}
} catch (error) {
toast({
variant: "destructive",
title: t("authPageErrorUpdate"),
description: formatAxiosError(
error,
t("authPageErrorUpdateMessage")
)
});
}
}
async function deleteBranding() {
if (!isPaidUser) return;
try {
const updateRes = await api.delete(
`/org/${orgId}/login-page-branding`
);
if (updateRes.status === 200) {
router.refresh();
form.reset();
toast({
variant: "default",
title: t("success"),
description: t("authPageBrandingRemoved")
});
}
} catch (error) {
toast({
variant: "destructive",
title: t("authPageErrorUpdate"),
description: formatAxiosError(
error,
t("authPageErrorUpdateMessage")
)
});
}
}
return (
<>
<SettingsSection>
<SettingsSectionHeader>
<SettingsSectionTitle>
{t("authPageBranding")}
</SettingsSectionTitle>
<SettingsSectionDescription>
{t("authPageBrandingDescription")}
</SettingsSectionDescription>
</SettingsSectionHeader>
<SettingsSectionBody>
<SettingsSectionForm>
<PaidFeaturesAlert />
<Form {...form}>
<form
action={updateFormAction}
id="auth-page-branding-form"
className="flex flex-col space-y-4 items-stretch"
>
<FormField
control={form.control}
name="primaryColor"
render={({ field }) => (
<FormItem className="">
<FormLabel>
{t("brandingPrimaryColor")}
</FormLabel>
<div className="flex items-center gap-2">
<label
className="size-8 rounded-sm"
aria-hidden="true"
style={{
backgroundColor:
field.value
}}
>
<input
type="color"
{...field}
className="sr-only"
/>
</label>
<FormControl>
<Input {...field} />
</FormControl>
</div>
<FormMessage />
</FormItem>
)}
/>
<div className="grid md:grid-cols-5 gap-3 items-start">
<FormField
control={form.control}
name="logoUrl"
render={({ field }) => (
<FormItem className="md:col-span-3">
<FormLabel>
{t("brandingLogoURL")}
</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<div className="md:col-span-2 flex gap-3 items-start">
<FormField
control={form.control}
name="logoWidth"
render={({ field }) => (
<FormItem className="grow">
<FormLabel>
{t("brandingLogoWidth")}
</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<span className="relative top-8">
<XIcon className="text-muted-foreground size-4" />
</span>
<FormField
control={form.control}
name="logoHeight"
render={({ field }) => (
<FormItem className="grow">
<FormLabel>
{t(
"brandingLogoHeight"
)}
</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
</div>
{build === "saas" && (
<>
<div className="mt-3 mb-6">
<SettingsSectionTitle>
{t(
"organizationLoginPageTitle"
)}
</SettingsSectionTitle>
<SettingsSectionDescription>
{t(
"organizationLoginPageDescription"
)}
</SettingsSectionDescription>
</div>
<div className="flex flex-col gap-5">
<FormField
control={form.control}
name="orgTitle"
render={({ field }) => (
<FormItem className="md:col-span-3">
<FormLabel>
{t(
"brandingOrgTitle"
)}
</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="orgSubtitle"
render={({ field }) => (
<FormItem className="md:col-span-3">
<FormLabel>
{t(
"brandingOrgSubtitle"
)}
</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
</>
)}
<div className="mt-3 mb-6">
<SettingsSectionTitle>
{t("resourceLoginPageTitle")}
</SettingsSectionTitle>
<SettingsSectionDescription>
{t("resourceLoginPageDescription")}
</SettingsSectionDescription>
</div>
<div className="flex flex-col gap-5">
<FormField
control={form.control}
name="resourceTitle"
render={({ field }) => (
<FormItem className="md:col-span-3">
<FormLabel>
{t("brandingResourceTitle")}
</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="resourceSubtitle"
render={({ field }) => (
<FormItem className="md:col-span-3">
<FormLabel>
{t(
"brandingResourceSubtitle"
)}
</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
</form>
</Form>
</SettingsSectionForm>
</SettingsSectionBody>
<div className="flex justify-end gap-2 mt-6 items-center">
{branding && (
<Button
variant="destructive"
type="button"
loading={isUpdatingBranding || isDeletingBranding}
disabled={
isUpdatingBranding ||
isDeletingBranding ||
!isPaidUser
}
onClick={() => {
deleteFormAction();
}}
className="gap-1"
>
{t("removeAuthPageBranding")}
</Button>
)}
<Button
type="submit"
form="auth-page-branding-form"
loading={isUpdatingBranding || isDeletingBranding}
disabled={
isUpdatingBranding ||
isDeletingBranding ||
!isPaidUser
}
>
{t("saveAuthPageBranding")}
</Button>
</div>
</SettingsSection>
</>
);
}

View File

@@ -7,6 +7,7 @@ import Image from "next/image";
import { useEffect, useState } from "react";
type BrandingLogoProps = {
logoPath?: string;
width: number;
height: number;
};
@@ -41,13 +42,16 @@ export default function BrandingLogo(props: BrandingLogoProps) {
return "/logo/word_mark_white.png";
}
const path = getPath();
setPath(path);
}, [theme, env]);
setPath(props.logoPath ?? getPath());
}, [theme, env, props.logoPath]);
// we use `img` tag here because the `logoPath` could be any URL
// and next.js `Image` component only accepts a restricted number of domains
const Component = props.logoPath ? "img" : Image;
return (
path && (
<Image
<Component
src={path}
alt="Logo"
width={props.width}

View File

@@ -41,6 +41,9 @@ export type InternalResourceRow = {
// destinationPort: number | null;
alias: string | null;
niceId: string;
tcpPortRangeString: string | null;
udpPortRangeString: string | null;
disableIcmp: boolean;
};
type ClientResourcesTableProps = {

View File

@@ -6,43 +6,22 @@ import {
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage
} from "@app/components/ui/form";
import { Input } from "@app/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue
} from "@app/components/ui/select";
import { useToast } from "@app/hooks/useToast";
import { zodResolver } from "@hookform/resolvers/zod";
import {
InviteUserBody,
InviteUserResponse,
ListUsersResponse
} from "@server/routers/user";
import { AxiosResponse } from "axios";
import React, { useState } from "react";
import React, { useActionState } from "react";
import { useForm } 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 { useOrgContext } from "@app/hooks/useOrgContext";
import { Description } from "@radix-ui/react-toast";
import { createApiClient } from "@app/lib/api";
import { useEnvContext } from "@app/hooks/useEnvContext";
import { useTranslations } from "next-intl";
import CopyToClipboard from "./CopyToClipboard";
@@ -57,7 +36,7 @@ type InviteUserFormProps = {
warningText?: string;
};
export default function InviteUserForm({
export default function ConfirmDeleteDialog({
open,
setOpen,
string,
@@ -67,9 +46,7 @@ export default function InviteUserForm({
dialog,
warningText
}: InviteUserFormProps) {
const [loading, setLoading] = useState(false);
const api = createApiClient(useEnvContext());
const [, formAction, loading] = useActionState(onSubmit, null);
const t = useTranslations();
@@ -86,21 +63,14 @@ export default function InviteUserForm({
}
});
function reset() {
form.reset();
}
async function onSubmit(values: z.infer<typeof formSchema>) {
setLoading(true);
async function onSubmit() {
try {
await onConfirm();
setOpen(false);
reset();
form.reset();
} catch (error) {
// Handle error if needed
console.error("Confirmation failed:", error);
} finally {
setLoading(false);
}
}
@@ -110,7 +80,7 @@ export default function InviteUserForm({
open={open}
onOpenChange={(val) => {
setOpen(val);
reset();
form.reset();
}}
>
<CredenzaContent>
@@ -136,7 +106,7 @@ export default function InviteUserForm({
</div>
<Form {...form}>
<form
onSubmit={form.handleSubmit(onSubmit)}
action={formAction}
className="space-y-4"
id="confirm-delete-form"
>
@@ -146,7 +116,12 @@ export default function InviteUserForm({
render={({ field }) => (
<FormItem>
<FormControl>
<Input {...field} />
<Input
{...field}
placeholder={t(
"enterConfirmation"
)}
/>
</FormControl>
<FormMessage />
</FormItem>

View File

@@ -42,15 +42,14 @@ import {
SelectTrigger,
SelectValue
} from "@app/components/ui/select";
import { Switch } from "@app/components/ui/switch";
import { useEnvContext } from "@app/hooks/useEnvContext";
import { toast } from "@app/hooks/useToast";
import { createApiClient, formatAxiosError } from "@app/lib/api";
import { cn } from "@app/lib/cn";
import { orgQueries } from "@app/lib/queries";
import { zodResolver } from "@hookform/resolvers/zod";
import { ListClientsResponse } from "@server/routers/client/listClients";
import { ListSitesResponse } from "@server/routers/site";
import { ListUsersResponse } from "@server/routers/user";
import { UserType } from "@server/types/UserTypes";
import { useQuery } from "@tanstack/react-query";
import { AxiosResponse } from "axios";
@@ -59,6 +58,82 @@ import { useTranslations } from "next-intl";
import { useEffect, useState } from "react";
import { useForm } from "react-hook-form";
import { z } from "zod";
// import { InfoPopup } from "@app/components/ui/info-popup";
// Helper to validate port range string format
const isValidPortRangeString = (val: string | undefined | null): boolean => {
if (!val || val.trim() === "" || val.trim() === "*") {
return true;
}
const parts = val.split(",").map((p) => p.trim());
for (const part of parts) {
if (part === "") {
return false;
}
if (part.includes("-")) {
const [start, end] = part.split("-").map((p) => p.trim());
if (!start || !end) {
return false;
}
const startPort = parseInt(start, 10);
const endPort = parseInt(end, 10);
if (isNaN(startPort) || isNaN(endPort)) {
return false;
}
if (startPort < 1 || startPort > 65535 || endPort < 1 || endPort > 65535) {
return false;
}
if (startPort > endPort) {
return false;
}
} else {
const port = parseInt(part, 10);
if (isNaN(port)) {
return false;
}
if (port < 1 || port > 65535) {
return false;
}
}
}
return true;
};
// Port range string schema for client-side validation
const portRangeStringSchema = z
.string()
.optional()
.nullable()
.refine(
(val) => isValidPortRangeString(val),
{
message:
'Port range must be "*" for all ports, or a comma-separated list of ports and ranges (e.g., "80,443,8000-9000"). Ports must be between 1 and 65535.'
}
);
// Helper to determine the port mode from a port range string
type PortMode = "all" | "blocked" | "custom";
const getPortModeFromString = (val: string | undefined | null): PortMode => {
if (val === "*") return "all";
if (!val || val.trim() === "") return "blocked";
return "custom";
};
// Helper to get the port string for API from mode and custom value
const getPortStringFromMode = (mode: PortMode, customValue: string): string | undefined => {
if (mode === "all") return "*";
if (mode === "blocked") return "";
return customValue;
};
type Site = ListSitesResponse["sites"][0];
@@ -103,6 +178,9 @@ export default function CreateInternalResourceDialog({
// .max(65535, t("createInternalResourceDialogDestinationPortMax"))
// .nullish(),
alias: z.string().nullish(),
tcpPortRangeString: portRangeStringSchema,
udpPortRangeString: portRangeStringSchema,
disableIcmp: z.boolean().optional(),
roles: z
.array(
z.object({
@@ -209,6 +287,12 @@ export default function CreateInternalResourceDialog({
number | null
>(null);
// Port restriction UI state - default to "all" (*) for new resources
const [tcpPortMode, setTcpPortMode] = useState<PortMode>("all");
const [udpPortMode, setUdpPortMode] = useState<PortMode>("all");
const [tcpCustomPorts, setTcpCustomPorts] = useState<string>("");
const [udpCustomPorts, setUdpCustomPorts] = useState<string>("");
const availableSites = sites.filter(
(site) => site.type === "newt" && site.subnet
);
@@ -224,6 +308,9 @@ export default function CreateInternalResourceDialog({
destination: "",
// destinationPort: undefined,
alias: "",
tcpPortRangeString: "*",
udpPortRangeString: "*",
disableIcmp: false,
roles: [],
users: [],
clients: []
@@ -232,6 +319,17 @@ export default function CreateInternalResourceDialog({
const mode = form.watch("mode");
// Update form values when port mode or custom ports change
useEffect(() => {
const tcpValue = getPortStringFromMode(tcpPortMode, tcpCustomPorts);
form.setValue("tcpPortRangeString", tcpValue);
}, [tcpPortMode, tcpCustomPorts, form]);
useEffect(() => {
const udpValue = getPortStringFromMode(udpPortMode, udpCustomPorts);
form.setValue("udpPortRangeString", udpValue);
}, [udpPortMode, udpCustomPorts, form]);
// Helper function to check if destination contains letters (hostname vs IP)
const isHostname = (destination: string): boolean => {
return /[a-zA-Z]/.test(destination);
@@ -258,10 +356,18 @@ export default function CreateInternalResourceDialog({
destination: "",
// destinationPort: undefined,
alias: "",
tcpPortRangeString: "*",
udpPortRangeString: "*",
disableIcmp: false,
roles: [],
users: [],
clients: []
});
// Reset port mode state
setTcpPortMode("all");
setUdpPortMode("all");
setTcpCustomPorts("");
setUdpCustomPorts("");
}
}, [open]);
@@ -304,6 +410,9 @@ export default function CreateInternalResourceDialog({
data.alias.trim()
? data.alias
: undefined,
tcpPortRangeString: data.tcpPortRangeString,
udpPortRangeString: data.udpPortRangeString,
disableIcmp: data.disableIcmp ?? false,
roleIds: data.roles
? data.roles.map((r) => parseInt(r.id))
: [],
@@ -727,6 +836,163 @@ export default function CreateInternalResourceDialog({
</div>
)}
{/* Port Restrictions Section */}
<div>
<h3 className="text-lg font-semibold mb-4">
{t("portRestrictions")}
</h3>
<div className="space-y-4">
{/* TCP Ports */}
<FormField
control={form.control}
name="tcpPortRangeString"
render={({ field }) => (
<FormItem>
<div className="flex items-center gap-2">
<FormLabel className="min-w-10">
TCP
</FormLabel>
{/*<InfoPopup
info={t("tcpPortsDescription")}
/>*/}
<Select
value={tcpPortMode}
onValueChange={(value: PortMode) => {
setTcpPortMode(value);
}}
>
<SelectTrigger className="w-[110px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">
{t("allPorts")}
</SelectItem>
<SelectItem value="blocked">
{t("blocked")}
</SelectItem>
<SelectItem value="custom">
{t("custom")}
</SelectItem>
</SelectContent>
</Select>
{tcpPortMode === "custom" ? (
<FormControl>
<Input
placeholder="80,443,8000-9000"
value={tcpCustomPorts}
onChange={(e) =>
setTcpCustomPorts(e.target.value)
}
className="flex-1"
/>
</FormControl>
) : (
<Input
disabled
placeholder={
tcpPortMode === "all"
? t("allPortsAllowed")
: t("allPortsBlocked")
}
className="flex-1"
/>
)}
</div>
<FormMessage />
</FormItem>
)}
/>
{/* UDP Ports */}
<FormField
control={form.control}
name="udpPortRangeString"
render={({ field }) => (
<FormItem>
<div className="flex items-center gap-2">
<FormLabel className="min-w-10">
UDP
</FormLabel>
{/*<InfoPopup
info={t("udpPortsDescription")}
/>*/}
<Select
value={udpPortMode}
onValueChange={(value: PortMode) => {
setUdpPortMode(value);
}}
>
<SelectTrigger className="w-[110px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">
{t("allPorts")}
</SelectItem>
<SelectItem value="blocked">
{t("blocked")}
</SelectItem>
<SelectItem value="custom">
{t("custom")}
</SelectItem>
</SelectContent>
</Select>
{udpPortMode === "custom" ? (
<FormControl>
<Input
placeholder="53,123,500-600"
value={udpCustomPorts}
onChange={(e) =>
setUdpCustomPorts(e.target.value)
}
className="flex-1"
/>
</FormControl>
) : (
<Input
disabled
placeholder={
udpPortMode === "all"
? t("allPortsAllowed")
: t("allPortsBlocked")
}
className="flex-1"
/>
)}
</div>
<FormMessage />
</FormItem>
)}
/>
{/* ICMP Toggle */}
<FormField
control={form.control}
name="disableIcmp"
render={({ field }) => (
<FormItem>
<div className="flex items-center gap-2">
<FormLabel className="min-w-10">
ICMP
</FormLabel>
<FormControl>
<Switch
checked={!field.value}
onCheckedChange={(checked) => field.onChange(!checked)}
/>
</FormControl>
<span className="text-sm text-muted-foreground">
{field.value ? t("blocked") : t("allowed")}
</span>
</div>
<FormMessage />
</FormItem>
)}
/>
</div>
</div>
{/* Access Control Section */}
<div>
<h3 className="text-lg font-semibold mb-4">

View File

@@ -179,7 +179,7 @@ const CredenzaFooter = ({ className, children, ...props }: CredenzaProps) => {
return (
<CredenzaFooter
className={cn(
"mt-8 md:mt-0 -mx-6 px-6 pt-6 border-t border-border",
"mt-8 md:mt-0 -mx-6 px-6 pt-4 border-t border-border",
className
)}
{...props}

View File

@@ -94,6 +94,12 @@ export default function DomainPicker({
const api = createApiClient({ env });
const t = useTranslations();
console.log({
defaultFullDomain,
defaultSubdomain,
defaultDomainId
});
const { data = [], isLoading: loadingDomains } = useQuery(
orgQueries.domains({ orgId })
);

View File

@@ -1,6 +1,6 @@
"use client";
import { useEffect, useMemo, useRef, useState } from "react";
import { useEffect, useRef, useState } from "react";
import { Button } from "@app/components/ui/button";
import { Input } from "@app/components/ui/input";
import {
@@ -10,6 +10,7 @@ import {
SelectTrigger,
SelectValue
} from "@app/components/ui/select";
import { Switch } from "@app/components/ui/switch";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
@@ -36,17 +37,86 @@ import { toast } from "@app/hooks/useToast";
import { useTranslations } from "next-intl";
import { createApiClient, formatAxiosError } from "@app/lib/api";
import { useEnvContext } from "@app/hooks/useEnvContext";
import { ListRolesResponse } from "@server/routers/role";
import { ListUsersResponse } from "@server/routers/user";
import { ListSiteResourceRolesResponse } from "@server/routers/siteResource/listSiteResourceRoles";
import { ListSiteResourceUsersResponse } from "@server/routers/siteResource/listSiteResourceUsers";
import { ListSiteResourceClientsResponse } from "@server/routers/siteResource/listSiteResourceClients";
import { ListClientsResponse } from "@server/routers/client/listClients";
import { Tag, TagInput } from "@app/components/tags/tag-input";
import { AxiosResponse } from "axios";
import { UserType } from "@server/types/UserTypes";
import { useQueries, useQuery, useQueryClient } from "@tanstack/react-query";
import { orgQueries, resourceQueries } from "@app/lib/queries";
// import { InfoPopup } from "@app/components/ui/info-popup";
// Helper to validate port range string format
const isValidPortRangeString = (val: string | undefined | null): boolean => {
if (!val || val.trim() === "" || val.trim() === "*") {
return true;
}
const parts = val.split(",").map((p) => p.trim());
for (const part of parts) {
if (part === "") {
return false;
}
if (part.includes("-")) {
const [start, end] = part.split("-").map((p) => p.trim());
if (!start || !end) {
return false;
}
const startPort = parseInt(start, 10);
const endPort = parseInt(end, 10);
if (isNaN(startPort) || isNaN(endPort)) {
return false;
}
if (startPort < 1 || startPort > 65535 || endPort < 1 || endPort > 65535) {
return false;
}
if (startPort > endPort) {
return false;
}
} else {
const port = parseInt(part, 10);
if (isNaN(port)) {
return false;
}
if (port < 1 || port > 65535) {
return false;
}
}
}
return true;
};
// Port range string schema for client-side validation
const portRangeStringSchema = z
.string()
.optional()
.nullable()
.refine(
(val) => isValidPortRangeString(val),
{
message:
'Port range must be "*" for all ports, or a comma-separated list of ports and ranges (e.g., "80,443,8000-9000"). Ports must be between 1 and 65535.'
}
);
// Helper to determine the port mode from a port range string
type PortMode = "all" | "blocked" | "custom";
const getPortModeFromString = (val: string | undefined | null): PortMode => {
if (val === "*") return "all";
if (!val || val.trim() === "") return "blocked";
return "custom";
};
// Helper to get the port string for API from mode and custom value
const getPortStringFromMode = (mode: PortMode, customValue: string): string | undefined => {
if (mode === "all") return "*";
if (mode === "blocked") return "";
return customValue;
};
type InternalResourceData = {
id: number;
@@ -61,6 +131,9 @@ type InternalResourceData = {
destination: string;
// destinationPort?: number | null;
alias?: string | null;
tcpPortRangeString?: string | null;
udpPortRangeString?: string | null;
disableIcmp?: boolean;
};
type EditInternalResourceDialogProps = {
@@ -94,6 +167,9 @@ export default function EditInternalResourceDialog({
destination: z.string().min(1),
// destinationPort: z.int().positive().min(1, t("editInternalResourceDialogDestinationPortMin")).max(65535, t("editInternalResourceDialogDestinationPortMax")).nullish(),
alias: z.string().nullish(),
tcpPortRangeString: portRangeStringSchema,
udpPortRangeString: portRangeStringSchema,
disableIcmp: z.boolean().optional(),
roles: z
.array(
z.object({
@@ -255,6 +331,24 @@ export default function EditInternalResourceDialog({
number | null
>(null);
// Port restriction UI state
const [tcpPortMode, setTcpPortMode] = useState<PortMode>(
getPortModeFromString(resource.tcpPortRangeString)
);
const [udpPortMode, setUdpPortMode] = useState<PortMode>(
getPortModeFromString(resource.udpPortRangeString)
);
const [tcpCustomPorts, setTcpCustomPorts] = useState<string>(
resource.tcpPortRangeString && resource.tcpPortRangeString !== "*"
? resource.tcpPortRangeString
: ""
);
const [udpCustomPorts, setUdpCustomPorts] = useState<string>(
resource.udpPortRangeString && resource.udpPortRangeString !== "*"
? resource.udpPortRangeString
: ""
);
const form = useForm<FormData>({
resolver: zodResolver(formSchema),
defaultValues: {
@@ -265,6 +359,9 @@ export default function EditInternalResourceDialog({
destination: resource.destination || "",
// destinationPort: resource.destinationPort ?? undefined,
alias: resource.alias ?? null,
tcpPortRangeString: resource.tcpPortRangeString ?? "*",
udpPortRangeString: resource.udpPortRangeString ?? "*",
disableIcmp: resource.disableIcmp ?? false,
roles: [],
users: [],
clients: []
@@ -273,6 +370,17 @@ export default function EditInternalResourceDialog({
const mode = form.watch("mode");
// Update form values when port mode or custom ports change
useEffect(() => {
const tcpValue = getPortStringFromMode(tcpPortMode, tcpCustomPorts);
form.setValue("tcpPortRangeString", tcpValue);
}, [tcpPortMode, tcpCustomPorts, form]);
useEffect(() => {
const udpValue = getPortStringFromMode(udpPortMode, udpCustomPorts);
form.setValue("udpPortRangeString", udpValue);
}, [udpPortMode, udpCustomPorts, form]);
// Helper function to check if destination contains letters (hostname vs IP)
const isHostname = (destination: string): boolean => {
return /[a-zA-Z]/.test(destination);
@@ -327,6 +435,9 @@ export default function EditInternalResourceDialog({
data.alias.trim()
? data.alias
: null,
tcpPortRangeString: data.tcpPortRangeString,
udpPortRangeString: data.udpPortRangeString,
disableIcmp: data.disableIcmp ?? false,
roleIds: (data.roles || []).map((r) => parseInt(r.id)),
userIds: (data.users || []).map((u) => u.id),
clientIds: (data.clients || []).map((c) => parseInt(c.id))
@@ -396,10 +507,26 @@ export default function EditInternalResourceDialog({
mode: resource.mode || "host",
destination: resource.destination || "",
alias: resource.alias ?? null,
tcpPortRangeString: resource.tcpPortRangeString ?? "*",
udpPortRangeString: resource.udpPortRangeString ?? "*",
disableIcmp: resource.disableIcmp ?? false,
roles: [],
users: [],
clients: []
});
// Reset port mode state
setTcpPortMode(getPortModeFromString(resource.tcpPortRangeString));
setUdpPortMode(getPortModeFromString(resource.udpPortRangeString));
setTcpCustomPorts(
resource.tcpPortRangeString && resource.tcpPortRangeString !== "*"
? resource.tcpPortRangeString
: ""
);
setUdpCustomPorts(
resource.udpPortRangeString && resource.udpPortRangeString !== "*"
? resource.udpPortRangeString
: ""
);
previousResourceId.current = resource.id;
}
@@ -438,10 +565,26 @@ export default function EditInternalResourceDialog({
destination: resource.destination || "",
// destinationPort: resource.destinationPort ?? undefined,
alias: resource.alias ?? null,
tcpPortRangeString: resource.tcpPortRangeString ?? "*",
udpPortRangeString: resource.udpPortRangeString ?? "*",
disableIcmp: resource.disableIcmp ?? false,
roles: [],
users: [],
clients: []
});
// Reset port mode state
setTcpPortMode(getPortModeFromString(resource.tcpPortRangeString));
setUdpPortMode(getPortModeFromString(resource.udpPortRangeString));
setTcpCustomPorts(
resource.tcpPortRangeString && resource.tcpPortRangeString !== "*"
? resource.tcpPortRangeString
: ""
);
setUdpCustomPorts(
resource.udpPortRangeString && resource.udpPortRangeString !== "*"
? resource.udpPortRangeString
: ""
);
// Reset previous resource ID to ensure clean state on next open
previousResourceId.current = null;
}
@@ -674,6 +817,163 @@ export default function EditInternalResourceDialog({
</div>
)}
{/* Port Restrictions Section */}
<div>
<h3 className="text-lg font-semibold mb-4">
{t("portRestrictions")}
</h3>
<div className="space-y-4">
{/* TCP Ports */}
<FormField
control={form.control}
name="tcpPortRangeString"
render={({ field }) => (
<FormItem>
<div className="flex items-center gap-2">
<FormLabel className="min-w-10">
TCP
</FormLabel>
{/*<InfoPopup
info={t("tcpPortsDescription")}
/>*/}
<Select
value={tcpPortMode}
onValueChange={(value: PortMode) => {
setTcpPortMode(value);
}}
>
<SelectTrigger className="w-[110px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">
{t("allPorts")}
</SelectItem>
<SelectItem value="blocked">
{t("blocked")}
</SelectItem>
<SelectItem value="custom">
{t("custom")}
</SelectItem>
</SelectContent>
</Select>
{tcpPortMode === "custom" ? (
<FormControl>
<Input
placeholder="80,443,8000-9000"
value={tcpCustomPorts}
onChange={(e) =>
setTcpCustomPorts(e.target.value)
}
className="flex-1"
/>
</FormControl>
) : (
<Input
disabled
placeholder={
tcpPortMode === "all"
? t("allPortsAllowed")
: t("allPortsBlocked")
}
className="flex-1"
/>
)}
</div>
<FormMessage />
</FormItem>
)}
/>
{/* UDP Ports */}
<FormField
control={form.control}
name="udpPortRangeString"
render={({ field }) => (
<FormItem>
<div className="flex items-center gap-2">
<FormLabel className="min-w-10">
UDP
</FormLabel>
{/*<InfoPopup
info={t("udpPortsDescription")}
/>*/}
<Select
value={udpPortMode}
onValueChange={(value: PortMode) => {
setUdpPortMode(value);
}}
>
<SelectTrigger className="w-[110px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">
{t("allPorts")}
</SelectItem>
<SelectItem value="blocked">
{t("blocked")}
</SelectItem>
<SelectItem value="custom">
{t("custom")}
</SelectItem>
</SelectContent>
</Select>
{udpPortMode === "custom" ? (
<FormControl>
<Input
placeholder="53,123,500-600"
value={udpCustomPorts}
onChange={(e) =>
setUdpCustomPorts(e.target.value)
}
className="flex-1"
/>
</FormControl>
) : (
<Input
disabled
placeholder={
udpPortMode === "all"
? t("allPortsAllowed")
: t("allPortsBlocked")
}
className="flex-1"
/>
)}
</div>
<FormMessage />
</FormItem>
)}
/>
{/* ICMP Toggle */}
<FormField
control={form.control}
name="disableIcmp"
render={({ field }) => (
<FormItem>
<div className="flex items-center gap-2">
<FormLabel className="min-w-10">
ICMP
</FormLabel>
<FormControl>
<Switch
checked={!field.value}
onCheckedChange={(checked) => field.onChange(!checked)}
/>
</FormControl>
<span className="text-sm text-muted-foreground">
{field.value ? t("blocked") : t("allowed")}
</span>
</div>
<FormMessage />
</FormItem>
)}
/>
</div>
</div>
{/* Access Control Section */}
<div>
<h3 className="text-lg font-semibold mb-4">

View File

@@ -19,7 +19,7 @@ export default function ExitNodeInfoCard({}: ExitNodeInfoCardProps) {
return (
<Alert>
<AlertDescription className="mt-4">
<AlertDescription>
<InfoSections cols={2}>
<>
<InfoSection>

View File

@@ -10,6 +10,8 @@ interface DataTableProps<TData, TValue> {
createRemoteExitNode?: () => void;
onRefresh?: () => void;
isRefreshing?: boolean;
columnVisibility?: Record<string, boolean>;
enableColumnVisibility?: boolean;
}
export function ExitNodesDataTable<TData, TValue>({
@@ -17,7 +19,9 @@ export function ExitNodesDataTable<TData, TValue>({
data,
createRemoteExitNode,
onRefresh,
isRefreshing
isRefreshing,
columnVisibility,
enableColumnVisibility
}: DataTableProps<TData, TValue>) {
const t = useTranslations();
@@ -36,6 +40,10 @@ export function ExitNodesDataTable<TData, TValue>({
id: "name",
desc: false
}}
columnVisibility={columnVisibility}
enableColumnVisibility={enableColumnVisibility}
stickyLeftColumn="name"
stickyRightColumn="actions"
/>
);
}

View File

@@ -2,7 +2,7 @@
import { ColumnDef } from "@tanstack/react-table";
import { ExtendedColumnDef } from "@app/components/ui/data-table";
import { ExitNodesDataTable } from "./ExitNodesDataTable";
import { ExitNodesDataTable } from "@app/components/ExitNodesDataTable";
import {
DropdownMenu,
DropdownMenuContent,
@@ -246,12 +246,13 @@ export default function ExitNodesTable({
},
{
id: "actions",
header: () => <span className="p-3">{t("actions")}</span>,
enableHiding: false,
header: () => <span className="p-3"></span>,
cell: ({ row }) => {
const nodeRow = row.original;
const remoteExitNodeId = nodeRow.id;
return (
<div className="flex items-center gap-2">
<div className="flex items-center gap-2 justify-end">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" className="h-8 w-8 p-0">
@@ -283,7 +284,7 @@ export default function ExitNodesTable({
<Link
href={`/${nodeRow.orgId}/settings/remote-exit-nodes/${remoteExitNodeId}`}
>
<Button variant={"secondary"} size="sm">
<Button variant={"outline"}>
{t("edit")}
<ArrowRight className="ml-2 w-4 h-4" />
</Button>
@@ -327,6 +328,11 @@ export default function ExitNodesTable({
}
onRefresh={refreshData}
isRefreshing={isRefreshing}
columnVisibility={{
type: false,
address: false,
}}
enableColumnVisibility={true}
/>
</>
);

View File

@@ -8,16 +8,17 @@ import { Badge } from "@app/components/ui/badge";
import { useLicenseStatusContext } from "@app/hooks/useLicenseStatusContext";
import { useTranslations } from "next-intl";
export type HorizontalTabs = Array<{
export type TabItem = {
title: string;
href: string;
icon?: React.ReactNode;
showProfessional?: boolean;
}>;
exact?: boolean;
};
interface HorizontalTabsProps {
children: React.ReactNode;
items: HorizontalTabs;
items: TabItem[];
disabled?: boolean;
}
@@ -38,7 +39,8 @@ export function HorizontalTabs({
.replace("{niceId}", params.niceId as string)
.replace("{userId}", params.userId as string)
.replace("{clientId}", params.clientId as string)
.replace("{apiKeyId}", params.apiKeyId as string);
.replace("{apiKeyId}", params.apiKeyId as string)
.replace("{remoteExitNodeId}", params.remoteExitNodeId as string);
}
return (
@@ -49,8 +51,11 @@ export function HorizontalTabs({
{items.map((item) => {
const hydratedHref = hydrateHref(item.href);
const isActive =
pathname.startsWith(hydratedHref) &&
(item.exact
? pathname === hydratedHref
: pathname.startsWith(hydratedHref)) &&
!pathname.includes("create");
const isProfessional =
item.showProfessional && !isUnlocked();
const isDisabled =

View File

@@ -49,7 +49,7 @@ export function LayoutHeader({ showTopBar }: LayoutHeaderProps) {
return (
<div className="absolute top-0 left-0 right-0 z-50 hidden md:block">
<div className="absolute inset-0 bg-background/86 backdrop-blur-sm" />
<div className="absolute inset-0 bg-background/83 backdrop-blur-sm" />
<div className="relative z-10 px-6 py-2">
<div className="container mx-auto max-w-12xl">
<div className="h-16 flex items-center justify-between">

View File

@@ -49,7 +49,7 @@ export function LayoutMobileMenu({
return (
<div className="shrink-0 md:hidden">
<div className="h-16 flex items-center px-4">
<div className="h-16 flex items-center px-2">
<div className="flex items-center gap-4">
{showSidebar && (
<div>

View File

@@ -91,15 +91,12 @@ export function LogAnalyticsData(props: AnalyticsContentProps) {
})
);
const percentBlocked = stats
? new Intl.NumberFormat(navigator.language, {
maximumFractionDigits: 2
}).format(
stats.totalRequests
? (stats.totalBlocked / stats.totalRequests) * 100
: 0
)
: null;
const percentBlocked =
stats && stats.totalRequests > 0
? new Intl.NumberFormat(navigator.language, {
maximumFractionDigits: 2
}).format((stats.totalBlocked / stats.totalRequests) * 100)
: null;
const totalRequests = stats
? new Intl.NumberFormat(navigator.language, {
maximumFractionDigits: 0

View File

@@ -2,17 +2,14 @@
import { Alert, AlertDescription } from "@app/components/ui/alert";
import { build } from "@server/build";
import { useTranslations } from "next-intl";
import { useLicenseStatusContext } from "@app/hooks/useLicenseStatusContext";
import { useSubscriptionStatusContext } from "@app/hooks/useSubscriptionStatusContext";
import { usePaidStatus } from "@app/hooks/usePaidStatus";
export function SecurityFeaturesAlert() {
export function PaidFeaturesAlert() {
const t = useTranslations();
const { isUnlocked } = useLicenseStatusContext();
const subscriptionStatus = useSubscriptionStatusContext();
const { hasSaasSubscription, hasEnterpriseLicense } = usePaidStatus();
return (
<>
{build === "saas" && !subscriptionStatus?.isSubscribed() ? (
{build === "saas" && !hasSaasSubscription ? (
<Alert variant="info" className="mb-6">
<AlertDescription>
{t("subscriptionRequiredToUse")}
@@ -20,7 +17,7 @@ export function SecurityFeaturesAlert() {
</Alert>
) : null}
{build === "enterprise" && !isUnlocked() ? (
{build === "enterprise" && !hasEnterpriseLicense ? (
<Alert variant="info" className="mb-6">
<AlertDescription>
{t("licenseRequiredToUse")}

View File

@@ -27,6 +27,7 @@ function getActionsCategories(root: boolean) {
[t("actionUpdateOrg")]: "updateOrg",
[t("actionGetOrgUser")]: "getOrgUser",
[t("actionInviteUser")]: "inviteUser",
[t("actionRemoveInvitation")]: "removeInvitation",
[t("actionListInvitations")]: "listInvitations",
[t("actionRemoveUser")]: "removeUser",
[t("actionListUsers")]: "listUsers",

View File

@@ -39,16 +39,15 @@ import {
resourceWhitelistProxy,
resourceAccessProxy
} from "@app/actions/server";
import { createApiClient } from "@app/lib/api";
import { useEnvContext } from "@app/hooks/useEnvContext";
import { toast } from "@app/hooks/useToast";
import Link from "next/link";
import Image from "next/image";
import BrandingLogo from "@app/components/BrandingLogo";
import { useSupporterStatusContext } from "@app/hooks/useSupporterStatusContext";
import { useTranslations } from "next-intl";
import { build } from "@server/build";
import { useLicenseStatusContext } from "@app/hooks/useLicenseStatusContext";
import { replacePlaceholder } from "@app/lib/replacePlaceholder";
const pinSchema = z.object({
pin: z
@@ -88,6 +87,14 @@ type ResourceAuthPortalProps = {
redirect: string;
idps?: LoginFormIDP[];
orgId?: string;
branding?: {
logoUrl: string;
logoWidth: number;
logoHeight: number;
primaryColor: string | null;
resourceTitle: string;
resourceSubtitle: string | null;
};
};
export default function ResourceAuthPortal(props: ResourceAuthPortalProps) {
@@ -104,7 +111,7 @@ export default function ResourceAuthPortal(props: ResourceAuthPortalProps) {
return colLength;
};
const [numMethods, setNumMethods] = useState(getNumMethods());
const [numMethods] = useState(() => getNumMethods());
const [passwordError, setPasswordError] = useState<string | null>(null);
const [pincodeError, setPincodeError] = useState<string | null>(null);
@@ -309,13 +316,19 @@ export default function ResourceAuthPortal(props: ResourceAuthPortalProps) {
}
}
function getTitle() {
function getTitle(resourceName: string) {
if (
isUnlocked() &&
build !== "oss" &&
env.branding.resourceAuthPage?.titleText
isUnlocked() &&
(!!env.branding.resourceAuthPage?.titleText ||
!!props.branding?.resourceTitle)
) {
return env.branding.resourceAuthPage.titleText;
if (props.branding?.resourceTitle) {
return replacePlaceholder(props.branding?.resourceTitle, {
resourceName
});
}
return env.branding.resourceAuthPage?.titleText;
}
return t("authenticationRequired");
}
@@ -324,10 +337,16 @@ export default function ResourceAuthPortal(props: ResourceAuthPortalProps) {
if (
isUnlocked() &&
build !== "oss" &&
env.branding.resourceAuthPage?.subtitleText
(env.branding.resourceAuthPage?.subtitleText ||
props.branding?.resourceSubtitle)
) {
return env.branding.resourceAuthPage.subtitleText
.split("{{resourceName}}")
if (props.branding?.resourceSubtitle) {
return replacePlaceholder(props.branding?.resourceSubtitle, {
resourceName
});
}
return env.branding.resourceAuthPage?.subtitleText
?.split("{{resourceName}}")
.join(resourceName);
}
return numMethods > 1
@@ -336,14 +355,23 @@ export default function ResourceAuthPortal(props: ResourceAuthPortalProps) {
}
const logoWidth = isUnlocked()
? env.branding.logo?.authPage?.width || 100
? (props.branding?.logoWidth ??
env.branding.logo?.authPage?.width ??
100)
: 100;
const logoHeight = isUnlocked()
? env.branding.logo?.authPage?.height || 100
? (props.branding?.logoHeight ??
env.branding.logo?.authPage?.height ??
100)
: 100;
return (
<div>
<div
style={{
// @ts-expect-error CSS variable
"--primary": isUnlocked() ? props.branding?.primaryColor : null
}}
>
{!accessDenied ? (
<div>
{isUnlocked() && build === "enterprise" ? (
@@ -381,15 +409,19 @@ export default function ResourceAuthPortal(props: ResourceAuthPortalProps) {
<CardHeader>
{isUnlocked() &&
build !== "oss" &&
env.branding?.resourceAuthPage?.showLogo && (
(env.branding?.resourceAuthPage?.showLogo ||
props.branding) && (
<div className="flex flex-row items-center justify-center mb-3">
<BrandingLogo
height={logoHeight}
width={logoWidth}
logoPath={props.branding?.logoUrl}
/>
</div>
)}
<CardTitle>{getTitle()}</CardTitle>
<CardTitle>
{getTitle(props.resource.name)}
</CardTitle>
<CardDescription>
{getSubtitle(props.resource.name)}
</CardDescription>

View File

@@ -13,6 +13,7 @@ import { Button } from "@app/components/ui/button";
import {
ArrowRight,
ArrowUpDown,
ArrowUpRight,
Check,
MoreHorizontal,
X
@@ -46,6 +47,7 @@ export type SiteRow = {
address?: string;
exitNodeName?: string;
exitNodeEndpoint?: string;
remoteExitNodeId?: string;
};
type SitesTableProps = {
@@ -303,27 +305,51 @@ export default function SitesTable({ sites, orgId }: SitesTableProps) {
},
cell: ({ row }) => {
const originalRow = row.original;
return originalRow.exitNodeName ? (
<div className="flex items-center space-x-2">
<span>{originalRow.exitNodeName}</span>
{build == "saas" &&
originalRow.exitNodeName &&
[
"mercury",
"venus",
"earth",
"mars",
"jupiter",
"saturn",
"uranus",
"neptune"
].includes(
originalRow.exitNodeName.toLowerCase()
) && <Badge variant="secondary">Cloud</Badge>}
</div>
) : (
"-"
);
if (!originalRow.exitNodeName) {
return "-";
}
const isCloudNode =
build == "saas" &&
originalRow.exitNodeName &&
[
"mercury",
"venus",
"earth",
"mars",
"jupiter",
"saturn",
"uranus",
"neptune"
].includes(originalRow.exitNodeName.toLowerCase());
if (isCloudNode) {
const capitalizedName =
originalRow.exitNodeName.charAt(0).toUpperCase() +
originalRow.exitNodeName.slice(1).toLowerCase();
return (
<Badge variant="secondary">
Pangolin {capitalizedName}
</Badge>
);
}
// Self-hosted node
if (originalRow.remoteExitNodeId) {
return (
<Link
href={`/${originalRow.orgId}/settings/remote-exit-nodes/${originalRow.remoteExitNodeId}`}
>
<Button variant="outline">
{originalRow.exitNodeName}
<ArrowUpRight className="ml-2 h-4 w-4" />
</Button>
</Link>
);
}
// Fallback if no remoteExitNodeId
return <span>{originalRow.exitNodeName}</span>;
}
},
{

View File

@@ -7,7 +7,7 @@ import { usePathname, useRouter, useSearchParams } from "next/navigation";
export function TopLoader() {
return (
<>
<NextTopLoader showSpinner={false} />
<NextTopLoader showSpinner={false} color="var(--color-primary)" />
<FinishingLoader />
</>
);

View File

@@ -26,6 +26,11 @@ type ValidateOidcTokenParams = {
stateCookie: string | undefined;
idp: { name: string };
loginPageId?: number;
providerError?: {
error: string;
description?: string | null;
uri?: string | null;
};
};
export default function ValidateOidcToken(props: ValidateOidcTokenParams) {
@@ -35,14 +40,65 @@ export default function ValidateOidcToken(props: ValidateOidcTokenParams) {
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [isProviderError, setIsProviderError] = useState(false);
const { licenseStatus, isLicenseViolation } = useLicenseStatusContext();
const t = useTranslations();
useEffect(() => {
async function validate() {
let isCancelled = false;
async function runValidation() {
setLoading(true);
setIsProviderError(false);
if (props.providerError?.error) {
const providerMessage =
props.providerError.description ||
t("idpErrorOidcProviderRejected", {
error: props.providerError.error,
defaultValue:
"The identity provider returned an error: {error}."
});
const suffix = props.providerError.uri
? ` (${props.providerError.uri})`
: "";
if (!isCancelled) {
setIsProviderError(true);
setError(`${providerMessage}${suffix}`);
setLoading(false);
}
return;
}
if (!props.code) {
if (!isCancelled) {
setIsProviderError(false);
setError(
t("idpErrorOidcMissingCode", {
defaultValue:
"The identity provider did not return an authorization code."
})
);
setLoading(false);
}
return;
}
if (!props.expectedState || !props.stateCookie) {
if (!isCancelled) {
setIsProviderError(false);
setError(
t("idpErrorOidcMissingState", {
defaultValue:
"The login request is missing state information. Please restart the login process."
})
);
setLoading(false);
}
return;
}
console.log(t("idpOidcTokenValidating"), {
code: props.code,
@@ -57,22 +113,28 @@ export default function ValidateOidcToken(props: ValidateOidcTokenParams) {
try {
const response = await validateOidcUrlCallbackProxy(
props.idpId,
props.code || "",
props.expectedState || "",
props.stateCookie || "",
props.code,
props.expectedState,
props.stateCookie,
props.loginPageId
);
if (response.error) {
setError(response.message);
setLoading(false);
if (!isCancelled) {
setIsProviderError(false);
setError(response.message);
setLoading(false);
}
return;
}
const data = response.data;
if (!data) {
setError("Unable to validate OIDC token");
setLoading(false);
if (!isCancelled) {
setIsProviderError(false);
setError("Unable to validate OIDC token");
setLoading(false);
}
return;
}
@@ -82,8 +144,11 @@ export default function ValidateOidcToken(props: ValidateOidcTokenParams) {
router.push(env.app.dashboardUrl);
}
setLoading(false);
await new Promise((resolve) => setTimeout(resolve, 100));
if (!isCancelled) {
setIsProviderError(false);
setLoading(false);
await new Promise((resolve) => setTimeout(resolve, 100));
}
if (redirectUrl.startsWith("http")) {
window.location.href = data.redirectUrl; // this is validated by the parent using this component
@@ -92,18 +157,27 @@ export default function ValidateOidcToken(props: ValidateOidcTokenParams) {
}
} catch (e: any) {
console.error(e);
setError(
t("idpErrorOidcTokenValidating", {
defaultValue:
"An unexpected error occurred. Please try again."
})
);
if (!isCancelled) {
setIsProviderError(false);
setError(
t("idpErrorOidcTokenValidating", {
defaultValue:
"An unexpected error occurred. Please try again."
})
);
}
} finally {
setLoading(false);
if (!isCancelled) {
setLoading(false);
}
}
}
validate();
runValidation();
return () => {
isCancelled = true;
};
}, []);
return (
@@ -134,12 +208,16 @@ export default function ValidateOidcToken(props: ValidateOidcTokenParams) {
<Alert variant="destructive" className="w-full">
<AlertCircle className="h-5 w-5" />
<AlertDescription className="flex flex-col space-y-2">
<span>
{t("idpErrorConnectingTo", {
name: props.idp.name
})}
<span className="text-sm font-medium">
{isProviderError
? error
: t("idpErrorConnectingTo", {
name: props.idp.name
})}
</span>
<span className="text-xs">{error}</span>
{!isProviderError && (
<span className="text-xs">{error}</span>
)}
</AlertDescription>
</Alert>
)}

View File

@@ -3,16 +3,8 @@
import { Button } from "@app/components/ui/button";
import { useOrgContext } from "@app/hooks/useOrgContext";
import { toast } from "@app/hooks/useToast";
import { useState, useEffect, forwardRef, useImperativeHandle } from "react";
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage
} from "@/components/ui/form";
import { useState, useEffect, useActionState } from "react";
import { Form } from "@/components/ui/form";
import { Label } from "@/components/ui/label";
import { z } from "zod";
import { useForm } from "react-hook-form";
@@ -51,9 +43,9 @@ import DomainPicker from "@app/components/DomainPicker";
import { finalizeSubdomainSanitize } from "@app/lib/subdomain-utils";
import { InfoPopup } from "@app/components/ui/info-popup";
import { Alert, AlertDescription } from "@app/components/ui/alert";
import { useSubscriptionStatusContext } from "@app/hooks/useSubscriptionStatusContext";
import { TierId } from "@server/lib/billing/tiers";
import { build } from "@server/build";
import { usePaidStatus } from "@app/hooks/usePaidStatus";
import { PaidFeaturesAlert } from "../PaidFeaturesAlert";
// Auth page form schema
const AuthPageFormSchema = z.object({
@@ -61,11 +53,10 @@ const AuthPageFormSchema = z.object({
authPageSubdomain: z.string().optional()
});
type AuthPageFormValues = z.infer<typeof AuthPageFormSchema>;
interface AuthPageSettingsProps {
onSaveSuccess?: () => void;
onSaveError?: (error: any) => void;
loginPage: GetLoginPageResponse | null;
}
export interface AuthPageSettingsRef {
@@ -73,475 +64,428 @@ export interface AuthPageSettingsRef {
hasUnsavedChanges: () => boolean;
}
const AuthPageSettings = forwardRef<AuthPageSettingsRef, AuthPageSettingsProps>(
({ onSaveSuccess, onSaveError }, ref) => {
const { org } = useOrgContext();
const api = createApiClient(useEnvContext());
const router = useRouter();
const t = useTranslations();
const { env } = useEnvContext();
function AuthPageSettings({
onSaveSuccess,
onSaveError,
loginPage: defaultLoginPage
}: AuthPageSettingsProps) {
const { org } = useOrgContext();
const api = createApiClient(useEnvContext());
const router = useRouter();
const t = useTranslations();
const { env } = useEnvContext();
const subscription = useSubscriptionStatusContext();
const { hasSaasSubscription } = usePaidStatus();
// Auth page domain state
const [loginPage, setLoginPage] = useState<GetLoginPageResponse | null>(
null
);
const [loginPageExists, setLoginPageExists] = useState(false);
const [editDomainOpen, setEditDomainOpen] = useState(false);
const [baseDomains, setBaseDomains] = useState<DomainRow[]>([]);
const [selectedDomain, setSelectedDomain] = useState<{
domainId: string;
subdomain?: string;
fullDomain: string;
baseDomain: string;
} | null>(null);
const [loadingLoginPage, setLoadingLoginPage] = useState(true);
const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false);
const [loadingSave, setLoadingSave] = useState(false);
// Auth page domain state
const [loginPage, setLoginPage] = useState(defaultLoginPage);
const [, formAction, isSubmitting] = useActionState(onSubmit, null);
const [loginPageExists, setLoginPageExists] = useState(
Boolean(defaultLoginPage)
);
const [editDomainOpen, setEditDomainOpen] = useState(false);
const [baseDomains, setBaseDomains] = useState<DomainRow[]>([]);
const [selectedDomain, setSelectedDomain] = useState<{
domainId: string;
subdomain?: string;
fullDomain: string;
baseDomain: string;
} | null>(null);
const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false);
const form = useForm({
resolver: zodResolver(AuthPageFormSchema),
defaultValues: {
authPageDomainId: loginPage?.domainId || "",
authPageSubdomain: loginPage?.subdomain || ""
},
mode: "onChange"
});
// Expose save function to parent component
useImperativeHandle(
ref,
() => ({
saveAuthSettings: async () => {
await form.handleSubmit(onSubmit)();
},
hasUnsavedChanges: () => hasUnsavedChanges
}),
[form, hasUnsavedChanges]
);
// Fetch login page and domains data
useEffect(() => {
const fetchLoginPage = async () => {
try {
const res = await api.get<
AxiosResponse<GetLoginPageResponse>
>(`/org/${org?.org.orgId}/login-page`);
if (res.status === 200) {
setLoginPage(res.data.data);
setLoginPageExists(true);
// Update form with login page data
form.setValue(
"authPageDomainId",
res.data.data.domainId || ""
);
form.setValue(
"authPageSubdomain",
res.data.data.subdomain || ""
);
}
} catch (err) {
// Login page doesn't exist yet, that's okay
setLoginPage(null);
setLoginPageExists(false);
} finally {
setLoadingLoginPage(false);
}
};
const fetchDomains = async () => {
try {
const res = await api.get<
AxiosResponse<ListDomainsResponse>
>(`/org/${org?.org.orgId}/domains/`);
if (res.status === 200) {
const rawDomains = res.data.data.domains as DomainRow[];
const domains = rawDomains.map((domain) => ({
...domain,
baseDomain: toUnicode(domain.baseDomain)
}));
setBaseDomains(domains);
}
} catch (err) {
console.error("Failed to fetch domains:", err);
}
};
if (org?.org.orgId) {
fetchLoginPage();
fetchDomains();
}
}, []);
// Handle domain selection from modal
function handleDomainSelection(domain: {
domainId: string;
subdomain?: string;
fullDomain: string;
baseDomain: string;
}) {
form.setValue("authPageDomainId", domain.domainId);
form.setValue("authPageSubdomain", domain.subdomain || "");
setEditDomainOpen(false);
// Update loginPage state to show the selected domain immediately
const sanitizedSubdomain = domain.subdomain
? finalizeSubdomainSanitize(domain.subdomain)
: "";
const sanitizedFullDomain = sanitizedSubdomain
? `${sanitizedSubdomain}.${domain.baseDomain}`
: domain.baseDomain;
// Only update loginPage state if a login page already exists
if (loginPageExists && loginPage) {
setLoginPage({
...loginPage,
domainId: domain.domainId,
subdomain: sanitizedSubdomain,
fullDomain: sanitizedFullDomain
});
}
setHasUnsavedChanges(true);
}
// Clear auth page domain
function clearAuthPageDomain() {
form.setValue("authPageDomainId", "");
form.setValue("authPageSubdomain", "");
setLoginPage(null);
setHasUnsavedChanges(true);
}
async function onSubmit(data: AuthPageFormValues) {
setLoadingSave(true);
const form = useForm({
resolver: zodResolver(AuthPageFormSchema),
defaultValues: {
authPageDomainId: loginPage?.domainId || "",
authPageSubdomain: loginPage?.subdomain || ""
},
mode: "onChange"
});
// Fetch login page and domains data
useEffect(() => {
const fetchDomains = async () => {
try {
// Handle auth page domain
if (data.authPageDomainId) {
if (
build === "enterprise" ||
(build === "saas" && subscription?.subscribed)
) {
const sanitizedSubdomain = data.authPageSubdomain
? finalizeSubdomainSanitize(data.authPageSubdomain)
: "";
const res = await api.get<AxiosResponse<ListDomainsResponse>>(
`/org/${org?.org.orgId}/domains/`
);
if (res.status === 200) {
const rawDomains = res.data.data.domains as DomainRow[];
const domains = rawDomains.map((domain) => ({
...domain,
baseDomain: toUnicode(domain.baseDomain)
}));
setBaseDomains(domains);
}
} catch (err) {
console.error("Failed to fetch domains:", err);
}
};
if (loginPageExists) {
// Login page exists on server - need to update it
// First, we need to get the loginPageId from the server since loginPage might be null locally
let loginPageId: number;
if (org?.org.orgId) {
fetchDomains();
}
}, []);
if (loginPage) {
// We have the loginPage data locally
loginPageId = loginPage.loginPageId;
} else {
// User cleared selection locally, but login page still exists on server
// We need to fetch it to get the loginPageId
const fetchRes = await api.get<
AxiosResponse<GetLoginPageResponse>
>(`/org/${org?.org.orgId}/login-page`);
loginPageId = fetchRes.data.data.loginPageId;
}
// Handle domain selection from modal
function handleDomainSelection(domain: {
domainId: string;
subdomain?: string;
fullDomain: string;
baseDomain: string;
}) {
form.setValue("authPageDomainId", domain.domainId);
form.setValue("authPageSubdomain", domain.subdomain || "");
setEditDomainOpen(false);
// Update existing auth page domain
const updateRes = await api.post(
`/org/${org?.org.orgId}/login-page/${loginPageId}`,
{
domainId: data.authPageDomainId,
subdomain: sanitizedSubdomain || null
}
);
// Update loginPage state to show the selected domain immediately
const sanitizedSubdomain = domain.subdomain
? finalizeSubdomainSanitize(domain.subdomain)
: "";
if (updateRes.status === 201) {
setLoginPage(updateRes.data.data);
setLoginPageExists(true);
}
const sanitizedFullDomain = sanitizedSubdomain
? `${sanitizedSubdomain}.${domain.baseDomain}`
: domain.baseDomain;
// Only update loginPage state if a login page already exists
if (loginPageExists && loginPage) {
setLoginPage({
...loginPage,
domainId: domain.domainId,
subdomain: sanitizedSubdomain,
fullDomain: sanitizedFullDomain
});
}
setHasUnsavedChanges(true);
}
// Clear auth page domain
function clearAuthPageDomain() {
form.setValue("authPageDomainId", "");
form.setValue("authPageSubdomain", "");
setLoginPage(null);
setHasUnsavedChanges(true);
}
async function onSubmit() {
const isValid = await form.trigger();
if (!isValid) return;
const data = form.getValues();
try {
// Handle auth page domain
if (data.authPageDomainId) {
if (build === "enterprise" || hasSaasSubscription) {
const sanitizedSubdomain = data.authPageSubdomain
? finalizeSubdomainSanitize(data.authPageSubdomain)
: "";
if (loginPageExists) {
// Login page exists on server - need to update it
// First, we need to get the loginPageId from the server since loginPage might be null locally
let loginPageId: number;
if (loginPage) {
// We have the loginPage data locally
loginPageId = loginPage.loginPageId;
} else {
// No login page exists on server - create new one
const createRes = await api.put(
`/org/${org?.org.orgId}/login-page`,
{
domainId: data.authPageDomainId,
subdomain: sanitizedSubdomain || null
}
);
// User cleared selection locally, but login page still exists on server
// We need to fetch it to get the loginPageId
const fetchRes = await api.get<
AxiosResponse<GetLoginPageResponse>
>(`/org/${org?.org.orgId}/login-page`);
loginPageId = fetchRes.data.data.loginPageId;
}
if (createRes.status === 201) {
setLoginPage(createRes.data.data);
setLoginPageExists(true);
// Update existing auth page domain
const updateRes = await api.post(
`/org/${org?.org.orgId}/login-page/${loginPageId}`,
{
domainId: data.authPageDomainId,
subdomain: sanitizedSubdomain || null
}
);
if (updateRes.status === 201) {
setLoginPage(updateRes.data.data);
setLoginPageExists(true);
}
} else {
// No login page exists on server - create new one
const createRes = await api.put(
`/org/${org?.org.orgId}/login-page`,
{
domainId: data.authPageDomainId,
subdomain: sanitizedSubdomain || null
}
);
if (createRes.status === 201) {
setLoginPage(createRes.data.data);
setLoginPageExists(true);
}
}
} else if (loginPageExists) {
// Delete existing auth page domain if no domain selected
let loginPageId: number;
}
} else if (loginPageExists) {
// Delete existing auth page domain if no domain selected
let loginPageId: number;
if (loginPage) {
// We have the loginPage data locally
loginPageId = loginPage.loginPageId;
} else {
// User cleared selection locally, but login page still exists on server
// We need to fetch it to get the loginPageId
const fetchRes = await api.get<
AxiosResponse<GetLoginPageResponse>
>(`/org/${org?.org.orgId}/login-page`);
loginPageId = fetchRes.data.data.loginPageId;
}
await api.delete(
`/org/${org?.org.orgId}/login-page/${loginPageId}`
);
setLoginPage(null);
setLoginPageExists(false);
if (loginPage) {
// We have the loginPage data locally
loginPageId = loginPage.loginPageId;
} else {
// User cleared selection locally, but login page still exists on server
// We need to fetch it to get the loginPageId
const fetchRes = await api.get<
AxiosResponse<GetLoginPageResponse>
>(`/org/${org?.org.orgId}/login-page`);
loginPageId = fetchRes.data.data.loginPageId;
}
setHasUnsavedChanges(false);
router.refresh();
onSaveSuccess?.();
} catch (e) {
toast({
variant: "destructive",
title: t("authPageErrorUpdate"),
description: formatAxiosError(
e,
t("authPageErrorUpdateMessage")
)
});
onSaveError?.(e);
} finally {
setLoadingSave(false);
await api.delete(
`/org/${org?.org.orgId}/login-page/${loginPageId}`
);
setLoginPage(null);
setLoginPageExists(false);
}
setHasUnsavedChanges(false);
router.refresh();
onSaveSuccess?.();
toast({
variant: "default",
title: t("success"),
description: t("authPageDomainUpdated")
});
} catch (e) {
toast({
variant: "destructive",
title: t("authPageErrorUpdate"),
description: formatAxiosError(
e,
t("authPageErrorUpdateMessage")
)
});
onSaveError?.(e);
}
return (
<>
<SettingsSection>
<SettingsSectionHeader>
<SettingsSectionTitle>
{t("authPage")}
</SettingsSectionTitle>
<SettingsSectionDescription>
{t("authPageDescription")}
</SettingsSectionDescription>
</SettingsSectionHeader>
<SettingsSectionBody>
{build === "saas" && !subscription?.subscribed ? (
<Alert variant="info" className="mb-6">
<AlertDescription>
{t("orgAuthPageDisabled")}{" "}
{t("subscriptionRequiredToUse")}
</AlertDescription>
</Alert>
) : null}
<SettingsSectionForm>
{loadingLoginPage ? (
<div className="flex items-center justify-center py-8">
<div className="text-sm text-muted-foreground">
{t("loading")}
</div>
</div>
) : (
<Form {...form}>
<form
onSubmit={form.handleSubmit(onSubmit)}
className="space-y-4"
id="auth-page-settings-form"
>
<div className="space-y-3">
<Label>{t("authPageDomain")}</Label>
<div className="border p-2 rounded-md flex items-center justify-between">
<span className="text-sm text-muted-foreground flex items-center gap-2">
<Globe size="14" />
{loginPage &&
!loginPage.domainId ? (
<InfoPopup
info={t(
"domainNotFoundDescription"
)}
text={t(
"domainNotFound"
)}
/>
) : loginPage?.fullDomain ? (
<a
href={`${window.location.protocol}//${loginPage.fullDomain}`}
target="_blank"
rel="noopener noreferrer"
className="hover:underline"
>
{`${window.location.protocol}//${loginPage.fullDomain}`}
</a>
) : form.watch(
"authPageDomainId"
) ? (
// Show selected domain from form state when no loginPage exists yet
(() => {
const selectedDomainId =
form.watch(
"authPageDomainId"
);
const selectedSubdomain =
form.watch(
"authPageSubdomain"
);
const domain =
baseDomains.find(
(d) =>
d.domainId ===
selectedDomainId
);
if (domain) {
const sanitizedSubdomain =
selectedSubdomain
? finalizeSubdomainSanitize(
selectedSubdomain
)
: "";
const fullDomain =
sanitizedSubdomain
? `${sanitizedSubdomain}.${domain.baseDomain}`
: domain.baseDomain;
return fullDomain;
}
return t(
"noDomainSet"
);
})()
) : (
t("noDomainSet")
)}
</span>
<div className="flex items-center gap-2">
<Button
variant="secondary"
type="button"
size="sm"
onClick={() =>
setEditDomainOpen(
true
)
}
>
{form.watch(
"authPageDomainId"
)
? t("changeDomain")
: t("selectDomain")}
</Button>
{form.watch(
"authPageDomainId"
) && (
<Button
variant="destructive"
type="button"
size="sm"
onClick={
clearAuthPageDomain
}
>
<Trash2 size="14" />
</Button>
)}
</div>
</div>
{!form.watch(
"authPageDomainId"
) && (
<div className="text-sm text-muted-foreground">
{t(
"addDomainToEnableCustomAuthPages"
)}
</div>
)}
{env.flags.usePangolinDns &&
(build === "enterprise" ||
(build === "saas" &&
subscription?.subscribed)) &&
loginPage?.domainId &&
loginPage?.fullDomain &&
!hasUnsavedChanges && (
<CertificateStatus
orgId={
org?.org.orgId || ""
}
domainId={
loginPage.domainId
}
fullDomain={
loginPage.fullDomain
}
autoFetch={true}
showLabel={true}
polling={true}
/>
)}
</div>
</form>
</Form>
)}
</SettingsSectionForm>
</SettingsSectionBody>
</SettingsSection>
{/* Domain Picker Modal */}
<Credenza
open={editDomainOpen}
onOpenChange={(setOpen) => setEditDomainOpen(setOpen)}
>
<CredenzaContent>
<CredenzaHeader>
<CredenzaTitle>
{loginPage
? t("editAuthPageDomain")
: t("setAuthPageDomain")}
</CredenzaTitle>
<CredenzaDescription>
{t("selectDomainForOrgAuthPage")}
</CredenzaDescription>
</CredenzaHeader>
<CredenzaBody>
<DomainPicker
hideFreeDomain={true}
orgId={org?.org.orgId as string}
cols={1}
onDomainChange={(res) => {
const selected = {
domainId: res.domainId,
subdomain: res.subdomain,
fullDomain: res.fullDomain,
baseDomain: res.baseDomain
};
setSelectedDomain(selected);
}}
/>
</CredenzaBody>
<CredenzaFooter>
<CredenzaClose asChild>
<Button variant="outline">{t("cancel")}</Button>
</CredenzaClose>
<Button
onClick={() => {
if (selectedDomain) {
handleDomainSelection(selectedDomain);
}
}}
disabled={!selectedDomain}
>
{t("selectDomain")}
</Button>
</CredenzaFooter>
</CredenzaContent>
</Credenza>
</>
);
}
);
return (
<>
<SettingsSection>
<SettingsSectionHeader>
<SettingsSectionTitle>{t("customDomain")}</SettingsSectionTitle>
<SettingsSectionDescription>
{t("authPageDescription")}
</SettingsSectionDescription>
</SettingsSectionHeader>
<SettingsSectionBody>
<SettingsSectionForm>
<PaidFeaturesAlert />
<Form {...form}>
<form
action={formAction}
className="space-y-4"
id="auth-page-settings-form"
>
<div className="space-y-3">
<Label>{t("authPageDomain")}</Label>
<div className="border p-2 rounded-md flex items-center justify-between">
<span className="text-sm text-muted-foreground flex items-center gap-2">
<Globe size="14" />
{loginPage &&
!loginPage.domainId ? (
<InfoPopup
info={t(
"domainNotFoundDescription"
)}
text={t("domainNotFound")}
/>
) : loginPage?.fullDomain ? (
<a
href={`${window.location.protocol}//${loginPage.fullDomain}`}
target="_blank"
rel="noopener noreferrer"
className="hover:underline"
>
{`${window.location.protocol}//${loginPage.fullDomain}`}
</a>
) : form.watch(
"authPageDomainId"
) ? (
// Show selected domain from form state when no loginPage exists yet
(() => {
const selectedDomainId =
form.watch(
"authPageDomainId"
);
const selectedSubdomain =
form.watch(
"authPageSubdomain"
);
const domain =
baseDomains.find(
(d) =>
d.domainId ===
selectedDomainId
);
if (domain) {
const sanitizedSubdomain =
selectedSubdomain
? finalizeSubdomainSanitize(
selectedSubdomain
)
: "";
const fullDomain =
sanitizedSubdomain
? `${sanitizedSubdomain}.${domain.baseDomain}`
: domain.baseDomain;
return fullDomain;
}
return t("noDomainSet");
})()
) : (
t("noDomainSet")
)}
</span>
<div className="flex items-center gap-2">
<Button
variant="secondary"
type="button"
size="sm"
onClick={() =>
setEditDomainOpen(true)
}
disabled={!hasSaasSubscription}
>
{form.watch("authPageDomainId")
? t("changeDomain")
: t("selectDomain")}
</Button>
{form.watch("authPageDomainId") && (
<Button
variant="destructive"
type="button"
size="sm"
onClick={
clearAuthPageDomain
}
disabled={
!hasSaasSubscription
}
>
<Trash2 size="14" />
</Button>
)}
</div>
</div>
{!form.watch("authPageDomainId") && (
<div className="text-sm text-muted-foreground">
{t(
"addDomainToEnableCustomAuthPages"
)}
</div>
)}
{env.flags.usePangolinDns &&
(build === "enterprise" ||
!hasSaasSubscription) &&
loginPage?.domainId &&
loginPage?.fullDomain &&
!hasUnsavedChanges && (
<CertificateStatus
orgId={org?.org.orgId || ""}
domainId={loginPage.domainId}
fullDomain={
loginPage.fullDomain
}
autoFetch={true}
showLabel={true}
polling={true}
/>
)}
</div>
</form>
</Form>
</SettingsSectionForm>
</SettingsSectionBody>
<div className="flex justify-end mt-6">
<Button
type="submit"
form="auth-page-settings-form"
loading={isSubmitting}
disabled={
isSubmitting ||
!hasUnsavedChanges ||
!hasSaasSubscription
}
>
{t("saveAuthPageDomain")}
</Button>
</div>
</SettingsSection>
{/* Domain Picker Modal */}
<Credenza
open={editDomainOpen}
onOpenChange={(setOpen) => setEditDomainOpen(setOpen)}
>
<CredenzaContent>
<CredenzaHeader>
<CredenzaTitle>
{loginPage
? t("editAuthPageDomain")
: t("setAuthPageDomain")}
</CredenzaTitle>
<CredenzaDescription>
{t("selectDomainForOrgAuthPage")}
</CredenzaDescription>
</CredenzaHeader>
<CredenzaBody>
<DomainPicker
hideFreeDomain={true}
orgId={org?.org.orgId as string}
cols={1}
onDomainChange={(res) => {
const selected =
res === null
? null
: {
domainId: res.domainId,
subdomain: res.subdomain,
fullDomain: res.fullDomain,
baseDomain: res.baseDomain
};
setSelectedDomain(selected);
}}
/>
</CredenzaBody>
<CredenzaFooter>
<CredenzaClose asChild>
<Button variant="outline">{t("cancel")}</Button>
</CredenzaClose>
<Button
onClick={() => {
if (selectedDomain) {
handleDomainSelection(selectedDomain);
}
}}
disabled={!selectedDomain || !hasSaasSubscription}
>
{t("selectDomain")}
</Button>
</CredenzaFooter>
</CredenzaContent>
</Credenza>
</>
);
}
AuthPageSettings.displayName = "AuthPageSettings";

View File

@@ -308,7 +308,7 @@ export const Autocomplete: React.FC<AutocompleteProps> = ({
role="option"
aria-selected={isSelected}
className={cn(
"relative flex cursor-pointer select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none aria-selected:bg-accent aria-selected:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 hover:bg-accent",
"relative flex cursor-pointer select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none aria-selected:bg-accent aria-selected:text-accent-foreground data-disabled:pointer-events-none data-disabled:opacity-50 hover:bg-accent",
isSelected &&
"bg-accent text-accent-foreground",
classStyleProps?.commandItem

View File

@@ -19,7 +19,7 @@ const buttonVariants = cva(
outlinePrimary:
"border border-primary bg-card hover:bg-primary/10 text-primary ",
secondary:
"bg-secondary border border-input border text-secondary-foreground hover:bg-secondary/80 ",
"bg-muted border border-input border text-secondary-foreground hover:bg-muted/80 ",
ghost: "hover:bg-accent hover:text-accent-foreground",
squareOutlinePrimary:
"border border-primary bg-card hover:bg-primary/10 text-primary rounded-md ",

View File

@@ -38,7 +38,7 @@ const DialogContent = React.forwardRef<
<DialogPrimitive.Content
ref={ref}
className={cn(
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-card p-6 duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:scale-95 data-[state=open]:scale-100 sm:rounded-lg",
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-card px-6 pt-6 pb-4 duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 sm:rounded-lg",
className
)}
{...props}
@@ -59,7 +59,7 @@ const DialogHeader = ({
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col space-y-1.5 text-center sm:text-left",
"flex flex-col space-y-1.5 text-center sm:text-left mb-3",
className
)}
{...props}

View File

@@ -44,7 +44,7 @@ function DropdownMenuContent({
data-slot="dropdown-menu-content"
sideOffset={sideOffset}
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 max-h-(--radix-dropdown-menu-content-available-height) min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border p-1 shadow-sm",
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 z-50 max-h-(--radix-dropdown-menu-content-available-height) min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border p-1 shadow-sm",
className
)}
{...props}
@@ -237,7 +237,7 @@ function DropdownMenuSubContent({
<DropdownMenuPrimitive.SubContent
data-slot="dropdown-menu-sub-content"
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-sm",
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 z-50 min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-sm",
className
)}
{...props}

View File

@@ -29,7 +29,7 @@ function PopoverContent({
align={align}
sideOffset={sideOffset}
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-72 origin-(--radix-popover-content-transform-origin) rounded-md border p-4 shadow-sm outline-hidden",
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 z-50 w-72 origin-(--radix-popover-content-transform-origin) rounded-md border p-4 shadow-sm outline-hidden",
className
)}
{...props}

View File

@@ -60,7 +60,7 @@ function SelectContent({
<SelectPrimitive.Content
data-slot="select-content"
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 relative z-50 max-h-(--radix-select-content-available-height) min-w-[8rem] origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border shadow-sm",
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 relative z-50 max-h-(--radix-select-content-available-height) min-w-[8rem] origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border shadow-sm",
position === "popper" &&
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
className

View File

@@ -31,7 +31,7 @@ const SheetOverlay = React.forwardRef<
SheetOverlay.displayName = SheetPrimitive.Overlay.displayName;
const sheetVariants = cva(
"fixed z-50 gap-4 bg-card p-6 shadow-lg transition ease-in-out data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:duration-100 data-[state=open]:duration-300",
"fixed z-50 gap-4 bg-card px-6 pt-6 pb-1 shadow-lg transition ease-in-out data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:duration-100 data-[state=open]:duration-300",
{
variants: {
side: {

View File

@@ -45,7 +45,7 @@ function TooltipContent({
data-slot="tooltip-content"
sideOffset={sideOffset}
className={cn(
"bg-primary text-primary-foreground animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-fit origin-(--radix-tooltip-content-transform-origin) rounded-md px-3 py-1.5 text-xs text-balance",
"bg-primary text-primary-foreground animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-fit origin-(--radix-tooltip-content-transform-origin) rounded-md px-3 py-1.5 text-xs text-balance",
className
)}
{...props}

View File

@@ -0,0 +1,13 @@
import type { GetOrgResponse } from "@server/routers/org";
import type { AxiosResponse } from "axios";
import { cache } from "react";
import { authCookieHeader } from "./cookies";
import { internal } from ".";
import type { GetOrgUserResponse } from "@server/routers/user";
export const getCachedOrgUser = cache(async (orgId: string, userId: string) =>
internal.get<AxiosResponse<GetOrgUserResponse>>(
`/org/${orgId}/user/${userId}`,
await authCookieHeader()
)
);

View File

@@ -0,0 +1,8 @@
import type { AxiosResponse } from "axios";
import { cache } from "react";
import { priv } from ".";
import type { GetOrgTierResponse } from "@server/routers/billing/types";
export const getCachedSubscription = cache(async (orgId: string) =>
priv.get<AxiosResponse<GetOrgTierResponse>>(`/org/${orgId}/billing/tier`)
);

View File

@@ -0,0 +1,30 @@
import { build } from "@server/build";
import { TierId } from "@server/lib/billing/tiers";
import { cache } from "react";
import { getCachedSubscription } from "./getCachedSubscription";
import { priv } from ".";
import { AxiosResponse } from "axios";
import { GetLicenseStatusResponse } from "@server/routers/license/types";
export const isOrgSubscribed = cache(async (orgId: string) => {
let subscribed = false;
if (build === "enterprise") {
try {
const licenseStatusRes =
await priv.get<AxiosResponse<GetLicenseStatusResponse>>(
"/license/status"
);
subscribed = licenseStatusRes.data.data.isLicenseValid;
} catch (error) {}
} else if (build === "saas") {
try {
const subRes = await getCachedSubscription(orgId);
subscribed =
subRes.data.data.tier === TierId.STANDARD &&
subRes.data.data.active;
} catch {}
}
return subscribed;
});

View File

@@ -3,8 +3,9 @@ import { authCookieHeader } from "@app/lib/api/cookies";
import { GetUserResponse } from "@server/routers/user";
import { AxiosResponse } from "axios";
import { pullEnv } from "../pullEnv";
import { cache } from "react";
export async function verifySession({
export const verifySession = cache(async function ({
skipCheckVerifyEmail,
forceLogin
}: {
@@ -14,8 +15,12 @@ export async function verifySession({
const env = pullEnv();
try {
const search = new URLSearchParams();
if (forceLogin) {
search.set("forceLogin", "true");
}
const res = await internal.get<AxiosResponse<GetUserResponse>>(
`/user${forceLogin ? "?forceLogin=true" : ""}`,
`/user?${search.toString()}`,
await authCookieHeader()
);
@@ -37,4 +42,4 @@ export async function verifySession({
} catch (e) {
return null;
}
}
});

View File

@@ -79,7 +79,7 @@ export const productUpdatesQueries = {
}
return false;
},
enabled: enabled && (build === "oss" || build === "enterprise") // disabled in cloud version
enabled: enabled && build !== "saas" // disabled in cloud version
// because we don't need to listen for new versions there
})
};

View File

@@ -0,0 +1,17 @@
export function replacePlaceholder(
stringWithPlaceholder: string,
data: Record<string, string>
) {
let newString = stringWithPlaceholder;
const keys = Object.keys(data);
for (const key of keys) {
newString = newString.replace(
new RegExp(`{{${key}}}`, "gm"),
data[key]
);
}
return newString;
}