From 1625dd1add12dbcfa3bb8c21e3077e57ab90b313 Mon Sep 17 00:00:00 2001 From: Owen Date: Mon, 1 Jun 2026 17:04:33 -0700 Subject: [PATCH] Include the new policy tables in the data --- server/routers/resource/getUserResources.ts | 34 +- .../resources/public/[niceId]/layout.tsx | 4 - .../resources/public/[niceId]/rules/page.tsx | 1597 ----------------- .../EditPolicyRulesSectionForm.tsx | 2 +- 4 files changed, 34 insertions(+), 1603 deletions(-) delete mode 100644 src/app/[orgId]/settings/resources/public/[niceId]/rules/page.tsx diff --git a/server/routers/resource/getUserResources.ts b/server/routers/resource/getUserResources.ts index be723af21..145487260 100644 --- a/server/routers/resource/getUserResources.ts +++ b/server/routers/resource/getUserResources.ts @@ -5,6 +5,8 @@ import { resources, userResources, roleResources, + userPolicies, + rolePolicies, userOrgRoles, userOrgs, resourcePassword, @@ -80,6 +82,30 @@ export async function getUserResources( .where(inArray(roleResources.roleId, userRoleIds)) : Promise.resolve([]); + const directPolicyResourcesQuery = db + .select({ resourceId: resources.resourceId }) + .from(resources) + .innerJoin( + userPolicies, + eq(resources.resourcePolicyId, userPolicies.resourcePolicyId) + ) + .where(eq(userPolicies.userId, userId)); + + const rolePolicyResourcesQuery = + userRoleIds.length > 0 + ? db + .select({ resourceId: resources.resourceId }) + .from(resources) + .innerJoin( + rolePolicies, + eq( + resources.resourcePolicyId, + rolePolicies.resourcePolicyId + ) + ) + .where(inArray(rolePolicies.roleId, userRoleIds)) + : Promise.resolve([]); + const directSiteResourcesQuery = db .select({ siteResourceId: userSiteResources.siteResourceId }) .from(userSiteResources) @@ -98,11 +124,15 @@ export async function getUserResources( const [ directResources, roleResourceResults, + directPolicyResourceResults, + rolePolicyResourceResults, directSiteResourceResults, roleSiteResourceResults ] = await Promise.all([ directResourcesQuery, roleResourcesQuery, + directPolicyResourcesQuery, + rolePolicyResourcesQuery, directSiteResourcesQuery, roleSiteResourcesQuery ]); @@ -110,7 +140,9 @@ export async function getUserResources( // Combine all accessible resource IDs const accessibleResourceIds = [ ...directResources.map((r) => r.resourceId), - ...roleResourceResults.map((r) => r.resourceId) + ...roleResourceResults.map((r) => r.resourceId), + ...directPolicyResourceResults.map((r) => r.resourceId), + ...rolePolicyResourceResults.map((r) => r.resourceId) ]; // Combine all accessible site resource IDs diff --git a/src/app/[orgId]/settings/resources/public/[niceId]/layout.tsx b/src/app/[orgId]/settings/resources/public/[niceId]/layout.tsx index 98e38668b..731991b73 100644 --- a/src/app/[orgId]/settings/resources/public/[niceId]/layout.tsx +++ b/src/app/[orgId]/settings/resources/public/[niceId]/layout.tsx @@ -96,10 +96,6 @@ export default async function ResourceLayout(props: ResourceLayoutProps) { title: t("authentication"), href: `/{orgId}/settings/resources/public/{niceId}/authentication` }); - // navItems.push({ - // title: t("rules"), - // href: `/{orgId}/settings/resources/public/{niceId}/rules` - // }); } return ( diff --git a/src/app/[orgId]/settings/resources/public/[niceId]/rules/page.tsx b/src/app/[orgId]/settings/resources/public/[niceId]/rules/page.tsx deleted file mode 100644 index d5b942dd1..000000000 --- a/src/app/[orgId]/settings/resources/public/[niceId]/rules/page.tsx +++ /dev/null @@ -1,1597 +0,0 @@ -"use client"; - -import { useEffect, useState, use } from "react"; -import { Button } from "@/components/ui/button"; -import { Input } from "@/components/ui/input"; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue -} from "@/components/ui/select"; -import { AxiosResponse } from "axios"; -import { useForm } from "react-hook-form"; -import { zodResolver } from "@hookform/resolvers/zod"; -import { z } from "zod"; -import { - Form, - FormControl, - FormField, - FormItem, - FormLabel, - FormMessage -} from "@app/components/ui/form"; -import { - ColumnDef, - getFilteredRowModel, - getSortedRowModel, - getPaginationRowModel, - getCoreRowModel, - useReactTable, - flexRender -} from "@tanstack/react-table"; -import { - Table, - TableBody, - TableCell, - TableHead, - TableHeader, - TableRow -} from "@app/components/ui/table"; -import { toast } from "@app/hooks/useToast"; -import { useResourceContext } from "@app/hooks/useResourceContext"; -import { ArrayElement } from "@server/types/ArrayElement"; -import { formatAxiosError } from "@app/lib/api/formatAxiosError"; -import { useEnvContext } from "@app/hooks/useEnvContext"; -import { createApiClient } from "@app/lib/api"; -import { - SettingsContainer, - SettingsSection, - SettingsSectionHeader, - SettingsSectionTitle, - SettingsSectionDescription, - SettingsSectionBody, - SettingsSectionFooter -} from "@app/components/Settings"; -import { ListResourceRulesResponse } from "@server/routers/resource/listResourceRules"; -import { SwitchInput } from "@app/components/SwitchInput"; -import { ArrowUpDown, Check, InfoIcon, X, ChevronsUpDown } from "lucide-react"; -import { InfoPopup } from "@app/components/ui/info-popup"; -import { - isValidCIDR, - isValidIP, - isValidUrlGlobPattern -} from "@server/lib/validators"; -import { Switch } from "@app/components/ui/switch"; -import { useRouter } from "next/navigation"; -import { useTranslations } from "next-intl"; -import { COUNTRIES } from "@server/db/countries"; -import { MAJOR_ASNS } from "@server/db/asns"; -import { - REGIONS, - getRegionNameById, - isValidRegionId -} from "@server/db/regions"; -import { - Command, - CommandEmpty, - CommandGroup, - CommandInput, - CommandItem, - CommandList -} from "@app/components/ui/command"; -import { - Popover, - PopoverContent, - PopoverTrigger -} from "@app/components/ui/popover"; - -// Schema for rule validation -const addRuleSchema = z.object({ - action: z.enum(["ACCEPT", "DROP", "PASS"]), - match: z.string(), - value: z.string(), - priority: z.coerce.number().int().optional() -}); - -type LocalRule = ArrayElement & { - new?: boolean; - updated?: boolean; -}; - -export default function ResourceRules(props: { - params: Promise<{ resourceId: number }>; -}) { - const { resource, updateResource } = useResourceContext(); - const api = createApiClient(useEnvContext()); - const [rules, setRules] = useState([]); - const [rulesToRemove, setRulesToRemove] = useState([]); - const [loading, setLoading] = useState(false); - const [pageLoading, setPageLoading] = useState(true); - const [rulesEnabled, setRulesEnabled] = useState( - resource.applyRules ?? false - ); - - useEffect(() => { - setRulesEnabled(resource.applyRules); - }, [resource.applyRules]); - - const [openAddRuleCountrySelect, setOpenAddRuleCountrySelect] = - useState(false); - const [openAddRuleAsnSelect, setOpenAddRuleAsnSelect] = useState(false); - const [openAddRuleRegionSelect, setOpenAddRuleRegionSelect] = - useState(false); - const router = useRouter(); - const t = useTranslations(); - const { env } = useEnvContext(); - 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 RuleAction = { - ACCEPT: t("alwaysAllow"), - DROP: t("alwaysDeny"), - PASS: t("passToAuth") - } as const; - - const RuleMatch = { - PATH: t("path"), - IP: "IP", - CIDR: t("ipAddressRange"), - COUNTRY: t("country"), - ASN: "ASN", - REGION: t("region") - } as const; - - const addRuleForm = useForm({ - resolver: zodResolver(addRuleSchema), - defaultValues: { - action: "ACCEPT", - match: resource.mode == "http" ? "PATH" : "IP", - value: "" - } - }); - - useEffect(() => { - const fetchRules = async () => { - try { - const res = await api.get< - AxiosResponse - >(`/resource/${resource.resourceId}/rules`); - if (res.status === 200) { - setRules(res.data.data.rules); - } - } catch (err) { - console.error(err); - toast({ - variant: "destructive", - title: t("rulesErrorFetch"), - description: formatAxiosError( - err, - t("rulesErrorFetchDescription") - ) - }); - } finally { - setPageLoading(false); - } - }; - fetchRules(); - }, []); - - async function addRule(data: z.infer) { - // Normalize ASN value - if (data.match === "ASN") { - const originalValue = data.value.toUpperCase(); - - // Handle special "ALL" case - if (originalValue === "ALL" || originalValue === "AS0") { - data.value = "ALL"; - } else { - // Remove AS prefix if present - const normalized = originalValue.replace(/^AS/, ""); - if (!/^\d+$/.test(normalized)) { - toast({ - variant: "destructive", - title: "Invalid ASN", - description: - "ASN must be a number, optionally prefixed with 'AS' (e.g., AS15169 or 15169), or 'ALL'" - }); - return; - } - // Add "AS" prefix for consistent storage - data.value = "AS" + normalized; - } - } - - 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") - }); - setLoading(false); - return; - } - if (data.match === "PATH" && !isValidUrlGlobPattern(data.value)) { - toast({ - variant: "destructive", - title: t("rulesErrorInvalidUrl"), - description: t("rulesErrorInvalidUrlDescription") - }); - setLoading(false); - return; - } - if (data.match === "IP" && !isValidIP(data.value)) { - toast({ - variant: "destructive", - title: t("rulesErrorInvalidIpAddress"), - description: t("rulesErrorInvalidIpAddressDescription") - }); - setLoading(false); - return; - } - if ( - data.match === "COUNTRY" && - !COUNTRIES.some((c) => c.code === data.value) - ) { - toast({ - variant: "destructive", - title: t("rulesErrorInvalidCountry"), - description: - t("rulesErrorInvalidCountryDescription") || - "Invalid country code." - }); - setLoading(false); - return; - } - if (data.match === "REGION" && !isValidRegionId(data.value)) { - toast({ - variant: "destructive", - title: t("rulesErrorInvalidRegion"), - description: - t("rulesErrorInvalidRegionDescription") || "Invalid region." - }); - setLoading(false); - return; - } - - // find the highest priority and add one - 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, - resourceId: resource.resourceId, - priority, - enabled: true - }; - - setRules([...rules, newRule]); - addRuleForm.reset(); - } - - const removeRule = (ruleId: number) => { - setRules([...rules.filter((rule) => rule.ruleId !== ruleId)]); - if (!rules.find((rule) => rule.ruleId === ruleId)?.new) { - setRulesToRemove([...rulesToRemove, ruleId]); - } - }; - - async function updateRule(ruleId: number, data: Partial) { - setRules( - rules.map((rule) => - rule.ruleId === ruleId - ? { ...rule, ...data, updated: true } - : rule - ) - ); - } - - 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"); - } - } - - async function saveAllSettings() { - try { - setLoading(true); - - // Save rules enabled state - const res = await api - .post(`/resource/${resource.resourceId}`, { - applyRules: rulesEnabled - }) - .catch((err) => { - console.error(err); - toast({ - variant: "destructive", - title: t("rulesErrorUpdate"), - description: formatAxiosError( - err, - t("rulesErrorUpdateDescription") - ) - }); - throw err; - }); - - if (res && res.status === 200) { - updateResource({ applyRules: rulesEnabled }); - } - - // Save rules - for (const rule of rules) { - const data = { - action: rule.action, - match: rule.match, - value: rule.value, - priority: rule.priority, - enabled: rule.enabled - }; - - if (rule.match === "CIDR" && !isValidCIDR(rule.value)) { - toast({ - variant: "destructive", - title: t("rulesErrorInvalidIpAddressRange"), - description: t( - "rulesErrorInvalidIpAddressRangeDescription" - ) - }); - setLoading(false); - return; - } - if ( - rule.match === "PATH" && - !isValidUrlGlobPattern(rule.value) - ) { - toast({ - variant: "destructive", - title: t("rulesErrorInvalidUrl"), - description: t("rulesErrorInvalidUrlDescription") - }); - setLoading(false); - return; - } - if (rule.match === "IP" && !isValidIP(rule.value)) { - toast({ - variant: "destructive", - title: t("rulesErrorInvalidIpAddress"), - description: t("rulesErrorInvalidIpAddressDescription") - }); - setLoading(false); - return; - } - - if (rule.priority === undefined) { - toast({ - variant: "destructive", - title: t("rulesErrorInvalidPriority"), - description: t("rulesErrorInvalidPriorityDescription") - }); - setLoading(false); - return; - } - - // make sure no duplicate priorities - const priorities = rules.map((r) => r.priority); - if (priorities.length !== new Set(priorities).size) { - toast({ - variant: "destructive", - title: t("rulesErrorDuplicatePriority"), - description: t("rulesErrorDuplicatePriorityDescription") - }); - setLoading(false); - return; - } - - if (rule.new) { - const res = await api.put( - `/resource/${resource.resourceId}/rule`, - data - ); - rule.ruleId = res.data.data.ruleId; - } else if (rule.updated) { - await api.post( - `/resource/${resource.resourceId}/rule/${rule.ruleId}`, - data - ); - } - - setRules([ - ...rules.map((r) => { - const res = { - ...r, - new: false, - updated: false - }; - return res; - }) - ]); - } - - for (const ruleId of rulesToRemove) { - await api.delete( - `/resource/${resource.resourceId}/rule/${ruleId}` - ); - setRules(rules.filter((r) => r.ruleId !== ruleId)); - } - - toast({ - title: t("ruleUpdated"), - description: t("ruleUpdatedDescription") - }); - - setRulesToRemove([]); - router.refresh(); - } catch (err) { - console.error(err); - toast({ - variant: "destructive", - title: t("ruleErrorUpdate"), - description: formatAxiosError( - err, - t("ruleErrorUpdateDescription") - ) - }); - } - setLoading(false); - } - - const columns: ColumnDef[] = [ - { - accessorKey: "priority", - header: ({ column }) => { - return ( - - ); - }, - 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"), // correct priority or IP? - description: t( - "rulesErrorInvalidPriorityDescription" - ) - }); - setLoading(false); - 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 }) => ( -
- -
- ) - } - ]; - - const table = useReactTable({ - data: rules, - columns, - getCoreRowModel: getCoreRowModel(), - getPaginationRowModel: getPaginationRowModel(), - getSortedRowModel: getSortedRowModel(), - getFilteredRowModel: getFilteredRowModel(), - state: { - pagination: { - pageIndex: 0, - pageSize: 1000 - } - } - }); - - if (pageLoading) { - return <>; - } - - return ( - - {/* */} - {/* */} - {/* {t('rulesAbout')} */} - {/* */} - {/*
*/} - {/*

*/} - {/* {t('rulesAboutDescription')} */} - {/*

*/} - {/*
*/} - {/* */} - {/* */} - {/* {t('rulesActions')} */} - {/*
    */} - {/*
  • */} - {/* */} - {/* {t('rulesActionAlwaysAllow')} */} - {/*
  • */} - {/*
  • */} - {/* */} - {/* {t('rulesActionAlwaysDeny')} */} - {/*
  • */} - {/*
*/} - {/*
*/} - {/* */} - {/* */} - {/* {t('rulesMatchCriteria')} */} - {/* */} - {/*
    */} - {/*
  • */} - {/* {t('rulesMatchCriteriaIpAddress')} */} - {/*
  • */} - {/*
  • */} - {/* {t('rulesMatchCriteriaIpAddressRange')} */} - {/*
  • */} - {/*
  • */} - {/* {t('rulesMatchCriteriaUrl')} */} - {/*
  • */} - {/*
*/} - {/*
*/} - {/*
*/} - {/*
*/} - {/*
*/} - - - - - {t("rulesResource")} - - - {t("rulesResourceDescription")} - - - -
-
- setRulesEnabled(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" - /> -
-
-
- ) : 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")} - - - )} - - {/* */} - {/* {t('rulesOrder')} */} - {/* */} -
-
-
- - - -
-
- ); -} diff --git a/src/components/resource-policy/EditPolicyRulesSectionForm.tsx b/src/components/resource-policy/EditPolicyRulesSectionForm.tsx index dc7bec7b3..843881ab3 100644 --- a/src/components/resource-policy/EditPolicyRulesSectionForm.tsx +++ b/src/components/resource-policy/EditPolicyRulesSectionForm.tsx @@ -171,7 +171,7 @@ export function EditPolicyRulesSectionForm({ }); const [rules, setRules] = useState( - policy.rules.map((r) => ({ ...r, fromPolicy: !isResourceOverlay })) + policy.rules.map((r) => ({ ...r, fromPolicy: isResourceOverlay })) ); const [isExpanded, setIsExpanded] = useState( rulesEnabled || isResourceOverlay