From 0bf0094dbdc6bc8efb09137bd1f432e01a99e3c8 Mon Sep 17 00:00:00 2001 From: Nicolas Meienberger Date: Sun, 22 Feb 2026 20:41:47 +0100 Subject: [PATCH] refactor: own page for sso registration --- .../components/sso/sso-settings-section.tsx | 268 +----------------- .../settings/routes/create-sso-provider.tsx | 258 +++++++++++++++++ app/routeTree.gen.ts | 21 ++ app/routes/(dashboard)/settings/sso/new.tsx | 22 ++ 4 files changed, 313 insertions(+), 256 deletions(-) create mode 100644 app/client/modules/settings/routes/create-sso-provider.tsx create mode 100644 app/routes/(dashboard)/settings/sso/new.tsx diff --git a/app/client/modules/settings/components/sso/sso-settings-section.tsx b/app/client/modules/settings/components/sso/sso-settings-section.tsx index 3d7f4a3b..7fd95384 100644 --- a/app/client/modules/settings/components/sso/sso-settings-section.tsx +++ b/app/client/modules/settings/components/sso/sso-settings-section.tsx @@ -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("member"); - const form = useForm({ - 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() {

Manage identity providers used for organization sign-in.

- - - - - - - - Register SSO provider - Connect an OIDC provider for the active organization. - - -
- registerProviderMutation.mutate(values))} - className="space-y-4" - > -
- ( - - Provider ID - - - - Unique identifier used in callback URLs. - - - )} - /> - - ( - - Organization Domain - - - - Used to discover providers during login. - - - )} - /> - - ( - - Issuer URL - - - - - - )} - /> - - ( - - Discovery Endpoint - - - - - - )} - /> - - ( - - Client ID - - - - - - )} - /> - - ( - - Client Secret - - - - - - )} - /> -
- - ( - -
-
- Link matching emails to existing accounts - - If enabled, users who sign in with this provider will automatically access their existing - account when the email address matches. - -
- - - -
- -
- )} - /> - - {!activeOrganization ? ( - - - No active organization found. Select an organization before registering an SSO provider. - - - ) : null} - -
- -
- - -
-
+ diff --git a/app/client/modules/settings/routes/create-sso-provider.tsx b/app/client/modules/settings/routes/create-sso-provider.tsx new file mode 100644 index 00000000..ccb95b83 --- /dev/null +++ b/app/client/modules/settings/routes/create-sso-provider.tsx @@ -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({ + 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 ( +
+ + +
+
+ +
+ Register SSO Provider +
+
+ +
+ registerProvider.mutate(values))} + className="space-y-4" + > +
+ ( + + Provider ID + + + + Unique identifier used in callback URLs. + + + )} + /> + + ( + + Organization Domain + + + + Used to discover providers during login. + + + )} + /> + + ( + + Issuer URL + + + + + + )} + /> + + ( + + Discovery Endpoint + + + + + + )} + /> + + ( + + Client ID + + + + + + )} + /> + + ( + + Client Secret + + + + + + )} + /> +
+ + ( + +
+
+ Link matching emails to existing accounts + + If enabled, users who sign in with this provider will automatically access their existing + account when the email address matches. + +
+ + + +
+ +
+ )} + /> + + + +
+ + +
+
+
+
+ ); +} diff --git a/app/routeTree.gen.ts b/app/routeTree.gen.ts index 27892183..696df13b 100644 --- a/app/routeTree.gen.ts +++ b/app/routeTree.gen.ts @@ -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, diff --git a/app/routes/(dashboard)/settings/sso/new.tsx b/app/routes/(dashboard)/settings/sso/new.tsx new file mode 100644 index 00000000..b11ad6af --- /dev/null +++ b/app/routes/(dashboard)/settings/sso/new.tsx @@ -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 ; +}