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,3 @@
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M9.16675 7.50008H10.8334V5.83342H9.16675V7.50008ZM10.0001 16.6667C6.32508 16.6667 3.33341 13.6751 3.33341 10.0001C3.33341 6.32508 6.32508 3.33341 10.0001 3.33341C13.6751 3.33341 16.6667 6.32508 16.6667 10.0001C16.6667 13.6751 13.6751 16.6667 10.0001 16.6667ZM10.0001 1.66675C8.90573 1.66675 7.8221 1.8823 6.81105 2.30109C5.80001 2.71987 4.88135 3.3337 4.10752 4.10752C2.54472 5.67033 1.66675 7.78994 1.66675 10.0001C1.66675 12.2102 2.54472 14.3298 4.10752 15.8926C4.88135 16.6665 5.80001 17.2803 6.81105 17.6991C7.8221 18.1179 8.90573 18.3334 10.0001 18.3334C12.2102 18.3334 14.3298 17.4554 15.8926 15.8926C17.4554 14.3298 18.3334 12.2102 18.3334 10.0001C18.3334 8.90573 18.1179 7.8221 17.6991 6.81105C17.2803 5.80001 16.6665 4.88135 15.8926 4.10752C15.1188 3.3337 14.2002 2.71987 13.1891 2.30109C12.1781 1.8823 11.0944 1.66675 10.0001 1.66675ZM9.16675 14.1667H10.8334V9.16675H9.16675V14.1667Z" fill="#72767E"/>
</svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

View File

@@ -0,0 +1,28 @@
import { FunctionComponent } from 'preact';
const baseClass = `rounded px-4 py-1.75 font-bold text-sm fit-content cursor-pointer`;
const normalClass = `${baseClass} bg-default color-text border-solid border-gray-300 border-1 \
focus:bg-contrast hover:bg-contrast`;
const primaryClass = `${baseClass} no-border bg-info color-info-contrast hover:brightness-130 \
focus:brightness-130`;
export const Button: FunctionComponent<{
className?: string;
type: 'normal' | 'primary';
label: string;
onClick: () => void;
}> = ({ type, label, className = '', onClick }) => {
const buttonClass = type === 'primary' ? primaryClass : normalClass;
return (
<button
className={`${buttonClass} ${className}`}
onClick={(e) => {
onClick();
e.preventDefault();
}}
>
{label}
</button>
);
};

View File

@@ -0,0 +1,38 @@
import { FunctionComponent } from 'preact';
export const CircleProgress: FunctionComponent<{
percent: number;
className?: string;
}> = ({ percent, className = '' }) => {
const size = 16;
const ratioStrokeRadius = 0.25;
const outerRadius = size / 2;
const radius = outerRadius * (1 - ratioStrokeRadius);
const stroke = outerRadius - radius;
const circumference = radius * 2 * Math.PI;
const offset = circumference - (percent / 100) * circumference;
const transition = `transition: 0.35s stroke-dashoffset;`;
const transform = `transform: rotate(-90deg);`;
const transformOrigin = `transform-origin: 50% 50%;`;
const dasharray = `stroke-dasharray: ${circumference} ${circumference};`;
const dashoffset = `stroke-dashoffset: ${offset};`;
const style = `${transition} ${transform} ${transformOrigin} ${dasharray} ${dashoffset}`;
return (
<div className="h-5 w-5 min-w-5 min-h-5">
<svg viewBox={`0 0 ${size} ${size}`}>
<circle
stroke="#086DD6"
stroke-width={stroke}
fill="transparent"
r={radius}
cx="50%"
cy="50%"
style={style}
/>
</svg>
</div>
);
};

View File

@@ -0,0 +1,27 @@
import { FunctionalComponent } from 'preact';
import { useEffect, useState } from 'preact/hooks';
import { CircleProgress } from './CircleProgress';
/**
* Circular progress bar which runs in a specified time interval
* @param time - time interval in ms
*/
export const CircleProgressTime: FunctionalComponent<{ time: number }> = ({
time,
}) => {
const [percent, setPercent] = useState(0);
const interval = time / 100;
useEffect(() => {
const tick = setInterval(() => {
if (percent === 100) {
setPercent(0);
} else {
setPercent(percent + 1);
}
}, interval);
return () => {
clearInterval(tick);
};
});
return <CircleProgress percent={percent} />;
};

View File

@@ -26,12 +26,12 @@ export const DecoratedInput: FunctionalComponent<Props> = ({
const classes = `${base} ${stateClasses} ${className}`;
return (
<div className={classes}>
<div className={`${classes} focus-within:ring-info`}>
{left}
<div className="flex-grow">
<input
type="text"
className="w-full no-border color-black"
className="w-full no-border color-black focus:shadow-none"
disabled={disabled}
value={text}
/>

View File

@@ -25,6 +25,7 @@ import ThemesIcon from '../../icons/ic-themes.svg';
import UserIcon from '../../icons/ic-user.svg';
import CopyIcon from '../../icons/ic-copy.svg';
import DownloadIcon from '../../icons/ic-download.svg';
import InfoIcon from '../../icons/ic-info.svg';
import { toDirective } from './utils';
import { FunctionalComponent } from 'preact';
@@ -56,6 +57,7 @@ const ICONS = {
user: UserIcon,
copy: CopyIcon,
download: DownloadIcon,
info: InfoIcon,
};
export type IconType = keyof typeof ICONS;

View File

@@ -27,7 +27,7 @@ export const IconButton: FunctionComponent<Props> = ({
};
return (
<button
className={`no-border bg-transparent hover:brightness-130 p-0 ${
className={`no-border cursor-pointer bg-transparent hover:brightness-130 p-0 ${
className ?? ''
}`}
onClick={click}

View File

@@ -1,19 +0,0 @@
import { observer } from 'mobx-react-lite';
import { FunctionComponent } from 'preact';
import { MenuItem } from './components';
import { Preferences } from './models/preferences';
export const PreferencesMenu: FunctionComponent<{ preferences: Preferences }> =
observer(({ preferences }) => (
<div className="min-w-55 overflow-y-auto flex flex-col px-3 py-6">
{preferences.menuItems.map((pref) => (
<MenuItem
key={pref.id}
iconType={pref.icon}
label={pref.label}
selected={pref.selected}
onClick={() => preferences.selectPane(pref.id)}
/>
))}
</div>
));

View File

@@ -0,0 +1,20 @@
import { observer } from 'mobx-react-lite';
import { FunctionComponent } from 'preact';
import { MenuItem } from './components';
import { PreferencesMenu } from './preferences-menu';
export const PreferencesMenuView: FunctionComponent<{
menu: PreferencesMenu;
}> = observer(({ menu }) => (
<div className="min-w-55 overflow-y-auto flex flex-col px-3 py-6">
{menu.menuItems.map((pref) => (
<MenuItem
key={pref.id}
iconType={pref.icon}
label={pref.label}
selected={pref.selected}
onClick={() => menu.selectPane(pref.id)}
/>
))}
</div>
));

View File

@@ -1,20 +1,15 @@
import { RoundIconButton } from '@/components/RoundIconButton';
import { TitleBar, Title } from '@/components/TitleBar';
import { FunctionComponent } from 'preact';
import { Preferences } from './models/preferences';
import { PreferencesMenu } from './PreferencesMenu';
import { HelpAndFeedback } from './panes/HelpFeedback';
import { HelpAndFeedback, Security } from './panes';
import { observer } from 'mobx-react-lite';
import { Security } from './panes/Security';
interface PreferencesViewProps {
close: () => void;
}
import { PreferencesMenu } from './preferences-menu';
import { PreferencesMenuView } from './PreferencesMenuView';
const PaneSelector: FunctionComponent<{
prefs: Preferences;
}> = observer(({ prefs }) => {
switch (prefs.selectedPaneId) {
prefs: PreferencesMenu;
}> = observer(({ prefs: menu }) => {
switch (menu.selectedPaneId) {
case 'general':
return null;
case 'account':
@@ -22,7 +17,7 @@ const PaneSelector: FunctionComponent<{
case 'appearance':
return null;
case 'security':
return <Security prefs={prefs} />;
return <Security />;
case 'listed':
return null;
case 'shortcuts':
@@ -37,17 +32,18 @@ const PaneSelector: FunctionComponent<{
});
const PreferencesCanvas: FunctionComponent<{
preferences: Preferences;
preferences: PreferencesMenu;
}> = observer(({ preferences: prefs }) => (
<div className="flex flex-row flex-grow min-h-0 justify-between">
<PreferencesMenu preferences={prefs}></PreferencesMenu>
<PreferencesMenuView menu={prefs}></PreferencesMenuView>
<PaneSelector prefs={prefs} />
</div>
));
const PreferencesView: FunctionComponent<PreferencesViewProps> = observer(
const PreferencesView: FunctionComponent<{ close: () => void }> = observer(
({ close }) => {
const prefs = new Preferences();
const prefs = new PreferencesMenu();
return (
<div className="sn-full-screen flex flex-col bg-contrast z-index-preferences">
<TitleBar className="items-center justify-between">

View File

@@ -12,17 +12,15 @@ export const Text: FunctionComponent = ({ children }) => (
<p className="text-xs">{children}</p>
);
export const Button: FunctionComponent<{ label: string; link: string }> = ({
const buttonClasses = `block bg-default color-text rounded border-solid \
border-1 border-gray-300 px-4 py-1.75 font-bold text-sm fit-content mt-3 \
focus:bg-contrast hover:bg-contrast `;
export const LinkButton: FunctionComponent<{ label: string; link: string }> = ({
label,
link,
}) => (
<a
target="_blank"
className="block bg-default color-text rounded border-solid border-1
border-gray-300 px-4 py-2 font-bold text-sm fit-content mt-3
focus:bg-contrast hover:bg-contrast "
href={link}
>
<a target="_blank" className={buttonClasses} href={link}>
{label}
</a>
);

View File

@@ -1,2 +0,0 @@
export * from './preferences';
export * from './two-factor-auth';

View File

@@ -1,81 +0,0 @@
import { makeAutoObservable, observable } from 'mobx';
function getNewAuthCode() {
const MIN = 100000;
const MAX = 999999;
const code = Math.floor(Math.random() * (MAX - MIN) + MIN);
return code.toString();
}
class TwoFactorData {
private _secretKey: string;
private _authCode: string;
constructor(secretKey: string) {
this._secretKey = secretKey;
this._authCode = getNewAuthCode();
makeAutoObservable<TwoFactorData, '_secretKey' | '_authCode'>(
this,
{
_secretKey: observable,
_authCode: observable,
},
{ autoBind: true }
);
}
get secretKey() {
return this._secretKey;
}
get authCode() {
return this._authCode;
}
refreshAuthCode() {
this._authCode = getNewAuthCode();
}
}
type TwoFactorStatus = 'enabled' | 'disabled';
export class TwoFactorAuth {
private _twoFactorStatus: TwoFactorStatus = 'disabled';
private _twoFactorData: TwoFactorData | null = null;
constructor() {
makeAutoObservable<TwoFactorAuth, '_twoFactorStatus' | '_twoFactorData'>(
this,
{
_twoFactorStatus: observable,
_twoFactorData: observable,
},
{ autoBind: true }
);
}
private activate2FA() {
this._twoFactorData = new TwoFactorData('FHJJSAJKDASKW43KJS');
this._twoFactorStatus = 'enabled';
}
private deactivate2FA() {
this._twoFactorData = null;
this._twoFactorStatus = 'disabled';
}
toggle2FA() {
if (this._twoFactorStatus === 'enabled') this.deactivate2FA();
else this.activate2FA();
}
get twoFactorStatus() {
return this._twoFactorStatus;
}
get twoFactorData() {
if (this._twoFactorStatus !== 'enabled')
throw new Error(`Can't provide 2FA data if not enabled`);
return this._twoFactorData;
}
}

View File

@@ -1,10 +1,9 @@
import { FunctionComponent } from 'preact';
import {} from '../components';
import {
Title,
Subtitle,
Text,
Button,
LinkButton,
PreferencesGroup,
PreferencesPane,
PreferencesSegment,
@@ -53,7 +52,7 @@ export const HelpAndFeedback: FunctionComponent = () => (
</PreferencesSegment>
<PreferencesSegment>
<Subtitle>Cant find your question here?</Subtitle>
<Button label="Open FAQ" link="https://standardnotes.com/help" />
<LinkButton label="Open FAQ" link="https://standardnotes.com/help" />
</PreferencesSegment>
</PreferencesGroup>
<PreferencesGroup>
@@ -68,7 +67,7 @@ export const HelpAndFeedback: FunctionComponent = () => (
</a>{' '}
before advocating for a feature request.
</Text>
<Button
<LinkButton
label="Go to the forum"
link="https://forum.standardnotes.org/"
/>
@@ -82,7 +81,7 @@ export const HelpAndFeedback: FunctionComponent = () => (
Want to share your feedback with us? Join the Standard Notes Slack
group for discussions on security, themes, editors and more.
</Text>
<Button
<LinkButton
link="https://standardnotes.com/slack"
label="Join our Slack group"
/>
@@ -94,7 +93,7 @@ export const HelpAndFeedback: FunctionComponent = () => (
<Text>
Send an email to help@standardnotes.org and well sort it out.
</Text>
<Button link="mailto: help@standardnotes.org" label="Email us" />
<LinkButton link="mailto: help@standardnotes.org" label="Email us" />
</PreferencesSegment>
</PreferencesGroup>
</PreferencesPane>

View File

@@ -1,13 +1,9 @@
import { observer } from 'mobx-react-lite';
import { FunctionComponent } from 'preact';
import { PreferencesPane } from '../components';
import { Preferences } from '../models';
import { TwoFactorAuthComponent } from './TwoFactorAuth';
import { TwoFactorAuthWrapper } from './two-factor-auth';
export const Security: FunctionComponent<{ prefs: Preferences }> = observer(
({ prefs }) => (
<PreferencesPane>
<TwoFactorAuthComponent tfAuth={prefs.twoFactorAuth} />
</PreferencesPane>
)
export const Security: FunctionComponent = () => (
<PreferencesPane>
<TwoFactorAuthWrapper />
</PreferencesPane>
);

View File

@@ -1,110 +0,0 @@
import { FunctionComponent } from 'preact';
import {
Title,
Text,
PreferencesGroup,
PreferencesSegment,
} from '../components';
import { Switch } from '../../components/Switch';
import { observer } from 'mobx-react-lite';
import { DecoratedInput } from '../../components/DecoratedInput';
import { IconButton } from '../../components/IconButton';
import { TwoFactorAuth } from '../models';
// Temporary implementation until integration
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);
}
export const TwoFactorAuthComponent: FunctionComponent<{
tfAuth: TwoFactorAuth;
}> = observer(({ tfAuth }) => {
return (
<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={tfAuth.twoFactorStatus === 'enabled'}
onChange={() => tfAuth.toggle2FA()}
/>
</div>
</PreferencesSegment>
<PreferencesSegment>
{tfAuth.twoFactorStatus === 'enabled' &&
tfAuth.twoFactorData != null ? (
<TwoFactorEnabled tfAuth={tfAuth} />
) : (
<TwoFactorDisabled />
)}
</PreferencesSegment>
</PreferencesGroup>
);
});
const TwoFactorEnabled: FunctionComponent<{ tfAuth: TwoFactorAuth }> = observer(
({ tfAuth }) => {
const state = tfAuth.twoFactorData!;
const download = (
<IconButton
icon="download"
onClick={() => {
downloadSecretKey(state.secretKey);
}}
/>
);
const copy = (
<IconButton
icon="copy"
onClick={() => {
navigator?.clipboard?.writeText(state.secretKey);
}}
/>
);
const spinner = <div class="sk-spinner info w-8 h-3.5" />;
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={state.secretKey}
/>
</div>
<div className="w-30 flex flex-col">
<Text>Authentication Code</Text>
<DecoratedInput
disabled={true}
text={state.authCode}
right={[spinner]}
/>
</div>
</div>
);
}
);
const TwoFactorDisabled: 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,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;
}
}

View File

@@ -1,6 +1,5 @@
import { IconType } from '@/components/Icon';
import { makeAutoObservable, observable } from 'mobx';
import { TwoFactorAuth } from './two-factor-auth';
const PREFERENCE_IDS = [
'general',
@@ -15,18 +14,16 @@ const PREFERENCE_IDS = [
] as const;
export type PreferenceId = typeof PREFERENCE_IDS[number];
interface PreferenceMenuItem {
interface PreferencesMenuItem {
readonly id: PreferenceId;
readonly icon: IconType;
readonly label: string;
}
type PreferencesMenu = PreferenceMenuItem[];
/**
* Items are in order of appearance
*/
const PREFERENCES_MENU: PreferencesMenu = [
const PREFERENCES_MENU_ITEMS: PreferencesMenuItem[] = [
{ id: 'general', label: 'General', icon: 'settings' },
{ id: 'account', label: 'Account', icon: 'user' },
{ id: 'appearance', label: 'Appearance', icon: 'themes' },
@@ -38,20 +35,22 @@ const PREFERENCES_MENU: PreferencesMenu = [
{ id: 'help-feedback', label: 'Help & feedback', icon: 'help' },
];
export class Preferences {
export class PreferencesMenu {
private _selectedPane: PreferenceId = 'general';
private _twoFactorAuth: TwoFactorAuth;
constructor(private readonly _menu: PreferencesMenu = PREFERENCES_MENU) {
this._twoFactorAuth = new TwoFactorAuth();
makeAutoObservable<Preferences, '_selectedPane' | '_twoFactorAuth'>(this, {
_twoFactorAuth: observable,
_selectedPane: observable,
});
constructor(
private readonly _menu: PreferencesMenuItem[] = PREFERENCES_MENU_ITEMS
) {
makeAutoObservable<PreferencesMenu, '_selectedPane' | '_twoFactorAuth'>(
this,
{
_twoFactorAuth: observable,
_selectedPane: observable,
}
);
}
get menuItems(): (PreferenceMenuItem & {
get menuItems(): (PreferencesMenuItem & {
selected: boolean;
})[] {
return this._menu.map((p) => ({
@@ -69,8 +68,4 @@ export class Preferences {
selectPane(key: PreferenceId) {
this._selectedPane = key;
}
get twoFactorAuth() {
return this._twoFactorAuth;
}
}

View File

@@ -55,7 +55,7 @@
"pug-loader": "^2.4.0",
"sass-loader": "^8.0.2",
"serve-static": "^1.14.1",
"sn-stylekit": "5.2.6",
"sn-stylekit": "5.2.8",
"ts-loader": "^8.0.17",
"typescript": "4.2.3",
"typescript-eslint": "0.0.1-alpha.0",

View File

@@ -7908,10 +7908,10 @@ slice-ansi@^4.0.0:
astral-regex "^2.0.0"
is-fullwidth-code-point "^3.0.0"
sn-stylekit@5.2.6:
version "5.2.6"
resolved "https://registry.yarnpkg.com/sn-stylekit/-/sn-stylekit-5.2.6.tgz#3c63d4a6b20bb002b6add685e28abce4663475d2"
integrity sha512-jZI+D/evLwcJjNbxBueoczjEOjJZL61eGd4cluC0lpPYvkMwHGYj/gWi09KN1E16K++FQRNVvZ3/mMZjSfam2g==
sn-stylekit@5.2.8:
version "5.2.8"
resolved "https://registry.yarnpkg.com/sn-stylekit/-/sn-stylekit-5.2.8.tgz#320d7f536036110fe57e0392cb7ff52076186d9a"
integrity sha512-08eKl2Eigb8kzxl+Tqp7PyzSVA0psCR6+hIVNL4V7//J7DjmM2RanBPMRxIUqBBTX/1b+CbZdZb8wnbzD18NZw==
dependencies:
"@reach/listbox" "^0.15.0"
"@reach/menu-button" "^0.15.1"