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). +

+ )}