Enhance SSO Functionality: Add detailed view for SSO providers in SSOSettings, including OIDC and SAML configuration parsing. Implement loading states for SSO sign-in on the homepage and expose a public API for listing SSO providers. Update UI components for better user experience and maintainability.

This commit is contained in:
Mauricio Siu
2026-01-30 20:35:17 -06:00
parent 61f6bbfe1c
commit 3c2f675eb9
6 changed files with 293 additions and 28 deletions

View File

@@ -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",
},
},
});

View File

@@ -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,
},
},
});

View File

@@ -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<ProviderForDetails | null>(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() {
</div>
</CardHeader>
<CardContent className="flex flex-wrap gap-2 pt-0">
<Button
variant="ghost"
size="sm"
onClick={() =>
setDetailsProvider({
id: provider.id,
providerId: provider.providerId,
issuer: provider.issuer,
domain: provider.domain,
oidcConfig: provider.oidcConfig,
samlConfig: provider.samlConfig,
organizationId: provider.organizationId,
})
}
>
<Eye className="mr-1 size-3" />
View details
</Button>
<DialogAction
title="Remove SSO provider"
description={`Remove provider "${provider.providerId}"? Users will no longer be able to sign in with this IdP.`}
@@ -167,6 +229,126 @@ export function SSOSettings() {
)}
</>
)}
<Dialog
open={!!detailsProvider}
onOpenChange={(open) => !open && setDetailsProvider(null)}
>
<DialogContent className="sm:max-w-[480px]">
{detailsProvider && (
<>
<DialogHeader>
<DialogTitle>SSO provider details</DialogTitle>
<DialogDescription>
View-only. To change settings, remove this provider and add it
again with the new values.
</DialogDescription>
</DialogHeader>
<div className="grid gap-3 py-2">
<div className="grid gap-1">
<span className="text-xs font-medium text-muted-foreground">
Provider ID
</span>
<p className="rounded-md bg-muted px-2 py-1.5 font-mono text-sm">
{detailsProvider.providerId}
</p>
</div>
<div className="grid gap-1">
<span className="text-xs font-medium text-muted-foreground">
Issuer URL
</span>
<p className="break-all rounded-md bg-muted px-2 py-1.5 text-sm">
{detailsProvider.issuer}
</p>
</div>
<div className="grid gap-1">
<span className="text-xs font-medium text-muted-foreground">
Domain
</span>
<p className="rounded-md bg-muted px-2 py-1.5 text-sm">
{detailsProvider.domain}
</p>
</div>
{detailsProvider.oidcConfig && (
<>
{(() => {
const oidc = parseOidcConfig(
detailsProvider.oidcConfig,
);
if (!oidc) return null;
return (
<>
{oidc.clientId && (
<div className="grid gap-1">
<span className="text-xs font-medium text-muted-foreground">
Client ID
</span>
<p className="rounded-md bg-muted px-2 py-1.5 font-mono text-sm">
{oidc.clientId}
</p>
</div>
)}
{oidc.scopes && oidc.scopes.length > 0 && (
<div className="grid gap-1">
<span className="text-xs font-medium text-muted-foreground">
Scopes
</span>
<p className="rounded-md bg-muted px-2 py-1.5 text-sm">
{oidc.scopes.join(" ")}
</p>
</div>
)}
</>
);
})()}
</>
)}
{detailsProvider.samlConfig && (
<>
{(() => {
const saml = parseSamlConfig(
detailsProvider.samlConfig,
);
if (!saml?.entryPoint) return null;
return (
<div className="grid gap-1">
<span className="text-xs font-medium text-muted-foreground">
Entry point
</span>
<p className="break-all rounded-md bg-muted px-2 py-1.5 text-sm">
{saml.entryPoint}
</p>
</div>
);
})()}
</>
)}
<div className="grid gap-1">
<span className="text-xs font-medium text-muted-foreground">
Callback URL (configure in your IdP)
</span>
<p className="break-all rounded-md bg-muted px-2 py-1.5 font-mono text-xs">
{"{baseURL}"}/api/auth/sso/callback/
{detailsProvider.providerId}
</p>
<p className="text-xs text-muted-foreground">
Replace {"{baseURL}"} with your Dokploy URL (e.g. https://
your-domain.com).
</p>
</div>
</div>
<DialogFooter>
<Button
variant="outline"
onClick={() => setDetailsProvider(null)}
>
Close
</Button>
</DialogFooter>
</>
)}
</DialogContent>
</Dialog>
</div>
);
}

View File

@@ -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<LoginForm>({
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 (
<>
<div className="flex flex-col space-y-2 text-center">
@@ -267,6 +298,34 @@ export default function Home({ IS_CLOUD }: Props) {
Sign in with Google
</Button>
)}
{!IS_CLOUD &&
ssoProviders &&
ssoProviders.length > 0 && (
<div className="mb-4 space-y-2">
<p className="text-center text-xs text-muted-foreground">
Sign in with SSO
</p>
<div className="flex flex-col gap-2">
{ssoProviders.map((provider) => (
<Button
key={provider.providerId}
variant="outline"
type="button"
className="w-full"
onClick={() =>
handleSSOSignIn(provider.providerId)
}
disabled={isSSOLoading}
>
<LogIn className="mr-2 size-4" />
Sign in with{" "}
{provider.providerId.charAt(0).toUpperCase() +
provider.providerId.slice(1)}
</Button>
))}
</div>
</div>
)}
<Form {...loginForm}>
<form
onSubmit={loginForm.handleSubmit(onSubmit)}

View File

@@ -2,10 +2,22 @@ import { ssoProvider } from "@dokploy/server/db/schema";
import { TRPCError } from "@trpc/server";
import { and, eq } from "drizzle-orm";
import { z } from "zod";
import { createTRPCRouter, enterpriseProcedure } from "@/server/api/trpc";
import {
createTRPCRouter,
enterpriseProcedure,
publicProcedure,
} from "@/server/api/trpc";
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 },
});
return providers;
}),
listProviders: enterpriseProcedure.query(async ({ ctx }) => {
const providers = await db.query.ssoProvider.findMany({
where: eq(ssoProvider.userId, ctx.user.id),

View File

@@ -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
});
}
});
// }
},
},
},