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:
Karol Sójko
2023-01-10 21:33:44 +01:00
committed by GitHub
parent de3fa476c7
commit 5e6c901c21
16 changed files with 234 additions and 32 deletions

View File

@@ -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<Props> = ({
disabled = false,
onPrivateUsernameModeChange,
onStrictSignInChange,
onRecoveryCodesChange,
children,
}) => {
const { server, setServer, enableServerOption, setEnableServerOption } = viewControllerManager.accountMenuController
@@ -29,6 +31,9 @@ const AdvancedOptions: FunctionComponent<Props> = ({
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<Props> = ({
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(
(e) => {
if (e.target instanceof HTMLInputElement) {
@@ -108,7 +135,7 @@ const AdvancedOptions: FunctionComponent<Props> = ({
name="private-workspace"
label="Private username mode"
checked={isPrivateUsername}
disabled={disabled}
disabled={disabled || isRecoveryCodes}
onChange={handleIsPrivateUsernameChange}
/>
<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"
value={privateUsername}
onChange={handlePrivateUsernameNameChange}
disabled={disabled}
disabled={disabled || isRecoveryCodes}
spellcheck={false}
autocomplete={false}
/>
@@ -138,7 +165,7 @@ const AdvancedOptions: FunctionComponent<Props> = ({
name="use-strict-signin"
label="Use strict sign-in"
checked={isStrictSignin}
disabled={disabled}
disabled={disabled || isRecoveryCodes}
onChange={handleStrictSigninChange}
/>
<a
@@ -152,12 +179,38 @@ const AdvancedOptions: FunctionComponent<Props> = ({
</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
name="custom-sync-server"
label="Custom sync server"
checked={enableServerOption}
onChange={handleServerOptionChange}
disabled={disabled}
disabled={disabled || isRecoveryCodes}
/>
<DecoratedInput
type="text"
@@ -165,7 +218,7 @@ const AdvancedOptions: FunctionComponent<Props> = ({
placeholder="https://api.standardnotes.com"
value={server}
onChange={handleSyncServerChange}
disabled={!enableServerOption && !disabled}
disabled={!enableServerOption && !disabled && !isRecoveryCodes}
/>
</div>
) : null}

View File

@@ -23,6 +23,7 @@ const SignInPane: FunctionComponent<Props> = ({ 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<Props> = ({ application, viewControllerManag
const [shouldMergeLocal, setShouldMergeLocal] = useState(true)
const [isPrivateUsername, setIsPrivateUsername] = useState(false)
const [isRecoverySignIn, setIsRecoverySignIn] = useState(false)
const emailInputRef = useRef<HTMLInputElement>(null)
const passwordInputRef = useRef<HTMLInputElement>(null)
@@ -72,6 +75,16 @@ const SignInPane: FunctionComponent<Props> = ({ 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<Props> = ({ 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<Props> = ({ 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<Props> = ({ 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<Props> = ({ application, viewControllerManag
disabled={isSigningIn}
onPrivateUsernameModeChange={onPrivateUsernameChange}
onStrictSignInChange={handleStrictSigninChange}
onRecoveryCodesChange={onRecoveryCodesChange}
/>
</>
)

View File

@@ -27,7 +27,11 @@ const Security: FunctionComponent<SecurityProps> = (props) => {
<ErroredItems viewControllerManager={props.viewControllerManager} />
)}
<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} />}
<PasscodeLock viewControllerManager={props.viewControllerManager} application={props.application} />
{isNativeMobileWeb && <BiometricsLock application={props.application} />}

View File

@@ -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
}

View File

@@ -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<Props> = ({ auth }) => {
const TwoFactorAuthView: FunctionComponent<Props> = ({ auth, application }) => {
return (
<>
<PreferencesGroup>
@@ -34,6 +37,13 @@ const TwoFactorAuthView: FunctionComponent<Props> = ({ auth }) => {
<Text className="text-danger">{auth.errorMessage}</Text>
</PreferencesSegment>
)}
{auth.status !== 'fetching' && is2FAEnabled(auth.status) && (
<PreferencesSegment>
<div className="mt-3">
<RecoveryCodeBanner application={application} />
</div>
</PreferencesSegment>
)}
</PreferencesGroup>
{auth.status !== 'fetching' && is2FAActivation(auth.status) && (
<TwoFactorActivationView activation={auth.status} />

View File

@@ -6,7 +6,7 @@ import TwoFactorAuthView from './TwoFactorAuthView/TwoFactorAuthView'
const TwoFactorAuthWrapper: FunctionComponent<MfaProps> = (props) => {
const [auth] = useState(() => new TwoFactorAuth(props.mfaProvider, props.userProvider))
auth.fetchStatus()
return <TwoFactorAuthView auth={auth} />
return <TwoFactorAuthView auth={auth} application={props.application} />
}
export default TwoFactorAuthWrapper

View File

@@ -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