"use client"; import { SettingsContainer, 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 { useEnvContext } from "@app/hooks/useEnvContext"; import { useOrgContext } from "@app/hooks/useOrgContext"; import { usePaidStatus } from "@app/hooks/usePaidStatus"; import { toast } from "@app/hooks/useToast"; import { createApiClient } from "@app/lib/api"; import { getUserDisplayName } from "@app/lib/getUserDisplayName"; import { orgQueries } from "@app/lib/queries"; import { zodResolver } from "@hookform/resolvers/zod"; import { build } from "@server/build"; import { MAJOR_ASNS } from "@server/db/asns"; import { COUNTRIES } from "@server/db/countries"; import { tierMatrix } from "@server/lib/billing/tierMatrix"; import { isValidCIDR, isValidIP, isValidUrlGlobPattern } from "@server/lib/validators"; import { UserType } from "@server/types/UserTypes"; import { useQuery } from "@tanstack/react-query"; import { ColumnDef, flexRender, getCoreRowModel, getFilteredRowModel, getPaginationRowModel, getSortedRowModel, useReactTable } from "@tanstack/react-table"; import { ArrowUpDown, Binary, Bot, Check, ChevronsUpDown, InfoIcon, Key } from "lucide-react"; import { useTranslations } from "next-intl"; import { useRouter } from "next/navigation"; import { useActionState, useCallback, useMemo, useState } from "react"; import { useForm } from "react-hook-form"; import z from "zod"; 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; }; const createPolicySchema = z.object({ name: z.string().min(1).max(255), sso: z.boolean().default(true), skipToIdpId: z.number().nullable().optional(), emailWhitelistEnabled: z.boolean().default(false), roles: z.array( z.object({ id: z.string(), text: z.string() }) ), users: z.array( z.object({ id: z.string(), text: z.string() }) ), emails: z.array( z.object({ id: z.string(), text: z.string() }) ), applyRules: z.boolean().default(false), rules: z .array( z.object({ action: z.enum(["ACCEPT", "DROP", "PASS"]), match: z.string(), value: z.string(), priority: z.number().int(), enabled: z.boolean() }) ) .default([]) }); export type CreatePolicyFormProps = {}; export function CreatePolicyForm({}: CreatePolicyFormProps) { const { org } = useOrgContext(); const t = useTranslations(); const { env } = useEnvContext(); const api = createApiClient({ env }); const [, formAction, isSubmitting] = useActionState(onSubmit, null); const router = useRouter(); const { isPaidUser } = usePaidStatus(); const isMaxmindAvailable = env.server.maxmind_db_path && env.server.maxmind_db_path.length > 0; const isMaxmindAsnAvailable = env.server.maxmind_asn_path && env.server.maxmind_asn_path.length > 0; const { data: orgRoles = [], isLoading: isLoadingOrgRoles } = useQuery( orgQueries.roles({ orgId: org.org.orgId }) ); const { data: orgUsers = [], isLoading: isLoadingOrgUsers } = useQuery( orgQueries.users({ orgId: org.org.orgId }) ); const { data: orgIdps = [], isLoading: isLoadingOrgIdps } = useQuery( orgQueries.identityProviders({ orgId: org.org.orgId, useOrgOnlyIdp: env.app.identityProviderMode === "org" }) ); const form = useForm({ resolver: zodResolver(createPolicySchema), defaultValues: { name: "", sso: true, skipToIdpId: null, emailWhitelistEnabled: false, roles: [], users: [], emails: [], applyRules: false, rules: [] } }); const [ssoEnabled, setSsoEnabled] = useState(true); const [whitelistEnabled, setWhitelistEnabled] = useState(false); const [selectedIdpId, setSelectedIdpId] = useState(null); const [activeRolesTagIndex, setActiveRolesTagIndex] = useState< number | null >(null); const [activeUsersTagIndex, setActiveUsersTagIndex] = useState< number | null >(null); const [activeEmailTagIndex, setActiveEmailTagIndex] = useState< number | null >(null); // Rules state const [rules, setRules] = useState([]); const [rulesEnabled, setRulesEnabled] = useState(false); const [openAddRuleCountrySelect, setOpenAddRuleCountrySelect] = useState(false); const [openAddRuleAsnSelect, setOpenAddRuleAsnSelect] = useState(false); const addRuleForm = useForm({ resolver: zodResolver(addRuleSchema), defaultValues: { action: "ACCEPT" as const, match: "IP", value: "" } }); const RuleAction = useMemo(() => { return { ACCEPT: t("alwaysAllow"), DROP: t("alwaysDeny"), PASS: t("passToAuth") } as const; }, [t]); const RuleMatch = useMemo(() => { return { PATH: t("path"), IP: "IP", CIDR: t("ipAddressRange"), COUNTRY: t("country"), ASN: "ASN" } as const; }, [t]); async function onSubmit() { // ... } 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 ); priority++; } const newRule: LocalRule = { ...data, ruleId: new Date().getTime(), new: true, priority, enabled: true }; const updatedRules = [...rules, newRule]; setRules(updatedRules); form.setValue( "rules", updatedRules.map(({ action, match, value, priority, enabled }) => ({ action, match, value, priority, enabled })) ); addRuleForm.reset(); }, [rules, t, form, addRuleForm]); const removeRule = useCallback(function removeRule(ruleId: number) { const updatedRules = rules.filter((rule) => rule.ruleId !== ruleId); setRules(updatedRules); form.setValue( "rules", updatedRules.map(({ action, match, value, priority, enabled }) => ({ action, match, value, priority, enabled })) ); }, [rules, form]); const updateRule = useCallback(function updateRule(ruleId: number, data: Partial) { const updatedRules = rules.map((rule) => rule.ruleId === ruleId ? { ...rule, ...data, updated: true } : rule ); setRules(updatedRules); form.setValue( "rules", updatedRules.map(({ action, match, value, priority, enabled }) => ({ action, match, value, priority, enabled })) ); }, [rules, form]); 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 allRoles = useMemo(() => { return orgRoles .map((role) => ({ id: role.roleId.toString(), text: role.name })) .filter((role) => role.text !== "Admin"); }, [orgRoles]); const allUsers = useMemo(() => { return orgUsers.map((user) => ({ id: user.id.toString(), text: `${getUserDisplayName({ email: user.email, username: user.username })}${user.type !== UserType.Internal ? ` (${user.idpName})` : ""}` })); }, [orgUsers]); const allIdps = useMemo(() => { if (build === "saas") { if (isPaidUser(tierMatrix.orgOidc)) { return orgIdps.map((idp) => ({ id: idp.idpId, text: idp.name })); } } else { return orgIdps.map((idp) => ({ id: idp.idpId, text: idp.name })); } return []; }, [orgIdps]); 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 } } }); const pageLoading = isLoadingOrgRoles || isLoadingOrgUsers || isLoadingOrgIdps; if (pageLoading) { return <>; } return (
{/* Name */} {t("resourcePolicyName")} {t("resourcePolicyNameDescription")} ( {t("name")} )} /> {/* Users & Roles */} {t("resourceUsersRoles")} {t("resourceUsersRolesDescription")} { setSsoEnabled(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" )}

)}
{/* Auth Methods */} {t("resourceAuthMethods")} {t("resourceAuthMethodsDescriptions")}
{t("resourcePasswordProtection", { status: t("disabled") })}
{t("resourcePincodeProtection", { status: t("disabled") })}
{t( "resourceHeaderAuthProtectionDisabled" )}
{/* OTP Email */} {t("otpEmailTitle")} {t("otpEmailTitleDescription")} {!env.email.emailEnabled && ( {t("otpEmailSmtpRequired")} {t( "otpEmailSmtpRequiredDescription" )} )} { setWhitelistEnabled(val); form.setValue( "emailWhitelistEnabled", val ); }} disabled={!env.email.emailEnabled} /> {whitelistEnabled && env.email.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" )} )} /> )} {/* Rules */} {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")} )}
); }