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:
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user