Compare commits

..

39 Commits

Author SHA1 Message Date
miloschwartz 407ba567a0 various visual changes 2026-06-08 22:07:53 -07:00
Owen f28571629f Make sure the pamMode is push for host resources 2026-06-08 21:54:06 -07:00
Owen 5a575c916b Handle backward compatability 2026-06-08 21:11:57 -07:00
Owen 9a7e534b10 Ssh session closed card 2026-06-08 17:44:48 -07:00
Owen 42974d1739 Make sure the skip to idp is pulled 2026-06-08 17:41:59 -07:00
Owen 780e8babe4 Perfect toolbar 2026-06-08 17:39:07 -07:00
Owen 2c7b8006cf Add gray bar 2026-06-08 16:07:36 -07:00
Owen 35066c1388 Add pulldown toolbar 2026-06-08 16:00:38 -07:00
miloschwartz 135a5d38af make form grids more consistent 2026-06-08 16:00:30 -07:00
Owen 1b7c1ffa70 Set the target port from the resource 2026-06-08 15:39:26 -07:00
Owen 641f643d2d Prefil the port with the best guess port 2026-06-08 15:39:26 -07:00
Owen b4ecfceb5e Show more information about error 2026-06-08 15:39:25 -07:00
Owen 08a84d4bb1 Add some connection feedback 2026-06-08 15:39:25 -07:00
Owen 4dbad7ab24 Close the tab when exiting 2026-06-08 15:39:25 -07:00
miloschwartz 859c0c9477 add description text to share link path input 2026-06-08 15:33:12 -07:00
miloschwartz d294bf8534 support uploading csv or txt to sudo commands and groups 2026-06-08 15:30:03 -07:00
miloschwartz 3c8fea382f improve unix group and sudo commands inputs 2026-06-08 14:36:10 -07:00
Owen b81bfcfcee Fix type error 2026-06-08 12:21:43 -07:00
Milo Schwartz 56c415ca05 Merge pull request #3219 from Fredkiss3/refactor/standardize-clear-buttons
feat: make clear filter buttons more consistent accross tables
2026-06-08 12:07:55 -07:00
Owen 74fdcceace Reconnect newts when a exit node comes back online 2026-06-08 12:02:12 -07:00
Owen 7dec8ba998 Add exit node if the sites dont have one 2026-06-08 12:02:12 -07:00
miloschwartz c9dc6affe7 Merge branch 'dev' into resource-policies-restyle 2026-06-08 12:00:08 -07:00
miloschwartz 8fe45ba78c prevent duplicate label names 2026-06-08 11:59:15 -07:00
Fred KISSIE 934886caea Merge branch 'dev' into refactor/standardize-clear-buttons 2026-06-08 20:42:11 +02:00
miloschwartz fae258b145 add labels to user-resources query 2026-06-08 10:55:24 -07:00
miloschwartz 9f224f655f Merge branch 'resource-policies-restyle' into dev 2026-06-08 10:38:13 -07:00
miloschwartz aea7df7dc2 rename share links 2026-06-08 10:37:46 -07:00
miloschwartz 3b675f7de1 policies and policy on resource structure in a good place 2026-06-07 12:19:33 -07:00
miloschwartz aa47f522ef move toggle on general page 2026-06-06 15:34:34 -07:00
Fred KISSIE a994f8ff07 💄 Column filter buttons for log tables 2026-06-05 21:47:08 +02:00
Fred KISSIE 95ce91d94b Merge branch 'dev' into refactor/standardize-clear-buttons 2026-06-05 20:21:34 +02:00
Fred KISSIE a4548fd874 💄 Break all text 2026-06-05 19:59:28 +02:00
Fred KISSIE eb03fb7060 ♻️ standardize http request log data-tables 2026-06-05 19:28:30 +02:00
Fred KISSIE 33fdc9a94f 🚧 wip: column filter button 2026-06-04 21:04:15 +02:00
Fred KISSIE c86026c941 ♻️ refactor 2026-06-04 20:09:07 +02:00
Fred KISSIE db014e3446 ♻️ use the same clear filter text for clearing filters in the column filter buttons 2026-06-04 20:08:27 +02:00
Fred KISSIE feb8045643 ♻️ refactor 2026-06-04 19:54:43 +02:00
Fred KISSIE d485a09318 ♻️ use site label filter column 2026-06-04 19:45:54 +02:00
Fred KISSIE 9cff5f66b1 🚧 wip: site label column filter standardized 2026-06-04 19:40:24 +02:00
87 changed files with 4136 additions and 2659 deletions
+5
View File
@@ -0,0 +1,5 @@
---
alwaysApply: true
---
When creating UI for popup dialogs or modals, use the Credenza componennt. This component is mobile responsive and works on desktop and wraps the dialog component and sheet into one.
+48 -13
View File
@@ -150,16 +150,16 @@
"siteCredentialsSaveDescription": "You will only be able to see this once. Make sure to copy it to a secure place.",
"siteInfo": "Site Information",
"status": "Status",
"shareTitle": "Manage Share Links",
"shareTitle": "Manage Shareable Links",
"shareDescription": "Create shareable links to grant temporary or permanent access to proxy resources",
"shareSearch": "Search share links...",
"shareCreate": "Create Share Link",
"shareSearch": "Search shareable links...",
"shareCreate": "Create Shareable Link",
"shareErrorDelete": "Failed to delete link",
"shareErrorDeleteMessage": "An error occurred deleting link",
"shareDeleted": "Link deleted",
"shareDeletedDescription": "The link has been deleted",
"shareDelete": "Delete Share Link",
"shareDeleteConfirm": "Confirm Delete Share Link",
"shareDelete": "Delete Shareable Link",
"shareDeleteConfirm": "Confirm Delete Shareable Link",
"shareQuestionRemove": "Are you sure you want to delete this share link?",
"shareMessageRemove": "Once deleted, the link will no longer work and anyone using it will lose access to the resource.",
"shareTokenDescription": "The access token can be passed in two ways: as a query parameter or in the request headers. These must be passed from the client on every request for authenticated access.",
@@ -179,6 +179,7 @@
"shareCreateDescription": "Anyone with this link can access the resource",
"shareTitleOptional": "Title (optional)",
"sharePathOptional": "Path (optional)",
"sharePathDescription": "The link will redirect users to this path after authentication.",
"expireIn": "Expire In",
"neverExpire": "Never expire",
"shareExpireDescription": "Expiration time is how long the link will be usable and provide access to the resource. After this time, the link will no longer work, and users who used this link will lose access to the resource.",
@@ -211,6 +212,8 @@
"resourcesSearch": "Search resources...",
"resourceAdd": "Add Resource",
"resourceErrorDelte": "Error deleting resource",
"resourcePoliciesBannerTitle": "Re-use Authentication and Access Rules",
"resourcePoliciesBannerDescription": "Shared resource policies let you define authentication methods and access rules once, then attach them to multiple public resources. When you update a policy, every linked resource inherits the change automatically.",
"resourcePoliciesTitle": "Manage Public Resource Policies",
"resourcePoliciesAttachedResourcesColumnTitle": "Resources",
"resourcePoliciesAttachedResources": "{count} resource(s)",
@@ -290,6 +293,8 @@
"labelDelete": "Delete Label",
"labelAdd": "Add Label",
"labelCreateSuccessMessage": "Label Created Successfully",
"labelDuplicateError": "Duplicate Label",
"labelDuplicateErrorDescription": "A label with this name already exists.",
"labelEditSuccessMessage": "Label Modified Successfully",
"labelNameField": "Label Name",
"labelColorField": "Label Color",
@@ -774,6 +779,7 @@
"rulesErrorDuplicatePriorityDescription": "Each rule must have a unique priority number.",
"rulesErrorValidation": "Invalid rules",
"rulesErrorValidationRuleDescription": "Rule {ruleNumber}: {message}",
"rulesErrorInvalidMatchTypeDescription": "Select a valid match type (path, IP, CIDR, country, region, or ASN).",
"rulesErrorValueRequired": "Enter a value for this rule.",
"rulesErrorInvalidCountry": "Invalid country",
"rulesErrorInvalidCountryDescription": "Select a valid country.",
@@ -843,6 +849,10 @@
"policyAuthHeaderAuthSummary": "Header configured",
"policyAuthHeaderName": "Header name",
"policyAuthHeaderValue": "Expected value",
"policyAuthSetPasscode": "Set Passcode",
"policyAuthSetPincode": "Set PIN Code",
"policyAuthSetEmailWhitelist": "Set Email Whitelist",
"policyAuthSetHeaderAuth": "Set Basic Header Auth",
"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.",
@@ -872,9 +882,9 @@
"resourcesErrorUpdateDescription": "An error occurred while updating the resource",
"access": "Access",
"accessControl": "Access Control",
"shareLink": "{resource} Share Link",
"shareLink": "{resource} Shareable Link",
"resourceSelect": "Select resource",
"shareLinks": "Share Links",
"shareLinks": "Shareable Links",
"share": "Shareable Links",
"shareDescription2": "Create shareable links to resources. Links provide temporary or unlimited access to your resource. You can configure the expiration duration of the link when you create one.",
"shareEasyCreate": "Easy to create and share",
@@ -964,10 +974,16 @@
"resourceRoleDescription": "Admins can always access this resource.",
"resourcePolicySelectTitle": "Resource Access Policy",
"resourcePolicySelectDescription": "Select the resource policy type for authentication",
"resourcePolicyTypeLabel": "Policy type",
"resourcePolicyLabel": "Resource policy",
"resourcePolicyInline": "Inline Resource Policy",
"resourcePolicyInlineDescription": "Access Policy scoped to only this resource",
"resourcePolicyShared": "Shared Resource Policy",
"resourcePolicySharedDescription": "This resource uses a shared policy. Policy-level settings (auth methods, email whitelist) are locked. You can add resource-specific rules, roles, and users below.",
"resourcePolicySharedDescription": "This resource uses a shared policy.",
"sharedPolicy": "Shared Policy",
"sharedPolicyNoneDescription": "This resource has its own policy.",
"resourceSharedPolicyAuthenticationNotice": "This resource is using a shared policy. Some authentication settings can be edited on this resource. To change the underlying policy, you must edit to <policyLink>{policyName}</policyLink>.",
"resourceSharedPolicyRulesNotice": "This resource is using a shared policy. Some access rules can be edited on this resource. To change the underlying policy, you must edit <policyLink>{policyName}</policyLink>.",
"resourceUsersRoles": "Access Controls",
"resourceUsersRolesDescription": "Configure which users and roles can visit this resource",
"resourceUsersRolesSubmit": "Save Access Controls",
@@ -1275,6 +1291,7 @@
"accessLabelFilterCount": "{count, plural, one {# label} other {# labels}}",
"labelOverflowCount": "+{count, plural, one {# label} other {# labels}}",
"accessLabelFilterClear": "Clear label filters",
"accessFilterClear": "Clear filters",
"selectColor": "Select color",
"createNewLabel": "Create new org label \"{label}\"",
"inviteInvalidDescription": "The invite link is invalid.",
@@ -1511,7 +1528,7 @@
"sidebarResources": "Resources",
"sidebarProxyResources": "Public",
"sidebarClientResources": "Private",
"sidebarPolicies": "Policies",
"sidebarPolicies": "Shared Policies",
"sidebarResourcePolicies": "Public Resources",
"sidebarAccessControl": "Access Control",
"sidebarLogsAndAnalytics": "Logs & Analytics",
@@ -1520,7 +1537,7 @@
"sidebarAdmin": "Admin",
"sidebarInvitations": "Invitations",
"sidebarRoles": "Roles",
"sidebarShareableLinks": "Share Links",
"sidebarShareableLinks": "Shareable Links",
"sidebarApiKeys": "API Keys",
"sidebarProvisioning": "Provisioning",
"sidebarSettings": "Settings",
@@ -2144,10 +2161,25 @@
"sshSudoModeCommandsDescription": "User can run only the specified commands with sudo.",
"sshSudo": "Allow sudo",
"sshSudoCommands": "Sudo Commands",
"sshSudoCommandsDescription": "Comma separated list of commands the user is allowed to run with sudo. Absolute paths must be used.",
"sshSudoCommandsDescription": "List of commands the user is allowed to run with sudo, separated by commas, spaces, or new lines. Absolute paths must be used.",
"sshCreateHomeDir": "Create Home Directory",
"sshUnixGroups": "Unix Groups",
"sshUnixGroupsDescription": "Comma separated Unix groups to add the user to on the target host.",
"sshUnixGroupsDescription": "Unix groups to add the user to on the target host, separated by commas, spaces, or new lines.",
"roleTextFieldPlaceholder": "Enter values, or drop a .txt or .csv file",
"roleTextImportTitle": "Import from File",
"roleTextImportDescription": "Importing {fileName} into {fieldLabel}.",
"roleTextImportSkipHeader": "Skip First Row (Header)",
"roleTextImportOverride": "Replace Existing",
"roleTextImportAppend": "Append to Existing",
"roleTextImportMode": "Import Mode",
"roleTextImportPreview": "Preview",
"roleTextImportItemCount": "{count, plural, =0 {No items to import} one {1 item to import} other {# items to import}}",
"roleTextImportTotalCount": "{existing} existing + {imported} imported = {total} total",
"roleTextImportConfirm": "Import",
"roleTextImportInvalidFile": "Unsupported file type",
"roleTextImportInvalidFileDescription": "Only .txt and .csv files are supported.",
"roleTextImportEmpty": "No items found in file",
"roleTextImportEmptyDescription": "The file does not contain any importable items.",
"retryAttempts": "Retry Attempts",
"expectedResponseCodes": "Expected Response Codes",
"expectedResponseCodesDescription": "HTTP status code that indicates healthy status. If left blank, 200-300 is considered healthy.",
@@ -3131,6 +3163,7 @@
"maintenanceModeType": "Maintenance Mode Type",
"showMaintenancePage": "Show a maintenance page to visitors",
"enableMaintenanceMode": "Enable Maintenance Mode",
"enableMaintenanceModeDescription": "When enabled, visitors will see a maintenance page instead of your resource.",
"automatic": "Automatic",
"automaticModeDescription": " Show maintenance page only when all backend targets are down or unhealthy. Your resource continues working normally as long as at least one target is healthy.",
"forced": "Forced",
@@ -3548,5 +3581,7 @@
"rdpFilesReadyToPaste": "Files ready to paste",
"rdpFilesReadyToPasteDescription": "{count} file(s) copied to remote clipboard — press Ctrl+V on the remote desktop to paste.",
"rdpUploadFailed": "Upload failed",
"rdpUnicodeKeyboardMode": "Unicode keyboard mode"
"rdpUnicodeKeyboardMode": "Unicode keyboard mode",
"sessionToolbarShow": "Show toolbar",
"sessionToolbarHide": "Hide toolbar"
}
+41
View File
@@ -1,5 +1,7 @@
import z from "zod";
import ipaddr from "ipaddr.js";
import { COUNTRIES } from "@server/db/countries";
import { isValidRegionId } from "@server/db/regions";
export function isValidCIDR(cidr: string): boolean {
return (
@@ -67,6 +69,45 @@ export function isValidUrlGlobPattern(pattern: string): boolean {
return true;
}
export const RESOURCE_RULE_MATCH_TYPES = [
"CIDR",
"IP",
"PATH",
"COUNTRY",
"ASN",
"REGION"
] as const;
export type ResourceRuleMatchType = (typeof RESOURCE_RULE_MATCH_TYPES)[number];
export function getResourceRuleValueValidationError(
match: ResourceRuleMatchType,
value: string
): string | null {
switch (match) {
case "CIDR":
return isValidCIDR(value) ? null : "Invalid CIDR provided";
case "IP":
return isValidIP(value) ? null : "Invalid IP provided";
case "PATH":
return isValidUrlGlobPattern(value)
? null
: "Invalid URL glob pattern provided";
case "REGION":
return isValidRegionId(value) ? null : "Invalid region ID provided";
case "COUNTRY":
return COUNTRIES.some((country) => country.code === value)
? null
: "Invalid country code provided";
case "ASN":
return /^AS\d+$/i.test(value.trim())
? null
: "Invalid ASN provided";
default:
return "Invalid rule match type provided";
}
}
export function isUrlValid(url: string | undefined) {
if (!url) return true; // the link is optional in the schema so if it's empty it's valid
var pattern = new RegExp(
+29 -3
View File
@@ -79,7 +79,10 @@ import logger from "@server/logger";
import { decrypt } from "@server/lib/crypto";
import config from "@server/lib/config";
import { exchangeSession } from "@server/routers/badger";
import { validateResourceSessionToken } from "@server/auth/sessions/resource";
import {
ResourceSessionValidationResult,
validateResourceSessionToken
} from "@server/auth/sessions/resource";
import { checkExitNodeOrg, resolveExitNodes } from "#private/lib/exitNodes";
import { maxmindLookup } from "@server/db/maxmind";
import { verifyResourceAccessToken } from "@server/auth/verifyResourceAccessToken";
@@ -1754,11 +1757,34 @@ hybridRouter.post(
resourceId
);
// this is for backward compatibility with nodes that did not have the policy id checking
const modifiedResult: ResourceSessionValidationResult = {
...result,
resourceSession: result.resourceSession
? {
...result.resourceSession,
// Prefer policy IDs, but keep legacy IDs populated for older nodes.
pincodeId:
result.resourceSession.policyPincodeId ??
result.resourceSession.pincodeId ??
null,
passwordId:
result.resourceSession.policyPasswordId ??
result.resourceSession.passwordId ??
null,
whitelistId:
result.resourceSession.policyWhitelistId ??
result.resourceSession.whitelistId ??
null
}
: null
};
return response(res, {
data: result,
data: modifiedResult,
success: true,
error: false,
message: result.resourceSession
message: modifiedResult.resourceSession
? "Resource session token is valid"
: "Resource session token is invalid or expired",
status: HttpCode.OK
@@ -22,7 +22,7 @@ import response from "@server/lib/response";
import logger from "@server/logger";
import type { CreateOrEditLabelResponse } from "@server/routers/labels/types";
import HttpCode from "@server/types/HttpCode";
import { and, eq } from "drizzle-orm";
import { and, eq, sql } from "drizzle-orm";
import { NextFunction, Request, Response } from "express";
import createHttpError from "http-errors";
import { z } from "zod";
@@ -107,6 +107,26 @@ export async function createOrgLabel(
}
}
const [existingLabel] = await db
.select({ labelId: labels.labelId })
.from(labels)
.where(
and(
eq(labels.orgId, orgId),
sql`LOWER(${labels.name}) = ${name.toLowerCase()}`
)
)
.limit(1);
if (existingLabel) {
return next(
createHttpError(
HttpCode.CONFLICT,
"A label with this name already exists"
)
);
}
const label = await db.transaction(async (tx) => {
const [label] = await tx
.insert(labels)
@@ -16,7 +16,7 @@ import response from "@server/lib/response";
import logger from "@server/logger";
import type { CreateOrEditLabelResponse } from "@server/routers/labels/types";
import HttpCode from "@server/types/HttpCode";
import { and, eq } from "drizzle-orm";
import { and, eq, ne, sql } from "drizzle-orm";
import { NextFunction, Request, Response } from "express";
import createHttpError from "http-errors";
import { z } from "zod";
@@ -74,6 +74,29 @@ export async function updateOrgLabel(
const { name, color } = parsedBody.data;
if (name && name.toLowerCase() !== existing.name.toLowerCase()) {
const [duplicateLabel] = await db
.select({ labelId: labels.labelId })
.from(labels)
.where(
and(
eq(labels.orgId, orgId),
ne(labels.labelId, labelId),
sql`LOWER(${labels.name}) = ${name.toLowerCase()}`
)
)
.limit(1);
if (duplicateLabel) {
return next(
createHttpError(
HttpCode.CONFLICT,
"A label with this name already exists"
)
);
}
}
const [label] = await db
.update(labels)
.set({
@@ -33,9 +33,8 @@ import {
import { getUniqueResourcePolicyName } from "@server/db/names";
import response from "@server/lib/response";
import {
isValidCIDR,
isValidIP,
isValidUrlGlobPattern
getResourceRuleValueValidationError,
RESOURCE_RULE_MATCH_TYPES
} from "@server/lib/validators";
import logger from "@server/logger";
import { OpenAPITags, registry } from "@server/openApi";
@@ -56,9 +55,9 @@ const ruleSchema = z.strictObject({
enum: ["ACCEPT", "DROP", "PASS"],
description: "rule action"
}),
match: z.enum(["CIDR", "IP", "PATH"]).openapi({
match: z.enum(RESOURCE_RULE_MATCH_TYPES).openapi({
type: "string",
enum: ["CIDR", "IP", "PATH"],
enum: [...RESOURCE_RULE_MATCH_TYPES],
description: "rule match"
}),
value: z.string().min(1),
@@ -261,26 +260,13 @@ export async function createResourcePolicy(
const niceId = await getUniqueResourcePolicyName(orgId);
for (const rule of rules) {
if (rule.match === "CIDR" && !isValidCIDR(rule.value)) {
const validationError = getResourceRuleValueValidationError(
rule.match,
rule.value
);
if (validationError) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
"Invalid CIDR provided"
)
);
} else if (rule.match === "IP" && !isValidIP(rule.value)) {
return next(
createHttpError(HttpCode.BAD_REQUEST, "Invalid IP provided")
);
} else if (
rule.match === "PATH" &&
!isValidUrlGlobPattern(rule.value)
) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
"Invalid URL glob pattern provided"
)
createHttpError(HttpCode.BAD_REQUEST, validationError)
);
}
}
+6 -4
View File
@@ -20,7 +20,8 @@ import {
ResourcePolicyPincode,
ResourcePolicyPassword,
ResourcePolicyHeaderAuth,
ResourceRule
ResourceRule,
ResourceSession
} from "@server/db";
import config from "@server/lib/config";
import { isIpInCidr, stripPortFromHost } from "@server/lib/ip";
@@ -536,7 +537,8 @@ export async function verifyResourceSession(
if (resourceSessionToken) {
const sessionCacheKey = `session:${resourceSessionToken}`;
let resourceSession: any = localCache.get(sessionCacheKey);
let resourceSession: ResourceSession | null | undefined =
localCache.get(sessionCacheKey);
if (!resourceSession) {
const result = await validateResourceSessionToken(
@@ -671,7 +673,7 @@ export async function verifyResourceSession(
orgId: resource.orgId,
location: ipCC,
apiKey: {
name: resourceSession.accessTokenTitle,
name: null,
apiKeyId: resourceSession.accessTokenId
}
},
@@ -717,7 +719,7 @@ export async function verifyResourceSession(
location: ipCC,
user: {
username: allowedUserData.username,
userId: resourceSession.userId
userId: allowedUserData.userId
}
},
parsedBody.data
+7
View File
@@ -666,6 +666,13 @@ authenticated.get(
resource.getResourcePolicies
);
authenticated.get(
"/resource-policy/:resourcePolicyId",
verifyResourcePolicyAccess,
verifyUserHasAction(ActionsEnum.getResourcePolicy),
policy.getResourcePolicy
);
authenticated.put(
"/resource-policy/:resourcePolicyId",
verifyResourcePolicyAccess,
+10 -24
View File
@@ -8,9 +8,8 @@ import createHttpError from "http-errors";
import logger from "@server/logger";
import { fromError } from "zod-validation-error";
import {
isValidCIDR,
isValidIP,
isValidUrlGlobPattern
getResourceRuleValueValidationError,
RESOURCE_RULE_MATCH_TYPES
} from "@server/lib/validators";
import { OpenAPITags, registry } from "@server/openApi";
@@ -20,9 +19,9 @@ const ruleSchema = z.strictObject({
enum: ["ACCEPT", "DROP", "PASS"],
description: "rule action"
}),
match: z.enum(["CIDR", "IP", "PATH"]).openapi({
match: z.enum(RESOURCE_RULE_MATCH_TYPES).openapi({
type: "string",
enum: ["CIDR", "IP", "PATH"],
enum: [...RESOURCE_RULE_MATCH_TYPES],
description: "rule match"
}),
value: z.string().min(1),
@@ -105,26 +104,13 @@ export async function setResourcePolicyRules(
}
for (const rule of rules) {
if (rule.match === "CIDR" && !isValidCIDR(rule.value)) {
const validationError = getResourceRuleValueValidationError(
rule.match,
rule.value
);
if (validationError) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
"Invalid CIDR provided"
)
);
} else if (rule.match === "IP" && !isValidIP(rule.value)) {
return next(
createHttpError(HttpCode.BAD_REQUEST, "Invalid IP provided")
);
} else if (
rule.match === "PATH" &&
!isValidUrlGlobPattern(rule.value)
) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
"Invalid URL glob pattern provided"
)
createHttpError(HttpCode.BAD_REQUEST, validationError)
);
}
}
@@ -225,7 +225,7 @@ export async function getResourceAuthInfo(
wildcard: resource.wildcard ?? false,
fullDomain: resource.fullDomain,
whitelist: effectivePolicy?.emailWhitelistEnabled ?? false,
skipToIdpId: resource.skipToIdpId,
skipToIdpId: effectivePolicy?.idpId ?? resource.skipToIdpId,
orgId: resource.orgId,
postAuthPath: resource.postAuthPath ?? null
},
+85 -5
View File
@@ -1,6 +1,6 @@
import { Request, Response, NextFunction } from "express";
import { db, DB_TYPE } from "@server/db";
import { and, eq, or, inArray, sql } from "drizzle-orm";
import { db, DB_TYPE, type Label } from "@server/db";
import { and, asc, eq, or, inArray, sql } from "drizzle-orm";
import {
resources,
userResources,
@@ -20,12 +20,17 @@ import {
userSiteResources,
roleSiteResources,
siteNetworks,
sites
sites,
labels,
resourceLabels,
siteResourceLabels
} from "@server/db";
import createHttpError from "http-errors";
import HttpCode from "@server/types/HttpCode";
import { response } from "@server/lib/response";
import { getFirstString } from "@server/lib/requestParams";
import { isLicensedOrSubscribed } from "#dynamic/lib/isLicencedOrSubscribed";
import { tierMatrix } from "@server/lib/billing/tierMatrix";
export async function getUserResources(
req: Request,
@@ -353,6 +358,73 @@ export async function getUserResources(
});
}
const resourceIdList = resourcesData.map((r) => r.resourceId);
const siteResourceIdList = siteResourcesData.map(
(r) => r.siteResourceId
);
const isLabelFeatureEnabled = await isLicensedOrSubscribed(
orgId,
tierMatrix.labels
);
let labelsForResources: Array<{
labelId: number;
name: string;
color: string;
resourceId: number;
}> = [];
let labelsForSiteResources: Array<{
labelId: number;
name: string;
color: string;
siteResourceId: number;
}> = [];
if (isLabelFeatureEnabled) {
[labelsForResources, labelsForSiteResources] = await Promise.all([
resourceIdList.length === 0
? Promise.resolve([])
: db
.select({
labelId: labels.labelId,
name: labels.name,
color: labels.color,
resourceId: resourceLabels.resourceId
})
.from(labels)
.innerJoin(
resourceLabels,
eq(resourceLabels.labelId, labels.labelId)
)
.where(
inArray(resourceLabels.resourceId, resourceIdList)
)
.orderBy(asc(resourceLabels.resourceLabelId)),
siteResourceIdList.length === 0
? Promise.resolve([])
: db
.select({
labelId: labels.labelId,
name: labels.name,
color: labels.color,
siteResourceId: siteResourceLabels.siteResourceId
})
.from(labels)
.innerJoin(
siteResourceLabels,
eq(siteResourceLabels.labelId, labels.labelId)
)
.where(
inArray(
siteResourceLabels.siteResourceId,
siteResourceIdList
)
)
.orderBy(asc(siteResourceLabels.siteResourceLabelId))
]);
}
// Check for password, pincode, and whitelist protection for each resource
const resourcesWithAuth = await Promise.all(
resourcesData.map(async (resource) => {
@@ -453,7 +525,10 @@ export async function getUserResources(
sso: resource.sso,
password: hasPassword,
pincode: hasPincode,
whitelist: hasWhitelist
whitelist: hasWhitelist,
labels: labelsForResources.filter(
(l) => l.resourceId === resource.resourceId
)
};
})
);
@@ -479,7 +554,10 @@ export async function getUserResources(
siteNiceIds: siteResource.siteNiceIds,
siteAddresses: siteResource.siteAddresses,
siteOnlines: siteResource.siteOnlines,
type: "site" as const
type: "site" as const,
labels: labelsForSiteResources.filter(
(l) => l.siteResourceId === siteResource.siteResourceId
)
};
});
@@ -514,6 +592,7 @@ export type GetUserResourcesResponse = {
enabled: boolean;
protected: boolean;
mode: string;
labels?: Array<Pick<Label, "color" | "labelId" | "name">>;
}>;
siteResources: Array<{
siteResourceId: number;
@@ -535,6 +614,7 @@ export type GetUserResourcesResponse = {
siteAddresses: (string | null)[];
siteOnlines: boolean[];
type: "site";
labels?: Array<Pick<Label, "color" | "labelId" | "name">>;
}>;
};
};
+11
View File
@@ -197,6 +197,11 @@ export default async function migration() {
await db.execute(
sql`ALTER TABLE "siteResources" ADD COLUMN "pamMode" varchar(32) DEFAULT 'passthrough';`
);
await db.execute(sql`
UPDATE "siteResources"
SET "pamMode" = 'push'
WHERE LOWER(COALESCE("mode", '')) = 'host';
`);
await db.execute(
sql`ALTER TABLE "sites" ADD COLUMN "autoUpdateEnabled" boolean DEFAULT false NOT NULL;`
);
@@ -274,6 +279,12 @@ export default async function migration() {
await db.execute(
sql`ALTER TABLE "targets" ADD "mode" text DEFAULT 'http' NOT NULL;`
);
await db.execute(sql`
UPDATE "targets"
SET "mode" = "resources"."mode"
FROM "resources"
WHERE "resources"."resourceId" = "targets"."resourceId";
`);
await db.execute(sql`ALTER TABLE "targets" ADD "authToken" text;`);
await db.execute(sql`COMMIT`);
+16
View File
@@ -247,6 +247,13 @@ export default async function migration() {
ALTER TABLE 'siteResources' ADD COLUMN 'pamMode' text DEFAULT 'passthrough';
`
).run();
db.prepare(
`
UPDATE 'siteResources'
SET "pamMode" = 'push'
WHERE LOWER(COALESCE("mode", '')) = 'host';
`
).run();
db.prepare(
`
@@ -339,6 +346,15 @@ export default async function migration() {
ALTER TABLE 'targets' ADD 'mode' text DEFAULT 'http' NOT NULL;
`
).run();
db.prepare(
`
UPDATE 'targets'
SET 'mode' = (
SELECT 'mode' FROM 'resources'
WHERE 'resources'.'resourceId' = 'targets'.'resourceId'
);
`
).run();
db.prepare(
`
ALTER TABLE 'targets' ADD 'authToken' text;
@@ -10,6 +10,8 @@ import { formatAxiosError } from "@app/lib/api";
import { AxiosResponse } from "axios";
import {
SettingsContainer,
SettingsFormCell,
SettingsFormGrid,
SettingsSection,
SettingsSectionHeader,
SettingsSectionTitle,
@@ -1324,42 +1326,44 @@ export default function BillingPage() {
</SettingsSectionDescription>
</SettingsSectionHeader>
<SettingsSectionBody>
<div className="w-full md:w-1/2">
<div className="flex flex-col md:flex-row items-start md:items-center justify-between gap-4 border rounded-lg p-4">
<div>
<div className="text-sm text-muted-foreground mb-1">
{t("billingCurrentKeys") ||
"Current Keys"}
</div>
<div className="flex items-baseline gap-2">
<span className="text-3xl font-semibold">
{getLicenseKeyCount()}
</span>
<span className="text-lg">
{getLicenseKeyCount() === 1
? "key"
: "keys"}
</span>
<SettingsFormGrid>
<SettingsFormCell span="half">
<div className="flex flex-col md:flex-row items-start md:items-center justify-between gap-4 border rounded-lg p-4">
<div>
<div className="text-sm text-muted-foreground mb-1">
{t("billingCurrentKeys") ||
"Current Keys"}
</div>
<div className="flex items-baseline gap-2">
<span className="text-3xl font-semibold">
{getLicenseKeyCount()}
</span>
<span className="text-lg">
{getLicenseKeyCount() === 1
? "key"
: "keys"}
</span>
</div>
</div>
<Button
variant="outline"
onClick={handleModifySubscription}
disabled={isLoading}
loading={isLoading}
>
<CreditCard className="mr-2 h-4 w-4" />
{t("billingModifyCurrentPlan") ||
"Modify Current Plan"}
</Button>
<p className="text-sm text-muted-foreground mt-2">
{t(
"billingManageLicenseSubscriptionDescription"
) ||
"Manage your subscription for paid self-hosted license keys and download invoices."}
</p>
</div>
<Button
variant="outline"
onClick={handleModifySubscription}
disabled={isLoading}
loading={isLoading}
>
<CreditCard className="mr-2 h-4 w-4" />
{t("billingModifyCurrentPlan") ||
"Modify Current Plan"}
</Button>
<p className="text-sm text-muted-foreground mt-2">
{t(
"billingManageLicenseSubscriptionDescription"
) ||
"Manage your subscription for paid self-hosted license keys and download invoices."}
</p>
</div>
</div>
</SettingsFormCell>
</SettingsFormGrid>
</SettingsSectionBody>
</SettingsSection>
)}
@@ -0,0 +1,7 @@
"use client";
import { EditPolicyForm } from "@app/components/resource-policy/EditPolicyForm";
export default function EditPolicyAuthenticationPage() {
return <EditPolicyForm section="authentication" />;
}
@@ -0,0 +1,7 @@
"use client";
import { EditPolicyForm } from "@app/components/resource-policy/EditPolicyForm";
export default function EditPolicyGeneralPage() {
return <EditPolicyForm section="general" />;
}
@@ -0,0 +1,85 @@
import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
import { HorizontalTabs } from "@app/components/HorizontalTabs";
import { Button } from "@app/components/ui/button";
import { internal } from "@app/lib/api";
import { authCookieHeader } from "@app/lib/api/cookies";
import { ResourcePolicyProvider } from "@app/providers/ResourcePolicyProvider";
import type { GetResourcePolicyResponse } from "@server/routers/policy";
import type { AxiosResponse } from "axios";
import { getTranslations } from "next-intl/server";
import Link from "next/link";
import { redirect } from "next/navigation";
import type { Metadata } from "next";
export const metadata: Metadata = {
title: "Resource Policy"
};
export const dynamic = "force-dynamic";
type EditPolicyLayoutProps = {
children: React.ReactNode;
params: Promise<{ niceId: string; orgId: string }>;
};
export default async function EditPolicyLayout(props: EditPolicyLayoutProps) {
const params = await props.params;
const t = await getTranslations();
let policyResponse: GetResourcePolicyResponse | null = null;
try {
const res = await internal.get<
AxiosResponse<GetResourcePolicyResponse>
>(
`/org/${params.orgId}/resource-policy/${params.niceId}`,
await authCookieHeader()
);
policyResponse = res.data.data;
} catch {
redirect(`/${params.orgId}/settings/policies/resources/public`);
}
if (!policyResponse) {
redirect(`/${params.orgId}/settings/policies/resources/public`);
}
const navItems = [
{
title: t("general"),
href: "/{orgId}/settings/policies/resources/public/{niceId}/general"
},
{
title: t("authentication"),
href: "/{orgId}/settings/policies/resources/public/{niceId}/authentication"
},
{
title: t("policyAccessRulesTitle"),
href: "/{orgId}/settings/policies/resources/public/{niceId}/rules"
}
];
return (
<>
<div className="flex justify-between">
<SettingsSectionTitle
title={t("resourcePolicySetting", {
policyName: policyResponse.name
})}
description={t("resourcePolicySettingDescription")}
/>
<Button asChild variant="outline">
<Link
href={`/${params.orgId}/settings/policies/resources/public`}
>
{t("resourcePoliciesSeeAll")}
</Link>
</Button>
</div>
<ResourcePolicyProvider policy={policyResponse}>
<HorizontalTabs items={navItems}>{props.children}</HorizontalTabs>
</ResourcePolicyProvider>
</>
);
}
@@ -1,62 +1,12 @@
import { EditPolicyForm } from "@app/components/resource-policy/EditPolicyForm";
import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
import { Button } from "@app/components/ui/button";
import { internal } from "@app/lib/api";
import { authCookieHeader } from "@app/lib/api/cookies";
import { ResourcePolicyProvider } from "@app/providers/ResourcePolicyProvider";
import type { GetResourcePolicyResponse } from "@server/routers/policy";
import type { AxiosResponse } from "axios";
import { getTranslations } from "next-intl/server";
import Link from "next/link";
import { redirect } from "next/navigation";
export interface EditPolicyPageProps {
type EditPolicyPageProps = {
params: Promise<{ niceId: string; orgId: string }>;
}
};
export default async function EditPolicyPage(props: EditPolicyPageProps) {
const params = await props.params;
const t = await getTranslations();
let policyResponse: GetResourcePolicyResponse | null = null;
try {
const res = await internal.get<
AxiosResponse<GetResourcePolicyResponse>
>(
`/org/${params.orgId}/resource-policy/${params.niceId}`,
await authCookieHeader()
);
policyResponse = res.data.data;
} catch {
redirect(`/${params.orgId}/settings/policies/resources/public`);
}
if (!policyResponse) {
redirect(`/${params.orgId}/settings/policies/resources/public`);
}
return (
<>
<div className="flex justify-between">
<SettingsSectionTitle
title={t("resourcePolicySetting", {
policyName: policyResponse.name
})}
description={t("resourcePolicySettingDescription")}
/>
<Button asChild variant="outline">
<Link
href={`/${params.orgId}/settings/policies/resources/public`}
>
{t("resourcePoliciesSeeAll")}
</Link>
</Button>
</div>
<ResourcePolicyProvider policy={policyResponse}>
<EditPolicyForm />
</ResourcePolicyProvider>
</>
redirect(
`/${params.orgId}/settings/policies/resources/public/${params.niceId}/general`
);
}
@@ -0,0 +1,7 @@
"use client";
import { EditPolicyForm } from "@app/components/resource-policy/EditPolicyForm";
export default function EditPolicyRulesPage() {
return <EditPolicyForm section="rules" />;
}
@@ -1,3 +1,4 @@
import ResourcePoliciesBanner from "@app/components/ResourcePoliciesBanner";
import { ResourcePoliciesTable } from "@app/components/ResourcePoliciesTable";
import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
import { internal } from "@app/lib/api";
@@ -54,6 +55,8 @@ export default async function ResourcePoliciesPage(
description={t("resourcePoliciesDescription")}
/>
<ResourcePoliciesBanner />
<ResourcePoliciesTable
policies={policies}
orgId={params.orgId}
@@ -9,6 +9,8 @@ import {
} from "@app/components/InfoSection";
import {
SettingsContainer,
SettingsFormCell,
SettingsFormGrid,
SettingsSection,
SettingsSectionBody,
SettingsSectionDescription,
@@ -257,80 +259,87 @@ export default function Page() {
e.preventDefault(); // block default enter refresh
}
}}
className="space-y-4 grid gap-4 grid-cols-1 md:grid-cols-2 items-start"
id="create-client-form"
>
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("name")}
</FormLabel>
<FormControl>
<Input
autoComplete="off"
{...field}
/>
</FormControl>
<FormMessage />
<FormDescription>
{t(
"clientNameDescription"
)}
</FormDescription>
</FormItem>
)}
/>
<div className="flex items-center justify-end md:col-start-2">
<Button
type="button"
variant="ghost"
size="sm"
onClick={() =>
setShowAdvancedSettings(
!showAdvancedSettings
)
}
className="flex items-center gap-2"
>
{showAdvancedSettings ? (
<ChevronUp className="h-4 w-4" />
) : (
<ChevronDown className="h-4 w-4" />
)}
{t("advancedSettings")}
</Button>
</div>
{showAdvancedSettings && (
<FormField
control={form.control}
name="subnet"
render={({ field }) => (
<FormItem className="md:col-start-1 md:col-span-2">
<FormLabel>
{t("clientAddress")}
</FormLabel>
<FormControl>
<Input
autoComplete="off"
placeholder={t(
"subnetPlaceholder"
<SettingsFormGrid>
<SettingsFormCell span="half">
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("name")}
</FormLabel>
<FormControl>
<Input
autoComplete="off"
{...field}
/>
</FormControl>
<FormMessage />
<FormDescription>
{t(
"clientNameDescription"
)}
{...field}
/>
</FormControl>
<FormMessage />
<FormDescription>
{t(
"addressDescription"
)}
</FormDescription>
</FormItem>
)}
/>
)}
</FormDescription>
</FormItem>
)}
/>
</SettingsFormCell>
<SettingsFormCell className="flex items-center justify-end md:col-span-2">
<Button
type="button"
variant="ghost"
size="sm"
onClick={() =>
setShowAdvancedSettings(
!showAdvancedSettings
)
}
className="flex items-center gap-2"
>
{showAdvancedSettings ? (
<ChevronUp className="h-4 w-4" />
) : (
<ChevronDown className="h-4 w-4" />
)}
{t("advancedSettings")}
</Button>
</SettingsFormCell>
{showAdvancedSettings && (
<SettingsFormCell span="full">
<FormField
control={form.control}
name="subnet"
render={({ field }) => (
<FormItem>
<FormLabel>
{t(
"clientAddress"
)}
</FormLabel>
<FormControl>
<Input
autoComplete="off"
placeholder={t(
"subnetPlaceholder"
)}
{...field}
/>
</FormControl>
<FormMessage />
<FormDescription>
{t(
"addressDescription"
)}
</FormDescription>
</FormItem>
)}
/>
</SettingsFormCell>
)}
</SettingsFormGrid>
</form>
</Form>
</SettingsSectionBody>
+29 -29
View File
@@ -11,7 +11,7 @@ import { ColumnDef } from "@tanstack/react-table";
import { DateTimeValue } from "@app/components/DateTimePicker";
import { ArrowUpRight, Key, User } from "lucide-react";
import Link from "next/link";
import { ColumnFilter } from "@app/components/ColumnFilter";
import { ColumnFilterButton } from "@app/components/ColumnFilterButton";
import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
import { build } from "@server/build";
import { getSevenDaysAgo } from "@app/lib/getSevenDaysAgo";
@@ -233,7 +233,7 @@ export default function GeneralPage() {
{
accessorKey: "timestamp",
header: () => {
return t("timestamp");
return <span className="px-2">{t("timestamp")}</span>;
},
cell: ({ row }) => {
return (
@@ -249,19 +249,19 @@ export default function GeneralPage() {
accessorKey: "action",
header: () => {
return (
<div className="flex items-center gap-2">
<span>{t("action")}</span>
<ColumnFilter
<div className="flex items-center gap-2 px-2">
<ColumnFilterButton
options={[
{ value: "true", label: "Allowed" },
{ value: "false", label: "Denied" }
]}
label={t("action")}
selectedValue={filters.action}
onValueChange={(value) =>
handleFilterChange("action", value)
}
searchPlaceholder="Search..."
emptyMessage="None found"
searchPlaceholder={t("searchPlaceholder")}
emptyMessage={t("emptySearchOptions")}
/>
</div>
);
@@ -276,27 +276,27 @@ export default function GeneralPage() {
},
{
accessorKey: "ip",
header: () => t("ip")
header: () => <span className="px-2">{t("ip")}</span>
},
{
accessorKey: "location",
header: () => {
return (
<div className="flex items-center gap-2">
<span>{t("location")}</span>
<ColumnFilter
<div className="flex items-center gap-2 px-2">
<ColumnFilterButton
options={filterAttributes.locations.map(
(location) => ({
value: location,
label: location
})
)}
label={t("location")}
selectedValue={filters.location}
onValueChange={(value) =>
handleFilterChange("location", value)
}
searchPlaceholder="Search..."
emptyMessage="None found"
searchPlaceholder={t("searchPlaceholder")}
emptyMessage={t("emptySearchOptions")}
/>
</div>
);
@@ -321,19 +321,19 @@ export default function GeneralPage() {
accessorKey: "resourceName",
header: () => {
return (
<div className="flex items-center gap-2">
<span>{t("resource")}</span>
<ColumnFilter
<div className="flex items-center gap-2 px-2">
<ColumnFilterButton
options={filterAttributes.resources.map((res) => ({
value: res.id.toString(),
label: res.name || "Unnamed Resource"
}))}
label={t("resource")}
selectedValue={filters.resourceId}
onValueChange={(value) =>
handleFilterChange("resourceId", value)
}
searchPlaceholder="Search..."
emptyMessage="None found"
searchPlaceholder={t("searchPlaceholder")}
emptyMessage={t("emptySearchOptions")}
/>
</div>
);
@@ -359,9 +359,8 @@ export default function GeneralPage() {
accessorKey: "type",
header: () => {
return (
<div className="flex items-center gap-2">
<span>{t("type")}</span>
<ColumnFilter
<div className="flex items-center gap-2 px-2">
<ColumnFilterButton
options={[
{ value: "password", label: "Password" },
{ value: "pincode", label: "Pincode" },
@@ -372,12 +371,13 @@ export default function GeneralPage() {
},
{ value: "ssh", label: "SSH" }
]}
label={t("type")}
selectedValue={filters.type}
onValueChange={(value) =>
handleFilterChange("type", value)
}
searchPlaceholder="Search..."
emptyMessage="None found"
searchPlaceholder={t("searchPlaceholder")}
emptyMessage={t("emptySearchOptions")}
/>
</div>
);
@@ -395,19 +395,19 @@ export default function GeneralPage() {
accessorKey: "actor",
header: () => {
return (
<div className="flex items-center gap-2">
<span>{t("actor")}</span>
<ColumnFilter
<div className="flex items-center gap-2 px-2">
<ColumnFilterButton
options={filterAttributes.actors.map((actor) => ({
value: actor,
label: actor
}))}
label={t("actor")}
selectedValue={filters.actor}
onValueChange={(value) =>
handleFilterChange("actor", value)
}
searchPlaceholder="Search..."
emptyMessage="None found"
searchPlaceholder={t("searchPlaceholder")}
emptyMessage={t("emptySearchOptions")}
/>
</div>
);
@@ -433,7 +433,7 @@ export default function GeneralPage() {
},
{
accessorKey: "actorId",
header: () => t("actorId"),
header: () => <span className="px-2">{t("actorId")}</span>,
cell: ({ row }) => (
<span className="flex items-center gap-1">
{row.original.actorId || "-"}
+13 -17
View File
@@ -1,5 +1,5 @@
"use client";
import { ColumnFilter } from "@app/components/ColumnFilter";
import { ColumnFilterButton } from "@app/components/ColumnFilterButton";
import { DateTimeValue } from "@app/components/DateTimePicker";
import { LogDataTable } from "@app/components/LogDataTable";
import { PaidFeaturesAlert } from "@app/components/PaidFeaturesAlert";
@@ -219,9 +219,7 @@ export default function GeneralPage() {
const columns: ColumnDef<any>[] = [
{
accessorKey: "timestamp",
header: () => {
return t("timestamp");
},
header: () => <span className="px-2">{t("timestamp")}</span>,
cell: ({ row }) => {
return (
<div className="whitespace-nowrap">
@@ -236,16 +234,16 @@ export default function GeneralPage() {
accessorKey: "action",
header: () => {
return (
<div className="flex items-center gap-2">
<span>{t("action")}</span>
<ColumnFilter
<div className="flex items-center gap-2 px-2">
<ColumnFilterButton
options={[]}
label={t("action")}
selectedValue={filters.action}
onValueChange={(value) =>
handleFilterChange("action", value)
}
searchPlaceholder="Search..."
emptyMessage="None found"
searchPlaceholder={t("searchPlaceholder")}
emptyMessage={t("emptySearchOptions")}
/>
</div>
);
@@ -263,19 +261,19 @@ export default function GeneralPage() {
accessorKey: "actor",
header: () => {
return (
<div className="flex items-center gap-2">
<span>{t("actor")}</span>
<ColumnFilter
<div className="flex items-center gap-2 px-2">
<ColumnFilterButton
options={filterAttributes.actors.map((actor) => ({
value: actor,
label: actor
}))}
label={t("actor")}
selectedValue={filters.actor}
onValueChange={(value) =>
handleFilterChange("actor", value)
}
searchPlaceholder="Search..."
emptyMessage="None found"
searchPlaceholder={t("searchPlaceholder")}
emptyMessage={t("emptySearchOptions")}
/>
</div>
);
@@ -295,9 +293,7 @@ export default function GeneralPage() {
},
{
accessorKey: "actorId",
header: () => {
return t("actorId");
},
header: () => <span className="px-2">{t("actorId")}</span>,
cell: ({ row }) => {
return (
<span className="flex items-center gap-1">
@@ -1,6 +1,6 @@
"use client";
import { Button } from "@app/components/ui/button";
import { ColumnFilter } from "@app/components/ColumnFilter";
import { ColumnFilterButton } from "@app/components/ColumnFilterButton";
import { DateTimeValue } from "@app/components/DateTimePicker";
import { LogDataTable } from "@app/components/LogDataTable";
import { PaidFeaturesAlert } from "@app/components/PaidFeaturesAlert";
@@ -256,9 +256,7 @@ export default function ConnectionLogsPage() {
const columns: ColumnDef<any>[] = [
{
accessorKey: "startedAt",
header: () => {
return t("timestamp");
},
header: () => <span className="px-2">{t("timestamp")}</span>,
cell: ({ row }) => {
return (
<div className="whitespace-nowrap">
@@ -273,21 +271,21 @@ export default function ConnectionLogsPage() {
accessorKey: "protocol",
header: () => {
return (
<div className="flex items-center gap-2">
<span>{t("protocol")}</span>
<ColumnFilter
<div className="flex items-center gap-2 px-2">
<ColumnFilterButton
options={filterAttributes.protocols.map(
(protocol) => ({
label: protocol.toUpperCase(),
value: protocol
})
)}
label={t("protocol")}
selectedValue={filters.protocol}
onValueChange={(value) =>
handleFilterChange("protocol", value)
}
searchPlaceholder="Search..."
emptyMessage="None found"
searchPlaceholder={t("searchPlaceholder")}
emptyMessage={t("emptySearchOptions")}
/>
</div>
);
@@ -304,19 +302,19 @@ export default function ConnectionLogsPage() {
accessorKey: "resourceName",
header: () => {
return (
<div className="flex items-center gap-2">
<span>{t("resource")}</span>
<ColumnFilter
<div className="flex items-center gap-2 px-2">
<ColumnFilterButton
options={filterAttributes.resources.map((res) => ({
value: res.id.toString(),
label: res.name || "Unnamed Resource"
}))}
label={t("resource")}
selectedValue={filters.siteResourceId}
onValueChange={(value) =>
handleFilterChange("siteResourceId", value)
}
searchPlaceholder="Search..."
emptyMessage="None found"
searchPlaceholder={t("searchPlaceholder")}
emptyMessage={t("emptySearchOptions")}
/>
</div>
);
@@ -345,19 +343,19 @@ export default function ConnectionLogsPage() {
accessorKey: "clientName",
header: () => {
return (
<div className="flex items-center gap-2">
<span>{t("client")}</span>
<ColumnFilter
<div className="flex items-center gap-2 px-2">
<ColumnFilterButton
options={filterAttributes.clients.map((c) => ({
value: c.id.toString(),
label: c.name
}))}
label={t("client")}
selectedValue={filters.clientId}
onValueChange={(value) =>
handleFilterChange("clientId", value)
}
searchPlaceholder="Search..."
emptyMessage="None found"
searchPlaceholder={t("searchPlaceholder")}
emptyMessage={t("emptySearchOptions")}
/>
</div>
);
@@ -388,19 +386,19 @@ export default function ConnectionLogsPage() {
accessorKey: "userEmail",
header: () => {
return (
<div className="flex items-center gap-2">
<span>{t("user")}</span>
<ColumnFilter
<div className="flex items-center gap-2 px-2">
<ColumnFilterButton
options={filterAttributes.users.map((u) => ({
value: u.id,
label: u.email || u.id
}))}
label={t("user")}
selectedValue={filters.userId}
onValueChange={(value) =>
handleFilterChange("userId", value)
}
searchPlaceholder="Search..."
emptyMessage="None found"
searchPlaceholder={t("searchPlaceholder")}
emptyMessage={t("emptySearchOptions")}
/>
</div>
);
@@ -419,9 +417,7 @@ export default function ConnectionLogsPage() {
},
{
accessorKey: "sourceAddr",
header: () => {
return t("sourceAddress");
},
header: () => <span className="px-2">{t("sourceAddress")}</span>,
cell: ({ row }) => {
return (
<span className="whitespace-nowrap font-mono text-xs">
@@ -434,19 +430,19 @@ export default function ConnectionLogsPage() {
accessorKey: "destAddr",
header: () => {
return (
<div className="flex items-center gap-2">
<span>{t("destinationAddress")}</span>
<ColumnFilter
<div className="flex items-center gap-2 px-2">
<ColumnFilterButton
options={filterAttributes.destAddrs.map((addr) => ({
value: addr,
label: addr
}))}
label={t("destinationAddress")}
selectedValue={filters.destAddr}
onValueChange={(value) =>
handleFilterChange("destAddr", value)
}
searchPlaceholder="Search..."
emptyMessage="None found"
searchPlaceholder={t("searchPlaceholder")}
emptyMessage={t("emptySearchOptions")}
/>
</div>
);
@@ -461,9 +457,7 @@ export default function ConnectionLogsPage() {
},
{
accessorKey: "duration",
header: () => {
return t("duration");
},
header: () => <span className="px-2">{t("duration")}</span>,
cell: ({ row }) => {
return (
<span className="whitespace-nowrap">
+46 -54
View File
@@ -20,6 +20,7 @@ import { useMemo, useState, useTransition } from "react";
import { useStoredPageSize } from "@app/hooks/useStoredPageSize";
import { build } from "@server/build";
import type { QueryRequestAuditLogResponse } from "@server/routers/auditLogs/types";
import { ColumnFilterButton } from "@app/components/ColumnFilterButton";
export default function GeneralPage() {
const router = useRouter();
@@ -284,9 +285,9 @@ export default function GeneralPage() {
const columns: ColumnDef<any>[] = [
{
accessorKey: "timestamp",
header: ({ column }) => {
return t("timestamp");
},
header: ({ column }) => (
<span className="px-2">{t("timestamp")}</span>
),
cell: ({ row }) => {
return (
<div className="whitespace-nowrap">
@@ -299,22 +300,21 @@ export default function GeneralPage() {
},
{
accessorKey: "action",
header: ({ column }) => {
header: () => {
return (
<div className="flex items-center gap-2">
<span>{t("action")}</span>
<ColumnFilter
<div className="flex items-center gap-2 px-2">
<ColumnFilterButton
options={[
{ value: "true", label: "Allowed" },
{ value: "false", label: "Denied" }
]}
label={t("action")}
selectedValue={filters.action}
onValueChange={(value) =>
handleFilterChange("action", value)
}
// placeholder=""
searchPlaceholder="Search..."
emptyMessage="None found"
searchPlaceholder={t("searchPlaceholder")}
emptyMessage={t("emptySearchOptions")}
/>
</div>
);
@@ -329,17 +329,14 @@ export default function GeneralPage() {
},
{
accessorKey: "ip",
header: ({ column }) => {
return t("ip");
}
header: ({ column }) => <span className="px-2">{t("ip")}</span>
},
{
accessorKey: "location",
header: ({ column }) => {
return (
<div className="flex items-center gap-2">
<span>{t("location")}</span>
<ColumnFilter
<div className="flex items-center gap-2 px-2">
<ColumnFilterButton
options={filterAttributes.locations.map(
(location) => ({
value: location,
@@ -351,8 +348,9 @@ export default function GeneralPage() {
handleFilterChange("location", value)
}
// placeholder=""
searchPlaceholder="Search..."
emptyMessage="None found"
label={t("location")}
searchPlaceholder={t("searchPlaceholder")}
emptyMessage={t("emptySearchOptions")}
/>
</div>
);
@@ -377,9 +375,8 @@ export default function GeneralPage() {
accessorKey: "resourceName",
header: ({ column }) => {
return (
<div className="flex items-center gap-2">
<span>{t("resource")}</span>
<ColumnFilter
<div className="flex items-center gap-2 px-2">
<ColumnFilterButton
options={filterAttributes.resources.map((res) => ({
value: res.id.toString(),
label: res.name || "Unnamed Resource"
@@ -388,9 +385,9 @@ export default function GeneralPage() {
onValueChange={(value) =>
handleFilterChange("resourceId", value)
}
// placeholder=""
searchPlaceholder="Search..."
emptyMessage="None found"
label={t("resource")}
searchPlaceholder={t("searchPlaceholder")}
emptyMessage={t("emptySearchOptions")}
/>
</div>
);
@@ -417,9 +414,8 @@ export default function GeneralPage() {
accessorKey: "host",
header: ({ column }) => {
return (
<div className="flex items-center gap-2">
<span>{t("host")}</span>
<ColumnFilter
<div className="flex items-center gap-2 px-2">
<ColumnFilterButton
options={filterAttributes.hosts.map((host) => ({
value: host,
label: host
@@ -428,9 +424,9 @@ export default function GeneralPage() {
onValueChange={(value) =>
handleFilterChange("host", value)
}
// placeholder=""
searchPlaceholder="Search..."
emptyMessage="None found"
label={t("host")}
searchPlaceholder={t("searchPlaceholder")}
emptyMessage={t("emptySearchOptions")}
/>
</div>
);
@@ -452,9 +448,8 @@ export default function GeneralPage() {
accessorKey: "path",
header: ({ column }) => {
return (
<div className="flex items-center gap-2">
<span>{t("path")}</span>
<ColumnFilter
<div className="flex items-center gap-2 px-2">
<ColumnFilterButton
options={filterAttributes.paths.map((path) => ({
value: path,
label: path
@@ -463,9 +458,9 @@ export default function GeneralPage() {
onValueChange={(value) =>
handleFilterChange("path", value)
}
// placeholder=""
searchPlaceholder="Search..."
emptyMessage="None found"
label={t("path")}
searchPlaceholder={t("searchPlaceholder")}
emptyMessage={t("emptySearchOptions")}
/>
</div>
);
@@ -482,9 +477,8 @@ export default function GeneralPage() {
accessorKey: "method",
header: ({ column }) => {
return (
<div className="flex items-center gap-2">
<span>{t("method")}</span>
<ColumnFilter
<div className="flex items-center gap-2 px-2">
<ColumnFilterButton
options={[
{ value: "GET", label: "GET" },
{ value: "POST", label: "POST" },
@@ -498,9 +492,9 @@ export default function GeneralPage() {
onValueChange={(value) =>
handleFilterChange("method", value)
}
// placeholder=""
searchPlaceholder="Search..."
emptyMessage="None found"
label={t("method")}
searchPlaceholder={t("searchPlaceholder")}
emptyMessage={t("emptySearchOptions")}
/>
</div>
);
@@ -510,9 +504,8 @@ export default function GeneralPage() {
accessorKey: "reason",
header: ({ column }) => {
return (
<div className="flex items-center gap-2">
<span>{t("reason")}</span>
<ColumnFilter
<div className="flex items-center gap-2 px-2">
<ColumnFilterButton
options={[
{ value: "100", label: t("allowedByRule") },
{ value: "101", label: t("allowedNoAuth") },
@@ -537,9 +530,9 @@ export default function GeneralPage() {
onValueChange={(value) =>
handleFilterChange("reason", value)
}
// placeholder=""
searchPlaceholder="Search..."
emptyMessage="None found"
label={t("reason")}
searchPlaceholder={t("searchPlaceholder")}
emptyMessage={t("emptySearchOptions")}
/>
</div>
);
@@ -556,9 +549,8 @@ export default function GeneralPage() {
accessorKey: "actor",
header: ({ column }) => {
return (
<div className="flex items-center gap-2">
<span>{t("actor")}</span>
<ColumnFilter
<div className="flex items-center gap-2 px-2">
<ColumnFilterButton
options={filterAttributes.actors.map((actor) => ({
value: actor,
label: actor
@@ -567,9 +559,9 @@ export default function GeneralPage() {
onValueChange={(value) =>
handleFilterChange("actor", value)
}
// placeholder=""
searchPlaceholder="Search..."
emptyMessage="None found"
label={t("actor")}
searchPlaceholder={t("searchPlaceholder")}
emptyMessage={t("emptySearchOptions")}
/>
</div>
);
@@ -108,10 +108,7 @@ export default async function ClientResourcesPage(
siteNiceId: siteResource.siteNiceIds[idx],
online: siteResource.siteOnlines[idx]
})),
mode:
siteResource.pamMode && siteResource.mode === "host"
? "ssh"
: siteResource.mode,
mode: siteResource.mode,
scheme: siteResource.scheme,
ssl: siteResource.ssl,
siteNames: siteResource.siteNames,
@@ -21,6 +21,7 @@ import {
SettingsSectionHeader,
SettingsSectionTitle
} from "@app/components/Settings";
import { DataTableEmptyState } from "@app/components/ui/data-table-empty-state";
import {
Table,
TableBody,
@@ -671,6 +672,15 @@ export function ProxyResourceTargetsForm({
const [, formAction, isSubmitting] = useActionState(saveTargets, null);
const addTargetButton = (
<Button onClick={addNewTarget} variant="outline">
<Plus className="h-4 w-4 mr-2" />
{t("addTarget")}
</Button>
);
const hasTargets = targets.length > 0;
async function saveTargets() {
if (!resource) return;
@@ -784,143 +794,104 @@ export function ProxyResourceTargetsForm({
</SettingsSectionDescription>
</SettingsSectionHeader>
<SettingsSectionBody>
{targets.length > 0 ? (
<>
<div className="overflow-x-auto">
<Table>
<TableHeader>
{table
.getHeaderGroups()
.map((headerGroup) => (
<TableRow key={headerGroup.id}>
{headerGroup.headers.map(
(header) => {
const isActionsColumn =
header.column
.id ===
"actions";
const isSiteColumn =
header.column
.id ===
"site";
return (
<TableHead
key={
header.id
}
className={
isActionsColumn
? "sticky right-0 z-10 w-auto min-w-fit bg-card"
: isSiteColumn
? "w-45"
: ""
}
>
{header.isPlaceholder
? null
: flexRender(
header
.column
.columnDef
.header,
header.getContext()
)}
</TableHead>
);
}
)}
</TableRow>
))}
</TableHeader>
<TableBody>
{table.getRowModel().rows?.length ? (
table
.getRowModel()
.rows.map((row) => (
<TableRow key={row.id}>
{row
.getVisibleCells()
.map((cell) => {
const isActionsColumn =
cell.column
.id ===
"actions";
const isSiteColumn =
cell.column
.id ===
"site";
return (
<TableCell
key={
cell.id
}
className={
isActionsColumn
? "sticky right-0 z-10 w-auto min-w-fit bg-card"
: isSiteColumn
? "w-45"
: ""
}
>
{flexRender(
cell
.column
.columnDef
.cell,
cell.getContext()
)}
</TableCell>
);
})}
</TableRow>
))
) : (
<TableRow>
<TableCell
colSpan={columns.length}
className="h-24 text-center"
<div className="overflow-x-auto">
<Table>
<TableHeader>
{table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id}>
{headerGroup.headers.map((header) => {
const isActionsColumn =
header.column.id === "actions";
const isSiteColumn =
header.column.id === "site";
return (
<TableHead
key={header.id}
className={
isActionsColumn
? "sticky right-0 z-10 w-auto min-w-fit bg-card"
: isSiteColumn
? "w-45"
: ""
}
>
{t("targetNoOne")}
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
<div className="flex items-center justify-between mb-4">
<div className="flex items-center justify-between w-full gap-2">
<Button
onClick={addNewTarget}
variant="outline"
{header.isPlaceholder
? null
: flexRender(
header.column
.columnDef
.header,
header.getContext()
)}
</TableHead>
);
})}
</TableRow>
))}
</TableHeader>
<TableBody>
{table.getRowModel().rows?.length ? (
table.getRowModel().rows.map((row) => (
<TableRow key={row.id}>
{row
.getVisibleCells()
.map((cell) => {
const isActionsColumn =
cell.column.id ===
"actions";
const isSiteColumn =
cell.column.id ===
"site";
return (
<TableCell
key={cell.id}
className={
isActionsColumn
? "sticky right-0 z-10 w-auto min-w-fit bg-card"
: isSiteColumn
? "w-45"
: ""
}
>
{flexRender(
cell.column
.columnDef
.cell,
cell.getContext()
)}
</TableCell>
);
})}
</TableRow>
))
) : (
<DataTableEmptyState
colSpan={columns.length}
message={t("targetNoOne")}
action={addTargetButton}
/>
)}
</TableBody>
</Table>
</div>
{hasTargets && (
<div className="flex items-center justify-between mb-4">
<div className="flex items-center justify-between w-full gap-2">
{addTargetButton}
<div className="flex items-center gap-2">
<Switch
id="advanced-mode-toggle"
checked={isAdvancedMode}
onCheckedChange={setIsAdvancedMode}
/>
<label
htmlFor="advanced-mode-toggle"
className="text-sm"
>
<Plus className="h-4 w-4 mr-2" />
{t("addTarget")}
</Button>
<div className="flex items-center gap-2">
<Switch
id="advanced-mode-toggle"
checked={isAdvancedMode}
onCheckedChange={setIsAdvancedMode}
/>
<label
htmlFor="advanced-mode-toggle"
className="text-sm"
>
{t("advancedMode")}
</label>
</div>
{t("advancedMode")}
</label>
</div>
</div>
</>
) : (
<div className="text-center py-8 border-2 border-dashed border-muted rounded-lg p-4">
<p className="text-muted-foreground mb-4">
{t("targetNoOne")}
</p>
<Button onClick={addNewTarget} variant="outline">
<Plus className="h-4 w-4 mr-2" />
{t("addTarget")}
</Button>
</div>
)}
{build === "saas" &&
@@ -1,320 +1,7 @@
"use client";
import { EditPolicyForm } from "@app/components/resource-policy/EditPolicyForm";
import {
SettingsContainer,
SettingsSection,
SettingsSectionBody,
SettingsSectionDescription,
SettingsSectionFooter,
SettingsSectionHeader,
SettingsSectionTitle
} from "@app/components/Settings";
import {
StrategySelect,
type StrategyOption
} from "@app/components/StrategySelect";
import { Button } from "@app/components/ui/button";
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList
} from "@app/components/ui/command";
import {
Popover,
PopoverContent,
PopoverTrigger
} from "@app/components/ui/popover";
import { useEnvContext } from "@app/hooks/useEnvContext";
import { useOrgContext } from "@app/hooks/useOrgContext";
import { usePaidStatus } from "@app/hooks/usePaidStatus";
import { useResourceContext } from "@app/hooks/useResourceContext";
import { toast } from "@app/hooks/useToast";
import { createApiClient, formatAxiosError } from "@app/lib/api";
import { cn } from "@app/lib/cn";
import { orgQueries, resourceQueries } from "@app/lib/queries";
import { ResourcePolicyProvider } from "@app/providers/ResourcePolicyProvider";
import { zodResolver } from "@hookform/resolvers/zod";
import { CaretSortIcon } from "@radix-ui/react-icons";
import { build } from "@server/build";
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 { CheckIcon } from "lucide-react";
import { useTranslations } from "next-intl";
import { useRouter } from "next/navigation";
import { useEffect, useState, useTransition } from "react";
import { useForm, useWatch } from "react-hook-form";
import { z } from "zod";
const resourceTypeSchema = z
.object({
type: z.literal("inline")
})
.or(
z.object({
type: z.literal("shared"),
resourcePolicyId: z.number()
})
);
type ResourcePolicyType = StrategyOption<"inline" | "shared">;
import { ResourcePolicyEditForm } from "@app/components/resource-policy/ResourcePolicyEditForm";
export default function ResourceAuthenticationPage() {
const { org } = useOrgContext();
const { resource, updateResource } = useResourceContext();
const queryClient = useQueryClient();
const { env } = useEnvContext();
const { isPaidUser } = usePaidStatus();
const api = createApiClient({ env });
const router = useRouter();
const t = useTranslations();
const { data: policies, isLoading: isLoadingPolicies } = useQuery(
resourceQueries.policies({
resourceId: resource.resourceId
})
);
const form = useForm({
resolver: zodResolver(resourceTypeSchema),
defaultValues: {
type:
build !== "oss" && resource.resourcePolicyId
? "shared"
: "inline"
}
});
const selectedResourceType = useWatch({
control: form.control,
name: "type"
});
const [resourcePolicysearchQuery, setResourcePolicySearchQuery] =
useState("");
const { data: policiesList = [] } = useQuery({
...orgQueries.policies({
orgId: org.org.orgId,
name: resourcePolicysearchQuery
}),
enabled: selectedResourceType === "shared"
});
const [selectedPolicy, setSelectedPolicy] = useState<{
name: string;
id: number;
} | null>(null);
const resourcePolicyTypes: Array<ResourcePolicyType> = [
{
id: "inline",
title: t("resourcePolicyInline"),
description: t("resourcePolicyInlineDescription")
},
{
id: "shared",
title: t("resourcePolicyShared"),
description: t("resourcePolicySharedDescription")
}
];
useEffect(() => {
if (!isLoadingPolicies && policies?.sharedPolicy) {
setSelectedPolicy({
id: policies?.sharedPolicy.resourcePolicyId,
name: policies?.sharedPolicy.name
});
}
}, [isLoadingPolicies, policies?.sharedPolicy]);
const [isUpdatingResource, startTransition] = useTransition();
async function handleSaveResourcePolicyType() {
try {
if (selectedResourceType === "inline") {
await api.post(`/resource/${resource.resourceId}`, {
resourcePolicyId: null
});
} else {
if (!selectedPolicy) {
toast({
title: t("error"),
description: t("resourcePolicySelectError"),
variant: "destructive"
});
return;
}
await api.post(`/resource/${resource.resourceId}`, {
resourcePolicyId: selectedPolicy.id
});
}
router.refresh();
toast({
title: t("resourceUpdated"),
description: t("resourceUpdatedDescription")
});
} catch (e) {
toast({
title: t("error"),
description: formatAxiosError(e),
variant: "destructive"
});
} finally {
await queryClient.invalidateQueries(
resourceQueries.policies({
resourceId: resource.resourceId
})
);
}
}
const pageLoading = isLoadingPolicies || !policies;
if (pageLoading) {
return <></>;
}
return (
<>
<SettingsContainer>
{build !== "oss" &&
isPaidUser(tierMatrix[TierFeature.ResourcePolicies]) && (
<SettingsSection>
<SettingsSectionHeader>
<SettingsSectionTitle>
{t("resourcePolicySelectTitle")}
</SettingsSectionTitle>
<SettingsSectionDescription>
{t("resourcePolicySelectDescription")}
</SettingsSectionDescription>
</SettingsSectionHeader>
<SettingsSectionBody>
<StrategySelect
options={resourcePolicyTypes}
value={selectedResourceType}
onChange={(value) => {
form.setValue("type", value);
}}
cols={2}
/>
{selectedResourceType === "shared" && (
<Popover>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
className={
"w-full md:w-1/2 justify-between"
}
>
<span className="truncate max-w-37.5">
{selectedPolicy
? selectedPolicy.name
: t(
"resourcePolicySelect"
)}
</span>
<CaretSortIcon className="ml-2h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="p-0 w-45">
<Command shouldFilter={false}>
<CommandInput
placeholder={t(
"resourcePolicySearch"
)}
value={
resourcePolicysearchQuery
}
onValueChange={
setResourcePolicySearchQuery
}
/>
<CommandList>
<CommandEmpty>
{t(
"resourcePolicyNotFound"
)}
</CommandEmpty>
<CommandGroup>
{policiesList.map(
(policy) => (
<CommandItem
key={
policy.resourcePolicyId
}
value={policy.resourcePolicyId.toString()}
onSelect={() =>
setSelectedPolicy(
{
id: policy.resourcePolicyId,
name: policy.name
}
)
}
>
<CheckIcon
className={cn(
"mr-2 h-4 w-4",
policy.resourcePolicyId ===
selectedPolicy?.id
? "opacity-100"
: "opacity-0"
)}
/>
{
policy.name
}
</CommandItem>
)
)}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
)}
</SettingsSectionBody>
<SettingsSectionFooter className="justify-start">
<Button
onClick={() =>
startTransition(
handleSaveResourcePolicyType
)
}
loading={isUpdatingResource}
>
{t("resourcePolicyTypeSave")}
</Button>
</SettingsSectionFooter>
</SettingsSection>
)}
{selectedResourceType === "inline" ? (
<ResourcePolicyProvider policy={policies.defaultPolicy}>
<EditPolicyForm hidePolicyNameForm />
</ResourcePolicyProvider>
) : (
policies.sharedPolicy && (
<ResourcePolicyProvider
policy={policies.sharedPolicy}
key={policies.sharedPolicy.resourcePolicyId}
>
<EditPolicyForm
resourceId={resource.resourceId}
/>
</ResourcePolicyProvider>
)
)}
</SettingsContainer>
</>
);
return <ResourcePolicyEditForm section="authentication" />;
}
@@ -20,6 +20,8 @@ import {
SettingsSectionBody,
SettingsSectionDescription,
SettingsSectionFooter,
SettingsFormCell,
SettingsFormGrid,
SettingsSectionForm,
SettingsSectionHeader,
SettingsSectionTitle
@@ -36,10 +38,14 @@ import { AlertCircle } from "lucide-react";
import { useTranslations } from "next-intl";
import { useParams, useRouter } from "next/navigation";
import { toASCII, toUnicode } from "punycode";
import { useActionState, useMemo, useState } from "react";
import { useActionState, useEffect, useMemo, useState } from "react";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import z from "zod";
import { SharedPolicySelect } from "@app/components/shared-policy-selector";
import { useOrgContext } from "@app/hooks/useOrgContext";
import { build } from "@server/build";
import { TierFeature } from "@server/lib/billing/tierMatrix";
import { Alert, AlertDescription } from "@app/components/ui/alert";
import { RadioGroup, RadioGroupItem } from "@app/components/ui/radio-group";
import {
@@ -220,6 +226,11 @@ function MaintenanceSectionForm({
</TooltipProvider>
</FormControl>
</div>
<FormDescription>
{t(
"enableMaintenanceModeDescription"
)}
</FormDescription>
<FormMessage />
</FormItem>
);
@@ -429,16 +440,30 @@ function MaintenanceSectionForm({
export default function GeneralForm() {
const params = useParams();
const { org } = useOrgContext();
const { resource, updateResource } = useResourceContext();
const router = useRouter();
const t = useTranslations();
const { env } = useEnvContext();
const { isPaidUser } = usePaidStatus();
const orgId = params.orgId;
const api = createApiClient({ env });
const showResourcePolicy =
build !== "oss" &&
isPaidUser(tierMatrix[TierFeature.ResourcePolicies]);
const [selectedSharedPolicyId, setSelectedSharedPolicyId] = useState<
number | null
>(resource.resourcePolicyId ?? null);
useEffect(() => {
setSelectedSharedPolicyId(resource.resourcePolicyId ?? null);
}, [resource.resourcePolicyId]);
const [resourceFullDomain, setResourceFullDomain] = useState(
`${resource.ssl ? "https" : "http"}://${toUnicode(resource.fullDomain || "")}`
);
@@ -501,6 +526,12 @@ export default function GeneralForm() {
const data = form.getValues();
let resourcePolicyId: number | null | undefined;
if (showResourcePolicy) {
resourcePolicyId = selectedSharedPolicyId;
}
const res = await api
.post<AxiosResponse<UpdateResourceResponse>>(
`resource/${resource?.resourceId}`,
@@ -514,7 +545,8 @@ export default function GeneralForm() {
)
: undefined,
domainId: data.domainId,
proxyPort: data.proxyPort
proxyPort: data.proxyPort,
...(resourcePolicyId !== undefined && { resourcePolicyId })
}
)
.catch((e) => {
@@ -538,7 +570,10 @@ export default function GeneralForm() {
subdomain: data.subdomain,
fullDomain: updated.fullDomain,
proxyPort: data.proxyPort,
domainId: data.domainId
domainId: data.domainId,
...(resourcePolicyId !== undefined && {
resourcePolicyId
})
});
toast({
@@ -579,193 +614,229 @@ export default function GeneralForm() {
</SettingsSectionHeader>
<SettingsSectionBody>
<SettingsSectionForm>
<SettingsSectionForm variant="half">
<Form {...form}>
<form
action={formAction}
className="space-y-4"
id="general-settings-form"
>
<div className="grid grid-cols-2 gap-4">
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("name")}
</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="niceId"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("identifier")}
</FormLabel>
<FormControl>
<Input
{...field}
placeholder={t(
"enterIdentifier"
)}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
{!["http", "ssh", "rdp", "vnc"].includes(
resource.mode
) && (
<>
<SettingsFormGrid>
<SettingsFormCell span="full">
<FormField
control={form.control}
name="proxyPort"
render={({ field }) => (
name="enabled"
render={() => (
<FormItem>
<FormLabel>
{t(
"resourcePortNumber"
)}
</FormLabel>
<FormControl>
<Input
type="number"
value={
field.value !==
undefined
? String(
field.value
)
: ""
<SwitchInput
id="enable-resource"
defaultChecked={
resource.enabled
}
onChange={(e) =>
field.onChange(
e.target
.value
? parseInt(
e
.target
.value
)
: undefined
label={t(
"resourceEnable"
)}
onCheckedChange={(
val
) =>
form.setValue(
"enabled",
val
)
}
/>
</FormControl>
<FormMessage />
<FormDescription>
{t(
"resourcePortNumberDescription"
"disabledResourceDescription"
)}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</>
)}
</SettingsFormCell>
{["http", "ssh", "rdp", "vnc"].includes(
resource.mode
) && (
<div className="space-y-4">
<div id="resource-domain-picker">
<DomainPicker
allowWildcard={true}
key={resource.resourceId}
orgId={orgId as string}
cols={2}
defaultSubdomain={
form.watch(
"subdomain"
) ?? undefined
}
defaultDomainId={
form.watch(
"domainId"
) ?? undefined
}
defaultFullDomain={
resourceFullDomainName ||
undefined
}
onDomainChange={(res) => {
if (res === null) {
<SettingsFormCell span="half">
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("name")}
</FormLabel>
<FormControl>
<Input
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</SettingsFormCell>
<SettingsFormCell span="half">
<FormField
control={form.control}
name="niceId"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("identifier")}
</FormLabel>
<FormControl>
<Input
{...field}
placeholder={t(
"enterIdentifier"
)}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</SettingsFormCell>
{!["http", "ssh", "rdp", "vnc"].includes(
resource.mode
) && (
<SettingsFormCell span="half">
<FormField
control={form.control}
name="proxyPort"
render={({ field }) => (
<FormItem>
<FormLabel>
{t(
"resourcePortNumber"
)}
</FormLabel>
<FormControl>
<Input
type="number"
value={
field.value !==
undefined
? String(
field.value
)
: ""
}
onChange={(
e
) =>
field.onChange(
e
.target
.value
? parseInt(
e
.target
.value
)
: undefined
)
}
/>
</FormControl>
<FormMessage />
<FormDescription>
{t(
"resourcePortNumberDescription"
)}
</FormDescription>
</FormItem>
)}
/>
</SettingsFormCell>
)}
{["http", "ssh", "rdp", "vnc"].includes(
resource.mode
) && (
<SettingsFormCell span="full">
<div id="resource-domain-picker">
<DomainPicker
allowWildcard={true}
key={
resource.resourceId
}
orgId={orgId as string}
cols={2}
defaultSubdomain={
form.watch(
"subdomain"
) ?? undefined
}
defaultDomainId={
form.watch(
"domainId"
) ?? undefined
}
defaultFullDomain={
resourceFullDomainName ||
undefined
}
onDomainChange={(
res
) => {
if (res === null) {
form.setValue(
"domainId",
undefined
);
form.setValue(
"subdomain",
undefined
);
setResourceFullDomain(
`${resource.ssl ? "https" : "http"}://`
);
return;
}
form.setValue(
"domainId",
undefined
res.domainId
);
form.setValue(
"subdomain",
undefined
res.subdomain ??
undefined
);
setResourceFullDomain(
`${resource.ssl ? "https" : "http"}://`
`${resource.ssl ? "https" : "http"}://${toUnicode(res.fullDomain)}`
);
return;
}}
/>
</div>
</SettingsFormCell>
)}
{showResourcePolicy && (
<SettingsFormCell span="half">
<div className="space-y-2">
<FormLabel>
{t("sharedPolicy")}
</FormLabel>
<SharedPolicySelect
key={
resource.resourcePolicyId ??
"none"
}
form.setValue(
"domainId",
res.domainId
);
form.setValue(
"subdomain",
res.subdomain ??
undefined
);
setResourceFullDomain(
`${resource.ssl ? "https" : "http"}://${toUnicode(res.fullDomain)}`
);
}}
/>
</div>
</div>
)}
<FormField
control={form.control}
name="enabled"
render={() => (
<FormItem>
<FormControl>
<SwitchInput
id="enable-resource"
defaultChecked={
resource.enabled
orgId={org.org.orgId}
value={
selectedSharedPolicyId
}
label={t(
"resourceEnable"
)}
onCheckedChange={(
val
) =>
form.setValue(
"enabled",
val
)
onChange={
setSelectedSharedPolicyId
}
/>
</FormControl>
<FormDescription>
{t(
"disabledResourceDescription"
)}
</FormDescription>
<FormMessage />
</FormItem>
</div>
</SettingsFormCell>
)}
/>
</SettingsFormGrid>
</form>
</Form>
</SettingsSectionForm>
@@ -92,10 +92,16 @@ export default async function ResourceLayout(props: ResourceLayoutProps) {
];
if (["http", "ssh", "rdp", "vnc"].includes(resource.mode)) {
navItems.push({
title: t("authentication"),
href: `/{orgId}/settings/resources/public/{niceId}/authentication`
});
navItems.push(
{
title: t("authentication"),
href: `/{orgId}/settings/resources/public/{niceId}/authentication`
},
{
title: t("policyAccessRulesTitle"),
href: `/{orgId}/settings/resources/public/{niceId}/rules`
}
);
}
return (
@@ -0,0 +1,7 @@
"use client";
import { ResourcePolicyEditForm } from "@app/components/resource-policy/ResourcePolicyEditForm";
export default function ResourcePolicyRulesPage() {
return <ResourcePolicyEditForm section="rules" />;
}
@@ -5,6 +5,8 @@ import {
SettingsSection,
SettingsSectionBody,
SettingsSectionDescription,
SettingsFormCell,
SettingsFormGrid,
SettingsSectionForm,
SettingsSectionHeader,
SettingsSectionTitle,
@@ -410,174 +412,199 @@ function SshServerForm({
<Form {...form}>
<SettingsSectionBody>
<SettingsSectionForm variant="half">
<div className="space-y-2">
<p className="font-semibold text-sm">{t("sshServerMode")}</p>
<Badge variant="secondary">
{sshServerMode == "standard"
? t("sshServerModeStandard")
: t("sshServerModePangolin")}
</Badge>
</div>
<SettingsFormGrid>
<SettingsFormCell span="full">
<div className="space-y-2">
<p className="font-semibold text-sm">
{t("sshServerMode")}
</p>
<Badge variant="secondary">
{sshServerMode == "standard"
? t("sshServerModeStandard")
: t("sshServerModePangolin")}
</Badge>
</div>
</SettingsFormCell>
<div className="space-y-2">
<p className="font-semibold text-sm">{t("sshAuthenticationMethod")}</p>
<StrategySelect<"passthrough" | "push">
value={pamMode}
options={authMethodOptions}
onChange={(value) =>
form.setValue("pamMode", value, {
shouldValidate: true
})
}
cols={2}
/>
</div>
<SettingsFormCell span="full">
<div className="space-y-2">
<p className="font-semibold text-sm">
{t("sshAuthenticationMethod")}
</p>
<StrategySelect<"passthrough" | "push">
value={pamMode}
options={authMethodOptions}
onChange={(value) =>
form.setValue("pamMode", value, {
shouldValidate: true
})
}
cols={2}
/>
</div>
</SettingsFormCell>
{showDaemonLocation && (
<div className="space-y-2">
<p className="font-semibold text-sm">{t("sshAuthDaemonLocation")}</p>
<StrategySelect<"site" | "remote">
value={standardDaemonLocation}
options={daemonLocationOptions}
onChange={(value) =>
form.setValue(
"standardDaemonLocation",
value,
{ shouldValidate: true }
)
}
cols={2}
/>
<p className="text-sm text-muted-foreground">
{t("sshDaemonDisclaimer")}{" "}
<a
href="https://docs.pangolin.net/manage/resources/public/ssh"
target="_blank"
rel="noopener noreferrer"
className="text-primary hover:underline inline-flex items-center gap-1"
>
{t("learnMore")}
<ExternalLink className="size-3.5 shrink-0" />
</a>
</p>
</div>
)}
{showDaemonPort && (
<div className="w-full md:w-1/2">
<FormField
control={form.control}
name="authDaemonPort"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("sshDaemonPort")}
</FormLabel>
<FormControl>
<Input
type="number"
min={1}
max={65535}
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
)}
<div className="space-y-3">
<SettingsSubsectionHeader>
<SettingsSubsectionTitle>
{t("sshServerDestination")}
</SettingsSubsectionTitle>
<SettingsSubsectionDescription>
{t("sshServerDestinationDescription")}
</SettingsSubsectionDescription>
</SettingsSubsectionHeader>
{isNative ? (
<FormField
control={form.control}
name="selectedNativeSite"
render={() => (
<FormItem>
<Popover
open={nativeSiteOpen}
onOpenChange={
setNativeSiteOpen
}
{showDaemonLocation && (
<SettingsFormCell span="full">
<div className="space-y-2">
<p className="font-semibold text-sm">
{t("sshAuthDaemonLocation")}
</p>
<StrategySelect<"site" | "remote">
value={standardDaemonLocation}
options={daemonLocationOptions}
onChange={(value) =>
form.setValue(
"standardDaemonLocation",
value,
{
shouldValidate: true
}
)
}
cols={2}
/>
<p className="text-sm text-muted-foreground">
{t("sshDaemonDisclaimer")}{" "}
<a
href="https://docs.pangolin.net/manage/resources/public/ssh"
target="_blank"
rel="noopener noreferrer"
className="text-primary hover:underline inline-flex items-center gap-1"
>
<PopoverTrigger asChild>
<FormControl>
<Button
variant="outline"
role="combobox"
className="w-full max-w-xs justify-between font-normal"
>
<span className="truncate">
{selectedNativeSite?.name ??
t(
"siteSelect"
)}
</span>
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</FormControl>
</PopoverTrigger>
<PopoverContent className="w-[var(--radix-popover-trigger-width)] p-0">
<SitesSelector
orgId={orgId}
selectedSite={
selectedNativeSite
}
onSelectSite={(
site
) => {
form.setValue(
"selectedNativeSite",
site,
{
shouldValidate:
true
}
);
setNativeSiteOpen(
false
);
}}
/>
</PopoverContent>
</Popover>
<FormMessage />
</FormItem>
)}
/>
) : useMultiSiteTargetForm ? (
<BrowserGatewayTargetForm
control={form.control}
orgId={orgId}
multiSite={true}
sitesField="selectedSites"
destinationField="destination"
destinationPortField="destinationPort"
learnMoreHref="https://docs.pangolin.net/manage/resources/public/ssh"
defaultPort={22}
/>
) : (
<BrowserGatewayTargetForm
control={form.control}
orgId={orgId}
multiSite={false}
siteField="selectedSite"
destinationField="destination"
destinationPortField="destinationPort"
learnMoreHref="https://docs.pangolin.net/manage/resources/public/ssh"
defaultPort={22}
/>
{t("learnMore")}
<ExternalLink className="size-3.5 shrink-0" />
</a>
</p>
</div>
</SettingsFormCell>
)}
</div>
{showDaemonPort && (
<SettingsFormCell span="half">
<FormField
control={form.control}
name="authDaemonPort"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("sshDaemonPort")}
</FormLabel>
<FormControl>
<Input
type="number"
min={1}
max={65535}
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</SettingsFormCell>
)}
<SettingsFormCell span="full">
<SettingsSubsectionHeader>
<SettingsSubsectionTitle>
{t("sshServerDestination")}
</SettingsSubsectionTitle>
<SettingsSubsectionDescription>
{t(
"sshServerDestinationDescription"
)}
</SettingsSubsectionDescription>
</SettingsSubsectionHeader>
</SettingsFormCell>
{isNative ? (
<SettingsFormCell span="half">
<FormField
control={form.control}
name="selectedNativeSite"
render={() => (
<FormItem>
<Popover
open={nativeSiteOpen}
onOpenChange={
setNativeSiteOpen
}
>
<PopoverTrigger asChild>
<FormControl>
<Button
variant="outline"
role="combobox"
className="w-full justify-between font-normal"
>
<span className="truncate">
{selectedNativeSite?.name ??
t(
"siteSelect"
)}
</span>
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</FormControl>
</PopoverTrigger>
<PopoverContent className="w-[var(--radix-popover-trigger-width)] p-0">
<SitesSelector
orgId={orgId}
selectedSite={
selectedNativeSite
}
onSelectSite={(
site
) => {
form.setValue(
"selectedNativeSite",
site,
{
shouldValidate:
true
}
);
setNativeSiteOpen(
false
);
}}
/>
</PopoverContent>
</Popover>
<FormMessage />
</FormItem>
)}
/>
</SettingsFormCell>
) : useMultiSiteTargetForm ? (
<SettingsFormCell span="full">
<BrowserGatewayTargetForm
control={form.control}
orgId={orgId}
multiSite={true}
sitesField="selectedSites"
destinationField="destination"
destinationPortField="destinationPort"
learnMoreHref="https://docs.pangolin.net/manage/resources/public/ssh"
defaultPort={22}
/>
</SettingsFormCell>
) : (
<SettingsFormCell span="full">
<BrowserGatewayTargetForm
control={form.control}
orgId={orgId}
multiSite={false}
siteField="selectedSite"
destinationField="destination"
destinationPortField="destinationPort"
learnMoreHref="https://docs.pangolin.net/manage/resources/public/ssh"
defaultPort={22}
/>
</SettingsFormCell>
)}
</SettingsFormGrid>
</SettingsSectionForm>
</SettingsSectionBody>
<form action={formAction} className="flex justify-end mt-4">
@@ -4,6 +4,8 @@ import CopyTextBox from "@app/components/CopyTextBox";
import DomainPicker from "@app/components/DomainPicker";
import {
SettingsContainer,
SettingsFormCell,
SettingsFormGrid,
SettingsSection,
SettingsSectionBody,
SettingsSectionDescription,
@@ -39,6 +41,7 @@ import {
FormMessage
} from "@app/components/ui/form";
import { Input } from "@app/components/ui/input";
import { Label } from "@app/components/ui/label";
import {
Popover,
PopoverContent,
@@ -806,172 +809,198 @@ export default function Page() {
</SettingsSectionHeader>
<SettingsSectionBody>
<SettingsSectionForm variant="half">
{/* Name */}
<Form {...baseForm}>
<form
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
}
}}
className="grid gap-4 grid-cols-1 md:grid-cols-2 items-start"
id="base-resource-form"
>
<FormField
control={baseForm.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("name")}
</FormLabel>
<FormControl>
<Input
{...field}
/>
</FormControl>
<FormMessage />
<FormDescription>
{t(
"resourceNameDescription"
)}
</FormDescription>
</FormItem>
)}
/>
</form>
</Form>
<SettingsFormGrid>
<SettingsFormCell span="half">
<Form {...baseForm}>
<form
onKeyDown={(e) => {
if (
e.key ===
"Enter"
) {
e.preventDefault();
}
}}
id="base-resource-form"
>
<FormField
control={
baseForm.control
}
name="name"
render={({
field
}) => (
<FormItem>
<FormLabel>
{t(
"name"
)}
</FormLabel>
<FormControl>
<Input
{...field}
/>
</FormControl>
<FormMessage />
<FormDescription>
{t(
"resourceNameDescription"
)}
</FormDescription>
</FormItem>
)}
/>
</form>
</Form>
</SettingsFormCell>
{/* Inline Type Selector */}
<div className="space-y-2">
<p className="text-sm font-medium">
{t("type")}
</p>
<OptionSelect<NewResourceType>
options={typeOptions}
value={resourceType}
onChange={setResourceType}
cols={6}
/>
<p className="text-sm text-muted-foreground">
{t("resourceTypeDescription")}
</p>
</div>
{/* Domain/Subdomain (HTTP-based types) */}
{isHttpResource && (
<Form {...httpForm}>
<FormField
control={httpForm.control}
name="domainId"
render={() => (
<FormItem>
<DomainPicker
allowWildcard={
true
}
orgId={
orgId as string
}
warnOnProvidedDomain={
remoteExitNodes.length >=
1
}
onDomainChange={(
res
) => {
if (!res)
return;
httpForm.setValue(
"subdomain",
res.subdomain,
{
shouldValidate:
true
}
);
httpForm.setValue(
"domainId",
res.domainId,
{
shouldValidate:
true
}
);
}}
/>
<FormMessage />
<FormDescription>
{t(
"resourceDomainDescription"
)}
</FormDescription>
</FormItem>
)}
/>
</Form>
)}
{/* Proxy Port (TCP/UDP types) */}
{!isHttpResource && (
<Form {...tcpUdpForm}>
<form
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
<SettingsFormCell span="full">
<div className="space-y-2">
<p className="text-sm font-medium">
{t("type")}
</p>
<OptionSelect<NewResourceType>
options={typeOptions}
value={resourceType}
onChange={
setResourceType
}
}}
className="grid gap-4 grid-cols-1 md:grid-cols-2 items-start"
id="tcp-udp-settings-form"
>
<FormField
control={
tcpUdpForm.control
}
name="proxyPort"
render={({ field }) => (
<FormItem>
<FormLabel>
{t(
"resourcePortNumber"
)}
</FormLabel>
<FormControl>
<Input
type="number"
value={
field.value ??
""
}
onChange={(
e
) =>
field.onChange(
e
.target
.value
? parseInt(
e
.target
.value
)
: undefined
)
}
/>
</FormControl>
<FormMessage />
<FormDescription>
{t(
"resourcePortDescription"
)}
</FormDescription>
</FormItem>
)}
cols={6}
/>
</form>
</Form>
)}
<p className="text-sm text-muted-foreground">
{t(
"resourceTypeDescription"
)}
</p>
</div>
</SettingsFormCell>
{isHttpResource && (
<SettingsFormCell span="full">
<Form {...httpForm}>
<FormField
control={
httpForm.control
}
name="domainId"
render={() => (
<FormItem>
<DomainPicker
allowWildcard={
true
}
orgId={
orgId as string
}
warnOnProvidedDomain={
remoteExitNodes.length >=
1
}
onDomainChange={(
res
) => {
if (
!res
)
return;
httpForm.setValue(
"subdomain",
res.subdomain,
{
shouldValidate:
true
}
);
httpForm.setValue(
"domainId",
res.domainId,
{
shouldValidate:
true
}
);
}}
/>
<FormMessage />
<FormDescription>
{t(
"resourceDomainDescription"
)}
</FormDescription>
</FormItem>
)}
/>
</Form>
</SettingsFormCell>
)}
{!isHttpResource && (
<SettingsFormCell span="half">
<Form {...tcpUdpForm}>
<form
onKeyDown={(e) => {
if (
e.key ===
"Enter"
) {
e.preventDefault();
}
}}
id="tcp-udp-settings-form"
>
<FormField
control={
tcpUdpForm.control
}
name="proxyPort"
render={({
field
}) => (
<FormItem>
<FormLabel>
{t(
"resourcePortNumber"
)}
</FormLabel>
<FormControl>
<Input
type="number"
value={
field.value ??
""
}
onChange={(
e
) =>
field.onChange(
e
.target
.value
? parseInt(
e
.target
.value
)
: undefined
)
}
/>
</FormControl>
<FormMessage />
<FormDescription>
{t(
"resourcePortDescription"
)}
</FormDescription>
</FormItem>
)}
/>
</form>
</Form>
</SettingsFormCell>
)}
</SettingsFormGrid>
</SettingsSectionForm>
</SettingsSectionBody>
</SettingsSection>
@@ -1005,202 +1034,240 @@ export default function Page() {
>
<SettingsSectionBody>
<SettingsSectionForm variant="half">
{/* Mode */}
<div className="space-y-2">
<p className="font-semibold text-sm">{t("sshServerMode")}</p>
<StrategySelect<
"standard" | "native"
>
value={sshServerMode}
options={sshModeOptions}
onChange={setSshServerMode}
cols={2}
/>
</div>
<div className="space-y-2">
<p className="font-semibold text-sm">{t("sshAuthenticationMethod")}</p>
<StrategySelect<
"passthrough" | "push"
>
value={pamMode}
options={
authMethodOptions
}
onChange={setPamMode}
cols={2}
/>
</div>
{/* Daemon Location (standard + push) */}
{showDaemonLocation && (
<div className="space-y-2">
<p className="font-semibold text-sm">{t("sshAuthDaemonLocation")}</p>
<StrategySelect<
"site" | "remote"
>
value={
standardDaemonLocation
}
options={
daemonLocationOptions
}
onChange={
setStandardDaemonLocation
}
cols={2}
/>
<p className="text-sm text-muted-foreground">
{t(
"sshDaemonDisclaimer"
)}{" "}
<a
href="https://docs.pangolin.net/manage/resources/public/ssh"
target="_blank"
rel="noopener noreferrer"
className="text-primary hover:underline inline-flex items-center gap-1"
<SettingsFormGrid>
<SettingsFormCell span="full">
<div className="space-y-2">
<p className="font-semibold text-sm">
{t("sshServerMode")}
</p>
<StrategySelect<
"standard" | "native"
>
{t("learnMore")}
<ExternalLink className="size-3.5 shrink-0" />
</a>
</p>
</div>
)}
{/* Daemon Port (standard + push + remote) */}
{showDaemonPort && (
<Form {...sshDaemonPortForm}>
<div className="w-full md:w-1/2">
<FormField
control={
sshDaemonPortForm.control
value={sshServerMode}
options={
sshModeOptions
}
name="authDaemonPort"
render={({
field
}) => (
<FormItem>
<FormLabel>
{t(
"sshDaemonPort"
)}
</FormLabel>
<FormControl>
<Input
type="number"
min={
1
}
max={
65535
}
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
onChange={
setSshServerMode
}
cols={2}
/>
</div>
</Form>
)}
</SettingsFormCell>
{/* Server Destination */}
<div className="space-y-3">
<SettingsSubsectionHeader>
<SettingsSubsectionTitle>
{t(
"sshServerDestination"
)}
</SettingsSubsectionTitle>
<SettingsSubsectionDescription>
{t(
"sshServerDestinationDescription"
)}
</SettingsSubsectionDescription>
</SettingsSubsectionHeader>
{isNative ? (
<Popover
open={nativeSiteOpen}
onOpenChange={
setNativeSiteOpen
}
>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
className="w-full md:w-1/2 justify-between font-normal"
<SettingsFormCell span="full">
<div className="space-y-2">
<p className="font-semibold text-sm">
{t(
"sshAuthenticationMethod"
)}
</p>
<StrategySelect<
"passthrough" | "push"
>
value={pamMode}
options={
authMethodOptions
}
onChange={setPamMode}
cols={2}
/>
</div>
</SettingsFormCell>
{showDaemonLocation && (
<SettingsFormCell span="full">
<div className="space-y-2">
<p className="font-semibold text-sm">
{t(
"sshAuthDaemonLocation"
)}
</p>
<StrategySelect<
"site" | "remote"
>
<span className="truncate">
{nativeSelectedSite?.name ??
t(
"siteSelect"
)}
</span>
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-[var(--radix-popover-trigger-width)] p-0">
<SitesSelector
orgId={
orgId as string
value={
standardDaemonLocation
}
selectedSite={
nativeSelectedSite
options={
daemonLocationOptions
}
onSelectSite={(
site
) => {
setNativeSelectedSite(
site
);
setNativeSiteOpen(
false
);
}}
onChange={
setStandardDaemonLocation
}
cols={2}
/>
</PopoverContent>
</Popover>
<p className="text-sm text-muted-foreground">
{t(
"sshDaemonDisclaimer"
)}{" "}
<a
href="https://docs.pangolin.net/manage/resources/public/ssh"
target="_blank"
rel="noopener noreferrer"
className="text-primary hover:underline inline-flex items-center gap-1"
>
{t(
"learnMore"
)}
<ExternalLink className="size-3.5 shrink-0" />
</a>
</p>
</div>
</SettingsFormCell>
)}
{showDaemonPort && (
<SettingsFormCell span="half">
<Form
{...sshDaemonPortForm}
>
<FormField
control={
sshDaemonPortForm.control
}
name="authDaemonPort"
render={({
field
}) => (
<FormItem>
<FormLabel>
{t(
"sshDaemonPort"
)}
</FormLabel>
<FormControl>
<Input
type="number"
min={
1
}
max={
65535
}
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</Form>
</SettingsFormCell>
)}
<SettingsFormCell span="full">
<SettingsSubsectionHeader>
<SettingsSubsectionTitle>
{t(
"sshServerDestination"
)}
</SettingsSubsectionTitle>
<SettingsSubsectionDescription>
{t(
"sshServerDestinationDescription"
)}
</SettingsSubsectionDescription>
</SettingsSubsectionHeader>
</SettingsFormCell>
{isNative ? (
<SettingsFormCell span="half">
<div className="grid gap-2">
<Label>{t("sites")}</Label>
<Popover
open={
nativeSiteOpen
}
onOpenChange={
setNativeSiteOpen
}
>
<PopoverTrigger
asChild
>
<Button
variant="outline"
role="combobox"
className="w-full justify-between font-normal"
>
<span className="truncate">
{nativeSelectedSite?.name ??
t(
"siteSelect"
)}
</span>
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-[var(--radix-popover-trigger-width)] p-0">
<SitesSelector
orgId={
orgId as string
}
selectedSite={
nativeSelectedSite
}
onSelectSite={(
site
) => {
setNativeSelectedSite(
site
);
setNativeSiteOpen(
false
);
}}
/>
</PopoverContent>
</Popover>
</div>
</SettingsFormCell>
) : standardDaemonLocation !==
"site" ||
pamMode ===
"passthrough" ? (
<Form {...bgTargetForm}>
<BrowserGatewayTargetForm
control={
bgTargetForm.control
}
orgId={
orgId as string
}
multiSite={true}
sitesField="selectedSites"
destinationField="destination"
destinationPortField="destinationPort"
learnMoreHref="https://docs.pangolin.net/manage/resources/public/ssh"
defaultPort={22}
/>
</Form>
<SettingsFormCell span="full">
<Form {...bgTargetForm}>
<BrowserGatewayTargetForm
control={
bgTargetForm.control
}
orgId={
orgId as string
}
multiSite={true}
sitesField="selectedSites"
destinationField="destination"
destinationPortField="destinationPort"
learnMoreHref="https://docs.pangolin.net/manage/resources/public/ssh"
defaultPort={22}
/>
</Form>
</SettingsFormCell>
) : (
<Form {...bgTargetForm}>
<BrowserGatewayTargetForm
control={
bgTargetForm.control
}
orgId={
orgId as string
}
multiSite={false}
siteField="selectedSite"
destinationField="destination"
destinationPortField="destinationPort"
learnMoreHref="https://docs.pangolin.net/manage/resources/public/ssh"
defaultPort={22}
/>
</Form>
<SettingsFormCell span="full">
<Form {...bgTargetForm}>
<BrowserGatewayTargetForm
control={
bgTargetForm.control
}
orgId={
orgId as string
}
multiSite={
false
}
siteField="selectedSite"
destinationField="destination"
destinationPortField="destinationPort"
learnMoreHref="https://docs.pangolin.net/manage/resources/public/ssh"
defaultPort={22}
/>
</Form>
</SettingsFormCell>
)}
</div>
</SettingsFormGrid>
</SettingsSectionForm>
</SettingsSectionBody>
</fieldset>
+83 -72
View File
@@ -2,6 +2,8 @@
import {
SettingsContainer,
SettingsFormCell,
SettingsFormGrid,
SettingsSection,
SettingsSectionBody,
SettingsSectionDescription,
@@ -514,98 +516,107 @@ export default function Page() {
e.preventDefault(); // block default enter refresh
}
}}
className="space-y-4 grid gap-4 grid-cols-1 md:grid-cols-2 items-start"
id="create-site-form"
>
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("name")}
</FormLabel>
<FormControl>
<Input
autoComplete="off"
{...field}
/>
</FormControl>
<FormMessage />
<FormDescription>
{t(
"siteNameDescription"
)}
</FormDescription>
</FormItem>
)}
/>
{form.watch("method") === "newt" && (
<div className="flex items-center justify-end md:col-start-2">
<Button
type="button"
variant="ghost"
size="sm"
onClick={() =>
setShowAdvancedSettings(
!showAdvancedSettings
)
}
className="flex items-center gap-2"
>
{showAdvancedSettings ? (
<ChevronUp className="h-4 w-4" />
) : (
<ChevronDown className="h-4 w-4" />
)}
{t("advancedSettings")}
</Button>
</div>
)}
{form.watch("method") === "newt" &&
showAdvancedSettings && (
<SettingsFormGrid>
<SettingsFormCell span="half">
<FormField
control={form.control}
name="clientAddress"
name="name"
render={({ field }) => (
<FormItem className="md:col-start-1 md:col-span-2">
<FormItem>
<FormLabel>
{t(
"siteAddress"
)}
{t("name")}
</FormLabel>
<FormControl>
<Input
autoComplete="off"
value={
clientAddress
}
onChange={(
e
) => {
setClientAddress(
e
.target
.value
);
field.onChange(
e
.target
.value
);
}}
{...field}
/>
</FormControl>
<FormMessage />
<FormDescription>
{t(
"siteAddressDescription"
"siteNameDescription"
)}
</FormDescription>
</FormItem>
)}
/>
)}
{form.watch("method") ===
"newt" && (
<>
<Button
type="button"
variant="ghost"
size="sm"
onClick={() =>
setShowAdvancedSettings(
!showAdvancedSettings
)
}
className="mt-2 flex items-center gap-2 -ml-3"
>
{showAdvancedSettings ? (
<ChevronUp className="h-4 w-4" />
) : (
<ChevronDown className="h-4 w-4" />
)}
{t(
"advancedSettings"
)}
</Button>
{showAdvancedSettings && (
<FormField
control={
form.control
}
name="clientAddress"
render={({
field
}) => (
<FormItem className="mt-4">
<FormLabel>
{t(
"siteAddress"
)}
</FormLabel>
<FormControl>
<Input
autoComplete="off"
value={
clientAddress
}
onChange={(
e
) => {
setClientAddress(
e
.target
.value
);
field.onChange(
e
.target
.value
);
}}
/>
</FormControl>
<FormMessage />
<FormDescription>
{t(
"siteAddressDescription"
)}
</FormDescription>
</FormItem>
)}
/>
)}
</>
)}
</SettingsFormCell>
</SettingsFormGrid>
</form>
</Form>
</SettingsSectionBody>
+2 -2
View File
@@ -22,7 +22,7 @@
--accent-foreground: oklch(0.21 0.006 285.885);
--destructive: oklch(0.577 0.245 27.325);
--destructive-foreground: oklch(0.985 0 0);
--border: oklch(0.91 0.004 286.32);
--border: oklch(0.88 0.004 286.32);
--input: oklch(0.88 0.004 286.32);
--ring: oklch(0.705 0.213 47.604);
--chart-1: oklch(0.646 0.222 41.116);
@@ -57,7 +57,7 @@
--accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.5382 0.1949 22.216);
--destructive-foreground: oklch(0.985 0 0);
--border: oklch(1 0 0 / 8%);
--border: oklch(1 0 0 / 18%);
--input: oklch(1 0 0 / 18%);
--ring: oklch(0.646 0.222 41.116);
--chart-1: oklch(0.488 0.243 264.376);
+33 -32
View File
@@ -36,6 +36,7 @@ import { Alert, AlertDescription } from "@app/components/ui/alert";
import BrandedAuthSurface from "@app/components/BrandedAuthSurface";
import PoweredByPangolin from "@app/components/PoweredByPangolin";
import AuthPageFooterNotices from "@app/components/AuthPageFooterNotices";
import CollapsibleSessionToolbar from "@app/components/CollapsibleSessionToolbar";
import { useTranslations } from "next-intl";
import {
loadEncryptedLocalStorage,
@@ -464,27 +465,16 @@ export default function RdpClient({
className="fixed inset-0 z-50 flex flex-col bg-neutral-900"
style={{ display: showLogin ? "none" : "flex" }}
>
<div className="flex flex-wrap items-center gap-2 bg-black p-2 text-white">
<CollapsibleSessionToolbar>
<Button
size="sm"
variant="secondary"
onClick={() => ui()?.setScale(1)}
variant="destructive"
onClick={() => {
ui()?.shutdown();
setShowLogin(true);
}}
>
{t("rdpFit")}
</Button>
<Button
size="sm"
variant="secondary"
onClick={() => ui()?.setScale(2)}
>
{t("rdpFull")}
</Button>
<Button
size="sm"
variant="secondary"
onClick={() => ui()?.setScale(3)}
>
{t("rdpReal")}
{t("sshTerminate")}
</Button>
<Button
size="sm"
@@ -500,13 +490,6 @@ export default function RdpClient({
>
{t("rdpMeta")}
</Button>
{/* <Button
size="sm"
variant="secondary"
onClick={toggleCursorKind}
>
Toggle cursor
</Button> */}
<Button
size="sm"
variant="secondary"
@@ -537,16 +520,34 @@ export default function RdpClient({
>
{t("rdpUploadFiles")}
</Button>
{/* <Button
size="sm"
variant="secondary"
onClick={() => ui()?.setScale(1)}
>
{t("rdpFit")}
</Button>
<Button
size="sm"
variant="destructive"
onClick={() => {
ui()?.shutdown();
setShowLogin(true);
}}
variant="secondary"
onClick={() => ui()?.setScale(2)}
>
{t("sshTerminate")}
{t("rdpFull")}
</Button>
<Button
size="sm"
variant="secondary"
onClick={() => ui()?.setScale(3)}
>
{t("rdpReal")}
</Button> */}
{/* <Button
size="sm"
variant="secondary"
onClick={toggleCursorKind}
>
Toggle cursor
</Button> */}
<label className="ml-2 flex items-center gap-2">
<input
type="checkbox"
@@ -558,7 +559,7 @@ export default function RdpClient({
/>
{t("rdpUnicodeKeyboardMode")}
</label>
</div>
</CollapsibleSessionToolbar>
{moduleReady && (
<iron-remote-desktop
+50
View File
@@ -122,6 +122,9 @@ export default function SshClient({
const [connected, setConnected] = useState(false);
const [connecting, setConnecting] = useState(false);
const [connectError, setConnectError] = useState<string | null>(null);
const [sessionClosedCode, setSessionClosedCode] = useState<number | null>(
null
);
const terminalRef = useRef<HTMLDivElement>(null);
const xtermRef = useRef<import("@xterm/xterm").Terminal | null>(null);
@@ -222,6 +225,8 @@ export default function SshClient({
authMethod: AuthTab = "password"
) {
setConnecting(true);
setSessionClosedCode(null);
setConnectError(null);
if (!target) {
setConnectError(t("sshErrorNoTarget"));
@@ -257,8 +262,10 @@ export default function SshClient({
let authConfirmed = false;
let authErrorShown = false;
let socketOpened = false;
ws.onopen = () => {
socketOpened = true;
ws.send(
JSON.stringify({
type: "auth",
@@ -331,7 +338,16 @@ export default function SshClient({
};
ws.onclose = (evt) => {
wsRef.current = null;
setConnecting(false);
const isCleanClose = evt.wasClean || evt.code === 1000;
if (isCleanClose && (authConfirmed || socketOpened)) {
xtermRef.current?.dispose();
xtermRef.current = null;
setConnected(false);
setSessionClosedCode(evt.code);
return;
}
if (authConfirmed) {
setConnected(false);
xtermRef.current?.writeln(
@@ -453,6 +469,40 @@ export default function SshClient({
);
}
if (sessionClosedCode !== null) {
return (
<BrandedAuthSurface primaryColor={primaryColor}>
<PoweredByPangolin />
<Card className="w-full max-w-md">
<CardHeader>
<CardTitle>{t("sshTitle")}</CardTitle>
<CardDescription>
{t("sshConnectionClosedCode", {
code: sessionClosedCode
})}
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<Alert>
<AlertDescription>
This session has ended. You can close this tab
now.
</AlertDescription>
</Alert>
<Button
type="button"
className="w-full"
onClick={() => window.close()}
>
{t("close")}
</Button>
</CardContent>
</Card>
<AuthPageFooterNotices />
</BrandedAuthSurface>
);
}
return (
<>
{!connected && (
+2 -1
View File
@@ -152,7 +152,8 @@ export default async function SshPage() {
await waitForRoundTripCompletion(messageIds, cookieHeader);
} catch (err) {
console.error("Error signing SSH key:", err);
error = t("sshErrorSignKeyFailed");
const detail = err instanceof Error ? err.message : String(err);
error = `${t("sshErrorSignKeyFailed")}: ${detail}`;
}
}
+44 -12
View File
@@ -27,6 +27,7 @@ import { Alert, AlertDescription } from "@app/components/ui/alert";
import BrandedAuthSurface from "@app/components/BrandedAuthSurface";
import PoweredByPangolin from "@app/components/PoweredByPangolin";
import AuthPageFooterNotices from "@app/components/AuthPageFooterNotices";
import CollapsibleSessionToolbar from "@app/components/CollapsibleSessionToolbar";
import { useTranslations } from "next-intl";
import {
loadEncryptedLocalStorage,
@@ -80,6 +81,7 @@ export default function VncClient({
}, [form, target?.authToken]);
const [connected, setConnected] = useState(false);
const [connecting, setConnecting] = useState(false);
const [connectError, setConnectError] = useState<string | null>(null);
const rfbRef = useRef<any>(null);
const screenRef = useRef<HTMLDivElement>(null);
@@ -89,6 +91,7 @@ export default function VncClient({
rfbRef.current.disconnect();
rfbRef.current = null;
}
setConnecting(false);
setConnected(false);
};
@@ -97,12 +100,19 @@ export default function VncClient({
}, []);
const connect = async (values: VncCredentialsForm) => {
setConnecting(true);
if (!target) {
setConnectError(t("vncNoResourceTarget"));
setConnecting(false);
return;
}
if (!screenRef.current) return;
if (!screenRef.current) {
setConnectError(t("sshErrorConnectionClosed"));
setConnecting(false);
return;
}
disconnect();
@@ -116,6 +126,8 @@ export default function VncClient({
const mod = await import("@novnc/novnc");
RFB = mod.default ?? mod;
} catch (err) {
setConnecting(false);
setConnectError(t("sshErrorWebSocket"));
toast({
variant: "destructive",
title: t("vncFailedToLoadNovnc"),
@@ -140,7 +152,16 @@ export default function VncClient({
options.credentials = { password: values.password };
}
const rfb: any = new RFB(screenRef.current, wsUrl, options);
let rfb: any;
try {
rfb = new RFB(screenRef.current, wsUrl, options);
} catch {
setConnecting(false);
setConnectError(t("sshErrorWebSocket"));
return;
}
let authConfirmed = false;
rfb.scaleViewport = true;
rfb.resizeSession = true;
@@ -151,6 +172,8 @@ export default function VncClient({
values,
target.authToken
);
authConfirmed = true;
setConnecting(false);
setConnected(true);
});
@@ -158,7 +181,11 @@ export default function VncClient({
"disconnect",
(e: { detail: { clean: boolean } }) => {
rfbRef.current = null;
setConnecting(false);
setConnected(false);
if (!authConfirmed && !e.detail.clean) {
setConnectError(t("sshErrorConnectionClosed"));
}
}
);
@@ -243,7 +270,12 @@ export default function VncClient({
</FormItem>
)}
/>
<Button type="submit" className="w-full">
<Button
type="submit"
className="w-full"
loading={connecting}
disabled={connecting}
>
{t("browserGatewayConnect")}
</Button>
{connectError && (
@@ -265,7 +297,14 @@ export default function VncClient({
className="fixed inset-0 z-50 flex flex-col bg-neutral-900"
style={{ display: connected ? "flex" : "none" }}
>
<div className="flex flex-wrap items-center gap-2 bg-black p-2 text-white">
<CollapsibleSessionToolbar>
<Button
size="sm"
variant="destructive"
onClick={disconnect}
>
{t("sshTerminate")}
</Button>
<Button
size="sm"
variant="secondary"
@@ -291,14 +330,7 @@ export default function VncClient({
>
{t("vncPasteClipboard")}
</Button>
<Button
size="sm"
variant="destructive"
onClick={disconnect}
>
{t("sshTerminate")}
</Button>
</div>
</CollapsibleSessionToolbar>
<div
ref={screenRef}
+1 -1
View File
@@ -91,7 +91,7 @@ export default function AuthPageBrandingForm({
orgSubtitle: branding?.orgSubtitle ?? `Log in to {{orgName}}`,
resourceTitle:
branding?.resourceTitle ??
`Authenticate to access {{resourceName}}`,
`Authenticate to Access {{resourceName}}`,
resourceSubtitle:
branding?.resourceSubtitle ??
`Choose your preferred authentication method for {{resourceName}}`,
@@ -0,0 +1,58 @@
"use client";
import { useState, type ReactNode } from "react";
import { ChevronDown, ChevronUp } from "lucide-react";
import { cn } from "@app/lib/cn";
import { useTranslations } from "next-intl";
export default function CollapsibleSessionToolbar({
children,
defaultOpen = false
}: {
children: ReactNode;
defaultOpen?: boolean;
}) {
const t = useTranslations();
const [open, setOpen] = useState(defaultOpen);
return (
<div className="pointer-events-none absolute inset-x-0 top-0 z-10">
<div
className={cn(
"pointer-events-auto absolute inset-x-0 top-0 isolate transition-transform duration-200 ease-out",
open ? "translate-y-0" : "-translate-y-full"
)}
>
<div className="relative z-20 flex flex-wrap items-center gap-2 bg-background p-2">
{children}
</div>
{/* Secondary toggle backdrop kept as a distinct style under
the main handle button. */}
<button
type="button"
onClick={() => setOpen((v) => !v)}
aria-label={
open ? t("sessionToolbarHide") : t("sessionToolbarShow")
}
aria-expanded={open}
className="absolute left-1/2 top-full -z-20 h-4 w-72 -translate-x-1/2 -translate-y-2 rounded-md bg-neutral-200 transition-opacity focus:outline-none focus-visible:ring-2 focus-visible:ring-primary dark:bg-neutral-500"
/>
<button
type="button"
onClick={() => setOpen((v) => !v)}
aria-label={
open ? t("sessionToolbarHide") : t("sessionToolbarShow")
}
aria-expanded={open}
className="absolute left-1/2 top-full -z-10 flex h-5 w-6 -translate-x-1/2 items-center justify-center rounded-b-sm bg-primary text-primary-foreground transition-opacity focus:outline-none focus-visible:ring-2 focus-visible:ring-primary"
>
{open ? (
<ChevronUp className="h-3 w-3" />
) : (
<ChevronDown className="h-3 w-3" />
)}
</button>
</div>
</div>
);
}
+5 -3
View File
@@ -17,6 +17,7 @@ import { CheckIcon, ChevronDownIcon, Funnel } from "lucide-react";
import { cn } from "@app/lib/cn";
import { dataTableFilterPopoverContentClassName } from "@app/lib/dataTableFilterPopover";
import { Badge } from "./ui/badge";
import { useTranslations } from "next-intl";
interface FilterOption {
value: string;
@@ -27,7 +28,6 @@ interface ColumnFilterButtonProps {
options: FilterOption[];
selectedValue?: string;
onValueChange: (value: string | undefined) => void;
placeholder?: string;
searchPlaceholder?: string;
emptyMessage?: string;
className?: string;
@@ -38,7 +38,6 @@ export function ColumnFilterButton({
options,
selectedValue,
onValueChange,
placeholder,
searchPlaceholder = "Search...",
emptyMessage = "No options found",
className,
@@ -50,6 +49,8 @@ export function ColumnFilterButton({
(option) => option.value === selectedValue
);
const t = useTranslations();
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
@@ -94,7 +95,7 @@ export function ColumnFilterButton({
}}
className="text-muted-foreground"
>
Clear filter
{t("accessFilterClear")}
</CommandItem>
)}
{options.map((option) => (
@@ -109,6 +110,7 @@ export function ColumnFilterButton({
);
setOpen(false);
}}
className="break-all"
>
<CheckIcon
className={cn(
+2 -1
View File
@@ -120,7 +120,7 @@ export function ColumnMultiFilterButton({
}}
className="text-muted-foreground"
>
{t("accessUsersRoleFilterClear")}
{t("accessFilterClear")}
</CommandItem>
)}
{options.map((option) => (
@@ -130,6 +130,7 @@ export function ColumnMultiFilterButton({
onSelect={() => {
toggle(option.value);
}}
className="break-all"
>
<Checkbox
className="pointer-events-none shrink-0"
+14 -6
View File
@@ -52,12 +52,20 @@ export function CreateOrgLabelDialog({
description: t("labelCreateSuccessMessage")
});
}
} catch (e) {
toast({
title: t("error"),
description: formatAxiosError(e, t("errorOccurred")),
variant: "destructive"
});
} catch (e: any) {
if (e.response?.status === 409) {
toast({
title: t("labelDuplicateError"),
description: t("labelDuplicateErrorDescription"),
variant: "destructive"
});
} else {
toast({
title: t("error"),
description: formatAxiosError(e, t("errorOccurred")),
variant: "destructive"
});
}
}
}
+8 -9
View File
@@ -20,7 +20,12 @@ import type { CreateRoleBody, CreateRoleResponse } from "@server/routers/role";
import { AxiosResponse } from "axios";
import { useTranslations } from "next-intl";
import { useTransition } from "react";
import { RoleForm, type RoleFormValues } from "./RoleForm";
import {
parseSudoCommands,
parseUnixGroups,
RoleForm,
type RoleFormValues
} from "./RoleForm";
import { tierMatrix } from "@server/lib/billing/tierMatrix";
type CreateRoleFormProps = {
@@ -53,16 +58,10 @@ export default function CreateRoleForm({
payload.sshSudoCommands =
values.sshSudoMode === "commands" &&
values.sshSudoCommands?.trim()
? values.sshSudoCommands
.split(",")
.map((s) => s.trim())
.filter(Boolean)
? parseSudoCommands(values.sshSudoCommands)
: [];
if (values.sshUnixGroups?.trim()) {
payload.sshUnixGroups = values.sshUnixGroups
.split(",")
.map((s) => s.trim())
.filter(Boolean);
payload.sshUnixGroups = parseUnixGroups(values.sshUnixGroups);
}
}
const res = await api
+11 -5
View File
@@ -4,6 +4,7 @@ import { Button } from "@app/components/ui/button";
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
@@ -269,11 +270,11 @@ export default function CreateShareLinkForm({
</PopoverTrigger>
<PopoverContent className="p-0">
<ResourceSelector
excludeWildcard
orgId={
org.org
.orgId
}
excludeWildcard
orgId={
org.org
.orgId
}
selectedResource={
selectedResource
}
@@ -333,6 +334,11 @@ export default function CreateShareLinkForm({
<FormControl>
<Input {...field} />
</FormControl>
<FormDescription>
{t(
"sharePathDescription"
)}
</FormDescription>
<FormMessage />
</FormItem>
)}
+14 -6
View File
@@ -58,12 +58,20 @@ export function EditOrgLabelDialog({
description: t("labelEditSuccessMessage")
});
}
} catch (e) {
toast({
title: t("error"),
description: formatAxiosError(e, t("errorOccurred")),
variant: "destructive"
});
} catch (e: any) {
if (e.response?.status === 409) {
toast({
title: t("labelDuplicateError"),
description: t("labelDuplicateErrorDescription"),
variant: "destructive"
});
} else {
toast({
title: t("error"),
description: formatAxiosError(e, t("errorOccurred")),
variant: "destructive"
});
}
}
}
+8 -9
View File
@@ -20,7 +20,12 @@ import type { UpdateRoleBody, UpdateRoleResponse } from "@server/routers/role";
import { AxiosResponse } from "axios";
import { useTranslations } from "next-intl";
import { useTransition } from "react";
import { RoleForm, type RoleFormValues } from "./RoleForm";
import {
parseSudoCommands,
parseUnixGroups,
RoleForm,
type RoleFormValues
} from "./RoleForm";
import { tierMatrix } from "@server/lib/billing/tierMatrix";
type EditRoleFormProps = {
@@ -56,16 +61,10 @@ export default function EditRoleForm({
payload.sshSudoCommands =
values.sshSudoMode === "commands" &&
values.sshSudoCommands?.trim()
? values.sshSudoCommands
.split(",")
.map((s) => s.trim())
.filter(Boolean)
? parseSudoCommands(values.sshSudoCommands)
: [];
if (values.sshUnixGroups !== undefined) {
payload.sshUnixGroups = values.sshUnixGroups
.split(",")
.map((s) => s.trim())
.filter(Boolean);
payload.sshUnixGroups = parseUnixGroups(values.sshUnixGroups);
}
}
const res = await api
+1 -1
View File
@@ -168,7 +168,7 @@ export function LabelColumnFilterButton({
}}
className="text-muted-foreground"
>
{t("accessLabelFilterClear")}
{t("accessFilterClear")}
</CommandItem>
)}
{labels.map((label) => (
+1 -1
View File
@@ -32,7 +32,7 @@ import {
RefreshCw
} from "lucide-react";
import { useTranslations } from "next-intl";
import { useState, useEffect, useMemo } from "react";
import { useEffect, useMemo, useState } from "react";
import {
Tooltip,
TooltipContent,
+30 -4
View File
@@ -570,7 +570,7 @@ export function PrivateResourceForm({
mode: "host",
destination: "",
alias: null,
destinationPort: 22,
destinationPort: null,
scheme: "http",
ssl: true,
httpConfigSubdomain: null,
@@ -1038,9 +1038,35 @@ export function PrivateResourceForm({
modeOptions
}
value={field.value}
onChange={
field.onChange
}
onChange={(
newMode
) => {
field.onChange(
newMode
);
if (
newMode ===
"ssh"
) {
form.setValue(
"destinationPort",
22
);
} else if (
newMode ===
"http"
) {
form.setValue(
"destinationPort",
443
);
} else {
form.setValue(
"destinationPort",
null
);
}
}}
cols={2}
/>
<FormMessage />
+43 -117
View File
@@ -2,9 +2,17 @@
import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog";
import CopyToClipboard from "@app/components/CopyToClipboard";
import { ExtendedColumnDef } from "@app/components/ui/data-table";
import CreatePrivateResourceDialog from "@app/components/CreatePrivateResourceDialog";
import EditPrivateResourceDialog from "@app/components/EditPrivateResourceDialog";
import { ResourceAccessCertIndicator } from "@app/components/ResourceAccessCertIndicator";
import {
ResourceSitesStatusCell,
type ResourceSiteRow
} from "@app/components/ResourceSitesStatusCell";
import { Selectedsite, SitesSelector } from "@app/components/site-selector";
import { Badge } from "@app/components/ui/badge";
import { Button } from "@app/components/ui/button";
import { ExtendedColumnDef } from "@app/components/ui/data-table";
import {
DropdownMenu,
DropdownMenuContent,
@@ -18,53 +26,35 @@ import {
PopoverTrigger
} from "@app/components/ui/popover";
import { useEnvContext } from "@app/hooks/useEnvContext";
import { useNavigationContext } from "@app/hooks/useNavigationContext";
import { useOptimisticLabels } from "@app/hooks/useOptimisticLabels";
import { usePaidStatus } from "@app/hooks/usePaidStatus";
import { toast } from "@app/hooks/useToast";
import { createApiClient, formatAxiosError } from "@app/lib/api";
import { cn } from "@app/lib/cn";
import { dataTableFilterPopoverContentClassName } from "@app/lib/dataTableFilterPopover";
import { formatSiteResourceDestinationDisplay } from "@app/lib/formatSiteResourceAccess";
import { getNextSortOrder, getSortDirection } from "@app/lib/sortColumn";
import { build } from "@server/build";
import { tierMatrix } from "@server/lib/billing/tierMatrix";
import type { PaginationState } from "@tanstack/react-table";
import {
ArrowDown01Icon,
ArrowUp10Icon,
ArrowUpDown,
ArrowUpRight,
ChevronDown,
ChevronsUpDownIcon,
Funnel,
MoreHorizontal
} from "lucide-react";
import { useTranslations } from "next-intl";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { Selectedsite, SitesSelector } from "@app/components/site-selector";
import {
startTransition,
useEffect,
useMemo,
useState,
useTransition
} from "react";
import CreatePrivateResourceDialog from "@app/components/CreatePrivateResourceDialog";
import EditPrivateResourceDialog from "@app/components/EditPrivateResourceDialog";
import type { PaginationState } from "@tanstack/react-table";
import { ControlledDataTable } from "./ui/controlled-data-table";
import { useNavigationContext } from "@app/hooks/useNavigationContext";
import { startTransition, useMemo, useState, useTransition } from "react";
import { useDebouncedCallback } from "use-debounce";
import { ColumnFilterButton } from "./ColumnFilterButton";
import { cn } from "@app/lib/cn";
import { dataTableFilterPopoverContentClassName } from "@app/lib/dataTableFilterPopover";
import { formatSiteResourceDestinationDisplay } from "@app/lib/formatSiteResourceAccess";
import {
ResourceSitesStatusCell,
type ResourceSiteRow
} from "@app/components/ResourceSitesStatusCell";
import { ResourceAccessCertIndicator } from "@app/components/ResourceAccessCertIndicator";
import { build } from "@server/build";
import { usePaidStatus } from "@app/hooks/usePaidStatus";
import { tierMatrix } from "@server/lib/billing/tierMatrix";
import { type SelectedLabel } from "./labels-selector";
import { LabelsTableCell } from "./LabelsTableCell";
import { LabelColumnFilterButton } from "./LabelColumnFilterButton";
import { useLocalLabels } from "@app/hooks/useLocalLabels";
import { useOptimisticLabels } from "@app/hooks/useOptimisticLabels";
import { LabelsTableCell } from "./LabelsTableCell";
import { ControlledDataTable } from "./ui/controlled-data-table";
import { SitesColumnFilterButton } from "./SitesColumnFilterButton";
export type InternalResourceSiteRow = ResourceSiteRow;
@@ -157,7 +147,6 @@ export default function PrivateResourcesTable({
const [editingResource, setEditingResource] =
useState<InternalResourceRow | null>();
const [isCreateDialogOpen, setIsCreateDialogOpen] = useState(false);
const [siteFilterOpen, setSiteFilterOpen] = useState(false);
const [isRefreshing, startRefreshTransition] = useTransition();
@@ -171,27 +160,6 @@ export default function PrivateResourcesTable({
// return () => clearInterval(interval);
// }, [router]);
const siteIdQ = searchParams.get("siteId");
const siteIdNum = siteIdQ ? parseInt(siteIdQ, 10) : NaN;
const selectedSite: Selectedsite | null = useMemo(() => {
if (!siteIdQ || !Number.isInteger(siteIdNum) || siteIdNum <= 0) {
return null;
}
if (initialFilterSite && initialFilterSite.siteId === siteIdNum) {
return initialFilterSite;
}
return {
siteId: siteIdNum,
name: t("standaloneHcFilterSiteIdFallback", { id: siteIdNum }),
type: "newt"
};
}, [initialFilterSite, siteIdQ, siteIdNum, t]);
const createInitialSites = useMemo(
() => (selectedSite ? [selectedSite] : undefined),
[selectedSite]
);
const refreshData = () => {
startRefreshTransition(() => {
try {
@@ -285,58 +253,27 @@ export default function PrivateResourcesTable({
accessorFn: (row) =>
row.sites.map((s) => s.siteName).join(", "),
friendlyName: t("sites"),
header: () => (
<Popover
open={siteFilterOpen}
onOpenChange={setSiteFilterOpen}
>
<PopoverTrigger asChild>
<Button
type="button"
variant="ghost"
role="combobox"
className={cn(
"justify-between text-sm h-8 px-2 w-full p-3",
!selectedSite && "text-muted-foreground"
)}
>
<div className="flex items-center gap-2 min-w-0">
{t("sites")}
<Funnel className="size-4 flex-none" />
{selectedSite && (
<Badge
className="truncate max-w-[10rem]"
variant="secondary"
>
{selectedSite.name}
</Badge>
)}
</div>
</Button>
</PopoverTrigger>
<PopoverContent
className={dataTableFilterPopoverContentClassName}
align="start"
>
<div className="border-b p-1">
<Button
type="button"
variant="ghost"
size="sm"
className="h-8 w-full justify-start font-normal"
onClick={clearSiteFilter}
>
{t("standaloneHcFilterAnySite")}
</Button>
</div>
<SitesSelector
orgId={orgId}
selectedSite={selectedSite}
onSelectSite={onPickSite}
/>
</PopoverContent>
</Popover>
),
header: () => {
const siteIdQ = searchParams.get("siteId");
const siteIdNum = siteIdQ ? parseInt(siteIdQ, 10) : NaN;
const selectedSiteId =
!siteIdQ ||
!Number.isInteger(siteIdNum) ||
siteIdNum <= 0
? null
: siteIdNum;
return (
<SitesColumnFilterButton
selectedSiteId={selectedSiteId}
onValueChange={(value) =>
handleFilterChange("siteId", value?.toString())
}
orgId={orgId}
/>
);
},
cell: ({ row }) => {
const resourceRow = row.original;
return (
@@ -586,16 +523,6 @@ export default function PrivateResourcesTable({
});
}
const clearSiteFilter = () => {
handleFilterChange("siteId", undefined);
setSiteFilterOpen(false);
};
const onPickSite = (site: Selectedsite) => {
handleFilterChange("siteId", String(site.siteId));
setSiteFilterOpen(false);
};
function toggleSort(column: string) {
const newSearch = getNextSortOrder(column, searchParams);
@@ -691,7 +618,6 @@ export default function PrivateResourcesTable({
open={isCreateDialogOpen}
setOpen={setIsCreateDialogOpen}
orgId={orgId}
initialSites={createInitialSites}
onSuccess={() => {
// Delay refresh to allow modal to close smoothly
setTimeout(() => {
+22 -101
View File
@@ -76,6 +76,7 @@ import { useLocalLabels } from "@app/hooks/useLocalLabels";
import { LabelsTableCell } from "./LabelsTableCell";
import { useOptimisticLabels } from "@app/hooks/useOptimisticLabels";
import { refresh } from "next/cache";
import { SitesColumnFilterButton } from "./SitesColumnFilterButton";
export type TargetHealth = {
targetId: number;
@@ -154,30 +155,6 @@ export default function PublicResourcesTable({
const [isRefreshing, startTransition] = useTransition();
const [isNavigatingToAddPage, startNavigation] = useTransition();
const [siteFilterOpen, setSiteFilterOpen] = useState(false);
const siteIdQ = searchParams.get("siteId");
const siteIdNum = siteIdQ ? parseInt(siteIdQ, 10) : NaN;
const selectedSite: Selectedsite | null = useMemo(() => {
if (!siteIdQ || !Number.isInteger(siteIdNum) || siteIdNum <= 0) {
return null;
}
if (initialFilterSite && initialFilterSite.siteId === siteIdNum) {
return initialFilterSite;
}
return {
siteId: siteIdNum,
name: t("standaloneHcFilterSiteIdFallback", { id: siteIdNum }),
type: "newt"
};
}, [initialFilterSite, siteIdQ, siteIdNum, t]);
// useEffect(() => {
// const interval = setInterval(() => {
// router.refresh();
// }, 30_000);
// return () => clearInterval(interval);
// }, [router]);
const refreshData = () => {
startTransition(() => {
@@ -227,28 +204,6 @@ export default function PublicResourcesTable({
}
}
const clearSiteFilter = () => {
handleFilterChange("siteId", undefined);
setSiteFilterOpen(false);
};
const onPickSite = (site: Selectedsite) => {
handleFilterChange("siteId", String(site.siteId));
setSiteFilterOpen(false);
};
const siteFilterOpenRef = useRef(siteFilterOpen);
siteFilterOpenRef.current = siteFilterOpen;
const selectedSiteRef = useRef(selectedSite);
selectedSiteRef.current = selectedSite;
const clearSiteFilterRef = useRef(clearSiteFilter);
clearSiteFilterRef.current = clearSiteFilter;
const onPickSiteRef = useRef(onPickSite);
onPickSiteRef.current = onPickSite;
const proxyColumns = useMemo<ExtendedColumnDef<ResourceRow>[]>(() => {
const cols: ExtendedColumnDef<ResourceRow>[] = [
{
@@ -291,61 +246,27 @@ export default function PublicResourcesTable({
accessorFn: (row) =>
row.sites.map((s) => s.siteName).join(", "),
friendlyName: t("sites"),
header: () => (
<Popover
open={siteFilterOpenRef.current}
onOpenChange={setSiteFilterOpen}
>
<PopoverTrigger asChild>
<Button
type="button"
variant="ghost"
role="combobox"
className={cn(
"justify-between text-sm h-8 px-2 w-full p-3",
!selectedSiteRef.current &&
"text-muted-foreground"
)}
>
<div className="flex items-center gap-2 min-w-0">
{t("sites")}
<Funnel className="size-4 flex-none" />
{selectedSiteRef.current && (
<Badge
className="truncate max-w-[10rem]"
variant="secondary"
>
{selectedSiteRef.current.name}
</Badge>
)}
</div>
</Button>
</PopoverTrigger>
<PopoverContent
className={dataTableFilterPopoverContentClassName}
align="start"
>
<div className="border-b p-1">
<Button
type="button"
variant="ghost"
size="sm"
className="h-8 w-full justify-start font-normal"
onClick={() => clearSiteFilterRef.current()}
>
{t("standaloneHcFilterAnySite")}
</Button>
</div>
<SitesSelector
orgId={orgId}
selectedSite={selectedSiteRef.current}
onSelectSite={(site) =>
onPickSiteRef.current(site)
}
/>
</PopoverContent>
</Popover>
),
header: () => {
const siteIdQ = searchParams.get("siteId");
const siteIdNum = siteIdQ ? parseInt(siteIdQ, 10) : NaN;
const selectedSiteId =
!siteIdQ ||
!Number.isInteger(siteIdNum) ||
siteIdNum <= 0
? null
: siteIdNum;
return (
<SitesColumnFilterButton
selectedSiteId={selectedSiteId}
onValueChange={(value) =>
handleFilterChange("siteId", value?.toString())
}
orgId={orgId}
/>
);
},
cell: ({ row }) => (
<ResourceSitesStatusCell
orgId={row.original.orgId}
+21
View File
@@ -0,0 +1,21 @@
"use client";
import { Shield } from "lucide-react";
import { useTranslations } from "next-intl";
import DismissableBanner from "./DismissableBanner";
export const ResourcePoliciesBanner = () => {
const t = useTranslations();
return (
<DismissableBanner
storageKey="resource-policies-banner-dismissed"
version={1}
title={t("resourcePoliciesBannerTitle")}
titleIcon={<Shield className="w-5 h-5 text-primary" />}
description={t("resourcePoliciesBannerDescription")}
/>
);
};
export default ResourcePoliciesBanner;
+1
View File
@@ -283,6 +283,7 @@ export function ResourcePoliciesTable({
searchPlaceholder={t("resourcePoliciesSearch")}
pagination={pagination}
rowCount={rowCount}
searchQuery={searchParams.get("query")?.toString()}
onSearch={handleSearchChange}
onPaginationChange={handlePaginationChange}
onAdd={() =>
+192 -13
View File
@@ -10,14 +10,25 @@ import {
FormMessage
} from "@app/components/ui/form";
import { Input } from "@app/components/ui/input";
import { Textarea } from "@app/components/ui/textarea";
import {
OptionSelect,
type OptionSelectOption
} from "@app/components/OptionSelect";
import { TextFileImportDialog } from "@app/components/TextFileImportDialog";
import { useEnvContext } from "@app/hooks/useEnvContext";
import { usePaidStatus } from "@app/hooks/usePaidStatus";
import { toast } from "@app/hooks/useToast";
import { cn } from "@app/lib/cn";
import {
getTextImportFileType,
isSupportedTextImportFile,
parseTextFileItems,
readFileAsText,
type TextImportFileType
} from "@app/lib/roleFormTextImport";
import { useTranslations } from "next-intl";
import { useEffect } from "react";
import { useEffect, useState } from "react";
import { useForm } from "react-hook-form";
import { z } from "zod";
import { zodResolver } from "@hookform/resolvers/zod";
@@ -46,15 +57,34 @@ function toSshSudoMode(value: string | null | undefined): SshSudoMode {
return "none";
}
function hasOnlyAbsoluteSudoCommands(value: string | undefined): boolean {
if (!value?.trim()) return true;
export function parseUnixGroups(value: string | undefined): string[] {
if (!value?.trim()) return [];
const commands = value
.split(",")
.map((command) => command.trim())
return value
.split(/[,\s\n]+/)
.map((group) => group.trim())
.filter(Boolean);
}
return commands.every((command) => {
export function parseSudoCommands(value: string | undefined): string[] {
if (!value?.trim()) return [];
const commands: string[] = [];
for (const segment of value.split(/[,\n]+/)) {
const trimmed = segment.trim();
if (!trimmed) continue;
for (const part of trimmed.split(/ (?=\/)/)) {
const command = part.trim();
if (command) commands.push(command);
}
}
return commands;
}
function hasOnlyAbsoluteSudoCommands(value: string | undefined): boolean {
return parseSudoCommands(value).every((command) => {
const executable = command.split(/\s+/)[0];
return executable.startsWith("/");
});
@@ -78,6 +108,15 @@ type RoleFormProps = {
formId?: string;
};
type RoleTextImportField = "sshSudoCommands" | "sshUnixGroups";
type PendingTextImport = {
field: RoleTextImportField;
fileName: string;
fileType: TextImportFileType;
rawContent: string;
};
export function RoleForm({
variant,
role,
@@ -125,10 +164,10 @@ export function RoleForm({
(role as Role & { allowSsh?: boolean }).allowSsh ?? false,
sshSudoMode: toSshSudoMode(role.sshSudoMode),
sshSudoCommands: parseRoleJsonArray(role.sshSudoCommands).join(
", "
"\n"
),
sshCreateHomeDir: role.sshCreateHomeDir ?? false,
sshUnixGroups: parseRoleJsonArray(role.sshUnixGroups).join(", ")
sshUnixGroups: parseRoleJsonArray(role.sshUnixGroups).join("\n")
}
: {
name: "",
@@ -156,10 +195,10 @@ export function RoleForm({
(role as Role & { allowSsh?: boolean }).allowSsh ?? false,
sshSudoMode: toSshSudoMode(role.sshSudoMode),
sshSudoCommands: parseRoleJsonArray(role.sshSudoCommands).join(
", "
"\n"
),
sshCreateHomeDir: role.sshCreateHomeDir ?? false,
sshUnixGroups: parseRoleJsonArray(role.sshUnixGroups).join(", ")
sshUnixGroups: parseRoleJsonArray(role.sshUnixGroups).join("\n")
});
}
}, [variant, role, form]);
@@ -167,6 +206,10 @@ export function RoleForm({
const sshDisabled = !isPaidUser(tierMatrix.advancedPrivateResources);
const sshSudoMode = form.watch("sshSudoMode");
const isAdminRole = variant === "edit" && role?.isAdmin === true;
const [pendingImport, setPendingImport] =
useState<PendingTextImport | null>(null);
const [dragOverField, setDragOverField] =
useState<RoleTextImportField | null>(null);
useEffect(() => {
if (sshDisabled) {
@@ -174,6 +217,78 @@ export function RoleForm({
}
}, [sshDisabled, form]);
async function handleFileDrop(
file: File,
field: RoleTextImportField
): Promise<void> {
if (!isSupportedTextImportFile(file)) {
toast({
variant: "destructive",
title: t("roleTextImportInvalidFile"),
description: t("roleTextImportInvalidFileDescription")
});
return;
}
const fileType = getTextImportFileType(file);
if (!fileType) return;
const rawContent = await readFileAsText(file);
const parser =
field === "sshSudoCommands" ? parseSudoCommands : parseUnixGroups;
const items = parseTextFileItems({
content: rawContent,
fileType,
skipHeader: false,
parser
});
if (items.length === 0) {
toast({
variant: "destructive",
title: t("roleTextImportEmpty"),
description: t("roleTextImportEmptyDescription")
});
return;
}
setPendingImport({
field,
fileName: file.name,
fileType,
rawContent
});
}
function getTextImportDropHandlers(field: RoleTextImportField) {
return {
onDragOver: (event: React.DragEvent<HTMLTextAreaElement>) => {
event.preventDefault();
event.stopPropagation();
if (!sshDisabled) {
setDragOverField(field);
}
},
onDragLeave: (event: React.DragEvent<HTMLTextAreaElement>) => {
event.preventDefault();
setDragOverField((current) =>
current === field ? null : current
);
},
onDrop: (event: React.DragEvent<HTMLTextAreaElement>) => {
event.preventDefault();
event.stopPropagation();
setDragOverField(null);
if (sshDisabled) return;
const file = event.dataTransfer.files[0];
if (file) {
void handleFileDrop(file, field);
}
}
};
}
return (
<Form {...form}>
<form
@@ -421,9 +536,25 @@ export function RoleForm({
{t("sshSudoCommands")}
</FormLabel>
<FormControl>
<Input
<Textarea
{...field}
{...getTextImportDropHandlers(
"sshSudoCommands"
)}
placeholder={
sshDisabled
? undefined
: t(
"roleTextFieldPlaceholder"
)
}
disabled={sshDisabled}
className={cn(
"h-20 min-h-20",
dragOverField ===
"sshSudoCommands" &&
"border-primary"
)}
/>
</FormControl>
<FormDescription>
@@ -446,9 +577,25 @@ export function RoleForm({
{t("sshUnixGroups")}
</FormLabel>
<FormControl>
<Input
<Textarea
{...field}
{...getTextImportDropHandlers(
"sshUnixGroups"
)}
placeholder={
sshDisabled
? undefined
: t(
"roleTextFieldPlaceholder"
)
}
disabled={sshDisabled}
className={cn(
"h-20 min-h-20",
dragOverField ===
"sshUnixGroups" &&
"border-primary"
)}
/>
</FormControl>
<FormDescription>
@@ -499,6 +646,38 @@ export function RoleForm({
</HorizontalTabs>
)}
</form>
{pendingImport && (
<TextFileImportDialog
key={`${pendingImport.field}-${pendingImport.fileName}`}
open={true}
onOpenChange={(open) => {
if (!open) {
setPendingImport(null);
}
}}
fileName={pendingImport.fileName}
fileType={pendingImport.fileType}
rawContent={pendingImport.rawContent}
currentValue={form.watch(pendingImport.field) ?? ""}
fieldLabel={
pendingImport.field === "sshSudoCommands"
? t("sshSudoCommands")
: t("sshUnixGroups")
}
parser={
pendingImport.field === "sshSudoCommands"
? parseSudoCommands
: parseUnixGroups
}
onConfirm={(value) => {
form.setValue(pendingImport.field, value, {
shouldDirty: true,
shouldValidate: true
});
setPendingImport(null);
}}
/>
)}
</Form>
);
}
+43
View File
@@ -43,6 +43,49 @@ export function SettingsSectionForm({
);
}
export function SettingsFormGrid({
children,
className
}: {
children: React.ReactNode;
className?: string;
}) {
return (
<div
className={cn(
"grid grid-cols-1 md:grid-cols-4 gap-4 items-start",
className
)}
>
{children}
</div>
);
}
export function SettingsFormCell({
children,
span = "half",
className
}: {
children: React.ReactNode;
span?: "quarter" | "half" | "full";
className?: string;
}) {
return (
<div
className={cn(
"min-w-0",
span === "quarter" && "md:col-span-1",
span === "half" && "md:col-span-2",
span === "full" && "md:col-span-4",
className
)}
>
{children}
</div>
);
}
export function SettingsSectionTitle({
children
}: {
+160
View File
@@ -0,0 +1,160 @@
import { useMemo, useState } from "react";
import { Popover, PopoverContent, PopoverTrigger } from "./ui/popover";
import { cn } from "@app/lib/cn";
import { dataTableFilterPopoverContentClassName } from "@app/lib/dataTableFilterPopover";
import { CheckIcon, Funnel } from "lucide-react";
import { SiteOnlineStatus, type Selectedsite } from "./site-selector";
import { Button } from "./ui/button";
import { useTranslations } from "next-intl";
import { Badge } from "./ui/badge";
import { orgQueries } from "@app/lib/queries";
import { useQuery } from "@tanstack/react-query";
import { useDebounce } from "use-debounce";
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList
} from "./ui/command";
export type SitesColumnFilterButtonProps = {
selectedSiteId: number | null;
onValueChange: (value: number | undefined) => void;
orgId: string;
};
export function SitesColumnFilterButton({
selectedSiteId,
onValueChange,
orgId
}: SitesColumnFilterButtonProps) {
const [open, setOpen] = useState(false);
const t = useTranslations();
const [siteSearchQuery, setSiteSearchQuery] = useState("");
const [debouncedQuery] = useDebounce(siteSearchQuery, 150);
const { data: sites = [] } = useQuery(
orgQueries.sites({
orgId,
query: debouncedQuery,
perPage: 500
})
);
const selectedSite = useMemo(() => {
let selected = undefined;
if (selectedSiteId) {
selected = sites.find((site) => site.siteId === selectedSiteId) ?? {
siteId: Number(selectedSiteId),
name: t("standaloneHcFilterSiteIdFallback", {
id: Number(selectedSiteId)
}),
type: "newt"
};
}
return selected;
}, [selectedSiteId, sites]);
// always include the selected site in the list of sites shown
const sitesShown = useMemo(() => {
const allSites: Array<Selectedsite> = [...sites];
if (
debouncedQuery.trim().length === 0 &&
selectedSite &&
!allSites.find((site) => site.siteId === selectedSite?.siteId)
) {
allSites.unshift(selectedSite);
}
return allSites;
}, [debouncedQuery, sites, selectedSite]);
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
type="button"
variant="ghost"
role="combobox"
className={cn(
"justify-between text-sm h-8 px-2 w-full p-3",
selectedSite && "text-muted-foreground"
)}
>
<div className="flex items-center gap-2 min-w-0">
{t("sites")}
<Funnel className="size-4 flex-none" />
{selectedSite && (
<Badge
className="truncate max-w-40"
variant="secondary"
>
{selectedSite.name}
</Badge>
)}
</div>
</Button>
</PopoverTrigger>
<PopoverContent
className={dataTableFilterPopoverContentClassName}
align="start"
>
<Command shouldFilter={false}>
<CommandInput
placeholder={t("siteSearch")}
value={siteSearchQuery}
onValueChange={(v) => setSiteSearchQuery(v)}
/>
<CommandList>
<CommandEmpty>{t("siteNotFound")}</CommandEmpty>
<CommandGroup>
{selectedSite && (
<CommandItem
onSelect={() => {
onValueChange(undefined);
}}
className="text-muted-foreground"
>
{t("accessFilterClear")}
</CommandItem>
)}
{sitesShown.map((site) => (
<CommandItem
key={site.siteId}
value={`${site.siteId}:${site.name}`}
onSelect={() => {
onValueChange(site.siteId);
}}
>
<CheckIcon
className={cn(
"mr-2 h-4 w-4",
site.siteId === selectedSite?.siteId
? "opacity-100"
: "opacity-0"
)}
/>
<div className="min-w-0 flex-1 flex items-center gap-2">
<span className="min-w-0 flex-1 truncate">
{site.name}
</span>
{site.online != null && (
<SiteOnlineStatus
type={site.type}
online={site.online}
/>
)}
</div>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
);
}
+189
View File
@@ -0,0 +1,189 @@
"use client";
import {
Credenza,
CredenzaBody,
CredenzaClose,
CredenzaContent,
CredenzaDescription,
CredenzaFooter,
CredenzaHeader,
CredenzaTitle
} from "@app/components/Credenza";
import {
OptionSelect,
type OptionSelectOption
} from "@app/components/OptionSelect";
import { Button } from "@app/components/ui/button";
import { CheckboxWithLabel } from "@app/components/ui/checkbox";
import { Textarea } from "@app/components/ui/textarea";
import {
applyTextImport,
parsePreviewLines,
parseTextFileItems,
type TextImportFileType,
type TextImportMode
} from "@app/lib/roleFormTextImport";
import { useTranslations } from "next-intl";
import { useEffect, useMemo, useState } from "react";
type TextFileImportDialogProps = {
open: boolean;
onOpenChange: (open: boolean) => void;
fileName: string;
fileType: TextImportFileType;
rawContent: string;
currentValue: string;
fieldLabel: string;
parser: (value: string | undefined) => string[];
onConfirm: (value: string) => void;
};
export function TextFileImportDialog({
open,
onOpenChange,
fileName,
fileType,
rawContent,
currentValue,
fieldLabel,
parser,
onConfirm
}: TextFileImportDialogProps) {
const t = useTranslations();
const [editablePreview, setEditablePreview] = useState("");
const [skipHeader, setSkipHeader] = useState(false);
const [mode, setMode] = useState<TextImportMode>("override");
const parsedFromFile = useMemo(
() =>
parseTextFileItems({
content: rawContent,
fileType,
skipHeader,
parser
}),
[rawContent, fileType, skipHeader, parser]
);
useEffect(() => {
setEditablePreview(parsedFromFile.join("\n"));
}, [parsedFromFile]);
const importedItems = useMemo(
() => parsePreviewLines(editablePreview),
[editablePreview]
);
const existingCount = useMemo(
() => parser(currentValue).length,
[currentValue, parser]
);
const totalCount =
mode === "append"
? existingCount + importedItems.length
: importedItems.length;
const modeOptions: OptionSelectOption<TextImportMode>[] = [
{
value: "override",
label: t("roleTextImportOverride")
},
{
value: "append",
label: t("roleTextImportAppend")
}
];
function handleConfirm() {
onConfirm(
applyTextImport({
currentValue,
imported: importedItems,
mode,
parser
})
);
onOpenChange(false);
}
return (
<Credenza open={open} onOpenChange={onOpenChange}>
<CredenzaContent>
<CredenzaHeader>
<CredenzaTitle>{t("roleTextImportTitle")}</CredenzaTitle>
<CredenzaDescription>
{t("roleTextImportDescription", {
fileName,
fieldLabel
})}
</CredenzaDescription>
</CredenzaHeader>
<CredenzaBody>
{fileType === "csv" && (
<CheckboxWithLabel
checked={skipHeader}
onCheckedChange={(checked) => {
if (checked !== "indeterminate") {
setSkipHeader(checked);
}
}}
label={t("roleTextImportSkipHeader")}
/>
)}
<div className="space-y-2">
<p className="text-sm font-medium">
{t("roleTextImportPreview")}
</p>
<Textarea
value={editablePreview}
onChange={(event) =>
setEditablePreview(event.target.value)
}
placeholder={t("roleTextImportEmpty")}
className="min-h-32 text-sm"
/>
</div>
<OptionSelect<TextImportMode>
label={t("roleTextImportMode")}
options={modeOptions}
value={mode}
onChange={setMode}
cols={2}
/>
<p className="text-sm text-muted-foreground">
{mode === "append"
? t("roleTextImportTotalCount", {
existing: existingCount,
imported: importedItems.length,
total: totalCount
})
: t("roleTextImportItemCount", {
count: importedItems.length
})}
</p>
</CredenzaBody>
<CredenzaFooter>
<CredenzaClose asChild>
<Button type="button" variant="outline">
{t("close")}
</Button>
</CredenzaClose>
<Button
type="button"
onClick={handleConfirm}
disabled={importedItems.length === 0}
>
{t("roleTextImportConfirm")}
</Button>
</CredenzaFooter>
</CredenzaContent>
</Credenza>
);
}
+14 -6
View File
@@ -112,12 +112,20 @@ export function LabelsSelector({
},
"attach"
);
} catch (e) {
toast({
title: t("error"),
description: formatAxiosError(e, t("errorOccurred")),
variant: "destructive"
});
} catch (e: any) {
if (e.response?.status === 409) {
toast({
title: t("labelDuplicateError"),
description: t("labelDuplicateErrorDescription"),
variant: "destructive"
});
} else {
toast({
title: t("error"),
description: formatAxiosError(e, t("errorOccurred")),
variant: "destructive"
});
}
}
setlabelsSearchQuery("");
}
-1
View File
@@ -115,7 +115,6 @@ export function MultiSitesSelector({
<SiteOnlineStatus
type={site.type}
online={site.online}
t={t}
/>
)}
</div>
@@ -12,11 +12,9 @@ import {
import { useEnvContext } from "@app/hooks/useEnvContext";
import { useOrgContext } from "@app/hooks/useOrgContext";
import { usePaidStatus } from "@app/hooks/usePaidStatus";
import { getUserDisplayName } from "@app/lib/getUserDisplayName";
import { orgQueries } from "@app/lib/queries";
import { zodResolver } from "@hookform/resolvers/zod";
import { build } from "@server/build";
import { UserType } from "@server/types/UserTypes";
import { useQuery } from "@tanstack/react-query";
import { useTranslations } from "next-intl";
import {
@@ -67,12 +65,6 @@ export function CreatePolicyForm({}: CreatePolicyFormProps) {
env.server.maxmind_asn_path && env.server.maxmind_asn_path.length > 0
);
const { data: orgRoles = [], isLoading: isLoadingOrgRoles } = useQuery(
orgQueries.roles({ orgId: org.org.orgId })
);
const { data: orgUsers = [], isLoading: isLoadingOrgUsers } = useQuery(
orgQueries.users({ orgId: org.org.orgId })
);
const { data: orgIdps = [], isLoading: isLoadingOrgIdps } = useQuery(
orgQueries.identityProviders({
orgId: org.org.orgId,
@@ -147,7 +139,7 @@ export function CreatePolicyForm({}: CreatePolicyFormProps) {
if (res && res.status === 201) {
const niceId = res.data.data.niceId;
router.push(
`/${org.org.orgId}/settings/policies/resources/public/${niceId}`
`/${org.org.orgId}/settings/policies/resources/public/${niceId}/general`
);
toast({
title: t("success"),
@@ -163,26 +155,6 @@ export function CreatePolicyForm({}: CreatePolicyFormProps) {
}
}
const allRoles = useMemo(
() =>
orgRoles
.map((role) => ({
id: role.roleId.toString(),
text: role.name
}))
.filter((role) => role.text !== "Admin"),
[orgRoles]
);
const allUsers = useMemo(
() =>
orgUsers.map((user) => ({
id: user.id.toString(),
text: `${getUserDisplayName({ email: user.email, username: user.username })}${user.type !== UserType.Internal ? ` (${user.idpName})` : ""}`
})),
[orgUsers]
);
const allIdps = useMemo(() => {
if (build === "saas") {
if (isPaidUser(tierMatrix.orgOidc)) {
@@ -197,7 +169,7 @@ export function CreatePolicyForm({}: CreatePolicyFormProps) {
return [];
}, [orgIdps, isPaidUser]);
if (isLoadingOrgRoles || isLoadingOrgUsers || isLoadingOrgIdps) {
if (isLoadingOrgIdps) {
return <></>;
}
@@ -227,7 +199,7 @@ export function CreatePolicyForm({}: CreatePolicyFormProps) {
</SettingsSectionDescription>
</SettingsSectionHeader>
<SettingsSectionBody>
<SettingsSectionForm>
<SettingsSectionForm variant="half">
<FormField
control={form.control}
name="name"
@@ -237,12 +209,7 @@ export function CreatePolicyForm({}: CreatePolicyFormProps) {
{t("name")}
</FormLabel>
<FormControl>
<Input
{...field}
placeholder={t(
"resourcePolicyNamePlaceholder"
)}
/>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
@@ -257,8 +224,6 @@ export function CreatePolicyForm({}: CreatePolicyFormProps) {
form={form}
orgId={org.org.orgId}
allIdps={allIdps}
allRoles={allRoles}
allUsers={allUsers}
emailEnabled={env.email.emailEnabled}
/>
<PolicyAccessRulesSection
@@ -1,169 +0,0 @@
"use client";
import {
SettingsSection,
SettingsSectionBody,
SettingsSectionDescription,
SettingsSectionHeader,
SettingsSectionTitle
} from "@app/components/Settings";
import { zodResolver } from "@hookform/resolvers/zod";
import { useTranslations } from "next-intl";
import { createPolicyRulesSectionSchema, type PolicyFormValues } from ".";
import { Button } from "@app/components/ui/button";
import { Plus } from "lucide-react";
import { useCallback, useEffect, useMemo, useState } from "react";
import { type UseFormReturn, useForm, useWatch } from "react-hook-form";
import { PolicyAccessRulesIntro } from "./PolicyAccessRulesIntro";
import { PolicyAccessRulesTable } from "./PolicyAccessRulesTable";
import {
createEmptyRule,
type PolicyAccessRule
} from "./policy-access-rule-utils";
export type CreatePolicyRulesSectionFormProps = {
form: UseFormReturn<PolicyFormValues, any, any>;
isMaxmindAvailable: boolean;
isMaxmindAsnAvailable: boolean;
};
export function CreatePolicyRulesSectionForm({
form: parentForm,
isMaxmindAvailable,
isMaxmindAsnAvailable
}: CreatePolicyRulesSectionFormProps) {
const t = useTranslations();
const [rules, setRules] = useState<PolicyAccessRule[]>([]);
const rulesFormSchema = useMemo(
() => createPolicyRulesSectionSchema(t),
[t]
);
const form = useForm({
resolver: zodResolver(rulesFormSchema),
defaultValues: {
applyRules: false,
rules: []
}
});
useEffect(() => {
const subscription = form.watch((values) => {
parentForm.setValue("applyRules", values.applyRules as boolean);
parentForm.setValue("rules", values.rules as any);
});
return () => subscription.unsubscribe();
}, [form, parentForm]);
const rulesEnabled = useWatch({
control: form.control,
name: "applyRules"
});
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 updatedRules = rules.filter((rule) => rule.ruleId !== ruleId);
setRules(updatedRules);
syncFormRules(updatedRules);
},
[rules, syncFormRules]
);
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 addRuleButton = (
<Button type="button" variant="outline" 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="flex flex-col gap-y-6 pb-20">
<PolicyAccessRulesIntro
rulesEnabled={Boolean(rulesEnabled)}
onRulesEnabledChange={(val) => {
form.setValue("applyRules", val);
}}
/>
{rulesEnabled && (
<>
<PolicyAccessRulesTable
rules={rules}
onRulesChange={handleRulesChange}
updateRule={updateRule}
removeRule={removeRule}
isMaxmindAvailable={isMaxmindAvailable}
isMaxmindAsnAvailable={isMaxmindAsnAvailable}
includeRegionMatch={false}
emptyStateAction={addRuleButton}
/>
{hasRules && addRuleButton}
</>
)}
</div>
</SettingsSectionBody>
</SettingsSection>
);
}
@@ -10,26 +10,27 @@ import { orgQueries } from "@app/lib/queries";
import { build } from "@server/build";
import { tierMatrix } from "@server/lib/billing/tierMatrix";
import { useQuery } from "@tanstack/react-query";
import { useTranslations } from "next-intl";
import { useMemo } from "react";
import { HorizontalTabs } from "@app/components/HorizontalTabs";
import { EditPolicyNameSectionForm } from "./EditPolicyNameSectionForm";
import { PolicyAuthStackSection } from "./PolicyAuthStackSection";
import { PolicyAccessRulesSection } from "./PolicyAccessRulesSection";
export type EditPolicyFormSection = "general" | "authentication" | "rules";
export type EditPolicyFormProps = {
hidePolicyNameForm?: boolean;
readonly?: boolean;
resourceId?: number;
section?: EditPolicyFormSection;
};
export function EditPolicyForm({
hidePolicyNameForm,
readonly,
resourceId
resourceId,
section
}: EditPolicyFormProps) {
const t = useTranslations();
const { org } = useOrgContext();
const { env } = useEnvContext();
const { isPaidUser } = usePaidStatus();
@@ -37,7 +38,6 @@ export function EditPolicyForm({
// 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 showTabs = !hidePolicyNameForm && !isOverlay;
const isMaxmindAvailable = !!(
env.server.maxmind_db_path && env.server.maxmind_db_path.length > 0
@@ -92,22 +92,16 @@ export function EditPolicyForm({
/>
);
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>
);
if (section === "general") {
return <EditPolicyNameSectionForm readonly={readonly} />;
}
if (section === "authentication") {
return authSection;
}
if (section === "rules") {
return rulesSection;
}
return (
@@ -109,7 +109,7 @@ export function EditPolicyNameSectionForm({
if (payload.niceId && payload.niceId !== policy.niceId) {
router.replace(
`/${org.org.orgId}/settings/policies/resources/public/${payload.niceId}`
`/${org.org.orgId}/settings/policies/resources/public/${payload.niceId}/general`
);
}
@@ -15,9 +15,11 @@ import { useTranslations } from "next-intl";
import { toast } from "@app/hooks/useToast";
import {
createPolicyRulesSectionSchema,
type PolicyRuleMatchType,
validatePolicyRulesForSave,
type PolicyFormValues
} from ".";
import { POLICY_RULE_MATCH_TYPES } from "./policy-access-rule-validation";
import { Button } from "@app/components/ui/button";
import { Plus } from "lucide-react";
@@ -28,7 +30,8 @@ import {
useMemo,
useRef,
useState,
useTransition
useTransition,
type ReactNode
} from "react";
import { UseFormReturn, useForm, useWatch } from "react-hook-form";
import { useResourcePolicyContext } from "@app/providers/ResourcePolicyProvider";
@@ -38,11 +41,12 @@ 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 { SharedPolicyResourceNotice } from "./SharedPolicyResourceNotice";
import {
createEmptyRule,
prependEmptyRule,
type PolicyAccessRule
} from "./policy-access-rule-utils";
@@ -67,6 +71,12 @@ export type PolicyAccessRulesSectionProps =
| PolicyAccessRulesSectionEditProps
| PolicyAccessRulesSectionCreateProps;
const POLICY_RULE_MATCH_SET = new Set<string>(POLICY_RULE_MATCH_TYPES);
function isPolicyRuleMatchType(value: string): value is PolicyRuleMatchType {
return POLICY_RULE_MATCH_SET.has(value);
}
export function PolicyAccessRulesSection(props: PolicyAccessRulesSectionProps) {
if (props.mode === "create") {
return <PolicyAccessRulesSectionCreate {...props} />;
@@ -74,6 +84,143 @@ export function PolicyAccessRulesSection(props: PolicyAccessRulesSectionProps) {
return <PolicyAccessRulesSectionEdit {...props} />;
}
type PolicyAccessRulesSectionLayoutProps = {
rulesEnabled: boolean;
onRulesEnabledChange: (enabled: boolean) => void;
disableToggle?: boolean;
rules: PolicyAccessRule[];
onRulesChange: (rules: PolicyAccessRule[]) => void;
updateRule: (ruleId: number, data: Partial<PolicyAccessRule>) => void;
removeRule: (ruleId: number) => void;
readonly?: boolean;
isMaxmindAvailable: boolean;
isMaxmindAsnAvailable: boolean;
resourceOverlayMode?: boolean;
footer?: ReactNode;
};
function PolicyAccessRulesSectionLayout({
rulesEnabled,
onRulesEnabledChange,
disableToggle,
rules,
onRulesChange,
updateRule,
removeRule,
readonly,
isMaxmindAvailable,
isMaxmindAsnAvailable,
resourceOverlayMode,
footer
}: PolicyAccessRulesSectionLayoutProps) {
const t = useTranslations();
const addEmptyRule = useCallback(() => {
if (resourceOverlayMode) {
onRulesChange(prependEmptyRule(rules));
return;
}
onRulesChange([...rules, createEmptyRule(rules)]);
}, [rules, onRulesChange, resourceOverlayMode]);
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-4">
{resourceOverlayMode && (
<SharedPolicyResourceNotice section="rules" />
)}
<PolicyAccessRulesIntro
rulesEnabled={rulesEnabled}
onRulesEnabledChange={onRulesEnabledChange}
disableToggle={disableToggle}
/>
{rulesEnabled && (
<>
<PolicyAccessRulesTable
rules={rules}
onRulesChange={onRulesChange}
updateRule={updateRule}
removeRule={removeRule}
readonly={readonly}
isMaxmindAvailable={isMaxmindAvailable}
isMaxmindAsnAvailable={isMaxmindAsnAvailable}
includeRegionMatch
markUpdatedOnReorder
resourceOverlayMode={resourceOverlayMode}
emptyStateAction={addRuleButton}
/>
{hasRules && addRuleButton}
</>
)}
</div>
</SettingsSectionBody>
{footer}
</SettingsSection>
);
}
function usePolicyAccessRulesFormSync(
form: UseFormReturn<{
applyRules: boolean;
rules: PolicyFormValues["rules"];
}>
) {
const syncFormRules = useCallback(
(updatedRules: PolicyAccessRule[]) => {
form.setValue(
"rules",
updatedRules.map(
({ action, match, value, priority, enabled }) => ({
action,
match,
value,
priority,
enabled
})
)
);
},
[form]
);
const updateRulesState = useCallback(
(
setRules: React.Dispatch<React.SetStateAction<PolicyAccessRule[]>>,
updatedRules: PolicyAccessRule[]
) => {
setRules(updatedRules);
syncFormRules(updatedRules);
},
[syncFormRules]
);
return { syncFormRules, updateRulesState };
}
function PolicyAccessRulesSectionEdit({
isMaxmindAvailable,
isMaxmindAsnAvailable,
@@ -119,6 +266,8 @@ function PolicyAccessRulesSectionEdit({
policy.rules.map((r) => ({ ...r, fromPolicy: isResourceOverlay }))
);
const { updateRulesState } = usePolicyAccessRulesFormSync(form);
useEffect(() => {
if (!isResourceOverlay || resourceRulesInitialized) return;
if (!resourceRulesData) return;
@@ -129,7 +278,7 @@ function PolicyAccessRulesSectionEdit({
.map((r) => ({
ruleId: r.ruleId,
action: r.action as "ACCEPT" | "DROP" | "PASS",
match: r.match,
match: isPolicyRuleMatchType(r.match) ? r.match : "PATH",
value: r.value,
priority: r.priority,
enabled: r.enabled,
@@ -148,30 +297,13 @@ function PolicyAccessRulesSectionEdit({
policy.rules
]);
const syncFormRules = useCallback(
const handleRulesChange = useCallback(
(updatedRules: PolicyAccessRule[]) => {
form.setValue(
"rules",
updatedRules.map(
({ action, match, value, priority, enabled }) => ({
action,
match,
value,
priority,
enabled
})
)
);
updateRulesState(setRules, updatedRules);
},
[form]
[updateRulesState]
);
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);
@@ -179,32 +311,22 @@ function PolicyAccessRulesSectionEdit({
if (isResourceOverlay && !rule.new) {
deletedResourceRuleIdsRef.current.add(ruleId);
}
const updatedRules = rules.filter((rule) => rule.ruleId !== ruleId);
setRules(updatedRules);
syncFormRules(updatedRules);
handleRulesChange(rules.filter((rule) => rule.ruleId !== ruleId));
},
[rules, syncFormRules, isResourceOverlay]
[rules, handleRulesChange, isResourceOverlay]
);
const updateRule = useCallback(
function updateRule(ruleId: number, data: Partial<PolicyAccessRule>) {
const updatedRules = rules.map((rule) =>
rule.ruleId === ruleId
? { ...rule, ...data, updated: true }
: rule
handleRulesChange(
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]
[rules, handleRulesChange]
);
const [isPending, startTransition] = useTransition();
@@ -213,7 +335,10 @@ function PolicyAccessRulesSectionEdit({
if (readonly) return;
const applyRules = form.getValues("applyRules") ?? false;
const rulesPayload = rules.map(
const rulesToValidate = isResourceOverlay
? rules.filter((rule) => !rule.fromPolicy)
: rules;
const rulesPayload = rulesToValidate.map(
({ action, match, value, priority, enabled }) => ({
action,
match,
@@ -331,80 +456,112 @@ function PolicyAccessRulesSectionEdit({
}
}
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>
<PolicyAccessRulesSectionLayout
rulesEnabled={Boolean(rulesEnabled)}
onRulesEnabledChange={(val) => {
form.setValue("applyRules", val);
}}
disableToggle={readonly || isResourceOverlay}
rules={rules}
onRulesChange={handleRulesChange}
updateRule={updateRule}
removeRule={removeRule}
readonly={readonly}
isMaxmindAvailable={isMaxmindAvailable}
isMaxmindAsnAvailable={isMaxmindAsnAvailable}
resourceOverlayMode={isResourceOverlay}
footer={
<SettingsSectionFooter>
<Button
onClick={() => startTransition(() => saveRules())}
loading={isPending}
disabled={readonly || isPending}
>
{t("saveSettings")}
</Button>
</SettingsSectionFooter>
}
/>
);
}
function PolicyAccessRulesSectionCreate({
form,
form: parentForm,
isMaxmindAvailable,
isMaxmindAsnAvailable
}: PolicyAccessRulesSectionCreateProps) {
const t = useTranslations();
const [rules, setRules] = useState<PolicyAccessRule[]>([]);
const rulesFormSchema = useMemo(
() => createPolicyRulesSectionSchema(t),
[t]
);
const form = useForm({
resolver: zodResolver(rulesFormSchema),
defaultValues: {
applyRules: false,
rules: []
}
});
useEffect(() => {
const subscription = form.watch((values) => {
parentForm.setValue("applyRules", values.applyRules as boolean);
parentForm.setValue(
"rules",
values.rules as PolicyFormValues["rules"]
);
});
return () => subscription.unsubscribe();
}, [form, parentForm]);
const rulesEnabled = useWatch({
control: form.control,
name: "applyRules"
});
const { updateRulesState } = usePolicyAccessRulesFormSync(form);
const handleRulesChange = useCallback(
(updatedRules: PolicyAccessRule[]) => {
updateRulesState(setRules, updatedRules);
},
[updateRulesState]
);
const removeRule = useCallback(
function removeRule(ruleId: number) {
handleRulesChange(rules.filter((rule) => rule.ruleId !== ruleId));
},
[rules, handleRulesChange]
);
const updateRule = useCallback(
function updateRule(ruleId: number, data: Partial<PolicyAccessRule>) {
handleRulesChange(
rules.map((rule) =>
rule.ruleId === ruleId
? { ...rule, ...data, updated: true }
: rule
)
);
},
[rules, handleRulesChange]
);
return (
<CreatePolicyRulesSectionForm
form={form}
<PolicyAccessRulesSectionLayout
rulesEnabled={Boolean(rulesEnabled)}
onRulesEnabledChange={(val) => {
form.setValue("applyRules", val);
}}
rules={rules}
onRulesChange={handleRulesChange}
updateRule={updateRule}
removeRule={removeRule}
isMaxmindAvailable={isMaxmindAvailable}
isMaxmindAsnAvailable={isMaxmindAsnAvailable}
/>
@@ -66,8 +66,12 @@ import {
validatePolicyRuleValue
} from "./policy-access-rule-validation";
import {
buildDisplayPrioritiesForResourceOverlay,
reorderPolicyRules,
reorderResourceOverlayRules,
setResourceRuleDisplayPriority,
sortPolicyRulesByPriority,
sortPolicyRulesForResourceOverlay,
type PolicyAccessRule
} from "./policy-access-rule-utils";
@@ -82,6 +86,7 @@ export type PolicyAccessRulesTableProps = {
readonly?: boolean;
includeRegionMatch?: boolean;
markUpdatedOnReorder?: boolean;
resourceOverlayMode?: boolean;
isRuleDraggable?: (rule: PolicyAccessRule) => boolean;
isRuleLocked?: (rule: PolicyAccessRule) => boolean;
};
@@ -97,7 +102,7 @@ function getColumnClassName(columnId: string) {
return "w-24 max-w-24";
}
if (columnId === "action") {
return "w-40 max-w-40";
return "w-42 max-w-42";
}
if (columnId === "match") {
return "w-36 max-w-36";
@@ -116,6 +121,7 @@ export function PolicyAccessRulesTable({
readonly = false,
includeRegionMatch = false,
markUpdatedOnReorder = false,
resourceOverlayMode = false,
isRuleDraggable: isRuleDraggableProp,
isRuleLocked: isRuleLockedProp
}: PolicyAccessRulesTableProps) {
@@ -140,12 +146,37 @@ export function PolicyAccessRulesTable({
);
const sortedRules = useMemo(
() => sortPolicyRulesByPriority(rules),
() =>
resourceOverlayMode
? sortPolicyRulesForResourceOverlay(rules)
: sortPolicyRulesByPriority(rules),
[rules, resourceOverlayMode]
);
const displayPriorities = useMemo(
() =>
resourceOverlayMode
? buildDisplayPrioritiesForResourceOverlay(rules)
: null,
[rules, resourceOverlayMode]
);
const resourceRuleCount = useMemo(
() => rules.filter((rule) => !rule.fromPolicy).length,
[rules]
);
const handleReorder = useCallback(
(fromRuleId: number, toRuleId: number) => {
if (resourceOverlayMode) {
onRulesChange(
reorderResourceOverlayRules(rules, fromRuleId, toRuleId, {
markUpdated: markUpdatedOnReorder
})
);
return;
}
const fromIndex = sortedRules.findIndex(
(rule) => rule.ruleId === fromRuleId
);
@@ -164,7 +195,13 @@ export function PolicyAccessRulesTable({
);
onRulesChange(reordered);
},
[sortedRules, onRulesChange, markUpdatedOnReorder]
[
rules,
sortedRules,
onRulesChange,
markUpdatedOnReorder,
resourceOverlayMode
]
);
const handleDragStart = useCallback((ruleId: number, e: DragEvent) => {
@@ -228,60 +265,132 @@ export function PolicyAccessRulesTable({
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>
{resourceOverlayMode ? (
<span className="font-medium text-muted-foreground">
{t("rulesPriority")}
</span>
) : (
<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
cell: ({ row }) => {
const displayPriority = resourceOverlayMode
? (displayPriorities?.get(row.original.ruleId) ??
row.original.priority)
: row.original.priority;
return (
<Input
key={`${row.original.ruleId}-${displayPriority}`}
defaultValue={displayPriority}
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;
}
if (resourceOverlayMode) {
if (
validated.data > resourceRuleCount ||
validated.data < 1
) {
toast({
variant: "destructive",
title: t(
"rulesErrorInvalidPriority"
),
description: t(
"rulesErrorInvalidPriorityDescription"
)
});
return;
}
const duplicateDisplayPriority = rules.some(
(rule) =>
!rule.fromPolicy &&
rule.ruleId !==
row.original.ruleId &&
displayPriorities?.get(
rule.ruleId
) === validated.data
);
if (duplicateDisplayPriority) {
toast({
variant: "destructive",
title: t(
"rulesErrorDuplicatePriority"
),
description: t(
"rulesErrorDuplicatePriorityDescription"
)
});
return;
}
if (validated.data === displayPriority) {
return;
}
onRulesChange(
setResourceRuleDisplayPriority(
rules,
row.original.ruleId,
validated.data,
{
markUpdated:
markUpdatedOnReorder
}
)
);
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
});
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",
@@ -683,13 +792,7 @@ export function PolicyAccessRulesTable({
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>
<LockIcon className="h-4 w-4 text-muted-foreground" />
) : (
<Button
variant="outline"
@@ -711,9 +814,14 @@ export function PolicyAccessRulesTable({
isMaxmindAsnAvailable,
includeRegionMatch,
updateRule,
onRulesChange,
removeRule,
readonly,
rules,
resourceOverlayMode,
displayPriorities,
resourceRuleCount,
markUpdatedOnReorder,
isRuleDraggable,
isRuleLocked,
handleDragStart,
@@ -775,7 +883,8 @@ export function PolicyAccessRulesTable({
e.preventDefault();
if (
draggedRuleId !== null &&
draggedRuleId !== rule.ruleId
draggedRuleId !== rule.ruleId &&
isRuleDraggable(rule)
) {
handleReorder(
draggedRuleId,
@@ -789,7 +898,7 @@ export function PolicyAccessRulesTable({
draggedRuleId === rule.ruleId &&
"opacity-50",
dragOverRuleId === rule.ruleId &&
"border-t-2 border-primary"
"border-t border-primary"
)}
>
{row.getVisibleCells().map((cell) => {
@@ -117,7 +117,7 @@ export function PasscodeCredenza({
title={t("resourcePasswordSetupTitle")}
description={t("resourcePasswordSetupTitleDescription")}
formId="policy-passcode-form"
submitLabel={t("resourcePasswordSubmit")}
submitLabel={t("policyAuthSetPasscode")}
>
<Form {...form}>
<form
@@ -191,7 +191,7 @@ export function PincodeCredenza({
title={t("resourcePincodeSetupTitle")}
description={t("resourcePincodeSetupTitleDescription")}
formId="policy-pincode-form"
submitLabel={t("resourcePincodeSubmit")}
submitLabel={t("policyAuthSetPincode")}
>
<Form {...form}>
<form
@@ -286,7 +286,7 @@ export function HeaderAuthCredenza({
title={t("resourceHeaderAuthSetupTitle")}
description={t("resourceHeaderAuthSetupTitleDescription")}
formId="policy-header-auth-form"
submitLabel={t("resourceHeaderAuthSubmit")}
submitLabel={t("policyAuthSetHeaderAuth")}
>
<Form {...form}>
<form
@@ -368,7 +368,7 @@ type EmailCredenzaProps = {
emailEnabled: boolean;
disabled?: boolean;
emails: Tag[];
onEmailsChange: (emails: Tag[]) => void;
onSave: (emails: Tag[]) => void;
};
export function EmailCredenza({
@@ -377,12 +377,19 @@ export function EmailCredenza({
emailEnabled,
disabled,
emails,
onEmailsChange
onSave
}: EmailCredenzaProps) {
const t = useTranslations();
const [activeEmailTagIndex, setActiveEmailTagIndex] = useState<
number | null
>(null);
const [draftEmails, setDraftEmails] = useState<Tag[]>(emails);
useEffect(() => {
if (open) {
setDraftEmails(emails);
}
}, [open, emails]);
return (
<Credenza open={open} onOpenChange={onOpenChange}>
@@ -394,72 +401,90 @@ export function EmailCredenza({
</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[]
);
<form
id="policy-email-form"
onSubmit={(event) => {
event.preventDefault();
onSave(draftEmails);
onOpenChange(false);
}}
>
<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
}
}}
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>
placeholder={t("otpEmailEnter")}
tags={draftEmails}
setTags={(newEmails) => {
if (!disabled) {
setDraftEmails(
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>
</form>
</CredenzaBody>
<CredenzaFooter>
<CredenzaClose asChild>
<Button variant="outline">{t("close")}</Button>
</CredenzaClose>
{emailEnabled && (
<Button
type="submit"
form="policy-email-form"
disabled={disabled}
>
{t("policyAuthSetEmailWhitelist")}
</Button>
)}
</CredenzaFooter>
</CredenzaContent>
</Credenza>
@@ -66,23 +66,6 @@ export function PolicyAuthMethodRow({
>
<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">
@@ -19,8 +19,6 @@ 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;
};
@@ -1,6 +1,8 @@
"use client";
import {
SettingsFormCell,
SettingsFormGrid,
SettingsSection,
SettingsSectionBody,
SettingsSectionDescription,
@@ -10,7 +12,8 @@ import {
SettingsSubsectionTitle,
SettingsSectionTitle
} from "@app/components/Settings";
import { TagInput } from "@app/components/tags/tag-input";
import { RolesSelector } from "@app/components/roles-selector";
import { UsersSelector } from "@app/components/users-selector";
import { FormField } from "@app/components/ui/form";
import { useTranslations } from "next-intl";
import { useState } from "react";
@@ -36,27 +39,18 @@ 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,
orgId,
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({
@@ -111,65 +105,58 @@ export function PolicyAuthStackSectionCreate({
</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>
<SettingsFormGrid>
<SettingsFormCell span="half">
<PolicyAuthSsoSection
sso={Boolean(sso)}
onSsoChange={(active) =>
parentForm.setValue("sso", active)
}
skipToIdpId={skipToIdpId}
onSkipToIdpChange={(id) =>
parentForm.setValue("skipToIdpId", id)
}
allIdps={allIdps}
rolesEditor={
<FormField
control={parentForm.control}
name="roles"
render={({ field }) => (
<RolesSelector
orgId={orgId}
selectedRoles={field.value}
onSelectRoles={(selected) =>
parentForm.setValue(
"roles",
selected
)
}
restrictAdminRole
/>
)}
/>
}
usersEditor={
<FormField
control={parentForm.control}
name="users"
render={({ field }) => (
<UsersSelector
orgId={orgId}
selectedUsers={field.value}
onSelectUsers={(selected) =>
parentForm.setValue(
"users",
selected
)
}
/>
)}
/>
}
/>
</SettingsFormCell>
</SettingsFormGrid>
<SettingsSubsectionHeader>
<SettingsSubsectionTitle>
@@ -279,7 +266,7 @@ export function PolicyAuthStackSectionCreate({
onOpenChange={(open) => !open && closeCredenza()}
emailEnabled={emailEnabled}
emails={emails}
onEmailsChange={(value) =>
onSave={(value) =>
parentForm.setValue(
"emails",
value as PolicyFormValues["emails"]
@@ -1,6 +1,8 @@
"use client";
import {
SettingsFormCell,
SettingsFormGrid,
SettingsSection,
SettingsSectionBody,
SettingsSectionDescription,
@@ -48,20 +50,43 @@ import {
getPasscodeSummary,
getPincodeSummary
} from "./policy-auth-summaries";
import { SharedPolicyResourceNotice } from "./SharedPolicyResourceNotice";
import z from "zod";
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
});
// Edit mode keeps placeholder values for configured methods; only validate on save when changed.
const authStackEditSchema = createPolicySchema
.pick({
sso: true,
skipToIdpId: true,
roles: true,
users: true,
emailWhitelistEnabled: true,
emails: true
})
.extend({
password: z
.object({
password: z.string()
})
.nullable()
.optional(),
pincode: z
.object({
pincode: z.string()
})
.nullable()
.optional(),
headerAuth: z
.object({
user: z.string(),
password: z.string(),
extendedCompatibility: z.boolean().default(true)
})
.nullable()
.optional()
});
export type PolicyAuthStackSectionEditProps = {
orgId: string;
@@ -182,14 +207,14 @@ export function PolicyAuthStackSectionEdit({
]);
const form = useForm({
resolver: zodResolver(authStackSchema),
resolver: zodResolver(authStackEditSchema),
defaultValues: {
sso: policy.sso,
skipToIdpId: policy.idpId,
roles: policyRoleItems,
users: policyUserItems,
password: policy.passwordId ? { password: "" } : null,
pincode: policy.pincodeId ? { pincode: "" } : null,
password: null,
pincode: null,
headerAuth: policy.headerAuth
? {
user: "",
@@ -247,7 +272,14 @@ export function PolicyAuthStackSectionEdit({
}
const isValid = await form.trigger();
if (!isValid) return;
if (!isValid) {
toast({
variant: "destructive",
title: t("policyErrorUpdate"),
description: t("policyErrorUpdateMessageDescription")
});
return;
}
const payload = form.getValues();
const requests: Array<Promise<AxiosResponse<{}> | void>> = [];
@@ -441,184 +473,227 @@ export function PolicyAuthStackSectionEdit({
</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 }) => (
<div className="space-y-4">
{isResourceOverlay && (
<SharedPolicyResourceNotice section="authentication" />
)}
<SettingsFormGrid>
<SettingsFormCell span="half">
<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={field.value}
selectedRoles={overlayRoles}
onSelectRoles={(selected) =>
form.setValue(
"roles",
selected
setCombinedRoles(
selected.map(
(role) => ({
...role,
isAdmin:
Boolean(
role.isAdmin
)
})
)
)
}
disabled={readonly}
disabled={isLoading}
restrictAdminRole
lockedIds={
policyRoleLockedIds
}
/>
)}
/>
)
}
usersEditor={
isResourceOverlay ? (
<UsersSelector
orgId={orgId}
selectedUsers={overlayUsers}
onSelectUsers={setCombinedUsers}
disabled={isLoading}
lockedIds={policyUserLockedIds}
/>
) : (
<FormField
control={form.control}
name="users"
render={({ field }) => (
) : (
<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={field.value}
onSelectUsers={(selected) =>
form.setValue(
"users",
selected
)
selectedUsers={overlayUsers}
onSelectUsers={
setCombinedUsers
}
disabled={isLoading}
lockedIds={
policyUserLockedIds
}
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
) : (
<FormField
control={form.control}
name="users"
render={({ field }) => (
<UsersSelector
orgId={orgId}
selectedUsers={
field.value
}
onSelectUsers={(
selected
) =>
form.setValue(
"users",
selected
)
}
disabled={readonly}
/>
)}
/>
)
)
}
disabled={authReadonly || !emailEnabled}
/>
}
/>
</SettingsFormCell>
</SettingsFormGrid>
<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}
/>
<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>
</div>
<PincodeCredenza
@@ -648,9 +723,7 @@ export function PolicyAuthStackSectionEdit({
emailEnabled={emailEnabled}
disabled={authReadonly}
emails={emails}
onEmailsChange={(value) =>
form.setValue("emails", value)
}
onSave={(value) => form.setValue("emails", value)}
/>
<HeaderAuthCredenza
@@ -0,0 +1,47 @@
"use client";
import { useResourceContext } from "@app/hooks/useResourceContext";
import { resourceQueries } from "@app/lib/queries";
import { ResourcePolicyProvider } from "@app/providers/ResourcePolicyProvider";
import { useQuery } from "@tanstack/react-query";
import { EditPolicyForm, type EditPolicyFormSection } from "./EditPolicyForm";
type ResourcePolicyEditFormProps = {
section: Extract<EditPolicyFormSection, "authentication" | "rules">;
};
export function ResourcePolicyEditForm({
section
}: ResourcePolicyEditFormProps) {
const { resource } = useResourceContext();
const { data: policies, isLoading: isLoadingPolicies } = useQuery(
resourceQueries.policies({
resourceId: resource.resourceId
})
);
if (isLoadingPolicies || !policies) {
return <></>;
}
if (!policies.sharedPolicy) {
return (
<ResourcePolicyProvider policy={policies.defaultPolicy}>
<EditPolicyForm hidePolicyNameForm section={section} />
</ResourcePolicyProvider>
);
}
return (
<ResourcePolicyProvider
policy={policies.sharedPolicy}
key={policies.sharedPolicy.resourcePolicyId}
>
<EditPolicyForm
resourceId={resource.resourceId}
section={section}
/>
</ResourcePolicyProvider>
);
}
@@ -0,0 +1,44 @@
"use client";
import { Alert, AlertDescription } from "@app/components/ui/alert";
import { useOrgContext } from "@app/hooks/useOrgContext";
import { useResourcePolicyContext } from "@app/providers/ResourcePolicyProvider";
import { InfoIcon } from "lucide-react";
import Link from "next/link";
import { useTranslations } from "next-intl";
type SharedPolicyResourceNoticeProps = {
section: "authentication" | "rules";
};
export function SharedPolicyResourceNotice({
section
}: SharedPolicyResourceNoticeProps) {
const t = useTranslations();
const { org } = useOrgContext();
const { policy } = useResourcePolicyContext();
const messageKey =
section === "authentication"
? "resourceSharedPolicyAuthenticationNotice"
: "resourceSharedPolicyRulesNotice";
return (
<Alert variant="neutral">
<InfoIcon className="h-4 w-4" />
<AlertDescription>
{t.rich(messageKey, {
policyName: policy.name,
policyLink: (chunks) => (
<Link
href={`/${org.org.orgId}/settings/policies/resources/public/${policy.niceId}/${section}`}
className="text-primary hover:underline"
>
{chunks}
</Link>
)
})}
</AlertDescription>
</Alert>
);
}
+7 -2
View File
@@ -1,6 +1,8 @@
// ─── Schemas & types ──────────────────────────────────────────────────────────
import z from "zod";
import { POLICY_RULE_MATCH_TYPES } from "./policy-access-rule-validation";
import type { PolicyRuleMatchType } from "./policy-access-rule-validation";
export const createPolicySchema = z.object({
name: z.string().min(1).max(255),
@@ -35,7 +37,7 @@ export const createPolicySchema = z.object({
.array(
z.object({
action: z.enum(["ACCEPT", "DROP", "PASS"]),
match: z.string(),
match: z.enum(POLICY_RULE_MATCH_TYPES),
value: z.string(),
priority: z.number().int(),
enabled: z.boolean()
@@ -49,7 +51,7 @@ export type PolicyFormValues = z.infer<typeof createPolicySchema>;
export type LocalRule = {
ruleId: number;
action: "ACCEPT" | "DROP" | "PASS";
match: string;
match: PolicyRuleMatchType;
value: string;
priority: number;
enabled: boolean;
@@ -67,6 +69,7 @@ export {
type PolicyAccessRule
} from "./policy-access-rule-utils";
export {
createPolicyRuleMatchSchema,
createPolicyRulePrioritySchema,
createPolicyRuleSchema,
createPolicyRuleValueSchema,
@@ -74,8 +77,10 @@ export {
createPolicyRulesSectionSchema,
createPolicySchemaWithI18n,
getPolicyRuleValidationMessage,
POLICY_RULE_MATCH_TYPES,
validatePolicyRulePriority,
validatePolicyRuleValue,
validatePolicyRulesForSave,
type PolicyRuleMatchType,
type RuleValidationToast
} from "./policy-access-rule-validation";
@@ -1,7 +1,9 @@
import type { PolicyRuleMatchType } from "./policy-access-rule-validation";
export type PolicyAccessRule = {
ruleId: number;
action: "ACCEPT" | "DROP" | "PASS";
match: string;
match: PolicyRuleMatchType;
value: string;
priority: number;
enabled: boolean;
@@ -34,12 +36,96 @@ export function createEmptyRule(
};
}
export function prependEmptyRule(
rules: PolicyAccessRule[]
): PolicyAccessRule[] {
const newRule: EmptyRuleDraft = {
ruleId: Date.now(),
action: "ACCEPT",
match: "PATH",
value: "",
priority: 1,
enabled: true,
new: true
};
const bumpedRules = rules.map((rule) => {
if (rule.fromPolicy) {
return rule;
}
const bumped = { ...rule, priority: rule.priority + 1 };
if (rule.new) {
return bumped;
}
return { ...bumped, updated: true };
});
return [newRule, ...bumpedRules];
}
export function sortPolicyRulesByPriority<T extends { priority: number }>(
rules: T[]
): T[] {
return [...rules].sort((a, b) => a.priority - b.priority);
}
export function sortPolicyRulesForResourceOverlay<
T extends { priority: number; fromPolicy?: boolean }
>(rules: T[]): T[] {
const resourceRules = rules
.filter((rule) => !rule.fromPolicy)
.sort((a, b) => a.priority - b.priority);
const policyRules = rules
.filter((rule) => rule.fromPolicy)
.sort((a, b) => a.priority - b.priority);
return [...resourceRules, ...policyRules];
}
export function buildDisplayPrioritiesForResourceOverlay<
T extends { ruleId: number; priority: number; fromPolicy?: boolean }
>(rules: T[]): Map<number, number> {
const sorted = sortPolicyRulesForResourceOverlay(rules);
const displayPriorities = new Map<number, number>();
sorted.forEach((rule, index) => {
displayPriorities.set(rule.ruleId, index + 1);
});
return displayPriorities;
}
export function setResourceRuleDisplayPriority(
rules: PolicyAccessRule[],
ruleId: number,
displayPriority: number,
options?: { markUpdated?: boolean }
): PolicyAccessRule[] {
const sorted = sortPolicyRulesForResourceOverlay(rules);
const resourceRules = sorted.filter((rule) => !rule.fromPolicy);
const policyRules = sorted.filter((rule) => rule.fromPolicy);
const fromIndex = resourceRules.findIndex((rule) => rule.ruleId === ruleId);
if (fromIndex === -1) {
return rules;
}
const targetIndex = Math.max(
0,
Math.min(displayPriority - 1, resourceRules.length - 1)
);
const reorderedResource = reorderPolicyRules(
resourceRules,
fromIndex,
targetIndex,
options
);
return [...reorderedResource, ...policyRules];
}
export function reorderPolicyRules<
T extends { priority: number; new?: boolean; updated?: boolean }
>(
@@ -70,3 +156,40 @@ export function reorderPolicyRules<
return next;
});
}
export function reorderResourceOverlayRules<
T extends {
ruleId: number;
priority: number;
fromPolicy?: boolean;
new?: boolean;
updated?: boolean;
}
>(
rules: T[],
fromRuleId: number,
toRuleId: number,
options?: { markUpdated?: boolean }
): T[] {
const sorted = sortPolicyRulesForResourceOverlay(rules);
const resourceRules = sorted.filter((rule) => !rule.fromPolicy);
const policyRules = sorted.filter((rule) => rule.fromPolicy);
const fromIndex = resourceRules.findIndex(
(rule) => rule.ruleId === fromRuleId
);
const toIndex = resourceRules.findIndex((rule) => rule.ruleId === toRuleId);
if (fromIndex === -1 || toIndex === -1 || fromIndex === toIndex) {
return rules;
}
const reorderedResource = reorderPolicyRules(
resourceRules,
fromIndex,
toIndex,
options
);
return [...reorderedResource, ...policyRules];
}
@@ -12,6 +12,23 @@ type TranslateFn = (
values?: Record<string, string | number>
) => string;
export const POLICY_RULE_MATCH_TYPES = [
"CIDR",
"IP",
"PATH",
"COUNTRY",
"ASN",
"REGION"
] as const;
export type PolicyRuleMatchType = (typeof POLICY_RULE_MATCH_TYPES)[number];
export function createPolicyRuleMatchSchema(t: TranslateFn) {
return z.enum(POLICY_RULE_MATCH_TYPES, {
error: t("rulesErrorInvalidMatchTypeDescription")
});
}
export type RuleValidationToast = {
title: string;
description: string;
@@ -78,7 +95,7 @@ export function createPolicyRuleSchema(t: TranslateFn) {
return z
.object({
action: z.enum(["ACCEPT", "DROP", "PASS"]),
match: z.string(),
match: createPolicyRuleMatchSchema(t),
value: z.string(),
priority: z.number().int(),
enabled: z.boolean()
+218
View File
@@ -0,0 +1,218 @@
"use client";
import { orgQueries } from "@app/lib/queries";
import { cn } from "@app/lib/cn";
import type { ListResourcePoliciesResponse } from "@server/routers/resource/types";
import { useQuery } from "@tanstack/react-query";
import { CheckIcon, ChevronsUpDown } from "lucide-react";
import { useTranslations } from "next-intl";
import { useMemo, useState } from "react";
import { useDebounce } from "use-debounce";
import { Button } from "./ui/button";
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList
} from "./ui/command";
import { Popover, PopoverContent, PopoverTrigger } from "./ui/popover";
export type SelectedSharedPolicy = Pick<
ListResourcePoliciesResponse["policies"][number],
"resourcePolicyId" | "name"
>;
export type SharedPolicySelectorProps = {
orgId: string;
selectedPolicy: SelectedSharedPolicy | null;
onSelectPolicy: (policy: SelectedSharedPolicy | null) => void;
};
export function SharedPolicySelector({
orgId,
selectedPolicy,
onSelectPolicy
}: SharedPolicySelectorProps) {
const t = useTranslations();
const [policySearchQuery, setPolicySearchQuery] = useState("");
const [debouncedQuery] = useDebounce(policySearchQuery, 150);
const { data: policies = [] } = useQuery(
orgQueries.policies({
orgId,
query: debouncedQuery
})
);
const policiesShown = useMemo((): SelectedSharedPolicy[] => {
const allPolicies: SelectedSharedPolicy[] = policies.map((policy) => ({
resourcePolicyId: policy.resourcePolicyId,
name: policy.name
}));
if (
debouncedQuery.trim().length === 0 &&
selectedPolicy &&
!allPolicies.find(
(policy) =>
policy.resourcePolicyId === selectedPolicy.resourcePolicyId
)
) {
allPolicies.unshift(selectedPolicy);
}
return allPolicies;
}, [debouncedQuery, policies, selectedPolicy]);
return (
<Command shouldFilter={false}>
<CommandInput
placeholder={t("resourcePolicySearch")}
value={policySearchQuery}
onValueChange={setPolicySearchQuery}
/>
<CommandList>
<CommandEmpty>{t("resourcePolicyNotFound")}</CommandEmpty>
<CommandGroup>
<CommandItem
value={`none:${t("none")}`}
onSelect={() => onSelectPolicy(null)}
>
<CheckIcon
className={cn(
"mr-2 h-4 w-4",
selectedPolicy === null
? "opacity-100"
: "opacity-0"
)}
/>
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
<span className="truncate">{t("none")}</span>
<span className="text-muted-foreground text-xs leading-snug">
{t("sharedPolicyNoneDescription")}
</span>
</div>
</CommandItem>
{policiesShown.map((policy) => (
<CommandItem
key={policy.resourcePolicyId}
value={`${policy.resourcePolicyId}:${policy.name}`}
onSelect={() =>
onSelectPolicy({
resourcePolicyId: policy.resourcePolicyId,
name: policy.name
})
}
>
<CheckIcon
className={cn(
"mr-2 h-4 w-4",
policy.resourcePolicyId ===
selectedPolicy?.resourcePolicyId
? "opacity-100"
: "opacity-0"
)}
/>
<span className="min-w-0 flex-1 truncate">
{policy.name}
</span>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
);
}
export type SharedPolicySelectProps = {
orgId: string;
value: number | null;
onChange: (value: number | null) => void;
className?: string;
disabled?: boolean;
};
export function SharedPolicySelect({
orgId,
value,
onChange,
className,
disabled
}: SharedPolicySelectProps) {
const t = useTranslations();
const [open, setOpen] = useState(false);
const [selectedLabel, setSelectedLabel] = useState<{
resourcePolicyId: number;
name: string;
} | null>(null);
const resolvedLabel =
selectedLabel?.resourcePolicyId === value ? selectedLabel.name : null;
const { data: fetchedPolicy } = useQuery({
...orgQueries.resourcePolicy({
resourcePolicyId: value!
}),
enabled: value !== null && resolvedLabel === null
});
const selectedPolicy = useMemo((): SelectedSharedPolicy | null => {
if (value === null) {
return null;
}
return {
resourcePolicyId: value,
name: resolvedLabel ?? fetchedPolicy?.name ?? ""
};
}, [value, resolvedLabel, fetchedPolicy?.name]);
const triggerLabel =
value === null
? t("none")
: (resolvedLabel ??
fetchedPolicy?.name ??
t("resourcePolicySelect"));
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
type="button"
variant="outline"
role="combobox"
disabled={disabled}
className={cn(
"w-full justify-between font-normal",
value !== null &&
!resolvedLabel &&
!fetchedPolicy?.name &&
"text-muted-foreground",
className
)}
>
<span className="truncate">{triggerLabel}</span>
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-[var(--radix-popover-trigger-width)] p-0">
<SharedPolicySelector
orgId={orgId}
selectedPolicy={selectedPolicy}
onSelectPolicy={(policy) => {
onChange(policy?.resourcePolicyId ?? null);
setSelectedLabel(
policy
? {
resourcePolicyId: policy.resourcePolicyId,
name: policy.name
}
: null
);
setOpen(false);
}}
/>
</PopoverContent>
</Popover>
);
}
+3 -3
View File
@@ -26,11 +26,12 @@ export type Selectedsite = Pick<
type SiteOnlineStatusProps = {
type: Selectedsite["type"];
online: Selectedsite["online"];
t: (key: "online" | "offline") => string;
};
/** Dot-only indicator matching `SitesTable` colors (newt/wireguard only; nothing for local or missing status). */
export function SiteOnlineStatus({ type, online, t }: SiteOnlineStatusProps) {
export function SiteOnlineStatus({ type, online }: SiteOnlineStatusProps) {
const t = useTranslations();
if (type !== "newt" && type !== "wireguard") {
return null;
}
@@ -128,7 +129,6 @@ export function SitesSelector({
<SiteOnlineStatus
type={site.type}
online={site.online}
t={t}
/>
)}
</div>
+1 -1
View File
@@ -4,7 +4,7 @@ import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@app/lib/cn";
const alertVariants = cva(
"relative w-full rounded-lg p-4 [&>svg~*]:pl-7 [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground",
"relative w-full rounded-lg p-4 has-[>svg]:grid has-[>svg]:grid-cols-[auto_1fr] has-[>svg]:gap-x-3 gap-y-1 [&>svg]:col-start-1 [&>svg]:row-start-1 [&>svg]:row-span-full [&>svg]:size-4 [&>svg]:shrink-0 [&>svg]:self-center [&>svg]:text-foreground [&>svg~*]:col-start-2",
{
variants: {
variant: {
+1 -1
View File
@@ -5,7 +5,7 @@ import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@app/lib/cn";
const buttonVariants = cva(
"cursor-pointer inline-flex items-center justify-center whitespace-nowrap text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-0 disabled:pointer-events-none disabled:opacity-50",
"cursor-pointer inline-flex items-center justify-center whitespace-nowrap text-sm font-normal ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-0 disabled:pointer-events-none disabled:opacity-50",
{
variants: {
variant: {
@@ -351,10 +351,6 @@ export function ControlledDataTable<TData, TValue>({
dataTableFilterDropdownContentClassName
}
>
<DropdownMenuLabel>
{filter.label}
</DropdownMenuLabel>
<DropdownMenuSeparator />
{filter.options.map(
(option) => {
const isChecked =
@@ -484,13 +480,6 @@ export function ControlledDataTable<TData, TValue>({
align="end"
className="w-48"
>
<DropdownMenuLabel>
{t(
"toggleColumns"
) ||
"Toggle columns"}
</DropdownMenuLabel>
<DropdownMenuSeparator />
{table
.getAllColumns()
.filter(
+18 -3
View File
@@ -105,13 +105,17 @@ function SelectLabel({
function SelectItem({
className,
children,
description,
...props
}: React.ComponentProps<typeof SelectPrimitive.Item>) {
}: React.ComponentProps<typeof SelectPrimitive.Item> & {
description?: React.ReactNode;
}) {
return (
<SelectPrimitive.Item
data-slot="select-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
"focus:bg-accent focus:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex w-full cursor-default gap-2 rounded-sm pr-8 pl-2 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
description ? "items-start py-2" : "items-center py-1.5",
className
)}
{...props}
@@ -121,7 +125,18 @@ function SelectItem({
<CheckIcon className="size-4" />
</SelectPrimitive.ItemIndicator>
</span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
{description ? (
<div className="flex flex-col gap-0.5 pr-2">
<SelectPrimitive.ItemText>
{children}
</SelectPrimitive.ItemText>
<span className="text-muted-foreground text-xs leading-snug">
{description}
</span>
</div>
) : (
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
)}
</SelectPrimitive.Item>
);
}
+1 -1
View File
@@ -91,7 +91,7 @@ const TableHead = React.forwardRef<
<th
ref={ref}
className={cn(
"h-10 text-left align-middle font-medium text-muted-foreground [&:has([role=checkbox])]:pr-0",
"h-10 text-left align-middle font-medium text-muted-foreground [&_button]:font-medium [&:has([role=checkbox])]:pr-0",
className
)}
{...props}
+17 -4
View File
@@ -45,6 +45,7 @@ import type { ListOrgLabelsResponse } from "@server/routers/labels/types";
import { ListHealthChecksResponse } from "@server/routers/healthChecks/types";
import { StatusHistoryResponse } from "@server/lib/statusHistory";
import type { ListResourcePoliciesResponse } from "@server/routers/resource/types";
import type { GetResourcePolicyResponse } from "@server/routers/policy";
export type ProductUpdate = {
link: string | null;
@@ -581,16 +582,16 @@ export const orgQueries = {
}
}),
policies: ({ orgId, name }: { orgId: string; name?: string }) =>
policies: ({ orgId, query }: { orgId: string; query?: string }) =>
queryOptions({
queryKey: ["ORG", orgId, "RESOURCES_POLICIES", name] as const,
queryKey: ["ORG", orgId, "RESOURCES_POLICIES", query] as const,
queryFn: async ({ signal, meta }) => {
const sp = new URLSearchParams({
pageSize: "10"
});
if (name) {
sp.set("query", name);
if (query) {
sp.set("query", query);
}
const res = await meta!.api.get<
@@ -601,6 +602,18 @@ export const orgQueries = {
return res.data.data.policies;
}
}),
resourcePolicy: ({ resourcePolicyId }: { resourcePolicyId: number }) =>
queryOptions({
queryKey: ["RESOURCE_POLICY", resourcePolicyId] as const,
queryFn: async ({ signal, meta }) => {
const res = await meta!.api.get<
AxiosResponse<GetResourcePolicyResponse>
>(`/resource-policy/${resourcePolicyId}`, { signal });
return res.data.data;
}
})
};
+110
View File
@@ -0,0 +1,110 @@
export type TextImportFileType = "txt" | "csv";
export type TextImportMode = "override" | "append";
export function getTextImportFileType(file: File): TextImportFileType | null {
const extension = file.name.split(".").pop()?.toLowerCase();
if (extension === "txt" || extension === "csv") {
return extension;
}
return null;
}
export function isSupportedTextImportFile(file: File): boolean {
return getTextImportFileType(file) !== null;
}
export function readFileAsText(file: File): Promise<string> {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = (event) => {
const result = event.target?.result;
if (typeof result === "string") {
resolve(result);
return;
}
reject(new Error("Failed to read file"));
};
reader.onerror = () => reject(reader.error);
reader.readAsText(file);
});
}
function parseCsvFirstCell(row: string): string {
const trimmed = row.trim();
if (!trimmed) return "";
if (trimmed.startsWith('"')) {
let index = 1;
let cell = "";
while (index < trimmed.length) {
if (trimmed[index] === '"') {
if (trimmed[index + 1] === '"') {
cell += '"';
index += 2;
continue;
}
break;
}
cell += trimmed[index];
index += 1;
}
return cell.trim();
}
const commaIndex = trimmed.indexOf(",");
return (commaIndex === -1 ? trimmed : trimmed.slice(0, commaIndex)).trim();
}
export function parseCsvFirstColumn(
content: string,
skipHeader: boolean
): string[] {
const rows = content.split(/\r?\n/).filter((line) => line.trim());
const dataRows = skipHeader ? rows.slice(1) : rows;
return dataRows.map(parseCsvFirstCell).filter(Boolean);
}
export function parseTextFileItems({
content,
fileType,
skipHeader,
parser
}: {
content: string;
fileType: TextImportFileType;
skipHeader: boolean;
parser: (value: string | undefined) => string[];
}): string[] {
if (fileType === "csv") {
return parseCsvFirstColumn(content, skipHeader);
}
return parser(content);
}
export function parsePreviewLines(value: string): string[] {
return value
.split(/\n/)
.map((line) => line.trim())
.filter(Boolean);
}
export function applyTextImport({
currentValue,
imported,
mode,
parser
}: {
currentValue: string;
imported: string[];
mode: TextImportMode;
parser: (value: string | undefined) => string[];
}): string {
if (mode === "override") {
return imported.join("\n");
}
const existing = parser(currentValue);
return [...existing, ...imported].join("\n");
}