Compare commits

..

2 Commits

Author SHA1 Message Date
Owen f213e81cde Reconnect newts when a exit node comes back online 2026-06-08 11:12:14 -07:00
Owen 7b6b1f0a4c Add exit node if the sites dont have one 2026-06-08 11:11:46 -07:00
79 changed files with 2589 additions and 4039 deletions
-5
View File
@@ -1,5 +0,0 @@
---
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.
+13 -48
View File
@@ -150,16 +150,16 @@
"siteCredentialsSaveDescription": "You will only be able to see this once. Make sure to copy it to a secure place.",
"siteInfo": "Site Information",
"status": "Status",
"shareTitle": "Manage Shareable Links",
"shareTitle": "Manage Share Links",
"shareDescription": "Create shareable links to grant temporary or permanent access to proxy resources",
"shareSearch": "Search shareable links...",
"shareCreate": "Create Shareable Link",
"shareSearch": "Search share links...",
"shareCreate": "Create Share Link",
"shareErrorDelete": "Failed to delete link",
"shareErrorDeleteMessage": "An error occurred deleting link",
"shareDeleted": "Link deleted",
"shareDeletedDescription": "The link has been deleted",
"shareDelete": "Delete Shareable Link",
"shareDeleteConfirm": "Confirm Delete Shareable Link",
"shareDelete": "Delete Share Link",
"shareDeleteConfirm": "Confirm Delete Share Link",
"shareQuestionRemove": "Are you sure you want to delete this share link?",
"shareMessageRemove": "Once deleted, the link will no longer work and anyone using it will lose access to the resource.",
"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,7 +179,6 @@
"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.",
@@ -212,8 +211,6 @@
"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)",
@@ -293,8 +290,6 @@
"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",
@@ -779,7 +774,6 @@
"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.",
@@ -849,10 +843,6 @@
"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.",
@@ -882,9 +872,9 @@
"resourcesErrorUpdateDescription": "An error occurred while updating the resource",
"access": "Access",
"accessControl": "Access Control",
"shareLink": "{resource} Shareable Link",
"shareLink": "{resource} Share Link",
"resourceSelect": "Select resource",
"shareLinks": "Shareable Links",
"shareLinks": "Share 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",
@@ -974,16 +964,10 @@
"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.",
"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>.",
"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.",
"resourceUsersRoles": "Access Controls",
"resourceUsersRolesDescription": "Configure which users and roles can visit this resource",
"resourceUsersRolesSubmit": "Save Access Controls",
@@ -1291,7 +1275,6 @@
"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.",
@@ -1528,7 +1511,7 @@
"sidebarResources": "Resources",
"sidebarProxyResources": "Public",
"sidebarClientResources": "Private",
"sidebarPolicies": "Shared Policies",
"sidebarPolicies": "Policies",
"sidebarResourcePolicies": "Public Resources",
"sidebarAccessControl": "Access Control",
"sidebarLogsAndAnalytics": "Logs & Analytics",
@@ -1537,7 +1520,7 @@
"sidebarAdmin": "Admin",
"sidebarInvitations": "Invitations",
"sidebarRoles": "Roles",
"sidebarShareableLinks": "Shareable Links",
"sidebarShareableLinks": "Share Links",
"sidebarApiKeys": "API Keys",
"sidebarProvisioning": "Provisioning",
"sidebarSettings": "Settings",
@@ -2161,25 +2144,10 @@
"sshSudoModeCommandsDescription": "User can run only the specified commands with sudo.",
"sshSudo": "Allow sudo",
"sshSudoCommands": "Sudo Commands",
"sshSudoCommandsDescription": "List of commands the user is allowed to run with sudo, separated by commas, spaces, or new lines. Absolute paths must be used.",
"sshSudoCommandsDescription": "Comma separated list of commands the user is allowed to run with sudo. Absolute paths must be used.",
"sshCreateHomeDir": "Create Home Directory",
"sshUnixGroups": "Unix Groups",
"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.",
"sshUnixGroupsDescription": "Comma separated Unix groups to add the user to on the target host.",
"retryAttempts": "Retry Attempts",
"expectedResponseCodes": "Expected Response Codes",
"expectedResponseCodesDescription": "HTTP status code that indicates healthy status. If left blank, 200-300 is considered healthy.",
@@ -3163,7 +3131,6 @@
"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",
@@ -3581,7 +3548,5 @@
"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",
"sessionToolbarShow": "Show toolbar",
"sessionToolbarHide": "Hide toolbar"
"rdpUnicodeKeyboardMode": "Unicode keyboard mode"
}
-41
View File
@@ -1,7 +1,5 @@
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 (
@@ -69,45 +67,6 @@ 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, sql } from "drizzle-orm";
import { and, eq } from "drizzle-orm";
import { NextFunction, Request, Response } from "express";
import createHttpError from "http-errors";
import { z } from "zod";
@@ -107,26 +107,6 @@ 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, ne, sql } from "drizzle-orm";
import { and, eq } from "drizzle-orm";
import { NextFunction, Request, Response } from "express";
import createHttpError from "http-errors";
import { z } from "zod";
@@ -74,29 +74,6 @@ 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,8 +33,9 @@ import {
import { getUniqueResourcePolicyName } from "@server/db/names";
import response from "@server/lib/response";
import {
getResourceRuleValueValidationError,
RESOURCE_RULE_MATCH_TYPES
isValidCIDR,
isValidIP,
isValidUrlGlobPattern
} from "@server/lib/validators";
import logger from "@server/logger";
import { OpenAPITags, registry } from "@server/openApi";
@@ -55,9 +56,9 @@ const ruleSchema = z.strictObject({
enum: ["ACCEPT", "DROP", "PASS"],
description: "rule action"
}),
match: z.enum(RESOURCE_RULE_MATCH_TYPES).openapi({
match: z.enum(["CIDR", "IP", "PATH"]).openapi({
type: "string",
enum: [...RESOURCE_RULE_MATCH_TYPES],
enum: ["CIDR", "IP", "PATH"],
description: "rule match"
}),
value: z.string().min(1),
@@ -260,13 +261,26 @@ export async function createResourcePolicy(
const niceId = await getUniqueResourcePolicyName(orgId);
for (const rule of rules) {
const validationError = getResourceRuleValueValidationError(
rule.match,
rule.value
);
if (validationError) {
if (rule.match === "CIDR" && !isValidCIDR(rule.value)) {
return next(
createHttpError(HttpCode.BAD_REQUEST, validationError)
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"
)
);
}
}
-7
View File
@@ -666,13 +666,6 @@ authenticated.get(
resource.getResourcePolicies
);
authenticated.get(
"/resource-policy/:resourcePolicyId",
verifyResourcePolicyAccess,
verifyUserHasAction(ActionsEnum.getResourcePolicy),
policy.getResourcePolicy
);
authenticated.put(
"/resource-policy/:resourcePolicyId",
verifyResourcePolicyAccess,
+24 -10
View File
@@ -8,8 +8,9 @@ import createHttpError from "http-errors";
import logger from "@server/logger";
import { fromError } from "zod-validation-error";
import {
getResourceRuleValueValidationError,
RESOURCE_RULE_MATCH_TYPES
isValidCIDR,
isValidIP,
isValidUrlGlobPattern
} from "@server/lib/validators";
import { OpenAPITags, registry } from "@server/openApi";
@@ -19,9 +20,9 @@ const ruleSchema = z.strictObject({
enum: ["ACCEPT", "DROP", "PASS"],
description: "rule action"
}),
match: z.enum(RESOURCE_RULE_MATCH_TYPES).openapi({
match: z.enum(["CIDR", "IP", "PATH"]).openapi({
type: "string",
enum: [...RESOURCE_RULE_MATCH_TYPES],
enum: ["CIDR", "IP", "PATH"],
description: "rule match"
}),
value: z.string().min(1),
@@ -104,13 +105,26 @@ export async function setResourcePolicyRules(
}
for (const rule of rules) {
const validationError = getResourceRuleValueValidationError(
rule.match,
rule.value
);
if (validationError) {
if (rule.match === "CIDR" && !isValidCIDR(rule.value)) {
return next(
createHttpError(HttpCode.BAD_REQUEST, validationError)
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"
)
);
}
}
@@ -225,7 +225,7 @@ export async function getResourceAuthInfo(
wildcard: resource.wildcard ?? false,
fullDomain: resource.fullDomain,
whitelist: effectivePolicy?.emailWhitelistEnabled ?? false,
skipToIdpId: effectivePolicy?.idpId ?? resource.skipToIdpId,
skipToIdpId: resource.skipToIdpId,
orgId: resource.orgId,
postAuthPath: resource.postAuthPath ?? null
},
+5 -85
View File
@@ -1,6 +1,6 @@
import { Request, Response, NextFunction } from "express";
import { db, DB_TYPE, type Label } from "@server/db";
import { and, asc, eq, or, inArray, sql } from "drizzle-orm";
import { db, DB_TYPE } from "@server/db";
import { and, eq, or, inArray, sql } from "drizzle-orm";
import {
resources,
userResources,
@@ -20,17 +20,12 @@ import {
userSiteResources,
roleSiteResources,
siteNetworks,
sites,
labels,
resourceLabels,
siteResourceLabels
sites
} 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,
@@ -358,73 +353,6 @@ 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) => {
@@ -525,10 +453,7 @@ export async function getUserResources(
sso: resource.sso,
password: hasPassword,
pincode: hasPincode,
whitelist: hasWhitelist,
labels: labelsForResources.filter(
(l) => l.resourceId === resource.resourceId
)
whitelist: hasWhitelist
};
})
);
@@ -554,10 +479,7 @@ export async function getUserResources(
siteNiceIds: siteResource.siteNiceIds,
siteAddresses: siteResource.siteAddresses,
siteOnlines: siteResource.siteOnlines,
type: "site" as const,
labels: labelsForSiteResources.filter(
(l) => l.siteResourceId === siteResource.siteResourceId
)
type: "site" as const
};
});
@@ -592,7 +514,6 @@ export type GetUserResourcesResponse = {
enabled: boolean;
protected: boolean;
mode: string;
labels?: Array<Pick<Label, "color" | "labelId" | "name">>;
}>;
siteResources: Array<{
siteResourceId: number;
@@ -614,7 +535,6 @@ export type GetUserResourcesResponse = {
siteAddresses: (string | null)[];
siteOnlines: boolean[];
type: "site";
labels?: Array<Pick<Label, "color" | "labelId" | "name">>;
}>;
};
};
-6
View File
@@ -274,12 +274,6 @@ 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`);
-9
View File
@@ -339,15 +339,6 @@ 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,8 +10,6 @@ import { formatAxiosError } from "@app/lib/api";
import { AxiosResponse } from "axios";
import {
SettingsContainer,
SettingsFormCell,
SettingsFormGrid,
SettingsSection,
SettingsSectionHeader,
SettingsSectionTitle,
@@ -1326,44 +1324,42 @@ export default function BillingPage() {
</SettingsSectionDescription>
</SettingsSectionHeader>
<SettingsSectionBody>
<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 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>
</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>
</SettingsFormCell>
</SettingsFormGrid>
<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>
</SettingsSectionBody>
</SettingsSection>
)}
@@ -1,7 +0,0 @@
"use client";
import { EditPolicyForm } from "@app/components/resource-policy/EditPolicyForm";
export default function EditPolicyAuthenticationPage() {
return <EditPolicyForm section="authentication" />;
}
@@ -1,7 +0,0 @@
"use client";
import { EditPolicyForm } from "@app/components/resource-policy/EditPolicyForm";
export default function EditPolicyGeneralPage() {
return <EditPolicyForm section="general" />;
}
@@ -1,85 +0,0 @@
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,12 +1,62 @@
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";
type EditPolicyPageProps = {
export interface EditPolicyPageProps {
params: Promise<{ niceId: string; orgId: string }>;
};
}
export default async function EditPolicyPage(props: EditPolicyPageProps) {
const params = await props.params;
redirect(
`/${params.orgId}/settings/policies/resources/public/${params.niceId}/general`
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>
</>
);
}
@@ -1,7 +0,0 @@
"use client";
import { EditPolicyForm } from "@app/components/resource-policy/EditPolicyForm";
export default function EditPolicyRulesPage() {
return <EditPolicyForm section="rules" />;
}
@@ -1,4 +1,3 @@
import ResourcePoliciesBanner from "@app/components/ResourcePoliciesBanner";
import { ResourcePoliciesTable } from "@app/components/ResourcePoliciesTable";
import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
import { internal } from "@app/lib/api";
@@ -55,8 +54,6 @@ export default async function ResourcePoliciesPage(
description={t("resourcePoliciesDescription")}
/>
<ResourcePoliciesBanner />
<ResourcePoliciesTable
policies={policies}
orgId={params.orgId}
@@ -9,8 +9,6 @@ import {
} from "@app/components/InfoSection";
import {
SettingsContainer,
SettingsFormCell,
SettingsFormGrid,
SettingsSection,
SettingsSectionBody,
SettingsSectionDescription,
@@ -259,87 +257,80 @@ 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"
>
<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"
)}
</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>
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("name")}
</FormLabel>
<FormControl>
<Input
autoComplete="off"
{...field}
/>
</FormControl>
<FormMessage />
<FormDescription>
{t(
"clientNameDescription"
)}
/>
</SettingsFormCell>
</FormDescription>
</FormItem>
)}
</SettingsFormGrid>
/>
<div className="flex items-center justify-end md:col-start-2">
<Button
type="button"
variant="ghost"
size="sm"
onClick={() =>
setShowAdvancedSettings(
!showAdvancedSettings
)
}
className="flex items-center gap-2"
>
{showAdvancedSettings ? (
<ChevronUp className="h-4 w-4" />
) : (
<ChevronDown className="h-4 w-4" />
)}
{t("advancedSettings")}
</Button>
</div>
{showAdvancedSettings && (
<FormField
control={form.control}
name="subnet"
render={({ field }) => (
<FormItem className="md:col-start-1 md:col-span-2">
<FormLabel>
{t("clientAddress")}
</FormLabel>
<FormControl>
<Input
autoComplete="off"
placeholder={t(
"subnetPlaceholder"
)}
{...field}
/>
</FormControl>
<FormMessage />
<FormDescription>
{t(
"addressDescription"
)}
</FormDescription>
</FormItem>
)}
/>
)}
</form>
</Form>
</SettingsSectionBody>
+29 -29
View File
@@ -11,7 +11,7 @@ import { ColumnDef } from "@tanstack/react-table";
import { DateTimeValue } from "@app/components/DateTimePicker";
import { ArrowUpRight, Key, User } from "lucide-react";
import Link from "next/link";
import { ColumnFilterButton } from "@app/components/ColumnFilterButton";
import { ColumnFilter } from "@app/components/ColumnFilter";
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 <span className="px-2">{t("timestamp")}</span>;
return t("timestamp");
},
cell: ({ row }) => {
return (
@@ -249,19 +249,19 @@ export default function GeneralPage() {
accessorKey: "action",
header: () => {
return (
<div className="flex items-center gap-2 px-2">
<ColumnFilterButton
<div className="flex items-center gap-2">
<span>{t("action")}</span>
<ColumnFilter
options={[
{ value: "true", label: "Allowed" },
{ value: "false", label: "Denied" }
]}
label={t("action")}
selectedValue={filters.action}
onValueChange={(value) =>
handleFilterChange("action", value)
}
searchPlaceholder={t("searchPlaceholder")}
emptyMessage={t("emptySearchOptions")}
searchPlaceholder="Search..."
emptyMessage="None found"
/>
</div>
);
@@ -276,27 +276,27 @@ export default function GeneralPage() {
},
{
accessorKey: "ip",
header: () => <span className="px-2">{t("ip")}</span>
header: () => t("ip")
},
{
accessorKey: "location",
header: () => {
return (
<div className="flex items-center gap-2 px-2">
<ColumnFilterButton
<div className="flex items-center gap-2">
<span>{t("location")}</span>
<ColumnFilter
options={filterAttributes.locations.map(
(location) => ({
value: location,
label: location
})
)}
label={t("location")}
selectedValue={filters.location}
onValueChange={(value) =>
handleFilterChange("location", value)
}
searchPlaceholder={t("searchPlaceholder")}
emptyMessage={t("emptySearchOptions")}
searchPlaceholder="Search..."
emptyMessage="None found"
/>
</div>
);
@@ -321,19 +321,19 @@ export default function GeneralPage() {
accessorKey: "resourceName",
header: () => {
return (
<div className="flex items-center gap-2 px-2">
<ColumnFilterButton
<div className="flex items-center gap-2">
<span>{t("resource")}</span>
<ColumnFilter
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={t("searchPlaceholder")}
emptyMessage={t("emptySearchOptions")}
searchPlaceholder="Search..."
emptyMessage="None found"
/>
</div>
);
@@ -359,8 +359,9 @@ export default function GeneralPage() {
accessorKey: "type",
header: () => {
return (
<div className="flex items-center gap-2 px-2">
<ColumnFilterButton
<div className="flex items-center gap-2">
<span>{t("type")}</span>
<ColumnFilter
options={[
{ value: "password", label: "Password" },
{ value: "pincode", label: "Pincode" },
@@ -371,13 +372,12 @@ export default function GeneralPage() {
},
{ value: "ssh", label: "SSH" }
]}
label={t("type")}
selectedValue={filters.type}
onValueChange={(value) =>
handleFilterChange("type", value)
}
searchPlaceholder={t("searchPlaceholder")}
emptyMessage={t("emptySearchOptions")}
searchPlaceholder="Search..."
emptyMessage="None found"
/>
</div>
);
@@ -395,19 +395,19 @@ export default function GeneralPage() {
accessorKey: "actor",
header: () => {
return (
<div className="flex items-center gap-2 px-2">
<ColumnFilterButton
<div className="flex items-center gap-2">
<span>{t("actor")}</span>
<ColumnFilter
options={filterAttributes.actors.map((actor) => ({
value: actor,
label: actor
}))}
label={t("actor")}
selectedValue={filters.actor}
onValueChange={(value) =>
handleFilterChange("actor", value)
}
searchPlaceholder={t("searchPlaceholder")}
emptyMessage={t("emptySearchOptions")}
searchPlaceholder="Search..."
emptyMessage="None found"
/>
</div>
);
@@ -433,7 +433,7 @@ export default function GeneralPage() {
},
{
accessorKey: "actorId",
header: () => <span className="px-2">{t("actorId")}</span>,
header: () => t("actorId"),
cell: ({ row }) => (
<span className="flex items-center gap-1">
{row.original.actorId || "-"}
+17 -13
View File
@@ -1,5 +1,5 @@
"use client";
import { ColumnFilterButton } from "@app/components/ColumnFilterButton";
import { ColumnFilter } from "@app/components/ColumnFilter";
import { DateTimeValue } from "@app/components/DateTimePicker";
import { LogDataTable } from "@app/components/LogDataTable";
import { PaidFeaturesAlert } from "@app/components/PaidFeaturesAlert";
@@ -219,7 +219,9 @@ export default function GeneralPage() {
const columns: ColumnDef<any>[] = [
{
accessorKey: "timestamp",
header: () => <span className="px-2">{t("timestamp")}</span>,
header: () => {
return t("timestamp");
},
cell: ({ row }) => {
return (
<div className="whitespace-nowrap">
@@ -234,16 +236,16 @@ export default function GeneralPage() {
accessorKey: "action",
header: () => {
return (
<div className="flex items-center gap-2 px-2">
<ColumnFilterButton
<div className="flex items-center gap-2">
<span>{t("action")}</span>
<ColumnFilter
options={[]}
label={t("action")}
selectedValue={filters.action}
onValueChange={(value) =>
handleFilterChange("action", value)
}
searchPlaceholder={t("searchPlaceholder")}
emptyMessage={t("emptySearchOptions")}
searchPlaceholder="Search..."
emptyMessage="None found"
/>
</div>
);
@@ -261,19 +263,19 @@ export default function GeneralPage() {
accessorKey: "actor",
header: () => {
return (
<div className="flex items-center gap-2 px-2">
<ColumnFilterButton
<div className="flex items-center gap-2">
<span>{t("actor")}</span>
<ColumnFilter
options={filterAttributes.actors.map((actor) => ({
value: actor,
label: actor
}))}
label={t("actor")}
selectedValue={filters.actor}
onValueChange={(value) =>
handleFilterChange("actor", value)
}
searchPlaceholder={t("searchPlaceholder")}
emptyMessage={t("emptySearchOptions")}
searchPlaceholder="Search..."
emptyMessage="None found"
/>
</div>
);
@@ -293,7 +295,9 @@ export default function GeneralPage() {
},
{
accessorKey: "actorId",
header: () => <span className="px-2">{t("actorId")}</span>,
header: () => {
return t("actorId");
},
cell: ({ row }) => {
return (
<span className="flex items-center gap-1">
@@ -1,6 +1,6 @@
"use client";
import { Button } from "@app/components/ui/button";
import { ColumnFilterButton } from "@app/components/ColumnFilterButton";
import { ColumnFilter } from "@app/components/ColumnFilter";
import { DateTimeValue } from "@app/components/DateTimePicker";
import { LogDataTable } from "@app/components/LogDataTable";
import { PaidFeaturesAlert } from "@app/components/PaidFeaturesAlert";
@@ -256,7 +256,9 @@ export default function ConnectionLogsPage() {
const columns: ColumnDef<any>[] = [
{
accessorKey: "startedAt",
header: () => <span className="px-2">{t("timestamp")}</span>,
header: () => {
return t("timestamp");
},
cell: ({ row }) => {
return (
<div className="whitespace-nowrap">
@@ -271,21 +273,21 @@ export default function ConnectionLogsPage() {
accessorKey: "protocol",
header: () => {
return (
<div className="flex items-center gap-2 px-2">
<ColumnFilterButton
<div className="flex items-center gap-2">
<span>{t("protocol")}</span>
<ColumnFilter
options={filterAttributes.protocols.map(
(protocol) => ({
label: protocol.toUpperCase(),
value: protocol
})
)}
label={t("protocol")}
selectedValue={filters.protocol}
onValueChange={(value) =>
handleFilterChange("protocol", value)
}
searchPlaceholder={t("searchPlaceholder")}
emptyMessage={t("emptySearchOptions")}
searchPlaceholder="Search..."
emptyMessage="None found"
/>
</div>
);
@@ -302,19 +304,19 @@ export default function ConnectionLogsPage() {
accessorKey: "resourceName",
header: () => {
return (
<div className="flex items-center gap-2 px-2">
<ColumnFilterButton
<div className="flex items-center gap-2">
<span>{t("resource")}</span>
<ColumnFilter
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={t("searchPlaceholder")}
emptyMessage={t("emptySearchOptions")}
searchPlaceholder="Search..."
emptyMessage="None found"
/>
</div>
);
@@ -343,19 +345,19 @@ export default function ConnectionLogsPage() {
accessorKey: "clientName",
header: () => {
return (
<div className="flex items-center gap-2 px-2">
<ColumnFilterButton
<div className="flex items-center gap-2">
<span>{t("client")}</span>
<ColumnFilter
options={filterAttributes.clients.map((c) => ({
value: c.id.toString(),
label: c.name
}))}
label={t("client")}
selectedValue={filters.clientId}
onValueChange={(value) =>
handleFilterChange("clientId", value)
}
searchPlaceholder={t("searchPlaceholder")}
emptyMessage={t("emptySearchOptions")}
searchPlaceholder="Search..."
emptyMessage="None found"
/>
</div>
);
@@ -386,19 +388,19 @@ export default function ConnectionLogsPage() {
accessorKey: "userEmail",
header: () => {
return (
<div className="flex items-center gap-2 px-2">
<ColumnFilterButton
<div className="flex items-center gap-2">
<span>{t("user")}</span>
<ColumnFilter
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={t("searchPlaceholder")}
emptyMessage={t("emptySearchOptions")}
searchPlaceholder="Search..."
emptyMessage="None found"
/>
</div>
);
@@ -417,7 +419,9 @@ export default function ConnectionLogsPage() {
},
{
accessorKey: "sourceAddr",
header: () => <span className="px-2">{t("sourceAddress")}</span>,
header: () => {
return t("sourceAddress");
},
cell: ({ row }) => {
return (
<span className="whitespace-nowrap font-mono text-xs">
@@ -430,19 +434,19 @@ export default function ConnectionLogsPage() {
accessorKey: "destAddr",
header: () => {
return (
<div className="flex items-center gap-2 px-2">
<ColumnFilterButton
<div className="flex items-center gap-2">
<span>{t("destinationAddress")}</span>
<ColumnFilter
options={filterAttributes.destAddrs.map((addr) => ({
value: addr,
label: addr
}))}
label={t("destinationAddress")}
selectedValue={filters.destAddr}
onValueChange={(value) =>
handleFilterChange("destAddr", value)
}
searchPlaceholder={t("searchPlaceholder")}
emptyMessage={t("emptySearchOptions")}
searchPlaceholder="Search..."
emptyMessage="None found"
/>
</div>
);
@@ -457,7 +461,9 @@ export default function ConnectionLogsPage() {
},
{
accessorKey: "duration",
header: () => <span className="px-2">{t("duration")}</span>,
header: () => {
return t("duration");
},
cell: ({ row }) => {
return (
<span className="whitespace-nowrap">
+54 -46
View File
@@ -20,7 +20,6 @@ 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();
@@ -285,9 +284,9 @@ export default function GeneralPage() {
const columns: ColumnDef<any>[] = [
{
accessorKey: "timestamp",
header: ({ column }) => (
<span className="px-2">{t("timestamp")}</span>
),
header: ({ column }) => {
return t("timestamp");
},
cell: ({ row }) => {
return (
<div className="whitespace-nowrap">
@@ -300,21 +299,22 @@ export default function GeneralPage() {
},
{
accessorKey: "action",
header: () => {
header: ({ column }) => {
return (
<div className="flex items-center gap-2 px-2">
<ColumnFilterButton
<div className="flex items-center gap-2">
<span>{t("action")}</span>
<ColumnFilter
options={[
{ value: "true", label: "Allowed" },
{ value: "false", label: "Denied" }
]}
label={t("action")}
selectedValue={filters.action}
onValueChange={(value) =>
handleFilterChange("action", value)
}
searchPlaceholder={t("searchPlaceholder")}
emptyMessage={t("emptySearchOptions")}
// placeholder=""
searchPlaceholder="Search..."
emptyMessage="None found"
/>
</div>
);
@@ -329,14 +329,17 @@ export default function GeneralPage() {
},
{
accessorKey: "ip",
header: ({ column }) => <span className="px-2">{t("ip")}</span>
header: ({ column }) => {
return t("ip");
}
},
{
accessorKey: "location",
header: ({ column }) => {
return (
<div className="flex items-center gap-2 px-2">
<ColumnFilterButton
<div className="flex items-center gap-2">
<span>{t("location")}</span>
<ColumnFilter
options={filterAttributes.locations.map(
(location) => ({
value: location,
@@ -348,9 +351,8 @@ export default function GeneralPage() {
handleFilterChange("location", value)
}
// placeholder=""
label={t("location")}
searchPlaceholder={t("searchPlaceholder")}
emptyMessage={t("emptySearchOptions")}
searchPlaceholder="Search..."
emptyMessage="None found"
/>
</div>
);
@@ -375,8 +377,9 @@ export default function GeneralPage() {
accessorKey: "resourceName",
header: ({ column }) => {
return (
<div className="flex items-center gap-2 px-2">
<ColumnFilterButton
<div className="flex items-center gap-2">
<span>{t("resource")}</span>
<ColumnFilter
options={filterAttributes.resources.map((res) => ({
value: res.id.toString(),
label: res.name || "Unnamed Resource"
@@ -385,9 +388,9 @@ export default function GeneralPage() {
onValueChange={(value) =>
handleFilterChange("resourceId", value)
}
label={t("resource")}
searchPlaceholder={t("searchPlaceholder")}
emptyMessage={t("emptySearchOptions")}
// placeholder=""
searchPlaceholder="Search..."
emptyMessage="None found"
/>
</div>
);
@@ -414,8 +417,9 @@ export default function GeneralPage() {
accessorKey: "host",
header: ({ column }) => {
return (
<div className="flex items-center gap-2 px-2">
<ColumnFilterButton
<div className="flex items-center gap-2">
<span>{t("host")}</span>
<ColumnFilter
options={filterAttributes.hosts.map((host) => ({
value: host,
label: host
@@ -424,9 +428,9 @@ export default function GeneralPage() {
onValueChange={(value) =>
handleFilterChange("host", value)
}
label={t("host")}
searchPlaceholder={t("searchPlaceholder")}
emptyMessage={t("emptySearchOptions")}
// placeholder=""
searchPlaceholder="Search..."
emptyMessage="None found"
/>
</div>
);
@@ -448,8 +452,9 @@ export default function GeneralPage() {
accessorKey: "path",
header: ({ column }) => {
return (
<div className="flex items-center gap-2 px-2">
<ColumnFilterButton
<div className="flex items-center gap-2">
<span>{t("path")}</span>
<ColumnFilter
options={filterAttributes.paths.map((path) => ({
value: path,
label: path
@@ -458,9 +463,9 @@ export default function GeneralPage() {
onValueChange={(value) =>
handleFilterChange("path", value)
}
label={t("path")}
searchPlaceholder={t("searchPlaceholder")}
emptyMessage={t("emptySearchOptions")}
// placeholder=""
searchPlaceholder="Search..."
emptyMessage="None found"
/>
</div>
);
@@ -477,8 +482,9 @@ export default function GeneralPage() {
accessorKey: "method",
header: ({ column }) => {
return (
<div className="flex items-center gap-2 px-2">
<ColumnFilterButton
<div className="flex items-center gap-2">
<span>{t("method")}</span>
<ColumnFilter
options={[
{ value: "GET", label: "GET" },
{ value: "POST", label: "POST" },
@@ -492,9 +498,9 @@ export default function GeneralPage() {
onValueChange={(value) =>
handleFilterChange("method", value)
}
label={t("method")}
searchPlaceholder={t("searchPlaceholder")}
emptyMessage={t("emptySearchOptions")}
// placeholder=""
searchPlaceholder="Search..."
emptyMessage="None found"
/>
</div>
);
@@ -504,8 +510,9 @@ export default function GeneralPage() {
accessorKey: "reason",
header: ({ column }) => {
return (
<div className="flex items-center gap-2 px-2">
<ColumnFilterButton
<div className="flex items-center gap-2">
<span>{t("reason")}</span>
<ColumnFilter
options={[
{ value: "100", label: t("allowedByRule") },
{ value: "101", label: t("allowedNoAuth") },
@@ -530,9 +537,9 @@ export default function GeneralPage() {
onValueChange={(value) =>
handleFilterChange("reason", value)
}
label={t("reason")}
searchPlaceholder={t("searchPlaceholder")}
emptyMessage={t("emptySearchOptions")}
// placeholder=""
searchPlaceholder="Search..."
emptyMessage="None found"
/>
</div>
);
@@ -549,8 +556,9 @@ export default function GeneralPage() {
accessorKey: "actor",
header: ({ column }) => {
return (
<div className="flex items-center gap-2 px-2">
<ColumnFilterButton
<div className="flex items-center gap-2">
<span>{t("actor")}</span>
<ColumnFilter
options={filterAttributes.actors.map((actor) => ({
value: actor,
label: actor
@@ -559,9 +567,9 @@ export default function GeneralPage() {
onValueChange={(value) =>
handleFilterChange("actor", value)
}
label={t("actor")}
searchPlaceholder={t("searchPlaceholder")}
emptyMessage={t("emptySearchOptions")}
// placeholder=""
searchPlaceholder="Search..."
emptyMessage="None found"
/>
</div>
);
@@ -21,7 +21,6 @@ import {
SettingsSectionHeader,
SettingsSectionTitle
} from "@app/components/Settings";
import { DataTableEmptyState } from "@app/components/ui/data-table-empty-state";
import {
Table,
TableBody,
@@ -672,15 +671,6 @@ 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;
@@ -794,104 +784,143 @@ export function ProxyResourceTargetsForm({
</SettingsSectionDescription>
</SettingsSectionHeader>
<SettingsSectionBody>
<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"
: ""
}
{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"
>
{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"
{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"
>
{t("advancedMode")}
</label>
<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>
</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,7 +1,320 @@
"use client";
import { ResourcePolicyEditForm } from "@app/components/resource-policy/ResourcePolicyEditForm";
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">;
export default function ResourceAuthenticationPage() {
return <ResourcePolicyEditForm section="authentication" />;
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>
</>
);
}
@@ -20,8 +20,6 @@ import {
SettingsSectionBody,
SettingsSectionDescription,
SettingsSectionFooter,
SettingsFormCell,
SettingsFormGrid,
SettingsSectionForm,
SettingsSectionHeader,
SettingsSectionTitle
@@ -38,14 +36,10 @@ import { AlertCircle } from "lucide-react";
import { useTranslations } from "next-intl";
import { useParams, useRouter } from "next/navigation";
import { toASCII, toUnicode } from "punycode";
import { useActionState, useEffect, useMemo, useState } from "react";
import { useActionState, 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 {
@@ -226,11 +220,6 @@ function MaintenanceSectionForm({
</TooltipProvider>
</FormControl>
</div>
<FormDescription>
{t(
"enableMaintenanceModeDescription"
)}
</FormDescription>
<FormMessage />
</FormItem>
);
@@ -440,30 +429,16 @@ 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 || "")}`
);
@@ -526,12 +501,6 @@ 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}`,
@@ -545,8 +514,7 @@ export default function GeneralForm() {
)
: undefined,
domainId: data.domainId,
proxyPort: data.proxyPort,
...(resourcePolicyId !== undefined && { resourcePolicyId })
proxyPort: data.proxyPort
}
)
.catch((e) => {
@@ -570,10 +538,7 @@ export default function GeneralForm() {
subdomain: data.subdomain,
fullDomain: updated.fullDomain,
proxyPort: data.proxyPort,
domainId: data.domainId,
...(resourcePolicyId !== undefined && {
resourcePolicyId
})
domainId: data.domainId
});
toast({
@@ -614,229 +579,193 @@ export default function GeneralForm() {
</SettingsSectionHeader>
<SettingsSectionBody>
<SettingsSectionForm variant="half">
<SettingsSectionForm>
<Form {...form}>
<form
action={formAction}
className="space-y-4"
id="general-settings-form"
>
<SettingsFormGrid>
<SettingsFormCell span="full">
<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
) && (
<>
<FormField
control={form.control}
name="enabled"
render={() => (
name="proxyPort"
render={({ field }) => (
<FormItem>
<FormLabel>
{t(
"resourcePortNumber"
)}
</FormLabel>
<FormControl>
<SwitchInput
id="enable-resource"
defaultChecked={
resource.enabled
<Input
type="number"
value={
field.value !==
undefined
? String(
field.value
)
: ""
}
label={t(
"resourceEnable"
)}
onCheckedChange={(
val
) =>
form.setValue(
"enabled",
val
onChange={(e) =>
field.onChange(
e.target
.value
? parseInt(
e
.target
.value
)
: undefined
)
}
/>
</FormControl>
<FormMessage />
<FormDescription>
{t(
"disabledResourceDescription"
"resourcePortNumberDescription"
)}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</SettingsFormCell>
</>
)}
<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;
}
{["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) {
form.setValue(
"domainId",
res.domainId
undefined
);
form.setValue(
"subdomain",
res.subdomain ??
undefined
undefined
);
setResourceFullDomain(
`${resource.ssl ? "https" : "http"}://${toUnicode(res.fullDomain)}`
`${resource.ssl ? "https" : "http"}://`
);
}}
/>
</div>
</SettingsFormCell>
)}
{showResourcePolicy && (
<SettingsFormCell span="half">
<div className="space-y-2">
<FormLabel>
{t("sharedPolicy")}
</FormLabel>
<SharedPolicySelect
key={
resource.resourcePolicyId ??
"none"
return;
}
orgId={org.org.orgId}
value={
selectedSharedPolicyId
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
}
onChange={
setSelectedSharedPolicyId
label={t(
"resourceEnable"
)}
onCheckedChange={(
val
) =>
form.setValue(
"enabled",
val
)
}
/>
</div>
</SettingsFormCell>
</FormControl>
<FormDescription>
{t(
"disabledResourceDescription"
)}
</FormDescription>
<FormMessage />
</FormItem>
)}
</SettingsFormGrid>
/>
</form>
</Form>
</SettingsSectionForm>
@@ -92,16 +92,10 @@ 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`
},
{
title: t("policyAccessRulesTitle"),
href: `/{orgId}/settings/resources/public/{niceId}/rules`
}
);
navItems.push({
title: t("authentication"),
href: `/{orgId}/settings/resources/public/{niceId}/authentication`
});
}
return (
@@ -1,7 +0,0 @@
"use client";
import { ResourcePolicyEditForm } from "@app/components/resource-policy/ResourcePolicyEditForm";
export default function ResourcePolicyRulesPage() {
return <ResourcePolicyEditForm section="rules" />;
}
@@ -5,8 +5,6 @@ import {
SettingsSection,
SettingsSectionBody,
SettingsSectionDescription,
SettingsFormCell,
SettingsFormGrid,
SettingsSectionForm,
SettingsSectionHeader,
SettingsSectionTitle,
@@ -412,199 +410,174 @@ function SshServerForm({
<Form {...form}>
<SettingsSectionBody>
<SettingsSectionForm variant="half">
<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("sshServerMode")}</p>
<Badge variant="secondary">
{sshServerMode == "standard"
? t("sshServerModeStandard")
: t("sshServerModePangolin")}
</Badge>
</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>
<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>
{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"
>
{t("learnMore")}
<ExternalLink className="size-3.5 shrink-0" />
</a>
</p>
</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 && (
<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>
{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 ? (
<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>
<FormField
control={form.control}
name="selectedNativeSite"
render={() => (
<FormItem>
<Popover
open={nativeSiteOpen}
onOpenChange={
setNativeSiteOpen
}
>
<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 ? (
<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>
<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 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>
<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}
/>
)}
</SettingsFormGrid>
</div>
</SettingsSectionForm>
</SettingsSectionBody>
<form action={formAction} className="flex justify-end mt-4">
@@ -4,8 +4,6 @@ import CopyTextBox from "@app/components/CopyTextBox";
import DomainPicker from "@app/components/DomainPicker";
import {
SettingsContainer,
SettingsFormCell,
SettingsFormGrid,
SettingsSection,
SettingsSectionBody,
SettingsSectionDescription,
@@ -808,198 +806,172 @@ export default function Page() {
</SettingsSectionHeader>
<SettingsSectionBody>
<SettingsSectionForm variant="half">
<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>
<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
}
cols={6}
/>
<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>
{/* 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>
{/* 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
}
);
}}
/>
</form>
</Form>
</SettingsFormCell>
)}
</SettingsFormGrid>
<FormMessage />
<FormDescription>
{t(
"resourceDomainDescription"
)}
</FormDescription>
</FormItem>
)}
/>
</Form>
)}
{/* Proxy Port (TCP/UDP types) */}
{!isHttpResource && (
<Form {...tcpUdpForm}>
<form
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
}
}}
className="grid gap-4 grid-cols-1 md:grid-cols-2 items-start"
id="tcp-udp-settings-form"
>
<FormField
control={
tcpUdpForm.control
}
name="proxyPort"
render={({ field }) => (
<FormItem>
<FormLabel>
{t(
"resourcePortNumber"
)}
</FormLabel>
<FormControl>
<Input
type="number"
value={
field.value ??
""
}
onChange={(
e
) =>
field.onChange(
e
.target
.value
? parseInt(
e
.target
.value
)
: undefined
)
}
/>
</FormControl>
<FormMessage />
<FormDescription>
{t(
"resourcePortDescription"
)}
</FormDescription>
</FormItem>
)}
/>
</form>
</Form>
)}
</SettingsSectionForm>
</SettingsSectionBody>
</SettingsSection>
@@ -1033,237 +1005,202 @@ export default function Page() {
>
<SettingsSectionBody>
<SettingsSectionForm variant="half">
<SettingsFormGrid>
<SettingsFormCell span="full">
<div className="space-y-2">
<p className="font-semibold text-sm">
{t("sshServerMode")}
</p>
<StrategySelect<
"standard" | "native"
{/* 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"
>
value={sshServerMode}
options={
sshModeOptions
{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
}
onChange={
setSshServerMode
}
cols={2}
name="authDaemonPort"
render={({
field
}) => (
<FormItem>
<FormLabel>
{t(
"sshDaemonPort"
)}
</FormLabel>
<FormControl>
<Input
type="number"
min={
1
}
max={
65535
}
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
</SettingsFormCell>
<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"
>
value={
standardDaemonLocation
}
options={
daemonLocationOptions
}
onChange={
setStandardDaemonLocation
}
cols={2}
/>
<p className="text-sm text-muted-foreground">
{t(
"sshDaemonDisclaimer"
)}{" "}
<a
href="https://docs.pangolin.net/manage/resources/public/ssh"
target="_blank"
rel="noopener noreferrer"
className="text-primary hover:underline inline-flex items-center gap-1"
>
{t(
"learnMore"
)}
<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>
</Form>
)}
{/* Server Destination */}
<div className="space-y-3">
<SettingsSubsectionHeader>
<SettingsSubsectionTitle>
{t(
"sshServerDestination"
)}
</SettingsSubsectionTitle>
<SettingsSubsectionDescription>
{t(
"sshServerDestinationDescription"
)}
</SettingsSubsectionDescription>
</SettingsSubsectionHeader>
{isNative ? (
<SettingsFormCell span="half">
<Popover
open={
nativeSiteOpen
}
onOpenChange={
setNativeSiteOpen
}
>
<PopoverTrigger
asChild
<Popover
open={nativeSiteOpen}
onOpenChange={
setNativeSiteOpen
}
>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
className="w-full md:w-1/2 justify-between font-normal"
>
<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={(
<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
) => {
setNativeSelectedSite(
site
);
setNativeSiteOpen(
false
);
}}
/>
</PopoverContent>
</Popover>
</SettingsFormCell>
);
setNativeSiteOpen(
false
);
}}
/>
</PopoverContent>
</Popover>
) : standardDaemonLocation !==
"site" ||
pamMode ===
"passthrough" ? (
<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={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={
false
}
siteField="selectedSite"
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>
)}
</SettingsFormGrid>
</div>
</SettingsSectionForm>
</SettingsSectionBody>
</fieldset>
+72 -83
View File
@@ -2,8 +2,6 @@
import {
SettingsContainer,
SettingsFormCell,
SettingsFormGrid,
SettingsSection,
SettingsSectionBody,
SettingsSectionDescription,
@@ -516,107 +514,98 @@ 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"
>
<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(
"siteNameDescription"
)}
</FormDescription>
</FormItem>
)}
/>
{form.watch("method") === "newt" && (
<div className="flex items-center justify-end md:col-start-2">
<Button
type="button"
variant="ghost"
size="sm"
onClick={() =>
setShowAdvancedSettings(
!showAdvancedSettings
)
}
className="flex items-center gap-2"
>
{showAdvancedSettings ? (
<ChevronUp className="h-4 w-4" />
) : (
<ChevronDown className="h-4 w-4" />
)}
{t("advancedSettings")}
</Button>
</div>
)}
{form.watch("method") === "newt" &&
showAdvancedSettings && (
<FormField
control={form.control}
name="name"
name="clientAddress"
render={({ field }) => (
<FormItem>
<FormItem className="md:col-start-1 md:col-span-2">
<FormLabel>
{t("name")}
{t(
"siteAddress"
)}
</FormLabel>
<FormControl>
<Input
autoComplete="off"
{...field}
value={
clientAddress
}
onChange={(
e
) => {
setClientAddress(
e
.target
.value
);
field.onChange(
e
.target
.value
);
}}
/>
</FormControl>
<FormMessage />
<FormDescription>
{t(
"siteNameDescription"
"siteAddressDescription"
)}
</FormDescription>
</FormItem>
)}
/>
{form.watch("method") ===
"newt" && (
<>
<Button
type="button"
variant="ghost"
size="sm"
onClick={() =>
setShowAdvancedSettings(
!showAdvancedSettings
)
}
className="mt-2 flex items-center gap-2 -ml-3"
>
{showAdvancedSettings ? (
<ChevronUp className="h-4 w-4" />
) : (
<ChevronDown className="h-4 w-4" />
)}
{t(
"advancedSettings"
)}
</Button>
{showAdvancedSettings && (
<FormField
control={
form.control
}
name="clientAddress"
render={({
field
}) => (
<FormItem className="mt-4">
<FormLabel>
{t(
"siteAddress"
)}
</FormLabel>
<FormControl>
<Input
autoComplete="off"
value={
clientAddress
}
onChange={(
e
) => {
setClientAddress(
e
.target
.value
);
field.onChange(
e
.target
.value
);
}}
/>
</FormControl>
<FormMessage />
<FormDescription>
{t(
"siteAddressDescription"
)}
</FormDescription>
</FormItem>
)}
/>
)}
</>
)}
</SettingsFormCell>
</SettingsFormGrid>
)}
</form>
</Form>
</SettingsSectionBody>
+2 -2
View File
@@ -22,7 +22,7 @@
--accent-foreground: oklch(0.21 0.006 285.885);
--destructive: oklch(0.577 0.245 27.325);
--destructive-foreground: oklch(0.985 0 0);
--border: oklch(0.88 0.004 286.32);
--border: oklch(0.91 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 / 18%);
--border: oklch(1 0 0 / 8%);
--input: oklch(1 0 0 / 18%);
--ring: oklch(0.646 0.222 41.116);
--chart-1: oklch(0.488 0.243 264.376);
+32 -33
View File
@@ -36,7 +36,6 @@ 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,
@@ -465,16 +464,27 @@ export default function RdpClient({
className="fixed inset-0 z-50 flex flex-col bg-neutral-900"
style={{ display: showLogin ? "none" : "flex" }}
>
<CollapsibleSessionToolbar>
<div className="flex flex-wrap items-center gap-2 bg-black p-2 text-white">
<Button
size="sm"
variant="destructive"
onClick={() => {
ui()?.shutdown();
setShowLogin(true);
}}
variant="secondary"
onClick={() => ui()?.setScale(1)}
>
{t("sshTerminate")}
{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")}
</Button>
<Button
size="sm"
@@ -490,6 +500,13 @@ export default function RdpClient({
>
{t("rdpMeta")}
</Button>
{/* <Button
size="sm"
variant="secondary"
onClick={toggleCursorKind}
>
Toggle cursor
</Button> */}
<Button
size="sm"
variant="secondary"
@@ -520,34 +537,16 @@ export default function RdpClient({
>
{t("rdpUploadFiles")}
</Button>
{/* <Button
size="sm"
variant="secondary"
onClick={() => ui()?.setScale(1)}
>
{t("rdpFit")}
</Button>
<Button
size="sm"
variant="secondary"
onClick={() => ui()?.setScale(2)}
variant="destructive"
onClick={() => {
ui()?.shutdown();
setShowLogin(true);
}}
>
{t("rdpFull")}
{t("sshTerminate")}
</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"
@@ -559,7 +558,7 @@ export default function RdpClient({
/>
{t("rdpUnicodeKeyboardMode")}
</label>
</CollapsibleSessionToolbar>
</div>
{moduleReady && (
<iron-remote-desktop
-4
View File
@@ -334,10 +334,6 @@ 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`
);
+1 -2
View File
@@ -152,8 +152,7 @@ export default async function SshPage() {
await waitForRoundTripCompletion(messageIds, cookieHeader);
} catch (err) {
console.error("Error signing SSH key:", err);
const detail = err instanceof Error ? err.message : String(err);
error = `${t("sshErrorSignKeyFailed")}: ${detail}`;
error = t("sshErrorSignKeyFailed");
}
}
+12 -44
View File
@@ -27,7 +27,6 @@ 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,
@@ -81,7 +80,6 @@ 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);
@@ -91,7 +89,6 @@ export default function VncClient({
rfbRef.current.disconnect();
rfbRef.current = null;
}
setConnecting(false);
setConnected(false);
};
@@ -100,19 +97,12 @@ export default function VncClient({
}, []);
const connect = async (values: VncCredentialsForm) => {
setConnecting(true);
if (!target) {
setConnectError(t("vncNoResourceTarget"));
setConnecting(false);
return;
}
if (!screenRef.current) {
setConnectError(t("sshErrorConnectionClosed"));
setConnecting(false);
return;
}
if (!screenRef.current) return;
disconnect();
@@ -126,8 +116,6 @@ 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"),
@@ -152,16 +140,7 @@ export default function VncClient({
options.credentials = { password: values.password };
}
let rfb: any;
try {
rfb = new RFB(screenRef.current, wsUrl, options);
} catch {
setConnecting(false);
setConnectError(t("sshErrorWebSocket"));
return;
}
let authConfirmed = false;
const rfb: any = new RFB(screenRef.current, wsUrl, options);
rfb.scaleViewport = true;
rfb.resizeSession = true;
@@ -172,8 +151,6 @@ export default function VncClient({
values,
target.authToken
);
authConfirmed = true;
setConnecting(false);
setConnected(true);
});
@@ -181,11 +158,7 @@ export default function VncClient({
"disconnect",
(e: { detail: { clean: boolean } }) => {
rfbRef.current = null;
setConnecting(false);
setConnected(false);
if (!authConfirmed && !e.detail.clean) {
setConnectError(t("sshErrorConnectionClosed"));
}
}
);
@@ -270,12 +243,7 @@ export default function VncClient({
</FormItem>
)}
/>
<Button
type="submit"
className="w-full"
loading={connecting}
disabled={connecting}
>
<Button type="submit" className="w-full">
{t("browserGatewayConnect")}
</Button>
{connectError && (
@@ -297,14 +265,7 @@ export default function VncClient({
className="fixed inset-0 z-50 flex flex-col bg-neutral-900"
style={{ display: connected ? "flex" : "none" }}
>
<CollapsibleSessionToolbar>
<Button
size="sm"
variant="destructive"
onClick={disconnect}
>
{t("sshTerminate")}
</Button>
<div className="flex flex-wrap items-center gap-2 bg-black p-2 text-white">
<Button
size="sm"
variant="secondary"
@@ -330,7 +291,14 @@ export default function VncClient({
>
{t("vncPasteClipboard")}
</Button>
</CollapsibleSessionToolbar>
<Button
size="sm"
variant="destructive"
onClick={disconnect}
>
{t("sshTerminate")}
</Button>
</div>
<div
ref={screenRef}
@@ -1,58 +0,0 @@
"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>
);
}
+3 -5
View File
@@ -17,7 +17,6 @@ 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;
@@ -28,6 +27,7 @@ interface ColumnFilterButtonProps {
options: FilterOption[];
selectedValue?: string;
onValueChange: (value: string | undefined) => void;
placeholder?: string;
searchPlaceholder?: string;
emptyMessage?: string;
className?: string;
@@ -38,6 +38,7 @@ export function ColumnFilterButton({
options,
selectedValue,
onValueChange,
placeholder,
searchPlaceholder = "Search...",
emptyMessage = "No options found",
className,
@@ -49,8 +50,6 @@ export function ColumnFilterButton({
(option) => option.value === selectedValue
);
const t = useTranslations();
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
@@ -95,7 +94,7 @@ export function ColumnFilterButton({
}}
className="text-muted-foreground"
>
{t("accessFilterClear")}
Clear filter
</CommandItem>
)}
{options.map((option) => (
@@ -110,7 +109,6 @@ export function ColumnFilterButton({
);
setOpen(false);
}}
className="break-all"
>
<CheckIcon
className={cn(
+1 -2
View File
@@ -120,7 +120,7 @@ export function ColumnMultiFilterButton({
}}
className="text-muted-foreground"
>
{t("accessFilterClear")}
{t("accessUsersRoleFilterClear")}
</CommandItem>
)}
{options.map((option) => (
@@ -130,7 +130,6 @@ export function ColumnMultiFilterButton({
onSelect={() => {
toggle(option.value);
}}
className="break-all"
>
<Checkbox
className="pointer-events-none shrink-0"
+6 -14
View File
@@ -52,20 +52,12 @@ export function CreateOrgLabelDialog({
description: t("labelCreateSuccessMessage")
});
}
} 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"
});
}
} catch (e) {
toast({
title: t("error"),
description: formatAxiosError(e, t("errorOccurred")),
variant: "destructive"
});
}
}
+9 -8
View File
@@ -20,12 +20,7 @@ import type { CreateRoleBody, CreateRoleResponse } from "@server/routers/role";
import { AxiosResponse } from "axios";
import { useTranslations } from "next-intl";
import { useTransition } from "react";
import {
parseSudoCommands,
parseUnixGroups,
RoleForm,
type RoleFormValues
} from "./RoleForm";
import { RoleForm, type RoleFormValues } from "./RoleForm";
import { tierMatrix } from "@server/lib/billing/tierMatrix";
type CreateRoleFormProps = {
@@ -58,10 +53,16 @@ export default function CreateRoleForm({
payload.sshSudoCommands =
values.sshSudoMode === "commands" &&
values.sshSudoCommands?.trim()
? parseSudoCommands(values.sshSudoCommands)
? values.sshSudoCommands
.split(",")
.map((s) => s.trim())
.filter(Boolean)
: [];
if (values.sshUnixGroups?.trim()) {
payload.sshUnixGroups = parseUnixGroups(values.sshUnixGroups);
payload.sshUnixGroups = values.sshUnixGroups
.split(",")
.map((s) => s.trim())
.filter(Boolean);
}
}
const res = await api
+5 -11
View File
@@ -4,7 +4,6 @@ import { Button } from "@app/components/ui/button";
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
@@ -270,11 +269,11 @@ export default function CreateShareLinkForm({
</PopoverTrigger>
<PopoverContent className="p-0">
<ResourceSelector
excludeWildcard
orgId={
org.org
.orgId
}
excludeWildcard
orgId={
org.org
.orgId
}
selectedResource={
selectedResource
}
@@ -334,11 +333,6 @@ export default function CreateShareLinkForm({
<FormControl>
<Input {...field} />
</FormControl>
<FormDescription>
{t(
"sharePathDescription"
)}
</FormDescription>
<FormMessage />
</FormItem>
)}
+6 -14
View File
@@ -58,20 +58,12 @@ export function EditOrgLabelDialog({
description: t("labelEditSuccessMessage")
});
}
} 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"
});
}
} catch (e) {
toast({
title: t("error"),
description: formatAxiosError(e, t("errorOccurred")),
variant: "destructive"
});
}
}
+9 -8
View File
@@ -20,12 +20,7 @@ import type { UpdateRoleBody, UpdateRoleResponse } from "@server/routers/role";
import { AxiosResponse } from "axios";
import { useTranslations } from "next-intl";
import { useTransition } from "react";
import {
parseSudoCommands,
parseUnixGroups,
RoleForm,
type RoleFormValues
} from "./RoleForm";
import { RoleForm, type RoleFormValues } from "./RoleForm";
import { tierMatrix } from "@server/lib/billing/tierMatrix";
type EditRoleFormProps = {
@@ -61,10 +56,16 @@ export default function EditRoleForm({
payload.sshSudoCommands =
values.sshSudoMode === "commands" &&
values.sshSudoCommands?.trim()
? parseSudoCommands(values.sshSudoCommands)
? values.sshSudoCommands
.split(",")
.map((s) => s.trim())
.filter(Boolean)
: [];
if (values.sshUnixGroups !== undefined) {
payload.sshUnixGroups = parseUnixGroups(values.sshUnixGroups);
payload.sshUnixGroups = values.sshUnixGroups
.split(",")
.map((s) => s.trim())
.filter(Boolean);
}
}
const res = await api
+1 -1
View File
@@ -168,7 +168,7 @@ export function LabelColumnFilterButton({
}}
className="text-muted-foreground"
>
{t("accessFilterClear")}
{t("accessLabelFilterClear")}
</CommandItem>
)}
{labels.map((label) => (
+1 -1
View File
@@ -32,7 +32,7 @@ import {
RefreshCw
} from "lucide-react";
import { useTranslations } from "next-intl";
import { useEffect, useMemo, useState } from "react";
import { useState, useEffect, useMemo } from "react";
import {
Tooltip,
TooltipContent,
+4 -30
View File
@@ -570,7 +570,7 @@ export function PrivateResourceForm({
mode: "host",
destination: "",
alias: null,
destinationPort: null,
destinationPort: 22,
scheme: "http",
ssl: true,
httpConfigSubdomain: null,
@@ -1038,35 +1038,9 @@ export function PrivateResourceForm({
modeOptions
}
value={field.value}
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
);
}
}}
onChange={
field.onChange
}
cols={2}
/>
<FormMessage />
+117 -43
View File
@@ -2,17 +2,9 @@
import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog";
import CopyToClipboard from "@app/components/CopyToClipboard";
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 { ExtendedColumnDef } from "@app/components/ui/data-table";
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,
@@ -26,35 +18,53 @@ 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 { startTransition, useMemo, useState, useTransition } from "react";
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 { useDebouncedCallback } from "use-debounce";
import { ColumnFilterButton } from "./ColumnFilterButton";
import { LabelColumnFilterButton } from "./LabelColumnFilterButton";
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 { ControlledDataTable } from "./ui/controlled-data-table";
import { SitesColumnFilterButton } from "./SitesColumnFilterButton";
import { LabelColumnFilterButton } from "./LabelColumnFilterButton";
import { useLocalLabels } from "@app/hooks/useLocalLabels";
import { useOptimisticLabels } from "@app/hooks/useOptimisticLabels";
export type InternalResourceSiteRow = ResourceSiteRow;
@@ -147,6 +157,7 @@ export default function PrivateResourcesTable({
const [editingResource, setEditingResource] =
useState<InternalResourceRow | null>();
const [isCreateDialogOpen, setIsCreateDialogOpen] = useState(false);
const [siteFilterOpen, setSiteFilterOpen] = useState(false);
const [isRefreshing, startRefreshTransition] = useTransition();
@@ -160,6 +171,27 @@ 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 {
@@ -253,27 +285,58 @@ export default function PrivateResourcesTable({
accessorFn: (row) =>
row.sites.map((s) => s.siteName).join(", "),
friendlyName: t("sites"),
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}
/>
);
},
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>
),
cell: ({ row }) => {
const resourceRow = row.original;
return (
@@ -523,6 +586,16 @@ 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);
@@ -618,6 +691,7 @@ export default function PrivateResourcesTable({
open={isCreateDialogOpen}
setOpen={setIsCreateDialogOpen}
orgId={orgId}
initialSites={createInitialSites}
onSuccess={() => {
// Delay refresh to allow modal to close smoothly
setTimeout(() => {
+101 -22
View File
@@ -76,7 +76,6 @@ 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;
@@ -155,6 +154,30 @@ 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(() => {
@@ -204,6 +227,28 @@ 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>[] = [
{
@@ -246,27 +291,61 @@ export default function PublicResourcesTable({
accessorFn: (row) =>
row.sites.map((s) => s.siteName).join(", "),
friendlyName: t("sites"),
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}
/>
);
},
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>
),
cell: ({ row }) => (
<ResourceSitesStatusCell
orgId={row.original.orgId}
-21
View File
@@ -1,21 +0,0 @@
"use client";
import { Shield } from "lucide-react";
import { useTranslations } from "next-intl";
import DismissableBanner from "./DismissableBanner";
export const ResourcePoliciesBanner = () => {
const t = useTranslations();
return (
<DismissableBanner
storageKey="resource-policies-banner-dismissed"
version={1}
title={t("resourcePoliciesBannerTitle")}
titleIcon={<Shield className="w-5 h-5 text-primary" />}
description={t("resourcePoliciesBannerDescription")}
/>
);
};
export default ResourcePoliciesBanner;
-1
View File
@@ -283,7 +283,6 @@ export function ResourcePoliciesTable({
searchPlaceholder={t("resourcePoliciesSearch")}
pagination={pagination}
rowCount={rowCount}
searchQuery={searchParams.get("query")?.toString()}
onSearch={handleSearchChange}
onPaginationChange={handlePaginationChange}
onAdd={() =>
+15 -194
View File
@@ -10,25 +10,14 @@ 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, useState } from "react";
import { useEffect } from "react";
import { useForm } from "react-hook-form";
import { z } from "zod";
import { zodResolver } from "@hookform/resolvers/zod";
@@ -57,34 +46,15 @@ function toSshSudoMode(value: string | null | undefined): SshSudoMode {
return "none";
}
export function parseUnixGroups(value: string | undefined): string[] {
if (!value?.trim()) return [];
return value
.split(/[,\s\n]+/)
.map((group) => group.trim())
.filter(Boolean);
}
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) => {
if (!value?.trim()) return true;
const commands = value
.split(",")
.map((command) => command.trim())
.filter(Boolean);
return commands.every((command) => {
const executable = command.split(/\s+/)[0];
return executable.startsWith("/");
});
@@ -108,15 +78,6 @@ type RoleFormProps = {
formId?: string;
};
type RoleTextImportField = "sshSudoCommands" | "sshUnixGroups";
type PendingTextImport = {
field: RoleTextImportField;
fileName: string;
fileType: TextImportFileType;
rawContent: string;
};
export function RoleForm({
variant,
role,
@@ -164,10 +125,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("\n")
sshUnixGroups: parseRoleJsonArray(role.sshUnixGroups).join(", ")
}
: {
name: "",
@@ -195,10 +156,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("\n")
sshUnixGroups: parseRoleJsonArray(role.sshUnixGroups).join(", ")
});
}
}, [variant, role, form]);
@@ -206,10 +167,6 @@ 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) {
@@ -217,78 +174,6 @@ 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
@@ -536,25 +421,9 @@ export function RoleForm({
{t("sshSudoCommands")}
</FormLabel>
<FormControl>
<Textarea
<Input
{...field}
{...getTextImportDropHandlers(
"sshSudoCommands"
)}
placeholder={
sshDisabled
? undefined
: t(
"roleTextFieldPlaceholder"
)
}
disabled={sshDisabled}
className={cn(
"h-20 min-h-20",
dragOverField ===
"sshSudoCommands" &&
"border-primary"
)}
/>
</FormControl>
<FormDescription>
@@ -577,25 +446,9 @@ export function RoleForm({
{t("sshUnixGroups")}
</FormLabel>
<FormControl>
<Textarea
<Input
{...field}
{...getTextImportDropHandlers(
"sshUnixGroups"
)}
placeholder={
sshDisabled
? undefined
: t(
"roleTextFieldPlaceholder"
)
}
disabled={sshDisabled}
className={cn(
"h-20 min-h-20",
dragOverField ===
"sshUnixGroups" &&
"border-primary"
)}
/>
</FormControl>
<FormDescription>
@@ -646,38 +499,6 @@ export function RoleForm({
</HorizontalTabs>
)}
</form>
{pendingImport && (
<TextFileImportDialog
key={`${pendingImport.field}-${pendingImport.fileName}`}
open={true}
onOpenChange={(open) => {
if (!open) {
setPendingImport(null);
}
}}
fileName={pendingImport.fileName}
fileType={pendingImport.fileType}
rawContent={pendingImport.rawContent}
currentValue={form.watch(pendingImport.field) ?? ""}
fieldLabel={
pendingImport.field === "sshSudoCommands"
? t("sshSudoCommands")
: t("sshUnixGroups")
}
parser={
pendingImport.field === "sshSudoCommands"
? parseSudoCommands
: parseUnixGroups
}
onConfirm={(value) => {
form.setValue(pendingImport.field, value, {
shouldDirty: true,
shouldValidate: true
});
setPendingImport(null);
}}
/>
)}
</Form>
);
}
-43
View File
@@ -43,49 +43,6 @@ export function SettingsSectionForm({
);
}
export function SettingsFormGrid({
children,
className
}: {
children: React.ReactNode;
className?: string;
}) {
return (
<div
className={cn(
"grid grid-cols-1 md:grid-cols-4 gap-4 items-start",
className
)}
>
{children}
</div>
);
}
export function SettingsFormCell({
children,
span = "half",
className
}: {
children: React.ReactNode;
span?: "quarter" | "half" | "full";
className?: string;
}) {
return (
<div
className={cn(
"min-w-0",
span === "quarter" && "md:col-span-1",
span === "half" && "md:col-span-2",
span === "full" && "md:col-span-4",
className
)}
>
{children}
</div>
);
}
export function SettingsSectionTitle({
children
}: {
-160
View File
@@ -1,160 +0,0 @@
import { useMemo, useState } from "react";
import { Popover, PopoverContent, PopoverTrigger } from "./ui/popover";
import { cn } from "@app/lib/cn";
import { dataTableFilterPopoverContentClassName } from "@app/lib/dataTableFilterPopover";
import { CheckIcon, Funnel } from "lucide-react";
import { SiteOnlineStatus, type Selectedsite } from "./site-selector";
import { Button } from "./ui/button";
import { useTranslations } from "next-intl";
import { Badge } from "./ui/badge";
import { orgQueries } from "@app/lib/queries";
import { useQuery } from "@tanstack/react-query";
import { useDebounce } from "use-debounce";
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList
} from "./ui/command";
export type SitesColumnFilterButtonProps = {
selectedSiteId: number | null;
onValueChange: (value: number | undefined) => void;
orgId: string;
};
export function SitesColumnFilterButton({
selectedSiteId,
onValueChange,
orgId
}: SitesColumnFilterButtonProps) {
const [open, setOpen] = useState(false);
const t = useTranslations();
const [siteSearchQuery, setSiteSearchQuery] = useState("");
const [debouncedQuery] = useDebounce(siteSearchQuery, 150);
const { data: sites = [] } = useQuery(
orgQueries.sites({
orgId,
query: debouncedQuery,
perPage: 500
})
);
const selectedSite = useMemo(() => {
let selected = undefined;
if (selectedSiteId) {
selected = sites.find((site) => site.siteId === selectedSiteId) ?? {
siteId: Number(selectedSiteId),
name: t("standaloneHcFilterSiteIdFallback", {
id: Number(selectedSiteId)
}),
type: "newt"
};
}
return selected;
}, [selectedSiteId, sites]);
// always include the selected site in the list of sites shown
const sitesShown = useMemo(() => {
const allSites: Array<Selectedsite> = [...sites];
if (
debouncedQuery.trim().length === 0 &&
selectedSite &&
!allSites.find((site) => site.siteId === selectedSite?.siteId)
) {
allSites.unshift(selectedSite);
}
return allSites;
}, [debouncedQuery, sites, selectedSite]);
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
type="button"
variant="ghost"
role="combobox"
className={cn(
"justify-between text-sm h-8 px-2 w-full p-3",
selectedSite && "text-muted-foreground"
)}
>
<div className="flex items-center gap-2 min-w-0">
{t("sites")}
<Funnel className="size-4 flex-none" />
{selectedSite && (
<Badge
className="truncate max-w-40"
variant="secondary"
>
{selectedSite.name}
</Badge>
)}
</div>
</Button>
</PopoverTrigger>
<PopoverContent
className={dataTableFilterPopoverContentClassName}
align="start"
>
<Command shouldFilter={false}>
<CommandInput
placeholder={t("siteSearch")}
value={siteSearchQuery}
onValueChange={(v) => setSiteSearchQuery(v)}
/>
<CommandList>
<CommandEmpty>{t("siteNotFound")}</CommandEmpty>
<CommandGroup>
{selectedSite && (
<CommandItem
onSelect={() => {
onValueChange(undefined);
}}
className="text-muted-foreground"
>
{t("accessFilterClear")}
</CommandItem>
)}
{sitesShown.map((site) => (
<CommandItem
key={site.siteId}
value={`${site.siteId}:${site.name}`}
onSelect={() => {
onValueChange(site.siteId);
}}
>
<CheckIcon
className={cn(
"mr-2 h-4 w-4",
site.siteId === selectedSite?.siteId
? "opacity-100"
: "opacity-0"
)}
/>
<div className="min-w-0 flex-1 flex items-center gap-2">
<span className="min-w-0 flex-1 truncate">
{site.name}
</span>
{site.online != null && (
<SiteOnlineStatus
type={site.type}
online={site.online}
/>
)}
</div>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
);
}
-189
View File
@@ -1,189 +0,0 @@
"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>
);
}
+6 -14
View File
@@ -112,20 +112,12 @@ export function LabelsSelector({
},
"attach"
);
} 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"
});
}
} catch (e) {
toast({
title: t("error"),
description: formatAxiosError(e, t("errorOccurred")),
variant: "destructive"
});
}
setlabelsSearchQuery("");
}
+1
View File
@@ -115,6 +115,7 @@ 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}/general`
`/${org.org.orgId}/settings/policies/resources/public/${niceId}`
);
toast({
title: t("success"),
@@ -227,7 +227,7 @@ export function CreatePolicyForm({}: CreatePolicyFormProps) {
</SettingsSectionDescription>
</SettingsSectionHeader>
<SettingsSectionBody>
<SettingsSectionForm variant="half">
<SettingsSectionForm>
<FormField
control={form.control}
name="name"
@@ -237,7 +237,12 @@ export function CreatePolicyForm({}: CreatePolicyFormProps) {
{t("name")}
</FormLabel>
<FormControl>
<Input {...field} />
<Input
{...field}
placeholder={t(
"resourcePolicyNamePlaceholder"
)}
/>
</FormControl>
<FormMessage />
</FormItem>
@@ -0,0 +1,169 @@
"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,27 +10,26 @@ 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,
section
resourceId
}: EditPolicyFormProps) {
const t = useTranslations();
const { org } = useOrgContext();
const { env } = useEnvContext();
const { isPaidUser } = usePaidStatus();
@@ -38,6 +37,7 @@ 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,16 +92,22 @@ export function EditPolicyForm({
/>
);
if (section === "general") {
return <EditPolicyNameSectionForm readonly={readonly} />;
}
if (section === "authentication") {
return authSection;
}
if (section === "rules") {
return rulesSection;
if (showTabs) {
return (
<HorizontalTabs
clientSide
defaultTab={0}
items={[
{ title: t("general"), href: "#" },
{ title: t("authentication"), href: "#" },
{ title: t("policyAccessRulesTitle"), href: "#" }
]}
>
<EditPolicyNameSectionForm readonly={readonly} />
{authSection}
{rulesSection}
</HorizontalTabs>
);
}
return (
@@ -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}/general`
`/${org.org.orgId}/settings/policies/resources/public/${payload.niceId}`
);
}
@@ -15,11 +15,9 @@ 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";
@@ -30,8 +28,7 @@ import {
useMemo,
useRef,
useState,
useTransition,
type ReactNode
useTransition
} from "react";
import { UseFormReturn, useForm, useWatch } from "react-hook-form";
import { useResourcePolicyContext } from "@app/providers/ResourcePolicyProvider";
@@ -41,12 +38,11 @@ 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";
@@ -71,12 +67,6 @@ 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} />;
@@ -84,143 +74,6 @@ 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,
@@ -266,8 +119,6 @@ function PolicyAccessRulesSectionEdit({
policy.rules.map((r) => ({ ...r, fromPolicy: isResourceOverlay }))
);
const { updateRulesState } = usePolicyAccessRulesFormSync(form);
useEffect(() => {
if (!isResourceOverlay || resourceRulesInitialized) return;
if (!resourceRulesData) return;
@@ -278,7 +129,7 @@ function PolicyAccessRulesSectionEdit({
.map((r) => ({
ruleId: r.ruleId,
action: r.action as "ACCEPT" | "DROP" | "PASS",
match: isPolicyRuleMatchType(r.match) ? r.match : "PATH",
match: r.match,
value: r.value,
priority: r.priority,
enabled: r.enabled,
@@ -297,13 +148,30 @@ function PolicyAccessRulesSectionEdit({
policy.rules
]);
const handleRulesChange = useCallback(
const syncFormRules = useCallback(
(updatedRules: PolicyAccessRule[]) => {
updateRulesState(setRules, updatedRules);
form.setValue(
"rules",
updatedRules.map(
({ action, match, value, priority, enabled }) => ({
action,
match,
value,
priority,
enabled
})
)
);
},
[updateRulesState]
[form]
);
const addEmptyRule = useCallback(() => {
const updatedRules = [...rules, createEmptyRule(rules)];
setRules(updatedRules);
syncFormRules(updatedRules);
}, [rules, syncFormRules]);
const removeRule = useCallback(
function removeRule(ruleId: number) {
const rule = rules.find((r) => r.ruleId === ruleId);
@@ -311,22 +179,32 @@ function PolicyAccessRulesSectionEdit({
if (isResourceOverlay && !rule.new) {
deletedResourceRuleIdsRef.current.add(ruleId);
}
handleRulesChange(rules.filter((rule) => rule.ruleId !== ruleId));
const updatedRules = rules.filter((rule) => rule.ruleId !== ruleId);
setRules(updatedRules);
syncFormRules(updatedRules);
},
[rules, handleRulesChange, isResourceOverlay]
[rules, syncFormRules, isResourceOverlay]
);
const updateRule = useCallback(
function updateRule(ruleId: number, data: Partial<PolicyAccessRule>) {
handleRulesChange(
rules.map((rule) =>
rule.ruleId === ruleId
? { ...rule, ...data, updated: true }
: rule
)
const updatedRules = rules.map((rule) =>
rule.ruleId === ruleId
? { ...rule, ...data, updated: true }
: rule
);
setRules(updatedRules);
syncFormRules(updatedRules);
},
[rules, handleRulesChange]
[rules, syncFormRules]
);
const handleRulesChange = useCallback(
(updatedRules: PolicyAccessRule[]) => {
setRules(updatedRules);
syncFormRules(updatedRules);
},
[syncFormRules]
);
const [isPending, startTransition] = useTransition();
@@ -335,10 +213,7 @@ function PolicyAccessRulesSectionEdit({
if (readonly) return;
const applyRules = form.getValues("applyRules") ?? false;
const rulesToValidate = isResourceOverlay
? rules.filter((rule) => !rule.fromPolicy)
: rules;
const rulesPayload = rulesToValidate.map(
const rulesPayload = rules.map(
({ action, match, value, priority, enabled }) => ({
action,
match,
@@ -456,112 +331,80 @@ 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 (
<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>
}
/>
<SettingsSection>
<SettingsSectionHeader>
<SettingsSectionTitle>
{t("policyAccessRulesTitle")}
</SettingsSectionTitle>
<SettingsSectionDescription>
{t("rulesResourceDescription")}
</SettingsSectionDescription>
</SettingsSectionHeader>
<SettingsSectionBody>
<div className="space-y-6">
<PolicyAccessRulesIntro
rulesEnabled={Boolean(rulesEnabled)}
onRulesEnabledChange={(val) => {
form.setValue("applyRules", val);
}}
disableToggle={readonly || isResourceOverlay}
/>
{rulesEnabled && (
<>
<PolicyAccessRulesTable
rules={rules}
onRulesChange={handleRulesChange}
updateRule={updateRule}
removeRule={removeRule}
readonly={readonly}
isMaxmindAvailable={isMaxmindAvailable}
isMaxmindAsnAvailable={isMaxmindAsnAvailable}
includeRegionMatch
markUpdatedOnReorder
emptyStateAction={addRuleButton}
/>
{hasRules && addRuleButton}
</>
)}
</div>
</SettingsSectionBody>
<SettingsSectionFooter>
<Button
onClick={() => startTransition(() => saveRules())}
loading={isPending}
disabled={readonly || isPending}
>
{t("saveSettings")}
</Button>
</SettingsSectionFooter>
</SettingsSection>
);
}
function PolicyAccessRulesSectionCreate({
form: parentForm,
form,
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 (
<PolicyAccessRulesSectionLayout
rulesEnabled={Boolean(rulesEnabled)}
onRulesEnabledChange={(val) => {
form.setValue("applyRules", val);
}}
rules={rules}
onRulesChange={handleRulesChange}
updateRule={updateRule}
removeRule={removeRule}
<CreatePolicyRulesSectionForm
form={form}
isMaxmindAvailable={isMaxmindAvailable}
isMaxmindAsnAvailable={isMaxmindAsnAvailable}
/>
@@ -66,12 +66,8 @@ import {
validatePolicyRuleValue
} from "./policy-access-rule-validation";
import {
buildDisplayPrioritiesForResourceOverlay,
reorderPolicyRules,
reorderResourceOverlayRules,
setResourceRuleDisplayPriority,
sortPolicyRulesByPriority,
sortPolicyRulesForResourceOverlay,
type PolicyAccessRule
} from "./policy-access-rule-utils";
@@ -86,7 +82,6 @@ export type PolicyAccessRulesTableProps = {
readonly?: boolean;
includeRegionMatch?: boolean;
markUpdatedOnReorder?: boolean;
resourceOverlayMode?: boolean;
isRuleDraggable?: (rule: PolicyAccessRule) => boolean;
isRuleLocked?: (rule: PolicyAccessRule) => boolean;
};
@@ -102,7 +97,7 @@ function getColumnClassName(columnId: string) {
return "w-24 max-w-24";
}
if (columnId === "action") {
return "w-42 max-w-42";
return "w-40 max-w-40";
}
if (columnId === "match") {
return "w-36 max-w-36";
@@ -121,7 +116,6 @@ export function PolicyAccessRulesTable({
readonly = false,
includeRegionMatch = false,
markUpdatedOnReorder = false,
resourceOverlayMode = false,
isRuleDraggable: isRuleDraggableProp,
isRuleLocked: isRuleLockedProp
}: PolicyAccessRulesTableProps) {
@@ -146,37 +140,12 @@ export function PolicyAccessRulesTable({
);
const sortedRules = useMemo(
() =>
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,
() => sortPolicyRulesByPriority(rules),
[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
);
@@ -195,13 +164,7 @@ export function PolicyAccessRulesTable({
);
onRulesChange(reordered);
},
[
rules,
sortedRules,
onRulesChange,
markUpdatedOnReorder,
resourceOverlayMode
]
[sortedRules, onRulesChange, markUpdatedOnReorder]
);
const handleDragStart = useCallback((ruleId: number, e: DragEvent) => {
@@ -265,132 +228,60 @@ export function PolicyAccessRulesTable({
maxSize: 96,
header: ({ column }) => (
<div className="p-3">
{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>
)}
<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 }) => {
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
cell: ({ row }) => (
<Input
defaultValue={row.original.priority}
className="w-full min-w-0"
type="number"
disabled={readonly || isRuleLocked(row.original)}
onClick={(e) => e.currentTarget.focus()}
onBlur={(e) => {
const validated = validatePolicyRulePriority(
t,
e.target.value
);
if (!validated.success) {
toast({
variant: "destructive",
...validated.toast
});
}}
/>
);
}
return;
}
const duplicatePriority = rules.some(
(rule) =>
rule.ruleId !== row.original.ruleId &&
rule.priority === validated.data
);
if (duplicatePriority) {
toast({
variant: "destructive",
title: t("rulesErrorDuplicatePriority"),
description: t(
"rulesErrorDuplicatePriorityDescription"
)
});
return;
}
updateRule(row.original.ruleId, {
priority: validated.data
});
}}
/>
)
},
{
accessorKey: "action",
@@ -792,7 +683,13 @@ export function PolicyAccessRulesTable({
cell: ({ row }) => (
<div className="flex items-center justify-end space-x-2">
{isRuleLocked(row.original) ? (
<LockIcon className="h-4 w-4 text-muted-foreground" />
<Button
variant="outline"
disabled
className="cursor-not-allowed"
>
<LockIcon className="h-4 w-4" />
</Button>
) : (
<Button
variant="outline"
@@ -814,14 +711,9 @@ export function PolicyAccessRulesTable({
isMaxmindAsnAvailable,
includeRegionMatch,
updateRule,
onRulesChange,
removeRule,
readonly,
rules,
resourceOverlayMode,
displayPriorities,
resourceRuleCount,
markUpdatedOnReorder,
isRuleDraggable,
isRuleLocked,
handleDragStart,
@@ -883,8 +775,7 @@ export function PolicyAccessRulesTable({
e.preventDefault();
if (
draggedRuleId !== null &&
draggedRuleId !== rule.ruleId &&
isRuleDraggable(rule)
draggedRuleId !== rule.ruleId
) {
handleReorder(
draggedRuleId,
@@ -898,7 +789,7 @@ export function PolicyAccessRulesTable({
draggedRuleId === rule.ruleId &&
"opacity-50",
dragOverRuleId === rule.ruleId &&
"border-t border-primary"
"border-t-2 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("policyAuthSetPasscode")}
submitLabel={t("resourcePasswordSubmit")}
>
<Form {...form}>
<form
@@ -191,7 +191,7 @@ export function PincodeCredenza({
title={t("resourcePincodeSetupTitle")}
description={t("resourcePincodeSetupTitleDescription")}
formId="policy-pincode-form"
submitLabel={t("policyAuthSetPincode")}
submitLabel={t("resourcePincodeSubmit")}
>
<Form {...form}>
<form
@@ -286,7 +286,7 @@ export function HeaderAuthCredenza({
title={t("resourceHeaderAuthSetupTitle")}
description={t("resourceHeaderAuthSetupTitleDescription")}
formId="policy-header-auth-form"
submitLabel={t("policyAuthSetHeaderAuth")}
submitLabel={t("resourceHeaderAuthSubmit")}
>
<Form {...form}>
<form
@@ -368,7 +368,7 @@ type EmailCredenzaProps = {
emailEnabled: boolean;
disabled?: boolean;
emails: Tag[];
onSave: (emails: Tag[]) => void;
onEmailsChange: (emails: Tag[]) => void;
};
export function EmailCredenza({
@@ -377,19 +377,12 @@ export function EmailCredenza({
emailEnabled,
disabled,
emails,
onSave
onEmailsChange
}: 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}>
@@ -401,90 +394,72 @@ export function EmailCredenza({
</CredenzaDescription>
</CredenzaHeader>
<CredenzaBody>
<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
<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[]
);
}
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>
}}
validateTag={(tag) =>
z
.email()
.or(
z
.string()
.regex(
/^\*@[\w.-]+\.[a-zA-Z]{2,}$/
)
)
.safeParse(tag).success
}
allowDuplicates={false}
sortTags
size="sm"
disabled={disabled}
/>
</FormControl>
<FormDescription>
{t("otpEmailEnterDescription")}
</FormDescription>
</FormItem>
)}
</div>
</CredenzaBody>
<CredenzaFooter>
<CredenzaClose asChild>
<Button variant="outline">{t("close")}</Button>
</CredenzaClose>
{emailEnabled && (
<Button
type="submit"
form="policy-email-form"
disabled={disabled}
>
{t("policyAuthSetEmailWhitelist")}
</Button>
)}
</CredenzaFooter>
</CredenzaContent>
</Credenza>
@@ -66,6 +66,23 @@ 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,8 +1,6 @@
"use client";
import {
SettingsFormCell,
SettingsFormGrid,
SettingsSection,
SettingsSectionBody,
SettingsSectionDescription,
@@ -113,67 +111,65 @@ export function PolicyAuthStackSectionCreate({
</SettingsSectionDescription>
</SettingsSectionHeader>
<SettingsSectionBody>
<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>
<div className="w-full md:w-1/2">
<PolicyAuthSsoSection
sso={Boolean(sso)}
onSsoChange={(active) =>
parentForm.setValue("sso", active)
}
skipToIdpId={skipToIdpId}
onSkipToIdpChange={(id) =>
parentForm.setValue("skipToIdpId", id)
}
allIdps={allIdps}
rolesEditor={
<FormField<PolicyFormValues, "roles">
control={parentForm.control}
name="roles"
render={({ field }) => (
<TagInput
{...field}
activeTagIndex={activeRolesTagIndex}
setActiveTagIndex={
setActiveRolesTagIndex
}
placeholder={t("accessRoleSelect2")}
tags={field.value ?? []}
setTags={(newRoles) =>
field.onChange(newRoles)
}
autocompleteOptions={allRoles}
allowDuplicates={false}
size="sm"
/>
)}
/>
}
usersEditor={
<FormField<PolicyFormValues, "users">
control={parentForm.control}
name="users"
render={({ field }) => (
<TagInput
{...field}
activeTagIndex={activeUsersTagIndex}
setActiveTagIndex={
setActiveUsersTagIndex
}
placeholder={t("accessUserSelect")}
tags={field.value ?? []}
setTags={(newUsers) =>
field.onChange(newUsers)
}
autocompleteOptions={allUsers}
allowDuplicates={false}
size="sm"
/>
)}
/>
}
/>
</div>
<SettingsSubsectionHeader>
<SettingsSubsectionTitle>
@@ -283,7 +279,7 @@ export function PolicyAuthStackSectionCreate({
onOpenChange={(open) => !open && closeCredenza()}
emailEnabled={emailEnabled}
emails={emails}
onSave={(value) =>
onEmailsChange={(value) =>
parentForm.setValue(
"emails",
value as PolicyFormValues["emails"]
@@ -1,8 +1,6 @@
"use client";
import {
SettingsFormCell,
SettingsFormGrid,
SettingsSection,
SettingsSectionBody,
SettingsSectionDescription,
@@ -50,43 +48,20 @@ import {
getPasscodeSummary,
getPincodeSummary
} from "./policy-auth-summaries";
import { SharedPolicyResourceNotice } from "./SharedPolicyResourceNotice";
import z from "zod";
type OverlaySelectedRole = SelectedRole & { isAdmin: boolean };
// 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()
});
const authStackSchema = createPolicySchema.pick({
sso: true,
skipToIdpId: true,
roles: true,
users: true,
password: true,
pincode: true,
headerAuth: true,
emailWhitelistEnabled: true,
emails: true
});
export type PolicyAuthStackSectionEditProps = {
orgId: string;
@@ -207,14 +182,14 @@ export function PolicyAuthStackSectionEdit({
]);
const form = useForm({
resolver: zodResolver(authStackEditSchema),
resolver: zodResolver(authStackSchema),
defaultValues: {
sso: policy.sso,
skipToIdpId: policy.idpId,
roles: policyRoleItems,
users: policyUserItems,
password: null,
pincode: null,
password: policy.passwordId ? { password: "" } : null,
pincode: policy.pincodeId ? { pincode: "" } : null,
headerAuth: policy.headerAuth
? {
user: "",
@@ -272,14 +247,7 @@ export function PolicyAuthStackSectionEdit({
}
const isValid = await form.trigger();
if (!isValid) {
toast({
variant: "destructive",
title: t("policyErrorUpdate"),
description: t("policyErrorUpdateMessageDescription")
});
return;
}
if (!isValid) return;
const payload = form.getValues();
const requests: Array<Promise<AxiosResponse<{}> | void>> = [];
@@ -473,227 +441,184 @@ export function PolicyAuthStackSectionEdit({
</SettingsSectionDescription>
</SettingsSectionHeader>
<SettingsSectionBody>
<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 ? (
<div className="w-full md:w-1/2">
<PolicyAuthSsoSection
sso={Boolean(sso)}
onSsoChange={(active) =>
form.setValue("sso", active)
}
skipToIdpId={skipToIdpId}
onSkipToIdpChange={(id) =>
form.setValue("skipToIdpId", id)
}
allIdps={allIdps}
disabled={authReadonly}
idpDisabled={authReadonly}
rolesEditor={
isResourceOverlay ? (
<RolesSelector
orgId={orgId}
selectedRoles={overlayRoles}
onSelectRoles={(selected) =>
setCombinedRoles(
selected.map((role) => ({
...role,
isAdmin: Boolean(
role.isAdmin
)
}))
)
}
disabled={isLoading}
restrictAdminRole
lockedIds={policyRoleLockedIds}
/>
) : (
<FormField
control={form.control}
name="roles"
render={({ field }) => (
<RolesSelector
orgId={orgId}
selectedRoles={overlayRoles}
selectedRoles={field.value}
onSelectRoles={(selected) =>
setCombinedRoles(
selected.map(
(role) => ({
...role,
isAdmin:
Boolean(
role.isAdmin
)
})
)
form.setValue(
"roles",
selected
)
}
disabled={isLoading}
disabled={readonly}
restrictAdminRole
lockedIds={
policyRoleLockedIds
}
/>
) : (
<FormField
control={form.control}
name="roles"
render={({ field }) => (
<RolesSelector
orgId={orgId}
selectedRoles={
field.value
}
onSelectRoles={(
selected
) =>
form.setValue(
"roles",
selected
)
}
disabled={readonly}
restrictAdminRole
/>
)}
/>
)
}
usersEditor={
isResourceOverlay ? (
)}
/>
)
}
usersEditor={
isResourceOverlay ? (
<UsersSelector
orgId={orgId}
selectedUsers={overlayUsers}
onSelectUsers={setCombinedUsers}
disabled={isLoading}
lockedIds={policyUserLockedIds}
/>
) : (
<FormField
control={form.control}
name="users"
render={({ field }) => (
<UsersSelector
orgId={orgId}
selectedUsers={overlayUsers}
onSelectUsers={
setCombinedUsers
}
disabled={isLoading}
lockedIds={
policyUserLockedIds
selectedUsers={field.value}
onSelectUsers={(selected) =>
form.setValue(
"users",
selected
)
}
disabled={readonly}
/>
) : (
<FormField
control={form.control}
name="users"
render={({ field }) => (
<UsersSelector
orgId={orgId}
selectedUsers={
field.value
}
onSelectUsers={(
selected
) =>
form.setValue(
"users",
selected
)
}
disabled={readonly}
/>
)}
/>
)}
/>
)
}
/>
</div>
<SettingsSubsectionHeader>
<SettingsSubsectionTitle>
{t("policyAuthOtherMethodsTitle")}
</SettingsSubsectionTitle>
<SettingsSubsectionDescription>
{t("policyAuthOtherMethodsDescription")}
</SettingsSubsectionDescription>
</SettingsSubsectionHeader>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
<PolicyAuthMethodRow
id="pincode"
title={t("policyAuthPincodeTitle")}
description={t("policyAuthPincodeDescription")}
summary={getPincodeSummary({ t })}
active={pinActive}
onConfigure={() => openMethodEditor("pincode")}
onToggle={(active) =>
handleToggle("pincode", active, () => {
setPinActive(false);
form.setValue("pincode", null);
})
}
disabled={authReadonly}
/>
<PolicyAuthMethodRow
id="passcode"
title={t("policyAuthPasscodeTitle")}
description={t("policyAuthPasscodeDescription")}
summary={getPasscodeSummary({ t })}
active={passcodeActive}
onConfigure={() => openMethodEditor("passcode")}
onToggle={(active) =>
handleToggle("passcode", active, () => {
setPasscodeActive(false);
form.setValue("password", null);
})
}
disabled={authReadonly}
/>
<PolicyAuthMethodRow
id="email"
title={t("policyAuthEmailTitle")}
description={t("policyAuthEmailDescription")}
summary={getEmailWhitelistSummary({
t,
count: emails.length
})}
active={Boolean(emailWhitelistEnabled)}
onConfigure={() => openMethodEditor("email")}
onToggle={(active) =>
handleToggle(
"email",
active,
() =>
form.setValue(
"emailWhitelistEnabled",
false
),
() =>
form.setValue(
"emailWhitelistEnabled",
true
)
}
/>
</SettingsFormCell>
</SettingsFormGrid>
)
}
disabled={authReadonly || !emailEnabled}
/>
<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>
<PolicyAuthMethodRow
id="header-auth"
title={t("policyAuthHeaderAuthTitle")}
description={t(
"policyAuthHeaderAuthDescription"
)}
summary={getHeaderAuthSummary({
t,
headerName: headerAuth?.user ?? ""
})}
active={headerAuthActive}
onConfigure={() =>
openMethodEditor("headerAuth")
}
onToggle={(active) =>
handleToggle("headerAuth", active, () => {
setHeaderAuthActive(false);
form.setValue("headerAuth", null);
})
}
disabled={authReadonly}
/>
</div>
<PincodeCredenza
@@ -723,7 +648,9 @@ export function PolicyAuthStackSectionEdit({
emailEnabled={emailEnabled}
disabled={authReadonly}
emails={emails}
onSave={(value) => form.setValue("emails", value)}
onEmailsChange={(value) =>
form.setValue("emails", value)
}
/>
<HeaderAuthCredenza
@@ -1,47 +0,0 @@
"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>
);
}
@@ -1,44 +0,0 @@
"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>
);
}
+2 -7
View File
@@ -1,8 +1,6 @@
// ─── 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),
@@ -37,7 +35,7 @@ export const createPolicySchema = z.object({
.array(
z.object({
action: z.enum(["ACCEPT", "DROP", "PASS"]),
match: z.enum(POLICY_RULE_MATCH_TYPES),
match: z.string(),
value: z.string(),
priority: z.number().int(),
enabled: z.boolean()
@@ -51,7 +49,7 @@ export type PolicyFormValues = z.infer<typeof createPolicySchema>;
export type LocalRule = {
ruleId: number;
action: "ACCEPT" | "DROP" | "PASS";
match: PolicyRuleMatchType;
match: string;
value: string;
priority: number;
enabled: boolean;
@@ -69,7 +67,6 @@ export {
type PolicyAccessRule
} from "./policy-access-rule-utils";
export {
createPolicyRuleMatchSchema,
createPolicyRulePrioritySchema,
createPolicyRuleSchema,
createPolicyRuleValueSchema,
@@ -77,10 +74,8 @@ export {
createPolicyRulesSectionSchema,
createPolicySchemaWithI18n,
getPolicyRuleValidationMessage,
POLICY_RULE_MATCH_TYPES,
validatePolicyRulePriority,
validatePolicyRuleValue,
validatePolicyRulesForSave,
type PolicyRuleMatchType,
type RuleValidationToast
} from "./policy-access-rule-validation";
@@ -1,9 +1,7 @@
import type { PolicyRuleMatchType } from "./policy-access-rule-validation";
export type PolicyAccessRule = {
ruleId: number;
action: "ACCEPT" | "DROP" | "PASS";
match: PolicyRuleMatchType;
match: string;
value: string;
priority: number;
enabled: boolean;
@@ -36,96 +34,12 @@ 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 }
>(
@@ -156,40 +70,3 @@ 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,23 +12,6 @@ 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;
@@ -95,7 +78,7 @@ export function createPolicyRuleSchema(t: TranslateFn) {
return z
.object({
action: z.enum(["ACCEPT", "DROP", "PASS"]),
match: createPolicyRuleMatchSchema(t),
match: z.string(),
value: z.string(),
priority: z.number().int(),
enabled: z.boolean()
-218
View File
@@ -1,218 +0,0 @@
"use client";
import { orgQueries } from "@app/lib/queries";
import { cn } from "@app/lib/cn";
import type { ListResourcePoliciesResponse } from "@server/routers/resource/types";
import { useQuery } from "@tanstack/react-query";
import { CheckIcon, ChevronsUpDown } from "lucide-react";
import { useTranslations } from "next-intl";
import { useMemo, useState } from "react";
import { useDebounce } from "use-debounce";
import { Button } from "./ui/button";
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList
} from "./ui/command";
import { Popover, PopoverContent, PopoverTrigger } from "./ui/popover";
export type SelectedSharedPolicy = Pick<
ListResourcePoliciesResponse["policies"][number],
"resourcePolicyId" | "name"
>;
export type SharedPolicySelectorProps = {
orgId: string;
selectedPolicy: SelectedSharedPolicy | null;
onSelectPolicy: (policy: SelectedSharedPolicy | null) => void;
};
export function SharedPolicySelector({
orgId,
selectedPolicy,
onSelectPolicy
}: SharedPolicySelectorProps) {
const t = useTranslations();
const [policySearchQuery, setPolicySearchQuery] = useState("");
const [debouncedQuery] = useDebounce(policySearchQuery, 150);
const { data: policies = [] } = useQuery(
orgQueries.policies({
orgId,
query: debouncedQuery
})
);
const policiesShown = useMemo((): SelectedSharedPolicy[] => {
const allPolicies: SelectedSharedPolicy[] = policies.map((policy) => ({
resourcePolicyId: policy.resourcePolicyId,
name: policy.name
}));
if (
debouncedQuery.trim().length === 0 &&
selectedPolicy &&
!allPolicies.find(
(policy) =>
policy.resourcePolicyId === selectedPolicy.resourcePolicyId
)
) {
allPolicies.unshift(selectedPolicy);
}
return allPolicies;
}, [debouncedQuery, policies, selectedPolicy]);
return (
<Command shouldFilter={false}>
<CommandInput
placeholder={t("resourcePolicySearch")}
value={policySearchQuery}
onValueChange={setPolicySearchQuery}
/>
<CommandList>
<CommandEmpty>{t("resourcePolicyNotFound")}</CommandEmpty>
<CommandGroup>
<CommandItem
value={`none:${t("none")}`}
onSelect={() => onSelectPolicy(null)}
>
<CheckIcon
className={cn(
"mr-2 h-4 w-4",
selectedPolicy === null
? "opacity-100"
: "opacity-0"
)}
/>
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
<span className="truncate">{t("none")}</span>
<span className="text-muted-foreground text-xs leading-snug">
{t("sharedPolicyNoneDescription")}
</span>
</div>
</CommandItem>
{policiesShown.map((policy) => (
<CommandItem
key={policy.resourcePolicyId}
value={`${policy.resourcePolicyId}:${policy.name}`}
onSelect={() =>
onSelectPolicy({
resourcePolicyId: policy.resourcePolicyId,
name: policy.name
})
}
>
<CheckIcon
className={cn(
"mr-2 h-4 w-4",
policy.resourcePolicyId ===
selectedPolicy?.resourcePolicyId
? "opacity-100"
: "opacity-0"
)}
/>
<span className="min-w-0 flex-1 truncate">
{policy.name}
</span>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
);
}
export type SharedPolicySelectProps = {
orgId: string;
value: number | null;
onChange: (value: number | null) => void;
className?: string;
disabled?: boolean;
};
export function SharedPolicySelect({
orgId,
value,
onChange,
className,
disabled
}: SharedPolicySelectProps) {
const t = useTranslations();
const [open, setOpen] = useState(false);
const [selectedLabel, setSelectedLabel] = useState<{
resourcePolicyId: number;
name: string;
} | null>(null);
const resolvedLabel =
selectedLabel?.resourcePolicyId === value ? selectedLabel.name : null;
const { data: fetchedPolicy } = useQuery({
...orgQueries.resourcePolicy({
resourcePolicyId: value!
}),
enabled: value !== null && resolvedLabel === null
});
const selectedPolicy = useMemo((): SelectedSharedPolicy | null => {
if (value === null) {
return null;
}
return {
resourcePolicyId: value,
name: resolvedLabel ?? fetchedPolicy?.name ?? ""
};
}, [value, resolvedLabel, fetchedPolicy?.name]);
const triggerLabel =
value === null
? t("none")
: (resolvedLabel ??
fetchedPolicy?.name ??
t("resourcePolicySelect"));
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
type="button"
variant="outline"
role="combobox"
disabled={disabled}
className={cn(
"w-full justify-between font-normal",
value !== null &&
!resolvedLabel &&
!fetchedPolicy?.name &&
"text-muted-foreground",
className
)}
>
<span className="truncate">{triggerLabel}</span>
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-[var(--radix-popover-trigger-width)] p-0">
<SharedPolicySelector
orgId={orgId}
selectedPolicy={selectedPolicy}
onSelectPolicy={(policy) => {
onChange(policy?.resourcePolicyId ?? null);
setSelectedLabel(
policy
? {
resourcePolicyId: policy.resourcePolicyId,
name: policy.name
}
: null
);
setOpen(false);
}}
/>
</PopoverContent>
</Popover>
);
}
+3 -3
View File
@@ -26,12 +26,11 @@ 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 }: SiteOnlineStatusProps) {
const t = useTranslations();
export function SiteOnlineStatus({ type, online, t }: SiteOnlineStatusProps) {
if (type !== "newt" && type !== "wireguard") {
return null;
}
@@ -129,6 +128,7 @@ export function SitesSelector({
<SiteOnlineStatus
type={site.type}
online={site.online}
t={t}
/>
)}
</div>
+1 -1
View File
@@ -4,7 +4,7 @@ import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@app/lib/cn";
const alertVariants = cva(
"relative w-full rounded-lg p-4 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",
"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",
{
variants: {
variant: {
+3 -18
View File
@@ -105,17 +105,13 @@ function SelectLabel({
function SelectItem({
className,
children,
description,
...props
}: React.ComponentProps<typeof SelectPrimitive.Item> & {
description?: React.ReactNode;
}) {
}: React.ComponentProps<typeof SelectPrimitive.Item>) {
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 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",
"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",
className
)}
{...props}
@@ -125,18 +121,7 @@ function SelectItem({
<CheckIcon className="size-4" />
</SelectPrimitive.ItemIndicator>
</span>
{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.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
);
}
+4 -17
View File
@@ -45,7 +45,6 @@ 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;
@@ -582,16 +581,16 @@ export const orgQueries = {
}
}),
policies: ({ orgId, query }: { orgId: string; query?: string }) =>
policies: ({ orgId, name }: { orgId: string; name?: string }) =>
queryOptions({
queryKey: ["ORG", orgId, "RESOURCES_POLICIES", query] as const,
queryKey: ["ORG", orgId, "RESOURCES_POLICIES", name] as const,
queryFn: async ({ signal, meta }) => {
const sp = new URLSearchParams({
pageSize: "10"
});
if (query) {
sp.set("query", query);
if (name) {
sp.set("query", name);
}
const res = await meta!.api.get<
@@ -602,18 +601,6 @@ export const orgQueries = {
return res.data.data.policies;
}
}),
resourcePolicy: ({ resourcePolicyId }: { resourcePolicyId: number }) =>
queryOptions({
queryKey: ["RESOURCE_POLICY", resourcePolicyId] as const,
queryFn: async ({ signal, meta }) => {
const res = await meta!.api.get<
AxiosResponse<GetResourcePolicyResponse>
>(`/resource-policy/${resourcePolicyId}`, { signal });
return res.data.data;
}
})
};
-110
View File
@@ -1,110 +0,0 @@
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");
}