Link device account management with OIDC (#2390)

* load auth metadata configs on startup

* deep-link cross-signing reset button with oidc

* deep-link manage devices and delete device with oidc

* fix import typo
This commit is contained in:
Ajay Bura 2025-07-15 18:10:16 +05:30 committed by GitHub
parent c30c142653
commit c462a3b8d5
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 185 additions and 52 deletions

View file

@ -1,36 +0,0 @@
import { ReactNode, useCallback, useEffect } from 'react';
import { Capabilities } from 'matrix-js-sdk';
import { AsyncStatus, useAsyncCallback } from '../hooks/useAsyncCallback';
import { useMatrixClient } from '../hooks/useMatrixClient';
import { MediaConfig } from '../hooks/useMediaConfig';
import { promiseFulfilledResult } from '../utils/common';
type CapabilitiesAndMediaConfigLoaderProps = {
children: (capabilities?: Capabilities, mediaConfig?: MediaConfig) => ReactNode;
};
export function CapabilitiesAndMediaConfigLoader({
children,
}: CapabilitiesAndMediaConfigLoaderProps) {
const mx = useMatrixClient();
const [state, load] = useAsyncCallback<
[Capabilities | undefined, MediaConfig | undefined],
unknown,
[]
>(
useCallback(async () => {
const result = await Promise.allSettled([mx.getCapabilities(), mx.getMediaConfig()]);
const capabilities = promiseFulfilledResult(result[0]);
const mediaConfig = promiseFulfilledResult(result[1]);
return [capabilities, mediaConfig];
}, [mx])
);
useEffect(() => {
load();
}, [load]);
const [capabilities, mediaConfig] =
state.status === AsyncStatus.Success ? state.data : [undefined, undefined];
return children(capabilities, mediaConfig);
}

View file

@ -0,0 +1,52 @@
import { ReactNode, useCallback, useMemo } from 'react';
import { Capabilities, validateAuthMetadata, ValidatedAuthMetadata } from 'matrix-js-sdk';
import { AsyncStatus, useAsyncCallbackValue } from '../hooks/useAsyncCallback';
import { useMatrixClient } from '../hooks/useMatrixClient';
import { MediaConfig } from '../hooks/useMediaConfig';
import { promiseFulfilledResult } from '../utils/common';
export type ServerConfigs = {
capabilities?: Capabilities;
mediaConfig?: MediaConfig;
authMetadata?: ValidatedAuthMetadata;
};
type ServerConfigsLoaderProps = {
children: (configs: ServerConfigs) => ReactNode;
};
export function ServerConfigsLoader({ children }: ServerConfigsLoaderProps) {
const mx = useMatrixClient();
const fallbackConfigs = useMemo(() => ({}), []);
const [configsState] = useAsyncCallbackValue<ServerConfigs, unknown>(
useCallback(async () => {
const result = await Promise.allSettled([
mx.getCapabilities(),
mx.getMediaConfig(),
mx.getAuthMetadata(),
]);
const capabilities = promiseFulfilledResult(result[0]);
const mediaConfig = promiseFulfilledResult(result[1]);
const authMetadata = promiseFulfilledResult(result[2]);
let validatedAuthMetadata: ValidatedAuthMetadata | undefined;
try {
validatedAuthMetadata = validateAuthMetadata(authMetadata);
} catch (e) {
console.error(e);
}
return {
capabilities,
mediaConfig,
authMetadata: validatedAuthMetadata,
};
}, [mx])
);
const configs: ServerConfigs =
configsState.status === AsyncStatus.Success ? configsState.data : fallbackConfigs;
return children(configs);
}

View file

@ -11,6 +11,10 @@ import { useUIAMatrixError } from '../../../hooks/useUIAFlows';
import { DeviceVerificationStatus } from '../../../components/DeviceVerificationStatus';
import { VerifyOtherDeviceTile } from './Verification';
import { VerificationStatus } from '../../../hooks/useDeviceVerificationStatus';
import { useAuthMetadata } from '../../../hooks/useAuthMetadata';
import { withSearchParam } from '../../../pages/pathUtils';
import { useAccountManagementActions } from '../../../hooks/useAccountManagement';
import { SettingTile } from '../../../components/setting-tile';
type OtherDevicesProps = {
devices: IMyDevice[];
@ -20,8 +24,39 @@ type OtherDevicesProps = {
export function OtherDevices({ devices, refreshDeviceList, showVerification }: OtherDevicesProps) {
const mx = useMatrixClient();
const crypto = mx.getCrypto();
const authMetadata = useAuthMetadata();
const accountManagementActions = useAccountManagementActions();
const [deleted, setDeleted] = useState<Set<string>>(new Set());
const handleDashboardOIDC = useCallback(() => {
const authUrl = authMetadata?.account_management_uri ?? authMetadata?.issuer;
if (!authUrl) return;
window.open(
withSearchParam(authUrl, {
action: accountManagementActions.sessionsList,
}),
'_blank'
);
}, [authMetadata, accountManagementActions]);
const handleDeleteOIDC = useCallback(
(deviceId: string) => {
const authUrl = authMetadata?.account_management_uri ?? authMetadata?.issuer;
if (!authUrl) return;
window.open(
withSearchParam(authUrl, {
action: accountManagementActions.sessionEnd,
device_id: deviceId,
}),
'_blank'
);
},
[authMetadata, accountManagementActions]
);
const handleToggleDelete = useCallback((deviceId: string) => {
setDeleted((deviceIds) => {
const newIds = new Set(deviceIds);
@ -70,6 +105,31 @@ export function OtherDevices({ devices, refreshDeviceList, showVerification }: O
<>
<Box direction="Column" gap="100">
<Text size="L400">Others</Text>
{authMetadata && (
<SequenceCard
className={SequenceCardStyle}
variant="SurfaceVariant"
direction="Column"
gap="400"
>
<SettingTile
title="Device Dashboard"
description="Manage your devices on OIDC dashboard."
after={
<Button
size="300"
variant="Secondary"
fill="Soft"
radii="300"
outlined
onClick={handleDashboardOIDC}
>
<Text size="B300">Open</Text>
</Button>
}
/>
</SequenceCard>
)}
{devices
.sort((d1, d2) => {
if (!d1.last_seen_ts || !d2.last_seen_ts) return 0;
@ -89,12 +149,20 @@ export function OtherDevices({ devices, refreshDeviceList, showVerification }: O
refreshDeviceList={refreshDeviceList}
disabled={deleting}
options={
authMetadata ? (
<DeviceDeleteBtn
deviceId={device.device_id}
deleted={false}
onDeleteToggle={handleDeleteOIDC}
/>
) : (
<DeviceDeleteBtn
deviceId={device.device_id}
deleted={deleted.has(device.device_id)}
onDeleteToggle={handleToggleDelete}
disabled={deleting}
/>
)
}
/>
{showVerification && crypto && (

View file

@ -32,6 +32,9 @@ import {
DeviceVerificationSetup,
} from '../../../components/DeviceVerificationSetup';
import { stopPropagation } from '../../../utils/keyboard';
import { useAuthMetadata } from '../../../hooks/useAuthMetadata';
import { withSearchParam } from '../../../pages/pathUtils';
import { useAccountManagementActions } from '../../../hooks/useAccountManagement';
type VerificationStatusBadgeProps = {
verificationStatus: VerificationStatus;
@ -252,6 +255,8 @@ export function EnableVerification({ visible }: EnableVerificationProps) {
export function DeviceVerificationOptions() {
const [menuCords, setMenuCords] = useState<RectCords>();
const authMetadata = useAuthMetadata();
const accountManagementActions = useAccountManagementActions();
const [reset, setReset] = useState(false);
@ -265,6 +270,18 @@ export function DeviceVerificationOptions() {
const handleReset = () => {
setMenuCords(undefined);
if (authMetadata) {
const authUrl = authMetadata.account_management_uri ?? authMetadata.issuer;
window.open(
withSearchParam(authUrl, {
action: accountManagementActions.crossSigningReset,
}),
'_blank'
);
return;
}
setReset(true);
};

View file

@ -0,0 +1,17 @@
import { useMemo } from 'react';
export const useAccountManagementActions = () => {
const actions = useMemo(
() => ({
profile: 'org.matrix.profile',
sessionsList: 'org.matrix.sessions_list',
sessionView: 'org.matrix.session_view',
sessionEnd: 'org.matrix.session_end',
accountDeactivate: 'org.matrix.account_deactivate',
crossSigningReset: 'org.matrix.cross_signing_reset',
}),
[]
);
return actions;
};

View file

@ -0,0 +1,12 @@
import { ValidatedAuthMetadata } from 'matrix-js-sdk';
import { createContext, useContext } from 'react';
const AuthMetadataContext = createContext<ValidatedAuthMetadata | undefined>(undefined);
export const AuthMetadataProvider = AuthMetadataContext.Provider;
export const useAuthMetadata = (): ValidatedAuthMetadata | undefined => {
const metadata = useContext(AuthMetadataContext);
return metadata;
};

View file

@ -25,7 +25,7 @@ import {
} from '../../../client/initMatrix';
import { getSecret } from '../../../client/state/auth';
import { SplashScreen } from '../../components/splash-screen';
import { CapabilitiesAndMediaConfigLoader } from '../../components/CapabilitiesAndMediaConfigLoader';
import { ServerConfigsLoader } from '../../components/ServerConfigsLoader';
import { CapabilitiesProvider } from '../../hooks/useCapabilities';
import { MediaConfigProvider } from '../../hooks/useMediaConfig';
import { MatrixClientProvider } from '../../hooks/useMatrixClient';
@ -37,6 +37,7 @@ import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback';
import { useSyncState } from '../../hooks/useSyncState';
import { stopPropagation } from '../../utils/keyboard';
import { SyncStatus } from './SyncStatus';
import { AuthMetadataProvider } from '../../hooks/useAuthMetadata';
function ClientRootLoading() {
return (
@ -207,18 +208,20 @@ export function ClientRoot({ children }: ClientRootProps) {
<ClientRootLoading />
) : (
<MatrixClientProvider value={mx}>
<CapabilitiesAndMediaConfigLoader>
{(capabilities, mediaConfig) => (
<CapabilitiesProvider value={capabilities ?? {}}>
<MediaConfigProvider value={mediaConfig ?? {}}>
<ServerConfigsLoader>
{(serverConfigs) => (
<CapabilitiesProvider value={serverConfigs.capabilities ?? {}}>
<MediaConfigProvider value={serverConfigs.mediaConfig ?? {}}>
<AuthMetadataProvider value={serverConfigs.authMetadata}>
{children}
<Windows />
<Dialogs />
<ReusableContextMenu />
</AuthMetadataProvider>
</MediaConfigProvider>
</CapabilitiesProvider>
)}
</CapabilitiesAndMediaConfigLoader>
</ServerConfigsLoader>
</MatrixClientProvider>
)}
</SpecVersions>