diff --git a/messages/en-US.json b/messages/en-US.json index 1bf6a30ad..d92871562 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -211,6 +211,8 @@ "resourcesSearch": "Search resources...", "resourceAdd": "Add Resource", "resourceErrorDelte": "Error deleting resource", + "resourcePoliciesBannerTitle": "Re-use Authentication and Access Rules", + "resourcePoliciesBannerDescription": "Shared resource policies let you define authentication methods and access rules once, then attach them to multiple public resources. When you update a policy, every linked resource inherits the change automatically.", "resourcePoliciesTitle": "Manage Public Resource Policies", "resourcePoliciesAttachedResourcesColumnTitle": "Resources", "resourcePoliciesAttachedResources": "{count} resource(s)", @@ -774,6 +776,7 @@ "rulesErrorDuplicatePriorityDescription": "Each rule must have a unique priority number.", "rulesErrorValidation": "Invalid rules", "rulesErrorValidationRuleDescription": "Rule {ruleNumber}: {message}", + "rulesErrorInvalidMatchTypeDescription": "Select a valid match type (path, IP, CIDR, country, region, or ASN).", "rulesErrorValueRequired": "Enter a value for this rule.", "rulesErrorInvalidCountry": "Invalid country", "rulesErrorInvalidCountryDescription": "Select a valid country.", @@ -968,10 +971,16 @@ "resourceRoleDescription": "Admins can always access this resource.", "resourcePolicySelectTitle": "Resource Access Policy", "resourcePolicySelectDescription": "Select the resource policy type for authentication", + "resourcePolicyTypeLabel": "Policy type", + "resourcePolicyLabel": "Resource policy", "resourcePolicyInline": "Inline Resource Policy", "resourcePolicyInlineDescription": "Access Policy scoped to only this resource", "resourcePolicyShared": "Shared Resource Policy", - "resourcePolicySharedDescription": "This resource uses a shared policy. Policy-level settings (auth methods, email whitelist) are locked. You can add resource-specific rules, roles, and users below.", + "resourcePolicySharedDescription": "This resource uses a shared policy.", + "sharedPolicy": "Shared Policy", + "sharedPolicyNoneDescription": "This resource has its own policy.", + "resourceSharedPolicyAuthenticationNotice": "This resource is using a shared policy. Some authentication settings can be edited on this resource. To change the underlying policy, you must edit to {policyName}.", + "resourceSharedPolicyRulesNotice": "This resource is using a shared policy. Some access rules can be edited on this resource. To change the underlying policy, you must edit {policyName}.", "resourceUsersRoles": "Access Controls", "resourceUsersRolesDescription": "Configure which users and roles can visit this resource", "resourceUsersRolesSubmit": "Save Access Controls", diff --git a/server/lib/validators.ts b/server/lib/validators.ts index b1efe8b38..c179d3c91 100644 --- a/server/lib/validators.ts +++ b/server/lib/validators.ts @@ -1,5 +1,7 @@ import z from "zod"; import ipaddr from "ipaddr.js"; +import { COUNTRIES } from "@server/db/countries"; +import { isValidRegionId } from "@server/db/regions"; export function isValidCIDR(cidr: string): boolean { return ( @@ -67,6 +69,45 @@ export function isValidUrlGlobPattern(pattern: string): boolean { return true; } +export const RESOURCE_RULE_MATCH_TYPES = [ + "CIDR", + "IP", + "PATH", + "COUNTRY", + "ASN", + "REGION" +] as const; + +export type ResourceRuleMatchType = (typeof RESOURCE_RULE_MATCH_TYPES)[number]; + +export function getResourceRuleValueValidationError( + match: ResourceRuleMatchType, + value: string +): string | null { + switch (match) { + case "CIDR": + return isValidCIDR(value) ? null : "Invalid CIDR provided"; + case "IP": + return isValidIP(value) ? null : "Invalid IP provided"; + case "PATH": + return isValidUrlGlobPattern(value) + ? null + : "Invalid URL glob pattern provided"; + case "REGION": + return isValidRegionId(value) ? null : "Invalid region ID provided"; + case "COUNTRY": + return COUNTRIES.some((country) => country.code === value) + ? null + : "Invalid country code provided"; + case "ASN": + return /^AS\d+$/i.test(value.trim()) + ? null + : "Invalid ASN provided"; + default: + return "Invalid rule match type provided"; + } +} + export function isUrlValid(url: string | undefined) { if (!url) return true; // the link is optional in the schema so if it's empty it's valid var pattern = new RegExp( diff --git a/server/private/routers/policy/createResourcePolicy.ts b/server/private/routers/policy/createResourcePolicy.ts index 2b4678331..9f02b912c 100644 --- a/server/private/routers/policy/createResourcePolicy.ts +++ b/server/private/routers/policy/createResourcePolicy.ts @@ -33,9 +33,8 @@ import { import { getUniqueResourcePolicyName } from "@server/db/names"; import response from "@server/lib/response"; import { - isValidCIDR, - isValidIP, - isValidUrlGlobPattern + getResourceRuleValueValidationError, + RESOURCE_RULE_MATCH_TYPES } from "@server/lib/validators"; import logger from "@server/logger"; import { OpenAPITags, registry } from "@server/openApi"; @@ -56,9 +55,9 @@ const ruleSchema = z.strictObject({ enum: ["ACCEPT", "DROP", "PASS"], description: "rule action" }), - match: z.enum(["CIDR", "IP", "PATH"]).openapi({ + match: z.enum(RESOURCE_RULE_MATCH_TYPES).openapi({ type: "string", - enum: ["CIDR", "IP", "PATH"], + enum: [...RESOURCE_RULE_MATCH_TYPES], description: "rule match" }), value: z.string().min(1), @@ -261,26 +260,13 @@ export async function createResourcePolicy( const niceId = await getUniqueResourcePolicyName(orgId); for (const rule of rules) { - if (rule.match === "CIDR" && !isValidCIDR(rule.value)) { + const validationError = getResourceRuleValueValidationError( + rule.match, + rule.value + ); + if (validationError) { return next( - createHttpError( - HttpCode.BAD_REQUEST, - "Invalid CIDR provided" - ) - ); - } else if (rule.match === "IP" && !isValidIP(rule.value)) { - return next( - createHttpError(HttpCode.BAD_REQUEST, "Invalid IP provided") - ); - } else if ( - rule.match === "PATH" && - !isValidUrlGlobPattern(rule.value) - ) { - return next( - createHttpError( - HttpCode.BAD_REQUEST, - "Invalid URL glob pattern provided" - ) + createHttpError(HttpCode.BAD_REQUEST, validationError) ); } } diff --git a/server/routers/external.ts b/server/routers/external.ts index db0db594a..960c00249 100644 --- a/server/routers/external.ts +++ b/server/routers/external.ts @@ -666,6 +666,13 @@ authenticated.get( resource.getResourcePolicies ); +authenticated.get( + "/resource-policy/:resourcePolicyId", + verifyResourcePolicyAccess, + verifyUserHasAction(ActionsEnum.getResourcePolicy), + policy.getResourcePolicy +); + authenticated.put( "/resource-policy/:resourcePolicyId", verifyResourcePolicyAccess, diff --git a/server/routers/policy/setResourcePolicyRules.ts b/server/routers/policy/setResourcePolicyRules.ts index 533e01c0e..f15c1e51a 100644 --- a/server/routers/policy/setResourcePolicyRules.ts +++ b/server/routers/policy/setResourcePolicyRules.ts @@ -8,9 +8,8 @@ import createHttpError from "http-errors"; import logger from "@server/logger"; import { fromError } from "zod-validation-error"; import { - isValidCIDR, - isValidIP, - isValidUrlGlobPattern + getResourceRuleValueValidationError, + RESOURCE_RULE_MATCH_TYPES } from "@server/lib/validators"; import { OpenAPITags, registry } from "@server/openApi"; @@ -20,9 +19,9 @@ const ruleSchema = z.strictObject({ enum: ["ACCEPT", "DROP", "PASS"], description: "rule action" }), - match: z.enum(["CIDR", "IP", "PATH"]).openapi({ + match: z.enum(RESOURCE_RULE_MATCH_TYPES).openapi({ type: "string", - enum: ["CIDR", "IP", "PATH"], + enum: [...RESOURCE_RULE_MATCH_TYPES], description: "rule match" }), value: z.string().min(1), @@ -105,26 +104,13 @@ export async function setResourcePolicyRules( } for (const rule of rules) { - if (rule.match === "CIDR" && !isValidCIDR(rule.value)) { + const validationError = getResourceRuleValueValidationError( + rule.match, + rule.value + ); + if (validationError) { return next( - createHttpError( - HttpCode.BAD_REQUEST, - "Invalid CIDR provided" - ) - ); - } else if (rule.match === "IP" && !isValidIP(rule.value)) { - return next( - createHttpError(HttpCode.BAD_REQUEST, "Invalid IP provided") - ); - } else if ( - rule.match === "PATH" && - !isValidUrlGlobPattern(rule.value) - ) { - return next( - createHttpError( - HttpCode.BAD_REQUEST, - "Invalid URL glob pattern provided" - ) + createHttpError(HttpCode.BAD_REQUEST, validationError) ); } } diff --git a/src/app/[orgId]/settings/(private)/policies/resources/public/[niceId]/authentication/page.tsx b/src/app/[orgId]/settings/(private)/policies/resources/public/[niceId]/authentication/page.tsx new file mode 100644 index 000000000..ff9ebd4cf --- /dev/null +++ b/src/app/[orgId]/settings/(private)/policies/resources/public/[niceId]/authentication/page.tsx @@ -0,0 +1,7 @@ +"use client"; + +import { EditPolicyForm } from "@app/components/resource-policy/EditPolicyForm"; + +export default function EditPolicyAuthenticationPage() { + return ; +} diff --git a/src/app/[orgId]/settings/(private)/policies/resources/public/[niceId]/general/page.tsx b/src/app/[orgId]/settings/(private)/policies/resources/public/[niceId]/general/page.tsx new file mode 100644 index 000000000..a0e80b9f7 --- /dev/null +++ b/src/app/[orgId]/settings/(private)/policies/resources/public/[niceId]/general/page.tsx @@ -0,0 +1,7 @@ +"use client"; + +import { EditPolicyForm } from "@app/components/resource-policy/EditPolicyForm"; + +export default function EditPolicyGeneralPage() { + return ; +} diff --git a/src/app/[orgId]/settings/(private)/policies/resources/public/[niceId]/layout.tsx b/src/app/[orgId]/settings/(private)/policies/resources/public/[niceId]/layout.tsx new file mode 100644 index 000000000..7c8d3d9bc --- /dev/null +++ b/src/app/[orgId]/settings/(private)/policies/resources/public/[niceId]/layout.tsx @@ -0,0 +1,85 @@ +import SettingsSectionTitle from "@app/components/SettingsSectionTitle"; +import { HorizontalTabs } from "@app/components/HorizontalTabs"; +import { Button } from "@app/components/ui/button"; +import { internal } from "@app/lib/api"; +import { authCookieHeader } from "@app/lib/api/cookies"; +import { ResourcePolicyProvider } from "@app/providers/ResourcePolicyProvider"; +import type { GetResourcePolicyResponse } from "@server/routers/policy"; +import type { AxiosResponse } from "axios"; +import { getTranslations } from "next-intl/server"; +import Link from "next/link"; +import { redirect } from "next/navigation"; +import type { Metadata } from "next"; + +export const metadata: Metadata = { + title: "Resource Policy" +}; + +export const dynamic = "force-dynamic"; + +type EditPolicyLayoutProps = { + children: React.ReactNode; + params: Promise<{ niceId: string; orgId: string }>; +}; + +export default async function EditPolicyLayout(props: EditPolicyLayoutProps) { + const params = await props.params; + const t = await getTranslations(); + + let policyResponse: GetResourcePolicyResponse | null = null; + try { + const res = await internal.get< + AxiosResponse + >( + `/org/${params.orgId}/resource-policy/${params.niceId}`, + await authCookieHeader() + ); + policyResponse = res.data.data; + } catch { + redirect(`/${params.orgId}/settings/policies/resources/public`); + } + + if (!policyResponse) { + redirect(`/${params.orgId}/settings/policies/resources/public`); + } + + const navItems = [ + { + title: t("general"), + href: "/{orgId}/settings/policies/resources/public/{niceId}/general" + }, + { + title: t("authentication"), + href: "/{orgId}/settings/policies/resources/public/{niceId}/authentication" + }, + { + title: t("policyAccessRulesTitle"), + href: "/{orgId}/settings/policies/resources/public/{niceId}/rules" + } + ]; + + return ( + <> +
+ + + +
+ + + {props.children} + + + ); +} diff --git a/src/app/[orgId]/settings/(private)/policies/resources/public/[niceId]/page.tsx b/src/app/[orgId]/settings/(private)/policies/resources/public/[niceId]/page.tsx index 1113016b8..9cc180715 100644 --- a/src/app/[orgId]/settings/(private)/policies/resources/public/[niceId]/page.tsx +++ b/src/app/[orgId]/settings/(private)/policies/resources/public/[niceId]/page.tsx @@ -1,62 +1,12 @@ -import { EditPolicyForm } from "@app/components/resource-policy/EditPolicyForm"; -import SettingsSectionTitle from "@app/components/SettingsSectionTitle"; -import { Button } from "@app/components/ui/button"; -import { internal } from "@app/lib/api"; -import { authCookieHeader } from "@app/lib/api/cookies"; -import { ResourcePolicyProvider } from "@app/providers/ResourcePolicyProvider"; -import type { GetResourcePolicyResponse } from "@server/routers/policy"; -import type { AxiosResponse } from "axios"; -import { getTranslations } from "next-intl/server"; -import Link from "next/link"; import { redirect } from "next/navigation"; -export interface EditPolicyPageProps { +type EditPolicyPageProps = { params: Promise<{ niceId: string; orgId: string }>; -} +}; export default async function EditPolicyPage(props: EditPolicyPageProps) { const params = await props.params; - const t = await getTranslations(); - - let policyResponse: GetResourcePolicyResponse | null = null; - try { - const res = await internal.get< - AxiosResponse - >( - `/org/${params.orgId}/resource-policy/${params.niceId}`, - await authCookieHeader() - ); - policyResponse = res.data.data; - } catch { - redirect(`/${params.orgId}/settings/policies/resources/public`); - } - - if (!policyResponse) { - redirect(`/${params.orgId}/settings/policies/resources/public`); - } - - return ( - <> -
- - - -
- - - - - + redirect( + `/${params.orgId}/settings/policies/resources/public/${params.niceId}/general` ); } diff --git a/src/app/[orgId]/settings/(private)/policies/resources/public/[niceId]/rules/page.tsx b/src/app/[orgId]/settings/(private)/policies/resources/public/[niceId]/rules/page.tsx new file mode 100644 index 000000000..a33fce94e --- /dev/null +++ b/src/app/[orgId]/settings/(private)/policies/resources/public/[niceId]/rules/page.tsx @@ -0,0 +1,7 @@ +"use client"; + +import { EditPolicyForm } from "@app/components/resource-policy/EditPolicyForm"; + +export default function EditPolicyRulesPage() { + return ; +} diff --git a/src/app/[orgId]/settings/(private)/policies/resources/public/page.tsx b/src/app/[orgId]/settings/(private)/policies/resources/public/page.tsx index a51bbef3a..8b12b75b2 100644 --- a/src/app/[orgId]/settings/(private)/policies/resources/public/page.tsx +++ b/src/app/[orgId]/settings/(private)/policies/resources/public/page.tsx @@ -1,3 +1,4 @@ +import ResourcePoliciesBanner from "@app/components/ResourcePoliciesBanner"; import { ResourcePoliciesTable } from "@app/components/ResourcePoliciesTable"; import SettingsSectionTitle from "@app/components/SettingsSectionTitle"; import { internal } from "@app/lib/api"; @@ -54,6 +55,8 @@ export default async function ResourcePoliciesPage( description={t("resourcePoliciesDescription")} /> + + + + {t("addTarget")} + + ); + + const hasTargets = targets.length > 0; + async function saveTargets() { if (!resource) return; @@ -823,143 +833,104 @@ export function ProxyResourceTargetsForm({ - {targets.length > 0 ? ( - <> -
- - - {table - .getHeaderGroups() - .map((headerGroup) => ( - - {headerGroup.headers.map( - (header) => { - const isActionsColumn = - header.column - .id === - "actions"; - const isSiteColumn = - header.column - .id === - "site"; - 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"; - const isSiteColumn = - cell.column - .id === - "site"; - return ( - - {flexRender( - cell - .column - .columnDef - .cell, - cell.getContext() - )} - - ); - })} - - )) - ) : ( - - +
+ + {table.getHeaderGroups().map((headerGroup) => ( + + {headerGroup.headers.map((header) => { + const isActionsColumn = + header.column.id === "actions"; + const isSiteColumn = + header.column.id === "site"; + return ( + - {t("targetNoOne")} - - - )} - -
-
-
-
-
+ {hasTargets && ( +
+
+ {addTargetButton} +
+ +
- - ) : ( -
-

- {t("targetNoOne")} -

-
)} {build === "saas" && 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 81a7db9ca..29c2f4825 100644 --- a/src/app/[orgId]/settings/resources/public/[niceId]/authentication/page.tsx +++ b/src/app/[orgId]/settings/resources/public/[niceId]/authentication/page.tsx @@ -1,320 +1,7 @@ "use client"; -import { EditPolicyForm } from "@app/components/resource-policy/EditPolicyForm"; -import { - SettingsContainer, - SettingsSection, - SettingsSectionBody, - SettingsSectionDescription, - SettingsSectionFooter, - SettingsSectionHeader, - SettingsSectionTitle -} from "@app/components/Settings"; -import { - StrategySelect, - type StrategyOption -} from "@app/components/StrategySelect"; -import { Button } from "@app/components/ui/button"; -import { - Command, - CommandEmpty, - CommandGroup, - CommandInput, - CommandItem, - CommandList -} from "@app/components/ui/command"; -import { - Popover, - PopoverContent, - PopoverTrigger -} from "@app/components/ui/popover"; -import { useEnvContext } from "@app/hooks/useEnvContext"; -import { useOrgContext } from "@app/hooks/useOrgContext"; -import { usePaidStatus } from "@app/hooks/usePaidStatus"; -import { useResourceContext } from "@app/hooks/useResourceContext"; -import { toast } from "@app/hooks/useToast"; -import { createApiClient, formatAxiosError } from "@app/lib/api"; -import { cn } from "@app/lib/cn"; -import { orgQueries, resourceQueries } from "@app/lib/queries"; -import { ResourcePolicyProvider } from "@app/providers/ResourcePolicyProvider"; -import { zodResolver } from "@hookform/resolvers/zod"; -import { CaretSortIcon } from "@radix-ui/react-icons"; -import { build } from "@server/build"; -import { tierMatrix, TierFeature } from "@server/lib/billing/tierMatrix"; -import { useQuery, useQueryClient } from "@tanstack/react-query"; -import SetResourcePasswordForm from "@app/components/SetResourcePasswordForm"; -import { Binary, Bot, InfoIcon, Key } from "lucide-react"; -import { CheckIcon } from "lucide-react"; -import { useTranslations } from "next-intl"; -import { useRouter } from "next/navigation"; -import { useEffect, useState, useTransition } from "react"; -import { useForm, useWatch } from "react-hook-form"; -import { z } from "zod"; - -const resourceTypeSchema = z - .object({ - type: z.literal("inline") - }) - .or( - z.object({ - type: z.literal("shared"), - resourcePolicyId: z.number() - }) - ); - -type ResourcePolicyType = StrategyOption<"inline" | "shared">; +import { ResourcePolicyEditForm } from "@app/components/resource-policy/ResourcePolicyEditForm"; export default function ResourceAuthenticationPage() { - const { org } = useOrgContext(); - const { resource, updateResource } = useResourceContext(); - const queryClient = useQueryClient(); - - const { env } = useEnvContext(); - const { isPaidUser } = usePaidStatus(); - - const api = createApiClient({ env }); - const router = useRouter(); - const t = useTranslations(); - - const { data: policies, isLoading: isLoadingPolicies } = useQuery( - resourceQueries.policies({ - resourceId: resource.resourceId - }) - ); - - const form = useForm({ - resolver: zodResolver(resourceTypeSchema), - defaultValues: { - type: - build !== "oss" && resource.resourcePolicyId - ? "shared" - : "inline" - } - }); - - const selectedResourceType = useWatch({ - control: form.control, - name: "type" - }); - - const [resourcePolicysearchQuery, setResourcePolicySearchQuery] = - useState(""); - - const { data: policiesList = [] } = useQuery({ - ...orgQueries.policies({ - orgId: org.org.orgId, - name: resourcePolicysearchQuery - }), - enabled: selectedResourceType === "shared" - }); - - const [selectedPolicy, setSelectedPolicy] = useState<{ - name: string; - id: number; - } | null>(null); - - const resourcePolicyTypes: Array = [ - { - id: "inline", - title: t("resourcePolicyInline"), - description: t("resourcePolicyInlineDescription") - }, - { - id: "shared", - title: t("resourcePolicyShared"), - description: t("resourcePolicySharedDescription") - } - ]; - - useEffect(() => { - if (!isLoadingPolicies && policies?.sharedPolicy) { - setSelectedPolicy({ - id: policies?.sharedPolicy.resourcePolicyId, - name: policies?.sharedPolicy.name - }); - } - }, [isLoadingPolicies, policies?.sharedPolicy]); - - const [isUpdatingResource, startTransition] = useTransition(); - - async function handleSaveResourcePolicyType() { - try { - if (selectedResourceType === "inline") { - await api.post(`/resource/${resource.resourceId}`, { - resourcePolicyId: null - }); - } else { - if (!selectedPolicy) { - toast({ - title: t("error"), - description: t("resourcePolicySelectError"), - variant: "destructive" - }); - return; - } - await api.post(`/resource/${resource.resourceId}`, { - resourcePolicyId: selectedPolicy.id - }); - } - router.refresh(); - toast({ - title: t("resourceUpdated"), - description: t("resourceUpdatedDescription") - }); - } catch (e) { - toast({ - title: t("error"), - description: formatAxiosError(e), - variant: "destructive" - }); - } finally { - await queryClient.invalidateQueries( - resourceQueries.policies({ - resourceId: resource.resourceId - }) - ); - } - } - - const pageLoading = isLoadingPolicies || !policies; - - if (pageLoading) { - return <>; - } - - return ( - <> - - {build !== "oss" && - isPaidUser(tierMatrix[TierFeature.ResourcePolicies]) && ( - - - - {t("resourcePolicySelectTitle")} - - - {t("resourcePolicySelectDescription")} - - - - { - form.setValue("type", value); - }} - cols={2} - /> - {selectedResourceType === "shared" && ( - - - - - - - - - - {t( - "resourcePolicyNotFound" - )} - - - {policiesList.map( - (policy) => ( - - setSelectedPolicy( - { - id: policy.resourcePolicyId, - name: policy.name - } - ) - } - > - - { - policy.name - } - - ) - )} - - - - - - )} - - - - - - )} - - {selectedResourceType === "inline" ? ( - - - - ) : ( - policies.sharedPolicy && ( - - - - ) - )} - - - ); + return ; } diff --git a/src/app/[orgId]/settings/resources/public/[niceId]/general/page.tsx b/src/app/[orgId]/settings/resources/public/[niceId]/general/page.tsx index ef909bd9f..ed0061269 100644 --- a/src/app/[orgId]/settings/resources/public/[niceId]/general/page.tsx +++ b/src/app/[orgId]/settings/resources/public/[niceId]/general/page.tsx @@ -36,10 +36,14 @@ import { AlertCircle } from "lucide-react"; import { useTranslations } from "next-intl"; import { useParams, useRouter } from "next/navigation"; import { toASCII, toUnicode } from "punycode"; -import { useActionState, useMemo, useState } from "react"; +import { useActionState, useEffect, useMemo, useState } from "react"; import { useForm } from "react-hook-form"; import { zodResolver } from "@hookform/resolvers/zod"; import z from "zod"; +import { SharedPolicySelect } from "@app/components/shared-policy-selector"; +import { useOrgContext } from "@app/hooks/useOrgContext"; +import { build } from "@server/build"; +import { TierFeature } from "@server/lib/billing/tierMatrix"; import { Alert, AlertDescription } from "@app/components/ui/alert"; import { RadioGroup, RadioGroupItem } from "@app/components/ui/radio-group"; import { @@ -434,16 +438,30 @@ function MaintenanceSectionForm({ export default function GeneralForm() { const params = useParams(); + const { org } = useOrgContext(); const { resource, updateResource } = useResourceContext(); const router = useRouter(); const t = useTranslations(); const { env } = useEnvContext(); + const { isPaidUser } = usePaidStatus(); const orgId = params.orgId; const api = createApiClient({ env }); + const showResourcePolicy = + build !== "oss" && + isPaidUser(tierMatrix[TierFeature.ResourcePolicies]); + + const [selectedSharedPolicyId, setSelectedSharedPolicyId] = useState< + number | null + >(resource.resourcePolicyId ?? null); + + useEffect(() => { + setSelectedSharedPolicyId(resource.resourcePolicyId ?? null); + }, [resource.resourcePolicyId]); + const [resourceFullDomain, setResourceFullDomain] = useState( `${resource.ssl ? "https" : "http"}://${toUnicode(resource.fullDomain || "")}` ); @@ -506,6 +524,12 @@ export default function GeneralForm() { const data = form.getValues(); + let resourcePolicyId: number | null | undefined; + + if (showResourcePolicy) { + resourcePolicyId = selectedSharedPolicyId; + } + const res = await api .post>( `resource/${resource?.resourceId}`, @@ -519,7 +543,8 @@ export default function GeneralForm() { ) : undefined, domainId: data.domainId, - proxyPort: data.proxyPort + proxyPort: data.proxyPort, + ...(resourcePolicyId !== undefined && { resourcePolicyId }) } ) .catch((e) => { @@ -543,7 +568,10 @@ export default function GeneralForm() { subdomain: data.subdomain, fullDomain: updated.fullDomain, proxyPort: data.proxyPort, - domainId: data.domainId + domainId: data.domainId, + ...(resourcePolicyId !== undefined && { + resourcePolicyId + }) }); toast({ @@ -584,7 +612,7 @@ export default function GeneralForm() { - +
)} + {showResourcePolicy && ( +
+ + {t("sharedPolicy")} + + +
+ )} diff --git a/src/app/[orgId]/settings/resources/public/[niceId]/layout.tsx b/src/app/[orgId]/settings/resources/public/[niceId]/layout.tsx index 731991b73..1b20dbc8e 100644 --- a/src/app/[orgId]/settings/resources/public/[niceId]/layout.tsx +++ b/src/app/[orgId]/settings/resources/public/[niceId]/layout.tsx @@ -92,10 +92,16 @@ export default async function ResourceLayout(props: ResourceLayoutProps) { ]; if (["http", "ssh", "rdp", "vnc"].includes(resource.mode)) { - navItems.push({ - title: t("authentication"), - href: `/{orgId}/settings/resources/public/{niceId}/authentication` - }); + navItems.push( + { + title: t("authentication"), + href: `/{orgId}/settings/resources/public/{niceId}/authentication` + }, + { + title: t("policyAccessRulesTitle"), + 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 new file mode 100644 index 000000000..08aac6fa2 --- /dev/null +++ b/src/app/[orgId]/settings/resources/public/[niceId]/rules/page.tsx @@ -0,0 +1,7 @@ +"use client"; + +import { ResourcePolicyEditForm } from "@app/components/resource-policy/ResourcePolicyEditForm"; + +export default function ResourcePolicyRulesPage() { + return ; +} diff --git a/src/app/globals.css b/src/app/globals.css index b5a231e11..07355700a 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -22,7 +22,7 @@ --accent-foreground: oklch(0.21 0.006 285.885); --destructive: oklch(0.577 0.245 27.325); --destructive-foreground: oklch(0.985 0 0); - --border: oklch(0.91 0.004 286.32); + --border: oklch(0.88 0.004 286.32); --input: oklch(0.88 0.004 286.32); --ring: oklch(0.705 0.213 47.604); --chart-1: oklch(0.646 0.222 41.116); @@ -57,7 +57,7 @@ --accent-foreground: oklch(0.985 0 0); --destructive: oklch(0.5382 0.1949 22.216); --destructive-foreground: oklch(0.985 0 0); - --border: oklch(1 0 0 / 8%); + --border: oklch(1 0 0 / 18%); --input: oklch(1 0 0 / 18%); --ring: oklch(0.646 0.222 41.116); --chart-1: oklch(0.488 0.243 264.376); diff --git a/src/components/ResourcePoliciesBanner.tsx b/src/components/ResourcePoliciesBanner.tsx new file mode 100644 index 000000000..4a8d88d11 --- /dev/null +++ b/src/components/ResourcePoliciesBanner.tsx @@ -0,0 +1,21 @@ +"use client"; + +import { Shield } from "lucide-react"; +import { useTranslations } from "next-intl"; +import DismissableBanner from "./DismissableBanner"; + +export const ResourcePoliciesBanner = () => { + const t = useTranslations(); + + return ( + } + description={t("resourcePoliciesBannerDescription")} + /> + ); +}; + +export default ResourcePoliciesBanner; diff --git a/src/components/ResourcePoliciesTable.tsx b/src/components/ResourcePoliciesTable.tsx index 4a0a382e5..a21168db6 100644 --- a/src/components/ResourcePoliciesTable.tsx +++ b/src/components/ResourcePoliciesTable.tsx @@ -283,6 +283,7 @@ export function ResourcePoliciesTable({ searchPlaceholder={t("resourcePoliciesSearch")} pagination={pagination} rowCount={rowCount} + searchQuery={searchParams.get("query")?.toString()} onSearch={handleSearchChange} onPaginationChange={handlePaginationChange} onAdd={() => diff --git a/src/components/resource-policy/CreatePolicyForm.tsx b/src/components/resource-policy/CreatePolicyForm.tsx index 17bc8b634..b4cd146b5 100644 --- a/src/components/resource-policy/CreatePolicyForm.tsx +++ b/src/components/resource-policy/CreatePolicyForm.tsx @@ -147,7 +147,7 @@ export function CreatePolicyForm({}: CreatePolicyFormProps) { if (res && res.status === 201) { const niceId = res.data.data.niceId; router.push( - `/${org.org.orgId}/settings/policies/resources/public/${niceId}` + `/${org.org.orgId}/settings/policies/resources/public/${niceId}/general` ); toast({ title: t("success"), @@ -227,7 +227,7 @@ export function CreatePolicyForm({}: CreatePolicyFormProps) { - + - + diff --git a/src/components/resource-policy/CreatePolicyRulesSectionForm.tsx b/src/components/resource-policy/CreatePolicyRulesSectionForm.tsx deleted file mode 100644 index 042b63e63..000000000 --- a/src/components/resource-policy/CreatePolicyRulesSectionForm.tsx +++ /dev/null @@ -1,169 +0,0 @@ -"use client"; - -import { - SettingsSection, - SettingsSectionBody, - SettingsSectionDescription, - SettingsSectionHeader, - SettingsSectionTitle -} from "@app/components/Settings"; - -import { zodResolver } from "@hookform/resolvers/zod"; -import { useTranslations } from "next-intl"; - -import { createPolicyRulesSectionSchema, type PolicyFormValues } from "."; -import { Button } from "@app/components/ui/button"; -import { Plus } from "lucide-react"; - -import { useCallback, useEffect, useMemo, useState } from "react"; -import { type UseFormReturn, useForm, useWatch } from "react-hook-form"; - -import { PolicyAccessRulesIntro } from "./PolicyAccessRulesIntro"; -import { PolicyAccessRulesTable } from "./PolicyAccessRulesTable"; -import { - createEmptyRule, - type PolicyAccessRule -} from "./policy-access-rule-utils"; - -export type CreatePolicyRulesSectionFormProps = { - form: UseFormReturn; - isMaxmindAvailable: boolean; - isMaxmindAsnAvailable: boolean; -}; - -export function CreatePolicyRulesSectionForm({ - form: parentForm, - isMaxmindAvailable, - isMaxmindAsnAvailable -}: CreatePolicyRulesSectionFormProps) { - const t = useTranslations(); - const [rules, setRules] = useState([]); - - const rulesFormSchema = useMemo( - () => createPolicyRulesSectionSchema(t), - [t] - ); - - const form = useForm({ - resolver: zodResolver(rulesFormSchema), - defaultValues: { - applyRules: false, - rules: [] - } - }); - - useEffect(() => { - const subscription = form.watch((values) => { - parentForm.setValue("applyRules", values.applyRules as boolean); - parentForm.setValue("rules", values.rules as any); - }); - return () => subscription.unsubscribe(); - }, [form, parentForm]); - - const rulesEnabled = useWatch({ - control: form.control, - name: "applyRules" - }); - - const syncFormRules = useCallback( - (updatedRules: PolicyAccessRule[]) => { - form.setValue( - "rules", - updatedRules.map( - ({ action, match, value, priority, enabled }) => ({ - action, - match, - value, - priority, - enabled - }) - ) - ); - }, - [form] - ); - - const addEmptyRule = useCallback(() => { - const updatedRules = [...rules, createEmptyRule(rules)]; - setRules(updatedRules); - syncFormRules(updatedRules); - }, [rules, syncFormRules]); - - const removeRule = useCallback( - function removeRule(ruleId: number) { - const updatedRules = rules.filter((rule) => rule.ruleId !== ruleId); - setRules(updatedRules); - syncFormRules(updatedRules); - }, - [rules, syncFormRules] - ); - - const updateRule = useCallback( - function updateRule(ruleId: number, data: Partial) { - const updatedRules = rules.map((rule) => - rule.ruleId === ruleId - ? { ...rule, ...data, updated: true } - : rule - ); - setRules(updatedRules); - syncFormRules(updatedRules); - }, - [rules, syncFormRules] - ); - - const handleRulesChange = useCallback( - (updatedRules: PolicyAccessRule[]) => { - setRules(updatedRules); - syncFormRules(updatedRules); - }, - [syncFormRules] - ); - - const addRuleButton = ( - - ); - - const hasRules = rules.length > 0; - - return ( - - - - {t("policyAccessRulesTitle")} - - - {t("rulesResourceDescription")} - - - -
- { - form.setValue("applyRules", val); - }} - /> - - {rulesEnabled && ( - <> - - {hasRules && addRuleButton} - - )} -
-
-
- ); -} diff --git a/src/components/resource-policy/EditPolicyForm.tsx b/src/components/resource-policy/EditPolicyForm.tsx index 5acc6de72..57dd12fdc 100644 --- a/src/components/resource-policy/EditPolicyForm.tsx +++ b/src/components/resource-policy/EditPolicyForm.tsx @@ -10,26 +10,27 @@ import { orgQueries } from "@app/lib/queries"; import { build } from "@server/build"; import { tierMatrix } from "@server/lib/billing/tierMatrix"; import { useQuery } from "@tanstack/react-query"; -import { useTranslations } from "next-intl"; import { useMemo } from "react"; -import { HorizontalTabs } from "@app/components/HorizontalTabs"; import { EditPolicyNameSectionForm } from "./EditPolicyNameSectionForm"; import { PolicyAuthStackSection } from "./PolicyAuthStackSection"; import { PolicyAccessRulesSection } from "./PolicyAccessRulesSection"; +export type EditPolicyFormSection = "general" | "authentication" | "rules"; + export type EditPolicyFormProps = { hidePolicyNameForm?: boolean; readonly?: boolean; resourceId?: number; + section?: EditPolicyFormSection; }; export function EditPolicyForm({ hidePolicyNameForm, readonly, - resourceId + resourceId, + section }: EditPolicyFormProps) { - const t = useTranslations(); const { org } = useOrgContext(); const { env } = useEnvContext(); const { isPaidUser } = usePaidStatus(); @@ -37,7 +38,6 @@ export function EditPolicyForm({ // 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 showTabs = !hidePolicyNameForm && !isOverlay; const isMaxmindAvailable = !!( env.server.maxmind_db_path && env.server.maxmind_db_path.length > 0 @@ -92,22 +92,16 @@ export function EditPolicyForm({ /> ); - if (showTabs) { - return ( - - - {authSection} - {rulesSection} - - ); + if (section === "general") { + return ; + } + + if (section === "authentication") { + return authSection; + } + + if (section === "rules") { + return rulesSection; } return ( diff --git a/src/components/resource-policy/EditPolicyNameSectionForm.tsx b/src/components/resource-policy/EditPolicyNameSectionForm.tsx index 5acb6fd7b..172c8f691 100644 --- a/src/components/resource-policy/EditPolicyNameSectionForm.tsx +++ b/src/components/resource-policy/EditPolicyNameSectionForm.tsx @@ -109,7 +109,7 @@ export function EditPolicyNameSectionForm({ if (payload.niceId && payload.niceId !== policy.niceId) { router.replace( - `/${org.org.orgId}/settings/policies/resources/public/${payload.niceId}` + `/${org.org.orgId}/settings/policies/resources/public/${payload.niceId}/general` ); } diff --git a/src/components/resource-policy/PolicyAccessRulesSection.tsx b/src/components/resource-policy/PolicyAccessRulesSection.tsx index 196fd9b21..796ebbad0 100644 --- a/src/components/resource-policy/PolicyAccessRulesSection.tsx +++ b/src/components/resource-policy/PolicyAccessRulesSection.tsx @@ -28,7 +28,8 @@ import { useMemo, useRef, useState, - useTransition + useTransition, + type ReactNode } from "react"; import { UseFormReturn, useForm, useWatch } from "react-hook-form"; import { useResourcePolicyContext } from "@app/providers/ResourcePolicyProvider"; @@ -38,11 +39,12 @@ 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 { SharedPolicyResourceNotice } from "./SharedPolicyResourceNotice"; import { createEmptyRule, + prependEmptyRule, type PolicyAccessRule } from "./policy-access-rule-utils"; @@ -74,6 +76,143 @@ export function PolicyAccessRulesSection(props: PolicyAccessRulesSectionProps) { return ; } +type PolicyAccessRulesSectionLayoutProps = { + rulesEnabled: boolean; + onRulesEnabledChange: (enabled: boolean) => void; + disableToggle?: boolean; + rules: PolicyAccessRule[]; + onRulesChange: (rules: PolicyAccessRule[]) => void; + updateRule: (ruleId: number, data: Partial) => void; + removeRule: (ruleId: number) => void; + readonly?: boolean; + isMaxmindAvailable: boolean; + isMaxmindAsnAvailable: boolean; + resourceOverlayMode?: boolean; + footer?: ReactNode; +}; + +function PolicyAccessRulesSectionLayout({ + rulesEnabled, + onRulesEnabledChange, + disableToggle, + rules, + onRulesChange, + updateRule, + removeRule, + readonly, + isMaxmindAvailable, + isMaxmindAsnAvailable, + resourceOverlayMode, + footer +}: PolicyAccessRulesSectionLayoutProps) { + const t = useTranslations(); + + const addEmptyRule = useCallback(() => { + if (resourceOverlayMode) { + onRulesChange(prependEmptyRule(rules)); + return; + } + onRulesChange([...rules, createEmptyRule(rules)]); + }, [rules, onRulesChange, resourceOverlayMode]); + + const addRuleButton = ( + + ); + + const hasRules = rules.length > 0; + + return ( + + + + {t("policyAccessRulesTitle")} + + + {t("rulesResourceDescription")} + + + +
+ {resourceOverlayMode && ( + + )} + + + {rulesEnabled && ( + <> + + {hasRules && addRuleButton} + + )} +
+
+ {footer} +
+ ); +} + +function usePolicyAccessRulesFormSync( + form: UseFormReturn<{ + applyRules: boolean; + rules: PolicyFormValues["rules"]; + }> +) { + const syncFormRules = useCallback( + (updatedRules: PolicyAccessRule[]) => { + form.setValue( + "rules", + updatedRules.map( + ({ action, match, value, priority, enabled }) => ({ + action, + match, + value, + priority, + enabled + }) + ) + ); + }, + [form] + ); + + const updateRulesState = useCallback( + ( + setRules: React.Dispatch>, + updatedRules: PolicyAccessRule[] + ) => { + setRules(updatedRules); + syncFormRules(updatedRules); + }, + [syncFormRules] + ); + + return { syncFormRules, updateRulesState }; +} + function PolicyAccessRulesSectionEdit({ isMaxmindAvailable, isMaxmindAsnAvailable, @@ -119,6 +258,8 @@ function PolicyAccessRulesSectionEdit({ policy.rules.map((r) => ({ ...r, fromPolicy: isResourceOverlay })) ); + const { updateRulesState } = usePolicyAccessRulesFormSync(form); + useEffect(() => { if (!isResourceOverlay || resourceRulesInitialized) return; if (!resourceRulesData) return; @@ -148,30 +289,13 @@ function PolicyAccessRulesSectionEdit({ policy.rules ]); - const syncFormRules = useCallback( + const handleRulesChange = useCallback( (updatedRules: PolicyAccessRule[]) => { - form.setValue( - "rules", - updatedRules.map( - ({ action, match, value, priority, enabled }) => ({ - action, - match, - value, - priority, - enabled - }) - ) - ); + updateRulesState(setRules, updatedRules); }, - [form] + [updateRulesState] ); - 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); @@ -179,32 +303,22 @@ function PolicyAccessRulesSectionEdit({ if (isResourceOverlay && !rule.new) { deletedResourceRuleIdsRef.current.add(ruleId); } - const updatedRules = rules.filter((rule) => rule.ruleId !== ruleId); - setRules(updatedRules); - syncFormRules(updatedRules); + handleRulesChange(rules.filter((rule) => rule.ruleId !== ruleId)); }, - [rules, syncFormRules, isResourceOverlay] + [rules, handleRulesChange, isResourceOverlay] ); const updateRule = useCallback( function updateRule(ruleId: number, data: Partial) { - const updatedRules = rules.map((rule) => - rule.ruleId === ruleId - ? { ...rule, ...data, updated: true } - : rule + handleRulesChange( + 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] + [rules, handleRulesChange] ); const [isPending, startTransition] = useTransition(); @@ -213,7 +327,10 @@ function PolicyAccessRulesSectionEdit({ if (readonly) return; const applyRules = form.getValues("applyRules") ?? false; - const rulesPayload = rules.map( + const rulesToValidate = isResourceOverlay + ? rules.filter((rule) => !rule.fromPolicy) + : rules; + const rulesPayload = rulesToValidate.map( ({ action, match, value, priority, enabled }) => ({ action, match, @@ -331,80 +448,112 @@ function PolicyAccessRulesSectionEdit({ } } - const addRuleButton = ( - - ); - - const hasRules = rules.length > 0; - return ( - - - - {t("policyAccessRulesTitle")} - - - {t("rulesResourceDescription")} - - - -
- { - form.setValue("applyRules", val); - }} - disableToggle={readonly || isResourceOverlay} - /> - - {rulesEnabled && ( - <> - - {hasRules && addRuleButton} - - )} -
-
- - - -
+ { + form.setValue("applyRules", val); + }} + disableToggle={readonly || isResourceOverlay} + rules={rules} + onRulesChange={handleRulesChange} + updateRule={updateRule} + removeRule={removeRule} + readonly={readonly} + isMaxmindAvailable={isMaxmindAvailable} + isMaxmindAsnAvailable={isMaxmindAsnAvailable} + resourceOverlayMode={isResourceOverlay} + footer={ + + + + } + /> ); } function PolicyAccessRulesSectionCreate({ - form, + form: parentForm, isMaxmindAvailable, isMaxmindAsnAvailable }: PolicyAccessRulesSectionCreateProps) { + const t = useTranslations(); + const [rules, setRules] = useState([]); + + const rulesFormSchema = useMemo( + () => createPolicyRulesSectionSchema(t), + [t] + ); + + const form = useForm({ + resolver: zodResolver(rulesFormSchema), + defaultValues: { + applyRules: false, + rules: [] + } + }); + + useEffect(() => { + const subscription = form.watch((values) => { + parentForm.setValue("applyRules", values.applyRules as boolean); + parentForm.setValue( + "rules", + values.rules as PolicyFormValues["rules"] + ); + }); + return () => subscription.unsubscribe(); + }, [form, parentForm]); + + const rulesEnabled = useWatch({ + control: form.control, + name: "applyRules" + }); + + const { updateRulesState } = usePolicyAccessRulesFormSync(form); + + const handleRulesChange = useCallback( + (updatedRules: PolicyAccessRule[]) => { + updateRulesState(setRules, updatedRules); + }, + [updateRulesState] + ); + + const removeRule = useCallback( + function removeRule(ruleId: number) { + handleRulesChange(rules.filter((rule) => rule.ruleId !== ruleId)); + }, + [rules, handleRulesChange] + ); + + const updateRule = useCallback( + function updateRule(ruleId: number, data: Partial) { + handleRulesChange( + rules.map((rule) => + rule.ruleId === ruleId + ? { ...rule, ...data, updated: true } + : rule + ) + ); + }, + [rules, handleRulesChange] + ); + return ( - { + form.setValue("applyRules", val); + }} + rules={rules} + onRulesChange={handleRulesChange} + updateRule={updateRule} + removeRule={removeRule} isMaxmindAvailable={isMaxmindAvailable} isMaxmindAsnAvailable={isMaxmindAsnAvailable} /> diff --git a/src/components/resource-policy/PolicyAccessRulesTable.tsx b/src/components/resource-policy/PolicyAccessRulesTable.tsx index 4f1052d4f..a701b92ff 100644 --- a/src/components/resource-policy/PolicyAccessRulesTable.tsx +++ b/src/components/resource-policy/PolicyAccessRulesTable.tsx @@ -66,8 +66,12 @@ import { validatePolicyRuleValue } from "./policy-access-rule-validation"; import { + buildDisplayPrioritiesForResourceOverlay, reorderPolicyRules, + reorderResourceOverlayRules, + setResourceRuleDisplayPriority, sortPolicyRulesByPriority, + sortPolicyRulesForResourceOverlay, type PolicyAccessRule } from "./policy-access-rule-utils"; @@ -82,6 +86,7 @@ export type PolicyAccessRulesTableProps = { readonly?: boolean; includeRegionMatch?: boolean; markUpdatedOnReorder?: boolean; + resourceOverlayMode?: boolean; isRuleDraggable?: (rule: PolicyAccessRule) => boolean; isRuleLocked?: (rule: PolicyAccessRule) => boolean; }; @@ -97,7 +102,7 @@ function getColumnClassName(columnId: string) { return "w-24 max-w-24"; } if (columnId === "action") { - return "w-40 max-w-40"; + return "w-42 max-w-42"; } if (columnId === "match") { return "w-36 max-w-36"; @@ -116,6 +121,7 @@ export function PolicyAccessRulesTable({ readonly = false, includeRegionMatch = false, markUpdatedOnReorder = false, + resourceOverlayMode = false, isRuleDraggable: isRuleDraggableProp, isRuleLocked: isRuleLockedProp }: PolicyAccessRulesTableProps) { @@ -140,12 +146,37 @@ export function PolicyAccessRulesTable({ ); const sortedRules = useMemo( - () => sortPolicyRulesByPriority(rules), + () => + resourceOverlayMode + ? sortPolicyRulesForResourceOverlay(rules) + : sortPolicyRulesByPriority(rules), + [rules, resourceOverlayMode] + ); + + const displayPriorities = useMemo( + () => + resourceOverlayMode + ? buildDisplayPrioritiesForResourceOverlay(rules) + : null, + [rules, resourceOverlayMode] + ); + + const resourceRuleCount = useMemo( + () => rules.filter((rule) => !rule.fromPolicy).length, [rules] ); const handleReorder = useCallback( (fromRuleId: number, toRuleId: number) => { + if (resourceOverlayMode) { + onRulesChange( + reorderResourceOverlayRules(rules, fromRuleId, toRuleId, { + markUpdated: markUpdatedOnReorder + }) + ); + return; + } + const fromIndex = sortedRules.findIndex( (rule) => rule.ruleId === fromRuleId ); @@ -164,7 +195,13 @@ export function PolicyAccessRulesTable({ ); onRulesChange(reordered); }, - [sortedRules, onRulesChange, markUpdatedOnReorder] + [ + rules, + sortedRules, + onRulesChange, + markUpdatedOnReorder, + resourceOverlayMode + ] ); const handleDragStart = useCallback((ruleId: number, e: DragEvent) => { @@ -228,60 +265,132 @@ export function PolicyAccessRulesTable({ maxSize: 96, header: ({ column }) => (
- + {resourceOverlayMode ? ( + + {t("rulesPriority")} + + ) : ( + + )}
), - cell: ({ row }) => ( - e.currentTarget.focus()} - onBlur={(e) => { - const validated = validatePolicyRulePriority( - t, - e.target.value - ); - if (!validated.success) { - toast({ - variant: "destructive", - ...validated.toast + cell: ({ row }) => { + const displayPriority = resourceOverlayMode + ? (displayPriorities?.get(row.original.ruleId) ?? + row.original.priority) + : row.original.priority; + + return ( + e.currentTarget.focus()} + onBlur={(e) => { + const validated = validatePolicyRulePriority( + t, + e.target.value + ); + if (!validated.success) { + toast({ + variant: "destructive", + ...validated.toast + }); + return; + } + + if (resourceOverlayMode) { + if ( + validated.data > resourceRuleCount || + validated.data < 1 + ) { + toast({ + variant: "destructive", + title: t( + "rulesErrorInvalidPriority" + ), + description: t( + "rulesErrorInvalidPriorityDescription" + ) + }); + return; + } + + const duplicateDisplayPriority = rules.some( + (rule) => + !rule.fromPolicy && + rule.ruleId !== + row.original.ruleId && + displayPriorities?.get( + rule.ruleId + ) === validated.data + ); + if (duplicateDisplayPriority) { + toast({ + variant: "destructive", + title: t( + "rulesErrorDuplicatePriority" + ), + description: t( + "rulesErrorDuplicatePriorityDescription" + ) + }); + return; + } + + if (validated.data === displayPriority) { + return; + } + + onRulesChange( + setResourceRuleDisplayPriority( + rules, + row.original.ruleId, + validated.data, + { + markUpdated: + markUpdatedOnReorder + } + ) + ); + return; + } + + const duplicatePriority = rules.some( + (rule) => + rule.ruleId !== row.original.ruleId && + rule.priority === validated.data + ); + if (duplicatePriority) { + toast({ + variant: "destructive", + title: t("rulesErrorDuplicatePriority"), + description: t( + "rulesErrorDuplicatePriorityDescription" + ) + }); + return; + } + updateRule(row.original.ruleId, { + priority: validated.data }); - return; - } - const duplicatePriority = rules.some( - (rule) => - rule.ruleId !== row.original.ruleId && - rule.priority === validated.data - ); - if (duplicatePriority) { - toast({ - variant: "destructive", - title: t("rulesErrorDuplicatePriority"), - description: t( - "rulesErrorDuplicatePriorityDescription" - ) - }); - return; - } - updateRule(row.original.ruleId, { - priority: validated.data - }); - }} - /> - ) + }} + /> + ); + } }, { accessorKey: "action", @@ -683,13 +792,7 @@ export function PolicyAccessRulesTable({ cell: ({ row }) => (
{isRuleLocked(row.original) ? ( - + ) : ( + + + { + onChange(policy?.resourcePolicyId ?? null); + setSelectedLabel( + policy + ? { + resourcePolicyId: policy.resourcePolicyId, + name: policy.name + } + : null + ); + setOpen(false); + }} + /> + + + ); +} diff --git a/src/components/ui/alert.tsx b/src/components/ui/alert.tsx index dce25949a..90a87bee6 100644 --- a/src/components/ui/alert.tsx +++ b/src/components/ui/alert.tsx @@ -4,7 +4,7 @@ import { cva, type VariantProps } from "class-variance-authority"; import { cn } from "@app/lib/cn"; const alertVariants = cva( - "relative w-full rounded-lg p-4 [&>svg~*]:pl-7 [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground", + "relative w-full rounded-lg p-4 has-[>svg]:grid has-[>svg]:grid-cols-[auto_1fr] has-[>svg]:gap-x-3 gap-y-1 [&>svg]:col-start-1 [&>svg]:row-start-1 [&>svg]:row-span-full [&>svg]:size-4 [&>svg]:shrink-0 [&>svg]:self-center [&>svg]:text-foreground [&>svg~*]:col-start-2", { variants: { variant: { diff --git a/src/components/ui/select.tsx b/src/components/ui/select.tsx index 6d065e7aa..c91799712 100644 --- a/src/components/ui/select.tsx +++ b/src/components/ui/select.tsx @@ -105,13 +105,17 @@ function SelectLabel({ function SelectItem({ className, children, + description, ...props -}: React.ComponentProps) { +}: React.ComponentProps & { + description?: React.ReactNode; +}) { return ( - {children} + {description ? ( +
+ + {children} + + + {description} + +
+ ) : ( + {children} + )}
); } diff --git a/src/lib/queries.ts b/src/lib/queries.ts index 17377af8f..7d224c7b1 100644 --- a/src/lib/queries.ts +++ b/src/lib/queries.ts @@ -45,6 +45,7 @@ import type { ListOrgLabelsResponse } from "@server/routers/labels/types"; import { ListHealthChecksResponse } from "@server/routers/healthChecks/types"; import { StatusHistoryResponse } from "@server/lib/statusHistory"; import type { ListResourcePoliciesResponse } from "@server/routers/resource/types"; +import type { GetResourcePolicyResponse } from "@server/routers/policy"; export type ProductUpdate = { link: string | null; @@ -581,16 +582,16 @@ export const orgQueries = { } }), - policies: ({ orgId, name }: { orgId: string; name?: string }) => + policies: ({ orgId, query }: { orgId: string; query?: string }) => queryOptions({ - queryKey: ["ORG", orgId, "RESOURCES_POLICIES", name] as const, + queryKey: ["ORG", orgId, "RESOURCES_POLICIES", query] as const, queryFn: async ({ signal, meta }) => { const sp = new URLSearchParams({ pageSize: "10" }); - if (name) { - sp.set("query", name); + if (query) { + sp.set("query", query); } const res = await meta!.api.get< @@ -601,6 +602,18 @@ export const orgQueries = { return res.data.data.policies; } + }), + + resourcePolicy: ({ resourcePolicyId }: { resourcePolicyId: number }) => + queryOptions({ + queryKey: ["RESOURCE_POLICY", resourcePolicyId] as const, + queryFn: async ({ signal, meta }) => { + const res = await meta!.api.get< + AxiosResponse + >(`/resource-policy/${resourcePolicyId}`, { signal }); + + return res.data.data; + } }) };