mirror of
https://github.com/fosrl/pangolin.git
synced 2026-07-06 12:19:50 +00:00
first pass restyle of auth methods and rules
This commit is contained in:
@@ -756,11 +756,11 @@
|
|||||||
"rulesErrorDuplicate": "Duplicate rule",
|
"rulesErrorDuplicate": "Duplicate rule",
|
||||||
"rulesErrorDuplicateDescription": "A rule with these settings already exists",
|
"rulesErrorDuplicateDescription": "A rule with these settings already exists",
|
||||||
"rulesErrorInvalidIpAddressRange": "Invalid CIDR",
|
"rulesErrorInvalidIpAddressRange": "Invalid CIDR",
|
||||||
"rulesErrorInvalidIpAddressRangeDescription": "Please enter a valid CIDR value",
|
"rulesErrorInvalidIpAddressRangeDescription": "Enter a valid CIDR range (e.g., 10.0.0.0/8).",
|
||||||
"rulesErrorInvalidUrl": "Invalid URL path",
|
"rulesErrorInvalidUrl": "Invalid path",
|
||||||
"rulesErrorInvalidUrlDescription": "Please enter a valid URL path value",
|
"rulesErrorInvalidUrlDescription": "Enter a valid URL path or pattern (e.g., /api/*).",
|
||||||
"rulesErrorInvalidIpAddress": "Invalid IP",
|
"rulesErrorInvalidIpAddress": "Invalid IP address",
|
||||||
"rulesErrorInvalidIpAddressDescription": "Please enter a valid IP address",
|
"rulesErrorInvalidIpAddressDescription": "Enter a valid IPv4 or IPv6 address.",
|
||||||
"rulesErrorUpdate": "Failed to update rules",
|
"rulesErrorUpdate": "Failed to update rules",
|
||||||
"rulesErrorUpdateDescription": "An error occurred while updating rules",
|
"rulesErrorUpdateDescription": "An error occurred while updating rules",
|
||||||
"rulesUpdated": "Enable Rules",
|
"rulesUpdated": "Enable Rules",
|
||||||
@@ -768,10 +768,17 @@
|
|||||||
"rulesMatchIpAddressRangeDescription": "Enter an address in CIDR format (e.g., 103.21.244.0/22)",
|
"rulesMatchIpAddressRangeDescription": "Enter an address in CIDR format (e.g., 103.21.244.0/22)",
|
||||||
"rulesMatchIpAddress": "Enter an IP address (e.g., 103.21.244.12)",
|
"rulesMatchIpAddress": "Enter an IP address (e.g., 103.21.244.12)",
|
||||||
"rulesMatchUrl": "Enter a URL path or pattern (e.g., /api/v1/todos or /api/v1/*)",
|
"rulesMatchUrl": "Enter a URL path or pattern (e.g., /api/v1/todos or /api/v1/*)",
|
||||||
"rulesErrorInvalidPriority": "Invalid Priority",
|
"rulesErrorInvalidPriority": "Invalid priority",
|
||||||
"rulesErrorInvalidPriorityDescription": "Please enter a valid priority",
|
"rulesErrorInvalidPriorityDescription": "Enter a whole number of 1 or higher.",
|
||||||
"rulesErrorDuplicatePriority": "Duplicate Priorities",
|
"rulesErrorDuplicatePriority": "Duplicate priorities",
|
||||||
"rulesErrorDuplicatePriorityDescription": "Please enter unique priorities",
|
"rulesErrorDuplicatePriorityDescription": "Each rule must have a unique priority number.",
|
||||||
|
"rulesErrorValidation": "Invalid rules",
|
||||||
|
"rulesErrorValidationRuleDescription": "Rule {ruleNumber}: {message}",
|
||||||
|
"rulesErrorValueRequired": "Enter a value for this rule.",
|
||||||
|
"rulesErrorInvalidCountry": "Invalid country",
|
||||||
|
"rulesErrorInvalidCountryDescription": "Select a valid country.",
|
||||||
|
"rulesErrorInvalidAsn": "Invalid ASN",
|
||||||
|
"rulesErrorInvalidAsnDescription": "Enter a valid ASN (e.g., AS15169).",
|
||||||
"ruleUpdated": "Rules updated",
|
"ruleUpdated": "Rules updated",
|
||||||
"ruleUpdatedDescription": "Rules updated successfully",
|
"ruleUpdatedDescription": "Rules updated successfully",
|
||||||
"ruleErrorUpdate": "Operation failed",
|
"ruleErrorUpdate": "Operation failed",
|
||||||
@@ -795,7 +802,7 @@
|
|||||||
"rulesResource": "Resource Rules Configuration",
|
"rulesResource": "Resource Rules Configuration",
|
||||||
"rulesResourceDescription": "Configure rules to control access to the resource",
|
"rulesResourceDescription": "Configure rules to control access to the resource",
|
||||||
"ruleSubmit": "Add Rule",
|
"ruleSubmit": "Add Rule",
|
||||||
"rulesNoOne": "No rules. Add a rule using the form.",
|
"rulesNoOne": "No rules yet.",
|
||||||
"rulesOrder": "Rules are evaluated by priority in ascending order.",
|
"rulesOrder": "Rules are evaluated by priority in ascending order.",
|
||||||
"rulesSubmit": "Save Rules",
|
"rulesSubmit": "Save Rules",
|
||||||
"policyErrorCreate": "Error creating policy",
|
"policyErrorCreate": "Error creating policy",
|
||||||
@@ -806,7 +813,44 @@
|
|||||||
"policyErrorUpdateMessageDescription": "An unexpected error occurred",
|
"policyErrorUpdateMessageDescription": "An unexpected error occurred",
|
||||||
"policyCreatedSuccess": "Resource policy succesfully created",
|
"policyCreatedSuccess": "Resource policy succesfully created",
|
||||||
"policyUpdatedSuccess": "Resource policy succesfully updated",
|
"policyUpdatedSuccess": "Resource policy succesfully updated",
|
||||||
"authMethodsSave": "Save auth methods",
|
"authMethodsSave": "Save Settings",
|
||||||
|
"policyAuthStackTitle": "Authentication",
|
||||||
|
"policyAuthStackDescription": "Control which authentication methods are required to access this resource",
|
||||||
|
"policyAuthOrLogicTitle": "Multiple authentication methods active",
|
||||||
|
"policyAuthOrLogicBanner": "Visitors may authenticate using any one of the active methods below. They do not need to complete all of them.",
|
||||||
|
"policyAuthMethodActive": "Active",
|
||||||
|
"policyAuthMethodOff": "Off",
|
||||||
|
"policyAuthSsoTitle": "Platform SSO",
|
||||||
|
"policyAuthSsoDescription": "Require sign-in through your organization's identity provider",
|
||||||
|
"policyAuthSsoSummary": "{idp} · {users} users, {roles} roles",
|
||||||
|
"policyAuthSsoDefaultIdp": "Default provider",
|
||||||
|
"policyAuthAddDefaultIdentityProvider": "Add Default Identity Provider",
|
||||||
|
"policyAuthOtherMethodsTitle": "Other Methods",
|
||||||
|
"policyAuthOtherMethodsDescription": "Optional methods visitors can use instead of or alongside platform SSO",
|
||||||
|
"policyAuthPasscodeTitle": "Passcode",
|
||||||
|
"policyAuthPasscodeDescription": "Require a shared alphanumeric passcode to access the resource",
|
||||||
|
"policyAuthPasscodeSummary": "Passcode set",
|
||||||
|
"policyAuthPincodeTitle": "PIN Code",
|
||||||
|
"policyAuthPincodeDescription": "A short numeric code required to access the resource",
|
||||||
|
"policyAuthPincodeSummary": "6-digit PIN set",
|
||||||
|
"policyAuthEmailTitle": "Email Whitelist",
|
||||||
|
"policyAuthEmailDescription": "Allow listed email addresses with one-time passwords",
|
||||||
|
"policyAuthEmailSummary": "{count} addresses allowed",
|
||||||
|
"policyAuthEmailOtpCallout": "Enabling email whitelist sends a one-time password to the visitor's email on login.",
|
||||||
|
"policyAuthHeaderAuthTitle": "Basic Header Auth",
|
||||||
|
"policyAuthHeaderAuthDescription": "Validate a custom HTTP header name and value on each request",
|
||||||
|
"policyAuthHeaderAuthSummary": "Header configured",
|
||||||
|
"policyAuthHeaderName": "Header name",
|
||||||
|
"policyAuthHeaderValue": "Expected value",
|
||||||
|
"policyAccessRulesTitle": "Access Rules",
|
||||||
|
"policyAccessRulesEnableDescription": "When enabled, rules are evaluated in descending order until one evaluates as true.",
|
||||||
|
"policyAccessRulesFirstMatch": "Rules are evaluated top to bottom. The first matching rule decides the outcome.",
|
||||||
|
"policyAccessRulesHowItWorks": "Rules match requests by path, IP address, location, or other criteria. Each rule applies an action: bypass authentication, block access, or pass to authentication. If no rule matches, traffic continues to authentication.",
|
||||||
|
"policyAccessRulesFallthroughOff": "When rules are disabled, all traffic passes through to authentication.",
|
||||||
|
"policyAccessRulesFallthroughOn": "When no rule matches, traffic passes through to authentication.",
|
||||||
|
"rulesPlaceholderCidr": "10.0.0.0/8",
|
||||||
|
"rulesPlaceholderPath": "/admin/*",
|
||||||
|
"rulesPlaceholderGeo": "RU, KP",
|
||||||
"rulesSave": "Save Rules",
|
"rulesSave": "Save Rules",
|
||||||
"resourceErrorCreate": "Error creating resource",
|
"resourceErrorCreate": "Error creating resource",
|
||||||
"resourceErrorCreateDescription": "An error occurred when creating the resource",
|
"resourceErrorCreateDescription": "An error occurred when creating the resource",
|
||||||
@@ -3045,7 +3089,7 @@
|
|||||||
"enterConfirmation": "Enter confirmation",
|
"enterConfirmation": "Enter confirmation",
|
||||||
"blueprintViewDetails": "Details",
|
"blueprintViewDetails": "Details",
|
||||||
"defaultIdentityProvider": "Default Identity Provider",
|
"defaultIdentityProvider": "Default Identity Provider",
|
||||||
"defaultIdentityProviderDescription": "When a default identity provider is selected, the user will be automatically redirected to the provider for authentication.",
|
"defaultIdentityProviderDescription": "The user will be automatically redirected to this identity provider for authentication.",
|
||||||
"editInternalResourceDialogNetworkSettings": "Network Settings",
|
"editInternalResourceDialogNetworkSettings": "Network Settings",
|
||||||
"editInternalResourceDialogAccessPolicy": "Access Policy",
|
"editInternalResourceDialogAccessPolicy": "Access Policy",
|
||||||
"editInternalResourceDialogAddRoles": "Add Roles",
|
"editInternalResourceDialogAddRoles": "Add Roles",
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import ActionBanner from "@app/components/ActionBanner";
|
|
||||||
import { EditPolicyForm } from "@app/components/resource-policy/EditPolicyForm";
|
import { EditPolicyForm } from "@app/components/resource-policy/EditPolicyForm";
|
||||||
import {
|
import {
|
||||||
SettingsContainer,
|
SettingsContainer,
|
||||||
@@ -45,9 +44,8 @@ import { tierMatrix, TierFeature } from "@server/lib/billing/tierMatrix";
|
|||||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import SetResourcePasswordForm from "@app/components/SetResourcePasswordForm";
|
import SetResourcePasswordForm from "@app/components/SetResourcePasswordForm";
|
||||||
import { Binary, Bot, InfoIcon, Key } from "lucide-react";
|
import { Binary, Bot, InfoIcon, Key } from "lucide-react";
|
||||||
import { ArrowRightIcon, CheckIcon, ShieldAlertIcon } from "lucide-react";
|
import { CheckIcon } from "lucide-react";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import Link from "next/link";
|
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { useEffect, useState, useTransition } from "react";
|
import { useEffect, useState, useTransition } from "react";
|
||||||
import { useForm, useWatch } from "react-hook-form";
|
import { useForm, useWatch } from "react-hook-form";
|
||||||
@@ -184,10 +182,6 @@ export default function ResourceAuthenticationPage() {
|
|||||||
return <></>;
|
return <></>;
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log({
|
|
||||||
shared: policies.sharedPolicy
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<SettingsContainer>
|
<SettingsContainer>
|
||||||
@@ -314,30 +308,6 @@ export default function ResourceAuthenticationPage() {
|
|||||||
policy={policies.sharedPolicy}
|
policy={policies.sharedPolicy}
|
||||||
key={policies.sharedPolicy.resourcePolicyId}
|
key={policies.sharedPolicy.resourcePolicyId}
|
||||||
>
|
>
|
||||||
<ActionBanner
|
|
||||||
variant="info"
|
|
||||||
title={t("resourcePolicyShared")}
|
|
||||||
titleIcon={
|
|
||||||
<ShieldAlertIcon className="w-5 h-5" />
|
|
||||||
}
|
|
||||||
description={t(
|
|
||||||
"resourcePolicySharedDescription"
|
|
||||||
)}
|
|
||||||
actions={
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
className="gap-2"
|
|
||||||
asChild
|
|
||||||
>
|
|
||||||
<Link
|
|
||||||
href={`/${org.org.orgId}/settings/policies/resources/public/${policies.sharedPolicy.niceId}`}
|
|
||||||
>
|
|
||||||
{t("editSharedPolicy")}
|
|
||||||
<ArrowRightIcon className="size-4" />
|
|
||||||
</Link>
|
|
||||||
</Button>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<EditPolicyForm
|
<EditPolicyForm
|
||||||
resourceId={resource.resourceId}
|
resourceId={resource.resourceId}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,530 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import {
|
|
||||||
SettingsSection,
|
|
||||||
SettingsSectionBody,
|
|
||||||
SettingsSectionDescription,
|
|
||||||
SettingsSectionForm,
|
|
||||||
SettingsSectionHeader,
|
|
||||||
SettingsSectionTitle
|
|
||||||
} from "@app/components/Settings";
|
|
||||||
|
|
||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
|
||||||
import { useTranslations } from "next-intl";
|
|
||||||
|
|
||||||
import z from "zod";
|
|
||||||
|
|
||||||
import { createPolicySchema, type PolicyFormValues } from ".";
|
|
||||||
|
|
||||||
import { SwitchInput } from "@app/components/SwitchInput";
|
|
||||||
import { Button } from "@app/components/ui/button";
|
|
||||||
import {
|
|
||||||
Credenza,
|
|
||||||
CredenzaBody,
|
|
||||||
CredenzaClose,
|
|
||||||
CredenzaContent,
|
|
||||||
CredenzaDescription,
|
|
||||||
CredenzaFooter,
|
|
||||||
CredenzaHeader,
|
|
||||||
CredenzaTitle
|
|
||||||
} from "@app/components/Credenza";
|
|
||||||
import {
|
|
||||||
Form,
|
|
||||||
FormControl,
|
|
||||||
FormField,
|
|
||||||
FormItem,
|
|
||||||
FormLabel,
|
|
||||||
FormMessage
|
|
||||||
} from "@app/components/ui/form";
|
|
||||||
import { Input } from "@app/components/ui/input";
|
|
||||||
import {
|
|
||||||
InputOTP,
|
|
||||||
InputOTPGroup,
|
|
||||||
InputOTPSlot
|
|
||||||
} from "@app/components/ui/input-otp";
|
|
||||||
|
|
||||||
import { cn } from "@app/lib/cn";
|
|
||||||
import { Binary, Bot, Key, Plus } from "lucide-react";
|
|
||||||
|
|
||||||
import { useEffect, useState } from "react";
|
|
||||||
import { type UseFormReturn, useForm, useWatch } from "react-hook-form";
|
|
||||||
|
|
||||||
// ─── CreatePolicyAuthMethodsSectionForm ───────────────────────────────────────
|
|
||||||
|
|
||||||
const setPasswordSchema = z.object({
|
|
||||||
password: z.string().min(4).max(100)
|
|
||||||
});
|
|
||||||
|
|
||||||
const setPincodeSchema = z.object({
|
|
||||||
pincode: z.string().length(6)
|
|
||||||
});
|
|
||||||
|
|
||||||
const setHeaderAuthSchema = z.object({
|
|
||||||
user: z.string().min(4).max(100),
|
|
||||||
password: z.string().min(4).max(100),
|
|
||||||
extendedCompatibility: z.boolean()
|
|
||||||
});
|
|
||||||
|
|
||||||
export type CreatePolicyAuthMethodsSectionFormProps = {
|
|
||||||
form: UseFormReturn<PolicyFormValues, any, any>;
|
|
||||||
};
|
|
||||||
|
|
||||||
export function CreatePolicyAuthMethodsSectionForm({
|
|
||||||
form: parentForm
|
|
||||||
}: CreatePolicyAuthMethodsSectionFormProps) {
|
|
||||||
const t = useTranslations();
|
|
||||||
const [isExpanded, setIsExpanded] = useState(false);
|
|
||||||
const [isSetPasswordOpen, setIsSetPasswordOpen] = useState(false);
|
|
||||||
const [isSetPincodeOpen, setIsSetPincodeOpen] = useState(false);
|
|
||||||
const [isSetHeaderAuthOpen, setIsSetHeaderAuthOpen] = useState(false);
|
|
||||||
|
|
||||||
const form = useForm({
|
|
||||||
resolver: zodResolver(
|
|
||||||
createPolicySchema.pick({
|
|
||||||
password: true,
|
|
||||||
pincode: true,
|
|
||||||
headerAuth: true
|
|
||||||
})
|
|
||||||
),
|
|
||||||
defaultValues: {
|
|
||||||
password: null,
|
|
||||||
pincode: null,
|
|
||||||
headerAuth: null
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const subscription = form.watch((values) => {
|
|
||||||
parentForm.setValue("password", values.password as any);
|
|
||||||
parentForm.setValue("pincode", values.pincode as any);
|
|
||||||
parentForm.setValue("headerAuth", values.headerAuth as any);
|
|
||||||
});
|
|
||||||
return () => subscription.unsubscribe();
|
|
||||||
}, [form, parentForm]);
|
|
||||||
|
|
||||||
const password = useWatch({
|
|
||||||
control: form.control,
|
|
||||||
name: "password"
|
|
||||||
});
|
|
||||||
const pincode = useWatch({
|
|
||||||
control: form.control,
|
|
||||||
name: "pincode"
|
|
||||||
});
|
|
||||||
const headerAuth = useWatch({
|
|
||||||
control: form.control,
|
|
||||||
name: "headerAuth"
|
|
||||||
});
|
|
||||||
|
|
||||||
const passwordForm = useForm({
|
|
||||||
resolver: zodResolver(setPasswordSchema),
|
|
||||||
defaultValues: { password: "" }
|
|
||||||
});
|
|
||||||
|
|
||||||
const pincodeForm = useForm({
|
|
||||||
resolver: zodResolver(setPincodeSchema),
|
|
||||||
defaultValues: { pincode: "" }
|
|
||||||
});
|
|
||||||
|
|
||||||
const headerAuthForm = useForm({
|
|
||||||
resolver: zodResolver(setHeaderAuthSchema),
|
|
||||||
defaultValues: { user: "", password: "", extendedCompatibility: true }
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!isExpanded) {
|
|
||||||
return (
|
|
||||||
<SettingsSection>
|
|
||||||
<SettingsSectionHeader>
|
|
||||||
<SettingsSectionTitle>
|
|
||||||
{t("resourceAuthMethods")}
|
|
||||||
</SettingsSectionTitle>
|
|
||||||
<SettingsSectionDescription>
|
|
||||||
{t("resourcePolicyAuthMethodsDescription")}
|
|
||||||
</SettingsSectionDescription>
|
|
||||||
</SettingsSectionHeader>
|
|
||||||
<SettingsSectionBody>
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="outline"
|
|
||||||
onClick={() => setIsExpanded(true)}
|
|
||||||
>
|
|
||||||
<Plus className="mr-2 h-4 w-4" />
|
|
||||||
{t("resourcePolicyAuthMethodAdd")}
|
|
||||||
</Button>
|
|
||||||
</SettingsSectionBody>
|
|
||||||
</SettingsSection>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
{/* Password Credenza */}
|
|
||||||
<Credenza
|
|
||||||
open={isSetPasswordOpen}
|
|
||||||
onOpenChange={(val) => {
|
|
||||||
setIsSetPasswordOpen(val);
|
|
||||||
if (!val) passwordForm.reset();
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<CredenzaContent>
|
|
||||||
<CredenzaHeader>
|
|
||||||
<CredenzaTitle>
|
|
||||||
{t("resourcePasswordSetupTitle")}
|
|
||||||
</CredenzaTitle>
|
|
||||||
<CredenzaDescription>
|
|
||||||
{t("resourcePasswordSetupTitleDescription")}
|
|
||||||
</CredenzaDescription>
|
|
||||||
</CredenzaHeader>
|
|
||||||
<CredenzaBody>
|
|
||||||
<Form {...passwordForm}>
|
|
||||||
<form
|
|
||||||
onSubmit={passwordForm.handleSubmit((data) => {
|
|
||||||
form.setValue("password", data);
|
|
||||||
setIsSetPasswordOpen(false);
|
|
||||||
passwordForm.reset();
|
|
||||||
})}
|
|
||||||
className="space-y-4"
|
|
||||||
id="set-password-form"
|
|
||||||
>
|
|
||||||
<FormField
|
|
||||||
control={passwordForm.control}
|
|
||||||
name="password"
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem>
|
|
||||||
<FormLabel>
|
|
||||||
{t("password")}
|
|
||||||
</FormLabel>
|
|
||||||
<FormControl>
|
|
||||||
<Input
|
|
||||||
autoComplete="off"
|
|
||||||
type="password"
|
|
||||||
{...field}
|
|
||||||
/>
|
|
||||||
</FormControl>
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</form>
|
|
||||||
</Form>
|
|
||||||
</CredenzaBody>
|
|
||||||
<CredenzaFooter>
|
|
||||||
<CredenzaClose asChild>
|
|
||||||
<Button variant="outline">{t("close")}</Button>
|
|
||||||
</CredenzaClose>
|
|
||||||
<Button type="submit" form="set-password-form">
|
|
||||||
{t("resourcePasswordSubmit")}
|
|
||||||
</Button>
|
|
||||||
</CredenzaFooter>
|
|
||||||
</CredenzaContent>
|
|
||||||
</Credenza>
|
|
||||||
|
|
||||||
{/* Pincode Credenza */}
|
|
||||||
<Credenza
|
|
||||||
open={isSetPincodeOpen}
|
|
||||||
onOpenChange={(val) => {
|
|
||||||
setIsSetPincodeOpen(val);
|
|
||||||
if (!val) pincodeForm.reset();
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<CredenzaContent>
|
|
||||||
<CredenzaHeader>
|
|
||||||
<CredenzaTitle>
|
|
||||||
{t("resourcePincodeSetupTitle")}
|
|
||||||
</CredenzaTitle>
|
|
||||||
<CredenzaDescription>
|
|
||||||
{t("resourcePincodeSetupTitleDescription")}
|
|
||||||
</CredenzaDescription>
|
|
||||||
</CredenzaHeader>
|
|
||||||
<CredenzaBody>
|
|
||||||
<Form {...pincodeForm}>
|
|
||||||
<form
|
|
||||||
onSubmit={pincodeForm.handleSubmit((data) => {
|
|
||||||
form.setValue("pincode", data);
|
|
||||||
setIsSetPincodeOpen(false);
|
|
||||||
pincodeForm.reset();
|
|
||||||
})}
|
|
||||||
className="space-y-4"
|
|
||||||
id="set-pincode-form"
|
|
||||||
>
|
|
||||||
<FormField
|
|
||||||
control={pincodeForm.control}
|
|
||||||
name="pincode"
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem>
|
|
||||||
<FormLabel>
|
|
||||||
{t("resourcePincode")}
|
|
||||||
</FormLabel>
|
|
||||||
<FormControl>
|
|
||||||
<div className="flex justify-center">
|
|
||||||
<InputOTP
|
|
||||||
autoComplete="false"
|
|
||||||
maxLength={6}
|
|
||||||
{...field}
|
|
||||||
>
|
|
||||||
<InputOTPGroup className="flex">
|
|
||||||
<InputOTPSlot
|
|
||||||
index={0}
|
|
||||||
obscured
|
|
||||||
/>
|
|
||||||
<InputOTPSlot
|
|
||||||
index={1}
|
|
||||||
obscured
|
|
||||||
/>
|
|
||||||
<InputOTPSlot
|
|
||||||
index={2}
|
|
||||||
obscured
|
|
||||||
/>
|
|
||||||
<InputOTPSlot
|
|
||||||
index={3}
|
|
||||||
obscured
|
|
||||||
/>
|
|
||||||
<InputOTPSlot
|
|
||||||
index={4}
|
|
||||||
obscured
|
|
||||||
/>
|
|
||||||
<InputOTPSlot
|
|
||||||
index={5}
|
|
||||||
obscured
|
|
||||||
/>
|
|
||||||
</InputOTPGroup>
|
|
||||||
</InputOTP>
|
|
||||||
</div>
|
|
||||||
</FormControl>
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</form>
|
|
||||||
</Form>
|
|
||||||
</CredenzaBody>
|
|
||||||
<CredenzaFooter>
|
|
||||||
<CredenzaClose asChild>
|
|
||||||
<Button variant="outline">{t("close")}</Button>
|
|
||||||
</CredenzaClose>
|
|
||||||
<Button type="submit" form="set-pincode-form">
|
|
||||||
{t("resourcePincodeSubmit")}
|
|
||||||
</Button>
|
|
||||||
</CredenzaFooter>
|
|
||||||
</CredenzaContent>
|
|
||||||
</Credenza>
|
|
||||||
|
|
||||||
{/* Header Auth Credenza */}
|
|
||||||
<Credenza
|
|
||||||
open={isSetHeaderAuthOpen}
|
|
||||||
onOpenChange={(val) => {
|
|
||||||
setIsSetHeaderAuthOpen(val);
|
|
||||||
if (!val) headerAuthForm.reset();
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<CredenzaContent>
|
|
||||||
<CredenzaHeader>
|
|
||||||
<CredenzaTitle>
|
|
||||||
{t("resourceHeaderAuthSetupTitle")}
|
|
||||||
</CredenzaTitle>
|
|
||||||
<CredenzaDescription>
|
|
||||||
{t("resourceHeaderAuthSetupTitleDescription")}
|
|
||||||
</CredenzaDescription>
|
|
||||||
</CredenzaHeader>
|
|
||||||
<CredenzaBody>
|
|
||||||
<Form {...headerAuthForm}>
|
|
||||||
<form
|
|
||||||
onSubmit={headerAuthForm.handleSubmit(
|
|
||||||
(data) => {
|
|
||||||
form.setValue("headerAuth", data);
|
|
||||||
setIsSetHeaderAuthOpen(false);
|
|
||||||
headerAuthForm.reset();
|
|
||||||
}
|
|
||||||
)}
|
|
||||||
className="space-y-4"
|
|
||||||
id="set-header-auth-form"
|
|
||||||
>
|
|
||||||
<FormField
|
|
||||||
control={headerAuthForm.control}
|
|
||||||
name="user"
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem>
|
|
||||||
<FormLabel>{t("user")}</FormLabel>
|
|
||||||
<FormControl>
|
|
||||||
<Input
|
|
||||||
autoComplete="off"
|
|
||||||
type="text"
|
|
||||||
{...field}
|
|
||||||
/>
|
|
||||||
</FormControl>
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
<FormField
|
|
||||||
control={headerAuthForm.control}
|
|
||||||
name="password"
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem>
|
|
||||||
<FormLabel>
|
|
||||||
{t("password")}
|
|
||||||
</FormLabel>
|
|
||||||
<FormControl>
|
|
||||||
<Input
|
|
||||||
autoComplete="off"
|
|
||||||
type="password"
|
|
||||||
{...field}
|
|
||||||
/>
|
|
||||||
</FormControl>
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
<FormField
|
|
||||||
control={headerAuthForm.control}
|
|
||||||
name="extendedCompatibility"
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem>
|
|
||||||
<FormControl>
|
|
||||||
<SwitchInput
|
|
||||||
id="header-auth-compatibility-toggle"
|
|
||||||
label={t(
|
|
||||||
"headerAuthCompatibility"
|
|
||||||
)}
|
|
||||||
description={t(
|
|
||||||
"headerAuthCompatibilityInfo"
|
|
||||||
)}
|
|
||||||
checked={field.value}
|
|
||||||
onCheckedChange={
|
|
||||||
field.onChange
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</FormControl>
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</form>
|
|
||||||
</Form>
|
|
||||||
</CredenzaBody>
|
|
||||||
<CredenzaFooter>
|
|
||||||
<CredenzaClose asChild>
|
|
||||||
<Button variant="outline">{t("close")}</Button>
|
|
||||||
</CredenzaClose>
|
|
||||||
<Button type="submit" form="set-header-auth-form">
|
|
||||||
{t("resourceHeaderAuthSubmit")}
|
|
||||||
</Button>
|
|
||||||
</CredenzaFooter>
|
|
||||||
</CredenzaContent>
|
|
||||||
</Credenza>
|
|
||||||
|
|
||||||
<SettingsSection>
|
|
||||||
<SettingsSectionHeader>
|
|
||||||
<SettingsSectionTitle>
|
|
||||||
{t("resourceAuthMethods")}
|
|
||||||
</SettingsSectionTitle>
|
|
||||||
<SettingsSectionDescription>
|
|
||||||
{t("resourcePolicyAuthMethodsDescription")}
|
|
||||||
</SettingsSectionDescription>
|
|
||||||
</SettingsSectionHeader>
|
|
||||||
<SettingsSectionBody>
|
|
||||||
<SettingsSectionForm>
|
|
||||||
{/* Password row */}
|
|
||||||
<div className="flex items-center justify-between border rounded-md p-2 mb-4">
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
"flex items-center text-sm space-x-2",
|
|
||||||
password && "text-green-500"
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<Key size="14" />
|
|
||||||
<span>
|
|
||||||
{t("resourcePasswordProtection", {
|
|
||||||
status: password
|
|
||||||
? t("enabled")
|
|
||||||
: t("disabled")
|
|
||||||
})}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="secondary"
|
|
||||||
size="sm"
|
|
||||||
onClick={
|
|
||||||
password
|
|
||||||
? () => form.setValue("password", null)
|
|
||||||
: () => setIsSetPasswordOpen(true)
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{password
|
|
||||||
? t("passwordRemove")
|
|
||||||
: t("passwordAdd")}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Pincode row */}
|
|
||||||
<div className="flex items-center justify-between border rounded-md p-2">
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
"flex items-center space-x-2 text-sm",
|
|
||||||
pincode && "text-green-500"
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<Binary size="14" />
|
|
||||||
<span>
|
|
||||||
{t("resourcePincodeProtection", {
|
|
||||||
status: pincode
|
|
||||||
? t("enabled")
|
|
||||||
: t("disabled")
|
|
||||||
})}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="secondary"
|
|
||||||
size="sm"
|
|
||||||
onClick={
|
|
||||||
pincode
|
|
||||||
? () => form.setValue("pincode", null)
|
|
||||||
: () => setIsSetPincodeOpen(true)
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{pincode ? t("pincodeRemove") : t("pincodeAdd")}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Header auth row */}
|
|
||||||
<div className="flex items-center justify-between border rounded-md p-2">
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
"flex items-center space-x-2 text-sm",
|
|
||||||
headerAuth && "text-green-500"
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<Bot size="14" />
|
|
||||||
<span>
|
|
||||||
{headerAuth
|
|
||||||
? t(
|
|
||||||
"resourceHeaderAuthProtectionEnabled"
|
|
||||||
)
|
|
||||||
: t(
|
|
||||||
"resourceHeaderAuthProtectionDisabled"
|
|
||||||
)}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="secondary"
|
|
||||||
size="sm"
|
|
||||||
onClick={
|
|
||||||
headerAuth
|
|
||||||
? () =>
|
|
||||||
form.setValue("headerAuth", null)
|
|
||||||
: () => setIsSetHeaderAuthOpen(true)
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{headerAuth
|
|
||||||
? t("headerAuthRemove")
|
|
||||||
: t("headerAuthAdd")}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</SettingsSectionForm>
|
|
||||||
</SettingsSectionBody>
|
|
||||||
</SettingsSection>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -19,7 +19,11 @@ import { build } from "@server/build";
|
|||||||
import { UserType } from "@server/types/UserTypes";
|
import { UserType } from "@server/types/UserTypes";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import { type PolicyFormValues, createPolicySchema } from ".";
|
import {
|
||||||
|
type PolicyFormValues,
|
||||||
|
createPolicySchema,
|
||||||
|
createPolicySchemaWithI18n
|
||||||
|
} from ".";
|
||||||
import { toast } from "@app/hooks/useToast";
|
import { toast } from "@app/hooks/useToast";
|
||||||
import { createApiClient, formatAxiosError } from "@app/lib/api";
|
import { createApiClient, formatAxiosError } from "@app/lib/api";
|
||||||
import { orgs, type ResourcePolicy } from "@server/db";
|
import { orgs, type ResourcePolicy } from "@server/db";
|
||||||
@@ -37,10 +41,8 @@ import {
|
|||||||
import { Input } from "@app/components/ui/input";
|
import { Input } from "@app/components/ui/input";
|
||||||
import { useMemo, useTransition } from "react";
|
import { useMemo, useTransition } from "react";
|
||||||
import { useForm } from "react-hook-form";
|
import { useForm } from "react-hook-form";
|
||||||
import { CreatePolicyUsersRolesSectionForm } from "./CreatePolicyUserRolesSectionForm";
|
import { PolicyAuthStackSection } from "./PolicyAuthStackSection";
|
||||||
import { CreatePolicyAuthMethodsSectionForm } from "./CreatePolicyAuthMethodsSectionForm";
|
import { PolicyAccessRulesSection } from "./PolicyAccessRulesSection";
|
||||||
import { CreatePolicyOtpEmailSectionForm } from "./CreatePolicyOtpEmailSectionForm";
|
|
||||||
import { CreatePolicyRulesSectionForm } from "./CreatePolicyRulesSectionForm";
|
|
||||||
import { PaidFeaturesAlert } from "@app/components/PaidFeaturesAlert";
|
import { PaidFeaturesAlert } from "@app/components/PaidFeaturesAlert";
|
||||||
import { tierMatrix, TierFeature } from "@server/lib/billing/tierMatrix";
|
import { tierMatrix, TierFeature } from "@server/lib/billing/tierMatrix";
|
||||||
|
|
||||||
@@ -78,8 +80,13 @@ export function CreatePolicyForm({}: CreatePolicyFormProps) {
|
|||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const policySchema = useMemo(
|
||||||
|
() => createPolicySchemaWithI18n(t, createPolicySchema),
|
||||||
|
[t]
|
||||||
|
);
|
||||||
|
|
||||||
const form = useForm<PolicyFormValues>({
|
const form = useForm<PolicyFormValues>({
|
||||||
resolver: zodResolver(createPolicySchema) as any,
|
resolver: zodResolver(policySchema) as any,
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
name: "",
|
name: "",
|
||||||
sso: true,
|
sso: true,
|
||||||
@@ -245,18 +252,17 @@ export function CreatePolicyForm({}: CreatePolicyFormProps) {
|
|||||||
</SettingsSectionBody>
|
</SettingsSectionBody>
|
||||||
</SettingsSection>
|
</SettingsSection>
|
||||||
|
|
||||||
<CreatePolicyUsersRolesSectionForm
|
<PolicyAuthStackSection
|
||||||
|
mode="create"
|
||||||
form={form}
|
form={form}
|
||||||
|
orgId={org.org.orgId}
|
||||||
|
allIdps={allIdps}
|
||||||
allRoles={allRoles}
|
allRoles={allRoles}
|
||||||
allUsers={allUsers}
|
allUsers={allUsers}
|
||||||
allIdps={allIdps}
|
|
||||||
/>
|
|
||||||
<CreatePolicyAuthMethodsSectionForm form={form} />
|
|
||||||
<CreatePolicyOtpEmailSectionForm
|
|
||||||
form={form}
|
|
||||||
emailEnabled={env.email.emailEnabled}
|
emailEnabled={env.email.emailEnabled}
|
||||||
/>
|
/>
|
||||||
<CreatePolicyRulesSectionForm
|
<PolicyAccessRulesSection
|
||||||
|
mode="create"
|
||||||
form={form}
|
form={form}
|
||||||
isMaxmindAvailable={isMaxmindAvailable}
|
isMaxmindAvailable={isMaxmindAvailable}
|
||||||
isMaxmindAsnAvailable={isMaxmindAsnAvailable}
|
isMaxmindAsnAvailable={isMaxmindAsnAvailable}
|
||||||
|
|||||||
@@ -1,213 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import {
|
|
||||||
SettingsSection,
|
|
||||||
SettingsSectionBody,
|
|
||||||
SettingsSectionDescription,
|
|
||||||
SettingsSectionForm,
|
|
||||||
SettingsSectionHeader,
|
|
||||||
SettingsSectionTitle
|
|
||||||
} from "@app/components/Settings";
|
|
||||||
|
|
||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
|
||||||
import { useTranslations } from "next-intl";
|
|
||||||
|
|
||||||
import z from "zod";
|
|
||||||
|
|
||||||
import { createPolicySchema, type PolicyFormValues } from ".";
|
|
||||||
|
|
||||||
import { SwitchInput } from "@app/components/SwitchInput";
|
|
||||||
import { Tag, TagInput } from "@app/components/tags/tag-input";
|
|
||||||
import { Alert, AlertDescription, AlertTitle } from "@app/components/ui/alert";
|
|
||||||
import { Button } from "@app/components/ui/button";
|
|
||||||
import {
|
|
||||||
Form,
|
|
||||||
FormControl,
|
|
||||||
FormDescription,
|
|
||||||
FormField,
|
|
||||||
FormItem,
|
|
||||||
FormLabel
|
|
||||||
} from "@app/components/ui/form";
|
|
||||||
import { InfoPopup } from "@app/components/ui/info-popup";
|
|
||||||
|
|
||||||
import { InfoIcon, Plus } from "lucide-react";
|
|
||||||
|
|
||||||
import { useEffect, useState } from "react";
|
|
||||||
import { type UseFormReturn, useForm, useWatch } from "react-hook-form";
|
|
||||||
|
|
||||||
// ─── CreatePolicyOtpEmailSectionForm ──────────────────────────────────────────
|
|
||||||
|
|
||||||
export type CreatePolicyOtpEmailSectionFormProps = {
|
|
||||||
form: UseFormReturn<PolicyFormValues, any, any>;
|
|
||||||
emailEnabled: boolean;
|
|
||||||
};
|
|
||||||
|
|
||||||
export function CreatePolicyOtpEmailSectionForm({
|
|
||||||
form: parentForm,
|
|
||||||
emailEnabled
|
|
||||||
}: CreatePolicyOtpEmailSectionFormProps) {
|
|
||||||
const t = useTranslations();
|
|
||||||
const [isExpanded, setIsExpanded] = useState(false);
|
|
||||||
const [activeEmailTagIndex, setActiveEmailTagIndex] = useState<
|
|
||||||
number | null
|
|
||||||
>(null);
|
|
||||||
|
|
||||||
const form = useForm({
|
|
||||||
resolver: zodResolver(
|
|
||||||
createPolicySchema.pick({
|
|
||||||
emailWhitelistEnabled: true,
|
|
||||||
emails: true
|
|
||||||
})
|
|
||||||
),
|
|
||||||
defaultValues: {
|
|
||||||
emailWhitelistEnabled: false,
|
|
||||||
emails: []
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const subscription = form.watch((values) => {
|
|
||||||
parentForm.setValue(
|
|
||||||
"emailWhitelistEnabled",
|
|
||||||
values.emailWhitelistEnabled as boolean
|
|
||||||
);
|
|
||||||
parentForm.setValue("emails", values.emails as [Tag, ...Tag[]]);
|
|
||||||
});
|
|
||||||
return () => subscription.unsubscribe();
|
|
||||||
}, [form, parentForm]);
|
|
||||||
|
|
||||||
const whitelistEnabled = useWatch({
|
|
||||||
control: form.control,
|
|
||||||
name: "emailWhitelistEnabled"
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!isExpanded) {
|
|
||||||
return (
|
|
||||||
<SettingsSection>
|
|
||||||
<SettingsSectionHeader>
|
|
||||||
<SettingsSectionTitle>
|
|
||||||
{t("otpEmailTitle")}
|
|
||||||
</SettingsSectionTitle>
|
|
||||||
<SettingsSectionDescription>
|
|
||||||
{t("otpEmailTitleDescription")}
|
|
||||||
</SettingsSectionDescription>
|
|
||||||
</SettingsSectionHeader>
|
|
||||||
<SettingsSectionBody>
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="outline"
|
|
||||||
onClick={() => setIsExpanded(true)}
|
|
||||||
>
|
|
||||||
<Plus className="mr-2 h-4 w-4" />
|
|
||||||
{t("resourcePolicyOtpEmailAdd")}
|
|
||||||
</Button>
|
|
||||||
</SettingsSectionBody>
|
|
||||||
</SettingsSection>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Form {...form}>
|
|
||||||
<SettingsSection>
|
|
||||||
<SettingsSectionHeader>
|
|
||||||
<SettingsSectionTitle>
|
|
||||||
{t("otpEmailTitle")}
|
|
||||||
</SettingsSectionTitle>
|
|
||||||
<SettingsSectionDescription>
|
|
||||||
{t("otpEmailTitleDescription")}
|
|
||||||
</SettingsSectionDescription>
|
|
||||||
</SettingsSectionHeader>
|
|
||||||
<SettingsSectionBody>
|
|
||||||
<SettingsSectionForm>
|
|
||||||
{!emailEnabled && (
|
|
||||||
<Alert variant="neutral" className="mb-4">
|
|
||||||
<InfoIcon className="h-4 w-4" />
|
|
||||||
<AlertTitle className="font-semibold">
|
|
||||||
{t("otpEmailSmtpRequired")}
|
|
||||||
</AlertTitle>
|
|
||||||
<AlertDescription>
|
|
||||||
{t("otpEmailSmtpRequiredDescription")}
|
|
||||||
</AlertDescription>
|
|
||||||
</Alert>
|
|
||||||
)}
|
|
||||||
<SwitchInput
|
|
||||||
id="whitelist-toggle"
|
|
||||||
label={t("otpEmailWhitelist")}
|
|
||||||
defaultChecked={false}
|
|
||||||
onCheckedChange={(val) => {
|
|
||||||
form.setValue("emailWhitelistEnabled", val);
|
|
||||||
}}
|
|
||||||
disabled={!emailEnabled}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{whitelistEnabled && emailEnabled && (
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name="emails"
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem>
|
|
||||||
<FormLabel>
|
|
||||||
<InfoPopup
|
|
||||||
text={t("otpEmailWhitelistList")}
|
|
||||||
info={t(
|
|
||||||
"otpEmailWhitelistListDescription"
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</FormLabel>
|
|
||||||
<FormControl>
|
|
||||||
{/* @ts-ignore */}
|
|
||||||
<TagInput
|
|
||||||
{...field}
|
|
||||||
activeTagIndex={
|
|
||||||
activeEmailTagIndex
|
|
||||||
}
|
|
||||||
size="sm"
|
|
||||||
validateTag={(tag) => {
|
|
||||||
return z
|
|
||||||
.email()
|
|
||||||
.or(
|
|
||||||
z
|
|
||||||
.string()
|
|
||||||
.regex(
|
|
||||||
/^\*@[\w.-]+\.[a-zA-Z]{2,}$/,
|
|
||||||
{
|
|
||||||
message:
|
|
||||||
t(
|
|
||||||
"otpEmailErrorInvalid"
|
|
||||||
)
|
|
||||||
}
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.safeParse(tag).success;
|
|
||||||
}}
|
|
||||||
setActiveTagIndex={
|
|
||||||
setActiveEmailTagIndex
|
|
||||||
}
|
|
||||||
placeholder={t("otpEmailEnter")}
|
|
||||||
tags={form.getValues().emails}
|
|
||||||
setTags={(newEmails) => {
|
|
||||||
form.setValue(
|
|
||||||
"emails",
|
|
||||||
newEmails as [
|
|
||||||
Tag,
|
|
||||||
...Tag[]
|
|
||||||
]
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
allowDuplicates={false}
|
|
||||||
sortTags={true}
|
|
||||||
/>
|
|
||||||
</FormControl>
|
|
||||||
<FormDescription>
|
|
||||||
{t("otpEmailEnterDescription")}
|
|
||||||
</FormDescription>
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</SettingsSectionForm>
|
|
||||||
</SettingsSectionBody>
|
|
||||||
</SettingsSection>
|
|
||||||
</Form>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -11,13 +11,15 @@ import {
|
|||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
|
|
||||||
import z from "zod";
|
import { createPolicyRulesSectionSchema, type PolicyFormValues } from ".";
|
||||||
|
|
||||||
import { createPolicySchema, type PolicyFormValues } from ".";
|
|
||||||
import { toast } from "@app/hooks/useToast";
|
import { toast } from "@app/hooks/useToast";
|
||||||
|
import {
|
||||||
|
validatePolicyRulePriority,
|
||||||
|
validatePolicyRuleValue
|
||||||
|
} from "./policy-access-rule-validation";
|
||||||
|
|
||||||
import { SwitchInput } from "@app/components/SwitchInput";
|
|
||||||
import { Button } from "@app/components/ui/button";
|
import { Button } from "@app/components/ui/button";
|
||||||
|
import { DataTableEmptyState } from "@app/components/ui/data-table-empty-state";
|
||||||
import {
|
import {
|
||||||
Command,
|
Command,
|
||||||
CommandEmpty,
|
CommandEmpty,
|
||||||
@@ -26,15 +28,6 @@ import {
|
|||||||
CommandItem,
|
CommandItem,
|
||||||
CommandList
|
CommandList
|
||||||
} from "@app/components/ui/command";
|
} from "@app/components/ui/command";
|
||||||
import {
|
|
||||||
Form,
|
|
||||||
FormControl,
|
|
||||||
FormField,
|
|
||||||
FormItem,
|
|
||||||
FormLabel,
|
|
||||||
FormMessage
|
|
||||||
} from "@app/components/ui/form";
|
|
||||||
import { InfoPopup } from "@app/components/ui/info-popup";
|
|
||||||
import { Input } from "@app/components/ui/input";
|
import { Input } from "@app/components/ui/input";
|
||||||
import {
|
import {
|
||||||
Popover,
|
Popover,
|
||||||
@@ -60,11 +53,6 @@ import {
|
|||||||
|
|
||||||
import { MAJOR_ASNS } from "@server/db/asns";
|
import { MAJOR_ASNS } from "@server/db/asns";
|
||||||
import { COUNTRIES } from "@server/db/countries";
|
import { COUNTRIES } from "@server/db/countries";
|
||||||
import {
|
|
||||||
isValidCIDR,
|
|
||||||
isValidIP,
|
|
||||||
isValidUrlGlobPattern
|
|
||||||
} from "@server/lib/validators";
|
|
||||||
import {
|
import {
|
||||||
ColumnDef,
|
ColumnDef,
|
||||||
flexRender,
|
flexRender,
|
||||||
@@ -79,14 +67,10 @@ import { ArrowUpDown, Check, ChevronsUpDown, Plus } from "lucide-react";
|
|||||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
import { type UseFormReturn, useForm, useWatch } from "react-hook-form";
|
import { type UseFormReturn, useForm, useWatch } from "react-hook-form";
|
||||||
|
|
||||||
// ─── CreatePolicyRulesSectionForm ─────────────────────────────────────────────
|
import { PolicyAccessRulesIntro } from "./PolicyAccessRulesIntro";
|
||||||
|
import { createEmptyRule } from "./policy-access-rule-utils";
|
||||||
|
|
||||||
const addRuleSchema = z.object({
|
// ─── CreatePolicyRulesSectionForm ─────────────────────────────────────────────
|
||||||
action: z.enum(["ACCEPT", "DROP", "PASS"]),
|
|
||||||
match: z.string(),
|
|
||||||
value: z.string(),
|
|
||||||
priority: z.coerce.number<number>().int().optional()
|
|
||||||
});
|
|
||||||
|
|
||||||
type LocalRule = {
|
type LocalRule = {
|
||||||
ruleId: number;
|
ruleId: number;
|
||||||
@@ -111,19 +95,15 @@ export function CreatePolicyRulesSectionForm({
|
|||||||
isMaxmindAsnAvailable
|
isMaxmindAsnAvailable
|
||||||
}: CreatePolicyRulesSectionFormProps) {
|
}: CreatePolicyRulesSectionFormProps) {
|
||||||
const t = useTranslations();
|
const t = useTranslations();
|
||||||
const [isExpanded, setIsExpanded] = useState(false);
|
|
||||||
const [rules, setRules] = useState<LocalRule[]>([]);
|
const [rules, setRules] = useState<LocalRule[]>([]);
|
||||||
const [openAddRuleCountrySelect, setOpenAddRuleCountrySelect] =
|
|
||||||
useState(false);
|
const rulesFormSchema = useMemo(
|
||||||
const [openAddRuleAsnSelect, setOpenAddRuleAsnSelect] = useState(false);
|
() => createPolicyRulesSectionSchema(t),
|
||||||
|
[t]
|
||||||
|
);
|
||||||
|
|
||||||
const form = useForm({
|
const form = useForm({
|
||||||
resolver: zodResolver(
|
resolver: zodResolver(rulesFormSchema),
|
||||||
createPolicySchema.pick({
|
|
||||||
applyRules: true,
|
|
||||||
rules: true
|
|
||||||
})
|
|
||||||
),
|
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
applyRules: false,
|
applyRules: false,
|
||||||
rules: []
|
rules: []
|
||||||
@@ -143,15 +123,6 @@ export function CreatePolicyRulesSectionForm({
|
|||||||
name: "applyRules"
|
name: "applyRules"
|
||||||
});
|
});
|
||||||
|
|
||||||
const addRuleForm = useForm({
|
|
||||||
resolver: zodResolver(addRuleSchema),
|
|
||||||
defaultValues: {
|
|
||||||
action: "ACCEPT" as const,
|
|
||||||
match: "PATH",
|
|
||||||
value: ""
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const RuleAction = useMemo(
|
const RuleAction = useMemo(
|
||||||
() => ({
|
() => ({
|
||||||
ACCEPT: t("alwaysAllow"),
|
ACCEPT: t("alwaysAllow"),
|
||||||
@@ -190,84 +161,11 @@ export function CreatePolicyRulesSectionForm({
|
|||||||
[form]
|
[form]
|
||||||
);
|
);
|
||||||
|
|
||||||
const addRule = useCallback(
|
const addEmptyRule = useCallback(() => {
|
||||||
function addRule(data: z.infer<typeof addRuleSchema>) {
|
const updatedRules = [...rules, createEmptyRule(rules)];
|
||||||
const isDuplicate = rules.some(
|
setRules(updatedRules);
|
||||||
(rule) =>
|
syncFormRules(updatedRules);
|
||||||
rule.action === data.action &&
|
}, [rules, syncFormRules]);
|
||||||
rule.match === data.match &&
|
|
||||||
rule.value === data.value
|
|
||||||
);
|
|
||||||
if (isDuplicate) {
|
|
||||||
toast({
|
|
||||||
variant: "destructive",
|
|
||||||
title: t("rulesErrorDuplicate"),
|
|
||||||
description: t("rulesErrorDuplicateDescription")
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (data.match === "CIDR" && !isValidCIDR(data.value)) {
|
|
||||||
toast({
|
|
||||||
variant: "destructive",
|
|
||||||
title: t("rulesErrorInvalidIpAddressRange"),
|
|
||||||
description: t("rulesErrorInvalidIpAddressRangeDescription")
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (data.match === "PATH" && !isValidUrlGlobPattern(data.value)) {
|
|
||||||
toast({
|
|
||||||
variant: "destructive",
|
|
||||||
title: t("rulesErrorInvalidUrl"),
|
|
||||||
description: t("rulesErrorInvalidUrlDescription")
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (data.match === "IP" && !isValidIP(data.value)) {
|
|
||||||
toast({
|
|
||||||
variant: "destructive",
|
|
||||||
title: t("rulesErrorInvalidIpAddress"),
|
|
||||||
description: t("rulesErrorInvalidIpAddressDescription")
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (
|
|
||||||
data.match === "COUNTRY" &&
|
|
||||||
!COUNTRIES.some((c) => c.code === data.value)
|
|
||||||
) {
|
|
||||||
toast({
|
|
||||||
variant: "destructive",
|
|
||||||
title: t("rulesErrorInvalidCountry"),
|
|
||||||
description: t("rulesErrorInvalidCountryDescription") || ""
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
let priority = data.priority;
|
|
||||||
if (priority === undefined) {
|
|
||||||
priority =
|
|
||||||
rules.reduce(
|
|
||||||
(acc, rule) =>
|
|
||||||
rule.priority > acc ? rule.priority : acc,
|
|
||||||
0
|
|
||||||
) + 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
const updatedRules = [
|
|
||||||
...rules,
|
|
||||||
{
|
|
||||||
...data,
|
|
||||||
ruleId: new Date().getTime(),
|
|
||||||
new: true,
|
|
||||||
priority,
|
|
||||||
enabled: true
|
|
||||||
}
|
|
||||||
];
|
|
||||||
setRules(updatedRules);
|
|
||||||
syncFormRules(updatedRules);
|
|
||||||
addRuleForm.reset();
|
|
||||||
},
|
|
||||||
[rules, t, addRuleForm, syncFormRules]
|
|
||||||
);
|
|
||||||
|
|
||||||
const removeRule = useCallback(
|
const removeRule = useCallback(
|
||||||
function removeRule(ruleId: number) {
|
function removeRule(ruleId: number) {
|
||||||
@@ -291,63 +189,63 @@ export function CreatePolicyRulesSectionForm({
|
|||||||
[rules, syncFormRules]
|
[rules, syncFormRules]
|
||||||
);
|
);
|
||||||
|
|
||||||
const getValueHelpText = useCallback(
|
|
||||||
function getValueHelpText(type: string) {
|
|
||||||
switch (type) {
|
|
||||||
case "CIDR":
|
|
||||||
return t("rulesMatchIpAddressRangeDescription");
|
|
||||||
case "IP":
|
|
||||||
return t("rulesMatchIpAddress");
|
|
||||||
case "PATH":
|
|
||||||
return t("rulesMatchUrl");
|
|
||||||
case "COUNTRY":
|
|
||||||
return t("rulesMatchCountry");
|
|
||||||
case "ASN":
|
|
||||||
return "Enter an Autonomous System Number (e.g., AS15169 or 15169)";
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[t]
|
|
||||||
);
|
|
||||||
|
|
||||||
const columns: ColumnDef<LocalRule>[] = useMemo(
|
const columns: ColumnDef<LocalRule>[] = useMemo(
|
||||||
() => [
|
() => [
|
||||||
{
|
{
|
||||||
accessorKey: "priority",
|
accessorKey: "priority",
|
||||||
|
size: 96,
|
||||||
|
maxSize: 96,
|
||||||
header: ({ column }) => (
|
header: ({ column }) => (
|
||||||
<Button
|
<div className="p-3">
|
||||||
variant="ghost"
|
<Button
|
||||||
onClick={() =>
|
variant="ghost"
|
||||||
column.toggleSorting(column.getIsSorted() === "asc")
|
className="h-auto p-0 font-medium text-muted-foreground hover:bg-transparent"
|
||||||
}
|
onClick={() =>
|
||||||
>
|
column.toggleSorting(
|
||||||
{t("rulesPriority")}
|
column.getIsSorted() === "asc"
|
||||||
<ArrowUpDown className="ml-2 h-4 w-4" />
|
)
|
||||||
</Button>
|
}
|
||||||
|
>
|
||||||
|
{t("rulesPriority")}
|
||||||
|
<ArrowUpDown className="ml-1 h-3 w-3" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
),
|
),
|
||||||
cell: ({ row }) => (
|
cell: ({ row }) => (
|
||||||
<Input
|
<Input
|
||||||
defaultValue={row.original.priority}
|
defaultValue={row.original.priority}
|
||||||
className="w-[75px]"
|
className="w-full min-w-0"
|
||||||
type="number"
|
type="number"
|
||||||
onClick={(e) => e.currentTarget.focus()}
|
onClick={(e) => e.currentTarget.focus()}
|
||||||
onBlur={(e) => {
|
onBlur={(e) => {
|
||||||
const parsed = z.coerce
|
const validated = validatePolicyRulePriority(
|
||||||
.number()
|
t,
|
||||||
.int()
|
e.target.value
|
||||||
.optional()
|
);
|
||||||
.safeParse(e.target.value);
|
if (!validated.success) {
|
||||||
if (!parsed.success) {
|
|
||||||
toast({
|
toast({
|
||||||
variant: "destructive",
|
variant: "destructive",
|
||||||
title: t("rulesErrorInvalidPriority"),
|
...validated.toast
|
||||||
|
});
|
||||||
|
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(
|
description: t(
|
||||||
"rulesErrorInvalidPriorityDescription"
|
"rulesErrorDuplicatePriorityDescription"
|
||||||
)
|
)
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
updateRule(row.original.ruleId, {
|
updateRule(row.original.ruleId, {
|
||||||
priority: parsed.data
|
priority: validated.data
|
||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
@@ -355,6 +253,8 @@ export function CreatePolicyRulesSectionForm({
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
accessorKey: "action",
|
accessorKey: "action",
|
||||||
|
size: 160,
|
||||||
|
maxSize: 160,
|
||||||
header: () => <span className="p-3">{t("rulesAction")}</span>,
|
header: () => <span className="p-3">{t("rulesAction")}</span>,
|
||||||
cell: ({ row }) => (
|
cell: ({ row }) => (
|
||||||
<Select
|
<Select
|
||||||
@@ -363,7 +263,7 @@ export function CreatePolicyRulesSectionForm({
|
|||||||
updateRule(row.original.ruleId, { action: value })
|
updateRule(row.original.ruleId, { action: value })
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<SelectTrigger className="min-w-[150px]">
|
<SelectTrigger className="h-8 w-full min-w-0">
|
||||||
<SelectValue />
|
<SelectValue />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
@@ -382,6 +282,8 @@ export function CreatePolicyRulesSectionForm({
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
accessorKey: "match",
|
accessorKey: "match",
|
||||||
|
size: 144,
|
||||||
|
maxSize: 144,
|
||||||
header: () => (
|
header: () => (
|
||||||
<span className="p-3">{t("rulesMatchType")}</span>
|
<span className="p-3">{t("rulesMatchType")}</span>
|
||||||
),
|
),
|
||||||
@@ -402,7 +304,7 @@ export function CreatePolicyRulesSectionForm({
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<SelectTrigger className="min-w-[125px]">
|
<SelectTrigger className="h-8 w-full min-w-0">
|
||||||
<SelectValue />
|
<SelectValue />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
@@ -577,11 +479,23 @@ export function CreatePolicyRulesSectionForm({
|
|||||||
<Input
|
<Input
|
||||||
defaultValue={row.original.value}
|
defaultValue={row.original.value}
|
||||||
className="min-w-50"
|
className="min-w-50"
|
||||||
onBlur={(e) =>
|
onBlur={(e) => {
|
||||||
|
const validated = validatePolicyRuleValue(
|
||||||
|
t,
|
||||||
|
row.original.match,
|
||||||
|
e.target.value
|
||||||
|
);
|
||||||
|
if (!validated.success) {
|
||||||
|
toast({
|
||||||
|
variant: "destructive",
|
||||||
|
...validated.toast
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
updateRule(row.original.ruleId, {
|
updateRule(row.original.ruleId, {
|
||||||
value: e.target.value
|
value: validated.data
|
||||||
})
|
});
|
||||||
}
|
}}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
@@ -589,19 +503,23 @@ export function CreatePolicyRulesSectionForm({
|
|||||||
accessorKey: "enabled",
|
accessorKey: "enabled",
|
||||||
header: () => <span className="p-3">{t("enabled")}</span>,
|
header: () => <span className="p-3">{t("enabled")}</span>,
|
||||||
cell: ({ row }) => (
|
cell: ({ row }) => (
|
||||||
<Switch
|
<div className="flex items-center w-full">
|
||||||
defaultChecked={row.original.enabled}
|
<Switch
|
||||||
onCheckedChange={(val) =>
|
defaultChecked={row.original.enabled}
|
||||||
updateRule(row.original.ruleId, { enabled: val })
|
onCheckedChange={(val) =>
|
||||||
}
|
updateRule(row.original.ruleId, {
|
||||||
/>
|
enabled: val
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "actions",
|
id: "actions",
|
||||||
header: () => <span className="p-3">{t("actions")}</span>,
|
header: () => null,
|
||||||
cell: ({ row }) => (
|
cell: ({ row }) => (
|
||||||
<div className="flex items-center space-x-2">
|
<div className="flex items-center justify-end space-x-2">
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
onClick={() => removeRule(row.original.ruleId)}
|
onClick={() => removeRule(row.original.ruleId)}
|
||||||
@@ -619,7 +537,8 @@ export function CreatePolicyRulesSectionForm({
|
|||||||
isMaxmindAvailable,
|
isMaxmindAvailable,
|
||||||
isMaxmindAsnAvailable,
|
isMaxmindAsnAvailable,
|
||||||
updateRule,
|
updateRule,
|
||||||
removeRule
|
removeRule,
|
||||||
|
rules
|
||||||
]
|
]
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -633,36 +552,18 @@ export function CreatePolicyRulesSectionForm({
|
|||||||
state: { pagination: { pageIndex: 0, pageSize: 1000 } }
|
state: { pagination: { pageIndex: 0, pageSize: 1000 } }
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!isExpanded) {
|
const addRuleButton = (
|
||||||
return (
|
<Button type="button" variant="outline" onClick={addEmptyRule}>
|
||||||
<SettingsSection>
|
<Plus className="h-4 w-4 mr-2" />
|
||||||
<SettingsSectionHeader>
|
{t("ruleSubmit")}
|
||||||
<SettingsSectionTitle>
|
</Button>
|
||||||
{t("rulesResource")}
|
);
|
||||||
</SettingsSectionTitle>
|
|
||||||
<SettingsSectionDescription>
|
|
||||||
{t("rulesResourcePolicyDescription")}
|
|
||||||
</SettingsSectionDescription>
|
|
||||||
</SettingsSectionHeader>
|
|
||||||
<SettingsSectionBody>
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="outline"
|
|
||||||
onClick={() => setIsExpanded(true)}
|
|
||||||
>
|
|
||||||
<Plus className="mr-2 h-4 w-4" />
|
|
||||||
{t("resourcePolicyRulesAdd")}
|
|
||||||
</Button>
|
|
||||||
</SettingsSectionBody>
|
|
||||||
</SettingsSection>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SettingsSection>
|
<SettingsSection>
|
||||||
<SettingsSectionHeader>
|
<SettingsSectionHeader>
|
||||||
<SettingsSectionTitle>
|
<SettingsSectionTitle>
|
||||||
{t("rulesResource")}
|
{t("policyAccessRulesTitle")}
|
||||||
</SettingsSectionTitle>
|
</SettingsSectionTitle>
|
||||||
<SettingsSectionDescription>
|
<SettingsSectionDescription>
|
||||||
{t("rulesResourceDescription")}
|
{t("rulesResourceDescription")}
|
||||||
@@ -670,421 +571,128 @@ export function CreatePolicyRulesSectionForm({
|
|||||||
</SettingsSectionHeader>
|
</SettingsSectionHeader>
|
||||||
<SettingsSectionBody>
|
<SettingsSectionBody>
|
||||||
<div className="flex flex-col gap-y-6 pb-20">
|
<div className="flex flex-col gap-y-6 pb-20">
|
||||||
<div className="flex items-center gap-x-2">
|
<PolicyAccessRulesIntro
|
||||||
<SwitchInput
|
rulesEnabled={Boolean(rulesEnabled)}
|
||||||
id="rules-toggle"
|
onRulesEnabledChange={(val) => {
|
||||||
label={t("rulesEnable")}
|
form.setValue("applyRules", val);
|
||||||
defaultChecked={false}
|
}}
|
||||||
onCheckedChange={(val) => {
|
/>
|
||||||
form.setValue("applyRules", val);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Form {...addRuleForm}>
|
{rulesEnabled && (
|
||||||
<form
|
<>
|
||||||
onSubmit={addRuleForm.handleSubmit(addRule)}
|
<Table>
|
||||||
className="space-y-4"
|
<TableHeader>
|
||||||
>
|
{table
|
||||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 items-end">
|
.getHeaderGroups()
|
||||||
<FormField
|
.map((headerGroup) => (
|
||||||
control={addRuleForm.control}
|
<TableRow key={headerGroup.id}>
|
||||||
name="action"
|
{headerGroup.headers.map(
|
||||||
render={({ field }) => (
|
(header) => {
|
||||||
<FormItem>
|
const columnId =
|
||||||
<FormLabel>
|
header.column.id;
|
||||||
{t("rulesAction")}
|
const isActionsColumn =
|
||||||
</FormLabel>
|
columnId ===
|
||||||
<FormControl>
|
"actions";
|
||||||
<Select
|
const isPriorityColumn =
|
||||||
value={field.value}
|
columnId ===
|
||||||
onValueChange={
|
"priority";
|
||||||
field.onChange
|
const isActionColumn =
|
||||||
}
|
columnId ===
|
||||||
>
|
"action";
|
||||||
<SelectTrigger className="w-full">
|
const isMatchColumn =
|
||||||
<SelectValue />
|
columnId ===
|
||||||
</SelectTrigger>
|
"match";
|
||||||
<SelectContent>
|
return (
|
||||||
<SelectItem value="ACCEPT">
|
<TableHead
|
||||||
{RuleAction.ACCEPT}
|
key={header.id}
|
||||||
</SelectItem>
|
className={
|
||||||
<SelectItem value="DROP">
|
isActionsColumn
|
||||||
{RuleAction.DROP}
|
? "sticky right-0 z-10 w-[1%] min-w-fit bg-card text-right"
|
||||||
</SelectItem>
|
: isPriorityColumn
|
||||||
<SelectItem value="PASS">
|
? "w-24 max-w-24"
|
||||||
{RuleAction.PASS}
|
: isActionColumn
|
||||||
</SelectItem>
|
? "w-40 max-w-40"
|
||||||
</SelectContent>
|
: isMatchColumn
|
||||||
</Select>
|
? "w-36 max-w-36"
|
||||||
</FormControl>
|
: ""
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
<FormField
|
|
||||||
control={addRuleForm.control}
|
|
||||||
name="match"
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem>
|
|
||||||
<FormLabel>
|
|
||||||
{t("rulesMatchType")}
|
|
||||||
</FormLabel>
|
|
||||||
<FormControl>
|
|
||||||
<Select
|
|
||||||
value={field.value}
|
|
||||||
onValueChange={
|
|
||||||
field.onChange
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<SelectTrigger className="w-full">
|
|
||||||
<SelectValue />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value="PATH">
|
|
||||||
{RuleMatch.PATH}
|
|
||||||
</SelectItem>
|
|
||||||
<SelectItem value="IP">
|
|
||||||
{RuleMatch.IP}
|
|
||||||
</SelectItem>
|
|
||||||
<SelectItem value="CIDR">
|
|
||||||
{RuleMatch.CIDR}
|
|
||||||
</SelectItem>
|
|
||||||
{isMaxmindAvailable && (
|
|
||||||
<SelectItem value="COUNTRY">
|
|
||||||
{
|
|
||||||
RuleMatch.COUNTRY
|
|
||||||
}
|
}
|
||||||
</SelectItem>
|
|
||||||
)}
|
|
||||||
{isMaxmindAsnAvailable && (
|
|
||||||
<SelectItem value="ASN">
|
|
||||||
{RuleMatch.ASN}
|
|
||||||
</SelectItem>
|
|
||||||
)}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</FormControl>
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
<FormField
|
|
||||||
control={addRuleForm.control}
|
|
||||||
name="value"
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem className="gap-1">
|
|
||||||
<InfoPopup
|
|
||||||
text={t("value")}
|
|
||||||
info={
|
|
||||||
getValueHelpText(
|
|
||||||
addRuleForm.watch(
|
|
||||||
"match"
|
|
||||||
)
|
|
||||||
) || ""
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<FormControl>
|
|
||||||
{addRuleForm.watch("match") ===
|
|
||||||
"COUNTRY" ? (
|
|
||||||
<Popover
|
|
||||||
open={
|
|
||||||
openAddRuleCountrySelect
|
|
||||||
}
|
|
||||||
onOpenChange={
|
|
||||||
setOpenAddRuleCountrySelect
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<PopoverTrigger asChild>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
role="combobox"
|
|
||||||
aria-expanded={
|
|
||||||
openAddRuleCountrySelect
|
|
||||||
}
|
|
||||||
className="w-full justify-between"
|
|
||||||
>
|
>
|
||||||
{field.value
|
{header.isPlaceholder
|
||||||
? COUNTRIES.find(
|
? null
|
||||||
(c) =>
|
: flexRender(
|
||||||
c.code ===
|
header
|
||||||
field.value
|
.column
|
||||||
)?.name +
|
.columnDef
|
||||||
" (" +
|
.header,
|
||||||
field.value +
|
header.getContext()
|
||||||
")"
|
|
||||||
: t(
|
|
||||||
"selectCountry"
|
|
||||||
)}
|
)}
|
||||||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
</TableHead>
|
||||||
</Button>
|
);
|
||||||
</PopoverTrigger>
|
|
||||||
<PopoverContent className="w-full p-0">
|
|
||||||
<Command>
|
|
||||||
<CommandInput
|
|
||||||
placeholder={t(
|
|
||||||
"searchCountries"
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
<CommandList>
|
|
||||||
<CommandEmpty>
|
|
||||||
{t(
|
|
||||||
"noCountryFound"
|
|
||||||
)}
|
|
||||||
</CommandEmpty>
|
|
||||||
<CommandGroup>
|
|
||||||
{COUNTRIES.map(
|
|
||||||
(
|
|
||||||
country
|
|
||||||
) => (
|
|
||||||
<CommandItem
|
|
||||||
key={
|
|
||||||
country.code
|
|
||||||
}
|
|
||||||
value={
|
|
||||||
country.name
|
|
||||||
}
|
|
||||||
onSelect={() => {
|
|
||||||
field.onChange(
|
|
||||||
country.code
|
|
||||||
);
|
|
||||||
setOpenAddRuleCountrySelect(
|
|
||||||
false
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Check
|
|
||||||
className={`mr-2 h-4 w-4 ${field.value === country.code ? "opacity-100" : "opacity-0"}`}
|
|
||||||
/>
|
|
||||||
{
|
|
||||||
country.name
|
|
||||||
}{" "}
|
|
||||||
(
|
|
||||||
{
|
|
||||||
country.code
|
|
||||||
}
|
|
||||||
|
|
||||||
)
|
|
||||||
</CommandItem>
|
|
||||||
)
|
|
||||||
)}
|
|
||||||
</CommandGroup>
|
|
||||||
</CommandList>
|
|
||||||
</Command>
|
|
||||||
</PopoverContent>
|
|
||||||
</Popover>
|
|
||||||
) : addRuleForm.watch(
|
|
||||||
"match"
|
|
||||||
) === "ASN" ? (
|
|
||||||
<Popover
|
|
||||||
open={
|
|
||||||
openAddRuleAsnSelect
|
|
||||||
}
|
|
||||||
onOpenChange={
|
|
||||||
setOpenAddRuleAsnSelect
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<PopoverTrigger asChild>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
role="combobox"
|
|
||||||
aria-expanded={
|
|
||||||
openAddRuleAsnSelect
|
|
||||||
}
|
|
||||||
className="w-full justify-between"
|
|
||||||
>
|
|
||||||
{field.value
|
|
||||||
? MAJOR_ASNS.find(
|
|
||||||
(
|
|
||||||
asn
|
|
||||||
) =>
|
|
||||||
asn.code ===
|
|
||||||
field.value
|
|
||||||
)?.name +
|
|
||||||
" (" +
|
|
||||||
field.value +
|
|
||||||
")" ||
|
|
||||||
field.value
|
|
||||||
: "Select ASN"}
|
|
||||||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
|
||||||
</Button>
|
|
||||||
</PopoverTrigger>
|
|
||||||
<PopoverContent className="w-full p-0">
|
|
||||||
<Command>
|
|
||||||
<CommandInput placeholder="Search ASNs or enter custom..." />
|
|
||||||
<CommandList>
|
|
||||||
<CommandEmpty>
|
|
||||||
No ASN
|
|
||||||
found.
|
|
||||||
Use the
|
|
||||||
custom
|
|
||||||
input
|
|
||||||
below.
|
|
||||||
</CommandEmpty>
|
|
||||||
<CommandGroup>
|
|
||||||
{MAJOR_ASNS.map(
|
|
||||||
(
|
|
||||||
asn
|
|
||||||
) => (
|
|
||||||
<CommandItem
|
|
||||||
key={
|
|
||||||
asn.code
|
|
||||||
}
|
|
||||||
value={
|
|
||||||
asn.name +
|
|
||||||
" " +
|
|
||||||
asn.code
|
|
||||||
}
|
|
||||||
onSelect={() => {
|
|
||||||
field.onChange(
|
|
||||||
asn.code
|
|
||||||
);
|
|
||||||
setOpenAddRuleAsnSelect(
|
|
||||||
false
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Check
|
|
||||||
className={`mr-2 h-4 w-4 ${field.value === asn.code ? "opacity-100" : "opacity-0"}`}
|
|
||||||
/>
|
|
||||||
{
|
|
||||||
asn.name
|
|
||||||
}{" "}
|
|
||||||
(
|
|
||||||
{
|
|
||||||
asn.code
|
|
||||||
}
|
|
||||||
|
|
||||||
)
|
|
||||||
</CommandItem>
|
|
||||||
)
|
|
||||||
)}
|
|
||||||
</CommandGroup>
|
|
||||||
</CommandList>
|
|
||||||
</Command>
|
|
||||||
<div className="border-t p-2">
|
|
||||||
<Input
|
|
||||||
placeholder="Enter custom ASN (e.g., AS15169)"
|
|
||||||
onKeyDown={(
|
|
||||||
e
|
|
||||||
) => {
|
|
||||||
if (
|
|
||||||
e.key ===
|
|
||||||
"Enter"
|
|
||||||
) {
|
|
||||||
const value =
|
|
||||||
e.currentTarget.value
|
|
||||||
.toUpperCase()
|
|
||||||
.replace(
|
|
||||||
/^AS/,
|
|
||||||
""
|
|
||||||
);
|
|
||||||
if (
|
|
||||||
/^\d+$/.test(
|
|
||||||
value
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
field.onChange(
|
|
||||||
"AS" +
|
|
||||||
value
|
|
||||||
);
|
|
||||||
setOpenAddRuleAsnSelect(
|
|
||||||
false
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
className="text-sm"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</PopoverContent>
|
|
||||||
</Popover>
|
|
||||||
) : (
|
|
||||||
<Input {...field} />
|
|
||||||
)}
|
|
||||||
</FormControl>
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
<Button
|
|
||||||
type="submit"
|
|
||||||
variant="outline"
|
|
||||||
disabled={!rulesEnabled}
|
|
||||||
>
|
|
||||||
{t("ruleSubmit")}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</Form>
|
|
||||||
|
|
||||||
<Table>
|
|
||||||
<TableHeader>
|
|
||||||
{table.getHeaderGroups().map((headerGroup) => (
|
|
||||||
<TableRow key={headerGroup.id}>
|
|
||||||
{headerGroup.headers.map((header) => {
|
|
||||||
const isActionsColumn =
|
|
||||||
header.column.id === "actions";
|
|
||||||
return (
|
|
||||||
<TableHead
|
|
||||||
key={header.id}
|
|
||||||
className={
|
|
||||||
isActionsColumn
|
|
||||||
? "sticky right-0 z-10 w-auto min-w-fit bg-card"
|
|
||||||
: ""
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{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";
|
|
||||||
return (
|
|
||||||
<TableCell
|
|
||||||
key={cell.id}
|
|
||||||
className={
|
|
||||||
isActionsColumn
|
|
||||||
? "sticky right-0 z-10 w-auto min-w-fit bg-card"
|
|
||||||
: ""
|
|
||||||
}
|
}
|
||||||
>
|
)}
|
||||||
{flexRender(
|
</TableRow>
|
||||||
cell.column.columnDef
|
))}
|
||||||
.cell,
|
</TableHeader>
|
||||||
cell.getContext()
|
<TableBody>
|
||||||
)}
|
{table.getRowModel().rows?.length ? (
|
||||||
</TableCell>
|
table.getRowModel().rows.map((row) => (
|
||||||
);
|
<TableRow key={row.id}>
|
||||||
})}
|
{row
|
||||||
</TableRow>
|
.getVisibleCells()
|
||||||
))
|
.map((cell) => {
|
||||||
) : (
|
const columnId =
|
||||||
<TableRow>
|
cell.column.id;
|
||||||
<TableCell
|
const isActionsColumn =
|
||||||
colSpan={columns.length}
|
columnId ===
|
||||||
className="h-24 text-center"
|
"actions";
|
||||||
>
|
const isPriorityColumn =
|
||||||
{t("rulesNoOne")}
|
columnId ===
|
||||||
</TableCell>
|
"priority";
|
||||||
</TableRow>
|
const isActionColumn =
|
||||||
)}
|
columnId ===
|
||||||
</TableBody>
|
"action";
|
||||||
</Table>
|
const isMatchColumn =
|
||||||
|
columnId ===
|
||||||
|
"match";
|
||||||
|
return (
|
||||||
|
<TableCell
|
||||||
|
key={cell.id}
|
||||||
|
className={
|
||||||
|
isActionsColumn
|
||||||
|
? "sticky right-0 z-10 w-[1%] min-w-fit bg-card text-right"
|
||||||
|
: isPriorityColumn
|
||||||
|
? "w-24 max-w-24"
|
||||||
|
: isActionColumn
|
||||||
|
? "w-40 max-w-40"
|
||||||
|
: isMatchColumn
|
||||||
|
? "w-36 max-w-36"
|
||||||
|
: ""
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{flexRender(
|
||||||
|
cell.column
|
||||||
|
.columnDef
|
||||||
|
.cell,
|
||||||
|
cell.getContext()
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</TableRow>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<DataTableEmptyState
|
||||||
|
colSpan={columns.length}
|
||||||
|
message={t("rulesNoOne")}
|
||||||
|
action={addRuleButton}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
{table.getRowModel().rows?.length > 0 &&
|
||||||
|
addRuleButton}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</SettingsSectionBody>
|
</SettingsSectionBody>
|
||||||
</SettingsSection>
|
</SettingsSection>
|
||||||
|
|||||||
@@ -1,257 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import {
|
|
||||||
SettingsSection,
|
|
||||||
SettingsSectionBody,
|
|
||||||
SettingsSectionDescription,
|
|
||||||
SettingsSectionForm,
|
|
||||||
SettingsSectionHeader,
|
|
||||||
SettingsSectionTitle
|
|
||||||
} from "@app/components/Settings";
|
|
||||||
|
|
||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
|
||||||
import { SwitchInput } from "@app/components/SwitchInput";
|
|
||||||
import { Tag, TagInput } from "@app/components/tags/tag-input";
|
|
||||||
import {
|
|
||||||
Form,
|
|
||||||
FormControl,
|
|
||||||
FormDescription,
|
|
||||||
FormField,
|
|
||||||
FormItem,
|
|
||||||
FormLabel,
|
|
||||||
FormMessage
|
|
||||||
} from "@app/components/ui/form";
|
|
||||||
import {
|
|
||||||
Select,
|
|
||||||
SelectContent,
|
|
||||||
SelectItem,
|
|
||||||
SelectTrigger,
|
|
||||||
SelectValue
|
|
||||||
} from "@app/components/ui/select";
|
|
||||||
import { createPolicySchema, type PolicyFormValues } from ".";
|
|
||||||
import { useTranslations } from "next-intl";
|
|
||||||
import { useEffect, useState } from "react";
|
|
||||||
import { type UseFormReturn, useForm, useWatch } from "react-hook-form";
|
|
||||||
|
|
||||||
// ─── CreatePolicyUsersRolesSectionForm ────────────────────────────────────────
|
|
||||||
|
|
||||||
export type CreatePolicyUsersRolesSectionFormProps = {
|
|
||||||
form: UseFormReturn<PolicyFormValues, any, any>;
|
|
||||||
allRoles: { id: string; text: string }[];
|
|
||||||
allUsers: { id: string; text: string }[];
|
|
||||||
allIdps: { id: number; text: string }[];
|
|
||||||
};
|
|
||||||
|
|
||||||
export function CreatePolicyUsersRolesSectionForm({
|
|
||||||
form: parentForm,
|
|
||||||
allRoles,
|
|
||||||
allUsers,
|
|
||||||
allIdps
|
|
||||||
}: CreatePolicyUsersRolesSectionFormProps) {
|
|
||||||
const t = useTranslations();
|
|
||||||
|
|
||||||
const form = useForm({
|
|
||||||
resolver: zodResolver(
|
|
||||||
createPolicySchema.pick({
|
|
||||||
sso: true,
|
|
||||||
skipToIdpId: true,
|
|
||||||
roles: true,
|
|
||||||
users: true
|
|
||||||
})
|
|
||||||
),
|
|
||||||
defaultValues: {
|
|
||||||
sso: true,
|
|
||||||
skipToIdpId: null,
|
|
||||||
roles: [],
|
|
||||||
users: []
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const subscription = form.watch((values) => {
|
|
||||||
parentForm.setValue("sso", values.sso as boolean);
|
|
||||||
parentForm.setValue("skipToIdpId", values.skipToIdpId as number | null);
|
|
||||||
parentForm.setValue("roles", values.roles as [Tag, ...Tag[]]);
|
|
||||||
parentForm.setValue("users", values.users as [Tag, ...Tag[]]);
|
|
||||||
});
|
|
||||||
return () => subscription.unsubscribe();
|
|
||||||
}, [form, parentForm]);
|
|
||||||
|
|
||||||
const ssoEnabled = useWatch({ control: form.control, name: "sso" });
|
|
||||||
const selectedIdpId = useWatch({
|
|
||||||
control: form.control,
|
|
||||||
name: "skipToIdpId"
|
|
||||||
});
|
|
||||||
const [activeRolesTagIndex, setActiveRolesTagIndex] = useState<
|
|
||||||
number | null
|
|
||||||
>(null);
|
|
||||||
const [activeUsersTagIndex, setActiveUsersTagIndex] = useState<
|
|
||||||
number | null
|
|
||||||
>(null);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Form {...form}>
|
|
||||||
<SettingsSection>
|
|
||||||
<SettingsSectionHeader>
|
|
||||||
<SettingsSectionTitle>
|
|
||||||
{t("resourceUsersRoles")}
|
|
||||||
</SettingsSectionTitle>
|
|
||||||
<SettingsSectionDescription>
|
|
||||||
{t("resourcePolicyUsersRolesDescription")}
|
|
||||||
</SettingsSectionDescription>
|
|
||||||
</SettingsSectionHeader>
|
|
||||||
<SettingsSectionBody>
|
|
||||||
<SettingsSectionForm>
|
|
||||||
<SwitchInput
|
|
||||||
id="sso-toggle"
|
|
||||||
label={t("ssoUse")}
|
|
||||||
defaultChecked={ssoEnabled}
|
|
||||||
onCheckedChange={(val) => {
|
|
||||||
form.setValue("sso", val);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{ssoEnabled && (
|
|
||||||
<>
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name="roles"
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem className="flex flex-col items-start">
|
|
||||||
<FormLabel>{t("roles")}</FormLabel>
|
|
||||||
<FormControl>
|
|
||||||
<TagInput
|
|
||||||
{...field}
|
|
||||||
activeTagIndex={
|
|
||||||
activeRolesTagIndex
|
|
||||||
}
|
|
||||||
setActiveTagIndex={
|
|
||||||
setActiveRolesTagIndex
|
|
||||||
}
|
|
||||||
placeholder={t(
|
|
||||||
"accessRoleSelect2"
|
|
||||||
)}
|
|
||||||
size="sm"
|
|
||||||
tags={form.getValues().roles}
|
|
||||||
setTags={(newRoles) => {
|
|
||||||
form.setValue(
|
|
||||||
"roles",
|
|
||||||
newRoles as [
|
|
||||||
Tag,
|
|
||||||
...Tag[]
|
|
||||||
]
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
enableAutocomplete={true}
|
|
||||||
autocompleteOptions={allRoles}
|
|
||||||
allowDuplicates={false}
|
|
||||||
restrictTagsToAutocompleteOptions={
|
|
||||||
true
|
|
||||||
}
|
|
||||||
sortTags={true}
|
|
||||||
/>
|
|
||||||
</FormControl>
|
|
||||||
<FormMessage />
|
|
||||||
<FormDescription>
|
|
||||||
{t("resourceRoleDescription")}
|
|
||||||
</FormDescription>
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name="users"
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem className="flex flex-col items-start">
|
|
||||||
<FormLabel>{t("users")}</FormLabel>
|
|
||||||
<FormControl>
|
|
||||||
<TagInput
|
|
||||||
{...field}
|
|
||||||
activeTagIndex={
|
|
||||||
activeUsersTagIndex
|
|
||||||
}
|
|
||||||
setActiveTagIndex={
|
|
||||||
setActiveUsersTagIndex
|
|
||||||
}
|
|
||||||
placeholder={t(
|
|
||||||
"accessUserSelect"
|
|
||||||
)}
|
|
||||||
size="sm"
|
|
||||||
tags={form.getValues().users}
|
|
||||||
setTags={(newUsers) => {
|
|
||||||
form.setValue(
|
|
||||||
"users",
|
|
||||||
newUsers as [
|
|
||||||
Tag,
|
|
||||||
...Tag[]
|
|
||||||
]
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
enableAutocomplete={true}
|
|
||||||
autocompleteOptions={allUsers}
|
|
||||||
allowDuplicates={false}
|
|
||||||
restrictTagsToAutocompleteOptions={
|
|
||||||
true
|
|
||||||
}
|
|
||||||
sortTags={true}
|
|
||||||
/>
|
|
||||||
</FormControl>
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{ssoEnabled && allIdps.length > 0 && (
|
|
||||||
<div className="space-y-2">
|
|
||||||
<label className="text-sm font-medium">
|
|
||||||
{t("defaultIdentityProvider")}
|
|
||||||
</label>
|
|
||||||
<Select
|
|
||||||
onValueChange={(value) => {
|
|
||||||
if (value === "none") {
|
|
||||||
form.setValue("skipToIdpId", null);
|
|
||||||
} else {
|
|
||||||
const id = parseInt(value);
|
|
||||||
form.setValue("skipToIdpId", id);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
value={
|
|
||||||
selectedIdpId
|
|
||||||
? selectedIdpId.toString()
|
|
||||||
: "none"
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<SelectTrigger className="w-full mt-1">
|
|
||||||
<SelectValue
|
|
||||||
placeholder={t(
|
|
||||||
"selectIdpPlaceholder"
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value="none">
|
|
||||||
{t("none")}
|
|
||||||
</SelectItem>
|
|
||||||
{allIdps.map((idp) => (
|
|
||||||
<SelectItem
|
|
||||||
key={idp.id}
|
|
||||||
value={idp.id.toString()}
|
|
||||||
>
|
|
||||||
{idp.text}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
{t("defaultIdentityProviderDescription")}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</SettingsSectionForm>
|
|
||||||
</SettingsSectionBody>
|
|
||||||
</SettingsSection>
|
|
||||||
</Form>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,671 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import {
|
|
||||||
SettingsSection,
|
|
||||||
SettingsSectionBody,
|
|
||||||
SettingsSectionDescription,
|
|
||||||
SettingsSectionFooter,
|
|
||||||
SettingsSectionForm,
|
|
||||||
SettingsSectionHeader,
|
|
||||||
SettingsSectionTitle
|
|
||||||
} from "@app/components/Settings";
|
|
||||||
|
|
||||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
|
||||||
|
|
||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
|
||||||
import { useTranslations } from "next-intl";
|
|
||||||
|
|
||||||
import z from "zod";
|
|
||||||
|
|
||||||
import { createApiClient, formatAxiosError } from "@app/lib/api";
|
|
||||||
import { useRouter } from "next/navigation";
|
|
||||||
import { createPolicySchema } from ".";
|
|
||||||
|
|
||||||
import {
|
|
||||||
Credenza,
|
|
||||||
CredenzaBody,
|
|
||||||
CredenzaClose,
|
|
||||||
CredenzaContent,
|
|
||||||
CredenzaDescription,
|
|
||||||
CredenzaFooter,
|
|
||||||
CredenzaHeader,
|
|
||||||
CredenzaTitle
|
|
||||||
} from "@app/components/Credenza";
|
|
||||||
import { SwitchInput } from "@app/components/SwitchInput";
|
|
||||||
import { Button } from "@app/components/ui/button";
|
|
||||||
import {
|
|
||||||
Form,
|
|
||||||
FormControl,
|
|
||||||
FormField,
|
|
||||||
FormItem,
|
|
||||||
FormLabel,
|
|
||||||
FormMessage
|
|
||||||
} from "@app/components/ui/form";
|
|
||||||
import { Input } from "@app/components/ui/input";
|
|
||||||
import {
|
|
||||||
InputOTP,
|
|
||||||
InputOTPGroup,
|
|
||||||
InputOTPSlot
|
|
||||||
} from "@app/components/ui/input-otp";
|
|
||||||
|
|
||||||
import { Binary, Bot, Key, Plus } from "lucide-react";
|
|
||||||
|
|
||||||
import { cn } from "@app/lib/cn";
|
|
||||||
import { useResourcePolicyContext } from "@app/providers/ResourcePolicyProvider";
|
|
||||||
import { useActionState, useState } from "react";
|
|
||||||
import { useForm } from "react-hook-form";
|
|
||||||
import { toast } from "@app/hooks/useToast";
|
|
||||||
import type { AxiosResponse } from "axios";
|
|
||||||
|
|
||||||
// ─── PolicyAuthMethodsSection ─────────────────────────────────────────────────
|
|
||||||
|
|
||||||
const setPasswordSchema = z.object({
|
|
||||||
password: z.string().min(4).max(100)
|
|
||||||
});
|
|
||||||
|
|
||||||
const setPincodeSchema = z.object({
|
|
||||||
pincode: z.string().length(6)
|
|
||||||
});
|
|
||||||
|
|
||||||
const setHeaderAuthSchema = z.object({
|
|
||||||
user: z.string().min(4).max(100),
|
|
||||||
password: z.string().min(4).max(100),
|
|
||||||
extendedCompatibility: z.boolean()
|
|
||||||
});
|
|
||||||
|
|
||||||
export function EditPolicyAuthMethodsSectionForm({
|
|
||||||
readonly
|
|
||||||
}: {
|
|
||||||
readonly?: boolean;
|
|
||||||
}) {
|
|
||||||
const { policy } = useResourcePolicyContext();
|
|
||||||
const router = useRouter();
|
|
||||||
|
|
||||||
const api = createApiClient(useEnvContext());
|
|
||||||
|
|
||||||
const form = useForm({
|
|
||||||
resolver: zodResolver(
|
|
||||||
createPolicySchema.pick({
|
|
||||||
password: true,
|
|
||||||
pincode: true,
|
|
||||||
headerAuth: true
|
|
||||||
})
|
|
||||||
)
|
|
||||||
});
|
|
||||||
|
|
||||||
const t = useTranslations();
|
|
||||||
const [isSetPasswordOpen, setIsSetPasswordOpen] = useState(false);
|
|
||||||
const [isSetPincodeOpen, setIsSetPincodeOpen] = useState(false);
|
|
||||||
const [isSetHeaderAuthOpen, setIsSetHeaderAuthOpen] = useState(false);
|
|
||||||
|
|
||||||
const password = form.watch("password");
|
|
||||||
const pincode = form.watch("pincode");
|
|
||||||
const headerAuth = form.watch("headerAuth");
|
|
||||||
|
|
||||||
// If explicitly removed (set to `null`) it means the value has been removed
|
|
||||||
// in the other case (`undefined` or object value), check if the value has been modified
|
|
||||||
// and fallback to the policy default value
|
|
||||||
const hasPassword =
|
|
||||||
password !== null ? Boolean(password ?? policy.passwordId) : false;
|
|
||||||
|
|
||||||
const hasPincode =
|
|
||||||
pincode !== null ? Boolean(pincode ?? policy.pincodeId) : false;
|
|
||||||
|
|
||||||
const hasHeaderAuth =
|
|
||||||
headerAuth !== null ? Boolean(headerAuth ?? policy.headerAuth) : false;
|
|
||||||
|
|
||||||
const [isExpanded, setIsExpanded] = useState(
|
|
||||||
hasPassword || hasPincode || hasHeaderAuth
|
|
||||||
);
|
|
||||||
|
|
||||||
const passwordForm = useForm({
|
|
||||||
resolver: zodResolver(setPasswordSchema),
|
|
||||||
defaultValues: { password: "" }
|
|
||||||
});
|
|
||||||
|
|
||||||
const pincodeForm = useForm({
|
|
||||||
resolver: zodResolver(setPincodeSchema),
|
|
||||||
defaultValues: { pincode: "" }
|
|
||||||
});
|
|
||||||
|
|
||||||
const headerAuthForm = useForm({
|
|
||||||
resolver: zodResolver(setHeaderAuthSchema),
|
|
||||||
defaultValues: { user: "", password: "", extendedCompatibility: true }
|
|
||||||
});
|
|
||||||
|
|
||||||
const [, formAction, isSubmitting] = useActionState(onSubmit, null);
|
|
||||||
|
|
||||||
async function onSubmit() {
|
|
||||||
if (readonly) return;
|
|
||||||
const isValid = await form.trigger();
|
|
||||||
|
|
||||||
if (!isValid) return;
|
|
||||||
|
|
||||||
const payload = form.getValues();
|
|
||||||
|
|
||||||
const responseArray: Array<Promise<AxiosResponse<{}> | void>> = [];
|
|
||||||
|
|
||||||
if (typeof payload.password !== "undefined") {
|
|
||||||
responseArray.push(
|
|
||||||
api
|
|
||||||
.put<AxiosResponse<{}>>(
|
|
||||||
`/resource-policy/${policy.resourcePolicyId}/password`,
|
|
||||||
{
|
|
||||||
password: payload.password?.password ?? null
|
|
||||||
}
|
|
||||||
)
|
|
||||||
.catch((e) => {
|
|
||||||
toast({
|
|
||||||
variant: "destructive",
|
|
||||||
title: t("policyErrorUpdate"),
|
|
||||||
description: formatAxiosError(
|
|
||||||
e,
|
|
||||||
t("policyErrorUpdateDescription")
|
|
||||||
)
|
|
||||||
});
|
|
||||||
})
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (typeof payload.pincode !== "undefined") {
|
|
||||||
responseArray.push(
|
|
||||||
api
|
|
||||||
.put<AxiosResponse<{}>>(
|
|
||||||
`/resource-policy/${policy.resourcePolicyId}/pincode`,
|
|
||||||
{
|
|
||||||
pincode: payload.pincode?.pincode ?? null
|
|
||||||
}
|
|
||||||
)
|
|
||||||
.catch((e) => {
|
|
||||||
toast({
|
|
||||||
variant: "destructive",
|
|
||||||
title: t("policyErrorUpdate"),
|
|
||||||
description: formatAxiosError(
|
|
||||||
e,
|
|
||||||
t("policyErrorUpdateDescription")
|
|
||||||
)
|
|
||||||
});
|
|
||||||
})
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (typeof payload.headerAuth !== "undefined") {
|
|
||||||
responseArray.push(
|
|
||||||
api
|
|
||||||
.put<AxiosResponse<{}>>(
|
|
||||||
`/resource-policy/${policy.resourcePolicyId}/header-auth`,
|
|
||||||
{
|
|
||||||
headerAuth: payload.headerAuth
|
|
||||||
}
|
|
||||||
)
|
|
||||||
.catch((e) => {
|
|
||||||
toast({
|
|
||||||
variant: "destructive",
|
|
||||||
title: t("policyErrorUpdate"),
|
|
||||||
description: formatAxiosError(
|
|
||||||
e,
|
|
||||||
t("policyErrorUpdateDescription")
|
|
||||||
)
|
|
||||||
});
|
|
||||||
})
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const responseList = await Promise.all(responseArray);
|
|
||||||
|
|
||||||
if (responseList.every((res) => res && res.status === 200)) {
|
|
||||||
toast({
|
|
||||||
title: t("success"),
|
|
||||||
description: t("policyUpdatedSuccess")
|
|
||||||
});
|
|
||||||
router.refresh();
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
toast({
|
|
||||||
variant: "destructive",
|
|
||||||
title: t("policyErrorUpdate"),
|
|
||||||
description: t("policyErrorUpdateMessageDescription")
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!isExpanded) {
|
|
||||||
return (
|
|
||||||
<SettingsSection>
|
|
||||||
<SettingsSectionHeader>
|
|
||||||
<SettingsSectionTitle>
|
|
||||||
{t("resourceAuthMethods")}
|
|
||||||
</SettingsSectionTitle>
|
|
||||||
<SettingsSectionDescription>
|
|
||||||
{t("resourcePolicyAuthMethodsDescription")}
|
|
||||||
</SettingsSectionDescription>
|
|
||||||
</SettingsSectionHeader>
|
|
||||||
<SettingsSectionBody>
|
|
||||||
{!readonly ? (
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="outline"
|
|
||||||
onClick={() => setIsExpanded(true)}
|
|
||||||
>
|
|
||||||
<Plus className="mr-2 h-4 w-4" />
|
|
||||||
{t("resourcePolicyAuthMethodAdd")}
|
|
||||||
</Button>
|
|
||||||
) : (
|
|
||||||
<div className="text-muted-foreground flex items-center h-full size-full bg-muted rounded-md px-8 py-6 border-dashed text-sm">
|
|
||||||
<p>{t("resourcePolicyAuthMethodsEmpty")}</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</SettingsSectionBody>
|
|
||||||
</SettingsSection>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
{/* Password Credenza */}
|
|
||||||
<Credenza
|
|
||||||
open={isSetPasswordOpen}
|
|
||||||
onOpenChange={(val) => {
|
|
||||||
setIsSetPasswordOpen(val);
|
|
||||||
if (!val) passwordForm.reset();
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<CredenzaContent>
|
|
||||||
<CredenzaHeader>
|
|
||||||
<CredenzaTitle>
|
|
||||||
{t("resourcePasswordSetupTitle")}
|
|
||||||
</CredenzaTitle>
|
|
||||||
<CredenzaDescription>
|
|
||||||
{t("resourcePasswordSetupTitleDescription")}
|
|
||||||
</CredenzaDescription>
|
|
||||||
</CredenzaHeader>
|
|
||||||
<CredenzaBody>
|
|
||||||
<Form {...passwordForm}>
|
|
||||||
<form
|
|
||||||
onSubmit={passwordForm.handleSubmit((data) => {
|
|
||||||
form.setValue("password", data);
|
|
||||||
setIsSetPasswordOpen(false);
|
|
||||||
passwordForm.reset();
|
|
||||||
})}
|
|
||||||
className="space-y-4"
|
|
||||||
id="set-password-form"
|
|
||||||
>
|
|
||||||
<FormField
|
|
||||||
control={passwordForm.control}
|
|
||||||
name="password"
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem>
|
|
||||||
<FormLabel>
|
|
||||||
{t("password")}
|
|
||||||
</FormLabel>
|
|
||||||
<FormControl>
|
|
||||||
<Input
|
|
||||||
autoComplete="off"
|
|
||||||
type="password"
|
|
||||||
{...field}
|
|
||||||
/>
|
|
||||||
</FormControl>
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</form>
|
|
||||||
</Form>
|
|
||||||
</CredenzaBody>
|
|
||||||
<CredenzaFooter>
|
|
||||||
<CredenzaClose asChild>
|
|
||||||
<Button variant="outline">{t("close")}</Button>
|
|
||||||
</CredenzaClose>
|
|
||||||
<Button type="submit" form="set-password-form">
|
|
||||||
{t("resourcePasswordSubmit")}
|
|
||||||
</Button>
|
|
||||||
</CredenzaFooter>
|
|
||||||
</CredenzaContent>
|
|
||||||
</Credenza>
|
|
||||||
|
|
||||||
{/* Pincode Credenza */}
|
|
||||||
<Credenza
|
|
||||||
open={isSetPincodeOpen}
|
|
||||||
onOpenChange={(val) => {
|
|
||||||
setIsSetPincodeOpen(val);
|
|
||||||
if (!val) pincodeForm.reset();
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<CredenzaContent>
|
|
||||||
<CredenzaHeader>
|
|
||||||
<CredenzaTitle>
|
|
||||||
{t("resourcePincodeSetupTitle")}
|
|
||||||
</CredenzaTitle>
|
|
||||||
<CredenzaDescription>
|
|
||||||
{t("resourcePincodeSetupTitleDescription")}
|
|
||||||
</CredenzaDescription>
|
|
||||||
</CredenzaHeader>
|
|
||||||
<CredenzaBody>
|
|
||||||
<Form {...pincodeForm}>
|
|
||||||
<form
|
|
||||||
onSubmit={pincodeForm.handleSubmit((data) => {
|
|
||||||
form.setValue("pincode", data);
|
|
||||||
setIsSetPincodeOpen(false);
|
|
||||||
pincodeForm.reset();
|
|
||||||
})}
|
|
||||||
className="space-y-4"
|
|
||||||
id="set-pincode-form"
|
|
||||||
>
|
|
||||||
<FormField
|
|
||||||
control={pincodeForm.control}
|
|
||||||
name="pincode"
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem>
|
|
||||||
<FormLabel>
|
|
||||||
{t("resourcePincode")}
|
|
||||||
</FormLabel>
|
|
||||||
<FormControl>
|
|
||||||
<div className="flex justify-center">
|
|
||||||
<InputOTP
|
|
||||||
autoComplete="false"
|
|
||||||
maxLength={6}
|
|
||||||
{...field}
|
|
||||||
>
|
|
||||||
<InputOTPGroup className="flex">
|
|
||||||
<InputOTPSlot
|
|
||||||
index={0}
|
|
||||||
obscured
|
|
||||||
/>
|
|
||||||
<InputOTPSlot
|
|
||||||
index={1}
|
|
||||||
obscured
|
|
||||||
/>
|
|
||||||
<InputOTPSlot
|
|
||||||
index={2}
|
|
||||||
obscured
|
|
||||||
/>
|
|
||||||
<InputOTPSlot
|
|
||||||
index={3}
|
|
||||||
obscured
|
|
||||||
/>
|
|
||||||
<InputOTPSlot
|
|
||||||
index={4}
|
|
||||||
obscured
|
|
||||||
/>
|
|
||||||
<InputOTPSlot
|
|
||||||
index={5}
|
|
||||||
obscured
|
|
||||||
/>
|
|
||||||
</InputOTPGroup>
|
|
||||||
</InputOTP>
|
|
||||||
</div>
|
|
||||||
</FormControl>
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</form>
|
|
||||||
</Form>
|
|
||||||
</CredenzaBody>
|
|
||||||
<CredenzaFooter>
|
|
||||||
<CredenzaClose asChild>
|
|
||||||
<Button variant="outline">{t("close")}</Button>
|
|
||||||
</CredenzaClose>
|
|
||||||
<Button type="submit" form="set-pincode-form">
|
|
||||||
{t("resourcePincodeSubmit")}
|
|
||||||
</Button>
|
|
||||||
</CredenzaFooter>
|
|
||||||
</CredenzaContent>
|
|
||||||
</Credenza>
|
|
||||||
|
|
||||||
{/* Header Auth Credenza */}
|
|
||||||
<Credenza
|
|
||||||
open={isSetHeaderAuthOpen}
|
|
||||||
onOpenChange={(val) => {
|
|
||||||
setIsSetHeaderAuthOpen(val);
|
|
||||||
if (!val) headerAuthForm.reset();
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<CredenzaContent>
|
|
||||||
<CredenzaHeader>
|
|
||||||
<CredenzaTitle>
|
|
||||||
{t("resourceHeaderAuthSetupTitle")}
|
|
||||||
</CredenzaTitle>
|
|
||||||
<CredenzaDescription>
|
|
||||||
{t("resourceHeaderAuthSetupTitleDescription")}
|
|
||||||
</CredenzaDescription>
|
|
||||||
</CredenzaHeader>
|
|
||||||
<CredenzaBody>
|
|
||||||
<Form {...headerAuthForm}>
|
|
||||||
<form
|
|
||||||
onSubmit={headerAuthForm.handleSubmit(
|
|
||||||
(data) => {
|
|
||||||
form.setValue("headerAuth", data);
|
|
||||||
setIsSetHeaderAuthOpen(false);
|
|
||||||
headerAuthForm.reset();
|
|
||||||
}
|
|
||||||
)}
|
|
||||||
className="space-y-4"
|
|
||||||
id="set-header-auth-form"
|
|
||||||
>
|
|
||||||
<FormField
|
|
||||||
control={headerAuthForm.control}
|
|
||||||
name="user"
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem>
|
|
||||||
<FormLabel>{t("user")}</FormLabel>
|
|
||||||
<FormControl>
|
|
||||||
<Input
|
|
||||||
autoComplete="off"
|
|
||||||
type="text"
|
|
||||||
{...field}
|
|
||||||
/>
|
|
||||||
</FormControl>
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
<FormField
|
|
||||||
control={headerAuthForm.control}
|
|
||||||
name="password"
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem>
|
|
||||||
<FormLabel>
|
|
||||||
{t("password")}
|
|
||||||
</FormLabel>
|
|
||||||
<FormControl>
|
|
||||||
<Input
|
|
||||||
autoComplete="off"
|
|
||||||
type="password"
|
|
||||||
{...field}
|
|
||||||
/>
|
|
||||||
</FormControl>
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
<FormField
|
|
||||||
control={headerAuthForm.control}
|
|
||||||
name="extendedCompatibility"
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem>
|
|
||||||
<FormControl>
|
|
||||||
<SwitchInput
|
|
||||||
id="header-auth-compatibility-toggle"
|
|
||||||
label={t(
|
|
||||||
"headerAuthCompatibility"
|
|
||||||
)}
|
|
||||||
description={t(
|
|
||||||
"headerAuthCompatibilityInfo"
|
|
||||||
)}
|
|
||||||
checked={field.value}
|
|
||||||
onCheckedChange={
|
|
||||||
field.onChange
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</FormControl>
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</form>
|
|
||||||
</Form>
|
|
||||||
</CredenzaBody>
|
|
||||||
<CredenzaFooter>
|
|
||||||
<CredenzaClose asChild>
|
|
||||||
<Button variant="outline">{t("close")}</Button>
|
|
||||||
</CredenzaClose>
|
|
||||||
<Button type="submit" form="set-header-auth-form">
|
|
||||||
{t("resourceHeaderAuthSubmit")}
|
|
||||||
</Button>
|
|
||||||
</CredenzaFooter>
|
|
||||||
</CredenzaContent>
|
|
||||||
</Credenza>
|
|
||||||
|
|
||||||
<Form {...form}>
|
|
||||||
<form action={formAction}>
|
|
||||||
<SettingsSection>
|
|
||||||
<SettingsSectionHeader>
|
|
||||||
<SettingsSectionTitle>
|
|
||||||
{t("resourceAuthMethods")}
|
|
||||||
</SettingsSectionTitle>
|
|
||||||
<SettingsSectionDescription>
|
|
||||||
{t("resourcePolicyAuthMethodsDescription")}
|
|
||||||
</SettingsSectionDescription>
|
|
||||||
</SettingsSectionHeader>
|
|
||||||
<SettingsSectionBody>
|
|
||||||
<SettingsSectionForm>
|
|
||||||
{/* Password row */}
|
|
||||||
<div className="flex items-center justify-between border rounded-md p-2 mb-4">
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
"flex items-center text-sm gap-x-2",
|
|
||||||
hasPassword && "text-green-500"
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<Key size="14" />
|
|
||||||
<span>
|
|
||||||
{t("resourcePasswordProtection", {
|
|
||||||
status: hasPassword
|
|
||||||
? t("enabled")
|
|
||||||
: t("disabled")
|
|
||||||
})}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="secondary"
|
|
||||||
size="sm"
|
|
||||||
disabled={readonly}
|
|
||||||
onClick={
|
|
||||||
hasPassword
|
|
||||||
? () =>
|
|
||||||
form.setValue(
|
|
||||||
"password",
|
|
||||||
null
|
|
||||||
)
|
|
||||||
: () =>
|
|
||||||
setIsSetPasswordOpen(true)
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{hasPassword
|
|
||||||
? t("passwordRemove")
|
|
||||||
: t("passwordAdd")}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Pincode row */}
|
|
||||||
<div className="flex items-center justify-between border rounded-md p-2">
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
"flex items-center gap-x-2 text-sm",
|
|
||||||
hasPincode && "text-green-500"
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<Binary size="14" />
|
|
||||||
<span>
|
|
||||||
{t("resourcePincodeProtection", {
|
|
||||||
status: hasPincode
|
|
||||||
? t("enabled")
|
|
||||||
: t("disabled")
|
|
||||||
})}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="secondary"
|
|
||||||
size="sm"
|
|
||||||
disabled={readonly}
|
|
||||||
onClick={
|
|
||||||
hasPincode
|
|
||||||
? () =>
|
|
||||||
form.setValue(
|
|
||||||
"pincode",
|
|
||||||
null
|
|
||||||
)
|
|
||||||
: () =>
|
|
||||||
setIsSetPincodeOpen(true)
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{hasPincode
|
|
||||||
? t("pincodeRemove")
|
|
||||||
: t("pincodeAdd")}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Header auth row */}
|
|
||||||
<div className="flex items-center justify-between border rounded-md p-2">
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
"flex items-center gap-x-2 text-sm",
|
|
||||||
hasHeaderAuth && "text-green-500"
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<Bot size="14" />
|
|
||||||
<span>
|
|
||||||
{hasHeaderAuth
|
|
||||||
? t(
|
|
||||||
"resourceHeaderAuthProtectionEnabled"
|
|
||||||
)
|
|
||||||
: t(
|
|
||||||
"resourceHeaderAuthProtectionDisabled"
|
|
||||||
)}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="secondary"
|
|
||||||
size="sm"
|
|
||||||
disabled={readonly}
|
|
||||||
onClick={
|
|
||||||
hasHeaderAuth
|
|
||||||
? () =>
|
|
||||||
form.setValue(
|
|
||||||
"headerAuth",
|
|
||||||
null
|
|
||||||
)
|
|
||||||
: () =>
|
|
||||||
setIsSetHeaderAuthOpen(
|
|
||||||
true
|
|
||||||
)
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{hasHeaderAuth
|
|
||||||
? t("headerAuthRemove")
|
|
||||||
: t("headerAuthAdd")}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</SettingsSectionForm>
|
|
||||||
</SettingsSectionBody>
|
|
||||||
|
|
||||||
<SettingsSectionFooter>
|
|
||||||
<Button
|
|
||||||
type="submit"
|
|
||||||
loading={isSubmitting}
|
|
||||||
disabled={readonly || isSubmitting}
|
|
||||||
>
|
|
||||||
{t("authMethodsSave")}
|
|
||||||
</Button>
|
|
||||||
</SettingsSectionFooter>
|
|
||||||
</SettingsSection>
|
|
||||||
</form>
|
|
||||||
</Form>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -12,17 +12,11 @@ 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 { useTranslations } from "next-intl";
|
||||||
|
|
||||||
import { createApiClient } from "@app/lib/api";
|
|
||||||
import { useRouter } from "next/navigation";
|
|
||||||
|
|
||||||
import { useMemo } from "react";
|
import { useMemo } from "react";
|
||||||
import { EditPolicyAuthMethodsSectionForm } from "./EditPolicyAuthMethodsSectionForm";
|
import { HorizontalTabs } from "@app/components/HorizontalTabs";
|
||||||
import { EditPolicyNameSectionForm } from "./EditPolicyNameSectionForm";
|
import { EditPolicyNameSectionForm } from "./EditPolicyNameSectionForm";
|
||||||
import { EditPolicyUsersRolesSectionForm } from "./EditPolicyUserRolesSectionForm";
|
import { PolicyAuthStackSection } from "./PolicyAuthStackSection";
|
||||||
import { EditPolicyOtpEmailSectionForm } from "./EditPolicyOtpEmailSectionForm";
|
import { PolicyAccessRulesSection } from "./PolicyAccessRulesSection";
|
||||||
import { EditPolicyRulesSectionForm } from "./EditPolicyRulesSectionForm";
|
|
||||||
|
|
||||||
// ─── EditPolicyForm ─────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
export type EditPolicyFormProps = {
|
export type EditPolicyFormProps = {
|
||||||
hidePolicyNameForm?: boolean;
|
hidePolicyNameForm?: boolean;
|
||||||
@@ -35,19 +29,15 @@ export function EditPolicyForm({
|
|||||||
readonly,
|
readonly,
|
||||||
resourceId
|
resourceId
|
||||||
}: EditPolicyFormProps) {
|
}: EditPolicyFormProps) {
|
||||||
const { org } = useOrgContext();
|
|
||||||
const t = useTranslations();
|
const t = useTranslations();
|
||||||
|
const { org } = useOrgContext();
|
||||||
const { env } = useEnvContext();
|
const { env } = useEnvContext();
|
||||||
const api = createApiClient({ env });
|
|
||||||
// const [, formAction, isSubmitting] = useActionState(onSubmit, null);
|
|
||||||
const { isPaidUser } = usePaidStatus();
|
const { isPaidUser } = usePaidStatus();
|
||||||
|
|
||||||
const router = useRouter();
|
|
||||||
|
|
||||||
// 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 policyLevelReadonly = readonly || isOverlay;
|
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
|
||||||
@@ -81,32 +71,54 @@ export function EditPolicyForm({
|
|||||||
return <></>;
|
return <></>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const authSection = (
|
||||||
|
<PolicyAuthStackSection
|
||||||
|
mode="edit"
|
||||||
|
orgId={org.org.orgId}
|
||||||
|
allIdps={allIdps}
|
||||||
|
emailEnabled={env.email.emailEnabled}
|
||||||
|
readonly={readonly}
|
||||||
|
resourceId={resourceId}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
const rulesSection = (
|
||||||
|
<PolicyAccessRulesSection
|
||||||
|
mode="edit"
|
||||||
|
isMaxmindAvailable={isMaxmindAvailable}
|
||||||
|
isMaxmindAsnAvailable={isMaxmindASNAvailable}
|
||||||
|
readonly={readonly}
|
||||||
|
resourceId={resourceId}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SettingsContainer>
|
<SettingsContainer>
|
||||||
{!hidePolicyNameForm && (
|
{!hidePolicyNameForm && !isOverlay && (
|
||||||
<EditPolicyNameSectionForm readonly={policyLevelReadonly} />
|
<EditPolicyNameSectionForm readonly={readonly} />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<EditPolicyUsersRolesSectionForm
|
{authSection}
|
||||||
orgId={org.org.orgId}
|
|
||||||
allIdps={allIdps}
|
|
||||||
readonly={readonly}
|
|
||||||
resourceId={resourceId}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<EditPolicyAuthMethodsSectionForm readonly={policyLevelReadonly} />
|
{rulesSection}
|
||||||
|
|
||||||
<EditPolicyOtpEmailSectionForm
|
|
||||||
emailEnabled={env.email.emailEnabled}
|
|
||||||
readonly={policyLevelReadonly}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<EditPolicyRulesSectionForm
|
|
||||||
isMaxmindAvailable={isMaxmindAvailable}
|
|
||||||
isMaxmindAsnAvailable={isMaxmindASNAvailable}
|
|
||||||
readonly={readonly}
|
|
||||||
resourceId={resourceId}
|
|
||||||
/>
|
|
||||||
</SettingsContainer>
|
</SettingsContainer>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -137,7 +137,7 @@ export function EditPolicyNameSectionForm({
|
|||||||
</SettingsSectionDescription>
|
</SettingsSectionDescription>
|
||||||
</SettingsSectionHeader>
|
</SettingsSectionHeader>
|
||||||
<SettingsSectionBody>
|
<SettingsSectionBody>
|
||||||
<SettingsSectionForm>
|
<SettingsSectionForm variant="half">
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name="name"
|
name="name"
|
||||||
|
|||||||
@@ -1,294 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import {
|
|
||||||
SettingsSection,
|
|
||||||
SettingsSectionBody,
|
|
||||||
SettingsSectionDescription,
|
|
||||||
SettingsSectionFooter,
|
|
||||||
SettingsSectionForm,
|
|
||||||
SettingsSectionHeader,
|
|
||||||
SettingsSectionTitle
|
|
||||||
} from "@app/components/Settings";
|
|
||||||
|
|
||||||
import { useTranslations } from "next-intl";
|
|
||||||
|
|
||||||
import z from "zod";
|
|
||||||
|
|
||||||
import { createPolicySchema, type PolicyFormValues } from ".";
|
|
||||||
|
|
||||||
import { toast } from "@app/hooks/useToast";
|
|
||||||
import { createApiClient, formatAxiosError } from "@app/lib/api";
|
|
||||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
|
||||||
import type { AxiosResponse } from "axios";
|
|
||||||
import { SwitchInput } from "@app/components/SwitchInput";
|
|
||||||
import { Tag, TagInput } from "@app/components/tags/tag-input";
|
|
||||||
import { Alert, AlertDescription, AlertTitle } from "@app/components/ui/alert";
|
|
||||||
import { Button } from "@app/components/ui/button";
|
|
||||||
import {
|
|
||||||
Form,
|
|
||||||
FormControl,
|
|
||||||
FormDescription,
|
|
||||||
FormField,
|
|
||||||
FormItem,
|
|
||||||
FormLabel
|
|
||||||
} from "@app/components/ui/form";
|
|
||||||
import { InfoPopup } from "@app/components/ui/info-popup";
|
|
||||||
|
|
||||||
import { InfoIcon, Plus } from "lucide-react";
|
|
||||||
|
|
||||||
import { useActionState, useState } from "react";
|
|
||||||
import { useForm, UseFormReturn, useWatch } from "react-hook-form";
|
|
||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
|
||||||
import { useRouter } from "next/navigation";
|
|
||||||
import { useResourcePolicyContext } from "@app/providers/ResourcePolicyProvider";
|
|
||||||
|
|
||||||
// ─── PolicyOtpEmailSection ────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
type PolicyOtpEmailSectionProps = {
|
|
||||||
emailEnabled: boolean;
|
|
||||||
readonly?: boolean;
|
|
||||||
};
|
|
||||||
|
|
||||||
export function EditPolicyOtpEmailSectionForm({
|
|
||||||
emailEnabled,
|
|
||||||
readonly
|
|
||||||
}: PolicyOtpEmailSectionProps) {
|
|
||||||
const t = useTranslations();
|
|
||||||
|
|
||||||
const { policy } = useResourcePolicyContext();
|
|
||||||
const router = useRouter();
|
|
||||||
|
|
||||||
const api = createApiClient(useEnvContext());
|
|
||||||
|
|
||||||
const form = useForm({
|
|
||||||
resolver: zodResolver(
|
|
||||||
createPolicySchema.pick({
|
|
||||||
emailWhitelistEnabled: true,
|
|
||||||
emails: true
|
|
||||||
})
|
|
||||||
),
|
|
||||||
defaultValues: {
|
|
||||||
emailWhitelistEnabled: policy.emailWhitelistEnabled,
|
|
||||||
emails: policy.emailWhiteList.map((email) => ({
|
|
||||||
id: email.whiteListId.toString(),
|
|
||||||
text: email.email
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const whitelistEnabled = useWatch({
|
|
||||||
control: form.control,
|
|
||||||
name: "emailWhitelistEnabled"
|
|
||||||
});
|
|
||||||
|
|
||||||
const [isExpanded, setIsExpanded] = useState(whitelistEnabled);
|
|
||||||
const [activeEmailTagIndex, setActiveEmailTagIndex] = useState<
|
|
||||||
number | null
|
|
||||||
>(null);
|
|
||||||
|
|
||||||
const [, formAction, isSubmitting] = useActionState(onSubmit, null);
|
|
||||||
|
|
||||||
async function onSubmit() {
|
|
||||||
if (readonly) return;
|
|
||||||
const isValid = await form.trigger();
|
|
||||||
|
|
||||||
if (!isValid) return;
|
|
||||||
|
|
||||||
const payload = form.getValues();
|
|
||||||
|
|
||||||
try {
|
|
||||||
const res = await api
|
|
||||||
.put<AxiosResponse<{}>>(
|
|
||||||
`/resource-policy/${policy.resourcePolicyId}/whitelist`,
|
|
||||||
{
|
|
||||||
emailWhitelistEnabled: payload.emailWhitelistEnabled,
|
|
||||||
emails: payload.emails?.map((e) => e.text) ?? []
|
|
||||||
}
|
|
||||||
)
|
|
||||||
.catch((e) => {
|
|
||||||
toast({
|
|
||||||
variant: "destructive",
|
|
||||||
title: t("policyErrorUpdate"),
|
|
||||||
description: formatAxiosError(
|
|
||||||
e,
|
|
||||||
t("policyErrorUpdateDescription")
|
|
||||||
)
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
if (res && res.status === 200) {
|
|
||||||
toast({
|
|
||||||
title: t("success"),
|
|
||||||
description: t("policyUpdatedSuccess")
|
|
||||||
});
|
|
||||||
router.refresh();
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
toast({
|
|
||||||
variant: "destructive",
|
|
||||||
title: t("policyErrorUpdate"),
|
|
||||||
description: t("policyErrorUpdateMessageDescription")
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!isExpanded) {
|
|
||||||
return (
|
|
||||||
<SettingsSection>
|
|
||||||
<SettingsSectionHeader>
|
|
||||||
<SettingsSectionTitle>
|
|
||||||
{t("otpEmailTitle")}
|
|
||||||
</SettingsSectionTitle>
|
|
||||||
<SettingsSectionDescription>
|
|
||||||
{t("otpEmailTitleDescription")}
|
|
||||||
</SettingsSectionDescription>
|
|
||||||
</SettingsSectionHeader>
|
|
||||||
<SettingsSectionBody>
|
|
||||||
{!readonly ? (
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="outline"
|
|
||||||
onClick={() => setIsExpanded(true)}
|
|
||||||
>
|
|
||||||
<Plus className="mr-2 h-4 w-4" />
|
|
||||||
{t("resourcePolicyOtpEmailAdd")}
|
|
||||||
</Button>
|
|
||||||
) : (
|
|
||||||
<div className="text-muted-foreground flex items-center h-full size-full bg-muted rounded-md px-8 py-6 border-dashed text-sm">
|
|
||||||
<p>{t("resourcePolicyOtpEmpty")}</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</SettingsSectionBody>
|
|
||||||
</SettingsSection>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Form {...form}>
|
|
||||||
<form action={formAction}>
|
|
||||||
<SettingsSection>
|
|
||||||
<SettingsSectionHeader>
|
|
||||||
<SettingsSectionTitle>
|
|
||||||
{t("otpEmailTitle")}
|
|
||||||
</SettingsSectionTitle>
|
|
||||||
<SettingsSectionDescription>
|
|
||||||
{t("otpEmailTitleDescription")}
|
|
||||||
</SettingsSectionDescription>
|
|
||||||
</SettingsSectionHeader>
|
|
||||||
<SettingsSectionBody>
|
|
||||||
<SettingsSectionForm>
|
|
||||||
{!emailEnabled && (
|
|
||||||
<Alert variant="neutral" className="mb-4">
|
|
||||||
<InfoIcon className="h-4 w-4" />
|
|
||||||
<AlertTitle className="font-semibold">
|
|
||||||
{t("otpEmailSmtpRequired")}
|
|
||||||
</AlertTitle>
|
|
||||||
<AlertDescription>
|
|
||||||
{t("otpEmailSmtpRequiredDescription")}
|
|
||||||
</AlertDescription>
|
|
||||||
</Alert>
|
|
||||||
)}
|
|
||||||
<SwitchInput
|
|
||||||
id="whitelist-toggle"
|
|
||||||
label={t("otpEmailWhitelist")}
|
|
||||||
defaultChecked={whitelistEnabled}
|
|
||||||
onCheckedChange={(val) => {
|
|
||||||
form.setValue("emailWhitelistEnabled", val);
|
|
||||||
}}
|
|
||||||
disabled={readonly || !emailEnabled}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{whitelistEnabled && emailEnabled && (
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name="emails"
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem>
|
|
||||||
<FormLabel>
|
|
||||||
<InfoPopup
|
|
||||||
text={t(
|
|
||||||
"otpEmailWhitelistList"
|
|
||||||
)}
|
|
||||||
info={t(
|
|
||||||
"otpEmailWhitelistListDescription"
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</FormLabel>
|
|
||||||
<FormControl>
|
|
||||||
{/* @ts-ignore */}
|
|
||||||
<TagInput
|
|
||||||
{...field}
|
|
||||||
activeTagIndex={
|
|
||||||
activeEmailTagIndex
|
|
||||||
}
|
|
||||||
size="sm"
|
|
||||||
validateTag={(tag) => {
|
|
||||||
return z
|
|
||||||
.email()
|
|
||||||
.or(
|
|
||||||
z
|
|
||||||
.string()
|
|
||||||
.regex(
|
|
||||||
/^\*@[\w.-]+\.[a-zA-Z]{2,}$/,
|
|
||||||
{
|
|
||||||
message:
|
|
||||||
t(
|
|
||||||
"otpEmailErrorInvalid"
|
|
||||||
)
|
|
||||||
}
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.safeParse(tag)
|
|
||||||
.success;
|
|
||||||
}}
|
|
||||||
setActiveTagIndex={
|
|
||||||
setActiveEmailTagIndex
|
|
||||||
}
|
|
||||||
placeholder={t(
|
|
||||||
"otpEmailEnter"
|
|
||||||
)}
|
|
||||||
tags={
|
|
||||||
form.getValues()
|
|
||||||
.emails ?? []
|
|
||||||
}
|
|
||||||
setTags={(newEmails) => {
|
|
||||||
if (!readonly) {
|
|
||||||
form.setValue(
|
|
||||||
"emails",
|
|
||||||
newEmails as [
|
|
||||||
Tag,
|
|
||||||
...Tag[]
|
|
||||||
]
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
allowDuplicates={false}
|
|
||||||
sortTags={true}
|
|
||||||
/>
|
|
||||||
</FormControl>
|
|
||||||
<FormDescription>
|
|
||||||
{t("otpEmailEnterDescription")}
|
|
||||||
</FormDescription>
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</SettingsSectionForm>
|
|
||||||
|
|
||||||
<SettingsSectionFooter>
|
|
||||||
<Button
|
|
||||||
type="submit"
|
|
||||||
loading={isSubmitting}
|
|
||||||
disabled={
|
|
||||||
readonly || isSubmitting || !emailEnabled
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{t("otpEmailWhitelistSave")}
|
|
||||||
</Button>
|
|
||||||
</SettingsSectionFooter>
|
|
||||||
</SettingsSectionBody>
|
|
||||||
</SettingsSection>
|
|
||||||
</form>
|
|
||||||
</Form>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,530 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import {
|
|
||||||
SettingsSection,
|
|
||||||
SettingsSectionBody,
|
|
||||||
SettingsSectionDescription,
|
|
||||||
SettingsSectionFooter,
|
|
||||||
SettingsSectionForm,
|
|
||||||
SettingsSectionHeader,
|
|
||||||
SettingsSectionTitle
|
|
||||||
} from "@app/components/Settings";
|
|
||||||
|
|
||||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
|
||||||
|
|
||||||
import { getUserDisplayName } from "@app/lib/getUserDisplayName";
|
|
||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
|
||||||
import { UserType } from "@server/types/UserTypes";
|
|
||||||
import { useTranslations } from "next-intl";
|
|
||||||
|
|
||||||
import { toast } from "@app/hooks/useToast";
|
|
||||||
import { createApiClient, formatAxiosError } from "@app/lib/api";
|
|
||||||
import type { AxiosResponse } from "axios";
|
|
||||||
import { useRouter } from "next/navigation";
|
|
||||||
import { createPolicySchema } from ".";
|
|
||||||
|
|
||||||
import {
|
|
||||||
RolesSelector,
|
|
||||||
type SelectedRole
|
|
||||||
} from "@app/components/roles-selector";
|
|
||||||
import { UsersSelector } from "@app/components/users-selector";
|
|
||||||
import { SwitchInput } from "@app/components/SwitchInput";
|
|
||||||
import { Button } from "@app/components/ui/button";
|
|
||||||
import {
|
|
||||||
Form,
|
|
||||||
FormControl,
|
|
||||||
FormDescription,
|
|
||||||
FormField,
|
|
||||||
FormItem,
|
|
||||||
FormLabel,
|
|
||||||
FormMessage
|
|
||||||
} from "@app/components/ui/form";
|
|
||||||
import {
|
|
||||||
Select,
|
|
||||||
SelectContent,
|
|
||||||
SelectItem,
|
|
||||||
SelectTrigger,
|
|
||||||
SelectValue
|
|
||||||
} from "@app/components/ui/select";
|
|
||||||
|
|
||||||
import { useResourcePolicyContext } from "@app/providers/ResourcePolicyProvider";
|
|
||||||
import { resourceQueries } from "@app/lib/queries";
|
|
||||||
import { useQuery } from "@tanstack/react-query";
|
|
||||||
import { useActionState, useEffect, useMemo, useRef, useState } from "react";
|
|
||||||
import { useForm, useWatch } from "react-hook-form";
|
|
||||||
|
|
||||||
// ─── PolicyUsersRolesSection ──────────────────────────────────────────────────
|
|
||||||
|
|
||||||
type PolicyUsersRolesSectionProps = {
|
|
||||||
orgId: string;
|
|
||||||
allIdps: { id: number; text: string }[];
|
|
||||||
readonly?: boolean;
|
|
||||||
resourceId?: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
type OverlaySelectedRole = SelectedRole & { isAdmin: boolean };
|
|
||||||
|
|
||||||
export function EditPolicyUsersRolesSectionForm({
|
|
||||||
orgId,
|
|
||||||
allIdps,
|
|
||||||
readonly,
|
|
||||||
resourceId
|
|
||||||
}: PolicyUsersRolesSectionProps) {
|
|
||||||
const t = useTranslations();
|
|
||||||
|
|
||||||
const router = useRouter();
|
|
||||||
|
|
||||||
const { policy } = useResourcePolicyContext();
|
|
||||||
|
|
||||||
const api = createApiClient(useEnvContext());
|
|
||||||
|
|
||||||
// ── Resource overlay: fetch resource-specific roles & users ──────────────
|
|
||||||
const isResourceOverlay = resourceId !== undefined;
|
|
||||||
|
|
||||||
const { data: resourceRolesData } = useQuery({
|
|
||||||
...resourceQueries.resourceRoles({ resourceId: resourceId! }),
|
|
||||||
enabled: isResourceOverlay
|
|
||||||
});
|
|
||||||
|
|
||||||
const { data: resourceUsersData } = useQuery({
|
|
||||||
...resourceQueries.resourceUsers({ resourceId: resourceId! }),
|
|
||||||
enabled: isResourceOverlay
|
|
||||||
});
|
|
||||||
|
|
||||||
// IDs from the policy (locked — cannot be removed)
|
|
||||||
const policyRoleLockedIds = useMemo(
|
|
||||||
() => new Set(policy.roles.map((r) => r.roleId.toString())),
|
|
||||||
[policy.roles]
|
|
||||||
);
|
|
||||||
const policyUserLockedIds = useMemo(
|
|
||||||
() => new Set(policy.users.map((u) => u.userId)),
|
|
||||||
[policy.users]
|
|
||||||
);
|
|
||||||
|
|
||||||
// Policy entries mapped to selector format
|
|
||||||
const policyRoleItems = useMemo<OverlaySelectedRole[]>(
|
|
||||||
() =>
|
|
||||||
policy.roles.map((r) => ({
|
|
||||||
id: r.roleId.toString(),
|
|
||||||
text: r.name,
|
|
||||||
isAdmin: false
|
|
||||||
})),
|
|
||||||
[policy.roles]
|
|
||||||
);
|
|
||||||
const policyUserItems = useMemo(
|
|
||||||
() =>
|
|
||||||
policy.users.map((u) => ({
|
|
||||||
id: u.userId,
|
|
||||||
text: `${getUserDisplayName({ email: u.email, username: u.username })}${u.type !== UserType.Internal ? ` (${u.idpName})` : ""}`
|
|
||||||
})),
|
|
||||||
[policy.users]
|
|
||||||
);
|
|
||||||
|
|
||||||
// Track the initial resource-specific roles/users for diffing on save
|
|
||||||
const initialResourceRoleIdsRef = useRef<Set<string>>(new Set());
|
|
||||||
const initialResourceUserIdsRef = useRef<Set<string>>(new Set());
|
|
||||||
|
|
||||||
// Combined selected roles/users (policy + resource-specific)
|
|
||||||
const [combinedRoles, setCombinedRoles] =
|
|
||||||
useState<OverlaySelectedRole[]>(policyRoleItems);
|
|
||||||
const [combinedUsers, setCombinedUsers] = useState(policyUserItems);
|
|
||||||
const [resourceRolesInitialized, setResourceRolesInitialized] =
|
|
||||||
useState(false);
|
|
||||||
const [resourceUsersInitialized, setResourceUsersInitialized] =
|
|
||||||
useState(false);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!isResourceOverlay || resourceRolesInitialized) return;
|
|
||||||
if (!resourceRolesData) return;
|
|
||||||
|
|
||||||
const resourceSpecific = resourceRolesData
|
|
||||||
.filter((r) => !policyRoleLockedIds.has(r.roleId.toString()))
|
|
||||||
.map((r) => ({
|
|
||||||
id: r.roleId.toString(),
|
|
||||||
text: r.name,
|
|
||||||
isAdmin: Boolean(r.isAdmin)
|
|
||||||
}));
|
|
||||||
|
|
||||||
initialResourceRoleIdsRef.current = new Set(
|
|
||||||
resourceSpecific.map((r) => r.id)
|
|
||||||
);
|
|
||||||
setCombinedRoles(
|
|
||||||
[...policyRoleItems, ...resourceSpecific].filter(
|
|
||||||
(role) => !role.isAdmin
|
|
||||||
)
|
|
||||||
);
|
|
||||||
setResourceRolesInitialized(true);
|
|
||||||
}, [
|
|
||||||
isResourceOverlay,
|
|
||||||
resourceRolesData,
|
|
||||||
resourceRolesInitialized,
|
|
||||||
policyRoleItems,
|
|
||||||
policyRoleLockedIds
|
|
||||||
]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!isResourceOverlay || resourceUsersInitialized) return;
|
|
||||||
if (!resourceUsersData) return;
|
|
||||||
|
|
||||||
const resourceSpecific = resourceUsersData
|
|
||||||
.filter((u) => !policyUserLockedIds.has(u.userId))
|
|
||||||
.map((u) => ({
|
|
||||||
id: u.userId,
|
|
||||||
text: `${getUserDisplayName({ email: u.email ?? undefined, username: u.username ?? undefined })}${u.type !== UserType.Internal ? ` (${u.idpName})` : ""}`
|
|
||||||
}));
|
|
||||||
|
|
||||||
initialResourceUserIdsRef.current = new Set(
|
|
||||||
resourceSpecific.map((u) => u.id)
|
|
||||||
);
|
|
||||||
setCombinedUsers([...policyUserItems, ...resourceSpecific]);
|
|
||||||
setResourceUsersInitialized(true);
|
|
||||||
}, [
|
|
||||||
isResourceOverlay,
|
|
||||||
resourceUsersData,
|
|
||||||
resourceUsersInitialized,
|
|
||||||
policyUserItems,
|
|
||||||
policyUserLockedIds
|
|
||||||
]);
|
|
||||||
|
|
||||||
// ── Standard policy form (non-overlay) ──────────────────────────────────
|
|
||||||
const form = useForm({
|
|
||||||
resolver: zodResolver(
|
|
||||||
createPolicySchema.pick({
|
|
||||||
sso: true,
|
|
||||||
skipToIdpId: true,
|
|
||||||
users: true,
|
|
||||||
roles: true
|
|
||||||
})
|
|
||||||
),
|
|
||||||
defaultValues: {
|
|
||||||
sso: policy.sso,
|
|
||||||
skipToIdpId: policy.idpId,
|
|
||||||
roles: policyRoleItems,
|
|
||||||
users: policyUserItems
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const ssoEnabled = useWatch({ control: form.control, name: "sso" });
|
|
||||||
const selectedIdpId = useWatch({
|
|
||||||
control: form.control,
|
|
||||||
name: "skipToIdpId"
|
|
||||||
});
|
|
||||||
|
|
||||||
const [, formAction, isSubmitting] = useActionState(onSubmit, null);
|
|
||||||
const [isSavingOverlay, setIsSavingOverlay] = useState(false);
|
|
||||||
|
|
||||||
async function onSubmit() {
|
|
||||||
if (readonly) return;
|
|
||||||
|
|
||||||
if (isResourceOverlay) {
|
|
||||||
await saveResourceOverlay();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const isValid = await form.trigger();
|
|
||||||
if (!isValid) return;
|
|
||||||
|
|
||||||
const payload = form.getValues();
|
|
||||||
|
|
||||||
try {
|
|
||||||
const res = await api
|
|
||||||
.put<AxiosResponse<{}>>(
|
|
||||||
`/resource-policy/${policy.resourcePolicyId}/access-control`,
|
|
||||||
{
|
|
||||||
sso: payload.sso,
|
|
||||||
userIds: payload.users.map((user) => user.id),
|
|
||||||
roleIds: payload.roles.map((role) => Number(role.id)),
|
|
||||||
skipToIdpId: payload.skipToIdpId
|
|
||||||
}
|
|
||||||
)
|
|
||||||
.catch((e) => {
|
|
||||||
toast({
|
|
||||||
variant: "destructive",
|
|
||||||
title: t("policyErrorUpdate"),
|
|
||||||
description: formatAxiosError(
|
|
||||||
e,
|
|
||||||
t("policyErrorUpdateDescription")
|
|
||||||
)
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
if (res && res.status === 200) {
|
|
||||||
toast({
|
|
||||||
title: t("success"),
|
|
||||||
description: t("policyUpdatedSuccess")
|
|
||||||
});
|
|
||||||
router.refresh();
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
toast({
|
|
||||||
variant: "destructive",
|
|
||||||
title: t("policyErrorUpdate"),
|
|
||||||
description: t("policyErrorUpdateMessageDescription")
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function saveResourceOverlay() {
|
|
||||||
setIsSavingOverlay(true);
|
|
||||||
try {
|
|
||||||
// Compute which roles/users are resource-specific (non-locked)
|
|
||||||
const currentResourceRoleIds = combinedRoles
|
|
||||||
.filter((r) => !policyRoleLockedIds.has(r.id))
|
|
||||||
.map((r) => Number(r.id));
|
|
||||||
const currentResourceUserIds = combinedUsers
|
|
||||||
.filter((u) => !policyUserLockedIds.has(u.id))
|
|
||||||
.map((u) => u.id);
|
|
||||||
|
|
||||||
// Use bulk-set endpoints (session-authenticated) which replace
|
|
||||||
// all resource-specific roles/users in one call
|
|
||||||
await Promise.all([
|
|
||||||
api.post(`/resource/${resourceId}/roles`, {
|
|
||||||
roleIds: currentResourceRoleIds
|
|
||||||
}),
|
|
||||||
api.post(`/resource/${resourceId}/users`, {
|
|
||||||
userIds: currentResourceUserIds
|
|
||||||
})
|
|
||||||
]);
|
|
||||||
|
|
||||||
// Update refs to reflect new state
|
|
||||||
initialResourceRoleIdsRef.current = new Set(
|
|
||||||
currentResourceRoleIds.map(String)
|
|
||||||
);
|
|
||||||
initialResourceUserIdsRef.current = new Set(currentResourceUserIds);
|
|
||||||
|
|
||||||
toast({
|
|
||||||
title: t("success"),
|
|
||||||
description: t("policyUpdatedSuccess")
|
|
||||||
});
|
|
||||||
router.refresh();
|
|
||||||
} catch (e) {
|
|
||||||
toast({
|
|
||||||
variant: "destructive",
|
|
||||||
title: t("policyErrorUpdate"),
|
|
||||||
description: formatAxiosError(
|
|
||||||
e,
|
|
||||||
t("policyErrorUpdateDescription")
|
|
||||||
)
|
|
||||||
});
|
|
||||||
} finally {
|
|
||||||
setIsSavingOverlay(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const isLoading =
|
|
||||||
isResourceOverlay &&
|
|
||||||
(!resourceRolesInitialized || !resourceUsersInitialized);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Form {...form}>
|
|
||||||
<form action={formAction}>
|
|
||||||
<SettingsSection>
|
|
||||||
<SettingsSectionHeader>
|
|
||||||
<SettingsSectionTitle>
|
|
||||||
{t("resourceUsersRoles")}
|
|
||||||
</SettingsSectionTitle>
|
|
||||||
<SettingsSectionDescription>
|
|
||||||
{t("resourcePolicyUsersRolesDescription")}
|
|
||||||
</SettingsSectionDescription>
|
|
||||||
</SettingsSectionHeader>
|
|
||||||
<SettingsSectionBody>
|
|
||||||
<SettingsSectionForm>
|
|
||||||
<SwitchInput
|
|
||||||
id="sso-toggle"
|
|
||||||
label={t("ssoUse")}
|
|
||||||
defaultChecked={ssoEnabled}
|
|
||||||
onCheckedChange={(val) => {
|
|
||||||
form.setValue("sso", val);
|
|
||||||
}}
|
|
||||||
disabled={readonly || isResourceOverlay}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{ssoEnabled && (
|
|
||||||
<>
|
|
||||||
<FormItem className="flex flex-col items-start">
|
|
||||||
<FormLabel>{t("roles")}</FormLabel>
|
|
||||||
<FormControl>
|
|
||||||
{isResourceOverlay ? (
|
|
||||||
<RolesSelector
|
|
||||||
orgId={orgId}
|
|
||||||
selectedRoles={combinedRoles.filter(
|
|
||||||
(role) => !role.isAdmin
|
|
||||||
)}
|
|
||||||
onSelectRoles={(roles) => {
|
|
||||||
setCombinedRoles(
|
|
||||||
roles
|
|
||||||
.map(
|
|
||||||
(role) => ({
|
|
||||||
...role,
|
|
||||||
isAdmin:
|
|
||||||
Boolean(
|
|
||||||
role.isAdmin
|
|
||||||
)
|
|
||||||
})
|
|
||||||
)
|
|
||||||
.filter(
|
|
||||||
(role) =>
|
|
||||||
!role.isAdmin
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
disabled={isLoading}
|
|
||||||
restrictAdminRole
|
|
||||||
lockedIds={
|
|
||||||
policyRoleLockedIds
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name="roles"
|
|
||||||
render={({ field }) => (
|
|
||||||
<RolesSelector
|
|
||||||
orgId={orgId}
|
|
||||||
selectedRoles={
|
|
||||||
field.value
|
|
||||||
}
|
|
||||||
onSelectRoles={(
|
|
||||||
roles
|
|
||||||
) =>
|
|
||||||
form.setValue(
|
|
||||||
"roles",
|
|
||||||
roles
|
|
||||||
)
|
|
||||||
}
|
|
||||||
disabled={readonly}
|
|
||||||
restrictAdminRole
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</FormControl>
|
|
||||||
<FormMessage />
|
|
||||||
<FormDescription>
|
|
||||||
{t("resourceRoleDescription")}
|
|
||||||
</FormDescription>
|
|
||||||
</FormItem>
|
|
||||||
|
|
||||||
<FormItem className="flex flex-col items-start">
|
|
||||||
<FormLabel>{t("users")}</FormLabel>
|
|
||||||
<FormControl>
|
|
||||||
{isResourceOverlay ? (
|
|
||||||
<UsersSelector
|
|
||||||
orgId={orgId}
|
|
||||||
selectedUsers={
|
|
||||||
combinedUsers
|
|
||||||
}
|
|
||||||
onSelectUsers={
|
|
||||||
setCombinedUsers
|
|
||||||
}
|
|
||||||
disabled={isLoading}
|
|
||||||
lockedIds={
|
|
||||||
policyUserLockedIds
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name="users"
|
|
||||||
render={({ field }) => (
|
|
||||||
<UsersSelector
|
|
||||||
orgId={orgId}
|
|
||||||
selectedUsers={
|
|
||||||
field.value
|
|
||||||
}
|
|
||||||
onSelectUsers={(
|
|
||||||
users
|
|
||||||
) =>
|
|
||||||
form.setValue(
|
|
||||||
"users",
|
|
||||||
users
|
|
||||||
)
|
|
||||||
}
|
|
||||||
disabled={readonly}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</FormControl>
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{ssoEnabled && allIdps.length > 0 && (
|
|
||||||
<div className="space-y-2">
|
|
||||||
<label className="text-sm font-medium">
|
|
||||||
{t("defaultIdentityProvider")}
|
|
||||||
</label>
|
|
||||||
<Select
|
|
||||||
disabled={readonly || isResourceOverlay}
|
|
||||||
onValueChange={(value) => {
|
|
||||||
if (value === "none") {
|
|
||||||
form.setValue(
|
|
||||||
"skipToIdpId",
|
|
||||||
null
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
const id = parseInt(value);
|
|
||||||
form.setValue(
|
|
||||||
"skipToIdpId",
|
|
||||||
id
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
value={
|
|
||||||
selectedIdpId
|
|
||||||
? selectedIdpId.toString()
|
|
||||||
: "none"
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<SelectTrigger className="w-full mt-1">
|
|
||||||
<SelectValue
|
|
||||||
placeholder={t(
|
|
||||||
"selectIdpPlaceholder"
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value="none">
|
|
||||||
{t("none")}
|
|
||||||
</SelectItem>
|
|
||||||
{allIdps.map((idp) => (
|
|
||||||
<SelectItem
|
|
||||||
key={idp.id}
|
|
||||||
value={idp.id.toString()}
|
|
||||||
>
|
|
||||||
{idp.text}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
{t(
|
|
||||||
"defaultIdentityProviderDescription"
|
|
||||||
)}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</SettingsSectionForm>
|
|
||||||
</SettingsSectionBody>
|
|
||||||
|
|
||||||
<SettingsSectionFooter>
|
|
||||||
<Button
|
|
||||||
type="submit"
|
|
||||||
loading={isSubmitting || isSavingOverlay}
|
|
||||||
disabled={
|
|
||||||
readonly ||
|
|
||||||
isSubmitting ||
|
|
||||||
isSavingOverlay ||
|
|
||||||
isLoading
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{t("resourceUsersRolesSubmit")}
|
|
||||||
</Button>
|
|
||||||
</SettingsSectionFooter>
|
|
||||||
</SettingsSection>
|
|
||||||
</form>
|
|
||||||
</Form>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
29
src/components/resource-policy/PolicyAccessRulesIntro.tsx
Normal file
29
src/components/resource-policy/PolicyAccessRulesIntro.tsx
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { SwitchInput } from "@app/components/SwitchInput";
|
||||||
|
import { useTranslations } from "next-intl";
|
||||||
|
|
||||||
|
export type PolicyAccessRulesIntroProps = {
|
||||||
|
rulesEnabled: boolean;
|
||||||
|
onRulesEnabledChange: (enabled: boolean) => void;
|
||||||
|
disableToggle?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function PolicyAccessRulesIntro({
|
||||||
|
rulesEnabled,
|
||||||
|
onRulesEnabledChange,
|
||||||
|
disableToggle
|
||||||
|
}: PolicyAccessRulesIntroProps) {
|
||||||
|
const t = useTranslations();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SwitchInput
|
||||||
|
id="rules-toggle"
|
||||||
|
label={t("rulesEnable")}
|
||||||
|
description={t("policyAccessRulesEnableDescription")}
|
||||||
|
checked={rulesEnabled}
|
||||||
|
disabled={disableToggle}
|
||||||
|
onCheckedChange={onRulesEnabledChange}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
1095
src/components/resource-policy/PolicyAccessRulesSection.tsx
Normal file
1095
src/components/resource-policy/PolicyAccessRulesSection.tsx
Normal file
File diff suppressed because it is too large
Load Diff
467
src/components/resource-policy/PolicyAuthMethodCredenzas.tsx
Normal file
467
src/components/resource-policy/PolicyAuthMethodCredenzas.tsx
Normal file
@@ -0,0 +1,467 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import {
|
||||||
|
Credenza,
|
||||||
|
CredenzaBody,
|
||||||
|
CredenzaClose,
|
||||||
|
CredenzaContent,
|
||||||
|
CredenzaDescription,
|
||||||
|
CredenzaFooter,
|
||||||
|
CredenzaHeader,
|
||||||
|
CredenzaTitle
|
||||||
|
} from "@app/components/Credenza";
|
||||||
|
import { SwitchInput } from "@app/components/SwitchInput";
|
||||||
|
import { Tag, TagInput } from "@app/components/tags/tag-input";
|
||||||
|
import { Alert, AlertDescription, AlertTitle } from "@app/components/ui/alert";
|
||||||
|
import { Button } from "@app/components/ui/button";
|
||||||
|
import {
|
||||||
|
Form,
|
||||||
|
FormControl,
|
||||||
|
FormDescription,
|
||||||
|
FormField,
|
||||||
|
FormItem,
|
||||||
|
FormLabel,
|
||||||
|
FormMessage
|
||||||
|
} from "@app/components/ui/form";
|
||||||
|
import { Input } from "@app/components/ui/input";
|
||||||
|
import {
|
||||||
|
InputOTP,
|
||||||
|
InputOTPGroup,
|
||||||
|
InputOTPSlot
|
||||||
|
} from "@app/components/ui/input-otp";
|
||||||
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
|
import { InfoIcon } from "lucide-react";
|
||||||
|
import { useTranslations } from "next-intl";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { useForm } from "react-hook-form";
|
||||||
|
import z from "zod";
|
||||||
|
import {
|
||||||
|
setHeaderAuthSchema,
|
||||||
|
setPasswordSchema,
|
||||||
|
setPincodeSchema
|
||||||
|
} from "./policy-auth-method-id";
|
||||||
|
|
||||||
|
type CredenzaShellProps = {
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
formId: string;
|
||||||
|
submitLabel: string;
|
||||||
|
children: React.ReactNode;
|
||||||
|
};
|
||||||
|
|
||||||
|
function CredenzaShell({
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
formId,
|
||||||
|
submitLabel,
|
||||||
|
children
|
||||||
|
}: CredenzaShellProps) {
|
||||||
|
const t = useTranslations();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Credenza open={open} onOpenChange={onOpenChange}>
|
||||||
|
<CredenzaContent>
|
||||||
|
<CredenzaHeader>
|
||||||
|
<CredenzaTitle>{title}</CredenzaTitle>
|
||||||
|
<CredenzaDescription>{description}</CredenzaDescription>
|
||||||
|
</CredenzaHeader>
|
||||||
|
<CredenzaBody>{children}</CredenzaBody>
|
||||||
|
<CredenzaFooter>
|
||||||
|
<CredenzaClose asChild>
|
||||||
|
<Button variant="outline">{t("close")}</Button>
|
||||||
|
</CredenzaClose>
|
||||||
|
<Button type="submit" form={formId}>
|
||||||
|
{submitLabel}
|
||||||
|
</Button>
|
||||||
|
</CredenzaFooter>
|
||||||
|
</CredenzaContent>
|
||||||
|
</Credenza>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
type PasscodeCredenzaProps = {
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
defaultPassword?: string;
|
||||||
|
existingConfigured?: boolean;
|
||||||
|
onSave: (password: string) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function PasscodeCredenza({
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
defaultPassword = "",
|
||||||
|
existingConfigured,
|
||||||
|
onSave
|
||||||
|
}: PasscodeCredenzaProps) {
|
||||||
|
const t = useTranslations();
|
||||||
|
const form = useForm({
|
||||||
|
resolver: zodResolver(setPasswordSchema),
|
||||||
|
defaultValues: { password: defaultPassword }
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (open) {
|
||||||
|
form.reset({ password: defaultPassword });
|
||||||
|
}
|
||||||
|
}, [open, defaultPassword, form]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<CredenzaShell
|
||||||
|
open={open}
|
||||||
|
onOpenChange={onOpenChange}
|
||||||
|
title={t("resourcePasswordSetupTitle")}
|
||||||
|
description={t("resourcePasswordSetupTitleDescription")}
|
||||||
|
formId="policy-passcode-form"
|
||||||
|
submitLabel={t("resourcePasswordSubmit")}
|
||||||
|
>
|
||||||
|
<Form {...form}>
|
||||||
|
<form
|
||||||
|
id="policy-passcode-form"
|
||||||
|
onSubmit={form.handleSubmit((data) => {
|
||||||
|
onSave(data.password);
|
||||||
|
onOpenChange(false);
|
||||||
|
form.reset();
|
||||||
|
})}
|
||||||
|
className="space-y-4"
|
||||||
|
>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="password"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>
|
||||||
|
{t("policyAuthPasscodeTitle")}
|
||||||
|
</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
autoComplete="off"
|
||||||
|
type="password"
|
||||||
|
placeholder={
|
||||||
|
existingConfigured
|
||||||
|
? "••••••••"
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
{...field}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
</CredenzaShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
type PincodeCredenzaProps = {
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
defaultPincode?: string;
|
||||||
|
onSave: (pincode: string) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function PincodeCredenza({
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
defaultPincode = "",
|
||||||
|
onSave
|
||||||
|
}: PincodeCredenzaProps) {
|
||||||
|
const t = useTranslations();
|
||||||
|
const form = useForm({
|
||||||
|
resolver: zodResolver(setPincodeSchema),
|
||||||
|
defaultValues: { pincode: defaultPincode }
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (open) {
|
||||||
|
form.reset({ pincode: defaultPincode });
|
||||||
|
}
|
||||||
|
}, [open, defaultPincode, form]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<CredenzaShell
|
||||||
|
open={open}
|
||||||
|
onOpenChange={onOpenChange}
|
||||||
|
title={t("resourcePincodeSetupTitle")}
|
||||||
|
description={t("resourcePincodeSetupTitleDescription")}
|
||||||
|
formId="policy-pincode-form"
|
||||||
|
submitLabel={t("resourcePincodeSubmit")}
|
||||||
|
>
|
||||||
|
<Form {...form}>
|
||||||
|
<form
|
||||||
|
id="policy-pincode-form"
|
||||||
|
onSubmit={form.handleSubmit((data) => {
|
||||||
|
onSave(data.pincode);
|
||||||
|
onOpenChange(false);
|
||||||
|
form.reset();
|
||||||
|
})}
|
||||||
|
className="space-y-4"
|
||||||
|
>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="pincode"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>{t("resourcePincode")}</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<div className="flex justify-center">
|
||||||
|
<InputOTP
|
||||||
|
maxLength={6}
|
||||||
|
value={field.value}
|
||||||
|
onChange={field.onChange}
|
||||||
|
>
|
||||||
|
<InputOTPGroup>
|
||||||
|
{[0, 1, 2, 3, 4, 5].map((i) => (
|
||||||
|
<InputOTPSlot
|
||||||
|
key={i}
|
||||||
|
index={i}
|
||||||
|
obscured
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</InputOTPGroup>
|
||||||
|
</InputOTP>
|
||||||
|
</div>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
</CredenzaShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
type HeaderAuthCredenzaProps = {
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
defaultValues?: {
|
||||||
|
user: string;
|
||||||
|
password: string;
|
||||||
|
extendedCompatibility: boolean;
|
||||||
|
};
|
||||||
|
existingConfigured?: boolean;
|
||||||
|
onSave: (values: z.infer<typeof setHeaderAuthSchema>) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function HeaderAuthCredenza({
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
defaultValues,
|
||||||
|
existingConfigured,
|
||||||
|
onSave
|
||||||
|
}: HeaderAuthCredenzaProps) {
|
||||||
|
const t = useTranslations();
|
||||||
|
const form = useForm({
|
||||||
|
resolver: zodResolver(setHeaderAuthSchema),
|
||||||
|
defaultValues: {
|
||||||
|
user: "",
|
||||||
|
password: "",
|
||||||
|
extendedCompatibility: true,
|
||||||
|
...defaultValues
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (open) {
|
||||||
|
form.reset({
|
||||||
|
user: defaultValues?.user ?? "",
|
||||||
|
password: defaultValues?.password ?? "",
|
||||||
|
extendedCompatibility:
|
||||||
|
defaultValues?.extendedCompatibility ?? true
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [open, defaultValues, form]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<CredenzaShell
|
||||||
|
open={open}
|
||||||
|
onOpenChange={onOpenChange}
|
||||||
|
title={t("resourceHeaderAuthSetupTitle")}
|
||||||
|
description={t("resourceHeaderAuthSetupTitleDescription")}
|
||||||
|
formId="policy-header-auth-form"
|
||||||
|
submitLabel={t("resourceHeaderAuthSubmit")}
|
||||||
|
>
|
||||||
|
<Form {...form}>
|
||||||
|
<form
|
||||||
|
id="policy-header-auth-form"
|
||||||
|
onSubmit={form.handleSubmit((data) => {
|
||||||
|
onSave(data);
|
||||||
|
onOpenChange(false);
|
||||||
|
form.reset();
|
||||||
|
})}
|
||||||
|
className="space-y-4"
|
||||||
|
>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="user"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>
|
||||||
|
{t("policyAuthHeaderName")}
|
||||||
|
</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input autoComplete="off" {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="password"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>
|
||||||
|
{t("policyAuthHeaderValue")}
|
||||||
|
</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
autoComplete="off"
|
||||||
|
type="password"
|
||||||
|
placeholder={
|
||||||
|
existingConfigured
|
||||||
|
? "••••••••"
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
{...field}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="extendedCompatibility"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormControl>
|
||||||
|
<SwitchInput
|
||||||
|
id="header-auth-compatibility-credenza"
|
||||||
|
label={t("headerAuthCompatibility")}
|
||||||
|
description={t(
|
||||||
|
"headerAuthCompatibilityInfo"
|
||||||
|
)}
|
||||||
|
checked={field.value}
|
||||||
|
onCheckedChange={field.onChange}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
</CredenzaShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
type EmailCredenzaProps = {
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
emailEnabled: boolean;
|
||||||
|
disabled?: boolean;
|
||||||
|
emails: Tag[];
|
||||||
|
onEmailsChange: (emails: Tag[]) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function EmailCredenza({
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
emailEnabled,
|
||||||
|
disabled,
|
||||||
|
emails,
|
||||||
|
onEmailsChange
|
||||||
|
}: EmailCredenzaProps) {
|
||||||
|
const t = useTranslations();
|
||||||
|
const [activeEmailTagIndex, setActiveEmailTagIndex] = useState<
|
||||||
|
number | null
|
||||||
|
>(null);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Credenza open={open} onOpenChange={onOpenChange}>
|
||||||
|
<CredenzaContent className="max-w-lg">
|
||||||
|
<CredenzaHeader>
|
||||||
|
<CredenzaTitle>{t("policyAuthEmailTitle")}</CredenzaTitle>
|
||||||
|
<CredenzaDescription>
|
||||||
|
{t("policyAuthEmailDescription")}
|
||||||
|
</CredenzaDescription>
|
||||||
|
</CredenzaHeader>
|
||||||
|
<CredenzaBody>
|
||||||
|
<div className="space-y-4">
|
||||||
|
{!emailEnabled && (
|
||||||
|
<Alert variant="neutral">
|
||||||
|
<InfoIcon className="h-4 w-4" />
|
||||||
|
<AlertTitle className="font-semibold">
|
||||||
|
{t("otpEmailSmtpRequired")}
|
||||||
|
</AlertTitle>
|
||||||
|
<AlertDescription>
|
||||||
|
{t("otpEmailSmtpRequiredDescription")}
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
{emailEnabled && (
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{t("otpEmailWhitelistListDescription")}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{emailEnabled && (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>
|
||||||
|
{t("otpEmailWhitelistList")}
|
||||||
|
</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<TagInput
|
||||||
|
activeTagIndex={activeEmailTagIndex}
|
||||||
|
setActiveTagIndex={
|
||||||
|
setActiveEmailTagIndex
|
||||||
|
}
|
||||||
|
placeholder={t("otpEmailEnter")}
|
||||||
|
tags={emails}
|
||||||
|
setTags={(newEmails) => {
|
||||||
|
if (!disabled) {
|
||||||
|
onEmailsChange(
|
||||||
|
newEmails as Tag[]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
validateTag={(tag) =>
|
||||||
|
z
|
||||||
|
.email()
|
||||||
|
.or(
|
||||||
|
z
|
||||||
|
.string()
|
||||||
|
.regex(
|
||||||
|
/^\*@[\w.-]+\.[a-zA-Z]{2,}$/
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.safeParse(tag).success
|
||||||
|
}
|
||||||
|
allowDuplicates={false}
|
||||||
|
sortTags
|
||||||
|
size="sm"
|
||||||
|
disabled={disabled}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormDescription>
|
||||||
|
{t("otpEmailEnterDescription")}
|
||||||
|
</FormDescription>
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</CredenzaBody>
|
||||||
|
<CredenzaFooter>
|
||||||
|
<CredenzaClose asChild>
|
||||||
|
<Button variant="outline">{t("close")}</Button>
|
||||||
|
</CredenzaClose>
|
||||||
|
</CredenzaFooter>
|
||||||
|
</CredenzaContent>
|
||||||
|
</Credenza>
|
||||||
|
);
|
||||||
|
}
|
||||||
118
src/components/resource-policy/PolicyAuthMethodRow.tsx
Normal file
118
src/components/resource-policy/PolicyAuthMethodRow.tsx
Normal file
@@ -0,0 +1,118 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { Button } from "@app/components/ui/button";
|
||||||
|
import { Switch } from "@app/components/ui/switch";
|
||||||
|
import { cn } from "@app/lib/cn";
|
||||||
|
import { useTranslations } from "next-intl";
|
||||||
|
|
||||||
|
export type PolicyAuthMethodRowProps = {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
summary: string;
|
||||||
|
active: boolean;
|
||||||
|
onConfigure: () => void;
|
||||||
|
onToggle: (active: boolean) => void;
|
||||||
|
disabled?: boolean;
|
||||||
|
configureDisabled?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function PolicyAuthMethodRow({
|
||||||
|
id,
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
summary,
|
||||||
|
active,
|
||||||
|
onConfigure,
|
||||||
|
onToggle,
|
||||||
|
disabled,
|
||||||
|
configureDisabled = disabled
|
||||||
|
}: PolicyAuthMethodRowProps) {
|
||||||
|
const t = useTranslations();
|
||||||
|
const canEdit = active && !configureDisabled;
|
||||||
|
const canEnable = !active && !disabled;
|
||||||
|
const isRowInteractive = canEdit || canEnable;
|
||||||
|
|
||||||
|
const handleRowClick = () => {
|
||||||
|
if (canEdit) {
|
||||||
|
onConfigure();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (canEnable) {
|
||||||
|
onToggle(true);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"flex items-center gap-3 rounded-md border border-input p-3 min-w-0",
|
||||||
|
disabled && "opacity-60",
|
||||||
|
isRowInteractive && "cursor-pointer hover:bg-muted/50"
|
||||||
|
)}
|
||||||
|
onClick={isRowInteractive ? handleRowClick : undefined}
|
||||||
|
onKeyDown={
|
||||||
|
isRowInteractive
|
||||||
|
? (e) => {
|
||||||
|
if (e.key === "Enter" || e.key === " ") {
|
||||||
|
e.preventDefault();
|
||||||
|
handleRowClick();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
role={isRowInteractive ? "button" : undefined}
|
||||||
|
tabIndex={isRowInteractive ? 0 : undefined}
|
||||||
|
>
|
||||||
|
<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">
|
||||||
|
{active ? summary : description}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className="flex shrink-0 items-center gap-2"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
onKeyDown={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
{active && (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="text"
|
||||||
|
size="sm"
|
||||||
|
className="h-auto px-0"
|
||||||
|
disabled={configureDisabled}
|
||||||
|
onClick={onConfigure}
|
||||||
|
>
|
||||||
|
{t("edit")}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<Switch
|
||||||
|
id={`${id}-toggle`}
|
||||||
|
checked={active}
|
||||||
|
disabled={disabled}
|
||||||
|
onCheckedChange={onToggle}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
140
src/components/resource-policy/PolicyAuthSsoSection.tsx
Normal file
140
src/components/resource-policy/PolicyAuthSsoSection.tsx
Normal file
@@ -0,0 +1,140 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { SettingsSectionForm } from "@app/components/Settings";
|
||||||
|
import { SwitchInput } from "@app/components/SwitchInput";
|
||||||
|
import { Button } from "@app/components/ui/button";
|
||||||
|
import { FormDescription, FormItem, FormLabel } from "@app/components/ui/form";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue
|
||||||
|
} from "@app/components/ui/select";
|
||||||
|
import { useTranslations } from "next-intl";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
|
||||||
|
export type PolicyAuthSsoSectionProps = {
|
||||||
|
sso: boolean;
|
||||||
|
onSsoChange: (active: boolean) => void;
|
||||||
|
skipToIdpId: number | null | undefined;
|
||||||
|
onSkipToIdpChange: (id: number | null) => void;
|
||||||
|
allIdps: { id: number; text: string }[];
|
||||||
|
rolesEditor: React.ReactNode;
|
||||||
|
usersEditor: React.ReactNode;
|
||||||
|
disabled?: boolean;
|
||||||
|
idpDisabled?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function PolicyAuthSsoSection({
|
||||||
|
sso,
|
||||||
|
onSsoChange,
|
||||||
|
skipToIdpId,
|
||||||
|
onSkipToIdpChange,
|
||||||
|
allIdps,
|
||||||
|
rolesEditor,
|
||||||
|
usersEditor,
|
||||||
|
disabled,
|
||||||
|
idpDisabled
|
||||||
|
}: PolicyAuthSsoSectionProps) {
|
||||||
|
const t = useTranslations();
|
||||||
|
const [showIdpSelect, setShowIdpSelect] = useState(skipToIdpId != null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (skipToIdpId != null) {
|
||||||
|
setShowIdpSelect(true);
|
||||||
|
}
|
||||||
|
}, [skipToIdpId]);
|
||||||
|
|
||||||
|
const idpSelectDisabled = idpDisabled ?? disabled;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<SwitchInput
|
||||||
|
id="policy-auth-sso"
|
||||||
|
label={t("policyAuthSsoTitle")}
|
||||||
|
description={t("policyAuthSsoDescription")}
|
||||||
|
checked={sso}
|
||||||
|
disabled={disabled}
|
||||||
|
onCheckedChange={onSsoChange}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{sso && (
|
||||||
|
<SettingsSectionForm className="max-w-none space-y-4">
|
||||||
|
<FormItem className="flex flex-col items-start">
|
||||||
|
<FormLabel>{t("roles")}</FormLabel>
|
||||||
|
{rolesEditor}
|
||||||
|
</FormItem>
|
||||||
|
<FormItem className="flex flex-col items-start">
|
||||||
|
<FormLabel>{t("users")}</FormLabel>
|
||||||
|
{usersEditor}
|
||||||
|
</FormItem>
|
||||||
|
{allIdps.length > 0 && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{skipToIdpId == null && !showIdpSelect ? (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="text"
|
||||||
|
size="sm"
|
||||||
|
className="h-auto px-0"
|
||||||
|
disabled={idpSelectDisabled}
|
||||||
|
onClick={() => setShowIdpSelect(true)}
|
||||||
|
>
|
||||||
|
{t("policyAuthAddDefaultIdentityProvider")}
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<label className="text-sm font-medium">
|
||||||
|
{t("defaultIdentityProvider")}
|
||||||
|
</label>
|
||||||
|
<Select
|
||||||
|
disabled={idpSelectDisabled}
|
||||||
|
onValueChange={(value) => {
|
||||||
|
if (value === "none") {
|
||||||
|
onSkipToIdpChange(null);
|
||||||
|
setShowIdpSelect(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
onSkipToIdpChange(parseInt(value));
|
||||||
|
}}
|
||||||
|
value={
|
||||||
|
skipToIdpId
|
||||||
|
? skipToIdpId.toString()
|
||||||
|
: "none"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="w-full">
|
||||||
|
<SelectValue
|
||||||
|
placeholder={t(
|
||||||
|
"selectIdpPlaceholder"
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="none">
|
||||||
|
{t("none")}
|
||||||
|
</SelectItem>
|
||||||
|
{allIdps.map((idp) => (
|
||||||
|
<SelectItem
|
||||||
|
key={idp.id}
|
||||||
|
value={idp.id.toString()}
|
||||||
|
>
|
||||||
|
{idp.text}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{t(
|
||||||
|
"defaultIdentityProviderDescription"
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</SettingsSectionForm>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
38
src/components/resource-policy/PolicyAuthStackSection.tsx
Normal file
38
src/components/resource-policy/PolicyAuthStackSection.tsx
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { type UseFormReturn } from "react-hook-form";
|
||||||
|
import type { PolicyFormValues } from ".";
|
||||||
|
import { PolicyAuthStackSectionCreate } from "./PolicyAuthStackSectionCreate";
|
||||||
|
import { PolicyAuthStackSectionEdit } from "./PolicyAuthStackSectionEdit";
|
||||||
|
|
||||||
|
type PolicyAuthStackSectionEditProps = {
|
||||||
|
mode: "edit";
|
||||||
|
orgId: string;
|
||||||
|
allIdps: { id: number; text: string }[];
|
||||||
|
emailEnabled: boolean;
|
||||||
|
readonly?: boolean;
|
||||||
|
resourceId?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
type PolicyAuthStackSectionCreateProps = {
|
||||||
|
mode: "create";
|
||||||
|
form: UseFormReturn<PolicyFormValues, any, any>;
|
||||||
|
orgId: string;
|
||||||
|
allIdps: { id: number; text: string }[];
|
||||||
|
allRoles: { id: string; text: string }[];
|
||||||
|
allUsers: { id: string; text: string }[];
|
||||||
|
emailEnabled: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type PolicyAuthStackSectionProps =
|
||||||
|
| PolicyAuthStackSectionEditProps
|
||||||
|
| PolicyAuthStackSectionCreateProps;
|
||||||
|
|
||||||
|
export function PolicyAuthStackSection(props: PolicyAuthStackSectionProps) {
|
||||||
|
if (props.mode === "create") {
|
||||||
|
const { mode: _, ...createProps } = props;
|
||||||
|
return <PolicyAuthStackSectionCreate {...createProps} />;
|
||||||
|
}
|
||||||
|
const { mode: _, ...editProps } = props;
|
||||||
|
return <PolicyAuthStackSectionEdit {...editProps} />;
|
||||||
|
}
|
||||||
310
src/components/resource-policy/PolicyAuthStackSectionCreate.tsx
Normal file
310
src/components/resource-policy/PolicyAuthStackSectionCreate.tsx
Normal file
@@ -0,0 +1,310 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import {
|
||||||
|
SettingsSection,
|
||||||
|
SettingsSectionBody,
|
||||||
|
SettingsSectionDescription,
|
||||||
|
SettingsSectionHeader,
|
||||||
|
SettingsSubsectionDescription,
|
||||||
|
SettingsSubsectionHeader,
|
||||||
|
SettingsSubsectionTitle,
|
||||||
|
SettingsSectionTitle
|
||||||
|
} from "@app/components/Settings";
|
||||||
|
import { TagInput } from "@app/components/tags/tag-input";
|
||||||
|
import { FormField } from "@app/components/ui/form";
|
||||||
|
import { useTranslations } from "next-intl";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { type UseFormReturn, useWatch } from "react-hook-form";
|
||||||
|
import type { PolicyFormValues } from ".";
|
||||||
|
import {
|
||||||
|
EmailCredenza,
|
||||||
|
HeaderAuthCredenza,
|
||||||
|
PasscodeCredenza,
|
||||||
|
PincodeCredenza
|
||||||
|
} from "./PolicyAuthMethodCredenzas";
|
||||||
|
import { PolicyAuthMethodRow } from "./PolicyAuthMethodRow";
|
||||||
|
import { PolicyAuthSsoSection } from "./PolicyAuthSsoSection";
|
||||||
|
import type { PolicyAuthMethodId } from "./policy-auth-method-id";
|
||||||
|
import {
|
||||||
|
getEmailWhitelistSummary,
|
||||||
|
getHeaderAuthSummary,
|
||||||
|
getPasscodeSummary,
|
||||||
|
getPincodeSummary
|
||||||
|
} from "./policy-auth-summaries";
|
||||||
|
|
||||||
|
export type PolicyAuthStackSectionCreateProps = {
|
||||||
|
form: UseFormReturn<PolicyFormValues, any, any>;
|
||||||
|
orgId: string;
|
||||||
|
allIdps: { id: number; text: string }[];
|
||||||
|
allRoles: { id: string; text: string }[];
|
||||||
|
allUsers: { id: string; text: string }[];
|
||||||
|
emailEnabled: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function PolicyAuthStackSectionCreate({
|
||||||
|
form: parentForm,
|
||||||
|
allIdps,
|
||||||
|
allRoles,
|
||||||
|
allUsers,
|
||||||
|
emailEnabled
|
||||||
|
}: PolicyAuthStackSectionCreateProps) {
|
||||||
|
const t = useTranslations();
|
||||||
|
const [editingMethod, setEditingMethod] =
|
||||||
|
useState<PolicyAuthMethodId | null>(null);
|
||||||
|
const [activeRolesTagIndex, setActiveRolesTagIndex] = useState<
|
||||||
|
number | null
|
||||||
|
>(null);
|
||||||
|
const [activeUsersTagIndex, setActiveUsersTagIndex] = useState<
|
||||||
|
number | null
|
||||||
|
>(null);
|
||||||
|
|
||||||
|
const sso = useWatch({ control: parentForm.control, name: "sso" });
|
||||||
|
const skipToIdpId = useWatch({
|
||||||
|
control: parentForm.control,
|
||||||
|
name: "skipToIdpId"
|
||||||
|
});
|
||||||
|
const password = useWatch({
|
||||||
|
control: parentForm.control,
|
||||||
|
name: "password"
|
||||||
|
});
|
||||||
|
const pincode = useWatch({ control: parentForm.control, name: "pincode" });
|
||||||
|
const headerAuth = useWatch({
|
||||||
|
control: parentForm.control,
|
||||||
|
name: "headerAuth"
|
||||||
|
});
|
||||||
|
const emailWhitelistEnabled = useWatch({
|
||||||
|
control: parentForm.control,
|
||||||
|
name: "emailWhitelistEnabled"
|
||||||
|
});
|
||||||
|
const emails =
|
||||||
|
useWatch({ control: parentForm.control, name: "emails" }) ?? [];
|
||||||
|
|
||||||
|
const passcodeActive = Boolean(password);
|
||||||
|
const pinActive = Boolean(pincode);
|
||||||
|
const headerAuthActive = Boolean(headerAuth);
|
||||||
|
|
||||||
|
const closeCredenza = () => setEditingMethod(null);
|
||||||
|
|
||||||
|
const handleToggle = (
|
||||||
|
method: PolicyAuthMethodId,
|
||||||
|
active: boolean,
|
||||||
|
onDisable: () => void,
|
||||||
|
onEnable?: () => void
|
||||||
|
) => {
|
||||||
|
if (active) {
|
||||||
|
onEnable?.();
|
||||||
|
setEditingMethod(method);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
onDisable();
|
||||||
|
setEditingMethod((current) => (current === method ? null : current));
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SettingsSection>
|
||||||
|
<SettingsSectionHeader>
|
||||||
|
<SettingsSectionTitle>
|
||||||
|
{t("policyAuthStackTitle")}
|
||||||
|
</SettingsSectionTitle>
|
||||||
|
<SettingsSectionDescription>
|
||||||
|
{t("policyAuthStackDescription")}
|
||||||
|
</SettingsSectionDescription>
|
||||||
|
</SettingsSectionHeader>
|
||||||
|
<SettingsSectionBody>
|
||||||
|
<div className="w-full md:w-1/2">
|
||||||
|
<PolicyAuthSsoSection
|
||||||
|
sso={Boolean(sso)}
|
||||||
|
onSsoChange={(active) =>
|
||||||
|
parentForm.setValue("sso", active)
|
||||||
|
}
|
||||||
|
skipToIdpId={skipToIdpId}
|
||||||
|
onSkipToIdpChange={(id) =>
|
||||||
|
parentForm.setValue("skipToIdpId", id)
|
||||||
|
}
|
||||||
|
allIdps={allIdps}
|
||||||
|
rolesEditor={
|
||||||
|
<FormField
|
||||||
|
control={parentForm.control}
|
||||||
|
name="roles"
|
||||||
|
render={({ field }) => (
|
||||||
|
<TagInput
|
||||||
|
{...field}
|
||||||
|
activeTagIndex={activeRolesTagIndex}
|
||||||
|
setActiveTagIndex={
|
||||||
|
setActiveRolesTagIndex
|
||||||
|
}
|
||||||
|
placeholder={t("accessRoleSelect2")}
|
||||||
|
tags={field.value ?? []}
|
||||||
|
setTags={(newRoles) =>
|
||||||
|
field.onChange(newRoles)
|
||||||
|
}
|
||||||
|
autocompleteOptions={allRoles}
|
||||||
|
allowDuplicates={false}
|
||||||
|
size="sm"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
usersEditor={
|
||||||
|
<FormField
|
||||||
|
control={parentForm.control}
|
||||||
|
name="users"
|
||||||
|
render={({ field }) => (
|
||||||
|
<TagInput
|
||||||
|
{...field}
|
||||||
|
activeTagIndex={activeUsersTagIndex}
|
||||||
|
setActiveTagIndex={
|
||||||
|
setActiveUsersTagIndex
|
||||||
|
}
|
||||||
|
placeholder={t("accessUserSelect")}
|
||||||
|
tags={field.value ?? []}
|
||||||
|
setTags={(newUsers) =>
|
||||||
|
field.onChange(newUsers)
|
||||||
|
}
|
||||||
|
autocompleteOptions={allUsers}
|
||||||
|
allowDuplicates={false}
|
||||||
|
size="sm"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</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={() => setEditingMethod("pincode")}
|
||||||
|
onToggle={(active) =>
|
||||||
|
handleToggle("pincode", active, () =>
|
||||||
|
parentForm.setValue("pincode", null)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<PolicyAuthMethodRow
|
||||||
|
id="passcode"
|
||||||
|
title={t("policyAuthPasscodeTitle")}
|
||||||
|
description={t("policyAuthPasscodeDescription")}
|
||||||
|
summary={getPasscodeSummary({ t })}
|
||||||
|
active={passcodeActive}
|
||||||
|
onConfigure={() => setEditingMethod("passcode")}
|
||||||
|
onToggle={(active) =>
|
||||||
|
handleToggle("passcode", active, () =>
|
||||||
|
parentForm.setValue("password", null)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<PolicyAuthMethodRow
|
||||||
|
id="email"
|
||||||
|
title={t("policyAuthEmailTitle")}
|
||||||
|
description={t("policyAuthEmailDescription")}
|
||||||
|
summary={getEmailWhitelistSummary({
|
||||||
|
t,
|
||||||
|
count: emails.length
|
||||||
|
})}
|
||||||
|
active={Boolean(emailWhitelistEnabled)}
|
||||||
|
onConfigure={() => setEditingMethod("email")}
|
||||||
|
onToggle={(active) =>
|
||||||
|
handleToggle(
|
||||||
|
"email",
|
||||||
|
active,
|
||||||
|
() =>
|
||||||
|
parentForm.setValue(
|
||||||
|
"emailWhitelistEnabled",
|
||||||
|
false
|
||||||
|
),
|
||||||
|
() =>
|
||||||
|
parentForm.setValue(
|
||||||
|
"emailWhitelistEnabled",
|
||||||
|
true
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
disabled={!emailEnabled}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<PolicyAuthMethodRow
|
||||||
|
id="header-auth"
|
||||||
|
title={t("policyAuthHeaderAuthTitle")}
|
||||||
|
description={t("policyAuthHeaderAuthDescription")}
|
||||||
|
summary={getHeaderAuthSummary({
|
||||||
|
t,
|
||||||
|
headerName: headerAuth?.user ?? ""
|
||||||
|
})}
|
||||||
|
active={headerAuthActive}
|
||||||
|
onConfigure={() => setEditingMethod("headerAuth")}
|
||||||
|
onToggle={(active) =>
|
||||||
|
handleToggle("headerAuth", active, () =>
|
||||||
|
parentForm.setValue("headerAuth", null)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<PincodeCredenza
|
||||||
|
open={editingMethod === "pincode"}
|
||||||
|
onOpenChange={(open) => !open && closeCredenza()}
|
||||||
|
defaultPincode={pincode?.pincode ?? ""}
|
||||||
|
onSave={(value) => {
|
||||||
|
parentForm.setValue("pincode", { pincode: value });
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<PasscodeCredenza
|
||||||
|
open={editingMethod === "passcode"}
|
||||||
|
onOpenChange={(open) => !open && closeCredenza()}
|
||||||
|
defaultPassword={password?.password ?? ""}
|
||||||
|
onSave={(value) => {
|
||||||
|
parentForm.setValue("password", { password: value });
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<EmailCredenza
|
||||||
|
open={editingMethod === "email"}
|
||||||
|
onOpenChange={(open) => !open && closeCredenza()}
|
||||||
|
emailEnabled={emailEnabled}
|
||||||
|
emails={emails}
|
||||||
|
onEmailsChange={(value) =>
|
||||||
|
parentForm.setValue(
|
||||||
|
"emails",
|
||||||
|
value as PolicyFormValues["emails"]
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<HeaderAuthCredenza
|
||||||
|
open={editingMethod === "headerAuth"}
|
||||||
|
onOpenChange={(open) => !open && closeCredenza()}
|
||||||
|
defaultValues={
|
||||||
|
headerAuth
|
||||||
|
? {
|
||||||
|
user: headerAuth.user,
|
||||||
|
password: headerAuth.password,
|
||||||
|
extendedCompatibility:
|
||||||
|
headerAuth.extendedCompatibility
|
||||||
|
}
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
onSave={(value) => {
|
||||||
|
parentForm.setValue("headerAuth", value);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</SettingsSectionBody>
|
||||||
|
</SettingsSection>
|
||||||
|
);
|
||||||
|
}
|
||||||
694
src/components/resource-policy/PolicyAuthStackSectionEdit.tsx
Normal file
694
src/components/resource-policy/PolicyAuthStackSectionEdit.tsx
Normal file
@@ -0,0 +1,694 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import {
|
||||||
|
SettingsSection,
|
||||||
|
SettingsSectionBody,
|
||||||
|
SettingsSectionDescription,
|
||||||
|
SettingsSectionFooter,
|
||||||
|
SettingsSectionHeader,
|
||||||
|
SettingsSubsectionDescription,
|
||||||
|
SettingsSubsectionHeader,
|
||||||
|
SettingsSubsectionTitle,
|
||||||
|
SettingsSectionTitle
|
||||||
|
} from "@app/components/Settings";
|
||||||
|
import {
|
||||||
|
RolesSelector,
|
||||||
|
type SelectedRole
|
||||||
|
} from "@app/components/roles-selector";
|
||||||
|
import { UsersSelector } from "@app/components/users-selector";
|
||||||
|
import { Button } from "@app/components/ui/button";
|
||||||
|
import { Form, FormField } from "@app/components/ui/form";
|
||||||
|
import { toast } from "@app/hooks/useToast";
|
||||||
|
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||||
|
import { createApiClient, formatAxiosError } from "@app/lib/api";
|
||||||
|
import { getUserDisplayName } from "@app/lib/getUserDisplayName";
|
||||||
|
import { resourceQueries } from "@app/lib/queries";
|
||||||
|
import { useResourcePolicyContext } from "@app/providers/ResourcePolicyProvider";
|
||||||
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
|
import { UserType } from "@server/types/UserTypes";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import type { AxiosResponse } from "axios";
|
||||||
|
import { useTranslations } from "next-intl";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { useActionState, useEffect, useMemo, useRef, useState } from "react";
|
||||||
|
import { useForm, useWatch } from "react-hook-form";
|
||||||
|
import { createPolicySchema } from ".";
|
||||||
|
import {
|
||||||
|
EmailCredenza,
|
||||||
|
HeaderAuthCredenza,
|
||||||
|
PasscodeCredenza,
|
||||||
|
PincodeCredenza
|
||||||
|
} from "./PolicyAuthMethodCredenzas";
|
||||||
|
import { PolicyAuthMethodRow } from "./PolicyAuthMethodRow";
|
||||||
|
import { PolicyAuthSsoSection } from "./PolicyAuthSsoSection";
|
||||||
|
import type { PolicyAuthMethodId } from "./policy-auth-method-id";
|
||||||
|
import {
|
||||||
|
getEmailWhitelistSummary,
|
||||||
|
getHeaderAuthSummary,
|
||||||
|
getPasscodeSummary,
|
||||||
|
getPincodeSummary
|
||||||
|
} from "./policy-auth-summaries";
|
||||||
|
|
||||||
|
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
|
||||||
|
});
|
||||||
|
|
||||||
|
export type PolicyAuthStackSectionEditProps = {
|
||||||
|
orgId: string;
|
||||||
|
allIdps: { id: number; text: string }[];
|
||||||
|
emailEnabled: boolean;
|
||||||
|
readonly?: boolean;
|
||||||
|
resourceId?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function PolicyAuthStackSectionEdit({
|
||||||
|
orgId,
|
||||||
|
allIdps,
|
||||||
|
emailEnabled,
|
||||||
|
readonly,
|
||||||
|
resourceId
|
||||||
|
}: PolicyAuthStackSectionEditProps) {
|
||||||
|
const t = useTranslations();
|
||||||
|
const router = useRouter();
|
||||||
|
const { policy } = useResourcePolicyContext();
|
||||||
|
const api = createApiClient(useEnvContext());
|
||||||
|
|
||||||
|
const isResourceOverlay = resourceId !== undefined;
|
||||||
|
const authReadonly = readonly || isResourceOverlay;
|
||||||
|
|
||||||
|
const policyRoleItems = useMemo<OverlaySelectedRole[]>(
|
||||||
|
() =>
|
||||||
|
policy.roles.map((r) => ({
|
||||||
|
id: r.roleId.toString(),
|
||||||
|
text: r.name,
|
||||||
|
isAdmin: false
|
||||||
|
})),
|
||||||
|
[policy.roles]
|
||||||
|
);
|
||||||
|
const policyUserItems = useMemo(
|
||||||
|
() =>
|
||||||
|
policy.users.map((u) => ({
|
||||||
|
id: u.userId,
|
||||||
|
text: `${getUserDisplayName({ email: u.email, username: u.username })}${u.type !== UserType.Internal ? ` (${u.idpName})` : ""}`
|
||||||
|
})),
|
||||||
|
[policy.users]
|
||||||
|
);
|
||||||
|
|
||||||
|
const policyRoleLockedIds = useMemo(
|
||||||
|
() => new Set(policy.roles.map((r) => r.roleId.toString())),
|
||||||
|
[policy.roles]
|
||||||
|
);
|
||||||
|
const policyUserLockedIds = useMemo(
|
||||||
|
() => new Set(policy.users.map((u) => u.userId)),
|
||||||
|
[policy.users]
|
||||||
|
);
|
||||||
|
|
||||||
|
const { data: resourceRolesData } = useQuery({
|
||||||
|
...resourceQueries.resourceRoles({ resourceId: resourceId! }),
|
||||||
|
enabled: isResourceOverlay
|
||||||
|
});
|
||||||
|
const { data: resourceUsersData } = useQuery({
|
||||||
|
...resourceQueries.resourceUsers({ resourceId: resourceId! }),
|
||||||
|
enabled: isResourceOverlay
|
||||||
|
});
|
||||||
|
|
||||||
|
const [combinedRoles, setCombinedRoles] =
|
||||||
|
useState<OverlaySelectedRole[]>(policyRoleItems);
|
||||||
|
const [combinedUsers, setCombinedUsers] = useState(policyUserItems);
|
||||||
|
const [resourceRolesInitialized, setResourceRolesInitialized] =
|
||||||
|
useState(false);
|
||||||
|
const [resourceUsersInitialized, setResourceUsersInitialized] =
|
||||||
|
useState(false);
|
||||||
|
const initialResourceRoleIdsRef = useRef<Set<string>>(new Set());
|
||||||
|
const initialResourceUserIdsRef = useRef<Set<string>>(new Set());
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isResourceOverlay || resourceRolesInitialized) return;
|
||||||
|
if (!resourceRolesData) return;
|
||||||
|
const resourceSpecific = resourceRolesData
|
||||||
|
.filter((r) => !policyRoleLockedIds.has(r.roleId.toString()))
|
||||||
|
.map((r) => ({
|
||||||
|
id: r.roleId.toString(),
|
||||||
|
text: r.name,
|
||||||
|
isAdmin: Boolean(r.isAdmin)
|
||||||
|
}));
|
||||||
|
initialResourceRoleIdsRef.current = new Set(
|
||||||
|
resourceSpecific.map((r) => r.id)
|
||||||
|
);
|
||||||
|
setCombinedRoles(
|
||||||
|
[...policyRoleItems, ...resourceSpecific].filter(
|
||||||
|
(role) => !role.isAdmin
|
||||||
|
)
|
||||||
|
);
|
||||||
|
setResourceRolesInitialized(true);
|
||||||
|
}, [
|
||||||
|
isResourceOverlay,
|
||||||
|
resourceRolesData,
|
||||||
|
resourceRolesInitialized,
|
||||||
|
policyRoleItems,
|
||||||
|
policyRoleLockedIds
|
||||||
|
]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isResourceOverlay || resourceUsersInitialized) return;
|
||||||
|
if (!resourceUsersData) return;
|
||||||
|
const resourceSpecific = resourceUsersData
|
||||||
|
.filter((u) => !policyUserLockedIds.has(u.userId))
|
||||||
|
.map((u) => ({
|
||||||
|
id: u.userId,
|
||||||
|
text: `${getUserDisplayName({ email: u.email ?? undefined, username: u.username ?? undefined })}${u.type !== UserType.Internal ? ` (${u.idpName})` : ""}`
|
||||||
|
}));
|
||||||
|
initialResourceUserIdsRef.current = new Set(
|
||||||
|
resourceSpecific.map((u) => u.id)
|
||||||
|
);
|
||||||
|
setCombinedUsers([...policyUserItems, ...resourceSpecific]);
|
||||||
|
setResourceUsersInitialized(true);
|
||||||
|
}, [
|
||||||
|
isResourceOverlay,
|
||||||
|
resourceUsersData,
|
||||||
|
resourceUsersInitialized,
|
||||||
|
policyUserItems,
|
||||||
|
policyUserLockedIds
|
||||||
|
]);
|
||||||
|
|
||||||
|
const form = useForm({
|
||||||
|
resolver: zodResolver(authStackSchema),
|
||||||
|
defaultValues: {
|
||||||
|
sso: policy.sso,
|
||||||
|
skipToIdpId: policy.idpId,
|
||||||
|
roles: policyRoleItems,
|
||||||
|
users: policyUserItems,
|
||||||
|
password: policy.passwordId ? { password: "" } : null,
|
||||||
|
pincode: policy.pincodeId ? { pincode: "" } : null,
|
||||||
|
headerAuth: policy.headerAuth
|
||||||
|
? {
|
||||||
|
user: "",
|
||||||
|
password: "",
|
||||||
|
extendedCompatibility:
|
||||||
|
policy.headerAuth.extendedCompability ?? true
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
emailWhitelistEnabled: policy.emailWhitelistEnabled,
|
||||||
|
emails: policy.emailWhiteList.map((email) => ({
|
||||||
|
id: email.whiteListId.toString(),
|
||||||
|
text: email.email
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const [passcodeActive, setPasscodeActive] = useState(
|
||||||
|
Boolean(policy.passwordId)
|
||||||
|
);
|
||||||
|
const [pinActive, setPinActive] = useState(Boolean(policy.pincodeId));
|
||||||
|
const [headerAuthActive, setHeaderAuthActive] = useState(
|
||||||
|
Boolean(policy.headerAuth)
|
||||||
|
);
|
||||||
|
const [editingMethod, setEditingMethod] =
|
||||||
|
useState<PolicyAuthMethodId | null>(null);
|
||||||
|
|
||||||
|
const sso = useWatch({ control: form.control, name: "sso" });
|
||||||
|
const skipToIdpId = useWatch({
|
||||||
|
control: form.control,
|
||||||
|
name: "skipToIdpId"
|
||||||
|
});
|
||||||
|
const roles = useWatch({ control: form.control, name: "roles" }) ?? [];
|
||||||
|
const users = useWatch({ control: form.control, name: "users" }) ?? [];
|
||||||
|
const password = useWatch({ control: form.control, name: "password" });
|
||||||
|
const pincode = useWatch({ control: form.control, name: "pincode" });
|
||||||
|
const headerAuth = useWatch({ control: form.control, name: "headerAuth" });
|
||||||
|
const emailWhitelistEnabled = useWatch({
|
||||||
|
control: form.control,
|
||||||
|
name: "emailWhitelistEnabled"
|
||||||
|
});
|
||||||
|
const emails = useWatch({ control: form.control, name: "emails" }) ?? [];
|
||||||
|
|
||||||
|
const overlayRoles = combinedRoles.filter((r) => !r.isAdmin);
|
||||||
|
const overlayUsers = combinedUsers;
|
||||||
|
|
||||||
|
const [, formAction, isSubmitting] = useActionState(onSubmit, null);
|
||||||
|
const [isSavingOverlay, setIsSavingOverlay] = useState(false);
|
||||||
|
|
||||||
|
async function onSubmit() {
|
||||||
|
if (readonly && !isResourceOverlay) return;
|
||||||
|
|
||||||
|
if (isResourceOverlay) {
|
||||||
|
await saveResourceOverlay();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const isValid = await form.trigger();
|
||||||
|
if (!isValid) return;
|
||||||
|
|
||||||
|
const payload = form.getValues();
|
||||||
|
const requests: Array<Promise<AxiosResponse<{}> | void>> = [];
|
||||||
|
|
||||||
|
requests.push(
|
||||||
|
api
|
||||||
|
.put(
|
||||||
|
`/resource-policy/${policy.resourcePolicyId}/access-control`,
|
||||||
|
{
|
||||||
|
sso: payload.sso,
|
||||||
|
userIds: payload.users.map((user) => user.id),
|
||||||
|
roleIds: payload.roles.map((role) => Number(role.id)),
|
||||||
|
skipToIdpId: payload.skipToIdpId
|
||||||
|
}
|
||||||
|
)
|
||||||
|
.catch(handleError)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (passcodeActive && payload.password?.password) {
|
||||||
|
requests.push(
|
||||||
|
api
|
||||||
|
.put(
|
||||||
|
`/resource-policy/${policy.resourcePolicyId}/password`,
|
||||||
|
{ password: payload.password.password }
|
||||||
|
)
|
||||||
|
.catch(handleError)
|
||||||
|
);
|
||||||
|
} else if (!passcodeActive && policy.passwordId) {
|
||||||
|
requests.push(
|
||||||
|
api
|
||||||
|
.put(
|
||||||
|
`/resource-policy/${policy.resourcePolicyId}/password`,
|
||||||
|
{ password: null }
|
||||||
|
)
|
||||||
|
.catch(handleError)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pinActive && payload.pincode?.pincode?.length === 6) {
|
||||||
|
requests.push(
|
||||||
|
api
|
||||||
|
.put(
|
||||||
|
`/resource-policy/${policy.resourcePolicyId}/pincode`,
|
||||||
|
{ pincode: payload.pincode.pincode }
|
||||||
|
)
|
||||||
|
.catch(handleError)
|
||||||
|
);
|
||||||
|
} else if (!pinActive && policy.pincodeId) {
|
||||||
|
requests.push(
|
||||||
|
api
|
||||||
|
.put(
|
||||||
|
`/resource-policy/${policy.resourcePolicyId}/pincode`,
|
||||||
|
{ pincode: null }
|
||||||
|
)
|
||||||
|
.catch(handleError)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
headerAuthActive &&
|
||||||
|
payload.headerAuth?.user &&
|
||||||
|
payload.headerAuth?.password
|
||||||
|
) {
|
||||||
|
requests.push(
|
||||||
|
api
|
||||||
|
.put(
|
||||||
|
`/resource-policy/${policy.resourcePolicyId}/header-auth`,
|
||||||
|
{ headerAuth: payload.headerAuth }
|
||||||
|
)
|
||||||
|
.catch(handleError)
|
||||||
|
);
|
||||||
|
} else if (!headerAuthActive && policy.headerAuth) {
|
||||||
|
requests.push(
|
||||||
|
api
|
||||||
|
.put(
|
||||||
|
`/resource-policy/${policy.resourcePolicyId}/header-auth`,
|
||||||
|
{ headerAuth: null }
|
||||||
|
)
|
||||||
|
.catch(handleError)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
requests.push(
|
||||||
|
api
|
||||||
|
.put(`/resource-policy/${policy.resourcePolicyId}/whitelist`, {
|
||||||
|
emailWhitelistEnabled: payload.emailWhitelistEnabled,
|
||||||
|
emails: payload.emails?.map((e) => e.text) ?? []
|
||||||
|
})
|
||||||
|
.catch(handleError)
|
||||||
|
);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const results = await Promise.all(requests);
|
||||||
|
if (results.every((res) => res && res.status === 200)) {
|
||||||
|
toast({
|
||||||
|
title: t("success"),
|
||||||
|
description: t("policyUpdatedSuccess")
|
||||||
|
});
|
||||||
|
router.refresh();
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
toast({
|
||||||
|
variant: "destructive",
|
||||||
|
title: t("policyErrorUpdate"),
|
||||||
|
description: t("policyErrorUpdateMessageDescription")
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleError(e: unknown) {
|
||||||
|
toast({
|
||||||
|
variant: "destructive",
|
||||||
|
title: t("policyErrorUpdate"),
|
||||||
|
description: formatAxiosError(e, t("policyErrorUpdateDescription"))
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveResourceOverlay() {
|
||||||
|
setIsSavingOverlay(true);
|
||||||
|
try {
|
||||||
|
const currentResourceRoleIds = combinedRoles
|
||||||
|
.filter((r) => !policyRoleLockedIds.has(r.id))
|
||||||
|
.map((r) => Number(r.id));
|
||||||
|
const currentResourceUserIds = combinedUsers
|
||||||
|
.filter((u) => !policyUserLockedIds.has(u.id))
|
||||||
|
.map((u) => u.id);
|
||||||
|
|
||||||
|
await Promise.all([
|
||||||
|
api.post(`/resource/${resourceId}/roles`, {
|
||||||
|
roleIds: currentResourceRoleIds
|
||||||
|
}),
|
||||||
|
api.post(`/resource/${resourceId}/users`, {
|
||||||
|
userIds: currentResourceUserIds
|
||||||
|
})
|
||||||
|
]);
|
||||||
|
|
||||||
|
toast({
|
||||||
|
title: t("success"),
|
||||||
|
description: t("policyUpdatedSuccess")
|
||||||
|
});
|
||||||
|
router.refresh();
|
||||||
|
} catch (e) {
|
||||||
|
toast({
|
||||||
|
variant: "destructive",
|
||||||
|
title: t("policyErrorUpdate"),
|
||||||
|
description: formatAxiosError(
|
||||||
|
e,
|
||||||
|
t("policyErrorUpdateDescription")
|
||||||
|
)
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setIsSavingOverlay(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const isLoading =
|
||||||
|
isResourceOverlay &&
|
||||||
|
(!resourceRolesInitialized || !resourceUsersInitialized);
|
||||||
|
|
||||||
|
const closeCredenza = () => setEditingMethod(null);
|
||||||
|
|
||||||
|
const openMethodEditor = (method: PolicyAuthMethodId) => {
|
||||||
|
setEditingMethod(method);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleToggle = (
|
||||||
|
method: PolicyAuthMethodId,
|
||||||
|
active: boolean,
|
||||||
|
onDisable: () => void,
|
||||||
|
onEnable?: () => void
|
||||||
|
) => {
|
||||||
|
if (active) {
|
||||||
|
onEnable?.();
|
||||||
|
openMethodEditor(method);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
onDisable();
|
||||||
|
setEditingMethod((current) => (current === method ? null : current));
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Form {...form}>
|
||||||
|
<form action={formAction}>
|
||||||
|
<SettingsSection>
|
||||||
|
<SettingsSectionHeader>
|
||||||
|
<SettingsSectionTitle>
|
||||||
|
{t("policyAuthStackTitle")}
|
||||||
|
</SettingsSectionTitle>
|
||||||
|
<SettingsSectionDescription>
|
||||||
|
{t("policyAuthStackDescription")}
|
||||||
|
</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
|
||||||
|
)
|
||||||
|
}))
|
||||||
|
)
|
||||||
|
}
|
||||||
|
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}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<PincodeCredenza
|
||||||
|
open={editingMethod === "pincode"}
|
||||||
|
onOpenChange={(open) => !open && closeCredenza()}
|
||||||
|
defaultPincode={pincode?.pincode ?? ""}
|
||||||
|
onSave={(value) => {
|
||||||
|
form.setValue("pincode", { pincode: value });
|
||||||
|
setPinActive(true);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<PasscodeCredenza
|
||||||
|
open={editingMethod === "passcode"}
|
||||||
|
onOpenChange={(open) => !open && closeCredenza()}
|
||||||
|
defaultPassword={password?.password ?? ""}
|
||||||
|
existingConfigured={Boolean(policy.passwordId)}
|
||||||
|
onSave={(value) => {
|
||||||
|
form.setValue("password", { password: value });
|
||||||
|
setPasscodeActive(true);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<EmailCredenza
|
||||||
|
open={editingMethod === "email"}
|
||||||
|
onOpenChange={(open) => !open && closeCredenza()}
|
||||||
|
emailEnabled={emailEnabled}
|
||||||
|
disabled={authReadonly}
|
||||||
|
emails={emails}
|
||||||
|
onEmailsChange={(value) =>
|
||||||
|
form.setValue("emails", value)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<HeaderAuthCredenza
|
||||||
|
open={editingMethod === "headerAuth"}
|
||||||
|
onOpenChange={(open) => !open && closeCredenza()}
|
||||||
|
defaultValues={
|
||||||
|
headerAuth
|
||||||
|
? {
|
||||||
|
user: headerAuth.user,
|
||||||
|
password: headerAuth.password,
|
||||||
|
extendedCompatibility:
|
||||||
|
headerAuth.extendedCompatibility
|
||||||
|
}
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
existingConfigured={Boolean(policy.headerAuth)}
|
||||||
|
onSave={(value) => {
|
||||||
|
form.setValue("headerAuth", value);
|
||||||
|
setHeaderAuthActive(true);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</SettingsSectionBody>
|
||||||
|
<SettingsSectionFooter>
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
loading={isSubmitting || isSavingOverlay}
|
||||||
|
disabled={
|
||||||
|
(readonly && !isResourceOverlay) ||
|
||||||
|
isSubmitting ||
|
||||||
|
isSavingOverlay ||
|
||||||
|
isLoading
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{t("authMethodsSave")}
|
||||||
|
</Button>
|
||||||
|
</SettingsSectionFooter>
|
||||||
|
</SettingsSection>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
);
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -46,13 +46,6 @@ export const createPolicySchema = z.object({
|
|||||||
|
|
||||||
export type PolicyFormValues = z.infer<typeof createPolicySchema>;
|
export type PolicyFormValues = z.infer<typeof createPolicySchema>;
|
||||||
|
|
||||||
export const addRuleSchema = z.object({
|
|
||||||
action: z.enum(["ACCEPT", "DROP", "PASS"]),
|
|
||||||
match: z.string(),
|
|
||||||
value: z.string(),
|
|
||||||
priority: z.coerce.number<number>().int().optional()
|
|
||||||
});
|
|
||||||
|
|
||||||
export type LocalRule = {
|
export type LocalRule = {
|
||||||
ruleId: number;
|
ruleId: number;
|
||||||
action: "ACCEPT" | "DROP" | "PASS";
|
action: "ACCEPT" | "DROP" | "PASS";
|
||||||
@@ -63,3 +56,17 @@ export type LocalRule = {
|
|||||||
new?: boolean;
|
new?: boolean;
|
||||||
updated?: boolean;
|
updated?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export {
|
||||||
|
createPolicyRulePrioritySchema,
|
||||||
|
createPolicyRuleSchema,
|
||||||
|
createPolicyRuleValueSchema,
|
||||||
|
createPolicyRulesArraySchema,
|
||||||
|
createPolicyRulesSectionSchema,
|
||||||
|
createPolicySchemaWithI18n,
|
||||||
|
getPolicyRuleValidationMessage,
|
||||||
|
validatePolicyRulePriority,
|
||||||
|
validatePolicyRuleValue,
|
||||||
|
validatePolicyRulesForSave,
|
||||||
|
type RuleValidationToast
|
||||||
|
} from "./policy-access-rule-validation";
|
||||||
|
|||||||
29
src/components/resource-policy/policy-access-rule-utils.ts
Normal file
29
src/components/resource-policy/policy-access-rule-utils.ts
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
export type EmptyRuleDraft = {
|
||||||
|
ruleId: number;
|
||||||
|
action: "ACCEPT" | "DROP" | "PASS";
|
||||||
|
match: string;
|
||||||
|
value: string;
|
||||||
|
priority: number;
|
||||||
|
enabled: boolean;
|
||||||
|
new: true;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function createEmptyRule(
|
||||||
|
existingRules: Array<{ priority: number }>
|
||||||
|
): EmptyRuleDraft {
|
||||||
|
const priority =
|
||||||
|
existingRules.reduce(
|
||||||
|
(acc, rule) => (rule.priority > acc ? rule.priority : acc),
|
||||||
|
0
|
||||||
|
) + 1;
|
||||||
|
|
||||||
|
return {
|
||||||
|
ruleId: Date.now(),
|
||||||
|
action: "ACCEPT",
|
||||||
|
match: "PATH",
|
||||||
|
value: "",
|
||||||
|
priority,
|
||||||
|
enabled: true,
|
||||||
|
new: true
|
||||||
|
};
|
||||||
|
}
|
||||||
237
src/components/resource-policy/policy-access-rule-validation.ts
Normal file
237
src/components/resource-policy/policy-access-rule-validation.ts
Normal file
@@ -0,0 +1,237 @@
|
|||||||
|
import { COUNTRIES } from "@server/db/countries";
|
||||||
|
import { isValidRegionId } from "@server/db/regions";
|
||||||
|
import {
|
||||||
|
isValidCIDR,
|
||||||
|
isValidIP,
|
||||||
|
isValidUrlGlobPattern
|
||||||
|
} from "@server/lib/validators";
|
||||||
|
import z from "zod";
|
||||||
|
|
||||||
|
type TranslateFn = (
|
||||||
|
key: string,
|
||||||
|
values?: Record<string, string | number>
|
||||||
|
) => string;
|
||||||
|
|
||||||
|
export type RuleValidationToast = {
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function getPolicyRuleValidationMessage(
|
||||||
|
t: TranslateFn,
|
||||||
|
issue: z.core.$ZodIssue
|
||||||
|
): string {
|
||||||
|
const ruleIndex = issue.path.find((segment) => typeof segment === "number");
|
||||||
|
if (typeof ruleIndex === "number") {
|
||||||
|
return t("rulesErrorValidationRuleDescription", {
|
||||||
|
ruleNumber: ruleIndex + 1,
|
||||||
|
message: issue.message
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return issue.message;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createPolicyRulePrioritySchema(t: TranslateFn) {
|
||||||
|
return z.coerce
|
||||||
|
.number({ error: t("rulesErrorInvalidPriorityDescription") })
|
||||||
|
.int({ message: t("rulesErrorInvalidPriorityDescription") })
|
||||||
|
.min(1, { message: t("rulesErrorInvalidPriorityDescription") });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createPolicyRuleValueSchema(t: TranslateFn, match: string) {
|
||||||
|
const required = z
|
||||||
|
.string()
|
||||||
|
.min(1, { message: t("rulesErrorValueRequired") });
|
||||||
|
|
||||||
|
switch (match) {
|
||||||
|
case "CIDR":
|
||||||
|
return required.refine(isValidCIDR, {
|
||||||
|
message: t("rulesErrorInvalidIpAddressRangeDescription")
|
||||||
|
});
|
||||||
|
case "IP":
|
||||||
|
return required.refine(isValidIP, {
|
||||||
|
message: t("rulesErrorInvalidIpAddressDescription")
|
||||||
|
});
|
||||||
|
case "PATH":
|
||||||
|
return required.refine(isValidUrlGlobPattern, {
|
||||||
|
message: t("rulesErrorInvalidUrlDescription")
|
||||||
|
});
|
||||||
|
case "REGION":
|
||||||
|
return required.refine(isValidRegionId, {
|
||||||
|
message: t("rulesErrorInvalidRegionDescription")
|
||||||
|
});
|
||||||
|
case "COUNTRY":
|
||||||
|
return required.refine(
|
||||||
|
(value) => COUNTRIES.some((country) => country.code === value),
|
||||||
|
{ message: t("rulesErrorInvalidCountryDescription") }
|
||||||
|
);
|
||||||
|
case "ASN":
|
||||||
|
return required.refine((value) => /^AS\d+$/i.test(value.trim()), {
|
||||||
|
message: t("rulesErrorInvalidAsnDescription")
|
||||||
|
});
|
||||||
|
default:
|
||||||
|
return required;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createPolicyRuleSchema(t: TranslateFn) {
|
||||||
|
return z
|
||||||
|
.object({
|
||||||
|
action: z.enum(["ACCEPT", "DROP", "PASS"]),
|
||||||
|
match: z.string(),
|
||||||
|
value: z.string(),
|
||||||
|
priority: z.number().int(),
|
||||||
|
enabled: z.boolean()
|
||||||
|
})
|
||||||
|
.superRefine((rule, ctx) => {
|
||||||
|
const priorityResult = createPolicyRulePrioritySchema(t).safeParse(
|
||||||
|
rule.priority
|
||||||
|
);
|
||||||
|
if (!priorityResult.success) {
|
||||||
|
ctx.addIssue({
|
||||||
|
code: "custom",
|
||||||
|
message:
|
||||||
|
priorityResult.error.issues[0]?.message ??
|
||||||
|
t("rulesErrorInvalidPriorityDescription"),
|
||||||
|
path: ["priority"]
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const valueResult = createPolicyRuleValueSchema(
|
||||||
|
t,
|
||||||
|
rule.match
|
||||||
|
).safeParse(rule.value);
|
||||||
|
if (!valueResult.success) {
|
||||||
|
ctx.addIssue({
|
||||||
|
code: "custom",
|
||||||
|
message:
|
||||||
|
valueResult.error.issues[0]?.message ??
|
||||||
|
t("rulesErrorValueRequired"),
|
||||||
|
path: ["value"]
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createPolicyRulesArraySchema(t: TranslateFn) {
|
||||||
|
return z.array(createPolicyRuleSchema(t)).superRefine((rules, ctx) => {
|
||||||
|
const seenPriorities = new Set<number>();
|
||||||
|
rules.forEach((rule, index) => {
|
||||||
|
if (seenPriorities.has(rule.priority)) {
|
||||||
|
ctx.addIssue({
|
||||||
|
code: "custom",
|
||||||
|
message: t("rulesErrorDuplicatePriorityDescription"),
|
||||||
|
path: [index, "priority"]
|
||||||
|
});
|
||||||
|
}
|
||||||
|
seenPriorities.add(rule.priority);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createPolicyRulesSectionSchema(t: TranslateFn) {
|
||||||
|
return z.object({
|
||||||
|
applyRules: z.boolean(),
|
||||||
|
rules: createPolicyRulesArraySchema(t)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createPolicySchemaWithI18n(
|
||||||
|
t: TranslateFn,
|
||||||
|
baseSchema: z.ZodObject<z.ZodRawShape>
|
||||||
|
) {
|
||||||
|
return baseSchema.extend({
|
||||||
|
rules: createPolicyRulesArraySchema(t)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function validatePolicyRulePriority(
|
||||||
|
t: TranslateFn,
|
||||||
|
value: unknown
|
||||||
|
):
|
||||||
|
| { success: true; data: number }
|
||||||
|
| { success: false; toast: RuleValidationToast } {
|
||||||
|
const result = createPolicyRulePrioritySchema(t).safeParse(value);
|
||||||
|
if (result.success) {
|
||||||
|
return { success: true, data: result.data };
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
toast: {
|
||||||
|
title: t("rulesErrorInvalidPriority"),
|
||||||
|
description:
|
||||||
|
result.error.issues[0]?.message ??
|
||||||
|
t("rulesErrorInvalidPriorityDescription")
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function validatePolicyRuleValue(
|
||||||
|
t: TranslateFn,
|
||||||
|
match: string,
|
||||||
|
value: string
|
||||||
|
):
|
||||||
|
| { success: true; data: string }
|
||||||
|
| { success: false; toast: RuleValidationToast } {
|
||||||
|
const result = createPolicyRuleValueSchema(t, match).safeParse(value);
|
||||||
|
if (result.success) {
|
||||||
|
return { success: true, data: result.data };
|
||||||
|
}
|
||||||
|
|
||||||
|
const issue = result.error.issues[0];
|
||||||
|
const titleKey =
|
||||||
|
match === "CIDR"
|
||||||
|
? "rulesErrorInvalidIpAddressRange"
|
||||||
|
: match === "IP"
|
||||||
|
? "rulesErrorInvalidIpAddress"
|
||||||
|
: match === "PATH"
|
||||||
|
? "rulesErrorInvalidUrl"
|
||||||
|
: match === "REGION"
|
||||||
|
? "rulesErrorInvalidRegion"
|
||||||
|
: match === "COUNTRY"
|
||||||
|
? "rulesErrorInvalidCountry"
|
||||||
|
: match === "ASN"
|
||||||
|
? "rulesErrorInvalidAsn"
|
||||||
|
: "rulesErrorValidation";
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
toast: {
|
||||||
|
title: t(titleKey),
|
||||||
|
description: issue?.message ?? t("rulesErrorValueRequired")
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function validatePolicyRulesForSave(
|
||||||
|
t: TranslateFn,
|
||||||
|
rules: Array<{
|
||||||
|
action: "ACCEPT" | "DROP" | "PASS";
|
||||||
|
match: string;
|
||||||
|
value: string;
|
||||||
|
priority: number;
|
||||||
|
enabled: boolean;
|
||||||
|
}>,
|
||||||
|
applyRules: boolean
|
||||||
|
): { success: true } | { success: false; toast: RuleValidationToast } {
|
||||||
|
if (!applyRules) {
|
||||||
|
return { success: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = createPolicyRulesArraySchema(t).safeParse(rules);
|
||||||
|
if (result.success) {
|
||||||
|
return { success: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
const issue = result.error.issues[0];
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
toast: {
|
||||||
|
title: t("rulesErrorValidation"),
|
||||||
|
description: issue
|
||||||
|
? getPolicyRuleValidationMessage(t, issue)
|
||||||
|
: t("rulesErrorUpdateDescription")
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
21
src/components/resource-policy/policy-auth-method-id.ts
Normal file
21
src/components/resource-policy/policy-auth-method-id.ts
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
import z from "zod";
|
||||||
|
|
||||||
|
export type PolicyAuthMethodId =
|
||||||
|
| "pincode"
|
||||||
|
| "passcode"
|
||||||
|
| "email"
|
||||||
|
| "headerAuth";
|
||||||
|
|
||||||
|
export const setPasswordSchema = z.object({
|
||||||
|
password: z.string().min(4).max(100)
|
||||||
|
});
|
||||||
|
|
||||||
|
export const setPincodeSchema = z.object({
|
||||||
|
pincode: z.string().length(6)
|
||||||
|
});
|
||||||
|
|
||||||
|
export const setHeaderAuthSchema = z.object({
|
||||||
|
user: z.string().min(4).max(100),
|
||||||
|
password: z.string().min(4).max(100),
|
||||||
|
extendedCompatibility: z.boolean()
|
||||||
|
});
|
||||||
45
src/components/resource-policy/policy-auth-summaries.ts
Normal file
45
src/components/resource-policy/policy-auth-summaries.ts
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
type SummaryParams = {
|
||||||
|
t: (key: string, values?: Record<string, string | number>) => string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type SsoSummaryParams = SummaryParams & {
|
||||||
|
idpName?: string;
|
||||||
|
userCount: number;
|
||||||
|
roleCount: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function getSsoSummary({
|
||||||
|
t,
|
||||||
|
idpName,
|
||||||
|
userCount,
|
||||||
|
roleCount
|
||||||
|
}: SsoSummaryParams) {
|
||||||
|
const idp = idpName ?? t("policyAuthSsoDefaultIdp");
|
||||||
|
return t("policyAuthSsoSummary", {
|
||||||
|
idp,
|
||||||
|
users: userCount,
|
||||||
|
roles: roleCount
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getPasscodeSummary({ t }: SummaryParams) {
|
||||||
|
return t("policyAuthPasscodeSummary");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getPincodeSummary({ t }: SummaryParams) {
|
||||||
|
return t("policyAuthPincodeSummary");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getEmailWhitelistSummary({
|
||||||
|
t,
|
||||||
|
count
|
||||||
|
}: SummaryParams & { count: number }) {
|
||||||
|
return t("policyAuthEmailSummary", { count });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getHeaderAuthSummary({
|
||||||
|
t,
|
||||||
|
headerName
|
||||||
|
}: SummaryParams & { headerName: string }) {
|
||||||
|
return headerName || t("policyAuthHeaderAuthSummary");
|
||||||
|
}
|
||||||
@@ -9,11 +9,13 @@ const PLACEHOLDER_ROW_COUNT = 5;
|
|||||||
type DataTableEmptyStateProps = {
|
type DataTableEmptyStateProps = {
|
||||||
colSpan: number;
|
colSpan: number;
|
||||||
action?: ReactNode;
|
action?: ReactNode;
|
||||||
|
message?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function DataTableEmptyState({
|
export function DataTableEmptyState({
|
||||||
colSpan,
|
colSpan,
|
||||||
action
|
action,
|
||||||
|
message
|
||||||
}: DataTableEmptyStateProps) {
|
}: DataTableEmptyStateProps) {
|
||||||
const t = useTranslations();
|
const t = useTranslations();
|
||||||
return (
|
return (
|
||||||
@@ -32,7 +34,7 @@ export function DataTableEmptyState({
|
|||||||
</div>
|
</div>
|
||||||
<div className="relative flex min-h-[11rem] w-full flex-col items-center justify-center gap-4 px-4 py-8">
|
<div className="relative flex min-h-[11rem] w-full flex-col items-center justify-center gap-4 px-4 py-8">
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
{t("noResults")}
|
{message ?? t("noResults")}
|
||||||
</p>
|
</p>
|
||||||
{action}
|
{action}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user