Skip to content

Commit

Permalink
feat: passwordless strategy
Browse files Browse the repository at this point in the history
  • Loading branch information
hperl committed Dec 11, 2023
1 parent 36e85dd commit 1816fa4
Show file tree
Hide file tree
Showing 10 changed files with 159 additions and 57 deletions.
2 changes: 2 additions & 0 deletions src/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,7 @@
"settings.navigation-profile": "Profile",
"settings.navigation-totp": "Authenticator App",
"settings.navigation-webauthn": "Hardware Tokens",
"settings.navigation-passkey": "Passkeys",
"settings.subtitle-instructions": "Here you can manage settings related to your account. Keep in mind that certain actions require you to re-authenticate.",
"settings.title": "Account Settings",
"settings.title-lookup-secret": "Manage 2FA Backup Recovery Codes",
Expand All @@ -166,6 +167,7 @@
"settings.title-profile": "Profile Settings",
"settings.title-totp": "Manage 2FA TOTP Authenticator App",
"settings.title-webauthn": "Manage Hardware Tokens",
"settings.title-passkey": "Manage Passkeys",
"verification.registration-button": "Sign up",
"verification.registration-label": "Don't have an account?",
"verification.title": "Verify your account"
Expand Down
9 changes: 9 additions & 0 deletions src/markup-components/components.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,8 @@ import {
UserSettingsScreenProps,
WebAuthnSettingsProps,
WebAuthnSettingsSection as webAuthnSettingsSection,
PasskeySettingsSection as passkeySettingsSection,
PasskeySettingsProps,
} from "../react-components"
import { ComponentWrapper, Context } from "./component-wrapper"

Expand Down Expand Up @@ -191,6 +193,13 @@ export const WebAuthnSettingsSection = (
return ComponentWrapper(webAuthnSettingsSection, props, context)
}

export const PasskeySettingsSection = (
props: PasskeySettingsProps,
context: Context = {},
) => {
return ComponentWrapper(passkeySettingsSection, props, context)
}

export const OIDCSettingsSection = (
props: OIDCSettingsProps,
context: Context = {},
Expand Down
82 changes: 50 additions & 32 deletions src/react-components/ory/helpers/node.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { UiNode, UiText } from "@ory/client"
import {UiNode, UiNodeAttributes, UiText} from "@ory/client"
import {
isUiNodeAnchorAttributes,
isUiNodeImageAttributes,
Expand Down Expand Up @@ -106,37 +106,37 @@ export const uiTextToFormattedMessage = (
// context might provide an array of objects instead of a single object
// for example when looking up a recovery code
/*
*
{
"text": {
"id": 1050015,
"text": "3r9noma8, tv14n5tu, ...",
"type": "info",
"context": {
"secrets": [
{
"context": {
"secret": "3r9noma8"
},
"id": 1050009,
"text": "3r9noma8",
"type": "info"
},
{
"context": {
"secret": "tv14n5tu"
},
"id": 1050009,
"text": "tv14n5tu",
"type": "info"
},
]
}
},
"id": "lookup_secret_codes",
"node_type": "text"
}
*/
*
{
"text": {
"id": 1050015,
"text": "3r9noma8, tv14n5tu, ...",
"type": "info",
"context": {
"secrets": [
{
"context": {
"secret": "3r9noma8"
},
"id": 1050009,
"text": "3r9noma8",
"type": "info"
},
{
"context": {
"secret": "tv14n5tu"
},
"id": 1050009,
"text": "tv14n5tu",
"type": "info"
},
]
}
},
"id": "lookup_secret_codes",
"node_type": "text"
}
*/
if (Array.isArray(value)) {
return {
...accumulator,
Expand Down Expand Up @@ -182,6 +182,18 @@ export const uiTextToFormattedMessage = (
)
}

function dataAttributes(attrs: UiNodeAttributes): Record<string, string> {
return Object.entries(attrs).reduce(
(accumulator, [key, value]) => {
if (key.startsWith("data-")) {
accumulator[key] = value
}
return accumulator
},
{} as Record<string, string>,
)
}

export const Node = ({
node,
className,
Expand All @@ -205,6 +217,7 @@ export const Node = ({
header={formatMessage(node.meta.label)}
width={node.attributes.width}
height={node.attributes.height}
{...dataAttributes(node.attributes)}
/>
)
} else if (isUiNodeTextAttributes(node.attributes)) {
Expand Down Expand Up @@ -307,6 +320,7 @@ export const Node = ({
disabled={attrs.disabled}
{...(buttonSocialOverrideProps && buttonSocialOverrideProps)}
{...submit}
{...dataAttributes(attrs)}
/>
) : (
<Button
Expand All @@ -318,6 +332,7 @@ export const Node = ({
disabled={attrs.disabled}
{...(buttonOverrideProps && buttonOverrideProps)}
{...submit}
{...dataAttributes(attrs)}
/>
)
case "datetime-local":
Expand All @@ -335,6 +350,7 @@ export const Node = ({
disabled={attrs.disabled}
defaultChecked={Boolean(attrs.value)}
dataTestid={`node/input/${attrs.name}`}
{...dataAttributes(attrs)}
/>
)
default:
Expand All @@ -356,6 +372,7 @@ export const Node = ({
required={attrs.required}
disabled={attrs.disabled}
pattern={attrs.pattern}
{...dataAttributes(attrs)}
/>
)
}
Expand All @@ -367,6 +384,7 @@ export const Node = ({
data-testid={`node/anchor/${node.attributes.id}`}
className={className}
position="center"
{...dataAttributes(node.attributes)}
>
{formatMessage(node.attributes.title)}
</ButtonLink>
Expand Down
27 changes: 10 additions & 17 deletions src/react-components/ory/helpers/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,23 +3,16 @@

import { UiNode } from "@ory/client"

export const hasOidc = (nodes: UiNode[]) =>
nodes.some(({ group }) => group === "oidc")

export const hasPassword = (nodes: UiNode[]) =>
nodes.some(({ group }) => group === "password")

export const hasWebauthn = (nodes: UiNode[]) =>
nodes.some(({ group }) => group === "webauthn")

export const hasLookupSecret = (nodes: UiNode[]) =>
nodes.some(({ group }) => group === "lookup_secret")

export const hasTotp = (nodes: UiNode[]) =>
nodes.some(({ group }) => group === "totp")

export const hasCode = (nodes: UiNode[]) =>
nodes.some(({ group }) => group === "code")
export const hasGroup = (group: string) => (nodes: UiNode[]) =>
nodes.some(({ type, group: g }) => type === "input" && g === group)

export const hasOidc = hasGroup("oidc")
export const hasPassword = hasGroup("password")
export const hasWebauthn = hasGroup("webauthn")
export const hasPasskey = hasGroup("passkey")
export const hasLookupSecret = hasGroup("lookup_secret")
export const hasTotp = hasGroup("totp")
export const hasCode = hasGroup("code")

export const hasHiddenIdentifier = (nodes: UiNode[]) =>
nodes.some(
Expand Down
1 change: 1 addition & 0 deletions src/react-components/ory/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ export * from "./sections/profile-settings-section"
export * from "./sections/registration-section"
export * from "./sections/totp-settings-section"
export * from "./sections/webauthn-settings-section"
export * from "./sections/passkey-settings-section"
export * from "./user-auth-card"
export * from "./user-error-card"
export * from "./user-settings-card"
Expand Down
32 changes: 32 additions & 0 deletions src/react-components/ory/sections/passkey-settings-section.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import { JSX } from "react"

import { SettingsFlow } from "@ory/client"
import { gridStyle } from "../../../theme"
import { FilterFlowNodes } from "../helpers/filter-flow-nodes"
import { hasPasskey } from "../helpers/utils"

export interface PasskeySettingsProps {
flow: SettingsFlow
}

export const PasskeySettingsSection = ({
flow,
}: PasskeySettingsProps): JSX.Element | null => {
const filter = {
nodes: flow.ui.nodes,
groups: ["passkey", "webauthn"],
withoutDefaultGroup: true,
}

return hasPasskey(flow.ui.nodes) ? (
<div>
<FilterFlowNodes
filter={{ ...filter, attributes: "submit,button" }}
buttonOverrideProps={{ fullWidth: false }}
/>
<FilterFlowNodes
filter={{ ...filter, excludeAttributes: "submit,button" }}
/>
</div>
) : null
}
25 changes: 21 additions & 4 deletions src/react-components/ory/sections/passwordless-section.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,19 +3,19 @@ import { JSX } from "react"
import { gridStyle } from "../../../theme"
import { FilterFlowNodes } from "../helpers/filter-flow-nodes"
import { SelfServiceFlow } from "../helpers/types"
import { hasWebauthn } from "../helpers/utils"
import { hasPasskey, hasWebauthn } from "../helpers/utils"

export const PasswordlessSection = (
flow: SelfServiceFlow,
): JSX.Element | null => {
return hasWebauthn(flow.ui.nodes) ? (
return hasWebauthn(flow.ui.nodes) || hasPasskey(flow.ui.nodes) ? (
<div className={gridStyle({ gap: 32 })}>
<div className={gridStyle({ gap: 16 })}>
<FilterFlowNodes
filter={{
nodes: flow.ui.nodes,
// we will also map default fields here but not oidc and password fields
groups: ["webauthn"],
groups: ["webauthn", "passkey"],
withoutDefaultAttributes: true,
excludeAttributes: ["hidden", "button", "submit"], // the form will take care of hidden fields
}}
Expand All @@ -24,7 +24,24 @@ export const PasswordlessSection = (
<FilterFlowNodes
filter={{
nodes: flow.ui.nodes,
groups: ["webauthn"],
groups: ["webauthn", "passkey"],
withoutDefaultAttributes: true,
attributes: ["button", "submit"],
}}
/>
</div>
) : null
}

export const PasswordlessLoginSection = (
flow: SelfServiceFlow,
): JSX.Element | null => {
return hasWebauthn(flow.ui.nodes) || hasPasskey(flow.ui.nodes) ? (
<div className={gridStyle({ gap: 32 })}>
<FilterFlowNodes
filter={{
nodes: flow.ui.nodes,
groups: ["webauthn", "passkey"],
withoutDefaultAttributes: true,
attributes: ["button", "submit"],
}}
Expand Down
7 changes: 5 additions & 2 deletions src/react-components/ory/user-auth-card.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,10 @@ import { LinkSection } from "./sections/link-section"
import { LoggedInInfo } from "./sections/logged-info"
import { LoginSection } from "./sections/login-section"
import { OIDCSection } from "./sections/oidc-section"
import { PasswordlessSection } from "./sections/passwordless-section"
import {
PasswordlessLoginSection,
PasswordlessSection,
} from "./sections/passwordless-section"
import { RegistrationSection } from "./sections/registration-section"

export interface LoginSectionAdditionalProps {
Expand Down Expand Up @@ -310,7 +313,7 @@ export const UserAuthCard = ({

switch (flowType) {
case "login":
$passwordless = PasswordlessSection(flow)
$passwordless = PasswordlessLoginSection(flow)
$oidc = OIDCSection(flow)
$code = AuthCodeSection({ nodes: flow.ui.nodes })

Expand Down
15 changes: 15 additions & 0 deletions src/react-components/ory/user-settings-card.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
import {
hasLookupSecret,
hasOidc,
hasPasskey,
hasPassword,
hasTotp,
hasWebauthn,
Expand All @@ -22,12 +23,14 @@ import { ProfileSettingsSection } from "./sections/profile-settings-section"
import { TOTPSettingsSection } from "./sections/totp-settings-section"
import { WebAuthnSettingsSection } from "./sections/webauthn-settings-section"
import { useIntl } from "react-intl"
import { PasskeySettingsSection } from "./sections/passkey-settings-section"

export type UserSettingsFlowType =
| "profile"
| "password"
| "totp"
| "webauthn"
| "passkey"
| "oidc"
| "lookupSecret"

Expand Down Expand Up @@ -92,6 +95,18 @@ export const UserSettingsCard = ({
$flow = <WebAuthnSettingsSection flow={flow} />
}
break
case "passkey":
if (hasPasskey(flow.ui.nodes)) {
hasFlow = true
cardTitle =
title ??
intl.formatMessage({
id: "settings.title-passkey",
defaultMessage: "Manage Passkeys",
})
$flow = <PasskeySettingsSection flow={flow} />
}
break
case "lookupSecret":
if (hasLookupSecret(flow.ui.nodes)) {
hasFlow = true
Expand Down
Loading

0 comments on commit 1816fa4

Please sign in to comment.