mirror of
https://github.com/Azure/cosmos-explorer.git
synced 2026-09-19 17:12:47 +01:00
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:
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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
@@ -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,
|
||||
}}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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));
|
||||
|
||||
+3
-15
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user