Merge pull request #168 from vector-im/bwindels/better-session-backup-ui

Better session backup ui
This commit is contained in:
Bruno Windels 2020-10-20 13:30:20 +00:00 committed by GitHub
commit 14da892ea0
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
14 changed files with 287 additions and 64 deletions

View file

@ -36,6 +36,8 @@ export class SessionStatusViewModel extends ViewModel {
this._reconnector = reconnector; this._reconnector = reconnector;
this._status = this._calculateState(reconnector.connectionStatus.get(), sync.status.get()); this._status = this._calculateState(reconnector.connectionStatus.get(), sync.status.get());
this._session = session; this._session = session;
this._setupSessionBackupUrl = this.urlCreator.urlForSegment("settings");
this._dismissSecretStorage = false;
} }
start() { start() {
@ -47,8 +49,12 @@ export class SessionStatusViewModel extends ViewModel {
})); }));
} }
get setupSessionBackupUrl () {
return this._setupSessionBackupUrl;
}
get isShown() { get isShown() {
return this._session.needsSessionBackup.get() || this._status !== SessionStatus.Syncing; return (this._session.needsSessionBackup.get() && !this._dismissSecretStorage) || this._status !== SessionStatus.Syncing;
} }
get statusLabel() { get statusLabel() {
@ -65,7 +71,7 @@ export class SessionStatusViewModel extends ViewModel {
return this.i18n`Sync failed because of ${this._sync.error}`; return this.i18n`Sync failed because of ${this._sync.error}`;
} }
if (this._session.needsSessionBackup.get()) { if (this._session.needsSessionBackup.get()) {
return this.i18n`Set up secret storage to decrypt older messages.`; return this.i18n`Set up session backup to decrypt older messages.`;
} }
return ""; return "";
} }
@ -130,7 +136,18 @@ export class SessionStatusViewModel extends ViewModel {
get isSecretStorageShown() { get isSecretStorageShown() {
// TODO: we need a model here where we can have multiple messages queued up and their buttons don't bleed into each other. // TODO: we need a model here where we can have multiple messages queued up and their buttons don't bleed into each other.
return this._status === SessionStatus.Syncing && this._session.needsSessionBackup.get(); return this._status === SessionStatus.Syncing && this._session.needsSessionBackup.get() && !this._dismissSecretStorage;
}
get canDismiss() {
return this.isSecretStorageShown;
}
dismiss() {
if (this.isSecretStorageShown) {
this._dismissSecretStorage = true;
this.emitChange();
}
} }
connectNow() { connectNow() {
@ -138,26 +155,4 @@ export class SessionStatusViewModel extends ViewModel {
this._reconnector.tryNow(); this._reconnector.tryNow();
} }
} }
async enterPassphrase(passphrase) {
if (passphrase) {
try {
await this._session.enableSecretStorage("passphrase", passphrase);
} catch (err) {
console.error(err);
alert(`Could not set up secret storage with passphrase: ${err.message}`);
}
}
}
async enterSecurityKey(securityKey) {
if (securityKey) {
try {
await this._session.enableSecretStorage("recoverykey", securityKey);
} catch (err) {
console.error(err);
alert(`Could not set up secret storage with securityKey: ${err.message}`);
}
}
}
} }

View file

@ -19,7 +19,7 @@ import {LeftPanelViewModel} from "./leftpanel/LeftPanelViewModel.js";
import {RoomViewModel} from "./room/RoomViewModel.js"; import {RoomViewModel} from "./room/RoomViewModel.js";
import {SessionStatusViewModel} from "./SessionStatusViewModel.js"; import {SessionStatusViewModel} from "./SessionStatusViewModel.js";
import {RoomGridViewModel} from "./RoomGridViewModel.js"; import {RoomGridViewModel} from "./RoomGridViewModel.js";
import {SettingsViewModel} from "./SettingsViewModel.js"; import {SettingsViewModel} from "./settings/SettingsViewModel.js";
import {ViewModel} from "../ViewModel.js"; import {ViewModel} from "../ViewModel.js";
export class SessionViewModel extends ViewModel { export class SessionViewModel extends ViewModel {

View file

@ -0,0 +1,91 @@
/*
Copyright 2020 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 {ViewModel} from "../../ViewModel.js";
export class SessionBackupViewModel extends ViewModel {
constructor(options) {
super(options);
this._session = options.session;
this._showKeySetup = true;
this._error = null;
this._isBusy = false;
}
get isBusy() {
return this._isBusy;
}
get backupVersion() {
return this._session.sessionBackup?.version;
}
get status() {
if (this._session.sessionBackup) {
return "enabled";
} else {
return this._showKeySetup ? "setupKey" : "setupPhrase";
}
}
get error() {
return this._error?.message;
}
showPhraseSetup() {
this._showKeySetup = false;
this.emitChange("status");
}
showKeySetup() {
this._showKeySetup = true;
this.emitChange("status");
}
async enterSecurityPhrase(passphrase) {
if (passphrase) {
try {
this._isBusy = true;
this.emitChange("isBusy");
await this._session.enableSecretStorage("phrase", passphrase);
} catch (err) {
console.error(err);
this._error = err;
this.emitChange("error");
} finally {
this._isBusy = false;
this.emitChange("");
}
}
}
async enterSecurityKey(securityKey) {
if (securityKey) {
try {
this._isBusy = true;
this.emitChange("isBusy");
await this._session.enableSecretStorage("key", securityKey);
} catch (err) {
console.error(err);
this._error = err;
this.emitChange("error");
} finally {
this._isBusy = false;
this.emitChange("");
}
}
}
}

View file

@ -1,5 +1,4 @@
/* /*
Copyright 2020 Bruno Windels <bruno@windels.cloud>
Copyright 2020 The Matrix.org Foundation C.I.C. Copyright 2020 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License"); Licensed under the Apache License, Version 2.0 (the "License");
@ -15,13 +14,26 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
import {ViewModel} from "../ViewModel.js"; import {ViewModel} from "../../ViewModel.js";
import {SessionBackupViewModel} from "./SessionBackupViewModel.js";
function formatKey(key) {
const partLength = 4;
const partCount = Math.ceil(key.length / partLength);
let formattedKey = "";
for (let i = 0; i < partCount; i += 1) {
formattedKey += (formattedKey.length ? " " : "") + key.slice(i * partLength, (i + 1) * partLength);
}
return formattedKey;
}
export class SettingsViewModel extends ViewModel { export class SettingsViewModel extends ViewModel {
constructor(options) { constructor(options) {
super(options); super(options);
this._updateService = options.updateService; this._updateService = options.updateService;
this._session = options.session; const session = options.session;
this._session = session;
this._sessionBackupViewModel = this.track(new SessionBackupViewModel(this.childOptions({session})));
this._closeUrl = this.urlCreator.urlUntilSegment("session"); this._closeUrl = this.urlCreator.urlUntilSegment("session");
} }
@ -30,14 +42,7 @@ export class SettingsViewModel extends ViewModel {
} }
get fingerprintKey() { get fingerprintKey() {
const key = this._session.fingerprintKey; return formatKey(this._session.fingerprintKey);
const partLength = 4;
const partCount = Math.ceil(key.length / partLength);
let formattedKey = "";
for (let i = 0; i < partCount; i += 1) {
formattedKey += (formattedKey.length ? " " : "") + key.slice(i * partLength, (i + 1) * partLength);
}
return formattedKey;
} }
get deviceId() { get deviceId() {
@ -52,7 +57,7 @@ export class SettingsViewModel extends ViewModel {
if (this._updateService) { if (this._updateService) {
return `${this._updateService.version} (${this._updateService.buildHash})`; return `${this._updateService.version} (${this._updateService.buildHash})`;
} }
return "development version"; return this.i18n`development version`;
} }
checkForUpdate() { checkForUpdate() {
@ -62,4 +67,8 @@ export class SettingsViewModel extends ViewModel {
get showUpdateButton() { get showUpdateButton() {
return !!this._updateService; return !!this._updateService;
} }
get sessionBackupViewModel() {
return this._sessionBackupViewModel;
}
} }

View file

@ -207,6 +207,10 @@ export class Session {
this.needsSessionBackup.set(false); this.needsSessionBackup.set(false);
} }
get sessionBackup() {
return this._sessionBackup;
}
// called after load // called after load
async beforeFirstSync(isNewLogin) { async beforeFirstSync(isNewLogin) {
if (this._olm) { if (this._olm) {

View file

@ -33,6 +33,10 @@ export class SessionBackup {
return JSON.parse(sessionInfo); return JSON.parse(sessionInfo);
} }
get version() {
return this._backupInfo.version;
}
dispose() { dispose() {
this._decryption.free(); this._decryption.free();
} }

View file

@ -53,9 +53,9 @@ export async function keyFromCredential(type, credential, storage, cryptoDriver,
throw new Error("Could not find a default secret storage key in account data"); throw new Error("Could not find a default secret storage key in account data");
} }
let key; let key;
if (type === "passphrase") { if (type === "phrase") {
key = await keyFromPassphrase(keyDescription, credential, cryptoDriver); key = await keyFromPassphrase(keyDescription, credential, cryptoDriver);
} else if (type === "recoverykey") { } else if (type === "key") {
key = keyFromRecoveryKey(olm, keyDescription, credential); key = keyFromRecoveryKey(olm, keyDescription, credential);
} else { } else {
throw new Error(`Invalid type: ${type}`); throw new Error(`Invalid type: ${type}`);

View file

@ -24,10 +24,3 @@ limitations under the License.
word-break: break-all; word-break: break-all;
word-break: break-word; word-break: break-word;
} }
.SessionStatusView button {
border: none;
background: none;
color: currentcolor;
text-decoration: underline;
}

View file

@ -0,0 +1,4 @@
<svg width="8" height="8" viewBox="0 0 8 8" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M1.33313 1.33313L6.66646 6.66646" stroke="#FFFFFF" stroke-width="1.5" stroke-linecap="round"/>
<path d="M6.66699 1.33313L1.33366 6.66646" stroke="#FFFFFF" stroke-width="1.5" stroke-linecap="round"/>
</svg>

After

Width:  |  Height:  |  Size: 307 B

View file

@ -306,18 +306,34 @@ a {
} }
.SessionStatusView { .SessionStatusView {
padding: 5px; padding: 4px;
position: absolute; min-height: 22px;
top: 20px; background-color: #03B381;
right: 20px;
background-color: #3D88FA;
color: white; color: white;
border-radius: 10px; align-items: center;
z-index: 2;
} }
.middle-shown .SessionStatusView {
top: 72px; .SessionStatusView button.link {
color: currentcolor;
}
.SessionStatusView > .end {
flex: 1;
display: flex;
justify-content: flex-end;
align-self: stretch;
align-items: stretch;
}
.SessionStatusView .dismiss {
border: none;
background: none;
background-image: url('icons/dismiss.svg');
background-position: center;
background-repeat: no-repeat;
width: 32px;
cursor: pointer;
} }
.room-placeholder { .room-placeholder {
@ -548,7 +564,15 @@ ul.Timeline > li.messageStatus .message-container > p {
} }
.SettingsBody { .SettingsBody {
padding: 12px 16px; padding: 0px 16px;
}
.Settings h3 {
margin: 16px 0 8px 0;
}
.Settings p {
max-width: 700px;
} }
.Settings .row .label { .Settings .row .label {
@ -583,3 +607,23 @@ ul.Timeline > li.messageStatus .message-container > p {
.Settings .row .label { .Settings .row .label {
flex: 0 0 200px; flex: 0 0 200px;
} }
.Settings .error {
color: red;
font-weight: 600;
}
button.link {
font-size: 1em;
border: none;
text-decoration: underline;
background: none;
cursor: pointer;
margin: -12px;
padding: 12px;
}
.Settings a, .Settings .link {
color: #03B381;
font-weight: 600;
}

View file

@ -25,10 +25,9 @@ export class SessionStatusView extends TemplateView {
}}, [ }}, [
spinner(t, {hidden: vm => !vm.isWaiting}), spinner(t, {hidden: vm => !vm.isWaiting}),
t.p(vm => vm.statusLabel), t.p(vm => vm.statusLabel),
t.if(vm => vm.isConnectNowShown, t.createTemplate(t => t.button({onClick: () => vm.connectNow()}, "Retry now"))), t.if(vm => vm.isConnectNowShown, t.createTemplate(t => t.button({className: "link", onClick: () => vm.connectNow()}, "Retry now"))),
t.if(vm => vm.isSecretStorageShown, t.createTemplate(t => t.button({onClick: () => vm.enterPassphrase(prompt("Passphrase"))}, "Enter passphrase"))), t.if(vm => vm.isSecretStorageShown, t.createTemplate(t => t.a({href: vm.setupSessionBackupUrl}, "Go to settings"))),
t.if(vm => vm.isSecretStorageShown, t.createTemplate(t => t.button({onClick: () => vm.enterSecurityKey(prompt("Security key"))}, "Enter security key"))), t.if(vm => vm.canDismiss, t.createTemplate(t => t.div({className: "end"}, t.button({className: "dismiss", onClick: () => vm.dismiss()})))),
window.DEBUG ? t.button({id: "showlogs"}, "Show logs") : ""
]); ]);
} }
} }

View file

@ -21,7 +21,7 @@ import {TemplateView} from "../general/TemplateView.js";
import {StaticView} from "../general/StaticView.js"; import {StaticView} from "../general/StaticView.js";
import {SessionStatusView} from "./SessionStatusView.js"; import {SessionStatusView} from "./SessionStatusView.js";
import {RoomGridView} from "./RoomGridView.js"; import {RoomGridView} from "./RoomGridView.js";
import {SettingsView} from "./SettingsView.js"; import {SettingsView} from "./settings/SettingsView.js";
export class SessionView extends TemplateView { export class SessionView extends TemplateView {
render(t, vm) { render(t, vm) {

View file

@ -0,0 +1,75 @@
/*
Copyright 2020 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 {TemplateView} from "../../general/TemplateView.js";
export class SessionBackupSettingsView extends TemplateView {
render(t, vm) {
return t.mapView(vm => vm.status, status => {
switch (status) {
case "enabled": return new TemplateView(vm, renderEnabled)
case "setupKey": return new TemplateView(vm, renderEnableFromKey)
case "setupPhrase": return new TemplateView(vm, renderEnableFromPhrase)
}
});
}
}
function renderEnabled(t, vm) {
return t.p(vm.i18n`Session backup is enabled, using backup version ${vm.backupVersion}.`);
}
function renderEnableFromKey(t, vm) {
const useASecurityPhrase = t.button({className: "link", onClick: () => vm.showPhraseSetup()}, vm.i18n`use a security phrase`);
return t.div([
t.p(vm.i18n`Enter your secret storage security key below to set up session backup, which will enable you to decrypt messages received before you logged into this session. The security key is a code of 12 groups of 4 characters separated by a space that Element created for you when setting up security.`),
t.p([vm.i18n`Alternatively, you can `, useASecurityPhrase, vm.i18n` if you have one.`]),
renderError(t),
renderEnableFieldRow(t, vm, vm.i18n`Security key`, key => vm.enterSecurityKey(key))
]);
}
function renderEnableFromPhrase(t, vm) {
const useASecurityKey = t.button({className: "link", onClick: () => vm.showKeySetup()}, vm.i18n`use your security key`);
return t.div([
t.p(vm.i18n`Enter your secret storage security phrase below to set up session backup, which will enable you to decrypt messages received before you logged into this session. The security phrase is a freeform secret phrase you optionally chose when setting up security in Element. It is different from your password to login, unless you chose to set them to the same value.`),
t.p([vm.i18n`You can also `, useASecurityKey, vm.i18n`.`]),
renderError(t),
renderEnableFieldRow(t, vm, vm.i18n`Security phrase`, phrase => vm.enterSecurityPhrase(phrase))
]);
}
function renderEnableFieldRow(t, vm, label, callback) {
const eventHandler = () => callback(input.value);
const input = t.input({type: "password", disabled: vm => vm.isBusy, placeholder: label, onChange: eventHandler});
return t.div({className: `row`}, [
t.div({className: "label"}, label),
t.div({className: "content"}, [
input,
t.button({disabled: vm => vm.isBusy, onClick: eventHandler}, vm.i18n`Set up`),
]),
]);
}
function renderError(t) {
return t.if(vm => vm.error, t.createTemplate((t, vm) => {
return t.div([
t.p({className: "error"}, vm => vm.i18n`Could not enable session backup: ${vm.error}.`),
t.p(vm.i18n`Try double checking that you did not mix up your security key, security phrase and login password as explained above.`)
])
}));
}

View file

@ -14,7 +14,8 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
import {TemplateView} from "../general/TemplateView.js"; import {TemplateView} from "../../general/TemplateView.js";
import {SessionBackupSettingsView} from "./SessionBackupSettingsView.js"
export class SettingsView extends TemplateView { export class SettingsView extends TemplateView {
render(t, vm) { render(t, vm) {
@ -39,9 +40,13 @@ export class SettingsView extends TemplateView {
t.h2("Settings") t.h2("Settings")
]), ]),
t.div({className: "SettingsBody"}, [ t.div({className: "SettingsBody"}, [
t.h3("Session"),
row(vm.i18n`User ID`, vm.userId), row(vm.i18n`User ID`, vm.userId),
row(vm.i18n`Session ID`, vm.deviceId, "code"), row(vm.i18n`Session ID`, vm.deviceId, "code"),
row(vm.i18n`Session key`, vm.fingerprintKey, "code"), row(vm.i18n`Session key`, vm.fingerprintKey, "code"),
t.h3("Session Backup"),
t.view(new SessionBackupSettingsView(vm.sessionBackupViewModel)),
t.h3("Application"),
row(vm.i18n`Version`, version), row(vm.i18n`Version`, version),
]) ])
]); ]);