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/**/*"] }