diff --git a/messages/en-US.json b/messages/en-US.json index 91cf42c53..8b46ee128 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -180,6 +180,8 @@ "resourcePolicyAuthMethodAdd": "Add Authentication Method", "resourcePolicyOtpEmailAdd": "Add OTP emails", "resourcePolicyRulesAdd": "Add Rules", + "resourcePolicyAuthMethodsDescription": "Allow access to resources via additional auth methods", + "resourcePolicyUsersRolesDescription": "Configure which users and roles can visit associated resources", "rulesResourcePolicyDescription": "Configure rules to control access resources associated to this policy", "authentication": "Authentication", "protected": "Protected", diff --git a/src/app/[orgId]/settings/(private)/resources/policies/create/page.tsx b/src/app/[orgId]/settings/(private)/resources/policies/create/page.tsx index 19fd2ca37..6efdc5597 100644 --- a/src/app/[orgId]/settings/(private)/resources/policies/create/page.tsx +++ b/src/app/[orgId]/settings/(private)/resources/policies/create/page.tsx @@ -1,4 +1,4 @@ -import { CreatePolicyForm } from "@app/components/CreatePolicyForm"; +import { CreatePolicyForm } from "@app/components/resource-policy/CreatePolicyForm"; import SettingsSectionTitle from "@app/components/SettingsSectionTitle"; import { Button } from "@app/components/ui/button"; import { getCachedOrg } from "@app/lib/api/getCachedOrg"; diff --git a/src/components/resource-policy/CreatePolicyForm.tsx b/src/components/resource-policy/CreatePolicyForm.tsx new file mode 100644 index 000000000..a2288268f --- /dev/null +++ b/src/components/resource-policy/CreatePolicyForm.tsx @@ -0,0 +1,204 @@ +"use client"; + +import { + SettingsContainer, + SettingsSection, + SettingsSectionBody, + SettingsSectionDescription, + SettingsSectionForm, + SettingsSectionHeader, + SettingsSectionTitle +} from "@app/components/Settings"; +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 { useEnvContext } from "@app/hooks/useEnvContext"; +import { useOrgContext } from "@app/hooks/useOrgContext"; +import { usePaidStatus } from "@app/hooks/usePaidStatus"; + +import { getUserDisplayName } from "@app/lib/getUserDisplayName"; +import { orgQueries } from "@app/lib/queries"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { build } from "@server/build"; +import { tierMatrix } from "@server/lib/billing/tierMatrix"; +import { UserType } from "@server/types/UserTypes"; +import { useQuery } from "@tanstack/react-query"; +import { useTranslations } from "next-intl"; + +import { useActionState, useMemo } from "react"; +import { useForm } from "react-hook-form"; +import z from "zod"; +import { + PolicyAuthMethodsSection, + PolicyOtpEmailSection, + PolicyRulesSection, + PolicyUsersRolesSection +} from "./ResourcePolicySubForms"; +import { type PolicyFormValues, createPolicySchema } from "."; + +// ─── CreatePolicyForm ───────────────────────────────────────────────────────── + +export type CreatePolicyFormProps = {}; + +export function CreatePolicyForm({}: CreatePolicyFormProps) { + const { org } = useOrgContext(); + const t = useTranslations(); + const { env } = useEnvContext(); + const [, formAction, isSubmitting] = useActionState(onSubmit, null); + const { isPaidUser } = usePaidStatus(); + + const isMaxmindAvailable = !!( + env.server.maxmind_db_path && env.server.maxmind_db_path.length > 0 + ); + const isMaxmindAsnAvailable = !!( + env.server.maxmind_asn_path && env.server.maxmind_asn_path.length > 0 + ); + + const { data: orgRoles = [], isLoading: isLoadingOrgRoles } = useQuery( + orgQueries.roles({ orgId: org.org.orgId }) + ); + const { data: orgUsers = [], isLoading: isLoadingOrgUsers } = useQuery( + orgQueries.users({ orgId: org.org.orgId }) + ); + const { data: orgIdps = [], isLoading: isLoadingOrgIdps } = useQuery( + orgQueries.identityProviders({ + orgId: org.org.orgId, + useOrgOnlyIdp: env.app.identityProviderMode === "org" + }) + ); + + const form = useForm({ + resolver: zodResolver(createPolicySchema) as any, + defaultValues: { + name: "", + sso: true, + skipToIdpId: null, + emailWhitelistEnabled: false, + roles: [], + users: [], + emails: [], + applyRules: false, + rules: [] + } + }); + + async function onSubmit() { + const isValid = await form.trigger(); + + if (!isValid) return; + } + + const allRoles = useMemo( + () => + orgRoles + .map((role) => ({ + id: role.roleId.toString(), + text: role.name + })) + .filter((role) => role.text !== "Admin"), + [orgRoles] + ); + + const allUsers = useMemo( + () => + orgUsers.map((user) => ({ + id: user.id.toString(), + text: `${getUserDisplayName({ email: user.email, username: user.username })}${user.type !== UserType.Internal ? ` (${user.idpName})` : ""}` + })), + [orgUsers] + ); + + const allIdps = useMemo(() => { + if (build === "saas") { + if (isPaidUser(tierMatrix.orgOidc)) { + return orgIdps.map((idp) => ({ + id: idp.idpId, + text: idp.name + })); + } + } else { + return orgIdps.map((idp) => ({ id: idp.idpId, text: idp.name })); + } + return []; + }, [orgIdps, isPaidUser]); + + if (isLoadingOrgRoles || isLoadingOrgUsers || isLoadingOrgIdps) { + return <>; + } + + return ( +
+ + + {/* Name */} + + + + {t("resourcePolicyName")} + + + {t("resourcePolicyNameDescription")} + + + + + ( + + {t("name")} + + + + + + )} + /> + + + + + + + + + + +
+ +
+
+ + ); +} diff --git a/src/components/CreatePolicyForm.tsx b/src/components/resource-policy/ResourcePolicySubForms.tsx similarity index 79% rename from src/components/CreatePolicyForm.tsx rename to src/components/resource-policy/ResourcePolicySubForms.tsx index 01d56fa46..bcb0f0470 100644 --- a/src/components/CreatePolicyForm.tsx +++ b/src/components/resource-policy/ResourcePolicySubForms.tsx @@ -1,7 +1,6 @@ "use client"; import { - SettingsContainer, SettingsSection, SettingsSectionBody, SettingsSectionDescription, @@ -53,25 +52,31 @@ import { TableHeader, TableRow } from "@app/components/ui/table"; -import { useEnvContext } from "@app/hooks/useEnvContext"; -import { useOrgContext } from "@app/hooks/useOrgContext"; -import { usePaidStatus } from "@app/hooks/usePaidStatus"; +import { + Credenza, + CredenzaBody, + CredenzaClose, + CredenzaContent, + CredenzaDescription, + CredenzaFooter, + CredenzaHeader, + CredenzaTitle +} from "@app/components/Credenza"; import { toast } from "@app/hooks/useToast"; +import { + InputOTP, + InputOTPGroup, + InputOTPSlot +} from "@app/components/ui/input-otp"; -import { getUserDisplayName } from "@app/lib/getUserDisplayName"; -import { orgQueries } from "@app/lib/queries"; import { zodResolver } from "@hookform/resolvers/zod"; -import { build } from "@server/build"; import { MAJOR_ASNS } from "@server/db/asns"; import { COUNTRIES } from "@server/db/countries"; -import { tierMatrix } from "@server/lib/billing/tierMatrix"; import { isValidCIDR, isValidIP, isValidUrlGlobPattern } from "@server/lib/validators"; -import { UserType } from "@server/types/UserTypes"; -import { useQuery } from "@tanstack/react-query"; import { ColumnDef, flexRender, @@ -93,11 +98,10 @@ import { } from "lucide-react"; import { useTranslations } from "next-intl"; -import { useActionState, useCallback, useMemo, useState } from "react"; +import { useCallback, useMemo, useState } from "react"; import { UseFormReturn, useForm } from "react-hook-form"; import z from "zod"; - -// ─── Schemas & types ────────────────────────────────────────────────────────── +import type { PolicyFormValues } from "."; const addRuleSchema = z.object({ action: z.enum(["ACCEPT", "DROP", "PASS"]), @@ -117,190 +121,6 @@ type LocalRule = { updated?: boolean; }; -const createPolicySchema = z.object({ - name: z.string().min(1).max(255), - sso: z.boolean().default(true), - skipToIdpId: z.number().nullable().optional(), - emailWhitelistEnabled: z.boolean().default(false), - roles: z.array(z.object({ id: z.string(), text: z.string() })), - users: z.array(z.object({ id: z.string(), text: z.string() })), - emails: z.array(z.object({ id: z.string(), text: z.string() })), - applyRules: z.boolean().default(false), - rules: z - .array( - z.object({ - action: z.enum(["ACCEPT", "DROP", "PASS"]), - match: z.string(), - value: z.string(), - priority: z.number().int(), - enabled: z.boolean() - }) - ) - .default([]) -}); - -type PolicyFormValues = z.infer; - -// ─── CreatePolicyForm ───────────────────────────────────────────────────────── - -export type CreatePolicyFormProps = {}; - -export function CreatePolicyForm({}: CreatePolicyFormProps) { - const { org } = useOrgContext(); - const t = useTranslations(); - const { env } = useEnvContext(); - const [, formAction, isSubmitting] = useActionState(onSubmit, null); - const { isPaidUser } = usePaidStatus(); - - const isMaxmindAvailable = !!( - env.server.maxmind_db_path && env.server.maxmind_db_path.length > 0 - ); - const isMaxmindAsnAvailable = !!( - env.server.maxmind_asn_path && env.server.maxmind_asn_path.length > 0 - ); - - const { data: orgRoles = [], isLoading: isLoadingOrgRoles } = useQuery( - orgQueries.roles({ orgId: org.org.orgId }) - ); - const { data: orgUsers = [], isLoading: isLoadingOrgUsers } = useQuery( - orgQueries.users({ orgId: org.org.orgId }) - ); - const { data: orgIdps = [], isLoading: isLoadingOrgIdps } = useQuery( - orgQueries.identityProviders({ - orgId: org.org.orgId, - useOrgOnlyIdp: env.app.identityProviderMode === "org" - }) - ); - - const form = useForm({ - resolver: zodResolver(createPolicySchema) as any, - defaultValues: { - name: "", - sso: true, - skipToIdpId: null, - emailWhitelistEnabled: false, - roles: [], - users: [], - emails: [], - applyRules: false, - rules: [] - } - }); - - async function onSubmit() { - // ... - } - - const allRoles = useMemo( - () => - orgRoles - .map((role) => ({ - id: role.roleId.toString(), - text: role.name - })) - .filter((role) => role.text !== "Admin"), - [orgRoles] - ); - - const allUsers = useMemo( - () => - orgUsers.map((user) => ({ - id: user.id.toString(), - text: `${getUserDisplayName({ email: user.email, username: user.username })}${user.type !== UserType.Internal ? ` (${user.idpName})` : ""}` - })), - [orgUsers] - ); - - const allIdps = useMemo(() => { - if (build === "saas") { - if (isPaidUser(tierMatrix.orgOidc)) { - return orgIdps.map((idp) => ({ - id: idp.idpId, - text: idp.name - })); - } - } else { - return orgIdps.map((idp) => ({ id: idp.idpId, text: idp.name })); - } - return []; - }, [orgIdps, isPaidUser]); - - if (isLoadingOrgRoles || isLoadingOrgUsers || isLoadingOrgIdps) { - return <>; - } - - return ( -
- - - {/* Name */} - - - - {t("resourcePolicyName")} - - - {t("resourcePolicyNameDescription")} - - - - - ( - - {t("name")} - - - - - - )} - /> - - - - - - - - - - -
- -
-
- - ); -} - -// ─── PolicyUsersRolesSection ────────────────────────────────────────────────── - type PolicyUsersRolesSectionProps = { form: UseFormReturn; allRoles: { id: string; text: string }[]; @@ -308,7 +128,9 @@ type PolicyUsersRolesSectionProps = { allIdps: { id: number; text: string }[]; }; -function PolicyUsersRolesSection({ +// ─── PolicyUsersRolesSection ────────────────────────────────────────────────── + +export function PolicyUsersRolesSection({ form, allRoles, allUsers, @@ -331,7 +153,7 @@ function PolicyUsersRolesSection({ {t("resourceUsersRoles")} - {t("resourceUsersRolesDescription")} + {t("resourcePolicyUsersRolesDescription")} @@ -489,9 +311,51 @@ function PolicyUsersRolesSection({ // ─── PolicyAuthMethodsSection ───────────────────────────────────────────────── -function 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() +}); + +type PolicyAuthMethodsSectionProps = { + form: UseFormReturn; +}; + +export function PolicyAuthMethodsSection({ + form +}: PolicyAuthMethodsSectionProps) { const t = useTranslations(); const [isOpen, setIsOpen] = useState(false); + 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"); + + 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 (!isOpen) { return ( @@ -501,7 +365,7 @@ function PolicyAuthMethodsSection() { {t("resourceAuthMethods")} - {t("resourceAuthMethodsDescriptions")} + {t("resourcePolicyAuthMethodsDescription")} @@ -519,59 +383,359 @@ function PolicyAuthMethodsSection() { } return ( - - - - {t("resourceAuthMethods")} - - - {t("resourceAuthMethodsDescriptions")} - - - - -
-
- - - {t("resourcePasswordProtection", { - status: t("disabled") + <> + {/* Password Credenza */} + { + setIsSetPasswordOpen(val); + if (!val) passwordForm.reset(); + }} + > + + + + {t("resourcePasswordSetupTitle")} + + + {t("resourcePasswordSetupTitleDescription")} + + + +
+ { + form.setValue("password", data); + setIsSetPasswordOpen(false); + passwordForm.reset(); })} - -
- + + -
+ + + -
-
- - - {t("resourcePincodeProtection", { - status: t("disabled") + {/* Pincode Credenza */} + { + setIsSetPincodeOpen(val); + if (!val) pincodeForm.reset(); + }} + > + + + + {t("resourcePincodeSetupTitle")} + + + {t("resourcePincodeSetupTitleDescription")} + + + +
+ { + form.setValue("pincode", data); + setIsSetPincodeOpen(false); + pincodeForm.reset(); })} - -
- + + -
+ + + -
-
- - - {t("resourceHeaderAuthProtectionDisabled")} - -
- + + -
-
-
-
+ + + + + + + + {t("resourceAuthMethods")} + + + {t("resourcePolicyAuthMethodsDescription")} + + + + + {/* Password row */} +
+
+ + + {t("resourcePasswordProtection", { + status: password + ? t("enabled") + : t("disabled") + })} + +
+ +
+ + {/* Pincode row */} +
+
+ + + {t("resourcePincodeProtection", { + status: pincode + ? t("enabled") + : t("disabled") + })} + +
+ +
+ + {/* Header auth row */} +
+
+ + + {headerAuth + ? t("resourceHeaderAuthProtectionEnabled") + : t("resourceHeaderAuthProtectionDisabled")} + +
+ +
+
+
+
+ ); } @@ -582,7 +746,7 @@ type PolicyOtpEmailSectionProps = { emailEnabled: boolean; }; -function PolicyOtpEmailSection({ +export function PolicyOtpEmailSection({ form, emailEnabled }: PolicyOtpEmailSectionProps) { @@ -725,7 +889,7 @@ type PolicyRulesSectionProps = { isMaxmindAsnAvailable: boolean; }; -function PolicyRulesSection({ +export function PolicyRulesSection({ form, isMaxmindAvailable, isMaxmindAsnAvailable diff --git a/src/components/resource-policy/index.ts b/src/components/resource-policy/index.ts new file mode 100644 index 000000000..7b77faddb --- /dev/null +++ b/src/components/resource-policy/index.ts @@ -0,0 +1,47 @@ +// ─── Schemas & types ────────────────────────────────────────────────────────── + +import z from "zod"; + +export const createPolicySchema = z.object({ + name: z.string().min(1).max(255), + sso: z.boolean().default(true), + skipToIdpId: z.number().nullable().optional(), + emailWhitelistEnabled: z.boolean().default(false), + roles: z.array(z.object({ id: z.string(), text: z.string() })), + users: z.array(z.object({ id: z.string(), text: z.string() })), + emails: z.array(z.object({ id: z.string(), text: z.string() })), + password: z + .object({ + password: z.string().min(4).max(100) + }) + .nullable() + .default(null), + pincode: z + .object({ + pincode: z.string().regex(/^\d{6}$/) + }) + .nullable() + .default(null), + headerAuth: z + .object({ + user: z.string().min(4).max(100), + password: z.string().min(4).max(100), + extendedCompatibility: z.boolean().default(true) + }) + .nullable() + .default(null), + applyRules: z.boolean().default(false), + rules: z + .array( + z.object({ + action: z.enum(["ACCEPT", "DROP", "PASS"]), + match: z.string(), + value: z.string(), + priority: z.number().int(), + enabled: z.boolean() + }) + ) + .default([]) +}); + +export type PolicyFormValues = z.infer;