Link device account management with OIDC (#2390)
authorAjay Bura <32841439+ajbura@users.noreply.github.com>
Tue, 15 Jul 2025 12:40:16 +0000 (18:10 +0530)
committerGitHub <noreply@github.com>
Tue, 15 Jul 2025 12:40:16 +0000 (22:40 +1000)
* 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

src/app/components/CapabilitiesAndMediaConfigLoader.tsx [deleted file]
src/app/components/ServerConfigsLoader.tsx [new file with mode: 0644]
src/app/features/settings/devices/OtherDevices.tsx
src/app/features/settings/devices/Verification.tsx
src/app/hooks/useAccountManagement.ts [new file with mode: 0644]
src/app/hooks/useAuthMetadata.ts [new file with mode: 0644]
src/app/pages/client/ClientRoot.tsx

diff --git a/src/app/components/CapabilitiesAndMediaConfigLoader.tsx b/src/app/components/CapabilitiesAndMediaConfigLoader.tsx
deleted file mode 100644 (file)
index 574d0ca..0000000
+++ /dev/null
@@ -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);
-}
diff --git a/src/app/components/ServerConfigsLoader.tsx b/src/app/components/ServerConfigsLoader.tsx
new file mode 100644 (file)
index 0000000..3c8ce8e
--- /dev/null
@@ -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);
+}
index 0d879e59cee3fe135481d70919c77e33284a6820..4bd83dd6c10d847490781aa7646b19136bb6479f 100644 (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={
-                  <DeviceDeleteBtn
-                    deviceId={device.device_id}
-                    deleted={deleted.has(device.device_id)}
-                    onDeleteToggle={handleToggleDelete}
-                    disabled={deleting}
-                  />
+                  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 && (
index 59fa6b67bb875288cde40b518ed1b5506edfba8f..6c7eab17babb2ae5b3b498e0cf27bc50482bae26 100644 (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);
   };
 
diff --git a/src/app/hooks/useAccountManagement.ts b/src/app/hooks/useAccountManagement.ts
new file mode 100644 (file)
index 0000000..5eafedc
--- /dev/null
@@ -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;
+};
diff --git a/src/app/hooks/useAuthMetadata.ts b/src/app/hooks/useAuthMetadata.ts
new file mode 100644 (file)
index 0000000..db96746
--- /dev/null
@@ -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;
+};
index 846d8ff3f36beb5227e64842dc742ff431142a8a..c48dbf532fe83eca789e0390c490ed3271a4f2f9 100644 (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 ?? {}}>
-                  {children}
-                  <Windows />
-                  <Dialogs />
-                  <ReusableContextMenu />
+          <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>