diff --git a/ui/Dockerfile b/ui/Dockerfile index d1ae9bfdc..8ee2d4399 100644 --- a/ui/Dockerfile +++ b/ui/Dockerfile @@ -16,14 +16,18 @@ COPY package*.json ./ # If you are building your code for production # RUN npm install --only=production -RUN npm install + +RUN \ + npm config list && \ + npm config delete proxy && \ + npm install --loglevel silly # Bundle app source COPY . . RUN npm run build -FROM nginx +FROM nginx:1.24.0-bullseye RUN \ apt-get update && \ diff --git a/ui/angular.json b/ui/angular.json index a07983c60..4efd8aec3 100644 --- a/ui/angular.json +++ b/ui/angular.json @@ -4,6 +4,51 @@ "newProjectRoot": "projects", "projects": { "ui": { + "i18n": { + "sourceLocale": { "code": "en", "baseHref": "en/../" }, + "locales": { + "cs": { + "translation": "src/i18n/messages.cs.xlf", + "baseHref": "cs/../" + }, + "es": { + "translation": "src/i18n/messages.es.xlf", + "baseHref": "es/../" + }, + "fr": { + "translation": "src/i18n/messages.fr.xlf", + "baseHref": "fr/../" + }, + "it": { + "translation": "src/i18n/messages.it.xlf", + "baseHref": "it/../" + }, + "ja": { + "translation": "src/i18n/messages.ja.xlf", + "baseHref": "ja/../" + }, + "ko": { + "translation": "src/i18n/messages.ko.xlf", + "baseHref": "ko/../" + }, + "pt": { + "translation": "src/i18n/messages.pt.xlf", + "baseHref": "pt/../" + }, + "ru": { + "translation": "src/i18n/messages.ru.xlf", + "baseHref": "ru/../" + }, + "zh-CN": { + "translation": "src/i18n/messages.zh-CN.xlf", + "baseHref": "zh-CN/../" + }, + "zh-TW": { + "translation": "src/i18n/messages.zh-TW.xlf", + "baseHref": "zh-TW/../" + } + } + }, "projectType": "application", "schematics": { "@schematics/angular:component": { @@ -17,13 +62,31 @@ "build": { "builder": "@angular-devkit/build-angular:browser", "options": { + "localize": [ + "en", + "fr", + "es", + "cs", + "it", + "ja", + "ko", + "pt", + "ru", + "zh-CN", + "zh-TW" + ], "outputPath": "dist/", "index": "src/index.html", "main": "src/main.ts", "polyfills": "src/polyfills.ts", "tsConfig": "tsconfig.app.json", "inlineStyleLanguage": "scss", - "assets": ["src/favicon.ico", "src/assets", "src/content/images"], + "assets": [ + "src/favicon.ico", + "src/assets", + "src/content/images", + "src/content/css" + ], "styles": ["src/content/scss/global.scss"], "scripts": [] }, @@ -50,6 +113,7 @@ "outputHashing": "all" }, "development": { + "localize": ["en"], "buildOptimizer": false, "optimization": false, "vendorChunk": true, @@ -77,9 +141,24 @@ "defaultConfiguration": "development" }, "extract-i18n": { - "builder": "@angular-devkit/build-angular:extract-i18n", + "builder": "ng-extract-i18n-merge:ng-extract-i18n-merge", "options": { - "browserTarget": "ui:build" + "includeContext": true, + "browserTarget": "ui:build", + "format": "xlf", + "outputPath": "src/i18n", + "targetFiles": [ + "messages.cs.xlf", + "messages.es.xlf", + "messages.fr.xlf", + "messages.it.xlf", + "messages.ja.xlf", + "messages.ko.xlf", + "messages.pt.xlf", + "messages.ru.xlf", + "messages.zh-CN.xlf", + "messages.zh-TW.xlf" + ] } }, "test": { diff --git a/ui/container-files/etc/nginx/conf.d/default.conf b/ui/container-files/etc/nginx/conf.d/default.conf index 226e97404..c46b77f33 100644 --- a/ui/container-files/etc/nginx/conf.d/default.conf +++ b/ui/container-files/etc/nginx/conf.d/default.conf @@ -4,22 +4,29 @@ server { #access_log /var/log/nginx/host.access.log main; + # Fallback to default language if no preference defined by browser + if ($accept_language ~ "^$") { + set $accept_language "en"; + } + location / { root /usr/share/nginx/html; index index.html index.htm; - rewrite ^/ui/(.*) /$1 break; - try_files $uri $uri/ /index.html; - } - # JT. Uncomment the error_page directive below to use the orcid 404 error page in /404 - #error_page 404 ../404; + location ~ ^/ui/(.*) { + # Redirect requests for `/ui/(.*)` to the Angular application in the preferred language of the browser + # For reference on multi language support see: https://angular.io/guide/i18n-common-deploy#configure-a-server + rewrite ^/ui/(.*)$ /$accept_language/$1 break; - # redirect server error pages to the static page /50x.html - # - #error_page 500 502 503 504 /50x.html; - #location = /50x.html { - # root /usr/share/nginx/html; - #} + # Configure a fallback route in the NGinX server to serve the index.html when requests arrive for `/ui/login` + # Requires static html content to have a `base-href=./` entry + try_files $uri $uri/ /$accept_language/index.html?$args; + + # Or use the following line instead + # auto-index on; + } + + } } diff --git a/ui/container-files/etc/nginx/nginx.conf b/ui/container-files/etc/nginx/nginx.conf index ee8c7fd6b..22027e11c 100644 --- a/ui/container-files/etc/nginx/nginx.conf +++ b/ui/container-files/etc/nginx/nginx.conf @@ -4,12 +4,10 @@ worker_processes auto; error_log /var/log/nginx/error.log notice; pid /var/run/nginx.pid; - events { worker_connections 1024; } - http { include /etc/nginx/mime.types; default_type application/octet-stream; @@ -27,5 +25,24 @@ http { #gzip on; + # Browser preferred language detection (does NOT require AcceptLanguageModule) + # For reference on multi language support see: https://angular.io/guide/i18n-common-deploy#configure-a-server + map $http_accept_language $accept_language { + default en; + ~*^cs cs; + ~*^de de; + ~*^es es; + ~*^fr fr; + ~*^it it; + ~*^ja ja; + ~*^ko ko; + ~*^pt pt; + ~*^ru ru; + ~*^ui ui; + ~*^zh-CN zh-CN; + ~*^zh-TW zh-TW; + } + include /etc/nginx/conf.d/*.conf; -} \ No newline at end of file + +} diff --git a/ui/package-lock.json b/ui/package-lock.json index 3f05d5d24..ed12ef05f 100644 --- a/ui/package-lock.json +++ b/ui/package-lock.json @@ -23,6 +23,7 @@ "@ng-bootstrap/ng-bootstrap": "^15.1.1", "bootstrap": "^5.3.2", "moment": "^2.29.4", + "ngx-cookie-service": "^16.1.0", "ngx-webstorage": "^12.0.0", "rxjs": "~7.5.0", "tslib": "^2.3.0", @@ -47,6 +48,7 @@ "karma-coverage": "~2.2.0", "karma-jasmine": "~5.1.0", "karma-jasmine-html-reporter": "~2.0.0", + "ng-extract-i18n-merge": "^2.9.1", "prettier": "^3.0.3", "typescript": "~4.9.5" } @@ -5829,9 +5831,9 @@ } }, "node_modules/axios": { - "version": "1.5.1", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.5.1.tgz", - "integrity": "sha512-Q28iYCWzNHjAm+yEAot5QaAMxhMghWLFVf7rRdwhUI+c2jix2DUXjAHXVi+s1ibs3mjPO/cCgbA++3BjD0vP/A==", + "version": "1.6.3", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.6.3.tgz", + "integrity": "sha512-fWyNdeawGam70jXSVlKl+SUNVcL6j6W79CuSIPfi6HnDUmSCH6gyUys/HrqHeA/wU0Az41rRgean494d0Jb+ww==", "dev": true, "dependencies": { "follow-redirects": "^1.15.0", @@ -11065,6 +11067,100 @@ "integrity": "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==", "dev": true }, + "node_modules/ng-extract-i18n-merge": { + "version": "2.9.1", + "resolved": "https://registry.npmjs.org/ng-extract-i18n-merge/-/ng-extract-i18n-merge-2.9.1.tgz", + "integrity": "sha512-EJAgJrV2ZSRoH1njMI9lLLtLJkwabkk41ZZyV+U+6h8e5vDCM4zPGjm0NNZFy+YP+/ST+nlvi2CxprDXnjS8BQ==", + "dev": true, + "dependencies": { + "@angular-devkit/architect": "^0.1301.0 || ^0.1401.0 || ^0.1501.0 || ^0.1601.0 || ^0.1700.0", + "@angular-devkit/core": "^13.0.0 || ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0", + "@angular-devkit/schematics": "^13.0.0 || ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0", + "@schematics/angular": "^13.0.0 || ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0", + "xmldoc": "^1.1.2" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "@angular-devkit/build-angular": "^13.0.0 || ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" + } + }, + "node_modules/ng-extract-i18n-merge/node_modules/@angular-devkit/architect": { + "version": "0.1700.9", + "resolved": "https://registry.npmjs.org/@angular-devkit/architect/-/architect-0.1700.9.tgz", + "integrity": "sha512-B8OeUrvJj5JsfOJIibpoVjvuZzthPFxf1LvuUXTyQcqDUscJAe/RJBc2woT6ss13Iv/HWt8mgaMPP4CccckdNg==", + "dev": true, + "dependencies": { + "@angular-devkit/core": "17.0.9", + "rxjs": "7.8.1" + }, + "engines": { + "node": "^18.13.0 || >=20.9.0", + "npm": "^6.11.0 || ^7.5.6 || >=8.0.0", + "yarn": ">= 1.13.0" + } + }, + "node_modules/ng-extract-i18n-merge/node_modules/@angular-devkit/core": { + "version": "17.0.9", + "resolved": "https://registry.npmjs.org/@angular-devkit/core/-/core-17.0.9.tgz", + "integrity": "sha512-r5jqwpWOgowqe9KSDqJ3iSbmsEt2XPjSvRG4DSI2T9s31bReoMtreo8b7wkRa2B3hbcDnstFbn8q27VvJDqRaQ==", + "dev": true, + "dependencies": { + "ajv": "8.12.0", + "ajv-formats": "2.1.1", + "jsonc-parser": "3.2.0", + "picomatch": "3.0.1", + "rxjs": "7.8.1", + "source-map": "0.7.4" + }, + "engines": { + "node": "^18.13.0 || >=20.9.0", + "npm": "^6.11.0 || ^7.5.6 || >=8.0.0", + "yarn": ">= 1.13.0" + }, + "peerDependencies": { + "chokidar": "^3.5.2" + }, + "peerDependenciesMeta": { + "chokidar": { + "optional": true + } + } + }, + "node_modules/ng-extract-i18n-merge/node_modules/picomatch": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-3.0.1.tgz", + "integrity": "sha512-I3EurrIQMlRc9IaAZnqRR044Phh2DXY+55o7uJ0V+hYZAcQYSuFWsc9q5PvyDHUSCe1Qxn/iBz+78s86zWnGag==", + "dev": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/ng-extract-i18n-merge/node_modules/rxjs": { + "version": "7.8.1", + "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.1.tgz", + "integrity": "sha512-AA3TVj+0A2iuIoQkWEK/tqFjBq2j+6PO6Y0zJcvzLAFhEFIO3HL0vls9hWLncZbAAbK0mar7oZ4V079I/qPMxg==", + "dev": true, + "dependencies": { + "tslib": "^2.1.0" + } + }, + "node_modules/ngx-cookie-service": { + "version": "16.1.0", + "resolved": "https://registry.npmjs.org/ngx-cookie-service/-/ngx-cookie-service-16.1.0.tgz", + "integrity": "sha512-FrzMjsGCHZCd2sEucigMaGyzImBL0l6gwWn6jmLBhcNVx0D7P8Yvtgk9aUptlqBrVKy4c2upglSa3Ogv3679bw==", + "dependencies": { + "tslib": "^2.0.0" + }, + "peerDependencies": { + "@angular/common": "^16.0.0", + "@angular/core": "^16.0.0" + } + }, "node_modules/ngx-webstorage": { "version": "12.0.0", "resolved": "https://registry.npmjs.org/ngx-webstorage/-/ngx-webstorage-12.0.0.tgz", @@ -13134,8 +13230,7 @@ "version": "1.3.0", "resolved": "https://registry.npmjs.org/sax/-/sax-1.3.0.tgz", "integrity": "sha512-0s+oAmw9zLl1V1cS9BtZN7JAd0cW5e0QH4W3LWEK6a4LaLEA2OTpGYWDY+6XasBLtz6wkm3u1xRw95mRuJ59WA==", - "dev": true, - "optional": true + "dev": true }, "node_modules/saxes": { "version": "5.0.1", @@ -15152,6 +15247,15 @@ "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", "dev": true }, + "node_modules/xmldoc": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/xmldoc/-/xmldoc-1.3.0.tgz", + "integrity": "sha512-y7IRWW6PvEnYQZNZFMRLNJw+p3pezM4nKYPfr15g4OOW9i8VpeydycFuipE2297OvZnh3jSb2pxOt9QpkZUVng==", + "dev": true, + "dependencies": { + "sax": "^1.2.4" + } + }, "node_modules/y18n": { "version": "5.0.8", "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", @@ -19470,9 +19574,9 @@ } }, "axios": { - "version": "1.5.1", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.5.1.tgz", - "integrity": "sha512-Q28iYCWzNHjAm+yEAot5QaAMxhMghWLFVf7rRdwhUI+c2jix2DUXjAHXVi+s1ibs3mjPO/cCgbA++3BjD0vP/A==", + "version": "1.6.3", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.6.3.tgz", + "integrity": "sha512-fWyNdeawGam70jXSVlKl+SUNVcL6j6W79CuSIPfi6HnDUmSCH6gyUys/HrqHeA/wU0Az41rRgean494d0Jb+ww==", "dev": true, "requires": { "follow-redirects": "^1.15.0", @@ -23415,6 +23519,68 @@ "integrity": "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==", "dev": true }, + "ng-extract-i18n-merge": { + "version": "2.9.1", + "resolved": "https://registry.npmjs.org/ng-extract-i18n-merge/-/ng-extract-i18n-merge-2.9.1.tgz", + "integrity": "sha512-EJAgJrV2ZSRoH1njMI9lLLtLJkwabkk41ZZyV+U+6h8e5vDCM4zPGjm0NNZFy+YP+/ST+nlvi2CxprDXnjS8BQ==", + "dev": true, + "requires": { + "@angular-devkit/architect": "^0.1301.0 || ^0.1401.0 || ^0.1501.0 || ^0.1601.0 || ^0.1700.0", + "@angular-devkit/core": "^13.0.0 || ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0", + "@angular-devkit/schematics": "^13.0.0 || ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0", + "@schematics/angular": "^13.0.0 || ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0", + "xmldoc": "^1.1.2" + }, + "dependencies": { + "@angular-devkit/architect": { + "version": "0.1700.9", + "resolved": "https://registry.npmjs.org/@angular-devkit/architect/-/architect-0.1700.9.tgz", + "integrity": "sha512-B8OeUrvJj5JsfOJIibpoVjvuZzthPFxf1LvuUXTyQcqDUscJAe/RJBc2woT6ss13Iv/HWt8mgaMPP4CccckdNg==", + "dev": true, + "requires": { + "@angular-devkit/core": "17.0.9", + "rxjs": "7.8.1" + } + }, + "@angular-devkit/core": { + "version": "17.0.9", + "resolved": "https://registry.npmjs.org/@angular-devkit/core/-/core-17.0.9.tgz", + "integrity": "sha512-r5jqwpWOgowqe9KSDqJ3iSbmsEt2XPjSvRG4DSI2T9s31bReoMtreo8b7wkRa2B3hbcDnstFbn8q27VvJDqRaQ==", + "dev": true, + "requires": { + "ajv": "8.12.0", + "ajv-formats": "2.1.1", + "jsonc-parser": "3.2.0", + "picomatch": "3.0.1", + "rxjs": "7.8.1", + "source-map": "0.7.4" + } + }, + "picomatch": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-3.0.1.tgz", + "integrity": "sha512-I3EurrIQMlRc9IaAZnqRR044Phh2DXY+55o7uJ0V+hYZAcQYSuFWsc9q5PvyDHUSCe1Qxn/iBz+78s86zWnGag==", + "dev": true + }, + "rxjs": { + "version": "7.8.1", + "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.1.tgz", + "integrity": "sha512-AA3TVj+0A2iuIoQkWEK/tqFjBq2j+6PO6Y0zJcvzLAFhEFIO3HL0vls9hWLncZbAAbK0mar7oZ4V079I/qPMxg==", + "dev": true, + "requires": { + "tslib": "^2.1.0" + } + } + } + }, + "ngx-cookie-service": { + "version": "16.1.0", + "resolved": "https://registry.npmjs.org/ngx-cookie-service/-/ngx-cookie-service-16.1.0.tgz", + "integrity": "sha512-FrzMjsGCHZCd2sEucigMaGyzImBL0l6gwWn6jmLBhcNVx0D7P8Yvtgk9aUptlqBrVKy4c2upglSa3Ogv3679bw==", + "requires": { + "tslib": "^2.0.0" + } + }, "ngx-webstorage": { "version": "12.0.0", "resolved": "https://registry.npmjs.org/ngx-webstorage/-/ngx-webstorage-12.0.0.tgz", @@ -24901,8 +25067,7 @@ "version": "1.3.0", "resolved": "https://registry.npmjs.org/sax/-/sax-1.3.0.tgz", "integrity": "sha512-0s+oAmw9zLl1V1cS9BtZN7JAd0cW5e0QH4W3LWEK6a4LaLEA2OTpGYWDY+6XasBLtz6wkm3u1xRw95mRuJ59WA==", - "dev": true, - "optional": true + "dev": true }, "saxes": { "version": "5.0.1", @@ -26368,6 +26533,15 @@ "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", "dev": true }, + "xmldoc": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/xmldoc/-/xmldoc-1.3.0.tgz", + "integrity": "sha512-y7IRWW6PvEnYQZNZFMRLNJw+p3pezM4nKYPfr15g4OOW9i8VpeydycFuipE2297OvZnh3jSb2pxOt9QpkZUVng==", + "dev": true, + "requires": { + "sax": "^1.2.4" + } + }, "y18n": { "version": "5.0.8", "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", diff --git a/ui/package.json b/ui/package.json index 1aa605da5..69d9b1c16 100644 --- a/ui/package.json +++ b/ui/package.json @@ -29,6 +29,7 @@ "@ng-bootstrap/ng-bootstrap": "^15.1.1", "bootstrap": "^5.3.2", "moment": "^2.29.4", + "ngx-cookie-service": "^16.1.0", "ngx-webstorage": "^12.0.0", "rxjs": "~7.5.0", "tslib": "^2.3.0", @@ -53,6 +54,7 @@ "karma-coverage": "~2.2.0", "karma-jasmine": "~5.1.0", "karma-jasmine-html-reporter": "~2.0.0", + "ng-extract-i18n-merge": "^2.9.1", "prettier": "^3.0.3", "typescript": "~4.9.5" } diff --git a/ui/src/app/account/account.module.ts b/ui/src/app/account/account.module.ts index 59bdfd816..e4ee6c1e4 100644 --- a/ui/src/app/account/account.module.ts +++ b/ui/src/app/account/account.module.ts @@ -3,11 +3,21 @@ import { CommonModule } from '@angular/common' import { LoginComponent } from './login/login.component' import { RouterModule } from '@angular/router' import { ReactiveFormsModule } from '@angular/forms' -import { routes } from './account.route'; +import { routes } from './account.route' import { PasswordResetInitComponent } from './password/password-reset-init.component' +import { SettingsComponent } from './settings/settings.component' +import { SharedModule } from '../shared/shared.module' +import { PasswordComponent } from './password/password.component' +import { PasswordStrengthComponent } from './password/password-strength.component' @NgModule({ - declarations: [LoginComponent, PasswordResetInitComponent], - imports: [CommonModule, ReactiveFormsModule, RouterModule.forChild(routes)], + declarations: [ + LoginComponent, + PasswordResetInitComponent, + SettingsComponent, + PasswordComponent, + PasswordStrengthComponent, + ], + imports: [SharedModule, CommonModule, ReactiveFormsModule, RouterModule.forChild(routes)], }) export class AccountModule {} diff --git a/ui/src/app/account/account.route.ts b/ui/src/app/account/account.route.ts index 48f4f60fd..dac2ef290 100644 --- a/ui/src/app/account/account.route.ts +++ b/ui/src/app/account/account.route.ts @@ -1,6 +1,9 @@ import { Routes } from '@angular/router' import { LoginComponent } from './login/login.component' import { PasswordResetInitComponent } from './password/password-reset-init.component' +import { SettingsComponent } from './settings/settings.component' +import { AuthGuard } from './auth.guard' +import { PasswordComponent } from './password/password.component' export const routes: Routes = [ { @@ -15,4 +18,22 @@ export const routes: Routes = [ pageTitle: 'global.menu.account.password.string', }, }, + { + path: 'settings', + component: SettingsComponent, + data: { + authorities: ['ROLE_USER'], + pageTitle: 'global.menu.account.settings.string', + }, + canActivate: [AuthGuard], + }, + { + path: 'password', + component: PasswordComponent, + data: { + authorities: ['ROLE_USER'], + pageTitle: 'global.menu.account.password.string', + }, + canActivate: [AuthGuard], + }, ] diff --git a/ui/src/app/account/auth.guard.ts b/ui/src/app/account/auth.guard.ts index 4f49e9871..67718ff98 100644 --- a/ui/src/app/account/auth.guard.ts +++ b/ui/src/app/account/auth.guard.ts @@ -15,8 +15,6 @@ export const AuthGuard = (route: ActivatedRouteSnapshot, state: RouterStateSnaps return accountService.getAccountData().pipe( filter((account) => account !== undefined), map((account) => { - console.log(authorities, account) - if (account) { const hasAnyAuthority = accountService.hasAnyAuthority(authorities) if (hasAnyAuthority) { diff --git a/ui/src/app/account/login/login.component.html b/ui/src/app/account/login/login.component.html index 70dcfb5d4..c7b6e1b73 100644 --- a/ui/src/app/account/login/login.component.html +++ b/ui/src/app/account/login/login.component.html @@ -2,49 +2,45 @@
-
+
Failed to sign in! Please check your credentials and try again.
- - +
- - +
-
+
Please enter the MFA code from your authenticator app
-
+
Invalid MFA code
- +
- @@ -55,7 +51,7 @@ class="alert-link" (click)="requestResetPassword()" (keypress)="requestResetPassword()" - jhiTranslate="login.password.forgot.string" + i18n="@@login.password.forgot.string" >Did you forget your password?
diff --git a/ui/src/app/account/login/login.component.ts b/ui/src/app/account/login/login.component.ts index 9d7d54a26..4dc92fe95 100644 --- a/ui/src/app/account/login/login.component.ts +++ b/ui/src/app/account/login/login.component.ts @@ -43,7 +43,6 @@ export class LoginComponent implements AfterViewInit, OnDestroy { ngOnDestroy(): void { this.sub?.unsubscribe() - console.log('test') } ngAfterViewInit() { diff --git a/ui/src/app/account/model/password-reset-init-result.model.ts b/ui/src/app/account/model/password-reset-init-result.model.ts new file mode 100644 index 000000000..614e41cdb --- /dev/null +++ b/ui/src/app/account/model/password-reset-init-result.model.ts @@ -0,0 +1,17 @@ +export interface IPasswordResetInitResult { + success: boolean + emailNotFound: boolean + generalError: boolean +} + +export class PasswordResetInitResult implements IPasswordResetInitResult { + success: boolean + emailNotFound: boolean + generalError: boolean + + constructor(success: boolean, emailNotFound: boolean, generalError: boolean) { + this.emailNotFound = emailNotFound + this.generalError = generalError + this.success = success + } +} diff --git a/ui/src/app/account/password/password-reset-init.component.html b/ui/src/app/account/password/password-reset-init.component.html index c21462f25..9d684b6f4 100644 --- a/ui/src/app/account/password/password-reset-init.component.html +++ b/ui/src/app/account/password/password-reset-init.component.html @@ -1,46 +1,81 @@
-
-
-

Reset your password

+
+
+

Reset your password

-
- Email address isn't registered! Please check and try again. -
+
+ Email address isn't registered! Please check and try again. +
-
-

Enter the email address you used to register.

-
+
+

Enter the email address you used to register.

+
-
-

Check your emails for details on how to reset your password.

-
+
+

+ Check your emails for details on how to reset your password. +

+
-
-
- - -
- - Your email is required. - - - Your email is invalid. - - - Your email is required to be at least 5 characters. - - - Your email cannot be longer than 100 characters. - -
-
- -
+
+
+ + +
+ + Your email is required. + + + Your email is invalid. + + + Your email is required to be at least 5 characters. + + + Your email cannot be longer than 100 characters. + +
+ +
+
diff --git a/ui/src/app/account/password/password-reset-init.component.spec.ts b/ui/src/app/account/password/password-reset-init.component.spec.ts index a9f124a90..30bddc76d 100644 --- a/ui/src/app/account/password/password-reset-init.component.spec.ts +++ b/ui/src/app/account/password/password-reset-init.component.spec.ts @@ -1,11 +1,12 @@ import { ComponentFixture, TestBed, inject } from '@angular/core/testing' import { of, throwError } from 'rxjs' -import { PasswordResetInitService } from '../service/password-reset-init.service' +import { PasswordService } from '../service/password.service' import { PasswordResetInitComponent } from './password-reset-init.component' import { EMAIL_NOT_FOUND_TYPE } from 'src/app/app.constants' import { HttpClientTestingModule } from '@angular/common/http/testing' import { By } from '@angular/platform-browser' +import { PasswordResetInitResult } from '../model/password-reset-init-result.model' describe('Component Tests', () => { describe('PasswordResetInitComponent', () => { @@ -25,38 +26,30 @@ describe('Component Tests', () => { expect(comp.errorEmailNotExists).toBeUndefined() }) - it('notifies of success upon successful requestReset', inject( - [PasswordResetInitService], - (service: PasswordResetInitService) => { - spyOn(service, 'initPasswordReset').and.returnValue(of({})) - comp.resetRequestForm.patchValue({ - email: 'user@domain.com', - }) + it('notifies of success upon successful requestReset', inject([PasswordService], (service: PasswordService) => { + spyOn(service, 'initPasswordReset').and.returnValue(of(new PasswordResetInitResult(true, false, false))) + comp.resetRequestForm.patchValue({ + email: 'user@domain.com', + }) - comp.requestReset() - const emailControl = comp.resetRequestForm.get('email')! - emailControl.setValue('valid@email.com') - fixture.detectChanges() - expect(comp.success).toEqual('OK') - expect(comp.error).toBeUndefined() - expect(comp.errorEmailNotExists).toBeUndefined() - fixture.whenStable().then(() => { - expect(true).toBeFalsy() - const button = fixture.debugElement.query(By.css('#reset')) - expect(button.nativeElement.disabled).toBeFalsy() - }) - } - )) + comp.requestReset() + const emailControl = comp.resetRequestForm.get('email')! + emailControl.setValue('valid@email.com') + fixture.detectChanges() + expect(comp.success).toEqual('OK') + expect(comp.error).toBeUndefined() + expect(comp.errorEmailNotExists).toBeUndefined() + fixture.whenStable().then(() => { + expect(true).toBeFalsy() + const button = fixture.debugElement.query(By.css('#reset')) + expect(button.nativeElement.disabled).toBeFalsy() + }) + })) it('notifies of unknown email upon email address not registered/400', inject( - [PasswordResetInitService], - (service: PasswordResetInitService) => { - spyOn(service, 'initPasswordReset').and.returnValue( - throwError({ - status: 400, - error: { type: EMAIL_NOT_FOUND_TYPE }, - }) - ) + [PasswordService], + (service: PasswordService) => { + spyOn(service, 'initPasswordReset').and.returnValue(of(new PasswordResetInitResult(false, true, false))) comp.resetRequestForm.patchValue({ email: 'user@domain.com', }) @@ -69,26 +62,18 @@ describe('Component Tests', () => { } )) - it('notifies of error upon error response', inject( - [PasswordResetInitService], - (service: PasswordResetInitService) => { - spyOn(service, 'initPasswordReset').and.returnValue( - throwError({ - status: 503, - data: 'something else', - }) - ) - comp.resetRequestForm.patchValue({ - email: 'user@domain.com', - }) - comp.requestReset() + it('notifies of error upon error response', inject([PasswordService], (service: PasswordService) => { + spyOn(service, 'initPasswordReset').and.returnValue(of(new PasswordResetInitResult(false, false, true))) + comp.resetRequestForm.patchValue({ + email: 'user@domain.com', + }) + comp.requestReset() - expect(service.initPasswordReset).toHaveBeenCalledWith('user@domain.com') - expect(comp.success).toBeUndefined() - expect(comp.errorEmailNotExists).toBeUndefined() - expect(comp.error).toEqual('ERROR') - } - )) + expect(service.initPasswordReset).toHaveBeenCalledWith('user@domain.com') + expect(comp.success).toBeUndefined() + expect(comp.errorEmailNotExists).toBeUndefined() + expect(comp.error).toEqual('ERROR') + })) it('should disable the submit button for invalid email address', () => { const emailControl = comp.resetRequestForm.get('email')! diff --git a/ui/src/app/account/password/password-reset-init.component.ts b/ui/src/app/account/password/password-reset-init.component.ts index 876147802..92883815d 100644 --- a/ui/src/app/account/password/password-reset-init.component.ts +++ b/ui/src/app/account/password/password-reset-init.component.ts @@ -1,8 +1,9 @@ import { Component, AfterViewInit, Renderer2 } from '@angular/core' import { FormBuilder, FormGroup, Validators } from '@angular/forms' -import { PasswordResetInitService } from '../service/password-reset-init.service' +import { PasswordService } from '../service/password.service' import { EMAIL_NOT_FOUND_TYPE } from 'src/app/app.constants' +import { PasswordResetInitResult } from '../model/password-reset-init-result.model' @Component({ selector: 'app-password-reset-init', @@ -17,7 +18,7 @@ export class PasswordResetInitComponent implements AfterViewInit { }) constructor( - private passwordResetInitService: PasswordResetInitService, + private passwordResetInitService: PasswordService, private renderer: Renderer2, private fb: FormBuilder ) {} @@ -31,19 +32,20 @@ export class PasswordResetInitComponent implements AfterViewInit { this.errorEmailNotExists = undefined if (this.resetRequestForm.get(['email'])) { - this.passwordResetInitService.initPasswordReset(this.resetRequestForm.get(['email'])!.value).subscribe({ - next: () => { - this.success = 'OK' - }, - error: (response) => { - this.success = undefined - if (response.status === 400 && response.error.type === EMAIL_NOT_FOUND_TYPE) { - this.errorEmailNotExists = 'ERROR' + this.passwordResetInitService + .initPasswordReset(this.resetRequestForm.get(['email'])!.value) + .subscribe((result: PasswordResetInitResult | null) => { + if (result && result.success) { + this.success = 'OK' } else { - this.error = 'ERROR' + this.success = undefined + if (result && result.emailNotFound) { + this.errorEmailNotExists = 'ERROR' + } else { + this.error = 'ERROR' + } } - }, - }) + }) } } } diff --git a/ui/src/app/account/password/password-strength.component.html b/ui/src/app/account/password/password-strength.component.html new file mode 100644 index 000000000..00bbbd0c5 --- /dev/null +++ b/ui/src/app/account/password/password-strength.component.html @@ -0,0 +1,10 @@ +
+ Password strength: +
    +
  • +
  • +
  • +
  • +
  • +
+
\ No newline at end of file diff --git a/ui/src/app/account/password/password-strength.component.scss b/ui/src/app/account/password/password-strength.component.scss new file mode 100644 index 000000000..88af5f5ef --- /dev/null +++ b/ui/src/app/account/password/password-strength.component.scss @@ -0,0 +1,21 @@ +ul#strength { + display: inline; + list-style: none; + margin: 0; + margin-left: 15px; + padding: 0; + vertical-align: 2px; + } + + .point { + background: #ddd; + border-radius: 2px; + display: inline-block; + height: 5px; + margin-right: 1px; + width: 20px; + &:last-child { + margin: 0 !important; + } + } + \ No newline at end of file diff --git a/ui/src/app/account/password/password-strength.component.spec.ts b/ui/src/app/account/password/password-strength.component.spec.ts new file mode 100644 index 000000000..592fabb7a --- /dev/null +++ b/ui/src/app/account/password/password-strength.component.spec.ts @@ -0,0 +1,47 @@ +import { ComponentFixture, TestBed, async } from '@angular/core/testing' +import { PasswordStrengthComponent } from './password-strength.component' + +describe('Component Tests', () => { + describe('PasswordStrengthBarComponent', () => { + let comp: PasswordStrengthComponent + let fixture: ComponentFixture + + beforeEach(async(() => { + TestBed.configureTestingModule({ + declarations: [PasswordStrengthComponent], + }) + .overrideTemplate(PasswordStrengthComponent, '') + .compileComponents() + })) + + beforeEach(() => { + fixture = TestBed.createComponent(PasswordStrengthComponent) + comp = fixture.componentInstance + }) + + describe('PasswordStrengthComponents', () => { + it('should initialize with default values', () => { + expect(comp.measureStrength('')).toBe(0) + expect(comp.colors).toEqual(['#F00', '#F90', '#FF0', '#9F0', '#0F0']) + expect(comp.getColor(0).idx).toBe(1) + expect(comp.getColor(0).col).toBe(comp.colors[0]) + }) + + it('should increase strength upon password value change', () => { + expect(comp.measureStrength('')).toBe(0) + expect(comp.measureStrength('aa')).toBeGreaterThanOrEqual(comp.measureStrength('')) + expect(comp.measureStrength('aa^6')).toBeGreaterThanOrEqual(comp.measureStrength('aa')) + expect(comp.measureStrength('Aa090(**)')).toBeGreaterThanOrEqual(comp.measureStrength('aa^6')) + expect(comp.measureStrength('Aa090(**)+-07365')).toBeGreaterThanOrEqual(comp.measureStrength('Aa090(**)')) + }) + + it('should change the color based on strength', () => { + expect(comp.getColor(0).col).toBe(comp.colors[0]) + expect(comp.getColor(11).col).toBe(comp.colors[1]) + expect(comp.getColor(22).col).toBe(comp.colors[2]) + expect(comp.getColor(33).col).toBe(comp.colors[3]) + expect(comp.getColor(44).col).toBe(comp.colors[4]) + }) + }) + }) +}) diff --git a/ui/src/app/account/password/password-strength.component.ts b/ui/src/app/account/password/password-strength.component.ts new file mode 100644 index 000000000..6c0faad46 --- /dev/null +++ b/ui/src/app/account/password/password-strength.component.ts @@ -0,0 +1,77 @@ +import { Component, ElementRef, Input, Renderer2 } from '@angular/core' + +@Component({ + selector: 'app-password-strength', + templateUrl: './password-strength.component.html', + styleUrls: ['./password-strength.component.scss'], +}) +export class PasswordStrengthComponent { + colors = ['#F00', '#F90', '#FF0', '#9F0', '#0F0'] + + constructor( + private renderer: Renderer2, + private elementRef: ElementRef + ) {} + + measureStrength(p: string): number { + let force = 0 + const regex = /[$-/:-?{-~!"^_`[\]]/g // " + const lowerLetters = /[a-z]+/.test(p) + const upperLetters = /[A-Z]+/.test(p) + const numbers = /[0-9]+/.test(p) + const symbols = regex.test(p) + + const flags = [lowerLetters, upperLetters, numbers, symbols] + const passedMatches = flags.filter((isMatchedFlag: boolean) => { + return isMatchedFlag === true + }).length + + force += 2 * p.length + (p.length >= 10 ? 1 : 0) + force += passedMatches * 10 + + // penalty (short password) + force = p.length <= 6 ? Math.min(force, 10) : force + + // penalty (poor variety of characters) + force = passedMatches === 1 ? Math.min(force, 10) : force + force = passedMatches === 2 ? Math.min(force, 20) : force + force = passedMatches === 3 ? Math.min(force, 40) : force + + return force + } + + getColor(s: number): any { + let idx = 0 + if (s <= 10) { + idx = 0 + } else if (s <= 20) { + idx = 1 + } else if (s <= 30) { + idx = 2 + } else if (s <= 40) { + idx = 3 + } else { + idx = 4 + } + return { idx: idx + 1, col: this.colors[idx] } + } + + @Input() + set passwordToCheck(password: string | undefined | null) { + if (password) { + const c = this.getColor(this.measureStrength(password)) + const element = this.elementRef.nativeElement + if (element.className) { + this.renderer.addClass(element, element.className) + } + const lis = element.getElementsByTagName('li') + for (let i = 0; i < lis.length; i++) { + if (i < c.idx) { + this.renderer.setStyle(lis[i], 'backgroundColor', c.col) + } else { + this.renderer.setStyle(lis[i], 'backgroundColor', '#DDD') + } + } + } + } +} diff --git a/ui/src/app/account/password/password.component.html b/ui/src/app/account/password/password.component.html new file mode 100644 index 000000000..d2dc6120a --- /dev/null +++ b/ui/src/app/account/password/password.component.html @@ -0,0 +1,78 @@ +
+
+
+

{{passwordForUsernameString}}

+ +
+ Password changed! +
+
+ An error has occurred! The password could not be changed. +
+ +
+ The password and its confirmation do not match! +
+ +
+ +
+ + +
+ + Your password is required. + +
+
+
+ + +
+ + Your password is required. + + + Your password is required to be at least 4 characters. + + + Your password cannot be longer than 50 characters. + +
+ +
+
+ + +
+ + Your confirmation password is required. + + + Your confirmation password is required to be at least 4 characters. + + + Your confirmation password cannot be longer than 50 characters. + +
+
+ +
+
+
+
diff --git a/ui/src/app/account/password/password.component.scss b/ui/src/app/account/password/password.component.scss new file mode 100644 index 000000000..e69de29bb diff --git a/ui/src/app/account/password/password.component.spec.ts b/ui/src/app/account/password/password.component.spec.ts new file mode 100644 index 000000000..bfe51a81e --- /dev/null +++ b/ui/src/app/account/password/password.component.spec.ts @@ -0,0 +1,107 @@ +import { ComponentFixture, TestBed, async } from '@angular/core/testing' +import { HttpClientModule, HttpResponse } from '@angular/common/http' +import { FormBuilder } from '@angular/forms' +import { Observable, of, throwError } from 'rxjs' +import { PasswordComponent } from './password.component' +import { PasswordService } from '../service/password.service' +import { AccountService } from '../service/account.service' + +describe('PasswordComponent', () => { + let comp: PasswordComponent + let fixture: ComponentFixture + let service: PasswordService + let accountServiceSpy: jasmine.SpyObj + + beforeEach(() => { + accountServiceSpy = jasmine.createSpyObj('AccountService', [ + 'getAccountData', + 'getUserName', + 'save', + 'getMfaSetup', + 'enableMfa', + 'disableMfa', + ]) + TestBed.configureTestingModule({ + imports: [HttpClientModule], + declarations: [PasswordComponent], + providers: [FormBuilder, { provide: AccountService, useValue: accountServiceSpy }], + }) + .overrideTemplate(PasswordComponent, '') + .compileComponents() + + accountServiceSpy = TestBed.inject(AccountService) as jasmine.SpyObj + fixture = TestBed.createComponent(PasswordComponent) + comp = fixture.componentInstance + service = fixture.debugElement.injector.get(PasswordService) + }) + + it('should show error if passwords do not match', () => { + // GIVEN + comp.passwordForm.patchValue({ + newPassword: 'password1', + confirmPassword: 'password2', + }) + // WHEN + comp.changePassword() + // THEN + expect(comp.doNotMatch).toBe('ERROR') + expect(comp.error).toBeUndefined() + expect(comp.success).toBeUndefined() + }) + + it('should call Auth.changePassword when passwords match', () => { + // GIVEN + const passwordValues = { + currentPassword: 'oldPassword', + newPassword: 'myPassword', + } + + spyOn(service, 'updatePassword').and.returnValue(of(new HttpResponse({ body: true }))) + + comp.passwordForm.patchValue({ + currentPassword: passwordValues.currentPassword, + newPassword: passwordValues.newPassword, + confirmPassword: passwordValues.newPassword, + }) + + // WHEN + comp.changePassword() + + // THEN + expect(service.updatePassword).toHaveBeenCalledWith(passwordValues.newPassword, passwordValues.currentPassword) + }) + + it('should set success to OK upon success', function () { + // GIVEN + spyOn(service, 'updatePassword').and.returnValue(of(new HttpResponse({ body: true }))) + comp.passwordForm.patchValue({ + newPassword: 'myPassword', + confirmPassword: 'myPassword', + }) + + // WHEN + comp.changePassword() + + // THEN + expect(comp.doNotMatch).toBeUndefined() + expect(comp.error).toBeUndefined() + expect(comp.success).toBe('OK') + }) + + it('should notify of error if change password fails', function () { + // GIVEN + spyOn(service, 'updatePassword').and.returnValue(throwError('ERROR')) + comp.passwordForm.patchValue({ + newPassword: 'myPassword', + confirmPassword: 'myPassword', + }) + + // WHEN + comp.changePassword() + + // THEN + expect(comp.doNotMatch).toBeUndefined() + expect(comp.success).toBeUndefined() + expect(comp.error).toBe('ERROR') + }) +}) diff --git a/ui/src/app/account/password/password.component.ts b/ui/src/app/account/password/password.component.ts new file mode 100644 index 000000000..909205d8c --- /dev/null +++ b/ui/src/app/account/password/password.component.ts @@ -0,0 +1,59 @@ +import { Component, OnInit } from '@angular/core' +import { FormBuilder, FormGroup, Validators } from '@angular/forms' +import { PasswordService } from '../service/password.service' +import { AccountService } from '../service/account.service' + +@Component({ + selector: 'app-password', + templateUrl: './password.component.html', + styleUrls: ['./password.component.scss'], +}) +export class PasswordComponent implements OnInit { + doNotMatch: string | undefined + error: string | undefined + success: string | undefined + username: string | undefined | null = null + passwordForUsernameString: string | undefined | null = null + account: any + passwordForm = this.fb.group({ + currentPassword: ['', [Validators.required]], + newPassword: ['', [Validators.required, Validators.minLength(4), Validators.maxLength(50)]], + confirmPassword: ['', [Validators.required, Validators.minLength(4), Validators.maxLength(50)]], + }) + + constructor( + private passwordService: PasswordService, + private accountService: AccountService, + private fb: FormBuilder + ) {} + + ngOnInit() { + this.accountService.getAccountData().subscribe((account) => { + this.account = account + this.username = this.accountService.getUsername() + this.passwordForUsernameString = $localize`:@@password.title.string:Password for ${this.username} (You)` + console.log('username:', this.username) + }) + } + + changePassword() { + const newPassword = this.passwordForm.get(['newPassword'])?.value + if (newPassword !== this.passwordForm.get(['confirmPassword'])?.value) { + this.error = undefined + this.success = undefined + this.doNotMatch = 'ERROR' + } else { + this.doNotMatch = undefined + this.passwordService.updatePassword(newPassword, this.passwordForm.get(['currentPassword'])?.value).subscribe( + () => { + this.error = undefined + this.success = 'OK' + }, + () => { + this.success = undefined + this.error = 'ERROR' + } + ) + } + } +} diff --git a/ui/src/app/account/service/account.service.ts b/ui/src/app/account/service/account.service.ts index ac9bade49..6f8b4447c 100644 --- a/ui/src/app/account/service/account.service.ts +++ b/ui/src/app/account/service/account.service.ts @@ -4,6 +4,7 @@ import { HttpClient, HttpHeaders, HttpResponse } from '@angular/common/http' import { BehaviorSubject, EMPTY, Observable, Subject, catchError, map, of, takeUntil, tap } from 'rxjs' import { IAccount } from '../model/account.model' +import { CookieService } from 'ngx-cookie-service' // TODO: uncomment when memberservice is added or change the account service so that this logic is absent from the account service //import { MSMemberService } from 'app/entities/member/member.service'; @@ -19,6 +20,7 @@ export class AccountService { // TODO: uncomment when language service is implemented //private languageService: JhiLanguageService, private sessionStorage: SessionStorageService, + private cookieService: CookieService, private http: HttpClient // TODO: uncomment when memberservice is added or change the account service so that this logic is absent from the account service //private memberService: MSMemberService ) {} @@ -44,6 +46,9 @@ export class AccountService { if (response && response.body) { this.authenticated = true const account: IAccount = response.body + if (account.langKey) { + this.cookieService.set('locale', account.langKey) + } this.accountData.next(account) // After retrieve the account info, the language will be changed to @@ -64,20 +69,44 @@ export class AccountService { ) } - getMfaSetup(): Observable> { - return this.http.get('/services/userservice/api/account/mfa', { observe: 'response' }) + getMfaSetup(): Observable<{ secret: string; otp: string; qrCode: any }> { + return this.http.get('/services/userservice/api/account/mfa') } - save(account: any): Observable> { - return this.http.post('/services/userservice/api/account', account, { observe: 'response' }) + save(account: IAccount): Observable { + const headers = { 'Accept-Language': account.langKey } + return this.http.post('/services/userservice/api/account', account, { observe: 'response', headers }).pipe( + map((res: HttpResponse) => this.isSuccess(res)), + catchError((err) => { + return of(false) + }) + ) } - enableMfa(mfaSetup: any): Observable> { - return this.http.post('/services/userservice/api/account/mfa/on', mfaSetup, { observe: 'response' }) + isSuccess(res: HttpResponse): boolean { + if (res.status == 200) { + return true + } + return false } - disableMfa(): Observable> { - return this.http.post('/services/userservice/api/account/mfa/off', null, { observe: 'response' }) + enableMfa(mfaSetup: any): Observable { + return this.http.post('/services/userservice/api/account/mfa/on', mfaSetup, { observe: 'response' }).pipe( + map((res: HttpResponse) => res.body), + catchError((err) => { + console.error('error enabling mfa') + return of(null) + }) + ) + } + + disableMfa(): Observable { + return this.http.post('/services/userservice/api/account/mfa/off', null, { observe: 'response' }).pipe( + map((res: HttpResponse) => this.isSuccess(res)), + catchError((err) => { + return of(false) + }) + ) } // TODO: any - this seems to only be used for logging out (only ever receives null as arg) clearAccountData() { @@ -86,8 +115,6 @@ export class AccountService { } hasAnyAuthority(authorities: string[]): boolean { - console.log(authorities, this.accountData.value?.authorities) - if (!this.authenticated || !this.accountData || !this.accountData.value?.authorities) { return false } @@ -136,25 +163,25 @@ export class AccountService { return this.isIdentityResolved() ? this.accountData.value!.imageUrl : null } - getUserName(): string | null { - let userName: string | null = null + getUsername(): string | null { + let username: string | null = null if (this.isIdentityResolved()) { if (this.accountData.value!.firstName) { - userName = this.accountData.value!.firstName + username = this.accountData.value!.firstName } if (this.accountData.value!.lastName) { - if (userName) { - userName = userName + ' ' + this.accountData.value!.lastName + if (username) { + username = username + ' ' + this.accountData.value!.lastName } else { - userName = this.accountData.value!.lastName + username = this.accountData.value!.lastName } } - if (userName == null) { - userName = this.accountData.value!.email + if (username == null) { + username = this.accountData.value!.email } } - return userName + return username } getSalesforceId(): string | null { diff --git a/ui/src/app/account/service/password-reset-init.service.ts b/ui/src/app/account/service/password-reset-init.service.ts deleted file mode 100644 index 965c0802c..000000000 --- a/ui/src/app/account/service/password-reset-init.service.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { Injectable } from '@angular/core' -import { HttpClient } from '@angular/common/http' -import { Observable } from 'rxjs' - -@Injectable({ providedIn: 'root' }) -export class PasswordResetInitService { - constructor(private http: HttpClient) {} - - initPasswordReset(mail: string): Observable { - return this.http.post('/services/userservice/api/account/reset-password/init', mail) - } -} diff --git a/ui/src/app/account/service/password.service.ts b/ui/src/app/account/service/password.service.ts new file mode 100644 index 000000000..6c7b8fbb6 --- /dev/null +++ b/ui/src/app/account/service/password.service.ts @@ -0,0 +1,38 @@ +import { Injectable } from '@angular/core' +import { HttpClient, HttpResponse } from '@angular/common/http' +import { Observable, map, catchError, of } from 'rxjs' +import { PasswordResetInitResult } from '../model/password-reset-init-result.model' +import { EMAIL_NOT_FOUND_TYPE } from 'src/app/app.constants' + +@Injectable({ providedIn: 'root' }) +export class PasswordService { + constructor(private http: HttpClient) {} + + initPasswordReset(mail: string): Observable { + return this.http.post('/services/userservice/api/account/reset-password/init', mail, { observe: 'response' }).pipe( + map((res: HttpResponse) => this.getResult(res)), + catchError((err) => { + return of(null) + }) + ) + } + + updatePassword(newPassword: string, currentPassword: string): Observable { + return this.http.post('/services/userservice/api/account/change-password', { + currentPassword, + newPassword, + }) + } + + getResult(res: HttpResponse): PasswordResetInitResult { + if (res.status == 200) { + return new PasswordResetInitResult(true, false, false) + } + + if (res.status === 400 && res.body.error.type === EMAIL_NOT_FOUND_TYPE) { + return new PasswordResetInitResult(false, false, true) + } + + return new PasswordResetInitResult(false, true, false) + } +} diff --git a/ui/src/app/account/settings/settings.component.html b/ui/src/app/account/settings/settings.component.html new file mode 100644 index 000000000..ec3ce29c6 --- /dev/null +++ b/ui/src/app/account/settings/settings.component.html @@ -0,0 +1,248 @@ +
+
+
+

Personal details

+
+ +
+ Settings saved! +
+ +
+
+ + +
+ + Your first name is required. + + + Your first name is required to be at least 1 character. + + + Your first name cannot be longer than 50 characters. + +
+
+
+ + +
+ + Your last name is required. + + + Your last name is required to be at least 1 character. + + + Your last name cannot be longer than 50 characters. + +
+
+
+ + +
+
+ + +
+ +
+
+ +
+

Security

+
+
+

+ Add extra security to your ORCID member portal account by enabling two-factor authentication. Each time you + sign in, you'll be prompted to enter a six-digit code we send to your preferred authentication application. +

+ + +
+
2FA settings updated
+
+
+
    +
  • + Install a two-factor authentication app
    A 2FA app is required to create the six-digit code you need + to access your account each time you sign in. Most apps are for mobile devices; some are also available + as desktop or web-based apps. Download and install your preferred 2FA app, such as + Google Authenticator, FreeOTP, or Authy. +
  • +
  • + Scan this QR code with your device
    Open your 2FA app and scan the image below. +
  • +
+
+
+
+
+ QR Code +
+
+
+
+

+ {{ mfaSetup.secret }} +

+
+
+
+
+
    +
  • + Can't scan the QR code?
    Get a text code and enter it into your 2FA app instead +
  • +
  • + Enter the six-digit code from the app
    After scanning the QR code or entering in the text code, your + 2FA app will display a six-digit code. Enter this code in the box below and click Save. +
  • +
+
+
+
+
+
+ Incorrect verification code +
+ +
+
+
+
+

+ Make a note of the following backup codes, this is the only time they will be shown. +

+ + + + +
{{ backupCode }}
+
+
+ +
+
+
+
+
diff --git a/ui/src/app/account/settings/settings.component.scss b/ui/src/app/account/settings/settings.component.scss new file mode 100644 index 000000000..e69de29bb diff --git a/ui/src/app/account/settings/settings.component.spec.ts b/ui/src/app/account/settings/settings.component.spec.ts new file mode 100644 index 000000000..d9af8c81f --- /dev/null +++ b/ui/src/app/account/settings/settings.component.spec.ts @@ -0,0 +1,245 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing' + +import { SettingsComponent } from './settings.component' +import { ReactiveFormsModule } from '@angular/forms' +import { HttpClientModule, HttpResponse } from '@angular/common/http' +import { LanguageService } from 'src/app/shared/service/language.service' +import { AccountService } from '../service/account.service' +import { of, throwError } from 'rxjs' +import { FindLanguageFromKeyPipe } from 'src/app/shared/pipe/find-language-from-key' + +describe('SettingsComponent', () => { + let component: SettingsComponent + let fixture: ComponentFixture + let accountServiceSpy: jasmine.SpyObj + let languageServiceSpy: jasmine.SpyObj + + beforeEach(() => { + accountServiceSpy = jasmine.createSpyObj('AccountService', [ + 'getAccountData', + 'getUsername', + 'save', + 'getMfaSetup', + 'enableMfa', + 'disableMfa', + ]) + languageServiceSpy = jasmine.createSpyObj('LanguageService', [ + 'getAllLanguages', + 'getCurrentLanguage', + 'changeLanguage', + ]) + + TestBed.configureTestingModule({ + declarations: [SettingsComponent, FindLanguageFromKeyPipe], + imports: [ReactiveFormsModule, HttpClientModule], + providers: [ + { provide: LanguageService, useValue: languageServiceSpy }, + { provide: AccountService, useValue: accountServiceSpy }, + ], + }).compileComponents() + + fixture = TestBed.createComponent(SettingsComponent) + component = fixture.componentInstance + accountServiceSpy = TestBed.inject(AccountService) as jasmine.SpyObj + + languageServiceSpy.getAllLanguages.and.returnValue({ + en: { name: 'English' }, + es: { name: 'Español' }, + fr: { name: 'Français' }, + ja: { name: '日本語' }, + 'zh-TW': { name: '繁體中文' }, + 'zh-CN': { name: '简体中文' }, + cs: { name: 'Čeština' }, + it: { name: 'Italiano' }, + ko: { name: '한국어' }, + pt: { name: 'Português' }, + ru: { name: 'Pусский' }, + xx: { name: 'Test' }, + }) + }) + + it('should create', () => { + accountServiceSpy.getAccountData.and.returnValue( + of({ + activated: true, + authorities: ['test', 'test'], + email: 'email@email.com', + firstName: 'name', + langKey: 'en', + lastName: 'surname', + imageUrl: 'url', + salesforceId: 'sfid', + loggedAs: false, + loginAs: 'sfid', + mainContact: false, + mfaEnabled: true, + }) + ) + + expect(component).toBeTruthy() + }) + + it('should flip mfa fields when mfa state changed', () => { + accountServiceSpy.getAccountData.and.returnValue( + of({ + activated: true, + authorities: ['test', 'test'], + email: 'email@email.com', + firstName: 'name', + langKey: 'en', + lastName: 'surname', + imageUrl: 'url', + salesforceId: 'sfid', + loggedAs: false, + loginAs: 'sfid', + mainContact: false, + mfaEnabled: false, + }) + ) + accountServiceSpy.getMfaSetup.and.returnValue(of({ secret: 'test', otp: 'test', qrCode: 'test' })) + accountServiceSpy.getUsername.and.returnValue('test') + fixture.detectChanges() + + expect(component.showMfaSetup).toBeFalsy() + expect(component.showMfaBackupCodes).toBeFalsy() + + component.mfaForm.patchValue({ mfaEnabled: true }) + component.mfaEnabledStateChange() + + expect(component.showMfaSetup).toBeTruthy() + expect(component.showMfaBackupCodes).toBeFalsy() + }) + + it('should flip mfa fields when mfa state changed', () => { + accountServiceSpy.getAccountData.and.returnValue( + of({ + activated: true, + authorities: ['test', 'test'], + email: 'email@email.com', + firstName: 'name', + langKey: 'en', + lastName: 'surname', + imageUrl: 'url', + salesforceId: 'sfid', + loggedAs: false, + loginAs: 'sfid', + mainContact: false, + mfaEnabled: true, + }) + ) + accountServiceSpy.getMfaSetup.and.returnValue(of({ secret: 'test', otp: 'test', qrCode: 'test' })) + + expect(component.showMfaTextCode).toBeFalsy() + + component.toggleMfaTextCode() + + expect(component.showMfaTextCode).toBeTruthy() + }) + + it('save mfa enabled should call account service enable', () => { + accountServiceSpy.getAccountData.and.returnValue( + of({ + activated: true, + authorities: ['test', 'test'], + email: 'email@email.com', + firstName: 'name', + langKey: 'en', + lastName: 'surname', + imageUrl: 'url', + salesforceId: 'sfid', + loggedAs: false, + loginAs: 'sfid', + mainContact: false, + mfaEnabled: false, + }) + ) + accountServiceSpy.getMfaSetup.and.returnValue(of({ secret: 'test', otp: 'test', qrCode: ['test'] })) + accountServiceSpy.enableMfa.and.returnValue(of(['code1', 'code2'])) + fixture.detectChanges() + + component.mfaForm.patchValue({ mfaEnabled: true, verificationCode: 'test' }) + component.saveMfa() + + expect(accountServiceSpy.enableMfa).toHaveBeenCalled() + expect(accountServiceSpy.disableMfa).toHaveBeenCalledTimes(0) + }) + + it('save mfa enabled should call account service disable', () => { + accountServiceSpy.getAccountData.and.returnValue( + of({ + activated: true, + authorities: ['test', 'test'], + email: 'email@email.com', + firstName: 'name', + langKey: 'en', + lastName: 'surname', + imageUrl: 'url', + salesforceId: 'sfid', + loggedAs: false, + loginAs: 'sfid', + mainContact: false, + mfaEnabled: true, + }) + ) + accountServiceSpy.getMfaSetup.and.returnValue(of({ secret: 'test', otp: 'test', qrCode: ['test'] })) + accountServiceSpy.disableMfa.and.returnValue(of(true)) + + component.mfaForm.patchValue({ mfaEnabled: false, verificationCode: 'test' }) + component.saveMfa() + + expect(accountServiceSpy.disableMfa).toHaveBeenCalled() + expect(accountServiceSpy.enableMfa).toHaveBeenCalledTimes(0) + }) + + it('save form should call accountService.save and then account data requested when save is successful', () => { + accountServiceSpy.save.and.returnValue(of(true)) + accountServiceSpy.getAccountData.and.returnValue( + of({ + activated: true, + authorities: ['test', 'test'], + email: 'email@email.com', + firstName: 'name', + langKey: 'en', + lastName: 'surname', + imageUrl: 'url', + salesforceId: 'sfid', + loggedAs: false, + loginAs: 'sfid', + mainContact: false, + mfaEnabled: true, + }) + ) + languageServiceSpy.getCurrentLanguage.and.returnValue(of('en')) + fixture.detectChanges() + expect(component.success).toBeFalsy() + component.save() + expect(component.success).toBeTruthy() + expect(accountServiceSpy.save).toHaveBeenCalled() + }) + + it('save form should call accountService.save and then account data requested when save is successful', () => { + accountServiceSpy.save.and.returnValue(of(false)) + accountServiceSpy.getAccountData.and.returnValue( + of({ + activated: true, + authorities: ['test', 'test'], + email: 'email@email.com', + firstName: 'name', + langKey: 'en', + lastName: 'surname', + imageUrl: 'url', + salesforceId: 'sfid', + loggedAs: false, + loginAs: 'sfid', + mainContact: false, + mfaEnabled: true, + }) + ) + fixture.detectChanges() + expect(component.success).toBeFalsy() + component.save() + expect(component.success).toBeFalsy() + expect(accountServiceSpy.save).toHaveBeenCalled() + expect(languageServiceSpy.getCurrentLanguage).toHaveBeenCalledTimes(0) + }) +}) diff --git a/ui/src/app/account/settings/settings.component.ts b/ui/src/app/account/settings/settings.component.ts new file mode 100644 index 000000000..956595a4a --- /dev/null +++ b/ui/src/app/account/settings/settings.component.ts @@ -0,0 +1,169 @@ +import { Component, OnInit } from '@angular/core' +import { FormBuilder, Validators } from '@angular/forms' +import { AccountService } from '../service/account.service' +import { DomSanitizer } from '@angular/platform-browser' +import { LanguageService } from 'src/app/shared/service/language.service' +import { IAccount } from '../model/account.model' + +@Component({ + selector: 'app-settings', + templateUrl: './settings.component.html', + styleUrls: ['./settings.component.scss'], +}) +export class SettingsComponent implements OnInit { + account: IAccount | undefined + error: string | undefined + success: string | undefined + languages: any[] | undefined + username: string | null = null + mfaSetup: any + showMfaSetup: boolean | undefined + showMfaTextCode: boolean | undefined + mfaSetupFailure: boolean | undefined + mfaBackupCodes: string[] | undefined + showMfaBackupCodes: boolean | undefined + showMfaUpdated: boolean | undefined + settingsForm = this.fb.group({ + firstName: ['', [Validators.required, Validators.minLength(1), Validators.maxLength(50)]], + lastName: ['', [Validators.required, Validators.minLength(1), Validators.maxLength(50)]], + email: ['', [Validators.required, Validators.minLength(5), Validators.maxLength(254), Validators.email]], + activated: [false], + authorities: [['']], + langKey: ['en'], + imageUrl: [''], + }) + mfaForm = this.fb.group({ + mfaEnabled: false, + verificationCode: [''], + securitySave: [], + }) + + constructor( + private accountService: AccountService, + private fb: FormBuilder, + private languageService: LanguageService, + private sanitizer: DomSanitizer + ) {} + + ngOnInit() { + this.showMfaSetup = false + this.showMfaTextCode = false + this.showMfaBackupCodes = false + this.accountService.getAccountData().subscribe((account) => { + if (account) { + this.account = account + this.updateForm(account) + this.updateMfaForm(account) + this.username = this.accountService.getUsername() + if (account && !account.mfaEnabled) { + this.accountService.getMfaSetup().subscribe((res) => { + this.mfaSetup = res + }) + } + } + }) + this.languages = Object.keys(this.languageService.getAllLanguages()) + } + + mfaEnabledStateChange(): void { + this.showMfaUpdated = false + const mfaEnabled = this.mfaForm.get('mfaEnabled')!.value + if (mfaEnabled && this.mfaSetup) { + this.showMfaSetup = true + this.showMfaBackupCodes = false + } else { + this.showMfaSetup = false + this.showMfaBackupCodes = false + } + } + + toggleMfaTextCode(): void { + this.showMfaTextCode = true + } + + save() { + const settingsAccount = this.accountFromForm() + this.accountService.save(settingsAccount).subscribe((success: boolean) => { + if (success) { + this.error = undefined + this.success = 'OK' + this.accountService.getAccountData(true).subscribe((account) => { + if (account) { + if (settingsAccount.langKey !== account.langKey) { + location.reload() + } + this.updateForm(account) + this.updateMfaForm(account) + } + }) + } else { + this.success = undefined + this.error = 'ERROR' + } + }) + } + + saveMfa() { + const enabled = this.mfaForm.get('mfaEnabled')!.value + if (enabled) { + const otp = this.mfaForm.get('verificationCode')!.value + console.log('about to set otp on ' + this.mfaSetup) + this.mfaSetup.otp = otp + this.accountService.enableMfa(this.mfaSetup).subscribe((codes: string[] | null) => { + if (codes) { + this.mfaBackupCodes = codes + this.showMfaBackupCodes = true + this.showMfaUpdated = true + } else { + this.mfaSetupFailure = true + } + }) + } else { + this.accountService.disableMfa().subscribe({ + next: () => { + this.showMfaUpdated = true + this.accountService.getMfaSetup().subscribe((res) => { + this.mfaSetup = res + }) + }, + error: (err) => console.log('error disabling mfa'), + }) + } + } + + safeQrCode() { + return this.sanitizer.bypassSecurityTrustResourceUrl('data:image/png;base64, ' + this.mfaSetup.qrCode) + } + + private accountFromForm(): any { + const account = {} + return { + ...account, + firstName: this.settingsForm.get('firstName')!.value, + lastName: this.settingsForm.get('lastName')!.value, + email: this.settingsForm.get('email')!.value, + activated: this.settingsForm.get('activated')!.value, + authorities: this.settingsForm.get('authorities')!.value, + langKey: this.settingsForm.get('langKey')!.value, + imageUrl: this.settingsForm.get('imageUrl')!.value, + } + } + + updateForm(account: IAccount): void { + this.settingsForm.patchValue({ + firstName: account.firstName, + lastName: account.lastName, + email: account.email, + activated: account.activated, + authorities: account.authorities, + langKey: account.langKey, + imageUrl: account.imageUrl, + }) + } + + updateMfaForm(account: IAccount): void { + this.mfaForm.patchValue({ + mfaEnabled: account.mfaEnabled, + }) + } +} diff --git a/ui/src/app/app.module.ts b/ui/src/app/app.module.ts index 140ef1aef..6626d517b 100644 --- a/ui/src/app/app.module.ts +++ b/ui/src/app/app.module.ts @@ -1,9 +1,9 @@ -import { NgModule } from '@angular/core' +import { ErrorHandler, NgModule } from '@angular/core' import { BrowserModule } from '@angular/platform-browser' import { AppRoutingModule } from './app-routing.module' import { AppComponent } from './app.component' -import { HttpClientModule } from '@angular/common/http' +import { HTTP_INTERCEPTORS, HttpClientModule } from '@angular/common/http' import { AccountModule } from './account/account.module' import { NgxWebstorageModule } from 'ngx-webstorage' import { FontAwesomeModule } from '@fortawesome/angular-fontawesome' @@ -11,8 +11,11 @@ import { NavbarComponent } from './layout/navbar/navbar.component' import { CommonModule } from '@angular/common' import { NgbModule } from '@ng-bootstrap/ng-bootstrap' import { HasAnyAuthorityDirective } from './shared/directive/has-any-authority.directive' -import { HomeModule } from './home/home.module'; +import { HomeModule } from './home/home.module' import { FooterComponent } from './layout/footer/footer.component' +import { SharedModule } from './shared/shared.module' +import { HeaderInterceptor } from './shared/interceptor/header.interceptor' +import { ErrorService } from './shared/service/error.service' @NgModule({ declarations: [AppComponent, NavbarComponent, HasAnyAuthorityDirective, FooterComponent], @@ -26,8 +29,19 @@ import { FooterComponent } from './layout/footer/footer.component' NgxWebstorageModule.forRoot(), CommonModule, NgbModule, + SharedModule.forRoot(), + ], + providers: [ + { + provide: HTTP_INTERCEPTORS, + useClass: HeaderInterceptor, + multi: true, + }, + { + provide: ErrorHandler, + useClass: ErrorService, + }, ], - providers: [], bootstrap: [AppComponent], }) export class AppModule {} diff --git a/ui/src/app/layout/footer/footer.component.html b/ui/src/app/layout/footer/footer.component.html index 1b44b11d2..cf470dd37 100644 --- a/ui/src/app/layout/footer/footer.component.html +++ b/ui/src/app/layout/footer/footer.component.html @@ -1,20 +1,28 @@ diff --git a/ui/src/app/layout/navbar/navbar.component.html b/ui/src/app/layout/navbar/navbar.component.html index 0454fb329..8ec559460 100644 --- a/ui/src/app/layout/navbar/navbar.component.html +++ b/ui/src/app/layout/navbar/navbar.component.html @@ -1,7 +1,7 @@
- - {{ this.getUserName() }} | + {{ this.getUserName() }} | {{ this.getUserName() }} @@ -12,7 +12,7 @@ > -   +