feat(preferences): 2FA activation dialog with mocked state (#605)

This commit is contained in:
Gorjan Petrovski
2021-07-27 11:32:07 +02:00
committed by GitHub
parent a0dbe6cedd
commit 84bb17ba1d
31 changed files with 845 additions and 278 deletions

View File

@@ -0,0 +1,59 @@
import { Icon, IconType } from '@/components/Icon';
import { IconButton } from '@/components/IconButton';
import {
Disclosure,
DisclosureButton,
DisclosurePanel,
} from '@reach/disclosure';
import { FunctionComponent } from 'preact';
import { useState, useRef, useEffect } from 'react';
const DisclosureIconButton: FunctionComponent<{
className?: string;
icon: IconType;
}> = ({ className = '', icon }) => (
<DisclosureButton
className={`no-border cursor-pointer bg-transparent hover:brightness-130 p-0 ${
className ?? ''
}`}
>
<Icon type={icon} />
</DisclosureButton>
);
/**
* AuthAppInfoPopup is an info icon that shows a tooltip when clicked
* Tooltip is dismissible by clicking outside
*
* Note: it can be generalized but more use cases are required
* @returns
*/
export const AuthAppInfoTooltip: FunctionComponent = () => {
const [isShown, setShown] = useState(false);
const ref = useRef(null);
useEffect(() => {
const dismiss = () => setShown(false);
document.addEventListener('mousedown', dismiss);
return () => {
document.removeEventListener('mousedown', dismiss);
};
}, [ref]);
return (
<Disclosure open={isShown} onChange={() => setShown(!isShown)}>
<div className="relative">
<DisclosureIconButton icon="info" className="mt-1" />
<DisclosurePanel>
<div
className={`bg-black color-white text-center rounded shadow-overlay
py-1.5 px-2 absolute w-103 -top-10 -left-51`}
>
Some apps, like Google Authenticator, do not back up and restore
your secret keys if you lose your device or get a new one.
</div>
</DisclosurePanel>
</div>
</Disclosure>
);
};

View File

@@ -0,0 +1,90 @@
import { Button } from '@/components/Button';
import { DecoratedInput } from '@/components/DecoratedInput';
import { IconButton } from '@/components/IconButton';
import { observer } from 'mobx-react-lite';
import { FunctionComponent } from 'preact';
import { downloadSecretKey } from './download-secret-key';
import { TwoFactorActivation } from './model';
import {
TwoFactorDialog,
TwoFactorDialogLabel,
TwoFactorDialogDescription,
TwoFactorDialogButtons,
} from './TwoFactorDialog';
export const SaveSecretKey: FunctionComponent<{
activation: TwoFactorActivation;
}> = observer(({ activation: act }) => {
const download = (
<IconButton
icon="download"
onClick={() => {
downloadSecretKey(act.secretKey);
}}
/>
);
const copy = (
<IconButton
icon="copy"
onClick={() => {
navigator?.clipboard?.writeText(act.secretKey);
}}
/>
);
return (
<TwoFactorDialog>
<TwoFactorDialogLabel
closeDialog={() => {
act.cancelActivation();
}}
>
Step 2 of 3 - Save secret key
</TwoFactorDialogLabel>
<TwoFactorDialogDescription>
<div className="flex-grow flex flex-col gap-2">
<div className="flex flex-row items-center gap-1">
<div className="text-sm">
<b>Save your secret key</b>{' '}
<a
target="_blank"
href="https://standardnotes.com/help/21/where-should-i-store-my-two-factor-authentication-secret-key"
>
somewhere safe
</a>
:
</div>
<DecoratedInput
disabled={true}
right={[copy, download]}
text={act.secretKey}
/>
</div>
<div className="text-sm">
You can use this key to generate codes if you lose access to your
authenticator app.{' '}
<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>
</TwoFactorDialogDescription>
<TwoFactorDialogButtons>
<Button
className="min-w-20"
type="normal"
label="Back"
onClick={() => act.openScanQRCode()}
/>
<Button
className="min-w-20"
type="primary"
label="Next"
onClick={() => act.openVerification()}
/>
</TwoFactorDialogButtons>
</TwoFactorDialog>
);
});

View File

@@ -0,0 +1,78 @@
import { FunctionComponent } from 'preact';
import { observer } from 'mobx-react-lite';
import { DecoratedInput } from '../../../components/DecoratedInput';
import { IconButton } from '../../../components/IconButton';
import { Button } from '@/components/Button';
import { TwoFactorActivation } from './model';
import {
TwoFactorDialog,
TwoFactorDialogLabel,
TwoFactorDialogDescription,
TwoFactorDialogButtons,
} from './TwoFactorDialog';
import { AuthAppInfoTooltip } from './AuthAppInfoPopup';
export const ScanQRCode: FunctionComponent<{
activation: TwoFactorActivation;
}> = observer(({ activation: act }) => {
const copy = (
<IconButton
icon="copy"
onClick={() => {
navigator?.clipboard?.writeText(act.secretKey);
}}
/>
);
return (
<TwoFactorDialog>
<TwoFactorDialogLabel
closeDialog={() => {
act.cancelActivation();
}}
>
Step 1 of 3 - Scan QR code
</TwoFactorDialogLabel>
<TwoFactorDialogDescription>
<div className="flex flex-row gap-3 items-center">
<div className="w-25 h-25 flex items-center justify-center bg-info">
QR code
</div>
<div className="flex-grow flex flex-col gap-2">
<div className="flex flex-row gap-1 items-center">
<div className="text-sm">
Open your <b>authenticator app</b>.
</div>
<AuthAppInfoTooltip />
</div>
<div className="flex flex-row items-center">
<div className="text-sm flex-grow">
<b>Scan this QR code</b> or <b>add this secret key</b>:
</div>
<div className="w-56">
<DecoratedInput
disabled={true}
text={act.secretKey}
right={[copy]}
/>
</div>
</div>
</div>
</div>
</TwoFactorDialogDescription>
<TwoFactorDialogButtons>
<Button
className="min-w-20"
type="normal"
label="Cancel"
onClick={() => act.cancelActivation()}
/>
<Button
className="min-w-20"
type="primary"
label="Next"
onClick={() => act.openSaveSecretKey()}
/>
</TwoFactorDialogButtons>
</TwoFactorDialog>
);
});

View File

@@ -0,0 +1,18 @@
import { observer } from 'mobx-react-lite';
import { FunctionComponent } from 'preact';
import { TwoFactorActivation } from './model';
import { SaveSecretKey } from './SaveSecretKey';
import { ScanQRCode } from './ScanQRCode';
import { Verification } from './Verification';
export const TwoFactorActivationView: FunctionComponent<{
activation: TwoFactorActivation;
}> = observer(({ activation: act }) => (
<>
{act.step === 'scan-qr-code' && <ScanQRCode activation={act} />}
{act.step === 'save-secret-key' && <SaveSecretKey activation={act} />}
{act.step === 'verification' && <Verification activation={act} />}
</>
));

View File

@@ -0,0 +1,53 @@
import { FunctionComponent } from 'preact';
import {
Title,
Text,
PreferencesGroup,
PreferencesSegment,
} from '../../components';
import { Switch } from '../../../components/Switch';
import { observer } from 'mobx-react-lite';
import {
is2FAActivation,
is2FADisabled,
is2FAEnabled,
TwoFactorAuth,
} from './model';
import { TwoFactorDisabledView } from './TwoFactorDisabledView';
import { TwoFactorEnabledView } from './TwoFactorEnabledView';
import { TwoFactorActivationView } from './TwoFactorActivationView';
export const TwoFactorAuthView: FunctionComponent<{
auth: TwoFactorAuth;
}> = observer(({ auth }) => (
<PreferencesGroup>
<PreferencesSegment>
<div className="flex flex-row items-center">
<div className="flex-grow flex flex-col">
<Title>Two-factor authentication</Title>
<Text>
An extra layer of security when logging in to your account.
</Text>
</div>
<Switch
checked={!is2FADisabled(auth.status)}
onChange={() => auth.toggle2FA()}
/>
</div>
</PreferencesSegment>
<PreferencesSegment>
{is2FAEnabled(auth.status) && (
<TwoFactorEnabledView
secretKey={auth.status.secretKey}
authCode={auth.status.authCode}
/>
)}
{is2FAActivation(auth.status) && (
<TwoFactorActivationView activation={auth.status} />
)}
{!is2FAEnabled(auth.status) && <TwoFactorDisabledView />}
</PreferencesSegment>
</PreferencesGroup>
));

View File

@@ -0,0 +1,64 @@
import { ComponentChildren, FunctionComponent } from 'preact';
import { IconButton } from '../../../components/IconButton';
import {
AlertDialog,
AlertDialogDescription,
AlertDialogLabel,
} from '@reach/alert-dialog';
import { useRef } from 'preact/hooks';
/**
* TwoFactorDialog is AlertDialog styled for 2FA
* Can be generalized but more use cases are needed
*/
export const TwoFactorDialog: FunctionComponent<{
children: ComponentChildren;
}> = ({ children }) => {
const ldRef = useRef<HTMLButtonElement>();
return (
<AlertDialog leastDestructiveRef={ldRef}>
{/* sn-component is focusable by default, but doesn't stretch to child width
resulting in a badly focused dialog. Utility classes are not available
at the sn-component level, only below it. tabIndex -1 disables focus
and enables it on the child component */}
<div tabIndex={-1} className="sn-component">
<div
tabIndex={0}
className="w-160 bg-default rounded shadow-overlay focus:padded-ring-info"
>
{children}
</div>
</div>
</AlertDialog>
);
};
export const TwoFactorDialogLabel: FunctionComponent<{
closeDialog: () => void;
}> = ({ children, closeDialog }) => (
<AlertDialogLabel className="">
<div className="px-4 pt-4 pb-3 flex flex-row">
<div className="flex-grow color-black text-lg font-bold">{children}</div>
<IconButton
className="color-grey-1 h-5 w-5"
icon="close"
onClick={() => closeDialog()}
/>
</div>
<hr className="h-1px bg-border no-border m-0" />
</AlertDialogLabel>
);
export const TwoFactorDialogDescription: FunctionComponent = ({ children }) => (
<AlertDialogDescription className="px-4 py-4">
{children}
</AlertDialogDescription>
);
export const TwoFactorDialogButtons: FunctionComponent = ({ children }) => (
<>
<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>
</>
);

View File

@@ -0,0 +1,14 @@
import { Text } from '../../components';
import { FunctionComponent } from 'preact';
export const TwoFactorDisabledView: FunctionComponent = () => (
<Text>
Enabling two-factor authentication will sign you out of all other sessions.{' '}
<a
target="_blank"
href="https://standardnotes.com/help/21/where-should-i-store-my-two-factor-authentication-secret-key"
>
Learn more
</a>
</Text>
);

View File

@@ -0,0 +1,45 @@
import { CircleProgressTime } from '@/components/CircleProgressTime';
import { DecoratedInput } from '@/components/DecoratedInput';
import { IconButton } from '@/components/IconButton';
import { FunctionComponent } from 'preact';
import { downloadSecretKey } from './download-secret-key';
import { Text } from '../../components';
export const TwoFactorEnabledView: FunctionComponent<{
secretKey: string;
authCode: string;
}> = ({ secretKey, authCode }) => {
const download = (
<IconButton
icon="download"
onClick={() => {
downloadSecretKey(secretKey);
}}
/>
);
const copy = (
<IconButton
icon="copy"
onClick={() => {
navigator?.clipboard?.writeText(secretKey);
}}
/>
);
const progress = <CircleProgressTime time={30000} />;
return (
<div className="flex flex-row gap-4">
<div className="flex-grow flex flex-col">
<Text>Secret Key</Text>
<DecoratedInput
disabled={true}
right={[copy, download]}
text={secretKey}
/>
</div>
<div className="w-30 flex flex-col">
<Text>Authentication Code</Text>
<DecoratedInput disabled={true} text={authCode} right={[progress]} />
</div>
</div>
);
};

View File

@@ -0,0 +1,65 @@
import { Button } from '@/components/Button';
import { DecoratedInput } from '@/components/DecoratedInput';
import { observer } from 'mobx-react-lite';
import { FunctionComponent } from 'preact';
import { TwoFactorActivation } from './model';
import {
TwoFactorDialog,
TwoFactorDialogLabel,
TwoFactorDialogDescription,
TwoFactorDialogButtons,
} from './TwoFactorDialog';
export const Verification: FunctionComponent<{
activation: TwoFactorActivation;
}> = observer(({ activation: act }) => {
const borderInv =
act.verificationStatus === 'invalid' ? 'border-dark-red' : '';
return (
<TwoFactorDialog>
<TwoFactorDialogLabel
closeDialog={() => {
act.cancelActivation();
}}
>
Step 3 of 3 - Verification
</TwoFactorDialogLabel>
<TwoFactorDialogDescription>
<div className="flex-grow flex flex-col gap-1">
<div className="flex flex-row items-center gap-2">
<div className="text-sm">
Enter your <b>secret key</b>:
</div>
<DecoratedInput className={borderInv} />
</div>
<div className="flex flex-row items-center gap-2">
<div className="text-sm">
Verify the <b>authentication code</b> generated by your
authenticator app:
</div>
<DecoratedInput className={`w-30 ${borderInv}`} />
</div>
</div>
</TwoFactorDialogDescription>
<TwoFactorDialogButtons>
{act.verificationStatus === 'invalid' && (
<div className="text-sm color-dark-red">
Incorrect credentials, please try again.
</div>
)}
<Button
className="min-w-20"
type="normal"
label="Back"
onClick={() => act.openSaveSecretKey()}
/>
<Button
className="min-w-20"
type="primary"
label="Next"
onClick={() => act.enable2FA('X', 'X')}
/>
</TwoFactorDialogButtons>
</TwoFactorDialog>
);
});

View File

@@ -0,0 +1,13 @@
// Temporary implementation until integration
export function downloadSecretKey(text: string) {
const link = document.createElement('a');
const blob = new Blob([text], {
type: 'text/plain;charset=utf-8',
});
link.href = window.URL.createObjectURL(blob);
link.setAttribute('download', 'secret_key.txt');
document.body.appendChild(link);
link.click();
link.remove();
window.URL.revokeObjectURL(link.href);
}

View File

@@ -0,0 +1,10 @@
import { observer } from 'mobx-react-lite';
import { FunctionComponent } from 'preact';
import { useState } from 'preact/hooks';
import { TwoFactorAuth } from './model';
import { TwoFactorAuthView } from './TwoFactorAuthView';
export const TwoFactorAuthWrapper: FunctionComponent = () => {
const [auth] = useState(() => new TwoFactorAuth());
return <TwoFactorAuthView auth={auth} />;
};

View File

@@ -0,0 +1,168 @@
import { action, makeAutoObservable, observable, untracked } from 'mobx';
function getNewAuthCode() {
const MIN = 100000;
const MAX = 999999;
const code = Math.floor(Math.random() * (MAX - MIN) + MIN);
return code.toString();
}
const activationSteps = [
'scan-qr-code',
'save-secret-key',
'verification',
] as const;
type ActivationStep = typeof activationSteps[number];
export class TwoFactorActivation {
public readonly type = 'two-factor-activation' as const;
private _step: ActivationStep;
private _secretKey: string;
private _authCode: string;
private _2FAVerification: 'none' | 'invalid' | 'valid' = 'none';
constructor(
private _cancelActivation: () => void,
private _enable2FA: (secretKey: string) => void
) {
this._secretKey = 'FHJJSAJKDASKW43KJS';
this._authCode = getNewAuthCode();
this._step = 'scan-qr-code';
makeAutoObservable<
TwoFactorActivation,
'_secretKey' | '_authCode' | '_step' | '_enable2FAVerification'
>(
this,
{
_secretKey: observable,
_authCode: observable,
_step: observable,
_enable2FAVerification: observable,
},
{ autoBind: true }
);
}
get secretKey() {
return this._secretKey;
}
get authCode() {
return this._authCode;
}
get step() {
return this._step;
}
get verificationStatus() {
return this._2FAVerification;
}
cancelActivation() {
this._cancelActivation();
}
openScanQRCode() {
this._step = 'scan-qr-code';
}
openSaveSecretKey() {
this._step = 'save-secret-key';
}
openVerification() {
this._step = 'verification';
this._2FAVerification = 'none';
}
enable2FA(secretKey: string, authCode: string) {
if (secretKey === this._secretKey && authCode === this._authCode) {
this._2FAVerification = 'valid';
this._enable2FA(secretKey);
return;
}
// Change to invalid upon implementation
this._2FAVerification = 'valid';
// Remove after implementation
this._enable2FA(secretKey);
}
}
export class TwoFactorEnabled {
public readonly type = 'two-factor-enabled' as const;
private _secretKey: string;
private _authCode: string;
constructor(secretKey: string) {
this._secretKey = secretKey;
this._authCode = getNewAuthCode();
makeAutoObservable<TwoFactorEnabled, '_secretKey' | '_authCode'>(this, {
_secretKey: observable,
_authCode: observable,
});
}
get secretKey() {
return this._secretKey;
}
get authCode() {
return this._authCode;
}
refreshAuthCode() {
this._authCode = getNewAuthCode();
}
}
type TwoFactorStatus =
| TwoFactorEnabled
| TwoFactorActivation
| 'two-factor-disabled';
export const is2FADisabled = (s: TwoFactorStatus): s is 'two-factor-disabled' =>
s === 'two-factor-disabled';
export const is2FAActivation = (s: TwoFactorStatus): s is TwoFactorActivation =>
(s as any).type === 'two-factor-activation';
export const is2FAEnabled = (s: TwoFactorStatus): s is TwoFactorEnabled =>
(s as any).type === 'two-factor-enabled';
export class TwoFactorAuth {
private _status: TwoFactorStatus = 'two-factor-disabled';
constructor() {
makeAutoObservable<TwoFactorAuth, '_status'>(this, {
_status: observable,
});
}
private startActivation() {
const cancel = action(() => (this._status = 'two-factor-disabled'));
const enable = action(
(secretKey: string) => (this._status = new TwoFactorEnabled(secretKey))
);
this._status = new TwoFactorActivation(cancel, enable);
}
private deactivate2FA() {
this._status = 'two-factor-disabled';
}
toggle2FA() {
if (this._status === 'two-factor-disabled') this.startActivation();
else this.deactivate2FA();
}
get status() {
return this._status;
}
}