diff --git a/res/css/structures/auth/_CompleteSecurity.scss b/res/css/structures/auth/_CompleteSecurity.scss index f742be70e4..b0462db477 100644 --- a/res/css/structures/auth/_CompleteSecurity.scss +++ b/res/css/structures/auth/_CompleteSecurity.scss @@ -98,3 +98,7 @@ limitations under the License. } } } + +.mx_CompleteSecurity_resetText { + padding-top: 20px; +} diff --git a/res/css/views/dialogs/secretstorage/_CreateSecretStorageDialog.scss b/res/css/views/dialogs/secretstorage/_CreateSecretStorageDialog.scss index 63e5a3de09..9f1d0f4998 100644 --- a/res/css/views/dialogs/secretstorage/_CreateSecretStorageDialog.scss +++ b/res/css/views/dialogs/secretstorage/_CreateSecretStorageDialog.scss @@ -73,33 +73,42 @@ limitations under the License. margin-left: 20px; } -.mx_CreateSecretStorageDialog_recoveryKeyHeader { - margin-bottom: 1em; -} - .mx_CreateSecretStorageDialog_recoveryKeyContainer { - display: flex; + width: 380px; + margin-left: auto; + margin-right: auto; } .mx_CreateSecretStorageDialog_recoveryKey { - width: 262px; + font-weight: bold; + text-align: center; padding: 20px; color: $info-plinth-fg-color; background-color: $info-plinth-bg-color; - margin-right: 12px; + border-radius: 6px; + word-spacing: 1em; + margin-bottom: 20px; } .mx_CreateSecretStorageDialog_recoveryKeyButtons { - flex: 1; display: flex; + justify-content: space-between; align-items: center; } .mx_CreateSecretStorageDialog_recoveryKeyButtons .mx_AccessibleButton { - margin-right: 10px; -} - -.mx_CreateSecretStorageDialog_recoveryKeyButtons button { - flex: 1; + width: 160px; + padding-left: 0px; + padding-right: 0px; white-space: nowrap; } + +.mx_CreateSecretStorageDialog_continueSpinner { + margin-top: 33px; + text-align: right; +} + +.mx_CreateSecretStorageDialog_continueSpinner img { + width: 20px; + height: 20px; +} diff --git a/res/css/views/elements/_StyledCheckbox.scss b/res/css/views/elements/_StyledCheckbox.scss index df0b8c6d94..5f5447e861 100644 --- a/res/css/views/elements/_StyledCheckbox.scss +++ b/res/css/views/elements/_StyledCheckbox.scss @@ -25,6 +25,8 @@ limitations under the License. input[type=checkbox] { appearance: none; + margin: 0; + padding: 0; & + label { display: flex; diff --git a/res/css/views/settings/tabs/user/_AppearanceUserSettingsTab.scss b/res/css/views/settings/tabs/user/_AppearanceUserSettingsTab.scss index 7308bb7177..0756e98782 100644 --- a/res/css/views/settings/tabs/user/_AppearanceUserSettingsTab.scss +++ b/res/css/views/settings/tabs/user/_AppearanceUserSettingsTab.scss @@ -44,6 +44,83 @@ limitations under the License. padding-right: 5px; } +.mx_AppearanceUserSettingsTab { + > .mx_SettingsTab_SubHeading { + margin-bottom: 32px; + } +} + +.mx_AppearanceUserSettingsTab_themeSection { + $radio-bg-color: $input-darker-bg-color; + color: $primary-fg-color; + + > .mx_ThemeSelectors { + display: flex; + flex-direction: row; + flex-wrap: wrap; + + margin-top: 4px; + margin-bottom: 30px; + + > .mx_RadioButton { + padding: $font-16px; + box-sizing: border-box; + border-radius: 10px; + width: 180px; + + background: $radio-bg-color; + opacity: 0.4; + + flex-shrink: 1; + flex-grow: 0; + + margin-right: 15px; + margin-top: 10px; + + font-weight: 600; + color: $muted-fg-color; + + > span { + justify-content: center; + } + } + + > .mx_RadioButton_enabled { + opacity: 1; + + // These colors need to be hardcoded because they don't change with the theme + &.mx_ThemeSelector_light { + background-color: #f3f8fd; + color: #2e2f32; + } + + &.mx_ThemeSelector_dark { + background-color: #181b21; + color: #f3f8fd; + + > input > div { + border-color: $input-darker-bg-color; + > div { + border-color: $input-darker-bg-color; + } + } + } + + &.mx_ThemeSelector_black { + background-color: #000000; + color: #f3f8fd; + + > input > div { + border-color: $input-darker-bg-color; + > div { + border-color: $input-darker-bg-color; + } + } + } + } + } +} + .mx_SettingsTab_customFontSizeField { margin-left: calc($font-16px + 10px); } diff --git a/src/CrossSigningManager.js b/src/CrossSigningManager.js index c37d0f8bf5..d40f820ac0 100644 --- a/src/CrossSigningManager.js +++ b/src/CrossSigningManager.js @@ -30,6 +30,8 @@ import {encodeBase64} from "matrix-js-sdk/src/crypto/olmlib"; // operation ends. let secretStorageKeys = {}; let secretStorageBeingAccessed = false; +// Stores the 'passphraseOnly' option for the active storage access operation +let passphraseOnlyOption = null; function isCachingAllowed() { return ( @@ -99,6 +101,7 @@ async function getSecretStorageKey({ keys: keyInfos }, ssssItemName) { const key = await inputToKey(input); return await MatrixClientPeg.get().checkSecretStorageKey(key, info); }, + passphraseOnly: passphraseOnlyOption, }, /* className= */ null, /* isPriorityModal= */ false, @@ -213,19 +216,27 @@ export async function promptForBackupPassphrase() { * * @param {Function} [func] An operation to perform once secret storage has been * bootstrapped. Optional. - * @param {bool} [forceReset] Reset secret storage even if it's already set up + * @param {object} [opts] Named options + * @param {bool} [opts.forceReset] Reset secret storage even if it's already set up + * @param {object} [opts.withKeys] Map of key ID to key for SSSS keys that the client + * already has available. If a key is not supplied here, the user will be prompted. + * @param {bool} [opts.passphraseOnly] If true, do not prompt for recovery key or to reset keys */ -export async function accessSecretStorage(func = async () => { }, forceReset = false) { +export async function accessSecretStorage( + func = async () => { }, opts = {}, +) { const cli = MatrixClientPeg.get(); secretStorageBeingAccessed = true; + passphraseOnlyOption = opts.passphraseOnly; + secretStorageKeys = Object.assign({}, opts.withKeys || {}); try { - if (!await cli.hasSecretStorageKey() || forceReset) { + if (!await cli.hasSecretStorageKey() || opts.forceReset) { // This dialog calls bootstrap itself after guiding the user through // passphrase creation. const { finished } = Modal.createTrackedDialogAsync('Create Secret Storage dialog', '', import("./async-components/views/dialogs/secretstorage/CreateSecretStorageDialog"), { - force: forceReset, + force: opts.forceReset, }, null, /* priority = */ false, /* static = */ true, ); @@ -263,5 +274,6 @@ export async function accessSecretStorage(func = async () => { }, forceReset = f if (!isCachingAllowed()) { secretStorageKeys = {}; } + passphraseOnlyOption = null; } } diff --git a/src/async-components/views/dialogs/secretstorage/CreateSecretStorageDialog.js b/src/async-components/views/dialogs/secretstorage/CreateSecretStorageDialog.js index d7b79c2cfa..192427d384 100644 --- a/src/async-components/views/dialogs/secretstorage/CreateSecretStorageDialog.js +++ b/src/async-components/views/dialogs/secretstorage/CreateSecretStorageDialog.js @@ -20,25 +20,23 @@ import PropTypes from 'prop-types'; import * as sdk from '../../../../index'; import {MatrixClientPeg} from '../../../../MatrixClientPeg'; import FileSaver from 'file-saver'; -import {_t, _td} from '../../../../languageHandler'; +import {_t} from '../../../../languageHandler'; import Modal from '../../../../Modal'; import { promptForBackupPassphrase } from '../../../../CrossSigningManager'; import {copyNode} from "../../../../utils/strings"; import {SSOAuthEntry} from "../../../../components/views/auth/InteractiveAuthEntryComponents"; -import PassphraseField from "../../../../components/views/auth/PassphraseField"; +import AccessibleButton from "../../../../components/views/elements/AccessibleButton"; +import DialogButtons from "../../../../components/views/elements/DialogButtons"; +import InlineSpinner from "../../../../components/views/elements/InlineSpinner"; + const PHASE_LOADING = 0; const PHASE_LOADERROR = 1; const PHASE_MIGRATE = 2; -const PHASE_PASSPHRASE = 3; -const PHASE_PASSPHRASE_CONFIRM = 4; -const PHASE_SHOWKEY = 5; -const PHASE_KEEPITSAFE = 6; -const PHASE_STORING = 7; -const PHASE_DONE = 8; -const PHASE_CONFIRM_SKIP = 9; - -const PASSWORD_MIN_SCORE = 4; // So secure, many characters, much complex, wow, etc, etc. +const PHASE_INTRO = 3; +const PHASE_SHOWKEY = 4; +const PHASE_STORING = 5; +const PHASE_CONFIRM_SKIP = 6; /* * Walks the user through the process of creating a passphrase to guard Secure @@ -65,34 +63,32 @@ export default class CreateSecretStorageDialog extends React.PureComponent { this.state = { phase: PHASE_LOADING, - passPhrase: '', - passPhraseValid: false, - passPhraseConfirm: '', - copied: false, downloaded: false, + copied: false, backupInfo: null, + backupInfoFetched: false, + backupInfoFetchError: null, backupSigStatus: null, // does the server offer a UI auth flow with just m.login.password - // for /keys/device_signing/upload? + // for /keys/device_signing/upload? (If we have an account password, we + // assume that it can) canUploadKeysWithPasswordOnly: null, + canUploadKeyCheckInProgress: false, accountPassword: props.accountPassword || "", accountPasswordCorrect: null, - // status of the key backup toggle switch + // No toggle for this: if we really don't want one, remove it & just hard code true useKeyBackup: true, }; + if (props.accountPassword) { + // If we have an account password, we assume we can upload keys with + // just a password (otherwise leave it as null so we poll to check) + this.state.canUploadKeysWithPasswordOnly = true; + } + this._passphraseField = createRef(); - this._fetchBackupInfo(); - if (this.state.accountPassword) { - // If we have an account password in memory, let's simplify and - // assume it means password auth is also supported for device - // signing key upload as well. This avoids hitting the server to - // test auth flows, which may be slow under high load. - this.state.canUploadKeysWithPasswordOnly = true; - } else { - this._queryKeyUploadAuth(); - } + this.loadData(); MatrixClientPeg.get().on('crypto.keyBackupStatus', this._onKeyBackupStatusChange); } @@ -109,13 +105,11 @@ export default class CreateSecretStorageDialog extends React.PureComponent { MatrixClientPeg.get().isCryptoEnabled() && await MatrixClientPeg.get().isKeyBackupTrusted(backupInfo) ); - const { force } = this.props; - const phase = (backupInfo && !force) ? PHASE_MIGRATE : PHASE_PASSPHRASE; - this.setState({ - phase, + backupInfoFetched: true, backupInfo, backupSigStatus, + backupInfoFetchError: null, }); return { @@ -123,20 +117,25 @@ export default class CreateSecretStorageDialog extends React.PureComponent { backupSigStatus, }; } catch (e) { - this.setState({phase: PHASE_LOADERROR}); + this.setState({backupInfoFetchError: e}); } } async _queryKeyUploadAuth() { try { + this.setState({canUploadKeyCheckInProgress: true}); await MatrixClientPeg.get().uploadDeviceSigningKeys(null, {}); // We should never get here: the server should always require // UI auth to upload device signing keys. If we do, we upload // no keys which would be a no-op. console.log("uploadDeviceSigningKeys unexpectedly succeeded without UI auth!"); + this.setState({canUploadKeyCheckInProgress: false}); } catch (error) { if (!error.data || !error.data.flows) { console.log("uploadDeviceSigningKeys advertised no flows!"); + this.setState({ + canUploadKeyCheckInProgress: false, + }); return; } const canUploadKeysWithPasswordOnly = error.data.flows.some(f => { @@ -144,10 +143,18 @@ export default class CreateSecretStorageDialog extends React.PureComponent { }); this.setState({ canUploadKeysWithPasswordOnly, + canUploadKeyCheckInProgress: false, }); } } + async _createRecoveryKey() { + this._recoveryKey = await MatrixClientPeg.get().createRecoveryKeyFromPassphrase(); + this.setState({ + phase: PHASE_SHOWKEY, + }); + } + _onKeyBackupStatusChange = () => { if (this.state.phase === PHASE_MIGRATE) this._fetchBackupInfo(); } @@ -156,12 +163,6 @@ export default class CreateSecretStorageDialog extends React.PureComponent { this._recoveryKeyNode = n; } - _onUseKeyBackupChange = (enabled) => { - this.setState({ - useKeyBackup: enabled, - }); - } - _onMigrateFormSubmit = (e) => { e.preventDefault(); if (this.state.backupSigStatus.usable) { @@ -171,12 +172,15 @@ export default class CreateSecretStorageDialog extends React.PureComponent { } } + _onIntroContinueClick = () => { + this._createRecoveryKey(); + } + _onCopyClick = () => { const successful = copyNode(this._recoveryKeyNode); if (successful) { this.setState({ copied: true, - phase: PHASE_KEEPITSAFE, }); } } @@ -186,10 +190,8 @@ export default class CreateSecretStorageDialog extends React.PureComponent { type: 'text/plain;charset=us-ascii', }); FileSaver.saveAs(blob, 'recovery-key.txt'); - this.setState({ downloaded: true, - phase: PHASE_KEEPITSAFE, }); } @@ -245,7 +247,9 @@ export default class CreateSecretStorageDialog extends React.PureComponent { _bootstrapSecretStorage = async () => { this.setState({ - phase: PHASE_STORING, + // we use LOADING here rather than STORING as STORING still shows the 'show key' + // screen which is not relevant: LOADING is just a generic spinner. + phase: PHASE_LOADING, error: null, }); @@ -286,9 +290,7 @@ export default class CreateSecretStorageDialog extends React.PureComponent { }, }); } - this.setState({ - phase: PHASE_DONE, - }); + this.props.onFinished(true); } catch (e) { if (this.state.canUploadKeysWithPasswordOnly && e.httpStatus === 401 && e.data.flows) { this.setState({ @@ -307,10 +309,6 @@ export default class CreateSecretStorageDialog extends React.PureComponent { this.props.onFinished(false); } - _onDone = () => { - this.props.onFinished(true); - } - _restoreBackup = async () => { // It's possible we'll need the backup key later on for bootstrapping, // so let's stash it here, rather than prompting for it twice. @@ -337,88 +335,41 @@ export default class CreateSecretStorageDialog extends React.PureComponent { } } + _onShowKeyContinueClick = () => { + this._bootstrapSecretStorage(); + } + _onLoadRetryClick = () => { + this.loadData(); + } + + async loadData() { this.setState({phase: PHASE_LOADING}); - this._fetchBackupInfo(); + const proms = []; + + if (!this.state.backupInfoFetched) proms.push(this._fetchBackupInfo()); + if (this.state.canUploadKeysWithPasswordOnly === null) proms.push(this._queryKeyUploadAuth()); + + await Promise.all(proms); + if (this.state.canUploadKeysWithPasswordOnly === null || this.state.backupInfoFetchError) { + this.setState({phase: PHASE_LOADERROR}); + } else if (this.state.backupInfo && !this.props.force) { + this.setState({phase: PHASE_MIGRATE}); + } else { + this.setState({phase: PHASE_INTRO}); + } } _onSkipSetupClick = () => { this.setState({phase: PHASE_CONFIRM_SKIP}); } - _onSetUpClick = () => { - this.setState({phase: PHASE_PASSPHRASE}); - } - - _onSkipPassPhraseClick = async () => { - this._recoveryKey = - await MatrixClientPeg.get().createRecoveryKeyFromPassphrase(); - this.setState({ - copied: false, - downloaded: false, - phase: PHASE_SHOWKEY, - }); - } - - _onPassPhraseNextClick = async (e) => { - e.preventDefault(); - if (!this._passphraseField.current) return; // unmounting - - await this._passphraseField.current.validate({ allowEmpty: false }); - if (!this._passphraseField.current.state.valid) { - this._passphraseField.current.focus(); - this._passphraseField.current.validate({ allowEmpty: false, focused: true }); - return; + _onGoBackClick = () => { + if (this.state.backupInfo && !this.props.force) { + this.setState({phase: PHASE_MIGRATE}); + } else { + this.setState({phase: PHASE_INTRO}); } - - this.setState({phase: PHASE_PASSPHRASE_CONFIRM}); - }; - - _onPassPhraseConfirmNextClick = async (e) => { - e.preventDefault(); - - if (this.state.passPhrase !== this.state.passPhraseConfirm) return; - - this._recoveryKey = - await MatrixClientPeg.get().createRecoveryKeyFromPassphrase(this.state.passPhrase); - this.setState({ - copied: false, - downloaded: false, - phase: PHASE_SHOWKEY, - }); - } - - _onSetAgainClick = () => { - this.setState({ - passPhrase: '', - passPhraseValid: false, - passPhraseConfirm: '', - phase: PHASE_PASSPHRASE, - }); - } - - _onKeepItSafeBackClick = () => { - this.setState({ - phase: PHASE_SHOWKEY, - }); - } - - _onPassPhraseValidate = (result) => { - this.setState({ - passPhraseValid: result.valid, - }); - }; - - _onPassPhraseChange = (e) => { - this.setState({ - passPhrase: e.target.value, - }); - } - - _onPassPhraseConfirmChange = (e) => { - this.setState({ - passPhraseConfirm: e.target.value, - }); } _onAccountPasswordChange = (e) => { @@ -433,12 +384,14 @@ export default class CreateSecretStorageDialog extends React.PureComponent { // Once we're confident enough in this (and it's supported enough) we can do // it automatically. // https://github.com/vector-im/riot-web/issues/11696 - const DialogButtons = sdk.getComponent('views.elements.DialogButtons'); const Field = sdk.getComponent('views.elements.Field'); let authPrompt; let nextCaption = _t("Next"); - if (this.state.canUploadKeysWithPasswordOnly) { + if (!this.state.backupSigStatus.usable) { + authPrompt = null; + nextCaption = _t("Upload"); + } else if (this.state.canUploadKeysWithPasswordOnly && !this.props.accountPassword) { authPrompt =
{_t("You'll need to authenticate with the server to confirm the upgrade.")} @@ -463,9 +411,9 @@ export default class CreateSecretStorageDialog extends React.PureComponent { return