зеркало из
1
0
Форкнуть 0
* The start of AAD (merge to feature branch) (#528)

* Ying/aad (#2)

IPC login/logout and token get 
Add an auth preference selection view

* update requestScopes

* add unit tests

* hub list and sub list

* add filter to sub and hub list

* update tests and address comments
This commit is contained in:
YingXue 2022-09-12 12:41:13 -07:00 коммит произвёл GitHub
Родитель d392a7af9b
Коммит 1e6e6b14e5
Не найден ключ, соответствующий данной подписи
Идентификатор ключа GPG: 4AEE18F83AFDEB23
108 изменённых файлов: 3366 добавлений и 649 удалений

Просмотреть файл

@ -3,7 +3,7 @@
CI Pipeline [![Build Status](https://dev.azure.com/azure/azure-iot-explorer/_apis/build/status/Azure%20IoT%20Explorer%20CI%20Pipeline?branchName=main)](https://dev.azure.com/azure/azure-iot-explorer/_build/latest?definitionId=31&branchName=main)
Release Pipeline [![Build Status](https://msazure.visualstudio.com/One/_apis/build/status/Custom/Azure_IOT/Portal/Azure.azure-iot-explorer?branchName=features%2FRBAC)](https://msazure.visualstudio.com/One/_build/latest?definitionId=118522&branchName=features%2FRBAC)
Release Pipeline [![Build Status](https://dev.azure.com/azureiotdevxp/azure-iot-explorer/_apis/build/status/official/PROD%20-%20Release%20-%20Azure%20IoT%20Explorer?branchName=main)](https://dev.azure.com/azureiotdevxp/azure-iot-explorer/_build/latest?definitionId=95&branchName=main)
## Table of Contents
@ -40,7 +40,10 @@ If you'd like to package the app yourself, please refer to the [FAQ](https://git
### Configure an IoT Hub connection
- Upon opening the application, add the connection string of your IoT hub. You can add multiple strings, view, update or delete them anytime by returning to Home.
- Upon opening the application, choose an authentication method and connect to an Azure IoT hub.
- If you picked **Connect via IoT Hub connection string**, you can add multiple strings, view, update or delete them anytime by returning to Home.
- If you picked **Connect via Azure Active Directory**, you will be redirected to login in through AAD, from where you will be able to pick a subscription, and then pick an IoT hub.
- You can switch back and forth between these two authentication methods.
<img src="doc/screenRecords/login.gif" alt="login" width="800"/>

420
package-lock.json сгенерированный
Просмотреть файл

@ -1,15 +1,16 @@
{
"name": "azure-iot-explorer",
"version": "0.14.14",
"version": "0.14.15",
"lockfileVersion": 2,
"requires": true,
"packages": {
"": {
"name": "azure-iot-explorer",
"version": "0.14.12",
"version": "0.14.15",
"license": "MIT",
"dependencies": {
"@azure/event-hubs": "1.0.7",
"@azure/msal-node": "1.3.0",
"@fluentui/react": "8.20.2",
"@microsoft/applicationinsights-web": "2.8.4",
"azure-iot-common": "1.12.14",
@ -295,6 +296,17 @@
"node": ">=8.0.0"
}
},
"node_modules/@azure/identity/node_modules/@azure/msal-node": {
"version": "1.0.0-beta.6",
"resolved": "https://registry.npmjs.org/@azure/msal-node/-/msal-node-1.0.0-beta.6.tgz",
"integrity": "sha512-ZQI11Uz1j0HJohb9JZLRD8z0moVcPks1AFW4Q/Gcl67+QvH4aKEJti7fjCcipEEZYb/qzLSO8U6IZgPYytsiJQ==",
"dependencies": {
"@azure/msal-common": "^4.0.0",
"axios": "^0.21.1",
"jsonwebtoken": "^8.5.1",
"uuid": "^8.3.0"
}
},
"node_modules/@azure/identity/node_modules/@opentelemetry/api": {
"version": "0.10.2",
"resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-0.10.2.tgz",
@ -421,14 +433,17 @@
"integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w=="
},
"node_modules/@azure/msal-node": {
"version": "1.0.0-beta.6",
"resolved": "https://registry.npmjs.org/@azure/msal-node/-/msal-node-1.0.0-beta.6.tgz",
"integrity": "sha512-ZQI11Uz1j0HJohb9JZLRD8z0moVcPks1AFW4Q/Gcl67+QvH4aKEJti7fjCcipEEZYb/qzLSO8U6IZgPYytsiJQ==",
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/@azure/msal-node/-/msal-node-1.3.0.tgz",
"integrity": "sha512-BM5S5sMB6N0aPux4l85NnRNO/5/G+w3oT+JtLbMDBsc/aUxLVYoWMmxVECrYzlQRm5QZzFWRo04Rv5AnAF7z2g==",
"dependencies": {
"@azure/msal-common": "^4.0.0",
"@azure/msal-common": "^4.5.0",
"axios": "^0.21.1",
"jsonwebtoken": "^8.5.1",
"uuid": "^8.3.0"
},
"engines": {
"node": "10 || 12 || 14 || 16"
}
},
"node_modules/@azure/msal-node/node_modules/uuid": {
@ -2605,17 +2620,6 @@
"integrity": "sha512-Gj7cI7z+98M282Tqmp2K5EIsoouUEzbBJhQQzDE3jSIRk6r9gsz0oUokqIUR4u1R3dMHo0pDHM7sNOHyhulypw==",
"dev": true
},
"node_modules/@types/plist": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/@types/plist/-/plist-3.0.2.tgz",
"integrity": "sha512-ULqvZNGMv0zRFvqn8/4LSPtnmN4MfhlPNtJCTpKuIIxGVGZ2rYWzFXrvEBoh9CVyqSE7D6YFRJ1hydLHI6kbWw==",
"dev": true,
"optional": true,
"dependencies": {
"@types/node": "*",
"xmlbuilder": ">=11.0.1"
}
},
"node_modules/@types/prettier": {
"version": "2.6.3",
"resolved": "https://registry.npmjs.org/@types/prettier/-/prettier-2.6.3.tgz",
@ -2792,13 +2796,6 @@
"@types/node": "*"
}
},
"node_modules/@types/verror": {
"version": "1.10.5",
"resolved": "https://registry.npmjs.org/@types/verror/-/verror-1.10.5.tgz",
"integrity": "sha512-9UjMCHK5GPgQRoNbqdLIAvAy0EInuiqbW0PBMtVP6B5B2HQJlvoJHM+KodPZMEjOa5VkSc+5LH7xy+cUzQdmHw==",
"dev": true,
"optional": true
},
"node_modules/@types/ws": {
"version": "8.5.3",
"resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.5.3.tgz",
@ -3779,16 +3776,6 @@
"node": ">=0.10.0"
}
},
"node_modules/astral-regex": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/astral-regex/-/astral-regex-2.0.0.tgz",
"integrity": "sha512-Z7tMw1ytTXt5jqMcOP+OQteU1VuNK9Y02uuJtKQ1Sv69jXQKKg5cibLwGJow8yzZP+eAc18EmLGPal0bp36rvQ==",
"dev": true,
"optional": true,
"engines": {
"node": ">=8"
}
},
"node_modules/async": {
"version": "3.2.3",
"resolved": "https://registry.npmjs.org/async/-/async-3.2.3.tgz",
@ -5389,78 +5376,6 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/cli-truncate": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/cli-truncate/-/cli-truncate-2.1.0.tgz",
"integrity": "sha512-n8fOixwDD6b/ObinzTrp1ZKFzbgvKZvuz/TvejnLn1aQfC6r52XEx85FmuC+3HI+JM7coBRXUvNqEU2PHVrHpg==",
"dev": true,
"optional": true,
"dependencies": {
"slice-ansi": "^3.0.0",
"string-width": "^4.2.0"
},
"engines": {
"node": ">=8"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/cli-truncate/node_modules/ansi-regex": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
"dev": true,
"optional": true,
"engines": {
"node": ">=8"
}
},
"node_modules/cli-truncate/node_modules/emoji-regex": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
"dev": true,
"optional": true
},
"node_modules/cli-truncate/node_modules/is-fullwidth-code-point": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
"integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
"dev": true,
"optional": true,
"engines": {
"node": ">=8"
}
},
"node_modules/cli-truncate/node_modules/string-width": {
"version": "4.2.3",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
"dev": true,
"optional": true,
"dependencies": {
"emoji-regex": "^8.0.0",
"is-fullwidth-code-point": "^3.0.0",
"strip-ansi": "^6.0.1"
},
"engines": {
"node": ">=8"
}
},
"node_modules/cli-truncate/node_modules/strip-ansi": {
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
"dev": true,
"optional": true,
"dependencies": {
"ansi-regex": "^5.0.1"
},
"engines": {
"node": ">=8"
}
},
"node_modules/cliui": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/cliui/-/cliui-5.0.0.tgz",
@ -6012,16 +5927,6 @@
"node": ">= 0.10"
}
},
"node_modules/crc": {
"version": "3.8.0",
"resolved": "https://registry.npmjs.org/crc/-/crc-3.8.0.tgz",
"integrity": "sha512-iX3mfgcTMIq3ZKLIsVFAbv7+Mc10kxabAGQb8HvjA1o3T1PIYprbakQ65d3I+2HGHt6nSKkM9PYjgoJO2KcFBQ==",
"dev": true,
"optional": true,
"dependencies": {
"buffer": "^5.1.0"
}
},
"node_modules/create-ecdh": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/create-ecdh/-/create-ecdh-4.0.4.tgz",
@ -6855,33 +6760,6 @@
"node": ">= 10.0.0"
}
},
"node_modules/dmg-license": {
"version": "1.0.11",
"resolved": "https://registry.npmjs.org/dmg-license/-/dmg-license-1.0.11.tgz",
"integrity": "sha512-ZdzmqwKmECOWJpqefloC5OJy1+WZBBse5+MR88z9g9Zn4VY+WYUkAyojmhzJckH5YbbZGcYIuGAkY5/Ys5OM2Q==",
"deprecated": "Disk image license agreements are deprecated by Apple and will probably be removed in a future macOS release. Discussion at: https://github.com/argv-minus-one/dmg-license/issues/11",
"dev": true,
"optional": true,
"os": [
"darwin"
],
"dependencies": {
"@types/plist": "^3.0.1",
"@types/verror": "^1.10.3",
"ajv": "^6.10.0",
"crc": "^3.8.0",
"iconv-corefoundation": "^1.1.7",
"plist": "^3.0.4",
"smart-buffer": "^4.0.2",
"verror": "^1.10.0"
},
"bin": {
"dmg-license": "bin/dmg-license.js"
},
"engines": {
"node": ">=8"
}
},
"node_modules/dns-equal": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/dns-equal/-/dns-equal-1.0.0.tgz",
@ -8631,20 +8509,6 @@
"integrity": "sha1-FQStJSMVjKpA20onh8sBQRmU6k8=",
"dev": true
},
"node_modules/fsevents": {
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
"dev": true,
"hasInstallScript": true,
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/function-bind": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.1.tgz",
@ -9611,30 +9475,6 @@
"@babel/runtime": "^7.12.0"
}
},
"node_modules/iconv-corefoundation": {
"version": "1.1.7",
"resolved": "https://registry.npmjs.org/iconv-corefoundation/-/iconv-corefoundation-1.1.7.tgz",
"integrity": "sha512-T10qvkw0zz4wnm560lOEg0PovVqUXuOFhhHAkixw8/sycy7TJt7v/RrkEKEQnAw2viPSJu6iAkErxnzR0g8PpQ==",
"dev": true,
"optional": true,
"os": [
"darwin"
],
"dependencies": {
"cli-truncate": "^2.1.0",
"node-addon-api": "^1.6.3"
},
"engines": {
"node": "^8.11.2 || >=10"
}
},
"node_modules/iconv-corefoundation/node_modules/node-addon-api": {
"version": "1.7.2",
"resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-1.7.2.tgz",
"integrity": "sha512-ibPK3iA+vaY1eEjESkQkM0BbCqFOaZMiXRTtdB0u7b4djtY6JnsjvPdUHVMg6xQt3B8fpTTWHI9A+ADjM9frzg==",
"dev": true,
"optional": true
},
"node_modules/iconv-lite": {
"version": "0.4.23",
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.23.tgz",
@ -16779,42 +16619,6 @@
"node": ">=8"
}
},
"node_modules/slice-ansi": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-3.0.0.tgz",
"integrity": "sha512-pSyv7bSTC7ig9Dcgbw9AuRNUb5k5V6oDudjZoMBSr13qpLBG7tB+zgCkARjq7xIUgdz5P1Qe8u+rSGdouOOIyQ==",
"dev": true,
"optional": true,
"dependencies": {
"ansi-styles": "^4.0.0",
"astral-regex": "^2.0.0",
"is-fullwidth-code-point": "^3.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/slice-ansi/node_modules/is-fullwidth-code-point": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
"integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
"dev": true,
"optional": true,
"engines": {
"node": ">=8"
}
},
"node_modules/smart-buffer": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/smart-buffer/-/smart-buffer-4.2.0.tgz",
"integrity": "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==",
"dev": true,
"optional": true,
"engines": {
"node": ">= 6.0.0",
"npm": ">= 3.0.0"
}
},
"node_modules/smooth-dnd": {
"version": "0.12.0",
"resolved": "https://registry.npmjs.org/smooth-dnd/-/smooth-dnd-0.12.0.tgz",
@ -20573,6 +20377,17 @@
"tslib": "^2.0.0"
}
},
"@azure/msal-node": {
"version": "1.0.0-beta.6",
"resolved": "https://registry.npmjs.org/@azure/msal-node/-/msal-node-1.0.0-beta.6.tgz",
"integrity": "sha512-ZQI11Uz1j0HJohb9JZLRD8z0moVcPks1AFW4Q/Gcl67+QvH4aKEJti7fjCcipEEZYb/qzLSO8U6IZgPYytsiJQ==",
"requires": {
"@azure/msal-common": "^4.0.0",
"axios": "^0.21.1",
"jsonwebtoken": "^8.5.1",
"uuid": "^8.3.0"
}
},
"@opentelemetry/api": {
"version": "0.10.2",
"resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-0.10.2.tgz",
@ -20673,11 +20488,11 @@
}
},
"@azure/msal-node": {
"version": "1.0.0-beta.6",
"resolved": "https://registry.npmjs.org/@azure/msal-node/-/msal-node-1.0.0-beta.6.tgz",
"integrity": "sha512-ZQI11Uz1j0HJohb9JZLRD8z0moVcPks1AFW4Q/Gcl67+QvH4aKEJti7fjCcipEEZYb/qzLSO8U6IZgPYytsiJQ==",
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/@azure/msal-node/-/msal-node-1.3.0.tgz",
"integrity": "sha512-BM5S5sMB6N0aPux4l85NnRNO/5/G+w3oT+JtLbMDBsc/aUxLVYoWMmxVECrYzlQRm5QZzFWRo04Rv5AnAF7z2g==",
"requires": {
"@azure/msal-common": "^4.0.0",
"@azure/msal-common": "^4.5.0",
"axios": "^0.21.1",
"jsonwebtoken": "^8.5.1",
"uuid": "^8.3.0"
@ -22485,17 +22300,6 @@
"integrity": "sha512-Gj7cI7z+98M282Tqmp2K5EIsoouUEzbBJhQQzDE3jSIRk6r9gsz0oUokqIUR4u1R3dMHo0pDHM7sNOHyhulypw==",
"dev": true
},
"@types/plist": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/@types/plist/-/plist-3.0.2.tgz",
"integrity": "sha512-ULqvZNGMv0zRFvqn8/4LSPtnmN4MfhlPNtJCTpKuIIxGVGZ2rYWzFXrvEBoh9CVyqSE7D6YFRJ1hydLHI6kbWw==",
"dev": true,
"optional": true,
"requires": {
"@types/node": "*",
"xmlbuilder": ">=11.0.1"
}
},
"@types/prettier": {
"version": "2.6.3",
"resolved": "https://registry.npmjs.org/@types/prettier/-/prettier-2.6.3.tgz",
@ -22678,13 +22482,6 @@
"@types/node": "*"
}
},
"@types/verror": {
"version": "1.10.5",
"resolved": "https://registry.npmjs.org/@types/verror/-/verror-1.10.5.tgz",
"integrity": "sha512-9UjMCHK5GPgQRoNbqdLIAvAy0EInuiqbW0PBMtVP6B5B2HQJlvoJHM+KodPZMEjOa5VkSc+5LH7xy+cUzQdmHw==",
"dev": true,
"optional": true
},
"@types/ws": {
"version": "8.5.3",
"resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.5.3.tgz",
@ -23481,13 +23278,6 @@
"integrity": "sha512-Q+JC7Whu8HhmTdBph/Tq59IoRtoy6KAm5zzPv00WdujX82lbAL8K7WVjne7vdCsAmbF4AYaDOPyO3k0kl8qIrw==",
"dev": true
},
"astral-regex": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/astral-regex/-/astral-regex-2.0.0.tgz",
"integrity": "sha512-Z7tMw1ytTXt5jqMcOP+OQteU1VuNK9Y02uuJtKQ1Sv69jXQKKg5cibLwGJow8yzZP+eAc18EmLGPal0bp36rvQ==",
"dev": true,
"optional": true
},
"async": {
"version": "3.2.3",
"resolved": "https://registry.npmjs.org/async/-/async-3.2.3.tgz",
@ -24753,62 +24543,6 @@
"integrity": "sha512-y4coMcylgSCdVinjiDBuR8PCC2bLjyGTwEmPb9NHR/QaNU6EUOXcTY/s6VjGMD6ENSEaeQYHCY0GNGS5jfMwPw==",
"dev": true
},
"cli-truncate": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/cli-truncate/-/cli-truncate-2.1.0.tgz",
"integrity": "sha512-n8fOixwDD6b/ObinzTrp1ZKFzbgvKZvuz/TvejnLn1aQfC6r52XEx85FmuC+3HI+JM7coBRXUvNqEU2PHVrHpg==",
"dev": true,
"optional": true,
"requires": {
"slice-ansi": "^3.0.0",
"string-width": "^4.2.0"
},
"dependencies": {
"ansi-regex": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
"dev": true,
"optional": true
},
"emoji-regex": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
"dev": true,
"optional": true
},
"is-fullwidth-code-point": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
"integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
"dev": true,
"optional": true
},
"string-width": {
"version": "4.2.3",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
"dev": true,
"optional": true,
"requires": {
"emoji-regex": "^8.0.0",
"is-fullwidth-code-point": "^3.0.0",
"strip-ansi": "^6.0.1"
}
},
"strip-ansi": {
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
"dev": true,
"optional": true,
"requires": {
"ansi-regex": "^5.0.1"
}
}
}
},
"cliui": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/cliui/-/cliui-5.0.0.tgz",
@ -25262,16 +24996,6 @@
"vary": "^1"
}
},
"crc": {
"version": "3.8.0",
"resolved": "https://registry.npmjs.org/crc/-/crc-3.8.0.tgz",
"integrity": "sha512-iX3mfgcTMIq3ZKLIsVFAbv7+Mc10kxabAGQb8HvjA1o3T1PIYprbakQ65d3I+2HGHt6nSKkM9PYjgoJO2KcFBQ==",
"dev": true,
"optional": true,
"requires": {
"buffer": "^5.1.0"
}
},
"create-ecdh": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/create-ecdh/-/create-ecdh-4.0.4.tgz",
@ -25907,23 +25631,6 @@
}
}
},
"dmg-license": {
"version": "1.0.11",
"resolved": "https://registry.npmjs.org/dmg-license/-/dmg-license-1.0.11.tgz",
"integrity": "sha512-ZdzmqwKmECOWJpqefloC5OJy1+WZBBse5+MR88z9g9Zn4VY+WYUkAyojmhzJckH5YbbZGcYIuGAkY5/Ys5OM2Q==",
"dev": true,
"optional": true,
"requires": {
"@types/plist": "^3.0.1",
"@types/verror": "^1.10.3",
"ajv": "^6.10.0",
"crc": "^3.8.0",
"iconv-corefoundation": "^1.1.7",
"plist": "^3.0.4",
"smart-buffer": "^4.0.2",
"verror": "^1.10.0"
}
},
"dns-equal": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/dns-equal/-/dns-equal-1.0.0.tgz",
@ -27313,13 +27020,6 @@
"integrity": "sha1-FQStJSMVjKpA20onh8sBQRmU6k8=",
"dev": true
},
"fsevents": {
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
"dev": true,
"optional": true
},
"function-bind": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.1.tgz",
@ -28074,26 +27774,6 @@
"@babel/runtime": "^7.12.0"
}
},
"iconv-corefoundation": {
"version": "1.1.7",
"resolved": "https://registry.npmjs.org/iconv-corefoundation/-/iconv-corefoundation-1.1.7.tgz",
"integrity": "sha512-T10qvkw0zz4wnm560lOEg0PovVqUXuOFhhHAkixw8/sycy7TJt7v/RrkEKEQnAw2viPSJu6iAkErxnzR0g8PpQ==",
"dev": true,
"optional": true,
"requires": {
"cli-truncate": "^2.1.0",
"node-addon-api": "^1.6.3"
},
"dependencies": {
"node-addon-api": {
"version": "1.7.2",
"resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-1.7.2.tgz",
"integrity": "sha512-ibPK3iA+vaY1eEjESkQkM0BbCqFOaZMiXRTtdB0u7b4djtY6JnsjvPdUHVMg6xQt3B8fpTTWHI9A+ADjM9frzg==",
"dev": true,
"optional": true
}
}
},
"iconv-lite": {
"version": "0.4.23",
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.23.tgz",
@ -33522,34 +33202,6 @@
"integrity": "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==",
"dev": true
},
"slice-ansi": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-3.0.0.tgz",
"integrity": "sha512-pSyv7bSTC7ig9Dcgbw9AuRNUb5k5V6oDudjZoMBSr13qpLBG7tB+zgCkARjq7xIUgdz5P1Qe8u+rSGdouOOIyQ==",
"dev": true,
"optional": true,
"requires": {
"ansi-styles": "^4.0.0",
"astral-regex": "^2.0.0",
"is-fullwidth-code-point": "^3.0.0"
},
"dependencies": {
"is-fullwidth-code-point": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
"integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
"dev": true,
"optional": true
}
}
},
"smart-buffer": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/smart-buffer/-/smart-buffer-4.2.0.tgz",
"integrity": "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==",
"dev": true,
"optional": true
},
"smooth-dnd": {
"version": "0.12.0",
"resolved": "https://registry.npmjs.org/smooth-dnd/-/smooth-dnd-0.12.0.tgz",

Просмотреть файл

@ -1,6 +1,6 @@
{
"name": "azure-iot-explorer",
"version": "0.14.14",
"version": "0.14.15",
"description": "This project welcomes contributions and suggestions. Most contributions require you to agree to a\r Contributor License Agreement (CLA) declaring that you have the right to, and actually do, grant us\r the rights to use your contribution. For details, visit https://cla.microsoft.com.",
"main": "host/electron.js",
"build": {
@ -70,6 +70,7 @@
"homepage": "https://github.com/Azure/azure-iot-explorer#readme",
"dependencies": {
"@azure/event-hubs": "1.0.7",
"@azure/msal-node": "1.3.0",
"@fluentui/react": "8.20.2",
"@microsoft/applicationinsights-web": "2.8.4",
"azure-iot-common": "1.12.14",

Просмотреть файл

@ -7,6 +7,9 @@ export const PLATFORMS = {
};
export const MESSAGE_CHANNELS = {
AUTHENTICATION_GET_PROFILE_TOKEN: 'authentication_get_profile_token',
AUTHENTICATION_LOGIN: 'authentication_login',
AUTHENTICATION_LOGOUT: 'authentication_logout',
DEVICE_SEND_MESSAGE: 'device_sendMessage',
DIRECTORY_GET_DIRECTORIES: 'directory_getDirectories',
EVENTHUB_START_MONITORING: 'eventhub_startMonitoring',
@ -16,6 +19,7 @@ export const MESSAGE_CHANNELS = {
};
export const API_INTERFACES = {
AUTHENTICATION: 'api_authentication',
DEVICE: 'api_device',
DIRECTORY: 'api_directory',
EVENTHUB: 'api_eventhub',

Просмотреть файл

@ -8,6 +8,7 @@ import { generateDirectoryInterface } from './factories/directoryInterfaceFactor
import { generateModelRepositoryInterface } from './factories/modelRepositoryInterfaceFactory';
import { generateDeviceInterface } from './factories/deviceInterfaceFactory';
import { generateEventHubInterface } from './factories/eventHubInterfaceFactory';
import { generateAuthenticationInterface } from './factories/authenticationInterfaceFactory';
import { API_INTERFACES } from './constants';
contextBridge.exposeInMainWorld(API_INTERFACES.DEVICE, generateDeviceInterface());
@ -15,3 +16,4 @@ contextBridge.exposeInMainWorld(API_INTERFACES.DIRECTORY, generateDirectoryInter
contextBridge.exposeInMainWorld(API_INTERFACES.EVENTHUB, generateEventHubInterface());
contextBridge.exposeInMainWorld(API_INTERFACES.MODEL_DEFINITION, generateModelRepositoryInterface());
contextBridge.exposeInMainWorld(API_INTERFACES.SETTINGS, generateSettingsInterface());
contextBridge.exposeInMainWorld(API_INTERFACES.AUTHENTICATION, generateAuthenticationInterface());

Просмотреть файл

@ -13,12 +13,14 @@ import { onGetDirectories } from './handlers/directoryHandler';
import { onSendMessageToDevice } from './handlers/deviceHandler';
import { onStartMonitoring, onStopMonitoring } from './handlers/eventHubHandler';
import { formatError } from './utils/errorHelper';
import { AuthProvider } from './utils/authProvider';
import '../dist/server/serverElectron';
class Main {
private static application: Electron.App;
private static mainWindow: BrowserWindow;
private static readonly target = path.join(__dirname, '/../dist/index.html');
private static authProvider: AuthProvider;
public static start() {
Main.application = app;
@ -35,6 +37,28 @@ class Main {
Main.registerHandler(MESSAGE_CHANNELS.DEVICE_SEND_MESSAGE, onSendMessageToDevice);
Main.registerHandler(MESSAGE_CHANNELS.EVENTHUB_START_MONITORING, onStartMonitoring);
Main.registerHandler(MESSAGE_CHANNELS.EVENTHUB_STOP_MONITORING, onStopMonitoring);
Main.registerHandler(MESSAGE_CHANNELS.AUTHENTICATION_LOGIN, Main.onLogin);
Main.registerHandler(MESSAGE_CHANNELS.AUTHENTICATION_LOGOUT, Main.onLogout);
Main.registerHandler(MESSAGE_CHANNELS.AUTHENTICATION_GET_PROFILE_TOKEN, Main.onGetProfileToken);
}
private static async loadTarget(redirect?: string): Promise<void> {
Main.mainWindow.loadFile(Main.target, { query: {redirect: redirect || ''} });
}
private static async onLogin(): Promise<void> {
await Main.authProvider.login(Main.mainWindow)
await Main.loadTarget();
}
private static async onLogout(): Promise<void> {
await Main.authProvider.logout();
await Main.loadTarget();
}
private static async onGetProfileToken(): Promise<string> {
const token = await Main.authProvider.getProfileTokenIfPresent();
return token;
}
private static setApplicationLock(): void {
@ -121,6 +145,8 @@ class Main {
Main.setErrorBoundary();
Main.setApplicationLock();
Main.authProvider = new AuthProvider();
Main.setMessageHandlers();
}

Просмотреть файл

@ -0,0 +1,21 @@
/***********************************************************
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License
**********************************************************/
import { MESSAGE_CHANNELS } from '../constants';
import { AuthenticationInterface } from '../interfaces/authenticationInterface';
import { invokeInMainWorld } from '../utils/invokeHelper';
export const generateAuthenticationInterface = (): AuthenticationInterface => {
return {
login: async (): Promise<void> => {
return invokeInMainWorld<void>(MESSAGE_CHANNELS.AUTHENTICATION_LOGIN);
},
logout: async (): Promise<void> => {
return invokeInMainWorld<void>(MESSAGE_CHANNELS.AUTHENTICATION_LOGOUT);
},
getProfileToken: async (): Promise<string> => {
return invokeInMainWorld<string>(MESSAGE_CHANNELS.AUTHENTICATION_GET_PROFILE_TOKEN);
},
};
};

Просмотреть файл

@ -0,0 +1,9 @@
/***********************************************************
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License
**********************************************************/
export interface AuthenticationInterface {
login(): Promise<void>;
logout(): Promise<void>;
getProfileToken(): Promise<string>;
}

Просмотреть файл

@ -0,0 +1,199 @@
/***********************************************************
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License
**********************************************************/
import {
PublicClientApplication,
Configuration,
LogLevel,
AccountInfo,
AuthorizationCodeRequest,
AuthorizationUrlRequest,
AuthenticationResult,
SilentFlowRequest } from '@azure/msal-node';
import { BrowserWindow } from 'electron';
const MSAL_CONFIG: Configuration = {
auth: {
clientId: '67ccd9d7-f5c7-475c-9da0-9700c24b2e66',
authority: 'https://login.microsoftonline.com/common',
},
system: {
loggerOptions: {
loggerCallback(loglevel, message, containsPii) {
console.log(message);
},
piiLoggingEnabled: false,
logLevel: LogLevel.Verbose,
}
}
};
export class AuthProvider {
private clientApplication: PublicClientApplication;
private account: AccountInfo;
private authCodeUrlParams: AuthorizationUrlRequest;
private authCodeRequest: AuthorizationCodeRequest;
private silentProfileRequest: SilentFlowRequest;
constructor() {
this.clientApplication = new PublicClientApplication(MSAL_CONFIG);
this.account = null;
this.setRequestObjects();
}
public get currentAccount(): AccountInfo | null {
return this.account;
}
/**
* Initialize request objects used by this AuthModule.
*/
private setRequestObjects(): void {
const requestScopes = ['openid', 'profile', 'https://management.azure.com/user_impersonation'];
const redirectUri = 'https://login.microsoftonline.com/oauth2/nativeclient';
const baseSilentRequest = {
account: null,
forceRefresh: false
};
this.authCodeUrlParams = {
scopes: requestScopes,
redirectUri: redirectUri
};
this.authCodeRequest = {
scopes: requestScopes,
redirectUri: redirectUri,
code: null
}
this.silentProfileRequest = {
...baseSilentRequest,
scopes: [],
};
}
async getProfileTokenIfPresent(): Promise<string> {
let authResponse: AuthenticationResult;
const account = this.account || await this.getAccount();
if (account) {
this.silentProfileRequest.account = account;
try {
authResponse = await this.clientApplication.acquireTokenSilent(this.silentProfileRequest);
} catch (error) {
// do nothing
}
}
return authResponse?.accessToken || null;
}
async getToken(authWindow: BrowserWindow, request: SilentFlowRequest): Promise<string> {
let authResponse: AuthenticationResult;
const account = this.account || await this.getAccount();
if (account) {
request.account = account;
authResponse = await this.getTokenSilent(authWindow, request);
} else {
authResponse = await this.getTokenInteractive(authWindow, this.authCodeRequest);
}
return authResponse.accessToken || null;
}
async getTokenSilent(authWindow: BrowserWindow, tokenRequest: SilentFlowRequest): Promise<AuthenticationResult> {
try {
return await this.clientApplication.acquireTokenSilent(tokenRequest);
} catch (error) {
console.log('Silent token acquisition failed, acquiring token using redirect');
return await this.getTokenInteractive(authWindow, this.authCodeRequest);
}
}
async getTokenInteractive(authWindow: BrowserWindow, tokenRequest: AuthorizationUrlRequest ): Promise<AuthenticationResult> {
const authCodeUrlParams = { ...this.authCodeUrlParams, scopes: tokenRequest.scopes };
const authCodeUrl = await this.clientApplication.getAuthCodeUrl(authCodeUrlParams);
const authCode = await this.listenForAuthCode(authCodeUrl, authWindow);
const authResult = await this.clientApplication.acquireTokenByCode({ ...this.authCodeRequest, scopes: tokenRequest.scopes, code: authCode});
return authResult;
}
async login(authWindow: BrowserWindow): Promise<void> {
const authResult = await this.getTokenInteractive(authWindow, this.authCodeUrlParams);
return this.handleResponse(authResult);
}
async loginSilent(): Promise<AccountInfo> {
if (!this.account) {
this.account = await this.getAccount();
}
return this.account;
}
async logout(): Promise<void> {
if (this.account) {
await this.clientApplication.getTokenCache().removeAccount(this.account);
this.account = null;
}
}
private async listenForAuthCode(navigateUrl: string, authWindow: BrowserWindow): Promise<string> {
authWindow.loadURL(navigateUrl);
return new Promise((resolve, reject) => {
authWindow.webContents.on('will-redirect', (event, responseUrl) => {
try {
const parsedUrl = new URL(responseUrl);
const authCode = parsedUrl.searchParams.get('code');
if(authCode) {
resolve(authCode);
}
} catch (err) {
reject(err);
}
});
});
}
/**
* Handles the response from a popup or redirect. If response is null, will check if we have any accounts and attempt to sign in.
* @param response
*/
private async handleResponse(response: AuthenticationResult) {
if (response !== null) {
this.account = response.account;
} else {
this.account = await this.getAccount();
}
}
/**
* Calls getAllAccounts and determines the correct account to sign into, currently defaults to first account found in cache.
* TODO: Add account chooser code
*
* https://github.com/AzureAD/microsoft-authentication-library-for-js/blob/dev/lib/msal-common/docs/Accounts.md
*/
private async getAccount(): Promise<AccountInfo> {
// need to call getAccount here?
const cache = this.clientApplication.getTokenCache();
const currentAccounts = await cache.getAllAccounts();
if (currentAccounts === null) {
console.log('No accounts detected');
return null;
}
if (currentAccounts.length > 1) {
// Add choose account code here
console.log('Multiple accounts detected, need to add choose account code.');
return currentAccounts[0];
} else if (currentAccounts.length === 1) {
return currentAccounts[0];
} else {
return null;
}
}
}

Просмотреть файл

@ -2,7 +2,6 @@
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License
**********************************************************/
export interface AzureResourceIdentifier {
id: string;
location: string;

Просмотреть файл

@ -2,8 +2,7 @@
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License
**********************************************************/
export interface AzureResourceManagementEndpoint {
authorizationToken: string;
endpoint: string;
}
export interface AzureResourceManagementEndpoint {
authorizationToken: string;
endpoint: string;
}

Просмотреть файл

@ -0,0 +1,19 @@
/***********************************************************
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License
**********************************************************/
export interface AzureSubscription {
id: string;
tenantId: string;
subscriptionId: string;
displayName: string;
state: SubscriptionState;
}
export enum SubscriptionState {
Deleted = 'Deleted',
Disabled = 'Disabled',
Enabled = 'Enabled',
PastDue = 'PastDue',
Warned = 'Warned'
}

Просмотреть файл

@ -2,8 +2,8 @@
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License
**********************************************************/
export interface AzureSubscription {
export interface IotHubDescription {
id: string;
tenantId: string;
subscriptionId: string;
name: string;
location: string;
}

Просмотреть файл

@ -0,0 +1,15 @@
/***********************************************************
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License
**********************************************************/
export interface SharedAccessSignatureAuthorizationRule {
keyName: string;
primaryKey: string;
rights: string;
}
export enum AccessRights {
RegistryWrite = 'RegistryWrite',
ServiceConnect = 'ServiceConnect',
DeviceConnect = 'DeviceConnect'
}

Просмотреть файл

@ -0,0 +1,20 @@
/***********************************************************
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License
**********************************************************/
import { getAuthenticationInterface } from '../shared/interfaceUtils';
export const login = async (): Promise<void> => {
const api = getAuthenticationInterface();
return api.login();
};
export const logout = async (): Promise<void> => {
const api = getAuthenticationInterface();
return api.logout();
};
export const getProfileToken = async (): Promise<string> => {
const api = getAuthenticationInterface();
return api.getProfileToken();
};

Просмотреть файл

@ -2,15 +2,15 @@
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License
**********************************************************/
import { ContinuingResultSet } from '../../api/models/continuingResultSet';
import { ContinuingResultSet } from '../models/continuingResultSet';
import { AzureResourceIdentifier } from '../models/azureResourceIdentifier';
import { AzureResourceIdentifierType } from '../models/azureResourceIdentifierType';
import { AzureResourceIdentifierQuery } from '../models/azureResourceIdentifierQuery';
import { AzureResourceIdentifierQueryResult } from '../models/azureResourceIdentifierQueryResult';
import { APPLICATION_JSON, HTTP_OPERATION_TYPES } from '../../constants/apiConstants';
import { AzureResourceManagementEndpoint } from '../models/azureResourceManagementEndpoint';
import { HttpError } from '../../api/models/httpError';
import { mapPropertyArrayToObject } from '../../api/shared/mapUtils';
import { HttpError } from '../models/httpError';
import { mapPropertyArrayToObject } from '../shared/mapUtils';
const azureResourceManagementAPIVersion = '2019-04-01';
const azureResourceManagementQueryFields = [

Просмотреть файл

@ -1,4 +1,3 @@
import { HTTP_OPERATION_TYPES } from './../../constants/apiConstants';
/***********************************************************
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License
@ -6,6 +5,7 @@ import { HTTP_OPERATION_TYPES } from './../../constants/apiConstants';
import { getAzureSubscriptions } from './azureSubscriptionService';
import { HttpError } from '../../api/models/httpError';
import { APPLICATION_JSON } from '../../constants/apiConstants';
import { HTTP_OPERATION_TYPES } from './../../constants/apiConstants';
describe('getAzureSubscriptions', () => {
it('calls fetch with expected parameters', () => {
@ -17,10 +17,8 @@ describe('getAzureSubscriptions', () => {
} as any); // tslint:disable-line:no-any
getAzureSubscriptions({
azureResourceManagementEndpoint: {
authorizationToken: 'token',
endpoint: 'managementEndpoint'
},
authorizationToken: 'token',
endpoint: 'managementEndpoint'
});
const resourceUrl = `https://managementEndpoint/subscriptions?api-version=2019-06-01`;
@ -47,11 +45,9 @@ describe('getAzureSubscriptions', () => {
} as any); // tslint:disable-line:no-any
await expect(getAzureSubscriptions({
azureResourceManagementEndpoint: {
authorizationToken: 'token',
endpoint: 'managementEndpoint'
},
})).rejects.toThrow(httpError);
authorizationToken: 'token',
endpoint: 'managementEndpoint'
})).rejects.toThrow();
});
it('returns expected data', async () => {
@ -75,10 +71,8 @@ describe('getAzureSubscriptions', () => {
} as any); // tslint:disable-line:no-any
const result = await getAzureSubscriptions({
azureResourceManagementEndpoint: {
authorizationToken: 'token',
endpoint: 'managementEndpoint'
},
authorizationToken: 'token',
endpoint: 'managementEndpoint'
});
expect(result).toHaveLength(2); // tslint:disable-line:no-magic-numbers

Просмотреть файл

@ -4,17 +4,14 @@
**********************************************************/
import { AzureResourceManagementEndpoint } from '../models/azureResourceManagementEndpoint';
import { APPLICATION_JSON, HTTP_OPERATION_TYPES } from '../../constants/apiConstants';
import { HttpError } from '../../api/models/httpError';
import { AzureSubscription } from '../models/azureSubscription';
import { throwHttpErrorWhenResponseNotOk } from '../shared/fetchUtils';
const azureSubscriptionAPIVersion = '2019-06-01';
export interface GetSubscriptionsParameters {
azureResourceManagementEndpoint: AzureResourceManagementEndpoint;
}
export type GetSubscriptionsParameters = AzureResourceManagementEndpoint;
export const getAzureSubscriptions = async (parameters: GetSubscriptionsParameters): Promise<AzureSubscription[]> => {
const { azureResourceManagementEndpoint } = parameters;
const { authorizationToken, endpoint } = azureResourceManagementEndpoint;
const { authorizationToken, endpoint } = parameters;
const resourceUrl = `https://${endpoint}/subscriptions?api-version=${azureSubscriptionAPIVersion}`;
const serviceRequestParams: RequestInit = {
@ -26,9 +23,7 @@ export const getAzureSubscriptions = async (parameters: GetSubscriptionsParamete
method: HTTP_OPERATION_TYPES.Get
};
const response = await fetch(resourceUrl, serviceRequestParams);
if (!response.ok) {
throw new HttpError(response.status);
}
await throwHttpErrorWhenResponseNotOk(response);
const responseBody = await response.json() as { value: AzureSubscription[] };
return responseBody.value;

Просмотреть файл

@ -5,8 +5,9 @@
import { CONTROLLER_API_ENDPOINT, DATAPLANE, DataPlaneStatusCode, HTTP_OPERATION_TYPES } from '../../constants/apiConstants';
import { getConnectionInfoFromConnectionString, generateSasToken } from '../shared/utils';
import { PortIsInUseError } from '../models/portIsInUseError';
import { CONNECTION_STRING_NAME_LIST } from '../../constants/browserStorage';
import { AUTHENTICATION_METHOD_PREFERENCE, CONNECTION_STRING_NAME_LIST, CONNECTION_STRING_THROUGH_AAD } from '../../constants/browserStorage';
import { getActiveConnectionString } from '../../shared/utils/hubConnectionStringHelper';
import { AuthenticationMethodPreference } from '../../authentication/state';
export const DATAPLANE_CONTROLLER_ENDPOINT = `${CONTROLLER_API_ENDPOINT}${DATAPLANE}`;
@ -38,9 +39,18 @@ export const request = async (endpoint: string, parameters: any) => { // tslint:
);
};
export const getConnectionStringHelper = async () => {
const authSelection = await localStorage.getItem(AUTHENTICATION_METHOD_PREFERENCE);
if (authSelection === AuthenticationMethodPreference.ConnectionString) {
return getActiveConnectionString(await localStorage.getItem(CONNECTION_STRING_NAME_LIST));
}
else {
return localStorage.getItem(CONNECTION_STRING_THROUGH_AAD);
}
};
export const dataPlaneConnectionHelper = async () => {
const connectionStrings = await localStorage.getItem(CONNECTION_STRING_NAME_LIST);
const connectionString = getActiveConnectionString(connectionStrings);
const connectionString = await getConnectionStringHelper();
const connectionInfo = getConnectionInfoFromConnectionString(connectionString);
if (!(connectionInfo && connectionInfo.hostName)) {
return;

Просмотреть файл

@ -0,0 +1,54 @@
/***********************************************************
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License
**********************************************************/
import { AzureResourceManagementEndpoint } from '../models/azureResourceManagementEndpoint';
import { APPLICATION_JSON, HTTP_OPERATION_TYPES } from '../../constants/apiConstants';
import { IotHubDescription } from '../models/iotHubDescription';
import { throwHttpErrorWhenResponseNotOk } from '../shared/fetchUtils';
import { SharedAccessSignatureAuthorizationRule } from '../models/sharedAccessSignatureAuthorizationRule';
const apiVersion = '2018-04-01';
export interface GetIotHubsBySubscriptionParameters extends AzureResourceManagementEndpoint {
subscriptionId: string;
}
export const getIotHubsBySubscription = async (parameters: GetIotHubsBySubscriptionParameters): Promise<IotHubDescription[]> => {
const { authorizationToken, endpoint, subscriptionId } = parameters;
const resourceUrl = `https://${endpoint}/subscriptions/${subscriptionId}/providers/Microsoft.Devices/IotHubs?api-version=${apiVersion}`;
const serviceRequestParams: RequestInit = {
headers: new Headers({
'Accept': APPLICATION_JSON,
'Authorization': `Bearer ${authorizationToken}`,
'Content-Type': APPLICATION_JSON
}),
method: HTTP_OPERATION_TYPES.Get
};
const response = await fetch(resourceUrl, serviceRequestParams);
await throwHttpErrorWhenResponseNotOk(response);
const responseBody = await response.json() as { value: IotHubDescription[] };
return responseBody.value;
};
export interface GetIotHubKeysParameters extends AzureResourceManagementEndpoint {
hubId: string;
}
export const getIotHubKeys = async (parameters: GetIotHubKeysParameters): Promise<SharedAccessSignatureAuthorizationRule[]> => {
const { authorizationToken, endpoint, hubId } = parameters;
const resourceUrl = `https://${endpoint}/${hubId}/listkeys?api-version=${apiVersion}`;
const serviceRequestParams: RequestInit = {
headers: new Headers({
'Accept': APPLICATION_JSON,
'Authorization': `Bearer ${authorizationToken}`,
'Content-Type': APPLICATION_JSON
}),
method: HTTP_OPERATION_TYPES.Post
};
const response = await fetch(resourceUrl, serviceRequestParams);
await throwHttpErrorWhenResponseNotOk(response);
const responseBody = await response.json() as { value: SharedAccessSignatureAuthorizationRule[] };
return responseBody.value;
};

Просмотреть файл

@ -9,3 +9,22 @@ export const getHeaderValue = (response: { headers: Headers }, headerName: strin
return response.headers.has(headerName) ? response.headers.get(headerName) : undefinedValue;
};
export const throwHttpErrorWhenResponseNotOk = async (response: Response) => {
if (!response.ok) {
const error = await getHttpErrorFromResponse(response);
throw error;
}
};
export const getHttpErrorFromResponse = async (response: Response): Promise<Error> => {
let message: string | object = await response.text();
try {
message = JSON.stringify(JSON.parse(message), null, 2); // tslint:disable-line:no-magic-numbers
} catch {
// intentionally blank
}
return new Error(message);
};

Просмотреть файл

@ -2,8 +2,8 @@
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License
**********************************************************/
import { getResourceNameFromHostName, getResourceTypeFromHostName, tryGetHostNameFromConnectionString } from './hostNameUtils';
import { AzureResourceIdentifierType } from '../../azureResourceIdentifier/models/azureResourceIdentifierType';
import { getResourceNameFromHostName, getResourceTypeFromHostName, tryGetHostNameFromConnectionString } from './hostNameUtils';
import { AzureResourceIdentifierType } from '../models/azureResourceIdentifierType';
import * as utils from './utils';
describe('getResourceNameFromHostName', () => {

Просмотреть файл

@ -2,8 +2,8 @@
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License
**********************************************************/
import { AzureResourceIdentifierType } from '../../azureResourceIdentifier/models/azureResourceIdentifierType';
import { AzureResourceHostNameType } from '../../azureResourceIdentifier/models/azureResourceHostNameType';
import { AzureResourceIdentifierType } from '../models/azureResourceIdentifierType';
import { AzureResourceHostNameType } from '../models/azureResourceHostNameType';
import { getConnectionInfoFromConnectionString } from './utils';
export const getResourceNameFromHostName = (hostName: string): string | undefined => {

Просмотреть файл

@ -7,6 +7,7 @@ import { DeviceInterface } from '../../../../public/interfaces/deviceInterface';
import { DirectoryInterface } from '../../../../public/interfaces/directoryInterface';
import { ModelRepositoryInterface } from '../../../../public/interfaces/modelRepositoryInterface';
import { EventHubInterface } from './../../../../public/interfaces/eventHubInterface';
import { AuthenticationInterface } from './../../../../public/interfaces/authenticationInterface';
import { API_INTERFACES } from '../../../../public/constants';
import { appConfig, HostMode } from '../../../appConfig/appConfig';
import { HIGH_CONTRAST } from '../../constants/browserStorage';
@ -67,6 +68,14 @@ export const getPublicDigitalTwinsModelInterface = (): PublicDigitalTwinsModelIn
return new PublicDigitalTwinsModelRepoHelper();
};
export const getAuthenticationInterface = (): AuthenticationInterface => {
if (appConfig.hostMode !== HostMode.Electron) {
throw new Error(NOT_AVAILABLE);
}
return getElectronInterface(API_INTERFACES.AUTHENTICATION);
};
export const getElectronInterface = <T>(name: string): T => {
// tslint:disable-next-line: no-any no-string-literal
const api = (window as any)[name];

Просмотреть файл

@ -0,0 +1,37 @@
/***********************************************************
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License
**********************************************************/
import { setLoginPreferenceAction, getLoginPreferenceAction } from './actions';
describe('actions', () => {
context('setLoginPreferenceAction', () => {
it('returns AUTHENTICATION/SET_STARTED action object', () => {
expect(setLoginPreferenceAction.started('').type).toEqual('AUTHENTICATION/SET_STARTED');
});
it('returns AUTHENTICATION/SET_DONE action object', () => {
expect(setLoginPreferenceAction.done({params: ''}).type).toEqual('AUTHENTICATION/SET_DONE');
});
it('returns AUTHENTICATION/SET_FAILED action object', () => {
expect(setLoginPreferenceAction.failed({params: '', error: {}}).type).toEqual('AUTHENTICATION/SET_FAILED');
});
});
context('getLoginPreferenceAction', () => {
it('returns AUTHENTICATION/GET_STARTED action object', () => {
expect(getLoginPreferenceAction.started().type).toEqual('AUTHENTICATION/GET_STARTED');
});
it('returns AUTHENTICATION/GET_DONE action object', () => {
expect(getLoginPreferenceAction.done({result: ''}).type).toEqual('AUTHENTICATION/GET_DONE');
});
it('returns AUTHENTICATION/GET_FAILED action object', () => {
expect(getLoginPreferenceAction.failed({error: {}}).type).toEqual('AUTHENTICATION/GET_FAILED');
});
});
});

Просмотреть файл

@ -0,0 +1,10 @@
/***********************************************************
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License
**********************************************************/
import actionCreatorFactory from 'typescript-fsa';
import { GET, SET } from '../constants/actionTypes';
const actionCreator = actionCreatorFactory('AUTHENTICATION');
export const setLoginPreferenceAction = actionCreator.async<string, void>(SET);
export const getLoginPreferenceAction = actionCreator.async<void, string>(GET);

Просмотреть файл

@ -0,0 +1,99 @@
/***********************************************************
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License
**********************************************************/
import { getUserProfileTokenAction, getSubscriptionListAction, getIotHubsBySubscriptionAction, loginAction, logoutAction, getIoTHubKeyAction } from './actions';
describe('actions', () => {
context('getUserProfileTokenAction', () => {
it('returns AAD/GET_TOKEN_STARTED action object', () => {
expect(getUserProfileTokenAction.started().type).toEqual('AAD/GET_TOKEN_STARTED');
});
it('returns AAD/GET_TOKEN_DONE action object', () => {
expect(getUserProfileTokenAction.done({result: 'token'}).type).toEqual('AAD/GET_TOKEN_DONE');
});
it('returns AAD/GET_TOKEN_FAILED action object', () => {
expect(getUserProfileTokenAction.failed({error: {}}).type).toEqual('AAD/GET_TOKEN_FAILED');
});
});
context('getSubscriptionListAction', () => {
it('returns AAD/GET_SUBSCRIPTIONS_STARTED action object', () => {
expect(getSubscriptionListAction.started().type).toEqual('AAD/GET_SUBSCRIPTIONS_STARTED');
});
it('returns AAD/GET_SUBSCRIPTIONS_DONE action object', () => {
expect(getSubscriptionListAction.done({result: []}).type).toEqual('AAD/GET_SUBSCRIPTIONS_DONE');
});
it('returns AAD/GET_SUBSCRIPTIONS_FAILED action object', () => {
expect(getSubscriptionListAction.failed({error: {}}).type).toEqual('AAD/GET_SUBSCRIPTIONS_FAILED');
});
});
context('getIotHubsBySubscriptionAction', () => {
it('returns AAD/GET_IOTHUBS_STARTED action object', () => {
expect(getIotHubsBySubscriptionAction.started('subscriptionId').type).toEqual('AAD/GET_IOTHUBS_STARTED');
});
it('returns AAD/GET_IOTHUBS_DONE action object', () => {
expect(getIotHubsBySubscriptionAction.done({params: 'subscriptionId', result: []}).type).toEqual('AAD/GET_IOTHUBS_DONE');
});
it('returns AAD/GET_IOTHUBS_FAILED action object', () => {
expect(getIotHubsBySubscriptionAction.failed({params: 'subscriptionId', error: {}}).type).toEqual('AAD/GET_IOTHUBS_FAILED');
});
});
context('getIoTHubKeyAction', () => {
const params = {hubId: 'hubid', hubName: 'test'};
it('returns AAD/GET_HUBKEYSTARTED action object', () => {
expect(getIoTHubKeyAction.started(params).type).toEqual('AAD/GET_HUBKEY_STARTED');
});
it('returns AAD/GET_HUBKEY_DONE action object', () => {
expect(getIoTHubKeyAction.done({params, result: 'key'}).type).toEqual('AAD/GET_HUBKEY_DONE');
});
it('returns AAD/GET_HUBKEY_FAILED action object', () => {
expect(getIoTHubKeyAction.failed({params, error: {}}).type).toEqual('AAD/GET_HUBKEY_FAILED');
});
});
context('loginAction', () => {
it('returns AAD/LOGIN_STARTED action object', () => {
expect(loginAction.started().type).toEqual('AAD/LOGIN_STARTED');
});
it('returns AAD/LOGIN_DONE action object', () => {
expect(loginAction.done({}).type).toEqual('AAD/LOGIN_DONE');
});
it('returns AAD/LOGIN_FAILED action object', () => {
expect(loginAction.failed({error: {}}).type).toEqual('AAD/LOGIN_FAILED');
});
});
context('logoutAction', () => {
it('returns AAD/LOGOUT_STARTED action object', () => {
expect(logoutAction.started().type).toEqual('AAD/LOGOUT_STARTED');
});
it('returns AAD/LOGOUT_DONE action object', () => {
expect(logoutAction.done({}).type).toEqual('AAD/LOGOUT_DONE');
});
it('returns AAD/LOGOUT_FAILED action object', () => {
expect(logoutAction.failed({error: {}}).type).toEqual('AAD/LOGOUT_FAILED');
});
});
});

Просмотреть файл

@ -0,0 +1,20 @@
/***********************************************************
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License
**********************************************************/
import actionCreatorFactory from 'typescript-fsa';
import { IotHubDescription } from '../../api/models/iotHubDescription';
import { AzureSubscription } from '../../api/models/azureSubscription';
export interface GetIotHubKeyActionParmas {
hubId: string;
hubName: string;
}
const actionCreator = actionCreatorFactory('AAD');
export const getIotHubsBySubscriptionAction = actionCreator.async<string, IotHubDescription[]>('GET_IOTHUBS');
export const getIoTHubKeyAction = actionCreator.async<GetIotHubKeyActionParmas, string>('GET_HUBKEY');
export const getSubscriptionListAction = actionCreator.async<void, AzureSubscription[]>('GET_SUBSCRIPTIONS');
export const getUserProfileTokenAction = actionCreator.async<void, string>('GET_TOKEN');
export const loginAction = actionCreator.async<void, void>('LOGIN');
export const logoutAction = actionCreator.async<void, void>('LOGOUT');

Просмотреть файл

@ -0,0 +1,14 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`BackButton matches snapshot 1`] = `
<CustomizedActionButton
iconProps={
Object {
"iconName": "back",
}
}
onClick={[MockFunction]}
>
authentication.azureActiveDirectory.hubList.backButton
</CustomizedActionButton>
`;

Просмотреть файл

@ -0,0 +1,55 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`AzureActiveDirectoryCommandBar matches snapshot when token is not present 1`] = `
<StyledCommandBarBase
items={
Array [
Object {
"ariaLabel": "authentication.azureActiveDirectory.command.login",
"iconProps": Object {
"iconName": "Signin",
},
"key": "signin",
"onClick": [Function],
"text": "authentication.azureActiveDirectory.command.login",
},
Object {
"ariaLabel": "authentication.autheSelection.switchAuthType",
"iconProps": Object {
"iconName": "NavigateBack",
},
"key": "switch",
"onClick": [Function],
"text": "authentication.autheSelection.switchAuthType",
},
]
}
/>
`;
exports[`AzureActiveDirectoryCommandBar matches snapshot when token is present 1`] = `
<StyledCommandBarBase
items={
Array [
Object {
"ariaLabel": "authentication.azureActiveDirectory.command.logout",
"iconProps": Object {
"iconName": "Signout",
},
"key": "signout",
"onClick": [Function],
"text": "authentication.azureActiveDirectory.command.logout",
},
Object {
"ariaLabel": "authentication.autheSelection.switchAuthType",
"iconProps": Object {
"iconName": "NavigateBack",
},
"key": "switch",
"onClick": [Function],
"text": "authentication.autheSelection.switchAuthType",
},
]
}
/>
`;

Просмотреть файл

@ -0,0 +1,16 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`FilterTextBox matches snapshot 1`] = `
<StyledTextFieldBase
ariaLabel="authentication.azureActiveDirectory.filter.placeHolder"
iconProps={
Object {
"iconName": "Filter",
}
}
onChange={[Function]}
placeholder="authentication.azureActiveDirectory.filter.placeHolder"
role="searchbox"
value=""
/>
`;

Просмотреть файл

@ -0,0 +1,68 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`HubList matches snapshot when there are list items 1`] = `
<Fragment>
<FilterTextBox
filterType={1}
setFilteredList={[Function]}
/>
<StyledWithViewportComponent
columns={
Array [
Object {
"isResizable": true,
"key": "name",
"maxWidth": 400,
"minWidth": 150,
"name": "authentication.azureActiveDirectory.hubList.columns.name",
"onRender": [Function],
},
Object {
"isResizable": true,
"key": "id",
"maxWidth": 400,
"minWidth": 150,
"name": "authentication.azureActiveDirectory.hubList.columns.location",
"onRender": [Function],
},
]
}
items={Array []}
selectionMode={0}
/>
</Fragment>
`;
exports[`HubList matches snapshot when there are no list items 1`] = `
<Fragment>
<FilterTextBox
filterType={1}
setFilteredList={[Function]}
/>
<StyledWithViewportComponent
columns={
Array [
Object {
"isResizable": true,
"key": "name",
"maxWidth": 400,
"minWidth": 150,
"name": "authentication.azureActiveDirectory.hubList.columns.name",
"onRender": [Function],
},
Object {
"isResizable": true,
"key": "id",
"maxWidth": 400,
"minWidth": 150,
"name": "authentication.azureActiveDirectory.hubList.columns.location",
"onRender": [Function],
},
]
}
items={Array []}
selectionMode={0}
/>
authentication.azureActiveDirectory.hubList.noItemText
</Fragment>
`;

Просмотреть файл

@ -0,0 +1,35 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`HubSelection matches snapshot when hub list is shown 1`] = `
<Fragment>
<AzureActiveDirectoryCommandBar />
<div
className="hub-selection-list"
>
<BackButton
backToSubscription={[Function]}
/>
<HubList />
</div>
</Fragment>
`;
exports[`HubSelection matches snapshot when page is loading 1`] = `
<Fragment>
<AzureActiveDirectoryCommandBar />
<MultiLineShimmer />
</Fragment>
`;
exports[`HubSelection matches snapshot when token is present 1`] = `
<Fragment>
<AzureActiveDirectoryCommandBar />
<div
className="hub-selection-list"
>
<SubscriptionList
renderHubList={[Function]}
/>
</div>
</Fragment>
`;

Просмотреть файл

@ -0,0 +1,84 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`SubscriptionList matches snapshot when there are list items 1`] = `
<Fragment>
<FilterTextBox
filterType={0}
setFilteredList={[Function]}
/>
<StyledWithViewportComponent
columns={
Array [
Object {
"isResizable": true,
"key": "name",
"maxWidth": 400,
"minWidth": 150,
"name": "authentication.azureActiveDirectory.subscriptionList.columns.name",
"onRender": [Function],
},
Object {
"isResizable": true,
"key": "id",
"maxWidth": 400,
"minWidth": 150,
"name": "authentication.azureActiveDirectory.subscriptionList.columns.id",
"onRender": [Function],
},
Object {
"isResizable": true,
"key": "state",
"maxWidth": 400,
"minWidth": 150,
"name": "authentication.azureActiveDirectory.subscriptionList.columns.state",
"onRender": [Function],
},
]
}
items={Array []}
selectionMode={0}
/>
</Fragment>
`;
exports[`SubscriptionList matches snapshot when there are no list items 1`] = `
<Fragment>
<FilterTextBox
filterType={0}
setFilteredList={[Function]}
/>
<StyledWithViewportComponent
columns={
Array [
Object {
"isResizable": true,
"key": "name",
"maxWidth": 400,
"minWidth": 150,
"name": "authentication.azureActiveDirectory.subscriptionList.columns.name",
"onRender": [Function],
},
Object {
"isResizable": true,
"key": "id",
"maxWidth": 400,
"minWidth": 150,
"name": "authentication.azureActiveDirectory.subscriptionList.columns.id",
"onRender": [Function],
},
Object {
"isResizable": true,
"key": "state",
"maxWidth": 400,
"minWidth": 150,
"name": "authentication.azureActiveDirectory.subscriptionList.columns.state",
"onRender": [Function],
},
]
}
items={Array []}
selectionMode={0}
/>
authentication.azureActiveDirectory.subscriptionList.noItemText
</Fragment>
`;

Просмотреть файл

@ -0,0 +1,18 @@
/***********************************************************
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License
**********************************************************/
import * as React from 'react';
import { shallow } from 'enzyme';
import { BackButton } from './backButton';
import { getInitialAzureActiveDirectoryState } from '../state';
import * as azureActiveDirectoryStateContext from '../context/azureActiveDirectoryStateContext';
describe('BackButton', () => {
it('matches snapshot ', () => {
jest.spyOn(azureActiveDirectoryStateContext, 'useAzureActiveDirectoryStateContext').mockReturnValue(
[getInitialAzureActiveDirectoryState(), azureActiveDirectoryStateContext.getInitialAzureActiveDirectoryOps()]);
const wrapper = shallow(<BackButton backToSubscription={jest.fn()}/>);
expect(wrapper).toMatchSnapshot();
});
});

Просмотреть файл

@ -0,0 +1,21 @@
/***********************************************************
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License
**********************************************************/
import * as React from 'react';
import { useTranslation } from 'react-i18next';
import { ActionButton } from '@fluentui/react';
import { ResourceKeys } from '../../../../localization/resourceKeys';
export interface BackButtonProps {
backToSubscription: () => void;
}
export const BackButton: React.FC<BackButtonProps> = props => {
const { t } = useTranslation();
return (
<ActionButton iconProps={{ iconName: 'back' }} onClick={props.backToSubscription}>
{t(ResourceKeys.authentication.azureActiveDirectory.hubList.backButton)}
</ActionButton>
);
};

Просмотреть файл

@ -0,0 +1,50 @@
/***********************************************************
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License
**********************************************************/
import * as React from 'react';
import { shallow } from 'enzyme';
import { CommandBar } from '@fluentui/react';
import { AzureActiveDirectoryCommandBar } from './commandBar';
import { getInitialAzureActiveDirectoryState } from '../state';
import * as azureActiveDirectoryStateContext from '../context/azureActiveDirectoryStateContext';
import * as authenticationStateContext from '../../../authentication/context/authenticationStateContext';
import { getInitialAuthenticationState } from '../../../authentication/state';
describe('AzureActiveDirectoryCommandBar', () => {
it('matches snapshot when token is not present', () => {
jest.spyOn(azureActiveDirectoryStateContext, 'useAzureActiveDirectoryStateContext').mockReturnValue(
[getInitialAzureActiveDirectoryState(), azureActiveDirectoryStateContext.getInitialAzureActiveDirectoryOps()]);
const wrapper = shallow(<AzureActiveDirectoryCommandBar/>);
expect(wrapper).toMatchSnapshot();
});
it('matches snapshot when token is present', () => {
jest.spyOn(azureActiveDirectoryStateContext, 'useAzureActiveDirectoryStateContext').mockReturnValue(
[{...getInitialAzureActiveDirectoryState(), token: 'token'}, azureActiveDirectoryStateContext.getInitialAzureActiveDirectoryOps()]);
const wrapper = shallow(<AzureActiveDirectoryCommandBar/>);
expect(wrapper).toMatchSnapshot();
});
it('calls api respectively', () => {
const logout = jest.fn();
jest.spyOn(azureActiveDirectoryStateContext, 'useAzureActiveDirectoryStateContext').mockReturnValue(
[{...getInitialAzureActiveDirectoryState(), token: 'token'}, {...azureActiveDirectoryStateContext.getInitialAzureActiveDirectoryOps(), logout}]);
const setLoginPreference = jest.fn();
jest.spyOn(authenticationStateContext, 'useAuthenticationStateContext').mockReturnValue(
[getInitialAuthenticationState(), {...authenticationStateContext.getInitialAuthenticationOps(), setLoginPreference}]);
const wrapper = shallow(<AzureActiveDirectoryCommandBar/>);
wrapper.find(CommandBar).props().items[0].onClick(undefined);
wrapper.update();
expect(logout).toHaveBeenCalled();
wrapper.find(CommandBar).props().items[1].onClick(undefined);
wrapper.update();
expect(setLoginPreference).toHaveBeenCalledWith('');
});
});

Просмотреть файл

@ -0,0 +1,52 @@
/***********************************************************
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License
**********************************************************/
import * as React from 'react';
import { useTranslation } from 'react-i18next';
import { CommandBar } from '@fluentui/react';
import { ResourceKeys } from '../../../../localization/resourceKeys';
import { useAuthenticationStateContext } from '../../context/authenticationStateContext';
import { NAVIGATE_BACK } from '../../../constants/iconNames';
import { useAzureActiveDirectoryStateContext } from '../context/azureActiveDirectoryStateContext';
export const AzureActiveDirectoryCommandBar: React.FC = () => {
const { t } = useTranslation();
const [ , { setLoginPreference } ] = useAuthenticationStateContext();
const [{ token }, { logout, login }] = useAzureActiveDirectoryStateContext();
const switchAuth = () => {
setLoginPreference('');
};
const getCommandBarItems = () => {
const items = [{
ariaLabel: t(ResourceKeys.authentication.autheSelection.switchAuthType),
iconProps: { iconName: NAVIGATE_BACK },
key: 'switch',
onClick: switchAuth,
text: t(ResourceKeys.authentication.autheSelection.switchAuthType)
}];
return token ? [{
ariaLabel: t(ResourceKeys.authentication.azureActiveDirectory.command.logout),
iconProps: { iconName: 'Signout' },
key: 'signout',
onClick: logout,
text: t(ResourceKeys.authentication.azureActiveDirectory.command.logout)
}, ...items] :
[{
ariaLabel: t(ResourceKeys.authentication.azureActiveDirectory.command.login),
iconProps: { iconName: 'Signin' },
key: 'signin',
onClick: login,
text: t(ResourceKeys.authentication.azureActiveDirectory.command.login)
}, ...items];
};
return (
<CommandBar
items={getCommandBarItems()}
/>
);
};

Просмотреть файл

@ -0,0 +1,18 @@
/***********************************************************
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License
**********************************************************/
import * as React from 'react';
import { shallow } from 'enzyme';
import { FilterTextBox, FilterType } from './filterTextBox';
import { getInitialAzureActiveDirectoryState } from '../state';
import * as azureActiveDirectoryStateContext from '../context/azureActiveDirectoryStateContext';
describe('FilterTextBox', () => {
it('matches snapshot', () => {
jest.spyOn(azureActiveDirectoryStateContext, 'useAzureActiveDirectoryStateContext').mockReturnValue(
[getInitialAzureActiveDirectoryState(), azureActiveDirectoryStateContext.getInitialAzureActiveDirectoryOps()]);
const wrapper = shallow(<FilterTextBox filterType={FilterType.IoTHub} setFilteredList={jest.fn()}/>);
expect(wrapper).toMatchSnapshot();
});
});

Просмотреть файл

@ -0,0 +1,48 @@
/***********************************************************
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License
**********************************************************/
import * as React from 'react';
import { useTranslation } from 'react-i18next';
import { TextField } from '@fluentui/react';
import { useAzureActiveDirectoryStateContext } from '../context/azureActiveDirectoryStateContext';
import { AzureSubscription } from '../../../api/models/azureSubscription';
import { ResourceKeys } from '../../../../localization/resourceKeys';
import { IotHubDescription } from '../../../api/models/iotHubDescription';
export enum FilterType {
Subscription,
IoTHub
}
export interface FilterTextBoxPros {
filterType: FilterType;
setFilteredList: (listValue: AzureSubscription[] | IotHubDescription[]) => void;
}
export const FilterTextBox: React.FC<FilterTextBoxPros> = props => {
const { t } = useTranslation();
const [{ subscriptions, iotHubs }, ] = useAzureActiveDirectoryStateContext();
const [ filterValue, setFilterValue ] = React.useState('');
const onValueChange = (event: React.FormEvent<HTMLInputElement | HTMLTextAreaElement>, newValue?: string) => {
setFilterValue(newValue);
if (props.filterType === FilterType.IoTHub) {
props.setFilteredList(iotHubs.filter(item => item.name.toLowerCase().includes(newValue.toLowerCase())));
}
else {
props.setFilteredList(subscriptions.filter(item => item.displayName.toLowerCase().includes(newValue.toLowerCase())));
}
};
return (
<TextField
placeholder={t(ResourceKeys.authentication.azureActiveDirectory.filter.placeHolder)}
ariaLabel={t(ResourceKeys.authentication.azureActiveDirectory.filter.placeHolder)}
iconProps={{ iconName: 'Filter' }}
role="searchbox"
value={filterValue}
onChange={onValueChange}
/>
);
};

Просмотреть файл

@ -0,0 +1,29 @@
/***********************************************************
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License
**********************************************************/
import * as React from 'react';
import { shallow } from 'enzyme';
import { HubList } from './hubList';
import { getInitialAzureActiveDirectoryState } from '../state';
import * as azureActiveDirectoryStateContext from '../context/azureActiveDirectoryStateContext';
describe('HubList', () => {
it('matches snapshot when there are no list items', () => {
jest.spyOn(azureActiveDirectoryStateContext, 'useAzureActiveDirectoryStateContext').mockReturnValue(
[{...getInitialAzureActiveDirectoryState(), formState: 'idle'}, azureActiveDirectoryStateContext.getInitialAzureActiveDirectoryOps()]);
const wrapper = shallow(<HubList/>);
expect(wrapper).toMatchSnapshot();
});
it('matches snapshot when there are list items', () => {
jest.spyOn(azureActiveDirectoryStateContext, 'useAzureActiveDirectoryStateContext').mockReturnValue(
[{...getInitialAzureActiveDirectoryState(), iotHubs: [{
name: 'hub',
location: 'westus',
id: 'id'
}]}, azureActiveDirectoryStateContext.getInitialAzureActiveDirectoryOps()]);
const wrapper = shallow(<HubList/>);
expect(wrapper).toMatchSnapshot();
});
});

Просмотреть файл

@ -0,0 +1,80 @@
/***********************************************************
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License
**********************************************************/
import * as React from 'react';
import { useHistory } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { DetailsList, IColumn, Link, SelectionMode } from '@fluentui/react';
import { useAzureActiveDirectoryStateContext } from '../context/azureActiveDirectoryStateContext';
import { IotHubDescription } from '../../../api/models/iotHubDescription';
import { ResourceKeys } from '../../../../localization/resourceKeys';
import { LARGE_COLUMN_WIDTH } from '../../../constants/columnWidth';
import { getConnectionInfoFromConnectionString } from '../../../api/shared/utils';
import { ROUTE_PARTS } from '../../../constants/routes';
import { FilterTextBox, FilterType } from './filterTextBox';
export const HubList: React.FC = () => {
const { t } = useTranslation();
const history = useHistory();
const [{ formState, iotHubs, iotHubKey }, { getIotHubKey }] = useAzureActiveDirectoryStateContext();
const [ filteredHubs, setFilteredHubs ] = React.useState<IotHubDescription[]>([]);
React.useEffect(() => {
if (formState === 'keyPicked') { // only when connection string got picked successfully would navigate to device list view
const hostName = getConnectionInfoFromConnectionString(iotHubKey).hostName;
history.push(`/${ROUTE_PARTS.IOT_HUB}/${ROUTE_PARTS.HOST_NAME}/${hostName}/`);
}
}, [formState]);
React.useEffect(() => {
setFilteredHubs(iotHubs);
}, [iotHubs]);
const getHubKey = (hubId: string, hubName: string) => () => {
getIotHubKey(hubId, hubName);
};
const getColumns = (): IColumn[] => {
const columnProps = {
isResizable: true,
maxWidth: LARGE_COLUMN_WIDTH,
minWidth: 150
};
return [
{
...columnProps,
key: 'name',
name: t(ResourceKeys.authentication.azureActiveDirectory.hubList.columns.name),
onRender: (item: IotHubDescription) => (
<Link key={item.name} onClick={getHubKey(item.id, item.name)}>
{item.name}
</Link>)
},
{ ...columnProps,
key: 'id',
name: t(ResourceKeys.authentication.azureActiveDirectory.hubList.columns.location),
onRender: item => item.location
}];
};
const setFilteredList = (listValue: IotHubDescription[]) => {
setFilteredHubs(listValue);
};
return (
<>
<FilterTextBox
filterType={FilterType.IoTHub}
setFilteredList={setFilteredList}
/>
<DetailsList
items={filteredHubs}
columns={getColumns()}
selectionMode={SelectionMode.none}
/>
{formState === 'idle' && filteredHubs?.length === 0 && t(ResourceKeys.authentication.azureActiveDirectory.hubList.noItemText)}
</>
);
};

Просмотреть файл

@ -0,0 +1,8 @@
/***********************************************************
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License
**********************************************************/
.hub-selection-list {
margin-left: 20px;
margin-top: 10px;
}

Просмотреть файл

@ -0,0 +1,33 @@
/***********************************************************
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License
**********************************************************/
import * as React from 'react';
import { shallow } from 'enzyme';
import { HubSelection } from './hubSelection';
import { getInitialAzureActiveDirectoryState } from '../state';
import * as azureActiveDirectoryStateContext from '../context/azureActiveDirectoryStateContext';
describe('HubSelection', () => {
it('matches snapshot when page is loading', () => {
jest.spyOn(azureActiveDirectoryStateContext, 'useAzureActiveDirectoryStateContext').mockReturnValue(
[{...getInitialAzureActiveDirectoryState(), formState: 'working'}, azureActiveDirectoryStateContext.getInitialAzureActiveDirectoryOps()]);
const wrapper = shallow(<HubSelection/>);
expect(wrapper).toMatchSnapshot();
});
it('matches snapshot when token is present', () => {
jest.spyOn(azureActiveDirectoryStateContext, 'useAzureActiveDirectoryStateContext').mockReturnValue(
[{...getInitialAzureActiveDirectoryState(), token: 'token'}, azureActiveDirectoryStateContext.getInitialAzureActiveDirectoryOps()]);
const wrapper = shallow(<HubSelection/>);
expect(wrapper).toMatchSnapshot();
});
it('matches snapshot when hub list is shown', () => {
jest.spyOn(React, 'useState').mockImplementationOnce(() => React.useState(true));
jest.spyOn(azureActiveDirectoryStateContext, 'useAzureActiveDirectoryStateContext').mockReturnValue(
[getInitialAzureActiveDirectoryState(), azureActiveDirectoryStateContext.getInitialAzureActiveDirectoryOps()]);
const wrapper = shallow(<HubSelection/>);
expect(wrapper).toMatchSnapshot();
});
});

Просмотреть файл

@ -0,0 +1,52 @@
/***********************************************************
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License
**********************************************************/
import * as React from 'react';
import { MultiLineShimmer } from '../../../shared/components/multiLineShimmer';
import { useAzureActiveDirectoryStateContext } from '../context/azureActiveDirectoryStateContext';
import { AzureActiveDirectoryCommandBar } from './commandBar';
import { HubList } from './hubList';
import { SubscriptionList } from './subscrptionList';
import { BackButton } from './backButton';
import './hubSelection.scss';
export const HubSelection: React.FC = () => {
const [{ token, formState }, { getToken, getSubscriptions }] = useAzureActiveDirectoryStateContext();
const [ showHubList, setShowHubList ] = React.useState<boolean>(false);
const renderSubscriptionList = () => {
setShowHubList(false);
};
const renderHubList = () => {
setShowHubList(true);
};
React.useEffect(() => {
getToken();
}, []);
React.useEffect(() => {
if (token) {
getSubscriptions();
}
}, [token]);
return (
<>
<AzureActiveDirectoryCommandBar/>
{formState === 'working' ? <MultiLineShimmer/> : <div className="hub-selection-list">
{token && !showHubList &&
<SubscriptionList renderHubList={renderHubList}/>
}
{showHubList &&
<>
<BackButton backToSubscription={renderSubscriptionList}/>
<HubList/>
</>
}
</div>}
</>
);
};

Просмотреть файл

@ -0,0 +1,32 @@
/***********************************************************
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License
**********************************************************/
import * as React from 'react';
import { shallow } from 'enzyme';
import { SubscriptionList } from './subscrptionList';
import { getInitialAzureActiveDirectoryState } from '../state';
import * as azureActiveDirectoryStateContext from '../context/azureActiveDirectoryStateContext';
import { SubscriptionState } from '../../../api/models/azureSubscription';
describe('SubscriptionList', () => {
it('matches snapshot when there are no list items', () => {
jest.spyOn(azureActiveDirectoryStateContext, 'useAzureActiveDirectoryStateContext').mockReturnValue(
[{...getInitialAzureActiveDirectoryState(), formState: 'idle'}, azureActiveDirectoryStateContext.getInitialAzureActiveDirectoryOps()]);
const wrapper = shallow(<SubscriptionList renderHubList={jest.fn()}/>);
expect(wrapper).toMatchSnapshot();
});
it('matches snapshot when there are list items', () => {
jest.spyOn(azureActiveDirectoryStateContext, 'useAzureActiveDirectoryStateContext').mockReturnValue(
[{...getInitialAzureActiveDirectoryState(), subscriptions: [{
displayName: 'test',
id: 'id',
tenantId: 'id',
state: SubscriptionState.Disabled,
subscriptionId: 'id'
}]}, azureActiveDirectoryStateContext.getInitialAzureActiveDirectoryOps()]);
const wrapper = shallow(<SubscriptionList renderHubList={jest.fn()}/>);
expect(wrapper).toMatchSnapshot();
});
});

Просмотреть файл

@ -0,0 +1,80 @@
/***********************************************************
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License
**********************************************************/
import * as React from 'react';
import { useTranslation } from 'react-i18next';
import { DetailsList, IColumn, Link, SelectionMode } from '@fluentui/react';
import { FilterTextBox, FilterType } from './filterTextBox';
import { useAzureActiveDirectoryStateContext } from '../context/azureActiveDirectoryStateContext';
import { AzureSubscription } from '../../../api/models/azureSubscription';
import { ResourceKeys } from '../../../../localization/resourceKeys';
import { LARGE_COLUMN_WIDTH } from '../../../constants/columnWidth';
export interface SubscriptionListPros {
renderHubList: () => void;
}
export const SubscriptionList: React.FC<SubscriptionListPros> = props => {
const { t } = useTranslation();
const [{ formState, subscriptions }, { getIotHubs } ] = useAzureActiveDirectoryStateContext();
const [ filteredSubscriptions, setFilteredSubscriptions ] = React.useState<AzureSubscription[]>([]);
React.useEffect(() => {
setFilteredSubscriptions(subscriptions);
}, [subscriptions]);
const renderHubList = (subscriptionId: string) => () => {
getIotHubs(subscriptionId);
props.renderHubList();
};
const getColumns = (): IColumn[] => {
const columnProps = {
isResizable: true,
maxWidth: LARGE_COLUMN_WIDTH,
minWidth: 150
};
return [
{
...columnProps,
key: 'name',
name: t(ResourceKeys.authentication.azureActiveDirectory.subscriptionList.columns.name),
onRender: (item: AzureSubscription) => (
<Link key={item.displayName} onClick={renderHubList(item.subscriptionId)}>
{item.displayName}
</Link>)
},
{ ...columnProps,
key: 'id',
name: t(ResourceKeys.authentication.azureActiveDirectory.subscriptionList.columns.id),
onRender: item => item.subscriptionId
},
{
...columnProps,
key: 'state',
name: t(ResourceKeys.authentication.azureActiveDirectory.subscriptionList.columns.state),
onRender: item => item.state
}];
};
const setFilteredList = (listValue: AzureSubscription[]) => {
setFilteredSubscriptions(listValue);
};
return (
<>
<FilterTextBox
filterType={FilterType.Subscription}
setFilteredList={setFilteredList}
/>
<DetailsList
items={filteredSubscriptions}
columns={getColumns()}
selectionMode={SelectionMode.none}
/>
{formState === 'idle' && filteredSubscriptions?.length === 0 && t(ResourceKeys.authentication.azureActiveDirectory.subscriptionList.noItemText)}
</>
);
};

Просмотреть файл

@ -0,0 +1,29 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`AzureActiveDirectoryStateContextProvider matches snapshot 1`] = `
<ContextProvider
value={
Array [
Object {
"formState": "initialized",
"iotHubKey": undefined,
"iotHubs": Array [],
"subscriptions": Array [],
"token": undefined,
},
Object {
"getIotHubKey": [Function],
"getIotHubs": [Function],
"getSubscriptions": [Function],
"getToken": [Function],
"login": [Function],
"logout": [Function],
},
]
}
>
<span>
test
</span>
</ContextProvider>
`;

Просмотреть файл

@ -0,0 +1,23 @@
/***********************************************************
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License
**********************************************************/
import * as React from 'react';
import { getInitialAzureActiveDirectoryState, AzureActiveDirectoryStateInterface } from '../state';
import { AzureActiveDirectoryInterface } from './azureActiveDirectoryStateProvider';
export const getInitialAzureActiveDirectoryOps = (): AzureActiveDirectoryInterface => ({
getIotHubKey: () => undefined,
getIotHubs: () => undefined,
getSubscriptions: () => undefined,
getToken: () => undefined,
login: () => undefined,
logout: () => undefined
});
export const AzureActiveDirectoryStateContext = React.createContext<[AzureActiveDirectoryStateInterface, AzureActiveDirectoryInterface]>
([
getInitialAzureActiveDirectoryState(),
getInitialAzureActiveDirectoryOps()
]);
export const useAzureActiveDirectoryStateContext = () => React.useContext(AzureActiveDirectoryStateContext);

Просмотреть файл

@ -0,0 +1,16 @@
import * as React from 'react';
import { shallow } from 'enzyme';
import { AzureActiveDirectoryStateContextProvider } from './azureActiveDirectoryStateProvider';
import { getInitialAzureActiveDirectoryState } from '../state';
import * as AsyncSagaReducer from '../../../shared/hooks/useAsyncSagaReducer';
describe('AzureActiveDirectoryStateContextProvider', ()=> {
jest.spyOn(AsyncSagaReducer, 'useAsyncSagaReducer').mockReturnValue([getInitialAzureActiveDirectoryState(), jest.fn()]);
it('matches snapshot', () => {
const component = <AzureActiveDirectoryStateContextProvider>
<span>test</span>
</AzureActiveDirectoryStateContextProvider>;
expect(shallow(component)).toMatchSnapshot();
});
});

Просмотреть файл

@ -0,0 +1,35 @@
import * as React from 'react';
import { getInitialAzureActiveDirectoryState } from '../state';
import { azureActiveDirectoryReducer } from '../reducers';
import { useAsyncSagaReducer } from '../../../shared/hooks/useAsyncSagaReducer';
import { AzureActiveDirectoryStateContext } from './azureActiveDirectoryStateContext';
import { getUserProfileTokenAction, getSubscriptionListAction, loginAction, logoutAction, getIotHubsBySubscriptionAction, getIoTHubKeyAction } from '../actions';
import { azureActiveDirectorySaga } from '../saga';
export interface AzureActiveDirectoryInterface {
getIotHubKey(hubId: string, hubName: string): void;
getIotHubs(subscriptionId: string): void;
getSubscriptions(): void;
getToken(): void;
login(): void;
logout(): void;
}
export const AzureActiveDirectoryStateContextProvider: React.FC = props => {
const [state, dispatch] = useAsyncSagaReducer(azureActiveDirectoryReducer, azureActiveDirectorySaga, getInitialAzureActiveDirectoryState(), 'azureActiveDirectoryState');
const azureActiveDirectoryApi: AzureActiveDirectoryInterface = {
getIotHubKey: (hubId: string, hubName: string) => dispatch(getIoTHubKeyAction.started({hubId, hubName})),
getIotHubs: (subscriptionId: string) => dispatch(getIotHubsBySubscriptionAction.started(subscriptionId)),
getSubscriptions: () => dispatch(getSubscriptionListAction.started()),
getToken: () => dispatch(getUserProfileTokenAction.started()),
login: () => dispatch(loginAction.started()),
logout: () => dispatch(logoutAction.started())
};
return (
<AzureActiveDirectoryStateContext.Provider value={[state, azureActiveDirectoryApi]}>
{props.children}
</AzureActiveDirectoryStateContext.Provider>
);
};

Просмотреть файл

@ -0,0 +1,142 @@
/***********************************************************
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License
**********************************************************/
import { azureActiveDirectoryReducer } from './reducers';
import { loginAction, logoutAction, getIotHubsBySubscriptionAction, getSubscriptionListAction, getUserProfileTokenAction, getIoTHubKeyAction } from './actions';
import { getInitialAzureActiveDirectoryState } from './state';
import { SubscriptionState } from '../../api/models/azureSubscription';
describe('azureActiveDirectoryReducer', () => {
context('AAD/GET_IOTHUBS', () => {
it('handles AAD/GET_IOTHUBS_STARTED action', ()=> {
const action = getIotHubsBySubscriptionAction.started('subscriptionId');
expect(azureActiveDirectoryReducer(getInitialAzureActiveDirectoryState(), action).formState).toEqual('working');
});
it('handles AAD/GET_IOTHUBS_DONE action', ()=> {
const iotHub = {
name: 'hub',
location: 'westus',
id: 'id'
}
const action = getIotHubsBySubscriptionAction.done({params: 'subscriptionId', result: [iotHub]});
expect(azureActiveDirectoryReducer(getInitialAzureActiveDirectoryState(), action)).toEqual({
...getInitialAzureActiveDirectoryState(),
formState: 'idle',
iotHubs: [iotHub]
});
});
it('handles AAD/GET_IOTHUBS_FAILED action', ()=> {
const action = getIotHubsBySubscriptionAction.failed({params: 'subscriptionId', error: {}});
expect(azureActiveDirectoryReducer(getInitialAzureActiveDirectoryState(), action).formState).toEqual('failed');
});
});
context('AAD/GET_SUBSCRIPTIONS', () => {
it('handles AAD/GET_SUBSCRIPTIONS_STARTED action', ()=> {
const action = getSubscriptionListAction.started();
expect(azureActiveDirectoryReducer(getInitialAzureActiveDirectoryState(), action).formState).toEqual('working');
});
it('handles AAD/GET_SUBSCRIPTIONS_DONE action', ()=> {
const sub = {
displayName: 'test',
id: 'id',
tenantId: 'id',
state: SubscriptionState.Disabled,
subscriptionId: 'id'
}
const action = getSubscriptionListAction.done({result: [sub]});
expect(azureActiveDirectoryReducer(getInitialAzureActiveDirectoryState(), action)).toEqual({
...getInitialAzureActiveDirectoryState(),
formState: 'idle',
subscriptions: [sub]
});
});
it('handles AAD/GET_SUBSCRIPTIONS_FAILED action', ()=> {
const action = getSubscriptionListAction.failed({error: {}});
expect(azureActiveDirectoryReducer(getInitialAzureActiveDirectoryState(), action).formState).toEqual('failed');
});
});
context('AAD/GET_TOKEN', () => {
it('handles AAD/GET_TOKEN_STARTED action', ()=> {
const action = getUserProfileTokenAction.started();
expect(azureActiveDirectoryReducer(getInitialAzureActiveDirectoryState(), action).formState).toEqual('working');
});
it('handles AAD/GET_TOKEN_DONE action', ()=> {
const action = getUserProfileTokenAction.done({result: 'token'});
expect(azureActiveDirectoryReducer(getInitialAzureActiveDirectoryState(), action)).toEqual({
...getInitialAzureActiveDirectoryState(),
formState: 'idle',
token: 'token'
});
});
it('handles AAD/GET_TOKEN_FAILED action', ()=> {
const action = getUserProfileTokenAction.failed({error: {}});
expect(azureActiveDirectoryReducer(getInitialAzureActiveDirectoryState(), action).formState).toEqual('failed');
});
});
context('AAD/GET_HUBKEY', () => {
const params = {hubId: 'hubid', hubName: 'test'};
it('handles AAD/GET_HUBKEY_STARTED action', ()=> {
const action = getIoTHubKeyAction.started(params);
expect(azureActiveDirectoryReducer(getInitialAzureActiveDirectoryState(), action).formState).toEqual('working');
});
it('handles AAD/GET_HUBKEY_DONE action', ()=> {
const action = getIoTHubKeyAction.done({params, result: 'key'});
expect(azureActiveDirectoryReducer(getInitialAzureActiveDirectoryState(), action)).toEqual({
...getInitialAzureActiveDirectoryState(),
formState: 'keyPicked',
iotHubKey: 'key'
});
});
it('handles AAD/GET_HUBKEY_FAILED action', ()=> {
const action = getIoTHubKeyAction.failed({params, error: {}});
expect(azureActiveDirectoryReducer(getInitialAzureActiveDirectoryState(), action).formState).toEqual('failed');
});
});
context('AAD/LOGIN', () => {
it('handles AAD/LOGIN_STARTED action', ()=> {
const action = loginAction.started();
expect(azureActiveDirectoryReducer(getInitialAzureActiveDirectoryState(), action).formState).toEqual('working');
});
it('handles AAD/LOGIN_DONE action', ()=> {
const action = loginAction.done({});
expect(azureActiveDirectoryReducer(getInitialAzureActiveDirectoryState(), action).formState).toEqual('idle');
});
it('handles AAD/LOGIN_FAILED action', ()=> {
const action = loginAction.failed({error: {}});
expect(azureActiveDirectoryReducer(getInitialAzureActiveDirectoryState(), action).formState).toEqual('failed');
});
});
context('AAD/LOGOUT', () => {
it('handles AAD/LOGOUT_STARTED action', ()=> {
const action = logoutAction.started();
expect(azureActiveDirectoryReducer(getInitialAzureActiveDirectoryState(), action).formState).toEqual('working');
});
it('handles AAD/LOGOUT_DONE action', ()=> {
const action = logoutAction.done({});
expect(azureActiveDirectoryReducer(getInitialAzureActiveDirectoryState(), action).formState).toEqual('idle');
});
it('handles AAD/LOGOUT_FAILED action', ()=> {
const action = logoutAction.failed({error: {}});
expect(azureActiveDirectoryReducer(getInitialAzureActiveDirectoryState(), action).formState).toEqual('failed');
});
});
});

Просмотреть файл

@ -0,0 +1,123 @@
/***********************************************************
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License
**********************************************************/
import { reducerWithInitialState } from 'typescript-fsa-reducers';
import { getUserProfileTokenAction, loginAction, logoutAction, getSubscriptionListAction, getIotHubsBySubscriptionAction, getIoTHubKeyAction, GetIotHubKeyActionParmas } from './actions';
import { getInitialAzureActiveDirectoryState, AzureActiveDirectoryStateInterface } from './state';
import { AzureSubscription } from '../../api/models/azureSubscription';
import { IotHubDescription } from '../../api/models/iotHubDescription';
export const azureActiveDirectoryReducer = reducerWithInitialState<AzureActiveDirectoryStateInterface>(getInitialAzureActiveDirectoryState())
.case(getUserProfileTokenAction.started, (state: AzureActiveDirectoryStateInterface) => {
return {
...state,
formState: 'working'
};
})
.case(getUserProfileTokenAction.done, (state: AzureActiveDirectoryStateInterface, payload: {params: void, result: string}) => {
return {
...state,
formState: 'idle',
token: payload.result
};
})
.case(getUserProfileTokenAction.failed, (state: AzureActiveDirectoryStateInterface) => {
return {
...state,
formState: 'failed'
};
})
.case(loginAction.started, (state: AzureActiveDirectoryStateInterface) => {
return {
...state,
formState: 'working',
};
})
.case(loginAction.done, (state: AzureActiveDirectoryStateInterface) => {
return {
...state,
formState: 'idle',
};
})
.case(loginAction.failed, (state: AzureActiveDirectoryStateInterface) => {
return {
...state,
formState: 'failed'
};
})
.case(logoutAction.started, (state: AzureActiveDirectoryStateInterface) => {
return {
...state,
formState: 'working',
};
})
.case(logoutAction.done, (state: AzureActiveDirectoryStateInterface) => {
return {
...state,
formState: 'idle',
};
})
.case(logoutAction.failed, (state: AzureActiveDirectoryStateInterface) => {
return {
...state,
formState: 'failed'
};
})
.case(getSubscriptionListAction.started, (state: AzureActiveDirectoryStateInterface) => {
return {
...state,
formState: 'working'
};
})
.case(getSubscriptionListAction.done, (state: AzureActiveDirectoryStateInterface, payload: {result: AzureSubscription[]}) => {
return {
...state,
formState: 'idle',
subscriptions: payload.result
};
})
.case(getSubscriptionListAction.failed, (state: AzureActiveDirectoryStateInterface) => {
return {
...state,
formState: 'failed'
};
})
.case(getIotHubsBySubscriptionAction.started, (state: AzureActiveDirectoryStateInterface) => {
return {
...state,
formState: 'working'
};
})
.case(getIotHubsBySubscriptionAction.done, (state: AzureActiveDirectoryStateInterface, payload: {params: string, result: IotHubDescription[]}) => {
return {
...state,
formState: 'idle',
iotHubs: payload.result
};
})
.case(getIotHubsBySubscriptionAction.failed, (state: AzureActiveDirectoryStateInterface) => {
return {
...state,
formState: 'failed'
};
})
.case(getIoTHubKeyAction.started, (state: AzureActiveDirectoryStateInterface) => {
return {
...state,
formState: 'working'
};
})
.case(getIoTHubKeyAction.done, (state: AzureActiveDirectoryStateInterface, payload: {params: GetIotHubKeyActionParmas, result: string}) => {
return {
...state,
formState: 'keyPicked',
iotHubKey: payload.result
};
})
.case(getIoTHubKeyAction.failed, (state: AzureActiveDirectoryStateInterface) => {
return {
...state,
formState: 'failed'
};
});

Просмотреть файл

@ -0,0 +1,21 @@
/***********************************************************
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License
**********************************************************/
import { takeLatest } from 'redux-saga/effects';
import { getUserProfileTokenAction, loginAction, logoutAction, getSubscriptionListAction, getIotHubsBySubscriptionAction, getIoTHubKeyAction } from './actions';
import { getTokenSaga } from './sagas/getUserProfileTokenSaga';
import { loginSaga } from './sagas/loginSaga';
import { logoutSaga } from './sagas/logoutSaga';
import { getSubscriptionListSaga } from './sagas/getSubscriptionListSaga';
import { getIotHubListSaga } from './sagas/getIotHubListSaga';
import { getIotHubKeySaga } from './sagas/getIotHubKeySaga';
export function* azureActiveDirectorySaga() {
yield takeLatest(getUserProfileTokenAction.started, getTokenSaga);
yield takeLatest(loginAction.started, loginSaga);
yield takeLatest(logoutAction.started, logoutSaga);
yield takeLatest(getSubscriptionListAction.started, getSubscriptionListSaga);
yield takeLatest(getIotHubsBySubscriptionAction.started, getIotHubListSaga);
yield takeLatest(getIoTHubKeyAction.started, getIotHubKeySaga);
}

Просмотреть файл

@ -0,0 +1,84 @@
/***********************************************************
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License
**********************************************************/
import { cloneableGenerator } from '@redux-saga/testing-utils';
import { put, call } from 'redux-saga/effects';
import { getIoTHubKeyAction } from '../actions';
import { formatConnectionString, getIotHubKeySaga } from './getIotHubKeySaga';
import * as IotHubService from '../../../api/services/iotHubService';
import * as AuthenticationService from '../../../api/services/authenticationService';
import { raiseNotificationToast } from '../../../notifications/components/notificationToast';
import { ResourceKeys } from '../../../../localization/resourceKeys';
import { NotificationType } from '../../../api/models/notification';
describe('getIotHubKeySaga', () => {
const mockGetProfileToken = jest.spyOn(AuthenticationService, 'getProfileToken').mockImplementationOnce(() => {
return 'token';
});
const mockGetIotKeys = jest.spyOn(IotHubService, 'getIotHubKeys').mockImplementationOnce(() => {
return [];
});
const params = {hubId: 'hubid', hubName: 'test'};
const authRules = [{
keyName: 'iothubowner',
primaryKey: 'key12345',
rights: 'RegistryWrite, ServiceConnect, DeviceConnect'
}]
const sagaGenerator = cloneableGenerator(getIotHubKeySaga)(getIoTHubKeyAction.started(params));
it('calls getProfileToken', () => {
expect(sagaGenerator.next()).toEqual({
done: false,
value: call(mockGetProfileToken)
});
});
it('calls getIotHubKeys', () => {
expect(sagaGenerator.next('token')).toEqual({
done: false,
value: call(mockGetIotKeys, {
authorizationToken: 'token',
endpoint: undefined,
hubId: 'hubid',
})
});
});
it('puts the successful action', () => {
const success = sagaGenerator.clone();
expect(success.next(authRules)).toEqual({
done: false,
value: put(getIoTHubKeyAction.done({
params,
result: formatConnectionString(params.hubName, authRules[0])
}))
});
expect(success.next().done).toEqual(true);
});
it('fails on error', () => {
const failure = sagaGenerator.clone();
const error = { message: 'error' };
expect(failure.throw(error)).toEqual({
done: false,
value: call(raiseNotificationToast, {
text: {
translationKey: ResourceKeys.authentication.azureActiveDirectory.notification.getIotHubKeyError,
translationOptions: {error: 'error'}
},
type: NotificationType.error
})
});
expect(failure.next(error)).toEqual({
done: false,
value: put(getIoTHubKeyAction.failed({
error,
params
}))
});
expect(failure.next().done).toEqual(true);
});
});

Просмотреть файл

@ -0,0 +1,59 @@
/***********************************************************
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License
**********************************************************/
import { call, put } from 'redux-saga/effects';
import { Action } from 'typescript-fsa';
import { raiseNotificationToast } from '../../../notifications/components/notificationToast';
import { getIotHubKeys } from '../../../api/services/iotHubService';
import { getIoTHubKeyAction, GetIotHubKeyActionParmas } from '../actions';
import { ResourceKeys } from '../../../../localization/resourceKeys';
import { NotificationType } from '../../../api/models/notification';
import { appConfig } from '../../../../appConfig/appConfig';
import { getProfileToken } from '../../../api/services/authenticationService';
import { CONNECTION_STRING_THROUGH_AAD } from '../../../constants/browserStorage';
import { SharedAccessSignatureAuthorizationRule, AccessRights } from '../../../api/models/sharedAccessSignatureAuthorizationRule';
export function* getIotHubKeySaga(action: Action<GetIotHubKeyActionParmas>) {
try {
const authorizationToken: string = yield call(getProfileToken); // always get a fresh token to prevent expiration
const results: SharedAccessSignatureAuthorizationRule[] = yield call(getIotHubKeys, {
authorizationToken,
endpoint: appConfig.azureResourceManagementEndpoint,
hubId: action.payload.hubId
});
const filtered = results.filter(element => filterKey(element.rights));
if (!filtered || !filtered.length) {
throw new Error();
}
const result = formatConnectionString(action.payload.hubName, filtered[0]);
localStorage.setItem(CONNECTION_STRING_THROUGH_AAD, result);
yield put(getIoTHubKeyAction.done({params: action.payload, result}));
}
catch (error) {
yield call(raiseNotificationToast, {
text: {
translationKey: ResourceKeys.authentication.azureActiveDirectory.notification.getIotHubKeyError,
translationOptions: {
error: error?.message || error
}
},
type: NotificationType.error
});
yield put(getIoTHubKeyAction.failed({params: action.payload, error}));
}
}
export const filterKey = (rights: string) => {
const rightArray = rights.split(',').map(element => element.trim());
return rightArray.includes(AccessRights.DeviceConnect) &&
rightArray.includes(AccessRights.RegistryWrite) &&
rightArray.includes(AccessRights.ServiceConnect);
};
export const formatConnectionString = (hubName: string, authRule: SharedAccessSignatureAuthorizationRule) => {
return `HostName=${hubName}.azure-devices.net;SharedAccessKeyName=${authRule.keyName};SharedAccessKey=${authRule.primaryKey}`;
};

Просмотреть файл

@ -0,0 +1,78 @@
/***********************************************************
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License
**********************************************************/
import { cloneableGenerator } from '@redux-saga/testing-utils';
import { put, call } from 'redux-saga/effects';
import { getIotHubsBySubscriptionAction } from '../actions';
import { getIotHubListSaga } from './getIotHubListSaga';
import * as IotHubService from '../../../api/services/iotHubService';
import * as AuthenticationService from '../../../api/services/authenticationService';
import { raiseNotificationToast } from '../../../notifications/components/notificationToast';
import { ResourceKeys } from '../../../../localization/resourceKeys';
import { NotificationType } from '../../../api/models/notification';
describe('getIotHubListSaga', () => {
const mockGetProfileToken = jest.spyOn(AuthenticationService, 'getProfileToken').mockImplementationOnce(() => {
return 'token';
});
const mockGetIotHubs = jest.spyOn(IotHubService, 'getIotHubsBySubscription').mockImplementationOnce(() => {
return [];
});
const sagaGenerator = cloneableGenerator(getIotHubListSaga)(getIotHubsBySubscriptionAction.started('subscriptionId'));
it('calls getProfileToken', () => {
expect(sagaGenerator.next()).toEqual({
done: false,
value: call(mockGetProfileToken)
});
});
it('calls getIotHubsBySubscription', () => {
expect(sagaGenerator.next('token')).toEqual({
done: false,
value: call(mockGetIotHubs, {
authorizationToken: 'token',
endpoint: undefined,
subscriptionId: 'subscriptionId',
})
});
});
it('puts the successful action', () => {
const success = sagaGenerator.clone();
expect(success.next([])).toEqual({
done: false,
value: put(getIotHubsBySubscriptionAction.done({
params: 'subscriptionId',
result: []
}))
});
expect(success.next().done).toEqual(true);
});
it('fails on error', () => {
const failure = sagaGenerator.clone();
const error = { message: 'error' };
expect(failure.throw(error)).toEqual({
done: false,
value: call(raiseNotificationToast, {
text: {
translationKey: ResourceKeys.authentication.azureActiveDirectory.notification.getIotHubListError,
translationOptions: {error: 'error'}
},
type: NotificationType.error
})
});
expect(failure.next(error)).toEqual({
done: false,
value: put(getIotHubsBySubscriptionAction.failed({
error,
params: 'subscriptionId',
}))
});
expect(failure.next().done).toEqual(true);
});
});

Просмотреть файл

@ -0,0 +1,39 @@
/***********************************************************
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License
**********************************************************/
import { call, put } from 'redux-saga/effects';
import { Action } from 'typescript-fsa';
import { raiseNotificationToast } from '../../../notifications/components/notificationToast';
import { getIotHubsBySubscription } from '../../../api/services/iotHubService';
import { getIotHubsBySubscriptionAction } from '../actions';
import { ResourceKeys } from '../../../../localization/resourceKeys';
import { NotificationType } from '../../../api/models/notification';
import { IotHubDescription } from '../../../api/models/iotHubDescription';
import { appConfig } from '../../../../appConfig/appConfig';
import { getProfileToken } from '../../../api/services/authenticationService';
export function* getIotHubListSaga(action: Action<string>) {
try {
const authorizationToken: string = yield call(getProfileToken); // always get a fresh token to prevent expiration
const result: IotHubDescription[] = yield call(getIotHubsBySubscription, {
authorizationToken,
endpoint: appConfig.azureResourceManagementEndpoint,
subscriptionId: action.payload
});
yield put(getIotHubsBySubscriptionAction.done({params: action.payload, result: result.sort((a, b) => a.name.toLowerCase() > b.name.toLowerCase() ? 1 : -1)}));
}
catch (error) {
yield call(raiseNotificationToast, {
text: {
translationKey: ResourceKeys.authentication.azureActiveDirectory.notification.getIotHubListError,
translationOptions: {
error: error?.message || error
}
},
type: NotificationType.error
});
yield put(getIotHubsBySubscriptionAction.failed({params: action.payload, error}));
}
}

Просмотреть файл

@ -0,0 +1,75 @@
/***********************************************************
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License
**********************************************************/
import { cloneableGenerator } from '@redux-saga/testing-utils';
import { put, call } from 'redux-saga/effects';
import { getSubscriptionListAction } from '../actions';
import { getSubscriptionListSaga } from './getSubscriptionListSaga';
import * as AzureSubscriptionService from '../../../api/services/azureSubscriptionService';
import * as AuthenticationService from '../../../api/services/authenticationService';
import { raiseNotificationToast } from '../../../notifications/components/notificationToast';
import { ResourceKeys } from '../../../../localization/resourceKeys';
import { NotificationType } from '../../../api/models/notification';
describe('getSubscriptionListSaga', () => {
const mockGetProfileToken = jest.spyOn(AuthenticationService, 'getProfileToken').mockImplementationOnce(() => {
return 'token';
});
const mockGetIubscriptions = jest.spyOn(AzureSubscriptionService, 'getAzureSubscriptions').mockImplementationOnce(() => {
return [];
});
const sagaGenerator = cloneableGenerator(getSubscriptionListSaga)(getSubscriptionListAction.started());
it('calls getProfileToken', () => {
expect(sagaGenerator.next()).toEqual({
done: false,
value: call(mockGetProfileToken)
});
});
it('calls getIotHubsBySubscription', () => {
expect(sagaGenerator.next('token')).toEqual({
done: false,
value: call(mockGetIubscriptions, {
authorizationToken: 'token',
endpoint: undefined
})
});
});
it('puts the successful action', () => {
const success = sagaGenerator.clone();
expect(success.next([])).toEqual({
done: false,
value: put(getSubscriptionListAction.done({
result: []
}))
});
expect(success.next().done).toEqual(true);
});
it('fails on error', () => {
const failure = sagaGenerator.clone();
const error = { code: -1 };
expect(failure.throw(error)).toEqual({
done: false,
value: call(raiseNotificationToast, {
text: {
translationKey: ResourceKeys.authentication.azureActiveDirectory.notification.getsubscriptionListError,
translationOptions: {error}
},
type: NotificationType.error
})
});
expect(failure.next(error)).toEqual({
done: false,
value: put(getSubscriptionListAction.failed({
error
}))
});
expect(failure.next().done).toEqual(true);
});
});

Просмотреть файл

@ -0,0 +1,37 @@
/***********************************************************
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License
**********************************************************/
import { call, put } from 'redux-saga/effects';
import { raiseNotificationToast } from '../../../notifications/components/notificationToast';
import { getAzureSubscriptions } from '../../../api/services/azureSubscriptionService';
import { getSubscriptionListAction } from '../actions';
import { ResourceKeys } from '../../../../localization/resourceKeys';
import { NotificationType } from '../../../api/models/notification';
import { AzureSubscription } from '../../../api/models/azureSubscription';
import { appConfig } from '../../../../appConfig/appConfig';
import { getProfileToken } from '../../../api/services/authenticationService';
export function* getSubscriptionListSaga() {
try {
const authorizationToken: string = yield call(getProfileToken); // always get a fresh token to prevent expiration
const result: AzureSubscription[] = yield call(getAzureSubscriptions, {
authorizationToken,
endpoint: appConfig.azureResourceManagementEndpoint
});
yield put(getSubscriptionListAction.done({result: result.sort((a, b) => a.displayName.toLowerCase() > b.displayName.toLowerCase() ? 1 : -1)}));
}
catch (error) {
yield call(raiseNotificationToast, {
text: {
translationKey: ResourceKeys.authentication.azureActiveDirectory.notification.getsubscriptionListError,
translationOptions: {
error: error?.message || error
}
},
type: NotificationType.error
});
yield put(getSubscriptionListAction.failed({error}));
}
}

Просмотреть файл

@ -0,0 +1,47 @@
/***********************************************************
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License
**********************************************************/
import { cloneableGenerator } from '@redux-saga/testing-utils';
import { put, call } from 'redux-saga/effects';
import { getUserProfileTokenAction } from '../actions';
import { getTokenSaga } from './getUserProfileTokenSaga';
import * as AuthenticationService from '../../../api/services/authenticationService';
describe('getTokenSaga', () => {
const mockGetProfileToken = jest.spyOn(AuthenticationService, 'getProfileToken').mockImplementationOnce(() => {
return 'token';
});
const sagaGenerator = cloneableGenerator(getTokenSaga)();
it('calls getProfileToken', () => {
expect(sagaGenerator.next()).toEqual({
done: false,
value: call(mockGetProfileToken)
});
});
it('puts the successful action', () => {
const success = sagaGenerator.clone();
expect(success.next('token')).toEqual({
done: false,
value: put(getUserProfileTokenAction.done({
result: 'token'
}))
});
expect(success.next().done).toEqual(true);
});
it('fails on error', () => {
const failure = sagaGenerator.clone();
const error = { code: -1 };
expect(failure.throw(error)).toEqual({
done: false,
value: put(getUserProfileTokenAction.failed({
error
}))
});
expect(failure.next().done).toEqual(true);
});
});

Просмотреть файл

@ -0,0 +1,17 @@
/***********************************************************
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License
**********************************************************/
import { call, put } from 'redux-saga/effects';
import { getProfileToken } from '../../../api/services/authenticationService';
import { getUserProfileTokenAction } from '../actions';
export function* getTokenSaga() {
try {
const token: string = yield call(getProfileToken);
yield put(getUserProfileTokenAction.done({result: token}));
}
catch (error) {
yield put(getUserProfileTokenAction.failed({error}));
}
}

Просмотреть файл

@ -0,0 +1,59 @@
/***********************************************************
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License
**********************************************************/
import { cloneableGenerator } from '@redux-saga/testing-utils';
import { put, call } from 'redux-saga/effects';
import { loginAction } from '../actions';
import { loginSaga } from './loginSaga';
import * as AuthenticationService from '../../../api/services/authenticationService';
import { raiseNotificationToast } from '../../../notifications/components/notificationToast';
import { ResourceKeys } from '../../../../localization/resourceKeys';
import { NotificationType } from '../../../api/models/notification';
describe('loginSaga', () => {
const mockLogin = jest.spyOn(AuthenticationService, 'login').mockImplementationOnce(() => {
return null;
});
const sagaGenerator = cloneableGenerator(loginSaga)();
it('calls login', () => {
expect(sagaGenerator.next()).toEqual({
done: false,
value: call(mockLogin)
});
});
it('puts the successful action', () => {
const success = sagaGenerator.clone();
expect(success.next()).toEqual({
done: false,
value: put(loginAction.done({}))
});
expect(success.next().done).toEqual(true);
});
it('fails on error', () => {
const failure = sagaGenerator.clone();
const error = { code: -1 };
expect(failure.throw(error)).toEqual({
done: false,
value: call(raiseNotificationToast, {
text: {
translationKey: ResourceKeys.authentication.azureActiveDirectory.notification.loginError,
},
type: NotificationType.error
})
});
expect(failure.next(error)).toEqual({
done: false,
value: put(loginAction.failed({
error
}))
});
expect(failure.next().done).toEqual(true);
});
});

Просмотреть файл

@ -0,0 +1,27 @@
/***********************************************************
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License
**********************************************************/
import { call, put } from 'redux-saga/effects';
import { raiseNotificationToast } from '../../../notifications/components/notificationToast';
import { login } from '../../../api/services/authenticationService';
import { loginAction } from '../actions';
import { ResourceKeys } from '../../../../localization/resourceKeys';
import { NotificationType } from '../../../api/models/notification';
export function* loginSaga() {
try {
yield call(login);
yield put(loginAction.done({}));
}
catch (error) {
yield call(raiseNotificationToast, {
text: {
translationKey: ResourceKeys.authentication.azureActiveDirectory.notification.loginError
},
type: NotificationType.error
});
yield put(loginAction.failed({error}));
}
}

Просмотреть файл

@ -0,0 +1,59 @@
/***********************************************************
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License
**********************************************************/
import { cloneableGenerator } from '@redux-saga/testing-utils';
import { put, call } from 'redux-saga/effects';
import { logoutAction } from '../actions';
import { logoutSaga } from './logoutSaga';
import * as AuthenticationService from '../../../api/services/authenticationService';
import { raiseNotificationToast } from '../../../notifications/components/notificationToast';
import { ResourceKeys } from '../../../../localization/resourceKeys';
import { NotificationType } from '../../../api/models/notification';
describe('logoutSaga', () => {
const mockLogout = jest.spyOn(AuthenticationService, 'logout').mockImplementationOnce(() => {
return null;
});
const sagaGenerator = cloneableGenerator(logoutSaga)();
it('calls logout', () => {
expect(sagaGenerator.next()).toEqual({
done: false,
value: call(mockLogout)
});
});
it('puts the successful action', () => {
const success = sagaGenerator.clone();
expect(success.next()).toEqual({
done: false,
value: put(logoutAction.done({}))
});
expect(success.next().done).toEqual(true);
});
it('fails on error', () => {
const failure = sagaGenerator.clone();
const error = { code: -1 };
expect(failure.throw(error)).toEqual({
done: false,
value: call(raiseNotificationToast, {
text: {
translationKey: ResourceKeys.authentication.azureActiveDirectory.notification.logoutError,
},
type: NotificationType.error
})
});
expect(failure.next(error)).toEqual({
done: false,
value: put(logoutAction.failed({
error
}))
});
expect(failure.next().done).toEqual(true);
});
});

Просмотреть файл

@ -0,0 +1,27 @@
/***********************************************************
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License
**********************************************************/
import { call, put } from 'redux-saga/effects';
import { raiseNotificationToast } from '../../../notifications/components/notificationToast';
import { logout } from '../../../api/services/authenticationService';
import { logoutAction } from '../actions';
import { ResourceKeys } from '../../../../localization/resourceKeys';
import { NotificationType } from '../../../api/models/notification';
export function* logoutSaga() {
try {
yield call(logout);
yield put(logoutAction.done({}));
}
catch (error) {
yield call(raiseNotificationToast, {
text: {
translationKey: ResourceKeys.authentication.azureActiveDirectory.notification.logoutError
},
type: NotificationType.error
});
yield put(logoutAction.failed({error}));
}
}

Просмотреть файл

@ -0,0 +1,21 @@
/***********************************************************
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License
**********************************************************/
import { IotHubDescription } from '../../api/models/iotHubDescription';
import { AzureSubscription } from '../../api/models/azureSubscription';
export interface AzureActiveDirectoryStateInterface {
formState: 'initialized' | 'working' | 'failed' | 'idle' | 'keyPicked';
iotHubKey: string;
iotHubs: IotHubDescription[];
subscriptions: AzureSubscription[];
token: string;
}
export const getInitialAzureActiveDirectoryState = (): AzureActiveDirectoryStateInterface => ({
formState: 'initialized',
iotHubKey: undefined,
iotHubs: [],
subscriptions: [],
token: undefined
});

Просмотреть файл

@ -0,0 +1,58 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`AuthenticationSelection matches snapshot 1`] = `
<div
className="auth-slection-container"
>
<Stack
tokens={
Object {
"childrenGap": 10,
}
}
>
<h3
aria-level={1}
role="heading"
>
authentication.autheSelection.header
</h3>
<span>
authentication.autheSelection.subText
</span>
<Stack
horizontal={true}
tokens={
Object {
"childrenGap": 80,
}
}
>
<CustomizedCompoundButton
className="auth-selection-tile"
iconProps={
Object {
"iconName": "Permissions",
}
}
onClick={[Function]}
primary={true}
>
authentication.autheSelection.selection.connectionString
</CustomizedCompoundButton>
<CustomizedCompoundButton
className="auth-selection-tile"
iconProps={
Object {
"iconName": "AADLogo",
}
}
onClick={[Function]}
secondaryText="authentication.autheSelection.selection.comingSoon"
>
authentication.autheSelection.selection.azureActiveDirectory
</CustomizedCompoundButton>
</Stack>
</Stack>
</div>
`;

Просмотреть файл

@ -0,0 +1,29 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`AuthenticationView matches snapshot when page is loading 1`] = `
<Fragment>
<MultiLineShimmer />
</Fragment>
`;
exports[`AuthenticationView matches snapshot when preference is aad 1`] = `
<Fragment>
<AzureActiveDirectoryStateContextProvider>
<HubSelection />
</AzureActiveDirectoryStateContextProvider>
</Fragment>
`;
exports[`AuthenticationView matches snapshot when preference is connection string 1`] = `
<Fragment>
<ConnectionStringStateContextProvider>
<ConnectionStringsView />
</ConnectionStringStateContextProvider>
</Fragment>
`;
exports[`AuthenticationView matches snapshot when preference not set 1`] = `
<Fragment>
<AuthenticationSelection />
</Fragment>
`;

Просмотреть файл

@ -0,0 +1,13 @@
/***********************************************************
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License
**********************************************************/
.auth-slection-container {
display: flex;
padding-top: 45px;
justify-content: center;
.auth-selection-tile {
padding: 50px;
margin-top: 100px;
}
}

Просмотреть файл

@ -0,0 +1,35 @@
/***********************************************************
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License
**********************************************************/
import * as React from 'react';
import { shallow } from 'enzyme';
import { CompoundButton } from '@fluentui/react';
import { AuthenticationSelection } from './authenticationSelection';
import { AuthenticationMethodPreference, getInitialAuthenticationState } from '../state';
import * as authenticationStateContext from '../context/authenticationStateContext';
describe('AuthenticationSelection', () => {
it('matches snapshot', () => {
jest.spyOn(authenticationStateContext, 'useAuthenticationStateContext').mockReturnValue(
[getInitialAuthenticationState(), authenticationStateContext.getInitialAuthenticationOps()]);
const wrapper = shallow(<AuthenticationSelection/>);
expect(wrapper).toMatchSnapshot();
});
it('calls api respectively', () => {
const setLoginPreference = jest.fn();
jest.spyOn(authenticationStateContext, 'useAuthenticationStateContext').mockReturnValue(
[getInitialAuthenticationState(), {...authenticationStateContext.getInitialAuthenticationOps(), setLoginPreference}]);
const wrapper = shallow(<AuthenticationSelection/>);
wrapper.find(CompoundButton).get(0).props.onClick(undefined);
wrapper.update();
expect(setLoginPreference).toHaveBeenCalledWith(AuthenticationMethodPreference.ConnectionString);
wrapper.find(CompoundButton).get(1).props.onClick(undefined);
wrapper.update();
expect(setLoginPreference).toHaveBeenCalledWith(AuthenticationMethodPreference.AzureAD);
});
});

Просмотреть файл

@ -0,0 +1,51 @@
/***********************************************************
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License
**********************************************************/
import * as React from 'react';
import { CompoundButton, Stack } from '@fluentui/react';
import { useTranslation } from 'react-i18next';
import { useAuthenticationStateContext } from '../context/authenticationStateContext';
import { AuthenticationMethodPreference } from '../state';
import { ResourceKeys } from '../../../localization/resourceKeys';
import './authenticationSelection.scss';
export const AuthenticationSelection: React.FC = () => {
const [, { setLoginPreference }] = useAuthenticationStateContext();
const { t } = useTranslation();
const connectViaConnectionString = () => {
setLoginPreference(AuthenticationMethodPreference.ConnectionString);
};
const loginViaAad = () => {
setLoginPreference(AuthenticationMethodPreference.AzureAD);
};
return (
<div className="auth-slection-container">
<Stack tokens={{ childrenGap: 10 }}>
<h3 role="heading" aria-level={1}>{t(ResourceKeys.authentication.autheSelection.header)}</h3>
<span>{t(ResourceKeys.authentication.autheSelection.subText)}</span>
<Stack tokens={{ childrenGap: 80 }} horizontal={true} >
<CompoundButton
primary={true}
iconProps={{ iconName: 'Permissions' }}
onClick={connectViaConnectionString}
className="auth-selection-tile"
>
{t(ResourceKeys.authentication.autheSelection.selection.connectionString)}
</CompoundButton>
<CompoundButton
iconProps={{ iconName: 'AADLogo' }}
className="auth-selection-tile"
onClick={loginViaAad}
secondaryText={t(ResourceKeys.authentication.autheSelection.selection.comingSoon)}
>
{t(ResourceKeys.authentication.autheSelection.selection.azureActiveDirectory)}
</CompoundButton>
</Stack>
</Stack>
</div>
);
};

Просмотреть файл

@ -0,0 +1,40 @@
/***********************************************************
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License
**********************************************************/
import * as React from 'react';
import { shallow } from 'enzyme';
import { AuthenticationView } from './authenticationView';
import { AuthenticationMethodPreference, getInitialAuthenticationState } from '../state';
import * as authenticationStateContext from '../context/authenticationStateContext';
describe('AuthenticationView', () => {
it('matches snapshot when page is loading', () => {
jest.spyOn(authenticationStateContext, 'useAuthenticationStateContext').mockReturnValue(
[{...getInitialAuthenticationState(), formState: 'working'}, authenticationStateContext.getInitialAuthenticationOps()]);
const wrapper = shallow(<AuthenticationView/>);
expect(wrapper).toMatchSnapshot();
});
it('matches snapshot when preference not set', () => {
jest.spyOn(authenticationStateContext, 'useAuthenticationStateContext').mockReturnValue(
[getInitialAuthenticationState(), authenticationStateContext.getInitialAuthenticationOps()]);
const wrapper = shallow(<AuthenticationView/>);
expect(wrapper).toMatchSnapshot();
});
it('matches snapshot when preference is aad', () => {
jest.spyOn(authenticationStateContext, 'useAuthenticationStateContext').mockReturnValue(
[{...getInitialAuthenticationState(), preference: AuthenticationMethodPreference.AzureAD}, authenticationStateContext.getInitialAuthenticationOps()]);
const wrapper = shallow(<AuthenticationView/>);
expect(wrapper).toMatchSnapshot();
});
it('matches snapshot when preference is connection string', () => {
jest.spyOn(authenticationStateContext, 'useAuthenticationStateContext').mockReturnValue(
[{...getInitialAuthenticationState(), preference: AuthenticationMethodPreference.ConnectionString}, authenticationStateContext.getInitialAuthenticationOps()]);
const wrapper = shallow(<AuthenticationView/>);
expect(wrapper).toMatchSnapshot();
});
});

Просмотреть файл

@ -0,0 +1,40 @@
/***********************************************************
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License
**********************************************************/
import * as React from 'react';
import { MultiLineShimmer } from '../../shared/components/multiLineShimmer';
import { HubSelection } from '../azureActiveDirectory/components/hubSelection';
import { AzureActiveDirectoryStateContextProvider } from '../azureActiveDirectory/context/azureActiveDirectoryStateProvider';
import { ConnectionStringsView } from '../../connectionStrings/components/connectionStringsView';
import { ConnectionStringStateContextProvider } from '../../connectionStrings/context/connectionStringStateProvider';
import { useAuthenticationStateContext } from '../context/authenticationStateContext';
import { AuthenticationMethodPreference } from '../state';
import { AuthenticationSelection } from './authenticationSelection';
export const AuthenticationView: React.FC = () => {
const [{ formState, preference }, api] = useAuthenticationStateContext();
React.useEffect(() => {
api.getLoginPreference();
}, []); // tslint:disable-line: align
return (
<>
{formState === 'working' ? <MultiLineShimmer/> :
<>
{!preference && <AuthenticationSelection/>}
{preference === AuthenticationMethodPreference.AzureAD &&
<AzureActiveDirectoryStateContextProvider>
<HubSelection/>
</AzureActiveDirectoryStateContextProvider>
}
{preference === AuthenticationMethodPreference.ConnectionString &&
<ConnectionStringStateContextProvider>
<ConnectionStringsView/>
</ConnectionStringStateContextProvider>
}
</>
}
</>
);
};

Просмотреть файл

@ -0,0 +1,22 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`AuthenticationStateContextProvider matches snapshot 1`] = `
<ContextProvider
value={
Array [
Object {
"formState": "initialized",
"preference": undefined,
},
Object {
"getLoginPreference": [Function],
"setLoginPreference": [Function],
},
]
}
>
<span>
test
</span>
</ContextProvider>
`;

Просмотреть файл

@ -0,0 +1,19 @@
/***********************************************************
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License
**********************************************************/
import * as React from 'react';
import { getInitialAuthenticationState, AuthenticationStateInterface } from '../state';
import { AuthenticationInterface } from './authenticationStateProvider';
export const getInitialAuthenticationOps = (): AuthenticationInterface => ({
getLoginPreference: () => undefined,
setLoginPreference: () => undefined
});
export const AuthenticationStateContext = React.createContext<[AuthenticationStateInterface, AuthenticationInterface]>
([
getInitialAuthenticationState(),
getInitialAuthenticationOps()
]);
export const useAuthenticationStateContext = () => React.useContext(AuthenticationStateContext);

Просмотреть файл

@ -0,0 +1,16 @@
import * as React from 'react';
import { shallow } from 'enzyme';
import { AuthenticationStateContextProvider } from './authenticationStateProvider';
import { getInitialAuthenticationState } from '../state';
import * as AsyncSagaReducer from '../../shared/hooks/useAsyncSagaReducer';
describe('AuthenticationStateContextProvider', ()=> {
jest.spyOn(AsyncSagaReducer, 'useAsyncSagaReducer').mockReturnValue([getInitialAuthenticationState(), jest.fn()]);
it('matches snapshot', () => {
const component = <AuthenticationStateContextProvider>
<span>test</span>
</AuthenticationStateContextProvider>;
expect(shallow(component)).toMatchSnapshot();
});
});

Просмотреть файл

@ -0,0 +1,31 @@
/***********************************************************
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License
**********************************************************/
import * as React from 'react';
import { getInitialAuthenticationState } from '../state';
import { authenticationReducer } from '../reducers';
import { useAsyncSagaReducer } from '../../shared/hooks/useAsyncSagaReducer';
import { AuthenticationStateContext } from './authenticationStateContext';
import { getLoginPreferenceAction, setLoginPreferenceAction } from '../actions';
import { authenticationSaga } from '../saga';
export interface AuthenticationInterface {
getLoginPreference(): void;
setLoginPreference(preference: string): void;
}
export const AuthenticationStateContextProvider: React.FC = props => {
const [state, dispatch] = useAsyncSagaReducer(authenticationReducer, authenticationSaga, getInitialAuthenticationState(), 'authenticationState');
const authenticationApi: AuthenticationInterface = {
getLoginPreference: () => dispatch(getLoginPreferenceAction.started()),
setLoginPreference: (reference: string) => dispatch(setLoginPreferenceAction.started(reference))
};
return (
<AuthenticationStateContext.Provider value={[state, authenticationApi]}>
{props.children}
</AuthenticationStateContext.Provider>
);
};

Просмотреть файл

@ -0,0 +1,50 @@
/***********************************************************
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License
**********************************************************/
import { authenticationReducer } from './reducers';
import { getLoginPreferenceAction, setLoginPreferenceAction } from './actions';
import { getInitialAuthenticationState, AuthenticationMethodPreference } from './state';
describe('authenticationReducer', () => {
context('AUTHENTICATION/GET', () => {
it('handles AUTHENTICATION/GET_STARTED action', ()=> {
const action = getLoginPreferenceAction.started();
expect(authenticationReducer(getInitialAuthenticationState(), action).formState).toEqual('working');
});
it('handles AUTHENTICATION/GET_DONE action', ()=> {
const action = getLoginPreferenceAction.done({result: AuthenticationMethodPreference.AzureAD});
expect(authenticationReducer(getInitialAuthenticationState(), action)).toEqual({
formState: 'idle',
preference: AuthenticationMethodPreference.AzureAD
});
});
it('handles AUTHENTICATION/GET_FAILED action', ()=> {
const action = getLoginPreferenceAction.failed({error: {}});
expect(authenticationReducer(getInitialAuthenticationState(), action).formState).toEqual('failed');
});
});
context('AUTHENTICATION/SET', () => {
it('handles AUTHENTICATION/SET_STARTED action', ()=> {
const action = setLoginPreferenceAction.started(AuthenticationMethodPreference.AzureAD);
expect(authenticationReducer(getInitialAuthenticationState(), action).formState).toEqual('working');
});
it('handles AUTHENTICATION/SET_DONE action', ()=> {
const action = setLoginPreferenceAction.done({params: AuthenticationMethodPreference.AzureAD});
expect(authenticationReducer(getInitialAuthenticationState(), action)).toEqual({
formState: 'idle',
preference: AuthenticationMethodPreference.AzureAD
});
});
it('handles AUTHENTICATION/SET_FAILED action', ()=> {
const action = setLoginPreferenceAction.failed({params: AuthenticationMethodPreference.AzureAD, error: {}});
expect(authenticationReducer(getInitialAuthenticationState(), action).formState).toEqual('failed');
});
});
});

Просмотреть файл

@ -0,0 +1,47 @@
/***********************************************************
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License
**********************************************************/
import { reducerWithInitialState } from 'typescript-fsa-reducers';
import { getLoginPreferenceAction, setLoginPreferenceAction } from './actions';
import { getInitialAuthenticationState, AuthenticationStateInterface } from './state';
export const authenticationReducer = reducerWithInitialState<AuthenticationStateInterface>(getInitialAuthenticationState())
.case(getLoginPreferenceAction.started, (state: AuthenticationStateInterface) => {
return {
...state,
formState: 'working'
};
})
.case(getLoginPreferenceAction.done, (state: AuthenticationStateInterface, payload: {params: void, result: string}) => {
return {
...state,
formState: 'idle',
preference: payload.result
};
})
.case(getLoginPreferenceAction.failed, (state: AuthenticationStateInterface) => {
return {
...state,
formState: 'failed'
};
})
.case(setLoginPreferenceAction.started, (state: AuthenticationStateInterface) => {
return {
...state,
formState: 'working'
};
})
.case(setLoginPreferenceAction.done, (state: AuthenticationStateInterface, payload: {params: string}) => {
return {
...state,
formState: 'idle',
preference: payload.params
};
})
.case(setLoginPreferenceAction.failed, (state: AuthenticationStateInterface) => {
return {
...state,
formState: 'failed'
};
});

Просмотреть файл

@ -0,0 +1,13 @@
/***********************************************************
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License
**********************************************************/
import { takeLatest } from 'redux-saga/effects';
import { getLoginPreferenceAction, setLoginPreferenceAction } from './actions';
import { getLoginPreferenceSaga } from './sagas/getLoginPreferenceSaga';
import { setLoginPreferenceSaga } from './sagas/setLoginPreferenceSaga';
export function* authenticationSaga() {
yield takeLatest(getLoginPreferenceAction.started, getLoginPreferenceSaga);
yield takeLatest(setLoginPreferenceAction.started, setLoginPreferenceSaga);
}

Просмотреть файл

@ -0,0 +1,22 @@
/***********************************************************
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License
**********************************************************/
import { cloneableGenerator } from '@redux-saga/testing-utils';
import { put } from 'redux-saga/effects';
import { AUTHENTICATION_METHOD_PREFERENCE } from '../../constants/browserStorage';
import { getLoginPreferenceAction } from '../actions';
import { getLoginPreferenceSaga } from './getLoginPreferenceSaga';
describe('getLoginPreferenceSaga', () => {
it('gets login preference', () => {
const sagaGenerator = cloneableGenerator(getLoginPreferenceSaga)();
localStorage.setItem(AUTHENTICATION_METHOD_PREFERENCE, 'aad');
expect(sagaGenerator.next('aad')).toEqual({
done: false,
value: put(getLoginPreferenceAction.done({result: 'aad'}))
});
expect(sagaGenerator.next().done).toEqual(true);
});
});

Просмотреть файл

@ -0,0 +1,13 @@
/***********************************************************
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License
**********************************************************/
import { put } from 'redux-saga/effects';
import { AUTHENTICATION_METHOD_PREFERENCE } from '../../constants/browserStorage';
import { getLoginPreferenceAction } from '../actions';
import { AuthenticationMethodPreference } from '../state';
export function* getLoginPreferenceSaga() {
const preference = localStorage.getItem(AUTHENTICATION_METHOD_PREFERENCE);
yield put(getLoginPreferenceAction.done({result: preference as AuthenticationMethodPreference}));
}

Просмотреть файл

@ -0,0 +1,23 @@
/***********************************************************
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License
**********************************************************/
import { cloneableGenerator } from '@redux-saga/testing-utils';
import { put } from 'redux-saga/effects';
import { AUTHENTICATION_METHOD_PREFERENCE } from '../../constants/browserStorage';
import { setLoginPreferenceAction } from '../actions';
import { setLoginPreferenceSaga } from './setLoginPreferenceSaga';
describe('setLoginPreferenceSaga', () => {
it('sets login preference', () => {
const sagaGenerator = cloneableGenerator(setLoginPreferenceSaga)(setLoginPreferenceAction.started('aad'));
localStorage.setItem(AUTHENTICATION_METHOD_PREFERENCE, '');
expect(sagaGenerator.next()).toEqual({
done: false,
value: put(setLoginPreferenceAction.done({params: 'aad'}))
});
expect(sagaGenerator.next().done).toEqual(true);
expect(localStorage.getItem(AUTHENTICATION_METHOD_PREFERENCE)).toEqual('aad');
});
});

Просмотреть файл

@ -0,0 +1,13 @@
/***********************************************************
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License
**********************************************************/
import { put } from 'redux-saga/effects';
import { Action } from 'typescript-fsa';
import { AUTHENTICATION_METHOD_PREFERENCE } from '../../constants/browserStorage';
import { setLoginPreferenceAction } from '../actions';
export function* setLoginPreferenceSaga(action: Action<string>) {
localStorage.setItem(AUTHENTICATION_METHOD_PREFERENCE, action.payload);
yield put(setLoginPreferenceAction.done({params: action.payload}));
}

Просмотреть файл

@ -0,0 +1,18 @@
/***********************************************************
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License
**********************************************************/
export enum AuthenticationMethodPreference {
AzureAD = 'AzureAD',
ConnectionString = 'ConnectionString'
}
export interface AuthenticationStateInterface {
formState: 'initialized' | 'working' | 'failed' | 'idle';
preference: string;
}
export const getInitialAuthenticationState = (): AuthenticationStateInterface => ({
formState: 'initialized',
preference: undefined
});

Просмотреть файл

@ -0,0 +1,29 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`ConnectionStringCommandBar matches snapshot 1`] = `
<StyledCommandBarBase
items={
Array [
Object {
"ariaLabel": "connectionStrings.addConnectionCommand.ariaLabel",
"disabled": false,
"iconProps": Object {
"iconName": "Add",
},
"key": "add",
"onClick": [MockFunction],
"text": "connectionStrings.addConnectionCommand.label",
},
Object {
"ariaLabel": "authentication.autheSelection.switchAuthType",
"iconProps": Object {
"iconName": "NavigateBack",
},
"key": "switch",
"onClick": [Function],
"text": "authentication.autheSelection.switchAuthType",
},
]
}
/>
`;

Просмотреть файл

@ -2,66 +2,36 @@
exports[`ConnectionStringsView matches snapshot when connection strings present 1`] = `
<div>
<StyledCommandBarBase
items={
Array [
Object {
"ariaLabel": "connectionStrings.addConnectionCommand.ariaLabel",
"disabled": false,
"iconProps": Object {
"iconName": "Add",
},
"key": "add",
"onClick": [Function],
"text": "connectionStrings.addConnectionCommand.label",
},
]
}
<ConnectionStringCommandBar
onAddConnectionStringClick={[Function]}
/>
<div>
<div
className="connection-strings"
>
<ConnectionString
connectionStringWithExpiry={
Object {
"connectionString": "connectionString1",
"expiration": "Thu, 01 Jan 1970 00:00:00 GMT",
}
<div
className="connection-strings"
>
<ConnectionString
connectionStringWithExpiry={
Object {
"connectionString": "connectionString1",
"expiration": "Thu, 01 Jan 1970 00:00:00 GMT",
}
key="connectionString1"
onDeleteConnectionString={[Function]}
onEditConnectionString={[Function]}
onSelectConnectionString={[Function]}
/>
</div>
}
key="connectionString1"
onDeleteConnectionString={[Function]}
onEditConnectionString={[Function]}
onSelectConnectionString={[Function]}
/>
</div>
</div>
`;
exports[`ConnectionStringsView matches snapshot when no connection strings 1`] = `
<div>
<StyledCommandBarBase
items={
Array [
Object {
"ariaLabel": "connectionStrings.addConnectionCommand.ariaLabel",
"disabled": false,
"iconProps": Object {
"iconName": "Add",
},
"key": "add",
"onClick": [Function],
"text": "connectionStrings.addConnectionCommand.label",
},
]
}
<ConnectionStringCommandBar
onAddConnectionStringClick={[Function]}
/>
<div>
<div
className="connection-strings"
/>
<ConnectionStringsEmpty />
</div>
<div
className="connection-strings"
/>
<ConnectionStringsEmpty />
</div>
`;

Просмотреть файл

@ -0,0 +1,34 @@
/***********************************************************
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License
**********************************************************/
import * as React from 'react';
import { shallow, mount } from 'enzyme';
import { CommandBar } from '@fluentui/react';
import { ConnectionStringCommandBar } from './commandBar';
import { connectionStringsStateInitial } from '../state';
import * as connectionStringContext from '../context/connectionStringStateContext';
import * as authenticationStateContext from '../../authentication/context/authenticationStateContext';
import { getInitialAuthenticationState } from '../../authentication/state';
describe('ConnectionStringCommandBar', () => {
it('matches snapshot', () => {
jest.spyOn(connectionStringContext, 'useConnectionStringContext').mockReturnValue(
[connectionStringsStateInitial(), connectionStringContext.getInitialConnectionStringOps()]);
const wrapper = shallow(<ConnectionStringCommandBar onAddConnectionStringClick={jest.fn()}/>);
expect(wrapper).toMatchSnapshot();
});
it('upserts when edit view applied', () => {
const setLoginPreference = jest.fn();
jest.spyOn(authenticationStateContext, 'useAuthenticationStateContext').mockReturnValue(
[getInitialAuthenticationState(), {...authenticationStateContext.getInitialAuthenticationOps(), setLoginPreference}]);
const wrapper = mount(<ConnectionStringCommandBar onAddConnectionStringClick={jest.fn()}/>);
wrapper.find(CommandBar).props().items[1].onClick(undefined);
wrapper.update();
expect(setLoginPreference).toHaveBeenCalledWith('');
});
});

Просмотреть файл

@ -0,0 +1,44 @@
import * as React from 'react';
import { useTranslation } from 'react-i18next';
import { CommandBar } from '@fluentui/react';
import { ResourceKeys } from '../../../localization/resourceKeys';
import { useConnectionStringContext } from '../context/connectionStringStateContext';
import { CONNECTION_STRING_LIST_MAX_LENGTH } from '../../constants/browserStorage';
import { ADD, NAVIGATE_BACK } from '../../constants/iconNames';
import { useAuthenticationStateContext } from '../../authentication/context/authenticationStateContext';
interface ConnectionStringCommandBarProps {
onAddConnectionStringClick: () => void;
}
export const ConnectionStringCommandBar: React.FC<ConnectionStringCommandBarProps> = props => {
const { t } = useTranslation();
const [ state, ] = useConnectionStringContext();
const [ , { setLoginPreference } ] = useAuthenticationStateContext();
const switchAuth = () => {
setLoginPreference('');
};
return (
<CommandBar
items={[
{
ariaLabel: t(ResourceKeys.connectionStrings.addConnectionCommand.ariaLabel),
disabled: state.payload.length >= CONNECTION_STRING_LIST_MAX_LENGTH,
iconProps: { iconName: ADD },
key: 'add',
onClick: props.onAddConnectionStringClick,
text: t(ResourceKeys.connectionStrings.addConnectionCommand.label)
},
{
ariaLabel: t(ResourceKeys.authentication.autheSelection.switchAuthType),
iconProps: { iconName: NAVIGATE_BACK },
key: 'switch',
onClick: switchAuth,
text: t(ResourceKeys.authentication.autheSelection.switchAuthType)
}
]}
/>
);
};

Просмотреть файл

@ -5,14 +5,12 @@
import * as React from 'react';
import { shallow, mount } from 'enzyme';
import { act } from 'react-dom/test-utils';
import { CommandBar } from '@fluentui/react';
import { ConnectionStringsView } from './connectionStringsView';
import { ConnectionString } from './connectionString';
import { ConnectionStringEditView } from './connectionStringEditView';
import * as AsyncSagaReducer from '../../shared/hooks/useAsyncSagaReducer';
import { connectionStringsStateInitial } from '../state';
import { deleteConnectionStringAction, upsertConnectionStringAction } from '../actions';
import * as HubConnectionStringHelper from '../../shared/utils/hubConnectionStringHelper';
import * as connectionStringContext from '../context/connectionStringStateContext';
jest.mock('react-router-dom', () => ({
useHistory: () => ({ push: jest.fn() }),
@ -23,67 +21,28 @@ describe('ConnectionStringsView', () => {
const connectionStringWithExpiry = {connectionString: 'connectionString1', expiration: (new Date(0)).toUTCString()};
it('matches snapshot when no connection strings', () => {
jest.spyOn(AsyncSagaReducer, 'useAsyncSagaReducer').mockReturnValue([connectionStringsStateInitial(), jest.fn()]);
jest.spyOn(connectionStringContext, 'useConnectionStringContext').mockReturnValue(
[connectionStringsStateInitial(), connectionStringContext.getInitialConnectionStringOps()]);
const wrapper = shallow(<ConnectionStringsView/>);
expect(wrapper).toMatchSnapshot();
});
it('matches snapshot when connection strings present', () => {
const state = connectionStringsStateInitial().merge({ payload: [connectionStringWithExpiry]});
jest.spyOn(AsyncSagaReducer, 'useAsyncSagaReducer').mockReturnValue([state, jest.fn()]);
jest.spyOn(connectionStringContext, 'useConnectionStringContext').mockReturnValue(
[state, connectionStringContext.getInitialConnectionStringOps()]);
const wrapper = shallow(<ConnectionStringsView/>);
expect(wrapper).toMatchSnapshot();
});
describe('add scenario', () => {
it('mounts edit view when add command clicked', () => {
const state = connectionStringsStateInitial().merge({ payload: []});
jest.spyOn(AsyncSagaReducer, 'useAsyncSagaReducer').mockReturnValue([state, jest.fn()]);
const wrapper = shallow(<ConnectionStringsView/>);
expect(wrapper.find(ConnectionStringEditView).length).toEqual(0);
act(() => {
wrapper.find(CommandBar).props().items[0].onClick(undefined);
});
wrapper.update();
expect(wrapper.find(ConnectionStringEditView).length).toEqual(1);
act(() => wrapper.find(ConnectionStringEditView).first().props().onDismiss());
wrapper.update();
expect(wrapper.find(ConnectionStringEditView).length).toEqual(0);
});
it('upserts when edit view applied', () => {
const upsertConnectionStringActionSpy = jest.spyOn(upsertConnectionStringAction, 'started');
const state = connectionStringsStateInitial().merge({ payload: []});
jest.spyOn(AsyncSagaReducer, 'useAsyncSagaReducer').mockReturnValue([state, jest.fn()]);
jest.spyOn(HubConnectionStringHelper, 'getExpiryDateInUtcString').mockReturnValue((new Date(0)).toUTCString());
const wrapper = shallow(<ConnectionStringsView/>);
act(() => {
wrapper.find(CommandBar).props().items[0].onClick(undefined);
});
wrapper.update();
const connectionStringEditView = wrapper.find(ConnectionStringEditView).first();
act(() => connectionStringEditView.props().onCommit('newConnectionString'));
wrapper.update();
expect(upsertConnectionStringActionSpy).toHaveBeenCalledWith({ connectionString: 'newConnectionString', expiration: (new Date(0)).toUTCString() });
expect(wrapper.find(ConnectionStringEditView).length).toEqual(0);
});
});
describe('edit scenario', () => {
const connectionString = 'HostName=test.azure-devices-int.net;SharedAccessKeyName=iothubowner;SharedAccessKey=key';
it('mounts edit view when add command clicked', () => {
const state = connectionStringsStateInitial().merge({ payload: [connectionStringWithExpiry] });
jest.spyOn(AsyncSagaReducer, 'useAsyncSagaReducer').mockReturnValue([state, jest.fn()]);
jest.spyOn(connectionStringContext, 'useConnectionStringContext').mockReturnValue(
[state, connectionStringContext.getInitialConnectionStringOps()]);
const wrapper = mount(<ConnectionStringsView/>);
act(() => wrapper.find(ConnectionString).props().onEditConnectionString(connectionString));
@ -93,10 +52,11 @@ describe('ConnectionStringsView', () => {
});
it('upserts when edit view applied', () => {
const upsertConnectionStringActionSpy = jest.spyOn(upsertConnectionStringAction, 'started');
const deleteConnectionStringActionSpy = jest.spyOn(deleteConnectionStringAction, 'started');
const deleteConnectionString = jest.fn();
const upsertConnectionString = jest.fn();
const state = connectionStringsStateInitial().merge({ payload: [connectionStringWithExpiry] });
jest.spyOn(AsyncSagaReducer, 'useAsyncSagaReducer').mockReturnValue([state, jest.fn()]);
jest.spyOn(connectionStringContext, 'useConnectionStringContext').mockReturnValue(
[state, {...connectionStringContext.getInitialConnectionStringOps(), deleteConnectionString, upsertConnectionString}]);
jest.spyOn(HubConnectionStringHelper, 'getExpiryDateInUtcString').mockReturnValue((new Date(0)).toUTCString());
const wrapper = mount(<ConnectionStringsView/>);
@ -107,8 +67,8 @@ describe('ConnectionStringsView', () => {
act(() => connectionStringEditView.props().onCommit('newConnectionString'));
wrapper.update();
expect(deleteConnectionStringActionSpy).toHaveBeenCalledWith(connectionString);
expect(upsertConnectionStringActionSpy).toHaveBeenCalledWith({ connectionString: 'newConnectionString', expiration: (new Date(0)).toUTCString() });
expect(deleteConnectionString).toHaveBeenCalledWith(connectionString);
expect(upsertConnectionString).toHaveBeenCalledWith({ connectionString: 'newConnectionString', expiration: (new Date(0)).toUTCString() });
expect(wrapper.find(ConnectionStringEditView).length).toEqual(0);
});
});

Просмотреть файл

@ -5,61 +5,55 @@
import * as React from 'react';
import { useTranslation } from 'react-i18next';
import { useHistory } from 'react-router-dom';
import { CommandBar } from '@fluentui/react';
import { ConnectionString } from './connectionString';
import { ConnectionStringEditView } from './connectionStringEditView';
import { ResourceKeys } from '../../../localization/resourceKeys';
import { CONNECTION_STRING_LIST_MAX_LENGTH } from '../../constants/browserStorage';
import { upsertConnectionStringAction, deleteConnectionStringAction, setConnectionStringsAction } from '../actions';
import { ROUTE_PARTS } from '../../constants/routes';
import { formatConnectionStrings, getExpiryDateInUtcString } from '../../shared/utils/hubConnectionStringHelper';
import { ConnectionStringsEmpty } from './connectionStringsEmpty';
import { useAsyncSagaReducer } from '../../shared/hooks/useAsyncSagaReducer';
import { connectionStringsReducer } from '../reducer';
import { connectionStringsSaga } from '../sagas';
import { connectionStringsStateInitial, ConnectionStringWithExpiry } from '../state';
import { ConnectionStringWithExpiry } from '../state';
import { SynchronizationStatus } from '../../api/models/synchronizationStatus';
import { MultiLineShimmer } from '../../shared/components/multiLineShimmer';
import { getConnectionInfoFromConnectionString } from '../../api/shared/utils';
import { getConnectionStringsAction } from './../actions';
import { useBreadcrumbEntry } from '../../navigation/hooks/useBreadcrumbEntry';
import { AppInsightsClient } from '../../shared/appTelemetry/appInsightsClient';
import { TELEMETRY_PAGE_NAMES } from '../../constants/telemetry';
import { useConnectionStringContext } from '../context/connectionStringStateContext';
import { ConnectionStringCommandBar } from './commandBar';
import '../../css/_layouts.scss';
import './connectionStringsView.scss';
import { AppInsightsClient } from '../../shared/appTelemetry/appInsightsClient';
import { TELEMETRY_PAGE_NAMES } from '../../../app/constants/telemetry';
// tslint:disable-next-line: cyclomatic-complexity
export const ConnectionStringsView: React.FC = () => {
const { t } = useTranslation();
const history = useHistory();
useBreadcrumbEntry({name: t(ResourceKeys.breadcrumb.resources)});
const [ localState, dispatch ] = useAsyncSagaReducer(connectionStringsReducer, connectionStringsSaga, connectionStringsStateInitial(), 'connectionStringsState');
const [ state, api ] = useConnectionStringContext();
const [ connectionStringUnderEdit, setConnectionStringUnderEdit ] = React.useState<string>(undefined);
const connectionStringsWithExpiry = localState.payload;
const synchronizationStatus = localState.synchronizationStatus;
const connectionStringsWithExpiry = state.payload;
const synchronizationStatus = state.synchronizationStatus;
const onUpsertConnectionString = (newConnectionString: string, connectionString: string) => {
if (newConnectionString !== connectionString) {
// replacing a connection string with new connection string
dispatch(deleteConnectionStringAction.started(connectionString));
api.deleteConnectionString(connectionString);
}
const stringWithExpiry: ConnectionStringWithExpiry = {
connectionString: newConnectionString,
expiration: getExpiryDateInUtcString()
};
dispatch(upsertConnectionStringAction.started(stringWithExpiry));
api.upsertConnectionString(stringWithExpiry);
};
const onDeleteConnectionString = (connectionString: string) => {
dispatch(deleteConnectionStringAction.started(connectionString));
api.deleteConnectionString(connectionString);
};
const onSelectConnectionString = (connectionString: string) => {
const updatedConnectionStrings = formatConnectionStrings(connectionStringsWithExpiry, connectionString);
dispatch(setConnectionStringsAction.started(updatedConnectionStrings));
api.setConnectionStrings(updatedConnectionStrings);
};
const onAddConnectionStringClick = () => {
@ -80,7 +74,7 @@ export const ConnectionStringsView: React.FC = () => {
};
React.useEffect(() => {
dispatch(getConnectionStringsAction.started());
api.getConnectionStrings();
}, []);
React.useEffect(() => {
@ -102,36 +96,21 @@ export const ConnectionStringsView: React.FC = () => {
return (
<div>
<CommandBar
items={[
{
ariaLabel: t(ResourceKeys.connectionStrings.addConnectionCommand.ariaLabel),
disabled: connectionStringsWithExpiry.length >= CONNECTION_STRING_LIST_MAX_LENGTH,
iconProps: { iconName: 'Add' },
key: 'add',
onClick: onAddConnectionStringClick,
text: t(ResourceKeys.connectionStrings.addConnectionCommand.label)
}
]}
/>
<div>
<div className="connection-strings">
{connectionStringsWithExpiry.map(connectionStringWithExpiry =>
<ConnectionString
key={connectionStringWithExpiry.connectionString}
connectionStringWithExpiry={connectionStringWithExpiry}
onEditConnectionString={onEditConnectionStringClick}
onDeleteConnectionString={onDeleteConnectionString}
onSelectConnectionString={onSelectConnectionString}
/>
)}
</div>
{(!connectionStringsWithExpiry || connectionStringsWithExpiry.length === 0) &&
<ConnectionStringsEmpty/>
}
<ConnectionStringCommandBar onAddConnectionStringClick={onAddConnectionStringClick}/>
<div className="connection-strings">
{connectionStringsWithExpiry.map(connectionStringWithExpiry =>
<ConnectionString
key={connectionStringWithExpiry.connectionString}
connectionStringWithExpiry={connectionStringWithExpiry}
onEditConnectionString={onEditConnectionStringClick}
onDeleteConnectionString={onDeleteConnectionString}
onSelectConnectionString={onSelectConnectionString}
/>
)}
</div>
{(!connectionStringsWithExpiry || connectionStringsWithExpiry.length === 0) &&
<ConnectionStringsEmpty/>
}
{connectionStringUnderEdit !== undefined &&
<ConnectionStringEditView
connectionStringUnderEdit={connectionStringUnderEdit}

Просмотреть файл

@ -0,0 +1,17 @@
import * as React from 'react';
import { connectionStringsStateInitial, ConnectionStringsStateType } from '../state';
import { ConnectionStringInterface } from './connectionStringStateProvider';
export const getInitialConnectionStringOps = (): ConnectionStringInterface => ({
deleteConnectionString: () => undefined,
getConnectionStrings: () => undefined,
setConnectionStrings: () => undefined,
upsertConnectionString: () => undefined,
});
export const ConnectionStringStateContext = React.createContext<[ConnectionStringsStateType, ConnectionStringInterface]>
([
connectionStringsStateInitial(),
getInitialConnectionStringOps()
]);
export const useConnectionStringContext = () => React.useContext(ConnectionStringStateContext);

Просмотреть файл

@ -0,0 +1,31 @@
import * as React from 'react';
import { connectionStringsStateInitial, ConnectionStringWithExpiry } from '../state';
import { connectionStringsReducer } from '../reducer';
import { connectionStringsSaga } from '../sagas';
import { useAsyncSagaReducer } from '../../shared/hooks/useAsyncSagaReducer';
import { ConnectionStringStateContext } from './connectionStringStateContext';
import { deleteConnectionStringAction, getConnectionStringsAction, setConnectionStringsAction, upsertConnectionStringAction } from '../actions';
export interface ConnectionStringInterface {
getConnectionStrings(): void;
setConnectionStrings(connectionStringInfoList: ConnectionStringWithExpiry[]): void;
upsertConnectionString(connectionStringInfo: ConnectionStringWithExpiry): void;
deleteConnectionString(connectionString: string): void;
}
export const ConnectionStringStateContextProvider: React.FC = props => {
const [ state, dispatch ] = useAsyncSagaReducer(connectionStringsReducer, connectionStringsSaga, connectionStringsStateInitial(), 'connectionStringsState');
const connectionStringApi: ConnectionStringInterface = {
deleteConnectionString: (connectionString: string) => dispatch(deleteConnectionStringAction.started(connectionString)),
getConnectionStrings: () => dispatch(getConnectionStringsAction.started()),
setConnectionStrings: (connectionStringInfoList: ConnectionStringWithExpiry[]) => dispatch(setConnectionStringsAction.started(connectionStringInfoList)),
upsertConnectionString: (connectionStringInfo: ConnectionStringWithExpiry) => dispatch(upsertConnectionStringAction.started(connectionStringInfo)),
};
return (
<ConnectionStringStateContext.Provider value={[state, connectionStringApi]}>
{props.children}
</ConnectionStringStateContext.Provider>
);
};

Просмотреть файл

@ -7,7 +7,7 @@ import { SagaIterator } from 'redux-saga';
import { NotificationType } from '../../api/models/notification';
import { raiseNotificationToast } from '../../notifications/components/notificationToast';
import { CONNECTION_STRING_NAME_LIST } from '../../constants/browserStorage';
import { getConnectionStringsAction } from './../actions';
import { getConnectionStringsAction } from '../actions';
import { ResourceKeys } from '../../../localization/resourceKeys';
import { ConnectionStringWithExpiry } from '../state';
import { setConnectionStrings } from './setConnectionStringsSaga';

Некоторые файлы не были показаны из-за слишком большого количества измененных файлов Показать больше