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...", "resourcesSearch": "Search resources...",
"resourceAdd": "Add Resource", "resourceAdd": "Add Resource",
"resourceErrorDelte": "Error deleting 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", "resourcePoliciesTitle": "Manage Public Resource Policies",
"resourcePoliciesAttachedResourcesColumnTitle": "Resources", "resourcePoliciesAttachedResourcesColumnTitle": "Resources",
"resourcePoliciesAttachedResources": "{count} resource(s)", "resourcePoliciesAttachedResources": "{count} resource(s)",
@@ -774,6 +776,7 @@
"rulesErrorDuplicatePriorityDescription": "Each rule must have a unique priority number.", "rulesErrorDuplicatePriorityDescription": "Each rule must have a unique priority number.",
"rulesErrorValidation": "Invalid rules", "rulesErrorValidation": "Invalid rules",
"rulesErrorValidationRuleDescription": "Rule {ruleNumber}: {message}", "rulesErrorValidationRuleDescription": "Rule {ruleNumber}: {message}",
"rulesErrorInvalidMatchTypeDescription": "Select a valid match type (path, IP, CIDR, country, region, or ASN).",
"rulesErrorValueRequired": "Enter a value for this rule.", "rulesErrorValueRequired": "Enter a value for this rule.",
"rulesErrorInvalidCountry": "Invalid country", "rulesErrorInvalidCountry": "Invalid country",
"rulesErrorInvalidCountryDescription": "Select a valid country.", "rulesErrorInvalidCountryDescription": "Select a valid country.",
@@ -968,10 +971,16 @@
"resourceRoleDescription": "Admins can always access this resource.", "resourceRoleDescription": "Admins can always access this resource.",
"resourcePolicySelectTitle": "Resource Access Policy", "resourcePolicySelectTitle": "Resource Access Policy",
"resourcePolicySelectDescription": "Select the resource policy type for authentication", "resourcePolicySelectDescription": "Select the resource policy type for authentication",
"resourcePolicyTypeLabel": "Policy type",
"resourcePolicyLabel": "Resource policy",
"resourcePolicyInline": "Inline Resource Policy", "resourcePolicyInline": "Inline Resource Policy",
"resourcePolicyInlineDescription": "Access Policy scoped to only this resource", "resourcePolicyInlineDescription": "Access Policy scoped to only this resource",
"resourcePolicyShared": "Shared Resource Policy", "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", "resourceUsersRoles": "Access Controls",
"resourceUsersRolesDescription": "Configure which users and roles can visit this resource", "resourceUsersRolesDescription": "Configure which users and roles can visit this resource",
"resourceUsersRolesSubmit": "Save Access Controls", "resourceUsersRolesSubmit": "Save Access Controls",

View File

@@ -1,5 +1,7 @@
import z from "zod"; import z from "zod";
import ipaddr from "ipaddr.js"; import ipaddr from "ipaddr.js";
import { COUNTRIES } from "@server/db/countries";
import { isValidRegionId } from "@server/db/regions";
export function isValidCIDR(cidr: string): boolean { export function isValidCIDR(cidr: string): boolean {
return ( return (
@@ -67,6 +69,45 @@ export function isValidUrlGlobPattern(pattern: string): boolean {
return true; 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) { 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 if (!url) return true; // the link is optional in the schema so if it's empty it's valid
var pattern = new RegExp( var pattern = new RegExp(

View File

@@ -33,9 +33,8 @@ import {
import { getUniqueResourcePolicyName } from "@server/db/names"; import { getUniqueResourcePolicyName } from "@server/db/names";
import response from "@server/lib/response"; import response from "@server/lib/response";
import { import {
isValidCIDR, getResourceRuleValueValidationError,
isValidIP, RESOURCE_RULE_MATCH_TYPES
isValidUrlGlobPattern
} from "@server/lib/validators"; } from "@server/lib/validators";
import logger from "@server/logger"; import logger from "@server/logger";
import { OpenAPITags, registry } from "@server/openApi"; import { OpenAPITags, registry } from "@server/openApi";
@@ -56,9 +55,9 @@ const ruleSchema = z.strictObject({
enum: ["ACCEPT", "DROP", "PASS"], enum: ["ACCEPT", "DROP", "PASS"],
description: "rule action" description: "rule action"
}), }),
match: z.enum(["CIDR", "IP", "PATH"]).openapi({ match: z.enum(RESOURCE_RULE_MATCH_TYPES).openapi({
type: "string", type: "string",
enum: ["CIDR", "IP", "PATH"], enum: [...RESOURCE_RULE_MATCH_TYPES],
description: "rule match" description: "rule match"
}), }),
value: z.string().min(1), value: z.string().min(1),
@@ -261,26 +260,13 @@ export async function createResourcePolicy(
const niceId = await getUniqueResourcePolicyName(orgId); const niceId = await getUniqueResourcePolicyName(orgId);
for (const rule of rules) { for (const rule of rules) {
if (rule.match === "CIDR" && !isValidCIDR(rule.value)) { const validationError = getResourceRuleValueValidationError(
rule.match,
rule.value
);
if (validationError) {
return next( return next(
createHttpError( createHttpError(HttpCode.BAD_REQUEST, validationError)
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"
)
); );
} }
} }

View File

@@ -666,6 +666,13 @@ authenticated.get(
resource.getResourcePolicies resource.getResourcePolicies
); );
authenticated.get(
"/resource-policy/:resourcePolicyId",
verifyResourcePolicyAccess,
verifyUserHasAction(ActionsEnum.getResourcePolicy),
policy.getResourcePolicy
);
authenticated.put( authenticated.put(
"/resource-policy/:resourcePolicyId", "/resource-policy/:resourcePolicyId",
verifyResourcePolicyAccess, verifyResourcePolicyAccess,

View File

@@ -8,9 +8,8 @@ import createHttpError from "http-errors";
import logger from "@server/logger"; import logger from "@server/logger";
import { fromError } from "zod-validation-error"; import { fromError } from "zod-validation-error";
import { import {
isValidCIDR, getResourceRuleValueValidationError,
isValidIP, RESOURCE_RULE_MATCH_TYPES
isValidUrlGlobPattern
} from "@server/lib/validators"; } from "@server/lib/validators";
import { OpenAPITags, registry } from "@server/openApi"; import { OpenAPITags, registry } from "@server/openApi";
@@ -20,9 +19,9 @@ const ruleSchema = z.strictObject({
enum: ["ACCEPT", "DROP", "PASS"], enum: ["ACCEPT", "DROP", "PASS"],
description: "rule action" description: "rule action"
}), }),
match: z.enum(["CIDR", "IP", "PATH"]).openapi({ match: z.enum(RESOURCE_RULE_MATCH_TYPES).openapi({
type: "string", type: "string",
enum: ["CIDR", "IP", "PATH"], enum: [...RESOURCE_RULE_MATCH_TYPES],
description: "rule match" description: "rule match"
}), }),
value: z.string().min(1), value: z.string().min(1),
@@ -105,26 +104,13 @@ export async function setResourcePolicyRules(
} }
for (const rule of rules) { for (const rule of rules) {
if (rule.match === "CIDR" && !isValidCIDR(rule.value)) { const validationError = getResourceRuleValueValidationError(
rule.match,
rule.value
);
if (validationError) {
return next( return next(
createHttpError( createHttpError(HttpCode.BAD_REQUEST, validationError)
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"
)
); );
} }
} }

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"; import { redirect } from "next/navigation";
export interface EditPolicyPageProps { type EditPolicyPageProps = {
params: Promise<{ niceId: string; orgId: string }>; params: Promise<{ niceId: string; orgId: string }>;
} };
export default async function EditPolicyPage(props: EditPolicyPageProps) { export default async function EditPolicyPage(props: EditPolicyPageProps) {
const params = await props.params; const params = await props.params;
const t = await getTranslations(); redirect(
`/${params.orgId}/settings/policies/resources/public/${params.niceId}/general`
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>
</>
); );
} }

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 { ResourcePoliciesTable } from "@app/components/ResourcePoliciesTable";
import SettingsSectionTitle from "@app/components/SettingsSectionTitle"; import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
import { internal } from "@app/lib/api"; import { internal } from "@app/lib/api";
@@ -54,6 +55,8 @@ export default async function ResourcePoliciesPage(
description={t("resourcePoliciesDescription")} description={t("resourcePoliciesDescription")}
/> />
<ResourcePoliciesBanner />
<ResourcePoliciesTable <ResourcePoliciesTable
policies={policies} policies={policies}
orgId={params.orgId} orgId={params.orgId}

View File

@@ -21,6 +21,7 @@ import {
SettingsSectionHeader, SettingsSectionHeader,
SettingsSectionTitle SettingsSectionTitle
} from "@app/components/Settings"; } from "@app/components/Settings";
import { DataTableEmptyState } from "@app/components/ui/data-table-empty-state";
import { import {
Table, Table,
TableBody, TableBody,
@@ -710,6 +711,15 @@ export function ProxyResourceTargetsForm({
const [, formAction, isSubmitting] = useActionState(saveTargets, null); 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() { async function saveTargets() {
if (!resource) return; if (!resource) return;
@@ -823,143 +833,104 @@ export function ProxyResourceTargetsForm({
</SettingsSectionDescription> </SettingsSectionDescription>
</SettingsSectionHeader> </SettingsSectionHeader>
<SettingsSectionBody> <SettingsSectionBody>
{targets.length > 0 ? ( <div className="overflow-x-auto">
<> <Table>
<div className="overflow-x-auto"> <TableHeader>
<Table> {table.getHeaderGroups().map((headerGroup) => (
<TableHeader> <TableRow key={headerGroup.id}>
{table {headerGroup.headers.map((header) => {
.getHeaderGroups() const isActionsColumn =
.map((headerGroup) => ( header.column.id === "actions";
<TableRow key={headerGroup.id}> const isSiteColumn =
{headerGroup.headers.map( header.column.id === "site";
(header) => { return (
const isActionsColumn = <TableHead
header.column key={header.id}
.id === className={
"actions"; isActionsColumn
const isSiteColumn = ? "sticky right-0 z-10 w-auto min-w-fit bg-card"
header.column : isSiteColumn
.id === ? "w-45"
"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"
> >
{t("targetNoOne")} {header.isPlaceholder
</TableCell> ? null
</TableRow> : flexRender(
)} header.column
</TableBody> .columnDef
</Table> .header,
</div> header.getContext()
<div className="flex items-center justify-between mb-4"> )}
<div className="flex items-center justify-between w-full gap-2"> </TableHead>
<Button );
onClick={addNewTarget} })}
variant="outline" </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("advancedMode")}
{t("addTarget")} </label>
</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>
</div> </div>
</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> </div>
)} )}
{build === "saas" && {build === "saas" &&

View File

@@ -1,320 +1,7 @@
"use client"; "use client";
import { EditPolicyForm } from "@app/components/resource-policy/EditPolicyForm"; import { ResourcePolicyEditForm } from "@app/components/resource-policy/ResourcePolicyEditForm";
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">;
export default function ResourceAuthenticationPage() { export default function ResourceAuthenticationPage() {
const { org } = useOrgContext(); return <ResourcePolicyEditForm section="authentication" />;
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>
</>
);
} }

View File

@@ -36,10 +36,14 @@ import { AlertCircle } from "lucide-react";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import { useParams, useRouter } from "next/navigation"; import { useParams, useRouter } from "next/navigation";
import { toASCII, toUnicode } from "punycode"; 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 { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod"; import { zodResolver } from "@hookform/resolvers/zod";
import z from "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 { Alert, AlertDescription } from "@app/components/ui/alert";
import { RadioGroup, RadioGroupItem } from "@app/components/ui/radio-group"; import { RadioGroup, RadioGroupItem } from "@app/components/ui/radio-group";
import { import {
@@ -434,16 +438,30 @@ function MaintenanceSectionForm({
export default function GeneralForm() { export default function GeneralForm() {
const params = useParams(); const params = useParams();
const { org } = useOrgContext();
const { resource, updateResource } = useResourceContext(); const { resource, updateResource } = useResourceContext();
const router = useRouter(); const router = useRouter();
const t = useTranslations(); const t = useTranslations();
const { env } = useEnvContext(); const { env } = useEnvContext();
const { isPaidUser } = usePaidStatus();
const orgId = params.orgId; const orgId = params.orgId;
const api = createApiClient({ env }); 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( const [resourceFullDomain, setResourceFullDomain] = useState(
`${resource.ssl ? "https" : "http"}://${toUnicode(resource.fullDomain || "")}` `${resource.ssl ? "https" : "http"}://${toUnicode(resource.fullDomain || "")}`
); );
@@ -506,6 +524,12 @@ export default function GeneralForm() {
const data = form.getValues(); const data = form.getValues();
let resourcePolicyId: number | null | undefined;
if (showResourcePolicy) {
resourcePolicyId = selectedSharedPolicyId;
}
const res = await api const res = await api
.post<AxiosResponse<UpdateResourceResponse>>( .post<AxiosResponse<UpdateResourceResponse>>(
`resource/${resource?.resourceId}`, `resource/${resource?.resourceId}`,
@@ -519,7 +543,8 @@ export default function GeneralForm() {
) )
: undefined, : undefined,
domainId: data.domainId, domainId: data.domainId,
proxyPort: data.proxyPort proxyPort: data.proxyPort,
...(resourcePolicyId !== undefined && { resourcePolicyId })
} }
) )
.catch((e) => { .catch((e) => {
@@ -543,7 +568,10 @@ export default function GeneralForm() {
subdomain: data.subdomain, subdomain: data.subdomain,
fullDomain: updated.fullDomain, fullDomain: updated.fullDomain,
proxyPort: data.proxyPort, proxyPort: data.proxyPort,
domainId: data.domainId domainId: data.domainId,
...(resourcePolicyId !== undefined && {
resourcePolicyId
})
}); });
toast({ toast({
@@ -584,7 +612,7 @@ export default function GeneralForm() {
</SettingsSectionHeader> </SettingsSectionHeader>
<SettingsSectionBody> <SettingsSectionBody>
<SettingsSectionForm> <SettingsSectionForm variant="half">
<Form {...form}> <Form {...form}>
<form <form
action={formAction} action={formAction}
@@ -771,6 +799,24 @@ export default function GeneralForm() {
</div> </div>
</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>
</Form> </Form>
</SettingsSectionForm> </SettingsSectionForm>

View File

@@ -92,10 +92,16 @@ export default async function ResourceLayout(props: ResourceLayoutProps) {
]; ];
if (["http", "ssh", "rdp", "vnc"].includes(resource.mode)) { if (["http", "ssh", "rdp", "vnc"].includes(resource.mode)) {
navItems.push({ navItems.push(
title: t("authentication"), {
href: `/{orgId}/settings/resources/public/{niceId}/authentication` title: t("authentication"),
}); href: `/{orgId}/settings/resources/public/{niceId}/authentication`
},
{
title: t("policyAccessRulesTitle"),
href: `/{orgId}/settings/resources/public/{niceId}/rules`
}
);
} }
return ( 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); --accent-foreground: oklch(0.21 0.006 285.885);
--destructive: oklch(0.577 0.245 27.325); --destructive: oklch(0.577 0.245 27.325);
--destructive-foreground: oklch(0.985 0 0); --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); --input: oklch(0.88 0.004 286.32);
--ring: oklch(0.705 0.213 47.604); --ring: oklch(0.705 0.213 47.604);
--chart-1: oklch(0.646 0.222 41.116); --chart-1: oklch(0.646 0.222 41.116);
@@ -57,7 +57,7 @@
--accent-foreground: oklch(0.985 0 0); --accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.5382 0.1949 22.216); --destructive: oklch(0.5382 0.1949 22.216);
--destructive-foreground: oklch(0.985 0 0); --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%); --input: oklch(1 0 0 / 18%);
--ring: oklch(0.646 0.222 41.116); --ring: oklch(0.646 0.222 41.116);
--chart-1: oklch(0.488 0.243 264.376); --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")} searchPlaceholder={t("resourcePoliciesSearch")}
pagination={pagination} pagination={pagination}
rowCount={rowCount} rowCount={rowCount}
searchQuery={searchParams.get("query")?.toString()}
onSearch={handleSearchChange} onSearch={handleSearchChange}
onPaginationChange={handlePaginationChange} onPaginationChange={handlePaginationChange}
onAdd={() => onAdd={() =>

View File

@@ -147,7 +147,7 @@ export function CreatePolicyForm({}: CreatePolicyFormProps) {
if (res && res.status === 201) { if (res && res.status === 201) {
const niceId = res.data.data.niceId; const niceId = res.data.data.niceId;
router.push( router.push(
`/${org.org.orgId}/settings/policies/resources/public/${niceId}` `/${org.org.orgId}/settings/policies/resources/public/${niceId}/general`
); );
toast({ toast({
title: t("success"), title: t("success"),
@@ -227,7 +227,7 @@ export function CreatePolicyForm({}: CreatePolicyFormProps) {
</SettingsSectionDescription> </SettingsSectionDescription>
</SettingsSectionHeader> </SettingsSectionHeader>
<SettingsSectionBody> <SettingsSectionBody>
<SettingsSectionForm> <SettingsSectionForm variant="half">
<FormField <FormField
control={form.control} control={form.control}
name="name" name="name"
@@ -237,12 +237,7 @@ export function CreatePolicyForm({}: CreatePolicyFormProps) {
{t("name")} {t("name")}
</FormLabel> </FormLabel>
<FormControl> <FormControl>
<Input <Input {...field} />
{...field}
placeholder={t(
"resourcePolicyNamePlaceholder"
)}
/>
</FormControl> </FormControl>
<FormMessage /> <FormMessage />
</FormItem> </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 { build } from "@server/build";
import { tierMatrix } from "@server/lib/billing/tierMatrix"; import { tierMatrix } from "@server/lib/billing/tierMatrix";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { useTranslations } from "next-intl";
import { useMemo } from "react"; import { useMemo } from "react";
import { HorizontalTabs } from "@app/components/HorizontalTabs";
import { EditPolicyNameSectionForm } from "./EditPolicyNameSectionForm"; import { EditPolicyNameSectionForm } from "./EditPolicyNameSectionForm";
import { PolicyAuthStackSection } from "./PolicyAuthStackSection"; import { PolicyAuthStackSection } from "./PolicyAuthStackSection";
import { PolicyAccessRulesSection } from "./PolicyAccessRulesSection"; import { PolicyAccessRulesSection } from "./PolicyAccessRulesSection";
export type EditPolicyFormSection = "general" | "authentication" | "rules";
export type EditPolicyFormProps = { export type EditPolicyFormProps = {
hidePolicyNameForm?: boolean; hidePolicyNameForm?: boolean;
readonly?: boolean; readonly?: boolean;
resourceId?: number; resourceId?: number;
section?: EditPolicyFormSection;
}; };
export function EditPolicyForm({ export function EditPolicyForm({
hidePolicyNameForm, hidePolicyNameForm,
readonly, readonly,
resourceId resourceId,
section
}: EditPolicyFormProps) { }: EditPolicyFormProps) {
const t = useTranslations();
const { org } = useOrgContext(); const { org } = useOrgContext();
const { env } = useEnvContext(); const { env } = useEnvContext();
const { isPaidUser } = usePaidStatus(); const { isPaidUser } = usePaidStatus();
@@ -37,7 +38,6 @@ export function EditPolicyForm({
// In overlay mode (resourceId provided), policy-level sections are locked. // In overlay mode (resourceId provided), policy-level sections are locked.
// Rules and users/roles sections handle their own hybrid logic via resourceId. // Rules and users/roles sections handle their own hybrid logic via resourceId.
const isOverlay = resourceId !== undefined; const isOverlay = resourceId !== undefined;
const showTabs = !hidePolicyNameForm && !isOverlay;
const isMaxmindAvailable = !!( const isMaxmindAvailable = !!(
env.server.maxmind_db_path && env.server.maxmind_db_path.length > 0 env.server.maxmind_db_path && env.server.maxmind_db_path.length > 0
@@ -92,22 +92,16 @@ export function EditPolicyForm({
/> />
); );
if (showTabs) { if (section === "general") {
return ( return <EditPolicyNameSectionForm readonly={readonly} />;
<HorizontalTabs }
clientSide
defaultTab={0} if (section === "authentication") {
items={[ return authSection;
{ title: t("general"), href: "#" }, }
{ title: t("authentication"), href: "#" },
{ title: t("policyAccessRulesTitle"), href: "#" } if (section === "rules") {
]} return rulesSection;
>
<EditPolicyNameSectionForm readonly={readonly} />
{authSection}
{rulesSection}
</HorizontalTabs>
);
} }
return ( return (

View File

@@ -109,7 +109,7 @@ export function EditPolicyNameSectionForm({
if (payload.niceId && payload.niceId !== policy.niceId) { if (payload.niceId && payload.niceId !== policy.niceId) {
router.replace( 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, useMemo,
useRef, useRef,
useState, useState,
useTransition useTransition,
type ReactNode
} from "react"; } from "react";
import { UseFormReturn, useForm, useWatch } from "react-hook-form"; import { UseFormReturn, useForm, useWatch } from "react-hook-form";
import { useResourcePolicyContext } from "@app/providers/ResourcePolicyProvider"; import { useResourcePolicyContext } from "@app/providers/ResourcePolicyProvider";
@@ -38,11 +39,12 @@ import { resourceQueries } from "@app/lib/queries";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import type { AxiosResponse } from "axios"; import type { AxiosResponse } from "axios";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { CreatePolicyRulesSectionForm } from "./CreatePolicyRulesSectionForm";
import { PolicyAccessRulesIntro } from "./PolicyAccessRulesIntro"; import { PolicyAccessRulesIntro } from "./PolicyAccessRulesIntro";
import { PolicyAccessRulesTable } from "./PolicyAccessRulesTable"; import { PolicyAccessRulesTable } from "./PolicyAccessRulesTable";
import { SharedPolicyResourceNotice } from "./SharedPolicyResourceNotice";
import { import {
createEmptyRule, createEmptyRule,
prependEmptyRule,
type PolicyAccessRule type PolicyAccessRule
} from "./policy-access-rule-utils"; } from "./policy-access-rule-utils";
@@ -74,6 +76,143 @@ export function PolicyAccessRulesSection(props: PolicyAccessRulesSectionProps) {
return <PolicyAccessRulesSectionEdit {...props} />; 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({ function PolicyAccessRulesSectionEdit({
isMaxmindAvailable, isMaxmindAvailable,
isMaxmindAsnAvailable, isMaxmindAsnAvailable,
@@ -119,6 +258,8 @@ function PolicyAccessRulesSectionEdit({
policy.rules.map((r) => ({ ...r, fromPolicy: isResourceOverlay })) policy.rules.map((r) => ({ ...r, fromPolicy: isResourceOverlay }))
); );
const { updateRulesState } = usePolicyAccessRulesFormSync(form);
useEffect(() => { useEffect(() => {
if (!isResourceOverlay || resourceRulesInitialized) return; if (!isResourceOverlay || resourceRulesInitialized) return;
if (!resourceRulesData) return; if (!resourceRulesData) return;
@@ -148,30 +289,13 @@ function PolicyAccessRulesSectionEdit({
policy.rules policy.rules
]); ]);
const syncFormRules = useCallback( const handleRulesChange = useCallback(
(updatedRules: PolicyAccessRule[]) => { (updatedRules: PolicyAccessRule[]) => {
form.setValue( updateRulesState(setRules, updatedRules);
"rules",
updatedRules.map(
({ action, match, value, priority, enabled }) => ({
action,
match,
value,
priority,
enabled
})
)
);
}, },
[form] [updateRulesState]
); );
const addEmptyRule = useCallback(() => {
const updatedRules = [...rules, createEmptyRule(rules)];
setRules(updatedRules);
syncFormRules(updatedRules);
}, [rules, syncFormRules]);
const removeRule = useCallback( const removeRule = useCallback(
function removeRule(ruleId: number) { function removeRule(ruleId: number) {
const rule = rules.find((r) => r.ruleId === ruleId); const rule = rules.find((r) => r.ruleId === ruleId);
@@ -179,32 +303,22 @@ function PolicyAccessRulesSectionEdit({
if (isResourceOverlay && !rule.new) { if (isResourceOverlay && !rule.new) {
deletedResourceRuleIdsRef.current.add(ruleId); deletedResourceRuleIdsRef.current.add(ruleId);
} }
const updatedRules = rules.filter((rule) => rule.ruleId !== ruleId); handleRulesChange(rules.filter((rule) => rule.ruleId !== ruleId));
setRules(updatedRules);
syncFormRules(updatedRules);
}, },
[rules, syncFormRules, isResourceOverlay] [rules, handleRulesChange, isResourceOverlay]
); );
const updateRule = useCallback( const updateRule = useCallback(
function updateRule(ruleId: number, data: Partial<PolicyAccessRule>) { function updateRule(ruleId: number, data: Partial<PolicyAccessRule>) {
const updatedRules = rules.map((rule) => handleRulesChange(
rule.ruleId === ruleId rules.map((rule) =>
? { ...rule, ...data, updated: true } rule.ruleId === ruleId
: rule ? { ...rule, ...data, updated: true }
: rule
)
); );
setRules(updatedRules);
syncFormRules(updatedRules);
}, },
[rules, syncFormRules] [rules, handleRulesChange]
);
const handleRulesChange = useCallback(
(updatedRules: PolicyAccessRule[]) => {
setRules(updatedRules);
syncFormRules(updatedRules);
},
[syncFormRules]
); );
const [isPending, startTransition] = useTransition(); const [isPending, startTransition] = useTransition();
@@ -213,7 +327,10 @@ function PolicyAccessRulesSectionEdit({
if (readonly) return; if (readonly) return;
const applyRules = form.getValues("applyRules") ?? false; 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, value, priority, enabled }) => ({
action, action,
match, 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 ( return (
<SettingsSection> <PolicyAccessRulesSectionLayout
<SettingsSectionHeader> rulesEnabled={Boolean(rulesEnabled)}
<SettingsSectionTitle> onRulesEnabledChange={(val) => {
{t("policyAccessRulesTitle")} form.setValue("applyRules", val);
</SettingsSectionTitle> }}
<SettingsSectionDescription> disableToggle={readonly || isResourceOverlay}
{t("rulesResourceDescription")} rules={rules}
</SettingsSectionDescription> onRulesChange={handleRulesChange}
</SettingsSectionHeader> updateRule={updateRule}
<SettingsSectionBody> removeRule={removeRule}
<div className="space-y-6"> readonly={readonly}
<PolicyAccessRulesIntro isMaxmindAvailable={isMaxmindAvailable}
rulesEnabled={Boolean(rulesEnabled)} isMaxmindAsnAvailable={isMaxmindAsnAvailable}
onRulesEnabledChange={(val) => { resourceOverlayMode={isResourceOverlay}
form.setValue("applyRules", val); footer={
}} <SettingsSectionFooter>
disableToggle={readonly || isResourceOverlay} <Button
/> onClick={() => startTransition(() => saveRules())}
loading={isPending}
{rulesEnabled && ( disabled={readonly || isPending}
<> >
<PolicyAccessRulesTable {t("saveSettings")}
rules={rules} </Button>
onRulesChange={handleRulesChange} </SettingsSectionFooter>
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>
); );
} }
function PolicyAccessRulesSectionCreate({ function PolicyAccessRulesSectionCreate({
form, form: parentForm,
isMaxmindAvailable, isMaxmindAvailable,
isMaxmindAsnAvailable isMaxmindAsnAvailable
}: PolicyAccessRulesSectionCreateProps) { }: 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 ( return (
<CreatePolicyRulesSectionForm <PolicyAccessRulesSectionLayout
form={form} rulesEnabled={Boolean(rulesEnabled)}
onRulesEnabledChange={(val) => {
form.setValue("applyRules", val);
}}
rules={rules}
onRulesChange={handleRulesChange}
updateRule={updateRule}
removeRule={removeRule}
isMaxmindAvailable={isMaxmindAvailable} isMaxmindAvailable={isMaxmindAvailable}
isMaxmindAsnAvailable={isMaxmindAsnAvailable} isMaxmindAsnAvailable={isMaxmindAsnAvailable}
/> />

View File

@@ -66,8 +66,12 @@ import {
validatePolicyRuleValue validatePolicyRuleValue
} from "./policy-access-rule-validation"; } from "./policy-access-rule-validation";
import { import {
buildDisplayPrioritiesForResourceOverlay,
reorderPolicyRules, reorderPolicyRules,
reorderResourceOverlayRules,
setResourceRuleDisplayPriority,
sortPolicyRulesByPriority, sortPolicyRulesByPriority,
sortPolicyRulesForResourceOverlay,
type PolicyAccessRule type PolicyAccessRule
} from "./policy-access-rule-utils"; } from "./policy-access-rule-utils";
@@ -82,6 +86,7 @@ export type PolicyAccessRulesTableProps = {
readonly?: boolean; readonly?: boolean;
includeRegionMatch?: boolean; includeRegionMatch?: boolean;
markUpdatedOnReorder?: boolean; markUpdatedOnReorder?: boolean;
resourceOverlayMode?: boolean;
isRuleDraggable?: (rule: PolicyAccessRule) => boolean; isRuleDraggable?: (rule: PolicyAccessRule) => boolean;
isRuleLocked?: (rule: PolicyAccessRule) => boolean; isRuleLocked?: (rule: PolicyAccessRule) => boolean;
}; };
@@ -97,7 +102,7 @@ function getColumnClassName(columnId: string) {
return "w-24 max-w-24"; return "w-24 max-w-24";
} }
if (columnId === "action") { if (columnId === "action") {
return "w-40 max-w-40"; return "w-42 max-w-42";
} }
if (columnId === "match") { if (columnId === "match") {
return "w-36 max-w-36"; return "w-36 max-w-36";
@@ -116,6 +121,7 @@ export function PolicyAccessRulesTable({
readonly = false, readonly = false,
includeRegionMatch = false, includeRegionMatch = false,
markUpdatedOnReorder = false, markUpdatedOnReorder = false,
resourceOverlayMode = false,
isRuleDraggable: isRuleDraggableProp, isRuleDraggable: isRuleDraggableProp,
isRuleLocked: isRuleLockedProp isRuleLocked: isRuleLockedProp
}: PolicyAccessRulesTableProps) { }: PolicyAccessRulesTableProps) {
@@ -140,12 +146,37 @@ export function PolicyAccessRulesTable({
); );
const sortedRules = useMemo( 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] [rules]
); );
const handleReorder = useCallback( const handleReorder = useCallback(
(fromRuleId: number, toRuleId: number) => { (fromRuleId: number, toRuleId: number) => {
if (resourceOverlayMode) {
onRulesChange(
reorderResourceOverlayRules(rules, fromRuleId, toRuleId, {
markUpdated: markUpdatedOnReorder
})
);
return;
}
const fromIndex = sortedRules.findIndex( const fromIndex = sortedRules.findIndex(
(rule) => rule.ruleId === fromRuleId (rule) => rule.ruleId === fromRuleId
); );
@@ -164,7 +195,13 @@ export function PolicyAccessRulesTable({
); );
onRulesChange(reordered); onRulesChange(reordered);
}, },
[sortedRules, onRulesChange, markUpdatedOnReorder] [
rules,
sortedRules,
onRulesChange,
markUpdatedOnReorder,
resourceOverlayMode
]
); );
const handleDragStart = useCallback((ruleId: number, e: DragEvent) => { const handleDragStart = useCallback((ruleId: number, e: DragEvent) => {
@@ -228,60 +265,132 @@ export function PolicyAccessRulesTable({
maxSize: 96, maxSize: 96,
header: ({ column }) => ( header: ({ column }) => (
<div className="p-3"> <div className="p-3">
<Button {resourceOverlayMode ? (
variant="ghost" <span className="font-medium text-muted-foreground">
className="h-auto p-0 font-medium text-muted-foreground hover:bg-transparent" {t("rulesPriority")}
onClick={() => </span>
column.toggleSorting( ) : (
column.getIsSorted() === "asc" <Button
) variant="ghost"
} className="h-auto p-0 font-medium text-muted-foreground hover:bg-transparent"
> onClick={() =>
{t("rulesPriority")} column.toggleSorting(
<ArrowUpDown className="ml-1 h-3 w-3" /> column.getIsSorted() === "asc"
</Button> )
}
>
{t("rulesPriority")}
<ArrowUpDown className="ml-1 h-3 w-3" />
</Button>
)}
</div> </div>
), ),
cell: ({ row }) => ( cell: ({ row }) => {
<Input const displayPriority = resourceOverlayMode
defaultValue={row.original.priority} ? (displayPriorities?.get(row.original.ruleId) ??
className="w-full min-w-0" row.original.priority)
type="number" : row.original.priority;
disabled={readonly || isRuleLocked(row.original)}
onClick={(e) => e.currentTarget.focus()} return (
onBlur={(e) => { <Input
const validated = validatePolicyRulePriority( key={`${row.original.ruleId}-${displayPriority}`}
t, defaultValue={displayPriority}
e.target.value className="w-full min-w-0"
); type="number"
if (!validated.success) { disabled={readonly || isRuleLocked(row.original)}
toast({ onClick={(e) => e.currentTarget.focus()}
variant: "destructive", onBlur={(e) => {
...validated.toast 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", accessorKey: "action",
@@ -683,13 +792,7 @@ export function PolicyAccessRulesTable({
cell: ({ row }) => ( cell: ({ row }) => (
<div className="flex items-center justify-end space-x-2"> <div className="flex items-center justify-end space-x-2">
{isRuleLocked(row.original) ? ( {isRuleLocked(row.original) ? (
<Button <LockIcon className="h-4 w-4 text-muted-foreground" />
variant="outline"
disabled
className="cursor-not-allowed"
>
<LockIcon className="h-4 w-4" />
</Button>
) : ( ) : (
<Button <Button
variant="outline" variant="outline"
@@ -711,9 +814,14 @@ export function PolicyAccessRulesTable({
isMaxmindAsnAvailable, isMaxmindAsnAvailable,
includeRegionMatch, includeRegionMatch,
updateRule, updateRule,
onRulesChange,
removeRule, removeRule,
readonly, readonly,
rules, rules,
resourceOverlayMode,
displayPriorities,
resourceRuleCount,
markUpdatedOnReorder,
isRuleDraggable, isRuleDraggable,
isRuleLocked, isRuleLocked,
handleDragStart, handleDragStart,
@@ -775,7 +883,8 @@ export function PolicyAccessRulesTable({
e.preventDefault(); e.preventDefault();
if ( if (
draggedRuleId !== null && draggedRuleId !== null &&
draggedRuleId !== rule.ruleId draggedRuleId !== rule.ruleId &&
isRuleDraggable(rule)
) { ) {
handleReorder( handleReorder(
draggedRuleId, draggedRuleId,
@@ -789,7 +898,7 @@ export function PolicyAccessRulesTable({
draggedRuleId === rule.ruleId && draggedRuleId === rule.ruleId &&
"opacity-50", "opacity-50",
dragOverRuleId === rule.ruleId && dragOverRuleId === rule.ruleId &&
"border-t-2 border-primary" "border-t border-primary"
)} )}
> >
{row.getVisibleCells().map((cell) => { {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 flex-1 min-w-0 flex-col gap-0.5">
<div className="flex items-center gap-2"> <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> <span className="text-sm font-medium">{title}</span>
</div> </div>
<p className="truncate text-sm text-muted-foreground"> <p className="truncate text-sm text-muted-foreground">

View File

@@ -48,20 +48,43 @@ import {
getPasscodeSummary, getPasscodeSummary,
getPincodeSummary getPincodeSummary
} from "./policy-auth-summaries"; } from "./policy-auth-summaries";
import { SharedPolicyResourceNotice } from "./SharedPolicyResourceNotice";
import z from "zod";
type OverlaySelectedRole = SelectedRole & { isAdmin: boolean }; type OverlaySelectedRole = SelectedRole & { isAdmin: boolean };
const authStackSchema = createPolicySchema.pick({ // Edit mode keeps placeholder values for configured methods; only validate on save when changed.
sso: true, const authStackEditSchema = createPolicySchema
skipToIdpId: true, .pick({
roles: true, sso: true,
users: true, skipToIdpId: true,
password: true, roles: true,
pincode: true, users: true,
headerAuth: true, emailWhitelistEnabled: true,
emailWhitelistEnabled: true, emails: 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 = { export type PolicyAuthStackSectionEditProps = {
orgId: string; orgId: string;
@@ -182,14 +205,14 @@ export function PolicyAuthStackSectionEdit({
]); ]);
const form = useForm({ const form = useForm({
resolver: zodResolver(authStackSchema), resolver: zodResolver(authStackEditSchema),
defaultValues: { defaultValues: {
sso: policy.sso, sso: policy.sso,
skipToIdpId: policy.idpId, skipToIdpId: policy.idpId,
roles: policyRoleItems, roles: policyRoleItems,
users: policyUserItems, users: policyUserItems,
password: policy.passwordId ? { password: "" } : null, password: null,
pincode: policy.pincodeId ? { pincode: "" } : null, pincode: null,
headerAuth: policy.headerAuth headerAuth: policy.headerAuth
? { ? {
user: "", user: "",
@@ -247,7 +270,14 @@ export function PolicyAuthStackSectionEdit({
} }
const isValid = await form.trigger(); 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 payload = form.getValues();
const requests: Array<Promise<AxiosResponse<{}> | void>> = []; const requests: Array<Promise<AxiosResponse<{}> | void>> = [];
@@ -441,184 +471,219 @@ export function PolicyAuthStackSectionEdit({
</SettingsSectionDescription> </SettingsSectionDescription>
</SettingsSectionHeader> </SettingsSectionHeader>
<SettingsSectionBody> <SettingsSectionBody>
<div className="w-full md:w-1/2"> <div className="space-y-4">
<PolicyAuthSsoSection {isResourceOverlay && (
sso={Boolean(sso)} <SharedPolicyResourceNotice section="authentication" />
onSsoChange={(active) => )}
form.setValue("sso", active) <div className="w-full md:w-1/2">
} <PolicyAuthSsoSection
skipToIdpId={skipToIdpId} sso={Boolean(sso)}
onSkipToIdpChange={(id) => onSsoChange={(active) =>
form.setValue("skipToIdpId", id) form.setValue("sso", active)
} }
allIdps={allIdps} skipToIdpId={skipToIdpId}
disabled={authReadonly} onSkipToIdpChange={(id) =>
idpDisabled={authReadonly} form.setValue("skipToIdpId", id)
rolesEditor={ }
isResourceOverlay ? ( allIdps={allIdps}
<RolesSelector disabled={authReadonly}
orgId={orgId} idpDisabled={authReadonly}
selectedRoles={overlayRoles} rolesEditor={
onSelectRoles={(selected) => isResourceOverlay ? (
setCombinedRoles( <RolesSelector
selected.map((role) => ({ orgId={orgId}
...role, selectedRoles={overlayRoles}
isAdmin: Boolean( onSelectRoles={(selected) =>
role.isAdmin 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} disabled={authReadonly}
/> />
) : ( </div>
<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}
/>
</div> </div>
<PincodeCredenza <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 ────────────────────────────────────────────────────────── // ─── Schemas & types ──────────────────────────────────────────────────────────
import z from "zod"; import z from "zod";
import { POLICY_RULE_MATCH_TYPES } from "./policy-access-rule-validation";
export const createPolicySchema = z.object({ export const createPolicySchema = z.object({
name: z.string().min(1).max(255), name: z.string().min(1).max(255),
@@ -35,7 +36,7 @@ export const createPolicySchema = z.object({
.array( .array(
z.object({ z.object({
action: z.enum(["ACCEPT", "DROP", "PASS"]), action: z.enum(["ACCEPT", "DROP", "PASS"]),
match: z.string(), match: z.enum(POLICY_RULE_MATCH_TYPES),
value: z.string(), value: z.string(),
priority: z.number().int(), priority: z.number().int(),
enabled: z.boolean() enabled: z.boolean()
@@ -67,6 +68,7 @@ export {
type PolicyAccessRule type PolicyAccessRule
} from "./policy-access-rule-utils"; } from "./policy-access-rule-utils";
export { export {
createPolicyRuleMatchSchema,
createPolicyRulePrioritySchema, createPolicyRulePrioritySchema,
createPolicyRuleSchema, createPolicyRuleSchema,
createPolicyRuleValueSchema, createPolicyRuleValueSchema,
@@ -74,8 +76,10 @@ export {
createPolicyRulesSectionSchema, createPolicyRulesSectionSchema,
createPolicySchemaWithI18n, createPolicySchemaWithI18n,
getPolicyRuleValidationMessage, getPolicyRuleValidationMessage,
POLICY_RULE_MATCH_TYPES,
validatePolicyRulePriority, validatePolicyRulePriority,
validatePolicyRuleValue, validatePolicyRuleValue,
validatePolicyRulesForSave, validatePolicyRulesForSave,
type PolicyRuleMatchType,
type RuleValidationToast type RuleValidationToast
} from "./policy-access-rule-validation"; } 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 }>( export function sortPolicyRulesByPriority<T extends { priority: number }>(
rules: T[] rules: T[]
): T[] { ): T[] {
return [...rules].sort((a, b) => a.priority - b.priority); 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< export function reorderPolicyRules<
T extends { priority: number; new?: boolean; updated?: boolean } T extends { priority: number; new?: boolean; updated?: boolean }
>( >(
@@ -70,3 +154,40 @@ export function reorderPolicyRules<
return next; 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> values?: Record<string, string | number>
) => string; ) => 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 = { export type RuleValidationToast = {
title: string; title: string;
description: string; description: string;
@@ -78,7 +95,7 @@ export function createPolicyRuleSchema(t: TranslateFn) {
return z return z
.object({ .object({
action: z.enum(["ACCEPT", "DROP", "PASS"]), action: z.enum(["ACCEPT", "DROP", "PASS"]),
match: z.string(), match: createPolicyRuleMatchSchema(t),
value: z.string(), value: z.string(),
priority: z.number().int(), priority: z.number().int(),
enabled: z.boolean() 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"; import { cn } from "@app/lib/cn";
const alertVariants = cva( 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: { variants: {
variant: { variant: {

View File

@@ -105,13 +105,17 @@ function SelectLabel({
function SelectItem({ function SelectItem({
className, className,
children, children,
description,
...props ...props
}: React.ComponentProps<typeof SelectPrimitive.Item>) { }: React.ComponentProps<typeof SelectPrimitive.Item> & {
description?: React.ReactNode;
}) {
return ( return (
<SelectPrimitive.Item <SelectPrimitive.Item
data-slot="select-item" data-slot="select-item"
className={cn( 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 className
)} )}
{...props} {...props}
@@ -121,7 +125,18 @@ function SelectItem({
<CheckIcon className="size-4" /> <CheckIcon className="size-4" />
</SelectPrimitive.ItemIndicator> </SelectPrimitive.ItemIndicator>
</span> </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> </SelectPrimitive.Item>
); );
} }

View File

@@ -45,6 +45,7 @@ import type { ListOrgLabelsResponse } from "@server/routers/labels/types";
import { ListHealthChecksResponse } from "@server/routers/healthChecks/types"; import { ListHealthChecksResponse } from "@server/routers/healthChecks/types";
import { StatusHistoryResponse } from "@server/lib/statusHistory"; import { StatusHistoryResponse } from "@server/lib/statusHistory";
import type { ListResourcePoliciesResponse } from "@server/routers/resource/types"; import type { ListResourcePoliciesResponse } from "@server/routers/resource/types";
import type { GetResourcePolicyResponse } from "@server/routers/policy";
export type ProductUpdate = { export type ProductUpdate = {
link: string | null; 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({ queryOptions({
queryKey: ["ORG", orgId, "RESOURCES_POLICIES", name] as const, queryKey: ["ORG", orgId, "RESOURCES_POLICIES", query] as const,
queryFn: async ({ signal, meta }) => { queryFn: async ({ signal, meta }) => {
const sp = new URLSearchParams({ const sp = new URLSearchParams({
pageSize: "10" pageSize: "10"
}); });
if (name) { if (query) {
sp.set("query", name); sp.set("query", query);
} }
const res = await meta!.api.get< const res = await meta!.api.get<
@@ -601,6 +602,18 @@ export const orgQueries = {
return res.data.data.policies; 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;
}
}) })
}; };