From 6064b8ca48876389db6346a6350e8208ed0fd437 Mon Sep 17 00:00:00 2001 From: Mauricio Siu Date: Thu, 29 Jan 2026 22:11:09 -0600 Subject: [PATCH] Implement SAML Provider Registration and Enhance OIDC Dialog: Add a new SAML provider registration dialog with form validation using Zod, integrate it into the SSO settings page, and refactor the OIDC registration dialog to utilize React Hook Form for improved state management and validation. --- .../proprietary/sso/register-oidc-dialog.tsx | 317 ++++++++++-------- .../proprietary/sso/register-saml-dialog.tsx | 262 +++++++++++++++ .../proprietary/sso/sso-settings.tsx | 143 ++------ 3 files changed, 468 insertions(+), 254 deletions(-) create mode 100644 apps/dokploy/components/proprietary/sso/register-saml-dialog.tsx diff --git a/apps/dokploy/components/proprietary/sso/register-oidc-dialog.tsx b/apps/dokploy/components/proprietary/sso/register-oidc-dialog.tsx index 20a829101..83e809f7d 100644 --- a/apps/dokploy/components/proprietary/sso/register-oidc-dialog.tsx +++ b/apps/dokploy/components/proprietary/sso/register-oidc-dialog.tsx @@ -1,8 +1,11 @@ "use client"; +import { zodResolver } from "@hookform/resolvers/zod"; import { Loader2 } from "lucide-react"; import { useState } from "react"; +import { useForm } from "react-hook-form"; import { toast } from "sonner"; +import { z } from "zod"; import { authClient } from "@/lib/auth-client"; import { Button } from "@/components/ui/button"; import { @@ -14,55 +17,67 @@ import { DialogTitle, DialogTrigger, } from "@/components/ui/dialog"; +import { + Form, + FormControl, + FormDescription, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/components/ui/form"; import { Input } from "@/components/ui/input"; -import { Label } from "@/components/ui/label"; const DEFAULT_SCOPES = ["openid", "email", "profile"]; +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(), + clientId: z.string().min(1, "Client ID is required").trim(), + clientSecret: z.string().min(1, "Client secret is required"), + scopes: z.string().optional(), +}); + +type OidcProviderForm = z.infer; + interface RegisterOidcDialogProps { children: React.ReactNode; onSuccess?: () => void; } +const formDefaultValues: OidcProviderForm = { + providerId: "", + issuer: "", + domain: "", + clientId: "", + clientSecret: "", + scopes: DEFAULT_SCOPES.join(" "), +}; + export function RegisterOidcDialog({ children, onSuccess }: RegisterOidcDialogProps) { const [open, setOpen] = useState(false); - const [isSubmitting, setIsSubmitting] = useState(false); - const [form, setForm] = useState({ - providerId: "", - issuer: "", - domain: "", - clientId: "", - clientSecret: "", - scopes: DEFAULT_SCOPES.join(" "), + + const form = useForm({ + resolver: zodResolver(oidcProviderSchema), + defaultValues: formDefaultValues, }); - const handleSubmit = async (e: React.FormEvent) => { - e.preventDefault(); - if ( - !form.providerId.trim() || - !form.issuer.trim() || - !form.domain.trim() || - !form.clientId.trim() || - !form.clientSecret.trim() - ) { - toast.error("Please fill in all required fields"); - return; - } + const isSubmitting = form.formState.isSubmitting; - setIsSubmitting(true); + const onSubmit = async (data: OidcProviderForm) => { try { - const scopes = form.scopes - .trim() - .split(/\s+/) - .filter(Boolean); - const { data, error } = await authClient.sso.register({ - providerId: form.providerId.trim(), - issuer: form.issuer.trim(), - domain: form.domain.trim(), + const scopes = data.scopes?.trim() + ? data.scopes.trim().split(/\s+/).filter(Boolean) + : DEFAULT_SCOPES; + const { error } = await authClient.sso.register({ + providerId: data.providerId, + issuer: data.issuer, + domain: data.domain, oidcConfig: { - clientId: form.clientId.trim(), - clientSecret: form.clientSecret.trim(), - scopes: scopes.length > 0 ? scopes : DEFAULT_SCOPES, + clientId: data.clientId, + clientSecret: data.clientSecret, + scopes, pkce: true, }, }); @@ -73,22 +88,13 @@ export function RegisterOidcDialog({ children, onSuccess }: RegisterOidcDialogPr } toast.success("OIDC provider registered successfully"); - setForm({ - providerId: "", - issuer: "", - domain: "", - clientId: "", - clientSecret: "", - scopes: DEFAULT_SCOPES.join(" "), - }); + form.reset(formDefaultValues); setOpen(false); onSuccess?.(); } catch (err) { toast.error( err instanceof Error ? err.message : "Failed to register SSO provider", ); - } finally { - setIsSubmitting(false); } }; @@ -99,107 +105,136 @@ export function RegisterOidcDialog({ children, onSuccess }: RegisterOidcDialogPr Register OIDC provider - Add an OpenID Connect (OIDC) identity provider. Discovery will - fill endpoints from the issuer URL when possible. + Add any OIDC-compliant identity provider (e.g. Okta, Azure AD, + Google Workspace, Auth0, Keycloak). Discovery will fill endpoints + from the issuer URL when possible. -
-
- - - setForm((f) => ({ ...f, providerId: e.target.value })) - } - /> -

- Unique identifier; used in callback URL path. -

-
-
- - - setForm((f) => ({ ...f, issuer: e.target.value })) - } - /> -

- Discovery document is fetched from{" "} - - {"{issuer}"}/.well-known/openid-configuration - -

-
-
- - - setForm((f) => ({ ...f, domain: e.target.value })) - } - /> -

- Email domain(s) that use this provider (e.g. for sign-in by email). -

-
-
- - - setForm((f) => ({ ...f, clientId: e.target.value })) - } - /> -
-
- - - setForm((f) => ({ ...f, clientSecret: e.target.value })) - } - /> -
-
- - - setForm((f) => ({ ...f, scopes: e.target.value })) - } - /> -
- - - - -
+ /> + ( + + Issuer URL + + + + + Discovery document is fetched from{" "} + + {"{issuer}"}/.well-known/openid-configuration + + + + + )} + /> + ( + + Domain + + + + + Email domain(s) that use this provider (e.g. for sign-in by + email). + + + + )} + /> + ( + + Client ID + + + + + + )} + /> + ( + + Client secret + + + + + + )} + /> + ( + + Scopes (optional) + + + + + + )} + /> + + + + + + ); diff --git a/apps/dokploy/components/proprietary/sso/register-saml-dialog.tsx b/apps/dokploy/components/proprietary/sso/register-saml-dialog.tsx new file mode 100644 index 000000000..5c6a7f7cf --- /dev/null +++ b/apps/dokploy/components/proprietary/sso/register-saml-dialog.tsx @@ -0,0 +1,262 @@ +"use client"; + +import { zodResolver } from "@hookform/resolvers/zod"; +import { Loader2 } from "lucide-react"; +import { useState } from "react"; +import { useForm } from "react-hook-form"; +import { toast } from "sonner"; +import { z } from "zod"; +import { authClient } from "@/lib/auth-client"; +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog"; +import { + Form, + FormControl, + FormDescription, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/components/ui/form"; +import { Input } from "@/components/ui/input"; +import { Textarea } from "@/components/ui/textarea"; + +const samlProviderSchema = 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(), + entryPoint: z + .string() + .min(1, "IdP SSO URL is required") + .url("Invalid URL") + .trim(), + cert: z.string().min(1, "IdP signing certificate is required"), + callbackUrl: z + .string() + .min(1, "Callback URL is required") + .url("Invalid URL") + .trim(), + audience: z.string().min(1, "Audience (Entity ID) is required").trim(), +}); + +type SamlProviderForm = z.infer; + +interface RegisterSamlDialogProps { + children: React.ReactNode; + onSuccess?: () => void; +} + +const formDefaultValues: SamlProviderForm = { + providerId: "", + issuer: "", + domain: "", + entryPoint: "", + cert: "", + callbackUrl: "", + audience: "", +}; + +export function RegisterSamlDialog({ children, onSuccess }: RegisterSamlDialogProps) { + const [open, setOpen] = useState(false); + + const form = useForm({ + resolver: zodResolver(samlProviderSchema), + defaultValues: formDefaultValues, + }); + + const isSubmitting = form.formState.isSubmitting; + + const onSubmit = async (data: SamlProviderForm) => { + try { + const { error } = await authClient.sso.register({ + providerId: data.providerId, + issuer: data.issuer, + domain: data.domain, + samlConfig: { + entryPoint: data.entryPoint, + cert: data.cert, + callbackUrl: data.callbackUrl, + audience: data.audience, + wantAssertionsSigned: true, + signatureAlgorithm: "sha256", + digestAlgorithm: "sha256", + }, + }); + + if (error) { + toast.error(error.message ?? "Failed to register SAML provider"); + return; + } + + toast.success("SAML provider registered successfully"); + form.reset(formDefaultValues); + setOpen(false); + onSuccess?.(); + } catch (err) { + toast.error( + err instanceof Error ? err.message : "Failed to register SAML provider", + ); + } + }; + + return ( + + {children} + + + Register SAML provider + + Add a SAML 2.0 identity provider (e.g. Okta SAML, Azure AD SAML, + OneLogin). You need the IdP's SSO URL and signing certificate. + + +
+ + ( + + Provider ID + + + + + + )} + /> + ( + + Issuer URL + + + + + + )} + /> + ( + + Domain + + + + + + )} + /> + ( + + IdP SSO URL (Entry point) + + + + + Single Sign-On URL from your IdP's SAML setup. + + + + )} + /> + ( + + IdP signing certificate (X.509) + +