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:
@@ -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 });
|
||||
// don’t 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>;
|
||||
}
|
||||
Reference in New Issue
Block a user