From f3d9960b7ffc2f4f4656d959351493a3e0f27f45 Mon Sep 17 00:00:00 2001 From: Mauricio Siu Date: Fri, 30 Jan 2026 22:28:17 -0600 Subject: [PATCH] Implement SSO Sign-In Options: Add components for signing in with GitHub, Google, and SSO, enhancing user authentication methods. Update SSO settings to conditionally render based on enterprise features and improve the overall login experience on the homepage. --- apps/dokploy/components/layouts/side.tsx | 6 +- .../proprietary/auth/sign-in-with-github.tsx | 47 ++++ .../proprietary/auth/sign-in-with-google.tsx | 59 +++++ .../proprietary/sso/sign-in-with-sso.tsx | 127 +++++++++ .../proprietary/sso/sso-settings.tsx | 12 +- apps/dokploy/pages/dashboard/settings/sso.tsx | 10 +- apps/dokploy/pages/index.tsx | 242 ++++-------------- .../api/routers/proprietary/license-key.ts | 1 - .../server/api/routers/proprietary/sso.ts | 35 ++- 9 files changed, 326 insertions(+), 213 deletions(-) create mode 100644 apps/dokploy/components/proprietary/auth/sign-in-with-github.tsx create mode 100644 apps/dokploy/components/proprietary/auth/sign-in-with-google.tsx create mode 100644 apps/dokploy/components/proprietary/sso/sign-in-with-sso.tsx diff --git a/apps/dokploy/components/layouts/side.tsx b/apps/dokploy/components/layouts/side.tsx index 1dd025fc5..204bf9695 100644 --- a/apps/dokploy/components/layouts/side.tsx +++ b/apps/dokploy/components/layouts/side.tsx @@ -412,9 +412,9 @@ const MENU: Menu = { title: "SSO", url: "/dashboard/settings/sso", icon: LogIn, - // Only enabled for admins in non-cloud environments (enterprise) - isEnabled: ({ auth, isCloud }) => - !!((auth?.role === "owner" || auth?.role === "admin") && !isCloud), + // Enabled for admins in both cloud and self-hosted (enterprise) + isEnabled: ({ auth }) => + !!(auth?.role === "owner" || auth?.role === "admin"), }, ], diff --git a/apps/dokploy/components/proprietary/auth/sign-in-with-github.tsx b/apps/dokploy/components/proprietary/auth/sign-in-with-github.tsx new file mode 100644 index 000000000..988eeae05 --- /dev/null +++ b/apps/dokploy/components/proprietary/auth/sign-in-with-github.tsx @@ -0,0 +1,47 @@ +"use client"; + +import { useState } from "react"; +import { toast } from "sonner"; +import { authClient } from "@/lib/auth-client"; +import { Button } from "@/components/ui/button"; + +export function SignInWithGithub() { + const [isLoading, setIsLoading] = useState(false); + + const handleClick = async () => { + setIsLoading(true); + try { + const { error } = await authClient.signIn.social({ + provider: "github", + }); + if (error) { + toast.error(error.message); + return; + } + } catch (err) { + toast.error("An error occurred while signing in with GitHub", { + description: err instanceof Error ? err.message : "Unknown error", + }); + } finally { + setIsLoading(false); + } + }; + + return ( + + ); +} diff --git a/apps/dokploy/components/proprietary/auth/sign-in-with-google.tsx b/apps/dokploy/components/proprietary/auth/sign-in-with-google.tsx new file mode 100644 index 000000000..bff0e69ab --- /dev/null +++ b/apps/dokploy/components/proprietary/auth/sign-in-with-google.tsx @@ -0,0 +1,59 @@ +"use client"; + +import { useState } from "react"; +import { toast } from "sonner"; +import { authClient } from "@/lib/auth-client"; +import { Button } from "@/components/ui/button"; + +export function SignInWithGoogle() { + const [isLoading, setIsLoading] = useState(false); + + const handleClick = async () => { + setIsLoading(true); + try { + const { error } = await authClient.signIn.social({ + provider: "google", + }); + if (error) { + toast.error(error.message); + return; + } + } catch (err) { + toast.error("An error occurred while signing in with Google", { + description: err instanceof Error ? err.message : "Unknown error", + }); + } finally { + setIsLoading(false); + } + }; + + return ( + + ); +} diff --git a/apps/dokploy/components/proprietary/sso/sign-in-with-sso.tsx b/apps/dokploy/components/proprietary/sso/sign-in-with-sso.tsx new file mode 100644 index 000000000..391329ed3 --- /dev/null +++ b/apps/dokploy/components/proprietary/sso/sign-in-with-sso.tsx @@ -0,0 +1,127 @@ +"use client"; + +import { zodResolver } from "@hookform/resolvers/zod"; +import { Loader2, LogIn } from "lucide-react"; +import { useState } from "react"; +import { useForm } from "react-hook-form"; +import { toast } from "sonner"; +import { z } from "zod"; +import { Button } from "@/components/ui/button"; +import { + Form, + FormControl, + FormField, + FormItem, + FormMessage, +} from "@/components/ui/form"; +import { Input } from "@/components/ui/input"; +import { authClient } from "@/lib/auth-client"; + +const ssoEmailSchema = z.object({ + email: z + .string() + .min(1, "Enter your work email") + .email("Enter a valid email address") + .transform((v) => v.trim()), +}); + +type SSOEmailForm = z.infer; + +interface SignInWithSSOProps { + /** Content shown when SSO is collapsed (e.g. email/password form) */ + children: React.ReactNode; +} + +export function SignInWithSSO({ children }: SignInWithSSOProps) { + const [expanded, setExpanded] = useState(false); + + const form = useForm({ + resolver: zodResolver(ssoEmailSchema), + defaultValues: { email: "" }, + }); + + const onSubmit = async (values: SSOEmailForm) => { + try { + const { data, error } = await authClient.signIn.sso({ + email: values.email, + callbackURL: "/dashboard/projects", + }); + if (error) { + toast.error(error.message ?? "Failed to sign in with SSO"); + return; + } + if (data?.url) { + window.location.href = data.url; + } + } catch (err) { + toast.error( + err instanceof Error ? err.message : "Failed to sign in with SSO", + ); + } + }; + + if (!expanded) { + return ( +
+ + {children} +
+ ); + } + + return ( +
+
+ + ( + + +
+ + +
+
+ +
+ )} + /> + + + +
+ ); +} diff --git a/apps/dokploy/components/proprietary/sso/sso-settings.tsx b/apps/dokploy/components/proprietary/sso/sso-settings.tsx index 850f65e88..8a4348c8f 100644 --- a/apps/dokploy/components/proprietary/sso/sso-settings.tsx +++ b/apps/dokploy/components/proprietary/sso/sso-settings.tsx @@ -1,6 +1,6 @@ "use client"; -import { Loader2, LogIn, Trash2, Eye } from "lucide-react"; +import { Eye, Loader2, LogIn, Trash2 } from "lucide-react"; import { useState } from "react"; import { toast } from "sonner"; import { DialogAction } from "@/components/shared/dialog-action"; @@ -63,10 +63,11 @@ function parseSamlConfig( } } -export function SSOSettings() { +export const 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(); @@ -119,7 +120,10 @@ export function SSOSettings() { const isSaml = !!provider.samlConfig; return ( - +
@@ -352,4 +356,4 @@ export function SSOSettings() {
); -} +}; diff --git a/apps/dokploy/pages/dashboard/settings/sso.tsx b/apps/dokploy/pages/dashboard/settings/sso.tsx index c0acedabb..164e2c3da 100644 --- a/apps/dokploy/pages/dashboard/settings/sso.tsx +++ b/apps/dokploy/pages/dashboard/settings/sso.tsx @@ -1,4 +1,4 @@ -import { IS_CLOUD, validateRequest } from "@dokploy/server"; +import { validateRequest } from "@dokploy/server"; import { createServerSideHelpers } from "@trpc/react-query/server"; import type { GetServerSidePropsContext } from "next"; import type { ReactElement } from "react"; @@ -44,14 +44,6 @@ Page.getLayout = (page: ReactElement) => { export async function getServerSideProps(ctx: GetServerSidePropsContext) { const { req, res } = ctx; const locale = await getLocale(req.cookies); - if (IS_CLOUD) { - return { - redirect: { - permanent: true, - destination: "/dashboard/projects", - }, - }; - } const { user, session } = await validateRequest(ctx.req); if (!user) { return { diff --git a/apps/dokploy/pages/index.tsx b/apps/dokploy/pages/index.tsx index c2d5df077..de4294581 100644 --- a/apps/dokploy/pages/index.tsx +++ b/apps/dokploy/pages/index.tsx @@ -3,7 +3,6 @@ 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"; @@ -11,6 +10,9 @@ import { useForm } from "react-hook-form"; import { toast } from "sonner"; import { z } from "zod"; import { OnboardingLayout } from "@/components/layouts/onboarding-layout"; +import { SignInWithGithub } from "@/components/proprietary/auth/sign-in-with-github"; +import { SignInWithGoogle } from "@/components/proprietary/auth/sign-in-with-google"; +import { SignInWithSSO } from "@/components/proprietary/sso/sign-in-with-sso"; import { AlertBlock } from "@/components/shared/alert-block"; import { Logo } from "@/components/shared/logo"; import { Button } from "@/components/ui/button"; @@ -56,6 +58,7 @@ interface Props { } export default function Home({ IS_CLOUD }: Props) { const router = useRouter(); + const { data: showSignInWithSSO } = api.sso.showSignInWithSSO.useQuery(); const [isLoginLoading, setIsLoginLoading] = useState(false); const [isTwoFactorLoading, setIsTwoFactorLoading] = useState(false); const [isBackupCodeLoading, setIsBackupCodeLoading] = useState(false); @@ -64,15 +67,6 @@ export default function Home({ IS_CLOUD }: Props) { const [twoFactorCode, setTwoFactorCode] = useState(""); const [isBackupCodeModalOpen, setIsBackupCodeModalOpen] = useState(false); 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: { @@ -170,69 +164,53 @@ export default function Home({ IS_CLOUD }: Props) { } }; - const handleGithubSignIn = async () => { - setIsGithubLoading(true); - try { - const { error } = await authClient.signIn.social({ - provider: "github", - }); - - if (error) { - toast.error(error.message); - return; - } - } catch (error) { - toast.error("An error occurred while signing in with GitHub", { - description: error instanceof Error ? error.message : "Unknown error", - }); - } finally { - setIsGithubLoading(false); - } - }; - - const handleGoogleSignIn = async () => { - setIsGoogleLoading(true); - try { - const { error } = await authClient.signIn.social({ - provider: "google", - }); - - if (error) { - toast.error(error.message); - return; - } - } catch (error) { - toast.error("An error occurred while signing in with Google", { - description: error instanceof Error ? error.message : "Unknown error", - }); - } finally { - 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); - } - }; + const loginContent = ( + <> + {IS_CLOUD && } + {IS_CLOUD && } +
+ + ( + + Email + + + + + + )} + /> + ( + + Password + + + + + + )} + /> + + + + + ); return ( <> @@ -255,121 +233,11 @@ export default function Home({ IS_CLOUD }: Props) { {!isTwoFactor ? ( <> - {IS_CLOUD && ( - + {showSignInWithSSO ? ( + {loginContent} + ) : ( + loginContent )} - {IS_CLOUD && ( - - )} - {!IS_CLOUD && ssoProviders && ssoProviders.length > 0 && ( -
-

- Sign in with SSO -

-
- {ssoProviders.map((provider) => ( - - ))} -
-
- )} -
- - ( - - Email - - - - - - )} - /> - ( - - Password - - - - - - )} - /> - - - ) : ( <> diff --git a/apps/dokploy/server/api/routers/proprietary/license-key.ts b/apps/dokploy/server/api/routers/proprietary/license-key.ts index d337b6cab..53816540c 100644 --- a/apps/dokploy/server/api/routers/proprietary/license-key.ts +++ b/apps/dokploy/server/api/routers/proprietary/license-key.ts @@ -168,7 +168,6 @@ export const licenseKeyRouter = createTRPCRouter({ currentUser?.isValidEnterpriseLicense ); }), - updateEnterpriseSettings: adminProcedure .input( z.object({ diff --git a/apps/dokploy/server/api/routers/proprietary/sso.ts b/apps/dokploy/server/api/routers/proprietary/sso.ts index dae7fe8d2..08483d96d 100644 --- a/apps/dokploy/server/api/routers/proprietary/sso.ts +++ b/apps/dokploy/server/api/routers/proprietary/sso.ts @@ -1,6 +1,7 @@ -import { ssoProvider } from "@dokploy/server/db/schema"; +import { IS_CLOUD } from "@dokploy/server/constants"; +import { member, ssoProvider } from "@dokploy/server/db/schema"; import { TRPCError } from "@trpc/server"; -import { and, eq } from "drizzle-orm"; +import { and, asc, eq } from "drizzle-orm"; import { z } from "zod"; import { createTRPCRouter, @@ -10,14 +11,31 @@ import { import { db } from "@/server/db"; export const ssoRouter = createTRPCRouter({ - /** Public list of SSO providers for the login page (providerId + issuer only). */ - listLoginProviders: publicProcedure.query(async () => { - const providers = await db.query.ssoProvider.findMany({ - columns: { providerId: true, issuer: true }, + showSignInWithSSO: publicProcedure.query(async () => { + if (IS_CLOUD) { + return true; + } + const owner = await db.query.member.findFirst({ + where: eq(member.role, "owner"), + with: { + user: { + columns: { + enableEnterpriseFeatures: true, + isValidEnterpriseLicense: true, + }, + }, + }, + orderBy: [asc(member.createdAt)], }); - return providers; - }), + if (!owner) { + return false; + } + + return ( + owner.user.enableEnterpriseFeatures && owner.user.isValidEnterpriseLicense + ); + }), listProviders: enterpriseProcedure.query(async ({ ctx }) => { const providers = await db.query.ssoProvider.findMany({ where: eq(ssoProvider.userId, ctx.user.id), @@ -33,7 +51,6 @@ export const ssoRouter = createTRPCRouter({ }); return providers; }), - deleteProvider: enterpriseProcedure .input(z.object({ providerId: z.string().min(1) })) .mutation(async ({ ctx, input }) => {