diff --git a/messages/en-US.json b/messages/en-US.json index ea4d1fc89..8454467b2 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -756,11 +756,11 @@ "rulesErrorDuplicate": "Duplicate rule", "rulesErrorDuplicateDescription": "A rule with these settings already exists", "rulesErrorInvalidIpAddressRange": "Invalid CIDR", - "rulesErrorInvalidIpAddressRangeDescription": "Please enter a valid CIDR value", - "rulesErrorInvalidUrl": "Invalid URL path", - "rulesErrorInvalidUrlDescription": "Please enter a valid URL path value", - "rulesErrorInvalidIpAddress": "Invalid IP", - "rulesErrorInvalidIpAddressDescription": "Please enter a valid IP address", + "rulesErrorInvalidIpAddressRangeDescription": "Enter a valid CIDR range (e.g., 10.0.0.0/8).", + "rulesErrorInvalidUrl": "Invalid path", + "rulesErrorInvalidUrlDescription": "Enter a valid URL path or pattern (e.g., /api/*).", + "rulesErrorInvalidIpAddress": "Invalid IP address", + "rulesErrorInvalidIpAddressDescription": "Enter a valid IPv4 or IPv6 address.", "rulesErrorUpdate": "Failed to update rules", "rulesErrorUpdateDescription": "An error occurred while updating rules", "rulesUpdated": "Enable Rules", @@ -768,15 +768,23 @@ "rulesMatchIpAddressRangeDescription": "Enter an address in CIDR format (e.g., 103.21.244.0/22)", "rulesMatchIpAddress": "Enter an IP address (e.g., 103.21.244.12)", "rulesMatchUrl": "Enter a URL path or pattern (e.g., /api/v1/todos or /api/v1/*)", - "rulesErrorInvalidPriority": "Invalid Priority", - "rulesErrorInvalidPriorityDescription": "Please enter a valid priority", - "rulesErrorDuplicatePriority": "Duplicate Priorities", - "rulesErrorDuplicatePriorityDescription": "Please enter unique priorities", + "rulesErrorInvalidPriority": "Invalid priority", + "rulesErrorInvalidPriorityDescription": "Enter a whole number of 1 or higher.", + "rulesErrorDuplicatePriority": "Duplicate priorities", + "rulesErrorDuplicatePriorityDescription": "Each rule must have a unique priority number.", + "rulesErrorValidation": "Invalid rules", + "rulesErrorValidationRuleDescription": "Rule {ruleNumber}: {message}", + "rulesErrorValueRequired": "Enter a value for this rule.", + "rulesErrorInvalidCountry": "Invalid country", + "rulesErrorInvalidCountryDescription": "Select a valid country.", + "rulesErrorInvalidAsn": "Invalid ASN", + "rulesErrorInvalidAsnDescription": "Enter a valid ASN (e.g., AS15169).", "ruleUpdated": "Rules updated", "ruleUpdatedDescription": "Rules updated successfully", "ruleErrorUpdate": "Operation failed", "ruleErrorUpdateDescription": "An error occurred during the save operation", "rulesPriority": "Priority", + "rulesReorderDragHandle": "Drag to reorder rule priority", "rulesAction": "Action", "rulesMatchType": "Match Type", "value": "Value", @@ -795,7 +803,7 @@ "rulesResource": "Resource Rules Configuration", "rulesResourceDescription": "Configure rules to control access to the resource", "ruleSubmit": "Add Rule", - "rulesNoOne": "No rules. Add a rule using the form.", + "rulesNoOne": "No rules yet.", "rulesOrder": "Rules are evaluated by priority in ascending order.", "rulesSubmit": "Save Rules", "policyErrorCreate": "Error creating policy", @@ -806,7 +814,44 @@ "policyErrorUpdateMessageDescription": "An unexpected error occurred", "policyCreatedSuccess": "Resource policy succesfully created", "policyUpdatedSuccess": "Resource policy succesfully updated", - "authMethodsSave": "Save auth methods", + "authMethodsSave": "Save Settings", + "policyAuthStackTitle": "Authentication", + "policyAuthStackDescription": "Control which authentication methods are required to access this resource", + "policyAuthOrLogicTitle": "Multiple authentication methods active", + "policyAuthOrLogicBanner": "Visitors may authenticate using any one of the active methods below. They do not need to complete all of them.", + "policyAuthMethodActive": "Active", + "policyAuthMethodOff": "Off", + "policyAuthSsoTitle": "Platform SSO", + "policyAuthSsoDescription": "Require sign-in through your organization's identity provider", + "policyAuthSsoSummary": "{idp} · {users} users, {roles} roles", + "policyAuthSsoDefaultIdp": "Default provider", + "policyAuthAddDefaultIdentityProvider": "Add Default Identity Provider", + "policyAuthOtherMethodsTitle": "Other Methods", + "policyAuthOtherMethodsDescription": "Optional methods visitors can use instead of or alongside platform SSO", + "policyAuthPasscodeTitle": "Passcode", + "policyAuthPasscodeDescription": "Require a shared alphanumeric passcode to access the resource", + "policyAuthPasscodeSummary": "Passcode set", + "policyAuthPincodeTitle": "PIN Code", + "policyAuthPincodeDescription": "A short numeric code required to access the resource", + "policyAuthPincodeSummary": "6-digit PIN set", + "policyAuthEmailTitle": "Email Whitelist", + "policyAuthEmailDescription": "Allow listed email addresses with one-time passwords", + "policyAuthEmailSummary": "{count} addresses allowed", + "policyAuthEmailOtpCallout": "Enabling email whitelist sends a one-time password to the visitor's email on login.", + "policyAuthHeaderAuthTitle": "Basic Header Auth", + "policyAuthHeaderAuthDescription": "Validate a custom HTTP header name and value on each request", + "policyAuthHeaderAuthSummary": "Header configured", + "policyAuthHeaderName": "Header name", + "policyAuthHeaderValue": "Expected value", + "policyAccessRulesTitle": "Access Rules", + "policyAccessRulesEnableDescription": "When enabled, rules are evaluated in descending order until one evaluates as true.", + "policyAccessRulesFirstMatch": "Rules are evaluated top to bottom. The first matching rule decides the outcome.", + "policyAccessRulesHowItWorks": "Rules match requests by path, IP address, location, or other criteria. Each rule applies an action: bypass authentication, block access, or pass to authentication. If no rule matches, traffic continues to authentication.", + "policyAccessRulesFallthroughOff": "When rules are disabled, all traffic passes through to authentication.", + "policyAccessRulesFallthroughOn": "When no rule matches, traffic passes through to authentication.", + "rulesPlaceholderCidr": "10.0.0.0/8", + "rulesPlaceholderPath": "/admin/*", + "rulesPlaceholderGeo": "RU, KP", "rulesSave": "Save Rules", "resourceErrorCreate": "Error creating resource", "resourceErrorCreateDescription": "An error occurred when creating the resource", @@ -3045,7 +3090,7 @@ "enterConfirmation": "Enter confirmation", "blueprintViewDetails": "Details", "defaultIdentityProvider": "Default Identity Provider", - "defaultIdentityProviderDescription": "When a default identity provider is selected, the user will be automatically redirected to the provider for authentication.", + "defaultIdentityProviderDescription": "The user will be automatically redirected to this identity provider for authentication.", "editInternalResourceDialogNetworkSettings": "Network Settings", "editInternalResourceDialogAccessPolicy": "Access Policy", "editInternalResourceDialogAddRoles": "Add Roles", diff --git a/src/app/[orgId]/settings/resources/public/[niceId]/authentication/page.tsx b/src/app/[orgId]/settings/resources/public/[niceId]/authentication/page.tsx index 5c02e8c8f..81a7db9ca 100644 --- a/src/app/[orgId]/settings/resources/public/[niceId]/authentication/page.tsx +++ b/src/app/[orgId]/settings/resources/public/[niceId]/authentication/page.tsx @@ -1,6 +1,5 @@ "use client"; -import ActionBanner from "@app/components/ActionBanner"; import { EditPolicyForm } from "@app/components/resource-policy/EditPolicyForm"; import { SettingsContainer, @@ -45,9 +44,8 @@ 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 { ArrowRightIcon, CheckIcon, ShieldAlertIcon } from "lucide-react"; +import { CheckIcon } from "lucide-react"; import { useTranslations } from "next-intl"; -import Link from "next/link"; import { useRouter } from "next/navigation"; import { useEffect, useState, useTransition } from "react"; import { useForm, useWatch } from "react-hook-form"; @@ -184,10 +182,6 @@ export default function ResourceAuthenticationPage() { return <>; } - console.log({ - shared: policies.sharedPolicy - }); - return ( <> @@ -314,30 +308,6 @@ export default function ResourceAuthenticationPage() { policy={policies.sharedPolicy} key={policies.sharedPolicy.resourcePolicyId} > - - } - description={t( - "resourcePolicySharedDescription" - )} - actions={ - - } - /> diff --git a/src/components/resource-policy/CreatePolicyAuthMethodsSectionForm.tsx b/src/components/resource-policy/CreatePolicyAuthMethodsSectionForm.tsx deleted file mode 100644 index c4d17e77f..000000000 --- a/src/components/resource-policy/CreatePolicyAuthMethodsSectionForm.tsx +++ /dev/null @@ -1,530 +0,0 @@ -"use client"; - -import { - SettingsSection, - SettingsSectionBody, - SettingsSectionDescription, - SettingsSectionForm, - SettingsSectionHeader, - SettingsSectionTitle -} from "@app/components/Settings"; - -import { zodResolver } from "@hookform/resolvers/zod"; -import { useTranslations } from "next-intl"; - -import z from "zod"; - -import { createPolicySchema, type PolicyFormValues } from "."; - -import { SwitchInput } from "@app/components/SwitchInput"; -import { Button } from "@app/components/ui/button"; -import { - Credenza, - CredenzaBody, - CredenzaClose, - CredenzaContent, - CredenzaDescription, - CredenzaFooter, - CredenzaHeader, - CredenzaTitle -} from "@app/components/Credenza"; -import { - Form, - FormControl, - FormField, - FormItem, - FormLabel, - FormMessage -} from "@app/components/ui/form"; -import { Input } from "@app/components/ui/input"; -import { - InputOTP, - InputOTPGroup, - InputOTPSlot -} from "@app/components/ui/input-otp"; - -import { cn } from "@app/lib/cn"; -import { Binary, Bot, Key, Plus } from "lucide-react"; - -import { useEffect, useState } from "react"; -import { type UseFormReturn, useForm, useWatch } from "react-hook-form"; - -// ─── CreatePolicyAuthMethodsSectionForm ─────────────────────────────────────── - -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() -}); - -export type CreatePolicyAuthMethodsSectionFormProps = { - form: UseFormReturn; -}; - -export function CreatePolicyAuthMethodsSectionForm({ - form: parentForm -}: CreatePolicyAuthMethodsSectionFormProps) { - const t = useTranslations(); - const [isExpanded, setIsExpanded] = useState(false); - const [isSetPasswordOpen, setIsSetPasswordOpen] = useState(false); - const [isSetPincodeOpen, setIsSetPincodeOpen] = useState(false); - const [isSetHeaderAuthOpen, setIsSetHeaderAuthOpen] = useState(false); - - const form = useForm({ - resolver: zodResolver( - createPolicySchema.pick({ - password: true, - pincode: true, - headerAuth: true - }) - ), - defaultValues: { - password: null, - pincode: null, - headerAuth: null - } - }); - - useEffect(() => { - const subscription = form.watch((values) => { - parentForm.setValue("password", values.password as any); - parentForm.setValue("pincode", values.pincode as any); - parentForm.setValue("headerAuth", values.headerAuth as any); - }); - return () => subscription.unsubscribe(); - }, [form, parentForm]); - - const password = useWatch({ - control: form.control, - name: "password" - }); - const pincode = useWatch({ - control: form.control, - name: "pincode" - }); - const headerAuth = useWatch({ - control: form.control, - name: "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 (!isExpanded) { - 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" - )} - -
- -
-
-
-
- - ); -} diff --git a/src/components/resource-policy/CreatePolicyForm.tsx b/src/components/resource-policy/CreatePolicyForm.tsx index c422a876a..17bc8b634 100644 --- a/src/components/resource-policy/CreatePolicyForm.tsx +++ b/src/components/resource-policy/CreatePolicyForm.tsx @@ -19,7 +19,11 @@ import { build } from "@server/build"; import { UserType } from "@server/types/UserTypes"; import { useQuery } from "@tanstack/react-query"; import { useTranslations } from "next-intl"; -import { type PolicyFormValues, createPolicySchema } from "."; +import { + type PolicyFormValues, + createPolicySchema, + createPolicySchemaWithI18n +} from "."; import { toast } from "@app/hooks/useToast"; import { createApiClient, formatAxiosError } from "@app/lib/api"; import { orgs, type ResourcePolicy } from "@server/db"; @@ -37,10 +41,8 @@ import { import { Input } from "@app/components/ui/input"; import { useMemo, useTransition } from "react"; import { useForm } from "react-hook-form"; -import { CreatePolicyUsersRolesSectionForm } from "./CreatePolicyUserRolesSectionForm"; -import { CreatePolicyAuthMethodsSectionForm } from "./CreatePolicyAuthMethodsSectionForm"; -import { CreatePolicyOtpEmailSectionForm } from "./CreatePolicyOtpEmailSectionForm"; -import { CreatePolicyRulesSectionForm } from "./CreatePolicyRulesSectionForm"; +import { PolicyAuthStackSection } from "./PolicyAuthStackSection"; +import { PolicyAccessRulesSection } from "./PolicyAccessRulesSection"; import { PaidFeaturesAlert } from "@app/components/PaidFeaturesAlert"; import { tierMatrix, TierFeature } from "@server/lib/billing/tierMatrix"; @@ -78,8 +80,13 @@ export function CreatePolicyForm({}: CreatePolicyFormProps) { }) ); + const policySchema = useMemo( + () => createPolicySchemaWithI18n(t, createPolicySchema), + [t] + ); + const form = useForm({ - resolver: zodResolver(createPolicySchema) as any, + resolver: zodResolver(policySchema) as any, defaultValues: { name: "", sso: true, @@ -245,18 +252,17 @@ export function CreatePolicyForm({}: CreatePolicyFormProps) { - - - - ; - emailEnabled: boolean; -}; - -export function CreatePolicyOtpEmailSectionForm({ - form: parentForm, - emailEnabled -}: CreatePolicyOtpEmailSectionFormProps) { - const t = useTranslations(); - const [isExpanded, setIsExpanded] = useState(false); - const [activeEmailTagIndex, setActiveEmailTagIndex] = useState< - number | null - >(null); - - const form = useForm({ - resolver: zodResolver( - createPolicySchema.pick({ - emailWhitelistEnabled: true, - emails: true - }) - ), - defaultValues: { - emailWhitelistEnabled: false, - emails: [] - } - }); - - useEffect(() => { - const subscription = form.watch((values) => { - parentForm.setValue( - "emailWhitelistEnabled", - values.emailWhitelistEnabled as boolean - ); - parentForm.setValue("emails", values.emails as [Tag, ...Tag[]]); - }); - return () => subscription.unsubscribe(); - }, [form, parentForm]); - - const whitelistEnabled = useWatch({ - control: form.control, - name: "emailWhitelistEnabled" - }); - - if (!isExpanded) { - return ( - - - - {t("otpEmailTitle")} - - - {t("otpEmailTitleDescription")} - - - - - - - ); - } - - return ( -
- - - - {t("otpEmailTitle")} - - - {t("otpEmailTitleDescription")} - - - - - {!emailEnabled && ( - - - - {t("otpEmailSmtpRequired")} - - - {t("otpEmailSmtpRequiredDescription")} - - - )} - { - 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")} - - - )} - /> - )} - - - -
- ); -} diff --git a/src/components/resource-policy/CreatePolicyRulesSectionForm.tsx b/src/components/resource-policy/CreatePolicyRulesSectionForm.tsx index 4550d5bfb..042b63e63 100644 --- a/src/components/resource-policy/CreatePolicyRulesSectionForm.tsx +++ b/src/components/resource-policy/CreatePolicyRulesSectionForm.tsx @@ -11,93 +11,19 @@ import { import { zodResolver } from "@hookform/resolvers/zod"; import { useTranslations } from "next-intl"; -import z from "zod"; - -import { createPolicySchema, type PolicyFormValues } from "."; -import { toast } from "@app/hooks/useToast"; - -import { SwitchInput } from "@app/components/SwitchInput"; +import { createPolicyRulesSectionSchema, type PolicyFormValues } from "."; import { Button } from "@app/components/ui/button"; -import { - Command, - CommandEmpty, - CommandGroup, - CommandInput, - CommandItem, - CommandList -} from "@app/components/ui/command"; -import { - Form, - FormControl, - 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 { 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, Check, ChevronsUpDown, Plus } from "lucide-react"; +import { Plus } from "lucide-react"; import { useCallback, useEffect, useMemo, useState } from "react"; import { type UseFormReturn, useForm, useWatch } from "react-hook-form"; -// ─── CreatePolicyRulesSectionForm ───────────────────────────────────────────── - -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; -}; +import { PolicyAccessRulesIntro } from "./PolicyAccessRulesIntro"; +import { PolicyAccessRulesTable } from "./PolicyAccessRulesTable"; +import { + createEmptyRule, + type PolicyAccessRule +} from "./policy-access-rule-utils"; export type CreatePolicyRulesSectionFormProps = { form: UseFormReturn; @@ -111,19 +37,15 @@ export function CreatePolicyRulesSectionForm({ isMaxmindAsnAvailable }: CreatePolicyRulesSectionFormProps) { const t = useTranslations(); - const [isExpanded, setIsExpanded] = useState(false); - const [rules, setRules] = useState([]); - const [openAddRuleCountrySelect, setOpenAddRuleCountrySelect] = - useState(false); - const [openAddRuleAsnSelect, setOpenAddRuleAsnSelect] = useState(false); + const [rules, setRules] = useState([]); + + const rulesFormSchema = useMemo( + () => createPolicyRulesSectionSchema(t), + [t] + ); const form = useForm({ - resolver: zodResolver( - createPolicySchema.pick({ - applyRules: true, - rules: true - }) - ), + resolver: zodResolver(rulesFormSchema), defaultValues: { applyRules: false, rules: [] @@ -143,37 +65,8 @@ export function CreatePolicyRulesSectionForm({ name: "applyRules" }); - const addRuleForm = useForm({ - resolver: zodResolver(addRuleSchema), - defaultValues: { - action: "ACCEPT" as const, - match: "PATH", - 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[]) => { + (updatedRules: PolicyAccessRule[]) => { form.setValue( "rules", updatedRules.map( @@ -190,84 +83,11 @@ export function CreatePolicyRulesSectionForm({ [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 addEmptyRule = useCallback(() => { + const updatedRules = [...rules, createEmptyRule(rules)]; + setRules(updatedRules); + syncFormRules(updatedRules); + }, [rules, syncFormRules]); const removeRule = useCallback( function removeRule(ruleId: number) { @@ -279,7 +99,7 @@ export function CreatePolicyRulesSectionForm({ ); const updateRule = useCallback( - function updateRule(ruleId: number, data: Partial) { + function updateRule(ruleId: number, data: Partial) { const updatedRules = rules.map((rule) => rule.ruleId === ruleId ? { ...rule, ...data, updated: true } @@ -291,378 +111,28 @@ export function CreatePolicyRulesSectionForm({ [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)"; - } + const handleRulesChange = useCallback( + (updatedRules: PolicyAccessRule[]) => { + setRules(updatedRules); + syncFormRules(updatedRules); }, - [t] + [syncFormRules] ); - 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 addRuleButton = ( + ); - const table = useReactTable({ - data: rules, - columns, - getCoreRowModel: getCoreRowModel(), - getPaginationRowModel: getPaginationRowModel(), - getSortedRowModel: getSortedRowModel(), - getFilteredRowModel: getFilteredRowModel(), - state: { pagination: { pageIndex: 0, pageSize: 1000 } } - }); - - if (!isExpanded) { - return ( - - - - {t("rulesResource")} - - - {t("rulesResourcePolicyDescription")} - - - - - - - ); - } + const hasRules = rules.length > 0; return ( - {t("rulesResource")} + {t("policyAccessRulesTitle")} {t("rulesResourceDescription")} @@ -670,421 +140,28 @@ export function CreatePolicyRulesSectionForm({
-
- { - form.setValue("applyRules", 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")} - - - )} - -
+ {rulesEnabled && ( + <> + + {hasRules && addRuleButton} + + )}
diff --git a/src/components/resource-policy/CreatePolicyUserRolesSectionForm.tsx b/src/components/resource-policy/CreatePolicyUserRolesSectionForm.tsx deleted file mode 100644 index 132363fc1..000000000 --- a/src/components/resource-policy/CreatePolicyUserRolesSectionForm.tsx +++ /dev/null @@ -1,257 +0,0 @@ -"use client"; - -import { - SettingsSection, - SettingsSectionBody, - SettingsSectionDescription, - SettingsSectionForm, - SettingsSectionHeader, - SettingsSectionTitle -} from "@app/components/Settings"; - -import { zodResolver } from "@hookform/resolvers/zod"; -import { SwitchInput } from "@app/components/SwitchInput"; -import { Tag, TagInput } from "@app/components/tags/tag-input"; -import { - Form, - FormControl, - FormDescription, - FormField, - FormItem, - FormLabel, - FormMessage -} from "@app/components/ui/form"; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue -} from "@app/components/ui/select"; -import { createPolicySchema, type PolicyFormValues } from "."; -import { useTranslations } from "next-intl"; -import { useEffect, useState } from "react"; -import { type UseFormReturn, useForm, useWatch } from "react-hook-form"; - -// ─── CreatePolicyUsersRolesSectionForm ──────────────────────────────────────── - -export type CreatePolicyUsersRolesSectionFormProps = { - form: UseFormReturn; - allRoles: { id: string; text: string }[]; - allUsers: { id: string; text: string }[]; - allIdps: { id: number; text: string }[]; -}; - -export function CreatePolicyUsersRolesSectionForm({ - form: parentForm, - allRoles, - allUsers, - allIdps -}: CreatePolicyUsersRolesSectionFormProps) { - const t = useTranslations(); - - const form = useForm({ - resolver: zodResolver( - createPolicySchema.pick({ - sso: true, - skipToIdpId: true, - roles: true, - users: true - }) - ), - defaultValues: { - sso: true, - skipToIdpId: null, - roles: [], - users: [] - } - }); - - useEffect(() => { - const subscription = form.watch((values) => { - parentForm.setValue("sso", values.sso as boolean); - parentForm.setValue("skipToIdpId", values.skipToIdpId as number | null); - parentForm.setValue("roles", values.roles as [Tag, ...Tag[]]); - parentForm.setValue("users", values.users as [Tag, ...Tag[]]); - }); - return () => subscription.unsubscribe(); - }, [form, parentForm]); - - 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")} - - - - - { - 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")} -

-
- )} -
-
-
-
- ); -} diff --git a/src/components/resource-policy/EditPolicyAuthMethodsSectionForm.tsx b/src/components/resource-policy/EditPolicyAuthMethodsSectionForm.tsx deleted file mode 100644 index 1fa241753..000000000 --- a/src/components/resource-policy/EditPolicyAuthMethodsSectionForm.tsx +++ /dev/null @@ -1,671 +0,0 @@ -"use client"; - -import { - SettingsSection, - SettingsSectionBody, - SettingsSectionDescription, - SettingsSectionFooter, - SettingsSectionForm, - SettingsSectionHeader, - SettingsSectionTitle -} from "@app/components/Settings"; - -import { useEnvContext } from "@app/hooks/useEnvContext"; - -import { zodResolver } from "@hookform/resolvers/zod"; -import { useTranslations } from "next-intl"; - -import z from "zod"; - -import { createApiClient, formatAxiosError } from "@app/lib/api"; -import { useRouter } from "next/navigation"; -import { createPolicySchema } from "."; - -import { - Credenza, - CredenzaBody, - CredenzaClose, - CredenzaContent, - CredenzaDescription, - CredenzaFooter, - CredenzaHeader, - CredenzaTitle -} from "@app/components/Credenza"; -import { SwitchInput } from "@app/components/SwitchInput"; -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 { - InputOTP, - InputOTPGroup, - InputOTPSlot -} from "@app/components/ui/input-otp"; - -import { Binary, Bot, Key, Plus } from "lucide-react"; - -import { cn } from "@app/lib/cn"; -import { useResourcePolicyContext } from "@app/providers/ResourcePolicyProvider"; -import { useActionState, useState } from "react"; -import { useForm } from "react-hook-form"; -import { toast } from "@app/hooks/useToast"; -import type { AxiosResponse } from "axios"; - -// ─── 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() -}); - -export function EditPolicyAuthMethodsSectionForm({ - readonly -}: { - readonly?: boolean; -}) { - const { policy } = useResourcePolicyContext(); - const router = useRouter(); - - const api = createApiClient(useEnvContext()); - - const form = useForm({ - resolver: zodResolver( - createPolicySchema.pick({ - password: true, - pincode: true, - headerAuth: true - }) - ) - }); - - const t = useTranslations(); - 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"); - - // If explicitly removed (set to `null`) it means the value has been removed - // in the other case (`undefined` or object value), check if the value has been modified - // and fallback to the policy default value - const hasPassword = - password !== null ? Boolean(password ?? policy.passwordId) : false; - - const hasPincode = - pincode !== null ? Boolean(pincode ?? policy.pincodeId) : false; - - const hasHeaderAuth = - headerAuth !== null ? Boolean(headerAuth ?? policy.headerAuth) : false; - - const [isExpanded, setIsExpanded] = useState( - hasPassword || hasPincode || hasHeaderAuth - ); - - 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 } - }); - - const [, formAction, isSubmitting] = useActionState(onSubmit, null); - - async function onSubmit() { - if (readonly) return; - const isValid = await form.trigger(); - - if (!isValid) return; - - const payload = form.getValues(); - - const responseArray: Array | void>> = []; - - if (typeof payload.password !== "undefined") { - responseArray.push( - api - .put>( - `/resource-policy/${policy.resourcePolicyId}/password`, - { - password: payload.password?.password ?? null - } - ) - .catch((e) => { - toast({ - variant: "destructive", - title: t("policyErrorUpdate"), - description: formatAxiosError( - e, - t("policyErrorUpdateDescription") - ) - }); - }) - ); - } - - if (typeof payload.pincode !== "undefined") { - responseArray.push( - api - .put>( - `/resource-policy/${policy.resourcePolicyId}/pincode`, - { - pincode: payload.pincode?.pincode ?? null - } - ) - .catch((e) => { - toast({ - variant: "destructive", - title: t("policyErrorUpdate"), - description: formatAxiosError( - e, - t("policyErrorUpdateDescription") - ) - }); - }) - ); - } - - if (typeof payload.headerAuth !== "undefined") { - responseArray.push( - api - .put>( - `/resource-policy/${policy.resourcePolicyId}/header-auth`, - { - headerAuth: payload.headerAuth - } - ) - .catch((e) => { - toast({ - variant: "destructive", - title: t("policyErrorUpdate"), - description: formatAxiosError( - e, - t("policyErrorUpdateDescription") - ) - }); - }) - ); - } - - try { - const responseList = await Promise.all(responseArray); - - if (responseList.every((res) => res && res.status === 200)) { - toast({ - title: t("success"), - description: t("policyUpdatedSuccess") - }); - router.refresh(); - } - } catch (e) { - toast({ - variant: "destructive", - title: t("policyErrorUpdate"), - description: t("policyErrorUpdateMessageDescription") - }); - } - } - - if (!isExpanded) { - return ( - - - - {t("resourceAuthMethods")} - - - {t("resourcePolicyAuthMethodsDescription")} - - - - {!readonly ? ( - - ) : ( -
-

{t("resourcePolicyAuthMethodsEmpty")}

-
- )} -
-
- ); - } - - 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: hasPassword - ? t("enabled") - : t("disabled") - })} - -
- -
- - {/* Pincode row */} -
-
- - - {t("resourcePincodeProtection", { - status: hasPincode - ? t("enabled") - : t("disabled") - })} - -
- -
- - {/* Header auth row */} -
-
- - - {hasHeaderAuth - ? t( - "resourceHeaderAuthProtectionEnabled" - ) - : t( - "resourceHeaderAuthProtectionDisabled" - )} - -
- -
-
-
- - - - -
-
- - - ); -} diff --git a/src/components/resource-policy/EditPolicyForm.tsx b/src/components/resource-policy/EditPolicyForm.tsx index bed4a4647..5acc6de72 100644 --- a/src/components/resource-policy/EditPolicyForm.tsx +++ b/src/components/resource-policy/EditPolicyForm.tsx @@ -12,17 +12,11 @@ import { tierMatrix } from "@server/lib/billing/tierMatrix"; import { useQuery } from "@tanstack/react-query"; import { useTranslations } from "next-intl"; -import { createApiClient } from "@app/lib/api"; -import { useRouter } from "next/navigation"; - import { useMemo } from "react"; -import { EditPolicyAuthMethodsSectionForm } from "./EditPolicyAuthMethodsSectionForm"; +import { HorizontalTabs } from "@app/components/HorizontalTabs"; import { EditPolicyNameSectionForm } from "./EditPolicyNameSectionForm"; -import { EditPolicyUsersRolesSectionForm } from "./EditPolicyUserRolesSectionForm"; -import { EditPolicyOtpEmailSectionForm } from "./EditPolicyOtpEmailSectionForm"; -import { EditPolicyRulesSectionForm } from "./EditPolicyRulesSectionForm"; - -// ─── EditPolicyForm ───────────────────────────────────────────────────────── +import { PolicyAuthStackSection } from "./PolicyAuthStackSection"; +import { PolicyAccessRulesSection } from "./PolicyAccessRulesSection"; export type EditPolicyFormProps = { hidePolicyNameForm?: boolean; @@ -35,19 +29,15 @@ export function EditPolicyForm({ readonly, resourceId }: EditPolicyFormProps) { - const { org } = useOrgContext(); const t = useTranslations(); + const { org } = useOrgContext(); const { env } = useEnvContext(); - const api = createApiClient({ env }); - // const [, formAction, isSubmitting] = useActionState(onSubmit, null); const { isPaidUser } = usePaidStatus(); - const router = useRouter(); - // In overlay mode (resourceId provided), policy-level sections are locked. // Rules and users/roles sections handle their own hybrid logic via resourceId. const isOverlay = resourceId !== undefined; - const policyLevelReadonly = readonly || isOverlay; + const showTabs = !hidePolicyNameForm && !isOverlay; const isMaxmindAvailable = !!( env.server.maxmind_db_path && env.server.maxmind_db_path.length > 0 @@ -81,32 +71,54 @@ export function EditPolicyForm({ return <>; } + const authSection = ( + + ); + + const rulesSection = ( + + ); + + if (showTabs) { + return ( + + + {authSection} + {rulesSection} + + ); + } + return ( - {!hidePolicyNameForm && ( - + {!hidePolicyNameForm && !isOverlay && ( + )} - + {authSection} - - - - - + {rulesSection} ); } diff --git a/src/components/resource-policy/EditPolicyNameSectionForm.tsx b/src/components/resource-policy/EditPolicyNameSectionForm.tsx index 87c1b5cdc..5acb6fd7b 100644 --- a/src/components/resource-policy/EditPolicyNameSectionForm.tsx +++ b/src/components/resource-policy/EditPolicyNameSectionForm.tsx @@ -137,7 +137,7 @@ export function EditPolicyNameSectionForm({ - + ({ - id: email.whiteListId.toString(), - text: email.email - })) - } - }); - - const whitelistEnabled = useWatch({ - control: form.control, - name: "emailWhitelistEnabled" - }); - - const [isExpanded, setIsExpanded] = useState(whitelistEnabled); - const [activeEmailTagIndex, setActiveEmailTagIndex] = useState< - number | null - >(null); - - const [, formAction, isSubmitting] = useActionState(onSubmit, null); - - async function onSubmit() { - if (readonly) return; - const isValid = await form.trigger(); - - if (!isValid) return; - - const payload = form.getValues(); - - try { - const res = await api - .put>( - `/resource-policy/${policy.resourcePolicyId}/whitelist`, - { - emailWhitelistEnabled: payload.emailWhitelistEnabled, - emails: payload.emails?.map((e) => e.text) ?? [] - } - ) - .catch((e) => { - toast({ - variant: "destructive", - title: t("policyErrorUpdate"), - description: formatAxiosError( - e, - t("policyErrorUpdateDescription") - ) - }); - }); - - if (res && res.status === 200) { - toast({ - title: t("success"), - description: t("policyUpdatedSuccess") - }); - router.refresh(); - } - } catch (e) { - toast({ - variant: "destructive", - title: t("policyErrorUpdate"), - description: t("policyErrorUpdateMessageDescription") - }); - } - } - - if (!isExpanded) { - return ( - - - - {t("otpEmailTitle")} - - - {t("otpEmailTitleDescription")} - - - - {!readonly ? ( - - ) : ( -
-

{t("resourcePolicyOtpEmpty")}

-
- )} -
-
- ); - } - - return ( -
- - - - - {t("otpEmailTitle")} - - - {t("otpEmailTitleDescription")} - - - - - {!emailEnabled && ( - - - - {t("otpEmailSmtpRequired")} - - - {t("otpEmailSmtpRequiredDescription")} - - - )} - { - form.setValue("emailWhitelistEnabled", val); - }} - disabled={readonly || !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) => { - if (!readonly) { - form.setValue( - "emails", - newEmails as [ - Tag, - ...Tag[] - ] - ); - } - }} - allowDuplicates={false} - sortTags={true} - /> - - - {t("otpEmailEnterDescription")} - - - )} - /> - )} - - - - - - - -
- - ); -} diff --git a/src/components/resource-policy/EditPolicyRulesSectionForm.tsx b/src/components/resource-policy/EditPolicyRulesSectionForm.tsx deleted file mode 100644 index 15669272b..000000000 --- a/src/components/resource-policy/EditPolicyRulesSectionForm.tsx +++ /dev/null @@ -1,1649 +0,0 @@ -"use client"; - -import { - SettingsSection, - SettingsSectionBody, - SettingsSectionDescription, - SettingsSectionFooter, - SettingsSectionHeader, - SettingsSectionTitle -} from "@app/components/Settings"; - -import { zodResolver } from "@hookform/resolvers/zod"; -import { useTranslations } from "next-intl"; - -import z from "zod"; - -import { toast } from "@app/hooks/useToast"; -import { createPolicySchema, type PolicyFormValues } from "."; - -import { SwitchInput } from "@app/components/SwitchInput"; -import { Button } from "@app/components/ui/button"; -import { - Command, - CommandEmpty, - CommandGroup, - CommandInput, - CommandItem, - CommandList -} from "@app/components/ui/command"; -import { - Form, - FormControl, - 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 { MAJOR_ASNS } from "@server/db/asns"; -import { COUNTRIES } from "@server/db/countries"; -import { - REGIONS, - getRegionNameById, - isValidRegionId -} from "@server/db/regions"; -import { - isValidCIDR, - isValidIP, - isValidUrlGlobPattern -} from "@server/lib/validators"; -import { - ColumnDef, - flexRender, - getCoreRowModel, - getFilteredRowModel, - getPaginationRowModel, - getSortedRowModel, - useReactTable -} from "@tanstack/react-table"; -import { - ArrowUpDown, - Check, - ChevronsUpDown, - LockIcon, - Plus -} from "lucide-react"; - -import { - useCallback, - useEffect, - useMemo, - useRef, - useState, - useTransition -} from "react"; -import { UseFormReturn, useForm, useWatch } from "react-hook-form"; -import { useResourcePolicyContext } from "@app/providers/ResourcePolicyProvider"; -import { createApiClient, formatAxiosError } from "@app/lib/api"; -import { useEnvContext } from "@app/hooks/useEnvContext"; -import { resourceQueries } from "@app/lib/queries"; -import { useQuery } from "@tanstack/react-query"; -import type { AxiosResponse } from "axios"; -import { useRouter } from "next/navigation"; - -// ─── 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; - fromPolicy?: boolean; -}; - -type PolicyRulesSectionProps = { - isMaxmindAvailable: boolean; - isMaxmindAsnAvailable: boolean; - readonly?: boolean; - resourceId?: number; -}; - -export function EditPolicyRulesSectionForm({ - isMaxmindAvailable, - isMaxmindAsnAvailable, - readonly, - resourceId -}: PolicyRulesSectionProps) { - const t = useTranslations(); - - const { policy } = useResourcePolicyContext(); - const api = createApiClient(useEnvContext()); - const router = useRouter(); - - const isResourceOverlay = resourceId !== undefined; - - // ── Fetch resource-specific rules when in overlay mode ─────────────────── - const { data: resourceRulesData } = useQuery({ - ...resourceQueries.resourceRules({ resourceId: resourceId! }), - enabled: isResourceOverlay - }); - - const deletedResourceRuleIdsRef = useRef>(new Set()); - const [resourceRulesInitialized, setResourceRulesInitialized] = - useState(false); - - const form = useForm({ - resolver: zodResolver( - createPolicySchema.pick({ - rules: true, - applyRules: true - }) - ), - defaultValues: { - applyRules: policy.applyRules, - rules: policy.rules - } - }); - - const rulesEnabled = useWatch({ - control: form.control, - name: "applyRules" - }); - - const [rules, setRules] = useState( - policy.rules.map((r) => ({ ...r, fromPolicy: isResourceOverlay })) - ); - const [isExpanded, setIsExpanded] = useState( - rulesEnabled || isResourceOverlay - ); - - // Initialize resource-specific rules once fetched - useEffect(() => { - if (!isResourceOverlay || resourceRulesInitialized) return; - if (!resourceRulesData) return; - - const policyRuleIds = new Set(policy.rules.map((r) => r.ruleId)); - const resourceSpecific: LocalRule[] = resourceRulesData - .filter((r) => !policyRuleIds.has(r.ruleId)) - .map((r) => ({ - ruleId: r.ruleId, - action: r.action as "ACCEPT" | "DROP" | "PASS", - match: r.match, - value: r.value, - priority: r.priority, - enabled: r.enabled, - fromPolicy: false - })); - - setRules([ - ...resourceSpecific, - ...policy.rules.map((r) => ({ ...r, fromPolicy: true })) - ]); - setResourceRulesInitialized(true); - }, [ - isResourceOverlay, - resourceRulesData, - resourceRulesInitialized, - policy.rules - ]); - - const [openAddRuleCountrySelect, setOpenAddRuleCountrySelect] = - useState(false); - const [openAddRuleAsnSelect, setOpenAddRuleAsnSelect] = useState(false); - const [openAddRuleRegionSelect, setOpenAddRuleRegionSelect] = - useState(false); - - const addRuleForm = useForm({ - resolver: zodResolver(addRuleSchema), - defaultValues: { - action: "ACCEPT" as const, - match: "PATH", - 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", - REGION: t("region") - }), - [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; - } - if (data.match === "REGION" && !isValidRegionId(data.value)) { - toast({ - variant: "destructive", - title: t("rulesErrorInvalidRegion"), - description: t("rulesErrorInvalidRegionDescription") || "" - }); - 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 rule = rules.find((r) => r.ruleId === ruleId); - if (!rule || rule.fromPolicy) return; // cannot remove policy rules - // Track deletion for resource overlay mode (only for existing DB rules) - if (isResourceOverlay && !rule.new) { - deletedResourceRuleIdsRef.current.add(ruleId); - } - const updatedRules = rules.filter((rule) => rule.ruleId !== ruleId); - setRules(updatedRules); - syncFormRules(updatedRules); - }, - [rules, syncFormRules, isResourceOverlay] - ); - - 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)"; - case "REGION": - return t("rulesMatchRegion"); - } - }, - [t] - ); - - const columns: ColumnDef[] = useMemo( - () => [ - { - accessorKey: "priority", - header: ({ column }) => ( - - ), - cell: ({ row }) => { - const isLocked = row.original.fromPolicy; - if (isLocked) { - return ( - - — - - ); - } - return ( - 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" - /> -
-
-
- ) : row.original.match === "REGION" ? ( - - - - - - - - - - {t("noRegionFound")} - - {REGIONS.map((continent) => ( - - - updateRule( - row.original.ruleId, - { - value: continent.id - } - ) - } - > - - {t(continent.name)} ( - {continent.id}) - - {continent.includes.map( - (subregion) => ( - - updateRule( - row.original - .ruleId, - { - value: subregion.id - } - ) - } - > - - {t(subregion.name)}{" "} - ({subregion.id}) - - ) - )} - - ))} - - - - - ) : ( - - 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 }) => ( -
- {row.original.fromPolicy ? ( - - ) : ( - - )} -
- ) - } - ], - [ - t, - RuleAction, - RuleMatch, - isMaxmindAvailable, - isMaxmindAsnAvailable, - updateRule, - removeRule, - readonly - ] - ); - - const table = useReactTable({ - data: rules, - columns, - getCoreRowModel: getCoreRowModel(), - getPaginationRowModel: getPaginationRowModel(), - getSortedRowModel: getSortedRowModel(), - getFilteredRowModel: getFilteredRowModel(), - state: { pagination: { pageIndex: 0, pageSize: 1000 } } - }); - - const [isPending, startTransition] = useTransition(); - - async function saveRules() { - if (readonly) return; - - if (isResourceOverlay) { - await saveResourceOverlayRules(); - return; - } - - const isValid = await form.trigger(); - if (!isValid) return; - - const payload = { - applyRules: form.getValues("applyRules") ?? false, - rules: rules.map(({ action, match, value, priority, enabled }) => ({ - action, - match, - value, - priority, - enabled - })) - }; - - try { - const res = await api - .put< - AxiosResponse<{}> - >(`/resource-policy/${policy.resourcePolicyId}/rules`, payload) - .catch((e) => { - toast({ - variant: "destructive", - title: t("policyErrorUpdate"), - description: formatAxiosError( - e, - t("policyErrorUpdateDescription") - ) - }); - }); - - if (res && res.status === 200) { - toast({ - title: t("success"), - description: t("policyUpdatedSuccess") - }); - router.refresh(); - } - } catch (e) { - toast({ - variant: "destructive", - title: t("policyErrorUpdate"), - description: t("policyErrorUpdateMessageDescription") - }); - } - } - - async function saveResourceOverlayRules() { - try { - const newRules = rules.filter((r) => !r.fromPolicy && r.new); - const updatedRules = rules.filter( - (r) => !r.fromPolicy && !r.new && r.updated - ); - const deletedIds = [...deletedResourceRuleIdsRef.current]; - - await Promise.all([ - ...newRules.map((r) => - api.put(`/resource/${resourceId}/rule`, { - action: r.action, - match: r.match, - value: r.value, - priority: r.priority, - enabled: r.enabled - }) - ), - ...updatedRules.map((r) => - api.post(`/resource/${resourceId}/rule/${r.ruleId}`, { - action: r.action, - match: r.match, - value: r.value, - priority: r.priority, - enabled: r.enabled - }) - ), - ...deletedIds.map((id) => - api.delete(`/resource/${resourceId}/rule/${id}`) - ) - ]); - - deletedResourceRuleIdsRef.current = new Set(); - - toast({ - title: t("success"), - description: t("policyUpdatedSuccess") - }); - router.refresh(); - } catch (e) { - toast({ - variant: "destructive", - title: t("policyErrorUpdate"), - description: formatAxiosError( - e, - t("policyErrorUpdateDescription") - ) - }); - } - } - - if (!isExpanded) { - return ( - - - - {t("rulesResource")} - - - {t("rulesResourcePolicyDescription")} - - - - {!readonly ? ( - - ) : ( -
-

{t("resourcePolicyRulesEmpty")}

-
- )} -
-
- ); - } - - return ( - - - - {t("rulesResource")} - - - {t("rulesResourceDescription")} - - - -
-
- { - form.setValue("applyRules", val); - }} - disabled={readonly || isResourceOverlay} - /> -
- -
- -
- ( - - - {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" - /> -
-
-
- ) : addRuleForm.watch( - "match" - ) === "REGION" ? ( - - - - - - - - - - {t( - "noRegionFound" - )} - - {REGIONS.map( - ( - continent - ) => ( - - { - field.onChange( - continent.id - ); - setOpenAddRuleRegionSelect( - false - ); - }} - > - - {t( - continent.name - )}{" "} - ( - { - continent.id - } - ) - - {continent.includes.map( - ( - subregion - ) => ( - { - field.onChange( - subregion.id - ); - setOpenAddRuleRegionSelect( - false - ); - }} - > - - {t( - subregion.name - )}{" "} - ( - { - subregion.id - } - ) - - ) - )} - - ) - )} - - - - - ) : ( - - )} -
- -
- )} - /> - -
-
- - - - - {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/EditPolicyUserRolesSectionForm.tsx b/src/components/resource-policy/EditPolicyUserRolesSectionForm.tsx deleted file mode 100644 index 2fa5103ba..000000000 --- a/src/components/resource-policy/EditPolicyUserRolesSectionForm.tsx +++ /dev/null @@ -1,530 +0,0 @@ -"use client"; - -import { - SettingsSection, - SettingsSectionBody, - SettingsSectionDescription, - SettingsSectionFooter, - SettingsSectionForm, - SettingsSectionHeader, - SettingsSectionTitle -} from "@app/components/Settings"; - -import { useEnvContext } from "@app/hooks/useEnvContext"; - -import { getUserDisplayName } from "@app/lib/getUserDisplayName"; -import { zodResolver } from "@hookform/resolvers/zod"; -import { UserType } from "@server/types/UserTypes"; -import { useTranslations } from "next-intl"; - -import { toast } from "@app/hooks/useToast"; -import { createApiClient, formatAxiosError } from "@app/lib/api"; -import type { AxiosResponse } from "axios"; -import { useRouter } from "next/navigation"; -import { createPolicySchema } from "."; - -import { - RolesSelector, - type SelectedRole -} from "@app/components/roles-selector"; -import { UsersSelector } from "@app/components/users-selector"; -import { SwitchInput } from "@app/components/SwitchInput"; -import { Button } from "@app/components/ui/button"; -import { - Form, - FormControl, - FormDescription, - FormField, - FormItem, - FormLabel, - FormMessage -} from "@app/components/ui/form"; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue -} from "@app/components/ui/select"; - -import { useResourcePolicyContext } from "@app/providers/ResourcePolicyProvider"; -import { resourceQueries } from "@app/lib/queries"; -import { useQuery } from "@tanstack/react-query"; -import { useActionState, useEffect, useMemo, useRef, useState } from "react"; -import { useForm, useWatch } from "react-hook-form"; - -// ─── PolicyUsersRolesSection ────────────────────────────────────────────────── - -type PolicyUsersRolesSectionProps = { - orgId: string; - allIdps: { id: number; text: string }[]; - readonly?: boolean; - resourceId?: number; -}; - -type OverlaySelectedRole = SelectedRole & { isAdmin: boolean }; - -export function EditPolicyUsersRolesSectionForm({ - orgId, - allIdps, - readonly, - resourceId -}: PolicyUsersRolesSectionProps) { - const t = useTranslations(); - - const router = useRouter(); - - const { policy } = useResourcePolicyContext(); - - const api = createApiClient(useEnvContext()); - - // ── Resource overlay: fetch resource-specific roles & users ────────────── - const isResourceOverlay = resourceId !== undefined; - - const { data: resourceRolesData } = useQuery({ - ...resourceQueries.resourceRoles({ resourceId: resourceId! }), - enabled: isResourceOverlay - }); - - const { data: resourceUsersData } = useQuery({ - ...resourceQueries.resourceUsers({ resourceId: resourceId! }), - enabled: isResourceOverlay - }); - - // IDs from the policy (locked — cannot be removed) - const policyRoleLockedIds = useMemo( - () => new Set(policy.roles.map((r) => r.roleId.toString())), - [policy.roles] - ); - const policyUserLockedIds = useMemo( - () => new Set(policy.users.map((u) => u.userId)), - [policy.users] - ); - - // Policy entries mapped to selector format - const policyRoleItems = useMemo( - () => - policy.roles.map((r) => ({ - id: r.roleId.toString(), - text: r.name, - isAdmin: false - })), - [policy.roles] - ); - const policyUserItems = useMemo( - () => - policy.users.map((u) => ({ - id: u.userId, - text: `${getUserDisplayName({ email: u.email, username: u.username })}${u.type !== UserType.Internal ? ` (${u.idpName})` : ""}` - })), - [policy.users] - ); - - // Track the initial resource-specific roles/users for diffing on save - const initialResourceRoleIdsRef = useRef>(new Set()); - const initialResourceUserIdsRef = useRef>(new Set()); - - // Combined selected roles/users (policy + resource-specific) - const [combinedRoles, setCombinedRoles] = - useState(policyRoleItems); - const [combinedUsers, setCombinedUsers] = useState(policyUserItems); - const [resourceRolesInitialized, setResourceRolesInitialized] = - useState(false); - const [resourceUsersInitialized, setResourceUsersInitialized] = - useState(false); - - useEffect(() => { - if (!isResourceOverlay || resourceRolesInitialized) return; - if (!resourceRolesData) return; - - const resourceSpecific = resourceRolesData - .filter((r) => !policyRoleLockedIds.has(r.roleId.toString())) - .map((r) => ({ - id: r.roleId.toString(), - text: r.name, - isAdmin: Boolean(r.isAdmin) - })); - - initialResourceRoleIdsRef.current = new Set( - resourceSpecific.map((r) => r.id) - ); - setCombinedRoles( - [...policyRoleItems, ...resourceSpecific].filter( - (role) => !role.isAdmin - ) - ); - setResourceRolesInitialized(true); - }, [ - isResourceOverlay, - resourceRolesData, - resourceRolesInitialized, - policyRoleItems, - policyRoleLockedIds - ]); - - useEffect(() => { - if (!isResourceOverlay || resourceUsersInitialized) return; - if (!resourceUsersData) return; - - const resourceSpecific = resourceUsersData - .filter((u) => !policyUserLockedIds.has(u.userId)) - .map((u) => ({ - id: u.userId, - text: `${getUserDisplayName({ email: u.email ?? undefined, username: u.username ?? undefined })}${u.type !== UserType.Internal ? ` (${u.idpName})` : ""}` - })); - - initialResourceUserIdsRef.current = new Set( - resourceSpecific.map((u) => u.id) - ); - setCombinedUsers([...policyUserItems, ...resourceSpecific]); - setResourceUsersInitialized(true); - }, [ - isResourceOverlay, - resourceUsersData, - resourceUsersInitialized, - policyUserItems, - policyUserLockedIds - ]); - - // ── Standard policy form (non-overlay) ────────────────────────────────── - const form = useForm({ - resolver: zodResolver( - createPolicySchema.pick({ - sso: true, - skipToIdpId: true, - users: true, - roles: true - }) - ), - defaultValues: { - sso: policy.sso, - skipToIdpId: policy.idpId, - roles: policyRoleItems, - users: policyUserItems - } - }); - - const ssoEnabled = useWatch({ control: form.control, name: "sso" }); - const selectedIdpId = useWatch({ - control: form.control, - name: "skipToIdpId" - }); - - const [, formAction, isSubmitting] = useActionState(onSubmit, null); - const [isSavingOverlay, setIsSavingOverlay] = useState(false); - - async function onSubmit() { - if (readonly) return; - - if (isResourceOverlay) { - await saveResourceOverlay(); - return; - } - - const isValid = await form.trigger(); - if (!isValid) return; - - const payload = form.getValues(); - - try { - const res = await api - .put>( - `/resource-policy/${policy.resourcePolicyId}/access-control`, - { - sso: payload.sso, - userIds: payload.users.map((user) => user.id), - roleIds: payload.roles.map((role) => Number(role.id)), - skipToIdpId: payload.skipToIdpId - } - ) - .catch((e) => { - toast({ - variant: "destructive", - title: t("policyErrorUpdate"), - description: formatAxiosError( - e, - t("policyErrorUpdateDescription") - ) - }); - }); - - if (res && res.status === 200) { - toast({ - title: t("success"), - description: t("policyUpdatedSuccess") - }); - router.refresh(); - } - } catch (e) { - toast({ - variant: "destructive", - title: t("policyErrorUpdate"), - description: t("policyErrorUpdateMessageDescription") - }); - } - } - - async function saveResourceOverlay() { - setIsSavingOverlay(true); - try { - // Compute which roles/users are resource-specific (non-locked) - const currentResourceRoleIds = combinedRoles - .filter((r) => !policyRoleLockedIds.has(r.id)) - .map((r) => Number(r.id)); - const currentResourceUserIds = combinedUsers - .filter((u) => !policyUserLockedIds.has(u.id)) - .map((u) => u.id); - - // Use bulk-set endpoints (session-authenticated) which replace - // all resource-specific roles/users in one call - await Promise.all([ - api.post(`/resource/${resourceId}/roles`, { - roleIds: currentResourceRoleIds - }), - api.post(`/resource/${resourceId}/users`, { - userIds: currentResourceUserIds - }) - ]); - - // Update refs to reflect new state - initialResourceRoleIdsRef.current = new Set( - currentResourceRoleIds.map(String) - ); - initialResourceUserIdsRef.current = new Set(currentResourceUserIds); - - toast({ - title: t("success"), - description: t("policyUpdatedSuccess") - }); - router.refresh(); - } catch (e) { - toast({ - variant: "destructive", - title: t("policyErrorUpdate"), - description: formatAxiosError( - e, - t("policyErrorUpdateDescription") - ) - }); - } finally { - setIsSavingOverlay(false); - } - } - - const isLoading = - isResourceOverlay && - (!resourceRolesInitialized || !resourceUsersInitialized); - - return ( -
- - - - - {t("resourceUsersRoles")} - - - {t("resourcePolicyUsersRolesDescription")} - - - - - { - form.setValue("sso", val); - }} - disabled={readonly || isResourceOverlay} - /> - - {ssoEnabled && ( - <> - - {t("roles")} - - {isResourceOverlay ? ( - !role.isAdmin - )} - onSelectRoles={(roles) => { - setCombinedRoles( - roles - .map( - (role) => ({ - ...role, - isAdmin: - Boolean( - role.isAdmin - ) - }) - ) - .filter( - (role) => - !role.isAdmin - ) - ); - }} - disabled={isLoading} - restrictAdminRole - lockedIds={ - policyRoleLockedIds - } - /> - ) : ( - ( - - form.setValue( - "roles", - roles - ) - } - disabled={readonly} - restrictAdminRole - /> - )} - /> - )} - - - - {t("resourceRoleDescription")} - - - - - {t("users")} - - {isResourceOverlay ? ( - - ) : ( - ( - - form.setValue( - "users", - users - ) - } - disabled={readonly} - /> - )} - /> - )} - - - - - )} - - {ssoEnabled && allIdps.length > 0 && ( -
- - -

- {t( - "defaultIdentityProviderDescription" - )} -

-
- )} -
-
- - - - -
-
- - ); -} diff --git a/src/components/resource-policy/PolicyAccessRulesIntro.tsx b/src/components/resource-policy/PolicyAccessRulesIntro.tsx new file mode 100644 index 000000000..60d9b0984 --- /dev/null +++ b/src/components/resource-policy/PolicyAccessRulesIntro.tsx @@ -0,0 +1,29 @@ +"use client"; + +import { SwitchInput } from "@app/components/SwitchInput"; +import { useTranslations } from "next-intl"; + +export type PolicyAccessRulesIntroProps = { + rulesEnabled: boolean; + onRulesEnabledChange: (enabled: boolean) => void; + disableToggle?: boolean; +}; + +export function PolicyAccessRulesIntro({ + rulesEnabled, + onRulesEnabledChange, + disableToggle +}: PolicyAccessRulesIntroProps) { + const t = useTranslations(); + + return ( + + ); +} diff --git a/src/components/resource-policy/PolicyAccessRulesSection.tsx b/src/components/resource-policy/PolicyAccessRulesSection.tsx new file mode 100644 index 000000000..196fd9b21 --- /dev/null +++ b/src/components/resource-policy/PolicyAccessRulesSection.tsx @@ -0,0 +1,412 @@ +"use client"; + +import { + SettingsSection, + SettingsSectionBody, + SettingsSectionDescription, + SettingsSectionFooter, + SettingsSectionHeader, + SettingsSectionTitle +} from "@app/components/Settings"; + +import { zodResolver } from "@hookform/resolvers/zod"; +import { useTranslations } from "next-intl"; + +import { toast } from "@app/hooks/useToast"; +import { + createPolicyRulesSectionSchema, + validatePolicyRulesForSave, + type PolicyFormValues +} from "."; + +import { Button } from "@app/components/ui/button"; +import { Plus } from "lucide-react"; + +import { + useCallback, + useEffect, + useMemo, + useRef, + useState, + useTransition +} from "react"; +import { UseFormReturn, useForm, useWatch } from "react-hook-form"; +import { useResourcePolicyContext } from "@app/providers/ResourcePolicyProvider"; +import { createApiClient, formatAxiosError } from "@app/lib/api"; +import { useEnvContext } from "@app/hooks/useEnvContext"; +import { resourceQueries } from "@app/lib/queries"; +import { useQuery } from "@tanstack/react-query"; +import type { AxiosResponse } from "axios"; +import { useRouter } from "next/navigation"; +import { CreatePolicyRulesSectionForm } from "./CreatePolicyRulesSectionForm"; +import { PolicyAccessRulesIntro } from "./PolicyAccessRulesIntro"; +import { PolicyAccessRulesTable } from "./PolicyAccessRulesTable"; +import { + createEmptyRule, + type PolicyAccessRule +} from "./policy-access-rule-utils"; + +// ─── PolicyRulesSection ─────────────────────────────────────────────────────── + +type PolicyAccessRulesSectionEditProps = { + mode: "edit"; + isMaxmindAvailable: boolean; + isMaxmindAsnAvailable: boolean; + readonly?: boolean; + resourceId?: number; +}; + +type PolicyAccessRulesSectionCreateProps = { + mode: "create"; + form: UseFormReturn; + isMaxmindAvailable: boolean; + isMaxmindAsnAvailable: boolean; +}; + +export type PolicyAccessRulesSectionProps = + | PolicyAccessRulesSectionEditProps + | PolicyAccessRulesSectionCreateProps; + +export function PolicyAccessRulesSection(props: PolicyAccessRulesSectionProps) { + if (props.mode === "create") { + return ; + } + return ; +} + +function PolicyAccessRulesSectionEdit({ + isMaxmindAvailable, + isMaxmindAsnAvailable, + readonly, + resourceId +}: PolicyAccessRulesSectionEditProps) { + const t = useTranslations(); + + const { policy } = useResourcePolicyContext(); + const api = createApiClient(useEnvContext()); + const router = useRouter(); + + const isResourceOverlay = resourceId !== undefined; + + const { data: resourceRulesData } = useQuery({ + ...resourceQueries.resourceRules({ resourceId: resourceId! }), + enabled: isResourceOverlay + }); + + const deletedResourceRuleIdsRef = useRef>(new Set()); + const [resourceRulesInitialized, setResourceRulesInitialized] = + useState(false); + + const rulesFormSchema = useMemo( + () => createPolicyRulesSectionSchema(t), + [t] + ); + + const form = useForm({ + resolver: zodResolver(rulesFormSchema), + defaultValues: { + applyRules: policy.applyRules, + rules: policy.rules + } + }); + + const rulesEnabled = useWatch({ + control: form.control, + name: "applyRules" + }); + + const [rules, setRules] = useState( + policy.rules.map((r) => ({ ...r, fromPolicy: isResourceOverlay })) + ); + + useEffect(() => { + if (!isResourceOverlay || resourceRulesInitialized) return; + if (!resourceRulesData) return; + + const policyRuleIds = new Set(policy.rules.map((r) => r.ruleId)); + const resourceSpecific: PolicyAccessRule[] = resourceRulesData + .filter((r) => !policyRuleIds.has(r.ruleId)) + .map((r) => ({ + ruleId: r.ruleId, + action: r.action as "ACCEPT" | "DROP" | "PASS", + match: r.match, + value: r.value, + priority: r.priority, + enabled: r.enabled, + fromPolicy: false + })); + + setRules([ + ...resourceSpecific, + ...policy.rules.map((r) => ({ ...r, fromPolicy: true })) + ]); + setResourceRulesInitialized(true); + }, [ + isResourceOverlay, + resourceRulesData, + resourceRulesInitialized, + policy.rules + ]); + + 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 rule = rules.find((r) => r.ruleId === ruleId); + if (!rule || rule.fromPolicy) return; + if (isResourceOverlay && !rule.new) { + deletedResourceRuleIdsRef.current.add(ruleId); + } + const updatedRules = rules.filter((rule) => rule.ruleId !== ruleId); + setRules(updatedRules); + syncFormRules(updatedRules); + }, + [rules, syncFormRules, isResourceOverlay] + ); + + 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 handleRulesChange = useCallback( + (updatedRules: PolicyAccessRule[]) => { + setRules(updatedRules); + syncFormRules(updatedRules); + }, + [syncFormRules] + ); + + const [isPending, startTransition] = useTransition(); + + async function saveRules() { + if (readonly) return; + + const applyRules = form.getValues("applyRules") ?? false; + const rulesPayload = rules.map( + ({ action, match, value, priority, enabled }) => ({ + action, + match, + value, + priority, + enabled + }) + ); + const validation = validatePolicyRulesForSave( + t, + rulesPayload, + applyRules + ); + if (!validation.success) { + toast({ + variant: "destructive", + ...validation.toast + }); + return; + } + + if (isResourceOverlay) { + await saveResourceOverlayRules(); + return; + } + + const isValid = await form.trigger(); + if (!isValid) return; + + const payload = { + applyRules, + rules: rulesPayload + }; + + try { + const res = await api + .put< + AxiosResponse<{}> + >(`/resource-policy/${policy.resourcePolicyId}/rules`, payload) + .catch((e) => { + toast({ + variant: "destructive", + title: t("policyErrorUpdate"), + description: formatAxiosError( + e, + t("policyErrorUpdateDescription") + ) + }); + }); + + if (res && res.status === 200) { + toast({ + title: t("success"), + description: t("policyUpdatedSuccess") + }); + router.refresh(); + } + } catch (e) { + toast({ + variant: "destructive", + title: t("policyErrorUpdate"), + description: t("policyErrorUpdateMessageDescription") + }); + } + } + + async function saveResourceOverlayRules() { + try { + const newRules = rules.filter((r) => !r.fromPolicy && r.new); + const updatedRules = rules.filter( + (r) => !r.fromPolicy && !r.new && r.updated + ); + const deletedIds = [...deletedResourceRuleIdsRef.current]; + + await Promise.all([ + ...newRules.map((r) => + api.put(`/resource/${resourceId}/rule`, { + action: r.action, + match: r.match, + value: r.value, + priority: r.priority, + enabled: r.enabled + }) + ), + ...updatedRules.map((r) => + api.post(`/resource/${resourceId}/rule/${r.ruleId}`, { + action: r.action, + match: r.match, + value: r.value, + priority: r.priority, + enabled: r.enabled + }) + ), + ...deletedIds.map((id) => + api.delete(`/resource/${resourceId}/rule/${id}`) + ) + ]); + + deletedResourceRuleIdsRef.current = new Set(); + + toast({ + title: t("success"), + description: t("policyUpdatedSuccess") + }); + router.refresh(); + } catch (e) { + toast({ + variant: "destructive", + title: t("policyErrorUpdate"), + description: formatAxiosError( + e, + t("policyErrorUpdateDescription") + ) + }); + } + } + + const addRuleButton = ( + + ); + + const hasRules = rules.length > 0; + + return ( + + + + {t("policyAccessRulesTitle")} + + + {t("rulesResourceDescription")} + + + +
+ { + form.setValue("applyRules", val); + }} + disableToggle={readonly || isResourceOverlay} + /> + + {rulesEnabled && ( + <> + + {hasRules && addRuleButton} + + )} +
+
+ + + +
+ ); +} + +function PolicyAccessRulesSectionCreate({ + form, + isMaxmindAvailable, + isMaxmindAsnAvailable +}: PolicyAccessRulesSectionCreateProps) { + return ( + + ); +} diff --git a/src/components/resource-policy/PolicyAccessRulesTable.tsx b/src/components/resource-policy/PolicyAccessRulesTable.tsx new file mode 100644 index 000000000..4f1052d4f --- /dev/null +++ b/src/components/resource-policy/PolicyAccessRulesTable.tsx @@ -0,0 +1,824 @@ +"use client"; + +import { Button } from "@app/components/ui/button"; +import { DataTableEmptyState } from "@app/components/ui/data-table-empty-state"; +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList +} from "@app/components/ui/command"; +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 { toast } from "@app/hooks/useToast"; +import { cn } from "@app/lib/cn"; +import { MAJOR_ASNS } from "@server/db/asns"; +import { COUNTRIES } from "@server/db/countries"; +import { REGIONS, getRegionNameById } from "@server/db/regions"; +import { + ColumnDef, + flexRender, + getCoreRowModel, + getFilteredRowModel, + getPaginationRowModel, + getSortedRowModel, + useReactTable +} from "@tanstack/react-table"; +import { + ArrowUpDown, + Check, + ChevronsUpDown, + GripVertical, + LockIcon +} from "lucide-react"; +import { useTranslations } from "next-intl"; +import { + useCallback, + useMemo, + useState, + type DragEvent, + type ReactNode +} from "react"; +import { + validatePolicyRulePriority, + validatePolicyRuleValue +} from "./policy-access-rule-validation"; +import { + reorderPolicyRules, + sortPolicyRulesByPriority, + type PolicyAccessRule +} from "./policy-access-rule-utils"; + +export type PolicyAccessRulesTableProps = { + rules: PolicyAccessRule[]; + onRulesChange: (rules: PolicyAccessRule[]) => void; + updateRule: (ruleId: number, data: Partial) => void; + removeRule: (ruleId: number) => void; + isMaxmindAvailable: boolean; + isMaxmindAsnAvailable: boolean; + emptyStateAction: ReactNode; + readonly?: boolean; + includeRegionMatch?: boolean; + markUpdatedOnReorder?: boolean; + isRuleDraggable?: (rule: PolicyAccessRule) => boolean; + isRuleLocked?: (rule: PolicyAccessRule) => boolean; +}; + +function getColumnClassName(columnId: string) { + if (columnId === "actions") { + return "sticky right-0 z-10 w-[1%] min-w-fit bg-card text-right"; + } + if (columnId === "dragHandle") { + return "w-8 max-w-8 px-2"; + } + if (columnId === "priority") { + return "w-24 max-w-24"; + } + if (columnId === "action") { + return "w-40 max-w-40"; + } + if (columnId === "match") { + return "w-36 max-w-36"; + } + return ""; +} + +export function PolicyAccessRulesTable({ + rules, + onRulesChange, + updateRule, + removeRule, + isMaxmindAvailable, + isMaxmindAsnAvailable, + emptyStateAction, + readonly = false, + includeRegionMatch = false, + markUpdatedOnReorder = false, + isRuleDraggable: isRuleDraggableProp, + isRuleLocked: isRuleLockedProp +}: PolicyAccessRulesTableProps) { + const t = useTranslations(); + const [draggedRuleId, setDraggedRuleId] = useState(null); + const [dragOverRuleId, setDragOverRuleId] = useState(null); + + const isRuleLocked = useCallback( + (rule: PolicyAccessRule) => + isRuleLockedProp + ? isRuleLockedProp(rule) + : Boolean(rule.fromPolicy), + [isRuleLockedProp] + ); + + const isRuleDraggable = useCallback( + (rule: PolicyAccessRule) => + isRuleDraggableProp + ? isRuleDraggableProp(rule) + : !readonly && !isRuleLocked(rule), + [isRuleDraggableProp, isRuleLocked, readonly] + ); + + const sortedRules = useMemo( + () => sortPolicyRulesByPriority(rules), + [rules] + ); + + const handleReorder = useCallback( + (fromRuleId: number, toRuleId: number) => { + const fromIndex = sortedRules.findIndex( + (rule) => rule.ruleId === fromRuleId + ); + const toIndex = sortedRules.findIndex( + (rule) => rule.ruleId === toRuleId + ); + if (fromIndex === -1 || toIndex === -1 || fromIndex === toIndex) { + return; + } + + const reordered = reorderPolicyRules( + sortedRules, + fromIndex, + toIndex, + { markUpdated: markUpdatedOnReorder } + ); + onRulesChange(reordered); + }, + [sortedRules, onRulesChange, markUpdatedOnReorder] + ); + + const handleDragStart = useCallback((ruleId: number, e: DragEvent) => { + setDraggedRuleId(ruleId); + e.dataTransfer.effectAllowed = "move"; + }, []); + + const handleDragEnd = useCallback(() => { + setDraggedRuleId(null); + setDragOverRuleId(null); + }, []); + + 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", + REGION: t("region") + }), + [t] + ); + + const columns: ColumnDef[] = useMemo( + () => [ + { + id: "dragHandle", + size: 32, + maxSize: 32, + header: () => null, + cell: ({ row }) => + isRuleDraggable(row.original) ? ( + + ) : null + }, + { + accessorKey: "priority", + size: 96, + maxSize: 96, + header: ({ column }) => ( +
+ +
+ ), + cell: ({ row }) => ( + e.currentTarget.focus()} + onBlur={(e) => { + const validated = validatePolicyRulePriority( + t, + e.target.value + ); + if (!validated.success) { + toast({ + variant: "destructive", + ...validated.toast + }); + return; + } + const duplicatePriority = rules.some( + (rule) => + rule.ruleId !== row.original.ruleId && + rule.priority === validated.data + ); + if (duplicatePriority) { + toast({ + variant: "destructive", + title: t("rulesErrorDuplicatePriority"), + description: t( + "rulesErrorDuplicatePriorityDescription" + ) + }); + return; + } + updateRule(row.original.ruleId, { + priority: validated.data + }); + }} + /> + ) + }, + { + accessorKey: "action", + size: 160, + maxSize: 160, + header: () => {t("rulesAction")}, + cell: ({ row }) => ( + + ) + }, + { + accessorKey: "match", + size: 144, + maxSize: 144, + 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" + /> +
+
+
+ ) : row.original.match === "REGION" ? ( + + + + + + + + + + {t("noRegionFound")} + + {REGIONS.map((continent) => ( + + + updateRule( + row.original.ruleId, + { + value: continent.id + } + ) + } + > + + {t(continent.name)} ( + {continent.id}) + + {continent.includes.map( + (subregion) => ( + + updateRule( + row.original + .ruleId, + { + value: subregion.id + } + ) + } + > + + {t(subregion.name)}{" "} + ({subregion.id}) + + ) + )} + + ))} + + + + + ) : ( + { + const validated = validatePolicyRuleValue( + t, + row.original.match, + e.target.value + ); + if (!validated.success) { + toast({ + variant: "destructive", + ...validated.toast + }); + return; + } + updateRule(row.original.ruleId, { + value: validated.data + }); + }} + /> + ) + }, + { + accessorKey: "enabled", + header: () => {t("enabled")}, + cell: ({ row }) => ( +
+ + updateRule(row.original.ruleId, { + enabled: val + }) + } + /> +
+ ) + }, + { + id: "actions", + header: () => null, + cell: ({ row }) => ( +
+ {isRuleLocked(row.original) ? ( + + ) : ( + + )} +
+ ) + } + ], + [ + t, + RuleAction, + RuleMatch, + isMaxmindAvailable, + isMaxmindAsnAvailable, + includeRegionMatch, + updateRule, + removeRule, + readonly, + rules, + isRuleDraggable, + isRuleLocked, + handleDragStart, + handleDragEnd + ] + ); + + const table = useReactTable({ + data: sortedRules, + columns, + getCoreRowModel: getCoreRowModel(), + getPaginationRowModel: getPaginationRowModel(), + getSortedRowModel: getSortedRowModel(), + getFilteredRowModel: getFilteredRowModel(), + state: { pagination: { pageIndex: 0, pageSize: 1000 } } + }); + + return ( + + + {table.getHeaderGroups().map((headerGroup) => ( + + {headerGroup.headers.map((header) => { + const columnId = header.column.id; + return ( + + {header.isPlaceholder + ? null + : flexRender( + header.column.columnDef.header, + header.getContext() + )} + + ); + })} + + ))} + + + {table.getRowModel().rows?.length ? ( + table.getRowModel().rows.map((row) => { + const rule = row.original; + return ( + { + e.preventDefault(); + if ( + draggedRuleId !== null && + draggedRuleId !== rule.ruleId + ) { + setDragOverRuleId(rule.ruleId); + } + }} + onDrop={(e) => { + e.preventDefault(); + if ( + draggedRuleId !== null && + draggedRuleId !== rule.ruleId + ) { + handleReorder( + draggedRuleId, + rule.ruleId + ); + } + setDraggedRuleId(null); + setDragOverRuleId(null); + }} + className={cn( + draggedRuleId === rule.ruleId && + "opacity-50", + dragOverRuleId === rule.ruleId && + "border-t-2 border-primary" + )} + > + {row.getVisibleCells().map((cell) => { + const columnId = cell.column.id; + return ( + + {flexRender( + cell.column.columnDef.cell, + cell.getContext() + )} + + ); + })} + + ); + }) + ) : ( + + )} + +
+ ); +} diff --git a/src/components/resource-policy/PolicyAuthMethodCredenzas.tsx b/src/components/resource-policy/PolicyAuthMethodCredenzas.tsx new file mode 100644 index 000000000..2daebb9f5 --- /dev/null +++ b/src/components/resource-policy/PolicyAuthMethodCredenzas.tsx @@ -0,0 +1,467 @@ +"use client"; + +import { + Credenza, + CredenzaBody, + CredenzaClose, + CredenzaContent, + CredenzaDescription, + CredenzaFooter, + CredenzaHeader, + CredenzaTitle +} from "@app/components/Credenza"; +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 { + Form, + FormControl, + FormDescription, + FormField, + FormItem, + FormLabel, + FormMessage +} from "@app/components/ui/form"; +import { Input } from "@app/components/ui/input"; +import { + InputOTP, + InputOTPGroup, + InputOTPSlot +} from "@app/components/ui/input-otp"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { InfoIcon } from "lucide-react"; +import { useTranslations } from "next-intl"; +import { useEffect, useState } from "react"; +import { useForm } from "react-hook-form"; +import z from "zod"; +import { + setHeaderAuthSchema, + setPasswordSchema, + setPincodeSchema +} from "./policy-auth-method-id"; + +type CredenzaShellProps = { + open: boolean; + onOpenChange: (open: boolean) => void; + title: string; + description: string; + formId: string; + submitLabel: string; + children: React.ReactNode; +}; + +function CredenzaShell({ + open, + onOpenChange, + title, + description, + formId, + submitLabel, + children +}: CredenzaShellProps) { + const t = useTranslations(); + + return ( + + + + {title} + {description} + + {children} + + + + + + + + + ); +} + +type PasscodeCredenzaProps = { + open: boolean; + onOpenChange: (open: boolean) => void; + defaultPassword?: string; + existingConfigured?: boolean; + onSave: (password: string) => void; +}; + +export function PasscodeCredenza({ + open, + onOpenChange, + defaultPassword = "", + existingConfigured, + onSave +}: PasscodeCredenzaProps) { + const t = useTranslations(); + const form = useForm({ + resolver: zodResolver(setPasswordSchema), + defaultValues: { password: defaultPassword } + }); + + useEffect(() => { + if (open) { + form.reset({ password: defaultPassword }); + } + }, [open, defaultPassword, form]); + + return ( + +
+ { + onSave(data.password); + onOpenChange(false); + form.reset(); + })} + className="space-y-4" + > + ( + + + {t("policyAuthPasscodeTitle")} + + + + + + + )} + /> + + +
+ ); +} + +type PincodeCredenzaProps = { + open: boolean; + onOpenChange: (open: boolean) => void; + defaultPincode?: string; + onSave: (pincode: string) => void; +}; + +export function PincodeCredenza({ + open, + onOpenChange, + defaultPincode = "", + onSave +}: PincodeCredenzaProps) { + const t = useTranslations(); + const form = useForm({ + resolver: zodResolver(setPincodeSchema), + defaultValues: { pincode: defaultPincode } + }); + + useEffect(() => { + if (open) { + form.reset({ pincode: defaultPincode }); + } + }, [open, defaultPincode, form]); + + return ( + +
+ { + onSave(data.pincode); + onOpenChange(false); + form.reset(); + })} + className="space-y-4" + > + ( + + {t("resourcePincode")} + +
+ + + {[0, 1, 2, 3, 4, 5].map((i) => ( + + ))} + + +
+
+ +
+ )} + /> + + +
+ ); +} + +type HeaderAuthCredenzaProps = { + open: boolean; + onOpenChange: (open: boolean) => void; + defaultValues?: { + user: string; + password: string; + extendedCompatibility: boolean; + }; + existingConfigured?: boolean; + onSave: (values: z.infer) => void; +}; + +export function HeaderAuthCredenza({ + open, + onOpenChange, + defaultValues, + existingConfigured, + onSave +}: HeaderAuthCredenzaProps) { + const t = useTranslations(); + const form = useForm({ + resolver: zodResolver(setHeaderAuthSchema), + defaultValues: { + user: "", + password: "", + extendedCompatibility: true, + ...defaultValues + } + }); + + useEffect(() => { + if (open) { + form.reset({ + user: defaultValues?.user ?? "", + password: defaultValues?.password ?? "", + extendedCompatibility: + defaultValues?.extendedCompatibility ?? true + }); + } + }, [open, defaultValues, form]); + + return ( + +
+ { + onSave(data); + onOpenChange(false); + form.reset(); + })} + className="space-y-4" + > + ( + + + {t("policyAuthHeaderName")} + + + + + + + )} + /> + ( + + + {t("policyAuthHeaderValue")} + + + + + + + )} + /> + ( + + + + + + )} + /> + + +
+ ); +} + +type EmailCredenzaProps = { + open: boolean; + onOpenChange: (open: boolean) => void; + emailEnabled: boolean; + disabled?: boolean; + emails: Tag[]; + onEmailsChange: (emails: Tag[]) => void; +}; + +export function EmailCredenza({ + open, + onOpenChange, + emailEnabled, + disabled, + emails, + onEmailsChange +}: EmailCredenzaProps) { + const t = useTranslations(); + const [activeEmailTagIndex, setActiveEmailTagIndex] = useState< + number | null + >(null); + + return ( + + + + {t("policyAuthEmailTitle")} + + {t("policyAuthEmailDescription")} + + + +
+ {!emailEnabled && ( + + + + {t("otpEmailSmtpRequired")} + + + {t("otpEmailSmtpRequiredDescription")} + + + )} + {emailEnabled && ( +

+ {t("otpEmailWhitelistListDescription")} +

+ )} + {emailEnabled && ( + + + {t("otpEmailWhitelistList")} + + + { + if (!disabled) { + onEmailsChange( + newEmails as Tag[] + ); + } + }} + validateTag={(tag) => + z + .email() + .or( + z + .string() + .regex( + /^\*@[\w.-]+\.[a-zA-Z]{2,}$/ + ) + ) + .safeParse(tag).success + } + allowDuplicates={false} + sortTags + size="sm" + disabled={disabled} + /> + + + {t("otpEmailEnterDescription")} + + + )} +
+
+ + + + + +
+
+ ); +} diff --git a/src/components/resource-policy/PolicyAuthMethodRow.tsx b/src/components/resource-policy/PolicyAuthMethodRow.tsx new file mode 100644 index 000000000..c1fac1a7f --- /dev/null +++ b/src/components/resource-policy/PolicyAuthMethodRow.tsx @@ -0,0 +1,118 @@ +"use client"; + +import { Button } from "@app/components/ui/button"; +import { Switch } from "@app/components/ui/switch"; +import { cn } from "@app/lib/cn"; +import { useTranslations } from "next-intl"; + +export type PolicyAuthMethodRowProps = { + id: string; + title: string; + description: string; + summary: string; + active: boolean; + onConfigure: () => void; + onToggle: (active: boolean) => void; + disabled?: boolean; + configureDisabled?: boolean; +}; + +export function PolicyAuthMethodRow({ + id, + title, + description, + summary, + active, + onConfigure, + onToggle, + disabled, + configureDisabled = disabled +}: PolicyAuthMethodRowProps) { + const t = useTranslations(); + const canEdit = active && !configureDisabled; + const canEnable = !active && !disabled; + const isRowInteractive = canEdit || canEnable; + + const handleRowClick = () => { + if (canEdit) { + onConfigure(); + return; + } + if (canEnable) { + onToggle(true); + } + }; + + return ( +
{ + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + handleRowClick(); + } + } + : undefined + } + role={isRowInteractive ? "button" : undefined} + tabIndex={isRowInteractive ? 0 : undefined} + > +
+
+ +
+ + {title} +
+

+ {active ? summary : description} +

+
+
e.stopPropagation()} + onKeyDown={(e) => e.stopPropagation()} + > + {active && ( + + )} + +
+
+ ); +} diff --git a/src/components/resource-policy/PolicyAuthSsoSection.tsx b/src/components/resource-policy/PolicyAuthSsoSection.tsx new file mode 100644 index 000000000..67f7ead27 --- /dev/null +++ b/src/components/resource-policy/PolicyAuthSsoSection.tsx @@ -0,0 +1,140 @@ +"use client"; + +import { SettingsSectionForm } from "@app/components/Settings"; +import { SwitchInput } from "@app/components/SwitchInput"; +import { Button } from "@app/components/ui/button"; +import { FormDescription, FormItem, FormLabel } from "@app/components/ui/form"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue +} from "@app/components/ui/select"; +import { useTranslations } from "next-intl"; +import { useEffect, useState } from "react"; + +export type PolicyAuthSsoSectionProps = { + sso: boolean; + onSsoChange: (active: boolean) => void; + skipToIdpId: number | null | undefined; + onSkipToIdpChange: (id: number | null) => void; + allIdps: { id: number; text: string }[]; + rolesEditor: React.ReactNode; + usersEditor: React.ReactNode; + disabled?: boolean; + idpDisabled?: boolean; +}; + +export function PolicyAuthSsoSection({ + sso, + onSsoChange, + skipToIdpId, + onSkipToIdpChange, + allIdps, + rolesEditor, + usersEditor, + disabled, + idpDisabled +}: PolicyAuthSsoSectionProps) { + const t = useTranslations(); + const [showIdpSelect, setShowIdpSelect] = useState(skipToIdpId != null); + + useEffect(() => { + if (skipToIdpId != null) { + setShowIdpSelect(true); + } + }, [skipToIdpId]); + + const idpSelectDisabled = idpDisabled ?? disabled; + + return ( +
+ + + {sso && ( + + + {t("roles")} + {rolesEditor} + + + {t("users")} + {usersEditor} + + {allIdps.length > 0 && ( +
+ {skipToIdpId == null && !showIdpSelect ? ( + + ) : ( + <> + + +

+ {t( + "defaultIdentityProviderDescription" + )} +

+ + )} +
+ )} +
+ )} +
+ ); +} diff --git a/src/components/resource-policy/PolicyAuthStackSection.tsx b/src/components/resource-policy/PolicyAuthStackSection.tsx new file mode 100644 index 000000000..f11629692 --- /dev/null +++ b/src/components/resource-policy/PolicyAuthStackSection.tsx @@ -0,0 +1,38 @@ +"use client"; + +import { type UseFormReturn } from "react-hook-form"; +import type { PolicyFormValues } from "."; +import { PolicyAuthStackSectionCreate } from "./PolicyAuthStackSectionCreate"; +import { PolicyAuthStackSectionEdit } from "./PolicyAuthStackSectionEdit"; + +type PolicyAuthStackSectionEditProps = { + mode: "edit"; + orgId: string; + allIdps: { id: number; text: string }[]; + emailEnabled: boolean; + readonly?: boolean; + resourceId?: number; +}; + +type PolicyAuthStackSectionCreateProps = { + mode: "create"; + form: UseFormReturn; + orgId: string; + allIdps: { id: number; text: string }[]; + allRoles: { id: string; text: string }[]; + allUsers: { id: string; text: string }[]; + emailEnabled: boolean; +}; + +export type PolicyAuthStackSectionProps = + | PolicyAuthStackSectionEditProps + | PolicyAuthStackSectionCreateProps; + +export function PolicyAuthStackSection(props: PolicyAuthStackSectionProps) { + if (props.mode === "create") { + const { mode: _, ...createProps } = props; + return ; + } + const { mode: _, ...editProps } = props; + return ; +} diff --git a/src/components/resource-policy/PolicyAuthStackSectionCreate.tsx b/src/components/resource-policy/PolicyAuthStackSectionCreate.tsx new file mode 100644 index 000000000..c462cd9ae --- /dev/null +++ b/src/components/resource-policy/PolicyAuthStackSectionCreate.tsx @@ -0,0 +1,310 @@ +"use client"; + +import { + SettingsSection, + SettingsSectionBody, + SettingsSectionDescription, + SettingsSectionHeader, + SettingsSubsectionDescription, + SettingsSubsectionHeader, + SettingsSubsectionTitle, + SettingsSectionTitle +} from "@app/components/Settings"; +import { TagInput } from "@app/components/tags/tag-input"; +import { FormField } from "@app/components/ui/form"; +import { useTranslations } from "next-intl"; +import { useState } from "react"; +import { type UseFormReturn, useWatch } from "react-hook-form"; +import type { PolicyFormValues } from "."; +import { + EmailCredenza, + HeaderAuthCredenza, + PasscodeCredenza, + PincodeCredenza +} from "./PolicyAuthMethodCredenzas"; +import { PolicyAuthMethodRow } from "./PolicyAuthMethodRow"; +import { PolicyAuthSsoSection } from "./PolicyAuthSsoSection"; +import type { PolicyAuthMethodId } from "./policy-auth-method-id"; +import { + getEmailWhitelistSummary, + getHeaderAuthSummary, + getPasscodeSummary, + getPincodeSummary +} from "./policy-auth-summaries"; + +export type PolicyAuthStackSectionCreateProps = { + form: UseFormReturn; + orgId: string; + allIdps: { id: number; text: string }[]; + allRoles: { id: string; text: string }[]; + allUsers: { id: string; text: string }[]; + emailEnabled: boolean; +}; + +export function PolicyAuthStackSectionCreate({ + form: parentForm, + allIdps, + allRoles, + allUsers, + emailEnabled +}: PolicyAuthStackSectionCreateProps) { + const t = useTranslations(); + const [editingMethod, setEditingMethod] = + useState(null); + const [activeRolesTagIndex, setActiveRolesTagIndex] = useState< + number | null + >(null); + const [activeUsersTagIndex, setActiveUsersTagIndex] = useState< + number | null + >(null); + + const sso = useWatch({ control: parentForm.control, name: "sso" }); + const skipToIdpId = useWatch({ + control: parentForm.control, + name: "skipToIdpId" + }); + const password = useWatch({ + control: parentForm.control, + name: "password" + }); + const pincode = useWatch({ control: parentForm.control, name: "pincode" }); + const headerAuth = useWatch({ + control: parentForm.control, + name: "headerAuth" + }); + const emailWhitelistEnabled = useWatch({ + control: parentForm.control, + name: "emailWhitelistEnabled" + }); + const emails = + useWatch({ control: parentForm.control, name: "emails" }) ?? []; + + const passcodeActive = Boolean(password); + const pinActive = Boolean(pincode); + const headerAuthActive = Boolean(headerAuth); + + const closeCredenza = () => setEditingMethod(null); + + const handleToggle = ( + method: PolicyAuthMethodId, + active: boolean, + onDisable: () => void, + onEnable?: () => void + ) => { + if (active) { + onEnable?.(); + setEditingMethod(method); + return; + } + onDisable(); + setEditingMethod((current) => (current === method ? null : current)); + }; + + return ( + + + + {t("policyAuthStackTitle")} + + + {t("policyAuthStackDescription")} + + + +
+ + parentForm.setValue("sso", active) + } + skipToIdpId={skipToIdpId} + onSkipToIdpChange={(id) => + parentForm.setValue("skipToIdpId", id) + } + allIdps={allIdps} + rolesEditor={ + ( + + field.onChange(newRoles) + } + autocompleteOptions={allRoles} + allowDuplicates={false} + size="sm" + /> + )} + /> + } + usersEditor={ + ( + + field.onChange(newUsers) + } + autocompleteOptions={allUsers} + allowDuplicates={false} + size="sm" + /> + )} + /> + } + /> +
+ + + + {t("policyAuthOtherMethodsTitle")} + + + {t("policyAuthOtherMethodsDescription")} + + + +
+ setEditingMethod("pincode")} + onToggle={(active) => + handleToggle("pincode", active, () => + parentForm.setValue("pincode", null) + ) + } + /> + + setEditingMethod("passcode")} + onToggle={(active) => + handleToggle("passcode", active, () => + parentForm.setValue("password", null) + ) + } + /> + + setEditingMethod("email")} + onToggle={(active) => + handleToggle( + "email", + active, + () => + parentForm.setValue( + "emailWhitelistEnabled", + false + ), + () => + parentForm.setValue( + "emailWhitelistEnabled", + true + ) + ) + } + disabled={!emailEnabled} + /> + + setEditingMethod("headerAuth")} + onToggle={(active) => + handleToggle("headerAuth", active, () => + parentForm.setValue("headerAuth", null) + ) + } + /> +
+ + !open && closeCredenza()} + defaultPincode={pincode?.pincode ?? ""} + onSave={(value) => { + parentForm.setValue("pincode", { pincode: value }); + }} + /> + + !open && closeCredenza()} + defaultPassword={password?.password ?? ""} + onSave={(value) => { + parentForm.setValue("password", { password: value }); + }} + /> + + !open && closeCredenza()} + emailEnabled={emailEnabled} + emails={emails} + onEmailsChange={(value) => + parentForm.setValue( + "emails", + value as PolicyFormValues["emails"] + ) + } + /> + + !open && closeCredenza()} + defaultValues={ + headerAuth + ? { + user: headerAuth.user, + password: headerAuth.password, + extendedCompatibility: + headerAuth.extendedCompatibility + } + : undefined + } + onSave={(value) => { + parentForm.setValue("headerAuth", value); + }} + /> +
+
+ ); +} diff --git a/src/components/resource-policy/PolicyAuthStackSectionEdit.tsx b/src/components/resource-policy/PolicyAuthStackSectionEdit.tsx new file mode 100644 index 000000000..f24e4360d --- /dev/null +++ b/src/components/resource-policy/PolicyAuthStackSectionEdit.tsx @@ -0,0 +1,694 @@ +"use client"; + +import { + SettingsSection, + SettingsSectionBody, + SettingsSectionDescription, + SettingsSectionFooter, + SettingsSectionHeader, + SettingsSubsectionDescription, + SettingsSubsectionHeader, + SettingsSubsectionTitle, + SettingsSectionTitle +} from "@app/components/Settings"; +import { + RolesSelector, + type SelectedRole +} from "@app/components/roles-selector"; +import { UsersSelector } from "@app/components/users-selector"; +import { Button } from "@app/components/ui/button"; +import { Form, FormField } from "@app/components/ui/form"; +import { toast } from "@app/hooks/useToast"; +import { useEnvContext } from "@app/hooks/useEnvContext"; +import { createApiClient, formatAxiosError } from "@app/lib/api"; +import { getUserDisplayName } from "@app/lib/getUserDisplayName"; +import { resourceQueries } from "@app/lib/queries"; +import { useResourcePolicyContext } from "@app/providers/ResourcePolicyProvider"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { UserType } from "@server/types/UserTypes"; +import { useQuery } from "@tanstack/react-query"; +import type { AxiosResponse } from "axios"; +import { useTranslations } from "next-intl"; +import { useRouter } from "next/navigation"; +import { useActionState, useEffect, useMemo, useRef, useState } from "react"; +import { useForm, useWatch } from "react-hook-form"; +import { createPolicySchema } from "."; +import { + EmailCredenza, + HeaderAuthCredenza, + PasscodeCredenza, + PincodeCredenza +} from "./PolicyAuthMethodCredenzas"; +import { PolicyAuthMethodRow } from "./PolicyAuthMethodRow"; +import { PolicyAuthSsoSection } from "./PolicyAuthSsoSection"; +import type { PolicyAuthMethodId } from "./policy-auth-method-id"; +import { + getEmailWhitelistSummary, + getHeaderAuthSummary, + getPasscodeSummary, + getPincodeSummary +} from "./policy-auth-summaries"; + +type OverlaySelectedRole = SelectedRole & { isAdmin: boolean }; + +const authStackSchema = createPolicySchema.pick({ + sso: true, + skipToIdpId: true, + roles: true, + users: true, + password: true, + pincode: true, + headerAuth: true, + emailWhitelistEnabled: true, + emails: true +}); + +export type PolicyAuthStackSectionEditProps = { + orgId: string; + allIdps: { id: number; text: string }[]; + emailEnabled: boolean; + readonly?: boolean; + resourceId?: number; +}; + +export function PolicyAuthStackSectionEdit({ + orgId, + allIdps, + emailEnabled, + readonly, + resourceId +}: PolicyAuthStackSectionEditProps) { + const t = useTranslations(); + const router = useRouter(); + const { policy } = useResourcePolicyContext(); + const api = createApiClient(useEnvContext()); + + const isResourceOverlay = resourceId !== undefined; + const authReadonly = readonly || isResourceOverlay; + + const policyRoleItems = useMemo( + () => + policy.roles.map((r) => ({ + id: r.roleId.toString(), + text: r.name, + isAdmin: false + })), + [policy.roles] + ); + const policyUserItems = useMemo( + () => + policy.users.map((u) => ({ + id: u.userId, + text: `${getUserDisplayName({ email: u.email, username: u.username })}${u.type !== UserType.Internal ? ` (${u.idpName})` : ""}` + })), + [policy.users] + ); + + const policyRoleLockedIds = useMemo( + () => new Set(policy.roles.map((r) => r.roleId.toString())), + [policy.roles] + ); + const policyUserLockedIds = useMemo( + () => new Set(policy.users.map((u) => u.userId)), + [policy.users] + ); + + const { data: resourceRolesData } = useQuery({ + ...resourceQueries.resourceRoles({ resourceId: resourceId! }), + enabled: isResourceOverlay + }); + const { data: resourceUsersData } = useQuery({ + ...resourceQueries.resourceUsers({ resourceId: resourceId! }), + enabled: isResourceOverlay + }); + + const [combinedRoles, setCombinedRoles] = + useState(policyRoleItems); + const [combinedUsers, setCombinedUsers] = useState(policyUserItems); + const [resourceRolesInitialized, setResourceRolesInitialized] = + useState(false); + const [resourceUsersInitialized, setResourceUsersInitialized] = + useState(false); + const initialResourceRoleIdsRef = useRef>(new Set()); + const initialResourceUserIdsRef = useRef>(new Set()); + + useEffect(() => { + if (!isResourceOverlay || resourceRolesInitialized) return; + if (!resourceRolesData) return; + const resourceSpecific = resourceRolesData + .filter((r) => !policyRoleLockedIds.has(r.roleId.toString())) + .map((r) => ({ + id: r.roleId.toString(), + text: r.name, + isAdmin: Boolean(r.isAdmin) + })); + initialResourceRoleIdsRef.current = new Set( + resourceSpecific.map((r) => r.id) + ); + setCombinedRoles( + [...policyRoleItems, ...resourceSpecific].filter( + (role) => !role.isAdmin + ) + ); + setResourceRolesInitialized(true); + }, [ + isResourceOverlay, + resourceRolesData, + resourceRolesInitialized, + policyRoleItems, + policyRoleLockedIds + ]); + + useEffect(() => { + if (!isResourceOverlay || resourceUsersInitialized) return; + if (!resourceUsersData) return; + const resourceSpecific = resourceUsersData + .filter((u) => !policyUserLockedIds.has(u.userId)) + .map((u) => ({ + id: u.userId, + text: `${getUserDisplayName({ email: u.email ?? undefined, username: u.username ?? undefined })}${u.type !== UserType.Internal ? ` (${u.idpName})` : ""}` + })); + initialResourceUserIdsRef.current = new Set( + resourceSpecific.map((u) => u.id) + ); + setCombinedUsers([...policyUserItems, ...resourceSpecific]); + setResourceUsersInitialized(true); + }, [ + isResourceOverlay, + resourceUsersData, + resourceUsersInitialized, + policyUserItems, + policyUserLockedIds + ]); + + const form = useForm({ + resolver: zodResolver(authStackSchema), + defaultValues: { + sso: policy.sso, + skipToIdpId: policy.idpId, + roles: policyRoleItems, + users: policyUserItems, + password: policy.passwordId ? { password: "" } : null, + pincode: policy.pincodeId ? { pincode: "" } : null, + headerAuth: policy.headerAuth + ? { + user: "", + password: "", + extendedCompatibility: + policy.headerAuth.extendedCompability ?? true + } + : null, + emailWhitelistEnabled: policy.emailWhitelistEnabled, + emails: policy.emailWhiteList.map((email) => ({ + id: email.whiteListId.toString(), + text: email.email + })) + } + }); + + const [passcodeActive, setPasscodeActive] = useState( + Boolean(policy.passwordId) + ); + const [pinActive, setPinActive] = useState(Boolean(policy.pincodeId)); + const [headerAuthActive, setHeaderAuthActive] = useState( + Boolean(policy.headerAuth) + ); + const [editingMethod, setEditingMethod] = + useState(null); + + const sso = useWatch({ control: form.control, name: "sso" }); + const skipToIdpId = useWatch({ + control: form.control, + name: "skipToIdpId" + }); + const roles = useWatch({ control: form.control, name: "roles" }) ?? []; + const users = useWatch({ control: form.control, name: "users" }) ?? []; + const password = useWatch({ control: form.control, name: "password" }); + const pincode = useWatch({ control: form.control, name: "pincode" }); + const headerAuth = useWatch({ control: form.control, name: "headerAuth" }); + const emailWhitelistEnabled = useWatch({ + control: form.control, + name: "emailWhitelistEnabled" + }); + const emails = useWatch({ control: form.control, name: "emails" }) ?? []; + + const overlayRoles = combinedRoles.filter((r) => !r.isAdmin); + const overlayUsers = combinedUsers; + + const [, formAction, isSubmitting] = useActionState(onSubmit, null); + const [isSavingOverlay, setIsSavingOverlay] = useState(false); + + async function onSubmit() { + if (readonly && !isResourceOverlay) return; + + if (isResourceOverlay) { + await saveResourceOverlay(); + return; + } + + const isValid = await form.trigger(); + if (!isValid) return; + + const payload = form.getValues(); + const requests: Array | void>> = []; + + requests.push( + api + .put( + `/resource-policy/${policy.resourcePolicyId}/access-control`, + { + sso: payload.sso, + userIds: payload.users.map((user) => user.id), + roleIds: payload.roles.map((role) => Number(role.id)), + skipToIdpId: payload.skipToIdpId + } + ) + .catch(handleError) + ); + + if (passcodeActive && payload.password?.password) { + requests.push( + api + .put( + `/resource-policy/${policy.resourcePolicyId}/password`, + { password: payload.password.password } + ) + .catch(handleError) + ); + } else if (!passcodeActive && policy.passwordId) { + requests.push( + api + .put( + `/resource-policy/${policy.resourcePolicyId}/password`, + { password: null } + ) + .catch(handleError) + ); + } + + if (pinActive && payload.pincode?.pincode?.length === 6) { + requests.push( + api + .put( + `/resource-policy/${policy.resourcePolicyId}/pincode`, + { pincode: payload.pincode.pincode } + ) + .catch(handleError) + ); + } else if (!pinActive && policy.pincodeId) { + requests.push( + api + .put( + `/resource-policy/${policy.resourcePolicyId}/pincode`, + { pincode: null } + ) + .catch(handleError) + ); + } + + if ( + headerAuthActive && + payload.headerAuth?.user && + payload.headerAuth?.password + ) { + requests.push( + api + .put( + `/resource-policy/${policy.resourcePolicyId}/header-auth`, + { headerAuth: payload.headerAuth } + ) + .catch(handleError) + ); + } else if (!headerAuthActive && policy.headerAuth) { + requests.push( + api + .put( + `/resource-policy/${policy.resourcePolicyId}/header-auth`, + { headerAuth: null } + ) + .catch(handleError) + ); + } + + requests.push( + api + .put(`/resource-policy/${policy.resourcePolicyId}/whitelist`, { + emailWhitelistEnabled: payload.emailWhitelistEnabled, + emails: payload.emails?.map((e) => e.text) ?? [] + }) + .catch(handleError) + ); + + try { + const results = await Promise.all(requests); + if (results.every((res) => res && res.status === 200)) { + toast({ + title: t("success"), + description: t("policyUpdatedSuccess") + }); + router.refresh(); + } + } catch { + toast({ + variant: "destructive", + title: t("policyErrorUpdate"), + description: t("policyErrorUpdateMessageDescription") + }); + } + } + + function handleError(e: unknown) { + toast({ + variant: "destructive", + title: t("policyErrorUpdate"), + description: formatAxiosError(e, t("policyErrorUpdateDescription")) + }); + } + + async function saveResourceOverlay() { + setIsSavingOverlay(true); + try { + const currentResourceRoleIds = combinedRoles + .filter((r) => !policyRoleLockedIds.has(r.id)) + .map((r) => Number(r.id)); + const currentResourceUserIds = combinedUsers + .filter((u) => !policyUserLockedIds.has(u.id)) + .map((u) => u.id); + + await Promise.all([ + api.post(`/resource/${resourceId}/roles`, { + roleIds: currentResourceRoleIds + }), + api.post(`/resource/${resourceId}/users`, { + userIds: currentResourceUserIds + }) + ]); + + toast({ + title: t("success"), + description: t("policyUpdatedSuccess") + }); + router.refresh(); + } catch (e) { + toast({ + variant: "destructive", + title: t("policyErrorUpdate"), + description: formatAxiosError( + e, + t("policyErrorUpdateDescription") + ) + }); + } finally { + setIsSavingOverlay(false); + } + } + + const isLoading = + isResourceOverlay && + (!resourceRolesInitialized || !resourceUsersInitialized); + + const closeCredenza = () => setEditingMethod(null); + + const openMethodEditor = (method: PolicyAuthMethodId) => { + setEditingMethod(method); + }; + + const handleToggle = ( + method: PolicyAuthMethodId, + active: boolean, + onDisable: () => void, + onEnable?: () => void + ) => { + if (active) { + onEnable?.(); + openMethodEditor(method); + return; + } + onDisable(); + setEditingMethod((current) => (current === method ? null : current)); + }; + + return ( +
+ + + + + {t("policyAuthStackTitle")} + + + {t("policyAuthStackDescription")} + + + +
+ + form.setValue("sso", active) + } + skipToIdpId={skipToIdpId} + onSkipToIdpChange={(id) => + form.setValue("skipToIdpId", id) + } + allIdps={allIdps} + disabled={authReadonly} + idpDisabled={authReadonly} + rolesEditor={ + isResourceOverlay ? ( + + setCombinedRoles( + selected.map((role) => ({ + ...role, + isAdmin: Boolean( + role.isAdmin + ) + })) + ) + } + disabled={isLoading} + restrictAdminRole + lockedIds={policyRoleLockedIds} + /> + ) : ( + ( + + form.setValue( + "roles", + selected + ) + } + disabled={readonly} + restrictAdminRole + /> + )} + /> + ) + } + usersEditor={ + isResourceOverlay ? ( + + ) : ( + ( + + form.setValue( + "users", + selected + ) + } + disabled={readonly} + /> + )} + /> + ) + } + /> +
+ + + + {t("policyAuthOtherMethodsTitle")} + + + {t("policyAuthOtherMethodsDescription")} + + + +
+ openMethodEditor("pincode")} + onToggle={(active) => + handleToggle("pincode", active, () => { + setPinActive(false); + form.setValue("pincode", null); + }) + } + disabled={authReadonly} + /> + + openMethodEditor("passcode")} + onToggle={(active) => + handleToggle("passcode", active, () => { + setPasscodeActive(false); + form.setValue("password", null); + }) + } + disabled={authReadonly} + /> + + openMethodEditor("email")} + onToggle={(active) => + handleToggle( + "email", + active, + () => + form.setValue( + "emailWhitelistEnabled", + false + ), + () => + form.setValue( + "emailWhitelistEnabled", + true + ) + ) + } + disabled={authReadonly || !emailEnabled} + /> + + + openMethodEditor("headerAuth") + } + onToggle={(active) => + handleToggle("headerAuth", active, () => { + setHeaderAuthActive(false); + form.setValue("headerAuth", null); + }) + } + disabled={authReadonly} + /> +
+ + !open && closeCredenza()} + defaultPincode={pincode?.pincode ?? ""} + onSave={(value) => { + form.setValue("pincode", { pincode: value }); + setPinActive(true); + }} + /> + + !open && closeCredenza()} + defaultPassword={password?.password ?? ""} + existingConfigured={Boolean(policy.passwordId)} + onSave={(value) => { + form.setValue("password", { password: value }); + setPasscodeActive(true); + }} + /> + + !open && closeCredenza()} + emailEnabled={emailEnabled} + disabled={authReadonly} + emails={emails} + onEmailsChange={(value) => + form.setValue("emails", value) + } + /> + + !open && closeCredenza()} + defaultValues={ + headerAuth + ? { + user: headerAuth.user, + password: headerAuth.password, + extendedCompatibility: + headerAuth.extendedCompatibility + } + : undefined + } + existingConfigured={Boolean(policy.headerAuth)} + onSave={(value) => { + form.setValue("headerAuth", value); + setHeaderAuthActive(true); + }} + /> +
+ + + +
+
+ + ); +} diff --git a/src/components/resource-policy/ResourcePolicySubForms.tsx b/src/components/resource-policy/ResourcePolicySubForms.tsx deleted file mode 100644 index 63f5b2a03..000000000 --- a/src/components/resource-policy/ResourcePolicySubForms.tsx +++ /dev/null @@ -1,1918 +0,0 @@ -"use client"; - -import { - SettingsSection, - SettingsSectionBody, - SettingsSectionDescription, - SettingsSectionForm, - SettingsSectionHeader, - SettingsSectionTitle -} from "@app/components/Settings"; -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 { toast } from "@app/hooks/useToast"; -import { - InputOTP, - InputOTPGroup, - InputOTPSlot -} from "@app/components/ui/input-otp"; - -import { zodResolver } from "@hookform/resolvers/zod"; -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 { useTranslations } from "next-intl"; - -import { useCallback, useMemo, useState } from "react"; -import { UseFormReturn, useForm, useWatch } from "react-hook-form"; -import z from "zod"; -import type { PolicyFormValues } from "."; - -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; -}; - -// ─── 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 ─────────────────────────────────────────────────────── - -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: "PATH", - 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/index.ts b/src/components/resource-policy/index.ts index 8579a6de5..a0456515b 100644 --- a/src/components/resource-policy/index.ts +++ b/src/components/resource-policy/index.ts @@ -46,13 +46,6 @@ 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"; @@ -63,3 +56,26 @@ export type LocalRule = { new?: boolean; updated?: boolean; }; + +export { PolicyAccessRulesTable } from "./PolicyAccessRulesTable"; +export type { PolicyAccessRulesTableProps } from "./PolicyAccessRulesTable"; +export { + createEmptyRule, + reorderPolicyRules, + sortPolicyRulesByPriority, + type EmptyRuleDraft, + type PolicyAccessRule +} from "./policy-access-rule-utils"; +export { + createPolicyRulePrioritySchema, + createPolicyRuleSchema, + createPolicyRuleValueSchema, + createPolicyRulesArraySchema, + createPolicyRulesSectionSchema, + createPolicySchemaWithI18n, + getPolicyRuleValidationMessage, + validatePolicyRulePriority, + validatePolicyRuleValue, + validatePolicyRulesForSave, + type RuleValidationToast +} from "./policy-access-rule-validation"; diff --git a/src/components/resource-policy/policy-access-rule-utils.ts b/src/components/resource-policy/policy-access-rule-utils.ts new file mode 100644 index 000000000..50178a4a5 --- /dev/null +++ b/src/components/resource-policy/policy-access-rule-utils.ts @@ -0,0 +1,72 @@ +export type PolicyAccessRule = { + ruleId: number; + action: "ACCEPT" | "DROP" | "PASS"; + match: string; + value: string; + priority: number; + enabled: boolean; + new?: boolean; + updated?: boolean; + fromPolicy?: boolean; +}; + +export type EmptyRuleDraft = PolicyAccessRule & { + new: true; +}; + +export function createEmptyRule( + existingRules: Array<{ priority: number }> +): EmptyRuleDraft { + const priority = + existingRules.reduce( + (acc, rule) => (rule.priority > acc ? rule.priority : acc), + 0 + ) + 1; + + return { + ruleId: Date.now(), + action: "ACCEPT", + match: "PATH", + value: "", + priority, + enabled: true, + new: true + }; +} + +export function sortPolicyRulesByPriority( + rules: T[] +): T[] { + return [...rules].sort((a, b) => a.priority - b.priority); +} + +export function reorderPolicyRules< + T extends { priority: number; new?: boolean; updated?: boolean } +>( + rules: T[], + fromIndex: number, + toIndex: number, + options?: { markUpdated?: boolean } +): T[] { + if ( + fromIndex === toIndex || + fromIndex < 0 || + toIndex < 0 || + fromIndex >= rules.length || + toIndex >= rules.length + ) { + return rules; + } + + const reordered = [...rules]; + const [moved] = reordered.splice(fromIndex, 1); + reordered.splice(toIndex, 0, moved); + + return reordered.map((rule, index) => { + const next = { ...rule, priority: index + 1 }; + if (options?.markUpdated && !rule.new) { + return { ...next, updated: true }; + } + return next; + }); +} diff --git a/src/components/resource-policy/policy-access-rule-validation.ts b/src/components/resource-policy/policy-access-rule-validation.ts new file mode 100644 index 000000000..e13fb9468 --- /dev/null +++ b/src/components/resource-policy/policy-access-rule-validation.ts @@ -0,0 +1,237 @@ +import { COUNTRIES } from "@server/db/countries"; +import { isValidRegionId } from "@server/db/regions"; +import { + isValidCIDR, + isValidIP, + isValidUrlGlobPattern +} from "@server/lib/validators"; +import z from "zod"; + +type TranslateFn = ( + key: string, + values?: Record +) => string; + +export type RuleValidationToast = { + title: string; + description: string; +}; + +export function getPolicyRuleValidationMessage( + t: TranslateFn, + issue: z.core.$ZodIssue +): string { + const ruleIndex = issue.path.find((segment) => typeof segment === "number"); + if (typeof ruleIndex === "number") { + return t("rulesErrorValidationRuleDescription", { + ruleNumber: ruleIndex + 1, + message: issue.message + }); + } + return issue.message; +} + +export function createPolicyRulePrioritySchema(t: TranslateFn) { + return z.coerce + .number({ error: t("rulesErrorInvalidPriorityDescription") }) + .int({ message: t("rulesErrorInvalidPriorityDescription") }) + .min(1, { message: t("rulesErrorInvalidPriorityDescription") }); +} + +export function createPolicyRuleValueSchema(t: TranslateFn, match: string) { + const required = z + .string() + .min(1, { message: t("rulesErrorValueRequired") }); + + switch (match) { + case "CIDR": + return required.refine(isValidCIDR, { + message: t("rulesErrorInvalidIpAddressRangeDescription") + }); + case "IP": + return required.refine(isValidIP, { + message: t("rulesErrorInvalidIpAddressDescription") + }); + case "PATH": + return required.refine(isValidUrlGlobPattern, { + message: t("rulesErrorInvalidUrlDescription") + }); + case "REGION": + return required.refine(isValidRegionId, { + message: t("rulesErrorInvalidRegionDescription") + }); + case "COUNTRY": + return required.refine( + (value) => COUNTRIES.some((country) => country.code === value), + { message: t("rulesErrorInvalidCountryDescription") } + ); + case "ASN": + return required.refine((value) => /^AS\d+$/i.test(value.trim()), { + message: t("rulesErrorInvalidAsnDescription") + }); + default: + return required; + } +} + +export function createPolicyRuleSchema(t: TranslateFn) { + return z + .object({ + action: z.enum(["ACCEPT", "DROP", "PASS"]), + match: z.string(), + value: z.string(), + priority: z.number().int(), + enabled: z.boolean() + }) + .superRefine((rule, ctx) => { + const priorityResult = createPolicyRulePrioritySchema(t).safeParse( + rule.priority + ); + if (!priorityResult.success) { + ctx.addIssue({ + code: "custom", + message: + priorityResult.error.issues[0]?.message ?? + t("rulesErrorInvalidPriorityDescription"), + path: ["priority"] + }); + } + + const valueResult = createPolicyRuleValueSchema( + t, + rule.match + ).safeParse(rule.value); + if (!valueResult.success) { + ctx.addIssue({ + code: "custom", + message: + valueResult.error.issues[0]?.message ?? + t("rulesErrorValueRequired"), + path: ["value"] + }); + } + }); +} + +export function createPolicyRulesArraySchema(t: TranslateFn) { + return z.array(createPolicyRuleSchema(t)).superRefine((rules, ctx) => { + const seenPriorities = new Set(); + rules.forEach((rule, index) => { + if (seenPriorities.has(rule.priority)) { + ctx.addIssue({ + code: "custom", + message: t("rulesErrorDuplicatePriorityDescription"), + path: [index, "priority"] + }); + } + seenPriorities.add(rule.priority); + }); + }); +} + +export function createPolicyRulesSectionSchema(t: TranslateFn) { + return z.object({ + applyRules: z.boolean(), + rules: createPolicyRulesArraySchema(t) + }); +} + +export function createPolicySchemaWithI18n( + t: TranslateFn, + baseSchema: z.ZodObject +) { + return baseSchema.extend({ + rules: createPolicyRulesArraySchema(t) + }); +} + +export function validatePolicyRulePriority( + t: TranslateFn, + value: unknown +): + | { success: true; data: number } + | { success: false; toast: RuleValidationToast } { + const result = createPolicyRulePrioritySchema(t).safeParse(value); + if (result.success) { + return { success: true, data: result.data }; + } + + return { + success: false, + toast: { + title: t("rulesErrorInvalidPriority"), + description: + result.error.issues[0]?.message ?? + t("rulesErrorInvalidPriorityDescription") + } + }; +} + +export function validatePolicyRuleValue( + t: TranslateFn, + match: string, + value: string +): + | { success: true; data: string } + | { success: false; toast: RuleValidationToast } { + const result = createPolicyRuleValueSchema(t, match).safeParse(value); + if (result.success) { + return { success: true, data: result.data }; + } + + const issue = result.error.issues[0]; + const titleKey = + match === "CIDR" + ? "rulesErrorInvalidIpAddressRange" + : match === "IP" + ? "rulesErrorInvalidIpAddress" + : match === "PATH" + ? "rulesErrorInvalidUrl" + : match === "REGION" + ? "rulesErrorInvalidRegion" + : match === "COUNTRY" + ? "rulesErrorInvalidCountry" + : match === "ASN" + ? "rulesErrorInvalidAsn" + : "rulesErrorValidation"; + + return { + success: false, + toast: { + title: t(titleKey), + description: issue?.message ?? t("rulesErrorValueRequired") + } + }; +} + +export function validatePolicyRulesForSave( + t: TranslateFn, + rules: Array<{ + action: "ACCEPT" | "DROP" | "PASS"; + match: string; + value: string; + priority: number; + enabled: boolean; + }>, + applyRules: boolean +): { success: true } | { success: false; toast: RuleValidationToast } { + if (!applyRules) { + return { success: true }; + } + + const result = createPolicyRulesArraySchema(t).safeParse(rules); + if (result.success) { + return { success: true }; + } + + const issue = result.error.issues[0]; + return { + success: false, + toast: { + title: t("rulesErrorValidation"), + description: issue + ? getPolicyRuleValidationMessage(t, issue) + : t("rulesErrorUpdateDescription") + } + }; +} diff --git a/src/components/resource-policy/policy-auth-method-id.ts b/src/components/resource-policy/policy-auth-method-id.ts new file mode 100644 index 000000000..a886735b4 --- /dev/null +++ b/src/components/resource-policy/policy-auth-method-id.ts @@ -0,0 +1,21 @@ +import z from "zod"; + +export type PolicyAuthMethodId = + | "pincode" + | "passcode" + | "email" + | "headerAuth"; + +export const setPasswordSchema = z.object({ + password: z.string().min(4).max(100) +}); + +export const setPincodeSchema = z.object({ + pincode: z.string().length(6) +}); + +export const setHeaderAuthSchema = z.object({ + user: z.string().min(4).max(100), + password: z.string().min(4).max(100), + extendedCompatibility: z.boolean() +}); diff --git a/src/components/resource-policy/policy-auth-summaries.ts b/src/components/resource-policy/policy-auth-summaries.ts new file mode 100644 index 000000000..21898553e --- /dev/null +++ b/src/components/resource-policy/policy-auth-summaries.ts @@ -0,0 +1,45 @@ +type SummaryParams = { + t: (key: string, values?: Record) => string; +}; + +type SsoSummaryParams = SummaryParams & { + idpName?: string; + userCount: number; + roleCount: number; +}; + +export function getSsoSummary({ + t, + idpName, + userCount, + roleCount +}: SsoSummaryParams) { + const idp = idpName ?? t("policyAuthSsoDefaultIdp"); + return t("policyAuthSsoSummary", { + idp, + users: userCount, + roles: roleCount + }); +} + +export function getPasscodeSummary({ t }: SummaryParams) { + return t("policyAuthPasscodeSummary"); +} + +export function getPincodeSummary({ t }: SummaryParams) { + return t("policyAuthPincodeSummary"); +} + +export function getEmailWhitelistSummary({ + t, + count +}: SummaryParams & { count: number }) { + return t("policyAuthEmailSummary", { count }); +} + +export function getHeaderAuthSummary({ + t, + headerName +}: SummaryParams & { headerName: string }) { + return headerName || t("policyAuthHeaderAuthSummary"); +} diff --git a/src/components/ui/data-table-empty-state.tsx b/src/components/ui/data-table-empty-state.tsx index e7da09f03..e734f7726 100644 --- a/src/components/ui/data-table-empty-state.tsx +++ b/src/components/ui/data-table-empty-state.tsx @@ -9,11 +9,13 @@ const PLACEHOLDER_ROW_COUNT = 5; type DataTableEmptyStateProps = { colSpan: number; action?: ReactNode; + message?: string; }; export function DataTableEmptyState({ colSpan, - action + action, + message }: DataTableEmptyStateProps) { const t = useTranslations(); return ( @@ -32,7 +34,7 @@ export function DataTableEmptyState({

- {t("noResults")} + {message ?? t("noResults")}

{action}