Prepare for OIDC QR Login PR (#12463)

* Move LoginWithQRSection to the top of the settings tab

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>

* Refactor LoginWithQRSection to a Functional Component

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>

* Extract LoginWithQR types

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>

* Update LoginWithQRFlow styling & copy

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>

* Re-add missing buttons and update snapshots

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>

* Use compound spacings

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>

---------

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>
This commit is contained in:
Michael Telatynski 2024-04-30 18:18:55 +01:00 committed by GitHub
parent 1c79bbb1ae
commit 641a20ce63
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 598 additions and 389 deletions

View file

@ -0,0 +1,43 @@
/*
Copyright 2024 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
/**
* The intention of this enum is to have a mode that scans a QR code instead of generating one.
*/
export enum Mode {
/**
* A QR code with be generated and shown
*/
Show = "show",
}
export enum Phase {
Loading,
ShowingQR,
Connecting,
Connected,
WaitingForDevice,
Verifying,
Error,
}
export enum Click {
Cancel,
Decline,
Approve,
TryAgain,
Back,
}

View file

@ -24,34 +24,7 @@ import { HTTPError, MatrixClient } from "matrix-js-sdk/src/matrix";
import { _t } from "../../../languageHandler";
import { wrapRequestWithDialog } from "../../../utils/UserInteractiveAuth";
import LoginWithQRFlow from "./LoginWithQRFlow";
/**
* The intention of this enum is to have a mode that scans a QR code instead of generating one.
*/
export enum Mode {
/**
* A QR code with be generated and shown
*/
Show = "show",
}
export enum Phase {
Loading,
ShowingQR,
Connecting,
Connected,
WaitingForDevice,
Verifying,
Error,
}
export enum Click {
Cancel,
Decline,
Approve,
TryAgain,
Back,
}
import { Click, Mode, Phase } from "./LoginWithQR-types";
interface IProps {
client: MatrixClient;

View file

@ -1,5 +1,5 @@
/*
Copyright 2022 The Matrix.org Foundation C.I.C.
Copyright 2022 - 2024 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
@ -14,19 +14,24 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import React from "react";
import { RendezvousFailureReason } from "matrix-js-sdk/src/rendezvous";
import React, { ReactNode } from "react";
import { RendezvousFailureReason as LegacyRendezvousFailureReason } from "matrix-js-sdk/src/rendezvous";
import { Icon as ChevronLeftIcon } from "@vector-im/compound-design-tokens/icons/chevron-left.svg";
import { Icon as CheckCircleSolidIcon } from "@vector-im/compound-design-tokens/icons/check-circle-solid.svg";
import { Icon as ErrorIcon } from "@vector-im/compound-design-tokens/icons/error.svg";
import { Heading, Text } from "@vector-im/compound-web";
import classNames from "classnames";
import { _t } from "../../../languageHandler";
import AccessibleButton from "../elements/AccessibleButton";
import QRCode from "../elements/QRCode";
import Spinner from "../elements/Spinner";
import { Icon as InfoIcon } from "../../../../res/img/element-icons/i.svg";
import { Click, FailureReason, LoginWithQRFailureReason, Phase } from "./LoginWithQR";
import { Click, Phase } from "./LoginWithQR-types";
import SdkConfig from "../../../SdkConfig";
import { FailureReason, LoginWithQRFailureReason } from "./LoginWithQR";
interface IProps {
interface Props {
phase: Phase;
code?: string;
onClick(type: Click): Promise<void>;
@ -39,8 +44,8 @@ interface IProps {
*
* This uses the unstable feature of MSC3906: https://github.com/matrix-org/matrix-spec-proposals/pull/3906
*/
export default class LoginWithQRFlow extends React.Component<IProps> {
public constructor(props: IProps) {
export default class LoginWithQRFlow extends React.Component<Props> {
public constructor(props: Props) {
super(props);
}
@ -72,49 +77,75 @@ export default class LoginWithQRFlow extends React.Component<IProps> {
let main: JSX.Element | undefined;
let buttons: JSX.Element | undefined;
let backButton = true;
let cancellationMessage: string | undefined;
let centreTitle = false;
let className = "";
switch (this.props.phase) {
case Phase.Error:
case Phase.Error: {
let success = false;
let title: string | undefined;
let message: ReactNode | undefined;
switch (this.props.failureReason) {
case RendezvousFailureReason.Expired:
cancellationMessage = _t("auth|qr_code_login|error_linking_incomplete");
case LegacyRendezvousFailureReason.UnsupportedAlgorithm:
case LegacyRendezvousFailureReason.UnsupportedTransport:
case LegacyRendezvousFailureReason.HomeserverLacksSupport:
title = _t("auth|qr_code_login|error_unsupported_protocol_title");
message = _t("auth|qr_code_login|error_unsupported_protocol");
break;
case RendezvousFailureReason.InvalidCode:
cancellationMessage = _t("auth|qr_code_login|error_invalid_scanned_code");
case LegacyRendezvousFailureReason.UserCancelled:
title = _t("auth|qr_code_login|error_user_cancelled_title");
message = _t("auth|qr_code_login|error_user_cancelled");
break;
case RendezvousFailureReason.UnsupportedAlgorithm:
cancellationMessage = _t("auth|qr_code_login|error_device_unsupported");
case LegacyRendezvousFailureReason.Expired:
title = _t("auth|qr_code_login|error_expired_title");
message = _t("auth|qr_code_login|error_expired");
break;
case RendezvousFailureReason.UserDeclined:
cancellationMessage = _t("auth|qr_code_login|error_request_declined");
case LegacyRendezvousFailureReason.InvalidCode:
title = _t("auth|qr_code_login|error_insecure_channel_detected_title");
message = (
<>
{_t("auth|qr_code_login|error_insecure_channel_detected")}
<Text as="h2" size="lg" weight="semibold" data-testid="cancellation-message">
{_t("auth|qr_code_login|error_insecure_channel_detected_instructions")}
</Text>
<ol>
<li>{_t("auth|qr_code_login|error_insecure_channel_detected_instructions_1")}</li>
<li>{_t("auth|qr_code_login|error_insecure_channel_detected_instructions_2")}</li>
<li>{_t("auth|qr_code_login|error_insecure_channel_detected_instructions_3")}</li>
</ol>
</>
);
break;
case RendezvousFailureReason.OtherDeviceAlreadySignedIn:
cancellationMessage = _t("auth|qr_code_login|error_device_already_signed_in");
case LegacyRendezvousFailureReason.OtherDeviceAlreadySignedIn:
success = true;
title = _t("auth|qr_code_login|error_other_device_already_signed_in_title");
message = _t("auth|qr_code_login|error_other_device_already_signed_in");
break;
case RendezvousFailureReason.OtherDeviceNotSignedIn:
cancellationMessage = _t("auth|qr_code_login|error_device_not_signed_in");
break;
case RendezvousFailureReason.UserCancelled:
cancellationMessage = _t("auth|qr_code_login|error_request_cancelled");
case LegacyRendezvousFailureReason.UserDeclined:
title = _t("auth|qr_code_login|error_user_declined_title");
message = _t("auth|qr_code_login|error_user_declined");
break;
case LoginWithQRFailureReason.RateLimited:
cancellationMessage = _t("auth|qr_code_login|error_rate_limited");
break;
case RendezvousFailureReason.Unknown:
cancellationMessage = _t("auth|qr_code_login|error_unexpected");
break;
case RendezvousFailureReason.HomeserverLacksSupport:
cancellationMessage = _t("auth|qr_code_login|error_homeserver_lacks_support");
title = _t("error|something_went_wrong");
message = _t("auth|qr_code_login|error_rate_limited");
break;
case LegacyRendezvousFailureReason.OtherDeviceNotSignedIn:
case LegacyRendezvousFailureReason.Unknown:
default:
cancellationMessage = _t("auth|qr_code_login|error_request_cancelled");
title = _t("error|something_went_wrong");
message = _t("auth|qr_code_login|error_unexpected");
break;
}
centreTitle = true;
className = "mx_LoginWithQR_error";
backButton = false;
main = <p data-testid="cancellation-message">{cancellationMessage}</p>;
buttons = (
<>
<AccessibleButton
@ -127,7 +158,23 @@ export default class LoginWithQRFlow extends React.Component<IProps> {
{this.cancelButton()}
</>
);
main = (
<>
<div
className={classNames("mx_LoginWithQR_icon", {
"mx_LoginWithQR_icon--critical": !success,
})}
>
{success ? <CheckCircleSolidIcon width="32px" /> : <ErrorIcon width="32px" />}
</div>
<Heading as="h1" size="sm" weight="semibold">
{title}
</Heading>
{typeof message === "object" ? message : <p data-testid="cancellation-message">{message}</p>}
</>
);
break;
}
case Phase.Connected:
backButton = false;
main = (
@ -145,13 +192,6 @@ export default class LoginWithQRFlow extends React.Component<IProps> {
buttons = (
<>
<AccessibleButton
data-testid="decline-login-button"
kind="primary_outline"
onClick={this.handleClick(Click.Decline)}
>
{_t("action|cancel")}
</AccessibleButton>
<AccessibleButton
data-testid="approve-login-button"
kind="primary"
@ -159,23 +199,28 @@ export default class LoginWithQRFlow extends React.Component<IProps> {
>
{_t("action|approve")}
</AccessibleButton>
<AccessibleButton
data-testid="decline-login-button"
kind="primary_outline"
onClick={this.handleClick(Click.Decline)}
>
{_t("action|cancel")}
</AccessibleButton>
</>
);
break;
case Phase.ShowingQR:
if (this.props.code) {
const code = (
<div className="mx_LoginWithQR_qrWrapper">
<QRCode
data={[{ data: Buffer.from(this.props.code ?? ""), mode: "byte" }]}
className="mx_QRCode"
/>
</div>
);
const data = Buffer.from(this.props.code ?? "");
main = (
<>
<h1>{_t("auth|qr_code_login|scan_code_instruction")}</h1>
{code}
<Heading as="h1" size="sm" weight="semibold">
{_t("auth|qr_code_login|scan_code_instruction")}
</Heading>
<div className="mx_LoginWithQR_qrWrapper">
<QRCode data={[{ data, mode: "byte" }]} className="mx_QRCode" />
</div>
<ol>
<li>
{_t("auth|qr_code_login|open_element_other_device", {
@ -209,30 +254,27 @@ export default class LoginWithQRFlow extends React.Component<IProps> {
buttons = this.cancelButton();
break;
case Phase.Verifying:
centreTitle = true;
main = this.simpleSpinner(_t("auth|qr_code_login|completing_setup"));
break;
}
return (
<div data-testid="login-with-qr" className="mx_LoginWithQR">
<div className={centreTitle ? "mx_LoginWithQR_centreTitle" : ""}>
{backButton ? (
<div className="mx_LoginWithQR_heading">
<AccessibleButton
data-testid="back-button"
className="mx_LoginWithQR_BackButton"
onClick={this.handleClick(Click.Back)}
title="Back"
>
<ChevronLeftIcon />
</AccessibleButton>
<div className="mx_LoginWithQR_breadcrumbs">
{_t("settings|sessions|title")} / {_t("settings|sessions|sign_in_with_qr")}
</div>
<div data-testid="login-with-qr" className={classNames("mx_LoginWithQR", className)}>
{backButton ? (
<div className="mx_LoginWithQR_heading">
<AccessibleButton
data-testid="back-button"
className="mx_LoginWithQR_BackButton"
onClick={this.handleClick(Click.Back)}
title="Back"
>
<ChevronLeftIcon />
</AccessibleButton>
<div className="mx_LoginWithQR_breadcrumbs">
{_t("settings|sessions|title")} / {_t("settings|sessions|sign_in_with_qr")}
</div>
) : null}
</div>
</div>
) : null}
<div className="mx_LoginWithQR_main">{main}</div>
<div className="mx_LoginWithQR_buttons">{buttons}</div>
</div>

View file

@ -35,39 +35,40 @@ interface IProps {
wellKnown?: IClientWellKnown;
}
export default class LoginWithQRSection extends React.Component<IProps> {
public constructor(props: IProps) {
super(props);
}
public render(): JSX.Element | null {
// Needs server support for get_login_token and MSC3886:
// in r0 of MSC3882 it is exposed as a feature flag, but in stable and unstable r1 it is a capability
const capability = GET_LOGIN_TOKEN_CAPABILITY.findIn<IGetLoginTokenCapability>(this.props.capabilities);
const getLoginTokenSupported =
!!this.props.versions?.unstable_features?.["org.matrix.msc3882"] || !!capability?.enabled;
const msc3886Supported =
!!this.props.versions?.unstable_features?.["org.matrix.msc3886"] ||
this.props.wellKnown?.["io.element.rendezvous"]?.server;
const offerShowQr = getLoginTokenSupported && msc3886Supported;
// don't show anything if no method is available
if (!offerShowQr) {
return null;
}
return (
<SettingsSubsection heading={_t("settings|sessions|sign_in_with_qr")}>
<div className="mx_LoginWithQRSection">
<p className="mx_SettingsTab_subsectionText">
{_t("settings|sessions|sign_in_with_qr_description")}
</p>
<AccessibleButton onClick={this.props.onShowQr} kind="primary">
<QrCodeIcon height={20} width={20} />
{_t("settings|sessions|sign_in_with_qr_button")}
</AccessibleButton>
</div>
</SettingsSubsection>
);
}
function shouldShowQrLegacy(
versions?: IServerVersions,
wellKnown?: IClientWellKnown,
capabilities?: Capabilities,
): boolean {
// Needs server support for (get_login_token or OIDC Device Authorization Grant) and MSC3886:
// in r0 of MSC3882 it is exposed as a feature flag, but in stable and unstable r1 it is a capability
const loginTokenCapability = GET_LOGIN_TOKEN_CAPABILITY.findIn<IGetLoginTokenCapability>(capabilities);
const getLoginTokenSupported =
!!versions?.unstable_features?.["org.matrix.msc3882"] || !!loginTokenCapability?.enabled;
const msc3886Supported =
!!versions?.unstable_features?.["org.matrix.msc3886"] || !!wellKnown?.["io.element.rendezvous"]?.server;
return getLoginTokenSupported && msc3886Supported;
}
const LoginWithQRSection: React.FC<IProps> = ({ onShowQr, versions, capabilities, wellKnown }) => {
const offerShowQr = shouldShowQrLegacy(versions, wellKnown, capabilities);
// don't show anything if no method is available
if (!offerShowQr) {
return null;
}
return (
<SettingsSubsection heading={_t("settings|sessions|sign_in_with_qr")}>
<div className="mx_LoginWithQRSection">
<p className="mx_SettingsTab_subsectionText">{_t("settings|sessions|sign_in_with_qr_description")}</p>
<AccessibleButton onClick={onShowQr} kind="primary">
<QrCodeIcon height={20} width={20} />
{_t("settings|sessions|sign_in_with_qr_button")}
</AccessibleButton>
</div>
</SettingsSubsection>
);
};
export default LoginWithQRSection;

View file

@ -32,7 +32,8 @@ import { ExtendedDevice } from "../../devices/types";
import { deleteDevicesWithInteractiveAuth } from "../../devices/deleteDevices";
import SettingsTab from "../SettingsTab";
import LoginWithQRSection from "../../devices/LoginWithQRSection";
import LoginWithQR, { Mode } from "../../../auth/LoginWithQR";
import LoginWithQR from "../../../auth/LoginWithQR";
import { Mode } from "../../../auth/LoginWithQR-types";
import { useAsyncMemo } from "../../../../../hooks/useAsyncMemo";
import QuestionDialog from "../../../dialogs/QuestionDialog";
import { FilterVariation } from "../../devices/filter";
@ -284,6 +285,12 @@ const SessionManagerTab: React.FC = () => {
return (
<SettingsTab>
<SettingsSection heading={_t("settings|sessions|title")}>
<LoginWithQRSection
onShowQr={onShowQrClicked}
versions={clientVersions}
capabilities={capabilities}
wellKnown={wellKnown}
/>
<SecurityRecommendations
devices={devices}
goToFilteredList={onGoToFilteredList}
@ -337,12 +344,6 @@ const SessionManagerTab: React.FC = () => {
/>
</SettingsSubsection>
)}
<LoginWithQRSection
onShowQr={onShowQrClicked}
versions={clientVersions}
capabilities={capabilities}
wellKnown={wellKnown}
/>
</SettingsSection>
</SettingsTab>
);