mirror of
https://github.com/fosrl/pangolin.git
synced 2026-06-15 03:48:00 +00:00
policies and policy on resource structure in a good place
This commit is contained in:
21
src/components/ResourcePoliciesBanner.tsx
Normal file
21
src/components/ResourcePoliciesBanner.tsx
Normal 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;
|
||||
@@ -283,6 +283,7 @@ export function ResourcePoliciesTable({
|
||||
searchPlaceholder={t("resourcePoliciesSearch")}
|
||||
pagination={pagination}
|
||||
rowCount={rowCount}
|
||||
searchQuery={searchParams.get("query")?.toString()}
|
||||
onSearch={handleSearchChange}
|
||||
onPaginationChange={handlePaginationChange}
|
||||
onAdd={() =>
|
||||
|
||||
@@ -147,7 +147,7 @@ export function CreatePolicyForm({}: CreatePolicyFormProps) {
|
||||
if (res && res.status === 201) {
|
||||
const niceId = res.data.data.niceId;
|
||||
router.push(
|
||||
`/${org.org.orgId}/settings/policies/resources/public/${niceId}`
|
||||
`/${org.org.orgId}/settings/policies/resources/public/${niceId}/general`
|
||||
);
|
||||
toast({
|
||||
title: t("success"),
|
||||
@@ -227,7 +227,7 @@ export function CreatePolicyForm({}: CreatePolicyFormProps) {
|
||||
</SettingsSectionDescription>
|
||||
</SettingsSectionHeader>
|
||||
<SettingsSectionBody>
|
||||
<SettingsSectionForm>
|
||||
<SettingsSectionForm variant="half">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="name"
|
||||
@@ -237,12 +237,7 @@ export function CreatePolicyForm({}: CreatePolicyFormProps) {
|
||||
{t("name")}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
{...field}
|
||||
placeholder={t(
|
||||
"resourcePolicyNamePlaceholder"
|
||||
)}
|
||||
/>
|
||||
<Input {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -10,26 +10,27 @@ import { orgQueries } from "@app/lib/queries";
|
||||
import { build } from "@server/build";
|
||||
import { tierMatrix } from "@server/lib/billing/tierMatrix";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
import { useMemo } from "react";
|
||||
import { HorizontalTabs } from "@app/components/HorizontalTabs";
|
||||
import { EditPolicyNameSectionForm } from "./EditPolicyNameSectionForm";
|
||||
import { PolicyAuthStackSection } from "./PolicyAuthStackSection";
|
||||
import { PolicyAccessRulesSection } from "./PolicyAccessRulesSection";
|
||||
|
||||
export type EditPolicyFormSection = "general" | "authentication" | "rules";
|
||||
|
||||
export type EditPolicyFormProps = {
|
||||
hidePolicyNameForm?: boolean;
|
||||
readonly?: boolean;
|
||||
resourceId?: number;
|
||||
section?: EditPolicyFormSection;
|
||||
};
|
||||
|
||||
export function EditPolicyForm({
|
||||
hidePolicyNameForm,
|
||||
readonly,
|
||||
resourceId
|
||||
resourceId,
|
||||
section
|
||||
}: EditPolicyFormProps) {
|
||||
const t = useTranslations();
|
||||
const { org } = useOrgContext();
|
||||
const { env } = useEnvContext();
|
||||
const { isPaidUser } = usePaidStatus();
|
||||
@@ -37,7 +38,6 @@ export function EditPolicyForm({
|
||||
// In overlay mode (resourceId provided), policy-level sections are locked.
|
||||
// Rules and users/roles sections handle their own hybrid logic via resourceId.
|
||||
const isOverlay = resourceId !== undefined;
|
||||
const showTabs = !hidePolicyNameForm && !isOverlay;
|
||||
|
||||
const isMaxmindAvailable = !!(
|
||||
env.server.maxmind_db_path && env.server.maxmind_db_path.length > 0
|
||||
@@ -92,22 +92,16 @@ export function EditPolicyForm({
|
||||
/>
|
||||
);
|
||||
|
||||
if (showTabs) {
|
||||
return (
|
||||
<HorizontalTabs
|
||||
clientSide
|
||||
defaultTab={0}
|
||||
items={[
|
||||
{ title: t("general"), href: "#" },
|
||||
{ title: t("authentication"), href: "#" },
|
||||
{ title: t("policyAccessRulesTitle"), href: "#" }
|
||||
]}
|
||||
>
|
||||
<EditPolicyNameSectionForm readonly={readonly} />
|
||||
{authSection}
|
||||
{rulesSection}
|
||||
</HorizontalTabs>
|
||||
);
|
||||
if (section === "general") {
|
||||
return <EditPolicyNameSectionForm readonly={readonly} />;
|
||||
}
|
||||
|
||||
if (section === "authentication") {
|
||||
return authSection;
|
||||
}
|
||||
|
||||
if (section === "rules") {
|
||||
return rulesSection;
|
||||
}
|
||||
|
||||
return (
|
||||
|
||||
@@ -109,7 +109,7 @@ export function EditPolicyNameSectionForm({
|
||||
|
||||
if (payload.niceId && payload.niceId !== policy.niceId) {
|
||||
router.replace(
|
||||
`/${org.org.orgId}/settings/policies/resources/public/${payload.niceId}`
|
||||
`/${org.org.orgId}/settings/policies/resources/public/${payload.niceId}/general`
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -28,7 +28,8 @@ import {
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
useTransition
|
||||
useTransition,
|
||||
type ReactNode
|
||||
} from "react";
|
||||
import { UseFormReturn, useForm, useWatch } from "react-hook-form";
|
||||
import { useResourcePolicyContext } from "@app/providers/ResourcePolicyProvider";
|
||||
@@ -38,11 +39,12 @@ import { resourceQueries } from "@app/lib/queries";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import type { AxiosResponse } from "axios";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { CreatePolicyRulesSectionForm } from "./CreatePolicyRulesSectionForm";
|
||||
import { PolicyAccessRulesIntro } from "./PolicyAccessRulesIntro";
|
||||
import { PolicyAccessRulesTable } from "./PolicyAccessRulesTable";
|
||||
import { SharedPolicyResourceNotice } from "./SharedPolicyResourceNotice";
|
||||
import {
|
||||
createEmptyRule,
|
||||
prependEmptyRule,
|
||||
type PolicyAccessRule
|
||||
} from "./policy-access-rule-utils";
|
||||
|
||||
@@ -74,6 +76,143 @@ export function PolicyAccessRulesSection(props: PolicyAccessRulesSectionProps) {
|
||||
return <PolicyAccessRulesSectionEdit {...props} />;
|
||||
}
|
||||
|
||||
type PolicyAccessRulesSectionLayoutProps = {
|
||||
rulesEnabled: boolean;
|
||||
onRulesEnabledChange: (enabled: boolean) => void;
|
||||
disableToggle?: boolean;
|
||||
rules: PolicyAccessRule[];
|
||||
onRulesChange: (rules: PolicyAccessRule[]) => void;
|
||||
updateRule: (ruleId: number, data: Partial<PolicyAccessRule>) => void;
|
||||
removeRule: (ruleId: number) => void;
|
||||
readonly?: boolean;
|
||||
isMaxmindAvailable: boolean;
|
||||
isMaxmindAsnAvailable: boolean;
|
||||
resourceOverlayMode?: boolean;
|
||||
footer?: ReactNode;
|
||||
};
|
||||
|
||||
function PolicyAccessRulesSectionLayout({
|
||||
rulesEnabled,
|
||||
onRulesEnabledChange,
|
||||
disableToggle,
|
||||
rules,
|
||||
onRulesChange,
|
||||
updateRule,
|
||||
removeRule,
|
||||
readonly,
|
||||
isMaxmindAvailable,
|
||||
isMaxmindAsnAvailable,
|
||||
resourceOverlayMode,
|
||||
footer
|
||||
}: PolicyAccessRulesSectionLayoutProps) {
|
||||
const t = useTranslations();
|
||||
|
||||
const addEmptyRule = useCallback(() => {
|
||||
if (resourceOverlayMode) {
|
||||
onRulesChange(prependEmptyRule(rules));
|
||||
return;
|
||||
}
|
||||
onRulesChange([...rules, createEmptyRule(rules)]);
|
||||
}, [rules, onRulesChange, resourceOverlayMode]);
|
||||
|
||||
const addRuleButton = (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
disabled={readonly}
|
||||
onClick={addEmptyRule}
|
||||
>
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
{t("ruleSubmit")}
|
||||
</Button>
|
||||
);
|
||||
|
||||
const hasRules = rules.length > 0;
|
||||
|
||||
return (
|
||||
<SettingsSection>
|
||||
<SettingsSectionHeader>
|
||||
<SettingsSectionTitle>
|
||||
{t("policyAccessRulesTitle")}
|
||||
</SettingsSectionTitle>
|
||||
<SettingsSectionDescription>
|
||||
{t("rulesResourceDescription")}
|
||||
</SettingsSectionDescription>
|
||||
</SettingsSectionHeader>
|
||||
<SettingsSectionBody>
|
||||
<div className="space-y-4">
|
||||
{resourceOverlayMode && (
|
||||
<SharedPolicyResourceNotice section="rules" />
|
||||
)}
|
||||
<PolicyAccessRulesIntro
|
||||
rulesEnabled={rulesEnabled}
|
||||
onRulesEnabledChange={onRulesEnabledChange}
|
||||
disableToggle={disableToggle}
|
||||
/>
|
||||
|
||||
{rulesEnabled && (
|
||||
<>
|
||||
<PolicyAccessRulesTable
|
||||
rules={rules}
|
||||
onRulesChange={onRulesChange}
|
||||
updateRule={updateRule}
|
||||
removeRule={removeRule}
|
||||
readonly={readonly}
|
||||
isMaxmindAvailable={isMaxmindAvailable}
|
||||
isMaxmindAsnAvailable={isMaxmindAsnAvailable}
|
||||
includeRegionMatch
|
||||
markUpdatedOnReorder
|
||||
resourceOverlayMode={resourceOverlayMode}
|
||||
emptyStateAction={addRuleButton}
|
||||
/>
|
||||
{hasRules && addRuleButton}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</SettingsSectionBody>
|
||||
{footer}
|
||||
</SettingsSection>
|
||||
);
|
||||
}
|
||||
|
||||
function usePolicyAccessRulesFormSync(
|
||||
form: UseFormReturn<{
|
||||
applyRules: boolean;
|
||||
rules: PolicyFormValues["rules"];
|
||||
}>
|
||||
) {
|
||||
const syncFormRules = useCallback(
|
||||
(updatedRules: PolicyAccessRule[]) => {
|
||||
form.setValue(
|
||||
"rules",
|
||||
updatedRules.map(
|
||||
({ action, match, value, priority, enabled }) => ({
|
||||
action,
|
||||
match,
|
||||
value,
|
||||
priority,
|
||||
enabled
|
||||
})
|
||||
)
|
||||
);
|
||||
},
|
||||
[form]
|
||||
);
|
||||
|
||||
const updateRulesState = useCallback(
|
||||
(
|
||||
setRules: React.Dispatch<React.SetStateAction<PolicyAccessRule[]>>,
|
||||
updatedRules: PolicyAccessRule[]
|
||||
) => {
|
||||
setRules(updatedRules);
|
||||
syncFormRules(updatedRules);
|
||||
},
|
||||
[syncFormRules]
|
||||
);
|
||||
|
||||
return { syncFormRules, updateRulesState };
|
||||
}
|
||||
|
||||
function PolicyAccessRulesSectionEdit({
|
||||
isMaxmindAvailable,
|
||||
isMaxmindAsnAvailable,
|
||||
@@ -119,6 +258,8 @@ function PolicyAccessRulesSectionEdit({
|
||||
policy.rules.map((r) => ({ ...r, fromPolicy: isResourceOverlay }))
|
||||
);
|
||||
|
||||
const { updateRulesState } = usePolicyAccessRulesFormSync(form);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isResourceOverlay || resourceRulesInitialized) return;
|
||||
if (!resourceRulesData) return;
|
||||
@@ -148,30 +289,13 @@ function PolicyAccessRulesSectionEdit({
|
||||
policy.rules
|
||||
]);
|
||||
|
||||
const syncFormRules = useCallback(
|
||||
const handleRulesChange = useCallback(
|
||||
(updatedRules: PolicyAccessRule[]) => {
|
||||
form.setValue(
|
||||
"rules",
|
||||
updatedRules.map(
|
||||
({ action, match, value, priority, enabled }) => ({
|
||||
action,
|
||||
match,
|
||||
value,
|
||||
priority,
|
||||
enabled
|
||||
})
|
||||
)
|
||||
);
|
||||
updateRulesState(setRules, updatedRules);
|
||||
},
|
||||
[form]
|
||||
[updateRulesState]
|
||||
);
|
||||
|
||||
const addEmptyRule = useCallback(() => {
|
||||
const updatedRules = [...rules, createEmptyRule(rules)];
|
||||
setRules(updatedRules);
|
||||
syncFormRules(updatedRules);
|
||||
}, [rules, syncFormRules]);
|
||||
|
||||
const removeRule = useCallback(
|
||||
function removeRule(ruleId: number) {
|
||||
const rule = rules.find((r) => r.ruleId === ruleId);
|
||||
@@ -179,32 +303,22 @@ function PolicyAccessRulesSectionEdit({
|
||||
if (isResourceOverlay && !rule.new) {
|
||||
deletedResourceRuleIdsRef.current.add(ruleId);
|
||||
}
|
||||
const updatedRules = rules.filter((rule) => rule.ruleId !== ruleId);
|
||||
setRules(updatedRules);
|
||||
syncFormRules(updatedRules);
|
||||
handleRulesChange(rules.filter((rule) => rule.ruleId !== ruleId));
|
||||
},
|
||||
[rules, syncFormRules, isResourceOverlay]
|
||||
[rules, handleRulesChange, isResourceOverlay]
|
||||
);
|
||||
|
||||
const updateRule = useCallback(
|
||||
function updateRule(ruleId: number, data: Partial<PolicyAccessRule>) {
|
||||
const updatedRules = rules.map((rule) =>
|
||||
rule.ruleId === ruleId
|
||||
? { ...rule, ...data, updated: true }
|
||||
: rule
|
||||
handleRulesChange(
|
||||
rules.map((rule) =>
|
||||
rule.ruleId === ruleId
|
||||
? { ...rule, ...data, updated: true }
|
||||
: rule
|
||||
)
|
||||
);
|
||||
setRules(updatedRules);
|
||||
syncFormRules(updatedRules);
|
||||
},
|
||||
[rules, syncFormRules]
|
||||
);
|
||||
|
||||
const handleRulesChange = useCallback(
|
||||
(updatedRules: PolicyAccessRule[]) => {
|
||||
setRules(updatedRules);
|
||||
syncFormRules(updatedRules);
|
||||
},
|
||||
[syncFormRules]
|
||||
[rules, handleRulesChange]
|
||||
);
|
||||
|
||||
const [isPending, startTransition] = useTransition();
|
||||
@@ -213,7 +327,10 @@ function PolicyAccessRulesSectionEdit({
|
||||
if (readonly) return;
|
||||
|
||||
const applyRules = form.getValues("applyRules") ?? false;
|
||||
const rulesPayload = rules.map(
|
||||
const rulesToValidate = isResourceOverlay
|
||||
? rules.filter((rule) => !rule.fromPolicy)
|
||||
: rules;
|
||||
const rulesPayload = rulesToValidate.map(
|
||||
({ action, match, value, priority, enabled }) => ({
|
||||
action,
|
||||
match,
|
||||
@@ -331,80 +448,112 @@ function PolicyAccessRulesSectionEdit({
|
||||
}
|
||||
}
|
||||
|
||||
const addRuleButton = (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
disabled={readonly}
|
||||
onClick={addEmptyRule}
|
||||
>
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
{t("ruleSubmit")}
|
||||
</Button>
|
||||
);
|
||||
|
||||
const hasRules = rules.length > 0;
|
||||
|
||||
return (
|
||||
<SettingsSection>
|
||||
<SettingsSectionHeader>
|
||||
<SettingsSectionTitle>
|
||||
{t("policyAccessRulesTitle")}
|
||||
</SettingsSectionTitle>
|
||||
<SettingsSectionDescription>
|
||||
{t("rulesResourceDescription")}
|
||||
</SettingsSectionDescription>
|
||||
</SettingsSectionHeader>
|
||||
<SettingsSectionBody>
|
||||
<div className="space-y-6">
|
||||
<PolicyAccessRulesIntro
|
||||
rulesEnabled={Boolean(rulesEnabled)}
|
||||
onRulesEnabledChange={(val) => {
|
||||
form.setValue("applyRules", val);
|
||||
}}
|
||||
disableToggle={readonly || isResourceOverlay}
|
||||
/>
|
||||
|
||||
{rulesEnabled && (
|
||||
<>
|
||||
<PolicyAccessRulesTable
|
||||
rules={rules}
|
||||
onRulesChange={handleRulesChange}
|
||||
updateRule={updateRule}
|
||||
removeRule={removeRule}
|
||||
readonly={readonly}
|
||||
isMaxmindAvailable={isMaxmindAvailable}
|
||||
isMaxmindAsnAvailable={isMaxmindAsnAvailable}
|
||||
includeRegionMatch
|
||||
markUpdatedOnReorder
|
||||
emptyStateAction={addRuleButton}
|
||||
/>
|
||||
{hasRules && addRuleButton}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</SettingsSectionBody>
|
||||
<SettingsSectionFooter>
|
||||
<Button
|
||||
onClick={() => startTransition(() => saveRules())}
|
||||
loading={isPending}
|
||||
disabled={readonly || isPending}
|
||||
>
|
||||
{t("saveSettings")}
|
||||
</Button>
|
||||
</SettingsSectionFooter>
|
||||
</SettingsSection>
|
||||
<PolicyAccessRulesSectionLayout
|
||||
rulesEnabled={Boolean(rulesEnabled)}
|
||||
onRulesEnabledChange={(val) => {
|
||||
form.setValue("applyRules", val);
|
||||
}}
|
||||
disableToggle={readonly || isResourceOverlay}
|
||||
rules={rules}
|
||||
onRulesChange={handleRulesChange}
|
||||
updateRule={updateRule}
|
||||
removeRule={removeRule}
|
||||
readonly={readonly}
|
||||
isMaxmindAvailable={isMaxmindAvailable}
|
||||
isMaxmindAsnAvailable={isMaxmindAsnAvailable}
|
||||
resourceOverlayMode={isResourceOverlay}
|
||||
footer={
|
||||
<SettingsSectionFooter>
|
||||
<Button
|
||||
onClick={() => startTransition(() => saveRules())}
|
||||
loading={isPending}
|
||||
disabled={readonly || isPending}
|
||||
>
|
||||
{t("saveSettings")}
|
||||
</Button>
|
||||
</SettingsSectionFooter>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function PolicyAccessRulesSectionCreate({
|
||||
form,
|
||||
form: parentForm,
|
||||
isMaxmindAvailable,
|
||||
isMaxmindAsnAvailable
|
||||
}: PolicyAccessRulesSectionCreateProps) {
|
||||
const t = useTranslations();
|
||||
const [rules, setRules] = useState<PolicyAccessRule[]>([]);
|
||||
|
||||
const rulesFormSchema = useMemo(
|
||||
() => createPolicyRulesSectionSchema(t),
|
||||
[t]
|
||||
);
|
||||
|
||||
const form = useForm({
|
||||
resolver: zodResolver(rulesFormSchema),
|
||||
defaultValues: {
|
||||
applyRules: false,
|
||||
rules: []
|
||||
}
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const subscription = form.watch((values) => {
|
||||
parentForm.setValue("applyRules", values.applyRules as boolean);
|
||||
parentForm.setValue(
|
||||
"rules",
|
||||
values.rules as PolicyFormValues["rules"]
|
||||
);
|
||||
});
|
||||
return () => subscription.unsubscribe();
|
||||
}, [form, parentForm]);
|
||||
|
||||
const rulesEnabled = useWatch({
|
||||
control: form.control,
|
||||
name: "applyRules"
|
||||
});
|
||||
|
||||
const { updateRulesState } = usePolicyAccessRulesFormSync(form);
|
||||
|
||||
const handleRulesChange = useCallback(
|
||||
(updatedRules: PolicyAccessRule[]) => {
|
||||
updateRulesState(setRules, updatedRules);
|
||||
},
|
||||
[updateRulesState]
|
||||
);
|
||||
|
||||
const removeRule = useCallback(
|
||||
function removeRule(ruleId: number) {
|
||||
handleRulesChange(rules.filter((rule) => rule.ruleId !== ruleId));
|
||||
},
|
||||
[rules, handleRulesChange]
|
||||
);
|
||||
|
||||
const updateRule = useCallback(
|
||||
function updateRule(ruleId: number, data: Partial<PolicyAccessRule>) {
|
||||
handleRulesChange(
|
||||
rules.map((rule) =>
|
||||
rule.ruleId === ruleId
|
||||
? { ...rule, ...data, updated: true }
|
||||
: rule
|
||||
)
|
||||
);
|
||||
},
|
||||
[rules, handleRulesChange]
|
||||
);
|
||||
|
||||
return (
|
||||
<CreatePolicyRulesSectionForm
|
||||
form={form}
|
||||
<PolicyAccessRulesSectionLayout
|
||||
rulesEnabled={Boolean(rulesEnabled)}
|
||||
onRulesEnabledChange={(val) => {
|
||||
form.setValue("applyRules", val);
|
||||
}}
|
||||
rules={rules}
|
||||
onRulesChange={handleRulesChange}
|
||||
updateRule={updateRule}
|
||||
removeRule={removeRule}
|
||||
isMaxmindAvailable={isMaxmindAvailable}
|
||||
isMaxmindAsnAvailable={isMaxmindAsnAvailable}
|
||||
/>
|
||||
|
||||
@@ -66,8 +66,12 @@ import {
|
||||
validatePolicyRuleValue
|
||||
} from "./policy-access-rule-validation";
|
||||
import {
|
||||
buildDisplayPrioritiesForResourceOverlay,
|
||||
reorderPolicyRules,
|
||||
reorderResourceOverlayRules,
|
||||
setResourceRuleDisplayPriority,
|
||||
sortPolicyRulesByPriority,
|
||||
sortPolicyRulesForResourceOverlay,
|
||||
type PolicyAccessRule
|
||||
} from "./policy-access-rule-utils";
|
||||
|
||||
@@ -82,6 +86,7 @@ export type PolicyAccessRulesTableProps = {
|
||||
readonly?: boolean;
|
||||
includeRegionMatch?: boolean;
|
||||
markUpdatedOnReorder?: boolean;
|
||||
resourceOverlayMode?: boolean;
|
||||
isRuleDraggable?: (rule: PolicyAccessRule) => boolean;
|
||||
isRuleLocked?: (rule: PolicyAccessRule) => boolean;
|
||||
};
|
||||
@@ -97,7 +102,7 @@ function getColumnClassName(columnId: string) {
|
||||
return "w-24 max-w-24";
|
||||
}
|
||||
if (columnId === "action") {
|
||||
return "w-40 max-w-40";
|
||||
return "w-42 max-w-42";
|
||||
}
|
||||
if (columnId === "match") {
|
||||
return "w-36 max-w-36";
|
||||
@@ -116,6 +121,7 @@ export function PolicyAccessRulesTable({
|
||||
readonly = false,
|
||||
includeRegionMatch = false,
|
||||
markUpdatedOnReorder = false,
|
||||
resourceOverlayMode = false,
|
||||
isRuleDraggable: isRuleDraggableProp,
|
||||
isRuleLocked: isRuleLockedProp
|
||||
}: PolicyAccessRulesTableProps) {
|
||||
@@ -140,12 +146,37 @@ export function PolicyAccessRulesTable({
|
||||
);
|
||||
|
||||
const sortedRules = useMemo(
|
||||
() => sortPolicyRulesByPriority(rules),
|
||||
() =>
|
||||
resourceOverlayMode
|
||||
? sortPolicyRulesForResourceOverlay(rules)
|
||||
: sortPolicyRulesByPriority(rules),
|
||||
[rules, resourceOverlayMode]
|
||||
);
|
||||
|
||||
const displayPriorities = useMemo(
|
||||
() =>
|
||||
resourceOverlayMode
|
||||
? buildDisplayPrioritiesForResourceOverlay(rules)
|
||||
: null,
|
||||
[rules, resourceOverlayMode]
|
||||
);
|
||||
|
||||
const resourceRuleCount = useMemo(
|
||||
() => rules.filter((rule) => !rule.fromPolicy).length,
|
||||
[rules]
|
||||
);
|
||||
|
||||
const handleReorder = useCallback(
|
||||
(fromRuleId: number, toRuleId: number) => {
|
||||
if (resourceOverlayMode) {
|
||||
onRulesChange(
|
||||
reorderResourceOverlayRules(rules, fromRuleId, toRuleId, {
|
||||
markUpdated: markUpdatedOnReorder
|
||||
})
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const fromIndex = sortedRules.findIndex(
|
||||
(rule) => rule.ruleId === fromRuleId
|
||||
);
|
||||
@@ -164,7 +195,13 @@ export function PolicyAccessRulesTable({
|
||||
);
|
||||
onRulesChange(reordered);
|
||||
},
|
||||
[sortedRules, onRulesChange, markUpdatedOnReorder]
|
||||
[
|
||||
rules,
|
||||
sortedRules,
|
||||
onRulesChange,
|
||||
markUpdatedOnReorder,
|
||||
resourceOverlayMode
|
||||
]
|
||||
);
|
||||
|
||||
const handleDragStart = useCallback((ruleId: number, e: DragEvent) => {
|
||||
@@ -228,60 +265,132 @@ export function PolicyAccessRulesTable({
|
||||
maxSize: 96,
|
||||
header: ({ column }) => (
|
||||
<div className="p-3">
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="h-auto p-0 font-medium text-muted-foreground hover:bg-transparent"
|
||||
onClick={() =>
|
||||
column.toggleSorting(
|
||||
column.getIsSorted() === "asc"
|
||||
)
|
||||
}
|
||||
>
|
||||
{t("rulesPriority")}
|
||||
<ArrowUpDown className="ml-1 h-3 w-3" />
|
||||
</Button>
|
||||
{resourceOverlayMode ? (
|
||||
<span className="font-medium text-muted-foreground">
|
||||
{t("rulesPriority")}
|
||||
</span>
|
||||
) : (
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="h-auto p-0 font-medium text-muted-foreground hover:bg-transparent"
|
||||
onClick={() =>
|
||||
column.toggleSorting(
|
||||
column.getIsSorted() === "asc"
|
||||
)
|
||||
}
|
||||
>
|
||||
{t("rulesPriority")}
|
||||
<ArrowUpDown className="ml-1 h-3 w-3" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<Input
|
||||
defaultValue={row.original.priority}
|
||||
className="w-full min-w-0"
|
||||
type="number"
|
||||
disabled={readonly || isRuleLocked(row.original)}
|
||||
onClick={(e) => e.currentTarget.focus()}
|
||||
onBlur={(e) => {
|
||||
const validated = validatePolicyRulePriority(
|
||||
t,
|
||||
e.target.value
|
||||
);
|
||||
if (!validated.success) {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
...validated.toast
|
||||
cell: ({ row }) => {
|
||||
const displayPriority = resourceOverlayMode
|
||||
? (displayPriorities?.get(row.original.ruleId) ??
|
||||
row.original.priority)
|
||||
: row.original.priority;
|
||||
|
||||
return (
|
||||
<Input
|
||||
key={`${row.original.ruleId}-${displayPriority}`}
|
||||
defaultValue={displayPriority}
|
||||
className="w-full min-w-0"
|
||||
type="number"
|
||||
disabled={readonly || isRuleLocked(row.original)}
|
||||
onClick={(e) => e.currentTarget.focus()}
|
||||
onBlur={(e) => {
|
||||
const validated = validatePolicyRulePriority(
|
||||
t,
|
||||
e.target.value
|
||||
);
|
||||
if (!validated.success) {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
...validated.toast
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (resourceOverlayMode) {
|
||||
if (
|
||||
validated.data > resourceRuleCount ||
|
||||
validated.data < 1
|
||||
) {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: t(
|
||||
"rulesErrorInvalidPriority"
|
||||
),
|
||||
description: t(
|
||||
"rulesErrorInvalidPriorityDescription"
|
||||
)
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const duplicateDisplayPriority = rules.some(
|
||||
(rule) =>
|
||||
!rule.fromPolicy &&
|
||||
rule.ruleId !==
|
||||
row.original.ruleId &&
|
||||
displayPriorities?.get(
|
||||
rule.ruleId
|
||||
) === validated.data
|
||||
);
|
||||
if (duplicateDisplayPriority) {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: t(
|
||||
"rulesErrorDuplicatePriority"
|
||||
),
|
||||
description: t(
|
||||
"rulesErrorDuplicatePriorityDescription"
|
||||
)
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (validated.data === displayPriority) {
|
||||
return;
|
||||
}
|
||||
|
||||
onRulesChange(
|
||||
setResourceRuleDisplayPriority(
|
||||
rules,
|
||||
row.original.ruleId,
|
||||
validated.data,
|
||||
{
|
||||
markUpdated:
|
||||
markUpdatedOnReorder
|
||||
}
|
||||
)
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const duplicatePriority = rules.some(
|
||||
(rule) =>
|
||||
rule.ruleId !== row.original.ruleId &&
|
||||
rule.priority === validated.data
|
||||
);
|
||||
if (duplicatePriority) {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: t("rulesErrorDuplicatePriority"),
|
||||
description: t(
|
||||
"rulesErrorDuplicatePriorityDescription"
|
||||
)
|
||||
});
|
||||
return;
|
||||
}
|
||||
updateRule(row.original.ruleId, {
|
||||
priority: validated.data
|
||||
});
|
||||
return;
|
||||
}
|
||||
const duplicatePriority = rules.some(
|
||||
(rule) =>
|
||||
rule.ruleId !== row.original.ruleId &&
|
||||
rule.priority === validated.data
|
||||
);
|
||||
if (duplicatePriority) {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: t("rulesErrorDuplicatePriority"),
|
||||
description: t(
|
||||
"rulesErrorDuplicatePriorityDescription"
|
||||
)
|
||||
});
|
||||
return;
|
||||
}
|
||||
updateRule(row.original.ruleId, {
|
||||
priority: validated.data
|
||||
});
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
},
|
||||
{
|
||||
accessorKey: "action",
|
||||
@@ -683,13 +792,7 @@ export function PolicyAccessRulesTable({
|
||||
cell: ({ row }) => (
|
||||
<div className="flex items-center justify-end space-x-2">
|
||||
{isRuleLocked(row.original) ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
disabled
|
||||
className="cursor-not-allowed"
|
||||
>
|
||||
<LockIcon className="h-4 w-4" />
|
||||
</Button>
|
||||
<LockIcon className="h-4 w-4 text-muted-foreground" />
|
||||
) : (
|
||||
<Button
|
||||
variant="outline"
|
||||
@@ -711,9 +814,14 @@ export function PolicyAccessRulesTable({
|
||||
isMaxmindAsnAvailable,
|
||||
includeRegionMatch,
|
||||
updateRule,
|
||||
onRulesChange,
|
||||
removeRule,
|
||||
readonly,
|
||||
rules,
|
||||
resourceOverlayMode,
|
||||
displayPriorities,
|
||||
resourceRuleCount,
|
||||
markUpdatedOnReorder,
|
||||
isRuleDraggable,
|
||||
isRuleLocked,
|
||||
handleDragStart,
|
||||
@@ -775,7 +883,8 @@ export function PolicyAccessRulesTable({
|
||||
e.preventDefault();
|
||||
if (
|
||||
draggedRuleId !== null &&
|
||||
draggedRuleId !== rule.ruleId
|
||||
draggedRuleId !== rule.ruleId &&
|
||||
isRuleDraggable(rule)
|
||||
) {
|
||||
handleReorder(
|
||||
draggedRuleId,
|
||||
@@ -789,7 +898,7 @@ export function PolicyAccessRulesTable({
|
||||
draggedRuleId === rule.ruleId &&
|
||||
"opacity-50",
|
||||
dragOverRuleId === rule.ruleId &&
|
||||
"border-t-2 border-primary"
|
||||
"border-t border-primary"
|
||||
)}
|
||||
>
|
||||
{row.getVisibleCells().map((cell) => {
|
||||
|
||||
@@ -66,23 +66,6 @@ export function PolicyAuthMethodRow({
|
||||
>
|
||||
<div className="flex flex-1 min-w-0 flex-col gap-0.5">
|
||||
<div className="flex items-center gap-2">
|
||||
<span
|
||||
className="shrink-0 flex items-center"
|
||||
role="img"
|
||||
aria-label={
|
||||
active
|
||||
? t("policyAuthMethodActive")
|
||||
: t("policyAuthMethodOff")
|
||||
}
|
||||
>
|
||||
<div
|
||||
className={
|
||||
active
|
||||
? "w-2 h-2 bg-green-500 rounded-full"
|
||||
: "w-2 h-2 bg-neutral-500 rounded-full"
|
||||
}
|
||||
/>
|
||||
</span>
|
||||
<span className="text-sm font-medium">{title}</span>
|
||||
</div>
|
||||
<p className="truncate text-sm text-muted-foreground">
|
||||
|
||||
@@ -48,20 +48,43 @@ import {
|
||||
getPasscodeSummary,
|
||||
getPincodeSummary
|
||||
} from "./policy-auth-summaries";
|
||||
import { SharedPolicyResourceNotice } from "./SharedPolicyResourceNotice";
|
||||
import z from "zod";
|
||||
|
||||
type OverlaySelectedRole = SelectedRole & { isAdmin: boolean };
|
||||
|
||||
const authStackSchema = createPolicySchema.pick({
|
||||
sso: true,
|
||||
skipToIdpId: true,
|
||||
roles: true,
|
||||
users: true,
|
||||
password: true,
|
||||
pincode: true,
|
||||
headerAuth: true,
|
||||
emailWhitelistEnabled: true,
|
||||
emails: true
|
||||
});
|
||||
// Edit mode keeps placeholder values for configured methods; only validate on save when changed.
|
||||
const authStackEditSchema = createPolicySchema
|
||||
.pick({
|
||||
sso: true,
|
||||
skipToIdpId: true,
|
||||
roles: true,
|
||||
users: true,
|
||||
emailWhitelistEnabled: true,
|
||||
emails: true
|
||||
})
|
||||
.extend({
|
||||
password: z
|
||||
.object({
|
||||
password: z.string()
|
||||
})
|
||||
.nullable()
|
||||
.optional(),
|
||||
pincode: z
|
||||
.object({
|
||||
pincode: z.string()
|
||||
})
|
||||
.nullable()
|
||||
.optional(),
|
||||
headerAuth: z
|
||||
.object({
|
||||
user: z.string(),
|
||||
password: z.string(),
|
||||
extendedCompatibility: z.boolean().default(true)
|
||||
})
|
||||
.nullable()
|
||||
.optional()
|
||||
});
|
||||
|
||||
export type PolicyAuthStackSectionEditProps = {
|
||||
orgId: string;
|
||||
@@ -182,14 +205,14 @@ export function PolicyAuthStackSectionEdit({
|
||||
]);
|
||||
|
||||
const form = useForm({
|
||||
resolver: zodResolver(authStackSchema),
|
||||
resolver: zodResolver(authStackEditSchema),
|
||||
defaultValues: {
|
||||
sso: policy.sso,
|
||||
skipToIdpId: policy.idpId,
|
||||
roles: policyRoleItems,
|
||||
users: policyUserItems,
|
||||
password: policy.passwordId ? { password: "" } : null,
|
||||
pincode: policy.pincodeId ? { pincode: "" } : null,
|
||||
password: null,
|
||||
pincode: null,
|
||||
headerAuth: policy.headerAuth
|
||||
? {
|
||||
user: "",
|
||||
@@ -247,7 +270,14 @@ export function PolicyAuthStackSectionEdit({
|
||||
}
|
||||
|
||||
const isValid = await form.trigger();
|
||||
if (!isValid) return;
|
||||
if (!isValid) {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: t("policyErrorUpdate"),
|
||||
description: t("policyErrorUpdateMessageDescription")
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const payload = form.getValues();
|
||||
const requests: Array<Promise<AxiosResponse<{}> | void>> = [];
|
||||
@@ -441,184 +471,219 @@ export function PolicyAuthStackSectionEdit({
|
||||
</SettingsSectionDescription>
|
||||
</SettingsSectionHeader>
|
||||
<SettingsSectionBody>
|
||||
<div className="w-full md:w-1/2">
|
||||
<PolicyAuthSsoSection
|
||||
sso={Boolean(sso)}
|
||||
onSsoChange={(active) =>
|
||||
form.setValue("sso", active)
|
||||
}
|
||||
skipToIdpId={skipToIdpId}
|
||||
onSkipToIdpChange={(id) =>
|
||||
form.setValue("skipToIdpId", id)
|
||||
}
|
||||
allIdps={allIdps}
|
||||
disabled={authReadonly}
|
||||
idpDisabled={authReadonly}
|
||||
rolesEditor={
|
||||
isResourceOverlay ? (
|
||||
<RolesSelector
|
||||
orgId={orgId}
|
||||
selectedRoles={overlayRoles}
|
||||
onSelectRoles={(selected) =>
|
||||
setCombinedRoles(
|
||||
selected.map((role) => ({
|
||||
...role,
|
||||
isAdmin: Boolean(
|
||||
role.isAdmin
|
||||
<div className="space-y-4">
|
||||
{isResourceOverlay && (
|
||||
<SharedPolicyResourceNotice section="authentication" />
|
||||
)}
|
||||
<div className="w-full md:w-1/2">
|
||||
<PolicyAuthSsoSection
|
||||
sso={Boolean(sso)}
|
||||
onSsoChange={(active) =>
|
||||
form.setValue("sso", active)
|
||||
}
|
||||
skipToIdpId={skipToIdpId}
|
||||
onSkipToIdpChange={(id) =>
|
||||
form.setValue("skipToIdpId", id)
|
||||
}
|
||||
allIdps={allIdps}
|
||||
disabled={authReadonly}
|
||||
idpDisabled={authReadonly}
|
||||
rolesEditor={
|
||||
isResourceOverlay ? (
|
||||
<RolesSelector
|
||||
orgId={orgId}
|
||||
selectedRoles={overlayRoles}
|
||||
onSelectRoles={(selected) =>
|
||||
setCombinedRoles(
|
||||
selected.map(
|
||||
(role) => ({
|
||||
...role,
|
||||
isAdmin:
|
||||
Boolean(
|
||||
role.isAdmin
|
||||
)
|
||||
})
|
||||
)
|
||||
}))
|
||||
)
|
||||
}
|
||||
disabled={isLoading}
|
||||
restrictAdminRole
|
||||
lockedIds={policyRoleLockedIds}
|
||||
/>
|
||||
) : (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="roles"
|
||||
render={({ field }) => (
|
||||
<RolesSelector
|
||||
orgId={orgId}
|
||||
selectedRoles={
|
||||
field.value
|
||||
}
|
||||
onSelectRoles={(
|
||||
selected
|
||||
) =>
|
||||
form.setValue(
|
||||
"roles",
|
||||
selected
|
||||
)
|
||||
}
|
||||
disabled={readonly}
|
||||
restrictAdminRole
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
usersEditor={
|
||||
isResourceOverlay ? (
|
||||
<UsersSelector
|
||||
orgId={orgId}
|
||||
selectedUsers={overlayUsers}
|
||||
onSelectUsers={setCombinedUsers}
|
||||
disabled={isLoading}
|
||||
lockedIds={policyUserLockedIds}
|
||||
/>
|
||||
) : (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="users"
|
||||
render={({ field }) => (
|
||||
<UsersSelector
|
||||
orgId={orgId}
|
||||
selectedUsers={
|
||||
field.value
|
||||
}
|
||||
onSelectUsers={(
|
||||
selected
|
||||
) =>
|
||||
form.setValue(
|
||||
"users",
|
||||
selected
|
||||
)
|
||||
}
|
||||
disabled={readonly}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<SettingsSubsectionHeader>
|
||||
<SettingsSubsectionTitle>
|
||||
{t("policyAuthOtherMethodsTitle")}
|
||||
</SettingsSubsectionTitle>
|
||||
<SettingsSubsectionDescription>
|
||||
{t("policyAuthOtherMethodsDescription")}
|
||||
</SettingsSubsectionDescription>
|
||||
</SettingsSubsectionHeader>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||
<PolicyAuthMethodRow
|
||||
id="pincode"
|
||||
title={t("policyAuthPincodeTitle")}
|
||||
description={t(
|
||||
"policyAuthPincodeDescription"
|
||||
)}
|
||||
summary={getPincodeSummary({ t })}
|
||||
active={pinActive}
|
||||
onConfigure={() =>
|
||||
openMethodEditor("pincode")
|
||||
}
|
||||
onToggle={(active) =>
|
||||
handleToggle("pincode", active, () => {
|
||||
setPinActive(false);
|
||||
form.setValue("pincode", null);
|
||||
})
|
||||
}
|
||||
disabled={authReadonly}
|
||||
/>
|
||||
|
||||
<PolicyAuthMethodRow
|
||||
id="passcode"
|
||||
title={t("policyAuthPasscodeTitle")}
|
||||
description={t(
|
||||
"policyAuthPasscodeDescription"
|
||||
)}
|
||||
summary={getPasscodeSummary({ t })}
|
||||
active={passcodeActive}
|
||||
onConfigure={() =>
|
||||
openMethodEditor("passcode")
|
||||
}
|
||||
onToggle={(active) =>
|
||||
handleToggle("passcode", active, () => {
|
||||
setPasscodeActive(false);
|
||||
form.setValue("password", null);
|
||||
})
|
||||
}
|
||||
disabled={authReadonly}
|
||||
/>
|
||||
|
||||
<PolicyAuthMethodRow
|
||||
id="email"
|
||||
title={t("policyAuthEmailTitle")}
|
||||
description={t(
|
||||
"policyAuthEmailDescription"
|
||||
)}
|
||||
summary={getEmailWhitelistSummary({
|
||||
t,
|
||||
count: emails.length
|
||||
})}
|
||||
active={Boolean(emailWhitelistEnabled)}
|
||||
onConfigure={() =>
|
||||
openMethodEditor("email")
|
||||
}
|
||||
onToggle={(active) =>
|
||||
handleToggle(
|
||||
"email",
|
||||
active,
|
||||
() =>
|
||||
form.setValue(
|
||||
"emailWhitelistEnabled",
|
||||
false
|
||||
),
|
||||
() =>
|
||||
form.setValue(
|
||||
"emailWhitelistEnabled",
|
||||
true
|
||||
)
|
||||
)
|
||||
}
|
||||
disabled={authReadonly || !emailEnabled}
|
||||
/>
|
||||
|
||||
<PolicyAuthMethodRow
|
||||
id="header-auth"
|
||||
title={t("policyAuthHeaderAuthTitle")}
|
||||
description={t(
|
||||
"policyAuthHeaderAuthDescription"
|
||||
)}
|
||||
summary={getHeaderAuthSummary({
|
||||
t,
|
||||
headerName: headerAuth?.user ?? ""
|
||||
})}
|
||||
active={headerAuthActive}
|
||||
onConfigure={() =>
|
||||
openMethodEditor("headerAuth")
|
||||
}
|
||||
onToggle={(active) =>
|
||||
handleToggle(
|
||||
"headerAuth",
|
||||
active,
|
||||
() => {
|
||||
setHeaderAuthActive(false);
|
||||
form.setValue(
|
||||
"headerAuth",
|
||||
null
|
||||
);
|
||||
}
|
||||
disabled={isLoading}
|
||||
restrictAdminRole
|
||||
lockedIds={policyRoleLockedIds}
|
||||
/>
|
||||
) : (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="roles"
|
||||
render={({ field }) => (
|
||||
<RolesSelector
|
||||
orgId={orgId}
|
||||
selectedRoles={field.value}
|
||||
onSelectRoles={(selected) =>
|
||||
form.setValue(
|
||||
"roles",
|
||||
selected
|
||||
)
|
||||
}
|
||||
disabled={readonly}
|
||||
restrictAdminRole
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
usersEditor={
|
||||
isResourceOverlay ? (
|
||||
<UsersSelector
|
||||
orgId={orgId}
|
||||
selectedUsers={overlayUsers}
|
||||
onSelectUsers={setCombinedUsers}
|
||||
disabled={isLoading}
|
||||
lockedIds={policyUserLockedIds}
|
||||
/>
|
||||
) : (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="users"
|
||||
render={({ field }) => (
|
||||
<UsersSelector
|
||||
orgId={orgId}
|
||||
selectedUsers={field.value}
|
||||
onSelectUsers={(selected) =>
|
||||
form.setValue(
|
||||
"users",
|
||||
selected
|
||||
)
|
||||
}
|
||||
disabled={readonly}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<SettingsSubsectionHeader>
|
||||
<SettingsSubsectionTitle>
|
||||
{t("policyAuthOtherMethodsTitle")}
|
||||
</SettingsSubsectionTitle>
|
||||
<SettingsSubsectionDescription>
|
||||
{t("policyAuthOtherMethodsDescription")}
|
||||
</SettingsSubsectionDescription>
|
||||
</SettingsSubsectionHeader>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||
<PolicyAuthMethodRow
|
||||
id="pincode"
|
||||
title={t("policyAuthPincodeTitle")}
|
||||
description={t("policyAuthPincodeDescription")}
|
||||
summary={getPincodeSummary({ t })}
|
||||
active={pinActive}
|
||||
onConfigure={() => openMethodEditor("pincode")}
|
||||
onToggle={(active) =>
|
||||
handleToggle("pincode", active, () => {
|
||||
setPinActive(false);
|
||||
form.setValue("pincode", null);
|
||||
})
|
||||
}
|
||||
disabled={authReadonly}
|
||||
/>
|
||||
|
||||
<PolicyAuthMethodRow
|
||||
id="passcode"
|
||||
title={t("policyAuthPasscodeTitle")}
|
||||
description={t("policyAuthPasscodeDescription")}
|
||||
summary={getPasscodeSummary({ t })}
|
||||
active={passcodeActive}
|
||||
onConfigure={() => openMethodEditor("passcode")}
|
||||
onToggle={(active) =>
|
||||
handleToggle("passcode", active, () => {
|
||||
setPasscodeActive(false);
|
||||
form.setValue("password", null);
|
||||
})
|
||||
}
|
||||
disabled={authReadonly}
|
||||
/>
|
||||
|
||||
<PolicyAuthMethodRow
|
||||
id="email"
|
||||
title={t("policyAuthEmailTitle")}
|
||||
description={t("policyAuthEmailDescription")}
|
||||
summary={getEmailWhitelistSummary({
|
||||
t,
|
||||
count: emails.length
|
||||
})}
|
||||
active={Boolean(emailWhitelistEnabled)}
|
||||
onConfigure={() => openMethodEditor("email")}
|
||||
onToggle={(active) =>
|
||||
handleToggle(
|
||||
"email",
|
||||
active,
|
||||
() =>
|
||||
form.setValue(
|
||||
"emailWhitelistEnabled",
|
||||
false
|
||||
),
|
||||
() =>
|
||||
form.setValue(
|
||||
"emailWhitelistEnabled",
|
||||
true
|
||||
)
|
||||
)
|
||||
}
|
||||
disabled={authReadonly || !emailEnabled}
|
||||
/>
|
||||
|
||||
<PolicyAuthMethodRow
|
||||
id="header-auth"
|
||||
title={t("policyAuthHeaderAuthTitle")}
|
||||
description={t(
|
||||
"policyAuthHeaderAuthDescription"
|
||||
)}
|
||||
summary={getHeaderAuthSummary({
|
||||
t,
|
||||
headerName: headerAuth?.user ?? ""
|
||||
})}
|
||||
active={headerAuthActive}
|
||||
onConfigure={() =>
|
||||
openMethodEditor("headerAuth")
|
||||
}
|
||||
onToggle={(active) =>
|
||||
handleToggle("headerAuth", active, () => {
|
||||
setHeaderAuthActive(false);
|
||||
form.setValue("headerAuth", null);
|
||||
})
|
||||
}
|
||||
disabled={authReadonly}
|
||||
/>
|
||||
)
|
||||
}
|
||||
disabled={authReadonly}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<PincodeCredenza
|
||||
|
||||
47
src/components/resource-policy/ResourcePolicyEditForm.tsx
Normal file
47
src/components/resource-policy/ResourcePolicyEditForm.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
@@ -34,12 +34,96 @@ export function createEmptyRule(
|
||||
};
|
||||
}
|
||||
|
||||
export function prependEmptyRule(
|
||||
rules: PolicyAccessRule[]
|
||||
): PolicyAccessRule[] {
|
||||
const newRule: EmptyRuleDraft = {
|
||||
ruleId: Date.now(),
|
||||
action: "ACCEPT",
|
||||
match: "PATH",
|
||||
value: "",
|
||||
priority: 1,
|
||||
enabled: true,
|
||||
new: true
|
||||
};
|
||||
|
||||
const bumpedRules = rules.map((rule) => {
|
||||
if (rule.fromPolicy) {
|
||||
return rule;
|
||||
}
|
||||
|
||||
const bumped = { ...rule, priority: rule.priority + 1 };
|
||||
if (rule.new) {
|
||||
return bumped;
|
||||
}
|
||||
return { ...bumped, updated: true };
|
||||
});
|
||||
|
||||
return [newRule, ...bumpedRules];
|
||||
}
|
||||
|
||||
export function sortPolicyRulesByPriority<T extends { priority: number }>(
|
||||
rules: T[]
|
||||
): T[] {
|
||||
return [...rules].sort((a, b) => a.priority - b.priority);
|
||||
}
|
||||
|
||||
export function sortPolicyRulesForResourceOverlay<
|
||||
T extends { priority: number; fromPolicy?: boolean }
|
||||
>(rules: T[]): T[] {
|
||||
const resourceRules = rules
|
||||
.filter((rule) => !rule.fromPolicy)
|
||||
.sort((a, b) => a.priority - b.priority);
|
||||
const policyRules = rules
|
||||
.filter((rule) => rule.fromPolicy)
|
||||
.sort((a, b) => a.priority - b.priority);
|
||||
|
||||
return [...resourceRules, ...policyRules];
|
||||
}
|
||||
|
||||
export function buildDisplayPrioritiesForResourceOverlay<
|
||||
T extends { ruleId: number; priority: number; fromPolicy?: boolean }
|
||||
>(rules: T[]): Map<number, number> {
|
||||
const sorted = sortPolicyRulesForResourceOverlay(rules);
|
||||
const displayPriorities = new Map<number, number>();
|
||||
|
||||
sorted.forEach((rule, index) => {
|
||||
displayPriorities.set(rule.ruleId, index + 1);
|
||||
});
|
||||
|
||||
return displayPriorities;
|
||||
}
|
||||
|
||||
export function setResourceRuleDisplayPriority(
|
||||
rules: PolicyAccessRule[],
|
||||
ruleId: number,
|
||||
displayPriority: number,
|
||||
options?: { markUpdated?: boolean }
|
||||
): PolicyAccessRule[] {
|
||||
const sorted = sortPolicyRulesForResourceOverlay(rules);
|
||||
const resourceRules = sorted.filter((rule) => !rule.fromPolicy);
|
||||
const policyRules = sorted.filter((rule) => rule.fromPolicy);
|
||||
|
||||
const fromIndex = resourceRules.findIndex((rule) => rule.ruleId === ruleId);
|
||||
if (fromIndex === -1) {
|
||||
return rules;
|
||||
}
|
||||
|
||||
const targetIndex = Math.max(
|
||||
0,
|
||||
Math.min(displayPriority - 1, resourceRules.length - 1)
|
||||
);
|
||||
|
||||
const reorderedResource = reorderPolicyRules(
|
||||
resourceRules,
|
||||
fromIndex,
|
||||
targetIndex,
|
||||
options
|
||||
);
|
||||
|
||||
return [...reorderedResource, ...policyRules];
|
||||
}
|
||||
|
||||
export function reorderPolicyRules<
|
||||
T extends { priority: number; new?: boolean; updated?: boolean }
|
||||
>(
|
||||
@@ -70,3 +154,40 @@ export function reorderPolicyRules<
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
export function reorderResourceOverlayRules<
|
||||
T extends {
|
||||
ruleId: number;
|
||||
priority: number;
|
||||
fromPolicy?: boolean;
|
||||
new?: boolean;
|
||||
updated?: boolean;
|
||||
}
|
||||
>(
|
||||
rules: T[],
|
||||
fromRuleId: number,
|
||||
toRuleId: number,
|
||||
options?: { markUpdated?: boolean }
|
||||
): T[] {
|
||||
const sorted = sortPolicyRulesForResourceOverlay(rules);
|
||||
const resourceRules = sorted.filter((rule) => !rule.fromPolicy);
|
||||
const policyRules = sorted.filter((rule) => rule.fromPolicy);
|
||||
|
||||
const fromIndex = resourceRules.findIndex(
|
||||
(rule) => rule.ruleId === fromRuleId
|
||||
);
|
||||
const toIndex = resourceRules.findIndex((rule) => rule.ruleId === toRuleId);
|
||||
|
||||
if (fromIndex === -1 || toIndex === -1 || fromIndex === toIndex) {
|
||||
return rules;
|
||||
}
|
||||
|
||||
const reorderedResource = reorderPolicyRules(
|
||||
resourceRules,
|
||||
fromIndex,
|
||||
toIndex,
|
||||
options
|
||||
);
|
||||
|
||||
return [...reorderedResource, ...policyRules];
|
||||
}
|
||||
|
||||
@@ -12,6 +12,23 @@ type TranslateFn = (
|
||||
values?: Record<string, string | number>
|
||||
) => string;
|
||||
|
||||
export const POLICY_RULE_MATCH_TYPES = [
|
||||
"CIDR",
|
||||
"IP",
|
||||
"PATH",
|
||||
"COUNTRY",
|
||||
"ASN",
|
||||
"REGION"
|
||||
] as const;
|
||||
|
||||
export type PolicyRuleMatchType = (typeof POLICY_RULE_MATCH_TYPES)[number];
|
||||
|
||||
export function createPolicyRuleMatchSchema(t: TranslateFn) {
|
||||
return z.enum(POLICY_RULE_MATCH_TYPES, {
|
||||
error: t("rulesErrorInvalidMatchTypeDescription")
|
||||
});
|
||||
}
|
||||
|
||||
export type RuleValidationToast = {
|
||||
title: string;
|
||||
description: string;
|
||||
@@ -78,7 +95,7 @@ export function createPolicyRuleSchema(t: TranslateFn) {
|
||||
return z
|
||||
.object({
|
||||
action: z.enum(["ACCEPT", "DROP", "PASS"]),
|
||||
match: z.string(),
|
||||
match: createPolicyRuleMatchSchema(t),
|
||||
value: z.string(),
|
||||
priority: z.number().int(),
|
||||
enabled: z.boolean()
|
||||
|
||||
218
src/components/shared-policy-selector.tsx
Normal file
218
src/components/shared-policy-selector.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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: {
|
||||
|
||||
@@ -105,13 +105,17 @@ function SelectLabel({
|
||||
function SelectItem({
|
||||
className,
|
||||
children,
|
||||
description,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Item>) {
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Item> & {
|
||||
description?: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<SelectPrimitive.Item
|
||||
data-slot="select-item"
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
|
||||
"focus:bg-accent focus:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex w-full cursor-default gap-2 rounded-sm pr-8 pl-2 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
|
||||
description ? "items-start py-2" : "items-center py-1.5",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
@@ -121,7 +125,18 @@ function SelectItem({
|
||||
<CheckIcon className="size-4" />
|
||||
</SelectPrimitive.ItemIndicator>
|
||||
</span>
|
||||
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
|
||||
{description ? (
|
||||
<div className="flex flex-col gap-0.5 pr-2">
|
||||
<SelectPrimitive.ItemText>
|
||||
{children}
|
||||
</SelectPrimitive.ItemText>
|
||||
<span className="text-muted-foreground text-xs leading-snug">
|
||||
{description}
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
|
||||
)}
|
||||
</SelectPrimitive.Item>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user