mirror of
https://github.com/fosrl/pangolin.git
synced 2026-03-01 08:16:44 +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:
|
jobs:
|
||||||
test:
|
test:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
steps:
|
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:
|
with:
|
||||||
node-version: '22'
|
node-version: '22'
|
||||||
|
|
||||||
@@ -57,8 +58,26 @@ jobs:
|
|||||||
echo "App failed to start"
|
echo "App failed to start"
|
||||||
exit 1
|
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
|
- 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
|
- 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
|
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
|
FROM node:24-alpine AS runner
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
# Curl used for the health checks
|
# Only curl and tzdata needed at runtime - no build tools!
|
||||||
# Python and build tools needed for better-sqlite3 native compilation
|
RUN apk add --no-cache curl tzdata
|
||||||
RUN apk add --no-cache curl tzdata python3 make g++
|
|
||||||
|
|
||||||
# COPY package.json package-lock.json ./
|
# Copy pre-built node_modules from builder (already pruned to production only)
|
||||||
COPY package*.json ./
|
# This includes the compiled native modules like better-sqlite3
|
||||||
|
COPY --from=builder /app/node_modules ./node_modules
|
||||||
RUN npm ci --omit=dev && npm cache clean --force
|
|
||||||
|
|
||||||
COPY --from=builder /app/.next/standalone ./
|
COPY --from=builder /app/.next/standalone ./
|
||||||
COPY --from=builder /app/.next/static ./.next/static
|
COPY --from=builder /app/.next/static ./.next/static
|
||||||
COPY --from=builder /app/dist ./dist
|
COPY --from=builder /app/dist ./dist
|
||||||
COPY --from=builder /app/init ./dist/init
|
COPY --from=builder /app/init ./dist/init
|
||||||
|
COPY --from=builder /app/package.json ./package.json
|
||||||
|
|
||||||
COPY ./cli/wrapper.sh /usr/local/bin/pangctl
|
COPY ./cli/wrapper.sh /usr/local/bin/pangctl
|
||||||
RUN chmod +x /usr/local/bin/pangctl ./dist/cli.mjs
|
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 { hideBin } from "yargs/helpers";
|
||||||
import { setAdminCredentials } from "@cli/commands/setAdminCredentials";
|
import { setAdminCredentials } from "@cli/commands/setAdminCredentials";
|
||||||
import { resetUserSecurityKeys } from "@cli/commands/resetUserSecurityKeys";
|
import { resetUserSecurityKeys } from "@cli/commands/resetUserSecurityKeys";
|
||||||
|
import { clearExitNodes } from "./commands/clearExitNodes";
|
||||||
|
import { rotateServerSecret } from "./commands/rotateServerSecret";
|
||||||
|
|
||||||
yargs(hideBin(process.argv))
|
yargs(hideBin(process.argv))
|
||||||
.scriptName("pangctl")
|
.scriptName("pangctl")
|
||||||
.command(setAdminCredentials)
|
.command(setAdminCredentials)
|
||||||
.command(resetUserSecurityKeys)
|
.command(resetUserSecurityKeys)
|
||||||
|
.command(clearExitNodes)
|
||||||
|
.command(rotateServerSecret)
|
||||||
.demandCommand()
|
.demandCommand()
|
||||||
.help().argv;
|
.help().argv;
|
||||||
|
|||||||
@@ -100,6 +100,7 @@
|
|||||||
"siteTunnelDescription": "Determine how you want to connect to the site",
|
"siteTunnelDescription": "Determine how you want to connect to the site",
|
||||||
"siteNewtCredentials": "Credentials",
|
"siteNewtCredentials": "Credentials",
|
||||||
"siteNewtCredentialsDescription": "This is how the site will authenticate with the server",
|
"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",
|
"siteCredentialsSave": "Save the Credentials",
|
||||||
"siteCredentialsSaveDescription": "You will only be able to see this once. Make sure to copy it to a secure place.",
|
"siteCredentialsSaveDescription": "You will only be able to see this once. Make sure to copy it to a secure place.",
|
||||||
"siteInfo": "Site Information",
|
"siteInfo": "Site Information",
|
||||||
@@ -419,7 +420,7 @@
|
|||||||
"userErrorExistsDescription": "This user is already a member of the organization.",
|
"userErrorExistsDescription": "This user is already a member of the organization.",
|
||||||
"inviteError": "Failed to invite user",
|
"inviteError": "Failed to invite user",
|
||||||
"inviteErrorDescription": "An error occurred while inviting the user",
|
"inviteErrorDescription": "An error occurred while inviting the user",
|
||||||
"userInvited": "User invited",
|
"userInvited": "User Invited",
|
||||||
"userInvitedDescription": "The user has been successfully invited.",
|
"userInvitedDescription": "The user has been successfully invited.",
|
||||||
"userErrorCreate": "Failed to create user",
|
"userErrorCreate": "Failed to create user",
|
||||||
"userErrorCreateDescription": "An error occurred while creating the user",
|
"userErrorCreateDescription": "An error occurred while creating the user",
|
||||||
@@ -1035,6 +1036,7 @@
|
|||||||
"updateOrgUser": "Update Org User",
|
"updateOrgUser": "Update Org User",
|
||||||
"createOrgUser": "Create Org User",
|
"createOrgUser": "Create Org User",
|
||||||
"actionUpdateOrg": "Update Organization",
|
"actionUpdateOrg": "Update Organization",
|
||||||
|
"actionRemoveInvitation": "Remove Invitation",
|
||||||
"actionUpdateUser": "Update User",
|
"actionUpdateUser": "Update User",
|
||||||
"actionGetUser": "Get User",
|
"actionGetUser": "Get User",
|
||||||
"actionGetOrgUser": "Get Organization User",
|
"actionGetOrgUser": "Get Organization User",
|
||||||
@@ -1673,7 +1675,7 @@
|
|||||||
"autoLoginErrorNoRedirectUrl": "No redirect URL received from the identity provider.",
|
"autoLoginErrorNoRedirectUrl": "No redirect URL received from the identity provider.",
|
||||||
"autoLoginErrorGeneratingUrl": "Failed to generate authentication URL.",
|
"autoLoginErrorGeneratingUrl": "Failed to generate authentication URL.",
|
||||||
"remoteExitNodeManageRemoteExitNodes": "Remote Nodes",
|
"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",
|
"remoteExitNodes": "Nodes",
|
||||||
"searchRemoteExitNodes": "Search nodes...",
|
"searchRemoteExitNodes": "Search nodes...",
|
||||||
"remoteExitNodeAdd": "Add Node",
|
"remoteExitNodeAdd": "Add Node",
|
||||||
@@ -1683,20 +1685,22 @@
|
|||||||
"remoteExitNodeConfirmDelete": "Confirm Delete Node",
|
"remoteExitNodeConfirmDelete": "Confirm Delete Node",
|
||||||
"remoteExitNodeDelete": "Delete Node",
|
"remoteExitNodeDelete": "Delete Node",
|
||||||
"sidebarRemoteExitNodes": "Remote Nodes",
|
"sidebarRemoteExitNodes": "Remote Nodes",
|
||||||
|
"remoteExitNodeId": "ID",
|
||||||
|
"remoteExitNodeSecretKey": "Secret",
|
||||||
"remoteExitNodeCreate": {
|
"remoteExitNodeCreate": {
|
||||||
"title": "Create Node",
|
"title": "Create Remote Node",
|
||||||
"description": "Create a new node to extend network connectivity",
|
"description": "Create a new self-hosted remote relay and proxy server node",
|
||||||
"viewAllButton": "View All Nodes",
|
"viewAllButton": "View All Nodes",
|
||||||
"strategy": {
|
"strategy": {
|
||||||
"title": "Creation 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": {
|
"adopt": {
|
||||||
"title": "Adopt Node",
|
"title": "Adopt Node",
|
||||||
"description": "Choose this if you already have the credentials for the node."
|
"description": "Choose this if you already have the credentials for the node."
|
||||||
},
|
},
|
||||||
"generate": {
|
"generate": {
|
||||||
"title": "Generate Keys",
|
"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": {
|
"adopt": {
|
||||||
@@ -1809,9 +1813,30 @@
|
|||||||
"idpAzureDescription": "Microsoft Azure OAuth2/OIDC provider",
|
"idpAzureDescription": "Microsoft Azure OAuth2/OIDC provider",
|
||||||
"subnet": "Subnet",
|
"subnet": "Subnet",
|
||||||
"subnetDescription": "The subnet for this organization's network configuration.",
|
"subnetDescription": "The subnet for this organization's network configuration.",
|
||||||
"authPage": "Auth Page",
|
"customDomain": "Custom Domain",
|
||||||
"authPageDescription": "Configure the auth page for the organization",
|
"authPage": "Authentication Pages",
|
||||||
|
"authPageDescription": "Set a custom domain for the organization's authentication pages",
|
||||||
"authPageDomain": "Auth Page Domain",
|
"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",
|
"noDomainSet": "No domain set",
|
||||||
"changeDomain": "Change Domain",
|
"changeDomain": "Change Domain",
|
||||||
"selectDomain": "Select Domain",
|
"selectDomain": "Select Domain",
|
||||||
@@ -1820,7 +1845,7 @@
|
|||||||
"setAuthPageDomain": "Set Auth Page Domain",
|
"setAuthPageDomain": "Set Auth Page Domain",
|
||||||
"failedToFetchCertificate": "Failed to fetch certificate",
|
"failedToFetchCertificate": "Failed to fetch certificate",
|
||||||
"failedToRestartCertificate": "Failed to restart 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",
|
"selectDomainForOrgAuthPage": "Select a domain for the organization's authentication page",
|
||||||
"domainPickerProvidedDomain": "Provided Domain",
|
"domainPickerProvidedDomain": "Provided Domain",
|
||||||
"domainPickerFreeProvidedDomain": "Free Provided Domain",
|
"domainPickerFreeProvidedDomain": "Free Provided Domain",
|
||||||
@@ -1892,7 +1917,7 @@
|
|||||||
"securityPolicyChangeWarningText": "This will affect all users in the organization",
|
"securityPolicyChangeWarningText": "This will affect all users in the organization",
|
||||||
"authPageErrorUpdateMessage": "An error occurred while updating the auth page settings",
|
"authPageErrorUpdateMessage": "An error occurred while updating the auth page settings",
|
||||||
"authPageErrorUpdate": "Unable to update auth page",
|
"authPageErrorUpdate": "Unable to update auth page",
|
||||||
"authPageUpdated": "Auth page updated successfully",
|
"authPageDomainUpdated": "Auth page Domain updated successfully",
|
||||||
"healthCheckNotAvailable": "Local",
|
"healthCheckNotAvailable": "Local",
|
||||||
"rewritePath": "Rewrite Path",
|
"rewritePath": "Rewrite Path",
|
||||||
"rewritePathDescription": "Optionally rewrite the path before forwarding to the target.",
|
"rewritePathDescription": "Optionally rewrite the path before forwarding to the target.",
|
||||||
@@ -2280,5 +2305,17 @@
|
|||||||
"agent": "Agent",
|
"agent": "Agent",
|
||||||
"personalUseOnly": "Personal Use Only",
|
"personalUseOnly": "Personal Use Only",
|
||||||
"loginPageLicenseWatermark": "This instance is licensed for 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:sqlite:studio": "drizzle-kit studio --config=./drizzle.sqlite.config.ts",
|
||||||
"db:pg:studio": "drizzle-kit studio --config=./drizzle.pg.config.ts",
|
"db:pg:studio": "drizzle-kit studio --config=./drizzle.pg.config.ts",
|
||||||
"db:clear-migrations": "rm -rf server/migrations",
|
"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: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 any;' > server/build.ts && cp tsconfig.saas.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 any;' > server/build.ts && cp tsconfig.enterprise.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: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",
|
"set:pg": "echo 'export * from \"./pg\";\nexport const driver: \"pg\" | \"sqlite\" = \"pg\";' > server/db/index.ts",
|
||||||
"next:build": "next build",
|
"next:build": "next build",
|
||||||
|
|||||||
@@ -6,28 +6,28 @@ import { withReplicas } from "drizzle-orm/pg-core";
|
|||||||
function createDb() {
|
function createDb() {
|
||||||
const config = readConfigFile();
|
const config = readConfigFile();
|
||||||
|
|
||||||
if (!config.postgres) {
|
// check the environment variables for postgres config first before the config file
|
||||||
// check the environment variables for postgres config
|
if (process.env.POSTGRES_CONNECTION_STRING) {
|
||||||
if (process.env.POSTGRES_CONNECTION_STRING) {
|
config.postgres = {
|
||||||
config.postgres = {
|
connection_string: process.env.POSTGRES_CONNECTION_STRING
|
||||||
connection_string: process.env.POSTGRES_CONNECTION_STRING
|
};
|
||||||
};
|
if (process.env.POSTGRES_REPLICA_CONNECTION_STRINGS) {
|
||||||
if (process.env.POSTGRES_REPLICA_CONNECTION_STRINGS) {
|
const replicas =
|
||||||
const replicas =
|
process.env.POSTGRES_REPLICA_CONNECTION_STRINGS.split(",").map(
|
||||||
process.env.POSTGRES_REPLICA_CONNECTION_STRINGS.split(
|
(conn) => ({
|
||||||
","
|
|
||||||
).map((conn) => ({
|
|
||||||
connection_string: conn.trim()
|
connection_string: conn.trim()
|
||||||
}));
|
})
|
||||||
config.postgres.replicas = replicas;
|
);
|
||||||
}
|
config.postgres.replicas = replicas;
|
||||||
} else {
|
|
||||||
throw new Error(
|
|
||||||
"Postgres configuration is missing in the configuration file."
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!config.postgres) {
|
||||||
|
throw new Error(
|
||||||
|
"Postgres configuration is missing in the configuration file."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const connectionString = config.postgres?.connection_string;
|
const connectionString = config.postgres?.connection_string;
|
||||||
const replicaConnections = config.postgres?.replicas || [];
|
const replicaConnections = config.postgres?.replicas || [];
|
||||||
|
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ const runMigrations = async () => {
|
|||||||
await migrate(db as any, {
|
await migrate(db as any, {
|
||||||
migrationsFolder: migrationsFolder
|
migrationsFolder: migrationsFolder
|
||||||
});
|
});
|
||||||
console.log("Migrations completed successfully.");
|
console.log("Migrations completed successfully. ✅");
|
||||||
process.exit(0);
|
process.exit(0);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error running migrations:", error);
|
console.error("Error running migrations:", error);
|
||||||
|
|||||||
@@ -204,6 +204,29 @@ export const loginPageOrg = pgTable("loginPageOrg", {
|
|||||||
.references(() => orgs.orgId, { onDelete: "cascade" })
|
.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", {
|
export const sessionTransferToken = pgTable("sessionTransferToken", {
|
||||||
token: varchar("token").primaryKey(),
|
token: varchar("token").primaryKey(),
|
||||||
sessionId: varchar("sessionId")
|
sessionId: varchar("sessionId")
|
||||||
@@ -283,5 +306,6 @@ export type RemoteExitNodeSession = InferSelectModel<
|
|||||||
>;
|
>;
|
||||||
export type ExitNodeOrg = InferSelectModel<typeof exitNodeOrgs>;
|
export type ExitNodeOrg = InferSelectModel<typeof exitNodeOrgs>;
|
||||||
export type LoginPage = InferSelectModel<typeof loginPage>;
|
export type LoginPage = InferSelectModel<typeof loginPage>;
|
||||||
|
export type LoginPageBranding = InferSelectModel<typeof loginPageBranding>;
|
||||||
export type ActionAuditLog = InferSelectModel<typeof actionAuditLog>;
|
export type ActionAuditLog = InferSelectModel<typeof actionAuditLog>;
|
||||||
export type AccessAuditLog = InferSelectModel<typeof accessAuditLog>;
|
export type AccessAuditLog = InferSelectModel<typeof accessAuditLog>;
|
||||||
|
|||||||
@@ -7,7 +7,8 @@ import {
|
|||||||
bigint,
|
bigint,
|
||||||
real,
|
real,
|
||||||
text,
|
text,
|
||||||
index
|
index,
|
||||||
|
uniqueIndex
|
||||||
} from "drizzle-orm/pg-core";
|
} from "drizzle-orm/pg-core";
|
||||||
import { InferSelectModel } from "drizzle-orm";
|
import { InferSelectModel } from "drizzle-orm";
|
||||||
import { randomUUID } from "crypto";
|
import { randomUUID } from "crypto";
|
||||||
@@ -213,7 +214,10 @@ export const siteResources = pgTable("siteResources", {
|
|||||||
destination: varchar("destination").notNull(), // ip, cidr, hostname; validate against the mode
|
destination: varchar("destination").notNull(), // ip, cidr, hostname; validate against the mode
|
||||||
enabled: boolean("enabled").notNull().default(true),
|
enabled: boolean("enabled").notNull().default(true),
|
||||||
alias: varchar("alias"),
|
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", {
|
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 { InferSelectModel } from "drizzle-orm";
|
||||||
import { domains, orgs, targets, users, exitNodes, sessions } from "./schema";
|
import {
|
||||||
import { metadata } from "@app/app/[orgId]/settings/layout";
|
index,
|
||||||
|
integer,
|
||||||
|
real,
|
||||||
|
sqliteTable,
|
||||||
|
text
|
||||||
|
} from "drizzle-orm/sqlite-core";
|
||||||
|
import { domains, exitNodes, orgs, sessions, users } from "./schema";
|
||||||
|
|
||||||
export const certificates = sqliteTable("certificates", {
|
export const certificates = sqliteTable("certificates", {
|
||||||
certId: integer("certId").primaryKey({ autoIncrement: true }),
|
certId: integer("certId").primaryKey({ autoIncrement: true }),
|
||||||
@@ -203,6 +202,31 @@ export const loginPageOrg = sqliteTable("loginPageOrg", {
|
|||||||
.references(() => orgs.orgId, { onDelete: "cascade" })
|
.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", {
|
export const sessionTransferToken = sqliteTable("sessionTransferToken", {
|
||||||
token: text("token").primaryKey(),
|
token: text("token").primaryKey(),
|
||||||
sessionId: text("sessionId")
|
sessionId: text("sessionId")
|
||||||
@@ -282,5 +306,6 @@ export type RemoteExitNodeSession = InferSelectModel<
|
|||||||
>;
|
>;
|
||||||
export type ExitNodeOrg = InferSelectModel<typeof exitNodeOrgs>;
|
export type ExitNodeOrg = InferSelectModel<typeof exitNodeOrgs>;
|
||||||
export type LoginPage = InferSelectModel<typeof loginPage>;
|
export type LoginPage = InferSelectModel<typeof loginPage>;
|
||||||
|
export type LoginPageBranding = InferSelectModel<typeof loginPageBranding>;
|
||||||
export type ActionAuditLog = InferSelectModel<typeof actionAuditLog>;
|
export type ActionAuditLog = InferSelectModel<typeof actionAuditLog>;
|
||||||
export type AccessAuditLog = InferSelectModel<typeof accessAuditLog>;
|
export type AccessAuditLog = InferSelectModel<typeof accessAuditLog>;
|
||||||
|
|||||||
@@ -1,6 +1,12 @@
|
|||||||
import { randomUUID } from "crypto";
|
import { randomUUID } from "crypto";
|
||||||
import { InferSelectModel } from "drizzle-orm";
|
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";
|
import { no } from "zod/v4/locales";
|
||||||
|
|
||||||
export const domains = sqliteTable("domains", {
|
export const domains = sqliteTable("domains", {
|
||||||
@@ -234,7 +240,10 @@ export const siteResources = sqliteTable("siteResources", {
|
|||||||
destination: text("destination").notNull(), // ip, cidr, hostname
|
destination: text("destination").notNull(), // ip, cidr, hostname
|
||||||
enabled: integer("enabled", { mode: "boolean" }).notNull().default(true),
|
enabled: integer("enabled", { mode: "boolean" }).notNull().default(true),
|
||||||
alias: text("alias"),
|
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", {
|
export const clientSiteResources = sqliteTable("clientSiteResources", {
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ export async function sendEmail(
|
|||||||
from: string | undefined;
|
from: string | undefined;
|
||||||
to: string | undefined;
|
to: string | undefined;
|
||||||
subject: string;
|
subject: string;
|
||||||
|
replyTo?: string;
|
||||||
}
|
}
|
||||||
) {
|
) {
|
||||||
if (!emailClient) {
|
if (!emailClient) {
|
||||||
@@ -32,6 +33,7 @@ export async function sendEmail(
|
|||||||
address: opts.from
|
address: opts.from
|
||||||
},
|
},
|
||||||
to: opts.to,
|
to: opts.to,
|
||||||
|
replyTo: opts.replyTo,
|
||||||
subject: opts.subject,
|
subject: opts.subject,
|
||||||
html: emailHtml
|
html: emailHtml
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -90,7 +90,10 @@ export async function updateClientResources(
|
|||||||
destination: resourceData.destination,
|
destination: resourceData.destination,
|
||||||
enabled: true, // hardcoded for now
|
enabled: true, // hardcoded for now
|
||||||
// enabled: resourceData.enabled ?? true,
|
// 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(
|
.where(
|
||||||
eq(
|
eq(
|
||||||
@@ -217,7 +220,10 @@ export async function updateClientResources(
|
|||||||
destination: resourceData.destination,
|
destination: resourceData.destination,
|
||||||
enabled: true, // hardcoded for now
|
enabled: true, // hardcoded for now
|
||||||
// enabled: resourceData.enabled ?? true,
|
// 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();
|
.returning();
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
import { portRangeStringSchema } from "@server/lib/ip";
|
||||||
|
|
||||||
export const SiteSchema = z.object({
|
export const SiteSchema = z.object({
|
||||||
name: z.string().min(1).max(100),
|
name: z.string().min(1).max(100),
|
||||||
@@ -222,6 +223,9 @@ export const ClientResourceSchema = z
|
|||||||
// destinationPort: z.int().positive().optional(),
|
// destinationPort: z.int().positive().optional(),
|
||||||
destination: z.string().min(1),
|
destination: z.string().min(1),
|
||||||
// enabled: z.boolean().default(true),
|
// enabled: z.boolean().default(true),
|
||||||
|
"tcp-ports": portRangeStringSchema.optional().default("*"),
|
||||||
|
"udp-ports": portRangeStringSchema.optional().default("*"),
|
||||||
|
"disable-icmp": z.boolean().optional().default(false),
|
||||||
alias: z
|
alias: z
|
||||||
.string()
|
.string()
|
||||||
.regex(
|
.regex(
|
||||||
|
|||||||
141
server/lib/ip.ts
141
server/lib/ip.ts
@@ -1,10 +1,4 @@
|
|||||||
import {
|
import { db, SiteResource, siteResources, Transaction } from "@server/db";
|
||||||
clientSitesAssociationsCache,
|
|
||||||
db,
|
|
||||||
SiteResource,
|
|
||||||
siteResources,
|
|
||||||
Transaction
|
|
||||||
} from "@server/db";
|
|
||||||
import { clients, orgs, sites } from "@server/db";
|
import { clients, orgs, sites } from "@server/db";
|
||||||
import { and, eq, isNotNull } from "drizzle-orm";
|
import { and, eq, isNotNull } from "drizzle-orm";
|
||||||
import config from "@server/lib/config";
|
import config from "@server/lib/config";
|
||||||
@@ -472,10 +466,12 @@ export function generateAliasConfig(allSiteResources: SiteResource[]): Alias[] {
|
|||||||
export type SubnetProxyTarget = {
|
export type SubnetProxyTarget = {
|
||||||
sourcePrefix: string; // must be a cidr
|
sourcePrefix: string; // must be a cidr
|
||||||
destPrefix: string; // must be a cidr
|
destPrefix: string; // must be a cidr
|
||||||
|
disableIcmp?: boolean;
|
||||||
rewriteTo?: string; // must be a cidr
|
rewriteTo?: string; // must be a cidr
|
||||||
portRange?: {
|
portRange?: {
|
||||||
min: number;
|
min: number;
|
||||||
max: number;
|
max: number;
|
||||||
|
protocol: "tcp" | "udp";
|
||||||
}[];
|
}[];
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -505,6 +501,11 @@ export function generateSubnetProxyTargets(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const clientPrefix = `${clientSite.subnet.split("/")[0]}/32`;
|
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") {
|
if (siteResource.mode == "host") {
|
||||||
let destination = siteResource.destination;
|
let destination = siteResource.destination;
|
||||||
@@ -515,7 +516,9 @@ export function generateSubnetProxyTargets(
|
|||||||
|
|
||||||
targets.push({
|
targets.push({
|
||||||
sourcePrefix: clientPrefix,
|
sourcePrefix: clientPrefix,
|
||||||
destPrefix: destination
|
destPrefix: destination,
|
||||||
|
portRange,
|
||||||
|
disableIcmp
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -524,13 +527,17 @@ export function generateSubnetProxyTargets(
|
|||||||
targets.push({
|
targets.push({
|
||||||
sourcePrefix: clientPrefix,
|
sourcePrefix: clientPrefix,
|
||||||
destPrefix: `${siteResource.aliasAddress}/32`,
|
destPrefix: `${siteResource.aliasAddress}/32`,
|
||||||
rewriteTo: destination
|
rewriteTo: destination,
|
||||||
|
portRange,
|
||||||
|
disableIcmp
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
} else if (siteResource.mode == "cidr") {
|
} else if (siteResource.mode == "cidr") {
|
||||||
targets.push({
|
targets.push({
|
||||||
sourcePrefix: clientPrefix,
|
sourcePrefix: clientPrefix,
|
||||||
destPrefix: siteResource.destination
|
destPrefix: siteResource.destination,
|
||||||
|
portRange,
|
||||||
|
disableIcmp
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -542,3 +549,117 @@ export function generateSubnetProxyTargets(
|
|||||||
|
|
||||||
return targets;
|
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
|
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(
|
authRouter.post(
|
||||||
"/remoteExitNode/get-token",
|
"/remoteExitNode/get-token",
|
||||||
verifyValidLicense,
|
verifyValidLicense,
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ internalRouter.get("/org/:orgId/idp", orgIdp.listOrgIdps);
|
|||||||
internalRouter.get("/org/:orgId/billing/tier", billing.getOrgTier);
|
internalRouter.get("/org/:orgId/billing/tier", billing.getOrgTier);
|
||||||
|
|
||||||
internalRouter.get("/login-page", loginPage.loadLoginPage);
|
internalRouter.get("/login-page", loginPage.loadLoginPage);
|
||||||
|
internalRouter.get("/login-page-branding", loginPage.loadLoginPageBranding);
|
||||||
|
|
||||||
internalRouter.post(
|
internalRouter.post(
|
||||||
"/get-session-transfer-token",
|
"/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 "./loadLoginPage";
|
||||||
export * from "./updateLoginPage";
|
export * from "./updateLoginPage";
|
||||||
export * from "./deleteLoginPage";
|
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",
|
name: req.user?.email || "Support User",
|
||||||
to: "support@pangolin.net",
|
to: "support@pangolin.net",
|
||||||
|
replyTo: req.user?.email || undefined,
|
||||||
from: config.getNoReplyEmail(),
|
from: config.getNoReplyEmail(),
|
||||||
subject: `Support Request: ${subject}`
|
subject: `Support Request: ${subject}`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,21 +4,48 @@ import { Alias, SubnetProxyTarget } from "@server/lib/ip";
|
|||||||
import logger from "@server/logger";
|
import logger from "@server/logger";
|
||||||
import { eq } from "drizzle-orm";
|
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[]) {
|
export async function addTargets(newtId: string, targets: SubnetProxyTarget[]) {
|
||||||
await sendToClient(newtId, {
|
const batches = chunkArray(targets, BATCH_SIZE);
|
||||||
type: `newt/wg/targets/add`,
|
for (let i = 0; i < batches.length; i++) {
|
||||||
data: targets
|
if (i > 0) {
|
||||||
});
|
await sleep(BATCH_DELAY_MS);
|
||||||
|
}
|
||||||
|
await sendToClient(newtId, {
|
||||||
|
type: `newt/wg/targets/add`,
|
||||||
|
data: batches[i]
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function removeTargets(
|
export async function removeTargets(
|
||||||
newtId: string,
|
newtId: string,
|
||||||
targets: SubnetProxyTarget[]
|
targets: SubnetProxyTarget[]
|
||||||
) {
|
) {
|
||||||
await sendToClient(newtId, {
|
const batches = chunkArray(targets, BATCH_SIZE);
|
||||||
type: `newt/wg/targets/remove`,
|
for (let i = 0; i < batches.length; i++) {
|
||||||
data: targets
|
if (i > 0) {
|
||||||
});
|
await sleep(BATCH_DELAY_MS);
|
||||||
|
}
|
||||||
|
await sendToClient(newtId, {
|
||||||
|
type: `newt/wg/targets/remove`,
|
||||||
|
data: batches[i]
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function updateTargets(
|
export async function updateTargets(
|
||||||
@@ -28,12 +55,24 @@ export async function updateTargets(
|
|||||||
newTargets: SubnetProxyTarget[];
|
newTargets: SubnetProxyTarget[];
|
||||||
}
|
}
|
||||||
) {
|
) {
|
||||||
await sendToClient(newtId, {
|
const oldBatches = chunkArray(targets.oldTargets, BATCH_SIZE);
|
||||||
type: `newt/wg/targets/update`,
|
const newBatches = chunkArray(targets.newTargets, BATCH_SIZE);
|
||||||
data: targets
|
const maxBatches = Math.max(oldBatches.length, newBatches.length);
|
||||||
}).catch((error) => {
|
|
||||||
logger.warn(`Error sending message:`, error);
|
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(
|
export async function addPeerData(
|
||||||
|
|||||||
@@ -192,11 +192,71 @@ export async function validateOidcCallback(
|
|||||||
state
|
state
|
||||||
});
|
});
|
||||||
|
|
||||||
const tokens = await client.validateAuthorizationCode(
|
let tokens: arctic.OAuth2Tokens;
|
||||||
ensureTrailingSlash(existingIdp.idpOidcConfig.tokenUrl),
|
try {
|
||||||
code,
|
tokens = await client.validateAuthorizationCode(
|
||||||
codeVerifier
|
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();
|
const idToken = tokens.idToken();
|
||||||
logger.debug("ID token", { idToken });
|
logger.debug("ID token", { idToken });
|
||||||
|
|||||||
@@ -352,6 +352,14 @@ authenticated.post(
|
|||||||
user.inviteUser
|
user.inviteUser
|
||||||
);
|
);
|
||||||
|
|
||||||
|
authenticated.delete(
|
||||||
|
"/org/:orgId/invitations/:inviteId",
|
||||||
|
verifyApiKeyOrgAccess,
|
||||||
|
verifyApiKeyHasAction(ActionsEnum.removeInvitation),
|
||||||
|
logActionAudit(ActionsEnum.removeInvitation),
|
||||||
|
user.removeInvitation
|
||||||
|
);
|
||||||
|
|
||||||
authenticated.get(
|
authenticated.get(
|
||||||
"/resource/:resourceId/roles",
|
"/resource/:resourceId/roles",
|
||||||
verifyApiKeyResourceAccess,
|
verifyApiKeyResourceAccess,
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { LoginPage } from "@server/db";
|
import type { LoginPage, LoginPageBranding } from "@server/db";
|
||||||
|
|
||||||
export type CreateLoginPageResponse = LoginPage;
|
export type CreateLoginPageResponse = LoginPage;
|
||||||
|
|
||||||
@@ -9,3 +9,10 @@ export type GetLoginPageResponse = LoginPage;
|
|||||||
export type UpdateLoginPageResponse = LoginPage;
|
export type UpdateLoginPageResponse = LoginPage;
|
||||||
|
|
||||||
export type LoadLoginPageResponse = LoginPage & { orgId: string };
|
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",
|
type: "newt/wg/connect",
|
||||||
data: {
|
data: {
|
||||||
endpoint: `${exitNode.endpoint}:${exitNode.listenPort}`,
|
endpoint: `${exitNode.endpoint}:${exitNode.listenPort}`,
|
||||||
|
relayPort: config.getRawConfig().gerbil.clients_start_port,
|
||||||
publicKey: exitNode.publicKey,
|
publicKey: exitNode.publicKey,
|
||||||
serverIP: exitNode.address.split("/")[0],
|
serverIP: exitNode.address.split("/")[0],
|
||||||
tunnelIP: siteSubnet.split("/")[0],
|
tunnelIP: siteSubnet.split("/")[0],
|
||||||
|
|||||||
@@ -197,6 +197,7 @@ export async function getOlmToken(
|
|||||||
const exitNodesHpData = allExitNodes.map((exitNode: ExitNode) => {
|
const exitNodesHpData = allExitNodes.map((exitNode: ExitNode) => {
|
||||||
return {
|
return {
|
||||||
publicKey: exitNode.publicKey,
|
publicKey: exitNode.publicKey,
|
||||||
|
relayPort: config.getRawConfig().gerbil.clients_start_port,
|
||||||
endpoint: exitNode.endpoint
|
endpoint: exitNode.endpoint
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { clients, clientSitesAssociationsCache, Olm } from "@server/db";
|
|||||||
import { and, eq } from "drizzle-orm";
|
import { and, eq } from "drizzle-orm";
|
||||||
import { updatePeer as newtUpdatePeer } from "../newt/peers";
|
import { updatePeer as newtUpdatePeer } from "../newt/peers";
|
||||||
import logger from "@server/logger";
|
import logger from "@server/logger";
|
||||||
|
import config from "@server/lib/config";
|
||||||
|
|
||||||
export const handleOlmRelayMessage: MessageHandler = async (context) => {
|
export const handleOlmRelayMessage: MessageHandler = async (context) => {
|
||||||
const { message, client: c, sendToClient } = context;
|
const { message, client: c, sendToClient } = context;
|
||||||
@@ -88,7 +89,8 @@ export const handleOlmRelayMessage: MessageHandler = async (context) => {
|
|||||||
type: "olm/wg/peer/relay",
|
type: "olm/wg/peer/relay",
|
||||||
data: {
|
data: {
|
||||||
siteId: siteId,
|
siteId: siteId,
|
||||||
relayEndpoint: exitNode.endpoint
|
relayEndpoint: exitNode.endpoint,
|
||||||
|
relayPort: config.getRawConfig().gerbil.clients_start_port
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
broadcast: false,
|
broadcast: false,
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { sendToClient } from "#dynamic/routers/ws";
|
import { sendToClient } from "#dynamic/routers/ws";
|
||||||
import { db, olms } from "@server/db";
|
import { db, olms } from "@server/db";
|
||||||
|
import config from "@server/lib/config";
|
||||||
import logger from "@server/logger";
|
import logger from "@server/logger";
|
||||||
import { eq } from "drizzle-orm";
|
import { eq } from "drizzle-orm";
|
||||||
import { Alias } from "yaml";
|
import { Alias } from "yaml";
|
||||||
@@ -156,6 +157,7 @@ export async function initPeerAddHandshake(
|
|||||||
siteId: peer.siteId,
|
siteId: peer.siteId,
|
||||||
exitNode: {
|
exitNode: {
|
||||||
publicKey: peer.exitNode.publicKey,
|
publicKey: peer.exitNode.publicKey,
|
||||||
|
relayPort: config.getRawConfig().gerbil.clients_start_port,
|
||||||
endpoint: peer.exitNode.endpoint
|
endpoint: peer.exitNode.endpoint
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -89,7 +89,6 @@ export async function getResourceAuthInfo(
|
|||||||
resourcePassword,
|
resourcePassword,
|
||||||
eq(resourcePassword.resourceId, resources.resourceId)
|
eq(resourcePassword.resourceId, resources.resourceId)
|
||||||
)
|
)
|
||||||
|
|
||||||
.leftJoin(
|
.leftJoin(
|
||||||
resourceHeaderAuth,
|
resourceHeaderAuth,
|
||||||
eq(
|
eq(
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { db, exitNodes, newts } from "@server/db";
|
import { db, exitNodes, newts } from "@server/db";
|
||||||
import { orgs, roleSites, sites, userSites } from "@server/db";
|
import { orgs, roleSites, sites, userSites } from "@server/db";
|
||||||
|
import { remoteExitNodes } from "@server/db";
|
||||||
import logger from "@server/logger";
|
import logger from "@server/logger";
|
||||||
import HttpCode from "@server/types/HttpCode";
|
import HttpCode from "@server/types/HttpCode";
|
||||||
import response from "@server/lib/response";
|
import response from "@server/lib/response";
|
||||||
@@ -104,12 +105,17 @@ function querySites(orgId: string, accessibleSiteIds: number[]) {
|
|||||||
newtVersion: newts.version,
|
newtVersion: newts.version,
|
||||||
exitNodeId: sites.exitNodeId,
|
exitNodeId: sites.exitNodeId,
|
||||||
exitNodeName: exitNodes.name,
|
exitNodeName: exitNodes.name,
|
||||||
exitNodeEndpoint: exitNodes.endpoint
|
exitNodeEndpoint: exitNodes.endpoint,
|
||||||
|
remoteExitNodeId: remoteExitNodes.remoteExitNodeId
|
||||||
})
|
})
|
||||||
.from(sites)
|
.from(sites)
|
||||||
.leftJoin(orgs, eq(sites.orgId, orgs.orgId))
|
.leftJoin(orgs, eq(sites.orgId, orgs.orgId))
|
||||||
.leftJoin(newts, eq(newts.siteId, sites.siteId))
|
.leftJoin(newts, eq(newts.siteId, sites.siteId))
|
||||||
.leftJoin(exitNodes, eq(exitNodes.exitNodeId, sites.exitNodeId))
|
.leftJoin(exitNodes, eq(exitNodes.exitNodeId, sites.exitNodeId))
|
||||||
|
.leftJoin(
|
||||||
|
remoteExitNodes,
|
||||||
|
eq(remoteExitNodes.exitNodeId, sites.exitNodeId)
|
||||||
|
)
|
||||||
.where(
|
.where(
|
||||||
and(
|
and(
|
||||||
inArray(sites.siteId, accessibleSiteIds),
|
inArray(sites.siteId, accessibleSiteIds),
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import {
|
|||||||
userSiteResources
|
userSiteResources
|
||||||
} from "@server/db";
|
} from "@server/db";
|
||||||
import { getUniqueSiteResourceName } from "@server/db/names";
|
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 { rebuildClientAssociationsFromSiteResource } from "@server/lib/rebuildClientAssociations";
|
||||||
import response from "@server/lib/response";
|
import response from "@server/lib/response";
|
||||||
import logger from "@server/logger";
|
import logger from "@server/logger";
|
||||||
@@ -39,13 +39,16 @@ const createSiteResourceSchema = z
|
|||||||
alias: z
|
alias: z
|
||||||
.string()
|
.string()
|
||||||
.regex(
|
.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])?$/,
|
/^(?:[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)"
|
"Alias must be a fully qualified domain name with optional wildcards (e.g., example.com, *.example.com, host-0?.example.internal)"
|
||||||
)
|
)
|
||||||
.optional(),
|
.optional(),
|
||||||
userIds: z.array(z.string()),
|
userIds: z.array(z.string()),
|
||||||
roleIds: z.array(z.int()),
|
roleIds: z.array(z.int()),
|
||||||
clientIds: z.array(z.int())
|
clientIds: z.array(z.int()),
|
||||||
|
tcpPortRangeString: portRangeStringSchema,
|
||||||
|
udpPortRangeString: portRangeStringSchema,
|
||||||
|
disableIcmp: z.boolean().optional()
|
||||||
})
|
})
|
||||||
.strict()
|
.strict()
|
||||||
.refine(
|
.refine(
|
||||||
@@ -65,7 +68,7 @@ const createSiteResourceSchema = z
|
|||||||
const domainRegex =
|
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])?$/;
|
/^(?:[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 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
|
return isValidDomain && isValidAlias; // require the alias to be set in the case of domain
|
||||||
}
|
}
|
||||||
@@ -154,7 +157,10 @@ export async function createSiteResource(
|
|||||||
alias,
|
alias,
|
||||||
userIds,
|
userIds,
|
||||||
roleIds,
|
roleIds,
|
||||||
clientIds
|
clientIds,
|
||||||
|
tcpPortRangeString,
|
||||||
|
udpPortRangeString,
|
||||||
|
disableIcmp
|
||||||
} = parsedBody.data;
|
} = parsedBody.data;
|
||||||
|
|
||||||
// Verify the site exists and belongs to the org
|
// Verify the site exists and belongs to the org
|
||||||
@@ -239,7 +245,10 @@ export async function createSiteResource(
|
|||||||
destination,
|
destination,
|
||||||
enabled,
|
enabled,
|
||||||
alias,
|
alias,
|
||||||
aliasAddress
|
aliasAddress,
|
||||||
|
tcpPortRangeString,
|
||||||
|
udpPortRangeString,
|
||||||
|
disableIcmp
|
||||||
})
|
})
|
||||||
.returning();
|
.returning();
|
||||||
|
|
||||||
|
|||||||
@@ -97,6 +97,9 @@ export async function listAllSiteResourcesByOrg(
|
|||||||
destination: siteResources.destination,
|
destination: siteResources.destination,
|
||||||
enabled: siteResources.enabled,
|
enabled: siteResources.enabled,
|
||||||
alias: siteResources.alias,
|
alias: siteResources.alias,
|
||||||
|
tcpPortRangeString: siteResources.tcpPortRangeString,
|
||||||
|
udpPortRangeString: siteResources.udpPortRangeString,
|
||||||
|
disableIcmp: siteResources.disableIcmp,
|
||||||
siteName: sites.name,
|
siteName: sites.name,
|
||||||
siteNiceId: sites.niceId,
|
siteNiceId: sites.niceId,
|
||||||
siteAddress: sites.address
|
siteAddress: sites.address
|
||||||
|
|||||||
@@ -23,7 +23,8 @@ import { updatePeerData, updateTargets } from "@server/routers/client/targets";
|
|||||||
import {
|
import {
|
||||||
generateAliasConfig,
|
generateAliasConfig,
|
||||||
generateRemoteSubnets,
|
generateRemoteSubnets,
|
||||||
generateSubnetProxyTargets
|
generateSubnetProxyTargets,
|
||||||
|
portRangeStringSchema
|
||||||
} from "@server/lib/ip";
|
} from "@server/lib/ip";
|
||||||
import {
|
import {
|
||||||
getClientSiteResourceAccess,
|
getClientSiteResourceAccess,
|
||||||
@@ -49,13 +50,16 @@ const updateSiteResourceSchema = z
|
|||||||
alias: z
|
alias: z
|
||||||
.string()
|
.string()
|
||||||
.regex(
|
.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])?$/,
|
/^(?:[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)"
|
"Alias must be a fully qualified domain name with optional wildcards (e.g., example.internal, *.example.internal, host-0?.example.internal)"
|
||||||
)
|
)
|
||||||
.nullish(),
|
.nullish(),
|
||||||
userIds: z.array(z.string()),
|
userIds: z.array(z.string()),
|
||||||
roleIds: z.array(z.int()),
|
roleIds: z.array(z.int()),
|
||||||
clientIds: z.array(z.int())
|
clientIds: z.array(z.int()),
|
||||||
|
tcpPortRangeString: portRangeStringSchema,
|
||||||
|
udpPortRangeString: portRangeStringSchema,
|
||||||
|
disableIcmp: z.boolean().optional()
|
||||||
})
|
})
|
||||||
.strict()
|
.strict()
|
||||||
.refine(
|
.refine(
|
||||||
@@ -74,7 +78,7 @@ const updateSiteResourceSchema = z
|
|||||||
const domainRegex =
|
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])?$/;
|
/^(?:[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 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
|
return isValidDomain && isValidAlias; // require the alias to be set in the case of domain
|
||||||
}
|
}
|
||||||
@@ -160,7 +164,10 @@ export async function updateSiteResource(
|
|||||||
enabled,
|
enabled,
|
||||||
userIds,
|
userIds,
|
||||||
roleIds,
|
roleIds,
|
||||||
clientIds
|
clientIds,
|
||||||
|
tcpPortRangeString,
|
||||||
|
udpPortRangeString,
|
||||||
|
disableIcmp
|
||||||
} = parsedBody.data;
|
} = parsedBody.data;
|
||||||
|
|
||||||
const [site] = await db
|
const [site] = await db
|
||||||
@@ -226,7 +233,10 @@ export async function updateSiteResource(
|
|||||||
mode: mode,
|
mode: mode,
|
||||||
destination: destination,
|
destination: destination,
|
||||||
enabled: enabled,
|
enabled: enabled,
|
||||||
alias: alias && alias.trim() ? alias : null
|
alias: alias && alias.trim() ? alias : null,
|
||||||
|
tcpPortRangeString: tcpPortRangeString,
|
||||||
|
udpPortRangeString: udpPortRangeString,
|
||||||
|
disableIcmp: disableIcmp
|
||||||
})
|
})
|
||||||
.where(
|
.where(
|
||||||
and(
|
and(
|
||||||
@@ -348,10 +358,18 @@ export async function handleMessagingForUpdatedSiteResource(
|
|||||||
const aliasChanged =
|
const aliasChanged =
|
||||||
existingSiteResource &&
|
existingSiteResource &&
|
||||||
existingSiteResource.alias !== updatedSiteResource.alias;
|
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 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
|
const [newt] = await trx
|
||||||
.select()
|
.select()
|
||||||
.from(newts)
|
.from(newts)
|
||||||
@@ -365,7 +383,7 @@ export async function handleMessagingForUpdatedSiteResource(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Only update targets on newt if destination changed
|
// Only update targets on newt if destination changed
|
||||||
if (destinationChanged) {
|
if (destinationChanged || portRangesChanged) {
|
||||||
const oldTargets = generateSubnetProxyTargets(
|
const oldTargets = generateSubnetProxyTargets(
|
||||||
existingSiteResource,
|
existingSiteResource,
|
||||||
mergedAllClients
|
mergedAllClients
|
||||||
|
|||||||
@@ -8,12 +8,24 @@ import HttpCode from "@server/types/HttpCode";
|
|||||||
import createHttpError from "http-errors";
|
import createHttpError from "http-errors";
|
||||||
import logger from "@server/logger";
|
import logger from "@server/logger";
|
||||||
import { fromError } from "zod-validation-error";
|
import { fromError } from "zod-validation-error";
|
||||||
|
import { OpenAPITags, registry } from "@server/openApi";
|
||||||
|
|
||||||
const removeInvitationParamsSchema = z.strictObject({
|
const removeInvitationParamsSchema = z.strictObject({
|
||||||
orgId: z.string(),
|
orgId: z.string(),
|
||||||
inviteId: 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(
|
export async function removeInvitation(
|
||||||
req: Request,
|
req: Request,
|
||||||
res: Response,
|
res: Response,
|
||||||
|
|||||||
@@ -16,11 +16,23 @@ function generateToken(): string {
|
|||||||
return generateRandomString(random, alphabet, 32);
|
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 {
|
function generateId(length: number): string {
|
||||||
const alphabet = "abcdefghijklmnopqrstuvwxyz0123456789";
|
const alphabet = "abcdefghijklmnopqrstuvwxyz0123456789";
|
||||||
return generateRandomString(random, alphabet, length);
|
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() {
|
export async function ensureSetupToken() {
|
||||||
try {
|
try {
|
||||||
// Check if a server admin already exists
|
// Check if a server admin already exists
|
||||||
@@ -38,17 +50,48 @@ export async function ensureSetupToken() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Check if a setup token already exists
|
// Check if a setup token already exists
|
||||||
const existingTokens = await db
|
const [existingToken] = await db
|
||||||
.select()
|
.select()
|
||||||
.from(setupTokens)
|
.from(setupTokens)
|
||||||
.where(eq(setupTokens.used, false));
|
.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 unused token exists, display it instead of creating a new one
|
||||||
if (existingTokens.length > 0) {
|
if (existingToken) {
|
||||||
console.log("=== SETUP TOKEN EXISTS ===");
|
showSetupToken(existingToken.token, "EXISTS");
|
||||||
console.log("Token:", existingTokens[0].token);
|
|
||||||
console.log("Use this token on the initial setup page");
|
|
||||||
console.log("================================");
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -64,10 +107,7 @@ export async function ensureSetupToken() {
|
|||||||
dateUsed: null
|
dateUsed: null
|
||||||
});
|
});
|
||||||
|
|
||||||
console.log("=== SETUP TOKEN GENERATED ===");
|
showSetupToken(token, "GENERATED");
|
||||||
console.log("Token:", token);
|
|
||||||
console.log("Use this token on the initial setup page");
|
|
||||||
console.log("================================");
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to ensure setup token:", error);
|
console.error("Failed to ensure setup token:", error);
|
||||||
throw 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 SettingsSectionTitle from "@app/components/SettingsSectionTitle";
|
||||||
import { HorizontalTabs } from "@app/components/HorizontalTabs";
|
|
||||||
import { verifySession } from "@app/lib/auth/verifySession";
|
import { verifySession } from "@app/lib/auth/verifySession";
|
||||||
import OrgProvider from "@app/providers/OrgProvider";
|
import OrgProvider from "@app/providers/OrgProvider";
|
||||||
import OrgUserProvider from "@app/providers/OrgUserProvider";
|
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 { redirect } from "next/navigation";
|
||||||
import { cache } from "react";
|
|
||||||
import { getTranslations } from "next-intl/server";
|
import { getTranslations } from "next-intl/server";
|
||||||
|
import { getCachedOrgUser } from "@app/lib/api/getCachedOrgUser";
|
||||||
|
import { getCachedOrg } from "@app/lib/api/getCachedOrg";
|
||||||
|
|
||||||
type BillingSettingsProps = {
|
type BillingSettingsProps = {
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
@@ -23,8 +18,7 @@ export default async function BillingSettingsPage({
|
|||||||
}: BillingSettingsProps) {
|
}: BillingSettingsProps) {
|
||||||
const { orgId } = await params;
|
const { orgId } = await params;
|
||||||
|
|
||||||
const getUser = cache(verifySession);
|
const user = await verifySession();
|
||||||
const user = await getUser();
|
|
||||||
|
|
||||||
if (!user) {
|
if (!user) {
|
||||||
redirect(`/`);
|
redirect(`/`);
|
||||||
@@ -32,13 +26,7 @@ export default async function BillingSettingsPage({
|
|||||||
|
|
||||||
let orgUser = null;
|
let orgUser = null;
|
||||||
try {
|
try {
|
||||||
const getOrgUser = cache(async () =>
|
const res = await getCachedOrgUser(orgId, user.userId);
|
||||||
internal.get<AxiosResponse<GetOrgUserResponse>>(
|
|
||||||
`/org/${orgId}/user/${user.userId}`,
|
|
||||||
await authCookieHeader()
|
|
||||||
)
|
|
||||||
);
|
|
||||||
const res = await getOrgUser();
|
|
||||||
orgUser = res.data.data;
|
orgUser = res.data.data;
|
||||||
} catch {
|
} catch {
|
||||||
redirect(`/${orgId}`);
|
redirect(`/${orgId}`);
|
||||||
@@ -46,13 +34,7 @@ export default async function BillingSettingsPage({
|
|||||||
|
|
||||||
let org = null;
|
let org = null;
|
||||||
try {
|
try {
|
||||||
const getOrg = cache(async () =>
|
const res = await getCachedOrg(orgId);
|
||||||
internal.get<AxiosResponse<GetOrgResponse>>(
|
|
||||||
`/org/${orgId}`,
|
|
||||||
await authCookieHeader()
|
|
||||||
)
|
|
||||||
);
|
|
||||||
const res = await getOrg();
|
|
||||||
org = res.data.data;
|
org = res.data.data;
|
||||||
} catch {
|
} catch {
|
||||||
redirect(`/${orgId}`);
|
redirect(`/${orgId}`);
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { GetIdpResponse as GetOrgIdpResponse } from "@server/routers/idp";
|
|||||||
import { AxiosResponse } from "axios";
|
import { AxiosResponse } from "axios";
|
||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
import { authCookieHeader } from "@app/lib/api/cookies";
|
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 SettingsSectionTitle from "@app/components/SettingsSectionTitle";
|
||||||
import { getTranslations } from "next-intl/server";
|
import { getTranslations } from "next-intl/server";
|
||||||
|
|
||||||
@@ -28,7 +28,7 @@ export default async function SettingsLayout(props: SettingsLayoutProps) {
|
|||||||
redirect(`/${params.orgId}/settings/idp`);
|
redirect(`/${params.orgId}/settings/idp`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const navItems: HorizontalTabs = [
|
const navItems: TabItem[] = [
|
||||||
{
|
{
|
||||||
title: t("general"),
|
title: t("general"),
|
||||||
href: `/${params.orgId}/settings/idp/${params.idpId}/general`
|
href: `/${params.orgId}/settings/idp/${params.idpId}/general`
|
||||||
|
|||||||
@@ -331,29 +331,24 @@ export default function Page() {
|
|||||||
</form>
|
</form>
|
||||||
</Form>
|
</Form>
|
||||||
</SettingsSectionForm>
|
</SettingsSectionForm>
|
||||||
</SettingsSectionBody>
|
|
||||||
</SettingsSection>
|
|
||||||
|
|
||||||
<SettingsSection>
|
<div>
|
||||||
<SettingsSectionHeader>
|
<div className="mb-2">
|
||||||
<SettingsSectionTitle>
|
<span className="text-sm font-medium">
|
||||||
{t("idpType")}
|
{t("idpType")}
|
||||||
</SettingsSectionTitle>
|
</span>
|
||||||
<SettingsSectionDescription>
|
</div>
|
||||||
{t("idpTypeDescription")}
|
<StrategySelect
|
||||||
</SettingsSectionDescription>
|
options={providerTypes}
|
||||||
</SettingsSectionHeader>
|
defaultValue={form.getValues("type")}
|
||||||
<SettingsSectionBody>
|
onChange={(value) => {
|
||||||
<StrategySelect
|
handleProviderChange(
|
||||||
options={providerTypes}
|
value as "oidc" | "google" | "azure"
|
||||||
defaultValue={form.getValues("type")}
|
);
|
||||||
onChange={(value) => {
|
}}
|
||||||
handleProviderChange(
|
cols={3}
|
||||||
value as "oidc" | "google" | "azure"
|
/>
|
||||||
);
|
</div>
|
||||||
}}
|
|
||||||
cols={3}
|
|
||||||
/>
|
|
||||||
</SettingsSectionBody>
|
</SettingsSectionBody>
|
||||||
</SettingsSection>
|
</SettingsSection>
|
||||||
|
|
||||||
|
|||||||
@@ -26,7 +26,6 @@ import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog";
|
|||||||
import { useSubscriptionStatusContext } from "@app/hooks/useSubscriptionStatusContext";
|
import { useSubscriptionStatusContext } from "@app/hooks/useSubscriptionStatusContext";
|
||||||
import { useLicenseStatusContext } from "@app/hooks/useLicenseStatusContext";
|
import { useLicenseStatusContext } from "@app/hooks/useLicenseStatusContext";
|
||||||
import { build } from "@server/build";
|
import { build } from "@server/build";
|
||||||
import { SecurityFeaturesAlert } from "@app/components/SecurityFeaturesAlert";
|
|
||||||
import {
|
import {
|
||||||
InfoSection,
|
InfoSection,
|
||||||
InfoSectionContent,
|
InfoSectionContent,
|
||||||
@@ -36,6 +35,7 @@ import {
|
|||||||
import CopyToClipboard from "@app/components/CopyToClipboard";
|
import CopyToClipboard from "@app/components/CopyToClipboard";
|
||||||
import { Alert, AlertDescription, AlertTitle } from "@app/components/ui/alert";
|
import { Alert, AlertDescription, AlertTitle } from "@app/components/ui/alert";
|
||||||
import { InfoIcon } from "lucide-react";
|
import { InfoIcon } from "lucide-react";
|
||||||
|
import { PaidFeaturesAlert } from "@app/components/PaidFeaturesAlert";
|
||||||
|
|
||||||
export default function CredentialsPage() {
|
export default function CredentialsPage() {
|
||||||
const { env } = useEnvContext();
|
const { env } = useEnvContext();
|
||||||
@@ -131,19 +131,19 @@ export default function CredentialsPage() {
|
|||||||
<SettingsSection>
|
<SettingsSection>
|
||||||
<SettingsSectionHeader>
|
<SettingsSectionHeader>
|
||||||
<SettingsSectionTitle>
|
<SettingsSectionTitle>
|
||||||
{t("generatedcredentials")}
|
{t("credentials")}
|
||||||
</SettingsSectionTitle>
|
</SettingsSectionTitle>
|
||||||
<SettingsSectionDescription>
|
<SettingsSectionDescription>
|
||||||
{t("regenerateCredentials")}
|
{t("remoteNodeCredentialsDescription")}
|
||||||
</SettingsSectionDescription>
|
</SettingsSectionDescription>
|
||||||
</SettingsSectionHeader>
|
</SettingsSectionHeader>
|
||||||
<SettingsSectionBody>
|
<SettingsSectionBody>
|
||||||
<SecurityFeaturesAlert />
|
<PaidFeaturesAlert />
|
||||||
|
|
||||||
<InfoSections cols={3}>
|
<InfoSections cols={3}>
|
||||||
<InfoSection>
|
<InfoSection>
|
||||||
<InfoSectionTitle>
|
<InfoSectionTitle>
|
||||||
{t("endpoint") || "Endpoint"}
|
{t("endpoint")}
|
||||||
</InfoSectionTitle>
|
</InfoSectionTitle>
|
||||||
<InfoSectionContent>
|
<InfoSectionContent>
|
||||||
<CopyToClipboard
|
<CopyToClipboard
|
||||||
@@ -153,8 +153,7 @@ export default function CredentialsPage() {
|
|||||||
</InfoSection>
|
</InfoSection>
|
||||||
<InfoSection>
|
<InfoSection>
|
||||||
<InfoSectionTitle>
|
<InfoSectionTitle>
|
||||||
{t("remoteExitNodeId") ||
|
{t("remoteExitNodeId")}
|
||||||
"Remote Exit Node ID"}
|
|
||||||
</InfoSectionTitle>
|
</InfoSectionTitle>
|
||||||
<InfoSectionContent>
|
<InfoSectionContent>
|
||||||
{displayRemoteExitNodeId ? (
|
{displayRemoteExitNodeId ? (
|
||||||
@@ -168,7 +167,7 @@ export default function CredentialsPage() {
|
|||||||
</InfoSection>
|
</InfoSection>
|
||||||
<InfoSection>
|
<InfoSection>
|
||||||
<InfoSectionTitle>
|
<InfoSectionTitle>
|
||||||
{t("secretKey") || "Secret Key"}
|
{t("remoteExitNodeSecretKey")}
|
||||||
</InfoSectionTitle>
|
</InfoSectionTitle>
|
||||||
<InfoSectionContent>
|
<InfoSectionContent>
|
||||||
{displaySecret ? (
|
{displaySecret ? (
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ export default async function SettingsLayout(props: SettingsLayoutProps) {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<SettingsSectionTitle
|
<SettingsSectionTitle
|
||||||
title={`Remote Exit Node ${remoteExitNode?.name || "Unknown"}`}
|
title={`Remote Node ${remoteExitNode?.name || "Unknown"}`}
|
||||||
description="Manage your remote exit node settings and configuration"
|
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 { authCookieHeader } from "@app/lib/api/cookies";
|
||||||
import { ListRemoteExitNodesResponse } from "@server/routers/remoteExitNode/types";
|
import { ListRemoteExitNodesResponse } from "@server/routers/remoteExitNode/types";
|
||||||
import { AxiosResponse } from "axios";
|
import { AxiosResponse } from "axios";
|
||||||
import ExitNodesTable, { RemoteExitNodeRow } from "./ExitNodesTable";
|
import ExitNodesTable, {
|
||||||
|
RemoteExitNodeRow
|
||||||
|
} from "@app/components/ExitNodesTable";
|
||||||
import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
|
import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
|
||||||
import { getTranslations } from "next-intl/server";
|
import { getTranslations } from "next-intl/server";
|
||||||
|
|
||||||
|
|||||||
@@ -22,7 +22,6 @@ import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog";
|
|||||||
import { useLicenseStatusContext } from "@app/hooks/useLicenseStatusContext";
|
import { useLicenseStatusContext } from "@app/hooks/useLicenseStatusContext";
|
||||||
import { useSubscriptionStatusContext } from "@app/hooks/useSubscriptionStatusContext";
|
import { useSubscriptionStatusContext } from "@app/hooks/useSubscriptionStatusContext";
|
||||||
import { build } from "@server/build";
|
import { build } from "@server/build";
|
||||||
import { SecurityFeaturesAlert } from "@app/components/SecurityFeaturesAlert";
|
|
||||||
import {
|
import {
|
||||||
InfoSection,
|
InfoSection,
|
||||||
InfoSectionContent,
|
InfoSectionContent,
|
||||||
@@ -32,6 +31,7 @@ import {
|
|||||||
import CopyToClipboard from "@app/components/CopyToClipboard";
|
import CopyToClipboard from "@app/components/CopyToClipboard";
|
||||||
import { Alert, AlertDescription, AlertTitle } from "@app/components/ui/alert";
|
import { Alert, AlertDescription, AlertTitle } from "@app/components/ui/alert";
|
||||||
import { InfoIcon } from "lucide-react";
|
import { InfoIcon } from "lucide-react";
|
||||||
|
import { PaidFeaturesAlert } from "@app/components/PaidFeaturesAlert";
|
||||||
|
|
||||||
export default function CredentialsPage() {
|
export default function CredentialsPage() {
|
||||||
const { env } = useEnvContext();
|
const { env } = useEnvContext();
|
||||||
@@ -127,7 +127,7 @@ export default function CredentialsPage() {
|
|||||||
</SettingsSectionDescription>
|
</SettingsSectionDescription>
|
||||||
</SettingsSectionHeader>
|
</SettingsSectionHeader>
|
||||||
<SettingsSectionBody>
|
<SettingsSectionBody>
|
||||||
<SecurityFeaturesAlert />
|
<PaidFeaturesAlert />
|
||||||
|
|
||||||
<InfoSections cols={3}>
|
<InfoSections cols={3}>
|
||||||
<InfoSection>
|
<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 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 { verifySession } from "@app/lib/auth/verifySession";
|
||||||
import OrgProvider from "@app/providers/OrgProvider";
|
import OrgProvider from "@app/providers/OrgProvider";
|
||||||
import OrgUserProvider from "@app/providers/OrgUserProvider";
|
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 { redirect } from "next/navigation";
|
||||||
import { cache } from "react";
|
|
||||||
import { getTranslations } from "next-intl/server";
|
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 = {
|
type GeneralSettingsProps = {
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
@@ -23,8 +21,7 @@ export default async function GeneralSettingsPage({
|
|||||||
}: GeneralSettingsProps) {
|
}: GeneralSettingsProps) {
|
||||||
const { orgId } = await params;
|
const { orgId } = await params;
|
||||||
|
|
||||||
const getUser = cache(verifySession);
|
const user = await verifySession();
|
||||||
const user = await getUser();
|
|
||||||
|
|
||||||
if (!user) {
|
if (!user) {
|
||||||
redirect(`/`);
|
redirect(`/`);
|
||||||
@@ -32,13 +29,7 @@ export default async function GeneralSettingsPage({
|
|||||||
|
|
||||||
let orgUser = null;
|
let orgUser = null;
|
||||||
try {
|
try {
|
||||||
const getOrgUser = cache(async () =>
|
const res = await getCachedOrgUser(orgId, user.userId);
|
||||||
internal.get<AxiosResponse<GetOrgUserResponse>>(
|
|
||||||
`/org/${orgId}/user/${user.userId}`,
|
|
||||||
await authCookieHeader()
|
|
||||||
)
|
|
||||||
);
|
|
||||||
const res = await getOrgUser();
|
|
||||||
orgUser = res.data.data;
|
orgUser = res.data.data;
|
||||||
} catch {
|
} catch {
|
||||||
redirect(`/${orgId}`);
|
redirect(`/${orgId}`);
|
||||||
@@ -46,13 +37,7 @@ export default async function GeneralSettingsPage({
|
|||||||
|
|
||||||
let org = null;
|
let org = null;
|
||||||
try {
|
try {
|
||||||
const getOrg = cache(async () =>
|
const res = await getCachedOrg(orgId);
|
||||||
internal.get<AxiosResponse<GetOrgResponse>>(
|
|
||||||
`/org/${orgId}`,
|
|
||||||
await authCookieHeader()
|
|
||||||
)
|
|
||||||
);
|
|
||||||
const res = await getOrg();
|
|
||||||
org = res.data.data;
|
org = res.data.data;
|
||||||
} catch {
|
} catch {
|
||||||
redirect(`/${orgId}`);
|
redirect(`/${orgId}`);
|
||||||
@@ -60,12 +45,19 @@ export default async function GeneralSettingsPage({
|
|||||||
|
|
||||||
const t = await getTranslations();
|
const t = await getTranslations();
|
||||||
|
|
||||||
const navItems = [
|
const navItems: TabItem[] = [
|
||||||
{
|
{
|
||||||
title: t("general"),
|
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
|
|||||||
@@ -43,14 +43,13 @@ import {
|
|||||||
SettingsSectionTitle,
|
SettingsSectionTitle,
|
||||||
SettingsSectionDescription,
|
SettingsSectionDescription,
|
||||||
SettingsSectionBody,
|
SettingsSectionBody,
|
||||||
SettingsSectionForm,
|
SettingsSectionForm
|
||||||
SettingsSectionFooter
|
|
||||||
} from "@app/components/Settings";
|
} from "@app/components/Settings";
|
||||||
import { useUserContext } from "@app/hooks/useUserContext";
|
import { useUserContext } from "@app/hooks/useUserContext";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import { build } from "@server/build";
|
import { build } from "@server/build";
|
||||||
import { SwitchInput } from "@app/components/SwitchInput";
|
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 { useLicenseStatusContext } from "@app/hooks/useLicenseStatusContext";
|
||||||
import { useSubscriptionStatusContext } from "@app/hooks/useSubscriptionStatusContext";
|
import { useSubscriptionStatusContext } from "@app/hooks/useSubscriptionStatusContext";
|
||||||
import { usePaidStatus } from "@app/hooks/usePaidStatus";
|
import { usePaidStatus } from "@app/hooks/usePaidStatus";
|
||||||
@@ -113,29 +112,18 @@ const LOG_RETENTION_OPTIONS = [
|
|||||||
|
|
||||||
export default function GeneralPage() {
|
export default function GeneralPage() {
|
||||||
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
|
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
|
||||||
const { orgUser } = userOrgUserContext();
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { org } = useOrgContext();
|
const { org } = useOrgContext();
|
||||||
const api = createApiClient(useEnvContext());
|
const api = createApiClient(useEnvContext());
|
||||||
const { user } = useUserContext();
|
const { user } = useUserContext();
|
||||||
const t = useTranslations();
|
const t = useTranslations();
|
||||||
const { env } = useEnvContext();
|
const { env } = useEnvContext();
|
||||||
const { licenseStatus, isUnlocked } = useLicenseStatusContext();
|
const { isPaidUser, hasSaasSubscription } = usePaidStatus();
|
||||||
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 [loadingDelete, setLoadingDelete] = useState(false);
|
const [loadingDelete, setLoadingDelete] = useState(false);
|
||||||
const [loadingSave, setLoadingSave] = useState(false);
|
const [loadingSave, setLoadingSave] = useState(false);
|
||||||
const [isSecurityPolicyConfirmOpen, setIsSecurityPolicyConfirmOpen] =
|
const [isSecurityPolicyConfirmOpen, setIsSecurityPolicyConfirmOpen] =
|
||||||
useState(false);
|
useState(false);
|
||||||
const authPageSettingsRef = useRef<AuthPageSettingsRef>(null);
|
|
||||||
|
|
||||||
const form = useForm({
|
const form = useForm({
|
||||||
resolver: zodResolver(GeneralFormSchema),
|
resolver: zodResolver(GeneralFormSchema),
|
||||||
@@ -258,14 +246,6 @@ export default function GeneralPage() {
|
|||||||
// Update organization
|
// Update organization
|
||||||
await api.post(`/org/${org?.org.orgId}`, reqData);
|
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({
|
toast({
|
||||||
title: t("orgUpdated"),
|
title: t("orgUpdated"),
|
||||||
description: t("orgUpdatedDescription")
|
description: t("orgUpdatedDescription")
|
||||||
@@ -410,9 +390,7 @@ export default function GeneralPage() {
|
|||||||
{LOG_RETENTION_OPTIONS.filter(
|
{LOG_RETENTION_OPTIONS.filter(
|
||||||
(option) => {
|
(option) => {
|
||||||
if (
|
if (
|
||||||
build ==
|
hasSaasSubscription &&
|
||||||
"saas" &&
|
|
||||||
!subscription?.subscribed &&
|
|
||||||
option.value >
|
option.value >
|
||||||
30
|
30
|
||||||
) {
|
) {
|
||||||
@@ -440,19 +418,15 @@ export default function GeneralPage() {
|
|||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{build != "oss" && (
|
{build !== "oss" && (
|
||||||
<>
|
<>
|
||||||
<SecurityFeaturesAlert />
|
<PaidFeaturesAlert />
|
||||||
|
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name="settingsLogRetentionDaysAccess"
|
name="settingsLogRetentionDaysAccess"
|
||||||
render={({ field }) => {
|
render={({ field }) => {
|
||||||
const isDisabled =
|
const isDisabled = !isPaidUser;
|
||||||
(build == "saas" &&
|
|
||||||
!subscription?.subscribed) ||
|
|
||||||
(build == "enterprise" &&
|
|
||||||
!isUnlocked());
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
@@ -518,11 +492,7 @@ export default function GeneralPage() {
|
|||||||
control={form.control}
|
control={form.control}
|
||||||
name="settingsLogRetentionDaysAction"
|
name="settingsLogRetentionDaysAction"
|
||||||
render={({ field }) => {
|
render={({ field }) => {
|
||||||
const isDisabled =
|
const isDisabled = !isPaidUser;
|
||||||
(build == "saas" &&
|
|
||||||
!subscription?.subscribed) ||
|
|
||||||
(build == "enterprise" &&
|
|
||||||
!isUnlocked());
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
@@ -590,8 +560,7 @@ export default function GeneralPage() {
|
|||||||
</SettingsSectionBody>
|
</SettingsSectionBody>
|
||||||
|
|
||||||
{build !== "oss" && (
|
{build !== "oss" && (
|
||||||
<>
|
<SettingsSection>
|
||||||
<hr className="my-10 max-w-xl" />
|
|
||||||
<SettingsSectionHeader>
|
<SettingsSectionHeader>
|
||||||
<SettingsSectionTitle>
|
<SettingsSectionTitle>
|
||||||
{t("securitySettings")}
|
{t("securitySettings")}
|
||||||
@@ -601,14 +570,13 @@ export default function GeneralPage() {
|
|||||||
</SettingsSectionDescription>
|
</SettingsSectionDescription>
|
||||||
</SettingsSectionHeader>
|
</SettingsSectionHeader>
|
||||||
<SettingsSectionBody>
|
<SettingsSectionBody>
|
||||||
<SettingsSectionForm className="mb-4">
|
<SettingsSectionForm>
|
||||||
<SecurityFeaturesAlert />
|
<PaidFeaturesAlert />
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name="requireTwoFactor"
|
name="requireTwoFactor"
|
||||||
render={({ field }) => {
|
render={({ field }) => {
|
||||||
const isDisabled =
|
const isDisabled = !isPaidUser;
|
||||||
isSecurityFeatureDisabled();
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<FormItem className="col-span-2">
|
<FormItem className="col-span-2">
|
||||||
@@ -655,8 +623,7 @@ export default function GeneralPage() {
|
|||||||
control={form.control}
|
control={form.control}
|
||||||
name="maxSessionLengthHours"
|
name="maxSessionLengthHours"
|
||||||
render={({ field }) => {
|
render={({ field }) => {
|
||||||
const isDisabled =
|
const isDisabled = !isPaidUser;
|
||||||
isSecurityFeatureDisabled();
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<FormItem className="col-span-2">
|
<FormItem className="col-span-2">
|
||||||
@@ -744,8 +711,7 @@ export default function GeneralPage() {
|
|||||||
control={form.control}
|
control={form.control}
|
||||||
name="passwordExpiryDays"
|
name="passwordExpiryDays"
|
||||||
render={({ field }) => {
|
render={({ field }) => {
|
||||||
const isDisabled =
|
const isDisabled = !isPaidUser;
|
||||||
isSecurityFeatureDisabled();
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<FormItem className="col-span-2">
|
<FormItem className="col-span-2">
|
||||||
@@ -831,7 +797,7 @@ export default function GeneralPage() {
|
|||||||
/>
|
/>
|
||||||
</SettingsSectionForm>
|
</SettingsSectionForm>
|
||||||
</SettingsSectionBody>
|
</SettingsSectionBody>
|
||||||
</>
|
</SettingsSection>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="flex justify-end gap-2 mt-4">
|
<div className="flex justify-end gap-2 mt-4">
|
||||||
@@ -848,8 +814,6 @@ export default function GeneralPage() {
|
|||||||
</form>
|
</form>
|
||||||
</Form>
|
</Form>
|
||||||
|
|
||||||
{build === "saas" && <AuthPageSettings ref={authPageSettingsRef} />}
|
|
||||||
|
|
||||||
{build !== "saas" && (
|
{build !== "saas" && (
|
||||||
<SettingsSection>
|
<SettingsSection>
|
||||||
<SettingsSectionHeader>
|
<SettingsSectionHeader>
|
||||||
|
|||||||
@@ -67,7 +67,10 @@ export default async function ClientResourcesPage(
|
|||||||
// destinationPort: siteResource.destinationPort,
|
// destinationPort: siteResource.destinationPort,
|
||||||
alias: siteResource.alias || null,
|
alias: siteResource.alias || null,
|
||||||
siteNiceId: siteResource.siteNiceId,
|
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";
|
} from "@/components/ui/form";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { useResourceContext } from "@app/hooks/useResourceContext";
|
import { useResourceContext } from "@app/hooks/useResourceContext";
|
||||||
import { formatAxiosError } from "@app/lib/api";
|
|
||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
|
||||||
import { z } from "zod";
|
|
||||||
|
|
||||||
import {
|
import {
|
||||||
Credenza,
|
Credenza,
|
||||||
CredenzaBody,
|
CredenzaBody,
|
||||||
@@ -41,7 +37,7 @@ import { SwitchInput } from "@app/components/SwitchInput";
|
|||||||
import { Label } from "@app/components/ui/label";
|
import { Label } from "@app/components/ui/label";
|
||||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||||
import { toast } from "@app/hooks/useToast";
|
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 { finalizeSubdomainSanitize } from "@app/lib/subdomain-utils";
|
||||||
import { UpdateResourceResponse } from "@server/routers/resource";
|
import { UpdateResourceResponse } from "@server/routers/resource";
|
||||||
import { AxiosResponse } from "axios";
|
import { AxiosResponse } from "axios";
|
||||||
@@ -51,6 +47,8 @@ import { useParams, useRouter } from "next/navigation";
|
|||||||
import { toASCII, toUnicode } from "punycode";
|
import { toASCII, toUnicode } from "punycode";
|
||||||
import { useActionState, useMemo, useState } from "react";
|
import { useActionState, useMemo, useState } from "react";
|
||||||
import { useForm } from "react-hook-form";
|
import { useForm } from "react-hook-form";
|
||||||
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
|
import z from "zod";
|
||||||
|
|
||||||
export default function GeneralForm() {
|
export default function GeneralForm() {
|
||||||
const params = useParams();
|
const params = useParams();
|
||||||
@@ -69,28 +67,14 @@ export default function GeneralForm() {
|
|||||||
`${resource.ssl ? "https" : "http"}://${toUnicode(resource.fullDomain || "")}`
|
`${resource.ssl ? "https" : "http"}://${toUnicode(resource.fullDomain || "")}`
|
||||||
);
|
);
|
||||||
|
|
||||||
console.log({ resource });
|
const resourceFullDomainName = useMemo(() => {
|
||||||
|
const url = new URL(resourceFullDomain);
|
||||||
const [defaultSubdomain, defaultBaseDomain] = useMemo(() => {
|
return url.hostname;
|
||||||
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];
|
|
||||||
}, [resourceFullDomain]);
|
}, [resourceFullDomain]);
|
||||||
|
|
||||||
const [selectedDomain, setSelectedDomain] = useState<{
|
const [selectedDomain, setSelectedDomain] = useState<{
|
||||||
domainId: string;
|
domainId: string;
|
||||||
|
domainNamespaceId?: string;
|
||||||
subdomain?: string;
|
subdomain?: string;
|
||||||
fullDomain: string;
|
fullDomain: string;
|
||||||
baseDomain: string;
|
baseDomain: string;
|
||||||
@@ -177,7 +161,11 @@ export default function GeneralForm() {
|
|||||||
niceId: data.niceId,
|
niceId: data.niceId,
|
||||||
subdomain: data.subdomain,
|
subdomain: data.subdomain,
|
||||||
fullDomain: updated.fullDomain,
|
fullDomain: updated.fullDomain,
|
||||||
proxyPort: data.proxyPort
|
proxyPort: data.proxyPort,
|
||||||
|
domainId: data.domainId
|
||||||
|
// ...(!resource.http && {
|
||||||
|
// enableProxy: data.enableProxy
|
||||||
|
// })
|
||||||
});
|
});
|
||||||
|
|
||||||
toast({
|
toast({
|
||||||
@@ -359,9 +347,6 @@ export default function GeneralForm() {
|
|||||||
<SettingsSectionFooter>
|
<SettingsSectionFooter>
|
||||||
<Button
|
<Button
|
||||||
type="submit"
|
type="submit"
|
||||||
onClick={() => {
|
|
||||||
console.log(form.getValues());
|
|
||||||
}}
|
|
||||||
loading={saveLoading}
|
loading={saveLoading}
|
||||||
disabled={saveLoading}
|
disabled={saveLoading}
|
||||||
form="general-settings-form"
|
form="general-settings-form"
|
||||||
@@ -387,15 +372,26 @@ export default function GeneralForm() {
|
|||||||
<DomainPicker
|
<DomainPicker
|
||||||
orgId={orgId as string}
|
orgId={orgId as string}
|
||||||
cols={1}
|
cols={1}
|
||||||
defaultSubdomain={defaultSubdomain}
|
defaultSubdomain={
|
||||||
defaultBaseDomain={defaultBaseDomain}
|
form.watch("subdomain") ?? resource.subdomain
|
||||||
|
}
|
||||||
|
defaultDomainId={
|
||||||
|
form.watch("domainId") ?? resource.domainId
|
||||||
|
}
|
||||||
|
defaultFullDomain={resourceFullDomainName}
|
||||||
onDomainChange={(res) => {
|
onDomainChange={(res) => {
|
||||||
const selected = {
|
const selected =
|
||||||
domainId: res.domainId,
|
res === null
|
||||||
subdomain: res.subdomain,
|
? null
|
||||||
fullDomain: res.fullDomain,
|
: {
|
||||||
baseDomain: res.baseDomain
|
domainId: res.domainId,
|
||||||
};
|
subdomain: res.subdomain,
|
||||||
|
fullDomain: res.fullDomain,
|
||||||
|
baseDomain: res.baseDomain,
|
||||||
|
domainNamespaceId:
|
||||||
|
res.domainNamespaceId
|
||||||
|
};
|
||||||
|
|
||||||
setSelectedDomain(selected);
|
setSelectedDomain(selected);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1396,6 +1396,8 @@ export default function Page() {
|
|||||||
<DomainPicker
|
<DomainPicker
|
||||||
orgId={orgId as string}
|
orgId={orgId as string}
|
||||||
onDomainChange={(res) => {
|
onDomainChange={(res) => {
|
||||||
|
if (!res) return;
|
||||||
|
|
||||||
httpForm.setValue(
|
httpForm.setValue(
|
||||||
"subdomain",
|
"subdomain",
|
||||||
res.subdomain
|
res.subdomain
|
||||||
@@ -1848,7 +1850,7 @@ export default function Page() {
|
|||||||
|
|
||||||
<Link
|
<Link
|
||||||
className="text-sm text-primary flex items-center gap-1"
|
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"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -23,7 +23,6 @@ import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog";
|
|||||||
import { useLicenseStatusContext } from "@app/hooks/useLicenseStatusContext";
|
import { useLicenseStatusContext } from "@app/hooks/useLicenseStatusContext";
|
||||||
import { useSubscriptionStatusContext } from "@app/hooks/useSubscriptionStatusContext";
|
import { useSubscriptionStatusContext } from "@app/hooks/useSubscriptionStatusContext";
|
||||||
import { build } from "@server/build";
|
import { build } from "@server/build";
|
||||||
import { SecurityFeaturesAlert } from "@app/components/SecurityFeaturesAlert";
|
|
||||||
import {
|
import {
|
||||||
InfoSection,
|
InfoSection,
|
||||||
InfoSectionContent,
|
InfoSectionContent,
|
||||||
@@ -39,6 +38,7 @@ import {
|
|||||||
generateObfuscatedWireGuardConfig
|
generateObfuscatedWireGuardConfig
|
||||||
} from "@app/lib/wireguard";
|
} from "@app/lib/wireguard";
|
||||||
import { QRCodeCanvas } from "qrcode.react";
|
import { QRCodeCanvas } from "qrcode.react";
|
||||||
|
import { PaidFeaturesAlert } from "@app/components/PaidFeaturesAlert";
|
||||||
|
|
||||||
export default function CredentialsPage() {
|
export default function CredentialsPage() {
|
||||||
const { env } = useEnvContext();
|
const { env } = useEnvContext();
|
||||||
@@ -203,7 +203,7 @@ export default function CredentialsPage() {
|
|||||||
</SettingsSectionDescription>
|
</SettingsSectionDescription>
|
||||||
</SettingsSectionHeader>
|
</SettingsSectionHeader>
|
||||||
|
|
||||||
<SecurityFeaturesAlert />
|
<PaidFeaturesAlert />
|
||||||
|
|
||||||
<SettingsSectionBody>
|
<SettingsSectionBody>
|
||||||
<InfoSections cols={3}>
|
<InfoSections cols={3}>
|
||||||
@@ -300,7 +300,7 @@ export default function CredentialsPage() {
|
|||||||
</SettingsSectionDescription>
|
</SettingsSectionDescription>
|
||||||
</SettingsSectionHeader>
|
</SettingsSectionHeader>
|
||||||
|
|
||||||
<SecurityFeaturesAlert />
|
<PaidFeaturesAlert />
|
||||||
|
|
||||||
<SettingsSectionBody>
|
<SettingsSectionBody>
|
||||||
{!loadingDefaults && (
|
{!loadingDefaults && (
|
||||||
|
|||||||
@@ -53,7 +53,8 @@ export default async function SitesPage(props: SitesPageProps) {
|
|||||||
newtVersion: site.newtVersion || undefined,
|
newtVersion: site.newtVersion || undefined,
|
||||||
newtUpdateAvailable: site.newtUpdateAvailable || false,
|
newtUpdateAvailable: site.newtUpdateAvailable || false,
|
||||||
exitNodeName: site.exitNodeName || undefined,
|
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 { AxiosResponse } from "axios";
|
||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
import { authCookieHeader } from "@app/lib/api/cookies";
|
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 SettingsSectionTitle from "@app/components/SettingsSectionTitle";
|
||||||
import { getTranslations } from "next-intl/server";
|
import { getTranslations } from "next-intl/server";
|
||||||
|
|
||||||
@@ -28,7 +28,7 @@ export default async function SettingsLayout(props: SettingsLayoutProps) {
|
|||||||
redirect("/admin/idp");
|
redirect("/admin/idp");
|
||||||
}
|
}
|
||||||
|
|
||||||
const navItems: HorizontalTabs = [
|
const navItems: TabItem[] = [
|
||||||
{
|
{
|
||||||
title: t("general"),
|
title: t("general"),
|
||||||
href: `/admin/idp/${params.idpId}/general`
|
href: `/admin/idp/${params.idpId}/general`
|
||||||
|
|||||||
@@ -208,27 +208,23 @@ export default function Page() {
|
|||||||
</form>
|
</form>
|
||||||
</Form>
|
</Form>
|
||||||
</SettingsSectionForm>
|
</SettingsSectionForm>
|
||||||
</SettingsSectionBody>
|
|
||||||
</SettingsSection>
|
|
||||||
|
|
||||||
<SettingsSection>
|
<div>
|
||||||
<SettingsSectionHeader>
|
<div className="mb-2">
|
||||||
<SettingsSectionTitle>
|
<span className="text-sm font-medium">
|
||||||
{t("idpType")}
|
{t("idpType")}
|
||||||
</SettingsSectionTitle>
|
</span>
|
||||||
<SettingsSectionDescription>
|
</div>
|
||||||
{t("idpTypeDescription")}
|
|
||||||
</SettingsSectionDescription>
|
<StrategySelect
|
||||||
</SettingsSectionHeader>
|
options={providerTypes}
|
||||||
<SettingsSectionBody>
|
defaultValue={form.getValues("type")}
|
||||||
<StrategySelect
|
onChange={(value) => {
|
||||||
options={providerTypes}
|
form.setValue("type", value as "oidc");
|
||||||
defaultValue={form.getValues("type")}
|
}}
|
||||||
onChange={(value) => {
|
cols={3}
|
||||||
form.setValue("type", value as "oidc");
|
/>
|
||||||
}}
|
</div>
|
||||||
cols={3}
|
|
||||||
/>
|
|
||||||
</SettingsSectionBody>
|
</SettingsSectionBody>
|
||||||
</SettingsSection>
|
</SettingsSection>
|
||||||
|
|
||||||
|
|||||||
@@ -9,7 +9,10 @@ import { LoginFormIDP } from "@app/components/LoginForm";
|
|||||||
import { ListOrgIdpsResponse } from "@server/routers/orgIdp/types";
|
import { ListOrgIdpsResponse } from "@server/routers/orgIdp/types";
|
||||||
import { build } from "@server/build";
|
import { build } from "@server/build";
|
||||||
import { headers } from "next/headers";
|
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 IdpLoginButtons from "@app/components/private/IdpLoginButtons";
|
||||||
import {
|
import {
|
||||||
Card,
|
Card,
|
||||||
@@ -23,8 +26,8 @@ import Link from "next/link";
|
|||||||
import { getTranslations } from "next-intl/server";
|
import { getTranslations } from "next-intl/server";
|
||||||
import { GetSessionTransferTokenRenponse } from "@server/routers/auth/types";
|
import { GetSessionTransferTokenRenponse } from "@server/routers/auth/types";
|
||||||
import ValidateSessionTransferToken from "@app/components/private/ValidateSessionTransferToken";
|
import ValidateSessionTransferToken from "@app/components/private/ValidateSessionTransferToken";
|
||||||
import { GetOrgTierResponse } from "@server/routers/billing/types";
|
import { replacePlaceholder } from "@app/lib/replacePlaceholder";
|
||||||
import { TierId } from "@server/lib/billing/tiers";
|
import { isOrgSubscribed } from "@app/lib/api/isOrgSubscribed";
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
@@ -32,7 +35,6 @@ export default async function OrgAuthPage(props: {
|
|||||||
params: Promise<{}>;
|
params: Promise<{}>;
|
||||||
searchParams: Promise<{ token?: string }>;
|
searchParams: Promise<{ token?: string }>;
|
||||||
}) {
|
}) {
|
||||||
const params = await props.params;
|
|
||||||
const searchParams = await props.searchParams;
|
const searchParams = await props.searchParams;
|
||||||
|
|
||||||
const env = pullEnv();
|
const env = pullEnv();
|
||||||
@@ -73,22 +75,7 @@ export default async function OrgAuthPage(props: {
|
|||||||
redirect(env.app.dashboardUrl);
|
redirect(env.app.dashboardUrl);
|
||||||
}
|
}
|
||||||
|
|
||||||
let subscriptionStatus: GetOrgTierResponse | null = null;
|
const subscribed = await isOrgSubscribed(loginPage.orgId);
|
||||||
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;
|
|
||||||
|
|
||||||
if (build === "saas" && !subscribed) {
|
if (build === "saas" && !subscribed) {
|
||||||
console.log(
|
console.log(
|
||||||
@@ -126,12 +113,10 @@ export default async function OrgAuthPage(props: {
|
|||||||
|
|
||||||
let loginIdps: LoginFormIDP[] = [];
|
let loginIdps: LoginFormIDP[] = [];
|
||||||
if (build === "saas") {
|
if (build === "saas") {
|
||||||
const idpsRes = await cache(
|
const idpsRes = await priv.get<AxiosResponse<ListOrgIdpsResponse>>(
|
||||||
async () =>
|
`/org/${loginPage.orgId}/idp`
|
||||||
await priv.get<AxiosResponse<ListOrgIdpsResponse>>(
|
);
|
||||||
`/org/${loginPage!.orgId}/idp`
|
|
||||||
)
|
|
||||||
)();
|
|
||||||
loginIdps = idpsRes.data.data.idps.map((idp) => ({
|
loginIdps = idpsRes.data.data.idps.map((idp) => ({
|
||||||
idpId: idp.idpId,
|
idpId: idp.idpId,
|
||||||
name: idp.name,
|
name: idp.name,
|
||||||
@@ -139,6 +124,18 @@ export default async function OrgAuthPage(props: {
|
|||||||
})) as LoginFormIDP[];
|
})) 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 (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<div className="text-center mb-2">
|
<div className="text-center mb-2">
|
||||||
@@ -156,11 +153,30 @@ export default async function OrgAuthPage(props: {
|
|||||||
</div>
|
</div>
|
||||||
<Card className="w-full max-w-md">
|
<Card className="w-full max-w-md">
|
||||||
<CardHeader>
|
<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>
|
<CardDescription>
|
||||||
{loginIdps.length > 0
|
{branding?.orgSubtitle
|
||||||
? t("orgAuthChooseIdpDescription")
|
? replacePlaceholder(branding.orgSubtitle, {
|
||||||
: ""}
|
orgName: branding.orgName
|
||||||
|
})
|
||||||
|
: loginIdps.length > 0
|
||||||
|
? t("orgAuthChooseIdpDescription")
|
||||||
|
: ""}
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
|
|||||||
@@ -14,8 +14,11 @@ export const dynamic = "force-dynamic";
|
|||||||
export default async function Page(props: {
|
export default async function Page(props: {
|
||||||
params: Promise<{ orgId: string; idpId: string }>;
|
params: Promise<{ orgId: string; idpId: string }>;
|
||||||
searchParams: Promise<{
|
searchParams: Promise<{
|
||||||
code: string;
|
code?: string;
|
||||||
state: string;
|
state?: string;
|
||||||
|
error?: string;
|
||||||
|
error_description?: string;
|
||||||
|
error_uri?: string;
|
||||||
}>;
|
}>;
|
||||||
}) {
|
}) {
|
||||||
const params = await props.params;
|
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
<ValidateOidcToken
|
<ValidateOidcToken
|
||||||
@@ -71,6 +82,7 @@ export default async function Page(props: {
|
|||||||
expectedState={searchParams.state}
|
expectedState={searchParams.state}
|
||||||
stateCookie={stateCookie}
|
stateCookie={stateCookie}
|
||||||
idp={{ name: foundIdp.name }}
|
idp={{ name: foundIdp.name }}
|
||||||
|
providerError={providerError}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -19,11 +19,15 @@ import { ListOrgIdpsResponse } from "@server/routers/orgIdp/types";
|
|||||||
import AutoLoginHandler from "@app/components/AutoLoginHandler";
|
import AutoLoginHandler from "@app/components/AutoLoginHandler";
|
||||||
import { build } from "@server/build";
|
import { build } from "@server/build";
|
||||||
import { headers } from "next/headers";
|
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 { GetOrgTierResponse } from "@server/routers/billing/types";
|
||||||
import { TierId } from "@server/lib/billing/tiers";
|
import { TierId } from "@server/lib/billing/tiers";
|
||||||
import { CheckOrgUserAccessResponse } from "@server/routers/org";
|
import { CheckOrgUserAccessResponse } from "@server/routers/org";
|
||||||
import OrgPolicyRequired from "@app/components/OrgPolicyRequired";
|
import OrgPolicyRequired from "@app/components/OrgPolicyRequired";
|
||||||
|
import { isOrgSubscribed } from "@app/lib/api/isOrgSubscribed";
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
@@ -52,8 +56,7 @@ export default async function ResourceAuthPage(props: {
|
|||||||
}
|
}
|
||||||
} catch (e) {}
|
} catch (e) {}
|
||||||
|
|
||||||
const getUser = cache(verifySession);
|
const user = await verifySession({ skipCheckVerifyEmail: true });
|
||||||
const user = await getUser({ skipCheckVerifyEmail: true });
|
|
||||||
|
|
||||||
if (!authInfo) {
|
if (!authInfo) {
|
||||||
return (
|
return (
|
||||||
@@ -63,22 +66,7 @@ export default async function ResourceAuthPage(props: {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
let subscriptionStatus: GetOrgTierResponse | null = null;
|
const subscribed = await isOrgSubscribed(authInfo.orgId);
|
||||||
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 allHeaders = await headers();
|
const allHeaders = await headers();
|
||||||
const host = allHeaders.get("host");
|
const host = allHeaders.get("host");
|
||||||
@@ -89,9 +77,9 @@ export default async function ResourceAuthPage(props: {
|
|||||||
redirect(env.app.dashboardUrl);
|
redirect(env.app.dashboardUrl);
|
||||||
}
|
}
|
||||||
|
|
||||||
let loginPage: GetLoginPageResponse | undefined;
|
let loginPage: LoadLoginPageResponse | undefined;
|
||||||
try {
|
try {
|
||||||
const res = await priv.get<AxiosResponse<GetLoginPageResponse>>(
|
const res = await priv.get<AxiosResponse<LoadLoginPageResponse>>(
|
||||||
`/login-page?resourceId=${authInfo.resourceId}&fullDomain=${host}`
|
`/login-page?resourceId=${authInfo.resourceId}&fullDomain=${host}`
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -106,6 +94,7 @@ export default async function ResourceAuthPage(props: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let redirectUrl = authInfo.url;
|
let redirectUrl = authInfo.url;
|
||||||
|
|
||||||
if (searchParams.redirect) {
|
if (searchParams.redirect) {
|
||||||
try {
|
try {
|
||||||
const serverResourceHost = new URL(authInfo.url).host;
|
const serverResourceHost = new URL(authInfo.url).host;
|
||||||
@@ -230,9 +219,7 @@ export default async function ResourceAuthPage(props: {
|
|||||||
})) as LoginFormIDP[];
|
})) as LoginFormIDP[];
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
const idpsRes = await cache(
|
const idpsRes = await priv.get<AxiosResponse<ListIdpsResponse>>("/idp");
|
||||||
async () => await priv.get<AxiosResponse<ListIdpsResponse>>("/idp")
|
|
||||||
)();
|
|
||||||
loginIdps = idpsRes.data.data.idps.map((idp) => ({
|
loginIdps = idpsRes.data.data.idps.map((idp) => ({
|
||||||
idpId: idp.idpId,
|
idpId: idp.idpId,
|
||||||
name: idp.name,
|
name: idp.name,
|
||||||
@@ -253,12 +240,24 @@ export default async function ResourceAuthPage(props: {
|
|||||||
resourceId={authInfo.resourceId}
|
resourceId={authInfo.resourceId}
|
||||||
skipToIdpId={authInfo.skipToIdpId}
|
skipToIdpId={authInfo.skipToIdpId}
|
||||||
redirectUrl={redirectUrl}
|
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
{userIsUnauthorized && isSSOOnly ? (
|
{userIsUnauthorized && isSSOOnly ? (
|
||||||
@@ -281,6 +280,19 @@ export default async function ResourceAuthPage(props: {
|
|||||||
redirect={redirectUrl}
|
redirect={redirectUrl}
|
||||||
idps={loginIdps}
|
idps={loginIdps}
|
||||||
orgId={build === "saas" ? authInfo.orgId : undefined}
|
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>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -4,161 +4,124 @@
|
|||||||
|
|
||||||
@custom-variant dark (&:is(.dark *));
|
@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 {
|
:root {
|
||||||
--radius: 0.65rem;
|
--background: oklch(0.99 0 0);
|
||||||
--background: oklch(0.99 0 0);
|
--foreground: oklch(0.145 0 0);
|
||||||
--foreground: oklch(0.141 0.005 285.823);
|
--card: oklch(1 0 0);
|
||||||
--card: oklch(1 0 0);
|
--card-foreground: oklch(0.145 0 0);
|
||||||
--card-foreground: oklch(0.141 0.005 285.823);
|
--popover: oklch(1 0 0);
|
||||||
--popover: oklch(1 0 0);
|
--popover-foreground: oklch(0.145 0 0);
|
||||||
--popover-foreground: oklch(0.141 0.005 285.823);
|
--primary: oklch(0.6734 0.195 41.36);
|
||||||
--primary: oklch(0.6717 0.1946 41.93);
|
--primary-foreground: oklch(0.98 0.016 73.684);
|
||||||
--primary-foreground: oklch(0.98 0.016 73.684);
|
--secondary: oklch(0.967 0.001 286.375);
|
||||||
--secondary: oklch(0.967 0.001 286.375);
|
--secondary-foreground: oklch(0.21 0.006 285.885);
|
||||||
--secondary-foreground: oklch(0.21 0.006 285.885);
|
--muted: oklch(0.97 0 0);
|
||||||
--muted: oklch(0.967 0.001 286.375);
|
--muted-foreground: oklch(0.556 0 0);
|
||||||
--muted-foreground: oklch(0.552 0.016 285.938);
|
--accent: oklch(0.97 0 0);
|
||||||
--accent: oklch(0.967 0.001 286.375);
|
--accent-foreground: oklch(0.205 0 0);
|
||||||
--accent-foreground: oklch(0.21 0.006 285.885);
|
--destructive: oklch(0.58 0.22 27);
|
||||||
--destructive: oklch(0.577 0.245 27.325);
|
--border: oklch(0.922 0 0);
|
||||||
--border: oklch(0.92 0.004 286.32);
|
--input: oklch(0.922 0 0);
|
||||||
--input: oklch(0.92 0.004 286.32);
|
--ring: oklch(0.6734 0.195 41.36);
|
||||||
--ring: oklch(0.705 0.213 47.604);
|
--chart-1: oklch(0.837 0.128 66.29);
|
||||||
--chart-1: oklch(0.646 0.222 41.116);
|
--chart-2: oklch(0.705 0.213 47.604);
|
||||||
--chart-2: oklch(0.6 0.118 184.704);
|
--chart-3: oklch(0.646 0.222 41.116);
|
||||||
--chart-3: oklch(0.398 0.07 227.392);
|
--chart-4: oklch(0.553 0.195 38.402);
|
||||||
--chart-4: oklch(0.828 0.189 84.429);
|
--chart-5: oklch(0.47 0.157 37.304);
|
||||||
--chart-5: oklch(0.769 0.188 70.08);
|
--radius: 0.75rem;
|
||||||
--sidebar: oklch(0.985 0 0);
|
--sidebar: oklch(0.985 0 0);
|
||||||
--sidebar-foreground: oklch(0.141 0.005 285.823);
|
--sidebar-foreground: oklch(0.145 0 0);
|
||||||
--sidebar-primary: oklch(0.705 0.213 47.604);
|
--sidebar-primary: oklch(0.646 0.222 41.116);
|
||||||
--sidebar-primary-foreground: oklch(0.98 0.016 73.684);
|
--sidebar-primary-foreground: oklch(0.98 0.016 73.684);
|
||||||
--sidebar-accent: oklch(0.967 0.001 286.375);
|
--sidebar-accent: oklch(0.97 0 0);
|
||||||
--sidebar-accent-foreground: oklch(0.21 0.006 285.885);
|
--sidebar-accent-foreground: oklch(0.205 0 0);
|
||||||
--sidebar-border: oklch(0.92 0.004 286.32);
|
--sidebar-border: oklch(0.922 0 0);
|
||||||
--sidebar-ring: oklch(0.705 0.213 47.604);
|
--sidebar-ring: oklch(0.708 0 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
.dark {
|
.dark {
|
||||||
--background: oklch(0.2 0.006 285.885);
|
--background: oklch(0.160 0 0);
|
||||||
--foreground: oklch(0.985 0 0);
|
--foreground: oklch(0.985 0 0);
|
||||||
--card: oklch(0.21 0.006 285.885);
|
--card: oklch(0.205 0 0);
|
||||||
--card-foreground: oklch(0.985 0 0);
|
--card-foreground: oklch(0.985 0 0);
|
||||||
--popover: oklch(0.21 0.006 285.885);
|
--popover: oklch(0.205 0 0);
|
||||||
--popover-foreground: oklch(0.985 0 0);
|
--popover-foreground: oklch(0.985 0 0);
|
||||||
--primary: oklch(0.6717 0.1946 41.93);
|
--primary: oklch(0.6734 0.195 41.36);
|
||||||
--primary-foreground: oklch(0.98 0.016 73.684);
|
--primary-foreground: oklch(0.98 0.016 73.684);
|
||||||
--secondary: oklch(0.274 0.006 286.033);
|
--secondary: oklch(0.274 0.006 286.033);
|
||||||
--secondary-foreground: oklch(0.985 0 0);
|
--secondary-foreground: oklch(0.985 0 0);
|
||||||
--muted: oklch(0.274 0.006 286.033);
|
--muted: oklch(0.269 0 0);
|
||||||
--muted-foreground: oklch(0.705 0.015 286.067);
|
--muted-foreground: oklch(0.708 0 0);
|
||||||
--accent: oklch(0.274 0.006 286.033);
|
--accent: oklch(0.371 0 0);
|
||||||
--accent-foreground: oklch(0.985 0 0);
|
--accent-foreground: oklch(0.985 0 0);
|
||||||
--destructive: oklch(0.5382 0.1949 22.216);
|
--destructive: oklch(0.704 0.191 22.216);
|
||||||
--border: oklch(1 0 0 / 10%);
|
--border: oklch(1 0 0 / 10%);
|
||||||
--input: oklch(1 0 0 / 15%);
|
--input: oklch(1 0 0 / 15%);
|
||||||
--ring: oklch(0.646 0.222 41.116);
|
--ring: oklch(0.6734 0.195 41.36);
|
||||||
--chart-1: oklch(0.488 0.243 264.376);
|
--chart-1: oklch(0.837 0.128 66.29);
|
||||||
--chart-2: oklch(0.696 0.17 162.48);
|
--chart-2: oklch(0.705 0.213 47.604);
|
||||||
--chart-3: oklch(0.769 0.188 70.08);
|
--chart-3: oklch(0.646 0.222 41.116);
|
||||||
--chart-4: oklch(0.627 0.265 303.9);
|
--chart-4: oklch(0.553 0.195 38.402);
|
||||||
--chart-5: oklch(0.645 0.246 16.439);
|
--chart-5: oklch(0.47 0.157 37.304);
|
||||||
--sidebar: oklch(0.21 0.006 285.885);
|
--sidebar: oklch(0.205 0 0);
|
||||||
--sidebar-foreground: oklch(0.985 0 0);
|
--sidebar-foreground: oklch(0.985 0 0);
|
||||||
--sidebar-primary: oklch(0.646 0.222 41.116);
|
--sidebar-primary: oklch(0.705 0.213 47.604);
|
||||||
--sidebar-primary-foreground: oklch(0.98 0.016 73.684);
|
--sidebar-primary-foreground: oklch(0.98 0.016 73.684);
|
||||||
--sidebar-accent: oklch(0.274 0.006 286.033);
|
--sidebar-accent: oklch(0.269 0 0);
|
||||||
--sidebar-accent-foreground: oklch(0.985 0 0);
|
--sidebar-accent-foreground: oklch(0.985 0 0);
|
||||||
--sidebar-border: oklch(1 0 0 / 10%);
|
--sidebar-border: oklch(1 0 0 / 10%);
|
||||||
--sidebar-ring: oklch(0.646 0.222 41.116);
|
--sidebar-ring: oklch(0.556 0 0);
|
||||||
}
|
|
||||||
|
|
||||||
@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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@layer base {
|
@layer base {
|
||||||
*,
|
* {
|
||||||
::after,
|
@apply border-border outline-ring/50;
|
||||||
::before,
|
}
|
||||||
::backdrop,
|
body {
|
||||||
::file-selector-button {
|
@apply bg-background text-foreground;
|
||||||
border-color: var(--color-gray-200, currentcolor);
|
}
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@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 type { Metadata } from "next";
|
||||||
import "./globals.css";
|
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 { ThemeProvider } from "@app/providers/ThemeProvider";
|
||||||
import EnvProvider from "@app/providers/EnvProvider";
|
import EnvProvider from "@app/providers/EnvProvider";
|
||||||
import { pullEnv } from "@app/lib/pullEnv";
|
import { pullEnv } from "@app/lib/pullEnv";
|
||||||
@@ -30,7 +30,9 @@ export const metadata: Metadata = {
|
|||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
const font = Inter({ subsets: ["latin"] });
|
const font = Inter({
|
||||||
|
subsets: ["latin"]
|
||||||
|
});
|
||||||
|
|
||||||
export default async function RootLayout({
|
export default async function RootLayout({
|
||||||
children
|
children
|
||||||
|
|||||||
@@ -269,7 +269,7 @@ export default function UsersTable({ users }: Props) {
|
|||||||
</Button>
|
</Button>
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent align="end">
|
<DropdownMenuContent align="end">
|
||||||
{r.type !== "internal" && (
|
{r.type === "internal" && (
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
generatePasswordResetCode(r.id);
|
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";
|
import { useEffect, useState } from "react";
|
||||||
|
|
||||||
type BrandingLogoProps = {
|
type BrandingLogoProps = {
|
||||||
|
logoPath?: string;
|
||||||
width: number;
|
width: number;
|
||||||
height: number;
|
height: number;
|
||||||
};
|
};
|
||||||
@@ -41,13 +42,16 @@ export default function BrandingLogo(props: BrandingLogoProps) {
|
|||||||
return "/logo/word_mark_white.png";
|
return "/logo/word_mark_white.png";
|
||||||
}
|
}
|
||||||
|
|
||||||
const path = getPath();
|
setPath(props.logoPath ?? getPath());
|
||||||
setPath(path);
|
}, [theme, env, props.logoPath]);
|
||||||
}, [theme, env]);
|
|
||||||
|
// 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 (
|
return (
|
||||||
path && (
|
path && (
|
||||||
<Image
|
<Component
|
||||||
src={path}
|
src={path}
|
||||||
alt="Logo"
|
alt="Logo"
|
||||||
width={props.width}
|
width={props.width}
|
||||||
|
|||||||
@@ -41,6 +41,9 @@ export type InternalResourceRow = {
|
|||||||
// destinationPort: number | null;
|
// destinationPort: number | null;
|
||||||
alias: string | null;
|
alias: string | null;
|
||||||
niceId: string;
|
niceId: string;
|
||||||
|
tcpPortRangeString: string | null;
|
||||||
|
udpPortRangeString: string | null;
|
||||||
|
disableIcmp: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
type ClientResourcesTableProps = {
|
type ClientResourcesTableProps = {
|
||||||
|
|||||||
@@ -6,43 +6,22 @@ import {
|
|||||||
FormControl,
|
FormControl,
|
||||||
FormField,
|
FormField,
|
||||||
FormItem,
|
FormItem,
|
||||||
FormLabel,
|
|
||||||
FormMessage
|
FormMessage
|
||||||
} from "@app/components/ui/form";
|
} from "@app/components/ui/form";
|
||||||
import { Input } from "@app/components/ui/input";
|
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 { zodResolver } from "@hookform/resolvers/zod";
|
||||||
import {
|
import React, { useActionState } from "react";
|
||||||
InviteUserBody,
|
|
||||||
InviteUserResponse,
|
|
||||||
ListUsersResponse
|
|
||||||
} from "@server/routers/user";
|
|
||||||
import { AxiosResponse } from "axios";
|
|
||||||
import React, { useState } from "react";
|
|
||||||
import { useForm } from "react-hook-form";
|
import { useForm } from "react-hook-form";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import CopyTextBox from "@app/components/CopyTextBox";
|
|
||||||
import {
|
import {
|
||||||
Credenza,
|
Credenza,
|
||||||
CredenzaBody,
|
CredenzaBody,
|
||||||
CredenzaClose,
|
CredenzaClose,
|
||||||
CredenzaContent,
|
CredenzaContent,
|
||||||
CredenzaDescription,
|
|
||||||
CredenzaFooter,
|
CredenzaFooter,
|
||||||
CredenzaHeader,
|
CredenzaHeader,
|
||||||
CredenzaTitle
|
CredenzaTitle
|
||||||
} from "@app/components/Credenza";
|
} 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 { useTranslations } from "next-intl";
|
||||||
import CopyToClipboard from "./CopyToClipboard";
|
import CopyToClipboard from "./CopyToClipboard";
|
||||||
|
|
||||||
@@ -57,7 +36,7 @@ type InviteUserFormProps = {
|
|||||||
warningText?: string;
|
warningText?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function InviteUserForm({
|
export default function ConfirmDeleteDialog({
|
||||||
open,
|
open,
|
||||||
setOpen,
|
setOpen,
|
||||||
string,
|
string,
|
||||||
@@ -67,9 +46,7 @@ export default function InviteUserForm({
|
|||||||
dialog,
|
dialog,
|
||||||
warningText
|
warningText
|
||||||
}: InviteUserFormProps) {
|
}: InviteUserFormProps) {
|
||||||
const [loading, setLoading] = useState(false);
|
const [, formAction, loading] = useActionState(onSubmit, null);
|
||||||
|
|
||||||
const api = createApiClient(useEnvContext());
|
|
||||||
|
|
||||||
const t = useTranslations();
|
const t = useTranslations();
|
||||||
|
|
||||||
@@ -86,21 +63,14 @@ export default function InviteUserForm({
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
function reset() {
|
async function onSubmit() {
|
||||||
form.reset();
|
|
||||||
}
|
|
||||||
|
|
||||||
async function onSubmit(values: z.infer<typeof formSchema>) {
|
|
||||||
setLoading(true);
|
|
||||||
try {
|
try {
|
||||||
await onConfirm();
|
await onConfirm();
|
||||||
setOpen(false);
|
setOpen(false);
|
||||||
reset();
|
form.reset();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// Handle error if needed
|
// Handle error if needed
|
||||||
console.error("Confirmation failed:", error);
|
console.error("Confirmation failed:", error);
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -110,7 +80,7 @@ export default function InviteUserForm({
|
|||||||
open={open}
|
open={open}
|
||||||
onOpenChange={(val) => {
|
onOpenChange={(val) => {
|
||||||
setOpen(val);
|
setOpen(val);
|
||||||
reset();
|
form.reset();
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<CredenzaContent>
|
<CredenzaContent>
|
||||||
@@ -136,7 +106,7 @@ export default function InviteUserForm({
|
|||||||
</div>
|
</div>
|
||||||
<Form {...form}>
|
<Form {...form}>
|
||||||
<form
|
<form
|
||||||
onSubmit={form.handleSubmit(onSubmit)}
|
action={formAction}
|
||||||
className="space-y-4"
|
className="space-y-4"
|
||||||
id="confirm-delete-form"
|
id="confirm-delete-form"
|
||||||
>
|
>
|
||||||
@@ -146,7 +116,12 @@ export default function InviteUserForm({
|
|||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input {...field} />
|
<Input
|
||||||
|
{...field}
|
||||||
|
placeholder={t(
|
||||||
|
"enterConfirmation"
|
||||||
|
)}
|
||||||
|
/>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
</FormItem>
|
</FormItem>
|
||||||
|
|||||||
@@ -42,15 +42,14 @@ import {
|
|||||||
SelectTrigger,
|
SelectTrigger,
|
||||||
SelectValue
|
SelectValue
|
||||||
} from "@app/components/ui/select";
|
} from "@app/components/ui/select";
|
||||||
|
import { Switch } from "@app/components/ui/switch";
|
||||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||||
import { toast } from "@app/hooks/useToast";
|
import { toast } from "@app/hooks/useToast";
|
||||||
import { createApiClient, formatAxiosError } from "@app/lib/api";
|
import { createApiClient, formatAxiosError } from "@app/lib/api";
|
||||||
import { cn } from "@app/lib/cn";
|
import { cn } from "@app/lib/cn";
|
||||||
import { orgQueries } from "@app/lib/queries";
|
import { orgQueries } from "@app/lib/queries";
|
||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
import { ListClientsResponse } from "@server/routers/client/listClients";
|
|
||||||
import { ListSitesResponse } from "@server/routers/site";
|
import { ListSitesResponse } from "@server/routers/site";
|
||||||
import { ListUsersResponse } from "@server/routers/user";
|
|
||||||
import { UserType } from "@server/types/UserTypes";
|
import { UserType } from "@server/types/UserTypes";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { AxiosResponse } from "axios";
|
import { AxiosResponse } from "axios";
|
||||||
@@ -59,6 +58,82 @@ import { useTranslations } from "next-intl";
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { useForm } from "react-hook-form";
|
import { useForm } from "react-hook-form";
|
||||||
import { z } from "zod";
|
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];
|
type Site = ListSitesResponse["sites"][0];
|
||||||
|
|
||||||
@@ -103,6 +178,9 @@ export default function CreateInternalResourceDialog({
|
|||||||
// .max(65535, t("createInternalResourceDialogDestinationPortMax"))
|
// .max(65535, t("createInternalResourceDialogDestinationPortMax"))
|
||||||
// .nullish(),
|
// .nullish(),
|
||||||
alias: z.string().nullish(),
|
alias: z.string().nullish(),
|
||||||
|
tcpPortRangeString: portRangeStringSchema,
|
||||||
|
udpPortRangeString: portRangeStringSchema,
|
||||||
|
disableIcmp: z.boolean().optional(),
|
||||||
roles: z
|
roles: z
|
||||||
.array(
|
.array(
|
||||||
z.object({
|
z.object({
|
||||||
@@ -209,6 +287,12 @@ export default function CreateInternalResourceDialog({
|
|||||||
number | null
|
number | null
|
||||||
>(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(
|
const availableSites = sites.filter(
|
||||||
(site) => site.type === "newt" && site.subnet
|
(site) => site.type === "newt" && site.subnet
|
||||||
);
|
);
|
||||||
@@ -224,6 +308,9 @@ export default function CreateInternalResourceDialog({
|
|||||||
destination: "",
|
destination: "",
|
||||||
// destinationPort: undefined,
|
// destinationPort: undefined,
|
||||||
alias: "",
|
alias: "",
|
||||||
|
tcpPortRangeString: "*",
|
||||||
|
udpPortRangeString: "*",
|
||||||
|
disableIcmp: false,
|
||||||
roles: [],
|
roles: [],
|
||||||
users: [],
|
users: [],
|
||||||
clients: []
|
clients: []
|
||||||
@@ -232,6 +319,17 @@ export default function CreateInternalResourceDialog({
|
|||||||
|
|
||||||
const mode = form.watch("mode");
|
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)
|
// Helper function to check if destination contains letters (hostname vs IP)
|
||||||
const isHostname = (destination: string): boolean => {
|
const isHostname = (destination: string): boolean => {
|
||||||
return /[a-zA-Z]/.test(destination);
|
return /[a-zA-Z]/.test(destination);
|
||||||
@@ -258,10 +356,18 @@ export default function CreateInternalResourceDialog({
|
|||||||
destination: "",
|
destination: "",
|
||||||
// destinationPort: undefined,
|
// destinationPort: undefined,
|
||||||
alias: "",
|
alias: "",
|
||||||
|
tcpPortRangeString: "*",
|
||||||
|
udpPortRangeString: "*",
|
||||||
|
disableIcmp: false,
|
||||||
roles: [],
|
roles: [],
|
||||||
users: [],
|
users: [],
|
||||||
clients: []
|
clients: []
|
||||||
});
|
});
|
||||||
|
// Reset port mode state
|
||||||
|
setTcpPortMode("all");
|
||||||
|
setUdpPortMode("all");
|
||||||
|
setTcpCustomPorts("");
|
||||||
|
setUdpCustomPorts("");
|
||||||
}
|
}
|
||||||
}, [open]);
|
}, [open]);
|
||||||
|
|
||||||
@@ -304,6 +410,9 @@ export default function CreateInternalResourceDialog({
|
|||||||
data.alias.trim()
|
data.alias.trim()
|
||||||
? data.alias
|
? data.alias
|
||||||
: undefined,
|
: undefined,
|
||||||
|
tcpPortRangeString: data.tcpPortRangeString,
|
||||||
|
udpPortRangeString: data.udpPortRangeString,
|
||||||
|
disableIcmp: data.disableIcmp ?? false,
|
||||||
roleIds: data.roles
|
roleIds: data.roles
|
||||||
? data.roles.map((r) => parseInt(r.id))
|
? data.roles.map((r) => parseInt(r.id))
|
||||||
: [],
|
: [],
|
||||||
@@ -727,6 +836,163 @@ export default function CreateInternalResourceDialog({
|
|||||||
</div>
|
</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 */}
|
{/* Access Control Section */}
|
||||||
<div>
|
<div>
|
||||||
<h3 className="text-lg font-semibold mb-4">
|
<h3 className="text-lg font-semibold mb-4">
|
||||||
|
|||||||
@@ -179,7 +179,7 @@ const CredenzaFooter = ({ className, children, ...props }: CredenzaProps) => {
|
|||||||
return (
|
return (
|
||||||
<CredenzaFooter
|
<CredenzaFooter
|
||||||
className={cn(
|
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
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
|
|||||||
@@ -94,6 +94,12 @@ export default function DomainPicker({
|
|||||||
const api = createApiClient({ env });
|
const api = createApiClient({ env });
|
||||||
const t = useTranslations();
|
const t = useTranslations();
|
||||||
|
|
||||||
|
console.log({
|
||||||
|
defaultFullDomain,
|
||||||
|
defaultSubdomain,
|
||||||
|
defaultDomainId
|
||||||
|
});
|
||||||
|
|
||||||
const { data = [], isLoading: loadingDomains } = useQuery(
|
const { data = [], isLoading: loadingDomains } = useQuery(
|
||||||
orgQueries.domains({ orgId })
|
orgQueries.domains({ orgId })
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect, useMemo, useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import { Button } from "@app/components/ui/button";
|
import { Button } from "@app/components/ui/button";
|
||||||
import { Input } from "@app/components/ui/input";
|
import { Input } from "@app/components/ui/input";
|
||||||
import {
|
import {
|
||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
SelectTrigger,
|
SelectTrigger,
|
||||||
SelectValue
|
SelectValue
|
||||||
} from "@app/components/ui/select";
|
} from "@app/components/ui/select";
|
||||||
|
import { Switch } from "@app/components/ui/switch";
|
||||||
import { useForm } from "react-hook-form";
|
import { useForm } from "react-hook-form";
|
||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
@@ -36,17 +37,86 @@ import { toast } from "@app/hooks/useToast";
|
|||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import { createApiClient, formatAxiosError } from "@app/lib/api";
|
import { createApiClient, formatAxiosError } from "@app/lib/api";
|
||||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
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 { Tag, TagInput } from "@app/components/tags/tag-input";
|
||||||
import { AxiosResponse } from "axios";
|
|
||||||
import { UserType } from "@server/types/UserTypes";
|
import { UserType } from "@server/types/UserTypes";
|
||||||
import { useQueries, useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useQueries, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import { orgQueries, resourceQueries } from "@app/lib/queries";
|
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 = {
|
type InternalResourceData = {
|
||||||
id: number;
|
id: number;
|
||||||
@@ -61,6 +131,9 @@ type InternalResourceData = {
|
|||||||
destination: string;
|
destination: string;
|
||||||
// destinationPort?: number | null;
|
// destinationPort?: number | null;
|
||||||
alias?: string | null;
|
alias?: string | null;
|
||||||
|
tcpPortRangeString?: string | null;
|
||||||
|
udpPortRangeString?: string | null;
|
||||||
|
disableIcmp?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
type EditInternalResourceDialogProps = {
|
type EditInternalResourceDialogProps = {
|
||||||
@@ -94,6 +167,9 @@ export default function EditInternalResourceDialog({
|
|||||||
destination: z.string().min(1),
|
destination: z.string().min(1),
|
||||||
// destinationPort: z.int().positive().min(1, t("editInternalResourceDialogDestinationPortMin")).max(65535, t("editInternalResourceDialogDestinationPortMax")).nullish(),
|
// destinationPort: z.int().positive().min(1, t("editInternalResourceDialogDestinationPortMin")).max(65535, t("editInternalResourceDialogDestinationPortMax")).nullish(),
|
||||||
alias: z.string().nullish(),
|
alias: z.string().nullish(),
|
||||||
|
tcpPortRangeString: portRangeStringSchema,
|
||||||
|
udpPortRangeString: portRangeStringSchema,
|
||||||
|
disableIcmp: z.boolean().optional(),
|
||||||
roles: z
|
roles: z
|
||||||
.array(
|
.array(
|
||||||
z.object({
|
z.object({
|
||||||
@@ -255,6 +331,24 @@ export default function EditInternalResourceDialog({
|
|||||||
number | null
|
number | null
|
||||||
>(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>({
|
const form = useForm<FormData>({
|
||||||
resolver: zodResolver(formSchema),
|
resolver: zodResolver(formSchema),
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
@@ -265,6 +359,9 @@ export default function EditInternalResourceDialog({
|
|||||||
destination: resource.destination || "",
|
destination: resource.destination || "",
|
||||||
// destinationPort: resource.destinationPort ?? undefined,
|
// destinationPort: resource.destinationPort ?? undefined,
|
||||||
alias: resource.alias ?? null,
|
alias: resource.alias ?? null,
|
||||||
|
tcpPortRangeString: resource.tcpPortRangeString ?? "*",
|
||||||
|
udpPortRangeString: resource.udpPortRangeString ?? "*",
|
||||||
|
disableIcmp: resource.disableIcmp ?? false,
|
||||||
roles: [],
|
roles: [],
|
||||||
users: [],
|
users: [],
|
||||||
clients: []
|
clients: []
|
||||||
@@ -273,6 +370,17 @@ export default function EditInternalResourceDialog({
|
|||||||
|
|
||||||
const mode = form.watch("mode");
|
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)
|
// Helper function to check if destination contains letters (hostname vs IP)
|
||||||
const isHostname = (destination: string): boolean => {
|
const isHostname = (destination: string): boolean => {
|
||||||
return /[a-zA-Z]/.test(destination);
|
return /[a-zA-Z]/.test(destination);
|
||||||
@@ -327,6 +435,9 @@ export default function EditInternalResourceDialog({
|
|||||||
data.alias.trim()
|
data.alias.trim()
|
||||||
? data.alias
|
? data.alias
|
||||||
: null,
|
: null,
|
||||||
|
tcpPortRangeString: data.tcpPortRangeString,
|
||||||
|
udpPortRangeString: data.udpPortRangeString,
|
||||||
|
disableIcmp: data.disableIcmp ?? false,
|
||||||
roleIds: (data.roles || []).map((r) => parseInt(r.id)),
|
roleIds: (data.roles || []).map((r) => parseInt(r.id)),
|
||||||
userIds: (data.users || []).map((u) => u.id),
|
userIds: (data.users || []).map((u) => u.id),
|
||||||
clientIds: (data.clients || []).map((c) => parseInt(c.id))
|
clientIds: (data.clients || []).map((c) => parseInt(c.id))
|
||||||
@@ -396,10 +507,26 @@ export default function EditInternalResourceDialog({
|
|||||||
mode: resource.mode || "host",
|
mode: resource.mode || "host",
|
||||||
destination: resource.destination || "",
|
destination: resource.destination || "",
|
||||||
alias: resource.alias ?? null,
|
alias: resource.alias ?? null,
|
||||||
|
tcpPortRangeString: resource.tcpPortRangeString ?? "*",
|
||||||
|
udpPortRangeString: resource.udpPortRangeString ?? "*",
|
||||||
|
disableIcmp: resource.disableIcmp ?? false,
|
||||||
roles: [],
|
roles: [],
|
||||||
users: [],
|
users: [],
|
||||||
clients: []
|
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;
|
previousResourceId.current = resource.id;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -438,10 +565,26 @@ export default function EditInternalResourceDialog({
|
|||||||
destination: resource.destination || "",
|
destination: resource.destination || "",
|
||||||
// destinationPort: resource.destinationPort ?? undefined,
|
// destinationPort: resource.destinationPort ?? undefined,
|
||||||
alias: resource.alias ?? null,
|
alias: resource.alias ?? null,
|
||||||
|
tcpPortRangeString: resource.tcpPortRangeString ?? "*",
|
||||||
|
udpPortRangeString: resource.udpPortRangeString ?? "*",
|
||||||
|
disableIcmp: resource.disableIcmp ?? false,
|
||||||
roles: [],
|
roles: [],
|
||||||
users: [],
|
users: [],
|
||||||
clients: []
|
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
|
// Reset previous resource ID to ensure clean state on next open
|
||||||
previousResourceId.current = null;
|
previousResourceId.current = null;
|
||||||
}
|
}
|
||||||
@@ -674,6 +817,163 @@ export default function EditInternalResourceDialog({
|
|||||||
</div>
|
</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 */}
|
{/* Access Control Section */}
|
||||||
<div>
|
<div>
|
||||||
<h3 className="text-lg font-semibold mb-4">
|
<h3 className="text-lg font-semibold mb-4">
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ export default function ExitNodeInfoCard({}: ExitNodeInfoCardProps) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Alert>
|
<Alert>
|
||||||
<AlertDescription className="mt-4">
|
<AlertDescription>
|
||||||
<InfoSections cols={2}>
|
<InfoSections cols={2}>
|
||||||
<>
|
<>
|
||||||
<InfoSection>
|
<InfoSection>
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ interface DataTableProps<TData, TValue> {
|
|||||||
createRemoteExitNode?: () => void;
|
createRemoteExitNode?: () => void;
|
||||||
onRefresh?: () => void;
|
onRefresh?: () => void;
|
||||||
isRefreshing?: boolean;
|
isRefreshing?: boolean;
|
||||||
|
columnVisibility?: Record<string, boolean>;
|
||||||
|
enableColumnVisibility?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ExitNodesDataTable<TData, TValue>({
|
export function ExitNodesDataTable<TData, TValue>({
|
||||||
@@ -17,7 +19,9 @@ export function ExitNodesDataTable<TData, TValue>({
|
|||||||
data,
|
data,
|
||||||
createRemoteExitNode,
|
createRemoteExitNode,
|
||||||
onRefresh,
|
onRefresh,
|
||||||
isRefreshing
|
isRefreshing,
|
||||||
|
columnVisibility,
|
||||||
|
enableColumnVisibility
|
||||||
}: DataTableProps<TData, TValue>) {
|
}: DataTableProps<TData, TValue>) {
|
||||||
const t = useTranslations();
|
const t = useTranslations();
|
||||||
|
|
||||||
@@ -36,6 +40,10 @@ export function ExitNodesDataTable<TData, TValue>({
|
|||||||
id: "name",
|
id: "name",
|
||||||
desc: false
|
desc: false
|
||||||
}}
|
}}
|
||||||
|
columnVisibility={columnVisibility}
|
||||||
|
enableColumnVisibility={enableColumnVisibility}
|
||||||
|
stickyLeftColumn="name"
|
||||||
|
stickyRightColumn="actions"
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import { ColumnDef } from "@tanstack/react-table";
|
import { ColumnDef } from "@tanstack/react-table";
|
||||||
import { ExtendedColumnDef } from "@app/components/ui/data-table";
|
import { ExtendedColumnDef } from "@app/components/ui/data-table";
|
||||||
import { ExitNodesDataTable } from "./ExitNodesDataTable";
|
import { ExitNodesDataTable } from "@app/components/ExitNodesDataTable";
|
||||||
import {
|
import {
|
||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
DropdownMenuContent,
|
DropdownMenuContent,
|
||||||
@@ -246,12 +246,13 @@ export default function ExitNodesTable({
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "actions",
|
id: "actions",
|
||||||
header: () => <span className="p-3">{t("actions")}</span>,
|
enableHiding: false,
|
||||||
|
header: () => <span className="p-3"></span>,
|
||||||
cell: ({ row }) => {
|
cell: ({ row }) => {
|
||||||
const nodeRow = row.original;
|
const nodeRow = row.original;
|
||||||
const remoteExitNodeId = nodeRow.id;
|
const remoteExitNodeId = nodeRow.id;
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2 justify-end">
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
<DropdownMenuTrigger asChild>
|
<DropdownMenuTrigger asChild>
|
||||||
<Button variant="ghost" className="h-8 w-8 p-0">
|
<Button variant="ghost" className="h-8 w-8 p-0">
|
||||||
@@ -283,7 +284,7 @@ export default function ExitNodesTable({
|
|||||||
<Link
|
<Link
|
||||||
href={`/${nodeRow.orgId}/settings/remote-exit-nodes/${remoteExitNodeId}`}
|
href={`/${nodeRow.orgId}/settings/remote-exit-nodes/${remoteExitNodeId}`}
|
||||||
>
|
>
|
||||||
<Button variant={"secondary"} size="sm">
|
<Button variant={"outline"}>
|
||||||
{t("edit")}
|
{t("edit")}
|
||||||
<ArrowRight className="ml-2 w-4 h-4" />
|
<ArrowRight className="ml-2 w-4 h-4" />
|
||||||
</Button>
|
</Button>
|
||||||
@@ -327,6 +328,11 @@ export default function ExitNodesTable({
|
|||||||
}
|
}
|
||||||
onRefresh={refreshData}
|
onRefresh={refreshData}
|
||||||
isRefreshing={isRefreshing}
|
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 { useLicenseStatusContext } from "@app/hooks/useLicenseStatusContext";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
|
|
||||||
export type HorizontalTabs = Array<{
|
export type TabItem = {
|
||||||
title: string;
|
title: string;
|
||||||
href: string;
|
href: string;
|
||||||
icon?: React.ReactNode;
|
icon?: React.ReactNode;
|
||||||
showProfessional?: boolean;
|
showProfessional?: boolean;
|
||||||
}>;
|
exact?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
interface HorizontalTabsProps {
|
interface HorizontalTabsProps {
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
items: HorizontalTabs;
|
items: TabItem[];
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -38,7 +39,8 @@ export function HorizontalTabs({
|
|||||||
.replace("{niceId}", params.niceId as string)
|
.replace("{niceId}", params.niceId as string)
|
||||||
.replace("{userId}", params.userId as string)
|
.replace("{userId}", params.userId as string)
|
||||||
.replace("{clientId}", params.clientId 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 (
|
return (
|
||||||
@@ -49,8 +51,11 @@ export function HorizontalTabs({
|
|||||||
{items.map((item) => {
|
{items.map((item) => {
|
||||||
const hydratedHref = hydrateHref(item.href);
|
const hydratedHref = hydrateHref(item.href);
|
||||||
const isActive =
|
const isActive =
|
||||||
pathname.startsWith(hydratedHref) &&
|
(item.exact
|
||||||
|
? pathname === hydratedHref
|
||||||
|
: pathname.startsWith(hydratedHref)) &&
|
||||||
!pathname.includes("create");
|
!pathname.includes("create");
|
||||||
|
|
||||||
const isProfessional =
|
const isProfessional =
|
||||||
item.showProfessional && !isUnlocked();
|
item.showProfessional && !isUnlocked();
|
||||||
const isDisabled =
|
const isDisabled =
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ export function LayoutHeader({ showTopBar }: LayoutHeaderProps) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="absolute top-0 left-0 right-0 z-50 hidden md:block">
|
<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="relative z-10 px-6 py-2">
|
||||||
<div className="container mx-auto max-w-12xl">
|
<div className="container mx-auto max-w-12xl">
|
||||||
<div className="h-16 flex items-center justify-between">
|
<div className="h-16 flex items-center justify-between">
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ export function LayoutMobileMenu({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="shrink-0 md:hidden">
|
<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">
|
<div className="flex items-center gap-4">
|
||||||
{showSidebar && (
|
{showSidebar && (
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
@@ -91,15 +91,12 @@ export function LogAnalyticsData(props: AnalyticsContentProps) {
|
|||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
const percentBlocked = stats
|
const percentBlocked =
|
||||||
? new Intl.NumberFormat(navigator.language, {
|
stats && stats.totalRequests > 0
|
||||||
maximumFractionDigits: 2
|
? new Intl.NumberFormat(navigator.language, {
|
||||||
}).format(
|
maximumFractionDigits: 2
|
||||||
stats.totalRequests
|
}).format((stats.totalBlocked / stats.totalRequests) * 100)
|
||||||
? (stats.totalBlocked / stats.totalRequests) * 100
|
: null;
|
||||||
: 0
|
|
||||||
)
|
|
||||||
: null;
|
|
||||||
const totalRequests = stats
|
const totalRequests = stats
|
||||||
? new Intl.NumberFormat(navigator.language, {
|
? new Intl.NumberFormat(navigator.language, {
|
||||||
maximumFractionDigits: 0
|
maximumFractionDigits: 0
|
||||||
|
|||||||
@@ -2,17 +2,14 @@
|
|||||||
import { Alert, AlertDescription } from "@app/components/ui/alert";
|
import { Alert, AlertDescription } from "@app/components/ui/alert";
|
||||||
import { build } from "@server/build";
|
import { build } from "@server/build";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import { useLicenseStatusContext } from "@app/hooks/useLicenseStatusContext";
|
import { usePaidStatus } from "@app/hooks/usePaidStatus";
|
||||||
import { useSubscriptionStatusContext } from "@app/hooks/useSubscriptionStatusContext";
|
|
||||||
|
|
||||||
export function SecurityFeaturesAlert() {
|
export function PaidFeaturesAlert() {
|
||||||
const t = useTranslations();
|
const t = useTranslations();
|
||||||
const { isUnlocked } = useLicenseStatusContext();
|
const { hasSaasSubscription, hasEnterpriseLicense } = usePaidStatus();
|
||||||
const subscriptionStatus = useSubscriptionStatusContext();
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{build === "saas" && !subscriptionStatus?.isSubscribed() ? (
|
{build === "saas" && !hasSaasSubscription ? (
|
||||||
<Alert variant="info" className="mb-6">
|
<Alert variant="info" className="mb-6">
|
||||||
<AlertDescription>
|
<AlertDescription>
|
||||||
{t("subscriptionRequiredToUse")}
|
{t("subscriptionRequiredToUse")}
|
||||||
@@ -20,7 +17,7 @@ export function SecurityFeaturesAlert() {
|
|||||||
</Alert>
|
</Alert>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{build === "enterprise" && !isUnlocked() ? (
|
{build === "enterprise" && !hasEnterpriseLicense ? (
|
||||||
<Alert variant="info" className="mb-6">
|
<Alert variant="info" className="mb-6">
|
||||||
<AlertDescription>
|
<AlertDescription>
|
||||||
{t("licenseRequiredToUse")}
|
{t("licenseRequiredToUse")}
|
||||||
@@ -27,6 +27,7 @@ function getActionsCategories(root: boolean) {
|
|||||||
[t("actionUpdateOrg")]: "updateOrg",
|
[t("actionUpdateOrg")]: "updateOrg",
|
||||||
[t("actionGetOrgUser")]: "getOrgUser",
|
[t("actionGetOrgUser")]: "getOrgUser",
|
||||||
[t("actionInviteUser")]: "inviteUser",
|
[t("actionInviteUser")]: "inviteUser",
|
||||||
|
[t("actionRemoveInvitation")]: "removeInvitation",
|
||||||
[t("actionListInvitations")]: "listInvitations",
|
[t("actionListInvitations")]: "listInvitations",
|
||||||
[t("actionRemoveUser")]: "removeUser",
|
[t("actionRemoveUser")]: "removeUser",
|
||||||
[t("actionListUsers")]: "listUsers",
|
[t("actionListUsers")]: "listUsers",
|
||||||
|
|||||||
@@ -39,16 +39,15 @@ import {
|
|||||||
resourceWhitelistProxy,
|
resourceWhitelistProxy,
|
||||||
resourceAccessProxy
|
resourceAccessProxy
|
||||||
} from "@app/actions/server";
|
} from "@app/actions/server";
|
||||||
import { createApiClient } from "@app/lib/api";
|
|
||||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||||
import { toast } from "@app/hooks/useToast";
|
import { toast } from "@app/hooks/useToast";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import Image from "next/image";
|
|
||||||
import BrandingLogo from "@app/components/BrandingLogo";
|
import BrandingLogo from "@app/components/BrandingLogo";
|
||||||
import { useSupporterStatusContext } from "@app/hooks/useSupporterStatusContext";
|
import { useSupporterStatusContext } from "@app/hooks/useSupporterStatusContext";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import { build } from "@server/build";
|
import { build } from "@server/build";
|
||||||
import { useLicenseStatusContext } from "@app/hooks/useLicenseStatusContext";
|
import { useLicenseStatusContext } from "@app/hooks/useLicenseStatusContext";
|
||||||
|
import { replacePlaceholder } from "@app/lib/replacePlaceholder";
|
||||||
|
|
||||||
const pinSchema = z.object({
|
const pinSchema = z.object({
|
||||||
pin: z
|
pin: z
|
||||||
@@ -88,6 +87,14 @@ type ResourceAuthPortalProps = {
|
|||||||
redirect: string;
|
redirect: string;
|
||||||
idps?: LoginFormIDP[];
|
idps?: LoginFormIDP[];
|
||||||
orgId?: string;
|
orgId?: string;
|
||||||
|
branding?: {
|
||||||
|
logoUrl: string;
|
||||||
|
logoWidth: number;
|
||||||
|
logoHeight: number;
|
||||||
|
primaryColor: string | null;
|
||||||
|
resourceTitle: string;
|
||||||
|
resourceSubtitle: string | null;
|
||||||
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function ResourceAuthPortal(props: ResourceAuthPortalProps) {
|
export default function ResourceAuthPortal(props: ResourceAuthPortalProps) {
|
||||||
@@ -104,7 +111,7 @@ export default function ResourceAuthPortal(props: ResourceAuthPortalProps) {
|
|||||||
return colLength;
|
return colLength;
|
||||||
};
|
};
|
||||||
|
|
||||||
const [numMethods, setNumMethods] = useState(getNumMethods());
|
const [numMethods] = useState(() => getNumMethods());
|
||||||
|
|
||||||
const [passwordError, setPasswordError] = useState<string | null>(null);
|
const [passwordError, setPasswordError] = useState<string | null>(null);
|
||||||
const [pincodeError, setPincodeError] = 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 (
|
if (
|
||||||
isUnlocked() &&
|
|
||||||
build !== "oss" &&
|
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");
|
return t("authenticationRequired");
|
||||||
}
|
}
|
||||||
@@ -324,10 +337,16 @@ export default function ResourceAuthPortal(props: ResourceAuthPortalProps) {
|
|||||||
if (
|
if (
|
||||||
isUnlocked() &&
|
isUnlocked() &&
|
||||||
build !== "oss" &&
|
build !== "oss" &&
|
||||||
env.branding.resourceAuthPage?.subtitleText
|
(env.branding.resourceAuthPage?.subtitleText ||
|
||||||
|
props.branding?.resourceSubtitle)
|
||||||
) {
|
) {
|
||||||
return env.branding.resourceAuthPage.subtitleText
|
if (props.branding?.resourceSubtitle) {
|
||||||
.split("{{resourceName}}")
|
return replacePlaceholder(props.branding?.resourceSubtitle, {
|
||||||
|
resourceName
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return env.branding.resourceAuthPage?.subtitleText
|
||||||
|
?.split("{{resourceName}}")
|
||||||
.join(resourceName);
|
.join(resourceName);
|
||||||
}
|
}
|
||||||
return numMethods > 1
|
return numMethods > 1
|
||||||
@@ -336,14 +355,23 @@ export default function ResourceAuthPortal(props: ResourceAuthPortalProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const logoWidth = isUnlocked()
|
const logoWidth = isUnlocked()
|
||||||
? env.branding.logo?.authPage?.width || 100
|
? (props.branding?.logoWidth ??
|
||||||
|
env.branding.logo?.authPage?.width ??
|
||||||
|
100)
|
||||||
: 100;
|
: 100;
|
||||||
const logoHeight = isUnlocked()
|
const logoHeight = isUnlocked()
|
||||||
? env.branding.logo?.authPage?.height || 100
|
? (props.branding?.logoHeight ??
|
||||||
|
env.branding.logo?.authPage?.height ??
|
||||||
|
100)
|
||||||
: 100;
|
: 100;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div
|
||||||
|
style={{
|
||||||
|
// @ts-expect-error CSS variable
|
||||||
|
"--primary": isUnlocked() ? props.branding?.primaryColor : null
|
||||||
|
}}
|
||||||
|
>
|
||||||
{!accessDenied ? (
|
{!accessDenied ? (
|
||||||
<div>
|
<div>
|
||||||
{isUnlocked() && build === "enterprise" ? (
|
{isUnlocked() && build === "enterprise" ? (
|
||||||
@@ -381,15 +409,19 @@ export default function ResourceAuthPortal(props: ResourceAuthPortalProps) {
|
|||||||
<CardHeader>
|
<CardHeader>
|
||||||
{isUnlocked() &&
|
{isUnlocked() &&
|
||||||
build !== "oss" &&
|
build !== "oss" &&
|
||||||
env.branding?.resourceAuthPage?.showLogo && (
|
(env.branding?.resourceAuthPage?.showLogo ||
|
||||||
|
props.branding) && (
|
||||||
<div className="flex flex-row items-center justify-center mb-3">
|
<div className="flex flex-row items-center justify-center mb-3">
|
||||||
<BrandingLogo
|
<BrandingLogo
|
||||||
height={logoHeight}
|
height={logoHeight}
|
||||||
width={logoWidth}
|
width={logoWidth}
|
||||||
|
logoPath={props.branding?.logoUrl}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<CardTitle>{getTitle()}</CardTitle>
|
<CardTitle>
|
||||||
|
{getTitle(props.resource.name)}
|
||||||
|
</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
{getSubtitle(props.resource.name)}
|
{getSubtitle(props.resource.name)}
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import { Button } from "@app/components/ui/button";
|
|||||||
import {
|
import {
|
||||||
ArrowRight,
|
ArrowRight,
|
||||||
ArrowUpDown,
|
ArrowUpDown,
|
||||||
|
ArrowUpRight,
|
||||||
Check,
|
Check,
|
||||||
MoreHorizontal,
|
MoreHorizontal,
|
||||||
X
|
X
|
||||||
@@ -46,6 +47,7 @@ export type SiteRow = {
|
|||||||
address?: string;
|
address?: string;
|
||||||
exitNodeName?: string;
|
exitNodeName?: string;
|
||||||
exitNodeEndpoint?: string;
|
exitNodeEndpoint?: string;
|
||||||
|
remoteExitNodeId?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
type SitesTableProps = {
|
type SitesTableProps = {
|
||||||
@@ -303,27 +305,51 @@ export default function SitesTable({ sites, orgId }: SitesTableProps) {
|
|||||||
},
|
},
|
||||||
cell: ({ row }) => {
|
cell: ({ row }) => {
|
||||||
const originalRow = row.original;
|
const originalRow = row.original;
|
||||||
return originalRow.exitNodeName ? (
|
if (!originalRow.exitNodeName) {
|
||||||
<div className="flex items-center space-x-2">
|
return "-";
|
||||||
<span>{originalRow.exitNodeName}</span>
|
}
|
||||||
{build == "saas" &&
|
|
||||||
originalRow.exitNodeName &&
|
const isCloudNode =
|
||||||
[
|
build == "saas" &&
|
||||||
"mercury",
|
originalRow.exitNodeName &&
|
||||||
"venus",
|
[
|
||||||
"earth",
|
"mercury",
|
||||||
"mars",
|
"venus",
|
||||||
"jupiter",
|
"earth",
|
||||||
"saturn",
|
"mars",
|
||||||
"uranus",
|
"jupiter",
|
||||||
"neptune"
|
"saturn",
|
||||||
].includes(
|
"uranus",
|
||||||
originalRow.exitNodeName.toLowerCase()
|
"neptune"
|
||||||
) && <Badge variant="secondary">Cloud</Badge>}
|
].includes(originalRow.exitNodeName.toLowerCase());
|
||||||
</div>
|
|
||||||
) : (
|
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() {
|
export function TopLoader() {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<NextTopLoader showSpinner={false} />
|
<NextTopLoader showSpinner={false} color="var(--color-primary)" />
|
||||||
<FinishingLoader />
|
<FinishingLoader />
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -26,6 +26,11 @@ type ValidateOidcTokenParams = {
|
|||||||
stateCookie: string | undefined;
|
stateCookie: string | undefined;
|
||||||
idp: { name: string };
|
idp: { name: string };
|
||||||
loginPageId?: number;
|
loginPageId?: number;
|
||||||
|
providerError?: {
|
||||||
|
error: string;
|
||||||
|
description?: string | null;
|
||||||
|
uri?: string | null;
|
||||||
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function ValidateOidcToken(props: ValidateOidcTokenParams) {
|
export default function ValidateOidcToken(props: ValidateOidcTokenParams) {
|
||||||
@@ -35,14 +40,65 @@ export default function ValidateOidcToken(props: ValidateOidcTokenParams) {
|
|||||||
|
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [isProviderError, setIsProviderError] = useState(false);
|
||||||
|
|
||||||
const { licenseStatus, isLicenseViolation } = useLicenseStatusContext();
|
const { licenseStatus, isLicenseViolation } = useLicenseStatusContext();
|
||||||
|
|
||||||
const t = useTranslations();
|
const t = useTranslations();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
async function validate() {
|
let isCancelled = false;
|
||||||
|
|
||||||
|
async function runValidation() {
|
||||||
setLoading(true);
|
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"), {
|
console.log(t("idpOidcTokenValidating"), {
|
||||||
code: props.code,
|
code: props.code,
|
||||||
@@ -57,22 +113,28 @@ export default function ValidateOidcToken(props: ValidateOidcTokenParams) {
|
|||||||
try {
|
try {
|
||||||
const response = await validateOidcUrlCallbackProxy(
|
const response = await validateOidcUrlCallbackProxy(
|
||||||
props.idpId,
|
props.idpId,
|
||||||
props.code || "",
|
props.code,
|
||||||
props.expectedState || "",
|
props.expectedState,
|
||||||
props.stateCookie || "",
|
props.stateCookie,
|
||||||
props.loginPageId
|
props.loginPageId
|
||||||
);
|
);
|
||||||
|
|
||||||
if (response.error) {
|
if (response.error) {
|
||||||
setError(response.message);
|
if (!isCancelled) {
|
||||||
setLoading(false);
|
setIsProviderError(false);
|
||||||
|
setError(response.message);
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const data = response.data;
|
const data = response.data;
|
||||||
if (!data) {
|
if (!data) {
|
||||||
setError("Unable to validate OIDC token");
|
if (!isCancelled) {
|
||||||
setLoading(false);
|
setIsProviderError(false);
|
||||||
|
setError("Unable to validate OIDC token");
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -82,8 +144,11 @@ export default function ValidateOidcToken(props: ValidateOidcTokenParams) {
|
|||||||
router.push(env.app.dashboardUrl);
|
router.push(env.app.dashboardUrl);
|
||||||
}
|
}
|
||||||
|
|
||||||
setLoading(false);
|
if (!isCancelled) {
|
||||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
setIsProviderError(false);
|
||||||
|
setLoading(false);
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||||
|
}
|
||||||
|
|
||||||
if (redirectUrl.startsWith("http")) {
|
if (redirectUrl.startsWith("http")) {
|
||||||
window.location.href = data.redirectUrl; // this is validated by the parent using this component
|
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) {
|
} catch (e: any) {
|
||||||
console.error(e);
|
console.error(e);
|
||||||
setError(
|
if (!isCancelled) {
|
||||||
t("idpErrorOidcTokenValidating", {
|
setIsProviderError(false);
|
||||||
defaultValue:
|
setError(
|
||||||
"An unexpected error occurred. Please try again."
|
t("idpErrorOidcTokenValidating", {
|
||||||
})
|
defaultValue:
|
||||||
);
|
"An unexpected error occurred. Please try again."
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
if (!isCancelled) {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
validate();
|
runValidation();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
isCancelled = true;
|
||||||
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -134,12 +208,16 @@ export default function ValidateOidcToken(props: ValidateOidcTokenParams) {
|
|||||||
<Alert variant="destructive" className="w-full">
|
<Alert variant="destructive" className="w-full">
|
||||||
<AlertCircle className="h-5 w-5" />
|
<AlertCircle className="h-5 w-5" />
|
||||||
<AlertDescription className="flex flex-col space-y-2">
|
<AlertDescription className="flex flex-col space-y-2">
|
||||||
<span>
|
<span className="text-sm font-medium">
|
||||||
{t("idpErrorConnectingTo", {
|
{isProviderError
|
||||||
name: props.idp.name
|
? error
|
||||||
})}
|
: t("idpErrorConnectingTo", {
|
||||||
|
name: props.idp.name
|
||||||
|
})}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-xs">{error}</span>
|
{!isProviderError && (
|
||||||
|
<span className="text-xs">{error}</span>
|
||||||
|
)}
|
||||||
</AlertDescription>
|
</AlertDescription>
|
||||||
</Alert>
|
</Alert>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -3,16 +3,8 @@
|
|||||||
import { Button } from "@app/components/ui/button";
|
import { Button } from "@app/components/ui/button";
|
||||||
import { useOrgContext } from "@app/hooks/useOrgContext";
|
import { useOrgContext } from "@app/hooks/useOrgContext";
|
||||||
import { toast } from "@app/hooks/useToast";
|
import { toast } from "@app/hooks/useToast";
|
||||||
import { useState, useEffect, forwardRef, useImperativeHandle } from "react";
|
import { useState, useEffect, useActionState } from "react";
|
||||||
import {
|
import { Form } from "@/components/ui/form";
|
||||||
Form,
|
|
||||||
FormControl,
|
|
||||||
FormDescription,
|
|
||||||
FormField,
|
|
||||||
FormItem,
|
|
||||||
FormLabel,
|
|
||||||
FormMessage
|
|
||||||
} from "@/components/ui/form";
|
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { useForm } from "react-hook-form";
|
import { useForm } from "react-hook-form";
|
||||||
@@ -51,9 +43,9 @@ import DomainPicker from "@app/components/DomainPicker";
|
|||||||
import { finalizeSubdomainSanitize } from "@app/lib/subdomain-utils";
|
import { finalizeSubdomainSanitize } from "@app/lib/subdomain-utils";
|
||||||
import { InfoPopup } from "@app/components/ui/info-popup";
|
import { InfoPopup } from "@app/components/ui/info-popup";
|
||||||
import { Alert, AlertDescription } from "@app/components/ui/alert";
|
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 { build } from "@server/build";
|
||||||
|
import { usePaidStatus } from "@app/hooks/usePaidStatus";
|
||||||
|
import { PaidFeaturesAlert } from "../PaidFeaturesAlert";
|
||||||
|
|
||||||
// Auth page form schema
|
// Auth page form schema
|
||||||
const AuthPageFormSchema = z.object({
|
const AuthPageFormSchema = z.object({
|
||||||
@@ -61,11 +53,10 @@ const AuthPageFormSchema = z.object({
|
|||||||
authPageSubdomain: z.string().optional()
|
authPageSubdomain: z.string().optional()
|
||||||
});
|
});
|
||||||
|
|
||||||
type AuthPageFormValues = z.infer<typeof AuthPageFormSchema>;
|
|
||||||
|
|
||||||
interface AuthPageSettingsProps {
|
interface AuthPageSettingsProps {
|
||||||
onSaveSuccess?: () => void;
|
onSaveSuccess?: () => void;
|
||||||
onSaveError?: (error: any) => void;
|
onSaveError?: (error: any) => void;
|
||||||
|
loginPage: GetLoginPageResponse | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AuthPageSettingsRef {
|
export interface AuthPageSettingsRef {
|
||||||
@@ -73,475 +64,428 @@ export interface AuthPageSettingsRef {
|
|||||||
hasUnsavedChanges: () => boolean;
|
hasUnsavedChanges: () => boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const AuthPageSettings = forwardRef<AuthPageSettingsRef, AuthPageSettingsProps>(
|
function AuthPageSettings({
|
||||||
({ onSaveSuccess, onSaveError }, ref) => {
|
onSaveSuccess,
|
||||||
const { org } = useOrgContext();
|
onSaveError,
|
||||||
const api = createApiClient(useEnvContext());
|
loginPage: defaultLoginPage
|
||||||
const router = useRouter();
|
}: AuthPageSettingsProps) {
|
||||||
const t = useTranslations();
|
const { org } = useOrgContext();
|
||||||
const { env } = useEnvContext();
|
const api = createApiClient(useEnvContext());
|
||||||
|
const router = useRouter();
|
||||||
|
const t = useTranslations();
|
||||||
|
const { env } = useEnvContext();
|
||||||
|
|
||||||
const subscription = useSubscriptionStatusContext();
|
const { hasSaasSubscription } = usePaidStatus();
|
||||||
|
|
||||||
// Auth page domain state
|
// Auth page domain state
|
||||||
const [loginPage, setLoginPage] = useState<GetLoginPageResponse | null>(
|
const [loginPage, setLoginPage] = useState(defaultLoginPage);
|
||||||
null
|
const [, formAction, isSubmitting] = useActionState(onSubmit, null);
|
||||||
);
|
const [loginPageExists, setLoginPageExists] = useState(
|
||||||
const [loginPageExists, setLoginPageExists] = useState(false);
|
Boolean(defaultLoginPage)
|
||||||
const [editDomainOpen, setEditDomainOpen] = useState(false);
|
);
|
||||||
const [baseDomains, setBaseDomains] = useState<DomainRow[]>([]);
|
const [editDomainOpen, setEditDomainOpen] = useState(false);
|
||||||
const [selectedDomain, setSelectedDomain] = useState<{
|
const [baseDomains, setBaseDomains] = useState<DomainRow[]>([]);
|
||||||
domainId: string;
|
const [selectedDomain, setSelectedDomain] = useState<{
|
||||||
subdomain?: string;
|
domainId: string;
|
||||||
fullDomain: string;
|
subdomain?: string;
|
||||||
baseDomain: string;
|
fullDomain: string;
|
||||||
} | null>(null);
|
baseDomain: string;
|
||||||
const [loadingLoginPage, setLoadingLoginPage] = useState(true);
|
} | null>(null);
|
||||||
const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false);
|
const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false);
|
||||||
const [loadingSave, setLoadingSave] = useState(false);
|
|
||||||
|
|
||||||
const form = useForm({
|
const form = useForm({
|
||||||
resolver: zodResolver(AuthPageFormSchema),
|
resolver: zodResolver(AuthPageFormSchema),
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
authPageDomainId: loginPage?.domainId || "",
|
authPageDomainId: loginPage?.domainId || "",
|
||||||
authPageSubdomain: loginPage?.subdomain || ""
|
authPageSubdomain: loginPage?.subdomain || ""
|
||||||
},
|
},
|
||||||
mode: "onChange"
|
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);
|
|
||||||
|
|
||||||
|
// Fetch login page and domains data
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchDomains = async () => {
|
||||||
try {
|
try {
|
||||||
// Handle auth page domain
|
const res = await api.get<AxiosResponse<ListDomainsResponse>>(
|
||||||
if (data.authPageDomainId) {
|
`/org/${org?.org.orgId}/domains/`
|
||||||
if (
|
);
|
||||||
build === "enterprise" ||
|
if (res.status === 200) {
|
||||||
(build === "saas" && subscription?.subscribed)
|
const rawDomains = res.data.data.domains as DomainRow[];
|
||||||
) {
|
const domains = rawDomains.map((domain) => ({
|
||||||
const sanitizedSubdomain = data.authPageSubdomain
|
...domain,
|
||||||
? finalizeSubdomainSanitize(data.authPageSubdomain)
|
baseDomain: toUnicode(domain.baseDomain)
|
||||||
: "";
|
}));
|
||||||
|
setBaseDomains(domains);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to fetch domains:", err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
if (loginPageExists) {
|
if (org?.org.orgId) {
|
||||||
// Login page exists on server - need to update it
|
fetchDomains();
|
||||||
// First, we need to get the loginPageId from the server since loginPage might be null locally
|
}
|
||||||
let loginPageId: number;
|
}, []);
|
||||||
|
|
||||||
if (loginPage) {
|
// Handle domain selection from modal
|
||||||
// We have the loginPage data locally
|
function handleDomainSelection(domain: {
|
||||||
loginPageId = loginPage.loginPageId;
|
domainId: string;
|
||||||
} else {
|
subdomain?: string;
|
||||||
// User cleared selection locally, but login page still exists on server
|
fullDomain: string;
|
||||||
// We need to fetch it to get the loginPageId
|
baseDomain: string;
|
||||||
const fetchRes = await api.get<
|
}) {
|
||||||
AxiosResponse<GetLoginPageResponse>
|
form.setValue("authPageDomainId", domain.domainId);
|
||||||
>(`/org/${org?.org.orgId}/login-page`);
|
form.setValue("authPageSubdomain", domain.subdomain || "");
|
||||||
loginPageId = fetchRes.data.data.loginPageId;
|
setEditDomainOpen(false);
|
||||||
}
|
|
||||||
|
|
||||||
// Update existing auth page domain
|
// Update loginPage state to show the selected domain immediately
|
||||||
const updateRes = await api.post(
|
const sanitizedSubdomain = domain.subdomain
|
||||||
`/org/${org?.org.orgId}/login-page/${loginPageId}`,
|
? finalizeSubdomainSanitize(domain.subdomain)
|
||||||
{
|
: "";
|
||||||
domainId: data.authPageDomainId,
|
|
||||||
subdomain: sanitizedSubdomain || null
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
if (updateRes.status === 201) {
|
const sanitizedFullDomain = sanitizedSubdomain
|
||||||
setLoginPage(updateRes.data.data);
|
? `${sanitizedSubdomain}.${domain.baseDomain}`
|
||||||
setLoginPageExists(true);
|
: 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 {
|
} else {
|
||||||
// No login page exists on server - create new one
|
// User cleared selection locally, but login page still exists on server
|
||||||
const createRes = await api.put(
|
// We need to fetch it to get the loginPageId
|
||||||
`/org/${org?.org.orgId}/login-page`,
|
const fetchRes = await api.get<
|
||||||
{
|
AxiosResponse<GetLoginPageResponse>
|
||||||
domainId: data.authPageDomainId,
|
>(`/org/${org?.org.orgId}/login-page`);
|
||||||
subdomain: sanitizedSubdomain || null
|
loginPageId = fetchRes.data.data.loginPageId;
|
||||||
}
|
}
|
||||||
);
|
|
||||||
|
|
||||||
if (createRes.status === 201) {
|
// Update existing auth page domain
|
||||||
setLoginPage(createRes.data.data);
|
const updateRes = await api.post(
|
||||||
setLoginPageExists(true);
|
`/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
|
} else if (loginPageExists) {
|
||||||
let loginPageId: number;
|
// Delete existing auth page domain if no domain selected
|
||||||
|
let loginPageId: number;
|
||||||
|
|
||||||
if (loginPage) {
|
if (loginPage) {
|
||||||
// We have the loginPage data locally
|
// We have the loginPage data locally
|
||||||
loginPageId = loginPage.loginPageId;
|
loginPageId = loginPage.loginPageId;
|
||||||
} else {
|
} else {
|
||||||
// User cleared selection locally, but login page still exists on server
|
// User cleared selection locally, but login page still exists on server
|
||||||
// We need to fetch it to get the loginPageId
|
// We need to fetch it to get the loginPageId
|
||||||
const fetchRes = await api.get<
|
const fetchRes = await api.get<
|
||||||
AxiosResponse<GetLoginPageResponse>
|
AxiosResponse<GetLoginPageResponse>
|
||||||
>(`/org/${org?.org.orgId}/login-page`);
|
>(`/org/${org?.org.orgId}/login-page`);
|
||||||
loginPageId = fetchRes.data.data.loginPageId;
|
loginPageId = fetchRes.data.data.loginPageId;
|
||||||
}
|
|
||||||
|
|
||||||
await api.delete(
|
|
||||||
`/org/${org?.org.orgId}/login-page/${loginPageId}`
|
|
||||||
);
|
|
||||||
setLoginPage(null);
|
|
||||||
setLoginPageExists(false);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
setHasUnsavedChanges(false);
|
await api.delete(
|
||||||
router.refresh();
|
`/org/${org?.org.orgId}/login-page/${loginPageId}`
|
||||||
onSaveSuccess?.();
|
);
|
||||||
} catch (e) {
|
setLoginPage(null);
|
||||||
toast({
|
setLoginPageExists(false);
|
||||||
variant: "destructive",
|
|
||||||
title: t("authPageErrorUpdate"),
|
|
||||||
description: formatAxiosError(
|
|
||||||
e,
|
|
||||||
t("authPageErrorUpdateMessage")
|
|
||||||
)
|
|
||||||
});
|
|
||||||
onSaveError?.(e);
|
|
||||||
} finally {
|
|
||||||
setLoadingSave(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";
|
AuthPageSettings.displayName = "AuthPageSettings";
|
||||||
|
|
||||||
|
|||||||
@@ -308,7 +308,7 @@ export const Autocomplete: React.FC<AutocompleteProps> = ({
|
|||||||
role="option"
|
role="option"
|
||||||
aria-selected={isSelected}
|
aria-selected={isSelected}
|
||||||
className={cn(
|
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 &&
|
isSelected &&
|
||||||
"bg-accent text-accent-foreground",
|
"bg-accent text-accent-foreground",
|
||||||
classStyleProps?.commandItem
|
classStyleProps?.commandItem
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ const buttonVariants = cva(
|
|||||||
outlinePrimary:
|
outlinePrimary:
|
||||||
"border border-primary bg-card hover:bg-primary/10 text-primary ",
|
"border border-primary bg-card hover:bg-primary/10 text-primary ",
|
||||||
secondary:
|
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",
|
ghost: "hover:bg-accent hover:text-accent-foreground",
|
||||||
squareOutlinePrimary:
|
squareOutlinePrimary:
|
||||||
"border border-primary bg-card hover:bg-primary/10 text-primary rounded-md ",
|
"border border-primary bg-card hover:bg-primary/10 text-primary rounded-md ",
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ const DialogContent = React.forwardRef<
|
|||||||
<DialogPrimitive.Content
|
<DialogPrimitive.Content
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
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
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
@@ -59,7 +59,7 @@ const DialogHeader = ({
|
|||||||
}: React.HTMLAttributes<HTMLDivElement>) => (
|
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
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
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ function DropdownMenuContent({
|
|||||||
data-slot="dropdown-menu-content"
|
data-slot="dropdown-menu-content"
|
||||||
sideOffset={sideOffset}
|
sideOffset={sideOffset}
|
||||||
className={cn(
|
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
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
@@ -237,7 +237,7 @@ function DropdownMenuSubContent({
|
|||||||
<DropdownMenuPrimitive.SubContent
|
<DropdownMenuPrimitive.SubContent
|
||||||
data-slot="dropdown-menu-sub-content"
|
data-slot="dropdown-menu-sub-content"
|
||||||
className={cn(
|
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
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ function PopoverContent({
|
|||||||
align={align}
|
align={align}
|
||||||
sideOffset={sideOffset}
|
sideOffset={sideOffset}
|
||||||
className={cn(
|
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
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
|
|||||||
@@ -60,7 +60,7 @@ function SelectContent({
|
|||||||
<SelectPrimitive.Content
|
<SelectPrimitive.Content
|
||||||
data-slot="select-content"
|
data-slot="select-content"
|
||||||
className={cn(
|
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" &&
|
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",
|
"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
|
className
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ const SheetOverlay = React.forwardRef<
|
|||||||
SheetOverlay.displayName = SheetPrimitive.Overlay.displayName;
|
SheetOverlay.displayName = SheetPrimitive.Overlay.displayName;
|
||||||
|
|
||||||
const sheetVariants = cva(
|
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: {
|
variants: {
|
||||||
side: {
|
side: {
|
||||||
|
|||||||
@@ -45,7 +45,7 @@ function TooltipContent({
|
|||||||
data-slot="tooltip-content"
|
data-slot="tooltip-content"
|
||||||
sideOffset={sideOffset}
|
sideOffset={sideOffset}
|
||||||
className={cn(
|
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
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...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 { GetUserResponse } from "@server/routers/user";
|
||||||
import { AxiosResponse } from "axios";
|
import { AxiosResponse } from "axios";
|
||||||
import { pullEnv } from "../pullEnv";
|
import { pullEnv } from "../pullEnv";
|
||||||
|
import { cache } from "react";
|
||||||
|
|
||||||
export async function verifySession({
|
export const verifySession = cache(async function ({
|
||||||
skipCheckVerifyEmail,
|
skipCheckVerifyEmail,
|
||||||
forceLogin
|
forceLogin
|
||||||
}: {
|
}: {
|
||||||
@@ -14,8 +15,12 @@ export async function verifySession({
|
|||||||
const env = pullEnv();
|
const env = pullEnv();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
const search = new URLSearchParams();
|
||||||
|
if (forceLogin) {
|
||||||
|
search.set("forceLogin", "true");
|
||||||
|
}
|
||||||
const res = await internal.get<AxiosResponse<GetUserResponse>>(
|
const res = await internal.get<AxiosResponse<GetUserResponse>>(
|
||||||
`/user${forceLogin ? "?forceLogin=true" : ""}`,
|
`/user?${search.toString()}`,
|
||||||
await authCookieHeader()
|
await authCookieHeader()
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -37,4 +42,4 @@ export async function verifySession({
|
|||||||
} catch (e) {
|
} catch (e) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
});
|
||||||
|
|||||||
@@ -79,7 +79,7 @@ export const productUpdatesQueries = {
|
|||||||
}
|
}
|
||||||
return false;
|
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
|
// 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