mirror of
https://github.com/fosrl/pangolin.git
synced 2026-08-30 16:01:30 +02:00
Compare commits
12 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f213e81cde | |||
| 7b6b1f0a4c | |||
| 8daf7c2872 | |||
| c394490473 | |||
| 3b6b78b3e1 | |||
| 8658198a93 | |||
| 4b770d1385 | |||
| cd4d7372a0 | |||
| dc8243cb51 | |||
| 7b1f8d98f3 | |||
| dd8bcbb3e3 | |||
| d1af7a153f |
+57
-12
@@ -756,11 +756,11 @@
|
||||
"rulesErrorDuplicate": "Duplicate rule",
|
||||
"rulesErrorDuplicateDescription": "A rule with these settings already exists",
|
||||
"rulesErrorInvalidIpAddressRange": "Invalid CIDR",
|
||||
"rulesErrorInvalidIpAddressRangeDescription": "Please enter a valid CIDR value",
|
||||
"rulesErrorInvalidUrl": "Invalid URL path",
|
||||
"rulesErrorInvalidUrlDescription": "Please enter a valid URL path value",
|
||||
"rulesErrorInvalidIpAddress": "Invalid IP",
|
||||
"rulesErrorInvalidIpAddressDescription": "Please enter a valid IP address",
|
||||
"rulesErrorInvalidIpAddressRangeDescription": "Enter a valid CIDR range (e.g., 10.0.0.0/8).",
|
||||
"rulesErrorInvalidUrl": "Invalid path",
|
||||
"rulesErrorInvalidUrlDescription": "Enter a valid URL path or pattern (e.g., /api/*).",
|
||||
"rulesErrorInvalidIpAddress": "Invalid IP address",
|
||||
"rulesErrorInvalidIpAddressDescription": "Enter a valid IPv4 or IPv6 address.",
|
||||
"rulesErrorUpdate": "Failed to update rules",
|
||||
"rulesErrorUpdateDescription": "An error occurred while updating rules",
|
||||
"rulesUpdated": "Enable Rules",
|
||||
@@ -768,15 +768,23 @@
|
||||
"rulesMatchIpAddressRangeDescription": "Enter an address in CIDR format (e.g., 103.21.244.0/22)",
|
||||
"rulesMatchIpAddress": "Enter an IP address (e.g., 103.21.244.12)",
|
||||
"rulesMatchUrl": "Enter a URL path or pattern (e.g., /api/v1/todos or /api/v1/*)",
|
||||
"rulesErrorInvalidPriority": "Invalid Priority",
|
||||
"rulesErrorInvalidPriorityDescription": "Please enter a valid priority",
|
||||
"rulesErrorDuplicatePriority": "Duplicate Priorities",
|
||||
"rulesErrorDuplicatePriorityDescription": "Please enter unique priorities",
|
||||
"rulesErrorInvalidPriority": "Invalid priority",
|
||||
"rulesErrorInvalidPriorityDescription": "Enter a whole number of 1 or higher.",
|
||||
"rulesErrorDuplicatePriority": "Duplicate priorities",
|
||||
"rulesErrorDuplicatePriorityDescription": "Each rule must have a unique priority number.",
|
||||
"rulesErrorValidation": "Invalid rules",
|
||||
"rulesErrorValidationRuleDescription": "Rule {ruleNumber}: {message}",
|
||||
"rulesErrorValueRequired": "Enter a value for this rule.",
|
||||
"rulesErrorInvalidCountry": "Invalid country",
|
||||
"rulesErrorInvalidCountryDescription": "Select a valid country.",
|
||||
"rulesErrorInvalidAsn": "Invalid ASN",
|
||||
"rulesErrorInvalidAsnDescription": "Enter a valid ASN (e.g., AS15169).",
|
||||
"ruleUpdated": "Rules updated",
|
||||
"ruleUpdatedDescription": "Rules updated successfully",
|
||||
"ruleErrorUpdate": "Operation failed",
|
||||
"ruleErrorUpdateDescription": "An error occurred during the save operation",
|
||||
"rulesPriority": "Priority",
|
||||
"rulesReorderDragHandle": "Drag to reorder rule priority",
|
||||
"rulesAction": "Action",
|
||||
"rulesMatchType": "Match Type",
|
||||
"value": "Value",
|
||||
@@ -795,7 +803,7 @@
|
||||
"rulesResource": "Resource Rules Configuration",
|
||||
"rulesResourceDescription": "Configure rules to control access to the resource",
|
||||
"ruleSubmit": "Add Rule",
|
||||
"rulesNoOne": "No rules. Add a rule using the form.",
|
||||
"rulesNoOne": "No rules yet.",
|
||||
"rulesOrder": "Rules are evaluated by priority in ascending order.",
|
||||
"rulesSubmit": "Save Rules",
|
||||
"policyErrorCreate": "Error creating policy",
|
||||
@@ -806,7 +814,44 @@
|
||||
"policyErrorUpdateMessageDescription": "An unexpected error occurred",
|
||||
"policyCreatedSuccess": "Resource policy succesfully created",
|
||||
"policyUpdatedSuccess": "Resource policy succesfully updated",
|
||||
"authMethodsSave": "Save auth methods",
|
||||
"authMethodsSave": "Save Settings",
|
||||
"policyAuthStackTitle": "Authentication",
|
||||
"policyAuthStackDescription": "Control which authentication methods are required to access this resource",
|
||||
"policyAuthOrLogicTitle": "Multiple authentication methods active",
|
||||
"policyAuthOrLogicBanner": "Visitors may authenticate using any one of the active methods below. They do not need to complete all of them.",
|
||||
"policyAuthMethodActive": "Active",
|
||||
"policyAuthMethodOff": "Off",
|
||||
"policyAuthSsoTitle": "Platform SSO",
|
||||
"policyAuthSsoDescription": "Require sign-in through your organization's identity provider",
|
||||
"policyAuthSsoSummary": "{idp} · {users} users, {roles} roles",
|
||||
"policyAuthSsoDefaultIdp": "Default provider",
|
||||
"policyAuthAddDefaultIdentityProvider": "Add Default Identity Provider",
|
||||
"policyAuthOtherMethodsTitle": "Other Methods",
|
||||
"policyAuthOtherMethodsDescription": "Optional methods visitors can use instead of or alongside platform SSO",
|
||||
"policyAuthPasscodeTitle": "Passcode",
|
||||
"policyAuthPasscodeDescription": "Require a shared alphanumeric passcode to access the resource",
|
||||
"policyAuthPasscodeSummary": "Passcode set",
|
||||
"policyAuthPincodeTitle": "PIN Code",
|
||||
"policyAuthPincodeDescription": "A short numeric code required to access the resource",
|
||||
"policyAuthPincodeSummary": "6-digit PIN set",
|
||||
"policyAuthEmailTitle": "Email Whitelist",
|
||||
"policyAuthEmailDescription": "Allow listed email addresses with one-time passwords",
|
||||
"policyAuthEmailSummary": "{count} addresses allowed",
|
||||
"policyAuthEmailOtpCallout": "Enabling email whitelist sends a one-time password to the visitor's email on login.",
|
||||
"policyAuthHeaderAuthTitle": "Basic Header Auth",
|
||||
"policyAuthHeaderAuthDescription": "Validate a custom HTTP header name and value on each request",
|
||||
"policyAuthHeaderAuthSummary": "Header configured",
|
||||
"policyAuthHeaderName": "Header name",
|
||||
"policyAuthHeaderValue": "Expected value",
|
||||
"policyAccessRulesTitle": "Access Rules",
|
||||
"policyAccessRulesEnableDescription": "When enabled, rules are evaluated in descending order until one evaluates as true.",
|
||||
"policyAccessRulesFirstMatch": "Rules are evaluated top to bottom. The first matching rule decides the outcome.",
|
||||
"policyAccessRulesHowItWorks": "Rules match requests by path, IP address, location, or other criteria. Each rule applies an action: bypass authentication, block access, or pass to authentication. If no rule matches, traffic continues to authentication.",
|
||||
"policyAccessRulesFallthroughOff": "When rules are disabled, all traffic passes through to authentication.",
|
||||
"policyAccessRulesFallthroughOn": "When no rule matches, traffic passes through to authentication.",
|
||||
"rulesPlaceholderCidr": "10.0.0.0/8",
|
||||
"rulesPlaceholderPath": "/admin/*",
|
||||
"rulesPlaceholderGeo": "RU, KP",
|
||||
"rulesSave": "Save Rules",
|
||||
"resourceErrorCreate": "Error creating resource",
|
||||
"resourceErrorCreateDescription": "An error occurred when creating the resource",
|
||||
@@ -3045,7 +3090,7 @@
|
||||
"enterConfirmation": "Enter confirmation",
|
||||
"blueprintViewDetails": "Details",
|
||||
"defaultIdentityProvider": "Default Identity Provider",
|
||||
"defaultIdentityProviderDescription": "When a default identity provider is selected, the user will be automatically redirected to the provider for authentication.",
|
||||
"defaultIdentityProviderDescription": "The user will be automatically redirected to this identity provider for authentication.",
|
||||
"editInternalResourceDialogNetworkSettings": "Network Settings",
|
||||
"editInternalResourceDialogAccessPolicy": "Access Policy",
|
||||
"editInternalResourceDialogAddRoles": "Add Roles",
|
||||
|
||||
@@ -10,16 +10,22 @@ import {
|
||||
clientSiteResources
|
||||
} from "@server/db";
|
||||
import { Config, ConfigSchema } from "./types";
|
||||
import { ProxyResourcesResults, updateProxyResources } from "./proxyResources";
|
||||
import {
|
||||
PublicResourcesResults,
|
||||
updatePublicResources
|
||||
} from "./publicResources";
|
||||
import { fromError } from "zod-validation-error";
|
||||
import logger from "@server/logger";
|
||||
import { sites } from "@server/db";
|
||||
import { eq, and, isNotNull } from "drizzle-orm";
|
||||
import { addTargets as addProxyTargets } from "@server/routers/newt/targets";
|
||||
import {
|
||||
addTargets as addProxyTargets,
|
||||
sendBrowserGatewayTargets
|
||||
} from "@server/routers/newt/targets";
|
||||
import {
|
||||
ClientResourcesResults,
|
||||
updateClientResources
|
||||
} from "./clientResources";
|
||||
updatePrivateResources
|
||||
} from "./privateResources";
|
||||
import { updateResourcePolicies } from "./resourcePolicies";
|
||||
import { BlueprintSource } from "@server/routers/blueprints/types";
|
||||
import { stringify as stringifyYaml } from "yaml";
|
||||
@@ -54,18 +60,18 @@ export async function applyBlueprint({
|
||||
let error: any | null = null;
|
||||
|
||||
try {
|
||||
let proxyResourcesResults: ProxyResourcesResults = [];
|
||||
let proxyResourcesResults: PublicResourcesResults = [];
|
||||
let clientResourcesResults: ClientResourcesResults = [];
|
||||
await db.transaction(async (trx) => {
|
||||
await updateResourcePolicies(orgId, config, trx);
|
||||
|
||||
proxyResourcesResults = await updateProxyResources(
|
||||
proxyResourcesResults = await updatePublicResources(
|
||||
orgId,
|
||||
config,
|
||||
trx,
|
||||
siteId
|
||||
);
|
||||
clientResourcesResults = await updateClientResources(
|
||||
clientResourcesResults = await updatePrivateResources(
|
||||
orgId,
|
||||
config,
|
||||
trx,
|
||||
@@ -104,13 +110,27 @@ export async function applyBlueprint({
|
||||
(hc) => hc.targetId === target.targetId
|
||||
);
|
||||
|
||||
await addProxyTargets(
|
||||
site.newt.newtId,
|
||||
[target],
|
||||
matchingHealthcheck ? [matchingHealthcheck] : [],
|
||||
result.proxyResource.mode === "udp" ? "udp" : "tcp",
|
||||
site.newt.version
|
||||
);
|
||||
if (["http", "tcp", "udp"].includes(target.mode)) {
|
||||
await addProxyTargets(
|
||||
site.newt.newtId,
|
||||
[target],
|
||||
matchingHealthcheck
|
||||
? [matchingHealthcheck]
|
||||
: [],
|
||||
result.proxyResource.mode === "udp"
|
||||
? "udp"
|
||||
: "tcp",
|
||||
site.newt.version
|
||||
);
|
||||
} else if (
|
||||
["ssh", "rdp", "vnc"].includes(target.mode)
|
||||
) {
|
||||
await sendBrowserGatewayTargets(
|
||||
site.newt.newtId,
|
||||
[target],
|
||||
site.newt.version
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -105,7 +105,7 @@ export type ClientResourcesResults = {
|
||||
oldSites: { siteId: number }[];
|
||||
}[];
|
||||
|
||||
export async function updateClientResources(
|
||||
export async function updatePrivateResources(
|
||||
orgId: string,
|
||||
config: Config,
|
||||
trx: Transaction,
|
||||
@@ -52,19 +52,19 @@ import { encrypt } from "@server/lib/crypto";
|
||||
import { generateId } from "@server/auth/sessions/app";
|
||||
import serverConfig from "@server/lib/config";
|
||||
|
||||
export type ProxyResourcesResults = {
|
||||
export type PublicResourcesResults = {
|
||||
proxyResource: Resource;
|
||||
targetsToUpdate: Target[];
|
||||
healthchecksToUpdate: TargetHealthCheck[];
|
||||
}[];
|
||||
|
||||
export async function updateProxyResources(
|
||||
export async function updatePublicResources(
|
||||
orgId: string,
|
||||
config: Config,
|
||||
trx: Transaction,
|
||||
siteId?: number
|
||||
): Promise<ProxyResourcesResults> {
|
||||
const results: ProxyResourcesResults = [];
|
||||
): Promise<PublicResourcesResults> {
|
||||
const results: PublicResourcesResults = [];
|
||||
|
||||
for (const [resourceNiceId, resourceData] of Object.entries(
|
||||
config["proxy-resources"]
|
||||
@@ -579,6 +579,13 @@ export async function updateProxyResources(
|
||||
? (resourceData["proxy-protocol-version"] ??
|
||||
1)
|
||||
: 1,
|
||||
pamMode:
|
||||
resourceData["auth-daemon"]?.pam ||
|
||||
"passthrough",
|
||||
authDaemonMode:
|
||||
resourceData["auth-daemon"]?.mode || "native",
|
||||
authDaemonPort:
|
||||
resourceData["auth-daemon"]?.port || 22123,
|
||||
resourcePolicyId: null,
|
||||
defaultResourcePolicyId: inlinePolicyId
|
||||
})
|
||||
@@ -268,8 +268,37 @@ export const PublicResourceSchema = z
|
||||
return true;
|
||||
}
|
||||
|
||||
// If protocol/mode is http, it must have a full-domain
|
||||
if ((resource.mode ?? resource.protocol) === "http") {
|
||||
const effectiveProtocol = resource.mode ?? resource.protocol;
|
||||
if (effectiveProtocol !== "ssh") {
|
||||
return true;
|
||||
}
|
||||
|
||||
const authDaemonMode = resource["auth-daemon"]?.mode;
|
||||
if (authDaemonMode !== "native" && authDaemonMode !== "site") {
|
||||
return true;
|
||||
}
|
||||
|
||||
return (
|
||||
resource.targets.filter((target) => target != null).length <= 1
|
||||
);
|
||||
},
|
||||
{
|
||||
path: ["targets"],
|
||||
error: "When protocol is 'ssh' and auth-daemon mode is 'native' or 'site', only one target/site is allowed"
|
||||
}
|
||||
)
|
||||
.refine(
|
||||
(resource) => {
|
||||
if (isTargetsOnlyResource(resource)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// If protocol/mode is http, ssh, rdp, or vnc, it must have a full-domain
|
||||
const effectiveProtocol = resource.mode ?? resource.protocol;
|
||||
if (
|
||||
effectiveProtocol !== undefined &&
|
||||
["http", "ssh", "rdp", "vnc"].includes(effectiveProtocol)
|
||||
) {
|
||||
return (
|
||||
resource["full-domain"] !== undefined &&
|
||||
resource["full-domain"].length > 0
|
||||
@@ -279,7 +308,7 @@ export const PublicResourceSchema = z
|
||||
},
|
||||
{
|
||||
path: ["full-domain"],
|
||||
error: "When protocol is 'http', a 'full-domain' must be provided"
|
||||
error: "When protocol is 'http', 'ssh', 'rdp', or 'vnc', a 'full-domain' must be provided"
|
||||
}
|
||||
)
|
||||
.refine(
|
||||
@@ -506,7 +535,44 @@ export const PrivateResourceSchema = z
|
||||
{
|
||||
message: "Destination must be a valid CIDR notation for cidr mode"
|
||||
}
|
||||
);
|
||||
)
|
||||
.refine(
|
||||
(data) => {
|
||||
if (data.mode !== "ssh") {
|
||||
return true;
|
||||
}
|
||||
|
||||
const authDaemonMode = data["auth-daemon"]?.mode;
|
||||
if (authDaemonMode !== "native" && authDaemonMode !== "site") {
|
||||
return true;
|
||||
}
|
||||
|
||||
const uniqueSites = new Set<string>();
|
||||
if (data.site) {
|
||||
uniqueSites.add(data.site);
|
||||
}
|
||||
for (const site of data.sites) {
|
||||
uniqueSites.add(site);
|
||||
}
|
||||
|
||||
return uniqueSites.size <= 1;
|
||||
},
|
||||
{
|
||||
path: ["sites"],
|
||||
message:
|
||||
"When mode is 'ssh' and auth-daemon mode is 'native' or 'site', only one site/target is allowed"
|
||||
}
|
||||
)
|
||||
.transform((data) => {
|
||||
if (
|
||||
data.mode === "ssh" &&
|
||||
data.destination !== undefined &&
|
||||
data["destination-port"] === undefined
|
||||
) {
|
||||
data["destination-port"] = 22;
|
||||
}
|
||||
return data;
|
||||
});
|
||||
|
||||
export const ResourcePolicyRuleSchema = RuleSchema;
|
||||
|
||||
|
||||
@@ -171,8 +171,15 @@ export async function getTraefikConfig(
|
||||
),
|
||||
inArray(sites.type, siteTypes),
|
||||
allowRawResources
|
||||
? inArray(resources.mode, ["http", "udp", "tcp"]) // allow all three
|
||||
: eq(resources.mode, "http")
|
||||
? inArray(resources.mode, [
|
||||
"http",
|
||||
"udp",
|
||||
"tcp",
|
||||
"vnc",
|
||||
"ssh",
|
||||
"rdp"
|
||||
]) // allow all three
|
||||
: inArray(resources.mode, ["http", "vnc", "ssh", "rdp"])
|
||||
)
|
||||
)
|
||||
.orderBy(desc(targets.priority), targets.targetId); // stable ordering
|
||||
@@ -180,9 +187,9 @@ export async function getTraefikConfig(
|
||||
// Group by resource and include targets with their unique site data
|
||||
const resourcesMap = new Map();
|
||||
|
||||
resourcesWithTargetsAndSites.forEach((row) => {
|
||||
for (const row of resourcesWithTargetsAndSites) {
|
||||
if (!["http", "tcp", "udp"].includes(row.mode)) {
|
||||
return;
|
||||
continue;
|
||||
}
|
||||
const resourceId = row.resourceId;
|
||||
const resourceName = sanitize(row.resourceName) || "";
|
||||
@@ -193,7 +200,7 @@ export async function getTraefikConfig(
|
||||
const priority = row.priority ?? 100;
|
||||
|
||||
if (filterOutNamespaceDomains && row.domainNamespaceId) {
|
||||
return;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Create a unique key combining resourceId, path config, and rewrite config
|
||||
@@ -220,7 +227,7 @@ export async function getTraefikConfig(
|
||||
logger.debug(
|
||||
`Invalid path rewrite configuration for resource ${resourceId}: ${validation.error}`
|
||||
);
|
||||
return;
|
||||
continue;
|
||||
}
|
||||
|
||||
resourcesMap.set(mapKey, {
|
||||
@@ -277,7 +284,7 @@ export async function getTraefikConfig(
|
||||
online: row.siteOnline
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Group browser gateway targets by resource
|
||||
type BrowserGatewayResourceEntry = {
|
||||
@@ -313,7 +320,7 @@ export async function getTraefikConfig(
|
||||
if (allowBrowserGatewayResources) {
|
||||
for (const row of resourcesWithTargetsAndSites) {
|
||||
if (!["ssh", "vnc", "rdp"].includes(row.mode)) {
|
||||
return;
|
||||
continue;
|
||||
}
|
||||
if (filterOutNamespaceDomains && row.domainNamespaceId) {
|
||||
continue;
|
||||
|
||||
@@ -0,0 +1,202 @@
|
||||
/*
|
||||
* This file is part of a proprietary work.
|
||||
*
|
||||
* Copyright (c) 2025-2026 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 axios from "axios";
|
||||
import { db, exitNodes, newts, sites } from "@server/db";
|
||||
import { eq } from "drizzle-orm";
|
||||
import logger from "@server/logger";
|
||||
import redisManager from "#private/lib/redis";
|
||||
import { sendToClient } from "#private/routers/ws";
|
||||
|
||||
const INITIAL_DELAY_MS = 15 * 1000; // 15 seconds before first check
|
||||
const CHECK_INTERVAL_MS = 10 * 1000; // Check every 10 seconds
|
||||
const MAX_DURATION_MS = 5 * 60 * 1000; // Give up after 5 minutes
|
||||
const REDIS_PENDING_SET = "exit-node-reconnect-pending";
|
||||
const REDIS_HASH_PREFIX = "exit-node-reconnect:";
|
||||
|
||||
interface PendingReconnect {
|
||||
startTime: number;
|
||||
reachableAt: string;
|
||||
}
|
||||
|
||||
// In-memory tracking for this node
|
||||
const pendingReconnects = new Map<number, PendingReconnect>();
|
||||
|
||||
let schedulerInterval: NodeJS.Timeout | null = null;
|
||||
|
||||
/**
|
||||
* Schedules a reconnect check for newts connected to the given exit node.
|
||||
* Called when an exit node transitions from offline to online.
|
||||
*/
|
||||
export async function scheduleExitNodeReconnect(
|
||||
exitNodeId: number,
|
||||
reachableAt: string
|
||||
): Promise<void> {
|
||||
logger.info(
|
||||
`Scheduling newt reconnect for exit node ${exitNodeId} (reachableAt: ${reachableAt})`
|
||||
);
|
||||
|
||||
const entry: PendingReconnect = {
|
||||
startTime: Date.now(),
|
||||
reachableAt
|
||||
};
|
||||
|
||||
pendingReconnects.set(exitNodeId, entry);
|
||||
|
||||
// Store in Redis if available for cross-node coordination
|
||||
if (redisManager.isRedisEnabled()) {
|
||||
await redisManager.sadd(REDIS_PENDING_SET, exitNodeId.toString());
|
||||
await redisManager.hset(
|
||||
`${REDIS_HASH_PREFIX}${exitNodeId}`,
|
||||
"startTime",
|
||||
entry.startTime.toString()
|
||||
);
|
||||
await redisManager.hset(
|
||||
`${REDIS_HASH_PREFIX}${exitNodeId}`,
|
||||
"reachableAt",
|
||||
reachableAt
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Starts the background interval that checks pending exit node reconnects.
|
||||
*/
|
||||
export function startExitNodeReconnectScheduler(): void {
|
||||
if (schedulerInterval) {
|
||||
return;
|
||||
}
|
||||
|
||||
schedulerInterval = setInterval(async () => {
|
||||
try {
|
||||
await processPendingReconnects();
|
||||
} catch (error) {
|
||||
logger.error("Error in exit node reconnect scheduler", { error });
|
||||
}
|
||||
}, CHECK_INTERVAL_MS);
|
||||
|
||||
logger.debug("Started exit node reconnect scheduler");
|
||||
}
|
||||
|
||||
async function processPendingReconnects(): Promise<void> {
|
||||
// Merge in-memory and Redis-tracked pending reconnects
|
||||
const toProcess = new Map(pendingReconnects);
|
||||
|
||||
if (redisManager.isRedisEnabled()) {
|
||||
const redisIds = await redisManager.smembers(REDIS_PENDING_SET);
|
||||
for (const idStr of redisIds) {
|
||||
const id = parseInt(idStr, 10);
|
||||
if (!toProcess.has(id)) {
|
||||
const startTimeStr = await redisManager.hget(
|
||||
`${REDIS_HASH_PREFIX}${id}`,
|
||||
"startTime"
|
||||
);
|
||||
const reachableAt = await redisManager.hget(
|
||||
`${REDIS_HASH_PREFIX}${id}`,
|
||||
"reachableAt"
|
||||
);
|
||||
if (startTimeStr && reachableAt) {
|
||||
toProcess.set(id, {
|
||||
startTime: parseInt(startTimeStr, 10),
|
||||
reachableAt
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
|
||||
for (const [exitNodeId, entry] of toProcess) {
|
||||
const elapsed = now - entry.startTime;
|
||||
|
||||
// Give up after max duration
|
||||
if (elapsed >= MAX_DURATION_MS) {
|
||||
logger.warn(
|
||||
`Exit node reconnect check timed out for exit node ${exitNodeId} after 5 minutes`
|
||||
);
|
||||
await removePending(exitNodeId);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Respect initial delay
|
||||
if (elapsed < INITIAL_DELAY_MS) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check if the exit node HTTP endpoint is reachable
|
||||
const pingUrl = `${entry.reachableAt}/ping`;
|
||||
try {
|
||||
await axios.get(pingUrl, { timeout: 5000 });
|
||||
} catch {
|
||||
logger.debug(
|
||||
`Exit node ${exitNodeId} not yet reachable at ${pingUrl}`
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Node is reachable — send reconnect to all connected newts
|
||||
logger.info(
|
||||
`Exit node ${exitNodeId} is reachable. Sending newt/wg/reconnect to connected newts.`
|
||||
);
|
||||
|
||||
await sendReconnectToNewts(exitNodeId);
|
||||
await removePending(exitNodeId);
|
||||
}
|
||||
}
|
||||
|
||||
async function sendReconnectToNewts(exitNodeId: number): Promise<void> {
|
||||
try {
|
||||
const connectedNewts = await db
|
||||
.select({ newtId: newts.newtId })
|
||||
.from(newts)
|
||||
.innerJoin(sites, eq(newts.siteId, sites.siteId))
|
||||
.where(eq(sites.exitNodeId, exitNodeId));
|
||||
|
||||
if (connectedNewts.length === 0) {
|
||||
logger.debug(
|
||||
`No newts found for exit node ${exitNodeId}, nothing to reconnect`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
logger.info(
|
||||
`Sending newt/wg/reconnect to ${connectedNewts.length} newt(s) for exit node ${exitNodeId}`
|
||||
);
|
||||
|
||||
const reconnectMessage = {
|
||||
type: "newt/wg/reconnect",
|
||||
data: {}
|
||||
};
|
||||
|
||||
await Promise.allSettled(
|
||||
connectedNewts.map(({ newtId }) =>
|
||||
sendToClient(newtId, reconnectMessage)
|
||||
)
|
||||
);
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
`Failed to send reconnect messages for exit node ${exitNodeId}`,
|
||||
{ error }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function removePending(exitNodeId: number): Promise<void> {
|
||||
pendingReconnects.delete(exitNodeId);
|
||||
|
||||
if (redisManager.isRedisEnabled()) {
|
||||
await redisManager.srem(REDIS_PENDING_SET, exitNodeId.toString());
|
||||
await redisManager.del(`${REDIS_HASH_PREFIX}${exitNodeId}`);
|
||||
}
|
||||
}
|
||||
@@ -16,6 +16,7 @@ import { MessageHandler } from "@server/routers/ws";
|
||||
import { RemoteExitNode } from "@server/db";
|
||||
import { eq } from "drizzle-orm";
|
||||
import logger from "@server/logger";
|
||||
import { scheduleExitNodeReconnect } from "./exitNodeReconnectScheduler";
|
||||
|
||||
/**
|
||||
* Handles ping messages from clients and responds with pong
|
||||
@@ -37,6 +38,13 @@ export const handleRemoteExitNodePingMessage: MessageHandler = async (
|
||||
}
|
||||
|
||||
try {
|
||||
// Fetch the current state before updating so we can detect the offline→online transition
|
||||
const [currentExitNode] = await db
|
||||
.select({ online: exitNodes.online, reachableAt: exitNodes.reachableAt })
|
||||
.from(exitNodes)
|
||||
.where(eq(exitNodes.exitNodeId, remoteExitNode.exitNodeId))
|
||||
.limit(1);
|
||||
|
||||
// Update the exit node's last ping timestamp
|
||||
await db
|
||||
.update(exitNodes)
|
||||
@@ -45,6 +53,16 @@ export const handleRemoteExitNodePingMessage: MessageHandler = async (
|
||||
online: true
|
||||
})
|
||||
.where(eq(exitNodes.exitNodeId, remoteExitNode.exitNodeId));
|
||||
|
||||
// If the exit node was offline and is now coming online, schedule newt reconnects
|
||||
if (currentExitNode && !currentExitNode.online && currentExitNode.reachableAt) {
|
||||
scheduleExitNodeReconnect(
|
||||
remoteExitNode.exitNodeId,
|
||||
currentExitNode.reachableAt
|
||||
).catch((error) => {
|
||||
logger.error("Failed to schedule exit node reconnect", { error });
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error("Error handling ping message", { error });
|
||||
}
|
||||
|
||||
@@ -22,3 +22,4 @@ export * from "./listRemoteExitNodes";
|
||||
export * from "./pickRemoteExitNodeDefaults";
|
||||
export * from "./quickStartRemoteExitNode";
|
||||
export * from "./offlineChecker";
|
||||
export * from "./exitNodeReconnectScheduler";
|
||||
|
||||
@@ -14,7 +14,8 @@
|
||||
import {
|
||||
handleRemoteExitNodeRegisterMessage,
|
||||
handleRemoteExitNodePingMessage,
|
||||
startRemoteExitNodeOfflineChecker
|
||||
startRemoteExitNodeOfflineChecker,
|
||||
startExitNodeReconnectScheduler
|
||||
} from "#private/routers/remoteExitNode";
|
||||
import { MessageHandler } from "@server/routers/ws";
|
||||
import { build } from "@server/build";
|
||||
@@ -29,4 +30,5 @@ export const messageHandlers: Record<string, MessageHandler> = {
|
||||
|
||||
if (build != "saas") {
|
||||
startRemoteExitNodeOfflineChecker(); // this is to handle the offline check for remote exit nodes
|
||||
startExitNodeReconnectScheduler(); // check pending exit node reconnects and notify newts
|
||||
}
|
||||
|
||||
@@ -13,7 +13,7 @@ import {
|
||||
import { olms } from "@server/db";
|
||||
import HttpCode from "@server/types/HttpCode";
|
||||
import response from "@server/lib/response";
|
||||
import { and, eq, inArray } from "drizzle-orm";
|
||||
import { and, count, eq, inArray } from "drizzle-orm";
|
||||
import { NextFunction, Request, Response } from "express";
|
||||
import createHttpError from "http-errors";
|
||||
import { z } from "zod";
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
EXPIRES
|
||||
} from "@server/auth/sessions/olm";
|
||||
import { getOrCreateCachedToken } from "#dynamic/lib/tokenCache";
|
||||
import { listExitNodes } from "#dynamic/lib/exitNodes";
|
||||
import { verifyPassword } from "@server/auth/password";
|
||||
import logger from "@server/logger";
|
||||
import config from "@server/lib/config";
|
||||
@@ -150,6 +151,7 @@ export async function getOlmToken(
|
||||
);
|
||||
|
||||
let clientIdToUse;
|
||||
let orgIdToUse: string;
|
||||
if (orgId) {
|
||||
// we did provide the org
|
||||
const [client] = await db
|
||||
@@ -183,6 +185,7 @@ export async function getOlmToken(
|
||||
}
|
||||
|
||||
clientIdToUse = client.clientId;
|
||||
orgIdToUse = orgId;
|
||||
} else {
|
||||
if (!existingOlm.clientId) {
|
||||
return next(
|
||||
@@ -209,6 +212,7 @@ export async function getOlmToken(
|
||||
}
|
||||
|
||||
clientIdToUse = client.clientId;
|
||||
orgIdToUse = client.orgId;
|
||||
}
|
||||
|
||||
// Get all exit nodes from sites where the client has peers
|
||||
@@ -265,7 +269,7 @@ export async function getOlmToken(
|
||||
}
|
||||
}
|
||||
|
||||
const exitNodesHpData = allExitNodes.map((exitNode: ExitNode) => {
|
||||
let exitNodesHpData = allExitNodes.map((exitNode: ExitNode) => {
|
||||
return {
|
||||
publicKey: exitNode.publicKey,
|
||||
relayPort: config.getRawConfig().gerbil.clients_start_port,
|
||||
@@ -274,6 +278,73 @@ export async function getOlmToken(
|
||||
};
|
||||
});
|
||||
|
||||
// If no exit nodes were found for the client's sites, fall back to
|
||||
// finding an available node in the same region (as newt does on ping).
|
||||
if (exitNodesHpData.length === 0) {
|
||||
logger.debug(
|
||||
`No exit nodes found for olm ${olmId} client sites; falling back to region node selection`
|
||||
);
|
||||
const fallbackNodes = await listExitNodes(orgIdToUse!, true);
|
||||
|
||||
const weightedNodes = await Promise.all(
|
||||
fallbackNodes.map(async (node) => {
|
||||
let weight = 1;
|
||||
const maxConnections = node.maxConnections;
|
||||
if (
|
||||
maxConnections !== null &&
|
||||
maxConnections !== undefined
|
||||
) {
|
||||
const [currentConnections] = await db
|
||||
.select({ count: count() })
|
||||
.from(sites)
|
||||
.where(
|
||||
and(
|
||||
eq(sites.exitNodeId, node.exitNodeId),
|
||||
eq(sites.online, true)
|
||||
)
|
||||
);
|
||||
if (currentConnections.count >= maxConnections) {
|
||||
return null;
|
||||
}
|
||||
weight =
|
||||
(maxConnections - currentConnections.count) /
|
||||
maxConnections;
|
||||
}
|
||||
return { node, weight };
|
||||
})
|
||||
);
|
||||
|
||||
const availableNodes = weightedNodes
|
||||
.filter(
|
||||
(
|
||||
n
|
||||
): n is {
|
||||
node: (typeof fallbackNodes)[0];
|
||||
weight: number;
|
||||
} => n !== null
|
||||
)
|
||||
.sort((a, b) => b.weight - a.weight);
|
||||
|
||||
if (availableNodes.length > 0) {
|
||||
const best = availableNodes[0].node;
|
||||
exitNodesHpData = [
|
||||
{
|
||||
publicKey: best.publicKey,
|
||||
relayPort:
|
||||
config.getRawConfig().gerbil.clients_start_port,
|
||||
endpoint: best.endpoint,
|
||||
siteIds: []
|
||||
// it should still HP without the site ids but it will get stuck in the client
|
||||
// if a site is removed or something because its not tied to a site which is okay for the session
|
||||
}
|
||||
];
|
||||
} else {
|
||||
logger.warn(
|
||||
`No available fallback exit nodes found for olm ${olmId}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
logger.debug("Token created successfully");
|
||||
|
||||
return response<{
|
||||
|
||||
@@ -27,6 +27,7 @@ import {
|
||||
import { encrypt } from "@server/lib/crypto";
|
||||
import { generateId } from "@server/auth/sessions/app";
|
||||
import config from "@server/lib/config";
|
||||
import { sendBrowserGatewayTargets } from "@server/routers/newt/targets";
|
||||
|
||||
const createTargetParamsSchema = z.strictObject({
|
||||
resourceId: z.coerce.number().int().positive()
|
||||
@@ -342,13 +343,21 @@ export async function createTarget(
|
||||
.where(eq(newts.siteId, site.siteId))
|
||||
.limit(1);
|
||||
|
||||
await addTargets(
|
||||
newt.newtId,
|
||||
newTarget,
|
||||
healthCheck,
|
||||
resource.mode === "udp" ? "udp" : "tcp",
|
||||
newt.version
|
||||
);
|
||||
if (["http", "tcp", "udp"].includes(newTarget[0].mode)) {
|
||||
await addTargets(
|
||||
newt.newtId,
|
||||
newTarget,
|
||||
healthCheck,
|
||||
resource.mode === "udp" ? "udp" : "tcp",
|
||||
newt.version
|
||||
);
|
||||
} else if (["ssh", "rdp", "vnc"].includes(newTarget[0].mode)) {
|
||||
await sendBrowserGatewayTargets(
|
||||
newt.newtId,
|
||||
newTarget,
|
||||
newt.version
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ import { fromError } from "zod-validation-error";
|
||||
import { removeTargets } from "../newt/targets";
|
||||
import { OpenAPITags, registry } from "@server/openApi";
|
||||
import { targetHealthCheck } from "@server/db";
|
||||
import { removeBrowserGatewayTarget } from "@server/routers/newt/targets";
|
||||
|
||||
const deleteTargetSchema = z.strictObject({
|
||||
targetId: z.coerce.number().int().positive()
|
||||
@@ -136,14 +137,22 @@ export async function deleteTarget(
|
||||
.where(eq(newts.siteId, site.siteId))
|
||||
.limit(1);
|
||||
|
||||
await removeTargets(
|
||||
newt.newtId,
|
||||
// [deletedTarget],
|
||||
[], // deleting the target from newt causes issues because we cant unbind the port. this needs to be fixed in newt before we can do this
|
||||
[deletedHealthCheck],
|
||||
resource.mode === "udp" ? "udp" : "tcp",
|
||||
newt.version
|
||||
);
|
||||
if (["http", "tcp", "udp"].includes(deletedTarget.mode)) {
|
||||
await removeTargets(
|
||||
newt.newtId,
|
||||
// [deletedTarget],
|
||||
[], // deleting the target from newt causes issues because we cant unbind the port. this needs to be fixed in newt before we can do this
|
||||
[deletedHealthCheck],
|
||||
resource.mode === "udp" ? "udp" : "tcp",
|
||||
newt.version
|
||||
);
|
||||
} else if (["ssh", "rdp", "vnc"].includes(deletedTarget.mode)) {
|
||||
await removeBrowserGatewayTarget(
|
||||
newt.newtId,
|
||||
deletedTarget.targetId,
|
||||
newt.version
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
import { pickPort } from "./helpers";
|
||||
import { isTargetValid } from "@server/lib/validators";
|
||||
import { OpenAPITags, registry } from "@server/openApi";
|
||||
import { sendBrowserGatewayTargets } from "@server/routers/newt/targets";
|
||||
|
||||
const updateTargetParamsSchema = z.strictObject({
|
||||
targetId: z.coerce.number().int().positive()
|
||||
@@ -350,13 +351,21 @@ export async function updateTarget(
|
||||
.where(eq(newts.siteId, site.siteId))
|
||||
.limit(1);
|
||||
|
||||
await addTargets(
|
||||
newt.newtId,
|
||||
[updatedTarget],
|
||||
[updatedHc],
|
||||
resource.mode === "udp" ? "udp" : "tcp",
|
||||
newt.version
|
||||
);
|
||||
if (["http", "tcp", "udp"].includes(updatedTarget.mode)) {
|
||||
await addTargets(
|
||||
newt.newtId,
|
||||
[updatedTarget],
|
||||
[updatedHc],
|
||||
resource.mode === "udp" ? "udp" : "tcp",
|
||||
newt.version
|
||||
);
|
||||
} else if (["ssh", "rdp", "vnc"].includes(updatedTarget.mode)) {
|
||||
await sendBrowserGatewayTargets(
|
||||
newt.newtId,
|
||||
[updatedTarget],
|
||||
newt.version
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
"use client";
|
||||
|
||||
import ActionBanner from "@app/components/ActionBanner";
|
||||
import { EditPolicyForm } from "@app/components/resource-policy/EditPolicyForm";
|
||||
import {
|
||||
SettingsContainer,
|
||||
@@ -45,9 +44,8 @@ import { tierMatrix, TierFeature } from "@server/lib/billing/tierMatrix";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import SetResourcePasswordForm from "@app/components/SetResourcePasswordForm";
|
||||
import { Binary, Bot, InfoIcon, Key } from "lucide-react";
|
||||
import { ArrowRightIcon, CheckIcon, ShieldAlertIcon } from "lucide-react";
|
||||
import { CheckIcon } from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useEffect, useState, useTransition } from "react";
|
||||
import { useForm, useWatch } from "react-hook-form";
|
||||
@@ -184,10 +182,6 @@ export default function ResourceAuthenticationPage() {
|
||||
return <></>;
|
||||
}
|
||||
|
||||
console.log({
|
||||
shared: policies.sharedPolicy
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<SettingsContainer>
|
||||
@@ -314,30 +308,6 @@ export default function ResourceAuthenticationPage() {
|
||||
policy={policies.sharedPolicy}
|
||||
key={policies.sharedPolicy.resourcePolicyId}
|
||||
>
|
||||
<ActionBanner
|
||||
variant="info"
|
||||
title={t("resourcePolicyShared")}
|
||||
titleIcon={
|
||||
<ShieldAlertIcon className="w-5 h-5" />
|
||||
}
|
||||
description={t(
|
||||
"resourcePolicySharedDescription"
|
||||
)}
|
||||
actions={
|
||||
<Button
|
||||
variant="outline"
|
||||
className="gap-2"
|
||||
asChild
|
||||
>
|
||||
<Link
|
||||
href={`/${org.org.orgId}/settings/policies/resources/public/${policies.sharedPolicy.niceId}`}
|
||||
>
|
||||
{t("editSharedPolicy")}
|
||||
<ArrowRightIcon className="size-4" />
|
||||
</Link>
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<EditPolicyForm
|
||||
resourceId={resource.resourceId}
|
||||
/>
|
||||
|
||||
@@ -178,7 +178,6 @@ function RdpServerForm({
|
||||
mode: "rdp",
|
||||
ip: destination,
|
||||
port: Number(destinationPort),
|
||||
authToken: null,
|
||||
hcEnabled: false
|
||||
})
|
||||
)
|
||||
|
||||
@@ -54,7 +54,6 @@ import type { ResourceContextType } from "@app/contexts/resourceContext";
|
||||
type ExistingTarget = {
|
||||
targetId: number;
|
||||
siteId: number;
|
||||
authToken?: string | null;
|
||||
};
|
||||
|
||||
type TargetRow = {
|
||||
@@ -65,7 +64,6 @@ type TargetRow = {
|
||||
mode: string | null;
|
||||
ip: string;
|
||||
port: number;
|
||||
authToken?: string | null;
|
||||
};
|
||||
|
||||
type ResourceTargetsResponse = {
|
||||
@@ -205,7 +203,7 @@ function SshServerForm({
|
||||
? []
|
||||
: targets.map((target) => ({
|
||||
targetId: target.targetId,
|
||||
siteId: target.siteId
|
||||
siteId: target.siteId,
|
||||
}))
|
||||
);
|
||||
|
||||
@@ -215,7 +213,6 @@ function SshServerForm({
|
||||
? {
|
||||
targetId: firstTarget.targetId,
|
||||
siteId: firstTarget.siteId,
|
||||
authToken: firstTarget.authToken
|
||||
}
|
||||
: null
|
||||
);
|
||||
@@ -253,7 +250,8 @@ function SshServerForm({
|
||||
});
|
||||
|
||||
if (isNative) {
|
||||
if (values.selectedNativeSite) {
|
||||
const nativeSite = values.selectedNativeSite;
|
||||
if (nativeSite) {
|
||||
if (nativeExistingTarget) {
|
||||
await api.post(
|
||||
`/target/${nativeExistingTarget.targetId}`,
|
||||
@@ -261,16 +259,19 @@ function SshServerForm({
|
||||
mode: "ssh",
|
||||
ip: "localhost",
|
||||
port: 22,
|
||||
siteId: selectedNativeSite.siteId,
|
||||
authToken: nativeExistingTarget.authToken,
|
||||
siteId: nativeSite.siteId,
|
||||
hcEnabled: false
|
||||
}
|
||||
);
|
||||
setNativeExistingTarget({
|
||||
...nativeExistingTarget,
|
||||
siteId: nativeSite.siteId
|
||||
});
|
||||
} else {
|
||||
const res = await api.put(
|
||||
`/resource/${resource.resourceId}/target`,
|
||||
{
|
||||
siteId: selectedNativeSite.siteId,
|
||||
siteId: nativeSite.siteId,
|
||||
mode: "ssh",
|
||||
ip: "localhost",
|
||||
port: 22,
|
||||
@@ -279,8 +280,7 @@ function SshServerForm({
|
||||
);
|
||||
setNativeExistingTarget({
|
||||
targetId: res.data.data.targetId,
|
||||
siteId: selectedNativeSite.siteId,
|
||||
authToken: res.data.data.authToken
|
||||
siteId: nativeSite.siteId,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -304,71 +304,62 @@ function SshServerForm({
|
||||
(t) => !selectedSiteIds.has(t.siteId)
|
||||
);
|
||||
await Promise.all(
|
||||
toDelete.map((t) =>
|
||||
api.delete(
|
||||
`/org/${orgId}/browser-gateway-target/${t.browserGatewayTargetId}`
|
||||
>>>>>>> 8ee520dbb58f6bd4009581c79322f77b17ff6757
|
||||
)
|
||||
toDelete.map((t) => api.delete(`/target/${t.targetId}`))
|
||||
);
|
||||
|
||||
const toUpdate = existingTargets.filter((t) =>
|
||||
selectedSiteIds.has(t.siteId)
|
||||
);
|
||||
await Promise.all(
|
||||
toUpdate.map((t) =>
|
||||
api.post(`/target/${t.targetId}`, {
|
||||
mode: "ssh",
|
||||
ip: values.destination,
|
||||
port: Number(values.destinationPort),
|
||||
siteId: t.siteId,
|
||||
hcEnabled: false
|
||||
})
|
||||
)
|
||||
);
|
||||
|
||||
<<<<<<< HEAD
|
||||
const toUpdate = existingTargets.filter((t) =>
|
||||
selectedSiteIds.has(t.siteId)
|
||||
);
|
||||
await Promise.all(
|
||||
toUpdate.map((t) =>
|
||||
api.post(
|
||||
`/target/${t.targetId}`,
|
||||
{
|
||||
mode: "ssh",
|
||||
ip: bgDestination,
|
||||
api.delete(`/target/${t.targetId}`)
|
||||
}
|
||||
)
|
||||
const toCreate = activeSites.filter(
|
||||
(s) => !existingSiteIds.has(s.siteId)
|
||||
);
|
||||
|
||||
<<<<<<< HEAD
|
||||
const toCreate = selectedSites.filter(
|
||||
(s) => !existingSiteIds.has(s.siteId)
|
||||
);
|
||||
`/target/${t.targetId}`,
|
||||
toCreate.map((s) =>
|
||||
mode: "ssh",
|
||||
ip: values.destination,
|
||||
port: Number(values.destinationPort),
|
||||
siteId: t.siteId,
|
||||
authToken: t.authToken,
|
||||
hcEnabled: false
|
||||
port: Number(bgDestinationPort),
|
||||
hcEnabled: false
|
||||
)
|
||||
);
|
||||
|
||||
const created = await Promise.all(
|
||||
toCreate.map((s) =>
|
||||
api.put(`/resource/${resource.resourceId}/target`, {
|
||||
siteId: s.siteId,
|
||||
mode: "ssh",
|
||||
ip: values.destination,
|
||||
port: Number(values.destinationPort),
|
||||
hcEnabled: false
|
||||
})
|
||||
)
|
||||
);
|
||||
|
||||
<<<<<<< HEAD
|
||||
const newTargets: ExistingTarget[] = created.map(
|
||||
(res, i) => ({
|
||||
`/resource/${resource.resourceId}/target`,
|
||||
siteId: toCreate[i].siteId,
|
||||
authToken: res.data.data.authToken
|
||||
mode: "ssh",
|
||||
ip: values.destination,
|
||||
port: Number(values.destinationPort),
|
||||
hcEnabled: false
|
||||
const newTargets: ExistingTarget[] = created.map((res, i) => ({
|
||||
browserGatewayTargetId:
|
||||
)
|
||||
);
|
||||
targetId: res.data.data.targetId,
|
||||
siteId: toCreate[i].siteId,
|
||||
}));
|
||||
setExistingTargets([...toUpdate, ...newTargets]);
|
||||
}
|
||||
|
||||
toast({
|
||||
title: t("settingsUpdated"),
|
||||
description: t("settingsUpdatedDescription")
|
||||
});
|
||||
const newTargets: ExistingTarget[] = created.map((res, i) => ({
|
||||
targetId: res.data.data.targetId,
|
||||
siteId: toCreate[i].siteId,
|
||||
authToken: res.data.data.authToken
|
||||
}));
|
||||
setExistingTargets([...toUpdate, ...newTargets]);
|
||||
router.refresh();
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: t("settingsErrorUpdate"),
|
||||
description: formatAxiosError(
|
||||
err,
|
||||
t("settingsErrorUpdateDescription")
|
||||
)
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const authMethodOptions: StrategyOption<"passthrough" | "push">[] = [
|
||||
|
||||
@@ -178,7 +178,6 @@ function VncServerForm({
|
||||
mode: "vnc",
|
||||
ip: destination,
|
||||
port: Number(destinationPort),
|
||||
authToken: null,
|
||||
hcEnabled: false
|
||||
})
|
||||
)
|
||||
|
||||
@@ -638,7 +638,6 @@ export default function Page() {
|
||||
mode: resourceType,
|
||||
ip: bgValues.destination,
|
||||
port: Number(bgValues.destinationPort),
|
||||
authToken: null,
|
||||
hcEnabled: false
|
||||
}
|
||||
);
|
||||
|
||||
+34
-22
@@ -37,6 +37,10 @@ import BrandedAuthSurface from "@app/components/BrandedAuthSurface";
|
||||
import PoweredByPangolin from "@app/components/PoweredByPangolin";
|
||||
import AuthPageFooterNotices from "@app/components/AuthPageFooterNotices";
|
||||
import { useTranslations } from "next-intl";
|
||||
import {
|
||||
loadEncryptedLocalStorage,
|
||||
saveEncryptedLocalStorage
|
||||
} from "@app/lib/secureLocalStorage";
|
||||
|
||||
declare module "react" {
|
||||
namespace JSX {
|
||||
@@ -63,22 +67,14 @@ type RdpCredentialsForm = {
|
||||
enableClipboard: boolean;
|
||||
};
|
||||
|
||||
function loadStoredCredentials(key: string): RdpCredentialsForm {
|
||||
try {
|
||||
const saved = localStorage.getItem(key);
|
||||
if (saved) return JSON.parse(saved) as RdpCredentialsForm;
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
return {
|
||||
username: "",
|
||||
password: "",
|
||||
domain: "",
|
||||
kdcProxyUrl: "",
|
||||
pcb: "",
|
||||
enableClipboard: true
|
||||
};
|
||||
}
|
||||
const DEFAULT_RDP_CREDENTIALS: RdpCredentialsForm = {
|
||||
username: "",
|
||||
password: "",
|
||||
domain: "",
|
||||
kdcProxyUrl: "",
|
||||
pcb: "",
|
||||
enableClipboard: true
|
||||
};
|
||||
|
||||
const isIronError = (error: unknown): error is IronError => {
|
||||
return (
|
||||
@@ -113,9 +109,25 @@ export default function RdpClient({
|
||||
|
||||
const form = useForm<RdpCredentialsForm>({
|
||||
resolver: zodResolver(formSchema),
|
||||
defaultValues: loadStoredCredentials(STORAGE_KEY)
|
||||
defaultValues: DEFAULT_RDP_CREDENTIALS
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
void loadEncryptedLocalStorage<RdpCredentialsForm>(
|
||||
STORAGE_KEY,
|
||||
target?.authToken
|
||||
).then((saved) => {
|
||||
if (cancelled || !saved) return;
|
||||
form.reset({ ...DEFAULT_RDP_CREDENTIALS, ...saved });
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [form, target?.authToken]);
|
||||
|
||||
const [showLogin, setShowLogin] = useState(true);
|
||||
const [moduleReady, setModuleReady] = useState(false);
|
||||
const [connecting, setConnecting] = useState(false);
|
||||
@@ -293,11 +305,11 @@ export default function RdpClient({
|
||||
try {
|
||||
const sessionInfo = await userInteraction.connect(builder.build());
|
||||
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(values));
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
void saveEncryptedLocalStorage(
|
||||
STORAGE_KEY,
|
||||
values,
|
||||
target.authToken
|
||||
);
|
||||
setConnecting(false);
|
||||
setShowLogin(false);
|
||||
userInteraction.setVisibility(true);
|
||||
|
||||
+33
-20
@@ -32,6 +32,10 @@ import { useTranslations } from "next-intl";
|
||||
import BrandedAuthSurface from "@app/components/BrandedAuthSurface";
|
||||
import PoweredByPangolin from "@app/components/PoweredByPangolin";
|
||||
import AuthPageFooterNotices from "@app/components/AuthPageFooterNotices";
|
||||
import {
|
||||
loadEncryptedLocalStorage,
|
||||
saveEncryptedLocalStorage
|
||||
} from "@app/lib/secureLocalStorage";
|
||||
|
||||
type AuthTab = "password" | "privateKey";
|
||||
|
||||
@@ -48,15 +52,11 @@ type ConnectCredentials = {
|
||||
certificate?: string;
|
||||
};
|
||||
|
||||
function loadStoredCredentials(key: string): SshCredentialsForm {
|
||||
try {
|
||||
const saved = localStorage.getItem(key);
|
||||
if (saved) return JSON.parse(saved) as SshCredentialsForm;
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
return { username: "", password: "", privateKey: "" };
|
||||
}
|
||||
const DEFAULT_SSH_CREDENTIALS: SshCredentialsForm = {
|
||||
username: "",
|
||||
password: "",
|
||||
privateKey: ""
|
||||
};
|
||||
|
||||
export default function SshClient({
|
||||
target,
|
||||
@@ -86,9 +86,25 @@ export default function SshClient({
|
||||
});
|
||||
|
||||
const form = useForm<SshCredentialsForm>({
|
||||
defaultValues: loadStoredCredentials(STORAGE_KEY)
|
||||
defaultValues: DEFAULT_SSH_CREDENTIALS
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
void loadEncryptedLocalStorage<SshCredentialsForm>(
|
||||
STORAGE_KEY,
|
||||
target?.authToken
|
||||
).then((saved) => {
|
||||
if (cancelled || !saved) return;
|
||||
form.reset({ ...DEFAULT_SSH_CREDENTIALS, ...saved });
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [form, target?.authToken]);
|
||||
|
||||
function handleKeyFile(e: React.ChangeEvent<HTMLInputElement>) {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
@@ -252,14 +268,11 @@ export default function SshClient({
|
||||
})
|
||||
);
|
||||
if (!override) {
|
||||
try {
|
||||
localStorage.setItem(
|
||||
STORAGE_KEY,
|
||||
JSON.stringify(form.getValues())
|
||||
);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
void saveEncryptedLocalStorage(
|
||||
STORAGE_KEY,
|
||||
form.getValues(),
|
||||
target.authToken
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -625,7 +638,7 @@ export default function SshClient({
|
||||
|
||||
{connected && (
|
||||
<div className="fixed inset-0 z-50 flex flex-col bg-neutral-900">
|
||||
<div className="flex flex-wrap items-center gap-2 bg-black p-2 text-white">
|
||||
{/* <div className="flex flex-wrap items-center gap-2 bg-black p-2 text-white">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="destructive"
|
||||
@@ -633,7 +646,7 @@ export default function SshClient({
|
||||
>
|
||||
{t("sshTerminate")}
|
||||
</Button>
|
||||
</div>
|
||||
</div> */}
|
||||
<div
|
||||
ref={terminalRef}
|
||||
className="flex-1 overflow-hidden"
|
||||
|
||||
+29
-15
@@ -28,20 +28,18 @@ import BrandedAuthSurface from "@app/components/BrandedAuthSurface";
|
||||
import PoweredByPangolin from "@app/components/PoweredByPangolin";
|
||||
import AuthPageFooterNotices from "@app/components/AuthPageFooterNotices";
|
||||
import { useTranslations } from "next-intl";
|
||||
import {
|
||||
loadEncryptedLocalStorage,
|
||||
saveEncryptedLocalStorage
|
||||
} from "@app/lib/secureLocalStorage";
|
||||
|
||||
type VncCredentialsForm = {
|
||||
password: string;
|
||||
};
|
||||
|
||||
function loadStoredCredentials(key: string): VncCredentialsForm {
|
||||
try {
|
||||
const saved = localStorage.getItem(key);
|
||||
if (saved) return JSON.parse(saved) as VncCredentialsForm;
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
return { password: "" };
|
||||
}
|
||||
const DEFAULT_VNC_CREDENTIALS: VncCredentialsForm = {
|
||||
password: ""
|
||||
};
|
||||
|
||||
export default function VncClient({
|
||||
target,
|
||||
@@ -62,9 +60,25 @@ export default function VncClient({
|
||||
|
||||
const form = useForm<VncCredentialsForm>({
|
||||
resolver: zodResolver(formSchema),
|
||||
defaultValues: loadStoredCredentials(STORAGE_KEY)
|
||||
defaultValues: DEFAULT_VNC_CREDENTIALS
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
void loadEncryptedLocalStorage<VncCredentialsForm>(
|
||||
STORAGE_KEY,
|
||||
target?.authToken
|
||||
).then((saved) => {
|
||||
if (cancelled || !saved) return;
|
||||
form.reset({ ...DEFAULT_VNC_CREDENTIALS, ...saved });
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [form, target?.authToken]);
|
||||
|
||||
const [connected, setConnected] = useState(false);
|
||||
const [connectError, setConnectError] = useState<string | null>(null);
|
||||
const rfbRef = useRef<any>(null);
|
||||
@@ -132,11 +146,11 @@ export default function VncClient({
|
||||
rfb.resizeSession = true;
|
||||
|
||||
rfb.addEventListener("connect", () => {
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(values));
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
void saveEncryptedLocalStorage(
|
||||
STORAGE_KEY,
|
||||
values,
|
||||
target.authToken
|
||||
);
|
||||
setConnected(true);
|
||||
});
|
||||
|
||||
|
||||
@@ -48,17 +48,46 @@ export type BrowserGatewayTargetFormProps<T extends FieldValues = FieldValues> =
|
||||
export function BrowserGatewayTargetForm<T extends FieldValues>(
|
||||
props: BrowserGatewayTargetFormProps<T>
|
||||
) {
|
||||
// IDK MAN REMOVING THIS SEEMS TO CAUSE ISSUES
|
||||
// Opt out of the React Compiler for this component.
|
||||
//
|
||||
// The parent (create page) shares a single `bgTargetForm` instance across
|
||||
// multiple conditionally-rendered Form sections (SSH passthrough/push, RDP,
|
||||
// VNC) and calls `bgTargetForm.reset(...)` in a useEffect when the
|
||||
// resource type changes. react-hook-form's Controller uses an external
|
||||
// subscription that the React Compiler cannot statically reason about, so
|
||||
// with `reactCompiler: true` (see next.config.ts) the Compiler can memoize
|
||||
// the render prop and skip re-rendering the <Input> elements when their
|
||||
// bound form values change. The visible symptom is that typing into the
|
||||
// destination/port inputs updates form state but the input itself never
|
||||
// visually updates. The escape hatch is the canonical fix here.
|
||||
"use no memo";
|
||||
const t = useTranslations();
|
||||
const [siteOpen, setSiteOpen] = useState(false);
|
||||
|
||||
const sitesFieldName =
|
||||
props.multiSite === true ? props.sitesField : props.siteField;
|
||||
|
||||
// Subscribe to field values via useWatch and drive the controlled <Input>
|
||||
// elements from these values rather than from the `field.value` returned
|
||||
// by the Controller render prop. Combined with the "use no memo" directive
|
||||
// above, this makes the inputs reliably re-render when their bound form
|
||||
// values change.
|
||||
const watchedSites = useWatch({
|
||||
control: props.control,
|
||||
name: sitesFieldName
|
||||
});
|
||||
|
||||
const watchedDestination = useWatch({
|
||||
control: props.control,
|
||||
name: props.destinationField
|
||||
});
|
||||
|
||||
const watchedDestinationPort = useWatch({
|
||||
control: props.control,
|
||||
name: props.destinationPortField
|
||||
});
|
||||
|
||||
const showMultiSiteDisclaimer =
|
||||
props.multiSite === true &&
|
||||
((watchedSites as Selectedsite[] | undefined)?.length ?? 0) > 1;
|
||||
@@ -141,7 +170,17 @@ export function BrowserGatewayTargetForm<T extends FieldValues>(
|
||||
<FormItem>
|
||||
<FormLabel>{t("destination")}</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} value={field.value ?? ""} />
|
||||
<Input
|
||||
name={field.name}
|
||||
ref={field.ref}
|
||||
onBlur={field.onBlur}
|
||||
onChange={field.onChange}
|
||||
value={
|
||||
(watchedDestination as
|
||||
| string
|
||||
| undefined) ?? ""
|
||||
}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
@@ -158,8 +197,16 @@ export function BrowserGatewayTargetForm<T extends FieldValues>(
|
||||
type="number"
|
||||
min={1}
|
||||
max={65535}
|
||||
{...field}
|
||||
value={field.value ?? ""}
|
||||
name={field.name}
|
||||
ref={field.ref}
|
||||
onBlur={field.onBlur}
|
||||
onChange={field.onChange}
|
||||
value={
|
||||
(watchedDestinationPort as
|
||||
| string
|
||||
| number
|
||||
| undefined) ?? ""
|
||||
}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
|
||||
@@ -1,530 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
SettingsSection,
|
||||
SettingsSectionBody,
|
||||
SettingsSectionDescription,
|
||||
SettingsSectionForm,
|
||||
SettingsSectionHeader,
|
||||
SettingsSectionTitle
|
||||
} from "@app/components/Settings";
|
||||
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
import z from "zod";
|
||||
|
||||
import { createPolicySchema, type PolicyFormValues } from ".";
|
||||
|
||||
import { SwitchInput } from "@app/components/SwitchInput";
|
||||
import { Button } from "@app/components/ui/button";
|
||||
import {
|
||||
Credenza,
|
||||
CredenzaBody,
|
||||
CredenzaClose,
|
||||
CredenzaContent,
|
||||
CredenzaDescription,
|
||||
CredenzaFooter,
|
||||
CredenzaHeader,
|
||||
CredenzaTitle
|
||||
} from "@app/components/Credenza";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage
|
||||
} from "@app/components/ui/form";
|
||||
import { Input } from "@app/components/ui/input";
|
||||
import {
|
||||
InputOTP,
|
||||
InputOTPGroup,
|
||||
InputOTPSlot
|
||||
} from "@app/components/ui/input-otp";
|
||||
|
||||
import { cn } from "@app/lib/cn";
|
||||
import { Binary, Bot, Key, Plus } from "lucide-react";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { type UseFormReturn, useForm, useWatch } from "react-hook-form";
|
||||
|
||||
// ─── CreatePolicyAuthMethodsSectionForm ───────────────────────────────────────
|
||||
|
||||
const setPasswordSchema = z.object({
|
||||
password: z.string().min(4).max(100)
|
||||
});
|
||||
|
||||
const setPincodeSchema = z.object({
|
||||
pincode: z.string().length(6)
|
||||
});
|
||||
|
||||
const setHeaderAuthSchema = z.object({
|
||||
user: z.string().min(4).max(100),
|
||||
password: z.string().min(4).max(100),
|
||||
extendedCompatibility: z.boolean()
|
||||
});
|
||||
|
||||
export type CreatePolicyAuthMethodsSectionFormProps = {
|
||||
form: UseFormReturn<PolicyFormValues, any, any>;
|
||||
};
|
||||
|
||||
export function CreatePolicyAuthMethodsSectionForm({
|
||||
form: parentForm
|
||||
}: CreatePolicyAuthMethodsSectionFormProps) {
|
||||
const t = useTranslations();
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
const [isSetPasswordOpen, setIsSetPasswordOpen] = useState(false);
|
||||
const [isSetPincodeOpen, setIsSetPincodeOpen] = useState(false);
|
||||
const [isSetHeaderAuthOpen, setIsSetHeaderAuthOpen] = useState(false);
|
||||
|
||||
const form = useForm({
|
||||
resolver: zodResolver(
|
||||
createPolicySchema.pick({
|
||||
password: true,
|
||||
pincode: true,
|
||||
headerAuth: true
|
||||
})
|
||||
),
|
||||
defaultValues: {
|
||||
password: null,
|
||||
pincode: null,
|
||||
headerAuth: null
|
||||
}
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const subscription = form.watch((values) => {
|
||||
parentForm.setValue("password", values.password as any);
|
||||
parentForm.setValue("pincode", values.pincode as any);
|
||||
parentForm.setValue("headerAuth", values.headerAuth as any);
|
||||
});
|
||||
return () => subscription.unsubscribe();
|
||||
}, [form, parentForm]);
|
||||
|
||||
const password = useWatch({
|
||||
control: form.control,
|
||||
name: "password"
|
||||
});
|
||||
const pincode = useWatch({
|
||||
control: form.control,
|
||||
name: "pincode"
|
||||
});
|
||||
const headerAuth = useWatch({
|
||||
control: form.control,
|
||||
name: "headerAuth"
|
||||
});
|
||||
|
||||
const passwordForm = useForm({
|
||||
resolver: zodResolver(setPasswordSchema),
|
||||
defaultValues: { password: "" }
|
||||
});
|
||||
|
||||
const pincodeForm = useForm({
|
||||
resolver: zodResolver(setPincodeSchema),
|
||||
defaultValues: { pincode: "" }
|
||||
});
|
||||
|
||||
const headerAuthForm = useForm({
|
||||
resolver: zodResolver(setHeaderAuthSchema),
|
||||
defaultValues: { user: "", password: "", extendedCompatibility: true }
|
||||
});
|
||||
|
||||
if (!isExpanded) {
|
||||
return (
|
||||
<SettingsSection>
|
||||
<SettingsSectionHeader>
|
||||
<SettingsSectionTitle>
|
||||
{t("resourceAuthMethods")}
|
||||
</SettingsSectionTitle>
|
||||
<SettingsSectionDescription>
|
||||
{t("resourcePolicyAuthMethodsDescription")}
|
||||
</SettingsSectionDescription>
|
||||
</SettingsSectionHeader>
|
||||
<SettingsSectionBody>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => setIsExpanded(true)}
|
||||
>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
{t("resourcePolicyAuthMethodAdd")}
|
||||
</Button>
|
||||
</SettingsSectionBody>
|
||||
</SettingsSection>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Password Credenza */}
|
||||
<Credenza
|
||||
open={isSetPasswordOpen}
|
||||
onOpenChange={(val) => {
|
||||
setIsSetPasswordOpen(val);
|
||||
if (!val) passwordForm.reset();
|
||||
}}
|
||||
>
|
||||
<CredenzaContent>
|
||||
<CredenzaHeader>
|
||||
<CredenzaTitle>
|
||||
{t("resourcePasswordSetupTitle")}
|
||||
</CredenzaTitle>
|
||||
<CredenzaDescription>
|
||||
{t("resourcePasswordSetupTitleDescription")}
|
||||
</CredenzaDescription>
|
||||
</CredenzaHeader>
|
||||
<CredenzaBody>
|
||||
<Form {...passwordForm}>
|
||||
<form
|
||||
onSubmit={passwordForm.handleSubmit((data) => {
|
||||
form.setValue("password", data);
|
||||
setIsSetPasswordOpen(false);
|
||||
passwordForm.reset();
|
||||
})}
|
||||
className="space-y-4"
|
||||
id="set-password-form"
|
||||
>
|
||||
<FormField
|
||||
control={passwordForm.control}
|
||||
name="password"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t("password")}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
autoComplete="off"
|
||||
type="password"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</form>
|
||||
</Form>
|
||||
</CredenzaBody>
|
||||
<CredenzaFooter>
|
||||
<CredenzaClose asChild>
|
||||
<Button variant="outline">{t("close")}</Button>
|
||||
</CredenzaClose>
|
||||
<Button type="submit" form="set-password-form">
|
||||
{t("resourcePasswordSubmit")}
|
||||
</Button>
|
||||
</CredenzaFooter>
|
||||
</CredenzaContent>
|
||||
</Credenza>
|
||||
|
||||
{/* Pincode Credenza */}
|
||||
<Credenza
|
||||
open={isSetPincodeOpen}
|
||||
onOpenChange={(val) => {
|
||||
setIsSetPincodeOpen(val);
|
||||
if (!val) pincodeForm.reset();
|
||||
}}
|
||||
>
|
||||
<CredenzaContent>
|
||||
<CredenzaHeader>
|
||||
<CredenzaTitle>
|
||||
{t("resourcePincodeSetupTitle")}
|
||||
</CredenzaTitle>
|
||||
<CredenzaDescription>
|
||||
{t("resourcePincodeSetupTitleDescription")}
|
||||
</CredenzaDescription>
|
||||
</CredenzaHeader>
|
||||
<CredenzaBody>
|
||||
<Form {...pincodeForm}>
|
||||
<form
|
||||
onSubmit={pincodeForm.handleSubmit((data) => {
|
||||
form.setValue("pincode", data);
|
||||
setIsSetPincodeOpen(false);
|
||||
pincodeForm.reset();
|
||||
})}
|
||||
className="space-y-4"
|
||||
id="set-pincode-form"
|
||||
>
|
||||
<FormField
|
||||
control={pincodeForm.control}
|
||||
name="pincode"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t("resourcePincode")}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<div className="flex justify-center">
|
||||
<InputOTP
|
||||
autoComplete="false"
|
||||
maxLength={6}
|
||||
{...field}
|
||||
>
|
||||
<InputOTPGroup className="flex">
|
||||
<InputOTPSlot
|
||||
index={0}
|
||||
obscured
|
||||
/>
|
||||
<InputOTPSlot
|
||||
index={1}
|
||||
obscured
|
||||
/>
|
||||
<InputOTPSlot
|
||||
index={2}
|
||||
obscured
|
||||
/>
|
||||
<InputOTPSlot
|
||||
index={3}
|
||||
obscured
|
||||
/>
|
||||
<InputOTPSlot
|
||||
index={4}
|
||||
obscured
|
||||
/>
|
||||
<InputOTPSlot
|
||||
index={5}
|
||||
obscured
|
||||
/>
|
||||
</InputOTPGroup>
|
||||
</InputOTP>
|
||||
</div>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</form>
|
||||
</Form>
|
||||
</CredenzaBody>
|
||||
<CredenzaFooter>
|
||||
<CredenzaClose asChild>
|
||||
<Button variant="outline">{t("close")}</Button>
|
||||
</CredenzaClose>
|
||||
<Button type="submit" form="set-pincode-form">
|
||||
{t("resourcePincodeSubmit")}
|
||||
</Button>
|
||||
</CredenzaFooter>
|
||||
</CredenzaContent>
|
||||
</Credenza>
|
||||
|
||||
{/* Header Auth Credenza */}
|
||||
<Credenza
|
||||
open={isSetHeaderAuthOpen}
|
||||
onOpenChange={(val) => {
|
||||
setIsSetHeaderAuthOpen(val);
|
||||
if (!val) headerAuthForm.reset();
|
||||
}}
|
||||
>
|
||||
<CredenzaContent>
|
||||
<CredenzaHeader>
|
||||
<CredenzaTitle>
|
||||
{t("resourceHeaderAuthSetupTitle")}
|
||||
</CredenzaTitle>
|
||||
<CredenzaDescription>
|
||||
{t("resourceHeaderAuthSetupTitleDescription")}
|
||||
</CredenzaDescription>
|
||||
</CredenzaHeader>
|
||||
<CredenzaBody>
|
||||
<Form {...headerAuthForm}>
|
||||
<form
|
||||
onSubmit={headerAuthForm.handleSubmit(
|
||||
(data) => {
|
||||
form.setValue("headerAuth", data);
|
||||
setIsSetHeaderAuthOpen(false);
|
||||
headerAuthForm.reset();
|
||||
}
|
||||
)}
|
||||
className="space-y-4"
|
||||
id="set-header-auth-form"
|
||||
>
|
||||
<FormField
|
||||
control={headerAuthForm.control}
|
||||
name="user"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t("user")}</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
autoComplete="off"
|
||||
type="text"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={headerAuthForm.control}
|
||||
name="password"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t("password")}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
autoComplete="off"
|
||||
type="password"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={headerAuthForm.control}
|
||||
name="extendedCompatibility"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormControl>
|
||||
<SwitchInput
|
||||
id="header-auth-compatibility-toggle"
|
||||
label={t(
|
||||
"headerAuthCompatibility"
|
||||
)}
|
||||
description={t(
|
||||
"headerAuthCompatibilityInfo"
|
||||
)}
|
||||
checked={field.value}
|
||||
onCheckedChange={
|
||||
field.onChange
|
||||
}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</form>
|
||||
</Form>
|
||||
</CredenzaBody>
|
||||
<CredenzaFooter>
|
||||
<CredenzaClose asChild>
|
||||
<Button variant="outline">{t("close")}</Button>
|
||||
</CredenzaClose>
|
||||
<Button type="submit" form="set-header-auth-form">
|
||||
{t("resourceHeaderAuthSubmit")}
|
||||
</Button>
|
||||
</CredenzaFooter>
|
||||
</CredenzaContent>
|
||||
</Credenza>
|
||||
|
||||
<SettingsSection>
|
||||
<SettingsSectionHeader>
|
||||
<SettingsSectionTitle>
|
||||
{t("resourceAuthMethods")}
|
||||
</SettingsSectionTitle>
|
||||
<SettingsSectionDescription>
|
||||
{t("resourcePolicyAuthMethodsDescription")}
|
||||
</SettingsSectionDescription>
|
||||
</SettingsSectionHeader>
|
||||
<SettingsSectionBody>
|
||||
<SettingsSectionForm>
|
||||
{/* Password row */}
|
||||
<div className="flex items-center justify-between border rounded-md p-2 mb-4">
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center text-sm space-x-2",
|
||||
password && "text-green-500"
|
||||
)}
|
||||
>
|
||||
<Key size="14" />
|
||||
<span>
|
||||
{t("resourcePasswordProtection", {
|
||||
status: password
|
||||
? t("enabled")
|
||||
: t("disabled")
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={
|
||||
password
|
||||
? () => form.setValue("password", null)
|
||||
: () => setIsSetPasswordOpen(true)
|
||||
}
|
||||
>
|
||||
{password
|
||||
? t("passwordRemove")
|
||||
: t("passwordAdd")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Pincode row */}
|
||||
<div className="flex items-center justify-between border rounded-md p-2">
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center space-x-2 text-sm",
|
||||
pincode && "text-green-500"
|
||||
)}
|
||||
>
|
||||
<Binary size="14" />
|
||||
<span>
|
||||
{t("resourcePincodeProtection", {
|
||||
status: pincode
|
||||
? t("enabled")
|
||||
: t("disabled")
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={
|
||||
pincode
|
||||
? () => form.setValue("pincode", null)
|
||||
: () => setIsSetPincodeOpen(true)
|
||||
}
|
||||
>
|
||||
{pincode ? t("pincodeRemove") : t("pincodeAdd")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Header auth row */}
|
||||
<div className="flex items-center justify-between border rounded-md p-2">
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center space-x-2 text-sm",
|
||||
headerAuth && "text-green-500"
|
||||
)}
|
||||
>
|
||||
<Bot size="14" />
|
||||
<span>
|
||||
{headerAuth
|
||||
? t(
|
||||
"resourceHeaderAuthProtectionEnabled"
|
||||
)
|
||||
: t(
|
||||
"resourceHeaderAuthProtectionDisabled"
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={
|
||||
headerAuth
|
||||
? () =>
|
||||
form.setValue("headerAuth", null)
|
||||
: () => setIsSetHeaderAuthOpen(true)
|
||||
}
|
||||
>
|
||||
{headerAuth
|
||||
? t("headerAuthRemove")
|
||||
: t("headerAuthAdd")}
|
||||
</Button>
|
||||
</div>
|
||||
</SettingsSectionForm>
|
||||
</SettingsSectionBody>
|
||||
</SettingsSection>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -19,7 +19,11 @@ import { build } from "@server/build";
|
||||
import { UserType } from "@server/types/UserTypes";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { type PolicyFormValues, createPolicySchema } from ".";
|
||||
import {
|
||||
type PolicyFormValues,
|
||||
createPolicySchema,
|
||||
createPolicySchemaWithI18n
|
||||
} from ".";
|
||||
import { toast } from "@app/hooks/useToast";
|
||||
import { createApiClient, formatAxiosError } from "@app/lib/api";
|
||||
import { orgs, type ResourcePolicy } from "@server/db";
|
||||
@@ -37,10 +41,8 @@ import {
|
||||
import { Input } from "@app/components/ui/input";
|
||||
import { useMemo, useTransition } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { CreatePolicyUsersRolesSectionForm } from "./CreatePolicyUserRolesSectionForm";
|
||||
import { CreatePolicyAuthMethodsSectionForm } from "./CreatePolicyAuthMethodsSectionForm";
|
||||
import { CreatePolicyOtpEmailSectionForm } from "./CreatePolicyOtpEmailSectionForm";
|
||||
import { CreatePolicyRulesSectionForm } from "./CreatePolicyRulesSectionForm";
|
||||
import { PolicyAuthStackSection } from "./PolicyAuthStackSection";
|
||||
import { PolicyAccessRulesSection } from "./PolicyAccessRulesSection";
|
||||
import { PaidFeaturesAlert } from "@app/components/PaidFeaturesAlert";
|
||||
import { tierMatrix, TierFeature } from "@server/lib/billing/tierMatrix";
|
||||
|
||||
@@ -78,8 +80,13 @@ export function CreatePolicyForm({}: CreatePolicyFormProps) {
|
||||
})
|
||||
);
|
||||
|
||||
const policySchema = useMemo(
|
||||
() => createPolicySchemaWithI18n(t, createPolicySchema),
|
||||
[t]
|
||||
);
|
||||
|
||||
const form = useForm<PolicyFormValues>({
|
||||
resolver: zodResolver(createPolicySchema) as any,
|
||||
resolver: zodResolver(policySchema) as any,
|
||||
defaultValues: {
|
||||
name: "",
|
||||
sso: true,
|
||||
@@ -245,18 +252,17 @@ export function CreatePolicyForm({}: CreatePolicyFormProps) {
|
||||
</SettingsSectionBody>
|
||||
</SettingsSection>
|
||||
|
||||
<CreatePolicyUsersRolesSectionForm
|
||||
<PolicyAuthStackSection
|
||||
mode="create"
|
||||
form={form}
|
||||
orgId={org.org.orgId}
|
||||
allIdps={allIdps}
|
||||
allRoles={allRoles}
|
||||
allUsers={allUsers}
|
||||
allIdps={allIdps}
|
||||
/>
|
||||
<CreatePolicyAuthMethodsSectionForm form={form} />
|
||||
<CreatePolicyOtpEmailSectionForm
|
||||
form={form}
|
||||
emailEnabled={env.email.emailEnabled}
|
||||
/>
|
||||
<CreatePolicyRulesSectionForm
|
||||
<PolicyAccessRulesSection
|
||||
mode="create"
|
||||
form={form}
|
||||
isMaxmindAvailable={isMaxmindAvailable}
|
||||
isMaxmindAsnAvailable={isMaxmindAsnAvailable}
|
||||
|
||||
@@ -1,213 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
SettingsSection,
|
||||
SettingsSectionBody,
|
||||
SettingsSectionDescription,
|
||||
SettingsSectionForm,
|
||||
SettingsSectionHeader,
|
||||
SettingsSectionTitle
|
||||
} from "@app/components/Settings";
|
||||
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
import z from "zod";
|
||||
|
||||
import { createPolicySchema, type PolicyFormValues } from ".";
|
||||
|
||||
import { SwitchInput } from "@app/components/SwitchInput";
|
||||
import { Tag, TagInput } from "@app/components/tags/tag-input";
|
||||
import { Alert, AlertDescription, AlertTitle } from "@app/components/ui/alert";
|
||||
import { Button } from "@app/components/ui/button";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel
|
||||
} from "@app/components/ui/form";
|
||||
import { InfoPopup } from "@app/components/ui/info-popup";
|
||||
|
||||
import { InfoIcon, Plus } from "lucide-react";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { type UseFormReturn, useForm, useWatch } from "react-hook-form";
|
||||
|
||||
// ─── CreatePolicyOtpEmailSectionForm ──────────────────────────────────────────
|
||||
|
||||
export type CreatePolicyOtpEmailSectionFormProps = {
|
||||
form: UseFormReturn<PolicyFormValues, any, any>;
|
||||
emailEnabled: boolean;
|
||||
};
|
||||
|
||||
export function CreatePolicyOtpEmailSectionForm({
|
||||
form: parentForm,
|
||||
emailEnabled
|
||||
}: CreatePolicyOtpEmailSectionFormProps) {
|
||||
const t = useTranslations();
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
const [activeEmailTagIndex, setActiveEmailTagIndex] = useState<
|
||||
number | null
|
||||
>(null);
|
||||
|
||||
const form = useForm({
|
||||
resolver: zodResolver(
|
||||
createPolicySchema.pick({
|
||||
emailWhitelistEnabled: true,
|
||||
emails: true
|
||||
})
|
||||
),
|
||||
defaultValues: {
|
||||
emailWhitelistEnabled: false,
|
||||
emails: []
|
||||
}
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const subscription = form.watch((values) => {
|
||||
parentForm.setValue(
|
||||
"emailWhitelistEnabled",
|
||||
values.emailWhitelistEnabled as boolean
|
||||
);
|
||||
parentForm.setValue("emails", values.emails as [Tag, ...Tag[]]);
|
||||
});
|
||||
return () => subscription.unsubscribe();
|
||||
}, [form, parentForm]);
|
||||
|
||||
const whitelistEnabled = useWatch({
|
||||
control: form.control,
|
||||
name: "emailWhitelistEnabled"
|
||||
});
|
||||
|
||||
if (!isExpanded) {
|
||||
return (
|
||||
<SettingsSection>
|
||||
<SettingsSectionHeader>
|
||||
<SettingsSectionTitle>
|
||||
{t("otpEmailTitle")}
|
||||
</SettingsSectionTitle>
|
||||
<SettingsSectionDescription>
|
||||
{t("otpEmailTitleDescription")}
|
||||
</SettingsSectionDescription>
|
||||
</SettingsSectionHeader>
|
||||
<SettingsSectionBody>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => setIsExpanded(true)}
|
||||
>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
{t("resourcePolicyOtpEmailAdd")}
|
||||
</Button>
|
||||
</SettingsSectionBody>
|
||||
</SettingsSection>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Form {...form}>
|
||||
<SettingsSection>
|
||||
<SettingsSectionHeader>
|
||||
<SettingsSectionTitle>
|
||||
{t("otpEmailTitle")}
|
||||
</SettingsSectionTitle>
|
||||
<SettingsSectionDescription>
|
||||
{t("otpEmailTitleDescription")}
|
||||
</SettingsSectionDescription>
|
||||
</SettingsSectionHeader>
|
||||
<SettingsSectionBody>
|
||||
<SettingsSectionForm>
|
||||
{!emailEnabled && (
|
||||
<Alert variant="neutral" className="mb-4">
|
||||
<InfoIcon className="h-4 w-4" />
|
||||
<AlertTitle className="font-semibold">
|
||||
{t("otpEmailSmtpRequired")}
|
||||
</AlertTitle>
|
||||
<AlertDescription>
|
||||
{t("otpEmailSmtpRequiredDescription")}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
<SwitchInput
|
||||
id="whitelist-toggle"
|
||||
label={t("otpEmailWhitelist")}
|
||||
defaultChecked={false}
|
||||
onCheckedChange={(val) => {
|
||||
form.setValue("emailWhitelistEnabled", val);
|
||||
}}
|
||||
disabled={!emailEnabled}
|
||||
/>
|
||||
|
||||
{whitelistEnabled && emailEnabled && (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="emails"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
<InfoPopup
|
||||
text={t("otpEmailWhitelistList")}
|
||||
info={t(
|
||||
"otpEmailWhitelistListDescription"
|
||||
)}
|
||||
/>
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
{/* @ts-ignore */}
|
||||
<TagInput
|
||||
{...field}
|
||||
activeTagIndex={
|
||||
activeEmailTagIndex
|
||||
}
|
||||
size="sm"
|
||||
validateTag={(tag) => {
|
||||
return z
|
||||
.email()
|
||||
.or(
|
||||
z
|
||||
.string()
|
||||
.regex(
|
||||
/^\*@[\w.-]+\.[a-zA-Z]{2,}$/,
|
||||
{
|
||||
message:
|
||||
t(
|
||||
"otpEmailErrorInvalid"
|
||||
)
|
||||
}
|
||||
)
|
||||
)
|
||||
.safeParse(tag).success;
|
||||
}}
|
||||
setActiveTagIndex={
|
||||
setActiveEmailTagIndex
|
||||
}
|
||||
placeholder={t("otpEmailEnter")}
|
||||
tags={form.getValues().emails}
|
||||
setTags={(newEmails) => {
|
||||
form.setValue(
|
||||
"emails",
|
||||
newEmails as [
|
||||
Tag,
|
||||
...Tag[]
|
||||
]
|
||||
);
|
||||
}}
|
||||
allowDuplicates={false}
|
||||
sortTags={true}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t("otpEmailEnterDescription")}
|
||||
</FormDescription>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</SettingsSectionForm>
|
||||
</SettingsSectionBody>
|
||||
</SettingsSection>
|
||||
</Form>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,257 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
SettingsSection,
|
||||
SettingsSectionBody,
|
||||
SettingsSectionDescription,
|
||||
SettingsSectionForm,
|
||||
SettingsSectionHeader,
|
||||
SettingsSectionTitle
|
||||
} from "@app/components/Settings";
|
||||
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { SwitchInput } from "@app/components/SwitchInput";
|
||||
import { Tag, TagInput } from "@app/components/tags/tag-input";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage
|
||||
} from "@app/components/ui/form";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue
|
||||
} from "@app/components/ui/select";
|
||||
import { createPolicySchema, type PolicyFormValues } from ".";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useEffect, useState } from "react";
|
||||
import { type UseFormReturn, useForm, useWatch } from "react-hook-form";
|
||||
|
||||
// ─── CreatePolicyUsersRolesSectionForm ────────────────────────────────────────
|
||||
|
||||
export type CreatePolicyUsersRolesSectionFormProps = {
|
||||
form: UseFormReturn<PolicyFormValues, any, any>;
|
||||
allRoles: { id: string; text: string }[];
|
||||
allUsers: { id: string; text: string }[];
|
||||
allIdps: { id: number; text: string }[];
|
||||
};
|
||||
|
||||
export function CreatePolicyUsersRolesSectionForm({
|
||||
form: parentForm,
|
||||
allRoles,
|
||||
allUsers,
|
||||
allIdps
|
||||
}: CreatePolicyUsersRolesSectionFormProps) {
|
||||
const t = useTranslations();
|
||||
|
||||
const form = useForm({
|
||||
resolver: zodResolver(
|
||||
createPolicySchema.pick({
|
||||
sso: true,
|
||||
skipToIdpId: true,
|
||||
roles: true,
|
||||
users: true
|
||||
})
|
||||
),
|
||||
defaultValues: {
|
||||
sso: true,
|
||||
skipToIdpId: null,
|
||||
roles: [],
|
||||
users: []
|
||||
}
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const subscription = form.watch((values) => {
|
||||
parentForm.setValue("sso", values.sso as boolean);
|
||||
parentForm.setValue("skipToIdpId", values.skipToIdpId as number | null);
|
||||
parentForm.setValue("roles", values.roles as [Tag, ...Tag[]]);
|
||||
parentForm.setValue("users", values.users as [Tag, ...Tag[]]);
|
||||
});
|
||||
return () => subscription.unsubscribe();
|
||||
}, [form, parentForm]);
|
||||
|
||||
const ssoEnabled = useWatch({ control: form.control, name: "sso" });
|
||||
const selectedIdpId = useWatch({
|
||||
control: form.control,
|
||||
name: "skipToIdpId"
|
||||
});
|
||||
const [activeRolesTagIndex, setActiveRolesTagIndex] = useState<
|
||||
number | null
|
||||
>(null);
|
||||
const [activeUsersTagIndex, setActiveUsersTagIndex] = useState<
|
||||
number | null
|
||||
>(null);
|
||||
|
||||
return (
|
||||
<Form {...form}>
|
||||
<SettingsSection>
|
||||
<SettingsSectionHeader>
|
||||
<SettingsSectionTitle>
|
||||
{t("resourceUsersRoles")}
|
||||
</SettingsSectionTitle>
|
||||
<SettingsSectionDescription>
|
||||
{t("resourcePolicyUsersRolesDescription")}
|
||||
</SettingsSectionDescription>
|
||||
</SettingsSectionHeader>
|
||||
<SettingsSectionBody>
|
||||
<SettingsSectionForm>
|
||||
<SwitchInput
|
||||
id="sso-toggle"
|
||||
label={t("ssoUse")}
|
||||
defaultChecked={ssoEnabled}
|
||||
onCheckedChange={(val) => {
|
||||
form.setValue("sso", val);
|
||||
}}
|
||||
/>
|
||||
|
||||
{ssoEnabled && (
|
||||
<>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="roles"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex flex-col items-start">
|
||||
<FormLabel>{t("roles")}</FormLabel>
|
||||
<FormControl>
|
||||
<TagInput
|
||||
{...field}
|
||||
activeTagIndex={
|
||||
activeRolesTagIndex
|
||||
}
|
||||
setActiveTagIndex={
|
||||
setActiveRolesTagIndex
|
||||
}
|
||||
placeholder={t(
|
||||
"accessRoleSelect2"
|
||||
)}
|
||||
size="sm"
|
||||
tags={form.getValues().roles}
|
||||
setTags={(newRoles) => {
|
||||
form.setValue(
|
||||
"roles",
|
||||
newRoles as [
|
||||
Tag,
|
||||
...Tag[]
|
||||
]
|
||||
);
|
||||
}}
|
||||
enableAutocomplete={true}
|
||||
autocompleteOptions={allRoles}
|
||||
allowDuplicates={false}
|
||||
restrictTagsToAutocompleteOptions={
|
||||
true
|
||||
}
|
||||
sortTags={true}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
<FormDescription>
|
||||
{t("resourceRoleDescription")}
|
||||
</FormDescription>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="users"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex flex-col items-start">
|
||||
<FormLabel>{t("users")}</FormLabel>
|
||||
<FormControl>
|
||||
<TagInput
|
||||
{...field}
|
||||
activeTagIndex={
|
||||
activeUsersTagIndex
|
||||
}
|
||||
setActiveTagIndex={
|
||||
setActiveUsersTagIndex
|
||||
}
|
||||
placeholder={t(
|
||||
"accessUserSelect"
|
||||
)}
|
||||
size="sm"
|
||||
tags={form.getValues().users}
|
||||
setTags={(newUsers) => {
|
||||
form.setValue(
|
||||
"users",
|
||||
newUsers as [
|
||||
Tag,
|
||||
...Tag[]
|
||||
]
|
||||
);
|
||||
}}
|
||||
enableAutocomplete={true}
|
||||
autocompleteOptions={allUsers}
|
||||
allowDuplicates={false}
|
||||
restrictTagsToAutocompleteOptions={
|
||||
true
|
||||
}
|
||||
sortTags={true}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{ssoEnabled && allIdps.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">
|
||||
{t("defaultIdentityProvider")}
|
||||
</label>
|
||||
<Select
|
||||
onValueChange={(value) => {
|
||||
if (value === "none") {
|
||||
form.setValue("skipToIdpId", null);
|
||||
} else {
|
||||
const id = parseInt(value);
|
||||
form.setValue("skipToIdpId", id);
|
||||
}
|
||||
}}
|
||||
value={
|
||||
selectedIdpId
|
||||
? selectedIdpId.toString()
|
||||
: "none"
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="w-full mt-1">
|
||||
<SelectValue
|
||||
placeholder={t(
|
||||
"selectIdpPlaceholder"
|
||||
)}
|
||||
/>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="none">
|
||||
{t("none")}
|
||||
</SelectItem>
|
||||
{allIdps.map((idp) => (
|
||||
<SelectItem
|
||||
key={idp.id}
|
||||
value={idp.id.toString()}
|
||||
>
|
||||
{idp.text}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("defaultIdentityProviderDescription")}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</SettingsSectionForm>
|
||||
</SettingsSectionBody>
|
||||
</SettingsSection>
|
||||
</Form>
|
||||
);
|
||||
}
|
||||
@@ -1,671 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
SettingsSection,
|
||||
SettingsSectionBody,
|
||||
SettingsSectionDescription,
|
||||
SettingsSectionFooter,
|
||||
SettingsSectionForm,
|
||||
SettingsSectionHeader,
|
||||
SettingsSectionTitle
|
||||
} from "@app/components/Settings";
|
||||
|
||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
import z from "zod";
|
||||
|
||||
import { createApiClient, formatAxiosError } from "@app/lib/api";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { createPolicySchema } from ".";
|
||||
|
||||
import {
|
||||
Credenza,
|
||||
CredenzaBody,
|
||||
CredenzaClose,
|
||||
CredenzaContent,
|
||||
CredenzaDescription,
|
||||
CredenzaFooter,
|
||||
CredenzaHeader,
|
||||
CredenzaTitle
|
||||
} from "@app/components/Credenza";
|
||||
import { SwitchInput } from "@app/components/SwitchInput";
|
||||
import { Button } from "@app/components/ui/button";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage
|
||||
} from "@app/components/ui/form";
|
||||
import { Input } from "@app/components/ui/input";
|
||||
import {
|
||||
InputOTP,
|
||||
InputOTPGroup,
|
||||
InputOTPSlot
|
||||
} from "@app/components/ui/input-otp";
|
||||
|
||||
import { Binary, Bot, Key, Plus } from "lucide-react";
|
||||
|
||||
import { cn } from "@app/lib/cn";
|
||||
import { useResourcePolicyContext } from "@app/providers/ResourcePolicyProvider";
|
||||
import { useActionState, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { toast } from "@app/hooks/useToast";
|
||||
import type { AxiosResponse } from "axios";
|
||||
|
||||
// ─── PolicyAuthMethodsSection ─────────────────────────────────────────────────
|
||||
|
||||
const setPasswordSchema = z.object({
|
||||
password: z.string().min(4).max(100)
|
||||
});
|
||||
|
||||
const setPincodeSchema = z.object({
|
||||
pincode: z.string().length(6)
|
||||
});
|
||||
|
||||
const setHeaderAuthSchema = z.object({
|
||||
user: z.string().min(4).max(100),
|
||||
password: z.string().min(4).max(100),
|
||||
extendedCompatibility: z.boolean()
|
||||
});
|
||||
|
||||
export function EditPolicyAuthMethodsSectionForm({
|
||||
readonly
|
||||
}: {
|
||||
readonly?: boolean;
|
||||
}) {
|
||||
const { policy } = useResourcePolicyContext();
|
||||
const router = useRouter();
|
||||
|
||||
const api = createApiClient(useEnvContext());
|
||||
|
||||
const form = useForm({
|
||||
resolver: zodResolver(
|
||||
createPolicySchema.pick({
|
||||
password: true,
|
||||
pincode: true,
|
||||
headerAuth: true
|
||||
})
|
||||
)
|
||||
});
|
||||
|
||||
const t = useTranslations();
|
||||
const [isSetPasswordOpen, setIsSetPasswordOpen] = useState(false);
|
||||
const [isSetPincodeOpen, setIsSetPincodeOpen] = useState(false);
|
||||
const [isSetHeaderAuthOpen, setIsSetHeaderAuthOpen] = useState(false);
|
||||
|
||||
const password = form.watch("password");
|
||||
const pincode = form.watch("pincode");
|
||||
const headerAuth = form.watch("headerAuth");
|
||||
|
||||
// If explicitly removed (set to `null`) it means the value has been removed
|
||||
// in the other case (`undefined` or object value), check if the value has been modified
|
||||
// and fallback to the policy default value
|
||||
const hasPassword =
|
||||
password !== null ? Boolean(password ?? policy.passwordId) : false;
|
||||
|
||||
const hasPincode =
|
||||
pincode !== null ? Boolean(pincode ?? policy.pincodeId) : false;
|
||||
|
||||
const hasHeaderAuth =
|
||||
headerAuth !== null ? Boolean(headerAuth ?? policy.headerAuth) : false;
|
||||
|
||||
const [isExpanded, setIsExpanded] = useState(
|
||||
hasPassword || hasPincode || hasHeaderAuth
|
||||
);
|
||||
|
||||
const passwordForm = useForm({
|
||||
resolver: zodResolver(setPasswordSchema),
|
||||
defaultValues: { password: "" }
|
||||
});
|
||||
|
||||
const pincodeForm = useForm({
|
||||
resolver: zodResolver(setPincodeSchema),
|
||||
defaultValues: { pincode: "" }
|
||||
});
|
||||
|
||||
const headerAuthForm = useForm({
|
||||
resolver: zodResolver(setHeaderAuthSchema),
|
||||
defaultValues: { user: "", password: "", extendedCompatibility: true }
|
||||
});
|
||||
|
||||
const [, formAction, isSubmitting] = useActionState(onSubmit, null);
|
||||
|
||||
async function onSubmit() {
|
||||
if (readonly) return;
|
||||
const isValid = await form.trigger();
|
||||
|
||||
if (!isValid) return;
|
||||
|
||||
const payload = form.getValues();
|
||||
|
||||
const responseArray: Array<Promise<AxiosResponse<{}> | void>> = [];
|
||||
|
||||
if (typeof payload.password !== "undefined") {
|
||||
responseArray.push(
|
||||
api
|
||||
.put<AxiosResponse<{}>>(
|
||||
`/resource-policy/${policy.resourcePolicyId}/password`,
|
||||
{
|
||||
password: payload.password?.password ?? null
|
||||
}
|
||||
)
|
||||
.catch((e) => {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: t("policyErrorUpdate"),
|
||||
description: formatAxiosError(
|
||||
e,
|
||||
t("policyErrorUpdateDescription")
|
||||
)
|
||||
});
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
if (typeof payload.pincode !== "undefined") {
|
||||
responseArray.push(
|
||||
api
|
||||
.put<AxiosResponse<{}>>(
|
||||
`/resource-policy/${policy.resourcePolicyId}/pincode`,
|
||||
{
|
||||
pincode: payload.pincode?.pincode ?? null
|
||||
}
|
||||
)
|
||||
.catch((e) => {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: t("policyErrorUpdate"),
|
||||
description: formatAxiosError(
|
||||
e,
|
||||
t("policyErrorUpdateDescription")
|
||||
)
|
||||
});
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
if (typeof payload.headerAuth !== "undefined") {
|
||||
responseArray.push(
|
||||
api
|
||||
.put<AxiosResponse<{}>>(
|
||||
`/resource-policy/${policy.resourcePolicyId}/header-auth`,
|
||||
{
|
||||
headerAuth: payload.headerAuth
|
||||
}
|
||||
)
|
||||
.catch((e) => {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: t("policyErrorUpdate"),
|
||||
description: formatAxiosError(
|
||||
e,
|
||||
t("policyErrorUpdateDescription")
|
||||
)
|
||||
});
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const responseList = await Promise.all(responseArray);
|
||||
|
||||
if (responseList.every((res) => res && res.status === 200)) {
|
||||
toast({
|
||||
title: t("success"),
|
||||
description: t("policyUpdatedSuccess")
|
||||
});
|
||||
router.refresh();
|
||||
}
|
||||
} catch (e) {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: t("policyErrorUpdate"),
|
||||
description: t("policyErrorUpdateMessageDescription")
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (!isExpanded) {
|
||||
return (
|
||||
<SettingsSection>
|
||||
<SettingsSectionHeader>
|
||||
<SettingsSectionTitle>
|
||||
{t("resourceAuthMethods")}
|
||||
</SettingsSectionTitle>
|
||||
<SettingsSectionDescription>
|
||||
{t("resourcePolicyAuthMethodsDescription")}
|
||||
</SettingsSectionDescription>
|
||||
</SettingsSectionHeader>
|
||||
<SettingsSectionBody>
|
||||
{!readonly ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => setIsExpanded(true)}
|
||||
>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
{t("resourcePolicyAuthMethodAdd")}
|
||||
</Button>
|
||||
) : (
|
||||
<div className="text-muted-foreground flex items-center h-full size-full bg-muted rounded-md px-8 py-6 border-dashed text-sm">
|
||||
<p>{t("resourcePolicyAuthMethodsEmpty")}</p>
|
||||
</div>
|
||||
)}
|
||||
</SettingsSectionBody>
|
||||
</SettingsSection>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Password Credenza */}
|
||||
<Credenza
|
||||
open={isSetPasswordOpen}
|
||||
onOpenChange={(val) => {
|
||||
setIsSetPasswordOpen(val);
|
||||
if (!val) passwordForm.reset();
|
||||
}}
|
||||
>
|
||||
<CredenzaContent>
|
||||
<CredenzaHeader>
|
||||
<CredenzaTitle>
|
||||
{t("resourcePasswordSetupTitle")}
|
||||
</CredenzaTitle>
|
||||
<CredenzaDescription>
|
||||
{t("resourcePasswordSetupTitleDescription")}
|
||||
</CredenzaDescription>
|
||||
</CredenzaHeader>
|
||||
<CredenzaBody>
|
||||
<Form {...passwordForm}>
|
||||
<form
|
||||
onSubmit={passwordForm.handleSubmit((data) => {
|
||||
form.setValue("password", data);
|
||||
setIsSetPasswordOpen(false);
|
||||
passwordForm.reset();
|
||||
})}
|
||||
className="space-y-4"
|
||||
id="set-password-form"
|
||||
>
|
||||
<FormField
|
||||
control={passwordForm.control}
|
||||
name="password"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t("password")}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
autoComplete="off"
|
||||
type="password"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</form>
|
||||
</Form>
|
||||
</CredenzaBody>
|
||||
<CredenzaFooter>
|
||||
<CredenzaClose asChild>
|
||||
<Button variant="outline">{t("close")}</Button>
|
||||
</CredenzaClose>
|
||||
<Button type="submit" form="set-password-form">
|
||||
{t("resourcePasswordSubmit")}
|
||||
</Button>
|
||||
</CredenzaFooter>
|
||||
</CredenzaContent>
|
||||
</Credenza>
|
||||
|
||||
{/* Pincode Credenza */}
|
||||
<Credenza
|
||||
open={isSetPincodeOpen}
|
||||
onOpenChange={(val) => {
|
||||
setIsSetPincodeOpen(val);
|
||||
if (!val) pincodeForm.reset();
|
||||
}}
|
||||
>
|
||||
<CredenzaContent>
|
||||
<CredenzaHeader>
|
||||
<CredenzaTitle>
|
||||
{t("resourcePincodeSetupTitle")}
|
||||
</CredenzaTitle>
|
||||
<CredenzaDescription>
|
||||
{t("resourcePincodeSetupTitleDescription")}
|
||||
</CredenzaDescription>
|
||||
</CredenzaHeader>
|
||||
<CredenzaBody>
|
||||
<Form {...pincodeForm}>
|
||||
<form
|
||||
onSubmit={pincodeForm.handleSubmit((data) => {
|
||||
form.setValue("pincode", data);
|
||||
setIsSetPincodeOpen(false);
|
||||
pincodeForm.reset();
|
||||
})}
|
||||
className="space-y-4"
|
||||
id="set-pincode-form"
|
||||
>
|
||||
<FormField
|
||||
control={pincodeForm.control}
|
||||
name="pincode"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t("resourcePincode")}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<div className="flex justify-center">
|
||||
<InputOTP
|
||||
autoComplete="false"
|
||||
maxLength={6}
|
||||
{...field}
|
||||
>
|
||||
<InputOTPGroup className="flex">
|
||||
<InputOTPSlot
|
||||
index={0}
|
||||
obscured
|
||||
/>
|
||||
<InputOTPSlot
|
||||
index={1}
|
||||
obscured
|
||||
/>
|
||||
<InputOTPSlot
|
||||
index={2}
|
||||
obscured
|
||||
/>
|
||||
<InputOTPSlot
|
||||
index={3}
|
||||
obscured
|
||||
/>
|
||||
<InputOTPSlot
|
||||
index={4}
|
||||
obscured
|
||||
/>
|
||||
<InputOTPSlot
|
||||
index={5}
|
||||
obscured
|
||||
/>
|
||||
</InputOTPGroup>
|
||||
</InputOTP>
|
||||
</div>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</form>
|
||||
</Form>
|
||||
</CredenzaBody>
|
||||
<CredenzaFooter>
|
||||
<CredenzaClose asChild>
|
||||
<Button variant="outline">{t("close")}</Button>
|
||||
</CredenzaClose>
|
||||
<Button type="submit" form="set-pincode-form">
|
||||
{t("resourcePincodeSubmit")}
|
||||
</Button>
|
||||
</CredenzaFooter>
|
||||
</CredenzaContent>
|
||||
</Credenza>
|
||||
|
||||
{/* Header Auth Credenza */}
|
||||
<Credenza
|
||||
open={isSetHeaderAuthOpen}
|
||||
onOpenChange={(val) => {
|
||||
setIsSetHeaderAuthOpen(val);
|
||||
if (!val) headerAuthForm.reset();
|
||||
}}
|
||||
>
|
||||
<CredenzaContent>
|
||||
<CredenzaHeader>
|
||||
<CredenzaTitle>
|
||||
{t("resourceHeaderAuthSetupTitle")}
|
||||
</CredenzaTitle>
|
||||
<CredenzaDescription>
|
||||
{t("resourceHeaderAuthSetupTitleDescription")}
|
||||
</CredenzaDescription>
|
||||
</CredenzaHeader>
|
||||
<CredenzaBody>
|
||||
<Form {...headerAuthForm}>
|
||||
<form
|
||||
onSubmit={headerAuthForm.handleSubmit(
|
||||
(data) => {
|
||||
form.setValue("headerAuth", data);
|
||||
setIsSetHeaderAuthOpen(false);
|
||||
headerAuthForm.reset();
|
||||
}
|
||||
)}
|
||||
className="space-y-4"
|
||||
id="set-header-auth-form"
|
||||
>
|
||||
<FormField
|
||||
control={headerAuthForm.control}
|
||||
name="user"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t("user")}</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
autoComplete="off"
|
||||
type="text"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={headerAuthForm.control}
|
||||
name="password"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t("password")}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
autoComplete="off"
|
||||
type="password"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={headerAuthForm.control}
|
||||
name="extendedCompatibility"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormControl>
|
||||
<SwitchInput
|
||||
id="header-auth-compatibility-toggle"
|
||||
label={t(
|
||||
"headerAuthCompatibility"
|
||||
)}
|
||||
description={t(
|
||||
"headerAuthCompatibilityInfo"
|
||||
)}
|
||||
checked={field.value}
|
||||
onCheckedChange={
|
||||
field.onChange
|
||||
}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</form>
|
||||
</Form>
|
||||
</CredenzaBody>
|
||||
<CredenzaFooter>
|
||||
<CredenzaClose asChild>
|
||||
<Button variant="outline">{t("close")}</Button>
|
||||
</CredenzaClose>
|
||||
<Button type="submit" form="set-header-auth-form">
|
||||
{t("resourceHeaderAuthSubmit")}
|
||||
</Button>
|
||||
</CredenzaFooter>
|
||||
</CredenzaContent>
|
||||
</Credenza>
|
||||
|
||||
<Form {...form}>
|
||||
<form action={formAction}>
|
||||
<SettingsSection>
|
||||
<SettingsSectionHeader>
|
||||
<SettingsSectionTitle>
|
||||
{t("resourceAuthMethods")}
|
||||
</SettingsSectionTitle>
|
||||
<SettingsSectionDescription>
|
||||
{t("resourcePolicyAuthMethodsDescription")}
|
||||
</SettingsSectionDescription>
|
||||
</SettingsSectionHeader>
|
||||
<SettingsSectionBody>
|
||||
<SettingsSectionForm>
|
||||
{/* Password row */}
|
||||
<div className="flex items-center justify-between border rounded-md p-2 mb-4">
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center text-sm gap-x-2",
|
||||
hasPassword && "text-green-500"
|
||||
)}
|
||||
>
|
||||
<Key size="14" />
|
||||
<span>
|
||||
{t("resourcePasswordProtection", {
|
||||
status: hasPassword
|
||||
? t("enabled")
|
||||
: t("disabled")
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
disabled={readonly}
|
||||
onClick={
|
||||
hasPassword
|
||||
? () =>
|
||||
form.setValue(
|
||||
"password",
|
||||
null
|
||||
)
|
||||
: () =>
|
||||
setIsSetPasswordOpen(true)
|
||||
}
|
||||
>
|
||||
{hasPassword
|
||||
? t("passwordRemove")
|
||||
: t("passwordAdd")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Pincode row */}
|
||||
<div className="flex items-center justify-between border rounded-md p-2">
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center gap-x-2 text-sm",
|
||||
hasPincode && "text-green-500"
|
||||
)}
|
||||
>
|
||||
<Binary size="14" />
|
||||
<span>
|
||||
{t("resourcePincodeProtection", {
|
||||
status: hasPincode
|
||||
? t("enabled")
|
||||
: t("disabled")
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
disabled={readonly}
|
||||
onClick={
|
||||
hasPincode
|
||||
? () =>
|
||||
form.setValue(
|
||||
"pincode",
|
||||
null
|
||||
)
|
||||
: () =>
|
||||
setIsSetPincodeOpen(true)
|
||||
}
|
||||
>
|
||||
{hasPincode
|
||||
? t("pincodeRemove")
|
||||
: t("pincodeAdd")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Header auth row */}
|
||||
<div className="flex items-center justify-between border rounded-md p-2">
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center gap-x-2 text-sm",
|
||||
hasHeaderAuth && "text-green-500"
|
||||
)}
|
||||
>
|
||||
<Bot size="14" />
|
||||
<span>
|
||||
{hasHeaderAuth
|
||||
? t(
|
||||
"resourceHeaderAuthProtectionEnabled"
|
||||
)
|
||||
: t(
|
||||
"resourceHeaderAuthProtectionDisabled"
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
disabled={readonly}
|
||||
onClick={
|
||||
hasHeaderAuth
|
||||
? () =>
|
||||
form.setValue(
|
||||
"headerAuth",
|
||||
null
|
||||
)
|
||||
: () =>
|
||||
setIsSetHeaderAuthOpen(
|
||||
true
|
||||
)
|
||||
}
|
||||
>
|
||||
{hasHeaderAuth
|
||||
? t("headerAuthRemove")
|
||||
: t("headerAuthAdd")}
|
||||
</Button>
|
||||
</div>
|
||||
</SettingsSectionForm>
|
||||
</SettingsSectionBody>
|
||||
|
||||
<SettingsSectionFooter>
|
||||
<Button
|
||||
type="submit"
|
||||
loading={isSubmitting}
|
||||
disabled={readonly || isSubmitting}
|
||||
>
|
||||
{t("authMethodsSave")}
|
||||
</Button>
|
||||
</SettingsSectionFooter>
|
||||
</SettingsSection>
|
||||
</form>
|
||||
</Form>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -12,17 +12,11 @@ import { tierMatrix } from "@server/lib/billing/tierMatrix";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
import { createApiClient } from "@app/lib/api";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
import { useMemo } from "react";
|
||||
import { EditPolicyAuthMethodsSectionForm } from "./EditPolicyAuthMethodsSectionForm";
|
||||
import { HorizontalTabs } from "@app/components/HorizontalTabs";
|
||||
import { EditPolicyNameSectionForm } from "./EditPolicyNameSectionForm";
|
||||
import { EditPolicyUsersRolesSectionForm } from "./EditPolicyUserRolesSectionForm";
|
||||
import { EditPolicyOtpEmailSectionForm } from "./EditPolicyOtpEmailSectionForm";
|
||||
import { EditPolicyRulesSectionForm } from "./EditPolicyRulesSectionForm";
|
||||
|
||||
// ─── EditPolicyForm ─────────────────────────────────────────────────────────
|
||||
import { PolicyAuthStackSection } from "./PolicyAuthStackSection";
|
||||
import { PolicyAccessRulesSection } from "./PolicyAccessRulesSection";
|
||||
|
||||
export type EditPolicyFormProps = {
|
||||
hidePolicyNameForm?: boolean;
|
||||
@@ -35,19 +29,15 @@ export function EditPolicyForm({
|
||||
readonly,
|
||||
resourceId
|
||||
}: EditPolicyFormProps) {
|
||||
const { org } = useOrgContext();
|
||||
const t = useTranslations();
|
||||
const { org } = useOrgContext();
|
||||
const { env } = useEnvContext();
|
||||
const api = createApiClient({ env });
|
||||
// const [, formAction, isSubmitting] = useActionState(onSubmit, null);
|
||||
const { isPaidUser } = usePaidStatus();
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
// In overlay mode (resourceId provided), policy-level sections are locked.
|
||||
// Rules and users/roles sections handle their own hybrid logic via resourceId.
|
||||
const isOverlay = resourceId !== undefined;
|
||||
const policyLevelReadonly = readonly || isOverlay;
|
||||
const showTabs = !hidePolicyNameForm && !isOverlay;
|
||||
|
||||
const isMaxmindAvailable = !!(
|
||||
env.server.maxmind_db_path && env.server.maxmind_db_path.length > 0
|
||||
@@ -81,32 +71,54 @@ export function EditPolicyForm({
|
||||
return <></>;
|
||||
}
|
||||
|
||||
const authSection = (
|
||||
<PolicyAuthStackSection
|
||||
mode="edit"
|
||||
orgId={org.org.orgId}
|
||||
allIdps={allIdps}
|
||||
emailEnabled={env.email.emailEnabled}
|
||||
readonly={readonly}
|
||||
resourceId={resourceId}
|
||||
/>
|
||||
);
|
||||
|
||||
const rulesSection = (
|
||||
<PolicyAccessRulesSection
|
||||
mode="edit"
|
||||
isMaxmindAvailable={isMaxmindAvailable}
|
||||
isMaxmindAsnAvailable={isMaxmindASNAvailable}
|
||||
readonly={readonly}
|
||||
resourceId={resourceId}
|
||||
/>
|
||||
);
|
||||
|
||||
if (showTabs) {
|
||||
return (
|
||||
<HorizontalTabs
|
||||
clientSide
|
||||
defaultTab={0}
|
||||
items={[
|
||||
{ title: t("general"), href: "#" },
|
||||
{ title: t("authentication"), href: "#" },
|
||||
{ title: t("policyAccessRulesTitle"), href: "#" }
|
||||
]}
|
||||
>
|
||||
<EditPolicyNameSectionForm readonly={readonly} />
|
||||
{authSection}
|
||||
{rulesSection}
|
||||
</HorizontalTabs>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<SettingsContainer>
|
||||
{!hidePolicyNameForm && (
|
||||
<EditPolicyNameSectionForm readonly={policyLevelReadonly} />
|
||||
{!hidePolicyNameForm && !isOverlay && (
|
||||
<EditPolicyNameSectionForm readonly={readonly} />
|
||||
)}
|
||||
|
||||
<EditPolicyUsersRolesSectionForm
|
||||
orgId={org.org.orgId}
|
||||
allIdps={allIdps}
|
||||
readonly={readonly}
|
||||
resourceId={resourceId}
|
||||
/>
|
||||
{authSection}
|
||||
|
||||
<EditPolicyAuthMethodsSectionForm readonly={policyLevelReadonly} />
|
||||
|
||||
<EditPolicyOtpEmailSectionForm
|
||||
emailEnabled={env.email.emailEnabled}
|
||||
readonly={policyLevelReadonly}
|
||||
/>
|
||||
|
||||
<EditPolicyRulesSectionForm
|
||||
isMaxmindAvailable={isMaxmindAvailable}
|
||||
isMaxmindAsnAvailable={isMaxmindASNAvailable}
|
||||
readonly={readonly}
|
||||
resourceId={resourceId}
|
||||
/>
|
||||
{rulesSection}
|
||||
</SettingsContainer>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -137,7 +137,7 @@ export function EditPolicyNameSectionForm({
|
||||
</SettingsSectionDescription>
|
||||
</SettingsSectionHeader>
|
||||
<SettingsSectionBody>
|
||||
<SettingsSectionForm>
|
||||
<SettingsSectionForm variant="half">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="name"
|
||||
|
||||
@@ -1,294 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
SettingsSection,
|
||||
SettingsSectionBody,
|
||||
SettingsSectionDescription,
|
||||
SettingsSectionFooter,
|
||||
SettingsSectionForm,
|
||||
SettingsSectionHeader,
|
||||
SettingsSectionTitle
|
||||
} from "@app/components/Settings";
|
||||
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
import z from "zod";
|
||||
|
||||
import { createPolicySchema, type PolicyFormValues } from ".";
|
||||
|
||||
import { toast } from "@app/hooks/useToast";
|
||||
import { createApiClient, formatAxiosError } from "@app/lib/api";
|
||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||
import type { AxiosResponse } from "axios";
|
||||
import { SwitchInput } from "@app/components/SwitchInput";
|
||||
import { Tag, TagInput } from "@app/components/tags/tag-input";
|
||||
import { Alert, AlertDescription, AlertTitle } from "@app/components/ui/alert";
|
||||
import { Button } from "@app/components/ui/button";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel
|
||||
} from "@app/components/ui/form";
|
||||
import { InfoPopup } from "@app/components/ui/info-popup";
|
||||
|
||||
import { InfoIcon, Plus } from "lucide-react";
|
||||
|
||||
import { useActionState, useState } from "react";
|
||||
import { useForm, UseFormReturn, useWatch } from "react-hook-form";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useResourcePolicyContext } from "@app/providers/ResourcePolicyProvider";
|
||||
|
||||
// ─── PolicyOtpEmailSection ────────────────────────────────────────────────────
|
||||
|
||||
type PolicyOtpEmailSectionProps = {
|
||||
emailEnabled: boolean;
|
||||
readonly?: boolean;
|
||||
};
|
||||
|
||||
export function EditPolicyOtpEmailSectionForm({
|
||||
emailEnabled,
|
||||
readonly
|
||||
}: PolicyOtpEmailSectionProps) {
|
||||
const t = useTranslations();
|
||||
|
||||
const { policy } = useResourcePolicyContext();
|
||||
const router = useRouter();
|
||||
|
||||
const api = createApiClient(useEnvContext());
|
||||
|
||||
const form = useForm({
|
||||
resolver: zodResolver(
|
||||
createPolicySchema.pick({
|
||||
emailWhitelistEnabled: true,
|
||||
emails: true
|
||||
})
|
||||
),
|
||||
defaultValues: {
|
||||
emailWhitelistEnabled: policy.emailWhitelistEnabled,
|
||||
emails: policy.emailWhiteList.map((email) => ({
|
||||
id: email.whiteListId.toString(),
|
||||
text: email.email
|
||||
}))
|
||||
}
|
||||
});
|
||||
|
||||
const whitelistEnabled = useWatch({
|
||||
control: form.control,
|
||||
name: "emailWhitelistEnabled"
|
||||
});
|
||||
|
||||
const [isExpanded, setIsExpanded] = useState(whitelistEnabled);
|
||||
const [activeEmailTagIndex, setActiveEmailTagIndex] = useState<
|
||||
number | null
|
||||
>(null);
|
||||
|
||||
const [, formAction, isSubmitting] = useActionState(onSubmit, null);
|
||||
|
||||
async function onSubmit() {
|
||||
if (readonly) return;
|
||||
const isValid = await form.trigger();
|
||||
|
||||
if (!isValid) return;
|
||||
|
||||
const payload = form.getValues();
|
||||
|
||||
try {
|
||||
const res = await api
|
||||
.put<AxiosResponse<{}>>(
|
||||
`/resource-policy/${policy.resourcePolicyId}/whitelist`,
|
||||
{
|
||||
emailWhitelistEnabled: payload.emailWhitelistEnabled,
|
||||
emails: payload.emails?.map((e) => e.text) ?? []
|
||||
}
|
||||
)
|
||||
.catch((e) => {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: t("policyErrorUpdate"),
|
||||
description: formatAxiosError(
|
||||
e,
|
||||
t("policyErrorUpdateDescription")
|
||||
)
|
||||
});
|
||||
});
|
||||
|
||||
if (res && res.status === 200) {
|
||||
toast({
|
||||
title: t("success"),
|
||||
description: t("policyUpdatedSuccess")
|
||||
});
|
||||
router.refresh();
|
||||
}
|
||||
} catch (e) {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: t("policyErrorUpdate"),
|
||||
description: t("policyErrorUpdateMessageDescription")
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (!isExpanded) {
|
||||
return (
|
||||
<SettingsSection>
|
||||
<SettingsSectionHeader>
|
||||
<SettingsSectionTitle>
|
||||
{t("otpEmailTitle")}
|
||||
</SettingsSectionTitle>
|
||||
<SettingsSectionDescription>
|
||||
{t("otpEmailTitleDescription")}
|
||||
</SettingsSectionDescription>
|
||||
</SettingsSectionHeader>
|
||||
<SettingsSectionBody>
|
||||
{!readonly ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => setIsExpanded(true)}
|
||||
>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
{t("resourcePolicyOtpEmailAdd")}
|
||||
</Button>
|
||||
) : (
|
||||
<div className="text-muted-foreground flex items-center h-full size-full bg-muted rounded-md px-8 py-6 border-dashed text-sm">
|
||||
<p>{t("resourcePolicyOtpEmpty")}</p>
|
||||
</div>
|
||||
)}
|
||||
</SettingsSectionBody>
|
||||
</SettingsSection>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Form {...form}>
|
||||
<form action={formAction}>
|
||||
<SettingsSection>
|
||||
<SettingsSectionHeader>
|
||||
<SettingsSectionTitle>
|
||||
{t("otpEmailTitle")}
|
||||
</SettingsSectionTitle>
|
||||
<SettingsSectionDescription>
|
||||
{t("otpEmailTitleDescription")}
|
||||
</SettingsSectionDescription>
|
||||
</SettingsSectionHeader>
|
||||
<SettingsSectionBody>
|
||||
<SettingsSectionForm>
|
||||
{!emailEnabled && (
|
||||
<Alert variant="neutral" className="mb-4">
|
||||
<InfoIcon className="h-4 w-4" />
|
||||
<AlertTitle className="font-semibold">
|
||||
{t("otpEmailSmtpRequired")}
|
||||
</AlertTitle>
|
||||
<AlertDescription>
|
||||
{t("otpEmailSmtpRequiredDescription")}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
<SwitchInput
|
||||
id="whitelist-toggle"
|
||||
label={t("otpEmailWhitelist")}
|
||||
defaultChecked={whitelistEnabled}
|
||||
onCheckedChange={(val) => {
|
||||
form.setValue("emailWhitelistEnabled", val);
|
||||
}}
|
||||
disabled={readonly || !emailEnabled}
|
||||
/>
|
||||
|
||||
{whitelistEnabled && emailEnabled && (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="emails"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
<InfoPopup
|
||||
text={t(
|
||||
"otpEmailWhitelistList"
|
||||
)}
|
||||
info={t(
|
||||
"otpEmailWhitelistListDescription"
|
||||
)}
|
||||
/>
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
{/* @ts-ignore */}
|
||||
<TagInput
|
||||
{...field}
|
||||
activeTagIndex={
|
||||
activeEmailTagIndex
|
||||
}
|
||||
size="sm"
|
||||
validateTag={(tag) => {
|
||||
return z
|
||||
.email()
|
||||
.or(
|
||||
z
|
||||
.string()
|
||||
.regex(
|
||||
/^\*@[\w.-]+\.[a-zA-Z]{2,}$/,
|
||||
{
|
||||
message:
|
||||
t(
|
||||
"otpEmailErrorInvalid"
|
||||
)
|
||||
}
|
||||
)
|
||||
)
|
||||
.safeParse(tag)
|
||||
.success;
|
||||
}}
|
||||
setActiveTagIndex={
|
||||
setActiveEmailTagIndex
|
||||
}
|
||||
placeholder={t(
|
||||
"otpEmailEnter"
|
||||
)}
|
||||
tags={
|
||||
form.getValues()
|
||||
.emails ?? []
|
||||
}
|
||||
setTags={(newEmails) => {
|
||||
if (!readonly) {
|
||||
form.setValue(
|
||||
"emails",
|
||||
newEmails as [
|
||||
Tag,
|
||||
...Tag[]
|
||||
]
|
||||
);
|
||||
}
|
||||
}}
|
||||
allowDuplicates={false}
|
||||
sortTags={true}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t("otpEmailEnterDescription")}
|
||||
</FormDescription>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</SettingsSectionForm>
|
||||
|
||||
<SettingsSectionFooter>
|
||||
<Button
|
||||
type="submit"
|
||||
loading={isSubmitting}
|
||||
disabled={
|
||||
readonly || isSubmitting || !emailEnabled
|
||||
}
|
||||
>
|
||||
{t("otpEmailWhitelistSave")}
|
||||
</Button>
|
||||
</SettingsSectionFooter>
|
||||
</SettingsSectionBody>
|
||||
</SettingsSection>
|
||||
</form>
|
||||
</Form>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,530 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
SettingsSection,
|
||||
SettingsSectionBody,
|
||||
SettingsSectionDescription,
|
||||
SettingsSectionFooter,
|
||||
SettingsSectionForm,
|
||||
SettingsSectionHeader,
|
||||
SettingsSectionTitle
|
||||
} from "@app/components/Settings";
|
||||
|
||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||
|
||||
import { getUserDisplayName } from "@app/lib/getUserDisplayName";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { UserType } from "@server/types/UserTypes";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
import { toast } from "@app/hooks/useToast";
|
||||
import { createApiClient, formatAxiosError } from "@app/lib/api";
|
||||
import type { AxiosResponse } from "axios";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { createPolicySchema } from ".";
|
||||
|
||||
import {
|
||||
RolesSelector,
|
||||
type SelectedRole
|
||||
} from "@app/components/roles-selector";
|
||||
import { UsersSelector } from "@app/components/users-selector";
|
||||
import { SwitchInput } from "@app/components/SwitchInput";
|
||||
import { Button } from "@app/components/ui/button";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage
|
||||
} from "@app/components/ui/form";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue
|
||||
} from "@app/components/ui/select";
|
||||
|
||||
import { useResourcePolicyContext } from "@app/providers/ResourcePolicyProvider";
|
||||
import { resourceQueries } from "@app/lib/queries";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useActionState, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useForm, useWatch } from "react-hook-form";
|
||||
|
||||
// ─── PolicyUsersRolesSection ──────────────────────────────────────────────────
|
||||
|
||||
type PolicyUsersRolesSectionProps = {
|
||||
orgId: string;
|
||||
allIdps: { id: number; text: string }[];
|
||||
readonly?: boolean;
|
||||
resourceId?: number;
|
||||
};
|
||||
|
||||
type OverlaySelectedRole = SelectedRole & { isAdmin: boolean };
|
||||
|
||||
export function EditPolicyUsersRolesSectionForm({
|
||||
orgId,
|
||||
allIdps,
|
||||
readonly,
|
||||
resourceId
|
||||
}: PolicyUsersRolesSectionProps) {
|
||||
const t = useTranslations();
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
const { policy } = useResourcePolicyContext();
|
||||
|
||||
const api = createApiClient(useEnvContext());
|
||||
|
||||
// ── Resource overlay: fetch resource-specific roles & users ──────────────
|
||||
const isResourceOverlay = resourceId !== undefined;
|
||||
|
||||
const { data: resourceRolesData } = useQuery({
|
||||
...resourceQueries.resourceRoles({ resourceId: resourceId! }),
|
||||
enabled: isResourceOverlay
|
||||
});
|
||||
|
||||
const { data: resourceUsersData } = useQuery({
|
||||
...resourceQueries.resourceUsers({ resourceId: resourceId! }),
|
||||
enabled: isResourceOverlay
|
||||
});
|
||||
|
||||
// IDs from the policy (locked — cannot be removed)
|
||||
const policyRoleLockedIds = useMemo(
|
||||
() => new Set(policy.roles.map((r) => r.roleId.toString())),
|
||||
[policy.roles]
|
||||
);
|
||||
const policyUserLockedIds = useMemo(
|
||||
() => new Set(policy.users.map((u) => u.userId)),
|
||||
[policy.users]
|
||||
);
|
||||
|
||||
// Policy entries mapped to selector format
|
||||
const policyRoleItems = useMemo<OverlaySelectedRole[]>(
|
||||
() =>
|
||||
policy.roles.map((r) => ({
|
||||
id: r.roleId.toString(),
|
||||
text: r.name,
|
||||
isAdmin: false
|
||||
})),
|
||||
[policy.roles]
|
||||
);
|
||||
const policyUserItems = useMemo(
|
||||
() =>
|
||||
policy.users.map((u) => ({
|
||||
id: u.userId,
|
||||
text: `${getUserDisplayName({ email: u.email, username: u.username })}${u.type !== UserType.Internal ? ` (${u.idpName})` : ""}`
|
||||
})),
|
||||
[policy.users]
|
||||
);
|
||||
|
||||
// Track the initial resource-specific roles/users for diffing on save
|
||||
const initialResourceRoleIdsRef = useRef<Set<string>>(new Set());
|
||||
const initialResourceUserIdsRef = useRef<Set<string>>(new Set());
|
||||
|
||||
// Combined selected roles/users (policy + resource-specific)
|
||||
const [combinedRoles, setCombinedRoles] =
|
||||
useState<OverlaySelectedRole[]>(policyRoleItems);
|
||||
const [combinedUsers, setCombinedUsers] = useState(policyUserItems);
|
||||
const [resourceRolesInitialized, setResourceRolesInitialized] =
|
||||
useState(false);
|
||||
const [resourceUsersInitialized, setResourceUsersInitialized] =
|
||||
useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isResourceOverlay || resourceRolesInitialized) return;
|
||||
if (!resourceRolesData) return;
|
||||
|
||||
const resourceSpecific = resourceRolesData
|
||||
.filter((r) => !policyRoleLockedIds.has(r.roleId.toString()))
|
||||
.map((r) => ({
|
||||
id: r.roleId.toString(),
|
||||
text: r.name,
|
||||
isAdmin: Boolean(r.isAdmin)
|
||||
}));
|
||||
|
||||
initialResourceRoleIdsRef.current = new Set(
|
||||
resourceSpecific.map((r) => r.id)
|
||||
);
|
||||
setCombinedRoles(
|
||||
[...policyRoleItems, ...resourceSpecific].filter(
|
||||
(role) => !role.isAdmin
|
||||
)
|
||||
);
|
||||
setResourceRolesInitialized(true);
|
||||
}, [
|
||||
isResourceOverlay,
|
||||
resourceRolesData,
|
||||
resourceRolesInitialized,
|
||||
policyRoleItems,
|
||||
policyRoleLockedIds
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isResourceOverlay || resourceUsersInitialized) return;
|
||||
if (!resourceUsersData) return;
|
||||
|
||||
const resourceSpecific = resourceUsersData
|
||||
.filter((u) => !policyUserLockedIds.has(u.userId))
|
||||
.map((u) => ({
|
||||
id: u.userId,
|
||||
text: `${getUserDisplayName({ email: u.email ?? undefined, username: u.username ?? undefined })}${u.type !== UserType.Internal ? ` (${u.idpName})` : ""}`
|
||||
}));
|
||||
|
||||
initialResourceUserIdsRef.current = new Set(
|
||||
resourceSpecific.map((u) => u.id)
|
||||
);
|
||||
setCombinedUsers([...policyUserItems, ...resourceSpecific]);
|
||||
setResourceUsersInitialized(true);
|
||||
}, [
|
||||
isResourceOverlay,
|
||||
resourceUsersData,
|
||||
resourceUsersInitialized,
|
||||
policyUserItems,
|
||||
policyUserLockedIds
|
||||
]);
|
||||
|
||||
// ── Standard policy form (non-overlay) ──────────────────────────────────
|
||||
const form = useForm({
|
||||
resolver: zodResolver(
|
||||
createPolicySchema.pick({
|
||||
sso: true,
|
||||
skipToIdpId: true,
|
||||
users: true,
|
||||
roles: true
|
||||
})
|
||||
),
|
||||
defaultValues: {
|
||||
sso: policy.sso,
|
||||
skipToIdpId: policy.idpId,
|
||||
roles: policyRoleItems,
|
||||
users: policyUserItems
|
||||
}
|
||||
});
|
||||
|
||||
const ssoEnabled = useWatch({ control: form.control, name: "sso" });
|
||||
const selectedIdpId = useWatch({
|
||||
control: form.control,
|
||||
name: "skipToIdpId"
|
||||
});
|
||||
|
||||
const [, formAction, isSubmitting] = useActionState(onSubmit, null);
|
||||
const [isSavingOverlay, setIsSavingOverlay] = useState(false);
|
||||
|
||||
async function onSubmit() {
|
||||
if (readonly) return;
|
||||
|
||||
if (isResourceOverlay) {
|
||||
await saveResourceOverlay();
|
||||
return;
|
||||
}
|
||||
|
||||
const isValid = await form.trigger();
|
||||
if (!isValid) return;
|
||||
|
||||
const payload = form.getValues();
|
||||
|
||||
try {
|
||||
const res = await api
|
||||
.put<AxiosResponse<{}>>(
|
||||
`/resource-policy/${policy.resourcePolicyId}/access-control`,
|
||||
{
|
||||
sso: payload.sso,
|
||||
userIds: payload.users.map((user) => user.id),
|
||||
roleIds: payload.roles.map((role) => Number(role.id)),
|
||||
skipToIdpId: payload.skipToIdpId
|
||||
}
|
||||
)
|
||||
.catch((e) => {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: t("policyErrorUpdate"),
|
||||
description: formatAxiosError(
|
||||
e,
|
||||
t("policyErrorUpdateDescription")
|
||||
)
|
||||
});
|
||||
});
|
||||
|
||||
if (res && res.status === 200) {
|
||||
toast({
|
||||
title: t("success"),
|
||||
description: t("policyUpdatedSuccess")
|
||||
});
|
||||
router.refresh();
|
||||
}
|
||||
} catch (e) {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: t("policyErrorUpdate"),
|
||||
description: t("policyErrorUpdateMessageDescription")
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function saveResourceOverlay() {
|
||||
setIsSavingOverlay(true);
|
||||
try {
|
||||
// Compute which roles/users are resource-specific (non-locked)
|
||||
const currentResourceRoleIds = combinedRoles
|
||||
.filter((r) => !policyRoleLockedIds.has(r.id))
|
||||
.map((r) => Number(r.id));
|
||||
const currentResourceUserIds = combinedUsers
|
||||
.filter((u) => !policyUserLockedIds.has(u.id))
|
||||
.map((u) => u.id);
|
||||
|
||||
// Use bulk-set endpoints (session-authenticated) which replace
|
||||
// all resource-specific roles/users in one call
|
||||
await Promise.all([
|
||||
api.post(`/resource/${resourceId}/roles`, {
|
||||
roleIds: currentResourceRoleIds
|
||||
}),
|
||||
api.post(`/resource/${resourceId}/users`, {
|
||||
userIds: currentResourceUserIds
|
||||
})
|
||||
]);
|
||||
|
||||
// Update refs to reflect new state
|
||||
initialResourceRoleIdsRef.current = new Set(
|
||||
currentResourceRoleIds.map(String)
|
||||
);
|
||||
initialResourceUserIdsRef.current = new Set(currentResourceUserIds);
|
||||
|
||||
toast({
|
||||
title: t("success"),
|
||||
description: t("policyUpdatedSuccess")
|
||||
});
|
||||
router.refresh();
|
||||
} catch (e) {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: t("policyErrorUpdate"),
|
||||
description: formatAxiosError(
|
||||
e,
|
||||
t("policyErrorUpdateDescription")
|
||||
)
|
||||
});
|
||||
} finally {
|
||||
setIsSavingOverlay(false);
|
||||
}
|
||||
}
|
||||
|
||||
const isLoading =
|
||||
isResourceOverlay &&
|
||||
(!resourceRolesInitialized || !resourceUsersInitialized);
|
||||
|
||||
return (
|
||||
<Form {...form}>
|
||||
<form action={formAction}>
|
||||
<SettingsSection>
|
||||
<SettingsSectionHeader>
|
||||
<SettingsSectionTitle>
|
||||
{t("resourceUsersRoles")}
|
||||
</SettingsSectionTitle>
|
||||
<SettingsSectionDescription>
|
||||
{t("resourcePolicyUsersRolesDescription")}
|
||||
</SettingsSectionDescription>
|
||||
</SettingsSectionHeader>
|
||||
<SettingsSectionBody>
|
||||
<SettingsSectionForm>
|
||||
<SwitchInput
|
||||
id="sso-toggle"
|
||||
label={t("ssoUse")}
|
||||
defaultChecked={ssoEnabled}
|
||||
onCheckedChange={(val) => {
|
||||
form.setValue("sso", val);
|
||||
}}
|
||||
disabled={readonly || isResourceOverlay}
|
||||
/>
|
||||
|
||||
{ssoEnabled && (
|
||||
<>
|
||||
<FormItem className="flex flex-col items-start">
|
||||
<FormLabel>{t("roles")}</FormLabel>
|
||||
<FormControl>
|
||||
{isResourceOverlay ? (
|
||||
<RolesSelector
|
||||
orgId={orgId}
|
||||
selectedRoles={combinedRoles.filter(
|
||||
(role) => !role.isAdmin
|
||||
)}
|
||||
onSelectRoles={(roles) => {
|
||||
setCombinedRoles(
|
||||
roles
|
||||
.map(
|
||||
(role) => ({
|
||||
...role,
|
||||
isAdmin:
|
||||
Boolean(
|
||||
role.isAdmin
|
||||
)
|
||||
})
|
||||
)
|
||||
.filter(
|
||||
(role) =>
|
||||
!role.isAdmin
|
||||
)
|
||||
);
|
||||
}}
|
||||
disabled={isLoading}
|
||||
restrictAdminRole
|
||||
lockedIds={
|
||||
policyRoleLockedIds
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="roles"
|
||||
render={({ field }) => (
|
||||
<RolesSelector
|
||||
orgId={orgId}
|
||||
selectedRoles={
|
||||
field.value
|
||||
}
|
||||
onSelectRoles={(
|
||||
roles
|
||||
) =>
|
||||
form.setValue(
|
||||
"roles",
|
||||
roles
|
||||
)
|
||||
}
|
||||
disabled={readonly}
|
||||
restrictAdminRole
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
<FormDescription>
|
||||
{t("resourceRoleDescription")}
|
||||
</FormDescription>
|
||||
</FormItem>
|
||||
|
||||
<FormItem className="flex flex-col items-start">
|
||||
<FormLabel>{t("users")}</FormLabel>
|
||||
<FormControl>
|
||||
{isResourceOverlay ? (
|
||||
<UsersSelector
|
||||
orgId={orgId}
|
||||
selectedUsers={
|
||||
combinedUsers
|
||||
}
|
||||
onSelectUsers={
|
||||
setCombinedUsers
|
||||
}
|
||||
disabled={isLoading}
|
||||
lockedIds={
|
||||
policyUserLockedIds
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="users"
|
||||
render={({ field }) => (
|
||||
<UsersSelector
|
||||
orgId={orgId}
|
||||
selectedUsers={
|
||||
field.value
|
||||
}
|
||||
onSelectUsers={(
|
||||
users
|
||||
) =>
|
||||
form.setValue(
|
||||
"users",
|
||||
users
|
||||
)
|
||||
}
|
||||
disabled={readonly}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
</>
|
||||
)}
|
||||
|
||||
{ssoEnabled && allIdps.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">
|
||||
{t("defaultIdentityProvider")}
|
||||
</label>
|
||||
<Select
|
||||
disabled={readonly || isResourceOverlay}
|
||||
onValueChange={(value) => {
|
||||
if (value === "none") {
|
||||
form.setValue(
|
||||
"skipToIdpId",
|
||||
null
|
||||
);
|
||||
} else {
|
||||
const id = parseInt(value);
|
||||
form.setValue(
|
||||
"skipToIdpId",
|
||||
id
|
||||
);
|
||||
}
|
||||
}}
|
||||
value={
|
||||
selectedIdpId
|
||||
? selectedIdpId.toString()
|
||||
: "none"
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="w-full mt-1">
|
||||
<SelectValue
|
||||
placeholder={t(
|
||||
"selectIdpPlaceholder"
|
||||
)}
|
||||
/>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="none">
|
||||
{t("none")}
|
||||
</SelectItem>
|
||||
{allIdps.map((idp) => (
|
||||
<SelectItem
|
||||
key={idp.id}
|
||||
value={idp.id.toString()}
|
||||
>
|
||||
{idp.text}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t(
|
||||
"defaultIdentityProviderDescription"
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</SettingsSectionForm>
|
||||
</SettingsSectionBody>
|
||||
|
||||
<SettingsSectionFooter>
|
||||
<Button
|
||||
type="submit"
|
||||
loading={isSubmitting || isSavingOverlay}
|
||||
disabled={
|
||||
readonly ||
|
||||
isSubmitting ||
|
||||
isSavingOverlay ||
|
||||
isLoading
|
||||
}
|
||||
>
|
||||
{t("resourceUsersRolesSubmit")}
|
||||
</Button>
|
||||
</SettingsSectionFooter>
|
||||
</SettingsSection>
|
||||
</form>
|
||||
</Form>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
"use client";
|
||||
|
||||
import { SwitchInput } from "@app/components/SwitchInput";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
export type PolicyAccessRulesIntroProps = {
|
||||
rulesEnabled: boolean;
|
||||
onRulesEnabledChange: (enabled: boolean) => void;
|
||||
disableToggle?: boolean;
|
||||
};
|
||||
|
||||
export function PolicyAccessRulesIntro({
|
||||
rulesEnabled,
|
||||
onRulesEnabledChange,
|
||||
disableToggle
|
||||
}: PolicyAccessRulesIntroProps) {
|
||||
const t = useTranslations();
|
||||
|
||||
return (
|
||||
<SwitchInput
|
||||
id="rules-toggle"
|
||||
label={t("rulesEnable")}
|
||||
description={t("policyAccessRulesEnableDescription")}
|
||||
checked={rulesEnabled}
|
||||
disabled={disableToggle}
|
||||
onCheckedChange={onRulesEnabledChange}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,412 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
SettingsSection,
|
||||
SettingsSectionBody,
|
||||
SettingsSectionDescription,
|
||||
SettingsSectionFooter,
|
||||
SettingsSectionHeader,
|
||||
SettingsSectionTitle
|
||||
} from "@app/components/Settings";
|
||||
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
import { toast } from "@app/hooks/useToast";
|
||||
import {
|
||||
createPolicyRulesSectionSchema,
|
||||
validatePolicyRulesForSave,
|
||||
type PolicyFormValues
|
||||
} from ".";
|
||||
|
||||
import { Button } from "@app/components/ui/button";
|
||||
import { Plus } from "lucide-react";
|
||||
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
useTransition
|
||||
} from "react";
|
||||
import { UseFormReturn, useForm, useWatch } from "react-hook-form";
|
||||
import { useResourcePolicyContext } from "@app/providers/ResourcePolicyProvider";
|
||||
import { createApiClient, formatAxiosError } from "@app/lib/api";
|
||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||
import { resourceQueries } from "@app/lib/queries";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import type { AxiosResponse } from "axios";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { CreatePolicyRulesSectionForm } from "./CreatePolicyRulesSectionForm";
|
||||
import { PolicyAccessRulesIntro } from "./PolicyAccessRulesIntro";
|
||||
import { PolicyAccessRulesTable } from "./PolicyAccessRulesTable";
|
||||
import {
|
||||
createEmptyRule,
|
||||
type PolicyAccessRule
|
||||
} from "./policy-access-rule-utils";
|
||||
|
||||
// ─── PolicyRulesSection ───────────────────────────────────────────────────────
|
||||
|
||||
type PolicyAccessRulesSectionEditProps = {
|
||||
mode: "edit";
|
||||
isMaxmindAvailable: boolean;
|
||||
isMaxmindAsnAvailable: boolean;
|
||||
readonly?: boolean;
|
||||
resourceId?: number;
|
||||
};
|
||||
|
||||
type PolicyAccessRulesSectionCreateProps = {
|
||||
mode: "create";
|
||||
form: UseFormReturn<PolicyFormValues, any, any>;
|
||||
isMaxmindAvailable: boolean;
|
||||
isMaxmindAsnAvailable: boolean;
|
||||
};
|
||||
|
||||
export type PolicyAccessRulesSectionProps =
|
||||
| PolicyAccessRulesSectionEditProps
|
||||
| PolicyAccessRulesSectionCreateProps;
|
||||
|
||||
export function PolicyAccessRulesSection(props: PolicyAccessRulesSectionProps) {
|
||||
if (props.mode === "create") {
|
||||
return <PolicyAccessRulesSectionCreate {...props} />;
|
||||
}
|
||||
return <PolicyAccessRulesSectionEdit {...props} />;
|
||||
}
|
||||
|
||||
function PolicyAccessRulesSectionEdit({
|
||||
isMaxmindAvailable,
|
||||
isMaxmindAsnAvailable,
|
||||
readonly,
|
||||
resourceId
|
||||
}: PolicyAccessRulesSectionEditProps) {
|
||||
const t = useTranslations();
|
||||
|
||||
const { policy } = useResourcePolicyContext();
|
||||
const api = createApiClient(useEnvContext());
|
||||
const router = useRouter();
|
||||
|
||||
const isResourceOverlay = resourceId !== undefined;
|
||||
|
||||
const { data: resourceRulesData } = useQuery({
|
||||
...resourceQueries.resourceRules({ resourceId: resourceId! }),
|
||||
enabled: isResourceOverlay
|
||||
});
|
||||
|
||||
const deletedResourceRuleIdsRef = useRef<Set<number>>(new Set());
|
||||
const [resourceRulesInitialized, setResourceRulesInitialized] =
|
||||
useState(false);
|
||||
|
||||
const rulesFormSchema = useMemo(
|
||||
() => createPolicyRulesSectionSchema(t),
|
||||
[t]
|
||||
);
|
||||
|
||||
const form = useForm({
|
||||
resolver: zodResolver(rulesFormSchema),
|
||||
defaultValues: {
|
||||
applyRules: policy.applyRules,
|
||||
rules: policy.rules
|
||||
}
|
||||
});
|
||||
|
||||
const rulesEnabled = useWatch({
|
||||
control: form.control,
|
||||
name: "applyRules"
|
||||
});
|
||||
|
||||
const [rules, setRules] = useState<PolicyAccessRule[]>(
|
||||
policy.rules.map((r) => ({ ...r, fromPolicy: isResourceOverlay }))
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isResourceOverlay || resourceRulesInitialized) return;
|
||||
if (!resourceRulesData) return;
|
||||
|
||||
const policyRuleIds = new Set(policy.rules.map((r) => r.ruleId));
|
||||
const resourceSpecific: PolicyAccessRule[] = resourceRulesData
|
||||
.filter((r) => !policyRuleIds.has(r.ruleId))
|
||||
.map((r) => ({
|
||||
ruleId: r.ruleId,
|
||||
action: r.action as "ACCEPT" | "DROP" | "PASS",
|
||||
match: r.match,
|
||||
value: r.value,
|
||||
priority: r.priority,
|
||||
enabled: r.enabled,
|
||||
fromPolicy: false
|
||||
}));
|
||||
|
||||
setRules([
|
||||
...resourceSpecific,
|
||||
...policy.rules.map((r) => ({ ...r, fromPolicy: true }))
|
||||
]);
|
||||
setResourceRulesInitialized(true);
|
||||
}, [
|
||||
isResourceOverlay,
|
||||
resourceRulesData,
|
||||
resourceRulesInitialized,
|
||||
policy.rules
|
||||
]);
|
||||
|
||||
const syncFormRules = useCallback(
|
||||
(updatedRules: PolicyAccessRule[]) => {
|
||||
form.setValue(
|
||||
"rules",
|
||||
updatedRules.map(
|
||||
({ action, match, value, priority, enabled }) => ({
|
||||
action,
|
||||
match,
|
||||
value,
|
||||
priority,
|
||||
enabled
|
||||
})
|
||||
)
|
||||
);
|
||||
},
|
||||
[form]
|
||||
);
|
||||
|
||||
const addEmptyRule = useCallback(() => {
|
||||
const updatedRules = [...rules, createEmptyRule(rules)];
|
||||
setRules(updatedRules);
|
||||
syncFormRules(updatedRules);
|
||||
}, [rules, syncFormRules]);
|
||||
|
||||
const removeRule = useCallback(
|
||||
function removeRule(ruleId: number) {
|
||||
const rule = rules.find((r) => r.ruleId === ruleId);
|
||||
if (!rule || rule.fromPolicy) return;
|
||||
if (isResourceOverlay && !rule.new) {
|
||||
deletedResourceRuleIdsRef.current.add(ruleId);
|
||||
}
|
||||
const updatedRules = rules.filter((rule) => rule.ruleId !== ruleId);
|
||||
setRules(updatedRules);
|
||||
syncFormRules(updatedRules);
|
||||
},
|
||||
[rules, syncFormRules, isResourceOverlay]
|
||||
);
|
||||
|
||||
const updateRule = useCallback(
|
||||
function updateRule(ruleId: number, data: Partial<PolicyAccessRule>) {
|
||||
const updatedRules = rules.map((rule) =>
|
||||
rule.ruleId === ruleId
|
||||
? { ...rule, ...data, updated: true }
|
||||
: rule
|
||||
);
|
||||
setRules(updatedRules);
|
||||
syncFormRules(updatedRules);
|
||||
},
|
||||
[rules, syncFormRules]
|
||||
);
|
||||
|
||||
const handleRulesChange = useCallback(
|
||||
(updatedRules: PolicyAccessRule[]) => {
|
||||
setRules(updatedRules);
|
||||
syncFormRules(updatedRules);
|
||||
},
|
||||
[syncFormRules]
|
||||
);
|
||||
|
||||
const [isPending, startTransition] = useTransition();
|
||||
|
||||
async function saveRules() {
|
||||
if (readonly) return;
|
||||
|
||||
const applyRules = form.getValues("applyRules") ?? false;
|
||||
const rulesPayload = rules.map(
|
||||
({ action, match, value, priority, enabled }) => ({
|
||||
action,
|
||||
match,
|
||||
value,
|
||||
priority,
|
||||
enabled
|
||||
})
|
||||
);
|
||||
const validation = validatePolicyRulesForSave(
|
||||
t,
|
||||
rulesPayload,
|
||||
applyRules
|
||||
);
|
||||
if (!validation.success) {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
...validation.toast
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (isResourceOverlay) {
|
||||
await saveResourceOverlayRules();
|
||||
return;
|
||||
}
|
||||
|
||||
const isValid = await form.trigger();
|
||||
if (!isValid) return;
|
||||
|
||||
const payload = {
|
||||
applyRules,
|
||||
rules: rulesPayload
|
||||
};
|
||||
|
||||
try {
|
||||
const res = await api
|
||||
.put<
|
||||
AxiosResponse<{}>
|
||||
>(`/resource-policy/${policy.resourcePolicyId}/rules`, payload)
|
||||
.catch((e) => {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: t("policyErrorUpdate"),
|
||||
description: formatAxiosError(
|
||||
e,
|
||||
t("policyErrorUpdateDescription")
|
||||
)
|
||||
});
|
||||
});
|
||||
|
||||
if (res && res.status === 200) {
|
||||
toast({
|
||||
title: t("success"),
|
||||
description: t("policyUpdatedSuccess")
|
||||
});
|
||||
router.refresh();
|
||||
}
|
||||
} catch (e) {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: t("policyErrorUpdate"),
|
||||
description: t("policyErrorUpdateMessageDescription")
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function saveResourceOverlayRules() {
|
||||
try {
|
||||
const newRules = rules.filter((r) => !r.fromPolicy && r.new);
|
||||
const updatedRules = rules.filter(
|
||||
(r) => !r.fromPolicy && !r.new && r.updated
|
||||
);
|
||||
const deletedIds = [...deletedResourceRuleIdsRef.current];
|
||||
|
||||
await Promise.all([
|
||||
...newRules.map((r) =>
|
||||
api.put(`/resource/${resourceId}/rule`, {
|
||||
action: r.action,
|
||||
match: r.match,
|
||||
value: r.value,
|
||||
priority: r.priority,
|
||||
enabled: r.enabled
|
||||
})
|
||||
),
|
||||
...updatedRules.map((r) =>
|
||||
api.post(`/resource/${resourceId}/rule/${r.ruleId}`, {
|
||||
action: r.action,
|
||||
match: r.match,
|
||||
value: r.value,
|
||||
priority: r.priority,
|
||||
enabled: r.enabled
|
||||
})
|
||||
),
|
||||
...deletedIds.map((id) =>
|
||||
api.delete(`/resource/${resourceId}/rule/${id}`)
|
||||
)
|
||||
]);
|
||||
|
||||
deletedResourceRuleIdsRef.current = new Set();
|
||||
|
||||
toast({
|
||||
title: t("success"),
|
||||
description: t("policyUpdatedSuccess")
|
||||
});
|
||||
router.refresh();
|
||||
} catch (e) {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: t("policyErrorUpdate"),
|
||||
description: formatAxiosError(
|
||||
e,
|
||||
t("policyErrorUpdateDescription")
|
||||
)
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const addRuleButton = (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
disabled={readonly}
|
||||
onClick={addEmptyRule}
|
||||
>
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
{t("ruleSubmit")}
|
||||
</Button>
|
||||
);
|
||||
|
||||
const hasRules = rules.length > 0;
|
||||
|
||||
return (
|
||||
<SettingsSection>
|
||||
<SettingsSectionHeader>
|
||||
<SettingsSectionTitle>
|
||||
{t("policyAccessRulesTitle")}
|
||||
</SettingsSectionTitle>
|
||||
<SettingsSectionDescription>
|
||||
{t("rulesResourceDescription")}
|
||||
</SettingsSectionDescription>
|
||||
</SettingsSectionHeader>
|
||||
<SettingsSectionBody>
|
||||
<div className="space-y-6">
|
||||
<PolicyAccessRulesIntro
|
||||
rulesEnabled={Boolean(rulesEnabled)}
|
||||
onRulesEnabledChange={(val) => {
|
||||
form.setValue("applyRules", val);
|
||||
}}
|
||||
disableToggle={readonly || isResourceOverlay}
|
||||
/>
|
||||
|
||||
{rulesEnabled && (
|
||||
<>
|
||||
<PolicyAccessRulesTable
|
||||
rules={rules}
|
||||
onRulesChange={handleRulesChange}
|
||||
updateRule={updateRule}
|
||||
removeRule={removeRule}
|
||||
readonly={readonly}
|
||||
isMaxmindAvailable={isMaxmindAvailable}
|
||||
isMaxmindAsnAvailable={isMaxmindAsnAvailable}
|
||||
includeRegionMatch
|
||||
markUpdatedOnReorder
|
||||
emptyStateAction={addRuleButton}
|
||||
/>
|
||||
{hasRules && addRuleButton}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</SettingsSectionBody>
|
||||
<SettingsSectionFooter>
|
||||
<Button
|
||||
onClick={() => startTransition(() => saveRules())}
|
||||
loading={isPending}
|
||||
disabled={readonly || isPending}
|
||||
>
|
||||
{t("saveSettings")}
|
||||
</Button>
|
||||
</SettingsSectionFooter>
|
||||
</SettingsSection>
|
||||
);
|
||||
}
|
||||
|
||||
function PolicyAccessRulesSectionCreate({
|
||||
form,
|
||||
isMaxmindAvailable,
|
||||
isMaxmindAsnAvailable
|
||||
}: PolicyAccessRulesSectionCreateProps) {
|
||||
return (
|
||||
<CreatePolicyRulesSectionForm
|
||||
form={form}
|
||||
isMaxmindAvailable={isMaxmindAvailable}
|
||||
isMaxmindAsnAvailable={isMaxmindAsnAvailable}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,824 @@
|
||||
"use client";
|
||||
|
||||
import { Button } from "@app/components/ui/button";
|
||||
import { DataTableEmptyState } from "@app/components/ui/data-table-empty-state";
|
||||
import {
|
||||
Command,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList
|
||||
} from "@app/components/ui/command";
|
||||
import { Input } from "@app/components/ui/input";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger
|
||||
} from "@app/components/ui/popover";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue
|
||||
} from "@app/components/ui/select";
|
||||
import { Switch } from "@app/components/ui/switch";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow
|
||||
} from "@app/components/ui/table";
|
||||
import { toast } from "@app/hooks/useToast";
|
||||
import { cn } from "@app/lib/cn";
|
||||
import { MAJOR_ASNS } from "@server/db/asns";
|
||||
import { COUNTRIES } from "@server/db/countries";
|
||||
import { REGIONS, getRegionNameById } from "@server/db/regions";
|
||||
import {
|
||||
ColumnDef,
|
||||
flexRender,
|
||||
getCoreRowModel,
|
||||
getFilteredRowModel,
|
||||
getPaginationRowModel,
|
||||
getSortedRowModel,
|
||||
useReactTable
|
||||
} from "@tanstack/react-table";
|
||||
import {
|
||||
ArrowUpDown,
|
||||
Check,
|
||||
ChevronsUpDown,
|
||||
GripVertical,
|
||||
LockIcon
|
||||
} from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import {
|
||||
useCallback,
|
||||
useMemo,
|
||||
useState,
|
||||
type DragEvent,
|
||||
type ReactNode
|
||||
} from "react";
|
||||
import {
|
||||
validatePolicyRulePriority,
|
||||
validatePolicyRuleValue
|
||||
} from "./policy-access-rule-validation";
|
||||
import {
|
||||
reorderPolicyRules,
|
||||
sortPolicyRulesByPriority,
|
||||
type PolicyAccessRule
|
||||
} from "./policy-access-rule-utils";
|
||||
|
||||
export type PolicyAccessRulesTableProps = {
|
||||
rules: PolicyAccessRule[];
|
||||
onRulesChange: (rules: PolicyAccessRule[]) => void;
|
||||
updateRule: (ruleId: number, data: Partial<PolicyAccessRule>) => void;
|
||||
removeRule: (ruleId: number) => void;
|
||||
isMaxmindAvailable: boolean;
|
||||
isMaxmindAsnAvailable: boolean;
|
||||
emptyStateAction: ReactNode;
|
||||
readonly?: boolean;
|
||||
includeRegionMatch?: boolean;
|
||||
markUpdatedOnReorder?: boolean;
|
||||
isRuleDraggable?: (rule: PolicyAccessRule) => boolean;
|
||||
isRuleLocked?: (rule: PolicyAccessRule) => boolean;
|
||||
};
|
||||
|
||||
function getColumnClassName(columnId: string) {
|
||||
if (columnId === "actions") {
|
||||
return "sticky right-0 z-10 w-[1%] min-w-fit bg-card text-right";
|
||||
}
|
||||
if (columnId === "dragHandle") {
|
||||
return "w-8 max-w-8 px-2";
|
||||
}
|
||||
if (columnId === "priority") {
|
||||
return "w-24 max-w-24";
|
||||
}
|
||||
if (columnId === "action") {
|
||||
return "w-40 max-w-40";
|
||||
}
|
||||
if (columnId === "match") {
|
||||
return "w-36 max-w-36";
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
export function PolicyAccessRulesTable({
|
||||
rules,
|
||||
onRulesChange,
|
||||
updateRule,
|
||||
removeRule,
|
||||
isMaxmindAvailable,
|
||||
isMaxmindAsnAvailable,
|
||||
emptyStateAction,
|
||||
readonly = false,
|
||||
includeRegionMatch = false,
|
||||
markUpdatedOnReorder = false,
|
||||
isRuleDraggable: isRuleDraggableProp,
|
||||
isRuleLocked: isRuleLockedProp
|
||||
}: PolicyAccessRulesTableProps) {
|
||||
const t = useTranslations();
|
||||
const [draggedRuleId, setDraggedRuleId] = useState<number | null>(null);
|
||||
const [dragOverRuleId, setDragOverRuleId] = useState<number | null>(null);
|
||||
|
||||
const isRuleLocked = useCallback(
|
||||
(rule: PolicyAccessRule) =>
|
||||
isRuleLockedProp
|
||||
? isRuleLockedProp(rule)
|
||||
: Boolean(rule.fromPolicy),
|
||||
[isRuleLockedProp]
|
||||
);
|
||||
|
||||
const isRuleDraggable = useCallback(
|
||||
(rule: PolicyAccessRule) =>
|
||||
isRuleDraggableProp
|
||||
? isRuleDraggableProp(rule)
|
||||
: !readonly && !isRuleLocked(rule),
|
||||
[isRuleDraggableProp, isRuleLocked, readonly]
|
||||
);
|
||||
|
||||
const sortedRules = useMemo(
|
||||
() => sortPolicyRulesByPriority(rules),
|
||||
[rules]
|
||||
);
|
||||
|
||||
const handleReorder = useCallback(
|
||||
(fromRuleId: number, toRuleId: number) => {
|
||||
const fromIndex = sortedRules.findIndex(
|
||||
(rule) => rule.ruleId === fromRuleId
|
||||
);
|
||||
const toIndex = sortedRules.findIndex(
|
||||
(rule) => rule.ruleId === toRuleId
|
||||
);
|
||||
if (fromIndex === -1 || toIndex === -1 || fromIndex === toIndex) {
|
||||
return;
|
||||
}
|
||||
|
||||
const reordered = reorderPolicyRules(
|
||||
sortedRules,
|
||||
fromIndex,
|
||||
toIndex,
|
||||
{ markUpdated: markUpdatedOnReorder }
|
||||
);
|
||||
onRulesChange(reordered);
|
||||
},
|
||||
[sortedRules, onRulesChange, markUpdatedOnReorder]
|
||||
);
|
||||
|
||||
const handleDragStart = useCallback((ruleId: number, e: DragEvent) => {
|
||||
setDraggedRuleId(ruleId);
|
||||
e.dataTransfer.effectAllowed = "move";
|
||||
}, []);
|
||||
|
||||
const handleDragEnd = useCallback(() => {
|
||||
setDraggedRuleId(null);
|
||||
setDragOverRuleId(null);
|
||||
}, []);
|
||||
|
||||
const RuleAction = useMemo(
|
||||
() => ({
|
||||
ACCEPT: t("alwaysAllow"),
|
||||
DROP: t("alwaysDeny"),
|
||||
PASS: t("passToAuth")
|
||||
}),
|
||||
[t]
|
||||
);
|
||||
|
||||
const RuleMatch = useMemo(
|
||||
() => ({
|
||||
PATH: t("path"),
|
||||
IP: "IP",
|
||||
CIDR: t("ipAddressRange"),
|
||||
COUNTRY: t("country"),
|
||||
ASN: "ASN",
|
||||
REGION: t("region")
|
||||
}),
|
||||
[t]
|
||||
);
|
||||
|
||||
const columns: ColumnDef<PolicyAccessRule>[] = useMemo(
|
||||
() => [
|
||||
{
|
||||
id: "dragHandle",
|
||||
size: 32,
|
||||
maxSize: 32,
|
||||
header: () => null,
|
||||
cell: ({ row }) =>
|
||||
isRuleDraggable(row.original) ? (
|
||||
<button
|
||||
type="button"
|
||||
draggable
|
||||
tabIndex={-1}
|
||||
aria-label={t("rulesReorderDragHandle")}
|
||||
className="flex items-center justify-center text-muted-foreground cursor-grab active:cursor-grabbing"
|
||||
onDragStart={(e) =>
|
||||
handleDragStart(row.original.ruleId, e)
|
||||
}
|
||||
onDragEnd={handleDragEnd}
|
||||
>
|
||||
<GripVertical className="h-4 w-4" />
|
||||
</button>
|
||||
) : null
|
||||
},
|
||||
{
|
||||
accessorKey: "priority",
|
||||
size: 96,
|
||||
maxSize: 96,
|
||||
header: ({ column }) => (
|
||||
<div className="p-3">
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="h-auto p-0 font-medium text-muted-foreground hover:bg-transparent"
|
||||
onClick={() =>
|
||||
column.toggleSorting(
|
||||
column.getIsSorted() === "asc"
|
||||
)
|
||||
}
|
||||
>
|
||||
{t("rulesPriority")}
|
||||
<ArrowUpDown className="ml-1 h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<Input
|
||||
defaultValue={row.original.priority}
|
||||
className="w-full min-w-0"
|
||||
type="number"
|
||||
disabled={readonly || isRuleLocked(row.original)}
|
||||
onClick={(e) => e.currentTarget.focus()}
|
||||
onBlur={(e) => {
|
||||
const validated = validatePolicyRulePriority(
|
||||
t,
|
||||
e.target.value
|
||||
);
|
||||
if (!validated.success) {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
...validated.toast
|
||||
});
|
||||
return;
|
||||
}
|
||||
const duplicatePriority = rules.some(
|
||||
(rule) =>
|
||||
rule.ruleId !== row.original.ruleId &&
|
||||
rule.priority === validated.data
|
||||
);
|
||||
if (duplicatePriority) {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: t("rulesErrorDuplicatePriority"),
|
||||
description: t(
|
||||
"rulesErrorDuplicatePriorityDescription"
|
||||
)
|
||||
});
|
||||
return;
|
||||
}
|
||||
updateRule(row.original.ruleId, {
|
||||
priority: validated.data
|
||||
});
|
||||
}}
|
||||
/>
|
||||
)
|
||||
},
|
||||
{
|
||||
accessorKey: "action",
|
||||
size: 160,
|
||||
maxSize: 160,
|
||||
header: () => <span className="p-3">{t("rulesAction")}</span>,
|
||||
cell: ({ row }) => (
|
||||
<Select
|
||||
defaultValue={row.original.action}
|
||||
disabled={readonly || isRuleLocked(row.original)}
|
||||
onValueChange={(value: "ACCEPT" | "DROP" | "PASS") =>
|
||||
updateRule(row.original.ruleId, {
|
||||
action: value
|
||||
})
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="h-8 w-full min-w-0">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="ACCEPT">
|
||||
{RuleAction.ACCEPT}
|
||||
</SelectItem>
|
||||
<SelectItem value="DROP">
|
||||
{RuleAction.DROP}
|
||||
</SelectItem>
|
||||
<SelectItem value="PASS">
|
||||
{RuleAction.PASS}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)
|
||||
},
|
||||
{
|
||||
accessorKey: "match",
|
||||
size: 144,
|
||||
maxSize: 144,
|
||||
header: () => (
|
||||
<span className="p-3">{t("rulesMatchType")}</span>
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<Select
|
||||
defaultValue={row.original.match}
|
||||
disabled={readonly || isRuleLocked(row.original)}
|
||||
onValueChange={(
|
||||
value:
|
||||
| "CIDR"
|
||||
| "IP"
|
||||
| "PATH"
|
||||
| "COUNTRY"
|
||||
| "ASN"
|
||||
| "REGION"
|
||||
) =>
|
||||
updateRule(row.original.ruleId, {
|
||||
match: value,
|
||||
value:
|
||||
value === "COUNTRY"
|
||||
? "US"
|
||||
: value === "ASN"
|
||||
? "AS15169"
|
||||
: value === "REGION"
|
||||
? "021"
|
||||
: row.original.value
|
||||
})
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="h-8 w-full min-w-0">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="PATH">
|
||||
{RuleMatch.PATH}
|
||||
</SelectItem>
|
||||
<SelectItem value="IP">{RuleMatch.IP}</SelectItem>
|
||||
<SelectItem value="CIDR">
|
||||
{RuleMatch.CIDR}
|
||||
</SelectItem>
|
||||
{isMaxmindAvailable && (
|
||||
<SelectItem value="COUNTRY">
|
||||
{RuleMatch.COUNTRY}
|
||||
</SelectItem>
|
||||
)}
|
||||
{includeRegionMatch && isMaxmindAvailable && (
|
||||
<SelectItem value="REGION">
|
||||
{RuleMatch.REGION}
|
||||
</SelectItem>
|
||||
)}
|
||||
{isMaxmindAsnAvailable && (
|
||||
<SelectItem value="ASN">
|
||||
{RuleMatch.ASN}
|
||||
</SelectItem>
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)
|
||||
},
|
||||
{
|
||||
accessorKey: "value",
|
||||
header: () => <span className="p-3">{t("value")}</span>,
|
||||
cell: ({ row }) =>
|
||||
row.original.match === "COUNTRY" ? (
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
disabled={
|
||||
readonly || isRuleLocked(row.original)
|
||||
}
|
||||
className="w-full min-w-0 justify-between"
|
||||
>
|
||||
{row.original.value
|
||||
? COUNTRIES.find(
|
||||
(c) =>
|
||||
c.code === row.original.value
|
||||
)?.name +
|
||||
" (" +
|
||||
row.original.value +
|
||||
")"
|
||||
: t("selectCountry")}
|
||||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="min-w-50 p-0">
|
||||
<Command>
|
||||
<CommandInput
|
||||
placeholder={t("searchCountries")}
|
||||
/>
|
||||
<CommandList>
|
||||
<CommandEmpty>
|
||||
{t("noCountryFound")}
|
||||
</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{COUNTRIES.map((country) => (
|
||||
<CommandItem
|
||||
key={country.code}
|
||||
value={country.name}
|
||||
onSelect={() =>
|
||||
updateRule(
|
||||
row.original.ruleId,
|
||||
{
|
||||
value: country.code
|
||||
}
|
||||
)
|
||||
}
|
||||
>
|
||||
<Check
|
||||
className={`mr-2 h-4 w-4 ${row.original.value === country.code ? "opacity-100" : "opacity-0"}`}
|
||||
/>
|
||||
{country.name} (
|
||||
{country.code})
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
) : row.original.match === "ASN" ? (
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
disabled={
|
||||
readonly || isRuleLocked(row.original)
|
||||
}
|
||||
className="w-full min-w-0 justify-between"
|
||||
>
|
||||
{row.original.value
|
||||
? (() => {
|
||||
const found = MAJOR_ASNS.find(
|
||||
(asn) =>
|
||||
asn.code ===
|
||||
row.original.value
|
||||
);
|
||||
return found
|
||||
? `${found.name} (${row.original.value})`
|
||||
: `Custom (${row.original.value})`;
|
||||
})()
|
||||
: "Select ASN"}
|
||||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="min-w-50 p-0">
|
||||
<Command>
|
||||
<CommandInput placeholder="Search ASNs or enter custom..." />
|
||||
<CommandList>
|
||||
<CommandEmpty>
|
||||
No ASN found. Enter a custom ASN
|
||||
below.
|
||||
</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{MAJOR_ASNS.map((asn) => (
|
||||
<CommandItem
|
||||
key={asn.code}
|
||||
value={
|
||||
asn.name +
|
||||
" " +
|
||||
asn.code
|
||||
}
|
||||
onSelect={() =>
|
||||
updateRule(
|
||||
row.original.ruleId,
|
||||
{ value: asn.code }
|
||||
)
|
||||
}
|
||||
>
|
||||
<Check
|
||||
className={`mr-2 h-4 w-4 ${row.original.value === asn.code ? "opacity-100" : "opacity-0"}`}
|
||||
/>
|
||||
{asn.name} ({asn.code})
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
<div className="border-t p-2">
|
||||
<Input
|
||||
placeholder="Enter custom ASN (e.g., AS15169)"
|
||||
defaultValue={
|
||||
!MAJOR_ASNS.find(
|
||||
(asn) =>
|
||||
asn.code ===
|
||||
row.original.value
|
||||
)
|
||||
? row.original.value
|
||||
: ""
|
||||
}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
const value =
|
||||
e.currentTarget.value
|
||||
.toUpperCase()
|
||||
.replace(/^AS/, "");
|
||||
if (/^\d+$/.test(value)) {
|
||||
updateRule(
|
||||
row.original.ruleId,
|
||||
{ value: "AS" + value }
|
||||
);
|
||||
}
|
||||
}
|
||||
}}
|
||||
className="text-sm"
|
||||
/>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
) : row.original.match === "REGION" ? (
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
disabled={
|
||||
readonly || isRuleLocked(row.original)
|
||||
}
|
||||
className="w-full min-w-0 justify-between"
|
||||
>
|
||||
{(() => {
|
||||
const regionName = getRegionNameById(
|
||||
row.original.value
|
||||
);
|
||||
if (!regionName) {
|
||||
return t("selectRegion");
|
||||
}
|
||||
return `${t(regionName)} (${row.original.value})`;
|
||||
})()}
|
||||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="min-w-50 p-0">
|
||||
<Command>
|
||||
<CommandInput
|
||||
placeholder={t("searchRegions")}
|
||||
/>
|
||||
<CommandList>
|
||||
<CommandEmpty>
|
||||
{t("noRegionFound")}
|
||||
</CommandEmpty>
|
||||
{REGIONS.map((continent) => (
|
||||
<CommandGroup
|
||||
key={continent.id}
|
||||
heading={t(continent.name)}
|
||||
>
|
||||
<CommandItem
|
||||
value={continent.id}
|
||||
keywords={[
|
||||
t(continent.name),
|
||||
continent.id
|
||||
]}
|
||||
onSelect={() =>
|
||||
updateRule(
|
||||
row.original.ruleId,
|
||||
{
|
||||
value: continent.id
|
||||
}
|
||||
)
|
||||
}
|
||||
>
|
||||
<Check
|
||||
className={`mr-2 h-4 w-4 ${
|
||||
row.original
|
||||
.value ===
|
||||
continent.id
|
||||
? "opacity-100"
|
||||
: "opacity-0"
|
||||
}`}
|
||||
/>
|
||||
{t(continent.name)} (
|
||||
{continent.id})
|
||||
</CommandItem>
|
||||
{continent.includes.map(
|
||||
(subregion) => (
|
||||
<CommandItem
|
||||
key={subregion.id}
|
||||
value={subregion.id}
|
||||
keywords={[
|
||||
t(
|
||||
subregion.name
|
||||
),
|
||||
subregion.id
|
||||
]}
|
||||
onSelect={() =>
|
||||
updateRule(
|
||||
row.original
|
||||
.ruleId,
|
||||
{
|
||||
value: subregion.id
|
||||
}
|
||||
)
|
||||
}
|
||||
>
|
||||
<Check
|
||||
className={`mr-2 h-4 w-4 ${
|
||||
row.original
|
||||
.value ===
|
||||
subregion.id
|
||||
? "opacity-100"
|
||||
: "opacity-0"
|
||||
}`}
|
||||
/>
|
||||
{t(subregion.name)}{" "}
|
||||
({subregion.id})
|
||||
</CommandItem>
|
||||
)
|
||||
)}
|
||||
</CommandGroup>
|
||||
))}
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
) : (
|
||||
<Input
|
||||
defaultValue={row.original.value}
|
||||
className="w-full min-w-0"
|
||||
disabled={readonly || isRuleLocked(row.original)}
|
||||
onBlur={(e) => {
|
||||
const validated = validatePolicyRuleValue(
|
||||
t,
|
||||
row.original.match,
|
||||
e.target.value
|
||||
);
|
||||
if (!validated.success) {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
...validated.toast
|
||||
});
|
||||
return;
|
||||
}
|
||||
updateRule(row.original.ruleId, {
|
||||
value: validated.data
|
||||
});
|
||||
}}
|
||||
/>
|
||||
)
|
||||
},
|
||||
{
|
||||
accessorKey: "enabled",
|
||||
header: () => <span className="p-3">{t("enabled")}</span>,
|
||||
cell: ({ row }) => (
|
||||
<div className="flex items-center w-full">
|
||||
<Switch
|
||||
defaultChecked={row.original.enabled}
|
||||
disabled={readonly || isRuleLocked(row.original)}
|
||||
onCheckedChange={(val) =>
|
||||
updateRule(row.original.ruleId, {
|
||||
enabled: val
|
||||
})
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
{
|
||||
id: "actions",
|
||||
header: () => null,
|
||||
cell: ({ row }) => (
|
||||
<div className="flex items-center justify-end space-x-2">
|
||||
{isRuleLocked(row.original) ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
disabled
|
||||
className="cursor-not-allowed"
|
||||
>
|
||||
<LockIcon className="h-4 w-4" />
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
variant="outline"
|
||||
disabled={readonly}
|
||||
onClick={() => removeRule(row.original.ruleId)}
|
||||
>
|
||||
{t("delete")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
],
|
||||
[
|
||||
t,
|
||||
RuleAction,
|
||||
RuleMatch,
|
||||
isMaxmindAvailable,
|
||||
isMaxmindAsnAvailable,
|
||||
includeRegionMatch,
|
||||
updateRule,
|
||||
removeRule,
|
||||
readonly,
|
||||
rules,
|
||||
isRuleDraggable,
|
||||
isRuleLocked,
|
||||
handleDragStart,
|
||||
handleDragEnd
|
||||
]
|
||||
);
|
||||
|
||||
const table = useReactTable({
|
||||
data: sortedRules,
|
||||
columns,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
getPaginationRowModel: getPaginationRowModel(),
|
||||
getSortedRowModel: getSortedRowModel(),
|
||||
getFilteredRowModel: getFilteredRowModel(),
|
||||
state: { pagination: { pageIndex: 0, pageSize: 1000 } }
|
||||
});
|
||||
|
||||
return (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
{table.getHeaderGroups().map((headerGroup) => (
|
||||
<TableRow key={headerGroup.id}>
|
||||
{headerGroup.headers.map((header) => {
|
||||
const columnId = header.column.id;
|
||||
return (
|
||||
<TableHead
|
||||
key={header.id}
|
||||
className={getColumnClassName(columnId)}
|
||||
>
|
||||
{header.isPlaceholder
|
||||
? null
|
||||
: flexRender(
|
||||
header.column.columnDef.header,
|
||||
header.getContext()
|
||||
)}
|
||||
</TableHead>
|
||||
);
|
||||
})}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{table.getRowModel().rows?.length ? (
|
||||
table.getRowModel().rows.map((row) => {
|
||||
const rule = row.original;
|
||||
return (
|
||||
<TableRow
|
||||
key={row.id}
|
||||
onDragOver={(e) => {
|
||||
e.preventDefault();
|
||||
if (
|
||||
draggedRuleId !== null &&
|
||||
draggedRuleId !== rule.ruleId
|
||||
) {
|
||||
setDragOverRuleId(rule.ruleId);
|
||||
}
|
||||
}}
|
||||
onDrop={(e) => {
|
||||
e.preventDefault();
|
||||
if (
|
||||
draggedRuleId !== null &&
|
||||
draggedRuleId !== rule.ruleId
|
||||
) {
|
||||
handleReorder(
|
||||
draggedRuleId,
|
||||
rule.ruleId
|
||||
);
|
||||
}
|
||||
setDraggedRuleId(null);
|
||||
setDragOverRuleId(null);
|
||||
}}
|
||||
className={cn(
|
||||
draggedRuleId === rule.ruleId &&
|
||||
"opacity-50",
|
||||
dragOverRuleId === rule.ruleId &&
|
||||
"border-t-2 border-primary"
|
||||
)}
|
||||
>
|
||||
{row.getVisibleCells().map((cell) => {
|
||||
const columnId = cell.column.id;
|
||||
return (
|
||||
<TableCell
|
||||
key={cell.id}
|
||||
className={getColumnClassName(
|
||||
columnId
|
||||
)}
|
||||
>
|
||||
{flexRender(
|
||||
cell.column.columnDef.cell,
|
||||
cell.getContext()
|
||||
)}
|
||||
</TableCell>
|
||||
);
|
||||
})}
|
||||
</TableRow>
|
||||
);
|
||||
})
|
||||
) : (
|
||||
<DataTableEmptyState
|
||||
colSpan={columns.length}
|
||||
message={t("rulesNoOne")}
|
||||
action={emptyStateAction}
|
||||
/>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,467 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
Credenza,
|
||||
CredenzaBody,
|
||||
CredenzaClose,
|
||||
CredenzaContent,
|
||||
CredenzaDescription,
|
||||
CredenzaFooter,
|
||||
CredenzaHeader,
|
||||
CredenzaTitle
|
||||
} from "@app/components/Credenza";
|
||||
import { SwitchInput } from "@app/components/SwitchInput";
|
||||
import { Tag, TagInput } from "@app/components/tags/tag-input";
|
||||
import { Alert, AlertDescription, AlertTitle } from "@app/components/ui/alert";
|
||||
import { Button } from "@app/components/ui/button";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage
|
||||
} from "@app/components/ui/form";
|
||||
import { Input } from "@app/components/ui/input";
|
||||
import {
|
||||
InputOTP,
|
||||
InputOTPGroup,
|
||||
InputOTPSlot
|
||||
} from "@app/components/ui/input-otp";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { InfoIcon } from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import z from "zod";
|
||||
import {
|
||||
setHeaderAuthSchema,
|
||||
setPasswordSchema,
|
||||
setPincodeSchema
|
||||
} from "./policy-auth-method-id";
|
||||
|
||||
type CredenzaShellProps = {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
title: string;
|
||||
description: string;
|
||||
formId: string;
|
||||
submitLabel: string;
|
||||
children: React.ReactNode;
|
||||
};
|
||||
|
||||
function CredenzaShell({
|
||||
open,
|
||||
onOpenChange,
|
||||
title,
|
||||
description,
|
||||
formId,
|
||||
submitLabel,
|
||||
children
|
||||
}: CredenzaShellProps) {
|
||||
const t = useTranslations();
|
||||
|
||||
return (
|
||||
<Credenza open={open} onOpenChange={onOpenChange}>
|
||||
<CredenzaContent>
|
||||
<CredenzaHeader>
|
||||
<CredenzaTitle>{title}</CredenzaTitle>
|
||||
<CredenzaDescription>{description}</CredenzaDescription>
|
||||
</CredenzaHeader>
|
||||
<CredenzaBody>{children}</CredenzaBody>
|
||||
<CredenzaFooter>
|
||||
<CredenzaClose asChild>
|
||||
<Button variant="outline">{t("close")}</Button>
|
||||
</CredenzaClose>
|
||||
<Button type="submit" form={formId}>
|
||||
{submitLabel}
|
||||
</Button>
|
||||
</CredenzaFooter>
|
||||
</CredenzaContent>
|
||||
</Credenza>
|
||||
);
|
||||
}
|
||||
|
||||
type PasscodeCredenzaProps = {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
defaultPassword?: string;
|
||||
existingConfigured?: boolean;
|
||||
onSave: (password: string) => void;
|
||||
};
|
||||
|
||||
export function PasscodeCredenza({
|
||||
open,
|
||||
onOpenChange,
|
||||
defaultPassword = "",
|
||||
existingConfigured,
|
||||
onSave
|
||||
}: PasscodeCredenzaProps) {
|
||||
const t = useTranslations();
|
||||
const form = useForm({
|
||||
resolver: zodResolver(setPasswordSchema),
|
||||
defaultValues: { password: defaultPassword }
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
form.reset({ password: defaultPassword });
|
||||
}
|
||||
}, [open, defaultPassword, form]);
|
||||
|
||||
return (
|
||||
<CredenzaShell
|
||||
open={open}
|
||||
onOpenChange={onOpenChange}
|
||||
title={t("resourcePasswordSetupTitle")}
|
||||
description={t("resourcePasswordSetupTitleDescription")}
|
||||
formId="policy-passcode-form"
|
||||
submitLabel={t("resourcePasswordSubmit")}
|
||||
>
|
||||
<Form {...form}>
|
||||
<form
|
||||
id="policy-passcode-form"
|
||||
onSubmit={form.handleSubmit((data) => {
|
||||
onSave(data.password);
|
||||
onOpenChange(false);
|
||||
form.reset();
|
||||
})}
|
||||
className="space-y-4"
|
||||
>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="password"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t("policyAuthPasscodeTitle")}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
autoComplete="off"
|
||||
type="password"
|
||||
placeholder={
|
||||
existingConfigured
|
||||
? "••••••••"
|
||||
: undefined
|
||||
}
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</form>
|
||||
</Form>
|
||||
</CredenzaShell>
|
||||
);
|
||||
}
|
||||
|
||||
type PincodeCredenzaProps = {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
defaultPincode?: string;
|
||||
onSave: (pincode: string) => void;
|
||||
};
|
||||
|
||||
export function PincodeCredenza({
|
||||
open,
|
||||
onOpenChange,
|
||||
defaultPincode = "",
|
||||
onSave
|
||||
}: PincodeCredenzaProps) {
|
||||
const t = useTranslations();
|
||||
const form = useForm({
|
||||
resolver: zodResolver(setPincodeSchema),
|
||||
defaultValues: { pincode: defaultPincode }
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
form.reset({ pincode: defaultPincode });
|
||||
}
|
||||
}, [open, defaultPincode, form]);
|
||||
|
||||
return (
|
||||
<CredenzaShell
|
||||
open={open}
|
||||
onOpenChange={onOpenChange}
|
||||
title={t("resourcePincodeSetupTitle")}
|
||||
description={t("resourcePincodeSetupTitleDescription")}
|
||||
formId="policy-pincode-form"
|
||||
submitLabel={t("resourcePincodeSubmit")}
|
||||
>
|
||||
<Form {...form}>
|
||||
<form
|
||||
id="policy-pincode-form"
|
||||
onSubmit={form.handleSubmit((data) => {
|
||||
onSave(data.pincode);
|
||||
onOpenChange(false);
|
||||
form.reset();
|
||||
})}
|
||||
className="space-y-4"
|
||||
>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="pincode"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t("resourcePincode")}</FormLabel>
|
||||
<FormControl>
|
||||
<div className="flex justify-center">
|
||||
<InputOTP
|
||||
maxLength={6}
|
||||
value={field.value}
|
||||
onChange={field.onChange}
|
||||
>
|
||||
<InputOTPGroup>
|
||||
{[0, 1, 2, 3, 4, 5].map((i) => (
|
||||
<InputOTPSlot
|
||||
key={i}
|
||||
index={i}
|
||||
obscured
|
||||
/>
|
||||
))}
|
||||
</InputOTPGroup>
|
||||
</InputOTP>
|
||||
</div>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</form>
|
||||
</Form>
|
||||
</CredenzaShell>
|
||||
);
|
||||
}
|
||||
|
||||
type HeaderAuthCredenzaProps = {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
defaultValues?: {
|
||||
user: string;
|
||||
password: string;
|
||||
extendedCompatibility: boolean;
|
||||
};
|
||||
existingConfigured?: boolean;
|
||||
onSave: (values: z.infer<typeof setHeaderAuthSchema>) => void;
|
||||
};
|
||||
|
||||
export function HeaderAuthCredenza({
|
||||
open,
|
||||
onOpenChange,
|
||||
defaultValues,
|
||||
existingConfigured,
|
||||
onSave
|
||||
}: HeaderAuthCredenzaProps) {
|
||||
const t = useTranslations();
|
||||
const form = useForm({
|
||||
resolver: zodResolver(setHeaderAuthSchema),
|
||||
defaultValues: {
|
||||
user: "",
|
||||
password: "",
|
||||
extendedCompatibility: true,
|
||||
...defaultValues
|
||||
}
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
form.reset({
|
||||
user: defaultValues?.user ?? "",
|
||||
password: defaultValues?.password ?? "",
|
||||
extendedCompatibility:
|
||||
defaultValues?.extendedCompatibility ?? true
|
||||
});
|
||||
}
|
||||
}, [open, defaultValues, form]);
|
||||
|
||||
return (
|
||||
<CredenzaShell
|
||||
open={open}
|
||||
onOpenChange={onOpenChange}
|
||||
title={t("resourceHeaderAuthSetupTitle")}
|
||||
description={t("resourceHeaderAuthSetupTitleDescription")}
|
||||
formId="policy-header-auth-form"
|
||||
submitLabel={t("resourceHeaderAuthSubmit")}
|
||||
>
|
||||
<Form {...form}>
|
||||
<form
|
||||
id="policy-header-auth-form"
|
||||
onSubmit={form.handleSubmit((data) => {
|
||||
onSave(data);
|
||||
onOpenChange(false);
|
||||
form.reset();
|
||||
})}
|
||||
className="space-y-4"
|
||||
>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="user"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t("policyAuthHeaderName")}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input autoComplete="off" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="password"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t("policyAuthHeaderValue")}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
autoComplete="off"
|
||||
type="password"
|
||||
placeholder={
|
||||
existingConfigured
|
||||
? "••••••••"
|
||||
: undefined
|
||||
}
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="extendedCompatibility"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormControl>
|
||||
<SwitchInput
|
||||
id="header-auth-compatibility-credenza"
|
||||
label={t("headerAuthCompatibility")}
|
||||
description={t(
|
||||
"headerAuthCompatibilityInfo"
|
||||
)}
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</form>
|
||||
</Form>
|
||||
</CredenzaShell>
|
||||
);
|
||||
}
|
||||
|
||||
type EmailCredenzaProps = {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
emailEnabled: boolean;
|
||||
disabled?: boolean;
|
||||
emails: Tag[];
|
||||
onEmailsChange: (emails: Tag[]) => void;
|
||||
};
|
||||
|
||||
export function EmailCredenza({
|
||||
open,
|
||||
onOpenChange,
|
||||
emailEnabled,
|
||||
disabled,
|
||||
emails,
|
||||
onEmailsChange
|
||||
}: EmailCredenzaProps) {
|
||||
const t = useTranslations();
|
||||
const [activeEmailTagIndex, setActiveEmailTagIndex] = useState<
|
||||
number | null
|
||||
>(null);
|
||||
|
||||
return (
|
||||
<Credenza open={open} onOpenChange={onOpenChange}>
|
||||
<CredenzaContent className="max-w-lg">
|
||||
<CredenzaHeader>
|
||||
<CredenzaTitle>{t("policyAuthEmailTitle")}</CredenzaTitle>
|
||||
<CredenzaDescription>
|
||||
{t("policyAuthEmailDescription")}
|
||||
</CredenzaDescription>
|
||||
</CredenzaHeader>
|
||||
<CredenzaBody>
|
||||
<div className="space-y-4">
|
||||
{!emailEnabled && (
|
||||
<Alert variant="neutral">
|
||||
<InfoIcon className="h-4 w-4" />
|
||||
<AlertTitle className="font-semibold">
|
||||
{t("otpEmailSmtpRequired")}
|
||||
</AlertTitle>
|
||||
<AlertDescription>
|
||||
{t("otpEmailSmtpRequiredDescription")}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
{emailEnabled && (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("otpEmailWhitelistListDescription")}
|
||||
</p>
|
||||
)}
|
||||
{emailEnabled && (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t("otpEmailWhitelistList")}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<TagInput
|
||||
activeTagIndex={activeEmailTagIndex}
|
||||
setActiveTagIndex={
|
||||
setActiveEmailTagIndex
|
||||
}
|
||||
placeholder={t("otpEmailEnter")}
|
||||
tags={emails}
|
||||
setTags={(newEmails) => {
|
||||
if (!disabled) {
|
||||
onEmailsChange(
|
||||
newEmails as Tag[]
|
||||
);
|
||||
}
|
||||
}}
|
||||
validateTag={(tag) =>
|
||||
z
|
||||
.email()
|
||||
.or(
|
||||
z
|
||||
.string()
|
||||
.regex(
|
||||
/^\*@[\w.-]+\.[a-zA-Z]{2,}$/
|
||||
)
|
||||
)
|
||||
.safeParse(tag).success
|
||||
}
|
||||
allowDuplicates={false}
|
||||
sortTags
|
||||
size="sm"
|
||||
disabled={disabled}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t("otpEmailEnterDescription")}
|
||||
</FormDescription>
|
||||
</FormItem>
|
||||
)}
|
||||
</div>
|
||||
</CredenzaBody>
|
||||
<CredenzaFooter>
|
||||
<CredenzaClose asChild>
|
||||
<Button variant="outline">{t("close")}</Button>
|
||||
</CredenzaClose>
|
||||
</CredenzaFooter>
|
||||
</CredenzaContent>
|
||||
</Credenza>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
"use client";
|
||||
|
||||
import { Button } from "@app/components/ui/button";
|
||||
import { Switch } from "@app/components/ui/switch";
|
||||
import { cn } from "@app/lib/cn";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
export type PolicyAuthMethodRowProps = {
|
||||
id: string;
|
||||
title: string;
|
||||
description: string;
|
||||
summary: string;
|
||||
active: boolean;
|
||||
onConfigure: () => void;
|
||||
onToggle: (active: boolean) => void;
|
||||
disabled?: boolean;
|
||||
configureDisabled?: boolean;
|
||||
};
|
||||
|
||||
export function PolicyAuthMethodRow({
|
||||
id,
|
||||
title,
|
||||
description,
|
||||
summary,
|
||||
active,
|
||||
onConfigure,
|
||||
onToggle,
|
||||
disabled,
|
||||
configureDisabled = disabled
|
||||
}: PolicyAuthMethodRowProps) {
|
||||
const t = useTranslations();
|
||||
const canEdit = active && !configureDisabled;
|
||||
const canEnable = !active && !disabled;
|
||||
const isRowInteractive = canEdit || canEnable;
|
||||
|
||||
const handleRowClick = () => {
|
||||
if (canEdit) {
|
||||
onConfigure();
|
||||
return;
|
||||
}
|
||||
if (canEnable) {
|
||||
onToggle(true);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center gap-3 rounded-md border border-input p-3 min-w-0",
|
||||
disabled && "opacity-60",
|
||||
isRowInteractive && "cursor-pointer hover:bg-muted/50"
|
||||
)}
|
||||
onClick={isRowInteractive ? handleRowClick : undefined}
|
||||
onKeyDown={
|
||||
isRowInteractive
|
||||
? (e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
handleRowClick();
|
||||
}
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
role={isRowInteractive ? "button" : undefined}
|
||||
tabIndex={isRowInteractive ? 0 : undefined}
|
||||
>
|
||||
<div className="flex flex-1 min-w-0 flex-col gap-0.5">
|
||||
<div className="flex items-center gap-2">
|
||||
<span
|
||||
className="shrink-0 flex items-center"
|
||||
role="img"
|
||||
aria-label={
|
||||
active
|
||||
? t("policyAuthMethodActive")
|
||||
: t("policyAuthMethodOff")
|
||||
}
|
||||
>
|
||||
<div
|
||||
className={
|
||||
active
|
||||
? "w-2 h-2 bg-green-500 rounded-full"
|
||||
: "w-2 h-2 bg-neutral-500 rounded-full"
|
||||
}
|
||||
/>
|
||||
</span>
|
||||
<span className="text-sm font-medium">{title}</span>
|
||||
</div>
|
||||
<p className="truncate text-sm text-muted-foreground">
|
||||
{active ? summary : description}
|
||||
</p>
|
||||
</div>
|
||||
<div
|
||||
className="flex shrink-0 items-center gap-2"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onKeyDown={(e) => e.stopPropagation()}
|
||||
>
|
||||
{active && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="text"
|
||||
size="sm"
|
||||
className="h-auto px-0"
|
||||
disabled={configureDisabled}
|
||||
onClick={onConfigure}
|
||||
>
|
||||
{t("edit")}
|
||||
</Button>
|
||||
)}
|
||||
<Switch
|
||||
id={`${id}-toggle`}
|
||||
checked={active}
|
||||
disabled={disabled}
|
||||
onCheckedChange={onToggle}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
"use client";
|
||||
|
||||
import { SettingsSectionForm } from "@app/components/Settings";
|
||||
import { SwitchInput } from "@app/components/SwitchInput";
|
||||
import { Button } from "@app/components/ui/button";
|
||||
import { FormDescription, FormItem, FormLabel } from "@app/components/ui/form";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue
|
||||
} from "@app/components/ui/select";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
export type PolicyAuthSsoSectionProps = {
|
||||
sso: boolean;
|
||||
onSsoChange: (active: boolean) => void;
|
||||
skipToIdpId: number | null | undefined;
|
||||
onSkipToIdpChange: (id: number | null) => void;
|
||||
allIdps: { id: number; text: string }[];
|
||||
rolesEditor: React.ReactNode;
|
||||
usersEditor: React.ReactNode;
|
||||
disabled?: boolean;
|
||||
idpDisabled?: boolean;
|
||||
};
|
||||
|
||||
export function PolicyAuthSsoSection({
|
||||
sso,
|
||||
onSsoChange,
|
||||
skipToIdpId,
|
||||
onSkipToIdpChange,
|
||||
allIdps,
|
||||
rolesEditor,
|
||||
usersEditor,
|
||||
disabled,
|
||||
idpDisabled
|
||||
}: PolicyAuthSsoSectionProps) {
|
||||
const t = useTranslations();
|
||||
const [showIdpSelect, setShowIdpSelect] = useState(skipToIdpId != null);
|
||||
|
||||
useEffect(() => {
|
||||
if (skipToIdpId != null) {
|
||||
setShowIdpSelect(true);
|
||||
}
|
||||
}, [skipToIdpId]);
|
||||
|
||||
const idpSelectDisabled = idpDisabled ?? disabled;
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<SwitchInput
|
||||
id="policy-auth-sso"
|
||||
label={t("policyAuthSsoTitle")}
|
||||
description={t("policyAuthSsoDescription")}
|
||||
checked={sso}
|
||||
disabled={disabled}
|
||||
onCheckedChange={onSsoChange}
|
||||
/>
|
||||
|
||||
{sso && (
|
||||
<SettingsSectionForm className="max-w-none space-y-4">
|
||||
<FormItem className="flex flex-col items-start">
|
||||
<FormLabel>{t("roles")}</FormLabel>
|
||||
{rolesEditor}
|
||||
</FormItem>
|
||||
<FormItem className="flex flex-col items-start">
|
||||
<FormLabel>{t("users")}</FormLabel>
|
||||
{usersEditor}
|
||||
</FormItem>
|
||||
{allIdps.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
{skipToIdpId == null && !showIdpSelect ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="text"
|
||||
size="sm"
|
||||
className="h-auto px-0"
|
||||
disabled={idpSelectDisabled}
|
||||
onClick={() => setShowIdpSelect(true)}
|
||||
>
|
||||
{t("policyAuthAddDefaultIdentityProvider")}
|
||||
</Button>
|
||||
) : (
|
||||
<>
|
||||
<label className="text-sm font-medium">
|
||||
{t("defaultIdentityProvider")}
|
||||
</label>
|
||||
<Select
|
||||
disabled={idpSelectDisabled}
|
||||
onValueChange={(value) => {
|
||||
if (value === "none") {
|
||||
onSkipToIdpChange(null);
|
||||
setShowIdpSelect(false);
|
||||
return;
|
||||
}
|
||||
onSkipToIdpChange(parseInt(value));
|
||||
}}
|
||||
value={
|
||||
skipToIdpId
|
||||
? skipToIdpId.toString()
|
||||
: "none"
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue
|
||||
placeholder={t(
|
||||
"selectIdpPlaceholder"
|
||||
)}
|
||||
/>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="none">
|
||||
{t("none")}
|
||||
</SelectItem>
|
||||
{allIdps.map((idp) => (
|
||||
<SelectItem
|
||||
key={idp.id}
|
||||
value={idp.id.toString()}
|
||||
>
|
||||
{idp.text}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t(
|
||||
"defaultIdentityProviderDescription"
|
||||
)}
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</SettingsSectionForm>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
"use client";
|
||||
|
||||
import { type UseFormReturn } from "react-hook-form";
|
||||
import type { PolicyFormValues } from ".";
|
||||
import { PolicyAuthStackSectionCreate } from "./PolicyAuthStackSectionCreate";
|
||||
import { PolicyAuthStackSectionEdit } from "./PolicyAuthStackSectionEdit";
|
||||
|
||||
type PolicyAuthStackSectionEditProps = {
|
||||
mode: "edit";
|
||||
orgId: string;
|
||||
allIdps: { id: number; text: string }[];
|
||||
emailEnabled: boolean;
|
||||
readonly?: boolean;
|
||||
resourceId?: number;
|
||||
};
|
||||
|
||||
type PolicyAuthStackSectionCreateProps = {
|
||||
mode: "create";
|
||||
form: UseFormReturn<PolicyFormValues, any, any>;
|
||||
orgId: string;
|
||||
allIdps: { id: number; text: string }[];
|
||||
allRoles: { id: string; text: string }[];
|
||||
allUsers: { id: string; text: string }[];
|
||||
emailEnabled: boolean;
|
||||
};
|
||||
|
||||
export type PolicyAuthStackSectionProps =
|
||||
| PolicyAuthStackSectionEditProps
|
||||
| PolicyAuthStackSectionCreateProps;
|
||||
|
||||
export function PolicyAuthStackSection(props: PolicyAuthStackSectionProps) {
|
||||
if (props.mode === "create") {
|
||||
const { mode: _, ...createProps } = props;
|
||||
return <PolicyAuthStackSectionCreate {...createProps} />;
|
||||
}
|
||||
const { mode: _, ...editProps } = props;
|
||||
return <PolicyAuthStackSectionEdit {...editProps} />;
|
||||
}
|
||||
@@ -0,0 +1,310 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
SettingsSection,
|
||||
SettingsSectionBody,
|
||||
SettingsSectionDescription,
|
||||
SettingsSectionHeader,
|
||||
SettingsSubsectionDescription,
|
||||
SettingsSubsectionHeader,
|
||||
SettingsSubsectionTitle,
|
||||
SettingsSectionTitle
|
||||
} from "@app/components/Settings";
|
||||
import { TagInput } from "@app/components/tags/tag-input";
|
||||
import { FormField } from "@app/components/ui/form";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useState } from "react";
|
||||
import { type UseFormReturn, useWatch } from "react-hook-form";
|
||||
import type { PolicyFormValues } from ".";
|
||||
import {
|
||||
EmailCredenza,
|
||||
HeaderAuthCredenza,
|
||||
PasscodeCredenza,
|
||||
PincodeCredenza
|
||||
} from "./PolicyAuthMethodCredenzas";
|
||||
import { PolicyAuthMethodRow } from "./PolicyAuthMethodRow";
|
||||
import { PolicyAuthSsoSection } from "./PolicyAuthSsoSection";
|
||||
import type { PolicyAuthMethodId } from "./policy-auth-method-id";
|
||||
import {
|
||||
getEmailWhitelistSummary,
|
||||
getHeaderAuthSummary,
|
||||
getPasscodeSummary,
|
||||
getPincodeSummary
|
||||
} from "./policy-auth-summaries";
|
||||
|
||||
export type PolicyAuthStackSectionCreateProps = {
|
||||
form: UseFormReturn<PolicyFormValues, any, any>;
|
||||
orgId: string;
|
||||
allIdps: { id: number; text: string }[];
|
||||
allRoles: { id: string; text: string }[];
|
||||
allUsers: { id: string; text: string }[];
|
||||
emailEnabled: boolean;
|
||||
};
|
||||
|
||||
export function PolicyAuthStackSectionCreate({
|
||||
form: parentForm,
|
||||
allIdps,
|
||||
allRoles,
|
||||
allUsers,
|
||||
emailEnabled
|
||||
}: PolicyAuthStackSectionCreateProps) {
|
||||
const t = useTranslations();
|
||||
const [editingMethod, setEditingMethod] =
|
||||
useState<PolicyAuthMethodId | null>(null);
|
||||
const [activeRolesTagIndex, setActiveRolesTagIndex] = useState<
|
||||
number | null
|
||||
>(null);
|
||||
const [activeUsersTagIndex, setActiveUsersTagIndex] = useState<
|
||||
number | null
|
||||
>(null);
|
||||
|
||||
const sso = useWatch({ control: parentForm.control, name: "sso" });
|
||||
const skipToIdpId = useWatch({
|
||||
control: parentForm.control,
|
||||
name: "skipToIdpId"
|
||||
});
|
||||
const password = useWatch({
|
||||
control: parentForm.control,
|
||||
name: "password"
|
||||
});
|
||||
const pincode = useWatch({ control: parentForm.control, name: "pincode" });
|
||||
const headerAuth = useWatch({
|
||||
control: parentForm.control,
|
||||
name: "headerAuth"
|
||||
});
|
||||
const emailWhitelistEnabled = useWatch({
|
||||
control: parentForm.control,
|
||||
name: "emailWhitelistEnabled"
|
||||
});
|
||||
const emails =
|
||||
useWatch({ control: parentForm.control, name: "emails" }) ?? [];
|
||||
|
||||
const passcodeActive = Boolean(password);
|
||||
const pinActive = Boolean(pincode);
|
||||
const headerAuthActive = Boolean(headerAuth);
|
||||
|
||||
const closeCredenza = () => setEditingMethod(null);
|
||||
|
||||
const handleToggle = (
|
||||
method: PolicyAuthMethodId,
|
||||
active: boolean,
|
||||
onDisable: () => void,
|
||||
onEnable?: () => void
|
||||
) => {
|
||||
if (active) {
|
||||
onEnable?.();
|
||||
setEditingMethod(method);
|
||||
return;
|
||||
}
|
||||
onDisable();
|
||||
setEditingMethod((current) => (current === method ? null : current));
|
||||
};
|
||||
|
||||
return (
|
||||
<SettingsSection>
|
||||
<SettingsSectionHeader>
|
||||
<SettingsSectionTitle>
|
||||
{t("policyAuthStackTitle")}
|
||||
</SettingsSectionTitle>
|
||||
<SettingsSectionDescription>
|
||||
{t("policyAuthStackDescription")}
|
||||
</SettingsSectionDescription>
|
||||
</SettingsSectionHeader>
|
||||
<SettingsSectionBody>
|
||||
<div className="w-full md:w-1/2">
|
||||
<PolicyAuthSsoSection
|
||||
sso={Boolean(sso)}
|
||||
onSsoChange={(active) =>
|
||||
parentForm.setValue("sso", active)
|
||||
}
|
||||
skipToIdpId={skipToIdpId}
|
||||
onSkipToIdpChange={(id) =>
|
||||
parentForm.setValue("skipToIdpId", id)
|
||||
}
|
||||
allIdps={allIdps}
|
||||
rolesEditor={
|
||||
<FormField<PolicyFormValues, "roles">
|
||||
control={parentForm.control}
|
||||
name="roles"
|
||||
render={({ field }) => (
|
||||
<TagInput
|
||||
{...field}
|
||||
activeTagIndex={activeRolesTagIndex}
|
||||
setActiveTagIndex={
|
||||
setActiveRolesTagIndex
|
||||
}
|
||||
placeholder={t("accessRoleSelect2")}
|
||||
tags={field.value ?? []}
|
||||
setTags={(newRoles) =>
|
||||
field.onChange(newRoles)
|
||||
}
|
||||
autocompleteOptions={allRoles}
|
||||
allowDuplicates={false}
|
||||
size="sm"
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
}
|
||||
usersEditor={
|
||||
<FormField<PolicyFormValues, "users">
|
||||
control={parentForm.control}
|
||||
name="users"
|
||||
render={({ field }) => (
|
||||
<TagInput
|
||||
{...field}
|
||||
activeTagIndex={activeUsersTagIndex}
|
||||
setActiveTagIndex={
|
||||
setActiveUsersTagIndex
|
||||
}
|
||||
placeholder={t("accessUserSelect")}
|
||||
tags={field.value ?? []}
|
||||
setTags={(newUsers) =>
|
||||
field.onChange(newUsers)
|
||||
}
|
||||
autocompleteOptions={allUsers}
|
||||
allowDuplicates={false}
|
||||
size="sm"
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<SettingsSubsectionHeader>
|
||||
<SettingsSubsectionTitle>
|
||||
{t("policyAuthOtherMethodsTitle")}
|
||||
</SettingsSubsectionTitle>
|
||||
<SettingsSubsectionDescription>
|
||||
{t("policyAuthOtherMethodsDescription")}
|
||||
</SettingsSubsectionDescription>
|
||||
</SettingsSubsectionHeader>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||
<PolicyAuthMethodRow
|
||||
id="pincode"
|
||||
title={t("policyAuthPincodeTitle")}
|
||||
description={t("policyAuthPincodeDescription")}
|
||||
summary={getPincodeSummary({ t })}
|
||||
active={pinActive}
|
||||
onConfigure={() => setEditingMethod("pincode")}
|
||||
onToggle={(active) =>
|
||||
handleToggle("pincode", active, () =>
|
||||
parentForm.setValue("pincode", null)
|
||||
)
|
||||
}
|
||||
/>
|
||||
|
||||
<PolicyAuthMethodRow
|
||||
id="passcode"
|
||||
title={t("policyAuthPasscodeTitle")}
|
||||
description={t("policyAuthPasscodeDescription")}
|
||||
summary={getPasscodeSummary({ t })}
|
||||
active={passcodeActive}
|
||||
onConfigure={() => setEditingMethod("passcode")}
|
||||
onToggle={(active) =>
|
||||
handleToggle("passcode", active, () =>
|
||||
parentForm.setValue("password", null)
|
||||
)
|
||||
}
|
||||
/>
|
||||
|
||||
<PolicyAuthMethodRow
|
||||
id="email"
|
||||
title={t("policyAuthEmailTitle")}
|
||||
description={t("policyAuthEmailDescription")}
|
||||
summary={getEmailWhitelistSummary({
|
||||
t,
|
||||
count: emails.length
|
||||
})}
|
||||
active={Boolean(emailWhitelistEnabled)}
|
||||
onConfigure={() => setEditingMethod("email")}
|
||||
onToggle={(active) =>
|
||||
handleToggle(
|
||||
"email",
|
||||
active,
|
||||
() =>
|
||||
parentForm.setValue(
|
||||
"emailWhitelistEnabled",
|
||||
false
|
||||
),
|
||||
() =>
|
||||
parentForm.setValue(
|
||||
"emailWhitelistEnabled",
|
||||
true
|
||||
)
|
||||
)
|
||||
}
|
||||
disabled={!emailEnabled}
|
||||
/>
|
||||
|
||||
<PolicyAuthMethodRow
|
||||
id="header-auth"
|
||||
title={t("policyAuthHeaderAuthTitle")}
|
||||
description={t("policyAuthHeaderAuthDescription")}
|
||||
summary={getHeaderAuthSummary({
|
||||
t,
|
||||
headerName: headerAuth?.user ?? ""
|
||||
})}
|
||||
active={headerAuthActive}
|
||||
onConfigure={() => setEditingMethod("headerAuth")}
|
||||
onToggle={(active) =>
|
||||
handleToggle("headerAuth", active, () =>
|
||||
parentForm.setValue("headerAuth", null)
|
||||
)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<PincodeCredenza
|
||||
open={editingMethod === "pincode"}
|
||||
onOpenChange={(open) => !open && closeCredenza()}
|
||||
defaultPincode={pincode?.pincode ?? ""}
|
||||
onSave={(value) => {
|
||||
parentForm.setValue("pincode", { pincode: value });
|
||||
}}
|
||||
/>
|
||||
|
||||
<PasscodeCredenza
|
||||
open={editingMethod === "passcode"}
|
||||
onOpenChange={(open) => !open && closeCredenza()}
|
||||
defaultPassword={password?.password ?? ""}
|
||||
onSave={(value) => {
|
||||
parentForm.setValue("password", { password: value });
|
||||
}}
|
||||
/>
|
||||
|
||||
<EmailCredenza
|
||||
open={editingMethod === "email"}
|
||||
onOpenChange={(open) => !open && closeCredenza()}
|
||||
emailEnabled={emailEnabled}
|
||||
emails={emails}
|
||||
onEmailsChange={(value) =>
|
||||
parentForm.setValue(
|
||||
"emails",
|
||||
value as PolicyFormValues["emails"]
|
||||
)
|
||||
}
|
||||
/>
|
||||
|
||||
<HeaderAuthCredenza
|
||||
open={editingMethod === "headerAuth"}
|
||||
onOpenChange={(open) => !open && closeCredenza()}
|
||||
defaultValues={
|
||||
headerAuth
|
||||
? {
|
||||
user: headerAuth.user,
|
||||
password: headerAuth.password,
|
||||
extendedCompatibility:
|
||||
headerAuth.extendedCompatibility
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
onSave={(value) => {
|
||||
parentForm.setValue("headerAuth", value);
|
||||
}}
|
||||
/>
|
||||
</SettingsSectionBody>
|
||||
</SettingsSection>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,695 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
SettingsSection,
|
||||
SettingsSectionBody,
|
||||
SettingsSectionDescription,
|
||||
SettingsSectionFooter,
|
||||
SettingsSectionHeader,
|
||||
SettingsSubsectionDescription,
|
||||
SettingsSubsectionHeader,
|
||||
SettingsSubsectionTitle,
|
||||
SettingsSectionTitle
|
||||
} from "@app/components/Settings";
|
||||
import {
|
||||
RolesSelector,
|
||||
type SelectedRole
|
||||
} from "@app/components/roles-selector";
|
||||
import { UsersSelector } from "@app/components/users-selector";
|
||||
import { Button } from "@app/components/ui/button";
|
||||
import { Form, FormField } from "@app/components/ui/form";
|
||||
import { toast } from "@app/hooks/useToast";
|
||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||
import { createApiClient, formatAxiosError } from "@app/lib/api";
|
||||
import { getUserDisplayName } from "@app/lib/getUserDisplayName";
|
||||
import { resourceQueries } from "@app/lib/queries";
|
||||
import { useResourcePolicyContext } from "@app/providers/ResourcePolicyProvider";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { UserType } from "@server/types/UserTypes";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import type { AxiosResponse } from "axios";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useActionState, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useForm, useWatch } from "react-hook-form";
|
||||
import { createPolicySchema } from ".";
|
||||
import {
|
||||
EmailCredenza,
|
||||
HeaderAuthCredenza,
|
||||
PasscodeCredenza,
|
||||
PincodeCredenza
|
||||
} from "./PolicyAuthMethodCredenzas";
|
||||
import { PolicyAuthMethodRow } from "./PolicyAuthMethodRow";
|
||||
import { PolicyAuthSsoSection } from "./PolicyAuthSsoSection";
|
||||
import type { PolicyAuthMethodId } from "./policy-auth-method-id";
|
||||
import {
|
||||
getEmailWhitelistSummary,
|
||||
getHeaderAuthSummary,
|
||||
getPasscodeSummary,
|
||||
getPincodeSummary
|
||||
} from "./policy-auth-summaries";
|
||||
|
||||
type OverlaySelectedRole = SelectedRole & { isAdmin: boolean };
|
||||
|
||||
const authStackSchema = createPolicySchema.pick({
|
||||
sso: true,
|
||||
skipToIdpId: true,
|
||||
roles: true,
|
||||
users: true,
|
||||
password: true,
|
||||
pincode: true,
|
||||
headerAuth: true,
|
||||
emailWhitelistEnabled: true,
|
||||
emails: true
|
||||
});
|
||||
|
||||
export type PolicyAuthStackSectionEditProps = {
|
||||
orgId: string;
|
||||
allIdps: { id: number; text: string }[];
|
||||
emailEnabled: boolean;
|
||||
readonly?: boolean;
|
||||
resourceId?: number;
|
||||
};
|
||||
|
||||
export function PolicyAuthStackSectionEdit({
|
||||
orgId,
|
||||
allIdps,
|
||||
emailEnabled,
|
||||
readonly,
|
||||
resourceId
|
||||
}: PolicyAuthStackSectionEditProps) {
|
||||
const t = useTranslations();
|
||||
const router = useRouter();
|
||||
const { policy } = useResourcePolicyContext();
|
||||
const api = createApiClient(useEnvContext());
|
||||
|
||||
const isResourceOverlay = resourceId !== undefined;
|
||||
const authReadonly = readonly || isResourceOverlay;
|
||||
|
||||
const policyRoleItems = useMemo<OverlaySelectedRole[]>(
|
||||
() =>
|
||||
policy.roles.map((r) => ({
|
||||
id: r.roleId.toString(),
|
||||
text: r.name,
|
||||
isAdmin: false
|
||||
})),
|
||||
[policy.roles]
|
||||
);
|
||||
const policyUserItems = useMemo(
|
||||
() =>
|
||||
policy.users.map((u) => ({
|
||||
id: u.userId,
|
||||
text: `${getUserDisplayName({ email: u.email, username: u.username })}${u.type !== UserType.Internal ? ` (${u.idpName})` : ""}`
|
||||
})),
|
||||
[policy.users]
|
||||
);
|
||||
|
||||
const policyRoleLockedIds = useMemo(
|
||||
() => new Set(policy.roles.map((r) => r.roleId.toString())),
|
||||
[policy.roles]
|
||||
);
|
||||
const policyUserLockedIds = useMemo(
|
||||
() => new Set(policy.users.map((u) => u.userId)),
|
||||
[policy.users]
|
||||
);
|
||||
|
||||
const { data: resourceRolesData } = useQuery({
|
||||
...resourceQueries.resourceRoles({ resourceId: resourceId! }),
|
||||
enabled: isResourceOverlay
|
||||
});
|
||||
const { data: resourceUsersData } = useQuery({
|
||||
...resourceQueries.resourceUsers({ resourceId: resourceId! }),
|
||||
enabled: isResourceOverlay
|
||||
});
|
||||
|
||||
const [combinedRoles, setCombinedRoles] =
|
||||
useState<OverlaySelectedRole[]>(policyRoleItems);
|
||||
const [combinedUsers, setCombinedUsers] = useState(policyUserItems);
|
||||
const [resourceRolesInitialized, setResourceRolesInitialized] =
|
||||
useState(false);
|
||||
const [resourceUsersInitialized, setResourceUsersInitialized] =
|
||||
useState(false);
|
||||
const initialResourceRoleIdsRef = useRef<Set<string>>(new Set());
|
||||
const initialResourceUserIdsRef = useRef<Set<string>>(new Set());
|
||||
|
||||
useEffect(() => {
|
||||
if (!isResourceOverlay || resourceRolesInitialized) return;
|
||||
if (!resourceRolesData) return;
|
||||
const resourceSpecific = resourceRolesData
|
||||
.filter((r) => !policyRoleLockedIds.has(r.roleId.toString()))
|
||||
.map((r) => ({
|
||||
id: r.roleId.toString(),
|
||||
text: r.name,
|
||||
isAdmin: Boolean(r.isAdmin)
|
||||
}));
|
||||
initialResourceRoleIdsRef.current = new Set(
|
||||
resourceSpecific.map((r) => r.id)
|
||||
);
|
||||
setCombinedRoles(
|
||||
[...policyRoleItems, ...resourceSpecific].filter(
|
||||
(role) => !role.isAdmin
|
||||
)
|
||||
);
|
||||
setResourceRolesInitialized(true);
|
||||
}, [
|
||||
isResourceOverlay,
|
||||
resourceRolesData,
|
||||
resourceRolesInitialized,
|
||||
policyRoleItems,
|
||||
policyRoleLockedIds
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isResourceOverlay || resourceUsersInitialized) return;
|
||||
if (!resourceUsersData) return;
|
||||
const resourceSpecific = resourceUsersData
|
||||
.filter((u) => !policyUserLockedIds.has(u.userId))
|
||||
.map((u) => ({
|
||||
id: u.userId,
|
||||
text: `${getUserDisplayName({ email: u.email ?? undefined, username: u.username ?? undefined })}${u.type !== UserType.Internal ? ` (${u.idpName})` : ""}`
|
||||
}));
|
||||
initialResourceUserIdsRef.current = new Set(
|
||||
resourceSpecific.map((u) => u.id)
|
||||
);
|
||||
setCombinedUsers([...policyUserItems, ...resourceSpecific]);
|
||||
setResourceUsersInitialized(true);
|
||||
}, [
|
||||
isResourceOverlay,
|
||||
resourceUsersData,
|
||||
resourceUsersInitialized,
|
||||
policyUserItems,
|
||||
policyUserLockedIds
|
||||
]);
|
||||
|
||||
const form = useForm({
|
||||
resolver: zodResolver(authStackSchema),
|
||||
defaultValues: {
|
||||
sso: policy.sso,
|
||||
skipToIdpId: policy.idpId,
|
||||
roles: policyRoleItems,
|
||||
users: policyUserItems,
|
||||
password: policy.passwordId ? { password: "" } : null,
|
||||
pincode: policy.pincodeId ? { pincode: "" } : null,
|
||||
headerAuth: policy.headerAuth
|
||||
? {
|
||||
user: "",
|
||||
password: "",
|
||||
extendedCompatibility:
|
||||
policy.headerAuth.extendedCompability ?? true
|
||||
}
|
||||
: null,
|
||||
emailWhitelistEnabled: policy.emailWhitelistEnabled,
|
||||
emails: policy.emailWhiteList.map((email) => ({
|
||||
id: email.whiteListId.toString(),
|
||||
text: email.email
|
||||
}))
|
||||
}
|
||||
});
|
||||
|
||||
const [passcodeActive, setPasscodeActive] = useState(
|
||||
Boolean(policy.passwordId)
|
||||
);
|
||||
const [pinActive, setPinActive] = useState(Boolean(policy.pincodeId));
|
||||
const [headerAuthActive, setHeaderAuthActive] = useState(
|
||||
Boolean(policy.headerAuth)
|
||||
);
|
||||
const [editingMethod, setEditingMethod] =
|
||||
useState<PolicyAuthMethodId | null>(null);
|
||||
|
||||
const sso = useWatch({ control: form.control, name: "sso" });
|
||||
const skipToIdpId = useWatch({
|
||||
control: form.control,
|
||||
name: "skipToIdpId"
|
||||
});
|
||||
const roles = useWatch({ control: form.control, name: "roles" }) ?? [];
|
||||
const users = useWatch({ control: form.control, name: "users" }) ?? [];
|
||||
const password = useWatch({ control: form.control, name: "password" });
|
||||
const pincode = useWatch({ control: form.control, name: "pincode" });
|
||||
const headerAuth = useWatch({ control: form.control, name: "headerAuth" });
|
||||
const emailWhitelistEnabled = useWatch({
|
||||
control: form.control,
|
||||
name: "emailWhitelistEnabled"
|
||||
});
|
||||
const emails = useWatch({ control: form.control, name: "emails" }) ?? [];
|
||||
|
||||
const overlayRoles = combinedRoles.filter((r) => !r.isAdmin);
|
||||
const overlayUsers = combinedUsers;
|
||||
|
||||
const [, formAction, isSubmitting] = useActionState(onSubmit, null);
|
||||
const [isSavingOverlay, setIsSavingOverlay] = useState(false);
|
||||
|
||||
async function onSubmit() {
|
||||
if (readonly && !isResourceOverlay) return;
|
||||
|
||||
if (isResourceOverlay) {
|
||||
await saveResourceOverlay();
|
||||
return;
|
||||
}
|
||||
|
||||
const isValid = await form.trigger();
|
||||
if (!isValid) return;
|
||||
|
||||
const payload = form.getValues();
|
||||
const requests: Array<Promise<AxiosResponse<{}> | void>> = [];
|
||||
|
||||
requests.push(
|
||||
api
|
||||
.put(
|
||||
`/resource-policy/${policy.resourcePolicyId}/access-control`,
|
||||
{
|
||||
sso: payload.sso,
|
||||
userIds: payload.users.map((user) => user.id),
|
||||
roleIds: payload.roles.map((role) => Number(role.id)),
|
||||
skipToIdpId: payload.skipToIdpId
|
||||
}
|
||||
)
|
||||
.catch(handleError)
|
||||
);
|
||||
|
||||
if (passcodeActive && payload.password?.password) {
|
||||
requests.push(
|
||||
api
|
||||
.put(
|
||||
`/resource-policy/${policy.resourcePolicyId}/password`,
|
||||
{ password: payload.password.password }
|
||||
)
|
||||
.catch(handleError)
|
||||
);
|
||||
} else if (!passcodeActive && policy.passwordId) {
|
||||
requests.push(
|
||||
api
|
||||
.put(
|
||||
`/resource-policy/${policy.resourcePolicyId}/password`,
|
||||
{ password: null }
|
||||
)
|
||||
.catch(handleError)
|
||||
);
|
||||
}
|
||||
|
||||
if (pinActive && payload.pincode?.pincode?.length === 6) {
|
||||
requests.push(
|
||||
api
|
||||
.put(
|
||||
`/resource-policy/${policy.resourcePolicyId}/pincode`,
|
||||
{ pincode: payload.pincode.pincode }
|
||||
)
|
||||
.catch(handleError)
|
||||
);
|
||||
} else if (!pinActive && policy.pincodeId) {
|
||||
requests.push(
|
||||
api
|
||||
.put(
|
||||
`/resource-policy/${policy.resourcePolicyId}/pincode`,
|
||||
{ pincode: null }
|
||||
)
|
||||
.catch(handleError)
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
headerAuthActive &&
|
||||
payload.headerAuth?.user &&
|
||||
payload.headerAuth?.password
|
||||
) {
|
||||
requests.push(
|
||||
api
|
||||
.put(
|
||||
`/resource-policy/${policy.resourcePolicyId}/header-auth`,
|
||||
{ headerAuth: payload.headerAuth }
|
||||
)
|
||||
.catch(handleError)
|
||||
);
|
||||
} else if (!headerAuthActive && policy.headerAuth) {
|
||||
requests.push(
|
||||
api
|
||||
.put(
|
||||
`/resource-policy/${policy.resourcePolicyId}/header-auth`,
|
||||
{ headerAuth: null }
|
||||
)
|
||||
.catch(handleError)
|
||||
);
|
||||
}
|
||||
|
||||
requests.push(
|
||||
api
|
||||
.put(`/resource-policy/${policy.resourcePolicyId}/whitelist`, {
|
||||
emailWhitelistEnabled: payload.emailWhitelistEnabled,
|
||||
emails: payload.emails?.map((e) => e.text) ?? []
|
||||
})
|
||||
.catch(handleError)
|
||||
);
|
||||
|
||||
try {
|
||||
const results = await Promise.all(requests);
|
||||
if (results.every((res) => res && res.status === 200)) {
|
||||
toast({
|
||||
title: t("success"),
|
||||
description: t("policyUpdatedSuccess")
|
||||
});
|
||||
router.refresh();
|
||||
}
|
||||
} catch {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: t("policyErrorUpdate"),
|
||||
description: t("policyErrorUpdateMessageDescription")
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function handleError(e: unknown) {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: t("policyErrorUpdate"),
|
||||
description: formatAxiosError(e, t("policyErrorUpdateDescription"))
|
||||
});
|
||||
}
|
||||
|
||||
async function saveResourceOverlay() {
|
||||
setIsSavingOverlay(true);
|
||||
try {
|
||||
const currentResourceRoleIds = combinedRoles
|
||||
.filter((r) => !policyRoleLockedIds.has(r.id))
|
||||
.map((r) => Number(r.id));
|
||||
const currentResourceUserIds = combinedUsers
|
||||
.filter((u) => !policyUserLockedIds.has(u.id))
|
||||
.map((u) => u.id);
|
||||
|
||||
await Promise.all([
|
||||
api.post(`/resource/${resourceId}/roles`, {
|
||||
roleIds: currentResourceRoleIds
|
||||
}),
|
||||
api.post(`/resource/${resourceId}/users`, {
|
||||
userIds: currentResourceUserIds
|
||||
})
|
||||
]);
|
||||
|
||||
toast({
|
||||
title: t("success"),
|
||||
description: t("policyUpdatedSuccess")
|
||||
});
|
||||
router.refresh();
|
||||
} catch (e) {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: t("policyErrorUpdate"),
|
||||
description: formatAxiosError(
|
||||
e,
|
||||
t("policyErrorUpdateDescription")
|
||||
)
|
||||
});
|
||||
} finally {
|
||||
setIsSavingOverlay(false);
|
||||
}
|
||||
}
|
||||
|
||||
const isLoading =
|
||||
isResourceOverlay &&
|
||||
(!resourceRolesInitialized || !resourceUsersInitialized);
|
||||
|
||||
const closeCredenza = () => setEditingMethod(null);
|
||||
|
||||
const openMethodEditor = (method: PolicyAuthMethodId) => {
|
||||
setEditingMethod(method);
|
||||
};
|
||||
|
||||
const handleToggle = (
|
||||
method: PolicyAuthMethodId,
|
||||
active: boolean,
|
||||
onDisable: () => void,
|
||||
onEnable?: () => void
|
||||
) => {
|
||||
if (active) {
|
||||
onEnable?.();
|
||||
openMethodEditor(method);
|
||||
return;
|
||||
}
|
||||
onDisable();
|
||||
setEditingMethod((current) => (current === method ? null : current));
|
||||
};
|
||||
|
||||
return (
|
||||
<Form {...form}>
|
||||
<form action={formAction}>
|
||||
<SettingsSection>
|
||||
<SettingsSectionHeader>
|
||||
<SettingsSectionTitle>
|
||||
{t("policyAuthStackTitle")}
|
||||
</SettingsSectionTitle>
|
||||
<SettingsSectionDescription>
|
||||
{t("policyAuthStackDescription")}
|
||||
</SettingsSectionDescription>
|
||||
</SettingsSectionHeader>
|
||||
<SettingsSectionBody>
|
||||
<div className="w-full md:w-1/2">
|
||||
<PolicyAuthSsoSection
|
||||
sso={Boolean(sso)}
|
||||
onSsoChange={(active) =>
|
||||
form.setValue("sso", active)
|
||||
}
|
||||
skipToIdpId={skipToIdpId}
|
||||
onSkipToIdpChange={(id) =>
|
||||
form.setValue("skipToIdpId", id)
|
||||
}
|
||||
allIdps={allIdps}
|
||||
disabled={authReadonly}
|
||||
idpDisabled={authReadonly}
|
||||
rolesEditor={
|
||||
isResourceOverlay ? (
|
||||
<RolesSelector
|
||||
orgId={orgId}
|
||||
selectedRoles={overlayRoles}
|
||||
onSelectRoles={(selected) =>
|
||||
setCombinedRoles(
|
||||
selected.map((role) => ({
|
||||
...role,
|
||||
isAdmin: Boolean(
|
||||
role.isAdmin
|
||||
)
|
||||
}))
|
||||
)
|
||||
}
|
||||
disabled={isLoading}
|
||||
restrictAdminRole
|
||||
lockedIds={policyRoleLockedIds}
|
||||
/>
|
||||
) : (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="roles"
|
||||
render={({ field }) => (
|
||||
<RolesSelector
|
||||
orgId={orgId}
|
||||
selectedRoles={field.value}
|
||||
onSelectRoles={(selected) =>
|
||||
form.setValue(
|
||||
"roles",
|
||||
selected
|
||||
)
|
||||
}
|
||||
disabled={readonly}
|
||||
restrictAdminRole
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
usersEditor={
|
||||
isResourceOverlay ? (
|
||||
<UsersSelector
|
||||
orgId={orgId}
|
||||
selectedUsers={overlayUsers}
|
||||
onSelectUsers={setCombinedUsers}
|
||||
disabled={isLoading}
|
||||
lockedIds={policyUserLockedIds}
|
||||
/>
|
||||
) : (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="users"
|
||||
render={({ field }) => (
|
||||
<UsersSelector
|
||||
orgId={orgId}
|
||||
selectedUsers={field.value}
|
||||
onSelectUsers={(selected) =>
|
||||
form.setValue(
|
||||
"users",
|
||||
selected
|
||||
)
|
||||
}
|
||||
disabled={readonly}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<SettingsSubsectionHeader>
|
||||
<SettingsSubsectionTitle>
|
||||
{t("policyAuthOtherMethodsTitle")}
|
||||
</SettingsSubsectionTitle>
|
||||
<SettingsSubsectionDescription>
|
||||
{t("policyAuthOtherMethodsDescription")}
|
||||
</SettingsSubsectionDescription>
|
||||
</SettingsSubsectionHeader>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||
<PolicyAuthMethodRow
|
||||
id="pincode"
|
||||
title={t("policyAuthPincodeTitle")}
|
||||
description={t("policyAuthPincodeDescription")}
|
||||
summary={getPincodeSummary({ t })}
|
||||
active={pinActive}
|
||||
onConfigure={() => openMethodEditor("pincode")}
|
||||
onToggle={(active) =>
|
||||
handleToggle("pincode", active, () => {
|
||||
setPinActive(false);
|
||||
form.setValue("pincode", null);
|
||||
})
|
||||
}
|
||||
disabled={authReadonly}
|
||||
/>
|
||||
|
||||
<PolicyAuthMethodRow
|
||||
id="passcode"
|
||||
title={t("policyAuthPasscodeTitle")}
|
||||
description={t("policyAuthPasscodeDescription")}
|
||||
summary={getPasscodeSummary({ t })}
|
||||
active={passcodeActive}
|
||||
onConfigure={() => openMethodEditor("passcode")}
|
||||
onToggle={(active) =>
|
||||
handleToggle("passcode", active, () => {
|
||||
setPasscodeActive(false);
|
||||
form.setValue("password", null);
|
||||
})
|
||||
}
|
||||
disabled={authReadonly}
|
||||
/>
|
||||
|
||||
<PolicyAuthMethodRow
|
||||
id="email"
|
||||
title={t("policyAuthEmailTitle")}
|
||||
description={t("policyAuthEmailDescription")}
|
||||
summary={getEmailWhitelistSummary({
|
||||
t,
|
||||
count: emails.length
|
||||
})}
|
||||
active={Boolean(emailWhitelistEnabled)}
|
||||
onConfigure={() => openMethodEditor("email")}
|
||||
onToggle={(active) =>
|
||||
handleToggle(
|
||||
"email",
|
||||
active,
|
||||
() =>
|
||||
form.setValue(
|
||||
"emailWhitelistEnabled",
|
||||
false
|
||||
),
|
||||
() =>
|
||||
form.setValue(
|
||||
"emailWhitelistEnabled",
|
||||
true
|
||||
)
|
||||
)
|
||||
}
|
||||
disabled={authReadonly || !emailEnabled}
|
||||
/>
|
||||
|
||||
<PolicyAuthMethodRow
|
||||
id="header-auth"
|
||||
title={t("policyAuthHeaderAuthTitle")}
|
||||
description={t(
|
||||
"policyAuthHeaderAuthDescription"
|
||||
)}
|
||||
summary={getHeaderAuthSummary({
|
||||
t,
|
||||
headerName: headerAuth?.user ?? ""
|
||||
})}
|
||||
active={headerAuthActive}
|
||||
onConfigure={() =>
|
||||
openMethodEditor("headerAuth")
|
||||
}
|
||||
onToggle={(active) =>
|
||||
handleToggle("headerAuth", active, () => {
|
||||
setHeaderAuthActive(false);
|
||||
form.setValue("headerAuth", null);
|
||||
})
|
||||
}
|
||||
disabled={authReadonly}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<PincodeCredenza
|
||||
open={editingMethod === "pincode"}
|
||||
onOpenChange={(open) => !open && closeCredenza()}
|
||||
defaultPincode={pincode?.pincode ?? ""}
|
||||
onSave={(value) => {
|
||||
form.setValue("pincode", { pincode: value });
|
||||
setPinActive(true);
|
||||
}}
|
||||
/>
|
||||
|
||||
<PasscodeCredenza
|
||||
open={editingMethod === "passcode"}
|
||||
onOpenChange={(open) => !open && closeCredenza()}
|
||||
defaultPassword={password?.password ?? ""}
|
||||
existingConfigured={Boolean(policy.passwordId)}
|
||||
onSave={(value) => {
|
||||
form.setValue("password", { password: value });
|
||||
setPasscodeActive(true);
|
||||
}}
|
||||
/>
|
||||
|
||||
<EmailCredenza
|
||||
open={editingMethod === "email"}
|
||||
onOpenChange={(open) => !open && closeCredenza()}
|
||||
emailEnabled={emailEnabled}
|
||||
disabled={authReadonly}
|
||||
emails={emails}
|
||||
onEmailsChange={(value) =>
|
||||
form.setValue("emails", value)
|
||||
}
|
||||
/>
|
||||
|
||||
<HeaderAuthCredenza
|
||||
open={editingMethod === "headerAuth"}
|
||||
onOpenChange={(open) => !open && closeCredenza()}
|
||||
defaultValues={
|
||||
headerAuth
|
||||
? {
|
||||
user: headerAuth.user,
|
||||
password: headerAuth.password,
|
||||
extendedCompatibility:
|
||||
headerAuth.extendedCompatibility ??
|
||||
true
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
existingConfigured={Boolean(policy.headerAuth)}
|
||||
onSave={(value) => {
|
||||
form.setValue("headerAuth", value);
|
||||
setHeaderAuthActive(true);
|
||||
}}
|
||||
/>
|
||||
</SettingsSectionBody>
|
||||
<SettingsSectionFooter>
|
||||
<Button
|
||||
type="submit"
|
||||
loading={isSubmitting || isSavingOverlay}
|
||||
disabled={
|
||||
(readonly && !isResourceOverlay) ||
|
||||
isSubmitting ||
|
||||
isSavingOverlay ||
|
||||
isLoading
|
||||
}
|
||||
>
|
||||
{t("authMethodsSave")}
|
||||
</Button>
|
||||
</SettingsSectionFooter>
|
||||
</SettingsSection>
|
||||
</form>
|
||||
</Form>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -46,13 +46,6 @@ export const createPolicySchema = z.object({
|
||||
|
||||
export type PolicyFormValues = z.infer<typeof createPolicySchema>;
|
||||
|
||||
export const addRuleSchema = z.object({
|
||||
action: z.enum(["ACCEPT", "DROP", "PASS"]),
|
||||
match: z.string(),
|
||||
value: z.string(),
|
||||
priority: z.coerce.number<number>().int().optional()
|
||||
});
|
||||
|
||||
export type LocalRule = {
|
||||
ruleId: number;
|
||||
action: "ACCEPT" | "DROP" | "PASS";
|
||||
@@ -63,3 +56,26 @@ export type LocalRule = {
|
||||
new?: boolean;
|
||||
updated?: boolean;
|
||||
};
|
||||
|
||||
export { PolicyAccessRulesTable } from "./PolicyAccessRulesTable";
|
||||
export type { PolicyAccessRulesTableProps } from "./PolicyAccessRulesTable";
|
||||
export {
|
||||
createEmptyRule,
|
||||
reorderPolicyRules,
|
||||
sortPolicyRulesByPriority,
|
||||
type EmptyRuleDraft,
|
||||
type PolicyAccessRule
|
||||
} from "./policy-access-rule-utils";
|
||||
export {
|
||||
createPolicyRulePrioritySchema,
|
||||
createPolicyRuleSchema,
|
||||
createPolicyRuleValueSchema,
|
||||
createPolicyRulesArraySchema,
|
||||
createPolicyRulesSectionSchema,
|
||||
createPolicySchemaWithI18n,
|
||||
getPolicyRuleValidationMessage,
|
||||
validatePolicyRulePriority,
|
||||
validatePolicyRuleValue,
|
||||
validatePolicyRulesForSave,
|
||||
type RuleValidationToast
|
||||
} from "./policy-access-rule-validation";
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
export type PolicyAccessRule = {
|
||||
ruleId: number;
|
||||
action: "ACCEPT" | "DROP" | "PASS";
|
||||
match: string;
|
||||
value: string;
|
||||
priority: number;
|
||||
enabled: boolean;
|
||||
new?: boolean;
|
||||
updated?: boolean;
|
||||
fromPolicy?: boolean;
|
||||
};
|
||||
|
||||
export type EmptyRuleDraft = PolicyAccessRule & {
|
||||
new: true;
|
||||
};
|
||||
|
||||
export function createEmptyRule(
|
||||
existingRules: Array<{ priority: number }>
|
||||
): EmptyRuleDraft {
|
||||
const priority =
|
||||
existingRules.reduce(
|
||||
(acc, rule) => (rule.priority > acc ? rule.priority : acc),
|
||||
0
|
||||
) + 1;
|
||||
|
||||
return {
|
||||
ruleId: Date.now(),
|
||||
action: "ACCEPT",
|
||||
match: "PATH",
|
||||
value: "",
|
||||
priority,
|
||||
enabled: true,
|
||||
new: true
|
||||
};
|
||||
}
|
||||
|
||||
export function sortPolicyRulesByPriority<T extends { priority: number }>(
|
||||
rules: T[]
|
||||
): T[] {
|
||||
return [...rules].sort((a, b) => a.priority - b.priority);
|
||||
}
|
||||
|
||||
export function reorderPolicyRules<
|
||||
T extends { priority: number; new?: boolean; updated?: boolean }
|
||||
>(
|
||||
rules: T[],
|
||||
fromIndex: number,
|
||||
toIndex: number,
|
||||
options?: { markUpdated?: boolean }
|
||||
): T[] {
|
||||
if (
|
||||
fromIndex === toIndex ||
|
||||
fromIndex < 0 ||
|
||||
toIndex < 0 ||
|
||||
fromIndex >= rules.length ||
|
||||
toIndex >= rules.length
|
||||
) {
|
||||
return rules;
|
||||
}
|
||||
|
||||
const reordered = [...rules];
|
||||
const [moved] = reordered.splice(fromIndex, 1);
|
||||
reordered.splice(toIndex, 0, moved);
|
||||
|
||||
return reordered.map((rule, index) => {
|
||||
const next = { ...rule, priority: index + 1 };
|
||||
if (options?.markUpdated && !rule.new) {
|
||||
return { ...next, updated: true };
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,237 @@
|
||||
import { COUNTRIES } from "@server/db/countries";
|
||||
import { isValidRegionId } from "@server/db/regions";
|
||||
import {
|
||||
isValidCIDR,
|
||||
isValidIP,
|
||||
isValidUrlGlobPattern
|
||||
} from "@server/lib/validators";
|
||||
import z from "zod";
|
||||
|
||||
type TranslateFn = (
|
||||
key: string,
|
||||
values?: Record<string, string | number>
|
||||
) => string;
|
||||
|
||||
export type RuleValidationToast = {
|
||||
title: string;
|
||||
description: string;
|
||||
};
|
||||
|
||||
export function getPolicyRuleValidationMessage(
|
||||
t: TranslateFn,
|
||||
issue: z.core.$ZodIssue
|
||||
): string {
|
||||
const ruleIndex = issue.path.find((segment) => typeof segment === "number");
|
||||
if (typeof ruleIndex === "number") {
|
||||
return t("rulesErrorValidationRuleDescription", {
|
||||
ruleNumber: ruleIndex + 1,
|
||||
message: issue.message
|
||||
});
|
||||
}
|
||||
return issue.message;
|
||||
}
|
||||
|
||||
export function createPolicyRulePrioritySchema(t: TranslateFn) {
|
||||
return z.coerce
|
||||
.number({ error: t("rulesErrorInvalidPriorityDescription") })
|
||||
.int({ message: t("rulesErrorInvalidPriorityDescription") })
|
||||
.min(1, { message: t("rulesErrorInvalidPriorityDescription") });
|
||||
}
|
||||
|
||||
export function createPolicyRuleValueSchema(t: TranslateFn, match: string) {
|
||||
const required = z
|
||||
.string()
|
||||
.min(1, { message: t("rulesErrorValueRequired") });
|
||||
|
||||
switch (match) {
|
||||
case "CIDR":
|
||||
return required.refine(isValidCIDR, {
|
||||
message: t("rulesErrorInvalidIpAddressRangeDescription")
|
||||
});
|
||||
case "IP":
|
||||
return required.refine(isValidIP, {
|
||||
message: t("rulesErrorInvalidIpAddressDescription")
|
||||
});
|
||||
case "PATH":
|
||||
return required.refine(isValidUrlGlobPattern, {
|
||||
message: t("rulesErrorInvalidUrlDescription")
|
||||
});
|
||||
case "REGION":
|
||||
return required.refine(isValidRegionId, {
|
||||
message: t("rulesErrorInvalidRegionDescription")
|
||||
});
|
||||
case "COUNTRY":
|
||||
return required.refine(
|
||||
(value) => COUNTRIES.some((country) => country.code === value),
|
||||
{ message: t("rulesErrorInvalidCountryDescription") }
|
||||
);
|
||||
case "ASN":
|
||||
return required.refine((value) => /^AS\d+$/i.test(value.trim()), {
|
||||
message: t("rulesErrorInvalidAsnDescription")
|
||||
});
|
||||
default:
|
||||
return required;
|
||||
}
|
||||
}
|
||||
|
||||
export function createPolicyRuleSchema(t: TranslateFn) {
|
||||
return z
|
||||
.object({
|
||||
action: z.enum(["ACCEPT", "DROP", "PASS"]),
|
||||
match: z.string(),
|
||||
value: z.string(),
|
||||
priority: z.number().int(),
|
||||
enabled: z.boolean()
|
||||
})
|
||||
.superRefine((rule, ctx) => {
|
||||
const priorityResult = createPolicyRulePrioritySchema(t).safeParse(
|
||||
rule.priority
|
||||
);
|
||||
if (!priorityResult.success) {
|
||||
ctx.addIssue({
|
||||
code: "custom",
|
||||
message:
|
||||
priorityResult.error.issues[0]?.message ??
|
||||
t("rulesErrorInvalidPriorityDescription"),
|
||||
path: ["priority"]
|
||||
});
|
||||
}
|
||||
|
||||
const valueResult = createPolicyRuleValueSchema(
|
||||
t,
|
||||
rule.match
|
||||
).safeParse(rule.value);
|
||||
if (!valueResult.success) {
|
||||
ctx.addIssue({
|
||||
code: "custom",
|
||||
message:
|
||||
valueResult.error.issues[0]?.message ??
|
||||
t("rulesErrorValueRequired"),
|
||||
path: ["value"]
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export function createPolicyRulesArraySchema(t: TranslateFn) {
|
||||
return z.array(createPolicyRuleSchema(t)).superRefine((rules, ctx) => {
|
||||
const seenPriorities = new Set<number>();
|
||||
rules.forEach((rule, index) => {
|
||||
if (seenPriorities.has(rule.priority)) {
|
||||
ctx.addIssue({
|
||||
code: "custom",
|
||||
message: t("rulesErrorDuplicatePriorityDescription"),
|
||||
path: [index, "priority"]
|
||||
});
|
||||
}
|
||||
seenPriorities.add(rule.priority);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export function createPolicyRulesSectionSchema(t: TranslateFn) {
|
||||
return z.object({
|
||||
applyRules: z.boolean(),
|
||||
rules: createPolicyRulesArraySchema(t)
|
||||
});
|
||||
}
|
||||
|
||||
export function createPolicySchemaWithI18n(
|
||||
t: TranslateFn,
|
||||
baseSchema: z.ZodObject<z.ZodRawShape>
|
||||
) {
|
||||
return baseSchema.extend({
|
||||
rules: createPolicyRulesArraySchema(t)
|
||||
});
|
||||
}
|
||||
|
||||
export function validatePolicyRulePriority(
|
||||
t: TranslateFn,
|
||||
value: unknown
|
||||
):
|
||||
| { success: true; data: number }
|
||||
| { success: false; toast: RuleValidationToast } {
|
||||
const result = createPolicyRulePrioritySchema(t).safeParse(value);
|
||||
if (result.success) {
|
||||
return { success: true, data: result.data };
|
||||
}
|
||||
|
||||
return {
|
||||
success: false,
|
||||
toast: {
|
||||
title: t("rulesErrorInvalidPriority"),
|
||||
description:
|
||||
result.error.issues[0]?.message ??
|
||||
t("rulesErrorInvalidPriorityDescription")
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export function validatePolicyRuleValue(
|
||||
t: TranslateFn,
|
||||
match: string,
|
||||
value: string
|
||||
):
|
||||
| { success: true; data: string }
|
||||
| { success: false; toast: RuleValidationToast } {
|
||||
const result = createPolicyRuleValueSchema(t, match).safeParse(value);
|
||||
if (result.success) {
|
||||
return { success: true, data: result.data };
|
||||
}
|
||||
|
||||
const issue = result.error.issues[0];
|
||||
const titleKey =
|
||||
match === "CIDR"
|
||||
? "rulesErrorInvalidIpAddressRange"
|
||||
: match === "IP"
|
||||
? "rulesErrorInvalidIpAddress"
|
||||
: match === "PATH"
|
||||
? "rulesErrorInvalidUrl"
|
||||
: match === "REGION"
|
||||
? "rulesErrorInvalidRegion"
|
||||
: match === "COUNTRY"
|
||||
? "rulesErrorInvalidCountry"
|
||||
: match === "ASN"
|
||||
? "rulesErrorInvalidAsn"
|
||||
: "rulesErrorValidation";
|
||||
|
||||
return {
|
||||
success: false,
|
||||
toast: {
|
||||
title: t(titleKey),
|
||||
description: issue?.message ?? t("rulesErrorValueRequired")
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export function validatePolicyRulesForSave(
|
||||
t: TranslateFn,
|
||||
rules: Array<{
|
||||
action: "ACCEPT" | "DROP" | "PASS";
|
||||
match: string;
|
||||
value: string;
|
||||
priority: number;
|
||||
enabled: boolean;
|
||||
}>,
|
||||
applyRules: boolean
|
||||
): { success: true } | { success: false; toast: RuleValidationToast } {
|
||||
if (!applyRules) {
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
const result = createPolicyRulesArraySchema(t).safeParse(rules);
|
||||
if (result.success) {
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
const issue = result.error.issues[0];
|
||||
return {
|
||||
success: false,
|
||||
toast: {
|
||||
title: t("rulesErrorValidation"),
|
||||
description: issue
|
||||
? getPolicyRuleValidationMessage(t, issue)
|
||||
: t("rulesErrorUpdateDescription")
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import z from "zod";
|
||||
|
||||
export type PolicyAuthMethodId =
|
||||
| "pincode"
|
||||
| "passcode"
|
||||
| "email"
|
||||
| "headerAuth";
|
||||
|
||||
export const setPasswordSchema = z.object({
|
||||
password: z.string().min(4).max(100)
|
||||
});
|
||||
|
||||
export const setPincodeSchema = z.object({
|
||||
pincode: z.string().length(6)
|
||||
});
|
||||
|
||||
export const setHeaderAuthSchema = z.object({
|
||||
user: z.string().min(4).max(100),
|
||||
password: z.string().min(4).max(100),
|
||||
extendedCompatibility: z.boolean()
|
||||
});
|
||||
@@ -0,0 +1,45 @@
|
||||
type SummaryParams = {
|
||||
t: (key: string, values?: Record<string, string | number>) => string;
|
||||
};
|
||||
|
||||
type SsoSummaryParams = SummaryParams & {
|
||||
idpName?: string;
|
||||
userCount: number;
|
||||
roleCount: number;
|
||||
};
|
||||
|
||||
export function getSsoSummary({
|
||||
t,
|
||||
idpName,
|
||||
userCount,
|
||||
roleCount
|
||||
}: SsoSummaryParams) {
|
||||
const idp = idpName ?? t("policyAuthSsoDefaultIdp");
|
||||
return t("policyAuthSsoSummary", {
|
||||
idp,
|
||||
users: userCount,
|
||||
roles: roleCount
|
||||
});
|
||||
}
|
||||
|
||||
export function getPasscodeSummary({ t }: SummaryParams) {
|
||||
return t("policyAuthPasscodeSummary");
|
||||
}
|
||||
|
||||
export function getPincodeSummary({ t }: SummaryParams) {
|
||||
return t("policyAuthPincodeSummary");
|
||||
}
|
||||
|
||||
export function getEmailWhitelistSummary({
|
||||
t,
|
||||
count
|
||||
}: SummaryParams & { count: number }) {
|
||||
return t("policyAuthEmailSummary", { count });
|
||||
}
|
||||
|
||||
export function getHeaderAuthSummary({
|
||||
t,
|
||||
headerName
|
||||
}: SummaryParams & { headerName: string }) {
|
||||
return headerName || t("policyAuthHeaderAuthSummary");
|
||||
}
|
||||
@@ -9,11 +9,13 @@ const PLACEHOLDER_ROW_COUNT = 5;
|
||||
type DataTableEmptyStateProps = {
|
||||
colSpan: number;
|
||||
action?: ReactNode;
|
||||
message?: string;
|
||||
};
|
||||
|
||||
export function DataTableEmptyState({
|
||||
colSpan,
|
||||
action
|
||||
action,
|
||||
message
|
||||
}: DataTableEmptyStateProps) {
|
||||
const t = useTranslations();
|
||||
return (
|
||||
@@ -32,7 +34,7 @@ export function DataTableEmptyState({
|
||||
</div>
|
||||
<div className="relative flex min-h-[11rem] w-full flex-col items-center justify-center gap-4 px-4 py-8">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("noResults")}
|
||||
{message ?? t("noResults")}
|
||||
</p>
|
||||
{action}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
type EncryptedStorageEnvelope = {
|
||||
v: 1;
|
||||
s: string;
|
||||
i: string;
|
||||
d: string;
|
||||
};
|
||||
|
||||
const PBKDF2_ITERATIONS = 120000;
|
||||
|
||||
function toArrayBuffer(bytes: Uint8Array): ArrayBuffer {
|
||||
return bytes.buffer.slice(
|
||||
bytes.byteOffset,
|
||||
bytes.byteOffset + bytes.byteLength
|
||||
) as ArrayBuffer;
|
||||
}
|
||||
|
||||
function bytesToBase64(bytes: Uint8Array): string {
|
||||
let binary = "";
|
||||
for (const byte of bytes) {
|
||||
binary += String.fromCharCode(byte);
|
||||
}
|
||||
return btoa(binary);
|
||||
}
|
||||
|
||||
function base64ToBytes(value: string): Uint8Array {
|
||||
const binary = atob(value);
|
||||
const bytes = new Uint8Array(binary.length);
|
||||
for (let i = 0; i < binary.length; i++) {
|
||||
bytes[i] = binary.charCodeAt(i);
|
||||
}
|
||||
return bytes;
|
||||
}
|
||||
|
||||
async function deriveKey(authToken: string, salt: ArrayBuffer) {
|
||||
const subtle = window.crypto?.subtle;
|
||||
if (!subtle) {
|
||||
throw new Error("Web Crypto is unavailable");
|
||||
}
|
||||
|
||||
const tokenKey = await subtle.importKey(
|
||||
"raw",
|
||||
toArrayBuffer(new TextEncoder().encode(authToken)),
|
||||
"PBKDF2",
|
||||
false,
|
||||
["deriveKey"]
|
||||
);
|
||||
|
||||
return subtle.deriveKey(
|
||||
{
|
||||
name: "PBKDF2",
|
||||
salt,
|
||||
iterations: PBKDF2_ITERATIONS,
|
||||
hash: "SHA-256"
|
||||
},
|
||||
tokenKey,
|
||||
{ name: "AES-GCM", length: 256 },
|
||||
false,
|
||||
["encrypt", "decrypt"]
|
||||
);
|
||||
}
|
||||
|
||||
export async function saveEncryptedLocalStorage<T>(
|
||||
storageKey: string,
|
||||
value: T,
|
||||
authToken: string | null | undefined
|
||||
) {
|
||||
if (typeof window === "undefined") return;
|
||||
if (!authToken) {
|
||||
window.localStorage.removeItem(storageKey);
|
||||
return;
|
||||
}
|
||||
|
||||
const salt = window.crypto.getRandomValues(new Uint8Array(16));
|
||||
const iv = window.crypto.getRandomValues(new Uint8Array(12));
|
||||
const key = await deriveKey(authToken, toArrayBuffer(salt));
|
||||
const plaintext = new TextEncoder().encode(JSON.stringify(value));
|
||||
const encrypted = await window.crypto.subtle.encrypt(
|
||||
{ name: "AES-GCM", iv: toArrayBuffer(iv) },
|
||||
key,
|
||||
toArrayBuffer(plaintext)
|
||||
);
|
||||
|
||||
const payload: EncryptedStorageEnvelope = {
|
||||
v: 1,
|
||||
s: bytesToBase64(salt),
|
||||
i: bytesToBase64(iv),
|
||||
d: bytesToBase64(new Uint8Array(encrypted))
|
||||
};
|
||||
|
||||
window.localStorage.setItem(storageKey, JSON.stringify(payload));
|
||||
}
|
||||
|
||||
export async function loadEncryptedLocalStorage<T>(
|
||||
storageKey: string,
|
||||
authToken: string | null | undefined
|
||||
): Promise<T | null> {
|
||||
if (typeof window === "undefined") return null;
|
||||
if (!authToken) return null;
|
||||
|
||||
const raw = window.localStorage.getItem(storageKey);
|
||||
if (!raw) return null;
|
||||
|
||||
try {
|
||||
const payload = JSON.parse(raw) as EncryptedStorageEnvelope;
|
||||
if (payload.v !== 1 || !payload.s || !payload.i || !payload.d) {
|
||||
throw new Error("Invalid encrypted payload");
|
||||
}
|
||||
|
||||
const salt = base64ToBytes(payload.s);
|
||||
const iv = base64ToBytes(payload.i);
|
||||
const data = base64ToBytes(payload.d);
|
||||
const key = await deriveKey(authToken, toArrayBuffer(salt));
|
||||
const decrypted = await window.crypto.subtle.decrypt(
|
||||
{ name: "AES-GCM", iv: toArrayBuffer(iv) },
|
||||
key,
|
||||
toArrayBuffer(data)
|
||||
);
|
||||
const json = new TextDecoder().decode(decrypted);
|
||||
return JSON.parse(json) as T;
|
||||
} catch {
|
||||
window.localStorage.removeItem(storageKey);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user