mirror of
https://github.com/Azure/cosmos-explorer.git
synced 2025-12-22 10:21:37 +00:00
Migrated Hosted Explorer to React (#360)
Co-authored-by: Victor Meng <vimeng@microsoft.com> Co-authored-by: Steve Faulkner <stfaul@microsoft.com>
This commit is contained in:
@@ -1,180 +0,0 @@
|
||||
import AuthHeadersUtil from "./Authorization";
|
||||
import * as Constants from "../../Common/Constants";
|
||||
import * as Logger from "../../Common/Logger";
|
||||
import { Tenant, Subscription, DatabaseAccount, AccountKeys } from "../../Contracts/DataModels";
|
||||
import { configContext } from "../../ConfigContext";
|
||||
import { getErrorMessage } from "../../Common/ErrorHandlingUtils";
|
||||
|
||||
// TODO: 421864 - add a fetch wrapper
|
||||
export abstract class ArmResourceUtils {
|
||||
private static readonly _armEndpoint: string = configContext.ARM_ENDPOINT;
|
||||
private static readonly _armApiVersion: string = configContext.ARM_API_VERSION;
|
||||
private static readonly _armAuthArea: string = configContext.ARM_AUTH_AREA;
|
||||
|
||||
// TODO: 422867 - return continuation token instead of read through
|
||||
public static async listTenants(): Promise<Array<Tenant>> {
|
||||
let tenants: Array<Tenant> = [];
|
||||
|
||||
try {
|
||||
const fetchHeaders = await ArmResourceUtils._getAuthHeader(ArmResourceUtils._armAuthArea);
|
||||
let nextLink = `${ArmResourceUtils._armEndpoint}/tenants?api-version=2017-08-01`;
|
||||
|
||||
while (nextLink) {
|
||||
const response: Response = await fetch(nextLink, { headers: fetchHeaders });
|
||||
const result: TenantListResult =
|
||||
response.status === 204 || response.status === 304 ? null : await response.json();
|
||||
if (!response.ok) {
|
||||
throw result;
|
||||
}
|
||||
nextLink = result.nextLink;
|
||||
tenants = [...tenants, ...result.value];
|
||||
}
|
||||
return tenants;
|
||||
} catch (error) {
|
||||
Logger.logError(getErrorMessage(error), "ArmResourceUtils/listTenants");
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: 422867 - return continuation token instead of read through
|
||||
public static async listSubscriptions(tenantId?: string): Promise<Array<Subscription>> {
|
||||
let subscriptions: Array<Subscription> = [];
|
||||
|
||||
try {
|
||||
const fetchHeaders = await ArmResourceUtils._getAuthHeader(ArmResourceUtils._armAuthArea, tenantId);
|
||||
let nextLink = `${ArmResourceUtils._armEndpoint}/subscriptions?api-version=${ArmResourceUtils._armApiVersion}`;
|
||||
|
||||
while (nextLink) {
|
||||
const response: Response = await fetch(nextLink, { headers: fetchHeaders });
|
||||
const result: SubscriptionListResult =
|
||||
response.status === 204 || response.status === 304 ? null : await response.json();
|
||||
if (!response.ok) {
|
||||
throw result;
|
||||
}
|
||||
nextLink = result.nextLink;
|
||||
const validSubscriptions = result.value.filter(
|
||||
sub => sub.state === "Enabled" || sub.state === "Warned" || sub.state === "PastDue"
|
||||
);
|
||||
subscriptions = [...subscriptions, ...validSubscriptions];
|
||||
}
|
||||
return subscriptions;
|
||||
} catch (error) {
|
||||
Logger.logError(getErrorMessage(error), "ArmResourceUtils/listSubscriptions");
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: 422867 - return continuation token instead of read through
|
||||
public static async listCosmosdbAccounts(
|
||||
subscriptionIds: string[],
|
||||
tenantId?: string
|
||||
): Promise<Array<DatabaseAccount>> {
|
||||
if (!subscriptionIds || !subscriptionIds.length) {
|
||||
return Promise.reject("No subscription passed in");
|
||||
}
|
||||
|
||||
let accounts: Array<DatabaseAccount> = [];
|
||||
|
||||
try {
|
||||
const subscriptionFilter = "subscriptionId eq '" + subscriptionIds.join("' or subscriptionId eq '") + "'";
|
||||
const urlFilter = `$filter=(${subscriptionFilter}) and (resourceType eq 'microsoft.documentdb/databaseaccounts')`;
|
||||
const fetchHeaders = await ArmResourceUtils._getAuthHeader(ArmResourceUtils._armAuthArea, tenantId);
|
||||
let nextLink = `${ArmResourceUtils._armEndpoint}/resources?api-version=${ArmResourceUtils._armApiVersion}&${urlFilter}`;
|
||||
|
||||
while (nextLink) {
|
||||
const response: Response = await fetch(nextLink, { headers: fetchHeaders });
|
||||
const result: AccountListResult =
|
||||
response.status === 204 || response.status === 304 ? null : await response.json();
|
||||
if (!response.ok) {
|
||||
throw result;
|
||||
}
|
||||
nextLink = result.nextLink;
|
||||
accounts = [...accounts, ...result.value];
|
||||
}
|
||||
return accounts;
|
||||
} catch (error) {
|
||||
Logger.logError(getErrorMessage(error), "ArmResourceUtils/listAccounts");
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
public static async getCosmosdbAccount(cosmosdbResourceId: string, tenantId?: string): Promise<DatabaseAccount> {
|
||||
if (!cosmosdbResourceId) {
|
||||
return Promise.reject("No Cosmos DB resource id passed in");
|
||||
}
|
||||
try {
|
||||
const fetchHeaders = await ArmResourceUtils._getAuthHeader(ArmResourceUtils._armAuthArea, tenantId);
|
||||
const url = `${ArmResourceUtils._armEndpoint}/${cosmosdbResourceId}?api-version=${Constants.ArmApiVersions.documentDB}`;
|
||||
|
||||
const response: Response = await fetch(url, { headers: fetchHeaders });
|
||||
const result: DatabaseAccount = response.status === 204 || response.status === 304 ? null : await response.json();
|
||||
if (!response.ok) {
|
||||
throw result;
|
||||
}
|
||||
return result;
|
||||
} catch (error) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
public static async getCosmosdbKeys(cosmosdbResourceId: string, tenantId?: string): Promise<AccountKeys> {
|
||||
if (!cosmosdbResourceId) {
|
||||
return Promise.reject("No Cosmos DB resource id passed in");
|
||||
}
|
||||
|
||||
try {
|
||||
const fetchHeaders = await ArmResourceUtils._getAuthHeader(ArmResourceUtils._armAuthArea, tenantId);
|
||||
const readWriteKeysUrl = `${ArmResourceUtils._armEndpoint}/${cosmosdbResourceId}/listKeys?api-version=${Constants.ArmApiVersions.documentDB}`;
|
||||
const readOnlyKeysUrl = `${ArmResourceUtils._armEndpoint}/${cosmosdbResourceId}/readOnlyKeys?api-version=${Constants.ArmApiVersions.documentDB}`;
|
||||
let response: Response = await fetch(readWriteKeysUrl, { headers: fetchHeaders, method: "POST" });
|
||||
if (response.status === Constants.HttpStatusCodes.Forbidden) {
|
||||
// fetch read only keys for readers
|
||||
response = await fetch(readOnlyKeysUrl, { headers: fetchHeaders, method: "POST" });
|
||||
}
|
||||
const result: AccountKeys =
|
||||
response.status === Constants.HttpStatusCodes.NoContent ||
|
||||
response.status === Constants.HttpStatusCodes.NotModified
|
||||
? null
|
||||
: await response.json();
|
||||
if (!response.ok) {
|
||||
throw result;
|
||||
}
|
||||
return result;
|
||||
} catch (error) {
|
||||
Logger.logError(getErrorMessage(error), "ArmResourceUtils/getAccountKeys");
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
public static async getAuthToken(tenantId?: string): Promise<string> {
|
||||
try {
|
||||
const token = await AuthHeadersUtil.getAccessToken(ArmResourceUtils._armAuthArea, tenantId);
|
||||
return token;
|
||||
} catch (error) {
|
||||
Logger.logError(getErrorMessage(error), "ArmResourceUtils/getAuthToken");
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
private static async _getAuthHeader(authArea: string, tenantId?: string): Promise<Headers> {
|
||||
const token = await AuthHeadersUtil.getAccessToken(authArea, tenantId);
|
||||
let fetchHeaders = new Headers();
|
||||
fetchHeaders.append("authorization", `Bearer ${token}`);
|
||||
return fetchHeaders;
|
||||
}
|
||||
}
|
||||
|
||||
interface TenantListResult {
|
||||
nextLink: string;
|
||||
value: Tenant[];
|
||||
}
|
||||
|
||||
interface SubscriptionListResult {
|
||||
nextLink: string;
|
||||
value: Subscription[];
|
||||
}
|
||||
|
||||
interface AccountListResult {
|
||||
nextLink: string;
|
||||
value: DatabaseAccount[];
|
||||
}
|
||||
@@ -1,88 +1,11 @@
|
||||
import "expose-loader?AuthenticationContext!../../../externals/adal";
|
||||
|
||||
import Q from "q";
|
||||
import * as Constants from "../../Common/Constants";
|
||||
import * as DataModels from "../../Contracts/DataModels";
|
||||
import { AuthType } from "../../AuthType";
|
||||
import * as NotificationConsoleUtils from "../../Utils/NotificationConsoleUtils";
|
||||
import { ConsoleDataType } from "../../Explorer/Menus/NotificationConsole/NotificationConsoleComponent";
|
||||
import { DefaultExperienceUtility } from "../../Shared/DefaultExperienceUtility";
|
||||
import * as Logger from "../../Common/Logger";
|
||||
import { configContext } from "../../ConfigContext";
|
||||
import * as DataModels from "../../Contracts/DataModels";
|
||||
import { DefaultExperienceUtility } from "../../Shared/DefaultExperienceUtility";
|
||||
import { userContext } from "../../UserContext";
|
||||
|
||||
export default class AuthHeadersUtil {
|
||||
public static serverId: string = Constants.ServerIds.productionPortal;
|
||||
|
||||
private static readonly _firstPartyAppId: string = "203f1145-856a-4232-83d4-a43568fba23d";
|
||||
private static readonly _aadEndpoint: string = configContext.AAD_ENDPOINT;
|
||||
private static readonly _armEndpoint: string = configContext.ARM_ENDPOINT;
|
||||
private static readonly _arcadiaEndpoint: string = configContext.ARCADIA_ENDPOINT;
|
||||
private static readonly _armAuthArea: string = configContext.ARM_AUTH_AREA;
|
||||
private static readonly _graphEndpoint: string = configContext.GRAPH_ENDPOINT;
|
||||
private static readonly _graphApiVersion: string = configContext.GRAPH_API_VERSION;
|
||||
|
||||
private static _authContext: AuthenticationContext = new AuthenticationContext({
|
||||
instance: AuthHeadersUtil._aadEndpoint,
|
||||
clientId: AuthHeadersUtil._firstPartyAppId,
|
||||
postLogoutRedirectUri: window.location.origin,
|
||||
endpoints: {
|
||||
aad: AuthHeadersUtil._aadEndpoint,
|
||||
graph: AuthHeadersUtil._graphEndpoint,
|
||||
armAuthArea: AuthHeadersUtil._armAuthArea,
|
||||
armEndpoint: AuthHeadersUtil._armEndpoint,
|
||||
arcadiaEndpoint: AuthHeadersUtil._arcadiaEndpoint
|
||||
},
|
||||
tenant: undefined,
|
||||
cacheLocation: window.navigator.userAgent.indexOf("Edge") > -1 ? "localStorage" : undefined
|
||||
});
|
||||
|
||||
public static getAccessInputMetadata(accessInput: string): Q.Promise<DataModels.AccessInputMetadata> {
|
||||
const deferred: Q.Deferred<DataModels.AccessInputMetadata> = Q.defer<DataModels.AccessInputMetadata>();
|
||||
const url = `${configContext.BACKEND_ENDPOINT}${Constants.ApiEndpoints.guestRuntimeProxy}/accessinputmetadata`;
|
||||
const authType: string = (<any>window).authType;
|
||||
const headers: { [headerName: string]: string } = {};
|
||||
|
||||
if (authType === AuthType.EncryptedToken) {
|
||||
headers[Constants.HttpHeaders.guestAccessToken] = accessInput;
|
||||
} else {
|
||||
headers[Constants.HttpHeaders.connectionString] = accessInput;
|
||||
}
|
||||
|
||||
$.ajax({
|
||||
url: url,
|
||||
type: "GET",
|
||||
headers: headers,
|
||||
cache: false,
|
||||
dataType: "text"
|
||||
}).then(
|
||||
(data: string, textStatus: string, xhr: JQueryXHR<any>) => {
|
||||
if (!data) {
|
||||
NotificationConsoleUtils.logConsoleMessage(ConsoleDataType.Error, `Failed to get access input metadata`);
|
||||
deferred.reject(`Failed to get access input metadata`);
|
||||
}
|
||||
|
||||
try {
|
||||
const metadata: DataModels.AccessInputMetadata = JSON.parse(JSON.parse(data));
|
||||
deferred.resolve(metadata); // TODO: update to a single JSON parse once backend response is stringified exactly once
|
||||
} catch (error) {
|
||||
NotificationConsoleUtils.logConsoleMessage(ConsoleDataType.Error, "Failed to parse access input metadata");
|
||||
deferred.reject("Failed to parse access input metadata");
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
(xhr: JQueryXHR<any>, textStatus: string, error: any) => {
|
||||
NotificationConsoleUtils.logConsoleMessage(
|
||||
ConsoleDataType.Error,
|
||||
`Error while fetching access input metadata: ${JSON.stringify(xhr.responseText)}`
|
||||
);
|
||||
deferred.reject(xhr.responseText);
|
||||
}
|
||||
);
|
||||
|
||||
return deferred.promise.timeout(Constants.ClientDefaults.requestTimeoutMs);
|
||||
}
|
||||
|
||||
public static generateEncryptedToken(): Q.Promise<DataModels.GenerateTokenResponse> {
|
||||
const url = configContext.BACKEND_ENDPOINT + "/api/tokens/generateToken" + AuthHeadersUtil._generateResourceUrl();
|
||||
const explorer = window.dataExplorer;
|
||||
@@ -118,154 +41,6 @@ export default class AuthHeadersUtil {
|
||||
});
|
||||
}
|
||||
|
||||
public static isUserSignedIn(): boolean {
|
||||
const user = AuthHeadersUtil._authContext.getCachedUser();
|
||||
return !!user;
|
||||
}
|
||||
|
||||
public static getCachedUser(): AuthenticationContext.UserInfo {
|
||||
if (this.isUserSignedIn()) {
|
||||
return AuthHeadersUtil._authContext.getCachedUser();
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
public static signIn() {
|
||||
if (!AuthHeadersUtil.isUserSignedIn()) {
|
||||
AuthHeadersUtil._authContext.login();
|
||||
}
|
||||
}
|
||||
|
||||
public static signOut() {
|
||||
AuthHeadersUtil._authContext.logOut();
|
||||
}
|
||||
|
||||
/**
|
||||
* Process token from oauth after login or get cached
|
||||
*/
|
||||
public static processTokenResponse() {
|
||||
const isCallback = AuthHeadersUtil._authContext.isCallback(window.location.hash);
|
||||
if (isCallback && !AuthHeadersUtil._authContext.getLoginError()) {
|
||||
AuthHeadersUtil._authContext.handleWindowCallback();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get auth token to access apis (Graph, ARM)
|
||||
*
|
||||
* @param authEndpoint Default to ARM endpoint
|
||||
* @param tenantId if tenant id provided, tenant id will set at global. Can be reset with 'common'
|
||||
*/
|
||||
public static async getAccessToken(
|
||||
authEndpoint: string = AuthHeadersUtil._armAuthArea,
|
||||
tenantId?: string
|
||||
): Promise<string> {
|
||||
const AuthorizationType: string = (<any>window).authType;
|
||||
if (AuthorizationType === AuthType.EncryptedToken) {
|
||||
// setting authorization header to an undefined value causes the browser to exclude
|
||||
// the header, which is expected here
|
||||
throw new Error("auth type is encrypted token, should not get access token");
|
||||
}
|
||||
|
||||
return new Promise<string>(async (resolve, reject) => {
|
||||
if (tenantId) {
|
||||
// if tenant id passed in, we will use this tenant id for all the rest calls until next tenant id passed in
|
||||
AuthHeadersUtil._authContext.config.tenant = tenantId;
|
||||
}
|
||||
|
||||
AuthHeadersUtil._authContext.acquireToken(
|
||||
authEndpoint,
|
||||
AuthHeadersUtil._authContext.config.tenant,
|
||||
(errorResponse: any, token: any) => {
|
||||
if (errorResponse && typeof errorResponse === "string") {
|
||||
if (errorResponse.indexOf("login is required") >= 0 || errorResponse.indexOf("AADSTS50058") === 0) {
|
||||
// Handle error AADSTS50058: A silent sign-in request was sent but no user is signed in.
|
||||
// The user's cached token is invalid, hence we let the user login again.
|
||||
AuthHeadersUtil._authContext.login();
|
||||
return;
|
||||
}
|
||||
if (
|
||||
this._isMultifactorAuthRequired(errorResponse) ||
|
||||
errorResponse.indexOf("AADSTS53000") > -1 ||
|
||||
errorResponse.indexOf("AADSTS65001") > -1
|
||||
) {
|
||||
// Handle error AADSTS50079 and AADSTS50076: User needs to use multifactor authentication and acquireToken fails silent. Redirect
|
||||
// Handle error AADSTS53000: User needs to use compliant device to access resource when Conditional Access Policy is set up for user.
|
||||
AuthHeadersUtil._authContext.acquireTokenRedirect(
|
||||
authEndpoint,
|
||||
AuthHeadersUtil._authContext.config.tenant
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (errorResponse || !token) {
|
||||
Logger.logError(errorResponse, "Hosted/Authorization/_getAuthHeader");
|
||||
reject(errorResponse);
|
||||
return;
|
||||
}
|
||||
resolve(token);
|
||||
}
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
public static async getPhotoFromGraphAPI(): Promise<Blob> {
|
||||
const token = await this.getAccessToken(AuthHeadersUtil._graphEndpoint);
|
||||
const headers = new Headers();
|
||||
headers.append("Authorization", `Bearer ${token}`);
|
||||
|
||||
try {
|
||||
const response: Response = await fetch(
|
||||
`${AuthHeadersUtil._graphEndpoint}/me/thumbnailPhoto?api-version=${AuthHeadersUtil._graphApiVersion}`,
|
||||
{
|
||||
method: "GET",
|
||||
headers: headers
|
||||
}
|
||||
);
|
||||
if (!response.ok) {
|
||||
throw response;
|
||||
}
|
||||
return response.blob();
|
||||
} catch (err) {
|
||||
return new Blob();
|
||||
}
|
||||
}
|
||||
|
||||
private static async _getTenant(subId: string): Promise<string | undefined> {
|
||||
if (subId) {
|
||||
try {
|
||||
// Follow https://github.com/MicrosoftDocs/azure-docs/blob/master/articles/azure-resource-manager/resource-manager-api-authentication.md
|
||||
// TenantId will be returned in the header of the response.
|
||||
const response: Response = await fetch(
|
||||
`https://management.core.windows.net/subscriptions/${subId}?api-version=2015-01-01`
|
||||
);
|
||||
if (!response.ok) {
|
||||
throw response;
|
||||
}
|
||||
} catch (reason) {
|
||||
if (reason.status === 401) {
|
||||
const authUrl: string = reason.headers
|
||||
.get("www-authenticate")
|
||||
.split(",")[0]
|
||||
.split("=")[1];
|
||||
// Fetch the tenant GUID ID and the length should be 36.
|
||||
const tenantId: string = authUrl.substring(authUrl.lastIndexOf("/") + 1, authUrl.lastIndexOf("/") + 37);
|
||||
return Promise.resolve(tenantId);
|
||||
}
|
||||
}
|
||||
}
|
||||
return Promise.resolve(undefined);
|
||||
}
|
||||
|
||||
private static _isMultifactorAuthRequired(errorResponse: string): boolean {
|
||||
for (const code of ["AADSTS50079", "AADSTS50076"]) {
|
||||
if (errorResponse.indexOf(code) === 0) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private static _generateResourceUrl(): string {
|
||||
const databaseAccount = userContext.databaseAccount;
|
||||
const subscriptionId: string = userContext.subscriptionId;
|
||||
|
||||
46
src/Platform/Hosted/Components/AccountSwitcher.test.tsx
Normal file
46
src/Platform/Hosted/Components/AccountSwitcher.test.tsx
Normal file
@@ -0,0 +1,46 @@
|
||||
jest.mock("../../../hooks/useSubscriptions");
|
||||
jest.mock("../../../hooks/useDatabaseAccounts");
|
||||
import React from "react";
|
||||
import { render, fireEvent, screen } from "@testing-library/react";
|
||||
import "@testing-library/jest-dom";
|
||||
import { AccountSwitcher } from "./AccountSwitcher";
|
||||
import { useSubscriptions } from "../../../hooks/useSubscriptions";
|
||||
import { useDatabaseAccounts } from "../../../hooks/useDatabaseAccounts";
|
||||
import { DatabaseAccount, Subscription } from "../../../Contracts/DataModels";
|
||||
|
||||
it("calls setAccount from parent component", () => {
|
||||
const armToken = "fakeToken";
|
||||
const setDatabaseAccount = jest.fn();
|
||||
const subscriptions = [
|
||||
{ subscriptionId: "testSub1", displayName: "Test Sub 1" },
|
||||
{ subscriptionId: "testSub2", displayName: "Test Sub 2" }
|
||||
] as Subscription[];
|
||||
(useSubscriptions as jest.Mock).mockReturnValue(subscriptions);
|
||||
const accounts = [{ name: "testAccount1" }, { name: "testAccount2" }] as DatabaseAccount[];
|
||||
(useDatabaseAccounts as jest.Mock).mockReturnValue(accounts);
|
||||
|
||||
render(<AccountSwitcher armToken={armToken} setDatabaseAccount={setDatabaseAccount} />);
|
||||
|
||||
fireEvent.click(screen.getByText("Select a Database Account"));
|
||||
expect(screen.getByLabelText("Subscription")).toHaveTextContent("Select a Subscription");
|
||||
fireEvent.click(screen.getByText("Select a Subscription"));
|
||||
fireEvent.click(screen.getByText(subscriptions[0].displayName));
|
||||
expect(screen.getByLabelText("Cosmos DB Account Name")).toHaveTextContent("Select an Account");
|
||||
fireEvent.click(screen.getByText("Select an Account"));
|
||||
fireEvent.click(screen.getByText(accounts[0].name));
|
||||
expect(setDatabaseAccount).toHaveBeenCalledWith(accounts[0]);
|
||||
});
|
||||
|
||||
it("No subscriptions", () => {
|
||||
const armToken = "fakeToken";
|
||||
const setDatabaseAccount = jest.fn();
|
||||
const subscriptions = [] as Subscription[];
|
||||
(useSubscriptions as jest.Mock).mockReturnValue(subscriptions);
|
||||
const accounts = [] as DatabaseAccount[];
|
||||
(useDatabaseAccounts as jest.Mock).mockReturnValue(accounts);
|
||||
|
||||
render(<AccountSwitcher armToken={armToken} setDatabaseAccount={setDatabaseAccount} />);
|
||||
|
||||
fireEvent.click(screen.getByText("Select a Database Account"));
|
||||
expect(screen.getByLabelText("Subscription")).toHaveTextContent("No Subscriptions Found");
|
||||
});
|
||||
109
src/Platform/Hosted/Components/AccountSwitcher.tsx
Normal file
109
src/Platform/Hosted/Components/AccountSwitcher.tsx
Normal file
@@ -0,0 +1,109 @@
|
||||
// TODO: Renable this rule for the file or turn it off everywhere
|
||||
/* eslint-disable react/display-name */
|
||||
|
||||
import { StyleConstants } from "../../../Common/Constants";
|
||||
import { FunctionComponent, useState, useEffect } from "react";
|
||||
import * as React from "react";
|
||||
import { DefaultButton, IButtonStyles } from "office-ui-fabric-react/lib/Button";
|
||||
import { IContextualMenuItem } from "office-ui-fabric-react/lib/ContextualMenu";
|
||||
import { DatabaseAccount } from "../../../Contracts/DataModels";
|
||||
import { useSubscriptions } from "../../../hooks/useSubscriptions";
|
||||
import { useDatabaseAccounts } from "../../../hooks/useDatabaseAccounts";
|
||||
import { SwitchSubscription } from "./SwitchSubscription";
|
||||
import { SwitchAccount } from "./SwitchAccount";
|
||||
|
||||
const buttonStyles: IButtonStyles = {
|
||||
root: {
|
||||
fontSize: StyleConstants.DefaultFontSize,
|
||||
height: 40,
|
||||
padding: 0,
|
||||
paddingLeft: 10,
|
||||
marginRight: 5,
|
||||
backgroundColor: StyleConstants.BaseDark,
|
||||
color: StyleConstants.BaseLight
|
||||
},
|
||||
rootHovered: {
|
||||
backgroundColor: StyleConstants.BaseHigh,
|
||||
color: StyleConstants.BaseLight
|
||||
},
|
||||
rootFocused: {
|
||||
backgroundColor: StyleConstants.BaseHigh,
|
||||
color: StyleConstants.BaseLight
|
||||
},
|
||||
rootPressed: {
|
||||
backgroundColor: StyleConstants.BaseHigh,
|
||||
color: StyleConstants.BaseLight
|
||||
},
|
||||
rootExpanded: {
|
||||
backgroundColor: StyleConstants.BaseHigh,
|
||||
color: StyleConstants.BaseLight
|
||||
},
|
||||
textContainer: {
|
||||
flexGrow: "initial"
|
||||
}
|
||||
};
|
||||
|
||||
interface Props {
|
||||
armToken: string;
|
||||
setDatabaseAccount: (account: DatabaseAccount) => void;
|
||||
}
|
||||
|
||||
export const AccountSwitcher: FunctionComponent<Props> = ({ armToken, setDatabaseAccount }: Props) => {
|
||||
const subscriptions = useSubscriptions(armToken);
|
||||
const [selectedSubscriptionId, setSelectedSubscriptionId] = useState<string>(() =>
|
||||
localStorage.getItem("cachedSubscriptionId")
|
||||
);
|
||||
const selectedSubscription = subscriptions?.find(sub => sub.subscriptionId === selectedSubscriptionId);
|
||||
const accounts = useDatabaseAccounts(selectedSubscription?.subscriptionId, armToken);
|
||||
const [selectedAccountName, setSelectedAccountName] = useState<string>(() =>
|
||||
localStorage.getItem("cachedDatabaseAccountName")
|
||||
);
|
||||
const selectedAccount = accounts?.find(account => account.name === selectedAccountName);
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedAccountName) {
|
||||
localStorage.setItem("cachedDatabaseAccountName", selectedAccountName);
|
||||
}
|
||||
}, [selectedAccountName]);
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedSubscriptionId) {
|
||||
localStorage.setItem("cachedSubscriptionId", selectedSubscriptionId);
|
||||
}
|
||||
}, [selectedSubscriptionId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedAccount) {
|
||||
setDatabaseAccount(selectedAccount);
|
||||
}
|
||||
}, [selectedAccount]);
|
||||
|
||||
const buttonText = selectedAccount?.name || "Select a Database Account";
|
||||
|
||||
const items: IContextualMenuItem[] = [
|
||||
{
|
||||
key: "switchSubscription",
|
||||
onRender: () => <SwitchSubscription {...{ subscriptions, setSelectedSubscriptionId, selectedSubscription }} />
|
||||
},
|
||||
{
|
||||
key: "switchAccount",
|
||||
onRender: (_, dismissMenu) => (
|
||||
<SwitchAccount {...{ accounts, dismissMenu, selectedAccount, setSelectedAccountName }} />
|
||||
)
|
||||
}
|
||||
];
|
||||
|
||||
return (
|
||||
<DefaultButton
|
||||
text={buttonText}
|
||||
menuProps={{
|
||||
directionalHintFixed: true,
|
||||
className: "accountSwitchContextualMenu",
|
||||
items
|
||||
}}
|
||||
styles={buttonStyles}
|
||||
className="accountSwitchButton"
|
||||
id="accountSwitchButton"
|
||||
/>
|
||||
);
|
||||
};
|
||||
18
src/Platform/Hosted/Components/ConnectExplorer.test.tsx
Normal file
18
src/Platform/Hosted/Components/ConnectExplorer.test.tsx
Normal file
@@ -0,0 +1,18 @@
|
||||
jest.mock("../../../hooks/useDirectories");
|
||||
import "@testing-library/jest-dom";
|
||||
import { fireEvent, render, screen } from "@testing-library/react";
|
||||
import React from "react";
|
||||
import { ConnectExplorer } from "./ConnectExplorer";
|
||||
|
||||
it("shows the connect form", () => {
|
||||
const connectionString = "fakeConnectionString";
|
||||
const login = jest.fn();
|
||||
const setConnectionString = jest.fn();
|
||||
const setEncryptedToken = jest.fn();
|
||||
const setAuthType = jest.fn();
|
||||
|
||||
render(<ConnectExplorer {...{ login, setEncryptedToken, setAuthType, connectionString, setConnectionString }} />);
|
||||
expect(screen.queryByPlaceholderText("Please enter a connection string")).toBeNull();
|
||||
fireEvent.click(screen.getByText("Connect to your account with connection string"));
|
||||
expect(screen.queryByPlaceholderText("Please enter a connection string")).toBeDefined();
|
||||
});
|
||||
94
src/Platform/Hosted/Components/ConnectExplorer.tsx
Normal file
94
src/Platform/Hosted/Components/ConnectExplorer.tsx
Normal file
@@ -0,0 +1,94 @@
|
||||
import * as React from "react";
|
||||
import { useBoolean } from "@uifabric/react-hooks";
|
||||
import { HttpHeaders } from "../../../Common/Constants";
|
||||
import { GenerateTokenResponse } from "../../../Contracts/DataModels";
|
||||
import { configContext } from "../../../ConfigContext";
|
||||
import { AuthType } from "../../../AuthType";
|
||||
import { isResourceTokenConnectionString } from "../Helpers/ResourceTokenUtils";
|
||||
|
||||
interface Props {
|
||||
connectionString: string;
|
||||
login: () => void;
|
||||
setEncryptedToken: (token: string) => void;
|
||||
setConnectionString: (connectionString: string) => void;
|
||||
setAuthType: (authType: AuthType) => void;
|
||||
}
|
||||
|
||||
export const ConnectExplorer: React.FunctionComponent<Props> = ({
|
||||
setEncryptedToken,
|
||||
login,
|
||||
setAuthType,
|
||||
connectionString,
|
||||
setConnectionString
|
||||
}: Props) => {
|
||||
const [isFormVisible, { setTrue: showForm }] = useBoolean(false);
|
||||
|
||||
return (
|
||||
<div id="connectExplorer" className="connectExplorerContainer" style={{ display: "flex" }}>
|
||||
<div className="connectExplorerFormContainer">
|
||||
<div className="connectExplorer">
|
||||
<p className="connectExplorerContent">
|
||||
<img src="images/HdeConnectCosmosDB.svg" alt="Azure Cosmos DB" />
|
||||
</p>
|
||||
<p className="welcomeText">Welcome to Azure Cosmos DB</p>
|
||||
{isFormVisible ? (
|
||||
<form
|
||||
id="connectWithConnectionString"
|
||||
onSubmit={async event => {
|
||||
event.preventDefault();
|
||||
|
||||
if (isResourceTokenConnectionString(connectionString)) {
|
||||
setAuthType(AuthType.ResourceToken);
|
||||
return;
|
||||
}
|
||||
|
||||
const headers = new Headers();
|
||||
headers.append(HttpHeaders.connectionString, connectionString);
|
||||
const url = configContext.BACKEND_ENDPOINT + "/api/guest/tokens/generateToken";
|
||||
const response = await fetch(url, { headers, method: "POST" });
|
||||
if (!response.ok) {
|
||||
throw response;
|
||||
}
|
||||
// This API has a quirk where it must be parsed twice
|
||||
const result: GenerateTokenResponse = JSON.parse(await response.json());
|
||||
setEncryptedToken(decodeURIComponent(result.readWrite || result.read));
|
||||
setAuthType(AuthType.ConnectionString);
|
||||
}}
|
||||
>
|
||||
<p className="connectExplorerContent connectStringText">Connect to your account with connection string</p>
|
||||
<p className="connectExplorerContent">
|
||||
<input
|
||||
className="inputToken"
|
||||
type="text"
|
||||
required
|
||||
placeholder="Please enter a connection string"
|
||||
value={connectionString}
|
||||
onChange={event => {
|
||||
setConnectionString(event.target.value);
|
||||
}}
|
||||
/>
|
||||
<span className="errorDetailsInfoTooltip" style={{ display: "none" }}>
|
||||
<img className="errorImg" src="images/error.svg" alt="Error notification" />
|
||||
<span className="errorDetails"></span>
|
||||
</span>
|
||||
</p>
|
||||
<p className="connectExplorerContent">
|
||||
<input className="filterbtnstyle" type="submit" value="Connect" />
|
||||
</p>
|
||||
<p className="switchConnectTypeText" onClick={login}>
|
||||
Sign In with Azure Account
|
||||
</p>
|
||||
</form>
|
||||
) : (
|
||||
<div id="connectWithAad">
|
||||
<input className="filterbtnstyle" type="button" value="Sign In" onClick={login} />
|
||||
<p className="switchConnectTypeText" onClick={showForm}>
|
||||
Connect to your account with connection string
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
31
src/Platform/Hosted/Components/DirectoryPickerPanel.test.tsx
Normal file
31
src/Platform/Hosted/Components/DirectoryPickerPanel.test.tsx
Normal file
@@ -0,0 +1,31 @@
|
||||
jest.mock("../../../hooks/useDirectories");
|
||||
import "@testing-library/jest-dom";
|
||||
import { fireEvent, render, screen } from "@testing-library/react";
|
||||
import React from "react";
|
||||
import { Tenant } from "../../../Contracts/DataModels";
|
||||
import { useDirectories } from "../../../hooks/useDirectories";
|
||||
import { DirectoryPickerPanel } from "./DirectoryPickerPanel";
|
||||
|
||||
it("switches tenant for user", () => {
|
||||
const armToken = "fakeToken";
|
||||
const switchTenant = jest.fn();
|
||||
const dismissPanel = jest.fn();
|
||||
const directories = [
|
||||
{ displayName: "test1", tenantId: "test1-id" },
|
||||
{ displayName: "test2", tenantId: "test2-id" }
|
||||
] as Tenant[];
|
||||
(useDirectories as jest.Mock).mockReturnValue(directories);
|
||||
|
||||
render(
|
||||
<DirectoryPickerPanel
|
||||
armToken={armToken}
|
||||
isOpen={true}
|
||||
tenantId="test1-id"
|
||||
switchTenant={switchTenant}
|
||||
dismissPanel={dismissPanel}
|
||||
/>
|
||||
);
|
||||
fireEvent.click(screen.getByLabelText(/test2-id/));
|
||||
expect(switchTenant).toHaveBeenCalledWith(directories[1].tenantId);
|
||||
expect(dismissPanel).toHaveBeenCalled();
|
||||
});
|
||||
39
src/Platform/Hosted/Components/DirectoryPickerPanel.tsx
Normal file
39
src/Platform/Hosted/Components/DirectoryPickerPanel.tsx
Normal file
@@ -0,0 +1,39 @@
|
||||
import { Panel, PanelType, ChoiceGroup } from "office-ui-fabric-react";
|
||||
import * as React from "react";
|
||||
import { useDirectories } from "../../../hooks/useDirectories";
|
||||
|
||||
interface Props {
|
||||
isOpen: boolean;
|
||||
dismissPanel: () => void;
|
||||
tenantId: string;
|
||||
armToken: string;
|
||||
switchTenant: (tenantId: string) => void;
|
||||
}
|
||||
|
||||
export const DirectoryPickerPanel: React.FunctionComponent<Props> = ({
|
||||
isOpen,
|
||||
dismissPanel,
|
||||
armToken,
|
||||
tenantId,
|
||||
switchTenant
|
||||
}: Props) => {
|
||||
const directories = useDirectories(armToken);
|
||||
return (
|
||||
<Panel
|
||||
type={PanelType.medium}
|
||||
headerText="Select Directory"
|
||||
isOpen={isOpen}
|
||||
onDismiss={dismissPanel}
|
||||
closeButtonAriaLabel="Close"
|
||||
>
|
||||
<ChoiceGroup
|
||||
options={directories.map(dir => ({ key: dir.tenantId, text: `${dir.displayName} (${dir.tenantId})` }))}
|
||||
selectedKey={tenantId}
|
||||
onChange={(event, option) => {
|
||||
switchTenant(option.key);
|
||||
dismissPanel();
|
||||
}}
|
||||
/>
|
||||
</Panel>
|
||||
);
|
||||
};
|
||||
22
src/Platform/Hosted/Components/FeedbackCommandButton.tsx
Normal file
22
src/Platform/Hosted/Components/FeedbackCommandButton.tsx
Normal file
@@ -0,0 +1,22 @@
|
||||
import * as React from "react";
|
||||
import { CommandButtonComponent } from "../../../Explorer/Controls/CommandButton/CommandButtonComponent";
|
||||
import FeedbackIcon from "../../../../images/Feedback.svg";
|
||||
|
||||
export const FeedbackCommandButton: React.FunctionComponent = () => {
|
||||
return (
|
||||
<div className="feedbackConnectSettingIcons">
|
||||
<CommandButtonComponent
|
||||
id="commandbutton-feedback"
|
||||
iconSrc={FeedbackIcon}
|
||||
iconAlt="feeback button"
|
||||
onCommandClick={() =>
|
||||
window.open("https://aka.ms/cosmosdbfeedback?subject=Cosmos%20DB%20Hosted%20Data%20Explorer%20Feedback")
|
||||
}
|
||||
ariaLabel="feeback button"
|
||||
tooltipText="Send feedback"
|
||||
hasPopup={true}
|
||||
disabled={false}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
17
src/Platform/Hosted/Components/MeControl.test.tsx
Normal file
17
src/Platform/Hosted/Components/MeControl.test.tsx
Normal file
@@ -0,0 +1,17 @@
|
||||
jest.mock("../../../hooks/useDirectories");
|
||||
import "@testing-library/jest-dom";
|
||||
import { fireEvent, render, screen } from "@testing-library/react";
|
||||
import React from "react";
|
||||
import { MeControl } from "./MeControl";
|
||||
import { Account } from "msal";
|
||||
|
||||
it("renders", () => {
|
||||
const account = {} as Account;
|
||||
const logout = jest.fn();
|
||||
const openPanel = jest.fn();
|
||||
|
||||
render(<MeControl graphToken="" account={account} logout={logout} openPanel={openPanel} />);
|
||||
fireEvent.click(screen.getByRole("button"));
|
||||
expect(screen.getByText("Switch Directory")).toBeDefined();
|
||||
expect(screen.getByText("Sign Out")).toBeDefined();
|
||||
});
|
||||
68
src/Platform/Hosted/Components/MeControl.tsx
Normal file
68
src/Platform/Hosted/Components/MeControl.tsx
Normal file
@@ -0,0 +1,68 @@
|
||||
import {
|
||||
FocusZone,
|
||||
DefaultButton,
|
||||
DirectionalHint,
|
||||
Persona,
|
||||
PersonaInitialsColor,
|
||||
PersonaSize
|
||||
} from "office-ui-fabric-react";
|
||||
import * as React from "react";
|
||||
import { Account } from "msal";
|
||||
import { useGraphPhoto } from "../../../hooks/useGraphPhoto";
|
||||
|
||||
interface Props {
|
||||
graphToken: string;
|
||||
account: Account;
|
||||
openPanel: () => void;
|
||||
logout: () => void;
|
||||
}
|
||||
|
||||
export const MeControl: React.FunctionComponent<Props> = ({ openPanel, logout, account, graphToken }: Props) => {
|
||||
const photo = useGraphPhoto(graphToken);
|
||||
return (
|
||||
<FocusZone>
|
||||
<DefaultButton
|
||||
id="mecontrolHeader"
|
||||
className="mecontrolHeaderButton"
|
||||
menuProps={{
|
||||
className: "mecontrolContextualMenu",
|
||||
isBeakVisible: false,
|
||||
directionalHintFixed: true,
|
||||
directionalHint: DirectionalHint.bottomRightEdge,
|
||||
calloutProps: {
|
||||
minPagePadding: 0
|
||||
},
|
||||
items: [
|
||||
{
|
||||
key: "SwitchDirectory",
|
||||
text: "Switch Directory",
|
||||
onClick: openPanel
|
||||
},
|
||||
{
|
||||
key: "SignOut",
|
||||
text: "Sign Out",
|
||||
onClick: logout
|
||||
}
|
||||
]
|
||||
}}
|
||||
styles={{
|
||||
rootHovered: { backgroundColor: "#393939" },
|
||||
rootFocused: { backgroundColor: "#393939" },
|
||||
rootPressed: { backgroundColor: "#393939" },
|
||||
rootExpanded: { backgroundColor: "#393939" }
|
||||
}}
|
||||
>
|
||||
<Persona
|
||||
imageUrl={photo}
|
||||
text={account?.name}
|
||||
secondaryText={account?.userName}
|
||||
showSecondaryText={true}
|
||||
showInitialsUntilImageLoads={true}
|
||||
initialsColor={PersonaInitialsColor.teal}
|
||||
size={PersonaSize.size28}
|
||||
className="mecontrolHeaderPersona"
|
||||
/>
|
||||
</DefaultButton>
|
||||
</FocusZone>
|
||||
);
|
||||
};
|
||||
21
src/Platform/Hosted/Components/SignInButton.tsx
Normal file
21
src/Platform/Hosted/Components/SignInButton.tsx
Normal file
@@ -0,0 +1,21 @@
|
||||
import { DefaultButton } from "office-ui-fabric-react";
|
||||
import * as React from "react";
|
||||
|
||||
interface Props {
|
||||
login: () => void;
|
||||
}
|
||||
|
||||
export const SignInButton: React.FunctionComponent<Props> = ({ login }: Props) => {
|
||||
return (
|
||||
<DefaultButton
|
||||
className="mecontrolSigninButton"
|
||||
text="Sign In"
|
||||
onClick={login}
|
||||
styles={{
|
||||
rootHovered: { backgroundColor: "#393939", color: "#fff" },
|
||||
rootFocused: { backgroundColor: "#393939", color: "#fff" },
|
||||
rootPressed: { backgroundColor: "#393939", color: "#fff" }
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
39
src/Platform/Hosted/Components/SwitchAccount.tsx
Normal file
39
src/Platform/Hosted/Components/SwitchAccount.tsx
Normal file
@@ -0,0 +1,39 @@
|
||||
import { Dropdown } from "office-ui-fabric-react/lib/Dropdown";
|
||||
import * as React from "react";
|
||||
import { FunctionComponent } from "react";
|
||||
import { DatabaseAccount } from "../../../Contracts/DataModels";
|
||||
|
||||
interface Props {
|
||||
accounts: DatabaseAccount[];
|
||||
selectedAccount: DatabaseAccount;
|
||||
setSelectedAccountName: (id: string) => void;
|
||||
dismissMenu: () => void;
|
||||
}
|
||||
|
||||
export const SwitchAccount: FunctionComponent<Props> = ({
|
||||
accounts,
|
||||
setSelectedAccountName,
|
||||
selectedAccount,
|
||||
dismissMenu
|
||||
}: Props) => {
|
||||
return (
|
||||
<Dropdown
|
||||
label="Cosmos DB Account Name"
|
||||
className="accountSwitchAccountDropdown"
|
||||
options={accounts?.map(account => ({
|
||||
key: account.name,
|
||||
text: account.name,
|
||||
data: account
|
||||
}))}
|
||||
onChange={(_, option) => {
|
||||
setSelectedAccountName(String(option.key));
|
||||
dismissMenu();
|
||||
}}
|
||||
defaultSelectedKey={selectedAccount?.name}
|
||||
placeholder={accounts && accounts.length === 0 ? "No Accounts Found" : "Select an Account"}
|
||||
styles={{
|
||||
callout: "accountSwitchAccountDropdownMenu"
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
38
src/Platform/Hosted/Components/SwitchSubscription.tsx
Normal file
38
src/Platform/Hosted/Components/SwitchSubscription.tsx
Normal file
@@ -0,0 +1,38 @@
|
||||
import { Dropdown } from "office-ui-fabric-react/lib/Dropdown";
|
||||
import * as React from "react";
|
||||
import { FunctionComponent } from "react";
|
||||
import { Subscription } from "../../../Contracts/DataModels";
|
||||
|
||||
interface Props {
|
||||
subscriptions: Subscription[];
|
||||
selectedSubscription: Subscription;
|
||||
setSelectedSubscriptionId: (id: string) => void;
|
||||
}
|
||||
|
||||
export const SwitchSubscription: FunctionComponent<Props> = ({
|
||||
subscriptions,
|
||||
setSelectedSubscriptionId,
|
||||
selectedSubscription
|
||||
}: Props) => {
|
||||
return (
|
||||
<Dropdown
|
||||
label="Subscription"
|
||||
className="accountSwitchSubscriptionDropdown"
|
||||
options={subscriptions?.map(sub => {
|
||||
return {
|
||||
key: sub.subscriptionId,
|
||||
text: sub.displayName,
|
||||
data: sub
|
||||
};
|
||||
})}
|
||||
onChange={(_, option) => {
|
||||
setSelectedSubscriptionId(String(option.key));
|
||||
}}
|
||||
defaultSelectedKey={selectedSubscription?.subscriptionId}
|
||||
placeholder={subscriptions && subscriptions.length === 0 ? "No Subscriptions Found" : "Select a Subscription"}
|
||||
styles={{
|
||||
callout: "accountSwitchSubscriptionDropdownMenu"
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
101
src/Platform/Hosted/ConnectScreen.less
Normal file
101
src/Platform/Hosted/ConnectScreen.less
Normal file
@@ -0,0 +1,101 @@
|
||||
.connectExplorerContainer {
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
}
|
||||
.connectExplorerContainer .connectExplorerFormContainer {
|
||||
display: -webkit-flex;
|
||||
display: -ms-flexbox;
|
||||
display: -ms-flex;
|
||||
display: flex;
|
||||
-webkit-flex-direction: column;
|
||||
-ms-flex-direction: column;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
}
|
||||
.connectExplorerContainer .connectExplorer {
|
||||
text-align: center;
|
||||
display: -webkit-flex;
|
||||
display: -ms-flexbox;
|
||||
display: -ms-flex;
|
||||
display: flex;
|
||||
-webkit-flex-direction: column;
|
||||
-ms-flex-direction: column;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
margin-bottom: 60px;
|
||||
}
|
||||
.connectExplorerContainer .connectExplorer .welcomeText {
|
||||
font-size: 14px;
|
||||
color: #393939;
|
||||
margin: 8px 8px 16px 8px;
|
||||
}
|
||||
.connectExplorerContainer .connectExplorer .switchConnectTypeText {
|
||||
margin: 8px;
|
||||
font-size: 12px;
|
||||
color: #0058ad;
|
||||
cursor: pointer;
|
||||
}
|
||||
.connectExplorerContainer .connectExplorer .connectStringText {
|
||||
font-size: 12px;
|
||||
color: #393939;
|
||||
}
|
||||
.connectExplorerContainer .connectExplorer .connectExplorerContent {
|
||||
margin: 8px;
|
||||
color: #393939;
|
||||
}
|
||||
.connectExplorerContainer .connectExplorer .connectExplorerContent .inputToken {
|
||||
width: 300px;
|
||||
padding: 0px 4px;
|
||||
text-overflow: ellipsis;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.connectExplorerContainer .connectExplorer .connectExplorerContent .inputToken::placeholder {
|
||||
font-style: italic;
|
||||
}
|
||||
.connectExplorerContainer .connectExplorer .connectExplorerContent .errorDetailsInfoTooltip {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
padding-left: 4px;
|
||||
vertical-align: top;
|
||||
}
|
||||
.connectExplorerContainer .connectExplorer .connectExplorerContent .errorDetailsInfoTooltip:hover .errorDetails {
|
||||
visibility: visible;
|
||||
}
|
||||
.connectExplorerContainer .connectExplorer .connectExplorerContent .errorDetailsInfoTooltip .errorDetails {
|
||||
bottom: 24px;
|
||||
width: 145px;
|
||||
visibility: hidden;
|
||||
background-color: #393939;
|
||||
color: #ffffff;
|
||||
position: absolute;
|
||||
z-index: 1;
|
||||
left: -10px;
|
||||
padding: 6px;
|
||||
}
|
||||
.connectExplorerContainer .connectExplorer .connectExplorerContent .errorDetailsInfoTooltip .errorDetails:after {
|
||||
border-width: 10px 10px 0px 10px;
|
||||
bottom: -8px;
|
||||
content: "";
|
||||
position: absolute;
|
||||
right: 100%;
|
||||
border-style: solid;
|
||||
left: 12px;
|
||||
width: 0;
|
||||
height: 0;
|
||||
border-color: #3b3b3b transparent;
|
||||
}
|
||||
.connectExplorerContainer .connectExplorer .connectExplorerContent .errorDetailsInfoTooltip .errorImg {
|
||||
height: 14px;
|
||||
width: 14px;
|
||||
}
|
||||
|
||||
.filterbtnstyle {
|
||||
background: #0058ad;
|
||||
width: 90px;
|
||||
height: 25px;
|
||||
color: white;
|
||||
border: solid 1px;
|
||||
}
|
||||
@@ -1,12 +1,12 @@
|
||||
import * as DataModels from "../../../Contracts/DataModels";
|
||||
import { ConnectionStringParser } from "./ConnectionStringParser";
|
||||
import { parseConnectionString } from "./ConnectionStringParser";
|
||||
|
||||
describe("ConnectionStringParser", () => {
|
||||
const mockAccountName: string = "Test";
|
||||
const mockMasterKey: string = "some-key";
|
||||
|
||||
it("should parse a valid sql account connection string", () => {
|
||||
const metadata: DataModels.AccessInputMetadata = ConnectionStringParser.parseConnectionString(
|
||||
const metadata = parseConnectionString(
|
||||
`AccountEndpoint=https://${mockAccountName}.documents.azure.com:443/;AccountKey=${mockMasterKey};`
|
||||
);
|
||||
|
||||
@@ -15,7 +15,7 @@ describe("ConnectionStringParser", () => {
|
||||
});
|
||||
|
||||
it("should parse a valid mongo account connection string", () => {
|
||||
const metadata: DataModels.AccessInputMetadata = ConnectionStringParser.parseConnectionString(
|
||||
const metadata = parseConnectionString(
|
||||
`mongodb://${mockAccountName}:${mockMasterKey}@${mockAccountName}.documents.azure.com:10255`
|
||||
);
|
||||
|
||||
@@ -24,7 +24,7 @@ describe("ConnectionStringParser", () => {
|
||||
});
|
||||
|
||||
it("should parse a valid compute mongo account connection string", () => {
|
||||
const metadata: DataModels.AccessInputMetadata = ConnectionStringParser.parseConnectionString(
|
||||
const metadata = parseConnectionString(
|
||||
`mongodb://${mockAccountName}:${mockMasterKey}@${mockAccountName}.mongo.cosmos.azure.com:10255`
|
||||
);
|
||||
|
||||
@@ -33,7 +33,7 @@ describe("ConnectionStringParser", () => {
|
||||
});
|
||||
|
||||
it("should parse a valid graph account connection string", () => {
|
||||
const metadata: DataModels.AccessInputMetadata = ConnectionStringParser.parseConnectionString(
|
||||
const metadata = parseConnectionString(
|
||||
`AccountEndpoint=https://${mockAccountName}.documents.azure.com:443/;AccountKey=${mockMasterKey};ApiKind=Gremlin;`
|
||||
);
|
||||
|
||||
@@ -42,7 +42,7 @@ describe("ConnectionStringParser", () => {
|
||||
});
|
||||
|
||||
it("should parse a valid table account connection string", () => {
|
||||
const metadata: DataModels.AccessInputMetadata = ConnectionStringParser.parseConnectionString(
|
||||
const metadata = parseConnectionString(
|
||||
`DefaultEndpointsProtocol=https;AccountName=${mockAccountName};AccountKey=${mockMasterKey};TableEndpoint=https://${mockAccountName}.table.cosmosdb.azure.com:443/;`
|
||||
);
|
||||
|
||||
@@ -51,7 +51,7 @@ describe("ConnectionStringParser", () => {
|
||||
});
|
||||
|
||||
it("should parse a valid cassandra account connection string", () => {
|
||||
const metadata: DataModels.AccessInputMetadata = ConnectionStringParser.parseConnectionString(
|
||||
const metadata = parseConnectionString(
|
||||
`AccountEndpoint=${mockAccountName}.cassandra.cosmosdb.azure.com;AccountKey=${mockMasterKey};`
|
||||
);
|
||||
|
||||
@@ -60,15 +60,13 @@ describe("ConnectionStringParser", () => {
|
||||
});
|
||||
|
||||
it("should fail to parse an invalid connection string", () => {
|
||||
const metadata: DataModels.AccessInputMetadata = ConnectionStringParser.parseConnectionString(
|
||||
"some-rogue-connection-string"
|
||||
);
|
||||
const metadata = parseConnectionString("some-rogue-connection-string");
|
||||
|
||||
expect(metadata).toBe(undefined);
|
||||
});
|
||||
|
||||
it("should fail to parse an empty connection string", () => {
|
||||
const metadata: DataModels.AccessInputMetadata = ConnectionStringParser.parseConnectionString("");
|
||||
const metadata = parseConnectionString("");
|
||||
|
||||
expect(metadata).toBe(undefined);
|
||||
});
|
||||
|
||||
@@ -1,50 +1,48 @@
|
||||
import * as Constants from "../../../Common/Constants";
|
||||
import * as DataModels from "../../../Contracts/DataModels";
|
||||
import { AccessInputMetadata, ApiKind } from "../../../Contracts/DataModels";
|
||||
|
||||
export class ConnectionStringParser {
|
||||
public static parseConnectionString(connectionString: string): DataModels.AccessInputMetadata {
|
||||
if (!!connectionString) {
|
||||
try {
|
||||
const accessInput: DataModels.AccessInputMetadata = {} as DataModels.AccessInputMetadata;
|
||||
const connectionStringParts = connectionString.split(";");
|
||||
export function parseConnectionString(connectionString: string): AccessInputMetadata {
|
||||
if (connectionString) {
|
||||
try {
|
||||
const accessInput = {} as AccessInputMetadata;
|
||||
const connectionStringParts = connectionString.split(";");
|
||||
|
||||
connectionStringParts.forEach((connectionStringPart: string) => {
|
||||
if (RegExp(Constants.EndpointsRegex.sql).test(connectionStringPart)) {
|
||||
accessInput.accountName = connectionStringPart.match(Constants.EndpointsRegex.sql)[1];
|
||||
accessInput.apiKind = DataModels.ApiKind.SQL;
|
||||
} else if (RegExp(Constants.EndpointsRegex.mongo).test(connectionStringPart)) {
|
||||
const matches: string[] = connectionStringPart.match(Constants.EndpointsRegex.mongo);
|
||||
accessInput.accountName = matches && matches.length > 1 && matches[2];
|
||||
accessInput.apiKind = DataModels.ApiKind.MongoDB;
|
||||
} else if (RegExp(Constants.EndpointsRegex.mongoCompute).test(connectionStringPart)) {
|
||||
const matches: string[] = connectionStringPart.match(Constants.EndpointsRegex.mongoCompute);
|
||||
accessInput.accountName = matches && matches.length > 1 && matches[2];
|
||||
accessInput.apiKind = DataModels.ApiKind.MongoDBCompute;
|
||||
} else if (Constants.EndpointsRegex.cassandra.some(regex => RegExp(regex).test(connectionStringPart))) {
|
||||
Constants.EndpointsRegex.cassandra.forEach(regex => {
|
||||
if (RegExp(regex).test(connectionStringPart)) {
|
||||
accessInput.accountName = connectionStringPart.match(regex)[1];
|
||||
accessInput.apiKind = DataModels.ApiKind.Cassandra;
|
||||
}
|
||||
});
|
||||
} else if (RegExp(Constants.EndpointsRegex.table).test(connectionStringPart)) {
|
||||
accessInput.accountName = connectionStringPart.match(Constants.EndpointsRegex.table)[1];
|
||||
accessInput.apiKind = DataModels.ApiKind.Table;
|
||||
} else if (connectionStringPart.indexOf("ApiKind=Gremlin") >= 0) {
|
||||
accessInput.apiKind = DataModels.ApiKind.Graph;
|
||||
}
|
||||
});
|
||||
|
||||
if (Object.keys(accessInput).length === 0) {
|
||||
return undefined;
|
||||
connectionStringParts.forEach((connectionStringPart: string) => {
|
||||
if (RegExp(Constants.EndpointsRegex.sql).test(connectionStringPart)) {
|
||||
accessInput.accountName = connectionStringPart.match(Constants.EndpointsRegex.sql)[1];
|
||||
accessInput.apiKind = ApiKind.SQL;
|
||||
} else if (RegExp(Constants.EndpointsRegex.mongo).test(connectionStringPart)) {
|
||||
const matches: string[] = connectionStringPart.match(Constants.EndpointsRegex.mongo);
|
||||
accessInput.accountName = matches && matches.length > 1 && matches[2];
|
||||
accessInput.apiKind = ApiKind.MongoDB;
|
||||
} else if (RegExp(Constants.EndpointsRegex.mongoCompute).test(connectionStringPart)) {
|
||||
const matches: string[] = connectionStringPart.match(Constants.EndpointsRegex.mongoCompute);
|
||||
accessInput.accountName = matches && matches.length > 1 && matches[2];
|
||||
accessInput.apiKind = ApiKind.MongoDBCompute;
|
||||
} else if (Constants.EndpointsRegex.cassandra.some(regex => RegExp(regex).test(connectionStringPart))) {
|
||||
Constants.EndpointsRegex.cassandra.forEach(regex => {
|
||||
if (RegExp(regex).test(connectionStringPart)) {
|
||||
accessInput.accountName = connectionStringPart.match(regex)[1];
|
||||
accessInput.apiKind = ApiKind.Cassandra;
|
||||
}
|
||||
});
|
||||
} else if (RegExp(Constants.EndpointsRegex.table).test(connectionStringPart)) {
|
||||
accessInput.accountName = connectionStringPart.match(Constants.EndpointsRegex.table)[1];
|
||||
accessInput.apiKind = ApiKind.Table;
|
||||
} else if (connectionStringPart.indexOf("ApiKind=Gremlin") >= 0) {
|
||||
accessInput.apiKind = ApiKind.Graph;
|
||||
}
|
||||
});
|
||||
|
||||
return accessInput;
|
||||
} catch (error) {
|
||||
if (Object.keys(accessInput).length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
return undefined;
|
||||
return accessInput;
|
||||
} catch (error) {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
@@ -1,24 +1,10 @@
|
||||
import Main from "./Main";
|
||||
|
||||
describe("Main", () => {
|
||||
it("correctly detects feature flags", () => {
|
||||
// Search containing non-features, with Camelcase keys and uri encoded values
|
||||
const params = new URLSearchParams(
|
||||
"?platform=Hosted&feature.notebookserverurl=https%3A%2F%2Flocalhost%3A10001%2F12345%2Fnotebook&feature.notebookServerToken=token&feature.enablenotebooks=true&key=mykey"
|
||||
);
|
||||
const features = Main.extractFeatures(params);
|
||||
|
||||
expect(features).toEqual({
|
||||
notebookserverurl: "https://localhost:10001/12345/notebook",
|
||||
notebookservertoken: "token",
|
||||
enablenotebooks: "true"
|
||||
});
|
||||
});
|
||||
import { isResourceTokenConnectionString, parseResourceTokenConnectionString } from "./ResourceTokenUtils";
|
||||
|
||||
describe("parseResourceTokenConnectionString", () => {
|
||||
it("correctly parses resource token connection string", () => {
|
||||
const connectionString =
|
||||
"AccountEndpoint=fakeEndpoint;DatabaseId=fakeDatabaseId;CollectionId=fakeCollectionId;type=resource&ver=1&sig=2dIP+CdIfT1ScwHWdv5GGw==;fakeToken;";
|
||||
const properties = Main.parseResourceTokenConnectionString(connectionString);
|
||||
const properties = parseResourceTokenConnectionString(connectionString);
|
||||
|
||||
expect(properties).toEqual({
|
||||
accountEndpoint: "fakeEndpoint",
|
||||
@@ -32,7 +18,7 @@ describe("Main", () => {
|
||||
it("correctly parses resource token connection string with partition key", () => {
|
||||
const connectionString =
|
||||
"type=resource&ver=1&sig=2dIP+CdIfT1ScwHWdv5GGw==;fakeToken;AccountEndpoint=fakeEndpoint;DatabaseId=fakeDatabaseId;CollectionId=fakeCollectionId;PartitionKey=fakePartitionKey;";
|
||||
const properties = Main.parseResourceTokenConnectionString(connectionString);
|
||||
const properties = parseResourceTokenConnectionString(connectionString);
|
||||
|
||||
expect(properties).toEqual({
|
||||
accountEndpoint: "fakeEndpoint",
|
||||
@@ -43,3 +29,16 @@ describe("Main", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("isResourceToken", () => {
|
||||
it("valid resource connection string", () => {
|
||||
const connectionString =
|
||||
"AccountEndpoint=fakeEndpoint;DatabaseId=fakeDatabaseId;CollectionId=fakeCollectionId;type=resource&ver=1&sig=2dIP+CdIfT1ScwHWdv5GGw==;fakeToken;";
|
||||
expect(isResourceTokenConnectionString(connectionString)).toBe(true);
|
||||
});
|
||||
|
||||
it("non-resource connection string", () => {
|
||||
const connectionString = "AccountEndpoint=https://stfaul-sql.documents.azure.com:443/;AccountKey=foo;";
|
||||
expect(isResourceTokenConnectionString(connectionString)).toBe(false);
|
||||
});
|
||||
});
|
||||
43
src/Platform/Hosted/Helpers/ResourceTokenUtils.ts
Normal file
43
src/Platform/Hosted/Helpers/ResourceTokenUtils.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
export interface ParsedResourceTokenConnectionString {
|
||||
accountEndpoint: string;
|
||||
collectionId: string;
|
||||
databaseId: string;
|
||||
partitionKey?: string;
|
||||
resourceToken: string;
|
||||
}
|
||||
|
||||
export function parseResourceTokenConnectionString(connectionString: string): ParsedResourceTokenConnectionString {
|
||||
let accountEndpoint: string;
|
||||
let collectionId: string;
|
||||
let databaseId: string;
|
||||
let partitionKey: string;
|
||||
let resourceToken: string;
|
||||
const connectionStringParts = connectionString.split(";");
|
||||
connectionStringParts.forEach((part: string) => {
|
||||
if (part.startsWith("type=resource")) {
|
||||
resourceToken = part + ";";
|
||||
} else if (part.startsWith("AccountEndpoint=")) {
|
||||
accountEndpoint = part.substring(16);
|
||||
} else if (part.startsWith("DatabaseId=")) {
|
||||
databaseId = part.substring(11);
|
||||
} else if (part.startsWith("CollectionId=")) {
|
||||
collectionId = part.substring(13);
|
||||
} else if (part.startsWith("PartitionKey=")) {
|
||||
partitionKey = part.substring(13);
|
||||
} else if (part !== "") {
|
||||
resourceToken += part + ";";
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
accountEndpoint,
|
||||
collectionId,
|
||||
databaseId,
|
||||
partitionKey,
|
||||
resourceToken
|
||||
};
|
||||
}
|
||||
|
||||
export function isResourceTokenConnectionString(connectionString: string): boolean {
|
||||
return connectionString && connectionString.includes("type=resource");
|
||||
}
|
||||
@@ -1,9 +1,9 @@
|
||||
import { AccessInputMetadata } from "../../Contracts/DataModels";
|
||||
import { HostedUtils } from "./HostedUtils";
|
||||
import { getDatabaseAccountPropertiesFromMetadata } from "./HostedUtils";
|
||||
|
||||
describe("getDatabaseAccountPropertiesFromMetadata", () => {
|
||||
it("should only return an object with the mongoEndpoint key if the apiKind is mongoCompute (5)", () => {
|
||||
let mongoComputeAccount: AccessInputMetadata = {
|
||||
const mongoComputeAccount: AccessInputMetadata = {
|
||||
accountName: "compute-batch2",
|
||||
apiEndpoint: "compute-batch2.mongo.cosmos.azure.com:10255",
|
||||
apiKind: 5,
|
||||
@@ -11,21 +11,21 @@ describe("getDatabaseAccountPropertiesFromMetadata", () => {
|
||||
expiryTimestamp: "1234",
|
||||
mongoEndpoint: "https://compute-batch2.mongo.cosmos.azure.com:443/"
|
||||
};
|
||||
expect(HostedUtils.getDatabaseAccountPropertiesFromMetadata(mongoComputeAccount)).toEqual({
|
||||
expect(getDatabaseAccountPropertiesFromMetadata(mongoComputeAccount)).toEqual({
|
||||
mongoEndpoint: mongoComputeAccount.mongoEndpoint,
|
||||
documentEndpoint: mongoComputeAccount.documentEndpoint
|
||||
});
|
||||
});
|
||||
|
||||
it("should not return an object with the mongoEndpoint key if the apiKind is mongo (1)", () => {
|
||||
let mongoAccount: AccessInputMetadata = {
|
||||
const mongoAccount: AccessInputMetadata = {
|
||||
accountName: "compute-batch2",
|
||||
apiEndpoint: "compute-batch2.mongo.cosmos.azure.com:10255",
|
||||
apiKind: 1,
|
||||
documentEndpoint: "https://compute-batch2.documents.azure.com:443/",
|
||||
expiryTimestamp: "1234"
|
||||
};
|
||||
expect(HostedUtils.getDatabaseAccountPropertiesFromMetadata(mongoAccount)).toEqual({
|
||||
expect(getDatabaseAccountPropertiesFromMetadata(mongoAccount)).toEqual({
|
||||
documentEndpoint: mongoAccount.documentEndpoint
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,35 +1,50 @@
|
||||
import * as Constants from "../../Common/Constants";
|
||||
import * as DataModels from "../../Contracts/DataModels";
|
||||
import { AccessInputMetadata } from "../../Contracts/DataModels";
|
||||
import { DefaultAccountExperience, CapabilityNames, AccountKind } from "../../Common/Constants";
|
||||
import { AccessInputMetadata, ApiKind } from "../../Contracts/DataModels";
|
||||
import { DefaultExperienceUtility } from "../../Shared/DefaultExperienceUtility";
|
||||
|
||||
export class HostedUtils {
|
||||
static getDatabaseAccountPropertiesFromMetadata(metadata: AccessInputMetadata): any {
|
||||
let properties = { documentEndpoint: metadata.documentEndpoint };
|
||||
const apiExperience: string = DefaultExperienceUtility.getDefaultExperienceFromApiKind(metadata.apiKind);
|
||||
export function getDatabaseAccountPropertiesFromMetadata(metadata: AccessInputMetadata): unknown {
|
||||
let properties = { documentEndpoint: metadata.documentEndpoint };
|
||||
const apiExperience: string = DefaultExperienceUtility.getDefaultExperienceFromApiKind(metadata.apiKind);
|
||||
|
||||
if (apiExperience === Constants.DefaultAccountExperience.Cassandra) {
|
||||
if (apiExperience === DefaultAccountExperience.Cassandra) {
|
||||
properties = Object.assign(properties, {
|
||||
cassandraEndpoint: metadata.apiEndpoint,
|
||||
capabilities: [{ name: CapabilityNames.EnableCassandra }]
|
||||
});
|
||||
} else if (apiExperience === DefaultAccountExperience.Table) {
|
||||
properties = Object.assign(properties, {
|
||||
tableEndpoint: metadata.apiEndpoint,
|
||||
capabilities: [{ name: CapabilityNames.EnableTable }]
|
||||
});
|
||||
} else if (apiExperience === DefaultAccountExperience.Graph) {
|
||||
properties = Object.assign(properties, {
|
||||
gremlinEndpoint: metadata.apiEndpoint,
|
||||
capabilities: [{ name: CapabilityNames.EnableGremlin }]
|
||||
});
|
||||
} else if (apiExperience === DefaultAccountExperience.MongoDB) {
|
||||
if (metadata.apiKind === ApiKind.MongoDBCompute) {
|
||||
properties = Object.assign(properties, {
|
||||
cassandraEndpoint: metadata.apiEndpoint,
|
||||
capabilities: [{ name: Constants.CapabilityNames.EnableCassandra }]
|
||||
mongoEndpoint: metadata.mongoEndpoint
|
||||
});
|
||||
} else if (apiExperience === Constants.DefaultAccountExperience.Table) {
|
||||
properties = Object.assign(properties, {
|
||||
tableEndpoint: metadata.apiEndpoint,
|
||||
capabilities: [{ name: Constants.CapabilityNames.EnableTable }]
|
||||
});
|
||||
} else if (apiExperience === Constants.DefaultAccountExperience.Graph) {
|
||||
properties = Object.assign(properties, {
|
||||
gremlinEndpoint: metadata.apiEndpoint,
|
||||
capabilities: [{ name: Constants.CapabilityNames.EnableGremlin }]
|
||||
});
|
||||
} else if (apiExperience === Constants.DefaultAccountExperience.MongoDB) {
|
||||
if (metadata.apiKind === DataModels.ApiKind.MongoDBCompute) {
|
||||
properties = Object.assign(properties, {
|
||||
mongoEndpoint: metadata.mongoEndpoint
|
||||
});
|
||||
}
|
||||
}
|
||||
return properties;
|
||||
}
|
||||
return properties;
|
||||
}
|
||||
|
||||
export function getDatabaseAccountKindFromExperience(apiExperience: string): string {
|
||||
if (apiExperience === DefaultAccountExperience.MongoDB) {
|
||||
return AccountKind.MongoDB;
|
||||
}
|
||||
|
||||
if (apiExperience === DefaultAccountExperience.ApiForMongoDB) {
|
||||
return AccountKind.MongoDB;
|
||||
}
|
||||
|
||||
return AccountKind.GlobalDocumentDB;
|
||||
}
|
||||
|
||||
export function extractMasterKeyfromConnectionString(connectionString: string): string {
|
||||
// Only Gremlin uses the actual master key for connection to cosmos
|
||||
const matchedParts = connectionString.match("AccountKey=(.*);ApiKind=Gremlin;$");
|
||||
return (matchedParts && matchedParts.length > 1 && matchedParts[1]) || undefined;
|
||||
}
|
||||
|
||||
@@ -1,598 +0,0 @@
|
||||
import * as Constants from "../../Common/Constants";
|
||||
import AuthHeadersUtil from "./Authorization";
|
||||
import Q from "q";
|
||||
import {
|
||||
AccessInputMetadata,
|
||||
AccountKeys,
|
||||
ApiKind,
|
||||
DatabaseAccount,
|
||||
GenerateTokenResponse,
|
||||
resourceTokenConnectionStringProperties
|
||||
} from "../../Contracts/DataModels";
|
||||
import { AuthType } from "../../AuthType";
|
||||
import { CollectionCreation } from "../../Shared/Constants";
|
||||
import { isInvalidParentFrameOrigin } from "../../Utils/MessageValidation";
|
||||
import { DataExplorerInputsFrame } from "../../Contracts/ViewModels";
|
||||
import { DefaultExperienceUtility } from "../../Shared/DefaultExperienceUtility";
|
||||
import { HostedUtils } from "./HostedUtils";
|
||||
import { sendMessage } from "../../Common/MessageHandler";
|
||||
import { MessageTypes } from "../../Contracts/ExplorerContracts";
|
||||
import { SessionStorageUtility, StorageKey } from "../../Shared/StorageUtility";
|
||||
import { SubscriptionUtilMappings } from "../../Shared/Constants";
|
||||
import "../../Explorer/Tables/DataTable/DataTableBindingManager";
|
||||
import Explorer from "../../Explorer/Explorer";
|
||||
import { updateUserContext } from "../../UserContext";
|
||||
import { configContext } from "../../ConfigContext";
|
||||
import { getErrorMessage } from "../../Common/ErrorHandlingUtils";
|
||||
|
||||
export default class Main {
|
||||
private static _databaseAccountId: string;
|
||||
private static _encryptedToken: string;
|
||||
private static _accessInputMetadata: AccessInputMetadata;
|
||||
private static _features: { [key: string]: string };
|
||||
// For AAD, Need to post message to hosted frame to do the auth
|
||||
// Use local deferred variable as work around until we find better solution
|
||||
private static _getAadAccessDeferred: Q.Deferred<Explorer>;
|
||||
private static _explorer: Explorer;
|
||||
|
||||
public static isUsingEncryptionToken(): boolean {
|
||||
const params = new URLSearchParams(window.parent.location.search);
|
||||
if ((!!params && params.has("key")) || Main._hasCachedEncryptedKey()) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
public static initializeExplorer(): Q.Promise<Explorer> {
|
||||
window.addEventListener("message", this._handleMessage.bind(this), false);
|
||||
this._features = {};
|
||||
const params = new URLSearchParams(window.parent.location.search);
|
||||
const deferred: Q.Deferred<Explorer> = Q.defer<Explorer>();
|
||||
let authType: string = null;
|
||||
|
||||
// Encrypted token flow
|
||||
if (!!params && params.has("key")) {
|
||||
Main._encryptedToken = encodeURIComponent(params.get("key"));
|
||||
SessionStorageUtility.setEntryString(StorageKey.EncryptedKeyToken, Main._encryptedToken);
|
||||
authType = AuthType.EncryptedToken;
|
||||
} else if (Main._hasCachedEncryptedKey()) {
|
||||
Main._encryptedToken = SessionStorageUtility.getEntryString(StorageKey.EncryptedKeyToken);
|
||||
authType = AuthType.EncryptedToken;
|
||||
}
|
||||
|
||||
// Aad flow
|
||||
if (AuthHeadersUtil.isUserSignedIn()) {
|
||||
authType = AuthType.AAD;
|
||||
}
|
||||
|
||||
if (params) {
|
||||
this._features = Main.extractFeatures(params);
|
||||
}
|
||||
|
||||
(<any>window).authType = authType;
|
||||
if (!authType) {
|
||||
return Q.reject("Sign in needed");
|
||||
}
|
||||
|
||||
const explorer: Explorer = this._instantiateExplorer();
|
||||
if (authType === AuthType.EncryptedToken) {
|
||||
sendMessage({
|
||||
type: MessageTypes.UpdateAccountSwitch,
|
||||
props: {
|
||||
authType: AuthType.EncryptedToken,
|
||||
displayText: "Loading..."
|
||||
}
|
||||
});
|
||||
updateUserContext({
|
||||
accessToken: Main._encryptedToken
|
||||
});
|
||||
Main._getAccessInputMetadata(Main._encryptedToken).then(
|
||||
() => {
|
||||
const expiryTimestamp: number =
|
||||
Main._accessInputMetadata && parseInt(Main._accessInputMetadata.expiryTimestamp);
|
||||
if (authType === AuthType.EncryptedToken && (isNaN(expiryTimestamp) || expiryTimestamp <= 0)) {
|
||||
return deferred.reject("Token expired");
|
||||
}
|
||||
|
||||
Main._initDataExplorerFrameInputs(explorer);
|
||||
deferred.resolve(explorer);
|
||||
},
|
||||
(error: any) => {
|
||||
console.error(error);
|
||||
deferred.reject(error);
|
||||
}
|
||||
);
|
||||
} else if (authType === AuthType.AAD) {
|
||||
sendMessage({
|
||||
type: MessageTypes.GetAccessAadRequest
|
||||
});
|
||||
if (this._getAadAccessDeferred != null) {
|
||||
// already request aad access, don't duplicate
|
||||
return Q(null);
|
||||
}
|
||||
this._explorer = explorer;
|
||||
this._getAadAccessDeferred = Q.defer<Explorer>();
|
||||
return this._getAadAccessDeferred.promise.finally(() => {
|
||||
this._getAadAccessDeferred = null;
|
||||
});
|
||||
} else {
|
||||
Main._initDataExplorerFrameInputs(explorer);
|
||||
deferred.resolve(explorer);
|
||||
}
|
||||
|
||||
return deferred.promise;
|
||||
}
|
||||
|
||||
public static extractFeatures(params: URLSearchParams): { [key: string]: string } {
|
||||
const featureParamRegex = /feature.(.*)/i;
|
||||
const features: { [key: string]: string } = {};
|
||||
params.forEach((value: string, param: string) => {
|
||||
if (featureParamRegex.test(param)) {
|
||||
const matches: string[] = param.match(featureParamRegex);
|
||||
if (matches.length > 0) {
|
||||
features[matches[1].toLowerCase()] = value;
|
||||
}
|
||||
}
|
||||
});
|
||||
return features;
|
||||
}
|
||||
|
||||
public static configureTokenValidationDisplayPrompt(explorer: Explorer): void {
|
||||
const authType: AuthType = (<any>window).authType;
|
||||
if (
|
||||
!explorer ||
|
||||
!Main._encryptedToken ||
|
||||
!Main._accessInputMetadata ||
|
||||
Main._accessInputMetadata.expiryTimestamp == null ||
|
||||
authType !== AuthType.EncryptedToken
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
Main._showGuestAccessTokenRenewalPromptInMs(explorer, parseInt(Main._accessInputMetadata.expiryTimestamp));
|
||||
}
|
||||
|
||||
public static parseResourceTokenConnectionString(connectionString: string): resourceTokenConnectionStringProperties {
|
||||
let accountEndpoint: string;
|
||||
let collectionId: string;
|
||||
let databaseId: string;
|
||||
let partitionKey: string;
|
||||
let resourceToken: string;
|
||||
const connectionStringParts = connectionString.split(";");
|
||||
connectionStringParts.forEach((part: string) => {
|
||||
if (part.startsWith("type=resource")) {
|
||||
resourceToken = part + ";";
|
||||
} else if (part.startsWith("AccountEndpoint=")) {
|
||||
accountEndpoint = part.substring(16);
|
||||
} else if (part.startsWith("DatabaseId=")) {
|
||||
databaseId = part.substring(11);
|
||||
} else if (part.startsWith("CollectionId=")) {
|
||||
collectionId = part.substring(13);
|
||||
} else if (part.startsWith("PartitionKey=")) {
|
||||
partitionKey = part.substring(13);
|
||||
} else if (part !== "") {
|
||||
resourceToken += part + ";";
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
accountEndpoint,
|
||||
collectionId,
|
||||
databaseId,
|
||||
partitionKey,
|
||||
resourceToken
|
||||
};
|
||||
}
|
||||
|
||||
public static renewExplorerAccess = (explorer: Explorer, connectionString: string): Q.Promise<void> => {
|
||||
if (!connectionString) {
|
||||
console.error("Missing or invalid connection string input");
|
||||
Q.reject("Missing or invalid connection string input");
|
||||
}
|
||||
|
||||
if (Main._isResourceToken(connectionString)) {
|
||||
return Main._renewExplorerAccessWithResourceToken(explorer, connectionString);
|
||||
}
|
||||
|
||||
const deferred: Q.Deferred<void> = Q.defer<void>();
|
||||
AuthHeadersUtil.generateUnauthenticatedEncryptedTokenForConnectionString(connectionString).then(
|
||||
(encryptedToken: GenerateTokenResponse) => {
|
||||
if (!encryptedToken || !encryptedToken.readWrite) {
|
||||
deferred.reject("Encrypted token is empty or undefined");
|
||||
}
|
||||
|
||||
Main._encryptedToken = encryptedToken.readWrite;
|
||||
window.authType = AuthType.EncryptedToken;
|
||||
|
||||
updateUserContext({
|
||||
accessToken: Main._encryptedToken
|
||||
});
|
||||
Main._getAccessInputMetadata(Main._encryptedToken).then(
|
||||
() => {
|
||||
if (explorer.isConnectExplorerVisible()) {
|
||||
explorer.notificationConsoleData([]);
|
||||
explorer.hideConnectExplorerForm();
|
||||
}
|
||||
|
||||
if (Main._accessInputMetadata.apiKind != ApiKind.Graph) {
|
||||
// do not save encrypted token for graphs because we cannot extract master key in the client
|
||||
SessionStorageUtility.setEntryString(StorageKey.EncryptedKeyToken, Main._encryptedToken);
|
||||
window.parent &&
|
||||
window.parent.history.replaceState(
|
||||
{ encryptedToken: encryptedToken },
|
||||
"",
|
||||
`?key=${Main._encryptedToken}${(window.parent && window.parent.location.hash) || ""}`
|
||||
); // replace query params if any
|
||||
} else {
|
||||
SessionStorageUtility.removeEntry(StorageKey.EncryptedKeyToken);
|
||||
window.parent &&
|
||||
window.parent.history.replaceState(
|
||||
{ encryptedToken: encryptedToken },
|
||||
"",
|
||||
`?${(window.parent && window.parent.location.hash) || ""}`
|
||||
); // replace query params if any
|
||||
}
|
||||
|
||||
const masterKey: string = Main._getMasterKeyFromConnectionString(connectionString);
|
||||
Main.configureTokenValidationDisplayPrompt(explorer);
|
||||
Main._setExplorerReady(explorer, masterKey);
|
||||
|
||||
deferred.resolve();
|
||||
},
|
||||
(error: any) => {
|
||||
console.error(error);
|
||||
deferred.reject(error);
|
||||
}
|
||||
);
|
||||
},
|
||||
(error: any) => {
|
||||
deferred.reject(`Failed to generate encrypted token: ${getErrorMessage(error)}`);
|
||||
}
|
||||
);
|
||||
|
||||
return deferred.promise.timeout(Constants.ClientDefaults.requestTimeoutMs);
|
||||
};
|
||||
|
||||
public static getUninitializedExplorerForGuestAccess(): Explorer {
|
||||
const explorer = Main._instantiateExplorer();
|
||||
if (window.authType === AuthType.AAD) {
|
||||
this._explorer = explorer;
|
||||
}
|
||||
(<any>window).dataExplorer = explorer;
|
||||
|
||||
return explorer;
|
||||
}
|
||||
|
||||
private static _initDataExplorerFrameInputs(
|
||||
explorer: Explorer,
|
||||
masterKey?: string /* master key extracted from connection string if available */,
|
||||
account?: DatabaseAccount,
|
||||
authorizationToken?: string /* access key */
|
||||
): void {
|
||||
const serverId: string = AuthHeadersUtil.serverId;
|
||||
const authType: string = (<any>window).authType;
|
||||
const accountResourceId =
|
||||
authType === AuthType.EncryptedToken
|
||||
? Main._databaseAccountId
|
||||
: authType === AuthType.AAD && account
|
||||
? account.id
|
||||
: "";
|
||||
const subscriptionId: string = accountResourceId && accountResourceId.split("subscriptions/")[1].split("/")[0];
|
||||
const resourceGroup: string = accountResourceId && accountResourceId.split("resourceGroups/")[1].split("/")[0];
|
||||
|
||||
explorer.isTryCosmosDBSubscription(SubscriptionUtilMappings.FreeTierSubscriptionIds.indexOf(subscriptionId) >= 0);
|
||||
if (authorizationToken && authorizationToken.indexOf("Bearer") !== 0) {
|
||||
// Portal sends the auth token with bearer suffix, so we prepend the same to be consistent
|
||||
authorizationToken = `Bearer ${authorizationToken}`;
|
||||
}
|
||||
|
||||
if (authType === AuthType.EncryptedToken) {
|
||||
const apiExperience: string = DefaultExperienceUtility.getDefaultExperienceFromApiKind(
|
||||
Main._accessInputMetadata.apiKind
|
||||
);
|
||||
sendMessage({
|
||||
type: MessageTypes.UpdateAccountSwitch,
|
||||
props: {
|
||||
authType: AuthType.EncryptedToken,
|
||||
selectedAccountName: Main._accessInputMetadata.accountName
|
||||
}
|
||||
});
|
||||
return explorer.initDataExplorerWithFrameInputs({
|
||||
databaseAccount: {
|
||||
id: Main._databaseAccountId,
|
||||
name: Main._accessInputMetadata.accountName,
|
||||
kind: this._getDatabaseAccountKindFromExperience(apiExperience),
|
||||
properties: HostedUtils.getDatabaseAccountPropertiesFromMetadata(Main._accessInputMetadata),
|
||||
tags: { defaultExperience: apiExperience }
|
||||
},
|
||||
subscriptionId,
|
||||
resourceGroup,
|
||||
masterKey,
|
||||
hasWriteAccess: true, // TODO: we should embed this information in the token ideally
|
||||
authorizationToken: undefined,
|
||||
features: this._features,
|
||||
csmEndpoint: undefined,
|
||||
dnsSuffix: null,
|
||||
serverId: serverId,
|
||||
extensionEndpoint: configContext.BACKEND_ENDPOINT,
|
||||
subscriptionType: CollectionCreation.DefaultSubscriptionType,
|
||||
quotaId: undefined,
|
||||
addCollectionDefaultFlight: explorer.flight(),
|
||||
isTryCosmosDBSubscription: explorer.isTryCosmosDBSubscription()
|
||||
});
|
||||
}
|
||||
|
||||
if (authType === AuthType.AAD) {
|
||||
const inputs: DataExplorerInputsFrame = {
|
||||
databaseAccount: account,
|
||||
subscriptionId,
|
||||
resourceGroup,
|
||||
masterKey,
|
||||
hasWriteAccess: true, //TODO: 425017 - support read access
|
||||
authorizationToken,
|
||||
features: this._features,
|
||||
csmEndpoint: undefined,
|
||||
dnsSuffix: null,
|
||||
serverId: serverId,
|
||||
extensionEndpoint: configContext.BACKEND_ENDPOINT,
|
||||
subscriptionType: CollectionCreation.DefaultSubscriptionType,
|
||||
quotaId: undefined,
|
||||
addCollectionDefaultFlight: explorer.flight(),
|
||||
isTryCosmosDBSubscription: explorer.isTryCosmosDBSubscription()
|
||||
};
|
||||
return explorer.initDataExplorerWithFrameInputs(inputs);
|
||||
}
|
||||
|
||||
if (authType === AuthType.ResourceToken) {
|
||||
const apiExperience: string = DefaultExperienceUtility.getDefaultExperienceFromApiKind(
|
||||
Main._accessInputMetadata.apiKind
|
||||
);
|
||||
return explorer.initDataExplorerWithFrameInputs({
|
||||
databaseAccount: {
|
||||
id: Main._databaseAccountId,
|
||||
name: Main._accessInputMetadata.accountName,
|
||||
kind: this._getDatabaseAccountKindFromExperience(apiExperience),
|
||||
properties: HostedUtils.getDatabaseAccountPropertiesFromMetadata(Main._accessInputMetadata),
|
||||
tags: { defaultExperience: apiExperience }
|
||||
},
|
||||
subscriptionId,
|
||||
resourceGroup,
|
||||
masterKey,
|
||||
hasWriteAccess: true, // TODO: we should embed this information in the token ideally
|
||||
authorizationToken: undefined,
|
||||
features: this._features,
|
||||
csmEndpoint: undefined,
|
||||
dnsSuffix: null,
|
||||
serverId: serverId,
|
||||
extensionEndpoint: configContext.BACKEND_ENDPOINT,
|
||||
subscriptionType: CollectionCreation.DefaultSubscriptionType,
|
||||
quotaId: undefined,
|
||||
addCollectionDefaultFlight: explorer.flight(),
|
||||
isTryCosmosDBSubscription: explorer.isTryCosmosDBSubscription(),
|
||||
isAuthWithresourceToken: true
|
||||
});
|
||||
}
|
||||
|
||||
throw new Error(`Unsupported AuthType ${authType}`);
|
||||
}
|
||||
|
||||
private static _instantiateExplorer(): Explorer {
|
||||
const explorer = new Explorer();
|
||||
// workaround to resolve cyclic refs with view
|
||||
explorer.renewExplorerShareAccess = Main.renewExplorerAccess;
|
||||
window.addEventListener("message", explorer.handleMessage.bind(explorer), false);
|
||||
|
||||
// Hosted needs click to dismiss any menu
|
||||
if (window.authType === AuthType.AAD) {
|
||||
window.addEventListener(
|
||||
"click",
|
||||
() => {
|
||||
sendMessage({
|
||||
type: MessageTypes.ExplorerClickEvent
|
||||
});
|
||||
},
|
||||
true
|
||||
);
|
||||
}
|
||||
|
||||
return explorer;
|
||||
}
|
||||
|
||||
private static _showGuestAccessTokenRenewalPromptInMs(explorer: Explorer, interval: number): void {
|
||||
if (interval != null && !isNaN(interval)) {
|
||||
setTimeout(() => {
|
||||
explorer.displayGuestAccessTokenRenewalPrompt();
|
||||
}, interval);
|
||||
}
|
||||
}
|
||||
|
||||
private static _hasCachedEncryptedKey(): boolean {
|
||||
return SessionStorageUtility.hasItem(StorageKey.EncryptedKeyToken);
|
||||
}
|
||||
|
||||
private static _getDatabaseAccountKindFromExperience(apiExperience: string): string {
|
||||
if (apiExperience === Constants.DefaultAccountExperience.MongoDB) {
|
||||
return Constants.AccountKind.MongoDB;
|
||||
}
|
||||
|
||||
if (apiExperience === Constants.DefaultAccountExperience.ApiForMongoDB) {
|
||||
return Constants.AccountKind.MongoDB;
|
||||
}
|
||||
|
||||
return Constants.AccountKind.GlobalDocumentDB;
|
||||
}
|
||||
|
||||
private static _getAccessInputMetadata(accessInput: string): Q.Promise<void> {
|
||||
const deferred: Q.Deferred<void> = Q.defer<void>();
|
||||
AuthHeadersUtil.getAccessInputMetadata(accessInput).then(
|
||||
(metadata: any) => {
|
||||
Main._accessInputMetadata = metadata;
|
||||
deferred.resolve();
|
||||
},
|
||||
(error: any) => {
|
||||
deferred.reject(error);
|
||||
}
|
||||
);
|
||||
|
||||
return deferred.promise.timeout(Constants.ClientDefaults.requestTimeoutMs);
|
||||
}
|
||||
|
||||
private static _getMasterKeyFromConnectionString(connectionString: string): string {
|
||||
if (!connectionString || Main._accessInputMetadata == null || Main._accessInputMetadata.apiKind !== ApiKind.Graph) {
|
||||
// client only needs master key for Graph API
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const matchedParts: string[] = connectionString.match("AccountKey=(.*);ApiKind=Gremlin;$");
|
||||
return (matchedParts.length > 1 && matchedParts[1]) || undefined;
|
||||
}
|
||||
|
||||
private static _isResourceToken(connectionString: string): boolean {
|
||||
return connectionString && connectionString.includes("type=resource");
|
||||
}
|
||||
|
||||
private static _renewExplorerAccessWithResourceToken = (
|
||||
explorer: Explorer,
|
||||
connectionString: string
|
||||
): Q.Promise<void> => {
|
||||
window.authType = AuthType.ResourceToken;
|
||||
|
||||
const properties: resourceTokenConnectionStringProperties = Main.parseResourceTokenConnectionString(
|
||||
connectionString
|
||||
);
|
||||
if (
|
||||
!properties.accountEndpoint ||
|
||||
!properties.resourceToken ||
|
||||
!properties.databaseId ||
|
||||
!properties.collectionId
|
||||
) {
|
||||
console.error("Invalid connection string input");
|
||||
Q.reject("Invalid connection string input");
|
||||
}
|
||||
updateUserContext({
|
||||
resourceToken: properties.resourceToken,
|
||||
endpoint: properties.accountEndpoint
|
||||
});
|
||||
explorer.resourceTokenDatabaseId(properties.databaseId);
|
||||
explorer.resourceTokenCollectionId(properties.collectionId);
|
||||
if (properties.partitionKey) {
|
||||
explorer.resourceTokenPartitionKey(properties.partitionKey);
|
||||
}
|
||||
Main._accessInputMetadata = Main._getAccessInputMetadataFromAccountEndpoint(properties.accountEndpoint);
|
||||
|
||||
if (explorer.isConnectExplorerVisible()) {
|
||||
explorer.notificationConsoleData([]);
|
||||
explorer.hideConnectExplorerForm();
|
||||
}
|
||||
|
||||
Main._setExplorerReady(explorer);
|
||||
return Q.resolve();
|
||||
};
|
||||
|
||||
private static _getAccessInputMetadataFromAccountEndpoint = (accountEndpoint: string): AccessInputMetadata => {
|
||||
const documentEndpoint: string = accountEndpoint;
|
||||
const result: RegExpMatchArray = accountEndpoint.match("https://([^\\.]+)\\..+");
|
||||
const accountName: string = result && result[1];
|
||||
const apiEndpoint: string = accountEndpoint.substring(8);
|
||||
const apiKind: number = ApiKind.SQL;
|
||||
|
||||
return {
|
||||
accountName,
|
||||
apiEndpoint,
|
||||
apiKind,
|
||||
documentEndpoint,
|
||||
expiryTimestamp: ""
|
||||
};
|
||||
};
|
||||
|
||||
private static _setExplorerReady(
|
||||
explorer: Explorer,
|
||||
masterKey?: string,
|
||||
account?: DatabaseAccount,
|
||||
authorizationToken?: string
|
||||
) {
|
||||
Main._initDataExplorerFrameInputs(explorer, masterKey, account, authorizationToken);
|
||||
explorer.isAccountReady.valueHasMutated();
|
||||
sendMessage("ready");
|
||||
}
|
||||
|
||||
private static _shouldProcessMessage(event: MessageEvent): boolean {
|
||||
if (typeof event.data !== "object") {
|
||||
return false;
|
||||
}
|
||||
if (event.data["signature"] !== "pcIframe") {
|
||||
return false;
|
||||
}
|
||||
if (!("data" in event.data)) {
|
||||
return false;
|
||||
}
|
||||
if (typeof event.data["data"] !== "object") {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private static _handleMessage(event: MessageEvent) {
|
||||
if (isInvalidParentFrameOrigin(event)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this._shouldProcessMessage(event)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const message: any = event.data.data;
|
||||
if (message.type) {
|
||||
if (message.type === MessageTypes.GetAccessAadResponse && (message.response || message.error)) {
|
||||
if (message.response) {
|
||||
Main._handleGetAccessAadSucceed(message.response);
|
||||
}
|
||||
if (message.error) {
|
||||
Main._handleGetAccessAadFailed(message.error);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (message.type === MessageTypes.SwitchAccount && message.account && message.keys) {
|
||||
Main._handleSwitchAccountSucceed(message.account, message.keys, message.authorizationToken);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static _handleSwitchAccountSucceed(account: DatabaseAccount, keys: AccountKeys, authorizationToken: string) {
|
||||
if (!this._explorer) {
|
||||
console.error("no explorer found");
|
||||
return;
|
||||
}
|
||||
|
||||
this._explorer.hideConnectExplorerForm();
|
||||
|
||||
const masterKey = Main._getMasterKey(keys);
|
||||
this._explorer.notificationConsoleData([]);
|
||||
Main._setExplorerReady(this._explorer, masterKey, account, authorizationToken);
|
||||
}
|
||||
|
||||
private static _handleGetAccessAadSucceed(response: [DatabaseAccount, AccountKeys, string]) {
|
||||
if (!response || response.length < 1) {
|
||||
return;
|
||||
}
|
||||
const account = response[0];
|
||||
const masterKey = Main._getMasterKey(response[1]);
|
||||
const authorizationToken = response[2];
|
||||
Main._setExplorerReady(this._explorer, masterKey, account, authorizationToken);
|
||||
this._getAadAccessDeferred.resolve(this._explorer);
|
||||
}
|
||||
|
||||
private static _getMasterKey(keys: AccountKeys): string {
|
||||
return (
|
||||
keys?.primaryMasterKey ??
|
||||
keys?.secondaryMasterKey ??
|
||||
keys?.primaryReadonlyMasterKey ??
|
||||
keys?.secondaryReadonlyMasterKey
|
||||
);
|
||||
}
|
||||
|
||||
private static _handleGetAccessAadFailed(error: any) {
|
||||
this._getAadAccessDeferred.reject(error);
|
||||
}
|
||||
}
|
||||
17
src/Platform/Hosted/extractFeatures.test.ts
Normal file
17
src/Platform/Hosted/extractFeatures.test.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import { extractFeatures } from "./extractFeatures";
|
||||
|
||||
describe("extractFeatures", () => {
|
||||
it("correctly detects feature flags", () => {
|
||||
// Search containing non-features, with Camelcase keys and uri encoded values
|
||||
const params = new URLSearchParams(
|
||||
"?platform=Hosted&feature.notebookserverurl=https%3A%2F%2Flocalhost%3A10001%2F12345%2Fnotebook&feature.notebookServerToken=token&feature.enablenotebooks=true&key=mykey"
|
||||
);
|
||||
const features = extractFeatures(params);
|
||||
|
||||
expect(features).toEqual({
|
||||
notebookserverurl: "https://localhost:10001/12345/notebook",
|
||||
notebookservertoken: "token",
|
||||
enablenotebooks: "true"
|
||||
});
|
||||
});
|
||||
});
|
||||
14
src/Platform/Hosted/extractFeatures.ts
Normal file
14
src/Platform/Hosted/extractFeatures.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
export function extractFeatures(params?: URLSearchParams): { [key: string]: string } {
|
||||
params = params || new URLSearchParams(window.parent.location.search);
|
||||
const featureParamRegex = /feature.(.*)/i;
|
||||
const features: { [key: string]: string } = {};
|
||||
params.forEach((value: string, param: string) => {
|
||||
if (featureParamRegex.test(param)) {
|
||||
const matches: string[] = param.match(featureParamRegex);
|
||||
if (matches.length > 0) {
|
||||
features[matches[1].toLowerCase()] = value;
|
||||
}
|
||||
}
|
||||
});
|
||||
return features;
|
||||
}
|
||||
Reference in New Issue
Block a user