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..6497ffcc 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: \"impossible_unknown\";\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..8884a6fe 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: "impossible_unknown"; +}; + +// @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..f9816d49 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,59 @@ 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) + : [] + console.log(formState) 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)} /> {finalNodes.sort(sortNodes).map((node, k) => ( ))} )} + - ) } @@ -147,38 +127,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..18b8807c --- /dev/null +++ b/packages/elements-react/src/components/form/form-helpers.test.ts @@ -0,0 +1,70 @@ +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..dda41c66 100644 --- a/packages/elements-react/src/components/form/form-helpers.ts +++ b/packages/elements-react/src/components/form/form-helpers.ts @@ -6,19 +6,44 @@ 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. - return acc - } - - acc[node.attributes.name] = node.attributes.value ?? "" + const attrs = node.attributes + + if (isUiNodeInputAttributes(attrs)) { + // Skip the "method" field and "submit" button + if (attrs.name === "method" || attrs.type === "submit") 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 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..8ac9ba25 --- /dev/null +++ b/packages/elements-react/src/components/form/form-provider.tsx @@ -0,0 +1,23 @@ +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..e8b19e49 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, @@ -66,14 +65,16 @@ export const NodeInput = ({ (attrs.name === "code" && node.group === "code") || (attrs.name === "totp_code" && node.group === "totp") const isResend = node.meta.label?.id === 1070008 + const isScreenSelection = + "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 (isResend || isScreenSelection) { 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..c8dcfaa0 --- /dev/null +++ b/packages/elements-react/src/context/form-state.test.ts @@ -0,0 +1,237 @@ +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 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 + + act(() => { + dispatch({ + type: "action_flow_update", + flow: mockFlow, + }) + }) + + const [state] = result.current + expect(state).toEqual({ current: "impossible_unknown" }) +}) + +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 + + act(() => { + dispatch({ + type: "action_flow_update", + flow: mockFlow, + }) + }) + + const [state] = result.current + expect(state).toEqual({ current: "impossible_unknown" }) +}) 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..b9fbf904 --- /dev/null +++ b/packages/elements-react/src/context/form-state.ts @@ -0,0 +1,70 @@ +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: "impossible_unknown" } + +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 (flow.flow.active) { + return { current: "method_active", method: flow.flow.active } + } else if (isChoosingMethod(flow.flow.ui.nodes)) { + return { current: "select_method" } + } + 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 + } + console.warn( + `[Ory/Elements React] Encountered an unknown form state on ${flow.flowType} flow with ID ${flow.flow.id}`, + ) + return { current: "impossible_unknown" } +} + +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..d32c98b1 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 versuchen" } 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/current-identifier-button.tsx b/packages/elements-react/src/theme/default/components/card/current-identifier-button.tsx index 52b62a92..a0bffd66 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,88 @@ // 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" -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 + } + + let nodeBackButton: UiNode | undefined + switch (flowType) { + case FlowType.Login: + nodeBackButton = ui.nodes.find( + (node) => + "name" in node.attributes && + node.attributes.name === "identifier" && + node.group === "identifier_first", + ) + break + case FlowType.Registration: + nodeBackButton = guessRegistrationBackButton(ui.nodes) + break + case FlowType.Recovery: + case FlowType.Verification: + // Re-use the email node for displaying the email + nodeBackButton = ui.nodes.find( + (n) => "name" in n.attributes && n.attributes.name === "email", + ) + break + } + + if ( + nodeBackButton?.attributes.node_type !== "input" || + !nodeBackButton.attributes.value + ) { + return null + } + const initFlowUrl = `${config.sdk.url}/self-service/${flowType}/browser` return (
- - - {attributes.value} + + {nodeBackButton?.attributes.value} - +
) } + +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..58ef55c6 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,70 @@ 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" ? ( + + ) : ( + <> + {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/label.tsx b/packages/elements-react/src/theme/default/components/form/label.tsx index d72aa80b..cfcd94bd 100644 --- a/packages/elements-react/src/theme/default/components/form/label.tsx +++ b/packages/elements-react/src/theme/default/components/form/label.tsx @@ -19,11 +19,16 @@ 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 hasResend = flow.ui.nodes.some( + (n) => + "name" in n.attributes && + n.attributes.name === "email" && + n.attributes.type === "submit", + ) return ( @@ -51,7 +56,7 @@ export function DefaultLabel({ })} )} - {isCode && ( + {hasResend && (