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