redesigned app settings and switch to rust crypto (#1988)

* rework general settings

* account settings - WIP

* add missing key prop

* add object url hook

* extract wide modal styles

* profile settings and image editor - WIP

* add outline style to upload card

* remove file param from bind upload atom hook

* add compact variant to upload card

* add  compact upload card renderer

* add option to update profile avatar

* add option to change profile displayname

* allow displayname change based on capabilities check

* rearrange settings components into folders

* add system notification settings

* add initial page param in settings

* convert account data hook to typescript

* add push rule hook

* add notification mode hook

* add notification mode switcher component

* add all messages notification settings options

* add special messages notification settings

* add keyword notifications

* add ignored users section

* improve ignore user list strings

* add about settings

* add access token option in about settings

* add developer tools settings

* add expand button to account data dev tool option

* update folds

* fix editable active element textarea check

* do not close dialog when editable element in focus

* add text area plugins

* add text area intent handler hook

* add newline intent mod in text area

* add next line hotkey in text area intent hook

* add syntax error position dom utility function

* add account data editor

* add button to send new account data in dev tools

* improve custom emoji plugin

* add more custom emojis hooks

* add text util css

* add word break in setting tile title and description

* emojis and sticker user settings - WIP

* view image packs from settings

* emoji pack editing - WIP

* add option to edit pack meta

* change saved changes message

* add image edit and delete controls

* add option to upload pack images and apply changes

* fix state event type when updating image pack

* lazy load pack image tile img

* hide upload image button when user can not edit pack

* add option to add or remove global image packs

* upgrade to rust crypto (#2168)

* update matrix js sdk

* remove dead code

* use rust crypto

* update setPowerLevel usage

* fix types

* fix deprecated isRoomEncrypted method uses

* fix deprecated room.currentState uses

* fix deprecated import/export room keys func

* fix merge issues in image pack file

* fix remaining issues in image pack file

* start indexedDBStore

* update package lock and vite-plugin-top-level-await

* user session settings - WIP

* add useAsync hook

* add password stage uia

* add uia flow matrix error hook

* add UIA action component

* add options to delete sessions

* add sso uia stage

* fix SSO stage complete error

* encryption - WIP

* update user settings encryption terminology

* add default variant to password input

* use password input in uia password stage

* add options for local backup in user settings

* remove typo in import local backup password input label

* online backup - WIP

* fix uia sso action

* move access token settings from about to developer tools

* merge encryption tab into sessions and rename it to devices

* add device placeholder tile

* add logout dialog

* add logout button for current device

* move other devices in component

* render unverified device verification tile

* add learn more section for current device verification

* add device verification status badge

* add info card component

* add index file for password input component

* add types for secret storage

* add component to access secret storage key

* manual verification - WIP

* update matrix-js-sdk to v35

* add manual verification

* use react query for device list

* show unverified tab on sidebar

* fix device list updates

* add session key details to current device

* render restore encryption backup

* fix loading state of restore backup

* fix unverified tab settings closes after verification

* key backup tile - WIP

* fix unverified tab badge

* rename session key to device key in device tile

* improve backup restore functionality

* fix restore button enabled after layout reload during restoring backup

* update backup info on status change

* add backup disconnection failures

* add device verification using sas

* restore backup after verification

* show option to logout on startup error screen

* fix key backup hook update on decryption key cached

* add option to enable device verification

* add device verification reset dialog

* add logout button in settings drawer

* add encrypted message lost on logout

* fix backup restore never finish with 0 keys

* fix setup dialog hides when enabling device verification

* show backup details in menu

* update setup device verification body copy

* replace deprecated method

* fix displayname appear as mxid in settings

* remove old refactored codes

* fix types
This commit is contained in:
Ajay Bura 2025-02-10 16:49:47 +11:00 committed by GitHub
parent f5d68fcc22
commit 56b754153a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
196 changed files with 14171 additions and 8403 deletions

View file

@ -1,204 +0,0 @@
/* eslint-disable react/prop-types */
import React, { useState, useEffect } from 'react';
import PropTypes from 'prop-types';
import './EmojiVerification.scss';
import cons from '../../../client/state/cons';
import navigation from '../../../client/state/navigation';
import { hasPrivateKey } from '../../../client/state/secretStorageKeys';
import { getDefaultSSKey, isCrossVerified } from '../../../util/matrixUtil';
import Text from '../../atoms/text/Text';
import IconButton from '../../atoms/button/IconButton';
import Button from '../../atoms/button/Button';
import Spinner from '../../atoms/spinner/Spinner';
import Dialog from '../../molecules/dialog/Dialog';
import CrossIC from '../../../../public/res/ic/outlined/cross.svg';
import { useStore } from '../../hooks/useStore';
import { accessSecretStorage } from '../settings/SecretStorageAccess';
import { useMatrixClient } from '../../hooks/useMatrixClient';
function EmojiVerificationContent({ data, requestClose }) {
const [sas, setSas] = useState(null);
const [process, setProcess] = useState(false);
const { request, targetDevice } = data;
const mx = useMatrixClient();
const mountStore = useStore();
const beginStore = useStore();
const beginVerification = async () => {
if (
isCrossVerified(mx, mx.deviceId) &&
(mx.getCrossSigningId() === null ||
(await mx.crypto.crossSigningInfo.isStoredInKeyCache('self_signing')) === false)
) {
if (!hasPrivateKey(getDefaultSSKey(mx))) {
const keyData = await accessSecretStorage(mx, 'Emoji verification');
if (!keyData) {
request.cancel();
return;
}
}
await mx.checkOwnCrossSigningTrust();
}
setProcess(true);
await request.accept();
const verifier = request.beginKeyVerification('m.sas.v1', targetDevice);
const handleVerifier = (sasData) => {
verifier.off('show_sas', handleVerifier);
if (!mountStore.getItem()) return;
setSas(sasData);
setProcess(false);
};
verifier.on('show_sas', handleVerifier);
await verifier.verify();
};
const sasMismatch = () => {
sas.mismatch();
setProcess(true);
};
const sasConfirm = () => {
sas.confirm();
setProcess(true);
};
useEffect(() => {
mountStore.setItem(true);
const handleChange = () => {
if (request.done || request.cancelled) {
requestClose();
return;
}
if (targetDevice && !beginStore.getItem()) {
beginStore.setItem(true);
beginVerification();
}
};
if (request === null) return undefined;
const req = request;
req.on('change', handleChange);
return () => {
req.off('change', handleChange);
if (req.cancelled === false && req.done === false) {
req.cancel();
}
};
}, [request]);
const renderWait = () => (
<>
<Spinner size="small" />
<Text>Waiting for response from other device...</Text>
</>
);
if (sas !== null) {
return (
<div className="emoji-verification__content">
<Text>Confirm the emoji below are displayed on both devices, in the same order:</Text>
<div className="emoji-verification__emojis">
{sas.sas.emoji.map((emoji, i) => (
// eslint-disable-next-line react/no-array-index-key
<div className="emoji-verification__emoji-block" key={`${emoji[1]}-${i}`}>
<Text variant="h1">{emoji[0]}</Text>
<Text>{emoji[1]}</Text>
</div>
))}
</div>
<div className="emoji-verification__buttons">
{process ? (
renderWait()
) : (
<>
<Button variant="primary" onClick={sasConfirm}>
They match
</Button>
<Button onClick={sasMismatch}>No match</Button>
</>
)}
</div>
</div>
);
}
if (targetDevice) {
return (
<div className="emoji-verification__content">
<Text>Please accept the request from other device.</Text>
<div className="emoji-verification__buttons">{renderWait()}</div>
</div>
);
}
return (
<div className="emoji-verification__content">
<Text>Click accept to start the verification process.</Text>
<div className="emoji-verification__buttons">
{process ? (
renderWait()
) : (
<Button variant="primary" onClick={beginVerification}>
Accept
</Button>
)}
</div>
</div>
);
}
EmojiVerificationContent.propTypes = {
data: PropTypes.shape({}).isRequired,
requestClose: PropTypes.func.isRequired,
};
function useVisibilityToggle() {
const [data, setData] = useState(null);
const mx = useMatrixClient();
useEffect(() => {
const handleOpen = (request, targetDevice) => {
setData({ request, targetDevice });
};
navigation.on(cons.events.navigation.EMOJI_VERIFICATION_OPENED, handleOpen);
mx.on('crypto.verification.request', handleOpen);
return () => {
navigation.removeListener(cons.events.navigation.EMOJI_VERIFICATION_OPENED, handleOpen);
mx.removeListener('crypto.verification.request', handleOpen);
};
}, [mx]);
const requestClose = () => setData(null);
return [data, requestClose];
}
function EmojiVerification() {
const [data, requestClose] = useVisibilityToggle();
return (
<Dialog
isOpen={data !== null}
className="emoji-verification"
title={
<Text variant="s1" weight="medium" primary>
Emoji verification
</Text>
}
contentOptions={<IconButton src={CrossIC} onClick={requestClose} tooltip="Close" />}
onRequestClose={requestClose}
>
{data !== null ? (
<EmojiVerificationContent data={data} requestClose={requestClose} />
) : (
<div />
)}
</Dialog>
);
}
export default EmojiVerification;

View file

@ -1,35 +0,0 @@
@use '../../partials/flex';
@use '../../partials/dir';
.emoji-verification {
&__content {
padding: var(--sp-normal);
@include dir.side(padding, var(--sp-normal), var(--sp-extra-tight));
display: flex;
flex-direction: column;
gap: var(--sp-normal);
}
&__emojis {
margin: var(--sp-loose) 0;
display: flex;
align-items: center;
justify-content: space-around;
gap: var(--sp-extra-tight);
flex-wrap: wrap;
}
&__emoji-block {
@extend .cp-fx__column;
flex: 1;
align-items: center;
gap: var(--sp-extra-tight);
white-space: nowrap;
text-transform: capitalize;
}
&__buttons {
display: flex;
gap: var(--sp-normal);
}
}

View file

@ -1,156 +0,0 @@
import React, { useState, useEffect, useRef } from 'react';
import PropTypes from 'prop-types';
import colorMXID from '../../../util/colorMXID';
import Text from '../../atoms/text/Text';
import IconButton from '../../atoms/button/IconButton';
import Button from '../../atoms/button/Button';
import ImageUpload from '../../molecules/image-upload/ImageUpload';
import Input from '../../atoms/input/Input';
import PencilIC from '../../../../public/res/ic/outlined/pencil.svg';
import { confirmDialog } from '../../molecules/confirm-dialog/ConfirmDialog';
import './ProfileEditor.scss';
import { useMatrixClient } from '../../hooks/useMatrixClient';
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
function ProfileEditor({ userId }) {
const [isEditing, setIsEditing] = useState(false);
const mx = useMatrixClient();
const user = mx.getUser(mx.getUserId());
const useAuthentication = useMediaAuthentication();
const displayNameRef = useRef(null);
const [avatarSrc, setAvatarSrc] = useState(
user.avatarUrl
? mx.mxcUrlToHttp(user.avatarUrl, 80, 80, 'crop', undefined, undefined, useAuthentication)
: null
);
const [username, setUsername] = useState(user.displayName);
const [disabled, setDisabled] = useState(true);
useEffect(() => {
let isMounted = true;
mx.getProfileInfo(mx.getUserId()).then((info) => {
if (!isMounted) return;
setAvatarSrc(
info.avatar_url
? mx.mxcUrlToHttp(
info.avatar_url,
80,
80,
'crop',
undefined,
undefined,
useAuthentication
)
: null
);
setUsername(info.displayname);
});
return () => {
isMounted = false;
};
}, [mx, userId, useAuthentication]);
const handleAvatarUpload = async (url) => {
if (url === null) {
const isConfirmed = await confirmDialog(
'Remove avatar',
'Are you sure that you want to remove avatar?',
'Remove',
'caution'
);
if (isConfirmed) {
mx.setAvatarUrl('');
setAvatarSrc(null);
}
return;
}
mx.setAvatarUrl(url);
setAvatarSrc(mx.mxcUrlToHttp(url, 80, 80, 'crop', undefined, undefined, useAuthentication));
};
const saveDisplayName = () => {
const newDisplayName = displayNameRef.current.value;
if (newDisplayName !== null && newDisplayName !== username) {
mx.setDisplayName(newDisplayName);
setUsername(newDisplayName);
setDisabled(true);
setIsEditing(false);
}
};
const onDisplayNameInputChange = () => {
setDisabled(username === displayNameRef.current.value || displayNameRef.current.value == null);
};
const cancelDisplayNameChanges = () => {
displayNameRef.current.value = username;
onDisplayNameInputChange();
setIsEditing(false);
};
const renderForm = () => (
<form
className="profile-editor__form"
style={{ marginBottom: avatarSrc ? '24px' : '0' }}
onSubmit={(e) => {
e.preventDefault();
saveDisplayName();
}}
>
<Input
label={`Display name of ${mx.getUserId()}`}
onChange={onDisplayNameInputChange}
value={mx.getUser(mx.getUserId()).displayName}
forwardRef={displayNameRef}
/>
<Button variant="primary" type="submit" disabled={disabled}>
Save
</Button>
<Button onClick={cancelDisplayNameChanges}>Cancel</Button>
</form>
);
const renderInfo = () => (
<div className="profile-editor__info" style={{ marginBottom: avatarSrc ? '24px' : '0' }}>
<div>
<Text variant="h2" primary weight="medium">
{username ?? userId}
</Text>
<IconButton
src={PencilIC}
size="extra-small"
tooltip="Edit"
onClick={() => setIsEditing(true)}
/>
</div>
<Text variant="b2">{mx.getUserId()}</Text>
</div>
);
return (
<div className="profile-editor">
<ImageUpload
text={username ?? userId}
bgColor={colorMXID(userId)}
imageSrc={avatarSrc}
onUpload={handleAvatarUpload}
onRequestRemove={() => handleAvatarUpload(null)}
/>
{isEditing ? renderForm() : renderInfo()}
</div>
);
}
ProfileEditor.defaultProps = {
userId: null,
};
ProfileEditor.propTypes = {
userId: PropTypes.string,
};
export default ProfileEditor;

View file

@ -1,41 +0,0 @@
@use '../../partials/dir';
@use '../../partials/flex';
.profile-editor {
display: flex;
align-items: flex-end;
}
.profile-editor__info,
.profile-editor__form {
@extend .cp-fx__item-one;
@include dir.side(margin, var(--sp-loose), 0);
display: flex;
}
.profile-editor__info {
flex-direction: column;
& > div:first-child {
display: flex;
align-items: center;
}
.ic-btn {
margin: 0 var(--sp-extra-tight);
}
}
.profile-editor__form {
margin-top: 10px;
flex-wrap: wrap;
align-items: flex-end;
& > .input-container {
@extend .cp-fx__item-one;
}
& > button {
height: 46px;
margin-top: var(--sp-normal);
@include dir.side(margin, var(--sp-normal), 0);
}
}

View file

@ -1,6 +1,7 @@
import React, { useState, useEffect, useRef } from 'react';
import PropTypes from 'prop-types';
import './ProfileViewer.scss';
import { EventTimeline } from 'matrix-js-sdk';
import cons from '../../../client/state/cons';
import navigation from '../../../client/state/navigation';
@ -45,13 +46,14 @@ function ModerationTools({ roomId, userId }) {
const myPowerLevel = room.getMember(mx.getUserId())?.powerLevel || 0;
const powerLevel = roomMember?.powerLevel || 0;
const roomState = room.getLiveTimeline().getState(EventTimeline.FORWARDS);
const canIKick =
roomMember?.membership === 'join' &&
room.currentState.hasSufficientPowerLevelFor('kick', myPowerLevel) &&
roomState?.hasSufficientPowerLevelFor('kick', myPowerLevel) &&
powerLevel < myPowerLevel;
const canIBan =
['join', 'leave'].includes(roomMember?.membership) &&
room.currentState.hasSufficientPowerLevelFor('ban', myPowerLevel) &&
roomState?.hasSufficientPowerLevelFor('ban', myPowerLevel) &&
powerLevel < myPowerLevel;
const handleKick = (e) => {
@ -98,8 +100,9 @@ function SessionInfo({ userId }) {
async function loadDevices() {
try {
await mx.downloadKeys([userId], true);
const myDevices = mx.getStoredDevicesForUser(userId);
const crypto = mx.getCrypto();
const userToDevices = await crypto.getUserDeviceInfo([userId], true);
const myDevices = Array.from(userToDevices.get(userId).values());
if (isUnmounted) return;
setDevices(myDevices);
@ -125,7 +128,7 @@ function SessionInfo({ userId }) {
<Chip
key={device.deviceId}
iconSrc={ShieldEmptyIC}
text={device.getDisplayName() || device.deviceId}
text={device.displayName || device.deviceId}
/>
))}
</div>
@ -170,8 +173,10 @@ function ProfileFooter({ roomId, userId, onRequestClose }) {
const myPowerlevel = room.getMember(mx.getUserId())?.powerLevel || 0;
const userPL = room.getMember(userId)?.powerLevel || 0;
const roomState = room.getLiveTimeline().getState(EventTimeline.FORWARDS);
const canIKick =
room.currentState.hasSufficientPowerLevelFor('kick', myPowerlevel) && userPL < myPowerlevel;
roomState?.hasSufficientPowerLevelFor('kick', myPowerlevel) && userPL < myPowerlevel;
const isBanned = member?.membership === 'ban';
@ -347,8 +352,9 @@ function ProfileViewer() {
const powerLevel = roomMember?.powerLevel || 0;
const myPowerLevel = room.getMember(mx.getUserId())?.powerLevel || 0;
const roomState = room.getLiveTimeline().getState(EventTimeline.FORWARDS);
const canChangeRole =
room.currentState.maySendEvent('m.room.power_levels', mx.getUserId()) &&
roomState?.maySendEvent('m.room.power_levels', mx.getUserId()) &&
(powerLevel < myPowerLevel || userId === mx.getUserId());
const handleChangePowerLevel = async (newPowerLevel) => {

View file

@ -5,7 +5,6 @@ import SpaceAddExisting from '../../molecules/space-add-existing/SpaceAddExistin
import Search from '../search/Search';
import CreateRoom from '../create-room/CreateRoom';
import JoinAlias from '../join-alias/JoinAlias';
import EmojiVerification from '../emoji-verification/EmojiVerification';
import ReusableDialog from '../../molecules/dialog/ReusableDialog';
@ -17,7 +16,6 @@ function Dialogs() {
<JoinAlias />
<SpaceAddExisting />
<Search />
<EmojiVerification />
<ReusableDialog />
</>

View file

@ -4,7 +4,6 @@ import cons from '../../../client/state/cons';
import navigation from '../../../client/state/navigation';
import InviteUser from '../invite-user/InviteUser';
import Settings from '../settings/Settings';
import SpaceSettings from '../space-settings/SpaceSettings';
import RoomSettings from '../room/RoomSettings';
@ -38,7 +37,6 @@ function Windows() {
searchTerm={inviteUser.searchTerm}
onRequestClose={() => changeInviteUser({ isOpen: false, roomId: undefined })}
/>
<Settings />
<SpaceSettings />
<RoomSettings />
</>

View file

@ -1,117 +0,0 @@
import React, { useState } from 'react';
import PropTypes from 'prop-types';
import './AuthRequest.scss';
import { openReusableDialog } from '../../../client/action/navigation';
import Text from '../../atoms/text/Text';
import Button from '../../atoms/button/Button';
import Input from '../../atoms/input/Input';
import Spinner from '../../atoms/spinner/Spinner';
import { useStore } from '../../hooks/useStore';
import { getSecret } from '../../../client/state/auth';
let lastUsedPassword;
const getAuthId = (password) => ({
type: 'm.login.password',
password,
identifier: {
type: 'm.id.user',
user: getSecret().userId,
},
});
function AuthRequest({ onComplete, makeRequest }) {
const [status, setStatus] = useState(false);
const mountStore = useStore();
const handleForm = async (e) => {
mountStore.setItem(true);
e.preventDefault();
const password = e.target.password.value;
if (password.trim() === '') return;
try {
setStatus({ ongoing: true });
await makeRequest(getAuthId(password));
lastUsedPassword = password;
if (!mountStore.getItem()) return;
onComplete(true);
} catch (err) {
lastUsedPassword = undefined;
if (!mountStore.getItem()) return;
if (err.errcode === 'M_FORBIDDEN') {
setStatus({ error: 'Wrong password. Please enter correct password.' });
return;
}
setStatus({ error: 'Request failed!' });
}
};
const handleChange = () => {
setStatus(false);
};
return (
<div className="auth-request">
<form onSubmit={handleForm}>
<Input
name="password"
label="Account password"
type="password"
onChange={handleChange}
required
/>
{status.ongoing && <Spinner size="small" />}
{status.error && <Text variant="b3">{status.error}</Text>}
{(status === false || status.error) && <Button variant="primary" type="submit" disabled={!!status.error}>Continue</Button>}
</form>
</div>
);
}
AuthRequest.propTypes = {
onComplete: PropTypes.func.isRequired,
makeRequest: PropTypes.func.isRequired,
};
/**
* @param {string} title Title of dialog
* @param {(auth) => void} makeRequest request to make
* @returns {Promise<boolean>} whether the request succeed or not.
*/
export const authRequest = async (title, makeRequest) => {
try {
const auth = lastUsedPassword ? getAuthId(lastUsedPassword) : undefined;
await makeRequest(auth);
return true;
} catch (e) {
lastUsedPassword = undefined;
if (e.httpStatus !== 401 || e.data?.flows === undefined) return false;
const { flows } = e.data;
const canUsePassword = flows.find((f) => f.stages.includes('m.login.password'));
if (!canUsePassword) return false;
return new Promise((resolve) => {
let isCompleted = false;
openReusableDialog(
<Text variant="s1" weight="medium">{title}</Text>,
(requestClose) => (
<AuthRequest
onComplete={(done) => {
isCompleted = true;
resolve(done);
requestClose();
}}
makeRequest={makeRequest}
/>
),
() => {
if (!isCompleted) resolve(false);
},
);
});
}
};
export default AuthRequest;

View file

@ -1,12 +0,0 @@
.auth-request {
padding: var(--sp-normal);
& form > *:not(:first-child) {
margin-top: var(--sp-normal);
}
& .text-b3 {
color: var(--tc-danger-high);
margin-top: var(--sp-ultra-tight) !important;
}
}

View file

@ -1,256 +0,0 @@
/* eslint-disable react/jsx-one-expression-per-line */
import React, { useState } from 'react';
import './CrossSigning.scss';
import FileSaver from 'file-saver';
import { Formik } from 'formik';
import { openReusableDialog } from '../../../client/action/navigation';
import { copyToClipboard } from '../../../util/common';
import { clearSecretStorageKeys } from '../../../client/state/secretStorageKeys';
import Text from '../../atoms/text/Text';
import Button from '../../atoms/button/Button';
import Input from '../../atoms/input/Input';
import Spinner from '../../atoms/spinner/Spinner';
import SettingTile from '../../molecules/setting-tile/SettingTile';
import { authRequest } from './AuthRequest';
import { useCrossSigningStatus } from '../../hooks/useCrossSigningStatus';
import { useMatrixClient } from '../../hooks/useMatrixClient';
const failedDialog = () => {
const renderFailure = (requestClose) => (
<div className="cross-signing__failure">
<Text variant="h1"></Text>
<Text weight="medium">Failed to setup cross signing. Please try again.</Text>
<Button onClick={requestClose}>Close</Button>
</div>
);
openReusableDialog(
<Text variant="s1" weight="medium">
Setup cross signing
</Text>,
renderFailure
);
};
const securityKeyDialog = (key) => {
const downloadKey = () => {
const blob = new Blob([key.encodedPrivateKey], {
type: 'text/plain;charset=us-ascii',
});
FileSaver.saveAs(blob, 'security-key.txt');
};
const copyKey = () => {
copyToClipboard(key.encodedPrivateKey);
};
const renderSecurityKey = () => (
<div className="cross-signing__key">
<Text weight="medium">Please save this security key somewhere safe.</Text>
<Text className="cross-signing__key-text">{key.encodedPrivateKey}</Text>
<div className="cross-signing__key-btn">
<Button variant="primary" onClick={() => copyKey(key)}>
Copy
</Button>
<Button onClick={() => downloadKey(key)}>Download</Button>
</div>
</div>
);
// Download automatically.
downloadKey();
openReusableDialog(
<Text variant="s1" weight="medium">
Security Key
</Text>,
() => renderSecurityKey()
);
};
function CrossSigningSetup() {
const initialValues = { phrase: '', confirmPhrase: '' };
const [genWithPhrase, setGenWithPhrase] = useState(undefined);
const mx = useMatrixClient();
const setup = async (securityPhrase = undefined) => {
setGenWithPhrase(typeof securityPhrase === 'string');
const recoveryKey = await mx.createRecoveryKeyFromPassphrase(securityPhrase);
clearSecretStorageKeys();
await mx.bootstrapSecretStorage({
createSecretStorageKey: async () => recoveryKey,
setupNewKeyBackup: true,
setupNewSecretStorage: true,
});
const authUploadDeviceSigningKeys = async (makeRequest) => {
const isDone = await authRequest('Setup cross signing', async (auth) => {
await makeRequest(auth);
});
setTimeout(() => {
if (isDone) securityKeyDialog(recoveryKey);
else failedDialog();
});
};
await mx.bootstrapCrossSigning({
authUploadDeviceSigningKeys,
setupNewCrossSigning: true,
});
};
const validator = (values) => {
const errors = {};
if (values.phrase === '12345678') {
errors.phrase = 'How about 87654321 ?';
}
if (values.phrase === '87654321') {
errors.phrase = 'Your are playing with 🔥';
}
const PHRASE_REGEX = /^([^\s]){8,127}$/;
if (values.phrase.length > 0 && !PHRASE_REGEX.test(values.phrase)) {
errors.phrase = 'Phrase must contain 8-127 characters with no space.';
}
if (values.confirmPhrase.length > 0 && values.confirmPhrase !== values.phrase) {
errors.confirmPhrase = "Phrase don't match.";
}
return errors;
};
return (
<div className="cross-signing__setup">
<div className="cross-signing__setup-entry">
<Text>
We will generate a <b>Security Key</b>, which you can use to manage messages backup and
session verification.
</Text>
{genWithPhrase !== false && (
<Button variant="primary" onClick={() => setup()} disabled={genWithPhrase !== undefined}>
Generate Key
</Button>
)}
{genWithPhrase === false && <Spinner size="small" />}
</div>
<Text className="cross-signing__setup-divider">OR</Text>
<Formik
initialValues={initialValues}
onSubmit={(values) => setup(values.phrase)}
validate={validator}
>
{({ values, errors, handleChange, handleSubmit }) => (
<form
className="cross-signing__setup-entry"
onSubmit={handleSubmit}
disabled={genWithPhrase !== undefined}
>
<Text>
Alternatively you can also set a <b>Security Phrase </b>
so you don't have to remember long Security Key, and optionally save the Key as
backup.
</Text>
<Input
name="phrase"
value={values.phrase}
onChange={handleChange}
label="Security Phrase"
type="password"
required
disabled={genWithPhrase !== undefined}
/>
{errors.phrase && (
<Text variant="b3" className="cross-signing__error">
{errors.phrase}
</Text>
)}
<Input
name="confirmPhrase"
value={values.confirmPhrase}
onChange={handleChange}
label="Confirm Security Phrase"
type="password"
required
disabled={genWithPhrase !== undefined}
/>
{errors.confirmPhrase && (
<Text variant="b3" className="cross-signing__error">
{errors.confirmPhrase}
</Text>
)}
{genWithPhrase !== true && (
<Button variant="primary" type="submit" disabled={genWithPhrase !== undefined}>
Set Phrase & Generate Key
</Button>
)}
{genWithPhrase === true && <Spinner size="small" />}
</form>
)}
</Formik>
</div>
);
}
const setupDialog = () => {
openReusableDialog(
<Text variant="s1" weight="medium">
Setup cross signing
</Text>,
() => <CrossSigningSetup />
);
};
function CrossSigningReset() {
return (
<div className="cross-signing__reset">
<Text variant="h1">🧑🚒🤚</Text>
<Text weight="medium">Resetting cross-signing keys is permanent.</Text>
<Text>
Anyone you have verified with will see security alerts and your message backup will be lost.
You almost certainly do not want to do this, unless you have lost <b>Security Key</b> or{' '}
<b>Phrase</b> and every session you can cross-sign from.
</Text>
<Button variant="danger" onClick={setupDialog}>
Reset
</Button>
</div>
);
}
const resetDialog = () => {
openReusableDialog(
<Text variant="s1" weight="medium">
Reset cross signing
</Text>,
() => <CrossSigningReset />
);
};
function CrossSignin() {
const isCSEnabled = useCrossSigningStatus();
return (
<SettingTile
title="Cross signing"
content={
<Text variant="b3">
Setup to verify and keep track of all your sessions. Also required to backup encrypted
message.
</Text>
}
options={
isCSEnabled ? (
<Button variant="danger" onClick={resetDialog}>
Reset
</Button>
) : (
<Button variant="primary" onClick={setupDialog}>
Setup
</Button>
)
}
/>
);
}
export default CrossSignin;

View file

@ -1,55 +0,0 @@
.cross-signing {
&__setup {
padding: var(--sp-normal);
}
&__setup-entry {
& > *:not(:first-child) {
margin-top: var(--sp-normal);
}
}
&__error {
color: var(--tc-danger-high);
margin-top: var(--sp-ultra-tight) !important;
}
&__setup-divider {
margin: var(--sp-tight) 0;
display: flex;
align-items: center;
&::before,
&::after {
flex: 1;
content: '';
margin: var(--sp-tight) 0;
border-bottom: 1px solid var(--bg-surface-border);
}
}
}
.cross-signing__key {
padding: var(--sp-normal);
&-text {
margin: var(--sp-normal) 0;
padding: var(--sp-extra-tight);
background-color: var(--bg-surface-low);
border-radius: var(--bo-radius);
}
&-btn {
display: flex;
& > button:last-child {
margin: 0 var(--sp-normal);
}
}
}
.cross-signing__failure,
.cross-signing__reset {
padding: var(--sp-normal);
padding-top: var(--sp-extra-loose);
& > .text {
padding-bottom: var(--sp-normal);
}
}

View file

@ -1,285 +0,0 @@
import React, { useState, useEffect } from 'react';
import './DeviceManage.scss';
import dateFormat from 'dateformat';
import { isCrossVerified } from '../../../util/matrixUtil';
import { openReusableDialog, openEmojiVerification } from '../../../client/action/navigation';
import Text from '../../atoms/text/Text';
import Button from '../../atoms/button/Button';
import IconButton from '../../atoms/button/IconButton';
import Input from '../../atoms/input/Input';
import { MenuHeader } from '../../atoms/context-menu/ContextMenu';
import InfoCard from '../../atoms/card/InfoCard';
import Spinner from '../../atoms/spinner/Spinner';
import SettingTile from '../../molecules/setting-tile/SettingTile';
import PencilIC from '../../../../public/res/ic/outlined/pencil.svg';
import BinIC from '../../../../public/res/ic/outlined/bin.svg';
import InfoIC from '../../../../public/res/ic/outlined/info.svg';
import { authRequest } from './AuthRequest';
import { confirmDialog } from '../../molecules/confirm-dialog/ConfirmDialog';
import { useStore } from '../../hooks/useStore';
import { useDeviceList } from '../../hooks/useDeviceList';
import { useCrossSigningStatus } from '../../hooks/useCrossSigningStatus';
import { accessSecretStorage } from './SecretStorageAccess';
import { useMatrixClient } from '../../hooks/useMatrixClient';
const promptDeviceName = async (deviceName) => new Promise((resolve) => {
let isCompleted = false;
const renderContent = (onComplete) => {
const handleSubmit = (e) => {
e.preventDefault();
const name = e.target.session.value;
if (typeof name !== 'string') onComplete(null);
onComplete(name);
};
return (
<form className="device-manage__rename" onSubmit={handleSubmit}>
<Input value={deviceName} label="Session name" name="session" />
<div className="device-manage__rename-btn">
<Button variant="primary" type="submit">Save</Button>
<Button onClick={() => onComplete(null)}>Cancel</Button>
</div>
</form>
);
};
openReusableDialog(
<Text variant="s1" weight="medium">Edit session name</Text>,
(requestClose) => renderContent((name) => {
isCompleted = true;
resolve(name);
requestClose();
}),
() => {
if (!isCompleted) resolve(null);
},
);
});
function DeviceManage() {
const TRUNCATED_COUNT = 4;
const mx = useMatrixClient();
const isCSEnabled = useCrossSigningStatus();
const deviceList = useDeviceList();
const [processing, setProcessing] = useState([]);
const [truncated, setTruncated] = useState(true);
const mountStore = useStore();
mountStore.setItem(true);
const isMeVerified = isCrossVerified(mx, mx.deviceId);
useEffect(() => {
setProcessing([]);
}, [deviceList]);
const addToProcessing = (device) => {
const old = [...processing];
old.push(device.device_id);
setProcessing(old);
};
const removeFromProcessing = () => {
setProcessing([]);
};
if (deviceList === null) {
return (
<div className="device-manage">
<div className="device-manage__loading">
<Spinner size="small" />
<Text>Loading sessions...</Text>
</div>
</div>
);
}
const handleRename = async (device) => {
const newName = await promptDeviceName(device.display_name);
if (newName === null || newName.trim() === '') return;
if (newName.trim() === device.display_name) return;
addToProcessing(device);
try {
await mx.setDeviceDetails(device.device_id, {
display_name: newName,
});
} catch {
if (!mountStore.getItem()) return;
removeFromProcessing(device);
}
};
const handleRemove = async (device) => {
const isConfirmed = await confirmDialog(
`Logout ${device.display_name}`,
`You are about to logout "${device.display_name}" session.`,
'Logout',
'danger',
);
if (!isConfirmed) return;
addToProcessing(device);
await authRequest(`Logout "${device.display_name}"`, async (auth) => {
await mx.deleteDevice(device.device_id, auth);
});
if (!mountStore.getItem()) return;
removeFromProcessing(device);
};
const verifyWithKey = async (device) => {
const keyData = await accessSecretStorage(mx, 'Session verification');
if (!keyData) return;
addToProcessing(device);
await mx.checkOwnCrossSigningTrust();
};
const verifyWithEmojis = async (deviceId) => {
const req = await mx.requestVerification(mx.getUserId(), [deviceId]);
openEmojiVerification(req, { userId: mx.getUserId(), deviceId });
};
const verify = (deviceId, isCurrentDevice) => {
if (isCurrentDevice) {
verifyWithKey(deviceId);
return;
}
verifyWithEmojis(deviceId);
};
const renderDevice = (device, isVerified) => {
const deviceId = device.device_id;
const displayName = device.display_name;
const lastIP = device.last_seen_ip;
const lastTS = device.last_seen_ts;
const isCurrentDevice = mx.deviceId === deviceId;
const canVerify = isVerified === false && (isMeVerified || isCurrentDevice);
return (
<SettingTile
key={deviceId}
title={(
<Text style={{ color: isVerified !== false ? '' : 'var(--tc-danger-high)' }}>
{displayName}
<Text variant="b3" span>{`${displayName ? ' — ' : ''}${deviceId}`}</Text>
{isCurrentDevice && <Text span className="device-manage__current-label" variant="b3">Current</Text>}
</Text>
)}
options={
processing.includes(deviceId)
? <Spinner size="small" />
: (
<>
{(isCSEnabled && canVerify) && <Button onClick={() => verify(deviceId, isCurrentDevice)} variant="positive">Verify</Button>}
<IconButton size="small" onClick={() => handleRename(device)} src={PencilIC} tooltip="Rename" />
<IconButton size="small" onClick={() => handleRemove(device)} src={BinIC} tooltip="Remove session" />
</>
)
}
content={(
<>
{lastTS && (
<Text variant="b3">
Last activity
<span style={{ color: 'var(--tc-surface-normal)' }}>
{dateFormat(new Date(lastTS), ' hh:MM TT, dd/mm/yyyy')}
</span>
{lastIP ? ` at ${lastIP}` : ''}
</Text>
)}
{isCurrentDevice && (
<Text style={{ marginTop: 'var(--sp-ultra-tight)' }} variant="b3">
{`Session Key: ${mx.getDeviceEd25519Key().match(/.{1,4}/g).join(' ')}`}
</Text>
)}
</>
)}
/>
);
};
const unverified = [];
const verified = [];
const noEncryption = [];
deviceList.sort((a, b) => b.last_seen_ts - a.last_seen_ts).forEach((device) => {
const isVerified = isCrossVerified(mx, device.device_id);
if (isVerified === true) {
verified.push(device);
} else if (isVerified === false) {
unverified.push(device);
} else {
noEncryption.push(device);
}
});
return (
<div className="device-manage">
<div>
<MenuHeader>Unverified sessions</MenuHeader>
{!isMeVerified && isCSEnabled && (
<div style={{ padding: 'var(--sp-extra-tight) var(--sp-normal)' }}>
<InfoCard
rounded
variant="primary"
iconSrc={InfoIC}
title="Verify this session either with your Security Key/Phrase here or by initiating emoji verification from a verified session."
/>
</div>
)}
{isMeVerified && unverified.length > 0 && (
<div style={{ padding: 'var(--sp-extra-tight) var(--sp-normal)' }}>
<InfoCard
rounded
variant="surface"
iconSrc={InfoIC}
title="Verify other sessions by emoji verification or remove unfamiliar ones."
/>
</div>
)}
{!isCSEnabled && (
<div style={{ padding: 'var(--sp-extra-tight) var(--sp-normal)' }}>
<InfoCard
rounded
variant="caution"
iconSrc={InfoIC}
title="Setup cross signing in case you lose all your sessions."
/>
</div>
)}
{
unverified.length > 0
? unverified.map((device) => renderDevice(device, false))
: <Text className="device-manage__info">No unverified sessions</Text>
}
</div>
{noEncryption.length > 0 && (
<div>
<MenuHeader>Sessions without encryption support</MenuHeader>
{noEncryption.map((device) => renderDevice(device, null))}
</div>
)}
<div>
<MenuHeader>Verified sessions</MenuHeader>
{
verified.length > 0
? verified.map((device, index) => {
if (truncated && index >= TRUNCATED_COUNT) return null;
return renderDevice(device, true);
})
: <Text className="device-manage__info">No verified sessions</Text>
}
{ verified.length > TRUNCATED_COUNT && (
<Button className="device-manage__info" onClick={() => setTruncated(!truncated)}>
{truncated ? `View ${verified.length - 4} more` : 'View less'}
</Button>
)}
{ deviceList.length > 0 && (
<Text className="device-manage__info" variant="b3">Session names are visible to everyone, so do not put any private info here.</Text>
)}
</div>
</div>
);
}
export default DeviceManage;

View file

@ -1,46 +0,0 @@
@use '../../partials/flex';
.device-manage {
&__loading {
@extend .cp-fx__row--c-c;
padding: var(--sp-extra-loose) var(--sp-normal);
.text {
margin: 0 var(--sp-normal);
}
}
&__info {
margin: var(--sp-normal);
}
& .setting-tile:last-of-type {
border-bottom: none;
}
& .setting-tile__options {
display: flex;
align-items: center;
gap: var(--sp-ultra-tight);
& .btn-positive {
padding: 6px var(--sp-tight);
min-width: 0;
}
}
&__current-label {
margin: 0 var(--sp-extra-tight);
padding: 2px var(--sp-ultra-tight);
color: var(--bg-surface);
background-color: var(--tc-surface-low);
border-radius: 4px;
}
&__rename {
padding: var(--sp-normal);
& > *:not(:last-child) {
margin-bottom: var(--sp-normal);
}
&-btn {
display: flex;
gap: var(--sp-normal);
}
}
}

View file

@ -1,303 +0,0 @@
/* eslint-disable react/prop-types */
import React, { useState, useEffect } from 'react';
import PropTypes from 'prop-types';
import './KeyBackup.scss';
import { openReusableDialog } from '../../../client/action/navigation';
import { deletePrivateKey } from '../../../client/state/secretStorageKeys';
import Text from '../../atoms/text/Text';
import Button from '../../atoms/button/Button';
import IconButton from '../../atoms/button/IconButton';
import Spinner from '../../atoms/spinner/Spinner';
import InfoCard from '../../atoms/card/InfoCard';
import SettingTile from '../../molecules/setting-tile/SettingTile';
import { accessSecretStorage } from './SecretStorageAccess';
import InfoIC from '../../../../public/res/ic/outlined/info.svg';
import BinIC from '../../../../public/res/ic/outlined/bin.svg';
import DownloadIC from '../../../../public/res/ic/outlined/download.svg';
import { useStore } from '../../hooks/useStore';
import { useCrossSigningStatus } from '../../hooks/useCrossSigningStatus';
import { useMatrixClient } from '../../hooks/useMatrixClient';
function CreateKeyBackupDialog({ keyData }) {
const [done, setDone] = useState(false);
const mx = useMatrixClient();
const mountStore = useStore();
const doBackup = async () => {
setDone(false);
let info;
try {
info = await mx.prepareKeyBackupVersion(null, { secureSecretStorage: true });
info = await mx.createKeyBackupVersion(info);
await mx.scheduleAllGroupSessionsForBackup();
if (!mountStore.getItem()) return;
setDone(true);
} catch (e) {
deletePrivateKey(keyData.keyId);
await mx.deleteKeyBackupVersion(info.version);
if (!mountStore.getItem()) return;
setDone(null);
}
};
useEffect(() => {
mountStore.setItem(true);
doBackup();
}, []);
return (
<div className="key-backup__create">
{done === false && (
<div>
<Spinner size="small" />
<Text>Creating backup...</Text>
</div>
)}
{done === true && (
<>
<Text variant="h1"></Text>
<Text>Successfully created backup</Text>
</>
)}
{done === null && (
<>
<Text>Failed to create backup</Text>
<Button onClick={doBackup}>Retry</Button>
</>
)}
</div>
);
}
CreateKeyBackupDialog.propTypes = {
keyData: PropTypes.shape({}).isRequired,
};
function RestoreKeyBackupDialog({ keyData }) {
const [status, setStatus] = useState(false);
const mx = useMatrixClient();
const mountStore = useStore();
const restoreBackup = async () => {
setStatus(false);
let meBreath = true;
const progressCallback = (progress) => {
if (!progress.successes) return;
if (meBreath === false) return;
meBreath = false;
setTimeout(() => {
meBreath = true;
}, 200);
setStatus({ message: `Restoring backup keys... (${progress.successes}/${progress.total})` });
};
try {
const backupInfo = await mx.getKeyBackupVersion();
const info = await mx.restoreKeyBackupWithSecretStorage(backupInfo, undefined, undefined, {
progressCallback,
});
if (!mountStore.getItem()) return;
setStatus({ done: `Successfully restored backup keys (${info.imported}/${info.total}).` });
} catch (e) {
if (!mountStore.getItem()) return;
if (e.errcode === 'RESTORE_BACKUP_ERROR_BAD_KEY') {
deletePrivateKey(keyData.keyId);
setStatus({ error: 'Failed to restore backup. Key is invalid!', errorCode: 'BAD_KEY' });
} else {
setStatus({ error: 'Failed to restore backup.', errCode: 'UNKNOWN' });
}
}
};
useEffect(() => {
mountStore.setItem(true);
restoreBackup();
}, []);
return (
<div className="key-backup__restore">
{(status === false || status.message) && (
<div>
<Spinner size="small" />
<Text>{status.message ?? 'Restoring backup keys...'}</Text>
</div>
)}
{status.done && (
<>
<Text variant="h1"></Text>
<Text>{status.done}</Text>
</>
)}
{status.error && (
<>
<Text>{status.error}</Text>
<Button onClick={restoreBackup}>Retry</Button>
</>
)}
</div>
);
}
RestoreKeyBackupDialog.propTypes = {
keyData: PropTypes.shape({}).isRequired,
};
function DeleteKeyBackupDialog({ requestClose }) {
const [isDeleting, setIsDeleting] = useState(false);
const mx = useMatrixClient();
const mountStore = useStore();
const deleteBackup = async () => {
mountStore.setItem(true);
setIsDeleting(true);
try {
const backupInfo = await mx.getKeyBackupVersion();
if (backupInfo) await mx.deleteKeyBackupVersion(backupInfo.version);
if (!mountStore.getItem()) return;
requestClose(true);
} catch {
if (!mountStore.getItem()) return;
setIsDeleting(false);
}
};
return (
<div className="key-backup__delete">
<Text variant="h1">🗑</Text>
<Text weight="medium">Deleting key backup is permanent.</Text>
<Text>All encrypted messages keys stored on server will be deleted.</Text>
{isDeleting ? (
<Spinner size="small" />
) : (
<Button variant="danger" onClick={deleteBackup}>
Delete
</Button>
)}
</div>
);
}
DeleteKeyBackupDialog.propTypes = {
requestClose: PropTypes.func.isRequired,
};
function KeyBackup() {
const mx = useMatrixClient();
const isCSEnabled = useCrossSigningStatus();
const [keyBackup, setKeyBackup] = useState(undefined);
const mountStore = useStore();
const fetchKeyBackupVersion = async () => {
const info = await mx.getKeyBackupVersion();
if (!mountStore.getItem()) return;
setKeyBackup(info);
};
useEffect(() => {
mountStore.setItem(true);
fetchKeyBackupVersion();
const handleAccountData = (event) => {
if (event.getType() === 'm.megolm_backup.v1') {
fetchKeyBackupVersion();
}
};
mx.on('accountData', handleAccountData);
return () => {
mx.removeListener('accountData', handleAccountData);
};
}, [isCSEnabled]);
const openCreateKeyBackup = async () => {
const keyData = await accessSecretStorage(mx, 'Create Key Backup');
if (keyData === null) return;
openReusableDialog(
<Text variant="s1" weight="medium">
Create Key Backup
</Text>,
() => <CreateKeyBackupDialog keyData={keyData} />,
() => fetchKeyBackupVersion()
);
};
const openRestoreKeyBackup = async () => {
const keyData = await accessSecretStorage(mx, 'Restore Key Backup');
if (keyData === null) return;
openReusableDialog(
<Text variant="s1" weight="medium">
Restore Key Backup
</Text>,
() => <RestoreKeyBackupDialog keyData={keyData} />
);
};
const openDeleteKeyBackup = () =>
openReusableDialog(
<Text variant="s1" weight="medium">
Delete Key Backup
</Text>,
(requestClose) => (
<DeleteKeyBackupDialog
requestClose={(isDone) => {
if (isDone) setKeyBackup(null);
requestClose();
}}
/>
)
);
const renderOptions = () => {
if (keyBackup === undefined) return <Spinner size="small" />;
if (keyBackup === null)
return (
<Button variant="primary" onClick={openCreateKeyBackup}>
Create Backup
</Button>
);
return (
<>
<IconButton
src={DownloadIC}
variant="positive"
onClick={openRestoreKeyBackup}
tooltip="Restore backup"
/>
<IconButton src={BinIC} onClick={openDeleteKeyBackup} tooltip="Delete backup" />
</>
);
};
return (
<SettingTile
title="Encrypted messages backup"
content={
<>
<Text variant="b3">
Online backup your encrypted messages keys with your account data in case you lose
access to your sessions. Your keys will be secured with a unique Security Key.
</Text>
{!isCSEnabled && (
<InfoCard
style={{ marginTop: 'var(--sp-ultra-tight)' }}
rounded
variant="caution"
iconSrc={InfoIC}
title="Setup cross signing to backup your encrypted messages."
/>
)}
</>
}
options={isCSEnabled ? renderOptions() : null}
/>
);
}
export default KeyBackup;

View file

@ -1,27 +0,0 @@
.key-backup__create,
.key-backup__restore {
padding: var(--sp-normal);
& > div {
padding: var(--sp-normal) 0;
display: flex;
align-items: center;
& > .text {
margin: 0 var(--sp-normal);
}
}
& > .text {
margin-bottom: var(--sp-normal);
}
}
.key-backup__delete {
padding: var(--sp-normal);
padding-top: var(--sp-extra-loose);
& > .text {
padding-bottom: var(--sp-normal);
}
}

View file

@ -1,134 +0,0 @@
import React, { useState } from 'react';
import PropTypes from 'prop-types';
import './SecretStorageAccess.scss';
import { deriveKey } from 'matrix-js-sdk/lib/crypto/key_passphrase';
import { openReusableDialog } from '../../../client/action/navigation';
import { getDefaultSSKey, getSSKeyInfo } from '../../../util/matrixUtil';
import { storePrivateKey, hasPrivateKey, getPrivateKey } from '../../../client/state/secretStorageKeys';
import Text from '../../atoms/text/Text';
import Button from '../../atoms/button/Button';
import Input from '../../atoms/input/Input';
import Spinner from '../../atoms/spinner/Spinner';
import { useStore } from '../../hooks/useStore';
import { useMatrixClient } from '../../hooks/useMatrixClient';
function SecretStorageAccess({ onComplete }) {
const mx = useMatrixClient();
const sSKeyId = getDefaultSSKey(mx);
const sSKeyInfo = getSSKeyInfo(mx, sSKeyId);
const isPassphrase = !!sSKeyInfo.passphrase;
const [withPhrase, setWithPhrase] = useState(isPassphrase);
const [process, setProcess] = useState(false);
const [error, setError] = useState(null);
const mountStore = useStore();
const toggleWithPhrase = () => setWithPhrase(!withPhrase);
const processInput = async ({ key, phrase }) => {
mountStore.setItem(true);
setProcess(true);
try {
const { salt, iterations } = sSKeyInfo.passphrase || {};
const privateKey = key
? mx.keyBackupKeyFromRecoveryKey(key)
: await deriveKey(phrase, salt, iterations);
const isCorrect = await mx.checkSecretStorageKey(privateKey, sSKeyInfo);
if (!mountStore.getItem()) return;
if (!isCorrect) {
setError(`Incorrect Security ${key ? 'Key' : 'Phrase'}`);
setProcess(false);
return;
}
onComplete({
keyId: sSKeyId,
key,
phrase,
privateKey,
});
} catch (e) {
if (!mountStore.getItem()) return;
setError(`Incorrect Security ${key ? 'Key' : 'Phrase'}`);
setProcess(false);
}
};
const handleForm = async (e) => {
e.preventDefault();
const password = e.target.password.value;
if (password.trim() === '') return;
const data = {};
if (withPhrase) data.phrase = password;
else data.key = password;
processInput(data);
};
const handleChange = () => {
setError(null);
setProcess(false);
};
return (
<div className="secret-storage-access">
<form onSubmit={handleForm}>
<Input
name="password"
label={`Security ${withPhrase ? 'Phrase' : 'Key'}`}
type="password"
onChange={handleChange}
required
/>
{error && <Text variant="b3">{error}</Text>}
{!process && (
<div className="secret-storage-access__btn">
<Button variant="primary" type="submit">Continue</Button>
{isPassphrase && <Button onClick={toggleWithPhrase}>{`Use Security ${withPhrase ? 'Key' : 'Phrase'}`}</Button>}
</div>
)}
</form>
{process && <Spinner size="small" />}
</div>
);
}
SecretStorageAccess.propTypes = {
onComplete: PropTypes.func.isRequired,
};
/**
* @param {MatrixClient} mx Matrix client
* @param {string} title Title of secret storage access dialog
* @returns {Promise<keyData | null>} resolve to keyData or null
*/
export const accessSecretStorage = (mx, title) => new Promise((resolve) => {
let isCompleted = false;
const defaultSSKey = getDefaultSSKey(mx);
if (hasPrivateKey(defaultSSKey)) {
resolve({ keyId: defaultSSKey, privateKey: getPrivateKey(defaultSSKey) });
return;
}
const handleComplete = (keyData) => {
isCompleted = true;
storePrivateKey(keyData.keyId, keyData.privateKey);
resolve(keyData);
};
openReusableDialog(
<Text variant="s1" weight="medium">{title}</Text>,
(requestClose) => (
<SecretStorageAccess
onComplete={(keyData) => {
handleComplete(keyData);
requestClose(requestClose);
}}
/>
),
() => {
if (!isCompleted) resolve(null);
},
);
});
export default SecretStorageAccess;

View file

@ -1,20 +0,0 @@
.secret-storage-access {
padding: var(--sp-normal);
& form > *:not(:first-child) {
margin-top: var(--sp-normal);
}
& .text-b3 {
color: var(--tc-danger-high);
margin-top: var(--sp-ultra-tight) !important;
}
&__btn {
display: flex;
justify-content: space-between;
}
& .donut-spinner {
margin-top: var(--sp-normal);
}
}

View file

@ -1,660 +0,0 @@
import React, { useState, useEffect } from 'react';
import { Input, toRem } from 'folds';
import { isKeyHotkey } from 'is-hotkey';
import './Settings.scss';
import { clearCacheAndReload, logoutClient } from '../../../client/initMatrix';
import cons from '../../../client/state/cons';
import settings from '../../../client/state/settings';
import navigation from '../../../client/state/navigation';
import { toggleSystemTheme } from '../../../client/action/settings';
import { usePermissionState } from '../../hooks/usePermission';
import Text from '../../atoms/text/Text';
import IconButton from '../../atoms/button/IconButton';
import Button from '../../atoms/button/Button';
import Toggle from '../../atoms/button/Toggle';
import Tabs from '../../atoms/tabs/Tabs';
import { MenuHeader } from '../../atoms/context-menu/ContextMenu';
import SegmentedControls from '../../atoms/segmented-controls/SegmentedControls';
import PopupWindow from '../../molecules/popup-window/PopupWindow';
import SettingTile from '../../molecules/setting-tile/SettingTile';
import ImportE2ERoomKeys from '../../molecules/import-export-e2e-room-keys/ImportE2ERoomKeys';
import ExportE2ERoomKeys from '../../molecules/import-export-e2e-room-keys/ExportE2ERoomKeys';
import { ImagePackUser, ImagePackGlobal } from '../../molecules/image-pack/ImagePack';
import GlobalNotification from '../../molecules/global-notification/GlobalNotification';
import KeywordNotification from '../../molecules/global-notification/KeywordNotification';
import IgnoreUserList from '../../molecules/global-notification/IgnoreUserList';
import ProfileEditor from '../profile-editor/ProfileEditor';
import CrossSigning from './CrossSigning';
import KeyBackup from './KeyBackup';
import DeviceManage from './DeviceManage';
import SunIC from '../../../../public/res/ic/outlined/sun.svg';
import EmojiIC from '../../../../public/res/ic/outlined/emoji.svg';
import LockIC from '../../../../public/res/ic/outlined/lock.svg';
import BellIC from '../../../../public/res/ic/outlined/bell.svg';
import InfoIC from '../../../../public/res/ic/outlined/info.svg';
import PowerIC from '../../../../public/res/ic/outlined/power.svg';
import CrossIC from '../../../../public/res/ic/outlined/cross.svg';
import CinnySVG from '../../../../public/res/svg/cinny.svg';
import { confirmDialog } from '../../molecules/confirm-dialog/ConfirmDialog';
import { useSetting } from '../../state/hooks/settings';
import { settingsAtom } from '../../state/settings';
import { isMacOS } from '../../utils/user-agent';
import { KeySymbol } from '../../utils/key-symbol';
import { useMatrixClient } from '../../hooks/useMatrixClient';
function AppearanceSection() {
const [, updateState] = useState({});
const [enterForNewline, setEnterForNewline] = useSetting(settingsAtom, 'enterForNewline');
const [messageLayout, setMessageLayout] = useSetting(settingsAtom, 'messageLayout');
const [messageSpacing, setMessageSpacing] = useSetting(settingsAtom, 'messageSpacing');
const [twitterEmoji, setTwitterEmoji] = useSetting(settingsAtom, 'twitterEmoji');
const [pageZoom, setPageZoom] = useSetting(settingsAtom, 'pageZoom');
const [isMarkdown, setIsMarkdown] = useSetting(settingsAtom, 'isMarkdown');
const [hideMembershipEvents, setHideMembershipEvents] = useSetting(
settingsAtom,
'hideMembershipEvents'
);
const [hideNickAvatarEvents, setHideNickAvatarEvents] = useSetting(
settingsAtom,
'hideNickAvatarEvents'
);
const [mediaAutoLoad, setMediaAutoLoad] = useSetting(settingsAtom, 'mediaAutoLoad');
const [urlPreview, setUrlPreview] = useSetting(settingsAtom, 'urlPreview');
const [encUrlPreview, setEncUrlPreview] = useSetting(settingsAtom, 'encUrlPreview');
const [showHiddenEvents, setShowHiddenEvents] = useSetting(settingsAtom, 'showHiddenEvents');
const spacings = ['0', '100', '200', '300', '400', '500'];
const [currentZoom, setCurrentZoom] = useState(`${pageZoom}`);
const handleZoomChange = (evt) => {
setCurrentZoom(evt.target.value);
};
const handleZoomEnter = (evt) => {
if (isKeyHotkey('escape', evt)) {
evt.stopPropagation();
setCurrentZoom(pageZoom);
}
if (isKeyHotkey('enter', evt)) {
const newZoom = parseInt(evt.target.value, 10);
if (Number.isNaN(newZoom)) return;
const safeZoom = Math.max(Math.min(newZoom, 150), 75);
setPageZoom(safeZoom);
setCurrentZoom(safeZoom);
}
};
return (
<div className="settings-appearance">
<div className="settings-appearance__card">
<MenuHeader>Theme</MenuHeader>
<SettingTile
title="Follow system theme"
options={
<Toggle
isActive={settings.useSystemTheme}
onToggle={() => {
toggleSystemTheme();
updateState({});
}}
/>
}
content={<Text variant="b3">Use light or dark mode based on the system settings.</Text>}
/>
<SettingTile
title="Theme"
content={
<SegmentedControls
selected={settings.useSystemTheme ? -1 : settings.getThemeIndex()}
segments={[
{ text: 'Light' },
{ text: 'Silver' },
{ text: 'Dark' },
{ text: 'Butter' },
]}
onSelect={(index) => {
if (settings.useSystemTheme) toggleSystemTheme();
settings.setTheme(index);
updateState({});
}}
/>
}
/>
<SettingTile
title="Use Twitter Emoji"
options={
<Toggle isActive={twitterEmoji} onToggle={() => setTwitterEmoji(!twitterEmoji)} />
}
content={<Text variant="b3">Use Twitter emoji instead of system emoji.</Text>}
/>
<SettingTile
title="Page Zoom"
options={
<Input
style={{ width: toRem(150) }}
variant={pageZoom === parseInt(currentZoom, 10) ? 'Background' : 'Primary'}
size="400"
type="number"
min="75"
max="150"
value={currentZoom}
onChange={handleZoomChange}
onKeyDown={handleZoomEnter}
outlined
after={<Text variant="b2">%</Text>}
/>
}
content={
<Text variant="b3">
Change page zoom to scale user interface between 75% to 150%. Default: 100%
</Text>
}
/>
</div>
<div className="settings-appearance__card">
<MenuHeader>Room messages</MenuHeader>
<SettingTile
title="Message Layout"
content={
<SegmentedControls
selected={messageLayout}
segments={[{ text: 'Modern' }, { text: 'Compact' }, { text: 'Bubble' }]}
onSelect={(index) => setMessageLayout(index)}
/>
}
/>
<SettingTile
title="Message Spacing"
content={
<SegmentedControls
selected={spacings.findIndex((s) => s === messageSpacing)}
segments={[
{ text: 'No' },
{ text: 'XXS' },
{ text: 'XS' },
{ text: 'S' },
{ text: 'M' },
{ text: 'L' },
]}
onSelect={(index) => {
setMessageSpacing(spacings[index]);
}}
/>
}
/>
<SettingTile
title="Use ENTER for Newline"
options={
<Toggle
isActive={enterForNewline}
onToggle={() => setEnterForNewline(!enterForNewline)}
/>
}
content={
<Text variant="b3">{`Use ${
isMacOS() ? KeySymbol.Command : 'Ctrl'
} + ENTER to send message and ENTER for newline.`}</Text>
}
/>
<SettingTile
title="Markdown formatting"
options={<Toggle isActive={isMarkdown} onToggle={() => setIsMarkdown(!isMarkdown)} />}
content={<Text variant="b3">Format messages with markdown syntax before sending.</Text>}
/>
<SettingTile
title="Hide membership events"
options={
<Toggle
isActive={hideMembershipEvents}
onToggle={() => setHideMembershipEvents(!hideMembershipEvents)}
/>
}
content={
<Text variant="b3">
Hide membership change messages from room timeline. (Join, Leave, Invite, Kick and
Ban)
</Text>
}
/>
<SettingTile
title="Hide nick/avatar events"
options={
<Toggle
isActive={hideNickAvatarEvents}
onToggle={() => setHideNickAvatarEvents(!hideNickAvatarEvents)}
/>
}
content={
<Text variant="b3">Hide nick and avatar change messages from room timeline.</Text>
}
/>
<SettingTile
title="Disable media auto load"
options={
<Toggle isActive={!mediaAutoLoad} onToggle={() => setMediaAutoLoad(!mediaAutoLoad)} />
}
content={
<Text variant="b3">Prevent images and videos from auto loading to save bandwidth.</Text>
}
/>
<SettingTile
title="Url Preview"
options={<Toggle isActive={urlPreview} onToggle={() => setUrlPreview(!urlPreview)} />}
content={<Text variant="b3">Show url preview for link in messages.</Text>}
/>
<SettingTile
title="Url Preview in Encrypted Room"
options={
<Toggle isActive={encUrlPreview} onToggle={() => setEncUrlPreview(!encUrlPreview)} />
}
content={<Text variant="b3">Show url preview for link in encrypted messages.</Text>}
/>
<SettingTile
title="Show hidden events"
options={
<Toggle
isActive={showHiddenEvents}
onToggle={() => setShowHiddenEvents(!showHiddenEvents)}
/>
}
content={<Text variant="b3">Show hidden state and message events.</Text>}
/>
</div>
</div>
);
}
function NotificationsSection() {
const notifPermission = usePermissionState(
'notifications',
window.Notification?.permission ?? 'denied'
);
const [showNotifications, setShowNotifications] = useSetting(settingsAtom, 'showNotifications');
const [isNotificationSounds, setIsNotificationSounds] = useSetting(
settingsAtom,
'isNotificationSounds'
);
const renderOptions = () => {
if (window.Notification === undefined) {
return (
<Text className="settings-notifications__not-supported">
Not supported in this browser.
</Text>
);
}
if (notifPermission === 'denied') {
return <Text>Permission Denied</Text>;
}
if (notifPermission === 'granted') {
return (
<Toggle
isActive={showNotifications}
onToggle={() => {
setShowNotifications(!showNotifications);
}}
/>
);
}
return (
<Button
variant="primary"
onClick={() =>
window.Notification.requestPermission().then(() => {
setShowNotifications(window.Notification?.permission === 'granted');
})
}
>
Request permission
</Button>
);
};
return (
<>
<div className="settings-notifications">
<MenuHeader>Notification & Sound</MenuHeader>
<SettingTile
title="Desktop notification"
options={renderOptions()}
content={<Text variant="b3">Show desktop notification when new messages arrive.</Text>}
/>
<SettingTile
title="Notification Sound"
options={
<Toggle
isActive={isNotificationSounds}
onToggle={() => setIsNotificationSounds(!isNotificationSounds)}
/>
}
content={<Text variant="b3">Play sound when new messages arrive.</Text>}
/>
</div>
<GlobalNotification />
<KeywordNotification />
<IgnoreUserList />
</>
);
}
function EmojiSection() {
return (
<>
<div className="settings-emoji__card">
<ImagePackUser />
</div>
<div className="settings-emoji__card">
<ImagePackGlobal />
</div>
</>
);
}
function SecuritySection() {
return (
<div className="settings-security">
<div className="settings-security__card">
<MenuHeader>Cross signing and backup</MenuHeader>
<CrossSigning />
<KeyBackup />
</div>
<DeviceManage />
<div className="settings-security__card">
<MenuHeader>Export/Import encryption keys</MenuHeader>
<SettingTile
title="Export E2E room keys"
content={
<>
<Text variant="b3">
Export end-to-end encryption room keys to decrypt old messages in other session. In
order to encrypt keys you need to set a password, which will be used while
importing.
</Text>
<ExportE2ERoomKeys />
</>
}
/>
<SettingTile
title="Import E2E room keys"
content={
<>
<Text variant="b3">
{
"To decrypt older messages, Export E2EE room keys from Element (Settings > Security & Privacy > Encryption > Cryptography) and import them here. Imported keys are encrypted so you'll have to enter the password you set in order to decrypt it."
}
</Text>
<ImportE2ERoomKeys />
</>
}
/>
</div>
</div>
);
}
function AboutSection() {
const mx = useMatrixClient();
return (
<div className="settings-about">
<div className="settings-about__card">
<MenuHeader>Application</MenuHeader>
<div className="settings-about__branding">
<img width="60" height="60" src={CinnySVG} alt="Cinny logo" />
<div>
<Text variant="h2" weight="medium">
Cinny
<span
className="text text-b3"
style={{ margin: '0 var(--sp-extra-tight)' }}
>{`v${cons.version}`}</span>
</Text>
<Text>Yet another matrix client</Text>
<div className="settings-about__btns">
<Button onClick={() => window.open('https://github.com/ajbura/cinny')}>
Source code
</Button>
<Button onClick={() => window.open('https://cinny.in/#sponsor')}>Support</Button>
<Button onClick={() => clearCacheAndReload(mx)} variant="danger">
Clear cache & reload
</Button>
</div>
</div>
</div>
</div>
<div className="settings-about__card">
<MenuHeader>Credits</MenuHeader>
<div className="settings-about__credits">
<ul>
<li>
{/* eslint-disable-next-line react/jsx-one-expression-per-line */}
<Text>
The{' '}
<a
href="https://github.com/matrix-org/matrix-js-sdk"
rel="noreferrer noopener"
target="_blank"
>
matrix-js-sdk
</a>{' '}
is ©{' '}
<a href="https://matrix.org/foundation" rel="noreferrer noopener" target="_blank">
The Matrix.org Foundation C.I.C
</a>{' '}
used under the terms of{' '}
<a
href="http://www.apache.org/licenses/LICENSE-2.0"
rel="noreferrer noopener"
target="_blank"
>
Apache 2.0
</a>
.
</Text>
</li>
<li>
{/* eslint-disable-next-line react/jsx-one-expression-per-line */}
<Text>
The{' '}
<a
href="https://github.com/mozilla/twemoji-colr"
target="_blank"
rel="noreferrer noopener"
>
twemoji-colr
</a>{' '}
font is ©{' '}
<a href="https://mozilla.org/" target="_blank" rel="noreferrer noopener">
Mozilla Foundation
</a>{' '}
used under the terms of{' '}
<a
href="http://www.apache.org/licenses/LICENSE-2.0"
target="_blank"
rel="noreferrer noopener"
>
Apache 2.0
</a>
.
</Text>
</li>
<li>
{/* eslint-disable-next-line react/jsx-one-expression-per-line */}
<Text>
The{' '}
<a href="https://twemoji.twitter.com" target="_blank" rel="noreferrer noopener">
Twemoji
</a>{' '}
emoji art is ©{' '}
<a href="https://twemoji.twitter.com" target="_blank" rel="noreferrer noopener">
Twitter, Inc and other contributors
</a>{' '}
used under the terms of{' '}
<a
href="https://creativecommons.org/licenses/by/4.0/"
target="_blank"
rel="noreferrer noopener"
>
CC-BY 4.0
</a>
.
</Text>
</li>
<li>
{/* eslint-disable-next-line react/jsx-one-expression-per-line */}
<Text>
The{' '}
<a
href="https://material.io/design/sound/sound-resources.html"
target="_blank"
rel="noreferrer noopener"
>
Material sound resources
</a>{' '}
are ©{' '}
<a href="https://google.com" target="_blank" rel="noreferrer noopener">
Google
</a>{' '}
used under the terms of{' '}
<a
href="https://creativecommons.org/licenses/by/4.0/"
target="_blank"
rel="noreferrer noopener"
>
CC-BY 4.0
</a>
.
</Text>
</li>
</ul>
</div>
</div>
</div>
);
}
export const tabText = {
APPEARANCE: 'Appearance',
NOTIFICATIONS: 'Notifications',
EMOJI: 'Emoji',
SECURITY: 'Security',
ABOUT: 'About',
};
const tabItems = [
{
text: tabText.APPEARANCE,
iconSrc: SunIC,
disabled: false,
render: () => <AppearanceSection />,
},
{
text: tabText.NOTIFICATIONS,
iconSrc: BellIC,
disabled: false,
render: () => <NotificationsSection />,
},
{
text: tabText.EMOJI,
iconSrc: EmojiIC,
disabled: false,
render: () => <EmojiSection />,
},
{
text: tabText.SECURITY,
iconSrc: LockIC,
disabled: false,
render: () => <SecuritySection />,
},
{
text: tabText.ABOUT,
iconSrc: InfoIC,
disabled: false,
render: () => <AboutSection />,
},
];
function useWindowToggle(setSelectedTab) {
const [isOpen, setIsOpen] = useState(false);
useEffect(() => {
const openSettings = (tab) => {
const tabItem = tabItems.find((item) => item.text === tab);
if (tabItem) setSelectedTab(tabItem);
setIsOpen(true);
};
navigation.on(cons.events.navigation.SETTINGS_OPENED, openSettings);
return () => {
navigation.removeListener(cons.events.navigation.SETTINGS_OPENED, openSettings);
};
}, []);
const requestClose = () => setIsOpen(false);
return [isOpen, requestClose];
}
function Settings() {
const [selectedTab, setSelectedTab] = useState(tabItems[0]);
const [isOpen, requestClose] = useWindowToggle(setSelectedTab);
const mx = useMatrixClient();
const handleTabChange = (tabItem) => setSelectedTab(tabItem);
const handleLogout = async () => {
if (
await confirmDialog(
'Logout',
'Are you sure that you want to logout your session?',
'Logout',
'danger'
)
) {
logoutClient(mx);
}
};
return (
<PopupWindow
isOpen={isOpen}
className="settings-window"
title={
<Text variant="s1" weight="medium" primary>
Settings
</Text>
}
contentOptions={
<>
<Button variant="danger" iconSrc={PowerIC} onClick={handleLogout}>
Logout
</Button>
<IconButton src={CrossIC} onClick={requestClose} tooltip="Close" />
</>
}
onRequestClose={requestClose}
>
{isOpen && (
<div className="settings-window__content">
<ProfileEditor userId={mx.getUserId()} />
<Tabs
items={tabItems}
defaultSelected={tabItems.findIndex((tab) => tab.text === selectedTab.text)}
onSelect={handleTabChange}
/>
<div className="settings-window__cards-wrapper">{selectedTab.render()}</div>
</div>
)}
</PopupWindow>
);
}
export default Settings;

View file

@ -1,94 +0,0 @@
@use '../../partials/flex';
@use '../../partials/dir';
@use '../../partials/screen';
.settings-window {
& .pw {
background-color: var(--bg-surface-low);
}
.header .btn-danger {
margin: 0 var(--sp-tight);
box-shadow: none;
}
& .profile-editor {
padding: var(--sp-loose) var(--sp-extra-loose);
}
& .tabs__content {
padding: 0 var(--sp-normal);
}
&__cards-wrapper {
padding: 0 var(--sp-normal);
@include dir.side(padding, var(--sp-normal), var(--sp-extra-tight));
}
}
.settings-window__card {
margin: var(--sp-normal) 0;
background-color: var(--bg-surface);
border-radius: var(--bo-radius);
box-shadow: var(--bs-surface-border);
overflow: hidden;
& > .context-menu__header:first-child {
margin-top: 2px;
}
}
.settings-appearance__card,
.settings-notifications,
.global-notification,
.keyword-notification,
.ignore-user-list,
.settings-security__card,
.settings-security .device-manage,
.settings-about__card,
.settings-emoji__card {
@extend .settings-window__card;
}
.settings-window__cards-wrapper{
& .setting-tile {
margin: 0 var(--sp-normal);
margin-top: var(--sp-normal);
padding-bottom: 16px;
border-bottom: 1px solid var(--bg-surface-border);
&:last-child {
border-bottom: none;
}
}
}
.settings-notifications {
&__not-supported {
padding: 0 var(--sp-ultra-tight);
}
}
.settings-about {
&__branding {
padding: var(--sp-normal);
display: flex;
& > div {
margin: 0 var(--sp-loose);
}
}
&__btns {
& button {
margin-top: var(--sp-tight);
@include dir.side(margin, 0, var(--sp-tight));
}
}
&__credits {
padding: 0 var(--sp-normal);
& ul {
color: var(--tc-surface-low);
padding: var(--sp-normal);
margin: var(--sp-extra-tight) 0;
}
}
}