diff --git a/cypress/support/commands.ts b/cypress/support/commands.ts index 5326d5e89..e93532d63 100644 --- a/cypress/support/commands.ts +++ b/cypress/support/commands.ts @@ -34,7 +34,7 @@ Cypress.Commands.add('createOIDCIntegration', (organizationName: string) => { cy.get('input[id="clientId"]').type('implicit-mock-client'); cy.get('input[id="clientSecret"]').type('client-credentials-mock-client-secret'); - cy.get('div[role="dialog"]').find('button[type="submit"]').click(); + cy.get('div[role="dialog"]').find('button[type="submit"]').last().click(); cy.url().then(url => { return new URL(url).pathname.split('/')[0]; diff --git a/packages/web/app/src/components/organization/settings/oidc-integration-section.tsx b/packages/web/app/src/components/organization/settings/oidc-integration-section.tsx index 9ae97d8a6..e7d787e9f 100644 --- a/packages/web/app/src/components/organization/settings/oidc-integration-section.tsx +++ b/packages/web/app/src/components/organization/settings/oidc-integration-section.tsx @@ -1,8 +1,10 @@ import { ReactElement, useEffect, useRef } from 'react'; import { format } from 'date-fns'; import { useFormik } from 'formik'; +import { useForm } from 'react-hook-form'; import { Virtuoso, VirtuosoHandle } from 'react-virtuoso'; import { useClient, useMutation } from 'urql'; +import { z } from 'zod'; import { Button, buttonVariants } from '@/components/ui/button'; import { Dialog, @@ -12,6 +14,14 @@ import { DialogHeader, DialogTitle, } from '@/components/ui/dialog'; +import { + Form, + FormControl, + FormDescription, + FormField, + FormItem, + FormMessage, +} from '@/components/ui/form'; import { AlertTriangleIcon, KeyIcon } from '@/components/ui/icon'; import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; @@ -24,6 +34,8 @@ import { DocumentType, FragmentType, graphql, useFragment } from '@/gql'; import { useClipboard } from '@/lib/hooks'; import { useResetState } from '@/lib/hooks/use-reset-state'; import { cn } from '@/lib/utils'; +import { zodResolver } from '@hookform/resolvers/zod'; +import { useMutation as useRQMutation } from '@tanstack/react-query'; import { Link, useRouter } from '@tanstack/react-router'; function CopyInput(props: { value: string; id?: string }) { @@ -227,6 +239,165 @@ function CreateOIDCIntegrationModal(props: { ); } +const OIDCMetadataSchema = z.object({ + token_endpoint: z + .string({ + required_error: 'Token endpoint not found', + }) + .url('Token endpoint must be a valid URL'), + userinfo_endpoint: z + .string({ + required_error: 'Userinfo endpoint not found', + }) + .url('Userinfo endpoint must be a valid URL'), + authorization_endpoint: z + .string({ + required_error: 'Authorization endpoint not found', + }) + .url('Authorization endpoint must be a valid URL'), +}); + +async function fetchOIDCMetadata(url: string) { + const res = await fetch(url, { + method: 'GET', + headers: { + 'Content-Type': 'application/json', + Accept: 'application/json', + }, + }); + + if (!res.ok) { + return { + ok: false, + error: { + message: 'Failed to fetch metadata', + details: { + url, + status: res.status, + statusText: res.statusText, + body: await res.text(), + }, + }, + } as const; + } + + return { + ok: true, + metadata: await res.json(), + } as const; +} + +const OIDCMetadataFormSchema = z.object({ + url: z.string().url('Must be a valid URL'), +}); + +function OIDCMetadataFetcher(props: { + onEndpointChange(endpoints: { token: string; userinfo: string; authorization: string }): void; +}) { + const { toast } = useToast(); + + const fetchMetadata = useRQMutation({ + mutationFn: fetchOIDCMetadata, + onSuccess(data) { + if (!data.ok) { + toast({ + title: data.error.message, + description: ( +
+

Status: {data.error.details.status}

+

Response: {data.error.details.body ?? data.error.details.statusText}

+
+ ), + variant: 'destructive', + }); + return; + } + + const metadataResult = OIDCMetadataSchema.safeParse(data.metadata); + if (!metadataResult.success) { + toast({ + title: 'Failed to parse OIDC metadata', + description: ( + <> + {[ + metadataResult.error.formErrors.fieldErrors.authorization_endpoint?.[0], + metadataResult.error.formErrors.fieldErrors.token_endpoint?.[0], + metadataResult.error.formErrors.fieldErrors.userinfo_endpoint?.[0], + ] + .filter(Boolean) + .map(msg => ( +

{msg}

+ ))} + + ), + variant: 'destructive', + }); + return; + } + + props.onEndpointChange({ + token: metadataResult.data.token_endpoint, + userinfo: metadataResult.data.userinfo_endpoint, + authorization: metadataResult.data.authorization_endpoint, + }); + }, + onError(error) { + console.error(error); + toast({ + title: 'Failed to fetch OIDC metadata', + description: 'Provide the endpoints manually or try again later', + variant: 'destructive', + }); + }, + }); + + function onSubmit(data: z.infer) { + fetchMetadata.mutate(data.url); + } + + const form = useForm({ + resolver: zodResolver(OIDCMetadataFormSchema), + defaultValues: { + url: '', + }, + mode: 'onSubmit', + }); + + return ( +
+ + { + return ( + +
+ + + + +
+ + Provide the OIDC metadata URL to automatically fill in the fields below. + + +
+ ); + }} + /> + + + ); +} + function CreateOIDCIntegrationForm(props: { organizationId: string; close: () => void; @@ -267,7 +438,7 @@ function CreateOIDCIntegrationForm(props: { }); return ( -
+
Connect OpenID Connect Provider @@ -279,82 +450,97 @@ function CreateOIDCIntegrationForm(props: { provider. -
-
- - - +
+ { + void formik.setFieldValue('tokenEndpoint', endpoints.token); + void formik.setFieldValue('userinfoEndpoint', endpoints.userinfo); + void formik.setFieldValue('authorizationEndpoint', endpoints.authorization); + }} /> - {mutation.data?.createOIDCIntegration.error?.details.tokenEndpoint}
+ +
+ -
- - - - {mutation.data?.createOIDCIntegration.error?.details.userinfoEndpoint} - -
+ + + {mutation.data?.createOIDCIntegration.error?.details.tokenEndpoint} + +
-
- - - - {mutation.data?.createOIDCIntegration.error?.details.authorizationEndpoint} - -
+
+ + + + {mutation.data?.createOIDCIntegration.error?.details.userinfoEndpoint} + +
-
- - - {mutation.data?.createOIDCIntegration.error?.details.clientId} -
+
+ + + + {mutation.data?.createOIDCIntegration.error?.details.authorizationEndpoint} + +
-
- - - {mutation.data?.createOIDCIntegration.error?.details.clientSecret} -
+
+ + + {mutation.data?.createOIDCIntegration.error?.details.clientId} +
-
- - -
+
+ + + + {mutation.data?.createOIDCIntegration.error?.details.clientSecret} + +
+ +
+ + +
+
- +
); }