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.
This commit is contained in:
Asier Isayas
2026-08-18 10:04:39 -07:00
parent f03539d713
commit b4a8e78fed
9 changed files with 38 additions and 48 deletions
+2 -2
View File
@@ -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);
}
+7 -7
View File
@@ -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<typeof fetchEncryptedToken>;
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 = "<redacted-test-key>";
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(<App />);
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(<App />);
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(<App />);
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(<App />);
+15 -13
View File
@@ -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<string>(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<AccessInputMetadata>();
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<AuthType>(encryptedToken ? AuthType.EncryptedToken : undefined);
const [connectionString, setConnectionString] = React.useState<string>();
const [errorMessage, setErrorMessage] = React.useState<string>();
// 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<AccessInputMetadata>();
const accountMetadata = encryptedTokenMetadata || directLoginMetadata;
const ref = React.useRef<HTMLIFrameElement>();
@@ -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.
<iframe
// Setting key is needed so React will re-render this element on any account change
key={
authType ? `${authType}-${encryptedTokenMetadata?.accountName || connectionString}` : databaseAccount?.id
}
key={authType ? `${authType}-${accountMetadata?.accountName || connectionString}` : databaseAccount?.id}
ref={ref}
data-test="DataExplorerFrame"
id="explorerMenu"
@@ -234,7 +236,7 @@ const App: React.FunctionComponent = () => {
src="explorer.html?v=1.0.1&platform=Hosted"
></iframe>
)}
{!isLoggedIn && !encryptedTokenMetadata && (
{!isLoggedIn && !accountMetadata && (
<ConnectExplorer
{...{
login,
@@ -242,7 +244,7 @@ const App: React.FunctionComponent = () => {
setAuthType,
connectionString,
setConnectionString,
setDirectLoginMetadata,
setAccountMetadata,
errorMessage,
setErrorMessage,
}}
+2 -2
View File
@@ -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.
+1 -1
View File
@@ -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."
}
}
}
@@ -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,
}}
/>,
@@ -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<Props> = ({
setAuthType,
connectionString,
setConnectionString,
setDirectLoginMetadata,
setAccountMetadata,
errorMessage,
setErrorMessage,
}: Props) => {
@@ -144,7 +144,7 @@ export const ConnectExplorer: React.FunctionComponent<Props> = ({
// 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));
@@ -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<AccessInputMetadata> {
const headers = new Headers();
@@ -18,14 +17,3 @@ export async function fetchAccessData(portalToken: string): Promise<AccessInputM
.then((response) => response.json())
.catch((error) => console.error(error));
}
export function useTokenMetadata(token: string): AccessInputMetadata | undefined {
const [state, setState] = useState<AccessInputMetadata | undefined>();
useEffect(() => {
if (token) {
fetchAccessData(token).then((response) => setState(response));
}
}, [token]);
return state;
}