mv element.io @types __mocks__/ debian docker module_system/ playwright res src test webapp Dockerfile .dockerignore .eslintignore .stylelintrc.cjs babel.config.cjs recorder-worklet-loader.cjs .modernizr.json components.json config.json config.sample.json package.json project.json tsconfig.json tsconfig.module_system.json jest.config.ts playwright.config.ts webpack.config.ts build_config.sample.yaml apps/web/

mkdir apps/web/scripts
mv scripts/{cleanup.sh,ci_package.sh,copy-res.ts,deploy.py,package.sh} apps/web/scripts

And a couple of gitignore tweaks

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>
This commit is contained in:
Michael Telatynski
2026-02-24 15:43:58 +00:00
parent e7509c92a1
commit 91a3cb03c1
3408 changed files with 28 additions and 32 deletions
@@ -0,0 +1,119 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2020 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
import React from "react";
import { Glass } from "@vector-im/compound-web";
import { CloseIcon } from "@vector-im/compound-design-tokens/assets/web/icons";
import { _t } from "../../../languageHandler";
import { Phase, SetupEncryptionStore } from "../../../stores/SetupEncryptionStore";
import SetupEncryptionBody from "./SetupEncryptionBody";
import AccessibleButton from "../../views/elements/AccessibleButton";
import CompleteSecurityBody from "../../views/auth/CompleteSecurityBody";
import AuthPage from "../../views/auth/AuthPage";
import SdkConfig from "../../../SdkConfig";
import E2EIcon from "../../views/rooms/E2EIcon.tsx";
import { E2EStatus } from "../../../utils/ShieldUtils.ts";
interface IProps {
onFinished: () => void;
}
interface IState {
phase?: Phase;
}
/**
* Prompts the user to verify their device when they first log in.
*/
export default class CompleteSecurity extends React.Component<IProps, IState> {
public constructor(props: IProps) {
super(props);
const store = SetupEncryptionStore.sharedInstance();
store.start();
this.state = { phase: store.phase };
}
public componentDidMount(): void {
const store = SetupEncryptionStore.sharedInstance();
store.on("update", this.onStoreUpdate);
}
private onStoreUpdate = (): void => {
const store = SetupEncryptionStore.sharedInstance();
this.setState({ phase: store.phase });
};
private onSkipClick = (): void => {
const store = SetupEncryptionStore.sharedInstance();
store.skip();
};
public componentWillUnmount(): void {
const store = SetupEncryptionStore.sharedInstance();
store.off("update", this.onStoreUpdate);
store.stop();
}
public render(): React.ReactNode {
const { phase } = this.state;
let icon;
let title;
if (phase === Phase.Loading) {
return null;
} else if (phase === Phase.Intro) {
// We don't specify an icon nor title since `SetupEncryptionBody` provides its own
} else if (phase === Phase.Done) {
icon = <E2EIcon className="mx_CompleteSecurity_headerIcon" status={E2EStatus.Verified} hideTooltip />;
title = _t("encryption|verification|after_new_login|device_verified");
} else if (phase === Phase.ConfirmSkip) {
icon = <E2EIcon className="mx_CompleteSecurity_headerIcon" status={E2EStatus.Warning} hideTooltip />;
title = _t("common|are_you_sure");
} else if (phase === Phase.Busy) {
icon = <E2EIcon className="mx_CompleteSecurity_headerIcon" status={E2EStatus.Warning} hideTooltip />;
title = _t("encryption|verification|after_new_login|verify_this_device");
} else if (phase === Phase.Finished) {
// SetupEncryptionBody will take care of calling onFinished, we don't need to do anything
} else {
throw new Error(`Unknown phase ${phase}`);
}
const forceVerification = SdkConfig.get("force_verification");
let skipButton;
if (!forceVerification && phase === Phase.Intro) {
skipButton = (
<AccessibleButton
onClick={this.onSkipClick}
className="mx_CompleteSecurity_skip"
aria-label={_t("encryption|verification|after_new_login|skip_verification")}
>
<CloseIcon />
</AccessibleButton>
);
}
return (
<AuthPage addBlur={false}>
<Glass className="mx_Dialog_border">
<CompleteSecurityBody>
<h1 className="mx_CompleteSecurity_header">
{icon}
{title}
{skipButton}
</h1>
<div className="mx_CompleteSecurity_body">
<SetupEncryptionBody onFinished={this.props.onFinished} allowLogout={true} />
</div>
</CompleteSecurityBody>
</Glass>
</AuthPage>
);
}
}
@@ -0,0 +1,38 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2023 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
import React, { type JSX } from "react";
import { _t } from "../../../languageHandler";
import SdkConfig from "../../../SdkConfig";
import AccessibleButton from "../../views/elements/AccessibleButton";
interface Props {
/** Callback which the view will call if the user confirms they want to use this window */
onConfirm: () => void;
}
/**
* Component shown by {@link MatrixChat} when another session is already active in the same browser and we need to
* confirm if we should steal its lock
*/
export function ConfirmSessionLockTheftView(props: Props): JSX.Element {
const brand = SdkConfig.get().brand;
return (
<div className="mx_ConfirmSessionLockTheftView">
<div className="mx_ConfirmSessionLockTheftView_body">
<p>{_t("error_app_opened_in_another_window", { brand, label: _t("action|continue") })}</p>
<AccessibleButton kind="primary" onClick={props.onConfirm}>
{_t("action|continue")}
</AccessibleButton>
</div>
</div>
);
}
@@ -0,0 +1,33 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2020 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
import React from "react";
import AuthPage from "../../views/auth/AuthPage";
import CompleteSecurityBody from "../../views/auth/CompleteSecurityBody";
import { InitialCryptoSetupDialog } from "../../views/dialogs/security/InitialCryptoSetupDialog";
interface IProps {
/** Callback which is called if the crypto setup failed, and the user clicked the 'cancel' button */
onCancelled: () => void;
}
/**
* An {@link AuthPage} which shows the {@link InitialCryptoSetupDialog}.
*/
export default class E2eSetup extends React.Component<IProps> {
public render(): React.ReactNode {
return (
<AuthPage>
<CompleteSecurityBody>
<InitialCryptoSetupDialog onCancelled={this.props.onCancelled} />
</CompleteSecurityBody>
</AuthPage>
);
}
}
@@ -0,0 +1,477 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2019 The Matrix.org Foundation C.I.C.
Copyright 2017, 2018 , 2019 New Vector Ltd
Copyright 2015, 2016 OpenMarket Ltd
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
import React, { type JSX, type ReactNode } from "react";
import { logger } from "matrix-js-sdk/src/logger";
import { sleep } from "matrix-js-sdk/src/utils";
import { LockSolidIcon, CheckIcon } from "@vector-im/compound-design-tokens/assets/web/icons";
import { Button } from "@vector-im/compound-web";
import { _t, _td } from "../../../languageHandler";
import Modal from "../../../Modal";
import PasswordReset from "../../../PasswordReset";
import AuthPage from "../../views/auth/AuthPage";
import PassphraseField from "../../views/auth/PassphraseField";
import { PASSWORD_MIN_SCORE } from "../../views/auth/RegistrationForm";
import AuthHeader from "../../views/auth/AuthHeader";
import AuthBody from "../../views/auth/AuthBody";
import PassphraseConfirmField from "../../views/auth/PassphraseConfirmField";
import StyledCheckbox from "../../views/elements/StyledCheckbox";
import { type ValidatedServerConfig } from "../../../utils/ValidatedServerConfig";
import QuestionDialog from "../../views/dialogs/QuestionDialog";
import { EnterEmail } from "./forgot-password/EnterEmail";
import { CheckEmail } from "./forgot-password/CheckEmail";
import type Field from "../../views/elements/Field";
import { ErrorMessage } from "../ErrorMessage";
import { VerifyEmailModal } from "./forgot-password/VerifyEmailModal";
import Spinner from "../../views/elements/Spinner";
import { formatSeconds } from "../../../DateUtils";
import AutoDiscoveryUtils from "../../../utils/AutoDiscoveryUtils";
const emailCheckInterval = 2000;
enum Phase {
// Show email input
EnterEmail = 1,
// Email is in the process of being sent
SendingEmail = 2,
// Email has been sent
EmailSent = 3,
// Show new password input
PasswordInput = 4,
// Password is in the process of being reset
ResettingPassword = 5,
// All done
Done = 6,
}
interface Props {
serverConfig: ValidatedServerConfig;
onLoginClick: () => void;
onComplete: () => void;
}
interface State {
phase: Phase;
email: string;
password: string;
password2: string;
errorText: string | ReactNode | null;
// We perform liveliness checks later, but for now suppress the errors.
// We also track the server dead errors independently of the regular errors so
// that we can render it differently, and override any other error the user may
// be seeing.
serverIsAlive: boolean;
serverDeadError: string;
logoutDevices: boolean;
}
export default class ForgotPassword extends React.Component<Props, State> {
private unmounted = false;
private reset: PasswordReset;
private fieldPassword: Field | null = null;
private fieldPasswordConfirm: Field | null = null;
public constructor(props: Props) {
super(props);
this.state = {
phase: Phase.EnterEmail,
email: "",
password: "",
password2: "",
errorText: null,
// We perform liveliness checks later, but for now suppress the errors.
// We also track the server dead errors independently of the regular errors so
// that we can render it differently, and override any other error the user may
// be seeing.
serverIsAlive: true,
serverDeadError: "",
logoutDevices: false,
};
this.reset = new PasswordReset(this.props.serverConfig.hsUrl, this.props.serverConfig.isUrl);
}
public componentDidUpdate(prevProps: Readonly<Props>): void {
if (
prevProps.serverConfig.hsUrl !== this.props.serverConfig.hsUrl ||
prevProps.serverConfig.isUrl !== this.props.serverConfig.isUrl
) {
// Do a liveliness check on the new URLs
this.checkServerLiveliness(this.props.serverConfig);
}
}
public componentWillUnmount(): void {
this.unmounted = true;
}
private async checkServerLiveliness(serverConfig: ValidatedServerConfig): Promise<boolean> {
try {
await AutoDiscoveryUtils.validateServerConfigWithStaticUrls(serverConfig.hsUrl, serverConfig.isUrl);
if (this.unmounted) return false;
this.setState({
serverIsAlive: true,
});
} catch (e: any) {
if (this.unmounted) return false;
const { serverIsAlive, serverDeadError } = AutoDiscoveryUtils.authComponentStateForError(
e,
"forgot_password",
);
this.setState({
serverIsAlive,
errorText: serverDeadError,
});
return serverIsAlive;
}
return true;
}
private async onPhaseEmailInputSubmit(): Promise<void> {
this.phase = Phase.SendingEmail;
if (await this.sendVerificationMail()) {
this.phase = Phase.EmailSent;
return;
}
this.phase = Phase.EnterEmail;
}
private sendVerificationMail = async (): Promise<boolean> => {
try {
await this.reset.requestResetToken(this.state.email);
return true;
} catch (err: any) {
this.handleError(err);
}
return false;
};
private handleError(err: any): void {
if (err?.httpStatus === 429) {
// 429: rate limit
const retryAfterMs = parseInt(err?.data?.retry_after_ms, 10);
const errorText = isNaN(retryAfterMs)
? _t("auth|reset_password|rate_limit_error")
: _t("auth|reset_password|rate_limit_error_with_time", {
timeout: formatSeconds(retryAfterMs / 1000),
});
this.setState({
errorText,
});
return;
}
if (err?.name === "ConnectionError") {
this.setState({
errorText: _t("cannot_reach_homeserver") + ": " + _t("cannot_reach_homeserver_detail"),
});
return;
}
this.setState({
errorText: err.message,
});
}
private async onPhaseEmailSentSubmit(): Promise<void> {
this.setState({
phase: Phase.PasswordInput,
});
}
private set phase(phase: Phase) {
this.setState({ phase });
}
private async verifyFieldsBeforeSubmit(): Promise<boolean> {
const fieldIdsInDisplayOrder = [this.fieldPassword, this.fieldPasswordConfirm];
const invalidFields: Field[] = [];
for (const field of fieldIdsInDisplayOrder) {
if (!field) continue;
const valid = await field.validate({ allowEmpty: false });
if (!valid) {
invalidFields.push(field);
}
}
if (invalidFields.length === 0) {
return true;
}
// Focus on the first invalid field, then re-validate,
// which will result in the error tooltip being displayed for that field.
invalidFields[0].focus();
invalidFields[0].validate({ allowEmpty: false, focused: true });
return false;
}
private async onPhasePasswordInputSubmit(): Promise<void> {
if (!(await this.verifyFieldsBeforeSubmit())) return;
if (this.state.logoutDevices) {
const logoutDevicesConfirmation = await this.renderConfirmLogoutDevicesDialog();
if (!logoutDevicesConfirmation) return;
}
this.phase = Phase.ResettingPassword;
this.reset.setLogoutDevices(this.state.logoutDevices);
try {
await this.reset.setNewPassword(this.state.password);
this.setState({ phase: Phase.Done });
return;
} catch (err: any) {
if (err.httpStatus !== 401) {
// 401 = waiting for email verification, else unknown error
this.handleError(err);
return;
}
}
const modal = Modal.createDialog(
VerifyEmailModal,
{
email: this.state.email,
errorText: this.state.errorText,
onCloseClick: () => {
modal.close();
this.setState({ phase: Phase.PasswordInput });
},
onReEnterEmailClick: () => {
modal.close();
this.setState({ phase: Phase.EnterEmail });
},
onResendClick: this.sendVerificationMail,
},
"mx_VerifyEMailDialog",
false,
false,
{
onBeforeClose: async (reason?: string): Promise<boolean> => {
if (reason === "backgroundClick") {
// Modal dismissed by clicking the background.
// Go one phase back.
this.setState({ phase: Phase.PasswordInput });
}
return true;
},
},
);
// Don't retry if the phase changed. For example when going back to email input.
while (this.state.phase === Phase.ResettingPassword) {
try {
await this.reset.setNewPassword(this.state.password);
this.setState({ phase: Phase.Done });
modal.close();
} catch {
// Email not confirmed, yet. Retry after a while.
await sleep(emailCheckInterval);
}
}
}
private onSubmitForm = async (ev: React.FormEvent): Promise<void> => {
ev.preventDefault();
// Should not happen because of disabled forms, but just return if currently doing an action.
if ([Phase.SendingEmail, Phase.ResettingPassword].includes(this.state.phase)) return;
this.setState({
errorText: "",
});
// Refresh the server errors. Just in case the server came back online of went offline.
const serverIsAlive = await this.checkServerLiveliness(this.props.serverConfig);
// Server error
if (!serverIsAlive) return;
switch (this.state.phase) {
case Phase.EnterEmail:
this.onPhaseEmailInputSubmit();
break;
case Phase.EmailSent:
this.onPhaseEmailSentSubmit();
break;
case Phase.PasswordInput:
this.onPhasePasswordInputSubmit();
break;
}
};
private onInputChanged = (
stateKey: "email" | "password" | "password2",
ev: React.FormEvent<HTMLInputElement>,
): void => {
let value = ev.currentTarget.value;
if (stateKey === "email") value = value.trim();
this.setState({
[stateKey]: value,
} as Pick<State, typeof stateKey>);
};
public renderEnterEmail(): JSX.Element {
return (
<EnterEmail
email={this.state.email}
errorText={this.state.errorText}
homeserver={this.props.serverConfig.hsName}
loading={this.state.phase === Phase.SendingEmail}
onInputChanged={this.onInputChanged}
onLoginClick={this.props.onLoginClick!} // set by default props
onSubmitForm={this.onSubmitForm}
/>
);
}
public async renderConfirmLogoutDevicesDialog(): Promise<boolean> {
const { finished } = Modal.createDialog(QuestionDialog, {
title: _t("common|warning"),
description: (
<div>
<p>{_t("auth|reset_password|other_devices_logout_warning_1")}</p>
<p>{_t("auth|reset_password|other_devices_logout_warning_2")}</p>
</div>
),
button: _t("action|continue"),
});
const [confirmed] = await finished;
return !!confirmed;
}
public renderCheckEmail(): JSX.Element {
return (
<CheckEmail
email={this.state.email}
errorText={this.state.errorText}
onReEnterEmailClick={() => this.setState({ phase: Phase.EnterEmail })}
onResendClick={this.sendVerificationMail}
onSubmitForm={this.onSubmitForm}
/>
);
}
public renderSetPassword(): JSX.Element {
const submitButtonChild =
this.state.phase === Phase.ResettingPassword ? <Spinner size={16} /> : _t("auth|reset_password_action");
return (
<>
<LockSolidIcon className="mx_AuthBody_lockIcon" />
<h1>{_t("auth|reset_password_title")}</h1>
<form onSubmit={this.onSubmitForm}>
<fieldset disabled={this.state.phase === Phase.ResettingPassword}>
<div className="mx_AuthBody_fieldRow">
<PassphraseField
name="reset_password"
type="password"
label={_td("auth|change_password_new_label")}
value={this.state.password}
minScore={PASSWORD_MIN_SCORE}
fieldRef={(field) => {
this.fieldPassword = field;
}}
onChange={this.onInputChanged.bind(this, "password")}
autoComplete="new-password"
/>
<PassphraseConfirmField
name="reset_password_confirm"
label={_td("auth|reset_password|confirm_new_password")}
labelRequired={_td("auth|reset_password|password_not_entered")}
labelInvalid={_td("auth|reset_password|passwords_mismatch")}
value={this.state.password2}
password={this.state.password}
fieldRef={(field) => {
this.fieldPasswordConfirm = field;
}}
onChange={this.onInputChanged.bind(this, "password2")}
autoComplete="new-password"
/>
</div>
<div className="mx_AuthBody_fieldRow">
<StyledCheckbox
onChange={() => this.setState({ logoutDevices: !this.state.logoutDevices })}
checked={this.state.logoutDevices}
>
{_t("auth|reset_password|sign_out_other_devices")}
</StyledCheckbox>
</div>
{this.state.errorText && <ErrorMessage message={this.state.errorText} />}
<Button type="submit" className="mx_Login_submit" size="sm">
{submitButtonChild}
</Button>
</fieldset>
</form>
</>
);
}
public renderDone(): JSX.Element {
return (
<>
<CheckIcon className="mx_Icon mx_Icon_32 mx_Icon_accent" />
<h1>{_t("auth|reset_password|reset_successful")}</h1>
{this.state.logoutDevices ? <p>{_t("auth|reset_password|devices_logout_success")}</p> : null}
<Button className="mx_Login_submit" size="sm" type="button" onClick={this.props.onComplete}>
{_t("auth|reset_password|return_to_login")}
</Button>
</>
);
}
public render(): React.ReactNode {
let resetPasswordJsx: JSX.Element;
switch (this.state.phase) {
case Phase.EnterEmail:
case Phase.SendingEmail:
resetPasswordJsx = this.renderEnterEmail();
break;
case Phase.EmailSent:
resetPasswordJsx = this.renderCheckEmail();
break;
case Phase.PasswordInput:
case Phase.ResettingPassword:
resetPasswordJsx = this.renderSetPassword();
break;
case Phase.Done:
resetPasswordJsx = this.renderDone();
break;
default:
// This should not happen. However, it is logged and the user is sent to the start.
logger.warn(`unknown forgot password phase ${this.state.phase}`);
this.setState({
phase: Phase.EnterEmail,
});
return;
}
return (
<AuthPage>
<AuthHeader />
<AuthBody className="mx_AuthBody_forgot-password">{resetPasswordJsx}</AuthBody>
</AuthPage>
);
}
}
@@ -0,0 +1,554 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2015-2021 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
import React, { type JSX, type ReactNode } from "react";
import classNames from "classnames";
import { logger } from "matrix-js-sdk/src/logger";
import { type SSOFlow, SSOAction } from "matrix-js-sdk/src/matrix";
import { Button } from "@vector-im/compound-web";
import { _t, UserFriendlyError } from "../../../languageHandler";
import Login, { type ClientLoginFlow, type OidcNativeFlow } from "../../../Login";
import { messageForConnectionError, messageForLoginError } from "../../../utils/ErrorUtils";
import AutoDiscoveryUtils from "../../../utils/AutoDiscoveryUtils";
import AuthPage from "../../views/auth/AuthPage";
import PlatformPeg from "../../../PlatformPeg";
import SettingsStore from "../../../settings/SettingsStore";
import { UIFeature } from "../../../settings/UIFeature";
import { type IMatrixClientCreds } from "../../../MatrixClientPeg";
import PasswordLogin from "../../views/auth/PasswordLogin";
import InlineSpinner from "../../views/elements/InlineSpinner";
import Spinner from "../../views/elements/Spinner";
import SSOButtons from "../../views/elements/SSOButtons";
import ServerPicker from "../../views/elements/ServerPicker";
import AuthBody from "../../views/auth/AuthBody";
import AuthHeader from "../../views/auth/AuthHeader";
import AccessibleButton, { type ButtonEvent } from "../../views/elements/AccessibleButton";
import { type ValidatedServerConfig } from "../../../utils/ValidatedServerConfig";
import { filterBoolean } from "../../../utils/arrays";
import { startOidcLogin } from "../../../utils/oidc/authorize";
interface IProps {
serverConfig: ValidatedServerConfig;
// If true, the component will consider itself busy.
busy?: boolean;
isSyncing?: boolean;
// Secondary HS which we try to log into if the user is using
// the default HS but login fails. Useful for migrating to a
// different homeserver without confusing users.
fallbackHsUrl?: string;
defaultDeviceDisplayName?: string;
fragmentAfterLogin?: string;
defaultUsername?: string;
// Called when the user has logged in. Params:
// - The object returned by the login API
onLoggedIn(data: IMatrixClientCreds): void;
// login shouldn't know or care how registration, password recovery, etc is done.
onRegisterClick(): void;
onForgotPasswordClick?(): void;
onServerConfigChange(config: ValidatedServerConfig): void;
}
interface IState {
busy: boolean;
busyLoggingIn?: boolean;
errorText?: ReactNode;
loginIncorrect: boolean;
// can we attempt to log in or are there validation errors?
canTryLogin: boolean;
flows?: ClientLoginFlow[];
// used for preserving form values when changing homeserver
username: string;
phoneCountry: string;
phoneNumber: string;
// We perform liveliness checks later, but for now suppress the errors.
// We also track the server dead errors independently of the regular errors so
// that we can render it differently, and override any other error the user may
// be seeing.
serverIsAlive: boolean;
serverErrorIsFatal: boolean;
serverDeadError?: ReactNode;
}
type OnPasswordLogin = {
(username: string, phoneCountry: undefined, phoneNumber: undefined, password: string): Promise<void>;
(username: undefined, phoneCountry: string, phoneNumber: string, password: string): Promise<void>;
};
/*
* A wire component which glues together login UI components and Login logic
*/
export default class LoginComponent extends React.PureComponent<IProps, IState> {
private unmounted = false;
private loginLogic!: Login;
private readonly stepRendererMap: Record<string, () => ReactNode>;
public constructor(props: IProps) {
super(props);
this.state = {
busy: false,
errorText: null,
loginIncorrect: false,
canTryLogin: true,
username: props.defaultUsername ? props.defaultUsername : "",
phoneCountry: "",
phoneNumber: "",
serverIsAlive: true,
serverErrorIsFatal: false,
serverDeadError: "",
};
// map from login step type to a function which will render a control
// letting you do that login type
this.stepRendererMap = {
"m.login.password": this.renderPasswordStep,
// CAS and SSO are the same thing, modulo the url we link to
// eslint-disable-next-line @typescript-eslint/naming-convention
"m.login.cas": () => this.renderSsoStep("cas"),
// eslint-disable-next-line @typescript-eslint/naming-convention
"m.login.sso": () => this.renderSsoStep("sso"),
"oidcNativeFlow": () => this.renderOidcNativeStep(),
};
}
public componentDidMount(): void {
this.unmounted = false;
this.initLoginLogic(this.props.serverConfig);
}
public componentWillUnmount(): void {
this.unmounted = true;
}
public componentDidUpdate(prevProps: IProps): void {
if (
prevProps.serverConfig.hsUrl !== this.props.serverConfig.hsUrl ||
prevProps.serverConfig.isUrl !== this.props.serverConfig.isUrl ||
// delegatedAuthentication is only set by buildValidatedConfigFromDiscovery and won't be modified
// so shallow comparison is fine
prevProps.serverConfig.delegatedAuthentication !== this.props.serverConfig.delegatedAuthentication
) {
// Ensure that we end up actually logging in to the right place
this.initLoginLogic(this.props.serverConfig);
}
}
public isBusy = (): boolean => !!this.state.busy || !!this.props.busy;
public onPasswordLogin: OnPasswordLogin = async (
username: string | undefined,
phoneCountry: string | undefined,
phoneNumber: string | undefined,
password: string,
): Promise<void> => {
if (!this.state.serverIsAlive) {
this.setState({ busy: true });
// Do a quick liveliness check on the URLs
let aliveAgain = true;
try {
await AutoDiscoveryUtils.validateServerConfigWithStaticUrls(
this.props.serverConfig.hsUrl,
this.props.serverConfig.isUrl,
);
this.setState({ serverIsAlive: true, errorText: "" });
} catch (e) {
const componentState = AutoDiscoveryUtils.authComponentStateForError(e);
this.setState({
busy: false,
busyLoggingIn: false,
...componentState,
});
aliveAgain = !componentState.serverErrorIsFatal;
}
// Prevent people from submitting their password when something isn't right.
if (!aliveAgain) {
return;
}
}
this.setState({
busy: true,
busyLoggingIn: true,
errorText: null,
loginIncorrect: false,
});
this.loginLogic.loginViaPassword(username, phoneCountry, phoneNumber, password).then(
(data) => {
this.setState({ serverIsAlive: true }); // it must be, we logged in.
this.props.onLoggedIn(data);
},
(error) => {
if (this.unmounted) return;
let errorText: ReactNode;
// Some error strings only apply for logging in
if (error.httpStatus === 400 && username && username.indexOf("@") > 0) {
errorText = _t("auth|unsupported_auth_email");
} else {
errorText = messageForLoginError(error, this.props.serverConfig);
}
this.setState({
busy: false,
busyLoggingIn: false,
errorText,
// 401 would be the sensible status code for 'incorrect password'
// but the login API gives a 403 https://matrix.org/jira/browse/SYN-744
// mentions this (although the bug is for UI auth which is not this)
// We treat both as an incorrect password
loginIncorrect: error.httpStatus === 401 || error.httpStatus === 403,
});
},
);
};
public onUsernameChanged = (username: string): void => {
this.setState({ username });
};
public onUsernameBlur = async (username: string): Promise<void> => {
const doWellknownLookup = username[0] === "@";
this.setState({
username: username,
busy: doWellknownLookup,
errorText: null,
canTryLogin: true,
});
if (doWellknownLookup) {
const serverName = username.split(":").slice(1).join(":");
try {
const result = await AutoDiscoveryUtils.validateServerName(serverName);
this.props.onServerConfigChange(result);
// We'd like to rely on new props coming in via `onServerConfigChange`
// so that we know the servers have definitely updated before clearing
// the busy state. In the case of a full MXID that resolves to the same
// HS as Element's default HS though, there may not be any server change.
// To avoid this trap, we clear busy here. For cases where the server
// actually has changed, `initLoginLogic` will be called and manages
// busy state for its own liveness check.
this.setState({
busy: false,
});
} catch (e) {
logger.error("Problem parsing URL or unhandled error doing .well-known discovery:", e);
let message = _t("auth|failed_homeserver_discovery");
if (e instanceof UserFriendlyError && e.translatedMessage) {
message = e.translatedMessage;
}
let errorText: ReactNode = message;
let discoveryState = {};
if (AutoDiscoveryUtils.isLivelinessError(e)) {
errorText = this.state.errorText;
discoveryState = AutoDiscoveryUtils.authComponentStateForError(e);
}
this.setState({
busy: false,
errorText,
...discoveryState,
});
}
}
};
public onPhoneCountryChanged = (phoneCountry: string): void => {
this.setState({ phoneCountry });
};
public onPhoneNumberChanged = (phoneNumber: string): void => {
this.setState({ phoneNumber });
};
public onRegisterClick = (ev: ButtonEvent): void => {
ev.preventDefault();
ev.stopPropagation();
this.props.onRegisterClick();
};
public onTryRegisterClick = (ev: ButtonEvent): void => {
const hasPasswordFlow = this.state.flows?.find((flow) => flow.type === "m.login.password");
const ssoFlow = this.state.flows?.find((flow) => flow.type === "m.login.sso" || flow.type === "m.login.cas");
// If has no password flow but an SSO flow guess that the user wants to register with SSO.
// TODO: instead hide the Register button if registration is disabled by checking with the server,
// has no specific errCode currently and uses M_FORBIDDEN.
if (ssoFlow && !hasPasswordFlow) {
ev.preventDefault();
ev.stopPropagation();
const ssoKind = ssoFlow.type === "m.login.sso" ? "sso" : "cas";
PlatformPeg.get()?.startSingleSignOn(
this.loginLogic.createTemporaryClient(),
ssoKind,
this.props.fragmentAfterLogin,
undefined,
SSOAction.REGISTER,
);
} else {
// Don't intercept - just go through to the register page
this.onRegisterClick(ev);
}
};
private async checkServerLiveliness({
hsUrl,
isUrl,
}: Pick<ValidatedServerConfig, "hsUrl" | "isUrl">): Promise<void> {
// Do a quick liveliness check on the URLs
try {
const { warning } = await AutoDiscoveryUtils.validateServerConfigWithStaticUrls(hsUrl, isUrl);
if (warning) {
this.setState({
...AutoDiscoveryUtils.authComponentStateForError(warning),
errorText: "",
});
} else {
this.setState({
serverIsAlive: true,
errorText: "",
});
}
} catch (e) {
this.setState({
busy: false,
...AutoDiscoveryUtils.authComponentStateForError(e as Error),
});
}
}
private async initLoginLogic({ hsUrl, isUrl }: ValidatedServerConfig): Promise<void> {
let isDefaultServer = false;
if (
this.props.serverConfig.isDefault &&
hsUrl === this.props.serverConfig.hsUrl &&
isUrl === this.props.serverConfig.isUrl
) {
isDefaultServer = true;
}
const fallbackHsUrl = isDefaultServer ? this.props.fallbackHsUrl! : null;
this.setState({
busy: true,
loginIncorrect: false,
});
await this.checkServerLiveliness({ hsUrl, isUrl });
const loginLogic = new Login(hsUrl, isUrl, fallbackHsUrl, {
defaultDeviceDisplayName: this.props.defaultDeviceDisplayName,
delegatedAuthentication: this.props.serverConfig.delegatedAuthentication,
});
this.loginLogic = loginLogic;
loginLogic
.getFlows()
.then(
(flows) => {
// look for a flow where we understand all of the steps.
const supportedFlows = flows.filter(this.isSupportedFlow);
this.setState({
flows: supportedFlows,
});
if (supportedFlows.length === 0) {
this.setState({
errorText: _t("auth|unsupported_auth"),
});
}
},
(err) => {
this.setState({
errorText: messageForConnectionError(err, this.props.serverConfig),
loginIncorrect: false,
canTryLogin: false,
});
},
)
.finally(() => {
this.setState({
busy: false,
});
});
}
private isSupportedFlow = (flow: ClientLoginFlow): boolean => {
// technically the flow can have multiple steps, but no one does this
// for login and loginLogic doesn't support it so we can ignore it.
if (!this.stepRendererMap[flow.type]) {
logger.log("Skipping flow", flow, "due to unsupported login type", flow.type);
return false;
}
return true;
};
public renderLoginComponentForFlows(): ReactNode {
if (!this.state.flows) return null;
// this is the ideal order we want to show the flows in
const order = ["oidcNativeFlow", "m.login.password", "m.login.sso"];
const flows = filterBoolean(order.map((type) => this.state.flows?.find((flow) => flow.type === type)));
return (
<React.Fragment>
{flows.map((flow) => {
const stepRenderer = this.stepRendererMap[flow.type];
return <React.Fragment key={flow.type}>{stepRenderer()}</React.Fragment>;
})}
</React.Fragment>
);
}
private renderPasswordStep = (): JSX.Element => {
return (
<PasswordLogin
onSubmit={this.onPasswordLogin}
username={this.state.username}
phoneCountry={this.state.phoneCountry}
phoneNumber={this.state.phoneNumber}
onUsernameChanged={this.onUsernameChanged}
onUsernameBlur={this.onUsernameBlur}
onPhoneCountryChanged={this.onPhoneCountryChanged}
onPhoneNumberChanged={this.onPhoneNumberChanged}
onForgotPasswordClick={this.props.onForgotPasswordClick}
loginIncorrect={this.state.loginIncorrect}
serverConfig={this.props.serverConfig}
disableSubmit={this.isBusy()}
busy={this.props.isSyncing || this.state.busyLoggingIn}
/>
);
};
private renderOidcNativeStep = (): React.ReactNode => {
const flow = this.state.flows!.find((flow) => flow.type === "oidcNativeFlow")! as OidcNativeFlow;
return (
<Button
className="mx_Login_fullWidthButton"
kind="primary"
size="sm"
onClick={async () => {
await startOidcLogin(
this.props.serverConfig.delegatedAuthentication!,
flow.clientId,
this.props.serverConfig.hsUrl,
this.props.serverConfig.isUrl,
);
}}
>
{_t("action|continue")}
</Button>
);
};
private renderSsoStep = (loginType: "cas" | "sso"): JSX.Element => {
const flow = this.state.flows?.find((flow) => flow.type === "m.login." + loginType) as SSOFlow;
return (
<SSOButtons
matrixClient={this.loginLogic.createTemporaryClient()}
flow={flow}
loginType={loginType}
fragmentAfterLogin={this.props.fragmentAfterLogin}
primary={!this.state.flows?.find((flow) => flow.type === "m.login.password")}
action={SSOAction.LOGIN}
disabled={this.isBusy()}
/>
);
};
public render(): React.ReactNode {
const loader =
this.isBusy() && !this.state.busyLoggingIn ? (
<div className="mx_Login_loader">
<Spinner />
</div>
) : null;
const errorText = this.state.errorText;
let errorTextSection;
if (errorText) {
errorTextSection = <div className="mx_Login_error">{errorText}</div>;
}
let serverDeadSection;
if (!this.state.serverIsAlive) {
const classes = classNames({
mx_Login_error: true,
mx_Login_serverError: true,
mx_Login_serverErrorNonFatal: !this.state.serverErrorIsFatal,
});
serverDeadSection = <div className={classes}>{this.state.serverDeadError}</div>;
}
let footer;
if (this.props.isSyncing || this.state.busyLoggingIn) {
footer = (
<div className="mx_AuthBody_paddedFooter">
<div className="mx_AuthBody_paddedFooter_title">
<InlineSpinner w={20} h={20} />
{this.props.isSyncing ? _t("auth|syncing") : _t("auth|signing_in")}
</div>
{this.props.isSyncing && (
<div className="mx_AuthBody_paddedFooter_subtitle">{_t("auth|sync_footer_subtitle")}</div>
)}
</div>
);
} else if (SettingsStore.getValue(UIFeature.Registration)) {
footer = (
<span className="mx_AuthBody_changeFlow">
{_t(
"auth|create_account_prompt",
{},
{
a: (sub) => (
<AccessibleButton kind="link_inline" onClick={this.onTryRegisterClick}>
{sub}
</AccessibleButton>
),
},
)}
</span>
);
}
return (
<AuthPage>
<AuthHeader disableLanguageSelector={this.props.isSyncing || this.state.busyLoggingIn} />
<AuthBody>
<h1>
{_t("action|sign_in")}
{loader}
</h1>
{errorTextSection}
{serverDeadSection}
<ServerPicker
serverConfig={this.props.serverConfig}
onServerConfigChange={this.props.onServerConfigChange}
disabled={this.isBusy()}
/>
{this.renderLoginComponentForFlows()}
{footer}
</AuthBody>
</AuthPage>
);
}
}
@@ -0,0 +1,81 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2015-2024 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
import React, { type JSX } from "react";
import { type MatrixClient } from "matrix-js-sdk/src/matrix";
import { CryptoEvent } from "matrix-js-sdk/src/crypto-api";
import { messageForSyncError } from "../../../utils/ErrorUtils";
import Spinner from "../../views/elements/Spinner";
import ProgressBar from "../../views/elements/ProgressBar";
import AccessibleButton, { type ButtonEvent } from "../../views/elements/AccessibleButton";
import { _t } from "../../../languageHandler";
import { useTypedEventEmitterState } from "../../../hooks/useEventEmitter";
import SdkConfig from "../../../SdkConfig";
interface Props {
/** The matrix client which is logging in */
matrixClient: MatrixClient;
/**
* A callback function. Will be called if the user clicks the "logout" button on the splash screen.
*
* @param event - The click event
*/
onLogoutClick: (this: void, event: ButtonEvent) => void;
/**
* Error that caused `/sync` to fail. If set, an error message will be shown on the splash screen.
*/
syncError: Error | null;
}
type MigrationState = {
progress: number;
totalSteps: number;
};
/**
* The view that is displayed after we have logged in, before the first /sync is completed.
*/
export function LoginSplashView(props: Props): JSX.Element {
const migrationState = useTypedEventEmitterState(
props.matrixClient,
CryptoEvent.LegacyCryptoStoreMigrationProgress,
(progress?: number, total?: number): MigrationState => ({ progress: progress ?? -1, totalSteps: total ?? -1 }),
);
let errorBox: JSX.Element | undefined;
if (props.syncError) {
errorBox = <div className="mx_LoginSplashView_syncError">{messageForSyncError(props.syncError)}</div>;
}
// If we are migrating the crypto data, show a progress bar. Otherwise, show a normal spinner.
let spinnerOrProgress;
if (migrationState.totalSteps !== -1) {
spinnerOrProgress = (
<div className="mx_LoginSplashView_migrationProgress">
<p>{_t("migrating_crypto", { brand: SdkConfig.get().brand })}</p>
<ProgressBar value={migrationState.progress} max={migrationState.totalSteps} />
</div>
);
} else {
spinnerOrProgress = <Spinner />;
}
return (
<div className="mx_MatrixChat_splash">
{errorBox}
{spinnerOrProgress}
<div className="mx_LoginSplashView_splashButtons">
<AccessibleButton kind="link_inline" onClick={props.onLogoutClick}>
{_t("action|logout")}
</AccessibleButton>
</div>
</div>
);
}
@@ -0,0 +1,784 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2015-2021 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
import {
AuthType,
createClient,
type IAuthData,
type AuthDict,
type IInputs,
MatrixError,
type IRegisterRequestParams,
type IRequestTokenResponse,
type MatrixClient,
type SSOFlow,
SSOAction,
type RegisterResponse,
} from "matrix-js-sdk/src/matrix";
import React, { type JSX, Fragment, type ReactNode } from "react";
import classNames from "classnames";
import { logger } from "matrix-js-sdk/src/logger";
import { Button } from "@vector-im/compound-web";
import { _t } from "../../../languageHandler";
import { adminContactStrings, messageForResourceLimitError, resourceLimitStrings } from "../../../utils/ErrorUtils";
import AutoDiscoveryUtils from "../../../utils/AutoDiscoveryUtils";
import * as Lifecycle from "../../../Lifecycle";
import { type IMatrixClientCreds, MatrixClientPeg } from "../../../MatrixClientPeg";
import AuthPage from "../../views/auth/AuthPage";
import Login, { type OidcNativeFlow } from "../../../Login";
import dis from "../../../dispatcher/dispatcher";
import SSOButtons from "../../views/elements/SSOButtons";
import ServerPicker from "../../views/elements/ServerPicker";
import RegistrationForm from "../../views/auth/RegistrationForm";
import AccessibleButton, { type ButtonEvent } from "../../views/elements/AccessibleButton";
import AuthBody from "../../views/auth/AuthBody";
import AuthHeader from "../../views/auth/AuthHeader";
import InteractiveAuth, { type InteractiveAuthCallback } from "../InteractiveAuth";
import Spinner from "../../views/elements/Spinner";
import { AuthHeaderDisplay } from "./header/AuthHeaderDisplay";
import { AuthHeaderProvider } from "./header/AuthHeaderProvider";
import SettingsStore from "../../../settings/SettingsStore";
import { type ValidatedServerConfig } from "../../../utils/ValidatedServerConfig";
import { startOidcLogin } from "../../../utils/oidc/authorize";
const debuglog = (...args: any[]): void => {
if (SettingsStore.getValue("debug_registration")) {
logger.log.call(console, "Registration debuglog:", ...args);
}
};
export interface MobileRegistrationResponse {
user_id: string;
home_server: string;
access_token: string;
device_id: string;
}
interface IProps {
serverConfig: ValidatedServerConfig;
defaultDeviceDisplayName?: string;
email?: string;
brand?: string;
clientSecret?: string;
sessionId?: string;
idSid?: string;
fragmentAfterLogin?: string;
mobileRegister?: boolean;
// Called when the user has logged in. Params:
// - object with userId, deviceId, homeserverUrl, identityServerUrl, accessToken
onLoggedIn(params: IMatrixClientCreds): Promise<void>;
// registration shouldn't know or care how login is done.
onLoginClick(): void;
onServerConfigChange(config: ValidatedServerConfig): void;
}
interface IState {
// true if we're waiting for the user to complete
busy: boolean;
errorText?: ReactNode;
// We remember the values entered by the user because
// the registration form will be unmounted during the
// course of registration, but if there's an error we
// want to bring back the registration form with the
// values the user entered still in it. We can keep
// them in this component's state since this component
// persist for the duration of the registration process.
formVals: Record<string, string | undefined>;
// user-interactive auth
// If we've been given a session ID, we're resuming
// straight back into UI auth
doingUIAuth: boolean;
// If set, we've registered but are not going to log
// the user in to their new account automatically.
completedNoSignin: boolean;
flows:
| {
stages: string[];
}[]
| null;
// We perform liveliness checks later, but for now suppress the errors.
// We also track the server dead errors independently of the regular errors so
// that we can render it differently, and override any other error the user may
// be seeing.
serverIsAlive: boolean;
serverErrorIsFatal: boolean;
serverDeadError?: ReactNode;
// Our matrix client - part of state because we can't render the UI auth
// component without it.
matrixClient?: MatrixClient;
// The user ID we've just registered
registeredUsername?: string;
// if a different user ID to the one we just registered is logged in,
// this is the user ID that's logged in.
differentLoggedInUserId?: string;
// the SSO flow definition, this is fetched from /login as that's the only
// place it is exposed.
ssoFlow?: SSOFlow;
// the OIDC native login flow, when supported and enabled
// if present, must be used for registration
oidcNativeFlow?: OidcNativeFlow;
}
export default class Registration extends React.Component<IProps, IState> {
private readonly loginLogic: Login;
// `replaceClient` tracks latest serverConfig to spot when it changes under the async method which fetches flows
private latestServerConfig?: ValidatedServerConfig;
public constructor(props: IProps) {
super(props);
this.state = {
busy: false,
errorText: null,
formVals: {
email: this.props.email,
},
doingUIAuth: Boolean(this.props.sessionId),
flows: null,
completedNoSignin: false,
serverIsAlive: true,
serverErrorIsFatal: false,
serverDeadError: "",
};
const { hsUrl, isUrl, delegatedAuthentication } = this.props.serverConfig;
this.loginLogic = new Login(hsUrl, isUrl, null, {
defaultDeviceDisplayName: "Element login check", // We shouldn't ever be used
delegatedAuthentication,
});
}
public componentDidMount(): void {
this.replaceClient(this.props.serverConfig);
//triggers a confirmation dialog for data loss before page unloads/refreshes
window.addEventListener("beforeunload", this.unloadCallback);
}
public componentWillUnmount(): void {
window.removeEventListener("beforeunload", this.unloadCallback);
}
private unloadCallback = (event: BeforeUnloadEvent): string | undefined => {
if (this.state.doingUIAuth) {
event.preventDefault();
event.returnValue = "";
return "";
}
};
public componentDidUpdate(prevProps: IProps): void {
if (
prevProps.serverConfig.hsUrl !== this.props.serverConfig.hsUrl ||
prevProps.serverConfig.isUrl !== this.props.serverConfig.isUrl
) {
this.replaceClient(this.props.serverConfig);
}
}
private async replaceClient(serverConfig: ValidatedServerConfig): Promise<void> {
this.latestServerConfig = serverConfig;
const { hsUrl, isUrl } = serverConfig;
this.setState({
errorText: null,
serverDeadError: null,
serverErrorIsFatal: false,
// busy while we do live-ness check (we need to avoid trying to render
// the UI auth component while we don't have a matrix client)
busy: true,
});
// Do a liveliness check on the URLs
try {
await AutoDiscoveryUtils.validateServerConfigWithStaticUrls(hsUrl, isUrl);
if (serverConfig !== this.latestServerConfig) return; // discard, serverConfig changed from under us
this.setState({
serverIsAlive: true,
serverErrorIsFatal: false,
});
} catch (e) {
if (serverConfig !== this.latestServerConfig) return; // discard, serverConfig changed from under us
this.setState({
busy: false,
...AutoDiscoveryUtils.authComponentStateForError(e, "register"),
});
if (this.state.serverErrorIsFatal) {
return; // Server is dead - do not continue.
}
}
const cli = createClient({
baseUrl: hsUrl,
idBaseUrl: isUrl,
});
this.loginLogic.setHomeserverUrl(hsUrl);
this.loginLogic.setIdentityServerUrl(isUrl);
this.loginLogic.setDelegatedAuthentication(serverConfig.delegatedAuthentication);
let ssoFlow: SSOFlow | undefined;
let oidcNativeFlow: OidcNativeFlow | undefined;
try {
const loginFlows = await this.loginLogic.getFlows(true);
if (serverConfig !== this.latestServerConfig) return; // discard, serverConfig changed from under us
ssoFlow = loginFlows.find((f) => f.type === "m.login.sso" || f.type === "m.login.cas") as SSOFlow;
oidcNativeFlow = loginFlows.find((f) => f.type === "oidcNativeFlow") as OidcNativeFlow;
} catch (e) {
if (serverConfig !== this.latestServerConfig) return; // discard, serverConfig changed from under us
logger.error("Failed to get login flows to check for SSO support", e);
}
await new Promise<void>((resolve) => {
this.setState(
({ flows }) => ({
matrixClient: cli,
ssoFlow,
oidcNativeFlow,
// if we are using oidc native we won't continue with flow discovery on HS
// so set an empty array to indicate flows are no longer loading
flows: oidcNativeFlow ? [] : flows,
busy: false,
}),
resolve,
);
});
// don't need to check with homeserver for login flows
// since we are going to use OIDC native flow
if (oidcNativeFlow) {
return;
}
try {
// We do the first registration request ourselves to discover whether we need to
// do SSO instead. If we've already started the UI Auth process though, we don't
// need to.
if (!this.state.doingUIAuth) {
await this.makeRegisterRequest(null);
if (serverConfig !== this.latestServerConfig) return; // discard, serverConfig changed from under us
// This should never succeed since we specified no auth object.
logger.log("Expecting 401 from register request but got success!");
}
} catch (e) {
if (serverConfig !== this.latestServerConfig) return; // discard, serverConfig changed from under us
if (e instanceof MatrixError && e.httpStatus === 401) {
this.setState({
flows: e.data.flows,
});
} else if (e instanceof MatrixError && (e.httpStatus === 403 || e.errcode === "M_FORBIDDEN")) {
// Check for 403 or M_FORBIDDEN, Synapse used to send 403 M_UNKNOWN but now sends 403 M_FORBIDDEN.
// At this point registration is pretty much disabled, but before we do that let's
// quickly check to see if the server supports SSO instead. If it does, we'll send
// the user off to the login page to figure their account out.
if (ssoFlow) {
// Redirect to login page - server probably expects SSO only
dis.dispatch({ action: "start_login" });
} else {
this.setState({
serverErrorIsFatal: true, // fatal because user cannot continue on this server
errorText: _t("auth|registration_disabled"),
// add empty flows array to get rid of spinner
flows: [],
});
}
} else {
logger.log("Unable to query for supported registration methods.", e);
this.setState({
errorText: _t("auth|failed_query_registration_methods"),
// add empty flows array to get rid of spinner
flows: [],
});
}
}
}
private onFormSubmit = async (formVals: Record<string, string>): Promise<void> => {
this.setState({
errorText: "",
busy: true,
formVals,
doingUIAuth: true,
});
};
private requestEmailToken = (
emailAddress: string,
clientSecret: string,
sendAttempt: number,
sessionId: string,
): Promise<IRequestTokenResponse> => {
if (!this.state.matrixClient) throw new Error("Matrix client has not yet been loaded");
return this.state.matrixClient.requestRegisterEmailToken(emailAddress, clientSecret, sendAttempt);
};
private onUIAuthFinished: InteractiveAuthCallback<RegisterResponse> = async (success, response): Promise<void> => {
if (!this.state.matrixClient) throw new Error("Matrix client has not yet been loaded");
debuglog("Registration: ui authentication finished: ", { success, response });
if (!success) {
let errorText: ReactNode = (response as Error).message || (response as Error).toString();
// can we give a better error message?
if (response instanceof MatrixError && response.errcode === "M_RESOURCE_LIMIT_EXCEEDED") {
const errorTop = messageForResourceLimitError(
response.data.limit_type,
response.data.admin_contact,
resourceLimitStrings,
);
const errorDetail = messageForResourceLimitError(
response.data.limit_type,
response.data.admin_contact,
adminContactStrings,
);
errorText = (
<div>
<p>{errorTop}</p>
<p>{errorDetail}</p>
</div>
);
} else if ((response as IAuthData).flows?.some((flow) => flow.stages.includes(AuthType.Msisdn))) {
const flows = (response as IAuthData).flows ?? [];
const msisdnAvailable = flows.some((flow) => flow.stages.includes(AuthType.Msisdn));
if (!msisdnAvailable) {
errorText = _t("auth|unsupported_auth_msisdn");
}
} else if (response instanceof MatrixError && response.errcode === "M_USER_IN_USE") {
errorText = _t("auth|username_in_use");
} else if (response instanceof MatrixError && response.errcode === "M_THREEPID_IN_USE") {
errorText = _t("auth|3pid_in_use");
}
this.setState({
busy: false,
doingUIAuth: false,
errorText,
});
return;
}
const userId = (response as RegisterResponse).user_id;
const accessToken = (response as RegisterResponse).access_token;
if (!userId || !accessToken) throw new Error("Registration failed");
MatrixClientPeg.setJustRegisteredUserId(userId);
const newState: Partial<IState> = {
doingUIAuth: false,
registeredUsername: userId,
differentLoggedInUserId: undefined,
completedNoSignin: false,
// we're still busy until we get unmounted: don't show the registration form again
busy: true,
};
// The user came in through an email validation link. To avoid overwriting
// their session, check to make sure the session isn't someone else, and
// isn't a guest user since we'll usually have set a guest user session before
// starting the registration process. This isn't perfect since it's possible
// the user had a separate guest session they didn't actually mean to replace.
const [sessionOwner, sessionIsGuest] = await Lifecycle.getStoredSessionOwner();
if (sessionOwner && !sessionIsGuest && sessionOwner !== userId) {
logger.log(`Found a session for ${sessionOwner} but ${userId} has just registered.`);
newState.differentLoggedInUserId = sessionOwner;
}
// if we don't have an email at all, only one client can be involved in this flow, and we can directly log in.
//
// if we've got an email, it needs to be verified. in that case, two clients can be involved in this flow, the
// original client starting the process and the client that submitted the verification token. After the token
// has been submitted, it can not be used again.
//
// we can distinguish them based on whether the client has form values saved (if so, it's the one that started
// the registration), or whether it doesn't have any form values saved (in which case it's the client that
// verified the email address)
//
// as the client that started registration may be gone by the time we've verified the email, and only the client
// that verified the email is guaranteed to exist, we'll always do the login in that client.
const hasEmail = Boolean(this.state.formVals.email);
const hasAccessToken = Boolean(accessToken);
debuglog("Registration: ui auth finished:", { hasEmail, hasAccessToken });
// dont log in if we found a session for a different user
if (hasAccessToken && !newState.differentLoggedInUserId) {
if (this.props.mobileRegister) {
const mobileResponse: MobileRegistrationResponse = {
user_id: userId,
home_server: this.state.matrixClient.getHomeserverUrl(),
access_token: accessToken,
device_id: (response as RegisterResponse).device_id!,
};
const event = new CustomEvent<MobileRegistrationResponse>("mobileregistrationresponse", {
detail: mobileResponse,
});
window.dispatchEvent(event);
newState.busy = false;
newState.completedNoSignin = true;
} else {
await this.props.onLoggedIn({
userId,
deviceId: (response as RegisterResponse).device_id!,
homeserverUrl: this.state.matrixClient.getHomeserverUrl(),
identityServerUrl: this.state.matrixClient.getIdentityServerUrl(),
accessToken,
});
this.setupPushers();
}
} else {
newState.busy = false;
newState.completedNoSignin = true;
}
this.setState(newState as IState);
};
private setupPushers(): Promise<void> {
if (!this.props.brand) {
return Promise.resolve();
}
const matrixClient = MatrixClientPeg.safeGet();
return matrixClient.getPushers().then(
(resp) => {
const pushers = resp.pushers;
for (let i = 0; i < pushers.length; ++i) {
if (pushers[i].kind === "email") {
const emailPusher = pushers[i];
emailPusher.data = { brand: this.props.brand };
matrixClient.setPusher(emailPusher).then(
() => {
logger.log("Set email branding to " + this.props.brand);
},
(error) => {
logger.error("Couldn't set email branding: " + error);
},
);
}
}
},
(error) => {
logger.error("Couldn't get pushers: " + error);
},
);
}
private onLoginClick = (ev: ButtonEvent): void => {
ev.preventDefault();
ev.stopPropagation();
this.props.onLoginClick();
};
private onGoToFormClicked = (ev: ButtonEvent): void => {
ev.preventDefault();
ev.stopPropagation();
this.replaceClient(this.props.serverConfig);
this.setState({
busy: false,
doingUIAuth: false,
});
};
private makeRegisterRequest = (auth: AuthDict | null): Promise<RegisterResponse> => {
if (!this.state.matrixClient) throw new Error("Matrix client has not yet been loaded");
const registerParams: IRegisterRequestParams = {
username: this.state.formVals.username,
password: this.state.formVals.password,
initial_device_display_name: this.props.defaultDeviceDisplayName,
auth: undefined,
// we still want to avoid the race conditions involved with multiple clients handling registration, but
// we'll handle these after we've received the access_token in onUIAuthFinished
inhibit_login: undefined,
};
if (auth) registerParams.auth = auth;
debuglog("Registration: sending registration request:", auth);
return this.state.matrixClient.registerRequest(registerParams);
};
private getUIAuthInputs(): IInputs {
return {
emailAddress: this.state.formVals.email,
phoneCountry: this.state.formVals.phoneCountry,
phoneNumber: this.state.formVals.phoneNumber,
};
}
// Links to the login page shown after registration is completed are routed through this
// which checks the user hasn't already logged in somewhere else (perhaps we should do
// this more generally?)
private onLoginClickWithCheck = async (ev: ButtonEvent): Promise<boolean> => {
ev.preventDefault();
const sessionLoaded = await Lifecycle.loadSession({ ignoreGuest: true });
if (!sessionLoaded) {
// ok fine, there's still no session: really go to the login page
this.props.onLoginClick();
}
return sessionLoaded;
};
private renderRegisterComponent(): ReactNode {
if (this.state.matrixClient && this.state.doingUIAuth) {
return (
<InteractiveAuth
matrixClient={this.state.matrixClient}
makeRequest={this.makeRegisterRequest}
onAuthFinished={this.onUIAuthFinished}
inputs={this.getUIAuthInputs()}
requestEmailToken={this.requestEmailToken}
sessionId={this.props.sessionId}
clientSecret={this.props.clientSecret}
emailSid={this.props.idSid}
poll={true}
/>
);
} else if (!this.state.matrixClient && !this.state.busy) {
return null;
} else if (this.state.busy || !this.state.flows) {
return (
<div className="mx_AuthBody_spinner">
<Spinner />
</div>
);
} else if (this.state.matrixClient && this.state.oidcNativeFlow) {
return (
<Button
className="mx_Login_fullWidthButton"
kind="primary"
size="sm"
onClick={async () => {
await startOidcLogin(
this.props.serverConfig.delegatedAuthentication!,
this.state.oidcNativeFlow!.clientId,
this.props.serverConfig.hsUrl,
this.props.serverConfig.isUrl,
true /* isRegistration */,
);
}}
>
{_t("action|continue")}
</Button>
);
} else if (this.state.matrixClient && this.state.flows.length) {
let ssoSection: JSX.Element | undefined;
if (!this.props.mobileRegister && this.state.ssoFlow) {
let continueWithSection;
const providers = this.state.ssoFlow.identity_providers || [];
// when there is only a single (or 0) providers we show a wide button with `Continue with X` text
if (providers.length > 1) {
// i18n: ssoButtons is a placeholder to help translators understand context
continueWithSection = (
<h2 className="mx_AuthBody_centered">
{_t("auth|continue_with_sso", { ssoButtons: "" }).trim()}
</h2>
);
}
// i18n: ssoButtons & usernamePassword are placeholders to help translators understand context
ssoSection = (
<React.Fragment>
{continueWithSection}
<SSOButtons
matrixClient={this.loginLogic.createTemporaryClient()}
flow={this.state.ssoFlow}
loginType={this.state.ssoFlow.type === "m.login.sso" ? "sso" : "cas"}
fragmentAfterLogin={this.props.fragmentAfterLogin}
action={SSOAction.REGISTER}
/>
<h2 className="mx_AuthBody_centered">
{_t("auth|sso_or_username_password", {
ssoButtons: "",
usernamePassword: "",
}).trim()}
</h2>
</React.Fragment>
);
}
return (
<React.Fragment>
{ssoSection}
<RegistrationForm
defaultUsername={this.state.formVals.username}
defaultEmail={this.state.formVals.email}
defaultPhoneCountry={this.state.formVals.phoneCountry}
defaultPhoneNumber={this.state.formVals.phoneNumber}
defaultPassword={this.state.formVals.password}
onRegisterClick={this.onFormSubmit}
flows={this.state.flows}
serverConfig={this.props.serverConfig}
canSubmit={!this.state.serverErrorIsFatal}
matrixClient={this.state.matrixClient}
mobileRegister={this.props.mobileRegister}
/>
</React.Fragment>
);
}
return null;
}
public render(): React.ReactNode {
let errorText;
const err = this.state.errorText;
if (err) {
errorText = <div className="mx_Login_error">{err}</div>;
}
let serverDeadSection;
if (!this.state.serverIsAlive) {
const classes = classNames({
mx_Login_error: true,
mx_Login_serverError: true,
mx_Login_serverErrorNonFatal: !this.state.serverErrorIsFatal,
});
serverDeadSection = <div className={classes}>{this.state.serverDeadError}</div>;
}
const signIn = (
<span className="mx_AuthBody_changeFlow">
{_t(
"auth|sign_in_instead_prompt",
{},
{
a: (sub) => (
<AccessibleButton kind="link_inline" onClick={this.onLoginClick}>
{sub}
</AccessibleButton>
),
},
)}
</span>
);
// Only show the 'go back' button if you're not looking at the form
let goBack;
if (this.state.doingUIAuth) {
goBack = (
<AccessibleButton kind="link" className="mx_AuthBody_changeFlow" onClick={this.onGoToFormClicked}>
{_t("action|go_back")}
</AccessibleButton>
);
}
let body;
if (this.state.completedNoSignin) {
let regDoneText;
if (this.props.mobileRegister) {
regDoneText = undefined;
} else if (this.state.differentLoggedInUserId) {
regDoneText = (
<div>
<p>
{_t("auth|account_clash", {
newAccountId: this.state.registeredUsername,
loggedInUserId: this.state.differentLoggedInUserId,
})}
</p>
<p>
<AccessibleButton
kind="link_inline"
onClick={async (event: ButtonEvent): Promise<void> => {
const sessionLoaded = await this.onLoginClickWithCheck(event);
if (sessionLoaded) {
dis.dispatch({ action: "view_welcome_page" });
}
}}
>
{_t("auth|account_clash_previous_account")}
</AccessibleButton>
</p>
</div>
);
} else {
// regardless of whether we're the client that started the registration or not, we should
// try our credentials anyway
regDoneText = (
<h2>
{_t(
"auth|log_in_new_account",
{},
{
a: (sub) => (
<AccessibleButton
kind="link_inline"
onClick={async (event: ButtonEvent): Promise<void> => {
const sessionLoaded = await this.onLoginClickWithCheck(event);
if (sessionLoaded) {
dis.dispatch({ action: "view_home_page" });
}
}}
>
{sub}
</AccessibleButton>
),
},
)}
</h2>
);
}
body = (
<div>
<h1>{_t("auth|registration_successful")}</h1>
{regDoneText}
</div>
);
} else if (this.props.mobileRegister) {
body = (
<Fragment>
<h1>{_t("auth|mobile_create_account_title", { hsName: this.props.serverConfig.hsName })}</h1>
{errorText}
{serverDeadSection}
{this.renderRegisterComponent()}
</Fragment>
);
} else {
body = (
<Fragment>
<div className="mx_Register_mainContent">
<AuthHeaderDisplay
title={_t("auth|create_account_title")}
serverPicker={
<ServerPicker
title={_t("auth|server_picker_title_registration")}
dialogTitle={_t("auth|server_picker_dialog_title")}
serverConfig={this.props.serverConfig}
onServerConfigChange={
this.state.doingUIAuth ? undefined : this.props.onServerConfigChange
}
/>
}
>
{errorText}
{serverDeadSection}
</AuthHeaderDisplay>
{this.renderRegisterComponent()}
</div>
<div className="mx_Register_footerActions">
{goBack}
{signIn}
</div>
</Fragment>
);
}
if (this.props.mobileRegister) {
return (
<div className="mx_MobileRegister_body" data-testid="mobile-register">
{body}
</div>
);
}
return (
<AuthPage>
<AuthHeader />
<AuthHeaderProvider>
<AuthBody flex>{body}</AuthBody>
</AuthHeaderProvider>
</AuthPage>
);
}
}
@@ -0,0 +1,27 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2023 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
import React, { type JSX } from "react";
import SplashPage from "../SplashPage";
import { _t } from "../../../languageHandler";
import SdkConfig from "../../../SdkConfig";
/**
* Component shown by {@link MatrixChat} when another session is started in the same browser.
*/
export function SessionLockStolenView(): JSX.Element {
const brand = SdkConfig.get().brand;
return (
<SplashPage className="mx_SessionLockStolenView">
<h1>{_t("error_app_open_in_another_tab_title", { brand })}</h1>
<h2>{_t("error_app_open_in_another_tab", { brand })}</h2>
</SplashPage>
);
}
@@ -0,0 +1,262 @@
/*
Copyright 2024, 2025 New Vector Ltd.
Copyright 2020, 2021 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
import React, { type JSX } from "react";
import { type KeyBackupInfo, type VerificationRequest } from "matrix-js-sdk/src/crypto-api";
import { logger } from "matrix-js-sdk/src/logger";
import DevicesIcon from "@vector-im/compound-design-tokens/assets/web/icons/devices";
import LockIcon from "@vector-im/compound-design-tokens/assets/web/icons/lock-solid";
import { Button } from "@vector-im/compound-web";
import { _t } from "../../../languageHandler";
import { MatrixClientPeg } from "../../../MatrixClientPeg";
import Modal from "../../../Modal";
import VerificationRequestDialog from "../../views/dialogs/VerificationRequestDialog";
import { SetupEncryptionStore, Phase } from "../../../stores/SetupEncryptionStore";
import EncryptionPanel from "../../views/right_panel/EncryptionPanel";
import AccessibleButton from "../../views/elements/AccessibleButton";
import Spinner from "../../views/elements/Spinner";
import { ResetIdentityDialog } from "../../views/dialogs/ResetIdentityDialog";
import { EncryptionCard } from "../../views/settings/encryption/EncryptionCard";
import { EncryptionCardButtons } from "../../views/settings/encryption/EncryptionCardButtons";
import { EncryptionCardEmphasisedContent } from "../../views/settings/encryption/EncryptionCardEmphasisedContent";
import ExternalLink from "../../views/elements/ExternalLink";
import dispatcher from "../../../dispatcher/dispatcher";
import E2EIcon from "../../views/rooms/E2EIcon.tsx";
import { E2EStatus } from "../../../utils/ShieldUtils.ts";
interface IProps {
onFinished: () => void;
/**
* Offer the user an option to log out, instead of setting up encryption.
*
* This is used when this component is shown when the user is initially
* prompted to set up encryption, before the user is shown the main chat
* interface.
*
* Defaults to `false` if omitted.
*/
allowLogout?: boolean;
}
interface IState {
phase?: Phase;
verificationRequest: VerificationRequest | null;
backupInfo: KeyBackupInfo | null;
}
/**
* Component to set up encryption by verifying the current device.
*/
export default class SetupEncryptionBody extends React.Component<IProps, IState> {
public constructor(props: IProps) {
super(props);
const store = SetupEncryptionStore.sharedInstance();
store.start();
this.state = {
phase: store.phase,
// this serves dual purpose as the object for the request logic and
// the presence of it indicating that we're in 'verify mode'.
// Because of the latter, it lives in the state.
verificationRequest: store.verificationRequest,
backupInfo: store.backupInfo,
};
}
public componentDidMount(): void {
const store = SetupEncryptionStore.sharedInstance();
store.on("update", this.onStoreUpdate);
}
private onStoreUpdate = (): void => {
const store = SetupEncryptionStore.sharedInstance();
if (store.phase === Phase.Finished) {
this.props.onFinished();
return;
}
this.setState({
phase: store.phase,
verificationRequest: store.verificationRequest,
backupInfo: store.backupInfo,
});
};
public componentWillUnmount(): void {
const store = SetupEncryptionStore.sharedInstance();
store.off("update", this.onStoreUpdate);
store.stop();
}
private onUsePassphraseClick = async (): Promise<void> => {
const store = SetupEncryptionStore.sharedInstance();
store.usePassPhrase();
};
private onVerifyClick = (): void => {
const cli = MatrixClientPeg.safeGet();
const userId = cli.getSafeUserId();
const requestPromise = cli.getCrypto()!.requestOwnUserVerification();
// We need to call onFinished now to close this dialog, and
// again later to signal that the verification is complete.
this.props.onFinished();
const { finished: verificationFinished } = Modal.createDialog(VerificationRequestDialog, {
verificationRequestPromise: requestPromise,
member: cli.getUser(userId) ?? undefined,
});
verificationFinished.then(async () => {
const request = await requestPromise;
request.cancel();
this.props.onFinished();
});
};
private onSkipConfirmClick = (): void => {
const store = SetupEncryptionStore.sharedInstance();
store.skipConfirm();
};
private onSkipBackClick = (): void => {
const store = SetupEncryptionStore.sharedInstance();
store.returnAfterSkip();
};
private onCantConfirmClick = (): void => {
const store = SetupEncryptionStore.sharedInstance();
Modal.createDialog(ResetIdentityDialog, {
onReset: () => {
// The user completed the reset process - close this dialog
this.props.onFinished();
const store = SetupEncryptionStore.sharedInstance();
store.done();
},
variant: store.lostKeys() ? "no_verification_method" : "confirm",
});
};
private onSignOutClick = (): void => {
dispatcher.dispatch({ action: "logout" });
};
private onDoneClick = (): void => {
const store = SetupEncryptionStore.sharedInstance();
store.done();
};
private onEncryptionPanelClose = (): void => {
this.props.onFinished();
};
public render(): React.ReactNode {
const cli = MatrixClientPeg.safeGet();
const { phase } = this.state;
if (this.state.verificationRequest && cli.getUser(this.state.verificationRequest.otherUserId)) {
return (
<EncryptionPanel
layout="dialog"
verificationRequest={this.state.verificationRequest}
onClose={this.onEncryptionPanelClose}
member={cli.getUser(this.state.verificationRequest.otherUserId)!}
isRoomEncrypted={false}
/>
);
} else if (phase === Phase.Intro) {
const store = SetupEncryptionStore.sharedInstance();
let verifyButton;
if (store.hasDevicesToVerifyAgainst) {
verifyButton = (
<Button kind="primary" onClick={this.onVerifyClick}>
<DevicesIcon /> {_t("encryption|verification|use_another_device")}
</Button>
);
}
let useRecoveryKeyButton;
if (store.keyInfo) {
useRecoveryKeyButton = (
<Button kind="primary" onClick={this.onUsePassphraseClick}>
{_t("encryption|verification|use_recovery_key")}
</Button>
);
}
let signOutButton;
if (this.props.allowLogout) {
signOutButton = (
<Button kind="tertiary" onClick={this.onSignOutClick}>
{_t("action|sign_out")}
</Button>
);
}
return (
<EncryptionCard
title={_t("encryption|verification|confirm_identity_title")}
Icon={LockIcon}
className="mx_EncryptionCard_noBorder mx_SetupEncryptionBody"
>
<EncryptionCardEmphasisedContent>
<span>{_t("encryption|verification|confirm_identity_description")}</span>
<span>
<ExternalLink href="https://element.io/help#encryption-device-verification">
{_t("action|learn_more")}
</ExternalLink>
</span>
</EncryptionCardEmphasisedContent>
<EncryptionCardButtons>
{verifyButton}
{useRecoveryKeyButton}
<Button kind="secondary" onClick={this.onCantConfirmClick}>
{_t("encryption|verification|cant_confirm")}
</Button>
{signOutButton}
</EncryptionCardButtons>
</EncryptionCard>
);
} else if (phase === Phase.Done) {
let message: JSX.Element;
if (this.state.backupInfo) {
message = <p>{_t("encryption|verification|verification_success_with_backup")}</p>;
} else {
message = <p>{_t("encryption|verification|verification_success_without_backup")}</p>;
}
return (
<div>
<E2EIcon className="mx_CompleteSecurity_heroIcon" status={E2EStatus.Verified} hideTooltip />
{message}
<div className="mx_CompleteSecurity_actionRow">
<AccessibleButton kind="primary" onClick={this.onDoneClick}>
{_t("action|done")}
</AccessibleButton>
</div>
</div>
);
} else if (phase === Phase.ConfirmSkip) {
return (
<div>
<p>{_t("encryption|verification|verification_skip_warning")}</p>
<div className="mx_CompleteSecurity_actionRow">
<AccessibleButton kind="danger_outline" onClick={this.onSkipConfirmClick}>
{_t("encryption|verification|verify_later")}
</AccessibleButton>
<AccessibleButton kind="primary" onClick={this.onSkipBackClick}>
{_t("action|go_back")}
</AccessibleButton>
</div>
</div>
);
} else if (phase === Phase.Busy || phase === Phase.Loading) {
return <Spinner />;
} else {
logger.log(`SetupEncryptionBody: Unknown phase ${phase}`);
}
}
}
@@ -0,0 +1,323 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2019-2022 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
import React, { type JSX, type ChangeEvent, type SyntheticEvent } from "react";
import { logger } from "matrix-js-sdk/src/logger";
import { type LoginFlow, MatrixError, SSOAction, type SSOFlow } from "matrix-js-sdk/src/matrix";
import { _t } from "../../../languageHandler";
import dis from "../../../dispatcher/dispatcher";
import * as Lifecycle from "../../../Lifecycle";
import Modal from "../../../Modal";
import { type IMatrixClientCreds, MatrixClientPeg } from "../../../MatrixClientPeg";
import { sendLoginRequest } from "../../../Login";
import AuthPage from "../../views/auth/AuthPage";
import { SSO_HOMESERVER_URL_KEY, SSO_ID_SERVER_URL_KEY } from "../../../BasePlatform";
import SSOButtons from "../../views/elements/SSOButtons";
import ConfirmWipeDeviceDialog from "../../views/dialogs/ConfirmWipeDeviceDialog";
import Field from "../../views/elements/Field";
import AccessibleButton from "../../views/elements/AccessibleButton";
import Spinner from "../../views/elements/Spinner";
import AuthHeader from "../../views/auth/AuthHeader";
import AuthBody from "../../views/auth/AuthBody";
import { SDKContext } from "../../../contexts/SDKContext";
enum LoginView {
Loading,
Password,
CAS, // SSO, but old
SSO,
PasswordWithSocialSignOn,
Unsupported,
}
const STATIC_FLOWS_TO_VIEWS: Record<string, LoginView> = {
"m.login.password": LoginView.Password,
"m.login.cas": LoginView.CAS,
"m.login.sso": LoginView.SSO,
};
interface IProps {
// Query parameters from MatrixChat
realQueryParams: {
loginToken?: string;
};
fragmentAfterLogin?: string;
// Called when the SSO login completes
onTokenLoginCompleted: () => void;
}
interface IState {
loginView: LoginView;
busy: boolean;
password: string;
errorText: string;
flows: LoginFlow[];
}
export default class SoftLogout extends React.Component<IProps, IState> {
public static contextType = SDKContext;
declare public context: React.ContextType<typeof SDKContext>;
public constructor(props: IProps) {
super(props);
this.state = {
loginView: LoginView.Loading,
busy: false,
password: "",
errorText: "",
flows: [],
};
}
public componentDidMount(): void {
// We've ended up here when we don't need to - navigate to login
if (!Lifecycle.isSoftLogout()) {
dis.dispatch({ action: "start_login" });
return;
}
this.initLogin();
}
private onClearAll = (): void => {
const { finished } = Modal.createDialog(ConfirmWipeDeviceDialog);
finished.then(([wipeData]) => {
if (!wipeData) return;
logger.log("Clearing data from soft-logged-out session");
Lifecycle.logout(this.context.oidcClientStore);
});
};
private async initLogin(): Promise<void> {
const queryParams = this.props.realQueryParams;
const hasAllParams = queryParams?.["loginToken"];
if (hasAllParams) {
this.setState({ loginView: LoginView.Loading });
const loggedIn = await this.trySsoLogin();
if (loggedIn) return;
}
// Note: we don't use the existing Login class because it is heavily flow-based. We don't
// care about login flows here, unless it is the single flow we support.
const client = MatrixClientPeg.safeGet();
const flows = (await client.loginFlows()).flows;
const loginViews = flows.map((f) => STATIC_FLOWS_TO_VIEWS[f.type]);
const isSocialSignOn = loginViews.includes(LoginView.Password) && loginViews.includes(LoginView.SSO);
const firstView = loginViews.filter((f) => !!f)[0] || LoginView.Unsupported;
const chosenView = isSocialSignOn ? LoginView.PasswordWithSocialSignOn : firstView;
this.setState({ flows, loginView: chosenView });
}
private onPasswordChange = (ev: ChangeEvent<HTMLInputElement>): void => {
this.setState({ password: ev.target.value });
};
private onForgotPassword = (): void => {
dis.dispatch({ action: "start_password_recovery" });
};
private onPasswordLogin = async (ev: SyntheticEvent): Promise<void> => {
ev.preventDefault();
ev.stopPropagation();
this.setState({ busy: true });
const cli = MatrixClientPeg.safeGet();
const hsUrl = cli.getHomeserverUrl();
const isUrl = cli.getIdentityServerUrl();
const loginType = "m.login.password";
const loginParams = {
identifier: {
type: "m.id.user",
user: cli.getUserId(),
},
password: this.state.password,
device_id: cli.getDeviceId() ?? undefined,
};
let credentials: IMatrixClientCreds;
try {
credentials = await sendLoginRequest(hsUrl, isUrl, loginType, loginParams);
} catch (e) {
let errorText = _t("auth|failed_soft_logout_homeserver");
if (
e instanceof MatrixError &&
e.errcode === "M_FORBIDDEN" &&
(e.httpStatus === 401 || e.httpStatus === 403)
) {
errorText = _t("auth|incorrect_password");
}
this.setState({
busy: false,
errorText: errorText,
});
return;
}
Lifecycle.hydrateSession(credentials).catch((e) => {
logger.error(e);
this.setState({ busy: false, errorText: _t("auth|failed_soft_logout_auth") });
});
};
/**
* Attempt to login via SSO
* @returns A promise that resolves to a boolean - true when sso login was successful
*/
private async trySsoLogin(): Promise<boolean> {
this.setState({ busy: true });
const hsUrl = localStorage.getItem(SSO_HOMESERVER_URL_KEY);
if (!hsUrl) {
logger.error("Homeserver URL unknown for SSO login callback");
this.setState({ busy: false, loginView: LoginView.Unsupported });
return false;
}
const isUrl = localStorage.getItem(SSO_ID_SERVER_URL_KEY) || MatrixClientPeg.safeGet().getIdentityServerUrl();
const loginType = "m.login.token";
const loginParams = {
token: this.props.realQueryParams["loginToken"],
device_id: MatrixClientPeg.safeGet().getDeviceId() ?? undefined,
};
let credentials: IMatrixClientCreds;
try {
credentials = await sendLoginRequest(hsUrl, isUrl, loginType, loginParams);
} catch (e) {
logger.error(e);
this.setState({ busy: false, loginView: LoginView.Unsupported });
return false;
}
return Lifecycle.hydrateSession(credentials)
.then(() => {
if (this.props.onTokenLoginCompleted) {
this.props.onTokenLoginCompleted();
}
return true;
})
.catch((e) => {
logger.error(e);
this.setState({ busy: false, loginView: LoginView.Unsupported });
return false;
});
}
private renderPasswordForm(introText?: string): JSX.Element {
let error: JSX.Element | undefined;
if (this.state.errorText) {
error = <span className="mx_Login_error">{this.state.errorText}</span>;
}
return (
<form onSubmit={this.onPasswordLogin}>
{introText ? <p>{introText}</p> : null}
{error}
<Field
type="password"
label={_t("common|password")}
onChange={this.onPasswordChange}
value={this.state.password}
disabled={this.state.busy}
/>
<AccessibleButton onClick={this.onPasswordLogin} kind="primary" disabled={this.state.busy}>
{_t("action|sign_in")}
</AccessibleButton>
<AccessibleButton onClick={this.onForgotPassword} kind="link">
{_t("auth|forgot_password_prompt")}
</AccessibleButton>
</form>
);
}
private renderSsoForm(introText?: string): JSX.Element {
const loginType = this.state.loginView === LoginView.CAS ? "cas" : "sso";
const flow = this.state.flows.find((flow) => flow.type === "m.login." + loginType) as SSOFlow;
return (
<div>
{introText ? <p>{introText}</p> : null}
<SSOButtons
matrixClient={MatrixClientPeg.safeGet()}
flow={flow}
loginType={loginType}
fragmentAfterLogin={this.props.fragmentAfterLogin}
primary={!this.state.flows.find((flow) => flow.type === "m.login.password")}
action={SSOAction.LOGIN}
/>
</div>
);
}
private renderSignInSection(): JSX.Element {
if (this.state.loginView === LoginView.Loading) {
return <Spinner />;
}
if (this.state.loginView === LoginView.Password) {
return this.renderPasswordForm(_t("auth|soft_logout_intro_password"));
}
if (this.state.loginView === LoginView.SSO || this.state.loginView === LoginView.CAS) {
return this.renderSsoForm(_t("auth|soft_logout_intro_sso"));
}
if (this.state.loginView === LoginView.PasswordWithSocialSignOn) {
// We render both forms with no intro/error to ensure the layout looks reasonably
// okay enough.
//
// Note: "mx_AuthBody_centered" text taken from registration page.
return (
<>
<p>{_t("auth|soft_logout_intro_sso")}</p>
{this.renderSsoForm()}
<h2 className="mx_AuthBody_centered">
{_t("auth|sso_or_username_password", {
ssoButtons: "",
usernamePassword: "",
}).trim()}
</h2>
{this.renderPasswordForm()}
</>
);
}
// Default: assume unsupported/error
return <p>{_t("auth|soft_logout_intro_unsupported_auth")}</p>;
}
public render(): React.ReactNode {
return (
<AuthPage>
<AuthHeader />
<AuthBody>
<h1>{_t("auth|soft_logout_heading")}</h1>
<h2>{_t("action|sign_in")}</h2>
<div>{this.renderSignInSection()}</div>
<h2>{_t("auth|soft_logout_subheading")}</h2>
<p>{_t("auth|soft_logout_warning")}</p>
<div>
<AccessibleButton onClick={this.onClearAll} kind="danger">
{_t("auth|soft_logout|clear_data_button")}
</AccessibleButton>
</div>
</AuthBody>
</AuthPage>
);
}
}
@@ -0,0 +1,72 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2022 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
import React, { type ReactNode } from "react";
import { Button, Tooltip } from "@vector-im/compound-web";
import { RestartIcon } from "@vector-im/compound-design-tokens/assets/web/icons";
import AccessibleButton from "../../../views/elements/AccessibleButton";
import { Icon as EMailPromptIcon } from "../../../../../res/img/element-icons/email-prompt.svg";
import { _t } from "../../../../languageHandler";
import { useTimeoutToggle } from "../../../../hooks/useTimeoutToggle";
import { ErrorMessage } from "../../ErrorMessage";
interface CheckEmailProps {
email: string;
errorText: string | ReactNode | null;
onReEnterEmailClick: () => void;
onResendClick: () => Promise<boolean>;
onSubmitForm: (ev: React.FormEvent) => void;
}
/**
* This component renders the email verification view of the forgot password flow.
*/
export const CheckEmail: React.FC<CheckEmailProps> = ({
email,
errorText,
onReEnterEmailClick,
onSubmitForm,
onResendClick,
}) => {
const { toggle: toggleTooltipVisible, value: tooltipVisible } = useTimeoutToggle(false, 2500);
const onResendClickFn = async (): Promise<void> => {
await onResendClick();
toggleTooltipVisible();
};
return (
<>
<EMailPromptIcon className="mx_AuthBody_emailPromptIcon--shifted" />
<h1>{_t("auth|uia|email_auth_header")}</h1>
<div className="mx_AuthBody_text">
<p>{_t("auth|check_email_explainer", { email: email }, { b: (t) => <strong>{t}</strong> })}</p>
<div className="mx_AuthBody_did-not-receive">
<span className="mx_VerifyEMailDialog_text-light">{_t("auth|check_email_wrong_email_prompt")}</span>
<AccessibleButton className="mx_AuthBody_resend-button" kind="link" onClick={onReEnterEmailClick}>
{_t("auth|check_email_wrong_email_button")}
</AccessibleButton>
</div>
</div>
{errorText && <ErrorMessage message={errorText} />}
<Button onClick={onSubmitForm} type="button" className="mx_Login_submit" size="sm">
{_t("action|next")}
</Button>
<div className="mx_AuthBody_did-not-receive">
<span className="mx_VerifyEMailDialog_text-light">{_t("auth|check_email_resend_prompt")}</span>
<Tooltip description={_t("auth|check_email_resend_tooltip")} placement="top" open={tooltipVisible}>
<AccessibleButton className="mx_AuthBody_resend-button" kind="link" onClick={onResendClickFn}>
<RestartIcon className="mx_Icon mx_Icon_16" />
{_t("action|resend")}
</AccessibleButton>
</Tooltip>
</div>
</>
);
};
@@ -0,0 +1,98 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2022 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
import React, { type ReactNode, useRef } from "react";
import { EmailSolidIcon } from "@vector-im/compound-design-tokens/assets/web/icons";
import { Button } from "@vector-im/compound-web";
import { _t, _td } from "../../../../languageHandler";
import EmailField from "../../../views/auth/EmailField";
import { ErrorMessage } from "../../ErrorMessage";
import Spinner from "../../../views/elements/Spinner";
import type Field from "../../../views/elements/Field";
import AccessibleButton, { type ButtonEvent } from "../../../views/elements/AccessibleButton";
interface EnterEmailProps {
email: string;
errorText: string | ReactNode | null;
homeserver: string;
loading: boolean;
onInputChanged: (stateKey: "email", ev: React.FormEvent<HTMLInputElement>) => void;
onLoginClick: () => void;
onSubmitForm: (ev: React.FormEvent) => void;
}
/**
* This component renders the email input view of the forgot password flow.
*/
export const EnterEmail: React.FC<EnterEmailProps> = ({
email,
errorText,
homeserver,
loading,
onInputChanged,
onLoginClick,
onSubmitForm,
}) => {
const submitButtonChild = loading ? <Spinner size={16} /> : _t("auth|forgot_password_send_email");
const emailFieldRef = useRef<Field>(null);
const onSubmit = async (event: React.FormEvent): Promise<void> => {
if (await emailFieldRef.current?.validate({ allowEmpty: false })) {
onSubmitForm(event);
return;
}
emailFieldRef.current?.focus();
emailFieldRef.current?.validate({ allowEmpty: false, focused: true });
};
return (
<>
<EmailSolidIcon className="mx_AuthBody_icon" />
<h1>{_t("auth|enter_email_heading")}</h1>
<p className="mx_AuthBody_text">
{_t("auth|enter_email_explainer", { homeserver }, { b: (t) => <strong>{t}</strong> })}
</p>
<form onSubmit={onSubmit}>
<fieldset disabled={loading}>
<div className="mx_AuthBody_fieldRow">
<EmailField
name="reset_email" // define a name so browser's password autofill gets less confused
label={_td("common|email_address")}
labelRequired={_td("auth|forgot_password_email_required")}
labelInvalid={_td("auth|forgot_password_email_invalid")}
value={email}
autoFocus={true}
onChange={(event: React.FormEvent<HTMLInputElement>) => onInputChanged("email", event)}
fieldRef={emailFieldRef}
/>
</div>
{errorText && <ErrorMessage message={errorText} />}
<Button type="submit" className="mx_Login_submit" size="sm">
{submitButtonChild}
</Button>
<div className="mx_AuthBody_button-container">
<AccessibleButton
className="mx_AuthBody_sign-in-instead-button"
element="button"
kind="link"
onClick={(e: ButtonEvent) => {
e.preventDefault();
onLoginClick();
}}
>
{_t("auth|sign_in_instead")}
</AccessibleButton>
</div>
</fieldset>
</form>
</>
);
};
@@ -0,0 +1,85 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2022 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
import React, { type ReactNode } from "react";
import { Tooltip } from "@vector-im/compound-web";
import { CloseIcon, RestartIcon } from "@vector-im/compound-design-tokens/assets/web/icons";
import { _t } from "../../../../languageHandler";
import AccessibleButton from "../../../views/elements/AccessibleButton";
import { Icon as EmailPromptIcon } from "../../../../../res/img/element-icons/email-prompt.svg";
import { useTimeoutToggle } from "../../../../hooks/useTimeoutToggle";
import { ErrorMessage } from "../../ErrorMessage";
interface Props {
email: string;
errorText: ReactNode | null;
onFinished(): void; // This modal is weird in that the way you close it signals intent
onCloseClick: () => void;
onReEnterEmailClick: () => void;
onResendClick: () => Promise<boolean>;
}
export const VerifyEmailModal: React.FC<Props> = ({
email,
errorText,
onCloseClick,
onReEnterEmailClick,
onResendClick,
}) => {
const { toggle: toggleTooltipVisible, value: tooltipVisible } = useTimeoutToggle(false, 2500);
const onResendClickFn = async (): Promise<void> => {
await onResendClick();
toggleTooltipVisible();
};
return (
<>
<EmailPromptIcon className="mx_AuthBody_emailPromptIcon" />
<h1>{_t("auth|verify_email_heading")}</h1>
<p>
{_t(
"auth|verify_email_explainer",
{
email,
},
{
b: (sub) => <strong>{sub}</strong>,
},
)}
</p>
<div className="mx_AuthBody_did-not-receive">
<span className="mx_VerifyEMailDialog_text-light">{_t("auth|check_email_resend_prompt")}</span>
<Tooltip description={_t("auth|check_email_resend_tooltip")} placement="top" open={tooltipVisible}>
<AccessibleButton className="mx_AuthBody_resend-button" kind="link" onClick={onResendClickFn}>
<RestartIcon className="mx_Icon mx_Icon_16" />
{_t("action|resend")}
</AccessibleButton>
</Tooltip>
{errorText && <ErrorMessage message={errorText} />}
</div>
<div className="mx_AuthBody_did-not-receive">
<span className="mx_VerifyEMailDialog_text-light">{_t("auth|check_email_wrong_email_prompt")}</span>
<AccessibleButton className="mx_AuthBody_resend-button" kind="link" onClick={onReEnterEmailClick}>
{_t("auth|check_email_wrong_email_button")}
</AccessibleButton>
</div>
<AccessibleButton
onClick={onCloseClick}
className="mx_Dialog_cancelButton"
aria-label={_t("dialog_close_label")}
>
<CloseIcon />
</AccessibleButton>
</>
);
};
@@ -0,0 +1,20 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2022 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
import { createContext, type Dispatch, type Reducer, type ReducerState } from "react";
import type { AuthHeaderReducer } from "./AuthHeaderProvider";
type ReducerAction<R extends Reducer<any, any>> = R extends Reducer<any, infer A> ? A : never;
interface AuthHeaderContextType {
state: ReducerState<AuthHeaderReducer>;
dispatch: Dispatch<ReducerAction<AuthHeaderReducer>>;
}
export const AuthHeaderContext = createContext<AuthHeaderContextType | undefined>(undefined);
@@ -0,0 +1,33 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2022 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
import React, { type JSX, Fragment, type PropsWithChildren, type ReactNode, useContext } from "react";
import { AuthHeaderContext } from "./AuthHeaderContext";
interface Props {
title: ReactNode;
icon?: ReactNode;
serverPicker: ReactNode;
}
export function AuthHeaderDisplay({ title, icon, serverPicker, children }: PropsWithChildren<Props>): JSX.Element {
const context = useContext(AuthHeaderContext);
if (!context) {
return <></>;
}
const current = context.state[0] ?? null;
return (
<Fragment>
{current?.icon ?? icon}
<h1>{current?.title ?? title}</h1>
{children}
{current?.hideServerPicker !== true && serverPicker}
</Fragment>
);
}
@@ -0,0 +1,31 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2022 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
import { type ReactNode, useContext, useEffect } from "react";
import { AuthHeaderContext } from "./AuthHeaderContext";
import { AuthHeaderActionType } from "./AuthHeaderProvider";
interface Props {
title: ReactNode;
icon?: ReactNode;
hideServerPicker?: boolean;
}
export function AuthHeaderModifier(props: Props): null {
const context = useContext(AuthHeaderContext);
const dispatch = context?.dispatch ?? null;
useEffect(() => {
if (!dispatch) {
return;
}
dispatch({ type: AuthHeaderActionType.Add, value: props });
return () => dispatch({ type: AuthHeaderActionType.Remove, value: props });
}, [props, dispatch]);
return null;
}
@@ -0,0 +1,40 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2022 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
import { isEqual } from "lodash";
import React, { type JSX, type ComponentProps, type PropsWithChildren, type Reducer, useReducer } from "react";
import { AuthHeaderContext } from "./AuthHeaderContext";
import { type AuthHeaderModifier } from "./AuthHeaderModifier";
export enum AuthHeaderActionType {
Add,
Remove,
}
interface AuthHeaderAction {
type: AuthHeaderActionType;
value: ComponentProps<typeof AuthHeaderModifier>;
}
export type AuthHeaderReducer = Reducer<ComponentProps<typeof AuthHeaderModifier>[], AuthHeaderAction>;
export function AuthHeaderProvider({ children }: PropsWithChildren): JSX.Element {
const [state, dispatch] = useReducer<ComponentProps<typeof AuthHeaderModifier>[], [AuthHeaderAction]>(
(state: ComponentProps<typeof AuthHeaderModifier>[], action: AuthHeaderAction) => {
switch (action.type) {
case AuthHeaderActionType.Add:
return [action.value, ...state];
case AuthHeaderActionType.Remove:
return state.length && isEqual(state[0], action.value) ? state.slice(1) : state;
}
},
[] as ComponentProps<typeof AuthHeaderModifier>[],
);
return <AuthHeaderContext.Provider value={{ state, dispatch }}>{children}</AuthHeaderContext.Provider>;
}