diff --git a/src/components/resource-policy/PolicyAuthStackSectionEdit.tsx b/src/components/resource-policy/PolicyAuthStackSectionEdit.tsx
index 7ecd7a8a8..bbf882a07 100644
--- a/src/components/resource-policy/PolicyAuthStackSectionEdit.tsx
+++ b/src/components/resource-policy/PolicyAuthStackSectionEdit.tsx
@@ -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 | void>> = [];
@@ -441,184 +471,219 @@ export function PolicyAuthStackSectionEdit({
-
-
- form.setValue("sso", active)
- }
- skipToIdpId={skipToIdpId}
- onSkipToIdpChange={(id) =>
- form.setValue("skipToIdpId", id)
- }
- allIdps={allIdps}
- disabled={authReadonly}
- idpDisabled={authReadonly}
- rolesEditor={
- isResourceOverlay ? (
-
- setCombinedRoles(
- selected.map((role) => ({
- ...role,
- isAdmin: Boolean(
- role.isAdmin
+
+ {isResourceOverlay && (
+
+ )}
+
+
+ form.setValue("sso", active)
+ }
+ skipToIdpId={skipToIdpId}
+ onSkipToIdpChange={(id) =>
+ form.setValue("skipToIdpId", id)
+ }
+ allIdps={allIdps}
+ disabled={authReadonly}
+ idpDisabled={authReadonly}
+ rolesEditor={
+ isResourceOverlay ? (
+
+ setCombinedRoles(
+ selected.map(
+ (role) => ({
+ ...role,
+ isAdmin:
+ Boolean(
+ role.isAdmin
+ )
+ })
)
- }))
+ )
+ }
+ disabled={isLoading}
+ restrictAdminRole
+ lockedIds={policyRoleLockedIds}
+ />
+ ) : (
+ (
+
+ form.setValue(
+ "roles",
+ selected
+ )
+ }
+ disabled={readonly}
+ restrictAdminRole
+ />
+ )}
+ />
+ )
+ }
+ usersEditor={
+ isResourceOverlay ? (
+
+ ) : (
+ (
+
+ form.setValue(
+ "users",
+ selected
+ )
+ }
+ disabled={readonly}
+ />
+ )}
+ />
+ )
+ }
+ />
+
+
+
+
+ {t("policyAuthOtherMethodsTitle")}
+
+
+ {t("policyAuthOtherMethodsDescription")}
+
+
+
+
+
+ openMethodEditor("pincode")
+ }
+ onToggle={(active) =>
+ handleToggle("pincode", active, () => {
+ setPinActive(false);
+ form.setValue("pincode", null);
+ })
+ }
+ disabled={authReadonly}
+ />
+
+
+ openMethodEditor("passcode")
+ }
+ onToggle={(active) =>
+ handleToggle("passcode", active, () => {
+ setPasscodeActive(false);
+ form.setValue("password", null);
+ })
+ }
+ disabled={authReadonly}
+ />
+
+
+ openMethodEditor("email")
+ }
+ onToggle={(active) =>
+ handleToggle(
+ "email",
+ active,
+ () =>
+ form.setValue(
+ "emailWhitelistEnabled",
+ false
+ ),
+ () =>
+ form.setValue(
+ "emailWhitelistEnabled",
+ true
)
+ )
+ }
+ disabled={authReadonly || !emailEnabled}
+ />
+
+
-
-
-
- {t("policyAuthOtherMethodsTitle")}
-
-
- {t("policyAuthOtherMethodsDescription")}
-
-
-
-
-
openMethodEditor("pincode")}
- onToggle={(active) =>
- handleToggle("pincode", active, () => {
- setPinActive(false);
- form.setValue("pincode", null);
- })
- }
- disabled={authReadonly}
- />
-
- openMethodEditor("passcode")}
- onToggle={(active) =>
- handleToggle("passcode", active, () => {
- setPasscodeActive(false);
- form.setValue("password", null);
- })
- }
- disabled={authReadonly}
- />
-
- openMethodEditor("email")}
- onToggle={(active) =>
- handleToggle(
- "email",
- active,
- () =>
- form.setValue(
- "emailWhitelistEnabled",
- false
- ),
- () =>
- form.setValue(
- "emailWhitelistEnabled",
- true
- )
- )
- }
- disabled={authReadonly || !emailEnabled}
- />
-
-
;
+};
+
+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 (
+
+
+
+ );
+ }
+
+ return (
+
+
+
+ );
+}
diff --git a/src/components/resource-policy/SharedPolicyResourceNotice.tsx b/src/components/resource-policy/SharedPolicyResourceNotice.tsx
new file mode 100644
index 000000000..8dd082e14
--- /dev/null
+++ b/src/components/resource-policy/SharedPolicyResourceNotice.tsx
@@ -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 (
+
+
+
+ {t.rich(messageKey, {
+ policyName: policy.name,
+ policyLink: (chunks) => (
+
+ {chunks}
+
+ )
+ })}
+
+
+ );
+}
diff --git a/src/components/resource-policy/index.ts b/src/components/resource-policy/index.ts
index a0456515b..6e867b017 100644
--- a/src/components/resource-policy/index.ts
+++ b/src/components/resource-policy/index.ts
@@ -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";
diff --git a/src/components/resource-policy/policy-access-rule-utils.ts b/src/components/resource-policy/policy-access-rule-utils.ts
index 50178a4a5..905023ff4 100644
--- a/src/components/resource-policy/policy-access-rule-utils.ts
+++ b/src/components/resource-policy/policy-access-rule-utils.ts
@@ -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(
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 {
+ const sorted = sortPolicyRulesForResourceOverlay(rules);
+ const displayPriorities = new Map();
+
+ 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];
+}
diff --git a/src/components/resource-policy/policy-access-rule-validation.ts b/src/components/resource-policy/policy-access-rule-validation.ts
index e13fb9468..387d2003b 100644
--- a/src/components/resource-policy/policy-access-rule-validation.ts
+++ b/src/components/resource-policy/policy-access-rule-validation.ts
@@ -12,6 +12,23 @@ type TranslateFn = (
values?: Record
) => 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()
diff --git a/src/components/shared-policy-selector.tsx b/src/components/shared-policy-selector.tsx
new file mode 100644
index 000000000..5eb4d0013
--- /dev/null
+++ b/src/components/shared-policy-selector.tsx
@@ -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 (
+
+
+
+ {t("resourcePolicyNotFound")}
+
+ onSelectPolicy(null)}
+ >
+
+
+ {t("none")}
+
+ {t("sharedPolicyNoneDescription")}
+
+
+
+ {policiesShown.map((policy) => (
+
+ onSelectPolicy({
+ resourcePolicyId: policy.resourcePolicyId,
+ name: policy.name
+ })
+ }
+ >
+
+
+ {policy.name}
+
+
+ ))}
+
+
+
+ );
+}
+
+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 (
+
+
+
+
+
+ {
+ onChange(policy?.resourcePolicyId ?? null);
+ setSelectedLabel(
+ policy
+ ? {
+ resourcePolicyId: policy.resourcePolicyId,
+ name: policy.name
+ }
+ : null
+ );
+ setOpen(false);
+ }}
+ />
+
+
+ );
+}
diff --git a/src/components/ui/alert.tsx b/src/components/ui/alert.tsx
index dce25949a..90a87bee6 100644
--- a/src/components/ui/alert.tsx
+++ b/src/components/ui/alert.tsx
@@ -4,7 +4,7 @@ import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@app/lib/cn";
const alertVariants = cva(
- "relative w-full rounded-lg p-4 [&>svg~*]:pl-7 [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground",
+ "relative w-full rounded-lg p-4 has-[>svg]:grid has-[>svg]:grid-cols-[auto_1fr] has-[>svg]:gap-x-3 gap-y-1 [&>svg]:col-start-1 [&>svg]:row-start-1 [&>svg]:row-span-full [&>svg]:size-4 [&>svg]:shrink-0 [&>svg]:self-center [&>svg]:text-foreground [&>svg~*]:col-start-2",
{
variants: {
variant: {
diff --git a/src/components/ui/select.tsx b/src/components/ui/select.tsx
index 6d065e7aa..c91799712 100644
--- a/src/components/ui/select.tsx
+++ b/src/components/ui/select.tsx
@@ -105,13 +105,17 @@ function SelectLabel({
function SelectItem({
className,
children,
+ description,
...props
-}: React.ComponentProps) {
+}: React.ComponentProps & {
+ description?: React.ReactNode;
+}) {
return (
- {children}
+ {description ? (
+
+
+ {children}
+
+
+ {description}
+
+
+ ) : (
+ {children}
+ )}
);
}
diff --git a/src/lib/queries.ts b/src/lib/queries.ts
index 17377af8f..7d224c7b1 100644
--- a/src/lib/queries.ts
+++ b/src/lib/queries.ts
@@ -45,6 +45,7 @@ import type { ListOrgLabelsResponse } from "@server/routers/labels/types";
import { ListHealthChecksResponse } from "@server/routers/healthChecks/types";
import { StatusHistoryResponse } from "@server/lib/statusHistory";
import type { ListResourcePoliciesResponse } from "@server/routers/resource/types";
+import type { GetResourcePolicyResponse } from "@server/routers/policy";
export type ProductUpdate = {
link: string | null;
@@ -581,16 +582,16 @@ export const orgQueries = {
}
}),
- policies: ({ orgId, name }: { orgId: string; name?: string }) =>
+ policies: ({ orgId, query }: { orgId: string; query?: string }) =>
queryOptions({
- queryKey: ["ORG", orgId, "RESOURCES_POLICIES", name] as const,
+ queryKey: ["ORG", orgId, "RESOURCES_POLICIES", query] as const,
queryFn: async ({ signal, meta }) => {
const sp = new URLSearchParams({
pageSize: "10"
});
- if (name) {
- sp.set("query", name);
+ if (query) {
+ sp.set("query", query);
}
const res = await meta!.api.get<
@@ -601,6 +602,18 @@ export const orgQueries = {
return res.data.data.policies;
}
+ }),
+
+ resourcePolicy: ({ resourcePolicyId }: { resourcePolicyId: number }) =>
+ queryOptions({
+ queryKey: ["RESOURCE_POLICY", resourcePolicyId] as const,
+ queryFn: async ({ signal, meta }) => {
+ const res = await meta!.api.get<
+ AxiosResponse
+ >(`/resource-policy/${resourcePolicyId}`, { signal });
+
+ return res.data.data;
+ }
})
};