feat: two factor authentication segment in preferences with mocked state (#600)
This commit is contained in:
@@ -65,7 +65,7 @@ import { NotesContextMenuDirective } from './components/NotesContextMenu';
|
||||
import { NotesOptionsPanelDirective } from './components/NotesOptionsPanel';
|
||||
import { IconDirective } from './components/Icon';
|
||||
import { NoteTagsContainerDirective } from './components/NoteTagsContainer';
|
||||
import { PreferencesDirective } from './components/preferences';
|
||||
import { PreferencesDirective } from './preferences';
|
||||
|
||||
function reloadHiddenFirefoxTab(): boolean {
|
||||
/**
|
||||
|
||||
42
app/assets/javascripts/components/DecoratedInput.tsx
Normal file
42
app/assets/javascripts/components/DecoratedInput.tsx
Normal file
@@ -0,0 +1,42 @@
|
||||
import { FunctionalComponent, ComponentChild } from 'preact';
|
||||
|
||||
interface Props {
|
||||
className?: string;
|
||||
disabled?: boolean;
|
||||
left?: ComponentChild[];
|
||||
right?: ComponentChild[];
|
||||
text?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Input that can be decorated on the left and right side
|
||||
*/
|
||||
export const DecoratedInput: FunctionalComponent<Props> = ({
|
||||
className = '',
|
||||
disabled = false,
|
||||
left,
|
||||
right,
|
||||
text,
|
||||
}) => {
|
||||
const base =
|
||||
'rounded py-1.5 px-3 text-input my-1 h-8 flex flex-row items-center gap-4';
|
||||
const stateClasses = disabled
|
||||
? 'no-border bg-grey-5'
|
||||
: 'border-solid border-1 border-gray-300';
|
||||
const classes = `${base} ${stateClasses} ${className}`;
|
||||
|
||||
return (
|
||||
<div className={classes}>
|
||||
{left}
|
||||
<div className="flex-grow">
|
||||
<input
|
||||
type="text"
|
||||
className="w-full no-border color-black"
|
||||
disabled={disabled}
|
||||
value={text}
|
||||
/>
|
||||
</div>
|
||||
{right}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -23,6 +23,8 @@ import SettingsIcon from '../../icons/ic-settings.svg';
|
||||
import StarIcon from '../../icons/ic-star.svg';
|
||||
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 { toDirective } from './utils';
|
||||
import { FunctionalComponent } from 'preact';
|
||||
@@ -52,6 +54,8 @@ const ICONS = {
|
||||
star: StarIcon,
|
||||
themes: ThemesIcon,
|
||||
user: UserIcon,
|
||||
copy: CopyIcon,
|
||||
download: DownloadIcon,
|
||||
};
|
||||
|
||||
export type IconType = keyof typeof ICONS;
|
||||
@@ -61,7 +65,7 @@ type Props = {
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export const Icon: FunctionalComponent<Props> = ({ type, className }) => {
|
||||
export const Icon: FunctionalComponent<Props> = ({ type, className = '' }) => {
|
||||
const IconComponent = ICONS[type];
|
||||
return <IconComponent className={`sn-icon ${className}`} />;
|
||||
};
|
||||
|
||||
@@ -1,53 +1,38 @@
|
||||
import { FunctionComponent } from 'preact';
|
||||
import { Icon, IconType } from './Icon';
|
||||
|
||||
const ICON_BUTTON_TYPES: {
|
||||
[type: string]: { className: string };
|
||||
} = {
|
||||
normal: {
|
||||
className: '',
|
||||
},
|
||||
primary: {
|
||||
className: 'info',
|
||||
},
|
||||
};
|
||||
|
||||
export type IconButtonType = keyof typeof ICON_BUTTON_TYPES;
|
||||
|
||||
interface IconButtonProps {
|
||||
interface Props {
|
||||
/**
|
||||
* onClick - preventDefault is handled within the component
|
||||
*/
|
||||
onClick: () => void;
|
||||
|
||||
type: IconButtonType;
|
||||
|
||||
className?: string;
|
||||
|
||||
iconType: IconType;
|
||||
icon: IconType;
|
||||
}
|
||||
|
||||
/**
|
||||
* CircleButton component with an icon for SPA
|
||||
* IconButton component with an icon
|
||||
* preventDefault is already handled within the component
|
||||
*/
|
||||
export const IconButton: FunctionComponent<IconButtonProps> = ({
|
||||
export const IconButton: FunctionComponent<Props> = ({
|
||||
onClick,
|
||||
type,
|
||||
className,
|
||||
iconType,
|
||||
icon,
|
||||
}) => {
|
||||
const click = (e: MouseEvent) => {
|
||||
e.preventDefault();
|
||||
onClick();
|
||||
};
|
||||
const typeProps = ICON_BUTTON_TYPES[type];
|
||||
return (
|
||||
<button
|
||||
className={`sn-icon-button ${typeProps.className} ${className ?? ''}`}
|
||||
className={`no-border bg-transparent hover:brightness-130 p-0 ${
|
||||
className ?? ''
|
||||
}`}
|
||||
onClick={click}
|
||||
>
|
||||
<Icon type={iconType} />
|
||||
<Icon type={icon} />
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
22
app/assets/javascripts/components/Input.tsx
Normal file
22
app/assets/javascripts/components/Input.tsx
Normal file
@@ -0,0 +1,22 @@
|
||||
import { FunctionalComponent } from 'preact';
|
||||
|
||||
interface Props {
|
||||
text?: string;
|
||||
disabled?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export const Input: FunctionalComponent<Props> = ({
|
||||
className = '',
|
||||
disabled = false,
|
||||
text,
|
||||
}) => {
|
||||
const base = `rounded py-1.5 px-3 text-input my-1 h-8`;
|
||||
const stateClasses = disabled
|
||||
? 'no-border bg-grey-5'
|
||||
: 'border-solid border-1 border-gray-300';
|
||||
const classes = `${base} ${stateClasses} ${className}`;
|
||||
return (
|
||||
<input type="text" className={classes} disabled={disabled} value={text} />
|
||||
);
|
||||
};
|
||||
@@ -1,23 +0,0 @@
|
||||
import { Icon, IconType } from '@/components/Icon';
|
||||
import { FunctionComponent } from 'preact';
|
||||
|
||||
interface PreferencesMenuItemProps {
|
||||
iconType: IconType;
|
||||
label: string;
|
||||
selected: boolean;
|
||||
onClick: () => void;
|
||||
}
|
||||
|
||||
export const PreferencesMenuItem: FunctionComponent<PreferencesMenuItemProps> =
|
||||
({ iconType, label, selected, onClick }) => (
|
||||
<div
|
||||
className={`preferences-menu-item ${selected ? 'selected' : ''}`}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
onClick();
|
||||
}}
|
||||
>
|
||||
<Icon className="icon" type={iconType} />
|
||||
{label}
|
||||
</div>
|
||||
);
|
||||
42
app/assets/javascripts/components/RoundIconButton.tsx
Normal file
42
app/assets/javascripts/components/RoundIconButton.tsx
Normal file
@@ -0,0 +1,42 @@
|
||||
import { FunctionComponent } from 'preact';
|
||||
import { Icon, IconType } from './Icon';
|
||||
|
||||
type ButtonType = 'normal' | 'primary';
|
||||
|
||||
interface Props {
|
||||
/**
|
||||
* onClick - preventDefault is handled within the component
|
||||
*/
|
||||
onClick: () => void;
|
||||
|
||||
type: ButtonType;
|
||||
|
||||
className?: string;
|
||||
|
||||
icon: IconType;
|
||||
}
|
||||
|
||||
/**
|
||||
* IconButton component with an icon
|
||||
* preventDefault is already handled within the component
|
||||
*/
|
||||
export const RoundIconButton: FunctionComponent<Props> = ({
|
||||
onClick,
|
||||
type,
|
||||
className,
|
||||
icon: iconType,
|
||||
}) => {
|
||||
const click = (e: MouseEvent) => {
|
||||
e.preventDefault();
|
||||
onClick();
|
||||
};
|
||||
const classes = type === 'primary' ? 'info ' : '';
|
||||
return (
|
||||
<button
|
||||
className={`sn-icon-button ${classes} ${className ?? ''}`}
|
||||
onClick={click}
|
||||
>
|
||||
<Icon type={iconType} />
|
||||
</button>
|
||||
);
|
||||
};
|
||||
@@ -12,7 +12,7 @@ export type SwitchProps = HTMLProps<HTMLInputElement> & {
|
||||
checked?: boolean;
|
||||
onChange: (checked: boolean) => void;
|
||||
className?: string;
|
||||
children: ComponentChildren;
|
||||
children?: ComponentChildren;
|
||||
};
|
||||
|
||||
export const Switch: FunctionalComponent<SwitchProps> = (
|
||||
@@ -22,7 +22,9 @@ export const Switch: FunctionalComponent<SwitchProps> = (
|
||||
const checked = props.checked ?? checkedState;
|
||||
const className = props.className ?? '';
|
||||
return (
|
||||
<label className={`sn-component flex justify-between items-center cursor-pointer hover:bg-contrast px-3 ${className}`}>
|
||||
<label
|
||||
className={`sn-component flex justify-between items-center cursor-pointer hover:bg-contrast px-3 ${className}`}
|
||||
>
|
||||
{props.children}
|
||||
<CustomCheckboxContainer
|
||||
checked={checked}
|
||||
|
||||
@@ -1,22 +0,0 @@
|
||||
import { observer } from 'mobx-react-lite';
|
||||
import { FunctionComponent } from 'preact';
|
||||
|
||||
import { toDirective } from '../utils';
|
||||
import { PreferencesView } from './view';
|
||||
|
||||
interface WrapperProps {
|
||||
appState: { preferences: { isOpen: boolean; closePreferences: () => void } };
|
||||
}
|
||||
|
||||
const PreferencesViewWrapper: FunctionComponent<WrapperProps> = observer(
|
||||
({ appState }) => {
|
||||
if (!appState.preferences.isOpen) return null;
|
||||
return (
|
||||
<PreferencesView close={() => appState.preferences.closePreferences()} />
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
export const PreferencesDirective = toDirective<WrapperProps>(
|
||||
PreferencesViewWrapper
|
||||
);
|
||||
@@ -1,23 +0,0 @@
|
||||
import { observer } from 'mobx-react-lite';
|
||||
import { FunctionComponent } from 'preact';
|
||||
import { PreferencesMenuItem } from '../PreferencesMenuItem';
|
||||
import { Preferences } from './preferences';
|
||||
|
||||
interface PreferencesMenuProps {
|
||||
preferences: Preferences;
|
||||
}
|
||||
|
||||
export const PreferencesMenu: FunctionComponent<PreferencesMenuProps> =
|
||||
observer(({ preferences }) => (
|
||||
<div className="min-w-55 overflow-y-auto flex flex-col px-3 py-6">
|
||||
{preferences.items.map((pref) => (
|
||||
<PreferencesMenuItem
|
||||
key={pref.id}
|
||||
iconType={pref.icon}
|
||||
label={pref.label}
|
||||
selected={pref.selected}
|
||||
onClick={() => preferences.selectItem(pref.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
));
|
||||
@@ -1,33 +0,0 @@
|
||||
import { FunctionalComponent } from 'preact';
|
||||
|
||||
const HorizontalLine: FunctionalComponent<{ index: number; length: number }> =
|
||||
({ index, length }) =>
|
||||
index < length - 1 ? (
|
||||
<hr className="h-1px w-full bg-border no-border" />
|
||||
) : null;
|
||||
|
||||
export const PreferencesSegment: FunctionalComponent = ({ children }) => (
|
||||
<div>{children}</div>
|
||||
);
|
||||
|
||||
export const PreferencesGroup: FunctionalComponent = ({ children }) => (
|
||||
<div className="bg-default border-1 border-solid rounded border-gray-300 px-6 py-6 flex flex-col gap-2">
|
||||
{!Array.isArray(children)
|
||||
? children
|
||||
: children.map((c, i, arr) => (
|
||||
<>
|
||||
{c}
|
||||
<HorizontalLine index={i} length={arr.length} />
|
||||
</>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
|
||||
export const PreferencesPane: FunctionalComponent = ({ children }) => (
|
||||
<div className="preferences-pane flex-grow flex flex-row overflow-y-auto min-h-0">
|
||||
<div className="flex-grow flex flex-col py-6 items-center">
|
||||
<div className="max-w-124 flex flex-col gap-3">{children}</div>
|
||||
</div>
|
||||
<div className="flex-basis-55 flex-shrink-max" />
|
||||
</div>
|
||||
);
|
||||
@@ -1,55 +0,0 @@
|
||||
import { IconType } from '@/components/Icon';
|
||||
import { action, computed, makeObservable, observable } from 'mobx';
|
||||
|
||||
interface PreferenceItem {
|
||||
icon: IconType;
|
||||
label: string;
|
||||
}
|
||||
|
||||
interface PreferenceListItem extends PreferenceItem {
|
||||
id: number;
|
||||
}
|
||||
|
||||
const predefinedItems: PreferenceItem[] = [
|
||||
{ label: 'General', icon: 'settings' },
|
||||
{ label: 'Account', icon: 'user' },
|
||||
{ label: 'Appearance', icon: 'themes' },
|
||||
{ label: 'Security', icon: 'security' },
|
||||
{ label: 'Listed', icon: 'listed' },
|
||||
{ label: 'Shortcuts', icon: 'keyboard' },
|
||||
{ label: 'Accessibility', icon: 'accessibility' },
|
||||
{ label: 'Get a free month', icon: 'star' },
|
||||
{ label: 'Help & feedback', icon: 'help' },
|
||||
];
|
||||
|
||||
export class Preferences {
|
||||
private readonly _items: PreferenceListItem[];
|
||||
private _selectedId = 0;
|
||||
|
||||
constructor(items: PreferenceItem[] = predefinedItems) {
|
||||
makeObservable<Preferences, '_selectedId'>(this, {
|
||||
_selectedId: observable,
|
||||
selectedItem: computed,
|
||||
items: computed,
|
||||
selectItem: action,
|
||||
});
|
||||
|
||||
this._items = items.map((p, idx) => ({ ...p, id: idx }));
|
||||
this._selectedId = this._items[0].id;
|
||||
}
|
||||
|
||||
selectItem(id: number) {
|
||||
this._selectedId = id;
|
||||
}
|
||||
|
||||
get items(): (PreferenceListItem & { selected: boolean })[] {
|
||||
return this._items.map((p) => ({
|
||||
...p,
|
||||
selected: p.id === this._selectedId,
|
||||
}));
|
||||
}
|
||||
|
||||
get selectedItem(): PreferenceListItem {
|
||||
return this._items.find((item) => item.id === this._selectedId)!;
|
||||
}
|
||||
}
|
||||
@@ -1,45 +0,0 @@
|
||||
import { IconButton } from '@/components/IconButton';
|
||||
import { TitleBar, Title } from '@/components/TitleBar';
|
||||
import { FunctionComponent } from 'preact';
|
||||
import { Preferences } from './preferences';
|
||||
import { PreferencesMenu } from './menu';
|
||||
import { HelpAndFeedback } from './help-feedback';
|
||||
import { observer } from 'mobx-react-lite';
|
||||
|
||||
interface PreferencesViewProps {
|
||||
close: () => void;
|
||||
}
|
||||
|
||||
export const PreferencesCanvas: FunctionComponent<{
|
||||
preferences: Preferences;
|
||||
}> = observer(({ preferences: prefs }) => (
|
||||
<div className="flex flex-row flex-grow min-h-0 justify-between">
|
||||
<PreferencesMenu preferences={prefs}></PreferencesMenu>
|
||||
{/* Temporary selector until a full solution is implemented */}
|
||||
{prefs.selectedItem.label === 'Help & feedback' ? (
|
||||
<HelpAndFeedback />
|
||||
) : null}
|
||||
</div>
|
||||
));
|
||||
|
||||
export const PreferencesView: FunctionComponent<PreferencesViewProps> =
|
||||
observer(({ close }) => {
|
||||
const prefs = new Preferences();
|
||||
return (
|
||||
<div className="sn-full-screen flex flex-col bg-contrast z-index-preferences">
|
||||
<TitleBar className="items-center justify-between">
|
||||
{/* div is added so flex justify-between can center the title */}
|
||||
<div className="h-8 w-8" />
|
||||
<Title className="text-lg">Your preferences for Standard Notes</Title>
|
||||
<IconButton
|
||||
onClick={() => {
|
||||
close();
|
||||
}}
|
||||
type="normal"
|
||||
iconType="close"
|
||||
/>
|
||||
</TitleBar>
|
||||
<PreferencesCanvas preferences={prefs} />
|
||||
</div>
|
||||
);
|
||||
});
|
||||
19
app/assets/javascripts/preferences/PreferencesMenu.tsx
Normal file
19
app/assets/javascripts/preferences/PreferencesMenu.tsx
Normal file
@@ -0,0 +1,19 @@
|
||||
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>
|
||||
));
|
||||
81
app/assets/javascripts/preferences/PreferencesView.tsx
Normal file
81
app/assets/javascripts/preferences/PreferencesView.tsx
Normal file
@@ -0,0 +1,81 @@
|
||||
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 { observer } from 'mobx-react-lite';
|
||||
import { Security } from './panes/Security';
|
||||
|
||||
interface PreferencesViewProps {
|
||||
close: () => void;
|
||||
}
|
||||
|
||||
const PaneSelector: FunctionComponent<{
|
||||
prefs: Preferences;
|
||||
}> = observer(({ prefs }) => {
|
||||
switch (prefs.selectedPaneId) {
|
||||
case 'general':
|
||||
return null;
|
||||
case 'account':
|
||||
return null;
|
||||
case 'appearance':
|
||||
return null;
|
||||
case 'security':
|
||||
return <Security prefs={prefs} />;
|
||||
case 'listed':
|
||||
return null;
|
||||
case 'shortcuts':
|
||||
return null;
|
||||
case 'accessibility':
|
||||
return null;
|
||||
case 'get-free-month':
|
||||
return null;
|
||||
case 'help-feedback':
|
||||
return <HelpAndFeedback />;
|
||||
}
|
||||
});
|
||||
|
||||
const PreferencesCanvas: FunctionComponent<{
|
||||
preferences: Preferences;
|
||||
}> = observer(({ preferences: prefs }) => (
|
||||
<div className="flex flex-row flex-grow min-h-0 justify-between">
|
||||
<PreferencesMenu preferences={prefs}></PreferencesMenu>
|
||||
<PaneSelector prefs={prefs} />
|
||||
</div>
|
||||
));
|
||||
|
||||
const PreferencesView: FunctionComponent<PreferencesViewProps> = observer(
|
||||
({ close }) => {
|
||||
const prefs = new Preferences();
|
||||
return (
|
||||
<div className="sn-full-screen flex flex-col bg-contrast z-index-preferences">
|
||||
<TitleBar className="items-center justify-between">
|
||||
{/* div is added so flex justify-between can center the title */}
|
||||
<div className="h-8 w-8" />
|
||||
<Title className="text-lg">Your preferences for Standard Notes</Title>
|
||||
<RoundIconButton
|
||||
onClick={() => {
|
||||
close();
|
||||
}}
|
||||
type="normal"
|
||||
icon="close"
|
||||
/>
|
||||
</TitleBar>
|
||||
<PreferencesCanvas preferences={prefs} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
export interface PreferencesWrapperProps {
|
||||
appState: { preferences: { isOpen: boolean; closePreferences: () => void } };
|
||||
}
|
||||
|
||||
export const PreferencesViewWrapper: FunctionComponent<PreferencesWrapperProps> =
|
||||
observer(({ appState }) => {
|
||||
if (!appState.preferences.isOpen) return null;
|
||||
return (
|
||||
<PreferencesView close={() => appState.preferences.closePreferences()} />
|
||||
);
|
||||
});
|
||||
@@ -1,18 +1,18 @@
|
||||
import { FunctionalComponent } from 'preact';
|
||||
import { FunctionComponent } from 'preact';
|
||||
|
||||
export const Title: FunctionalComponent = ({ children }) => (
|
||||
<h2 className="text-base m-0 mb-3">{children}</h2>
|
||||
export const Title: FunctionComponent = ({ children }) => (
|
||||
<h2 className="text-base m-0 mb-1">{children}</h2>
|
||||
);
|
||||
|
||||
export const Subtitle: FunctionalComponent = ({ children }) => (
|
||||
export const Subtitle: FunctionComponent = ({ children }) => (
|
||||
<h4 className="font-medium text-sm m-0 mb-1">{children}</h4>
|
||||
);
|
||||
|
||||
export const Text: FunctionalComponent = ({ children }) => (
|
||||
export const Text: FunctionComponent = ({ children }) => (
|
||||
<p className="text-xs">{children}</p>
|
||||
);
|
||||
|
||||
export const Button: FunctionalComponent<{ label: string; link: string }> = ({
|
||||
export const Button: FunctionComponent<{ label: string; link: string }> = ({
|
||||
label,
|
||||
link,
|
||||
}) => (
|
||||
27
app/assets/javascripts/preferences/components/MenuItem.tsx
Normal file
27
app/assets/javascripts/preferences/components/MenuItem.tsx
Normal file
@@ -0,0 +1,27 @@
|
||||
import { Icon, IconType } from '@/components/Icon';
|
||||
import { FunctionComponent } from 'preact';
|
||||
|
||||
interface Props {
|
||||
iconType: IconType;
|
||||
label: string;
|
||||
selected: boolean;
|
||||
onClick: () => void;
|
||||
}
|
||||
|
||||
export const MenuItem: FunctionComponent<Props> = ({
|
||||
iconType,
|
||||
label,
|
||||
selected,
|
||||
onClick,
|
||||
}) => (
|
||||
<div
|
||||
className={`preferences-menu-item ${selected ? 'selected' : ''}`}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
onClick();
|
||||
}}
|
||||
>
|
||||
<Icon className="icon" type={iconType} />
|
||||
{label}
|
||||
</div>
|
||||
);
|
||||
35
app/assets/javascripts/preferences/components/Pane.tsx
Normal file
35
app/assets/javascripts/preferences/components/Pane.tsx
Normal file
@@ -0,0 +1,35 @@
|
||||
import { FunctionComponent } from 'preact';
|
||||
|
||||
const HorizontalLine: FunctionComponent<{ index: number; length: number }> = ({
|
||||
index,
|
||||
length,
|
||||
}) =>
|
||||
index < length - 1 ? (
|
||||
<hr className="h-1px w-full bg-border no-border" />
|
||||
) : null;
|
||||
|
||||
export const PreferencesSegment: FunctionComponent = ({ children }) => (
|
||||
<div className="flex flex-col">{children}</div>
|
||||
);
|
||||
|
||||
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">
|
||||
{!Array.isArray(children)
|
||||
? children
|
||||
: children.map((c, i, arr) => (
|
||||
<>
|
||||
{c}
|
||||
<HorizontalLine index={i} length={arr.length} />
|
||||
</>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
|
||||
export const PreferencesPane: FunctionComponent = ({ children }) => (
|
||||
<div className="preferences-pane flex-grow flex flex-row overflow-y-auto min-h-0">
|
||||
<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>
|
||||
<div className="flex-basis-55 flex-shrink" />
|
||||
</div>
|
||||
);
|
||||
3
app/assets/javascripts/preferences/components/index.ts
Normal file
3
app/assets/javascripts/preferences/components/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export * from './Content';
|
||||
export * from './MenuItem';
|
||||
export * from './Pane';
|
||||
9
app/assets/javascripts/preferences/index.ts
Normal file
9
app/assets/javascripts/preferences/index.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { toDirective } from '../components/utils';
|
||||
import {
|
||||
PreferencesViewWrapper,
|
||||
PreferencesWrapperProps,
|
||||
} from './PreferencesView';
|
||||
|
||||
export const PreferencesDirective = toDirective<PreferencesWrapperProps>(
|
||||
PreferencesViewWrapper
|
||||
);
|
||||
2
app/assets/javascripts/preferences/models/index.ts
Normal file
2
app/assets/javascripts/preferences/models/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from './preferences';
|
||||
export * from './two-factor-auth';
|
||||
76
app/assets/javascripts/preferences/models/preferences.ts
Normal file
76
app/assets/javascripts/preferences/models/preferences.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
import { IconType } from '@/components/Icon';
|
||||
import { makeAutoObservable, observable } from 'mobx';
|
||||
import { TwoFactorAuth } from './two-factor-auth';
|
||||
|
||||
const PREFERENCE_IDS = [
|
||||
'general',
|
||||
'account',
|
||||
'appearance',
|
||||
'security',
|
||||
'listed',
|
||||
'shortcuts',
|
||||
'accessibility',
|
||||
'get-free-month',
|
||||
'help-feedback',
|
||||
] as const;
|
||||
|
||||
export type PreferenceId = typeof PREFERENCE_IDS[number];
|
||||
interface PreferenceMenuItem {
|
||||
readonly id: PreferenceId;
|
||||
readonly icon: IconType;
|
||||
readonly label: string;
|
||||
}
|
||||
|
||||
type PreferencesMenu = PreferenceMenuItem[];
|
||||
|
||||
/**
|
||||
* Items are in order of appearance
|
||||
*/
|
||||
const PREFERENCES_MENU: PreferencesMenu = [
|
||||
{ id: 'general', label: 'General', icon: 'settings' },
|
||||
{ id: 'account', label: 'Account', icon: 'user' },
|
||||
{ id: 'appearance', label: 'Appearance', icon: 'themes' },
|
||||
{ id: 'security', label: 'Security', icon: 'security' },
|
||||
{ id: 'listed', label: 'Listed', icon: 'listed' },
|
||||
{ id: 'shortcuts', label: 'Shortcuts', icon: 'keyboard' },
|
||||
{ id: 'accessibility', label: 'Accessibility', icon: 'accessibility' },
|
||||
{ id: 'get-free-month', label: 'Get a free month', icon: 'star' },
|
||||
{ id: 'help-feedback', label: 'Help & feedback', icon: 'help' },
|
||||
];
|
||||
|
||||
export class Preferences {
|
||||
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,
|
||||
});
|
||||
}
|
||||
|
||||
get menuItems(): (PreferenceMenuItem & {
|
||||
selected: boolean;
|
||||
})[] {
|
||||
return this._menu.map((p) => ({
|
||||
...p,
|
||||
selected: p.id === this._selectedPane,
|
||||
}));
|
||||
}
|
||||
|
||||
get selectedPaneId(): PreferenceId {
|
||||
return (
|
||||
this._menu.find((item) => item.id === this._selectedPane)?.id ?? 'general'
|
||||
);
|
||||
}
|
||||
|
||||
selectPane(key: PreferenceId) {
|
||||
this._selectedPane = key;
|
||||
}
|
||||
|
||||
get twoFactorAuth() {
|
||||
return this._twoFactorAuth;
|
||||
}
|
||||
}
|
||||
81
app/assets/javascripts/preferences/models/two-factor-auth.ts
Normal file
81
app/assets/javascripts/preferences/models/two-factor-auth.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
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,12 +1,21 @@
|
||||
import { FunctionalComponent } from 'preact';
|
||||
import { PreferencesGroup, PreferencesPane, PreferencesSegment } from './pane';
|
||||
import { Title, Subtitle, Text, Button } from './content';
|
||||
import { FunctionComponent } from 'preact';
|
||||
import {} from '../components';
|
||||
import {
|
||||
Title,
|
||||
Subtitle,
|
||||
Text,
|
||||
Button,
|
||||
PreferencesGroup,
|
||||
PreferencesPane,
|
||||
PreferencesSegment,
|
||||
} from '../components';
|
||||
|
||||
export const HelpAndFeedback: FunctionalComponent = () => (
|
||||
export const HelpAndFeedback: FunctionComponent = () => (
|
||||
<PreferencesPane>
|
||||
<PreferencesGroup>
|
||||
<PreferencesSegment>
|
||||
<Title>Frequently asked questions</Title>
|
||||
<div className="h-2 w-full" />
|
||||
<Subtitle>Who can read my private notes?</Subtitle>
|
||||
<Text>
|
||||
Quite simply: no one but you. Not us, not your ISP, not a hacker, and
|
||||
13
app/assets/javascripts/preferences/panes/Security.tsx
Normal file
13
app/assets/javascripts/preferences/panes/Security.tsx
Normal file
@@ -0,0 +1,13 @@
|
||||
import { observer } from 'mobx-react-lite';
|
||||
import { FunctionComponent } from 'preact';
|
||||
import { PreferencesPane } from '../components';
|
||||
import { Preferences } from '../models';
|
||||
import { TwoFactorAuthComponent } from './TwoFactorAuth';
|
||||
|
||||
export const Security: FunctionComponent<{ prefs: Preferences }> = observer(
|
||||
({ prefs }) => (
|
||||
<PreferencesPane>
|
||||
<TwoFactorAuthComponent tfAuth={prefs.twoFactorAuth} />
|
||||
</PreferencesPane>
|
||||
)
|
||||
);
|
||||
110
app/assets/javascripts/preferences/panes/TwoFactorAuth.tsx
Normal file
110
app/assets/javascripts/preferences/panes/TwoFactorAuth.tsx
Normal file
@@ -0,0 +1,110 @@
|
||||
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>
|
||||
);
|
||||
2
app/assets/javascripts/preferences/panes/index.ts
Normal file
2
app/assets/javascripts/preferences/panes/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from './HelpFeedback';
|
||||
export * from './Security';
|
||||
@@ -56,7 +56,8 @@ export function StringEmptyTrash(count: number) {
|
||||
/** @account */
|
||||
export const STRING_ACCOUNT_MENU_UNCHECK_MERGE =
|
||||
'Unchecking this option means any of the notes you have written while you were signed out will be deleted. Are you sure you want to discard these notes?';
|
||||
export const STRING_SIGN_OUT_CONFIRMATION = 'This will delete all local items and extensions.';
|
||||
export const STRING_SIGN_OUT_CONFIRMATION =
|
||||
'This will delete all local items and extensions.';
|
||||
export const STRING_ERROR_DECRYPTING_IMPORT =
|
||||
'There was an error decrypting your items. Make sure the password you entered is correct and try again.';
|
||||
export const STRING_E2E_ENABLED =
|
||||
@@ -111,7 +112,8 @@ export const STRING_UPGRADE_ACCOUNT_CONFIRM_TEXT =
|
||||
export const STRING_UPGRADE_ACCOUNT_CONFIRM_BUTTON = 'Upgrade';
|
||||
|
||||
export const Strings = {
|
||||
protectingNoteWithoutProtectionSources: 'Access to this note will not be restricted until you set up a passcode or account.',
|
||||
protectingNoteWithoutProtectionSources:
|
||||
'Access to this note will not be restricted until you set up a passcode or account.',
|
||||
openAccountMenu: 'Open Account Menu',
|
||||
trashNotesTitle: 'Move to Trash',
|
||||
trashNotesText: 'Are you sure you want to move these notes to the trash?',
|
||||
@@ -157,4 +159,4 @@ export const StringUtils = {
|
||||
? "This note has editing disabled. If you'd like to delete it, enable editing, and try again."
|
||||
: "One or more of these notes have editing disabled. If you'd like to delete them, make sure editing is enabled on all of them, and try again.";
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user