feat(preferences): 2FA activation dialog with mocked state (#605)
This commit is contained in:
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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user