From cae7a925999c1a4e3a3f7d33491b188773a24e03 Mon Sep 17 00:00:00 2001 From: Mauricio Siu Date: Sat, 31 Jan 2026 00:55:09 -0600 Subject: [PATCH] Refactor SSO Registration Dialogs: Update RegisterOidcDialog and RegisterSamlDialog components to use field arrays for managing multiple domains and scopes. Enhance validation logic to ensure at least one domain is provided. Improve UI for adding and removing domains and scopes dynamically, streamlining the user experience in SSO configuration. --- .../proprietary/sso/register-oidc-dialog.tsx | 203 ++++++++++++++---- .../proprietary/sso/register-saml-dialog.tsx | 106 +++++++-- .../proprietary/sso/sso-settings.tsx | 21 +- 3 files changed, 260 insertions(+), 70 deletions(-) diff --git a/apps/dokploy/components/proprietary/sso/register-oidc-dialog.tsx b/apps/dokploy/components/proprietary/sso/register-oidc-dialog.tsx index 8239f75a8..7e4d4b18d 100644 --- a/apps/dokploy/components/proprietary/sso/register-oidc-dialog.tsx +++ b/apps/dokploy/components/proprietary/sso/register-oidc-dialog.tsx @@ -1,13 +1,12 @@ "use client"; import { zodResolver } from "@hookform/resolvers/zod"; -import { Loader2 } from "lucide-react"; +import { Loader2, Plus, Trash2 } from "lucide-react"; import { useState } from "react"; -import { useForm } from "react-hook-form"; +import type { FieldArrayPath } from "react-hook-form"; +import { useFieldArray, useForm } from "react-hook-form"; import { toast } from "sonner"; import { z } from "zod"; -import { authClient } from "@/lib/auth-client"; -import { api } from "@/utils/api"; import { Button } from "@/components/ui/button"; import { Dialog, @@ -28,16 +27,33 @@ import { FormMessage, } from "@/components/ui/form"; import { Input } from "@/components/ui/input"; +import { authClient } from "@/lib/auth-client"; +import { api } from "@/utils/api"; const DEFAULT_SCOPES = ["openid", "email", "profile"]; +const domainsArraySchema = z + .array(z.string().trim()) + .superRefine((arr, ctx) => { + const filled = arr.filter((s) => s.length > 0); + if (filled.length < 1) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "At least one domain is required", + path: [], + }); + } + }); + +const scopesArraySchema = z.array(z.string().trim()); + const oidcProviderSchema = z.object({ providerId: z.string().min(1, "Provider ID is required").trim(), issuer: z.string().min(1, "Issuer URL is required").url("Invalid URL").trim(), - domain: z.string().min(1, "Domain is required").trim(), + domains: domainsArraySchema, clientId: z.string().min(1, "Client ID is required").trim(), clientSecret: z.string().min(1, "Client secret is required"), - scopes: z.string().optional(), + scopes: scopesArraySchema, }); type OidcProviderForm = z.infer; @@ -46,13 +62,13 @@ interface RegisterOidcDialogProps { children: React.ReactNode; } -const formDefaultValues: OidcProviderForm = { +const formDefaultValues = { providerId: "", issuer: "", - domain: "", + domains: [""], clientId: "", clientSecret: "", - scopes: DEFAULT_SCOPES.join(" "), + scopes: [...DEFAULT_SCOPES], }; export function RegisterOidcDialog({ children }: RegisterOidcDialogProps) { @@ -64,17 +80,35 @@ export function RegisterOidcDialog({ children }: RegisterOidcDialogProps) { defaultValues: formDefaultValues, }); + const { fields, append, remove } = useFieldArray({ + control: form.control, + name: "domains" as FieldArrayPath, + }); + + const { + fields: scopeFields, + append: appendScope, + remove: removeScope, + } = useFieldArray({ + control: form.control, + name: "scopes" as FieldArrayPath, + }); + const isSubmitting = form.formState.isSubmitting; const onSubmit = async (data: OidcProviderForm) => { try { - const scopes = data.scopes?.trim() - ? data.scopes.trim().split(/\s+/).filter(Boolean) + const scopes = data.scopes.filter(Boolean).length + ? data.scopes.filter(Boolean) : DEFAULT_SCOPES; + const domain = data.domains + .map((d) => d.trim()) + .filter(Boolean) + .join(","); const { error } = await authClient.sso.register({ providerId: data.providerId, issuer: data.issuer, - domain: data.domain, + domain, oidcConfig: { clientId: data.clientId, clientSecret: data.clientSecret, @@ -156,23 +190,67 @@ export function RegisterOidcDialog({ children }: RegisterOidcDialogProps) { )} /> - ( - - Domain - - - - - Email domain(s) that use this provider (e.g. for sign-in by - email). - - - - )} - /> +
+
+ Domains + +
+

+ Email domains that use this provider (sign-in by email and org + assignment; subdomains matched automatically). +

+ {fields.map((field, index) => ( + ( + + +
+ + +
+
+ +
+ )} + /> + ))} + {(() => { + const err = form.formState.errors.domains; + const msg = + typeof err?.message === "string" + ? err.message + : (err as { root?: { message?: string } } | undefined)?.root + ?.message; + return msg ? ( +

{msg}

+ ) : null; + })()} +
)} /> - ( - - Scopes (optional) - - - - - - )} - /> +
+
+ Scopes (optional) + +
+ + OIDC scopes to request (e.g. openid, email, profile). If empty, + openid, email and profile are used. + + {scopeFields.map((field, index) => ( + ( + + +
+ + +
+
+ +
+ )} + /> + ))} +
+ + + Email domains that use this provider (sign-in by email and org + assignment; subdomains matched automatically). + + {fields.map((field, index) => ( + ( + + +
+ + +
+
+ +
+ )} + /> + ))} + {(() => { + const err = form.formState.errors.domains; + const msg = + typeof err?.message === "string" + ? err.message + : (err as { root?: { message?: string } } | undefined)?.root + ?.message; + return msg ? ( +

{msg}

+ ) : null; + })()} + { const utils = api.useUtils(); const [detailsProvider, setDetailsProvider] = useState(null); + const [baseURL, setBaseURL] = useState(""); + + useEffect(() => { + if (typeof window !== "undefined") { + setBaseURL(window.location.origin); + } + }, []); const { data: providers, isLoading } = api.sso.listProviders.useQuery(); const { mutateAsync: deleteProvider, isLoading: isDeleting } = @@ -333,13 +340,15 @@ export const SSOSettings = () => { Callback URL (configure in your IdP)

- {"{baseURL}"}/api/auth/sso/callback/ + {baseURL || "{baseURL}"}/api/auth/sso/callback/ {detailsProvider.providerId}

-

- Replace {"{baseURL}"} with your Dokploy URL (e.g. https:// - your-domain.com). -

+ {!baseURL && ( +

+ Replace {"{baseURL}"} with your Dokploy URL (e.g. https:// + your-domain.com). +

+ )}