feat: improve 2fa styles based on feedback (#635)
* feat: improve 2fa styles based on feedback * fix: preferences panes and dialogs electron compatibility * fix: no horizontal line when opening two factor activation * feat: improve two factor activation styles * feat: further 2fa style improvements * feat: padding 2fa widgets * feat: add padding between QR code and content * feat: refresh 2fa after passcode confirmation * feat: don't autocomplete passwords for DecoratedInput
This commit is contained in:
3
app/assets/icons/ic-check.svg
Normal file
3
app/assets/icons/ic-check.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="M17.5001 5.83345L7.50008 15.8334L2.91675 11.2501L4.09175 10.0751L7.50008 13.4751L16.3251 4.65845L17.5001 5.83345Z"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 229 B |
@@ -10,6 +10,7 @@ interface Props {
|
|||||||
text?: string;
|
text?: string;
|
||||||
placeholder?: string;
|
placeholder?: string;
|
||||||
onChange?: (text: string) => void;
|
onChange?: (text: string) => void;
|
||||||
|
autocomplete?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -24,30 +25,47 @@ export const DecoratedInput: FunctionalComponent<Props> = ({
|
|||||||
text,
|
text,
|
||||||
placeholder = '',
|
placeholder = '',
|
||||||
onChange,
|
onChange,
|
||||||
|
autocomplete = false,
|
||||||
}) => {
|
}) => {
|
||||||
const base =
|
const baseClasses =
|
||||||
'rounded py-1.5 px-3 text-input my-1 h-8 flex flex-row items-center gap-4';
|
'rounded py-1.5 px-3 text-input my-1 h-8 flex flex-row items-center';
|
||||||
const stateClasses = disabled
|
const stateClasses = disabled
|
||||||
? 'no-border bg-grey-5'
|
? 'no-border bg-grey-5'
|
||||||
: 'border-solid border-1 border-gray-300';
|
: 'border-solid border-1 border-gray-300';
|
||||||
const classes = `${base} ${stateClasses} ${className}`;
|
const classes = `${baseClasses} ${stateClasses} ${className}`;
|
||||||
|
|
||||||
|
const inputBaseClasses = 'w-full no-border color-black focus:shadow-none';
|
||||||
|
const inputStateClasses = disabled ? 'overflow-ellipsis' : '';
|
||||||
return (
|
return (
|
||||||
<div className={`${classes} focus-within:ring-info`}>
|
<div className={`${classes} focus-within:ring-info`}>
|
||||||
{left}
|
{left?.map((leftChild, idx, arr) => (
|
||||||
|
<>
|
||||||
|
{leftChild}
|
||||||
|
{idx < arr.length - 1 && <div className="min-w-3 min-h-1" />}
|
||||||
|
</>
|
||||||
|
))}
|
||||||
|
{left !== undefined && <div className="min-w-7 min-h-1" />}
|
||||||
<div className="flex-grow">
|
<div className="flex-grow">
|
||||||
<input
|
<input
|
||||||
type={type}
|
type={type}
|
||||||
className="w-full no-border color-black focus:shadow-none"
|
className={`${inputBaseClasses} ${inputStateClasses}`}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
value={text}
|
value={text}
|
||||||
placeholder={placeholder}
|
placeholder={placeholder}
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
onChange && onChange((e.target as HTMLInputElement).value)
|
onChange && onChange((e.target as HTMLInputElement).value)
|
||||||
}
|
}
|
||||||
|
data-lpignore={type !== 'password' ? true : false}
|
||||||
|
autocomplete={autocomplete ? 'on' : 'off'}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{right}
|
{right !== undefined && <div className="min-w-7 min-h-1" />}
|
||||||
|
{right?.map((rightChild, idx, arr) => (
|
||||||
|
<>
|
||||||
|
{rightChild}
|
||||||
|
{idx < arr.length - 1 && <div className="min-w-3 min-h-1" />}
|
||||||
|
</>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ import UserIcon from '../../icons/ic-user.svg';
|
|||||||
import CopyIcon from '../../icons/ic-copy.svg';
|
import CopyIcon from '../../icons/ic-copy.svg';
|
||||||
import DownloadIcon from '../../icons/ic-download.svg';
|
import DownloadIcon from '../../icons/ic-download.svg';
|
||||||
import InfoIcon from '../../icons/ic-info.svg';
|
import InfoIcon from '../../icons/ic-info.svg';
|
||||||
|
import CheckIcon from '../../icons/ic-check.svg';
|
||||||
|
|
||||||
import { toDirective } from './utils';
|
import { toDirective } from './utils';
|
||||||
import { FunctionalComponent } from 'preact';
|
import { FunctionalComponent } from 'preact';
|
||||||
@@ -58,6 +59,7 @@ const ICONS = {
|
|||||||
copy: CopyIcon,
|
copy: CopyIcon,
|
||||||
download: DownloadIcon,
|
download: DownloadIcon,
|
||||||
info: InfoIcon,
|
info: InfoIcon,
|
||||||
|
check: CheckIcon
|
||||||
};
|
};
|
||||||
|
|
||||||
export type IconType = keyof typeof ICONS;
|
export type IconType = keyof typeof ICONS;
|
||||||
|
|||||||
@@ -10,6 +10,15 @@ interface Props {
|
|||||||
className?: string;
|
className?: string;
|
||||||
|
|
||||||
icon: IconType;
|
icon: IconType;
|
||||||
|
|
||||||
|
iconClassName?: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Button tooltip
|
||||||
|
*/
|
||||||
|
title: string;
|
||||||
|
|
||||||
|
focusable: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -18,18 +27,20 @@ interface Props {
|
|||||||
*/
|
*/
|
||||||
export const IconButton: FunctionComponent<Props> = ({
|
export const IconButton: FunctionComponent<Props> = ({
|
||||||
onClick,
|
onClick,
|
||||||
className,
|
className = '',
|
||||||
icon,
|
icon,
|
||||||
|
title,
|
||||||
|
focusable,
|
||||||
}) => {
|
}) => {
|
||||||
const click = (e: MouseEvent) => {
|
const click = (e: MouseEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
onClick();
|
onClick();
|
||||||
};
|
};
|
||||||
|
const focusableClass = focusable ? '' : 'focus:shadow-none';
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
className={`no-border cursor-pointer bg-transparent hover:brightness-130 p-0 ${
|
title={title}
|
||||||
className ?? ''
|
className={`no-border cursor-pointer bg-transparent flex flex-row items-center hover:brightness-130 p-0 ${focusableClass} ${className}`}
|
||||||
}`}
|
|
||||||
onClick={click}
|
onClick={click}
|
||||||
>
|
>
|
||||||
<Icon type={icon} />
|
<Icon type={icon} />
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ export const Switch: FunctionalComponent<SwitchProps> = (
|
|||||||
const className = props.className ?? '';
|
const className = props.className ?? '';
|
||||||
return (
|
return (
|
||||||
<label
|
<label
|
||||||
className={`sn-component flex justify-between items-center cursor-pointer hover:bg-contrast px-3 ${className}`}
|
className={`sn-component flex justify-between items-center cursor-pointer px-3 ${className}`}
|
||||||
>
|
>
|
||||||
{props.children}
|
{props.children}
|
||||||
<CustomCheckboxContainer
|
<CustomCheckboxContainer
|
||||||
|
|||||||
@@ -1,5 +1,9 @@
|
|||||||
import { FunctionComponent } from 'preact';
|
import { FunctionComponent } from 'preact';
|
||||||
import { AlertDialog, AlertDialogDescription, AlertDialogLabel } from '@node_modules/@reach/alert-dialog';
|
import {
|
||||||
|
AlertDialog,
|
||||||
|
AlertDialogDescription,
|
||||||
|
AlertDialogLabel,
|
||||||
|
} from '@node_modules/@reach/alert-dialog';
|
||||||
import { useRef } from '@node_modules/preact/hooks';
|
import { useRef } from '@node_modules/preact/hooks';
|
||||||
import { IconButton } from '@/components/IconButton';
|
import { IconButton } from '@/components/IconButton';
|
||||||
|
|
||||||
@@ -28,11 +32,11 @@ export const ModalDialogLabel: FunctionComponent<{
|
|||||||
closeDialog: () => void;
|
closeDialog: () => void;
|
||||||
}> = ({ children, closeDialog }) => (
|
}> = ({ children, closeDialog }) => (
|
||||||
<AlertDialogLabel className="">
|
<AlertDialogLabel className="">
|
||||||
<div className="px-4 pt-4 pb-3 flex flex-row">
|
<div className="px-4 py-4 flex flex-row items-center">
|
||||||
<div className="flex-grow color-black text-lg font-bold">
|
<div className="flex-grow color-black text-lg font-bold">{children}</div>
|
||||||
{children}
|
|
||||||
</div>
|
|
||||||
<IconButton
|
<IconButton
|
||||||
|
focusable={true}
|
||||||
|
title="Close"
|
||||||
className="color-grey-1 h-5 w-5"
|
className="color-grey-1 h-5 w-5"
|
||||||
icon="close"
|
icon="close"
|
||||||
onClick={() => closeDialog()}
|
onClick={() => closeDialog()}
|
||||||
@@ -42,16 +46,28 @@ export const ModalDialogLabel: FunctionComponent<{
|
|||||||
</AlertDialogLabel>
|
</AlertDialogLabel>
|
||||||
);
|
);
|
||||||
|
|
||||||
export const ModalDialogDescription: FunctionComponent = ({ children }) => (
|
export const ModalDialogDescription: FunctionComponent<{ className?: string }> =
|
||||||
<AlertDialogDescription className="px-4 py-4">
|
({ children, className = '' }) => (
|
||||||
{children}
|
<AlertDialogDescription
|
||||||
</AlertDialogDescription>
|
className={`px-4 py-4 flex flex-row items-center ${className}`}
|
||||||
);
|
>
|
||||||
|
{children}
|
||||||
|
</AlertDialogDescription>
|
||||||
|
);
|
||||||
|
|
||||||
export const ModalDialogButtons: FunctionComponent = ({ children }) => (
|
export const ModalDialogButtons: FunctionComponent = ({ children }) => (
|
||||||
<>
|
<>
|
||||||
<hr className="h-1px bg-border no-border m-0" />
|
<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>
|
<div className="px-4 py-4 flex flex-row justify-end items-center">
|
||||||
|
{children != undefined && Array.isArray(children)
|
||||||
|
? children.map((child, idx, arr) => (
|
||||||
|
<>
|
||||||
|
{child}
|
||||||
|
{idx < arr.length - 1 ? <div className="min-w-3" /> : undefined}
|
||||||
|
</>
|
||||||
|
))
|
||||||
|
: children}
|
||||||
|
</div>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -56,7 +56,7 @@ export const PreferencesView: FunctionComponent<PreferencesProps> = observer(
|
|||||||
(props) => {
|
(props) => {
|
||||||
const menu = new PreferencesMenu();
|
const menu = new PreferencesMenu();
|
||||||
return (
|
return (
|
||||||
<div className="sn-full-screen flex flex-col bg-contrast z-index-preferences">
|
<div className="h-full w-full absolute top-left-0 flex flex-col bg-contrast z-index-preferences">
|
||||||
<TitleBar className="items-center justify-between">
|
<TitleBar className="items-center justify-between">
|
||||||
{/* div is added so flex justify-between can center the title */}
|
{/* div is added so flex justify-between can center the title */}
|
||||||
<div className="h-8 w-8" />
|
<div className="h-8 w-8" />
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ export const MenuItem: FunctionComponent<Props> = ({
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Icon className="icon" type={iconType} />
|
<Icon className="icon" type={iconType} />
|
||||||
|
<div className="min-w-1" />
|
||||||
{label}
|
{label}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -4,17 +4,14 @@ import { HorizontalSeparator } from '@/components/shared/HorizontalSeparator';
|
|||||||
const HorizontalLine: FunctionComponent<{ index: number; length: number }> = ({
|
const HorizontalLine: FunctionComponent<{ index: number; length: number }> = ({
|
||||||
index,
|
index,
|
||||||
length,
|
length,
|
||||||
}) =>
|
}) => (index < length - 1 ? <HorizontalSeparator classes="my-4" /> : null);
|
||||||
index < length - 1 ? (
|
|
||||||
<HorizontalSeparator />
|
|
||||||
) : null;
|
|
||||||
|
|
||||||
export const PreferencesSegment: FunctionComponent = ({ children }) => (
|
export const PreferencesSegment: FunctionComponent = ({ children }) => (
|
||||||
<div className="flex flex-col">{children}</div>
|
<div className="flex flex-col">{children}</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
export const PreferencesGroup: FunctionComponent = ({ children }) => (
|
export const PreferencesGroup: FunctionComponent = ({ children }) => (
|
||||||
<div className="bg-default border-1 border-solid rounded border-gray-300 px-6 py-6 flex flex-col gap-2">
|
<div className="bg-default border-1 border-solid rounded border-gray-300 px-6 py-6 flex flex-col">
|
||||||
{Array.isArray(children)
|
{Array.isArray(children)
|
||||||
? children
|
? children
|
||||||
.filter(
|
.filter(
|
||||||
@@ -33,7 +30,16 @@ export const PreferencesGroup: FunctionComponent = ({ children }) => (
|
|||||||
export const PreferencesPane: FunctionComponent = ({ children }) => (
|
export const PreferencesPane: FunctionComponent = ({ children }) => (
|
||||||
<div className="color-black flex-grow flex flex-row overflow-y-auto min-h-0">
|
<div className="color-black flex-grow flex flex-row overflow-y-auto min-h-0">
|
||||||
<div className="flex-grow flex flex-col py-6 items-center">
|
<div className="flex-grow flex flex-col py-6 items-center">
|
||||||
<div className="w-125 max-w-125 flex flex-col gap-3">{children}</div>
|
<div className="w-125 max-w-125 flex flex-col">
|
||||||
|
{children != undefined && Array.isArray(children)
|
||||||
|
? children.map((child, idx, arr) => (
|
||||||
|
<>
|
||||||
|
{child}
|
||||||
|
{idx < arr.length - 1 ? <div className="min-h-3" /> : undefined}
|
||||||
|
</>
|
||||||
|
))
|
||||||
|
: children}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-basis-55 flex-shrink" />
|
<div className="flex-basis-55 flex-shrink" />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import {
|
|||||||
ModalDialog,
|
ModalDialog,
|
||||||
ModalDialogButtons,
|
ModalDialogButtons,
|
||||||
ModalDialogDescription,
|
ModalDialogDescription,
|
||||||
ModalDialogLabel
|
ModalDialogLabel,
|
||||||
} from '@/components/shared/ModalDialog';
|
} from '@/components/shared/ModalDialog';
|
||||||
import { Button } from '@/components/Button';
|
import { Button } from '@/components/Button';
|
||||||
import { FunctionalComponent } from 'preact';
|
import { FunctionalComponent } from 'preact';
|
||||||
@@ -16,28 +16,30 @@ import { isEmailValid } from '@/utils';
|
|||||||
enum SubmitButtonTitles {
|
enum SubmitButtonTitles {
|
||||||
Default = 'Continue',
|
Default = 'Continue',
|
||||||
GeneratingKeys = 'Generating Keys...',
|
GeneratingKeys = 'Generating Keys...',
|
||||||
Finish = 'Finish'
|
Finish = 'Finish',
|
||||||
}
|
}
|
||||||
|
|
||||||
enum Steps {
|
enum Steps {
|
||||||
InitialStep,
|
InitialStep,
|
||||||
FinishStep
|
FinishStep,
|
||||||
}
|
}
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
onCloseDialog: () => void;
|
onCloseDialog: () => void;
|
||||||
application: WebApplication;
|
application: WebApplication;
|
||||||
}
|
};
|
||||||
|
|
||||||
export const ChangeEmail: FunctionalComponent<Props> = ({
|
export const ChangeEmail: FunctionalComponent<Props> = ({
|
||||||
onCloseDialog,
|
onCloseDialog,
|
||||||
application
|
application,
|
||||||
}) => {
|
}) => {
|
||||||
const [currentPassword, setCurrentPassword] = useState('');
|
const [currentPassword, setCurrentPassword] = useState('');
|
||||||
const [newEmail, setNewEmail] = useState('');
|
const [newEmail, setNewEmail] = useState('');
|
||||||
const [isContinuing, setIsContinuing] = useState(false);
|
const [isContinuing, setIsContinuing] = useState(false);
|
||||||
const [lockContinue, setLockContinue] = useState(false);
|
const [lockContinue, setLockContinue] = useState(false);
|
||||||
const [submitButtonTitle, setSubmitButtonTitle] = useState(SubmitButtonTitles.Default);
|
const [submitButtonTitle, setSubmitButtonTitle] = useState(
|
||||||
|
SubmitButtonTitles.Default
|
||||||
|
);
|
||||||
const [currentStep, setCurrentStep] = useState(Steps.InitialStep);
|
const [currentStep, setCurrentStep] = useState(Steps.InitialStep);
|
||||||
|
|
||||||
useBeforeUnload();
|
useBeforeUnload();
|
||||||
@@ -46,9 +48,7 @@ export const ChangeEmail: FunctionalComponent<Props> = ({
|
|||||||
|
|
||||||
const validateCurrentPassword = async () => {
|
const validateCurrentPassword = async () => {
|
||||||
if (!currentPassword || currentPassword.length === 0) {
|
if (!currentPassword || currentPassword.length === 0) {
|
||||||
applicationAlertService.alert(
|
applicationAlertService.alert('Please enter your current password.');
|
||||||
'Please enter your current password.'
|
|
||||||
);
|
|
||||||
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -67,7 +67,9 @@ export const ChangeEmail: FunctionalComponent<Props> = ({
|
|||||||
|
|
||||||
const validateNewEmail = async () => {
|
const validateNewEmail = async () => {
|
||||||
if (!isEmailValid(newEmail)) {
|
if (!isEmailValid(newEmail)) {
|
||||||
applicationAlertService.alert('The email you entered has an invalid format. Please review your input and try again.');
|
applicationAlertService.alert(
|
||||||
|
'The email you entered has an invalid format. Please review your input and try again.'
|
||||||
|
);
|
||||||
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -85,10 +87,7 @@ export const ChangeEmail: FunctionalComponent<Props> = ({
|
|||||||
|
|
||||||
setLockContinue(true);
|
setLockContinue(true);
|
||||||
|
|
||||||
const response = await application.changeEmail(
|
const response = await application.changeEmail(newEmail, currentPassword);
|
||||||
newEmail,
|
|
||||||
currentPassword,
|
|
||||||
);
|
|
||||||
|
|
||||||
const success = !response.error;
|
const success = !response.error;
|
||||||
|
|
||||||
@@ -121,7 +120,8 @@ export const ChangeEmail: FunctionalComponent<Props> = ({
|
|||||||
setIsContinuing(true);
|
setIsContinuing(true);
|
||||||
setSubmitButtonTitle(SubmitButtonTitles.GeneratingKeys);
|
setSubmitButtonTitle(SubmitButtonTitles.GeneratingKeys);
|
||||||
|
|
||||||
const valid = await validateCurrentPassword() && await validateNewEmail();
|
const valid =
|
||||||
|
(await validateCurrentPassword()) && (await validateNewEmail());
|
||||||
|
|
||||||
if (!valid) {
|
if (!valid) {
|
||||||
resetProgressState();
|
resetProgressState();
|
||||||
@@ -169,15 +169,15 @@ export const ChangeEmail: FunctionalComponent<Props> = ({
|
|||||||
<ModalDialogButtons>
|
<ModalDialogButtons>
|
||||||
{currentStep === Steps.InitialStep && (
|
{currentStep === Steps.InitialStep && (
|
||||||
<Button
|
<Button
|
||||||
className='min-w-20'
|
className="min-w-20"
|
||||||
type='normal'
|
type="normal"
|
||||||
label='Cancel'
|
label="Cancel"
|
||||||
onClick={handleDialogClose}
|
onClick={handleDialogClose}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<Button
|
<Button
|
||||||
className='min-w-20'
|
className="min-w-20"
|
||||||
type='primary'
|
type="primary"
|
||||||
label={submitButtonTitle}
|
label={submitButtonTitle}
|
||||||
onClick={handleSubmit}
|
onClick={handleSubmit}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,18 +1,21 @@
|
|||||||
import { Icon, IconType } from '@/components/Icon';
|
import { Icon, IconType } from '@/components/Icon';
|
||||||
import { IconButton } from '@/components/IconButton';
|
|
||||||
import {
|
import {
|
||||||
Disclosure,
|
Disclosure,
|
||||||
DisclosureButton,
|
DisclosureButton,
|
||||||
DisclosurePanel,
|
DisclosurePanel,
|
||||||
} from '@reach/disclosure';
|
} from '@reach/disclosure';
|
||||||
import { FunctionComponent } from 'preact';
|
import { FunctionComponent } from 'preact';
|
||||||
import { useState, useRef, useEffect } from 'react';
|
import { useState, useRef, useEffect, MouseEventHandler } from 'react';
|
||||||
|
|
||||||
const DisclosureIconButton: FunctionComponent<{
|
const DisclosureIconButton: FunctionComponent<{
|
||||||
className?: string;
|
className?: string;
|
||||||
icon: IconType;
|
icon: IconType;
|
||||||
}> = ({ className = '', icon }) => (
|
onMouseEnter?: MouseEventHandler;
|
||||||
|
onMouseLeave?: MouseEventHandler;
|
||||||
|
}> = ({ className = '', icon, onMouseEnter, onMouseLeave }) => (
|
||||||
<DisclosureButton
|
<DisclosureButton
|
||||||
|
onMouseEnter={onMouseEnter}
|
||||||
|
onMouseLeave={onMouseLeave}
|
||||||
className={`no-border cursor-pointer bg-transparent hover:brightness-130 p-0 ${
|
className={`no-border cursor-pointer bg-transparent hover:brightness-130 p-0 ${
|
||||||
className ?? ''
|
className ?? ''
|
||||||
}`}
|
}`}
|
||||||
@@ -29,11 +32,12 @@ const DisclosureIconButton: FunctionComponent<{
|
|||||||
* @returns
|
* @returns
|
||||||
*/
|
*/
|
||||||
export const AuthAppInfoTooltip: FunctionComponent = () => {
|
export const AuthAppInfoTooltip: FunctionComponent = () => {
|
||||||
const [isShown, setShown] = useState(false);
|
const [isClicked, setClicked] = useState(false);
|
||||||
|
const [isHover, setHover] = useState(false);
|
||||||
const ref = useRef(null);
|
const ref = useRef(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const dismiss = () => setShown(false);
|
const dismiss = () => setClicked(false);
|
||||||
document.addEventListener('mousedown', dismiss);
|
document.addEventListener('mousedown', dismiss);
|
||||||
return () => {
|
return () => {
|
||||||
document.removeEventListener('mousedown', dismiss);
|
document.removeEventListener('mousedown', dismiss);
|
||||||
@@ -41,9 +45,17 @@ export const AuthAppInfoTooltip: FunctionComponent = () => {
|
|||||||
}, [ref]);
|
}, [ref]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Disclosure open={isShown} onChange={() => setShown(!isShown)}>
|
<Disclosure
|
||||||
|
open={isClicked || isHover}
|
||||||
|
onChange={() => setClicked(!isClicked)}
|
||||||
|
>
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<DisclosureIconButton icon="info" className="mt-1" />
|
<DisclosureIconButton
|
||||||
|
icon="info"
|
||||||
|
className="mt-1"
|
||||||
|
onMouseEnter={() => setHover(true)}
|
||||||
|
onMouseLeave={() => setHover(false)}
|
||||||
|
/>
|
||||||
<DisclosurePanel>
|
<DisclosurePanel>
|
||||||
<div
|
<div
|
||||||
className={`bg-black color-white text-center rounded shadow-overlay
|
className={`bg-black color-white text-center rounded shadow-overlay
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import { FunctionComponent } from 'preact';
|
||||||
|
|
||||||
|
export const Bullet: FunctionComponent<{ className?: string }> = ({
|
||||||
|
className = '',
|
||||||
|
}) => (
|
||||||
|
<div className={`min-w-1 min-h-1 rounded-full bg-black ${className} mr-2`} />
|
||||||
|
);
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import { FunctionComponent } from 'preact';
|
||||||
|
|
||||||
|
import { IconButton } from '../../../components/IconButton';
|
||||||
|
|
||||||
|
import { useState } from 'preact/hooks';
|
||||||
|
|
||||||
|
export const CopyButton: FunctionComponent<{ copyValue: string }> = ({
|
||||||
|
copyValue: secretKey,
|
||||||
|
}) => {
|
||||||
|
const [isCopied, setCopied] = useState(false);
|
||||||
|
return (
|
||||||
|
<IconButton
|
||||||
|
focusable={false}
|
||||||
|
title="Copy to clipboard"
|
||||||
|
icon={isCopied ? 'check' : 'copy'}
|
||||||
|
className={isCopied ? 'success' : undefined}
|
||||||
|
onClick={() => {
|
||||||
|
navigator?.clipboard?.writeText(secretKey);
|
||||||
|
setCopied(() => true);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -3,13 +3,15 @@ import { DecoratedInput } from '@/components/DecoratedInput';
|
|||||||
import { IconButton } from '@/components/IconButton';
|
import { IconButton } from '@/components/IconButton';
|
||||||
import { observer } from 'mobx-react-lite';
|
import { observer } from 'mobx-react-lite';
|
||||||
import { FunctionComponent } from 'preact';
|
import { FunctionComponent } from 'preact';
|
||||||
|
import { CopyButton } from './CopyButton';
|
||||||
|
import { Bullet } from './Bullet';
|
||||||
import { downloadSecretKey } from './download-secret-key';
|
import { downloadSecretKey } from './download-secret-key';
|
||||||
import { TwoFactorActivation } from './TwoFactorActivation';
|
import { TwoFactorActivation } from './TwoFactorActivation';
|
||||||
import {
|
import {
|
||||||
ModalDialog,
|
ModalDialog,
|
||||||
ModalDialogButtons,
|
ModalDialogButtons,
|
||||||
ModalDialogDescription,
|
ModalDialogDescription,
|
||||||
ModalDialogLabel
|
ModalDialogLabel,
|
||||||
} from '@/components/shared/ModalDialog';
|
} from '@/components/shared/ModalDialog';
|
||||||
|
|
||||||
export const SaveSecretKey: FunctionComponent<{
|
export const SaveSecretKey: FunctionComponent<{
|
||||||
@@ -17,20 +19,14 @@ export const SaveSecretKey: FunctionComponent<{
|
|||||||
}> = observer(({ activation: act }) => {
|
}> = observer(({ activation: act }) => {
|
||||||
const download = (
|
const download = (
|
||||||
<IconButton
|
<IconButton
|
||||||
|
focusable={false}
|
||||||
|
title="Download"
|
||||||
icon="download"
|
icon="download"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
downloadSecretKey(act.secretKey);
|
downloadSecretKey(act.secretKey);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
const copy = (
|
|
||||||
<IconButton
|
|
||||||
icon="copy"
|
|
||||||
onClick={() => {
|
|
||||||
navigator?.clipboard?.writeText(act.secretKey);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
return (
|
return (
|
||||||
<ModalDialog>
|
<ModalDialog>
|
||||||
<ModalDialogLabel
|
<ModalDialogLabel
|
||||||
@@ -40,11 +36,13 @@ export const SaveSecretKey: FunctionComponent<{
|
|||||||
>
|
>
|
||||||
Step 2 of 3 - Save secret key
|
Step 2 of 3 - Save secret key
|
||||||
</ModalDialogLabel>
|
</ModalDialogLabel>
|
||||||
<ModalDialogDescription>
|
<ModalDialogDescription className="h-33">
|
||||||
<div className="flex-grow flex flex-col gap-2">
|
<div className="flex-grow flex flex-col">
|
||||||
<div className="flex flex-row items-center gap-1">
|
<div className="flex flex-row items-center">
|
||||||
|
<Bullet />
|
||||||
|
<div className="min-w-1" />
|
||||||
<div className="text-sm">
|
<div className="text-sm">
|
||||||
・<b>Save your secret key</b>{' '}
|
<b>Save your secret key</b>{' '}
|
||||||
<a
|
<a
|
||||||
target="_blank"
|
target="_blank"
|
||||||
href="https://standardnotes.com/help/21/where-should-i-store-my-two-factor-authentication-secret-key"
|
href="https://standardnotes.com/help/21/where-should-i-store-my-two-factor-authentication-secret-key"
|
||||||
@@ -53,21 +51,27 @@ export const SaveSecretKey: FunctionComponent<{
|
|||||||
</a>
|
</a>
|
||||||
:
|
:
|
||||||
</div>
|
</div>
|
||||||
|
<div className="min-w-2" />
|
||||||
<DecoratedInput
|
<DecoratedInput
|
||||||
disabled={true}
|
disabled={true}
|
||||||
right={[copy, download]}
|
right={[<CopyButton copyValue={act.secretKey} />, download]}
|
||||||
text={act.secretKey}
|
text={act.secretKey}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-sm">
|
<div className="h-2" />
|
||||||
・You can use this key to generate codes if you lose access to your
|
<div className="flex flex-row items-center">
|
||||||
authenticator app.{' '}
|
<Bullet />
|
||||||
<a
|
<div className="min-w-1" />
|
||||||
target="_blank"
|
<div className="text-sm">
|
||||||
href="https://standardnotes.com/help/22/what-happens-if-i-lose-my-2fa-device-and-my-secret-key"
|
You can use this key to generate codes if you lose access to your
|
||||||
>
|
authenticator app.{' '}
|
||||||
Learn more
|
<a
|
||||||
</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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</ModalDialogDescription>
|
</ModalDialogDescription>
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import { observer } from 'mobx-react-lite';
|
|||||||
import QRCode from 'qrcode.react';
|
import QRCode from 'qrcode.react';
|
||||||
|
|
||||||
import { DecoratedInput } from '@/components/DecoratedInput';
|
import { DecoratedInput } from '@/components/DecoratedInput';
|
||||||
import { IconButton } from '@/components/IconButton';
|
|
||||||
import { Button } from '@/components/Button';
|
import { Button } from '@/components/Button';
|
||||||
import { TwoFactorActivation } from './TwoFactorActivation';
|
import { TwoFactorActivation } from './TwoFactorActivation';
|
||||||
import { AuthAppInfoTooltip } from './AuthAppInfoPopup';
|
import { AuthAppInfoTooltip } from './AuthAppInfoPopup';
|
||||||
@@ -12,54 +11,49 @@ import {
|
|||||||
ModalDialog,
|
ModalDialog,
|
||||||
ModalDialogButtons,
|
ModalDialogButtons,
|
||||||
ModalDialogDescription,
|
ModalDialogDescription,
|
||||||
ModalDialogLabel
|
ModalDialogLabel,
|
||||||
} from '@/components/shared/ModalDialog';
|
} from '@/components/shared/ModalDialog';
|
||||||
|
import { CopyButton } from './CopyButton';
|
||||||
|
import { Bullet } from './Bullet';
|
||||||
|
|
||||||
export const ScanQRCode: FunctionComponent<{
|
export const ScanQRCode: FunctionComponent<{
|
||||||
activation: TwoFactorActivation;
|
activation: TwoFactorActivation;
|
||||||
}> = observer(({ activation: act }) => {
|
}> = observer(({ activation: act }) => {
|
||||||
const copy = (
|
|
||||||
<IconButton
|
|
||||||
icon="copy"
|
|
||||||
onClick={() => {
|
|
||||||
navigator?.clipboard?.writeText(act.secretKey);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
return (
|
return (
|
||||||
<ModalDialog>
|
<ModalDialog>
|
||||||
<ModalDialogLabel
|
<ModalDialogLabel closeDialog={act.cancelActivation}>
|
||||||
closeDialog={() => {
|
|
||||||
act.cancelActivation();
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Step 1 of 3 - Scan QR code
|
Step 1 of 3 - Scan QR code
|
||||||
</ModalDialogLabel>
|
</ModalDialogLabel>
|
||||||
<ModalDialogDescription>
|
<ModalDialogDescription className="h-33">
|
||||||
<div className="flex flex-row gap-3 items-center">
|
<div className="w-25 h-25 flex items-center justify-center bg-info">
|
||||||
<div className="w-25 h-25 flex items-center justify-center bg-info">
|
<QRCode value={act.qrCode} size={100} />
|
||||||
<QRCode value={act.qrCode} size={100} />
|
</div>
|
||||||
</div>
|
<div className="min-w-5" />
|
||||||
<div className="flex-grow flex flex-col gap-2">
|
<div className="flex-grow flex flex-col">
|
||||||
<div className="flex flex-row gap-1 items-center">
|
<div className="flex flex-row items-center">
|
||||||
<div className="text-sm">
|
<Bullet />
|
||||||
・Open your <b>authenticator app</b>.
|
<div className="min-w-1" />
|
||||||
</div>
|
<div className="text-sm">
|
||||||
<AuthAppInfoTooltip />
|
Open your <b>authenticator app</b>.
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-row items-center">
|
<div className="min-w-2" />
|
||||||
<div className="text-sm flex-grow">
|
<AuthAppInfoTooltip />
|
||||||
・<b>Scan this QR code</b> or <b>add this secret key</b>:
|
</div>
|
||||||
</div>
|
<div className="min-h-2" />
|
||||||
<div className="w-56">
|
<div className="flex flex-row items-center">
|
||||||
<DecoratedInput
|
<Bullet className="self-start mt-2" />
|
||||||
disabled={true}
|
<div className="min-w-1" />
|
||||||
text={act.secretKey}
|
<div className="text-sm flex-grow">
|
||||||
right={[copy]}
|
<b>Scan this QR code</b> or <b>add this secret key</b>:
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="min-h-2" />
|
||||||
|
<DecoratedInput
|
||||||
|
className="ml-4 w-92"
|
||||||
|
disabled={true}
|
||||||
|
text={act.secretKey}
|
||||||
|
right={[<CopyButton copyValue={act.secretKey} />]}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</ModalDialogDescription>
|
</ModalDialogDescription>
|
||||||
<ModalDialogButtons>
|
<ModalDialogButtons>
|
||||||
|
|||||||
@@ -1,8 +1,16 @@
|
|||||||
import { MfaProvider, UserProvider } from '../../providers';
|
import { MfaProvider } from '../../providers';
|
||||||
import { action, makeAutoObservable, observable } from 'mobx';
|
import { action, makeAutoObservable, observable } from 'mobx';
|
||||||
|
|
||||||
type ActivationStep = 'scan-qr-code' | 'save-secret-key' | 'verification';
|
type ActivationStep =
|
||||||
type VerificationStatus = 'none' | 'invalid' | 'valid';
|
| 'scan-qr-code'
|
||||||
|
| 'save-secret-key'
|
||||||
|
| 'verification'
|
||||||
|
| 'success';
|
||||||
|
type VerificationStatus =
|
||||||
|
| 'none'
|
||||||
|
| 'invalid-auth-code'
|
||||||
|
| 'invalid-secret'
|
||||||
|
| 'valid';
|
||||||
|
|
||||||
export class TwoFactorActivation {
|
export class TwoFactorActivation {
|
||||||
public readonly type = 'two-factor-activation' as const;
|
public readonly type = 'two-factor-activation' as const;
|
||||||
@@ -16,7 +24,7 @@ export class TwoFactorActivation {
|
|||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private mfaProvider: MfaProvider,
|
private mfaProvider: MfaProvider,
|
||||||
private userProvider: UserProvider,
|
private readonly email: string,
|
||||||
private readonly _secretKey: string,
|
private readonly _secretKey: string,
|
||||||
private _cancelActivation: () => void,
|
private _cancelActivation: () => void,
|
||||||
private _enabled2FA: () => void
|
private _enabled2FA: () => void
|
||||||
@@ -29,7 +37,6 @@ export class TwoFactorActivation {
|
|||||||
| '_authCode'
|
| '_authCode'
|
||||||
| '_step'
|
| '_step'
|
||||||
| '_enable2FAVerification'
|
| '_enable2FAVerification'
|
||||||
| 'refreshOtp'
|
|
||||||
| 'inputOtpToken'
|
| 'inputOtpToken'
|
||||||
| 'inputSecretKey'
|
| 'inputSecretKey'
|
||||||
>(
|
>(
|
||||||
@@ -39,7 +46,6 @@ export class TwoFactorActivation {
|
|||||||
_authCode: observable,
|
_authCode: observable,
|
||||||
_step: observable,
|
_step: observable,
|
||||||
_enable2FAVerification: observable,
|
_enable2FAVerification: observable,
|
||||||
refreshOtp: action,
|
|
||||||
inputOtpToken: observable,
|
inputOtpToken: observable,
|
||||||
inputSecretKey: observable,
|
inputSecretKey: observable,
|
||||||
},
|
},
|
||||||
@@ -60,8 +66,7 @@ export class TwoFactorActivation {
|
|||||||
}
|
}
|
||||||
|
|
||||||
get qrCode(): string {
|
get qrCode(): string {
|
||||||
const email = this.userProvider.getUser()!.email;
|
return `otpauth://totp/2FA?secret=${this._secretKey}&issuer=Standard%20Notes&label=${this.email}`;
|
||||||
return `otpauth://totp/2FA?secret=${this._secretKey}&issuer=Standard%20Notes&label=${email}`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
cancelActivation(): void {
|
cancelActivation(): void {
|
||||||
@@ -69,8 +74,7 @@ export class TwoFactorActivation {
|
|||||||
}
|
}
|
||||||
|
|
||||||
openScanQRCode(): void {
|
openScanQRCode(): void {
|
||||||
const preconditions: ActivationStep[] = ['save-secret-key'];
|
if (this._activationStep === 'save-secret-key') {
|
||||||
if (preconditions.includes(this._activationStep)) {
|
|
||||||
this._activationStep = 'scan-qr-code';
|
this._activationStep = 'scan-qr-code';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -85,13 +89,18 @@ export class TwoFactorActivation {
|
|||||||
openVerification(): void {
|
openVerification(): void {
|
||||||
this.inputOtpToken = '';
|
this.inputOtpToken = '';
|
||||||
this.inputSecretKey = '';
|
this.inputSecretKey = '';
|
||||||
const preconditions: ActivationStep[] = ['save-secret-key'];
|
if (this._activationStep === 'save-secret-key') {
|
||||||
if (preconditions.includes(this._activationStep)) {
|
|
||||||
this._activationStep = 'verification';
|
this._activationStep = 'verification';
|
||||||
this._2FAVerification = 'none';
|
this._2FAVerification = 'none';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
openSuccess(): void {
|
||||||
|
if (this._activationStep === 'verification') {
|
||||||
|
this._activationStep = 'success';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
setInputSecretKey(secretKey: string): void {
|
setInputSecretKey(secretKey: string): void {
|
||||||
this.inputSecretKey = secretKey;
|
this.inputSecretKey = secretKey;
|
||||||
}
|
}
|
||||||
@@ -101,22 +110,29 @@ export class TwoFactorActivation {
|
|||||||
}
|
}
|
||||||
|
|
||||||
enable2FA(): void {
|
enable2FA(): void {
|
||||||
if (this.inputSecretKey === this._secretKey) {
|
if (this.inputSecretKey !== this._secretKey) {
|
||||||
this.mfaProvider
|
this._2FAVerification = 'invalid-secret';
|
||||||
.enableMfa(this.inputSecretKey, this.inputOtpToken)
|
return;
|
||||||
.then(
|
}
|
||||||
action(() => {
|
|
||||||
this._2FAVerification = 'valid';
|
this.mfaProvider
|
||||||
this._enabled2FA();
|
.enableMfa(this.inputSecretKey, this.inputOtpToken)
|
||||||
})
|
.then(
|
||||||
)
|
action(() => {
|
||||||
.catch(
|
this._2FAVerification = 'valid';
|
||||||
action(() => {
|
this.openSuccess();
|
||||||
this._2FAVerification = 'invalid';
|
})
|
||||||
})
|
)
|
||||||
);
|
.catch(
|
||||||
} else {
|
action(() => {
|
||||||
this._2FAVerification = 'invalid';
|
this._2FAVerification = 'invalid-auth-code';
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
finishActivation(): void {
|
||||||
|
if (this._activationStep === 'success') {
|
||||||
|
this._enabled2FA();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,17 +4,19 @@ import { TwoFactorActivation } from './TwoFactorActivation';
|
|||||||
import { SaveSecretKey } from './SaveSecretKey';
|
import { SaveSecretKey } from './SaveSecretKey';
|
||||||
import { ScanQRCode } from './ScanQRCode';
|
import { ScanQRCode } from './ScanQRCode';
|
||||||
import { Verification } from './Verification';
|
import { Verification } from './Verification';
|
||||||
|
import { TwoFactorSuccess } from './TwoFactorSuccess';
|
||||||
|
|
||||||
export const TwoFactorActivationView: FunctionComponent<{
|
export const TwoFactorActivationView: FunctionComponent<{
|
||||||
activation: TwoFactorActivation;
|
activation: TwoFactorActivation;
|
||||||
}> = observer(({ activation: act }) => (
|
}> = observer(({ activation: act }) => {
|
||||||
<>
|
switch (act.activationStep) {
|
||||||
{act.activationStep === 'scan-qr-code' && <ScanQRCode activation={act} />}
|
case 'scan-qr-code':
|
||||||
|
return <ScanQRCode activation={act} />;
|
||||||
{act.activationStep === 'save-secret-key' && (
|
case 'save-secret-key':
|
||||||
<SaveSecretKey activation={act} />
|
return <SaveSecretKey activation={act} />;
|
||||||
)}
|
case 'verification':
|
||||||
|
return <Verification activation={act} />;
|
||||||
{act.activationStep === 'verification' && <Verification activation={act} />}
|
case 'success':
|
||||||
</>
|
return <TwoFactorSuccess activation={act} />;
|
||||||
));
|
}
|
||||||
|
});
|
||||||
|
|||||||
@@ -7,17 +7,21 @@ type TwoFactorStatus =
|
|||||||
| TwoFactorActivation
|
| TwoFactorActivation
|
||||||
| 'two-factor-disabled';
|
| 'two-factor-disabled';
|
||||||
|
|
||||||
export const is2FADisabled = (status: TwoFactorStatus): status is 'two-factor-disabled' =>
|
export const is2FADisabled = (
|
||||||
status === 'two-factor-disabled';
|
status: TwoFactorStatus
|
||||||
|
): status is 'two-factor-disabled' => status === 'two-factor-disabled';
|
||||||
|
|
||||||
export const is2FAActivation = (status: TwoFactorStatus): status is TwoFactorActivation =>
|
export const is2FAActivation = (
|
||||||
|
status: TwoFactorStatus
|
||||||
|
): status is TwoFactorActivation =>
|
||||||
(status as TwoFactorActivation)?.type === 'two-factor-activation';
|
(status as TwoFactorActivation)?.type === 'two-factor-activation';
|
||||||
|
|
||||||
export const is2FAEnabled = (status: TwoFactorStatus): status is 'two-factor-enabled' =>
|
export const is2FAEnabled = (
|
||||||
status === 'two-factor-enabled';
|
status: TwoFactorStatus
|
||||||
|
): status is 'two-factor-enabled' => status === 'two-factor-enabled';
|
||||||
|
|
||||||
export class TwoFactorAuth {
|
export class TwoFactorAuth {
|
||||||
private _status: TwoFactorStatus | 'fetching' = 'fetching';
|
private _status: TwoFactorStatus = 'two-factor-disabled';
|
||||||
private _errorMessage: string | null;
|
private _errorMessage: string | null;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
@@ -29,24 +33,28 @@ export class TwoFactorAuth {
|
|||||||
makeAutoObservable<
|
makeAutoObservable<
|
||||||
TwoFactorAuth,
|
TwoFactorAuth,
|
||||||
'_status' | '_errorMessage' | 'deactivateMfa' | 'startActivation'
|
'_status' | '_errorMessage' | 'deactivateMfa' | 'startActivation'
|
||||||
>(this, {
|
>(
|
||||||
_status: observable,
|
this,
|
||||||
_errorMessage: observable,
|
{
|
||||||
deactivateMfa: action,
|
_status: observable,
|
||||||
startActivation: action,
|
_errorMessage: observable,
|
||||||
});
|
deactivateMfa: action,
|
||||||
|
startActivation: action,
|
||||||
|
},
|
||||||
|
{ autoBind: true }
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
private startActivation(): void {
|
private startActivation(): void {
|
||||||
const setDisabled = action(() => (this._status = 'two-factor-disabled'));
|
const setDisabled = action(() => (this._status = 'two-factor-disabled'));
|
||||||
const setEnabled = action(() => (this._status = 'two-factor-enabled'));
|
const setEnabled = action(() => this.fetchStatus());
|
||||||
this.mfaProvider
|
this.mfaProvider
|
||||||
.generateMfaSecret()
|
.generateMfaSecret()
|
||||||
.then(
|
.then(
|
||||||
action((secret) => {
|
action((secret) => {
|
||||||
this._status = new TwoFactorActivation(
|
this._status = new TwoFactorActivation(
|
||||||
this.mfaProvider,
|
this.mfaProvider,
|
||||||
this.userProvider,
|
this.userProvider.getUser()!.email,
|
||||||
secret,
|
secret,
|
||||||
setDisabled,
|
setDisabled,
|
||||||
setEnabled
|
setEnabled
|
||||||
@@ -65,7 +73,7 @@ export class TwoFactorAuth {
|
|||||||
.disableMfa()
|
.disableMfa()
|
||||||
.then(
|
.then(
|
||||||
action(() => {
|
action(() => {
|
||||||
this._status = 'two-factor-disabled';
|
this.fetchStatus();
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
.catch(
|
.catch(
|
||||||
@@ -75,18 +83,16 @@ export class TwoFactorAuth {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
private get isLoggedIn(): boolean {
|
isLoggedIn(): boolean {
|
||||||
return this.userProvider.getUser() != undefined;
|
return this.userProvider.getUser() != undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
fetchStatus(): void {
|
fetchStatus(): void {
|
||||||
this._status = 'fetching';
|
if (!this.isLoggedIn()) {
|
||||||
|
|
||||||
if (!this.isLoggedIn) {
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!this.isMfaFeatureAvailable) {
|
if (!this.isMfaFeatureAvailable()) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -111,11 +117,11 @@ export class TwoFactorAuth {
|
|||||||
}
|
}
|
||||||
|
|
||||||
toggle2FA(): void {
|
toggle2FA(): void {
|
||||||
if (!this.isLoggedIn) {
|
if (!this.isLoggedIn()) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!this.isMfaFeatureAvailable) {
|
if (!this.isMfaFeatureAvailable()) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -129,23 +135,14 @@ export class TwoFactorAuth {
|
|||||||
}
|
}
|
||||||
|
|
||||||
get errorMessage(): string | null {
|
get errorMessage(): string | null {
|
||||||
if (!this.isLoggedIn) {
|
|
||||||
return 'Two-factor authentication not available / Sign in or register for an account to configure 2FA';
|
|
||||||
}
|
|
||||||
if (!this.isMfaFeatureAvailable) {
|
|
||||||
return 'Two-factor authentication not available / A paid subscription plan is required to enable 2FA.';
|
|
||||||
}
|
|
||||||
return this._errorMessage;
|
return this._errorMessage;
|
||||||
}
|
}
|
||||||
|
|
||||||
get status(): TwoFactorStatus {
|
get status(): TwoFactorStatus {
|
||||||
if (this._status === 'fetching') {
|
|
||||||
return 'two-factor-disabled';
|
|
||||||
}
|
|
||||||
return this._status;
|
return this._status;
|
||||||
}
|
}
|
||||||
|
|
||||||
private get isMfaFeatureAvailable(): boolean {
|
isMfaFeatureAvailable(): boolean {
|
||||||
return this.mfaProvider.isMfaFeatureAvailable();
|
return this.mfaProvider.isMfaFeatureAvailable();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,41 +4,85 @@ import {
|
|||||||
Text,
|
Text,
|
||||||
PreferencesGroup,
|
PreferencesGroup,
|
||||||
PreferencesSegment,
|
PreferencesSegment,
|
||||||
|
LinkButton,
|
||||||
} from '../../components';
|
} from '../../components';
|
||||||
import { Switch } from '../../../components/Switch';
|
import { Switch } from '../../../components/Switch';
|
||||||
import { observer } from 'mobx-react-lite';
|
import { observer } from 'mobx-react-lite';
|
||||||
import { is2FAActivation, is2FADisabled, TwoFactorAuth } from './TwoFactorAuth';
|
import { is2FAActivation, is2FADisabled, TwoFactorAuth } from './TwoFactorAuth';
|
||||||
import { TwoFactorActivationView } from './TwoFactorActivationView';
|
import { TwoFactorActivationView } from './TwoFactorActivationView';
|
||||||
|
|
||||||
|
const TwoFactorTitle: FunctionComponent<{ auth: TwoFactorAuth }> = observer(
|
||||||
|
({ auth }) => {
|
||||||
|
if (!auth.isLoggedIn()) {
|
||||||
|
return <Title>Two-factor authentication not available</Title>;
|
||||||
|
}
|
||||||
|
if (!auth.isMfaFeatureAvailable()) {
|
||||||
|
return <Title>Two-factor authentication not available</Title>;
|
||||||
|
}
|
||||||
|
return <Title>Two-factor authentication</Title>;
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
const TwoFactorDescription: FunctionComponent<{ auth: TwoFactorAuth }> =
|
||||||
|
observer(({ auth }) => {
|
||||||
|
if (!auth.isLoggedIn()) {
|
||||||
|
return <Text>Sign in or register for an account to configure 2FA.</Text>;
|
||||||
|
}
|
||||||
|
if (!auth.isMfaFeatureAvailable()) {
|
||||||
|
return (
|
||||||
|
<Text>
|
||||||
|
A paid subscription plan is required to enable 2FA.{' '}
|
||||||
|
<a target="_blank" href="https://standardnotes.com/features">
|
||||||
|
Learn more
|
||||||
|
</a>
|
||||||
|
.
|
||||||
|
</Text>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<Text>An extra layer of security when logging in to your account.</Text>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
const TwoFactorSwitch: FunctionComponent<{ auth: TwoFactorAuth }> = observer(
|
||||||
|
({ auth }) => {
|
||||||
|
if (auth.isLoggedIn() && auth.isMfaFeatureAvailable()) {
|
||||||
|
return (
|
||||||
|
<Switch
|
||||||
|
checked={!is2FADisabled(auth.status)}
|
||||||
|
onChange={auth.toggle2FA}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
export const TwoFactorAuthView: FunctionComponent<{
|
export const TwoFactorAuthView: FunctionComponent<{
|
||||||
auth: TwoFactorAuth;
|
auth: TwoFactorAuth;
|
||||||
}> = observer(({ auth }) => {
|
}> = observer(({ auth }) => {
|
||||||
return (
|
return (
|
||||||
<PreferencesGroup>
|
<>
|
||||||
<PreferencesSegment>
|
<PreferencesGroup>
|
||||||
<div className="flex flex-row items-center">
|
<PreferencesSegment>
|
||||||
<div className="flex-grow flex flex-col">
|
<div className="flex flex-row items-center">
|
||||||
<Title>Two-factor authentication</Title>
|
<div className="flex-grow flex flex-col">
|
||||||
<Text>
|
<TwoFactorTitle auth={auth} />
|
||||||
An extra layer of security when logging in to your account.
|
<TwoFactorDescription auth={auth} />
|
||||||
</Text>
|
</div>
|
||||||
|
<TwoFactorSwitch auth={auth} />
|
||||||
</div>
|
</div>
|
||||||
<Switch
|
</PreferencesSegment>
|
||||||
checked={!is2FADisabled(auth.status)}
|
|
||||||
onChange={auth.toggle2FA}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</PreferencesSegment>
|
|
||||||
|
|
||||||
|
{auth.errorMessage != null && (
|
||||||
|
<PreferencesSegment>
|
||||||
|
<Text className="color-danger">{auth.errorMessage}</Text>
|
||||||
|
</PreferencesSegment>
|
||||||
|
)}
|
||||||
|
</PreferencesGroup>
|
||||||
{is2FAActivation(auth.status) && (
|
{is2FAActivation(auth.status) && (
|
||||||
<TwoFactorActivationView activation={auth.status} />
|
<TwoFactorActivationView activation={auth.status} />
|
||||||
)}
|
)}
|
||||||
|
</>
|
||||||
{auth.errorMessage != null && (
|
|
||||||
<PreferencesSegment>
|
|
||||||
<Text className="color-danger">{auth.errorMessage}</Text>
|
|
||||||
</PreferencesSegment>
|
|
||||||
)}
|
|
||||||
</PreferencesGroup>
|
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import { Button } from '@/components/Button';
|
||||||
|
import ModalDialog, {
|
||||||
|
ModalDialogButtons,
|
||||||
|
ModalDialogDescription,
|
||||||
|
ModalDialogLabel,
|
||||||
|
} from '@/components/shared/ModalDialog';
|
||||||
|
import { Subtitle } from '@/preferences/components';
|
||||||
|
import { observer } from 'mobx-react-lite';
|
||||||
|
import { FunctionComponent } from 'preact';
|
||||||
|
import { TwoFactorActivation } from './TwoFactorActivation';
|
||||||
|
|
||||||
|
export const TwoFactorSuccess: FunctionComponent<{
|
||||||
|
activation: TwoFactorActivation;
|
||||||
|
}> = observer(({ activation: act }) => (
|
||||||
|
<ModalDialog>
|
||||||
|
<ModalDialogLabel closeDialog={act.finishActivation}>
|
||||||
|
Successfully Enabled
|
||||||
|
</ModalDialogLabel>
|
||||||
|
<ModalDialogDescription>
|
||||||
|
<div className="flex flex-row items-center justify-center pt-2">
|
||||||
|
<Subtitle>
|
||||||
|
Two-factor authentication has been successfully enabled for your
|
||||||
|
account.
|
||||||
|
</Subtitle>
|
||||||
|
</div>
|
||||||
|
</ModalDialogDescription>
|
||||||
|
<ModalDialogButtons>
|
||||||
|
<Button
|
||||||
|
className="min-w-20"
|
||||||
|
type="primary"
|
||||||
|
label="Finish"
|
||||||
|
onClick={act.finishActivation}
|
||||||
|
/>
|
||||||
|
</ModalDialogButtons>
|
||||||
|
</ModalDialog>
|
||||||
|
));
|
||||||
@@ -2,51 +2,66 @@ import { Button } from '@/components/Button';
|
|||||||
import { DecoratedInput } from '@/components/DecoratedInput';
|
import { DecoratedInput } from '@/components/DecoratedInput';
|
||||||
import { observer } from 'mobx-react-lite';
|
import { observer } from 'mobx-react-lite';
|
||||||
import { FunctionComponent } from 'preact';
|
import { FunctionComponent } from 'preact';
|
||||||
|
import { Bullet } from './Bullet';
|
||||||
import { TwoFactorActivation } from './TwoFactorActivation';
|
import { TwoFactorActivation } from './TwoFactorActivation';
|
||||||
import {
|
import {
|
||||||
ModalDialog,
|
ModalDialog,
|
||||||
ModalDialogButtons,
|
ModalDialogButtons,
|
||||||
ModalDialogDescription,
|
ModalDialogDescription,
|
||||||
ModalDialogLabel
|
ModalDialogLabel,
|
||||||
} from '@/components/shared/ModalDialog';
|
} from '@/components/shared/ModalDialog';
|
||||||
|
|
||||||
export const Verification: FunctionComponent<{
|
export const Verification: FunctionComponent<{
|
||||||
activation: TwoFactorActivation;
|
activation: TwoFactorActivation;
|
||||||
}> = observer(({ activation: act }) => {
|
}> = observer(({ activation: act }) => {
|
||||||
const borderInv =
|
const secretKeyClass =
|
||||||
act.verificationStatus === 'invalid' ? 'border-dark-red' : '';
|
act.verificationStatus === 'invalid-secret' ? 'border-danger' : '';
|
||||||
|
const authTokenClass =
|
||||||
|
act.verificationStatus === 'invalid-auth-code' ? 'border-danger' : '';
|
||||||
return (
|
return (
|
||||||
<ModalDialog>
|
<ModalDialog>
|
||||||
<ModalDialogLabel closeDialog={act.cancelActivation}>
|
<ModalDialogLabel closeDialog={act.cancelActivation}>
|
||||||
Step 3 of 3 - Verification
|
Step 3 of 3 - Verification
|
||||||
</ModalDialogLabel>
|
</ModalDialogLabel>
|
||||||
<ModalDialogDescription>
|
<ModalDialogDescription className="h-33">
|
||||||
<div className="flex-grow flex flex-col gap-1">
|
<div className="flex-grow flex flex-col">
|
||||||
<div className="flex flex-row items-center gap-2">
|
<div className="flex flex-row items-center">
|
||||||
|
<Bullet />
|
||||||
|
<div className="min-w-1" />
|
||||||
<div className="text-sm">
|
<div className="text-sm">
|
||||||
・Enter your <b>secret key</b>:
|
Enter your <b>secret key</b>:
|
||||||
</div>
|
</div>
|
||||||
|
<div className="min-w-2" />
|
||||||
<DecoratedInput
|
<DecoratedInput
|
||||||
className={borderInv}
|
className={`w-92 ${secretKeyClass}`}
|
||||||
onChange={act.setInputSecretKey}
|
onChange={act.setInputSecretKey}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-row items-center gap-2">
|
<div className="min-h-1" />
|
||||||
|
<div className="flex flex-row items-center">
|
||||||
|
<Bullet />
|
||||||
|
<div className="min-w-1" />
|
||||||
<div className="text-sm">
|
<div className="text-sm">
|
||||||
・Verify the <b>authentication code</b> generated by your
|
Verify the <b>authentication code</b> generated by your
|
||||||
authenticator app:
|
authenticator app:
|
||||||
</div>
|
</div>
|
||||||
|
<div className="min-w-2" />
|
||||||
<DecoratedInput
|
<DecoratedInput
|
||||||
className={`w-30 ${borderInv}`}
|
className={`w-30 ${authTokenClass}`}
|
||||||
onChange={act.setInputOtpToken}
|
onChange={act.setInputOtpToken}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</ModalDialogDescription>
|
</ModalDialogDescription>
|
||||||
<ModalDialogButtons>
|
<ModalDialogButtons>
|
||||||
{act.verificationStatus === 'invalid' && (
|
{act.verificationStatus === 'invalid-auth-code' && (
|
||||||
<div className="text-sm color-danger">
|
<div className="text-sm color-danger flex-grow">
|
||||||
Incorrect credentials, please try again.
|
Incorrect authentication code, please try again.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{act.verificationStatus === 'invalid-secret' && (
|
||||||
|
<div className="text-sm color-danger flex-grow">
|
||||||
|
Incorrect secret key, please try again.
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
@@ -13,7 +13,6 @@
|
|||||||
@extend .items-center;
|
@extend .items-center;
|
||||||
@extend .justify-start;
|
@extend .justify-start;
|
||||||
|
|
||||||
@extend .gap-1;
|
|
||||||
@extend .text-sm;
|
@extend .text-sm;
|
||||||
@extend .cursor-pointer;
|
@extend .cursor-pointer;
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,10 @@
|
|||||||
height: 90vh;
|
height: 90vh;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.h-33 {
|
||||||
|
height: 8.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
.hidden {
|
.hidden {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
@@ -150,6 +154,62 @@
|
|||||||
@extend .font-bold;
|
@extend .font-bold;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.ml-4 {
|
||||||
|
margin-left: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
.mr-3 {
|
.mr-3 {
|
||||||
margin-right: 0.75rem;
|
margin-right: 0.75rem;
|
||||||
}
|
}
|
||||||
|
.my-4 {
|
||||||
|
margin-top: 1rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.w-92 {
|
||||||
|
width: 23rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.min-w-1 {
|
||||||
|
min-width: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.min-w-2 {
|
||||||
|
min-width: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.min-w-3 {
|
||||||
|
min-width: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.min-w-5 {
|
||||||
|
min-width: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.min-w-7 {
|
||||||
|
min-width: 1.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.min-h-1 {
|
||||||
|
min-height: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.min-h-2 {
|
||||||
|
min-height: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.min-h-3 {
|
||||||
|
min-height: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.border-danger {
|
||||||
|
border-color: var(--sn-stylekit-danger-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pt-1 {
|
||||||
|
padding-top: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pt-2 {
|
||||||
|
padding-top: 0.5rem;
|
||||||
|
}
|
||||||
|
|||||||
@@ -72,7 +72,7 @@
|
|||||||
"@reach/dialog": "^0.13.0",
|
"@reach/dialog": "^0.13.0",
|
||||||
"@standardnotes/sncrypto-web": "1.5.2",
|
"@standardnotes/sncrypto-web": "1.5.2",
|
||||||
"@standardnotes/features": "1.6.1",
|
"@standardnotes/features": "1.6.1",
|
||||||
"@standardnotes/snjs": "2.14.3",
|
"@standardnotes/snjs": "2.14.5",
|
||||||
"mobx": "^6.3.2",
|
"mobx": "^6.3.2",
|
||||||
"mobx-react-lite": "^3.2.0",
|
"mobx-react-lite": "^3.2.0",
|
||||||
"preact": "^10.5.12",
|
"preact": "^10.5.12",
|
||||||
|
|||||||
@@ -2069,10 +2069,10 @@
|
|||||||
"@standardnotes/sncrypto-common" "^1.5.2"
|
"@standardnotes/sncrypto-common" "^1.5.2"
|
||||||
libsodium-wrappers "^0.7.8"
|
libsodium-wrappers "^0.7.8"
|
||||||
|
|
||||||
"@standardnotes/snjs@2.14.3":
|
"@standardnotes/snjs@2.14.5":
|
||||||
version "2.14.3"
|
version "2.14.5"
|
||||||
resolved "https://registry.yarnpkg.com/@standardnotes/snjs/-/snjs-2.14.3.tgz#9d39508d5144db87359b3893ef05da8b0b793053"
|
resolved "https://registry.yarnpkg.com/@standardnotes/snjs/-/snjs-2.14.5.tgz#1a48b71a4ae5dbdc863cdbbb468e8ebf2113f918"
|
||||||
integrity sha512-pX0WoWgY11ZMLdePvRNjnNoYVpTjWn4MLVnZGdSXp+Qz5rra/Y/ZBXFusrHG4+KqAqJ0AVhD6y5AwxtxuJwISQ==
|
integrity sha512-PAjv4VgWs//pzG4aYdnQC+4bY8MmwBgkdbwEfCmWRVXv7o/Mfa4t1ds+FbeiyqoEhwCcdWg3E7RRWPqRH93lQQ==
|
||||||
dependencies:
|
dependencies:
|
||||||
"@standardnotes/auth" "3.7.2"
|
"@standardnotes/auth" "3.7.2"
|
||||||
"@standardnotes/common" "1.1.0"
|
"@standardnotes/common" "1.1.0"
|
||||||
|
|||||||
Reference in New Issue
Block a user