Compare commits

...

16 Commits

Author SHA1 Message Date
Laurent Nguyen
88e47403d8 Replace datalist with fluentui menu 2024-07-31 16:17:49 +02:00
Laurent Nguyen
582d581edf Fix unit tests 2024-07-23 12:09:56 +02:00
Laurent Nguyen
f65e7ab1d6 Disable saving middle split position for now 2024-07-23 11:05:04 +02:00
Laurent Nguyen
bf1474e101 Make datalist for filter unique per database/container combination 2024-07-23 10:09:01 +02:00
Laurent Nguyen
4fda518a3f Replace read/save methods with more generic ones 2024-07-22 17:16:05 +02:00
Laurent Nguyen
9aa55aa4e5 Save filters 2024-07-22 16:21:26 +02:00
Laurent Nguyen
96b2ef728a Infrastructure to save app state 2024-07-22 12:17:56 +02:00
sindhuba
5871c1e2d0 Add more logs for RBAC (#1906)
* Fix API endpoint for CassandraProxy query API

* activate Mongo Proxy and Cassandra Proxy in Prod

* Add CP Prod endpoint

* Run npm format and tests

* Revert code

* fix bug that blocked local mongo proxy and cassandra proxy development

* Add prod endpoint

* fix pr check tests

* Remove prod

* Remove prod endpoint

* Remove dev endpoint

* Support data plane RBAC

* Support data plane RBAC

* Add additional changes for Portal RBAC functionality

* Remove unnecessary code

* Remove unnecessary code

* Add code to fix VCoreMongo/PG bug

* Address feedback

* Add more logs for RBAC feature

* Add more logs for RBAC features

---------

Co-authored-by: Asier Isayas <aisayas@microsoft.com>
2024-07-10 10:16:05 -07:00
sindhuba
81dccbe5be Fix vCoreMongo/PostGres quickstart bug (#1903)
* Fix API endpoint for CassandraProxy query API

* activate Mongo Proxy and Cassandra Proxy in Prod

* Add CP Prod endpoint

* Run npm format and tests

* Revert code

* fix bug that blocked local mongo proxy and cassandra proxy development

* Add prod endpoint

* fix pr check tests

* Remove prod

* Remove prod endpoint

* Remove dev endpoint

* Support data plane RBAC

* Support data plane RBAC

* Add additional changes for Portal RBAC functionality

* Remove unnecessary code

* Remove unnecessary code

* Add code to fix VCoreMongo/PG bug

* Address feedback

---------

Co-authored-by: Asier Isayas <aisayas@microsoft.com>
2024-07-09 10:58:13 -07:00
vchske
49c3d0f0cb Reorder Asier's commits in order to deploy CRI fixes (#1905)
* Set AllowPartialScopes flag to true (#1900)

* add partial scopes flag

* add partial scopes flag

* add partial scopes flag

---------

Co-authored-by: Asier Isayas <aisayas@microsoft.com>

* Adding CRI fixes to pre RBAC commit (#1902)

Co-authored-by: Asier Isayas <aisayas@microsoft.com>

* Add Data Plane RBAC functionality (#1878)

* Fix API endpoint for CassandraProxy query API

* activate Mongo Proxy and Cassandra Proxy in Prod

* Add CP Prod endpoint

* Run npm format and tests

* Revert code

* fix bug that blocked local mongo proxy and cassandra proxy development

* Add prod endpoint

* fix pr check tests

* Remove prod

* Remove prod endpoint

* Remove dev endpoint

* Support data plane RBAC

* Support data plane RBAC

* Add additional changes for Portal RBAC functionality

* Address errors and checks

* Cleanup DP RBAC code

* Run format

* Fix unit tests

* Remove unnecessary code

* Run npm format

* Fix enableAadDataPlane feature flag behavior

* Fix  enable AAD dataplane feature flag behavior

* Address feedback comments

* Minor fix

* Add new fixes

* Fix Tables test

* Run npm format

---------

Co-authored-by: Asier Isayas <aisayas@microsoft.com>

* Fix LMS regression when using old backend (#1890)

Co-authored-by: Asier Isayas <aisayas@microsoft.com>

* Address RBAC local storage default setting issue (#1892)

* Fix API endpoint for CassandraProxy query API

* activate Mongo Proxy and Cassandra Proxy in Prod

* Add CP Prod endpoint

* Run npm format and tests

* Revert code

* fix bug that blocked local mongo proxy and cassandra proxy development

* Add prod endpoint

* fix pr check tests

* Remove prod

* Remove prod endpoint

* Remove dev endpoint

* Support data plane RBAC

* Support data plane RBAC

* Add additional changes for Portal RBAC functionality

* Address errors and checks

* Cleanup DP RBAC code

* Run format

* Fix unit tests

* Remove unnecessary code

* Run npm format

* Fix enableAadDataPlane feature flag behavior

* Fix  enable AAD dataplane feature flag behavior

* Address feedback comments

* Minor fix

* Add new fixes

* Fix Tables test

* Run npm format

* Address Local storage default setting issue

* Run npm format

* Address lint error

* Run format

---------

Co-authored-by: Asier Isayas <aisayas@microsoft.com>

* Fix bug in viewing tables account databases (#1899)

* Fix API endpoint for CassandraProxy query API

* activate Mongo Proxy and Cassandra Proxy in Prod

* Add CP Prod endpoint

* Run npm format and tests

* Revert code

* fix bug that blocked local mongo proxy and cassandra proxy development

* Add prod endpoint

* fix pr check tests

* Remove prod

* Remove prod endpoint

* Remove dev endpoint

* Support data plane RBAC

* Support data plane RBAC

* Add additional changes for Portal RBAC functionality

* Address errors and checks

* Cleanup DP RBAC code

* Run format

* Fix unit tests

* Remove unnecessary code

* Run npm format

* Fix enableAadDataPlane feature flag behavior

* Fix  enable AAD dataplane feature flag behavior

* Address feedback comments

* Minor fix

* Add new fixes

* Fix Tables test

* Run npm format

* Address Local storage default setting issue

* Run npm format

* Address lint error

* Run format

* Address bug in fetching data for Tables Account

* Add fetchAndUpdate Keys

* Add fix for MPAC Tables account issue

* Fix issue with Cosmos Client

* Run np format

* Address bugs

* Remove unused import

---------

Co-authored-by: Asier Isayas <aisayas@microsoft.com>

---------

Co-authored-by: Asier Isayas <asier.isayas@gmail.com>
Co-authored-by: Asier Isayas <aisayas@microsoft.com>
Co-authored-by: sindhuba <122321535+sindhuba@users.noreply.github.com>
2024-07-09 12:27:57 -04:00
Asier Isayas
375bb5f567 Adding CRI fixes to pre RBAC commit (#1902)
Co-authored-by: Asier Isayas <aisayas@microsoft.com>
2024-07-08 17:27:34 -04:00
Asier Isayas
e9f83a8efd Set AllowPartialScopes flag to true (#1900)
* add partial scopes flag

* add partial scopes flag

* add partial scopes flag

---------

Co-authored-by: Asier Isayas <aisayas@microsoft.com>
2024-07-08 16:30:14 -04:00
sindhuba
093ddba2db Fix bug in viewing tables account databases (#1899)
* Fix API endpoint for CassandraProxy query API

* activate Mongo Proxy and Cassandra Proxy in Prod

* Add CP Prod endpoint

* Run npm format and tests

* Revert code

* fix bug that blocked local mongo proxy and cassandra proxy development

* Add prod endpoint

* fix pr check tests

* Remove prod

* Remove prod endpoint

* Remove dev endpoint

* Support data plane RBAC

* Support data plane RBAC

* Add additional changes for Portal RBAC functionality

* Address errors and checks

* Cleanup DP RBAC code

* Run format

* Fix unit tests

* Remove unnecessary code

* Run npm format

* Fix enableAadDataPlane feature flag behavior

* Fix  enable AAD dataplane feature flag behavior

* Address feedback comments

* Minor fix

* Add new fixes

* Fix Tables test

* Run npm format

* Address Local storage default setting issue

* Run npm format

* Address lint error

* Run format

* Address bug in fetching data for Tables Account

* Add fetchAndUpdate Keys

* Add fix for MPAC Tables account issue

* Fix issue with Cosmos Client

* Run np format

* Address bugs

* Remove unused import

---------

Co-authored-by: Asier Isayas <aisayas@microsoft.com>
2024-07-08 08:48:22 -07:00
sindhuba
dfe79b20f5 Address RBAC local storage default setting issue (#1892)
* Fix API endpoint for CassandraProxy query API

* activate Mongo Proxy and Cassandra Proxy in Prod

* Add CP Prod endpoint

* Run npm format and tests

* Revert code

* fix bug that blocked local mongo proxy and cassandra proxy development

* Add prod endpoint

* fix pr check tests

* Remove prod

* Remove prod endpoint

* Remove dev endpoint

* Support data plane RBAC

* Support data plane RBAC

* Add additional changes for Portal RBAC functionality

* Address errors and checks

* Cleanup DP RBAC code

* Run format

* Fix unit tests

* Remove unnecessary code

* Run npm format

* Fix enableAadDataPlane feature flag behavior

* Fix  enable AAD dataplane feature flag behavior

* Address feedback comments

* Minor fix

* Add new fixes

* Fix Tables test

* Run npm format

* Address Local storage default setting issue

* Run npm format

* Address lint error

* Run format

---------

Co-authored-by: Asier Isayas <aisayas@microsoft.com>
2024-07-01 16:58:05 -07:00
Asier Isayas
1021e9c969 Fix LMS regression when using old backend (#1890)
Co-authored-by: Asier Isayas <aisayas@microsoft.com>
2024-07-01 12:40:07 -04:00
sindhuba
c30a9681fe Add Data Plane RBAC functionality (#1878)
* Fix API endpoint for CassandraProxy query API

* activate Mongo Proxy and Cassandra Proxy in Prod

* Add CP Prod endpoint

* Run npm format and tests

* Revert code

* fix bug that blocked local mongo proxy and cassandra proxy development

* Add prod endpoint

* fix pr check tests

* Remove prod

* Remove prod endpoint

* Remove dev endpoint

* Support data plane RBAC

* Support data plane RBAC

* Add additional changes for Portal RBAC functionality

* Address errors and checks

* Cleanup DP RBAC code

* Run format

* Fix unit tests

* Remove unnecessary code

* Run npm format

* Fix enableAadDataPlane feature flag behavior

* Fix  enable AAD dataplane feature flag behavior

* Address feedback comments

* Minor fix

* Add new fixes

* Fix Tables test

* Run npm format

---------

Co-authored-by: Asier Isayas <aisayas@microsoft.com>
2024-07-01 09:33:07 -07:00
23 changed files with 788 additions and 80 deletions

9
images/EntraID.svg Normal file
View File

@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg id="uuid-f8d4d392-7c12-4bd9-baff-66fbf7814b91" data-name="Layer 1" xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 18 18">
<path d="m3.802,14.032c.388.242,1.033.511,1.715.511.621,0,1.198-.18,1.676-.487,0,0,.001,0,.002-.001l1.805-1.128v4.073c-.286,0-.574-.078-.824-.234l-4.374-2.734Z" fill="#225086"/>
<path d="m7.853,1.507L.353,9.967c-.579.654-.428,1.642.323,2.111,0,0,2.776,1.735,3.126,1.954.388.242,1.033.511,1.715.511.621,0,1.198-.18,1.676-.487,0,0,.001,0,.002-.001l1.805-1.128-4.364-2.728,4.365-4.924V1s0,0,0,0c-.424,0-.847.169-1.147.507Z" fill="#6df"/>
<polygon points="4.636 10.199 4.688 10.231 9 12.927 9.001 12.927 9.001 12.927 9.001 5.276 9 5.275 4.636 10.199" fill="#cbf8ff"/>
<path d="m17.324,12.078c.751-.469.902-1.457.323-2.111l-4.921-5.551c-.397-.185-.842-.291-1.313-.291-.925,0-1.752.399-2.302,1.026l-.109.123h0s4.364,4.924,4.364,4.924h0s0,0,0,0l-4.365,2.728v4.073c.287,0,.573-.078.823-.234l7.5-4.688Z" fill="#074793"/>
<path d="m9.001,1v4.275s.109-.123.109-.123c.55-.627,1.377-1.026,2.302-1.026.472,0,.916.107,1.313.291l-2.579-2.909c-.299-.338-.723-.507-1.146-.507Z" fill="#0294e4"/>
<polygon points="13.365 10.199 13.365 10.199 13.365 10.199 9.001 5.276 9.001 12.926 13.365 10.199" fill="#96bcc2"/>
</svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

View File

@@ -197,6 +197,12 @@ export class Queries {
public static readonly DefaultMaxWaitTimeInSeconds = 30;
}
export class RBACOptions {
public static setAutomaticRBACOption: string = "Automatic";
public static setTrueRBACOption: string = "True";
public static setFalseRBACOption: string = "False";
}
export class SavedQueries {
public static readonly CollectionName: string = "___Query";
public static readonly DatabaseName: string = "___Cosmos";

View File

@@ -28,19 +28,6 @@ describe("tokenProvider", () => {
afterEach(() => {
jest.restoreAllMocks();
});
it("calls the auth token service if no master key is set", async () => {
await tokenProvider(options);
expect((window.fetch as any).mock.calls.length).toBe(1);
});
it("does not call the auth service if a master key is set", async () => {
updateUserContext({
masterKey: "foo",
});
await tokenProvider(options);
expect((window.fetch as any).mock.calls.length).toBe(0);
});
});
describe("getTokenFromAuthService", () => {

View File

@@ -11,13 +11,27 @@ import { logConsoleError } from "../Utils/NotificationConsoleUtils";
import * as PriorityBasedExecutionUtils from "../Utils/PriorityBasedExecutionUtils";
import { EmulatorMasterKey, HttpHeaders } from "./Constants";
import { getErrorMessage } from "./ErrorHandlingUtils";
import * as Logger from "../Common/Logger";
const _global = typeof self === "undefined" ? window : self;
export const tokenProvider = async (requestInfo: Cosmos.RequestInfo) => {
const { verb, resourceId, resourceType, headers } = requestInfo;
if (userContext.features.enableAadDataPlane && userContext.aadToken) {
const aadDataPlaneFeatureEnabled =
userContext.features.enableAadDataPlane && userContext.databaseAccount.properties.disableLocalAuth;
const dataPlaneRBACOptionEnabled = userContext.dataPlaneRbacEnabled && userContext.apiType === "SQL";
if (aadDataPlaneFeatureEnabled || (!userContext.features.enableAadDataPlane && dataPlaneRBACOptionEnabled)) {
Logger.logInfo(
`AAD Data Plane Feature flag set to ${userContext.features.enableAadDataPlane} for account with disable local auth ${userContext.databaseAccount.properties.disableLocalAuth} `,
"Explorer/tokenProvider",
);
if (!userContext.aadToken) {
logConsoleError(
`AAD token does not exist. Please use "Login for Entra ID" prior to performing Entra ID RBAC operations`,
);
return null;
}
const AUTH_PREFIX = `type=aad&ver=1.0&sig=`;
const authorizationToken = `${AUTH_PREFIX}${userContext.aadToken}`;
return authorizationToken;
@@ -71,8 +85,15 @@ export const tokenProvider = async (requestInfo: Cosmos.RequestInfo) => {
}
if (userContext.masterKey) {
Logger.logInfo(`Master Key exists`, "Explorer/tokenProvider");
// TODO This SDK method mutates the headers object. Find a better one or fix the SDK.
await Cosmos.setAuthorizationTokenHeaderUsingMasterKey(verb, resourceId, resourceType, headers, EmulatorMasterKey);
await Cosmos.setAuthorizationTokenHeaderUsingMasterKey(
verb,
resourceId,
resourceType,
headers,
userContext.masterKey,
);
return decodeURIComponent(headers.authorization);
}
@@ -137,8 +158,11 @@ enum SDKSupportedCapabilities {
let _client: Cosmos.CosmosClient;
export function client(): Cosmos.CosmosClient {
if (_client) return _client;
if (_client) {
if (!userContext.hasDataPlaneRbacSettingChanged) {
return _client;
}
}
let _defaultHeaders: Cosmos.CosmosHeaders = {};
_defaultHeaders["x-ms-cosmos-sdk-supportedcapabilities"] =
SDKSupportedCapabilities.None | SDKSupportedCapabilities.PartitionMerge;
@@ -157,7 +181,7 @@ export function client(): Cosmos.CosmosClient {
const options: Cosmos.CosmosClientOptions = {
endpoint: endpoint() || "https://cosmos.azure.com", // CosmosClient gets upset if we pass a bad URL. This should never actually get called
key: userContext.masterKey,
key: userContext.dataPlaneRbacEnabled ? "" : userContext.masterKey,
tokenProvider,
userAgentSuffix: "Azure Portal",
defaultHeaders: _defaultHeaders,

View File

@@ -1,7 +1,8 @@
export interface QueryRequestOptions {
$skipToken?: string;
$top?: number;
subscriptions: string[];
$allowPartialScopes: boolean;
subscriptions?: string[];
}
export interface QueryResponse {

View File

@@ -7,11 +7,13 @@ import { getCopilotEnabled, isCopilotFeatureRegistered } from "Explorer/QueryCop
import { IGalleryItem } from "Juno/JunoClient";
import { scheduleRefreshDatabaseResourceToken } from "Platform/Fabric/FabricUtil";
import { LocalStorageUtility, StorageKey } from "Shared/StorageUtility";
import { acquireTokenWithMsal, getMsalInstance } from "Utils/AuthorizationUtils";
import { allowedNotebookServerUrls, validateEndpoint } from "Utils/EndpointUtils";
import { useQueryCopilot } from "hooks/useQueryCopilot";
import * as ko from "knockout";
import React from "react";
import _ from "underscore";
import * as msal from "@azure/msal-browser";
import shallow from "zustand/shallow";
import { AuthType } from "../AuthType";
import { BindingHandlersRegisterer } from "../Bindings/BindingHandlersRegisterer";
@@ -30,14 +32,13 @@ import { PhoenixClient } from "../Phoenix/PhoenixClient";
import * as ExplorerSettings from "../Shared/ExplorerSettings";
import { Action, ActionModifiers } from "../Shared/Telemetry/TelemetryConstants";
import * as TelemetryProcessor from "../Shared/Telemetry/TelemetryProcessor";
import { isAccountNewerThanThresholdInMs, userContext } from "../UserContext";
import { isAccountNewerThanThresholdInMs, updateUserContext, userContext } from "../UserContext";
import { getCollectionName, getUploadName } from "../Utils/APITypeUtils";
import { stringToBlob } from "../Utils/BlobUtils";
import { isCapabilityEnabled } from "../Utils/CapabilityUtils";
import { fromContentUri, toRawContentUri } from "../Utils/GitHubUtils";
import * as NotificationConsoleUtils from "../Utils/NotificationConsoleUtils";
import { logConsoleError, logConsoleInfo, logConsoleProgress } from "../Utils/NotificationConsoleUtils";
import { update } from "../Utils/arm/generatedClients/cosmos/databaseAccounts";
import { useSidePanel } from "../hooks/useSidePanel";
import { useTabs } from "../hooks/useTabs";
import "./ComponentRegisterer";
@@ -66,6 +67,8 @@ import { ResourceTreeAdapter } from "./Tree/ResourceTreeAdapter";
import StoredProcedure from "./Tree/StoredProcedure";
import { useDatabases } from "./useDatabases";
import { useSelectedNode } from "./useSelectedNode";
import { update } from "Utils/arm/generatedClients/cosmos/databaseAccounts";
import { useDataPlaneRbac } from "Explorer/Panes/SettingsPane/SettingsPane";
BindingHandlersRegisterer.registerBindingHandlers();
@@ -251,8 +254,44 @@ export default class Explorer {
};
useDialog.getState().openDialog(addSynapseLinkDialogProps);
TelemetryProcessor.traceStart(Action.EnableAzureSynapseLink);
}
// TODO: return result
public async openLoginForEntraIDPopUp(): Promise<void> {
if (userContext.databaseAccount.properties?.documentEndpoint) {
const hrefEndpoint = new URL(userContext.databaseAccount.properties.documentEndpoint).href.replace(
/\/$/,
"/.default",
);
const msalInstance = await getMsalInstance();
try {
const response = await msalInstance.loginPopup({
redirectUri: configContext.msalRedirectURI,
scopes: [],
});
localStorage.setItem("cachedTenantId", response.tenantId);
const cachedAccount = msalInstance.getAllAccounts()?.[0];
msalInstance.setActiveAccount(cachedAccount);
const aadToken = await acquireTokenWithMsal(msalInstance, {
forceRefresh: true,
scopes: [hrefEndpoint],
authority: `${configContext.AAD_ENDPOINT}${localStorage.getItem("cachedTenantId")}`,
});
updateUserContext({ aadToken: aadToken });
useDataPlaneRbac.setState({ aadTokenUpdated: true });
} catch (error) {
if (error instanceof msal.AuthError && error.errorCode === msal.BrowserAuthErrorMessage.popUpWindowError.code) {
logConsoleError(
"We were unable to establish authorization for this account, due to pop-ups being disabled in the browser.\nPlease enable pop-ups for this site and try again",
);
} else {
const errorJson = JSON.stringify(error);
logConsoleError(
`Failed to perform authorization for this account, due to the following error: \n${errorJson}`,
);
}
}
}
}
public openNPSSurveyDialog(): void {

View File

@@ -15,6 +15,7 @@ import OpenQueryFromDiskIcon from "../../../../images/OpenQueryFromDisk.svg";
import OpenInTabIcon from "../../../../images/open-in-tab.svg";
import SettingsIcon from "../../../../images/settings_15x15.svg";
import SynapseIcon from "../../../../images/synapse-link.svg";
import EntraIDIcon from "../../../../images/EntraID.svg";
import { AuthType } from "../../../AuthType";
import * as Constants from "../../../Common/Constants";
import { Platform, configContext } from "../../../ConfigContext";
@@ -30,9 +31,10 @@ import { OpenFullScreen } from "../../OpenFullScreen";
import { AddDatabasePanel } from "../../Panes/AddDatabasePanel/AddDatabasePanel";
import { BrowseQueriesPane } from "../../Panes/BrowseQueriesPane/BrowseQueriesPane";
import { LoadQueryPane } from "../../Panes/LoadQueryPane/LoadQueryPane";
import { SettingsPane } from "../../Panes/SettingsPane/SettingsPane";
import { SettingsPane, useDataPlaneRbac } from "../../Panes/SettingsPane/SettingsPane";
import { useDatabases } from "../../useDatabases";
import { SelectedNodeState, useSelectedNode } from "../../useSelectedNode";
import { useEffect, useState } from "react";
let counter = 0;
@@ -69,6 +71,22 @@ export function createStaticCommandBarButtons(
}
}
if (userContext.apiType === "SQL") {
const [loginButtonProps, setLoginButtonProps] = useState<CommandButtonComponentProps | undefined>(undefined);
const dataPlaneRbacEnabled = useDataPlaneRbac((state) => state.dataPlaneRbacEnabled);
const aadTokenUpdated = useDataPlaneRbac((state) => state.aadTokenUpdated);
useEffect(() => {
const buttonProps = createLoginForEntraIDButton(container);
setLoginButtonProps(buttonProps);
}, [dataPlaneRbacEnabled, aadTokenUpdated, container]);
if (loginButtonProps) {
addDivider();
buttons.push(loginButtonProps);
}
}
if (userContext.apiType !== "Tables") {
newCollectionBtn.children = [createNewCollectionGroup(container)];
const newDatabaseBtn = createNewDatabase(container);
@@ -275,6 +293,31 @@ function createOpenSynapseLinkDialogButton(container: Explorer): CommandButtonCo
};
}
function createLoginForEntraIDButton(container: Explorer): CommandButtonComponentProps {
if (configContext.platform !== Platform.Portal) {
return undefined;
}
const handleCommandClick = async () => {
await container.openLoginForEntraIDPopUp();
useDataPlaneRbac.setState({ dataPlaneRbacEnabled: true });
};
if (!userContext.dataPlaneRbacEnabled || userContext.aadToken) {
return undefined;
}
const label = "Login for Entra ID RBAC";
return {
iconSrc: EntraIDIcon,
iconAlt: label,
onCommandClick: handleCommandClick,
commandButtonLabel: label,
hasPopup: true,
ariaLabel: label,
};
}
function createNewDatabase(container: Explorer): CommandButtonComponentProps {
const label = "New " + getDatabaseName();
return {

View File

@@ -4,14 +4,18 @@ import {
IChoiceGroupOption,
ISpinButtonStyles,
IToggleStyles,
Icon,
MessageBar,
MessageBarType,
Position,
SpinButton,
Toggle,
TooltipHost,
} from "@fluentui/react";
import * as Constants from "Common/Constants";
import { SplitterDirection } from "Common/Splitter";
import { InfoTooltip } from "Common/Tooltip/InfoTooltip";
import { configContext } from "ConfigContext";
import { Platform, configContext } from "ConfigContext";
import { useDatabases } from "Explorer/useDatabases";
import {
DefaultRUThreshold,
@@ -22,7 +26,7 @@ import {
ruThresholdEnabled as isRUThresholdEnabled,
} from "Shared/StorageUtility";
import * as StringUtility from "Shared/StringUtility";
import { userContext } from "UserContext";
import { updateUserContext, userContext } from "UserContext";
import { logConsoleInfo } from "Utils/NotificationConsoleUtils";
import * as PriorityBasedExecutionUtils from "Utils/PriorityBasedExecutionUtils";
import { useQueryCopilot } from "hooks/useQueryCopilot";
@@ -30,6 +34,26 @@ import { useSidePanel } from "hooks/useSidePanel";
import React, { FunctionComponent, useState } from "react";
import Explorer from "../../Explorer";
import { RightPaneForm, RightPaneFormProps } from "../RightPaneForm/RightPaneForm";
import { AuthType } from "AuthType";
import create, { UseStore } from "zustand";
import { DatabaseAccountListKeysResult } from "@azure/arm-cosmosdb/esm/models";
import { listKeys } from "Utils/arm/generatedClients/cosmos/databaseAccounts";
export interface DataPlaneRbacState {
dataPlaneRbacEnabled: boolean;
aadTokenUpdated: boolean;
getState?: () => DataPlaneRbacState;
setDataPlaneRbacEnabled: (dataPlaneRbacEnabled: boolean) => void;
setAadDataPlaneUpdated: (aadTokenUpdated: boolean) => void;
}
type DataPlaneRbacStore = UseStore<Partial<DataPlaneRbacState>>;
export const useDataPlaneRbac: DataPlaneRbacStore = create(() => ({
dataPlaneRbacEnabled: false,
}));
export const SettingsPane: FunctionComponent<{ explorer: Explorer }> = ({
explorer,
@@ -44,6 +68,14 @@ export const SettingsPane: FunctionComponent<{ explorer: Explorer }> = ({
? Constants.Queries.UnlimitedPageOption
: Constants.Queries.CustomPageOption,
);
const [enableDataPlaneRBACOption, setEnableDataPlaneRBACOption] = useState<string>(
LocalStorageUtility.hasItem(StorageKey.DataPlaneRbacEnabled)
? LocalStorageUtility.getEntryString(StorageKey.DataPlaneRbacEnabled)
: Constants.RBACOptions.setAutomaticRBACOption,
);
const [showDataPlaneRBACWarning, setShowDataPlaneRBACWarning] = useState<boolean>(false);
const [ruThresholdEnabled, setRUThresholdEnabled] = useState<boolean>(isRUThresholdEnabled());
const [ruThreshold, setRUThreshold] = useState<number>(getRUThreshold());
const [queryTimeoutEnabled, setQueryTimeoutEnabled] = useState<boolean>(
@@ -119,7 +151,37 @@ export const SettingsPane: FunctionComponent<{ explorer: Explorer }> = ({
StorageKey.ActualItemPerPage,
isCustomPageOptionSelected() ? customItemPerPage : Constants.Queries.unlimitedItemsPerPage,
);
LocalStorageUtility.setEntryNumber(StorageKey.CustomItemPerPage, customItemPerPage);
LocalStorageUtility.setEntryString(StorageKey.DataPlaneRbacEnabled, enableDataPlaneRBACOption);
if (
enableDataPlaneRBACOption === Constants.RBACOptions.setTrueRBACOption ||
(enableDataPlaneRBACOption === Constants.RBACOptions.setAutomaticRBACOption &&
userContext.databaseAccount.properties.disableLocalAuth)
) {
updateUserContext({
dataPlaneRbacEnabled: true,
hasDataPlaneRbacSettingChanged: true,
});
useDataPlaneRbac.setState({ dataPlaneRbacEnabled: true });
} else {
updateUserContext({
dataPlaneRbacEnabled: false,
hasDataPlaneRbacSettingChanged: true,
});
const { databaseAccount: account, subscriptionId, resourceGroup } = userContext;
if (!userContext.features.enableAadDataPlane) {
const keys: DatabaseAccountListKeysResult = await listKeys(subscriptionId, resourceGroup, account.name);
if (keys.primaryMasterKey) {
updateUserContext({ masterKey: keys.primaryMasterKey });
useDataPlaneRbac.setState({ dataPlaneRbacEnabled: false });
}
}
}
LocalStorageUtility.setEntryBoolean(StorageKey.RUThresholdEnabled, ruThresholdEnabled);
LocalStorageUtility.setEntryBoolean(StorageKey.QueryTimeoutEnabled, queryTimeoutEnabled);
LocalStorageUtility.setEntryNumber(StorageKey.RetryAttempts, retryAttempts);
@@ -207,6 +269,12 @@ export const SettingsPane: FunctionComponent<{ explorer: Explorer }> = ({
{ key: Constants.PriorityLevel.High, text: "High" },
];
const dataPlaneRBACOptionsList: IChoiceGroupOption[] = [
{ key: Constants.RBACOptions.setAutomaticRBACOption, text: "Automatic" },
{ key: Constants.RBACOptions.setTrueRBACOption, text: "True" },
{ key: Constants.RBACOptions.setFalseRBACOption, text: "False" },
];
const defaultQueryResultsViewOptionList: IChoiceGroupOption[] = [
{ key: SplitterDirection.Vertical, text: "Vertical" },
{ key: SplitterDirection.Horizontal, text: "Horizontal" },
@@ -223,6 +291,20 @@ export const SettingsPane: FunctionComponent<{ explorer: Explorer }> = ({
setPageOption(option.key);
};
const handleOnDataPlaneRBACOptionChange = (
ev: React.FormEvent<HTMLInputElement>,
option: IChoiceGroupOption,
): void => {
setEnableDataPlaneRBACOption(option.key);
const shouldShowWarning =
(option.key === Constants.RBACOptions.setTrueRBACOption ||
(option.key === Constants.RBACOptions.setAutomaticRBACOption &&
userContext.databaseAccount.properties.disableLocalAuth === true)) &&
!useDataPlaneRbac.getState().aadTokenUpdated;
setShowDataPlaneRBACWarning(shouldShowWarning);
};
const handleOnRUThresholdToggleChange = (ev: React.MouseEvent<HTMLElement>, checked?: boolean): void => {
setRUThresholdEnabled(checked);
};
@@ -383,6 +465,54 @@ export const SettingsPane: FunctionComponent<{ explorer: Explorer }> = ({
</div>
</div>
)}
{userContext.apiType === "SQL" && userContext.authType === AuthType.AAD && (
<>
<div className="settingsSection">
<div className="settingsSectionPart">
<fieldset>
<legend id="enableDataPlaneRBACOptions" className="settingsSectionLabel legendLabel">
Enable Entra ID RBAC
</legend>
<TooltipHost
content={
<>
Choose Automatic to enable Entra ID RBAC automatically. True/False to force enable/disable Entra
ID RBAC.
<a
href="https://learn.microsoft.com/en-us/azure/cosmos-db/how-to-setup-rbac#use-data-explorer"
target="_blank"
rel="noopener noreferrer"
>
{" "}
Learn more{" "}
</a>
</>
}
>
<Icon iconName="Info" ariaLabel="Info tooltip" className="panelInfoIcon" tabIndex={0} />
</TooltipHost>
{showDataPlaneRBACWarning && configContext.platform === Platform.Portal && (
<MessageBar
messageBarType={MessageBarType.warning}
isMultiline={true}
onDismiss={() => setShowDataPlaneRBACWarning(false)}
dismissButtonAriaLabel="Close"
>
Please click on &quot;Login for Entra ID RBAC&quot; prior to performing Entra ID RBAC operations
</MessageBar>
)}
<ChoiceGroup
ariaLabelledBy="enableDataPlaneRBACOptions"
options={dataPlaneRBACOptionsList}
styles={choiceButtonStyles}
selectedKey={enableDataPlaneRBACOption}
onChange={handleOnDataPlaneRBACOptionChange}
/>
</fieldset>
</div>
</div>
</>
)}
{userContext.apiType === "SQL" && (
<>
<div className="settingsSection">

View File

@@ -0,0 +1,68 @@
// Definitions of State data
import { loadState, saveState, saveStateDebounced } from "Shared/AppStatePersistenceUtility";
import { userContext } from "UserContext";
import * as ViewModels from "../../../Contracts/ViewModels";
// Component states
export interface DocumentsTabStateData {
leftPaneWidthPercent: number;
}
const defaultState: DocumentsTabStateData = {
leftPaneWidthPercent: 35,
};
const ComponentName = "DocumentsTab";
export const readDocumentsTabState = (): DocumentsTabStateData => {
const state = loadState({ componentName: ComponentName });
return (state as DocumentsTabStateData) || defaultState;
};
export const saveDocumentsTabState = (state: DocumentsTabStateData): void => {
saveStateDebounced({ componentName: ComponentName }, state);
};
export type ColumnSizesMap = { [columnId: string]: WidthDefinition };
export type WidthDefinition = { idealWidth?: number; minWidth?: number };
export const readSubComponentState = <T>(
subComponentName: "ColumnSizes" | "FilterHistory",
collection: ViewModels.CollectionBase,
defaultValue: T,
): T => {
const globalAccountName = userContext.databaseAccount?.name;
// TODO what if databaseAccount doesn't exist?
const state = loadState({
componentName: ComponentName,
subComponentName,
globalAccountName,
databaseName: collection.databaseId,
containerName: collection.id(),
}) as T;
return state || defaultValue;
};
export const saveSubComponentState = <T>(
subComponentName: "ColumnSizes" | "FilterHistory",
collection: ViewModels.CollectionBase,
state: T,
debounce?: boolean,
): void => {
const globalAccountName = userContext.databaseAccount?.name;
// TODO what if databaseAccount doesn't exist?
(debounce ? saveStateDebounced : saveState)(
{
componentName: ComponentName,
subComponentName,
globalAccountName,
databaseName: collection.databaseId,
containerName: collection.id(),
},
state,
);
};

View File

@@ -13,6 +13,7 @@ import {
SAVE_BUTTON_ID,
UPDATE_BUTTON_ID,
UPLOAD_BUTTON_ID,
addStringsNoDuplicate,
buildQuery,
getDiscardExistingDocumentChangesButtonState,
getDiscardNewDocumentChangesButtonState,
@@ -339,7 +340,10 @@ describe("Documents tab (noSql API)", () => {
const createMockProps = (): IDocumentsTabComponentProps => ({
isPreferredApiMongoDB: false,
documentIds: [],
collection: undefined,
collection: {
id: ko.observable<string>("collectionId"),
databaseId: "databaseId",
} as ViewModels.CollectionBase,
partitionKey: { kind: "Hash", paths: ["/foo"], version: 2 },
onLoadStartKey: 0,
tabTitle: "",
@@ -474,3 +478,13 @@ describe("Documents tab (noSql API)", () => {
});
});
});
describe("Documents tab", () => {
it("should add strings to array without duplicate", () => {
const array1 = ["a", "b", "c"];
const array2 = ["b", "c", "d"];
const array3 = addStringsNoDuplicate(array1, array2);
expect(array3).toEqual(["a", "b", "c", "d"]);
});
});

View File

@@ -1,5 +1,17 @@
import { Item, ItemDefinition, PartitionKey, PartitionKeyDefinition, QueryIterator, Resource } from "@azure/cosmos";
import { Button, FluentProvider, Input, TableRowId } from "@fluentui/react-components";
import {
Button,
FluentProvider,
Input,
Menu,
MenuItem,
MenuList,
MenuPopover,
MenuProps,
PositioningImperativeRef,
TableRowId,
useRestoreFocusTarget,
} from "@fluentui/react-components";
import { ArrowClockwise16Filled, Dismiss16Filled } from "@fluentui/react-icons";
import Split from "@uiw/react-split";
import { KeyCodes, QueryCopilotSampleContainerId, QueryCopilotSampleDatabaseId } from "Common/Constants";
@@ -21,6 +33,12 @@ import { EditorReact } from "Explorer/Controls/Editor/EditorReact";
import Explorer from "Explorer/Explorer";
import { useCommandBar } from "Explorer/Menus/CommandBar/CommandBarComponentAdapter";
import { querySampleDocuments, readSampleDocument } from "Explorer/QueryCopilot/QueryCopilotUtilities";
import {
DocumentsTabStateData,
readDocumentsTabState,
readSubComponentState,
saveSubComponentState,
} from "Explorer/Tabs/DocumentsTabV2/DocumentsTabStateUtil";
import { getPlatformTheme } from "Explorer/Theme/ThemeUtil";
import { useSelectedNode } from "Explorer/useSelectedNode";
import { KeyboardAction, KeyboardActionGroup, useKeyboardActionGroup } from "KeyboardShortcuts";
@@ -420,6 +438,24 @@ export const buildQuery = (
return QueryUtils.buildDocumentsQuery(filter, partitionKeyProperties, partitionKey);
};
/**
* Export to expose to unit tests
*
* Add array2 to array1 without duplicates
* @param array1
* @param array2
* @return array1 with array2 added without duplicates
*/
export const addStringsNoDuplicate = (array1: string[], array2: string[]): string[] => {
const result = [...array1];
array2.forEach((item) => {
if (!result.includes(item)) {
result.push(item);
}
});
return result;
};
// Export to expose to unit tests
export interface IDocumentsTabComponentProps {
isPreferredApiMongoDB: boolean;
@@ -434,6 +470,11 @@ export interface IDocumentsTabComponentProps {
isTabActive: boolean;
}
const getUniqueId = (collection: ViewModels.CollectionBase): string => `${collection.databaseId}-${collection.id()}`;
const defaultSqlFilters = ['WHERE c.id = "foo"', "ORDER BY c._ts DESC", 'WHERE c.id = "foo" ORDER BY c._ts DESC'];
const defaultMongoFilters = ['{"id":"foo"}', "{ qty: { $gte: 20 } }"];
// Export to expose to unit tests
export const DocumentsTabComponent: React.FunctionComponent<IDocumentsTabComponentProps> = ({
isPreferredApiMongoDB,
@@ -480,6 +521,9 @@ export const DocumentsTabComponent: React.FunctionComponent<IDocumentsTabCompone
ViewModels.DocumentExplorerState.noDocumentSelected,
);
// State
const [tabStateData, setTabStateData] = useState<DocumentsTabStateData>(() => readDocumentsTabState());
const isQueryCopilotSampleContainer =
_collection?.isSampleCollection &&
_collection?.databaseId === QueryCopilotSampleDatabaseId &&
@@ -488,6 +532,11 @@ export const DocumentsTabComponent: React.FunctionComponent<IDocumentsTabCompone
// For Mongo only
const [continuationToken, setContinuationToken] = useState<string>(undefined);
// User's filter history
const [lastFilterContents, setLastFilterContents] = useState<string[]>(() =>
readSubComponentState("FilterHistory", _collection, []),
);
const setKeyboardActions = useKeyboardActionGroup(KeyboardActionGroup.ACTIVE_TAB);
useEffect(() => {
@@ -513,8 +562,6 @@ export const DocumentsTabComponent: React.FunctionComponent<IDocumentsTabCompone
}
}, [documentIds, clickedRowIndex, editorState]);
let lastFilterContents = ['WHERE c.id = "foo"', "ORDER BY c._ts DESC", 'WHERE c.id = "foo" ORDER BY c._ts DESC'];
const applyFilterButton = {
enabled: true,
visible: true,
@@ -1303,6 +1350,26 @@ export const DocumentsTabComponent: React.FunctionComponent<IDocumentsTabCompone
return () => resizeObserver.disconnect(); // clean up
}, []);
const [filterMenuOpen, setFilterMenuOpen] = React.useState(false);
const onFilterMenuOpenChange: MenuProps["onOpenChange"] = (e, data) => {
// do not close menu as an outside click if clicking on the custom trigger/target
// this prevents it from closing & immediately re-opening when clicking custom triggers
if (data.type === "clickOutside" && (e.target === filterInput.current || e.target === inputRef.current)) {
return;
}
setFilterMenuOpen(data.open);
};
const inputRef = React.useRef<HTMLInputElement>(null);
const positioningRef = React.useRef<PositioningImperativeRef>(null);
React.useEffect(() => {
if (filterInput.current) {
positioningRef.current?.setTarget(filterInput.current);
}
}, [filterInput, positioningRef]);
const restoreFocusTargetAttribute = useRestoreFocusTarget();
const columnHeaders = {
idHeader: isPreferredApiMongoDB ? "_id" : "id",
partitionKeyHeaders: (showPartitionKey(_collection, isPreferredApiMongoDB) && partitionKeyPropertyHeaders) || [],
@@ -1369,7 +1436,6 @@ export const DocumentsTabComponent: React.FunctionComponent<IDocumentsTabCompone
return partitionKey;
};
lastFilterContents = ['{"id":"foo"}', "{ qty: { $gte: 20 } }"];
partitionKeyProperties = partitionKeyProperties?.map((partitionKeyProperty, i) => {
if (partitionKeyProperty && ~partitionKeyProperty.indexOf(`"`)) {
partitionKeyProperty = partitionKeyProperty.replace(/["]+/g, "");
@@ -1646,6 +1712,20 @@ export const DocumentsTabComponent: React.FunctionComponent<IDocumentsTabCompone
}
// ***************** Mongo ***************************
const onApplyFilterClick = (): void => {
refreshDocumentsGrid(true);
// Remove duplicates, but keep order
if (lastFilterContents.includes(filterContent)) {
lastFilterContents.splice(lastFilterContents.indexOf(filterContent), 1);
}
// Save filter content to local storage
lastFilterContents.unshift(filterContent);
setLastFilterContents([...lastFilterContents]);
saveSubComponentState("FilterHistory", _collection, lastFilterContents);
};
const refreshDocumentsGrid = useCallback(
(applyFilterButtonPressed: boolean): void => {
// clear documents grid
@@ -1708,8 +1788,10 @@ export const DocumentsTabComponent: React.FunctionComponent<IDocumentsTabCompone
<Input
id="filterInput"
ref={filterInput}
{...restoreFocusTargetAttribute}
autocomplete="off"
type="text"
list="filtersList"
list={`filtersList-${getUniqueId(_collection)}`}
className={`${filterContent.length === 0 ? "placeholderVisible" : ""}`}
style={{ width: "100%" }}
title="Type a query predicate or choose one from the list."
@@ -1721,21 +1803,38 @@ export const DocumentsTabComponent: React.FunctionComponent<IDocumentsTabCompone
value={filterContent}
autoFocus={true}
onKeyDown={onFilterKeyDown}
onChange={(e) => setFilterContent(e.target.value)}
onChange={(e) => {
setFilterContent(e.target.value);
setFilterMenuOpen(true);
}}
onBlur={() => setIsFilterFocused(false)}
/>
<datalist id="filtersList">
{lastFilterContents.map((filter) => (
<Menu open={filterMenuOpen} onOpenChange={onFilterMenuOpenChange} positioning={{ positioningRef }}>
<MenuPopover>
<MenuList>
{addStringsNoDuplicate(
lastFilterContents,
isPreferredApiMongoDB ? defaultMongoFilters : defaultSqlFilters,
).map((filter) => (
<MenuItem key={filter}>{filter}</MenuItem>
))}
</MenuList>
</MenuPopover>
</Menu>
{/* <datalist id={`filtersList-${getUniqueId(_collection)}`}>
{addStringsNoDuplicate(
lastFilterContents,
isPreferredApiMongoDB ? defaultMongoFilters : defaultSqlFilters,
).map((filter) => (
<option key={filter} value={filter} />
))}
</datalist>
</datalist> */}
<span className="filterbuttonpad">
<Button
appearance="primary"
style={filterButtonStyle}
onClick={() => refreshDocumentsGrid(true)}
onClick={onApplyFilterClick}
disabled={!applyFilterButton.enabled}
aria-label="Apply filter"
tabIndex={0}
@@ -1772,7 +1871,13 @@ export const DocumentsTabComponent: React.FunctionComponent<IDocumentsTabCompone
)}
{/* <Split> doesn't like to be a flex child */}
<div style={{ overflow: "hidden", height: "100%" }}>
<Split>
<Split
onDragEnd={(preSize: number) => {
tabStateData.leftPaneWidthPercent = Math.min(100, Math.max(0, Math.round(100 * preSize) / 100));
// saveDocumentsTabState(tabStateData); // Disable saving split position for now
setTabStateData({ ...tabStateData });
}}
>
<div
style={{ minWidth: 120, width: "35%", overflow: "hidden", position: "relative" }}
ref={tableContainerRef}
@@ -1813,6 +1918,7 @@ export const DocumentsTabComponent: React.FunctionComponent<IDocumentsTabCompone
(partitionKey.systemKey && !isPreferredApiMongoDB) ||
(configContext.platform === Platform.Fabric && userContext.fabricContext?.isReadOnly)
}
collection={_collection}
/>
{tableItems.length > 0 && (
<a

View File

@@ -1,6 +1,7 @@
import { TableRowId } from "@fluentui/react-components";
import { mount } from "enzyme";
import React from "react";
import * as ViewModels from "../../../Contracts/ViewModels";
import { DocumentsTableComponent, IDocumentsTableComponentProps } from "./DocumentsTableComponent";
const PARTITION_KEY_HEADER = "partitionKey";
@@ -25,6 +26,10 @@ describe("DocumentsTableComponent", () => {
partitionKeyHeaders: [PARTITION_KEY_HEADER],
},
isSelectionDisabled: false,
collection: {
databaseId: "db",
id: ((): string => "coll") as ko.Observable<string>,
} as ViewModels.CollectionBase,
});
it("should render documents and partition keys in header", () => {

View File

@@ -1,4 +1,5 @@
import {
createTableColumn,
Menu,
MenuItem,
MenuList,
@@ -16,17 +17,23 @@ import {
TableRow,
TableRowId,
TableSelectionCell,
createTableColumn,
useArrowNavigationGroup,
useTableColumnSizing_unstable,
useTableFeatures,
useTableSelection,
} from "@fluentui/react-components";
import { NormalizedEventKey } from "Common/Constants";
import {
ColumnSizesMap,
readSubComponentState,
saveSubComponentState,
WidthDefinition,
} from "Explorer/Tabs/DocumentsTabV2/DocumentsTabStateUtil";
import { selectionHelper } from "Explorer/Tabs/DocumentsTabV2/SelectionHelper";
import { isEnvironmentCtrlPressed, isEnvironmentShiftPressed } from "Utils/KeyboardUtils";
import React, { useCallback, useMemo } from "react";
import { FixedSizeList as List, ListChildComponentProps } from "react-window";
import * as ViewModels from "../../../Contracts/ViewModels";
export type DocumentsTableComponentItem = {
id: string;
@@ -45,6 +52,7 @@ export interface IDocumentsTableComponentProps {
columnHeaders: ColumnHeaders;
style?: React.CSSProperties;
isSelectionDisabled?: boolean;
collection: ViewModels.CollectionBase;
}
interface TableRowData extends RowStateBase<DocumentsTableComponentItem> {
@@ -57,6 +65,11 @@ interface ReactWindowRenderFnProps extends ListChildComponentProps {
data: TableRowData[];
}
const defaultSize: WidthDefinition = {
idealWidth: 200,
minWidth: 50,
};
export const DocumentsTableComponent: React.FC<IDocumentsTableComponentProps> = ({
items,
onSelectedRowsChange,
@@ -65,30 +78,32 @@ export const DocumentsTableComponent: React.FC<IDocumentsTableComponentProps> =
size,
columnHeaders,
isSelectionDisabled,
collection,
}: IDocumentsTableComponentProps) => {
const initialSizingOptions: TableColumnSizingOptions = {
id: {
idealWidth: 280,
minWidth: 50,
},
};
columnHeaders.partitionKeyHeaders.forEach((pkHeader) => {
initialSizingOptions[pkHeader] = {
idealWidth: 200,
minWidth: 50,
};
const [columnSizingOptions, setColumnSizingOptions] = React.useState<TableColumnSizingOptions>(() => {
const columnIds = ["id"].concat(columnHeaders.partitionKeyHeaders);
const columnSizesMap: ColumnSizesMap = readSubComponentState("ColumnSizes", collection, {});
const columnSizesPx: ColumnSizesMap = {};
columnIds.forEach((columnId) => {
columnSizesPx[columnId] = (columnSizesMap && columnSizesMap[columnId]) || defaultSize;
});
return columnSizesPx;
});
const [columnSizingOptions, setColumnSizingOptions] = React.useState<TableColumnSizingOptions>(initialSizingOptions);
const onColumnResize = React.useCallback((_, { columnId, width }) => {
setColumnSizingOptions((state) => ({
...state,
[columnId]: {
...state[columnId],
idealWidth: width,
},
}));
setColumnSizingOptions((state) => {
const newSizingOptions = {
...state,
[columnId]: {
...state[columnId],
idealWidth: width,
},
};
saveSubComponentState("ColumnSizes", collection, newSizingOptions, true);
return newSizingOptions;
});
}, []);
// Columns must be a static object and cannot change on re-renders otherwise React will complain about too many refreshes

View File

@@ -485,6 +485,7 @@ exports[`Documents tab (noSql API) when rendered should render the page 1`] = `
>
<Split
mode="horizontal"
onDragEnd={[Function]}
prefixCls="w-split"
visiable={true}
>
@@ -526,6 +527,12 @@ exports[`Documents tab (noSql API) when rendered should render the page 1`] = `
}
>
<DocumentsTableComponent
collection={
Object {
"databaseId": "databaseId",
"id": [Function],
}
}
columnHeaders={
Object {
"idHeader": "id",

View File

@@ -2,6 +2,12 @@
exports[`DocumentsTableComponent should not render selection column when isSelectionDisabled is true 1`] = `
<DocumentsTableComponent
collection={
Object {
"databaseId": "db",
"id": [Function],
}
}
columnHeaders={
Object {
"idHeader": "id",
@@ -994,6 +1000,12 @@ exports[`DocumentsTableComponent should not render selection column when isSelec
exports[`DocumentsTableComponent should render documents and partition keys in header 1`] = `
<DocumentsTableComponent
collection={
Object {
"databaseId": "db",
"id": [Function],
}
}
columnHeaders={
Object {
"idHeader": "id",

View File

@@ -1,5 +1,6 @@
import { useMongoProxyEndpoint } from "Common/MongoProxyClient";
import React, { Component } from "react";
import * as Constants from "../../../Common/Constants";
import { configContext } from "../../../ConfigContext";
import * as ViewModels from "../../../Contracts/ViewModels";
import { Action, ActionModifiers } from "../../../Shared/Telemetry/TelemetryConstants";
@@ -112,6 +113,12 @@ export default class MongoShellTabComponent extends Component<
const resourceId = databaseAccount?.id;
const accountName = databaseAccount?.name;
const documentEndpoint = databaseAccount?.properties.mongoEndpoint || databaseAccount?.properties.documentEndpoint;
const mongoEndpoint =
documentEndpoint.substr(
Constants.MongoDBAccounts.protocol.length + 3,
documentEndpoint.length -
(Constants.MongoDBAccounts.protocol.length + 2 + Constants.MongoDBAccounts.defaultPort.length),
) + Constants.MongoDBAccounts.defaultPort.toString();
const databaseId = this.props.collection.databaseId;
const collectionId = this.props.collection.id();
const apiEndpoint = this._useMongoProxyEndpoint
@@ -125,7 +132,7 @@ export default class MongoShellTabComponent extends Component<
data: {
resourceId: resourceId,
accountName: accountName,
mongoEndpoint: documentEndpoint,
mongoEndpoint: this._useMongoProxyEndpoint ? documentEndpoint : mongoEndpoint,
authorization: authorization,
databaseId: databaseId,
collectionId: collectionId,

View File

@@ -0,0 +1,76 @@
import { LocalStorageUtility, StorageKey } from "Shared/StorageUtility";
// The component name whose state is being saved. Component name must not include special characters.
export type ComponentName = "DocumentsTab" | "DocumentsTab.columnSizes";
const SCHEMA_VERSION = 1;
export interface StateData {
schemaVersion: number;
timestamp: number;
data: unknown;
}
type StorePath = {
componentName: string;
subComponentName?: string;
globalAccountName?: string;
databaseName?: string;
containerName?: string;
};
// Load and save state data
export const loadState = (path: StorePath): unknown => {
const appState =
LocalStorageUtility.getEntryObject<ApplicationState>(StorageKey.AppState) || ({} as ApplicationState);
const key = createKeyFromPath(path);
return appState[key]?.data;
};
export const saveState = (path: StorePath, state: unknown): void => {
// Retrieve state object
const appState =
LocalStorageUtility.getEntryObject<ApplicationState>(StorageKey.AppState) || ({} as ApplicationState);
const key = createKeyFromPath(path);
appState[key] = {
schemaVersion: SCHEMA_VERSION,
timestamp: Date.now(),
data: state,
};
// TODO Add logic to clean up old state data based on timestamp
LocalStorageUtility.setEntryObject(StorageKey.AppState, appState);
};
// This is for high-frequency state changes
let timeoutId: NodeJS.Timeout | undefined;
export const saveStateDebounced = (path: StorePath, state: unknown, debounceDelayMs = 1000): void => {
if (timeoutId) {
clearTimeout(timeoutId);
}
timeoutId = setTimeout(() => saveState(path, state), debounceDelayMs);
};
interface ApplicationState {
[statePath: string]: StateData;
}
const orderedPathSegments: (keyof StorePath)[] = [
"subComponentName",
"globalAccountName",
"databaseName",
"containerName",
];
/**
* /componentName/subComponentName/globalAccountName/databaseName/containerName/
* Any of the path segments can be "" except componentName
* @param path
*/
const createKeyFromPath = (path: StorePath): string => {
let key = `/${path.componentName}`; // ComponentName is always there
orderedPathSegments.forEach((segment) => {
const segmentValue = path[segment as keyof StorePath];
key += `/${segmentValue !== undefined ? segmentValue : ""}`;
});
return key;
};

View File

@@ -20,3 +20,14 @@ export const setEntryNumber = (key: StorageKey, value: number): void =>
export const setEntryBoolean = (key: StorageKey, value: boolean): void =>
localStorage.setItem(StorageKey[key], value.toString());
export const setEntryObject = (key: StorageKey, value: unknown): void => {
localStorage.setItem(StorageKey[key], JSON.stringify(value));
};
export const getEntryObject = <T>(key: StorageKey): T | null => {
const item = localStorage.getItem(StorageKey[key]);
if (item) {
return JSON.parse(item) as T;
}
return null;
};

View File

@@ -6,6 +6,7 @@ import * as StringUtility from "./StringUtility";
export { LocalStorageUtility, SessionStorageUtility };
export enum StorageKey {
ActualItemPerPage,
DataPlaneRbacEnabled,
RUThresholdEnabled,
RUThreshold,
QueryTimeoutEnabled,
@@ -29,6 +30,7 @@ export enum StorageKey {
VisitedAccounts,
PriorityLevel,
DefaultQueryResultsView,
AppState,
}
export const hasRUThresholdBeenConfigured = (): boolean => {

View File

@@ -101,6 +101,8 @@ export interface UserContext {
sampleDataConnectionInfo?: ParsedResourceTokenConnectionString;
readonly vcoreMongoConnectionParams?: VCoreMongoConnectionParams;
readonly feedbackPolicies?: AdminFeedbackPolicySettings;
readonly dataPlaneRbacEnabled?: boolean;
readonly hasDataPlaneRbacSettingChanged?: boolean;
}
export type ApiType = "SQL" | "Mongo" | "Gremlin" | "Tables" | "Cassandra" | "Postgres" | "VCoreMongo";

View File

@@ -1,3 +1,4 @@
import * as Constants from "Common/Constants";
import { createUri } from "Common/UrlUtility";
import { DATA_EXPLORER_RPC_VERSION } from "Contracts/DataExplorerMessagesContract";
import { FabricMessageTypes } from "Contracts/FabricMessageTypes";
@@ -5,6 +6,7 @@ import { FABRIC_RPC_VERSION, FabricMessageV2 } from "Contracts/FabricMessagesCon
import Explorer from "Explorer/Explorer";
import { useSelectedNode } from "Explorer/useSelectedNode";
import { scheduleRefreshDatabaseResourceToken } from "Platform/Fabric/FabricUtil";
import { LocalStorageUtility, StorageKey } from "Shared/StorageUtility";
import { getNetworkSettingsWarningMessage } from "Utils/NetworkUtility";
import { logConsoleError } from "Utils/NotificationConsoleUtils";
import { useQueryCopilot } from "hooks/useQueryCopilot";
@@ -39,8 +41,9 @@ import { Node, PortalEnv, updateUserContext, userContext } from "../UserContext"
import { acquireTokenWithMsal, getAuthorizationHeader, getMsalInstance } from "../Utils/AuthorizationUtils";
import { isInvalidParentFrameOrigin, shouldProcessMessage } from "../Utils/MessageValidation";
import { listKeys } from "../Utils/arm/generatedClients/cosmos/databaseAccounts";
import { DatabaseAccountListKeysResult } from "../Utils/arm/generatedClients/cosmos/types";
import { applyExplorerBindings } from "../applyExplorerBindings";
import { useDataPlaneRbac } from "Explorer/Panes/SettingsPane/SettingsPane";
import * as Logger from "../Common/Logger";
// This hook will create a new instance of Explorer.ts and bind it to the DOM
// This hook has a LOT of magic, but ideally we can delete it once we have removed KO and switched entirely to React
@@ -253,7 +256,6 @@ async function configureHostedWithAAD(config: AAD): Promise<Explorer> {
const subscriptionId = accountResourceId && accountResourceId.split("subscriptions/")[1].split("/")[0];
const resourceGroup = accountResourceId && accountResourceId.split("resourceGroups/")[1].split("/")[0];
let aadToken;
let keys: DatabaseAccountListKeysResult = {};
if (account.properties?.documentEndpoint) {
const hrefEndpoint = new URL(account.properties.documentEndpoint).href.replace(/\/$/, "/.default");
const msalInstance = await getMsalInstance();
@@ -271,8 +273,83 @@ async function configureHostedWithAAD(config: AAD): Promise<Explorer> {
}
}
try {
if (!account.properties.disableLocalAuth) {
keys = await listKeys(subscriptionId, resourceGroup, account.name);
updateUserContext({
databaseAccount: config.databaseAccount,
});
Logger.logInfo(
`Configuring Data Explorer for ${userContext.apiType} account ${account.name}`,
"Explorer/configureHostedWithAAD",
);
if (!userContext.features.enableAadDataPlane) {
Logger.logInfo(`AAD Feature flag is not enabled for account ${account.name}`, "Explorer/configureHostedWithAAD");
if (userContext.apiType === "SQL") {
if (LocalStorageUtility.hasItem(StorageKey.DataPlaneRbacEnabled)) {
const isDataPlaneRbacSetting = LocalStorageUtility.getEntryString(StorageKey.DataPlaneRbacEnabled);
Logger.logInfo(
`Local storage RBAC setting for ${userContext.apiType} account ${account.name} is ${isDataPlaneRbacSetting}`,
"Explorer/configureHostedWithAAD",
);
let dataPlaneRbacEnabled;
if (isDataPlaneRbacSetting === Constants.RBACOptions.setAutomaticRBACOption) {
dataPlaneRbacEnabled = account.properties.disableLocalAuth;
Logger.logInfo(
`Data Plane RBAC value for ${userContext.apiType} account ${account.name} with disable local auth set to ${account.properties.disableLocalAuth} is ${dataPlaneRbacEnabled}`,
"Explorer/configureHostedWithAAD",
);
} else {
dataPlaneRbacEnabled = isDataPlaneRbacSetting === Constants.RBACOptions.setTrueRBACOption;
Logger.logInfo(
`Data Plane RBAC value for ${userContext.apiType} account ${account.name} with disable local auth set to ${account.properties.disableLocalAuth} is ${dataPlaneRbacEnabled}`,
"Explorer/configureHostedWithAAD",
);
}
if (!dataPlaneRbacEnabled) {
Logger.logInfo(
`Calling fetch keys for ${userContext.apiType} account ${account.name} with RBAC setting ${dataPlaneRbacEnabled}`,
"Explorer/configureHostedWithAAD",
);
await fetchAndUpdateKeys(subscriptionId, resourceGroup, account.name);
}
updateUserContext({ dataPlaneRbacEnabled });
} else {
const dataPlaneRbacEnabled = account.properties.disableLocalAuth;
Logger.logInfo(
`Local storage setting does not exist : Data Plane RBAC value for ${userContext.apiType} account ${account.name} with disable local auth set to ${account.properties.disableLocalAuth} is ${dataPlaneRbacEnabled}`,
"Explorer/configureHostedWithAAD",
);
if (!dataPlaneRbacEnabled) {
Logger.logInfo(
`Fetching keys for ${userContext.apiType} account ${account.name} with RBAC setting ${dataPlaneRbacEnabled}`,
"Explorer/configureHostedWithAAD",
);
await fetchAndUpdateKeys(subscriptionId, resourceGroup, account.name);
}
updateUserContext({ dataPlaneRbacEnabled });
useDataPlaneRbac.setState({ dataPlaneRbacEnabled: dataPlaneRbacEnabled });
}
} else {
Logger.logInfo(
`Fetching keys for ${userContext.apiType} account ${account.name}`,
"Explorer/configureHostedWithAAD",
);
await fetchAndUpdateKeys(subscriptionId, resourceGroup, account.name);
}
} else {
Logger.logInfo(
`AAD Feature flag is enabled for account ${account.name} with disable local auth set to ${account.properties.disableLocalAuth} `,
"Explorer/configureHostedWithAAD",
);
if (!account.properties.disableLocalAuth) {
Logger.logInfo(
`Fetching keys for ${userContext.apiType} account ${account.name} with AAD data plane feature enabled`,
"Explorer/configureHostedWithAAD",
);
await fetchAndUpdateKeys(subscriptionId, resourceGroup, account.name);
}
}
} catch (e) {
if (userContext.features.enableAadDataPlane) {
@@ -285,8 +362,6 @@ async function configureHostedWithAAD(config: AAD): Promise<Explorer> {
subscriptionId,
resourceGroup,
aadToken,
databaseAccount: config.databaseAccount,
masterKey: keys.primaryMasterKey,
});
const explorer = new Explorer();
return explorer;
@@ -390,10 +465,34 @@ function configureEmulator(): Explorer {
return explorer;
}
async function fetchAndUpdateKeys(subscriptionId: string, resourceGroup: string, account: string) {
try {
Logger.logInfo(`Fetching keys for ${userContext.apiType} account ${account}`, "Explorer/fetchAndUpdateKeys");
const keys = await listKeys(subscriptionId, resourceGroup, account);
Logger.logInfo(`Keys fetched for ${userContext.apiType} account ${account}`, "Explorer/fetchAndUpdateKeys");
updateUserContext({
masterKey: keys.primaryMasterKey,
});
Logger.logInfo(
`User context updated with Master key for ${userContext.apiType} account ${account}`,
"Explorer/fetchAndUpdateKeys",
);
} catch (error) {
console.error("Error during fetching keys or updating user context:", error);
Logger.logError(
`Error during fetching keys or updating user context: ${error} for ${userContext.apiType} account ${account}`,
"Explorer/fetchAndUpdateKeys",
);
throw error;
}
}
async function configurePortal(): Promise<Explorer> {
updateUserContext({
authType: AuthType.AAD,
});
let explorer: Explorer;
return new Promise((resolve) => {
// In development mode, try to load the iframe message from session storage.
@@ -408,6 +507,7 @@ async function configurePortal(): Promise<Explorer> {
console.dir(message);
updateContextsFromPortalMessage(message);
explorer = new Explorer();
// In development mode, save the iframe message from the portal in session storage.
// This allows webpack hot reload to funciton properly
if (process.env.NODE_ENV === "development") {
@@ -420,7 +520,7 @@ async function configurePortal(): Promise<Explorer> {
// In the Portal, configuration of Explorer happens via iframe message
window.addEventListener(
"message",
(event) => {
async (event) => {
if (isInvalidParentFrameOrigin(event)) {
return;
}
@@ -443,6 +543,53 @@ async function configurePortal(): Promise<Explorer> {
}
updateContextsFromPortalMessage(inputs);
const { databaseAccount: account, subscriptionId, resourceGroup } = userContext;
let dataPlaneRbacEnabled;
if (userContext.apiType === "SQL") {
if (LocalStorageUtility.hasItem(StorageKey.DataPlaneRbacEnabled)) {
const isDataPlaneRbacSetting = LocalStorageUtility.getEntryString(StorageKey.DataPlaneRbacEnabled);
Logger.logInfo(
`Local storage RBAC setting for ${userContext.apiType} account ${account.name} is ${isDataPlaneRbacSetting}`,
"Explorer/configurePortal",
);
if (isDataPlaneRbacSetting === Constants.RBACOptions.setAutomaticRBACOption) {
dataPlaneRbacEnabled = account.properties.disableLocalAuth;
} else {
dataPlaneRbacEnabled = isDataPlaneRbacSetting === Constants.RBACOptions.setTrueRBACOption;
}
} else {
Logger.logInfo(
`Local storage does not exist for ${userContext.apiType} account ${account.name} with disable local auth set to ${account.properties.disableLocalAuth} is ${dataPlaneRbacEnabled}`,
"Explorer/configurePortal",
);
dataPlaneRbacEnabled = account.properties.disableLocalAuth;
}
Logger.logInfo(
`Data Plane RBAC value for ${userContext.apiType} account ${account.name} with disable local auth set to ${account.properties.disableLocalAuth} is ${dataPlaneRbacEnabled}`,
"Explorer/configurePortal",
);
if (!dataPlaneRbacEnabled) {
Logger.logInfo(
`Calling fetch keys for ${userContext.apiType} account ${account.name}`,
"Explorer/configurePortal",
);
await fetchAndUpdateKeys(subscriptionId, resourceGroup, account.name);
}
updateUserContext({ dataPlaneRbacEnabled });
useDataPlaneRbac.setState({ dataPlaneRbacEnabled: dataPlaneRbacEnabled });
} else if (userContext.apiType !== "Postgres" && userContext.apiType !== "VCoreMongo") {
Logger.logInfo(
`Calling fetch keys for ${userContext.apiType} account ${account.name}`,
"Explorer/configurePortal",
);
await fetchAndUpdateKeys(subscriptionId, resourceGroup, account.name);
}
explorer = new Explorer();
resolve(explorer);
@@ -490,7 +637,6 @@ function updateContextsFromPortalMessage(inputs: DataExplorerInputsFrame) {
}
const authorizationToken = inputs.authorizationToken || "";
const masterKey = inputs.masterKey || "";
const databaseAccount = inputs.databaseAccount;
updateConfigContext({
@@ -503,7 +649,6 @@ function updateContextsFromPortalMessage(inputs: DataExplorerInputsFrame) {
updateUserContext({
authorizationToken,
masterKey,
databaseAccount,
resourceGroup: inputs.resourceGroup,
subscriptionId: inputs.subscriptionId,

View File

@@ -51,17 +51,13 @@ export async function fetchSubscriptionsFromGraph(accessToken: string): Promise<
do {
const body = {
query: subscriptionsQuery,
...(skipToken
? {
options: {
$skipToken: skipToken,
} as QueryRequestOptions,
}
: {
options: {
$top: 150,
} as QueryRequestOptions,
}),
options: {
$allowPartialScopes: true,
$top: 150,
...(skipToken && {
$skipToken: skipToken,
}),
} as QueryRequestOptions,
};
const response = await fetch(managementResourceGraphAPIURL, {

View File

@@ -14,6 +14,9 @@ test("Tables CRUD", async ({ page }) => {
await okButton.click();
});
const databaseNode = explorer.treeNode("DATA/TablesDB");
await databaseNode.expand();
const tableNode = explorer.treeNode(`DATA/TablesDB/${tableId}`);
await expect(tableNode.element).toBeAttached();