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:
Kerry 2023-08-22 14:25:34 +12:00 committed by GitHub
parent 3c52ba0c92
commit dfded8d4d3
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
9 changed files with 362 additions and 61 deletions

View file

@ -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)}

View file

@ -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 })

View file

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

View file

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

View file

@ -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>
)} )}

View 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;
};

View file

@ -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", () => {

View file

@ -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"

View 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");
});
});