mirror of
https://github.com/Dokploy/dokploy.git
synced 2026-06-19 14:15:21 +02:00
Implement SAML Provider Registration and Enhance OIDC Dialog: Add a new SAML provider registration dialog with form validation using Zod, integrate it into the SSO settings page, and refactor the OIDC registration dialog to utilize React Hook Form for improved state management and validation.
This commit is contained in:
@@ -1,8 +1,11 @@
|
||||
"use client";
|
||||
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { Loader2 } from "lucide-react";
|
||||
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 { Button } from "@/components/ui/button";
|
||||
import {
|
||||
@@ -14,55 +17,67 @@ import {
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@/components/ui/form";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
|
||||
const DEFAULT_SCOPES = ["openid", "email", "profile"];
|
||||
|
||||
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(),
|
||||
clientId: z.string().min(1, "Client ID is required").trim(),
|
||||
clientSecret: z.string().min(1, "Client secret is required"),
|
||||
scopes: z.string().optional(),
|
||||
});
|
||||
|
||||
type OidcProviderForm = z.infer<typeof oidcProviderSchema>;
|
||||
|
||||
interface RegisterOidcDialogProps {
|
||||
children: React.ReactNode;
|
||||
onSuccess?: () => void;
|
||||
}
|
||||
|
||||
const formDefaultValues: OidcProviderForm = {
|
||||
providerId: "",
|
||||
issuer: "",
|
||||
domain: "",
|
||||
clientId: "",
|
||||
clientSecret: "",
|
||||
scopes: DEFAULT_SCOPES.join(" "),
|
||||
};
|
||||
|
||||
export function RegisterOidcDialog({ children, onSuccess }: RegisterOidcDialogProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [form, setForm] = useState({
|
||||
providerId: "",
|
||||
issuer: "",
|
||||
domain: "",
|
||||
clientId: "",
|
||||
clientSecret: "",
|
||||
scopes: DEFAULT_SCOPES.join(" "),
|
||||
|
||||
const form = useForm<OidcProviderForm>({
|
||||
resolver: zodResolver(oidcProviderSchema),
|
||||
defaultValues: formDefaultValues,
|
||||
});
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (
|
||||
!form.providerId.trim() ||
|
||||
!form.issuer.trim() ||
|
||||
!form.domain.trim() ||
|
||||
!form.clientId.trim() ||
|
||||
!form.clientSecret.trim()
|
||||
) {
|
||||
toast.error("Please fill in all required fields");
|
||||
return;
|
||||
}
|
||||
const isSubmitting = form.formState.isSubmitting;
|
||||
|
||||
setIsSubmitting(true);
|
||||
const onSubmit = async (data: OidcProviderForm) => {
|
||||
try {
|
||||
const scopes = form.scopes
|
||||
.trim()
|
||||
.split(/\s+/)
|
||||
.filter(Boolean);
|
||||
const { data, error } = await authClient.sso.register({
|
||||
providerId: form.providerId.trim(),
|
||||
issuer: form.issuer.trim(),
|
||||
domain: form.domain.trim(),
|
||||
const scopes = data.scopes?.trim()
|
||||
? data.scopes.trim().split(/\s+/).filter(Boolean)
|
||||
: DEFAULT_SCOPES;
|
||||
const { error } = await authClient.sso.register({
|
||||
providerId: data.providerId,
|
||||
issuer: data.issuer,
|
||||
domain: data.domain,
|
||||
oidcConfig: {
|
||||
clientId: form.clientId.trim(),
|
||||
clientSecret: form.clientSecret.trim(),
|
||||
scopes: scopes.length > 0 ? scopes : DEFAULT_SCOPES,
|
||||
clientId: data.clientId,
|
||||
clientSecret: data.clientSecret,
|
||||
scopes,
|
||||
pkce: true,
|
||||
},
|
||||
});
|
||||
@@ -73,22 +88,13 @@ export function RegisterOidcDialog({ children, onSuccess }: RegisterOidcDialogPr
|
||||
}
|
||||
|
||||
toast.success("OIDC provider registered successfully");
|
||||
setForm({
|
||||
providerId: "",
|
||||
issuer: "",
|
||||
domain: "",
|
||||
clientId: "",
|
||||
clientSecret: "",
|
||||
scopes: DEFAULT_SCOPES.join(" "),
|
||||
});
|
||||
form.reset(formDefaultValues);
|
||||
setOpen(false);
|
||||
onSuccess?.();
|
||||
} catch (err) {
|
||||
toast.error(
|
||||
err instanceof Error ? err.message : "Failed to register SSO provider",
|
||||
);
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -99,107 +105,136 @@ export function RegisterOidcDialog({ children, onSuccess }: RegisterOidcDialogPr
|
||||
<DialogHeader>
|
||||
<DialogTitle>Register OIDC provider</DialogTitle>
|
||||
<DialogDescription>
|
||||
Add an OpenID Connect (OIDC) identity provider. Discovery will
|
||||
fill endpoints from the issuer URL when possible.
|
||||
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.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="providerId">Provider ID</Label>
|
||||
<Input
|
||||
id="providerId"
|
||||
placeholder="e.g. okta or my-idp"
|
||||
value={form.providerId}
|
||||
onChange={(e) =>
|
||||
setForm((f) => ({ ...f, providerId: e.target.value }))
|
||||
}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Unique identifier; used in callback URL path.
|
||||
</p>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="issuer">Issuer URL</Label>
|
||||
<Input
|
||||
id="issuer"
|
||||
placeholder="https://idp.example.com"
|
||||
value={form.issuer}
|
||||
onChange={(e) =>
|
||||
setForm((f) => ({ ...f, issuer: e.target.value }))
|
||||
}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Discovery document is fetched from{" "}
|
||||
<code className="rounded bg-muted px-1">
|
||||
{"{issuer}"}/.well-known/openid-configuration
|
||||
</code>
|
||||
</p>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="domain">Domain</Label>
|
||||
<Input
|
||||
id="domain"
|
||||
placeholder="example.com"
|
||||
value={form.domain}
|
||||
onChange={(e) =>
|
||||
setForm((f) => ({ ...f, domain: e.target.value }))
|
||||
}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Email domain(s) that use this provider (e.g. for sign-in by email).
|
||||
</p>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="clientId">Client ID</Label>
|
||||
<Input
|
||||
id="clientId"
|
||||
placeholder="Client ID from IdP"
|
||||
value={form.clientId}
|
||||
onChange={(e) =>
|
||||
setForm((f) => ({ ...f, clientId: e.target.value }))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="clientSecret">Client secret</Label>
|
||||
<Input
|
||||
id="clientSecret"
|
||||
type="password"
|
||||
placeholder="Client secret from IdP"
|
||||
value={form.clientSecret}
|
||||
onChange={(e) =>
|
||||
setForm((f) => ({ ...f, clientSecret: e.target.value }))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="scopes">Scopes (optional)</Label>
|
||||
<Input
|
||||
id="scopes"
|
||||
placeholder="openid email profile"
|
||||
value={form.scopes}
|
||||
onChange={(e) =>
|
||||
setForm((f) => ({ ...f, scopes: e.target.value }))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => setOpen(false)}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" disabled={isSubmitting}>
|
||||
{isSubmitting && (
|
||||
<Loader2 className="mr-2 size-4 animate-spin" />
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="providerId"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Provider ID</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder="e.g. okta or my-idp"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
Unique identifier; used in callback URL path.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
Register provider
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="issuer"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Issuer URL</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder="https://idp.example.com"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
Discovery document is fetched from{" "}
|
||||
<code className="rounded bg-muted px-1">
|
||||
{"{issuer}"}/.well-known/openid-configuration
|
||||
</code>
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="domain"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Domain</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="example.com" {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
Email domain(s) that use this provider (e.g. for sign-in by
|
||||
email).
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="clientId"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Client ID</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="Client ID from IdP" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="clientSecret"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Client secret</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="password"
|
||||
placeholder="Client secret from IdP"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="scopes"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Scopes (optional)</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder="openid email profile"
|
||||
{...field}
|
||||
value={field.value ?? ""}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => setOpen(false)}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" disabled={isSubmitting}>
|
||||
{isSubmitting && (
|
||||
<Loader2 className="mr-2 size-4 animate-spin" />
|
||||
)}
|
||||
Register provider
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</Form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
|
||||
262
apps/dokploy/components/proprietary/sso/register-saml-dialog.tsx
Normal file
262
apps/dokploy/components/proprietary/sso/register-saml-dialog.tsx
Normal file
@@ -0,0 +1,262 @@
|
||||
"use client";
|
||||
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { Loader2 } from "lucide-react";
|
||||
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 { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@/components/ui/form";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
|
||||
const samlProviderSchema = 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(),
|
||||
entryPoint: z
|
||||
.string()
|
||||
.min(1, "IdP SSO URL is required")
|
||||
.url("Invalid URL")
|
||||
.trim(),
|
||||
cert: z.string().min(1, "IdP signing certificate is required"),
|
||||
callbackUrl: z
|
||||
.string()
|
||||
.min(1, "Callback URL is required")
|
||||
.url("Invalid URL")
|
||||
.trim(),
|
||||
audience: z.string().min(1, "Audience (Entity ID) is required").trim(),
|
||||
});
|
||||
|
||||
type SamlProviderForm = z.infer<typeof samlProviderSchema>;
|
||||
|
||||
interface RegisterSamlDialogProps {
|
||||
children: React.ReactNode;
|
||||
onSuccess?: () => void;
|
||||
}
|
||||
|
||||
const formDefaultValues: SamlProviderForm = {
|
||||
providerId: "",
|
||||
issuer: "",
|
||||
domain: "",
|
||||
entryPoint: "",
|
||||
cert: "",
|
||||
callbackUrl: "",
|
||||
audience: "",
|
||||
};
|
||||
|
||||
export function RegisterSamlDialog({ children, onSuccess }: RegisterSamlDialogProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
const form = useForm<SamlProviderForm>({
|
||||
resolver: zodResolver(samlProviderSchema),
|
||||
defaultValues: formDefaultValues,
|
||||
});
|
||||
|
||||
const isSubmitting = form.formState.isSubmitting;
|
||||
|
||||
const onSubmit = async (data: SamlProviderForm) => {
|
||||
try {
|
||||
const { error } = await authClient.sso.register({
|
||||
providerId: data.providerId,
|
||||
issuer: data.issuer,
|
||||
domain: data.domain,
|
||||
samlConfig: {
|
||||
entryPoint: data.entryPoint,
|
||||
cert: data.cert,
|
||||
callbackUrl: data.callbackUrl,
|
||||
audience: data.audience,
|
||||
wantAssertionsSigned: true,
|
||||
signatureAlgorithm: "sha256",
|
||||
digestAlgorithm: "sha256",
|
||||
},
|
||||
});
|
||||
|
||||
if (error) {
|
||||
toast.error(error.message ?? "Failed to register SAML provider");
|
||||
return;
|
||||
}
|
||||
|
||||
toast.success("SAML provider registered successfully");
|
||||
form.reset(formDefaultValues);
|
||||
setOpen(false);
|
||||
onSuccess?.();
|
||||
} catch (err) {
|
||||
toast.error(
|
||||
err instanceof Error ? err.message : "Failed to register SAML provider",
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger asChild>{children}</DialogTrigger>
|
||||
<DialogContent className="sm:max-w-[500px] max-h-[90vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Register SAML provider</DialogTitle>
|
||||
<DialogDescription>
|
||||
Add a SAML 2.0 identity provider (e.g. Okta SAML, Azure AD SAML,
|
||||
OneLogin). You need the IdP's SSO URL and signing certificate.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="providerId"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Provider ID</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder="e.g. okta-saml or azure-saml"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="issuer"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Issuer URL</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder="https://idp.example.com"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="domain"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Domain</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="example.com" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="entryPoint"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>IdP SSO URL (Entry point)</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder="https://idp.example.com/sso"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
Single Sign-On URL from your IdP's SAML setup.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="cert"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>IdP signing certificate (X.509)</FormLabel>
|
||||
<FormControl>
|
||||
<Textarea
|
||||
placeholder="Paste IdP signing certificate (PEM, BEGIN CERTIFICATE / END CERTIFICATE)"
|
||||
rows={4}
|
||||
className="font-mono text-xs"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="callbackUrl"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Callback URL (ACS)</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder="https://yourapp.com/api/auth/sso/saml2/callback/my-provider"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
Use the callback URL shown in your IdP app config for this
|
||||
provider.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="audience"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Audience (Entity ID)</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder="https://yourapp.com"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => setOpen(false)}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" disabled={isSubmitting}>
|
||||
{isSubmitting && (
|
||||
<Loader2 className="mr-2 size-4 animate-spin" />
|
||||
)}
|
||||
Register provider
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</Form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { Loader2, LogIn, ShieldCheck, Trash2 } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { Loader2, LogIn, Trash2 } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { DialogAction } from "@/components/shared/dialog-action";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
@@ -13,9 +12,9 @@ import {
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { authClient } from "@/lib/auth-client";
|
||||
import { api } from "@/utils/api";
|
||||
import { RegisterOidcDialog } from "./register-oidc-dialog";
|
||||
import { RegisterSamlDialog } from "./register-saml-dialog";
|
||||
|
||||
export function SSOSettings() {
|
||||
const utils = api.useUtils();
|
||||
@@ -23,57 +22,6 @@ export function SSOSettings() {
|
||||
const { mutateAsync: deleteProvider, isLoading: isDeleting } =
|
||||
api.sso.deleteProvider.useMutation();
|
||||
|
||||
const [verifyingId, setVerifyingId] = useState<string | null>(null);
|
||||
const [requestingVerificationId, setRequestingVerificationId] = useState<
|
||||
string | null
|
||||
>(null);
|
||||
|
||||
const handleVerifyDomain = async (providerId: string) => {
|
||||
setVerifyingId(providerId);
|
||||
try {
|
||||
const { data, error } = await authClient.sso.verifyDomain({
|
||||
providerId,
|
||||
});
|
||||
if (error) {
|
||||
toast.error(error.message ?? "Domain verification failed");
|
||||
return;
|
||||
}
|
||||
toast.success("Domain verified successfully");
|
||||
await utils.sso.listProviders.invalidate();
|
||||
} catch (err) {
|
||||
toast.error(
|
||||
err instanceof Error ? err.message : "Domain verification failed",
|
||||
);
|
||||
} finally {
|
||||
setVerifyingId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRequestDomainVerification = async (providerId: string) => {
|
||||
setRequestingVerificationId(providerId);
|
||||
try {
|
||||
const { data, error } = await authClient.sso.requestDomainVerification({
|
||||
providerId,
|
||||
});
|
||||
if (error) {
|
||||
toast.error(error.message ?? "Failed to request domain verification");
|
||||
return;
|
||||
}
|
||||
toast.success(
|
||||
"Verification token created. Add the TXT DNS record and verify.",
|
||||
);
|
||||
await utils.sso.listProviders.invalidate();
|
||||
} catch (err) {
|
||||
toast.error(
|
||||
err instanceof Error
|
||||
? err.message
|
||||
: "Failed to request domain verification",
|
||||
);
|
||||
} finally {
|
||||
setRequestingVerificationId(null);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4 rounded-lg border p-4">
|
||||
<div className="flex flex-col gap-2">
|
||||
@@ -106,9 +54,14 @@ export function SSOSettings() {
|
||||
Add OIDC provider
|
||||
</Button>
|
||||
</RegisterOidcDialog>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
SAML support can be added via API or future UI.
|
||||
</span>
|
||||
<RegisterSamlDialog
|
||||
onSuccess={() => utils.sso.listProviders.invalidate()}
|
||||
>
|
||||
<Button variant="secondary" size="sm">
|
||||
<LogIn className="mr-2 size-4" />
|
||||
Add SAML provider
|
||||
</Button>
|
||||
</RegisterSamlDialog>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -119,7 +72,6 @@ export function SSOSettings() {
|
||||
{providers.map((provider) => {
|
||||
const isOidc = !!provider.oidcConfig;
|
||||
const isSaml = !!provider.samlConfig;
|
||||
const verified = !!provider.domainVerified;
|
||||
|
||||
return (
|
||||
<Card key={provider.id} className="overflow-hidden">
|
||||
@@ -146,56 +98,11 @@ export function SSOSettings() {
|
||||
SAML
|
||||
</Badge>
|
||||
)}
|
||||
{verified && (
|
||||
<Badge
|
||||
variant="default"
|
||||
className="text-xs bg-green-600"
|
||||
>
|
||||
Verified
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-wrap gap-2 pt-0">
|
||||
{!verified && (
|
||||
<>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={verifyingId === provider.providerId}
|
||||
onClick={() =>
|
||||
handleVerifyDomain(provider.providerId)
|
||||
}
|
||||
>
|
||||
{verifyingId === provider.providerId ? (
|
||||
<Loader2 className="mr-1 size-3 animate-spin" />
|
||||
) : (
|
||||
<ShieldCheck className="mr-1 size-3" />
|
||||
)}
|
||||
Verify domain
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
disabled={
|
||||
requestingVerificationId === provider.providerId
|
||||
}
|
||||
onClick={() =>
|
||||
handleRequestDomainVerification(
|
||||
provider.providerId,
|
||||
)
|
||||
}
|
||||
>
|
||||
{requestingVerificationId ===
|
||||
provider.providerId ? (
|
||||
<Loader2 className="mr-1 size-3 animate-spin" />
|
||||
) : null}
|
||||
New verification token
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
<DialogAction
|
||||
title="Remove SSO provider"
|
||||
description={`Remove provider "${provider.providerId}"? Users will no longer be able to sign in with this IdP.`}
|
||||
@@ -241,19 +148,29 @@ export function SSOSettings() {
|
||||
<div className="space-y-1">
|
||||
<h3 className="text-lg font-semibold">No SSO providers</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Add an OIDC provider to allow users to sign in with their
|
||||
organization's identity provider (e.g. Okta, Azure AD).
|
||||
Add an OIDC or SAML provider so users can sign in with their
|
||||
organization's IdP (e.g. Okta, Azure AD).
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<RegisterOidcDialog
|
||||
onSuccess={() => utils.sso.listProviders.invalidate()}
|
||||
>
|
||||
<Button variant="secondary">
|
||||
<LogIn className="mr-2 size-4" />
|
||||
Add OIDC provider
|
||||
</Button>
|
||||
</RegisterOidcDialog>
|
||||
<div className="flex flex-wrap gap-2 justify-center">
|
||||
<RegisterOidcDialog
|
||||
onSuccess={() => utils.sso.listProviders.invalidate()}
|
||||
>
|
||||
<Button variant="secondary">
|
||||
<LogIn className="mr-2 size-4" />
|
||||
Add OIDC provider
|
||||
</Button>
|
||||
</RegisterOidcDialog>
|
||||
<RegisterSamlDialog
|
||||
onSuccess={() => utils.sso.listProviders.invalidate()}
|
||||
>
|
||||
<Button variant="outline">
|
||||
<LogIn className="mr-2 size-4" />
|
||||
Add SAML provider
|
||||
</Button>
|
||||
</RegisterSamlDialog>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
|
||||
Reference in New Issue
Block a user