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 <amanharwara@protonmail.com> Co-authored-by: Mo <mo@standardnotes.com>
This commit is contained in:
@@ -20,6 +20,7 @@ export interface ChallengeServiceInterface extends AbstractService {
|
|||||||
subheading?: string,
|
subheading?: string,
|
||||||
): ChallengeInterface
|
): ChallengeInterface
|
||||||
completeChallenge(challenge: ChallengeInterface): void
|
completeChallenge(challenge: ChallengeInterface): void
|
||||||
|
promptForAccountPassword(): Promise<boolean>
|
||||||
getWrappingKeyIfApplicable(passcode?: string): Promise<
|
getWrappingKeyIfApplicable(passcode?: string): Promise<
|
||||||
| {
|
| {
|
||||||
canceled?: undefined
|
canceled?: undefined
|
||||||
|
|||||||
@@ -333,6 +333,10 @@ export class SNApplication implements ApplicationInterface, AppGroupManagedAppli
|
|||||||
return this.actionsManager
|
return this.actionsManager
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public get challenges(): ExternalServices.ChallengeServiceInterface {
|
||||||
|
return this.challengeService
|
||||||
|
}
|
||||||
|
|
||||||
public computePrivateUsername(username: string): Promise<string | undefined> {
|
public computePrivateUsername(username: string): Promise<string | undefined> {
|
||||||
return ComputePrivateUsername(this.options.crypto, username)
|
return ComputePrivateUsername(this.options.crypto, username)
|
||||||
}
|
}
|
||||||
@@ -823,10 +827,6 @@ export class SNApplication implements ApplicationInterface, AppGroupManagedAppli
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public promptForCustomChallenge(challenge: Challenge): Promise<ChallengeResponse | undefined> {
|
|
||||||
return this.challengeService?.promptForChallengeResponse(challenge)
|
|
||||||
}
|
|
||||||
|
|
||||||
public addChallengeObserver(challenge: Challenge, observer: InternalServices.ChallengeObserver): () => void {
|
public addChallengeObserver(challenge: Challenge, observer: InternalServices.ChallengeObserver): () => void {
|
||||||
return this.challengeService.addChallengeObserver(challenge, observer)
|
return this.challengeService.addChallengeObserver(challenge, observer)
|
||||||
}
|
}
|
||||||
@@ -996,7 +996,7 @@ export class SNApplication implements ApplicationInterface, AppGroupManagedAppli
|
|||||||
false,
|
false,
|
||||||
)
|
)
|
||||||
|
|
||||||
void this.promptForCustomChallenge(challenge)
|
void this.challengeService.promptForChallengeResponse(challenge)
|
||||||
|
|
||||||
this.isBiometricsSoftLockEngaged = true
|
this.isBiometricsSoftLockEngaged = true
|
||||||
void this.notifyEvent(ApplicationEvent.BiometricsSoftLockEngaged)
|
void this.notifyEvent(ApplicationEvent.BiometricsSoftLockEngaged)
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ describe('GetRecoveryCodes', () => {
|
|||||||
settingsClient.getSetting = jest.fn().mockResolvedValue('existing-recovery-codes')
|
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 useCase = createUseCase()
|
||||||
|
|
||||||
const result = await useCase.execute()
|
const result = await useCase.execute()
|
||||||
@@ -25,7 +25,7 @@ describe('GetRecoveryCodes', () => {
|
|||||||
expect(result.getValue()).toBe('existing-recovery-codes')
|
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)
|
settingsClient.getSetting = jest.fn().mockResolvedValue(undefined)
|
||||||
|
|
||||||
const useCase = createUseCase()
|
const useCase = createUseCase()
|
||||||
@@ -35,7 +35,7 @@ describe('GetRecoveryCodes', () => {
|
|||||||
expect(result.getValue()).toBe('recovery-codes')
|
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)
|
settingsClient.getSetting = jest.fn().mockResolvedValue(undefined)
|
||||||
authClient.generateRecoveryCodes = jest.fn().mockResolvedValue(false)
|
authClient.generateRecoveryCodes = jest.fn().mockResolvedValue(false)
|
||||||
|
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ export class GetRecoveryCodes implements UseCaseInterface<string> {
|
|||||||
|
|
||||||
const generatedRecoveryCodes = await this.authClient.generateRecoveryCodes()
|
const generatedRecoveryCodes = await this.authClient.generateRecoveryCodes()
|
||||||
if (generatedRecoveryCodes === false) {
|
if (generatedRecoveryCodes === false) {
|
||||||
return Result.fail('Could not generate recovery codes')
|
return Result.fail('Could not generate recovery code')
|
||||||
}
|
}
|
||||||
|
|
||||||
return Result.ok(generatedRecoveryCodes)
|
return Result.ok(generatedRecoveryCodes)
|
||||||
|
|||||||
@@ -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.')
|
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)
|
authManager.signInWithRecoveryCodes = jest.fn().mockReturnValue(false)
|
||||||
|
|
||||||
const useCase = createUseCase()
|
const useCase = createUseCase()
|
||||||
const result = await useCase.execute({ recoveryCodes: 'recovery-codes', password: 'foobar', username: 'test@test.te' })
|
const result = await useCase.execute({ recoveryCodes: 'recovery-codes', password: 'foobar', username: 'test@test.te' })
|
||||||
|
|
||||||
expect(result.isFailed()).toBe(true)
|
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({
|
authManager.signInWithRecoveryCodes = jest.fn().mockReturnValue({
|
||||||
keyParams: {} as AnyKeyParamsContent,
|
keyParams: {} as AnyKeyParamsContent,
|
||||||
session: {} as SessionBody,
|
session: {} as SessionBody,
|
||||||
|
|||||||
@@ -70,7 +70,7 @@ export class SignInWithRecoveryCodes implements UseCaseInterface<void> {
|
|||||||
})
|
})
|
||||||
|
|
||||||
if (signInResult === false) {
|
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)
|
this.inMemoryStore.removeValue(StorageKey.CodeVerifier)
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import {
|
|||||||
ChallengePromptInterface,
|
ChallengePromptInterface,
|
||||||
ChallengePrompt,
|
ChallengePrompt,
|
||||||
EncryptionService,
|
EncryptionService,
|
||||||
|
ChallengeStrings,
|
||||||
} from '@standardnotes/services'
|
} from '@standardnotes/services'
|
||||||
import { ChallengeResponse } from './ChallengeResponse'
|
import { ChallengeResponse } from './ChallengeResponse'
|
||||||
import { ChallengeOperation } from './ChallengeOperation'
|
import { ChallengeOperation } from './ChallengeOperation'
|
||||||
@@ -109,6 +110,27 @@ export class ChallengeService extends AbstractService implements ChallengeServic
|
|||||||
return value.value as string
|
return value.value as string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async promptForAccountPassword(): Promise<boolean> {
|
||||||
|
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
|
* Returns the wrapping key for operations that require resaving the root key
|
||||||
* (changing the account password, signing in, registering, or upgrading protocol)
|
* (changing the account password, signing in, registering, or upgrading protocol)
|
||||||
|
|||||||
@@ -388,7 +388,7 @@ export class SNProtectionService extends AbstractService<ProtectionEvent> implem
|
|||||||
if (isNullOrUndefined(length)) {
|
if (isNullOrUndefined(length)) {
|
||||||
SNLog.error(Error('No valid protection session length found. Got ' + length))
|
SNLog.error(Error('No valid protection session length found. Got ' + length))
|
||||||
} else {
|
} else {
|
||||||
await this.setSessionLength(length as UnprotectedAccessSecondsDuration)
|
this.setSessionLength(length as UnprotectedAccessSecondsDuration)
|
||||||
}
|
}
|
||||||
return true
|
return true
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ describe('account recovery', function () {
|
|||||||
localStorage.clear()
|
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)
|
let recoveryCodesSetting = await application.settings.getSetting(SettingName.RecoveryCodes)
|
||||||
expect(recoveryCodesSetting).to.equal(undefined)
|
expect(recoveryCodesSetting).to.equal(undefined)
|
||||||
|
|
||||||
@@ -39,7 +39,7 @@ describe('account recovery', function () {
|
|||||||
expect(generatedRecoveryCodesAfterFirstCall.getValue()).to.equal(fetchedRecoveryCodesOnTheSecondCall.getValue())
|
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()
|
const generatedRecoveryCodes = await application.getRecoveryCodes.execute()
|
||||||
|
|
||||||
application = await context.signout()
|
application = await context.signout()
|
||||||
@@ -55,7 +55,7 @@ describe('account recovery', function () {
|
|||||||
expect(await application.protocolService.getRootKey()).to.be.ok
|
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()
|
const generatedRecoveryCodes = await application.getRecoveryCodes.execute()
|
||||||
|
|
||||||
application = await context.signout()
|
application = await context.signout()
|
||||||
@@ -91,7 +91,7 @@ describe('account recovery', function () {
|
|||||||
expect(await application.isMfaActivated()).to.equal(false)
|
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()
|
const generatedRecoveryCodes = await application.getRecoveryCodes.execute()
|
||||||
|
|
||||||
application = await context.signout()
|
application = await context.signout()
|
||||||
@@ -107,7 +107,7 @@ describe('account recovery', function () {
|
|||||||
expect(await application.protocolService.getRootKey()).to.not.be.ok
|
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()
|
await application.getRecoveryCodes.execute()
|
||||||
|
|
||||||
application = await context.signout()
|
application = await context.signout()
|
||||||
@@ -115,7 +115,7 @@ describe('account recovery', function () {
|
|||||||
expect(await application.protocolService.getRootKey()).to.not.be.ok
|
expect(await application.protocolService.getRootKey()).to.not.be.ok
|
||||||
|
|
||||||
await application.signInWithRecoveryCodes.execute({
|
await application.signInWithRecoveryCodes.execute({
|
||||||
recoveryCodes: 'invalid recovery codes',
|
recoveryCodes: 'invalid recovery code',
|
||||||
username: context.email,
|
username: context.email,
|
||||||
password: context.paswword,
|
password: context.paswword,
|
||||||
})
|
})
|
||||||
@@ -123,7 +123,7 @@ describe('account recovery', function () {
|
|||||||
expect(await application.protocolService.getRootKey()).to.not.be.ok
|
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()
|
application = await context.signout()
|
||||||
|
|
||||||
expect(await application.protocolService.getRootKey()).to.not.be.ok
|
expect(await application.protocolService.getRootKey()).to.not.be.ok
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ type Props = {
|
|||||||
disabled?: boolean
|
disabled?: boolean
|
||||||
onPrivateUsernameModeChange?: (isPrivate: boolean, identifier?: string) => void
|
onPrivateUsernameModeChange?: (isPrivate: boolean, identifier?: string) => void
|
||||||
onStrictSignInChange?: (isStrictSignIn: boolean) => void
|
onStrictSignInChange?: (isStrictSignIn: boolean) => void
|
||||||
|
onRecoveryCodesChange?: (isRecoveryCodes: boolean, recoveryCodes?: string) => void
|
||||||
children?: ReactNode
|
children?: ReactNode
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -21,6 +22,7 @@ const AdvancedOptions: FunctionComponent<Props> = ({
|
|||||||
disabled = false,
|
disabled = false,
|
||||||
onPrivateUsernameModeChange,
|
onPrivateUsernameModeChange,
|
||||||
onStrictSignInChange,
|
onStrictSignInChange,
|
||||||
|
onRecoveryCodesChange,
|
||||||
children,
|
children,
|
||||||
}) => {
|
}) => {
|
||||||
const { server, setServer, enableServerOption, setEnableServerOption } = viewControllerManager.accountMenuController
|
const { server, setServer, enableServerOption, setEnableServerOption } = viewControllerManager.accountMenuController
|
||||||
@@ -29,6 +31,9 @@ const AdvancedOptions: FunctionComponent<Props> = ({
|
|||||||
const [isPrivateUsername, setIsPrivateUsername] = useState(false)
|
const [isPrivateUsername, setIsPrivateUsername] = useState(false)
|
||||||
const [privateUsername, setPrivateUsername] = useState('')
|
const [privateUsername, setPrivateUsername] = useState('')
|
||||||
|
|
||||||
|
const [isRecoveryCodes, setIsRecoveryCodes] = useState(false)
|
||||||
|
const [recoveryCodes, setRecoveryCodes] = useState('')
|
||||||
|
|
||||||
const [isStrictSignin, setIsStrictSignin] = useState(false)
|
const [isStrictSignin, setIsStrictSignin] = useState(false)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -61,6 +66,28 @@ const AdvancedOptions: FunctionComponent<Props> = ({
|
|||||||
setPrivateUsername(name)
|
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<HTMLInputElement> = useCallback(
|
const handleServerOptionChange: ChangeEventHandler<HTMLInputElement> = useCallback(
|
||||||
(e) => {
|
(e) => {
|
||||||
if (e.target instanceof HTMLInputElement) {
|
if (e.target instanceof HTMLInputElement) {
|
||||||
@@ -108,7 +135,7 @@ const AdvancedOptions: FunctionComponent<Props> = ({
|
|||||||
name="private-workspace"
|
name="private-workspace"
|
||||||
label="Private username mode"
|
label="Private username mode"
|
||||||
checked={isPrivateUsername}
|
checked={isPrivateUsername}
|
||||||
disabled={disabled}
|
disabled={disabled || isRecoveryCodes}
|
||||||
onChange={handleIsPrivateUsernameChange}
|
onChange={handleIsPrivateUsernameChange}
|
||||||
/>
|
/>
|
||||||
<a href="https://standardnotes.com/help/80" target="_blank" rel="noopener noreferrer" title="Learn more">
|
<a href="https://standardnotes.com/help/80" target="_blank" rel="noopener noreferrer" title="Learn more">
|
||||||
@@ -125,7 +152,7 @@ const AdvancedOptions: FunctionComponent<Props> = ({
|
|||||||
placeholder="Username"
|
placeholder="Username"
|
||||||
value={privateUsername}
|
value={privateUsername}
|
||||||
onChange={handlePrivateUsernameNameChange}
|
onChange={handlePrivateUsernameNameChange}
|
||||||
disabled={disabled}
|
disabled={disabled || isRecoveryCodes}
|
||||||
spellcheck={false}
|
spellcheck={false}
|
||||||
autocomplete={false}
|
autocomplete={false}
|
||||||
/>
|
/>
|
||||||
@@ -138,7 +165,7 @@ const AdvancedOptions: FunctionComponent<Props> = ({
|
|||||||
name="use-strict-signin"
|
name="use-strict-signin"
|
||||||
label="Use strict sign-in"
|
label="Use strict sign-in"
|
||||||
checked={isStrictSignin}
|
checked={isStrictSignin}
|
||||||
disabled={disabled}
|
disabled={disabled || isRecoveryCodes}
|
||||||
onChange={handleStrictSigninChange}
|
onChange={handleStrictSigninChange}
|
||||||
/>
|
/>
|
||||||
<a
|
<a
|
||||||
@@ -152,12 +179,38 @@ const AdvancedOptions: FunctionComponent<Props> = ({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<div className="mb-1 flex items-center justify-between">
|
||||||
|
<Checkbox
|
||||||
|
name="recovery-codes"
|
||||||
|
label="Use recovery code"
|
||||||
|
checked={isRecoveryCodes}
|
||||||
|
disabled={disabled}
|
||||||
|
onChange={handleIsRecoveryCodesChange}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isRecoveryCodes && (
|
||||||
|
<>
|
||||||
|
<DecoratedInput
|
||||||
|
className={{ container: 'mb-2' }}
|
||||||
|
left={[<Icon type="security" className="text-neutral" />]}
|
||||||
|
type="text"
|
||||||
|
placeholder="Recovery code"
|
||||||
|
value={recoveryCodes}
|
||||||
|
onChange={handleRecoveryCodesChange}
|
||||||
|
disabled={disabled}
|
||||||
|
spellcheck={false}
|
||||||
|
autocomplete={false}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
<Checkbox
|
<Checkbox
|
||||||
name="custom-sync-server"
|
name="custom-sync-server"
|
||||||
label="Custom sync server"
|
label="Custom sync server"
|
||||||
checked={enableServerOption}
|
checked={enableServerOption}
|
||||||
onChange={handleServerOptionChange}
|
onChange={handleServerOptionChange}
|
||||||
disabled={disabled}
|
disabled={disabled || isRecoveryCodes}
|
||||||
/>
|
/>
|
||||||
<DecoratedInput
|
<DecoratedInput
|
||||||
type="text"
|
type="text"
|
||||||
@@ -165,7 +218,7 @@ const AdvancedOptions: FunctionComponent<Props> = ({
|
|||||||
placeholder="https://api.standardnotes.com"
|
placeholder="https://api.standardnotes.com"
|
||||||
value={server}
|
value={server}
|
||||||
onChange={handleSyncServerChange}
|
onChange={handleSyncServerChange}
|
||||||
disabled={!enableServerOption && !disabled}
|
disabled={!enableServerOption && !disabled && !isRecoveryCodes}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ const SignInPane: FunctionComponent<Props> = ({ application, viewControllerManag
|
|||||||
const { notesAndTagsCount } = viewControllerManager.accountMenuController
|
const { notesAndTagsCount } = viewControllerManager.accountMenuController
|
||||||
const [email, setEmail] = useState('')
|
const [email, setEmail] = useState('')
|
||||||
const [password, setPassword] = useState('')
|
const [password, setPassword] = useState('')
|
||||||
|
const [recoveryCodes, setRecoveryCodes] = useState('')
|
||||||
const [error, setError] = useState('')
|
const [error, setError] = useState('')
|
||||||
const [isEphemeral, setIsEphemeral] = useState(false)
|
const [isEphemeral, setIsEphemeral] = useState(false)
|
||||||
|
|
||||||
@@ -31,6 +32,8 @@ const SignInPane: FunctionComponent<Props> = ({ application, viewControllerManag
|
|||||||
const [shouldMergeLocal, setShouldMergeLocal] = useState(true)
|
const [shouldMergeLocal, setShouldMergeLocal] = useState(true)
|
||||||
const [isPrivateUsername, setIsPrivateUsername] = useState(false)
|
const [isPrivateUsername, setIsPrivateUsername] = useState(false)
|
||||||
|
|
||||||
|
const [isRecoverySignIn, setIsRecoverySignIn] = useState(false)
|
||||||
|
|
||||||
const emailInputRef = useRef<HTMLInputElement>(null)
|
const emailInputRef = useRef<HTMLInputElement>(null)
|
||||||
const passwordInputRef = useRef<HTMLInputElement>(null)
|
const passwordInputRef = useRef<HTMLInputElement>(null)
|
||||||
|
|
||||||
@@ -72,6 +75,16 @@ const SignInPane: FunctionComponent<Props> = ({ application, viewControllerManag
|
|||||||
setIsStrictSignin(!isStrictSignin)
|
setIsStrictSignin(!isStrictSignin)
|
||||||
}, [isStrictSignin])
|
}, [isStrictSignin])
|
||||||
|
|
||||||
|
const onRecoveryCodesChange = useCallback(
|
||||||
|
(newIsRecoverySignIn: boolean, recoveryCodes?: string) => {
|
||||||
|
setIsRecoverySignIn(newIsRecoverySignIn)
|
||||||
|
if (newIsRecoverySignIn && recoveryCodes) {
|
||||||
|
setRecoveryCodes(recoveryCodes)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[setRecoveryCodes],
|
||||||
|
)
|
||||||
|
|
||||||
const handleShouldMergeChange = useCallback(() => {
|
const handleShouldMergeChange = useCallback(() => {
|
||||||
setShouldMergeLocal(!shouldMergeLocal)
|
setShouldMergeLocal(!shouldMergeLocal)
|
||||||
}, [shouldMergeLocal])
|
}, [shouldMergeLocal])
|
||||||
@@ -100,6 +113,34 @@ const SignInPane: FunctionComponent<Props> = ({ application, viewControllerManag
|
|||||||
})
|
})
|
||||||
}, [viewControllerManager, application, email, isEphemeral, isStrictSignin, password, shouldMergeLocal])
|
}, [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(
|
const onPrivateUsernameChange = useCallback(
|
||||||
(newisPrivateUsername: boolean, privateUsernameIdentifier?: string) => {
|
(newisPrivateUsername: boolean, privateUsernameIdentifier?: string) => {
|
||||||
setIsPrivateUsername(newisPrivateUsername)
|
setIsPrivateUsername(newisPrivateUsername)
|
||||||
@@ -124,9 +165,14 @@ const SignInPane: FunctionComponent<Props> = ({ application, viewControllerManag
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (isRecoverySignIn) {
|
||||||
|
recoverySignIn()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
signIn()
|
signIn()
|
||||||
},
|
},
|
||||||
[email, password, signIn],
|
[email, password, isRecoverySignIn, signIn, recoverySignIn],
|
||||||
)
|
)
|
||||||
|
|
||||||
const handleKeyDown: KeyboardEventHandler = useCallback(
|
const handleKeyDown: KeyboardEventHandler = useCallback(
|
||||||
@@ -188,7 +234,7 @@ const SignInPane: FunctionComponent<Props> = ({ application, viewControllerManag
|
|||||||
name="is-ephemeral"
|
name="is-ephemeral"
|
||||||
label="Stay signed in"
|
label="Stay signed in"
|
||||||
checked={!isEphemeral}
|
checked={!isEphemeral}
|
||||||
disabled={isSigningIn}
|
disabled={isSigningIn || isRecoverySignIn}
|
||||||
onChange={handleEphemeralChange}
|
onChange={handleEphemeralChange}
|
||||||
/>
|
/>
|
||||||
{notesAndTagsCount > 0 ? (
|
{notesAndTagsCount > 0 ? (
|
||||||
@@ -208,6 +254,7 @@ const SignInPane: FunctionComponent<Props> = ({ application, viewControllerManag
|
|||||||
disabled={isSigningIn}
|
disabled={isSigningIn}
|
||||||
onPrivateUsernameModeChange={onPrivateUsernameChange}
|
onPrivateUsernameModeChange={onPrivateUsernameChange}
|
||||||
onStrictSignInChange={handleStrictSigninChange}
|
onStrictSignInChange={handleStrictSigninChange}
|
||||||
|
onRecoveryCodesChange={onRecoveryCodesChange}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -27,7 +27,11 @@ const Security: FunctionComponent<SecurityProps> = (props) => {
|
|||||||
<ErroredItems viewControllerManager={props.viewControllerManager} />
|
<ErroredItems viewControllerManager={props.viewControllerManager} />
|
||||||
)}
|
)}
|
||||||
<Protections application={props.application} />
|
<Protections application={props.application} />
|
||||||
<TwoFactorAuthWrapper mfaProvider={props.mfaProvider} userProvider={props.userProvider} />
|
<TwoFactorAuthWrapper
|
||||||
|
mfaProvider={props.mfaProvider}
|
||||||
|
userProvider={props.userProvider}
|
||||||
|
application={props.application}
|
||||||
|
/>
|
||||||
{isNativeMobileWeb && <MultitaskingPrivacy application={props.application} />}
|
{isNativeMobileWeb && <MultitaskingPrivacy application={props.application} />}
|
||||||
<PasscodeLock viewControllerManager={props.viewControllerManager} application={props.application} />
|
<PasscodeLock viewControllerManager={props.viewControllerManager} application={props.application} />
|
||||||
{isNativeMobileWeb && <BiometricsLock application={props.application} />}
|
{isNativeMobileWeb && <BiometricsLock application={props.application} />}
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
|
import { WebApplication } from '@/Application/Application'
|
||||||
import { MfaProvider, UserProvider } from '@/Components/Preferences/Providers'
|
import { MfaProvider, UserProvider } from '@/Components/Preferences/Providers'
|
||||||
|
|
||||||
export interface MfaProps {
|
export interface MfaProps {
|
||||||
userProvider: UserProvider
|
userProvider: UserProvider
|
||||||
mfaProvider: MfaProvider
|
mfaProvider: MfaProvider
|
||||||
|
application: WebApplication
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,19 +1,22 @@
|
|||||||
import { FunctionComponent } from 'react'
|
import { FunctionComponent } from 'react'
|
||||||
import { Text } from '@/Components/Preferences/PreferencesComponents/Content'
|
import { Text } from '@/Components/Preferences/PreferencesComponents/Content'
|
||||||
import { observer } from 'mobx-react-lite'
|
import { observer } from 'mobx-react-lite'
|
||||||
import { is2FAActivation, TwoFactorAuth } from '../TwoFactorAuth'
|
import { is2FAActivation, is2FAEnabled, TwoFactorAuth } from '../TwoFactorAuth'
|
||||||
import TwoFactorActivationView from '../TwoFactorActivationView'
|
import TwoFactorActivationView from '../TwoFactorActivationView'
|
||||||
import TwoFactorTitle from './TwoFactorTitle'
|
import TwoFactorTitle from './TwoFactorTitle'
|
||||||
import TwoFactorDescription from './TwoFactorDescription'
|
import TwoFactorDescription from './TwoFactorDescription'
|
||||||
import TwoFactorSwitch from './TwoFactorSwitch'
|
import TwoFactorSwitch from './TwoFactorSwitch'
|
||||||
import PreferencesGroup from '@/Components/Preferences/PreferencesComponents/PreferencesGroup'
|
import PreferencesGroup from '@/Components/Preferences/PreferencesComponents/PreferencesGroup'
|
||||||
import PreferencesSegment from '@/Components/Preferences/PreferencesComponents/PreferencesSegment'
|
import PreferencesSegment from '@/Components/Preferences/PreferencesComponents/PreferencesSegment'
|
||||||
|
import { WebApplication } from '@/Application/Application'
|
||||||
|
import RecoveryCodeBanner from '@/Components/RecoveryCodeBanner/RecoveryCodeBanner'
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
auth: TwoFactorAuth
|
auth: TwoFactorAuth
|
||||||
|
application: WebApplication
|
||||||
}
|
}
|
||||||
|
|
||||||
const TwoFactorAuthView: FunctionComponent<Props> = ({ auth }) => {
|
const TwoFactorAuthView: FunctionComponent<Props> = ({ auth, application }) => {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<PreferencesGroup>
|
<PreferencesGroup>
|
||||||
@@ -34,6 +37,13 @@ const TwoFactorAuthView: FunctionComponent<Props> = ({ auth }) => {
|
|||||||
<Text className="text-danger">{auth.errorMessage}</Text>
|
<Text className="text-danger">{auth.errorMessage}</Text>
|
||||||
</PreferencesSegment>
|
</PreferencesSegment>
|
||||||
)}
|
)}
|
||||||
|
{auth.status !== 'fetching' && is2FAEnabled(auth.status) && (
|
||||||
|
<PreferencesSegment>
|
||||||
|
<div className="mt-3">
|
||||||
|
<RecoveryCodeBanner application={application} />
|
||||||
|
</div>
|
||||||
|
</PreferencesSegment>
|
||||||
|
)}
|
||||||
</PreferencesGroup>
|
</PreferencesGroup>
|
||||||
{auth.status !== 'fetching' && is2FAActivation(auth.status) && (
|
{auth.status !== 'fetching' && is2FAActivation(auth.status) && (
|
||||||
<TwoFactorActivationView activation={auth.status} />
|
<TwoFactorActivationView activation={auth.status} />
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import TwoFactorAuthView from './TwoFactorAuthView/TwoFactorAuthView'
|
|||||||
const TwoFactorAuthWrapper: FunctionComponent<MfaProps> = (props) => {
|
const TwoFactorAuthWrapper: FunctionComponent<MfaProps> = (props) => {
|
||||||
const [auth] = useState(() => new TwoFactorAuth(props.mfaProvider, props.userProvider))
|
const [auth] = useState(() => new TwoFactorAuth(props.mfaProvider, props.userProvider))
|
||||||
auth.fetchStatus()
|
auth.fetchStatus()
|
||||||
return <TwoFactorAuthView auth={auth} />
|
return <TwoFactorAuthView auth={auth} application={props.application} />
|
||||||
}
|
}
|
||||||
|
|
||||||
export default TwoFactorAuthWrapper
|
export default TwoFactorAuthWrapper
|
||||||
|
|||||||
@@ -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<string>()
|
||||||
|
const [errorMessage, setErrorMessage] = useState<string>()
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<div className="grid grid-cols-1 rounded-md border border-border p-4">
|
||||||
|
<div className="flex items-center">
|
||||||
|
<Icon className="mr-1 -ml-1 h-5 w-5 text-info group-disabled:text-passive-2" type="asterisk" />
|
||||||
|
<h1 className="sk-h3 m-0 text-sm font-semibold">Save your recovery code</h1>
|
||||||
|
</div>
|
||||||
|
<p className="col-start-1 col-end-3 m-0 mt-1 text-sm">
|
||||||
|
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.
|
||||||
|
</p>
|
||||||
|
{errorMessage && <div>{errorMessage}</div>}
|
||||||
|
{!recoveryCode && (
|
||||||
|
<Button primary small className="col-start-1 col-end-3 mt-3 justify-self-start uppercase" onClick={onClickShow}>
|
||||||
|
Show Recovery Code
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{recoveryCode && (
|
||||||
|
<div className="group relative mt-2 rounded border border-border py-2 px-3 text-sm font-semibold">
|
||||||
|
<StyledTooltip label="Copy to clipboard" className="!z-modal">
|
||||||
|
<button
|
||||||
|
className="absolute top-2 right-2 flex rounded border border-border bg-default p-1 opacity-0 hover:bg-contrast focus:opacity-100 group-hover:opacity-100"
|
||||||
|
onClick={() => {
|
||||||
|
void navigator.clipboard.writeText(recoveryCode)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Icon type="copy" size="small" />
|
||||||
|
</button>
|
||||||
|
</StyledTooltip>
|
||||||
|
{recoveryCode}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default RecoveryCodeBanner
|
||||||
Reference in New Issue
Block a user