feat: New account menu and text input with icon & toggle (#665)

* feat: Add new icons

* Revert "feat: Add new icons"

This reverts commit 0acb403fe846dbb2e48fd22de35c3568c3cb4453.

* feat: Add new icons for account menu

* feat: Add new Icons

* feat: Add "currentPane" state to prefs view

* feat: Update account menu to new design

* feat: Add input component with icon & toggle

* fix: sync icon & function

* fix: Fix eye icon

* feat: Create re-usable checkbox

feat: Add "merge local" option

* feat: Allow using className on IconButton

* feat: Add disabled state on input

feat: Make toggle circle

* refactor: Move checkbox to components

* feat: Handle invalid email/password error

* feat: Implement new design for Create Account

* feat: Implement new account menu design

* feat: Add disabled option to IconButton

* feat: Set account menu pane from other component

* feat: Add 2fa account menu pane

feat: Add lock icon

* feat: Remove unnecessary 2FA menu pane

feat: Reset current menu pane on clickOutside

* feat: Change "Log in" to "Sign in"

* feat: Remove sync from footer

* feat: Change "Login" to "Sign in"

feat: Add spinner to "Syncing..."

refactor: Use then-catch-finally for sync

* feat: Use common enableCustomServer state

* feat: Animate account menu closing

* fix: Reset menu pane only after it's closed

* feat: Add keyDown handler to InputWithIcon

* feat: Handle Enter press in inputs

* Update app/assets/javascripts/components/InputWithIcon.tsx

Co-authored-by: Antonella Sgarlatta <antsgar@gmail.com>

* Update app/assets/javascripts/components/InputWithIcon.tsx

Co-authored-by: Antonella Sgarlatta <antsgar@gmail.com>

* refactor: Use server state from AccountMenuState

* Update app/assets/javascripts/components/AccountMenu/CreateAccount.tsx

Co-authored-by: Antonella Sgarlatta <antsgar@gmail.com>

* Update app/assets/javascripts/components/AccountMenu/ConfirmPassword.tsx

Co-authored-by: Antonella Sgarlatta <antsgar@gmail.com>

* feat: Use common AdvancedOptions

* feat: Add "eye-off" icon and toggle state

* feat: Allow undefined values

* refactor: Remove enableCustomServer state

* feat: Persist server option state

* feat: Add bottom-100 and cursor-auto util classes

refactor: Use bottom-100 and cursor-auto classes

* refactor: Invert ternary operator

* refactor: Remove unused imports

* refactor: Use toggled as prop instead of state

* refactor: Change "Log in/out" to "Sign in/out"

* refactor: Change "Login" to "Sign in"

* refactor: Remove hardcoded width/height

* refactor: Use success class

* feat: Remove hardcoded width & height from svg

* fix: Fix chevron-down icon

Co-authored-by: Antonella Sgarlatta <antsgar@gmail.com>
Co-authored-by: Antonella Sgarlatta <antonella@standardnotes.org>
This commit is contained in:
Aman Harwara
2021-10-08 21:48:31 +05:30
committed by GitHub
parent 7b6c99d188
commit f1122f292e
51 changed files with 1566 additions and 407 deletions

View File

@@ -0,0 +1,73 @@
import { WebApplication } from '@/ui_models/application';
import { AppState } from '@/ui_models/app_state';
import { observer } from 'mobx-react-lite';
import { FunctionComponent } from 'preact';
import { useState } from 'preact/hooks';
import { Checkbox } from '../Checkbox';
import { Icon } from '../Icon';
import { InputWithIcon } from '../InputWithIcon';
type Props = {
application: WebApplication;
appState: AppState;
disabled?: boolean;
};
export const AdvancedOptions: FunctionComponent<Props> = observer(
({ appState, application, disabled = false, children }) => {
const { server, setServer, enableServerOption, setEnableServerOption } =
appState.accountMenu;
const [showAdvanced, setShowAdvanced] = useState(false);
const handleServerOptionChange = (e: Event) => {
if (e.target instanceof HTMLInputElement) {
setEnableServerOption(e.target.checked);
}
};
const handleSyncServerChange = (e: Event) => {
if (e.target instanceof HTMLInputElement) {
setServer(e.target.value);
application.setCustomHost(e.target.value);
}
};
const toggleShowAdvanced = () => {
setShowAdvanced(!showAdvanced);
};
return (
<>
<button
className="sn-dropdown-item font-bold"
onClick={toggleShowAdvanced}
>
<div className="flex items-center">
Advanced options
<Icon type="chevron-down" className="color-grey-1 ml-1" />
</div>
</button>
{showAdvanced ? (
<div className="px-3 my-2">
{children}
<Checkbox
name="custom-sync-server"
label="Custom sync server"
checked={enableServerOption}
onChange={handleServerOptionChange}
disabled={disabled}
/>
<InputWithIcon
inputType="text"
icon="server"
placeholder="https://api.standardnotes.com"
value={server}
onChange={handleSyncServerChange}
disabled={!enableServerOption && !disabled}
/>
</div>
) : null}
</>
);
}
);

View File

@@ -3,7 +3,7 @@ import {
STRING_ACCOUNT_MENU_UNCHECK_MERGE,
STRING_GENERATING_LOGIN_KEYS,
STRING_GENERATING_REGISTER_KEYS,
STRING_NON_MATCHING_PASSWORDS
STRING_NON_MATCHING_PASSWORDS,
} from '@/strings';
import { JSXInternal } from 'preact/src/jsx';
import TargetedEvent = JSXInternal.TargetedEvent;
@@ -17,13 +17,9 @@ import { AppState } from '@/ui_models/app_state';
type Props = {
application: WebApplication;
appState: AppState;
}
const Authentication = observer(({
application,
appState,
}: Props) => {
};
const Authentication = observer(({ application, appState }: Props) => {
const [showAdvanced, setShowAdvanced] = useState(false);
const [isAuthenticating, setIsAuthenticating] = useState(false);
const [email, setEmail] = useState('');
@@ -39,12 +35,12 @@ const Authentication = observer(({
const {
server,
notesAndTagsCount,
showLogin,
showSignIn,
showRegister,
setShowLogin,
setShowSignIn,
setShowRegister,
setServer,
closeAccountMenu
closeAccountMenu,
} = appState.accountMenu;
const user = application.getUser();
@@ -58,11 +54,11 @@ const Authentication = observer(({
// Reset password and confirmation fields when hiding the form
useEffect(() => {
if (!showLogin && !showRegister) {
if (!showSignIn && !showRegister) {
setPassword('');
setPasswordConfirmation('');
}
}, [showLogin, showRegister]);
}, [showSignIn, showRegister]);
const handleHostInputChange = (event: TargetedEvent<HTMLInputElement>) => {
const { value } = event.target as HTMLInputElement;
@@ -75,7 +71,7 @@ const Authentication = observer(({
const passwordConfirmationInputRef = useRef<HTMLInputElement>();
const handleSignInClick = () => {
setShowLogin(true);
setShowSignIn(true);
setIsEmailFocused(true);
};
@@ -90,7 +86,7 @@ const Authentication = observer(({
passwordConfirmationInputRef.current?.blur();
};
const login = async () => {
const signin = async () => {
setStatus(STRING_GENERATING_LOGIN_KEYS);
setIsAuthenticating(true);
@@ -105,13 +101,13 @@ const Authentication = observer(({
if (!error) {
setIsAuthenticating(false);
setPassword('');
setShowLogin(false);
setShowSignIn(false);
closeAccountMenu();
return;
}
setShowLogin(true);
setShowSignIn(true);
setStatus(undefined);
setPassword('');
@@ -150,10 +146,11 @@ const Authentication = observer(({
}
};
const handleAuthFormSubmit = (event:
TargetedEvent<HTMLFormElement> |
TargetedMouseEvent<HTMLButtonElement> |
TargetedKeyboardEvent<HTMLButtonElement>
const handleAuthFormSubmit = (
event:
| TargetedEvent<HTMLFormElement>
| TargetedMouseEvent<HTMLButtonElement>
| TargetedKeyboardEvent<HTMLButtonElement>
) => {
event.preventDefault();
@@ -163,8 +160,8 @@ const Authentication = observer(({
blurAuthFields();
if (showLogin) {
login();
if (showSignIn) {
signin();
} else {
register();
}
@@ -186,19 +183,23 @@ const Authentication = observer(({
setEmail(value);
};
const handlePasswordConfirmationChange = (event: TargetedEvent<HTMLInputElement>) => {
const handlePasswordConfirmationChange = (
event: TargetedEvent<HTMLInputElement>
) => {
const { value } = event.target as HTMLInputElement;
setPasswordConfirmation(value);
};
const handleMergeLocalData = async (event: TargetedEvent<HTMLInputElement>) => {
const handleMergeLocalData = async (
event: TargetedEvent<HTMLInputElement>
) => {
const { checked } = event.target as HTMLInputElement;
setShouldMergeLocal(checked);
if (!checked) {
const confirmResult = await confirmDialog({
text: STRING_ACCOUNT_MENU_UNCHECK_MERGE,
confirmButtonStyle: 'danger'
confirmButtonStyle: 'danger',
});
setShouldMergeLocal(!confirmResult);
}
@@ -206,10 +207,12 @@ const Authentication = observer(({
return (
<>
{!user && !showLogin && !showRegister && (
{!user && !showSignIn && !showRegister && (
<div className="sk-panel-section sk-panel-hero">
<div className="sk-panel-row">
<div className="sk-h1">Sign in or register to enable sync and end-to-end encryption.</div>
<div className="sk-h1">
Sign in or register to enable sync and end-to-end encryption.
</div>
</div>
<div className="flex my-1">
<button
@@ -226,17 +229,21 @@ const Authentication = observer(({
</button>
</div>
<div className="sk-panel-row sk-p">
Standard Notes is free on every platform, and comes
standard with sync and encryption.
Standard Notes is free on every platform, and comes standard with
sync and encryption.
</div>
</div>
)}
{(showLogin || showRegister) && (
{(showSignIn || showRegister) && (
<div className="sk-panel-section">
<div className="sk-panel-section-title">
{showLogin ? 'Sign In' : 'Register'}
{showSignIn ? 'Sign In' : 'Register'}
</div>
<form className="sk-panel-form" onSubmit={handleAuthFormSubmit} noValidate>
<form
className="sk-panel-form"
onSubmit={handleAuthFormSubmit}
noValidate
>
<div className="sk-panel-section">
<input
className="sk-input contrast"
@@ -261,26 +268,28 @@ const Authentication = observer(({
onKeyDown={handleKeyPressKeyDown}
ref={passwordInputRef}
/>
{showRegister &&
<input
className="sk-input contrast"
name="password_conf"
type="password"
placeholder="Confirm Password"
required
onKeyPress={handleKeyPressKeyDown}
onKeyDown={handleKeyPressKeyDown}
value={passwordConfirmation}
onChange={handlePasswordConfirmationChange}
ref={passwordConfirmationInputRef}
/>}
{showRegister && (
<input
className="sk-input contrast"
name="password_conf"
type="password"
placeholder="Confirm Password"
required
onKeyPress={handleKeyPressKeyDown}
onKeyDown={handleKeyPressKeyDown}
value={passwordConfirmation}
onChange={handlePasswordConfirmationChange}
ref={passwordConfirmationInputRef}
/>
)}
<div className="sk-panel-row" />
<button
type="button"
className="sk-a info font-bold text-left p-0 cursor-pointer hover:underline mr-1 ml-1"
onClick={() => {
setShowAdvanced(!showAdvanced);
}}>
}}
>
Advanced Options
</button>
</div>
@@ -301,24 +310,28 @@ const Authentication = observer(({
required
/>
</div>
{showLogin && (
{showSignIn && (
<label className="sk-label padded-row sk-panel-row justify-left">
<div className="sk-horizontal-group tight cursor-pointer">
<input
className="sk-input"
type="checkbox"
checked={isStrictSignIn}
onChange={() => setIsStrictSignIn(prevState => !prevState)}
onChange={() =>
setIsStrictSignIn((prevState) => !prevState)
}
/>
<p className="sk-p">Use strict sign in</p>
<span>
<a className="info"
href="https://standardnotes.com/help/security" rel="noopener"
target="_blank"
>
(Learn more)
</a>
</span>
<a
className="info"
href="https://standardnotes.com/help/security"
rel="noopener"
target="_blank"
>
(Learn more)
</a>
</span>
</div>
</label>
)}
@@ -327,9 +340,12 @@ const Authentication = observer(({
)}
{!isAuthenticating && (
<div className="sk-panel-section form-submit">
<button className="sn-button info text-base py-3 text-center" type="submit"
disabled={isAuthenticating}>
{showLogin ? 'Sign In' : 'Register'}
<button
className="sn-button info text-base py-3 text-center"
type="submit"
disabled={isAuthenticating}
>
{showSignIn ? 'Sign In' : 'Register'}
</button>
</div>
)}
@@ -337,9 +353,9 @@ const Authentication = observer(({
<div className="sk-notification neutral">
<div className="sk-notification-title">No Password Reset.</div>
<div className="sk-notification-text">
Because your notes are encrypted using your password,
Standard Notes does not have a password reset option.
You cannot forget your password.
Because your notes are encrypted using your password, Standard
Notes does not have a password reset option. You cannot forget
your password.
</div>
</div>
)}
@@ -358,7 +374,7 @@ const Authentication = observer(({
<input
type="checkbox"
checked={!isEphemeral}
onChange={() => setIsEphemeral(prevState => !prevState)}
onChange={() => setIsEphemeral((prevState) => !prevState)}
/>
<p className="sk-p">Stay signed in</p>
</div>
@@ -371,7 +387,9 @@ const Authentication = observer(({
checked={shouldMergeLocal}
onChange={handleMergeLocalData}
/>
<p className="sk-p">Merge local data ({notesAndTagsCount}) notes and tags</p>
<p className="sk-p">
Merge local data ({notesAndTagsCount}) notes and tags
</p>
</div>
</label>
)}
@@ -379,7 +397,8 @@ const Authentication = observer(({
)}
</form>
</div>
)}</>
)}
</>
);
});

View File

@@ -0,0 +1,172 @@
import { STRING_NON_MATCHING_PASSWORDS } from '@/strings';
import { WebApplication } from '@/ui_models/application';
import { AppState } from '@/ui_models/app_state';
import { observer } from 'mobx-react-lite';
import { FunctionComponent } from 'preact';
import { StateUpdater, useRef, useState } from 'preact/hooks';
import { AccountMenuPane } from '.';
import { Button } from '../Button';
import { Checkbox } from '../Checkbox';
import { IconButton } from '../IconButton';
import { InputWithIcon } from '../InputWithIcon';
import { AdvancedOptions } from './AdvancedOptions';
type Props = {
appState: AppState;
application: WebApplication;
setMenuPane: (pane: AccountMenuPane) => void;
email: string;
password: string;
setPassword: StateUpdater<string>;
};
export const ConfirmPassword: FunctionComponent<Props> = observer(
({ application, appState, setMenuPane, email, password, setPassword }) => {
const { notesAndTagsCount } = appState.accountMenu;
const [confirmPassword, setConfirmPassword] = useState('');
const [showPassword, setShowPassword] = useState(false);
const [isRegistering, setIsRegistering] = useState(false);
const [isEphemeral, setIsEphemeral] = useState(false);
const [shouldMergeLocal, setShouldMergeLocal] = useState(true);
const passwordInputRef = useRef<HTMLInputElement>();
const handlePasswordChange = (e: Event) => {
if (e.target instanceof HTMLInputElement) {
setConfirmPassword(e.target.value);
}
};
const handleEphemeralChange = () => {
setIsEphemeral(!isEphemeral);
};
const handleShouldMergeChange = () => {
setShouldMergeLocal(!shouldMergeLocal);
};
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Enter') {
handleConfirmFormSubmit(e);
}
};
const handleConfirmFormSubmit = (e: Event) => {
e.preventDefault();
if (!password) {
passwordInputRef?.current.focus();
return;
}
if (password === confirmPassword) {
setIsRegistering(true);
application
.register(email, password, isEphemeral, shouldMergeLocal)
.then((res) => {
if (res.error) {
throw new Error(res.error.message);
}
appState.accountMenu.closeAccountMenu();
appState.accountMenu.setCurrentPane(AccountMenuPane.GeneralMenu);
})
.catch((err) => {
console.error(err);
application.alertService.alert(err).finally(() => {
setPassword('');
handleGoBack();
});
})
.finally(() => {
setIsRegistering(false);
});
} else {
application.alertService
.alert(STRING_NON_MATCHING_PASSWORDS)
.finally(() => {
setConfirmPassword('');
passwordInputRef?.current.focus();
});
}
};
const handleGoBack = () => {
setMenuPane(AccountMenuPane.Register);
};
return (
<>
<div className="flex items-center px-3 mt-1 mb-3">
<IconButton
icon="arrow-left"
title="Go back"
className="flex mr-2 color-neutral"
onClick={handleGoBack}
focusable={true}
disabled={isRegistering}
/>
<div className="sn-account-menu-headline">Confirm password</div>
</div>
<div className="px-3 mb-3 text-sm">
Because your notes are encrypted using your password,{' '}
<span className="color-dark-red">
Standard Notes does not have a password reset option
</span>
. If you forget your password, you will permanently lose access to
your data.
</div>
<form onSubmit={handleConfirmFormSubmit} className="px-3 mb-1">
<InputWithIcon
className="mb-2"
icon="password"
inputType={showPassword ? 'text' : 'password'}
placeholder="Confirm password"
value={confirmPassword}
onChange={handlePasswordChange}
onKeyDown={handleKeyDown}
toggle={{
toggleOnIcon: 'eye-off',
toggleOffIcon: 'eye',
title: 'Show password',
toggled: showPassword,
onClick: setShowPassword,
}}
ref={passwordInputRef}
disabled={isRegistering}
/>
<Button
className="btn-w-full mt-1 mb-3"
label={
isRegistering ? 'Creating account...' : 'Create account & sign in'
}
type="primary"
onClick={handleConfirmFormSubmit}
disabled={isRegistering}
/>
<Checkbox
name="is-ephemeral"
label="Stay signed in"
checked={!isEphemeral}
onChange={handleEphemeralChange}
disabled={isRegistering}
/>
{notesAndTagsCount > 0 ? (
<Checkbox
name="should-merge-local"
label={`Merge local data (${notesAndTagsCount} notes and tags)`}
checked={shouldMergeLocal}
onChange={handleShouldMergeChange}
disabled={isRegistering}
/>
) : null}
</form>
<div className="h-1px my-2 bg-border"></div>
<AdvancedOptions
appState={appState}
application={application}
disabled={isRegistering}
/>
</>
);
}
);

View File

@@ -0,0 +1,137 @@
import { WebApplication } from '@/ui_models/application';
import { AppState } from '@/ui_models/app_state';
import { observer } from 'mobx-react-lite';
import { FunctionComponent } from 'preact';
import { StateUpdater, useEffect, useRef, useState } from 'preact/hooks';
import { AccountMenuPane } from '.';
import { Button } from '../Button';
import { IconButton } from '../IconButton';
import { InputWithIcon } from '../InputWithIcon';
import { AdvancedOptions } from './AdvancedOptions';
type Props = {
appState: AppState;
application: WebApplication;
setMenuPane: (pane: AccountMenuPane) => void;
email: string;
setEmail: StateUpdater<string>;
password: string;
setPassword: StateUpdater<string>;
};
export const CreateAccount: FunctionComponent<Props> = observer(
({
appState,
application,
setMenuPane,
email,
setEmail,
password,
setPassword,
}) => {
const [showPassword, setShowPassword] = useState(false);
const emailInputRef = useRef<HTMLInputElement>();
const passwordInputRef = useRef<HTMLInputElement>();
useEffect(() => {
if (emailInputRef.current) {
emailInputRef.current.focus();
}
}, []);
const handleEmailChange = (e: Event) => {
if (e.target instanceof HTMLInputElement) {
setEmail(e.target.value);
}
};
const handlePasswordChange = (e: Event) => {
if (e.target instanceof HTMLInputElement) {
setPassword(e.target.value);
}
};
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Enter') {
handleRegisterFormSubmit(e);
}
};
const handleRegisterFormSubmit = (e: Event) => {
e.preventDefault();
if (!email || email.length === 0) {
emailInputRef?.current.focus();
return;
}
if (!password || password.length === 0) {
passwordInputRef?.current.focus();
return;
}
setEmail(email);
setPassword(password);
setMenuPane(AccountMenuPane.ConfirmPassword);
};
const handleClose = () => {
setMenuPane(AccountMenuPane.GeneralMenu);
setEmail('');
setPassword('');
};
return (
<>
<div className="flex items-center px-3 mt-1 mb-3">
<IconButton
icon="arrow-left"
title="Go back"
className="flex mr-2 color-neutral"
onClick={handleClose}
focusable={true}
/>
<div className="sn-account-menu-headline">Create account</div>
</div>
<form onSubmit={handleRegisterFormSubmit} className="px-3 mb-1">
<InputWithIcon
className="mb-2"
icon="email"
inputType="email"
placeholder="Email"
value={email}
onChange={handleEmailChange}
onKeyDown={handleKeyDown}
ref={emailInputRef}
/>
<InputWithIcon
className="mb-2"
icon="password"
inputType={showPassword ? 'text' : 'password'}
placeholder="Password"
value={password}
onChange={handlePasswordChange}
onKeyDown={handleKeyDown}
toggle={{
toggleOnIcon: 'eye-off',
toggleOffIcon: 'eye',
title: 'Show password',
toggled: showPassword,
onClick: setShowPassword,
}}
ref={passwordInputRef}
/>
<Button
className="btn-w-full mt-1"
label="Next"
type="primary"
onClick={handleRegisterFormSubmit}
/>
</form>
<div className="h-1px my-2 bg-border"></div>
<AdvancedOptions application={application} appState={appState} />
</>
);
}
);

View File

@@ -6,25 +6,26 @@ import { observer } from 'mobx-react-lite';
type Props = {
application: WebApplication;
appState: AppState;
}
};
const Footer = observer(({
application,
appState,
}: Props) => {
const Footer = observer(({ application, appState }: Props) => {
const {
showLogin,
showSignIn,
showRegister,
setShowLogin,
setShowSignIn,
setShowRegister,
setSigningOut
setSigningOut,
} = appState.accountMenu;
const user = application.getUser();
const { showBetaWarning, disableBetaWarning: disableAppStateBetaWarning } = appState;
const { showBetaWarning, disableBetaWarning: disableAppStateBetaWarning } =
appState;
const [appVersion] = useState(() => `v${((window as any).electronAppVersion || application.bridge.appVersion)}`);
const [appVersion] = useState(
() =>
`v${(window as any).electronAppVersion || application.bridge.appVersion}`
);
const disableBetaWarning = () => {
disableAppStateBetaWarning();
@@ -35,7 +36,7 @@ const Footer = observer(({
};
const hidePasswordForm = () => {
setShowLogin(false);
setShowSignIn(false);
setShowRegister(false);
};
@@ -46,16 +47,20 @@ const Footer = observer(({
<span>{appVersion}</span>
{showBetaWarning && (
<span>
<span> (</span>
<a className="sk-a" onClick={disableBetaWarning}>Hide beta warning</a>
<span>)</span>
</span>
<span> (</span>
<a className="sk-a" onClick={disableBetaWarning}>
Hide beta warning
</a>
<span>)</span>
</span>
)}
</div>
{(showLogin || showRegister) && (
<a className="sk-a right" onClick={hidePasswordForm}>Cancel</a>
{(showSignIn || showRegister) && (
<a className="sk-a right" onClick={hidePasswordForm}>
Cancel
</a>
)}
{!showLogin && !showRegister && (
{!showSignIn && !showRegister && (
<a className="sk-a right danger capitalize" onClick={signOut}>
{user ? 'Sign out' : 'Clear session data'}
</a>

View File

@@ -0,0 +1,166 @@
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 { SyncQueueStrategy } from '@standardnotes/snjs';
import { STRING_GENERIC_SYNC_ERROR } from '@/strings';
import { useState } from 'preact/hooks';
import { AccountMenuPane } from '.';
import { FunctionComponent } from 'preact';
type Props = {
appState: AppState;
application: WebApplication;
setMenuPane: (pane: AccountMenuPane) => void;
closeMenu: () => void;
};
const iconClassName = 'color-grey-1 mr-2';
export const GeneralAccountMenu: FunctionComponent<Props> = observer(
({ application, appState, setMenuPane, closeMenu }) => {
const [isSyncingInProgress, setIsSyncingInProgress] = useState(false);
const [lastSyncDate, setLastSyncDate] = useState(
formatLastSyncDate(application.getLastSyncDate() as Date)
);
const doSynchronization = async () => {
setIsSyncingInProgress(true);
application
.sync({
queueStrategy: SyncQueueStrategy.ForceSpawnNew,
checkIntegrity: true,
})
.then((res) => {
if (res && res.error) {
throw new Error();
} else {
setLastSyncDate(
formatLastSyncDate(application.getLastSyncDate() as Date)
);
}
})
.catch(() => {
application.alertService.alert(STRING_GENERIC_SYNC_ERROR);
})
.finally(() => {
setIsSyncingInProgress(false);
});
};
const user = application.getUser();
return (
<>
<div className="flex items-center justify-between px-3 mt-1 mb-3">
<div className="sn-account-menu-headline">Account</div>
<div className="flex cursor-pointer" onClick={closeMenu}>
<Icon type="close" className="color-grey-1" />
</div>
</div>
{user ? (
<>
<div className="px-3 mb-2 color-foreground text-sm">
<div>You're signed in as:</div>
<div className="font-bold">{user.email}</div>
</div>
<div className="flex items-center justify-between px-3 mb-2">
{isSyncingInProgress ? (
<div className="flex items-center color-info font-semibold">
<div className="sk-spinner w-5 h-5 mr-2 spinner-info"></div>
Syncing...
</div>
) : (
<div className="flex items-center success font-semibold">
<Icon type="check-circle" className="mr-2" />
Last synced: {lastSyncDate}
</div>
)}
<div
className="flex cursor-pointer color-grey-1"
onClick={doSynchronization}
>
<Icon type="sync" />
</div>
</div>
</>
) : (
<>
<div className="px-3 mb-1">
<div className="mb-3 color-foreground">
Youre offline. Sign in to sync your notes and preferences
across all your devices and enable end-to-end encryption.
</div>
<div className="flex items-center color-grey-1">
<Icon type="cloud-off" className="mr-2" />
<span className="font-semibold">Offline</span>
</div>
</div>
</>
)}
<div className="h-1px my-2 bg-border"></div>
{user ? (
<button
className="sn-dropdown-item"
onClick={() => {
appState.accountMenu.closeAccountMenu();
appState.preferences.setCurrentPane('account');
appState.preferences.openPreferences();
}}
>
<Icon type="user" className={iconClassName} />
Account settings
</button>
) : (
<>
<button
className="sn-dropdown-item"
onClick={() => {
setMenuPane(AccountMenuPane.Register);
}}
>
<Icon type="user" className={iconClassName} />
Create free account
</button>
<button
className="sn-dropdown-item"
onClick={() => {
setMenuPane(AccountMenuPane.SignIn);
}}
>
<Icon type="signIn" className={iconClassName} />
Sign in
</button>
</>
)}
<button
className="sn-dropdown-item"
onClick={() => {
appState.accountMenu.closeAccountMenu();
appState.preferences.setCurrentPane('help-feedback');
appState.preferences.openPreferences();
}}
>
<Icon type="help" className={iconClassName} />
Help &amp; feedback
</button>
{user ? (
<>
<div className="h-1px my-2 bg-border"></div>
<button
className="sn-dropdown-item"
onClick={() => {
appState.accountMenu.setSigningOut(true);
}}
>
<Icon type="signOut" className={iconClassName} />
Sign out and clear local data
</button>
</>
) : null}
</>
);
}
);

View File

@@ -0,0 +1,227 @@
import { WebApplication } from '@/ui_models/application';
import { AppState } from '@/ui_models/app_state';
import { observer } from 'mobx-react-lite';
import { FunctionComponent } from 'preact';
import { useEffect, useRef, useState } from 'preact/hooks';
import { AccountMenuPane } from '.';
import { Button } from '../Button';
import { Checkbox } from '../Checkbox';
import { Icon } from '../Icon';
import { IconButton } from '../IconButton';
import { InputWithIcon } from '../InputWithIcon';
import { AdvancedOptions } from './AdvancedOptions';
type Props = {
appState: AppState;
application: WebApplication;
setMenuPane: (pane: AccountMenuPane) => void;
};
export const SignInPane: FunctionComponent<Props> = observer(
({ application, appState, setMenuPane }) => {
const { notesAndTagsCount } = appState.accountMenu;
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [isInvalid, setIsInvalid] = useState(false);
const [isEphemeral, setIsEphemeral] = useState(false);
const [isStrictSignin, setIsStrictSignin] = useState(false);
const [isSigningIn, setIsSigningIn] = useState(false);
const [showPassword, setShowPassword] = useState(false);
const [shouldMergeLocal, setShouldMergeLocal] = useState(true);
const emailInputRef = useRef<HTMLInputElement>();
const passwordInputRef = useRef<HTMLInputElement>();
useEffect(() => {
if (emailInputRef?.current) {
emailInputRef.current.focus();
}
}, []);
const resetInvalid = () => {
if (isInvalid) {
setIsInvalid(false);
}
};
const handleEmailChange = (e: Event) => {
if (e.target instanceof HTMLInputElement) {
setEmail(e.target.value);
}
};
const handlePasswordChange = (e: Event) => {
if (isInvalid) {
setIsInvalid(false);
}
if (e.target instanceof HTMLInputElement) {
setPassword(e.target.value);
}
};
const handleEphemeralChange = () => {
setIsEphemeral(!isEphemeral);
};
const handleStrictSigninChange = () => {
setIsStrictSignin(!isStrictSignin);
};
const handleShouldMergeChange = () => {
setShouldMergeLocal(!shouldMergeLocal);
};
const signIn = () => {
setIsSigningIn(true);
emailInputRef?.current.blur();
passwordInputRef?.current.blur();
application
.signIn(email, password, isStrictSignin, isEphemeral, shouldMergeLocal)
.then((res) => {
if (res.error) {
throw new Error(res.error.message);
}
appState.accountMenu.closeAccountMenu();
})
.catch((err) => {
console.error(err);
if (err.toString().includes('Invalid email or password')) {
setIsInvalid(true);
} else {
application.alertService.alert(err);
}
setPassword('');
passwordInputRef?.current.blur();
})
.finally(() => {
setIsSigningIn(false);
});
};
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Enter') {
handleSignInFormSubmit(e);
}
};
const handleSignInFormSubmit = (e: Event) => {
e.preventDefault();
if (!email || email.length === 0) {
emailInputRef?.current.focus();
return;
}
if (!password || password.length === 0) {
passwordInputRef?.current.focus();
return;
}
signIn();
};
return (
<>
<div className="flex items-center px-3 mt-1 mb-3">
<IconButton
icon="arrow-left"
title="Go back"
className="flex mr-2 color-neutral"
onClick={() => setMenuPane(AccountMenuPane.GeneralMenu)}
focusable={true}
disabled={isSigningIn}
/>
<div className="sn-account-menu-headline">Sign in</div>
</div>
<form onSubmit={handleSignInFormSubmit}>
<div className="px-3 mb-1">
<InputWithIcon
className={`mb-2 ${isInvalid ? 'border-dark-red' : null}`}
icon="email"
inputType="email"
placeholder="Email"
value={email}
onChange={handleEmailChange}
onFocus={resetInvalid}
onKeyDown={handleKeyDown}
disabled={isSigningIn}
ref={emailInputRef}
/>
<InputWithIcon
className={`mb-2 ${isInvalid ? 'border-dark-red' : null}`}
icon="password"
inputType={showPassword ? 'text' : 'password'}
placeholder="Password"
value={password}
onChange={handlePasswordChange}
onFocus={resetInvalid}
onKeyDown={handleKeyDown}
disabled={isSigningIn}
toggle={{
toggleOnIcon: 'eye-off',
toggleOffIcon: 'eye',
title: 'Show password',
toggled: showPassword,
onClick: setShowPassword,
}}
ref={passwordInputRef}
/>
{isInvalid ? (
<div className="color-dark-red my-2">
Invalid email or password.
</div>
) : null}
<Button
className="btn-w-full mt-1 mb-3"
label={isSigningIn ? 'Signing in...' : 'Sign in'}
type="primary"
onClick={handleSignInFormSubmit}
disabled={isSigningIn}
/>
<Checkbox
name="is-ephemeral"
label="Stay signed in"
checked={!isEphemeral}
disabled={isSigningIn}
onChange={handleEphemeralChange}
/>
{notesAndTagsCount > 0 ? (
<Checkbox
name="should-merge-local"
label={`Merge local data (${notesAndTagsCount} notes and tags)`}
checked={shouldMergeLocal}
disabled={isSigningIn}
onChange={handleShouldMergeChange}
/>
) : null}
</div>
</form>
<div className="h-1px my-2 bg-border"></div>
<AdvancedOptions
appState={appState}
application={application}
disabled={isSigningIn}
>
<div className="flex justify-between items-center mb-1">
<Checkbox
name="use-strict-signin"
label="Use strict sign-in"
checked={isStrictSignin}
disabled={isSigningIn}
onChange={handleStrictSigninChange}
/>
<a
href="https://standardnotes.com/help/security"
target="_blank"
rel="noopener noreferrer"
title="Learn more"
>
<Icon type="info" className="color-neutral" />
</a>
</div>
</AdvancedOptions>
</>
);
}
);

View File

@@ -2,72 +2,115 @@ import { observer } from 'mobx-react-lite';
import { toDirective } from '@/components/utils';
import { AppState } from '@/ui_models/app_state';
import { WebApplication } from '@/ui_models/application';
import { ConfirmSignoutContainer } from '@/components/ConfirmSignoutModal';
import Authentication from '@/components/AccountMenu/Authentication';
import Footer from '@/components/AccountMenu/Footer';
import User from '@/components/AccountMenu/User';
import { useEffect } from 'preact/hooks';
import { useState } from 'preact/hooks';
import { GeneralAccountMenu } from './GeneralAccountMenu';
import { FunctionComponent } from 'preact';
import { SignInPane } from './SignIn';
import { CreateAccount } from './CreateAccount';
import { ConfirmSignoutContainer } from '../ConfirmSignoutModal';
import { ConfirmPassword } from './ConfirmPassword';
export enum AccountMenuPane {
GeneralMenu,
SignIn,
Register,
ConfirmPassword,
}
type Props = {
appState: AppState;
application: WebApplication;
};
const AccountMenu = observer(({ application, appState }: Props) => {
const {
show: showAccountMenu,
showLogin,
showRegister,
setShowLogin,
setShowRegister,
closeAccountMenu
} = appState.accountMenu;
type PaneSelectorProps = Props & {
menuPane: AccountMenuPane;
setMenuPane: (pane: AccountMenuPane) => void;
closeMenu: () => void;
};
const user = application.getUser();
const MenuPaneSelector: FunctionComponent<PaneSelectorProps> = observer(
({ application, appState, menuPane, setMenuPane, closeMenu }) => {
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
useEffect(() => {
// Reset "Login" and "Registration" sections state when hiding account menu,
// so the next time account menu is opened these sections are closed
if (!showAccountMenu) {
setShowLogin(false);
setShowRegister(false);
}
}, [setShowLogin, setShowRegister, showAccountMenu]);
return (
<div className="sn-component">
<div id="account-panel" className="sk-panel">
<div className="sk-panel-header">
<div className="sk-panel-header-title">Account</div>
<a className="sk-a info close-button" onClick={closeAccountMenu}>Close</a>
</div>
<div className="sk-panel-content">
<Authentication
application={application}
switch (menuPane) {
case AccountMenuPane.GeneralMenu:
return (
<GeneralAccountMenu
appState={appState}
application={application}
setMenuPane={setMenuPane}
closeMenu={closeMenu}
/>
);
case AccountMenuPane.SignIn:
return (
<SignInPane
appState={appState}
application={application}
setMenuPane={setMenuPane}
/>
);
case AccountMenuPane.Register:
return (
<CreateAccount
appState={appState}
application={application}
setMenuPane={setMenuPane}
email={email}
setEmail={setEmail}
password={password}
setPassword={setPassword}
/>
);
case AccountMenuPane.ConfirmPassword:
return (
<ConfirmPassword
appState={appState}
application={application}
setMenuPane={setMenuPane}
email={email}
password={password}
setPassword={setPassword}
/>
);
}
}
);
const AccountMenu: FunctionComponent<Props> = observer(
({ application, appState }) => {
const {
currentPane,
setCurrentPane,
shouldAnimateCloseMenu,
closeAccountMenu,
} = appState.accountMenu;
return (
<div className="sn-component">
<div
className={`sn-account-menu sn-dropdown ${
shouldAnimateCloseMenu
? 'slide-up-animation'
: 'sn-dropdown--animated'
} min-w-80 max-h-120 max-w-xs flex flex-col py-2 overflow-y-auto absolute`}
>
<MenuPaneSelector
appState={appState}
application={application}
menuPane={currentPane}
setMenuPane={setCurrentPane}
closeMenu={closeAccountMenu}
/>
{!showLogin && !showRegister && user && (
<div>
<User
application={application}
appState={appState}
/>
</div>
)}
</div>
<ConfirmSignoutContainer
application={application}
appState={appState}
/>
<Footer
application={application}
appState={appState}
/>
</div>
</div>
);
});
export const AccountMenuDirective = toDirective<Props>(
AccountMenu
);
}
);
export const AccountMenuDirective = toDirective<Props>(AccountMenu);

View File

@@ -0,0 +1,32 @@
import { FunctionComponent } from 'preact';
type CheckboxProps = {
name: string;
checked: boolean;
onChange: (e: Event) => void;
disabled?: boolean;
label: string;
};
export const Checkbox: FunctionComponent<CheckboxProps> = ({
name,
checked,
onChange,
disabled,
label,
}) => {
return (
<label htmlFor={name} className="flex items-center fit-content mb-2">
<input
className="mr-2"
type="checkbox"
name={name}
id={name}
checked={checked}
onChange={onChange}
disabled={disabled}
/>
{label}
</label>
);
};

View File

@@ -36,11 +36,35 @@ import InfoIcon from '../../icons/ic-info.svg';
import CheckIcon from '../../icons/ic-check.svg';
import CheckBoldIcon from '../../icons/ic-check-bold.svg';
import AccountCircleIcon from '../../icons/ic-account-circle.svg';
import CloudOffIcon from '../../icons/ic-cloud-off.svg';
import SignInIcon from '../../icons/ic-signin.svg';
import SignOutIcon from '../../icons/ic-signout.svg';
import CheckCircleIcon from '../../icons/ic-check-circle.svg';
import SyncIcon from '../../icons/ic-sync.svg';
import ArrowLeftIcon from '../../icons/ic-arrow-left.svg';
import ChevronDownIcon from '../../icons/ic-chevron-down.svg';
import EmailIcon from '../../icons/ic-email.svg';
import ServerIcon from '../../icons/ic-server.svg';
import EyeIcon from '../../icons/ic-eye.svg';
import EyeOffIcon from '../../icons/ic-eye-off.svg';
import LockIcon from '../../icons/ic-lock.svg';
import { toDirective } from './utils';
import { FunctionalComponent } from 'preact';
const ICONS = {
lock: LockIcon,
eye: EyeIcon,
'eye-off': EyeOffIcon,
server: ServerIcon,
email: EmailIcon,
'chevron-down': ChevronDownIcon,
'arrow-left': ArrowLeftIcon,
sync: SyncIcon,
'check-circle': CheckCircleIcon,
signIn: SignInIcon,
signOut: SignOutIcon,
'cloud-off': CloudOffIcon,
'pencil-off': PencilOffIcon,
'plain-text': PlainTextIcon,
'rich-text': RichTextIcon,

View File

@@ -19,6 +19,8 @@ interface Props {
title: string;
focusable: boolean;
disabled?: boolean;
}
/**
@@ -31,6 +33,8 @@ export const IconButton: FunctionComponent<Props> = ({
icon,
title,
focusable,
iconClassName = '',
disabled = false,
}) => {
const click = (e: MouseEvent) => {
e.preventDefault();
@@ -42,8 +46,9 @@ export const IconButton: FunctionComponent<Props> = ({
title={title}
className={`no-border cursor-pointer bg-transparent flex flex-row items-center hover:brightness-130 p-0 ${focusableClass} ${className}`}
onClick={click}
disabled={disabled}
>
<Icon type={icon} />
<Icon type={icon} className={iconClassName} />
</button>
);
};

View File

@@ -0,0 +1,89 @@
import { FunctionComponent, Ref } from 'preact';
import { JSXInternal } from 'preact/src/jsx';
import { forwardRef } from 'preact/compat';
import { Icon, IconType } from './Icon';
import { IconButton } from './IconButton';
type ToggleProps = {
toggleOnIcon: IconType;
toggleOffIcon: IconType;
title: string;
toggled: boolean;
onClick: (toggled: boolean) => void;
};
type Props = {
icon: IconType;
inputType: 'text' | 'email' | 'password';
className?: string;
iconClassName?: string;
value: string | undefined;
onChange: JSXInternal.GenericEventHandler<HTMLInputElement>;
onFocus?: JSXInternal.GenericEventHandler<HTMLInputElement>;
onKeyDown?: JSXInternal.KeyboardEventHandler<HTMLInputElement>;
disabled?: boolean;
placeholder: string;
toggle?: ToggleProps;
};
const DISABLED_CLASSNAME = 'bg-grey-5 cursor-not-allowed';
export const InputWithIcon: FunctionComponent<Props> = forwardRef(
(
{
icon,
inputType,
className,
iconClassName,
value,
onChange,
onFocus,
onKeyDown,
disabled,
toggle,
placeholder,
},
ref: Ref<HTMLInputElement>
) => {
const handleToggle = () => {
if (toggle) toggle.onClick(!toggle?.toggled);
};
return (
<div
className={`flex items-stretch position-relative bg-default border-1 border-solid border-neutral rounded focus-within:ring-info overflow-hidden ${
disabled ? DISABLED_CLASSNAME : ''
} ${className}`}
>
<div className="flex px-2 py-1.5">
<Icon type={icon} className={`color-grey-1 ${iconClassName}`} />
</div>
<input
type={inputType}
onFocus={onFocus}
onChange={onChange}
onKeyDown={onKeyDown}
value={value}
className={`pr-2 w-full border-0 focus:shadow-none ${
disabled ? DISABLED_CLASSNAME : ''
}`}
disabled={disabled}
placeholder={placeholder}
ref={ref}
/>
{toggle ? (
<div className="flex items-center justify-center px-2">
<IconButton
className="w-5 h-5 justify-center sk-circle hover:bg-grey-4"
icon={toggle.toggled ? toggle.toggleOnIcon : toggle.toggleOffIcon}
iconClassName="sn-icon--small"
title={toggle.title}
onClick={handleToggle}
focusable={true}
/>
</div>
) : null}
</div>
);
}
);

View File

@@ -14,19 +14,18 @@ type Props = {
appState: AppState;
};
export const OtherSessionsLogoutContainer = observer((props: Props) => {
if (!props.appState.accountMenu.otherSessionsLogOut) {
export const OtherSessionsSignOutContainer = observer((props: Props) => {
if (!props.appState.accountMenu.otherSessionsSignOut) {
return null;
}
return <ConfirmOtherSessionsLogout {...props} />;
return <ConfirmOtherSessionsSignOut {...props} />;
});
const ConfirmOtherSessionsLogout = observer(
const ConfirmOtherSessionsSignOut = observer(
({ application, appState }: Props) => {
const cancelRef = useRef<HTMLButtonElement>();
function closeDialog() {
appState.accountMenu.setOtherSessionsLogout(false);
appState.accountMenu.setOtherSessionsSignOut(false);
}
return (
@@ -41,9 +40,10 @@ const ConfirmOtherSessionsLogout = observer(
</AlertDialogLabel>
<AlertDialogDescription className="sk-panel-row">
<p className="color-foreground">
This action will sign out all other devices signed into your account,
and remove your data from those devices when they next regain connection
to the internet. You may sign back in on those devices at any time.
This action will sign out all other devices signed into
your account, and remove your data from those devices when
they next regain connection to the internet. You may sign
back in on those devices at any time.
</p>
</AlertDialogDescription>
<div className="flex my-1 mt-4">
@@ -60,9 +60,9 @@ const ConfirmOtherSessionsLogout = observer(
application.revokeAllOtherSessions();
closeDialog();
application.alertService.alert(
"You have successfully revoked your sessions from other devices.",
'You have successfully revoked your sessions from other devices.',
undefined,
"Finish"
'Finish'
);
}}
>