mirror of
https://github.com/fosrl/pangolin.git
synced 2026-06-10 09:33:15 +00:00
policies and policy on resource structure in a good place
This commit is contained in:
@@ -211,6 +211,8 @@
|
||||
"resourcesSearch": "Search resources...",
|
||||
"resourceAdd": "Add Resource",
|
||||
"resourceErrorDelte": "Error deleting resource",
|
||||
"resourcePoliciesBannerTitle": "Re-use Authentication and Access Rules",
|
||||
"resourcePoliciesBannerDescription": "Shared resource policies let you define authentication methods and access rules once, then attach them to multiple public resources. When you update a policy, every linked resource inherits the change automatically.",
|
||||
"resourcePoliciesTitle": "Manage Public Resource Policies",
|
||||
"resourcePoliciesAttachedResourcesColumnTitle": "Resources",
|
||||
"resourcePoliciesAttachedResources": "{count} resource(s)",
|
||||
@@ -774,6 +776,7 @@
|
||||
"rulesErrorDuplicatePriorityDescription": "Each rule must have a unique priority number.",
|
||||
"rulesErrorValidation": "Invalid rules",
|
||||
"rulesErrorValidationRuleDescription": "Rule {ruleNumber}: {message}",
|
||||
"rulesErrorInvalidMatchTypeDescription": "Select a valid match type (path, IP, CIDR, country, region, or ASN).",
|
||||
"rulesErrorValueRequired": "Enter a value for this rule.",
|
||||
"rulesErrorInvalidCountry": "Invalid country",
|
||||
"rulesErrorInvalidCountryDescription": "Select a valid country.",
|
||||
@@ -968,10 +971,16 @@
|
||||
"resourceRoleDescription": "Admins can always access this resource.",
|
||||
"resourcePolicySelectTitle": "Resource Access Policy",
|
||||
"resourcePolicySelectDescription": "Select the resource policy type for authentication",
|
||||
"resourcePolicyTypeLabel": "Policy type",
|
||||
"resourcePolicyLabel": "Resource policy",
|
||||
"resourcePolicyInline": "Inline Resource Policy",
|
||||
"resourcePolicyInlineDescription": "Access Policy scoped to only this resource",
|
||||
"resourcePolicyShared": "Shared Resource Policy",
|
||||
"resourcePolicySharedDescription": "This resource uses a shared policy. Policy-level settings (auth methods, email whitelist) are locked. You can add resource-specific rules, roles, and users below.",
|
||||
"resourcePolicySharedDescription": "This resource uses a shared policy.",
|
||||
"sharedPolicy": "Shared Policy",
|
||||
"sharedPolicyNoneDescription": "This resource has its own policy.",
|
||||
"resourceSharedPolicyAuthenticationNotice": "This resource is using a shared policy. Some authentication settings can be edited on this resource. To change the underlying policy, you must edit to <policyLink>{policyName}</policyLink>.",
|
||||
"resourceSharedPolicyRulesNotice": "This resource is using a shared policy. Some access rules can be edited on this resource. To change the underlying policy, you must edit <policyLink>{policyName}</policyLink>.",
|
||||
"resourceUsersRoles": "Access Controls",
|
||||
"resourceUsersRolesDescription": "Configure which users and roles can visit this resource",
|
||||
"resourceUsersRolesSubmit": "Save Access Controls",
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import z from "zod";
|
||||
import ipaddr from "ipaddr.js";
|
||||
import { COUNTRIES } from "@server/db/countries";
|
||||
import { isValidRegionId } from "@server/db/regions";
|
||||
|
||||
export function isValidCIDR(cidr: string): boolean {
|
||||
return (
|
||||
@@ -67,6 +69,45 @@ export function isValidUrlGlobPattern(pattern: string): boolean {
|
||||
return true;
|
||||
}
|
||||
|
||||
export const RESOURCE_RULE_MATCH_TYPES = [
|
||||
"CIDR",
|
||||
"IP",
|
||||
"PATH",
|
||||
"COUNTRY",
|
||||
"ASN",
|
||||
"REGION"
|
||||
] as const;
|
||||
|
||||
export type ResourceRuleMatchType = (typeof RESOURCE_RULE_MATCH_TYPES)[number];
|
||||
|
||||
export function getResourceRuleValueValidationError(
|
||||
match: ResourceRuleMatchType,
|
||||
value: string
|
||||
): string | null {
|
||||
switch (match) {
|
||||
case "CIDR":
|
||||
return isValidCIDR(value) ? null : "Invalid CIDR provided";
|
||||
case "IP":
|
||||
return isValidIP(value) ? null : "Invalid IP provided";
|
||||
case "PATH":
|
||||
return isValidUrlGlobPattern(value)
|
||||
? null
|
||||
: "Invalid URL glob pattern provided";
|
||||
case "REGION":
|
||||
return isValidRegionId(value) ? null : "Invalid region ID provided";
|
||||
case "COUNTRY":
|
||||
return COUNTRIES.some((country) => country.code === value)
|
||||
? null
|
||||
: "Invalid country code provided";
|
||||
case "ASN":
|
||||
return /^AS\d+$/i.test(value.trim())
|
||||
? null
|
||||
: "Invalid ASN provided";
|
||||
default:
|
||||
return "Invalid rule match type provided";
|
||||
}
|
||||
}
|
||||
|
||||
export function isUrlValid(url: string | undefined) {
|
||||
if (!url) return true; // the link is optional in the schema so if it's empty it's valid
|
||||
var pattern = new RegExp(
|
||||
|
||||
@@ -33,9 +33,8 @@ import {
|
||||
import { getUniqueResourcePolicyName } from "@server/db/names";
|
||||
import response from "@server/lib/response";
|
||||
import {
|
||||
isValidCIDR,
|
||||
isValidIP,
|
||||
isValidUrlGlobPattern
|
||||
getResourceRuleValueValidationError,
|
||||
RESOURCE_RULE_MATCH_TYPES
|
||||
} from "@server/lib/validators";
|
||||
import logger from "@server/logger";
|
||||
import { OpenAPITags, registry } from "@server/openApi";
|
||||
@@ -56,9 +55,9 @@ const ruleSchema = z.strictObject({
|
||||
enum: ["ACCEPT", "DROP", "PASS"],
|
||||
description: "rule action"
|
||||
}),
|
||||
match: z.enum(["CIDR", "IP", "PATH"]).openapi({
|
||||
match: z.enum(RESOURCE_RULE_MATCH_TYPES).openapi({
|
||||
type: "string",
|
||||
enum: ["CIDR", "IP", "PATH"],
|
||||
enum: [...RESOURCE_RULE_MATCH_TYPES],
|
||||
description: "rule match"
|
||||
}),
|
||||
value: z.string().min(1),
|
||||
@@ -261,26 +260,13 @@ export async function createResourcePolicy(
|
||||
const niceId = await getUniqueResourcePolicyName(orgId);
|
||||
|
||||
for (const rule of rules) {
|
||||
if (rule.match === "CIDR" && !isValidCIDR(rule.value)) {
|
||||
const validationError = getResourceRuleValueValidationError(
|
||||
rule.match,
|
||||
rule.value
|
||||
);
|
||||
if (validationError) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
"Invalid CIDR provided"
|
||||
)
|
||||
);
|
||||
} else if (rule.match === "IP" && !isValidIP(rule.value)) {
|
||||
return next(
|
||||
createHttpError(HttpCode.BAD_REQUEST, "Invalid IP provided")
|
||||
);
|
||||
} else if (
|
||||
rule.match === "PATH" &&
|
||||
!isValidUrlGlobPattern(rule.value)
|
||||
) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
"Invalid URL glob pattern provided"
|
||||
)
|
||||
createHttpError(HttpCode.BAD_REQUEST, validationError)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -666,6 +666,13 @@ authenticated.get(
|
||||
resource.getResourcePolicies
|
||||
);
|
||||
|
||||
authenticated.get(
|
||||
"/resource-policy/:resourcePolicyId",
|
||||
verifyResourcePolicyAccess,
|
||||
verifyUserHasAction(ActionsEnum.getResourcePolicy),
|
||||
policy.getResourcePolicy
|
||||
);
|
||||
|
||||
authenticated.put(
|
||||
"/resource-policy/:resourcePolicyId",
|
||||
verifyResourcePolicyAccess,
|
||||
|
||||
@@ -8,9 +8,8 @@ import createHttpError from "http-errors";
|
||||
import logger from "@server/logger";
|
||||
import { fromError } from "zod-validation-error";
|
||||
import {
|
||||
isValidCIDR,
|
||||
isValidIP,
|
||||
isValidUrlGlobPattern
|
||||
getResourceRuleValueValidationError,
|
||||
RESOURCE_RULE_MATCH_TYPES
|
||||
} from "@server/lib/validators";
|
||||
import { OpenAPITags, registry } from "@server/openApi";
|
||||
|
||||
@@ -20,9 +19,9 @@ const ruleSchema = z.strictObject({
|
||||
enum: ["ACCEPT", "DROP", "PASS"],
|
||||
description: "rule action"
|
||||
}),
|
||||
match: z.enum(["CIDR", "IP", "PATH"]).openapi({
|
||||
match: z.enum(RESOURCE_RULE_MATCH_TYPES).openapi({
|
||||
type: "string",
|
||||
enum: ["CIDR", "IP", "PATH"],
|
||||
enum: [...RESOURCE_RULE_MATCH_TYPES],
|
||||
description: "rule match"
|
||||
}),
|
||||
value: z.string().min(1),
|
||||
@@ -105,26 +104,13 @@ export async function setResourcePolicyRules(
|
||||
}
|
||||
|
||||
for (const rule of rules) {
|
||||
if (rule.match === "CIDR" && !isValidCIDR(rule.value)) {
|
||||
const validationError = getResourceRuleValueValidationError(
|
||||
rule.match,
|
||||
rule.value
|
||||
);
|
||||
if (validationError) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
"Invalid CIDR provided"
|
||||
)
|
||||
);
|
||||
} else if (rule.match === "IP" && !isValidIP(rule.value)) {
|
||||
return next(
|
||||
createHttpError(HttpCode.BAD_REQUEST, "Invalid IP provided")
|
||||
);
|
||||
} else if (
|
||||
rule.match === "PATH" &&
|
||||
!isValidUrlGlobPattern(rule.value)
|
||||
) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
"Invalid URL glob pattern provided"
|
||||
)
|
||||
createHttpError(HttpCode.BAD_REQUEST, validationError)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { EditPolicyForm } from "@app/components/resource-policy/EditPolicyForm";
|
||||
|
||||
export default function EditPolicyAuthenticationPage() {
|
||||
return <EditPolicyForm section="authentication" />;
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { EditPolicyForm } from "@app/components/resource-policy/EditPolicyForm";
|
||||
|
||||
export default function EditPolicyGeneralPage() {
|
||||
return <EditPolicyForm section="general" />;
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
|
||||
import { HorizontalTabs } from "@app/components/HorizontalTabs";
|
||||
import { Button } from "@app/components/ui/button";
|
||||
import { internal } from "@app/lib/api";
|
||||
import { authCookieHeader } from "@app/lib/api/cookies";
|
||||
import { ResourcePolicyProvider } from "@app/providers/ResourcePolicyProvider";
|
||||
import type { GetResourcePolicyResponse } from "@server/routers/policy";
|
||||
import type { AxiosResponse } from "axios";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import Link from "next/link";
|
||||
import { redirect } from "next/navigation";
|
||||
import type { Metadata } from "next";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Resource Policy"
|
||||
};
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
type EditPolicyLayoutProps = {
|
||||
children: React.ReactNode;
|
||||
params: Promise<{ niceId: string; orgId: string }>;
|
||||
};
|
||||
|
||||
export default async function EditPolicyLayout(props: EditPolicyLayoutProps) {
|
||||
const params = await props.params;
|
||||
const t = await getTranslations();
|
||||
|
||||
let policyResponse: GetResourcePolicyResponse | null = null;
|
||||
try {
|
||||
const res = await internal.get<
|
||||
AxiosResponse<GetResourcePolicyResponse>
|
||||
>(
|
||||
`/org/${params.orgId}/resource-policy/${params.niceId}`,
|
||||
await authCookieHeader()
|
||||
);
|
||||
policyResponse = res.data.data;
|
||||
} catch {
|
||||
redirect(`/${params.orgId}/settings/policies/resources/public`);
|
||||
}
|
||||
|
||||
if (!policyResponse) {
|
||||
redirect(`/${params.orgId}/settings/policies/resources/public`);
|
||||
}
|
||||
|
||||
const navItems = [
|
||||
{
|
||||
title: t("general"),
|
||||
href: "/{orgId}/settings/policies/resources/public/{niceId}/general"
|
||||
},
|
||||
{
|
||||
title: t("authentication"),
|
||||
href: "/{orgId}/settings/policies/resources/public/{niceId}/authentication"
|
||||
},
|
||||
{
|
||||
title: t("policyAccessRulesTitle"),
|
||||
href: "/{orgId}/settings/policies/resources/public/{niceId}/rules"
|
||||
}
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex justify-between">
|
||||
<SettingsSectionTitle
|
||||
title={t("resourcePolicySetting", {
|
||||
policyName: policyResponse.name
|
||||
})}
|
||||
description={t("resourcePolicySettingDescription")}
|
||||
/>
|
||||
|
||||
<Button asChild variant="outline">
|
||||
<Link
|
||||
href={`/${params.orgId}/settings/policies/resources/public`}
|
||||
>
|
||||
{t("resourcePoliciesSeeAll")}
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<ResourcePolicyProvider policy={policyResponse}>
|
||||
<HorizontalTabs items={navItems}>{props.children}</HorizontalTabs>
|
||||
</ResourcePolicyProvider>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,62 +1,12 @@
|
||||
import { EditPolicyForm } from "@app/components/resource-policy/EditPolicyForm";
|
||||
import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
|
||||
import { Button } from "@app/components/ui/button";
|
||||
import { internal } from "@app/lib/api";
|
||||
import { authCookieHeader } from "@app/lib/api/cookies";
|
||||
import { ResourcePolicyProvider } from "@app/providers/ResourcePolicyProvider";
|
||||
import type { GetResourcePolicyResponse } from "@server/routers/policy";
|
||||
import type { AxiosResponse } from "axios";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import Link from "next/link";
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
export interface EditPolicyPageProps {
|
||||
type EditPolicyPageProps = {
|
||||
params: Promise<{ niceId: string; orgId: string }>;
|
||||
}
|
||||
};
|
||||
|
||||
export default async function EditPolicyPage(props: EditPolicyPageProps) {
|
||||
const params = await props.params;
|
||||
const t = await getTranslations();
|
||||
|
||||
let policyResponse: GetResourcePolicyResponse | null = null;
|
||||
try {
|
||||
const res = await internal.get<
|
||||
AxiosResponse<GetResourcePolicyResponse>
|
||||
>(
|
||||
`/org/${params.orgId}/resource-policy/${params.niceId}`,
|
||||
await authCookieHeader()
|
||||
);
|
||||
policyResponse = res.data.data;
|
||||
} catch {
|
||||
redirect(`/${params.orgId}/settings/policies/resources/public`);
|
||||
}
|
||||
|
||||
if (!policyResponse) {
|
||||
redirect(`/${params.orgId}/settings/policies/resources/public`);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex justify-between">
|
||||
<SettingsSectionTitle
|
||||
title={t("resourcePolicySetting", {
|
||||
policyName: policyResponse.name
|
||||
})}
|
||||
description={t("resourcePolicySettingDescription")}
|
||||
/>
|
||||
|
||||
<Button asChild variant="outline">
|
||||
<Link
|
||||
href={`/${params.orgId}/settings/policies/resources/public`}
|
||||
>
|
||||
{t("resourcePoliciesSeeAll")}
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<ResourcePolicyProvider policy={policyResponse}>
|
||||
<EditPolicyForm />
|
||||
</ResourcePolicyProvider>
|
||||
</>
|
||||
redirect(
|
||||
`/${params.orgId}/settings/policies/resources/public/${params.niceId}/general`
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { EditPolicyForm } from "@app/components/resource-policy/EditPolicyForm";
|
||||
|
||||
export default function EditPolicyRulesPage() {
|
||||
return <EditPolicyForm section="rules" />;
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import ResourcePoliciesBanner from "@app/components/ResourcePoliciesBanner";
|
||||
import { ResourcePoliciesTable } from "@app/components/ResourcePoliciesTable";
|
||||
import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
|
||||
import { internal } from "@app/lib/api";
|
||||
@@ -54,6 +55,8 @@ export default async function ResourcePoliciesPage(
|
||||
description={t("resourcePoliciesDescription")}
|
||||
/>
|
||||
|
||||
<ResourcePoliciesBanner />
|
||||
|
||||
<ResourcePoliciesTable
|
||||
policies={policies}
|
||||
orgId={params.orgId}
|
||||
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
SettingsSectionHeader,
|
||||
SettingsSectionTitle
|
||||
} from "@app/components/Settings";
|
||||
import { DataTableEmptyState } from "@app/components/ui/data-table-empty-state";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
@@ -710,6 +711,15 @@ export function ProxyResourceTargetsForm({
|
||||
|
||||
const [, formAction, isSubmitting] = useActionState(saveTargets, null);
|
||||
|
||||
const addTargetButton = (
|
||||
<Button onClick={addNewTarget} variant="outline">
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
{t("addTarget")}
|
||||
</Button>
|
||||
);
|
||||
|
||||
const hasTargets = targets.length > 0;
|
||||
|
||||
async function saveTargets() {
|
||||
if (!resource) return;
|
||||
|
||||
@@ -823,143 +833,104 @@ export function ProxyResourceTargetsForm({
|
||||
</SettingsSectionDescription>
|
||||
</SettingsSectionHeader>
|
||||
<SettingsSectionBody>
|
||||
{targets.length > 0 ? (
|
||||
<>
|
||||
<div className="overflow-x-auto">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
{table
|
||||
.getHeaderGroups()
|
||||
.map((headerGroup) => (
|
||||
<TableRow key={headerGroup.id}>
|
||||
{headerGroup.headers.map(
|
||||
(header) => {
|
||||
const isActionsColumn =
|
||||
header.column
|
||||
.id ===
|
||||
"actions";
|
||||
const isSiteColumn =
|
||||
header.column
|
||||
.id ===
|
||||
"site";
|
||||
return (
|
||||
<TableHead
|
||||
key={
|
||||
header.id
|
||||
}
|
||||
className={
|
||||
isActionsColumn
|
||||
? "sticky right-0 z-10 w-auto min-w-fit bg-card"
|
||||
: isSiteColumn
|
||||
? "w-45"
|
||||
: ""
|
||||
}
|
||||
>
|
||||
{header.isPlaceholder
|
||||
? null
|
||||
: flexRender(
|
||||
header
|
||||
.column
|
||||
.columnDef
|
||||
.header,
|
||||
header.getContext()
|
||||
)}
|
||||
</TableHead>
|
||||
);
|
||||
}
|
||||
)}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{table.getRowModel().rows?.length ? (
|
||||
table
|
||||
.getRowModel()
|
||||
.rows.map((row) => (
|
||||
<TableRow key={row.id}>
|
||||
{row
|
||||
.getVisibleCells()
|
||||
.map((cell) => {
|
||||
const isActionsColumn =
|
||||
cell.column
|
||||
.id ===
|
||||
"actions";
|
||||
const isSiteColumn =
|
||||
cell.column
|
||||
.id ===
|
||||
"site";
|
||||
return (
|
||||
<TableCell
|
||||
key={
|
||||
cell.id
|
||||
}
|
||||
className={
|
||||
isActionsColumn
|
||||
? "sticky right-0 z-10 w-auto min-w-fit bg-card"
|
||||
: isSiteColumn
|
||||
? "w-45"
|
||||
: ""
|
||||
}
|
||||
>
|
||||
{flexRender(
|
||||
cell
|
||||
.column
|
||||
.columnDef
|
||||
.cell,
|
||||
cell.getContext()
|
||||
)}
|
||||
</TableCell>
|
||||
);
|
||||
})}
|
||||
</TableRow>
|
||||
))
|
||||
) : (
|
||||
<TableRow>
|
||||
<TableCell
|
||||
colSpan={columns.length}
|
||||
className="h-24 text-center"
|
||||
<div className="overflow-x-auto">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
{table.getHeaderGroups().map((headerGroup) => (
|
||||
<TableRow key={headerGroup.id}>
|
||||
{headerGroup.headers.map((header) => {
|
||||
const isActionsColumn =
|
||||
header.column.id === "actions";
|
||||
const isSiteColumn =
|
||||
header.column.id === "site";
|
||||
return (
|
||||
<TableHead
|
||||
key={header.id}
|
||||
className={
|
||||
isActionsColumn
|
||||
? "sticky right-0 z-10 w-auto min-w-fit bg-card"
|
||||
: isSiteColumn
|
||||
? "w-45"
|
||||
: ""
|
||||
}
|
||||
>
|
||||
{t("targetNoOne")}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center justify-between w-full gap-2">
|
||||
<Button
|
||||
onClick={addNewTarget}
|
||||
variant="outline"
|
||||
{header.isPlaceholder
|
||||
? null
|
||||
: flexRender(
|
||||
header.column
|
||||
.columnDef
|
||||
.header,
|
||||
header.getContext()
|
||||
)}
|
||||
</TableHead>
|
||||
);
|
||||
})}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{table.getRowModel().rows?.length ? (
|
||||
table.getRowModel().rows.map((row) => (
|
||||
<TableRow key={row.id}>
|
||||
{row
|
||||
.getVisibleCells()
|
||||
.map((cell) => {
|
||||
const isActionsColumn =
|
||||
cell.column.id ===
|
||||
"actions";
|
||||
const isSiteColumn =
|
||||
cell.column.id ===
|
||||
"site";
|
||||
return (
|
||||
<TableCell
|
||||
key={cell.id}
|
||||
className={
|
||||
isActionsColumn
|
||||
? "sticky right-0 z-10 w-auto min-w-fit bg-card"
|
||||
: isSiteColumn
|
||||
? "w-45"
|
||||
: ""
|
||||
}
|
||||
>
|
||||
{flexRender(
|
||||
cell.column
|
||||
.columnDef
|
||||
.cell,
|
||||
cell.getContext()
|
||||
)}
|
||||
</TableCell>
|
||||
);
|
||||
})}
|
||||
</TableRow>
|
||||
))
|
||||
) : (
|
||||
<DataTableEmptyState
|
||||
colSpan={columns.length}
|
||||
message={t("targetNoOne")}
|
||||
action={addTargetButton}
|
||||
/>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
{hasTargets && (
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center justify-between w-full gap-2">
|
||||
{addTargetButton}
|
||||
<div className="flex items-center gap-2">
|
||||
<Switch
|
||||
id="advanced-mode-toggle"
|
||||
checked={isAdvancedMode}
|
||||
onCheckedChange={setIsAdvancedMode}
|
||||
/>
|
||||
<label
|
||||
htmlFor="advanced-mode-toggle"
|
||||
className="text-sm"
|
||||
>
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
{t("addTarget")}
|
||||
</Button>
|
||||
<div className="flex items-center gap-2">
|
||||
<Switch
|
||||
id="advanced-mode-toggle"
|
||||
checked={isAdvancedMode}
|
||||
onCheckedChange={setIsAdvancedMode}
|
||||
/>
|
||||
<label
|
||||
htmlFor="advanced-mode-toggle"
|
||||
className="text-sm"
|
||||
>
|
||||
{t("advancedMode")}
|
||||
</label>
|
||||
</div>
|
||||
{t("advancedMode")}
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="text-center py-8 border-2 border-dashed border-muted rounded-lg p-4">
|
||||
<p className="text-muted-foreground mb-4">
|
||||
{t("targetNoOne")}
|
||||
</p>
|
||||
<Button onClick={addNewTarget} variant="outline">
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
{t("addTarget")}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
{build === "saas" &&
|
||||
|
||||
@@ -1,320 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { EditPolicyForm } from "@app/components/resource-policy/EditPolicyForm";
|
||||
import {
|
||||
SettingsContainer,
|
||||
SettingsSection,
|
||||
SettingsSectionBody,
|
||||
SettingsSectionDescription,
|
||||
SettingsSectionFooter,
|
||||
SettingsSectionHeader,
|
||||
SettingsSectionTitle
|
||||
} from "@app/components/Settings";
|
||||
import {
|
||||
StrategySelect,
|
||||
type StrategyOption
|
||||
} from "@app/components/StrategySelect";
|
||||
import { Button } from "@app/components/ui/button";
|
||||
import {
|
||||
Command,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList
|
||||
} from "@app/components/ui/command";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger
|
||||
} from "@app/components/ui/popover";
|
||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||
import { useOrgContext } from "@app/hooks/useOrgContext";
|
||||
import { usePaidStatus } from "@app/hooks/usePaidStatus";
|
||||
import { useResourceContext } from "@app/hooks/useResourceContext";
|
||||
import { toast } from "@app/hooks/useToast";
|
||||
import { createApiClient, formatAxiosError } from "@app/lib/api";
|
||||
import { cn } from "@app/lib/cn";
|
||||
import { orgQueries, resourceQueries } from "@app/lib/queries";
|
||||
import { ResourcePolicyProvider } from "@app/providers/ResourcePolicyProvider";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { CaretSortIcon } from "@radix-ui/react-icons";
|
||||
import { build } from "@server/build";
|
||||
import { tierMatrix, TierFeature } from "@server/lib/billing/tierMatrix";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import SetResourcePasswordForm from "@app/components/SetResourcePasswordForm";
|
||||
import { Binary, Bot, InfoIcon, Key } from "lucide-react";
|
||||
import { CheckIcon } from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useEffect, useState, useTransition } from "react";
|
||||
import { useForm, useWatch } from "react-hook-form";
|
||||
import { z } from "zod";
|
||||
|
||||
const resourceTypeSchema = z
|
||||
.object({
|
||||
type: z.literal("inline")
|
||||
})
|
||||
.or(
|
||||
z.object({
|
||||
type: z.literal("shared"),
|
||||
resourcePolicyId: z.number()
|
||||
})
|
||||
);
|
||||
|
||||
type ResourcePolicyType = StrategyOption<"inline" | "shared">;
|
||||
import { ResourcePolicyEditForm } from "@app/components/resource-policy/ResourcePolicyEditForm";
|
||||
|
||||
export default function ResourceAuthenticationPage() {
|
||||
const { org } = useOrgContext();
|
||||
const { resource, updateResource } = useResourceContext();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const { env } = useEnvContext();
|
||||
const { isPaidUser } = usePaidStatus();
|
||||
|
||||
const api = createApiClient({ env });
|
||||
const router = useRouter();
|
||||
const t = useTranslations();
|
||||
|
||||
const { data: policies, isLoading: isLoadingPolicies } = useQuery(
|
||||
resourceQueries.policies({
|
||||
resourceId: resource.resourceId
|
||||
})
|
||||
);
|
||||
|
||||
const form = useForm({
|
||||
resolver: zodResolver(resourceTypeSchema),
|
||||
defaultValues: {
|
||||
type:
|
||||
build !== "oss" && resource.resourcePolicyId
|
||||
? "shared"
|
||||
: "inline"
|
||||
}
|
||||
});
|
||||
|
||||
const selectedResourceType = useWatch({
|
||||
control: form.control,
|
||||
name: "type"
|
||||
});
|
||||
|
||||
const [resourcePolicysearchQuery, setResourcePolicySearchQuery] =
|
||||
useState("");
|
||||
|
||||
const { data: policiesList = [] } = useQuery({
|
||||
...orgQueries.policies({
|
||||
orgId: org.org.orgId,
|
||||
name: resourcePolicysearchQuery
|
||||
}),
|
||||
enabled: selectedResourceType === "shared"
|
||||
});
|
||||
|
||||
const [selectedPolicy, setSelectedPolicy] = useState<{
|
||||
name: string;
|
||||
id: number;
|
||||
} | null>(null);
|
||||
|
||||
const resourcePolicyTypes: Array<ResourcePolicyType> = [
|
||||
{
|
||||
id: "inline",
|
||||
title: t("resourcePolicyInline"),
|
||||
description: t("resourcePolicyInlineDescription")
|
||||
},
|
||||
{
|
||||
id: "shared",
|
||||
title: t("resourcePolicyShared"),
|
||||
description: t("resourcePolicySharedDescription")
|
||||
}
|
||||
];
|
||||
|
||||
useEffect(() => {
|
||||
if (!isLoadingPolicies && policies?.sharedPolicy) {
|
||||
setSelectedPolicy({
|
||||
id: policies?.sharedPolicy.resourcePolicyId,
|
||||
name: policies?.sharedPolicy.name
|
||||
});
|
||||
}
|
||||
}, [isLoadingPolicies, policies?.sharedPolicy]);
|
||||
|
||||
const [isUpdatingResource, startTransition] = useTransition();
|
||||
|
||||
async function handleSaveResourcePolicyType() {
|
||||
try {
|
||||
if (selectedResourceType === "inline") {
|
||||
await api.post(`/resource/${resource.resourceId}`, {
|
||||
resourcePolicyId: null
|
||||
});
|
||||
} else {
|
||||
if (!selectedPolicy) {
|
||||
toast({
|
||||
title: t("error"),
|
||||
description: t("resourcePolicySelectError"),
|
||||
variant: "destructive"
|
||||
});
|
||||
return;
|
||||
}
|
||||
await api.post(`/resource/${resource.resourceId}`, {
|
||||
resourcePolicyId: selectedPolicy.id
|
||||
});
|
||||
}
|
||||
router.refresh();
|
||||
toast({
|
||||
title: t("resourceUpdated"),
|
||||
description: t("resourceUpdatedDescription")
|
||||
});
|
||||
} catch (e) {
|
||||
toast({
|
||||
title: t("error"),
|
||||
description: formatAxiosError(e),
|
||||
variant: "destructive"
|
||||
});
|
||||
} finally {
|
||||
await queryClient.invalidateQueries(
|
||||
resourceQueries.policies({
|
||||
resourceId: resource.resourceId
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const pageLoading = isLoadingPolicies || !policies;
|
||||
|
||||
if (pageLoading) {
|
||||
return <></>;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<SettingsContainer>
|
||||
{build !== "oss" &&
|
||||
isPaidUser(tierMatrix[TierFeature.ResourcePolicies]) && (
|
||||
<SettingsSection>
|
||||
<SettingsSectionHeader>
|
||||
<SettingsSectionTitle>
|
||||
{t("resourcePolicySelectTitle")}
|
||||
</SettingsSectionTitle>
|
||||
<SettingsSectionDescription>
|
||||
{t("resourcePolicySelectDescription")}
|
||||
</SettingsSectionDescription>
|
||||
</SettingsSectionHeader>
|
||||
<SettingsSectionBody>
|
||||
<StrategySelect
|
||||
options={resourcePolicyTypes}
|
||||
value={selectedResourceType}
|
||||
onChange={(value) => {
|
||||
form.setValue("type", value);
|
||||
}}
|
||||
cols={2}
|
||||
/>
|
||||
{selectedResourceType === "shared" && (
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
className={
|
||||
"w-full md:w-1/2 justify-between"
|
||||
}
|
||||
>
|
||||
<span className="truncate max-w-37.5">
|
||||
{selectedPolicy
|
||||
? selectedPolicy.name
|
||||
: t(
|
||||
"resourcePolicySelect"
|
||||
)}
|
||||
</span>
|
||||
<CaretSortIcon className="ml-2h-4 w-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="p-0 w-45">
|
||||
<Command shouldFilter={false}>
|
||||
<CommandInput
|
||||
placeholder={t(
|
||||
"resourcePolicySearch"
|
||||
)}
|
||||
value={
|
||||
resourcePolicysearchQuery
|
||||
}
|
||||
onValueChange={
|
||||
setResourcePolicySearchQuery
|
||||
}
|
||||
/>
|
||||
<CommandList>
|
||||
<CommandEmpty>
|
||||
{t(
|
||||
"resourcePolicyNotFound"
|
||||
)}
|
||||
</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{policiesList.map(
|
||||
(policy) => (
|
||||
<CommandItem
|
||||
key={
|
||||
policy.resourcePolicyId
|
||||
}
|
||||
value={policy.resourcePolicyId.toString()}
|
||||
onSelect={() =>
|
||||
setSelectedPolicy(
|
||||
{
|
||||
id: policy.resourcePolicyId,
|
||||
name: policy.name
|
||||
}
|
||||
)
|
||||
}
|
||||
>
|
||||
<CheckIcon
|
||||
className={cn(
|
||||
"mr-2 h-4 w-4",
|
||||
policy.resourcePolicyId ===
|
||||
selectedPolicy?.id
|
||||
? "opacity-100"
|
||||
: "opacity-0"
|
||||
)}
|
||||
/>
|
||||
{
|
||||
policy.name
|
||||
}
|
||||
</CommandItem>
|
||||
)
|
||||
)}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)}
|
||||
</SettingsSectionBody>
|
||||
<SettingsSectionFooter className="justify-start">
|
||||
<Button
|
||||
onClick={() =>
|
||||
startTransition(
|
||||
handleSaveResourcePolicyType
|
||||
)
|
||||
}
|
||||
loading={isUpdatingResource}
|
||||
>
|
||||
{t("resourcePolicyTypeSave")}
|
||||
</Button>
|
||||
</SettingsSectionFooter>
|
||||
</SettingsSection>
|
||||
)}
|
||||
|
||||
{selectedResourceType === "inline" ? (
|
||||
<ResourcePolicyProvider policy={policies.defaultPolicy}>
|
||||
<EditPolicyForm hidePolicyNameForm />
|
||||
</ResourcePolicyProvider>
|
||||
) : (
|
||||
policies.sharedPolicy && (
|
||||
<ResourcePolicyProvider
|
||||
policy={policies.sharedPolicy}
|
||||
key={policies.sharedPolicy.resourcePolicyId}
|
||||
>
|
||||
<EditPolicyForm
|
||||
resourceId={resource.resourceId}
|
||||
/>
|
||||
</ResourcePolicyProvider>
|
||||
)
|
||||
)}
|
||||
</SettingsContainer>
|
||||
</>
|
||||
);
|
||||
return <ResourcePolicyEditForm section="authentication" />;
|
||||
}
|
||||
|
||||
@@ -36,10 +36,14 @@ import { AlertCircle } from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useParams, useRouter } from "next/navigation";
|
||||
import { toASCII, toUnicode } from "punycode";
|
||||
import { useActionState, useMemo, useState } from "react";
|
||||
import { useActionState, useEffect, useMemo, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import z from "zod";
|
||||
import { SharedPolicySelect } from "@app/components/shared-policy-selector";
|
||||
import { useOrgContext } from "@app/hooks/useOrgContext";
|
||||
import { build } from "@server/build";
|
||||
import { TierFeature } from "@server/lib/billing/tierMatrix";
|
||||
import { Alert, AlertDescription } from "@app/components/ui/alert";
|
||||
import { RadioGroup, RadioGroupItem } from "@app/components/ui/radio-group";
|
||||
import {
|
||||
@@ -434,16 +438,30 @@ function MaintenanceSectionForm({
|
||||
|
||||
export default function GeneralForm() {
|
||||
const params = useParams();
|
||||
const { org } = useOrgContext();
|
||||
const { resource, updateResource } = useResourceContext();
|
||||
const router = useRouter();
|
||||
const t = useTranslations();
|
||||
|
||||
const { env } = useEnvContext();
|
||||
const { isPaidUser } = usePaidStatus();
|
||||
|
||||
const orgId = params.orgId;
|
||||
|
||||
const api = createApiClient({ env });
|
||||
|
||||
const showResourcePolicy =
|
||||
build !== "oss" &&
|
||||
isPaidUser(tierMatrix[TierFeature.ResourcePolicies]);
|
||||
|
||||
const [selectedSharedPolicyId, setSelectedSharedPolicyId] = useState<
|
||||
number | null
|
||||
>(resource.resourcePolicyId ?? null);
|
||||
|
||||
useEffect(() => {
|
||||
setSelectedSharedPolicyId(resource.resourcePolicyId ?? null);
|
||||
}, [resource.resourcePolicyId]);
|
||||
|
||||
const [resourceFullDomain, setResourceFullDomain] = useState(
|
||||
`${resource.ssl ? "https" : "http"}://${toUnicode(resource.fullDomain || "")}`
|
||||
);
|
||||
@@ -506,6 +524,12 @@ export default function GeneralForm() {
|
||||
|
||||
const data = form.getValues();
|
||||
|
||||
let resourcePolicyId: number | null | undefined;
|
||||
|
||||
if (showResourcePolicy) {
|
||||
resourcePolicyId = selectedSharedPolicyId;
|
||||
}
|
||||
|
||||
const res = await api
|
||||
.post<AxiosResponse<UpdateResourceResponse>>(
|
||||
`resource/${resource?.resourceId}`,
|
||||
@@ -519,7 +543,8 @@ export default function GeneralForm() {
|
||||
)
|
||||
: undefined,
|
||||
domainId: data.domainId,
|
||||
proxyPort: data.proxyPort
|
||||
proxyPort: data.proxyPort,
|
||||
...(resourcePolicyId !== undefined && { resourcePolicyId })
|
||||
}
|
||||
)
|
||||
.catch((e) => {
|
||||
@@ -543,7 +568,10 @@ export default function GeneralForm() {
|
||||
subdomain: data.subdomain,
|
||||
fullDomain: updated.fullDomain,
|
||||
proxyPort: data.proxyPort,
|
||||
domainId: data.domainId
|
||||
domainId: data.domainId,
|
||||
...(resourcePolicyId !== undefined && {
|
||||
resourcePolicyId
|
||||
})
|
||||
});
|
||||
|
||||
toast({
|
||||
@@ -584,7 +612,7 @@ export default function GeneralForm() {
|
||||
</SettingsSectionHeader>
|
||||
|
||||
<SettingsSectionBody>
|
||||
<SettingsSectionForm>
|
||||
<SettingsSectionForm variant="half">
|
||||
<Form {...form}>
|
||||
<form
|
||||
action={formAction}
|
||||
@@ -771,6 +799,24 @@ export default function GeneralForm() {
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{showResourcePolicy && (
|
||||
<div className="space-y-2">
|
||||
<FormLabel>
|
||||
{t("sharedPolicy")}
|
||||
</FormLabel>
|
||||
<SharedPolicySelect
|
||||
key={
|
||||
resource.resourcePolicyId ??
|
||||
"none"
|
||||
}
|
||||
orgId={org.org.orgId}
|
||||
value={selectedSharedPolicyId}
|
||||
onChange={
|
||||
setSelectedSharedPolicyId
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</form>
|
||||
</Form>
|
||||
</SettingsSectionForm>
|
||||
|
||||
@@ -92,10 +92,16 @@ export default async function ResourceLayout(props: ResourceLayoutProps) {
|
||||
];
|
||||
|
||||
if (["http", "ssh", "rdp", "vnc"].includes(resource.mode)) {
|
||||
navItems.push({
|
||||
title: t("authentication"),
|
||||
href: `/{orgId}/settings/resources/public/{niceId}/authentication`
|
||||
});
|
||||
navItems.push(
|
||||
{
|
||||
title: t("authentication"),
|
||||
href: `/{orgId}/settings/resources/public/{niceId}/authentication`
|
||||
},
|
||||
{
|
||||
title: t("policyAccessRulesTitle"),
|
||||
href: `/{orgId}/settings/resources/public/{niceId}/rules`
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { ResourcePolicyEditForm } from "@app/components/resource-policy/ResourcePolicyEditForm";
|
||||
|
||||
export default function ResourcePolicyRulesPage() {
|
||||
return <ResourcePolicyEditForm section="rules" />;
|
||||
}
|
||||
@@ -22,7 +22,7 @@
|
||||
--accent-foreground: oklch(0.21 0.006 285.885);
|
||||
--destructive: oklch(0.577 0.245 27.325);
|
||||
--destructive-foreground: oklch(0.985 0 0);
|
||||
--border: oklch(0.91 0.004 286.32);
|
||||
--border: oklch(0.88 0.004 286.32);
|
||||
--input: oklch(0.88 0.004 286.32);
|
||||
--ring: oklch(0.705 0.213 47.604);
|
||||
--chart-1: oklch(0.646 0.222 41.116);
|
||||
@@ -57,7 +57,7 @@
|
||||
--accent-foreground: oklch(0.985 0 0);
|
||||
--destructive: oklch(0.5382 0.1949 22.216);
|
||||
--destructive-foreground: oklch(0.985 0 0);
|
||||
--border: oklch(1 0 0 / 8%);
|
||||
--border: oklch(1 0 0 / 18%);
|
||||
--input: oklch(1 0 0 / 18%);
|
||||
--ring: oklch(0.646 0.222 41.116);
|
||||
--chart-1: oklch(0.488 0.243 264.376);
|
||||
|
||||
21
src/components/ResourcePoliciesBanner.tsx
Normal file
21
src/components/ResourcePoliciesBanner.tsx
Normal file
@@ -0,0 +1,21 @@
|
||||
"use client";
|
||||
|
||||
import { Shield } from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import DismissableBanner from "./DismissableBanner";
|
||||
|
||||
export const ResourcePoliciesBanner = () => {
|
||||
const t = useTranslations();
|
||||
|
||||
return (
|
||||
<DismissableBanner
|
||||
storageKey="resource-policies-banner-dismissed"
|
||||
version={1}
|
||||
title={t("resourcePoliciesBannerTitle")}
|
||||
titleIcon={<Shield className="w-5 h-5 text-primary" />}
|
||||
description={t("resourcePoliciesBannerDescription")}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default ResourcePoliciesBanner;
|
||||
@@ -283,6 +283,7 @@ export function ResourcePoliciesTable({
|
||||
searchPlaceholder={t("resourcePoliciesSearch")}
|
||||
pagination={pagination}
|
||||
rowCount={rowCount}
|
||||
searchQuery={searchParams.get("query")?.toString()}
|
||||
onSearch={handleSearchChange}
|
||||
onPaginationChange={handlePaginationChange}
|
||||
onAdd={() =>
|
||||
|
||||
@@ -147,7 +147,7 @@ export function CreatePolicyForm({}: CreatePolicyFormProps) {
|
||||
if (res && res.status === 201) {
|
||||
const niceId = res.data.data.niceId;
|
||||
router.push(
|
||||
`/${org.org.orgId}/settings/policies/resources/public/${niceId}`
|
||||
`/${org.org.orgId}/settings/policies/resources/public/${niceId}/general`
|
||||
);
|
||||
toast({
|
||||
title: t("success"),
|
||||
@@ -227,7 +227,7 @@ export function CreatePolicyForm({}: CreatePolicyFormProps) {
|
||||
</SettingsSectionDescription>
|
||||
</SettingsSectionHeader>
|
||||
<SettingsSectionBody>
|
||||
<SettingsSectionForm>
|
||||
<SettingsSectionForm variant="half">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="name"
|
||||
@@ -237,12 +237,7 @@ export function CreatePolicyForm({}: CreatePolicyFormProps) {
|
||||
{t("name")}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
{...field}
|
||||
placeholder={t(
|
||||
"resourcePolicyNamePlaceholder"
|
||||
)}
|
||||
/>
|
||||
<Input {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
|
||||
@@ -1,169 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
SettingsSection,
|
||||
SettingsSectionBody,
|
||||
SettingsSectionDescription,
|
||||
SettingsSectionHeader,
|
||||
SettingsSectionTitle
|
||||
} from "@app/components/Settings";
|
||||
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
import { createPolicyRulesSectionSchema, type PolicyFormValues } from ".";
|
||||
import { Button } from "@app/components/ui/button";
|
||||
import { Plus } from "lucide-react";
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { type UseFormReturn, useForm, useWatch } from "react-hook-form";
|
||||
|
||||
import { PolicyAccessRulesIntro } from "./PolicyAccessRulesIntro";
|
||||
import { PolicyAccessRulesTable } from "./PolicyAccessRulesTable";
|
||||
import {
|
||||
createEmptyRule,
|
||||
type PolicyAccessRule
|
||||
} from "./policy-access-rule-utils";
|
||||
|
||||
export type CreatePolicyRulesSectionFormProps = {
|
||||
form: UseFormReturn<PolicyFormValues, any, any>;
|
||||
isMaxmindAvailable: boolean;
|
||||
isMaxmindAsnAvailable: boolean;
|
||||
};
|
||||
|
||||
export function CreatePolicyRulesSectionForm({
|
||||
form: parentForm,
|
||||
isMaxmindAvailable,
|
||||
isMaxmindAsnAvailable
|
||||
}: CreatePolicyRulesSectionFormProps) {
|
||||
const t = useTranslations();
|
||||
const [rules, setRules] = useState<PolicyAccessRule[]>([]);
|
||||
|
||||
const rulesFormSchema = useMemo(
|
||||
() => createPolicyRulesSectionSchema(t),
|
||||
[t]
|
||||
);
|
||||
|
||||
const form = useForm({
|
||||
resolver: zodResolver(rulesFormSchema),
|
||||
defaultValues: {
|
||||
applyRules: false,
|
||||
rules: []
|
||||
}
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const subscription = form.watch((values) => {
|
||||
parentForm.setValue("applyRules", values.applyRules as boolean);
|
||||
parentForm.setValue("rules", values.rules as any);
|
||||
});
|
||||
return () => subscription.unsubscribe();
|
||||
}, [form, parentForm]);
|
||||
|
||||
const rulesEnabled = useWatch({
|
||||
control: form.control,
|
||||
name: "applyRules"
|
||||
});
|
||||
|
||||
const syncFormRules = useCallback(
|
||||
(updatedRules: PolicyAccessRule[]) => {
|
||||
form.setValue(
|
||||
"rules",
|
||||
updatedRules.map(
|
||||
({ action, match, value, priority, enabled }) => ({
|
||||
action,
|
||||
match,
|
||||
value,
|
||||
priority,
|
||||
enabled
|
||||
})
|
||||
)
|
||||
);
|
||||
},
|
||||
[form]
|
||||
);
|
||||
|
||||
const addEmptyRule = useCallback(() => {
|
||||
const updatedRules = [...rules, createEmptyRule(rules)];
|
||||
setRules(updatedRules);
|
||||
syncFormRules(updatedRules);
|
||||
}, [rules, syncFormRules]);
|
||||
|
||||
const removeRule = useCallback(
|
||||
function removeRule(ruleId: number) {
|
||||
const updatedRules = rules.filter((rule) => rule.ruleId !== ruleId);
|
||||
setRules(updatedRules);
|
||||
syncFormRules(updatedRules);
|
||||
},
|
||||
[rules, syncFormRules]
|
||||
);
|
||||
|
||||
const updateRule = useCallback(
|
||||
function updateRule(ruleId: number, data: Partial<PolicyAccessRule>) {
|
||||
const updatedRules = rules.map((rule) =>
|
||||
rule.ruleId === ruleId
|
||||
? { ...rule, ...data, updated: true }
|
||||
: rule
|
||||
);
|
||||
setRules(updatedRules);
|
||||
syncFormRules(updatedRules);
|
||||
},
|
||||
[rules, syncFormRules]
|
||||
);
|
||||
|
||||
const handleRulesChange = useCallback(
|
||||
(updatedRules: PolicyAccessRule[]) => {
|
||||
setRules(updatedRules);
|
||||
syncFormRules(updatedRules);
|
||||
},
|
||||
[syncFormRules]
|
||||
);
|
||||
|
||||
const addRuleButton = (
|
||||
<Button type="button" variant="outline" onClick={addEmptyRule}>
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
{t("ruleSubmit")}
|
||||
</Button>
|
||||
);
|
||||
|
||||
const hasRules = rules.length > 0;
|
||||
|
||||
return (
|
||||
<SettingsSection>
|
||||
<SettingsSectionHeader>
|
||||
<SettingsSectionTitle>
|
||||
{t("policyAccessRulesTitle")}
|
||||
</SettingsSectionTitle>
|
||||
<SettingsSectionDescription>
|
||||
{t("rulesResourceDescription")}
|
||||
</SettingsSectionDescription>
|
||||
</SettingsSectionHeader>
|
||||
<SettingsSectionBody>
|
||||
<div className="flex flex-col gap-y-6 pb-20">
|
||||
<PolicyAccessRulesIntro
|
||||
rulesEnabled={Boolean(rulesEnabled)}
|
||||
onRulesEnabledChange={(val) => {
|
||||
form.setValue("applyRules", val);
|
||||
}}
|
||||
/>
|
||||
|
||||
{rulesEnabled && (
|
||||
<>
|
||||
<PolicyAccessRulesTable
|
||||
rules={rules}
|
||||
onRulesChange={handleRulesChange}
|
||||
updateRule={updateRule}
|
||||
removeRule={removeRule}
|
||||
isMaxmindAvailable={isMaxmindAvailable}
|
||||
isMaxmindAsnAvailable={isMaxmindAsnAvailable}
|
||||
includeRegionMatch={false}
|
||||
emptyStateAction={addRuleButton}
|
||||
/>
|
||||
{hasRules && addRuleButton}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</SettingsSectionBody>
|
||||
</SettingsSection>
|
||||
);
|
||||
}
|
||||
@@ -10,26 +10,27 @@ import { orgQueries } from "@app/lib/queries";
|
||||
import { build } from "@server/build";
|
||||
import { tierMatrix } from "@server/lib/billing/tierMatrix";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
import { useMemo } from "react";
|
||||
import { HorizontalTabs } from "@app/components/HorizontalTabs";
|
||||
import { EditPolicyNameSectionForm } from "./EditPolicyNameSectionForm";
|
||||
import { PolicyAuthStackSection } from "./PolicyAuthStackSection";
|
||||
import { PolicyAccessRulesSection } from "./PolicyAccessRulesSection";
|
||||
|
||||
export type EditPolicyFormSection = "general" | "authentication" | "rules";
|
||||
|
||||
export type EditPolicyFormProps = {
|
||||
hidePolicyNameForm?: boolean;
|
||||
readonly?: boolean;
|
||||
resourceId?: number;
|
||||
section?: EditPolicyFormSection;
|
||||
};
|
||||
|
||||
export function EditPolicyForm({
|
||||
hidePolicyNameForm,
|
||||
readonly,
|
||||
resourceId
|
||||
resourceId,
|
||||
section
|
||||
}: EditPolicyFormProps) {
|
||||
const t = useTranslations();
|
||||
const { org } = useOrgContext();
|
||||
const { env } = useEnvContext();
|
||||
const { isPaidUser } = usePaidStatus();
|
||||
@@ -37,7 +38,6 @@ export function EditPolicyForm({
|
||||
// In overlay mode (resourceId provided), policy-level sections are locked.
|
||||
// Rules and users/roles sections handle their own hybrid logic via resourceId.
|
||||
const isOverlay = resourceId !== undefined;
|
||||
const showTabs = !hidePolicyNameForm && !isOverlay;
|
||||
|
||||
const isMaxmindAvailable = !!(
|
||||
env.server.maxmind_db_path && env.server.maxmind_db_path.length > 0
|
||||
@@ -92,22 +92,16 @@ export function EditPolicyForm({
|
||||
/>
|
||||
);
|
||||
|
||||
if (showTabs) {
|
||||
return (
|
||||
<HorizontalTabs
|
||||
clientSide
|
||||
defaultTab={0}
|
||||
items={[
|
||||
{ title: t("general"), href: "#" },
|
||||
{ title: t("authentication"), href: "#" },
|
||||
{ title: t("policyAccessRulesTitle"), href: "#" }
|
||||
]}
|
||||
>
|
||||
<EditPolicyNameSectionForm readonly={readonly} />
|
||||
{authSection}
|
||||
{rulesSection}
|
||||
</HorizontalTabs>
|
||||
);
|
||||
if (section === "general") {
|
||||
return <EditPolicyNameSectionForm readonly={readonly} />;
|
||||
}
|
||||
|
||||
if (section === "authentication") {
|
||||
return authSection;
|
||||
}
|
||||
|
||||
if (section === "rules") {
|
||||
return rulesSection;
|
||||
}
|
||||
|
||||
return (
|
||||
|
||||
@@ -109,7 +109,7 @@ export function EditPolicyNameSectionForm({
|
||||
|
||||
if (payload.niceId && payload.niceId !== policy.niceId) {
|
||||
router.replace(
|
||||
`/${org.org.orgId}/settings/policies/resources/public/${payload.niceId}`
|
||||
`/${org.org.orgId}/settings/policies/resources/public/${payload.niceId}/general`
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -28,7 +28,8 @@ import {
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
useTransition
|
||||
useTransition,
|
||||
type ReactNode
|
||||
} from "react";
|
||||
import { UseFormReturn, useForm, useWatch } from "react-hook-form";
|
||||
import { useResourcePolicyContext } from "@app/providers/ResourcePolicyProvider";
|
||||
@@ -38,11 +39,12 @@ import { resourceQueries } from "@app/lib/queries";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import type { AxiosResponse } from "axios";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { CreatePolicyRulesSectionForm } from "./CreatePolicyRulesSectionForm";
|
||||
import { PolicyAccessRulesIntro } from "./PolicyAccessRulesIntro";
|
||||
import { PolicyAccessRulesTable } from "./PolicyAccessRulesTable";
|
||||
import { SharedPolicyResourceNotice } from "./SharedPolicyResourceNotice";
|
||||
import {
|
||||
createEmptyRule,
|
||||
prependEmptyRule,
|
||||
type PolicyAccessRule
|
||||
} from "./policy-access-rule-utils";
|
||||
|
||||
@@ -74,6 +76,143 @@ export function PolicyAccessRulesSection(props: PolicyAccessRulesSectionProps) {
|
||||
return <PolicyAccessRulesSectionEdit {...props} />;
|
||||
}
|
||||
|
||||
type PolicyAccessRulesSectionLayoutProps = {
|
||||
rulesEnabled: boolean;
|
||||
onRulesEnabledChange: (enabled: boolean) => void;
|
||||
disableToggle?: boolean;
|
||||
rules: PolicyAccessRule[];
|
||||
onRulesChange: (rules: PolicyAccessRule[]) => void;
|
||||
updateRule: (ruleId: number, data: Partial<PolicyAccessRule>) => void;
|
||||
removeRule: (ruleId: number) => void;
|
||||
readonly?: boolean;
|
||||
isMaxmindAvailable: boolean;
|
||||
isMaxmindAsnAvailable: boolean;
|
||||
resourceOverlayMode?: boolean;
|
||||
footer?: ReactNode;
|
||||
};
|
||||
|
||||
function PolicyAccessRulesSectionLayout({
|
||||
rulesEnabled,
|
||||
onRulesEnabledChange,
|
||||
disableToggle,
|
||||
rules,
|
||||
onRulesChange,
|
||||
updateRule,
|
||||
removeRule,
|
||||
readonly,
|
||||
isMaxmindAvailable,
|
||||
isMaxmindAsnAvailable,
|
||||
resourceOverlayMode,
|
||||
footer
|
||||
}: PolicyAccessRulesSectionLayoutProps) {
|
||||
const t = useTranslations();
|
||||
|
||||
const addEmptyRule = useCallback(() => {
|
||||
if (resourceOverlayMode) {
|
||||
onRulesChange(prependEmptyRule(rules));
|
||||
return;
|
||||
}
|
||||
onRulesChange([...rules, createEmptyRule(rules)]);
|
||||
}, [rules, onRulesChange, resourceOverlayMode]);
|
||||
|
||||
const addRuleButton = (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
disabled={readonly}
|
||||
onClick={addEmptyRule}
|
||||
>
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
{t("ruleSubmit")}
|
||||
</Button>
|
||||
);
|
||||
|
||||
const hasRules = rules.length > 0;
|
||||
|
||||
return (
|
||||
<SettingsSection>
|
||||
<SettingsSectionHeader>
|
||||
<SettingsSectionTitle>
|
||||
{t("policyAccessRulesTitle")}
|
||||
</SettingsSectionTitle>
|
||||
<SettingsSectionDescription>
|
||||
{t("rulesResourceDescription")}
|
||||
</SettingsSectionDescription>
|
||||
</SettingsSectionHeader>
|
||||
<SettingsSectionBody>
|
||||
<div className="space-y-4">
|
||||
{resourceOverlayMode && (
|
||||
<SharedPolicyResourceNotice section="rules" />
|
||||
)}
|
||||
<PolicyAccessRulesIntro
|
||||
rulesEnabled={rulesEnabled}
|
||||
onRulesEnabledChange={onRulesEnabledChange}
|
||||
disableToggle={disableToggle}
|
||||
/>
|
||||
|
||||
{rulesEnabled && (
|
||||
<>
|
||||
<PolicyAccessRulesTable
|
||||
rules={rules}
|
||||
onRulesChange={onRulesChange}
|
||||
updateRule={updateRule}
|
||||
removeRule={removeRule}
|
||||
readonly={readonly}
|
||||
isMaxmindAvailable={isMaxmindAvailable}
|
||||
isMaxmindAsnAvailable={isMaxmindAsnAvailable}
|
||||
includeRegionMatch
|
||||
markUpdatedOnReorder
|
||||
resourceOverlayMode={resourceOverlayMode}
|
||||
emptyStateAction={addRuleButton}
|
||||
/>
|
||||
{hasRules && addRuleButton}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</SettingsSectionBody>
|
||||
{footer}
|
||||
</SettingsSection>
|
||||
);
|
||||
}
|
||||
|
||||
function usePolicyAccessRulesFormSync(
|
||||
form: UseFormReturn<{
|
||||
applyRules: boolean;
|
||||
rules: PolicyFormValues["rules"];
|
||||
}>
|
||||
) {
|
||||
const syncFormRules = useCallback(
|
||||
(updatedRules: PolicyAccessRule[]) => {
|
||||
form.setValue(
|
||||
"rules",
|
||||
updatedRules.map(
|
||||
({ action, match, value, priority, enabled }) => ({
|
||||
action,
|
||||
match,
|
||||
value,
|
||||
priority,
|
||||
enabled
|
||||
})
|
||||
)
|
||||
);
|
||||
},
|
||||
[form]
|
||||
);
|
||||
|
||||
const updateRulesState = useCallback(
|
||||
(
|
||||
setRules: React.Dispatch<React.SetStateAction<PolicyAccessRule[]>>,
|
||||
updatedRules: PolicyAccessRule[]
|
||||
) => {
|
||||
setRules(updatedRules);
|
||||
syncFormRules(updatedRules);
|
||||
},
|
||||
[syncFormRules]
|
||||
);
|
||||
|
||||
return { syncFormRules, updateRulesState };
|
||||
}
|
||||
|
||||
function PolicyAccessRulesSectionEdit({
|
||||
isMaxmindAvailable,
|
||||
isMaxmindAsnAvailable,
|
||||
@@ -119,6 +258,8 @@ function PolicyAccessRulesSectionEdit({
|
||||
policy.rules.map((r) => ({ ...r, fromPolicy: isResourceOverlay }))
|
||||
);
|
||||
|
||||
const { updateRulesState } = usePolicyAccessRulesFormSync(form);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isResourceOverlay || resourceRulesInitialized) return;
|
||||
if (!resourceRulesData) return;
|
||||
@@ -148,30 +289,13 @@ function PolicyAccessRulesSectionEdit({
|
||||
policy.rules
|
||||
]);
|
||||
|
||||
const syncFormRules = useCallback(
|
||||
const handleRulesChange = useCallback(
|
||||
(updatedRules: PolicyAccessRule[]) => {
|
||||
form.setValue(
|
||||
"rules",
|
||||
updatedRules.map(
|
||||
({ action, match, value, priority, enabled }) => ({
|
||||
action,
|
||||
match,
|
||||
value,
|
||||
priority,
|
||||
enabled
|
||||
})
|
||||
)
|
||||
);
|
||||
updateRulesState(setRules, updatedRules);
|
||||
},
|
||||
[form]
|
||||
[updateRulesState]
|
||||
);
|
||||
|
||||
const addEmptyRule = useCallback(() => {
|
||||
const updatedRules = [...rules, createEmptyRule(rules)];
|
||||
setRules(updatedRules);
|
||||
syncFormRules(updatedRules);
|
||||
}, [rules, syncFormRules]);
|
||||
|
||||
const removeRule = useCallback(
|
||||
function removeRule(ruleId: number) {
|
||||
const rule = rules.find((r) => r.ruleId === ruleId);
|
||||
@@ -179,32 +303,22 @@ function PolicyAccessRulesSectionEdit({
|
||||
if (isResourceOverlay && !rule.new) {
|
||||
deletedResourceRuleIdsRef.current.add(ruleId);
|
||||
}
|
||||
const updatedRules = rules.filter((rule) => rule.ruleId !== ruleId);
|
||||
setRules(updatedRules);
|
||||
syncFormRules(updatedRules);
|
||||
handleRulesChange(rules.filter((rule) => rule.ruleId !== ruleId));
|
||||
},
|
||||
[rules, syncFormRules, isResourceOverlay]
|
||||
[rules, handleRulesChange, isResourceOverlay]
|
||||
);
|
||||
|
||||
const updateRule = useCallback(
|
||||
function updateRule(ruleId: number, data: Partial<PolicyAccessRule>) {
|
||||
const updatedRules = rules.map((rule) =>
|
||||
rule.ruleId === ruleId
|
||||
? { ...rule, ...data, updated: true }
|
||||
: rule
|
||||
handleRulesChange(
|
||||
rules.map((rule) =>
|
||||
rule.ruleId === ruleId
|
||||
? { ...rule, ...data, updated: true }
|
||||
: rule
|
||||
)
|
||||
);
|
||||
setRules(updatedRules);
|
||||
syncFormRules(updatedRules);
|
||||
},
|
||||
[rules, syncFormRules]
|
||||
);
|
||||
|
||||
const handleRulesChange = useCallback(
|
||||
(updatedRules: PolicyAccessRule[]) => {
|
||||
setRules(updatedRules);
|
||||
syncFormRules(updatedRules);
|
||||
},
|
||||
[syncFormRules]
|
||||
[rules, handleRulesChange]
|
||||
);
|
||||
|
||||
const [isPending, startTransition] = useTransition();
|
||||
@@ -213,7 +327,10 @@ function PolicyAccessRulesSectionEdit({
|
||||
if (readonly) return;
|
||||
|
||||
const applyRules = form.getValues("applyRules") ?? false;
|
||||
const rulesPayload = rules.map(
|
||||
const rulesToValidate = isResourceOverlay
|
||||
? rules.filter((rule) => !rule.fromPolicy)
|
||||
: rules;
|
||||
const rulesPayload = rulesToValidate.map(
|
||||
({ action, match, value, priority, enabled }) => ({
|
||||
action,
|
||||
match,
|
||||
@@ -331,80 +448,112 @@ function PolicyAccessRulesSectionEdit({
|
||||
}
|
||||
}
|
||||
|
||||
const addRuleButton = (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
disabled={readonly}
|
||||
onClick={addEmptyRule}
|
||||
>
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
{t("ruleSubmit")}
|
||||
</Button>
|
||||
);
|
||||
|
||||
const hasRules = rules.length > 0;
|
||||
|
||||
return (
|
||||
<SettingsSection>
|
||||
<SettingsSectionHeader>
|
||||
<SettingsSectionTitle>
|
||||
{t("policyAccessRulesTitle")}
|
||||
</SettingsSectionTitle>
|
||||
<SettingsSectionDescription>
|
||||
{t("rulesResourceDescription")}
|
||||
</SettingsSectionDescription>
|
||||
</SettingsSectionHeader>
|
||||
<SettingsSectionBody>
|
||||
<div className="space-y-6">
|
||||
<PolicyAccessRulesIntro
|
||||
rulesEnabled={Boolean(rulesEnabled)}
|
||||
onRulesEnabledChange={(val) => {
|
||||
form.setValue("applyRules", val);
|
||||
}}
|
||||
disableToggle={readonly || isResourceOverlay}
|
||||
/>
|
||||
|
||||
{rulesEnabled && (
|
||||
<>
|
||||
<PolicyAccessRulesTable
|
||||
rules={rules}
|
||||
onRulesChange={handleRulesChange}
|
||||
updateRule={updateRule}
|
||||
removeRule={removeRule}
|
||||
readonly={readonly}
|
||||
isMaxmindAvailable={isMaxmindAvailable}
|
||||
isMaxmindAsnAvailable={isMaxmindAsnAvailable}
|
||||
includeRegionMatch
|
||||
markUpdatedOnReorder
|
||||
emptyStateAction={addRuleButton}
|
||||
/>
|
||||
{hasRules && addRuleButton}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</SettingsSectionBody>
|
||||
<SettingsSectionFooter>
|
||||
<Button
|
||||
onClick={() => startTransition(() => saveRules())}
|
||||
loading={isPending}
|
||||
disabled={readonly || isPending}
|
||||
>
|
||||
{t("saveSettings")}
|
||||
</Button>
|
||||
</SettingsSectionFooter>
|
||||
</SettingsSection>
|
||||
<PolicyAccessRulesSectionLayout
|
||||
rulesEnabled={Boolean(rulesEnabled)}
|
||||
onRulesEnabledChange={(val) => {
|
||||
form.setValue("applyRules", val);
|
||||
}}
|
||||
disableToggle={readonly || isResourceOverlay}
|
||||
rules={rules}
|
||||
onRulesChange={handleRulesChange}
|
||||
updateRule={updateRule}
|
||||
removeRule={removeRule}
|
||||
readonly={readonly}
|
||||
isMaxmindAvailable={isMaxmindAvailable}
|
||||
isMaxmindAsnAvailable={isMaxmindAsnAvailable}
|
||||
resourceOverlayMode={isResourceOverlay}
|
||||
footer={
|
||||
<SettingsSectionFooter>
|
||||
<Button
|
||||
onClick={() => startTransition(() => saveRules())}
|
||||
loading={isPending}
|
||||
disabled={readonly || isPending}
|
||||
>
|
||||
{t("saveSettings")}
|
||||
</Button>
|
||||
</SettingsSectionFooter>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function PolicyAccessRulesSectionCreate({
|
||||
form,
|
||||
form: parentForm,
|
||||
isMaxmindAvailable,
|
||||
isMaxmindAsnAvailable
|
||||
}: PolicyAccessRulesSectionCreateProps) {
|
||||
const t = useTranslations();
|
||||
const [rules, setRules] = useState<PolicyAccessRule[]>([]);
|
||||
|
||||
const rulesFormSchema = useMemo(
|
||||
() => createPolicyRulesSectionSchema(t),
|
||||
[t]
|
||||
);
|
||||
|
||||
const form = useForm({
|
||||
resolver: zodResolver(rulesFormSchema),
|
||||
defaultValues: {
|
||||
applyRules: false,
|
||||
rules: []
|
||||
}
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const subscription = form.watch((values) => {
|
||||
parentForm.setValue("applyRules", values.applyRules as boolean);
|
||||
parentForm.setValue(
|
||||
"rules",
|
||||
values.rules as PolicyFormValues["rules"]
|
||||
);
|
||||
});
|
||||
return () => subscription.unsubscribe();
|
||||
}, [form, parentForm]);
|
||||
|
||||
const rulesEnabled = useWatch({
|
||||
control: form.control,
|
||||
name: "applyRules"
|
||||
});
|
||||
|
||||
const { updateRulesState } = usePolicyAccessRulesFormSync(form);
|
||||
|
||||
const handleRulesChange = useCallback(
|
||||
(updatedRules: PolicyAccessRule[]) => {
|
||||
updateRulesState(setRules, updatedRules);
|
||||
},
|
||||
[updateRulesState]
|
||||
);
|
||||
|
||||
const removeRule = useCallback(
|
||||
function removeRule(ruleId: number) {
|
||||
handleRulesChange(rules.filter((rule) => rule.ruleId !== ruleId));
|
||||
},
|
||||
[rules, handleRulesChange]
|
||||
);
|
||||
|
||||
const updateRule = useCallback(
|
||||
function updateRule(ruleId: number, data: Partial<PolicyAccessRule>) {
|
||||
handleRulesChange(
|
||||
rules.map((rule) =>
|
||||
rule.ruleId === ruleId
|
||||
? { ...rule, ...data, updated: true }
|
||||
: rule
|
||||
)
|
||||
);
|
||||
},
|
||||
[rules, handleRulesChange]
|
||||
);
|
||||
|
||||
return (
|
||||
<CreatePolicyRulesSectionForm
|
||||
form={form}
|
||||
<PolicyAccessRulesSectionLayout
|
||||
rulesEnabled={Boolean(rulesEnabled)}
|
||||
onRulesEnabledChange={(val) => {
|
||||
form.setValue("applyRules", val);
|
||||
}}
|
||||
rules={rules}
|
||||
onRulesChange={handleRulesChange}
|
||||
updateRule={updateRule}
|
||||
removeRule={removeRule}
|
||||
isMaxmindAvailable={isMaxmindAvailable}
|
||||
isMaxmindAsnAvailable={isMaxmindAsnAvailable}
|
||||
/>
|
||||
|
||||
@@ -66,8 +66,12 @@ import {
|
||||
validatePolicyRuleValue
|
||||
} from "./policy-access-rule-validation";
|
||||
import {
|
||||
buildDisplayPrioritiesForResourceOverlay,
|
||||
reorderPolicyRules,
|
||||
reorderResourceOverlayRules,
|
||||
setResourceRuleDisplayPriority,
|
||||
sortPolicyRulesByPriority,
|
||||
sortPolicyRulesForResourceOverlay,
|
||||
type PolicyAccessRule
|
||||
} from "./policy-access-rule-utils";
|
||||
|
||||
@@ -82,6 +86,7 @@ export type PolicyAccessRulesTableProps = {
|
||||
readonly?: boolean;
|
||||
includeRegionMatch?: boolean;
|
||||
markUpdatedOnReorder?: boolean;
|
||||
resourceOverlayMode?: boolean;
|
||||
isRuleDraggable?: (rule: PolicyAccessRule) => boolean;
|
||||
isRuleLocked?: (rule: PolicyAccessRule) => boolean;
|
||||
};
|
||||
@@ -97,7 +102,7 @@ function getColumnClassName(columnId: string) {
|
||||
return "w-24 max-w-24";
|
||||
}
|
||||
if (columnId === "action") {
|
||||
return "w-40 max-w-40";
|
||||
return "w-42 max-w-42";
|
||||
}
|
||||
if (columnId === "match") {
|
||||
return "w-36 max-w-36";
|
||||
@@ -116,6 +121,7 @@ export function PolicyAccessRulesTable({
|
||||
readonly = false,
|
||||
includeRegionMatch = false,
|
||||
markUpdatedOnReorder = false,
|
||||
resourceOverlayMode = false,
|
||||
isRuleDraggable: isRuleDraggableProp,
|
||||
isRuleLocked: isRuleLockedProp
|
||||
}: PolicyAccessRulesTableProps) {
|
||||
@@ -140,12 +146,37 @@ export function PolicyAccessRulesTable({
|
||||
);
|
||||
|
||||
const sortedRules = useMemo(
|
||||
() => sortPolicyRulesByPriority(rules),
|
||||
() =>
|
||||
resourceOverlayMode
|
||||
? sortPolicyRulesForResourceOverlay(rules)
|
||||
: sortPolicyRulesByPriority(rules),
|
||||
[rules, resourceOverlayMode]
|
||||
);
|
||||
|
||||
const displayPriorities = useMemo(
|
||||
() =>
|
||||
resourceOverlayMode
|
||||
? buildDisplayPrioritiesForResourceOverlay(rules)
|
||||
: null,
|
||||
[rules, resourceOverlayMode]
|
||||
);
|
||||
|
||||
const resourceRuleCount = useMemo(
|
||||
() => rules.filter((rule) => !rule.fromPolicy).length,
|
||||
[rules]
|
||||
);
|
||||
|
||||
const handleReorder = useCallback(
|
||||
(fromRuleId: number, toRuleId: number) => {
|
||||
if (resourceOverlayMode) {
|
||||
onRulesChange(
|
||||
reorderResourceOverlayRules(rules, fromRuleId, toRuleId, {
|
||||
markUpdated: markUpdatedOnReorder
|
||||
})
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const fromIndex = sortedRules.findIndex(
|
||||
(rule) => rule.ruleId === fromRuleId
|
||||
);
|
||||
@@ -164,7 +195,13 @@ export function PolicyAccessRulesTable({
|
||||
);
|
||||
onRulesChange(reordered);
|
||||
},
|
||||
[sortedRules, onRulesChange, markUpdatedOnReorder]
|
||||
[
|
||||
rules,
|
||||
sortedRules,
|
||||
onRulesChange,
|
||||
markUpdatedOnReorder,
|
||||
resourceOverlayMode
|
||||
]
|
||||
);
|
||||
|
||||
const handleDragStart = useCallback((ruleId: number, e: DragEvent) => {
|
||||
@@ -228,60 +265,132 @@ export function PolicyAccessRulesTable({
|
||||
maxSize: 96,
|
||||
header: ({ column }) => (
|
||||
<div className="p-3">
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="h-auto p-0 font-medium text-muted-foreground hover:bg-transparent"
|
||||
onClick={() =>
|
||||
column.toggleSorting(
|
||||
column.getIsSorted() === "asc"
|
||||
)
|
||||
}
|
||||
>
|
||||
{t("rulesPriority")}
|
||||
<ArrowUpDown className="ml-1 h-3 w-3" />
|
||||
</Button>
|
||||
{resourceOverlayMode ? (
|
||||
<span className="font-medium text-muted-foreground">
|
||||
{t("rulesPriority")}
|
||||
</span>
|
||||
) : (
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="h-auto p-0 font-medium text-muted-foreground hover:bg-transparent"
|
||||
onClick={() =>
|
||||
column.toggleSorting(
|
||||
column.getIsSorted() === "asc"
|
||||
)
|
||||
}
|
||||
>
|
||||
{t("rulesPriority")}
|
||||
<ArrowUpDown className="ml-1 h-3 w-3" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<Input
|
||||
defaultValue={row.original.priority}
|
||||
className="w-full min-w-0"
|
||||
type="number"
|
||||
disabled={readonly || isRuleLocked(row.original)}
|
||||
onClick={(e) => e.currentTarget.focus()}
|
||||
onBlur={(e) => {
|
||||
const validated = validatePolicyRulePriority(
|
||||
t,
|
||||
e.target.value
|
||||
);
|
||||
if (!validated.success) {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
...validated.toast
|
||||
cell: ({ row }) => {
|
||||
const displayPriority = resourceOverlayMode
|
||||
? (displayPriorities?.get(row.original.ruleId) ??
|
||||
row.original.priority)
|
||||
: row.original.priority;
|
||||
|
||||
return (
|
||||
<Input
|
||||
key={`${row.original.ruleId}-${displayPriority}`}
|
||||
defaultValue={displayPriority}
|
||||
className="w-full min-w-0"
|
||||
type="number"
|
||||
disabled={readonly || isRuleLocked(row.original)}
|
||||
onClick={(e) => e.currentTarget.focus()}
|
||||
onBlur={(e) => {
|
||||
const validated = validatePolicyRulePriority(
|
||||
t,
|
||||
e.target.value
|
||||
);
|
||||
if (!validated.success) {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
...validated.toast
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (resourceOverlayMode) {
|
||||
if (
|
||||
validated.data > resourceRuleCount ||
|
||||
validated.data < 1
|
||||
) {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: t(
|
||||
"rulesErrorInvalidPriority"
|
||||
),
|
||||
description: t(
|
||||
"rulesErrorInvalidPriorityDescription"
|
||||
)
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const duplicateDisplayPriority = rules.some(
|
||||
(rule) =>
|
||||
!rule.fromPolicy &&
|
||||
rule.ruleId !==
|
||||
row.original.ruleId &&
|
||||
displayPriorities?.get(
|
||||
rule.ruleId
|
||||
) === validated.data
|
||||
);
|
||||
if (duplicateDisplayPriority) {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: t(
|
||||
"rulesErrorDuplicatePriority"
|
||||
),
|
||||
description: t(
|
||||
"rulesErrorDuplicatePriorityDescription"
|
||||
)
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (validated.data === displayPriority) {
|
||||
return;
|
||||
}
|
||||
|
||||
onRulesChange(
|
||||
setResourceRuleDisplayPriority(
|
||||
rules,
|
||||
row.original.ruleId,
|
||||
validated.data,
|
||||
{
|
||||
markUpdated:
|
||||
markUpdatedOnReorder
|
||||
}
|
||||
)
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const duplicatePriority = rules.some(
|
||||
(rule) =>
|
||||
rule.ruleId !== row.original.ruleId &&
|
||||
rule.priority === validated.data
|
||||
);
|
||||
if (duplicatePriority) {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: t("rulesErrorDuplicatePriority"),
|
||||
description: t(
|
||||
"rulesErrorDuplicatePriorityDescription"
|
||||
)
|
||||
});
|
||||
return;
|
||||
}
|
||||
updateRule(row.original.ruleId, {
|
||||
priority: validated.data
|
||||
});
|
||||
return;
|
||||
}
|
||||
const duplicatePriority = rules.some(
|
||||
(rule) =>
|
||||
rule.ruleId !== row.original.ruleId &&
|
||||
rule.priority === validated.data
|
||||
);
|
||||
if (duplicatePriority) {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: t("rulesErrorDuplicatePriority"),
|
||||
description: t(
|
||||
"rulesErrorDuplicatePriorityDescription"
|
||||
)
|
||||
});
|
||||
return;
|
||||
}
|
||||
updateRule(row.original.ruleId, {
|
||||
priority: validated.data
|
||||
});
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
},
|
||||
{
|
||||
accessorKey: "action",
|
||||
@@ -683,13 +792,7 @@ export function PolicyAccessRulesTable({
|
||||
cell: ({ row }) => (
|
||||
<div className="flex items-center justify-end space-x-2">
|
||||
{isRuleLocked(row.original) ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
disabled
|
||||
className="cursor-not-allowed"
|
||||
>
|
||||
<LockIcon className="h-4 w-4" />
|
||||
</Button>
|
||||
<LockIcon className="h-4 w-4 text-muted-foreground" />
|
||||
) : (
|
||||
<Button
|
||||
variant="outline"
|
||||
@@ -711,9 +814,14 @@ export function PolicyAccessRulesTable({
|
||||
isMaxmindAsnAvailable,
|
||||
includeRegionMatch,
|
||||
updateRule,
|
||||
onRulesChange,
|
||||
removeRule,
|
||||
readonly,
|
||||
rules,
|
||||
resourceOverlayMode,
|
||||
displayPriorities,
|
||||
resourceRuleCount,
|
||||
markUpdatedOnReorder,
|
||||
isRuleDraggable,
|
||||
isRuleLocked,
|
||||
handleDragStart,
|
||||
@@ -775,7 +883,8 @@ export function PolicyAccessRulesTable({
|
||||
e.preventDefault();
|
||||
if (
|
||||
draggedRuleId !== null &&
|
||||
draggedRuleId !== rule.ruleId
|
||||
draggedRuleId !== rule.ruleId &&
|
||||
isRuleDraggable(rule)
|
||||
) {
|
||||
handleReorder(
|
||||
draggedRuleId,
|
||||
@@ -789,7 +898,7 @@ export function PolicyAccessRulesTable({
|
||||
draggedRuleId === rule.ruleId &&
|
||||
"opacity-50",
|
||||
dragOverRuleId === rule.ruleId &&
|
||||
"border-t-2 border-primary"
|
||||
"border-t border-primary"
|
||||
)}
|
||||
>
|
||||
{row.getVisibleCells().map((cell) => {
|
||||
|
||||
@@ -66,23 +66,6 @@ export function PolicyAuthMethodRow({
|
||||
>
|
||||
<div className="flex flex-1 min-w-0 flex-col gap-0.5">
|
||||
<div className="flex items-center gap-2">
|
||||
<span
|
||||
className="shrink-0 flex items-center"
|
||||
role="img"
|
||||
aria-label={
|
||||
active
|
||||
? t("policyAuthMethodActive")
|
||||
: t("policyAuthMethodOff")
|
||||
}
|
||||
>
|
||||
<div
|
||||
className={
|
||||
active
|
||||
? "w-2 h-2 bg-green-500 rounded-full"
|
||||
: "w-2 h-2 bg-neutral-500 rounded-full"
|
||||
}
|
||||
/>
|
||||
</span>
|
||||
<span className="text-sm font-medium">{title}</span>
|
||||
</div>
|
||||
<p className="truncate text-sm text-muted-foreground">
|
||||
|
||||
@@ -48,20 +48,43 @@ import {
|
||||
getPasscodeSummary,
|
||||
getPincodeSummary
|
||||
} from "./policy-auth-summaries";
|
||||
import { SharedPolicyResourceNotice } from "./SharedPolicyResourceNotice";
|
||||
import z from "zod";
|
||||
|
||||
type OverlaySelectedRole = SelectedRole & { isAdmin: boolean };
|
||||
|
||||
const authStackSchema = createPolicySchema.pick({
|
||||
sso: true,
|
||||
skipToIdpId: true,
|
||||
roles: true,
|
||||
users: true,
|
||||
password: true,
|
||||
pincode: true,
|
||||
headerAuth: true,
|
||||
emailWhitelistEnabled: true,
|
||||
emails: true
|
||||
});
|
||||
// Edit mode keeps placeholder values for configured methods; only validate on save when changed.
|
||||
const authStackEditSchema = createPolicySchema
|
||||
.pick({
|
||||
sso: true,
|
||||
skipToIdpId: true,
|
||||
roles: true,
|
||||
users: true,
|
||||
emailWhitelistEnabled: true,
|
||||
emails: true
|
||||
})
|
||||
.extend({
|
||||
password: z
|
||||
.object({
|
||||
password: z.string()
|
||||
})
|
||||
.nullable()
|
||||
.optional(),
|
||||
pincode: z
|
||||
.object({
|
||||
pincode: z.string()
|
||||
})
|
||||
.nullable()
|
||||
.optional(),
|
||||
headerAuth: z
|
||||
.object({
|
||||
user: z.string(),
|
||||
password: z.string(),
|
||||
extendedCompatibility: z.boolean().default(true)
|
||||
})
|
||||
.nullable()
|
||||
.optional()
|
||||
});
|
||||
|
||||
export type PolicyAuthStackSectionEditProps = {
|
||||
orgId: string;
|
||||
@@ -182,14 +205,14 @@ export function PolicyAuthStackSectionEdit({
|
||||
]);
|
||||
|
||||
const form = useForm({
|
||||
resolver: zodResolver(authStackSchema),
|
||||
resolver: zodResolver(authStackEditSchema),
|
||||
defaultValues: {
|
||||
sso: policy.sso,
|
||||
skipToIdpId: policy.idpId,
|
||||
roles: policyRoleItems,
|
||||
users: policyUserItems,
|
||||
password: policy.passwordId ? { password: "" } : null,
|
||||
pincode: policy.pincodeId ? { pincode: "" } : null,
|
||||
password: null,
|
||||
pincode: null,
|
||||
headerAuth: policy.headerAuth
|
||||
? {
|
||||
user: "",
|
||||
@@ -247,7 +270,14 @@ export function PolicyAuthStackSectionEdit({
|
||||
}
|
||||
|
||||
const isValid = await form.trigger();
|
||||
if (!isValid) return;
|
||||
if (!isValid) {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: t("policyErrorUpdate"),
|
||||
description: t("policyErrorUpdateMessageDescription")
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const payload = form.getValues();
|
||||
const requests: Array<Promise<AxiosResponse<{}> | void>> = [];
|
||||
@@ -441,184 +471,219 @@ export function PolicyAuthStackSectionEdit({
|
||||
</SettingsSectionDescription>
|
||||
</SettingsSectionHeader>
|
||||
<SettingsSectionBody>
|
||||
<div className="w-full md:w-1/2">
|
||||
<PolicyAuthSsoSection
|
||||
sso={Boolean(sso)}
|
||||
onSsoChange={(active) =>
|
||||
form.setValue("sso", active)
|
||||
}
|
||||
skipToIdpId={skipToIdpId}
|
||||
onSkipToIdpChange={(id) =>
|
||||
form.setValue("skipToIdpId", id)
|
||||
}
|
||||
allIdps={allIdps}
|
||||
disabled={authReadonly}
|
||||
idpDisabled={authReadonly}
|
||||
rolesEditor={
|
||||
isResourceOverlay ? (
|
||||
<RolesSelector
|
||||
orgId={orgId}
|
||||
selectedRoles={overlayRoles}
|
||||
onSelectRoles={(selected) =>
|
||||
setCombinedRoles(
|
||||
selected.map((role) => ({
|
||||
...role,
|
||||
isAdmin: Boolean(
|
||||
role.isAdmin
|
||||
<div className="space-y-4">
|
||||
{isResourceOverlay && (
|
||||
<SharedPolicyResourceNotice section="authentication" />
|
||||
)}
|
||||
<div className="w-full md:w-1/2">
|
||||
<PolicyAuthSsoSection
|
||||
sso={Boolean(sso)}
|
||||
onSsoChange={(active) =>
|
||||
form.setValue("sso", active)
|
||||
}
|
||||
skipToIdpId={skipToIdpId}
|
||||
onSkipToIdpChange={(id) =>
|
||||
form.setValue("skipToIdpId", id)
|
||||
}
|
||||
allIdps={allIdps}
|
||||
disabled={authReadonly}
|
||||
idpDisabled={authReadonly}
|
||||
rolesEditor={
|
||||
isResourceOverlay ? (
|
||||
<RolesSelector
|
||||
orgId={orgId}
|
||||
selectedRoles={overlayRoles}
|
||||
onSelectRoles={(selected) =>
|
||||
setCombinedRoles(
|
||||
selected.map(
|
||||
(role) => ({
|
||||
...role,
|
||||
isAdmin:
|
||||
Boolean(
|
||||
role.isAdmin
|
||||
)
|
||||
})
|
||||
)
|
||||
}))
|
||||
)
|
||||
}
|
||||
disabled={isLoading}
|
||||
restrictAdminRole
|
||||
lockedIds={policyRoleLockedIds}
|
||||
/>
|
||||
) : (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="roles"
|
||||
render={({ field }) => (
|
||||
<RolesSelector
|
||||
orgId={orgId}
|
||||
selectedRoles={
|
||||
field.value
|
||||
}
|
||||
onSelectRoles={(
|
||||
selected
|
||||
) =>
|
||||
form.setValue(
|
||||
"roles",
|
||||
selected
|
||||
)
|
||||
}
|
||||
disabled={readonly}
|
||||
restrictAdminRole
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
usersEditor={
|
||||
isResourceOverlay ? (
|
||||
<UsersSelector
|
||||
orgId={orgId}
|
||||
selectedUsers={overlayUsers}
|
||||
onSelectUsers={setCombinedUsers}
|
||||
disabled={isLoading}
|
||||
lockedIds={policyUserLockedIds}
|
||||
/>
|
||||
) : (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="users"
|
||||
render={({ field }) => (
|
||||
<UsersSelector
|
||||
orgId={orgId}
|
||||
selectedUsers={
|
||||
field.value
|
||||
}
|
||||
onSelectUsers={(
|
||||
selected
|
||||
) =>
|
||||
form.setValue(
|
||||
"users",
|
||||
selected
|
||||
)
|
||||
}
|
||||
disabled={readonly}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<SettingsSubsectionHeader>
|
||||
<SettingsSubsectionTitle>
|
||||
{t("policyAuthOtherMethodsTitle")}
|
||||
</SettingsSubsectionTitle>
|
||||
<SettingsSubsectionDescription>
|
||||
{t("policyAuthOtherMethodsDescription")}
|
||||
</SettingsSubsectionDescription>
|
||||
</SettingsSubsectionHeader>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||
<PolicyAuthMethodRow
|
||||
id="pincode"
|
||||
title={t("policyAuthPincodeTitle")}
|
||||
description={t(
|
||||
"policyAuthPincodeDescription"
|
||||
)}
|
||||
summary={getPincodeSummary({ t })}
|
||||
active={pinActive}
|
||||
onConfigure={() =>
|
||||
openMethodEditor("pincode")
|
||||
}
|
||||
onToggle={(active) =>
|
||||
handleToggle("pincode", active, () => {
|
||||
setPinActive(false);
|
||||
form.setValue("pincode", null);
|
||||
})
|
||||
}
|
||||
disabled={authReadonly}
|
||||
/>
|
||||
|
||||
<PolicyAuthMethodRow
|
||||
id="passcode"
|
||||
title={t("policyAuthPasscodeTitle")}
|
||||
description={t(
|
||||
"policyAuthPasscodeDescription"
|
||||
)}
|
||||
summary={getPasscodeSummary({ t })}
|
||||
active={passcodeActive}
|
||||
onConfigure={() =>
|
||||
openMethodEditor("passcode")
|
||||
}
|
||||
onToggle={(active) =>
|
||||
handleToggle("passcode", active, () => {
|
||||
setPasscodeActive(false);
|
||||
form.setValue("password", null);
|
||||
})
|
||||
}
|
||||
disabled={authReadonly}
|
||||
/>
|
||||
|
||||
<PolicyAuthMethodRow
|
||||
id="email"
|
||||
title={t("policyAuthEmailTitle")}
|
||||
description={t(
|
||||
"policyAuthEmailDescription"
|
||||
)}
|
||||
summary={getEmailWhitelistSummary({
|
||||
t,
|
||||
count: emails.length
|
||||
})}
|
||||
active={Boolean(emailWhitelistEnabled)}
|
||||
onConfigure={() =>
|
||||
openMethodEditor("email")
|
||||
}
|
||||
onToggle={(active) =>
|
||||
handleToggle(
|
||||
"email",
|
||||
active,
|
||||
() =>
|
||||
form.setValue(
|
||||
"emailWhitelistEnabled",
|
||||
false
|
||||
),
|
||||
() =>
|
||||
form.setValue(
|
||||
"emailWhitelistEnabled",
|
||||
true
|
||||
)
|
||||
)
|
||||
}
|
||||
disabled={authReadonly || !emailEnabled}
|
||||
/>
|
||||
|
||||
<PolicyAuthMethodRow
|
||||
id="header-auth"
|
||||
title={t("policyAuthHeaderAuthTitle")}
|
||||
description={t(
|
||||
"policyAuthHeaderAuthDescription"
|
||||
)}
|
||||
summary={getHeaderAuthSummary({
|
||||
t,
|
||||
headerName: headerAuth?.user ?? ""
|
||||
})}
|
||||
active={headerAuthActive}
|
||||
onConfigure={() =>
|
||||
openMethodEditor("headerAuth")
|
||||
}
|
||||
onToggle={(active) =>
|
||||
handleToggle(
|
||||
"headerAuth",
|
||||
active,
|
||||
() => {
|
||||
setHeaderAuthActive(false);
|
||||
form.setValue(
|
||||
"headerAuth",
|
||||
null
|
||||
);
|
||||
}
|
||||
disabled={isLoading}
|
||||
restrictAdminRole
|
||||
lockedIds={policyRoleLockedIds}
|
||||
/>
|
||||
) : (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="roles"
|
||||
render={({ field }) => (
|
||||
<RolesSelector
|
||||
orgId={orgId}
|
||||
selectedRoles={field.value}
|
||||
onSelectRoles={(selected) =>
|
||||
form.setValue(
|
||||
"roles",
|
||||
selected
|
||||
)
|
||||
}
|
||||
disabled={readonly}
|
||||
restrictAdminRole
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
usersEditor={
|
||||
isResourceOverlay ? (
|
||||
<UsersSelector
|
||||
orgId={orgId}
|
||||
selectedUsers={overlayUsers}
|
||||
onSelectUsers={setCombinedUsers}
|
||||
disabled={isLoading}
|
||||
lockedIds={policyUserLockedIds}
|
||||
/>
|
||||
) : (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="users"
|
||||
render={({ field }) => (
|
||||
<UsersSelector
|
||||
orgId={orgId}
|
||||
selectedUsers={field.value}
|
||||
onSelectUsers={(selected) =>
|
||||
form.setValue(
|
||||
"users",
|
||||
selected
|
||||
)
|
||||
}
|
||||
disabled={readonly}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<SettingsSubsectionHeader>
|
||||
<SettingsSubsectionTitle>
|
||||
{t("policyAuthOtherMethodsTitle")}
|
||||
</SettingsSubsectionTitle>
|
||||
<SettingsSubsectionDescription>
|
||||
{t("policyAuthOtherMethodsDescription")}
|
||||
</SettingsSubsectionDescription>
|
||||
</SettingsSubsectionHeader>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||
<PolicyAuthMethodRow
|
||||
id="pincode"
|
||||
title={t("policyAuthPincodeTitle")}
|
||||
description={t("policyAuthPincodeDescription")}
|
||||
summary={getPincodeSummary({ t })}
|
||||
active={pinActive}
|
||||
onConfigure={() => openMethodEditor("pincode")}
|
||||
onToggle={(active) =>
|
||||
handleToggle("pincode", active, () => {
|
||||
setPinActive(false);
|
||||
form.setValue("pincode", null);
|
||||
})
|
||||
}
|
||||
disabled={authReadonly}
|
||||
/>
|
||||
|
||||
<PolicyAuthMethodRow
|
||||
id="passcode"
|
||||
title={t("policyAuthPasscodeTitle")}
|
||||
description={t("policyAuthPasscodeDescription")}
|
||||
summary={getPasscodeSummary({ t })}
|
||||
active={passcodeActive}
|
||||
onConfigure={() => openMethodEditor("passcode")}
|
||||
onToggle={(active) =>
|
||||
handleToggle("passcode", active, () => {
|
||||
setPasscodeActive(false);
|
||||
form.setValue("password", null);
|
||||
})
|
||||
}
|
||||
disabled={authReadonly}
|
||||
/>
|
||||
|
||||
<PolicyAuthMethodRow
|
||||
id="email"
|
||||
title={t("policyAuthEmailTitle")}
|
||||
description={t("policyAuthEmailDescription")}
|
||||
summary={getEmailWhitelistSummary({
|
||||
t,
|
||||
count: emails.length
|
||||
})}
|
||||
active={Boolean(emailWhitelistEnabled)}
|
||||
onConfigure={() => openMethodEditor("email")}
|
||||
onToggle={(active) =>
|
||||
handleToggle(
|
||||
"email",
|
||||
active,
|
||||
() =>
|
||||
form.setValue(
|
||||
"emailWhitelistEnabled",
|
||||
false
|
||||
),
|
||||
() =>
|
||||
form.setValue(
|
||||
"emailWhitelistEnabled",
|
||||
true
|
||||
)
|
||||
)
|
||||
}
|
||||
disabled={authReadonly || !emailEnabled}
|
||||
/>
|
||||
|
||||
<PolicyAuthMethodRow
|
||||
id="header-auth"
|
||||
title={t("policyAuthHeaderAuthTitle")}
|
||||
description={t(
|
||||
"policyAuthHeaderAuthDescription"
|
||||
)}
|
||||
summary={getHeaderAuthSummary({
|
||||
t,
|
||||
headerName: headerAuth?.user ?? ""
|
||||
})}
|
||||
active={headerAuthActive}
|
||||
onConfigure={() =>
|
||||
openMethodEditor("headerAuth")
|
||||
}
|
||||
onToggle={(active) =>
|
||||
handleToggle("headerAuth", active, () => {
|
||||
setHeaderAuthActive(false);
|
||||
form.setValue("headerAuth", null);
|
||||
})
|
||||
}
|
||||
disabled={authReadonly}
|
||||
/>
|
||||
)
|
||||
}
|
||||
disabled={authReadonly}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<PincodeCredenza
|
||||
|
||||
47
src/components/resource-policy/ResourcePolicyEditForm.tsx
Normal file
47
src/components/resource-policy/ResourcePolicyEditForm.tsx
Normal file
@@ -0,0 +1,47 @@
|
||||
"use client";
|
||||
|
||||
import { useResourceContext } from "@app/hooks/useResourceContext";
|
||||
import { resourceQueries } from "@app/lib/queries";
|
||||
import { ResourcePolicyProvider } from "@app/providers/ResourcePolicyProvider";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { EditPolicyForm, type EditPolicyFormSection } from "./EditPolicyForm";
|
||||
|
||||
type ResourcePolicyEditFormProps = {
|
||||
section: Extract<EditPolicyFormSection, "authentication" | "rules">;
|
||||
};
|
||||
|
||||
export function ResourcePolicyEditForm({
|
||||
section
|
||||
}: ResourcePolicyEditFormProps) {
|
||||
const { resource } = useResourceContext();
|
||||
|
||||
const { data: policies, isLoading: isLoadingPolicies } = useQuery(
|
||||
resourceQueries.policies({
|
||||
resourceId: resource.resourceId
|
||||
})
|
||||
);
|
||||
|
||||
if (isLoadingPolicies || !policies) {
|
||||
return <></>;
|
||||
}
|
||||
|
||||
if (!policies.sharedPolicy) {
|
||||
return (
|
||||
<ResourcePolicyProvider policy={policies.defaultPolicy}>
|
||||
<EditPolicyForm hidePolicyNameForm section={section} />
|
||||
</ResourcePolicyProvider>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ResourcePolicyProvider
|
||||
policy={policies.sharedPolicy}
|
||||
key={policies.sharedPolicy.resourcePolicyId}
|
||||
>
|
||||
<EditPolicyForm
|
||||
resourceId={resource.resourceId}
|
||||
section={section}
|
||||
/>
|
||||
</ResourcePolicyProvider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
"use client";
|
||||
|
||||
import { Alert, AlertDescription } from "@app/components/ui/alert";
|
||||
import { useOrgContext } from "@app/hooks/useOrgContext";
|
||||
import { useResourcePolicyContext } from "@app/providers/ResourcePolicyProvider";
|
||||
import { InfoIcon } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
type SharedPolicyResourceNoticeProps = {
|
||||
section: "authentication" | "rules";
|
||||
};
|
||||
|
||||
export function SharedPolicyResourceNotice({
|
||||
section
|
||||
}: SharedPolicyResourceNoticeProps) {
|
||||
const t = useTranslations();
|
||||
const { org } = useOrgContext();
|
||||
const { policy } = useResourcePolicyContext();
|
||||
|
||||
const messageKey =
|
||||
section === "authentication"
|
||||
? "resourceSharedPolicyAuthenticationNotice"
|
||||
: "resourceSharedPolicyRulesNotice";
|
||||
|
||||
return (
|
||||
<Alert variant="neutral">
|
||||
<InfoIcon className="h-4 w-4" />
|
||||
<AlertDescription>
|
||||
{t.rich(messageKey, {
|
||||
policyName: policy.name,
|
||||
policyLink: (chunks) => (
|
||||
<Link
|
||||
href={`/${org.org.orgId}/settings/policies/resources/public/${policy.niceId}/${section}`}
|
||||
className="text-primary hover:underline"
|
||||
>
|
||||
{chunks}
|
||||
</Link>
|
||||
)
|
||||
})}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
// ─── Schemas & types ──────────────────────────────────────────────────────────
|
||||
|
||||
import z from "zod";
|
||||
import { POLICY_RULE_MATCH_TYPES } from "./policy-access-rule-validation";
|
||||
|
||||
export const createPolicySchema = z.object({
|
||||
name: z.string().min(1).max(255),
|
||||
@@ -35,7 +36,7 @@ export const createPolicySchema = z.object({
|
||||
.array(
|
||||
z.object({
|
||||
action: z.enum(["ACCEPT", "DROP", "PASS"]),
|
||||
match: z.string(),
|
||||
match: z.enum(POLICY_RULE_MATCH_TYPES),
|
||||
value: z.string(),
|
||||
priority: z.number().int(),
|
||||
enabled: z.boolean()
|
||||
@@ -67,6 +68,7 @@ export {
|
||||
type PolicyAccessRule
|
||||
} from "./policy-access-rule-utils";
|
||||
export {
|
||||
createPolicyRuleMatchSchema,
|
||||
createPolicyRulePrioritySchema,
|
||||
createPolicyRuleSchema,
|
||||
createPolicyRuleValueSchema,
|
||||
@@ -74,8 +76,10 @@ export {
|
||||
createPolicyRulesSectionSchema,
|
||||
createPolicySchemaWithI18n,
|
||||
getPolicyRuleValidationMessage,
|
||||
POLICY_RULE_MATCH_TYPES,
|
||||
validatePolicyRulePriority,
|
||||
validatePolicyRuleValue,
|
||||
validatePolicyRulesForSave,
|
||||
type PolicyRuleMatchType,
|
||||
type RuleValidationToast
|
||||
} from "./policy-access-rule-validation";
|
||||
|
||||
@@ -34,12 +34,96 @@ export function createEmptyRule(
|
||||
};
|
||||
}
|
||||
|
||||
export function prependEmptyRule(
|
||||
rules: PolicyAccessRule[]
|
||||
): PolicyAccessRule[] {
|
||||
const newRule: EmptyRuleDraft = {
|
||||
ruleId: Date.now(),
|
||||
action: "ACCEPT",
|
||||
match: "PATH",
|
||||
value: "",
|
||||
priority: 1,
|
||||
enabled: true,
|
||||
new: true
|
||||
};
|
||||
|
||||
const bumpedRules = rules.map((rule) => {
|
||||
if (rule.fromPolicy) {
|
||||
return rule;
|
||||
}
|
||||
|
||||
const bumped = { ...rule, priority: rule.priority + 1 };
|
||||
if (rule.new) {
|
||||
return bumped;
|
||||
}
|
||||
return { ...bumped, updated: true };
|
||||
});
|
||||
|
||||
return [newRule, ...bumpedRules];
|
||||
}
|
||||
|
||||
export function sortPolicyRulesByPriority<T extends { priority: number }>(
|
||||
rules: T[]
|
||||
): T[] {
|
||||
return [...rules].sort((a, b) => a.priority - b.priority);
|
||||
}
|
||||
|
||||
export function sortPolicyRulesForResourceOverlay<
|
||||
T extends { priority: number; fromPolicy?: boolean }
|
||||
>(rules: T[]): T[] {
|
||||
const resourceRules = rules
|
||||
.filter((rule) => !rule.fromPolicy)
|
||||
.sort((a, b) => a.priority - b.priority);
|
||||
const policyRules = rules
|
||||
.filter((rule) => rule.fromPolicy)
|
||||
.sort((a, b) => a.priority - b.priority);
|
||||
|
||||
return [...resourceRules, ...policyRules];
|
||||
}
|
||||
|
||||
export function buildDisplayPrioritiesForResourceOverlay<
|
||||
T extends { ruleId: number; priority: number; fromPolicy?: boolean }
|
||||
>(rules: T[]): Map<number, number> {
|
||||
const sorted = sortPolicyRulesForResourceOverlay(rules);
|
||||
const displayPriorities = new Map<number, number>();
|
||||
|
||||
sorted.forEach((rule, index) => {
|
||||
displayPriorities.set(rule.ruleId, index + 1);
|
||||
});
|
||||
|
||||
return displayPriorities;
|
||||
}
|
||||
|
||||
export function setResourceRuleDisplayPriority(
|
||||
rules: PolicyAccessRule[],
|
||||
ruleId: number,
|
||||
displayPriority: number,
|
||||
options?: { markUpdated?: boolean }
|
||||
): PolicyAccessRule[] {
|
||||
const sorted = sortPolicyRulesForResourceOverlay(rules);
|
||||
const resourceRules = sorted.filter((rule) => !rule.fromPolicy);
|
||||
const policyRules = sorted.filter((rule) => rule.fromPolicy);
|
||||
|
||||
const fromIndex = resourceRules.findIndex((rule) => rule.ruleId === ruleId);
|
||||
if (fromIndex === -1) {
|
||||
return rules;
|
||||
}
|
||||
|
||||
const targetIndex = Math.max(
|
||||
0,
|
||||
Math.min(displayPriority - 1, resourceRules.length - 1)
|
||||
);
|
||||
|
||||
const reorderedResource = reorderPolicyRules(
|
||||
resourceRules,
|
||||
fromIndex,
|
||||
targetIndex,
|
||||
options
|
||||
);
|
||||
|
||||
return [...reorderedResource, ...policyRules];
|
||||
}
|
||||
|
||||
export function reorderPolicyRules<
|
||||
T extends { priority: number; new?: boolean; updated?: boolean }
|
||||
>(
|
||||
@@ -70,3 +154,40 @@ export function reorderPolicyRules<
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
export function reorderResourceOverlayRules<
|
||||
T extends {
|
||||
ruleId: number;
|
||||
priority: number;
|
||||
fromPolicy?: boolean;
|
||||
new?: boolean;
|
||||
updated?: boolean;
|
||||
}
|
||||
>(
|
||||
rules: T[],
|
||||
fromRuleId: number,
|
||||
toRuleId: number,
|
||||
options?: { markUpdated?: boolean }
|
||||
): T[] {
|
||||
const sorted = sortPolicyRulesForResourceOverlay(rules);
|
||||
const resourceRules = sorted.filter((rule) => !rule.fromPolicy);
|
||||
const policyRules = sorted.filter((rule) => rule.fromPolicy);
|
||||
|
||||
const fromIndex = resourceRules.findIndex(
|
||||
(rule) => rule.ruleId === fromRuleId
|
||||
);
|
||||
const toIndex = resourceRules.findIndex((rule) => rule.ruleId === toRuleId);
|
||||
|
||||
if (fromIndex === -1 || toIndex === -1 || fromIndex === toIndex) {
|
||||
return rules;
|
||||
}
|
||||
|
||||
const reorderedResource = reorderPolicyRules(
|
||||
resourceRules,
|
||||
fromIndex,
|
||||
toIndex,
|
||||
options
|
||||
);
|
||||
|
||||
return [...reorderedResource, ...policyRules];
|
||||
}
|
||||
|
||||
@@ -12,6 +12,23 @@ type TranslateFn = (
|
||||
values?: Record<string, string | number>
|
||||
) => string;
|
||||
|
||||
export const POLICY_RULE_MATCH_TYPES = [
|
||||
"CIDR",
|
||||
"IP",
|
||||
"PATH",
|
||||
"COUNTRY",
|
||||
"ASN",
|
||||
"REGION"
|
||||
] as const;
|
||||
|
||||
export type PolicyRuleMatchType = (typeof POLICY_RULE_MATCH_TYPES)[number];
|
||||
|
||||
export function createPolicyRuleMatchSchema(t: TranslateFn) {
|
||||
return z.enum(POLICY_RULE_MATCH_TYPES, {
|
||||
error: t("rulesErrorInvalidMatchTypeDescription")
|
||||
});
|
||||
}
|
||||
|
||||
export type RuleValidationToast = {
|
||||
title: string;
|
||||
description: string;
|
||||
@@ -78,7 +95,7 @@ export function createPolicyRuleSchema(t: TranslateFn) {
|
||||
return z
|
||||
.object({
|
||||
action: z.enum(["ACCEPT", "DROP", "PASS"]),
|
||||
match: z.string(),
|
||||
match: createPolicyRuleMatchSchema(t),
|
||||
value: z.string(),
|
||||
priority: z.number().int(),
|
||||
enabled: z.boolean()
|
||||
|
||||
218
src/components/shared-policy-selector.tsx
Normal file
218
src/components/shared-policy-selector.tsx
Normal file
@@ -0,0 +1,218 @@
|
||||
"use client";
|
||||
|
||||
import { orgQueries } from "@app/lib/queries";
|
||||
import { cn } from "@app/lib/cn";
|
||||
import type { ListResourcePoliciesResponse } from "@server/routers/resource/types";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { CheckIcon, ChevronsUpDown } from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useMemo, useState } from "react";
|
||||
import { useDebounce } from "use-debounce";
|
||||
import { Button } from "./ui/button";
|
||||
import {
|
||||
Command,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList
|
||||
} from "./ui/command";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "./ui/popover";
|
||||
|
||||
export type SelectedSharedPolicy = Pick<
|
||||
ListResourcePoliciesResponse["policies"][number],
|
||||
"resourcePolicyId" | "name"
|
||||
>;
|
||||
|
||||
export type SharedPolicySelectorProps = {
|
||||
orgId: string;
|
||||
selectedPolicy: SelectedSharedPolicy | null;
|
||||
onSelectPolicy: (policy: SelectedSharedPolicy | null) => void;
|
||||
};
|
||||
|
||||
export function SharedPolicySelector({
|
||||
orgId,
|
||||
selectedPolicy,
|
||||
onSelectPolicy
|
||||
}: SharedPolicySelectorProps) {
|
||||
const t = useTranslations();
|
||||
const [policySearchQuery, setPolicySearchQuery] = useState("");
|
||||
const [debouncedQuery] = useDebounce(policySearchQuery, 150);
|
||||
|
||||
const { data: policies = [] } = useQuery(
|
||||
orgQueries.policies({
|
||||
orgId,
|
||||
query: debouncedQuery
|
||||
})
|
||||
);
|
||||
|
||||
const policiesShown = useMemo((): SelectedSharedPolicy[] => {
|
||||
const allPolicies: SelectedSharedPolicy[] = policies.map((policy) => ({
|
||||
resourcePolicyId: policy.resourcePolicyId,
|
||||
name: policy.name
|
||||
}));
|
||||
if (
|
||||
debouncedQuery.trim().length === 0 &&
|
||||
selectedPolicy &&
|
||||
!allPolicies.find(
|
||||
(policy) =>
|
||||
policy.resourcePolicyId === selectedPolicy.resourcePolicyId
|
||||
)
|
||||
) {
|
||||
allPolicies.unshift(selectedPolicy);
|
||||
}
|
||||
return allPolicies;
|
||||
}, [debouncedQuery, policies, selectedPolicy]);
|
||||
|
||||
return (
|
||||
<Command shouldFilter={false}>
|
||||
<CommandInput
|
||||
placeholder={t("resourcePolicySearch")}
|
||||
value={policySearchQuery}
|
||||
onValueChange={setPolicySearchQuery}
|
||||
/>
|
||||
<CommandList>
|
||||
<CommandEmpty>{t("resourcePolicyNotFound")}</CommandEmpty>
|
||||
<CommandGroup>
|
||||
<CommandItem
|
||||
value={`none:${t("none")}`}
|
||||
onSelect={() => onSelectPolicy(null)}
|
||||
>
|
||||
<CheckIcon
|
||||
className={cn(
|
||||
"mr-2 h-4 w-4",
|
||||
selectedPolicy === null
|
||||
? "opacity-100"
|
||||
: "opacity-0"
|
||||
)}
|
||||
/>
|
||||
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
|
||||
<span className="truncate">{t("none")}</span>
|
||||
<span className="text-muted-foreground text-xs leading-snug">
|
||||
{t("sharedPolicyNoneDescription")}
|
||||
</span>
|
||||
</div>
|
||||
</CommandItem>
|
||||
{policiesShown.map((policy) => (
|
||||
<CommandItem
|
||||
key={policy.resourcePolicyId}
|
||||
value={`${policy.resourcePolicyId}:${policy.name}`}
|
||||
onSelect={() =>
|
||||
onSelectPolicy({
|
||||
resourcePolicyId: policy.resourcePolicyId,
|
||||
name: policy.name
|
||||
})
|
||||
}
|
||||
>
|
||||
<CheckIcon
|
||||
className={cn(
|
||||
"mr-2 h-4 w-4",
|
||||
policy.resourcePolicyId ===
|
||||
selectedPolicy?.resourcePolicyId
|
||||
? "opacity-100"
|
||||
: "opacity-0"
|
||||
)}
|
||||
/>
|
||||
<span className="min-w-0 flex-1 truncate">
|
||||
{policy.name}
|
||||
</span>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
);
|
||||
}
|
||||
|
||||
export type SharedPolicySelectProps = {
|
||||
orgId: string;
|
||||
value: number | null;
|
||||
onChange: (value: number | null) => void;
|
||||
className?: string;
|
||||
disabled?: boolean;
|
||||
};
|
||||
|
||||
export function SharedPolicySelect({
|
||||
orgId,
|
||||
value,
|
||||
onChange,
|
||||
className,
|
||||
disabled
|
||||
}: SharedPolicySelectProps) {
|
||||
const t = useTranslations();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [selectedLabel, setSelectedLabel] = useState<{
|
||||
resourcePolicyId: number;
|
||||
name: string;
|
||||
} | null>(null);
|
||||
|
||||
const resolvedLabel =
|
||||
selectedLabel?.resourcePolicyId === value ? selectedLabel.name : null;
|
||||
|
||||
const { data: fetchedPolicy } = useQuery({
|
||||
...orgQueries.resourcePolicy({
|
||||
resourcePolicyId: value!
|
||||
}),
|
||||
enabled: value !== null && resolvedLabel === null
|
||||
});
|
||||
|
||||
const selectedPolicy = useMemo((): SelectedSharedPolicy | null => {
|
||||
if (value === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
resourcePolicyId: value,
|
||||
name: resolvedLabel ?? fetchedPolicy?.name ?? ""
|
||||
};
|
||||
}, [value, resolvedLabel, fetchedPolicy?.name]);
|
||||
|
||||
const triggerLabel =
|
||||
value === null
|
||||
? t("none")
|
||||
: (resolvedLabel ??
|
||||
fetchedPolicy?.name ??
|
||||
t("resourcePolicySelect"));
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
disabled={disabled}
|
||||
className={cn(
|
||||
"w-full justify-between font-normal md:w-1/2",
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -4,7 +4,7 @@ import { cva, type VariantProps } from "class-variance-authority";
|
||||
import { cn } from "@app/lib/cn";
|
||||
|
||||
const alertVariants = cva(
|
||||
"relative w-full rounded-lg p-4 [&>svg~*]:pl-7 [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground",
|
||||
"relative w-full rounded-lg p-4 has-[>svg]:grid has-[>svg]:grid-cols-[auto_1fr] has-[>svg]:gap-x-3 gap-y-1 [&>svg]:col-start-1 [&>svg]:row-start-1 [&>svg]:row-span-full [&>svg]:size-4 [&>svg]:shrink-0 [&>svg]:self-center [&>svg]:text-foreground [&>svg~*]:col-start-2",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
|
||||
@@ -105,13 +105,17 @@ function SelectLabel({
|
||||
function SelectItem({
|
||||
className,
|
||||
children,
|
||||
description,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Item>) {
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Item> & {
|
||||
description?: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<SelectPrimitive.Item
|
||||
data-slot="select-item"
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
|
||||
"focus:bg-accent focus:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex w-full cursor-default gap-2 rounded-sm pr-8 pl-2 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
|
||||
description ? "items-start py-2" : "items-center py-1.5",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
@@ -121,7 +125,18 @@ function SelectItem({
|
||||
<CheckIcon className="size-4" />
|
||||
</SelectPrimitive.ItemIndicator>
|
||||
</span>
|
||||
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
|
||||
{description ? (
|
||||
<div className="flex flex-col gap-0.5 pr-2">
|
||||
<SelectPrimitive.ItemText>
|
||||
{children}
|
||||
</SelectPrimitive.ItemText>
|
||||
<span className="text-muted-foreground text-xs leading-snug">
|
||||
{description}
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
|
||||
)}
|
||||
</SelectPrimitive.Item>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -45,6 +45,7 @@ import type { ListOrgLabelsResponse } from "@server/routers/labels/types";
|
||||
import { ListHealthChecksResponse } from "@server/routers/healthChecks/types";
|
||||
import { StatusHistoryResponse } from "@server/lib/statusHistory";
|
||||
import type { ListResourcePoliciesResponse } from "@server/routers/resource/types";
|
||||
import type { GetResourcePolicyResponse } from "@server/routers/policy";
|
||||
|
||||
export type ProductUpdate = {
|
||||
link: string | null;
|
||||
@@ -581,16 +582,16 @@ export const orgQueries = {
|
||||
}
|
||||
}),
|
||||
|
||||
policies: ({ orgId, name }: { orgId: string; name?: string }) =>
|
||||
policies: ({ orgId, query }: { orgId: string; query?: string }) =>
|
||||
queryOptions({
|
||||
queryKey: ["ORG", orgId, "RESOURCES_POLICIES", name] as const,
|
||||
queryKey: ["ORG", orgId, "RESOURCES_POLICIES", query] as const,
|
||||
queryFn: async ({ signal, meta }) => {
|
||||
const sp = new URLSearchParams({
|
||||
pageSize: "10"
|
||||
});
|
||||
|
||||
if (name) {
|
||||
sp.set("query", name);
|
||||
if (query) {
|
||||
sp.set("query", query);
|
||||
}
|
||||
|
||||
const res = await meta!.api.get<
|
||||
@@ -601,6 +602,18 @@ export const orgQueries = {
|
||||
|
||||
return res.data.data.policies;
|
||||
}
|
||||
}),
|
||||
|
||||
resourcePolicy: ({ resourcePolicyId }: { resourcePolicyId: number }) =>
|
||||
queryOptions({
|
||||
queryKey: ["RESOURCE_POLICY", resourcePolicyId] as const,
|
||||
queryFn: async ({ signal, meta }) => {
|
||||
const res = await meta!.api.get<
|
||||
AxiosResponse<GetResourcePolicyResponse>
|
||||
>(`/resource-policy/${resourcePolicyId}`, { signal });
|
||||
|
||||
return res.data.data;
|
||||
}
|
||||
})
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user