From 7e655666164b02fd41607ef850bac0e44f516b2e Mon Sep 17 00:00:00 2001 From: Dennis Snell Date: Mon, 11 May 2020 18:50:41 -0700 Subject: [PATCH] Refactor: Extract authentication and login screen from main app (#2066) Explored in 5f1f65d Explored in e3c660e This work exists in preparation for further and deeper work to decouple Simperium in the app data flows and to finish the internal state refactors. The goal here is to initialize Simperium only after having proper authentication in order to allow us to move Simperium into Redux middleware. The further goal is to remove the race condition that exists in many places between making an edit or clicking a button, making a network call to Simperium (or not), updating indexedDB, and rerendering the app. App boot is now handled on its own and centralizes the token-loading process. Upon logout it force-reloads the browser window to clear out app state. This is necessary due to the ways that things like client and app-state initialize their variables in the module global scope. We can't reload the app state once the module has been imported the first time. Reloading the page completely resets this. There is a flash of a white screen when logging out. The auth component of app state has been correspondingly removed because the app will not load without an authorization. A new action LOGOUT has been created in order to trigger a logout in the app, driven from the Simperium middleware. --- lib/app.tsx | 80 ++------ lib/auth/index.tsx | 67 +++---- lib/boot-with-auth.tsx | 97 +++++++++ lib/boot-without-auth.tsx | 117 +++++++++++ lib/boot.ts | 321 +++++++++--------------------- lib/dialogs/settings/index.tsx | 43 ++-- lib/flux/app-state.ts | 8 +- lib/global.d.ts | 12 ++ lib/logging-out.tsx | 34 ++++ lib/simperium/index.ts | 2 +- lib/state/action-types.ts | 9 +- lib/state/actions.ts | 2 - lib/state/auth/actions.ts | 26 --- lib/state/auth/constants.ts | 6 - lib/state/auth/reducer.ts | 13 -- lib/state/auth/selectors.ts | 13 -- lib/state/index.ts | 43 ++-- lib/state/selectors.ts | 5 - lib/state/settings/actions.ts | 5 - lib/state/settings/reducer.ts | 2 - lib/state/simperium/middleware.ts | 104 +++++++--- lib/state/ui/actions.ts | 4 + lib/types.ts | 2 +- lib/typings/simperium/index.d.ts | 38 ++++ package.json | 2 +- tsconfig.json | 3 +- 26 files changed, 561 insertions(+), 497 deletions(-) create mode 100644 lib/boot-with-auth.tsx create mode 100644 lib/boot-without-auth.tsx create mode 100644 lib/logging-out.tsx delete mode 100644 lib/state/auth/actions.ts delete mode 100644 lib/state/auth/constants.ts delete mode 100644 lib/state/auth/reducer.ts delete mode 100644 lib/state/auth/selectors.ts delete mode 100644 lib/state/selectors.ts create mode 100644 lib/typings/simperium/index.d.ts diff --git a/lib/app.tsx b/lib/app.tsx index 31b0ca5c6..a626ad8b1 100644 --- a/lib/app.tsx +++ b/lib/app.tsx @@ -5,13 +5,10 @@ import { connect } from 'react-redux'; import 'focus-visible/dist/focus-visible.js'; import appState from './flux/app-state'; import { loadTags } from './state/domain/tags'; -import reduxActions from './state/actions'; -import selectors from './state/selectors'; import browserShell from './browser-shell'; import NoteInfo from './note-info'; import NavigationBar from './navigation-bar'; import AppLayout from './app-layout'; -import Auth from './auth'; import DevBadge from './components/dev-badge'; import DialogRenderer from './dialog-renderer'; import { getIpcRenderer } from './utils/electron'; @@ -42,11 +39,6 @@ export type OwnProps = { noteBucket: object; }; -export type StateProps = S.State & { - authIsPending: boolean; - isAuthorized: boolean; -}; - export type DispatchProps = { createNote: () => any; closeNote: () => any; @@ -56,13 +48,9 @@ export type DispatchProps = { trashNote: (previousIndex: number) => any; }; -export type Props = OwnProps & StateProps & DispatchProps; +export type Props = OwnProps & DispatchProps; -const mapStateToProps: S.MapState = (state) => ({ - ...state, - authIsPending: selectors.auth.authIsPending(state), - isAuthorized: selectors.auth.isAuthorized(state), -}); +const mapStateToProps: S.MapState = (state) => state; const mapDispatchToProps: S.MapDispatch< DispatchProps, @@ -106,9 +94,7 @@ const mapDispatchToProps: S.MapDispatch< toggleSortTagsAlpha: thenReloadTags(settingsActions.toggleSortTagsAlpha), createNote: () => dispatch(createNote()), openTagList: () => dispatch(toggleNavigation()), - resetAuth: () => dispatch(reduxActions.auth.reset()), selectNote: (note: T.NoteEntity) => dispatch(actions.ui.selectNote(note)), - setAuthorized: () => dispatch(reduxActions.auth.setAuthorized()), focusSearchField: () => dispatch(actions.ui.focusSearchField()), setSimperiumConnectionStatus: (connected) => dispatch(toggleSimperiumConnectionStatus(connected)), @@ -129,31 +115,17 @@ export const App = connect( static propTypes = { actions: PropTypes.object.isRequired, appState: PropTypes.object.isRequired, - authIsPending: PropTypes.bool.isRequired, - authorizeUserWithToken: PropTypes.func.isRequired, client: PropTypes.object.isRequired, - isAuthorized: PropTypes.bool.isRequired, isDevConfig: PropTypes.bool.isRequired, isSmallScreen: PropTypes.bool.isRequired, loadTags: PropTypes.func.isRequired, - onAuthenticate: PropTypes.func.isRequired, - onCreateUser: PropTypes.func.isRequired, openTagList: PropTypes.func.isRequired, - onSignOut: PropTypes.func.isRequired, settings: PropTypes.object.isRequired, preferencesBucket: PropTypes.object.isRequired, - resetAuth: PropTypes.func.isRequired, - setAuthorized: PropTypes.func.isRequired, systemTheme: PropTypes.string.isRequired, tagBucket: PropTypes.object.isRequired, }; - static defaultProps = { - onAuthenticate: () => {}, - onCreateUser: () => {}, - onSignOut: () => {}, - }; - UNSAFE_componentWillMount() { if (isElectron) { this.initializeElectron(); @@ -341,21 +313,9 @@ export const App = connect( onAuthChanged = () => { const { - actions, appState: { accountName }, - client, - resetAuth, - setAuthorized, } = this.props; - actions.authChanged(); - - if (!client.isAuthorized()) { - this.props.closeNote(); - return resetAuth(); - } - - setAuthorized(); analytics.initialize(accountName); this.onLoadPreferences(); @@ -487,8 +447,6 @@ export const App = connect( render() { const { appState: state, - authIsPending, - isAuthorized, isDevConfig, noteBucket, preferencesBucket, @@ -515,29 +473,19 @@ export const App = connect( return (
{isDevConfig && } - {isAuthorized ? ( -
- {showNavigation && } - - {showNoteInfo && } -
- ) : ( - + {showNavigation && } + - )} + {showNoteInfo && } +
any; + signup: (username: string, password: string) => any; + tokenLogin: (username: string, token: string) => any; + resetErrors: () => any; +}; + +type Props = OwnProps; +export class Auth extends Component { state = { isCreatingAccount: false, passwordErrorMessage: null, + onLine: window.navigator.onLine, }; componentDidMount() { if (this.usernameInput) { this.usernameInput.focus(); } + + window.addEventListener('online', this.setConnectivity, false); + window.addEventListener('offline', this.setConnectivity, false); } + componentWillUnmount() { + window.removeEventListener('online', this.setConnectivity, false); + window.removeEventListener('offline', this.setConnectivity, false); + } + + setConnectivity = () => this.setState({ onLine: window.navigator.onLine }); + render() { // Don't render this component when running on the web const config = getConfig(); @@ -67,7 +73,9 @@ export class Auth extends Component {

{buttonLabel}

- + {!this.state.onLine && ( +

Offline

+ )} {this.props.hasInvalidCredentials && (

@@ -207,7 +216,7 @@ export class Auth extends Component { return; } - this.props.onAuthenticate(username, password); + this.props.login(username, password); }; onWPLogin = () => { @@ -283,11 +292,7 @@ export class Auth extends Component { return; } - const { authorizeUserWithToken, saveWPToken } = this.props; - authorizeUserWithToken(userEmail, simpToken); - if (wpccToken) { - saveWPToken(wpccToken); - } + this.props.tokenLogin(userEmail, simpToken); } ); }; @@ -331,7 +336,7 @@ export class Auth extends Component { return; } - this.props.onCreateUser(username, password); + this.props.signup(username, password, true); this.setState({ passwordErrorMessage: null }); }; @@ -344,15 +349,3 @@ export class Auth extends Component { this.setState({ isCreatingAccount: !this.state.isCreatingAccount }); }; } - -const mapDispatchToProps = (dispatch) => ({ - resetErrors: () => dispatch(reset()), - saveWPToken: (token) => dispatch(setWPToken(token)), -}); - -const mapStateToProps = (state) => ({ - hasInvalidCredentials: hasInvalidCredentials(state), - hasLoginError: hasLoginError(state), -}); - -export default connect(mapStateToProps, mapDispatchToProps)(Auth); diff --git a/lib/boot-with-auth.tsx b/lib/boot-with-auth.tsx new file mode 100644 index 000000000..ab59c0455 --- /dev/null +++ b/lib/boot-with-auth.tsx @@ -0,0 +1,97 @@ +if (__TEST__) { + window.testEvents = []; +} + +import 'core-js/stable'; +import 'regenerator-runtime/runtime'; +import 'unorm'; + +import React from 'react'; +import App from './app'; +import Modal from 'react-modal'; +import Debug from 'debug'; +import { initClient } from './client'; +import getConfig from '../get-config'; +import { makeStore } from './state'; +import actions from './state/actions'; +import { initSimperium } from './state/simperium/middleware'; +import { render } from 'react-dom'; +import { Provider } from 'react-redux'; + +import '../scss/style.scss'; + +import isDevConfig from './utils/is-dev-config'; +import { normalizeForSorting } from './utils/note-utils'; + +import * as T from './types'; + +const config = getConfig(); +const appID = config.app_id; + +export const bootWithToken = ( + logout: () => any, + token: string, + username: string | null, + createWelcomeNote: boolean +) => { + const client = initClient({ + appID, + token, + bucketConfig: { + note: { + beforeIndex: function (note: T.NoteEntity) { + var content = (note.data && note.data.content) || ''; + + return { + ...note, + contentKey: normalizeForSorting(content), + }; + }, + configure: function (objectStore) { + objectStore.createIndex('modificationDate', 'data.modificationDate'); + objectStore.createIndex('creationDate', 'data.creationDate'); + objectStore.createIndex('alphabetical', 'contentKey'); + }, + }, + preferences: function (objectStore) { + console.log('Configure preferences', objectStore); // eslint-disable-line no-console + }, + tag: function (objectStore) { + console.log('Configure tag', objectStore); // eslint-disable-line no-console + }, + }, + database: 'simplenote', + version: 42, + }); + + const debug = Debug('client'); + const l = (msg: string) => (...args: unknown[]) => debug(msg, ...args); + + client + .on('connect', l('Connected')) + .on('disconnect', l('Not connected')) + .on('message', l('<=')) + .on('send', l('=>')) + .on('unauthorized', l('Not authorized')); + + Modal.setAppElement('#root'); + + const store = makeStore( + initSimperium(logout, token, username, createWelcomeNote, client) + ); + + store.dispatch(actions.settings.setAccountName(username)); + + render( + + + , + document.getElementById('root') + ); +}; diff --git a/lib/boot-without-auth.tsx b/lib/boot-without-auth.tsx new file mode 100644 index 000000000..93bbf5dc8 --- /dev/null +++ b/lib/boot-without-auth.tsx @@ -0,0 +1,117 @@ +import React, { Component } from 'react'; +import { render } from 'react-dom'; +import { Auth as AuthApp } from './auth'; +import { Auth as SimperiumAuth } from 'simperium'; +import analytics from './analytics'; + +import getConfig from '../get-config'; + +import '../scss/style.scss'; + +type Props = { + onAuth: (token: string, username: string, createWelcomeNote: boolean) => any; +}; + +type State = { + authStatus: + | 'unsubmitted' + | 'submitting' + | 'invalid-credentials' + | 'unknown-error'; +}; + +type User = { + access_token?: string; +}; + +const appProvider = 'simplenote.com'; +const config = getConfig(); +const auth = new SimperiumAuth(config.app_id, config.app_key); + +class AppWithoutAuth extends Component { + state: State = { + authStatus: 'unsubmitted', + }; + + authenticate = (username: string, password: string) => { + if (!(username && password)) { + return; + } + + this.setState({ authStatus: 'submitting' }, () => { + auth + .authorize(username, password) + .then((user: User) => { + if (!user.access_token) { + throw new Error('missing access token'); + } + this.props.onAuth(user.access_token, username, false); + }) + .catch((error: unknown) => { + if ( + 'invalid password' === error?.message || + error?.message.startsWith('unknown username:') + ) { + this.setState({ authStatus: 'invalid-credentials' }); + } else { + this.setState({ authStatus: 'unknown-error' }); + } + }); + }); + }; + + createUser = (username: string, password: string) => { + if (!(username && password)) { + return; + } + + this.setState({ authStatus: 'submitting' }, () => { + auth + .create(username, password, appProvider) + .then((user: User) => { + if (!user.access_token) { + throw new Error('missing access token'); + } + + analytics.tracks.recordEvent('user_account_created'); + this.props.onAuth(user.access_token, username, true); + }) + .catch(() => { + this.setState({ authStatus: 'unknown-error' }); + }); + }); + }; + + tokenLogin = (username: string, token: string) => { + this.props.onAuth(token, username, false); + }; + + render() { + const systemTheme = window.matchMedia('(prefers-color-scheme: dark)') + .matches + ? 'dark' + : 'light'; + + return ( +

+ this.setState({ authStatus: 'unsubmitted' })} + /> +
+ ); + } +} + +export const boot = ( + onAuth: (token: string, username: string, createWelcomeNote: boolean) => any +) => { + render(, document.getElementById('root')); +}; diff --git a/lib/boot.ts b/lib/boot.ts index c13603900..c7d90ad03 100644 --- a/lib/boot.ts +++ b/lib/boot.ts @@ -1,246 +1,123 @@ -if (__TEST__) { - window.testEvents = []; -} - import './utils/ensure-platform-support'; -import 'core-js/stable'; -import 'regenerator-runtime/runtime'; -import 'unorm'; - -import React from 'react'; -import App from './app'; -import Modal from 'react-modal'; -import Debug from 'debug'; -import { initClient } from './client'; -import getConfig from '../get-config'; -import store from './state'; -import * as simperiumMiddleware from './state/simperium/middleware'; -import { - reset as resetAuth, - setAuthorized, - setInvalidCredentials, - setLoginError, - setPending as setPendingAuth, -} from './state/auth/actions'; -import { setAccountName } from './state/settings/actions'; -import analytics from './analytics'; -import { Auth } from 'simperium'; -import { parse } from 'cookie'; -import { render } from 'react-dom'; -import { Provider } from 'react-redux'; -import { get, some } from 'lodash'; -import '../scss/style.scss'; - -import { content as welcomeMessage } from './welcome-message'; - -import appState from './flux/app-state'; -import isDevConfig from './utils/is-dev-config'; -import { normalizeForSorting } from './utils/note-utils'; -const { newNote } = appState.actionCreators; +import { parse } from 'cookie'; -import * as T from './types'; +import analytics from './analytics'; +import getConfig from '../get-config'; +import { boot as bootWithoutAuth } from './boot-without-auth'; +import { boot as bootLoggingOut } from './logging-out'; +import { isElectron } from './utils/platform'; const config = getConfig(); -const cookie = parse(document.cookie); -const auth = new Auth(config.app_id, config.app_key); -const appProvider = 'simplenote.com'; +const clearStorage = () => + new Promise((resolve) => { + localStorage.removeItem('access_token'); + localStorage.removeItem('lastSyncedTime'); + localStorage.removeItem('localQueue:note'); + localStorage.removeItem('localQueue:preferences'); + localStorage.removeItem('localQueue:tag'); + localStorage.removeItem('stored_user'); + indexedDB.deleteDatabase('ghost'); + indexedDB.deleteDatabase('simplenote'); + if (isElectron) { + const ipcRenderer = __non_webpack_require__('electron').ipcRenderer; // eslint-disable-line no-undef + ipcRenderer.send('clearCookies'); + } -const appID = config.app_id; -let token = cookie.token || localStorage.access_token; + // let everything settle + setTimeout(() => resolve(), 500); + }); -// Signs out the user from app engine and redirects to signin page -const redirectToWebSigninIfNecessary = () => { - if (!config.is_app_engine) { - return; +const forceReload = () => history.go(); + +const loadAccount = () => { + const storedUserData = localStorage.getItem('stored_user'); + if (!storedUserData) { + return [null, null]; } - if (window.webConfig && window.webConfig.signout) { - window.webConfig.signout(function () { - window.location = `${config.app_engine_url}/`; - }); + try { + const storedUser = JSON.parse(storedUserData); + return [storedUser.accessToken, storedUser.username]; + } catch (e) { + return [null, null]; } }; -// Redirect to web sign in if running on App Engine -if (!token && config.is_app_engine) { - redirectToWebSigninIfNecessary(); -} - -const client = initClient({ - appID, - token, - bucketConfig: { - note: { - beforeIndex: function (note: T.NoteEntity) { - var content = (note.data && note.data.content) || ''; - - return { - ...note, - contentKey: normalizeForSorting(content), - }; - }, - configure: function (objectStore) { - objectStore.createIndex('modificationDate', 'data.modificationDate'); - objectStore.createIndex('creationDate', 'data.creationDate'); - objectStore.createIndex('alphabetical', 'contentKey'); - }, - }, - preferences: function (objectStore) { - console.log('Configure preferences', objectStore); // eslint-disable-line no-console - }, - tag: function (objectStore) { - console.log('Configure tag', objectStore); // eslint-disable-line no-console - }, - }, - database: 'simplenote', - version: 42, -}); - -const debug = Debug('client'); -const l = (msg: string) => (...args: unknown[]) => debug(msg, ...args); - -client - .on('connect', l('Connected')) - .on('disconnect', l('Not connected')) - .on('message', l('<=')) - .on('send', l('=>')) - .on('unauthorized', l('Not authorized')); - -client.on('unauthorized', () => { - // If a token exists, we probaly reached this point from a password change. - // The client should sign out the user, but preserve db content in case - // some data has not synced yet. - if (token) { - client.clearAuthorization(); - redirectToWebSigninIfNecessary(); - return; - } +const saveAccount = (accessToken: string, username: string): void => { + localStorage.setItem( + 'stored_user', + JSON.stringify({ accessToken, username }) + ); +}; - client.reset().then(() => { - console.log('Reset complete'); // eslint-disable-line no-console - }); -}); - -let props = { - client: client, - noteBucket: client.bucket('note'), - preferencesBucket: client.bucket('preferences'), - tagBucket: client.bucket('tag'), - isDevConfig: isDevConfig(config?.development), - onAuthenticate: (username: string, password: string) => { - if (!(username && password)) { - return; - } +const getStoredAccount = () => { + const [storedToken, storedUsername] = loadAccount(); - store.dispatch(setPendingAuth()); - auth - .authorize(username, password) - .then((user) => { - resetStorageIfAccountChanged(username); - if (!user.access_token) { - return store.dispatch(resetAuth()); - } - - store.dispatch(setAccountName(username)); - store.dispatch(setAuthorized()); - localStorage.access_token = user.access_token; - token = user.access_token; - client.setUser(user); - analytics.tracks.recordEvent('user_signed_in'); - }) - .catch(({ message }: { message: string }) => { - if ( - some([ - 'invalid password' === message, - message.startsWith('unknown username:'), - ]) - ) { - store.dispatch(setInvalidCredentials()); - } else { - store.dispatch(setLoginError()); - } - }); - }, - onCreateUser: (username: string, password: string) => { - if (!(username && password)) { - return; + // App Engine gets preference if it sends authentication details + const cookie = parse(document.cookie); + if (config.is_app_engine && cookie?.token && cookie?.email) { + if (cookie.email !== storedUsername) { + clearStorage(); + saveAccount(cookie.token, cookie.email); } + return [cookie.token, cookie.email]; + } - store.dispatch(setPendingAuth()); - auth - .create(username, password, appProvider) - .then((user) => { - resetStorageIfAccountChanged(username); - if (!user.access_token) { - return store.dispatch(resetAuth()); - } - - store.dispatch(setAccountName(username)); - store.dispatch(setAuthorized()); - localStorage.setItem('access_token', user.access_token); - token = user.access_token; - client.setUser(user); - analytics.tracks.recordEvent('user_account_created'); - analytics.tracks.recordEvent('user_signed_in'); - }) - .then(() => - store.dispatch( - newNote({ - noteBucket: client.bucket('note'), - content: welcomeMessage, - }) - ) - ) - .catch(() => { - store.dispatch(setLoginError()); - }); - }, - onSignOut: () => { - delete localStorage.access_token; - token = null; - store.dispatch(setAccountName(null)); - client.deauthorize(); - redirectToWebSigninIfNecessary(); - analytics.tracks.recordEvent('user_signed_out'); - }, - authorizeUserWithToken: (accountName: string, userToken: string) => { - resetStorageIfAccountChanged(accountName); - localStorage.setItem('access_token', userToken); - token = userToken; - store.dispatch(setAccountName(accountName)); - store.dispatch(setAuthorized()); - - const user = { access_token: userToken }; - client.setUser(user); - - analytics.tracks.recordEvent('user_signed_in'); - }, -}; + if (storedToken) { + return [storedToken, storedUsername]; + } -// If we sign in with a different username, ensure storage is reset -function resetStorageIfAccountChanged(newAccountName: string) { - const accountName = get(store.getState(), 'settings.accountName', ''); - if (accountName !== newAccountName) { - client.reset(); + const accessToken = localStorage.getItem('access_token'); + if (accessToken) { + return [accessToken, null]; } -} -// Set account email if app engine provided it -if (cookie.email && config.is_app_engine) { - // If the stored email doesn't match, we should reset the app storage - resetStorageIfAccountChanged(cookie.email); + return [null, null]; +}; + +const [storedToken, storedUsername] = getStoredAccount(); - store.dispatch(setAccountName(cookie.email)); +if (config.is_app_engine && !storedToken) { + window.webConfig?.signout?.(() => { + window.location = `${config.app_engine_url}/`; + }); } -Modal.setAppElement('#root'); -simperiumMiddleware.storeBuckets({ - note: client.bucket('note'), -}); +const run = ( + token: string | null, + username: string | null, + createWelcomeNote: boolean +) => { + if (token) { + import('./boot-with-auth').then(({ bootWithToken }) => { + bootWithToken( + () => { + bootLoggingOut(); + analytics.tracks.recordEvent('user_signed_out'); + clearStorage().then(() => { + if (window.webConfig?.signout) { + window.webConfig.signout(forceReload); + } else { + forceReload(); + } + }); + }, + token, + username, + createWelcomeNote + ); + }); + } else { + bootWithoutAuth( + (token: string, username: string, createWelcomeNote: boolean) => { + saveAccount(token, username); + analytics.tracks.recordEvent('user_signed_in'); + run(token, username, createWelcomeNote); + } + ); + } +}; -render( - React.createElement(Provider, { store }, React.createElement(App, props)), - document.getElementById('root') -); +run(storedToken, storedUsername, false); diff --git a/lib/dialogs/settings/index.tsx b/lib/dialogs/settings/index.tsx index b25ad1295..d450b4713 100644 --- a/lib/dialogs/settings/index.tsx +++ b/lib/dialogs/settings/index.tsx @@ -1,6 +1,5 @@ import React, { Component } from 'react'; import { connect } from 'react-redux'; -import PropTypes from 'prop-types'; import Dialog from '../../dialog'; import { isElectron } from '../../utils/platform'; @@ -12,9 +11,8 @@ import DisplayPanel from './panels/display'; import ToolsPanel from './panels/tools'; import appState from '../../flux/app-state'; -import { setWPToken } from '../../state/settings/actions'; -import { closeDialog } from '../../state/ui/actions'; +import { closeDialog, logout } from '../../state/ui/actions'; import * as S from '../../state'; @@ -31,7 +29,7 @@ type OwnProps = { type DispatchProps = { closeDialog: () => any; - setWPToken: (token: string) => any; + logout: () => any; }; type Props = OwnProps & DispatchProps; @@ -62,50 +60,39 @@ export class SettingsDialog extends Component { ) ); + if (!notes) { + return this.props.logout(); + } + Promise.all(notes.map(noteHasSynced)).then( - () => this.signOut(), // All good, sign out now! + () => this.props.logout(), // All good, sign out now! () => this.showUnsyncedWarning() // Show a warning to the user ); }); }; - signOut = () => { - const { onSignOut, setWPToken } = this.props; - - // Reset the WordPress Token - setWPToken(null); - - onSignOut(); - - if (isElectron) { - const ipcRenderer = __non_webpack_require__('electron').ipcRenderer; // eslint-disable-line no-undef - ipcRenderer.send('clearCookies'); - } - }; - showUnsyncedWarning = () => { isElectron ? this.showElectronWarningDialog() : this.showWebWarningDialog(); }; showElectronWarningDialog = () => { const dialog = __non_webpack_require__('electron').remote.dialog; // eslint-disable-line no-undef - dialog.showMessageBox( - { + dialog + .showMessageBox({ type: 'warning', buttons: ['Delete Notes', 'Cancel', 'Visit Web App'], title: 'Unsynced Notes Detected', message: 'Logging out will delete any unsynced notes. You can verify your ' + 'synced notes by logging in to the Web App.', - }, - (response) => { + }) + .then(({ response }) => { if (response === 0) { - this.signOut(); + this.props.logout(); } else if (response === 2) { viewExternalUrl('https://app.simplenote.com'); } - } - ); + }); }; showWebWarningDialog = () => { @@ -117,7 +104,7 @@ export class SettingsDialog extends Component { ); if (shouldReallySignOut) { - this.signOut(); + this.props.logout(); } }; @@ -148,7 +135,7 @@ const { toggleShareAnalyticsPreference } = appState.actionCreators; const mapDispatchToProps: S.MapDispatch = (dispatch) => ({ closeDialog: () => dispatch(closeDialog()), - setWPToken: (token) => dispatch(setWPToken(token)), + logout: () => dispatch(logout()), toggleShareAnalyticsPreference: (args) => { dispatch(toggleShareAnalyticsPreference(args)); }, diff --git a/lib/flux/app-state.ts b/lib/flux/app-state.ts index fcca42398..65c644d23 100644 --- a/lib/flux/app-state.ts +++ b/lib/flux/app-state.ts @@ -11,6 +11,7 @@ const debug = Debug('appState'); const initialState: AppState = { notes: null, + preferences: { analytics_enabled: null }, tags: [], unsyncedNoteIds: [], // note bucket only }; @@ -19,13 +20,6 @@ export const actionMap = new ActionMap({ namespace: 'App', initialState, handlers: { - authChanged(state: AppState) { - return update(state, { - notes: { $set: null }, - tags: { $set: [] }, - }); - }, - showAllNotesAndSelectFirst: { creator() { return (dispatch, getState) => { diff --git a/lib/global.d.ts b/lib/global.d.ts index ba87bef28..b056988f7 100644 --- a/lib/global.d.ts +++ b/lib/global.d.ts @@ -2,11 +2,23 @@ import { TKQItem, TracksAPI } from './analytics/types'; declare global { const __TEST__: boolean; + function __non_webpack_require__( + moduleName: 'electron' + ): { + ipcRenderer: { + send(command: 'clearCookies'): void; + send(command: 'setAutoHideMenuBar', newValue: boolean); + }; + }; interface Window { analyticsEnabled: boolean; + location: Location; testEvents: (string | [string, ...any[]])[]; _tkq: TKQItem[] & { a: unknown }; + webConfig?: { + signout?: (callback: () => void) => void; + }; wpcom: { tracks: TracksAPI; }; diff --git a/lib/logging-out.tsx b/lib/logging-out.tsx new file mode 100644 index 000000000..c802c2359 --- /dev/null +++ b/lib/logging-out.tsx @@ -0,0 +1,34 @@ +import React, { Component } from 'react'; +import { render } from 'react-dom'; + +import '../scss/style.scss'; + +class LoggingOut extends Component { + render() { + const systemTheme = window.matchMedia('(prefers-color-scheme: dark)') + .matches + ? 'dark' + : 'light'; + + return ( +
+
+ Logging out… +
+
+ ); + } +} + +export const boot = () => { + render(, document.getElementById('root')); +}; diff --git a/lib/simperium/index.ts b/lib/simperium/index.ts index 750900344..3673aa7f3 100644 --- a/lib/simperium/index.ts +++ b/lib/simperium/index.ts @@ -31,7 +31,7 @@ function BrowserClient({ appID, token, bucketConfig, database, version }) { let objectStoreProvider = this.bucketDB.provider(); this.ghostStore = ghost_store; - this.client = simperium(appID, token, { + this.client = simperium<'note' | 'preferences' | 'tag'>(appID, token, { ghostStoreProvider: ghost_store, objectStoreProvider: function (bucket) { var store = objectStoreProvider.apply(null, arguments); diff --git a/lib/state/action-types.ts b/lib/state/action-types.ts index 99cb7d936..085e48cfa 100644 --- a/lib/state/action-types.ts +++ b/lib/state/action-types.ts @@ -1,7 +1,5 @@ import * as T from '../types'; -import { AuthState } from './auth/constants'; - export type Action< T extends string, Args extends { [extraProps: string]: unknown } = {} @@ -42,7 +40,6 @@ export type SetSpellCheck = Action< { spellCheckEnabled: boolean } >; export type SetTheme = Action<'setTheme', { theme: T.Theme }>; -export type SetWPToken = Action<'setWPToken', { token: string }>; /* * Normal action types @@ -56,6 +53,7 @@ export type FilterNotes = Action< { notes: T.NoteEntity[]; tags: T.TagEntity[] } >; export type FocusSearchField = Action<'FOCUS_SEARCH_FIELD'>; +export type Logout = Action<'LOGOUT'>; export type OpenNote = Action<'OPEN_NOTE', { note: T.NoteEntity }>; export type OpenTag = Action<'OPEN_TAG', { tag: T.TagEntity }>; export type RemoteNoteUpdate = Action< @@ -73,7 +71,6 @@ export type SelectRevision = Action< { revision: T.NoteEntity } >; export type SelectTrash = Action<'SELECT_TRASH'>; -export type SetAuth = Action<'AUTH_SET', { status: AuthState }>; export type SetSystemTag = Action< 'SET_SYSTEM_TAG', { note: T.NoteEntity; tagName: T.SystemTag; shouldHaveTag: boolean } @@ -113,6 +110,7 @@ export type ActionType = | LegacyAction | FilterNotes | FocusSearchField + | Logout | RemoteNoteUpdate | OpenNote | OpenTag @@ -122,7 +120,6 @@ export type ActionType = | SelectRevision | SelectTrash | SetAccountName - | SetAuth | SetAutoHideMenuBar | SetFocusMode | SetFontSize @@ -135,7 +132,6 @@ export type ActionType = | SetSystemTag | SetTheme | SetUnsyncedNoteIds - | SetWPToken | ShowAllNotes | ShowDialog | StoreRevisions @@ -208,7 +204,6 @@ type LegacyAction = note: T.NoteEntity; } > - | Action<'App.authChanged'> | Action<'App.emptyTrash', { noteBucket: T.Bucket }> | Action<'App.loadNotes', { noteBucket: T.Bucket }> | Action<'App.newNote', { noteBucket: T.Bucket; content: string }> diff --git a/lib/state/actions.ts b/lib/state/actions.ts index f465a0588..4eef4eb1b 100644 --- a/lib/state/actions.ts +++ b/lib/state/actions.ts @@ -1,11 +1,9 @@ -import * as auth from './auth/actions'; import * as settings from './settings/actions'; import * as simperium from './simperium/actions'; import * as tags from './tags/actions'; import * as ui from './ui/actions'; export default { - auth, simperium, settings, tags, diff --git a/lib/state/auth/actions.ts b/lib/state/auth/actions.ts deleted file mode 100644 index ce360684f..000000000 --- a/lib/state/auth/actions.ts +++ /dev/null @@ -1,26 +0,0 @@ -import * as A from '../action-types'; - -export const reset: A.ActionCreator = () => ({ - type: 'AUTH_SET', - status: 'not-authorized', -}); - -export const setInvalidCredentials: A.ActionCreator = () => ({ - type: 'AUTH_SET', - status: 'invalid-credentials', -}); - -export const setLoginError: A.ActionCreator = () => ({ - type: 'AUTH_SET', - status: 'login-error', -}); - -export const setPending: A.ActionCreator = () => ({ - type: 'AUTH_SET', - status: 'authorizing', -}); - -export const setAuthorized: A.ActionCreator = () => ({ - type: 'AUTH_SET', - status: 'authorized', -}); diff --git a/lib/state/auth/constants.ts b/lib/state/auth/constants.ts deleted file mode 100644 index f70e7679f..000000000 --- a/lib/state/auth/constants.ts +++ /dev/null @@ -1,6 +0,0 @@ -export type AuthState = - | 'authorized' - | 'authorizing' - | 'invalid-credentials' - | 'login-error' - | 'not-authorized'; diff --git a/lib/state/auth/reducer.ts b/lib/state/auth/reducer.ts deleted file mode 100644 index fb23a195e..000000000 --- a/lib/state/auth/reducer.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { combineReducers } from 'redux'; - -import * as A from '../action-types'; -import { AuthState } from './constants'; - -export const authStatus: A.Reducer = ( - state = 'not-authorized', - action -) => ('AUTH_SET' === action.type ? action.status : state); - -export default combineReducers({ - authStatus, -}); diff --git a/lib/state/auth/selectors.ts b/lib/state/auth/selectors.ts deleted file mode 100644 index e292f7096..000000000 --- a/lib/state/auth/selectors.ts +++ /dev/null @@ -1,13 +0,0 @@ -import * as S from '../'; - -export const authIsPending = (state: S.State) => - 'authorizing' === state.auth.authStatus; - -export const hasInvalidCredentials = (state: S.State) => - 'invalid-credentials' === state.auth.authStatus; - -export const hasLoginError = (state: S.State) => - 'login-error' === state.auth.authStatus; - -export const isAuthorized = (state: S.State) => - 'authorized' === state.auth.authStatus; diff --git a/lib/state/index.ts b/lib/state/index.ts index f68af6140..1cd03cb9b 100644 --- a/lib/state/index.ts +++ b/lib/state/index.ts @@ -7,8 +7,6 @@ import { Dispatch as ReduxDispatch, Middleware as ReduxMiddleware, - MiddlewareAPI, - Store as ReduxStore, compose, createStore, combineReducers, @@ -22,9 +20,7 @@ import appState from '../flux/app-state'; import { middleware as searchMiddleware } from '../search'; import searchFieldMiddleware from './ui/search-field-middleware'; -import simperiumMiddleware from './simperium/middleware'; -import auth from './auth/reducer'; import settings from './settings/reducer'; import tags from './tags/reducer'; import ui from './ui/reducer'; @@ -41,9 +37,8 @@ export type AppState = { unsyncedNoteIds: T.EntityId[]; }; -export const reducers = combineReducers({ +const reducers = combineReducers({ appState: appState.reducer.bind(appState), - auth, settings, tags, ui, @@ -51,7 +46,6 @@ export const reducers = combineReducers({ export type State = { appState: AppState; - auth: ReturnType; settings: ReturnType; tags: ReturnType; ui: ReturnType; @@ -59,24 +53,25 @@ export type State = { const composeEnhancers = window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__ || compose; -export const store = createStore( - reducers, - composeEnhancers( - persistState('settings', { - key: 'simpleNote', - slicer: (path) => (state) => ({ - // Omit property from persisting - [path]: omit(state[path], 'focusModeEnabled'), +export const makeStore = (...middlewares) => + createStore( + reducers, + composeEnhancers( + persistState('settings', { + key: 'simpleNote', + slicer: (path) => (state) => ({ + // Omit property from persisting + [path]: omit(state[path], ['accountName', 'focusModeEnabled']), + }), }), - }), - applyMiddleware( - thunk, - searchMiddleware, - searchFieldMiddleware, - simperiumMiddleware + applyMiddleware( + thunk, + searchMiddleware, + searchFieldMiddleware, + ...middlewares + ) ) - ) -); + ); export type Store = { dispatch: Dispatch; @@ -110,5 +105,3 @@ export type Middleware = ReduxMiddleware< State, Dispatch >; - -export default store; diff --git a/lib/state/selectors.ts b/lib/state/selectors.ts deleted file mode 100644 index 6c6098eb9..000000000 --- a/lib/state/selectors.ts +++ /dev/null @@ -1,5 +0,0 @@ -import * as auth from './auth/selectors'; - -export default { - auth, -}; diff --git a/lib/state/settings/actions.ts b/lib/state/settings/actions.ts index 5c8ac7983..f6ce81af0 100644 --- a/lib/state/settings/actions.ts +++ b/lib/state/settings/actions.ts @@ -75,11 +75,6 @@ export const setAccountName: A.ActionCreator = ( accountName, }); -export const setWPToken: A.ActionCreator = (token) => ({ - type: 'setWPToken', - token, -}); - export const toggleFocusMode = () => (dispatch, getState) => { dispatch({ type: 'setFocusMode', diff --git a/lib/state/settings/reducer.ts b/lib/state/settings/reducer.ts index b4d971f5d..6f0770ba4 100644 --- a/lib/state/settings/reducer.ts +++ b/lib/state/settings/reducer.ts @@ -53,8 +53,6 @@ const reducer: A.Reducer = ( return { ...state, spellCheckEnabled: action.spellCheckEnabled }; case 'setTheme': return { ...state, theme: action.theme }; - case 'setWPToken': - return { ...state, wpToken: action.token }; default: return state; } diff --git a/lib/state/simperium/middleware.ts b/lib/state/simperium/middleware.ts index 5590febf1..ce8aaf1c2 100644 --- a/lib/state/simperium/middleware.ts +++ b/lib/state/simperium/middleware.ts @@ -1,3 +1,5 @@ +import type { Client } from 'simperium'; + import debugFactory from 'debug'; import actions from '../actions'; @@ -9,48 +11,94 @@ import * as T from '../../types'; const debug = debugFactory('simperium-middleware'); -type Buckets = { - note: T.Bucket; -}; +export const initSimperium = ( + logout: () => any, + token: string, + username: string | null, + createWelcomeNote: boolean, + client: Client<'note' | 'preferences' | 'tag'> +): S.Middleware => (store) => { + client.on('message', (message: string) => { + if (!message.startsWith('0:auth:')) { + return; + } -const buckets: Buckets = {} as Buckets; + const [prefix, authenticatedUsername] = message.split('0:auth:'); + debug(`authenticated: ${authenticatedUsername}`); -export const storeBuckets = (newBuckets: Buckets) => { - buckets.note = newBuckets.note; -}; + if (null === username) { + return store.dispatch( + actions.settings.setAccountName(authenticatedUsername) + ); + } -const fetchRevisions = (store: S.Store, state: S.State) => { - if (!state.ui.showRevisions || !state.ui.note) { - return; + if (username !== authenticatedUsername) { + debug(`was logged in as ${username} - logging out`); + return logout(); + } + }); + + client.on('unauthorized', () => { + logout(); + }); + + const noteBucket = client.bucket('note'); + + if (createWelcomeNote) { + import( + /* webpackChunkName: 'welcome-message' */ '../../welcome-message' + ).then(({ content }) => { + const now = Date.now() / 1000; + noteBucket.add({ + content, + deleted: false, + systemTags: [], + creationDate: now, + modificationDate: now, + shareURL: '', + publishURL: '', + tags: [], + }); + }); } - const note = state.ui.note; + const fetchRevisions = (store: S.Store, state: S.State) => { + if (!state.ui.showRevisions || !state.ui.note) { + return; + } - buckets.note.getRevisions( - note.id, - (error: unknown, revisions: T.NoteEntity[]) => { - if (error) { - return debug(`Failed to load revisions for note ${note.id}: ${error}`); - } + const note = state.ui.note; - const thisState = store.getState(); - if (!(thisState.ui.note && note.id === thisState.ui.note.id)) { - return; - } + noteBucket.getRevisions( + note.id, + (error: unknown, revisions: T.NoteEntity[]) => { + if (error) { + return debug( + `Failed to load revisions for note ${note.id}: ${error}` + ); + } - store.dispatch(actions.ui.storeRevisions(note.id, revisions)); - } - ); -}; + const thisState = store.getState(); + if (!(thisState.ui.note && note.id === thisState.ui.note.id)) { + return; + } + + store.dispatch(actions.ui.storeRevisions(note.id, revisions)); + } + ); + }; -export const middleware: S.Middleware = (store) => { return (next) => (action: A.ActionType) => { const result = next(action); const nextState = store.getState(); switch (action.type) { + case 'LOGOUT': + client.reset().then(() => logout()); + return result; + case 'SET_SYSTEM_TAG': - buckets.note.update( + noteBucket.update( action.note.id, toggleSystemTag(action.note, action.tagName, action.shouldHaveTag) .data @@ -65,5 +113,3 @@ export const middleware: S.Middleware = (store) => { return result; }; }; - -export default middleware; diff --git a/lib/state/ui/actions.ts b/lib/state/ui/actions.ts index 7aea6328d..872f48d67 100644 --- a/lib/state/ui/actions.ts +++ b/lib/state/ui/actions.ts @@ -30,6 +30,10 @@ export const focusSearchField: A.ActionCreator = () => ({ type: 'FOCUS_SEARCH_FIELD', }); +export const logout: A.ActionCreator = () => ({ + type: 'LOGOUT', +}); + export const markdownNote: A.ActionCreator = ( note: T.NoteEntity, isMarkdown: boolean diff --git a/lib/types.ts b/lib/types.ts index 4405ac6fd..7a4c24b69 100644 --- a/lib/types.ts +++ b/lib/types.ts @@ -35,7 +35,7 @@ export type Tag = { export type TagEntity = Entity; export type Preferences = { - analytics_enabled: boolean; + analytics_enabled: boolean | null; }; export type PreferencesEntity = Entity; diff --git a/lib/typings/simperium/index.d.ts b/lib/typings/simperium/index.d.ts new file mode 100644 index 000000000..ee3899598 --- /dev/null +++ b/lib/typings/simperium/index.d.ts @@ -0,0 +1,38 @@ +declare module simperium { + export interface Client { + bucket(name: BucketName): Bucket; + on(type: 'message', callback: (message: string) => void); + on(type: 'ready', callback: () => void); + on(type: 'send', callback: never); + on(type: 'connect', callback: () => void); + on(type: 'disconnect', callback: () => void); + on(type: 'unauthorized', callback: () => void); + } + + export interface Bucket { + on(type: string, callback: Function); + } + + function initClient(args: { + appID: string; + token: string; + bucketConfig?: object; + }): Client; + + export function Auth( + appId: string, + apiKey: string + ): { + authorize( + username: string, + password: string + ): Promise<{ access_token?: string }>; + + create( + username: string, + password: string + ): Promise<{ access_token?: string }>; + }; + + export = initClient; +} diff --git a/package.json b/package.json index 1ece33f87..0b496df90 100644 --- a/package.json +++ b/package.json @@ -6,7 +6,7 @@ "email": "support@simplenote.com" }, "productName": "Simplenote", - "version": "1.16.0", + "version": "1.16.0-2066", "main": "desktop/index.js", "license": "GPL-2.0", "homepage": "https://simplenote.com", diff --git a/tsconfig.json b/tsconfig.json index 342522d35..ff393f975 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -10,7 +10,8 @@ "strict": true, "isolatedModules": true, "esModuleInterop": true, - "types": ["dom", "jest"] + "types": ["dom", "jest"], + "typeRoots": ["./lib/typings", "./node_modules/@types"] }, "include": ["lib/**/*"] }