From b4a8e78fed5b943aa1ad5658da2b572b958c3ccd Mon Sep 17 00:00:00 2001 From: Asier Isayas Date: Tue, 18 Aug 2026 10:04:39 -0700 Subject: [PATCH] Consolidate hosted login account metadata into a single state Encrypted-token and direct connection-string logins now write to one accountMetadata state instead of two, which also fixes the connect form staying mounted after a successful SQL/Tables/Gremlin login. Deletes the now-unused useTokenMetadata hook and moves fetchAccessData to Platform/Hosted/Helpers/PortalAccessData.ts. --- src/Common/ErrorHandlingUtils.ts | 4 +-- src/HostedExplorer.test.tsx | 14 +++++----- src/HostedExplorer.tsx | 28 ++++++++++--------- src/HostedExplorerChildFrame.ts | 4 +-- src/Localization/en/Resources.json | 2 +- .../Components/ConnectExplorer.test.tsx | 8 +++--- .../Hosted/Components/ConnectExplorer.tsx | 6 ++-- .../Hosted/Helpers/PortalAccessData.ts} | 18 ++---------- tsconfig.strict.json | 2 +- 9 files changed, 38 insertions(+), 48 deletions(-) rename src/{hooks/usePortalAccessToken.tsx => Platform/Hosted/Helpers/PortalAccessData.ts} (56%) diff --git a/src/Common/ErrorHandlingUtils.ts b/src/Common/ErrorHandlingUtils.ts index 9755c8a63..6996f19cd 100644 --- a/src/Common/ErrorHandlingUtils.ts +++ b/src/Common/ErrorHandlingUtils.ts @@ -42,8 +42,8 @@ export const handleError = ( } }; -export const getErrorMessage = (error: unknown = ""): string => { - let errorMessage = typeof error === "string" ? error : (error as Error)?.message; +export const getErrorMessage = (error: string | Error = ""): string => { + let errorMessage = typeof error === "string" ? error : error.message; if (!errorMessage) { errorMessage = stringifyError(error); } diff --git a/src/HostedExplorer.test.tsx b/src/HostedExplorer.test.tsx index 19483cf7d..9ee38cef5 100644 --- a/src/HostedExplorer.test.tsx +++ b/src/HostedExplorer.test.tsx @@ -1,6 +1,6 @@ jest.mock("./hooks/useAADAuth"); jest.mock("./hooks/useConfig"); -jest.mock("./hooks/usePortalAccessToken"); +jest.mock("./Platform/Hosted/Helpers/PortalAccessData"); jest.mock("./Platform/Hosted/Components/ConnectExplorer"); jest.mock("./Shared/appInsights"); jest.mock("./Platform/Hosted/Components/AccountSwitcher", () => ({ @@ -27,13 +27,13 @@ import { act, render } from "@testing-library/react"; import React from "react"; import { useAADAuth } from "./hooks/useAADAuth"; import { useConfig } from "./hooks/useConfig"; -import { useTokenMetadata } from "./hooks/usePortalAccessToken"; import { App } from "./HostedExplorer"; import { ConnectExplorer, fetchEncryptedToken, validateDirectConnectionStringConnectivity, } from "./Platform/Hosted/Components/ConnectExplorer"; +import { fetchAccessData } from "./Platform/Hosted/Helpers/PortalAccessData"; const mockFetchEncryptedToken = fetchEncryptedToken as jest.MockedFunction; const mockValidateDirectConnectionStringConnectivity = @@ -60,7 +60,7 @@ beforeEach(() => { jest.clearAllMocks(); (useAADAuth as jest.Mock).mockReturnValue(defaultAADAuth); (useConfig as jest.Mock).mockReturnValue({}); - (useTokenMetadata as jest.Mock).mockReturnValue(undefined); + (fetchAccessData as jest.Mock).mockResolvedValue(undefined); mockFetchEncryptedToken.mockResolvedValue("encrypted-token"); mockValidateDirectConnectionStringConnectivity.mockResolvedValue(undefined); }); @@ -75,7 +75,7 @@ const FAKE_ACCOUNT_NAME: string = "-FakeAccount-"; const FAKE_KEY: string = ""; describe("HostedExplorer tryCosmosDB postMessage handler", () => { - it("signs a valid SQL connection string client-side without calling the backend", async () => { + it("accepts a valid SQL connection string from an allowed origin", async () => { render(); const validConnStr = `AccountEndpoint=https://${FAKE_ACCOUNT_NAME}.documents.azure.com:443/;AccountKey=${FAKE_KEY};`; @@ -124,7 +124,7 @@ describe("HostedExplorer tryCosmosDB postMessage handler", () => { expect(mockFetchEncryptedToken).toHaveBeenCalledWith(cassandraConnStr); }); - it("signs a valid Table connection string client-side without calling the backend", async () => { + it("accepts a valid Table connection string from an allowed origin", async () => { render(); const tableConnStr = `DefaultEndpointsProtocol=https;AccountName=${FAKE_ACCOUNT_NAME};AccountKey=${FAKE_KEY};TableEndpoint=https://${FAKE_ACCOUNT_NAME}.table.cosmosdb.azure.com:443/;`; @@ -141,7 +141,7 @@ describe("HostedExplorer tryCosmosDB postMessage handler", () => { expect(mockValidateDirectConnectionStringConnectivity).toHaveBeenCalled(); }); - it("signs a valid Gremlin connection string client-side without calling the backend", async () => { + it("accepts a valid Gremlin connection string from an allowed origin", async () => { render(); const gremlinConnStr = `AccountEndpoint=https://${FAKE_ACCOUNT_NAME}.documents.azure.com:443/;AccountKey=${FAKE_KEY};ApiKind=Gremlin;`; @@ -158,7 +158,7 @@ describe("HostedExplorer tryCosmosDB postMessage handler", () => { expect(mockValidateDirectConnectionStringConnectivity).toHaveBeenCalled(); }); - it("does not open the Data Explorer when the Cosmos client cannot connect", async () => { + it("does not open Data Explorer when the Cosmos client cannot connect", async () => { mockValidateDirectConnectionStringConnectivity.mockRejectedValue(new Error("Unable to connect to the account.")); const { container } = render(); diff --git a/src/HostedExplorer.tsx b/src/HostedExplorer.tsx index 5b4ecb027..79cc39b0c 100644 --- a/src/HostedExplorer.tsx +++ b/src/HostedExplorer.tsx @@ -23,13 +23,13 @@ import { MeControl } from "./Platform/Hosted/Components/MeControl"; import { SignInButton } from "./Platform/Hosted/Components/SignInButton"; import "./Platform/Hosted/ConnectScreen.less"; import { parseConnectionString } from "./Platform/Hosted/Helpers/ConnectionStringParser"; +import { fetchAccessData } from "./Platform/Hosted/Helpers/PortalAccessData"; import { isResourceTokenConnectionString } from "./Platform/Hosted/Helpers/ResourceTokenUtils"; import { extractMasterKeyfromConnectionString, isDirectConnectionStringLoginApi } from "./Platform/Hosted/HostedUtils"; import "./Shared/appInsights"; import { allowedHostedExplorerEndpoints } from "./Utils/EndpointUtils"; import { useAADAuth } from "./hooks/useAADAuth"; import { useConfig } from "./hooks/useConfig"; -import { useTokenMetadata } from "./hooks/usePortalAccessToken"; initializeIcons(); @@ -37,7 +37,15 @@ const App: React.FunctionComponent = () => { // For handling encrypted portal tokens sent via query paramter const params = new URLSearchParams(window.location.search); const [encryptedToken, setEncryptedToken] = React.useState(params && params.get("key")); - const encryptedTokenMetadata = useTokenMetadata(encryptedToken); + // Encrypted token logins resolve the account metadata through the Portal Backend, while SQL/Tables/Gremlin + // connection-string logins derive it client-side, so both paths write to the same value. + const [accountMetadata, setAccountMetadata] = React.useState(); + + React.useEffect(() => { + if (encryptedToken) { + fetchAccessData(encryptedToken).then(setAccountMetadata); + } + }, [encryptedToken]); // For showing/hiding panel const [isOpen, { setTrue: openPanel, setFalse: dismissPanel }] = useBoolean(false); @@ -49,10 +57,6 @@ const App: React.FunctionComponent = () => { const [authType, setAuthType] = React.useState(encryptedToken ? AuthType.EncryptedToken : undefined); const [connectionString, setConnectionString] = React.useState(); const [errorMessage, setErrorMessage] = React.useState(); - // For SQL/Tables/Gremlin connection-string login, the account metadata is derived client-side from the - // connection string instead of the Portal Backend, so there is no encrypted token. - const [directLoginMetadata, setDirectLoginMetadata] = React.useState(); - const accountMetadata = encryptedTokenMetadata || directLoginMetadata; const ref = React.useRef(); @@ -74,7 +78,7 @@ const App: React.FunctionComponent = () => { // the Portal Backend proxy and use the metadata derived from the connection string directly. validateDirectConnectionStringConnectivity(connStr, metadata) .then(() => { - setDirectLoginMetadata(metadata); + setAccountMetadata(metadata); setAuthType(AuthType.ConnectionString); }) .catch((error) => { @@ -148,7 +152,7 @@ const App: React.FunctionComponent = () => { frameWindow.hostedConfig = { authType: AuthType.EncryptedToken, encryptedToken, - encryptedTokenMetadata, + encryptedTokenMetadata: accountMetadata, }; } else if (authType === AuthType.ConnectionString) { frameWindow.hostedConfig = { @@ -222,9 +226,7 @@ const App: React.FunctionComponent = () => { // It's possible this can be changed once all knockout code has been removed. )} - {!isLoggedIn && !encryptedTokenMetadata && ( + {!isLoggedIn && !accountMetadata && ( { setAuthType, connectionString, setConnectionString, - setDirectLoginMetadata, + setAccountMetadata, errorMessage, setErrorMessage, }} diff --git a/src/HostedExplorerChildFrame.ts b/src/HostedExplorerChildFrame.ts index ef147ab32..dc5c3b256 100644 --- a/src/HostedExplorerChildFrame.ts +++ b/src/HostedExplorerChildFrame.ts @@ -15,8 +15,8 @@ export interface AAD { export interface ConnectionString { authType: AuthType.ConnectionString; // SQL, Tables, and Gremlin sign data-plane requests client-side with the master key and do not need the - // backend proxy, so they carry no encrypted token. Mongo and Cassandra still use the encrypted - // token because their operations go through the backend proxy. + // proxies, so they carry no encrypted token. Mongo and Cassandra still use the encrypted + // token because their operations go through the proxies. encryptedToken?: string; encryptedTokenMetadata: AccessInputMetadata; // Master key is used for the client-side signing path (SQL, Tables, Gremlin). Mongo/Cassandra leave it undefined. diff --git a/src/Localization/en/Resources.json b/src/Localization/en/Resources.json index 372bb6512..2844e2644 100644 --- a/src/Localization/en/Resources.json +++ b/src/Localization/en/Resources.json @@ -1177,7 +1177,7 @@ }, "connectExplorer": { "errors": { - "connectFailed": "Failed to connect using the provided connection string. Please verify it is correct and try again." + "connectFailed": "Failed to connect using the provided connection string. Please verify it is correct and that the account is reachable, then try again." } } } diff --git a/src/Platform/Hosted/Components/ConnectExplorer.test.tsx b/src/Platform/Hosted/Components/ConnectExplorer.test.tsx index 10ca86e72..bea5992e1 100644 --- a/src/Platform/Hosted/Components/ConnectExplorer.test.tsx +++ b/src/Platform/Hosted/Components/ConnectExplorer.test.tsx @@ -20,7 +20,7 @@ it("shows the connect form", () => { const setConnectionString = jest.fn(); const setEncryptedToken = jest.fn(); const setAuthType = jest.fn(); - const setDirectLoginMetadata = jest.fn(); + const setAccountMetadata = jest.fn(); const setErrorMessage = jest.fn(); render( @@ -31,7 +31,7 @@ it("shows the connect form", () => { setAuthType, connectionString, setConnectionString, - setDirectLoginMetadata, + setAccountMetadata, setErrorMessage, }} />, @@ -47,7 +47,7 @@ it("hides the connection string link when feature.disableConnectionStringLogin i const setConnectionString = jest.fn(); const setEncryptedToken = jest.fn(); const setAuthType = jest.fn(); - const setDirectLoginMetadata = jest.fn(); + const setAccountMetadata = jest.fn(); const setErrorMessage = jest.fn(); const oldFeatures = userContext.features; @@ -66,7 +66,7 @@ it("hides the connection string link when feature.disableConnectionStringLogin i setAuthType, connectionString, setConnectionString, - setDirectLoginMetadata, + setAccountMetadata, setErrorMessage, }} />, diff --git a/src/Platform/Hosted/Components/ConnectExplorer.tsx b/src/Platform/Hosted/Components/ConnectExplorer.tsx index f83b18e8e..1a5dca789 100644 --- a/src/Platform/Hosted/Components/ConnectExplorer.tsx +++ b/src/Platform/Hosted/Components/ConnectExplorer.tsx @@ -22,7 +22,7 @@ interface Props { setEncryptedToken: (token: string) => void; setConnectionString: (connectionString: string) => void; setAuthType: (authType: AuthType) => void; - setDirectLoginMetadata: (metadata: AccessInputMetadata) => void; + setAccountMetadata: (metadata: AccessInputMetadata) => void; errorMessage?: string; setErrorMessage: (message: string) => void; } @@ -99,7 +99,7 @@ export const ConnectExplorer: React.FunctionComponent = ({ setAuthType, connectionString, setConnectionString, - setDirectLoginMetadata, + setAccountMetadata, errorMessage, setErrorMessage, }: Props) => { @@ -144,7 +144,7 @@ export const ConnectExplorer: React.FunctionComponent = ({ // we skip the Portal Backend proxy and use the metadata parsed from the connection string. try { await validateDirectConnectionStringConnectivity(connectionString, metadata); - setDirectLoginMetadata(metadata); + setAccountMetadata(metadata); setAuthType(AuthType.ConnectionString); } catch (error) { setErrorMessage(getErrorMessage(error)); diff --git a/src/hooks/usePortalAccessToken.tsx b/src/Platform/Hosted/Helpers/PortalAccessData.ts similarity index 56% rename from src/hooks/usePortalAccessToken.tsx rename to src/Platform/Hosted/Helpers/PortalAccessData.ts index 4740d699e..ac271f17e 100644 --- a/src/hooks/usePortalAccessToken.tsx +++ b/src/Platform/Hosted/Helpers/PortalAccessData.ts @@ -1,7 +1,6 @@ -import { useEffect, useState } from "react"; -import { HttpHeaders } from "../Common/Constants"; -import { configContext } from "../ConfigContext"; -import { AccessInputMetadata } from "../Contracts/DataModels"; +import { HttpHeaders } from "../../../Common/Constants"; +import { configContext } from "../../../ConfigContext"; +import { AccessInputMetadata } from "../../../Contracts/DataModels"; export async function fetchAccessData(portalToken: string): Promise { const headers = new Headers(); @@ -18,14 +17,3 @@ export async function fetchAccessData(portalToken: string): Promise response.json()) .catch((error) => console.error(error)); } - -export function useTokenMetadata(token: string): AccessInputMetadata | undefined { - const [state, setState] = useState(); - - useEffect(() => { - if (token) { - fetchAccessData(token).then((response) => setState(response)); - } - }, [token]); - return state; -} diff --git a/tsconfig.strict.json b/tsconfig.strict.json index e1021f162..47d6c9c41 100644 --- a/tsconfig.strict.json +++ b/tsconfig.strict.json @@ -66,6 +66,7 @@ "./src/Platform/Hosted/Components/MeControl.test.tsx", "./src/Platform/Hosted/Components/MeControl.tsx", "./src/Platform/Hosted/Components/SignInButton.tsx", + "./src/Platform/Hosted/Helpers/PortalAccessData.ts", "./src/Platform/Hosted/HostedUtils.test.ts", "./src/Platform/Hosted/HostedUtils.ts", "./src/Platform/Hosted/extractFeatures.test.ts", @@ -107,7 +108,6 @@ "./src/hooks/useDirectories.tsx", "./src/hooks/useGraphPhoto.tsx", "./src/hooks/useNotebookSnapshotStore.ts", - "./src/hooks/usePortalAccessToken.tsx", "./src/hooks/useNotificationConsole.ts", "./src/hooks/useObservable.ts", "./src/hooks/useSidePanel.ts",