mirror of
https://github.com/Azure/cosmos-explorer.git
synced 2026-09-06 13:48:45 +01:00
a825a7ddc5
* Support sovereign/PPE endpoint zones for connection string login Move the hardcoded account endpoint suffixes out of Constants and into ConfigContext so connection string login works in sovereign clouds and PPE, and widen ConnectionStringParser to accept every configured zone. Surface Portal Backend rejections in the hosted connect form: read the body off the Response that fetchEncryptedToken throws, and offer a firewall help link on a 403. Add an E2E test covering SQL connection string login against an account with public network access disabled. * Harden connection string DNS zone matching Detect PPE accounts from Mongo and Cassandra connection strings, build their document endpoint from the matched zone, match the PPE suffix on a label boundary, and escape every regex metacharacter in config-supplied zones. Drop the sqlx.cosmosdb.azure.com zone, which is not a real SQL zone. * Mock web-vitals globally in test setup ScenarioMonitor subscribes to web-vitals when it is imported, so the onTTFB timer can throw during any suite that outlives it. Mocking it in setupTests.ts keeps that out of individual test files. * Improve connection string login feedback * Preserve connection restriction check order * Flatten connection login error handling --------- Co-authored-by: Asier Isayas <aisayas@microsoft.com>
248 lines
9.7 KiB
TypeScript
248 lines
9.7 KiB
TypeScript
import { initializeIcons } from "@fluentui/react";
|
|
import { useBoolean } from "@fluentui/react-hooks";
|
|
import { AadAuthorizationFailure } from "Platform/Hosted/Components/AadAuthorizationFailure";
|
|
import * as React from "react";
|
|
import { render } from "react-dom";
|
|
import ChevronRight from "../images/chevron-right.svg";
|
|
import "../less/hostedexplorer.less";
|
|
import { AuthType } from "./AuthType";
|
|
import { logError } from "./Common/Logger";
|
|
import { fetchAccessData, fetchEncryptedToken } from "./Common/PortalBackendClient";
|
|
import { AccessInputMetadata, DatabaseAccount } from "./Contracts/DataModels";
|
|
import "./Explorer/Menus/NavBar/MeControlComponent.less";
|
|
import { HostedExplorerChildFrame } from "./HostedExplorerChildFrame";
|
|
import { AccountSwitcher } from "./Platform/Hosted/Components/AccountSwitcher";
|
|
import { ConnectExplorer } from "./Platform/Hosted/Components/ConnectExplorer";
|
|
import { DirectoryPickerPanel } from "./Platform/Hosted/Components/DirectoryPickerPanel";
|
|
import { FeedbackCommandButton } from "./Platform/Hosted/Components/FeedbackCommandButton";
|
|
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 { isResourceTokenConnectionString } from "./Platform/Hosted/Helpers/ResourceTokenUtils";
|
|
import {
|
|
extractMasterKeyFromDirectLoginConnectionString,
|
|
isDirectConnectionStringLoginApi,
|
|
} from "./Platform/Hosted/HostedUtils";
|
|
import "./Shared/appInsights";
|
|
import { allowedHostedExplorerEndpoints } from "./Utils/EndpointUtils";
|
|
import { useAADAuth } from "./hooks/useAADAuth";
|
|
import { useConfig } from "./hooks/useConfig";
|
|
|
|
initializeIcons();
|
|
|
|
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"));
|
|
// Encrypted token logins resolve the account metadata through the Portal Backend, while SQL/Table/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);
|
|
const config = useConfig();
|
|
const { isLoggedIn, armToken, graphToken, account, tenantId, logout, login, switchTenant, authFailure } =
|
|
useAADAuth(config);
|
|
|
|
const [databaseAccount, setDatabaseAccount] = React.useState<DatabaseAccount>();
|
|
const [authType, setAuthType] = React.useState<AuthType>(encryptedToken ? AuthType.EncryptedToken : undefined);
|
|
const [connectionString, setConnectionString] = React.useState<string>();
|
|
|
|
const ref = React.useRef<HTMLIFrameElement>();
|
|
|
|
const connectWithConnectionString = React.useCallback(
|
|
(connStr: string) => {
|
|
if (!connStr || authType) {
|
|
return;
|
|
}
|
|
setConnectionString(connStr);
|
|
if (isResourceTokenConnectionString(connStr)) {
|
|
setAuthType(AuthType.ResourceToken);
|
|
return;
|
|
}
|
|
|
|
const metadata = parseConnectionString(connStr);
|
|
if (metadata && isDirectConnectionStringLoginApi(metadata.apiKind)) {
|
|
// SQL, Table, and Gremlin sign data-plane requests client-side with the account key, so we skip
|
|
// the Portal Backend proxy and use the metadata derived from the connection string directly.
|
|
setAccountMetadata(metadata);
|
|
setAuthType(AuthType.ConnectionString);
|
|
return;
|
|
}
|
|
|
|
fetchEncryptedToken(connStr)
|
|
.then((token) => {
|
|
setEncryptedToken(token);
|
|
setAuthType(AuthType.ConnectionString);
|
|
})
|
|
.catch(async (error) => {
|
|
logError(
|
|
`Failed to connect with connection string: ${await (error as Response).text()}`,
|
|
"HostedExplorer/connectWithConnectionString",
|
|
);
|
|
});
|
|
},
|
|
[authType],
|
|
);
|
|
|
|
// Listen for connection string sent via postMessage (from TryCosmosDB)
|
|
React.useEffect(() => {
|
|
const MSG_READY = "tryCosmosDBReady";
|
|
const MSG_CONNECTION_STRING = "tryCosmosDBConnectionString";
|
|
|
|
// Signal to the opener that we are ready to receive a connection string
|
|
if (window.opener) {
|
|
try {
|
|
for (const origin of allowedHostedExplorerEndpoints) {
|
|
window.opener.postMessage({ type: MSG_READY }, origin);
|
|
}
|
|
} catch {
|
|
// opener may be cross-origin, ignore
|
|
}
|
|
}
|
|
|
|
const handler = (event: MessageEvent) => {
|
|
if (!allowedHostedExplorerEndpoints.includes(event.origin)) {
|
|
return;
|
|
}
|
|
if (event.data?.type === MSG_CONNECTION_STRING) {
|
|
const connStr: string = event.data.connectionString;
|
|
if (parseConnectionString(connStr)) {
|
|
connectWithConnectionString(connStr);
|
|
}
|
|
}
|
|
};
|
|
|
|
window.addEventListener("message", handler);
|
|
return () => window.removeEventListener("message", handler);
|
|
}, [connectWithConnectionString]);
|
|
|
|
React.useEffect(() => {
|
|
// If ref.current is undefined no iframe has been rendered
|
|
if (ref.current) {
|
|
// In hosted mode, we can set global properties directly on the child iframe.
|
|
// This is not possible in the portal where the iframes have different origins
|
|
const frameWindow = ref.current.contentWindow as HostedExplorerChildFrame;
|
|
|
|
if (authType === AuthType.EncryptedToken) {
|
|
frameWindow.hostedConfig = {
|
|
authType: AuthType.EncryptedToken,
|
|
encryptedToken,
|
|
encryptedTokenMetadata: accountMetadata,
|
|
};
|
|
} else if (authType === AuthType.ConnectionString) {
|
|
frameWindow.hostedConfig = {
|
|
authType: AuthType.ConnectionString,
|
|
encryptedToken,
|
|
encryptedTokenMetadata: accountMetadata,
|
|
masterKey: extractMasterKeyFromDirectLoginConnectionString(connectionString),
|
|
};
|
|
} else if (authType === AuthType.ResourceToken) {
|
|
frameWindow.hostedConfig = {
|
|
authType: AuthType.ResourceToken,
|
|
resourceToken: connectionString,
|
|
};
|
|
} else if (isLoggedIn && !connectionString) {
|
|
frameWindow.hostedConfig = {
|
|
authType: AuthType.AAD,
|
|
databaseAccount,
|
|
authorizationToken: armToken,
|
|
};
|
|
}
|
|
}
|
|
});
|
|
|
|
const showExplorer =
|
|
(config && isLoggedIn && databaseAccount && !connectionString) ||
|
|
accountMetadata ||
|
|
(authType === AuthType.ResourceToken && connectionString);
|
|
|
|
return (
|
|
<>
|
|
<header>
|
|
<div className="items" role="menubar">
|
|
<div className="cosmosDBTitle">
|
|
<span
|
|
className="title"
|
|
onClick={() => window.open("https://portal.azure.com", "_blank")}
|
|
tabIndex={0}
|
|
title="Go to Azure Portal"
|
|
>
|
|
Microsoft Azure
|
|
</span>
|
|
<span className="accontSplitter" /> <span className="serviceTitle">Cosmos DB</span>
|
|
{(isLoggedIn || accountMetadata?.accountName) && (
|
|
<img className="chevronRight" src={ChevronRight} alt="account separator" />
|
|
)}
|
|
{isLoggedIn && !connectionString && (
|
|
<span className="accountSwitchComponentContainer">
|
|
<AccountSwitcher armToken={armToken} setDatabaseAccount={setDatabaseAccount} />
|
|
</span>
|
|
)}
|
|
{(!isLoggedIn || connectionString) && accountMetadata?.accountName && (
|
|
<span className="accountSwitchComponentContainer">
|
|
<span className="accountNameHeader">{accountMetadata?.accountName}</span>
|
|
</span>
|
|
)}
|
|
</div>
|
|
<FeedbackCommandButton />
|
|
<div className="meControl">
|
|
{isLoggedIn ? (
|
|
<MeControl {...{ graphToken, openPanel, logout, account }} />
|
|
) : (
|
|
<SignInButton {...{ login }} />
|
|
)}
|
|
</div>
|
|
</div>
|
|
</header>
|
|
{showExplorer && (
|
|
// Ideally we would import and render data explorer like any other React component, however
|
|
// because it still has a significant amount of Knockout code, this would lead to memory leaks.
|
|
// Knockout does not have a way to tear down all of its binding and listeners with a single method.
|
|
// 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}-${accountMetadata?.accountName || connectionString}` : databaseAccount?.id}
|
|
ref={ref}
|
|
data-test="DataExplorerFrame"
|
|
id="explorerMenu"
|
|
name="explorer"
|
|
className="iframe"
|
|
title="explorer"
|
|
src="explorer.html?v=1.0.1&platform=Hosted"
|
|
></iframe>
|
|
)}
|
|
{!isLoggedIn && !accountMetadata && (
|
|
<ConnectExplorer
|
|
{...{
|
|
login,
|
|
setEncryptedToken,
|
|
setAuthType,
|
|
connectionString,
|
|
setConnectionString,
|
|
setAccountMetadata,
|
|
}}
|
|
/>
|
|
)}
|
|
{isLoggedIn && authFailure && <AadAuthorizationFailure {...{ authFailure }} />}
|
|
{isLoggedIn && !authFailure && (
|
|
<DirectoryPickerPanel {...{ isOpen, dismissPanel, armToken, tenantId, switchTenant }} />
|
|
)}
|
|
</>
|
|
);
|
|
};
|
|
|
|
export { App };
|
|
|
|
const appElement = document.getElementById("App");
|
|
if (appElement) {
|
|
render(<App />, appElement);
|
|
}
|