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>
);

View file

@ -249,21 +249,29 @@
"completing_setup": "Completing set up of your new device",
"confirm_code_match": "Check that the code below matches with your other device:",
"connecting": "Connecting…",
"error_device_already_signed_in": "The other device is already signed in.",
"error_device_not_signed_in": "The other device isn't signed in.",
"error_device_unsupported": "Linking with this device is not supported.",
"error_homeserver_lacks_support": "The homeserver doesn't support signing in another device.",
"error_invalid_scanned_code": "The scanned code is invalid.",
"error_linking_incomplete": "The linking wasn't completed in the required time.",
"error_expired": "Sign in expired. Please try again.",
"error_expired_title": "The sign in was not completed in time",
"error_insecure_channel_detected": "A secure connection could not be made to the new device. Your existing devices are still safe and you don't need to worry about them.",
"error_insecure_channel_detected_instructions": "Now what?",
"error_insecure_channel_detected_instructions_1": "Try signing in to the other device again with a QR code in case this was a network problem",
"error_insecure_channel_detected_instructions_2": "If you encounter the same problem, try a different wifi network or use your mobile data instead of wifi",
"error_insecure_channel_detected_instructions_3": "If that doesn't work, sign in manually",
"error_insecure_channel_detected_title": "Connection not secure",
"error_other_device_already_signed_in": "You dont need to do anything else.",
"error_other_device_already_signed_in_title": "Your other device is already signed in",
"error_rate_limited": "Too many attempts in a short time. Wait some time before trying again.",
"error_request_cancelled": "The request was cancelled.",
"error_request_declined": "The request was declined on the other device.",
"error_unexpected": "An unexpected error occurred.",
"follow_remaining_instructions": "Follow the remaining instructions to verify your other device",
"error_unexpected": "An unexpected error occurred. The request to connect your other device has been cancelled.",
"error_unsupported_protocol": "This device does not support signing in to the other device with a QR code.",
"error_unsupported_protocol_title": "Other device not compatible",
"error_user_cancelled": "The sign in was cancelled on the other device.",
"error_user_cancelled_title": "Sign in request cancelled",
"error_user_declined": "You declined the request from your other device to sign in.",
"error_user_declined_title": "Sign in declined",
"follow_remaining_instructions": "Follow the instructions to link your other device",
"open_element_other_device": "Open %(brand)s on your other device",
"point_the_camera": "Point the camera at the QR code shown here",
"scan_code_instruction": "Scan the QR code with another device",
"scan_qr_code": "Scan QR code",
"scan_qr_code": "Sign in with QR code",
"select_qr_code": "Select \"%(scanQRCode)s\"",
"sign_in_new_device": "Sign in new device",
"waiting_for_device": "Waiting for device to sign in"