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,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;
}
}