Skip to content

Commit

Permalink
[CA-3583] Support account recovery with Passkeys (#208)
Browse files Browse the repository at this point in the history
  • Loading branch information
rchapel authored May 16, 2024
1 parent cbab746 commit c5acea3
Show file tree
Hide file tree
Showing 13 changed files with 491 additions and 38 deletions.
7 changes: 6 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,15 @@ to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [Unreleased]

## Added

- Add new widgets for account recovery (Passkeys)


## [1.26.1] - 2024-04-19

## Added
-

- Add a second UI for login with passkey: choose between integrated password and passkey with `initialScreen: 'login'` or separated password and passkey with `initialScreen: 'login-with-web-authn'`

## [1.26.0] - 2024-03-25
Expand Down
8 changes: 4 additions & 4 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@
"lint": "eslint --ext .js,.jsx,.ts,.tsx src/"
},
"dependencies": {
"@reachfive/identity-core": "^1.32.2",
"@reachfive/identity-core": "^1.33.0",
"@reachfive/zxcvbn": "1.0.0-alpha.2",
"buffer": "^6.0.3",
"char-info": "0.3.2",
Expand Down
15 changes: 10 additions & 5 deletions src/client.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import webAuthnWidget, { type WebAuthnWidgetProps } from './widgets/webAuthn/web
import mfaCredentialsWidget, { type MfaCredentialsWidgetProps } from './widgets/mfa/MfaCredentialsWidget';
import mfaListWidget, { type MfaListWidgetProps } from './widgets/mfa/mfaListWidget'
import mfaStepUpWidget, { type MfaStepUpWidgetProps } from './widgets/stepUp/mfaStepUpWidget';
import accountRecoveryWidget, { type AccountRecoveryWidgetProps } from './widgets/accountRecovery/accountRecoveryWidget.tsx'

export interface WidgetInstance {
destroy(): void
Expand All @@ -38,7 +39,7 @@ export interface WidgetProps {
*/
countryCode?: string
/**
* Callback function called after the widget has been successfully loaded and rendered inside the container.
* Callback function called after the widget has been successfully loaded and rendered inside the container.
* The callback is called with the widget instance.
*/
onReady?: (instance: WidgetInstance) => void
Expand All @@ -55,7 +56,7 @@ export class UiClient {
config: Config
core: CoreClient
defaultI18n: I18nMessages

constructor(config: Config, coreClient: CoreClient, defaultI18n: I18nMessages) {
this.config = config;
this.core = coreClient;
Expand All @@ -66,6 +67,10 @@ export class UiClient {
this._ssoCheck(authWidget, options);
}

showAccountRecovery(options: WidgetOptions<AccountRecoveryWidgetProps>) {
this._showWidget(accountRecoveryWidget, options);
}

showSocialLogin(options: WidgetOptions<SocialLoginWidgetProps>) {
this._ssoCheck(socialLoginWidget, options);
}
Expand Down Expand Up @@ -155,7 +160,7 @@ export class UiClient {
_ssoCheck<P extends WidgetProps>(widget: Widget<Omit<P, keyof WidgetProps>>, options: P & { auth?: AuthOptions }) {
const { auth = {} } = options;
const showAuthWidget = (session?: SessionInfo) => this._showWidget(widget, options, { session });

if (this.config.sso || auth.idTokenHint || auth.loginHint) {
setTimeout(() =>
Promise.resolve(this.core.checkUrlFragment(window.location.href)).then(authResult => {
Expand Down Expand Up @@ -188,8 +193,8 @@ export class UiClient {
adaptError(error: unknown): string {
return error instanceof UserError
? error.message
: error instanceof Error
? error.message
: error instanceof Error
? error.message
: 'Unexpected error'
}

Expand Down
5 changes: 5 additions & 0 deletions src/icons/passkeys.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
2 changes: 2 additions & 0 deletions src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ export type { Config } from '@reachfive/identity-core';
export type Client = {
core: CoreClient,
showAuth: InstanceType<typeof UiClient>['showAuth']
showAccountRecovery: InstanceType<typeof UiClient>['showAccountRecovery']
showEmailEditor: InstanceType<typeof UiClient>['showEmailEditor']
showPasswordEditor: InstanceType<typeof UiClient>['showPasswordEditor']
showPhoneNumberEditor: InstanceType<typeof UiClient>['showPhoneNumberEditor']
Expand Down Expand Up @@ -50,6 +51,7 @@ export function createClient(creationConfig: Config): Client {
return {
core: coreClient,
showAuth: (options: Parameters<Awaited<typeof client>['showAuth']>[0]) => client.then(client => client.showAuth(options)),
showAccountRecovery: (options: Parameters<Awaited<typeof client>['showAccountRecovery']>[0]) => client.then(client => client.showAccountRecovery(options)),
showEmailEditor: (options: Parameters<Awaited<typeof client>['showEmailEditor']>[0]) => client.then(client => client.showEmailEditor(options)),
showPasswordEditor: (options: Parameters<Awaited<typeof client>['showPasswordEditor']>[0]) => client.then(client => client.showPasswordEditor(options)),
showPhoneNumberEditor: (options: Parameters<Awaited<typeof client>['showPhoneNumberEditor']>[0]) => client.then(client => client.showPhoneNumberEditor(options)),
Expand Down
216 changes: 216 additions & 0 deletions src/widgets/accountRecovery/accountRecoveryWidget.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,216 @@
import React from 'react';

import { parseQueryString } from '../../helpers/queryString'

import { Alternative, Heading, Info, Link, Intro, Separator } from '../../components/miscComponent';
import { createMultiViewWidget } from '../../components/widget/widget';
import { useReachfive } from '../../contexts/reachfive';
import { useRouting } from '../../contexts/routing';
import { useI18n } from '../../contexts/i18n';
import { createForm } from '../../components/form/formComponent'
import { PasswordEditorForm, PasswordEditorFormData } from '../passwordEditor/passwordEditorWidget.tsx'
import { ReactComponent as Passkeys } from '../../icons/passkeys.svg'
import styled from 'styled-components'


interface MainViewProps {

/**
* Allow an end-user to create a password instead of a Passkey
* @default true
*/
allowCreatePassword?: boolean
/**
* Callback function called when the request has failed.
*/
onSuccess?: () => void
/**
* Callback function called after the widget has been successfully loaded and rendered inside the container.
* The callback is called with the widget instance.
*/
onError?: () => void
/**
* Whether the form fields' labels are displayed on the form view.
* @default false
*/
showLabels?: boolean
}

const DeviceInputForm = createForm<{}, MainViewProps>({
prefix: 'r5-credentials-reset',
fields: [],
submitLabel: 'accountRecovery.passkeyReset.button',
supportMultipleSubmits: true,
resetAfterSuccess: true
})

const iconStyle = `
width: 60px;
height: 60px;
margin-left: auto;
margin-right: auto;
margin-bottom: 1em;
display: block;
`
const PasskeysIcon = styled(Passkeys)`${iconStyle}`;

const PasskeysExplanation = styled(() => {
const i18n = useI18n()
return (
<ul>
<li><b>{i18n('accountRecovery.passkeyReset.subtitle1')}</b></li>
<ul>
<li>{i18n('accountRecovery.passkeyReset.legend1')}</li>
</ul>
<li><b>{i18n('accountRecovery.passkeyReset.subtitle2')}</b></li>
<ul>
<li>{i18n('accountRecovery.passkeyReset.legend2')}</li>
</ul>
</ul>
)
})`
`;

const NewPasskey = ({
authentication,
allowCreatePassword = true,
onSuccess = () => {},
onError = () => {},
}: PropsWithAuthentication<MainViewProps>) => {
const coreClient = useReachfive()
const i18n = useI18n()
const {goTo} = useRouting()

const handleSubmit = () => {
return coreClient.resetPasskeys({
email: authentication?.email,
verificationCode: authentication?.verificationCode,
clientId: authentication?.clientId
});
};

const handleSuccess = () => {
onSuccess();
goTo('passkey-success');
};

return (
<div>
<Heading>{i18n('accountRecovery.passkeyReset.title')}</Heading>
<PasskeysIcon/>
<Intro><b>{i18n('accountRecovery.passkeyReset.intro')}</b></Intro>
<PasskeysExplanation/>
<DeviceInputForm
handler={handleSubmit}
onSuccess={handleSuccess}
onError={onError}
/>
{allowCreatePassword &&
<Alternative>
<Separator text={i18n('or')} />
<Intro><Link target="new-password">Create a new password</Link></Intro>
</Alternative>
}
</div>
)
}

interface SuccessViewProps {
loginLink?: string
}

const PasskeySuccessView = ({loginLink}: SuccessViewProps) => {
const i18n = useI18n()
return (
<div>
<Heading>{i18n('accountRecovery.passkeyReset.successTitle')}</Heading>
<Info>{i18n('accountRecovery.passkeyReset.successMessage')}</Info>
{loginLink && (
<Info>
<Link href={loginLink}>{i18n('accountRecovery.passkeyReset.loginLink')}</Link>
</Info>
)}
</div>
)
}

const PasswordSuccessView = ({loginLink}: SuccessViewProps) => {
const i18n = useI18n()
return (
<div>
<Heading>{i18n('passwordReset.successTitle')}</Heading>
<Info>{i18n('passwordReset.successMessage')}</Info>
{loginLink && (
<Info>
<Link href={loginLink}>{i18n('passwordReset.loginLink')}</Link>
</Info>
)}
</div>
)
}

export const NewPasswordView = ({
authentication,
onSuccess = () => {},
onError = () => {},
showLabels = false,
}: PropsWithAuthentication<MainViewProps>) => {
const coreClient = useReachfive()
const i18n = useI18n()
const { goTo } = useRouting()

const handleSubmit = ({ password }: PasswordEditorFormData) => {
return coreClient.updatePassword({
password,
...authentication
});
};

const handleSuccess = () => {
onSuccess();
goTo('password-success');
};

return (
<div>
<Heading>{i18n('accountRecovery.password.title')}</Heading>
<Info>{i18n('passwordReset.intro')}</Info>
<PasswordEditorForm
handler={handleSubmit}
showLabels={showLabels}
onSuccess={handleSuccess}
onError={onError} />
<Alternative>
<Link target="new-passkey">Back</Link>
</Alternative>
</div>
)
}

const resolveCode = () => {
const qs = (window.location.search && window.location.search.length)
? window.location.search.substr(1)
: '';
const {verificationCode, email, clientId} = parseQueryString(qs)
return {authentication: { verificationCode, email, clientId } as Authentication};
};

type Authentication = { verificationCode: string, email: string, clientId: string }
type PropsWithAuthentication<P> = P & { authentication: Authentication }

export interface AccountRecoveryWidgetProps extends MainViewProps, SuccessViewProps {
}

export default createMultiViewWidget<AccountRecoveryWidgetProps, PropsWithAuthentication<AccountRecoveryWidgetProps>>({
initialView: 'new-passkey',
views: {
'new-passkey': NewPasskey,
'new-password': NewPasswordView,
'passkey-success': PasskeySuccessView,
'password-success': PasswordSuccessView
},
prepare: options => ({
...options,
...resolveCode()
})
});
3 changes: 3 additions & 0 deletions src/widgets/auth/authWidget.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import type { FaSelectionViewProps, FaSelectionViewState, VerificationCodeViewPr
import type { PropsWithSession } from '../../contexts/session'

import { ProviderId } from '../../providers/providers';
import { AccountRecoverySuccessView, AccountRecoveryView } from './views/accountRecoveryViewComponent.tsx'
import { InitialScreen } from '../../../constants.ts';

export interface AuthWidgetProps extends
Expand Down Expand Up @@ -87,7 +88,9 @@ export default createMultiViewWidget<AuthWidgetProps, PropsWithSession<AuthWidge
'signup-with-password': SignupWithPasswordView,
'signup-with-web-authn': SignupWithWebAuthnView,
'forgot-password': ForgotPasswordView,
'account-recovery': AccountRecoveryView,
'forgot-password-success': ForgotPasswordSuccessView,
'account-recovery-success': AccountRecoverySuccessView,
'quick-login': QuickLoginView,
'fa-selection': FaSelectionView,
'verification-code': VerificationCodeView,
Expand Down
Loading

0 comments on commit c5acea3

Please sign in to comment.