first pass restyle of auth methods and rules

This commit is contained in:
miloschwartz
2026-06-05 21:04:03 -07:00
parent 8ee520dbb5
commit dd8bcbb3e3
28 changed files with 3583 additions and 6773 deletions

View File

@@ -756,11 +756,11 @@
"rulesErrorDuplicate": "Duplicate rule",
"rulesErrorDuplicateDescription": "A rule with these settings already exists",
"rulesErrorInvalidIpAddressRange": "Invalid CIDR",
"rulesErrorInvalidIpAddressRangeDescription": "Please enter a valid CIDR value",
"rulesErrorInvalidUrl": "Invalid URL path",
"rulesErrorInvalidUrlDescription": "Please enter a valid URL path value",
"rulesErrorInvalidIpAddress": "Invalid IP",
"rulesErrorInvalidIpAddressDescription": "Please enter a valid IP address",
"rulesErrorInvalidIpAddressRangeDescription": "Enter a valid CIDR range (e.g., 10.0.0.0/8).",
"rulesErrorInvalidUrl": "Invalid path",
"rulesErrorInvalidUrlDescription": "Enter a valid URL path or pattern (e.g., /api/*).",
"rulesErrorInvalidIpAddress": "Invalid IP address",
"rulesErrorInvalidIpAddressDescription": "Enter a valid IPv4 or IPv6 address.",
"rulesErrorUpdate": "Failed to update rules",
"rulesErrorUpdateDescription": "An error occurred while updating rules",
"rulesUpdated": "Enable Rules",
@@ -768,10 +768,17 @@
"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)",
"rulesMatchUrl": "Enter a URL path or pattern (e.g., /api/v1/todos or /api/v1/*)",
"rulesErrorInvalidPriority": "Invalid Priority",
"rulesErrorInvalidPriorityDescription": "Please enter a valid priority",
"rulesErrorDuplicatePriority": "Duplicate Priorities",
"rulesErrorDuplicatePriorityDescription": "Please enter unique priorities",
"rulesErrorInvalidPriority": "Invalid priority",
"rulesErrorInvalidPriorityDescription": "Enter a whole number of 1 or higher.",
"rulesErrorDuplicatePriority": "Duplicate 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",
"ruleUpdatedDescription": "Rules updated successfully",
"ruleErrorUpdate": "Operation failed",
@@ -795,7 +802,7 @@
"rulesResource": "Resource Rules Configuration",
"rulesResourceDescription": "Configure rules to control access to the resource",
"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.",
"rulesSubmit": "Save Rules",
"policyErrorCreate": "Error creating policy",
@@ -806,7 +813,44 @@
"policyErrorUpdateMessageDescription": "An unexpected error occurred",
"policyCreatedSuccess": "Resource policy succesfully created",
"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",
"resourceErrorCreate": "Error creating resource",
"resourceErrorCreateDescription": "An error occurred when creating the resource",
@@ -3045,7 +3089,7 @@
"enterConfirmation": "Enter confirmation",
"blueprintViewDetails": "Details",
"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",
"editInternalResourceDialogAccessPolicy": "Access Policy",
"editInternalResourceDialogAddRoles": "Add Roles",

View File

@@ -1,6 +1,5 @@
"use client";
import ActionBanner from "@app/components/ActionBanner";
import { EditPolicyForm } from "@app/components/resource-policy/EditPolicyForm";
import {
SettingsContainer,
@@ -45,9 +44,8 @@ import { tierMatrix, TierFeature } from "@server/lib/billing/tierMatrix";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import SetResourcePasswordForm from "@app/components/SetResourcePasswordForm";
import { Binary, Bot, InfoIcon, Key } from "lucide-react";
import { ArrowRightIcon, CheckIcon, ShieldAlertIcon } from "lucide-react";
import { CheckIcon } from "lucide-react";
import { useTranslations } from "next-intl";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { useEffect, useState, useTransition } from "react";
import { useForm, useWatch } from "react-hook-form";
@@ -184,10 +182,6 @@ export default function ResourceAuthenticationPage() {
return <></>;
}
console.log({
shared: policies.sharedPolicy
});
return (
<>
<SettingsContainer>
@@ -314,30 +308,6 @@ export default function ResourceAuthenticationPage() {
policy={policies.sharedPolicy}
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
resourceId={resource.resourceId}
/>

View File

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

View File

@@ -19,7 +19,11 @@ import { build } from "@server/build";
import { UserType } from "@server/types/UserTypes";
import { useQuery } from "@tanstack/react-query";
import { useTranslations } from "next-intl";
import { type PolicyFormValues, createPolicySchema } from ".";
import {
type PolicyFormValues,
createPolicySchema,
createPolicySchemaWithI18n
} from ".";
import { toast } from "@app/hooks/useToast";
import { createApiClient, formatAxiosError } from "@app/lib/api";
import { orgs, type ResourcePolicy } from "@server/db";
@@ -37,10 +41,8 @@ import {
import { Input } from "@app/components/ui/input";
import { useMemo, useTransition } from "react";
import { useForm } from "react-hook-form";
import { CreatePolicyUsersRolesSectionForm } from "./CreatePolicyUserRolesSectionForm";
import { CreatePolicyAuthMethodsSectionForm } from "./CreatePolicyAuthMethodsSectionForm";
import { CreatePolicyOtpEmailSectionForm } from "./CreatePolicyOtpEmailSectionForm";
import { CreatePolicyRulesSectionForm } from "./CreatePolicyRulesSectionForm";
import { PolicyAuthStackSection } from "./PolicyAuthStackSection";
import { PolicyAccessRulesSection } from "./PolicyAccessRulesSection";
import { PaidFeaturesAlert } from "@app/components/PaidFeaturesAlert";
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>({
resolver: zodResolver(createPolicySchema) as any,
resolver: zodResolver(policySchema) as any,
defaultValues: {
name: "",
sso: true,
@@ -245,18 +252,17 @@ export function CreatePolicyForm({}: CreatePolicyFormProps) {
</SettingsSectionBody>
</SettingsSection>
<CreatePolicyUsersRolesSectionForm
<PolicyAuthStackSection
mode="create"
form={form}
orgId={org.org.orgId}
allIdps={allIdps}
allRoles={allRoles}
allUsers={allUsers}
allIdps={allIdps}
/>
<CreatePolicyAuthMethodsSectionForm form={form} />
<CreatePolicyOtpEmailSectionForm
form={form}
emailEnabled={env.email.emailEnabled}
/>
<CreatePolicyRulesSectionForm
<PolicyAccessRulesSection
mode="create"
form={form}
isMaxmindAvailable={isMaxmindAvailable}
isMaxmindAsnAvailable={isMaxmindAsnAvailable}

View File

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

View File

@@ -11,13 +11,15 @@ import {
import { zodResolver } from "@hookform/resolvers/zod";
import { useTranslations } from "next-intl";
import z from "zod";
import { createPolicySchema, type PolicyFormValues } from ".";
import { createPolicyRulesSectionSchema, type PolicyFormValues } from ".";
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 { DataTableEmptyState } from "@app/components/ui/data-table-empty-state";
import {
Command,
CommandEmpty,
@@ -26,15 +28,6 @@ import {
CommandItem,
CommandList
} 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 {
Popover,
@@ -60,11 +53,6 @@ import {
import { MAJOR_ASNS } from "@server/db/asns";
import { COUNTRIES } from "@server/db/countries";
import {
isValidCIDR,
isValidIP,
isValidUrlGlobPattern
} from "@server/lib/validators";
import {
ColumnDef,
flexRender,
@@ -79,14 +67,10 @@ import { ArrowUpDown, Check, ChevronsUpDown, Plus } from "lucide-react";
import { useCallback, useEffect, useMemo, useState } from "react";
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({
action: z.enum(["ACCEPT", "DROP", "PASS"]),
match: z.string(),
value: z.string(),
priority: z.coerce.number<number>().int().optional()
});
// ─── CreatePolicyRulesSectionForm ─────────────────────────────────────────────
type LocalRule = {
ruleId: number;
@@ -111,19 +95,15 @@ export function CreatePolicyRulesSectionForm({
isMaxmindAsnAvailable
}: CreatePolicyRulesSectionFormProps) {
const t = useTranslations();
const [isExpanded, setIsExpanded] = useState(false);
const [rules, setRules] = useState<LocalRule[]>([]);
const [openAddRuleCountrySelect, setOpenAddRuleCountrySelect] =
useState(false);
const [openAddRuleAsnSelect, setOpenAddRuleAsnSelect] = useState(false);
const rulesFormSchema = useMemo(
() => createPolicyRulesSectionSchema(t),
[t]
);
const form = useForm({
resolver: zodResolver(
createPolicySchema.pick({
applyRules: true,
rules: true
})
),
resolver: zodResolver(rulesFormSchema),
defaultValues: {
applyRules: false,
rules: []
@@ -143,15 +123,6 @@ export function CreatePolicyRulesSectionForm({
name: "applyRules"
});
const addRuleForm = useForm({
resolver: zodResolver(addRuleSchema),
defaultValues: {
action: "ACCEPT" as const,
match: "PATH",
value: ""
}
});
const RuleAction = useMemo(
() => ({
ACCEPT: t("alwaysAllow"),
@@ -190,84 +161,11 @@ export function CreatePolicyRulesSectionForm({
[form]
);
const addRule = useCallback(
function addRule(data: z.infer<typeof addRuleSchema>) {
const isDuplicate = rules.some(
(rule) =>
rule.action === data.action &&
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 addEmptyRule = useCallback(() => {
const updatedRules = [...rules, createEmptyRule(rules)];
setRules(updatedRules);
syncFormRules(updatedRules);
}, [rules, syncFormRules]);
const removeRule = useCallback(
function removeRule(ruleId: number) {
@@ -291,63 +189,63 @@ export function CreatePolicyRulesSectionForm({
[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(
() => [
{
accessorKey: "priority",
size: 96,
maxSize: 96,
header: ({ column }) => (
<Button
variant="ghost"
onClick={() =>
column.toggleSorting(column.getIsSorted() === "asc")
}
>
{t("rulesPriority")}
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
<div className="p-3">
<Button
variant="ghost"
className="h-auto p-0 font-medium text-muted-foreground hover:bg-transparent"
onClick={() =>
column.toggleSorting(
column.getIsSorted() === "asc"
)
}
>
{t("rulesPriority")}
<ArrowUpDown className="ml-1 h-3 w-3" />
</Button>
</div>
),
cell: ({ row }) => (
<Input
defaultValue={row.original.priority}
className="w-[75px]"
className="w-full min-w-0"
type="number"
onClick={(e) => e.currentTarget.focus()}
onBlur={(e) => {
const parsed = z.coerce
.number()
.int()
.optional()
.safeParse(e.target.value);
if (!parsed.success) {
const validated = validatePolicyRulePriority(
t,
e.target.value
);
if (!validated.success) {
toast({
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(
"rulesErrorInvalidPriorityDescription"
"rulesErrorDuplicatePriorityDescription"
)
});
return;
}
updateRule(row.original.ruleId, {
priority: parsed.data
priority: validated.data
});
}}
/>
@@ -355,6 +253,8 @@ export function CreatePolicyRulesSectionForm({
},
{
accessorKey: "action",
size: 160,
maxSize: 160,
header: () => <span className="p-3">{t("rulesAction")}</span>,
cell: ({ row }) => (
<Select
@@ -363,7 +263,7 @@ export function CreatePolicyRulesSectionForm({
updateRule(row.original.ruleId, { action: value })
}
>
<SelectTrigger className="min-w-[150px]">
<SelectTrigger className="h-8 w-full min-w-0">
<SelectValue />
</SelectTrigger>
<SelectContent>
@@ -382,6 +282,8 @@ export function CreatePolicyRulesSectionForm({
},
{
accessorKey: "match",
size: 144,
maxSize: 144,
header: () => (
<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 />
</SelectTrigger>
<SelectContent>
@@ -577,11 +479,23 @@ export function CreatePolicyRulesSectionForm({
<Input
defaultValue={row.original.value}
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, {
value: e.target.value
})
}
value: validated.data
});
}}
/>
)
},
@@ -589,19 +503,23 @@ export function CreatePolicyRulesSectionForm({
accessorKey: "enabled",
header: () => <span className="p-3">{t("enabled")}</span>,
cell: ({ row }) => (
<Switch
defaultChecked={row.original.enabled}
onCheckedChange={(val) =>
updateRule(row.original.ruleId, { enabled: val })
}
/>
<div className="flex items-center w-full">
<Switch
defaultChecked={row.original.enabled}
onCheckedChange={(val) =>
updateRule(row.original.ruleId, {
enabled: val
})
}
/>
</div>
)
},
{
id: "actions",
header: () => <span className="p-3">{t("actions")}</span>,
header: () => null,
cell: ({ row }) => (
<div className="flex items-center space-x-2">
<div className="flex items-center justify-end space-x-2">
<Button
variant="outline"
onClick={() => removeRule(row.original.ruleId)}
@@ -619,7 +537,8 @@ export function CreatePolicyRulesSectionForm({
isMaxmindAvailable,
isMaxmindAsnAvailable,
updateRule,
removeRule
removeRule,
rules
]
);
@@ -633,36 +552,18 @@ export function CreatePolicyRulesSectionForm({
state: { pagination: { pageIndex: 0, pageSize: 1000 } }
});
if (!isExpanded) {
return (
<SettingsSection>
<SettingsSectionHeader>
<SettingsSectionTitle>
{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>
);
}
const addRuleButton = (
<Button type="button" variant="outline" onClick={addEmptyRule}>
<Plus className="h-4 w-4 mr-2" />
{t("ruleSubmit")}
</Button>
);
return (
<SettingsSection>
<SettingsSectionHeader>
<SettingsSectionTitle>
{t("rulesResource")}
{t("policyAccessRulesTitle")}
</SettingsSectionTitle>
<SettingsSectionDescription>
{t("rulesResourceDescription")}
@@ -670,421 +571,128 @@ export function CreatePolicyRulesSectionForm({
</SettingsSectionHeader>
<SettingsSectionBody>
<div className="flex flex-col gap-y-6 pb-20">
<div className="flex items-center gap-x-2">
<SwitchInput
id="rules-toggle"
label={t("rulesEnable")}
defaultChecked={false}
onCheckedChange={(val) => {
form.setValue("applyRules", val);
}}
/>
</div>
<PolicyAccessRulesIntro
rulesEnabled={Boolean(rulesEnabled)}
onRulesEnabledChange={(val) => {
form.setValue("applyRules", val);
}}
/>
<Form {...addRuleForm}>
<form
onSubmit={addRuleForm.handleSubmit(addRule)}
className="space-y-4"
>
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 items-end">
<FormField
control={addRuleForm.control}
name="action"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("rulesAction")}
</FormLabel>
<FormControl>
<Select
value={field.value}
onValueChange={
field.onChange
}
>
<SelectTrigger className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="ACCEPT">
{RuleAction.ACCEPT}
</SelectItem>
<SelectItem value="DROP">
{RuleAction.DROP}
</SelectItem>
<SelectItem value="PASS">
{RuleAction.PASS}
</SelectItem>
</SelectContent>
</Select>
</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
{rulesEnabled && (
<>
<Table>
<TableHeader>
{table
.getHeaderGroups()
.map((headerGroup) => (
<TableRow key={headerGroup.id}>
{headerGroup.headers.map(
(header) => {
const columnId =
header.column.id;
const isActionsColumn =
columnId ===
"actions";
const isPriorityColumn =
columnId ===
"priority";
const isActionColumn =
columnId ===
"action";
const isMatchColumn =
columnId ===
"match";
return (
<TableHead
key={header.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"
: ""
}
</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
? COUNTRIES.find(
(c) =>
c.code ===
field.value
)?.name +
" (" +
field.value +
")"
: t(
"selectCountry"
{header.isPlaceholder
? null
: flexRender(
header
.column
.columnDef
.header,
header.getContext()
)}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</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"
: ""
</TableHead>
);
}
>
{flexRender(
cell.column.columnDef
.cell,
cell.getContext()
)}
</TableCell>
);
})}
</TableRow>
))
) : (
<TableRow>
<TableCell
colSpan={columns.length}
className="h-24 text-center"
>
{t("rulesNoOne")}
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
)}
</TableRow>
))}
</TableHeader>
<TableBody>
{table.getRowModel().rows?.length ? (
table.getRowModel().rows.map((row) => (
<TableRow key={row.id}>
{row
.getVisibleCells()
.map((cell) => {
const columnId =
cell.column.id;
const isActionsColumn =
columnId ===
"actions";
const isPriorityColumn =
columnId ===
"priority";
const isActionColumn =
columnId ===
"action";
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>
</SettingsSectionBody>
</SettingsSection>

View File

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

View File

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

View File

@@ -12,17 +12,11 @@ import { tierMatrix } from "@server/lib/billing/tierMatrix";
import { useQuery } from "@tanstack/react-query";
import { useTranslations } from "next-intl";
import { createApiClient } from "@app/lib/api";
import { useRouter } from "next/navigation";
import { useMemo } from "react";
import { EditPolicyAuthMethodsSectionForm } from "./EditPolicyAuthMethodsSectionForm";
import { HorizontalTabs } from "@app/components/HorizontalTabs";
import { EditPolicyNameSectionForm } from "./EditPolicyNameSectionForm";
import { EditPolicyUsersRolesSectionForm } from "./EditPolicyUserRolesSectionForm";
import { EditPolicyOtpEmailSectionForm } from "./EditPolicyOtpEmailSectionForm";
import { EditPolicyRulesSectionForm } from "./EditPolicyRulesSectionForm";
// ─── EditPolicyForm ─────────────────────────────────────────────────────────
import { PolicyAuthStackSection } from "./PolicyAuthStackSection";
import { PolicyAccessRulesSection } from "./PolicyAccessRulesSection";
export type EditPolicyFormProps = {
hidePolicyNameForm?: boolean;
@@ -35,19 +29,15 @@ export function EditPolicyForm({
readonly,
resourceId
}: EditPolicyFormProps) {
const { org } = useOrgContext();
const t = useTranslations();
const { org } = useOrgContext();
const { env } = useEnvContext();
const api = createApiClient({ env });
// const [, formAction, isSubmitting] = useActionState(onSubmit, null);
const { isPaidUser } = usePaidStatus();
const router = useRouter();
// In overlay mode (resourceId provided), policy-level sections are locked.
// Rules and users/roles sections handle their own hybrid logic via resourceId.
const isOverlay = resourceId !== undefined;
const policyLevelReadonly = readonly || isOverlay;
const showTabs = !hidePolicyNameForm && !isOverlay;
const isMaxmindAvailable = !!(
env.server.maxmind_db_path && env.server.maxmind_db_path.length > 0
@@ -81,32 +71,54 @@ export function EditPolicyForm({
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 (
<SettingsContainer>
{!hidePolicyNameForm && (
<EditPolicyNameSectionForm readonly={policyLevelReadonly} />
{!hidePolicyNameForm && !isOverlay && (
<EditPolicyNameSectionForm readonly={readonly} />
)}
<EditPolicyUsersRolesSectionForm
orgId={org.org.orgId}
allIdps={allIdps}
readonly={readonly}
resourceId={resourceId}
/>
{authSection}
<EditPolicyAuthMethodsSectionForm readonly={policyLevelReadonly} />
<EditPolicyOtpEmailSectionForm
emailEnabled={env.email.emailEnabled}
readonly={policyLevelReadonly}
/>
<EditPolicyRulesSectionForm
isMaxmindAvailable={isMaxmindAvailable}
isMaxmindAsnAvailable={isMaxmindASNAvailable}
readonly={readonly}
resourceId={resourceId}
/>
{rulesSection}
</SettingsContainer>
);
}

View File

@@ -137,7 +137,7 @@ export function EditPolicyNameSectionForm({
</SettingsSectionDescription>
</SettingsSectionHeader>
<SettingsSectionBody>
<SettingsSectionForm>
<SettingsSectionForm variant="half">
<FormField
control={form.control}
name="name"

View File

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

View File

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

View 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}
/>
);
}

File diff suppressed because it is too large Load Diff

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

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

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

View 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} />;
}

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

View 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

View File

@@ -46,13 +46,6 @@ export const createPolicySchema = z.object({
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 = {
ruleId: number;
action: "ACCEPT" | "DROP" | "PASS";
@@ -63,3 +56,17 @@ export type LocalRule = {
new?: boolean;
updated?: boolean;
};
export {
createPolicyRulePrioritySchema,
createPolicyRuleSchema,
createPolicyRuleValueSchema,
createPolicyRulesArraySchema,
createPolicyRulesSectionSchema,
createPolicySchemaWithI18n,
getPolicyRuleValidationMessage,
validatePolicyRulePriority,
validatePolicyRuleValue,
validatePolicyRulesForSave,
type RuleValidationToast
} from "./policy-access-rule-validation";

View 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
};
}

View 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")
}
};
}

View 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()
});

View 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");
}

View File

@@ -9,11 +9,13 @@ const PLACEHOLDER_ROW_COUNT = 5;
type DataTableEmptyStateProps = {
colSpan: number;
action?: ReactNode;
message?: string;
};
export function DataTableEmptyState({
colSpan,
action
action,
message
}: DataTableEmptyStateProps) {
const t = useTranslations();
return (
@@ -32,7 +34,7 @@ export function DataTableEmptyState({
</div>
<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">
{t("noResults")}
{message ?? t("noResults")}
</p>
{action}
</div>