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