feat: two factor authentication segment in preferences with mocked state (#600)
This commit is contained in:
3
app/assets/icons/ic-copy.svg
Normal file
3
app/assets/icons/ic-copy.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="M1.66724 3.66626C1.66724 2.56169 2.56267 1.66626 3.66724 1.66626H11.3339C12.4385 1.66626 13.3339 2.56169 13.3339 3.66626V13.3329H3.66724C2.56267 13.3329 1.66724 12.4375 1.66724 11.3329V3.66626ZM16.3339 6.66626C17.4385 6.66626 18.3339 7.56169 18.3339 8.66626V16.3329C18.3339 17.4375 17.4385 18.3329 16.3339 18.3329H8.66724C7.56267 18.3329 6.66724 17.4375 6.66724 16.3329V14.9996H15.0006V6.66626H16.3339ZM3.3339 3.33293V11.6663H11.6672V3.33293H3.3339Z" fill="#72767E"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 580 B |
3
app/assets/icons/ic-download.svg
Normal file
3
app/assets/icons/ic-download.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="M11 3.5V9.5H12.17L10 11.67L7.83 9.5H9V3.5H11ZM13 1.5H7V7.5H3L10 14.5L17 7.5H13V1.5ZM17 16.5H3V18.5H17V16.5Z" fill="#72767E"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 238 B |
@@ -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.";
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
@@ -55,7 +55,7 @@
|
||||
"pug-loader": "^2.4.0",
|
||||
"sass-loader": "^8.0.2",
|
||||
"serve-static": "^1.14.1",
|
||||
"sn-stylekit": "5.2.5",
|
||||
"sn-stylekit": "5.2.6",
|
||||
"ts-loader": "^8.0.17",
|
||||
"typescript": "4.2.3",
|
||||
"typescript-eslint": "0.0.1-alpha.0",
|
||||
|
||||
@@ -7908,10 +7908,10 @@ slice-ansi@^4.0.0:
|
||||
astral-regex "^2.0.0"
|
||||
is-fullwidth-code-point "^3.0.0"
|
||||
|
||||
sn-stylekit@5.2.5:
|
||||
version "5.2.5"
|
||||
resolved "https://registry.yarnpkg.com/sn-stylekit/-/sn-stylekit-5.2.5.tgz#85a28da395fedbaae9f7a91c48648042cdcc8052"
|
||||
integrity sha512-8J+8UtRvukyJOBp79RcD4IZrvJJbjYY6EdN4N125K0xW84nDjgURuPuCjwm4lnp6vcXODU6r5d3JMDJoXYq8wA==
|
||||
sn-stylekit@5.2.6:
|
||||
version "5.2.6"
|
||||
resolved "https://registry.yarnpkg.com/sn-stylekit/-/sn-stylekit-5.2.6.tgz#3c63d4a6b20bb002b6add685e28abce4663475d2"
|
||||
integrity sha512-jZI+D/evLwcJjNbxBueoczjEOjJZL61eGd4cluC0lpPYvkMwHGYj/gWi09KN1E16K++FQRNVvZ3/mMZjSfam2g==
|
||||
dependencies:
|
||||
"@reach/listbox" "^0.15.0"
|
||||
"@reach/menu-button" "^0.15.1"
|
||||
|
||||
Reference in New Issue
Block a user