From 9578d423f526dd48688a81161aeabae95d45ea73 Mon Sep 17 00:00:00 2001 From: aeneasr <3372410+aeneasr@users.noreply.github.com> Date: Wed, 18 Dec 2024 11:33:30 +0100 Subject: [PATCH 1/6] feat: do not show two-step selector if only one method exists --- examples/nextjs-app-router/README.md | 14 ++++- examples/nextjs-pages-router/README.md | 12 +++- .../elements-react/src/context/form-state.ts | 7 +++ packages/elements-react/src/util/ui/index.ts | 58 ++++++++++++++++++- 4 files changed, 86 insertions(+), 5 deletions(-) diff --git a/examples/nextjs-app-router/README.md b/examples/nextjs-app-router/README.md index 1491ba83..45f44200 100644 --- a/examples/nextjs-app-router/README.md +++ b/examples/nextjs-app-router/README.md @@ -13,7 +13,7 @@ router. > [!WARNING] -> For convinience Ory provides a default "playground" project, that +> For convenience Ory provides a default "playground" project, that > can be used to interact with Ory's APIs. It is a public project, that can be > used by anyone and data can be deleted at any time. Make sure to use a > dedicated project. @@ -34,10 +34,20 @@ router. The project files reside in the `app/` directory: - `app/auth` - contains the page files for the user auth flows -- `app/settings` - contaisn the page file for the settings flow +- `app/settings` - contains the page file for the settings flow - `app` - contains the root page file and layout. ## Need help? If you have any issues using this examples, or Ory's products, don't hesitate to reach out via the [Ory Community Slack](https://slack.ory.sh). + +## Run against local Ory Network instance + +This section is relevant to Ory engineers only. When running a local Ory Network +instance, you will need to disable TLS verification and set the +`NEXT_PUBLIC_ORY_SDK_URL` to `https://.projects.oryapis:8080`: + +```sh +NODE_TLS_REJECT_UNAUTHORIZED=0 npm run dev +``` diff --git a/examples/nextjs-pages-router/README.md b/examples/nextjs-pages-router/README.md index 4218b4f9..a6697cfc 100644 --- a/examples/nextjs-pages-router/README.md +++ b/examples/nextjs-pages-router/README.md @@ -23,7 +23,7 @@ router. > [!WARNING] -> For convinience Ory provides a default "playground" project, that +> For convenience Ory provides a default "playground" project, that > can be used to interact with Ory's APIs. It is a public project, that can be > used by anyone and data can be deleted at any time. Make sure to use a > dedicated project. @@ -33,3 +33,13 @@ router. If you have any issues using this examples, or Ory's products, don't hesitate to reach out via the [Ory Community Slack](https://slack.ory.sh). + +## Run against local Ory Network instance + +This section is relevant to Ory engineers only. When running a local Ory Network +instance, you will need to disable TLS verification and set the +`NEXT_PUBLIC_ORY_SDK_URL` to `https://.projects.oryapis:8080`: + +```sh +NODE_TLS_REJECT_UNAUTHORIZED=0 npm run dev +``` diff --git a/packages/elements-react/src/context/form-state.ts b/packages/elements-react/src/context/form-state.ts index aeaea0d7..0a84e9f7 100644 --- a/packages/elements-react/src/context/form-state.ts +++ b/packages/elements-react/src/context/form-state.ts @@ -5,6 +5,7 @@ import { FlowType, UiNode, UiNodeGroupEnum } from "@ory/client-fetch" import { useReducer } from "react" import { isChoosingMethod } from "../components/card/card-two-step.utils" import { OryFlowContainer } from "../util" +import { nodesToAuthMethodGroups } from "../util/ui" export type FormState = | { current: "provide_identifier" } @@ -46,6 +47,12 @@ function parseStateFromFlow(flow: OryFlowContainer): FormState { ) { return { current: "method_active", method: flow.flow.active } } else if (isChoosingMethod(flow.flow.ui.nodes)) { + // Login has a special case where we only have one method. Here, we + // do not want to display the chooser. + const authMethods = nodesToAuthMethodGroups(flow.flow.ui.nodes) + if (authMethods.length === 1) { + return { current: "method_active", method: authMethods[0] } + } return { current: "select_method" } } else if (flow.flow.ui.messages?.some((m) => m.id === 1010016)) { // Account linking edge case diff --git a/packages/elements-react/src/util/ui/index.ts b/packages/elements-react/src/util/ui/index.ts index 9769a0bb..38745bab 100644 --- a/packages/elements-react/src/util/ui/index.ts +++ b/packages/elements-react/src/util/ui/index.ts @@ -4,11 +4,11 @@ import { UiNode } from "@ory/client-fetch" import type { - UiNodeGroupEnum, UiNodeInputAttributesOnclickTriggerEnum, UiNodeInputAttributesOnloadTriggerEnum, UiNodeInputAttributesTypeEnum, } from "@ory/client-fetch" +import { UiNodeGroupEnum } from "@ory/client-fetch" import { useMemo } from "react" import { useGroupSorter } from "../../context/component" @@ -98,7 +98,61 @@ type Entries = { [K in keyof T]: [K, T[K]] }[keyof T][] -export function useNodesGroups(nodes: UiNode[]) { +/** + * Returns a list of auth methods from a list of nodes. For example, + * if Password and Passkey are present, it will return [password, passkey]. + * + * Please note that OIDC is not considered an auth method because it is + * usually shown as a separate auth method + * + * This method the default, identifier_first, and profile groups. + * + * @param nodes The nodes to extract the auth methods from + * @param excludeAuthMethods A list of auth methods to exclude + */ +export function nodesToAuthMethodGroups( + nodes: Array, + excludeAuthMethods = [UiNodeGroupEnum.Oidc], +): UiNodeGroupEnum[] { + 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 + } + + return Object.values(UiNodeGroupEnum) + .filter((group) => groups[group]?.length) + .filter( + (group) => + !( + [ + UiNodeGroupEnum.Default, + UiNodeGroupEnum.IdentifierFirst, + UiNodeGroupEnum.Profile, + ...excludeAuthMethods, + ] as UiNodeGroupEnum[] + ).includes(group), + ) +} + +type NodeGroups = { + groups: Partial> + entries: Entries>> +} + +/** + * Groups nodes by their group and returns an object with the groups and entries. + * + * @param nodes + */ +export function useNodesGroups(nodes: UiNode[]): NodeGroups { const groupSorter = useGroupSorter() const groups = useMemo(() => { From e31c0614c912cba7ee3f45ec75cc751219ddc582 Mon Sep 17 00:00:00 2001 From: aeneasr <3372410+aeneasr@users.noreply.github.com> Date: Fri, 20 Dec 2024 09:50:23 +0100 Subject: [PATCH 2/6] feat: user experience improvements and e2e test coverage This patch resolves several issues: - Adds test IDs to components used by e2e selectors - Transports the return URL across flows (login/registration, recovery) --- .../default/components/card/auth-methods.tsx | 3 +-- .../src/theme/default/components/card/footer.tsx | 15 +++++++++++---- .../src/theme/default/components/form/input.tsx | 1 + .../src/theme/default/components/form/label.tsx | 12 +++++++++++- .../theme/default/components/form/link-button.tsx | 1 + .../src/theme/default/components/form/social.tsx | 1 + .../src/theme/default/components/form/text.tsx | 4 ++-- packages/elements-react/src/util/ui/index.ts | 7 +------ 8 files changed, 29 insertions(+), 15 deletions(-) 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 d66939ac..c74089b3 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 @@ -30,8 +30,7 @@ export function DefaultAuthMethodListItem({ className="flex cursor-pointer gap-3 py-2 text-left items-start" onClick={onClick} type={isGroupImmediateSubmit(group) ? "submit" : "button"} - id={`auth-method-list-item-${group}`} - data-testid="auth-method-list-item" + data-testid="ory/ui/groups/auth-methods" aria-label={`Authenticate with ${group}`} > 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 4ecede3f..674b35c1 100644 --- a/packages/elements-react/src/theme/default/components/card/footer.tsx +++ b/packages/elements-react/src/theme/default/components/card/footer.tsx @@ -22,7 +22,12 @@ export function DefaultCardFooter() { } } -function getReturnToQueryParam() { +export function getReturnToQueryParam(flow: { + return_to?: string +}) { + if (flow.return_to) { + return flow.return_to + } if (typeof window !== "undefined") { const searchParams = new URLSearchParams(window.location.search) return searchParams.get("return_to") @@ -30,7 +35,7 @@ function getReturnToQueryParam() { } function LoginCardFooter() { - const { config, formState } = useOryFlow() + const { config, formState, flow} = useOryFlow() const intl = useIntl() if ( @@ -43,7 +48,7 @@ function LoginCardFooter() { } let registrationLink = `${config.sdk.url}/self-service/registration/browser` - const returnTo = getReturnToQueryParam() + const returnTo = getReturnToQueryParam(flow) if (returnTo) { registrationLink += `?return_to=${returnTo}` } @@ -57,6 +62,7 @@ function LoginCardFooter() { {intl.formatMessage({ id: "login.registration-button", @@ -96,7 +102,7 @@ function RegistrationCardFooter() { } let loginLink = `${config.sdk.url}/self-service/login/browser` - const returnTo = getReturnToQueryParam() + const returnTo = getReturnToQueryParam(flow) if (returnTo) { loginLink += `?return_to=${returnTo}` } @@ -127,6 +133,7 @@ function RegistrationCardFooter() { {intl.formatMessage({ id: "registration.login-button", 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 3f22c79d..d333476f 100644 --- a/packages/elements-react/src/theme/default/components/form/input.tsx +++ b/packages/elements-react/src/theme/default/components/form/input.tsx @@ -48,6 +48,7 @@ export const DefaultInput = ({ maxLength={maxlength} autoComplete={autocomplete} placeholder={formattedLabel} + data-testid={`ory/ui/node/input/${name}`} className={cn( "antialiased rounded-forms border leading-tight transition-colors placeholder:h-[20px] placeholder:text-input-foreground-tertiary focus-visible:outline-none focus:ring-0", "bg-input-background-default border-input-border-default text-input-foreground-primary", 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 70d3c1bc..00bf9698 100644 --- a/packages/elements-react/src/theme/default/components/form/label.tsx +++ b/packages/elements-react/src/theme/default/components/form/label.tsx @@ -16,6 +16,7 @@ import { } from "@ory/elements-react" import { useFormContext } from "react-hook-form" import { useIntl } from "react-intl" +import { getReturnToQueryParam } from "../card/footer" function findResendNode(nodes: UiNode[]) { return nodes.find( @@ -50,6 +51,14 @@ export function DefaultLabel({ const fieldError = formState.errors[attributes.name] + const recoveryUrl = config.project.recovery_ui_url + + let recoveryLink = `${config.sdk.url}/self-service/registration/browser` + const returnTo = getReturnToQueryParam(flow) + if (returnTo) { + recoveryLink += `?return_to=${returnTo}` + } + return (
{label && ( @@ -58,6 +67,7 @@ export function DefaultLabel({ {...messageTestId(label)} className="leading-normal text-input-foreground-primary" htmlFor={attributes.name} + data-testid={`ory/ui/node/input/label/${attributes.name}`} {...rest} > {uiTextToFormattedMessage(label, intl)} @@ -67,7 +77,7 @@ export function DefaultLabel({ flowType === FlowType.Login && ( // TODO: make it possible to override with a custom component {intl.formatMessage({ diff --git a/packages/elements-react/src/theme/default/components/form/link-button.tsx b/packages/elements-react/src/theme/default/components/form/link-button.tsx index 54837e3c..bfa6ab98 100644 --- a/packages/elements-react/src/theme/default/components/form/link-button.tsx +++ b/packages/elements-react/src/theme/default/components/form/link-button.tsx @@ -21,6 +21,7 @@ export const DefaultLinkButton = forwardRef< {...attributes} ref={ref} title={label ? uiTextToFormattedMessage(label, intl) : ""} + data-testid={`ory/ui/node/link/${attributes.id}`} className={cn( "antialiased rounded cursor-pointer text-center border gap-3 leading-none bg-button-primary-background-default hover:bg-button-primary-background-hover transition-colors text-button-primary-foreground-default hover:text-button-primary-foreground-hover p-4 font-medium", )} diff --git a/packages/elements-react/src/theme/default/components/form/social.tsx b/packages/elements-react/src/theme/default/components/form/social.tsx index 6d3becd8..81bd70a2 100644 --- a/packages/elements-react/src/theme/default/components/form/social.tsx +++ b/packages/elements-react/src/theme/default/components/form/social.tsx @@ -71,6 +71,7 @@ export function DefaultButtonSocial({ value={attributes.value} type="submit" name="provider" + data-testid={`ory/ui/node/input/${attributes.name}`} {...props} onClick={onClick} disabled={isSubmitting} diff --git a/packages/elements-react/src/theme/default/components/form/text.tsx b/packages/elements-react/src/theme/default/components/form/text.tsx index 15e8065a..5bbf28be 100644 --- a/packages/elements-react/src/theme/default/components/form/text.tsx +++ b/packages/elements-react/src/theme/default/components/form/text.tsx @@ -9,7 +9,7 @@ export function DefaultText({ node, attributes }: OryNodeTextProps) { const intl = useIntl() return ( <> -

+

{node.meta.label ? uiTextToFormattedMessage(node.meta.label, intl) : ""}

{( @@ -17,7 +17,7 @@ export function DefaultText({ node, attributes }: OryNodeTextProps) { secrets: UiText[] } ).secrets?.map((text: UiText, index) => ( -
+        
           {text ? uiTextToFormattedMessage(text, intl) : ""}
         
))} diff --git a/packages/elements-react/src/util/ui/index.ts b/packages/elements-react/src/util/ui/index.ts index 38745bab..8a352367 100644 --- a/packages/elements-react/src/util/ui/index.ts +++ b/packages/elements-react/src/util/ui/index.ts @@ -142,17 +142,12 @@ export function nodesToAuthMethodGroups( ) } -type NodeGroups = { - groups: Partial> - entries: Entries>> -} - /** * Groups nodes by their group and returns an object with the groups and entries. * * @param nodes */ -export function useNodesGroups(nodes: UiNode[]): NodeGroups { +export function useNodesGroups(nodes: UiNode[]) { const groupSorter = useGroupSorter() const groups = useMemo(() => { From 348ba527b53b4d0ba6ff68661c5f32b9c3a5c380 Mon Sep 17 00:00:00 2001 From: aeneasr <3372410+aeneasr@users.noreply.github.com> Date: Fri, 20 Dec 2024 14:08:40 +0100 Subject: [PATCH 3/6] test: additional tests and improved codedocs --- .../card/current-identifier-button.tsx | 17 +++-- .../theme/default/components/card/footer.tsx | 23 ++----- .../theme/default/components/form/label.tsx | 11 +--- .../theme/default/components/form/text.tsx | 5 +- .../theme/default/utils/__tests__/url.spec.ts | 64 +++++++++++++++++++ .../src/theme/default/utils/url.ts | 45 +++++++++++++ .../elements-react/src/util/onSubmitLogin.ts | 3 +- packages/elements-react/src/util/test-id.ts | 2 +- 8 files changed, 133 insertions(+), 37 deletions(-) create mode 100644 packages/elements-react/src/theme/default/utils/__tests__/url.spec.ts create mode 100644 packages/elements-react/src/theme/default/utils/url.ts 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 c775e19c..24bd8474 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 @@ -5,14 +5,11 @@ 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" +import { restartFlowUrl } from "../../utils/url" export function DefaultCurrentIdentifierButton() { - const { - flow: { ui }, - flowType, - config, - formState, - } = useOryFlow() + const { flow, flowType, config, formState } = useOryFlow() + const ui = flow.ui if (formState.current === "provide_identifier") { return null @@ -26,7 +23,12 @@ export function DefaultCurrentIdentifierButton() { ) { return null } - const initFlowUrl = `${config.sdk.url}/self-service/${flowType}/browser` + + const initFlowUrl = restartFlowUrl( + flow, + `${config.sdk.url}/self-service/${flowType}/browser`, + ) + const attributes = omit(nodeBackButton.attributes, [ "autocomplete", "node_type", @@ -41,6 +43,7 @@ export function DefaultCurrentIdentifierButton() { {...attributes} href={initFlowUrl} title={`Adjust ${nodeBackButton?.attributes.value}`} + data-testid={"ory/ui/login/link/restart"} > {intl.formatMessage({ @@ -61,7 +54,7 @@ function LoginCardFooter() { })}{" "}
{intl.formatMessage({ @@ -101,12 +94,6 @@ function RegistrationCardFooter() { } } - let loginLink = `${config.sdk.url}/self-service/login/browser` - const returnTo = getReturnToQueryParam(flow) - if (returnTo) { - loginLink += `?return_to=${returnTo}` - } - return ( {formState.current === "method_active" ? ( @@ -132,7 +119,7 @@ function RegistrationCardFooter() { })}{" "} {intl.formatMessage({ 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 00bf9698..f46f63f8 100644 --- a/packages/elements-react/src/theme/default/components/form/label.tsx +++ b/packages/elements-react/src/theme/default/components/form/label.tsx @@ -17,6 +17,7 @@ import { import { useFormContext } from "react-hook-form" import { useIntl } from "react-intl" import { getReturnToQueryParam } from "../card/footer" +import { initFlowUrl } from "../../utils/url" function findResendNode(nodes: UiNode[]) { return nodes.find( @@ -51,14 +52,6 @@ export function DefaultLabel({ const fieldError = formState.errors[attributes.name] - const recoveryUrl = config.project.recovery_ui_url - - let recoveryLink = `${config.sdk.url}/self-service/registration/browser` - const returnTo = getReturnToQueryParam(flow) - if (returnTo) { - recoveryLink += `?return_to=${returnTo}` - } - return (
{label && ( @@ -77,7 +70,7 @@ export function DefaultLabel({ flowType === FlowType.Login && ( // TODO: make it possible to override with a custom component {intl.formatMessage({ diff --git a/packages/elements-react/src/theme/default/components/form/text.tsx b/packages/elements-react/src/theme/default/components/form/text.tsx index 5bbf28be..61bd8c70 100644 --- a/packages/elements-react/src/theme/default/components/form/text.tsx +++ b/packages/elements-react/src/theme/default/components/form/text.tsx @@ -17,7 +17,10 @@ export function DefaultText({ node, attributes }: OryNodeTextProps) { secrets: UiText[] } ).secrets?.map((text: UiText, index) => ( -
+        
           {text ? uiTextToFormattedMessage(text, intl) : ""}
         
))} diff --git a/packages/elements-react/src/theme/default/utils/__tests__/url.spec.ts b/packages/elements-react/src/theme/default/utils/__tests__/url.spec.ts new file mode 100644 index 00000000..61492ec0 --- /dev/null +++ b/packages/elements-react/src/theme/default/utils/__tests__/url.spec.ts @@ -0,0 +1,64 @@ +// Copyright © 2024 Ory Corp +// SPDX-License-Identifier: Apache-2.0 + +import { restartFlowUrl, initFlowUrl } from "../url" + +describe("url utils", () => { + describe("restartFlowUrl", () => { + it("should return request_url if present", () => { + const flow = { request_url: "http://example.com/request" } + const fallback = "http://example.com/fallback" + expect(restartFlowUrl(flow, fallback)).toBe(flow.request_url) + }) + + it("should return fallback with return_to if request_url is not present", () => { + const flow = { return_to: "http://example.com/return" } + const fallback = "http://example.com/fallback" + expect(restartFlowUrl(flow, fallback)).toBe( + "http://example.com/fallback?return_to=http%3A%2F%2Fexample.com%2Freturn", + ) + }) + + it("should return fallback if neither request_url nor return_to are present", () => { + const flow = {} + const fallback = "http://example.com/fallback" + expect(restartFlowUrl(flow, fallback)).toBe(fallback) + }) + }) + + describe("initFlowUrl", () => { + it("should return sdkUrl with flowType and return_to if present in flow", () => { + const sdkUrl = "http://example.com" + const flowType = "login" + const flow = { return_to: "http://example.com/return" } + expect(initFlowUrl(sdkUrl, flowType, flow)).toBe( + "http://example.com/self-service/login/browser?return_to=http%3A%2F%2Fexample.com%2Freturn", + ) + }) + + xit("should return sdkUrl with flowType and return_to if present in window location", () => { + const sdkUrl = "http://example.com" + const flowType = "login" + const flow = {} + + // Not sure how to mock this. + ;(window.location.href = + "http://example.com?return_to=http://example.com/return"), + expect(initFlowUrl(sdkUrl, flowType, flow)).toBe( + "http://example.com/self-service/login/browser?return_to=http%3A%2F%2Fexample.com%2Freturn", + ) + }) + + xit("should return sdkUrl with flowType if return_to is not present", () => { + const sdkUrl = "http://example.com" + const flowType = "login" + const flow = {} + + // Not sure how to mock this. + window.location.href = "http://example.com" + expect(initFlowUrl(sdkUrl, flowType, flow)).toBe( + "http://example.com/self-service/login/browser", + ) + }) + }) +}) diff --git a/packages/elements-react/src/theme/default/utils/url.ts b/packages/elements-react/src/theme/default/utils/url.ts new file mode 100644 index 00000000..ffd95043 --- /dev/null +++ b/packages/elements-react/src/theme/default/utils/url.ts @@ -0,0 +1,45 @@ +// Copyright © 2024 Ory Corp +// SPDX-License-Identifier: Apache-2.0 + +export function restartFlowUrl( + flow: { request_url?: string; return_to?: string }, + fallback: string, +) { + return flow.request_url || appendReturnTo(fallback, flow.return_to) +} + +export function initFlowUrl( + sdkUrl: string, + flowType: string, + flow: { + return_to?: string + }, +) { + const result = `${sdkUrl}/self-service/${flowType}/browser` + const qs = new URLSearchParams() + + if (flow.return_to) { + qs.set("return_to", flow.return_to) + } else if (typeof window !== "undefined") { + const searchParams = new URLSearchParams(window.location.search) + if (searchParams.has("return_to")) { + qs.set("return_to", searchParams.get("return_to") || "") + } + } + + if (qs.toString().length === 0) { + return result + } + + return result + "?" + qs.toString() +} + +function appendReturnTo(url: string, returnTo?: string) { + if (!returnTo) { + return url + } + + const urlObj = new URL(url) + urlObj.searchParams.set("return_to", returnTo) + return urlObj.toString() +} diff --git a/packages/elements-react/src/util/onSubmitLogin.ts b/packages/elements-react/src/util/onSubmitLogin.ts index 52543b1e..3f97fc48 100644 --- a/packages/elements-react/src/util/onSubmitLogin.ts +++ b/packages/elements-react/src/util/onSubmitLogin.ts @@ -41,7 +41,8 @@ export async function onSubmitLogin( updateLoginFlowBody: body, }) .then(() => { - // Workaround + // TODO Remove this workaround. If the return_to value is missing we redirect to the browser endpoint which will redirect us + // TODO to the default_redirect_url. Ideally, this value comes from the project config. window.location.href = // eslint-disable-next-line promise/always-return flow.return_to ?? config.sdk.url + "/self-service/login/browser" diff --git a/packages/elements-react/src/util/test-id.ts b/packages/elements-react/src/util/test-id.ts index 40c8c70b..51cc5c7f 100644 --- a/packages/elements-react/src/util/test-id.ts +++ b/packages/elements-react/src/util/test-id.ts @@ -11,6 +11,6 @@ import { UiText } from "@ory/client-fetch" */ export function messageTestId(message: UiText) { return { - "data-testid": `ory-message-${message.id}`, + "data-testid": `ory/ui/message/${message.id}`, } } From 040fba19196f0551c4864dcf0706cbe9657e51ba Mon Sep 17 00:00:00 2001 From: aeneasr <3372410+aeneasr@users.noreply.github.com> Date: Mon, 23 Dec 2024 15:48:06 +0100 Subject: [PATCH 4/6] fix: better validation for code method --- .../src/components/card/card-two-step.tsx | 2 +- .../src/components/form/form-resolver.ts | 26 ++++++++++++++++++- .../src/components/form/nodes/input.tsx | 17 ++++++++++-- .../theme/default/components/form/button.tsx | 3 --- .../theme/default/components/form/label.tsx | 1 - 5 files changed, 41 insertions(+), 8 deletions(-) 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 a6823594..6f06bdac 100644 --- a/packages/elements-react/src/components/card/card-two-step.tsx +++ b/packages/elements-react/src/components/card/card-two-step.tsx @@ -118,7 +118,7 @@ function AuthMethodList({ options, setSelectedGroup }: AuthMethodListProps) { const handleClick = (group: UiNodeGroupEnum) => { if (isGroupImmediateSubmit(group)) { // If the method is "immediate submit" (e.g. the method's submit button should be triggered immediately) - // then the methid needs to be added to the form data. + // then the method needs to be added to the form data. setValue("method", group) } else { setSelectedGroup(group) diff --git a/packages/elements-react/src/components/form/form-resolver.ts b/packages/elements-react/src/components/form/form-resolver.ts index 822cc563..6f6cd6b6 100644 --- a/packages/elements-react/src/components/form/form-resolver.ts +++ b/packages/elements-react/src/components/form/form-resolver.ts @@ -3,8 +3,10 @@ import { useOryFlow } from "../../context" import { FormValues } from "../../types" +import { isUiNodeInputAttributes } from "@ory/client-fetch" function isCodeResendRequest(data: FormValues) { + // There are two types of resend - one return data.email ?? data.resend } @@ -20,10 +22,32 @@ export function useOryFormResolver() { return (data: FormValues) => { if (flowContainer.formState.current === "method_active") { - if (data.method === "code" && !data.code && !isCodeResendRequest(data)) { + // This is a workaround which prevents the flow from being submitted without a code, + // which in some cases can cause issues in Ory Kratos' resend detection. + if ( + // When we submit a code + data.method === "code" && + // And the code is not present + !data.code && + // And the flow is not a code resend request + !isCodeResendRequest(data) && + // And the flow has a code input node + flowContainer.flow.ui.nodes.find(({ attributes, group }) => { + if (!isUiNodeInputAttributes(attributes)) { + return false + } + + return ( + group === "code" && + attributes.name === "code" && + attributes.type !== "hidden" + ) + }) + ) { return { values: data, errors: { + // We know the code node exists, so we can safely hardcode the ID. code: { id: 4000002, context: { diff --git a/packages/elements-react/src/components/form/nodes/input.tsx b/packages/elements-react/src/components/form/nodes/input.tsx index ff3649c0..b7691ffa 100644 --- a/packages/elements-react/src/components/form/nodes/input.tsx +++ b/packages/elements-react/src/components/form/nodes/input.tsx @@ -79,13 +79,26 @@ export const NodeInput = ({ } return ( - + + + ) case UiNodeInputAttributesTypeEnum.DatetimeLocal: throw new Error("Not implemented") case UiNodeInputAttributesTypeEnum.Checkbox: return ( - + // The label is rendered in the checkbox component + + + ) case UiNodeInputAttributesTypeEnum.Hidden: return diff --git a/packages/elements-react/src/theme/default/components/form/button.tsx b/packages/elements-react/src/theme/default/components/form/button.tsx index 9889da99..4a1abaf8 100644 --- a/packages/elements-react/src/theme/default/components/form/button.tsx +++ b/packages/elements-react/src/theme/default/components/form/button.tsx @@ -81,9 +81,6 @@ export const DefaultButton = ({ value={value} name={name} type={type === "button" ? "button" : "submit"} // TODO - onSubmit={() => { - setValue(name, value) - }} onClick={(e) => { onClick?.(e) 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 f46f63f8..38e97353 100644 --- a/packages/elements-react/src/theme/default/components/form/label.tsx +++ b/packages/elements-react/src/theme/default/components/form/label.tsx @@ -51,7 +51,6 @@ export function DefaultLabel({ } const fieldError = formState.errors[attributes.name] - return (
{label && ( From ef8de07144f59c7f3d11d952d1fc3f710306e952 Mon Sep 17 00:00:00 2001 From: aeneasr <3372410+aeneasr@users.noreply.github.com> Date: Mon, 23 Dec 2024 17:35:28 +0100 Subject: [PATCH 5/6] test: better test ids --- .../src/theme/default/components/card/auth-methods.tsx | 2 +- .../elements-react/src/theme/default/components/form/label.tsx | 1 - 2 files changed, 1 insertion(+), 2 deletions(-) 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 c74089b3..366f12d0 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 @@ -30,7 +30,7 @@ export function DefaultAuthMethodListItem({ className="flex cursor-pointer gap-3 py-2 text-left items-start" onClick={onClick} type={isGroupImmediateSubmit(group) ? "submit" : "button"} - data-testid="ory/ui/groups/auth-methods" + data-testid={`ory/ui/groups/auth-method/${group}`} aria-label={`Authenticate with ${group}`} > 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 38e97353..3c00f28e 100644 --- a/packages/elements-react/src/theme/default/components/form/label.tsx +++ b/packages/elements-react/src/theme/default/components/form/label.tsx @@ -16,7 +16,6 @@ import { } from "@ory/elements-react" import { useFormContext } from "react-hook-form" import { useIntl } from "react-intl" -import { getReturnToQueryParam } from "../card/footer" import { initFlowUrl } from "../../utils/url" function findResendNode(nodes: UiNode[]) { From 39a6367fbe764567639a175eaa5015251cd74780 Mon Sep 17 00:00:00 2001 From: aeneasr <3372410+aeneasr@users.noreply.github.com> Date: Fri, 10 Jan 2025 15:06:18 +0100 Subject: [PATCH 6/6] feat: handle state transition edge cases --- package-lock.json | 25 ++++++++----------- packages/elements-react/package.json | 2 +- .../components/form/form-resolver.test.tsx | 15 ++++++++++- .../src/components/form/nodes/input.tsx | 3 +-- .../theme/default/components/card/footer.tsx | 2 +- .../src/theme/default/flows/error.tsx | 6 ++++- .../theme/default/utils/__tests__/url.spec.ts | 1 + .../elements-react/src/util/i18n/index.ts | 8 +++--- packages/elements-react/src/util/internal.ts | 8 ++++++ .../elements-react/src/util/onSubmitLogin.ts | 9 +++++-- .../src/util/onSubmitRecovery.ts | 11 +++++--- .../src/util/onSubmitRegistration.ts | 9 +++++-- .../src/util/onSubmitSettings.ts | 9 +++++-- .../src/util/onSubmitVerification.ts | 9 +++++-- packages/elements-react/src/util/ui/index.ts | 6 ++--- packages/nextjs/package.json | 2 +- 16 files changed, 86 insertions(+), 39 deletions(-) create mode 100644 packages/elements-react/src/util/internal.ts diff --git a/package-lock.json b/package-lock.json index 0e49d72f..16f929b7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -26888,16 +26888,15 @@ } }, "node_modules/nanoid": { - "version": "3.3.7", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.7.tgz", - "integrity": "sha512-eSRppjcPIatRIMC1U6UngP8XFcz8MQWGQdt1MTBQ7NaAmvXDfvNxbvWV3x2y6CdEUciCSsDHDQZbhYaB8QEo2g==", + "version": "3.3.8", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.8.tgz", + "integrity": "sha512-WNLf5Sd8oZxOm+TzppcYk8gVOgP+l58xNy58D0nbUnOxOWRWvlcCV4kUF7ltmI6PsrLl/BgKEyS4mqsGChFN0w==", "funding": [ { "type": "github", "url": "https://github.com/sponsors/ai" } ], - "license": "MIT", "bin": { "nanoid": "bin/nanoid.cjs" }, @@ -34757,7 +34756,7 @@ "version": "1.0.0-next.19", "license": "Apache License 2.0", "dependencies": { - "@ory/client-fetch": "^1.15.12", + "@ory/client-fetch": "~1.16.1", "@radix-ui/react-dropdown-menu": "2.1.2", "class-variance-authority": "0.7.0", "clsx": "2.1.1", @@ -35251,10 +35250,9 @@ } }, "packages/elements-react/node_modules/@ory/client-fetch": { - "version": "1.15.12", - "resolved": "https://registry.npmjs.org/@ory/client-fetch/-/client-fetch-1.15.12.tgz", - "integrity": "sha512-oY6v1ywYfFH6DtgZG/bWLkn0elBUDkxBk9ToaaX3gF5WmwN2I37JKElteZGV7GchzA9uCUJ5BmWgHvRnv3hj3A==", - "license": "Apache-2.0" + "version": "1.16.1", + "resolved": "https://registry.npmjs.org/@ory/client-fetch/-/client-fetch-1.16.1.tgz", + "integrity": "sha512-xQC9QnAu0EbDTD0VT74RJIszAFQ4cvJyXvN6nktsXebwFaotgoBI95b75S2+wCwvXYzqiY5pefZqrksfxuowjQ==" }, "packages/elements-react/node_modules/@types/react": { "version": "18.3.11", @@ -35622,7 +35620,7 @@ "version": "1.0.0-next.0", "license": "Apache License 2.0", "dependencies": { - "@ory/client-fetch": "^1.15.6", + "@ory/client-fetch": "~1.16.0", "cookie": "^1.0.1", "psl": "^1.15.0", "set-cookie-parser": "^2.7.1" @@ -36014,10 +36012,9 @@ } }, "packages/nextjs/node_modules/@ory/client-fetch": { - "version": "1.15.10", - "resolved": "https://registry.npmjs.org/@ory/client-fetch/-/client-fetch-1.15.10.tgz", - "integrity": "sha512-PKKi9XjjdCah0pVz/4WB3G41ttA4tuXuOLGrdAJR63iFKTiRtINocVCdXzmI9GzYI/kjhjKxwbRTs1pXCY6H7A==", - "license": "Apache-2.0" + "version": "1.16.0", + "resolved": "https://registry.npmjs.org/@ory/client-fetch/-/client-fetch-1.16.0.tgz", + "integrity": "sha512-jjSCXffX03MMKClsq0YoeoBqF+ATk1LEd5sQjOLVG9PuzIzuxKjRPYF8tKCLe0ca8GbgmSySqbEcE7bq7LS4fg==" }, "packages/nextjs/node_modules/cookie": { "version": "1.0.1", diff --git a/packages/elements-react/package.json b/packages/elements-react/package.json index bb37688e..49157459 100644 --- a/packages/elements-react/package.json +++ b/packages/elements-react/package.json @@ -36,7 +36,7 @@ "module": "./dist/index.mjs", "types": "./dist/index.d.ts", "dependencies": { - "@ory/client-fetch": "^1.15.12", + "@ory/client-fetch": "~1.16.1", "@radix-ui/react-dropdown-menu": "2.1.2", "class-variance-authority": "0.7.0", "clsx": "2.1.1", diff --git a/packages/elements-react/src/components/form/form-resolver.test.tsx b/packages/elements-react/src/components/form/form-resolver.test.tsx index bf3d88b9..a9ad2aac 100644 --- a/packages/elements-react/src/components/form/form-resolver.test.tsx +++ b/packages/elements-react/src/components/form/form-resolver.test.tsx @@ -39,7 +39,20 @@ const wrapper = ({ children }: PropsWithChildren) => ( flow={ { active: "code", - ui: { nodes: [], action: "", method: "" }, + ui: { + nodes: [ + { + group: "code", + attributes: { + node_type: "input", + name: "code", + type: "text", + }, + }, + ], + action: "", + method: "", + }, } as unknown as LoginFlow // Fine, we're just testing the resolver } flowType={FlowType.Login} diff --git a/packages/elements-react/src/components/form/nodes/input.tsx b/packages/elements-react/src/components/form/nodes/input.tsx index b7691ffa..605eb4c0 100644 --- a/packages/elements-react/src/components/form/nodes/input.tsx +++ b/packages/elements-react/src/components/form/nodes/input.tsx @@ -91,9 +91,8 @@ export const NodeInput = ({ throw new Error("Not implemented") case UiNodeInputAttributesTypeEnum.Checkbox: return ( - // The label is rendered in the checkbox component 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 86afbb62..6f0db2a4 100644 --- a/packages/elements-react/src/theme/default/components/card/footer.tsx +++ b/packages/elements-react/src/theme/default/components/card/footer.tsx @@ -5,7 +5,7 @@ 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" -import { initFlowUrl, restartFlowUrl } from "../../utils/url" +import { initFlowUrl } from "../../utils/url" export function DefaultCardFooter() { const { flowType } = useOryFlow() diff --git a/packages/elements-react/src/theme/default/flows/error.tsx b/packages/elements-react/src/theme/default/flows/error.tsx index d450bd24..06fbc935 100644 --- a/packages/elements-react/src/theme/default/flows/error.tsx +++ b/packages/elements-react/src/theme/default/flows/error.tsx @@ -19,5 +19,9 @@ export function Error({ error, children, }: PropsWithChildren) { - return
{JSON.stringify(error) || children}
+ return ( +
+ {JSON.stringify(error) || children} +
+ ) } diff --git a/packages/elements-react/src/theme/default/utils/__tests__/url.spec.ts b/packages/elements-react/src/theme/default/utils/__tests__/url.spec.ts index 61492ec0..ef0fecd9 100644 --- a/packages/elements-react/src/theme/default/utils/__tests__/url.spec.ts +++ b/packages/elements-react/src/theme/default/utils/__tests__/url.spec.ts @@ -42,6 +42,7 @@ describe("url utils", () => { const flow = {} // Not sure how to mock this. + // eslint-disable-next-line @typescript-eslint/no-unused-expressions ;(window.location.href = "http://example.com?return_to=http://example.com/return"), expect(initFlowUrl(sdkUrl, flowType, flow)).toBe( diff --git a/packages/elements-react/src/util/i18n/index.ts b/packages/elements-react/src/util/i18n/index.ts index fa0da887..f04ecf4f 100644 --- a/packages/elements-react/src/util/i18n/index.ts +++ b/packages/elements-react/src/util/i18n/index.ts @@ -69,16 +69,16 @@ export const uiTextToFormattedMessage = ( new Date(value), new Date(), ), - [key + "_since_minutes"]: Math.abs( + [key + "_since_minutes"]: Math.ceil( (value - new Date().getTime() / 1000) / 60, - ).toFixed(2), + ).toFixed(0), [key + "_until"]: intl.formatDateTimeRange( new Date(), new Date(value), ), - [key + "_until_minutes"]: Math.abs( + [key + "_until_minutes"]: Math.ceil( (new Date().getTime() / 1000 - value) / 60, - ).toFixed(2), + ).toFixed(0), } } } diff --git a/packages/elements-react/src/util/internal.ts b/packages/elements-react/src/util/internal.ts new file mode 100644 index 00000000..5a033165 --- /dev/null +++ b/packages/elements-react/src/util/internal.ts @@ -0,0 +1,8 @@ +// Copyright © 2025 Ory Corp +// SPDX-License-Identifier: Apache-2.0 + +export function replaceWindowFlowId(flow: string) { + const url = new URL(window.location.href) + url.searchParams.set("flow", flow) + window.location.href = url.toString() +} diff --git a/packages/elements-react/src/util/onSubmitLogin.ts b/packages/elements-react/src/util/onSubmitLogin.ts index 3f97fc48..da35be27 100644 --- a/packages/elements-react/src/util/onSubmitLogin.ts +++ b/packages/elements-react/src/util/onSubmitLogin.ts @@ -11,6 +11,7 @@ import { import { OnSubmitHandlerProps } from "./submitHandler" import { OryFlowContainer } from "./flowContainer" import { frontendClient } from "./client" +import { replaceWindowFlowId } from "./internal" /** * Use this method to submit a login flow. This method is used in the `onSubmit` handler of the login form. @@ -49,8 +50,12 @@ export async function onSubmitLogin( }) .catch( handleFlowError({ - onRestartFlow: () => { - onRedirect(loginUrl(config), true) + onRestartFlow: (useFlowId?: string) => { + if (useFlowId) { + replaceWindowFlowId(useFlowId) + } else { + onRedirect(loginUrl(config), true) + } }, onValidationError: (body: LoginFlow) => { setFlowContainer({ diff --git a/packages/elements-react/src/util/onSubmitRecovery.ts b/packages/elements-react/src/util/onSubmitRecovery.ts index 61459626..6b915419 100644 --- a/packages/elements-react/src/util/onSubmitRecovery.ts +++ b/packages/elements-react/src/util/onSubmitRecovery.ts @@ -17,6 +17,7 @@ import { frontendClient } from "./client" import { OryClientConfiguration } from "./clientConfiguration" import { OryFlowContainer } from "./flowContainer" import { OnSubmitHandlerProps } from "./submitHandler" +import { replaceWindowFlowId } from "./internal" /** * Use this method to submit a recovery flow. This method is used in the `onSubmit` handler of the recovery form. @@ -24,7 +25,7 @@ import { OnSubmitHandlerProps } from "./submitHandler" * @param config - The configuration object. * @param flow - The flow object. * @param setFlowContainer - This method is used to update the flow container when a validation error occurs, for example. - * @param body- The form values to submit. + * @param body - The form values to submit. * @param onRedirect - This method is used to redirect the user to a different page. */ export async function onSubmitRecovery( @@ -66,8 +67,12 @@ export async function onSubmitRecovery( }) .catch( handleFlowError({ - onRestartFlow: () => { - onRedirect(recoveryUrl(config), true) + onRestartFlow: (useFlowId) => { + if (useFlowId) { + replaceWindowFlowId(useFlowId) + } else { + onRedirect(recoveryUrl(config), true) + } }, onValidationError: (body: RecoveryFlow | { error: GenericError }) => { if ("error" in body) { diff --git a/packages/elements-react/src/util/onSubmitRegistration.ts b/packages/elements-react/src/util/onSubmitRegistration.ts index e6633d7d..a7206fc9 100644 --- a/packages/elements-react/src/util/onSubmitRegistration.ts +++ b/packages/elements-react/src/util/onSubmitRegistration.ts @@ -12,6 +12,7 @@ import { import { OryFlowContainer } from "./flowContainer" import { OnSubmitHandlerProps } from "./submitHandler" import { frontendClient } from "./client" +import { replaceWindowFlowId } from "./internal" /** * Use this method to submit a registration flow. This method is used in the `onSubmit` handler of the registration form. @@ -60,8 +61,12 @@ export async function onSubmitRegistration( }) .catch( handleFlowError({ - onRestartFlow: () => { - onRedirect(registrationUrl(config), true) + onRestartFlow: (useFlowId) => { + if (useFlowId) { + replaceWindowFlowId(useFlowId) + } else { + onRedirect(registrationUrl(config), true) + } }, onValidationError: (body: RegistrationFlow) => { setFlowContainer({ diff --git a/packages/elements-react/src/util/onSubmitSettings.ts b/packages/elements-react/src/util/onSubmitSettings.ts index bf9ac6f9..c3bbb79b 100644 --- a/packages/elements-react/src/util/onSubmitSettings.ts +++ b/packages/elements-react/src/util/onSubmitSettings.ts @@ -14,6 +14,7 @@ import { import { OryFlowContainer } from "./flowContainer" import { OnSubmitHandlerProps } from "./submitHandler" import { frontendClient } from "./client" +import { replaceWindowFlowId } from "./internal" /** * Use this method to submit a settings flow. This method is used in the `onSubmit` handler of the settings form. @@ -64,8 +65,12 @@ export async function onSubmitSettings( }) .catch( handleFlowError({ - onRestartFlow: () => { - onRedirect(settingsUrl(config), true) + onRestartFlow: (useFlowId) => { + if (useFlowId) { + replaceWindowFlowId(useFlowId) + } else { + onRedirect(settingsUrl(config), true) + } }, onValidationError: (body: SettingsFlow) => { setFlowContainer({ diff --git a/packages/elements-react/src/util/onSubmitVerification.ts b/packages/elements-react/src/util/onSubmitVerification.ts index 5ebee301..fa82c5dd 100644 --- a/packages/elements-react/src/util/onSubmitVerification.ts +++ b/packages/elements-react/src/util/onSubmitVerification.ts @@ -11,6 +11,7 @@ import { import { OryFlowContainer } from "./flowContainer" import { OnSubmitHandlerProps } from "./submitHandler" import { frontendClient } from "./client" +import { replaceWindowFlowId } from "./internal" /** * Use this method to submit a verification flow. This method is used in the `onSubmit` handler of the verification form. @@ -49,8 +50,12 @@ export async function onSubmitVerification( ) .catch( handleFlowError({ - onRestartFlow: () => { - onRedirect(verificationUrl(config), true) + onRestartFlow: (useFlowId) => { + if (useFlowId) { + replaceWindowFlowId(useFlowId) + } else { + onRedirect(verificationUrl(config), true) + } }, onValidationError: (body: VerificationFlow) => { setFlowContainer({ diff --git a/packages/elements-react/src/util/ui/index.ts b/packages/elements-react/src/util/ui/index.ts index 8a352367..42533e55 100644 --- a/packages/elements-react/src/util/ui/index.ts +++ b/packages/elements-react/src/util/ui/index.ts @@ -107,8 +107,8 @@ type Entries = { * * This method the default, identifier_first, and profile groups. * - * @param nodes The nodes to extract the auth methods from - * @param excludeAuthMethods A list of auth methods to exclude + * @param nodes - The nodes to extract the auth methods from + * @param excludeAuthMethods - A list of auth methods to exclude */ export function nodesToAuthMethodGroups( nodes: Array, @@ -145,7 +145,7 @@ export function nodesToAuthMethodGroups( /** * Groups nodes by their group and returns an object with the groups and entries. * - * @param nodes + * @param nodes - The nodes to group */ export function useNodesGroups(nodes: UiNode[]) { const groupSorter = useGroupSorter() diff --git a/packages/nextjs/package.json b/packages/nextjs/package.json index b49c0bce..8968a5f4 100644 --- a/packages/nextjs/package.json +++ b/packages/nextjs/package.json @@ -8,7 +8,7 @@ "types": "./dist/index.d.ts", "private": false, "dependencies": { - "@ory/client-fetch": "^1.15.6", + "@ory/client-fetch": "~1.16.0", "cookie": "^1.0.1", "psl": "^1.15.0", "set-cookie-parser": "^2.7.1"