Compare commits

..

13 Commits

Author SHA1 Message Date
Chris-MS-896
34f53709c8 Merge branch 'v-yiqcao/addHostedFile' of https://github.com/Azure/cosmos-explorer into v-yiqcao/addHostedFile 2021-01-11 11:08:34 -06:00
Chris-MS-896
ed7a8dafcf "minor changess" 2021-01-11 11:08:23 -06:00
Chris-MS-896
2e83adc7db Merge branch 'master' into v-yiqcao/addHostedFile 2021-01-08 10:27:51 -06:00
Laurent Nguyen
6e619175c6 Fix missing scrollbar in left pane when too many collections/notebooks (#375)
Constrain left pane container to height: 100% so that scrollbar show up when content wants to overflow.
The `main` classname seems too generic, but I left it alone (so I don't break anything), since this part will eventually be ported to React.
2021-01-08 14:00:26 +00:00
victor-meng
08e8bf4bcf Fix two settings tab issues (#374) 2021-01-07 15:38:13 -06:00
Chris-MS-896
bbddeddbc7 Merge branch 'master' into v-yiqcao/addHostedFile 2021-01-06 19:49:56 -06:00
Chris-MS-896
89dc0f394b Add Spliter file to Master (#358) 2021-01-06 12:51:42 -06:00
Chris-MS-896
e28b6cd44a no message 2021-01-05 21:26:42 -06:00
Chris-MS-896
30e0001b7f no message (#359) 2021-01-05 16:45:13 -06:00
Steve Faulkner
4a8f408112 Add UX for Mongo indexing experiment (#368)
Co-authored-by: Tim Sander <tisande@microsoft.com>
2021-01-05 16:04:55 -06:00
Armando Trejo Oliver
e801364800 Remove stale .main class from tree.less (#362)
.main CSS class has a naming conflict with Moncao editor CSS classes and this is causing  A11y issues with Moncao editor.

This class should no longer be used since we moved to the new tree component in REACT, so I am removing it. From my testing, this is not affecting anything.

If we find any styling issue later, we should fix without adding back this class.
2021-01-05 10:53:55 -08:00
victor-meng
a55f2d0de9 Free tier improvements in DE (#348)
Co-authored-by: Steve Faulkner <southpolesteve@gmail.com>
2021-01-04 12:56:55 -08:00
Steve Faulkner
d40b1aa9b5 Remove Empty Query Logging (#361) 2021-01-04 13:58:01 -06:00
87 changed files with 5769 additions and 1614 deletions

View File

@@ -42,6 +42,7 @@ src/Contracts/ViewModels.ts
src/Controls/Heatmap/Heatmap.test.ts
src/Controls/Heatmap/Heatmap.ts
src/Controls/Heatmap/HeatmapDatatypes.ts
src/Definitions/adal.d.ts
src/Definitions/datatables.d.ts
src/Definitions/gif.d.ts
src/Definitions/globals.d.ts
@@ -241,6 +242,9 @@ src/Platform/Hosted/Authorization.ts
src/Platform/Hosted/DataAccessUtility.ts
src/Platform/Hosted/ExplorerFactory.ts
src/Platform/Hosted/Helpers/ConnectionStringParser.test.ts
src/Platform/Hosted/Helpers/ConnectionStringParser.ts
src/Platform/Hosted/HostedUtils.test.ts
src/Platform/Hosted/HostedUtils.ts
src/Platform/Hosted/Main.ts
src/Platform/Hosted/Maint.test.ts
src/Platform/Hosted/NotificationsClient.ts

View File

@@ -43,7 +43,6 @@ module.exports = {
"@typescript-eslint/no-explicit-any": "error",
"prefer-arrow/prefer-arrow-functions": ["error", { allowStandaloneDeclarations: true }],
eqeqeq: "error",
"react/display-name": "off",
"no-restricted-syntax": [
"error",
{

1963
externals/adal.js vendored Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -3,8 +3,8 @@
/******************************************************************************/
@font-face {
font-family: wf_segoe-ui_normal;
src: url('../../fonts/segoe-ui/west-european/normal/latest.woff');
font-family: wf_segoe-ui_normal;
src: url("../../fonts/segoe-ui/west-european/normal/latest.woff");
}
@DataExplorerFont: wf_segoe-ui_normal, "Segoe UI", "Segoe WP", Tahoma, Arial, sans-serif;
@@ -20,26 +20,26 @@
COLORS
/******************************************************************************/
@AccentMediumHigh: #0058AD;
@AccentMedium: #004E87;
@AccentHigh: #1EBAED;
@AccentExtraHigh: #55B3FF;
@AccentLow: #EDF6FF;
@AccentMediumLow: #DDEEFE;
@AccentLight: #EEF7FF;
@AccentExtra: #DDF0FF;
@AccentMediumHigh: #0058ad;
@AccentMedium: #004e87;
@AccentHigh: #1ebaed;
@AccentExtraHigh: #55b3ff;
@AccentLow: #edf6ff;
@AccentMediumLow: #ddeefe;
@AccentLight: #eef7ff;
@AccentExtra: #ddf0ff;
@SelectionHigh: #B91F26;
@BaseLight: #FFFFFF;
@SelectionHigh: #b91f26;
@BaseLight: #ffffff;
@BaseDark: #000000;
@NotificationLow: #FFF4CE;
@NotificationHigh: #F9E9B0;
@Purple1: #8A2DA5;
@NotificationLow: #fff4ce;
@NotificationHigh: #f9e9b0;
@Purple1: #8a2da5;
@Dirty: #9b4f96;
@BaseLow: #F2F2F2;
@BaseMediumLow: #E6E6E6;
@BaseMedium: #CCCCCC;
@BaseLow: #f2f2f2;
@BaseMediumLow: #e6e6e6;
@BaseMedium: #cccccc;
@BaseMediumHigh: #767676;
@BaseHigh: #393939;
@@ -53,7 +53,7 @@
@ErrorColor: @SelectionHigh;
@SelectionColor: #3074B0;
@SelectionColor: #3074b0;
@FocusColor: #605e5c;
@@ -80,7 +80,7 @@
@ImgWidth: 14px;
@ImgHeight: 14px;
@toggleFontWeight:700;
@toggleFontWeight: 700;
//Resource Tree
@TreeLineHeight: 17px;
@@ -144,16 +144,16 @@
/**********************************************************************************/
.flex-display(@display: flex) {
display: ~"-webkit-@{display}";
display: ~"-ms-@{display}box"; // IE10 uses -ms-flexbox
display: ~"-ms-@{display}"; // IE11
display: @display;
display: ~"-webkit-@{display}";
display: ~"-ms-@{display}box"; // IE10 uses -ms-flexbox
display: ~"-ms-@{display}"; // IE11
display: @display;
}
.flex-direction(@direction: column) {
-webkit-flex-direction: @direction;
-ms-flex-direction: @direction;
flex-direction: @direction;
-ms-flex-direction: @direction;
flex-direction: @direction;
}
/*************************************************************************************
@@ -161,32 +161,31 @@
**************************************************************************************/
@media all and (-ms-high-contrast: none), (-ms-high-contrast: active) {
.selectedRadio,
.selectedRadio:hover,
.selectedRadio:active,
.selectedRadio.dirty,
.tab [type=radio]:checked ~ label,
.tab [type=radio]:checked ~ label:hover {
-ms-high-contrast-adjust: none;
-webkit-text-fill-color: HighlightText;
color: HighlightText;
border-color: HighlightText;
background-color: Highlight;
}
.queryMetricsSummaryTuple {
th, td {
&:nth-child(2) {
width: @IETableDataWidth;
}
&:nth-child(3) {
width: 50%;
}
}
.selectedRadio,
.selectedRadio:hover,
.selectedRadio:active,
.selectedRadio.dirty,
.tab [type="radio"]:checked ~ label,
.tab [type="radio"]:checked ~ label:hover {
-ms-high-contrast-adjust: none;
-webkit-text-fill-color: HighlightText;
color: HighlightText;
border-color: HighlightText;
background-color: Highlight;
}
.queryMetricsSummaryTuple {
th,
td {
&:nth-child(2) {
width: @IETableDataWidth;
}
&:nth-child(3) {
width: 50%;
}
}
}
}
/********************************************************************************************
@@ -194,15 +193,15 @@
*********************************************************************************************/
.hover() {
background-color: @AccentLight;
background-color: @AccentLight;
}
.active() {
background-color: @AccentExtra;
background-color: @AccentExtra;
}
.focus() {
outline: 1px dashed @FocusColor;
outline: 1px dashed @FocusColor;
}
/************************************************************************************************
@@ -212,63 +211,87 @@
@ToggleWidth: 180px;
.toggleSwitch() {
max-width: 100%;
margin-bottom: @SmallSpace;
padding: @SmallSpace;
cursor: pointer;
color: @BaseHigh;
font-weight: 400;
font-size: @mediumFontSize;
font-family: @DataExplorerFont;
max-width: 100%;
margin-bottom: @SmallSpace;
padding: @SmallSpace;
cursor: pointer;
color: @BaseHigh;
font-weight: 400;
font-size: @mediumFontSize;
font-family: @DataExplorerFont;
}
.selectedToggle() {
border-bottom: 2px solid @BaseHigh;
border-bottom: 2px solid @BaseHigh;
}
.unselectedToggle() {
color: @AccentMediumHigh;
color: @AccentMediumHigh;
}
/********************************************************************************************************
Common Data Explorer Icons
*********************************************************************************************************/
.dataExplorerIcons() {
cursor: pointer;
width: @ImgWidth;
height: @ImgHeight;
cursor: pointer;
width: @ImgWidth;
height: @ImgHeight;
}
/*********************************************************************************************************
Info Tooltip
**********************************************************************************************************/
.infoTooltip() {
position: relative;
display: inline-block;
position: relative;
display: inline-block;
}
.tooltipText(@textColor: @BaseLight, @backgroundColor: @BaseHigh) {
visibility: hidden;
background-color: @backgroundColor;
color: @textColor;
position: absolute;
z-index: 1;
left: @MediumSpace;
padding: @MediumSpace;
visibility: hidden;
background-color: @backgroundColor;
color: @textColor;
position: absolute;
z-index: 1;
left: @MediumSpace;
padding: @MediumSpace;
}
.tooltipTextAfter(@color: @BaseDark) {
content: "";
position: absolute;
right: 100%;
border-style: solid;
border-color: transparent @color transparent transparent;
left: 0px;
width: 0;
height: 0;
border-color: @InfoPointerColor transparent;
content: "";
position: absolute;
right: 100%;
border-style: solid;
border-color: transparent @color transparent transparent;
left: 0px;
width: 0;
height: 0;
border-color: @InfoPointerColor transparent;
}
.tooltipVisible() {
visibility: visible;
visibility: visible;
}
.inputTooltip() {
position: relative;
}
.inputTooltipText(@textColor: @BaseLight, @backgroundColor: @BaseHigh) {
background-color: @backgroundColor;
color: @textColor;
position: absolute;
z-index: 1;
padding: @MediumSpace;
}
.inputTooltipTextAfter(@color: @BaseDark) {
content: "";
position: absolute;
right: 100%;
border-style: solid;
border-color: transparent @color transparent transparent;
left: 10px;
width: 0;
height: 0;
border-color: @InfoPointerColor transparent;
}

View File

@@ -1527,6 +1527,21 @@ p {
color: @AccentHigh;
}
.inputTooltip {
.inputTooltip();
}
.inputTooltip .inputTooltipText {
top: -68px;
.inputTooltipText();
}
.inputTooltip .inputTooltipText::after {
border-width: @MediumSpace @MediumSpace 0 @MediumSpace;
top: 55px;
.inputTooltipTextAfter();
}
.nowrap {
white-space: nowrap;
}
@@ -3027,3 +3042,45 @@ settings-pane {
.collapsibleSection :hover {
cursor: pointer;
}
.messageBarInfoIcon {
color: #0072c6;
}
.messageBarWarningIcon {
color: #db7500;
}
.freeTierInfoBanner {
background-color: @BaseLow;
display: inline-flex;
padding: @DefaultSpace;
width: 100%;
.freeTierInfoIcon img {
height: 28px;
width: 28px;
margin-left: 4px;
}
.freeTierInfoMessage {
margin: auto 0;
padding-left: @MediumSpace;
}
}
.freeTierInlineWarning {
display: inline-flex;
padding: 8px 8px 8px 0;
width: 100%;
.freeTierWarningIcon img {
height: 20px;
width: 20px;
}
.freeTierWarningMessage {
margin: auto 0;
padding-left: @SmallSpace;
}
}

View File

@@ -1,20 +1,12 @@
@import "./Common/Constants";
.main {
width: 100%;
float: left;
transition: all .0s ease-in-out;
-ms-transition: all 0s ease-in-out;
-webkit-transition: all 0s ease-in-out;
-moz-transition: all .0s ease-in-out;
height: 100%;
background-color: white;
border-left: 0px solid white;
}
.resourceTree {
height: 100%;
flex: 0 0 auto;
.main {
height: 100%;
}
}
.resourceTreeScroll {

56
package-lock.json generated
View File

@@ -275,27 +275,6 @@
}
}
},
"@azure/msal-browser": {
"version": "2.8.0",
"resolved": "https://registry.npmjs.org/@azure/msal-browser/-/msal-browser-2.8.0.tgz",
"integrity": "sha512-I6n7EQnwsZXgKPOLlS5X48jhzUNUFwMVm180wDBA/pwEkUy8ei6zWiPMBfWaMSxz9uNx9WHaEhgAyhJy0ze3AQ==",
"requires": {
"@azure/msal-common": "^2.0.0"
}
},
"@azure/msal-common": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/@azure/msal-common/-/msal-common-2.0.0.tgz",
"integrity": "sha512-d1RNcJb+P1EGzMHtgbZoVlHLQWjlVfr504jywNk9YEfoq8Hw3BxJ0wepu+1w0hc64D8zG0wljcvHaIH1jTn2SA==",
"requires": {
"debug": "^4.1.1"
}
},
"@azure/msal-react": {
"version": "1.0.0-alpha.1",
"resolved": "https://registry.npmjs.org/@azure/msal-react/-/msal-react-1.0.0-alpha.1.tgz",
"integrity": "sha512-8BftMP1DyXf7/Fa7mxi14/fmHBdDGDUONmE8sm1T6w7ERJyY1RN7PZgdnUOcYcqj2xMnxfz9++8HsrzMrtMc0Q=="
},
"@babel/code-frame": {
"version": "7.10.4",
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.10.4.tgz",
@@ -5464,6 +5443,12 @@
"resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-6.2.0.tgz",
"integrity": "sha512-7evsyfH1cLOCdAzZAd43Cic04yKydNx0cF+7tiA19p1XnLLPU4dpCQOqpjqwokFe//vS0QqfqqjCS2JkiIs0cA=="
},
"adal-angular": {
"version": "1.0.15",
"resolved": "https://registry.npmjs.org/adal-angular/-/adal-angular-1.0.15.tgz",
"integrity": "sha1-8qnvgvNYxEToMUKs5l0yJ6RBBDs=",
"dev": true
},
"agent-base": {
"version": "6.0.2",
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz",
@@ -15328,15 +15313,6 @@
"tinyqueue": "^1.1.0"
}
},
"match-sorter": {
"version": "6.0.2",
"resolved": "https://registry.npmjs.org/match-sorter/-/match-sorter-6.0.2.tgz",
"integrity": "sha512-SDRLNlWof9GnAUEyhKP0O5525MMGXUGt+ep4MrrqQ2StAh3zjvICVZseiwg7Zijn3GazpJDiwuRr/mFDHd92NQ==",
"requires": {
"@babel/runtime": "^7.12.5",
"remove-accents": "0.4.2"
}
},
"matchdep": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/matchdep/-/matchdep-2.0.0.tgz",
@@ -15815,9 +15791,9 @@
"integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w=="
},
"msal": {
"version": "1.4.4",
"resolved": "https://registry.npmjs.org/msal/-/msal-1.4.4.tgz",
"integrity": "sha512-aOBD/L6jAsizDFzKxxvXxH0FEDjp6Inr3Ufi/Y2o7KCFKN+akoE2sLeszEb/0Y3VxHxK0F0ea7xQ/HHTomKivw==",
"version": "1.4.3",
"resolved": "https://registry.npmjs.org/msal/-/msal-1.4.3.tgz",
"integrity": "sha512-C90MhgzcBuTSR2BOQ/LQryY1CZVESQLJDdmRDWSsaVde+zwZ2iXD0fWw7zeBd5TzfUCiJEXZVs4lFJ8d/IGbiQ==",
"requires": {
"tslib": "^1.9.3"
},
@@ -17784,15 +17760,6 @@
"warning": "^4.0.2"
}
},
"react-query": {
"version": "3.5.5",
"resolved": "https://registry.npmjs.org/react-query/-/react-query-3.5.5.tgz",
"integrity": "sha512-WYZcHcAs5K5lPGT6CI8fz3lU62S8IfZhvB1K4aZH27wg9T6CWei+y7IRyZwti9X18LX134O4olgEuNth9LEX+w==",
"requires": {
"@babel/runtime": "^7.5.5",
"match-sorter": "^6.0.2"
}
},
"react-redux": {
"version": "7.1.3",
"resolved": "https://registry.npmjs.org/react-redux/-/react-redux-7.1.3.tgz",
@@ -18185,11 +18152,6 @@
"superagent-proxy": "^2.0.0"
}
},
"remove-accents": {
"version": "0.4.2",
"resolved": "https://registry.npmjs.org/remove-accents/-/remove-accents-0.4.2.tgz",
"integrity": "sha1-CkPTqq4egNuRngeuJUsoXZ4ce7U="
},
"remove-trailing-separator": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/remove-trailing-separator/-/remove-trailing-separator-1.1.0.tgz",

View File

@@ -6,10 +6,8 @@
"dependencies": {
"@azure/arm-cosmosdb": "9.1.0",
"@azure/cosmos": "3.9.0",
"@azure/cosmos-language-service": "0.0.5",
"@azure/identity": "1.1.0",
"@azure/msal-browser": "2.8.0",
"@azure/msal-react": "1.0.0-alpha.1",
"@azure/cosmos-language-service": "0.0.5",
"@jupyterlab/services": "6.0.0-rc.2",
"@jupyterlab/terminal": "3.0.0-rc.2",
"@microsoft/applicationinsights-web": "2.5.9",
@@ -71,7 +69,6 @@
"knockout": "3.5.1",
"mkdirp": "1.0.4",
"monaco-editor": "0.18.1",
"msal": "1.4.4",
"object.entries": "1.1.0",
"office-ui-fabric-react": "7.134.1",
"p-retry": "4.2.0",
@@ -86,7 +83,6 @@
"react-dom": "16.9.0",
"react-hotkeys": "2.0.0",
"react-notification-system": "0.2.17",
"react-query": "3.5.5",
"react-redux": "7.1.3",
"redux": "4.0.4",
"rx-jupyter": "5.5.12",
@@ -132,6 +128,7 @@
"@types/webfontloader": "1.6.29",
"@typescript-eslint/eslint-plugin": "4.0.1",
"@typescript-eslint/parser": "4.0.1",
"adal-angular": "1.0.15",
"axe-puppeteer": "1.1.0",
"babel-jest": "24.9.0",
"babel-loader": "8.1.0",

View File

@@ -2,6 +2,5 @@ export enum AuthType {
AAD = "aad",
EncryptedToken = "encryptedtoken",
MasterKey = "masterkey",
ResourceToken = "resourcetoken",
ConnectionString = "connectionstring"
ResourceToken = "resourcetoken"
}

View File

@@ -132,6 +132,7 @@ export class Features {
export class Flights {
public static readonly SettingsV2 = "settingsv2";
public static readonly MongoIndexEditor = "mongoindexeditor";
public static readonly MongoIndexing = "mongoindexing";
}
export class AfecFeatures {

View File

@@ -23,10 +23,10 @@ export class Splitter {
public splitterId: string;
public leftSideId: string;
public splitter: HTMLElement;
public leftSide: HTMLElement;
public lastX: number;
public lastWidth: number;
public splitter!: HTMLElement;
public leftSide!: HTMLElement;
public lastX!: number;
public lastWidth!: number;
private isCollapsed: ko.Observable<boolean>;
private bounds: SplitterBounds;
@@ -42,9 +42,10 @@ export class Splitter {
}
public initialize() {
this.splitter = document.getElementById(this.splitterId);
this.leftSide = document.getElementById(this.leftSideId);
if (document.getElementById(this.splitterId) !== null && document.getElementById(this.leftSideId) != null) {
this.splitter = <HTMLElement>document.getElementById(this.splitterId);
this.leftSide = <HTMLElement>document.getElementById(this.leftSideId);
}
const isVerticalSplitter: boolean = this.direction === SplitterDirection.Vertical;
const splitterOptions: JQueryUI.ResizableOptions = {
animate: true,

View File

@@ -587,3 +587,11 @@ export interface MemoryUsageInfo {
freeKB: number;
totalKB: number;
}
export interface resourceTokenConnectionStringProperties {
accountEndpoint: string;
collectionId: string;
databaseId: string;
partitionKey?: string;
resourceToken: string;
}

383
src/Definitions/adal.d.ts vendored Normal file
View File

@@ -0,0 +1,383 @@
// Type definitions for adal-angular 1.0.1.1
// Project: https://github.com/AzureAD/azure-activedirectory-library-for-js#readme
// Definitions by: Daniel Perez Alvarez <https://github.com/unindented>
// Anthony Ciccarello <https://github.com/aciccarello>
// Definitions: https://github.com/DefinitelyTyped/DefinitelyTyped
//This is a customized version of adal on top of version 1.0.1 which does not support multi tenant
// Customized version add tenantId to stored tokens so when tenant change, adal will refetch instead of read from sessionStorage
// In module contexts the class constructor function is the exported object
// export = AuthenticationContext;
// This class is defined globally in not in a module context
declare class AuthenticationContext {
instance: string;
config: AuthenticationContext.Options;
callback: AuthenticationContext.TokenCallback;
popUp: boolean;
isAngular: boolean;
/**
* Enum for request type
*/
REQUEST_TYPE: AuthenticationContext.RequestType;
RESPONSE_TYPE: AuthenticationContext.ResponseType;
CONSTANTS: AuthenticationContext.Constants;
constructor(options: AuthenticationContext.Options);
/**
* Initiates the login process by redirecting the user to Azure AD authorization endpoint.
*/
login(): void;
/**
* Returns whether a login is in progress.
*/
loginInProgress(): boolean;
/**
* Gets token for the specified resource from the cache.
* @param resource A URI that identifies the resource for which the token is requested.
* @param tenantId tenant Id.
*/
getCachedToken(resource: string, tenantId: string): string;
/**
* If user object exists, returns it. Else creates a new user object by decoding `id_token` from the cache.
*/
getCachedUser(): AuthenticationContext.UserInfo;
/**
* Adds the passed callback to the array of callbacks for the specified resource.
* @param resource A URI that identifies the resource for which the token is requested.
* @param expectedState A unique identifier (guid).
* @param callback The callback provided by the caller. It will be called with token or error.
*/
registerCallback(
expectedState: string,
resource: string,
callback: AuthenticationContext.TokenCallback,
tenantId: string
): void;
/**
* Acquires token from the cache if it is not expired. Otherwise sends request to AAD to obtain a new token.
* @param resource Resource URI identifying the target resource.
* @param callback The callback provided by the caller. It will be called with token or error.
*/
acquireToken(resource: string, tenantId: string, callback: AuthenticationContext.TokenCallback): void;
/**
* Acquires token (interactive flow using a popup window) by sending request to AAD to obtain a new token.
* @param resource Resource URI identifying the target resource.
* @param extraQueryParameters Query parameters to add to the authentication request.
* @param claims Claims to add to the authentication request.
* @param callback The callback provided by the caller. It will be called with token or error.
*/
acquireTokenPopup(
resource: string,
tenantId: string,
extraQueryParameters: string | null | undefined,
claims: string | null | undefined,
callback: AuthenticationContext.TokenCallback
): void;
/**
* Acquires token (interactive flow using a redirect) by sending request to AAD to obtain a new token. In this case the callback passed in the authentication request constructor will be called.
* @param resource Resource URI identifying the target resource.
* @param extraQueryParameters Query parameters to add to the authentication request.
* @param claims Claims to add to the authentication request.
*/
acquireTokenRedirect(
resource: string,
tenantId: string,
extraQueryParameters?: string | null,
claims?: string | null
): void;
/**
* Redirects the browser to Azure AD authorization endpoint.
* @param urlNavigate URL of the authorization endpoint.
*/
promptUser(urlNavigate: string): void;
/**
* Clears cache items.
*/
clearCache(): void;
/**
* Clears cache items for a given resource.
* @param resource Resource URI identifying the target resource.
*/
clearCacheForResource(resource: string): void;
/**
* Redirects user to logout endpoint. After logout, it will redirect to `postLogoutRedirectUri` if added as a property on the config object.
*/
logOut(): void;
/**
* Calls the passed in callback with the user object or error message related to the user.
* @param callback The callback provided by the caller. It will be called with user or error.
*/
getUser(callback: AuthenticationContext.UserCallback): void;
/**
* Checks if the URL fragment contains access token, id token or error description.
* @param hash Hash passed from redirect page.
*/
isCallback(hash: string): boolean;
/**
* Gets login error.
*/
getLoginError(): string;
/**
* Creates a request info object from the URL fragment and returns it.
*/
getRequestInfo(hash: string): AuthenticationContext.RequestInfo;
/**
* Saves token or error received in the response from AAD in the cache. In case of `id_token`, it also creates the user object.
*/
saveTokenFromHash(requestInfo: AuthenticationContext.RequestInfo): void;
/**
* Gets resource for given endpoint if mapping is provided with config.
* @param endpoint Resource URI identifying the target resource.
*/
getResourceForEndpoint(resource: string): string;
/**
* This method must be called for processing the response received from AAD. It extracts the hash, processes the token or error, saves it in the cache and calls the callbacks with the result.
* @param hash Hash fragment of URL. Defaults to `window.location.hash`.
*/
handleWindowCallback(hash?: string): void;
/**
* Checks the logging Level, constructs the log message and logs it. Users need to implement/override this method to turn on logging.
* @param level Level can be set 0, 1, 2 and 3 which turns on 'error', 'warning', 'info' or 'verbose' level logging respectively.
* @param message Message to log.
* @param error Error to log.
*/
log(level: AuthenticationContext.LoggingLevel, message: string, error: any): void;
/**
* Logs messages when logging level is set to 0.
* @param message Message to log.
* @param error Error to log.
*/
error(message: string, error: any): void;
/**
* Logs messages when logging level is set to 1.
* @param message Message to log.
*/
warn(message: string): void;
/**
* Logs messages when logging level is set to 2.
* @param message Message to log.
*/
info(message: string): void;
/**
* Logs messages when logging level is set to 3.
* @param message Message to log.
*/
verbose(message: string): void;
/**
* Logs Pii messages when Logging Level is set to 0 and window.piiLoggingEnabled is set to true.
* @param message Message to log.
* @param error Error to log.
*/
errorPii(message: string, error: any): void;
/**
* Logs Pii messages when Logging Level is set to 1 and window.piiLoggingEnabled is set to true.
* @param message Message to log.
*/
warnPii(message: string): void;
/**
* Logs messages when Logging Level is set to 2 and window.piiLoggingEnabled is set to true.
* @param message Message to log.
*/
infoPii(message: string): void;
/**
* Logs messages when Logging Level is set to 3 and window.piiLoggingEnabled is set to true.
* @param message Message to log.
*/
verbosePii(message: string): void;
}
declare namespace AuthenticationContext {
function inject(config: Options): AuthenticationContext;
type LoggingLevel = 0 | 1 | 2 | 3;
type RequestType = "LOGIN" | "RENEW_TOKEN" | "UNKNOWN";
type ResponseType = "id_token token" | "token";
interface RequestInfo {
/**
* Object comprising of fields such as id_token/error, session_state, state, e.t.c.
*/
parameters: any;
/**
* Request type.
*/
requestType: RequestType;
/**
* Whether state is valid.
*/
stateMatch: boolean;
/**
* Unique guid used to match the response with the request.
*/
stateResponse: string;
/**
* Whether `requestType` contains `id_token`, `access_token` or error.
*/
valid: boolean;
}
interface UserInfo {
/**
* Username assigned from UPN or email.
*/
userName: string;
/**
* Properties parsed from `id_token`.
*/
profile: any;
}
type TokenCallback = (errorDesc: string | null, token: string | null, error: any) => void;
type UserCallback = (errorDesc: string | null, user: UserInfo | null) => void;
/**
* Configuration options for Authentication Context
*/
interface Options {
/**
* Client ID assigned to your app by Azure Active Directory.
*/
clientId: string;
/**
* Endpoint at which you expect to receive tokens.Defaults to `window.location.href`.
*/
redirectUri?: string;
/**
* Azure Active Directory instance. Defaults to `https://login.microsoftonline.com/`.
*/
instance?: string;
/**
* Your target tenant. Defaults to `common`.
*/
tenant?: string;
/**
* Query parameters to add to the authentication request.
*/
extraQueryParameter?: string;
/**
* Unique identifier used to map the request with the response. Defaults to RFC4122 version 4 guid (128 bits).
*/
correlationId?: string;
/**
* User defined function of handling the navigation to Azure AD authorization endpoint in case of login.
*/
displayCall?: (url: string) => void;
/**
* Set this to true to enable login in a popup winodow instead of a full redirect. Defaults to `false`.
*/
popUp?: boolean;
/**
* Set this to the resource to request on login. Defaults to `clientId`.
*/
loginResource?: string;
/**
* Set this to redirect the user to a custom login page.
*/
localLoginUrl?: string;
/**
* Redirects to start page after login. Defaults to `true`.
*/
navigateToLoginRequestUrl?: boolean;
/**
* Set this to redirect the user to a custom logout page.
*/
logOutUri?: string;
/**
* Redirects the user to postLogoutRedirectUri after logout. Defaults to `redirectUri`.
*/
postLogoutRedirectUri?: string;
/**
* Sets browser storage to either 'localStorage' or sessionStorage'. Defaults to `sessionStorage`.
*/
cacheLocation?: "localStorage" | "sessionStorage";
/**
* Array of keywords or URIs. Adal will attach a token to outgoing requests that have these keywords or URIs.
*/
endpoints?: { [resource: string]: string };
/**
* Array of keywords or URIs. Adal will not attach a token to outgoing requests that have these keywords or URIs.
*/
anonymousEndpoints?: string[];
/**
* If the cached token is about to be expired in the expireOffsetSeconds (in seconds), Adal will renew the token instead of using the cached token. Defaults to 300 seconds.
*/
expireOffsetSeconds?: number;
/**
* The number of milliseconds of inactivity before a token renewal response from AAD should be considered timed out. Defaults to 6 seconds.
*/
loadFrameTimeout?: number;
/**
* Callback to be invoked when a token is acquired.
*/
callback?: TokenCallback;
}
interface LoggingConfig {
level: LoggingLevel;
log: (message: string) => void;
piiLoggingEnabled: boolean;
}
/**
* Enum for storage constants
*/
interface Constants {
ACCESS_TOKEN: "access_token";
EXPIRES_IN: "expires_in";
ID_TOKEN: "id_token";
ERROR_DESCRIPTION: "error_description";
SESSION_STATE: "session_state";
STORAGE: {
TOKEN_KEYS: "adal.token.keys";
ACCESS_TOKEN_KEY: "adal.access.token.key";
EXPIRATION_KEY: "adal.expiration.key";
STATE_LOGIN: "adal.state.login";
STATE_RENEW: "adal.state.renew";
NONCE_IDTOKEN: "adal.nonce.idtoken";
SESSION_STATE: "adal.session.state";
USERNAME: "adal.username";
IDTOKEN: "adal.idtoken";
ERROR: "adal.error";
ERROR_DESCRIPTION: "adal.error.description";
LOGIN_REQUEST: "adal.login.request";
LOGIN_ERROR: "adal.login.error";
RENEW_STATUS: "adal.token.renew.status";
};
RESOURCE_DELIMETER: "|";
LOADFRAME_TIMEOUT: "6000";
TOKEN_RENEW_STATUS_CANCELED: "Canceled";
TOKEN_RENEW_STATUS_COMPLETED: "Completed";
TOKEN_RENEW_STATUS_IN_PROGRESS: "In Progress";
LOGGING_LEVEL: {
ERROR: 0;
WARN: 1;
INFO: 2;
VERBOSE: 3;
};
LEVEL_STRING_MAP: {
0: "ERROR:";
1: "WARNING:";
2: "INFO:";
3: "VERBOSE:";
};
POPUP_WIDTH: 483;
POPUP_HEIGHT: 600;
}
}
// declare global {
// interface Window {
// Logging: AuthenticationContext.LoggingConfig;
// }
// }

View File

@@ -0,0 +1,159 @@
import React from "react";
import { shallow, mount } from "enzyme";
import { AccountSwitchComponent, AccountSwitchComponentProps } from "./AccountSwitchComponent";
import { AuthType } from "../../../AuthType";
import { DatabaseAccount, Subscription } from "../../../Contracts/DataModels";
import { AccountKind } from "../../../Common/Constants";
const createBlankProps = (): AccountSwitchComponentProps => {
return {
authType: null,
displayText: "",
accounts: [],
selectedAccountName: null,
isLoadingAccounts: false,
onAccountChange: jest.fn(),
subscriptions: [],
selectedSubscriptionId: null,
isLoadingSubscriptions: false,
onSubscriptionChange: jest.fn()
};
};
const createBlankAccount = (): DatabaseAccount => {
return {
id: "",
kind: AccountKind.Default,
name: "",
properties: null,
location: "",
tags: null,
type: ""
};
};
const createBlankSubscription = (): Subscription => {
return {
subscriptionId: "",
displayName: "",
authorizationSource: "",
state: "",
subscriptionPolicies: null,
tenantId: "",
uniqueDisplayName: ""
};
};
const createFullProps = (): AccountSwitchComponentProps => {
const props = createBlankProps();
props.authType = AuthType.AAD;
const account1 = createBlankAccount();
account1.name = "account1";
const account2 = createBlankAccount();
account2.name = "account2";
const account3 = createBlankAccount();
account3.name = "superlongaccountnamestringtest";
props.accounts = [account1, account2, account3];
props.selectedAccountName = "account2";
const sub1 = createBlankSubscription();
sub1.displayName = "sub1";
sub1.subscriptionId = "a6062a74-5d53-4b20-9545-000b95f22297";
const sub2 = createBlankSubscription();
sub2.displayName = "subsubsubsubsubsubsub2";
sub2.subscriptionId = "b20b3e93-0185-4326-8a9c-d44bac276b6b";
props.subscriptions = [sub1, sub2];
props.selectedSubscriptionId = "a6062a74-5d53-4b20-9545-000b95f22297";
return props;
};
describe("test render", () => {
it("renders no auth type -> handle error in code", () => {
const props = createBlankProps();
const wrapper = shallow(<AccountSwitchComponent {...props} />);
expect(wrapper).toMatchSnapshot();
});
// Encrypted Token
it("renders auth security token, with selected account name", () => {
const props = createBlankProps();
props.authType = AuthType.EncryptedToken;
props.selectedAccountName = "testaccount";
const wrapper = shallow(<AccountSwitchComponent {...props} />);
expect(wrapper).toMatchSnapshot();
});
// AAD
it("renders auth aad, with all information", () => {
const props = createFullProps();
const wrapper = shallow(<AccountSwitchComponent {...props} />);
expect(wrapper).toMatchSnapshot();
});
it("renders auth aad all dropdown menus", () => {
const props = createFullProps();
const wrapper = mount(<AccountSwitchComponent {...props} />);
expect(wrapper.exists("div.accountSwitchContextualMenu")).toBe(false);
wrapper.find("button.accountSwitchButton").simulate("click");
expect(wrapper.exists("div.accountSwitchContextualMenu")).toBe(true);
expect(wrapper.exists("div.accountSwitchSubscriptionDropdown")).toBe(true);
wrapper.find("DropdownBase.accountSwitchSubscriptionDropdown").simulate("click");
// Click will dismiss the first contextual menu in enzyme. Need to dig deeper to achieve below test
// expect(wrapper.exists("div.accountSwitchSubscriptionDropdownMenu")).toBe(true);
// expect(wrapper.find("button.ms-Dropdown-item").length).toBe(2);
// wrapper.find("div.accountSwitchSubscriptionDropdown").simulate("click");
// expect(wrapper.exists("div.accountSwitchSubscriptionDropdownMenu")).toBe(false);
// expect(wrapper.exists("div.accountSwitchAccountDropdown")).toBe(true);
// wrapper.find("div.accountSwitchAccountDropdown").simulate("click");
// expect(wrapper.exists("div.accountSwitchAccountDropdownMenu")).toBe(true);
// expect(wrapper.find("button.ms-Dropdown-item").length).toBe(3);
// wrapper.find("div.accountSwitchAccountDropdown").simulate("click");
// expect(wrapper.exists("div.accountSwitchAccountDropdownMenu")).toBe(false);
// wrapper.find("button.accountSwitchButton").simulate("click");
// expect(wrapper.exists("div.accountSwitchContextualMenu")).toBe(false);
wrapper.unmount();
});
});
// describe("test function", () => {
// it("switch subscription function", () => {
// const props = createFullProps();
// const wrapper = mount(<AccountSwitchComponent {...props} />);
// wrapper.find("button.accountSwitchButton").simulate("click");
// wrapper.find("div.accountSwitchSubscriptionDropdown").simulate("click");
// wrapper
// .find("button.ms-Dropdown-item")
// .at(1)
// .simulate("click");
// expect(props.onSubscriptionChange).toBeCalled();
// expect(props.onSubscriptionChange).toHaveBeenCalled();
// wrapper.unmount();
// });
// it("switch account", () => {
// const props = createFullProps();
// const wrapper = mount(<AccountSwitchComponent {...props} />);
// wrapper.find("button.accountSwitchButton").simulate("click");
// wrapper.find("div.accountSwitchAccountDropdown").simulate("click");
// wrapper
// .find("button.ms-Dropdown-item")
// .at(0)
// .simulate("click");
// expect(props.onAccountChange).toBeCalled();
// expect(props.onAccountChange).toHaveBeenCalled();
// wrapper.unmount();
// });
// });

View File

@@ -0,0 +1,177 @@
import { AuthType } from "../../../AuthType";
import { StyleConstants } from "../../../Common/Constants";
import { DatabaseAccount, Subscription } from "../../../Contracts/DataModels";
import * as React from "react";
import { DefaultButton, IButtonStyles, IButtonProps } from "office-ui-fabric-react/lib/Button";
import { IContextualMenuProps } from "office-ui-fabric-react/lib/ContextualMenu";
import { Dropdown, IDropdownOption, IDropdownProps } from "office-ui-fabric-react/lib/Dropdown";
export interface AccountSwitchComponentProps {
authType: AuthType;
selectedAccountName: string;
accounts: DatabaseAccount[];
isLoadingAccounts: boolean;
onAccountChange: (newAccount: DatabaseAccount) => void;
selectedSubscriptionId: string;
subscriptions: Subscription[];
isLoadingSubscriptions: boolean;
onSubscriptionChange: (newSubscription: Subscription) => void;
displayText?: string;
}
export class AccountSwitchComponent extends React.Component<AccountSwitchComponentProps> {
public render(): JSX.Element {
return this.props.authType === AuthType.AAD ? this._renderSwitchDropDown() : this._renderAccountName();
}
private _renderSwitchDropDown(): JSX.Element {
const { displayText, selectedAccountName } = this.props;
const menuProps: IContextualMenuProps = {
directionalHintFixed: true,
className: "accountSwitchContextualMenu",
items: [
{
key: "switchSubscription",
onRender: this._renderSubscriptionDropdown.bind(this)
},
{
key: "switchAccount",
onRender: this._renderAccountDropDown.bind(this)
}
]
};
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"
}
};
const buttonProps: IButtonProps = {
text: displayText || selectedAccountName,
menuProps: menuProps,
styles: buttonStyles,
className: "accountSwitchButton",
id: "accountSwitchButton"
};
return <DefaultButton {...buttonProps} />;
}
private _renderSubscriptionDropdown(): JSX.Element {
const { subscriptions, selectedSubscriptionId, isLoadingSubscriptions } = this.props;
const options: IDropdownOption[] = subscriptions.map(sub => {
return {
key: sub.subscriptionId,
text: sub.displayName,
data: sub
};
});
const placeHolderText = isLoadingSubscriptions
? "Loading subscriptions"
: !options || !options.length
? "No subscriptions found in current directory"
: "Select subscription from list";
const dropdownProps: IDropdownProps = {
label: "Subscription",
className: "accountSwitchSubscriptionDropdown",
options: options,
onChange: this._onSubscriptionDropdownChange,
defaultSelectedKey: selectedSubscriptionId,
placeholder: placeHolderText,
styles: {
callout: "accountSwitchSubscriptionDropdownMenu"
}
};
return <Dropdown {...dropdownProps} />;
}
private _onSubscriptionDropdownChange = (e: React.FormEvent<HTMLDivElement>, option: IDropdownOption): void => {
if (!option) {
return;
}
this.props.onSubscriptionChange(option.data);
};
private _renderAccountDropDown(): JSX.Element {
const { accounts, selectedAccountName, isLoadingAccounts } = this.props;
const options: IDropdownOption[] = accounts.map(account => {
return {
key: account.name,
text: account.name,
data: account
};
});
// Fabric UI will also try to select the first non-disabled option from dropdown.
// Add a option to prevent pop the message when user click on dropdown on first time.
options.unshift({
key: "select from list",
text: "Select Cosmos DB account from list",
data: undefined
});
const placeHolderText = isLoadingAccounts
? "Loading Cosmos DB accounts"
: !options || !options.length
? "No Cosmos DB accounts found"
: "Select Cosmos DB account from list";
const dropdownProps: IDropdownProps = {
label: "Cosmos DB Account Name",
className: "accountSwitchAccountDropdown",
options: options,
onChange: this._onAccountDropdownChange,
defaultSelectedKey: selectedAccountName,
placeholder: placeHolderText,
styles: {
callout: "accountSwitchAccountDropdownMenu"
}
};
return <Dropdown {...dropdownProps} />;
}
private _onAccountDropdownChange = (e: React.FormEvent<HTMLDivElement>, option: IDropdownOption): void => {
if (!option) {
return;
}
this.props.onAccountChange(option.data);
};
private _renderAccountName(): JSX.Element {
const { displayText, selectedAccountName } = this.props;
return <span className="accountNameHeader">{displayText || selectedAccountName}</span>;
}
}

View File

@@ -0,0 +1,11 @@
import * as React from "react";
import { ReactAdapter } from "../../../Bindings/ReactBindingHandler";
import { AccountSwitchComponent, AccountSwitchComponentProps } from "./AccountSwitchComponent";
export class AccountSwitchComponentAdapter implements ReactAdapter {
public parameters: ko.Observable<AccountSwitchComponentProps>;
public renderComponent(): JSX.Element {
return <AccountSwitchComponent {...this.parameters()} />;
}
}

View File

@@ -0,0 +1,71 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`test render renders auth aad, with all information 1`] = `
<CustomizedDefaultButton
className="accountSwitchButton"
id="accountSwitchButton"
menuProps={
Object {
"className": "accountSwitchContextualMenu",
"directionalHintFixed": true,
"items": Array [
Object {
"key": "switchSubscription",
"onRender": [Function],
},
Object {
"key": "switchAccount",
"onRender": [Function],
},
],
}
}
styles={
Object {
"root": Object {
"backgroundColor": undefined,
"color": undefined,
"fontSize": undefined,
"height": 40,
"marginRight": 5,
"padding": 0,
"paddingLeft": 10,
},
"rootExpanded": Object {
"backgroundColor": undefined,
"color": undefined,
},
"rootFocused": Object {
"backgroundColor": undefined,
"color": undefined,
},
"rootHovered": Object {
"backgroundColor": undefined,
"color": undefined,
},
"rootPressed": Object {
"backgroundColor": undefined,
"color": undefined,
},
"textContainer": Object {
"flexGrow": "initial",
},
}
}
text="account2"
/>
`;
exports[`test render renders auth security token, with selected account name 1`] = `
<span
className="accountNameHeader"
>
testaccount
</span>
`;
exports[`test render renders no auth type -> handle error in code 1`] = `
<span
className="accountNameHeader"
/>
`;

View File

@@ -38,7 +38,7 @@ export interface CommandButtonComponentProps {
/**
* Label for the button
*/
commandButtonLabel?: string;
commandButtonLabel: string;
/**
* True if this button opens a tab or pane, false otherwise.

View File

@@ -138,8 +138,8 @@ export class SettingsComponent extends React.Component<SettingsComponentProps, S
// Mongo container with system partition key still treat as "Fixed"
this.isFixedContainer =
!this.collection.partitionKey ||
(this.container.isPreferredApiMongoDB() && this.collection.partitionKey.systemKey);
this.container.isPreferredApiMongoDB() &&
(!this.collection.partitionKey || this.collection.partitionKey.systemKey);
this.state = {
throughput: undefined,

View File

@@ -66,7 +66,7 @@ export interface PriceBreakdown {
currencySign: string;
}
export const infoAndToolTipTextStyle: ITextStyles = { root: { fontSize: 12 } };
export const infoAndToolTipTextStyle: ITextStyles = { root: { fontSize: 14 } };
export const noLeftPaddingCheckBoxStyle: ICheckboxStyles = {
label: {
@@ -166,7 +166,10 @@ export const separatorStyles: Partial<ISeparatorStyles> = {
]
};
export const messageBarStyles: Partial<IMessageBarStyles> = { root: { marginTop: "5px" } };
export const messageBarStyles: Partial<IMessageBarStyles> = {
root: { marginTop: "5px", backgroundColor: "white" },
text: { fontSize: 14 }
};
export const throughputUnit = "RU/s";

View File

@@ -8,7 +8,7 @@ exports[`IndexingPolicyRefreshComponent renders 1`] = `
styles={
Object {
"root": Object {
"fontSize": 12,
"fontSize": 14,
},
}
}

View File

@@ -16,7 +16,7 @@ import {
} from "../SettingsRenderUtils";
import { hasDatabaseSharedThroughput } from "../SettingsUtils";
import * as AutoPilotUtils from "../../../../Utils/AutoPilotUtils";
import { Text, TextField, Stack, Label, MessageBar, MessageBarType } from "office-ui-fabric-react";
import { Link, Text, TextField, Stack, Label, MessageBar, MessageBarType } from "office-ui-fabric-react";
import { configContext, Platform } from "../../../../ConfigContext";
export interface ScaleComponentProps {
@@ -176,6 +176,7 @@ export class ScaleComponent extends React.Component<ScaleComponentProps> {
label={this.getThroughputTitle()}
isEmulator={this.isEmulator}
isFixed={this.props.isFixedContainer}
isFreeTierAccount={this.isFreeTierAccount()}
isAutoPilotSelected={this.props.isAutoPilotSelected}
onAutoPilotSelected={this.props.onAutoPilotSelected}
wasAutopilotOriginallySet={this.props.wasAutopilotOriginallySet}
@@ -190,9 +191,37 @@ export class ScaleComponent extends React.Component<ScaleComponentProps> {
/>
);
private isFreeTierAccount(): boolean {
const databaseAccount = this.props.container?.databaseAccount();
return databaseAccount?.properties?.enableFreeTier;
}
private getFreeTierInfoMessage(): JSX.Element {
return (
<Text>
With free tier, you will get the first 400 RU/s and 5 GB of storage in this account for free. To keep your
account free, keep the total RU/s across all resources in the account to 400 RU/s.
<Link
href="https://docs.microsoft.com/en-us/azure/cosmos-db/understand-your-bill#billing-examples-with-free-tier-accounts"
target="_blank"
>
Learn more.
</Link>
</Text>
);
}
public render(): JSX.Element {
return (
<Stack {...subComponentStackProps}>
{this.isFreeTierAccount() && (
<MessageBar
messageBarIconProps={{ iconName: "InfoSolid", className: "messageBarInfoIcon" }}
styles={{ text: { fontSize: 14 } }}
>
{this.getFreeTierInfoMessage()}
</MessageBar>
)}
{this.getInitialNotificationElement() && (
<MessageBar messageBarType={MessageBarType.warning}>{this.getInitialNotificationElement()}</MessageBar>
)}

View File

@@ -13,16 +13,7 @@ import {
} from "../SettingsUtils";
import Explorer from "../../../Explorer";
import { Int32 } from "../../../Panes/Tables/Validators/EntityPropertyValidationCommon";
import {
Label,
Text,
TextField,
Stack,
IChoiceGroupOption,
ChoiceGroup,
MessageBar,
MessageBarType
} from "office-ui-fabric-react";
import { Label, Text, TextField, Stack, IChoiceGroupOption, ChoiceGroup, MessageBar } from "office-ui-fabric-react";
import {
getTextFieldStyles,
changeFeedPolicyToolTip,
@@ -190,7 +181,10 @@ export class SubSettingsComponent extends React.Component<SubSettingsComponentPr
styles={getChoiceGroupStyles(this.props.timeToLive, this.props.timeToLiveBaseline)}
/>
{isDirty(this.props.timeToLive, this.props.timeToLiveBaseline) && this.props.timeToLive === TtlType.On && (
<MessageBar messageBarType={MessageBarType.warning} styles={messageBarStyles}>
<MessageBar
messageBarIconProps={{ iconName: "InfoSolid", className: "messageBarInfoIcon" }}
styles={messageBarStyles}
>
{ttlWarning}
</MessageBar>
)}

View File

@@ -26,6 +26,7 @@ describe("ThroughputInputAutoPilotV3Component", () => {
spendAckVisible: false,
showAsMandatory: true,
isFixed: false,
isFreeTierAccount: false,
label: "label",
infoBubbleText: "infoBubbleText",
canExceedMaximumValue: true,

View File

@@ -28,7 +28,6 @@ import {
Label,
Link,
MessageBar,
MessageBarType,
FontIcon,
IColumn
} from "office-ui-fabric-react";
@@ -58,6 +57,7 @@ export interface ThroughputInputAutoPilotV3Props {
spendAckVisible?: boolean;
showAsMandatory?: boolean;
isFixed: boolean;
isFreeTierAccount: boolean;
isEmulator: boolean;
label: string;
infoBubbleText?: string;
@@ -76,6 +76,7 @@ export interface ThroughputInputAutoPilotV3Props {
interface ThroughputInputAutoPilotV3State {
spendAckChecked: boolean;
exceedFreeTierThroughput: boolean;
}
export class ThroughputInputAutoPilotV3Component extends React.Component<
@@ -149,7 +150,9 @@ export class ThroughputInputAutoPilotV3Component extends React.Component<
public constructor(props: ThroughputInputAutoPilotV3Props) {
super(props);
this.state = {
spendAckChecked: this.props.spendAckChecked
spendAckChecked: this.props.spendAckChecked,
exceedFreeTierThroughput:
this.props.isFreeTierAccount && !this.props.isAutoPilotSelected && this.props.throughput > 400
};
this.step = this.props.step ?? ThroughputInputAutoPilotV3Component.defaultStep;
@@ -424,7 +427,7 @@ export class ThroughputInputAutoPilotV3Component extends React.Component<
event: React.FormEvent<HTMLInputElement | HTMLTextAreaElement>,
newValue?: string
): void => {
const newThroughput = getSanitizedInputValue(newValue, this.autoPilotInputMaxValue);
const newThroughput = getSanitizedInputValue(newValue);
this.props.onMaxAutoPilotThroughputChange(newThroughput);
};
@@ -432,10 +435,11 @@ export class ThroughputInputAutoPilotV3Component extends React.Component<
event: React.FormEvent<HTMLInputElement | HTMLTextAreaElement>,
newValue?: string
): void => {
const newThroughput = getSanitizedInputValue(newValue, this.throughputInputMaxValue);
const newThroughput = getSanitizedInputValue(newValue);
if (this.overrideWithAutoPilotSettings()) {
this.props.onMaxAutoPilotThroughputChange(newThroughput);
} else {
this.setState({ exceedFreeTierThroughput: this.props.isFreeTierAccount && newThroughput > 400 });
this.props.onThroughputChange(newThroughput);
}
};
@@ -479,7 +483,10 @@ export class ThroughputInputAutoPilotV3Component extends React.Component<
/>
</Label>
{this.overrideWithProvisionedThroughputSettings() && (
<MessageBar messageBarType={MessageBarType.warning} styles={messageBarStyles}>
<MessageBar
messageBarIconProps={{ iconName: "InfoSolid", className: "messageBarInfoIcon" }}
styles={messageBarStyles}
>
{manualToAutoscaleDisclaimerElement}
</MessageBar>
)}
@@ -556,8 +563,21 @@ export class ThroughputInputAutoPilotV3Component extends React.Component<
}
onChange={this.onThroughputChange}
/>
{this.state.exceedFreeTierThroughput && (
<MessageBar
messageBarIconProps={{ iconName: "WarningSolid", className: "messageBarWarningIcon" }}
styles={messageBarStyles}
>
{
"Billing will apply if you provision more than 400 RU/s of manual throughput, or if the resource scales beyond 400 RU/s with autoscale."
}
</MessageBar>
)}
{this.props.getThroughputWarningMessage() && (
<MessageBar messageBarType={MessageBarType.warning} styles={messageBarStyles}>
<MessageBar
messageBarIconProps={{ iconName: "InfoSolid", className: "messageBarInfoIcon" }}
styles={messageBarStyles}
>
{this.props.getThroughputWarningMessage()}
</MessageBar>
)}
@@ -583,7 +603,15 @@ export class ThroughputInputAutoPilotV3Component extends React.Component<
warningMessage = saveThroughputWarningMessage;
}
return <>{warningMessage && <MessageBar messageBarType={MessageBarType.warning}>{warningMessage}</MessageBar>}</>;
return (
<>
{warningMessage && (
<MessageBar messageBarIconProps={{ iconName: "WarningSolid", className: "messageBarWarningIcon" }}>
{warningMessage}
</MessageBar>
)}
</>
);
};
public render(): JSX.Element {

View File

@@ -9,13 +9,18 @@ exports[`ThroughputInputAutoPilotV3Component autopilot input visible 1`] = `
}
>
<StyledMessageBarBase
messageBarType={5}
messageBarIconProps={
Object {
"className": "messageBarWarningIcon",
"iconName": "WarningSolid",
}
}
>
<Text
styles={
Object {
"root": Object {
"fontSize": 12,
"fontSize": 14,
},
}
}
@@ -34,7 +39,7 @@ exports[`ThroughputInputAutoPilotV3Component autopilot input visible 1`] = `
styles={
Object {
"root": Object {
"fontSize": 12,
"fontSize": 14,
},
}
}
@@ -45,12 +50,21 @@ exports[`ThroughputInputAutoPilotV3Component autopilot input visible 1`] = `
/>
</StyledLabelBase>
<StyledMessageBarBase
messageBarType={5}
messageBarIconProps={
Object {
"className": "messageBarInfoIcon",
"iconName": "InfoSolid",
}
}
styles={
Object {
"root": Object {
"backgroundColor": "white",
"marginTop": "5px",
},
"text": Object {
"fontSize": 14,
},
}
}
>
@@ -59,7 +73,7 @@ exports[`ThroughputInputAutoPilotV3Component autopilot input visible 1`] = `
styles={
Object {
"root": Object {
"fontSize": 12,
"fontSize": 14,
},
}
}
@@ -171,7 +185,7 @@ exports[`ThroughputInputAutoPilotV3Component spendAck checkbox visible 1`] = `
styles={
Object {
"root": Object {
"fontSize": 12,
"fontSize": 14,
},
}
}
@@ -444,7 +458,7 @@ exports[`ThroughputInputAutoPilotV3Component throughput input visible 1`] = `
styles={
Object {
"root": Object {
"fontSize": 12,
"fontSize": 14,
},
}
}

View File

@@ -16,7 +16,7 @@ exports[`ScaleComponent renders with correct initial notification 1`] = `
styles={
Object {
"root": Object {
"fontSize": 12,
"fontSize": 14,
},
}
}

View File

@@ -136,7 +136,7 @@ exports[`SubSettingsComponent analyticalTimeToLive hidden 1`] = `
styles={
Object {
"root": Object {
"fontSize": 12,
"fontSize": 14,
},
}
}
@@ -412,7 +412,7 @@ exports[`SubSettingsComponent analyticalTimeToLiveSeconds hidden 1`] = `
styles={
Object {
"root": Object {
"fontSize": 12,
"fontSize": 14,
},
}
}
@@ -952,7 +952,7 @@ exports[`SubSettingsComponent renders 1`] = `
styles={
Object {
"root": Object {
"fontSize": 12,
"fontSize": 14,
},
}
}
@@ -1228,7 +1228,7 @@ exports[`SubSettingsComponent timeToLiveSeconds hidden 1`] = `
styles={
Object {
"root": Object {
"fontSize": 12,
"fontSize": 14,
},
}
}

View File

@@ -101,13 +101,13 @@ export const parseConflictResolutionProcedure = (procedureFromBackEnd: string):
return procedureFromBackEnd;
};
export const getSanitizedInputValue = (newValueString: string, max: number): number => {
export const getSanitizedInputValue = (newValueString: string, max?: number): number => {
const newValue = parseInt(newValueString);
if (isNaN(newValue)) {
return zeroValue;
}
// make sure new value does not exceed the maximum throughput
return Math.min(newValue, max);
return max ? Math.min(newValue, max) : newValue;
};
export const isDirty = (current: isDirtyTypes, baseline: isDirtyTypes): boolean => {

View File

@@ -55,6 +55,7 @@ exports[`SettingsComponent renders 1`] = `
"firstFieldHasFocus": [Function],
"formErrors": [Function],
"formErrorsDetails": [Function],
"freeTierExceedThroughputTooltip": [Function],
"id": "adddatabasepane",
"isAutoPilotSelected": [Function],
"isExecuting": [Function],
@@ -104,6 +105,7 @@ exports[`SettingsComponent renders 1`] = `
"formErrors": [Function],
"formErrorsDetails": [Function],
"formWarnings": [Function],
"freeTierExceedThroughputTooltip": [Function],
"id": "addcollectionpane",
"isAnalyticalStorageOn": [Function],
"isAutoPilotSelected": [Function],
@@ -591,6 +593,7 @@ exports[`SettingsComponent renders 1`] = `
"formErrors": [Function],
"formErrorsDetails": [Function],
"formWarnings": [Function],
"freeTierExceedThroughputTooltip": [Function],
"id": "addcollectionpane",
"isAnalyticalStorageOn": [Function],
"isAutoPilotSelected": [Function],
@@ -665,6 +668,7 @@ exports[`SettingsComponent renders 1`] = `
"firstFieldHasFocus": [Function],
"formErrors": [Function],
"formErrorsDetails": [Function],
"freeTierExceedThroughputTooltip": [Function],
"id": "adddatabasepane",
"isAutoPilotSelected": [Function],
"isExecuting": [Function],
@@ -950,6 +954,7 @@ exports[`SettingsComponent renders 1`] = `
"isHostedDataExplorerEnabled": [Function],
"isLeftPaneExpanded": [Function],
"isLinkInjectionEnabled": [Function],
"isMongoIndexingEnabled": [Function],
"isNotebookEnabled": [Function],
"isNotebooksEnabledForAccount": [Function],
"isNotificationConsoleExpanded": [Function],
@@ -1175,11 +1180,9 @@ exports[`SettingsComponent renders 1`] = `
},
"direction": "vertical",
"isCollapsed": [Function],
"leftSide": null,
"leftSideId": "resourcetree",
"onResizeStart": [Function],
"onResizeStop": [Function],
"splitter": null,
"splitterId": "h_splitter1",
},
"stringInputPane": StringInputPane {
@@ -1326,6 +1329,7 @@ exports[`SettingsComponent renders 1`] = `
"firstFieldHasFocus": [Function],
"formErrors": [Function],
"formErrorsDetails": [Function],
"freeTierExceedThroughputTooltip": [Function],
"id": "adddatabasepane",
"isAutoPilotSelected": [Function],
"isExecuting": [Function],
@@ -1375,6 +1379,7 @@ exports[`SettingsComponent renders 1`] = `
"formErrors": [Function],
"formErrorsDetails": [Function],
"formWarnings": [Function],
"freeTierExceedThroughputTooltip": [Function],
"id": "addcollectionpane",
"isAnalyticalStorageOn": [Function],
"isAutoPilotSelected": [Function],
@@ -1862,6 +1867,7 @@ exports[`SettingsComponent renders 1`] = `
"formErrors": [Function],
"formErrorsDetails": [Function],
"formWarnings": [Function],
"freeTierExceedThroughputTooltip": [Function],
"id": "addcollectionpane",
"isAnalyticalStorageOn": [Function],
"isAutoPilotSelected": [Function],
@@ -1936,6 +1942,7 @@ exports[`SettingsComponent renders 1`] = `
"firstFieldHasFocus": [Function],
"formErrors": [Function],
"formErrorsDetails": [Function],
"freeTierExceedThroughputTooltip": [Function],
"id": "adddatabasepane",
"isAutoPilotSelected": [Function],
"isExecuting": [Function],
@@ -2221,6 +2228,7 @@ exports[`SettingsComponent renders 1`] = `
"isHostedDataExplorerEnabled": [Function],
"isLeftPaneExpanded": [Function],
"isLinkInjectionEnabled": [Function],
"isMongoIndexingEnabled": [Function],
"isNotebookEnabled": [Function],
"isNotebooksEnabledForAccount": [Function],
"isNotificationConsoleExpanded": [Function],
@@ -2446,11 +2454,9 @@ exports[`SettingsComponent renders 1`] = `
},
"direction": "vertical",
"isCollapsed": [Function],
"leftSide": null,
"leftSideId": "resourcetree",
"onResizeStart": [Function],
"onResizeStop": [Function],
"splitter": null,
"splitterId": "h_splitter1",
},
"stringInputPane": StringInputPane {
@@ -2610,6 +2616,7 @@ exports[`SettingsComponent renders 1`] = `
"firstFieldHasFocus": [Function],
"formErrors": [Function],
"formErrorsDetails": [Function],
"freeTierExceedThroughputTooltip": [Function],
"id": "adddatabasepane",
"isAutoPilotSelected": [Function],
"isExecuting": [Function],
@@ -2659,6 +2666,7 @@ exports[`SettingsComponent renders 1`] = `
"formErrors": [Function],
"formErrorsDetails": [Function],
"formWarnings": [Function],
"freeTierExceedThroughputTooltip": [Function],
"id": "addcollectionpane",
"isAnalyticalStorageOn": [Function],
"isAutoPilotSelected": [Function],
@@ -3146,6 +3154,7 @@ exports[`SettingsComponent renders 1`] = `
"formErrors": [Function],
"formErrorsDetails": [Function],
"formWarnings": [Function],
"freeTierExceedThroughputTooltip": [Function],
"id": "addcollectionpane",
"isAnalyticalStorageOn": [Function],
"isAutoPilotSelected": [Function],
@@ -3220,6 +3229,7 @@ exports[`SettingsComponent renders 1`] = `
"firstFieldHasFocus": [Function],
"formErrors": [Function],
"formErrorsDetails": [Function],
"freeTierExceedThroughputTooltip": [Function],
"id": "adddatabasepane",
"isAutoPilotSelected": [Function],
"isExecuting": [Function],
@@ -3505,6 +3515,7 @@ exports[`SettingsComponent renders 1`] = `
"isHostedDataExplorerEnabled": [Function],
"isLeftPaneExpanded": [Function],
"isLinkInjectionEnabled": [Function],
"isMongoIndexingEnabled": [Function],
"isNotebookEnabled": [Function],
"isNotebooksEnabledForAccount": [Function],
"isNotificationConsoleExpanded": [Function],
@@ -3730,11 +3741,9 @@ exports[`SettingsComponent renders 1`] = `
},
"direction": "vertical",
"isCollapsed": [Function],
"leftSide": null,
"leftSideId": "resourcetree",
"onResizeStart": [Function],
"onResizeStop": [Function],
"splitter": null,
"splitterId": "h_splitter1",
},
"stringInputPane": StringInputPane {
@@ -3881,6 +3890,7 @@ exports[`SettingsComponent renders 1`] = `
"firstFieldHasFocus": [Function],
"formErrors": [Function],
"formErrorsDetails": [Function],
"freeTierExceedThroughputTooltip": [Function],
"id": "adddatabasepane",
"isAutoPilotSelected": [Function],
"isExecuting": [Function],
@@ -3930,6 +3940,7 @@ exports[`SettingsComponent renders 1`] = `
"formErrors": [Function],
"formErrorsDetails": [Function],
"formWarnings": [Function],
"freeTierExceedThroughputTooltip": [Function],
"id": "addcollectionpane",
"isAnalyticalStorageOn": [Function],
"isAutoPilotSelected": [Function],
@@ -4417,6 +4428,7 @@ exports[`SettingsComponent renders 1`] = `
"formErrors": [Function],
"formErrorsDetails": [Function],
"formWarnings": [Function],
"freeTierExceedThroughputTooltip": [Function],
"id": "addcollectionpane",
"isAnalyticalStorageOn": [Function],
"isAutoPilotSelected": [Function],
@@ -4491,6 +4503,7 @@ exports[`SettingsComponent renders 1`] = `
"firstFieldHasFocus": [Function],
"formErrors": [Function],
"formErrorsDetails": [Function],
"freeTierExceedThroughputTooltip": [Function],
"id": "adddatabasepane",
"isAutoPilotSelected": [Function],
"isExecuting": [Function],
@@ -4776,6 +4789,7 @@ exports[`SettingsComponent renders 1`] = `
"isHostedDataExplorerEnabled": [Function],
"isLeftPaneExpanded": [Function],
"isLinkInjectionEnabled": [Function],
"isMongoIndexingEnabled": [Function],
"isNotebookEnabled": [Function],
"isNotebooksEnabledForAccount": [Function],
"isNotificationConsoleExpanded": [Function],
@@ -5001,11 +5015,9 @@ exports[`SettingsComponent renders 1`] = `
},
"direction": "vertical",
"isCollapsed": [Function],
"leftSide": null,
"leftSideId": "resourcetree",
"onResizeStart": [Function],
"onResizeStop": [Function],
"splitter": null,
"splitterId": "h_splitter1",
},
"stringInputPane": StringInputPane {

View File

@@ -159,7 +159,7 @@ exports[`SettingsUtils functions render 1`] = `
styles={
Object {
"root": Object {
"fontSize": 12,
"fontSize": 14,
},
}
}
@@ -176,7 +176,7 @@ exports[`SettingsUtils functions render 1`] = `
styles={
Object {
"root": Object {
"fontSize": 12,
"fontSize": 14,
},
}
}
@@ -195,7 +195,7 @@ exports[`SettingsUtils functions render 1`] = `
styles={
Object {
"root": Object {
"fontSize": 12,
"fontSize": 14,
},
}
}
@@ -207,7 +207,7 @@ exports[`SettingsUtils functions render 1`] = `
styles={
Object {
"root": Object {
"fontSize": 12,
"fontSize": 14,
},
}
}
@@ -219,7 +219,7 @@ exports[`SettingsUtils functions render 1`] = `
styles={
Object {
"root": Object {
"fontSize": 12,
"fontSize": 14,
},
}
}
@@ -230,7 +230,7 @@ exports[`SettingsUtils functions render 1`] = `
styles={
Object {
"root": Object {
"fontSize": 12,
"fontSize": 14,
},
}
}
@@ -249,7 +249,7 @@ exports[`SettingsUtils functions render 1`] = `
styles={
Object {
"root": Object {
"fontSize": 12,
"fontSize": 14,
},
}
}
@@ -268,7 +268,7 @@ exports[`SettingsUtils functions render 1`] = `
styles={
Object {
"root": Object {
"fontSize": 12,
"fontSize": 14,
},
}
}
@@ -286,7 +286,7 @@ exports[`SettingsUtils functions render 1`] = `
styles={
Object {
"root": Object {
"fontSize": 12,
"fontSize": 14,
},
}
}
@@ -299,7 +299,7 @@ exports[`SettingsUtils functions render 1`] = `
styles={
Object {
"root": Object {
"fontSize": 12,
"fontSize": 14,
},
}
}
@@ -310,7 +310,7 @@ exports[`SettingsUtils functions render 1`] = `
styles={
Object {
"root": Object {
"fontSize": 12,
"fontSize": 14,
},
}
}
@@ -329,7 +329,7 @@ exports[`SettingsUtils functions render 1`] = `
styles={
Object {
"root": Object {
"fontSize": 12,
"fontSize": 14,
},
}
}
@@ -371,7 +371,7 @@ exports[`SettingsUtils functions render 1`] = `
styles={
Object {
"root": Object {
"fontSize": 12,
"fontSize": 14,
},
}
}
@@ -386,7 +386,7 @@ exports[`SettingsUtils functions render 1`] = `
styles={
Object {
"root": Object {
"fontSize": 12,
"fontSize": 14,
},
}
}
@@ -402,7 +402,7 @@ exports[`SettingsUtils functions render 1`] = `
styles={
Object {
"root": Object {
"fontSize": 12,
"fontSize": 14,
},
}
}

View File

@@ -129,6 +129,8 @@ export interface ThroughputInputParams {
showAutoPilot?: ko.Observable<boolean>;
overrideWithAutoPilotSettings: ko.Observable<boolean>;
overrideWithProvisionedThroughputSettings: ko.Observable<boolean>;
freeTierExceedThroughputTooltip?: ko.Observable<string>;
freeTierExceedThroughputWarning?: ko.Observable<string>;
}
export class ThroughputInputViewModel extends WaitsForTemplateViewModel {
@@ -165,6 +167,10 @@ export class ThroughputInputViewModel extends WaitsForTemplateViewModel {
public overrideWithProvisionedThroughputSettings: ko.Observable<boolean>;
public isManualThroughputInputFieldRequired: ko.Computed<boolean>;
public isAutoscaleThroughputInputFieldRequired: ko.Computed<boolean>;
public freeTierExceedThroughputTooltip: ko.Observable<string>;
public freeTierExceedThroughputWarning: ko.Observable<string>;
public showFreeTierExceedThroughputTooltip: ko.Computed<boolean>;
public showFreeTierExceedThroughputWarning: ko.Computed<boolean>;
public constructor(options: ThroughputInputParams) {
super();
@@ -219,6 +225,16 @@ export class ThroughputInputViewModel extends WaitsForTemplateViewModel {
this.isAutoscaleThroughputInputFieldRequired = ko.pureComputed(
() => this.isEnabled() && this.isAutoPilotSelected()
);
this.freeTierExceedThroughputTooltip = options.freeTierExceedThroughputTooltip || ko.observable<string>();
this.freeTierExceedThroughputWarning = options.freeTierExceedThroughputWarning || ko.observable<string>();
this.showFreeTierExceedThroughputTooltip = ko.pureComputed<boolean>(
() => !!this.freeTierExceedThroughputTooltip() && this.value() > 400
);
this.showFreeTierExceedThroughputWarning = ko.pureComputed<boolean>(
() => !!this.freeTierExceedThroughputWarning() && this.value() > 400
);
}
public decreaseThroughput() {

View File

@@ -132,6 +132,14 @@
<a target="_blank" href="https://cosmos.azure.com/capacitycalculator/">capacity calculator</a></span
>
</p>
<div class="inputTooltip">
<span
data-bind="text: freeTierExceedThroughputTooltip, visible: showFreeTierExceedThroughputTooltip"
class="inputTooltipText"
></span>
</div>
<div data-bind="setTemplateReady: true">
<input
data-bind="
@@ -154,6 +162,11 @@
/>
</div>
<div class="freeTierInlineWarning" data-bind="visible: showFreeTierExceedThroughputWarning">
<span class="freeTierWarningIcon"><img src="/warning.svg" alt="Warning"/></span>
<span class="freeTierWarningMessage" data-bind="text: freeTierExceedThroughputWarning"></span>
</div>
<p data-bind="visible: costsVisible">
<span data-bind="html: requestUnitsUsageCost"></span>
</p>

View File

@@ -207,6 +207,7 @@ export default class Explorer {
public isCopyNotebookPaneEnabled: ko.Observable<boolean>;
public isHostedDataExplorerEnabled: ko.Computed<boolean>;
public isRightPanelV2Enabled: ko.Computed<boolean>;
public isMongoIndexingEnabled: ko.Observable<boolean>;
public canExceedMaximumValue: ko.Computed<boolean>;
public shouldShowShareDialogContents: ko.Observable<boolean>;
@@ -402,6 +403,7 @@ export default class Explorer {
this.isFeatureEnabled(Constants.Features.enableLinkInjection)
);
this.isGitHubPaneEnabled = ko.observable<boolean>(false);
this.isMongoIndexingEnabled = ko.observable<boolean>(false);
this.isPublishNotebookPaneEnabled = ko.observable<boolean>(false);
this.isCopyNotebookPaneEnabled = ko.observable<boolean>(false);
@@ -1260,6 +1262,16 @@ export default class Explorer {
$("#contextSwitchPrompt").dialog("open");
}
public displayConnectExplorerForm(): void {
$("#divExplorer").hide();
$("#connectExplorer").css("display", "flex");
}
public hideConnectExplorerForm(): void {
$("#connectExplorer").hide();
$("#divExplorer").show();
}
public isReadWriteToggled: () => boolean = (): boolean => {
return this.shareAccessToggleState() === ShareAccessToggleState.ReadWrite;
};
@@ -1833,7 +1845,7 @@ export default class Explorer {
if (inputs != null) {
// 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" && configContext.platform === Platform.Portal) {
if (process.env.NODE_ENV === "development") {
sessionStorage.setItem("portalDataExplorerInitMessage", JSON.stringify(inputs));
}
@@ -1868,6 +1880,16 @@ export default class Explorer {
subscriptionType: inputs.subscriptionType,
quotaId: inputs.quotaId
});
TelemetryProcessor.traceSuccess(
Action.LoadDatabaseAccount,
{
resourceId: this.databaseAccount && this.databaseAccount().id,
dataExplorerArea: Constants.Areas.ResourceTree,
databaseAccount: this.databaseAccount && this.databaseAccount()
},
inputs.loadDatabaseAccountTimestamp
);
this.isAccountReady(true);
}
}
@@ -1876,6 +1898,9 @@ export default class Explorer {
if (!flights) {
return;
}
if (flights.indexOf(Constants.Flights.MongoIndexing) !== -1) {
this.isMongoIndexingEnabled(true);
}
}
public findSelectedCollection(): ViewModels.Collection {
@@ -2994,4 +3019,25 @@ export default class Explorer {
})
);
}
public isFirstResourceCreated(): boolean {
const databases: ViewModels.Database[] = this.databases();
if (!databases || databases.length === 0) {
return false;
}
return databases.some(database => {
// user has created at least one collection
if (database.collections()?.length > 0) {
return true;
}
// user has created a database with shared throughput
if (database.offer()) {
return true;
}
// use has created an empty database without shared throughput
return false;
});
}
}

View File

@@ -1,4 +1,3 @@
import "./MeControlComponent.less";
import * as React from "react";
import { DefaultButton, BaseButton, IButtonProps } from "office-ui-fabric-react/lib/Button";
import { DirectionalHint, IContextualMenuProps } from "office-ui-fabric-react/lib/ContextualMenu";

View File

@@ -152,7 +152,8 @@
maxAutoPilotThroughputSet: sharedAutoPilotThroughput,
autoPilotUsageCost: autoPilotUsageCost,
canExceedMaximumValue: canExceedMaximumValue,
showAutoPilot: !isFreeTierAccount()
showAutoPilot: !isFreeTierAccount(),
freeTierExceedThroughputTooltip: freeTierExceedThroughputTooltip
}">
</throughput-input-autopilot-v3>
</div>
@@ -333,7 +334,8 @@
maxAutoPilotThroughputSet: autoPilotThroughput,
autoPilotUsageCost: autoPilotUsageCost,
canExceedMaximumValue: canExceedMaximumValue,
showAutoPilot: !isFixedStorageSelected()
showAutoPilot: !isFixedStorageSelected(),
freeTierExceedThroughputTooltip: freeTierExceedThroughputTooltip
}">
</throughput-input-autopilot-v3>
</div>

View File

@@ -74,7 +74,7 @@ describe("Add Collection Pane", () => {
explorer.databaseAccount(mockFreeTierDatabaseAccount);
const addCollectionPane = explorer.addCollectionPane as AddCollectionPane;
expect(addCollectionPane.isFreeTierAccount()).toBe(true);
expect(addCollectionPane.upsellMessage()).toContain("With free tier discount");
expect(addCollectionPane.upsellMessage()).toContain("With free tier");
expect(addCollectionPane.upsellAnchorUrl()).toBe(Constants.Urls.freeTierInformation);
expect(addCollectionPane.upsellAnchorText()).toBe("Learn more");
});

View File

@@ -89,9 +89,10 @@ export default class AddCollectionPane extends ContextualPaneBase {
public isSynapseLinkUpdating: ko.Computed<boolean>;
public canExceedMaximumValue: ko.PureComputed<boolean>;
public ruToolTipText: ko.Computed<string>;
public freeTierExceedThroughputTooltip: ko.Computed<string>;
public canConfigureThroughput: ko.PureComputed<boolean>;
public showUpsellMessage: ko.PureComputed<boolean>;
public shouldCreateMongoWildcardIndex: ko.Observable<boolean>;
public shouldCreateMongoWildcardIndex: ko.Computed<boolean>;
private _isSynapseLinkEnabled: ko.Computed<boolean>;
@@ -99,7 +100,6 @@ export default class AddCollectionPane extends ContextualPaneBase {
super(options);
this.ruToolTipText = ko.pureComputed(() => PricingUtils.getRuToolTipText());
this.canConfigureThroughput = ko.pureComputed(() => !this.container.isServerlessEnabled());
this.showUpsellMessage = ko.pureComputed(() => !this.container.isServerlessEnabled());
this.formWarnings = ko.observable<string>();
this.collectionId = ko.observable<string>();
this.databaseId = ko.observable<string>();
@@ -481,8 +481,20 @@ export default class AddCollectionPane extends ContextualPaneBase {
this.resetData();
});
this.freeTierExceedThroughputTooltip = ko.pureComputed<string>(() =>
this.isFreeTierAccount() && !this.container.isFirstResourceCreated()
? "The first 400 RU/s in this account are free. Billing will apply to any throughput beyond 400 RU/s."
: ""
);
this.upsellMessage = ko.pureComputed<string>(() => {
return PricingUtils.getUpsellMessage(this.container.serverId(), this.isFreeTierAccount());
return PricingUtils.getUpsellMessage(
this.container.serverId(),
this.isFreeTierAccount(),
this.container.isFirstResourceCreated(),
this.container.defaultExperience(),
true
);
});
this.upsellMessageAriaLabel = ko.pureComputed<string>(() => {
@@ -534,6 +546,23 @@ export default class AddCollectionPane extends ContextualPaneBase {
return isFreeTierAccount;
});
this.showUpsellMessage = ko.pureComputed(() => {
if (this.container.isServerlessEnabled()) {
return false;
}
if (
this.isFreeTierAccount() &&
!this.databaseCreateNew() &&
this.databaseHasSharedOffer() &&
!this.collectionWithThroughputInShared()
) {
return false;
}
return true;
});
this.showIndexingOptionsForSharedThroughput = ko.computed<boolean>(() => {
const newDatabaseWithSharedOffer = this.databaseCreateNew() && this.databaseCreateNewShared();
const existingDatabaseWithSharedOffer = !this.databaseCreateNew() && this.databaseHasSharedOffer();
@@ -625,7 +654,9 @@ export default class AddCollectionPane extends ContextualPaneBase {
});
});
this.shouldCreateMongoWildcardIndex = ko.observable(false);
this.shouldCreateMongoWildcardIndex = ko.computed(function() {
return this.container.isMongoIndexingEnabled();
}, this);
}
public getSharedThroughputDefault(): boolean {

View File

@@ -114,7 +114,8 @@
maxAutoPilotThroughputSet: maxAutoPilotThroughputSet,
autoPilotUsageCost: autoPilotUsageCost,
canExceedMaximumValue: canExceedMaximumValue,
showAutoPilot: !isFreeTierAccount()
showAutoPilot: !isFreeTierAccount(),
freeTierExceedThroughputTooltip: freeTierExceedThroughputTooltip
}">
</throughput-input-autopilot-v3>
<p data-bind="visible: canRequestSupport">

View File

@@ -77,7 +77,7 @@ describe("Add Database Pane", () => {
explorer.databaseAccount(mockFreeTierDatabaseAccount);
const addDatabasePane = explorer.addDatabasePane as AddDatabasePane;
expect(addDatabasePane.isFreeTierAccount()).toBe(true);
expect(addDatabasePane.upsellMessage()).toContain("With free tier discount");
expect(addDatabasePane.upsellMessage()).toContain("With free tier");
expect(addDatabasePane.upsellAnchorUrl()).toBe(Constants.Urls.freeTierInformation);
expect(addDatabasePane.upsellAnchorText()).toBe("Learn more");
});

View File

@@ -44,6 +44,7 @@ export default class AddDatabasePane extends ContextualPaneBase {
public autoPilotUsageCost: ko.Computed<string>;
public canExceedMaximumValue: ko.PureComputed<boolean>;
public ruToolTipText: ko.Computed<string>;
public freeTierExceedThroughputTooltip: ko.Computed<string>;
public isFreeTierAccount: ko.Computed<boolean>;
public canConfigureThroughput: ko.PureComputed<boolean>;
public showUpsellMessage: ko.PureComputed<boolean>;
@@ -54,7 +55,6 @@ export default class AddDatabasePane extends ContextualPaneBase {
this.databaseId = ko.observable<string>();
this.ruToolTipText = ko.pureComputed(() => PricingUtils.getRuToolTipText());
this.canConfigureThroughput = ko.pureComputed(() => !this.container.isServerlessEnabled());
this.showUpsellMessage = ko.pureComputed(() => !this.container.isServerlessEnabled());
this.canExceedMaximumValue = ko.pureComputed(() => this.container.canExceedMaximumValue());
@@ -182,6 +182,18 @@ export default class AddDatabasePane extends ContextualPaneBase {
return isFreeTierAccount;
});
this.showUpsellMessage = ko.pureComputed(() => {
if (this.container.isServerlessEnabled()) {
return false;
}
if (this.isFreeTierAccount()) {
return this.databaseCreateNewShared();
}
return true;
});
this.maxThroughputRUText = ko.pureComputed(() => {
return this.maxThroughputRU().toLocaleString();
});
@@ -219,8 +231,20 @@ export default class AddDatabasePane extends ContextualPaneBase {
this.resetData();
});
this.freeTierExceedThroughputTooltip = ko.pureComputed<string>(() =>
this.isFreeTierAccount() && !this.container.isFirstResourceCreated()
? "The first 400 RU/s in this account are free. Billing will apply to any throughput beyond 400 RU/s."
: ""
);
this.upsellMessage = ko.pureComputed<string>(() => {
return PricingUtils.getUpsellMessage(this.container.serverId(), this.isFreeTierAccount());
return PricingUtils.getUpsellMessage(
this.container.serverId(),
this.isFreeTierAccount(),
this.container.isFirstResourceCreated(),
this.container.defaultExperience(),
false
);
});
this.upsellMessageAriaLabel = ko.pureComputed<string>(() => {

View File

@@ -2,7 +2,7 @@ import * as ko from "knockout";
import * as Constants from "../../Common/Constants";
import * as DataModels from "../../Contracts/DataModels";
import * as ViewModels from "../../Contracts/ViewModels";
import { parseConnectionString } from "../../Platform/Hosted/Helpers/ConnectionStringParser";
import { ConnectionStringParser } from "../../Platform/Hosted/Helpers/ConnectionStringParser";
import { ContextualPaneBase } from "./ContextualPaneBase";
import { ConsoleDataType } from "../Menus/NotificationConsole/NotificationConsoleComponent";
import { DefaultExperienceUtility } from "../../Shared/DefaultExperienceUtility";
@@ -48,7 +48,9 @@ export class RenewAdHocAccessPane extends ContextualPaneBase {
};
private _shouldShowContextSwitchPrompt(): boolean {
const inputMetadata: DataModels.AccessInputMetadata = parseConnectionString(this.accessKey());
const inputMetadata: DataModels.AccessInputMetadata = ConnectionStringParser.parseConnectionString(
this.accessKey()
);
const apiKind: DataModels.ApiKind =
this.container && DefaultExperienceUtility.getApiKindFromDefaultExperience(this.container.defaultExperience());
const hasOpenedTabs: boolean =

View File

@@ -23,6 +23,19 @@
<div class="scaleDivison" aria-label="Scale" aria-controls="scaleRegion">
<span class="scaleSettingTitle">Scale</span>
</div>
<div class="freeTierInfoBanner" data-bind="visible: isFreeTierAccount">
<span class="freeTierInfoIcon"><img src="/info_color.svg" alt="Info"/></span>
<span class="freeTierInfoMessage"
>With free tier, you'll get the first 400 RU/s and 5 GB of storage in this account for free. To keep your
account free, keep the total RU/s across all resources in the account to 400 RU/s.
<a
href="https://docs.microsoft.com/en-us/azure/cosmos-db/understand-your-bill#billing-examples-with-free-tier-accounts"
target="_blank"
>
Learn more.</a
>
</span>
</div>
<div class="ssTextAllignment" id="scaleRegion">
<throughput-input-autopilot-v3
params="{
@@ -46,7 +59,8 @@
autoPilotUsageCost: autoPilotUsageCost,
canExceedMaximumValue: canExceedMaximumValue,
overrideWithAutoPilotSettings: overrideWithAutoPilotSettings,
overrideWithProvisionedThroughputSettings: overrideWithProvisionedThroughputSettings
overrideWithProvisionedThroughputSettings: overrideWithProvisionedThroughputSettings,
freeTierExceedThroughputWarning: freeTierExceedThroughputWarning
}"
>
</throughput-input-autopilot-v3>

View File

@@ -57,6 +57,7 @@ export default class DatabaseSettingsTab extends TabsBase implements ViewModels.
public canThroughputExceedMaximumValue: ko.Computed<boolean>;
public costsVisible: ko.Computed<boolean>;
public displayedError: ko.Observable<string>;
public isFreeTierAccount: ko.Computed<boolean>;
public isTemplateReady: ko.Observable<boolean>;
public minRUAnotationVisible: ko.Computed<boolean>;
public minRUs: ko.Observable<number>;
@@ -82,6 +83,7 @@ export default class DatabaseSettingsTab extends TabsBase implements ViewModels.
public throughputAutoPilotRadioId: string;
public throughputProvisionedRadioId: string;
public throughputModeRadioName: string;
public freeTierExceedThroughputWarning: ko.Computed<string>;
private _hasProvisioningTypeChanged: ko.Computed<boolean>;
private _wasAutopilotOriginallySet: ko.Observable<boolean>;
@@ -359,6 +361,17 @@ export default class DatabaseSettingsTab extends TabsBase implements ViewModels.
this.isTemplateReady = ko.observable<boolean>(false);
this.isFreeTierAccount = ko.computed<boolean>(() => {
const databaseAccount = this.container?.databaseAccount();
return databaseAccount?.properties?.enableFreeTier;
});
this.freeTierExceedThroughputWarning = ko.computed<string>(() =>
this.isFreeTierAccount()
? "Billing will apply if you provision more than 400 RU/s of manual throughput, or if the resource scales beyond 400 RU/s with autoscale."
: ""
);
this._buildCommandBarOptions();
}

View File

@@ -324,21 +324,6 @@ export default class QueryTab extends TabsBase implements ViewModels.WaitsForTem
queryResults.itemCount > 0 ? `${queryResults.firstItemIndex} - ${queryResults.lastItemIndex}` : `0 - 0`;
this.showingDocumentsDisplayText(resultsDisplay);
this.requestChargeDisplayText(`${queryResults.requestCharge} RUs`);
if (!this.queryResults() && !results) {
const errorMessage: string = JSON.stringify({
error: `Returned no results after query execution`,
accountName: this.collection && this.collection.container.databaseAccount(),
databaseName: this.collection && this.collection.databaseId,
collectionName: this.collection && this.collection.id(),
sqlQuery: this.sqlStatementToExecute(),
hasMoreResults: resultsMetadata.hasMoreResults,
itemCount: resultsMetadata.itemCount,
responseHeaders: queryResults && queryResults.headers
});
Logger.logError(errorMessage, "QueryTab");
}
this.queryResults(results);
TelemetryProcessor.traceSuccess(

1174
src/HostedExplorer.ts Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -1,140 +0,0 @@
import { useBoolean } from "@uifabric/react-hooks";
import { initializeIcons } from "office-ui-fabric-react";
import * as React from "react";
import { render } from "react-dom";
import ChevronRight from "../images/chevron-right.svg";
import "../less/hostedexplorer.less";
import { AuthType } from "./AuthType";
import { ConnectExplorer } from "./Platform/Hosted/Components/ConnectExplorer";
import { DatabaseAccount } from "./Contracts/DataModels";
import { DirectoryPickerPanel } from "./Platform/Hosted/Components/DirectoryPickerPanel";
import { AccountSwitcher } from "./Platform/Hosted/Components/AccountSwitcher";
import "./Explorer/Menus/NavBar/MeControlComponent.less";
import { usePortalAccessToken } from "./hooks/usePortalAccessToken";
import { MeControl } from "./Platform/Hosted/Components/MeControl";
import "./Platform/Hosted/ConnectScreen.less";
import "./Shared/appInsights";
import { SignInButton } from "./Platform/Hosted/Components/SignInButton";
import { useAADAuth } from "./hooks/useAADAuth";
import { FeedbackCommandButton } from "./Platform/Hosted/Components/FeedbackCommandButton";
import { HostedExplorerChildFrame } from "./HostedExplorerChildFrame";
import { extractMasterKeyfromConnectionString } from "./Platform/Hosted/HostedUtils";
initializeIcons();
const App: React.FunctionComponent = () => {
// For handling encrypted portal tokens sent via query paramter
const params = new URLSearchParams(window.location.search);
const [encryptedToken, setEncryptedToken] = React.useState<string>(params && params.get("key"));
const encryptedTokenMetadata = usePortalAccessToken(encryptedToken);
// For showing/hiding panel
const [isOpen, { setTrue: openPanel, setFalse: dismissPanel }] = useBoolean(false);
const { isLoggedIn, armToken, graphToken, account, tenantId, logout, login, switchTenant } = useAADAuth();
const [databaseAccount, setDatabaseAccount] = React.useState<DatabaseAccount>();
const [authType, setAuthType] = React.useState<AuthType>(encryptedToken ? AuthType.EncryptedToken : undefined);
const [connectionString, setConnectionString] = React.useState<string>();
const ref = React.useRef<HTMLIFrameElement>();
React.useEffect(() => {
// If ref.current is undefined no iframe has been rendered
if (ref.current) {
// In hosted mode, we can set global properties directly on the child iframe.
// This is not possible in the portal where the iframes have different origins
const frameWindow = ref.current.contentWindow as HostedExplorerChildFrame;
// AAD authenticated uses ALWAYS using AAD authType
if (isLoggedIn) {
frameWindow.hostedConfig = {
authType: AuthType.AAD,
databaseAccount,
authorizationToken: armToken
};
} else if (authType === AuthType.EncryptedToken) {
frameWindow.hostedConfig = {
authType: AuthType.EncryptedToken,
encryptedToken,
encryptedTokenMetadata
};
} else if (authType === AuthType.ConnectionString) {
frameWindow.hostedConfig = {
authType: AuthType.ConnectionString,
encryptedToken,
encryptedTokenMetadata,
masterKey: extractMasterKeyfromConnectionString(connectionString)
};
} else if (authType === AuthType.ResourceToken) {
frameWindow.hostedConfig = {
authType: AuthType.ResourceToken,
resourceToken: connectionString
};
}
}
}, [ref, encryptedToken, encryptedTokenMetadata, isLoggedIn, databaseAccount]);
const showAccount = (isLoggedIn && databaseAccount) || (encryptedTokenMetadata && encryptedTokenMetadata);
return (
<>
<header>
<div className="items" role="menubar">
<div className="cosmosDBTitle">
<span
className="title"
onClick={() => window.open("https://portal.azure.com", "_blank")}
tabIndex={0}
title="Go to Azure Portal"
>
Microsoft Azure
</span>
<span className="accontSplitter" /> <span className="serviceTitle">Cosmos DB</span>
{(isLoggedIn || encryptedTokenMetadata?.accountName) && (
<img className="chevronRight" src={ChevronRight} alt="account separator" />
)}
{isLoggedIn && (
<span className="accountSwitchComponentContainer">
<AccountSwitcher armToken={armToken} setDatabaseAccount={setDatabaseAccount} />
</span>
)}
{!isLoggedIn && encryptedTokenMetadata?.accountName && (
<span className="accountSwitchComponentContainer">
<span className="accountNameHeader">{encryptedTokenMetadata?.accountName}</span>
</span>
)}
</div>
<FeedbackCommandButton />
<div className="meControl">
{isLoggedIn ? (
<MeControl {...{ graphToken, openPanel, logout, account }} />
) : (
<SignInButton {...{ login }} />
)}
</div>
</div>
</header>
{showAccount && (
// Ideally we would import and render data explorer like any other React component, however
// because it still has a significant amount of Knockout code, this would lead to memory leaks.
// Knockout does not have a way to tear down all of its binding and listeners with a single method.
// It's possible this can be changed once all knockout code has been removed.
<iframe
// Setting key is needed so React will re-render this element on any account change
key={databaseAccount?.id || encryptedTokenMetadata?.accountName}
ref={ref}
id="explorerMenu"
name="explorer"
className="iframe"
title="explorer"
src="explorer.html?v=1.0.1&platform=Hosted"
></iframe>
)}
{!isLoggedIn && !encryptedTokenMetadata && (
<ConnectExplorer {...{ login, setEncryptedToken, setAuthType, connectionString, setConnectionString }} />
)}
{isLoggedIn && <DirectoryPickerPanel {...{ isOpen, dismissPanel, armToken, tenantId, switchTenant }} />}
</>
);
};
render(<App />, document.getElementById("App"));

View File

@@ -1,32 +0,0 @@
import { AuthType } from "./AuthType";
import { AccessInputMetadata, DatabaseAccount } from "./Contracts/DataModels";
export interface HostedExplorerChildFrame extends Window {
hostedConfig: AAD | ConnectionString | EncryptedToken | ResourceToken;
}
interface AAD {
authType: AuthType.AAD;
databaseAccount: DatabaseAccount;
authorizationToken: string;
}
interface ConnectionString {
authType: AuthType.ConnectionString;
// Connection string uses still use encrypted token for Cassandra/Mongo APIs as they us the portal backend proxy
encryptedToken: string;
encryptedTokenMetadata: AccessInputMetadata;
// Master key is currently only used by Graph API. All other APIs use encrypted tokens and proxy with connection string
masterKey?: string;
}
interface EncryptedToken {
authType: AuthType.EncryptedToken;
encryptedToken: string;
encryptedTokenMetadata: AccessInputMetadata;
}
interface ResourceToken {
authType: AuthType.ResourceToken;
resourceToken: string;
}

View File

@@ -44,6 +44,7 @@ import "./Libs/jquery";
import "bootstrap/dist/js/npm";
import "../externals/jquery.typeahead.min.js";
import "../externals/jquery-ui.min.js";
import "../externals/adal.js";
import "promise-polyfill/src/polyfill";
import "abort-controller/polyfill";
import "whatwg-fetch";
@@ -55,11 +56,19 @@ import "url-polyfill/url-polyfill.min";
initializeIcons();
import * as ko from "knockout";
import * as TelemetryProcessor from "./Shared/Telemetry/TelemetryProcessor";
import { Action, ActionModifiers } from "./Shared/Telemetry/TelemetryConstants";
import { BindingHandlersRegisterer } from "./Bindings/BindingHandlersRegisterer";
import * as Emulator from "./Platform/Emulator/Main";
import Hosted from "./Platform/Hosted/Main";
import * as Portal from "./Platform/Portal/Main";
import { AuthType } from "./AuthType";
import { initializeIcons } from "office-ui-fabric-react/lib/Icons";
import { applyExplorerBindings } from "./applyExplorerBindings";
import { configContext, initializeConfiguration, Platform } from "./ConfigContext";
import { initializeConfiguration, Platform } from "./ConfigContext";
import Explorer from "./Explorer/Explorer";
import React, { useEffect } from "react";
import ReactDOM from "react-dom";
@@ -69,195 +78,49 @@ import hdeConnectImage from "../images/HdeConnectCosmosDB.svg";
import refreshImg from "../images/refresh-cosmos.svg";
import arrowLeftImg from "../images/imgarrowlefticon.svg";
import { KOCommentEnd, KOCommentIfStart } from "./koComment";
import { updateUserContext } from "./UserContext";
import AuthHeadersUtil from "./Platform/Hosted/Authorization";
import { CollectionCreation } from "./Shared/Constants";
import { extractFeatures } from "./Platform/Hosted/extractFeatures";
import { emulatorAccount } from "./Platform/Emulator/emulatorAccount";
import { HostedExplorerChildFrame } from "./HostedExplorerChildFrame";
import {
getDatabaseAccountKindFromExperience,
getDatabaseAccountPropertiesFromMetadata
} from "./Platform/Hosted/HostedUtils";
import { DefaultExperienceUtility } from "./Shared/DefaultExperienceUtility";
import { parseResourceTokenConnectionString } from "./Platform/Hosted/Helpers/ResourceTokenUtils";
import { AccountKind, DefaultAccountExperience } from "./Common/Constants";
// 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];
// TODO: Encapsulate and reuse all global variables as environment variables
window.authType = AuthType.AAD;
const App: React.FunctionComponent = () => {
useEffect(() => {
initializeConfiguration().then(config => {
let explorer: Explorer;
if (config.platform === Platform.Hosted) {
const win = (window as unknown) as HostedExplorerChildFrame;
explorer = new Explorer();
if (win.hostedConfig.authType === AuthType.EncryptedToken) {
// TODO: Remove window.authType
window.authType = AuthType.EncryptedToken;
// Impossible to tell if this is a try cosmos sub using an encrypted token
explorer.isTryCosmosDBSubscription(false);
updateUserContext({
accessToken: encodeURIComponent(win.hostedConfig.encryptedToken)
});
const apiExperience: string = DefaultExperienceUtility.getDefaultExperienceFromApiKind(
win.hostedConfig.encryptedTokenMetadata.apiKind
try {
Hosted.initializeExplorer().then(
(explorer: Explorer) => {
applyExplorerBindings(explorer);
Hosted.configureTokenValidationDisplayPrompt(explorer);
},
(error: unknown) => {
try {
const uninitializedExplorer: Explorer = Hosted.getUninitializedExplorerForGuestAccess();
window.dataExplorer = uninitializedExplorer;
ko.applyBindings(uninitializedExplorer);
BindingHandlersRegisterer.registerBindingHandlers();
if (window.authType !== AuthType.AAD) {
uninitializedExplorer.isRefreshingExplorer(false);
uninitializedExplorer.displayConnectExplorerForm();
}
} catch (e) {
console.log(e);
}
console.error(error);
}
);
explorer.initDataExplorerWithFrameInputs({
databaseAccount: {
id: "",
// id: Main._databaseAccountId,
name: win.hostedConfig.encryptedTokenMetadata.accountName,
kind: getDatabaseAccountKindFromExperience(apiExperience),
properties: getDatabaseAccountPropertiesFromMetadata(win.hostedConfig.encryptedTokenMetadata),
tags: []
},
subscriptionId: undefined,
resourceGroup: undefined,
masterKey: undefined,
hasWriteAccess: true, // TODO: we should embed this information in the token ideally
authorizationToken: undefined,
features: extractFeatures(),
csmEndpoint: undefined,
dnsSuffix: undefined,
serverId: AuthHeadersUtil.serverId,
extensionEndpoint: configContext.BACKEND_ENDPOINT,
subscriptionType: CollectionCreation.DefaultSubscriptionType,
quotaId: undefined,
addCollectionDefaultFlight: explorer.flight(),
isTryCosmosDBSubscription: explorer.isTryCosmosDBSubscription()
});
explorer.isAccountReady(true);
} else if (win.hostedConfig.authType === AuthType.ResourceToken) {
window.authType = AuthType.ResourceToken;
// Resource tokens can only be used with SQL API
const apiExperience: string = DefaultAccountExperience.DocumentDB;
const parsedResourceToken = parseResourceTokenConnectionString(win.hostedConfig.resourceToken);
updateUserContext({
resourceToken: parsedResourceToken.resourceToken
});
return explorer.initDataExplorerWithFrameInputs({
databaseAccount: {
id: "",
name: parsedResourceToken.accountEndpoint,
kind: AccountKind.GlobalDocumentDB,
properties: { documentEndpoint: parsedResourceToken.accountEndpoint },
tags: { defaultExperience: apiExperience }
},
subscriptionId: undefined,
resourceGroup: undefined,
masterKey: undefined,
hasWriteAccess: true, // TODO: we should embed this information in the token ideally
authorizationToken: undefined,
features: extractFeatures(),
csmEndpoint: undefined,
dnsSuffix: undefined,
serverId: AuthHeadersUtil.serverId,
extensionEndpoint: configContext.BACKEND_ENDPOINT,
subscriptionType: CollectionCreation.DefaultSubscriptionType,
quotaId: undefined,
addCollectionDefaultFlight: explorer.flight(),
isTryCosmosDBSubscription: explorer.isTryCosmosDBSubscription(),
isAuthWithresourceToken: true
});
} else if (win.hostedConfig.authType === AuthType.ConnectionString) {
// For legacy reasons lots of code expects a connection string login to look and act like an encrypted token login
window.authType = AuthType.EncryptedToken;
// Impossible to tell if this is a try cosmos sub using an encrypted token
explorer.isTryCosmosDBSubscription(false);
updateUserContext({
accessToken: encodeURIComponent(win.hostedConfig.encryptedToken)
});
const apiExperience: string = DefaultExperienceUtility.getDefaultExperienceFromApiKind(
win.hostedConfig.encryptedTokenMetadata.apiKind
);
explorer.initDataExplorerWithFrameInputs({
databaseAccount: {
id: "",
// id: Main._databaseAccountId,
name: win.hostedConfig.encryptedTokenMetadata.accountName,
kind: getDatabaseAccountKindFromExperience(apiExperience),
properties: getDatabaseAccountPropertiesFromMetadata(win.hostedConfig.encryptedTokenMetadata),
tags: []
},
subscriptionId: undefined,
resourceGroup: undefined,
masterKey: win.hostedConfig.masterKey,
hasWriteAccess: true, // TODO: we should embed this information in the token ideally
authorizationToken: undefined,
features: extractFeatures(),
csmEndpoint: undefined,
dnsSuffix: undefined,
serverId: AuthHeadersUtil.serverId,
extensionEndpoint: configContext.BACKEND_ENDPOINT,
subscriptionType: CollectionCreation.DefaultSubscriptionType,
quotaId: undefined,
addCollectionDefaultFlight: explorer.flight(),
isTryCosmosDBSubscription: explorer.isTryCosmosDBSubscription()
});
explorer.isAccountReady(true);
} else if (win.hostedConfig.authType === AuthType.AAD) {
window.authType = AuthType.AAD;
const account = win.hostedConfig.databaseAccount;
const accountResourceId = account.id;
const subscriptionId = accountResourceId && accountResourceId.split("subscriptions/")[1].split("/")[0];
const resourceGroup = accountResourceId && accountResourceId.split("resourceGroups/")[1].split("/")[0];
updateUserContext({
databaseAccount: win.hostedConfig.databaseAccount
});
explorer.initDataExplorerWithFrameInputs({
databaseAccount: account,
subscriptionId,
resourceGroup,
masterKey: "",
hasWriteAccess: true, //TODO: 425017 - support read access
authorizationToken: `Bearer ${win.hostedConfig.authorizationToken}`,
features: extractFeatures(),
csmEndpoint: undefined,
dnsSuffix: undefined,
serverId: AuthHeadersUtil.serverId,
extensionEndpoint: configContext.BACKEND_ENDPOINT,
subscriptionType: CollectionCreation.DefaultSubscriptionType,
quotaId: undefined,
addCollectionDefaultFlight: explorer.flight(),
isTryCosmosDBSubscription: explorer.isTryCosmosDBSubscription()
});
explorer.isAccountReady(true);
} catch (e) {
console.log(e);
}
} else if (config.platform === Platform.Emulator) {
window.authType = AuthType.MasterKey;
explorer = new Explorer();
explorer.databaseAccount(emulatorAccount);
explorer.isAccountReady(true);
const explorer = Emulator.initializeExplorer();
applyExplorerBindings(explorer);
} else if (config.platform === Platform.Portal) {
explorer = new Explorer();
// In development mode, try to load the iframe message from session storage.
// This allows webpack hot reload to funciton properly
if (process.env.NODE_ENV === "development") {
const initMessage = sessionStorage.getItem("portalDataExplorerInitMessage");
if (initMessage) {
const message = JSON.parse(initMessage);
console.warn("Loaded cached portal iframe message from session storage");
console.dir(message);
explorer.initDataExplorerWithFrameInputs(message);
}
}
window.addEventListener("message", explorer.handleMessage.bind(explorer), false);
TelemetryProcessor.trace(Action.InitializeDataExplorer, ActionModifiers.Open, {});
const explorer = Portal.initializeExplorer();
TelemetryProcessor.trace(Action.InitializeDataExplorer, ActionModifiers.IFrameReady, {});
applyExplorerBindings(explorer);
}
applyExplorerBindings(explorer);
});
}, []);
@@ -314,7 +177,7 @@ const App: React.FunctionComponent = () => {
aria-label="Share url link"
className="shareLink"
type="text"
read-only={true}
read-only
data-bind="value: shareAccessUrl"
/>
<span

View File

@@ -0,0 +1,25 @@
import Explorer from "../../Explorer/Explorer";
import { AccountKind, DefaultAccountExperience, TagNames } from "../../Common/Constants";
export function initializeExplorer(): Explorer {
const explorer = new Explorer();
explorer.databaseAccount({
name: "",
id: "",
location: "",
type: "",
kind: AccountKind.DocumentDB,
tags: {
[TagNames.defaultExperience]: DefaultAccountExperience.DocumentDB
},
properties: {
documentEndpoint: "",
tableEndpoint: "",
gremlinEndpoint: "",
cassandraEndpoint: ""
}
});
explorer.isAccountReady(true);
return explorer;
}

View File

@@ -1,18 +0,0 @@
import { AccountKind, DefaultAccountExperience, TagNames } from "../../Common/Constants";
export const emulatorAccount = {
name: "",
id: "",
location: "",
type: "",
kind: AccountKind.DocumentDB,
tags: {
[TagNames.defaultExperience]: DefaultAccountExperience.DocumentDB
},
properties: {
documentEndpoint: "",
tableEndpoint: "",
gremlinEndpoint: "",
cassandraEndpoint: ""
}
};

View File

@@ -0,0 +1,180 @@
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[];
}

View File

@@ -1,3 +1,5 @@
import "expose-loader?AuthenticationContext!../../../externals/adal";
import Q from "q";
import * as Constants from "../../Common/Constants";
import * as DataModels from "../../Contracts/DataModels";
@@ -12,6 +14,29 @@ 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`;
@@ -93,6 +118,154 @@ 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;

View File

@@ -1,139 +0,0 @@
// TODO: Renable this rule for the file or turn it off everywhere
/* eslint-disable react/display-name */
import { StyleConstants } from "../../../Common/Constants";
import * as React from "react";
import { DefaultButton, IButtonStyles } from "office-ui-fabric-react/lib/Button";
import { IContextualMenuProps } from "office-ui-fabric-react/lib/ContextualMenu";
import { Dropdown, IDropdownProps } from "office-ui-fabric-react/lib/Dropdown";
import { useSubscriptions } from "../../../hooks/useSubscriptions";
import { useDatabaseAccounts } from "../../../hooks/useDatabaseAccounts";
import { DatabaseAccount } from "../../../Contracts/DataModels";
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"
}
};
const cachedSubscriptionId = localStorage.getItem("cachedSubscriptionId");
const cachedDatabaseAccountName = localStorage.getItem("cachedDatabaseAccountName");
interface Props {
armToken: string;
setDatabaseAccount: (account: DatabaseAccount) => void;
}
export const AccountSwitcher: React.FunctionComponent<Props> = ({ armToken, setDatabaseAccount }: Props) => {
const subscriptions = useSubscriptions(armToken);
const [selectedSubscriptionId, setSelectedSubscriptionId] = React.useState<string>(cachedSubscriptionId);
const accounts = useDatabaseAccounts(selectedSubscriptionId, armToken);
const [selectedAccountName, setSelectedAccoutName] = React.useState<string>(cachedDatabaseAccountName);
React.useEffect(() => {
if (accounts && selectedAccountName) {
const account = accounts.find(account => account.name === selectedAccountName);
// Only set a new account if one is found
if (account) {
setDatabaseAccount(account);
}
}
}, [accounts, selectedAccountName]);
const menuProps: IContextualMenuProps = {
directionalHintFixed: true,
className: "accountSwitchContextualMenu",
items: [
{
key: "switchSubscription",
onRender: () => {
const dropdownProps: IDropdownProps = {
label: "Subscription",
className: "accountSwitchSubscriptionDropdown",
options: subscriptions.map(sub => {
return {
key: sub.subscriptionId,
text: sub.displayName,
data: sub
};
}),
onChange: (event, option) => {
const subscriptionId = String(option.key);
setSelectedSubscriptionId(subscriptionId);
localStorage.setItem("cachedSubscriptionId", subscriptionId);
},
defaultSelectedKey: selectedSubscriptionId,
placeholder: "Select subscription from list",
styles: {
callout: "accountSwitchSubscriptionDropdownMenu"
}
};
return <Dropdown {...dropdownProps} />;
}
},
{
key: "switchAccount",
onRender: (item, dismissMenu) => {
const dropdownProps: IDropdownProps = {
label: "Cosmos DB Account Name",
className: "accountSwitchAccountDropdown",
options: accounts.map(account => ({
key: account.name,
text: account.name,
data: account
})),
onChange: (event, option) => {
const accountName = String(option.key);
setSelectedAccoutName(String(option.key));
localStorage.setItem("cachedDatabaseAccountName", accountName);
dismissMenu();
},
defaultSelectedKey: selectedAccountName,
placeholder: "No Cosmos DB accounts found",
styles: {
callout: "accountSwitchAccountDropdownMenu"
}
};
return <Dropdown {...dropdownProps} />;
}
}
]
};
return (
<DefaultButton
text={selectedAccountName || "Select Database Account"}
menuProps={menuProps}
styles={buttonStyles}
className="accountSwitchButton"
id="accountSwitchButton"
/>
);
};

View File

@@ -1,95 +0,0 @@
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());
console.log(result.readWrite || result.read);
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>
);
};

View File

@@ -1,39 +0,0 @@
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={async (event, option) => {
switchTenant(option.key);
dismissPanel();
}}
/>
</Panel>
);
};

View File

@@ -1,22 +0,0 @@
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>
);
};

View File

@@ -1,68 +0,0 @@
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>
);
};

View File

@@ -1,21 +0,0 @@
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" }
}}
/>
);
};

View File

@@ -1,101 +0,0 @@
.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;
}

View File

@@ -1,12 +1,12 @@
import * as DataModels from "../../../Contracts/DataModels";
import { parseConnectionString } from "./ConnectionStringParser";
import { ConnectionStringParser } from "./ConnectionStringParser";
describe("ConnectionStringParser", () => {
const mockAccountName: string = "Test";
const mockMasterKey: string = "some-key";
it("should parse a valid sql account connection string", () => {
const metadata = parseConnectionString(
const metadata: DataModels.AccessInputMetadata = ConnectionStringParser.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 = parseConnectionString(
const metadata: DataModels.AccessInputMetadata = ConnectionStringParser.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 = parseConnectionString(
const metadata: DataModels.AccessInputMetadata = ConnectionStringParser.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 = parseConnectionString(
const metadata: DataModels.AccessInputMetadata = ConnectionStringParser.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 = parseConnectionString(
const metadata: DataModels.AccessInputMetadata = ConnectionStringParser.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 = parseConnectionString(
const metadata: DataModels.AccessInputMetadata = ConnectionStringParser.parseConnectionString(
`AccountEndpoint=${mockAccountName}.cassandra.cosmosdb.azure.com;AccountKey=${mockMasterKey};`
);
@@ -60,13 +60,15 @@ describe("ConnectionStringParser", () => {
});
it("should fail to parse an invalid connection string", () => {
const metadata = parseConnectionString("some-rogue-connection-string");
const metadata: DataModels.AccessInputMetadata = ConnectionStringParser.parseConnectionString(
"some-rogue-connection-string"
);
expect(metadata).toBe(undefined);
});
it("should fail to parse an empty connection string", () => {
const metadata = parseConnectionString("");
const metadata: DataModels.AccessInputMetadata = ConnectionStringParser.parseConnectionString("");
expect(metadata).toBe(undefined);
});

View File

@@ -1,48 +1,59 @@
import * as Constants from "../../../Common/Constants";
import { AccessInputMetadata, ApiKind } from "../../../Contracts/DataModels";
import * as DataModels from "../../../Contracts/DataModels";
export function parseConnectionString(connectionString: string): AccessInputMetadata {
if (connectionString) {
try {
const accessInput = {} as AccessInputMetadata;
const connectionStringParts = connectionString.split(";");
export class ConnectionStringParser {
public static parseConnectionString(connectionString: string): DataModels.AccessInputMetadata | undefined {
if (!!connectionString) {
try {
const accessInput: DataModels.AccessInputMetadata = {} as DataModels.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 = 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;
connectionStringParts.forEach((connectionStringPart: string) => {
const sqlMatchResult = connectionStringPart.match(Constants.EndpointsRegex.sql);
const mongoMatchResult = connectionStringPart.match(Constants.EndpointsRegex.mongo);
const mongoComputeMatchResult = connectionStringPart.match(Constants.EndpointsRegex.mongoCompute);
const tableMatchResult = connectionStringPart.match(Constants.EndpointsRegex.table);
if (sqlMatchResult && sqlMatchResult.length > 1) {
accessInput.accountName = sqlMatchResult[1];
accessInput.apiKind = DataModels.ApiKind.SQL;
} else if (mongoMatchResult && mongoMatchResult.length > 2) {
accessInput.accountName = mongoMatchResult[2];
accessInput.apiKind = DataModels.ApiKind.MongoDB;
} else if (mongoComputeMatchResult && mongoComputeMatchResult.length > 2) {
accessInput.accountName = mongoComputeMatchResult[2];
accessInput.apiKind = DataModels.ApiKind.MongoDBCompute;
} else if (
Constants.EndpointsRegex.cassandra &&
Constants.EndpointsRegex.cassandra.some(regex => RegExp(regex).test(connectionStringPart))
) {
Constants.EndpointsRegex.cassandra.forEach(regex => {
if (RegExp(regex).test(connectionStringPart)) {
const connectionMatch = connectionStringPart.match(regex);
if (connectionMatch && connectionMatch.length > 1) {
accessInput.accountName = connectionMatch[1];
accessInput.apiKind = DataModels.ApiKind.Cassandra;
}
}
});
} else if (tableMatchResult && tableMatchResult.length > 1) {
accessInput.accountName = tableMatchResult[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;
}
});
if (Object.keys(accessInput).length === 0) {
return accessInput;
} catch (error) {
return undefined;
}
return accessInput;
} catch (error) {
return undefined;
}
}
return undefined;
return undefined;
}
}

View File

@@ -1,43 +0,0 @@
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");
}

View File

@@ -1,9 +1,9 @@
import { AccessInputMetadata } from "../../Contracts/DataModels";
import { getDatabaseAccountPropertiesFromMetadata } from "./HostedUtils";
import { HostedUtils } from "./HostedUtils";
describe("getDatabaseAccountPropertiesFromMetadata", () => {
it("should only return an object with the mongoEndpoint key if the apiKind is mongoCompute (5)", () => {
const mongoComputeAccount: AccessInputMetadata = {
let 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(getDatabaseAccountPropertiesFromMetadata(mongoComputeAccount)).toEqual({
expect(HostedUtils.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)", () => {
const mongoAccount: AccessInputMetadata = {
let 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(getDatabaseAccountPropertiesFromMetadata(mongoAccount)).toEqual({
expect(HostedUtils.getDatabaseAccountPropertiesFromMetadata(mongoAccount)).toEqual({
documentEndpoint: mongoAccount.documentEndpoint
});
});

View File

@@ -3,49 +3,33 @@ import * as DataModels from "../../Contracts/DataModels";
import { AccessInputMetadata } from "../../Contracts/DataModels";
import { DefaultExperienceUtility } from "../../Shared/DefaultExperienceUtility";
export function getDatabaseAccountPropertiesFromMetadata(metadata: AccessInputMetadata): unknown {
let properties = { documentEndpoint: metadata.documentEndpoint };
const apiExperience: string = DefaultExperienceUtility.getDefaultExperienceFromApiKind(metadata.apiKind);
export class HostedUtils {
static getDatabaseAccountPropertiesFromMetadata(metadata: AccessInputMetadata): any {
let properties = { documentEndpoint: metadata.documentEndpoint };
const apiExperience: string = DefaultExperienceUtility.getDefaultExperienceFromApiKind(metadata.apiKind);
if (apiExperience === Constants.DefaultAccountExperience.Cassandra) {
properties = Object.assign(properties, {
cassandraEndpoint: metadata.apiEndpoint,
capabilities: [{ name: Constants.CapabilityNames.EnableCassandra }]
});
} 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) {
if (apiExperience === Constants.DefaultAccountExperience.Cassandra) {
properties = Object.assign(properties, {
mongoEndpoint: metadata.mongoEndpoint
cassandraEndpoint: metadata.apiEndpoint,
capabilities: [{ name: Constants.CapabilityNames.EnableCassandra }]
});
} 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 === Constants.DefaultAccountExperience.MongoDB) {
return Constants.AccountKind.MongoDB;
}
if (apiExperience === Constants.DefaultAccountExperience.ApiForMongoDB) {
return Constants.AccountKind.MongoDB;
}
return Constants.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;
}

598
src/Platform/Hosted/Main.ts Normal file
View File

@@ -0,0 +1,598 @@
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);
}
}

View File

@@ -1,10 +1,24 @@
import { isResourceTokenConnectionString, parseResourceTokenConnectionString } from "./ResourceTokenUtils";
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"
});
});
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 = parseResourceTokenConnectionString(connectionString);
const properties = Main.parseResourceTokenConnectionString(connectionString);
expect(properties).toEqual({
accountEndpoint: "fakeEndpoint",
@@ -18,7 +32,7 @@ describe("parseResourceTokenConnectionString", () => {
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 = parseResourceTokenConnectionString(connectionString);
const properties = Main.parseResourceTokenConnectionString(connectionString);
expect(properties).toEqual({
accountEndpoint: "fakeEndpoint",
@@ -29,16 +43,3 @@ describe("parseResourceTokenConnectionString", () => {
});
});
});
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);
});
});

View File

@@ -1,17 +0,0 @@
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"
});
});
});

View File

@@ -1,14 +0,0 @@
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;
}

View File

@@ -0,0 +1,23 @@
import "../../Explorer/Tables/DataTable/DataTableBindingManager";
import Explorer from "../../Explorer/Explorer";
import { handleMessage } from "../../Controls/Heatmap/Heatmap";
export function initializeExplorer(): Explorer {
const explorer = new Explorer();
// In development mode, try to load the iframe message from session storage.
// This allows webpack hot reload to funciton properly
if (process.env.NODE_ENV === "development") {
const initMessage = sessionStorage.getItem("portalDataExplorerInitMessage");
if (initMessage) {
const message = JSON.parse(initMessage);
console.warn("Loaded cached portal iframe message from session storage");
console.dir(message);
explorer.initDataExplorerWithFrameInputs(message);
}
}
window.addEventListener("message", explorer.handleMessage.bind(explorer), false);
return explorer;
}

View File

@@ -4,7 +4,7 @@ import * as DataModels from "../Contracts/DataModels";
import { DefaultAccountExperienceType } from "../DefaultAccountExperienceType";
export class DefaultExperienceUtility {
public static getDefaultExperienceFromDatabaseAccount(databaseAccount: DataModels.DatabaseAccount): string {
public static getDefaultExperienceFromDatabaseAccount(databaseAccount: DataModels.DatabaseAccount): string | null {
if (!databaseAccount) {
return null;
}
@@ -81,11 +81,9 @@ export class DefaultExperienceUtility {
private static _getDefaultExperience(kind: string, capabilities: DataModels.Capability[]): string {
const defaultDefaultExperience: string = Constants.DefaultAccountExperience.DocumentDB;
const defaultExperienceFromKind: string = DefaultExperienceUtility._getDefaultExperienceFromAccountKind(kind);
const defaultExperienceFromCapabilities: string = DefaultExperienceUtility._getDefaultExperienceFromAccountCapabilities(
capabilities
);
const defaultExperienceFromKind: string = DefaultExperienceUtility._getDefaultExperienceFromAccountKind(kind) || "";
const defaultExperienceFromCapabilities: string =
DefaultExperienceUtility._getDefaultExperienceFromAccountCapabilities(capabilities) || "";
if (!!defaultExperienceFromKind) {
return defaultExperienceFromKind;
}
@@ -97,7 +95,7 @@ export class DefaultExperienceUtility {
return defaultDefaultExperience;
}
private static _getDefaultExperienceFromAccountKind(kind: string): string {
private static _getDefaultExperienceFromAccountKind(kind: string): string | null {
if (!kind) {
return null;
}
@@ -113,7 +111,7 @@ export class DefaultExperienceUtility {
return null;
}
private static _getDefaultExperienceFromAccountCapabilities(capabilities: DataModels.Capability[]): string {
private static _getDefaultExperienceFromAccountCapabilities(capabilities: DataModels.Capability[]): string | null {
if (!capabilities) {
return null;
}

View File

@@ -1,9 +1,11 @@
import { AuthType } from "../AuthType";
import * as Constants from "../Common/Constants";
import * as ViewModels from "../Contracts/ViewModels";
import AuthHeadersUtil from "../Platform/Hosted/Authorization";
import { AuthType } from "../AuthType";
import * as Logger from "../Common/Logger";
import { configContext, Platform } from "../ConfigContext";
import * as ViewModels from "../Contracts/ViewModels";
import { userContext } from "../UserContext";
import { getErrorMessage } from "../Common/ErrorHandlingUtils";
export function getAuthorizationHeader(): ViewModels.AuthorizationTokenHeaderMetadata {
if (window.authType === AuthType.EncryptedToken) {
@@ -19,6 +21,19 @@ export function getAuthorizationHeader(): ViewModels.AuthorizationTokenHeaderMet
}
}
export async function getArcadiaAuthToken(
arcadiaEndpoint: string = configContext.ARCADIA_ENDPOINT,
tenantId?: string
): Promise<string> {
try {
const token = await AuthHeadersUtil.getAccessToken(arcadiaEndpoint, tenantId);
return token;
} catch (error) {
Logger.logError(getErrorMessage(error), "AuthorizationUtils/getArcadiaAuthToken");
throw error;
}
}
export function decryptJWTToken(token: string) {
if (!token) {
Logger.logError("Cannot decrypt token: No JWT token found", "AuthorizationUtils/decryptJWTToken");

View File

@@ -1,5 +1,6 @@
import * as AutoPilotUtils from "../Utils/AutoPilotUtils";
import * as Constants from "../Shared/Constants";
import { DefaultAccountExperienceType } from "../DefaultAccountExperienceType";
interface ComputeRUUsagePriceHourlyArgs {
serverId: string;
@@ -256,9 +257,19 @@ export function getEstimatedSpendAcknowledgeString(
)} - ${currencySign}${calculateEstimateNumber(monthlyPrice)} monthly cost for the throughput above.`;
}
export function getUpsellMessage(serverId = "default", isFreeTier = false): string {
export function getUpsellMessage(
serverId = "default",
isFreeTier = false,
isFirstResourceCreated = false,
defaultExperience: string,
isCollection: boolean
): string {
if (isFreeTier) {
return "With free tier discount, you'll get the first 400 RU/s and 5 GB of storage in this account for free. Charges will apply if your resource throughput exceeds 400 RU/s.";
const collectionName = getCollectionName(defaultExperience);
const resourceType = isCollection ? collectionName : "database";
return isFirstResourceCreated
? `The free tier discount of 400 RU/s has already been applied to a database or ${collectionName} in this account. Billing will apply to this ${resourceType} after it is created.`
: `With free tier, you'll get the first 400 RU/s and 5 GB of storage in this account for free. Billing will apply if you provision more than 400 RU/s of manual throughput, or if the ${resourceType} scales beyond 400 RU/s with autoscale.`;
} else {
let price: number = Constants.OfferPricing.MonthlyPricing.default.Standard.StartingPrice;
@@ -269,3 +280,19 @@ export function getUpsellMessage(serverId = "default", isFreeTier = false): stri
return `Start at ${getCurrencySign(serverId)}${price}/mo per database, multiple containers included`;
}
}
function getCollectionName(defaultExperience: string): string {
switch (defaultExperience) {
case DefaultAccountExperienceType.DocumentDB:
return "container";
case DefaultAccountExperienceType.MongoDB:
return "collection";
case DefaultAccountExperienceType.Table:
case DefaultAccountExperienceType.Cassandra:
return "table";
case DefaultAccountExperienceType.Graph:
return "graph";
default:
throw Error("unknown API type");
}
}

View File

@@ -0,0 +1,24 @@
import AuthHeadersUtil from "../Platform/Hosted/Authorization";
import * as UserUtils from "./UserUtils";
describe("UserUtils", () => {
it("getFullName works in regular data explorer (inside portal)", () => {
const user: AuthenticationContext.UserInfo = {
userName: "userName",
profile: {
name: "name"
}
};
AuthHeadersUtil.getCachedUser = jest.fn().mockReturnValue(user);
expect(UserUtils.getFullName()).toBe("name");
});
it("getFullName works in fullscreen data explorer (outside portal)", () => {
jest.mock("./AuthorizationUtils", () => {
(): { name: string } => ({ name: "name" });
});
expect(UserUtils.getFullName()).toBe("name");
});
});

View File

@@ -1,8 +1,17 @@
import AuthHeadersUtil from "../Platform/Hosted/Authorization";
import { decryptJWTToken } from "./AuthorizationUtils";
import { userContext } from "../UserContext";
export function getFullName(): string {
const authToken = userContext.authorizationToken;
const props = decryptJWTToken(authToken);
return props.name;
let fullName: string;
const user = AuthHeadersUtil.getCachedUser();
if (user) {
fullName = user.profile.name;
} else {
const authToken = userContext.authorizationToken;
const props = decryptJWTToken(authToken);
fullName = props.name;
}
return fullName;
}

View File

@@ -5,12 +5,12 @@ import Explorer from "./Explorer/Explorer";
export const applyExplorerBindings = (explorer: Explorer) => {
if (!!explorer) {
window.dataExplorer = explorer;
BindingHandlersRegisterer.registerBindingHandlers();
ko.applyBindings(explorer);
// This message should ideally be sent immediately after explorer has been initialized for optimal data explorer load times.
// TODO: Send another message to describe that the bindings have been applied, and handle message transfers accordingly in the portal
sendMessage("ready");
window.dataExplorer = explorer;
BindingHandlersRegisterer.registerBindingHandlers();
ko.applyBindings(explorer);
$("#divExplorer").show();
}
};

View File

@@ -1,96 +0,0 @@
import * as React from "react";
import { useBoolean } from "@uifabric/react-hooks";
import { UserAgentApplication, Account } from "msal";
const msal = new UserAgentApplication({
cache: {
cacheLocation: "localStorage"
},
auth: {
authority: "https://login.microsoftonline.com/common",
clientId: "203f1145-856a-4232-83d4-a43568fba23d",
redirectUri: "https://dataexplorer-dev.azurewebsites.net" // TODO! This should only be set in development
}
});
const cachedAccount = msal.getAllAccounts()?.[0];
const cachedTenantId = localStorage.getItem("cachedTenantId");
interface ReturnType {
isLoggedIn: boolean;
graphToken: string;
armToken: string;
login: () => void;
logout: () => void;
tenantId: string;
account: Account;
switchTenant: (tenantId: string) => void;
}
export function useAADAuth(): ReturnType {
const [isLoggedIn, { setTrue: setLoggedIn, setFalse: setLoggedOut }] = useBoolean(
Boolean(cachedAccount && cachedTenantId) || false
);
const [account, setAccount] = React.useState<Account>(cachedAccount);
const [tenantId, setTenantId] = React.useState<string>(cachedTenantId);
const [graphToken, setGraphToken] = React.useState<string>();
const [armToken, setArmToken] = React.useState<string>();
const login = React.useCallback(async () => {
const response = await msal.loginPopup();
setLoggedIn();
setAccount(response.account);
setTenantId(response.tenantId);
localStorage.setItem("cachedTenantId", response.tenantId);
}, []);
const logout = React.useCallback(() => {
setLoggedOut();
localStorage.removeItem("cachedTenantId");
msal.logout();
}, []);
const switchTenant = React.useCallback(
async id => {
const response = await msal.loginPopup({
authority: `https://login.microsoftonline.com/${id}`
});
setTenantId(response.tenantId);
setAccount(response.account);
},
[account, tenantId]
);
React.useEffect(() => {
if (account && tenantId) {
Promise.all([
msal.acquireTokenSilent({
// There is a bug in MSALv1 that requires us to refresh the token. Their internal cache is not respecting authority
forceRefresh: true,
authority: `https://login.microsoftonline.com/${tenantId}`,
scopes: ["https://graph.windows.net//.default"]
}),
msal.acquireTokenSilent({
// There is a bug in MSALv1 that requires us to refresh the token. Their internal cache is not respecting authority
forceRefresh: true,
authority: `https://login.microsoftonline.com/${tenantId}`,
scopes: ["https://management.azure.com//.default"]
})
]).then(([graphTokenResponse, armTokenResponse]) => {
setGraphToken(graphTokenResponse.accessToken);
setArmToken(armTokenResponse.accessToken);
});
}
}, [account, tenantId]);
return {
account,
tenantId,
isLoggedIn,
graphToken,
armToken,
login,
logout,
switchTenant
};
}

View File

@@ -1,50 +0,0 @@
import { useEffect, useState } from "react";
import { DatabaseAccount } from "../Contracts/DataModels";
interface AccountListResult {
nextLink: string;
value: DatabaseAccount[];
}
export async function fetchDatabaseAccounts(
subscriptionIds: string[],
accessToken: string
): Promise<DatabaseAccount[]> {
const headers = new Headers();
const bearer = `Bearer ${accessToken}`;
headers.append("Authorization", bearer);
if (!subscriptionIds || !subscriptionIds.length) {
return Promise.reject("No subscription passed in");
}
let accounts: Array<DatabaseAccount> = [];
const subscriptionFilter = "subscriptionId eq '" + subscriptionIds.join("' or subscriptionId eq '") + "'";
const urlFilter = `$filter=(${subscriptionFilter}) and (resourceType eq 'microsoft.documentdb/databaseaccounts')`;
let nextLink = `https://management.azure.com/resources?api-version=2020-01-01&${urlFilter}`;
while (nextLink) {
const response: Response = await fetch(nextLink, { headers });
const result: AccountListResult =
response.status === 204 || response.status === 304 ? undefined : await response.json();
if (!response.ok) {
throw result;
}
nextLink = result.nextLink;
accounts = [...accounts, ...result.value];
}
return accounts;
}
export function useDatabaseAccounts(subscriptionId: string, armToken: string): DatabaseAccount[] {
const [state, setState] = useState<DatabaseAccount[]>();
useEffect(() => {
if (subscriptionId && armToken) {
fetchDatabaseAccounts([subscriptionId], armToken).then(response => setState(response));
}
}, [subscriptionId, armToken]);
return state || [];
}

View File

@@ -1,40 +0,0 @@
import { useEffect, useState } from "react";
import { Tenant } from "../Contracts/DataModels";
interface TenantListResult {
nextLink: string;
value: Tenant[];
}
export async function fetchDirectories(accessToken: string): Promise<Tenant[]> {
const headers = new Headers();
const bearer = `Bearer ${accessToken}`;
headers.append("Authorization", bearer);
let tenents: Array<Tenant> = [];
let nextLink = `https://management.azure.com/tenants?api-version=2020-01-01`;
while (nextLink) {
const response = await fetch(nextLink, { headers });
const result: TenantListResult =
response.status === 204 || response.status === 304 ? undefined : await response.json();
if (!response.ok) {
throw result;
}
nextLink = result.nextLink;
tenents = [...tenents, ...result.value];
}
return tenents;
}
export function useDirectories(armToken: string): Tenant[] {
const [state, setState] = useState<Tenant[]>();
useEffect(() => {
if (armToken) {
fetchDirectories(armToken).then(response => setState(response));
}
}, [armToken]);
return state || [];
}

View File

@@ -1,29 +0,0 @@
import { useEffect, useState } from "react";
export async function fetchPhoto(accessToken: string): Promise<Blob | void> {
const headers = new Headers();
const bearer = `Bearer ${accessToken}`;
headers.append("Authorization", bearer);
headers.append("Content-Type", "image/jpg");
const options = {
method: "GET",
headers: headers
};
return fetch("https://graph.windows.net/me/thumbnailPhoto?api-version=1.6", options)
.then(response => response.blob())
.catch(error => console.log(error));
}
export function useGraphPhoto(graphToken: string): string {
const [photo, setPhoto] = useState<string>();
useEffect(() => {
if (graphToken) {
fetchPhoto(graphToken).then(response => setPhoto(URL.createObjectURL(response)));
}
}, [graphToken]);
return photo;
}

View File

@@ -1,36 +0,0 @@
import { useEffect, useState } from "react";
import { ApiEndpoints } from "../Common/Constants";
import { configContext } from "../ConfigContext";
import { AccessInputMetadata } from "../Contracts/DataModels";
const url = `${configContext.BACKEND_ENDPOINT}${ApiEndpoints.guestRuntimeProxy}/accessinputmetadata?_=1609359229955`;
export async function fetchAccessData(portalToken: string): Promise<AccessInputMetadata> {
const headers = new Headers();
// Portal encrypted token API quirk: The token header must be URL encoded
headers.append("x-ms-encrypted-auth-token", encodeURIComponent(portalToken));
const options = {
method: "GET",
headers: headers
};
return (
fetch(url, options)
.then(response => response.json())
// Portal encrypted token API quirk: The response is double JSON encoded
.then(json => JSON.parse(json))
.catch(error => console.log(error))
);
}
export function usePortalAccessToken(token: string): AccessInputMetadata {
const [state, setState] = useState<AccessInputMetadata>();
useEffect(() => {
if (token) {
fetchAccessData(token).then(response => setState(response));
}
}, [token]);
return state;
}

View File

@@ -1,43 +0,0 @@
import { useEffect, useState } from "react";
import { Subscription } from "../Contracts/DataModels";
interface SubscriptionListResult {
nextLink: string;
value: Subscription[];
}
export async function fetchSubscriptions(accessToken: string): Promise<Subscription[]> {
const headers = new Headers();
const bearer = `Bearer ${accessToken}`;
headers.append("Authorization", bearer);
let subscriptions: Array<Subscription> = [];
let nextLink = `https://management.azure.com/subscriptions?api-version=2020-01-01`;
while (nextLink) {
const response = await fetch(nextLink, { headers });
const result: SubscriptionListResult =
response.status === 204 || response.status === 304 ? undefined : 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;
}
export function useSubscriptions(armToken: string): Subscription[] {
const [state, setState] = useState<Subscription[]>();
useEffect(() => {
if (armToken) {
fetchSubscriptions(armToken).then(response => setState(response));
}
}, [armToken]);
return state || [];
}

View File

@@ -7,6 +7,59 @@
</head>
<body>
<div id="App"></div>
<header>
<div class="items" role="menubar">
<div class="cosmosDBTitle">
<span
class="title"
data-bind="click: openAzurePortal, event: { keypress: onOpenAzurePortalKeyPress }"
tabindex="0"
title="Go to Azure Portal"
>Microsoft Azure</span
>
<span class="accontSplitter"></span> <span class="serviceTitle">Cosmos DB</span>
<img
class="chevronRight"
src="/chevron-right.svg"
alt="account separator"
data-bind="visible: isAccountActive"
/>
<span
class="accountSwitchComponentContainer"
data-bind="react: accountSwitchComponentAdapter, visible: isAccountActive"
></span>
</div>
<div class="feedbackConnectSettingIcons" data-bind="react: controlBarComponentAdapter"></div>
<div class="meControl" data-bind="react: meControlComponentAdapter"></div>
</div>
</header>
<!-- TODO display after introducing multiple account access -->
<nav class="fixedleftpane" style="display: none;">
<div class="fixedLeftPaneIcons"><img src="/Hamburger.svg" alt="Expand" /></div>
<div class="fixedLeftPaneIcons"><img src="/Connect.svg" alt="Connect to an account" /></div>
<div
class="fixedLeftPaneIcons"
data-bind="click: explorer_click, css:{ topSelected: navigationSelection() === 'explorer' }"
>
<img src="/HostedExplorer.svg" alt="Open Data Explorer" />
</div>
</nav>
<switch-directory-pane params="{data: switchDirectoryPane}"></switch-directory-pane>
<!-- TODO generate version number dynamically -->
<iframe
id="explorerMenu"
name="explorer"
class="iframe"
title="explorer"
src="explorer.html?v=1.0.1&platform=Hosted"
data-bind="visible: navigationSelection() === 'explorer'"
>
</iframe>
<div data-bind="react: firewallWarningComponentAdapter"></div>
<div data-bind="react: dialogComponentAdapter"></div>
</body>
</html>

View File

@@ -9,13 +9,13 @@ export async function login(connectionString: string): Promise<Frame> {
return page.mainFrame();
}
// log in with connection string
await page.waitFor("div > p.switchConnectTypeText", { visible: true });
await page.click("div > p.switchConnectTypeText");
const connStr = connectionString;
await page.type("input[class='inputToken']", connStr);
await page.click("input[value='Connect']");
const handle = await page.waitForSelector("iframe");
const frame = await handle.contentFrame();
await frame.waitFor("div > p.switchConnectTypeText", { visible: true });
await frame.click("div > p.switchConnectTypeText");
const connStr = connectionString;
await frame.type("input[class='inputToken']", connStr);
await frame.click("input[value='Connect']");
return frame;
}

View File

@@ -11,7 +11,7 @@
"allowSyntheticDefaultImports": true,
"downlevelIteration": true,
"module": "esnext",
"target": "es2017",
"target": "es5",
"lib": ["es5", "es6", "dom", "webworker.importscripts"],
"jsx": "react",
"moduleResolution": "node",

View File

@@ -12,7 +12,8 @@
"./src/Bindings/ReactBindingHandler.ts",
"./src/Common/ArrayHashMap.ts",
"./src/Common/Constants.ts",
"./src/Common/DeleteFeedback.ts",
"./src/Common/DeleteFeedback.ts",
"./src/Common/DocumentUtility.ts",
"./src/Common/EnvironmentUtility.ts",
"./src/Common/HashMap.ts",
"./src/Common/HeadersUtility.ts",
@@ -21,7 +22,8 @@
"./src/Common/MongoUtility.ts",
"./src/Common/ObjectCache.ts",
"./src/Common/ThemeUtility.ts",
"./src/Common/UrlUtility.ts",
"./src/Common/UrlUtility.ts",
"./src/Common/Splitter.ts",
"./src/ConfigContext.ts",
"./src/Contracts/ActionContracts.ts",
"./src/Contracts/DataModels.ts",
@@ -60,6 +62,8 @@
"./src/GitHub/GitHubConnector.ts",
"./src/Index.ts",
"./src/NotebookWorkspaceManager/NotebookWorkspaceResourceProviderMockClients.ts",
"./src/Platform/Hosted/Helpers/ConnectionStringParser.ts",
"./src/Platform/Hosted/HostedUtils.ts",
"./src/ReactDevTools.ts",
"./src/ResourceProvider/IResourceProviderClient.ts",
"./src/Shared/Constants.ts",

View File

@@ -78,7 +78,7 @@ const ModulesRule = {
loader: "babel-loader",
options: {
cacheDirectory: ".cache/babel",
presets: [["@babel/preset-env", { targets: { chrome: "80" }, useBuiltIns: false }]]
presets: [["@babel/preset-env", { targets: { ie: "11" }, useBuiltIns: false }]]
}
}
],
@@ -132,8 +132,8 @@ module.exports = function(env = {}, argv = {}) {
}),
new HtmlWebpackPlugin({
filename: "index.html",
template: "src/hostedExplorer.html",
chunks: ["hostedExplorer"]
template: "src/index.html",
chunks: ["index"]
}),
new HtmlWebpackPlugin({
filename: "hostedExplorer.html",
@@ -182,7 +182,7 @@ module.exports = function(env = {}, argv = {}) {
main: "./src/Main.tsx",
index: "./src/Index.ts",
quickstart: "./src/quickstart.ts",
hostedExplorer: "./src/HostedExplorer.tsx",
hostedExplorer: "./src/HostedExplorer.ts",
testExplorer: "./test/notebooks/testExplorer/TestExplorer.ts",
heatmap: "./src/Controls/Heatmap/Heatmap.ts",
terminal: "./src/Terminal/index.ts",