refactor: own page for sso registration

This commit is contained in:
Nicolas Meienberger 2026-02-22 20:41:47 +01:00
parent 4619d9d976
commit 0bf0094dbd
4 changed files with 313 additions and 256 deletions

View file

@ -1,9 +1,7 @@
import { arktypeResolver } from "@hookform/resolvers/arktype";
import { useMutation, useSuspenseQuery } from "@tanstack/react-query";
import { type } from "arktype";
import { AlertTriangle, Ban, Trash2 } from "lucide-react";
import { useNavigate } from "@tanstack/react-router";
import { Ban, Trash2 } from "lucide-react";
import { useState } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import {
deleteSsoInvitationMutation,
@ -22,69 +20,28 @@ import {
AlertDialogTitle,
AlertDialogTrigger,
} from "~/client/components/ui/alert-dialog";
import { Alert, AlertDescription, AlertTitle } from "~/client/components/ui/alert";
import { Alert, AlertDescription } from "~/client/components/ui/alert";
import { Button } from "~/client/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "~/client/components/ui/dialog";
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "~/client/components/ui/form";
import { Input } from "~/client/components/ui/input";
import { Label } from "~/client/components/ui/label";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "~/client/components/ui/select";
import { Switch } from "~/client/components/ui/switch";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "~/client/components/ui/table";
import { authClient } from "~/client/lib/auth-client";
import { parseError } from "~/client/lib/errors";
import { useOrganizationContext } from "~/client/hooks/use-org-context";
import { formatDateWithMonth } from "~/client/lib/datetime";
import { getOrigin } from "~/client/functions/get-origin";
import { authClient } from "~/client/lib/auth-client";
const ssoProviderSchema = type({
providerId: "string>=1",
issuer: "string>=1",
domain: "string>=1",
clientId: "string>=1",
clientSecret: "string>=1",
discoveryEndpoint: "string>=1",
linkMatchingEmails: "boolean",
});
type ProviderForm = typeof ssoProviderSchema.infer;
type InvitationRole = "member" | "admin" | "owner";
export function SsoSettingsSection() {
const origin = getOrigin();
const navigate = useNavigate();
const { activeOrganization } = useOrganizationContext();
const [isRegisterProviderDialogOpen, setIsRegisterProviderDialogOpen] = useState(false);
const [inviteEmail, setInviteEmail] = useState("");
const [inviteRole, setInviteRole] = useState<InvitationRole>("member");
const form = useForm<ProviderForm>({
resolver: arktypeResolver(ssoProviderSchema),
defaultValues: {
providerId: "",
issuer: "",
domain: "",
clientId: "",
clientSecret: "",
discoveryEndpoint: "",
linkMatchingEmails: true,
},
});
const { data } = useSuspenseQuery({
...getSsoSettingsOptions(),
});
@ -96,48 +53,6 @@ export function SsoSettingsSection() {
...updateSsoProviderAutoLinkingMutation(),
});
const registerProviderMutation = useMutation({
mutationFn: async (formData: ProviderForm) => {
if (!activeOrganization) {
throw new Error("No active organization found in session");
}
const { error } = await authClient.sso.register({
providerId: formData.providerId,
issuer: formData.issuer,
domain: formData.domain,
organizationId: activeOrganization.id,
oidcConfig: {
clientId: formData.clientId,
clientSecret: formData.clientSecret,
discoveryEndpoint: formData.discoveryEndpoint,
scopes: ["openid", "email", "profile"],
},
});
if (error) throw error;
await updateProviderAutoLinkingMutation
.mutateAsync({
path: { providerId: formData.providerId },
body: { enabled: formData.linkMatchingEmails },
})
.catch((updateError) => {
throw new Error(
`The provider was created, but we could not save the auto-link setting. ${parseError(updateError)?.message ?? ""}`,
);
});
},
onSuccess: () => {
toast.success("SSO provider registered successfully");
form.reset();
setIsRegisterProviderDialogOpen(false);
},
onError: (error: unknown) => {
toast.error("Failed to register provider", { description: parseError(error)?.message });
},
});
const deleteProviderMutation = useMutation({
...deleteSsoProviderMutation(),
onSuccess: () => {
@ -213,172 +128,13 @@ export function SsoSettingsSection() {
<p className="text-xs text-muted-foreground">Manage identity providers used for organization sign-in.</p>
</div>
<Dialog open={isRegisterProviderDialogOpen} onOpenChange={setIsRegisterProviderDialogOpen}>
<DialogTrigger asChild>
<Button type="button" disabled={!activeOrganization}>
Register new
</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-2xl">
<DialogHeader>
<DialogTitle>Register SSO provider</DialogTitle>
<DialogDescription>Connect an OIDC provider for the active organization.</DialogDescription>
</DialogHeader>
<Form {...form}>
<form
onSubmit={form.handleSubmit((values) => registerProviderMutation.mutate(values))}
className="space-y-4"
>
<div className="grid gap-4 @xl:grid-cols-2">
<FormField
control={form.control}
name="providerId"
render={({ field }) => (
<FormItem>
<FormLabel>Provider ID</FormLabel>
<FormControl>
<Input {...field} placeholder="acme-oidc" disabled={registerProviderMutation.isPending} />
</FormControl>
<FormDescription>Unique identifier used in callback URLs.</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="domain"
render={({ field }) => (
<FormItem>
<FormLabel>Organization Domain</FormLabel>
<FormControl>
<Input {...field} placeholder="example.com" disabled={registerProviderMutation.isPending} />
</FormControl>
<FormDescription>Used to discover providers during login.</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="issuer"
render={({ field }) => (
<FormItem>
<FormLabel>Issuer URL</FormLabel>
<FormControl>
<Input
{...field}
placeholder="https://idp.example.com"
disabled={registerProviderMutation.isPending}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="discoveryEndpoint"
render={({ field }) => (
<FormItem>
<FormLabel>Discovery Endpoint</FormLabel>
<FormControl>
<Input
{...field}
placeholder="https://idp.example.com/.well-known/openid-configuration"
disabled={registerProviderMutation.isPending}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="clientId"
render={({ field }) => (
<FormItem>
<FormLabel>Client ID</FormLabel>
<FormControl>
<Input
{...field}
placeholder="oidc-client-id"
disabled={registerProviderMutation.isPending}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="clientSecret"
render={({ field }) => (
<FormItem>
<FormLabel>Client Secret</FormLabel>
<FormControl>
<Input
{...field}
type="password"
placeholder="oidc-client-secret"
disabled={registerProviderMutation.isPending}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
<FormField
control={form.control}
name="linkMatchingEmails"
render={({ field }) => (
<FormItem className="rounded-md border p-4">
<div className="flex items-start justify-between gap-4">
<div className="space-y-1">
<FormLabel>Link matching emails to existing accounts</FormLabel>
<FormDescription>
If enabled, users who sign in with this provider will automatically access their existing
account when the email address matches.
</FormDescription>
</div>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
disabled={registerProviderMutation.isPending}
/>
</FormControl>
</div>
<FormMessage />
</FormItem>
)}
/>
{!activeOrganization ? (
<Alert variant="destructive">
<AlertDescription>
No active organization found. Select an organization before registering an SSO provider.
</AlertDescription>
</Alert>
) : null}
<div className="flex justify-end">
<Button type="submit" loading={registerProviderMutation.isPending} disabled={!activeOrganization}>
Register SSO Provider
</Button>
</div>
</form>
</Form>
</DialogContent>
</Dialog>
<Button
type="button"
disabled={!activeOrganization}
onClick={() => void navigate({ to: "/settings/sso/new" })}
>
Register new
</Button>
</div>
<Alert variant="warning">

View file

@ -0,0 +1,258 @@
import { arktypeResolver } from "@hookform/resolvers/arktype";
import { useMutation } from "@tanstack/react-query";
import { useNavigate } from "@tanstack/react-router";
import { type } from "arktype";
import { ShieldCheck, Plus } from "lucide-react";
import { useId } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import { updateSsoProviderAutoLinkingMutation } from "~/client/api-client/@tanstack/react-query.gen";
import { Button } from "~/client/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "~/client/components/ui/card";
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "~/client/components/ui/form";
import { Input } from "~/client/components/ui/input";
import { Switch } from "~/client/components/ui/switch";
import { authClient } from "~/client/lib/auth-client";
import { parseError } from "~/client/lib/errors";
import { useOrganizationContext } from "~/client/hooks/use-org-context";
const ssoProviderSchema = type({
providerId: "string>=1",
issuer: "string>=1",
domain: "string>=1",
clientId: "string>=1",
clientSecret: "string>=1",
discoveryEndpoint: "string>=1",
linkMatchingEmails: "boolean",
});
type ProviderForm = typeof ssoProviderSchema.infer;
export function CreateSsoProviderPage() {
const navigate = useNavigate();
const formId = useId();
const { activeOrganization } = useOrganizationContext();
const form = useForm<ProviderForm>({
resolver: arktypeResolver(ssoProviderSchema),
defaultValues: {
providerId: "",
issuer: "",
domain: "",
clientId: "",
clientSecret: "",
discoveryEndpoint: "",
linkMatchingEmails: true,
},
});
const updateProviderAutoLinking = useMutation({
...updateSsoProviderAutoLinkingMutation(),
});
const registerProvider = useMutation({
mutationFn: async (formData: ProviderForm) => {
const { error } = await authClient.sso.register({
providerId: formData.providerId,
issuer: formData.issuer,
domain: formData.domain,
organizationId: activeOrganization.id,
oidcConfig: {
clientId: formData.clientId,
clientSecret: formData.clientSecret,
discoveryEndpoint: formData.discoveryEndpoint,
scopes: ["openid", "email", "profile"],
},
});
if (error) throw error;
await updateProviderAutoLinking
.mutateAsync({
path: { providerId: formData.providerId },
body: { enabled: formData.linkMatchingEmails },
})
.catch((updateError) => {
throw new Error(
`The provider was created, but we could not save the auto-link setting. ${parseError(updateError)?.message ?? ""}`,
);
});
},
onSuccess: () => {
toast.success("SSO provider registered successfully");
void navigate({ to: "/settings", search: { tab: "users" } });
},
onError: (error: unknown) => {
toast.error("Failed to register provider", { description: parseError(error)?.message });
},
});
return (
<div className="container mx-auto space-y-6">
<Card>
<CardHeader>
<div className="flex items-center gap-3">
<div className="flex items-center justify-center w-10 h-10 rounded-lg bg-primary/10">
<ShieldCheck className="w-5 h-5 text-primary" />
</div>
<CardTitle>Register SSO Provider</CardTitle>
</div>
</CardHeader>
<CardContent className="space-y-6">
<Form {...form}>
<form
id={formId}
onSubmit={form.handleSubmit((values) => registerProvider.mutate(values))}
className="space-y-4"
>
<div className="grid gap-4 @xl:grid-cols-2">
<FormField
control={form.control}
name="providerId"
render={({ field }) => (
<FormItem>
<FormLabel>Provider ID</FormLabel>
<FormControl>
<Input {...field} placeholder="acme-oidc" disabled={registerProvider.isPending} />
</FormControl>
<FormDescription>Unique identifier used in callback URLs.</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="domain"
render={({ field }) => (
<FormItem>
<FormLabel>Organization Domain</FormLabel>
<FormControl>
<Input {...field} placeholder="example.com" disabled={registerProvider.isPending} />
</FormControl>
<FormDescription>Used to discover providers during login.</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="issuer"
render={({ field }) => (
<FormItem>
<FormLabel>Issuer URL</FormLabel>
<FormControl>
<Input {...field} placeholder="https://idp.example.com" disabled={registerProvider.isPending} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="discoveryEndpoint"
render={({ field }) => (
<FormItem>
<FormLabel>Discovery Endpoint</FormLabel>
<FormControl>
<Input
{...field}
placeholder="https://idp.example.com/.well-known/openid-configuration"
disabled={registerProvider.isPending}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="clientId"
render={({ field }) => (
<FormItem>
<FormLabel>Client ID</FormLabel>
<FormControl>
<Input {...field} placeholder="oidc-client-id" disabled={registerProvider.isPending} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="clientSecret"
render={({ field }) => (
<FormItem>
<FormLabel>Client Secret</FormLabel>
<FormControl>
<Input
{...field}
type="password"
placeholder="oidc-client-secret"
disabled={registerProvider.isPending}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
<FormField
control={form.control}
name="linkMatchingEmails"
render={({ field }) => (
<FormItem className="rounded-md border p-4">
<div className="flex items-start justify-between gap-4">
<div className="space-y-1">
<FormLabel>Link matching emails to existing accounts</FormLabel>
<FormDescription>
If enabled, users who sign in with this provider will automatically access their existing
account when the email address matches.
</FormDescription>
</div>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
disabled={registerProvider.isPending}
/>
</FormControl>
</div>
<FormMessage />
</FormItem>
)}
/>
</form>
</Form>
<div className="flex justify-end gap-2 pt-4 border-t">
<Button
type="button"
variant="secondary"
onClick={() => void navigate({ to: "/settings", search: { tab: "users" } })}
>
Cancel
</Button>
<Button type="submit" form={formId} loading={registerProvider.isPending}>
<Plus className="h-4 w-4 mr-2" />
Register Provider
</Button>
</div>
</CardContent>
</Card>
</div>
);
}

View file

@ -30,6 +30,7 @@ import { Route as dashboardBackupsCreateRouteImport } from './routes/(dashboard)
import { Route as authLoginErrorRouteImport } from './routes/(auth)/login.error'
import { Route as dashboardRepositoriesRepositoryIdIndexRouteImport } from './routes/(dashboard)/repositories/$repositoryId/index'
import { Route as dashboardBackupsBackupIdIndexRouteImport } from './routes/(dashboard)/backups/$backupId/index'
import { Route as dashboardSettingsSsoNewRouteImport } from './routes/(dashboard)/settings/sso/new'
import { Route as dashboardRepositoriesRepositoryIdEditRouteImport } from './routes/(dashboard)/repositories/$repositoryId/edit'
import { Route as dashboardRepositoriesRepositoryIdSnapshotIdIndexRouteImport } from './routes/(dashboard)/repositories/$repositoryId/$snapshotId/index'
import { Route as dashboardRepositoriesRepositoryIdSnapshotIdRestoreRouteImport } from './routes/(dashboard)/repositories/$repositoryId/$snapshotId/restore'
@ -146,6 +147,11 @@ const dashboardBackupsBackupIdIndexRoute =
path: '/backups/$backupId/',
getParentRoute: () => dashboardRouteRoute,
} as any)
const dashboardSettingsSsoNewRoute = dashboardSettingsSsoNewRouteImport.update({
id: '/settings/sso/new',
path: '/settings/sso/new',
getParentRoute: () => dashboardRouteRoute,
} as any)
const dashboardRepositoriesRepositoryIdEditRoute =
dashboardRepositoriesRepositoryIdEditRouteImport.update({
id: '/repositories/$repositoryId/edit',
@ -190,6 +196,7 @@ export interface FileRoutesByFullPath {
'/settings/': typeof dashboardSettingsIndexRoute
'/volumes/': typeof dashboardVolumesIndexRoute
'/repositories/$repositoryId/edit': typeof dashboardRepositoriesRepositoryIdEditRoute
'/settings/sso/new': typeof dashboardSettingsSsoNewRoute
'/backups/$backupId/': typeof dashboardBackupsBackupIdIndexRoute
'/repositories/$repositoryId/': typeof dashboardRepositoriesRepositoryIdIndexRoute
'/backups/$backupId/$snapshotId/restore': typeof dashboardBackupsBackupIdSnapshotIdRestoreRoute
@ -215,6 +222,7 @@ export interface FileRoutesByTo {
'/settings': typeof dashboardSettingsIndexRoute
'/volumes': typeof dashboardVolumesIndexRoute
'/repositories/$repositoryId/edit': typeof dashboardRepositoriesRepositoryIdEditRoute
'/settings/sso/new': typeof dashboardSettingsSsoNewRoute
'/backups/$backupId': typeof dashboardBackupsBackupIdIndexRoute
'/repositories/$repositoryId': typeof dashboardRepositoriesRepositoryIdIndexRoute
'/backups/$backupId/$snapshotId/restore': typeof dashboardBackupsBackupIdSnapshotIdRestoreRoute
@ -243,6 +251,7 @@ export interface FileRoutesById {
'/(dashboard)/settings/': typeof dashboardSettingsIndexRoute
'/(dashboard)/volumes/': typeof dashboardVolumesIndexRoute
'/(dashboard)/repositories/$repositoryId/edit': typeof dashboardRepositoriesRepositoryIdEditRoute
'/(dashboard)/settings/sso/new': typeof dashboardSettingsSsoNewRoute
'/(dashboard)/backups/$backupId/': typeof dashboardBackupsBackupIdIndexRoute
'/(dashboard)/repositories/$repositoryId/': typeof dashboardRepositoriesRepositoryIdIndexRoute
'/(dashboard)/backups/$backupId/$snapshotId/restore': typeof dashboardBackupsBackupIdSnapshotIdRestoreRoute
@ -270,6 +279,7 @@ export interface FileRouteTypes {
| '/settings/'
| '/volumes/'
| '/repositories/$repositoryId/edit'
| '/settings/sso/new'
| '/backups/$backupId/'
| '/repositories/$repositoryId/'
| '/backups/$backupId/$snapshotId/restore'
@ -295,6 +305,7 @@ export interface FileRouteTypes {
| '/settings'
| '/volumes'
| '/repositories/$repositoryId/edit'
| '/settings/sso/new'
| '/backups/$backupId'
| '/repositories/$repositoryId'
| '/backups/$backupId/$snapshotId/restore'
@ -322,6 +333,7 @@ export interface FileRouteTypes {
| '/(dashboard)/settings/'
| '/(dashboard)/volumes/'
| '/(dashboard)/repositories/$repositoryId/edit'
| '/(dashboard)/settings/sso/new'
| '/(dashboard)/backups/$backupId/'
| '/(dashboard)/repositories/$repositoryId/'
| '/(dashboard)/backups/$backupId/$snapshotId/restore'
@ -485,6 +497,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof dashboardBackupsBackupIdIndexRouteImport
parentRoute: typeof dashboardRouteRoute
}
'/(dashboard)/settings/sso/new': {
id: '/(dashboard)/settings/sso/new'
path: '/settings/sso/new'
fullPath: '/settings/sso/new'
preLoaderRoute: typeof dashboardSettingsSsoNewRouteImport
parentRoute: typeof dashboardRouteRoute
}
'/(dashboard)/repositories/$repositoryId/edit': {
id: '/(dashboard)/repositories/$repositoryId/edit'
path: '/repositories/$repositoryId/edit'
@ -557,6 +576,7 @@ interface dashboardRouteRouteChildren {
dashboardSettingsIndexRoute: typeof dashboardSettingsIndexRoute
dashboardVolumesIndexRoute: typeof dashboardVolumesIndexRoute
dashboardRepositoriesRepositoryIdEditRoute: typeof dashboardRepositoriesRepositoryIdEditRoute
dashboardSettingsSsoNewRoute: typeof dashboardSettingsSsoNewRoute
dashboardBackupsBackupIdIndexRoute: typeof dashboardBackupsBackupIdIndexRoute
dashboardRepositoriesRepositoryIdIndexRoute: typeof dashboardRepositoriesRepositoryIdIndexRoute
dashboardBackupsBackupIdSnapshotIdRestoreRoute: typeof dashboardBackupsBackupIdSnapshotIdRestoreRoute
@ -579,6 +599,7 @@ const dashboardRouteRouteChildren: dashboardRouteRouteChildren = {
dashboardVolumesIndexRoute: dashboardVolumesIndexRoute,
dashboardRepositoriesRepositoryIdEditRoute:
dashboardRepositoriesRepositoryIdEditRoute,
dashboardSettingsSsoNewRoute: dashboardSettingsSsoNewRoute,
dashboardBackupsBackupIdIndexRoute: dashboardBackupsBackupIdIndexRoute,
dashboardRepositoriesRepositoryIdIndexRoute:
dashboardRepositoriesRepositoryIdIndexRoute,

View file

@ -0,0 +1,22 @@
import { createFileRoute } from "@tanstack/react-router";
import { CreateSsoProviderPage } from "~/client/modules/settings/routes/create-sso-provider";
export const Route = createFileRoute("/(dashboard)/settings/sso/new")({
component: RouteComponent,
staticData: {
breadcrumb: () => [{ label: "Settings", href: "/settings" }, { label: "Register SSO Provider" }],
},
head: () => ({
meta: [
{ title: "Zerobyte - Register SSO Provider" },
{
name: "description",
content: "Register a new OIDC identity provider for organization sign-in.",
},
],
}),
});
function RouteComponent() {
return <CreateSsoProviderPage />;
}