policies and policy on resource structure in a good place

This commit is contained in:
miloschwartz
2026-06-07 12:19:33 -07:00
parent aa47f522ef
commit 3b675f7de1
36 changed files with 1579 additions and 1147 deletions

View File

@@ -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 <policyLink>{policyName}</policyLink>.",
"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 <policyLink>{policyName}</policyLink>.",
"resourceUsersRoles": "Access Controls",
"resourceUsersRolesDescription": "Configure which users and roles can visit this resource",
"resourceUsersRolesSubmit": "Save Access Controls",

View File

@@ -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(

View File

@@ -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)
);
}
}

View File

@@ -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,

View File

@@ -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)
);
}
}

View File

@@ -0,0 +1,7 @@
"use client";
import { EditPolicyForm } from "@app/components/resource-policy/EditPolicyForm";
export default function EditPolicyAuthenticationPage() {
return <EditPolicyForm section="authentication" />;
}

View File

@@ -0,0 +1,7 @@
"use client";
import { EditPolicyForm } from "@app/components/resource-policy/EditPolicyForm";
export default function EditPolicyGeneralPage() {
return <EditPolicyForm section="general" />;
}

View File

@@ -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<GetResourcePolicyResponse>
>(
`/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 (
<>
<div className="flex justify-between">
<SettingsSectionTitle
title={t("resourcePolicySetting", {
policyName: policyResponse.name
})}
description={t("resourcePolicySettingDescription")}
/>
<Button asChild variant="outline">
<Link
href={`/${params.orgId}/settings/policies/resources/public`}
>
{t("resourcePoliciesSeeAll")}
</Link>
</Button>
</div>
<ResourcePolicyProvider policy={policyResponse}>
<HorizontalTabs items={navItems}>{props.children}</HorizontalTabs>
</ResourcePolicyProvider>
</>
);
}

View File

@@ -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<GetResourcePolicyResponse>
>(
`/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 (
<>
<div className="flex justify-between">
<SettingsSectionTitle
title={t("resourcePolicySetting", {
policyName: policyResponse.name
})}
description={t("resourcePolicySettingDescription")}
/>
<Button asChild variant="outline">
<Link
href={`/${params.orgId}/settings/policies/resources/public`}
>
{t("resourcePoliciesSeeAll")}
</Link>
</Button>
</div>
<ResourcePolicyProvider policy={policyResponse}>
<EditPolicyForm />
</ResourcePolicyProvider>
</>
redirect(
`/${params.orgId}/settings/policies/resources/public/${params.niceId}/general`
);
}

View File

@@ -0,0 +1,7 @@
"use client";
import { EditPolicyForm } from "@app/components/resource-policy/EditPolicyForm";
export default function EditPolicyRulesPage() {
return <EditPolicyForm section="rules" />;
}

View File

@@ -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")}
/>
<ResourcePoliciesBanner />
<ResourcePoliciesTable
policies={policies}
orgId={params.orgId}

View File

@@ -21,6 +21,7 @@ import {
SettingsSectionHeader,
SettingsSectionTitle
} from "@app/components/Settings";
import { DataTableEmptyState } from "@app/components/ui/data-table-empty-state";
import {
Table,
TableBody,
@@ -710,6 +711,15 @@ export function ProxyResourceTargetsForm({
const [, formAction, isSubmitting] = useActionState(saveTargets, null);
const addTargetButton = (
<Button onClick={addNewTarget} variant="outline">
<Plus className="h-4 w-4 mr-2" />
{t("addTarget")}
</Button>
);
const hasTargets = targets.length > 0;
async function saveTargets() {
if (!resource) return;
@@ -823,143 +833,104 @@ export function ProxyResourceTargetsForm({
</SettingsSectionDescription>
</SettingsSectionHeader>
<SettingsSectionBody>
{targets.length > 0 ? (
<>
<div className="overflow-x-auto">
<Table>
<TableHeader>
{table
.getHeaderGroups()
.map((headerGroup) => (
<TableRow key={headerGroup.id}>
{headerGroup.headers.map(
(header) => {
const isActionsColumn =
header.column
.id ===
"actions";
const isSiteColumn =
header.column
.id ===
"site";
return (
<TableHead
key={
header.id
}
className={
isActionsColumn
? "sticky right-0 z-10 w-auto min-w-fit bg-card"
: isSiteColumn
? "w-45"
: ""
}
>
{header.isPlaceholder
? null
: flexRender(
header
.column
.columnDef
.header,
header.getContext()
)}
</TableHead>
);
}
)}
</TableRow>
))}
</TableHeader>
<TableBody>
{table.getRowModel().rows?.length ? (
table
.getRowModel()
.rows.map((row) => (
<TableRow key={row.id}>
{row
.getVisibleCells()
.map((cell) => {
const isActionsColumn =
cell.column
.id ===
"actions";
const isSiteColumn =
cell.column
.id ===
"site";
return (
<TableCell
key={
cell.id
}
className={
isActionsColumn
? "sticky right-0 z-10 w-auto min-w-fit bg-card"
: isSiteColumn
? "w-45"
: ""
}
>
{flexRender(
cell
.column
.columnDef
.cell,
cell.getContext()
)}
</TableCell>
);
})}
</TableRow>
))
) : (
<TableRow>
<TableCell
colSpan={columns.length}
className="h-24 text-center"
<div className="overflow-x-auto">
<Table>
<TableHeader>
{table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id}>
{headerGroup.headers.map((header) => {
const isActionsColumn =
header.column.id === "actions";
const isSiteColumn =
header.column.id === "site";
return (
<TableHead
key={header.id}
className={
isActionsColumn
? "sticky right-0 z-10 w-auto min-w-fit bg-card"
: isSiteColumn
? "w-45"
: ""
}
>
{t("targetNoOne")}
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
<div className="flex items-center justify-between mb-4">
<div className="flex items-center justify-between w-full gap-2">
<Button
onClick={addNewTarget}
variant="outline"
{header.isPlaceholder
? null
: flexRender(
header.column
.columnDef
.header,
header.getContext()
)}
</TableHead>
);
})}
</TableRow>
))}
</TableHeader>
<TableBody>
{table.getRowModel().rows?.length ? (
table.getRowModel().rows.map((row) => (
<TableRow key={row.id}>
{row
.getVisibleCells()
.map((cell) => {
const isActionsColumn =
cell.column.id ===
"actions";
const isSiteColumn =
cell.column.id ===
"site";
return (
<TableCell
key={cell.id}
className={
isActionsColumn
? "sticky right-0 z-10 w-auto min-w-fit bg-card"
: isSiteColumn
? "w-45"
: ""
}
>
{flexRender(
cell.column
.columnDef
.cell,
cell.getContext()
)}
</TableCell>
);
})}
</TableRow>
))
) : (
<DataTableEmptyState
colSpan={columns.length}
message={t("targetNoOne")}
action={addTargetButton}
/>
)}
</TableBody>
</Table>
</div>
{hasTargets && (
<div className="flex items-center justify-between mb-4">
<div className="flex items-center justify-between w-full gap-2">
{addTargetButton}
<div className="flex items-center gap-2">
<Switch
id="advanced-mode-toggle"
checked={isAdvancedMode}
onCheckedChange={setIsAdvancedMode}
/>
<label
htmlFor="advanced-mode-toggle"
className="text-sm"
>
<Plus className="h-4 w-4 mr-2" />
{t("addTarget")}
</Button>
<div className="flex items-center gap-2">
<Switch
id="advanced-mode-toggle"
checked={isAdvancedMode}
onCheckedChange={setIsAdvancedMode}
/>
<label
htmlFor="advanced-mode-toggle"
className="text-sm"
>
{t("advancedMode")}
</label>
</div>
{t("advancedMode")}
</label>
</div>
</div>
</>
) : (
<div className="text-center py-8 border-2 border-dashed border-muted rounded-lg p-4">
<p className="text-muted-foreground mb-4">
{t("targetNoOne")}
</p>
<Button onClick={addNewTarget} variant="outline">
<Plus className="h-4 w-4 mr-2" />
{t("addTarget")}
</Button>
</div>
)}
{build === "saas" &&

View File

@@ -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<ResourcePolicyType> = [
{
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 (
<>
<SettingsContainer>
{build !== "oss" &&
isPaidUser(tierMatrix[TierFeature.ResourcePolicies]) && (
<SettingsSection>
<SettingsSectionHeader>
<SettingsSectionTitle>
{t("resourcePolicySelectTitle")}
</SettingsSectionTitle>
<SettingsSectionDescription>
{t("resourcePolicySelectDescription")}
</SettingsSectionDescription>
</SettingsSectionHeader>
<SettingsSectionBody>
<StrategySelect
options={resourcePolicyTypes}
value={selectedResourceType}
onChange={(value) => {
form.setValue("type", value);
}}
cols={2}
/>
{selectedResourceType === "shared" && (
<Popover>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
className={
"w-full md:w-1/2 justify-between"
}
>
<span className="truncate max-w-37.5">
{selectedPolicy
? selectedPolicy.name
: t(
"resourcePolicySelect"
)}
</span>
<CaretSortIcon className="ml-2h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="p-0 w-45">
<Command shouldFilter={false}>
<CommandInput
placeholder={t(
"resourcePolicySearch"
)}
value={
resourcePolicysearchQuery
}
onValueChange={
setResourcePolicySearchQuery
}
/>
<CommandList>
<CommandEmpty>
{t(
"resourcePolicyNotFound"
)}
</CommandEmpty>
<CommandGroup>
{policiesList.map(
(policy) => (
<CommandItem
key={
policy.resourcePolicyId
}
value={policy.resourcePolicyId.toString()}
onSelect={() =>
setSelectedPolicy(
{
id: policy.resourcePolicyId,
name: policy.name
}
)
}
>
<CheckIcon
className={cn(
"mr-2 h-4 w-4",
policy.resourcePolicyId ===
selectedPolicy?.id
? "opacity-100"
: "opacity-0"
)}
/>
{
policy.name
}
</CommandItem>
)
)}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
)}
</SettingsSectionBody>
<SettingsSectionFooter className="justify-start">
<Button
onClick={() =>
startTransition(
handleSaveResourcePolicyType
)
}
loading={isUpdatingResource}
>
{t("resourcePolicyTypeSave")}
</Button>
</SettingsSectionFooter>
</SettingsSection>
)}
{selectedResourceType === "inline" ? (
<ResourcePolicyProvider policy={policies.defaultPolicy}>
<EditPolicyForm hidePolicyNameForm />
</ResourcePolicyProvider>
) : (
policies.sharedPolicy && (
<ResourcePolicyProvider
policy={policies.sharedPolicy}
key={policies.sharedPolicy.resourcePolicyId}
>
<EditPolicyForm
resourceId={resource.resourceId}
/>
</ResourcePolicyProvider>
)
)}
</SettingsContainer>
</>
);
return <ResourcePolicyEditForm section="authentication" />;
}

View File

@@ -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<AxiosResponse<UpdateResourceResponse>>(
`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() {
</SettingsSectionHeader>
<SettingsSectionBody>
<SettingsSectionForm>
<SettingsSectionForm variant="half">
<Form {...form}>
<form
action={formAction}
@@ -771,6 +799,24 @@ export default function GeneralForm() {
</div>
</div>
)}
{showResourcePolicy && (
<div className="space-y-2">
<FormLabel>
{t("sharedPolicy")}
</FormLabel>
<SharedPolicySelect
key={
resource.resourcePolicyId ??
"none"
}
orgId={org.org.orgId}
value={selectedSharedPolicyId}
onChange={
setSelectedSharedPolicyId
}
/>
</div>
)}
</form>
</Form>
</SettingsSectionForm>

View File

@@ -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 (

View File

@@ -0,0 +1,7 @@
"use client";
import { ResourcePolicyEditForm } from "@app/components/resource-policy/ResourcePolicyEditForm";
export default function ResourcePolicyRulesPage() {
return <ResourcePolicyEditForm section="rules" />;
}

View File

@@ -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);

View File

@@ -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 (
<DismissableBanner
storageKey="resource-policies-banner-dismissed"
version={1}
title={t("resourcePoliciesBannerTitle")}
titleIcon={<Shield className="w-5 h-5 text-primary" />}
description={t("resourcePoliciesBannerDescription")}
/>
);
};
export default ResourcePoliciesBanner;

View File

@@ -283,6 +283,7 @@ export function ResourcePoliciesTable({
searchPlaceholder={t("resourcePoliciesSearch")}
pagination={pagination}
rowCount={rowCount}
searchQuery={searchParams.get("query")?.toString()}
onSearch={handleSearchChange}
onPaginationChange={handlePaginationChange}
onAdd={() =>

View File

@@ -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) {
</SettingsSectionDescription>
</SettingsSectionHeader>
<SettingsSectionBody>
<SettingsSectionForm>
<SettingsSectionForm variant="half">
<FormField
control={form.control}
name="name"
@@ -237,12 +237,7 @@ export function CreatePolicyForm({}: CreatePolicyFormProps) {
{t("name")}
</FormLabel>
<FormControl>
<Input
{...field}
placeholder={t(
"resourcePolicyNamePlaceholder"
)}
/>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>

View File

@@ -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<PolicyFormValues, any, any>;
isMaxmindAvailable: boolean;
isMaxmindAsnAvailable: boolean;
};
export function CreatePolicyRulesSectionForm({
form: parentForm,
isMaxmindAvailable,
isMaxmindAsnAvailable
}: CreatePolicyRulesSectionFormProps) {
const t = useTranslations();
const [rules, setRules] = useState<PolicyAccessRule[]>([]);
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<PolicyAccessRule>) {
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 = (
<Button type="button" variant="outline" onClick={addEmptyRule}>
<Plus className="h-4 w-4 mr-2" />
{t("ruleSubmit")}
</Button>
);
const hasRules = rules.length > 0;
return (
<SettingsSection>
<SettingsSectionHeader>
<SettingsSectionTitle>
{t("policyAccessRulesTitle")}
</SettingsSectionTitle>
<SettingsSectionDescription>
{t("rulesResourceDescription")}
</SettingsSectionDescription>
</SettingsSectionHeader>
<SettingsSectionBody>
<div className="flex flex-col gap-y-6 pb-20">
<PolicyAccessRulesIntro
rulesEnabled={Boolean(rulesEnabled)}
onRulesEnabledChange={(val) => {
form.setValue("applyRules", val);
}}
/>
{rulesEnabled && (
<>
<PolicyAccessRulesTable
rules={rules}
onRulesChange={handleRulesChange}
updateRule={updateRule}
removeRule={removeRule}
isMaxmindAvailable={isMaxmindAvailable}
isMaxmindAsnAvailable={isMaxmindAsnAvailable}
includeRegionMatch={false}
emptyStateAction={addRuleButton}
/>
{hasRules && addRuleButton}
</>
)}
</div>
</SettingsSectionBody>
</SettingsSection>
);
}

View File

@@ -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 (
<HorizontalTabs
clientSide
defaultTab={0}
items={[
{ title: t("general"), href: "#" },
{ title: t("authentication"), href: "#" },
{ title: t("policyAccessRulesTitle"), href: "#" }
]}
>
<EditPolicyNameSectionForm readonly={readonly} />
{authSection}
{rulesSection}
</HorizontalTabs>
);
if (section === "general") {
return <EditPolicyNameSectionForm readonly={readonly} />;
}
if (section === "authentication") {
return authSection;
}
if (section === "rules") {
return rulesSection;
}
return (

View File

@@ -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`
);
}

View File

@@ -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 <PolicyAccessRulesSectionEdit {...props} />;
}
type PolicyAccessRulesSectionLayoutProps = {
rulesEnabled: boolean;
onRulesEnabledChange: (enabled: boolean) => void;
disableToggle?: boolean;
rules: PolicyAccessRule[];
onRulesChange: (rules: PolicyAccessRule[]) => void;
updateRule: (ruleId: number, data: Partial<PolicyAccessRule>) => 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 = (
<Button
type="button"
variant="outline"
disabled={readonly}
onClick={addEmptyRule}
>
<Plus className="h-4 w-4 mr-2" />
{t("ruleSubmit")}
</Button>
);
const hasRules = rules.length > 0;
return (
<SettingsSection>
<SettingsSectionHeader>
<SettingsSectionTitle>
{t("policyAccessRulesTitle")}
</SettingsSectionTitle>
<SettingsSectionDescription>
{t("rulesResourceDescription")}
</SettingsSectionDescription>
</SettingsSectionHeader>
<SettingsSectionBody>
<div className="space-y-4">
{resourceOverlayMode && (
<SharedPolicyResourceNotice section="rules" />
)}
<PolicyAccessRulesIntro
rulesEnabled={rulesEnabled}
onRulesEnabledChange={onRulesEnabledChange}
disableToggle={disableToggle}
/>
{rulesEnabled && (
<>
<PolicyAccessRulesTable
rules={rules}
onRulesChange={onRulesChange}
updateRule={updateRule}
removeRule={removeRule}
readonly={readonly}
isMaxmindAvailable={isMaxmindAvailable}
isMaxmindAsnAvailable={isMaxmindAsnAvailable}
includeRegionMatch
markUpdatedOnReorder
resourceOverlayMode={resourceOverlayMode}
emptyStateAction={addRuleButton}
/>
{hasRules && addRuleButton}
</>
)}
</div>
</SettingsSectionBody>
{footer}
</SettingsSection>
);
}
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<React.SetStateAction<PolicyAccessRule[]>>,
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<PolicyAccessRule>) {
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 = (
<Button
type="button"
variant="outline"
disabled={readonly}
onClick={addEmptyRule}
>
<Plus className="h-4 w-4 mr-2" />
{t("ruleSubmit")}
</Button>
);
const hasRules = rules.length > 0;
return (
<SettingsSection>
<SettingsSectionHeader>
<SettingsSectionTitle>
{t("policyAccessRulesTitle")}
</SettingsSectionTitle>
<SettingsSectionDescription>
{t("rulesResourceDescription")}
</SettingsSectionDescription>
</SettingsSectionHeader>
<SettingsSectionBody>
<div className="space-y-6">
<PolicyAccessRulesIntro
rulesEnabled={Boolean(rulesEnabled)}
onRulesEnabledChange={(val) => {
form.setValue("applyRules", val);
}}
disableToggle={readonly || isResourceOverlay}
/>
{rulesEnabled && (
<>
<PolicyAccessRulesTable
rules={rules}
onRulesChange={handleRulesChange}
updateRule={updateRule}
removeRule={removeRule}
readonly={readonly}
isMaxmindAvailable={isMaxmindAvailable}
isMaxmindAsnAvailable={isMaxmindAsnAvailable}
includeRegionMatch
markUpdatedOnReorder
emptyStateAction={addRuleButton}
/>
{hasRules && addRuleButton}
</>
)}
</div>
</SettingsSectionBody>
<SettingsSectionFooter>
<Button
onClick={() => startTransition(() => saveRules())}
loading={isPending}
disabled={readonly || isPending}
>
{t("saveSettings")}
</Button>
</SettingsSectionFooter>
</SettingsSection>
<PolicyAccessRulesSectionLayout
rulesEnabled={Boolean(rulesEnabled)}
onRulesEnabledChange={(val) => {
form.setValue("applyRules", val);
}}
disableToggle={readonly || isResourceOverlay}
rules={rules}
onRulesChange={handleRulesChange}
updateRule={updateRule}
removeRule={removeRule}
readonly={readonly}
isMaxmindAvailable={isMaxmindAvailable}
isMaxmindAsnAvailable={isMaxmindAsnAvailable}
resourceOverlayMode={isResourceOverlay}
footer={
<SettingsSectionFooter>
<Button
onClick={() => startTransition(() => saveRules())}
loading={isPending}
disabled={readonly || isPending}
>
{t("saveSettings")}
</Button>
</SettingsSectionFooter>
}
/>
);
}
function PolicyAccessRulesSectionCreate({
form,
form: parentForm,
isMaxmindAvailable,
isMaxmindAsnAvailable
}: PolicyAccessRulesSectionCreateProps) {
const t = useTranslations();
const [rules, setRules] = useState<PolicyAccessRule[]>([]);
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<PolicyAccessRule>) {
handleRulesChange(
rules.map((rule) =>
rule.ruleId === ruleId
? { ...rule, ...data, updated: true }
: rule
)
);
},
[rules, handleRulesChange]
);
return (
<CreatePolicyRulesSectionForm
form={form}
<PolicyAccessRulesSectionLayout
rulesEnabled={Boolean(rulesEnabled)}
onRulesEnabledChange={(val) => {
form.setValue("applyRules", val);
}}
rules={rules}
onRulesChange={handleRulesChange}
updateRule={updateRule}
removeRule={removeRule}
isMaxmindAvailable={isMaxmindAvailable}
isMaxmindAsnAvailable={isMaxmindAsnAvailable}
/>

View File

@@ -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 }) => (
<div className="p-3">
<Button
variant="ghost"
className="h-auto p-0 font-medium text-muted-foreground hover:bg-transparent"
onClick={() =>
column.toggleSorting(
column.getIsSorted() === "asc"
)
}
>
{t("rulesPriority")}
<ArrowUpDown className="ml-1 h-3 w-3" />
</Button>
{resourceOverlayMode ? (
<span className="font-medium text-muted-foreground">
{t("rulesPriority")}
</span>
) : (
<Button
variant="ghost"
className="h-auto p-0 font-medium text-muted-foreground hover:bg-transparent"
onClick={() =>
column.toggleSorting(
column.getIsSorted() === "asc"
)
}
>
{t("rulesPriority")}
<ArrowUpDown className="ml-1 h-3 w-3" />
</Button>
)}
</div>
),
cell: ({ row }) => (
<Input
defaultValue={row.original.priority}
className="w-full min-w-0"
type="number"
disabled={readonly || isRuleLocked(row.original)}
onClick={(e) => 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 (
<Input
key={`${row.original.ruleId}-${displayPriority}`}
defaultValue={displayPriority}
className="w-full min-w-0"
type="number"
disabled={readonly || isRuleLocked(row.original)}
onClick={(e) => 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 }) => (
<div className="flex items-center justify-end space-x-2">
{isRuleLocked(row.original) ? (
<Button
variant="outline"
disabled
className="cursor-not-allowed"
>
<LockIcon className="h-4 w-4" />
</Button>
<LockIcon className="h-4 w-4 text-muted-foreground" />
) : (
<Button
variant="outline"
@@ -711,9 +814,14 @@ export function PolicyAccessRulesTable({
isMaxmindAsnAvailable,
includeRegionMatch,
updateRule,
onRulesChange,
removeRule,
readonly,
rules,
resourceOverlayMode,
displayPriorities,
resourceRuleCount,
markUpdatedOnReorder,
isRuleDraggable,
isRuleLocked,
handleDragStart,
@@ -775,7 +883,8 @@ export function PolicyAccessRulesTable({
e.preventDefault();
if (
draggedRuleId !== null &&
draggedRuleId !== rule.ruleId
draggedRuleId !== rule.ruleId &&
isRuleDraggable(rule)
) {
handleReorder(
draggedRuleId,
@@ -789,7 +898,7 @@ export function PolicyAccessRulesTable({
draggedRuleId === rule.ruleId &&
"opacity-50",
dragOverRuleId === rule.ruleId &&
"border-t-2 border-primary"
"border-t border-primary"
)}
>
{row.getVisibleCells().map((cell) => {

View File

@@ -66,23 +66,6 @@ export function PolicyAuthMethodRow({
>
<div className="flex flex-1 min-w-0 flex-col gap-0.5">
<div className="flex items-center gap-2">
<span
className="shrink-0 flex items-center"
role="img"
aria-label={
active
? t("policyAuthMethodActive")
: t("policyAuthMethodOff")
}
>
<div
className={
active
? "w-2 h-2 bg-green-500 rounded-full"
: "w-2 h-2 bg-neutral-500 rounded-full"
}
/>
</span>
<span className="text-sm font-medium">{title}</span>
</div>
<p className="truncate text-sm text-muted-foreground">

View File

@@ -48,20 +48,43 @@ import {
getPasscodeSummary,
getPincodeSummary
} from "./policy-auth-summaries";
import { SharedPolicyResourceNotice } from "./SharedPolicyResourceNotice";
import z from "zod";
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
});
// Edit mode keeps placeholder values for configured methods; only validate on save when changed.
const authStackEditSchema = createPolicySchema
.pick({
sso: true,
skipToIdpId: true,
roles: true,
users: true,
emailWhitelistEnabled: true,
emails: true
})
.extend({
password: z
.object({
password: z.string()
})
.nullable()
.optional(),
pincode: z
.object({
pincode: z.string()
})
.nullable()
.optional(),
headerAuth: z
.object({
user: z.string(),
password: z.string(),
extendedCompatibility: z.boolean().default(true)
})
.nullable()
.optional()
});
export type PolicyAuthStackSectionEditProps = {
orgId: string;
@@ -182,14 +205,14 @@ export function PolicyAuthStackSectionEdit({
]);
const form = useForm({
resolver: zodResolver(authStackSchema),
resolver: zodResolver(authStackEditSchema),
defaultValues: {
sso: policy.sso,
skipToIdpId: policy.idpId,
roles: policyRoleItems,
users: policyUserItems,
password: policy.passwordId ? { password: "" } : null,
pincode: policy.pincodeId ? { pincode: "" } : null,
password: null,
pincode: null,
headerAuth: policy.headerAuth
? {
user: "",
@@ -247,7 +270,14 @@ export function PolicyAuthStackSectionEdit({
}
const isValid = await form.trigger();
if (!isValid) return;
if (!isValid) {
toast({
variant: "destructive",
title: t("policyErrorUpdate"),
description: t("policyErrorUpdateMessageDescription")
});
return;
}
const payload = form.getValues();
const requests: Array<Promise<AxiosResponse<{}> | void>> = [];
@@ -441,184 +471,219 @@ export function PolicyAuthStackSectionEdit({
</SettingsSectionDescription>
</SettingsSectionHeader>
<SettingsSectionBody>
<div className="w-full md:w-1/2">
<PolicyAuthSsoSection
sso={Boolean(sso)}
onSsoChange={(active) =>
form.setValue("sso", active)
}
skipToIdpId={skipToIdpId}
onSkipToIdpChange={(id) =>
form.setValue("skipToIdpId", id)
}
allIdps={allIdps}
disabled={authReadonly}
idpDisabled={authReadonly}
rolesEditor={
isResourceOverlay ? (
<RolesSelector
orgId={orgId}
selectedRoles={overlayRoles}
onSelectRoles={(selected) =>
setCombinedRoles(
selected.map((role) => ({
...role,
isAdmin: Boolean(
role.isAdmin
<div className="space-y-4">
{isResourceOverlay && (
<SharedPolicyResourceNotice section="authentication" />
)}
<div className="w-full md:w-1/2">
<PolicyAuthSsoSection
sso={Boolean(sso)}
onSsoChange={(active) =>
form.setValue("sso", active)
}
skipToIdpId={skipToIdpId}
onSkipToIdpChange={(id) =>
form.setValue("skipToIdpId", id)
}
allIdps={allIdps}
disabled={authReadonly}
idpDisabled={authReadonly}
rolesEditor={
isResourceOverlay ? (
<RolesSelector
orgId={orgId}
selectedRoles={overlayRoles}
onSelectRoles={(selected) =>
setCombinedRoles(
selected.map(
(role) => ({
...role,
isAdmin:
Boolean(
role.isAdmin
)
})
)
}))
)
}
disabled={isLoading}
restrictAdminRole
lockedIds={policyRoleLockedIds}
/>
) : (
<FormField
control={form.control}
name="roles"
render={({ field }) => (
<RolesSelector
orgId={orgId}
selectedRoles={
field.value
}
onSelectRoles={(
selected
) =>
form.setValue(
"roles",
selected
)
}
disabled={readonly}
restrictAdminRole
/>
)}
/>
)
}
usersEditor={
isResourceOverlay ? (
<UsersSelector
orgId={orgId}
selectedUsers={overlayUsers}
onSelectUsers={setCombinedUsers}
disabled={isLoading}
lockedIds={policyUserLockedIds}
/>
) : (
<FormField
control={form.control}
name="users"
render={({ field }) => (
<UsersSelector
orgId={orgId}
selectedUsers={
field.value
}
onSelectUsers={(
selected
) =>
form.setValue(
"users",
selected
)
}
disabled={readonly}
/>
)}
/>
)
}
/>
</div>
<SettingsSubsectionHeader>
<SettingsSubsectionTitle>
{t("policyAuthOtherMethodsTitle")}
</SettingsSubsectionTitle>
<SettingsSubsectionDescription>
{t("policyAuthOtherMethodsDescription")}
</SettingsSubsectionDescription>
</SettingsSubsectionHeader>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
<PolicyAuthMethodRow
id="pincode"
title={t("policyAuthPincodeTitle")}
description={t(
"policyAuthPincodeDescription"
)}
summary={getPincodeSummary({ t })}
active={pinActive}
onConfigure={() =>
openMethodEditor("pincode")
}
onToggle={(active) =>
handleToggle("pincode", active, () => {
setPinActive(false);
form.setValue("pincode", null);
})
}
disabled={authReadonly}
/>
<PolicyAuthMethodRow
id="passcode"
title={t("policyAuthPasscodeTitle")}
description={t(
"policyAuthPasscodeDescription"
)}
summary={getPasscodeSummary({ t })}
active={passcodeActive}
onConfigure={() =>
openMethodEditor("passcode")
}
onToggle={(active) =>
handleToggle("passcode", active, () => {
setPasscodeActive(false);
form.setValue("password", null);
})
}
disabled={authReadonly}
/>
<PolicyAuthMethodRow
id="email"
title={t("policyAuthEmailTitle")}
description={t(
"policyAuthEmailDescription"
)}
summary={getEmailWhitelistSummary({
t,
count: emails.length
})}
active={Boolean(emailWhitelistEnabled)}
onConfigure={() =>
openMethodEditor("email")
}
onToggle={(active) =>
handleToggle(
"email",
active,
() =>
form.setValue(
"emailWhitelistEnabled",
false
),
() =>
form.setValue(
"emailWhitelistEnabled",
true
)
)
}
disabled={authReadonly || !emailEnabled}
/>
<PolicyAuthMethodRow
id="header-auth"
title={t("policyAuthHeaderAuthTitle")}
description={t(
"policyAuthHeaderAuthDescription"
)}
summary={getHeaderAuthSummary({
t,
headerName: headerAuth?.user ?? ""
})}
active={headerAuthActive}
onConfigure={() =>
openMethodEditor("headerAuth")
}
onToggle={(active) =>
handleToggle(
"headerAuth",
active,
() => {
setHeaderAuthActive(false);
form.setValue(
"headerAuth",
null
);
}
disabled={isLoading}
restrictAdminRole
lockedIds={policyRoleLockedIds}
/>
) : (
<FormField
control={form.control}
name="roles"
render={({ field }) => (
<RolesSelector
orgId={orgId}
selectedRoles={field.value}
onSelectRoles={(selected) =>
form.setValue(
"roles",
selected
)
}
disabled={readonly}
restrictAdminRole
/>
)}
/>
)
}
usersEditor={
isResourceOverlay ? (
<UsersSelector
orgId={orgId}
selectedUsers={overlayUsers}
onSelectUsers={setCombinedUsers}
disabled={isLoading}
lockedIds={policyUserLockedIds}
/>
) : (
<FormField
control={form.control}
name="users"
render={({ field }) => (
<UsersSelector
orgId={orgId}
selectedUsers={field.value}
onSelectUsers={(selected) =>
form.setValue(
"users",
selected
)
}
disabled={readonly}
/>
)}
/>
)
}
/>
</div>
<SettingsSubsectionHeader>
<SettingsSubsectionTitle>
{t("policyAuthOtherMethodsTitle")}
</SettingsSubsectionTitle>
<SettingsSubsectionDescription>
{t("policyAuthOtherMethodsDescription")}
</SettingsSubsectionDescription>
</SettingsSubsectionHeader>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
<PolicyAuthMethodRow
id="pincode"
title={t("policyAuthPincodeTitle")}
description={t("policyAuthPincodeDescription")}
summary={getPincodeSummary({ t })}
active={pinActive}
onConfigure={() => openMethodEditor("pincode")}
onToggle={(active) =>
handleToggle("pincode", active, () => {
setPinActive(false);
form.setValue("pincode", null);
})
}
disabled={authReadonly}
/>
<PolicyAuthMethodRow
id="passcode"
title={t("policyAuthPasscodeTitle")}
description={t("policyAuthPasscodeDescription")}
summary={getPasscodeSummary({ t })}
active={passcodeActive}
onConfigure={() => openMethodEditor("passcode")}
onToggle={(active) =>
handleToggle("passcode", active, () => {
setPasscodeActive(false);
form.setValue("password", null);
})
}
disabled={authReadonly}
/>
<PolicyAuthMethodRow
id="email"
title={t("policyAuthEmailTitle")}
description={t("policyAuthEmailDescription")}
summary={getEmailWhitelistSummary({
t,
count: emails.length
})}
active={Boolean(emailWhitelistEnabled)}
onConfigure={() => openMethodEditor("email")}
onToggle={(active) =>
handleToggle(
"email",
active,
() =>
form.setValue(
"emailWhitelistEnabled",
false
),
() =>
form.setValue(
"emailWhitelistEnabled",
true
)
)
}
disabled={authReadonly || !emailEnabled}
/>
<PolicyAuthMethodRow
id="header-auth"
title={t("policyAuthHeaderAuthTitle")}
description={t(
"policyAuthHeaderAuthDescription"
)}
summary={getHeaderAuthSummary({
t,
headerName: headerAuth?.user ?? ""
})}
active={headerAuthActive}
onConfigure={() =>
openMethodEditor("headerAuth")
}
onToggle={(active) =>
handleToggle("headerAuth", active, () => {
setHeaderAuthActive(false);
form.setValue("headerAuth", null);
})
}
disabled={authReadonly}
/>
)
}
disabled={authReadonly}
/>
</div>
</div>
<PincodeCredenza

View File

@@ -0,0 +1,47 @@
"use client";
import { useResourceContext } from "@app/hooks/useResourceContext";
import { resourceQueries } from "@app/lib/queries";
import { ResourcePolicyProvider } from "@app/providers/ResourcePolicyProvider";
import { useQuery } from "@tanstack/react-query";
import { EditPolicyForm, type EditPolicyFormSection } from "./EditPolicyForm";
type ResourcePolicyEditFormProps = {
section: Extract<EditPolicyFormSection, "authentication" | "rules">;
};
export function ResourcePolicyEditForm({
section
}: ResourcePolicyEditFormProps) {
const { resource } = useResourceContext();
const { data: policies, isLoading: isLoadingPolicies } = useQuery(
resourceQueries.policies({
resourceId: resource.resourceId
})
);
if (isLoadingPolicies || !policies) {
return <></>;
}
if (!policies.sharedPolicy) {
return (
<ResourcePolicyProvider policy={policies.defaultPolicy}>
<EditPolicyForm hidePolicyNameForm section={section} />
</ResourcePolicyProvider>
);
}
return (
<ResourcePolicyProvider
policy={policies.sharedPolicy}
key={policies.sharedPolicy.resourcePolicyId}
>
<EditPolicyForm
resourceId={resource.resourceId}
section={section}
/>
</ResourcePolicyProvider>
);
}

View File

@@ -0,0 +1,44 @@
"use client";
import { Alert, AlertDescription } from "@app/components/ui/alert";
import { useOrgContext } from "@app/hooks/useOrgContext";
import { useResourcePolicyContext } from "@app/providers/ResourcePolicyProvider";
import { InfoIcon } from "lucide-react";
import Link from "next/link";
import { useTranslations } from "next-intl";
type SharedPolicyResourceNoticeProps = {
section: "authentication" | "rules";
};
export function SharedPolicyResourceNotice({
section
}: SharedPolicyResourceNoticeProps) {
const t = useTranslations();
const { org } = useOrgContext();
const { policy } = useResourcePolicyContext();
const messageKey =
section === "authentication"
? "resourceSharedPolicyAuthenticationNotice"
: "resourceSharedPolicyRulesNotice";
return (
<Alert variant="neutral">
<InfoIcon className="h-4 w-4" />
<AlertDescription>
{t.rich(messageKey, {
policyName: policy.name,
policyLink: (chunks) => (
<Link
href={`/${org.org.orgId}/settings/policies/resources/public/${policy.niceId}/${section}`}
className="text-primary hover:underline"
>
{chunks}
</Link>
)
})}
</AlertDescription>
</Alert>
);
}

View File

@@ -1,6 +1,7 @@
// ─── Schemas & types ──────────────────────────────────────────────────────────
import z from "zod";
import { POLICY_RULE_MATCH_TYPES } from "./policy-access-rule-validation";
export const createPolicySchema = z.object({
name: z.string().min(1).max(255),
@@ -35,7 +36,7 @@ export const createPolicySchema = z.object({
.array(
z.object({
action: z.enum(["ACCEPT", "DROP", "PASS"]),
match: z.string(),
match: z.enum(POLICY_RULE_MATCH_TYPES),
value: z.string(),
priority: z.number().int(),
enabled: z.boolean()
@@ -67,6 +68,7 @@ export {
type PolicyAccessRule
} from "./policy-access-rule-utils";
export {
createPolicyRuleMatchSchema,
createPolicyRulePrioritySchema,
createPolicyRuleSchema,
createPolicyRuleValueSchema,
@@ -74,8 +76,10 @@ export {
createPolicyRulesSectionSchema,
createPolicySchemaWithI18n,
getPolicyRuleValidationMessage,
POLICY_RULE_MATCH_TYPES,
validatePolicyRulePriority,
validatePolicyRuleValue,
validatePolicyRulesForSave,
type PolicyRuleMatchType,
type RuleValidationToast
} from "./policy-access-rule-validation";

View File

@@ -34,12 +34,96 @@ export function createEmptyRule(
};
}
export function prependEmptyRule(
rules: PolicyAccessRule[]
): PolicyAccessRule[] {
const newRule: EmptyRuleDraft = {
ruleId: Date.now(),
action: "ACCEPT",
match: "PATH",
value: "",
priority: 1,
enabled: true,
new: true
};
const bumpedRules = rules.map((rule) => {
if (rule.fromPolicy) {
return rule;
}
const bumped = { ...rule, priority: rule.priority + 1 };
if (rule.new) {
return bumped;
}
return { ...bumped, updated: true };
});
return [newRule, ...bumpedRules];
}
export function sortPolicyRulesByPriority<T extends { priority: number }>(
rules: T[]
): T[] {
return [...rules].sort((a, b) => a.priority - b.priority);
}
export function sortPolicyRulesForResourceOverlay<
T extends { priority: number; fromPolicy?: boolean }
>(rules: T[]): T[] {
const resourceRules = rules
.filter((rule) => !rule.fromPolicy)
.sort((a, b) => a.priority - b.priority);
const policyRules = rules
.filter((rule) => rule.fromPolicy)
.sort((a, b) => a.priority - b.priority);
return [...resourceRules, ...policyRules];
}
export function buildDisplayPrioritiesForResourceOverlay<
T extends { ruleId: number; priority: number; fromPolicy?: boolean }
>(rules: T[]): Map<number, number> {
const sorted = sortPolicyRulesForResourceOverlay(rules);
const displayPriorities = new Map<number, number>();
sorted.forEach((rule, index) => {
displayPriorities.set(rule.ruleId, index + 1);
});
return displayPriorities;
}
export function setResourceRuleDisplayPriority(
rules: PolicyAccessRule[],
ruleId: number,
displayPriority: number,
options?: { markUpdated?: boolean }
): PolicyAccessRule[] {
const sorted = sortPolicyRulesForResourceOverlay(rules);
const resourceRules = sorted.filter((rule) => !rule.fromPolicy);
const policyRules = sorted.filter((rule) => rule.fromPolicy);
const fromIndex = resourceRules.findIndex((rule) => rule.ruleId === ruleId);
if (fromIndex === -1) {
return rules;
}
const targetIndex = Math.max(
0,
Math.min(displayPriority - 1, resourceRules.length - 1)
);
const reorderedResource = reorderPolicyRules(
resourceRules,
fromIndex,
targetIndex,
options
);
return [...reorderedResource, ...policyRules];
}
export function reorderPolicyRules<
T extends { priority: number; new?: boolean; updated?: boolean }
>(
@@ -70,3 +154,40 @@ export function reorderPolicyRules<
return next;
});
}
export function reorderResourceOverlayRules<
T extends {
ruleId: number;
priority: number;
fromPolicy?: boolean;
new?: boolean;
updated?: boolean;
}
>(
rules: T[],
fromRuleId: number,
toRuleId: number,
options?: { markUpdated?: boolean }
): T[] {
const sorted = sortPolicyRulesForResourceOverlay(rules);
const resourceRules = sorted.filter((rule) => !rule.fromPolicy);
const policyRules = sorted.filter((rule) => rule.fromPolicy);
const fromIndex = resourceRules.findIndex(
(rule) => rule.ruleId === fromRuleId
);
const toIndex = resourceRules.findIndex((rule) => rule.ruleId === toRuleId);
if (fromIndex === -1 || toIndex === -1 || fromIndex === toIndex) {
return rules;
}
const reorderedResource = reorderPolicyRules(
resourceRules,
fromIndex,
toIndex,
options
);
return [...reorderedResource, ...policyRules];
}

View File

@@ -12,6 +12,23 @@ type TranslateFn = (
values?: Record<string, string | number>
) => string;
export const POLICY_RULE_MATCH_TYPES = [
"CIDR",
"IP",
"PATH",
"COUNTRY",
"ASN",
"REGION"
] as const;
export type PolicyRuleMatchType = (typeof POLICY_RULE_MATCH_TYPES)[number];
export function createPolicyRuleMatchSchema(t: TranslateFn) {
return z.enum(POLICY_RULE_MATCH_TYPES, {
error: t("rulesErrorInvalidMatchTypeDescription")
});
}
export type RuleValidationToast = {
title: string;
description: string;
@@ -78,7 +95,7 @@ export function createPolicyRuleSchema(t: TranslateFn) {
return z
.object({
action: z.enum(["ACCEPT", "DROP", "PASS"]),
match: z.string(),
match: createPolicyRuleMatchSchema(t),
value: z.string(),
priority: z.number().int(),
enabled: z.boolean()

View File

@@ -0,0 +1,218 @@
"use client";
import { orgQueries } from "@app/lib/queries";
import { cn } from "@app/lib/cn";
import type { ListResourcePoliciesResponse } from "@server/routers/resource/types";
import { useQuery } from "@tanstack/react-query";
import { CheckIcon, ChevronsUpDown } from "lucide-react";
import { useTranslations } from "next-intl";
import { useMemo, useState } from "react";
import { useDebounce } from "use-debounce";
import { Button } from "./ui/button";
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList
} from "./ui/command";
import { Popover, PopoverContent, PopoverTrigger } from "./ui/popover";
export type SelectedSharedPolicy = Pick<
ListResourcePoliciesResponse["policies"][number],
"resourcePolicyId" | "name"
>;
export type SharedPolicySelectorProps = {
orgId: string;
selectedPolicy: SelectedSharedPolicy | null;
onSelectPolicy: (policy: SelectedSharedPolicy | null) => void;
};
export function SharedPolicySelector({
orgId,
selectedPolicy,
onSelectPolicy
}: SharedPolicySelectorProps) {
const t = useTranslations();
const [policySearchQuery, setPolicySearchQuery] = useState("");
const [debouncedQuery] = useDebounce(policySearchQuery, 150);
const { data: policies = [] } = useQuery(
orgQueries.policies({
orgId,
query: debouncedQuery
})
);
const policiesShown = useMemo((): SelectedSharedPolicy[] => {
const allPolicies: SelectedSharedPolicy[] = policies.map((policy) => ({
resourcePolicyId: policy.resourcePolicyId,
name: policy.name
}));
if (
debouncedQuery.trim().length === 0 &&
selectedPolicy &&
!allPolicies.find(
(policy) =>
policy.resourcePolicyId === selectedPolicy.resourcePolicyId
)
) {
allPolicies.unshift(selectedPolicy);
}
return allPolicies;
}, [debouncedQuery, policies, selectedPolicy]);
return (
<Command shouldFilter={false}>
<CommandInput
placeholder={t("resourcePolicySearch")}
value={policySearchQuery}
onValueChange={setPolicySearchQuery}
/>
<CommandList>
<CommandEmpty>{t("resourcePolicyNotFound")}</CommandEmpty>
<CommandGroup>
<CommandItem
value={`none:${t("none")}`}
onSelect={() => onSelectPolicy(null)}
>
<CheckIcon
className={cn(
"mr-2 h-4 w-4",
selectedPolicy === null
? "opacity-100"
: "opacity-0"
)}
/>
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
<span className="truncate">{t("none")}</span>
<span className="text-muted-foreground text-xs leading-snug">
{t("sharedPolicyNoneDescription")}
</span>
</div>
</CommandItem>
{policiesShown.map((policy) => (
<CommandItem
key={policy.resourcePolicyId}
value={`${policy.resourcePolicyId}:${policy.name}`}
onSelect={() =>
onSelectPolicy({
resourcePolicyId: policy.resourcePolicyId,
name: policy.name
})
}
>
<CheckIcon
className={cn(
"mr-2 h-4 w-4",
policy.resourcePolicyId ===
selectedPolicy?.resourcePolicyId
? "opacity-100"
: "opacity-0"
)}
/>
<span className="min-w-0 flex-1 truncate">
{policy.name}
</span>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
);
}
export type SharedPolicySelectProps = {
orgId: string;
value: number | null;
onChange: (value: number | null) => void;
className?: string;
disabled?: boolean;
};
export function SharedPolicySelect({
orgId,
value,
onChange,
className,
disabled
}: SharedPolicySelectProps) {
const t = useTranslations();
const [open, setOpen] = useState(false);
const [selectedLabel, setSelectedLabel] = useState<{
resourcePolicyId: number;
name: string;
} | null>(null);
const resolvedLabel =
selectedLabel?.resourcePolicyId === value ? selectedLabel.name : null;
const { data: fetchedPolicy } = useQuery({
...orgQueries.resourcePolicy({
resourcePolicyId: value!
}),
enabled: value !== null && resolvedLabel === null
});
const selectedPolicy = useMemo((): SelectedSharedPolicy | null => {
if (value === null) {
return null;
}
return {
resourcePolicyId: value,
name: resolvedLabel ?? fetchedPolicy?.name ?? ""
};
}, [value, resolvedLabel, fetchedPolicy?.name]);
const triggerLabel =
value === null
? t("none")
: (resolvedLabel ??
fetchedPolicy?.name ??
t("resourcePolicySelect"));
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
type="button"
variant="outline"
role="combobox"
disabled={disabled}
className={cn(
"w-full justify-between font-normal md:w-1/2",
value !== null &&
!resolvedLabel &&
!fetchedPolicy?.name &&
"text-muted-foreground",
className
)}
>
<span className="truncate">{triggerLabel}</span>
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-[var(--radix-popover-trigger-width)] p-0">
<SharedPolicySelector
orgId={orgId}
selectedPolicy={selectedPolicy}
onSelectPolicy={(policy) => {
onChange(policy?.resourcePolicyId ?? null);
setSelectedLabel(
policy
? {
resourcePolicyId: policy.resourcePolicyId,
name: policy.name
}
: null
);
setOpen(false);
}}
/>
</PopoverContent>
</Popover>
);
}

View File

@@ -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: {

View File

@@ -105,13 +105,17 @@ function SelectLabel({
function SelectItem({
className,
children,
description,
...props
}: React.ComponentProps<typeof SelectPrimitive.Item>) {
}: React.ComponentProps<typeof SelectPrimitive.Item> & {
description?: React.ReactNode;
}) {
return (
<SelectPrimitive.Item
data-slot="select-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
"focus:bg-accent focus:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex w-full cursor-default gap-2 rounded-sm pr-8 pl-2 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
description ? "items-start py-2" : "items-center py-1.5",
className
)}
{...props}
@@ -121,7 +125,18 @@ function SelectItem({
<CheckIcon className="size-4" />
</SelectPrimitive.ItemIndicator>
</span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
{description ? (
<div className="flex flex-col gap-0.5 pr-2">
<SelectPrimitive.ItemText>
{children}
</SelectPrimitive.ItemText>
<span className="text-muted-foreground text-xs leading-snug">
{description}
</span>
</div>
) : (
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
)}
</SelectPrimitive.Item>
);
}

View File

@@ -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<GetResourcePolicyResponse>
>(`/resource-policy/${resourcePolicyId}`, { signal });
return res.data.data;
}
})
};