feat(sso): enhance SAML provider registration and editing experience

- Added support for editing existing SAML providers, allowing users to update issuer, domains, entry point, and certificate.
- Introduced a new function to parse SAML configuration from JSON.
- Updated the UI to reflect changes in the registration dialog based on whether the user is adding or editing a provider.
- Improved user feedback with success messages tailored for registration and updates.
- Added a new column `created_at` to the `sso_provider` table for better tracking of provider creation times.
This commit is contained in:
Mauricio Siu
2026-02-12 23:49:27 -06:00
parent 8291c6d835
commit 60f5ab304a
7 changed files with 7387 additions and 10 deletions

View File

@@ -58,6 +58,7 @@ const samlProviderSchema = z.object({
type SamlProviderForm = z.infer<typeof samlProviderSchema>;
interface RegisterSamlDialogProps {
providerId?: string;
children: React.ReactNode;
}
@@ -70,10 +71,45 @@ const formDefaultValues: SamlProviderForm = {
idpMetadataXml: "",
};
export function RegisterSamlDialog({ children }: RegisterSamlDialogProps) {
function parseSamlConfig(samlConfig: string | null): {
entryPoint?: string;
cert?: string;
idpMetadataXml?: string;
} | null {
if (!samlConfig) return null;
try {
const parsed = JSON.parse(samlConfig) as {
entryPoint?: string;
cert?: string;
idpMetadata?: { metadata?: string };
};
return {
entryPoint: parsed.entryPoint,
cert: parsed.cert,
idpMetadataXml: parsed.idpMetadata?.metadata,
};
} catch {
return null;
}
}
export function RegisterSamlDialog({
providerId,
children,
}: RegisterSamlDialogProps) {
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 [baseURL, setBaseURL] = useState("");
@@ -88,6 +124,23 @@ export function RegisterSamlDialog({ children }: RegisterSamlDialogProps) {
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 saml = parseSamlConfig(data.samlConfig);
form.reset({
providerId: data.providerId,
issuer: data.issuer,
domains,
entryPoint: saml?.entryPoint ?? "",
cert: saml?.cert ?? "",
idpMetadataXml: saml?.idpMetadataXml ?? "",
});
}, [data, open, form]);
const { fields, append, remove } = useFieldArray({
control: form.control,
name: "domains" as FieldArrayPath<SamlProviderForm>,
@@ -133,7 +186,11 @@ export function RegisterSamlDialog({ children }: RegisterSamlDialogProps) {
},
});
toast.success("SAML provider registered successfully");
toast.success(
isEdit
? "SAML provider updated successfully"
: "SAML provider registered successfully",
);
form.reset(formDefaultValues);
setOpen(false);
await utils.sso.listProviders.invalidate();
@@ -149,10 +206,13 @@ export function RegisterSamlDialog({ children }: RegisterSamlDialogProps) {
<DialogTrigger asChild>{children}</DialogTrigger>
<DialogContent className="sm:max-w-[500px] max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>Register SAML provider</DialogTitle>
<DialogTitle>
{isEdit ? "Update SAML provider" : "Register SAML provider"}
</DialogTitle>
<DialogDescription>
Add a SAML 2.0 identity provider (e.g. Okta SAML, Azure AD SAML,
OneLogin). You need the IdP&apos;s SSO URL and signing certificate.
{isEdit
? "Change issuer, domains, entry point or certificate. Provider ID cannot be changed."
: "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}>
@@ -167,8 +227,15 @@ export function RegisterSamlDialog({ children }: RegisterSamlDialogProps) {
<Input
placeholder="e.g. okta-saml or azure-saml"
{...field}
readOnly={isEdit}
className={isEdit ? "bg-muted" : undefined}
/>
</FormControl>
{isEdit && (
<FormDescription>
Cannot be changed when editing.
</FormDescription>
)}
<FormMessage />
</FormItem>
)}
@@ -317,7 +384,7 @@ export function RegisterSamlDialog({ children }: RegisterSamlDialogProps) {
Cancel
</Button>
<Button type="submit" isLoading={isLoading}>
Register provider
{isEdit ? "Update provider" : "Register provider"}
</Button>
</DialogFooter>
</form>

View File

@@ -281,6 +281,16 @@ export const SSOSettings = () => {
</Button>
</RegisterOidcDialog>
)}
{isSaml && (
<RegisterSamlDialog
providerId={provider.providerId}
>
<Button variant="ghost" size="sm">
<Pencil className="mr-1 size-3" />
Edit
</Button>
</RegisterSamlDialog>
)}
<DialogAction
title="Remove SSO provider"
description={`Remove provider "${provider.providerId}"? Users will no longer be able to sign in with this IdP.`}
@@ -360,8 +370,7 @@ export const SSOSettings = () => {
<DialogHeader>
<DialogTitle>SSO provider details</DialogTitle>
<DialogDescription>
OIDC providers can be updated via Edit. SAML providers must be
removed and re-added to change settings.
Use Edit to change provider settings (OIDC or SAML).
</DialogDescription>
</DialogHeader>
<div className="grid gap-3 py-2">

View File

@@ -0,0 +1 @@
ALTER TABLE "sso_provider" ADD COLUMN "created_at" timestamp DEFAULT now() NOT NULL;

File diff suppressed because it is too large Load Diff

View File

@@ -1002,6 +1002,13 @@
"when": 1770615019498,
"tag": "0142_outstanding_tusk",
"breakpoints": true
},
{
"idx": 143,
"version": "7",
"when": 1770961667210,
"tag": "0143_brown_ultron",
"breakpoints": true
}
]
}

View File

@@ -55,6 +55,7 @@ export const ssoRouter = createTRPCRouter({
samlConfig: true,
organizationId: true,
},
orderBy: [asc(ssoProvider.createdAt)],
});
return providers;
}),

View File

@@ -1,5 +1,5 @@
import { relations } from "drizzle-orm";
import { pgTable, text } from "drizzle-orm/pg-core";
import { pgTable, text, timestamp } from "drizzle-orm/pg-core";
import { z } from "zod";
import { organization } from "./account";
import { user } from "./user";
@@ -15,6 +15,7 @@ export const ssoProvider = pgTable("sso_provider", {
onDelete: "cascade",
}),
domain: text("domain").notNull(),
createdAt: timestamp("created_at").notNull().defaultNow(),
});
export const ssoProviderRelations = relations(ssoProvider, ({ one }) => ({