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.", "siteCredentialsSaveDescription": "You will only be able to see this once. Make sure to copy it to a secure place.",
"siteInfo": "Site Information", "siteInfo": "Site Information",
"status": "Status", "status": "Status",
"shareTitle": "Manage Share Links", "shareTitle": "Manage Shareable Links",
"shareDescription": "Create shareable links to grant temporary or permanent access to proxy resources", "shareDescription": "Create shareable links to grant temporary or permanent access to proxy resources",
"shareSearch": "Search share links...", "shareSearch": "Search shareable links...",
"shareCreate": "Create Share Link", "shareCreate": "Create Shareable Link",
"shareErrorDelete": "Failed to delete link", "shareErrorDelete": "Failed to delete link",
"shareErrorDeleteMessage": "An error occurred deleting link", "shareErrorDeleteMessage": "An error occurred deleting link",
"shareDeleted": "Link deleted", "shareDeleted": "Link deleted",
"shareDeletedDescription": "The link has been deleted", "shareDeletedDescription": "The link has been deleted",
"shareDelete": "Delete Share Link", "shareDelete": "Delete Shareable Link",
"shareDeleteConfirm": "Confirm Delete Share Link", "shareDeleteConfirm": "Confirm Delete Shareable Link",
"shareQuestionRemove": "Are you sure you want to delete this share 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.", "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.", "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", "shareCreateDescription": "Anyone with this link can access the resource",
"shareTitleOptional": "Title (optional)", "shareTitleOptional": "Title (optional)",
"sharePathOptional": "Path (optional)", "sharePathOptional": "Path (optional)",
"sharePathDescription": "The link will redirect users to this path after authentication.",
"expireIn": "Expire In", "expireIn": "Expire In",
"neverExpire": "Never expire", "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.", "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...", "resourcesSearch": "Search resources...",
"resourceAdd": "Add Resource", "resourceAdd": "Add Resource",
"resourceErrorDelte": "Error deleting 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", "resourcePoliciesTitle": "Manage Public Resource Policies",
"resourcePoliciesAttachedResourcesColumnTitle": "Resources", "resourcePoliciesAttachedResourcesColumnTitle": "Resources",
"resourcePoliciesAttachedResources": "{count} resource(s)", "resourcePoliciesAttachedResources": "{count} resource(s)",
@@ -290,6 +293,8 @@
"labelDelete": "Delete Label", "labelDelete": "Delete Label",
"labelAdd": "Add Label", "labelAdd": "Add Label",
"labelCreateSuccessMessage": "Label Created Successfully", "labelCreateSuccessMessage": "Label Created Successfully",
"labelDuplicateError": "Duplicate Label",
"labelDuplicateErrorDescription": "A label with this name already exists.",
"labelEditSuccessMessage": "Label Modified Successfully", "labelEditSuccessMessage": "Label Modified Successfully",
"labelNameField": "Label Name", "labelNameField": "Label Name",
"labelColorField": "Label Color", "labelColorField": "Label Color",
@@ -774,6 +779,7 @@
"rulesErrorDuplicatePriorityDescription": "Each rule must have a unique priority number.", "rulesErrorDuplicatePriorityDescription": "Each rule must have a unique priority number.",
"rulesErrorValidation": "Invalid rules", "rulesErrorValidation": "Invalid rules",
"rulesErrorValidationRuleDescription": "Rule {ruleNumber}: {message}", "rulesErrorValidationRuleDescription": "Rule {ruleNumber}: {message}",
"rulesErrorInvalidMatchTypeDescription": "Select a valid match type (path, IP, CIDR, country, region, or ASN).",
"rulesErrorValueRequired": "Enter a value for this rule.", "rulesErrorValueRequired": "Enter a value for this rule.",
"rulesErrorInvalidCountry": "Invalid country", "rulesErrorInvalidCountry": "Invalid country",
"rulesErrorInvalidCountryDescription": "Select a valid country.", "rulesErrorInvalidCountryDescription": "Select a valid country.",
@@ -843,6 +849,10 @@
"policyAuthHeaderAuthSummary": "Header configured", "policyAuthHeaderAuthSummary": "Header configured",
"policyAuthHeaderName": "Header name", "policyAuthHeaderName": "Header name",
"policyAuthHeaderValue": "Expected value", "policyAuthHeaderValue": "Expected value",
"policyAuthSetPasscode": "Set Passcode",
"policyAuthSetPincode": "Set PIN Code",
"policyAuthSetEmailWhitelist": "Set Email Whitelist",
"policyAuthSetHeaderAuth": "Set Basic Header Auth",
"policyAccessRulesTitle": "Access Rules", "policyAccessRulesTitle": "Access Rules",
"policyAccessRulesEnableDescription": "When enabled, rules are evaluated in descending order until one evaluates as true.", "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.", "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", "resourcesErrorUpdateDescription": "An error occurred while updating the resource",
"access": "Access", "access": "Access",
"accessControl": "Access Control", "accessControl": "Access Control",
"shareLink": "{resource} Share Link", "shareLink": "{resource} Shareable Link",
"resourceSelect": "Select resource", "resourceSelect": "Select resource",
"shareLinks": "Share Links", "shareLinks": "Shareable Links",
"share": "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.", "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", "shareEasyCreate": "Easy to create and share",
@@ -964,10 +974,16 @@
"resourceRoleDescription": "Admins can always access this resource.", "resourceRoleDescription": "Admins can always access this resource.",
"resourcePolicySelectTitle": "Resource Access Policy", "resourcePolicySelectTitle": "Resource Access Policy",
"resourcePolicySelectDescription": "Select the resource policy type for authentication", "resourcePolicySelectDescription": "Select the resource policy type for authentication",
"resourcePolicyTypeLabel": "Policy type",
"resourcePolicyLabel": "Resource policy",
"resourcePolicyInline": "Inline Resource Policy", "resourcePolicyInline": "Inline Resource Policy",
"resourcePolicyInlineDescription": "Access Policy scoped to only this resource", "resourcePolicyInlineDescription": "Access Policy scoped to only this resource",
"resourcePolicyShared": "Shared Resource Policy", "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", "resourceUsersRoles": "Access Controls",
"resourceUsersRolesDescription": "Configure which users and roles can visit this resource", "resourceUsersRolesDescription": "Configure which users and roles can visit this resource",
"resourceUsersRolesSubmit": "Save Access Controls", "resourceUsersRolesSubmit": "Save Access Controls",
@@ -1275,6 +1291,7 @@
"accessLabelFilterCount": "{count, plural, one {# label} other {# labels}}", "accessLabelFilterCount": "{count, plural, one {# label} other {# labels}}",
"labelOverflowCount": "+{count, plural, one {# label} other {# labels}}", "labelOverflowCount": "+{count, plural, one {# label} other {# labels}}",
"accessLabelFilterClear": "Clear label filters", "accessLabelFilterClear": "Clear label filters",
"accessFilterClear": "Clear filters",
"selectColor": "Select color", "selectColor": "Select color",
"createNewLabel": "Create new org label \"{label}\"", "createNewLabel": "Create new org label \"{label}\"",
"inviteInvalidDescription": "The invite link is invalid.", "inviteInvalidDescription": "The invite link is invalid.",
@@ -1511,7 +1528,7 @@
"sidebarResources": "Resources", "sidebarResources": "Resources",
"sidebarProxyResources": "Public", "sidebarProxyResources": "Public",
"sidebarClientResources": "Private", "sidebarClientResources": "Private",
"sidebarPolicies": "Policies", "sidebarPolicies": "Shared Policies",
"sidebarResourcePolicies": "Public Resources", "sidebarResourcePolicies": "Public Resources",
"sidebarAccessControl": "Access Control", "sidebarAccessControl": "Access Control",
"sidebarLogsAndAnalytics": "Logs & Analytics", "sidebarLogsAndAnalytics": "Logs & Analytics",
@@ -1520,7 +1537,7 @@
"sidebarAdmin": "Admin", "sidebarAdmin": "Admin",
"sidebarInvitations": "Invitations", "sidebarInvitations": "Invitations",
"sidebarRoles": "Roles", "sidebarRoles": "Roles",
"sidebarShareableLinks": "Share Links", "sidebarShareableLinks": "Shareable Links",
"sidebarApiKeys": "API Keys", "sidebarApiKeys": "API Keys",
"sidebarProvisioning": "Provisioning", "sidebarProvisioning": "Provisioning",
"sidebarSettings": "Settings", "sidebarSettings": "Settings",
@@ -2144,10 +2161,25 @@
"sshSudoModeCommandsDescription": "User can run only the specified commands with sudo.", "sshSudoModeCommandsDescription": "User can run only the specified commands with sudo.",
"sshSudo": "Allow sudo", "sshSudo": "Allow sudo",
"sshSudoCommands": "Sudo Commands", "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", "sshCreateHomeDir": "Create Home Directory",
"sshUnixGroups": "Unix Groups", "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", "retryAttempts": "Retry Attempts",
"expectedResponseCodes": "Expected Response Codes", "expectedResponseCodes": "Expected Response Codes",
"expectedResponseCodesDescription": "HTTP status code that indicates healthy status. If left blank, 200-300 is considered healthy.", "expectedResponseCodesDescription": "HTTP status code that indicates healthy status. If left blank, 200-300 is considered healthy.",
@@ -3131,6 +3163,7 @@
"maintenanceModeType": "Maintenance Mode Type", "maintenanceModeType": "Maintenance Mode Type",
"showMaintenancePage": "Show a maintenance page to visitors", "showMaintenancePage": "Show a maintenance page to visitors",
"enableMaintenanceMode": "Enable Maintenance Mode", "enableMaintenanceMode": "Enable Maintenance Mode",
"enableMaintenanceModeDescription": "When enabled, visitors will see a maintenance page instead of your resource.",
"automatic": "Automatic", "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.", "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", "forced": "Forced",
@@ -3548,5 +3581,7 @@
"rdpFilesReadyToPaste": "Files ready to paste", "rdpFilesReadyToPaste": "Files ready to paste",
"rdpFilesReadyToPasteDescription": "{count} file(s) copied to remote clipboard — press Ctrl+V on the remote desktop to paste.", "rdpFilesReadyToPasteDescription": "{count} file(s) copied to remote clipboard — press Ctrl+V on the remote desktop to paste.",
"rdpUploadFailed": "Upload failed", "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 z from "zod";
import ipaddr from "ipaddr.js"; import ipaddr from "ipaddr.js";
import { COUNTRIES } from "@server/db/countries";
import { isValidRegionId } from "@server/db/regions";
export function isValidCIDR(cidr: string): boolean { export function isValidCIDR(cidr: string): boolean {
return ( return (
@@ -67,6 +69,45 @@ export function isValidUrlGlobPattern(pattern: string): boolean {
return true; 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) { 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 if (!url) return true; // the link is optional in the schema so if it's empty it's valid
var pattern = new RegExp( var pattern = new RegExp(
+29 -3
View File
@@ -79,7 +79,10 @@ import logger from "@server/logger";
import { decrypt } from "@server/lib/crypto"; import { decrypt } from "@server/lib/crypto";
import config from "@server/lib/config"; import config from "@server/lib/config";
import { exchangeSession } from "@server/routers/badger"; 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 { checkExitNodeOrg, resolveExitNodes } from "#private/lib/exitNodes";
import { maxmindLookup } from "@server/db/maxmind"; import { maxmindLookup } from "@server/db/maxmind";
import { verifyResourceAccessToken } from "@server/auth/verifyResourceAccessToken"; import { verifyResourceAccessToken } from "@server/auth/verifyResourceAccessToken";
@@ -1754,11 +1757,34 @@ hybridRouter.post(
resourceId 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, { return response(res, {
data: result, data: modifiedResult,
success: true, success: true,
error: false, error: false,
message: result.resourceSession message: modifiedResult.resourceSession
? "Resource session token is valid" ? "Resource session token is valid"
: "Resource session token is invalid or expired", : "Resource session token is invalid or expired",
status: HttpCode.OK status: HttpCode.OK
@@ -22,7 +22,7 @@ import response from "@server/lib/response";
import logger from "@server/logger"; import logger from "@server/logger";
import type { CreateOrEditLabelResponse } from "@server/routers/labels/types"; import type { CreateOrEditLabelResponse } from "@server/routers/labels/types";
import HttpCode from "@server/types/HttpCode"; 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 { NextFunction, Request, Response } from "express";
import createHttpError from "http-errors"; import createHttpError from "http-errors";
import { z } from "zod"; 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 db.transaction(async (tx) => {
const [label] = await tx const [label] = await tx
.insert(labels) .insert(labels)
@@ -16,7 +16,7 @@ import response from "@server/lib/response";
import logger from "@server/logger"; import logger from "@server/logger";
import type { CreateOrEditLabelResponse } from "@server/routers/labels/types"; import type { CreateOrEditLabelResponse } from "@server/routers/labels/types";
import HttpCode from "@server/types/HttpCode"; 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 { NextFunction, Request, Response } from "express";
import createHttpError from "http-errors"; import createHttpError from "http-errors";
import { z } from "zod"; import { z } from "zod";
@@ -74,6 +74,29 @@ export async function updateOrgLabel(
const { name, color } = parsedBody.data; 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 const [label] = await db
.update(labels) .update(labels)
.set({ .set({
@@ -33,9 +33,8 @@ import {
import { getUniqueResourcePolicyName } from "@server/db/names"; import { getUniqueResourcePolicyName } from "@server/db/names";
import response from "@server/lib/response"; import response from "@server/lib/response";
import { import {
isValidCIDR, getResourceRuleValueValidationError,
isValidIP, RESOURCE_RULE_MATCH_TYPES
isValidUrlGlobPattern
} from "@server/lib/validators"; } from "@server/lib/validators";
import logger from "@server/logger"; import logger from "@server/logger";
import { OpenAPITags, registry } from "@server/openApi"; import { OpenAPITags, registry } from "@server/openApi";
@@ -56,9 +55,9 @@ const ruleSchema = z.strictObject({
enum: ["ACCEPT", "DROP", "PASS"], enum: ["ACCEPT", "DROP", "PASS"],
description: "rule action" description: "rule action"
}), }),
match: z.enum(["CIDR", "IP", "PATH"]).openapi({ match: z.enum(RESOURCE_RULE_MATCH_TYPES).openapi({
type: "string", type: "string",
enum: ["CIDR", "IP", "PATH"], enum: [...RESOURCE_RULE_MATCH_TYPES],
description: "rule match" description: "rule match"
}), }),
value: z.string().min(1), value: z.string().min(1),
@@ -261,26 +260,13 @@ export async function createResourcePolicy(
const niceId = await getUniqueResourcePolicyName(orgId); const niceId = await getUniqueResourcePolicyName(orgId);
for (const rule of rules) { for (const rule of rules) {
if (rule.match === "CIDR" && !isValidCIDR(rule.value)) { const validationError = getResourceRuleValueValidationError(
rule.match,
rule.value
);
if (validationError) {
return next( return next(
createHttpError( createHttpError(HttpCode.BAD_REQUEST, validationError)
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"
)
); );
} }
} }
+6 -4
View File
@@ -20,7 +20,8 @@ import {
ResourcePolicyPincode, ResourcePolicyPincode,
ResourcePolicyPassword, ResourcePolicyPassword,
ResourcePolicyHeaderAuth, ResourcePolicyHeaderAuth,
ResourceRule ResourceRule,
ResourceSession
} from "@server/db"; } from "@server/db";
import config from "@server/lib/config"; import config from "@server/lib/config";
import { isIpInCidr, stripPortFromHost } from "@server/lib/ip"; import { isIpInCidr, stripPortFromHost } from "@server/lib/ip";
@@ -536,7 +537,8 @@ export async function verifyResourceSession(
if (resourceSessionToken) { if (resourceSessionToken) {
const sessionCacheKey = `session:${resourceSessionToken}`; const sessionCacheKey = `session:${resourceSessionToken}`;
let resourceSession: any = localCache.get(sessionCacheKey); let resourceSession: ResourceSession | null | undefined =
localCache.get(sessionCacheKey);
if (!resourceSession) { if (!resourceSession) {
const result = await validateResourceSessionToken( const result = await validateResourceSessionToken(
@@ -671,7 +673,7 @@ export async function verifyResourceSession(
orgId: resource.orgId, orgId: resource.orgId,
location: ipCC, location: ipCC,
apiKey: { apiKey: {
name: resourceSession.accessTokenTitle, name: null,
apiKeyId: resourceSession.accessTokenId apiKeyId: resourceSession.accessTokenId
} }
}, },
@@ -717,7 +719,7 @@ export async function verifyResourceSession(
location: ipCC, location: ipCC,
user: { user: {
username: allowedUserData.username, username: allowedUserData.username,
userId: resourceSession.userId userId: allowedUserData.userId
} }
}, },
parsedBody.data parsedBody.data
+7
View File
@@ -666,6 +666,13 @@ authenticated.get(
resource.getResourcePolicies resource.getResourcePolicies
); );
authenticated.get(
"/resource-policy/:resourcePolicyId",
verifyResourcePolicyAccess,
verifyUserHasAction(ActionsEnum.getResourcePolicy),
policy.getResourcePolicy
);
authenticated.put( authenticated.put(
"/resource-policy/:resourcePolicyId", "/resource-policy/:resourcePolicyId",
verifyResourcePolicyAccess, verifyResourcePolicyAccess,
+10 -24
View File
@@ -8,9 +8,8 @@ import createHttpError from "http-errors";
import logger from "@server/logger"; import logger from "@server/logger";
import { fromError } from "zod-validation-error"; import { fromError } from "zod-validation-error";
import { import {
isValidCIDR, getResourceRuleValueValidationError,
isValidIP, RESOURCE_RULE_MATCH_TYPES
isValidUrlGlobPattern
} from "@server/lib/validators"; } from "@server/lib/validators";
import { OpenAPITags, registry } from "@server/openApi"; import { OpenAPITags, registry } from "@server/openApi";
@@ -20,9 +19,9 @@ const ruleSchema = z.strictObject({
enum: ["ACCEPT", "DROP", "PASS"], enum: ["ACCEPT", "DROP", "PASS"],
description: "rule action" description: "rule action"
}), }),
match: z.enum(["CIDR", "IP", "PATH"]).openapi({ match: z.enum(RESOURCE_RULE_MATCH_TYPES).openapi({
type: "string", type: "string",
enum: ["CIDR", "IP", "PATH"], enum: [...RESOURCE_RULE_MATCH_TYPES],
description: "rule match" description: "rule match"
}), }),
value: z.string().min(1), value: z.string().min(1),
@@ -105,26 +104,13 @@ export async function setResourcePolicyRules(
} }
for (const rule of rules) { for (const rule of rules) {
if (rule.match === "CIDR" && !isValidCIDR(rule.value)) { const validationError = getResourceRuleValueValidationError(
rule.match,
rule.value
);
if (validationError) {
return next( return next(
createHttpError( createHttpError(HttpCode.BAD_REQUEST, validationError)
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"
)
); );
} }
} }
@@ -225,7 +225,7 @@ export async function getResourceAuthInfo(
wildcard: resource.wildcard ?? false, wildcard: resource.wildcard ?? false,
fullDomain: resource.fullDomain, fullDomain: resource.fullDomain,
whitelist: effectivePolicy?.emailWhitelistEnabled ?? false, whitelist: effectivePolicy?.emailWhitelistEnabled ?? false,
skipToIdpId: resource.skipToIdpId, skipToIdpId: effectivePolicy?.idpId ?? resource.skipToIdpId,
orgId: resource.orgId, orgId: resource.orgId,
postAuthPath: resource.postAuthPath ?? null postAuthPath: resource.postAuthPath ?? null
}, },
+85 -5
View File
@@ -1,6 +1,6 @@
import { Request, Response, NextFunction } from "express"; import { Request, Response, NextFunction } from "express";
import { db, DB_TYPE } from "@server/db"; import { db, DB_TYPE, type Label } from "@server/db";
import { and, eq, or, inArray, sql } from "drizzle-orm"; import { and, asc, eq, or, inArray, sql } from "drizzle-orm";
import { import {
resources, resources,
userResources, userResources,
@@ -20,12 +20,17 @@ import {
userSiteResources, userSiteResources,
roleSiteResources, roleSiteResources,
siteNetworks, siteNetworks,
sites sites,
labels,
resourceLabels,
siteResourceLabels
} from "@server/db"; } from "@server/db";
import createHttpError from "http-errors"; import createHttpError from "http-errors";
import HttpCode from "@server/types/HttpCode"; import HttpCode from "@server/types/HttpCode";
import { response } from "@server/lib/response"; import { response } from "@server/lib/response";
import { getFirstString } from "@server/lib/requestParams"; import { getFirstString } from "@server/lib/requestParams";
import { isLicensedOrSubscribed } from "#dynamic/lib/isLicencedOrSubscribed";
import { tierMatrix } from "@server/lib/billing/tierMatrix";
export async function getUserResources( export async function getUserResources(
req: Request, 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 // Check for password, pincode, and whitelist protection for each resource
const resourcesWithAuth = await Promise.all( const resourcesWithAuth = await Promise.all(
resourcesData.map(async (resource) => { resourcesData.map(async (resource) => {
@@ -453,7 +525,10 @@ export async function getUserResources(
sso: resource.sso, sso: resource.sso,
password: hasPassword, password: hasPassword,
pincode: hasPincode, 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, siteNiceIds: siteResource.siteNiceIds,
siteAddresses: siteResource.siteAddresses, siteAddresses: siteResource.siteAddresses,
siteOnlines: siteResource.siteOnlines, 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; enabled: boolean;
protected: boolean; protected: boolean;
mode: string; mode: string;
labels?: Array<Pick<Label, "color" | "labelId" | "name">>;
}>; }>;
siteResources: Array<{ siteResources: Array<{
siteResourceId: number; siteResourceId: number;
@@ -535,6 +614,7 @@ export type GetUserResourcesResponse = {
siteAddresses: (string | null)[]; siteAddresses: (string | null)[];
siteOnlines: boolean[]; siteOnlines: boolean[];
type: "site"; type: "site";
labels?: Array<Pick<Label, "color" | "labelId" | "name">>;
}>; }>;
}; };
}; };
+11
View File
@@ -197,6 +197,11 @@ export default async function migration() {
await db.execute( await db.execute(
sql`ALTER TABLE "siteResources" ADD COLUMN "pamMode" varchar(32) DEFAULT 'passthrough';` 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( await db.execute(
sql`ALTER TABLE "sites" ADD COLUMN "autoUpdateEnabled" boolean DEFAULT false NOT NULL;` sql`ALTER TABLE "sites" ADD COLUMN "autoUpdateEnabled" boolean DEFAULT false NOT NULL;`
); );
@@ -274,6 +279,12 @@ export default async function migration() {
await db.execute( await db.execute(
sql`ALTER TABLE "targets" ADD "mode" text DEFAULT 'http' NOT NULL;` 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`ALTER TABLE "targets" ADD "authToken" text;`);
await db.execute(sql`COMMIT`); 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'; ALTER TABLE 'siteResources' ADD COLUMN 'pamMode' text DEFAULT 'passthrough';
` `
).run(); ).run();
db.prepare(
`
UPDATE 'siteResources'
SET "pamMode" = 'push'
WHERE LOWER(COALESCE("mode", '')) = 'host';
`
).run();
db.prepare( db.prepare(
` `
@@ -339,6 +346,15 @@ export default async function migration() {
ALTER TABLE 'targets' ADD 'mode' text DEFAULT 'http' NOT NULL; ALTER TABLE 'targets' ADD 'mode' text DEFAULT 'http' NOT NULL;
` `
).run(); ).run();
db.prepare(
`
UPDATE 'targets'
SET 'mode' = (
SELECT 'mode' FROM 'resources'
WHERE 'resources'.'resourceId' = 'targets'.'resourceId'
);
`
).run();
db.prepare( db.prepare(
` `
ALTER TABLE 'targets' ADD 'authToken' text; ALTER TABLE 'targets' ADD 'authToken' text;
@@ -10,6 +10,8 @@ import { formatAxiosError } from "@app/lib/api";
import { AxiosResponse } from "axios"; import { AxiosResponse } from "axios";
import { import {
SettingsContainer, SettingsContainer,
SettingsFormCell,
SettingsFormGrid,
SettingsSection, SettingsSection,
SettingsSectionHeader, SettingsSectionHeader,
SettingsSectionTitle, SettingsSectionTitle,
@@ -1324,42 +1326,44 @@ export default function BillingPage() {
</SettingsSectionDescription> </SettingsSectionDescription>
</SettingsSectionHeader> </SettingsSectionHeader>
<SettingsSectionBody> <SettingsSectionBody>
<div className="w-full md:w-1/2"> <SettingsFormGrid>
<div className="flex flex-col md:flex-row items-start md:items-center justify-between gap-4 border rounded-lg p-4"> <SettingsFormCell span="half">
<div> <div className="flex flex-col md:flex-row items-start md:items-center justify-between gap-4 border rounded-lg p-4">
<div className="text-sm text-muted-foreground mb-1"> <div>
{t("billingCurrentKeys") || <div className="text-sm text-muted-foreground mb-1">
"Current Keys"} {t("billingCurrentKeys") ||
</div> "Current Keys"}
<div className="flex items-baseline gap-2"> </div>
<span className="text-3xl font-semibold"> <div className="flex items-baseline gap-2">
{getLicenseKeyCount()} <span className="text-3xl font-semibold">
</span> {getLicenseKeyCount()}
<span className="text-lg"> </span>
{getLicenseKeyCount() === 1 <span className="text-lg">
? "key" {getLicenseKeyCount() === 1
: "keys"} ? "key"
</span> : "keys"}
</span>
</div>
</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> </div>
<Button </SettingsFormCell>
variant="outline" </SettingsFormGrid>
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>
</SettingsSectionBody> </SettingsSectionBody>
</SettingsSection> </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"; import { redirect } from "next/navigation";
export interface EditPolicyPageProps { type EditPolicyPageProps = {
params: Promise<{ niceId: string; orgId: string }>; params: Promise<{ niceId: string; orgId: string }>;
} };
export default async function EditPolicyPage(props: EditPolicyPageProps) { export default async function EditPolicyPage(props: EditPolicyPageProps) {
const params = await props.params; const params = await props.params;
const t = await getTranslations(); redirect(
`/${params.orgId}/settings/policies/resources/public/${params.niceId}/general`
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>
</>
); );
} }
@@ -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 { ResourcePoliciesTable } from "@app/components/ResourcePoliciesTable";
import SettingsSectionTitle from "@app/components/SettingsSectionTitle"; import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
import { internal } from "@app/lib/api"; import { internal } from "@app/lib/api";
@@ -54,6 +55,8 @@ export default async function ResourcePoliciesPage(
description={t("resourcePoliciesDescription")} description={t("resourcePoliciesDescription")}
/> />
<ResourcePoliciesBanner />
<ResourcePoliciesTable <ResourcePoliciesTable
policies={policies} policies={policies}
orgId={params.orgId} orgId={params.orgId}
@@ -9,6 +9,8 @@ import {
} from "@app/components/InfoSection"; } from "@app/components/InfoSection";
import { import {
SettingsContainer, SettingsContainer,
SettingsFormCell,
SettingsFormGrid,
SettingsSection, SettingsSection,
SettingsSectionBody, SettingsSectionBody,
SettingsSectionDescription, SettingsSectionDescription,
@@ -257,80 +259,87 @@ export default function Page() {
e.preventDefault(); // block default enter refresh 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" id="create-client-form"
> >
<FormField <SettingsFormGrid>
control={form.control} <SettingsFormCell span="half">
name="name" <FormField
render={({ field }) => ( control={form.control}
<FormItem> name="name"
<FormLabel> render={({ field }) => (
{t("name")} <FormItem>
</FormLabel> <FormLabel>
<FormControl> {t("name")}
<Input </FormLabel>
autoComplete="off" <FormControl>
{...field} <Input
/> autoComplete="off"
</FormControl> {...field}
<FormMessage /> />
<FormDescription> </FormControl>
{t( <FormMessage />
"clientNameDescription" <FormDescription>
)} {t(
</FormDescription> "clientNameDescription"
</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"
)} )}
{...field} </FormDescription>
/> </FormItem>
</FormControl> )}
<FormMessage /> />
<FormDescription> </SettingsFormCell>
{t( <SettingsFormCell className="flex items-center justify-end md:col-span-2">
"addressDescription" <Button
)} type="button"
</FormDescription> variant="ghost"
</FormItem> 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>
</Form> </Form>
</SettingsSectionBody> </SettingsSectionBody>
+29 -29
View File
@@ -11,7 +11,7 @@ import { ColumnDef } from "@tanstack/react-table";
import { DateTimeValue } from "@app/components/DateTimePicker"; import { DateTimeValue } from "@app/components/DateTimePicker";
import { ArrowUpRight, Key, User } from "lucide-react"; import { ArrowUpRight, Key, User } from "lucide-react";
import Link from "next/link"; import Link from "next/link";
import { ColumnFilter } from "@app/components/ColumnFilter"; import { ColumnFilterButton } from "@app/components/ColumnFilterButton";
import SettingsSectionTitle from "@app/components/SettingsSectionTitle"; import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
import { build } from "@server/build"; import { build } from "@server/build";
import { getSevenDaysAgo } from "@app/lib/getSevenDaysAgo"; import { getSevenDaysAgo } from "@app/lib/getSevenDaysAgo";
@@ -233,7 +233,7 @@ export default function GeneralPage() {
{ {
accessorKey: "timestamp", accessorKey: "timestamp",
header: () => { header: () => {
return t("timestamp"); return <span className="px-2">{t("timestamp")}</span>;
}, },
cell: ({ row }) => { cell: ({ row }) => {
return ( return (
@@ -249,19 +249,19 @@ export default function GeneralPage() {
accessorKey: "action", accessorKey: "action",
header: () => { header: () => {
return ( return (
<div className="flex items-center gap-2"> <div className="flex items-center gap-2 px-2">
<span>{t("action")}</span> <ColumnFilterButton
<ColumnFilter
options={[ options={[
{ value: "true", label: "Allowed" }, { value: "true", label: "Allowed" },
{ value: "false", label: "Denied" } { value: "false", label: "Denied" }
]} ]}
label={t("action")}
selectedValue={filters.action} selectedValue={filters.action}
onValueChange={(value) => onValueChange={(value) =>
handleFilterChange("action", value) handleFilterChange("action", value)
} }
searchPlaceholder="Search..." searchPlaceholder={t("searchPlaceholder")}
emptyMessage="None found" emptyMessage={t("emptySearchOptions")}
/> />
</div> </div>
); );
@@ -276,27 +276,27 @@ export default function GeneralPage() {
}, },
{ {
accessorKey: "ip", accessorKey: "ip",
header: () => t("ip") header: () => <span className="px-2">{t("ip")}</span>
}, },
{ {
accessorKey: "location", accessorKey: "location",
header: () => { header: () => {
return ( return (
<div className="flex items-center gap-2"> <div className="flex items-center gap-2 px-2">
<span>{t("location")}</span> <ColumnFilterButton
<ColumnFilter
options={filterAttributes.locations.map( options={filterAttributes.locations.map(
(location) => ({ (location) => ({
value: location, value: location,
label: location label: location
}) })
)} )}
label={t("location")}
selectedValue={filters.location} selectedValue={filters.location}
onValueChange={(value) => onValueChange={(value) =>
handleFilterChange("location", value) handleFilterChange("location", value)
} }
searchPlaceholder="Search..." searchPlaceholder={t("searchPlaceholder")}
emptyMessage="None found" emptyMessage={t("emptySearchOptions")}
/> />
</div> </div>
); );
@@ -321,19 +321,19 @@ export default function GeneralPage() {
accessorKey: "resourceName", accessorKey: "resourceName",
header: () => { header: () => {
return ( return (
<div className="flex items-center gap-2"> <div className="flex items-center gap-2 px-2">
<span>{t("resource")}</span> <ColumnFilterButton
<ColumnFilter
options={filterAttributes.resources.map((res) => ({ options={filterAttributes.resources.map((res) => ({
value: res.id.toString(), value: res.id.toString(),
label: res.name || "Unnamed Resource" label: res.name || "Unnamed Resource"
}))} }))}
label={t("resource")}
selectedValue={filters.resourceId} selectedValue={filters.resourceId}
onValueChange={(value) => onValueChange={(value) =>
handleFilterChange("resourceId", value) handleFilterChange("resourceId", value)
} }
searchPlaceholder="Search..." searchPlaceholder={t("searchPlaceholder")}
emptyMessage="None found" emptyMessage={t("emptySearchOptions")}
/> />
</div> </div>
); );
@@ -359,9 +359,8 @@ export default function GeneralPage() {
accessorKey: "type", accessorKey: "type",
header: () => { header: () => {
return ( return (
<div className="flex items-center gap-2"> <div className="flex items-center gap-2 px-2">
<span>{t("type")}</span> <ColumnFilterButton
<ColumnFilter
options={[ options={[
{ value: "password", label: "Password" }, { value: "password", label: "Password" },
{ value: "pincode", label: "Pincode" }, { value: "pincode", label: "Pincode" },
@@ -372,12 +371,13 @@ export default function GeneralPage() {
}, },
{ value: "ssh", label: "SSH" } { value: "ssh", label: "SSH" }
]} ]}
label={t("type")}
selectedValue={filters.type} selectedValue={filters.type}
onValueChange={(value) => onValueChange={(value) =>
handleFilterChange("type", value) handleFilterChange("type", value)
} }
searchPlaceholder="Search..." searchPlaceholder={t("searchPlaceholder")}
emptyMessage="None found" emptyMessage={t("emptySearchOptions")}
/> />
</div> </div>
); );
@@ -395,19 +395,19 @@ export default function GeneralPage() {
accessorKey: "actor", accessorKey: "actor",
header: () => { header: () => {
return ( return (
<div className="flex items-center gap-2"> <div className="flex items-center gap-2 px-2">
<span>{t("actor")}</span> <ColumnFilterButton
<ColumnFilter
options={filterAttributes.actors.map((actor) => ({ options={filterAttributes.actors.map((actor) => ({
value: actor, value: actor,
label: actor label: actor
}))} }))}
label={t("actor")}
selectedValue={filters.actor} selectedValue={filters.actor}
onValueChange={(value) => onValueChange={(value) =>
handleFilterChange("actor", value) handleFilterChange("actor", value)
} }
searchPlaceholder="Search..." searchPlaceholder={t("searchPlaceholder")}
emptyMessage="None found" emptyMessage={t("emptySearchOptions")}
/> />
</div> </div>
); );
@@ -433,7 +433,7 @@ export default function GeneralPage() {
}, },
{ {
accessorKey: "actorId", accessorKey: "actorId",
header: () => t("actorId"), header: () => <span className="px-2">{t("actorId")}</span>,
cell: ({ row }) => ( cell: ({ row }) => (
<span className="flex items-center gap-1"> <span className="flex items-center gap-1">
{row.original.actorId || "-"} {row.original.actorId || "-"}
+13 -17
View File
@@ -1,5 +1,5 @@
"use client"; "use client";
import { ColumnFilter } from "@app/components/ColumnFilter"; import { ColumnFilterButton } from "@app/components/ColumnFilterButton";
import { DateTimeValue } from "@app/components/DateTimePicker"; import { DateTimeValue } from "@app/components/DateTimePicker";
import { LogDataTable } from "@app/components/LogDataTable"; import { LogDataTable } from "@app/components/LogDataTable";
import { PaidFeaturesAlert } from "@app/components/PaidFeaturesAlert"; import { PaidFeaturesAlert } from "@app/components/PaidFeaturesAlert";
@@ -219,9 +219,7 @@ export default function GeneralPage() {
const columns: ColumnDef<any>[] = [ const columns: ColumnDef<any>[] = [
{ {
accessorKey: "timestamp", accessorKey: "timestamp",
header: () => { header: () => <span className="px-2">{t("timestamp")}</span>,
return t("timestamp");
},
cell: ({ row }) => { cell: ({ row }) => {
return ( return (
<div className="whitespace-nowrap"> <div className="whitespace-nowrap">
@@ -236,16 +234,16 @@ export default function GeneralPage() {
accessorKey: "action", accessorKey: "action",
header: () => { header: () => {
return ( return (
<div className="flex items-center gap-2"> <div className="flex items-center gap-2 px-2">
<span>{t("action")}</span> <ColumnFilterButton
<ColumnFilter
options={[]} options={[]}
label={t("action")}
selectedValue={filters.action} selectedValue={filters.action}
onValueChange={(value) => onValueChange={(value) =>
handleFilterChange("action", value) handleFilterChange("action", value)
} }
searchPlaceholder="Search..." searchPlaceholder={t("searchPlaceholder")}
emptyMessage="None found" emptyMessage={t("emptySearchOptions")}
/> />
</div> </div>
); );
@@ -263,19 +261,19 @@ export default function GeneralPage() {
accessorKey: "actor", accessorKey: "actor",
header: () => { header: () => {
return ( return (
<div className="flex items-center gap-2"> <div className="flex items-center gap-2 px-2">
<span>{t("actor")}</span> <ColumnFilterButton
<ColumnFilter
options={filterAttributes.actors.map((actor) => ({ options={filterAttributes.actors.map((actor) => ({
value: actor, value: actor,
label: actor label: actor
}))} }))}
label={t("actor")}
selectedValue={filters.actor} selectedValue={filters.actor}
onValueChange={(value) => onValueChange={(value) =>
handleFilterChange("actor", value) handleFilterChange("actor", value)
} }
searchPlaceholder="Search..." searchPlaceholder={t("searchPlaceholder")}
emptyMessage="None found" emptyMessage={t("emptySearchOptions")}
/> />
</div> </div>
); );
@@ -295,9 +293,7 @@ export default function GeneralPage() {
}, },
{ {
accessorKey: "actorId", accessorKey: "actorId",
header: () => { header: () => <span className="px-2">{t("actorId")}</span>,
return t("actorId");
},
cell: ({ row }) => { cell: ({ row }) => {
return ( return (
<span className="flex items-center gap-1"> <span className="flex items-center gap-1">
@@ -1,6 +1,6 @@
"use client"; "use client";
import { Button } from "@app/components/ui/button"; 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 { DateTimeValue } from "@app/components/DateTimePicker";
import { LogDataTable } from "@app/components/LogDataTable"; import { LogDataTable } from "@app/components/LogDataTable";
import { PaidFeaturesAlert } from "@app/components/PaidFeaturesAlert"; import { PaidFeaturesAlert } from "@app/components/PaidFeaturesAlert";
@@ -256,9 +256,7 @@ export default function ConnectionLogsPage() {
const columns: ColumnDef<any>[] = [ const columns: ColumnDef<any>[] = [
{ {
accessorKey: "startedAt", accessorKey: "startedAt",
header: () => { header: () => <span className="px-2">{t("timestamp")}</span>,
return t("timestamp");
},
cell: ({ row }) => { cell: ({ row }) => {
return ( return (
<div className="whitespace-nowrap"> <div className="whitespace-nowrap">
@@ -273,21 +271,21 @@ export default function ConnectionLogsPage() {
accessorKey: "protocol", accessorKey: "protocol",
header: () => { header: () => {
return ( return (
<div className="flex items-center gap-2"> <div className="flex items-center gap-2 px-2">
<span>{t("protocol")}</span> <ColumnFilterButton
<ColumnFilter
options={filterAttributes.protocols.map( options={filterAttributes.protocols.map(
(protocol) => ({ (protocol) => ({
label: protocol.toUpperCase(), label: protocol.toUpperCase(),
value: protocol value: protocol
}) })
)} )}
label={t("protocol")}
selectedValue={filters.protocol} selectedValue={filters.protocol}
onValueChange={(value) => onValueChange={(value) =>
handleFilterChange("protocol", value) handleFilterChange("protocol", value)
} }
searchPlaceholder="Search..." searchPlaceholder={t("searchPlaceholder")}
emptyMessage="None found" emptyMessage={t("emptySearchOptions")}
/> />
</div> </div>
); );
@@ -304,19 +302,19 @@ export default function ConnectionLogsPage() {
accessorKey: "resourceName", accessorKey: "resourceName",
header: () => { header: () => {
return ( return (
<div className="flex items-center gap-2"> <div className="flex items-center gap-2 px-2">
<span>{t("resource")}</span> <ColumnFilterButton
<ColumnFilter
options={filterAttributes.resources.map((res) => ({ options={filterAttributes.resources.map((res) => ({
value: res.id.toString(), value: res.id.toString(),
label: res.name || "Unnamed Resource" label: res.name || "Unnamed Resource"
}))} }))}
label={t("resource")}
selectedValue={filters.siteResourceId} selectedValue={filters.siteResourceId}
onValueChange={(value) => onValueChange={(value) =>
handleFilterChange("siteResourceId", value) handleFilterChange("siteResourceId", value)
} }
searchPlaceholder="Search..." searchPlaceholder={t("searchPlaceholder")}
emptyMessage="None found" emptyMessage={t("emptySearchOptions")}
/> />
</div> </div>
); );
@@ -345,19 +343,19 @@ export default function ConnectionLogsPage() {
accessorKey: "clientName", accessorKey: "clientName",
header: () => { header: () => {
return ( return (
<div className="flex items-center gap-2"> <div className="flex items-center gap-2 px-2">
<span>{t("client")}</span> <ColumnFilterButton
<ColumnFilter
options={filterAttributes.clients.map((c) => ({ options={filterAttributes.clients.map((c) => ({
value: c.id.toString(), value: c.id.toString(),
label: c.name label: c.name
}))} }))}
label={t("client")}
selectedValue={filters.clientId} selectedValue={filters.clientId}
onValueChange={(value) => onValueChange={(value) =>
handleFilterChange("clientId", value) handleFilterChange("clientId", value)
} }
searchPlaceholder="Search..." searchPlaceholder={t("searchPlaceholder")}
emptyMessage="None found" emptyMessage={t("emptySearchOptions")}
/> />
</div> </div>
); );
@@ -388,19 +386,19 @@ export default function ConnectionLogsPage() {
accessorKey: "userEmail", accessorKey: "userEmail",
header: () => { header: () => {
return ( return (
<div className="flex items-center gap-2"> <div className="flex items-center gap-2 px-2">
<span>{t("user")}</span> <ColumnFilterButton
<ColumnFilter
options={filterAttributes.users.map((u) => ({ options={filterAttributes.users.map((u) => ({
value: u.id, value: u.id,
label: u.email || u.id label: u.email || u.id
}))} }))}
label={t("user")}
selectedValue={filters.userId} selectedValue={filters.userId}
onValueChange={(value) => onValueChange={(value) =>
handleFilterChange("userId", value) handleFilterChange("userId", value)
} }
searchPlaceholder="Search..." searchPlaceholder={t("searchPlaceholder")}
emptyMessage="None found" emptyMessage={t("emptySearchOptions")}
/> />
</div> </div>
); );
@@ -419,9 +417,7 @@ export default function ConnectionLogsPage() {
}, },
{ {
accessorKey: "sourceAddr", accessorKey: "sourceAddr",
header: () => { header: () => <span className="px-2">{t("sourceAddress")}</span>,
return t("sourceAddress");
},
cell: ({ row }) => { cell: ({ row }) => {
return ( return (
<span className="whitespace-nowrap font-mono text-xs"> <span className="whitespace-nowrap font-mono text-xs">
@@ -434,19 +430,19 @@ export default function ConnectionLogsPage() {
accessorKey: "destAddr", accessorKey: "destAddr",
header: () => { header: () => {
return ( return (
<div className="flex items-center gap-2"> <div className="flex items-center gap-2 px-2">
<span>{t("destinationAddress")}</span> <ColumnFilterButton
<ColumnFilter
options={filterAttributes.destAddrs.map((addr) => ({ options={filterAttributes.destAddrs.map((addr) => ({
value: addr, value: addr,
label: addr label: addr
}))} }))}
label={t("destinationAddress")}
selectedValue={filters.destAddr} selectedValue={filters.destAddr}
onValueChange={(value) => onValueChange={(value) =>
handleFilterChange("destAddr", value) handleFilterChange("destAddr", value)
} }
searchPlaceholder="Search..." searchPlaceholder={t("searchPlaceholder")}
emptyMessage="None found" emptyMessage={t("emptySearchOptions")}
/> />
</div> </div>
); );
@@ -461,9 +457,7 @@ export default function ConnectionLogsPage() {
}, },
{ {
accessorKey: "duration", accessorKey: "duration",
header: () => { header: () => <span className="px-2">{t("duration")}</span>,
return t("duration");
},
cell: ({ row }) => { cell: ({ row }) => {
return ( return (
<span className="whitespace-nowrap"> <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 { useStoredPageSize } from "@app/hooks/useStoredPageSize";
import { build } from "@server/build"; import { build } from "@server/build";
import type { QueryRequestAuditLogResponse } from "@server/routers/auditLogs/types"; import type { QueryRequestAuditLogResponse } from "@server/routers/auditLogs/types";
import { ColumnFilterButton } from "@app/components/ColumnFilterButton";
export default function GeneralPage() { export default function GeneralPage() {
const router = useRouter(); const router = useRouter();
@@ -284,9 +285,9 @@ export default function GeneralPage() {
const columns: ColumnDef<any>[] = [ const columns: ColumnDef<any>[] = [
{ {
accessorKey: "timestamp", accessorKey: "timestamp",
header: ({ column }) => { header: ({ column }) => (
return t("timestamp"); <span className="px-2">{t("timestamp")}</span>
}, ),
cell: ({ row }) => { cell: ({ row }) => {
return ( return (
<div className="whitespace-nowrap"> <div className="whitespace-nowrap">
@@ -299,22 +300,21 @@ export default function GeneralPage() {
}, },
{ {
accessorKey: "action", accessorKey: "action",
header: ({ column }) => { header: () => {
return ( return (
<div className="flex items-center gap-2"> <div className="flex items-center gap-2 px-2">
<span>{t("action")}</span> <ColumnFilterButton
<ColumnFilter
options={[ options={[
{ value: "true", label: "Allowed" }, { value: "true", label: "Allowed" },
{ value: "false", label: "Denied" } { value: "false", label: "Denied" }
]} ]}
label={t("action")}
selectedValue={filters.action} selectedValue={filters.action}
onValueChange={(value) => onValueChange={(value) =>
handleFilterChange("action", value) handleFilterChange("action", value)
} }
// placeholder="" searchPlaceholder={t("searchPlaceholder")}
searchPlaceholder="Search..." emptyMessage={t("emptySearchOptions")}
emptyMessage="None found"
/> />
</div> </div>
); );
@@ -329,17 +329,14 @@ export default function GeneralPage() {
}, },
{ {
accessorKey: "ip", accessorKey: "ip",
header: ({ column }) => { header: ({ column }) => <span className="px-2">{t("ip")}</span>
return t("ip");
}
}, },
{ {
accessorKey: "location", accessorKey: "location",
header: ({ column }) => { header: ({ column }) => {
return ( return (
<div className="flex items-center gap-2"> <div className="flex items-center gap-2 px-2">
<span>{t("location")}</span> <ColumnFilterButton
<ColumnFilter
options={filterAttributes.locations.map( options={filterAttributes.locations.map(
(location) => ({ (location) => ({
value: location, value: location,
@@ -351,8 +348,9 @@ export default function GeneralPage() {
handleFilterChange("location", value) handleFilterChange("location", value)
} }
// placeholder="" // placeholder=""
searchPlaceholder="Search..." label={t("location")}
emptyMessage="None found" searchPlaceholder={t("searchPlaceholder")}
emptyMessage={t("emptySearchOptions")}
/> />
</div> </div>
); );
@@ -377,9 +375,8 @@ export default function GeneralPage() {
accessorKey: "resourceName", accessorKey: "resourceName",
header: ({ column }) => { header: ({ column }) => {
return ( return (
<div className="flex items-center gap-2"> <div className="flex items-center gap-2 px-2">
<span>{t("resource")}</span> <ColumnFilterButton
<ColumnFilter
options={filterAttributes.resources.map((res) => ({ options={filterAttributes.resources.map((res) => ({
value: res.id.toString(), value: res.id.toString(),
label: res.name || "Unnamed Resource" label: res.name || "Unnamed Resource"
@@ -388,9 +385,9 @@ export default function GeneralPage() {
onValueChange={(value) => onValueChange={(value) =>
handleFilterChange("resourceId", value) handleFilterChange("resourceId", value)
} }
// placeholder="" label={t("resource")}
searchPlaceholder="Search..." searchPlaceholder={t("searchPlaceholder")}
emptyMessage="None found" emptyMessage={t("emptySearchOptions")}
/> />
</div> </div>
); );
@@ -417,9 +414,8 @@ export default function GeneralPage() {
accessorKey: "host", accessorKey: "host",
header: ({ column }) => { header: ({ column }) => {
return ( return (
<div className="flex items-center gap-2"> <div className="flex items-center gap-2 px-2">
<span>{t("host")}</span> <ColumnFilterButton
<ColumnFilter
options={filterAttributes.hosts.map((host) => ({ options={filterAttributes.hosts.map((host) => ({
value: host, value: host,
label: host label: host
@@ -428,9 +424,9 @@ export default function GeneralPage() {
onValueChange={(value) => onValueChange={(value) =>
handleFilterChange("host", value) handleFilterChange("host", value)
} }
// placeholder="" label={t("host")}
searchPlaceholder="Search..." searchPlaceholder={t("searchPlaceholder")}
emptyMessage="None found" emptyMessage={t("emptySearchOptions")}
/> />
</div> </div>
); );
@@ -452,9 +448,8 @@ export default function GeneralPage() {
accessorKey: "path", accessorKey: "path",
header: ({ column }) => { header: ({ column }) => {
return ( return (
<div className="flex items-center gap-2"> <div className="flex items-center gap-2 px-2">
<span>{t("path")}</span> <ColumnFilterButton
<ColumnFilter
options={filterAttributes.paths.map((path) => ({ options={filterAttributes.paths.map((path) => ({
value: path, value: path,
label: path label: path
@@ -463,9 +458,9 @@ export default function GeneralPage() {
onValueChange={(value) => onValueChange={(value) =>
handleFilterChange("path", value) handleFilterChange("path", value)
} }
// placeholder="" label={t("path")}
searchPlaceholder="Search..." searchPlaceholder={t("searchPlaceholder")}
emptyMessage="None found" emptyMessage={t("emptySearchOptions")}
/> />
</div> </div>
); );
@@ -482,9 +477,8 @@ export default function GeneralPage() {
accessorKey: "method", accessorKey: "method",
header: ({ column }) => { header: ({ column }) => {
return ( return (
<div className="flex items-center gap-2"> <div className="flex items-center gap-2 px-2">
<span>{t("method")}</span> <ColumnFilterButton
<ColumnFilter
options={[ options={[
{ value: "GET", label: "GET" }, { value: "GET", label: "GET" },
{ value: "POST", label: "POST" }, { value: "POST", label: "POST" },
@@ -498,9 +492,9 @@ export default function GeneralPage() {
onValueChange={(value) => onValueChange={(value) =>
handleFilterChange("method", value) handleFilterChange("method", value)
} }
// placeholder="" label={t("method")}
searchPlaceholder="Search..." searchPlaceholder={t("searchPlaceholder")}
emptyMessage="None found" emptyMessage={t("emptySearchOptions")}
/> />
</div> </div>
); );
@@ -510,9 +504,8 @@ export default function GeneralPage() {
accessorKey: "reason", accessorKey: "reason",
header: ({ column }) => { header: ({ column }) => {
return ( return (
<div className="flex items-center gap-2"> <div className="flex items-center gap-2 px-2">
<span>{t("reason")}</span> <ColumnFilterButton
<ColumnFilter
options={[ options={[
{ value: "100", label: t("allowedByRule") }, { value: "100", label: t("allowedByRule") },
{ value: "101", label: t("allowedNoAuth") }, { value: "101", label: t("allowedNoAuth") },
@@ -537,9 +530,9 @@ export default function GeneralPage() {
onValueChange={(value) => onValueChange={(value) =>
handleFilterChange("reason", value) handleFilterChange("reason", value)
} }
// placeholder="" label={t("reason")}
searchPlaceholder="Search..." searchPlaceholder={t("searchPlaceholder")}
emptyMessage="None found" emptyMessage={t("emptySearchOptions")}
/> />
</div> </div>
); );
@@ -556,9 +549,8 @@ export default function GeneralPage() {
accessorKey: "actor", accessorKey: "actor",
header: ({ column }) => { header: ({ column }) => {
return ( return (
<div className="flex items-center gap-2"> <div className="flex items-center gap-2 px-2">
<span>{t("actor")}</span> <ColumnFilterButton
<ColumnFilter
options={filterAttributes.actors.map((actor) => ({ options={filterAttributes.actors.map((actor) => ({
value: actor, value: actor,
label: actor label: actor
@@ -567,9 +559,9 @@ export default function GeneralPage() {
onValueChange={(value) => onValueChange={(value) =>
handleFilterChange("actor", value) handleFilterChange("actor", value)
} }
// placeholder="" label={t("actor")}
searchPlaceholder="Search..." searchPlaceholder={t("searchPlaceholder")}
emptyMessage="None found" emptyMessage={t("emptySearchOptions")}
/> />
</div> </div>
); );
@@ -108,10 +108,7 @@ export default async function ClientResourcesPage(
siteNiceId: siteResource.siteNiceIds[idx], siteNiceId: siteResource.siteNiceIds[idx],
online: siteResource.siteOnlines[idx] online: siteResource.siteOnlines[idx]
})), })),
mode: mode: siteResource.mode,
siteResource.pamMode && siteResource.mode === "host"
? "ssh"
: siteResource.mode,
scheme: siteResource.scheme, scheme: siteResource.scheme,
ssl: siteResource.ssl, ssl: siteResource.ssl,
siteNames: siteResource.siteNames, siteNames: siteResource.siteNames,
@@ -21,6 +21,7 @@ import {
SettingsSectionHeader, SettingsSectionHeader,
SettingsSectionTitle SettingsSectionTitle
} from "@app/components/Settings"; } from "@app/components/Settings";
import { DataTableEmptyState } from "@app/components/ui/data-table-empty-state";
import { import {
Table, Table,
TableBody, TableBody,
@@ -671,6 +672,15 @@ export function ProxyResourceTargetsForm({
const [, formAction, isSubmitting] = useActionState(saveTargets, null); 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() { async function saveTargets() {
if (!resource) return; if (!resource) return;
@@ -784,143 +794,104 @@ export function ProxyResourceTargetsForm({
</SettingsSectionDescription> </SettingsSectionDescription>
</SettingsSectionHeader> </SettingsSectionHeader>
<SettingsSectionBody> <SettingsSectionBody>
{targets.length > 0 ? ( <div className="overflow-x-auto">
<> <Table>
<div className="overflow-x-auto"> <TableHeader>
<Table> {table.getHeaderGroups().map((headerGroup) => (
<TableHeader> <TableRow key={headerGroup.id}>
{table {headerGroup.headers.map((header) => {
.getHeaderGroups() const isActionsColumn =
.map((headerGroup) => ( header.column.id === "actions";
<TableRow key={headerGroup.id}> const isSiteColumn =
{headerGroup.headers.map( header.column.id === "site";
(header) => { return (
const isActionsColumn = <TableHead
header.column key={header.id}
.id === className={
"actions"; isActionsColumn
const isSiteColumn = ? "sticky right-0 z-10 w-auto min-w-fit bg-card"
header.column : isSiteColumn
.id === ? "w-45"
"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"
> >
{t("targetNoOne")} {header.isPlaceholder
</TableCell> ? null
</TableRow> : flexRender(
)} header.column
</TableBody> .columnDef
</Table> .header,
</div> header.getContext()
<div className="flex items-center justify-between mb-4"> )}
<div className="flex items-center justify-between w-full gap-2"> </TableHead>
<Button );
onClick={addNewTarget} })}
variant="outline" </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("advancedMode")}
{t("addTarget")} </label>
</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>
</div> </div>
</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> </div>
)} )}
{build === "saas" && {build === "saas" &&
@@ -1,320 +1,7 @@
"use client"; "use client";
import { EditPolicyForm } from "@app/components/resource-policy/EditPolicyForm"; import { ResourcePolicyEditForm } from "@app/components/resource-policy/ResourcePolicyEditForm";
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">;
export default function ResourceAuthenticationPage() { export default function ResourceAuthenticationPage() {
const { org } = useOrgContext(); return <ResourcePolicyEditForm section="authentication" />;
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>
</>
);
} }
@@ -20,6 +20,8 @@ import {
SettingsSectionBody, SettingsSectionBody,
SettingsSectionDescription, SettingsSectionDescription,
SettingsSectionFooter, SettingsSectionFooter,
SettingsFormCell,
SettingsFormGrid,
SettingsSectionForm, SettingsSectionForm,
SettingsSectionHeader, SettingsSectionHeader,
SettingsSectionTitle SettingsSectionTitle
@@ -36,10 +38,14 @@ import { AlertCircle } from "lucide-react";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import { useParams, useRouter } from "next/navigation"; import { useParams, useRouter } from "next/navigation";
import { toASCII, toUnicode } from "punycode"; 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 { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod"; import { zodResolver } from "@hookform/resolvers/zod";
import z from "zod"; import z from "zod";
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 { Alert, AlertDescription } from "@app/components/ui/alert";
import { RadioGroup, RadioGroupItem } from "@app/components/ui/radio-group"; import { RadioGroup, RadioGroupItem } from "@app/components/ui/radio-group";
import { import {
@@ -220,6 +226,11 @@ function MaintenanceSectionForm({
</TooltipProvider> </TooltipProvider>
</FormControl> </FormControl>
</div> </div>
<FormDescription>
{t(
"enableMaintenanceModeDescription"
)}
</FormDescription>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
); );
@@ -429,16 +440,30 @@ function MaintenanceSectionForm({
export default function GeneralForm() { export default function GeneralForm() {
const params = useParams(); const params = useParams();
const { org } = useOrgContext();
const { resource, updateResource } = useResourceContext(); const { resource, updateResource } = useResourceContext();
const router = useRouter(); const router = useRouter();
const t = useTranslations(); const t = useTranslations();
const { env } = useEnvContext(); const { env } = useEnvContext();
const { isPaidUser } = usePaidStatus();
const orgId = params.orgId; const orgId = params.orgId;
const api = createApiClient({ env }); 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( const [resourceFullDomain, setResourceFullDomain] = useState(
`${resource.ssl ? "https" : "http"}://${toUnicode(resource.fullDomain || "")}` `${resource.ssl ? "https" : "http"}://${toUnicode(resource.fullDomain || "")}`
); );
@@ -501,6 +526,12 @@ export default function GeneralForm() {
const data = form.getValues(); const data = form.getValues();
let resourcePolicyId: number | null | undefined;
if (showResourcePolicy) {
resourcePolicyId = selectedSharedPolicyId;
}
const res = await api const res = await api
.post<AxiosResponse<UpdateResourceResponse>>( .post<AxiosResponse<UpdateResourceResponse>>(
`resource/${resource?.resourceId}`, `resource/${resource?.resourceId}`,
@@ -514,7 +545,8 @@ export default function GeneralForm() {
) )
: undefined, : undefined,
domainId: data.domainId, domainId: data.domainId,
proxyPort: data.proxyPort proxyPort: data.proxyPort,
...(resourcePolicyId !== undefined && { resourcePolicyId })
} }
) )
.catch((e) => { .catch((e) => {
@@ -538,7 +570,10 @@ export default function GeneralForm() {
subdomain: data.subdomain, subdomain: data.subdomain,
fullDomain: updated.fullDomain, fullDomain: updated.fullDomain,
proxyPort: data.proxyPort, proxyPort: data.proxyPort,
domainId: data.domainId domainId: data.domainId,
...(resourcePolicyId !== undefined && {
resourcePolicyId
})
}); });
toast({ toast({
@@ -579,193 +614,229 @@ export default function GeneralForm() {
</SettingsSectionHeader> </SettingsSectionHeader>
<SettingsSectionBody> <SettingsSectionBody>
<SettingsSectionForm> <SettingsSectionForm variant="half">
<Form {...form}> <Form {...form}>
<form <form
action={formAction} action={formAction}
className="space-y-4"
id="general-settings-form" id="general-settings-form"
> >
<div className="grid grid-cols-2 gap-4"> <SettingsFormGrid>
<FormField <SettingsFormCell span="full">
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
) && (
<>
<FormField <FormField
control={form.control} control={form.control}
name="proxyPort" name="enabled"
render={({ field }) => ( render={() => (
<FormItem> <FormItem>
<FormLabel>
{t(
"resourcePortNumber"
)}
</FormLabel>
<FormControl> <FormControl>
<Input <SwitchInput
type="number" id="enable-resource"
value={ defaultChecked={
field.value !== resource.enabled
undefined
? String(
field.value
)
: ""
} }
onChange={(e) => label={t(
field.onChange( "resourceEnable"
e.target )}
.value onCheckedChange={(
? parseInt( val
e ) =>
.target form.setValue(
.value "enabled",
) val
: undefined
) )
} }
/> />
</FormControl> </FormControl>
<FormMessage />
<FormDescription> <FormDescription>
{t( {t(
"resourcePortNumberDescription" "disabledResourceDescription"
)} )}
</FormDescription> </FormDescription>
<FormMessage />
</FormItem> </FormItem>
)} )}
/> />
</> </SettingsFormCell>
)}
{["http", "ssh", "rdp", "vnc"].includes( <SettingsFormCell span="half">
resource.mode <FormField
) && ( control={form.control}
<div className="space-y-4"> name="name"
<div id="resource-domain-picker"> render={({ field }) => (
<DomainPicker <FormItem>
allowWildcard={true} <FormLabel>
key={resource.resourceId} {t("name")}
orgId={orgId as string} </FormLabel>
cols={2} <FormControl>
defaultSubdomain={ <Input
form.watch( {...field}
"subdomain" />
) ?? undefined </FormControl>
} <FormMessage />
defaultDomainId={ </FormItem>
form.watch( )}
"domainId" />
) ?? undefined </SettingsFormCell>
}
defaultFullDomain={ <SettingsFormCell span="half">
resourceFullDomainName || <FormField
undefined control={form.control}
} name="niceId"
onDomainChange={(res) => { render={({ field }) => (
if (res === null) { <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( form.setValue(
"domainId", "domainId",
undefined res.domainId
); );
form.setValue( form.setValue(
"subdomain", "subdomain",
undefined res.subdomain ??
undefined
); );
setResourceFullDomain( 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( orgId={org.org.orgId}
"domainId", value={
res.domainId selectedSharedPolicyId
);
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
} }
label={t( onChange={
"resourceEnable" setSelectedSharedPolicyId
)}
onCheckedChange={(
val
) =>
form.setValue(
"enabled",
val
)
} }
/> />
</FormControl> </div>
<FormDescription> </SettingsFormCell>
{t(
"disabledResourceDescription"
)}
</FormDescription>
<FormMessage />
</FormItem>
)} )}
/> </SettingsFormGrid>
</form> </form>
</Form> </Form>
</SettingsSectionForm> </SettingsSectionForm>
@@ -92,10 +92,16 @@ export default async function ResourceLayout(props: ResourceLayoutProps) {
]; ];
if (["http", "ssh", "rdp", "vnc"].includes(resource.mode)) { if (["http", "ssh", "rdp", "vnc"].includes(resource.mode)) {
navItems.push({ navItems.push(
title: t("authentication"), {
href: `/{orgId}/settings/resources/public/{niceId}/authentication` title: t("authentication"),
}); href: `/{orgId}/settings/resources/public/{niceId}/authentication`
},
{
title: t("policyAccessRulesTitle"),
href: `/{orgId}/settings/resources/public/{niceId}/rules`
}
);
} }
return ( 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, SettingsSection,
SettingsSectionBody, SettingsSectionBody,
SettingsSectionDescription, SettingsSectionDescription,
SettingsFormCell,
SettingsFormGrid,
SettingsSectionForm, SettingsSectionForm,
SettingsSectionHeader, SettingsSectionHeader,
SettingsSectionTitle, SettingsSectionTitle,
@@ -410,174 +412,199 @@ function SshServerForm({
<Form {...form}> <Form {...form}>
<SettingsSectionBody> <SettingsSectionBody>
<SettingsSectionForm variant="half"> <SettingsSectionForm variant="half">
<div className="space-y-2"> <SettingsFormGrid>
<p className="font-semibold text-sm">{t("sshServerMode")}</p> <SettingsFormCell span="full">
<Badge variant="secondary"> <div className="space-y-2">
{sshServerMode == "standard" <p className="font-semibold text-sm">
? t("sshServerModeStandard") {t("sshServerMode")}
: t("sshServerModePangolin")} </p>
</Badge> <Badge variant="secondary">
</div> {sshServerMode == "standard"
? t("sshServerModeStandard")
: t("sshServerModePangolin")}
</Badge>
</div>
</SettingsFormCell>
<div className="space-y-2"> <SettingsFormCell span="full">
<p className="font-semibold text-sm">{t("sshAuthenticationMethod")}</p> <div className="space-y-2">
<StrategySelect<"passthrough" | "push"> <p className="font-semibold text-sm">
value={pamMode} {t("sshAuthenticationMethod")}
options={authMethodOptions} </p>
onChange={(value) => <StrategySelect<"passthrough" | "push">
form.setValue("pamMode", value, { value={pamMode}
shouldValidate: true options={authMethodOptions}
}) onChange={(value) =>
} form.setValue("pamMode", value, {
cols={2} shouldValidate: true
/> })
</div> }
cols={2}
/>
</div>
</SettingsFormCell>
{showDaemonLocation && ( {showDaemonLocation && (
<div className="space-y-2"> <SettingsFormCell span="full">
<p className="font-semibold text-sm">{t("sshAuthDaemonLocation")}</p> <div className="space-y-2">
<StrategySelect<"site" | "remote"> <p className="font-semibold text-sm">
value={standardDaemonLocation} {t("sshAuthDaemonLocation")}
options={daemonLocationOptions} </p>
onChange={(value) => <StrategySelect<"site" | "remote">
form.setValue( value={standardDaemonLocation}
"standardDaemonLocation", options={daemonLocationOptions}
value, onChange={(value) =>
{ shouldValidate: true } form.setValue(
) "standardDaemonLocation",
} value,
cols={2} {
/> shouldValidate: true
<p className="text-sm text-muted-foreground"> }
{t("sshDaemonDisclaimer")}{" "} )
<a }
href="https://docs.pangolin.net/manage/resources/public/ssh" cols={2}
target="_blank" />
rel="noopener noreferrer" <p className="text-sm text-muted-foreground">
className="text-primary hover:underline inline-flex items-center gap-1" {t("sshDaemonDisclaimer")}{" "}
> <a
{t("learnMore")} href="https://docs.pangolin.net/manage/resources/public/ssh"
<ExternalLink className="size-3.5 shrink-0" /> target="_blank"
</a> rel="noopener noreferrer"
</p> className="text-primary hover:underline inline-flex items-center gap-1"
</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
}
> >
<PopoverTrigger asChild> {t("learnMore")}
<FormControl> <ExternalLink className="size-3.5 shrink-0" />
<Button </a>
variant="outline" </p>
role="combobox" </div>
className="w-full max-w-xs justify-between font-normal" </SettingsFormCell>
>
<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}
/>
)} )}
</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> </SettingsSectionForm>
</SettingsSectionBody> </SettingsSectionBody>
<form action={formAction} className="flex justify-end mt-4"> <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 DomainPicker from "@app/components/DomainPicker";
import { import {
SettingsContainer, SettingsContainer,
SettingsFormCell,
SettingsFormGrid,
SettingsSection, SettingsSection,
SettingsSectionBody, SettingsSectionBody,
SettingsSectionDescription, SettingsSectionDescription,
@@ -39,6 +41,7 @@ import {
FormMessage FormMessage
} from "@app/components/ui/form"; } from "@app/components/ui/form";
import { Input } from "@app/components/ui/input"; import { Input } from "@app/components/ui/input";
import { Label } from "@app/components/ui/label";
import { import {
Popover, Popover,
PopoverContent, PopoverContent,
@@ -806,172 +809,198 @@ export default function Page() {
</SettingsSectionHeader> </SettingsSectionHeader>
<SettingsSectionBody> <SettingsSectionBody>
<SettingsSectionForm variant="half"> <SettingsSectionForm variant="half">
{/* Name */} <SettingsFormGrid>
<Form {...baseForm}> <SettingsFormCell span="half">
<form <Form {...baseForm}>
onKeyDown={(e) => { <form
if (e.key === "Enter") { onKeyDown={(e) => {
e.preventDefault(); if (
} e.key ===
}} "Enter"
className="grid gap-4 grid-cols-1 md:grid-cols-2 items-start" ) {
id="base-resource-form" e.preventDefault();
> }
<FormField }}
control={baseForm.control} id="base-resource-form"
name="name" >
render={({ field }) => ( <FormField
<FormItem> control={
<FormLabel> baseForm.control
{t("name")} }
</FormLabel> name="name"
<FormControl> render={({
<Input field
{...field} }) => (
/> <FormItem>
</FormControl> <FormLabel>
<FormMessage /> {t(
<FormDescription> "name"
{t( )}
"resourceNameDescription" </FormLabel>
)} <FormControl>
</FormDescription> <Input
</FormItem> {...field}
)} />
/> </FormControl>
</form> <FormMessage />
</Form> <FormDescription>
{t(
"resourceNameDescription"
)}
</FormDescription>
</FormItem>
)}
/>
</form>
</Form>
</SettingsFormCell>
{/* Inline Type Selector */} <SettingsFormCell span="full">
<div className="space-y-2"> <div className="space-y-2">
<p className="text-sm font-medium"> <p className="text-sm font-medium">
{t("type")} {t("type")}
</p> </p>
<OptionSelect<NewResourceType> <OptionSelect<NewResourceType>
options={typeOptions} options={typeOptions}
value={resourceType} value={resourceType}
onChange={setResourceType} onChange={
cols={6} setResourceType
/>
<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();
} }
}} cols={6}
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>
)}
/> />
</form> <p className="text-sm text-muted-foreground">
</Form> {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> </SettingsSectionForm>
</SettingsSectionBody> </SettingsSectionBody>
</SettingsSection> </SettingsSection>
@@ -1005,202 +1034,240 @@ export default function Page() {
> >
<SettingsSectionBody> <SettingsSectionBody>
<SettingsSectionForm variant="half"> <SettingsSectionForm variant="half">
{/* Mode */} <SettingsFormGrid>
<div className="space-y-2"> <SettingsFormCell span="full">
<p className="font-semibold text-sm">{t("sshServerMode")}</p> <div className="space-y-2">
<StrategySelect< <p className="font-semibold text-sm">
"standard" | "native" {t("sshServerMode")}
> </p>
value={sshServerMode} <StrategySelect<
options={sshModeOptions} "standard" | "native"
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"
> >
{t("learnMore")} value={sshServerMode}
<ExternalLink className="size-3.5 shrink-0" /> options={
</a> sshModeOptions
</p>
</div>
)}
{/* Daemon Port (standard + push + remote) */}
{showDaemonPort && (
<Form {...sshDaemonPortForm}>
<div className="w-full md:w-1/2">
<FormField
control={
sshDaemonPortForm.control
} }
name="authDaemonPort" onChange={
render={({ setSshServerMode
field }
}) => ( cols={2}
<FormItem>
<FormLabel>
{t(
"sshDaemonPort"
)}
</FormLabel>
<FormControl>
<Input
type="number"
min={
1
}
max={
65535
}
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/> />
</div> </div>
</Form> </SettingsFormCell>
)}
{/* Server Destination */} <SettingsFormCell span="full">
<div className="space-y-3"> <div className="space-y-2">
<SettingsSubsectionHeader> <p className="font-semibold text-sm">
<SettingsSubsectionTitle> {t(
{t( "sshAuthenticationMethod"
"sshServerDestination" )}
)} </p>
</SettingsSubsectionTitle> <StrategySelect<
<SettingsSubsectionDescription> "passthrough" | "push"
{t( >
"sshServerDestinationDescription" value={pamMode}
)} options={
</SettingsSubsectionDescription> authMethodOptions
</SettingsSubsectionHeader> }
{isNative ? ( onChange={setPamMode}
<Popover cols={2}
open={nativeSiteOpen} />
onOpenChange={ </div>
setNativeSiteOpen </SettingsFormCell>
}
> {showDaemonLocation && (
<PopoverTrigger asChild> <SettingsFormCell span="full">
<Button <div className="space-y-2">
variant="outline" <p className="font-semibold text-sm">
role="combobox" {t(
className="w-full md:w-1/2 justify-between font-normal" "sshAuthDaemonLocation"
)}
</p>
<StrategySelect<
"site" | "remote"
> >
<span className="truncate"> value={
{nativeSelectedSite?.name ?? standardDaemonLocation
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={ options={
nativeSelectedSite daemonLocationOptions
} }
onSelectSite={( onChange={
site setStandardDaemonLocation
) => { }
setNativeSelectedSite( cols={2}
site
);
setNativeSiteOpen(
false
);
}}
/> />
</PopoverContent> <p className="text-sm text-muted-foreground">
</Popover> {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 !== ) : standardDaemonLocation !==
"site" || "site" ||
pamMode === pamMode ===
"passthrough" ? ( "passthrough" ? (
<Form {...bgTargetForm}> <SettingsFormCell span="full">
<BrowserGatewayTargetForm <Form {...bgTargetForm}>
control={ <BrowserGatewayTargetForm
bgTargetForm.control control={
} bgTargetForm.control
orgId={ }
orgId as string orgId={
} orgId as string
multiSite={true} }
sitesField="selectedSites" multiSite={true}
destinationField="destination" sitesField="selectedSites"
destinationPortField="destinationPort" destinationField="destination"
learnMoreHref="https://docs.pangolin.net/manage/resources/public/ssh" destinationPortField="destinationPort"
defaultPort={22} learnMoreHref="https://docs.pangolin.net/manage/resources/public/ssh"
/> defaultPort={22}
</Form> />
</Form>
</SettingsFormCell>
) : ( ) : (
<Form {...bgTargetForm}> <SettingsFormCell span="full">
<BrowserGatewayTargetForm <Form {...bgTargetForm}>
control={ <BrowserGatewayTargetForm
bgTargetForm.control control={
} bgTargetForm.control
orgId={ }
orgId as string orgId={
} orgId as string
multiSite={false} }
siteField="selectedSite" multiSite={
destinationField="destination" false
destinationPortField="destinationPort" }
learnMoreHref="https://docs.pangolin.net/manage/resources/public/ssh" siteField="selectedSite"
defaultPort={22} destinationField="destination"
/> destinationPortField="destinationPort"
</Form> learnMoreHref="https://docs.pangolin.net/manage/resources/public/ssh"
defaultPort={22}
/>
</Form>
</SettingsFormCell>
)} )}
</div> </SettingsFormGrid>
</SettingsSectionForm> </SettingsSectionForm>
</SettingsSectionBody> </SettingsSectionBody>
</fieldset> </fieldset>
+83 -72
View File
@@ -2,6 +2,8 @@
import { import {
SettingsContainer, SettingsContainer,
SettingsFormCell,
SettingsFormGrid,
SettingsSection, SettingsSection,
SettingsSectionBody, SettingsSectionBody,
SettingsSectionDescription, SettingsSectionDescription,
@@ -514,98 +516,107 @@ export default function Page() {
e.preventDefault(); // block default enter refresh 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" id="create-site-form"
> >
<FormField <SettingsFormGrid>
control={form.control} <SettingsFormCell span="half">
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 && (
<FormField <FormField
control={form.control} control={form.control}
name="clientAddress" name="name"
render={({ field }) => ( render={({ field }) => (
<FormItem className="md:col-start-1 md:col-span-2"> <FormItem>
<FormLabel> <FormLabel>
{t( {t("name")}
"siteAddress"
)}
</FormLabel> </FormLabel>
<FormControl> <FormControl>
<Input <Input
autoComplete="off" autoComplete="off"
value={ {...field}
clientAddress
}
onChange={(
e
) => {
setClientAddress(
e
.target
.value
);
field.onChange(
e
.target
.value
);
}}
/> />
</FormControl> </FormControl>
<FormMessage /> <FormMessage />
<FormDescription> <FormDescription>
{t( {t(
"siteAddressDescription" "siteNameDescription"
)} )}
</FormDescription> </FormDescription>
</FormItem> </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>
</Form> </Form>
</SettingsSectionBody> </SettingsSectionBody>
+2 -2
View File
@@ -22,7 +22,7 @@
--accent-foreground: oklch(0.21 0.006 285.885); --accent-foreground: oklch(0.21 0.006 285.885);
--destructive: oklch(0.577 0.245 27.325); --destructive: oklch(0.577 0.245 27.325);
--destructive-foreground: oklch(0.985 0 0); --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); --input: oklch(0.88 0.004 286.32);
--ring: oklch(0.705 0.213 47.604); --ring: oklch(0.705 0.213 47.604);
--chart-1: oklch(0.646 0.222 41.116); --chart-1: oklch(0.646 0.222 41.116);
@@ -57,7 +57,7 @@
--accent-foreground: oklch(0.985 0 0); --accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.5382 0.1949 22.216); --destructive: oklch(0.5382 0.1949 22.216);
--destructive-foreground: oklch(0.985 0 0); --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%); --input: oklch(1 0 0 / 18%);
--ring: oklch(0.646 0.222 41.116); --ring: oklch(0.646 0.222 41.116);
--chart-1: oklch(0.488 0.243 264.376); --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 BrandedAuthSurface from "@app/components/BrandedAuthSurface";
import PoweredByPangolin from "@app/components/PoweredByPangolin"; import PoweredByPangolin from "@app/components/PoweredByPangolin";
import AuthPageFooterNotices from "@app/components/AuthPageFooterNotices"; import AuthPageFooterNotices from "@app/components/AuthPageFooterNotices";
import CollapsibleSessionToolbar from "@app/components/CollapsibleSessionToolbar";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import { import {
loadEncryptedLocalStorage, loadEncryptedLocalStorage,
@@ -464,27 +465,16 @@ export default function RdpClient({
className="fixed inset-0 z-50 flex flex-col bg-neutral-900" className="fixed inset-0 z-50 flex flex-col bg-neutral-900"
style={{ display: showLogin ? "none" : "flex" }} style={{ display: showLogin ? "none" : "flex" }}
> >
<div className="flex flex-wrap items-center gap-2 bg-black p-2 text-white"> <CollapsibleSessionToolbar>
<Button <Button
size="sm" size="sm"
variant="secondary" variant="destructive"
onClick={() => ui()?.setScale(1)} onClick={() => {
ui()?.shutdown();
setShowLogin(true);
}}
> >
{t("rdpFit")} {t("sshTerminate")}
</Button>
<Button
size="sm"
variant="secondary"
onClick={() => ui()?.setScale(2)}
>
{t("rdpFull")}
</Button>
<Button
size="sm"
variant="secondary"
onClick={() => ui()?.setScale(3)}
>
{t("rdpReal")}
</Button> </Button>
<Button <Button
size="sm" size="sm"
@@ -500,13 +490,6 @@ export default function RdpClient({
> >
{t("rdpMeta")} {t("rdpMeta")}
</Button> </Button>
{/* <Button
size="sm"
variant="secondary"
onClick={toggleCursorKind}
>
Toggle cursor
</Button> */}
<Button <Button
size="sm" size="sm"
variant="secondary" variant="secondary"
@@ -537,16 +520,34 @@ export default function RdpClient({
> >
{t("rdpUploadFiles")} {t("rdpUploadFiles")}
</Button> </Button>
{/* <Button
size="sm"
variant="secondary"
onClick={() => ui()?.setScale(1)}
>
{t("rdpFit")}
</Button>
<Button <Button
size="sm" size="sm"
variant="destructive" variant="secondary"
onClick={() => { onClick={() => ui()?.setScale(2)}
ui()?.shutdown();
setShowLogin(true);
}}
> >
{t("sshTerminate")} {t("rdpFull")}
</Button> </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"> <label className="ml-2 flex items-center gap-2">
<input <input
type="checkbox" type="checkbox"
@@ -558,7 +559,7 @@ export default function RdpClient({
/> />
{t("rdpUnicodeKeyboardMode")} {t("rdpUnicodeKeyboardMode")}
</label> </label>
</div> </CollapsibleSessionToolbar>
{moduleReady && ( {moduleReady && (
<iron-remote-desktop <iron-remote-desktop
+50
View File
@@ -122,6 +122,9 @@ export default function SshClient({
const [connected, setConnected] = useState(false); const [connected, setConnected] = useState(false);
const [connecting, setConnecting] = useState(false); const [connecting, setConnecting] = useState(false);
const [connectError, setConnectError] = useState<string | null>(null); const [connectError, setConnectError] = useState<string | null>(null);
const [sessionClosedCode, setSessionClosedCode] = useState<number | null>(
null
);
const terminalRef = useRef<HTMLDivElement>(null); const terminalRef = useRef<HTMLDivElement>(null);
const xtermRef = useRef<import("@xterm/xterm").Terminal | null>(null); const xtermRef = useRef<import("@xterm/xterm").Terminal | null>(null);
@@ -222,6 +225,8 @@ export default function SshClient({
authMethod: AuthTab = "password" authMethod: AuthTab = "password"
) { ) {
setConnecting(true); setConnecting(true);
setSessionClosedCode(null);
setConnectError(null);
if (!target) { if (!target) {
setConnectError(t("sshErrorNoTarget")); setConnectError(t("sshErrorNoTarget"));
@@ -257,8 +262,10 @@ export default function SshClient({
let authConfirmed = false; let authConfirmed = false;
let authErrorShown = false; let authErrorShown = false;
let socketOpened = false;
ws.onopen = () => { ws.onopen = () => {
socketOpened = true;
ws.send( ws.send(
JSON.stringify({ JSON.stringify({
type: "auth", type: "auth",
@@ -331,7 +338,16 @@ export default function SshClient({
}; };
ws.onclose = (evt) => { ws.onclose = (evt) => {
wsRef.current = null;
setConnecting(false); 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) { if (authConfirmed) {
setConnected(false); setConnected(false);
xtermRef.current?.writeln( 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 ( return (
<> <>
{!connected && ( {!connected && (
+2 -1
View File
@@ -152,7 +152,8 @@ export default async function SshPage() {
await waitForRoundTripCompletion(messageIds, cookieHeader); await waitForRoundTripCompletion(messageIds, cookieHeader);
} catch (err) { } catch (err) {
console.error("Error signing SSH key:", 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 BrandedAuthSurface from "@app/components/BrandedAuthSurface";
import PoweredByPangolin from "@app/components/PoweredByPangolin"; import PoweredByPangolin from "@app/components/PoweredByPangolin";
import AuthPageFooterNotices from "@app/components/AuthPageFooterNotices"; import AuthPageFooterNotices from "@app/components/AuthPageFooterNotices";
import CollapsibleSessionToolbar from "@app/components/CollapsibleSessionToolbar";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import { import {
loadEncryptedLocalStorage, loadEncryptedLocalStorage,
@@ -80,6 +81,7 @@ export default function VncClient({
}, [form, target?.authToken]); }, [form, target?.authToken]);
const [connected, setConnected] = useState(false); const [connected, setConnected] = useState(false);
const [connecting, setConnecting] = useState(false);
const [connectError, setConnectError] = useState<string | null>(null); const [connectError, setConnectError] = useState<string | null>(null);
const rfbRef = useRef<any>(null); const rfbRef = useRef<any>(null);
const screenRef = useRef<HTMLDivElement>(null); const screenRef = useRef<HTMLDivElement>(null);
@@ -89,6 +91,7 @@ export default function VncClient({
rfbRef.current.disconnect(); rfbRef.current.disconnect();
rfbRef.current = null; rfbRef.current = null;
} }
setConnecting(false);
setConnected(false); setConnected(false);
}; };
@@ -97,12 +100,19 @@ export default function VncClient({
}, []); }, []);
const connect = async (values: VncCredentialsForm) => { const connect = async (values: VncCredentialsForm) => {
setConnecting(true);
if (!target) { if (!target) {
setConnectError(t("vncNoResourceTarget")); setConnectError(t("vncNoResourceTarget"));
setConnecting(false);
return; return;
} }
if (!screenRef.current) return; if (!screenRef.current) {
setConnectError(t("sshErrorConnectionClosed"));
setConnecting(false);
return;
}
disconnect(); disconnect();
@@ -116,6 +126,8 @@ export default function VncClient({
const mod = await import("@novnc/novnc"); const mod = await import("@novnc/novnc");
RFB = mod.default ?? mod; RFB = mod.default ?? mod;
} catch (err) { } catch (err) {
setConnecting(false);
setConnectError(t("sshErrorWebSocket"));
toast({ toast({
variant: "destructive", variant: "destructive",
title: t("vncFailedToLoadNovnc"), title: t("vncFailedToLoadNovnc"),
@@ -140,7 +152,16 @@ export default function VncClient({
options.credentials = { password: values.password }; 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.scaleViewport = true;
rfb.resizeSession = true; rfb.resizeSession = true;
@@ -151,6 +172,8 @@ export default function VncClient({
values, values,
target.authToken target.authToken
); );
authConfirmed = true;
setConnecting(false);
setConnected(true); setConnected(true);
}); });
@@ -158,7 +181,11 @@ export default function VncClient({
"disconnect", "disconnect",
(e: { detail: { clean: boolean } }) => { (e: { detail: { clean: boolean } }) => {
rfbRef.current = null; rfbRef.current = null;
setConnecting(false);
setConnected(false); setConnected(false);
if (!authConfirmed && !e.detail.clean) {
setConnectError(t("sshErrorConnectionClosed"));
}
} }
); );
@@ -243,7 +270,12 @@ export default function VncClient({
</FormItem> </FormItem>
)} )}
/> />
<Button type="submit" className="w-full"> <Button
type="submit"
className="w-full"
loading={connecting}
disabled={connecting}
>
{t("browserGatewayConnect")} {t("browserGatewayConnect")}
</Button> </Button>
{connectError && ( {connectError && (
@@ -265,7 +297,14 @@ export default function VncClient({
className="fixed inset-0 z-50 flex flex-col bg-neutral-900" className="fixed inset-0 z-50 flex flex-col bg-neutral-900"
style={{ display: connected ? "flex" : "none" }} 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 <Button
size="sm" size="sm"
variant="secondary" variant="secondary"
@@ -291,14 +330,7 @@ export default function VncClient({
> >
{t("vncPasteClipboard")} {t("vncPasteClipboard")}
</Button> </Button>
<Button </CollapsibleSessionToolbar>
size="sm"
variant="destructive"
onClick={disconnect}
>
{t("sshTerminate")}
</Button>
</div>
<div <div
ref={screenRef} ref={screenRef}
+1 -1
View File
@@ -91,7 +91,7 @@ export default function AuthPageBrandingForm({
orgSubtitle: branding?.orgSubtitle ?? `Log in to {{orgName}}`, orgSubtitle: branding?.orgSubtitle ?? `Log in to {{orgName}}`,
resourceTitle: resourceTitle:
branding?.resourceTitle ?? branding?.resourceTitle ??
`Authenticate to access {{resourceName}}`, `Authenticate to Access {{resourceName}}`,
resourceSubtitle: resourceSubtitle:
branding?.resourceSubtitle ?? branding?.resourceSubtitle ??
`Choose your preferred authentication method for {{resourceName}}`, `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 { cn } from "@app/lib/cn";
import { dataTableFilterPopoverContentClassName } from "@app/lib/dataTableFilterPopover"; import { dataTableFilterPopoverContentClassName } from "@app/lib/dataTableFilterPopover";
import { Badge } from "./ui/badge"; import { Badge } from "./ui/badge";
import { useTranslations } from "next-intl";
interface FilterOption { interface FilterOption {
value: string; value: string;
@@ -27,7 +28,6 @@ interface ColumnFilterButtonProps {
options: FilterOption[]; options: FilterOption[];
selectedValue?: string; selectedValue?: string;
onValueChange: (value: string | undefined) => void; onValueChange: (value: string | undefined) => void;
placeholder?: string;
searchPlaceholder?: string; searchPlaceholder?: string;
emptyMessage?: string; emptyMessage?: string;
className?: string; className?: string;
@@ -38,7 +38,6 @@ export function ColumnFilterButton({
options, options,
selectedValue, selectedValue,
onValueChange, onValueChange,
placeholder,
searchPlaceholder = "Search...", searchPlaceholder = "Search...",
emptyMessage = "No options found", emptyMessage = "No options found",
className, className,
@@ -50,6 +49,8 @@ export function ColumnFilterButton({
(option) => option.value === selectedValue (option) => option.value === selectedValue
); );
const t = useTranslations();
return ( return (
<Popover open={open} onOpenChange={setOpen}> <Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild> <PopoverTrigger asChild>
@@ -94,7 +95,7 @@ export function ColumnFilterButton({
}} }}
className="text-muted-foreground" className="text-muted-foreground"
> >
Clear filter {t("accessFilterClear")}
</CommandItem> </CommandItem>
)} )}
{options.map((option) => ( {options.map((option) => (
@@ -109,6 +110,7 @@ export function ColumnFilterButton({
); );
setOpen(false); setOpen(false);
}} }}
className="break-all"
> >
<CheckIcon <CheckIcon
className={cn( className={cn(
+2 -1
View File
@@ -120,7 +120,7 @@ export function ColumnMultiFilterButton({
}} }}
className="text-muted-foreground" className="text-muted-foreground"
> >
{t("accessUsersRoleFilterClear")} {t("accessFilterClear")}
</CommandItem> </CommandItem>
)} )}
{options.map((option) => ( {options.map((option) => (
@@ -130,6 +130,7 @@ export function ColumnMultiFilterButton({
onSelect={() => { onSelect={() => {
toggle(option.value); toggle(option.value);
}} }}
className="break-all"
> >
<Checkbox <Checkbox
className="pointer-events-none shrink-0" className="pointer-events-none shrink-0"
+14 -6
View File
@@ -52,12 +52,20 @@ export function CreateOrgLabelDialog({
description: t("labelCreateSuccessMessage") description: t("labelCreateSuccessMessage")
}); });
} }
} catch (e) { } catch (e: any) {
toast({ if (e.response?.status === 409) {
title: t("error"), toast({
description: formatAxiosError(e, t("errorOccurred")), title: t("labelDuplicateError"),
variant: "destructive" 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 { AxiosResponse } from "axios";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import { useTransition } from "react"; 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"; import { tierMatrix } from "@server/lib/billing/tierMatrix";
type CreateRoleFormProps = { type CreateRoleFormProps = {
@@ -53,16 +58,10 @@ export default function CreateRoleForm({
payload.sshSudoCommands = payload.sshSudoCommands =
values.sshSudoMode === "commands" && values.sshSudoMode === "commands" &&
values.sshSudoCommands?.trim() values.sshSudoCommands?.trim()
? values.sshSudoCommands ? parseSudoCommands(values.sshSudoCommands)
.split(",")
.map((s) => s.trim())
.filter(Boolean)
: []; : [];
if (values.sshUnixGroups?.trim()) { if (values.sshUnixGroups?.trim()) {
payload.sshUnixGroups = values.sshUnixGroups payload.sshUnixGroups = parseUnixGroups(values.sshUnixGroups);
.split(",")
.map((s) => s.trim())
.filter(Boolean);
} }
} }
const res = await api const res = await api
+11 -5
View File
@@ -4,6 +4,7 @@ import { Button } from "@app/components/ui/button";
import { import {
Form, Form,
FormControl, FormControl,
FormDescription,
FormField, FormField,
FormItem, FormItem,
FormLabel, FormLabel,
@@ -269,11 +270,11 @@ export default function CreateShareLinkForm({
</PopoverTrigger> </PopoverTrigger>
<PopoverContent className="p-0"> <PopoverContent className="p-0">
<ResourceSelector <ResourceSelector
excludeWildcard excludeWildcard
orgId={ orgId={
org.org org.org
.orgId .orgId
} }
selectedResource={ selectedResource={
selectedResource selectedResource
} }
@@ -333,6 +334,11 @@ export default function CreateShareLinkForm({
<FormControl> <FormControl>
<Input {...field} /> <Input {...field} />
</FormControl> </FormControl>
<FormDescription>
{t(
"sharePathDescription"
)}
</FormDescription>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
)} )}
+14 -6
View File
@@ -58,12 +58,20 @@ export function EditOrgLabelDialog({
description: t("labelEditSuccessMessage") description: t("labelEditSuccessMessage")
}); });
} }
} catch (e) { } catch (e: any) {
toast({ if (e.response?.status === 409) {
title: t("error"), toast({
description: formatAxiosError(e, t("errorOccurred")), title: t("labelDuplicateError"),
variant: "destructive" 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 { AxiosResponse } from "axios";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import { useTransition } from "react"; 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"; import { tierMatrix } from "@server/lib/billing/tierMatrix";
type EditRoleFormProps = { type EditRoleFormProps = {
@@ -56,16 +61,10 @@ export default function EditRoleForm({
payload.sshSudoCommands = payload.sshSudoCommands =
values.sshSudoMode === "commands" && values.sshSudoMode === "commands" &&
values.sshSudoCommands?.trim() values.sshSudoCommands?.trim()
? values.sshSudoCommands ? parseSudoCommands(values.sshSudoCommands)
.split(",")
.map((s) => s.trim())
.filter(Boolean)
: []; : [];
if (values.sshUnixGroups !== undefined) { if (values.sshUnixGroups !== undefined) {
payload.sshUnixGroups = values.sshUnixGroups payload.sshUnixGroups = parseUnixGroups(values.sshUnixGroups);
.split(",")
.map((s) => s.trim())
.filter(Boolean);
} }
} }
const res = await api const res = await api
+1 -1
View File
@@ -168,7 +168,7 @@ export function LabelColumnFilterButton({
}} }}
className="text-muted-foreground" className="text-muted-foreground"
> >
{t("accessLabelFilterClear")} {t("accessFilterClear")}
</CommandItem> </CommandItem>
)} )}
{labels.map((label) => ( {labels.map((label) => (
+1 -1
View File
@@ -32,7 +32,7 @@ import {
RefreshCw RefreshCw
} from "lucide-react"; } from "lucide-react";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import { useState, useEffect, useMemo } from "react"; import { useEffect, useMemo, useState } from "react";
import { import {
Tooltip, Tooltip,
TooltipContent, TooltipContent,
+30 -4
View File
@@ -570,7 +570,7 @@ export function PrivateResourceForm({
mode: "host", mode: "host",
destination: "", destination: "",
alias: null, alias: null,
destinationPort: 22, destinationPort: null,
scheme: "http", scheme: "http",
ssl: true, ssl: true,
httpConfigSubdomain: null, httpConfigSubdomain: null,
@@ -1038,9 +1038,35 @@ export function PrivateResourceForm({
modeOptions modeOptions
} }
value={field.value} value={field.value}
onChange={ onChange={(
field.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} cols={2}
/> />
<FormMessage /> <FormMessage />
+43 -117
View File
@@ -2,9 +2,17 @@
import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog"; import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog";
import CopyToClipboard from "@app/components/CopyToClipboard"; 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 { Badge } from "@app/components/ui/badge";
import { Button } from "@app/components/ui/button"; import { Button } from "@app/components/ui/button";
import { ExtendedColumnDef } from "@app/components/ui/data-table";
import { import {
DropdownMenu, DropdownMenu,
DropdownMenuContent, DropdownMenuContent,
@@ -18,53 +26,35 @@ import {
PopoverTrigger PopoverTrigger
} from "@app/components/ui/popover"; } from "@app/components/ui/popover";
import { useEnvContext } from "@app/hooks/useEnvContext"; 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 { toast } from "@app/hooks/useToast";
import { createApiClient, formatAxiosError } from "@app/lib/api"; import { createApiClient, formatAxiosError } from "@app/lib/api";
import { cn } from "@app/lib/cn";
import { dataTableFilterPopoverContentClassName } from "@app/lib/dataTableFilterPopover";
import { formatSiteResourceDestinationDisplay } from "@app/lib/formatSiteResourceAccess";
import { getNextSortOrder, getSortDirection } from "@app/lib/sortColumn"; 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 { import {
ArrowDown01Icon, ArrowDown01Icon,
ArrowUp10Icon, ArrowUp10Icon,
ArrowUpDown, ArrowUpDown,
ArrowUpRight,
ChevronDown,
ChevronsUpDownIcon, ChevronsUpDownIcon,
Funnel, Funnel,
MoreHorizontal MoreHorizontal
} from "lucide-react"; } from "lucide-react";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import Link from "next/link";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { Selectedsite, SitesSelector } from "@app/components/site-selector"; import { startTransition, useMemo, useState, useTransition } from "react";
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 { useDebouncedCallback } from "use-debounce"; import { useDebouncedCallback } from "use-debounce";
import { ColumnFilterButton } from "./ColumnFilterButton"; 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 { LabelColumnFilterButton } from "./LabelColumnFilterButton";
import { useLocalLabels } from "@app/hooks/useLocalLabels"; import { LabelsTableCell } from "./LabelsTableCell";
import { useOptimisticLabels } from "@app/hooks/useOptimisticLabels"; import { ControlledDataTable } from "./ui/controlled-data-table";
import { SitesColumnFilterButton } from "./SitesColumnFilterButton";
export type InternalResourceSiteRow = ResourceSiteRow; export type InternalResourceSiteRow = ResourceSiteRow;
@@ -157,7 +147,6 @@ export default function PrivateResourcesTable({
const [editingResource, setEditingResource] = const [editingResource, setEditingResource] =
useState<InternalResourceRow | null>(); useState<InternalResourceRow | null>();
const [isCreateDialogOpen, setIsCreateDialogOpen] = useState(false); const [isCreateDialogOpen, setIsCreateDialogOpen] = useState(false);
const [siteFilterOpen, setSiteFilterOpen] = useState(false);
const [isRefreshing, startRefreshTransition] = useTransition(); const [isRefreshing, startRefreshTransition] = useTransition();
@@ -171,27 +160,6 @@ export default function PrivateResourcesTable({
// return () => clearInterval(interval); // return () => clearInterval(interval);
// }, [router]); // }, [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 = () => { const refreshData = () => {
startRefreshTransition(() => { startRefreshTransition(() => {
try { try {
@@ -285,58 +253,27 @@ export default function PrivateResourcesTable({
accessorFn: (row) => accessorFn: (row) =>
row.sites.map((s) => s.siteName).join(", "), row.sites.map((s) => s.siteName).join(", "),
friendlyName: t("sites"), friendlyName: t("sites"),
header: () => ( header: () => {
<Popover const siteIdQ = searchParams.get("siteId");
open={siteFilterOpen} const siteIdNum = siteIdQ ? parseInt(siteIdQ, 10) : NaN;
onOpenChange={setSiteFilterOpen}
> const selectedSiteId =
<PopoverTrigger asChild> !siteIdQ ||
<Button !Number.isInteger(siteIdNum) ||
type="button" siteIdNum <= 0
variant="ghost" ? null
role="combobox" : siteIdNum;
className={cn(
"justify-between text-sm h-8 px-2 w-full p-3", return (
!selectedSite && "text-muted-foreground" <SitesColumnFilterButton
)} selectedSiteId={selectedSiteId}
> onValueChange={(value) =>
<div className="flex items-center gap-2 min-w-0"> handleFilterChange("siteId", value?.toString())
{t("sites")} }
<Funnel className="size-4 flex-none" /> orgId={orgId}
{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>
),
cell: ({ row }) => { cell: ({ row }) => {
const resourceRow = row.original; const resourceRow = row.original;
return ( 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) { function toggleSort(column: string) {
const newSearch = getNextSortOrder(column, searchParams); const newSearch = getNextSortOrder(column, searchParams);
@@ -691,7 +618,6 @@ export default function PrivateResourcesTable({
open={isCreateDialogOpen} open={isCreateDialogOpen}
setOpen={setIsCreateDialogOpen} setOpen={setIsCreateDialogOpen}
orgId={orgId} orgId={orgId}
initialSites={createInitialSites}
onSuccess={() => { onSuccess={() => {
// Delay refresh to allow modal to close smoothly // Delay refresh to allow modal to close smoothly
setTimeout(() => { setTimeout(() => {
+22 -101
View File
@@ -76,6 +76,7 @@ import { useLocalLabels } from "@app/hooks/useLocalLabels";
import { LabelsTableCell } from "./LabelsTableCell"; import { LabelsTableCell } from "./LabelsTableCell";
import { useOptimisticLabels } from "@app/hooks/useOptimisticLabels"; import { useOptimisticLabels } from "@app/hooks/useOptimisticLabels";
import { refresh } from "next/cache"; import { refresh } from "next/cache";
import { SitesColumnFilterButton } from "./SitesColumnFilterButton";
export type TargetHealth = { export type TargetHealth = {
targetId: number; targetId: number;
@@ -154,30 +155,6 @@ export default function PublicResourcesTable({
const [isRefreshing, startTransition] = useTransition(); const [isRefreshing, startTransition] = useTransition();
const [isNavigatingToAddPage, startNavigation] = 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 = () => { const refreshData = () => {
startTransition(() => { 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 proxyColumns = useMemo<ExtendedColumnDef<ResourceRow>[]>(() => {
const cols: ExtendedColumnDef<ResourceRow>[] = [ const cols: ExtendedColumnDef<ResourceRow>[] = [
{ {
@@ -291,61 +246,27 @@ export default function PublicResourcesTable({
accessorFn: (row) => accessorFn: (row) =>
row.sites.map((s) => s.siteName).join(", "), row.sites.map((s) => s.siteName).join(", "),
friendlyName: t("sites"), friendlyName: t("sites"),
header: () => ( header: () => {
<Popover const siteIdQ = searchParams.get("siteId");
open={siteFilterOpenRef.current} const siteIdNum = siteIdQ ? parseInt(siteIdQ, 10) : NaN;
onOpenChange={setSiteFilterOpen}
> const selectedSiteId =
<PopoverTrigger asChild> !siteIdQ ||
<Button !Number.isInteger(siteIdNum) ||
type="button" siteIdNum <= 0
variant="ghost" ? null
role="combobox" : siteIdNum;
className={cn(
"justify-between text-sm h-8 px-2 w-full p-3", return (
!selectedSiteRef.current && <SitesColumnFilterButton
"text-muted-foreground" selectedSiteId={selectedSiteId}
)} onValueChange={(value) =>
> handleFilterChange("siteId", value?.toString())
<div className="flex items-center gap-2 min-w-0"> }
{t("sites")} orgId={orgId}
<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>
),
cell: ({ row }) => ( cell: ({ row }) => (
<ResourceSitesStatusCell <ResourceSitesStatusCell
orgId={row.original.orgId} 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")} searchPlaceholder={t("resourcePoliciesSearch")}
pagination={pagination} pagination={pagination}
rowCount={rowCount} rowCount={rowCount}
searchQuery={searchParams.get("query")?.toString()}
onSearch={handleSearchChange} onSearch={handleSearchChange}
onPaginationChange={handlePaginationChange} onPaginationChange={handlePaginationChange}
onAdd={() => onAdd={() =>
+192 -13
View File
@@ -10,14 +10,25 @@ import {
FormMessage FormMessage
} from "@app/components/ui/form"; } from "@app/components/ui/form";
import { Input } from "@app/components/ui/input"; import { Input } from "@app/components/ui/input";
import { Textarea } from "@app/components/ui/textarea";
import { import {
OptionSelect, OptionSelect,
type OptionSelectOption type OptionSelectOption
} from "@app/components/OptionSelect"; } from "@app/components/OptionSelect";
import { TextFileImportDialog } from "@app/components/TextFileImportDialog";
import { useEnvContext } from "@app/hooks/useEnvContext"; import { useEnvContext } from "@app/hooks/useEnvContext";
import { usePaidStatus } from "@app/hooks/usePaidStatus"; 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 { useTranslations } from "next-intl";
import { useEffect } from "react"; import { useEffect, useState } from "react";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
import { z } from "zod"; import { z } from "zod";
import { zodResolver } from "@hookform/resolvers/zod"; import { zodResolver } from "@hookform/resolvers/zod";
@@ -46,15 +57,34 @@ function toSshSudoMode(value: string | null | undefined): SshSudoMode {
return "none"; return "none";
} }
function hasOnlyAbsoluteSudoCommands(value: string | undefined): boolean { export function parseUnixGroups(value: string | undefined): string[] {
if (!value?.trim()) return true; if (!value?.trim()) return [];
const commands = value return value
.split(",") .split(/[,\s\n]+/)
.map((command) => command.trim()) .map((group) => group.trim())
.filter(Boolean); .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]; const executable = command.split(/\s+/)[0];
return executable.startsWith("/"); return executable.startsWith("/");
}); });
@@ -78,6 +108,15 @@ type RoleFormProps = {
formId?: string; formId?: string;
}; };
type RoleTextImportField = "sshSudoCommands" | "sshUnixGroups";
type PendingTextImport = {
field: RoleTextImportField;
fileName: string;
fileType: TextImportFileType;
rawContent: string;
};
export function RoleForm({ export function RoleForm({
variant, variant,
role, role,
@@ -125,10 +164,10 @@ export function RoleForm({
(role as Role & { allowSsh?: boolean }).allowSsh ?? false, (role as Role & { allowSsh?: boolean }).allowSsh ?? false,
sshSudoMode: toSshSudoMode(role.sshSudoMode), sshSudoMode: toSshSudoMode(role.sshSudoMode),
sshSudoCommands: parseRoleJsonArray(role.sshSudoCommands).join( sshSudoCommands: parseRoleJsonArray(role.sshSudoCommands).join(
", " "\n"
), ),
sshCreateHomeDir: role.sshCreateHomeDir ?? false, sshCreateHomeDir: role.sshCreateHomeDir ?? false,
sshUnixGroups: parseRoleJsonArray(role.sshUnixGroups).join(", ") sshUnixGroups: parseRoleJsonArray(role.sshUnixGroups).join("\n")
} }
: { : {
name: "", name: "",
@@ -156,10 +195,10 @@ export function RoleForm({
(role as Role & { allowSsh?: boolean }).allowSsh ?? false, (role as Role & { allowSsh?: boolean }).allowSsh ?? false,
sshSudoMode: toSshSudoMode(role.sshSudoMode), sshSudoMode: toSshSudoMode(role.sshSudoMode),
sshSudoCommands: parseRoleJsonArray(role.sshSudoCommands).join( sshSudoCommands: parseRoleJsonArray(role.sshSudoCommands).join(
", " "\n"
), ),
sshCreateHomeDir: role.sshCreateHomeDir ?? false, sshCreateHomeDir: role.sshCreateHomeDir ?? false,
sshUnixGroups: parseRoleJsonArray(role.sshUnixGroups).join(", ") sshUnixGroups: parseRoleJsonArray(role.sshUnixGroups).join("\n")
}); });
} }
}, [variant, role, form]); }, [variant, role, form]);
@@ -167,6 +206,10 @@ export function RoleForm({
const sshDisabled = !isPaidUser(tierMatrix.advancedPrivateResources); const sshDisabled = !isPaidUser(tierMatrix.advancedPrivateResources);
const sshSudoMode = form.watch("sshSudoMode"); const sshSudoMode = form.watch("sshSudoMode");
const isAdminRole = variant === "edit" && role?.isAdmin === true; const isAdminRole = variant === "edit" && role?.isAdmin === true;
const [pendingImport, setPendingImport] =
useState<PendingTextImport | null>(null);
const [dragOverField, setDragOverField] =
useState<RoleTextImportField | null>(null);
useEffect(() => { useEffect(() => {
if (sshDisabled) { if (sshDisabled) {
@@ -174,6 +217,78 @@ export function RoleForm({
} }
}, [sshDisabled, form]); }, [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 ( return (
<Form {...form}> <Form {...form}>
<form <form
@@ -421,9 +536,25 @@ export function RoleForm({
{t("sshSudoCommands")} {t("sshSudoCommands")}
</FormLabel> </FormLabel>
<FormControl> <FormControl>
<Input <Textarea
{...field} {...field}
{...getTextImportDropHandlers(
"sshSudoCommands"
)}
placeholder={
sshDisabled
? undefined
: t(
"roleTextFieldPlaceholder"
)
}
disabled={sshDisabled} disabled={sshDisabled}
className={cn(
"h-20 min-h-20",
dragOverField ===
"sshSudoCommands" &&
"border-primary"
)}
/> />
</FormControl> </FormControl>
<FormDescription> <FormDescription>
@@ -446,9 +577,25 @@ export function RoleForm({
{t("sshUnixGroups")} {t("sshUnixGroups")}
</FormLabel> </FormLabel>
<FormControl> <FormControl>
<Input <Textarea
{...field} {...field}
{...getTextImportDropHandlers(
"sshUnixGroups"
)}
placeholder={
sshDisabled
? undefined
: t(
"roleTextFieldPlaceholder"
)
}
disabled={sshDisabled} disabled={sshDisabled}
className={cn(
"h-20 min-h-20",
dragOverField ===
"sshUnixGroups" &&
"border-primary"
)}
/> />
</FormControl> </FormControl>
<FormDescription> <FormDescription>
@@ -499,6 +646,38 @@ export function RoleForm({
</HorizontalTabs> </HorizontalTabs>
)} )}
</form> </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> </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({ export function SettingsSectionTitle({
children 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" "attach"
); );
} catch (e) { } catch (e: any) {
toast({ if (e.response?.status === 409) {
title: t("error"), toast({
description: formatAxiosError(e, t("errorOccurred")), title: t("labelDuplicateError"),
variant: "destructive" description: t("labelDuplicateErrorDescription"),
}); variant: "destructive"
});
} else {
toast({
title: t("error"),
description: formatAxiosError(e, t("errorOccurred")),
variant: "destructive"
});
}
} }
setlabelsSearchQuery(""); setlabelsSearchQuery("");
} }
-1
View File
@@ -115,7 +115,6 @@ export function MultiSitesSelector({
<SiteOnlineStatus <SiteOnlineStatus
type={site.type} type={site.type}
online={site.online} online={site.online}
t={t}
/> />
)} )}
</div> </div>
@@ -12,11 +12,9 @@ import {
import { useEnvContext } from "@app/hooks/useEnvContext"; import { useEnvContext } from "@app/hooks/useEnvContext";
import { useOrgContext } from "@app/hooks/useOrgContext"; import { useOrgContext } from "@app/hooks/useOrgContext";
import { usePaidStatus } from "@app/hooks/usePaidStatus"; import { usePaidStatus } from "@app/hooks/usePaidStatus";
import { getUserDisplayName } from "@app/lib/getUserDisplayName";
import { orgQueries } from "@app/lib/queries"; import { orgQueries } from "@app/lib/queries";
import { zodResolver } from "@hookform/resolvers/zod"; import { zodResolver } from "@hookform/resolvers/zod";
import { build } from "@server/build"; import { build } from "@server/build";
import { UserType } from "@server/types/UserTypes";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import { import {
@@ -67,12 +65,6 @@ export function CreatePolicyForm({}: CreatePolicyFormProps) {
env.server.maxmind_asn_path && env.server.maxmind_asn_path.length > 0 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( const { data: orgIdps = [], isLoading: isLoadingOrgIdps } = useQuery(
orgQueries.identityProviders({ orgQueries.identityProviders({
orgId: org.org.orgId, orgId: org.org.orgId,
@@ -147,7 +139,7 @@ export function CreatePolicyForm({}: CreatePolicyFormProps) {
if (res && res.status === 201) { if (res && res.status === 201) {
const niceId = res.data.data.niceId; const niceId = res.data.data.niceId;
router.push( router.push(
`/${org.org.orgId}/settings/policies/resources/public/${niceId}` `/${org.org.orgId}/settings/policies/resources/public/${niceId}/general`
); );
toast({ toast({
title: t("success"), 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(() => { const allIdps = useMemo(() => {
if (build === "saas") { if (build === "saas") {
if (isPaidUser(tierMatrix.orgOidc)) { if (isPaidUser(tierMatrix.orgOidc)) {
@@ -197,7 +169,7 @@ export function CreatePolicyForm({}: CreatePolicyFormProps) {
return []; return [];
}, [orgIdps, isPaidUser]); }, [orgIdps, isPaidUser]);
if (isLoadingOrgRoles || isLoadingOrgUsers || isLoadingOrgIdps) { if (isLoadingOrgIdps) {
return <></>; return <></>;
} }
@@ -227,7 +199,7 @@ export function CreatePolicyForm({}: CreatePolicyFormProps) {
</SettingsSectionDescription> </SettingsSectionDescription>
</SettingsSectionHeader> </SettingsSectionHeader>
<SettingsSectionBody> <SettingsSectionBody>
<SettingsSectionForm> <SettingsSectionForm variant="half">
<FormField <FormField
control={form.control} control={form.control}
name="name" name="name"
@@ -237,12 +209,7 @@ export function CreatePolicyForm({}: CreatePolicyFormProps) {
{t("name")} {t("name")}
</FormLabel> </FormLabel>
<FormControl> <FormControl>
<Input <Input {...field} />
{...field}
placeholder={t(
"resourcePolicyNamePlaceholder"
)}
/>
</FormControl> </FormControl>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
@@ -257,8 +224,6 @@ export function CreatePolicyForm({}: CreatePolicyFormProps) {
form={form} form={form}
orgId={org.org.orgId} orgId={org.org.orgId}
allIdps={allIdps} allIdps={allIdps}
allRoles={allRoles}
allUsers={allUsers}
emailEnabled={env.email.emailEnabled} emailEnabled={env.email.emailEnabled}
/> />
<PolicyAccessRulesSection <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 { build } from "@server/build";
import { tierMatrix } from "@server/lib/billing/tierMatrix"; import { tierMatrix } from "@server/lib/billing/tierMatrix";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { useTranslations } from "next-intl";
import { useMemo } from "react"; import { useMemo } from "react";
import { HorizontalTabs } from "@app/components/HorizontalTabs";
import { EditPolicyNameSectionForm } from "./EditPolicyNameSectionForm"; import { EditPolicyNameSectionForm } from "./EditPolicyNameSectionForm";
import { PolicyAuthStackSection } from "./PolicyAuthStackSection"; import { PolicyAuthStackSection } from "./PolicyAuthStackSection";
import { PolicyAccessRulesSection } from "./PolicyAccessRulesSection"; import { PolicyAccessRulesSection } from "./PolicyAccessRulesSection";
export type EditPolicyFormSection = "general" | "authentication" | "rules";
export type EditPolicyFormProps = { export type EditPolicyFormProps = {
hidePolicyNameForm?: boolean; hidePolicyNameForm?: boolean;
readonly?: boolean; readonly?: boolean;
resourceId?: number; resourceId?: number;
section?: EditPolicyFormSection;
}; };
export function EditPolicyForm({ export function EditPolicyForm({
hidePolicyNameForm, hidePolicyNameForm,
readonly, readonly,
resourceId resourceId,
section
}: EditPolicyFormProps) { }: EditPolicyFormProps) {
const t = useTranslations();
const { org } = useOrgContext(); const { org } = useOrgContext();
const { env } = useEnvContext(); const { env } = useEnvContext();
const { isPaidUser } = usePaidStatus(); const { isPaidUser } = usePaidStatus();
@@ -37,7 +38,6 @@ export function EditPolicyForm({
// In overlay mode (resourceId provided), policy-level sections are locked. // In overlay mode (resourceId provided), policy-level sections are locked.
// Rules and users/roles sections handle their own hybrid logic via resourceId. // Rules and users/roles sections handle their own hybrid logic via resourceId.
const isOverlay = resourceId !== undefined; const isOverlay = resourceId !== undefined;
const showTabs = !hidePolicyNameForm && !isOverlay;
const isMaxmindAvailable = !!( const isMaxmindAvailable = !!(
env.server.maxmind_db_path && env.server.maxmind_db_path.length > 0 env.server.maxmind_db_path && env.server.maxmind_db_path.length > 0
@@ -92,22 +92,16 @@ export function EditPolicyForm({
/> />
); );
if (showTabs) { if (section === "general") {
return ( return <EditPolicyNameSectionForm readonly={readonly} />;
<HorizontalTabs }
clientSide
defaultTab={0} if (section === "authentication") {
items={[ return authSection;
{ title: t("general"), href: "#" }, }
{ title: t("authentication"), href: "#" },
{ title: t("policyAccessRulesTitle"), href: "#" } if (section === "rules") {
]} return rulesSection;
>
<EditPolicyNameSectionForm readonly={readonly} />
{authSection}
{rulesSection}
</HorizontalTabs>
);
} }
return ( return (
@@ -109,7 +109,7 @@ export function EditPolicyNameSectionForm({
if (payload.niceId && payload.niceId !== policy.niceId) { if (payload.niceId && payload.niceId !== policy.niceId) {
router.replace( 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 { toast } from "@app/hooks/useToast";
import { import {
createPolicyRulesSectionSchema, createPolicyRulesSectionSchema,
type PolicyRuleMatchType,
validatePolicyRulesForSave, validatePolicyRulesForSave,
type PolicyFormValues type PolicyFormValues
} from "."; } from ".";
import { POLICY_RULE_MATCH_TYPES } from "./policy-access-rule-validation";
import { Button } from "@app/components/ui/button"; import { Button } from "@app/components/ui/button";
import { Plus } from "lucide-react"; import { Plus } from "lucide-react";
@@ -28,7 +30,8 @@ import {
useMemo, useMemo,
useRef, useRef,
useState, useState,
useTransition useTransition,
type ReactNode
} from "react"; } from "react";
import { UseFormReturn, useForm, useWatch } from "react-hook-form"; import { UseFormReturn, useForm, useWatch } from "react-hook-form";
import { useResourcePolicyContext } from "@app/providers/ResourcePolicyProvider"; import { useResourcePolicyContext } from "@app/providers/ResourcePolicyProvider";
@@ -38,11 +41,12 @@ import { resourceQueries } from "@app/lib/queries";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import type { AxiosResponse } from "axios"; import type { AxiosResponse } from "axios";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { CreatePolicyRulesSectionForm } from "./CreatePolicyRulesSectionForm";
import { PolicyAccessRulesIntro } from "./PolicyAccessRulesIntro"; import { PolicyAccessRulesIntro } from "./PolicyAccessRulesIntro";
import { PolicyAccessRulesTable } from "./PolicyAccessRulesTable"; import { PolicyAccessRulesTable } from "./PolicyAccessRulesTable";
import { SharedPolicyResourceNotice } from "./SharedPolicyResourceNotice";
import { import {
createEmptyRule, createEmptyRule,
prependEmptyRule,
type PolicyAccessRule type PolicyAccessRule
} from "./policy-access-rule-utils"; } from "./policy-access-rule-utils";
@@ -67,6 +71,12 @@ export type PolicyAccessRulesSectionProps =
| PolicyAccessRulesSectionEditProps | PolicyAccessRulesSectionEditProps
| PolicyAccessRulesSectionCreateProps; | 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) { export function PolicyAccessRulesSection(props: PolicyAccessRulesSectionProps) {
if (props.mode === "create") { if (props.mode === "create") {
return <PolicyAccessRulesSectionCreate {...props} />; return <PolicyAccessRulesSectionCreate {...props} />;
@@ -74,6 +84,143 @@ export function PolicyAccessRulesSection(props: PolicyAccessRulesSectionProps) {
return <PolicyAccessRulesSectionEdit {...props} />; 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({ function PolicyAccessRulesSectionEdit({
isMaxmindAvailable, isMaxmindAvailable,
isMaxmindAsnAvailable, isMaxmindAsnAvailable,
@@ -119,6 +266,8 @@ function PolicyAccessRulesSectionEdit({
policy.rules.map((r) => ({ ...r, fromPolicy: isResourceOverlay })) policy.rules.map((r) => ({ ...r, fromPolicy: isResourceOverlay }))
); );
const { updateRulesState } = usePolicyAccessRulesFormSync(form);
useEffect(() => { useEffect(() => {
if (!isResourceOverlay || resourceRulesInitialized) return; if (!isResourceOverlay || resourceRulesInitialized) return;
if (!resourceRulesData) return; if (!resourceRulesData) return;
@@ -129,7 +278,7 @@ function PolicyAccessRulesSectionEdit({
.map((r) => ({ .map((r) => ({
ruleId: r.ruleId, ruleId: r.ruleId,
action: r.action as "ACCEPT" | "DROP" | "PASS", action: r.action as "ACCEPT" | "DROP" | "PASS",
match: r.match, match: isPolicyRuleMatchType(r.match) ? r.match : "PATH",
value: r.value, value: r.value,
priority: r.priority, priority: r.priority,
enabled: r.enabled, enabled: r.enabled,
@@ -148,30 +297,13 @@ function PolicyAccessRulesSectionEdit({
policy.rules policy.rules
]); ]);
const syncFormRules = useCallback( const handleRulesChange = useCallback(
(updatedRules: PolicyAccessRule[]) => { (updatedRules: PolicyAccessRule[]) => {
form.setValue( updateRulesState(setRules, updatedRules);
"rules",
updatedRules.map(
({ action, match, value, priority, enabled }) => ({
action,
match,
value,
priority,
enabled
})
)
);
}, },
[form] [updateRulesState]
); );
const addEmptyRule = useCallback(() => {
const updatedRules = [...rules, createEmptyRule(rules)];
setRules(updatedRules);
syncFormRules(updatedRules);
}, [rules, syncFormRules]);
const removeRule = useCallback( const removeRule = useCallback(
function removeRule(ruleId: number) { function removeRule(ruleId: number) {
const rule = rules.find((r) => r.ruleId === ruleId); const rule = rules.find((r) => r.ruleId === ruleId);
@@ -179,32 +311,22 @@ function PolicyAccessRulesSectionEdit({
if (isResourceOverlay && !rule.new) { if (isResourceOverlay && !rule.new) {
deletedResourceRuleIdsRef.current.add(ruleId); deletedResourceRuleIdsRef.current.add(ruleId);
} }
const updatedRules = rules.filter((rule) => rule.ruleId !== ruleId); handleRulesChange(rules.filter((rule) => rule.ruleId !== ruleId));
setRules(updatedRules);
syncFormRules(updatedRules);
}, },
[rules, syncFormRules, isResourceOverlay] [rules, handleRulesChange, isResourceOverlay]
); );
const updateRule = useCallback( const updateRule = useCallback(
function updateRule(ruleId: number, data: Partial<PolicyAccessRule>) { function updateRule(ruleId: number, data: Partial<PolicyAccessRule>) {
const updatedRules = rules.map((rule) => handleRulesChange(
rule.ruleId === ruleId rules.map((rule) =>
? { ...rule, ...data, updated: true } rule.ruleId === ruleId
: rule ? { ...rule, ...data, updated: true }
: rule
)
); );
setRules(updatedRules);
syncFormRules(updatedRules);
}, },
[rules, syncFormRules] [rules, handleRulesChange]
);
const handleRulesChange = useCallback(
(updatedRules: PolicyAccessRule[]) => {
setRules(updatedRules);
syncFormRules(updatedRules);
},
[syncFormRules]
); );
const [isPending, startTransition] = useTransition(); const [isPending, startTransition] = useTransition();
@@ -213,7 +335,10 @@ function PolicyAccessRulesSectionEdit({
if (readonly) return; if (readonly) return;
const applyRules = form.getValues("applyRules") ?? false; 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, value, priority, enabled }) => ({
action, action,
match, 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 ( return (
<SettingsSection> <PolicyAccessRulesSectionLayout
<SettingsSectionHeader> rulesEnabled={Boolean(rulesEnabled)}
<SettingsSectionTitle> onRulesEnabledChange={(val) => {
{t("policyAccessRulesTitle")} form.setValue("applyRules", val);
</SettingsSectionTitle> }}
<SettingsSectionDescription> disableToggle={readonly || isResourceOverlay}
{t("rulesResourceDescription")} rules={rules}
</SettingsSectionDescription> onRulesChange={handleRulesChange}
</SettingsSectionHeader> updateRule={updateRule}
<SettingsSectionBody> removeRule={removeRule}
<div className="space-y-6"> readonly={readonly}
<PolicyAccessRulesIntro isMaxmindAvailable={isMaxmindAvailable}
rulesEnabled={Boolean(rulesEnabled)} isMaxmindAsnAvailable={isMaxmindAsnAvailable}
onRulesEnabledChange={(val) => { resourceOverlayMode={isResourceOverlay}
form.setValue("applyRules", val); footer={
}} <SettingsSectionFooter>
disableToggle={readonly || isResourceOverlay} <Button
/> onClick={() => startTransition(() => saveRules())}
loading={isPending}
{rulesEnabled && ( disabled={readonly || isPending}
<> >
<PolicyAccessRulesTable {t("saveSettings")}
rules={rules} </Button>
onRulesChange={handleRulesChange} </SettingsSectionFooter>
updateRule={updateRule} }
removeRule={removeRule} />
readonly={readonly}
isMaxmindAvailable={isMaxmindAvailable}
isMaxmindAsnAvailable={isMaxmindAsnAvailable}
includeRegionMatch
markUpdatedOnReorder
emptyStateAction={addRuleButton}
/>
{hasRules && addRuleButton}
</>
)}
</div>
</SettingsSectionBody>
<SettingsSectionFooter>
<Button
onClick={() => startTransition(() => saveRules())}
loading={isPending}
disabled={readonly || isPending}
>
{t("saveSettings")}
</Button>
</SettingsSectionFooter>
</SettingsSection>
); );
} }
function PolicyAccessRulesSectionCreate({ function PolicyAccessRulesSectionCreate({
form, form: parentForm,
isMaxmindAvailable, isMaxmindAvailable,
isMaxmindAsnAvailable isMaxmindAsnAvailable
}: PolicyAccessRulesSectionCreateProps) { }: 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 ( return (
<CreatePolicyRulesSectionForm <PolicyAccessRulesSectionLayout
form={form} rulesEnabled={Boolean(rulesEnabled)}
onRulesEnabledChange={(val) => {
form.setValue("applyRules", val);
}}
rules={rules}
onRulesChange={handleRulesChange}
updateRule={updateRule}
removeRule={removeRule}
isMaxmindAvailable={isMaxmindAvailable} isMaxmindAvailable={isMaxmindAvailable}
isMaxmindAsnAvailable={isMaxmindAsnAvailable} isMaxmindAsnAvailable={isMaxmindAsnAvailable}
/> />
@@ -66,8 +66,12 @@ import {
validatePolicyRuleValue validatePolicyRuleValue
} from "./policy-access-rule-validation"; } from "./policy-access-rule-validation";
import { import {
buildDisplayPrioritiesForResourceOverlay,
reorderPolicyRules, reorderPolicyRules,
reorderResourceOverlayRules,
setResourceRuleDisplayPriority,
sortPolicyRulesByPriority, sortPolicyRulesByPriority,
sortPolicyRulesForResourceOverlay,
type PolicyAccessRule type PolicyAccessRule
} from "./policy-access-rule-utils"; } from "./policy-access-rule-utils";
@@ -82,6 +86,7 @@ export type PolicyAccessRulesTableProps = {
readonly?: boolean; readonly?: boolean;
includeRegionMatch?: boolean; includeRegionMatch?: boolean;
markUpdatedOnReorder?: boolean; markUpdatedOnReorder?: boolean;
resourceOverlayMode?: boolean;
isRuleDraggable?: (rule: PolicyAccessRule) => boolean; isRuleDraggable?: (rule: PolicyAccessRule) => boolean;
isRuleLocked?: (rule: PolicyAccessRule) => boolean; isRuleLocked?: (rule: PolicyAccessRule) => boolean;
}; };
@@ -97,7 +102,7 @@ function getColumnClassName(columnId: string) {
return "w-24 max-w-24"; return "w-24 max-w-24";
} }
if (columnId === "action") { if (columnId === "action") {
return "w-40 max-w-40"; return "w-42 max-w-42";
} }
if (columnId === "match") { if (columnId === "match") {
return "w-36 max-w-36"; return "w-36 max-w-36";
@@ -116,6 +121,7 @@ export function PolicyAccessRulesTable({
readonly = false, readonly = false,
includeRegionMatch = false, includeRegionMatch = false,
markUpdatedOnReorder = false, markUpdatedOnReorder = false,
resourceOverlayMode = false,
isRuleDraggable: isRuleDraggableProp, isRuleDraggable: isRuleDraggableProp,
isRuleLocked: isRuleLockedProp isRuleLocked: isRuleLockedProp
}: PolicyAccessRulesTableProps) { }: PolicyAccessRulesTableProps) {
@@ -140,12 +146,37 @@ export function PolicyAccessRulesTable({
); );
const sortedRules = useMemo( 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] [rules]
); );
const handleReorder = useCallback( const handleReorder = useCallback(
(fromRuleId: number, toRuleId: number) => { (fromRuleId: number, toRuleId: number) => {
if (resourceOverlayMode) {
onRulesChange(
reorderResourceOverlayRules(rules, fromRuleId, toRuleId, {
markUpdated: markUpdatedOnReorder
})
);
return;
}
const fromIndex = sortedRules.findIndex( const fromIndex = sortedRules.findIndex(
(rule) => rule.ruleId === fromRuleId (rule) => rule.ruleId === fromRuleId
); );
@@ -164,7 +195,13 @@ export function PolicyAccessRulesTable({
); );
onRulesChange(reordered); onRulesChange(reordered);
}, },
[sortedRules, onRulesChange, markUpdatedOnReorder] [
rules,
sortedRules,
onRulesChange,
markUpdatedOnReorder,
resourceOverlayMode
]
); );
const handleDragStart = useCallback((ruleId: number, e: DragEvent) => { const handleDragStart = useCallback((ruleId: number, e: DragEvent) => {
@@ -228,60 +265,132 @@ export function PolicyAccessRulesTable({
maxSize: 96, maxSize: 96,
header: ({ column }) => ( header: ({ column }) => (
<div className="p-3"> <div className="p-3">
<Button {resourceOverlayMode ? (
variant="ghost" <span className="font-medium text-muted-foreground">
className="h-auto p-0 font-medium text-muted-foreground hover:bg-transparent" {t("rulesPriority")}
onClick={() => </span>
column.toggleSorting( ) : (
column.getIsSorted() === "asc" <Button
) variant="ghost"
} className="h-auto p-0 font-medium text-muted-foreground hover:bg-transparent"
> onClick={() =>
{t("rulesPriority")} column.toggleSorting(
<ArrowUpDown className="ml-1 h-3 w-3" /> column.getIsSorted() === "asc"
</Button> )
}
>
{t("rulesPriority")}
<ArrowUpDown className="ml-1 h-3 w-3" />
</Button>
)}
</div> </div>
), ),
cell: ({ row }) => ( cell: ({ row }) => {
<Input const displayPriority = resourceOverlayMode
defaultValue={row.original.priority} ? (displayPriorities?.get(row.original.ruleId) ??
className="w-full min-w-0" row.original.priority)
type="number" : row.original.priority;
disabled={readonly || isRuleLocked(row.original)}
onClick={(e) => e.currentTarget.focus()} return (
onBlur={(e) => { <Input
const validated = validatePolicyRulePriority( key={`${row.original.ruleId}-${displayPriority}`}
t, defaultValue={displayPriority}
e.target.value className="w-full min-w-0"
); type="number"
if (!validated.success) { disabled={readonly || isRuleLocked(row.original)}
toast({ onClick={(e) => e.currentTarget.focus()}
variant: "destructive", onBlur={(e) => {
...validated.toast 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", accessorKey: "action",
@@ -683,13 +792,7 @@ export function PolicyAccessRulesTable({
cell: ({ row }) => ( cell: ({ row }) => (
<div className="flex items-center justify-end space-x-2"> <div className="flex items-center justify-end space-x-2">
{isRuleLocked(row.original) ? ( {isRuleLocked(row.original) ? (
<Button <LockIcon className="h-4 w-4 text-muted-foreground" />
variant="outline"
disabled
className="cursor-not-allowed"
>
<LockIcon className="h-4 w-4" />
</Button>
) : ( ) : (
<Button <Button
variant="outline" variant="outline"
@@ -711,9 +814,14 @@ export function PolicyAccessRulesTable({
isMaxmindAsnAvailable, isMaxmindAsnAvailable,
includeRegionMatch, includeRegionMatch,
updateRule, updateRule,
onRulesChange,
removeRule, removeRule,
readonly, readonly,
rules, rules,
resourceOverlayMode,
displayPriorities,
resourceRuleCount,
markUpdatedOnReorder,
isRuleDraggable, isRuleDraggable,
isRuleLocked, isRuleLocked,
handleDragStart, handleDragStart,
@@ -775,7 +883,8 @@ export function PolicyAccessRulesTable({
e.preventDefault(); e.preventDefault();
if ( if (
draggedRuleId !== null && draggedRuleId !== null &&
draggedRuleId !== rule.ruleId draggedRuleId !== rule.ruleId &&
isRuleDraggable(rule)
) { ) {
handleReorder( handleReorder(
draggedRuleId, draggedRuleId,
@@ -789,7 +898,7 @@ export function PolicyAccessRulesTable({
draggedRuleId === rule.ruleId && draggedRuleId === rule.ruleId &&
"opacity-50", "opacity-50",
dragOverRuleId === rule.ruleId && dragOverRuleId === rule.ruleId &&
"border-t-2 border-primary" "border-t border-primary"
)} )}
> >
{row.getVisibleCells().map((cell) => { {row.getVisibleCells().map((cell) => {
@@ -117,7 +117,7 @@ export function PasscodeCredenza({
title={t("resourcePasswordSetupTitle")} title={t("resourcePasswordSetupTitle")}
description={t("resourcePasswordSetupTitleDescription")} description={t("resourcePasswordSetupTitleDescription")}
formId="policy-passcode-form" formId="policy-passcode-form"
submitLabel={t("resourcePasswordSubmit")} submitLabel={t("policyAuthSetPasscode")}
> >
<Form {...form}> <Form {...form}>
<form <form
@@ -191,7 +191,7 @@ export function PincodeCredenza({
title={t("resourcePincodeSetupTitle")} title={t("resourcePincodeSetupTitle")}
description={t("resourcePincodeSetupTitleDescription")} description={t("resourcePincodeSetupTitleDescription")}
formId="policy-pincode-form" formId="policy-pincode-form"
submitLabel={t("resourcePincodeSubmit")} submitLabel={t("policyAuthSetPincode")}
> >
<Form {...form}> <Form {...form}>
<form <form
@@ -286,7 +286,7 @@ export function HeaderAuthCredenza({
title={t("resourceHeaderAuthSetupTitle")} title={t("resourceHeaderAuthSetupTitle")}
description={t("resourceHeaderAuthSetupTitleDescription")} description={t("resourceHeaderAuthSetupTitleDescription")}
formId="policy-header-auth-form" formId="policy-header-auth-form"
submitLabel={t("resourceHeaderAuthSubmit")} submitLabel={t("policyAuthSetHeaderAuth")}
> >
<Form {...form}> <Form {...form}>
<form <form
@@ -368,7 +368,7 @@ type EmailCredenzaProps = {
emailEnabled: boolean; emailEnabled: boolean;
disabled?: boolean; disabled?: boolean;
emails: Tag[]; emails: Tag[];
onEmailsChange: (emails: Tag[]) => void; onSave: (emails: Tag[]) => void;
}; };
export function EmailCredenza({ export function EmailCredenza({
@@ -377,12 +377,19 @@ export function EmailCredenza({
emailEnabled, emailEnabled,
disabled, disabled,
emails, emails,
onEmailsChange onSave
}: EmailCredenzaProps) { }: EmailCredenzaProps) {
const t = useTranslations(); const t = useTranslations();
const [activeEmailTagIndex, setActiveEmailTagIndex] = useState< const [activeEmailTagIndex, setActiveEmailTagIndex] = useState<
number | null number | null
>(null); >(null);
const [draftEmails, setDraftEmails] = useState<Tag[]>(emails);
useEffect(() => {
if (open) {
setDraftEmails(emails);
}
}, [open, emails]);
return ( return (
<Credenza open={open} onOpenChange={onOpenChange}> <Credenza open={open} onOpenChange={onOpenChange}>
@@ -394,72 +401,90 @@ export function EmailCredenza({
</CredenzaDescription> </CredenzaDescription>
</CredenzaHeader> </CredenzaHeader>
<CredenzaBody> <CredenzaBody>
<div className="space-y-4"> <form
{!emailEnabled && ( id="policy-email-form"
<Alert variant="neutral"> onSubmit={(event) => {
<InfoIcon className="h-4 w-4" /> event.preventDefault();
<AlertTitle className="font-semibold"> onSave(draftEmails);
{t("otpEmailSmtpRequired")} onOpenChange(false);
</AlertTitle> }}
<AlertDescription> >
{t("otpEmailSmtpRequiredDescription")} <div className="space-y-4">
</AlertDescription> {!emailEnabled && (
</Alert> <Alert variant="neutral">
)} <InfoIcon className="h-4 w-4" />
{emailEnabled && ( <AlertTitle className="font-semibold">
<p className="text-sm text-muted-foreground"> {t("otpEmailSmtpRequired")}
{t("otpEmailWhitelistListDescription")} </AlertTitle>
</p> <AlertDescription>
)} {t("otpEmailSmtpRequiredDescription")}
{emailEnabled && ( </AlertDescription>
<FormItem> </Alert>
<FormLabel> )}
{t("otpEmailWhitelistList")} {emailEnabled && (
</FormLabel> <p className="text-sm text-muted-foreground">
<FormControl> {t("otpEmailWhitelistListDescription")}
<TagInput </p>
activeTagIndex={activeEmailTagIndex} )}
setActiveTagIndex={ {emailEnabled && (
setActiveEmailTagIndex <FormItem>
} <FormLabel>
placeholder={t("otpEmailEnter")} {t("otpEmailWhitelistList")}
tags={emails} </FormLabel>
setTags={(newEmails) => { <FormControl>
if (!disabled) { <TagInput
onEmailsChange( activeTagIndex={activeEmailTagIndex}
newEmails as Tag[] setActiveTagIndex={
); setActiveEmailTagIndex
} }
}} placeholder={t("otpEmailEnter")}
validateTag={(tag) => tags={draftEmails}
z setTags={(newEmails) => {
.email() if (!disabled) {
.or( setDraftEmails(
z newEmails as Tag[]
.string() );
.regex( }
/^\*@[\w.-]+\.[a-zA-Z]{2,}$/ }}
) validateTag={(tag) =>
) z
.safeParse(tag).success .email()
} .or(
allowDuplicates={false} z
sortTags .string()
size="sm" .regex(
disabled={disabled} /^\*@[\w.-]+\.[a-zA-Z]{2,}$/
/> )
</FormControl> )
<FormDescription> .safeParse(tag).success
{t("otpEmailEnterDescription")} }
</FormDescription> allowDuplicates={false}
</FormItem> sortTags
)} size="sm"
</div> disabled={disabled}
/>
</FormControl>
<FormDescription>
{t("otpEmailEnterDescription")}
</FormDescription>
</FormItem>
)}
</div>
</form>
</CredenzaBody> </CredenzaBody>
<CredenzaFooter> <CredenzaFooter>
<CredenzaClose asChild> <CredenzaClose asChild>
<Button variant="outline">{t("close")}</Button> <Button variant="outline">{t("close")}</Button>
</CredenzaClose> </CredenzaClose>
{emailEnabled && (
<Button
type="submit"
form="policy-email-form"
disabled={disabled}
>
{t("policyAuthSetEmailWhitelist")}
</Button>
)}
</CredenzaFooter> </CredenzaFooter>
</CredenzaContent> </CredenzaContent>
</Credenza> </Credenza>
@@ -66,23 +66,6 @@ export function PolicyAuthMethodRow({
> >
<div className="flex flex-1 min-w-0 flex-col gap-0.5"> <div className="flex flex-1 min-w-0 flex-col gap-0.5">
<div className="flex items-center gap-2"> <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> <span className="text-sm font-medium">{title}</span>
</div> </div>
<p className="truncate text-sm text-muted-foreground"> <p className="truncate text-sm text-muted-foreground">
@@ -19,8 +19,6 @@ type PolicyAuthStackSectionCreateProps = {
form: UseFormReturn<PolicyFormValues, any, any>; form: UseFormReturn<PolicyFormValues, any, any>;
orgId: string; orgId: string;
allIdps: { id: number; text: string }[]; allIdps: { id: number; text: string }[];
allRoles: { id: string; text: string }[];
allUsers: { id: string; text: string }[];
emailEnabled: boolean; emailEnabled: boolean;
}; };
@@ -1,6 +1,8 @@
"use client"; "use client";
import { import {
SettingsFormCell,
SettingsFormGrid,
SettingsSection, SettingsSection,
SettingsSectionBody, SettingsSectionBody,
SettingsSectionDescription, SettingsSectionDescription,
@@ -10,7 +12,8 @@ import {
SettingsSubsectionTitle, SettingsSubsectionTitle,
SettingsSectionTitle SettingsSectionTitle
} from "@app/components/Settings"; } 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 { FormField } from "@app/components/ui/form";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import { useState } from "react"; import { useState } from "react";
@@ -36,27 +39,18 @@ export type PolicyAuthStackSectionCreateProps = {
form: UseFormReturn<PolicyFormValues, any, any>; form: UseFormReturn<PolicyFormValues, any, any>;
orgId: string; orgId: string;
allIdps: { id: number; text: string }[]; allIdps: { id: number; text: string }[];
allRoles: { id: string; text: string }[];
allUsers: { id: string; text: string }[];
emailEnabled: boolean; emailEnabled: boolean;
}; };
export function PolicyAuthStackSectionCreate({ export function PolicyAuthStackSectionCreate({
form: parentForm, form: parentForm,
orgId,
allIdps, allIdps,
allRoles,
allUsers,
emailEnabled emailEnabled
}: PolicyAuthStackSectionCreateProps) { }: PolicyAuthStackSectionCreateProps) {
const t = useTranslations(); const t = useTranslations();
const [editingMethod, setEditingMethod] = const [editingMethod, setEditingMethod] =
useState<PolicyAuthMethodId | null>(null); 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 sso = useWatch({ control: parentForm.control, name: "sso" });
const skipToIdpId = useWatch({ const skipToIdpId = useWatch({
@@ -111,65 +105,58 @@ export function PolicyAuthStackSectionCreate({
</SettingsSectionDescription> </SettingsSectionDescription>
</SettingsSectionHeader> </SettingsSectionHeader>
<SettingsSectionBody> <SettingsSectionBody>
<div className="w-full md:w-1/2"> <SettingsFormGrid>
<PolicyAuthSsoSection <SettingsFormCell span="half">
sso={Boolean(sso)} <PolicyAuthSsoSection
onSsoChange={(active) => sso={Boolean(sso)}
parentForm.setValue("sso", active) onSsoChange={(active) =>
} parentForm.setValue("sso", active)
skipToIdpId={skipToIdpId} }
onSkipToIdpChange={(id) => skipToIdpId={skipToIdpId}
parentForm.setValue("skipToIdpId", id) onSkipToIdpChange={(id) =>
} parentForm.setValue("skipToIdpId", id)
allIdps={allIdps} }
rolesEditor={ allIdps={allIdps}
<FormField<PolicyFormValues, "roles"> rolesEditor={
control={parentForm.control} <FormField
name="roles" control={parentForm.control}
render={({ field }) => ( name="roles"
<TagInput render={({ field }) => (
{...field} <RolesSelector
activeTagIndex={activeRolesTagIndex} orgId={orgId}
setActiveTagIndex={ selectedRoles={field.value}
setActiveRolesTagIndex onSelectRoles={(selected) =>
} parentForm.setValue(
placeholder={t("accessRoleSelect2")} "roles",
tags={field.value ?? []} selected
setTags={(newRoles) => )
field.onChange(newRoles) }
} restrictAdminRole
autocompleteOptions={allRoles} />
allowDuplicates={false} )}
size="sm" />
/> }
)} usersEditor={
/> <FormField
} control={parentForm.control}
usersEditor={ name="users"
<FormField<PolicyFormValues, "users"> render={({ field }) => (
control={parentForm.control} <UsersSelector
name="users" orgId={orgId}
render={({ field }) => ( selectedUsers={field.value}
<TagInput onSelectUsers={(selected) =>
{...field} parentForm.setValue(
activeTagIndex={activeUsersTagIndex} "users",
setActiveTagIndex={ selected
setActiveUsersTagIndex )
} }
placeholder={t("accessUserSelect")} />
tags={field.value ?? []} )}
setTags={(newUsers) => />
field.onChange(newUsers) }
} />
autocompleteOptions={allUsers} </SettingsFormCell>
allowDuplicates={false} </SettingsFormGrid>
size="sm"
/>
)}
/>
}
/>
</div>
<SettingsSubsectionHeader> <SettingsSubsectionHeader>
<SettingsSubsectionTitle> <SettingsSubsectionTitle>
@@ -279,7 +266,7 @@ export function PolicyAuthStackSectionCreate({
onOpenChange={(open) => !open && closeCredenza()} onOpenChange={(open) => !open && closeCredenza()}
emailEnabled={emailEnabled} emailEnabled={emailEnabled}
emails={emails} emails={emails}
onEmailsChange={(value) => onSave={(value) =>
parentForm.setValue( parentForm.setValue(
"emails", "emails",
value as PolicyFormValues["emails"] value as PolicyFormValues["emails"]
@@ -1,6 +1,8 @@
"use client"; "use client";
import { import {
SettingsFormCell,
SettingsFormGrid,
SettingsSection, SettingsSection,
SettingsSectionBody, SettingsSectionBody,
SettingsSectionDescription, SettingsSectionDescription,
@@ -48,20 +50,43 @@ import {
getPasscodeSummary, getPasscodeSummary,
getPincodeSummary getPincodeSummary
} from "./policy-auth-summaries"; } from "./policy-auth-summaries";
import { SharedPolicyResourceNotice } from "./SharedPolicyResourceNotice";
import z from "zod";
type OverlaySelectedRole = SelectedRole & { isAdmin: boolean }; type OverlaySelectedRole = SelectedRole & { isAdmin: boolean };
const authStackSchema = createPolicySchema.pick({ // Edit mode keeps placeholder values for configured methods; only validate on save when changed.
sso: true, const authStackEditSchema = createPolicySchema
skipToIdpId: true, .pick({
roles: true, sso: true,
users: true, skipToIdpId: true,
password: true, roles: true,
pincode: true, users: true,
headerAuth: true, emailWhitelistEnabled: true,
emailWhitelistEnabled: true, emails: 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 = { export type PolicyAuthStackSectionEditProps = {
orgId: string; orgId: string;
@@ -182,14 +207,14 @@ export function PolicyAuthStackSectionEdit({
]); ]);
const form = useForm({ const form = useForm({
resolver: zodResolver(authStackSchema), resolver: zodResolver(authStackEditSchema),
defaultValues: { defaultValues: {
sso: policy.sso, sso: policy.sso,
skipToIdpId: policy.idpId, skipToIdpId: policy.idpId,
roles: policyRoleItems, roles: policyRoleItems,
users: policyUserItems, users: policyUserItems,
password: policy.passwordId ? { password: "" } : null, password: null,
pincode: policy.pincodeId ? { pincode: "" } : null, pincode: null,
headerAuth: policy.headerAuth headerAuth: policy.headerAuth
? { ? {
user: "", user: "",
@@ -247,7 +272,14 @@ export function PolicyAuthStackSectionEdit({
} }
const isValid = await form.trigger(); 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 payload = form.getValues();
const requests: Array<Promise<AxiosResponse<{}> | void>> = []; const requests: Array<Promise<AxiosResponse<{}> | void>> = [];
@@ -441,184 +473,227 @@ export function PolicyAuthStackSectionEdit({
</SettingsSectionDescription> </SettingsSectionDescription>
</SettingsSectionHeader> </SettingsSectionHeader>
<SettingsSectionBody> <SettingsSectionBody>
<div className="w-full md:w-1/2"> <div className="space-y-4">
<PolicyAuthSsoSection {isResourceOverlay && (
sso={Boolean(sso)} <SharedPolicyResourceNotice section="authentication" />
onSsoChange={(active) => )}
form.setValue("sso", active) <SettingsFormGrid>
} <SettingsFormCell span="half">
skipToIdpId={skipToIdpId} <PolicyAuthSsoSection
onSkipToIdpChange={(id) => sso={Boolean(sso)}
form.setValue("skipToIdpId", id) onSsoChange={(active) =>
} form.setValue("sso", active)
allIdps={allIdps} }
disabled={authReadonly} skipToIdpId={skipToIdpId}
idpDisabled={authReadonly} onSkipToIdpChange={(id) =>
rolesEditor={ form.setValue("skipToIdpId", id)
isResourceOverlay ? ( }
<RolesSelector allIdps={allIdps}
orgId={orgId} disabled={authReadonly}
selectedRoles={overlayRoles} idpDisabled={authReadonly}
onSelectRoles={(selected) => rolesEditor={
setCombinedRoles( isResourceOverlay ? (
selected.map((role) => ({
...role,
isAdmin: Boolean(
role.isAdmin
)
}))
)
}
disabled={isLoading}
restrictAdminRole
lockedIds={policyRoleLockedIds}
/>
) : (
<FormField
control={form.control}
name="roles"
render={({ field }) => (
<RolesSelector <RolesSelector
orgId={orgId} orgId={orgId}
selectedRoles={field.value} selectedRoles={overlayRoles}
onSelectRoles={(selected) => onSelectRoles={(selected) =>
form.setValue( setCombinedRoles(
"roles", selected.map(
selected (role) => ({
...role,
isAdmin:
Boolean(
role.isAdmin
)
})
)
) )
} }
disabled={readonly} disabled={isLoading}
restrictAdminRole restrictAdminRole
lockedIds={
policyRoleLockedIds
}
/> />
)} ) : (
/> <FormField
) control={form.control}
} name="roles"
usersEditor={ render={({ field }) => (
isResourceOverlay ? ( <RolesSelector
<UsersSelector orgId={orgId}
orgId={orgId} selectedRoles={
selectedUsers={overlayUsers} field.value
onSelectUsers={setCombinedUsers} }
disabled={isLoading} onSelectRoles={(
lockedIds={policyUserLockedIds} selected
/> ) =>
) : ( form.setValue(
<FormField "roles",
control={form.control} selected
name="users" )
render={({ field }) => ( }
disabled={readonly}
restrictAdminRole
/>
)}
/>
)
}
usersEditor={
isResourceOverlay ? (
<UsersSelector <UsersSelector
orgId={orgId} orgId={orgId}
selectedUsers={field.value} selectedUsers={overlayUsers}
onSelectUsers={(selected) => onSelectUsers={
form.setValue( setCombinedUsers
"users", }
selected disabled={isLoading}
) lockedIds={
policyUserLockedIds
} }
disabled={readonly}
/> />
)} ) : (
/> <FormField
) control={form.control}
} name="users"
/> render={({ field }) => (
</div> <UsersSelector
orgId={orgId}
<SettingsSubsectionHeader> selectedUsers={
<SettingsSubsectionTitle> field.value
{t("policyAuthOtherMethodsTitle")} }
</SettingsSubsectionTitle> onSelectUsers={(
<SettingsSubsectionDescription> selected
{t("policyAuthOtherMethodsDescription")} ) =>
</SettingsSubsectionDescription> form.setValue(
</SettingsSubsectionHeader> "users",
selected
<div className="grid grid-cols-1 md:grid-cols-2 gap-3"> )
<PolicyAuthMethodRow }
id="pincode" disabled={readonly}
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} </SettingsFormCell>
/> </SettingsFormGrid>
<PolicyAuthMethodRow <SettingsSubsectionHeader>
id="header-auth" <SettingsSubsectionTitle>
title={t("policyAuthHeaderAuthTitle")} {t("policyAuthOtherMethodsTitle")}
description={t( </SettingsSubsectionTitle>
"policyAuthHeaderAuthDescription" <SettingsSubsectionDescription>
)} {t("policyAuthOtherMethodsDescription")}
summary={getHeaderAuthSummary({ </SettingsSubsectionDescription>
t, </SettingsSubsectionHeader>
headerName: headerAuth?.user ?? ""
})} <div className="grid grid-cols-1 md:grid-cols-2 gap-3">
active={headerAuthActive} <PolicyAuthMethodRow
onConfigure={() => id="pincode"
openMethodEditor("headerAuth") title={t("policyAuthPincodeTitle")}
} description={t(
onToggle={(active) => "policyAuthPincodeDescription"
handleToggle("headerAuth", active, () => { )}
setHeaderAuthActive(false); summary={getPincodeSummary({ t })}
form.setValue("headerAuth", null); active={pinActive}
}) onConfigure={() =>
} openMethodEditor("pincode")
disabled={authReadonly} }
/> 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> </div>
<PincodeCredenza <PincodeCredenza
@@ -648,9 +723,7 @@ export function PolicyAuthStackSectionEdit({
emailEnabled={emailEnabled} emailEnabled={emailEnabled}
disabled={authReadonly} disabled={authReadonly}
emails={emails} emails={emails}
onEmailsChange={(value) => onSave={(value) => form.setValue("emails", value)}
form.setValue("emails", value)
}
/> />
<HeaderAuthCredenza <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 ────────────────────────────────────────────────────────── // ─── Schemas & types ──────────────────────────────────────────────────────────
import z from "zod"; 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({ export const createPolicySchema = z.object({
name: z.string().min(1).max(255), name: z.string().min(1).max(255),
@@ -35,7 +37,7 @@ export const createPolicySchema = z.object({
.array( .array(
z.object({ z.object({
action: z.enum(["ACCEPT", "DROP", "PASS"]), action: z.enum(["ACCEPT", "DROP", "PASS"]),
match: z.string(), match: z.enum(POLICY_RULE_MATCH_TYPES),
value: z.string(), value: z.string(),
priority: z.number().int(), priority: z.number().int(),
enabled: z.boolean() enabled: z.boolean()
@@ -49,7 +51,7 @@ export type PolicyFormValues = z.infer<typeof createPolicySchema>;
export type LocalRule = { export type LocalRule = {
ruleId: number; ruleId: number;
action: "ACCEPT" | "DROP" | "PASS"; action: "ACCEPT" | "DROP" | "PASS";
match: string; match: PolicyRuleMatchType;
value: string; value: string;
priority: number; priority: number;
enabled: boolean; enabled: boolean;
@@ -67,6 +69,7 @@ export {
type PolicyAccessRule type PolicyAccessRule
} from "./policy-access-rule-utils"; } from "./policy-access-rule-utils";
export { export {
createPolicyRuleMatchSchema,
createPolicyRulePrioritySchema, createPolicyRulePrioritySchema,
createPolicyRuleSchema, createPolicyRuleSchema,
createPolicyRuleValueSchema, createPolicyRuleValueSchema,
@@ -74,8 +77,10 @@ export {
createPolicyRulesSectionSchema, createPolicyRulesSectionSchema,
createPolicySchemaWithI18n, createPolicySchemaWithI18n,
getPolicyRuleValidationMessage, getPolicyRuleValidationMessage,
POLICY_RULE_MATCH_TYPES,
validatePolicyRulePriority, validatePolicyRulePriority,
validatePolicyRuleValue, validatePolicyRuleValue,
validatePolicyRulesForSave, validatePolicyRulesForSave,
type PolicyRuleMatchType,
type RuleValidationToast type RuleValidationToast
} from "./policy-access-rule-validation"; } from "./policy-access-rule-validation";
@@ -1,7 +1,9 @@
import type { PolicyRuleMatchType } from "./policy-access-rule-validation";
export type PolicyAccessRule = { export type PolicyAccessRule = {
ruleId: number; ruleId: number;
action: "ACCEPT" | "DROP" | "PASS"; action: "ACCEPT" | "DROP" | "PASS";
match: string; match: PolicyRuleMatchType;
value: string; value: string;
priority: number; priority: number;
enabled: boolean; 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 }>( export function sortPolicyRulesByPriority<T extends { priority: number }>(
rules: T[] rules: T[]
): T[] { ): T[] {
return [...rules].sort((a, b) => a.priority - b.priority); 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< export function reorderPolicyRules<
T extends { priority: number; new?: boolean; updated?: boolean } T extends { priority: number; new?: boolean; updated?: boolean }
>( >(
@@ -70,3 +156,40 @@ export function reorderPolicyRules<
return next; 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> values?: Record<string, string | number>
) => string; ) => 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 = { export type RuleValidationToast = {
title: string; title: string;
description: string; description: string;
@@ -78,7 +95,7 @@ export function createPolicyRuleSchema(t: TranslateFn) {
return z return z
.object({ .object({
action: z.enum(["ACCEPT", "DROP", "PASS"]), action: z.enum(["ACCEPT", "DROP", "PASS"]),
match: z.string(), match: createPolicyRuleMatchSchema(t),
value: z.string(), value: z.string(),
priority: z.number().int(), priority: z.number().int(),
enabled: z.boolean() 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 SiteOnlineStatusProps = {
type: Selectedsite["type"]; type: Selectedsite["type"];
online: Selectedsite["online"]; online: Selectedsite["online"];
t: (key: "online" | "offline") => string;
}; };
/** Dot-only indicator matching `SitesTable` colors (newt/wireguard only; nothing for local or missing status). */ /** 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") { if (type !== "newt" && type !== "wireguard") {
return null; return null;
} }
@@ -128,7 +129,6 @@ export function SitesSelector({
<SiteOnlineStatus <SiteOnlineStatus
type={site.type} type={site.type}
online={site.online} online={site.online}
t={t}
/> />
)} )}
</div> </div>
+1 -1
View File
@@ -4,7 +4,7 @@ import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@app/lib/cn"; import { cn } from "@app/lib/cn";
const alertVariants = cva( 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: { variants: {
variant: { variant: {
+1 -1
View File
@@ -5,7 +5,7 @@ import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@app/lib/cn"; import { cn } from "@app/lib/cn";
const buttonVariants = cva( 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: { variants: {
variant: { variant: {
@@ -351,10 +351,6 @@ export function ControlledDataTable<TData, TValue>({
dataTableFilterDropdownContentClassName dataTableFilterDropdownContentClassName
} }
> >
<DropdownMenuLabel>
{filter.label}
</DropdownMenuLabel>
<DropdownMenuSeparator />
{filter.options.map( {filter.options.map(
(option) => { (option) => {
const isChecked = const isChecked =
@@ -484,13 +480,6 @@ export function ControlledDataTable<TData, TValue>({
align="end" align="end"
className="w-48" className="w-48"
> >
<DropdownMenuLabel>
{t(
"toggleColumns"
) ||
"Toggle columns"}
</DropdownMenuLabel>
<DropdownMenuSeparator />
{table {table
.getAllColumns() .getAllColumns()
.filter( .filter(
+18 -3
View File
@@ -105,13 +105,17 @@ function SelectLabel({
function SelectItem({ function SelectItem({
className, className,
children, children,
description,
...props ...props
}: React.ComponentProps<typeof SelectPrimitive.Item>) { }: React.ComponentProps<typeof SelectPrimitive.Item> & {
description?: React.ReactNode;
}) {
return ( return (
<SelectPrimitive.Item <SelectPrimitive.Item
data-slot="select-item" data-slot="select-item"
className={cn( 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 className
)} )}
{...props} {...props}
@@ -121,7 +125,18 @@ function SelectItem({
<CheckIcon className="size-4" /> <CheckIcon className="size-4" />
</SelectPrimitive.ItemIndicator> </SelectPrimitive.ItemIndicator>
</span> </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> </SelectPrimitive.Item>
); );
} }
+1 -1
View File
@@ -91,7 +91,7 @@ const TableHead = React.forwardRef<
<th <th
ref={ref} ref={ref}
className={cn( 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 className
)} )}
{...props} {...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 { ListHealthChecksResponse } from "@server/routers/healthChecks/types";
import { StatusHistoryResponse } from "@server/lib/statusHistory"; import { StatusHistoryResponse } from "@server/lib/statusHistory";
import type { ListResourcePoliciesResponse } from "@server/routers/resource/types"; import type { ListResourcePoliciesResponse } from "@server/routers/resource/types";
import type { GetResourcePolicyResponse } from "@server/routers/policy";
export type ProductUpdate = { export type ProductUpdate = {
link: string | null; 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({ queryOptions({
queryKey: ["ORG", orgId, "RESOURCES_POLICIES", name] as const, queryKey: ["ORG", orgId, "RESOURCES_POLICIES", query] as const,
queryFn: async ({ signal, meta }) => { queryFn: async ({ signal, meta }) => {
const sp = new URLSearchParams({ const sp = new URLSearchParams({
pageSize: "10" pageSize: "10"
}); });
if (name) { if (query) {
sp.set("query", name); sp.set("query", query);
} }
const res = await meta!.api.get< const res = await meta!.api.get<
@@ -601,6 +602,18 @@ export const orgQueries = {
return res.data.data.policies; 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");
}