diff --git a/apps/dokploy/components/proprietary/sso/register-oidc-dialog.tsx b/apps/dokploy/components/proprietary/sso/register-oidc-dialog.tsx index 1f34b0347..8239f75a8 100644 --- a/apps/dokploy/components/proprietary/sso/register-oidc-dialog.tsx +++ b/apps/dokploy/components/proprietary/sso/register-oidc-dialog.tsx @@ -80,6 +80,14 @@ export function RegisterOidcDialog({ children }: RegisterOidcDialogProps) { clientSecret: data.clientSecret, scopes, pkce: true, + // Keycloak (and many IdPs) send preferred_username; better-auth expects name + mapping: { + id: "sub", + email: "email", + emailVerified: "email_verified", + name: "preferred_username", + image: "picture", + }, }, }); diff --git a/apps/dokploy/components/proprietary/sso/register-saml-dialog.tsx b/apps/dokploy/components/proprietary/sso/register-saml-dialog.tsx index b8ae4ccb3..bed80161b 100644 --- a/apps/dokploy/components/proprietary/sso/register-saml-dialog.tsx +++ b/apps/dokploy/components/proprietary/sso/register-saml-dialog.tsx @@ -6,8 +6,6 @@ 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 { api } from "@/utils/api"; import { Button } from "@/components/ui/button"; import { Dialog, @@ -29,6 +27,8 @@ import { } from "@/components/ui/form"; import { Input } from "@/components/ui/input"; import { Textarea } from "@/components/ui/textarea"; +import { authClient } from "@/lib/auth-client"; +import { api } from "@/utils/api"; const samlProviderSchema = z.object({ providerId: z.string().min(1, "Provider ID is required").trim(), @@ -89,6 +89,9 @@ export function RegisterSamlDialog({ children }: RegisterSamlDialogProps) { wantAssertionsSigned: true, signatureAlgorithm: "sha256", digestAlgorithm: "sha256", + spMetadata: { + entityID: data.audience, + }, }, }); diff --git a/apps/dokploy/components/proprietary/sso/sso-settings.tsx b/apps/dokploy/components/proprietary/sso/sso-settings.tsx index dabe79c12..5ed2917a9 100644 --- a/apps/dokploy/components/proprietary/sso/sso-settings.tsx +++ b/apps/dokploy/components/proprietary/sso/sso-settings.tsx @@ -1,6 +1,7 @@ "use client"; -import { Loader2, LogIn, Trash2 } from "lucide-react"; +import { Loader2, LogIn, Trash2, Eye } from "lucide-react"; +import { useState } from "react"; import { toast } from "sonner"; import { DialogAction } from "@/components/shared/dialog-action"; import { Badge } from "@/components/ui/badge"; @@ -12,12 +13,55 @@ import { CardHeader, CardTitle, } from "@/components/ui/card"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; import { api } from "@/utils/api"; import { RegisterOidcDialog } from "./register-oidc-dialog"; import { RegisterSamlDialog } from "./register-saml-dialog"; +type ProviderForDetails = { + id: string | null; + providerId: string; + issuer: string; + domain: string; + oidcConfig: string | null; + samlConfig: string | null; + organizationId: string | null; +}; + +function parseOidcConfig(config: string | null): { + clientId?: string; + scopes?: string[]; +} | null { + if (!config) return null; + try { + const parsed = JSON.parse(config) as { clientId?: string; scopes?: string[] }; + return { clientId: parsed.clientId, scopes: parsed.scopes }; + } catch { + return null; + } +} + +function parseSamlConfig(config: string | null): { entryPoint?: string } | null { + if (!config) return null; + try { + const parsed = JSON.parse(config) as { entryPoint?: string }; + return { entryPoint: parsed.entryPoint }; + } catch { + return null; + } +} + export function SSOSettings() { const utils = api.useUtils(); + const [detailsProvider, setDetailsProvider] = + useState(null); const { data: providers, isLoading } = api.sso.listProviders.useQuery(); const { mutateAsync: deleteProvider, isLoading: isDeleting } = api.sso.deleteProvider.useMutation(); @@ -99,6 +143,24 @@ export function SSOSettings() { + )} + + !open && setDetailsProvider(null)} + > + + {detailsProvider && ( + <> + + SSO provider details + + View-only. To change settings, remove this provider and add it + again with the new values. + + +
+
+ + Provider ID + +

+ {detailsProvider.providerId} +

+
+
+ + Issuer URL + +

+ {detailsProvider.issuer} +

+
+
+ + Domain + +

+ {detailsProvider.domain} +

+
+ {detailsProvider.oidcConfig && ( + <> + {(() => { + const oidc = parseOidcConfig( + detailsProvider.oidcConfig, + ); + if (!oidc) return null; + return ( + <> + {oidc.clientId && ( +
+ + Client ID + +

+ {oidc.clientId} +

+
+ )} + {oidc.scopes && oidc.scopes.length > 0 && ( +
+ + Scopes + +

+ {oidc.scopes.join(" ")} +

+
+ )} + + ); + })()} + + )} + {detailsProvider.samlConfig && ( + <> + {(() => { + const saml = parseSamlConfig( + detailsProvider.samlConfig, + ); + if (!saml?.entryPoint) return null; + return ( +
+ + Entry point + +

+ {saml.entryPoint} +

+
+ ); + })()} + + )} +
+ + Callback URL (configure in your IdP) + +

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

+

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

+
+
+ + + + + )} +
+
); } diff --git a/apps/dokploy/pages/index.tsx b/apps/dokploy/pages/index.tsx index 8127b41fd..51a34b908 100644 --- a/apps/dokploy/pages/index.tsx +++ b/apps/dokploy/pages/index.tsx @@ -3,6 +3,7 @@ import { validateRequest } from "@dokploy/server/lib/auth"; import { zodResolver } from "@hookform/resolvers/zod"; import { REGEXP_ONLY_DIGITS } from "input-otp"; import type { GetServerSidePropsContext } from "next"; +import { LogIn } from "lucide-react"; import Link from "next/link"; import { useRouter } from "next/router"; import { type ReactElement, useState } from "react"; @@ -37,6 +38,7 @@ import { } from "@/components/ui/input-otp"; import { Label } from "@/components/ui/label"; import { authClient } from "@/lib/auth-client"; +import { api } from "@/utils/api"; const LoginSchema = z.object({ email: z.string().email(), @@ -64,6 +66,10 @@ export default function Home({ IS_CLOUD }: Props) { const [backupCode, setBackupCode] = useState(""); const [isGithubLoading, setIsGithubLoading] = useState(false); const [isGoogleLoading, setIsGoogleLoading] = useState(false); + const [isSSOLoading, setIsSSOLoading] = useState(false); + const { data: ssoProviders } = api.sso.listLoginProviders.useQuery(undefined, { + enabled: !IS_CLOUD, + }); const loginForm = useForm({ resolver: zodResolver(LoginSchema), defaultValues: { @@ -200,6 +206,31 @@ export default function Home({ IS_CLOUD }: Props) { setIsGoogleLoading(false); } }; + + const handleSSOSignIn = async (providerId: string) => { + setIsSSOLoading(true); + try { + const { data, error } = await authClient.signIn.sso({ + providerId, + callbackURL: "/dashboard/projects", + }); + if (error) { + toast.error(error.message ?? "Failed to sign in with SSO"); + return; + } + if (data?.url) { + window.location.href = data.url; + return; + } + } catch (err) { + toast.error( + err instanceof Error ? err.message : "Failed to sign in with SSO", + ); + } finally { + setIsSSOLoading(false); + } + }; + return ( <>
@@ -267,6 +298,34 @@ export default function Home({ IS_CLOUD }: Props) { Sign in with Google )} + {!IS_CLOUD && + ssoProviders && + ssoProviders.length > 0 && ( +
+

+ Sign in with SSO +

+
+ {ssoProviders.map((provider) => ( + + ))} +
+
+ )}
{ + const providers = await db.query.ssoProvider.findMany({ + columns: { providerId: true, issuer: true }, + }); + return providers; + }), + listProviders: enterpriseProcedure.query(async ({ ctx }) => { const providers = await db.query.ssoProvider.findMany({ where: eq(ssoProvider.userId, ctx.user.id), diff --git a/packages/server/src/lib/auth.ts b/packages/server/src/lib/auth.ts index 5b3377aec..beeb79dfd 100644 --- a/packages/server/src/lib/auth.ts +++ b/packages/server/src/lib/auth.ts @@ -50,6 +50,7 @@ export const { handler, api } = betterAuth({ ? [ "http://localhost:3000", "https://absolutely-handy-falcon.ngrok-free.app", + "https://keycloak.vesperfit.com", ] : []), ]; @@ -110,11 +111,11 @@ export const { handler, api } = betterAuth({ const isAdminPresent = await db.query.member.findFirst({ where: eq(schema.member.role, "owner"), }); - if (isAdminPresent) { - throw new APIError("BAD_REQUEST", { - message: "Admin is already created", - }); - } + // if (isAdminPresent) { + // throw new APIError("BAD_REQUEST", { + // message: "Admin is already created", + // }); + // } } } }, @@ -154,27 +155,27 @@ export const { handler, api } = betterAuth({ } } - if (IS_CLOUD || !isAdminPresent) { - await db.transaction(async (tx) => { - const organization = await tx - .insert(schema.organization) - .values({ - name: "My Organization", - ownerId: user.id, - createdAt: new Date(), - }) - .returning() - .then((res) => res[0]); - - await tx.insert(schema.member).values({ - userId: user.id, - organizationId: organization?.id || "", - role: "owner", + // if (IS_CLOUD || !isAdminPresent) { + await db.transaction(async (tx) => { + const organization = await tx + .insert(schema.organization) + .values({ + name: "My Organization", + ownerId: user.id, createdAt: new Date(), - isDefault: true, // Mark first organization as default - }); + }) + .returning() + .then((res) => res[0]); + + await tx.insert(schema.member).values({ + userId: user.id, + organizationId: organization?.id || "", + role: "owner", + createdAt: new Date(), + isDefault: true, // Mark first organization as default }); - } + }); + // } }, }, },