From c5231d37f69274309e8634fff996a170cd3191ae Mon Sep 17 00:00:00 2001 From: Fred KISSIE Date: Thu, 26 Feb 2026 19:20:15 +0100 Subject: [PATCH] =?UTF-8?q?=F0=9F=9A=A7=20wip?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- messages/en-US.json | 2 + server/auth/actions.ts | 1 + server/middlewares/integration/index.ts | 1 + .../verifyApiKeyResourcePolicyAccess.ts | 92 + .../routers/policy/updateResourcePolicy.ts | 1 - server/routers/external.ts | 11 + server/routers/integration.ts | 11 +- server/routers/policy/getResourcePolicy.ts | 123 + server/routers/policy/index.ts | 1 + .../settings/(private)/policies/layout.tsx | 23 + .../policies/resource/[niceId]/page.tsx | 58 + .../{resources => resource}/create/page.tsx | 17 +- .../policies/{resources => resource}/page.tsx | 20 +- src/app/navigation.tsx | 2 +- src/components/ResourcePoliciesTable.tsx | 6 +- .../resource-policy/CreatePolicyForm.tsx | 1870 +++++++++++++++- .../resource-policy/EditPolicyForm.tsx | 1978 ++++++++++++++++- .../ResourcePolicySubForms.tsx | 58 +- src/components/resource-policy/index.ts | 18 + 19 files changed, 4177 insertions(+), 116 deletions(-) create mode 100644 server/middlewares/integration/verifyApiKeyResourcePolicyAccess.ts create mode 100644 server/routers/policy/getResourcePolicy.ts create mode 100644 server/routers/policy/index.ts create mode 100644 src/app/[orgId]/settings/(private)/policies/layout.tsx create mode 100644 src/app/[orgId]/settings/(private)/policies/resource/[niceId]/page.tsx rename src/app/[orgId]/settings/(private)/policies/{resources => resource}/create/page.tsx (62%) rename src/app/[orgId]/settings/(private)/policies/{resources => resource}/page.tsx (83%) diff --git a/messages/en-US.json b/messages/en-US.json index fb827ffe3..78b4ad0ba 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -238,6 +238,8 @@ "rules": "Rules", "resourceSettingDescription": "Configure the settings on the resource", "resourceSetting": "{resourceName} Settings", + "resourcePolicySettingDescription": "Configure the settings on the resource policy", + "resourcePolicySetting": "{policyName} Settings", "alwaysAllow": "Bypass Auth", "alwaysDeny": "Block Access", "passToAuth": "Pass to Auth", diff --git a/server/auth/actions.ts b/server/auth/actions.ts index 6e863829e..bcb5df50c 100644 --- a/server/auth/actions.ts +++ b/server/auth/actions.ts @@ -133,6 +133,7 @@ export enum ActionsEnum { listApprovals = "listApprovals", updateApprovals = "updateApprovals", listResourcePolicies = "listResourcePolicies", + getResourcePolicy = "getResourcePolicy", createResourcePolicy = "createResourcePolicy", updateResourcePolicy = "updateResourcePolicy", deleteResourcePolicy = "deleteResourcePolicy", diff --git a/server/middlewares/integration/index.ts b/server/middlewares/integration/index.ts index 565751913..fa004bd8c 100644 --- a/server/middlewares/integration/index.ts +++ b/server/middlewares/integration/index.ts @@ -14,3 +14,4 @@ export * from "./verifyApiKeyApiKeyAccess"; export * from "./verifyApiKeyClientAccess"; export * from "./verifyApiKeySiteResourceAccess"; export * from "./verifyApiKeyIdpAccess"; +export * from "./verifyApiKeyResourcePolicyAccess"; diff --git a/server/middlewares/integration/verifyApiKeyResourcePolicyAccess.ts b/server/middlewares/integration/verifyApiKeyResourcePolicyAccess.ts new file mode 100644 index 000000000..2d997de53 --- /dev/null +++ b/server/middlewares/integration/verifyApiKeyResourcePolicyAccess.ts @@ -0,0 +1,92 @@ +import { Request, Response, NextFunction } from "express"; +import { db } from "@server/db"; +import { resourcePolicies, apiKeyOrg } from "@server/db"; +import { eq, and } from "drizzle-orm"; +import createHttpError from "http-errors"; +import HttpCode from "@server/types/HttpCode"; + +export async function verifyApiKeyResourcePolicyAccess( + req: Request, + res: Response, + next: NextFunction +) { + const apiKey = req.apiKey; + const resourcePolicyId = + req.params.resourcePolicyId || + req.body.resourcePolicyId || + req.query.resourcePolicyId; + + if (!apiKey) { + return next( + createHttpError(HttpCode.UNAUTHORIZED, "Key not authenticated") + ); + } + + try { + // Retrieve the resource policy + const [policy] = await db + .select() + .from(resourcePolicies) + .where(eq(resourcePolicies.resourcePolicyId, resourcePolicyId)) + .limit(1); + + if (!policy) { + return next( + createHttpError( + HttpCode.NOT_FOUND, + `Resource policy with ID ${resourcePolicyId} not found` + ) + ); + } + + if (apiKey.isRoot) { + // Root keys can access any resource policy in any org + return next(); + } + + if (!policy.orgId) { + return next( + createHttpError( + HttpCode.INTERNAL_SERVER_ERROR, + `Resource policy with ID ${resourcePolicyId} does not have an organization ID` + ) + ); + } + + // Verify that the API key is linked to the resource policy's organization + if (!req.apiKeyOrg) { + const apiKeyOrgResult = await db + .select() + .from(apiKeyOrg) + .where( + and( + eq(apiKeyOrg.apiKeyId, apiKey.apiKeyId), + eq(apiKeyOrg.orgId, policy.orgId) + ) + ) + .limit(1); + + if (apiKeyOrgResult.length > 0) { + req.apiKeyOrg = apiKeyOrgResult[0]; + } + } + + if (!req.apiKeyOrg) { + return next( + createHttpError( + HttpCode.FORBIDDEN, + "Key does not have access to this organization" + ) + ); + } + + return next(); + } catch (error) { + return next( + createHttpError( + HttpCode.INTERNAL_SERVER_ERROR, + "Error verifying resource policy access" + ) + ); + } +} diff --git a/server/private/routers/policy/updateResourcePolicy.ts b/server/private/routers/policy/updateResourcePolicy.ts index 58ea688cc..1f4ff5971 100644 --- a/server/private/routers/policy/updateResourcePolicy.ts +++ b/server/private/routers/policy/updateResourcePolicy.ts @@ -10,7 +10,6 @@ import { resourcePolicies, rolePolicies, userPolicies, - type ResourcePolicy, type ResourcePolicy } from "@server/db"; import { and, eq } from "drizzle-orm"; diff --git a/server/routers/external.ts b/server/routers/external.ts index c69fdacc5..67494c643 100644 --- a/server/routers/external.ts +++ b/server/routers/external.ts @@ -3,6 +3,7 @@ import config from "@server/lib/config"; import * as site from "./site"; import * as org from "./org"; import * as resource from "./resource"; +import * as policy from "./policy"; import * as domain from "./domain"; import * as target from "./target"; import * as user from "./user"; @@ -521,6 +522,7 @@ authenticated.get( verifyUserHasAction(ActionsEnum.getResource), resource.getResource ); + authenticated.post( "/resource/:resourceId", verifyResourceAccess, @@ -627,6 +629,15 @@ authenticated.post( logActionAudit(ActionsEnum.updateRole), role.updateRole ); + +authenticated.get( + "/org/:orgId/resource-policy/:niceId", + verifyOrgAccess, + verifyResourcePolicyAccess, + verifyUserHasAction(ActionsEnum.getResourcePolicy), + policy.getResourcePolicy +); + // authenticated.get( // "/role/:roleId", // verifyRoleAccess, diff --git a/server/routers/integration.ts b/server/routers/integration.ts index 6c39fe983..e52e710ee 100644 --- a/server/routers/integration.ts +++ b/server/routers/integration.ts @@ -2,6 +2,7 @@ import * as site from "./site"; import * as org from "./org"; import * as blueprints from "./blueprints"; import * as resource from "./resource"; +import * as policy from "./policy"; import * as domain from "./domain"; import * as target from "./target"; import * as user from "./user"; @@ -27,7 +28,8 @@ import { verifyApiKeyClientAccess, verifyApiKeySiteResourceAccess, verifyApiKeySetResourceClients, - verifyLimits + verifyLimits, + verifyApiKeyResourcePolicyAccess } from "@server/middlewares"; import HttpCode from "@server/types/HttpCode"; import { Router } from "express"; @@ -392,6 +394,13 @@ authenticated.get( resource.getResource ); +authenticated.get( + "/resource-policy/:resourcePolicyId", + verifyApiKeyResourcePolicyAccess, + verifyApiKeyHasAction(ActionsEnum.getResourcePolicy), + policy.getResourcePolicy +); + authenticated.post( "/resource/:resourceId", verifyApiKeyResourceAccess, diff --git a/server/routers/policy/getResourcePolicy.ts b/server/routers/policy/getResourcePolicy.ts new file mode 100644 index 000000000..0d33a222e --- /dev/null +++ b/server/routers/policy/getResourcePolicy.ts @@ -0,0 +1,123 @@ +import { db, resourcePolicies, rolePolicies, userPolicies } from "@server/db"; +import response from "@server/lib/response"; +import logger from "@server/logger"; +import { OpenAPITags, registry } from "@server/openApi"; +import HttpCode from "@server/types/HttpCode"; +import { and, eq, type SQL } from "drizzle-orm"; +import type { NextFunction, Request, Response } from "express"; +import createHttpError from "http-errors"; +import z from "zod"; +import { fromError } from "zod-validation-error"; + +const getResourcePolicySchema = z + .strictObject({ + niceId: z.string(), + orgId: z.string() + }) + .or( + z.strictObject({ + resourcePolicyId: z.coerce.number().int().positive() + }) + ); + +async function query(params: z.infer) { + const conditions: SQL[] = []; + if ("resourcePolicyId" in params) { + conditions.push( + eq(resourcePolicies.resourcePolicyId, params.resourcePolicyId) + ); + } else { + conditions.push( + eq(resourcePolicies.niceId, params.niceId), + eq(resourcePolicies.orgId, params.orgId) + ); + } + + const [res] = await db + .select({ + policy: resourcePolicies, + userPolicies, + rolePolicies + }) + .from(resourcePolicies) + .leftJoin( + userPolicies, + eq(userPolicies.resourcePolicyId, resourcePolicies.resourcePolicyId) + ) + .leftJoin( + rolePolicies, + eq(rolePolicies.resourcePolicyId, resourcePolicies.resourcePolicyId) + ) + .where(and(...conditions)) + .limit(1); + return res; +} + +export type GetResourcePolicyResponse = Awaited>; + +registry.registerPath({ + method: "get", + path: "/org/{orgId}/resource-policy/{niceId}", + description: + "Get a resource policy by orgId and niceId. NiceId is a readable ID for the resource and unique on a per org basis.", + tags: [OpenAPITags.Org, OpenAPITags.Policy], + request: { + params: z.object({ + orgId: z.string(), + niceId: z.string() + }) + }, + responses: {} +}); + +registry.registerPath({ + method: "get", + path: "/resource-policy/{resourcePolicyId}", + description: "Get a resource policy by its resourcePolicyId.", + tags: [OpenAPITags.Policy], + request: { + params: z.object({ + resourcePolicyId: z.number() + }) + }, + responses: {} +}); + +export async function getResourcePolicy( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const parsedParams = getResourcePolicySchema.safeParse(req.params); + if (!parsedParams.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedParams.error).toString() + ) + ); + } + + const policy = await query(parsedParams.data); + + if (!policy) { + return next( + createHttpError(HttpCode.NOT_FOUND, "Resource policy not found") + ); + } + + return response(res, { + data: policy, + success: true, + error: false, + message: "Resource Policy retrieved successfully", + status: HttpCode.OK + }); + } catch (error) { + logger.error(error); + return next( + createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") + ); + } +} diff --git a/server/routers/policy/index.ts b/server/routers/policy/index.ts new file mode 100644 index 000000000..a05c292ee --- /dev/null +++ b/server/routers/policy/index.ts @@ -0,0 +1 @@ +export * from "./getResourcePolicy"; diff --git a/src/app/[orgId]/settings/(private)/policies/layout.tsx b/src/app/[orgId]/settings/(private)/policies/layout.tsx new file mode 100644 index 000000000..ef5803e1a --- /dev/null +++ b/src/app/[orgId]/settings/(private)/policies/layout.tsx @@ -0,0 +1,23 @@ +import { getCachedOrg } from "@app/lib/api/getCachedOrg"; +import OrgProvider from "@app/providers/OrgProvider"; +import type { GetOrgResponse } from "@server/routers/org"; +import { redirect } from "next/navigation"; + +export interface PolicyLayoutPageProps { + params: Promise<{ orgId: string }>; + children: React.ReactNode; +} + +export default async function PolicyLayoutPage(props: PolicyLayoutPageProps) { + const params = await props.params; + + let org: GetOrgResponse | null = null; + try { + const res = await getCachedOrg(params.orgId); + org = res.data.data; + } catch { + redirect(`/${params.orgId}/settings`); + } + + return {props.children}; +} diff --git a/src/app/[orgId]/settings/(private)/policies/resource/[niceId]/page.tsx b/src/app/[orgId]/settings/(private)/policies/resource/[niceId]/page.tsx new file mode 100644 index 000000000..61833a1f1 --- /dev/null +++ b/src/app/[orgId]/settings/(private)/policies/resource/[niceId]/page.tsx @@ -0,0 +1,58 @@ +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 type { ResourcePolicy } from "@server/db"; +import type { GetResourcePolicyResponse } from "@server/routers/policy"; +import type { AxiosResponse } from "axios"; +import { getTranslations } from "next-intl/server"; +import Link from "next/link"; +import { redirect } from "next/navigation"; + +export interface EditPolicyPageProps { + params: Promise<{ niceId: string; orgId: string }>; +} + +export default async function EditPolicyPage(props: EditPolicyPageProps) { + const params = await props.params; + const t = await getTranslations(); + + let policy: ResourcePolicy | null = null; + try { + const res = await internal.get< + AxiosResponse + >( + `/org/${params.orgId}/resource-policy/${params.niceId}`, + await authCookieHeader() + ); + policy = res.data.data.policy; + } catch { + redirect(`/${params.orgId}/settings/policies/resource`); + } + + if (!policy) { + redirect(`/${params.orgId}/settings/policies/resource`); + } + + return ( + <> +
+ + + +
+ + + + ); +} diff --git a/src/app/[orgId]/settings/(private)/policies/resources/create/page.tsx b/src/app/[orgId]/settings/(private)/policies/resource/create/page.tsx similarity index 62% rename from src/app/[orgId]/settings/(private)/policies/resources/create/page.tsx rename to src/app/[orgId]/settings/(private)/policies/resource/create/page.tsx index 6efdc5597..edf67fbef 100644 --- a/src/app/[orgId]/settings/(private)/policies/resources/create/page.tsx +++ b/src/app/[orgId]/settings/(private)/policies/resource/create/page.tsx @@ -1,12 +1,8 @@ import { CreatePolicyForm } from "@app/components/resource-policy/CreatePolicyForm"; import SettingsSectionTitle from "@app/components/SettingsSectionTitle"; import { Button } from "@app/components/ui/button"; -import { getCachedOrg } from "@app/lib/api/getCachedOrg"; -import OrgProvider from "@app/providers/OrgProvider"; -import type { GetOrgResponse } from "@server/routers/org"; import { getTranslations } from "next-intl/server"; import Link from "next/link"; -import { redirect } from "next/navigation"; export interface CreateResourcePolicyPageProps { params: Promise<{ orgId: string }>; @@ -18,13 +14,6 @@ export default async function CreateResourcePolicyPage( const params = await props.params; const t = await getTranslations(); - let org: GetOrgResponse | null = null; - try { - const res = await getCachedOrg(params.orgId); - org = res.data.data; - } catch { - redirect(`/${params.orgId}/settings/resources`); - } return ( <>
@@ -34,15 +23,13 @@ export default async function CreateResourcePolicyPage( />
- - - + ); } diff --git a/src/app/[orgId]/settings/(private)/policies/resources/page.tsx b/src/app/[orgId]/settings/(private)/policies/resource/page.tsx similarity index 83% rename from src/app/[orgId]/settings/(private)/policies/resources/page.tsx rename to src/app/[orgId]/settings/(private)/policies/resource/page.tsx index e641696ef..3f2ec53b0 100644 --- a/src/app/[orgId]/settings/(private)/policies/resources/page.tsx +++ b/src/app/[orgId]/settings/(private)/policies/resource/page.tsx @@ -55,17 +55,15 @@ export default async function ResourcePoliciesPage( description={t("resourcePoliciesDescription")} /> - - - + ); } diff --git a/src/app/navigation.tsx b/src/app/navigation.tsx index bf68837f5..324f051c3 100644 --- a/src/app/navigation.tsx +++ b/src/app/navigation.tsx @@ -132,7 +132,7 @@ export const orgNavSections = (env?: Env): SidebarNavSection[] => [ items: [ { title: "sidebarResourcePolicies", - href: "/{orgId}/settings/policies/resources", + href: "/{orgId}/settings/policies/resource", icon: ( ) diff --git a/src/components/ResourcePoliciesTable.tsx b/src/components/ResourcePoliciesTable.tsx index 32860a464..69dee6963 100644 --- a/src/components/ResourcePoliciesTable.tsx +++ b/src/components/ResourcePoliciesTable.tsx @@ -103,7 +103,7 @@ export function ResourcePoliciesTable({ {t("viewSettings")} @@ -122,7 +122,7 @@ export function ResourcePoliciesTable({ + + + ); + } + + return ( + <> + {/* Password Credenza */} + { + setIsSetPasswordOpen(val); + if (!val) passwordForm.reset(); + }} + > + + + + {t("resourcePasswordSetupTitle")} + + + {t("resourcePasswordSetupTitleDescription")} + + + +
+ { + form.setValue("password", data); + setIsSetPasswordOpen(false); + passwordForm.reset(); + })} + className="space-y-4" + id="set-password-form" + > + ( + + + {t("password")} + + + + + + + )} + /> + + +
+ + + + + + +
+
+ + {/* Pincode Credenza */} + { + setIsSetPincodeOpen(val); + if (!val) pincodeForm.reset(); + }} + > + + + + {t("resourcePincodeSetupTitle")} + + + {t("resourcePincodeSetupTitleDescription")} + + + +
+ { + form.setValue("pincode", data); + setIsSetPincodeOpen(false); + pincodeForm.reset(); + })} + className="space-y-4" + id="set-pincode-form" + > + ( + + + {t("resourcePincode")} + + +
+ + + + + + + + + + +
+
+ +
+ )} + /> + + +
+ + + + + + +
+
+ + {/* Header Auth Credenza */} + { + setIsSetHeaderAuthOpen(val); + if (!val) headerAuthForm.reset(); + }} + > + + + + {t("resourceHeaderAuthSetupTitle")} + + + {t("resourceHeaderAuthSetupTitleDescription")} + + + +
+ { + form.setValue("headerAuth", data); + setIsSetHeaderAuthOpen(false); + headerAuthForm.reset(); + } + )} + className="space-y-4" + id="set-header-auth-form" + > + ( + + {t("user")} + + + + + + )} + /> + ( + + + {t("password")} + + + + + + + )} + /> + ( + + + + + + + )} + /> + + +
+ + + + + + +
+
+ + + + + {t("resourceAuthMethods")} + + + {t("resourcePolicyAuthMethodsDescription")} + + + + + {/* Password row */} +
+
+ + + {t("resourcePasswordProtection", { + status: password + ? t("enabled") + : t("disabled") + })} + +
+ +
+ + {/* Pincode row */} +
+
+ + + {t("resourcePincodeProtection", { + status: pincode + ? t("enabled") + : t("disabled") + })} + +
+ +
+ + {/* Header auth row */} +
+
+ + + {headerAuth + ? t( + "resourceHeaderAuthProtectionEnabled" + ) + : t( + "resourceHeaderAuthProtectionDisabled" + )} + +
+ +
+
+
+
+ + ); +} + +// ─── PolicyOtpEmailSection ──────────────────────────────────────────────────── + +type PolicyOtpEmailSectionProps = { + form: UseFormReturn; + emailEnabled: boolean; +}; + +export function PolicyOtpEmailSection({ + form, + emailEnabled +}: PolicyOtpEmailSectionProps) { + const t = useTranslations(); + const [isOpen, setIsOpen] = useState(false); + const [whitelistEnabled, setWhitelistEnabled] = useState(false); + const [activeEmailTagIndex, setActiveEmailTagIndex] = useState< + number | null + >(null); + + if (!isOpen) { + return ( + + + + {t("otpEmailTitle")} + + + {t("otpEmailTitleDescription")} + + + + + + + ); + } + + return ( + + + + {t("otpEmailTitle")} + + + {t("otpEmailTitleDescription")} + + + + + {!emailEnabled && ( + + + + {t("otpEmailSmtpRequired")} + + + {t("otpEmailSmtpRequiredDescription")} + + + )} + { + setWhitelistEnabled(val); + form.setValue("emailWhitelistEnabled", val); + }} + disabled={!emailEnabled} + /> + + {whitelistEnabled && emailEnabled && ( + ( + + + + + + {/* @ts-ignore */} + { + return z + .email() + .or( + z + .string() + .regex( + /^\*@[\w.-]+\.[a-zA-Z]{2,}$/, + { + message: t( + "otpEmailErrorInvalid" + ) + } + ) + ) + .safeParse(tag).success; + }} + setActiveTagIndex={ + setActiveEmailTagIndex + } + placeholder={t("otpEmailEnter")} + tags={form.getValues().emails} + setTags={(newEmails) => { + form.setValue( + "emails", + newEmails as [Tag, ...Tag[]] + ); + }} + allowDuplicates={false} + sortTags={true} + /> + + + {t("otpEmailEnterDescription")} + + + )} + /> + )} + + + + ); +} + +// ─── PolicyRulesSection ─────────────────────────────────────────────────────── + +const addRuleSchema = z.object({ + action: z.enum(["ACCEPT", "DROP", "PASS"]), + match: z.string(), + value: z.string(), + priority: z.coerce.number().int().optional() +}); + +type LocalRule = { + ruleId: number; + action: "ACCEPT" | "DROP" | "PASS"; + match: string; + value: string; + priority: number; + enabled: boolean; + new?: boolean; + updated?: boolean; +}; + +type PolicyRulesSectionProps = { + form: UseFormReturn; + isMaxmindAvailable: boolean; + isMaxmindAsnAvailable: boolean; +}; + +export function PolicyRulesSection({ + form, + isMaxmindAvailable, + isMaxmindAsnAvailable +}: PolicyRulesSectionProps) { + const t = useTranslations(); + const [isOpen, setIsOpen] = useState(false); + const [rules, setRules] = useState([]); + const [rulesEnabled, setRulesEnabled] = useState(false); + const [openAddRuleCountrySelect, setOpenAddRuleCountrySelect] = + useState(false); + const [openAddRuleAsnSelect, setOpenAddRuleAsnSelect] = useState(false); + + const addRuleForm = useForm({ + resolver: zodResolver(addRuleSchema), + defaultValues: { + action: "ACCEPT" as const, + match: "IP", + value: "" + } + }); + + const RuleAction = useMemo( + () => ({ + ACCEPT: t("alwaysAllow"), + DROP: t("alwaysDeny"), + PASS: t("passToAuth") + }), + [t] + ); + + const RuleMatch = useMemo( + () => ({ + PATH: t("path"), + IP: "IP", + CIDR: t("ipAddressRange"), + COUNTRY: t("country"), + ASN: "ASN" + }), + [t] + ); + + const syncFormRules = useCallback( + (updatedRules: LocalRule[]) => { + form.setValue( + "rules", + updatedRules.map( + ({ action, match, value, priority, enabled }) => ({ + action, + match, + value, + priority, + enabled + }) + ) + ); + }, + [form] + ); + + const addRule = useCallback( + function addRule(data: z.infer) { + const isDuplicate = rules.some( + (rule) => + rule.action === data.action && + rule.match === data.match && + rule.value === data.value + ); + if (isDuplicate) { + toast({ + variant: "destructive", + title: t("rulesErrorDuplicate"), + description: t("rulesErrorDuplicateDescription") + }); + return; + } + if (data.match === "CIDR" && !isValidCIDR(data.value)) { + toast({ + variant: "destructive", + title: t("rulesErrorInvalidIpAddressRange"), + description: t("rulesErrorInvalidIpAddressRangeDescription") + }); + return; + } + if (data.match === "PATH" && !isValidUrlGlobPattern(data.value)) { + toast({ + variant: "destructive", + title: t("rulesErrorInvalidUrl"), + description: t("rulesErrorInvalidUrlDescription") + }); + return; + } + if (data.match === "IP" && !isValidIP(data.value)) { + toast({ + variant: "destructive", + title: t("rulesErrorInvalidIpAddress"), + description: t("rulesErrorInvalidIpAddressDescription") + }); + return; + } + if ( + data.match === "COUNTRY" && + !COUNTRIES.some((c) => c.code === data.value) + ) { + toast({ + variant: "destructive", + title: t("rulesErrorInvalidCountry"), + description: t("rulesErrorInvalidCountryDescription") || "" + }); + return; + } + + let priority = data.priority; + if (priority === undefined) { + priority = + rules.reduce( + (acc, rule) => + rule.priority > acc ? rule.priority : acc, + 0 + ) + 1; + } + + const updatedRules = [ + ...rules, + { + ...data, + ruleId: new Date().getTime(), + new: true, + priority, + enabled: true + } + ]; + setRules(updatedRules); + syncFormRules(updatedRules); + addRuleForm.reset(); + }, + [rules, t, addRuleForm, 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) { + const updatedRules = rules.map((rule) => + rule.ruleId === ruleId + ? { ...rule, ...data, updated: true } + : rule + ); + setRules(updatedRules); + syncFormRules(updatedRules); + }, + [rules, syncFormRules] + ); + + const getValueHelpText = useCallback( + function getValueHelpText(type: string) { + switch (type) { + case "CIDR": + return t("rulesMatchIpAddressRangeDescription"); + case "IP": + return t("rulesMatchIpAddress"); + case "PATH": + return t("rulesMatchUrl"); + case "COUNTRY": + return t("rulesMatchCountry"); + case "ASN": + return "Enter an Autonomous System Number (e.g., AS15169 or 15169)"; + } + }, + [t] + ); + + const columns: ColumnDef[] = useMemo( + () => [ + { + accessorKey: "priority", + header: ({ column }) => ( + + ), + cell: ({ row }) => ( + e.currentTarget.focus()} + onBlur={(e) => { + const parsed = z.coerce + .number() + .int() + .optional() + .safeParse(e.target.value); + if (!parsed.success) { + toast({ + variant: "destructive", + title: t("rulesErrorInvalidPriority"), + description: t( + "rulesErrorInvalidPriorityDescription" + ) + }); + return; + } + updateRule(row.original.ruleId, { + priority: parsed.data + }); + }} + /> + ) + }, + { + accessorKey: "action", + header: () => {t("rulesAction")}, + cell: ({ row }) => ( + + ) + }, + { + accessorKey: "match", + header: () => ( + {t("rulesMatchType")} + ), + cell: ({ row }) => ( + + ) + }, + { + accessorKey: "value", + header: () => {t("value")}, + cell: ({ row }) => + row.original.match === "COUNTRY" ? ( + + + + + + + + + + {t("noCountryFound")} + + + {COUNTRIES.map((country) => ( + + updateRule( + row.original.ruleId, + { + value: country.code + } + ) + } + > + + {country.name} ( + {country.code}) + + ))} + + + + + + ) : row.original.match === "ASN" ? ( + + + + + + + + + + No ASN found. Enter a custom ASN + below. + + + {MAJOR_ASNS.map((asn) => ( + + updateRule( + row.original.ruleId, + { value: asn.code } + ) + } + > + + {asn.name} ({asn.code}) + + ))} + + + +
+ + asn.code === + row.original.value + ) + ? row.original.value + : "" + } + onKeyDown={(e) => { + if (e.key === "Enter") { + const value = + e.currentTarget.value + .toUpperCase() + .replace(/^AS/, ""); + if (/^\d+$/.test(value)) { + updateRule( + row.original.ruleId, + { value: "AS" + value } + ); + } + } + }} + className="text-sm" + /> +
+
+
+ ) : ( + + updateRule(row.original.ruleId, { + value: e.target.value + }) + } + /> + ) + }, + { + accessorKey: "enabled", + header: () => {t("enabled")}, + cell: ({ row }) => ( + + updateRule(row.original.ruleId, { enabled: val }) + } + /> + ) + }, + { + id: "actions", + header: () => {t("actions")}, + cell: ({ row }) => ( +
+ +
+ ) + } + ], + [ + t, + RuleAction, + RuleMatch, + isMaxmindAvailable, + isMaxmindAsnAvailable, + updateRule, + removeRule + ] + ); + + const table = useReactTable({ + data: rules, + columns, + getCoreRowModel: getCoreRowModel(), + getPaginationRowModel: getPaginationRowModel(), + getSortedRowModel: getSortedRowModel(), + getFilteredRowModel: getFilteredRowModel(), + state: { pagination: { pageIndex: 0, pageSize: 1000 } } + }); + + if (!isOpen) { + return ( + + + + {t("rulesResource")} + + + {t("rulesResourcePolicyDescription")} + + + + + + + ); + } + + return ( + + + + {t("rulesResource")} + + + {t("rulesResourceDescription")} + + + +
+
+ { + setRulesEnabled(val); + form.setValue("applyRules", val); + }} + /> +
+ +
+ +
+ ( + + + {t("rulesAction")} + + + + + + + )} + /> + ( + + + {t("rulesMatchType")} + + + + + + + )} + /> + ( + + + + {addRuleForm.watch("match") === + "COUNTRY" ? ( + + + + + + + + + + {t( + "noCountryFound" + )} + + + {COUNTRIES.map( + ( + country + ) => ( + { + field.onChange( + country.code + ); + setOpenAddRuleCountrySelect( + false + ); + }} + > + + { + country.name + }{" "} + ( + { + country.code + } + + ) + + ) + )} + + + + + + ) : addRuleForm.watch( + "match" + ) === "ASN" ? ( + + + + + + + + + + No ASN + found. + Use the + custom + input + below. + + + {MAJOR_ASNS.map( + ( + asn + ) => ( + { + field.onChange( + asn.code + ); + setOpenAddRuleAsnSelect( + false + ); + }} + > + + { + asn.name + }{" "} + ( + { + asn.code + } + + ) + + ) + )} + + + +
+ { + if ( + e.key === + "Enter" + ) { + const value = + e.currentTarget.value + .toUpperCase() + .replace( + /^AS/, + "" + ); + if ( + /^\d+$/.test( + value + ) + ) { + field.onChange( + "AS" + + value + ); + setOpenAddRuleAsnSelect( + false + ); + } + } + }} + className="text-sm" + /> +
+
+
+ ) : ( + + )} +
+ +
+ )} + /> + +
+
+ + + + + {table.getHeaderGroups().map((headerGroup) => ( + + {headerGroup.headers.map((header) => { + const isActionsColumn = + header.column.id === "actions"; + return ( + + {header.isPlaceholder + ? null + : flexRender( + header.column + .columnDef.header, + header.getContext() + )} + + ); + })} + + ))} + + + {table.getRowModel().rows?.length ? ( + table.getRowModel().rows.map((row) => ( + + {row.getVisibleCells().map((cell) => { + const isActionsColumn = + cell.column.id === "actions"; + return ( + + {flexRender( + cell.column.columnDef + .cell, + cell.getContext() + )} + + ); + })} + + )) + ) : ( + + + {t("rulesNoOne")} + + + )} + +
+
+
+
+ ); +} diff --git a/src/components/resource-policy/EditPolicyForm.tsx b/src/components/resource-policy/EditPolicyForm.tsx index 3f09f7ef8..4331fb286 100644 --- a/src/components/resource-policy/EditPolicyForm.tsx +++ b/src/components/resource-policy/EditPolicyForm.tsx @@ -9,16 +9,7 @@ import { SettingsSectionHeader, SettingsSectionTitle } from "@app/components/Settings"; -import { Button } from "@app/components/ui/button"; -import { - Form, - FormControl, - FormField, - FormItem, - FormLabel, - FormMessage -} from "@app/components/ui/form"; -import { Input } from "@app/components/ui/input"; + import { useEnvContext } from "@app/hooks/useEnvContext"; import { useOrgContext } from "@app/hooks/useOrgContext"; import { usePaidStatus } from "@app/hooks/usePaidStatus"; @@ -32,15 +23,8 @@ import { UserType } from "@server/types/UserTypes"; import { useQuery } from "@tanstack/react-query"; import { useTranslations } from "next-intl"; -import { useActionState, useMemo } from "react"; -import { useForm } from "react-hook-form"; import z from "zod"; -import { - PolicyAuthMethodsSection, - PolicyOtpEmailSection, - PolicyRulesSection, - PolicyUsersRolesSection -} from "./ResourcePolicySubForms"; + import { type PolicyFormValues, createPolicySchema } from "."; import { toast } from "@app/hooks/useToast"; import { createApiClient, formatAxiosError } from "@app/lib/api"; @@ -48,13 +32,107 @@ import { orgs, type ResourcePolicy } from "@server/db"; import type { AxiosResponse } from "axios"; import { useRouter } from "next/navigation"; -// ─── CreatePolicyForm ───────────────────────────────────────────────────────── +import { SwitchInput } from "@app/components/SwitchInput"; +import { Tag, TagInput } from "@app/components/tags/tag-input"; +import { Alert, AlertDescription, AlertTitle } from "@app/components/ui/alert"; +import { Button } from "@app/components/ui/button"; +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList +} from "@app/components/ui/command"; +import { + Form, + FormControl, + FormDescription, + FormField, + FormItem, + FormLabel, + FormMessage +} from "@app/components/ui/form"; +import { InfoPopup } from "@app/components/ui/info-popup"; +import { Input } from "@app/components/ui/input"; +import { + Popover, + PopoverContent, + PopoverTrigger +} from "@app/components/ui/popover"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue +} from "@app/components/ui/select"; +import { Switch } from "@app/components/ui/switch"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow +} from "@app/components/ui/table"; +import { + Credenza, + CredenzaBody, + CredenzaClose, + CredenzaContent, + CredenzaDescription, + CredenzaFooter, + CredenzaHeader, + CredenzaTitle +} from "@app/components/Credenza"; +import { + InputOTP, + InputOTPGroup, + InputOTPSlot +} from "@app/components/ui/input-otp"; + +import { MAJOR_ASNS } from "@server/db/asns"; +import { COUNTRIES } from "@server/db/countries"; +import { + isValidCIDR, + isValidIP, + isValidUrlGlobPattern +} from "@server/lib/validators"; +import { + ColumnDef, + flexRender, + getCoreRowModel, + getFilteredRowModel, + getPaginationRowModel, + getSortedRowModel, + useReactTable +} from "@tanstack/react-table"; +import { + ArrowUpDown, + Binary, + Bot, + Check, + ChevronsUpDown, + InfoIcon, + Key, + Plus +} from "lucide-react"; + +import { useCallback, useMemo, useState, useActionState } from "react"; +import { UseFormReturn, useForm, useWatch } from "react-hook-form"; + +// ─── EditPolicyForm ───────────────────────────────────────────────────────── export type EditPolicyFormProps = { policy: ResourcePolicy; + hidePolicyNameForm?: boolean; }; -export function EditPolicyForm({}: EditPolicyFormProps) { +export function EditPolicyForm({ + hidePolicyNameForm, + policy +}: EditPolicyFormProps) { const { org } = useOrgContext(); const t = useTranslations(); const { env } = useEnvContext(); @@ -87,7 +165,7 @@ export function EditPolicyForm({}: EditPolicyFormProps) { const form = useForm({ resolver: zodResolver(createPolicySchema) as any, defaultValues: { - name: "", + name: policy.name, sso: true, skipToIdpId: null, emailWhitelistEnabled: false, @@ -197,39 +275,7 @@ export function EditPolicyForm({}: EditPolicyFormProps) {
{/* Name */} - - - - {t("resourcePolicyName")} - - - {t("resourcePolicyNameDescription")} - - - - - ( - - {t("name")} - - - - - - )} - /> - - - - + {!hidePolicyNameForm && } - -
- -
); } + +// ─── PolicyNameSection ────────────────────────────────────────────────── +type PolicyNameSectionProps = { + form: UseFormReturn; + isEditing?: boolean; +}; + +export function PolicyNameSection({ form }: PolicyNameSectionProps) { + const t = useTranslations(); + return ( + + + + {t("resourcePolicyName")} + + + {t("resourcePolicyNameDescription")} + + + + + ( + + {t("name")} + + + + + + )} + /> + + + +
+ +
+
+ ); +} + +// ─── PolicyUsersRolesSection ────────────────────────────────────────────────── + +type PolicyUsersRolesSectionProps = { + form: UseFormReturn; + allRoles: { id: string; text: string }[]; + allUsers: { id: string; text: string }[]; + allIdps: { id: number; text: string }[]; +}; + +export function PolicyUsersRolesSection({ + form, + allRoles, + allUsers, + allIdps +}: PolicyUsersRolesSectionProps) { + const t = useTranslations(); + const ssoEnabled = useWatch({ control: form.control, name: "sso" }); + const selectedIdpId = useWatch({ + control: form.control, + name: "skipToIdpId" + }); + const [activeRolesTagIndex, setActiveRolesTagIndex] = useState< + number | null + >(null); + const [activeUsersTagIndex, setActiveUsersTagIndex] = useState< + number | null + >(null); + + return ( + + + + {t("resourceUsersRoles")} + + + {t("resourcePolicyUsersRolesDescription")} + + + + + { + console.log(`form.setValue("sso", ${val})`); + form.setValue("sso", val); + }} + /> + + {ssoEnabled && ( + <> + ( + + {t("roles")} + + { + form.setValue( + "roles", + newRoles as [ + Tag, + ...Tag[] + ] + ); + }} + enableAutocomplete={true} + autocompleteOptions={allRoles} + allowDuplicates={false} + restrictTagsToAutocompleteOptions={ + true + } + sortTags={true} + /> + + + + {t("resourceRoleDescription")} + + + )} + /> + ( + + {t("users")} + + { + form.setValue( + "users", + newUsers as [ + Tag, + ...Tag[] + ] + ); + }} + enableAutocomplete={true} + autocompleteOptions={allUsers} + allowDuplicates={false} + restrictTagsToAutocompleteOptions={ + true + } + sortTags={true} + /> + + + + )} + /> + + )} + + {ssoEnabled && allIdps.length > 0 && ( +
+ + +

+ {t("defaultIdentityProviderDescription")} +

+
+ )} +
+
+
+ ); +} + +// ─── PolicyAuthMethodsSection ───────────────────────────────────────────────── + +const setPasswordSchema = z.object({ + password: z.string().min(4).max(100) +}); + +const setPincodeSchema = z.object({ + pincode: z.string().length(6) +}); + +const setHeaderAuthSchema = z.object({ + user: z.string().min(4).max(100), + password: z.string().min(4).max(100), + extendedCompatibility: z.boolean() +}); + +type PolicyAuthMethodsSectionProps = { + form: UseFormReturn; +}; + +export function PolicyAuthMethodsSection({ + form +}: PolicyAuthMethodsSectionProps) { + const t = useTranslations(); + const [isOpen, setIsOpen] = useState(false); + const [isSetPasswordOpen, setIsSetPasswordOpen] = useState(false); + const [isSetPincodeOpen, setIsSetPincodeOpen] = useState(false); + const [isSetHeaderAuthOpen, setIsSetHeaderAuthOpen] = useState(false); + + const password = form.watch("password"); + const pincode = form.watch("pincode"); + const headerAuth = form.watch("headerAuth"); + + const passwordForm = useForm({ + resolver: zodResolver(setPasswordSchema), + defaultValues: { password: "" } + }); + + const pincodeForm = useForm({ + resolver: zodResolver(setPincodeSchema), + defaultValues: { pincode: "" } + }); + + const headerAuthForm = useForm({ + resolver: zodResolver(setHeaderAuthSchema), + defaultValues: { user: "", password: "", extendedCompatibility: true } + }); + + if (!isOpen) { + return ( + + + + {t("resourceAuthMethods")} + + + {t("resourcePolicyAuthMethodsDescription")} + + + + + + + ); + } + + return ( + <> + {/* Password Credenza */} + { + setIsSetPasswordOpen(val); + if (!val) passwordForm.reset(); + }} + > + + + + {t("resourcePasswordSetupTitle")} + + + {t("resourcePasswordSetupTitleDescription")} + + + +
+ { + form.setValue("password", data); + setIsSetPasswordOpen(false); + passwordForm.reset(); + })} + className="space-y-4" + id="set-password-form" + > + ( + + + {t("password")} + + + + + + + )} + /> + + +
+ + + + + + +
+
+ + {/* Pincode Credenza */} + { + setIsSetPincodeOpen(val); + if (!val) pincodeForm.reset(); + }} + > + + + + {t("resourcePincodeSetupTitle")} + + + {t("resourcePincodeSetupTitleDescription")} + + + +
+ { + form.setValue("pincode", data); + setIsSetPincodeOpen(false); + pincodeForm.reset(); + })} + className="space-y-4" + id="set-pincode-form" + > + ( + + + {t("resourcePincode")} + + +
+ + + + + + + + + + +
+
+ +
+ )} + /> + + +
+ + + + + + +
+
+ + {/* Header Auth Credenza */} + { + setIsSetHeaderAuthOpen(val); + if (!val) headerAuthForm.reset(); + }} + > + + + + {t("resourceHeaderAuthSetupTitle")} + + + {t("resourceHeaderAuthSetupTitleDescription")} + + + +
+ { + form.setValue("headerAuth", data); + setIsSetHeaderAuthOpen(false); + headerAuthForm.reset(); + } + )} + className="space-y-4" + id="set-header-auth-form" + > + ( + + {t("user")} + + + + + + )} + /> + ( + + + {t("password")} + + + + + + + )} + /> + ( + + + + + + + )} + /> + + +
+ + + + + + +
+
+ + + + + {t("resourceAuthMethods")} + + + {t("resourcePolicyAuthMethodsDescription")} + + + + + {/* Password row */} +
+
+ + + {t("resourcePasswordProtection", { + status: password + ? t("enabled") + : t("disabled") + })} + +
+ +
+ + {/* Pincode row */} +
+
+ + + {t("resourcePincodeProtection", { + status: pincode + ? t("enabled") + : t("disabled") + })} + +
+ +
+ + {/* Header auth row */} +
+
+ + + {headerAuth + ? t( + "resourceHeaderAuthProtectionEnabled" + ) + : t( + "resourceHeaderAuthProtectionDisabled" + )} + +
+ +
+
+
+
+ + ); +} + +// ─── PolicyOtpEmailSection ──────────────────────────────────────────────────── + +type PolicyOtpEmailSectionProps = { + form: UseFormReturn; + emailEnabled: boolean; +}; + +export function PolicyOtpEmailSection({ + form, + emailEnabled +}: PolicyOtpEmailSectionProps) { + const t = useTranslations(); + const [isOpen, setIsOpen] = useState(false); + const [whitelistEnabled, setWhitelistEnabled] = useState(false); + const [activeEmailTagIndex, setActiveEmailTagIndex] = useState< + number | null + >(null); + + if (!isOpen) { + return ( + + + + {t("otpEmailTitle")} + + + {t("otpEmailTitleDescription")} + + + + + + + ); + } + + return ( + + + + {t("otpEmailTitle")} + + + {t("otpEmailTitleDescription")} + + + + + {!emailEnabled && ( + + + + {t("otpEmailSmtpRequired")} + + + {t("otpEmailSmtpRequiredDescription")} + + + )} + { + setWhitelistEnabled(val); + form.setValue("emailWhitelistEnabled", val); + }} + disabled={!emailEnabled} + /> + + {whitelistEnabled && emailEnabled && ( + ( + + + + + + {/* @ts-ignore */} + { + return z + .email() + .or( + z + .string() + .regex( + /^\*@[\w.-]+\.[a-zA-Z]{2,}$/, + { + message: t( + "otpEmailErrorInvalid" + ) + } + ) + ) + .safeParse(tag).success; + }} + setActiveTagIndex={ + setActiveEmailTagIndex + } + placeholder={t("otpEmailEnter")} + tags={form.getValues().emails} + setTags={(newEmails) => { + form.setValue( + "emails", + newEmails as [Tag, ...Tag[]] + ); + }} + allowDuplicates={false} + sortTags={true} + /> + + + {t("otpEmailEnterDescription")} + + + )} + /> + )} + + + + ); +} + +// ─── PolicyRulesSection ─────────────────────────────────────────────────────── + +const addRuleSchema = z.object({ + action: z.enum(["ACCEPT", "DROP", "PASS"]), + match: z.string(), + value: z.string(), + priority: z.coerce.number().int().optional() +}); + +type LocalRule = { + ruleId: number; + action: "ACCEPT" | "DROP" | "PASS"; + match: string; + value: string; + priority: number; + enabled: boolean; + new?: boolean; + updated?: boolean; +}; + +type PolicyRulesSectionProps = { + form: UseFormReturn; + isMaxmindAvailable: boolean; + isMaxmindAsnAvailable: boolean; +}; + +export function PolicyRulesSection({ + form, + isMaxmindAvailable, + isMaxmindAsnAvailable +}: PolicyRulesSectionProps) { + const t = useTranslations(); + const [isOpen, setIsOpen] = useState(false); + const [rules, setRules] = useState([]); + const [rulesEnabled, setRulesEnabled] = useState(false); + const [openAddRuleCountrySelect, setOpenAddRuleCountrySelect] = + useState(false); + const [openAddRuleAsnSelect, setOpenAddRuleAsnSelect] = useState(false); + + const addRuleForm = useForm({ + resolver: zodResolver(addRuleSchema), + defaultValues: { + action: "ACCEPT" as const, + match: "IP", + value: "" + } + }); + + const RuleAction = useMemo( + () => ({ + ACCEPT: t("alwaysAllow"), + DROP: t("alwaysDeny"), + PASS: t("passToAuth") + }), + [t] + ); + + const RuleMatch = useMemo( + () => ({ + PATH: t("path"), + IP: "IP", + CIDR: t("ipAddressRange"), + COUNTRY: t("country"), + ASN: "ASN" + }), + [t] + ); + + const syncFormRules = useCallback( + (updatedRules: LocalRule[]) => { + form.setValue( + "rules", + updatedRules.map( + ({ action, match, value, priority, enabled }) => ({ + action, + match, + value, + priority, + enabled + }) + ) + ); + }, + [form] + ); + + const addRule = useCallback( + function addRule(data: z.infer) { + const isDuplicate = rules.some( + (rule) => + rule.action === data.action && + rule.match === data.match && + rule.value === data.value + ); + if (isDuplicate) { + toast({ + variant: "destructive", + title: t("rulesErrorDuplicate"), + description: t("rulesErrorDuplicateDescription") + }); + return; + } + if (data.match === "CIDR" && !isValidCIDR(data.value)) { + toast({ + variant: "destructive", + title: t("rulesErrorInvalidIpAddressRange"), + description: t("rulesErrorInvalidIpAddressRangeDescription") + }); + return; + } + if (data.match === "PATH" && !isValidUrlGlobPattern(data.value)) { + toast({ + variant: "destructive", + title: t("rulesErrorInvalidUrl"), + description: t("rulesErrorInvalidUrlDescription") + }); + return; + } + if (data.match === "IP" && !isValidIP(data.value)) { + toast({ + variant: "destructive", + title: t("rulesErrorInvalidIpAddress"), + description: t("rulesErrorInvalidIpAddressDescription") + }); + return; + } + if ( + data.match === "COUNTRY" && + !COUNTRIES.some((c) => c.code === data.value) + ) { + toast({ + variant: "destructive", + title: t("rulesErrorInvalidCountry"), + description: t("rulesErrorInvalidCountryDescription") || "" + }); + return; + } + + let priority = data.priority; + if (priority === undefined) { + priority = + rules.reduce( + (acc, rule) => + rule.priority > acc ? rule.priority : acc, + 0 + ) + 1; + } + + const updatedRules = [ + ...rules, + { + ...data, + ruleId: new Date().getTime(), + new: true, + priority, + enabled: true + } + ]; + setRules(updatedRules); + syncFormRules(updatedRules); + addRuleForm.reset(); + }, + [rules, t, addRuleForm, 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) { + const updatedRules = rules.map((rule) => + rule.ruleId === ruleId + ? { ...rule, ...data, updated: true } + : rule + ); + setRules(updatedRules); + syncFormRules(updatedRules); + }, + [rules, syncFormRules] + ); + + const getValueHelpText = useCallback( + function getValueHelpText(type: string) { + switch (type) { + case "CIDR": + return t("rulesMatchIpAddressRangeDescription"); + case "IP": + return t("rulesMatchIpAddress"); + case "PATH": + return t("rulesMatchUrl"); + case "COUNTRY": + return t("rulesMatchCountry"); + case "ASN": + return "Enter an Autonomous System Number (e.g., AS15169 or 15169)"; + } + }, + [t] + ); + + const columns: ColumnDef[] = useMemo( + () => [ + { + accessorKey: "priority", + header: ({ column }) => ( + + ), + cell: ({ row }) => ( + e.currentTarget.focus()} + onBlur={(e) => { + const parsed = z.coerce + .number() + .int() + .optional() + .safeParse(e.target.value); + if (!parsed.success) { + toast({ + variant: "destructive", + title: t("rulesErrorInvalidPriority"), + description: t( + "rulesErrorInvalidPriorityDescription" + ) + }); + return; + } + updateRule(row.original.ruleId, { + priority: parsed.data + }); + }} + /> + ) + }, + { + accessorKey: "action", + header: () => {t("rulesAction")}, + cell: ({ row }) => ( + + ) + }, + { + accessorKey: "match", + header: () => ( + {t("rulesMatchType")} + ), + cell: ({ row }) => ( + + ) + }, + { + accessorKey: "value", + header: () => {t("value")}, + cell: ({ row }) => + row.original.match === "COUNTRY" ? ( + + + + + + + + + + {t("noCountryFound")} + + + {COUNTRIES.map((country) => ( + + updateRule( + row.original.ruleId, + { + value: country.code + } + ) + } + > + + {country.name} ( + {country.code}) + + ))} + + + + + + ) : row.original.match === "ASN" ? ( + + + + + + + + + + No ASN found. Enter a custom ASN + below. + + + {MAJOR_ASNS.map((asn) => ( + + updateRule( + row.original.ruleId, + { value: asn.code } + ) + } + > + + {asn.name} ({asn.code}) + + ))} + + + +
+ + asn.code === + row.original.value + ) + ? row.original.value + : "" + } + onKeyDown={(e) => { + if (e.key === "Enter") { + const value = + e.currentTarget.value + .toUpperCase() + .replace(/^AS/, ""); + if (/^\d+$/.test(value)) { + updateRule( + row.original.ruleId, + { value: "AS" + value } + ); + } + } + }} + className="text-sm" + /> +
+
+
+ ) : ( + + updateRule(row.original.ruleId, { + value: e.target.value + }) + } + /> + ) + }, + { + accessorKey: "enabled", + header: () => {t("enabled")}, + cell: ({ row }) => ( + + updateRule(row.original.ruleId, { enabled: val }) + } + /> + ) + }, + { + id: "actions", + header: () => {t("actions")}, + cell: ({ row }) => ( +
+ +
+ ) + } + ], + [ + t, + RuleAction, + RuleMatch, + isMaxmindAvailable, + isMaxmindAsnAvailable, + updateRule, + removeRule + ] + ); + + const table = useReactTable({ + data: rules, + columns, + getCoreRowModel: getCoreRowModel(), + getPaginationRowModel: getPaginationRowModel(), + getSortedRowModel: getSortedRowModel(), + getFilteredRowModel: getFilteredRowModel(), + state: { pagination: { pageIndex: 0, pageSize: 1000 } } + }); + + if (!isOpen) { + return ( + + + + {t("rulesResource")} + + + {t("rulesResourcePolicyDescription")} + + + + + + + ); + } + + return ( + + + + {t("rulesResource")} + + + {t("rulesResourceDescription")} + + + +
+
+ { + setRulesEnabled(val); + form.setValue("applyRules", val); + }} + /> +
+ +
+ +
+ ( + + + {t("rulesAction")} + + + + + + + )} + /> + ( + + + {t("rulesMatchType")} + + + + + + + )} + /> + ( + + + + {addRuleForm.watch("match") === + "COUNTRY" ? ( + + + + + + + + + + {t( + "noCountryFound" + )} + + + {COUNTRIES.map( + ( + country + ) => ( + { + field.onChange( + country.code + ); + setOpenAddRuleCountrySelect( + false + ); + }} + > + + { + country.name + }{" "} + ( + { + country.code + } + + ) + + ) + )} + + + + + + ) : addRuleForm.watch( + "match" + ) === "ASN" ? ( + + + + + + + + + + No ASN + found. + Use the + custom + input + below. + + + {MAJOR_ASNS.map( + ( + asn + ) => ( + { + field.onChange( + asn.code + ); + setOpenAddRuleAsnSelect( + false + ); + }} + > + + { + asn.name + }{" "} + ( + { + asn.code + } + + ) + + ) + )} + + + +
+ { + if ( + e.key === + "Enter" + ) { + const value = + e.currentTarget.value + .toUpperCase() + .replace( + /^AS/, + "" + ); + if ( + /^\d+$/.test( + value + ) + ) { + field.onChange( + "AS" + + value + ); + setOpenAddRuleAsnSelect( + false + ); + } + } + }} + className="text-sm" + /> +
+
+
+ ) : ( + + )} +
+ +
+ )} + /> + +
+
+ + + + + {table.getHeaderGroups().map((headerGroup) => ( + + {headerGroup.headers.map((header) => { + const isActionsColumn = + header.column.id === "actions"; + return ( + + {header.isPlaceholder + ? null + : flexRender( + header.column + .columnDef.header, + header.getContext() + )} + + ); + })} + + ))} + + + {table.getRowModel().rows?.length ? ( + table.getRowModel().rows.map((row) => ( + + {row.getVisibleCells().map((cell) => { + const isActionsColumn = + cell.column.id === "actions"; + return ( + + {flexRender( + cell.column.columnDef + .cell, + cell.getContext() + )} + + ); + })} + + )) + ) : ( + + + {t("rulesNoOne")} + + + )} + +
+
+
+
+ ); +} diff --git a/src/components/resource-policy/ResourcePolicySubForms.tsx b/src/components/resource-policy/ResourcePolicySubForms.tsx index 3b0056ba4..37a83b3fe 100644 --- a/src/components/resource-policy/ResourcePolicySubForms.tsx +++ b/src/components/resource-policy/ResourcePolicySubForms.tsx @@ -121,6 +121,62 @@ type LocalRule = { updated?: boolean; }; +// ─── PolicyNameSection ────────────────────────────────────────────────── +type PolicyNameSectionProps = { + form: UseFormReturn; + isEditing?: boolean; +}; + +export function PolicyNameSection({ form }: PolicyNameSectionProps) { + const t = useTranslations(); + return ( + + + + {t("resourcePolicyName")} + + + {t("resourcePolicyNameDescription")} + + + + + ( + + {t("name")} + + + + + + )} + /> + + + +
+ +
+
+ ); +} + +// ─── PolicyUsersRolesSection ────────────────────────────────────────────────── + type PolicyUsersRolesSectionProps = { form: UseFormReturn; allRoles: { id: string; text: string }[]; @@ -128,8 +184,6 @@ type PolicyUsersRolesSectionProps = { allIdps: { id: number; text: string }[]; }; -// ─── PolicyUsersRolesSection ────────────────────────────────────────────────── - export function PolicyUsersRolesSection({ form, allRoles, diff --git a/src/components/resource-policy/index.ts b/src/components/resource-policy/index.ts index 7b77faddb..8579a6de5 100644 --- a/src/components/resource-policy/index.ts +++ b/src/components/resource-policy/index.ts @@ -45,3 +45,21 @@ export const createPolicySchema = z.object({ }); export type PolicyFormValues = z.infer; + +export const addRuleSchema = z.object({ + action: z.enum(["ACCEPT", "DROP", "PASS"]), + match: z.string(), + value: z.string(), + priority: z.coerce.number().int().optional() +}); + +export type LocalRule = { + ruleId: number; + action: "ACCEPT" | "DROP" | "PASS"; + match: string; + value: string; + priority: number; + enabled: boolean; + new?: boolean; + updated?: boolean; +};