OIDC: disable multi session signout for OIDC-aware servers in session manager (#11431)
* util for account url * test cases * disable multi session selection on device list * remove sign out all from context menus when oidc-aware * comment * remove unused param * typo
This commit is contained in:
parent
3c52ba0c92
commit
dfded8d4d3
9 changed files with 362 additions and 61 deletions
|
@ -31,6 +31,7 @@ import { DevicesState } from "./useOwnDevices";
|
||||||
import FilteredDeviceListHeader from "./FilteredDeviceListHeader";
|
import FilteredDeviceListHeader from "./FilteredDeviceListHeader";
|
||||||
import Spinner from "../../elements/Spinner";
|
import Spinner from "../../elements/Spinner";
|
||||||
import { DeviceSecurityLearnMore } from "./DeviceSecurityLearnMore";
|
import { DeviceSecurityLearnMore } from "./DeviceSecurityLearnMore";
|
||||||
|
import DeviceTile from "./DeviceTile";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
devices: DevicesDictionary;
|
devices: DevicesDictionary;
|
||||||
|
@ -48,6 +49,11 @@ interface Props {
|
||||||
setPushNotifications: (deviceId: string, enabled: boolean) => Promise<void>;
|
setPushNotifications: (deviceId: string, enabled: boolean) => Promise<void>;
|
||||||
setSelectedDeviceIds: (deviceIds: ExtendedDevice["device_id"][]) => void;
|
setSelectedDeviceIds: (deviceIds: ExtendedDevice["device_id"][]) => void;
|
||||||
supportsMSC3881?: boolean | undefined;
|
supportsMSC3881?: boolean | undefined;
|
||||||
|
/**
|
||||||
|
* Only allow sessions to be signed out individually
|
||||||
|
* Removes checkboxes and multi selection header
|
||||||
|
*/
|
||||||
|
disableMultipleSignout?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const isDeviceSelected = (
|
const isDeviceSelected = (
|
||||||
|
@ -178,6 +184,7 @@ const DeviceListItem: React.FC<{
|
||||||
toggleSelected: () => void;
|
toggleSelected: () => void;
|
||||||
setPushNotifications: (deviceId: string, enabled: boolean) => Promise<void>;
|
setPushNotifications: (deviceId: string, enabled: boolean) => Promise<void>;
|
||||||
supportsMSC3881?: boolean | undefined;
|
supportsMSC3881?: boolean | undefined;
|
||||||
|
isSelectDisabled?: boolean;
|
||||||
}> = ({
|
}> = ({
|
||||||
device,
|
device,
|
||||||
pusher,
|
pusher,
|
||||||
|
@ -192,33 +199,47 @@ const DeviceListItem: React.FC<{
|
||||||
setPushNotifications,
|
setPushNotifications,
|
||||||
toggleSelected,
|
toggleSelected,
|
||||||
supportsMSC3881,
|
supportsMSC3881,
|
||||||
}) => (
|
isSelectDisabled,
|
||||||
<li className="mx_FilteredDeviceList_listItem">
|
}) => {
|
||||||
<SelectableDeviceTile
|
const tileContent = (
|
||||||
isSelected={isSelected}
|
<>
|
||||||
onSelect={toggleSelected}
|
|
||||||
onClick={onDeviceExpandToggle}
|
|
||||||
device={device}
|
|
||||||
>
|
|
||||||
{isSigningOut && <Spinner w={16} h={16} />}
|
{isSigningOut && <Spinner w={16} h={16} />}
|
||||||
<DeviceExpandDetailsButton isExpanded={isExpanded} onClick={onDeviceExpandToggle} />
|
<DeviceExpandDetailsButton isExpanded={isExpanded} onClick={onDeviceExpandToggle} />
|
||||||
</SelectableDeviceTile>
|
</>
|
||||||
{isExpanded && (
|
);
|
||||||
<DeviceDetails
|
return (
|
||||||
device={device}
|
<li className="mx_FilteredDeviceList_listItem">
|
||||||
pusher={pusher}
|
{isSelectDisabled ? (
|
||||||
localNotificationSettings={localNotificationSettings}
|
<DeviceTile device={device} onClick={onDeviceExpandToggle}>
|
||||||
isSigningOut={isSigningOut}
|
{tileContent}
|
||||||
onVerifyDevice={onRequestDeviceVerification}
|
</DeviceTile>
|
||||||
onSignOutDevice={onSignOutDevice}
|
) : (
|
||||||
saveDeviceName={saveDeviceName}
|
<SelectableDeviceTile
|
||||||
setPushNotifications={setPushNotifications}
|
isSelected={isSelected}
|
||||||
supportsMSC3881={supportsMSC3881}
|
onSelect={toggleSelected}
|
||||||
className="mx_FilteredDeviceList_deviceDetails"
|
onClick={onDeviceExpandToggle}
|
||||||
/>
|
device={device}
|
||||||
)}
|
>
|
||||||
</li>
|
{tileContent}
|
||||||
);
|
</SelectableDeviceTile>
|
||||||
|
)}
|
||||||
|
{isExpanded && (
|
||||||
|
<DeviceDetails
|
||||||
|
device={device}
|
||||||
|
pusher={pusher}
|
||||||
|
localNotificationSettings={localNotificationSettings}
|
||||||
|
isSigningOut={isSigningOut}
|
||||||
|
onVerifyDevice={onRequestDeviceVerification}
|
||||||
|
onSignOutDevice={onSignOutDevice}
|
||||||
|
saveDeviceName={saveDeviceName}
|
||||||
|
setPushNotifications={setPushNotifications}
|
||||||
|
supportsMSC3881={supportsMSC3881}
|
||||||
|
className="mx_FilteredDeviceList_deviceDetails"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Filtered list of devices
|
* Filtered list of devices
|
||||||
|
@ -242,6 +263,7 @@ export const FilteredDeviceList = forwardRef(
|
||||||
setPushNotifications,
|
setPushNotifications,
|
||||||
setSelectedDeviceIds,
|
setSelectedDeviceIds,
|
||||||
supportsMSC3881,
|
supportsMSC3881,
|
||||||
|
disableMultipleSignout,
|
||||||
}: Props,
|
}: Props,
|
||||||
ref: ForwardedRef<HTMLDivElement>,
|
ref: ForwardedRef<HTMLDivElement>,
|
||||||
) => {
|
) => {
|
||||||
|
@ -302,6 +324,7 @@ export const FilteredDeviceList = forwardRef(
|
||||||
selectedDeviceCount={selectedDeviceIds.length}
|
selectedDeviceCount={selectedDeviceIds.length}
|
||||||
isAllSelected={isAllSelected}
|
isAllSelected={isAllSelected}
|
||||||
toggleSelectAll={toggleSelectAll}
|
toggleSelectAll={toggleSelectAll}
|
||||||
|
isSelectDisabled={disableMultipleSignout}
|
||||||
>
|
>
|
||||||
{selectedDeviceIds.length ? (
|
{selectedDeviceIds.length ? (
|
||||||
<>
|
<>
|
||||||
|
@ -351,6 +374,7 @@ export const FilteredDeviceList = forwardRef(
|
||||||
isExpanded={expandedDeviceIds.includes(device.device_id)}
|
isExpanded={expandedDeviceIds.includes(device.device_id)}
|
||||||
isSigningOut={signingOutDeviceIds.includes(device.device_id)}
|
isSigningOut={signingOutDeviceIds.includes(device.device_id)}
|
||||||
isSelected={isDeviceSelected(device.device_id, selectedDeviceIds)}
|
isSelected={isDeviceSelected(device.device_id, selectedDeviceIds)}
|
||||||
|
isSelectDisabled={disableMultipleSignout}
|
||||||
onDeviceExpandToggle={() => onDeviceExpandToggle(device.device_id)}
|
onDeviceExpandToggle={() => onDeviceExpandToggle(device.device_id)}
|
||||||
onSignOutDevice={() => onSignOutDevices([device.device_id])}
|
onSignOutDevice={() => onSignOutDevices([device.device_id])}
|
||||||
saveDeviceName={(deviceName: string) => saveDeviceName(device.device_id, deviceName)}
|
saveDeviceName={(deviceName: string) => saveDeviceName(device.device_id, deviceName)}
|
||||||
|
|
|
@ -24,6 +24,7 @@ import TooltipTarget from "../../elements/TooltipTarget";
|
||||||
interface Props extends Omit<HTMLProps<HTMLDivElement>, "className"> {
|
interface Props extends Omit<HTMLProps<HTMLDivElement>, "className"> {
|
||||||
selectedDeviceCount: number;
|
selectedDeviceCount: number;
|
||||||
isAllSelected: boolean;
|
isAllSelected: boolean;
|
||||||
|
isSelectDisabled?: boolean;
|
||||||
toggleSelectAll: () => void;
|
toggleSelectAll: () => void;
|
||||||
children?: React.ReactNode;
|
children?: React.ReactNode;
|
||||||
}
|
}
|
||||||
|
@ -31,6 +32,7 @@ interface Props extends Omit<HTMLProps<HTMLDivElement>, "className"> {
|
||||||
const FilteredDeviceListHeader: React.FC<Props> = ({
|
const FilteredDeviceListHeader: React.FC<Props> = ({
|
||||||
selectedDeviceCount,
|
selectedDeviceCount,
|
||||||
isAllSelected,
|
isAllSelected,
|
||||||
|
isSelectDisabled,
|
||||||
toggleSelectAll,
|
toggleSelectAll,
|
||||||
children,
|
children,
|
||||||
...rest
|
...rest
|
||||||
|
@ -38,16 +40,18 @@ const FilteredDeviceListHeader: React.FC<Props> = ({
|
||||||
const checkboxLabel = isAllSelected ? _t("Deselect all") : _t("Select all");
|
const checkboxLabel = isAllSelected ? _t("Deselect all") : _t("Select all");
|
||||||
return (
|
return (
|
||||||
<div className="mx_FilteredDeviceListHeader" {...rest}>
|
<div className="mx_FilteredDeviceListHeader" {...rest}>
|
||||||
<TooltipTarget label={checkboxLabel} alignment={Alignment.Top}>
|
{!isSelectDisabled && (
|
||||||
<StyledCheckbox
|
<TooltipTarget label={checkboxLabel} alignment={Alignment.Top}>
|
||||||
kind={CheckboxStyle.Solid}
|
<StyledCheckbox
|
||||||
checked={isAllSelected}
|
kind={CheckboxStyle.Solid}
|
||||||
onChange={toggleSelectAll}
|
checked={isAllSelected}
|
||||||
id="device-select-all-checkbox"
|
onChange={toggleSelectAll}
|
||||||
data-testid="device-select-all-checkbox"
|
id="device-select-all-checkbox"
|
||||||
aria-label={checkboxLabel}
|
data-testid="device-select-all-checkbox"
|
||||||
/>
|
aria-label={checkboxLabel}
|
||||||
</TooltipTarget>
|
/>
|
||||||
|
</TooltipTarget>
|
||||||
|
)}
|
||||||
<span className="mx_FilteredDeviceListHeader_label">
|
<span className="mx_FilteredDeviceListHeader_label">
|
||||||
{selectedDeviceCount > 0
|
{selectedDeviceCount > 0
|
||||||
? _t("%(count)s sessions selected", { count: selectedDeviceCount })
|
? _t("%(count)s sessions selected", { count: selectedDeviceCount })
|
||||||
|
|
|
@ -20,6 +20,7 @@ import { _t } from "../../../../languageHandler";
|
||||||
import { KebabContextMenu } from "../../context_menus/KebabContextMenu";
|
import { KebabContextMenu } from "../../context_menus/KebabContextMenu";
|
||||||
import { SettingsSubsectionHeading } from "../shared/SettingsSubsectionHeading";
|
import { SettingsSubsectionHeading } from "../shared/SettingsSubsectionHeading";
|
||||||
import { IconizedContextMenuOption } from "../../context_menus/IconizedContextMenu";
|
import { IconizedContextMenuOption } from "../../context_menus/IconizedContextMenu";
|
||||||
|
import { filterBoolean } from "../../../../utils/arrays";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
// total count of other sessions
|
// total count of other sessions
|
||||||
|
@ -27,7 +28,8 @@ interface Props {
|
||||||
// not affected by filters
|
// not affected by filters
|
||||||
otherSessionsCount: number;
|
otherSessionsCount: number;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
signOutAllOtherSessions: () => void;
|
// not provided when sign out all other sessions is not available
|
||||||
|
signOutAllOtherSessions?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const OtherSessionsSectionHeading: React.FC<Props> = ({
|
export const OtherSessionsSectionHeading: React.FC<Props> = ({
|
||||||
|
@ -35,22 +37,26 @@ export const OtherSessionsSectionHeading: React.FC<Props> = ({
|
||||||
disabled,
|
disabled,
|
||||||
signOutAllOtherSessions,
|
signOutAllOtherSessions,
|
||||||
}) => {
|
}) => {
|
||||||
const menuOptions = [
|
const menuOptions = filterBoolean([
|
||||||
<IconizedContextMenuOption
|
signOutAllOtherSessions ? (
|
||||||
key="sign-out-all-others"
|
<IconizedContextMenuOption
|
||||||
label={_t("Sign out of %(count)s sessions", { count: otherSessionsCount })}
|
key="sign-out-all-others"
|
||||||
onClick={signOutAllOtherSessions}
|
label={_t("Sign out of %(count)s sessions", { count: otherSessionsCount })}
|
||||||
isDestructive
|
onClick={signOutAllOtherSessions}
|
||||||
/>,
|
isDestructive
|
||||||
];
|
/>
|
||||||
|
) : null,
|
||||||
|
]);
|
||||||
return (
|
return (
|
||||||
<SettingsSubsectionHeading heading={_t("Other sessions")}>
|
<SettingsSubsectionHeading heading={_t("Other sessions")}>
|
||||||
<KebabContextMenu
|
{!!menuOptions.length && (
|
||||||
disabled={disabled}
|
<KebabContextMenu
|
||||||
title={_t("Options")}
|
disabled={disabled}
|
||||||
options={menuOptions}
|
title={_t("Options")}
|
||||||
data-testid="other-sessions-menu"
|
options={menuOptions}
|
||||||
/>
|
data-testid="other-sessions-menu"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</SettingsSubsectionHeading>
|
</SettingsSubsectionHeading>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
@ -17,7 +17,7 @@ limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React, { ReactNode } from "react";
|
import React, { ReactNode } from "react";
|
||||||
import { SERVICE_TYPES, IDelegatedAuthConfig, M_AUTHENTICATION, HTTPError } from "matrix-js-sdk/src/matrix";
|
import { SERVICE_TYPES, HTTPError } from "matrix-js-sdk/src/matrix";
|
||||||
import { IThreepid, ThreepidMedium } from "matrix-js-sdk/src/@types/threepids";
|
import { IThreepid, ThreepidMedium } from "matrix-js-sdk/src/@types/threepids";
|
||||||
import { logger } from "matrix-js-sdk/src/logger";
|
import { logger } from "matrix-js-sdk/src/logger";
|
||||||
|
|
||||||
|
@ -59,6 +59,7 @@ import Heading from "../../../typography/Heading";
|
||||||
import InlineSpinner from "../../../elements/InlineSpinner";
|
import InlineSpinner from "../../../elements/InlineSpinner";
|
||||||
import MatrixClientContext from "../../../../../contexts/MatrixClientContext";
|
import MatrixClientContext from "../../../../../contexts/MatrixClientContext";
|
||||||
import { ThirdPartyIdentifier } from "../../../../../AddThreepid";
|
import { ThirdPartyIdentifier } from "../../../../../AddThreepid";
|
||||||
|
import { getDelegatedAuthAccountUrl } from "../../../../../utils/oidc/getDelegatedAuthAccountUrl";
|
||||||
|
|
||||||
interface IProps {
|
interface IProps {
|
||||||
closeSettingsFn: () => void;
|
closeSettingsFn: () => void;
|
||||||
|
@ -172,8 +173,7 @@ export default class GeneralUserSettingsTab extends React.Component<IProps, ISta
|
||||||
// the enabled flag value.
|
// the enabled flag value.
|
||||||
const canChangePassword = !changePasswordCap || changePasswordCap["enabled"] !== false;
|
const canChangePassword = !changePasswordCap || changePasswordCap["enabled"] !== false;
|
||||||
|
|
||||||
const delegatedAuthConfig = M_AUTHENTICATION.findIn<IDelegatedAuthConfig | undefined>(cli.getClientWellKnown());
|
const externalAccountManagementUrl = getDelegatedAuthAccountUrl(cli.getClientWellKnown());
|
||||||
const externalAccountManagementUrl = delegatedAuthConfig?.account;
|
|
||||||
|
|
||||||
this.setState({ canChangePassword, externalAccountManagementUrl });
|
this.setState({ canChangePassword, externalAccountManagementUrl });
|
||||||
}
|
}
|
||||||
|
|
|
@ -39,6 +39,7 @@ import QuestionDialog from "../../../dialogs/QuestionDialog";
|
||||||
import { FilterVariation } from "../../devices/filter";
|
import { FilterVariation } from "../../devices/filter";
|
||||||
import { OtherSessionsSectionHeading } from "../../devices/OtherSessionsSectionHeading";
|
import { OtherSessionsSectionHeading } from "../../devices/OtherSessionsSectionHeading";
|
||||||
import { SettingsSection } from "../../shared/SettingsSection";
|
import { SettingsSection } from "../../shared/SettingsSection";
|
||||||
|
import { getDelegatedAuthAccountUrl } from "../../../../../utils/oidc/getDelegatedAuthAccountUrl";
|
||||||
|
|
||||||
const confirmSignOut = async (sessionsToSignOutCount: number): Promise<boolean> => {
|
const confirmSignOut = async (sessionsToSignOutCount: number): Promise<boolean> => {
|
||||||
const { finished } = Modal.createDialog(QuestionDialog, {
|
const { finished } = Modal.createDialog(QuestionDialog, {
|
||||||
|
@ -130,6 +131,14 @@ const SessionManagerTab: React.FC = () => {
|
||||||
const scrollIntoViewTimeoutRef = useRef<number>();
|
const scrollIntoViewTimeoutRef = useRef<number>();
|
||||||
|
|
||||||
const matrixClient = useContext(MatrixClientContext);
|
const matrixClient = useContext(MatrixClientContext);
|
||||||
|
/**
|
||||||
|
* If we have a delegated auth account management URL, all sessions but the current session need to be managed in the
|
||||||
|
* delegated auth provider.
|
||||||
|
* See https://github.com/matrix-org/matrix-spec-proposals/pull/3824
|
||||||
|
*/
|
||||||
|
const delegatedAuthAccountUrl = getDelegatedAuthAccountUrl(matrixClient.getClientWellKnown());
|
||||||
|
const disableMultipleSignout = !!delegatedAuthAccountUrl;
|
||||||
|
|
||||||
const userId = matrixClient?.getUserId();
|
const userId = matrixClient?.getUserId();
|
||||||
const currentUserMember = (userId && matrixClient?.getUser(userId)) || undefined;
|
const currentUserMember = (userId && matrixClient?.getUser(userId)) || undefined;
|
||||||
const clientVersions = useAsyncMemo(() => matrixClient.getVersions(), [matrixClient]);
|
const clientVersions = useAsyncMemo(() => matrixClient.getVersions(), [matrixClient]);
|
||||||
|
@ -205,11 +214,12 @@ const SessionManagerTab: React.FC = () => {
|
||||||
setSelectedDeviceIds([]);
|
setSelectedDeviceIds([]);
|
||||||
}, [filter, setSelectedDeviceIds]);
|
}, [filter, setSelectedDeviceIds]);
|
||||||
|
|
||||||
const signOutAllOtherSessions = shouldShowOtherSessions
|
const signOutAllOtherSessions =
|
||||||
? () => {
|
shouldShowOtherSessions && !disableMultipleSignout
|
||||||
onSignOutOtherDevices(Object.keys(otherDevices));
|
? () => {
|
||||||
}
|
onSignOutOtherDevices(Object.keys(otherDevices));
|
||||||
: undefined;
|
}
|
||||||
|
: undefined;
|
||||||
|
|
||||||
const [signInWithQrMode, setSignInWithQrMode] = useState<Mode | null>();
|
const [signInWithQrMode, setSignInWithQrMode] = useState<Mode | null>();
|
||||||
|
|
||||||
|
@ -250,7 +260,7 @@ const SessionManagerTab: React.FC = () => {
|
||||||
heading={
|
heading={
|
||||||
<OtherSessionsSectionHeading
|
<OtherSessionsSectionHeading
|
||||||
otherSessionsCount={otherSessionsCount}
|
otherSessionsCount={otherSessionsCount}
|
||||||
signOutAllOtherSessions={signOutAllOtherSessions!}
|
signOutAllOtherSessions={signOutAllOtherSessions}
|
||||||
disabled={!!signingOutDeviceIds.length}
|
disabled={!!signingOutDeviceIds.length}
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
|
@ -280,6 +290,7 @@ const SessionManagerTab: React.FC = () => {
|
||||||
setPushNotifications={setPushNotifications}
|
setPushNotifications={setPushNotifications}
|
||||||
ref={filteredDeviceListRef}
|
ref={filteredDeviceListRef}
|
||||||
supportsMSC3881={supportsMSC3881}
|
supportsMSC3881={supportsMSC3881}
|
||||||
|
disableMultipleSignout={disableMultipleSignout}
|
||||||
/>
|
/>
|
||||||
</SettingsSubsection>
|
</SettingsSubsection>
|
||||||
)}
|
)}
|
||||||
|
|
27
src/utils/oidc/getDelegatedAuthAccountUrl.ts
Normal file
27
src/utils/oidc/getDelegatedAuthAccountUrl.ts
Normal file
|
@ -0,0 +1,27 @@
|
||||||
|
/*
|
||||||
|
Copyright 2023 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.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { IClientWellKnown, IDelegatedAuthConfig, M_AUTHENTICATION } from "matrix-js-sdk/src/matrix";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the delegated auth account management url if configured
|
||||||
|
* @param clientWellKnown from MatrixClient.getClientWellKnown
|
||||||
|
* @returns the account management url, or undefined
|
||||||
|
*/
|
||||||
|
export const getDelegatedAuthAccountUrl = (clientWellKnown: IClientWellKnown | undefined): string | undefined => {
|
||||||
|
const delegatedAuthConfig = M_AUTHENTICATION.findIn<IDelegatedAuthConfig | undefined>(clientWellKnown);
|
||||||
|
return delegatedAuthConfig?.account;
|
||||||
|
};
|
|
@ -15,7 +15,7 @@ limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import { act, fireEvent, render, RenderResult } from "@testing-library/react";
|
import { act, fireEvent, render, RenderResult, screen } from "@testing-library/react";
|
||||||
import { DeviceInfo } from "matrix-js-sdk/src/crypto/deviceinfo";
|
import { DeviceInfo } from "matrix-js-sdk/src/crypto/deviceinfo";
|
||||||
import { logger } from "matrix-js-sdk/src/logger";
|
import { logger } from "matrix-js-sdk/src/logger";
|
||||||
import { VerificationRequest } from "matrix-js-sdk/src/crypto-api";
|
import { VerificationRequest } from "matrix-js-sdk/src/crypto-api";
|
||||||
|
@ -32,6 +32,7 @@ import {
|
||||||
CryptoApi,
|
CryptoApi,
|
||||||
DeviceVerificationStatus,
|
DeviceVerificationStatus,
|
||||||
MatrixError,
|
MatrixError,
|
||||||
|
M_AUTHENTICATION,
|
||||||
} from "matrix-js-sdk/src/matrix";
|
} from "matrix-js-sdk/src/matrix";
|
||||||
import { mocked } from "jest-mock";
|
import { mocked } from "jest-mock";
|
||||||
|
|
||||||
|
@ -87,7 +88,7 @@ describe("<SessionManagerTab />", () => {
|
||||||
requestDeviceVerification: jest.fn().mockResolvedValue(mockVerificationRequest),
|
requestDeviceVerification: jest.fn().mockResolvedValue(mockVerificationRequest),
|
||||||
} as unknown as CryptoApi);
|
} as unknown as CryptoApi);
|
||||||
|
|
||||||
const mockClient = getMockClientWithEventEmitter({
|
let mockClient = getMockClientWithEventEmitter({
|
||||||
...mockClientMethodsUser(aliceId),
|
...mockClientMethodsUser(aliceId),
|
||||||
getCrypto: jest.fn().mockReturnValue(mockCrypto),
|
getCrypto: jest.fn().mockReturnValue(mockCrypto),
|
||||||
getDevices: jest.fn(),
|
getDevices: jest.fn(),
|
||||||
|
@ -104,6 +105,7 @@ describe("<SessionManagerTab />", () => {
|
||||||
setLocalNotificationSettings: jest.fn(),
|
setLocalNotificationSettings: jest.fn(),
|
||||||
getVersions: jest.fn().mockResolvedValue({}),
|
getVersions: jest.fn().mockResolvedValue({}),
|
||||||
getCapabilities: jest.fn().mockResolvedValue({}),
|
getCapabilities: jest.fn().mockResolvedValue({}),
|
||||||
|
getClientWellKnown: jest.fn().mockReturnValue({}),
|
||||||
});
|
});
|
||||||
|
|
||||||
const defaultProps = {};
|
const defaultProps = {};
|
||||||
|
@ -173,6 +175,25 @@ describe("<SessionManagerTab />", () => {
|
||||||
};
|
};
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
|
mockClient = getMockClientWithEventEmitter({
|
||||||
|
...mockClientMethodsUser(aliceId),
|
||||||
|
getCrypto: jest.fn().mockReturnValue(mockCrypto),
|
||||||
|
getDevices: jest.fn(),
|
||||||
|
getStoredDevice: jest.fn(),
|
||||||
|
getDeviceId: jest.fn().mockReturnValue(deviceId),
|
||||||
|
deleteMultipleDevices: jest.fn(),
|
||||||
|
generateClientSecret: jest.fn(),
|
||||||
|
setDeviceDetails: jest.fn(),
|
||||||
|
getAccountData: jest.fn(),
|
||||||
|
deleteAccountData: jest.fn(),
|
||||||
|
doesServerSupportUnstableFeature: jest.fn().mockResolvedValue(true),
|
||||||
|
getPushers: jest.fn(),
|
||||||
|
setPusher: jest.fn(),
|
||||||
|
setLocalNotificationSettings: jest.fn(),
|
||||||
|
getVersions: jest.fn().mockResolvedValue({}),
|
||||||
|
getCapabilities: jest.fn().mockResolvedValue({}),
|
||||||
|
getClientWellKnown: jest.fn().mockReturnValue({}),
|
||||||
|
});
|
||||||
jest.clearAllMocks();
|
jest.clearAllMocks();
|
||||||
jest.spyOn(logger, "error").mockRestore();
|
jest.spyOn(logger, "error").mockRestore();
|
||||||
mockClient.getStoredDevice.mockImplementation((_userId, id) => {
|
mockClient.getStoredDevice.mockImplementation((_userId, id) => {
|
||||||
|
@ -1012,6 +1033,138 @@ describe("<SessionManagerTab />", () => {
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("for an OIDC-aware server", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
mockClient.getClientWellKnown.mockReturnValue({
|
||||||
|
[M_AUTHENTICATION.name]: {
|
||||||
|
issuer: "https://issuer.org",
|
||||||
|
account: "https://issuer.org/account",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// signing out the current device works as usual
|
||||||
|
it("Signs out of current device", async () => {
|
||||||
|
const modalSpy = jest.spyOn(Modal, "createDialog");
|
||||||
|
|
||||||
|
mockClient.getDevices.mockResolvedValue({
|
||||||
|
devices: [alicesDevice],
|
||||||
|
});
|
||||||
|
const { getByTestId } = render(getComponent());
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
await flushPromises();
|
||||||
|
});
|
||||||
|
|
||||||
|
toggleDeviceDetails(getByTestId, alicesDevice.device_id);
|
||||||
|
|
||||||
|
const signOutButton = getByTestId("device-detail-sign-out-cta");
|
||||||
|
expect(signOutButton).toMatchSnapshot();
|
||||||
|
fireEvent.click(signOutButton);
|
||||||
|
|
||||||
|
// logout dialog opened
|
||||||
|
expect(modalSpy).toHaveBeenCalledWith(LogoutDialog, {}, undefined, false, true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not allow signing out of all other devices from current session context menu", async () => {
|
||||||
|
mockClient.getDevices.mockResolvedValue({
|
||||||
|
devices: [alicesDevice, alicesMobileDevice, alicesOlderMobileDevice],
|
||||||
|
});
|
||||||
|
const { getByTestId } = render(getComponent());
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
await flushPromises();
|
||||||
|
});
|
||||||
|
|
||||||
|
fireEvent.click(getByTestId("current-session-menu"));
|
||||||
|
expect(screen.queryByLabelText("Sign out of all other sessions (2)")).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("other devices", () => {
|
||||||
|
// signing out a single device still works
|
||||||
|
// this test will be updated once redirect to MAS is added
|
||||||
|
// https://github.com/vector-im/element-web/issues/26000
|
||||||
|
it("deletes a device when interactive auth is not required", async () => {
|
||||||
|
mockClient.deleteMultipleDevices.mockResolvedValue({});
|
||||||
|
mockClient.getDevices
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
devices: [alicesDevice, alicesMobileDevice, alicesOlderMobileDevice],
|
||||||
|
})
|
||||||
|
// pretend it was really deleted on refresh
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
devices: [alicesDevice, alicesOlderMobileDevice],
|
||||||
|
});
|
||||||
|
|
||||||
|
const { getByTestId } = render(getComponent());
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
await flushPromises();
|
||||||
|
});
|
||||||
|
|
||||||
|
toggleDeviceDetails(getByTestId, alicesMobileDevice.device_id);
|
||||||
|
|
||||||
|
const deviceDetails = getByTestId(`device-detail-${alicesMobileDevice.device_id}`);
|
||||||
|
const signOutButton = deviceDetails.querySelector(
|
||||||
|
'[data-testid="device-detail-sign-out-cta"]',
|
||||||
|
) as Element;
|
||||||
|
fireEvent.click(signOutButton);
|
||||||
|
|
||||||
|
await confirmSignout(getByTestId);
|
||||||
|
|
||||||
|
// sign out button is disabled with spinner
|
||||||
|
expect(
|
||||||
|
(
|
||||||
|
deviceDetails.querySelector('[data-testid="device-detail-sign-out-cta"]') as Element
|
||||||
|
).getAttribute("aria-disabled"),
|
||||||
|
).toEqual("true");
|
||||||
|
// delete called
|
||||||
|
expect(mockClient.deleteMultipleDevices).toHaveBeenCalledWith(
|
||||||
|
[alicesMobileDevice.device_id],
|
||||||
|
undefined,
|
||||||
|
);
|
||||||
|
|
||||||
|
await flushPromises();
|
||||||
|
|
||||||
|
// devices refreshed
|
||||||
|
expect(mockClient.getDevices).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not allow removing multiple devices at once", async () => {
|
||||||
|
mockClient.getDevices.mockResolvedValue({
|
||||||
|
devices: [alicesDevice, alicesMobileDevice, alicesOlderMobileDevice, alicesInactiveDevice],
|
||||||
|
});
|
||||||
|
|
||||||
|
render(getComponent());
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
await flushPromises();
|
||||||
|
});
|
||||||
|
|
||||||
|
// sessions don't have checkboxes
|
||||||
|
expect(
|
||||||
|
screen.queryByTestId(`device-tile-checkbox-${alicesMobileDevice.device_id}`),
|
||||||
|
).not.toBeInTheDocument();
|
||||||
|
// no select all
|
||||||
|
expect(screen.queryByLabelText("Select all")).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not allow signing out of all other devices from other sessions context menu", async () => {
|
||||||
|
mockClient.getDevices.mockResolvedValue({
|
||||||
|
devices: [alicesDevice, alicesMobileDevice, alicesOlderMobileDevice],
|
||||||
|
});
|
||||||
|
render(getComponent());
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
await flushPromises();
|
||||||
|
});
|
||||||
|
|
||||||
|
// no context menu because 'sign out all' is the only option
|
||||||
|
// and it is not allowed when server is oidc-aware
|
||||||
|
expect(screen.queryByTestId("other-sessions-menu")).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("Rename sessions", () => {
|
describe("Rename sessions", () => {
|
||||||
|
|
|
@ -53,6 +53,21 @@ exports[`<SessionManagerTab /> Sign out Signs out of current device 1`] = `
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
exports[`<SessionManagerTab /> Sign out for an OIDC-aware server Signs out of current device 1`] = `
|
||||||
|
<div
|
||||||
|
class="mx_AccessibleButton mx_AccessibleButton_hasKind mx_AccessibleButton_kind_danger_inline"
|
||||||
|
data-testid="device-detail-sign-out-cta"
|
||||||
|
role="button"
|
||||||
|
tabindex="0"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
class="mx_DeviceDetails_signOutButtonContent"
|
||||||
|
>
|
||||||
|
Sign out of this session
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
exports[`<SessionManagerTab /> current session section renders current session section with a verified session 1`] = `
|
exports[`<SessionManagerTab /> current session section renders current session section with a verified session 1`] = `
|
||||||
<div
|
<div
|
||||||
class="mx_SettingsSubsection"
|
class="mx_SettingsSubsection"
|
||||||
|
|
61
test/utils/oidc/getDelegatedAuthAccountUrl-test.ts
Normal file
61
test/utils/oidc/getDelegatedAuthAccountUrl-test.ts
Normal file
|
@ -0,0 +1,61 @@
|
||||||
|
/*
|
||||||
|
Copyright 2023 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.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { M_AUTHENTICATION } from "matrix-js-sdk/src/matrix";
|
||||||
|
|
||||||
|
import { getDelegatedAuthAccountUrl } from "../../../src/utils/oidc/getDelegatedAuthAccountUrl";
|
||||||
|
|
||||||
|
describe("getDelegatedAuthAccountUrl()", () => {
|
||||||
|
it("should return undefined when wk is undefined", () => {
|
||||||
|
expect(getDelegatedAuthAccountUrl(undefined)).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return undefined when wk has no authentication config", () => {
|
||||||
|
expect(getDelegatedAuthAccountUrl({})).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return undefined when wk authentication config has no configured account url", () => {
|
||||||
|
expect(
|
||||||
|
getDelegatedAuthAccountUrl({
|
||||||
|
[M_AUTHENTICATION.stable!]: {
|
||||||
|
issuer: "issuer.org",
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return the account url for authentication config using the unstable prefix", () => {
|
||||||
|
expect(
|
||||||
|
getDelegatedAuthAccountUrl({
|
||||||
|
[M_AUTHENTICATION.unstable!]: {
|
||||||
|
issuer: "issuer.org",
|
||||||
|
account: "issuer.org/account",
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
).toEqual("issuer.org/account");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return the account url for authentication config using the stable prefix", () => {
|
||||||
|
expect(
|
||||||
|
getDelegatedAuthAccountUrl({
|
||||||
|
[M_AUTHENTICATION.stable!]: {
|
||||||
|
issuer: "issuer.org",
|
||||||
|
account: "issuer.org/account",
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
).toEqual("issuer.org/account");
|
||||||
|
});
|
||||||
|
});
|
Loading…
Reference in a new issue