Implement basic soft logout handling

Fixes https://github.com/vector-im/riot-web/issues/10235

CSS and copy are left as an exercise for a later iteration.

Login page handling is left for https://github.com/vector-im/riot-web/issues/10236

This implementation reuses as much of the Lifecycle flow as it can without causing problems. Most importantly, it requires https://github.com/matrix-org/matrix-js-sdk/pull/975 to be able to detect a soft logout and react to it. When it comes time to starting/stopping the Lifecycle, additional parameters are provided so that the auxiliary services can (re)start themselves without the client starting to sync.
This commit is contained in:
Travis Ralston 2019-07-03 16:46:37 -06:00
parent 668d24111c
commit 42e6287bdb
12 changed files with 286 additions and 8 deletions

View file

@ -36,6 +36,7 @@ export default class BasePlatform {
_onAction(payload: Object) { _onAction(payload: Object) {
switch (payload.action) { switch (payload.action) {
case 'on_client_not_viable':
case 'on_logged_out': case 'on_logged_out':
this.setNotificationCount(0); this.setNotificationCount(0);
break; break;

View file

@ -352,11 +352,14 @@ export function setLoggedIn(credentials) {
async function _doSetLoggedIn(credentials, clearStorage) { async function _doSetLoggedIn(credentials, clearStorage) {
credentials.guest = Boolean(credentials.guest); credentials.guest = Boolean(credentials.guest);
const softLogout = isSoftLogout();
console.log( console.log(
"setLoggedIn: mxid: " + credentials.userId + "setLoggedIn: mxid: " + credentials.userId +
" deviceId: " + credentials.deviceId + " deviceId: " + credentials.deviceId +
" guest: " + credentials.guest + " guest: " + credentials.guest +
" hs: " + credentials.homeserverUrl, " hs: " + credentials.homeserverUrl +
" softLogout: " + softLogout,
); );
// This is dispatched to indicate that the user is still in the process of logging in // This is dispatched to indicate that the user is still in the process of logging in
@ -414,7 +417,7 @@ async function _doSetLoggedIn(credentials, clearStorage) {
dis.dispatch({ action: 'on_logged_in' }); dis.dispatch({ action: 'on_logged_in' });
await startMatrixClient(); await startMatrixClient(/*startSyncing=*/!softLogout);
return MatrixClientPeg.get(); return MatrixClientPeg.get();
} }
@ -487,6 +490,25 @@ export function logout() {
).done(); ).done();
} }
export function softLogout() {
if (!MatrixClientPeg.get()) return;
// Track that we've detected and trapped a soft logout. This helps prevent other
// parts of the app from starting if there's no point (ie: don't sync if we've
// been soft logged out, despite having credentials and data for a MatrixClient).
localStorage.setItem("mx_soft_logout", "true");
_isLoggingOut = true; // to avoid repeated flags
stopMatrixClient(/*unsetClient=*/false);
dis.dispatch({action: 'on_client_not_viable'}); // generic version of on_logged_out
// DO NOT CALL LOGOUT. A soft logout preserves data, logout does not.
}
export function isSoftLogout() {
return localStorage.getItem("mx_soft_logout") === "true";
}
export function isLoggingOut() { export function isLoggingOut() {
return _isLoggingOut; return _isLoggingOut;
} }
@ -494,8 +516,10 @@ export function isLoggingOut() {
/** /**
* Starts the matrix client and all other react-sdk services that * Starts the matrix client and all other react-sdk services that
* listen for events while a session is logged in. * listen for events while a session is logged in.
* @param {boolean} startSyncing True (default) to actually start
* syncing the client.
*/ */
async function startMatrixClient() { async function startMatrixClient(startSyncing=true) {
console.log(`Lifecycle: Starting MatrixClient`); console.log(`Lifecycle: Starting MatrixClient`);
// dispatch this before starting the matrix client: it's used // dispatch this before starting the matrix client: it's used
@ -513,11 +537,19 @@ async function startMatrixClient() {
DMRoomMap.makeShared().start(); DMRoomMap.makeShared().start();
ActiveWidgetStore.start(); ActiveWidgetStore.start();
await MatrixClientPeg.start(); if (startSyncing) {
await MatrixClientPeg.start();
} else {
console.warn("Caller requested only auxiliary services be started");
}
// dispatch that we finished starting up to wire up any other bits // dispatch that we finished starting up to wire up any other bits
// of the matrix client that cannot be set prior to starting up. // of the matrix client that cannot be set prior to starting up.
dis.dispatch({action: 'client_started'}); dis.dispatch({action: 'client_started'});
if (isSoftLogout()) {
softLogout();
}
} }
/* /*
@ -551,8 +583,10 @@ function _clearStorage() {
/** /**
* Stop all the background processes related to the current client. * Stop all the background processes related to the current client.
* @param {boolean} unsetClient True (default) to abandon the client
* on MatrixClientPeg after stopping.
*/ */
export function stopMatrixClient() { export function stopMatrixClient(unsetClient=true) {
Notifier.stop(); Notifier.stop();
UserActivity.sharedInstance().stop(); UserActivity.sharedInstance().stop();
TypingStore.sharedInstance().reset(); TypingStore.sharedInstance().reset();
@ -563,6 +597,9 @@ export function stopMatrixClient() {
if (cli) { if (cli) {
cli.stopClient(); cli.stopClient();
cli.removeAllListeners(); cli.removeAllListeners();
MatrixClientPeg.unset();
if (unsetClient) {
MatrixClientPeg.unset();
}
} }
} }

View file

@ -90,6 +90,10 @@ const VIEWS = {
// we are logged in with an active matrix client. // we are logged in with an active matrix client.
LOGGED_IN: 7, LOGGED_IN: 7,
// We are logged out (invalid token) but have our local state again. The user
// should log back in to rehydrate the client.
SOFT_LOGOUT: 8,
}; };
// Actions that are redirected through the onboarding process prior to being // Actions that are redirected through the onboarding process prior to being
@ -432,6 +436,7 @@ export default React.createClass({
switch (payload.action) { switch (payload.action) {
case 'logout': case 'logout':
console.log(payload);
Lifecycle.logout(); Lifecycle.logout();
break; break;
case 'require_registration': case 'require_registration':
@ -615,7 +620,12 @@ export default React.createClass({
}); });
break; break;
case 'on_logged_in': case 'on_logged_in':
this._onLoggedIn(); if (!Lifecycle.isSoftLogout()) {
this._onLoggedIn();
}
break;
case 'on_client_not_viable':
this._onSoftLogout();
break; break;
case 'on_logged_out': case 'on_logged_out':
this._onLoggedOut(); this._onLoggedOut();
@ -1258,6 +1268,22 @@ export default React.createClass({
this._setPageSubtitle(); this._setPageSubtitle();
}, },
/**
* Called when the session is softly logged out
*/
_onSoftLogout: function() {
this.notifyNewScreen('soft_logout');
this.setStateForNewView({
view: VIEWS.SOFT_LOGOUT,
ready: false,
collapseLhs: false,
collapsedRhs: false,
currentRoomId: null,
page_type: PageTypes.RoomDirectory,
});
this._setPageSubtitle();
},
/** /**
* Called just before the matrix client is started * Called just before the matrix client is started
* (useful for setting listeners) * (useful for setting listeners)
@ -1337,8 +1363,16 @@ export default React.createClass({
call: call, call: call,
}, true); }, true);
}); });
cli.on('Session.logged_out', function(call) { cli.on('Session.logged_out', function(errObj) {
if (Lifecycle.isLoggingOut()) return; if (Lifecycle.isLoggingOut()) return;
if (errObj.httpStatus === 401 && errObj.data && errObj.data['soft_logout']) {
console.warn("Soft logout issued by server - avoiding data deletion");
Lifecycle.softLogout();
dis.dispatch({actions: 'soft_logout'});
return;
}
const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
Modal.createTrackedDialog('Signed out', '', ErrorDialog, { Modal.createTrackedDialog('Signed out', '', ErrorDialog, {
title: _t('Signed Out'), title: _t('Signed Out'),
@ -1908,6 +1942,13 @@ export default React.createClass({
); );
} }
if (this.state.view === VIEWS.SOFT_LOGOUT) {
const SoftLogout = sdk.getComponent('structures.auth.SoftLogout');
return (
<SoftLogout />
);
}
console.error(`Unknown view ${this.state.view}`); console.error(`Unknown view ${this.state.view}`);
}, },
}); });

View file

@ -0,0 +1,123 @@
/*
Copyright 2019 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import React from 'react';
import {_t} from '../../../languageHandler';
import sdk from '../../../index';
import dis from '../../../dispatcher';
import * as Lifecycle from '../../../Lifecycle';
import Modal from '../../../Modal';
import {ValidatedServerConfig} from "../../../utils/AutoDiscoveryUtils";
import SdkConfig from "../../../SdkConfig";
import MatrixClientPeg from "../../../MatrixClientPeg";
export default class SoftLogout extends React.Component {
static propTypes = {
// Nothing.
};
constructor() {
super();
const defaultServerConfig: ValidatedServerConfig = SdkConfig.get()["validated_server_config"];
const hsUrl = MatrixClientPeg.get().getHomeserverUrl();
const domainName = hsUrl === defaultServerConfig.hsUrl
? defaultServerConfig.hsName
: MatrixClientPeg.get().getHomeServerName();
const userId = MatrixClientPeg.get().getUserId();
const user = MatrixClientPeg.get().getUser(userId);
const displayName = user ? user.displayName : userId.substring(1).split(':')[0];
this.state = {
domainName,
userId,
displayName,
};
}
onClearAll = () => {
const ConfirmWipeDeviceDialog = sdk.getComponent('dialogs.ConfirmWipeDeviceDialog');
Modal.createTrackedDialog('Clear Data', 'Soft Logout', ConfirmWipeDeviceDialog, {
onFinished: (wipeData) => {
if (!wipeData) return;
console.log("Clearing data from soft-logged-out device");
Lifecycle.logout();
},
});
};
onLogin = () => {
dis.dispatch({action: 'start_login'});
};
render() {
const AuthPage = sdk.getComponent("auth.AuthPage");
const AuthHeader = sdk.getComponent("auth.AuthHeader");
const AuthBody = sdk.getComponent("auth.AuthBody");
const AccessibleButton = sdk.getComponent('elements.AccessibleButton');
return (
<AuthPage>
<AuthHeader />
<AuthBody>
<h2>
{_t("You're signed out")}
</h2>
<div>
{_t(
"Your homeserver (%(domainName)s) admin has signed you out of your " +
"account %(displayName)s (%(userId)s).",
{
domainName: this.state.domainName,
displayName: this.state.displayName,
userId: this.state.userId,
},
)}
</div>
<h3>{_t("I don't want to sign in")}</h3>
<div>
{_t(
"If this is a shared device, or you don't want to access your account " +
"again from it, clear all data stored locally on this device.",
)}
</div>
<div>
<AccessibleButton onClick={this.onClearAll} kind="primary">
{_t("Clear all data")}
</AccessibleButton>
</div>
<h3>{_t("Sign in")}</h3>
<div>
{_t(
"Sign in again to regain access to your account, or a different one.",
)}
</div>
<div>
<AccessibleButton onClick={this.onLogin} kind="primary">
{_t("Sign in")}
</AccessibleButton>
</div>
</AuthBody>
</AuthPage>
);
}
}

View file

@ -0,0 +1,60 @@
/*
Copyright 2019 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import React from 'react';
import PropTypes from 'prop-types';
import {_t} from "../../../languageHandler";
import sdk from "../../../index";
export default class ConfirmWipeDeviceDialog extends React.Component {
static propTypes = {
onFinished: PropTypes.func.isRequired,
};
_onConfirm = () => {
this.props.onFinished(true);
};
_onDecline = () => {
this.props.onFinished(false);
};
render() {
const BaseDialog = sdk.getComponent('views.dialogs.BaseDialog');
const DialogButtons = sdk.getComponent('views.elements.DialogButtons');
return (
<BaseDialog className='mx_ConfirmWipeDeviceDialog' hasCancel={true}
onFinished={this.props.onFinished}
title={_t("Clear all data on this device?")}>
<div className='mx_ConfirmWipeDeviceDialog_content'>
<p>
{_t(
"Deleting all data from this device is permanent. Encrypted messages will be lost " +
"unless their keys have been backed up.",
)}
</p>
</div>
<DialogButtons
primaryButton={_t("Delete everything")}
onPrimaryButtonClick={this._onConfirm}
cancelButton={_t("Cancel")}
onCancel={this._onDecline}
/>
</BaseDialog>
);
}
}

View file

@ -147,6 +147,7 @@
"Changes your display nickname": "Changes your display nickname", "Changes your display nickname": "Changes your display nickname",
"Changes your display nickname in the current room only": "Changes your display nickname in the current room only", "Changes your display nickname in the current room only": "Changes your display nickname in the current room only",
"Changes your avatar in this current room only": "Changes your avatar in this current room only", "Changes your avatar in this current room only": "Changes your avatar in this current room only",
"Changes your avatar in all rooms": "Changes your avatar in all rooms",
"Changes colour scheme of current room": "Changes colour scheme of current room", "Changes colour scheme of current room": "Changes colour scheme of current room",
"Gets or sets the room topic": "Gets or sets the room topic", "Gets or sets the room topic": "Gets or sets the room topic",
"This room has no topic.": "This room has no topic.", "This room has no topic.": "This room has no topic.",
@ -1127,6 +1128,9 @@
"Start Chatting": "Start Chatting", "Start Chatting": "Start Chatting",
"Confirm Removal": "Confirm Removal", "Confirm Removal": "Confirm Removal",
"Are you sure you wish to remove (delete) this event? Note that if you delete a room name or topic change, it could undo the change.": "Are you sure you wish to remove (delete) this event? Note that if you delete a room name or topic change, it could undo the change.", "Are you sure you wish to remove (delete) this event? Note that if you delete a room name or topic change, it could undo the change.": "Are you sure you wish to remove (delete) this event? Note that if you delete a room name or topic change, it could undo the change.",
"Clear all data on this device?": "Clear all data on this device?",
"Deleting all data from this device is permanent. Encrypted messages will be lost unless their keys have been backed up.": "Deleting all data from this device is permanent. Encrypted messages will be lost unless their keys have been backed up.",
"Delete everything": "Delete everything",
"Community IDs cannot be empty.": "Community IDs cannot be empty.", "Community IDs cannot be empty.": "Community IDs cannot be empty.",
"Community IDs may only contain characters a-z, 0-9, or '=_-./'": "Community IDs may only contain characters a-z, 0-9, or '=_-./'", "Community IDs may only contain characters a-z, 0-9, or '=_-./'": "Community IDs may only contain characters a-z, 0-9, or '=_-./'",
"Something went wrong whilst creating your community": "Something went wrong whilst creating your community", "Something went wrong whilst creating your community": "Something went wrong whilst creating your community",
@ -1581,6 +1585,12 @@
"You can now close this window or <a>log in</a> to your new account.": "You can now close this window or <a>log in</a> to your new account.", "You can now close this window or <a>log in</a> to your new account.": "You can now close this window or <a>log in</a> to your new account.",
"Registration Successful": "Registration Successful", "Registration Successful": "Registration Successful",
"Create your account": "Create your account", "Create your account": "Create your account",
"You're signed out": "You're signed out",
"Your homeserver (%(domainName)s) admin has signed you out of your account %(displayName)s (%(userId)s).": "Your homeserver (%(domainName)s) admin has signed you out of your account %(displayName)s (%(userId)s).",
"I don't want to sign in": "I don't want to sign in",
"If this is a shared device, or you don't want to access your account again from it, clear all data stored locally on this device.": "If this is a shared device, or you don't want to access your account again from it, clear all data stored locally on this device.",
"Clear all data": "Clear all data",
"Sign in again to regain access to your account, or a different one.": "Sign in again to regain access to your account, or a different one.",
"Commands": "Commands", "Commands": "Commands",
"Results from DuckDuckGo": "Results from DuckDuckGo", "Results from DuckDuckGo": "Results from DuckDuckGo",
"Emoji": "Emoji", "Emoji": "Emoji",

View file

@ -122,6 +122,7 @@ class CustomRoomTagStore extends EventEmitter {
} }
} }
break; break;
case 'on_client_not_viable':
case 'on_logged_out': { case 'on_logged_out': {
// we assume to always have a tags object in the state // we assume to always have a tags object in the state
this._state = {tags: {}}; this._state = {tags: {}};

View file

@ -63,6 +63,7 @@ class LifecycleStore extends Store {
dis.dispatch(deferredAction); dis.dispatch(deferredAction);
break; break;
} }
case 'on_client_not_viable':
case 'on_logged_out': case 'on_logged_out':
this.reset(); this.reset();
break; break;

View file

@ -261,6 +261,7 @@ class RoomListStore extends Store {
// console.log("!! Optimistic tag failure: ", payload); // console.log("!! Optimistic tag failure: ", payload);
// } // }
// break; // break;
case 'on_client_not_viable':
case 'on_logged_out': { case 'on_logged_out': {
// Reset state without pushing an update to the view, which generally assumes that // Reset state without pushing an update to the view, which generally assumes that
// the matrix client isn't `null` and so causing a re-render will cause NPEs. // the matrix client isn't `null` and so causing a re-render will cause NPEs.

View file

@ -103,6 +103,7 @@ class RoomViewStore extends Store {
case 'join_room_error': case 'join_room_error':
this._joinRoomError(payload); this._joinRoomError(payload);
break; break;
case 'on_client_not_viable':
case 'on_logged_out': case 'on_logged_out':
this.reset(); this.reset();
break; break;

View file

@ -68,6 +68,7 @@ class SessionStore extends Store {
cachedPassword: null, cachedPassword: null,
}); });
break; break;
case 'on_client_not_viable':
case 'on_logged_out': case 'on_logged_out':
this._setState({ this._setState({
cachedPassword: null, cachedPassword: null,

View file

@ -166,6 +166,7 @@ class TagOrderStore extends Store {
}); });
Analytics.trackEvent('FilterStore', 'deselect_tags'); Analytics.trackEvent('FilterStore', 'deselect_tags');
break; break;
case 'on_client_not_viable':
case 'on_logged_out': { case 'on_logged_out': {
// Reset state without pushing an update to the view, which generally assumes that // Reset state without pushing an update to the view, which generally assumes that
// the matrix client isn't `null` and so causing a re-render will cause NPEs. // the matrix client isn't `null` and so causing a re-render will cause NPEs.