mirror of
https://github.com/fosrl/pangolin.git
synced 2026-02-08 05:56:38 +00:00
Merge branch 'dev' into refactor/save-button-positions
This commit is contained in:
29
.github/workflows/test.yml
vendored
29
.github/workflows/test.yml
vendored
@@ -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
|
||||
|
||||
16
Dockerfile
16
Dockerfile
@@ -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
|
||||
|
||||
36
cli/commands/clearExitNodes.ts
Normal file
36
cli/commands/clearExitNodes.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
};
|
||||
284
cli/commands/rotateServerSecret.ts
Normal file
284
cli/commands/rotateServerSecret.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
2272
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -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",
|
||||
|
||||
@@ -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 || [];
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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", {
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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", {
|
||||
|
||||
@@ -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
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
141
server/lib/ip.ts
141
server/lib/ip.ts
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
113
server/private/routers/loginPage/deleteLoginPageBranding.ts
Normal file
113
server/private/routers/loginPage/deleteLoginPageBranding.ts
Normal 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")
|
||||
);
|
||||
}
|
||||
}
|
||||
103
server/private/routers/loginPage/getLoginPageBranding.ts
Normal file
103
server/private/routers/loginPage/getLoginPageBranding.ts
Normal 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")
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
100
server/private/routers/loginPage/loadLoginPageBranding.ts
Normal file
100
server/private/routers/loginPage/loadLoginPageBranding.ts
Normal 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")
|
||||
);
|
||||
}
|
||||
}
|
||||
162
server/private/routers/loginPage/upsertLoginPageBranding.ts
Normal file
162
server/private/routers/loginPage/upsertLoginPageBranding.ts
Normal 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")
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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}`
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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
|
||||
};
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -89,7 +89,6 @@ export async function getResourceAuthInfo(
|
||||
resourcePassword,
|
||||
eq(resourcePassword.resourceId, resources.resourceId)
|
||||
)
|
||||
|
||||
.leftJoin(
|
||||
resourceHeaderAuth,
|
||||
eq(
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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}`);
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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 ? (
|
||||
|
||||
@@ -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"
|
||||
/>
|
||||
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
56
src/app/[orgId]/settings/general/auth-page/page.tsx
Normal file
56
src/app/[orgId]/settings/general/auth-page/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
@@ -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);
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -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"
|
||||
>
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
432
src/components/AuthPageBrandingForm.tsx
Normal file
432
src/components/AuthPageBrandingForm.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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 })
|
||||
);
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -19,7 +19,7 @@ export default function ExitNodeInfoCard({}: ExitNodeInfoCardProps) {
|
||||
|
||||
return (
|
||||
<Alert>
|
||||
<AlertDescription className="mt-4">
|
||||
<AlertDescription>
|
||||
<InfoSections cols={2}>
|
||||
<>
|
||||
<InfoSection>
|
||||
|
||||
@@ -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"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
@@ -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 =
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")}
|
||||
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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 />
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 ",
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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}
|
||||
|
||||
13
src/lib/api/getCachedOrgUser.ts
Normal file
13
src/lib/api/getCachedOrgUser.ts
Normal 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()
|
||||
)
|
||||
);
|
||||
8
src/lib/api/getCachedSubscription.ts
Normal file
8
src/lib/api/getCachedSubscription.ts
Normal 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`)
|
||||
);
|
||||
30
src/lib/api/isOrgSubscribed.ts
Normal file
30
src/lib/api/isOrgSubscribed.ts
Normal 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;
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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
|
||||
})
|
||||
};
|
||||
|
||||
17
src/lib/replacePlaceholder.ts
Normal file
17
src/lib/replacePlaceholder.ts
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user