diff --git a/packages/elements-react/api-report/elements-react-theme.api.json b/packages/elements-react/api-report/elements-react-theme.api.json index d65dcca1..c0b60000 100644 --- a/packages/elements-react/api-report/elements-react-theme.api.json +++ b/packages/elements-react/api-report/elements-react-theme.api.json @@ -357,6 +357,39 @@ "parameters": [], "name": "DefaultCardLogo" }, + { + "kind": "Function", + "canonicalReference": "@ory/elements-react!DefaultCurrentIdentifierButton:function(1)", + "docComment": "", + "excerptTokens": [ + { + "kind": "Content", + "text": "declare function DefaultCurrentIdentifierButton(): " + }, + { + "kind": "Reference", + "text": "react_jsx_runtime.JSX.Element", + "canonicalReference": "@types/react!JSX.Element:interface" + }, + { + "kind": "Content", + "text": " | null" + }, + { + "kind": "Content", + "text": ";" + } + ], + "fileUrlPath": "dist/theme/default/index.d.ts", + "returnTypeTokenRange": { + "startIndex": 1, + "endIndex": 3 + }, + "releaseTag": "Public", + "overloadIndex": 1, + "parameters": [], + "name": "DefaultCurrentIdentifierButton" + }, { "kind": "Function", "canonicalReference": "@ory/elements-react!DefaultFormContainer:function(1)", diff --git a/packages/elements-react/api-report/elements-react-theme.api.md b/packages/elements-react/api-report/elements-react-theme.api.md index c9412205..0b2b4516 100644 --- a/packages/elements-react/api-report/elements-react-theme.api.md +++ b/packages/elements-react/api-report/elements-react-theme.api.md @@ -47,6 +47,9 @@ export function DefaultCardHeader(): react_jsx_runtime.JSX.Element; // @public (undocumented) export function DefaultCardLogo(): react_jsx_runtime.JSX.Element; +// @public (undocumented) +export function DefaultCurrentIdentifierButton(): react_jsx_runtime.JSX.Element | null; + // Warning: (ae-forgotten-export) The symbol "OryFormRootProps" needs to be exported by the entry point index.d.ts // // @public (undocumented) @@ -123,8 +126,8 @@ export type VerificationFlowContextProps = { // Warnings were encountered during analysis: // -// dist/theme/default/index.d.ts:25:5 - (ae-forgotten-export) The symbol "OryFlowComponentOverrides" needs to be exported by the entry point index.d.ts -// dist/theme/default/index.d.ts:26:5 - (ae-forgotten-export) The symbol "OryClientConfiguration" needs to be exported by the entry point index.d.ts +// dist/theme/default/index.d.ts:27:5 - (ae-forgotten-export) The symbol "OryFlowComponentOverrides" needs to be exported by the entry point index.d.ts +// dist/theme/default/index.d.ts:28:5 - (ae-forgotten-export) The symbol "OryClientConfiguration" needs to be exported by the entry point index.d.ts // (No @packageDocumentation comment for this package) diff --git a/packages/elements-react/api-report/elements-react.api.json b/packages/elements-react/api-report/elements-react.api.json index 16ad13a5..fa0ab7e0 100644 --- a/packages/elements-react/api-report/elements-react.api.json +++ b/packages/elements-react/api-report/elements-react.api.json @@ -239,15 +239,6 @@ "kind": "Content", "text": "<" }, - { - "kind": "Reference", - "text": "Partial", - "canonicalReference": "!Partial:type" - }, - { - "kind": "Content", - "text": "<" - }, { "kind": "Reference", "text": "OryFlowContainer", @@ -255,7 +246,7 @@ }, { "kind": "Content", - "text": ">>" + "text": ">" }, { "kind": "Content", @@ -267,7 +258,7 @@ "name": "FlowContainerSetter", "typeTokenRange": { "startIndex": 1, - "endIndex": 7 + "endIndex": 5 } }, { @@ -295,7 +286,34 @@ }, { "kind": "Content", - "text": ";\n}" + "text": ";\n formState: " + }, + { + "kind": "Reference", + "text": "FormState", + "canonicalReference": "@ory/elements-react!FormState:type" + }, + { + "kind": "Content", + "text": ";\n dispatchFormState: " + }, + { + "kind": "Reference", + "text": "Dispatch", + "canonicalReference": "@types/react!React.Dispatch:type" + }, + { + "kind": "Content", + "text": "<" + }, + { + "kind": "Reference", + "text": "FormStateAction", + "canonicalReference": "@ory/elements-react!FormStateAction:type" + }, + { + "kind": "Content", + "text": ">;\n}" }, { "kind": "Content", @@ -307,7 +325,86 @@ "name": "FlowContextValue", "typeTokenRange": { "startIndex": 1, - "endIndex": 5 + "endIndex": 11 + } + }, + { + "kind": "TypeAlias", + "canonicalReference": "@ory/elements-react!FormState:type", + "docComment": "", + "excerptTokens": [ + { + "kind": "Content", + "text": "type FormState = " + }, + { + "kind": "Content", + "text": "{\n current: \"provide_identifier\";\n} | {\n current: \"select_method\";\n} | {\n current: \"method_active\";\n method: " + }, + { + "kind": "Reference", + "text": "UiNodeGroupEnum", + "canonicalReference": "@ory/client-fetch!UiNodeGroupEnum:type" + }, + { + "kind": "Content", + "text": ";\n} | {\n current: \"success_screen\";\n} | {\n current: \"settings\";\n}" + }, + { + "kind": "Content", + "text": ";" + } + ], + "fileUrlPath": "dist/index.d.ts", + "releaseTag": "Public", + "name": "FormState", + "typeTokenRange": { + "startIndex": 1, + "endIndex": 4 + } + }, + { + "kind": "TypeAlias", + "canonicalReference": "@ory/elements-react!FormStateAction:type", + "docComment": "", + "excerptTokens": [ + { + "kind": "Content", + "text": "type FormStateAction = " + }, + { + "kind": "Content", + "text": "{\n type: \"action_flow_update\";\n flow: " + }, + { + "kind": "Reference", + "text": "OryFlowContainer", + "canonicalReference": "@ory/elements-react!OryFlowContainer:type" + }, + { + "kind": "Content", + "text": ";\n} | {\n type: \"action_select_method\";\n method: " + }, + { + "kind": "Reference", + "text": "UiNodeGroupEnum", + "canonicalReference": "@ory/client-fetch!UiNodeGroupEnum:type" + }, + { + "kind": "Content", + "text": ";\n}" + }, + { + "kind": "Content", + "text": ";" + } + ], + "fileUrlPath": "dist/index.d.ts", + "releaseTag": "Public", + "name": "FormStateAction", + "typeTokenRange": { + "startIndex": 1, + "endIndex": 6 } }, { @@ -1050,68 +1147,6 @@ "endIndex": 8 } }, - { - "kind": "TypeAlias", - "canonicalReference": "@ory/elements-react!OryCurrentIdentifierProps:type", - "docComment": "", - "excerptTokens": [ - { - "kind": "Content", - "text": "type OryCurrentIdentifierProps = " - }, - { - "kind": "Content", - "text": "{\n attributes: " - }, - { - "kind": "Reference", - "text": "UiNodeInputAttributes", - "canonicalReference": "@ory/client-fetch!UiNodeInputAttributes:interface" - }, - { - "kind": "Content", - "text": ";\n node: " - }, - { - "kind": "Reference", - "text": "UiNode", - "canonicalReference": "@ory/client-fetch!UiNode:interface" - }, - { - "kind": "Content", - "text": ";\n onClick?: () => void;\n href?: string;\n} & " - }, - { - "kind": "Reference", - "text": "Omit", - "canonicalReference": "!Omit:type" - }, - { - "kind": "Content", - "text": "<" - }, - { - "kind": "Reference", - "text": "ComponentPropsWithoutRef", - "canonicalReference": "@types/react!React.ComponentPropsWithoutRef:type" - }, - { - "kind": "Content", - "text": "<\"button\">, \"children\" | \"onClick\">" - }, - { - "kind": "Content", - "text": ";" - } - ], - "fileUrlPath": "dist/index.d.ts", - "releaseTag": "Public", - "name": "OryCurrentIdentifierProps", - "typeTokenRange": { - "startIndex": 1, - "endIndex": 10 - } - }, { "kind": "TypeAlias", "canonicalReference": "@ory/elements-react!OryFlowComponentOverrides:type", @@ -1197,24 +1232,6 @@ "text": "OryNodeOidcButtonProps", "canonicalReference": "@ory/elements-react!OryNodeOidcButtonProps:type" }, - { - "kind": "Content", - "text": ">;\n CurrentIdentifierButton: " - }, - { - "kind": "Reference", - "text": "ComponentType", - "canonicalReference": "@types/react!React.ComponentType:type" - }, - { - "kind": "Content", - "text": "<" - }, - { - "kind": "Reference", - "text": "OryCurrentIdentifierProps", - "canonicalReference": "@ory/elements-react!OryCurrentIdentifierProps:type" - }, { "kind": "Content", "text": ">;\n Anchor: " @@ -1733,7 +1750,7 @@ "name": "OryFlowComponents", "typeTokenRange": { "startIndex": 1, - "endIndex": 126 + "endIndex": 122 } }, { @@ -1806,7 +1823,7 @@ "excerptTokens": [ { "kind": "Content", - "text": "declare function OryForm({ children, onAfterSubmit, nodes }: " + "text": "declare function OryForm({ children, onAfterSubmit }: " }, { "kind": "Reference", @@ -1840,7 +1857,7 @@ "overloadIndex": 1, "parameters": [ { - "parameterName": "{ children, onAfterSubmit, nodes }", + "parameterName": "{ children, onAfterSubmit }", "parameterTypeTokenRange": { "startIndex": 1, "endIndex": 2 @@ -2135,16 +2152,7 @@ }, { "kind": "Content", - "text": "<{\n onAfterSubmit?: (method: string | number | boolean | undefined) => void;\n nodes?: " - }, - { - "kind": "Reference", - "text": "UiNode", - "canonicalReference": "@ory/client-fetch!UiNode:interface" - }, - { - "kind": "Content", - "text": "[];\n}>" + "text": "<{\n onAfterSubmit?: (method: string | number | boolean | undefined) => void;\n}>" }, { "kind": "Content", @@ -2156,7 +2164,7 @@ "name": "OryFormProps", "typeTokenRange": { "startIndex": 1, - "endIndex": 5 + "endIndex": 3 } }, { diff --git a/packages/elements-react/api-report/elements-react.api.md b/packages/elements-react/api-report/elements-react.api.md index 44e13207..152ab6cc 100644 --- a/packages/elements-react/api-report/elements-react.api.md +++ b/packages/elements-react/api-report/elements-react.api.md @@ -44,11 +44,36 @@ import { VerificationFlow } from '@ory/client-fetch'; export type ErrorFlowContainer = OryFlow; // @public -export type FlowContainerSetter = Dispatch>; +export type FlowContainerSetter = Dispatch; // @public export type FlowContextValue = OryFlowContainer & { setFlowContainer: FlowContainerSetter; + formState: FormState; + dispatchFormState: Dispatch; +}; + +// @public (undocumented) +export type FormState = { + current: "provide_identifier"; +} | { + current: "select_method"; +} | { + current: "method_active"; + method: UiNodeGroupEnum; +} | { + current: "success_screen"; +} | { + current: "settings"; +}; + +// @public (undocumented) +export type FormStateAction = { + type: "action_flow_update"; + flow: OryFlowContainer; +} | { + type: "action_select_method"; + method: UiNodeGroupEnum; }; // @public @@ -138,14 +163,6 @@ export type OryClientConfiguration = { intl?: IntlConfig; }; -// @public (undocumented) -export type OryCurrentIdentifierProps = { - attributes: UiNodeInputAttributes; - node: UiNode; - onClick?: () => void; - href?: string; -} & Omit, "children" | "onClick">; - // Warning: (ae-forgotten-export) The symbol "DeepPartialTwoLevels" needs to be exported by the entry point index.d.ts // // @public (undocumented) @@ -156,7 +173,6 @@ export type OryFlowComponents = { Node: { Button: ComponentType; OidcButton: ComponentType; - CurrentIdentifierButton: ComponentType; Anchor: ComponentType; Input: ComponentType; CodeInput: ComponentType; @@ -200,7 +216,7 @@ export type OryFlowComponents = { export type OryFlowContainer = LoginFlowContainer | RegistrationFlowContainer | RecoveryFlowContainer | VerificationFlowContainer | SettingsFlowContainer; // @public (undocumented) -export function OryForm({ children, onAfterSubmit, nodes }: OryFormProps): string | react_jsx_runtime.JSX.Element; +export function OryForm({ children, onAfterSubmit }: OryFormProps): string | react_jsx_runtime.JSX.Element; // @public export function OryFormGroupDivider(): react_jsx_runtime.JSX.Element | null; @@ -232,7 +248,6 @@ export type OryFormOidcRootProps = PropsWithChildren<{ // @public (undocumented) export type OryFormProps = PropsWithChildren<{ onAfterSubmit?: (method: string | number | boolean | undefined) => void; - nodes?: UiNode[]; }>; // @public (undocumented) diff --git a/packages/elements-react/src/components/card/card-two-step.tsx b/packages/elements-react/src/components/card/card-two-step.tsx index d6ff4164..1eb92825 100644 --- a/packages/elements-react/src/components/card/card-two-step.tsx +++ b/packages/elements-react/src/components/card/card-two-step.tsx @@ -1,48 +1,26 @@ // Copyright © 2024 Ory Corp // SPDX-License-Identifier: Apache-2.0 -import { - FlowType, - UiNode, - UiNodeGroupEnum, - UiNodeInputAttributes, -} from "@ory/client-fetch" -import { useState } from "react" +import { UiNode, UiNodeGroupEnum } from "@ory/client-fetch" import { useFormContext } from "react-hook-form" import { OryCard, OryCardContent, OryCardFooter } from "." import { useComponents, useNodeSorter, useOryFlow } from "../../context" +import { isGroupImmediateSubmit } from "../../theme/default/utils/form" import { useNodesGroups } from "../../util/ui" import { OryForm } from "../form/form" import { OryCardValidationMessages } from "../form/messages" import { Node } from "../form/nodes/node" import { OryFormSocialButtonsForm } from "../form/social" -import { - filterZeroStepGroups, - getFinalNodes, - isChoosingMethod, -} from "./card-two-step.utils" +import { filterZeroStepGroups, getFinalNodes } from "./card-two-step.utils" import { OryCardHeader } from "./header" -import { isGroupImmediateSubmit } from "../../theme/default/utils/form" - -enum ProcessStep { - ProvideIdentifier, - ChooseAuthMethod, - ExecuteAuthMethod, -} export function OryTwoStepCard() { const { flow: { ui }, - config, } = useOryFlow() - const choosingMethod = isChoosingMethod(ui.nodes) - - const [selectedGroup, setSelectedGroup] = useState< - UiNodeGroupEnum | undefined - >() const { Form } = useComponents() - const { flowType } = useOryFlow() + const { flowType, formState, dispatchFormState } = useOryFlow() const nodeSorter = useNodeSorter() const sortNodes = (a: UiNode, b: UiNode) => nodeSorter(a, b, { flowType }) @@ -66,57 +44,63 @@ export function OryTwoStepCard() { const hasOidc = Boolean(uniqueGroups.groups[UiNodeGroupEnum.Oidc]?.length) const zeroStepGroups = filterZeroStepGroups(ui.nodes) - const finalNodes = getFinalNodes(uniqueGroups.groups, selectedGroup) - - const step = selectedGroup - ? ProcessStep.ExecuteAuthMethod - : choosingMethod - ? ProcessStep.ChooseAuthMethod - : ProcessStep.ProvideIdentifier + const finalNodes = + formState.current === "method_active" + ? getFinalNodes(uniqueGroups.groups, formState.method) + : [] return ( - {step === ProcessStep.ProvideIdentifier && hasOidc && ( + {formState.current === "provide_identifier" && hasOidc && ( )} isGroupImmediateSubmit(method + "") - ? setSelectedGroup(method as UiNodeGroupEnum) + ? dispatchFormState({ + type: "action_select_method", + method: method as UiNodeGroupEnum, + }) : undefined } > - {step === ProcessStep.ProvideIdentifier && + {formState.current === "provide_identifier" && zeroStepGroups .sort(sortNodes) .map((node, k) => )} - {step === ProcessStep.ChooseAuthMethod && ( + {formState.current === "select_method" && ( <> - {flowType === FlowType.Login && ( - - )} + dispatchFormState({ + type: "action_select_method", + method: group, + }) + } /> )} - {step === ProcessStep.ExecuteAuthMethod && ( + {formState.current === "method_active" && ( <> - setSelectedGroup(undefined)} /> + {ui.nodes + .filter((n) => n.type === "script") + .map((node, k) => ( + + ))} {finalNodes.sort(sortNodes).map((node, k) => ( ))} )} + - ) } @@ -147,38 +131,3 @@ function AuthMethodList({ options, setSelectedGroup }: AuthMethodListProps) { /> )) } - -type BackButtonProps = { - onClick?: () => void - href?: string -} - -const BackButton = ({ onClick, href }: BackButtonProps) => { - const { - flow: { ui }, - } = useOryFlow() - const { Node } = useComponents() - - const nodeBackButton = ui.nodes.find( - (node) => - // ("value" in node.attributes && - // node.attributes.value === "profile:back") || - "name" in node.attributes && - node.attributes.name === "identifier" && - node.group === "identifier_first", - ) - - if (!nodeBackButton) { - return null - } - - return ( - - ) -} diff --git a/packages/elements-react/src/components/card/card.tsx b/packages/elements-react/src/components/card/card.tsx index b8842ae8..b0a74dde 100644 --- a/packages/elements-react/src/components/card/card.tsx +++ b/packages/elements-react/src/components/card/card.tsx @@ -2,10 +2,11 @@ // SPDX-License-Identifier: Apache-2.0 import { PropsWithChildren } from "react" -import { useComponents } from "../../context" -import { OryCardHeader } from "./header" import { OryCardFooter } from "." +import { useComponents } from "../../context" import { OryCardContent } from "./content" +import { OryCardHeader } from "./header" +import { OryFormProvider } from "../form/form-provider" export type OryCardRootProps = PropsWithChildren @@ -22,14 +23,20 @@ export type OryCardRootProps = PropsWithChildren export function OryCard({ children }: PropsWithChildren) { const { Card } = useComponents() if (children) { - return {children} + return ( + + {children} + + ) } return ( - - - + + + + + ) } diff --git a/packages/elements-react/src/components/form/form-helpers.test.ts b/packages/elements-react/src/components/form/form-helpers.test.ts new file mode 100644 index 00000000..7458b552 --- /dev/null +++ b/packages/elements-react/src/components/form/form-helpers.test.ts @@ -0,0 +1,73 @@ +// Copyright © 2024 Ory Corp +// SPDX-License-Identifier: Apache-2.0 + +import { unrollTrait } from "./form-helpers" + +describe("unrollTrait", () => { + type UnrollTrait< + T extends string, + V, + > = T extends `${infer Head}.${infer Tail}` + ? { [K in Head]: UnrollTrait } + : { [K in T]: V } + + test("should create a nested structure for a single input", () => { + const input = { name: "a.b.c", value: 42 } + const expected = { a: { b: { c: 42 } } } + const result = unrollTrait(input) + expect(result).toEqual(expected) + }) + + test("should merge with an existing structure", () => { + const input: { name: string; value: number | string | object } = { + name: "a.b.c", + value: 42, + } + const output = { a: { b: { d: "existing" } } } + const expected = { a: { b: { c: 42, d: "existing" } } } + const result = unrollTrait(input, output) + expect(result).toEqual(expected) + }) + + test("should handle single-level keys", () => { + const input = { name: "key", value: "value" } + const expected = { key: "value" } + const result = unrollTrait(input) + expect(result).toEqual(expected) + }) + + test("should handle empty output object", () => { + const input = { name: "x.y.z", value: "value" } + const expected = { x: { y: { z: "value" } } } + const result = unrollTrait(input, {}) + expect(result).toEqual(expected) + }) + + test("should return undefined if the name is empty", () => { + const input = { name: "", value: "value" } + const result = unrollTrait(input) + expect(result).toStrictEqual({}) + }) + + test("should handle input with overlapping keys", () => { + const input1 = { name: "a.b.c", value: 1 } + const input2 = { name: "a.b.d", value: 2 } + const output: Partial> = {} + unrollTrait(input1, output) + unrollTrait(input2, output) + + const expected = { a: { b: { c: 1, d: 2 } } } + expect(output).toEqual(expected) + }) + + test("should not modify the output object for disjoint keys", () => { + const input1 = { name: "p.q", value: 100 } + const input2 = { name: "x.y.z", value: 200 } + const output: Partial> = {} + unrollTrait(input1, output) + unrollTrait(input2, output) + + const expected = { p: { q: 100 }, x: { y: { z: 200 } } } + expect(output).toEqual(expected) + }) +}) diff --git a/packages/elements-react/src/components/form/form-helpers.ts b/packages/elements-react/src/components/form/form-helpers.ts index e98065d5..c6b78d6b 100644 --- a/packages/elements-react/src/components/form/form-helpers.ts +++ b/packages/elements-react/src/components/form/form-helpers.ts @@ -6,19 +6,49 @@ import { FormValues } from "../../types" export function computeDefaultValues(nodes: UiNode[]): FormValues { return nodes.reduce((acc, node) => { - if (isUiNodeInputAttributes(node.attributes)) { - if (node.attributes.name === "method") { - // Do not set the default values for this. - return acc - } - if (node.attributes.type === "submit") { - // Submit buttons are not supposed to be part of the form until the user submits it. + const attrs = node.attributes + + if (isUiNodeInputAttributes(attrs)) { + // Skip the "method" field and "submit" button + if ( + attrs.name === "method" || + attrs.type === "submit" || + typeof attrs.value === "undefined" + ) return acc - } - acc[node.attributes.name] = node.attributes.value ?? "" + // 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 acc }, {}) } + +export function unrollTrait( + input: { name: T; value: V }, + output: Partial> = {}, +): UnrollTrait | undefined { + 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. + // eslint-disable-next-line @typescript-eslint/no-explicit-any + let current: any = output + keys.forEach((key, index) => { + if (!key) return + current = current[key] = + index === keys.length - 1 ? input.value : current[key] || {} + }) + + return output as UnrollTrait +} + +type UnrollTrait = T extends `${infer Head}.${infer Tail}` + ? { [K in Head]: UnrollTrait } + : { [K in T]: V } diff --git a/packages/elements-react/src/components/form/form-provider.tsx b/packages/elements-react/src/components/form/form-provider.tsx new file mode 100644 index 00000000..78e31d88 --- /dev/null +++ b/packages/elements-react/src/components/form/form-provider.tsx @@ -0,0 +1,26 @@ +// Copyright © 2024 Ory Corp +// SPDX-License-Identifier: Apache-2.0 + +import { UiNode, UiNodeGroupEnum } from "@ory/client-fetch" +import { PropsWithChildren } from "react" +import { useOryFlow } from "../../context" +import { FormProvider, useForm } from "react-hook-form" +import { computeDefaultValues } from "./form-helpers" + +export function OryFormProvider({ + children, + nodes, +}: PropsWithChildren & { nodes?: UiNode[] }) { + const flowContainer = useOryFlow() + const defaultNodes = nodes + ? flowContainer.flow.ui.nodes + .filter((node) => node.group === UiNodeGroupEnum.Default) + .concat(nodes) + : flowContainer.flow.ui.nodes + + const methods = useForm({ + // TODO: Generify this, so we have typesafety in the submit handler. + defaultValues: computeDefaultValues(defaultNodes), + }) + return {children} +} diff --git a/packages/elements-react/src/components/form/form.tsx b/packages/elements-react/src/components/form/form.tsx index 33c8688d..38cdc837 100644 --- a/packages/elements-react/src/components/form/form.tsx +++ b/packages/elements-react/src/components/form/form.tsx @@ -2,60 +2,46 @@ // SPDX-License-Identifier: Apache-2.0 import { - UiNode, - UiNodeGroupEnum, + FlowType, + OnRedirectHandler, UpdateLoginFlowBody, UpdateRecoveryFlowBody, UpdateRegistrationFlowBody, UpdateSettingsFlowBody, UpdateVerificationFlowBody, + isUiNodeAnchorAttributes, + isUiNodeImageAttributes, + isUiNodeInputAttributes, + isUiNodeScriptAttributes, } from "@ory/client-fetch" import { ComponentType, PropsWithChildren } from "react" -import { FormProvider, SubmitHandler, useForm } from "react-hook-form" +import { SubmitHandler, useFormContext } from "react-hook-form" import { useIntl } from "react-intl" -import { useOryFlow, useComponents } from "../../context" +import { useComponents, useOryFlow } from "../../context" import { FormValues, OryCardAuthMethodListItemProps, - OryNodeButtonProps, + OryCardLogoProps, OryFormRootProps, + OryFormSectionContentProps, + OryNodeAnchorProps, + OryNodeButtonProps, OryNodeImageProps, OryNodeInputProps, OryNodeLabelProps, - OryNodeAnchorProps, OryNodeTextProps, - OryCurrentIdentifierProps, - OryCardLogoProps, - OryFormSectionContentProps, } from "../../types" -import { OryCardDividerProps } from "../generic/divider" -import { OryFormGroupProps, OryFormGroups } from "./groups" -import { OryMessageContentProps, OryMessageRootProps } from "./messages" -import { - OryFormOidcRootProps, - OryNodeOidcButtonProps, - OryFormOidcButtons, -} from "./social" -import { - FlowType, - OnRedirectHandler, - isUiNodeAnchorAttributes, - isUiNodeImageAttributes, - isUiNodeInputAttributes, - isUiNodeScriptAttributes, -} from "@ory/client-fetch" import { OryFlowContainer } from "../../util" -import { computeDefaultValues } from "./form-helpers" -import { OryCardRootProps } from "../card/card" -import { OryCardFooterProps } from "../card" -import { OryCardContentProps } from "../card/content" import { onSubmitLogin } from "../../util/onSubmitLogin" -import { onSubmitRegistration } from "../../util/onSubmitRegistration" -import { onSubmitVerification } from "../../util/onSubmitVerification" import { onSubmitRecovery } from "../../util/onSubmitRecovery" +import { onSubmitRegistration } from "../../util/onSubmitRegistration" import { onSubmitSettings } from "../../util/onSubmitSettings" +import { onSubmitVerification } from "../../util/onSubmitVerification" +import { OryCardFooterProps } from "../card" +import { OryCardRootProps } from "../card/card" +import { OryCardContentProps } from "../card/content" import { OryPageHeaderProps } from "../generic" -import { OryFormSectionProps } from "./section" +import { OryCardDividerProps } from "../generic/divider" import { OrySettingsOidcProps, OrySettingsPasskeyProps, @@ -63,6 +49,15 @@ import { OrySettingsTotpProps, OrySettingsWebauthnProps, } from "../settings" +import { computeDefaultValues } from "./form-helpers" +import { OryFormGroupProps, OryFormGroups } from "./groups" +import { OryMessageContentProps, OryMessageRootProps } from "./messages" +import { OryFormSectionProps } from "./section" +import { + OryFormOidcButtons, + OryFormOidcRootProps, + OryNodeOidcButtonProps, +} from "./social" /** * A record of all the components that are used in the OryForm component. @@ -79,12 +74,6 @@ export type OryFlowComponents = { * It renders the "Login with Google", "Login with Facebook" etc. buttons. */ OidcButton: ComponentType - /** - * The CurrentIdentifierButton component is rendered whenever a button of group "identifier_first" node is encountered. - * - * It is used to show the current identifier and can allow the user to start a new flow, if they want to. - */ - CurrentIdentifierButton: ComponentType /** * Anchor component, rendered whenever an "anchor" node is encountered */ @@ -233,23 +222,12 @@ export type OryFlowComponentOverrides = DeepPartialTwoLevels export type OryFormProps = PropsWithChildren<{ onAfterSubmit?: (method: string | number | boolean | undefined) => void - nodes?: UiNode[] }> -export function OryForm({ children, onAfterSubmit, nodes }: OryFormProps) { +export function OryForm({ children, onAfterSubmit }: OryFormProps) { const { Form } = useComponents() const flowContainer = useOryFlow() - - const defaultNodes = nodes - ? flowContainer.flow.ui.nodes - .filter((node) => node.group === UiNodeGroupEnum.Default) - .concat(nodes) - : flowContainer.flow.ui.nodes - - const methods = useForm({ - // TODO: Generify this, so we have typesafety in the submit handler. - defaultValues: computeDefaultValues(defaultNodes), - }) + const methods = useFormContext() const intl = useIntl() @@ -393,28 +371,26 @@ export function OryForm({ children, onAfterSubmit, nodes }: OryFormProps) { } return ( - - void methods.handleSubmit(onSubmit)(e)} - > - {children ?? ( - <> - - - - )} - - + void methods.handleSubmit(onSubmit)(e)} + > + {children ?? ( + <> + + + + )} + ) } diff --git a/packages/elements-react/src/components/form/messages.tsx b/packages/elements-react/src/components/form/messages.tsx index e5623c11..d0979765 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] +const messageIdsToHide = [1040009, 1060003, 1080003, 1010014, 1040005] export function OryCardValidationMessages({ ...props }: OryMessageRootProps) { const { flow } = useOryFlow() diff --git a/packages/elements-react/src/components/form/nodes/input.tsx b/packages/elements-react/src/components/form/nodes/input.tsx index cf5d8ffd..e4681a59 100644 --- a/packages/elements-react/src/components/form/nodes/input.tsx +++ b/packages/elements-react/src/components/form/nodes/input.tsx @@ -21,7 +21,6 @@ export const NodeInput = ({ const { Node } = useComponents() const { setValue } = useFormContext() - const nodeType = attributes.type const { onloadTrigger: onloadTrigger, onclickTrigger, @@ -65,15 +64,17 @@ export const NodeInput = ({ const isPinCodeInput = (attrs.name === "code" && node.group === "code") || (attrs.name === "totp_code" && node.group === "totp") - const isResend = node.meta.label?.id === 1070008 + const isResendNode = node.meta.label?.id === 1070008 + const isScreenSelectionNode = + "name" in node.attributes && node.attributes.name === "screen" - switch (nodeType) { + switch (attributes.type) { case UiNodeInputAttributesTypeEnum.Submit: case UiNodeInputAttributesTypeEnum.Button: if (isSocial) { return } - if (isResend) { + if (isResendNode || isScreenSelectionNode) { return null } diff --git a/packages/elements-react/src/components/form/section.tsx b/packages/elements-react/src/components/form/section.tsx index 684ee203..d4192f31 100644 --- a/packages/elements-react/src/components/form/section.tsx +++ b/packages/elements-react/src/components/form/section.tsx @@ -5,6 +5,7 @@ import { PropsWithChildren } from "react" import { useComponents } from "../../context/component" import { OryForm } from "./form" import { UiNode } from "@ory/client-fetch" +import { OryFormProvider } from "./form-provider" export type OryFormSectionProps = PropsWithChildren<{ nodes?: UiNode[] @@ -14,8 +15,10 @@ export function OryFormSection({ children, nodes }: OryFormSectionProps) { const { Card } = useComponents() return ( - - {children} - + + + {children} + + ) } diff --git a/packages/elements-react/src/context/__snapshots__/form-state.test.ts.snap b/packages/elements-react/src/context/__snapshots__/form-state.test.ts.snap new file mode 100644 index 00000000..29f9ce74 --- /dev/null +++ b/packages/elements-react/src/context/__snapshots__/form-state.test.ts.snap @@ -0,0 +1,22 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`should parse state from flow on "action_flow_update" with active method = code 1`] = ` +{ + "current": "method_active", + "method": "code", +} +`; + +exports[`should parse state from flow on "action_flow_update" with active method = code_recovery 1`] = ` +{ + "current": "method_active", + "method": "code", +} +`; + +exports[`should parse state from flow on "action_flow_update" with active method = link_recovery 1`] = ` +{ + "current": "method_active", + "method": "link", +} +`; diff --git a/packages/elements-react/src/context/flow-context.tsx b/packages/elements-react/src/context/flow-context.tsx index 506d5d3a..30c010e0 100644 --- a/packages/elements-react/src/context/flow-context.tsx +++ b/packages/elements-react/src/context/flow-context.tsx @@ -9,6 +9,7 @@ import { useState, } from "react" import { OryFlowContainer } from "../util/flowContainer" +import { FormState, FormStateAction, useFormStateReducer } from "./form-state" /** * Returns an object that contains the current flow and the flow type, as well as the configuration. @@ -27,7 +28,7 @@ export function useOryFlow() { /** * Function to set the flow container. */ -export type FlowContainerSetter = Dispatch> +export type FlowContainerSetter = Dispatch /** * The return value of the OryFlowContext. @@ -37,6 +38,17 @@ export type FlowContextValue = OryFlowContainer & { * Function to set the flow container. */ setFlowContainer: FlowContainerSetter + + /** + * The current form state. + * @see FormState + */ + formState: FormState + + /** + * Dispatch function to update the form state. + */ + dispatchFormState: Dispatch } // This is fine, because we don't export the context itself and guard from it being null in useOryFlow @@ -49,21 +61,22 @@ export function OryFlowProvider({ ...container }: OryFlowProviderProps) { const [flowContainer, setFlowContainer] = useState(container) + const [formState, dispatchFormState] = useFormStateReducer(container) return ( { - setFlowContainer( - (container) => - ({ - ...container, - ...updatedContainer, - }) as OryFlowContainer, - ) + setFlowContainer: (flowContainer) => { + setFlowContainer(flowContainer) + dispatchFormState({ + type: "action_flow_update", + flow: flowContainer, + }) }, + formState, + dispatchFormState, } as FlowContextValue } > diff --git a/packages/elements-react/src/context/form-state.test.ts b/packages/elements-react/src/context/form-state.test.ts new file mode 100644 index 00000000..876d4ce4 --- /dev/null +++ b/packages/elements-react/src/context/form-state.test.ts @@ -0,0 +1,249 @@ +// Copyright © 2024 Ory Corp +// SPDX-License-Identifier: Apache-2.0 + +import { FlowType, UiNodeGroupEnum } from "@ory/client-fetch" +import { act, renderHook } from "@testing-library/react" +import { OryFlowContainer } from "../util" +import { useFormStateReducer } from "./form-state" // Adjust path as needed + +const init = { + flowType: FlowType.Login, + flow: { ui: { nodes: [] } }, +} as unknown as OryFlowContainer +test('should initialize with "provide_identifier" state', () => { + const { result } = renderHook(() => useFormStateReducer(init)) + + const [state] = result.current + expect(state).toEqual({ current: "provide_identifier" }) +}) + +test('should initialize with "settings" state for settings flows', () => { + const { result } = renderHook(() => + useFormStateReducer({ + flowType: FlowType.Settings, + } as unknown as OryFlowContainer), + ) + + const [state] = result.current + expect(state).toEqual({ current: "settings" }) +}) + +test('should transition to "method_active" state when "action_select_method" is dispatched', () => { + const { result } = renderHook(() => useFormStateReducer(init)) + const [, dispatch] = result.current + + act(() => { + dispatch({ + type: "action_select_method", + method: UiNodeGroupEnum.Code, + }) + }) + + const [state] = result.current + expect(state).toEqual({ + current: "method_active", + method: UiNodeGroupEnum.Code, + }) +}) + +const activeMethods = ["link_recovery", "code_recovery", "code"] + +activeMethods.forEach((activeMethod) => { + test(`should parse state from flow on "action_flow_update" with active method = ${activeMethod}`, () => { + const { result } = renderHook(() => useFormStateReducer(init)) + const [, dispatch] = result.current + + const mockFlow = { + flowType: FlowType.Login, + flow: { + active: activeMethod, + ui: { nodes: [] }, // Assuming nodes structure + }, + } as unknown as OryFlowContainer + + act(() => { + dispatch({ + type: "action_flow_update", + flow: mockFlow, + }) + }) + + const [state] = result.current + expect(state).toMatchSnapshot() + }) +}) + +test(`should parse state from flow on "action_flow_update" provide_identifier`, () => { + const { result } = renderHook(() => useFormStateReducer(init)) + const [, dispatch] = result.current + + const mockFlow = { + flowType: FlowType.Login, + flow: { + active: "", + ui: { nodes: [] }, // Assuming nodes structure + }, + } as unknown as OryFlowContainer + + act(() => { + dispatch({ + type: "action_flow_update", + flow: mockFlow, + }) + }) + + const [state] = result.current + expect(state).toEqual({ current: "provide_identifier" }) +}) + +test(`should parse state from flow on "action_flow_update" when choosing method on registration`, () => { + const { result } = renderHook(() => useFormStateReducer(init)) + const [, dispatch] = result.current + + const mockFlow = { + flowType: FlowType.Registration, + flow: { + active: "", + ui: { + nodes: [ + { + attributes: { + name: "screen", + value: "previous", + }, + }, + ], + }, // 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" }) +}) + +test(`should parse state from flow on "action_flow_update" when choosing method on login`, () => { + const { result } = renderHook(() => useFormStateReducer(init)) + const [, dispatch] = result.current + + const mockFlow = { + flowType: FlowType.Login, + flow: { + active: "", + ui: { + nodes: [ + { + group: "identifier_first", + attributes: { + name: "identifier", + type: "hidden", + }, + }, + ], + }, // 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" }) +}) +;[(FlowType.Recovery, FlowType.Verification)].forEach((flowType) => { + test(`should parse state from flow on "action_flow_update" ${flowType} provide_identifier`, () => { + const { result } = renderHook(() => useFormStateReducer(init)) + const [, dispatch] = result.current + + const mockFlow = { + flowType: FlowType.Recovery, + flow: { + state: "choose_method", + active: "code", + ui: { nodes: [] }, // Assuming nodes structure + }, + } as unknown as OryFlowContainer + + act(() => { + dispatch({ + type: "action_flow_update", + flow: mockFlow, + }) + }) + + const [state] = result.current + expect(state).toEqual({ current: "provide_identifier" }) + }) + + test(`should parse state from flow on "action_flow_update" ${flowType} flow active`, () => { + const { result } = renderHook(() => useFormStateReducer(init)) + const [, dispatch] = result.current + + const mockFlow = { + flowType: FlowType.Recovery, + flow: { + state: "sent_email", + active: "code", + ui: { nodes: [] }, // Assuming nodes structure + }, + } as unknown as OryFlowContainer + + act(() => { + dispatch({ + type: "action_flow_update", + flow: mockFlow, + }) + }) + + const [state] = result.current + expect(state).toEqual({ current: "method_active", method: "code" }) + }) +}) + +test('should fallback to "impossible_unknown" for unknown recovery state', () => { + const { result } = renderHook(() => useFormStateReducer(init)) + const [, dispatch] = result.current + + const mockFlow = { + flowType: FlowType.Recovery, // Intentional to simulate an unexpected flow + flow: { id: "unknown", active: null, ui: { nodes: [] } }, + } as unknown as OryFlowContainer + + expect(() => + act(() => { + dispatch({ + type: "action_flow_update", + flow: mockFlow, + }) + }), + ).toThrow("Unknown form state") +}) + +test('should fallback to "impossible_unknown" for unrecognized flow', () => { + const { result } = renderHook(() => useFormStateReducer(init)) + const [, dispatch] = result.current + + const mockFlow = { + flowType: "unknown" as FlowType, // Intentional to simulate an unexpected flow + flow: { id: "unknown", active: null, ui: { nodes: [] } }, + } as unknown as OryFlowContainer + + expect(() => + act(() => { + dispatch({ + type: "action_flow_update", + flow: mockFlow, + }) + }), + ).toThrow("Unknown form state") +}) diff --git a/packages/elements-react/src/context/form-state.ts b/packages/elements-react/src/context/form-state.ts new file mode 100644 index 00000000..44257277 --- /dev/null +++ b/packages/elements-react/src/context/form-state.ts @@ -0,0 +1,75 @@ +// Copyright © 2024 Ory Corp +// SPDX-License-Identifier: Apache-2.0 + +import { FlowType, UiNodeGroupEnum } from "@ory/client-fetch" +import { useReducer } from "react" +import { isChoosingMethod } from "../components/card/card-two-step.utils" +import { OryFlowContainer } from "../util" + +export type FormState = + | { current: "provide_identifier" } + | { current: "select_method" } + | { current: "method_active"; method: UiNodeGroupEnum } + | { current: "success_screen" } + | { current: "settings" } + +export type FormStateAction = + | { + type: "action_flow_update" + flow: OryFlowContainer + } + | { + type: "action_select_method" + method: UiNodeGroupEnum + } + +function parseStateFromFlow(flow: OryFlowContainer): FormState { + switch (flow.flowType) { + case FlowType.Registration: + case FlowType.Login: + if (flow.flow.active == "link_recovery") { + return { current: "method_active", method: "link" } + } else if (flow.flow.active == "code_recovery") { + return { current: "method_active", method: "code" } + } else if (isChoosingMethod(flow.flow.ui.nodes)) { + return { current: "select_method" } + } else if (flow.flow.active) { + return { current: "method_active", method: flow.flow.active } + } + return { current: "provide_identifier" } + case FlowType.Recovery: + case FlowType.Verification: + // The API does not provide types for the active field of the recovery flow + // TODO: Add types for the recovery flow in Kratos + if (flow.flow.active === "code" || flow.flow.active === "link") { + if (flow.flow.state === "choose_method") { + return { current: "provide_identifier" } + } + return { current: "method_active", method: flow.flow.active } + } + break + case FlowType.Settings: + return { current: "settings" } + } + console.warn( + `[Ory/Elements React] Encountered an unknown form state on ${flow.flowType} flow with ID ${flow.flow.id}`, + ) + throw new Error("Unknown form state") +} + +export function formStateReducer( + state: FormState, + action: FormStateAction, +): FormState { + switch (action.type) { + case "action_flow_update": + return parseStateFromFlow(action.flow) + case "action_select_method": + return { current: "method_active", method: action.method } + } + return state +} + +export function useFormStateReducer(flow: OryFlowContainer) { + return useReducer(formStateReducer, parseStateFromFlow(flow)) +} diff --git a/packages/elements-react/src/context/index.tsx b/packages/elements-react/src/context/index.tsx index 23ca85cf..e9d8dc85 100644 --- a/packages/elements-react/src/context/index.tsx +++ b/packages/elements-react/src/context/index.tsx @@ -8,3 +8,5 @@ export { type FlowContainerSetter, } from "./flow-context" export * from "./provider" + +export type { FormState, FormStateAction } from "./form-state" diff --git a/packages/elements-react/src/locales/de.json b/packages/elements-react/src/locales/de.json index 32147dfd..29e9874b 100644 --- a/packages/elements-react/src/locales/de.json +++ b/packages/elements-react/src/locales/de.json @@ -239,5 +239,6 @@ "settings.title-profile": "Profileinstellungen", "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" + "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" } diff --git a/packages/elements-react/src/locales/en.json b/packages/elements-react/src/locales/en.json index 70c2eec0..bb2c5144 100644 --- a/packages/elements-react/src/locales/en.json +++ b/packages/elements-react/src/locales/en.json @@ -239,5 +239,6 @@ "settings.webauthn.info": "Hardware Tokens are used for second-factor authentication or as first-factor with Passkeys", "settings.passkey.title": "Manage Passkeys", "settings.passkey.description": "Manage your passkey settings", - "settings.passkey.info": "Manage your passkey settings" + "settings.passkey.info": "Manage your passkey settings", + "card.footer.select-another-method": "Select another method" } diff --git a/packages/elements-react/src/locales/es.json b/packages/elements-react/src/locales/es.json index 9e1421bc..5f15a9a2 100644 --- a/packages/elements-react/src/locales/es.json +++ b/packages/elements-react/src/locales/es.json @@ -239,5 +239,6 @@ "settings.title-profile": "", "settings.title-totp": "", "settings.title-webauthn": "", - "settings.webauthn.info": "" + "settings.webauthn.info": "", + "card.footer.select-another-method": "" } diff --git a/packages/elements-react/src/locales/fr.json b/packages/elements-react/src/locales/fr.json index 341a9f2c..f8d65087 100644 --- a/packages/elements-react/src/locales/fr.json +++ b/packages/elements-react/src/locales/fr.json @@ -239,5 +239,6 @@ "settings.title-webauthn": "", "settings.webauthn.description": "", "settings.webauthn.info": "", - "settings.webauthn.title": "" + "settings.webauthn.title": "", + "card.footer.select-another-method": "" } diff --git a/packages/elements-react/src/locales/nl.json b/packages/elements-react/src/locales/nl.json index 56608a7f..c8b29986 100644 --- a/packages/elements-react/src/locales/nl.json +++ b/packages/elements-react/src/locales/nl.json @@ -239,5 +239,6 @@ "settings.title-webauthn": "", "settings.webauthn.description": "", "settings.webauthn.info": "", - "settings.webauthn.title": "" + "settings.webauthn.title": "", + "card.footer.select-another-method": "" } diff --git a/packages/elements-react/src/locales/pl.json b/packages/elements-react/src/locales/pl.json index be19465a..4709be66 100644 --- a/packages/elements-react/src/locales/pl.json +++ b/packages/elements-react/src/locales/pl.json @@ -239,5 +239,6 @@ "settings.title-webauthn": "", "settings.webauthn.description": "", "settings.webauthn.info": "", - "settings.webauthn.title": "" + "settings.webauthn.title": "", + "card.footer.select-another-method": "" } diff --git a/packages/elements-react/src/locales/pt.json b/packages/elements-react/src/locales/pt.json index 7ba40d47..f8d719b1 100644 --- a/packages/elements-react/src/locales/pt.json +++ b/packages/elements-react/src/locales/pt.json @@ -239,5 +239,6 @@ "settings.title-webauthn": "", "settings.webauthn.description": "", "settings.webauthn.info": "", - "settings.webauthn.title": "" + "settings.webauthn.title": "", + "card.footer.select-another-method": "" } diff --git a/packages/elements-react/src/locales/sv.json b/packages/elements-react/src/locales/sv.json index 82282e03..6d31bd16 100644 --- a/packages/elements-react/src/locales/sv.json +++ b/packages/elements-react/src/locales/sv.json @@ -239,5 +239,6 @@ "settings.title-webauthn": "Hantera maskinvarutokens", "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" + "settings.webauthn.title": "Hantera maskinvarutokens", + "card.footer.select-another-method": "Välj en annan metod" } diff --git a/packages/elements-react/src/theme/default/components/card/auth-methods.tsx b/packages/elements-react/src/theme/default/components/card/auth-methods.tsx index 5ccb3493..680fd7bd 100644 --- a/packages/elements-react/src/theme/default/components/card/auth-methods.tsx +++ b/packages/elements-react/src/theme/default/components/card/auth-methods.tsx @@ -1,8 +1,8 @@ // Copyright © 2024 Ory Corp // SPDX-License-Identifier: Apache-2.0 -import { useIntl } from "react-intl" import { OryCardAuthMethodListItemProps } from "@ory/elements-react" +import { useIntl } from "react-intl" import code from "../../assets/icons/code.svg" import passkey from "../../assets/icons/passkey.svg" import password from "../../assets/icons/password.svg" diff --git a/packages/elements-react/src/theme/default/components/card/current-identifier-button.test.ts b/packages/elements-react/src/theme/default/components/card/current-identifier-button.test.ts new file mode 100644 index 00000000..819fd8c9 --- /dev/null +++ b/packages/elements-react/src/theme/default/components/card/current-identifier-button.test.ts @@ -0,0 +1,93 @@ +// Copyright © 2024 Ory Corp +// SPDX-License-Identifier: Apache-2.0 + +import { FlowType, UiNode } from "@ory/client-fetch" +import { getBackButtonNode } from "./current-identifier-button" + +test("getBackButtonNode should return the correct node for FlowType.Login", () => { + const nodes = [ + { + attributes: { name: "identifier" }, + group: "identifier_first", + }, + { + attributes: { name: "email" }, + group: "default", + }, + ] as UiNode[] + + const result = getBackButtonNode(FlowType.Login, nodes) + expect(result).toEqual({ + attributes: { name: "identifier" }, + group: "identifier_first", + }) +}) + +test("getBackButtonNode should return the correct node for FlowType.Registration", () => { + const nodes: UiNode[] = [ + { + attributes: { name: "traits.email" }, + group: "default", + }, + { + attributes: { name: "traits.username" }, + group: "default", + }, + ] as UiNode[] + + const result = getBackButtonNode(FlowType.Registration, nodes) + expect(result).toEqual({ + attributes: { name: "traits.email" }, + group: "default", + }) +}) + +test("getBackButtonNode should return the correct node for FlowType.Recovery", () => { + const nodes: UiNode[] = [ + { + attributes: { name: "email" }, + group: "default", + }, + { + attributes: { name: "other" }, + group: "default", + }, + ] as UiNode[] + + const result = getBackButtonNode(FlowType.Recovery, nodes) + expect(result).toEqual({ + attributes: { name: "email" }, + group: "default", + }) +}) + +test("getBackButtonNode should return the correct node for FlowType.Verification", () => { + const nodes: UiNode[] = [ + { + attributes: { name: "email" }, + group: "default", + }, + { + attributes: { name: "traits.username" }, + group: "default", + }, + ] as UiNode[] + + const result = getBackButtonNode(FlowType.Verification, nodes) + expect(result).toEqual({ + attributes: { name: "email" }, + group: "default", + }) +}) + +test("getBackButtonNode should return undefined if no matching node is found", () => { + const nodes: UiNode[] = [ + { + attributes: { name: "non-matching" }, + group: "non-matching-group", + }, + ] as unknown[] as UiNode[] + + const result = getBackButtonNode(FlowType.Login, nodes) + expect(result).toBeUndefined() +}) diff --git a/packages/elements-react/src/theme/default/components/card/current-identifier-button.tsx b/packages/elements-react/src/theme/default/components/card/current-identifier-button.tsx index 52b62a92..ea600bb5 100644 --- a/packages/elements-react/src/theme/default/components/card/current-identifier-button.tsx +++ b/packages/elements-react/src/theme/default/components/card/current-identifier-button.tsx @@ -1,31 +1,100 @@ // Copyright © 2024 Ory Corp // SPDX-License-Identifier: Apache-2.0 -import { OryCurrentIdentifierProps } from "@ory/elements-react" +import { FlowType, UiNode } from "@ory/client-fetch" +import { useOryFlow } from "@ory/elements-react" import IconArrowLeft from "../../assets/icons/arrow-left.svg" +import { omit } from "../../utils/attributes" -export function DefaultCurrentIdentifierButton({ - attributes, - onClick, - type, - href, -}: OryCurrentIdentifierProps) { - const Element = onClick ? "button" : "a" +export function DefaultCurrentIdentifierButton() { + const { + flow: { ui }, + flowType, + config, + formState, + } = useOryFlow() + + if (formState.current === "provide_identifier") { + return null + } + + const nodeBackButton = getBackButtonNode(flowType, ui.nodes) + + if ( + nodeBackButton?.attributes.node_type !== "input" || + !nodeBackButton.attributes.value + ) { + return null + } + const initFlowUrl = `${config.sdk.url}/self-service/${flowType}/browser` + const attributes = omit(nodeBackButton.attributes, [ + "autocomplete", + "node_type", + ]) return (
- - - {attributes.value} + + {nodeBackButton?.attributes.value} - +
) } + +export function getBackButtonNode( + flowType: FlowType, + nodes: UiNode[], +): UiNode | undefined { + let nodeBackButton: UiNode | undefined + switch (flowType) { + case FlowType.Login: + nodeBackButton = nodes.find( + (node) => + "name" in node.attributes && + node.attributes.name === "identifier" && + node.group === "identifier_first", + ) + break + case FlowType.Registration: + nodeBackButton = guessRegistrationBackButton(nodes) + break + case FlowType.Recovery: + case FlowType.Verification: + // Re-use the email node for displaying the email + nodeBackButton = nodes.find( + (n) => "name" in n.attributes && n.attributes.name === "email", + ) + break + } + return nodeBackButton +} + +const backButtonCandiates = [ + "traits.email", + "traits.username", + "traits.phone_number", +] +/** + * Guesses the back button for registration flows + * + * This is based on the list above, and the first node that matches the criteria is returned. + * + * The list is most likely not exhaustive yet, and may need to be updated in the future. + * + */ +function guessRegistrationBackButton(uiNodes: UiNode[]): UiNode | undefined { + return uiNodes.find( + (node) => + "name" in node.attributes && + backButtonCandiates.includes(node.attributes.name) && + node.group === "default", + ) +} diff --git a/packages/elements-react/src/theme/default/components/card/footer.tsx b/packages/elements-react/src/theme/default/components/card/footer.tsx index 4d611359..436b923d 100644 --- a/packages/elements-react/src/theme/default/components/card/footer.tsx +++ b/packages/elements-react/src/theme/default/components/card/footer.tsx @@ -1,9 +1,10 @@ // Copyright © 2024 Ory Corp // SPDX-License-Identifier: Apache-2.0 -import { FlowType } from "@ory/client-fetch" -import { useIntl } from "react-intl" +import { FlowType, UiNode, UiNodeInputAttributes } from "@ory/client-fetch" import { useOryFlow } from "@ory/elements-react" +import { useFormContext } from "react-hook-form" +import { useIntl } from "react-intl" export function DefaultCardFooter() { const { flowType } = useOryFlow() @@ -29,10 +30,15 @@ function getReturnToQueryParam() { } function LoginCardFooter() { - const { config } = useOryFlow() + const { config, formState } = useOryFlow() const intl = useIntl() - if (!config.project.registration_enabled) { + if ( + !config.project.registration_enabled || + formState.current !== "provide_identifier" + ) { + // The two-step login flow does not support the "navigation" between steps, so we don't have + // anything to render on the footer in those steps return null } @@ -61,29 +67,74 @@ function LoginCardFooter() { ) } +function findScreenSelectionButton( + nodes: UiNode[], +): { attributes: UiNodeInputAttributes } | undefined { + return nodes.find( + (node) => + node.attributes.node_type === "input" && + node.attributes.type === "submit" && + node.attributes.name === "screen", + ) as { attributes: UiNodeInputAttributes } +} + function RegistrationCardFooter() { const intl = useIntl() - const { config } = useOryFlow() + const { config, flow, formState } = useOryFlow() + const { setValue } = useFormContext() + + if (formState.current === "select_method") { + return null + } + const screenSelectionNode = findScreenSelectionButton(flow.ui.nodes) + + function handleScreenSelection() { + setValue("method", "profile") + if (screenSelectionNode) { + setValue("screen", "credential-selection") + } + } + let loginLink = `${config.sdk.url}/self-service/login/browser` const returnTo = getReturnToQueryParam() if (returnTo) { loginLink += `?return_to=${returnTo}` } + return ( - {intl.formatMessage({ - id: "registration.login-label", - defaultMessage: "Already have an account?", - })}{" "} - - {intl.formatMessage({ - id: "registration.login-button", - defaultMessage: "Sign in", - })} - + {formState.current === "method_active" ? ( + <> + {screenSelectionNode && ( + + )} + + ) : ( + <> + {intl.formatMessage({ + id: "registration.login-label", + defaultMessage: "Already have an account?", + })}{" "} + + {intl.formatMessage({ + id: "registration.login-button", + defaultMessage: "Sign in", + })} + + + )} ) } 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 23ba05a4..d08148ea 100644 --- a/packages/elements-react/src/theme/default/components/card/header.tsx +++ b/packages/elements-react/src/theme/default/components/card/header.tsx @@ -3,17 +3,19 @@ import { useComponents, useOryFlow } from "@ory/elements-react" import { useCardHeaderText } from "../../utils/constructCardHeader" +import { DefaultCurrentIdentifierButton } from "./current-identifier-button" function InnerCardHeader({ title, text }: { title: string; text?: string }) { const { Card } = useComponents() return (
-
+

{title}

{text}

+
) diff --git a/packages/elements-react/src/theme/default/components/card/index.tsx b/packages/elements-react/src/theme/default/components/card/index.tsx index 3185ebe3..3f43a930 100644 --- a/packages/elements-react/src/theme/default/components/card/index.tsx +++ b/packages/elements-react/src/theme/default/components/card/index.tsx @@ -7,6 +7,7 @@ import { DefaultCardContent } from "./content" import { DefaultCardFooter } from "./footer" import { DefaultCardHeader } from "./header" import { DefaultCardLogo } from "./logo" +import { DefaultCurrentIdentifierButton } from "./current-identifier-button" export function DefaultCard({ children }: OryCardProps) { return ( @@ -24,4 +25,5 @@ export { DefaultCardFooter, DefaultCardHeader, DefaultCardLogo, + DefaultCurrentIdentifierButton, } diff --git a/packages/elements-react/src/theme/default/components/default-components.tsx b/packages/elements-react/src/theme/default/components/default-components.tsx index 5db2b700..19000f50 100644 --- a/packages/elements-react/src/theme/default/components/default-components.tsx +++ b/packages/elements-react/src/theme/default/components/default-components.tsx @@ -1,6 +1,10 @@ // Copyright © 2024 Ory Corp // SPDX-License-Identifier: Apache-2.0 +import { + OryFlowComponentOverrides, + OryFlowComponents, +} from "@ory/elements-react" import { DefaultCard, DefaultCardContent, @@ -23,27 +27,22 @@ import { DefaultInput } from "./form/input" import { DefaultLabel } from "./form/label" import { DefaultLinkButton } from "./form/link-button" import { DefaultPinCodeInput } from "./form/pin-code-input" -import { - DefaultButtonSocial, - DefaultSocialButtonContainer, -} from "./form/social" -import { DefaultText } from "./form/text" -import { DefaultCurrentIdentifierButton } from "./card/current-identifier-button" -import { - OryFlowComponentOverrides, - OryFlowComponents, -} from "@ory/elements-react" import { DefaultFormSection, DefaultFormSectionContent, DefaultFormSectionFooter, } from "./form/section" +import { + DefaultButtonSocial, + DefaultSocialButtonContainer, +} from "./form/social" +import { DefaultText } from "./form/text" +import { DefaultPageHeader } from "./generic/page-header" +import { DefaultSettingsOidc } from "./settings/settings-oidc" +import { DefaultSettingsPasskey } from "./settings/settings-passkey" import { DefaultSettingsRecoveryCodes } from "./settings/settings-recovery-codes" import { DefaultSettingsTotp } from "./settings/settings-top" -import { DefaultSettingsOidc } from "./settings/settings-oidc" import { DefaultSettingsWebauthn } from "./settings/settings-webauthn" -import { DefaultSettingsPasskey } from "./settings/settings-passkey" -import { DefaultPageHeader } from "./generic/page-header" export function getOryComponents( overrides?: OryFlowComponentOverrides, @@ -69,9 +68,6 @@ export function getOryComponents( Node: { Button: overrides?.Node?.Button ?? DefaultButton, OidcButton: overrides?.Node?.OidcButton ?? DefaultButtonSocial, - CurrentIdentifierButton: - overrides?.Node?.CurrentIdentifierButton ?? - DefaultCurrentIdentifierButton, Input: overrides?.Node?.Input ?? DefaultInput, CodeInput: overrides?.Node?.CodeInput ?? DefaultPinCodeInput, Image: overrides?.Node?.Image ?? DefaultImage, diff --git a/packages/elements-react/src/theme/default/components/form/input.tsx b/packages/elements-react/src/theme/default/components/form/input.tsx index 5204670a..9d6aeb45 100644 --- a/packages/elements-react/src/theme/default/components/form/input.tsx +++ b/packages/elements-react/src/theme/default/components/form/input.tsx @@ -18,7 +18,14 @@ export const DefaultInput = ({ }: OryNodeInputProps) => { const label = getNodeLabel(node) const { register } = useFormContext() - const { value, autocomplete, name, maxlength, ...rest } = attributes + const { + value, + autocomplete, + name, + maxlength, + node_type: _, + ...rest + } = attributes const intl = useIntl() const { flowType } = useOryFlow() diff --git a/packages/elements-react/src/theme/default/components/form/label.tsx b/packages/elements-react/src/theme/default/components/form/label.tsx index d72aa80b..2c4a873e 100644 --- a/packages/elements-react/src/theme/default/components/form/label.tsx +++ b/packages/elements-react/src/theme/default/components/form/label.tsx @@ -19,14 +19,19 @@ export function DefaultLabel({ }: OryNodeLabelProps) { const intl = useIntl() const label = getNodeLabel(node) - const { config, flowType } = useOryFlow() + const { config, flowType, flow } = useOryFlow() const isPassword = attributes.type === "password" - const isCode = attributes.name === "code" + const hasResendNode = flow.ui.nodes.some( + (n) => + "name" in n.attributes && + n.attributes.name === "email" && + n.attributes.type === "submit", + ) return ( - +
{label && (
) } 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 803312c8..689d16ee 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 @@ -198,14 +198,14 @@ exports[`flowType=login refresh=true combination=webauthn constructCardHeaderTex exports[`flowType=recovery refresh=false combination=code constructCardHeaderText 1`] = ` { - "description": "Enter the email address associated with your account to receive a one-time access code", + "description": "An email containing a recovery code has been sent to the email address you provided. If you have not received an email, check the spelling of the address and make sure to use the address you registered with.", "title": "Recover your account", } `; exports[`flowType=recovery refresh=false combination=default_and_code constructCardHeaderText 1`] = ` { - "description": "Enter the email address associated with your account to receive a one-time access code", + "description": "An email containing a recovery code has been sent to the email address you provided. If you have not received an email, check the spelling of the address and make sure to use the address you registered with.", "title": "Recover your account", } `; @@ -296,14 +296,14 @@ exports[`flowType=recovery refresh=false combination=webauthn constructCardHeade exports[`flowType=recovery refresh=true combination=code constructCardHeaderText 1`] = ` { - "description": "Enter the email address associated with your account to receive a one-time access code", + "description": "An email containing a recovery code has been sent to the email address you provided. If you have not received an email, check the spelling of the address and make sure to use the address you registered with.", "title": "Recover your account", } `; exports[`flowType=recovery refresh=true combination=default_and_code constructCardHeaderText 1`] = ` { - "description": "Enter the email address associated with your account to receive a one-time access code", + "description": "An email containing a recovery code has been sent to the email address you provided. If you have not received an email, check the spelling of the address and make sure to use the address you registered with.", "title": "Recover your account", } `; @@ -786,14 +786,14 @@ exports[`flowType=settings refresh=true combination=webauthn constructCardHeader exports[`flowType=verification refresh=false combination=code constructCardHeaderText 1`] = ` { - "description": "Enter the email address associated with your account to verify it", + "description": "An email containing a verification code has been sent to the email address you provided. If you have not received an email, check the spelling of the address and make sure to use the address you registered with.", "title": "Verify your account", } `; exports[`flowType=verification refresh=false combination=default_and_code constructCardHeaderText 1`] = ` { - "description": "Enter the email address associated with your account to verify it", + "description": "An email containing a verification code has been sent to the email address you provided. If you have not received an email, check the spelling of the address and make sure to use the address you registered with.", "title": "Verify your account", } `; @@ -884,14 +884,14 @@ exports[`flowType=verification refresh=false combination=webauthn constructCardH exports[`flowType=verification refresh=true combination=code constructCardHeaderText 1`] = ` { - "description": "Enter the email address associated with your account to verify it", + "description": "An email containing a verification code has been sent to the email address you provided. If you have not received an email, check the spelling of the address and make sure to use the address you registered with.", "title": "Verify your account", } `; exports[`flowType=verification refresh=true combination=default_and_code constructCardHeaderText 1`] = ` { - "description": "Enter the email address associated with your account to verify it", + "description": "An email containing a verification code has been sent to the email address you provided. If you have not received an email, check the spelling of the address and make sure to use the address you registered with.", "title": "Verify your account", } `; diff --git a/packages/elements-react/src/theme/default/utils/__tests__/attributes.test.ts b/packages/elements-react/src/theme/default/utils/__tests__/attributes.test.ts new file mode 100644 index 00000000..0b95fc8a --- /dev/null +++ b/packages/elements-react/src/theme/default/utils/__tests__/attributes.test.ts @@ -0,0 +1,34 @@ +// Copyright © 2024 Ory Corp +// SPDX-License-Identifier: Apache-2.0 + +import { omit } from "../attributes" + +test("deletes keys from object", () => { + const obj = { + a: 1, + b: 2, + c: 3, + } + const result = omit(obj, ["a", "c"]) + expect(result).toEqual({ b: 2 }) +}) + +test("ignores unknown keys", () => { + const obj = { + a: 1, + b: 2, + c: 3, + } + const result = omit(obj, ["a", "x"] as (keyof typeof obj)[]) + expect(result).toEqual({ b: 2, c: 3 }) +}) + +test("returns object if keys are empty", () => { + const obj = { + a: 1, + b: 2, + c: 3, + } + const result = omit(obj, []) + expect(result).toEqual({ a: 1, b: 2, c: 3 }) +}) diff --git a/packages/elements-react/src/theme/default/utils/attributes.ts b/packages/elements-react/src/theme/default/utils/attributes.ts new file mode 100644 index 00000000..ee2ea28a --- /dev/null +++ b/packages/elements-react/src/theme/default/utils/attributes.ts @@ -0,0 +1,13 @@ +// Copyright © 2024 Ory Corp +// SPDX-License-Identifier: Apache-2.0 + +export function omit( + obj: OBJ, + keys: (keyof OBJ)[], +): Omit { + const ret = { ...obj } + for (const key of keys) { + delete ret[key] + } + return ret +} diff --git a/packages/elements-react/src/theme/default/utils/constructCardHeader.ts b/packages/elements-react/src/theme/default/utils/constructCardHeader.ts index 94059f36..772a280e 100644 --- a/packages/elements-react/src/theme/default/utils/constructCardHeader.ts +++ b/packages/elements-react/src/theme/default/utils/constructCardHeader.ts @@ -54,6 +54,21 @@ export function useCardHeaderText( const intl = useIntl() switch (opts.flowType) { case FlowType.Recovery: + if ( + nodes.find( + (node) => + "name" in node.attributes && node.attributes.name === "code", + ) + ) { + return { + title: intl.formatMessage({ + id: "recovery.title", + }), + description: intl.formatMessage({ + id: "identities.messages.1060003", + }), + } + } return { title: intl.formatMessage({ id: "recovery.title", @@ -72,6 +87,21 @@ export function useCardHeaderText( }), } case FlowType.Verification: + if ( + nodes.find( + (node) => + "name" in node.attributes && node.attributes.name === "code", + ) + ) { + return { + title: intl.formatMessage({ + id: "verification.title", + }), + description: intl.formatMessage({ + id: "identities.messages.1080003", + }), + } + } return { title: intl.formatMessage({ id: "verification.title", diff --git a/packages/elements-react/src/types.ts b/packages/elements-react/src/types.ts index d46e34e2..562f46c6 100644 --- a/packages/elements-react/src/types.ts +++ b/packages/elements-react/src/types.ts @@ -22,13 +22,6 @@ export type OryNodeButtonProps = { } & Omit, "children"> & ButtonVariants -export type OryCurrentIdentifierProps = { - attributes: UiNodeInputAttributes - node: UiNode - onClick?: () => void - href?: string -} & Omit, "children" | "onClick"> - export type OryNodeAnchorProps = { attributes: UiNodeAnchorAttributes node: UiNode diff --git a/packages/elements-react/src/util/ui/__test__/ui.spec.ts b/packages/elements-react/src/util/ui/__test__/ui.spec.ts index 49bf215c..cbd67db7 100644 --- a/packages/elements-react/src/util/ui/__test__/ui.spec.ts +++ b/packages/elements-react/src/util/ui/__test__/ui.spec.ts @@ -16,7 +16,7 @@ describe("utils/ui", () => { expect(result.current.groups.oidc).toHaveLength(2) expect(result.current.groups.default).toHaveLength(2) - expect(result.current.groups.webauthn).toHaveLength(2) + expect(result.current.groups.webauthn).toHaveLength(1) expect(result.current.groups.passkey).toHaveLength(3) expect(result.current.groups.password).toHaveLength(2) expect(result.current.groups.code).toHaveLength(1) diff --git a/packages/elements-react/src/util/ui/index.ts b/packages/elements-react/src/util/ui/index.ts index 40c86f51..a60b8b71 100644 --- a/packages/elements-react/src/util/ui/index.ts +++ b/packages/elements-react/src/util/ui/index.ts @@ -48,10 +48,10 @@ export function triggerToWindowCall( // Retry every 250ms for 5 seconds let i = 0 - const ms = 250 + const ms = 100 const interval = setInterval(() => { i++ - if (i > 20) { + if (i > 100) { clearInterval(interval) throw new Error( "Unable to load Ory's WebAuthn script. Is it being blocked or otherwise failing to load? If you are running an old version of Ory Elements, please upgrade. For more information, please check your browser's developer console.", @@ -107,6 +107,11 @@ export function useNodesGroups(nodes: UiNode[]) { const groups: Partial> = {} for (const node of nodes) { + if (node.type === "script") { + // We always render all scripts, because the scripts for passkeys are part of the webauthn group, + // which leads to this hook returning a webauthn group on passkey flows (which it should not - webauthn is the "legacy" passkey implementation). + continue + } const groupNodes = groups[node.group] ?? [] groupNodes.push(node) groups[node.group] = groupNodes