feat(preferences): 2FA activation dialog with mocked state (#605)
This commit is contained in:
3
app/assets/icons/ic-info.svg
Normal file
3
app/assets/icons/ic-info.svg
Normal 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 |
28
app/assets/javascripts/components/Button.tsx
Normal file
28
app/assets/javascripts/components/Button.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
38
app/assets/javascripts/components/CircleProgress.tsx
Normal file
38
app/assets/javascripts/components/CircleProgress.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
27
app/assets/javascripts/components/CircleProgressTime.tsx
Normal file
27
app/assets/javascripts/components/CircleProgressTime.tsx
Normal 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} />;
|
||||
};
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
));
|
||||
20
app/assets/javascripts/preferences/PreferencesMenuView.tsx
Normal file
20
app/assets/javascripts/preferences/PreferencesMenuView.tsx
Normal 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>
|
||||
));
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -1,2 +0,0 @@
|
||||
export * from './preferences';
|
||||
export * from './two-factor-auth';
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>Can’t 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 we’ll 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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
});
|
||||
@@ -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} />}
|
||||
</>
|
||||
));
|
||||
@@ -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>
|
||||
));
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
@@ -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>
|
||||
);
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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} />;
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user