diff --git a/apps/dokploy/components/proprietary/sso/register-oidc-dialog.tsx b/apps/dokploy/components/proprietary/sso/register-oidc-dialog.tsx index 77a68a55a..63b2c0c52 100644 --- a/apps/dokploy/components/proprietary/sso/register-oidc-dialog.tsx +++ b/apps/dokploy/components/proprietary/sso/register-oidc-dialog.tsx @@ -2,7 +2,7 @@ import { zodResolver } from "@hookform/resolvers/zod"; import { Plus, Trash2 } from "lucide-react"; -import { useState } from "react"; +import { useEffect, useState } from "react"; import type { FieldArrayPath } from "react-hook-form"; import { useFieldArray, useForm } from "react-hook-form"; import { toast } from "sonner"; @@ -58,6 +58,7 @@ const oidcProviderSchema = z.object({ type OidcProviderForm = z.infer; interface RegisterOidcDialogProps { + providerId?: string; children: React.ReactNode; } @@ -70,16 +71,66 @@ const formDefaultValues = { scopes: [...DEFAULT_SCOPES], }; -export function RegisterOidcDialog({ children }: RegisterOidcDialogProps) { +function parseOidcConfig(oidcConfig: string | null): { + clientId?: string; + clientSecret?: string; + scopes?: string[]; +} | null { + if (!oidcConfig) return null; + try { + const parsed = JSON.parse(oidcConfig) as { + clientId?: string; + clientSecret?: string; + scopes?: string[]; + }; + return { + clientId: parsed.clientId, + clientSecret: parsed.clientSecret, + scopes: Array.isArray(parsed.scopes) ? parsed.scopes : undefined, + }; + } catch { + return null; + } +} + +export function RegisterOidcDialog({ providerId, children }: RegisterOidcDialogProps) { const utils = api.useUtils(); const [open, setOpen] = useState(false); - const { mutateAsync, isLoading } = api.sso.register.useMutation(); + + const { data } = api.sso.one.useQuery( + { providerId: providerId ?? "" }, + { enabled: !!providerId && open }, + ); + const registerMutation = api.sso.register.useMutation(); + const updateMutation = api.sso.update.useMutation(); + + const isEdit = !!providerId; + const mutateAsync = isEdit ? updateMutation.mutateAsync : registerMutation.mutateAsync; + const isLoading = isEdit ? updateMutation.isLoading : registerMutation.isLoading; const form = useForm({ resolver: zodResolver(oidcProviderSchema), defaultValues: formDefaultValues, }); + useEffect(() => { + if (!data || !open) return; + const domains = data.domain + ? data.domain.split(",").map((d) => d.trim()).filter(Boolean) + : [""]; + if (domains.length === 0) domains.push(""); + const oidc = parseOidcConfig(data.oidcConfig); + form.reset({ + providerId: data.providerId, + issuer: data.issuer, + domains, + clientId: oidc?.clientId ?? "", + clientSecret: oidc?.clientSecret ?? "", + scopes: + oidc?.scopes && oidc.scopes.length > 0 ? oidc.scopes : [...DEFAULT_SCOPES], + }); + }, [data, open, form]); + const { fields, append, remove } = useFieldArray({ control: form.control, name: "domains" as FieldArrayPath, @@ -130,7 +181,11 @@ export function RegisterOidcDialog({ children }: RegisterOidcDialogProps) { }, }); - toast.success("OIDC provider registered successfully"); + toast.success( + isEdit + ? "OIDC provider updated successfully" + : "OIDC provider registered successfully", + ); form.reset(formDefaultValues); setOpen(false); await utils.sso.listProviders.invalidate(); @@ -146,11 +201,13 @@ export function RegisterOidcDialog({ children }: RegisterOidcDialogProps) { {children} - Register OIDC provider + + {isEdit ? "Update OIDC provider" : "Register OIDC provider"} + - 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. + {isEdit + ? "Change issuer, domains, client settings or scopes. Provider ID cannot be changed." + : "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."}
@@ -162,10 +219,16 @@ export function RegisterOidcDialog({ children }: RegisterOidcDialogProps) { Provider ID - + Unique identifier; used in callback URL path. + {isEdit && " Cannot be changed when editing."} @@ -341,7 +404,7 @@ export function RegisterOidcDialog({ children }: RegisterOidcDialogProps) { Cancel
diff --git a/apps/dokploy/components/proprietary/sso/sso-settings.tsx b/apps/dokploy/components/proprietary/sso/sso-settings.tsx index 842c5d87d..d6f46471e 100644 --- a/apps/dokploy/components/proprietary/sso/sso-settings.tsx +++ b/apps/dokploy/components/proprietary/sso/sso-settings.tsx @@ -271,6 +271,16 @@ export const SSOSettings = () => { View details + {isOidc && ( + + + + )} { SSO provider details - View-only. To change settings, remove this provider and add it - again with the new values. + OIDC providers can be updated via Edit. SAML providers must be + removed and re-added to change settings.
diff --git a/apps/dokploy/server/api/routers/proprietary/sso.ts b/apps/dokploy/server/api/routers/proprietary/sso.ts index b7f15c9e5..78422ba02 100644 --- a/apps/dokploy/server/api/routers/proprietary/sso.ts +++ b/apps/dokploy/server/api/routers/proprietary/sso.ts @@ -58,6 +58,130 @@ export const ssoRouter = createTRPCRouter({ }); return providers; }), + one: enterpriseProcedure + .input(z.object({ providerId: z.string().min(1) })) + .query(async ({ ctx, input }) => { + const provider = await db.query.ssoProvider.findFirst({ + where: and( + eq(ssoProvider.providerId, input.providerId), + eq(ssoProvider.organizationId, ctx.session.activeOrganizationId), + eq(ssoProvider.userId, ctx.session.userId), + ), + columns: { + id: true, + providerId: true, + issuer: true, + domain: true, + oidcConfig: true, + samlConfig: true, + organizationId: true, + }, + }); + if (!provider) { + throw new TRPCError({ + code: "NOT_FOUND", + message: + "SSO provider not found or you do not have permission to access it", + }); + } + return provider; + }), + update: enterpriseProcedure + .input(ssoProviderBodySchema) + .mutation(async ({ ctx, input }) => { + const existing = await db.query.ssoProvider.findFirst({ + where: and( + eq(ssoProvider.providerId, input.providerId), + eq(ssoProvider.organizationId, ctx.session.activeOrganizationId), + eq(ssoProvider.userId, ctx.session.userId), + ), + columns: { + id: true, + issuer: true, + domain: true, + }, + }); + + if (!existing) { + throw new TRPCError({ + code: "NOT_FOUND", + message: + "SSO provider not found or you do not have permission to update it", + }); + } + + const providers = await db.query.ssoProvider.findMany({ + where: eq(ssoProvider.organizationId, ctx.session.activeOrganizationId), + columns: { providerId: true, domain: true }, + }); + + for (const provider of providers) { + if (provider.providerId === input.providerId) continue; + const providerDomains = provider.domain + .split(",") + .map((d) => d.trim().toLowerCase()); + for (const domain of input.domains) { + if (providerDomains.includes(domain)) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: `Domain ${domain} is already registered for another provider`, + }); + } + } + } + + const issuerChanged = + normalizeTrustedOrigin(existing.issuer) !== + normalizeTrustedOrigin(input.issuer); + if (issuerChanged) { + const currentUser = await db.query.user.findFirst({ + where: eq(user.id, ctx.session.userId), + columns: { trustedOrigins: true }, + }); + const trustedOrigins = currentUser?.trustedOrigins ?? []; + const newOrigin = normalizeTrustedOrigin(input.issuer); + const isInTrustedOrigins = trustedOrigins.some( + (o) => o.toLowerCase() === newOrigin.toLowerCase(), + ); + if (!isInTrustedOrigins) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: + "The new Issuer URL is not in your trusted origins list. Please add it in Manage origins before saving.", + }); + } + } + + const domain = input.domains.join(","); + + const updatePayload: { + issuer: string; + domain: string; + oidcConfig?: string; + samlConfig?: string; + } = { + issuer: input.issuer, + domain, + }; + if (input.oidcConfig != null) { + updatePayload.oidcConfig = JSON.stringify(input.oidcConfig); + } + if (input.samlConfig != null) { + updatePayload.samlConfig = JSON.stringify(input.samlConfig); + } + + await db + .update(ssoProvider) + .set(updatePayload) + .where( + and( + eq(ssoProvider.providerId, input.providerId), + eq(ssoProvider.organizationId, ctx.session.activeOrganizationId), + eq(ssoProvider.userId, ctx.session.userId), + ), + ); + return { success: true }; + }), deleteProvider: enterpriseProcedure .input(z.object({ providerId: z.string().min(1) })) .mutation(async ({ ctx, input }) => { @@ -102,24 +226,6 @@ export const ssoRouter = createTRPCRouter({ }); } - const currentUser = await db.query.user.findFirst({ - where: eq(user.id, ctx.session.userId), - columns: { - trustedOrigins: true, - }, - }); - - if (currentUser?.trustedOrigins) { - const issuerOrigin = normalizeTrustedOrigin(providerToDelete.issuer); - const updatedOrigins = currentUser.trustedOrigins.filter( - (origin) => origin.toLowerCase() !== issuerOrigin.toLowerCase(), - ); - - await db - .update(user) - .set({ trustedOrigins: updatedOrigins }) - .where(eq(user.id, ctx.session.userId)); - } return { success: true }; }), register: enterpriseProcedure @@ -147,25 +253,6 @@ export const ssoRouter = createTRPCRouter({ } } const domain = input.domains.join(","); - const currentUser = await db.query.user.findFirst({ - where: eq(user.id, ctx.session.userId), - columns: { - trustedOrigins: true, - }, - }); - - const existingOrigins = currentUser?.trustedOrigins || []; - - const issuerOrigin = normalizeTrustedOrigin(input.issuer); - - const newOrigins = Array.from( - new Set([...existingOrigins, issuerOrigin]), - ); - - await db - .update(user) - .set({ trustedOrigins: newOrigins }) - .where(eq(user.id, ctx.session.userId)); await auth.registerSSOProvider({ body: {