Compare commits

...

12 Commits

Author SHA1 Message Date
Owen f213e81cde Reconnect newts when a exit node comes back online 2026-06-08 11:12:14 -07:00
Owen 7b6b1f0a4c Add exit node if the sites dont have one 2026-06-08 11:11:46 -07:00
Owen 8daf7c2872 Rename and add browser target update 2026-06-07 12:07:08 -07:00
Owen c394490473 Update browser targets 2026-06-07 10:43:16 -07:00
Owen 3b6b78b3e1 Update traefik config 2026-06-06 16:14:20 -07:00
Owen 8658198a93 Remove unnessicary auth token 2026-06-06 13:47:23 -07:00
Owen 4b770d1385 Fix issues 2026-06-06 13:34:24 -07:00
miloschwartz cd4d7372a0 Merge branch 'resource-policies-restyle' into dev 2026-06-06 12:27:59 -07:00
Owen dc8243cb51 Fix form rendering issue 2026-06-06 12:27:14 -07:00
miloschwartz 7b1f8d98f3 make the rule rows draggable 2026-06-06 12:27:11 -07:00
miloschwartz dd8bcbb3e3 first pass restyle of auth methods and rules 2026-06-05 21:04:03 -07:00
Owen d1af7a153f Enforece some more things on the types 2026-06-05 16:57:53 -07:00
51 changed files with 4420 additions and 7327 deletions
+57 -12
View File
@@ -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",
+34 -14
View File
@@ -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
);
}
}
}
}
@@ -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
})
+70 -4
View File
@@ -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;
+15 -8
View File
@@ -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";
+3 -1
View File
@@ -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
}
+73 -2
View File
@@ -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<{
+16 -7
View File
@@ -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
);
}
}
}
+17 -8
View File
@@ -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
);
}
}
}
+16 -7
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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);
});
+50 -3
View File
@@ -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
+23 -7
View File
@@ -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");
}
+4 -2
View File
@@ -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>
+124
View File
@@ -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;
}
}