From 2339e0f069fd1443b7b2f2a990e28205d02b0cf4 Mon Sep 17 00:00:00 2001 From: Jonas Hungershausen Date: Wed, 27 Nov 2024 17:39:58 +0100 Subject: [PATCH] feat: enable proper account linking flows (#297) --- .../src/components/form/form-helpers.ts | 18 +++++----- .../src/components/form/messages.tsx | 2 +- .../src/components/form/social.tsx | 9 +++-- .../src/context/form-state.test.ts | 34 ++++++++++++++++++- .../elements-react/src/context/form-state.ts | 5 ++- packages/elements-react/src/locales/de.json | 3 +- packages/elements-react/src/locales/en.json | 3 +- packages/elements-react/src/locales/es.json | 3 +- packages/elements-react/src/locales/fr.json | 3 +- packages/elements-react/src/locales/nl.json | 3 +- packages/elements-react/src/locales/pl.json | 3 +- packages/elements-react/src/locales/pt.json | 3 +- packages/elements-react/src/locales/sv.json | 3 +- .../theme/default/components/card/header.tsx | 5 +-- .../constructCardHeader.spec.tsx.snap | 7 ++++ .../__tests__/constructCardHeader.spec.tsx | 23 ++++++++++++- .../default/utils/constructCardHeader.ts | 28 +++++++++++++-- 17 files changed, 126 insertions(+), 29 deletions(-) diff --git a/packages/elements-react/src/components/form/form-helpers.ts b/packages/elements-react/src/components/form/form-helpers.ts index c6b78d6b9..95ec5a6ac 100644 --- a/packages/elements-react/src/components/form/form-helpers.ts +++ b/packages/elements-react/src/components/form/form-helpers.ts @@ -14,15 +14,18 @@ export function computeDefaultValues(nodes: UiNode[]): FormValues { attrs.name === "method" || attrs.type === "submit" || typeof attrs.value === "undefined" - ) + ) { return acc + } // Unroll nested traits or assign default values - const unrolled = unrollTrait({ - name: attrs.name, - value: attrs.value, - }) - Object.assign(acc, unrolled ?? { [attrs.name]: attrs.value }) + return unrollTrait( + { + name: attrs.name, + value: attrs.value, + }, + acc, + ) } return acc @@ -32,9 +35,8 @@ export function computeDefaultValues(nodes: UiNode[]): FormValues { export function unrollTrait( input: { name: T; value: V }, output: Partial> = {}, -): UnrollTrait | undefined { +): UnrollTrait { const keys = input.name.split(".") - if (!keys.length) return undefined // It's challenging to type this for deeply nested structures because the shape // of current changes dynamically as we navigate through levels. diff --git a/packages/elements-react/src/components/form/messages.tsx b/packages/elements-react/src/components/form/messages.tsx index d09797654..8d6afa648 100644 --- a/packages/elements-react/src/components/form/messages.tsx +++ b/packages/elements-react/src/components/form/messages.tsx @@ -16,7 +16,7 @@ export type OryMessageRootProps = DetailedHTMLProps< // This is a list of message IDs that should not be shown to the user. // They're returned by the API, but they don't work well in the two step flows. -const messageIdsToHide = [1040009, 1060003, 1080003, 1010014, 1040005] +const messageIdsToHide = [1040009, 1060003, 1080003, 1010014, 1040005, 1010016] export function OryCardValidationMessages({ ...props }: OryMessageRootProps) { const { flow } = useOryFlow() diff --git a/packages/elements-react/src/components/form/social.tsx b/packages/elements-react/src/components/form/social.tsx index 1313f2fff..37c25418f 100644 --- a/packages/elements-react/src/components/form/social.tsx +++ b/packages/elements-react/src/components/form/social.tsx @@ -7,6 +7,7 @@ import { UiNode, UiNodeInputAttributes } from "@ory/client-fetch" import { PropsWithChildren } from "react" import { OryForm } from "./form" import { useFormContext } from "react-hook-form" +import { OryFormProvider } from "./form-provider" export type OryFormOidcButtonsProps = PropsWithChildren<{ hideDivider?: boolean @@ -86,8 +87,10 @@ export function OryFormSocialButtonsForm() { } return ( - - - + + + + + ) } diff --git a/packages/elements-react/src/context/form-state.test.ts b/packages/elements-react/src/context/form-state.test.ts index 19984c58a..07fa5e3b4 100644 --- a/packages/elements-react/src/context/form-state.test.ts +++ b/packages/elements-react/src/context/form-state.test.ts @@ -1,7 +1,7 @@ // Copyright © 2024 Ory Corp // SPDX-License-Identifier: Apache-2.0 -import { FlowType, UiNodeGroupEnum } from "@ory/client-fetch" +import { FlowType, UiNodeGroupEnum, UiText } from "@ory/client-fetch" import { act, renderHook } from "@testing-library/react" import { OryFlowContainer } from "../util" import { useFormStateReducer } from "./form-state" // Adjust path as needed @@ -384,3 +384,35 @@ for (const activeMethod of ["identifier_first", "default"]) { expect(state).toEqual({ current: "provide_identifier" }) }) } + +test(`should parse select_method from account linking flow`, () => { + const { result } = renderHook(() => useFormStateReducer(init)) + const [, dispatch] = result.current + + const mockFlow = { + flowType: FlowType.Login, + flow: { + active: "", + ui: { + nodes: [], + messages: [ + { + id: 1010016, + text: "", + type: "error", + } satisfies UiText, + ], + }, // Assuming nodes structure + }, + } as unknown as OryFlowContainer + + act(() => { + dispatch({ + type: "action_flow_update", + flow: mockFlow, + }) + }) + + const [state] = result.current + expect(state).toEqual({ current: "select_method" }) +}) diff --git a/packages/elements-react/src/context/form-state.ts b/packages/elements-react/src/context/form-state.ts index b958cdcd7..aeaea0d72 100644 --- a/packages/elements-react/src/context/form-state.ts +++ b/packages/elements-react/src/context/form-state.ts @@ -42,11 +42,14 @@ function parseStateFromFlow(flow: OryFlowContainer): FormState { return { current: "method_active", method: methodWithMessage.group } } else if ( flow.flow.active && - !["default", "identifier_first"].includes(flow.flow.active) + !["default", "identifier_first", "oidc"].includes(flow.flow.active) ) { return { current: "method_active", method: flow.flow.active } } else if (isChoosingMethod(flow.flow.ui.nodes)) { return { current: "select_method" } + } else if (flow.flow.ui.messages?.some((m) => m.id === 1010016)) { + // Account linking edge case + return { current: "select_method" } } return { current: "provide_identifier" } } diff --git a/packages/elements-react/src/locales/de.json b/packages/elements-react/src/locales/de.json index 29e9874bc..ee57eeccb 100644 --- a/packages/elements-react/src/locales/de.json +++ b/packages/elements-react/src/locales/de.json @@ -240,5 +240,6 @@ "settings.title-totp": "Verwalten Sie die 2FA TOTP Authenticator-App", "settings.title-webauthn": "Hardware-Token verwalten", "settings.webauthn.info": "Hardware-Tokens werden für die Zweitfaktor-Authentifizierung oder als Erstfaktor-Authentifizierung mit Passkeys verwendet", - "card.footer.select-another-method": "Eine andere Methode verwenden" + "card.footer.select-another-method": "Eine andere Methode verwenden", + "account-linking.title": "Account Verbinden" } diff --git a/packages/elements-react/src/locales/en.json b/packages/elements-react/src/locales/en.json index bb2c5144a..a6ec0a81c 100644 --- a/packages/elements-react/src/locales/en.json +++ b/packages/elements-react/src/locales/en.json @@ -240,5 +240,6 @@ "settings.passkey.title": "Manage Passkeys", "settings.passkey.description": "Manage your passkey settings", "settings.passkey.info": "Manage your passkey settings", - "card.footer.select-another-method": "Select another method" + "card.footer.select-another-method": "Select another method", + "account-linking.title": "Link account" } diff --git a/packages/elements-react/src/locales/es.json b/packages/elements-react/src/locales/es.json index 5f15a9a2a..846bc6234 100644 --- a/packages/elements-react/src/locales/es.json +++ b/packages/elements-react/src/locales/es.json @@ -240,5 +240,6 @@ "settings.title-totp": "", "settings.title-webauthn": "", "settings.webauthn.info": "", - "card.footer.select-another-method": "" + "card.footer.select-another-method": "", + "account-linking.title": "" } diff --git a/packages/elements-react/src/locales/fr.json b/packages/elements-react/src/locales/fr.json index f8d650875..55e4eb5c7 100644 --- a/packages/elements-react/src/locales/fr.json +++ b/packages/elements-react/src/locales/fr.json @@ -240,5 +240,6 @@ "settings.webauthn.description": "", "settings.webauthn.info": "", "settings.webauthn.title": "", - "card.footer.select-another-method": "" + "card.footer.select-another-method": "", + "account-linking.title": "" } diff --git a/packages/elements-react/src/locales/nl.json b/packages/elements-react/src/locales/nl.json index c8b299864..338375c9f 100644 --- a/packages/elements-react/src/locales/nl.json +++ b/packages/elements-react/src/locales/nl.json @@ -240,5 +240,6 @@ "settings.webauthn.description": "", "settings.webauthn.info": "", "settings.webauthn.title": "", - "card.footer.select-another-method": "" + "card.footer.select-another-method": "", + "account-linking.title": "" } diff --git a/packages/elements-react/src/locales/pl.json b/packages/elements-react/src/locales/pl.json index 4709be665..2297fc077 100644 --- a/packages/elements-react/src/locales/pl.json +++ b/packages/elements-react/src/locales/pl.json @@ -240,5 +240,6 @@ "settings.webauthn.description": "", "settings.webauthn.info": "", "settings.webauthn.title": "", - "card.footer.select-another-method": "" + "card.footer.select-another-method": "", + "account-linking.title": "" } diff --git a/packages/elements-react/src/locales/pt.json b/packages/elements-react/src/locales/pt.json index f8d719b14..b0c1a035c 100644 --- a/packages/elements-react/src/locales/pt.json +++ b/packages/elements-react/src/locales/pt.json @@ -240,5 +240,6 @@ "settings.webauthn.description": "", "settings.webauthn.info": "", "settings.webauthn.title": "", - "card.footer.select-another-method": "" + "card.footer.select-another-method": "", + "account-linking.title": "" } diff --git a/packages/elements-react/src/locales/sv.json b/packages/elements-react/src/locales/sv.json index 6d31bd164..27cdc55f3 100644 --- a/packages/elements-react/src/locales/sv.json +++ b/packages/elements-react/src/locales/sv.json @@ -240,5 +240,6 @@ "settings.webauthn.description": "Hantera inställningarna för din maskinvarutoken", "settings.webauthn.info": "Hårdvarutokens används för andrafaktorsautentisering eller som förstafaktor med lösenordsnycklar", "settings.webauthn.title": "Hantera maskinvarutokens", - "card.footer.select-another-method": "Välj en annan metod" + "card.footer.select-another-method": "Välj en annan metod", + "account-linking.title": "Länka ditt konto" } diff --git a/packages/elements-react/src/theme/default/components/card/header.tsx b/packages/elements-react/src/theme/default/components/card/header.tsx index d08148ea8..1686c2ace 100644 --- a/packages/elements-react/src/theme/default/components/card/header.tsx +++ b/packages/elements-react/src/theme/default/components/card/header.tsx @@ -23,10 +23,7 @@ function InnerCardHeader({ title, text }: { title: string; text?: string }) { export function DefaultCardHeader() { const context = useOryFlow() - const { title, description } = useCardHeaderText( - context.flow.ui.nodes, - context, - ) + const { title, description } = useCardHeaderText(context.flow.ui, context) return } diff --git a/packages/elements-react/src/theme/default/utils/__tests__/__snapshots__/constructCardHeader.spec.tsx.snap b/packages/elements-react/src/theme/default/utils/__tests__/__snapshots__/constructCardHeader.spec.tsx.snap index 689d16ee1..61f85e02b 100644 --- a/packages/elements-react/src/theme/default/utils/__tests__/__snapshots__/constructCardHeader.spec.tsx.snap +++ b/packages/elements-react/src/theme/default/utils/__tests__/__snapshots__/constructCardHeader.spec.tsx.snap @@ -1,5 +1,12 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP +exports[`constructCardHeaderText on account linking 1`] = ` +{ + "description": "You tried to sign in with "{duplicateIdentifier}", but that email is already used by another account. Sign in to your account with one of the options below to add your account "{duplicateIdentifier}" at "{provider}" as another way to sign in.", + "title": "Link account", +} +`; + exports[`flowType=login refresh=false combination=code constructCardHeaderText 1`] = ` { "description": "Sign in with a code sent to your email", diff --git a/packages/elements-react/src/theme/default/utils/__tests__/constructCardHeader.spec.tsx b/packages/elements-react/src/theme/default/utils/__tests__/constructCardHeader.spec.tsx index 1348e9c82..7a899e6b7 100644 --- a/packages/elements-react/src/theme/default/utils/__tests__/constructCardHeader.spec.tsx +++ b/packages/elements-react/src/theme/default/utils/__tests__/constructCardHeader.spec.tsx @@ -184,7 +184,11 @@ for (const flowType of [ const res = renderHook( () => useCardHeaderText( - Array.isArray(value) ? value : [value], + { + nodes: Array.isArray(value) ? value : [value], + action: "", + method: "", + }, opts, ), { wrapper }, @@ -197,3 +201,20 @@ for (const flowType of [ } }) } + +test("constructCardHeaderText on account linking", () => { + const res = renderHook( + () => + useCardHeaderText( + { + nodes: [defaultGroup], + action: "", + method: "", + messages: [{ id: 1010016, text: "", type: "error" }], + }, + { flowType: FlowType.Login, flow: { refresh: false } }, + ), + { wrapper }, + ) + expect(res.result.current).toMatchSnapshot() +}) diff --git a/packages/elements-react/src/theme/default/utils/constructCardHeader.ts b/packages/elements-react/src/theme/default/utils/constructCardHeader.ts index 772a280e6..1fed28b6a 100644 --- a/packages/elements-react/src/theme/default/utils/constructCardHeader.ts +++ b/packages/elements-react/src/theme/default/utils/constructCardHeader.ts @@ -1,7 +1,11 @@ // Copyright © 2024 Ory Corp // SPDX-License-Identifier: Apache-2.0 -import { FlowType, isUiNodeInputAttributes, UiNode } from "@ory/client-fetch" +import { + FlowType, + isUiNodeInputAttributes, + UiContainer, +} from "@ory/client-fetch" import { useIntl } from "react-intl" function joinWithCommaOr(list: string[], orText = "or"): string { @@ -48,9 +52,10 @@ type opts = * @returns a title and a description for the card header */ export function useCardHeaderText( - nodes: UiNode[], + container: UiContainer, opts: opts, ): { title: string; description: string } { + const nodes = container.nodes const intl = useIntl() switch (opts.flowType) { case FlowType.Recovery: @@ -110,6 +115,25 @@ export function useCardHeaderText( id: "verification.subtitle", }), } + case FlowType.Login: { + // account linking + const accountLinkingMessage = container.messages?.find( + (m) => m.id === 1010016, + ) + if (accountLinkingMessage) { + return { + title: intl.formatMessage({ + id: "account-linking.title", + }), + description: intl.formatMessage( + { + id: "identities.messages.1010016", + }, + accountLinkingMessage.context as Record, + ), + } + } + } } const parts = []