/* 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 Please see LICENSE files in the repository root for full details. */ import React, { ChangeEvent, SyntheticEvent } from "react"; import { logger } from "matrix-js-sdk/src/logger"; import { Optional } from "matrix-events-sdk"; import { LoginFlow, MatrixError, SSOAction, 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 { 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 = { "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 { public static contextType = SDKContext; public declare context: React.ContextType; public constructor(props: IProps, context: React.ContextType) { super(props, context); 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 => { Modal.createDialog(ConfirmWipeDeviceDialog, { onFinished: (wipeData) => { if (!wipeData) return; logger.log("Clearing data from soft-logged-out session"); Lifecycle.logout(this.context.oidcClientStore); }, }); }; private async initLogin(): Promise { 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): void => { this.setState({ password: ev.target.value }); }; private onForgotPassword = (): void => { dis.dispatch({ action: "start_password_recovery" }); }; private onPasswordLogin = async (ev: SyntheticEvent): Promise => { 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.setLoggedIn(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 { 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.setLoggedIn(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: Optional): JSX.Element { let error: JSX.Element | undefined; if (this.state.errorText) { error = {this.state.errorText}; } return (
{introText ?

{introText}

: null} {error} {_t("action|sign_in")} {_t("auth|forgot_password_prompt")} ); } private renderSsoForm(introText: Optional): 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 (
{introText ?

{introText}

: null} flow.type === "m.login.password")} action={SSOAction.LOGIN} />
); } private renderSignInSection(): JSX.Element { if (this.state.loginView === LoginView.Loading) { return ; } 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 ( <>

{_t("auth|soft_logout_intro_sso")}

{this.renderSsoForm(null)}

{_t("auth|sso_or_username_password", { ssoButtons: "", usernamePassword: "", }).trim()}

{this.renderPasswordForm(null)} ); } // Default: assume unsupported/error return

{_t("auth|soft_logout_intro_unsupported_auth")}

; } public render(): React.ReactNode { return (

{_t("auth|soft_logout_heading")}

{_t("action|sign_in")}

{this.renderSignInSection()}

{_t("auth|soft_logout_subheading")}

{_t("auth|soft_logout_warning")}

{_t("auth|soft_logout|clear_data_button")}
); } }