feat: improve 2fa styles based on feedback (#635)

* feat: improve 2fa styles based on feedback

* fix: preferences panes and dialogs electron compatibility

* fix: no horizontal line when opening two factor activation

* feat: improve two factor activation styles

* feat: further 2fa style improvements

* feat: padding 2fa widgets

* feat: add padding between QR code and content

* feat: refresh 2fa after passcode confirmation

* feat: don't autocomplete passwords for DecoratedInput
This commit is contained in:
Gorjan Petrovski
2021-09-17 18:14:53 +02:00
committed by GitHub
parent 9d85fbccc4
commit 8fb34f2e85
25 changed files with 494 additions and 228 deletions

View File

@@ -0,0 +1,3 @@
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M17.5001 5.83345L7.50008 15.8334L2.91675 11.2501L4.09175 10.0751L7.50008 13.4751L16.3251 4.65845L17.5001 5.83345Z"/>
</svg>

After

Width:  |  Height:  |  Size: 229 B

View File

@@ -10,6 +10,7 @@ interface Props {
text?: string; text?: string;
placeholder?: string; placeholder?: string;
onChange?: (text: string) => void; onChange?: (text: string) => void;
autocomplete?: boolean;
} }
/** /**
@@ -24,30 +25,47 @@ export const DecoratedInput: FunctionalComponent<Props> = ({
text, text,
placeholder = '', placeholder = '',
onChange, onChange,
autocomplete = false,
}) => { }) => {
const base = const baseClasses =
'rounded py-1.5 px-3 text-input my-1 h-8 flex flex-row items-center gap-4'; 'rounded py-1.5 px-3 text-input my-1 h-8 flex flex-row items-center';
const stateClasses = disabled const stateClasses = disabled
? 'no-border bg-grey-5' ? 'no-border bg-grey-5'
: 'border-solid border-1 border-gray-300'; : 'border-solid border-1 border-gray-300';
const classes = `${base} ${stateClasses} ${className}`; const classes = `${baseClasses} ${stateClasses} ${className}`;
const inputBaseClasses = 'w-full no-border color-black focus:shadow-none';
const inputStateClasses = disabled ? 'overflow-ellipsis' : '';
return ( return (
<div className={`${classes} focus-within:ring-info`}> <div className={`${classes} focus-within:ring-info`}>
{left} {left?.map((leftChild, idx, arr) => (
<>
{leftChild}
{idx < arr.length - 1 && <div className="min-w-3 min-h-1" />}
</>
))}
{left !== undefined && <div className="min-w-7 min-h-1" />}
<div className="flex-grow"> <div className="flex-grow">
<input <input
type={type} type={type}
className="w-full no-border color-black focus:shadow-none" className={`${inputBaseClasses} ${inputStateClasses}`}
disabled={disabled} disabled={disabled}
value={text} value={text}
placeholder={placeholder} placeholder={placeholder}
onChange={(e) => onChange={(e) =>
onChange && onChange((e.target as HTMLInputElement).value) onChange && onChange((e.target as HTMLInputElement).value)
} }
data-lpignore={type !== 'password' ? true : false}
autocomplete={autocomplete ? 'on' : 'off'}
/> />
</div> </div>
{right} {right !== undefined && <div className="min-w-7 min-h-1" />}
{right?.map((rightChild, idx, arr) => (
<>
{rightChild}
{idx < arr.length - 1 && <div className="min-w-3 min-h-1" />}
</>
))}
</div> </div>
); );
}; };

View File

@@ -26,6 +26,7 @@ import UserIcon from '../../icons/ic-user.svg';
import CopyIcon from '../../icons/ic-copy.svg'; import CopyIcon from '../../icons/ic-copy.svg';
import DownloadIcon from '../../icons/ic-download.svg'; import DownloadIcon from '../../icons/ic-download.svg';
import InfoIcon from '../../icons/ic-info.svg'; import InfoIcon from '../../icons/ic-info.svg';
import CheckIcon from '../../icons/ic-check.svg';
import { toDirective } from './utils'; import { toDirective } from './utils';
import { FunctionalComponent } from 'preact'; import { FunctionalComponent } from 'preact';
@@ -58,6 +59,7 @@ const ICONS = {
copy: CopyIcon, copy: CopyIcon,
download: DownloadIcon, download: DownloadIcon,
info: InfoIcon, info: InfoIcon,
check: CheckIcon
}; };
export type IconType = keyof typeof ICONS; export type IconType = keyof typeof ICONS;

View File

@@ -10,6 +10,15 @@ interface Props {
className?: string; className?: string;
icon: IconType; icon: IconType;
iconClassName?: string;
/**
* Button tooltip
*/
title: string;
focusable: boolean;
} }
/** /**
@@ -18,18 +27,20 @@ interface Props {
*/ */
export const IconButton: FunctionComponent<Props> = ({ export const IconButton: FunctionComponent<Props> = ({
onClick, onClick,
className, className = '',
icon, icon,
title,
focusable,
}) => { }) => {
const click = (e: MouseEvent) => { const click = (e: MouseEvent) => {
e.preventDefault(); e.preventDefault();
onClick(); onClick();
}; };
const focusableClass = focusable ? '' : 'focus:shadow-none';
return ( return (
<button <button
className={`no-border cursor-pointer bg-transparent hover:brightness-130 p-0 ${ title={title}
className ?? '' className={`no-border cursor-pointer bg-transparent flex flex-row items-center hover:brightness-130 p-0 ${focusableClass} ${className}`}
}`}
onClick={click} onClick={click}
> >
<Icon type={icon} /> <Icon type={icon} />

View File

@@ -23,7 +23,7 @@ export const Switch: FunctionalComponent<SwitchProps> = (
const className = props.className ?? ''; const className = props.className ?? '';
return ( return (
<label <label
className={`sn-component flex justify-between items-center cursor-pointer hover:bg-contrast px-3 ${className}`} className={`sn-component flex justify-between items-center cursor-pointer px-3 ${className}`}
> >
{props.children} {props.children}
<CustomCheckboxContainer <CustomCheckboxContainer

View File

@@ -1,5 +1,9 @@
import { FunctionComponent } from 'preact'; import { FunctionComponent } from 'preact';
import { AlertDialog, AlertDialogDescription, AlertDialogLabel } from '@node_modules/@reach/alert-dialog'; import {
AlertDialog,
AlertDialogDescription,
AlertDialogLabel,
} from '@node_modules/@reach/alert-dialog';
import { useRef } from '@node_modules/preact/hooks'; import { useRef } from '@node_modules/preact/hooks';
import { IconButton } from '@/components/IconButton'; import { IconButton } from '@/components/IconButton';
@@ -28,11 +32,11 @@ export const ModalDialogLabel: FunctionComponent<{
closeDialog: () => void; closeDialog: () => void;
}> = ({ children, closeDialog }) => ( }> = ({ children, closeDialog }) => (
<AlertDialogLabel className=""> <AlertDialogLabel className="">
<div className="px-4 pt-4 pb-3 flex flex-row"> <div className="px-4 py-4 flex flex-row items-center">
<div className="flex-grow color-black text-lg font-bold"> <div className="flex-grow color-black text-lg font-bold">{children}</div>
{children}
</div>
<IconButton <IconButton
focusable={true}
title="Close"
className="color-grey-1 h-5 w-5" className="color-grey-1 h-5 w-5"
icon="close" icon="close"
onClick={() => closeDialog()} onClick={() => closeDialog()}
@@ -42,16 +46,28 @@ export const ModalDialogLabel: FunctionComponent<{
</AlertDialogLabel> </AlertDialogLabel>
); );
export const ModalDialogDescription: FunctionComponent = ({ children }) => ( export const ModalDialogDescription: FunctionComponent<{ className?: string }> =
<AlertDialogDescription className="px-4 py-4"> ({ children, className = '' }) => (
{children} <AlertDialogDescription
</AlertDialogDescription> className={`px-4 py-4 flex flex-row items-center ${className}`}
); >
{children}
</AlertDialogDescription>
);
export const ModalDialogButtons: FunctionComponent = ({ children }) => ( export const ModalDialogButtons: FunctionComponent = ({ children }) => (
<> <>
<hr className="h-1px bg-border no-border m-0" /> <hr className="h-1px bg-border no-border m-0" />
<div className="px-4 py-4 flex flex-row justify-end gap-3">{children}</div> <div className="px-4 py-4 flex flex-row justify-end items-center">
{children != undefined && Array.isArray(children)
? children.map((child, idx, arr) => (
<>
{child}
{idx < arr.length - 1 ? <div className="min-w-3" /> : undefined}
</>
))
: children}
</div>
</> </>
); );

View File

@@ -56,7 +56,7 @@ export const PreferencesView: FunctionComponent<PreferencesProps> = observer(
(props) => { (props) => {
const menu = new PreferencesMenu(); const menu = new PreferencesMenu();
return ( return (
<div className="sn-full-screen flex flex-col bg-contrast z-index-preferences"> <div className="h-full w-full absolute top-left-0 flex flex-col bg-contrast z-index-preferences">
<TitleBar className="items-center justify-between"> <TitleBar className="items-center justify-between">
{/* div is added so flex justify-between can center the title */} {/* div is added so flex justify-between can center the title */}
<div className="h-8 w-8" /> <div className="h-8 w-8" />

View File

@@ -22,6 +22,7 @@ export const MenuItem: FunctionComponent<Props> = ({
}} }}
> >
<Icon className="icon" type={iconType} /> <Icon className="icon" type={iconType} />
<div className="min-w-1" />
{label} {label}
</div> </div>
); );

View File

@@ -4,17 +4,14 @@ import { HorizontalSeparator } from '@/components/shared/HorizontalSeparator';
const HorizontalLine: FunctionComponent<{ index: number; length: number }> = ({ const HorizontalLine: FunctionComponent<{ index: number; length: number }> = ({
index, index,
length, length,
}) => }) => (index < length - 1 ? <HorizontalSeparator classes="my-4" /> : null);
index < length - 1 ? (
<HorizontalSeparator />
) : null;
export const PreferencesSegment: FunctionComponent = ({ children }) => ( export const PreferencesSegment: FunctionComponent = ({ children }) => (
<div className="flex flex-col">{children}</div> <div className="flex flex-col">{children}</div>
); );
export const PreferencesGroup: FunctionComponent = ({ children }) => ( export const PreferencesGroup: FunctionComponent = ({ children }) => (
<div className="bg-default border-1 border-solid rounded border-gray-300 px-6 py-6 flex flex-col gap-2"> <div className="bg-default border-1 border-solid rounded border-gray-300 px-6 py-6 flex flex-col">
{Array.isArray(children) {Array.isArray(children)
? children ? children
.filter( .filter(
@@ -33,7 +30,16 @@ export const PreferencesGroup: FunctionComponent = ({ children }) => (
export const PreferencesPane: FunctionComponent = ({ children }) => ( export const PreferencesPane: FunctionComponent = ({ children }) => (
<div className="color-black flex-grow flex flex-row overflow-y-auto min-h-0"> <div className="color-black flex-grow flex flex-row overflow-y-auto min-h-0">
<div className="flex-grow flex flex-col py-6 items-center"> <div className="flex-grow flex flex-col py-6 items-center">
<div className="w-125 max-w-125 flex flex-col gap-3">{children}</div> <div className="w-125 max-w-125 flex flex-col">
{children != undefined && Array.isArray(children)
? children.map((child, idx, arr) => (
<>
{child}
{idx < arr.length - 1 ? <div className="min-h-3" /> : undefined}
</>
))
: children}
</div>
</div> </div>
<div className="flex-basis-55 flex-shrink" /> <div className="flex-basis-55 flex-shrink" />
</div> </div>

View File

@@ -3,7 +3,7 @@ import {
ModalDialog, ModalDialog,
ModalDialogButtons, ModalDialogButtons,
ModalDialogDescription, ModalDialogDescription,
ModalDialogLabel ModalDialogLabel,
} from '@/components/shared/ModalDialog'; } from '@/components/shared/ModalDialog';
import { Button } from '@/components/Button'; import { Button } from '@/components/Button';
import { FunctionalComponent } from 'preact'; import { FunctionalComponent } from 'preact';
@@ -16,28 +16,30 @@ import { isEmailValid } from '@/utils';
enum SubmitButtonTitles { enum SubmitButtonTitles {
Default = 'Continue', Default = 'Continue',
GeneratingKeys = 'Generating Keys...', GeneratingKeys = 'Generating Keys...',
Finish = 'Finish' Finish = 'Finish',
} }
enum Steps { enum Steps {
InitialStep, InitialStep,
FinishStep FinishStep,
} }
type Props = { type Props = {
onCloseDialog: () => void; onCloseDialog: () => void;
application: WebApplication; application: WebApplication;
} };
export const ChangeEmail: FunctionalComponent<Props> = ({ export const ChangeEmail: FunctionalComponent<Props> = ({
onCloseDialog, onCloseDialog,
application application,
}) => { }) => {
const [currentPassword, setCurrentPassword] = useState(''); const [currentPassword, setCurrentPassword] = useState('');
const [newEmail, setNewEmail] = useState(''); const [newEmail, setNewEmail] = useState('');
const [isContinuing, setIsContinuing] = useState(false); const [isContinuing, setIsContinuing] = useState(false);
const [lockContinue, setLockContinue] = useState(false); const [lockContinue, setLockContinue] = useState(false);
const [submitButtonTitle, setSubmitButtonTitle] = useState(SubmitButtonTitles.Default); const [submitButtonTitle, setSubmitButtonTitle] = useState(
SubmitButtonTitles.Default
);
const [currentStep, setCurrentStep] = useState(Steps.InitialStep); const [currentStep, setCurrentStep] = useState(Steps.InitialStep);
useBeforeUnload(); useBeforeUnload();
@@ -46,9 +48,7 @@ export const ChangeEmail: FunctionalComponent<Props> = ({
const validateCurrentPassword = async () => { const validateCurrentPassword = async () => {
if (!currentPassword || currentPassword.length === 0) { if (!currentPassword || currentPassword.length === 0) {
applicationAlertService.alert( applicationAlertService.alert('Please enter your current password.');
'Please enter your current password.'
);
return false; return false;
} }
@@ -67,7 +67,9 @@ export const ChangeEmail: FunctionalComponent<Props> = ({
const validateNewEmail = async () => { const validateNewEmail = async () => {
if (!isEmailValid(newEmail)) { if (!isEmailValid(newEmail)) {
applicationAlertService.alert('The email you entered has an invalid format. Please review your input and try again.'); applicationAlertService.alert(
'The email you entered has an invalid format. Please review your input and try again.'
);
return false; return false;
} }
@@ -85,10 +87,7 @@ export const ChangeEmail: FunctionalComponent<Props> = ({
setLockContinue(true); setLockContinue(true);
const response = await application.changeEmail( const response = await application.changeEmail(newEmail, currentPassword);
newEmail,
currentPassword,
);
const success = !response.error; const success = !response.error;
@@ -121,7 +120,8 @@ export const ChangeEmail: FunctionalComponent<Props> = ({
setIsContinuing(true); setIsContinuing(true);
setSubmitButtonTitle(SubmitButtonTitles.GeneratingKeys); setSubmitButtonTitle(SubmitButtonTitles.GeneratingKeys);
const valid = await validateCurrentPassword() && await validateNewEmail(); const valid =
(await validateCurrentPassword()) && (await validateNewEmail());
if (!valid) { if (!valid) {
resetProgressState(); resetProgressState();
@@ -169,15 +169,15 @@ export const ChangeEmail: FunctionalComponent<Props> = ({
<ModalDialogButtons> <ModalDialogButtons>
{currentStep === Steps.InitialStep && ( {currentStep === Steps.InitialStep && (
<Button <Button
className='min-w-20' className="min-w-20"
type='normal' type="normal"
label='Cancel' label="Cancel"
onClick={handleDialogClose} onClick={handleDialogClose}
/> />
)} )}
<Button <Button
className='min-w-20' className="min-w-20"
type='primary' type="primary"
label={submitButtonTitle} label={submitButtonTitle}
onClick={handleSubmit} onClick={handleSubmit}
/> />

View File

@@ -1,18 +1,21 @@
import { Icon, IconType } from '@/components/Icon'; import { Icon, IconType } from '@/components/Icon';
import { IconButton } from '@/components/IconButton';
import { import {
Disclosure, Disclosure,
DisclosureButton, DisclosureButton,
DisclosurePanel, DisclosurePanel,
} from '@reach/disclosure'; } from '@reach/disclosure';
import { FunctionComponent } from 'preact'; import { FunctionComponent } from 'preact';
import { useState, useRef, useEffect } from 'react'; import { useState, useRef, useEffect, MouseEventHandler } from 'react';
const DisclosureIconButton: FunctionComponent<{ const DisclosureIconButton: FunctionComponent<{
className?: string; className?: string;
icon: IconType; icon: IconType;
}> = ({ className = '', icon }) => ( onMouseEnter?: MouseEventHandler;
onMouseLeave?: MouseEventHandler;
}> = ({ className = '', icon, onMouseEnter, onMouseLeave }) => (
<DisclosureButton <DisclosureButton
onMouseEnter={onMouseEnter}
onMouseLeave={onMouseLeave}
className={`no-border cursor-pointer bg-transparent hover:brightness-130 p-0 ${ className={`no-border cursor-pointer bg-transparent hover:brightness-130 p-0 ${
className ?? '' className ?? ''
}`} }`}
@@ -29,11 +32,12 @@ const DisclosureIconButton: FunctionComponent<{
* @returns * @returns
*/ */
export const AuthAppInfoTooltip: FunctionComponent = () => { export const AuthAppInfoTooltip: FunctionComponent = () => {
const [isShown, setShown] = useState(false); const [isClicked, setClicked] = useState(false);
const [isHover, setHover] = useState(false);
const ref = useRef(null); const ref = useRef(null);
useEffect(() => { useEffect(() => {
const dismiss = () => setShown(false); const dismiss = () => setClicked(false);
document.addEventListener('mousedown', dismiss); document.addEventListener('mousedown', dismiss);
return () => { return () => {
document.removeEventListener('mousedown', dismiss); document.removeEventListener('mousedown', dismiss);
@@ -41,9 +45,17 @@ export const AuthAppInfoTooltip: FunctionComponent = () => {
}, [ref]); }, [ref]);
return ( return (
<Disclosure open={isShown} onChange={() => setShown(!isShown)}> <Disclosure
open={isClicked || isHover}
onChange={() => setClicked(!isClicked)}
>
<div className="relative"> <div className="relative">
<DisclosureIconButton icon="info" className="mt-1" /> <DisclosureIconButton
icon="info"
className="mt-1"
onMouseEnter={() => setHover(true)}
onMouseLeave={() => setHover(false)}
/>
<DisclosurePanel> <DisclosurePanel>
<div <div
className={`bg-black color-white text-center rounded shadow-overlay className={`bg-black color-white text-center rounded shadow-overlay

View File

@@ -0,0 +1,7 @@
import { FunctionComponent } from 'preact';
export const Bullet: FunctionComponent<{ className?: string }> = ({
className = '',
}) => (
<div className={`min-w-1 min-h-1 rounded-full bg-black ${className} mr-2`} />
);

View File

@@ -0,0 +1,23 @@
import { FunctionComponent } from 'preact';
import { IconButton } from '../../../components/IconButton';
import { useState } from 'preact/hooks';
export const CopyButton: FunctionComponent<{ copyValue: string }> = ({
copyValue: secretKey,
}) => {
const [isCopied, setCopied] = useState(false);
return (
<IconButton
focusable={false}
title="Copy to clipboard"
icon={isCopied ? 'check' : 'copy'}
className={isCopied ? 'success' : undefined}
onClick={() => {
navigator?.clipboard?.writeText(secretKey);
setCopied(() => true);
}}
/>
);
};

View File

@@ -3,13 +3,15 @@ import { DecoratedInput } from '@/components/DecoratedInput';
import { IconButton } from '@/components/IconButton'; import { IconButton } from '@/components/IconButton';
import { observer } from 'mobx-react-lite'; import { observer } from 'mobx-react-lite';
import { FunctionComponent } from 'preact'; import { FunctionComponent } from 'preact';
import { CopyButton } from './CopyButton';
import { Bullet } from './Bullet';
import { downloadSecretKey } from './download-secret-key'; import { downloadSecretKey } from './download-secret-key';
import { TwoFactorActivation } from './TwoFactorActivation'; import { TwoFactorActivation } from './TwoFactorActivation';
import { import {
ModalDialog, ModalDialog,
ModalDialogButtons, ModalDialogButtons,
ModalDialogDescription, ModalDialogDescription,
ModalDialogLabel ModalDialogLabel,
} from '@/components/shared/ModalDialog'; } from '@/components/shared/ModalDialog';
export const SaveSecretKey: FunctionComponent<{ export const SaveSecretKey: FunctionComponent<{
@@ -17,20 +19,14 @@ export const SaveSecretKey: FunctionComponent<{
}> = observer(({ activation: act }) => { }> = observer(({ activation: act }) => {
const download = ( const download = (
<IconButton <IconButton
focusable={false}
title="Download"
icon="download" icon="download"
onClick={() => { onClick={() => {
downloadSecretKey(act.secretKey); downloadSecretKey(act.secretKey);
}} }}
/> />
); );
const copy = (
<IconButton
icon="copy"
onClick={() => {
navigator?.clipboard?.writeText(act.secretKey);
}}
/>
);
return ( return (
<ModalDialog> <ModalDialog>
<ModalDialogLabel <ModalDialogLabel
@@ -40,11 +36,13 @@ export const SaveSecretKey: FunctionComponent<{
> >
Step 2 of 3 - Save secret key Step 2 of 3 - Save secret key
</ModalDialogLabel> </ModalDialogLabel>
<ModalDialogDescription> <ModalDialogDescription className="h-33">
<div className="flex-grow flex flex-col gap-2"> <div className="flex-grow flex flex-col">
<div className="flex flex-row items-center gap-1"> <div className="flex flex-row items-center">
<Bullet />
<div className="min-w-1" />
<div className="text-sm"> <div className="text-sm">
<b>Save your secret key</b>{' '} <b>Save your secret key</b>{' '}
<a <a
target="_blank" target="_blank"
href="https://standardnotes.com/help/21/where-should-i-store-my-two-factor-authentication-secret-key" href="https://standardnotes.com/help/21/where-should-i-store-my-two-factor-authentication-secret-key"
@@ -53,21 +51,27 @@ export const SaveSecretKey: FunctionComponent<{
</a> </a>
: :
</div> </div>
<div className="min-w-2" />
<DecoratedInput <DecoratedInput
disabled={true} disabled={true}
right={[copy, download]} right={[<CopyButton copyValue={act.secretKey} />, download]}
text={act.secretKey} text={act.secretKey}
/> />
</div> </div>
<div className="text-sm"> <div className="h-2" />
You can use this key to generate codes if you lose access to your <div className="flex flex-row items-center">
authenticator app.{' '} <Bullet />
<a <div className="min-w-1" />
target="_blank" <div className="text-sm">
href="https://standardnotes.com/help/22/what-happens-if-i-lose-my-2fa-device-and-my-secret-key" You can use this key to generate codes if you lose access to your
> authenticator app.{' '}
Learn more <a
</a> target="_blank"
href="https://standardnotes.com/help/22/what-happens-if-i-lose-my-2fa-device-and-my-secret-key"
>
Learn more
</a>
</div>
</div> </div>
</div> </div>
</ModalDialogDescription> </ModalDialogDescription>

View File

@@ -4,7 +4,6 @@ import { observer } from 'mobx-react-lite';
import QRCode from 'qrcode.react'; import QRCode from 'qrcode.react';
import { DecoratedInput } from '@/components/DecoratedInput'; import { DecoratedInput } from '@/components/DecoratedInput';
import { IconButton } from '@/components/IconButton';
import { Button } from '@/components/Button'; import { Button } from '@/components/Button';
import { TwoFactorActivation } from './TwoFactorActivation'; import { TwoFactorActivation } from './TwoFactorActivation';
import { AuthAppInfoTooltip } from './AuthAppInfoPopup'; import { AuthAppInfoTooltip } from './AuthAppInfoPopup';
@@ -12,54 +11,49 @@ import {
ModalDialog, ModalDialog,
ModalDialogButtons, ModalDialogButtons,
ModalDialogDescription, ModalDialogDescription,
ModalDialogLabel ModalDialogLabel,
} from '@/components/shared/ModalDialog'; } from '@/components/shared/ModalDialog';
import { CopyButton } from './CopyButton';
import { Bullet } from './Bullet';
export const ScanQRCode: FunctionComponent<{ export const ScanQRCode: FunctionComponent<{
activation: TwoFactorActivation; activation: TwoFactorActivation;
}> = observer(({ activation: act }) => { }> = observer(({ activation: act }) => {
const copy = (
<IconButton
icon="copy"
onClick={() => {
navigator?.clipboard?.writeText(act.secretKey);
}}
/>
);
return ( return (
<ModalDialog> <ModalDialog>
<ModalDialogLabel <ModalDialogLabel closeDialog={act.cancelActivation}>
closeDialog={() => {
act.cancelActivation();
}}
>
Step 1 of 3 - Scan QR code Step 1 of 3 - Scan QR code
</ModalDialogLabel> </ModalDialogLabel>
<ModalDialogDescription> <ModalDialogDescription className="h-33">
<div className="flex flex-row gap-3 items-center"> <div className="w-25 h-25 flex items-center justify-center bg-info">
<div className="w-25 h-25 flex items-center justify-center bg-info"> <QRCode value={act.qrCode} size={100} />
<QRCode value={act.qrCode} size={100} /> </div>
</div> <div className="min-w-5" />
<div className="flex-grow flex flex-col gap-2"> <div className="flex-grow flex flex-col">
<div className="flex flex-row gap-1 items-center"> <div className="flex flex-row items-center">
<div className="text-sm"> <Bullet />
Open your <b>authenticator app</b>. <div className="min-w-1" />
</div> <div className="text-sm">
<AuthAppInfoTooltip /> Open your <b>authenticator app</b>.
</div> </div>
<div className="flex flex-row items-center"> <div className="min-w-2" />
<div className="text-sm flex-grow"> <AuthAppInfoTooltip />
<b>Scan this QR code</b> or <b>add this secret key</b>: </div>
</div> <div className="min-h-2" />
<div className="w-56"> <div className="flex flex-row items-center">
<DecoratedInput <Bullet className="self-start mt-2" />
disabled={true} <div className="min-w-1" />
text={act.secretKey} <div className="text-sm flex-grow">
right={[copy]} <b>Scan this QR code</b> or <b>add this secret key</b>:
/>
</div>
</div> </div>
</div> </div>
<div className="min-h-2" />
<DecoratedInput
className="ml-4 w-92"
disabled={true}
text={act.secretKey}
right={[<CopyButton copyValue={act.secretKey} />]}
/>
</div> </div>
</ModalDialogDescription> </ModalDialogDescription>
<ModalDialogButtons> <ModalDialogButtons>

View File

@@ -1,8 +1,16 @@
import { MfaProvider, UserProvider } from '../../providers'; import { MfaProvider } from '../../providers';
import { action, makeAutoObservable, observable } from 'mobx'; import { action, makeAutoObservable, observable } from 'mobx';
type ActivationStep = 'scan-qr-code' | 'save-secret-key' | 'verification'; type ActivationStep =
type VerificationStatus = 'none' | 'invalid' | 'valid'; | 'scan-qr-code'
| 'save-secret-key'
| 'verification'
| 'success';
type VerificationStatus =
| 'none'
| 'invalid-auth-code'
| 'invalid-secret'
| 'valid';
export class TwoFactorActivation { export class TwoFactorActivation {
public readonly type = 'two-factor-activation' as const; public readonly type = 'two-factor-activation' as const;
@@ -16,7 +24,7 @@ export class TwoFactorActivation {
constructor( constructor(
private mfaProvider: MfaProvider, private mfaProvider: MfaProvider,
private userProvider: UserProvider, private readonly email: string,
private readonly _secretKey: string, private readonly _secretKey: string,
private _cancelActivation: () => void, private _cancelActivation: () => void,
private _enabled2FA: () => void private _enabled2FA: () => void
@@ -29,7 +37,6 @@ export class TwoFactorActivation {
| '_authCode' | '_authCode'
| '_step' | '_step'
| '_enable2FAVerification' | '_enable2FAVerification'
| 'refreshOtp'
| 'inputOtpToken' | 'inputOtpToken'
| 'inputSecretKey' | 'inputSecretKey'
>( >(
@@ -39,7 +46,6 @@ export class TwoFactorActivation {
_authCode: observable, _authCode: observable,
_step: observable, _step: observable,
_enable2FAVerification: observable, _enable2FAVerification: observable,
refreshOtp: action,
inputOtpToken: observable, inputOtpToken: observable,
inputSecretKey: observable, inputSecretKey: observable,
}, },
@@ -60,8 +66,7 @@ export class TwoFactorActivation {
} }
get qrCode(): string { get qrCode(): string {
const email = this.userProvider.getUser()!.email; return `otpauth://totp/2FA?secret=${this._secretKey}&issuer=Standard%20Notes&label=${this.email}`;
return `otpauth://totp/2FA?secret=${this._secretKey}&issuer=Standard%20Notes&label=${email}`;
} }
cancelActivation(): void { cancelActivation(): void {
@@ -69,8 +74,7 @@ export class TwoFactorActivation {
} }
openScanQRCode(): void { openScanQRCode(): void {
const preconditions: ActivationStep[] = ['save-secret-key']; if (this._activationStep === 'save-secret-key') {
if (preconditions.includes(this._activationStep)) {
this._activationStep = 'scan-qr-code'; this._activationStep = 'scan-qr-code';
} }
} }
@@ -85,13 +89,18 @@ export class TwoFactorActivation {
openVerification(): void { openVerification(): void {
this.inputOtpToken = ''; this.inputOtpToken = '';
this.inputSecretKey = ''; this.inputSecretKey = '';
const preconditions: ActivationStep[] = ['save-secret-key']; if (this._activationStep === 'save-secret-key') {
if (preconditions.includes(this._activationStep)) {
this._activationStep = 'verification'; this._activationStep = 'verification';
this._2FAVerification = 'none'; this._2FAVerification = 'none';
} }
} }
openSuccess(): void {
if (this._activationStep === 'verification') {
this._activationStep = 'success';
}
}
setInputSecretKey(secretKey: string): void { setInputSecretKey(secretKey: string): void {
this.inputSecretKey = secretKey; this.inputSecretKey = secretKey;
} }
@@ -101,22 +110,29 @@ export class TwoFactorActivation {
} }
enable2FA(): void { enable2FA(): void {
if (this.inputSecretKey === this._secretKey) { if (this.inputSecretKey !== this._secretKey) {
this.mfaProvider this._2FAVerification = 'invalid-secret';
.enableMfa(this.inputSecretKey, this.inputOtpToken) return;
.then( }
action(() => {
this._2FAVerification = 'valid'; this.mfaProvider
this._enabled2FA(); .enableMfa(this.inputSecretKey, this.inputOtpToken)
}) .then(
) action(() => {
.catch( this._2FAVerification = 'valid';
action(() => { this.openSuccess();
this._2FAVerification = 'invalid'; })
}) )
); .catch(
} else { action(() => {
this._2FAVerification = 'invalid'; this._2FAVerification = 'invalid-auth-code';
})
);
}
finishActivation(): void {
if (this._activationStep === 'success') {
this._enabled2FA();
} }
} }
} }

View File

@@ -4,17 +4,19 @@ import { TwoFactorActivation } from './TwoFactorActivation';
import { SaveSecretKey } from './SaveSecretKey'; import { SaveSecretKey } from './SaveSecretKey';
import { ScanQRCode } from './ScanQRCode'; import { ScanQRCode } from './ScanQRCode';
import { Verification } from './Verification'; import { Verification } from './Verification';
import { TwoFactorSuccess } from './TwoFactorSuccess';
export const TwoFactorActivationView: FunctionComponent<{ export const TwoFactorActivationView: FunctionComponent<{
activation: TwoFactorActivation; activation: TwoFactorActivation;
}> = observer(({ activation: act }) => ( }> = observer(({ activation: act }) => {
<> switch (act.activationStep) {
{act.activationStep === 'scan-qr-code' && <ScanQRCode activation={act} />} case 'scan-qr-code':
return <ScanQRCode activation={act} />;
{act.activationStep === 'save-secret-key' && ( case 'save-secret-key':
<SaveSecretKey activation={act} /> return <SaveSecretKey activation={act} />;
)} case 'verification':
return <Verification activation={act} />;
{act.activationStep === 'verification' && <Verification activation={act} />} case 'success':
</> return <TwoFactorSuccess activation={act} />;
)); }
});

View File

@@ -7,17 +7,21 @@ type TwoFactorStatus =
| TwoFactorActivation | TwoFactorActivation
| 'two-factor-disabled'; | 'two-factor-disabled';
export const is2FADisabled = (status: TwoFactorStatus): status is 'two-factor-disabled' => export const is2FADisabled = (
status === 'two-factor-disabled'; status: TwoFactorStatus
): status is 'two-factor-disabled' => status === 'two-factor-disabled';
export const is2FAActivation = (status: TwoFactorStatus): status is TwoFactorActivation => export const is2FAActivation = (
status: TwoFactorStatus
): status is TwoFactorActivation =>
(status as TwoFactorActivation)?.type === 'two-factor-activation'; (status as TwoFactorActivation)?.type === 'two-factor-activation';
export const is2FAEnabled = (status: TwoFactorStatus): status is 'two-factor-enabled' => export const is2FAEnabled = (
status === 'two-factor-enabled'; status: TwoFactorStatus
): status is 'two-factor-enabled' => status === 'two-factor-enabled';
export class TwoFactorAuth { export class TwoFactorAuth {
private _status: TwoFactorStatus | 'fetching' = 'fetching'; private _status: TwoFactorStatus = 'two-factor-disabled';
private _errorMessage: string | null; private _errorMessage: string | null;
constructor( constructor(
@@ -29,24 +33,28 @@ export class TwoFactorAuth {
makeAutoObservable< makeAutoObservable<
TwoFactorAuth, TwoFactorAuth,
'_status' | '_errorMessage' | 'deactivateMfa' | 'startActivation' '_status' | '_errorMessage' | 'deactivateMfa' | 'startActivation'
>(this, { >(
_status: observable, this,
_errorMessage: observable, {
deactivateMfa: action, _status: observable,
startActivation: action, _errorMessage: observable,
}); deactivateMfa: action,
startActivation: action,
},
{ autoBind: true }
);
} }
private startActivation(): void { private startActivation(): void {
const setDisabled = action(() => (this._status = 'two-factor-disabled')); const setDisabled = action(() => (this._status = 'two-factor-disabled'));
const setEnabled = action(() => (this._status = 'two-factor-enabled')); const setEnabled = action(() => this.fetchStatus());
this.mfaProvider this.mfaProvider
.generateMfaSecret() .generateMfaSecret()
.then( .then(
action((secret) => { action((secret) => {
this._status = new TwoFactorActivation( this._status = new TwoFactorActivation(
this.mfaProvider, this.mfaProvider,
this.userProvider, this.userProvider.getUser()!.email,
secret, secret,
setDisabled, setDisabled,
setEnabled setEnabled
@@ -65,7 +73,7 @@ export class TwoFactorAuth {
.disableMfa() .disableMfa()
.then( .then(
action(() => { action(() => {
this._status = 'two-factor-disabled'; this.fetchStatus();
}) })
) )
.catch( .catch(
@@ -75,18 +83,16 @@ export class TwoFactorAuth {
); );
} }
private get isLoggedIn(): boolean { isLoggedIn(): boolean {
return this.userProvider.getUser() != undefined; return this.userProvider.getUser() != undefined;
} }
fetchStatus(): void { fetchStatus(): void {
this._status = 'fetching'; if (!this.isLoggedIn()) {
if (!this.isLoggedIn) {
return; return;
} }
if (!this.isMfaFeatureAvailable) { if (!this.isMfaFeatureAvailable()) {
return; return;
} }
@@ -111,11 +117,11 @@ export class TwoFactorAuth {
} }
toggle2FA(): void { toggle2FA(): void {
if (!this.isLoggedIn) { if (!this.isLoggedIn()) {
return; return;
} }
if (!this.isMfaFeatureAvailable) { if (!this.isMfaFeatureAvailable()) {
return; return;
} }
@@ -129,23 +135,14 @@ export class TwoFactorAuth {
} }
get errorMessage(): string | null { get errorMessage(): string | null {
if (!this.isLoggedIn) {
return 'Two-factor authentication not available / Sign in or register for an account to configure 2FA';
}
if (!this.isMfaFeatureAvailable) {
return 'Two-factor authentication not available / A paid subscription plan is required to enable 2FA.';
}
return this._errorMessage; return this._errorMessage;
} }
get status(): TwoFactorStatus { get status(): TwoFactorStatus {
if (this._status === 'fetching') {
return 'two-factor-disabled';
}
return this._status; return this._status;
} }
private get isMfaFeatureAvailable(): boolean { isMfaFeatureAvailable(): boolean {
return this.mfaProvider.isMfaFeatureAvailable(); return this.mfaProvider.isMfaFeatureAvailable();
} }
} }

View File

@@ -4,41 +4,85 @@ import {
Text, Text,
PreferencesGroup, PreferencesGroup,
PreferencesSegment, PreferencesSegment,
LinkButton,
} from '../../components'; } from '../../components';
import { Switch } from '../../../components/Switch'; import { Switch } from '../../../components/Switch';
import { observer } from 'mobx-react-lite'; import { observer } from 'mobx-react-lite';
import { is2FAActivation, is2FADisabled, TwoFactorAuth } from './TwoFactorAuth'; import { is2FAActivation, is2FADisabled, TwoFactorAuth } from './TwoFactorAuth';
import { TwoFactorActivationView } from './TwoFactorActivationView'; import { TwoFactorActivationView } from './TwoFactorActivationView';
const TwoFactorTitle: FunctionComponent<{ auth: TwoFactorAuth }> = observer(
({ auth }) => {
if (!auth.isLoggedIn()) {
return <Title>Two-factor authentication not available</Title>;
}
if (!auth.isMfaFeatureAvailable()) {
return <Title>Two-factor authentication not available</Title>;
}
return <Title>Two-factor authentication</Title>;
}
);
const TwoFactorDescription: FunctionComponent<{ auth: TwoFactorAuth }> =
observer(({ auth }) => {
if (!auth.isLoggedIn()) {
return <Text>Sign in or register for an account to configure 2FA.</Text>;
}
if (!auth.isMfaFeatureAvailable()) {
return (
<Text>
A paid subscription plan is required to enable 2FA.{' '}
<a target="_blank" href="https://standardnotes.com/features">
Learn more
</a>
.
</Text>
);
}
return (
<Text>An extra layer of security when logging in to your account.</Text>
);
});
const TwoFactorSwitch: FunctionComponent<{ auth: TwoFactorAuth }> = observer(
({ auth }) => {
if (auth.isLoggedIn() && auth.isMfaFeatureAvailable()) {
return (
<Switch
checked={!is2FADisabled(auth.status)}
onChange={auth.toggle2FA}
/>
);
}
return null;
}
);
export const TwoFactorAuthView: FunctionComponent<{ export const TwoFactorAuthView: FunctionComponent<{
auth: TwoFactorAuth; auth: TwoFactorAuth;
}> = observer(({ auth }) => { }> = observer(({ auth }) => {
return ( return (
<PreferencesGroup> <>
<PreferencesSegment> <PreferencesGroup>
<div className="flex flex-row items-center"> <PreferencesSegment>
<div className="flex-grow flex flex-col"> <div className="flex flex-row items-center">
<Title>Two-factor authentication</Title> <div className="flex-grow flex flex-col">
<Text> <TwoFactorTitle auth={auth} />
An extra layer of security when logging in to your account. <TwoFactorDescription auth={auth} />
</Text> </div>
<TwoFactorSwitch auth={auth} />
</div> </div>
<Switch </PreferencesSegment>
checked={!is2FADisabled(auth.status)}
onChange={auth.toggle2FA}
/>
</div>
</PreferencesSegment>
{auth.errorMessage != null && (
<PreferencesSegment>
<Text className="color-danger">{auth.errorMessage}</Text>
</PreferencesSegment>
)}
</PreferencesGroup>
{is2FAActivation(auth.status) && ( {is2FAActivation(auth.status) && (
<TwoFactorActivationView activation={auth.status} /> <TwoFactorActivationView activation={auth.status} />
)} )}
</>
{auth.errorMessage != null && (
<PreferencesSegment>
<Text className="color-danger">{auth.errorMessage}</Text>
</PreferencesSegment>
)}
</PreferencesGroup>
); );
}); });

View File

@@ -0,0 +1,36 @@
import { Button } from '@/components/Button';
import ModalDialog, {
ModalDialogButtons,
ModalDialogDescription,
ModalDialogLabel,
} from '@/components/shared/ModalDialog';
import { Subtitle } from '@/preferences/components';
import { observer } from 'mobx-react-lite';
import { FunctionComponent } from 'preact';
import { TwoFactorActivation } from './TwoFactorActivation';
export const TwoFactorSuccess: FunctionComponent<{
activation: TwoFactorActivation;
}> = observer(({ activation: act }) => (
<ModalDialog>
<ModalDialogLabel closeDialog={act.finishActivation}>
Successfully Enabled
</ModalDialogLabel>
<ModalDialogDescription>
<div className="flex flex-row items-center justify-center pt-2">
<Subtitle>
Two-factor authentication has been successfully enabled for your
account.
</Subtitle>
</div>
</ModalDialogDescription>
<ModalDialogButtons>
<Button
className="min-w-20"
type="primary"
label="Finish"
onClick={act.finishActivation}
/>
</ModalDialogButtons>
</ModalDialog>
));

View File

@@ -2,51 +2,66 @@ import { Button } from '@/components/Button';
import { DecoratedInput } from '@/components/DecoratedInput'; import { DecoratedInput } from '@/components/DecoratedInput';
import { observer } from 'mobx-react-lite'; import { observer } from 'mobx-react-lite';
import { FunctionComponent } from 'preact'; import { FunctionComponent } from 'preact';
import { Bullet } from './Bullet';
import { TwoFactorActivation } from './TwoFactorActivation'; import { TwoFactorActivation } from './TwoFactorActivation';
import { import {
ModalDialog, ModalDialog,
ModalDialogButtons, ModalDialogButtons,
ModalDialogDescription, ModalDialogDescription,
ModalDialogLabel ModalDialogLabel,
} from '@/components/shared/ModalDialog'; } from '@/components/shared/ModalDialog';
export const Verification: FunctionComponent<{ export const Verification: FunctionComponent<{
activation: TwoFactorActivation; activation: TwoFactorActivation;
}> = observer(({ activation: act }) => { }> = observer(({ activation: act }) => {
const borderInv = const secretKeyClass =
act.verificationStatus === 'invalid' ? 'border-dark-red' : ''; act.verificationStatus === 'invalid-secret' ? 'border-danger' : '';
const authTokenClass =
act.verificationStatus === 'invalid-auth-code' ? 'border-danger' : '';
return ( return (
<ModalDialog> <ModalDialog>
<ModalDialogLabel closeDialog={act.cancelActivation}> <ModalDialogLabel closeDialog={act.cancelActivation}>
Step 3 of 3 - Verification Step 3 of 3 - Verification
</ModalDialogLabel> </ModalDialogLabel>
<ModalDialogDescription> <ModalDialogDescription className="h-33">
<div className="flex-grow flex flex-col gap-1"> <div className="flex-grow flex flex-col">
<div className="flex flex-row items-center gap-2"> <div className="flex flex-row items-center">
<Bullet />
<div className="min-w-1" />
<div className="text-sm"> <div className="text-sm">
Enter your <b>secret key</b>: Enter your <b>secret key</b>:
</div> </div>
<div className="min-w-2" />
<DecoratedInput <DecoratedInput
className={borderInv} className={`w-92 ${secretKeyClass}`}
onChange={act.setInputSecretKey} onChange={act.setInputSecretKey}
/> />
</div> </div>
<div className="flex flex-row items-center gap-2"> <div className="min-h-1" />
<div className="flex flex-row items-center">
<Bullet />
<div className="min-w-1" />
<div className="text-sm"> <div className="text-sm">
Verify the <b>authentication code</b> generated by your Verify the <b>authentication code</b> generated by your
authenticator app: authenticator app:
</div> </div>
<div className="min-w-2" />
<DecoratedInput <DecoratedInput
className={`w-30 ${borderInv}`} className={`w-30 ${authTokenClass}`}
onChange={act.setInputOtpToken} onChange={act.setInputOtpToken}
/> />
</div> </div>
</div> </div>
</ModalDialogDescription> </ModalDialogDescription>
<ModalDialogButtons> <ModalDialogButtons>
{act.verificationStatus === 'invalid' && ( {act.verificationStatus === 'invalid-auth-code' && (
<div className="text-sm color-danger"> <div className="text-sm color-danger flex-grow">
Incorrect credentials, please try again. Incorrect authentication code, please try again.
</div>
)}
{act.verificationStatus === 'invalid-secret' && (
<div className="text-sm color-danger flex-grow">
Incorrect secret key, please try again.
</div> </div>
)} )}
<Button <Button

View File

@@ -13,7 +13,6 @@
@extend .items-center; @extend .items-center;
@extend .justify-start; @extend .justify-start;
@extend .gap-1;
@extend .text-sm; @extend .text-sm;
@extend .cursor-pointer; @extend .cursor-pointer;

View File

@@ -4,6 +4,10 @@
height: 90vh; height: 90vh;
} }
.h-33 {
height: 8.25rem;
}
.hidden { .hidden {
display: none; display: none;
} }
@@ -150,6 +154,62 @@
@extend .font-bold; @extend .font-bold;
} }
.ml-4 {
margin-left: 1rem;
}
.mr-3 { .mr-3 {
margin-right: 0.75rem; margin-right: 0.75rem;
} }
.my-4 {
margin-top: 1rem;
margin-bottom: 1rem;
}
.w-92 {
width: 23rem;
}
.min-w-1 {
min-width: 0.25rem;
}
.min-w-2 {
min-width: 0.5rem;
}
.min-w-3 {
min-width: 0.75rem;
}
.min-w-5 {
min-width: 1.25rem;
}
.min-w-7 {
min-width: 1.75rem;
}
.min-h-1 {
min-height: 0.25rem;
}
.min-h-2 {
min-height: 0.5rem;
}
.min-h-3 {
min-height: 0.75rem;
}
.border-danger {
border-color: var(--sn-stylekit-danger-color);
}
.pt-1 {
padding-top: 0.25rem;
}
.pt-2 {
padding-top: 0.5rem;
}

View File

@@ -72,7 +72,7 @@
"@reach/dialog": "^0.13.0", "@reach/dialog": "^0.13.0",
"@standardnotes/sncrypto-web": "1.5.2", "@standardnotes/sncrypto-web": "1.5.2",
"@standardnotes/features": "1.6.1", "@standardnotes/features": "1.6.1",
"@standardnotes/snjs": "2.14.3", "@standardnotes/snjs": "2.14.5",
"mobx": "^6.3.2", "mobx": "^6.3.2",
"mobx-react-lite": "^3.2.0", "mobx-react-lite": "^3.2.0",
"preact": "^10.5.12", "preact": "^10.5.12",

View File

@@ -2069,10 +2069,10 @@
"@standardnotes/sncrypto-common" "^1.5.2" "@standardnotes/sncrypto-common" "^1.5.2"
libsodium-wrappers "^0.7.8" libsodium-wrappers "^0.7.8"
"@standardnotes/snjs@2.14.3": "@standardnotes/snjs@2.14.5":
version "2.14.3" version "2.14.5"
resolved "https://registry.yarnpkg.com/@standardnotes/snjs/-/snjs-2.14.3.tgz#9d39508d5144db87359b3893ef05da8b0b793053" resolved "https://registry.yarnpkg.com/@standardnotes/snjs/-/snjs-2.14.5.tgz#1a48b71a4ae5dbdc863cdbbb468e8ebf2113f918"
integrity sha512-pX0WoWgY11ZMLdePvRNjnNoYVpTjWn4MLVnZGdSXp+Qz5rra/Y/ZBXFusrHG4+KqAqJ0AVhD6y5AwxtxuJwISQ== integrity sha512-PAjv4VgWs//pzG4aYdnQC+4bY8MmwBgkdbwEfCmWRVXv7o/Mfa4t1ds+FbeiyqoEhwCcdWg3E7RRWPqRH93lQQ==
dependencies: dependencies:
"@standardnotes/auth" "3.7.2" "@standardnotes/auth" "3.7.2"
"@standardnotes/common" "1.1.0" "@standardnotes/common" "1.1.0"