chore: move all components into Components dir with pascal case (#934)

This commit is contained in:
Mo
2022-03-17 11:38:45 -05:00
committed by GitHub
parent 42b84ef9b1
commit c29e45795d
89 changed files with 370 additions and 259 deletions

View File

@@ -2,14 +2,14 @@ import { WebApplication } from '@/ui_models/application';
import { AppState } from '@/ui_models/app_state';
import { observer } from 'mobx-react-lite';
import { Icon } from '../Icon';
import { formatLastSyncDate } from '@/preferences/panes/account/Sync';
import { formatLastSyncDate } from '@/components/Preferences/panes/account/Sync';
import { SyncQueueStrategy } from '@standardnotes/snjs';
import { STRING_GENERIC_SYNC_ERROR } from '@/strings';
import { useState } from 'preact/hooks';
import { AccountMenuPane } from '.';
import { FunctionComponent } from 'preact';
import { Menu } from '../menu/Menu';
import { MenuItem, MenuItemSeparator, MenuItemType } from '../menu/MenuItem';
import { Menu } from '../Menu/Menu';
import { MenuItem, MenuItemSeparator, MenuItemType } from '../Menu/MenuItem';
type Props = {
appState: AppState;

View File

@@ -16,10 +16,10 @@ import { NotesView } from '@/components/NotesView';
import { NoteGroupView } from '@/components/NoteGroupView';
import { Footer } from '@/components/Footer';
import { SessionsModal } from '@/components/SessionsModal';
import { PreferencesViewWrapper } from '@/preferences/PreferencesViewWrapper';
import { PreferencesViewWrapper } from '@/components/Preferences/PreferencesViewWrapper';
import { ChallengeModal } from '@/components/ChallengeModal';
import { NotesContextMenu } from '@/components/NotesContextMenu';
import { PurchaseFlowWrapper } from '@/purchaseFlow/PurchaseFlowWrapper';
import { PurchaseFlowWrapper } from '@/components/PurchaseFlow/PurchaseFlowWrapper';
import { render } from 'preact';
import { PermissionsModal } from './PermissionsModal';
import { RevisionHistoryModalWrapper } from './RevisionHistoryModal/RevisionHistoryModalWrapper';

View File

@@ -4,8 +4,8 @@ import { observer } from 'mobx-react-lite';
import { FunctionComponent } from 'preact';
import { useRef, useState } from 'preact/hooks';
import { Icon } from './Icon';
import { Menu } from './menu/Menu';
import { MenuItem, MenuItemSeparator, MenuItemType } from './menu/MenuItem';
import { Menu } from './Menu/Menu';
import { MenuItem, MenuItemSeparator, MenuItemType } from './Menu/MenuItem';
type Props = {
application: WebApplication;

View File

@@ -1,6 +1,6 @@
import { Icon } from '@/components/Icon';
import { Menu } from '@/components/menu/Menu';
import { MenuItem, MenuItemType } from '@/components/menu/MenuItem';
import { Menu } from '@/components/Menu/Menu';
import { MenuItem, MenuItemType } from '@/components/Menu/MenuItem';
import {
reloadFont,
transactionForAssociateComponentWithCurrentNote,

View File

@@ -0,0 +1,172 @@
import { action, makeAutoObservable, observable } from 'mobx';
import { ExtensionsLatestVersions } from '@/components/Preferences/panes/extensions-segments';
import {
ComponentArea,
ContentType,
FeatureIdentifier,
SNComponent,
IconType,
} from '@standardnotes/snjs';
import { WebApplication } from '@/ui_models/application';
const PREFERENCE_IDS = [
'general',
'account',
'security',
'appearance',
'backups',
'listed',
'shortcuts',
'accessibility',
'get-free-month',
'help-feedback',
] as const;
export type PreferenceId = typeof PREFERENCE_IDS[number];
interface PreferencesMenuItem {
readonly id: PreferenceId | FeatureIdentifier;
readonly icon: IconType;
readonly label: string;
}
interface SelectableMenuItem extends PreferencesMenuItem {
selected: boolean;
}
/**
* Items are in order of appearance
*/
const PREFERENCES_MENU_ITEMS: PreferencesMenuItem[] = [
{ id: 'account', label: 'Account', icon: 'user' },
{ id: 'general', label: 'General', icon: 'settings' },
{ id: 'security', label: 'Security', icon: 'security' },
{ id: 'appearance', label: 'Appearance', icon: 'themes' },
{ id: 'backups', label: 'Backups', icon: 'restore' },
{ 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' },
];
const READY_PREFERENCES_MENU_ITEMS: PreferencesMenuItem[] = [
{ id: 'account', label: 'Account', icon: 'user' },
{ id: 'general', label: 'General', icon: 'settings' },
{ id: 'security', label: 'Security', icon: 'security' },
{ id: 'appearance', label: 'Appearance', icon: 'themes' },
{ id: 'backups', label: 'Backups', icon: 'restore' },
{ id: 'listed', label: 'Listed', icon: 'listed' },
{ id: 'help-feedback', label: 'Help & feedback', icon: 'help' },
];
export class PreferencesMenu {
private _selectedPane: PreferenceId | FeatureIdentifier = 'account';
private _extensionPanes: SNComponent[] = [];
private _menu: PreferencesMenuItem[];
private _extensionLatestVersions: ExtensionsLatestVersions =
new ExtensionsLatestVersions(new Map());
constructor(
private application: WebApplication,
private readonly _enableUnfinishedFeatures: boolean
) {
this._menu = this._enableUnfinishedFeatures
? PREFERENCES_MENU_ITEMS
: READY_PREFERENCES_MENU_ITEMS;
this.loadExtensionsPanes();
this.loadLatestVersions();
makeAutoObservable<
PreferencesMenu,
| '_selectedPane'
| '_twoFactorAuth'
| '_extensionPanes'
| '_extensionLatestVersions'
| 'loadLatestVersions'
>(this, {
_twoFactorAuth: observable,
_selectedPane: observable,
_extensionPanes: observable.ref,
_extensionLatestVersions: observable.ref,
loadLatestVersions: action,
});
}
private loadLatestVersions(): void {
ExtensionsLatestVersions.load(this.application).then((versions) => {
if (versions) {
this._extensionLatestVersions = versions;
}
});
}
get extensionsLatestVersions(): ExtensionsLatestVersions {
return this._extensionLatestVersions;
}
loadExtensionsPanes(): void {
const excludedComponents = [
FeatureIdentifier.TwoFactorAuthManager,
'org.standardnotes.batch-manager',
'org.standardnotes.extensions-manager',
FeatureIdentifier.CloudLink,
];
this._extensionPanes = (
this.application.getItems([
ContentType.ActionsExtension,
ContentType.Component,
ContentType.Theme,
]) as SNComponent[]
).filter(
(extension) =>
extension.area === ComponentArea.Modal &&
!excludedComponents.includes(extension.package_info.identifier)
);
}
get menuItems(): SelectableMenuItem[] {
const menuItems = this._menu.map((preference) => ({
...preference,
selected: preference.id === this._selectedPane,
}));
const extensionsMenuItems: SelectableMenuItem[] = this._extensionPanes.map(
(extension) => {
return {
icon: 'window',
id: extension.package_info.identifier,
label: extension.name,
selected: extension.package_info.identifier === this._selectedPane,
};
}
);
return menuItems.concat(extensionsMenuItems);
}
get selectedMenuItem(): PreferencesMenuItem | undefined {
return this._menu.find((item) => item.id === this._selectedPane);
}
get selectedExtension(): SNComponent | undefined {
return this._extensionPanes.find(
(extension) => extension.package_info.identifier === this._selectedPane
);
}
get selectedPaneId(): PreferenceId | FeatureIdentifier {
if (this.selectedMenuItem != undefined) {
return this.selectedMenuItem.id;
}
if (this.selectedExtension != undefined) {
return this.selectedExtension.package_info.identifier;
}
return 'account';
}
selectPane(key: PreferenceId | FeatureIdentifier): void {
this._selectedPane = key;
}
}

View File

@@ -0,0 +1,20 @@
import { observer } from 'mobx-react-lite';
import { FunctionComponent } from 'preact';
import { MenuItem } from './components';
import { PreferencesMenu } from './PreferencesMenu';
export const PreferencesMenuView: FunctionComponent<{
menu: PreferencesMenu;
}> = observer(({ menu }) => (
<div className="min-w-55 overflow-y-auto flex flex-col px-3 py-6">
{menu.menuItems.map((pref) => (
<MenuItem
key={pref.id}
iconType={pref.icon}
label={pref.label}
selected={pref.selected}
onClick={() => menu.selectPane(pref.id)}
/>
))}
</div>
));

View File

@@ -0,0 +1,142 @@
import { RoundIconButton } from '@/components/RoundIconButton';
import { TitleBar, Title } from '@/components/TitleBar';
import { FunctionComponent } from 'preact';
import {
AccountPreferences,
HelpAndFeedback,
Listed,
General,
Security,
} from './panes';
import { observer } from 'mobx-react-lite';
import { PreferencesMenu } from './PreferencesMenu';
import { PreferencesMenuView } from './PreferencesMenuView';
import { WebApplication } from '@/ui_models/application';
import { MfaProps } from './panes/two-factor-auth/MfaProps';
import { AppState } from '@/ui_models/app_state';
import { useEffect, useMemo } from 'preact/hooks';
import { ExtensionPane } from './panes/ExtensionPane';
import { Backups } from '@/components/Preferences/panes/Backups';
import { Appearance } from './panes/Appearance';
interface PreferencesProps extends MfaProps {
application: WebApplication;
appState: AppState;
closePreferences: () => void;
}
const PaneSelector: FunctionComponent<
PreferencesProps & { menu: PreferencesMenu }
> = observer(({ menu, appState, application, mfaProvider, userProvider }) => {
switch (menu.selectedPaneId) {
case 'general':
return (
<General
appState={appState}
application={application}
extensionsLatestVersions={menu.extensionsLatestVersions}
/>
);
case 'account':
return (
<AccountPreferences application={application} appState={appState} />
);
case 'appearance':
return <Appearance application={application} />;
case 'security':
return (
<Security
mfaProvider={mfaProvider}
userProvider={userProvider}
appState={appState}
application={application}
/>
);
case 'backups':
return <Backups application={application} appState={appState} />;
case 'listed':
return <Listed application={application} />;
case 'shortcuts':
return null;
case 'accessibility':
return null;
case 'get-free-month':
return null;
case 'help-feedback':
return <HelpAndFeedback />;
default:
if (menu.selectedExtension != undefined) {
return (
<ExtensionPane
application={application}
appState={appState}
extension={menu.selectedExtension}
preferencesMenu={menu}
/>
);
} else {
return (
<General
appState={appState}
application={application}
extensionsLatestVersions={menu.extensionsLatestVersions}
/>
);
}
}
});
const PreferencesCanvas: FunctionComponent<
PreferencesProps & { menu: PreferencesMenu }
> = observer((props) => (
<div className="flex flex-row flex-grow min-h-0 justify-between">
<PreferencesMenuView menu={props.menu} />
<PaneSelector {...props} />
</div>
));
export const PreferencesView: FunctionComponent<PreferencesProps> = observer(
(props) => {
const menu = useMemo(
() =>
new PreferencesMenu(
props.application,
props.appState.enableUnfinishedFeatures
),
[props.appState.enableUnfinishedFeatures, props.application]
);
useEffect(() => {
menu.selectPane(props.appState.preferences.currentPane);
const removeEscKeyObserver = props.application.io.addKeyObserver({
key: 'Escape',
onKeyDown: (event) => {
event.preventDefault();
props.closePreferences();
},
});
return () => {
removeEscKeyObserver();
};
}, [props, menu]);
return (
<div className="h-full w-full absolute top-left-0 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={() => {
props.closePreferences();
}}
type="normal"
icon="close"
/>
</TitleBar>
<PreferencesCanvas {...props} menu={menu} />
</div>
);
}
);

View File

@@ -0,0 +1,27 @@
import { FunctionComponent } from 'preact';
import { observer } from 'mobx-react-lite';
import { WebApplication } from '@/ui_models/application';
import { PreferencesView } from './PreferencesView';
import { AppState } from '@/ui_models/app_state';
export interface PreferencesViewWrapperProps {
appState: AppState;
application: WebApplication;
}
export const PreferencesViewWrapper: FunctionComponent<PreferencesViewWrapperProps> =
observer(({ appState, application }) => {
if (!appState.preferences.isOpen) {
return null;
}
return (
<PreferencesView
closePreferences={() => appState.preferences.closePreferences()}
application={application}
appState={appState}
mfaProvider={application}
userProvider={application}
/>
);
});

View File

@@ -0,0 +1,41 @@
import { FunctionComponent } from 'preact';
export const Title: FunctionComponent = ({ children }) => (
<>
<h2 className="text-base m-0 mb-1">{children}</h2>
<div className="min-h-2" />
</>
);
export const Subtitle: FunctionComponent<{ className?: string }> = ({
children,
className = '',
}) => (
<h4 className={`font-medium text-sm m-0 mb-1 ${className}`}>{children}</h4>
);
export const SubtitleLight: FunctionComponent<{ className?: string }> = ({
children,
className = '',
}) => (
<h4 className={`font-normal text-sm m-0 mb-1 ${className}`}>{children}</h4>
);
export const Text: FunctionComponent<{ className?: string }> = ({
children,
className = '',
}) => <p className={`${className} text-xs`}>{children}</p>;
const buttonClasses = `block bg-default color-text rounded border-solid \
border-1 px-4 py-1.75 font-bold text-sm fit-content \
focus:bg-contrast hover:bg-contrast border-main`;
export const LinkButton: FunctionComponent<{
label: string;
link: string;
className?: string;
}> = ({ label, link, className }) => (
<a target="_blank" className={`${className} ${buttonClasses}`} href={link}>
{label}
</a>
);

View File

@@ -0,0 +1,31 @@
import { Icon } from '@/components/Icon';
import { FunctionComponent } from 'preact';
import { IconType } from '@standardnotes/snjs';
interface Props {
iconType: IconType;
label: string;
selected: boolean;
onClick: () => void;
}
export const MenuItem: FunctionComponent<Props> = ({
iconType,
label,
selected,
onClick,
}) => (
<div
className={`preferences-menu-item select-none ${
selected ? 'selected' : ''
}`}
onClick={(e) => {
e.preventDefault();
onClick();
}}
>
<Icon className="icon" type={iconType} />
<div className="min-w-1" />
{label}
</div>
);

View File

@@ -0,0 +1,24 @@
import { FunctionComponent } from 'preact';
import { HorizontalSeparator } from '@/components/Shared/HorizontalSeparator';
const HorizontalLine: FunctionComponent<{ index: number; length: number }> = ({
index,
length,
}) => (index < length - 1 ? <HorizontalSeparator classes="my-4" /> : null);
export const PreferencesGroup: FunctionComponent = ({ children }) => (
<div className="bg-default border-1 border-solid rounded border-main px-6 py-6 flex flex-col mb-3">
{Array.isArray(children)
? children
.filter(
(child) => child != undefined && child !== '' && child !== false
)
.map((child, i, arr) => (
<>
{child}
<HorizontalLine index={i} length={arr.length} />
</>
))
: children}
</div>
);

View File

@@ -0,0 +1,14 @@
import { FunctionComponent } from 'preact';
export const PreferencesPane: FunctionComponent = ({ children }) => (
<div className="color-foreground 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">
{children != undefined && Array.isArray(children)
? children.filter((child) => child != undefined)
: children}
</div>
</div>
<div className="flex-basis-55 flex-shrink" />
</div>
);

View File

@@ -0,0 +1,9 @@
import { FunctionComponent } from 'preact';
type Props = {
classes?: string;
};
export const PreferencesSegment: FunctionComponent<Props> = ({
children,
classes = '',
}) => <div className={`flex flex-col ${classes}`}>{children}</div>;

View File

@@ -0,0 +1,5 @@
export * from './Content';
export * from './MenuItem';
export * from './PreferencesPane';
export * from './PreferencesGroup';
export * from './PreferencesSegment';

View File

@@ -0,0 +1,33 @@
import {
Sync,
Subscription,
Credentials,
SignOutWrapper,
Authentication,
} from '@/components/Preferences/panes/account';
import { PreferencesPane } from '@/components/Preferences/components';
import { observer } from 'mobx-react-lite';
import { WebApplication } from '@/ui_models/application';
import { AppState } from '@/ui_models/app_state';
type Props = {
application: WebApplication;
appState: AppState;
};
export const AccountPreferences = observer(
({ application, appState }: Props) => (
<PreferencesPane>
{!application.hasAccount() ? (
<Authentication application={application} appState={appState} />
) : (
<>
<Credentials application={application} appState={appState} />
<Sync application={application} />
</>
)}
<Subscription application={application} appState={appState} />
<SignOutWrapper application={application} appState={appState} />
</PreferencesPane>
)
);

View File

@@ -0,0 +1,202 @@
import { Dropdown, DropdownItem } from '@/components/Dropdown';
import { usePremiumModal } from '@/components/Premium';
import { sortThemes } from '@/components/QuickSettingsMenu/QuickSettingsMenu';
import { HorizontalSeparator } from '@/components/Shared/HorizontalSeparator';
import { Switch } from '@/components/Switch';
import { WebApplication } from '@/ui_models/application';
import { GetFeatures } from '@standardnotes/features';
import {
ContentType,
FeatureIdentifier,
FeatureStatus,
PrefKey,
SNTheme,
} from '@standardnotes/snjs';
import { observer } from 'mobx-react-lite';
import { FunctionComponent } from 'preact';
import { useEffect, useState } from 'preact/hooks';
import {
PreferencesGroup,
PreferencesPane,
PreferencesSegment,
Subtitle,
Title,
Text,
} from '../components';
type Props = {
application: WebApplication;
};
export const Appearance: FunctionComponent<Props> = observer(
({ application }) => {
const premiumModal = usePremiumModal();
const isEntitledToMidnightTheme =
application.features.getFeatureStatus(FeatureIdentifier.MidnightTheme) ===
FeatureStatus.Entitled;
const [themeItems, setThemeItems] = useState<DropdownItem[]>([]);
const [autoLightTheme, setAutoLightTheme] = useState<string>(
() =>
application.getPreference(
PrefKey.AutoLightThemeIdentifier,
'Default'
) as string
);
const [autoDarkTheme, setAutoDarkTheme] = useState<string>(
() =>
application.getPreference(
PrefKey.AutoDarkThemeIdentifier,
isEntitledToMidnightTheme
? FeatureIdentifier.MidnightTheme
: 'Default'
) as string
);
const [useDeviceSettings, setUseDeviceSettings] = useState(
() =>
application.getPreference(
PrefKey.UseSystemColorScheme,
false
) as boolean
);
useEffect(() => {
const themesAsItems: DropdownItem[] = (
application.getDisplayableItems(ContentType.Theme) as SNTheme[]
)
.filter((theme) => !theme.isLayerable())
.sort(sortThemes)
.map((theme) => {
return {
label: theme.name,
value: theme.identifier as string,
};
});
GetFeatures()
.filter(
(feature) =>
feature.content_type === ContentType.Theme && !feature.layerable
)
.forEach((theme) => {
if (
themesAsItems.findIndex(
(item) => item.value === theme.identifier
) === -1
) {
themesAsItems.push({
label: theme.name as string,
value: theme.identifier,
icon: 'premium-feature',
});
}
});
themesAsItems.unshift({
label: 'Default',
value: 'Default',
});
setThemeItems(themesAsItems);
}, [application]);
const toggleUseDeviceSettings = () => {
application.setPreference(
PrefKey.UseSystemColorScheme,
!useDeviceSettings
);
if (!application.getPreference(PrefKey.AutoLightThemeIdentifier)) {
application.setPreference(
PrefKey.AutoLightThemeIdentifier,
autoLightTheme as FeatureIdentifier
);
}
if (!application.getPreference(PrefKey.AutoDarkThemeIdentifier)) {
application.setPreference(
PrefKey.AutoDarkThemeIdentifier,
autoDarkTheme as FeatureIdentifier
);
}
setUseDeviceSettings(!useDeviceSettings);
};
const changeAutoLightTheme = (value: string, item: DropdownItem) => {
if (item.icon === 'premium-feature') {
premiumModal.activate(`${item.label} theme`);
} else {
application.setPreference(
PrefKey.AutoLightThemeIdentifier,
value as FeatureIdentifier
);
setAutoLightTheme(value);
}
};
const changeAutoDarkTheme = (value: string, item: DropdownItem) => {
if (item.icon === 'premium-feature') {
premiumModal.activate(`${item.label} theme`);
} else {
application.setPreference(
PrefKey.AutoDarkThemeIdentifier,
value as FeatureIdentifier
);
setAutoDarkTheme(value);
}
};
return (
<PreferencesPane>
<PreferencesGroup>
<PreferencesSegment>
<Title>Themes</Title>
<div className="mt-2">
<div className="flex items-center justify-between">
<div className="flex flex-col">
<Subtitle>Use system color scheme</Subtitle>
<Text>
Automatically change active theme based on your system
settings.
</Text>
</div>
<Switch
onChange={toggleUseDeviceSettings}
checked={useDeviceSettings}
/>
</div>
<HorizontalSeparator classes="mt-5 mb-3" />
<div>
<Subtitle>Automatic Light Theme</Subtitle>
<Text>Theme to be used for system light mode:</Text>
<div className="mt-2">
<Dropdown
id="auto-light-theme-dropdown"
label="Select the automatic light theme"
items={themeItems}
value={autoLightTheme}
onChange={changeAutoLightTheme}
disabled={!useDeviceSettings}
/>
</div>
</div>
<HorizontalSeparator classes="mt-5 mb-3" />
<div>
<Subtitle>Automatic Dark Theme</Subtitle>
<Text>Theme to be used for system dark mode:</Text>
<div className="mt-2">
<Dropdown
id="auto-dark-theme-dropdown"
label="Select the automatic dark theme"
items={themeItems}
value={autoDarkTheme}
onChange={changeAutoDarkTheme}
disabled={!useDeviceSettings}
/>
</div>
</div>
</div>
</PreferencesSegment>
</PreferencesGroup>
</PreferencesPane>
);
}
);

View File

@@ -0,0 +1,23 @@
import { WebApplication } from '@/ui_models/application';
import { AppState } from '@/ui_models/app_state';
import { FunctionComponent } from 'preact';
import { PreferencesPane } from '../components';
import { CloudLink, DataBackups, EmailBackups } from './backups-segments';
interface Props {
appState: AppState;
application: WebApplication;
}
export const Backups: FunctionComponent<Props> = ({
application,
appState,
}) => {
return (
<PreferencesPane>
<DataBackups application={application} appState={appState} />
<EmailBackups application={application} />
<CloudLink application={application} />
</PreferencesPane>
);
};

View File

@@ -0,0 +1,115 @@
import { FunctionComponent } from 'preact';
import {
Title,
Subtitle,
Text,
LinkButton,
PreferencesGroup,
PreferencesPane,
PreferencesSegment,
} from '../components';
export const CloudLink: 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
not a government agency. As long as you keep your password safe, and
your password is reasonably strong, then you are the only person in
the world with the ability to decrypt your notes. For more on how we
handle your privacy and security, check out our easy to read{' '}
<a target="_blank" href="https://standardnotes.com/privacy">
Privacy Manifesto.
</a>
</Text>
</PreferencesSegment>
<PreferencesSegment>
<Subtitle>Can I collaborate with others on a note?</Subtitle>
<Text>
Because of our encrypted architecture, Standard Notes does not
currently provide a real-time collaboration solution. Multiple users
can share the same account however, but editing at the same time may
result in sync conflicts, which may result in the duplication of
notes.
</Text>
</PreferencesSegment>
<PreferencesSegment>
<Subtitle>Can I use Standard Notes totally offline?</Subtitle>
<Text>
Standard Notes can be used totally offline without an account, and
without an internet connection. You can find{' '}
<a
target="_blank"
href="https://standardnotes.com/help/59/can-i-use-standard-notes-totally-offline"
>
more details here.
</a>
</Text>
</PreferencesSegment>
<PreferencesSegment>
<Subtitle>Cant find your question here?</Subtitle>
<LinkButton
className="mt-3"
label="Open FAQ"
link="https://standardnotes.com/help"
/>
</PreferencesSegment>
</PreferencesGroup>
<PreferencesGroup>
<PreferencesSegment>
<Title>Community forum</Title>
<Text>
If you have an issue, found a bug or want to suggest a feature, you
can browse or post to the forum. Its recommended for non-account
related issues. Please read our{' '}
<a target="_blank" href="https://standardnotes.com/longevity/">
Longevity statement
</a>{' '}
before advocating for a feature request.
</Text>
<LinkButton
className="mt-3"
label="Go to the forum"
link="https://forum.standardnotes.org/"
/>
</PreferencesSegment>
</PreferencesGroup>
<PreferencesGroup>
<PreferencesSegment>
<Title>Community groups</Title>
<Text>
Want to meet other passionate note-takers and privacy enthusiasts?
Want to share your feedback with us? Join the Standard Notes community
groups for discussions on security, themes, editors and more.
</Text>
<LinkButton
className="mt-3"
link="https://standardnotes.com/slack"
label="Join our Slack"
/>
<LinkButton
className="mt-3"
link="https://standardnotes.com/discord"
label="Join our Discord"
/>
</PreferencesSegment>
</PreferencesGroup>
<PreferencesGroup>
<PreferencesSegment>
<Title>Account related issue?</Title>
<Text>
Send an email to help@standardnotes.com and well sort it out.
</Text>
<LinkButton
className="mt-3"
link="mailto: help@standardnotes.com"
label="Email us"
/>
</PreferencesSegment>
</PreferencesGroup>
</PreferencesPane>
);

View File

@@ -0,0 +1,79 @@
import {
PreferencesGroup,
PreferencesSegment,
} from '@/components/Preferences/components';
import { WebApplication } from '@/ui_models/application';
import { ComponentViewer, SNComponent } from '@standardnotes/snjs';
import { FeatureIdentifier } from '@standardnotes/features';
import { observer } from 'mobx-react-lite';
import { FunctionComponent } from 'preact';
import { ExtensionItem } from './extensions-segments';
import { ComponentView } from '@/components/ComponentView';
import { AppState } from '@/ui_models/app_state';
import { PreferencesMenu } from '@/components/Preferences/PreferencesMenu';
import { useEffect, useState } from 'preact/hooks';
interface IProps {
application: WebApplication;
appState: AppState;
extension: SNComponent;
preferencesMenu: PreferencesMenu;
}
const urlOverrideForExtension = (extension: SNComponent) => {
if (extension.identifier === FeatureIdentifier.CloudLink) {
return 'https://extensions.standardnotes.org/components/cloudlink';
} else {
return undefined;
}
};
export const ExtensionPane: FunctionComponent<IProps> = observer(
({ extension, application, appState, preferencesMenu }) => {
const [componentViewer] = useState<ComponentViewer>(
application.componentManager.createComponentViewer(
extension,
undefined,
undefined,
urlOverrideForExtension(extension)
)
);
const latestVersion =
preferencesMenu.extensionsLatestVersions.getVersion(extension);
useEffect(() => {
return () => {
application.componentManager.destroyComponentViewer(componentViewer);
};
}, [application, componentViewer]);
return (
<div className="preferences-extension-pane color-foreground 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-200 max-w-200 flex flex-col">
<PreferencesGroup>
<ExtensionItem
application={application}
extension={extension}
first={false}
uninstall={() =>
application
.deleteItem(extension)
.then(() => preferencesMenu.loadExtensionsPanes())
}
latestVersion={latestVersion}
/>
<PreferencesSegment>
<ComponentView
application={application}
appState={appState}
componentViewer={componentViewer}
/>
</PreferencesSegment>
</PreferencesGroup>
</div>
</div>
</div>
);
}
);

View File

@@ -0,0 +1,141 @@
import { ButtonType, ContentType, SNComponent } from '@standardnotes/snjs';
import { Button } from '@/components/Button';
import { DecoratedInput } from '@/components/DecoratedInput';
import { WebApplication } from '@/ui_models/application';
import { FunctionComponent } from 'preact';
import { Title, PreferencesSegment } from '../components';
import {
ConfirmCustomExtension,
ExtensionItem,
ExtensionsLatestVersions,
} from './extensions-segments';
import { useEffect, useRef, useState } from 'preact/hooks';
import { observer } from 'mobx-react-lite';
const loadExtensions = (application: WebApplication) =>
application.getItems(
[ContentType.ActionsExtension, ContentType.Component, ContentType.Theme],
true
) as SNComponent[];
export const Extensions: FunctionComponent<{
application: WebApplication;
extensionsLatestVersions: ExtensionsLatestVersions;
className?: string;
}> = observer(({ application, extensionsLatestVersions, className = '' }) => {
const [customUrl, setCustomUrl] = useState('');
const [confirmableExtension, setConfirmableExtension] = useState<
SNComponent | undefined
>(undefined);
const [extensions, setExtensions] = useState(loadExtensions(application));
const confirmableEnd = useRef<HTMLDivElement>(null);
useEffect(() => {
if (confirmableExtension) {
confirmableEnd.current!.scrollIntoView({ behavior: 'smooth' });
}
}, [confirmableExtension, confirmableEnd]);
const uninstallExtension = async (extension: SNComponent) => {
application.alertService
.confirm(
'Are you sure you want to uninstall this extension? Note that extensions managed by your subscription will automatically be re-installed on application restart.',
'Uninstall Extension?',
'Uninstall',
ButtonType.Danger,
'Cancel'
)
.then(async (shouldRemove: boolean) => {
if (shouldRemove) {
await application.deleteItem(extension);
setExtensions(loadExtensions(application));
}
})
.catch((err: string) => {
application.alertService.alert(err);
});
};
const submitExtensionUrl = async (url: string) => {
const component = await application.features.downloadExternalFeature(url);
if (component) {
setConfirmableExtension(component);
}
};
const handleConfirmExtensionSubmit = async (confirm: boolean) => {
if (confirm) {
confirmExtension();
}
setConfirmableExtension(undefined);
setCustomUrl('');
};
const confirmExtension = async () => {
await application.insertItem(confirmableExtension as SNComponent);
application.sync.sync();
setExtensions(loadExtensions(application));
};
const visibleExtensions = extensions.filter((extension) => {
return (
extension.package_info != undefined &&
!['modal', 'rooms'].includes(extension.area)
);
});
return (
<div className={className}>
{visibleExtensions.length > 0 && (
<div>
{visibleExtensions
.sort((e1, e2) =>
e1.name?.toLowerCase().localeCompare(e2.name?.toLowerCase())
)
.map((extension, i) => (
<ExtensionItem
key={extension.uuid}
application={application}
extension={extension}
latestVersion={extensionsLatestVersions.getVersion(extension)}
first={i === 0}
uninstall={uninstallExtension}
/>
))}
</div>
)}
<div>
{!confirmableExtension && (
<PreferencesSegment>
<Title>Install Custom Extension</Title>
<DecoratedInput
placeholder={'Enter Extension URL'}
text={customUrl}
onChange={(value) => {
setCustomUrl(value);
}}
/>
<div className="min-h-2" />
<Button
className="min-w-20"
type="normal"
label="Install"
onClick={() => submitExtensionUrl(customUrl)}
/>
</PreferencesSegment>
)}
{confirmableExtension && (
<PreferencesSegment>
<ConfirmCustomExtension
component={confirmableExtension}
callback={handleConfirmExtensionSubmit}
/>
<div ref={confirmableEnd} />
</PreferencesSegment>
)}
</div>
</div>
);
});

View File

@@ -0,0 +1,29 @@
import { WebApplication } from '@/ui_models/application';
import { AppState } from '@/ui_models/app_state';
import { FunctionComponent } from 'preact';
import { PreferencesPane } from '../components';
import { Tools, Defaults, LabsPane } from './general-segments';
import { ExtensionsLatestVersions } from '@/components/Preferences/panes/extensions-segments';
import { Advanced } from '@/components/Preferences/panes/account';
import { observer } from 'mobx-react-lite';
interface GeneralProps {
appState: AppState;
application: WebApplication;
extensionsLatestVersions: ExtensionsLatestVersions;
}
export const General: FunctionComponent<GeneralProps> = observer(
({ appState, application, extensionsLatestVersions }) => (
<PreferencesPane>
<Tools application={application} />
<Defaults application={application} />
<LabsPane application={application} />
<Advanced
application={application}
appState={appState}
extensionsLatestVersions={extensionsLatestVersions}
/>
</PreferencesPane>
)
);

View File

@@ -0,0 +1,115 @@
import { FunctionComponent } from 'preact';
import {
Title,
Subtitle,
Text,
LinkButton,
PreferencesGroup,
PreferencesPane,
PreferencesSegment,
} from '../components';
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
not a government agency. As long as you keep your password safe, and
your password is reasonably strong, then you are the only person in
the world with the ability to decrypt your notes. For more on how we
handle your privacy and security, check out our easy to read{' '}
<a target="_blank" href="https://standardnotes.com/privacy">
Privacy Manifesto.
</a>
</Text>
</PreferencesSegment>
<PreferencesSegment>
<Subtitle>Can I collaborate with others on a note?</Subtitle>
<Text>
Because of our encrypted architecture, Standard Notes does not
currently provide a real-time collaboration solution. Multiple users
can share the same account however, but editing at the same time may
result in sync conflicts, which may result in the duplication of
notes.
</Text>
</PreferencesSegment>
<PreferencesSegment>
<Subtitle>Can I use Standard Notes totally offline?</Subtitle>
<Text>
Standard Notes can be used totally offline without an account, and
without an internet connection. You can find{' '}
<a
target="_blank"
href="https://standardnotes.com/help/59/can-i-use-standard-notes-totally-offline"
>
more details here.
</a>
</Text>
</PreferencesSegment>
<PreferencesSegment>
<Subtitle>Cant find your question here?</Subtitle>
<LinkButton
className="mt-3"
label="Open FAQ"
link="https://standardnotes.com/help"
/>
</PreferencesSegment>
</PreferencesGroup>
<PreferencesGroup>
<PreferencesSegment>
<Title>Community forum</Title>
<Text>
If you have an issue, found a bug or want to suggest a feature, you
can browse or post to the forum. Its recommended for non-account
related issues. Please read our{' '}
<a target="_blank" href="https://standardnotes.com/longevity/">
Longevity statement
</a>{' '}
before advocating for a feature request.
</Text>
<LinkButton
className="mt-3"
label="Go to the forum"
link="https://forum.standardnotes.org/"
/>
</PreferencesSegment>
</PreferencesGroup>
<PreferencesGroup>
<PreferencesSegment>
<Title>Community groups</Title>
<Text>
Want to meet other passionate note-takers and privacy enthusiasts?
Want to share your feedback with us? Join the Standard Notes community
groups for discussions on security, themes, editors and more.
</Text>
<LinkButton
className="mt-3"
link="https://standardnotes.com/slack"
label="Join our Slack"
/>
<LinkButton
className="mt-3"
link="https://standardnotes.com/discord"
label="Join our Discord"
/>
</PreferencesSegment>
</PreferencesGroup>
<PreferencesGroup>
<PreferencesSegment>
<Title>Account related issue?</Title>
<Text>
Send an email to help@standardnotes.com and well sort it out.
</Text>
<LinkButton
className="mt-3"
link="mailto: help@standardnotes.com"
label="Email us"
/>
</PreferencesSegment>
</PreferencesGroup>
</PreferencesPane>
);

View File

@@ -0,0 +1,116 @@
import {
PreferencesGroup,
PreferencesPane,
PreferencesSegment,
Title,
Subtitle,
Text,
} from '../components';
import { observer } from 'mobx-react-lite';
import { WebApplication } from '@/ui_models/application';
import { ButtonType, ListedAccount } from '@standardnotes/snjs';
import { useCallback, useEffect, useState } from 'preact/hooks';
import { ListedAccountItem } from './listed/BlogItem';
import { Button } from '@/components/Button';
type Props = {
application: WebApplication;
};
export const Listed = observer(({ application }: Props) => {
const [accounts, setAccounts] = useState<ListedAccount[]>([]);
const [requestingAccount, setRequestingAccount] = useState<boolean>();
const reloadAccounts = useCallback(async () => {
setAccounts(await application.getListedAccounts());
}, [application]);
useEffect(() => {
reloadAccounts();
}, [reloadAccounts]);
const registerNewAccount = useCallback(() => {
setRequestingAccount(true);
const requestAccount = async () => {
const account = await application.requestNewListedAccount();
if (account) {
const openSettings = await application.alertService.confirm(
`Your new Listed blog has been successfully created!` +
` You can publish a new post to your blog from Standard Notes via the` +
` <i>Actions</i> menu in the editor pane. Open your blog settings to begin setting it up.`,
undefined,
'Open Settings',
ButtonType.Info,
'Later'
);
reloadAccounts();
if (openSettings) {
const info = await application.getListedAccountInfo(account);
if (info) {
application.deviceInterface.openUrl(info?.settings_url);
}
}
}
setRequestingAccount(false);
};
requestAccount();
}, [application, reloadAccounts]);
return (
<PreferencesPane>
{accounts.length > 0 && (
<PreferencesGroup>
<PreferencesSegment>
<Title>
Your {accounts.length === 1 ? 'Blog' : 'Blogs'} on Listed
</Title>
<div className="h-2 w-full" />
{accounts.map((item, index, array) => {
return (
<ListedAccountItem
account={item}
showSeparator={index !== array.length - 1}
key={item.authorId}
application={application}
/>
);
})}
</PreferencesSegment>
</PreferencesGroup>
)}
<PreferencesGroup>
<PreferencesSegment>
<Title>About Listed</Title>
<div className="h-2 w-full" />
<Subtitle>What is Listed?</Subtitle>
<Text>
Listed is a free blogging platform that allows you to create a
public journal published directly from your notes.{' '}
<a
target="_blank"
href="https://listed.to"
rel="noreferrer noopener"
>
Learn more
</a>
</Text>
</PreferencesSegment>
<PreferencesSegment>
<Subtitle>Get Started</Subtitle>
<Text>Create a free Listed author account to get started.</Text>
<Button
className="mt-3"
type="normal"
disabled={requestingAccount}
label={
requestingAccount ? 'Creating account...' : 'Create New Author'
}
onClick={registerNewAccount}
/>
</PreferencesSegment>
</PreferencesGroup>
</PreferencesPane>
);
});

View File

@@ -0,0 +1,24 @@
import { WebApplication } from '@/ui_models/application';
import { AppState } from '@/ui_models/app_state';
import { FunctionComponent } from 'preact';
import { PreferencesPane } from '../components';
import { Encryption, PasscodeLock, Protections } from './security-segments';
import { TwoFactorAuthWrapper } from './two-factor-auth';
import { MfaProps } from './two-factor-auth/MfaProps';
interface SecurityProps extends MfaProps {
appState: AppState;
application: WebApplication;
}
export const Security: FunctionComponent<SecurityProps> = (props) => (
<PreferencesPane>
<Encryption appState={props.appState} />
<Protections application={props.application} />
<TwoFactorAuthWrapper
mfaProvider={props.mfaProvider}
userProvider={props.userProvider}
/>
<PasscodeLock appState={props.appState} application={props.application} />
</PreferencesPane>
);

View File

@@ -0,0 +1,44 @@
import { FunctionalComponent } from 'preact';
import {
PreferencesGroup,
PreferencesSegment,
} from '@/components/Preferences/components';
import { OfflineSubscription } from '@/components/Preferences/panes/account/offlineSubscription';
import { WebApplication } from '@/ui_models/application';
import { observer } from 'mobx-react-lite';
import { AppState } from '@/ui_models/app_state';
import { Extensions } from '@/components/Preferences/panes/Extensions';
import { ExtensionsLatestVersions } from '@/components/Preferences/panes/extensions-segments';
import { AccordionItem } from '@/components/Shared/AccordionItem';
interface IProps {
application: WebApplication;
appState: AppState;
extensionsLatestVersions: ExtensionsLatestVersions;
}
export const Advanced: FunctionalComponent<IProps> = observer(
({ application, appState, extensionsLatestVersions }) => {
return (
<PreferencesGroup>
<PreferencesSegment>
<AccordionItem title={'Advanced Settings'}>
<div className="flex flex-row items-center">
<div className="flex-grow flex flex-col">
<OfflineSubscription
application={application}
appState={appState}
/>
<Extensions
className={'mt-3'}
application={application}
extensionsLatestVersions={extensionsLatestVersions}
/>
</div>
</div>
</AccordionItem>
</PreferencesSegment>
</PreferencesGroup>
);
}
);

View File

@@ -0,0 +1,60 @@
import { AccountMenuPane } from '@/components/AccountMenu';
import { Button } from '@/components/Button';
import {
PreferencesGroup,
PreferencesSegment,
Text,
Title,
} from '@/components/Preferences/components';
import { WebApplication } from '@/ui_models/application';
import { AppState } from '@/ui_models/app_state';
import { observer } from 'mobx-react-lite';
import { FunctionComponent } from 'preact';
import { AccountIllustration } from '@standardnotes/stylekit';
export const Authentication: FunctionComponent<{
application: WebApplication;
appState: AppState;
}> = observer(({ appState }) => {
const clickSignIn = () => {
appState.preferences.closePreferences();
appState.accountMenu.setCurrentPane(AccountMenuPane.SignIn);
appState.accountMenu.setShow(true);
};
const clickRegister = () => {
appState.preferences.closePreferences();
appState.accountMenu.setCurrentPane(AccountMenuPane.Register);
appState.accountMenu.setShow(true);
};
return (
<PreferencesGroup>
<PreferencesSegment>
<div className="flex flex-col items-center px-12">
<AccountIllustration className="mb-3" />
<Title>You're not signed in</Title>
<Text className="text-center mb-3">
Sign in to sync your notes and preferences across all your devices
and enable end-to-end encryption.
</Text>
<Button
type="primary"
label="Create free account"
onClick={clickRegister}
className="mb-3"
/>
<div className="text-input">
Already have an account?{' '}
<button
className="border-0 p-0 bg-default color-info underline cursor-pointer"
onClick={clickSignIn}
>
Sign in
</button>
</div>
</div>
</PreferencesSegment>
</PreferencesGroup>
);
});

View File

@@ -0,0 +1,80 @@
import {
PreferencesGroup,
PreferencesSegment,
Subtitle,
Text,
Title,
} from '@/components/Preferences/components';
import { Button } from '@/components/Button';
import { WebApplication } from '@/ui_models/application';
import { observer } from '@node_modules/mobx-react-lite';
import { HorizontalSeparator } from '@/components/Shared/HorizontalSeparator';
import { dateToLocalizedString } from '@standardnotes/snjs';
import { useCallback, useState } from 'preact/hooks';
import { ChangeEmail } from '@/components/Preferences/panes/account/changeEmail';
import { FunctionComponent, render } from 'preact';
import { AppState } from '@/ui_models/app_state';
import { PasswordWizard } from '@/components/PasswordWizard';
type Props = {
application: WebApplication;
appState: AppState;
};
export const Credentials: FunctionComponent<Props> = observer(
({ application }: Props) => {
const [isChangeEmailDialogOpen, setIsChangeEmailDialogOpen] =
useState(false);
const user = application.getUser();
const passwordCreatedAtTimestamp =
application.getUserPasswordCreationDate() as Date;
const passwordCreatedOn = dateToLocalizedString(passwordCreatedAtTimestamp);
const presentPasswordWizard = useCallback(() => {
render(
<PasswordWizard application={application} />,
document.body.appendChild(document.createElement('div'))
);
}, [application]);
return (
<PreferencesGroup>
<PreferencesSegment>
<Title>Credentials</Title>
<Subtitle>Email</Subtitle>
<Text>
You're signed in as <span className="font-bold">{user?.email}</span>
</Text>
<Button
className="min-w-20 mt-3"
type="normal"
label="Change email"
onClick={() => {
setIsChangeEmailDialogOpen(true);
}}
/>
<HorizontalSeparator classes="mt-5 mb-3" />
<Subtitle>Password</Subtitle>
<Text>
Current password was set on{' '}
<span className="font-bold">{passwordCreatedOn}</span>
</Text>
<Button
className="min-w-20 mt-3"
type="normal"
label="Change password"
onClick={presentPasswordWizard}
/>
{isChangeEmailDialogOpen && (
<ChangeEmail
onCloseDialog={() => setIsChangeEmailDialogOpen(false)}
application={application}
/>
)}
</PreferencesSegment>
</PreferencesGroup>
);
}
);

View File

@@ -0,0 +1,93 @@
import { Button } from '@/components/Button';
import { OtherSessionsSignOutContainer } from '@/components/OtherSessionsSignOut';
import {
PreferencesGroup,
PreferencesSegment,
Subtitle,
Text,
Title,
} from '@/components/Preferences/components';
import { WebApplication } from '@/ui_models/application';
import { AppState } from '@/ui_models/app_state';
import { observer } from 'mobx-react-lite';
import { FunctionComponent } from 'preact';
const SignOutView: FunctionComponent<{
application: WebApplication;
appState: AppState;
}> = observer(({ application, appState }) => {
return (
<>
<PreferencesGroup>
<PreferencesSegment>
<Title>Sign out</Title>
<Subtitle>Other devices</Subtitle>
<Text>Want to sign out on all devices except this one?</Text>
<div className="min-h-3" />
<div className="flex flex-row">
<Button
className="mr-3"
type="normal"
label="Sign out other sessions"
onClick={() => {
appState.accountMenu.setOtherSessionsSignOut(true);
}}
/>
<Button
type="normal"
label="Manage sessions"
onClick={() => appState.openSessionsModal()}
/>
</div>
</PreferencesSegment>
<PreferencesSegment>
<Subtitle>This device</Subtitle>
<Text>This will delete all local items and preferences.</Text>
<div className="min-h-3" />
<Button
type="danger"
label="Sign out and clear local data"
onClick={() => {
appState.accountMenu.setSigningOut(true);
}}
/>
</PreferencesSegment>
</PreferencesGroup>
<OtherSessionsSignOutContainer
appState={appState}
application={application}
/>
</>
);
});
const ClearSessionDataView: FunctionComponent<{
appState: AppState;
}> = observer(({ appState }) => {
return (
<PreferencesGroup>
<PreferencesSegment>
<Title>Clear session data</Title>
<Text>This will delete all local items and preferences.</Text>
<div className="min-h-3" />
<Button
type="danger"
label="Clear Session Data"
onClick={() => {
appState.accountMenu.setSigningOut(true);
}}
/>
</PreferencesSegment>
</PreferencesGroup>
);
});
export const SignOutWrapper: FunctionComponent<{
application: WebApplication;
appState: AppState;
}> = observer(({ application, appState }) => {
if (!application.hasAccount()) {
return <ClearSessionDataView appState={appState} />;
}
return <SignOutView appState={appState} application={application} />;
});

View File

@@ -0,0 +1,69 @@
import {
PreferencesGroup,
PreferencesSegment,
Text,
Title,
} from '@/components/Preferences/components';
import { Button } from '@/components/Button';
import { SyncQueueStrategy, dateToLocalizedString } from '@standardnotes/snjs';
import { STRING_GENERIC_SYNC_ERROR } from '@/strings';
import { useState } from '@node_modules/preact/hooks';
import { observer } from 'mobx-react-lite';
import { WebApplication } from '@/ui_models/application';
import { FunctionComponent } from 'preact';
type Props = {
application: WebApplication;
};
export const formatLastSyncDate = (lastUpdatedDate: Date) => {
return dateToLocalizedString(lastUpdatedDate);
};
export const Sync: FunctionComponent<Props> = observer(
({ application }: Props) => {
const [isSyncingInProgress, setIsSyncingInProgress] = useState(false);
const [lastSyncDate, setLastSyncDate] = useState(
formatLastSyncDate(application.sync.getLastSyncDate() as Date)
);
const doSynchronization = async () => {
setIsSyncingInProgress(true);
const response = await application.sync.sync({
queueStrategy: SyncQueueStrategy.ForceSpawnNew,
checkIntegrity: true,
});
setIsSyncingInProgress(false);
if (response && (response as any).error) {
application.alertService.alert(STRING_GENERIC_SYNC_ERROR);
} else {
setLastSyncDate(
formatLastSyncDate(application.sync.getLastSyncDate() as Date)
);
}
};
return (
<PreferencesGroup>
<PreferencesSegment>
<div className="flex flex-row items-center">
<div className="flex-grow flex flex-col">
<Title>Sync</Title>
<Text>
Last synced <span className="font-bold">on {lastSyncDate}</span>
</Text>
<Button
className="min-w-20 mt-3"
type="normal"
label="Sync now"
disabled={isSyncingInProgress}
onClick={doSynchronization}
/>
</div>
</div>
</PreferencesSegment>
</PreferencesGroup>
);
}
);

View File

@@ -0,0 +1,47 @@
import { StateUpdater } from 'preact/hooks';
import { FunctionalComponent } from 'preact';
type Props = {
setNewEmail: StateUpdater<string>;
setCurrentPassword: StateUpdater<string>;
};
const labelClassName = `block mb-1`;
const inputClassName = 'sk-input contrast';
export const ChangeEmailForm: FunctionalComponent<Props> = ({
setNewEmail,
setCurrentPassword,
}) => {
return (
<div className="w-full flex flex-col">
<div className="mt-2 mb-3">
<label className={labelClassName} htmlFor="change-email-email-input">
New Email:
</label>
<input
id="change-email-email-input"
className={inputClassName}
type="email"
onChange={({ target }) => {
setNewEmail((target as HTMLInputElement).value);
}}
/>
</div>
<div className="mb-2">
<label className={labelClassName} htmlFor="change-email-password-input">
Current Password:
</label>
<input
id="change-email-password-input"
className={inputClassName}
type="password"
onChange={({ target }) => {
setCurrentPassword((target as HTMLInputElement).value);
}}
/>
</div>
</div>
);
};

View File

@@ -0,0 +1,15 @@
import { FunctionalComponent } from 'preact';
export const ChangeEmailSuccess: FunctionalComponent = () => {
return (
<div>
<div className={'sk-label sk-bold info mt-2'}>
Your email has been successfully changed.
</div>
<p className={'sk-p'}>
Please ensure you are running the latest version of Standard Notes on
all platforms to ensure maximum compatibility.
</p>
</div>
);
};

View File

@@ -0,0 +1,180 @@
import { useState } from '@node_modules/preact/hooks';
import {
ModalDialog,
ModalDialogButtons,
ModalDialogDescription,
ModalDialogLabel,
} from '@/components/Shared/ModalDialog';
import { Button } from '@/components/Button';
import { FunctionalComponent } from 'preact';
import { WebApplication } from '@/ui_models/application';
import { useBeforeUnload } from '@/hooks/useBeforeUnload';
import { ChangeEmailForm } from './ChangeEmailForm';
import { ChangeEmailSuccess } from './ChangeEmailSuccess';
import { isEmailValid } from '@/utils';
enum SubmitButtonTitles {
Default = 'Continue',
GeneratingKeys = 'Generating Keys...',
Finish = 'Finish',
}
enum Steps {
InitialStep,
FinishStep,
}
type Props = {
onCloseDialog: () => void;
application: WebApplication;
};
export const ChangeEmail: FunctionalComponent<Props> = ({
onCloseDialog,
application,
}) => {
const [currentPassword, setCurrentPassword] = useState('');
const [newEmail, setNewEmail] = useState('');
const [isContinuing, setIsContinuing] = useState(false);
const [lockContinue, setLockContinue] = useState(false);
const [submitButtonTitle, setSubmitButtonTitle] = useState(
SubmitButtonTitles.Default
);
const [currentStep, setCurrentStep] = useState(Steps.InitialStep);
useBeforeUnload();
const applicationAlertService = application.alertService;
const validateCurrentPassword = async () => {
if (!currentPassword || currentPassword.length === 0) {
applicationAlertService.alert('Please enter your current password.');
return false;
}
const success = await application.validateAccountPassword(currentPassword);
if (!success) {
applicationAlertService.alert(
'The current password you entered is not correct. Please try again.'
);
return false;
}
return success;
};
const validateNewEmail = async () => {
if (!isEmailValid(newEmail)) {
applicationAlertService.alert(
'The email you entered has an invalid format. Please review your input and try again.'
);
return false;
}
return true;
};
const resetProgressState = () => {
setSubmitButtonTitle(SubmitButtonTitles.Default);
setIsContinuing(false);
};
const processEmailChange = async () => {
await application.downloadBackup();
setLockContinue(true);
const response = await application.changeEmail(newEmail, currentPassword);
const success = !response.error;
setLockContinue(false);
return success;
};
const dismiss = () => {
if (lockContinue) {
applicationAlertService.alert(
'Cannot close window until pending tasks are complete.'
);
} else {
onCloseDialog();
}
};
const handleSubmit = async () => {
if (lockContinue || isContinuing) {
return;
}
if (currentStep === Steps.FinishStep) {
dismiss();
return;
}
setIsContinuing(true);
setSubmitButtonTitle(SubmitButtonTitles.GeneratingKeys);
const valid =
(await validateCurrentPassword()) && (await validateNewEmail());
if (!valid) {
resetProgressState();
return;
}
const success = await processEmailChange();
if (!success) {
resetProgressState();
return;
}
setIsContinuing(false);
setSubmitButtonTitle(SubmitButtonTitles.Finish);
setCurrentStep(Steps.FinishStep);
};
const handleDialogClose = () => {
if (lockContinue) {
applicationAlertService.alert(
'Cannot close window until pending tasks are complete.'
);
} else {
onCloseDialog();
}
};
return (
<div>
<ModalDialog>
<ModalDialogLabel closeDialog={handleDialogClose}>
Change Email
</ModalDialogLabel>
<ModalDialogDescription className="px-4.5">
{currentStep === Steps.InitialStep && (
<ChangeEmailForm
setNewEmail={setNewEmail}
setCurrentPassword={setCurrentPassword}
/>
)}
{currentStep === Steps.FinishStep && <ChangeEmailSuccess />}
</ModalDialogDescription>
<ModalDialogButtons className="px-4.5">
<Button
className="min-w-20"
type="primary"
label={submitButtonTitle}
onClick={handleSubmit}
/>
</ModalDialogButtons>
</ModalDialog>
</div>
);
};

View File

@@ -0,0 +1,6 @@
export { Subscription } from './subscription/Subscription';
export { Sync } from './Sync';
export { Credentials } from './Credentials';
export { SignOutWrapper } from './SignOutView';
export { Authentication } from './Authentication';
export { Advanced } from './Advanced';

View File

@@ -0,0 +1,146 @@
import { FunctionalComponent } from 'preact';
import { Subtitle } from '@/components/Preferences/components';
import { DecoratedInput } from '@/components/DecoratedInput';
import { Button } from '@/components/Button';
import { JSXInternal } from '@node_modules/preact/src/jsx';
import TargetedEvent = JSXInternal.TargetedEvent;
import { useEffect, useState } from 'preact/hooks';
import { WebApplication } from '@/ui_models/application';
import { AppState } from '@/ui_models/app_state';
import { observer } from 'mobx-react-lite';
import { STRING_REMOVE_OFFLINE_KEY_CONFIRMATION } from '@/strings';
import { ButtonType, ClientDisplayableError } from '@standardnotes/snjs';
import { HorizontalSeparator } from '@/components/Shared/HorizontalSeparator';
interface IProps {
application: WebApplication;
appState: AppState;
}
export const OfflineSubscription: FunctionalComponent<IProps> = observer(
({ application }) => {
const [activationCode, setActivationCode] = useState('');
const [isSuccessfullyActivated, setIsSuccessfullyActivated] =
useState(false);
const [isSuccessfullyRemoved, setIsSuccessfullyRemoved] = useState(false);
const [hasUserPreviouslyStoredCode, setHasUserPreviouslyStoredCode] =
useState(false);
useEffect(() => {
if (application.features.hasOfflineRepo()) {
setHasUserPreviouslyStoredCode(true);
}
}, [application]);
const shouldShowOfflineSubscription = () => {
return (
!application.hasAccount() ||
application.isThirdPartyHostUsed() ||
hasUserPreviouslyStoredCode
);
};
const handleSubscriptionCodeSubmit = async (
event: TargetedEvent<HTMLFormElement, Event>
) => {
event.preventDefault();
const result = await application.features.setOfflineFeaturesCode(
activationCode
);
if (result instanceof ClientDisplayableError) {
await application.alertService.alert(result.text);
} else {
setIsSuccessfullyActivated(true);
setHasUserPreviouslyStoredCode(true);
setIsSuccessfullyRemoved(false);
}
};
const handleRemoveOfflineKey = async () => {
await application.features.deleteOfflineFeatureRepo();
setIsSuccessfullyActivated(false);
setHasUserPreviouslyStoredCode(false);
setActivationCode('');
setIsSuccessfullyRemoved(true);
};
const handleRemoveClick = async () => {
application.alertService
.confirm(
STRING_REMOVE_OFFLINE_KEY_CONFIRMATION,
'Remove offline key?',
'Remove Offline Key',
ButtonType.Danger,
'Cancel'
)
.then(async (shouldRemove: boolean) => {
if (shouldRemove) {
await handleRemoveOfflineKey();
}
})
.catch((err: string) => {
application.alertService.alert(err);
});
};
if (!shouldShowOfflineSubscription()) {
return null;
}
return (
<>
<div className="flex items-center justify-between">
<div className="flex flex-col mt-3 w-full">
<Subtitle>
{!hasUserPreviouslyStoredCode && 'Activate'} Offline Subscription
</Subtitle>
<form onSubmit={handleSubscriptionCodeSubmit}>
<div className={'mt-2'}>
{!hasUserPreviouslyStoredCode && (
<DecoratedInput
onChange={(code) => setActivationCode(code)}
placeholder={'Offline Subscription Code'}
text={activationCode}
disabled={isSuccessfullyActivated}
className={'mb-3'}
/>
)}
</div>
{(isSuccessfullyActivated || isSuccessfullyRemoved) && (
<div className={'mt-3 mb-3 info'}>
Your offline subscription code has been successfully{' '}
{isSuccessfullyActivated ? 'activated' : 'removed'}.
</div>
)}
{hasUserPreviouslyStoredCode && (
<Button
type="danger"
label="Remove offline key"
onClick={() => {
handleRemoveClick();
}}
/>
)}
{!hasUserPreviouslyStoredCode && !isSuccessfullyActivated && (
<Button
label={'Submit'}
type="primary"
disabled={activationCode === ''}
onClick={(event) =>
handleSubscriptionCodeSubmit(
event as TargetedEvent<HTMLFormElement>
)
}
/>
)}
</form>
</div>
</div>
<HorizontalSeparator classes="mt-8 mb-5" />
</>
);
}
);

View File

@@ -0,0 +1,57 @@
import { FunctionalComponent } from 'preact';
import { LinkButton, Text } from '@/components/Preferences/components';
import { Button } from '@/components/Button';
import { WebApplication } from '@/ui_models/application';
import { useState } from 'preact/hooks';
import { loadPurchaseFlowUrl } from '@/components/PurchaseFlow/PurchaseFlowWrapper';
export const NoSubscription: FunctionalComponent<{
application: WebApplication;
}> = ({ application }) => {
const [isLoadingPurchaseFlow, setIsLoadingPurchaseFlow] = useState(false);
const [purchaseFlowError, setPurchaseFlowError] = useState<
string | undefined
>(undefined);
const onPurchaseClick = async () => {
const errorMessage =
'There was an error when attempting to redirect you to the subscription page.';
setIsLoadingPurchaseFlow(true);
try {
if (!(await loadPurchaseFlowUrl(application))) {
setPurchaseFlowError(errorMessage);
}
} catch (e) {
setPurchaseFlowError(errorMessage);
} finally {
setIsLoadingPurchaseFlow(false);
}
};
return (
<>
<Text>You don't have a Standard Notes subscription yet.</Text>
{isLoadingPurchaseFlow && (
<Text>Redirecting you to the subscription page...</Text>
)}
{purchaseFlowError && (
<Text className="color-danger">{purchaseFlowError}</Text>
)}
<div className="flex">
<LinkButton
className="min-w-20 mt-3 mr-3"
label="Learn More"
link={window.plansUrl as string}
/>
{application.hasAccount() && (
<Button
className="min-w-20 mt-3"
type="primary"
label="Subscribe"
onClick={onPurchaseClick}
/>
)}
</div>
</>
);
};

View File

@@ -0,0 +1,45 @@
import {
PreferencesGroup,
PreferencesSegment,
Title,
} from '@/components/Preferences/components';
import { WebApplication } from '@/ui_models/application';
import { SubscriptionInformation } from './SubscriptionInformation';
import { NoSubscription } from './NoSubscription';
import { observer } from 'mobx-react-lite';
import { FunctionComponent } from 'preact';
import { AppState } from '@/ui_models/app_state';
type Props = {
application: WebApplication;
appState: AppState;
};
export const Subscription: FunctionComponent<Props> = observer(
({ application, appState }: Props) => {
const subscriptionState = appState.subscription;
const { userSubscription } = subscriptionState;
const now = new Date().getTime();
return (
<PreferencesGroup>
<PreferencesSegment>
<div className="flex flex-row items-center">
<div className="flex-grow flex flex-col">
<Title>Subscription</Title>
{userSubscription && userSubscription.endsAt > now ? (
<SubscriptionInformation
subscriptionState={subscriptionState}
application={application}
/>
) : (
<NoSubscription application={application} />
)}
</div>
</div>
</PreferencesSegment>
</PreferencesGroup>
);
}
);

View File

@@ -0,0 +1,91 @@
import { observer } from 'mobx-react-lite';
import { SubscriptionState } from '../../../../../ui_models/app_state/subscription_state';
import { Text } from '@/components/Preferences/components';
import { Button } from '@/components/Button';
import { WebApplication } from '@/ui_models/application';
import { openSubscriptionDashboard } from '@/hooks/manageSubscription';
type Props = {
subscriptionState: SubscriptionState;
application?: WebApplication;
};
const StatusText = observer(({ subscriptionState }: Props) => {
const {
userSubscriptionName,
userSubscriptionExpirationDate,
isUserSubscriptionExpired,
isUserSubscriptionCanceled,
} = subscriptionState;
const expirationDateString = userSubscriptionExpirationDate?.toLocaleString();
if (isUserSubscriptionCanceled) {
return (
<Text className="mt-1">
Your{' '}
<span className="font-bold">
Standard Notes{userSubscriptionName ? ' ' : ''}
{userSubscriptionName}
</span>{' '}
subscription has been canceled{' '}
{isUserSubscriptionExpired ? (
<span className="font-bold">
and expired on {expirationDateString}
</span>
) : (
<span className="font-bold">
but will remain valid until {expirationDateString}
</span>
)}
. You may resubscribe below if you wish.
</Text>
);
}
if (isUserSubscriptionExpired) {
return (
<Text className="mt-1">
Your{' '}
<span className="font-bold">
Standard Notes{userSubscriptionName ? ' ' : ''}
{userSubscriptionName}
</span>{' '}
subscription{' '}
<span className="font-bold">expired on {expirationDateString}</span>.
You may resubscribe below if you wish.
</Text>
);
}
return (
<Text className="mt-1">
Your{' '}
<span className="font-bold">
Standard Notes{userSubscriptionName ? ' ' : ''}
{userSubscriptionName}
</span>{' '}
subscription will be{' '}
<span className="font-bold">renewed on {expirationDateString}</span>.
</Text>
);
});
export const SubscriptionInformation = observer(
({ subscriptionState, application }: Props) => {
const manageSubscription = async () => {
openSubscriptionDashboard(application!);
};
return (
<>
<StatusText subscriptionState={subscriptionState} />
<Button
className="min-w-20 mt-3 mr-3"
type="normal"
label="Manage subscription"
onClick={manageSubscription}
/>
</>
);
}
);

View File

@@ -0,0 +1,233 @@
import { isDesktopApplication } from '@/utils';
import { alertDialog } from '@Services/alertService';
import {
STRING_IMPORT_SUCCESS,
STRING_INVALID_IMPORT_FILE,
STRING_IMPORTING_ZIP_FILE,
STRING_UNSUPPORTED_BACKUP_FILE_VERSION,
StringImportError,
STRING_E2E_ENABLED,
STRING_LOCAL_ENC_ENABLED,
STRING_ENC_NOT_ENABLED,
} from '@/strings';
import { BackupFile } from '@standardnotes/snjs';
import { useCallback, useEffect, useRef, useState } from 'preact/hooks';
import { WebApplication } from '@/ui_models/application';
import { JSXInternal } from 'preact/src/jsx';
import TargetedEvent = JSXInternal.TargetedEvent;
import { AppState } from '@/ui_models/app_state';
import { observer } from 'mobx-react-lite';
import {
PreferencesGroup,
PreferencesSegment,
Title,
Text,
Subtitle,
} from '../../components';
import { Button } from '@/components/Button';
type Props = {
application: WebApplication;
appState: AppState;
};
export const DataBackups = observer(({ application, appState }: Props) => {
const fileInputRef = useRef<HTMLInputElement>(null);
const [isImportDataLoading, setIsImportDataLoading] = useState(false);
const {
isBackupEncrypted,
isEncryptionEnabled,
setIsBackupEncrypted,
setIsEncryptionEnabled,
setEncryptionStatusString,
} = appState.accountMenu;
const refreshEncryptionStatus = useCallback(() => {
const hasUser = application.hasAccount();
const hasPasscode = application.hasPasscode();
const encryptionEnabled = hasUser || hasPasscode;
const encryptionStatusString = hasUser
? STRING_E2E_ENABLED
: hasPasscode
? STRING_LOCAL_ENC_ENABLED
: STRING_ENC_NOT_ENABLED;
setEncryptionStatusString(encryptionStatusString);
setIsEncryptionEnabled(encryptionEnabled);
setIsBackupEncrypted(encryptionEnabled);
}, [
application,
setEncryptionStatusString,
setIsBackupEncrypted,
setIsEncryptionEnabled,
]);
useEffect(() => {
refreshEncryptionStatus();
}, [refreshEncryptionStatus]);
const downloadDataArchive = () => {
application.getArchiveService().downloadBackup(isBackupEncrypted);
};
const readFile = async (file: File): Promise<any> => {
if (file.type === 'application/zip') {
application.alertService.alert(STRING_IMPORTING_ZIP_FILE);
return;
}
return new Promise((resolve) => {
const reader = new FileReader();
reader.onload = (e) => {
try {
const data = JSON.parse(e.target!.result as string);
resolve(data);
} catch (e) {
application.alertService.alert(STRING_INVALID_IMPORT_FILE);
}
};
reader.readAsText(file);
});
};
const performImport = async (data: BackupFile) => {
setIsImportDataLoading(true);
const result = await application.importData(data);
setIsImportDataLoading(false);
if (!result) {
return;
}
let statusText = STRING_IMPORT_SUCCESS;
if ('error' in result) {
statusText = result.error;
} else if (result.errorCount) {
statusText = StringImportError(result.errorCount);
}
void alertDialog({
text: statusText,
});
};
const importFileSelected = async (
event: TargetedEvent<HTMLInputElement, Event>
) => {
const { files } = event.target as HTMLInputElement;
if (!files) {
return;
}
const file = files[0];
const data = await readFile(file);
if (!data) {
return;
}
const version =
data.version || data.keyParams?.version || data.auth_params?.version;
if (!version) {
await performImport(data);
return;
}
if (application.protocolService.supportedVersions().includes(version)) {
await performImport(data);
} else {
setIsImportDataLoading(false);
void alertDialog({ text: STRING_UNSUPPORTED_BACKUP_FILE_VERSION });
}
};
// Whenever "Import Backup" is either clicked or key-pressed, proceed the import
const handleImportFile = (
event: TargetedEvent<HTMLSpanElement, Event> | KeyboardEvent
) => {
if (event instanceof KeyboardEvent) {
const { code } = event;
// Process only when "Enter" or "Space" keys are pressed
if (code !== 'Enter' && code !== 'Space') {
return;
}
// Don't proceed the event's default action
// (like scrolling in case the "space" key is pressed)
event.preventDefault();
}
(fileInputRef.current as HTMLInputElement).click();
};
return (
<>
<PreferencesGroup>
<PreferencesSegment>
<Title>Data Backups</Title>
{!isDesktopApplication() && (
<Text className="mb-3">
Backups are automatically created on desktop and can be managed
via the "Backups" top-level menu.
</Text>
)}
<Subtitle>Download a backup of all your data</Subtitle>
{isEncryptionEnabled && (
<form className="sk-panel-form sk-panel-row">
<div className="sk-input-group">
<label className="sk-horizontal-group tight">
<input
type="radio"
onChange={() => setIsBackupEncrypted(true)}
checked={isBackupEncrypted}
/>
<Subtitle>Encrypted</Subtitle>
</label>
<label className="sk-horizontal-group tight">
<input
type="radio"
onChange={() => setIsBackupEncrypted(false)}
checked={!isBackupEncrypted}
/>
<Subtitle>Decrypted</Subtitle>
</label>
</div>
</form>
)}
<Button
type="normal"
onClick={downloadDataArchive}
label="Download backup"
className="mt-2"
/>
</PreferencesSegment>
<PreferencesSegment>
<Subtitle>Import a previously saved backup file</Subtitle>
<div class="flex flex-row items-center mt-3">
<Button
type="normal"
label="Import Backup"
onClick={handleImportFile}
/>
<input
type="file"
ref={fileInputRef}
onChange={importFileSelected}
className="hidden"
/>
{isImportDataLoading && (
<div className="sk-spinner normal info ml-4" />
)}
</div>
</PreferencesSegment>
</PreferencesGroup>
</>
);
});

View File

@@ -0,0 +1,194 @@
import {
convertStringifiedBooleanToBoolean,
isDesktopApplication,
} from '@/utils';
import { STRING_FAILED_TO_UPDATE_USER_SETTING } from '@/strings';
import { useCallback, useEffect, useState } from 'preact/hooks';
import { WebApplication } from '@/ui_models/application';
import { observer } from 'mobx-react-lite';
import {
PreferencesGroup,
PreferencesSegment,
Subtitle,
Text,
Title,
} from '../../components';
import { EmailBackupFrequency, SettingName } from '@standardnotes/settings';
import { Dropdown, DropdownItem } from '@/components/Dropdown';
import { Switch } from '@/components/Switch';
import { HorizontalSeparator } from '@/components/Shared/HorizontalSeparator';
import { FeatureIdentifier } from '@standardnotes/features';
import { FeatureStatus } from '@standardnotes/snjs';
type Props = {
application: WebApplication;
};
export const EmailBackups = observer(({ application }: Props) => {
const [isLoading, setIsLoading] = useState(false);
const [emailFrequency, setEmailFrequency] = useState<EmailBackupFrequency>(
EmailBackupFrequency.Disabled
);
const [emailFrequencyOptions, setEmailFrequencyOptions] = useState<
DropdownItem[]
>([]);
const [isFailedBackupEmailMuted, setIsFailedBackupEmailMuted] =
useState(true);
const [isEntitledToEmailBackups, setIsEntitledToEmailBackups] =
useState(false);
const loadEmailFrequencySetting = useCallback(async () => {
if (!application.getUser()) {
return;
}
setIsLoading(true);
try {
const userSettings = await application.listSettings();
setEmailFrequency(
(userSettings.EMAIL_BACKUP_FREQUENCY ||
EmailBackupFrequency.Disabled) as EmailBackupFrequency
);
setIsFailedBackupEmailMuted(
convertStringifiedBooleanToBoolean(
userSettings[SettingName.MuteFailedBackupsEmails] as string
)
);
} catch (error) {
console.error(error);
} finally {
setIsLoading(false);
}
}, [application]);
useEffect(() => {
const emailBackupsFeatureStatus = application.features.getFeatureStatus(
FeatureIdentifier.DailyEmailBackup
);
setIsEntitledToEmailBackups(
emailBackupsFeatureStatus === FeatureStatus.Entitled
);
const frequencyOptions = [];
for (const frequency in EmailBackupFrequency) {
const frequencyValue =
EmailBackupFrequency[frequency as keyof typeof EmailBackupFrequency];
frequencyOptions.push({
value: frequencyValue,
label: application.getEmailBackupFrequencyOptionLabel(frequencyValue),
});
}
setEmailFrequencyOptions(frequencyOptions);
loadEmailFrequencySetting();
}, [application, loadEmailFrequencySetting]);
const updateSetting = async (
settingName: SettingName,
payload: string
): Promise<boolean> => {
try {
await application.updateSetting(settingName, payload);
return true;
} catch (e) {
application.alertService.alert(STRING_FAILED_TO_UPDATE_USER_SETTING);
return false;
}
};
const updateEmailFrequency = async (frequency: EmailBackupFrequency) => {
const previousFrequency = emailFrequency;
setEmailFrequency(frequency);
const updateResult = await updateSetting(
SettingName.EmailBackupFrequency,
frequency
);
if (!updateResult) {
setEmailFrequency(previousFrequency);
}
};
const toggleMuteFailedBackupEmails = async () => {
const previousValue = isFailedBackupEmailMuted;
setIsFailedBackupEmailMuted(!isFailedBackupEmailMuted);
const updateResult = await updateSetting(
SettingName.MuteFailedBackupsEmails,
`${!isFailedBackupEmailMuted}`
);
if (!updateResult) {
setIsFailedBackupEmailMuted(previousValue);
}
};
return (
<PreferencesGroup>
<PreferencesSegment>
<Title>Email Backups</Title>
{!isEntitledToEmailBackups && (
<>
<Text>
A <span className={'font-bold'}>Plus</span> or{' '}
<span className={'font-bold'}>Pro</span> subscription plan is
required to enable Email Backups.{' '}
<a target="_blank" href="https://standardnotes.com/features">
Learn more
</a>
.
</Text>
<HorizontalSeparator classes="mt-3 mb-3" />
</>
)}
<div
className={
isEntitledToEmailBackups
? ''
: 'faded cursor-default pointer-events-none'
}
>
{!isDesktopApplication() && (
<Text className="mb-3">
Daily encrypted email backups of your entire data set delivered
directly to your inbox.
</Text>
)}
<Subtitle>Email frequency</Subtitle>
<Text>How often to receive backups.</Text>
<div className="mt-2">
{isLoading ? (
<div className={'sk-spinner info small'} />
) : (
<Dropdown
id="def-editor-dropdown"
label="Select email frequency"
items={emailFrequencyOptions}
value={emailFrequency}
onChange={(item) => {
updateEmailFrequency(item as EmailBackupFrequency);
}}
/>
)}
</div>
<HorizontalSeparator classes="mt-5 mb-4" />
<Subtitle>Email preferences</Subtitle>
<div className="flex items-center justify-between">
<div className="flex flex-col">
<Text>
Receive a notification email if an email backup fails.
</Text>
</div>
{isLoading ? (
<div className={'sk-spinner info small'} />
) : (
<Switch
onChange={toggleMuteFailedBackupEmails}
checked={!isFailedBackupEmailMuted}
/>
)}
</div>
</div>
</PreferencesSegment>
</PreferencesGroup>
);
});

View File

@@ -0,0 +1,245 @@
import React from 'react';
import { useCallback, useEffect, useState } from 'preact/hooks';
import { ButtonType, SettingName } from '@standardnotes/snjs';
import {
CloudProvider,
DropboxBackupFrequency,
GoogleDriveBackupFrequency,
OneDriveBackupFrequency,
} from '@standardnotes/settings';
import { WebApplication } from '@/ui_models/application';
import { Button } from '@/components/Button';
import { isDev, openInNewTab } from '@/utils';
import { Subtitle } from '@/components/Preferences/components';
import { KeyboardKey } from '@Services/ioService';
import { FunctionComponent } from 'preact';
type Props = {
application: WebApplication;
providerName: CloudProvider;
isEntitledToCloudBackups: boolean;
};
export const CloudBackupProvider: FunctionComponent<Props> = ({
application,
providerName,
isEntitledToCloudBackups,
}) => {
const [authBegan, setAuthBegan] = useState(false);
const [successfullyInstalled, setSuccessfullyInstalled] = useState(false);
const [backupFrequency, setBackupFrequency] = useState<string | null>(null);
const [confirmation, setConfirmation] = useState('');
const disable = async (event: Event) => {
event.stopPropagation();
try {
const shouldDisable = await application.alertService.confirm(
'Are you sure you want to disable this integration?',
'Disable?',
'Disable',
ButtonType.Danger,
'Cancel'
);
if (shouldDisable) {
await application.deleteSetting(backupFrequencySettingName);
await application.deleteSetting(backupTokenSettingName);
setBackupFrequency(null);
}
} catch (error) {
application.alertService.alert(error as string);
}
};
const installIntegration = (event: Event) => {
event.stopPropagation();
const authUrl = application.getCloudProviderIntegrationUrl(
providerName,
isDev
);
openInNewTab(authUrl);
setAuthBegan(true);
};
const performBackupNow = async () => {
// A backup is performed anytime the setting is updated with the integration token, so just update it here
try {
await application.updateSetting(
backupFrequencySettingName,
backupFrequency as string
);
application.alertService.alert(
'A backup has been triggered for this provider. Please allow a couple minutes for your backup to be processed.'
);
} catch (err) {
application.alertService.alert(
'There was an error while trying to trigger a backup for this provider. Please try again.'
);
}
};
const backupSettingsData = {
[CloudProvider.Dropbox]: {
backupTokenSettingName: SettingName.DropboxBackupToken,
backupFrequencySettingName: SettingName.DropboxBackupFrequency,
defaultBackupFrequency: DropboxBackupFrequency.Daily,
},
[CloudProvider.Google]: {
backupTokenSettingName: SettingName.GoogleDriveBackupToken,
backupFrequencySettingName: SettingName.GoogleDriveBackupFrequency,
defaultBackupFrequency: GoogleDriveBackupFrequency.Daily,
},
[CloudProvider.OneDrive]: {
backupTokenSettingName: SettingName.OneDriveBackupToken,
backupFrequencySettingName: SettingName.OneDriveBackupFrequency,
defaultBackupFrequency: OneDriveBackupFrequency.Daily,
},
};
const {
backupTokenSettingName,
backupFrequencySettingName,
defaultBackupFrequency,
} = backupSettingsData[providerName];
const getCloudProviderIntegrationTokenFromUrl = (url: URL) => {
const urlSearchParams = new URLSearchParams(url.search);
let integrationTokenKeyInUrl = '';
switch (providerName) {
case CloudProvider.Dropbox:
integrationTokenKeyInUrl = 'dbt';
break;
case CloudProvider.Google:
integrationTokenKeyInUrl = 'key';
break;
case CloudProvider.OneDrive:
integrationTokenKeyInUrl = 'key';
break;
default:
throw new Error('Invalid Cloud Provider name');
}
return urlSearchParams.get(integrationTokenKeyInUrl);
};
const handleKeyPress = async (event: KeyboardEvent) => {
if (event.key === KeyboardKey.Enter) {
try {
const decryptedCode = atob(confirmation);
const urlFromDecryptedCode = new URL(decryptedCode);
const cloudProviderToken =
getCloudProviderIntegrationTokenFromUrl(urlFromDecryptedCode);
if (!cloudProviderToken) {
throw new Error();
}
await application.updateSetting(
backupTokenSettingName,
cloudProviderToken
);
await application.updateSetting(
backupFrequencySettingName,
defaultBackupFrequency
);
setBackupFrequency(defaultBackupFrequency);
setAuthBegan(false);
setSuccessfullyInstalled(true);
setConfirmation('');
await application.alertService.alert(
`${providerName} has been successfully installed. Your first backup has also been queued and should be reflected in your external cloud's folder within the next few minutes.`
);
} catch (e) {
await application.alertService.alert('Invalid code. Please try again.');
}
}
};
const handleChange = (event: Event) => {
setConfirmation((event.target as HTMLInputElement).value);
};
const getIntegrationStatus = useCallback(async () => {
if (!application.getUser()) {
return;
}
const frequency = await application.getSetting(backupFrequencySettingName);
setBackupFrequency(frequency);
}, [application, backupFrequencySettingName]);
useEffect(() => {
getIntegrationStatus();
}, [getIntegrationStatus]);
const isExpanded = authBegan || successfullyInstalled;
const shouldShowEnableButton = !backupFrequency && !authBegan;
const additionalClass = isEntitledToCloudBackups
? ''
: 'faded cursor-default pointer-events-none';
return (
<div
className={`mr-1 ${isExpanded ? 'expanded' : ' '} ${
shouldShowEnableButton || backupFrequency
? 'flex justify-between items-center'
: ''
}`}
>
<div>
<Subtitle className={additionalClass}>{providerName}</Subtitle>
{successfullyInstalled && (
<p>{providerName} has been successfully enabled.</p>
)}
</div>
{authBegan && (
<div>
<p className="sk-panel-row">
Complete authentication from the newly opened window. Upon
completion, a confirmation code will be displayed. Enter this code
below:
</p>
<div className={`mt-1`}>
<input
className="sk-input sk-base center-text"
placeholder="Enter confirmation code"
value={confirmation}
onKeyPress={handleKeyPress}
onChange={handleChange}
/>
</div>
</div>
)}
{shouldShowEnableButton && (
<div>
<Button
type="normal"
label="Enable"
className={`px-1 text-xs min-w-40 ${additionalClass}`}
onClick={installIntegration}
/>
</div>
)}
{backupFrequency && (
<div className={'flex flex-col items-end'}>
<Button
className={`min-w-40 mb-2 ${additionalClass}`}
type="normal"
label="Perform Backup"
onClick={performBackupNow}
/>
<Button
className="min-w-40"
type="normal"
label="Disable"
onClick={disable}
/>
</div>
)}
</div>
);
};

View File

@@ -0,0 +1,176 @@
import React from 'react';
import { CloudBackupProvider } from './CloudBackupProvider';
import { useCallback, useEffect, useState } from 'preact/hooks';
import { WebApplication } from '@/ui_models/application';
import {
PreferencesGroup,
PreferencesSegment,
Subtitle,
Text,
Title,
} from '@/components/Preferences/components';
import { HorizontalSeparator } from '@/components/Shared/HorizontalSeparator';
import { FeatureIdentifier } from '@standardnotes/features';
import { FeatureStatus } from '@standardnotes/snjs';
import { FunctionComponent } from 'preact';
import { CloudProvider, SettingName } from '@standardnotes/settings';
import { Switch } from '@/components/Switch';
import { convertStringifiedBooleanToBoolean } from '@/utils';
import { STRING_FAILED_TO_UPDATE_USER_SETTING } from '@/strings';
const providerData = [
{
name: CloudProvider.Dropbox,
},
{
name: CloudProvider.Google,
},
{
name: CloudProvider.OneDrive,
},
];
type Props = {
application: WebApplication;
};
export const CloudLink: FunctionComponent<Props> = ({ application }) => {
const [isEntitledToCloudBackups, setIsEntitledToCloudBackups] =
useState(false);
const [isFailedCloudBackupEmailMuted, setIsFailedCloudBackupEmailMuted] =
useState(true);
const [isLoading, setIsLoading] = useState(false);
const additionalClass = isEntitledToCloudBackups
? ''
: 'faded cursor-default pointer-events-none';
const loadIsFailedCloudBackupEmailMutedSetting = useCallback(async () => {
if (!application.getUser()) {
return;
}
setIsLoading(true);
try {
const userSettings = await application.listSettings();
setIsFailedCloudBackupEmailMuted(
convertStringifiedBooleanToBoolean(
userSettings[SettingName.MuteFailedCloudBackupsEmails] as string
)
);
} catch (error) {
console.error(error);
} finally {
setIsLoading(false);
}
}, [application]);
useEffect(() => {
const dailyDropboxBackupStatus = application.features.getFeatureStatus(
FeatureIdentifier.DailyDropboxBackup
);
const dailyGdriveBackupStatus = application.features.getFeatureStatus(
FeatureIdentifier.DailyGDriveBackup
);
const dailyOneDriveBackupStatus = application.features.getFeatureStatus(
FeatureIdentifier.DailyOneDriveBackup
);
const isCloudBackupsAllowed = [
dailyDropboxBackupStatus,
dailyGdriveBackupStatus,
dailyOneDriveBackupStatus,
].every((status) => status === FeatureStatus.Entitled);
setIsEntitledToCloudBackups(isCloudBackupsAllowed);
loadIsFailedCloudBackupEmailMutedSetting();
}, [application, loadIsFailedCloudBackupEmailMutedSetting]);
const updateSetting = async (
settingName: SettingName,
payload: string
): Promise<boolean> => {
try {
await application.updateSetting(settingName, payload);
return true;
} catch (e) {
application.alertService.alert(STRING_FAILED_TO_UPDATE_USER_SETTING);
return false;
}
};
const toggleMuteFailedCloudBackupEmails = async () => {
const previousValue = isFailedCloudBackupEmailMuted;
setIsFailedCloudBackupEmailMuted(!isFailedCloudBackupEmailMuted);
const updateResult = await updateSetting(
SettingName.MuteFailedCloudBackupsEmails,
`${!isFailedCloudBackupEmailMuted}`
);
if (!updateResult) {
setIsFailedCloudBackupEmailMuted(previousValue);
}
};
return (
<PreferencesGroup>
<PreferencesSegment>
<Title>Cloud Backups</Title>
{!isEntitledToCloudBackups && (
<>
<Text>
A <span className={'font-bold'}>Plus</span> or{' '}
<span className={'font-bold'}>Pro</span> subscription plan is
required to enable Cloud Backups.{' '}
<a target="_blank" href="https://standardnotes.com/features">
Learn more
</a>
.
</Text>
<HorizontalSeparator classes="mt-3 mb-3" />
</>
)}
<div>
<Text className={additionalClass}>
Configure the integrations below to enable automatic daily backups
of your encrypted data set to your third-party cloud provider.
</Text>
<div>
<HorizontalSeparator classes={`mt-3 mb-3 ${additionalClass}`} />
<div>
{providerData.map(({ name }) => (
<>
<CloudBackupProvider
application={application}
providerName={name}
isEntitledToCloudBackups={isEntitledToCloudBackups}
/>
<HorizontalSeparator
classes={`mt-3 mb-3 ${additionalClass}`}
/>
</>
))}
</div>
</div>
<div className={additionalClass}>
<Subtitle>Email preferences</Subtitle>
<div className="flex items-center justify-between mt-1">
<div className="flex flex-col">
<Text>
Receive a notification email if a cloud backup fails.
</Text>
</div>
{isLoading ? (
<div className={'sk-spinner info small'} />
) : (
<Switch
onChange={toggleMuteFailedCloudBackupEmails}
checked={!isFailedCloudBackupEmailMuted}
/>
)}
</div>
</div>
</div>
</PreferencesSegment>
</PreferencesGroup>
);
};

View File

@@ -0,0 +1,3 @@
export * from './DataBackups';
export * from './EmailBackups';
export * from './cloud-backups';

View File

@@ -0,0 +1,75 @@
import { displayStringForContentType, SNComponent } from '@standardnotes/snjs';
import { Button } from '@/components/Button';
import { FunctionComponent } from 'preact';
import { Title, Text, Subtitle, PreferencesSegment } from '../../components';
export const ConfirmCustomExtension: FunctionComponent<{
component: SNComponent;
callback: (confirmed: boolean) => void;
}> = ({ component, callback }) => {
const fields = [
{
label: 'Name',
value: component.package_info.name,
},
{
label: 'Description',
value: component.package_info.description,
},
{
label: 'Version',
value: component.package_info.version,
},
{
label: 'Hosted URL',
value: component.thirdPartyPackageInfo.url,
},
{
label: 'Download URL',
value: component.package_info.download_url,
},
{
label: 'Extension Type',
value: displayStringForContentType(component.content_type),
},
];
return (
<PreferencesSegment>
<Title>Confirm Extension</Title>
{fields.map((field) => {
if (!field.value) {
return undefined;
}
return (
<>
<Subtitle>{field.label}</Subtitle>
<Text className={'wrap'}>{field.value}</Text>
<div className="min-h-2" />
</>
);
})}
<div className="min-h-3" />
<div className="flex flex-row">
<Button
className="min-w-20"
type="normal"
label="Cancel"
onClick={() => callback(false)}
/>
<div className="min-w-3" />
<Button
className="min-w-20"
type="normal"
label="Install"
onClick={() => callback(true)}
/>
</div>
</PreferencesSegment>
);
};

View File

@@ -0,0 +1,115 @@
import { FunctionComponent } from 'preact';
import { SNComponent } from '@standardnotes/snjs';
import {
PreferencesSegment,
SubtitleLight,
Title,
} from '@/components/Preferences/components';
import { Switch } from '@/components/Switch';
import { WebApplication } from '@/ui_models/application';
import { useState } from 'preact/hooks';
import { Button } from '@/components/Button';
import { RenameExtension } from './RenameExtension';
const UseHosted: FunctionComponent<{
offlineOnly: boolean;
toggleOfllineOnly: () => void;
}> = ({ offlineOnly, toggleOfllineOnly }) => (
<div className="flex flex-row">
<SubtitleLight className="flex-grow">
Use hosted when local is unavailable
</SubtitleLight>
<Switch onChange={toggleOfllineOnly} checked={!offlineOnly} />
</div>
);
export interface ExtensionItemProps {
application: WebApplication;
extension: SNComponent;
first: boolean;
latestVersion: string | undefined;
uninstall: (extension: SNComponent) => void;
toggleActivate?: (extension: SNComponent) => void;
}
export const ExtensionItem: FunctionComponent<ExtensionItemProps> = ({
application,
extension,
first,
uninstall,
}) => {
const [offlineOnly, setOfflineOnly] = useState(
extension.offlineOnly ?? false
);
const [extensionName, setExtensionName] = useState(extension.name);
const toggleOffllineOnly = () => {
const newOfflineOnly = !offlineOnly;
setOfflineOnly(newOfflineOnly);
application
.changeAndSaveItem(extension.uuid, (m: any) => {
if (m.content == undefined) m.content = {};
m.content.offlineOnly = newOfflineOnly;
})
.then((item) => {
const component = item as SNComponent;
setOfflineOnly(component.offlineOnly);
})
.catch((e) => {
console.error(e);
});
};
const changeExtensionName = (newName: string) => {
setExtensionName(newName);
application
.changeAndSaveItem(extension.uuid, (m: any) => {
if (m.content == undefined) m.content = {};
m.content.name = newName;
})
.then((item) => {
const component = item as SNComponent;
setExtensionName(component.name);
});
};
const localInstallable = extension.package_info.download_url;
const isThirParty = application.features.isThirdPartyFeature(
extension.identifier
);
return (
<PreferencesSegment classes={'mb-5'}>
{first && (
<>
<Title>Extensions</Title>
</>
)}
<RenameExtension
extensionName={extensionName}
changeName={changeExtensionName}
/>
<div className="min-h-2" />
{isThirParty && localInstallable && (
<UseHosted
offlineOnly={offlineOnly}
toggleOfllineOnly={toggleOffllineOnly}
/>
)}
<>
<div className="min-h-2" />
<div className="flex flex-row">
<Button
className="min-w-20"
type="normal"
label="Uninstall"
onClick={() => uninstall(extension)}
/>
</div>
</>
</PreferencesSegment>
);
};

View File

@@ -0,0 +1,52 @@
import { WebApplication } from '@/ui_models/application';
import { FeatureDescription } from '@standardnotes/features';
import { SNComponent, ClientDisplayableError } from '@standardnotes/snjs';
import { makeAutoObservable, observable } from 'mobx';
export class ExtensionsLatestVersions {
static async load(
application: WebApplication
): Promise<ExtensionsLatestVersions | undefined> {
const response = await application.getAvailableSubscriptions();
if (response instanceof ClientDisplayableError) {
return undefined;
}
const versionMap: Map<string, string> = new Map();
collectFeatures(
response.CORE_PLAN?.features as FeatureDescription[],
versionMap
);
collectFeatures(
response.PLUS_PLAN?.features as FeatureDescription[],
versionMap
);
collectFeatures(
response.PRO_PLAN?.features as FeatureDescription[],
versionMap
);
return new ExtensionsLatestVersions(versionMap);
}
constructor(private readonly latestVersionsMap: Map<string, string>) {
makeAutoObservable<ExtensionsLatestVersions, 'latestVersionsMap'>(this, {
latestVersionsMap: observable.ref,
});
}
getVersion(extension: SNComponent): string | undefined {
return this.latestVersionsMap.get(extension.package_info.identifier);
}
}
function collectFeatures(
features: FeatureDescription[] | undefined,
versionMap: Map<string, string>
) {
if (features == undefined) return;
for (const feature of features) {
versionMap.set(feature.identifier, feature.version!);
}
}

View File

@@ -0,0 +1,69 @@
import { FunctionComponent } from 'preact';
import { useState, useRef, useEffect } from 'preact/hooks';
export const RenameExtension: FunctionComponent<{
extensionName: string;
changeName: (newName: string) => void;
}> = ({ extensionName, changeName }) => {
const [isRenaming, setIsRenaming] = useState(false);
const [newExtensionName, setNewExtensionName] =
useState<string>(extensionName);
const inputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
if (isRenaming) {
inputRef.current!.focus();
}
}, [inputRef, isRenaming]);
const startRenaming = () => {
setNewExtensionName(extensionName);
setIsRenaming(true);
};
const cancelRename = () => {
setNewExtensionName(extensionName);
setIsRenaming(false);
};
const confirmRename = () => {
if (!newExtensionName) {
return;
}
changeName(newExtensionName);
setIsRenaming(false);
};
return (
<div className="flex flex-row mr-3 items-center">
<input
ref={inputRef}
disabled={!isRenaming}
autocomplete="off"
className="flex-grow text-base font-bold no-border bg-default px-0 color-text"
type="text"
value={newExtensionName}
onChange={({ target: input }) =>
setNewExtensionName((input as HTMLInputElement)?.value)
}
/>
<div className="min-w-3" />
{isRenaming ? (
<>
<a className="pt-1 cursor-pointer" onClick={confirmRename}>
Confirm
</a>
<div className="min-w-3" />
<a className="pt-1 cursor-pointer" onClick={cancelRename}>
Cancel
</a>
</>
) : (
<a className="pt-1 cursor-pointer" onClick={startRenaming}>
Rename
</a>
)}
</div>
);
};

View File

@@ -0,0 +1,3 @@
export * from './ConfirmCustomExtension';
export * from './ExtensionItem';
export * from './ExtensionsLatestVersions';

View File

@@ -0,0 +1,154 @@
import { Dropdown, DropdownItem } from '@/components/Dropdown';
import { FeatureIdentifier, PrefKey } from '@standardnotes/snjs';
import {
PreferencesGroup,
PreferencesSegment,
Subtitle,
Text,
Title,
} from '@/components/Preferences/components';
import { WebApplication } from '@/ui_models/application';
import {
ComponentArea,
ComponentMutator,
SNComponent,
} from '@standardnotes/snjs';
import { FunctionComponent } from 'preact';
import { useEffect, useState } from 'preact/hooks';
import { HorizontalSeparator } from '@/components/Shared/HorizontalSeparator';
import { Switch } from '@/components/Switch';
type Props = {
application: WebApplication;
};
type EditorOption = DropdownItem & {
value: FeatureIdentifier | 'plain-editor';
};
const makeEditorDefault = (
application: WebApplication,
component: SNComponent,
currentDefault: SNComponent
) => {
if (currentDefault) {
removeEditorDefault(application, currentDefault);
}
application.changeAndSaveItem(component.uuid, (m) => {
const mutator = m as ComponentMutator;
mutator.defaultEditor = true;
});
};
const removeEditorDefault = (
application: WebApplication,
component: SNComponent
) => {
application.changeAndSaveItem(component.uuid, (m) => {
const mutator = m as ComponentMutator;
mutator.defaultEditor = false;
});
};
const getDefaultEditor = (application: WebApplication) => {
return application.componentManager
.componentsForArea(ComponentArea.Editor)
.filter((e) => e.isDefaultEditor())[0];
};
export const Defaults: FunctionComponent<Props> = ({ application }) => {
const [editorItems, setEditorItems] = useState<DropdownItem[]>([]);
const [defaultEditorValue, setDefaultEditorValue] = useState(
() =>
getDefaultEditor(application)?.package_info?.identifier || 'plain-editor'
);
const [spellcheck, setSpellcheck] = useState(() =>
application.getPreference(PrefKey.EditorSpellcheck, true)
);
const toggleSpellcheck = () => {
setSpellcheck(!spellcheck);
application.getAppState().toggleGlobalSpellcheck();
};
useEffect(() => {
const editors = application.componentManager
.componentsForArea(ComponentArea.Editor)
.map((editor): EditorOption => {
const identifier = editor.package_info.identifier;
const [iconType, tint] =
application.iconsController.getIconAndTintForEditor(identifier);
return {
label: editor.name,
value: identifier,
...(iconType ? { icon: iconType } : null),
...(tint ? { iconClassName: `color-accessory-tint-${tint}` } : null),
};
})
.concat([
{
icon: 'plain-text',
iconClassName: `color-accessory-tint-1`,
label: 'Plain Editor',
value: 'plain-editor',
},
])
.sort((a, b) => {
return a.label.toLowerCase() < b.label.toLowerCase() ? -1 : 1;
});
setEditorItems(editors);
}, [application]);
const setDefaultEditor = (value: string) => {
setDefaultEditorValue(value as FeatureIdentifier);
const editors = application.componentManager.componentsForArea(
ComponentArea.Editor
);
const currentDefault = getDefaultEditor(application);
if (value !== 'plain-editor') {
const editorComponent = editors.filter(
(e) => e.package_info.identifier === value
)[0];
makeEditorDefault(application, editorComponent, currentDefault);
} else {
removeEditorDefault(application, currentDefault);
}
};
return (
<PreferencesGroup>
<PreferencesSegment>
<Title>Defaults</Title>
<div>
<Subtitle>Default Editor</Subtitle>
<Text>New notes will be created using this editor.</Text>
<div className="mt-2">
<Dropdown
id="def-editor-dropdown"
label="Select the default editor"
items={editorItems}
value={defaultEditorValue}
onChange={setDefaultEditor}
/>
</div>
</div>
<HorizontalSeparator classes="mt-5 mb-3" />
<div className="flex items-center justify-between">
<div className="flex flex-col">
<Subtitle>Spellcheck</Subtitle>
<Text>
The default spellcheck value for new notes. Spellcheck can be
configured per note from the note context menu. Spellcheck may
degrade overall typing performance with long notes.
</Text>
</div>
<Switch onChange={toggleSpellcheck} checked={spellcheck} />
</div>
</PreferencesSegment>
</PreferencesGroup>
);
};

View File

@@ -0,0 +1,112 @@
import { FindNativeFeature } from '@standardnotes/features';
import { Switch } from '@/components/Switch';
import {
PreferencesGroup,
PreferencesSegment,
Subtitle,
Text,
Title,
} from '@/components/Preferences/components';
import { WebApplication } from '@/ui_models/application';
import { FeatureIdentifier, FeatureStatus } from '@standardnotes/snjs';
import { FunctionComponent } from 'preact';
import { useCallback, useEffect, useState } from 'preact/hooks';
import { usePremiumModal } from '@/components/Premium';
import { HorizontalSeparator } from '@/components/Shared/HorizontalSeparator';
type ExperimentalFeatureItem = {
identifier: FeatureIdentifier;
name: string;
description: string;
isEnabled: boolean;
isEntitled: boolean;
};
type Props = {
application: WebApplication;
};
export const LabsPane: FunctionComponent<Props> = ({ application }) => {
const [experimentalFeatures, setExperimentalFeatures] = useState<
ExperimentalFeatureItem[]
>([]);
const reloadExperimentalFeatures = useCallback(() => {
const experimentalFeatures = application.features
.getExperimentalFeatures()
.map((featureIdentifier) => {
const feature = FindNativeFeature(featureIdentifier);
return {
identifier: featureIdentifier,
name: feature?.name ?? featureIdentifier,
description: feature?.description ?? '',
isEnabled:
application.features.isExperimentalFeatureEnabled(
featureIdentifier
),
isEntitled:
application.features.getFeatureStatus(featureIdentifier) ===
FeatureStatus.Entitled,
};
});
setExperimentalFeatures(experimentalFeatures);
}, [application.features]);
useEffect(() => {
reloadExperimentalFeatures();
}, [reloadExperimentalFeatures]);
const premiumModal = usePremiumModal();
return (
<PreferencesGroup>
<PreferencesSegment>
<Title>Labs</Title>
<div>
{experimentalFeatures.map(
(
{ identifier, name, description, isEnabled, isEntitled },
index: number
) => {
const toggleFeature = () => {
if (!isEntitled) {
premiumModal.activate(name);
return;
}
application.features.toggleExperimentalFeature(identifier);
reloadExperimentalFeatures();
};
const showHorizontalSeparator =
experimentalFeatures.length > 1 &&
index !== experimentalFeatures.length - 1;
return (
<>
<div className="flex items-center justify-between">
<div className="flex flex-col">
<Subtitle>{name}</Subtitle>
<Text>{description}</Text>
</div>
<Switch onChange={toggleFeature} checked={isEnabled} />
</div>
{showHorizontalSeparator && (
<HorizontalSeparator classes="mt-5 mb-3" />
)}
</>
);
}
)}
{experimentalFeatures.length === 0 && (
<div className="flex items-center justify-between">
<div className="flex flex-col">
<Text>No experimental features available.</Text>
</div>
</div>
)}
</div>
</PreferencesSegment>
</PreferencesGroup>
);
};

View File

@@ -0,0 +1,67 @@
import { HorizontalSeparator } from '@/components/Shared/HorizontalSeparator';
import { Switch } from '@/components/Switch';
import {
PreferencesGroup,
PreferencesSegment,
Subtitle,
Text,
Title,
} from '@/components/Preferences/components';
import { WebApplication } from '@/ui_models/application';
import { PrefKey } from '@standardnotes/snjs';
import { observer } from 'mobx-react-lite';
import { FunctionalComponent } from 'preact';
import { useState } from 'preact/hooks';
type Props = {
application: WebApplication;
};
export const Tools: FunctionalComponent<Props> = observer(
({ application }: Props) => {
const [monospaceFont, setMonospaceFont] = useState(() =>
application.getPreference(PrefKey.EditorMonospaceEnabled, true)
);
const [marginResizers, setMarginResizers] = useState(() =>
application.getPreference(PrefKey.EditorResizersEnabled, true)
);
const toggleMonospaceFont = () => {
setMonospaceFont(!monospaceFont);
application.setPreference(PrefKey.EditorMonospaceEnabled, !monospaceFont);
};
const toggleMarginResizers = () => {
setMarginResizers(!marginResizers);
application.setPreference(PrefKey.EditorResizersEnabled, !marginResizers);
};
return (
<PreferencesGroup>
<PreferencesSegment>
<Title>Tools</Title>
<div>
<div className="flex items-center justify-between">
<div className="flex flex-col">
<Subtitle>Monospace Font</Subtitle>
<Text>Toggles the font style in the Plain Text editor.</Text>
</div>
<Switch onChange={toggleMonospaceFont} checked={monospaceFont} />
</div>
<HorizontalSeparator classes="mt-5 mb-3" />
<div className="flex items-center justify-between">
<div className="flex flex-col">
<Subtitle>Margin Resizers</Subtitle>
<Text>Allows left and right editor margins to be resized.</Text>
</div>
<Switch
onChange={toggleMarginResizers}
checked={marginResizers}
/>
</div>
</div>
</PreferencesSegment>
</PreferencesGroup>
);
}
);

View File

@@ -0,0 +1,3 @@
export * from './Tools';
export * from './Defaults';
export * from './Labs';

View File

@@ -0,0 +1,5 @@
export * from './HelpFeedback';
export * from './Security';
export * from './AccountPreferences';
export * from './Listed';
export * from './General';

View File

@@ -0,0 +1,56 @@
import { HorizontalSeparator } from '@/components/Shared/HorizontalSeparator';
import { LinkButton, Subtitle } from '@/components/Preferences/components';
import { WebApplication } from '@/ui_models/application';
import { ListedAccount, ListedAccountInfo } from '@standardnotes/snjs';
import { FunctionalComponent } from 'preact';
import { useEffect, useState } from 'preact/hooks';
type Props = {
account: ListedAccount;
showSeparator: boolean;
application: WebApplication;
};
export const ListedAccountItem: FunctionalComponent<Props> = ({
account,
showSeparator,
application,
}) => {
const [isLoading, setIsLoading] = useState(false);
const [accountInfo, setAccountInfo] = useState<ListedAccountInfo>();
useEffect(() => {
const loadAccount = async () => {
setIsLoading(true);
const info = await application.getListedAccountInfo(account);
setAccountInfo(info);
setIsLoading(false);
};
loadAccount();
}, [account, application]);
return (
<>
<Subtitle className="em">{accountInfo?.display_name}</Subtitle>
<div className="mb-2" />
<div className="flex">
{isLoading ? <div className="sk-spinner small info"></div> : null}
{accountInfo && (
<>
<LinkButton
className="mr-2"
label="Open Blog"
link={accountInfo.author_url}
/>
<LinkButton
className="mr-2"
label="Settings"
link={accountInfo.settings_url}
/>
</>
)}
</div>
{showSeparator && <HorizontalSeparator classes="mt-5 mb-3" />}
</>
);
};

View File

@@ -0,0 +1,97 @@
import { DecoratedInput } from '@/components/DecoratedInput';
import { Icon } from '@/components/Icon';
import {
STRING_E2E_ENABLED,
STRING_ENC_NOT_ENABLED,
STRING_LOCAL_ENC_ENABLED,
} from '@/strings';
import { AppState } from '@/ui_models/app_state';
import { observer } from 'mobx-react-lite';
import { FunctionComponent } from 'preact';
import {
PreferencesGroup,
PreferencesSegment,
Text,
Title,
} from '../../components';
const formatCount = (count: number, itemType: string) =>
`${count} / ${count} ${itemType}`;
const EncryptionEnabled: FunctionComponent<{ appState: AppState }> = observer(
({ appState }) => {
const count = appState.accountMenu.structuredNotesAndTagsCount;
const notes = formatCount(count.notes, 'notes');
const tags = formatCount(count.tags, 'tags');
const archived = formatCount(count.archived, 'archived notes');
const deleted = formatCount(count.deleted, 'trashed notes');
const checkIcon = (
<Icon className="success min-w-4 min-h-4" type="check-bold" />
);
const noteIcon = <Icon type="rich-text" className="min-w-5 min-h-5" />;
const tagIcon = <Icon type="hashtag" className="min-w-5 min-h-5" />;
const archiveIcon = <Icon type="archive" className="min-w-5 min-h-5" />;
const trashIcon = <Icon type="trash" className="min-w-5 min-h-5" />;
return (
<>
<div className="flex flex-row pb-1 pt-1.5">
<DecoratedInput
disabled={true}
text={notes}
right={[checkIcon]}
left={[noteIcon]}
/>
<div className="min-w-3" />
<DecoratedInput
disabled={true}
text={tags}
right={[checkIcon]}
left={[tagIcon]}
/>
</div>
<div className="flex flex-row">
<DecoratedInput
disabled={true}
text={archived}
right={[checkIcon]}
left={[archiveIcon]}
/>
<div className="min-w-3" />
<DecoratedInput
disabled={true}
text={deleted}
right={[checkIcon]}
left={[trashIcon]}
/>
</div>
</>
);
}
);
export const Encryption: FunctionComponent<{ appState: AppState }> = observer(
({ appState }) => {
const app = appState.application;
const hasUser = app.hasAccount();
const hasPasscode = app.hasPasscode();
const isEncryptionEnabled = app.isEncryptionAvailable();
const encryptionStatusString = hasUser
? STRING_E2E_ENABLED
: hasPasscode
? STRING_LOCAL_ENC_ENABLED
: STRING_ENC_NOT_ENABLED;
return (
<PreferencesGroup>
<PreferencesSegment>
<Title>Encryption</Title>
<Text>{encryptionStatusString}</Text>
{isEncryptionEnabled && <EncryptionEnabled appState={appState} />}
</PreferencesSegment>
</PreferencesGroup>
);
}
);

View File

@@ -0,0 +1,324 @@
import {
STRING_CONFIRM_APP_QUIT_DURING_PASSCODE_CHANGE,
STRING_CONFIRM_APP_QUIT_DURING_PASSCODE_REMOVAL,
STRING_E2E_ENABLED,
STRING_ENC_NOT_ENABLED,
STRING_LOCAL_ENC_ENABLED,
STRING_NON_MATCHING_PASSCODES,
StringUtils,
Strings,
} from '@/strings';
import { WebApplication } from '@/ui_models/application';
import { preventRefreshing } from '@/utils';
import { JSXInternal } from 'preact/src/jsx';
import TargetedEvent = JSXInternal.TargetedEvent;
import TargetedMouseEvent = JSXInternal.TargetedMouseEvent;
import { alertDialog } from '@Services/alertService';
import { useCallback, useEffect, useRef, useState } from 'preact/hooks';
import { ApplicationEvent } from '@standardnotes/snjs';
import { observer } from 'mobx-react-lite';
import { AppState } from '@/ui_models/app_state';
import {
PreferencesSegment,
Title,
Text,
PreferencesGroup,
} from '@/components/Preferences/components';
import { Button } from '@/components/Button';
type Props = {
application: WebApplication;
appState: AppState;
};
export const PasscodeLock = observer(({ application, appState }: Props) => {
const keyStorageInfo = StringUtils.keyStorageInfo(application);
const passcodeAutoLockOptions = application
.getAutolockService()
.getAutoLockIntervalOptions();
const {
setIsEncryptionEnabled,
setIsBackupEncrypted,
setEncryptionStatusString,
} = appState.accountMenu;
const passcodeInputRef = useRef<HTMLInputElement>(null);
const [passcode, setPasscode] = useState<string | undefined>(undefined);
const [passcodeConfirmation, setPasscodeConfirmation] = useState<
string | undefined
>(undefined);
const [selectedAutoLockInterval, setSelectedAutoLockInterval] =
useState<unknown>(null);
const [isPasscodeFocused, setIsPasscodeFocused] = useState(false);
const [showPasscodeForm, setShowPasscodeForm] = useState(false);
const [canAddPasscode, setCanAddPasscode] = useState(
!application.isEphemeralSession()
);
const [hasPasscode, setHasPasscode] = useState(application.hasPasscode());
const handleAddPassCode = () => {
setShowPasscodeForm(true);
setIsPasscodeFocused(true);
};
const changePasscodePressed = () => {
handleAddPassCode();
};
const reloadAutoLockInterval = useCallback(async () => {
const interval = await application
.getAutolockService()
.getAutoLockInterval();
setSelectedAutoLockInterval(interval);
}, [application]);
const refreshEncryptionStatus = useCallback(() => {
const hasUser = application.hasAccount();
const hasPasscode = application.hasPasscode();
setHasPasscode(hasPasscode);
const encryptionEnabled = hasUser || hasPasscode;
const encryptionStatusString = hasUser
? STRING_E2E_ENABLED
: hasPasscode
? STRING_LOCAL_ENC_ENABLED
: STRING_ENC_NOT_ENABLED;
setEncryptionStatusString(encryptionStatusString);
setIsEncryptionEnabled(encryptionEnabled);
setIsBackupEncrypted(encryptionEnabled);
}, [
application,
setEncryptionStatusString,
setIsBackupEncrypted,
setIsEncryptionEnabled,
]);
const selectAutoLockInterval = async (interval: number) => {
if (!(await application.authorizeAutolockIntervalChange())) {
return;
}
await application.getAutolockService().setAutoLockInterval(interval);
reloadAutoLockInterval();
};
const removePasscodePressed = async () => {
await preventRefreshing(
STRING_CONFIRM_APP_QUIT_DURING_PASSCODE_REMOVAL,
async () => {
if (await application.removePasscode()) {
await application.getAutolockService().deleteAutolockPreference();
await reloadAutoLockInterval();
refreshEncryptionStatus();
}
}
);
};
const handlePasscodeChange = (event: TargetedEvent<HTMLInputElement>) => {
const { value } = event.target as HTMLInputElement;
setPasscode(value);
};
const handleConfirmPasscodeChange = (
event: TargetedEvent<HTMLInputElement>
) => {
const { value } = event.target as HTMLInputElement;
setPasscodeConfirmation(value);
};
const submitPasscodeForm = async (
event:
| TargetedEvent<HTMLFormElement>
| TargetedMouseEvent<HTMLButtonElement>
) => {
event.preventDefault();
if (!passcode || passcode.length === 0) {
await alertDialog({
text: Strings.enterPasscode,
});
}
if (passcode !== passcodeConfirmation) {
await alertDialog({
text: STRING_NON_MATCHING_PASSCODES,
});
setIsPasscodeFocused(true);
return;
}
await preventRefreshing(
STRING_CONFIRM_APP_QUIT_DURING_PASSCODE_CHANGE,
async () => {
const successful = application.hasPasscode()
? await application.changePasscode(passcode as string)
: await application.addPasscode(passcode as string);
if (!successful) {
setIsPasscodeFocused(true);
}
}
);
setPasscode(undefined);
setPasscodeConfirmation(undefined);
setShowPasscodeForm(false);
refreshEncryptionStatus();
};
useEffect(() => {
refreshEncryptionStatus();
}, [refreshEncryptionStatus]);
// `reloadAutoLockInterval` gets interval asynchronously, therefore we call `useEffect` to set initial
// value of `selectedAutoLockInterval`
useEffect(() => {
reloadAutoLockInterval();
}, [reloadAutoLockInterval]);
useEffect(() => {
if (isPasscodeFocused) {
passcodeInputRef.current!.focus();
setIsPasscodeFocused(false);
}
}, [isPasscodeFocused]);
// Add the required event observers
useEffect(() => {
const removeKeyStatusChangedObserver = application.addEventObserver(
async () => {
setCanAddPasscode(!application.isEphemeralSession());
setHasPasscode(application.hasPasscode());
setShowPasscodeForm(false);
},
ApplicationEvent.KeyStatusChanged
);
return () => {
removeKeyStatusChangedObserver();
};
}, [application]);
return (
<>
<PreferencesGroup>
<PreferencesSegment>
<Title>Passcode Lock</Title>
{!hasPasscode && canAddPasscode && (
<>
<Text className="mb-3">
Add a passcode to lock the application and encrypt on-device key
storage.
</Text>
{keyStorageInfo && <Text className="mb-3">{keyStorageInfo}</Text>}
{!showPasscodeForm && (
<Button
label="Add Passcode"
onClick={handleAddPassCode}
type="primary"
/>
)}
</>
)}
{!hasPasscode && !canAddPasscode && (
<Text>
Adding a passcode is not supported in temporary sessions. Please
sign out, then sign back in with the "Stay signed in" option
checked.
</Text>
)}
{showPasscodeForm && (
<form className="sk-panel-form" onSubmit={submitPasscodeForm}>
<div className="sk-panel-row" />
<input
className="sk-input contrast"
type="password"
ref={passcodeInputRef}
value={passcode}
onChange={handlePasscodeChange}
placeholder="Passcode"
/>
<input
className="sk-input contrast"
type="password"
value={passcodeConfirmation}
onChange={handleConfirmPasscodeChange}
placeholder="Confirm Passcode"
/>
<div className="min-h-2" />
<Button
type="primary"
onClick={submitPasscodeForm}
label="Set Passcode"
className="mr-3"
/>
<Button
type="normal"
onClick={() => setShowPasscodeForm(false)}
label="Cancel"
/>
</form>
)}
{hasPasscode && !showPasscodeForm && (
<>
<Text>Passcode lock is enabled.</Text>
<div className="flex flex-row mt-3">
<Button
type="normal"
label="Change Passcode"
onClick={changePasscodePressed}
className="mr-3"
/>
<Button
type="danger"
label="Remove Passcode"
onClick={removePasscodePressed}
/>
</div>
</>
)}
</PreferencesSegment>
</PreferencesGroup>
{hasPasscode && (
<>
<div className="min-h-3" />
<PreferencesGroup>
<PreferencesSegment>
<Title>Autolock</Title>
<Text className="mb-3">
The autolock timer begins when the window or tab loses focus.
</Text>
<div className="flex flex-row items-center">
{passcodeAutoLockOptions.map((option) => {
return (
<a
className={`sk-a info mr-3 ${
option.value === selectedAutoLockInterval ? 'boxed' : ''
}`}
onClick={() => selectAutoLockInterval(option.value)}
>
{option.label}
</a>
);
})}
</div>
</PreferencesSegment>
</PreferencesGroup>
</>
)}
</>
);
});

View File

@@ -0,0 +1,118 @@
import { WebApplication } from '@/ui_models/application';
import { FunctionalComponent } from 'preact';
import { useCallback, useState } from 'preact/hooks';
import { useEffect } from 'preact/hooks';
import { ApplicationEvent } from '@standardnotes/snjs';
import { isSameDay } from '@/utils';
import {
PreferencesGroup,
PreferencesSegment,
Title,
Text,
} from '@/components/Preferences/components';
import { Button } from '@/components/Button';
type Props = {
application: WebApplication;
};
export const Protections: FunctionalComponent<Props> = ({ application }) => {
const enableProtections = () => {
application.clearProtectionSession();
};
const [hasProtections, setHasProtections] = useState(() =>
application.hasProtectionSources()
);
const getProtectionsDisabledUntil = useCallback((): string | null => {
const protectionExpiry = application.getProtectionSessionExpiryDate();
const now = new Date();
if (protectionExpiry > now) {
let f: Intl.DateTimeFormat;
if (isSameDay(protectionExpiry, now)) {
f = new Intl.DateTimeFormat(undefined, {
hour: 'numeric',
minute: 'numeric',
});
} else {
f = new Intl.DateTimeFormat(undefined, {
weekday: 'long',
day: 'numeric',
month: 'short',
hour: 'numeric',
minute: 'numeric',
});
}
return f.format(protectionExpiry);
}
return null;
}, [application]);
const [protectionsDisabledUntil, setProtectionsDisabledUntil] = useState(
getProtectionsDisabledUntil()
);
useEffect(() => {
const removeUnprotectedSessionBeginObserver = application.addEventObserver(
async () => {
setProtectionsDisabledUntil(getProtectionsDisabledUntil());
},
ApplicationEvent.UnprotectedSessionBegan
);
const removeUnprotectedSessionEndObserver = application.addEventObserver(
async () => {
setProtectionsDisabledUntil(getProtectionsDisabledUntil());
},
ApplicationEvent.UnprotectedSessionExpired
);
const removeKeyStatusChangedObserver = application.addEventObserver(
async () => {
setHasProtections(application.hasProtectionSources());
},
ApplicationEvent.KeyStatusChanged
);
return () => {
removeUnprotectedSessionBeginObserver();
removeUnprotectedSessionEndObserver();
removeKeyStatusChangedObserver();
};
}, [application, getProtectionsDisabledUntil]);
if (!hasProtections) {
return null;
}
return (
<PreferencesGroup>
<PreferencesSegment>
<Title>Protections</Title>
{protectionsDisabledUntil ? (
<Text className="info">
Unprotected access expires at {protectionsDisabledUntil}.
</Text>
) : (
<Text className="info">Protections are enabled.</Text>
)}
<Text className="mt-2">
Actions like viewing or searching protected notes, exporting decrypted
backups, or revoking an active session require additional
authentication such as entering your account password or application
passcode.
</Text>
{protectionsDisabledUntil && (
<Button
className="mt-3"
type="primary"
label="End Unprotected Access"
onClick={enableProtections}
/>
)}
</PreferencesSegment>
</PreferencesGroup>
);
};

View File

@@ -0,0 +1,3 @@
export * from './Encryption';
export * from './PasscodeLock';
export * from './Protections';

View File

@@ -0,0 +1,73 @@
import { Icon } from '@/components/Icon';
import {
Disclosure,
DisclosureButton,
DisclosurePanel,
} from '@reach/disclosure';
import { FunctionComponent } from 'preact';
import { MouseEventHandler } from 'react';
import { useState, useRef, useEffect } from 'preact/hooks';
import { IconType } from '@standardnotes/snjs';
const DisclosureIconButton: FunctionComponent<{
className?: string;
icon: IconType;
onMouseEnter?: MouseEventHandler;
onMouseLeave?: MouseEventHandler;
}> = ({ className = '', icon, onMouseEnter, onMouseLeave }) => (
<DisclosureButton
onMouseEnter={onMouseEnter}
onMouseLeave={onMouseLeave}
className={`no-border cursor-pointer bg-transparent hover:brightness-130 p-0 ${
className ?? ''
}`}
>
<Icon type={icon} />
</DisclosureButton>
);
/**
* AuthAppInfoPopup is an info icon that shows a tooltip when clicked
* Tooltip is dismissible by clicking outside
*
* Note: it can be generalized but more use cases are required
* @returns
*/
export const AuthAppInfoTooltip: FunctionComponent = () => {
const [isClicked, setClicked] = useState(false);
const [isHover, setHover] = useState(false);
const ref = useRef(null);
useEffect(() => {
const dismiss = () => setClicked(false);
document.addEventListener('mousedown', dismiss);
return () => {
document.removeEventListener('mousedown', dismiss);
};
}, [ref]);
return (
<Disclosure
open={isClicked || isHover}
onChange={() => setClicked(!isClicked)}
>
<div className="relative">
<DisclosureIconButton
icon="info"
className="mt-1"
onMouseEnter={() => setHover(true)}
onMouseLeave={() => setHover(false)}
/>
<DisclosurePanel>
<div
className={`bg-inverted-default color-inverted-default text-center rounded shadow-overlay
py-1.5 px-2 absolute w-103 -top-10 -left-51`}
>
Some apps, like Google Authenticator, do not back up and restore
your secret keys if you lose your device or get a new one.
</div>
</DisclosurePanel>
</div>
</Disclosure>
);
};

View File

@@ -0,0 +1,9 @@
import { FunctionComponent } from 'preact';
export const Bullet: FunctionComponent<{ className?: string }> = ({
className = '',
}) => (
<div
className={`min-w-1 min-h-1 rounded-full bg-inverted-default ${className} mr-2`}
/>
);

View File

@@ -0,0 +1,23 @@
import { FunctionComponent } from 'preact';
import { IconButton } from '../../../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);
}}
/>
);
};

View File

@@ -0,0 +1,6 @@
import { MfaProvider, UserProvider } from '../../providers';
export interface MfaProps {
userProvider: UserProvider;
mfaProvider: MfaProvider;
}

View File

@@ -0,0 +1,94 @@
import { Button } from '@/components/Button';
import { DecoratedInput } from '@/components/DecoratedInput';
import { IconButton } from '@/components/IconButton';
import { observer } from 'mobx-react-lite';
import { FunctionComponent } from 'preact';
import { CopyButton } from './CopyButton';
import { Bullet } from './Bullet';
import { downloadSecretKey } from './download-secret-key';
import { TwoFactorActivation } from './TwoFactorActivation';
import {
ModalDialog,
ModalDialogButtons,
ModalDialogDescription,
ModalDialogLabel,
} from '@/components/Shared/ModalDialog';
export const SaveSecretKey: FunctionComponent<{
activation: TwoFactorActivation;
}> = observer(({ activation: act }) => {
const download = (
<IconButton
focusable={false}
title="Download"
icon="download"
onClick={() => {
downloadSecretKey(act.secretKey);
}}
/>
);
return (
<ModalDialog>
<ModalDialogLabel
closeDialog={() => {
act.cancelActivation();
}}
>
Step 2 of 3 - Save secret key
</ModalDialogLabel>
<ModalDialogDescription className="h-33">
<div className="flex-grow flex flex-col">
<div className="flex flex-row items-center">
<Bullet />
<div className="min-w-1" />
<div className="text-sm">
<b>Save your secret key</b>{' '}
<a
target="_blank"
href="https://standardnotes.com/help/21/where-should-i-store-my-two-factor-authentication-secret-key"
>
somewhere safe
</a>
:
</div>
<div className="min-w-2" />
<DecoratedInput
disabled={true}
right={[<CopyButton copyValue={act.secretKey} />, download]}
text={act.secretKey}
/>
</div>
<div className="h-2" />
<div className="flex flex-row items-center">
<Bullet />
<div className="min-w-1" />
<div className="text-sm">
You can use this key to generate codes if you lose access to your
authenticator app.{' '}
<a
target="_blank"
href="https://standardnotes.com/help/22/what-happens-if-i-lose-my-2fa-device-and-my-secret-key"
>
Learn more
</a>
</div>
</div>
</div>
</ModalDialogDescription>
<ModalDialogButtons>
<Button
className="min-w-20"
type="normal"
label="Back"
onClick={() => act.openScanQRCode()}
/>
<Button
className="min-w-20"
type="primary"
label="Next"
onClick={() => act.openVerification()}
/>
</ModalDialogButtons>
</ModalDialog>
);
});

View File

@@ -0,0 +1,79 @@
import { FunctionComponent } from 'preact';
import { observer } from 'mobx-react-lite';
import QRCode from 'qrcode.react';
import { DecoratedInput } from '@/components/DecoratedInput';
import { Button } from '@/components/Button';
import { TwoFactorActivation } from './TwoFactorActivation';
import { AuthAppInfoTooltip } from './AuthAppInfoPopup';
import {
ModalDialog,
ModalDialogButtons,
ModalDialogDescription,
ModalDialogLabel,
} from '@/components/Shared/ModalDialog';
import { CopyButton } from './CopyButton';
import { Bullet } from './Bullet';
export const ScanQRCode: FunctionComponent<{
activation: TwoFactorActivation;
}> = observer(({ activation: act }) => {
return (
<ModalDialog>
<ModalDialogLabel closeDialog={act.cancelActivation}>
Step 1 of 3 - Scan QR code
</ModalDialogLabel>
<ModalDialogDescription className="h-33">
<div className="w-25 h-25 flex items-center justify-center bg-info">
<QRCode
className="border-neutral-contrast-bg border-solid border-2"
value={act.qrCode}
size={100}
/>
</div>
<div className="min-w-5" />
<div className="flex-grow flex flex-col">
<div className="flex flex-row items-center">
<Bullet />
<div className="min-w-1" />
<div className="text-sm">
Open your <b>authenticator app</b>.
</div>
<div className="min-w-2" />
<AuthAppInfoTooltip />
</div>
<div className="min-h-2" />
<div className="flex flex-row items-center">
<Bullet className="self-start mt-2" />
<div className="min-w-1" />
<div className="text-sm flex-grow">
<b>Scan this QR code</b> or <b>add this secret key</b>:
</div>
</div>
<div className="min-h-2" />
<DecoratedInput
className="ml-4 w-92"
disabled={true}
text={act.secretKey}
right={[<CopyButton copyValue={act.secretKey} />]}
/>
</div>
</ModalDialogDescription>
<ModalDialogButtons>
<Button
className="min-w-20"
type="normal"
label="Cancel"
onClick={() => act.cancelActivation()}
/>
<Button
className="min-w-20"
type="primary"
label="Next"
onClick={() => act.openSaveSecretKey()}
/>
</ModalDialogButtons>
</ModalDialog>
);
});

View File

@@ -0,0 +1,138 @@
import { MfaProvider } from '../../providers';
import { action, makeAutoObservable, observable } from 'mobx';
type ActivationStep =
| 'scan-qr-code'
| 'save-secret-key'
| 'verification'
| 'success';
type VerificationStatus =
| 'none'
| 'invalid-auth-code'
| 'invalid-secret'
| 'valid';
export class TwoFactorActivation {
public readonly type = 'two-factor-activation' as const;
private _activationStep: ActivationStep;
private _2FAVerification: VerificationStatus = 'none';
private inputSecretKey = '';
private inputOtpToken = '';
constructor(
private mfaProvider: MfaProvider,
private readonly email: string,
private readonly _secretKey: string,
private _cancelActivation: () => void,
private _enabled2FA: () => void
) {
this._activationStep = 'scan-qr-code';
makeAutoObservable<
TwoFactorActivation,
| '_secretKey'
| '_authCode'
| '_step'
| '_enable2FAVerification'
| 'inputOtpToken'
| 'inputSecretKey'
>(
this,
{
_secretKey: observable,
_authCode: observable,
_step: observable,
_enable2FAVerification: observable,
inputOtpToken: observable,
inputSecretKey: observable,
},
{ autoBind: true }
);
}
get secretKey(): string {
return this._secretKey;
}
get activationStep(): ActivationStep {
return this._activationStep;
}
get verificationStatus(): VerificationStatus {
return this._2FAVerification;
}
get qrCode(): string {
return `otpauth://totp/2FA?secret=${this._secretKey}&issuer=Standard%20Notes&label=${this.email}`;
}
cancelActivation(): void {
this._cancelActivation();
}
openScanQRCode(): void {
if (this._activationStep === 'save-secret-key') {
this._activationStep = 'scan-qr-code';
}
}
openSaveSecretKey(): void {
const preconditions: ActivationStep[] = ['scan-qr-code', 'verification'];
if (preconditions.includes(this._activationStep)) {
this._activationStep = 'save-secret-key';
}
}
openVerification(): void {
this.inputOtpToken = '';
this.inputSecretKey = '';
if (this._activationStep === 'save-secret-key') {
this._activationStep = 'verification';
this._2FAVerification = 'none';
}
}
openSuccess(): void {
if (this._activationStep === 'verification') {
this._activationStep = 'success';
}
}
setInputSecretKey(secretKey: string): void {
this.inputSecretKey = secretKey;
}
setInputOtpToken(otpToken: string): void {
this.inputOtpToken = otpToken;
}
enable2FA(): void {
if (this.inputSecretKey !== this._secretKey) {
this._2FAVerification = 'invalid-secret';
return;
}
this.mfaProvider
.enableMfa(this.inputSecretKey, this.inputOtpToken)
.then(
action(() => {
this._2FAVerification = 'valid';
this.openSuccess();
})
)
.catch(
action(() => {
this._2FAVerification = 'invalid-auth-code';
})
);
}
finishActivation(): void {
if (this._activationStep === 'success') {
this._enabled2FA();
}
}
}

View File

@@ -0,0 +1,22 @@
import { observer } from 'mobx-react-lite';
import { FunctionComponent } from 'preact';
import { TwoFactorActivation } from './TwoFactorActivation';
import { SaveSecretKey } from './SaveSecretKey';
import { ScanQRCode } from './ScanQRCode';
import { Verification } from './Verification';
import { TwoFactorSuccess } from './TwoFactorSuccess';
export const TwoFactorActivationView: FunctionComponent<{
activation: TwoFactorActivation;
}> = observer(({ activation: act }) => {
switch (act.activationStep) {
case 'scan-qr-code':
return <ScanQRCode activation={act} />;
case 'save-secret-key':
return <SaveSecretKey activation={act} />;
case 'verification':
return <Verification activation={act} />;
case 'success':
return <TwoFactorSuccess activation={act} />;
}
});

View File

@@ -0,0 +1,151 @@
import { MfaProvider, UserProvider } from '@/components/Preferences/providers';
import { action, makeAutoObservable, observable } from 'mobx';
import { TwoFactorActivation } from './TwoFactorActivation';
type TwoFactorStatus =
| 'two-factor-enabled'
| TwoFactorActivation
| 'two-factor-disabled';
export const is2FADisabled = (
status: TwoFactorStatus
): status is 'two-factor-disabled' => status === 'two-factor-disabled';
export const is2FAActivation = (
status: TwoFactorStatus
): status is TwoFactorActivation =>
(status as TwoFactorActivation)?.type === 'two-factor-activation';
export const is2FAEnabled = (
status: TwoFactorStatus
): status is 'two-factor-enabled' => status === 'two-factor-enabled';
export class TwoFactorAuth {
private _status: TwoFactorStatus | 'fetching' = 'fetching';
private _errorMessage: string | null;
constructor(
private readonly mfaProvider: MfaProvider,
private readonly userProvider: UserProvider
) {
this._errorMessage = null;
makeAutoObservable<
TwoFactorAuth,
'_status' | '_errorMessage' | 'deactivateMfa' | 'startActivation'
>(
this,
{
_status: observable,
_errorMessage: observable,
deactivateMfa: action,
startActivation: action,
},
{ autoBind: true }
);
}
private startActivation(): void {
const setDisabled = action(() => (this._status = 'two-factor-disabled'));
const setEnabled = action(() => {
this._status = 'two-factor-enabled';
this.fetchStatus();
});
this.mfaProvider
.generateMfaSecret()
.then(
action((secret) => {
this._status = new TwoFactorActivation(
this.mfaProvider,
this.userProvider.getUser()!.email,
secret,
setDisabled,
setEnabled
);
})
)
.catch(
action((e) => {
this.setError(e.message);
})
);
}
private deactivate2FA(): void {
this.mfaProvider
.disableMfa()
.then(
action(() => {
this.fetchStatus();
})
)
.catch(
action((e) => {
this.setError(e.message);
})
);
}
isLoggedIn(): boolean {
return this.userProvider.getUser() != undefined;
}
fetchStatus(): void {
if (!this.isLoggedIn()) {
return;
}
if (!this.isMfaFeatureAvailable()) {
return;
}
this.mfaProvider
.isMfaActivated()
.then(
action((active) => {
this._status = active ? 'two-factor-enabled' : 'two-factor-disabled';
this.setError(null);
})
)
.catch(
action((e) => {
this._status = 'two-factor-disabled';
this.setError(e.message);
})
);
}
private setError(errorMessage: string | null): void {
this._errorMessage = errorMessage;
}
toggle2FA(): void {
if (!this.isLoggedIn()) {
return;
}
if (!this.isMfaFeatureAvailable()) {
return;
}
if (this._status === 'two-factor-disabled') {
return this.startActivation();
}
if (this._status === 'two-factor-enabled') {
return this.deactivate2FA();
}
}
get errorMessage(): string | null {
return this._errorMessage;
}
get status(): TwoFactorStatus | 'fetching' {
return this._status;
}
isMfaFeatureAvailable(): boolean {
return this.mfaProvider.isMfaFeatureAvailable();
}
}

View File

@@ -0,0 +1,91 @@
import { FunctionComponent } from 'preact';
import {
Title,
Text,
PreferencesGroup,
PreferencesSegment,
} from '../../components';
import { Switch } from '../../../Switch';
import { observer } from 'mobx-react-lite';
import { is2FAActivation, is2FADisabled, TwoFactorAuth } from './TwoFactorAuth';
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 null;
}
if (auth.status === 'fetching') {
return <div class="sk-spinner normal info" />;
}
return (
<Switch checked={!is2FADisabled(auth.status)} onChange={auth.toggle2FA} />
);
}
);
export const TwoFactorAuthView: FunctionComponent<{
auth: TwoFactorAuth;
}> = observer(({ auth }) => {
return (
<>
<PreferencesGroup>
<PreferencesSegment>
<div className="flex flex-row items-center">
<div className="flex-grow flex flex-col">
<TwoFactorTitle auth={auth} />
<TwoFactorDescription auth={auth} />
</div>
<div className="flex flex-col justify-center items-center min-w-15">
<TwoFactorSwitch auth={auth} />
</div>
</div>
</PreferencesSegment>
{auth.errorMessage != null && (
<PreferencesSegment>
<Text className="color-danger">{auth.errorMessage}</Text>
</PreferencesSegment>
)}
</PreferencesGroup>
{auth.status !== 'fetching' && is2FAActivation(auth.status) && (
<TwoFactorActivationView activation={auth.status} />
)}
</>
);
});

View File

@@ -0,0 +1,36 @@
import { Button } from '@/components/Button';
import ModalDialog, {
ModalDialogButtons,
ModalDialogDescription,
ModalDialogLabel,
} from '@/components/Shared/ModalDialog';
import { Subtitle } from '@/components/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>
));

View File

@@ -0,0 +1,82 @@
import { Button } from '@/components/Button';
import { DecoratedInput } from '@/components/DecoratedInput';
import { observer } from 'mobx-react-lite';
import { FunctionComponent } from 'preact';
import { Bullet } from './Bullet';
import { TwoFactorActivation } from './TwoFactorActivation';
import {
ModalDialog,
ModalDialogButtons,
ModalDialogDescription,
ModalDialogLabel,
} from '@/components/Shared/ModalDialog';
export const Verification: FunctionComponent<{
activation: TwoFactorActivation;
}> = observer(({ activation: act }) => {
const secretKeyClass =
act.verificationStatus === 'invalid-secret' ? 'border-danger' : '';
const authTokenClass =
act.verificationStatus === 'invalid-auth-code' ? 'border-danger' : '';
return (
<ModalDialog>
<ModalDialogLabel closeDialog={act.cancelActivation}>
Step 3 of 3 - Verification
</ModalDialogLabel>
<ModalDialogDescription className="h-33">
<div className="flex-grow flex flex-col">
<div className="flex flex-row items-center">
<Bullet />
<div className="min-w-1" />
<div className="text-sm">
Enter your <b>secret key</b>:
</div>
<div className="min-w-2" />
<DecoratedInput
className={`w-92 ${secretKeyClass}`}
onChange={act.setInputSecretKey}
/>
</div>
<div className="min-h-1" />
<div className="flex flex-row items-center">
<Bullet />
<div className="min-w-1" />
<div className="text-sm">
Verify the <b>authentication code</b> generated by your
authenticator app:
</div>
<div className="min-w-2" />
<DecoratedInput
className={`w-30 ${authTokenClass}`}
onChange={act.setInputOtpToken}
/>
</div>
</div>
</ModalDialogDescription>
<ModalDialogButtons>
{act.verificationStatus === 'invalid-auth-code' && (
<div className="text-sm color-danger flex-grow">
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>
)}
<Button
className="min-w-20"
type="normal"
label="Back"
onClick={act.openSaveSecretKey}
/>
<Button
className="min-w-20"
type="primary"
label="Next"
onClick={act.enable2FA}
/>
</ModalDialogButtons>
</ModalDialog>
);
});

View File

@@ -0,0 +1,13 @@
// Temporary implementation until integration
export function downloadSecretKey(text: string) {
const link = document.createElement('a');
const blob = new Blob([text], {
type: 'text/plain;charset=utf-8',
});
link.href = window.URL.createObjectURL(blob);
link.setAttribute('download', 'standardnotes_2fa_key.txt');
document.body.appendChild(link);
link.click();
link.remove();
window.URL.revokeObjectURL(link.href);
}

View File

@@ -0,0 +1,13 @@
import { FunctionComponent } from 'preact';
import { useState } from 'preact/hooks';
import { MfaProps } from './MfaProps';
import { TwoFactorAuth } from './TwoFactorAuth';
import { TwoFactorAuthView } from './TwoFactorAuthView';
export const TwoFactorAuthWrapper: FunctionComponent<MfaProps> = (props) => {
const [auth] = useState(
() => new TwoFactorAuth(props.mfaProvider, props.userProvider)
);
auth.fetchStatus();
return <TwoFactorAuthView auth={auth} />;
};

View File

@@ -0,0 +1,13 @@
export interface MfaProvider {
isMfaActivated(): Promise<boolean>;
generateMfaSecret(): Promise<string>;
getOtpToken(secret: string): Promise<string>;
enableMfa(secret: string, otpToken: string): Promise<void>;
disableMfa(): Promise<void>;
isMfaFeatureAvailable(): boolean;
}

View File

@@ -0,0 +1,3 @@
export interface UserProvider {
getUser(): { uuid: string; email: string } | undefined;
}

View File

@@ -0,0 +1,2 @@
export * from './MfaProvider';
export * from './UserProvider';

View File

@@ -0,0 +1,68 @@
import { WebApplication } from '@/ui_models/application';
import { AppState } from '@/ui_models/app_state';
import { PurchaseFlowPane } from '@/ui_models/app_state/purchase_flow_state';
import { observer } from 'mobx-react-lite';
import { FunctionComponent } from 'preact';
import { CreateAccount } from './panes/CreateAccount';
import { SignIn } from './panes/SignIn';
import { SNLogoFull } from '@standardnotes/stylekit';
type PaneSelectorProps = {
currentPane: PurchaseFlowPane;
} & PurchaseFlowViewProps;
type PurchaseFlowViewProps = {
appState: AppState;
application: WebApplication;
};
const PurchaseFlowPaneSelector: FunctionComponent<PaneSelectorProps> = ({
currentPane,
appState,
application,
}) => {
switch (currentPane) {
case PurchaseFlowPane.CreateAccount:
return <CreateAccount appState={appState} application={application} />;
case PurchaseFlowPane.SignIn:
return <SignIn appState={appState} application={application} />;
}
};
export const PurchaseFlowView: FunctionComponent<PurchaseFlowViewProps> =
observer(({ appState, application }) => {
const { currentPane } = appState.purchaseFlow;
return (
<div className="flex items-center justify-center overflow-hidden h-full w-full absolute top-left-0 z-index-purchase-flow bg-grey-super-light">
<div className="relative fit-content">
<div className="relative p-12 xs:px-8 mb-4 bg-default border-1 border-solid border-main rounded xs:rounded-0">
<SNLogoFull className="mb-5" />
<PurchaseFlowPaneSelector
currentPane={currentPane}
appState={appState}
application={application}
/>
</div>
<div className="flex justify-end xs:px-4">
<a
className="mr-3 font-medium color-grey-1"
href="https://standardnotes.com/privacy"
target="_blank"
rel="noopener noreferrer"
>
Privacy
</a>
<a
className="font-medium color-grey-1"
href="https://standardnotes.com/help"
target="_blank"
rel="noopener noreferrer"
>
Help
</a>
</div>
</div>
</div>
);
});

View File

@@ -0,0 +1,49 @@
import { WebApplication } from '@/ui_models/application';
import { AppState } from '@/ui_models/app_state';
import { isDesktopApplication } from '@/utils';
import { observer } from 'mobx-react-lite';
import { FunctionComponent } from 'preact';
import { PurchaseFlowView } from './PurchaseFlowView';
export type PurchaseFlowWrapperProps = {
appState: AppState;
application: WebApplication;
};
export const getPurchaseFlowUrl = async (
application: WebApplication
): Promise<string | undefined> => {
const currentUrl = window.location.origin;
const successUrl = isDesktopApplication() ? `standardnotes://` : currentUrl;
if (application.noAccount()) {
return `${window.purchaseUrl}/offline?&success_url=${successUrl}`;
}
const token = await application.getNewSubscriptionToken();
if (token) {
return `${window.purchaseUrl}?subscription_token=${token}&success_url=${successUrl}`;
}
return undefined;
};
export const loadPurchaseFlowUrl = async (
application: WebApplication
): Promise<boolean> => {
const url = await getPurchaseFlowUrl(application);
const params = new URLSearchParams(window.location.search);
const period = params.get('period') ? `&period=${params.get('period')}` : '';
const plan = params.get('plan') ? `&plan=${params.get('plan')}` : '';
if (url) {
window.location.assign(`${url}${period}${plan}`);
return true;
}
return false;
};
export const PurchaseFlowWrapper: FunctionComponent<PurchaseFlowWrapperProps> =
observer(({ appState, application }) => {
if (!appState.purchaseFlow.isOpen) {
return null;
}
return <PurchaseFlowView appState={appState} application={application} />;
});

View File

@@ -0,0 +1,220 @@
import { Button } from '@/components/Button';
import { WebApplication } from '@/ui_models/application';
import { AppState } from '@/ui_models/app_state';
import { PurchaseFlowPane } from '@/ui_models/app_state/purchase_flow_state';
import { observer } from 'mobx-react-lite';
import { FunctionComponent } from 'preact';
import { useEffect, useRef, useState } from 'preact/hooks';
import { FloatingLabelInput } from '@/components/FloatingLabelInput';
import { isEmailValid } from '@/utils';
import { loadPurchaseFlowUrl } from '../PurchaseFlowWrapper';
import {
BlueDotIcon,
CircleIcon,
DiamondIcon,
CreateAccountIllustration,
} from '@standardnotes/stylekit';
type Props = {
appState: AppState;
application: WebApplication;
};
export const CreateAccount: FunctionComponent<Props> = observer(
({ appState, application }) => {
const { setCurrentPane } = appState.purchaseFlow;
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [confirmPassword, setConfirmPassword] = useState('');
const [isCreatingAccount, setIsCreatingAccount] = useState(false);
const [isEmailInvalid, setIsEmailInvalid] = useState(false);
const [isPasswordNotMatching, setIsPasswordNotMatching] = useState(false);
const emailInputRef = useRef<HTMLInputElement>(null);
const passwordInputRef = useRef<HTMLInputElement>(null);
const confirmPasswordInputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
if (emailInputRef.current) emailInputRef.current?.focus();
}, []);
const handleEmailChange = (e: Event) => {
if (e.target instanceof HTMLInputElement) {
setEmail(e.target.value);
setIsEmailInvalid(false);
}
};
const handlePasswordChange = (e: Event) => {
if (e.target instanceof HTMLInputElement) {
setPassword(e.target.value);
}
};
const handleConfirmPasswordChange = (e: Event) => {
if (e.target instanceof HTMLInputElement) {
setConfirmPassword(e.target.value);
setIsPasswordNotMatching(false);
}
};
const handleSignInInstead = () => {
setCurrentPane(PurchaseFlowPane.SignIn);
};
const subscribeWithoutAccount = () => {
loadPurchaseFlowUrl(application).catch((err) => {
console.error(err);
application.alertService.alert(err);
});
};
const handleCreateAccount = async () => {
if (!email) {
emailInputRef?.current?.focus();
return;
}
if (!isEmailValid(email)) {
setIsEmailInvalid(true);
emailInputRef?.current?.focus();
return;
}
if (!password) {
passwordInputRef?.current?.focus();
return;
}
if (!confirmPassword) {
confirmPasswordInputRef?.current?.focus();
return;
}
if (password !== confirmPassword) {
setConfirmPassword('');
setIsPasswordNotMatching(true);
confirmPasswordInputRef?.current?.focus();
return;
}
setIsCreatingAccount(true);
try {
const response = await application.register(email, password);
if (response.error || response.data?.error) {
throw new Error(
response.error?.message || response.data?.error?.message
);
} else {
loadPurchaseFlowUrl(application).catch((err) => {
console.error(err);
application.alertService.alert(err);
});
}
} catch (err) {
console.error(err);
application.alertService.alert(err as string);
} finally {
setIsCreatingAccount(false);
}
};
return (
<div className="flex items-center">
<CircleIcon className="absolute w-8 h-8 top-40% -left-28" />
<BlueDotIcon className="absolute w-4 h-4 top-35% -left-10" />
<DiamondIcon className="absolute w-26 h-26 -bottom-5 left-0 -translate-x-1/2 -z-index-1" />
<CircleIcon className="absolute w-8 h-8 bottom-35% -right-20" />
<BlueDotIcon className="absolute w-4 h-4 bottom-25% -right-10" />
<DiamondIcon className="absolute w-18 h-18 top-0 -right-2 translate-x-1/2 -z-index-1" />
<div className="mr-12 md:mr-0">
<h1 className="mt-0 mb-2 text-2xl">Create your free account</h1>
<div className="mb-4 font-medium text-sm">
to continue to Standard Notes.
</div>
<form onSubmit={handleCreateAccount}>
<div className="flex flex-col">
<FloatingLabelInput
className={`min-w-90 xs:min-w-auto ${
isEmailInvalid ? 'mb-2' : 'mb-4'
}`}
id="purchase-sign-in-email"
type="email"
label="Email"
value={email}
onChange={handleEmailChange}
ref={emailInputRef}
disabled={isCreatingAccount}
isInvalid={isEmailInvalid}
/>
{isEmailInvalid ? (
<div className="color-dark-red mb-4">
Please provide a valid email.
</div>
) : null}
<FloatingLabelInput
className="min-w-90 xs:min-w-auto mb-4"
id="purchase-create-account-password"
type="password"
label="Password"
value={password}
onChange={handlePasswordChange}
ref={passwordInputRef}
disabled={isCreatingAccount}
/>
<FloatingLabelInput
className={`min-w-90 xs:min-w-auto ${
isPasswordNotMatching ? 'mb-2' : 'mb-4'
}`}
id="create-account-confirm"
type="password"
label="Repeat password"
value={confirmPassword}
onChange={handleConfirmPasswordChange}
ref={confirmPasswordInputRef}
disabled={isCreatingAccount}
isInvalid={isPasswordNotMatching}
/>
{isPasswordNotMatching ? (
<div className="color-dark-red mb-4">
Passwords don't match. Please try again.
</div>
) : null}
</div>
</form>
<div className="flex xs:flex-col-reverse xs:items-start items-center justify-between">
<div className="flex flex-col">
<button
onClick={handleSignInInstead}
disabled={isCreatingAccount}
className="flex items-start p-0 mb-2 bg-default border-0 font-medium color-info cursor-pointer hover:underline"
>
Sign in instead
</button>
<button
onClick={subscribeWithoutAccount}
disabled={isCreatingAccount}
className="flex items-start p-0 bg-default border-0 font-medium color-info cursor-pointer hover:underline"
>
Subscribe without account
</button>
</div>
<Button
className="py-2.5 xs:mb-4"
type="primary"
label={
isCreatingAccount ? 'Creating account...' : 'Create account'
}
onClick={handleCreateAccount}
disabled={isCreatingAccount}
/>
</div>
</div>
<CreateAccountIllustration className="md:hidden" />
</div>
);
}
);

View File

@@ -0,0 +1,175 @@
import { Button } from '@/components/Button';
import { WebApplication } from '@/ui_models/application';
import { AppState } from '@/ui_models/app_state';
import { PurchaseFlowPane } from '@/ui_models/app_state/purchase_flow_state';
import { observer } from 'mobx-react-lite';
import { FunctionComponent } from 'preact';
import { useEffect, useRef, useState } from 'preact/hooks';
import { FloatingLabelInput } from '@/components/FloatingLabelInput';
import { isEmailValid } from '@/utils';
import { loadPurchaseFlowUrl } from '../PurchaseFlowWrapper';
import { BlueDotIcon, CircleIcon, DiamondIcon } from '@standardnotes/stylekit';
type Props = {
appState: AppState;
application: WebApplication;
};
export const SignIn: FunctionComponent<Props> = observer(
({ appState, application }) => {
const { setCurrentPane } = appState.purchaseFlow;
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [isSigningIn, setIsSigningIn] = useState(false);
const [isEmailInvalid, setIsEmailInvalid] = useState(false);
const [isPasswordInvalid, setIsPasswordInvalid] = useState(false);
const [otherErrorMessage, setOtherErrorMessage] = useState('');
const emailInputRef = useRef<HTMLInputElement>(null);
const passwordInputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
if (emailInputRef.current) emailInputRef.current?.focus();
}, []);
const handleEmailChange = (e: Event) => {
if (e.target instanceof HTMLInputElement) {
setEmail(e.target.value);
setIsEmailInvalid(false);
}
};
const handlePasswordChange = (e: Event) => {
if (e.target instanceof HTMLInputElement) {
setPassword(e.target.value);
setIsPasswordInvalid(false);
setOtherErrorMessage('');
}
};
const handleCreateAccountInstead = () => {
if (isSigningIn) return;
setCurrentPane(PurchaseFlowPane.CreateAccount);
};
const handleSignIn = async () => {
if (!email) {
emailInputRef?.current?.focus();
return;
}
if (!isEmailValid(email)) {
setIsEmailInvalid(true);
emailInputRef?.current?.focus();
return;
}
if (!password) {
passwordInputRef?.current?.focus();
return;
}
setIsSigningIn(true);
try {
const response = await application.signIn(email, password);
if (response.error || response.data?.error) {
throw new Error(
response.error?.message || response.data?.error?.message
);
} else {
loadPurchaseFlowUrl(application).catch((err) => {
console.error(err);
application.alertService.alert(err);
});
}
} catch (err) {
console.error(err);
if ((err as Error).toString().includes('Invalid email or password')) {
setIsSigningIn(false);
setIsEmailInvalid(true);
setIsPasswordInvalid(true);
setOtherErrorMessage('Invalid email or password.');
setPassword('');
} else {
application.alertService.alert(err as string);
}
}
};
return (
<div className="flex items-center">
<CircleIcon className="absolute w-8 h-8 top-35% -left-56" />
<BlueDotIcon className="absolute w-4 h-4 top-30% -left-40" />
<DiamondIcon className="absolute w-26 h-26 -bottom-5 left-0 -translate-x-1/2 -z-index-1" />
<CircleIcon className="absolute w-8 h-8 bottom-30% -right-56" />
<BlueDotIcon className="absolute w-4 h-4 bottom-20% -right-44" />
<DiamondIcon className="absolute w-18 h-18 top-0 -right-2 translate-x-1/2 -z-index-1" />
<div>
<h1 className="mt-0 mb-2 text-2xl">Sign in</h1>
<div className="mb-4 font-medium text-sm">
to continue to Standard Notes.
</div>
<form onSubmit={handleSignIn}>
<div className="flex flex-col">
<FloatingLabelInput
className={`min-w-90 xs:min-w-auto ${
isEmailInvalid && !otherErrorMessage ? 'mb-2' : 'mb-4'
}`}
id="purchase-sign-in-email"
type="email"
label="Email"
value={email}
onChange={handleEmailChange}
ref={emailInputRef}
disabled={isSigningIn}
isInvalid={isEmailInvalid}
/>
{isEmailInvalid && !otherErrorMessage ? (
<div className="color-dark-red mb-4">
Please provide a valid email.
</div>
) : null}
<FloatingLabelInput
className={`min-w-90 xs:min-w-auto ${
otherErrorMessage ? 'mb-2' : 'mb-4'
}`}
id="purchase-sign-in-password"
type="password"
label="Password"
value={password}
onChange={handlePasswordChange}
ref={passwordInputRef}
disabled={isSigningIn}
isInvalid={isPasswordInvalid}
/>
{otherErrorMessage ? (
<div className="color-dark-red mb-4">{otherErrorMessage}</div>
) : null}
</div>
<Button
className={`${isSigningIn ? 'min-w-30' : 'min-w-24'} py-2.5 mb-5`}
type="primary"
label={isSigningIn ? 'Signing in...' : 'Sign in'}
onClick={handleSignIn}
disabled={isSigningIn}
/>
</form>
<div className="text-sm font-medium color-grey-1">
Dont have an account yet?{' '}
<a
className={`color-info ${
isSigningIn ? 'cursor-not-allowed' : 'cursor-pointer '
}`}
onClick={handleCreateAccountInstead}
>
Create account
</a>
</div>
</div>
</div>
);
}
);

View File

@@ -1,7 +1,7 @@
import { FunctionalComponent } from 'preact';
import { useRef, useState } from 'preact/hooks';
import { ArrowDownCheckmarkIcon } from '@standardnotes/stylekit';
import { Title } from '@/preferences/components';
import { Title } from '@/components/Preferences/components';
type Props = {
title: string | JSX.Element;

View File

@@ -48,14 +48,15 @@ export const ModalDialogLabel: FunctionComponent<{
</AlertDialogLabel>
);
export const ModalDialogDescription: FunctionComponent<{ className?: string }> =
({ children, className = '' }) => (
<AlertDialogDescription
className={`px-4 py-4 flex flex-row items-center ${className}`}
>
{children}
</AlertDialogDescription>
);
export const ModalDialogDescription: FunctionComponent<{
className?: string;
}> = ({ children, className = '' }) => (
<AlertDialogDescription
className={`px-4 py-4 flex flex-row items-center ${className}`}
>
{children}
</AlertDialogDescription>
);
export const ModalDialogButtons: FunctionComponent<{ className?: string }> = ({
children,

View File

@@ -3,8 +3,8 @@ import { observer } from 'mobx-react-lite';
import { FunctionComponent } from 'preact';
import { useCallback, useEffect, useRef } from 'preact/hooks';
import { Icon } from '../Icon';
import { Menu } from '../menu/Menu';
import { MenuItem, MenuItemType } from '../menu/MenuItem';
import { Menu } from '../Menu/Menu';
import { MenuItem, MenuItemType } from '../Menu/MenuItem';
import { usePremiumModal } from '../Premium';
import { useCloseOnBlur } from '../utils';
import { SNTag } from '@standardnotes/snjs';