From 5e6c901c21e7e9df77cad79d1738124130c3a6cb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Karol=20S=C3=B3jko?= Date: Tue, 10 Jan 2023 21:33:44 +0100 Subject: [PATCH] feat: recovery codes UI (recovery sign in + get recovery codes) (#2139) * feat(web): show recovery codes * feat(web): add recovery sign in * fix: copy * fix: styles * feat: add "copy to clipboard" button * style: copy * fix: copy button bg * style: singularize recovery codes * style: singularize recovery codes * feat: password validation Co-authored-by: Aman Harwara Co-authored-by: Mo --- .../Challenge/ChallengeServiceInterface.ts | 1 + packages/snjs/lib/Application/Application.ts | 10 +-- .../GetRecoveryCodes/GetRecoveryCodes.spec.ts | 6 +- .../GetRecoveryCodes/GetRecoveryCodes.ts | 2 +- .../SignInWithRecoveryCodes.spec.ts | 6 +- .../SignInWithRecoveryCodes.ts | 2 +- .../Services/Challenge/ChallengeService.ts | 22 +++++++ .../Services/Protection/ProtectionService.ts | 2 +- packages/snjs/mocha/recovery.test.js | 14 ++--- .../AccountMenu/AdvancedOptions.tsx | 63 +++++++++++++++++-- .../Components/AccountMenu/SignIn.tsx | 51 ++++++++++++++- .../Preferences/Panes/Security/Security.tsx | 6 +- .../Panes/Security/TwoFactorAuth/MfaProps.ts | 2 + .../TwoFactorAuthView/TwoFactorAuthView.tsx | 14 ++++- .../TwoFactorAuth/TwoFactorAuthWrapper.tsx | 2 +- .../RecoveryCodeBanner/RecoveryCodeBanner.tsx | 63 +++++++++++++++++++ 16 files changed, 234 insertions(+), 32 deletions(-) create mode 100644 packages/web/src/javascripts/Components/RecoveryCodeBanner/RecoveryCodeBanner.tsx diff --git a/packages/services/src/Domain/Challenge/ChallengeServiceInterface.ts b/packages/services/src/Domain/Challenge/ChallengeServiceInterface.ts index 292216bc7..11aa4b380 100644 --- a/packages/services/src/Domain/Challenge/ChallengeServiceInterface.ts +++ b/packages/services/src/Domain/Challenge/ChallengeServiceInterface.ts @@ -20,6 +20,7 @@ export interface ChallengeServiceInterface extends AbstractService { subheading?: string, ): ChallengeInterface completeChallenge(challenge: ChallengeInterface): void + promptForAccountPassword(): Promise getWrappingKeyIfApplicable(passcode?: string): Promise< | { canceled?: undefined diff --git a/packages/snjs/lib/Application/Application.ts b/packages/snjs/lib/Application/Application.ts index 44c219732..223428167 100644 --- a/packages/snjs/lib/Application/Application.ts +++ b/packages/snjs/lib/Application/Application.ts @@ -333,6 +333,10 @@ export class SNApplication implements ApplicationInterface, AppGroupManagedAppli return this.actionsManager } + public get challenges(): ExternalServices.ChallengeServiceInterface { + return this.challengeService + } + public computePrivateUsername(username: string): Promise { return ComputePrivateUsername(this.options.crypto, username) } @@ -823,10 +827,6 @@ export class SNApplication implements ApplicationInterface, AppGroupManagedAppli } } - public promptForCustomChallenge(challenge: Challenge): Promise { - return this.challengeService?.promptForChallengeResponse(challenge) - } - public addChallengeObserver(challenge: Challenge, observer: InternalServices.ChallengeObserver): () => void { return this.challengeService.addChallengeObserver(challenge, observer) } @@ -996,7 +996,7 @@ export class SNApplication implements ApplicationInterface, AppGroupManagedAppli false, ) - void this.promptForCustomChallenge(challenge) + void this.challengeService.promptForChallengeResponse(challenge) this.isBiometricsSoftLockEngaged = true void this.notifyEvent(ApplicationEvent.BiometricsSoftLockEngaged) diff --git a/packages/snjs/lib/Domain/UseCase/GetRecoveryCodes/GetRecoveryCodes.spec.ts b/packages/snjs/lib/Domain/UseCase/GetRecoveryCodes/GetRecoveryCodes.spec.ts index f23193305..8a9b4bbae 100644 --- a/packages/snjs/lib/Domain/UseCase/GetRecoveryCodes/GetRecoveryCodes.spec.ts +++ b/packages/snjs/lib/Domain/UseCase/GetRecoveryCodes/GetRecoveryCodes.spec.ts @@ -17,7 +17,7 @@ describe('GetRecoveryCodes', () => { settingsClient.getSetting = jest.fn().mockResolvedValue('existing-recovery-codes') }) - it('should return existing recovery codes if they exist', async () => { + it('should return existing recovery code if they exist', async () => { const useCase = createUseCase() const result = await useCase.execute() @@ -25,7 +25,7 @@ describe('GetRecoveryCodes', () => { expect(result.getValue()).toBe('existing-recovery-codes') }) - it('should generate recovery codes if they do not exist', async () => { + it('should generate recovery code if they do not exist', async () => { settingsClient.getSetting = jest.fn().mockResolvedValue(undefined) const useCase = createUseCase() @@ -35,7 +35,7 @@ describe('GetRecoveryCodes', () => { expect(result.getValue()).toBe('recovery-codes') }) - it('should return error if recovery codes could not be generated', async () => { + it('should return error if recovery code could not be generated', async () => { settingsClient.getSetting = jest.fn().mockResolvedValue(undefined) authClient.generateRecoveryCodes = jest.fn().mockResolvedValue(false) diff --git a/packages/snjs/lib/Domain/UseCase/GetRecoveryCodes/GetRecoveryCodes.ts b/packages/snjs/lib/Domain/UseCase/GetRecoveryCodes/GetRecoveryCodes.ts index 698b1e2ff..be382d828 100644 --- a/packages/snjs/lib/Domain/UseCase/GetRecoveryCodes/GetRecoveryCodes.ts +++ b/packages/snjs/lib/Domain/UseCase/GetRecoveryCodes/GetRecoveryCodes.ts @@ -15,7 +15,7 @@ export class GetRecoveryCodes implements UseCaseInterface { const generatedRecoveryCodes = await this.authClient.generateRecoveryCodes() if (generatedRecoveryCodes === false) { - return Result.fail('Could not generate recovery codes') + return Result.fail('Could not generate recovery code') } return Result.ok(generatedRecoveryCodes) diff --git a/packages/snjs/lib/Domain/UseCase/SignInWithRecoveryCodes/SignInWithRecoveryCodes.spec.ts b/packages/snjs/lib/Domain/UseCase/SignInWithRecoveryCodes/SignInWithRecoveryCodes.spec.ts index 512b93285..02e6e8f70 100644 --- a/packages/snjs/lib/Domain/UseCase/SignInWithRecoveryCodes/SignInWithRecoveryCodes.spec.ts +++ b/packages/snjs/lib/Domain/UseCase/SignInWithRecoveryCodes/SignInWithRecoveryCodes.spec.ts @@ -150,17 +150,17 @@ describe('SignInWithRecoveryCodes', () => { expect(result.getError()).toEqual('The protocol version associated with your account is outdated and no longer supported by this application. Please visit standardnotes.com/help/security for more information.') }) - it('should fail if the sign in with recovery codes fails', async () => { + it('should fail if the sign in with recovery code fails', async () => { authManager.signInWithRecoveryCodes = jest.fn().mockReturnValue(false) const useCase = createUseCase() const result = await useCase.execute({ recoveryCodes: 'recovery-codes', password: 'foobar', username: 'test@test.te' }) expect(result.isFailed()).toBe(true) - expect(result.getError()).toEqual('Could not sign in with recovery codes') + expect(result.getError()).toEqual('Could not sign in with recovery code') }) - it('should sign in with recovery codes', async () => { + it('should sign in with recovery code', async () => { authManager.signInWithRecoveryCodes = jest.fn().mockReturnValue({ keyParams: {} as AnyKeyParamsContent, session: {} as SessionBody, diff --git a/packages/snjs/lib/Domain/UseCase/SignInWithRecoveryCodes/SignInWithRecoveryCodes.ts b/packages/snjs/lib/Domain/UseCase/SignInWithRecoveryCodes/SignInWithRecoveryCodes.ts index 73a18b7d6..1c8c42b4c 100644 --- a/packages/snjs/lib/Domain/UseCase/SignInWithRecoveryCodes/SignInWithRecoveryCodes.ts +++ b/packages/snjs/lib/Domain/UseCase/SignInWithRecoveryCodes/SignInWithRecoveryCodes.ts @@ -70,7 +70,7 @@ export class SignInWithRecoveryCodes implements UseCaseInterface { }) if (signInResult === false) { - return Result.fail('Could not sign in with recovery codes') + return Result.fail('Could not sign in with recovery code') } this.inMemoryStore.removeValue(StorageKey.CodeVerifier) diff --git a/packages/snjs/lib/Services/Challenge/ChallengeService.ts b/packages/snjs/lib/Services/Challenge/ChallengeService.ts index 189ce400d..567012bef 100644 --- a/packages/snjs/lib/Services/Challenge/ChallengeService.ts +++ b/packages/snjs/lib/Services/Challenge/ChallengeService.ts @@ -15,6 +15,7 @@ import { ChallengePromptInterface, ChallengePrompt, EncryptionService, + ChallengeStrings, } from '@standardnotes/services' import { ChallengeResponse } from './ChallengeResponse' import { ChallengeOperation } from './ChallengeOperation' @@ -109,6 +110,27 @@ export class ChallengeService extends AbstractService implements ChallengeServic return value.value as string } + async promptForAccountPassword(): Promise { + if (!this.protocolService.hasAccount()) { + throw Error('Requiring account password for challenge with no account') + } + + const response = await this.promptForChallengeResponse( + new Challenge( + [new ChallengePrompt(ChallengeValidation.AccountPassword)], + ChallengeReason.Custom, + true, + ChallengeStrings.EnterAccountPassword, + ), + ) + + if (response) { + return true + } else { + return false + } + } + /** * Returns the wrapping key for operations that require resaving the root key * (changing the account password, signing in, registering, or upgrading protocol) diff --git a/packages/snjs/lib/Services/Protection/ProtectionService.ts b/packages/snjs/lib/Services/Protection/ProtectionService.ts index 6ebc71c78..80fe8dc74 100644 --- a/packages/snjs/lib/Services/Protection/ProtectionService.ts +++ b/packages/snjs/lib/Services/Protection/ProtectionService.ts @@ -388,7 +388,7 @@ export class SNProtectionService extends AbstractService implem if (isNullOrUndefined(length)) { SNLog.error(Error('No valid protection session length found. Got ' + length)) } else { - await this.setSessionLength(length as UnprotectedAccessSecondsDuration) + this.setSessionLength(length as UnprotectedAccessSecondsDuration) } return true } else { diff --git a/packages/snjs/mocha/recovery.test.js b/packages/snjs/mocha/recovery.test.js index 57a9e9859..8f6701932 100644 --- a/packages/snjs/mocha/recovery.test.js +++ b/packages/snjs/mocha/recovery.test.js @@ -25,7 +25,7 @@ describe('account recovery', function () { localStorage.clear() }) - it('should get the same recovery codes at each consecutive call', async () => { + it('should get the same recovery code at each consecutive call', async () => { let recoveryCodesSetting = await application.settings.getSetting(SettingName.RecoveryCodes) expect(recoveryCodesSetting).to.equal(undefined) @@ -39,7 +39,7 @@ describe('account recovery', function () { expect(generatedRecoveryCodesAfterFirstCall.getValue()).to.equal(fetchedRecoveryCodesOnTheSecondCall.getValue()) }) - it('should allow to sign in with recovery codes', async () => { + it('should allow to sign in with recovery code', async () => { const generatedRecoveryCodes = await application.getRecoveryCodes.execute() application = await context.signout() @@ -55,7 +55,7 @@ describe('account recovery', function () { expect(await application.protocolService.getRootKey()).to.be.ok }) - it('should automatically generate new recovery codes after recovery sign in', async () => { + it('should automatically generate new recovery code after recovery sign in', async () => { const generatedRecoveryCodes = await application.getRecoveryCodes.execute() application = await context.signout() @@ -91,7 +91,7 @@ describe('account recovery', function () { expect(await application.isMfaActivated()).to.equal(false) }) - it('should not allow to sign in with recovery codes and invalid credentials', async () => { + it('should not allow to sign in with recovery code and invalid credentials', async () => { const generatedRecoveryCodes = await application.getRecoveryCodes.execute() application = await context.signout() @@ -107,7 +107,7 @@ describe('account recovery', function () { expect(await application.protocolService.getRootKey()).to.not.be.ok }) - it('should not allow to sign in with invalid recovery codes', async () => { + it('should not allow to sign in with invalid recovery code', async () => { await application.getRecoveryCodes.execute() application = await context.signout() @@ -115,7 +115,7 @@ describe('account recovery', function () { expect(await application.protocolService.getRootKey()).to.not.be.ok await application.signInWithRecoveryCodes.execute({ - recoveryCodes: 'invalid recovery codes', + recoveryCodes: 'invalid recovery code', username: context.email, password: context.paswword, }) @@ -123,7 +123,7 @@ describe('account recovery', function () { expect(await application.protocolService.getRootKey()).to.not.be.ok }) - it('should not allow to sign in with recovery codes if user has none', async () => { + it('should not allow to sign in with recovery code if user has none', async () => { application = await context.signout() expect(await application.protocolService.getRootKey()).to.not.be.ok diff --git a/packages/web/src/javascripts/Components/AccountMenu/AdvancedOptions.tsx b/packages/web/src/javascripts/Components/AccountMenu/AdvancedOptions.tsx index 360a116ae..57748d479 100644 --- a/packages/web/src/javascripts/Components/AccountMenu/AdvancedOptions.tsx +++ b/packages/web/src/javascripts/Components/AccountMenu/AdvancedOptions.tsx @@ -12,6 +12,7 @@ type Props = { disabled?: boolean onPrivateUsernameModeChange?: (isPrivate: boolean, identifier?: string) => void onStrictSignInChange?: (isStrictSignIn: boolean) => void + onRecoveryCodesChange?: (isRecoveryCodes: boolean, recoveryCodes?: string) => void children?: ReactNode } @@ -21,6 +22,7 @@ const AdvancedOptions: FunctionComponent = ({ disabled = false, onPrivateUsernameModeChange, onStrictSignInChange, + onRecoveryCodesChange, children, }) => { const { server, setServer, enableServerOption, setEnableServerOption } = viewControllerManager.accountMenuController @@ -29,6 +31,9 @@ const AdvancedOptions: FunctionComponent = ({ const [isPrivateUsername, setIsPrivateUsername] = useState(false) const [privateUsername, setPrivateUsername] = useState('') + const [isRecoveryCodes, setIsRecoveryCodes] = useState(false) + const [recoveryCodes, setRecoveryCodes] = useState('') + const [isStrictSignin, setIsStrictSignin] = useState(false) useEffect(() => { @@ -61,6 +66,28 @@ const AdvancedOptions: FunctionComponent = ({ setPrivateUsername(name) }, []) + const handleIsRecoveryCodesChange = useCallback(() => { + const newValue = !isRecoveryCodes + setIsRecoveryCodes(newValue) + onRecoveryCodesChange?.(newValue) + + if (!isRecoveryCodes) { + setIsPrivateUsername(false) + setIsStrictSignin(false) + setEnableServerOption(false) + } + }, [isRecoveryCodes, setIsPrivateUsername, setIsStrictSignin, setEnableServerOption, onRecoveryCodesChange]) + + const handleRecoveryCodesChange = useCallback( + (recoveryCodes: string) => { + setRecoveryCodes(recoveryCodes) + if (recoveryCodes) { + onRecoveryCodesChange?.(true, recoveryCodes) + } + }, + [onRecoveryCodesChange], + ) + const handleServerOptionChange: ChangeEventHandler = useCallback( (e) => { if (e.target instanceof HTMLInputElement) { @@ -108,7 +135,7 @@ const AdvancedOptions: FunctionComponent = ({ name="private-workspace" label="Private username mode" checked={isPrivateUsername} - disabled={disabled} + disabled={disabled || isRecoveryCodes} onChange={handleIsPrivateUsernameChange} /> @@ -125,7 +152,7 @@ const AdvancedOptions: FunctionComponent = ({ placeholder="Username" value={privateUsername} onChange={handlePrivateUsernameNameChange} - disabled={disabled} + disabled={disabled || isRecoveryCodes} spellcheck={false} autocomplete={false} /> @@ -138,7 +165,7 @@ const AdvancedOptions: FunctionComponent = ({ name="use-strict-signin" label="Use strict sign-in" checked={isStrictSignin} - disabled={disabled} + disabled={disabled || isRecoveryCodes} onChange={handleStrictSigninChange} /> = ({ )} +
+ +
+ + {isRecoveryCodes && ( + <> + ]} + type="text" + placeholder="Recovery code" + value={recoveryCodes} + onChange={handleRecoveryCodesChange} + disabled={disabled} + spellcheck={false} + autocomplete={false} + /> + + )} + = ({ placeholder="https://api.standardnotes.com" value={server} onChange={handleSyncServerChange} - disabled={!enableServerOption && !disabled} + disabled={!enableServerOption && !disabled && !isRecoveryCodes} /> ) : null} diff --git a/packages/web/src/javascripts/Components/AccountMenu/SignIn.tsx b/packages/web/src/javascripts/Components/AccountMenu/SignIn.tsx index 5b8001d17..00f3c73ac 100644 --- a/packages/web/src/javascripts/Components/AccountMenu/SignIn.tsx +++ b/packages/web/src/javascripts/Components/AccountMenu/SignIn.tsx @@ -23,6 +23,7 @@ const SignInPane: FunctionComponent = ({ application, viewControllerManag const { notesAndTagsCount } = viewControllerManager.accountMenuController const [email, setEmail] = useState('') const [password, setPassword] = useState('') + const [recoveryCodes, setRecoveryCodes] = useState('') const [error, setError] = useState('') const [isEphemeral, setIsEphemeral] = useState(false) @@ -31,6 +32,8 @@ const SignInPane: FunctionComponent = ({ application, viewControllerManag const [shouldMergeLocal, setShouldMergeLocal] = useState(true) const [isPrivateUsername, setIsPrivateUsername] = useState(false) + const [isRecoverySignIn, setIsRecoverySignIn] = useState(false) + const emailInputRef = useRef(null) const passwordInputRef = useRef(null) @@ -72,6 +75,16 @@ const SignInPane: FunctionComponent = ({ application, viewControllerManag setIsStrictSignin(!isStrictSignin) }, [isStrictSignin]) + const onRecoveryCodesChange = useCallback( + (newIsRecoverySignIn: boolean, recoveryCodes?: string) => { + setIsRecoverySignIn(newIsRecoverySignIn) + if (newIsRecoverySignIn && recoveryCodes) { + setRecoveryCodes(recoveryCodes) + } + }, + [setRecoveryCodes], + ) + const handleShouldMergeChange = useCallback(() => { setShouldMergeLocal(!shouldMergeLocal) }, [shouldMergeLocal]) @@ -100,6 +113,34 @@ const SignInPane: FunctionComponent = ({ application, viewControllerManag }) }, [viewControllerManager, application, email, isEphemeral, isStrictSignin, password, shouldMergeLocal]) + const recoverySignIn = useCallback(() => { + setIsSigningIn(true) + emailInputRef?.current?.blur() + passwordInputRef?.current?.blur() + + application.signInWithRecoveryCodes + .execute({ + recoveryCodes, + username: email, + password: password, + }) + .then((result) => { + if (result.isFailed()) { + throw new Error(result.getError()) + } + viewControllerManager.accountMenuController.closeAccountMenu() + }) + .catch((err) => { + console.error(err) + setError(err.message ?? err.toString()) + setPassword('') + passwordInputRef?.current?.blur() + }) + .finally(() => { + setIsSigningIn(false) + }) + }, [viewControllerManager, application, email, password, recoveryCodes]) + const onPrivateUsernameChange = useCallback( (newisPrivateUsername: boolean, privateUsernameIdentifier?: string) => { setIsPrivateUsername(newisPrivateUsername) @@ -124,9 +165,14 @@ const SignInPane: FunctionComponent = ({ application, viewControllerManag return } + if (isRecoverySignIn) { + recoverySignIn() + return + } + signIn() }, - [email, password, signIn], + [email, password, isRecoverySignIn, signIn, recoverySignIn], ) const handleKeyDown: KeyboardEventHandler = useCallback( @@ -188,7 +234,7 @@ const SignInPane: FunctionComponent = ({ application, viewControllerManag name="is-ephemeral" label="Stay signed in" checked={!isEphemeral} - disabled={isSigningIn} + disabled={isSigningIn || isRecoverySignIn} onChange={handleEphemeralChange} /> {notesAndTagsCount > 0 ? ( @@ -208,6 +254,7 @@ const SignInPane: FunctionComponent = ({ application, viewControllerManag disabled={isSigningIn} onPrivateUsernameModeChange={onPrivateUsernameChange} onStrictSignInChange={handleStrictSigninChange} + onRecoveryCodesChange={onRecoveryCodesChange} /> ) diff --git a/packages/web/src/javascripts/Components/Preferences/Panes/Security/Security.tsx b/packages/web/src/javascripts/Components/Preferences/Panes/Security/Security.tsx index 779c1e065..f1649f763 100644 --- a/packages/web/src/javascripts/Components/Preferences/Panes/Security/Security.tsx +++ b/packages/web/src/javascripts/Components/Preferences/Panes/Security/Security.tsx @@ -27,7 +27,11 @@ const Security: FunctionComponent = (props) => { )} - + {isNativeMobileWeb && } {isNativeMobileWeb && } diff --git a/packages/web/src/javascripts/Components/Preferences/Panes/Security/TwoFactorAuth/MfaProps.ts b/packages/web/src/javascripts/Components/Preferences/Panes/Security/TwoFactorAuth/MfaProps.ts index 1534e38cc..800e66bd0 100644 --- a/packages/web/src/javascripts/Components/Preferences/Panes/Security/TwoFactorAuth/MfaProps.ts +++ b/packages/web/src/javascripts/Components/Preferences/Panes/Security/TwoFactorAuth/MfaProps.ts @@ -1,6 +1,8 @@ +import { WebApplication } from '@/Application/Application' import { MfaProvider, UserProvider } from '@/Components/Preferences/Providers' export interface MfaProps { userProvider: UserProvider mfaProvider: MfaProvider + application: WebApplication } diff --git a/packages/web/src/javascripts/Components/Preferences/Panes/Security/TwoFactorAuth/TwoFactorAuthView/TwoFactorAuthView.tsx b/packages/web/src/javascripts/Components/Preferences/Panes/Security/TwoFactorAuth/TwoFactorAuthView/TwoFactorAuthView.tsx index b4367b764..be8496d73 100644 --- a/packages/web/src/javascripts/Components/Preferences/Panes/Security/TwoFactorAuth/TwoFactorAuthView/TwoFactorAuthView.tsx +++ b/packages/web/src/javascripts/Components/Preferences/Panes/Security/TwoFactorAuth/TwoFactorAuthView/TwoFactorAuthView.tsx @@ -1,19 +1,22 @@ import { FunctionComponent } from 'react' import { Text } from '@/Components/Preferences/PreferencesComponents/Content' import { observer } from 'mobx-react-lite' -import { is2FAActivation, TwoFactorAuth } from '../TwoFactorAuth' +import { is2FAActivation, is2FAEnabled, TwoFactorAuth } from '../TwoFactorAuth' import TwoFactorActivationView from '../TwoFactorActivationView' import TwoFactorTitle from './TwoFactorTitle' import TwoFactorDescription from './TwoFactorDescription' import TwoFactorSwitch from './TwoFactorSwitch' import PreferencesGroup from '@/Components/Preferences/PreferencesComponents/PreferencesGroup' import PreferencesSegment from '@/Components/Preferences/PreferencesComponents/PreferencesSegment' +import { WebApplication } from '@/Application/Application' +import RecoveryCodeBanner from '@/Components/RecoveryCodeBanner/RecoveryCodeBanner' type Props = { auth: TwoFactorAuth + application: WebApplication } -const TwoFactorAuthView: FunctionComponent = ({ auth }) => { +const TwoFactorAuthView: FunctionComponent = ({ auth, application }) => { return ( <> @@ -34,6 +37,13 @@ const TwoFactorAuthView: FunctionComponent = ({ auth }) => { {auth.errorMessage} )} + {auth.status !== 'fetching' && is2FAEnabled(auth.status) && ( + +
+ +
+
+ )}
{auth.status !== 'fetching' && is2FAActivation(auth.status) && ( diff --git a/packages/web/src/javascripts/Components/Preferences/Panes/Security/TwoFactorAuth/TwoFactorAuthWrapper.tsx b/packages/web/src/javascripts/Components/Preferences/Panes/Security/TwoFactorAuth/TwoFactorAuthWrapper.tsx index 850fade1c..09929c791 100644 --- a/packages/web/src/javascripts/Components/Preferences/Panes/Security/TwoFactorAuth/TwoFactorAuthWrapper.tsx +++ b/packages/web/src/javascripts/Components/Preferences/Panes/Security/TwoFactorAuth/TwoFactorAuthWrapper.tsx @@ -6,7 +6,7 @@ import TwoFactorAuthView from './TwoFactorAuthView/TwoFactorAuthView' const TwoFactorAuthWrapper: FunctionComponent = (props) => { const [auth] = useState(() => new TwoFactorAuth(props.mfaProvider, props.userProvider)) auth.fetchStatus() - return + return } export default TwoFactorAuthWrapper diff --git a/packages/web/src/javascripts/Components/RecoveryCodeBanner/RecoveryCodeBanner.tsx b/packages/web/src/javascripts/Components/RecoveryCodeBanner/RecoveryCodeBanner.tsx new file mode 100644 index 000000000..bf961cfee --- /dev/null +++ b/packages/web/src/javascripts/Components/RecoveryCodeBanner/RecoveryCodeBanner.tsx @@ -0,0 +1,63 @@ +import { WebApplication } from '@/Application/Application' +import { useState } from 'react' + +import Button from '../Button/Button' +import Icon from '../Icon/Icon' +import StyledTooltip from '../StyledTooltip/StyledTooltip' + +const RecoveryCodeBanner = ({ application }: { application: WebApplication }) => { + const [recoveryCode, setRecoveryCode] = useState() + const [errorMessage, setErrorMessage] = useState() + + const onClickShow = async () => { + const authorized = await application.challenges.promptForAccountPassword() + + if (!authorized) { + return + } + + const recoveryCodeOrError = await application.getRecoveryCodes.execute() + if (recoveryCodeOrError.isFailed()) { + setErrorMessage(recoveryCodeOrError.getError()) + return + } + + setRecoveryCode(recoveryCodeOrError.getValue()) + } + + return ( +
+
+ +

Save your recovery code

+
+

+ Your recovery code allows you access to your account in the event you lose your 2FA authenticating device or + app. Save your recovery code in a safe place outside your account. +

+ {errorMessage &&
{errorMessage}
} + {!recoveryCode && ( + + )} + {recoveryCode && ( +
+ + + + {recoveryCode} +
+ )} +
+ ) +} + +export default RecoveryCodeBanner