diff --git a/console-webapp/angular.json b/console-webapp/angular.json index 63c7237ca7a..2631ae0f74c 100644 --- a/console-webapp/angular.json +++ b/console-webapp/angular.json @@ -22,7 +22,7 @@ "browser": "" }, "index": "src/index.html", - "polyfills": ["src/polyfills.ts"], + "polyfills": [], "tsConfig": "tsconfig.app.json", "inlineStyleLanguage": "scss", "assets": ["src/favicon.ico", "src/assets"], @@ -155,19 +155,11 @@ } }, "test": { - "builder": "@angular/build:karma", + "builder": "@angular/build:unit-test", "options": { - "main": "src/test.ts", - "polyfills": ["src/polyfills.ts"], "tsConfig": "tsconfig.spec.json", - "karmaConfig": "karma.conf.js", - "inlineStyleLanguage": "scss", - "assets": ["src/favicon.ico", "src/assets"], - "styles": ["src/theme.scss", "src/styles.scss"], - "stylePreprocessorOptions": { - "includePaths": ["node_modules/"] - }, - "scripts": [] + "buildTarget": "console-webapp:build:development", + "runner": "vitest" } }, "lint": { diff --git a/console-webapp/eslint.config.js b/console-webapp/eslint.config.js index 73bf0b95405..41738f28170 100644 --- a/console-webapp/eslint.config.js +++ b/console-webapp/eslint.config.js @@ -40,7 +40,7 @@ module.exports = tseslint.config( "@typescript-eslint/no-unused-expressions": "off", "@angular-eslint/prefer-on-push-component-change-detection": "off", "@angular-eslint/prefer-inject": "off", - "@angular-eslint/prefer-standalone": "off", + "@angular-eslint/prefer-standalone": "error", "@angular-eslint/no-output-on-prefix": "off", "no-var": "off", "prefer-const": "off", diff --git a/console-webapp/karma.conf.js b/console-webapp/karma.conf.js deleted file mode 100644 index 353ccd5ffc4..00000000000 --- a/console-webapp/karma.conf.js +++ /dev/null @@ -1,55 +0,0 @@ -// Karma configuration file, see link for more information -// https://karma-runner.github.io/1.0/config/configuration-file.html - -module.exports = function (config) { - config.set({ - customLaunchers: { - ChromeHeadless: { - base: 'Chrome', - flags: [ - '--no-sandbox', - '--disable-gpu', - '--headless', - '--remote-debugging-port=9222' - ] - } - }, - basePath: '', - frameworks: ['jasmine', '@angular-devkit/build-angular'], - plugins: [ - require('karma-jasmine'), - require('karma-chrome-launcher'), - require('karma-jasmine-html-reporter'), - require('karma-coverage'), - - ], - client: { - jasmine: { - // you can add configuration options for Jasmine here - // the possible options are listed at https://jasmine.github.io/api/edge/Configuration.html - // for example, you can disable the random execution with `random: false` - // or set a specific seed with `seed: 4321` - }, - clearContext: false // leave Jasmine Spec Runner output visible in browser - }, - jasmineHtmlReporter: { - suppressAll: true // removes the duplicated traces - }, - coverageReporter: { - dir: require('path').join(__dirname, './coverage/console-webapp'), - subdir: '.', - reporters: [ - { type: 'html' }, - { type: 'text-summary' } - ] - }, - reporters: ['progress', 'kjhtml'], - port: 9876, - colors: true, - logLevel: config.LOG_INFO, - autoWatch: true, - browsers: ['Chrome'], - singleRun: false, - restartOnFileChange: true - }); -}; diff --git a/console-webapp/package-lock.json b/console-webapp/package-lock.json index 0cc66ec48fc..d6989119aaf 100644 --- a/console-webapp/package-lock.json +++ b/console-webapp/package-lock.json @@ -16,11 +16,9 @@ "@angular/forms": "^22.0.6", "@angular/material": "^22.0.4", "@angular/platform-browser": "^22.0.6", - "@angular/platform-browser-dynamic": "^22.0.6", "@angular/router": "^22.0.6", "rxjs": "~7.5.0", - "tslib": "^2.3.0", - "zone.js": "~0.15.0" + "tslib": "^2.3.0" }, "devDependencies": { "@angular-eslint/builder": "22.1.0", @@ -32,7 +30,6 @@ "@angular/cli": "~22.0.6", "@angular/compiler-cli": "^22.0.6", "@eslint/js": "^10.0.1", - "@types/jasmine": "~4.0.0", "@types/node": "^24.13.3", "@typescript-eslint/eslint-plugin": "^8.64.0", "@typescript-eslint/parser": "^8.64.0", @@ -41,19 +38,20 @@ "angular-eslint": "^22.1.0", "concurrently": "^7.6.0", "eslint": "^10.7.0", - "istanbul-lib-instrument": "^6.0.3", - "jasmine-core": "~4.3.0", - "karma": "~6.4.0", - "karma-chrome-launcher": "~3.1.0", - "karma-coverage": "~2.2.0", - "karma-jasmine": "~5.1.0", - "karma-jasmine-html-reporter": "~2.0.0", + "jsdom": "^28.0.0", "prettier": "2.8.7", "typescript": "^6.0.3", "typescript-eslint": "^8.64.0", "vitest": "^4.0.8" } }, + "node_modules/@acemir/cssom": { + "version": "0.9.32", + "resolved": "https://registry.npmjs.org/@acemir/cssom/-/cssom-0.9.32.tgz", + "integrity": "sha512-OijrAUu7rrJhSIoHNg6LMPS5e344nwUHA+1EFYYjrh64D+GmytUsfBQjci45TjT+rd5Spsp8B5xsZDT+TgyXXQ==", + "dev": true, + "license": "MIT" + }, "node_modules/@algolia/abtesting": { "version": "1.18.0", "resolved": "https://registry.npmjs.org/@algolia/abtesting/-/abtesting-1.18.0.tgz", @@ -324,40 +322,6 @@ } } }, - "node_modules/@angular-devkit/architect/node_modules/chokidar": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-5.0.0.tgz", - "integrity": "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "readdirp": "^5.0.0" - }, - "engines": { - "node": ">= 20.19.0" - }, - "funding": { - "url": "https://paulmillr.com/funding/" - } - }, - "node_modules/@angular-devkit/architect/node_modules/readdirp": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-5.1.1.tgz", - "integrity": "sha512-Kko+Y5XQ6fM+Ce3dq3m9YGxnacYZYl9cA1wZjaF3Vbry2L3i1qVg8+CAgNPsXRArPMUMCaOR7oa9Nqntc43JKA==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "engines": { - "node": ">= 20.19.0" - }, - "funding": { - "type": "individual", - "url": "https://paulmillr.com/funding/" - } - }, "node_modules/@angular-devkit/architect/node_modules/rxjs": { "version": "7.8.2", "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz", @@ -415,40 +379,6 @@ } } }, - "node_modules/@angular-devkit/schematics/node_modules/chokidar": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-5.0.0.tgz", - "integrity": "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "readdirp": "^5.0.0" - }, - "engines": { - "node": ">= 20.19.0" - }, - "funding": { - "url": "https://paulmillr.com/funding/" - } - }, - "node_modules/@angular-devkit/schematics/node_modules/readdirp": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-5.1.1.tgz", - "integrity": "sha512-Kko+Y5XQ6fM+Ce3dq3m9YGxnacYZYl9cA1wZjaF3Vbry2L3i1qVg8+CAgNPsXRArPMUMCaOR7oa9Nqntc43JKA==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "engines": { - "node": ">= 20.19.0" - }, - "funding": { - "type": "individual", - "url": "https://paulmillr.com/funding/" - } - }, "node_modules/@angular-devkit/schematics/node_modules/rxjs": { "version": "7.8.2", "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz", @@ -503,40 +433,6 @@ } } }, - "node_modules/@angular-eslint/builder/node_modules/chokidar": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-5.0.0.tgz", - "integrity": "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "readdirp": "^5.0.0" - }, - "engines": { - "node": ">= 20.19.0" - }, - "funding": { - "url": "https://paulmillr.com/funding/" - } - }, - "node_modules/@angular-eslint/builder/node_modules/readdirp": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-5.1.1.tgz", - "integrity": "sha512-Kko+Y5XQ6fM+Ce3dq3m9YGxnacYZYl9cA1wZjaF3Vbry2L3i1qVg8+CAgNPsXRArPMUMCaOR7oa9Nqntc43JKA==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "engines": { - "node": ">= 20.19.0" - }, - "funding": { - "type": "individual", - "url": "https://paulmillr.com/funding/" - } - }, "node_modules/@angular-eslint/builder/node_modules/rxjs": { "version": "7.8.2", "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz", @@ -638,40 +534,6 @@ } } }, - "node_modules/@angular-eslint/schematics/node_modules/chokidar": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-5.0.0.tgz", - "integrity": "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "readdirp": "^5.0.0" - }, - "engines": { - "node": ">= 20.19.0" - }, - "funding": { - "url": "https://paulmillr.com/funding/" - } - }, - "node_modules/@angular-eslint/schematics/node_modules/readdirp": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-5.1.1.tgz", - "integrity": "sha512-Kko+Y5XQ6fM+Ce3dq3m9YGxnacYZYl9cA1wZjaF3Vbry2L3i1qVg8+CAgNPsXRArPMUMCaOR7oa9Nqntc43JKA==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "engines": { - "node": ">= 20.19.0" - }, - "funding": { - "type": "individual", - "url": "https://paulmillr.com/funding/" - } - }, "node_modules/@angular-eslint/schematics/node_modules/rxjs": { "version": "7.8.2", "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz", @@ -970,40 +832,6 @@ } } }, - "node_modules/@angular/cli/node_modules/chokidar": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-5.0.0.tgz", - "integrity": "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "readdirp": "^5.0.0" - }, - "engines": { - "node": ">= 20.19.0" - }, - "funding": { - "url": "https://paulmillr.com/funding/" - } - }, - "node_modules/@angular/cli/node_modules/readdirp": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-5.1.1.tgz", - "integrity": "sha512-Kko+Y5XQ6fM+Ce3dq3m9YGxnacYZYl9cA1wZjaF3Vbry2L3i1qVg8+CAgNPsXRArPMUMCaOR7oa9Nqntc43JKA==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "engines": { - "node": ">= 20.19.0" - }, - "funding": { - "type": "individual", - "url": "https://paulmillr.com/funding/" - } - }, "node_modules/@angular/cli/node_modules/rxjs": { "version": "7.8.2", "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz", @@ -1202,11 +1030,10 @@ } } }, - "node_modules/@angular/platform-browser-dynamic": { + "node_modules/@angular/router": { "version": "22.0.6", - "resolved": "https://registry.npmjs.org/@angular/platform-browser-dynamic/-/platform-browser-dynamic-22.0.6.tgz", - "integrity": "sha512-VKf8mg+BviBp3wlElZYxWFuXtMLQqd8rfm6jEzwP1Emaz2MCx69Y54dXopD81KJCqRwTmJUVxOjoubqUOlJzRw==", - "deprecated": "@angular/platform-browser-dynamic is deprecated. Use `@angular/platform-browser` instead.", + "resolved": "https://registry.npmjs.org/@angular/router/-/router-22.0.6.tgz", + "integrity": "sha512-s0BErBBadCXlvilob8aWke4H1FRlXDbnDfyYkjW6RkUaGQ4jzeEAzH56KsG2hf8NtBtYwkRlsLfOX9s5sPwm6w==", "license": "MIT", "dependencies": { "tslib": "^2.3.0" @@ -1216,29 +1043,69 @@ }, "peerDependencies": { "@angular/common": "22.0.6", - "@angular/compiler": "22.0.6", "@angular/core": "22.0.6", - "@angular/platform-browser": "22.0.6" + "@angular/platform-browser": "22.0.6", + "rxjs": "^6.5.3 || ^7.4.0" } }, - "node_modules/@angular/router": { - "version": "22.0.6", - "resolved": "https://registry.npmjs.org/@angular/router/-/router-22.0.6.tgz", - "integrity": "sha512-s0BErBBadCXlvilob8aWke4H1FRlXDbnDfyYkjW6RkUaGQ4jzeEAzH56KsG2hf8NtBtYwkRlsLfOX9s5sPwm6w==", + "node_modules/@asamuzakjp/css-color": { + "version": "5.1.11", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-5.1.11.tgz", + "integrity": "sha512-KVw6qIiCTUQhByfTd78h2yD1/00waTmm9uy/R7Ck/ctUyAPj+AEDLkQIdJW0T8+qGgj3j5bpNKK7Q3G+LedJWg==", + "dev": true, "license": "MIT", "dependencies": { - "tslib": "^2.3.0" + "@asamuzakjp/generational-cache": "^1.0.1", + "@csstools/css-calc": "^3.2.0", + "@csstools/css-color-parser": "^4.1.0", + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" }, "engines": { - "node": "^22.22.3 || ^24.15.0 || >=26.0.0" - }, - "peerDependencies": { - "@angular/common": "22.0.6", - "@angular/core": "22.0.6", - "@angular/platform-browser": "22.0.6", - "rxjs": "^6.5.3 || ^7.4.0" + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/dom-selector": { + "version": "6.8.1", + "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-6.8.1.tgz", + "integrity": "sha512-MvRz1nCqW0fsy8Qz4dnLIvhOlMzqDVBabZx6lH+YywFDdjXhMY37SmpV1XFX3JzG5GWHn63j6HX6QPr3lZXHvQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/nwsapi": "^2.3.9", + "bidi-js": "^1.0.3", + "css-tree": "^3.1.0", + "is-potential-custom-element-name": "^1.0.1", + "lru-cache": "^11.2.6" + } + }, + "node_modules/@asamuzakjp/dom-selector/node_modules/lru-cache": { + "version": "11.5.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.3.tgz", + "integrity": "sha512-U4N8FgzmWxc8k1VH8Kr6lQg18U7Fjvby6wXHVRX/ZZ7IwWbRMgrRbP0Wrb5q5NVinryp4SQampHKdvtecItxUg==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/@asamuzakjp/generational-cache": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@asamuzakjp/generational-cache/-/generational-cache-1.0.1.tgz", + "integrity": "sha512-wajfB8KqzMCN2KGNFdLkReeHncd0AslUSrvHVvvYWuU8ghncRJoA50kT3zP9MVL0+9g4/67H+cdvBskj9THPzg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" } }, + "node_modules/@asamuzakjp/nwsapi": { + "version": "2.3.9", + "resolved": "https://registry.npmjs.org/@asamuzakjp/nwsapi/-/nwsapi-2.3.9.tgz", + "integrity": "sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==", + "dev": true, + "license": "MIT" + }, "node_modules/@babel/code-frame": { "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", @@ -1549,66 +1416,209 @@ "dev": true, "license": "MIT" }, - "node_modules/@colors/colors": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/@colors/colors/-/colors-1.5.0.tgz", - "integrity": "sha512-ooWCrlZP11i8GImSjTHYHLkvFDP48nS4+204nGb1RiX/WXYHmJA2III9/e2DWVabCESdW7hBAEzHRqUn9OUVvQ==", + "node_modules/@bramus/specificity": { + "version": "2.4.3", + "resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.3.tgz", + "integrity": "sha512-0GEz8qJWokkUVYQYRukmkSHIFNoBH8QTreeHrAFbvz1XFYc3Y+puhMs8PQ5x8GNugiC2l5jvDBXKLbBIByeSBw==", "dev": true, "license": "MIT", - "engines": { - "node": ">=0.1.90" + "dependencies": { + "css-tree": "^3.2.1" + }, + "bin": { + "specificity": "bin/cli.js" } }, - "node_modules/@esbuild/aix-ppc64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz", - "integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==", - "cpu": [ - "ppc64" - ], + "node_modules/@csstools/color-helpers": { + "version": "6.1.2", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.1.2.tgz", + "integrity": "sha512-grhRy3OKmniaAEKXMjua5z/EODX0MSqBGjunw8+j/3HQjOnahs2AGhvEOIYVUWcU6ScApbhLhVrQTX8XqrMrow==", "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "aix" + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } ], + "license": "MIT-0", "engines": { - "node": ">=18" + "node": ">=20.19.0" } }, - "node_modules/@esbuild/android-arm": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz", - "integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==", - "cpu": [ - "arm" - ], + "node_modules/@csstools/css-calc": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.4.3.tgz", + "integrity": "sha512-iex20d8CHVkyvg6B7UKV7uHnI2Bqo9g+EFfT9E0y+GvTvhZ/DwONJ+9aKb1dlqm0ZiGsL5RXjp0fCoJYnkeDjA==", "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } ], + "license": "MIT", "engines": { - "node": ">=18" + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.2", + "@csstools/css-tokenizer": "^4.0.2" } }, - "node_modules/@esbuild/android-arm64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz", - "integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==", - "cpu": [ - "arm64" - ], + "node_modules/@csstools/css-color-parser": { + "version": "4.2.7", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.2.7.tgz", + "integrity": "sha512-zgm9yjQz5iJB4VL8RyVwd71TWB8Q89FSxt/NzXoWqPSkEBOVtn9L6vAPtSc9hpKfB0Awo/AL2VKFHjYetBLFHQ==", "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } ], + "license": "MIT", + "dependencies": { + "@csstools/color-helpers": "^6.1.2", + "@csstools/css-calc": "^3.4.3" + }, "engines": { - "node": ">=18" - } + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.2", + "@csstools/css-tokenizer": "^4.0.2" + } + }, + "node_modules/@csstools/css-parser-algorithms": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-4.0.2.tgz", + "integrity": "sha512-40cSKyMvK+tq4qz6Awrlye2WGuOKt3FwPgtGg6KTfbHOWNw+Rk1rzbAtZnZ6IBhsY491HLRnDXwoyBAijmmILA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^4.0.2" + } + }, + "node_modules/@csstools/css-syntax-patches-for-csstree": { + "version": "1.1.16", + "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.16.tgz", + "integrity": "sha512-LsDbJPP/B8cPasUJyTA5S99F7C4G93ngW8hUluXz8CFeOaaRnBPtBiIlvgcPYU0HmHNz8CXg+1vwFrIB4F6Dgw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "peerDependencies": { + "css-tree": "^3.2.1" + }, + "peerDependenciesMeta": { + "css-tree": { + "optional": true + } + } + }, + "node_modules/@csstools/css-tokenizer": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-4.0.2.tgz", + "integrity": "sha512-OoKoR0f76dCY666JlcbhmVTs2drYj1GUXZTYTcbUgJjh9Nv41aFfZ21bPQTERm5+L5cBDo466NltB2lplS5GBw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz", + "integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz", + "integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz", + "integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } }, "node_modules/@esbuild/android-x64": { "version": "0.28.1", @@ -2116,6 +2126,24 @@ "node": "^20.19.0 || ^22.13.0 || >=24" } }, + "node_modules/@exodus/bytes": { + "version": "1.16.0", + "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.16.0.tgz", + "integrity": "sha512-IcpW84uEn3N7ETtNZMlxKhfl6Pec8rUNGOTBtWbK1FKhJxIFAptZyVrvVRVBimAJxJCgc3PxepxkdWWG4DVzfA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + }, + "peerDependencies": { + "@noble/hashes": "^1.8.0 || ^2.0.0" + }, + "peerDependenciesMeta": { + "@noble/hashes": { + "optional": true + } + } + }, "node_modules/@gar/promise-retry": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/@gar/promise-retry/-/promise-retry-1.0.3.tgz", @@ -2570,16 +2598,6 @@ "node": ">=18.0.0" } }, - "node_modules/@istanbuljs/schema": { - "version": "0.1.3", - "resolved": "https://registry.npmjs.org/@istanbuljs/schema/-/schema-0.1.3.tgz", - "integrity": "sha512-ZXRY4jNvVgSVQ8DL3LTcakaAtXwTVUxE81hslsyD2AtoXW/wVob10HkOJ1X/pAlcI7D+2YoZKg5do8G/w6RYgA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, "node_modules/@jridgewell/gen-mapping": { "version": "0.3.13", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", @@ -4178,40 +4196,6 @@ } } }, - "node_modules/@schematics/angular/node_modules/chokidar": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-5.0.0.tgz", - "integrity": "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "readdirp": "^5.0.0" - }, - "engines": { - "node": ">= 20.19.0" - }, - "funding": { - "url": "https://paulmillr.com/funding/" - } - }, - "node_modules/@schematics/angular/node_modules/readdirp": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-5.1.1.tgz", - "integrity": "sha512-Kko+Y5XQ6fM+Ce3dq3m9YGxnacYZYl9cA1wZjaF3Vbry2L3i1qVg8+CAgNPsXRArPMUMCaOR7oa9Nqntc43JKA==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "engines": { - "node": ">= 20.19.0" - }, - "funding": { - "type": "individual", - "url": "https://paulmillr.com/funding/" - } - }, "node_modules/@schematics/angular/node_modules/rxjs": { "version": "7.8.2", "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz", @@ -4302,13 +4286,6 @@ "node": "^20.17.0 || >=22.9.0" } }, - "node_modules/@socket.io/component-emitter": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/@socket.io/component-emitter/-/component-emitter-3.1.2.tgz", - "integrity": "sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA==", - "dev": true, - "license": "MIT" - }, "node_modules/@standard-schema/spec": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", @@ -4401,16 +4378,6 @@ "assertion-error": "^2.0.1" } }, - "node_modules/@types/cors": { - "version": "2.8.19", - "resolved": "https://registry.npmjs.org/@types/cors/-/cors-2.8.19.tgz", - "integrity": "sha512-mFNylyeyqN93lfe/9CSxOGREz8cpzAhH+E93xJ4xWQf62V8sQ/24reV2nyzUWM6H6Xji+GGHpkbLe7pVoUEskg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/node": "*" - } - }, "node_modules/@types/deep-eql": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", @@ -4432,13 +4399,6 @@ "dev": true, "license": "MIT" }, - "node_modules/@types/jasmine": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/@types/jasmine/-/jasmine-4.0.3.tgz", - "integrity": "sha512-Opp1LvvEuZdk8fSSvchK2mZwhVrsNT0JgJE9Di6MjnaIpmEXM8TLCPPrVtNTYh8+5MPdY8j9bAHMu2SSfwpZJg==", - "dev": true, - "license": "MIT" - }, "node_modules/@types/json-schema": { "version": "7.0.15", "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", @@ -5072,40 +5032,6 @@ } } }, - "node_modules/angular-eslint/node_modules/chokidar": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-5.0.0.tgz", - "integrity": "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "readdirp": "^5.0.0" - }, - "engines": { - "node": ">= 20.19.0" - }, - "funding": { - "url": "https://paulmillr.com/funding/" - } - }, - "node_modules/angular-eslint/node_modules/readdirp": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-5.1.1.tgz", - "integrity": "sha512-Kko+Y5XQ6fM+Ce3dq3m9YGxnacYZYl9cA1wZjaF3Vbry2L3i1qVg8+CAgNPsXRArPMUMCaOR7oa9Nqntc43JKA==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "engines": { - "node": ">= 20.19.0" - }, - "funding": { - "type": "individual", - "url": "https://paulmillr.com/funding/" - } - }, "node_modules/angular-eslint/node_modules/rxjs": { "version": "7.8.2", "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz", @@ -5158,33 +5084,6 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, - "node_modules/anymatch": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", - "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", - "dev": true, - "license": "ISC", - "dependencies": { - "normalize-path": "^3.0.0", - "picomatch": "^2.0.4" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/anymatch/node_modules/picomatch": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", - "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8.6" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, "node_modules/aria-query": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.2.tgz", @@ -5215,23 +5114,6 @@ "node": ">= 0.4" } }, - "node_modules/balanced-match": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", - "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", - "dev": true, - "license": "MIT" - }, - "node_modules/base64id": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/base64id/-/base64id-2.0.0.tgz", - "integrity": "sha512-lGe34o6EHj9y3Kts9R4ZYs/Gr+6N7MCaMlIFA3F1R2O5/m7K06AxfSeO5530PEERE6/WyEg3lsuyw4GHlPZHog==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^4.5.0 || >= 5.9" - } - }, "node_modules/baseline-browser-mapping": { "version": "2.10.0", "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.0.tgz", @@ -5266,17 +5148,14 @@ "node": ">=18.0.0" } }, - "node_modules/binary-extensions": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", - "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", + "node_modules/bidi-js": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.1.0.tgz", + "integrity": "sha512-fX1Onk0tdVPC7obPWB5EbJ1z7NVhLq4m2xZLq2YXBkxzMXIGRpNMU88n0EPgWseKl12J7zXs7qrDxPK4sRs2fg==", "dev": true, "license": "MIT", - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "dependencies": { + "require-from-string": "^2.0.2" } }, "node_modules/body-parser": { @@ -5348,19 +5227,6 @@ "node": "18 || 20 || >=22" } }, - "node_modules/braces": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", - "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", - "dev": true, - "license": "MIT", - "dependencies": { - "fill-range": "^7.1.1" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/browserslist": { "version": "4.28.1", "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz", @@ -5756,13 +5622,6 @@ "dev": true, "license": "MIT" }, - "node_modules/concat-map": { - "version": "0.0.1", - "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", - "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", - "dev": true, - "license": "MIT" - }, "node_modules/concurrently": { "version": "7.6.0", "resolved": "https://registry.npmjs.org/concurrently/-/concurrently-7.6.0.tgz", @@ -5885,113 +5744,28 @@ "node": ">=12" } }, - "node_modules/connect": { - "version": "3.7.0", - "resolved": "https://registry.npmjs.org/connect/-/connect-3.7.0.tgz", - "integrity": "sha512-ZqRXc+tZukToSNmh5C2iWMSoV3X1YUcPbqEM4DkEG5tNQXrQUZCNVGGv3IuicnkMtPfGf3Xtp8WCXs295iQ1pQ==", + "node_modules/content-disposition": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.1.0.tgz", + "integrity": "sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==", "dev": true, "license": "MIT", - "dependencies": { - "debug": "2.6.9", - "finalhandler": "1.1.2", - "parseurl": "~1.3.3", - "utils-merge": "1.0.1" - }, "engines": { - "node": ">= 0.10.0" + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, - "node_modules/connect/node_modules/debug": { - "version": "2.6.9", - "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", - "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", "dev": true, "license": "MIT", - "dependencies": { - "ms": "2.0.0" - } - }, - "node_modules/connect/node_modules/encodeurl": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-1.0.2.tgz", - "integrity": "sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/connect/node_modules/finalhandler": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.1.2.tgz", - "integrity": "sha512-aAWcW57uxVNrQZqFXjITpW3sIUQmHGG3qSb9mUah9MgMC4NeWhNOlNjXEYq3HjRAvL6arUviZGGJsBg6z0zsWA==", - "dev": true, - "license": "MIT", - "dependencies": { - "debug": "2.6.9", - "encodeurl": "~1.0.2", - "escape-html": "~1.0.3", - "on-finished": "~2.3.0", - "parseurl": "~1.3.3", - "statuses": "~1.5.0", - "unpipe": "~1.0.0" - }, - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/connect/node_modules/ms": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", - "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", - "dev": true, - "license": "MIT" - }, - "node_modules/connect/node_modules/on-finished": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.3.0.tgz", - "integrity": "sha512-ikqdkGAAyf/X/gPhXGvfgAytDZtDbr+bkNUJ0N9h5MI/dmdgCs3l6hoHrcUv41sRKew3jIwrp4qQDXiK99Utww==", - "dev": true, - "license": "MIT", - "dependencies": { - "ee-first": "1.1.1" - }, - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/connect/node_modules/statuses": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/statuses/-/statuses-1.5.0.tgz", - "integrity": "sha512-OpZ3zP+jT1PI7I8nemJX4AKmAX070ZkYPVWV/AaKTJl+tXCTGyVdC1a4SL8RUQYEwk/f34ZX8UTykN68FwrqAA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/content-disposition": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.1.0.tgz", - "integrity": "sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/content-type": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", - "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.6" + "engines": { + "node": ">= 0.6" } }, "node_modules/convert-source-map": { @@ -6071,6 +5845,20 @@ "url": "https://github.com/sponsors/fb55" } }, + "node_modules/css-tree": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz", + "integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==", + "dev": true, + "license": "MIT", + "dependencies": { + "mdn-data": "2.27.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" + } + }, "node_modules/css-what": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/css-what/-/css-what-7.0.0.tgz", @@ -6084,12 +5872,45 @@ "url": "https://github.com/sponsors/fb55" } }, - "node_modules/custom-event": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/custom-event/-/custom-event-1.0.1.tgz", - "integrity": "sha512-GAj5FOq0Hd+RsCGVJxZuKaIDXDf3h6GQoNEjFgbLLI/trgtavwUbSnZ5pVfg27DVCaWjIohryS0JFwIJyT2cMg==", + "node_modules/cssstyle": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-6.2.0.tgz", + "integrity": "sha512-Fm5NvhYathRnXNVndkUsCCuR63DCLVVwGOOwQw782coXFi5HhkXdu289l59HlXZBawsyNccXfWRYvLzcDCdDig==", "dev": true, - "license": "MIT" + "license": "MIT", + "dependencies": { + "@asamuzakjp/css-color": "^5.0.1", + "@csstools/css-syntax-patches-for-csstree": "^1.0.28", + "css-tree": "^3.1.0", + "lru-cache": "^11.2.6" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/cssstyle/node_modules/lru-cache": { + "version": "11.5.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.3.tgz", + "integrity": "sha512-U4N8FgzmWxc8k1VH8Kr6lQg18U7Fjvby6wXHVRX/ZZ7IwWbRMgrRbP0Wrb5q5NVinryp4SQampHKdvtecItxUg==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/data-urls": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz", + "integrity": "sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^16.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } }, "node_modules/date-fns": { "version": "2.30.0", @@ -6108,16 +5929,6 @@ "url": "https://opencollective.com/date-fns" } }, - "node_modules/date-format": { - "version": "4.0.14", - "resolved": "https://registry.npmjs.org/date-format/-/date-format-4.0.14.tgz", - "integrity": "sha512-39BOQLs9ZjKh0/patS9nrT8wc3ioX3/eA/zgbKNopnF2wCqJEoxywwwElATYvRsXdnOxA/OQeQoFZ3rFjVajhg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=4.0" - } - }, "node_modules/debug": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", @@ -6136,6 +5947,13 @@ } } }, + "node_modules/decimal.js": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", + "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", + "dev": true, + "license": "MIT" + }, "node_modules/deep-is": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", @@ -6163,17 +5981,6 @@ "node": ">=6" } }, - "node_modules/destroy": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz", - "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.8", - "npm": "1.2.8000 || >= 1.4.16" - } - }, "node_modules/detect-libc": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", @@ -6185,13 +5992,6 @@ "node": ">=8" } }, - "node_modules/di": { - "version": "0.0.1", - "resolved": "https://registry.npmjs.org/di/-/di-0.0.1.tgz", - "integrity": "sha512-uJaamHkagcZtHPqCIHZxnFrXlunQXgBOsZSUOWwFw31QJCAbyTBoHMW75YOTur5ZNx8pIeAKgf6GWIgaqqiLhA==", - "dev": true, - "license": "MIT" - }, "node_modules/dom-accessibility-api": { "version": "0.5.16", "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", @@ -6199,19 +5999,6 @@ "dev": true, "license": "MIT" }, - "node_modules/dom-serialize": { - "version": "2.2.1", - "resolved": "https://registry.npmjs.org/dom-serialize/-/dom-serialize-2.2.1.tgz", - "integrity": "sha512-Yra4DbvoW7/Z6LBN560ZwXMjoNOSAN2wRsKFGc4iBeso+mpIA6qj1vfdf9HpMaKAqG6wXTy+1SYEzmNpKXOSsQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "custom-event": "~1.0.0", - "ent": "~2.2.0", - "extend": "^3.0.0", - "void-elements": "^2.0.0" - } - }, "node_modules/dom-serializer": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz", @@ -6317,100 +6104,6 @@ "node": ">= 0.8" } }, - "node_modules/engine.io": { - "version": "6.6.5", - "resolved": "https://registry.npmjs.org/engine.io/-/engine.io-6.6.5.tgz", - "integrity": "sha512-2RZdgEbXmp5+dVbRm0P7HQUImZpICccJy7rN7Tv+SFa55pH+lxnuw6/K1ZxxBfHoYpSkHLAO92oa8O4SwFXA2A==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/cors": "^2.8.12", - "@types/node": ">=10.0.0", - "accepts": "~1.3.4", - "base64id": "2.0.0", - "cookie": "~0.7.2", - "cors": "~2.8.5", - "debug": "~4.4.1", - "engine.io-parser": "~5.2.1", - "ws": "~8.18.3" - }, - "engines": { - "node": ">=10.2.0" - } - }, - "node_modules/engine.io-parser": { - "version": "5.2.3", - "resolved": "https://registry.npmjs.org/engine.io-parser/-/engine.io-parser-5.2.3.tgz", - "integrity": "sha512-HqD3yTBfnBxIrbnM1DoD6Pcq8NECnh8d4As1Qgh0z5Gg3jRRIqijury0CL3ghu/edArpUYiYqQiDUQBIs4np3Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10.0.0" - } - }, - "node_modules/engine.io/node_modules/accepts": { - "version": "1.3.8", - "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", - "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", - "dev": true, - "license": "MIT", - "dependencies": { - "mime-types": "~2.1.34", - "negotiator": "0.6.3" - }, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/engine.io/node_modules/mime-db": { - "version": "1.52.0", - "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", - "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/engine.io/node_modules/mime-types": { - "version": "2.1.35", - "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", - "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", - "dev": true, - "license": "MIT", - "dependencies": { - "mime-db": "1.52.0" - }, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/engine.io/node_modules/negotiator": { - "version": "0.6.3", - "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", - "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/ent": { - "version": "2.2.2", - "resolved": "https://registry.npmjs.org/ent/-/ent-2.2.2.tgz", - "integrity": "sha512-kKvD1tO6BM+oK9HzCPpUdRb4vKFQY/FPTFmurMvh6LlN68VMrdj77w8yp51/kDbpkFOS9J8w5W6zIzgM2H8/hw==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.3", - "es-errors": "^1.3.0", - "punycode": "^1.4.1", - "safe-regex-test": "^1.1.0" - }, - "engines": { - "node": ">= 0.4" - } - }, "node_modules/entities": { "version": "4.5.0", "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", @@ -6794,13 +6487,6 @@ "node": ">= 0.6" } }, - "node_modules/eventemitter3": { - "version": "4.0.7", - "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz", - "integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==", - "dev": true, - "license": "MIT" - }, "node_modules/eventsource": { "version": "3.0.7", "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-3.0.7.tgz", @@ -6904,13 +6590,6 @@ "express": ">= 4.11" } }, - "node_modules/extend": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", - "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", - "dev": true, - "license": "MIT" - }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -7007,19 +6686,6 @@ "node": ">=16.0.0" } }, - "node_modules/fill-range": { - "version": "7.1.1", - "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", - "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", - "dev": true, - "license": "MIT", - "dependencies": { - "to-regex-range": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/finalhandler": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz", @@ -7080,27 +6746,6 @@ "dev": true, "license": "ISC" }, - "node_modules/follow-redirects": { - "version": "1.15.11", - "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz", - "integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==", - "dev": true, - "funding": [ - { - "type": "individual", - "url": "https://github.com/sponsors/RubenVerborgh" - } - ], - "license": "MIT", - "engines": { - "node": ">=4.0" - }, - "peerDependenciesMeta": { - "debug": { - "optional": true - } - } - }, "node_modules/forwarded": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", @@ -7121,21 +6766,6 @@ "node": ">= 0.8" } }, - "node_modules/fs-extra": { - "version": "8.1.0", - "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-8.1.0.tgz", - "integrity": "sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g==", - "dev": true, - "license": "MIT", - "dependencies": { - "graceful-fs": "^4.2.0", - "jsonfile": "^4.0.0", - "universalify": "^0.1.0" - }, - "engines": { - "node": ">=6 <7 || >=8" - } - }, "node_modules/fs-minipass": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/fs-minipass/-/fs-minipass-3.0.3.tgz", @@ -7149,13 +6779,6 @@ "node": "^14.17.0 || ^16.13.0 || >=18.0.0" } }, - "node_modules/fs.realpath": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", - "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", - "dev": true, - "license": "ISC" - }, "node_modules/fsevents": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", @@ -7253,28 +6876,6 @@ "node": ">= 0.4" } }, - "node_modules/glob": { - "version": "7.2.3", - "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", - "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", - "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", - "dev": true, - "license": "ISC", - "dependencies": { - "fs.realpath": "^1.0.0", - "inflight": "^1.0.4", - "inherits": "2", - "minimatch": "^3.1.1", - "once": "^1.3.0", - "path-is-absolute": "^1.0.0" - }, - "engines": { - "node": "*" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, "node_modules/glob-parent": { "version": "6.0.2", "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", @@ -7295,30 +6896,6 @@ "dev": true, "license": "BSD-2-Clause" }, - "node_modules/glob/node_modules/brace-expansion": { - "version": "1.1.12", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", - "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" - } - }, - "node_modules/glob/node_modules/minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", - "dev": true, - "license": "ISC", - "dependencies": { - "brace-expansion": "^1.1.7" - }, - "engines": { - "node": "*" - } - }, "node_modules/gopd": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", @@ -7362,22 +6939,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/has-tostringtag": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", - "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", - "dev": true, - "license": "MIT", - "dependencies": { - "has-symbols": "^1.0.3" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/hasown": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", @@ -7424,12 +6985,18 @@ "node": "20 || >=22" } }, - "node_modules/html-escaper": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", - "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", + "node_modules/html-encoding-sniffer": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz", + "integrity": "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==", "dev": true, - "license": "MIT" + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.6.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } }, "node_modules/htmlparser2": { "version": "10.1.0", @@ -7492,21 +7059,6 @@ "url": "https://opencollective.com/express" } }, - "node_modules/http-proxy": { - "version": "1.18.1", - "resolved": "https://registry.npmjs.org/http-proxy/-/http-proxy-1.18.1.tgz", - "integrity": "sha512-7mz/721AbnJwIVbnaSv1Cz3Am0ZLT/UBwkC92VlxhXv/k/BBQfM2fXElQNC27BVGr0uwUpplYPQM9LnaBMR5NQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "eventemitter3": "^4.0.0", - "follow-redirects": "^1.0.0", - "requires-port": "^1.0.0" - }, - "engines": { - "node": ">=8.0.0" - } - }, "node_modules/http-proxy-agent": { "version": "7.0.2", "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", @@ -7602,18 +7154,6 @@ "node": ">=0.8.19" } }, - "node_modules/inflight": { - "version": "1.0.6", - "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", - "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", - "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.", - "dev": true, - "license": "ISC", - "dependencies": { - "once": "^1.3.0", - "wrappy": "1" - } - }, "node_modules/inherits": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", @@ -7651,19 +7191,6 @@ "node": ">= 0.10" } }, - "node_modules/is-binary-path": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", - "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", - "dev": true, - "license": "MIT", - "dependencies": { - "binary-extensions": "^2.0.0" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/is-extglob": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", @@ -7716,15 +7243,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/is-number": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", - "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "node_modules/is-potential-custom-element-name": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.12.0" - } + "license": "MIT" }, "node_modules/is-promise": { "version": "4.0.0", @@ -7733,25 +7257,6 @@ "dev": true, "license": "MIT" }, - "node_modules/is-regex": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz", - "integrity": "sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.2", - "gopd": "^1.2.0", - "has-tostringtag": "^1.0.2", - "hasown": "^2.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/is-unicode-supported": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-2.1.0.tgz", @@ -7765,19 +7270,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/isbinaryfile": { - "version": "4.0.10", - "resolved": "https://registry.npmjs.org/isbinaryfile/-/isbinaryfile-4.0.10.tgz", - "integrity": "sha512-iHrqe5shvBUcFbmZq9zOQHBoeOhZJu6RQGrDpBgenUm/Am+F3JM2MgQj+rK3Z601fzrL5gLZWtAPH2OBaSVcyw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 8.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/gjtorikian/" - } - }, "node_modules/isexe": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", @@ -7785,124 +7277,98 @@ "dev": true, "license": "ISC" }, - "node_modules/istanbul-lib-coverage": { - "version": "3.2.2", - "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz", - "integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==", - "dev": true, - "license": "BSD-3-Clause", - "engines": { - "node": ">=8" - } - }, - "node_modules/istanbul-lib-instrument": { - "version": "6.0.3", - "resolved": "https://registry.npmjs.org/istanbul-lib-instrument/-/istanbul-lib-instrument-6.0.3.tgz", - "integrity": "sha512-Vtgk7L/R2JHyyGW07spoFlB8/lpjiOLTjMdms6AFMraYt3BaJauod/NGrfnVG/y4Ix1JEuMRPDPEj2ua+zz1/Q==", + "node_modules/jose": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.3.tgz", + "integrity": "sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw==", "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "@babel/core": "^7.23.9", - "@babel/parser": "^7.23.9", - "@istanbuljs/schema": "^0.1.3", - "istanbul-lib-coverage": "^3.2.0", - "semver": "^7.5.4" - }, - "engines": { - "node": ">=10" + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" } }, - "node_modules/istanbul-lib-report": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz", - "integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==", + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "istanbul-lib-coverage": "^3.0.0", - "make-dir": "^4.0.0", - "supports-color": "^7.1.0" - }, - "engines": { - "node": ">=10" - } + "license": "MIT" }, - "node_modules/istanbul-lib-report/node_modules/supports-color": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", - "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "node_modules/jsdom": { + "version": "28.1.0", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-28.1.0.tgz", + "integrity": "sha512-0+MoQNYyr2rBHqO1xilltfDjV9G7ymYGlAUazgcDLQaUf8JDHbuGwsxN6U9qWaElZ4w1B2r7yEGIL3GdeW3Rug==", "dev": true, "license": "MIT", "dependencies": { - "has-flag": "^4.0.0" + "@acemir/cssom": "^0.9.31", + "@asamuzakjp/dom-selector": "^6.8.1", + "@bramus/specificity": "^2.4.2", + "@exodus/bytes": "^1.11.0", + "cssstyle": "^6.0.1", + "data-urls": "^7.0.0", + "decimal.js": "^10.6.0", + "html-encoding-sniffer": "^6.0.0", + "http-proxy-agent": "^7.0.2", + "https-proxy-agent": "^7.0.6", + "is-potential-custom-element-name": "^1.0.1", + "parse5": "^8.0.0", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^6.0.0", + "undici": "^7.21.0", + "w3c-xmlserializer": "^5.0.0", + "webidl-conversions": "^8.0.1", + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^16.0.0", + "xml-name-validator": "^5.0.0" }, "engines": { - "node": ">=8" - } - }, - "node_modules/istanbul-lib-source-maps": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/istanbul-lib-source-maps/-/istanbul-lib-source-maps-4.0.1.tgz", - "integrity": "sha512-n3s8EwkdFIJCG3BPKBYvskgXGoy88ARzvegkitk60NxRdwltLOTaH7CUiMRXvwYorl0Q712iEjcWB+fK/MrWVw==", - "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "debug": "^4.1.1", - "istanbul-lib-coverage": "^3.0.0", - "source-map": "^0.6.1" + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" }, - "engines": { - "node": ">=10" + "peerDependencies": { + "canvas": "^3.0.0" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } } }, - "node_modules/istanbul-lib-source-maps/node_modules/source-map": { - "version": "0.6.1", - "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", - "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "node_modules/jsdom/node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", "dev": true, - "license": "BSD-3-Clause", + "license": "MIT", "engines": { - "node": ">=0.10.0" + "node": ">= 14" } }, - "node_modules/istanbul-reports": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.2.0.tgz", - "integrity": "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==", + "node_modules/jsdom/node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", "dev": true, - "license": "BSD-3-Clause", + "license": "MIT", "dependencies": { - "html-escaper": "^2.0.0", - "istanbul-lib-report": "^3.0.0" + "agent-base": "^7.1.2", + "debug": "4" }, "engines": { - "node": ">=8" + "node": ">= 14" } }, - "node_modules/jasmine-core": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/jasmine-core/-/jasmine-core-4.3.0.tgz", - "integrity": "sha512-qybtBUesniQdW6n+QIHMng2vDOHscIC/dEXjW+JzO9+LoAZMb03RCUC5xFOv/btSKPm1xL42fn+RjlU4oB42Lg==", - "dev": true, - "license": "MIT" - }, - "node_modules/jose": { - "version": "6.2.3", - "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.3.tgz", - "integrity": "sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw==", + "node_modules/jsdom/node_modules/undici": { + "version": "7.30.1", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.30.1.tgz", + "integrity": "sha512-03KrMbjIFDWQR5RNPrSw3bd1r12JpyFQl9/yxjUbJtqkjmllqC5lFjZh6ujeuJCKr8EOQvRW872Oy/az+nLYxg==", "dev": true, "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/panva" + "engines": { + "node": ">=20.18.1" } }, - "node_modules/js-tokens": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", - "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", - "dev": true, - "license": "MIT" - }, "node_modules/jsesc": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", @@ -7974,16 +7440,6 @@ "dev": true, "license": "MIT" }, - "node_modules/jsonfile": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-4.0.0.tgz", - "integrity": "sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg==", - "dev": true, - "license": "MIT", - "optionalDependencies": { - "graceful-fs": "^4.1.6" - } - }, "node_modules/jsonparse": { "version": "1.3.1", "resolved": "https://registry.npmjs.org/jsonparse/-/jsonparse-1.3.1.tgz", @@ -7994,488 +7450,6 @@ ], "license": "MIT" }, - "node_modules/karma": { - "version": "6.4.4", - "resolved": "https://registry.npmjs.org/karma/-/karma-6.4.4.tgz", - "integrity": "sha512-LrtUxbdvt1gOpo3gxG+VAJlJAEMhbWlM4YrFQgql98FwF7+K8K12LYO4hnDdUkNjeztYrOXEMqgTajSWgmtI/w==", - "dev": true, - "license": "MIT", - "dependencies": { - "@colors/colors": "1.5.0", - "body-parser": "^1.19.0", - "braces": "^3.0.2", - "chokidar": "^3.5.1", - "connect": "^3.7.0", - "di": "^0.0.1", - "dom-serialize": "^2.2.1", - "glob": "^7.1.7", - "graceful-fs": "^4.2.6", - "http-proxy": "^1.18.1", - "isbinaryfile": "^4.0.8", - "lodash": "^4.17.21", - "log4js": "^6.4.1", - "mime": "^2.5.2", - "minimatch": "^3.0.4", - "mkdirp": "^0.5.5", - "qjobs": "^1.2.0", - "range-parser": "^1.2.1", - "rimraf": "^3.0.2", - "socket.io": "^4.7.2", - "source-map": "^0.6.1", - "tmp": "^0.2.1", - "ua-parser-js": "^0.7.30", - "yargs": "^16.1.1" - }, - "bin": { - "karma": "bin/karma" - }, - "engines": { - "node": ">= 10" - } - }, - "node_modules/karma-chrome-launcher": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/karma-chrome-launcher/-/karma-chrome-launcher-3.1.1.tgz", - "integrity": "sha512-hsIglcq1vtboGPAN+DGCISCFOxW+ZVnIqhDQcCMqqCp+4dmJ0Qpq5QAjkbA0X2L9Mi6OBkHi2Srrbmm7pUKkzQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "which": "^1.2.1" - } - }, - "node_modules/karma-chrome-launcher/node_modules/which": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/which/-/which-1.3.1.tgz", - "integrity": "sha512-HxJdYWq1MTIQbJ3nw0cqssHoTNU267KlrDuGZ1WYlxDStUtKUhOaJmh112/TZmHxxUfuJqPXSOm7tDyas0OSIQ==", - "dev": true, - "license": "ISC", - "dependencies": { - "isexe": "^2.0.0" - }, - "bin": { - "which": "bin/which" - } - }, - "node_modules/karma-coverage": { - "version": "2.2.1", - "resolved": "https://registry.npmjs.org/karma-coverage/-/karma-coverage-2.2.1.tgz", - "integrity": "sha512-yj7hbequkQP2qOSb20GuNSIyE//PgJWHwC2IydLE6XRtsnaflv+/OSGNssPjobYUlhVVagy99TQpqUt3vAUG7A==", - "dev": true, - "license": "MIT", - "dependencies": { - "istanbul-lib-coverage": "^3.2.0", - "istanbul-lib-instrument": "^5.1.0", - "istanbul-lib-report": "^3.0.0", - "istanbul-lib-source-maps": "^4.0.1", - "istanbul-reports": "^3.0.5", - "minimatch": "^3.0.4" - }, - "engines": { - "node": ">=10.0.0" - } - }, - "node_modules/karma-coverage/node_modules/brace-expansion": { - "version": "1.1.12", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", - "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" - } - }, - "node_modules/karma-coverage/node_modules/istanbul-lib-instrument": { - "version": "5.2.1", - "resolved": "https://registry.npmjs.org/istanbul-lib-instrument/-/istanbul-lib-instrument-5.2.1.tgz", - "integrity": "sha512-pzqtp31nLv/XFOzXGuvhCb8qhjmTVo5vjVk19XE4CRlSWz0KoeJ3bw9XsA7nOp9YBf4qHjwBxkDzKcME/J29Yg==", - "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "@babel/core": "^7.12.3", - "@babel/parser": "^7.14.7", - "@istanbuljs/schema": "^0.1.2", - "istanbul-lib-coverage": "^3.2.0", - "semver": "^6.3.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/karma-coverage/node_modules/minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", - "dev": true, - "license": "ISC", - "dependencies": { - "brace-expansion": "^1.1.7" - }, - "engines": { - "node": "*" - } - }, - "node_modules/karma-coverage/node_modules/semver": { - "version": "6.3.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", - "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", - "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - } - }, - "node_modules/karma-jasmine": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/karma-jasmine/-/karma-jasmine-5.1.0.tgz", - "integrity": "sha512-i/zQLFrfEpRyQoJF9fsCdTMOF5c2dK7C7OmsuKg2D0YSsuZSfQDiLuaiktbuio6F2wiCsZSnSnieIQ0ant/uzQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "jasmine-core": "^4.1.0" - }, - "engines": { - "node": ">=12" - }, - "peerDependencies": { - "karma": "^6.0.0" - } - }, - "node_modules/karma-jasmine-html-reporter": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/karma-jasmine-html-reporter/-/karma-jasmine-html-reporter-2.0.0.tgz", - "integrity": "sha512-SB8HNNiazAHXM1vGEzf8/tSyEhkfxuDdhYdPBX2Mwgzt0OuF2gicApQ+uvXLID/gXyJQgvrM9+1/2SxZFUUDIA==", - "dev": true, - "license": "MIT", - "peerDependencies": { - "jasmine-core": "^4.0.0", - "karma": "^6.0.0", - "karma-jasmine": "^5.0.0" - } - }, - "node_modules/karma/node_modules/body-parser": { - "version": "1.20.4", - "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.4.tgz", - "integrity": "sha512-ZTgYYLMOXY9qKU/57FAo8F+HA2dGX7bqGc71txDRC1rS4frdFI5R7NhluHxH6M0YItAP0sHB4uqAOcYKxO6uGA==", - "dev": true, - "license": "MIT", - "dependencies": { - "bytes": "~3.1.2", - "content-type": "~1.0.5", - "debug": "2.6.9", - "depd": "2.0.0", - "destroy": "~1.2.0", - "http-errors": "~2.0.1", - "iconv-lite": "~0.4.24", - "on-finished": "~2.4.1", - "qs": "~6.14.0", - "raw-body": "~2.5.3", - "type-is": "~1.6.18", - "unpipe": "~1.0.0" - }, - "engines": { - "node": ">= 0.8", - "npm": "1.2.8000 || >= 1.4.16" - } - }, - "node_modules/karma/node_modules/brace-expansion": { - "version": "1.1.12", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", - "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" - } - }, - "node_modules/karma/node_modules/chokidar": { - "version": "3.6.0", - "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", - "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", - "dev": true, - "license": "MIT", - "dependencies": { - "anymatch": "~3.1.2", - "braces": "~3.0.2", - "glob-parent": "~5.1.2", - "is-binary-path": "~2.1.0", - "is-glob": "~4.0.1", - "normalize-path": "~3.0.0", - "readdirp": "~3.6.0" - }, - "engines": { - "node": ">= 8.10.0" - }, - "funding": { - "url": "https://paulmillr.com/funding/" - }, - "optionalDependencies": { - "fsevents": "~2.3.2" - } - }, - "node_modules/karma/node_modules/cliui": { - "version": "7.0.4", - "resolved": "https://registry.npmjs.org/cliui/-/cliui-7.0.4.tgz", - "integrity": "sha512-OcRE68cOsVMXp1Yvonl/fzkQOyjLSu/8bhPDfQt0e0/Eb283TKP20Fs2MqoPsr9SwA595rRCA+QMzYc9nBP+JQ==", - "dev": true, - "license": "ISC", - "dependencies": { - "string-width": "^4.2.0", - "strip-ansi": "^6.0.0", - "wrap-ansi": "^7.0.0" - } - }, - "node_modules/karma/node_modules/debug": { - "version": "2.6.9", - "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", - "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", - "dev": true, - "license": "MIT", - "dependencies": { - "ms": "2.0.0" - } - }, - "node_modules/karma/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, - "license": "MIT" - }, - "node_modules/karma/node_modules/glob-parent": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", - "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", - "dev": true, - "license": "ISC", - "dependencies": { - "is-glob": "^4.0.1" - }, - "engines": { - "node": ">= 6" - } - }, - "node_modules/karma/node_modules/iconv-lite": { - "version": "0.4.24", - "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", - "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", - "dev": true, - "license": "MIT", - "dependencies": { - "safer-buffer": ">= 2.1.2 < 3" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/karma/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, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/karma/node_modules/media-typer": { - "version": "0.3.0", - "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", - "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/karma/node_modules/mime-db": { - "version": "1.52.0", - "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", - "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/karma/node_modules/mime-types": { - "version": "2.1.35", - "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", - "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", - "dev": true, - "license": "MIT", - "dependencies": { - "mime-db": "1.52.0" - }, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/karma/node_modules/minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", - "dev": true, - "license": "ISC", - "dependencies": { - "brace-expansion": "^1.1.7" - }, - "engines": { - "node": "*" - } - }, - "node_modules/karma/node_modules/ms": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", - "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", - "dev": true, - "license": "MIT" - }, - "node_modules/karma/node_modules/picomatch": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", - "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8.6" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, - "node_modules/karma/node_modules/qs": { - "version": "6.14.2", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.2.tgz", - "integrity": "sha512-V/yCWTTF7VJ9hIh18Ugr2zhJMP01MY7c5kh4J870L7imm6/DIzBsNLTXzMwUA3yZ5b/KBqLx8Kp3uRvd7xSe3Q==", - "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "side-channel": "^1.1.0" - }, - "engines": { - "node": ">=0.6" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/karma/node_modules/raw-body": { - "version": "2.5.3", - "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.3.tgz", - "integrity": "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==", - "dev": true, - "license": "MIT", - "dependencies": { - "bytes": "~3.1.2", - "http-errors": "~2.0.1", - "iconv-lite": "~0.4.24", - "unpipe": "~1.0.0" - }, - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/karma/node_modules/readdirp": { - "version": "3.6.0", - "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", - "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", - "dev": true, - "license": "MIT", - "dependencies": { - "picomatch": "^2.2.1" - }, - "engines": { - "node": ">=8.10.0" - } - }, - "node_modules/karma/node_modules/source-map": { - "version": "0.6.1", - "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", - "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", - "dev": true, - "license": "BSD-3-Clause", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/karma/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, - "license": "MIT", - "dependencies": { - "emoji-regex": "^8.0.0", - "is-fullwidth-code-point": "^3.0.0", - "strip-ansi": "^6.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/karma/node_modules/type-is": { - "version": "1.6.18", - "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", - "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", - "dev": true, - "license": "MIT", - "dependencies": { - "media-typer": "0.3.0", - "mime-types": "~2.1.24" - }, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/karma/node_modules/wrap-ansi": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", - "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-styles": "^4.0.0", - "string-width": "^4.1.0", - "strip-ansi": "^6.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/wrap-ansi?sponsor=1" - } - }, - "node_modules/karma/node_modules/yargs": { - "version": "16.2.0", - "resolved": "https://registry.npmjs.org/yargs/-/yargs-16.2.0.tgz", - "integrity": "sha512-D1mvvtDG0L5ft/jGWkLpG1+m0eQxOfaBvTNELraWj22wSVUMWxZUvYgJYcKh6jGGIkJFhH4IZPQhR4TKpc8mBw==", - "dev": true, - "license": "MIT", - "dependencies": { - "cliui": "^7.0.2", - "escalade": "^3.1.1", - "get-caller-file": "^2.0.5", - "require-directory": "^2.1.1", - "string-width": "^4.2.0", - "y18n": "^5.0.5", - "yargs-parser": "^20.2.2" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/karma/node_modules/yargs-parser": { - "version": "20.2.9", - "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-20.2.9.tgz", - "integrity": "sha512-y11nGElTIV+CT3Zv9t7VKl+Q3hTQoT9a1Qzezhhl6Rp21gJ/IVTW7Z3y9EWXhuUBC2Shnf+DX0antecpAwSP8w==", - "dev": true, - "license": "ISC", - "engines": { - "node": ">=10" - } - }, "node_modules/keyv": { "version": "4.5.4", "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", @@ -8708,23 +7682,6 @@ "url": "https://github.com/chalk/wrap-ansi?sponsor=1" } }, - "node_modules/log4js": { - "version": "6.9.1", - "resolved": "https://registry.npmjs.org/log4js/-/log4js-6.9.1.tgz", - "integrity": "sha512-1somDdy9sChrr9/f4UlzhdaGfDR2c/SaD2a4T7qEkG4jTS57/B3qmnjLYePwQ8cqWnUHZI0iAKxMBpCZICiZ2g==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "date-format": "^4.0.14", - "debug": "^4.3.4", - "flatted": "^3.2.7", - "rfdc": "^1.3.0", - "streamroller": "^3.1.5" - }, - "engines": { - "node": ">=8.0" - } - }, "node_modules/lru-cache": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", @@ -8755,22 +7712,6 @@ "@jridgewell/sourcemap-codec": "^1.5.5" } }, - "node_modules/make-dir": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz", - "integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==", - "dev": true, - "license": "MIT", - "dependencies": { - "semver": "^7.5.3" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/make-fetch-happen": { "version": "15.0.6", "resolved": "https://registry.npmjs.org/make-fetch-happen/-/make-fetch-happen-15.0.6.tgz", @@ -8805,6 +7746,13 @@ "node": ">= 0.4" } }, + "node_modules/mdn-data": { + "version": "2.27.1", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz", + "integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==", + "dev": true, + "license": "CC0-1.0" + }, "node_modules/media-typer": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz", @@ -8820,25 +7768,12 @@ "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz", "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==", "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/mime": { - "version": "2.6.0", - "resolved": "https://registry.npmjs.org/mime/-/mime-2.6.0.tgz", - "integrity": "sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg==", - "dev": true, - "license": "MIT", - "bin": { - "mime": "cli.js" - }, + "license": "MIT", "engines": { - "node": ">=4.0.0" + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, "node_modules/mime-db": { @@ -8897,16 +7832,6 @@ "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/minimist": { - "version": "1.2.8", - "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", - "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", - "dev": true, - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/minipass": { "version": "7.1.3", "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", @@ -9040,19 +7965,6 @@ "node": ">= 18" } }, - "node_modules/mkdirp": { - "version": "0.5.6", - "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.6.tgz", - "integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==", - "dev": true, - "license": "MIT", - "dependencies": { - "minimist": "^1.2.6" - }, - "bin": { - "mkdirp": "bin/cmd.js" - } - }, "node_modules/mrmime": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/mrmime/-/mrmime-2.0.1.tgz", @@ -9248,16 +8160,6 @@ "node": "^20.17.0 || >=22.9.0" } }, - "node_modules/normalize-path": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", - "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/npm-bundled": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/npm-bundled/-/npm-bundled-5.0.0.tgz", @@ -9673,16 +8575,6 @@ "node": ">=8" } }, - "node_modules/path-is-absolute": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", - "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/path-key": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", @@ -9932,23 +8824,6 @@ "node": ">= 0.10" } }, - "node_modules/punycode": { - "version": "1.4.1", - "resolved": "https://registry.npmjs.org/punycode/-/punycode-1.4.1.tgz", - "integrity": "sha512-jmYNElW7yvO7TV33CjSmvSiE2yco3bV2czu/OzDKdMNVZQWfxCblURLhf+47syQRBntjfLdd/H0egrzIG+oaFQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/qjobs": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/qjobs/-/qjobs-1.2.0.tgz", - "integrity": "sha512-8YOJEHtxpySA3fFDyCRxA+UUV+fA+rTWnuWvylOK/NCjhY+b4ocCtmu8TtsWb+mYeU+GCHf/S66KZF/AsteKHg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.9" - } - }, "node_modules/qs": { "version": "6.15.3", "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.3.tgz", @@ -10040,13 +8915,6 @@ "node": ">=0.10.0" } }, - "node_modules/requires-port": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz", - "integrity": "sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==", - "dev": true, - "license": "MIT" - }, "node_modules/restore-cursor": { "version": "5.1.0", "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-5.1.0.tgz", @@ -10071,23 +8939,6 @@ "dev": true, "license": "MIT" }, - "node_modules/rimraf": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", - "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==", - "deprecated": "Rimraf versions prior to v4 are no longer supported", - "dev": true, - "license": "ISC", - "dependencies": { - "glob": "^7.1.3" - }, - "bin": { - "rimraf": "bin.js" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, "node_modules/rollup": { "version": "4.60.2", "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.2.tgz", @@ -10159,24 +9010,6 @@ "tslib": "^2.1.0" } }, - "node_modules/safe-regex-test": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/safe-regex-test/-/safe-regex-test-1.1.0.tgz", - "integrity": "sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.2", - "es-errors": "^1.3.0", - "is-regex": "^1.2.1" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/safer-buffer": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", @@ -10205,6 +9038,19 @@ "@parcel/watcher": "^2.4.1" } }, + "node_modules/saxes": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", + "dev": true, + "license": "ISC", + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=v12.22.7" + } + }, "node_modules/semver": { "version": "7.8.5", "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", @@ -10478,97 +9324,6 @@ "npm": ">= 3.0.0" } }, - "node_modules/socket.io": { - "version": "4.8.3", - "resolved": "https://registry.npmjs.org/socket.io/-/socket.io-4.8.3.tgz", - "integrity": "sha512-2Dd78bqzzjE6KPkD5fHZmDAKRNe3J15q+YHDrIsy9WEkqttc7GY+kT9OBLSMaPbQaEd0x1BjcmtMtXkfpc+T5A==", - "dev": true, - "license": "MIT", - "dependencies": { - "accepts": "~1.3.4", - "base64id": "~2.0.0", - "cors": "~2.8.5", - "debug": "~4.4.1", - "engine.io": "~6.6.0", - "socket.io-adapter": "~2.5.2", - "socket.io-parser": "~4.2.4" - }, - "engines": { - "node": ">=10.2.0" - } - }, - "node_modules/socket.io-adapter": { - "version": "2.5.6", - "resolved": "https://registry.npmjs.org/socket.io-adapter/-/socket.io-adapter-2.5.6.tgz", - "integrity": "sha512-DkkO/dz7MGln0dHn5bmN3pPy+JmywNICWrJqVWiVOyvXjWQFIv9c2h24JrQLLFJ2aQVQf/Cvl1vblnd4r2apLQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "debug": "~4.4.1", - "ws": "~8.18.3" - } - }, - "node_modules/socket.io-parser": { - "version": "4.2.5", - "resolved": "https://registry.npmjs.org/socket.io-parser/-/socket.io-parser-4.2.5.tgz", - "integrity": "sha512-bPMmpy/5WWKHea5Y/jYAP6k74A+hvmRCQaJuJB6I/ML5JZq/KfNieUVo/3Mh7SAqn7TyFdIo6wqYHInG1MU1bQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@socket.io/component-emitter": "~3.1.0", - "debug": "~4.4.1" - }, - "engines": { - "node": ">=10.0.0" - } - }, - "node_modules/socket.io/node_modules/accepts": { - "version": "1.3.8", - "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", - "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", - "dev": true, - "license": "MIT", - "dependencies": { - "mime-types": "~2.1.34", - "negotiator": "0.6.3" - }, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/socket.io/node_modules/mime-db": { - "version": "1.52.0", - "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", - "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/socket.io/node_modules/mime-types": { - "version": "2.1.35", - "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", - "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", - "dev": true, - "license": "MIT", - "dependencies": { - "mime-db": "1.52.0" - }, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/socket.io/node_modules/negotiator": { - "version": "0.6.3", - "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", - "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, "node_modules/socks": { "version": "2.8.9", "resolved": "https://registry.npmjs.org/socks/-/socks-2.8.9.tgz", @@ -10731,21 +9486,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/streamroller": { - "version": "3.1.5", - "resolved": "https://registry.npmjs.org/streamroller/-/streamroller-3.1.5.tgz", - "integrity": "sha512-KFxaM7XT+irxvdqSP1LGLgNWbYN7ay5owZ3r/8t77p+EtSUAfUgtl7be3xtqtOmGUl9K9YPO2ca8133RlTjvKw==", - "dev": true, - "license": "MIT", - "dependencies": { - "date-format": "^4.0.14", - "debug": "^4.3.4", - "fs-extra": "^8.1.0" - }, - "engines": { - "node": ">=8.0" - } - }, "node_modules/string-width": { "version": "8.2.2", "resolved": "https://registry.npmjs.org/string-width/-/string-width-8.2.2.tgz", @@ -10834,6 +9574,13 @@ "url": "https://github.com/chalk/supports-color?sponsor=1" } }, + "node_modules/symbol-tree": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", + "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", + "dev": true, + "license": "MIT" + }, "node_modules/tar": { "version": "7.5.20", "resolved": "https://registry.npmjs.org/tar/-/tar-7.5.20.tgz", @@ -10918,29 +9665,26 @@ "node": ">=14.0.0" } }, - "node_modules/tmp": { - "version": "0.2.5", - "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.5.tgz", - "integrity": "sha512-voyz6MApa1rQGUxT3E+BK7/ROe8itEx7vD8/HEvt4xwXucvQ5G5oeEiHkmHZJuBO21RpOf+YYm9MOivj709jow==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=14.14" - } - }, - "node_modules/to-regex-range": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", - "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "node_modules/tldts": { + "version": "7.4.19", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.4.19.tgz", + "integrity": "sha512-48dH6aSpQnpbzDUc4vIQZ1VjB8tkK+e8c1yx05/SZK+rkssgjEOlxDd/yU8/a4vGYQ9vRgJr9dwMq9TJ0b3X2Q==", "dev": true, "license": "MIT", "dependencies": { - "is-number": "^7.0.0" + "tldts-core": "^7.4.19" }, - "engines": { - "node": ">=8.0" + "bin": { + "tldts": "bin/cli.js" } }, + "node_modules/tldts-core": { + "version": "7.4.20", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.4.20.tgz", + "integrity": "sha512-C630TWXZCZmLq0F2bW4QqwnaaxCQ/svpAqvJwnC4JvgEM5ohhBnGsEzTLSOXcN+xjz+kEaHS5uoWIw2klnqd0A==", + "dev": true, + "license": "MIT" + }, "node_modules/toidentifier": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", @@ -10961,6 +9705,42 @@ "node": ">=6" } }, + "node_modules/tough-cookie": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.2.tgz", + "integrity": "sha512-exgYmnmL/sJpR3upZfXG5PoatXQii55xAiXGXzY+sROLZ/Y+SLcp9PgJNI9Vz37HpQ74WvDcLT8eqm+kV3FzrA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "tldts": "^7.0.5" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/tr46": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz", + "integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/tr46/node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/tree-kill": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.2.tgz", @@ -11089,33 +9869,6 @@ "typescript": ">=4.8.4 <6.1.0" } }, - "node_modules/ua-parser-js": { - "version": "0.7.41", - "resolved": "https://registry.npmjs.org/ua-parser-js/-/ua-parser-js-0.7.41.tgz", - "integrity": "sha512-O3oYyCMPYgNNHuO7Jjk3uacJWZF8loBgwrfd/5LE/HyZ3lUIOdniQ7DNXJcIgZbwioZxk0fLfI4EVnetdiX5jg==", - "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/ua-parser-js" - }, - { - "type": "paypal", - "url": "https://paypal.me/faisalman" - }, - { - "type": "github", - "url": "https://github.com/sponsors/faisalman" - } - ], - "license": "MIT", - "bin": { - "ua-parser-js": "script/cli.js" - }, - "engines": { - "node": "*" - } - }, "node_modules/undici": { "version": "6.27.0", "resolved": "https://registry.npmjs.org/undici/-/undici-6.27.0.tgz", @@ -11133,16 +9886,6 @@ "dev": true, "license": "MIT" }, - "node_modules/universalify": { - "version": "0.1.2", - "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.1.2.tgz", - "integrity": "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 4.0.0" - } - }, "node_modules/unpipe": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", @@ -11204,16 +9947,6 @@ "node": ">=6" } }, - "node_modules/utils-merge": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", - "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4.0" - } - }, "node_modules/validate-npm-package-name": { "version": "7.0.2", "resolved": "https://registry.npmjs.org/validate-npm-package-name/-/validate-npm-package-name-7.0.2.tgz", @@ -11909,14 +10642,17 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, - "node_modules/void-elements": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/void-elements/-/void-elements-2.0.1.tgz", - "integrity": "sha512-qZKX4RnBzH2ugr8Lxa7x+0V6XD9Sb/ouARtiasEQCHB1EVU4NXtmHsDDrx1dO4ne5fc3J6EW05BP1Dl0z0iung==", + "node_modules/w3c-xmlserializer": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", + "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", "dev": true, "license": "MIT", + "dependencies": { + "xml-name-validator": "^5.0.0" + }, "engines": { - "node": ">=0.10.0" + "node": ">=18" } }, "node_modules/watchpack": { @@ -11941,6 +10677,41 @@ "license": "MIT", "optional": true }, + "node_modules/webidl-conversions": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz", + "integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20" + } + }, + "node_modules/whatwg-mimetype": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz", + "integrity": "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + } + }, + "node_modules/whatwg-url": { + "version": "16.0.1", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-16.0.1.tgz", + "integrity": "sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.11.0", + "tr46": "^6.0.0", + "webidl-conversions": "^8.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", @@ -12051,28 +10822,23 @@ "dev": true, "license": "ISC" }, - "node_modules/ws": { - "version": "8.18.3", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz", - "integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==", + "node_modules/xml-name-validator": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", + "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", "dev": true, - "license": "MIT", + "license": "Apache-2.0", "engines": { - "node": ">=10.0.0" - }, - "peerDependencies": { - "bufferutil": "^4.0.1", - "utf-8-validate": ">=5.0.2" - }, - "peerDependenciesMeta": { - "bufferutil": { - "optional": true - }, - "utf-8-validate": { - "optional": true - } + "node": ">=18" } }, + "node_modules/xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "dev": true, + "license": "MIT" + }, "node_modules/y18n": { "version": "5.0.8", "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", @@ -12209,12 +10975,6 @@ "peerDependencies": { "zod": "^3.25.28 || ^4" } - }, - "node_modules/zone.js": { - "version": "0.15.1", - "resolved": "https://registry.npmjs.org/zone.js/-/zone.js-0.15.1.tgz", - "integrity": "sha512-XE96n56IQpJM7NAoXswY3XRLcWFW83xe0BiAOeMD7K5k5xecOeul3Qcpx6GqEeeHNkW5DWL5zOyTbEfB4eti8w==", - "license": "MIT" } } } diff --git a/console-webapp/package.json b/console-webapp/package.json index b5256b9abe9..bd9ccdfa1a5 100644 --- a/console-webapp/package.json +++ b/console-webapp/package.json @@ -7,7 +7,7 @@ "build": "ng build --base-href=/console/ --configuration=$npm_config_configuration", "build:local": "ng build --base-href=/default/console/", "watch": "ng build --watch --configuration=development", - "test": "ng test --browsers=ChromeHeadless --watch=false", + "test": "ng test --watch=false", "run:dev": "", "prettify": "npx prettier --write ./src/", "prettify:check": "npx prettier --check ./src/", @@ -24,11 +24,9 @@ "@angular/forms": "^22.0.6", "@angular/material": "^22.0.4", "@angular/platform-browser": "^22.0.6", - "@angular/platform-browser-dynamic": "^22.0.6", "@angular/router": "^22.0.6", "rxjs": "~7.5.0", - "tslib": "^2.3.0", - "zone.js": "~0.15.0" + "tslib": "^2.3.0" }, "devDependencies": { "@angular-eslint/builder": "22.1.0", @@ -40,7 +38,6 @@ "@angular/cli": "~22.0.6", "@angular/compiler-cli": "^22.0.6", "@eslint/js": "^10.0.1", - "@types/jasmine": "~4.0.0", "@types/node": "^24.13.3", "@typescript-eslint/eslint-plugin": "^8.64.0", "@typescript-eslint/parser": "^8.64.0", @@ -49,13 +46,7 @@ "angular-eslint": "^22.1.0", "concurrently": "^7.6.0", "eslint": "^10.7.0", - "istanbul-lib-instrument": "^6.0.3", - "jasmine-core": "~4.3.0", - "karma": "~6.4.0", - "karma-chrome-launcher": "~3.1.0", - "karma-coverage": "~2.2.0", - "karma-jasmine": "~5.1.0", - "karma-jasmine-html-reporter": "~2.0.0", + "jsdom": "^28.0.0", "prettier": "2.8.7", "typescript": "^6.0.3", "typescript-eslint": "^8.64.0", diff --git a/console-webapp/src/app/app-routing.module.ts b/console-webapp/src/app/app-routing.module.ts deleted file mode 100644 index ec9f46c6a71..00000000000 --- a/console-webapp/src/app/app-routing.module.ts +++ /dev/null @@ -1,155 +0,0 @@ -// Copyright 2024 The Nomulus Authors. All Rights Reserved. -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -import { NgModule } from '@angular/core'; -import { Route, RouterModule } from '@angular/router'; -import { BillingInfoComponent } from './billingInfo/billingInfo.component'; -import { DomainListComponent } from './domains/domainList.component'; -import { HomeComponent } from './home/home.component'; -import { RegistryLockVerifyComponent } from './lock/registryLockVerify.component'; -import { RegistrarDetailsComponent } from './registrar/registrarDetails.component'; -import { RegistrarComponent } from './registrar/registrarsTable.component'; -import { ResourcesComponent } from './resources/resources.component'; -import ContactComponent from './settings/contact/contact.component'; -import SecurityComponent from './settings/security/security.component'; -import { SettingsComponent } from './settings/settings.component'; -import { SupportComponent } from './support/support.component'; -import RdapComponent from './settings/rdap/rdap.component'; -import { HistoryComponent } from './history/history.component'; -import { PasswordResetVerifyComponent } from './shared/components/passwordReset/passwordResetVerify.component'; - -export interface RouteWithIcon extends Route { - iconName?: string; -} - -export const PATHS = { - NewOteComponent: 'new-ote', - OteStatusComponent: 'ote-status/:registrarId', - UsersComponent: 'users', -}; -export const routes: RouteWithIcon[] = [ - { path: '', redirectTo: '/home', pathMatch: 'full' }, - { - path: PasswordResetVerifyComponent.PATH, - component: PasswordResetVerifyComponent, - }, - { - path: RegistryLockVerifyComponent.PATH, - component: RegistryLockVerifyComponent, - }, - { - path: PATHS.NewOteComponent, - loadComponent: () => - import('./ote/newOte.component').then((mod) => mod.NewOteComponent), - }, - { - path: PATHS.OteStatusComponent, - loadComponent: () => - import('./ote/oteStatus.component').then((mod) => mod.OteStatusComponent), - }, - { path: 'registrars', component: RegistrarComponent }, - { - path: 'home', - component: HomeComponent, - title: 'Dashboard', - iconName: 'view_comfy_alt', - }, - { - path: DomainListComponent.PATH, - component: DomainListComponent, - title: 'Domains', - iconName: 'view_list', - }, - { - path: HistoryComponent.PATH, - component: HistoryComponent, - // title: 'History', - // iconName: 'history', - }, - { - path: SettingsComponent.PATH, - component: SettingsComponent, - title: 'Settings', - iconName: 'settings', - children: [ - { - path: '', - redirectTo: ContactComponent.PATH, - pathMatch: 'full', - }, - { - path: ContactComponent.PATH, - component: ContactComponent, - title: 'Contacts', - }, - { - path: RdapComponent.PATH, - component: RdapComponent, - title: 'RDAP Info', - }, - { - path: SecurityComponent.PATH, - component: SecurityComponent, - title: 'Security', - }, - ], - }, - // { - // path: EppConsole.PATH, - // component: EppConsoleComponent, - // title: "EPP Console", - // iconName: "upgrade" - // }, - { - path: RegistrarComponent.PATH, - component: RegistrarComponent, - title: 'Registrars', - iconName: 'account_circle', - }, - { - path: RegistrarDetailsComponent.PATH, - component: RegistrarDetailsComponent, - }, - { - path: BillingInfoComponent.PATH, - component: BillingInfoComponent, - title: 'Billing Info', - iconName: 'credit_card', - }, - { - path: ResourcesComponent.PATH, - component: ResourcesComponent, - title: 'Resources', - iconName: 'description', - }, - { - path: PATHS.UsersComponent, - title: 'Users', - iconName: 'manage_accounts', - loadComponent: () => - import('./users/users.component').then((mod) => mod.UsersComponent), - }, - { - path: SupportComponent.PATH, - component: SupportComponent, - title: 'Support', - iconName: 'help', - }, -]; - -@NgModule({ - imports: [RouterModule.forRoot(routes, { useHash: true })], - exports: [RouterModule], -}) -export class AppRoutingModule {} diff --git a/console-webapp/src/app/app.component.html b/console-webapp/src/app/app.component.html index 78b58b4fd24..341925375a4 100644 --- a/console-webapp/src/app/app.component.html +++ b/console-webapp/src/app/app.component.html @@ -1,7 +1,7 @@ -
+
- @if (globalLoader.isLoading) { + @if (globalLoader.isLoading()) { }
diff --git a/console-webapp/src/app/app.component.spec.ts b/console-webapp/src/app/app.component.spec.ts index ed224c7bd71..e9759755fc2 100644 --- a/console-webapp/src/app/app.component.spec.ts +++ b/console-webapp/src/app/app.component.spec.ts @@ -14,18 +14,19 @@ import { provideHttpClient } from '@angular/common/http'; import { provideHttpClientTesting } from '@angular/common/http/testing'; -import { ComponentFixture, fakeAsync, TestBed } from '@angular/core/testing'; +import { ComponentFixture, TestBed } from '@angular/core/testing'; import { NoopAnimationsModule } from '@angular/platform-browser/animations'; import { AppComponent } from './app.component'; -import { routes } from './app-routing.module'; -import { AppModule } from './app.module'; +import { routes } from './app.routes'; import { PocReminderComponent } from './shared/components/pocReminder/pocReminder.component'; -import { RouterModule } from '@angular/router'; +import { NavigationEnd, Router, RouterModule } from '@angular/router'; import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar'; import { UserData, UserDataService } from './shared/services/userData.service'; import { Registrar, RegistrarService } from './registrar/registrar.service'; import { MatSidenavModule } from '@angular/material/sidenav'; import { signal, WritableSignal } from '@angular/core'; +import { BreakPointObserverService } from './shared/services/breakPoint.service'; +import { Subject } from 'rxjs'; describe('AppComponent', () => { let component: AppComponent; @@ -36,9 +37,13 @@ describe('AppComponent', () => { registrars: WritableSignal>>; }; let mockUserDataService: { userData: WritableSignal> }; - let mockSnackBar: jasmine.SpyObj; + let mockBreakPointService: { + isMobileView: WritableSignal; + isTabletView: WritableSignal; + }; + let openFromComponentSpy: ReturnType; - const dummyPocReminderComponent = class {}; // Dummy class for type checking + const dummyPocReminderComponent = class {}; beforeEach(async () => { mockRegistrarService = { @@ -53,20 +58,26 @@ describe('AppComponent', () => { }), }; - mockSnackBar = jasmine.createSpyObj('MatSnackBar', ['openFromComponent']); + mockBreakPointService = { + isMobileView: signal(false), + isTabletView: signal(false), + }; await TestBed.configureTestingModule({ imports: [ MatSidenavModule, NoopAnimationsModule, MatSnackBarModule, - AppModule, + AppComponent, RouterModule.forRoot(routes), ], providers: [ { provide: RegistrarService, useValue: mockRegistrarService }, { provide: UserDataService, useValue: mockUserDataService }, - { provide: MatSnackBar, useValue: mockSnackBar }, + { + provide: BreakPointObserverService, + useValue: mockBreakPointService, + }, { provide: PocReminderComponent, useClass: dummyPocReminderComponent }, provideHttpClient(), provideHttpClientTesting(), @@ -75,27 +86,29 @@ describe('AppComponent', () => { fixture = TestBed.createComponent(AppComponent); component = fixture.componentInstance; + const snackBar = fixture.debugElement.injector.get(MatSnackBar); + openFromComponentSpy = vi + .spyOn(snackBar, 'openFromComponent') + .mockReturnValue({} as any); }); afterEach(() => { - jasmine.clock().uninstall(); + vi.useRealTimers(); }); it('should create the app', () => { - const fixture = TestBed.createComponent(AppComponent); fixture.detectChanges(); - const app = fixture.componentInstance; - expect(app).toBeTruthy(); + expect(component).toBeTruthy(); }); describe('PoC Verification Reminder', () => { beforeEach(() => { - jasmine.clock().install(); + vi.useFakeTimers(); }); - it('should open snackbar if lastPocVerificationDate is older than one year', fakeAsync(() => { + it('should open snackbar if lastPocVerificationDate is older than one year for NONE role', () => { const MOCK_TODAY = new Date('2024-07-15T10:00:00.000Z'); - jasmine.clock().mockDate(MOCK_TODAY); + vi.setSystemTime(MOCK_TODAY); const twoYearsAgo = new Date(MOCK_TODAY); twoYearsAgo.setFullYear(MOCK_TODAY.getFullYear() - 2); @@ -107,19 +120,16 @@ describe('AppComponent', () => { fixture.detectChanges(); TestBed.flushEffects(); - expect(mockSnackBar.openFromComponent).toHaveBeenCalledWith( - PocReminderComponent, - { - horizontalPosition: 'center', - verticalPosition: 'top', - duration: 1000000000, - } - ); - })); - - it('should NOT open snackbar if lastPocVerificationDate is within last year', fakeAsync(() => { + expect(openFromComponentSpy).toHaveBeenCalledWith(PocReminderComponent, { + horizontalPosition: 'center', + verticalPosition: 'top', + duration: 1000000000, + }); + }); + + it('should NOT open snackbar if lastPocVerificationDate is within last year', () => { const MOCK_TODAY = new Date('2024-07-15T10:00:00.000Z'); - jasmine.clock().mockDate(MOCK_TODAY); + vi.setSystemTime(MOCK_TODAY); const sixMonthsAgo = new Date(MOCK_TODAY); sixMonthsAgo.setMonth(MOCK_TODAY.getMonth() - 6); @@ -131,7 +141,71 @@ describe('AppComponent', () => { fixture.detectChanges(); TestBed.flushEffects(); - expect(mockSnackBar.openFromComponent).not.toHaveBeenCalled(); - })); + expect(openFromComponentSpy).not.toHaveBeenCalled(); + }); + + it('should NOT open snackbar if user has a support globalRole', () => { + const MOCK_TODAY = new Date('2024-07-15T10:00:00.000Z'); + vi.setSystemTime(MOCK_TODAY); + + const twoYearsAgo = new Date(MOCK_TODAY); + twoYearsAgo.setFullYear(MOCK_TODAY.getFullYear() - 2); + + mockUserDataService.userData.set({ globalRole: 'SUPPORT_AGENT' }); + mockRegistrarService.registrar.set({ + lastPocVerificationDate: twoYearsAgo.toISOString(), + }); + + fixture.detectChanges(); + TestBed.flushEffects(); + + expect(openFromComponentSpy).not.toHaveBeenCalled(); + }); + }); + + describe('Sidenav behavior', () => { + it('should toggle sidenav open and closed', () => { + fixture.detectChanges(); + const closeSpy = vi.spyOn(component.sidenav, 'close'); + const openSpy = vi.spyOn(component.sidenav, 'open'); + + Object.defineProperty(component.sidenav, 'opened', { + value: true, + configurable: true, + }); + component.toggleSidenav(); + expect(closeSpy).toHaveBeenCalled(); + + Object.defineProperty(component.sidenav, 'opened', { + value: false, + configurable: true, + }); + component.toggleSidenav(); + expect(openSpy).toHaveBeenCalled(); + }); + + it('should close sidenav on NavigationEnd when in mobile view', () => { + const router = TestBed.inject(Router); + const eventsSubject = router.events as unknown as Subject; + fixture.detectChanges(); + + const closeSpy = vi.spyOn(component.sidenav, 'close'); + mockBreakPointService.isMobileView.set(true); + eventsSubject.next(new NavigationEnd(1, '/home', '/home')); + + expect(closeSpy).toHaveBeenCalled(); + }); + + it('should NOT close sidenav on NavigationEnd when in desktop view', () => { + const router = TestBed.inject(Router); + const eventsSubject = router.events as unknown as Subject; + fixture.detectChanges(); + + const closeSpy = vi.spyOn(component.sidenav, 'close'); + mockBreakPointService.isMobileView.set(false); + eventsSubject.next(new NavigationEnd(1, '/home', '/home')); + + expect(closeSpy).not.toHaveBeenCalled(); + }); }); }); diff --git a/console-webapp/src/app/app.component.ts b/console-webapp/src/app/app.component.ts index 96e654f18d5..c2f0b9104ac 100644 --- a/console-webapp/src/app/app.component.ts +++ b/console-webapp/src/app/app.component.ts @@ -20,7 +20,10 @@ import { ChangeDetectionStrategy, } from '@angular/core'; import { MatSidenav } from '@angular/material/sidenav'; -import { NavigationEnd, Router } from '@angular/router'; +import { NavigationEnd, Router, RouterModule } from '@angular/router'; +import { HeaderComponent } from './header/header.component'; +import { MaterialModule } from './material.module'; +import { NavigationComponent } from './navigation/navigation.component'; import { RegistrarService } from './registrar/registrar.service'; import { BreakPointObserverService } from './shared/services/breakPoint.service'; import { GlobalLoaderService } from './shared/services/globalLoader.service'; @@ -30,10 +33,10 @@ import { PocReminderComponent } from './shared/components/pocReminder/pocReminde @Component({ selector: 'app-root', + imports: [MaterialModule, RouterModule, HeaderComponent, NavigationComponent], templateUrl: './app.component.html', styleUrls: ['./app.component.scss'], - changeDetection: ChangeDetectionStrategy.Eager, - standalone: false, + changeDetection: ChangeDetectionStrategy.OnPush, }) export class AppComponent implements AfterViewInit { @ViewChild(MatSidenav) diff --git a/console-webapp/src/app/app.config.ts b/console-webapp/src/app/app.config.ts new file mode 100644 index 00000000000..bce0e387183 --- /dev/null +++ b/console-webapp/src/app/app.config.ts @@ -0,0 +1,64 @@ +// Copyright 2026 The Nomulus Authors. All Rights Reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { + provideHttpClient, + withInterceptorsFromDi, + withXsrfConfiguration, +} from '@angular/common/http'; +import { + ApplicationConfig, + importProvidersFrom, + provideZonelessChangeDetection, +} from '@angular/core'; +import { MAT_FORM_FIELD_DEFAULT_OPTIONS } from '@angular/material/form-field'; +import { MAT_SNACK_BAR_DEFAULT_OPTIONS } from '@angular/material/snack-bar'; +import { provideAnimations } from '@angular/platform-browser/animations'; +import { provideRouter, withHashLocation } from '@angular/router'; +import { routes } from './app.routes'; +import { MaterialModule } from './material.module'; +import { BackendService } from './shared/services/backend.service'; +import { BreakPointObserverService } from './shared/services/breakPoint.service'; +import { GlobalLoaderService } from './shared/services/globalLoader.service'; +import { UserDataService } from './shared/services/userData.service'; + +export const appConfig: ApplicationConfig = { + providers: [ + provideZonelessChangeDetection(), + provideRouter(routes, withHashLocation()), + provideAnimations(), + importProvidersFrom(MaterialModule), + provideHttpClient( + withInterceptorsFromDi(), + withXsrfConfiguration({ + cookieName: 'X-CSRF-Token', + headerName: 'X-CSRF-Token', + }) + ), + BackendService, + BreakPointObserverService, + GlobalLoaderService, + UserDataService, + { + provide: MAT_FORM_FIELD_DEFAULT_OPTIONS, + useValue: { + subscriptSizing: 'dynamic', + }, + }, + { + provide: MAT_SNACK_BAR_DEFAULT_OPTIONS, + useValue: { duration: 5000 }, + }, + ], +}; diff --git a/console-webapp/src/app/app.module.ts b/console-webapp/src/app/app.module.ts deleted file mode 100644 index 2fc62e8e684..00000000000 --- a/console-webapp/src/app/app.module.ts +++ /dev/null @@ -1,145 +0,0 @@ -// Copyright 2024 The Nomulus Authors. All Rights Reserved. -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -import { NgModule } from '@angular/core'; -import { FormsModule } from '@angular/forms'; -import { BrowserModule } from '@angular/platform-browser'; - -import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; -import { AppRoutingModule } from './app-routing.module'; -import { AppComponent } from './app.component'; -import { MaterialModule } from './material.module'; - -import { BackendService } from './shared/services/backend.service'; - -import { - HttpClientXsrfModule, - provideHttpClient, - withInterceptorsFromDi, - withXhr, -} from '@angular/common/http'; -import { MAT_FORM_FIELD_DEFAULT_OPTIONS } from '@angular/material/form-field'; -import { BillingInfoComponent } from './billingInfo/billingInfo.component'; -import { - DomainListComponent, - ReasonDialogComponent, - ResponseDialogComponent, -} from './domains/domainList.component'; -import { RegistryLockComponent } from './domains/registryLock.component'; -import { HeaderComponent } from './header/header.component'; -import { HomeComponent } from './home/home.component'; -import { RegistryLockVerifyComponent } from './lock/registryLockVerify.component'; -import { NavigationComponent } from './navigation/navigation.component'; -import NewRegistrarComponent from './registrar/newRegistrar.component'; -import { RegistrarDetailsComponent } from './registrar/registrarDetails.component'; -import { RegistrarSelectorComponent } from './registrar/registrarSelector.component'; -import { RegistrarComponent } from './registrar/registrarsTable.component'; -import { ResourcesComponent } from './resources/resources.component'; -import SettingsContactComponent from './settings/contact/contact.component'; -import { ContactDetailsComponent } from './settings/contact/contactDetails.component'; -import EppPasswordEditComponent from './settings/security/eppPasswordEdit.component'; -import SecurityComponent from './settings/security/security.component'; -import SecurityEditComponent from './settings/security/securityEdit.component'; -import { SettingsComponent } from './settings/settings.component'; -import { NotificationsComponent } from './shared/components/notifications/notifications.component'; -import { SelectedRegistrarWrapper } from './shared/components/selectedRegistrarWrapper/selectedRegistrarWrapper.component'; -import { LocationBackDirective } from './shared/directives/locationBack.directive'; -import { UserLevelVisibility } from './shared/directives/userLevelVisiblity.directive'; -import { BreakPointObserverService } from './shared/services/breakPoint.service'; -import { GlobalLoaderService } from './shared/services/globalLoader.service'; -import { UserDataService } from './shared/services/userData.service'; -import { SnackBarModule } from './snackbar.module'; -import { SupportComponent } from './support/support.component'; -import { ForceFocusDirective } from './shared/directives/forceFocus.directive'; -import RdapComponent from './settings/rdap/rdap.component'; -import RdapEditComponent from './settings/rdap/rdapEdit.component'; -import { PocReminderComponent } from './shared/components/pocReminder/pocReminder.component'; -import { PasswordResetVerifyComponent } from './shared/components/passwordReset/passwordResetVerify.component'; -import { PasswordInputForm } from './shared/components/passwordReset/passwordInputForm.component'; -import { HistoryComponent } from './history/history.component'; -import { HistoryListComponent } from './history/historyList.component'; - -@NgModule({ - declarations: [SelectedRegistrarWrapper], - imports: [MaterialModule], - exports: [SelectedRegistrarWrapper], - providers: [], -}) -export class SelectedRegistrarModule {} - -@NgModule({ - declarations: [ - AppComponent, - BillingInfoComponent, - ContactDetailsComponent, - DomainListComponent, - EppPasswordEditComponent, - ForceFocusDirective, - HeaderComponent, - HistoryComponent, - HistoryListComponent, - HomeComponent, - LocationBackDirective, - NavigationComponent, - NewRegistrarComponent, - NotificationsComponent, - PasswordInputForm, - PasswordResetVerifyComponent, - PocReminderComponent, - RdapComponent, - RdapEditComponent, - ReasonDialogComponent, - RegistrarComponent, - RegistrarDetailsComponent, - RegistrarSelectorComponent, - RegistryLockComponent, - RegistryLockVerifyComponent, - ResourcesComponent, - ResponseDialogComponent, - SecurityComponent, - SecurityEditComponent, - SettingsComponent, - SettingsContactComponent, - SupportComponent, - UserLevelVisibility, - ], - bootstrap: [AppComponent], - imports: [ - AppRoutingModule, - BrowserAnimationsModule, - BrowserModule, - FormsModule, - MaterialModule, - SelectedRegistrarModule, - SnackBarModule, - HttpClientXsrfModule.withOptions({ - cookieName: 'X-CSRF-Token', - headerName: 'X-CSRF-Token', - }), - ], - providers: [ - BackendService, - BreakPointObserverService, - GlobalLoaderService, - UserDataService, - { - provide: MAT_FORM_FIELD_DEFAULT_OPTIONS, - useValue: { - subscriptSizing: 'dynamic', - }, - }, - provideHttpClient(withInterceptorsFromDi()), - ], -}) -export class AppModule {} diff --git a/console-webapp/src/app/app.routes.ts b/console-webapp/src/app/app.routes.ts new file mode 100644 index 00000000000..8d3be0df71d --- /dev/null +++ b/console-webapp/src/app/app.routes.ts @@ -0,0 +1,191 @@ +// Copyright 2026 The Nomulus Authors. All Rights Reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { Route } from '@angular/router'; + +export interface RouteWithIcon extends Route { + iconName?: string; +} + +export const PATHS = { + BillingInfoComponent: 'billingInfo', + ContactComponent: 'contact', + DomainListComponent: 'domain-list', + HistoryComponent: 'history', + HomeComponent: 'home', + NewOteComponent: 'new-ote', + OteStatusComponent: 'ote-status/:registrarId', + PasswordResetVerifyComponent: 'password-reset-verify', + RdapComponent: 'rdap', + RegistrarComponent: 'registrars', + RegistrarDetailsComponent: 'registrars/:id', + RegistryLockVerifyComponent: 'registry-lock-verify', + ResourcesComponent: 'resources', + SecurityComponent: 'security', + SettingsComponent: 'settings', + SupportComponent: 'support', + UsersComponent: 'users', +}; + +export const routes: RouteWithIcon[] = [ + { path: '', redirectTo: '/home', pathMatch: 'full' }, + { + path: PATHS.PasswordResetVerifyComponent, + loadComponent: () => + import( + './shared/components/passwordReset/passwordResetVerify.component' + ).then((mod) => mod.PasswordResetVerifyComponent), + }, + { + path: PATHS.RegistryLockVerifyComponent, + loadComponent: () => + import('./lock/registryLockVerify.component').then( + (mod) => mod.RegistryLockVerifyComponent + ), + }, + { + path: PATHS.NewOteComponent, + loadComponent: () => + import('./ote/newOte.component').then((mod) => mod.NewOteComponent), + }, + { + path: PATHS.OteStatusComponent, + loadComponent: () => + import('./ote/oteStatus.component').then((mod) => mod.OteStatusComponent), + }, + { + path: 'registrars', + loadComponent: () => + import('./registrar/registrarsTable.component').then( + (mod) => mod.RegistrarComponent + ), + }, + { + path: PATHS.HomeComponent, + loadComponent: () => + import('./home/home.component').then((mod) => mod.HomeComponent), + title: 'Dashboard', + iconName: 'view_comfy_alt', + }, + { + path: PATHS.DomainListComponent, + loadComponent: () => + import('./domains/domainList.component').then( + (mod) => mod.DomainListComponent + ), + title: 'Domains', + iconName: 'view_list', + }, + { + path: PATHS.HistoryComponent, + loadComponent: () => + import('./history/history.component').then((mod) => mod.HistoryComponent), + // title: 'History', + // iconName: 'history', + }, + { + path: PATHS.SettingsComponent, + loadComponent: () => + import('./settings/settings.component').then( + (mod) => mod.SettingsComponent + ), + title: 'Settings', + iconName: 'settings', + children: [ + { + path: '', + redirectTo: PATHS.ContactComponent, + pathMatch: 'full', + }, + { + path: PATHS.ContactComponent, + loadComponent: () => + import('./settings/contact/contact.component').then( + (mod) => mod.ContactComponent + ), + title: 'Contacts', + }, + { + path: PATHS.RdapComponent, + loadComponent: () => + import('./settings/rdap/rdap.component').then( + (mod) => mod.RdapComponent + ), + title: 'RDAP Info', + }, + { + path: PATHS.SecurityComponent, + loadComponent: () => + import('./settings/security/security.component').then( + (mod) => mod.SecurityComponent + ), + title: 'Security', + }, + ], + }, + // { + // path: EppConsole.PATH, + // component: EppConsoleComponent, + // title: "EPP Console", + // iconName: "upgrade" + // }, + { + path: PATHS.RegistrarComponent, + loadComponent: () => + import('./registrar/registrarsTable.component').then( + (mod) => mod.RegistrarComponent + ), + title: 'Registrars', + iconName: 'account_circle', + }, + { + path: PATHS.RegistrarDetailsComponent, + loadComponent: () => + import('./registrar/registrarDetails.component').then( + (mod) => mod.RegistrarDetailsComponent + ), + }, + { + path: PATHS.BillingInfoComponent, + loadComponent: () => + import('./billingInfo/billingInfo.component').then( + (mod) => mod.BillingInfoComponent + ), + title: 'Billing Info', + iconName: 'credit_card', + }, + { + path: PATHS.ResourcesComponent, + loadComponent: () => + import('./resources/resources.component').then( + (mod) => mod.ResourcesComponent + ), + title: 'Resources', + iconName: 'description', + }, + { + path: PATHS.UsersComponent, + title: 'Users', + iconName: 'manage_accounts', + loadComponent: () => + import('./users/users.component').then((mod) => mod.UsersComponent), + }, + { + path: PATHS.SupportComponent, + loadComponent: () => + import('./support/support.component').then((mod) => mod.SupportComponent), + title: 'Support', + iconName: 'help', + }, +]; diff --git a/console-webapp/src/app/billingInfo/billingInfo.component.html b/console-webapp/src/app/billingInfo/billingInfo.component.html index 4d08d47f451..893d298885d 100644 --- a/console-webapp/src/app/billingInfo/billingInfo.component.html +++ b/console-webapp/src/app/billingInfo/billingInfo.component.html @@ -1,5 +1,5 @@ -

Billing Info

+

Billing Info

diff --git a/console-webapp/src/app/billingInfo/billingInfo.component.spec.ts b/console-webapp/src/app/billingInfo/billingInfo.component.spec.ts new file mode 100644 index 00000000000..38df0f3c6ac --- /dev/null +++ b/console-webapp/src/app/billingInfo/billingInfo.component.spec.ts @@ -0,0 +1,85 @@ +// Copyright 2026 The Nomulus Authors. All Rights Reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { provideHttpClient } from '@angular/common/http'; +import { provideHttpClientTesting } from '@angular/common/http/testing'; +import { MatSnackBar } from '@angular/material/snack-bar'; +import { signal } from '@angular/core'; +import { MaterialModule } from '../material.module'; +import { Registrar, RegistrarService } from '../registrar/registrar.service'; +import { BillingInfoComponent } from './billingInfo.component'; + +describe('BillingInfoComponent', () => { + let component: BillingInfoComponent; + let fixture: ComponentFixture; + let registrarSignal: ReturnType< + typeof signal | undefined> + >; + let mockSnackBar: { open: ReturnType }; + + beforeEach(async () => { + registrarSignal = signal | undefined>({ + registrarId: 'reg-1', + driveFolderId: 'drive-folder-abc', + }); + + await TestBed.configureTestingModule({ + imports: [BillingInfoComponent, MaterialModule], + providers: [ + { + provide: RegistrarService, + useValue: { + registrarId: signal('reg-1'), + registrar: registrarSignal, + }, + }, + provideHttpClient(), + provideHttpClientTesting(), + ], + }).compileComponents(); + + fixture = TestBed.createComponent(BillingInfoComponent); + mockSnackBar = { + open: vi + .spyOn(fixture.debugElement.injector.get(MatSnackBar), 'open') + .mockReturnValue({} as any), + }; + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should compute Google Drive folder URL when driveFolderId is present', () => { + expect(component.driveFolderUrl()).toBe( + 'https://drive.google.com/drive/folders/drive-folder-abc' + ); + + const clickEvent = { preventDefault: vi.fn() } as unknown as MouseEvent; + component.openBillingDetails(clickEvent); + expect(clickEvent.preventDefault).not.toHaveBeenCalled(); + expect(mockSnackBar.open).not.toHaveBeenCalled(); + }); + + it('should return empty URL and show snackbar when driveFolderId is missing', () => { + registrarSignal.set({ registrarId: 'reg-1' }); + expect(component.driveFolderUrl()).toBe(''); + + const clickEvent = { preventDefault: vi.fn() } as unknown as MouseEvent; + component.openBillingDetails(clickEvent); + expect(clickEvent.preventDefault).toHaveBeenCalled(); + expect(mockSnackBar.open).toHaveBeenCalledWith( + 'Billing Folder ID has not been assigned' + ); + }); +}); diff --git a/console-webapp/src/app/billingInfo/billingInfo.component.ts b/console-webapp/src/app/billingInfo/billingInfo.component.ts index dbd32f8c1dc..e69345b4af9 100644 --- a/console-webapp/src/app/billingInfo/billingInfo.component.ts +++ b/console-webapp/src/app/billingInfo/billingInfo.component.ts @@ -13,15 +13,18 @@ // limitations under the License. import { Component, computed, ChangeDetectionStrategy } from '@angular/core'; +import { MaterialModule } from '../material.module'; import { RegistrarService } from '../registrar/registrar.service'; import { MatSnackBar } from '@angular/material/snack-bar'; +import { SelectedRegistrarWrapper } from '../shared/components/selectedRegistrarWrapper/selectedRegistrarWrapper.component'; +import { ForceFocusDirective } from '../shared/directives/forceFocus.directive'; @Component({ selector: 'app-billingInfo', + imports: [MaterialModule, SelectedRegistrarWrapper, ForceFocusDirective], templateUrl: './billingInfo.component.html', styleUrls: ['./billingInfo.component.scss'], - changeDetection: ChangeDetectionStrategy.Eager, - standalone: false, + changeDetection: ChangeDetectionStrategy.OnPush, }) export class BillingInfoComponent { public static PATH = 'billingInfo'; diff --git a/console-webapp/src/app/domains/domainList.component.html b/console-webapp/src/app/domains/domainList.component.html index 69df4b85bcd..2008c7b5faf 100644 --- a/console-webapp/src/app/domains/domainList.component.html +++ b/console-webapp/src/app/domains/domainList.component.html @@ -1,19 +1,18 @@
-

Domains

+

Domains

- @if (!isLoading && totalResults == 0) { + @if (!isLoading() && totalResults() == 0) {

No domains found

- @if (isLoading) { + @if (isLoading()) {
@@ -215,7 +214,7 @@

No domains found

{ let component: DomainListComponent; let fixture: ComponentFixture; + let domainListService: DomainListService; + let registryLockService: RegistryLockService; + let registrarIdSignal: ReturnType>; + let snackBarOpenSpy: ReturnType; + let dialogOpenSpy: ReturnType; + + const activeDomain: Domain = { + domainName: 'example.tld', + currentSponsorRegistrarId: 'reg-1', + creationTime: { creationTime: '2024-01-01T00:00:00Z' }, + registrationExpirationTime: '2025-01-01T00:00:00Z', + statuses: ['OK'], + }; + + const suspendedDomain: Domain = { + domainName: 'suspended.tld', + currentSponsorRegistrarId: 'reg-1', + creationTime: { creationTime: '2024-01-01T00:00:00Z' }, + registrationExpirationTime: '2025-01-01T00:00:00Z', + statuses: [ + 'SERVER_RENEW_PROHIBITED', + 'SERVER_TRANSFER_PROHIBITED', + 'SERVER_UPDATE_PROHIBITED', + 'SERVER_DELETE_PROHIBITED', + 'SERVER_HOLD', + ], + }; beforeEach(async () => { + registrarIdSignal = signal('reg-1'); + await TestBed.configureTestingModule({ - declarations: [DomainListComponent], imports: [ + DomainListComponent, MaterialModule, BrowserAnimationsModule, FormsModule, - AppModule, ], providers: [ BackendService, + DomainListService, + RegistryLockService, + { + provide: RegistrarService, + useValue: { + registrarId: registrarIdSignal, + registrars: signal([]), + registrar: signal(undefined), + }, + }, provideHttpClient(), provideHttpClientTesting(), ], }).compileComponents(); + domainListService = TestBed.inject(DomainListService); + registryLockService = TestBed.inject(RegistryLockService); + + vi.spyOn(registryLockService, 'retrieveLocks').mockImplementation(() => { + registryLockService.domainsLocks.set([{ domainName: 'example.tld' }]); + return of(registryLockService.domainsLocks()); + }); + vi.spyOn(domainListService, 'retrieveDomains').mockImplementation(() => { + domainListService.domainsList.set([activeDomain, suspendedDomain]); + return of({ + checkpointTime: '2024-06-01T00:00:00Z', + domains: domainListService.domainsList(), + totalResults: 2, + }); + }); + fixture = TestBed.createComponent(DomainListComponent); component = fixture.componentInstance; + snackBarOpenSpy = vi + .spyOn(fixture.debugElement.injector.get(MatSnackBar), 'open') + .mockReturnValue({} as any); + dialogOpenSpy = vi + .spyOn(fixture.debugElement.injector.get(MatDialog), 'open') + .mockReturnValue({ + afterClosed: () => of('admin policy reason'), + } as any); fixture.detectChanges(); }); - it('should create', () => { - expect(component).toBeTruthy(); + afterEach(() => { + vi.useRealTimers(); + }); + + it('should load domains and registry locks when registrarId is present', () => { + expect(registryLockService.retrieveLocks).toHaveBeenCalled(); + expect(domainListService.retrieveDomains).toHaveBeenCalledWith( + 0, + 50, + 0, + undefined + ); + expect(component.dataSource.data).toEqual([activeDomain, suspendedDomain]); + expect(component.totalResults()).toBe(2); + expect(component.isLoading()).toBe(false); + }); + + it('should suppress snackbar on 403 Forbidden from retrieveLocks but display other errors', () => { + vi.spyOn(registryLockService, 'retrieveLocks').mockReturnValueOnce( + throwError( + () => + new HttpErrorResponse({ + status: HttpStatusCode.Forbidden, + statusText: 'Forbidden', + }) + ) + ); + component.loadLocks(); + expect(snackBarOpenSpy).not.toHaveBeenCalled(); + + vi.spyOn(registryLockService, 'retrieveLocks').mockReturnValueOnce( + throwError( + () => + new HttpErrorResponse({ + status: HttpStatusCode.InternalServerError, + statusText: 'Server Error', + }) + ) + ); + component.loadLocks(); + expect(snackBarOpenSpy).toHaveBeenCalled(); + }); + + it('should identify locked and unsuspendable domains accurately', () => { + expect(component.isDomainLocked('example.tld')).toBe(true); + expect(component.isDomainLocked('suspended.tld')).toBe(false); + + expect(component.isDomainUnsuspendable(activeDomain)).toBe(false); + expect(component.isDomainUnsuspendable(suspendedDomain)).toBe(true); + }); + + it('should toggle all rows selection and compute checkbox labels', () => { + expect(component.checkboxLabel()).toBe('select all'); + expect(component.checkboxLabel(activeDomain)).toBe( + 'select row example.tld' + ); + + component.toggleAllRows(); + expect(component.isAllSelected).toBe(true); + expect(component.selection.selected).toHaveLength(2); + expect(component.checkboxLabel()).toBe('deselect all'); + expect(component.checkboxLabel(activeDomain)).toBe( + 'deselect row example.tld' + ); + + component.toggleAllRows(); + expect(component.isAllSelected).toBe(false); + expect(component.selection.selected).toHaveLength(0); + }); + + it('should update pagination, clear selection, and reload domains on page change', () => { + component.selection.select(activeDomain); + const pageEvent: PageEvent = { + pageIndex: 2, + pageSize: 25, + length: 100, + }; + + component.onPageChange(pageEvent); + + expect(component.pageNumber).toBe(2); + expect(component.resultsPerPage).toBe(25); + expect(component.selection.selected).toHaveLength(0); + expect(domainListService.retrieveDomains).toHaveBeenLastCalledWith( + 2, + 25, + 2, + undefined + ); + }); + + it('should debounce search input before reloading domains', () => { + vi.useFakeTimers(); + const retrieveSpy = vi.spyOn(domainListService, 'retrieveDomains'); + retrieveSpy.mockClear(); + + component.searchTerm = 'example'; + component.sendInput(); + expect(retrieveSpy).not.toHaveBeenCalled(); + + vi.advanceTimersByTime(500); + expect(retrieveSpy).toHaveBeenCalledWith(0, 50, 2, 'example'); + }); + + it('should open registry lock view for selected domain', () => { + component.openRegistryLock('example.tld'); + expect(domainListService.selectedDomain()).toBe('example.tld'); + expect(domainListService.activeActionComponent()).toBe( + RegistryLockComponent + ); + }); + + it('should return restricted element IDs for bulk delete and suspend', () => { + expect(component.getElementIdForBulkDelete()).toBe( + RESTRICTED_ELEMENTS.BULK_DELETE + ); + expect(component.getElementIdForSuspendUnsuspend()).toBe( + RESTRICTED_ELEMENTS.SUSPEND + ); + }); + + it('should execute bulk delete via ReasonDialog and display ResponseDialog summary', () => { + const bulkResult = { + 'example.tld': { message: 'Deleted', responseCode: '1000' }, + 'suspended.tld': { message: 'In use', responseCode: '2304' }, + }; + const bulkSpy = vi + .spyOn(domainListService, 'bulkDomainAction') + .mockReturnValue(of(bulkResult)); + + component.selection.select(activeDomain, suspendedDomain); + component.deleteSelectedDomains(); + + expect(dialogOpenSpy).toHaveBeenCalledWith(ReasonDialogComponent, { + data: { operation: 'deleting' }, + }); + expect(bulkSpy).toHaveBeenCalledWith( + ['example.tld', 'suspended.tld'], + 'admin policy reason', + 'reg-1', + BULK_ACTION_NAME.DELETE + ); + expect(dialogOpenSpy).toHaveBeenCalledWith(ResponseDialogComponent, { + data: { + title: 'Domain Deletion Results', + content: [ + 'Successfully deleted - 1 domain(s)', + 'Failed to delete - 1 domain(s)', + 'Some domains could not be deleted due to ongoing processes or server errors. Please check the table for more information.', + ], + }, + }); + expect(component.getOperationMessage('suspended.tld')).toBe('In use'); + expect(component.getOperationMessage('other.tld')).toBe(''); + }); + + it('should execute suspend and unsuspend requests and handle 2xxx error codes', () => { + const bulkSpy = vi + .spyOn(domainListService, 'bulkDomainAction') + .mockReturnValueOnce( + of({ + 'example.tld': { message: 'Suspended OK', responseCode: '1000' }, + }) + ) + .mockReturnValueOnce( + of({ + 'suspended.tld': { + message: 'Cannot unsuspend', + responseCode: '2304', + }, + }) + ); + + component.onSuspendClick('example.tld'); + expect(bulkSpy).toHaveBeenCalledWith( + ['example.tld'], + 'admin policy reason', + 'reg-1', + BULK_ACTION_NAME.SUSPEND + ); + + component.onUnsuspendClick('suspended.tld'); + expect(bulkSpy).toHaveBeenCalledWith( + ['suspended.tld'], + 'admin policy reason', + 'reg-1', + BULK_ACTION_NAME.UNSUSPEND + ); + expect(snackBarOpenSpy).toHaveBeenCalledWith('Cannot unsuspend'); }); }); diff --git a/console-webapp/src/app/domains/domainList.component.ts b/console-webapp/src/app/domains/domainList.component.ts index a701acbb3ac..5663da5f821 100644 --- a/console-webapp/src/app/domains/domainList.component.ts +++ b/console-webapp/src/app/domains/domainList.component.ts @@ -13,19 +13,26 @@ // limitations under the License. import { SelectionModel } from '@angular/cdk/collections'; +import { CommonModule } from '@angular/common'; import { HttpErrorResponse, HttpStatusCode } from '@angular/common/http'; import { Component, effect, Inject, + signal, + untracked, ViewChild, ChangeDetectionStrategy, } from '@angular/core'; +import { FormsModule } from '@angular/forms'; import { MatPaginator, PageEvent } from '@angular/material/paginator'; import { MatSnackBar } from '@angular/material/snack-bar'; import { MatTableDataSource } from '@angular/material/table'; import { debounceTime, filter, Subject, take } from 'rxjs'; +import { MaterialModule } from '../material.module'; import { RegistrarService } from '../registrar/registrar.service'; +import { SelectedRegistrarWrapper } from '../shared/components/selectedRegistrarWrapper/selectedRegistrarWrapper.component'; +import { ForceFocusDirective } from '../shared/directives/forceFocus.directive'; import { BULK_ACTION_NAME, Domain, @@ -38,7 +45,10 @@ import { MatDialog, MatDialogRef, } from '@angular/material/dialog'; -import { RESTRICTED_ELEMENTS } from '../shared/directives/userLevelVisiblity.directive'; +import { + RESTRICTED_ELEMENTS, + UserLevelVisibility, +} from '../shared/directives/userLevelVisiblity.directive'; import { CdkColumnDef } from '@angular/cdk/table'; interface DomainResponse { @@ -52,6 +62,7 @@ interface DomainData { @Component({ selector: 'app-response-dialog', + imports: [MaterialModule], template: `

{{ data.title }}

@@ -63,8 +74,7 @@ interface DomainData { `, - changeDetection: ChangeDetectionStrategy.Eager, - standalone: false, + changeDetection: ChangeDetectionStrategy.OnPush, }) export class ResponseDialogComponent { constructor( @@ -86,6 +96,7 @@ enum Operation { @Component({ selector: 'app-reason-dialog', + imports: [FormsModule, MaterialModule], template: `

Please provide the (EPP) reason for {{ data.operation }} the domain(s): @@ -102,8 +113,7 @@ enum Operation { `, - changeDetection: ChangeDetectionStrategy.Eager, - standalone: false, + changeDetection: ChangeDetectionStrategy.OnPush, }) export class ReasonDialogComponent { reason: string = ''; @@ -124,10 +134,17 @@ export class ReasonDialogComponent { @Component({ selector: 'app-domain-list', + imports: [ + CommonModule, + FormsModule, + MaterialModule, + SelectedRegistrarWrapper, + ForceFocusDirective, + UserLevelVisibility, + ], templateUrl: './domainList.component.html', styleUrls: ['./domainList.component.scss'], - standalone: false, - changeDetection: ChangeDetectionStrategy.Eager, + changeDetection: ChangeDetectionStrategy.OnPush, providers: [CdkColumnDef], }) export class DomainListComponent { @@ -154,18 +171,18 @@ export class DomainListComponent { dataSource: MatTableDataSource = new MatTableDataSource(); selection = new SelectionModel(true, [], undefined, this.isChecked()); - isLoading = true; + isLoading = signal(true); searchTermSubject = new Subject(); searchTerm?: string; pageNumber?: number; resultsPerPage = 50; - totalResults?: number = 0; + totalResults = signal(0); reason: string = ''; - operationResult: DomainData | undefined; + operationResult = signal(undefined); @ViewChild(MatPaginator, { static: true }) paginator!: MatPaginator; @@ -177,12 +194,15 @@ export class DomainListComponent { private dialog: MatDialog ) { effect(() => { - this.pageNumber = 0; - this.totalResults = 0; - if (this.registrarService.registrarId()) { - this.loadLocks(); - this.reloadData(); - } + const registrarId = this.registrarService.registrarId(); + untracked(() => { + this.pageNumber = 0; + this.totalResults.set(0); + if (registrarId) { + this.loadLocks(); + this.reloadData(); + } + }); }); } @@ -201,8 +221,8 @@ export class DomainListComponent { } openRegistryLock(domainName: string) { - this.domainListService.selectedDomain = domainName; - this.domainListService.activeActionComponent = RegistryLockComponent; + this.domainListService.selectedDomain.set(domainName); + this.domainListService.activeActionComponent.set(RegistryLockComponent); } loadLocks() { @@ -217,29 +237,29 @@ export class DomainListComponent { } isDomainLocked(domainName: string) { - return this.registryLockService.domainsLocks.some( - (d) => d.domainName === domainName - ); + return this.registryLockService + .domainsLocks() + .some((d) => d.domainName === domainName); } reloadData() { - this.isLoading = true; + this.isLoading.set(true); this.domainListService .retrieveDomains( this.pageNumber, this.resultsPerPage, - this.totalResults, + this.totalResults(), this.searchTerm ) .subscribe({ error: (err: HttpErrorResponse) => { this._snackBar.open(err.message); - this.isLoading = false; + this.isLoading.set(false); }, next: (domainListResult) => { - this.dataSource.data = this.domainListService.domainsList; - this.totalResults = (domainListResult || {}).totalResults || 0; - this.isLoading = false; + this.dataSource.data = this.domainListService.domainsList(); + this.totalResults.set((domainListResult || {}).totalResults || 0); + this.isLoading.set(false); }, }); } @@ -294,8 +314,8 @@ export class DomainListComponent { } getOperationMessage(domain: string) { - if (this.operationResult && this.operationResult[domain]) - return this.operationResult[domain].message; + const opResult = this.operationResult(); + if (opResult && opResult[domain]) return opResult[domain].message; return ''; } @@ -306,7 +326,7 @@ export class DomainListComponent { } sendDeleteRequest(reason: string) { - this.isLoading = true; + this.isLoading.set(true); this.domainListService .bulkDomainAction( this.selection.selected.map((d) => d.domainName), @@ -317,7 +337,7 @@ export class DomainListComponent { .pipe(take(1)) .subscribe({ next: (result: DomainData) => { - this.isLoading = false; + this.isLoading.set(false); const successCount = Object.keys(result).filter((domainName) => result[domainName].responseCode.toString().startsWith('1') ).length; @@ -335,11 +355,11 @@ export class DomainListComponent { }, }); this.selection.clear(); - this.operationResult = result; + this.operationResult.set(result); this.reloadData(); }, error: (err: HttpErrorResponse) => { - this.isLoading = false; + this.isLoading.set(false); this._snackBar.open(err.error || err.message); }, }); @@ -366,7 +386,7 @@ export class DomainListComponent { reason: string, actionName: BULK_ACTION_NAME ) { - this.isLoading = true; + this.isLoading.set(true); this.domainListService .bulkDomainAction( [domainName], @@ -377,7 +397,7 @@ export class DomainListComponent { .pipe(take(1)) .subscribe({ next: (result: DomainData) => { - this.isLoading = false; + this.isLoading.set(false); if (result[domainName].responseCode.toString().startsWith('2')) { this._snackBar.open(result[domainName].message); } else { @@ -385,7 +405,7 @@ export class DomainListComponent { } }, error: (err: HttpErrorResponse) => { - this.isLoading = false; + this.isLoading.set(false); this._snackBar.open(err.error || err.message); }, }); diff --git a/console-webapp/src/app/domains/domainList.service.ts b/console-webapp/src/app/domains/domainList.service.ts index d277e0f58d9..4aa3933a3a7 100644 --- a/console-webapp/src/app/domains/domainList.service.ts +++ b/console-webapp/src/app/domains/domainList.service.ts @@ -12,7 +12,7 @@ // See the License for the specific language governing permissions and // limitations under the License. -import { Injectable, Type } from '@angular/core'; +import { Injectable, signal, Type } from '@angular/core'; import { tap } from 'rxjs'; import { RegistrarService } from '../registrar/registrar.service'; import { BackendService } from '../shared/services/backend.service'; @@ -46,9 +46,9 @@ export enum BULK_ACTION_NAME { }) export class DomainListService { checkpointTime?: string; - selectedDomain?: string; - public activeActionComponent: Type | null = null; - public domainsList: Domain[] = []; + selectedDomain = signal(undefined); + public activeActionComponent = signal | null>(null); + public domainsList = signal([]); constructor( private backendService: BackendService, @@ -72,7 +72,7 @@ export class DomainListService { .pipe( tap((domainListResult: DomainListResult) => { this.checkpointTime = domainListResult?.checkpointTime; - this.domainsList = domainListResult?.domains; + this.domainsList.set(domainListResult?.domains); }) ); } diff --git a/console-webapp/src/app/domains/registryLock.component.html b/console-webapp/src/app/domains/registryLock.component.html index b5553acd168..82192616b32 100644 --- a/console-webapp/src/app/domains/registryLock.component.html +++ b/console-webapp/src/app/domains/registryLock.component.html @@ -15,7 +15,7 @@

contact {{ userDataService.userData()?.supportEmail }}.

} @else if (isLocked()) { -

Unlock the domain {{ domainListService.selectedDomain }}

+

Unlock the domain {{ domainListService.selectedDomain() }}

Password: @@ -55,7 +55,7 @@

Unlock the domain {{ domainListService.selectedDomain }}

} @else { -

Lock the domain {{ domainListService.selectedDomain }}

+

Lock the domain {{ domainListService.selectedDomain() }}

Password: diff --git a/console-webapp/src/app/domains/registryLock.component.spec.ts b/console-webapp/src/app/domains/registryLock.component.spec.ts new file mode 100644 index 00000000000..eea63a7ce08 --- /dev/null +++ b/console-webapp/src/app/domains/registryLock.component.spec.ts @@ -0,0 +1,188 @@ +// Copyright 2026 The Nomulus Authors. All Rights Reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { HttpErrorResponse, provideHttpClient } from '@angular/common/http'; +import { provideHttpClientTesting } from '@angular/common/http/testing'; +import { NoopAnimationsModule } from '@angular/platform-browser/animations'; +import { MatSnackBar } from '@angular/material/snack-bar'; +import { signal } from '@angular/core'; +import { of, throwError } from 'rxjs'; +import { MaterialModule } from '../material.module'; +import { BackendService } from '../shared/services/backend.service'; +import { RegistrarService } from '../registrar/registrar.service'; +import { UserDataService } from '../shared/services/userData.service'; +import { BULK_ACTION_NAME, DomainListService } from './domainList.service'; +import { RegistryLockService } from './registryLock.service'; +import { RegistryLockComponent } from './registryLock.component'; + +describe('RegistryLockComponent, DomainListService & RegistryLockService', () => { + let component: RegistryLockComponent; + let fixture: ComponentFixture; + let domainListService: DomainListService; + let registryLockService: RegistryLockService; + let backendService: BackendService; + let snackBarOpenSpy: ReturnType; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [RegistryLockComponent, MaterialModule, NoopAnimationsModule], + providers: [ + BackendService, + DomainListService, + RegistryLockService, + { + provide: RegistrarService, + useValue: { + registrarId: signal('reg-1'), + registrar: signal({ registryLockAllowed: true }), + }, + }, + { + provide: UserDataService, + useValue: { + userData: signal({ isAdmin: false }), + }, + }, + provideHttpClient(), + provideHttpClientTesting(), + ], + }).compileComponents(); + + backendService = TestBed.inject(BackendService); + domainListService = TestBed.inject(DomainListService); + registryLockService = TestBed.inject(RegistryLockService); + + domainListService.selectedDomain.set('locked.tld'); + domainListService.activeActionComponent.set(RegistryLockComponent); + registryLockService.domainsLocks.set([{ domainName: 'locked.tld' }]); + + fixture = TestBed.createComponent(RegistryLockComponent); + component = fixture.componentInstance; + snackBarOpenSpy = vi + .spyOn(fixture.debugElement.injector.get(MatSnackBar), 'open') + .mockReturnValue({} as any); + fixture.detectChanges(); + }); + + it('should compute isLocked from domainsLocks and selectedDomain', () => { + expect(component.isLocked()).toBe(true); + }); + + it('should retrieve domains and execute bulk actions in DomainListService', () => { + const mockResult = { + checkpointTime: '2024-05-01T00:00:00Z', + domains: [], + totalResults: 0, + }; + const getDomainsSpy = vi + .spyOn(backendService, 'getDomains') + .mockReturnValue(of(mockResult)); + const bulkSpy = vi + .spyOn(backendService, 'bulkDomainAction') + .mockReturnValue(of({})); + + domainListService.retrieveDomains(1, 25, 100, 'test').subscribe(); + expect(getDomainsSpy).toHaveBeenCalledWith( + 'reg-1', + undefined, + 1, + 25, + 100, + 'test' + ); + expect(domainListService.checkpointTime).toBe('2024-05-01T00:00:00Z'); + + domainListService + .bulkDomainAction( + ['locked.tld'], + 'reason', + 'reg-1', + BULK_ACTION_NAME.DELETE + ) + .subscribe(); + expect(bulkSpy).toHaveBeenCalledWith( + ['locked.tld'], + 'reason', + BULK_ACTION_NAME.DELETE, + 'reg-1' + ); + }); + + it('should retrieve locks and submit lock/unlock requests via RegistryLockService', () => { + const getLocksSpy = vi + .spyOn(backendService, 'getLocks') + .mockReturnValue(of([{ domainName: 'newlock.tld' }])); + const lockDomainSpy = vi + .spyOn(backendService, 'registryLockDomain') + .mockReturnValue(of({})); + + registryLockService.retrieveLocks().subscribe(); + expect(getLocksSpy).toHaveBeenCalledWith('reg-1'); + expect(registryLockService.domainsLocks()).toEqual([ + { domainName: 'newlock.tld' }, + ]); + + registryLockService + .registryLockDomain('newlock.tld', 'pass123', 3600000, false) + .subscribe(); + expect(lockDomainSpy).toHaveBeenCalledWith( + 'newlock.tld', + 'pass123', + 3600000, + 'reg-1', + false + ); + }); + + it('should submit lock and unlock forms in RegistryLockComponent and goBack on completion', () => { + const lockSpy = vi + .spyOn(registryLockService, 'registryLockDomain') + .mockReturnValue(of({})); + + component.lockDomain.setValue({ password: 'lockPassword' }); + component.save(true); + expect(lockSpy).toHaveBeenCalledWith( + 'locked.tld', + 'lockPassword', + undefined, + true + ); + expect(domainListService.selectedDomain()).toBeUndefined(); + expect(domainListService.activeActionComponent()).toBeNull(); + + domainListService.selectedDomain.set('locked.tld'); + component.unlockDomain.setValue({ + password: 'unlockPassword', + relockTime: 3600000 as any, + }); + component.save(false); + expect(lockSpy).toHaveBeenCalledWith( + 'locked.tld', + 'unlockPassword', + 3600000, + false + ); + + lockSpy.mockReturnValueOnce( + throwError( + () => new HttpErrorResponse({ error: 'Invalid registry lock password' }) + ) + ); + component.save(true); + expect(snackBarOpenSpy).toHaveBeenCalledWith( + 'Invalid registry lock password' + ); + }); +}); diff --git a/console-webapp/src/app/domains/registryLock.component.ts b/console-webapp/src/app/domains/registryLock.component.ts index 285bbf320ec..5bfca1a505e 100644 --- a/console-webapp/src/app/domains/registryLock.component.ts +++ b/console-webapp/src/app/domains/registryLock.component.ts @@ -14,8 +14,14 @@ import { HttpErrorResponse } from '@angular/common/http'; import { Component, computed, ChangeDetectionStrategy } from '@angular/core'; -import { FormControl, FormGroup, Validators } from '@angular/forms'; +import { + FormControl, + FormGroup, + FormsModule, + Validators, +} from '@angular/forms'; import { MatSnackBar } from '@angular/material/snack-bar'; +import { MaterialModule } from '../material.module'; import { RegistrarService } from '../registrar/registrar.service'; import { UserDataService } from '../shared/services/userData.service'; import { DomainListService } from './domainList.service'; @@ -23,16 +29,16 @@ import { RegistryLockService } from './registryLock.service'; @Component({ selector: 'app-registry-lock', + imports: [FormsModule, MaterialModule], templateUrl: './registryLock.component.html', styleUrls: ['./registryLock.component.scss'], - changeDetection: ChangeDetectionStrategy.Eager, - standalone: false, + changeDetection: ChangeDetectionStrategy.OnPush, }) export class RegistryLockComponent { readonly isLocked = computed(() => - this.registryLockService.domainsLocks.some( - (dl) => dl.domainName === this.domainListService.selectedDomain - ) + this.registryLockService + .domainsLocks() + .some((dl) => dl.domainName === this.domainListService.selectedDomain()) ); relockOptions = [ @@ -60,22 +66,22 @@ export class RegistryLockComponent { ) {} goBack() { - this.domainListService.selectedDomain = undefined; - this.domainListService.activeActionComponent = null; + this.domainListService.selectedDomain.set(undefined); + this.domainListService.activeActionComponent.set(null); } save(isLock: boolean) { let request; if (!isLock) { request = this.registryLockService.registryLockDomain( - this.domainListService.selectedDomain || '', + this.domainListService.selectedDomain() || '', this.unlockDomain.value.password || '', this.unlockDomain.value.relockTime || undefined, isLock ); } else { request = this.registryLockService.registryLockDomain( - this.domainListService.selectedDomain || '', + this.domainListService.selectedDomain() || '', this.lockDomain.value.password || '', undefined, isLock diff --git a/console-webapp/src/app/domains/registryLock.service.ts b/console-webapp/src/app/domains/registryLock.service.ts index e4b87707c81..d1a53e6da01 100644 --- a/console-webapp/src/app/domains/registryLock.service.ts +++ b/console-webapp/src/app/domains/registryLock.service.ts @@ -12,7 +12,7 @@ // See the License for the specific language governing permissions and // limitations under the License. -import { Injectable } from '@angular/core'; +import { Injectable, signal } from '@angular/core'; import { tap } from 'rxjs'; import { RegistrarService } from '../registrar/registrar.service'; import { BackendService } from '../shared/services/backend.service'; @@ -25,7 +25,7 @@ export interface DomainLocksResult { providedIn: 'root', }) export class RegistryLockService { - public domainsLocks: DomainLocksResult[] = []; + public domainsLocks = signal([]); constructor( private backendService: BackendService, @@ -37,7 +37,7 @@ export class RegistryLockService { .getLocks(this.registrarService.registrarId()) .pipe( tap((domainLocksResult) => { - this.domainsLocks = domainLocksResult; + this.domainsLocks.set(domainLocksResult); }) ); } diff --git a/console-webapp/src/app/header/header.component.spec.ts b/console-webapp/src/app/header/header.component.spec.ts index 56a9226b594..941ce44d5f4 100644 --- a/console-webapp/src/app/header/header.component.spec.ts +++ b/console-webapp/src/app/header/header.component.spec.ts @@ -17,9 +17,8 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'; import { HeaderComponent } from './header.component'; import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; import { MaterialModule } from '../material.module'; -import { ActivatedRoute } from '@angular/router'; -import { AppModule, SelectedRegistrarModule } from '../app.module'; -import { AppRoutingModule } from '../app-routing.module'; +import { ActivatedRoute, RouterModule } from '@angular/router'; +import { routes } from '../app.routes'; import { BackendService } from '../shared/services/backend.service'; import { provideHttpClient } from '@angular/common/http'; import { provideHttpClientTesting } from '@angular/common/http/testing'; @@ -31,11 +30,10 @@ describe('HeaderComponent', () => { beforeEach(async () => { await TestBed.configureTestingModule({ imports: [ - SelectedRegistrarModule, + HeaderComponent, MaterialModule, BrowserAnimationsModule, - AppRoutingModule, - AppModule, + RouterModule.forRoot(routes), ], providers: [ BackendService, @@ -43,7 +41,6 @@ describe('HeaderComponent', () => { provideHttpClient(), provideHttpClientTesting(), ], - declarations: [HeaderComponent], }).compileComponents(); fixture = TestBed.createComponent(HeaderComponent); @@ -51,7 +48,23 @@ describe('HeaderComponent', () => { fixture.detectChanges(); }); - it('should create', () => { - expect(component).toBeTruthy(); + it('should toggle nav pane state and emit toggleNavOpen event', () => { + const emittedValues: boolean[] = []; + component.toggleNavOpen.subscribe((val) => emittedValues.push(val)); + + component.toggleNavPane(); + component.toggleNavPane(); + + expect(emittedValues).toEqual([true, false]); + }); + + it('should redirect to IAP logout URL on logOut()', () => { + const openSpy = vi.spyOn(window, 'open').mockImplementation(() => null); + component.logOut(); + expect(openSpy).toHaveBeenCalledWith( + '/console?gcp-iap-mode=CLEAR_LOGIN_COOKIE', + '_self' + ); + openSpy.mockRestore(); }); }); diff --git a/console-webapp/src/app/header/header.component.ts b/console-webapp/src/app/header/header.component.ts index 621cbfc1eb8..e9cbf0ffe8a 100644 --- a/console-webapp/src/app/header/header.component.ts +++ b/console-webapp/src/app/header/header.component.ts @@ -18,14 +18,17 @@ import { Output, ChangeDetectionStrategy, } from '@angular/core'; +import { RouterModule } from '@angular/router'; +import { MaterialModule } from '../material.module'; +import { RegistrarSelectorComponent } from '../registrar/registrarSelector.component'; import { BreakPointObserverService } from '../shared/services/breakPoint.service'; @Component({ selector: 'app-header', + imports: [MaterialModule, RouterModule, RegistrarSelectorComponent], templateUrl: './header.component.html', styleUrls: ['./header.component.scss'], - changeDetection: ChangeDetectionStrategy.Eager, - standalone: false, + changeDetection: ChangeDetectionStrategy.OnPush, }) export class HeaderComponent { private isNavOpen = false; diff --git a/console-webapp/src/app/history/history.component.html b/console-webapp/src/app/history/history.component.html index d0612ea2e9a..46722261be4 100644 --- a/console-webapp/src/app/history/history.component.html +++ b/console-webapp/src/app/history/history.component.html @@ -1,6 +1,6 @@

-

+

Registrar Console Activity History

@@ -47,7 +47,7 @@

@@ -57,6 +57,6 @@

[elementId]="getElementIdForUserLog()" [isReverse]="true" [historyRecords]="historyService.historyRecordsUser()" - [isLoading]="isLoading" + [isLoading]="isLoading()" /> diff --git a/console-webapp/src/app/history/history.component.spec.ts b/console-webapp/src/app/history/history.component.spec.ts new file mode 100644 index 00000000000..cc7bb001f39 --- /dev/null +++ b/console-webapp/src/app/history/history.component.spec.ts @@ -0,0 +1,156 @@ +// Copyright 2026 The Nomulus Authors. All Rights Reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { TestBed } from '@angular/core/testing'; +import { HttpErrorResponse, provideHttpClient } from '@angular/common/http'; +import { provideHttpClientTesting } from '@angular/common/http/testing'; +import { NoopAnimationsModule } from '@angular/platform-browser/animations'; +import { MatSnackBar } from '@angular/material/snack-bar'; +import { signal } from '@angular/core'; +import { of, throwError } from 'rxjs'; +import { MaterialModule } from '../material.module'; +import { BackendService } from '../shared/services/backend.service'; +import { GlobalLoaderService } from '../shared/services/globalLoader.service'; +import { UserDataService } from '../shared/services/userData.service'; +import { RegistrarService } from '../registrar/registrar.service'; +import { HistoryRecord, HistoryService } from './history.service'; +import { HistoryComponent } from './history.component'; +import { HistoryListComponent } from './historyList.component'; +import { RESTRICTED_ELEMENTS } from '../shared/directives/userLevelVisiblity.directive'; + +describe('HistoryComponent, HistoryListComponent & HistoryService', () => { + let backendService: BackendService; + let globalLoader: GlobalLoaderService; + let mockSnackBar: { open: ReturnType }; + + const sampleRecords: HistoryRecord[] = [ + { + modificationTime: '2025-01-01T12:00:00Z', + type: 'REGISTRAR_UPDATE', + description: 'Updated registrar|CLIENT_CERTIFICATE_UPDATED', + actingUser: { emailAddress: 'admin@example.com' }, + }, + ]; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [ + HistoryComponent, + HistoryListComponent, + MaterialModule, + NoopAnimationsModule, + ], + providers: [ + BackendService, + HistoryService, + GlobalLoaderService, + { + provide: RegistrarService, + useValue: { + registrarId: signal('reg-1'), + }, + }, + { + provide: UserDataService, + useValue: { + userData: signal({ globalRole: 'SUPPORT_ADMIN' }), + }, + }, + provideHttpClient(), + provideHttpClientTesting(), + ], + }).compileComponents(); + + backendService = TestBed.inject(BackendService); + globalLoader = TestBed.inject(GlobalLoaderService); + vi.spyOn(backendService, 'getHistoryLog').mockReturnValue( + of(sampleRecords) + ); + }); + + it('should store registrar and user history records in HistoryService signals', () => { + const historyService = TestBed.inject(HistoryService); + + historyService.getHistoryLog('reg-1').subscribe(); + expect(historyService.historyRecordsRegistrar()).toEqual(sampleRecords); + + historyService.getHistoryLog('reg-1', 'user@example.com').subscribe(); + expect(historyService.historyRecordsUser()).toEqual(sampleRecords); + }); + + it('should load history on registrarId effect, manage globalLoader, and handle errors', () => { + const fixture = TestBed.createComponent(HistoryComponent); + mockSnackBar = { + open: vi + .spyOn(fixture.debugElement.injector.get(MatSnackBar), 'open') + .mockReturnValue({} as any), + }; + const component = fixture.componentInstance; + fixture.detectChanges(); + + expect(backendService.getHistoryLog).toHaveBeenCalledWith('reg-1', ''); + expect(component.isLoading()).toBe(false); + expect(globalLoader.isLoading()).toBe(false); + expect(component.getElementIdForUserLog()).toBe( + RESTRICTED_ELEMENTS.ACTIVITY_PER_USER + ); + + component.loadingTimeout(); + expect(mockSnackBar.open).toHaveBeenCalledWith( + 'Timeout loading records history' + ); + + vi.spyOn(backendService, 'getHistoryLog').mockReturnValueOnce( + throwError(() => new HttpErrorResponse({ error: 'History fetch error' })) + ); + component.loadHistory(); + expect(mockSnackBar.open).toHaveBeenCalledWith('History fetch error'); + expect(globalLoader.isLoading()).toBe(false); + expect(component.isLoading()).toBe(false); + }); + + it('should map icons, icon classes, and split descriptions in HistoryListComponent', () => { + const fixture = TestBed.createComponent(HistoryListComponent); + const component = fixture.componentInstance; + + expect(component.getIconForType('REGISTRAR_UPDATE')).toBe('edit'); + expect(component.getIconForType('REGISTRAR_SECURITY_UPDATE')).toBe( + 'security' + ); + expect(component.getIconForType('OTHER')).toBe('history'); + + expect(component.getIconClass('REGISTRAR_UPDATE')).toBe( + 'history-log__icon--update' + ); + expect(component.getIconClass('REGISTRAR_SECURITY_UPDATE')).toBe( + 'history-log__icon--security' + ); + expect(component.getIconClass('OTHER')).toBe(''); + + expect(component.parseDescription('')).toEqual({ + main: 'N/A', + detail: null, + }); + expect( + component.parseDescription('Updated registrar|CLIENT_CERT_ADDED') + ).toEqual({ + main: 'Updated registrar', + detail: 'CLIENT CERT ADDED', + }); + expect(component.parseDescription('Simple event')).toEqual({ + main: 'Simple event', + detail: 'Simple event', + }); + }); +}); diff --git a/console-webapp/src/app/history/history.component.ts b/console-webapp/src/app/history/history.component.ts index 090556421c6..0a31e9f91bd 100644 --- a/console-webapp/src/app/history/history.component.ts +++ b/console-webapp/src/app/history/history.component.ts @@ -12,32 +12,53 @@ // See the License for the specific language governing permissions and // limitations under the License. -import { Component, effect, ChangeDetectionStrategy } from '@angular/core'; +import { + Component, + effect, + signal, + untracked, + ChangeDetectionStrategy, +} from '@angular/core'; +import { FormsModule } from '@angular/forms'; +import { MaterialModule } from '../material.module'; import { UserDataService } from '../shared/services/userData.service'; import { BackendService } from '../shared/services/backend.service'; import { RegistrarService } from '../registrar/registrar.service'; import { HistoryService } from './history.service'; +import { HistoryListComponent } from './historyList.component'; import { MatSnackBar } from '@angular/material/snack-bar'; import { GlobalLoader, GlobalLoaderService, } from '../shared/services/globalLoader.service'; import { HttpErrorResponse } from '@angular/common/http'; -import { RESTRICTED_ELEMENTS } from '../shared/directives/userLevelVisiblity.directive'; +import { SelectedRegistrarWrapper } from '../shared/components/selectedRegistrarWrapper/selectedRegistrarWrapper.component'; +import { ForceFocusDirective } from '../shared/directives/forceFocus.directive'; +import { + RESTRICTED_ELEMENTS, + UserLevelVisibility, +} from '../shared/directives/userLevelVisiblity.directive'; @Component({ selector: 'app-history', + imports: [ + FormsModule, + MaterialModule, + SelectedRegistrarWrapper, + ForceFocusDirective, + UserLevelVisibility, + HistoryListComponent, + ], templateUrl: './history.component.html', styleUrls: ['./history.component.scss'], providers: [HistoryService], - changeDetection: ChangeDetectionStrategy.Eager, - standalone: false, + changeDetection: ChangeDetectionStrategy.OnPush, }) export class HistoryComponent implements GlobalLoader { public static PATH = 'history'; consoleUserEmail: string = ''; - isLoading: boolean = false; + isLoading = signal(false); constructor( private backendService: BackendService, @@ -49,7 +70,9 @@ export class HistoryComponent implements GlobalLoader { ) { effect(() => { if (registrarService.registrarId()) { - this.loadHistory(); + untracked(() => { + this.loadHistory(); + }); } }); } @@ -64,17 +87,18 @@ export class HistoryComponent implements GlobalLoader { loadHistory() { this.globalLoader.startGlobalLoader(this); - this.isLoading = true; + this.isLoading.set(true); this.historyService .getHistoryLog(this.registrarService.registrarId(), this.consoleUserEmail) .subscribe({ error: (err: HttpErrorResponse) => { this._snackBar.open(err.error || err.message); - this.isLoading = false; + this.globalLoader.stopGlobalLoader(this); + this.isLoading.set(false); }, next: () => { this.globalLoader.stopGlobalLoader(this); - this.isLoading = false; + this.isLoading.set(false); }, }); } diff --git a/console-webapp/src/app/history/historyList.component.ts b/console-webapp/src/app/history/historyList.component.ts index bb1e2e97323..14309272d0c 100644 --- a/console-webapp/src/app/history/historyList.component.ts +++ b/console-webapp/src/app/history/historyList.component.ts @@ -12,15 +12,17 @@ // See the License for the specific language governing permissions and // limitations under the License. +import { CommonModule } from '@angular/common'; import { ChangeDetectionStrategy, Component, Input } from '@angular/core'; +import { MaterialModule } from '../material.module'; import { HistoryRecord } from './history.service'; @Component({ selector: 'app-history-list', + imports: [CommonModule, MaterialModule], templateUrl: './historyList.component.html', styleUrls: ['./historyList.component.scss'], changeDetection: ChangeDetectionStrategy.OnPush, - standalone: false, }) export class HistoryListComponent { @Input() historyRecords: HistoryRecord[] = []; diff --git a/console-webapp/src/app/home/home.component.html b/console-webapp/src/app/home/home.component.html index c41a41b1541..271366c8bfa 100644 --- a/console-webapp/src/app/home/home.component.html +++ b/console-webapp/src/app/home/home.component.html @@ -2,7 +2,7 @@ class="console-app__home" [class.console-app__home_tablet]="breakPointObserverService.isTabletView()" > -

Dashboard

+

Dashboard

diff --git a/console-webapp/src/app/home/home.component.spec.ts b/console-webapp/src/app/home/home.component.spec.ts index 46b7da7a0aa..cd2195f425d 100644 --- a/console-webapp/src/app/home/home.component.spec.ts +++ b/console-webapp/src/app/home/home.component.spec.ts @@ -16,19 +16,20 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'; import { HomeComponent } from './home.component'; import { MaterialModule } from '../material.module'; -import { AppModule } from '../app.module'; import { BackendService } from '../shared/services/backend.service'; import { provideHttpClient } from '@angular/common/http'; import { provideHttpClientTesting } from '@angular/common/http/testing'; +import { Router } from '@angular/router'; +import { RESTRICTED_ELEMENTS } from '../shared/directives/userLevelVisiblity.directive'; describe('HomeComponent', () => { let component: HomeComponent; let fixture: ComponentFixture; + let router: Router; beforeEach(async () => { await TestBed.configureTestingModule({ - imports: [MaterialModule, AppModule], - declarations: [HomeComponent], + imports: [HomeComponent, MaterialModule], providers: [ BackendService, provideHttpClient(), @@ -36,12 +37,39 @@ describe('HomeComponent', () => { ], }).compileComponents(); + router = TestBed.inject(Router); fixture = TestBed.createComponent(HomeComponent); component = fixture.componentInstance; fixture.detectChanges(); }); - it('should create', () => { - expect(component).toBeTruthy(); + it('should return registrar restricted element ID', () => { + expect(component.getElementIdForRegistrarsBlock()).toBe( + RESTRICTED_ELEMENTS.REGISTRAR_ELEMENT + ); + }); + + it('should navigate to registrars view with merged query params', () => { + const navigateSpy = vi.spyOn(router, 'navigate').mockResolvedValue(true); + component.viewRegistrars(); + expect(navigateSpy).toHaveBeenCalledWith(['registrars'], { + queryParamsHandling: 'merge', + }); + }); + + it('should navigate to security settings on updateEppPassword()', () => { + const navigateSpy = vi.spyOn(router, 'navigate').mockResolvedValue(true); + component.updateEppPassword(); + expect(navigateSpy).toHaveBeenCalledWith(['settings/security'], { + queryParamsHandling: 'merge', + }); + }); + + it('should navigate to domain list on viewDums()', () => { + const navigateSpy = vi.spyOn(router, 'navigate').mockResolvedValue(true); + component.viewDums(); + expect(navigateSpy).toHaveBeenCalledWith(['domain-list'], { + queryParamsHandling: 'merge', + }); }); }); diff --git a/console-webapp/src/app/home/home.component.ts b/console-webapp/src/app/home/home.component.ts index 89d8c381d1d..186d2f38306 100644 --- a/console-webapp/src/app/home/home.component.ts +++ b/console-webapp/src/app/home/home.component.ts @@ -14,19 +14,20 @@ import { Component, ChangeDetectionStrategy } from '@angular/core'; import { Router } from '@angular/router'; -import { DomainListComponent } from '../domains/domainList.component'; -import { RegistrarComponent } from '../registrar/registrarsTable.component'; -import SecurityComponent from '../settings/security/security.component'; -import { SettingsComponent } from '../settings/settings.component'; -import { RESTRICTED_ELEMENTS } from '../shared/directives/userLevelVisiblity.directive'; +import { PATHS } from '../app.routes'; +import { MaterialModule } from '../material.module'; +import { + RESTRICTED_ELEMENTS, + UserLevelVisibility, +} from '../shared/directives/userLevelVisiblity.directive'; import { BreakPointObserverService } from '../shared/services/breakPoint.service'; @Component({ selector: 'app-home', + imports: [MaterialModule, UserLevelVisibility], templateUrl: './home.component.html', styleUrls: ['./home.component.scss'], - changeDetection: ChangeDetectionStrategy.Eager, - standalone: false, + changeDetection: ChangeDetectionStrategy.OnPush, }) export class HomeComponent { constructor( @@ -37,18 +38,18 @@ export class HomeComponent { return RESTRICTED_ELEMENTS.REGISTRAR_ELEMENT; } viewRegistrars() { - this.router.navigate([RegistrarComponent.PATH], { + this.router.navigate([PATHS.RegistrarComponent], { queryParamsHandling: 'merge', }); } updateEppPassword() { this.router.navigate( - [SettingsComponent.PATH + '/' + SecurityComponent.PATH], + [PATHS.SettingsComponent + '/' + PATHS.SecurityComponent], { queryParamsHandling: 'merge' } ); } viewDums() { - this.router.navigate([DomainListComponent.PATH], { + this.router.navigate([PATHS.DomainListComponent], { queryParamsHandling: 'merge', }); } diff --git a/console-webapp/src/app/lock/registryLockVerify.component.html b/console-webapp/src/app/lock/registryLockVerify.component.html index dcad70b0eda..8b54c6e81b8 100644 --- a/console-webapp/src/app/lock/registryLockVerify.component.html +++ b/console-webapp/src/app/lock/registryLockVerify.component.html @@ -1,9 +1,9 @@ -@if (isLoading) { +@if (isLoading()) {
} @else if (domainName) { -

Success!

+

Success!

The domain {{ domainName }} has been successfully {{ action }}ed. @@ -18,7 +18,7 @@

Success!

>
} @else { -

Failure

+

Failure

An error occurred: {{ errorMessage }}.

Please double-check the diff --git a/console-webapp/src/app/lock/registryLockVerify.component.spec.ts b/console-webapp/src/app/lock/registryLockVerify.component.spec.ts new file mode 100644 index 00000000000..5f8918504b5 --- /dev/null +++ b/console-webapp/src/app/lock/registryLockVerify.component.spec.ts @@ -0,0 +1,93 @@ +// Copyright 2026 The Nomulus Authors. All Rights Reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { TestBed } from '@angular/core/testing'; +import { HttpErrorResponse, provideHttpClient } from '@angular/common/http'; +import { provideHttpClientTesting } from '@angular/common/http/testing'; +import { ActivatedRoute, convertToParamMap } from '@angular/router'; +import { signal } from '@angular/core'; +import { of, throwError } from 'rxjs'; +import { MaterialModule } from '../material.module'; +import { BackendService } from '../shared/services/backend.service'; +import { RegistrarService } from '../registrar/registrar.service'; +import { RegistryLockVerifyComponent } from './registryLockVerify.component'; + +describe('RegistryLockVerifyComponent & RegistryLockVerifyService', () => { + let backendService: BackendService; + let registrarIdSignal: ReturnType>; + + beforeEach(async () => { + registrarIdSignal = signal(''); + + await TestBed.configureTestingModule({ + imports: [RegistryLockVerifyComponent, MaterialModule], + providers: [ + BackendService, + { + provide: RegistrarService, + useValue: { registrarId: registrarIdSignal }, + }, + { + provide: ActivatedRoute, + useValue: { + queryParamMap: of( + convertToParamMap({ lockVerificationCode: 'verify-code-123' }) + ), + }, + }, + provideHttpClient(), + provideHttpClientTesting(), + ], + }).compileComponents(); + + backendService = TestBed.inject(BackendService); + }); + + it('should verify lock request from query param and update domainName, action, and registrarId', () => { + const verifySpy = vi + .spyOn(backendService, 'verifyRegistryLockRequest') + .mockReturnValue( + of({ + action: 'LOCK', + domainName: 'locked.tld', + registrarId: 'reg-verified', + }) + ); + + const fixture = TestBed.createComponent(RegistryLockVerifyComponent); + const component = fixture.componentInstance; + fixture.detectChanges(); + + expect(verifySpy).toHaveBeenCalledWith('verify-code-123'); + expect(component.isLoading()).toBe(false); + expect(component.domainName).toBe('locked.tld'); + expect(component.action).toBe('LOCK'); + expect(registrarIdSignal()).toBe('reg-verified'); + }); + + it('should display errorMessage when verification fails', () => { + vi.spyOn(backendService, 'verifyRegistryLockRequest').mockReturnValue( + throwError( + () => new HttpErrorResponse({ error: 'Expired verification code' }) + ) + ); + + const fixture = TestBed.createComponent(RegistryLockVerifyComponent); + const component = fixture.componentInstance; + fixture.detectChanges(); + + expect(component.isLoading()).toBe(false); + expect(component.errorMessage).toBe('Expired verification code'); + }); +}); diff --git a/console-webapp/src/app/lock/registryLockVerify.component.ts b/console-webapp/src/app/lock/registryLockVerify.component.ts index 51ddc95fd64..8af3dddf2d5 100644 --- a/console-webapp/src/app/lock/registryLockVerify.component.ts +++ b/console-webapp/src/app/lock/registryLockVerify.component.ts @@ -12,28 +12,29 @@ // See the License for the specific language governing permissions and // limitations under the License. -import { Component, ChangeDetectionStrategy } from '@angular/core'; +import { Component, signal, ChangeDetectionStrategy } from '@angular/core'; import { RegistrarService } from '../registrar/registrar.service'; -import { ActivatedRoute, ParamMap } from '@angular/router'; +import { ActivatedRoute, ParamMap, RouterModule } from '@angular/router'; import { RegistryLockVerifyService } from './registryLockVerify.service'; import { HttpErrorResponse } from '@angular/common/http'; import { take } from 'rxjs'; -import { DomainListComponent } from '../domains/domainList.component'; +import { PATHS } from '../app.routes'; +import { MaterialModule } from '../material.module'; @Component({ selector: 'app-registry-lock-verify', + imports: [MaterialModule, RouterModule], templateUrl: './registryLockVerify.component.html', styleUrls: ['./registryLockVerify.component.scss'], providers: [RegistryLockVerifyService], - changeDetection: ChangeDetectionStrategy.Eager, - standalone: false, + changeDetection: ChangeDetectionStrategy.OnPush, }) export class RegistryLockVerifyComponent { public static PATH = 'registry-lock-verify'; - readonly DOMAIN_LIST_COMPONENT_PATH = `/${DomainListComponent.PATH}`; + readonly DOMAIN_LIST_COMPONENT_PATH = `/${PATHS.DomainListComponent}`; - isLoading = true; + isLoading = signal(true); domainName?: string; action?: string; errorMessage?: string; @@ -50,8 +51,8 @@ export class RegistryLockVerifyComponent { .verifyRequest(params.get('lockVerificationCode') || '') .subscribe({ error: (err: HttpErrorResponse) => { - this.isLoading = false; this.errorMessage = err.error; + this.isLoading.set(false); }, next: (verificationResponse) => { this.domainName = verificationResponse.domainName; @@ -59,7 +60,7 @@ export class RegistryLockVerifyComponent { this.registrarService.registrarId.set( verificationResponse.registrarId ); - this.isLoading = false; + this.isLoading.set(false); }, }); }); diff --git a/console-webapp/src/app/navigation/navigation.component.html b/console-webapp/src/app/navigation/navigation.component.html index e0f0e8729e0..cde4ee65aad 100644 --- a/console-webapp/src/app/navigation/navigation.component.html +++ b/console-webapp/src/app/navigation/navigation.component.html @@ -9,7 +9,7 @@ tabindex="0" (click)="onClick(node)" (keyup.enter)="onClick(node)" - [class.active]="router.url.includes(node.path)" + [class.active]="(currentUrl() || router.url).includes(node.path)" [elementId]="getElementId(node)" > @if (node.iconName) { @@ -25,7 +25,10 @@ tabindex="0" (keyup.enter)="onClick(node)" > -
+
- @if(!inEdit && !registrarNotFound) { @if (oteButtonVisible) { + @if(!inEdit && !registrarNotFound()) { @if (oteButtonVisible) {

}

- @if(registrarNotFound) { + @if(registrarNotFound()) {

Registrar not found

} @else {

{{ registrarInEdit.registrarId }}

diff --git a/console-webapp/src/app/registrar/registrarDetails.component.spec.ts b/console-webapp/src/app/registrar/registrarDetails.component.spec.ts new file mode 100644 index 00000000000..4e79f4c40d0 --- /dev/null +++ b/console-webapp/src/app/registrar/registrarDetails.component.spec.ts @@ -0,0 +1,214 @@ +// Copyright 2026 The Nomulus Authors. All Rights Reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { HttpErrorResponse, provideHttpClient } from '@angular/common/http'; +import { provideHttpClientTesting } from '@angular/common/http/testing'; +import { NoopAnimationsModule } from '@angular/platform-browser/animations'; +import { ActivatedRoute, convertToParamMap, Router } from '@angular/router'; +import { MatChipInputEvent } from '@angular/material/chips'; +import { MatSnackBar } from '@angular/material/snack-bar'; +import { signal } from '@angular/core'; +import { BehaviorSubject, of, throwError } from 'rxjs'; +import { MaterialModule } from '../material.module'; +import { BackendService } from '../shared/services/backend.service'; +import { Registrar, RegistrarService } from './registrar.service'; +import { RegistrarDetailsComponent } from './registrarDetails.component'; +import { NewRegistrarComponent } from './newRegistrar.component'; + +describe('RegistrarDetailsComponent & NewRegistrarComponent', () => { + let paramMapSubject: BehaviorSubject; + let registrarsSignal: ReturnType>; + let inNewRegistrarModeSignal: ReturnType>; + let mockRegistrarService: any; + let router: Router; + + const sampleRegistrar: Registrar = { + registrarId: 'reg-1', + registrarName: 'Registrar One', + allowedTlds: ['app'], + icannReferralEmail: 'icann@one.example', + localizedAddress: { city: 'NYC' }, + url: 'https://one.example', + }; + + beforeEach(async () => { + paramMapSubject = new BehaviorSubject(convertToParamMap({ id: 'reg-1' })); + registrarsSignal = signal([sampleRegistrar]); + inNewRegistrarModeSignal = signal(true); + + mockRegistrarService = { + registrars: registrarsSignal, + registrarsLoaded: Promise.resolve(), + inNewRegistrarMode: inNewRegistrarModeSignal, + updateRegistrar: vi.fn().mockReturnValue(of(sampleRegistrar)), + createRegistrar: vi.fn().mockReturnValue(of([sampleRegistrar])), + }; + + await TestBed.configureTestingModule({ + imports: [ + RegistrarDetailsComponent, + NewRegistrarComponent, + MaterialModule, + NoopAnimationsModule, + ], + providers: [ + BackendService, + { provide: RegistrarService, useValue: mockRegistrarService }, + { + provide: ActivatedRoute, + useValue: { paramMap: paramMapSubject.asObservable() }, + }, + provideHttpClient(), + provideHttpClientTesting(), + ], + }).compileComponents(); + + router = TestBed.inject(Router); + }); + + describe('RegistrarDetailsComponent', () => { + let fixture: ComponentFixture; + let component: RegistrarDetailsComponent; + let snackBarOpenSpy: ReturnType; + + beforeEach(async () => { + fixture = TestBed.createComponent(RegistrarDetailsComponent); + component = fixture.componentInstance; + snackBarOpenSpy = vi + .spyOn(fixture.debugElement.injector.get(MatSnackBar), 'open') + .mockReturnValue({} as any); + fixture.detectChanges(); + await fixture.whenStable(); + }); + + it('should load registrar from route param and handle missing registrar ID', async () => { + expect(component.registrarNotFound()).toBe(false); + expect(component.registrarInEdit.registrarId).toBe('reg-1'); + + paramMapSubject.next(convertToParamMap({ id: 'missing-id' })); + expect(component.registrarNotFound()).toBe(true); + expect(snackBarOpenSpy).toHaveBeenCalledWith( + 'Registrar with id missing-id is not available' + ); + }); + + it('should add lowercased deduplicated TLDs and remove TLDs', () => { + component.addTLD({ value: 'DEV' } as MatChipInputEvent); + expect(component.registrarInEdit.allowedTlds).toEqual(['app', 'dev']); + + component.addTLD({ value: 'app' } as MatChipInputEvent); + expect(component.registrarInEdit.allowedTlds).toEqual(['dev', 'app']); + + component.removeTLD('dev'); + expect(component.registrarInEdit.allowedTlds).toEqual(['app']); + }); + + it('should navigate to OTE status and save updated registrar', () => { + const navigateSpy = vi.spyOn(router, 'navigate').mockResolvedValue(true); + + component.checkOteStatus(); + expect(navigateSpy).toHaveBeenCalledWith(['ote-status', 'reg-1'], { + queryParamsHandling: 'merge', + }); + + component.inEdit = true; + component.saveAndClose(); + expect(mockRegistrarService.updateRegistrar).toHaveBeenCalledWith( + component.registrarInEdit + ); + expect(component.inEdit).toBe(false); + expect(navigateSpy).toHaveBeenCalledWith(['registrars'], { + queryParamsHandling: 'merge', + }); + + mockRegistrarService.updateRegistrar.mockReturnValueOnce( + throwError(() => new HttpErrorResponse({ error: 'Update failed' })) + ); + component.saveAndClose(); + expect(snackBarOpenSpy).toHaveBeenCalledWith('Update failed'); + }); + }); + + describe('NewRegistrarComponent', () => { + let fixture: ComponentFixture; + let component: NewRegistrarComponent; + let snackBarOpenSpy: ReturnType; + + beforeEach(() => { + fixture = TestBed.createComponent(NewRegistrarComponent); + component = fixture.componentInstance; + snackBarOpenSpy = vi + .spyOn(fixture.debugElement.injector.get(MatSnackBar), 'open') + .mockReturnValue({} as any); + fixture.detectChanges(); + }); + + it('should parse multiline currency=billingCode entries into billingAccountMap', () => { + component.onBillingAccountMapChange( + ' USD = acc-1 \n EUR = acc-2 \ninvalid' + ); + expect((component as any).newRegistrar.billingAccountMap).toEqual({ + USD: 'acc-1', + EUR: 'acc-2', + }); + }); + + it('should validate form, populate street lines, and create registrar on save', () => { + (component as any).localizedAddressStreet = { + line1: '1600 Amphitheatre Pkwy', + line2: '', + line3: 'Bldg 43', + }; + component.form = { + nativeElement: { + checkValidity: vi.fn().mockReturnValue(true), + reportValidity: vi.fn(), + }, + }; + + const submitEvent = { preventDefault: vi.fn() } as unknown as SubmitEvent; + component.save(submitEvent); + + expect(submitEvent.preventDefault).toHaveBeenCalled(); + expect((component as any).newRegistrar.localizedAddress.street).toEqual([ + '1600 Amphitheatre Pkwy', + 'Bldg 43', + ]); + expect(mockRegistrarService.createRegistrar).toHaveBeenCalled(); + expect(inNewRegistrarModeSignal()).toBe(false); + }); + + it('should call reportValidity when form is invalid and show snackbar on backend error', () => { + const reportValiditySpy = vi.fn(); + component.form = { + nativeElement: { + checkValidity: vi.fn().mockReturnValue(false), + reportValidity: reportValiditySpy, + }, + }; + + const submitEvent = { preventDefault: vi.fn() } as unknown as SubmitEvent; + component.save(submitEvent); + expect(reportValiditySpy).toHaveBeenCalled(); + + component.form.nativeElement.checkValidity.mockReturnValue(true); + mockRegistrarService.createRegistrar.mockReturnValueOnce( + throwError(() => new HttpErrorResponse({ error: 'Duplicate ID' })) + ); + component.save(submitEvent); + expect(snackBarOpenSpy).toHaveBeenCalledWith('Duplicate ID'); + }); + }); +}); diff --git a/console-webapp/src/app/registrar/registrarDetails.component.ts b/console-webapp/src/app/registrar/registrarDetails.component.ts index d5a5f42d784..4569e89813a 100644 --- a/console-webapp/src/app/registrar/registrarDetails.component.ts +++ b/console-webapp/src/app/registrar/registrarDetails.component.ts @@ -13,29 +13,46 @@ // limitations under the License. import { HttpErrorResponse } from '@angular/common/http'; -import { Component, OnInit, ChangeDetectionStrategy } from '@angular/core'; +import { + Component, + OnInit, + signal, + ChangeDetectionStrategy, +} from '@angular/core'; +import { FormsModule } from '@angular/forms'; import { MatChipInputEvent } from '@angular/material/chips'; import { MatSnackBar } from '@angular/material/snack-bar'; import { ActivatedRoute, ParamMap, Router } from '@angular/router'; import { Subscription } from 'rxjs'; -import { RESTRICTED_ELEMENTS } from '../shared/directives/userLevelVisiblity.directive'; +import { MaterialModule } from '../material.module'; +import { LocationBackDirective } from '../shared/directives/locationBack.directive'; +import { + RESTRICTED_ELEMENTS, + UserLevelVisibility, +} from '../shared/directives/userLevelVisiblity.directive'; import { Registrar, RegistrarService } from './registrar.service'; -import { RegistrarComponent, columns } from './registrarsTable.component'; +import { columns } from './registrarsTable.component'; +import { PATHS } from '../app.routes'; import { environment } from '../../environments/environment'; @Component({ selector: 'app-registrar-details', + imports: [ + FormsModule, + MaterialModule, + LocationBackDirective, + UserLevelVisibility, + ], templateUrl: './registrarDetails.component.html', styleUrls: ['./registrarDetails.component.scss'], - changeDetection: ChangeDetectionStrategy.Eager, - standalone: false, + changeDetection: ChangeDetectionStrategy.OnPush, }) export class RegistrarDetailsComponent implements OnInit { public static PATH = 'registrars/:id'; inEdit: boolean = false; oteButtonVisible = environment.sandbox; registrarInEdit!: Registrar; - registrarNotFound: boolean = true; + registrarNotFound = signal(true); columns = columns.filter((c) => !c.hiddenOnDetailsCard); private subscription!: Subscription; @@ -58,9 +75,9 @@ export class RegistrarDetailsComponent implements OnInit { this._snackBar.open( `Registrar with id ${params.get('id')} is not available` ); - this.registrarNotFound = true; + this.registrarNotFound.set(true); } else { - this.registrarNotFound = false; + this.registrarNotFound.set(false); } }); }); @@ -94,7 +111,7 @@ export class RegistrarDetailsComponent implements OnInit { saveAndClose() { this.registrarService.updateRegistrar(this.registrarInEdit).subscribe({ complete: () => { - this.router.navigate([RegistrarComponent.PATH], { + this.router.navigate([PATHS.RegistrarComponent], { queryParamsHandling: 'merge', }); }, diff --git a/console-webapp/src/app/registrar/registrarSelector.component.html b/console-webapp/src/app/registrar/registrarSelector.component.html index 4f3b2276077..f8e60e41363 100644 --- a/console-webapp/src/app/registrar/registrarSelector.component.html +++ b/console-webapp/src/app/registrar/registrarSelector.component.html @@ -18,7 +18,7 @@ #auto="matAutocomplete" (optionSelected)="onSelect($event.option.value)" > - @for (registrarId of filteredOptions; track registrarId) { + @for (registrarId of filteredOptions(); track registrarId) { { let component: RegistrarSelectorComponent; let fixture: ComponentFixture; + let mockRegistrarService: { + registrarId: ReturnType>; + registrars: ReturnType[]>>; + updateSelectedRegistrar: ReturnType; + }; beforeEach(async () => { + mockRegistrarService = { + registrarId: signal('alpha'), + registrars: signal([ + { registrarId: 'charlie' }, + { registrarId: 'alpha' }, + { registrarId: 'bravo' }, + ]), + updateSelectedRegistrar: vi.fn(), + }; + await TestBed.configureTestingModule({ - declarations: [RegistrarSelectorComponent], - imports: [MaterialModule, BrowserAnimationsModule, FormsModule], + imports: [ + RegistrarSelectorComponent, + MaterialModule, + BrowserAnimationsModule, + FormsModule, + ], providers: [ BackendService, + { provide: RegistrarService, useValue: mockRegistrarService }, provideHttpClient(), provideHttpClientTesting(), ], @@ -42,7 +64,28 @@ describe('RegistrarSelectorComponent', () => { fixture.detectChanges(); }); - it('should create', () => { - expect(component).toBeTruthy(); + it('should compute sorted registrar IDs and filter by input', () => { + expect(component.allRegistrarIds()).toEqual(['alpha', 'bravo', 'charlie']); + expect(component.filteredOptions()).toEqual(['alpha']); + + component.registrarInput.set('R'); + TestBed.flushEffects(); + expect(component.filteredOptions()).toEqual(['bravo', 'charlie']); + }); + + it('should reset filteredOptions to all sorted registrar IDs on focus', () => { + component.registrarInput.set('charlie'); + TestBed.flushEffects(); + expect(component.filteredOptions()).toEqual(['charlie']); + + component.onFocus(); + expect(component.filteredOptions()).toEqual(['alpha', 'bravo', 'charlie']); + }); + + it('should delegate selection to RegistrarService.updateSelectedRegistrar', () => { + component.onSelect('bravo'); + expect(mockRegistrarService.updateSelectedRegistrar).toHaveBeenCalledWith( + 'bravo' + ); }); }); diff --git a/console-webapp/src/app/registrar/registrarSelector.component.ts b/console-webapp/src/app/registrar/registrarSelector.component.ts index b2882eeeb9d..95916fa8f0f 100644 --- a/console-webapp/src/app/registrar/registrarSelector.component.ts +++ b/console-webapp/src/app/registrar/registrarSelector.component.ts @@ -15,41 +15,41 @@ import { Component, computed, - effect, + linkedSignal, signal, ChangeDetectionStrategy, } from '@angular/core'; +import { FormsModule } from '@angular/forms'; +import { MaterialModule } from '../material.module'; import { RegistrarService } from './registrar.service'; @Component({ selector: 'app-registrar-selector', + imports: [FormsModule, MaterialModule], templateUrl: './registrarSelector.component.html', styleUrls: ['./registrarSelector.component.scss'], - changeDetection: ChangeDetectionStrategy.Eager, - standalone: false, + changeDetection: ChangeDetectionStrategy.OnPush, }) export class RegistrarSelectorComponent { registrarInput = signal(this.registrarService.registrarId()); - filteredOptions?: string[]; allRegistrarIds = computed(() => this.registrarService .registrars() .map((r) => r.registrarId) .sort() ); + filteredOptions = linkedSignal(() => { + const filterValue = this.registrarInput().toLowerCase(); + return this.allRegistrarIds().filter((option) => + option.toLowerCase().includes(filterValue) + ); + }); - constructor(protected registrarService: RegistrarService) { - effect(() => { - const filterValue = this.registrarInput().toLowerCase(); - this.filteredOptions = this.allRegistrarIds().filter((option) => - option.toLowerCase().includes(filterValue) - ); - }); - } + constructor(protected registrarService: RegistrarService) {} onFocus() { // We reset the list of options after selection, so that user doesn't have to clear it out - this.filteredOptions = this.allRegistrarIds(); + this.filteredOptions.set(this.allRegistrarIds()); } onSelect(registrarId: string) { diff --git a/console-webapp/src/app/registrar/registrarsTable.component.html b/console-webapp/src/app/registrar/registrarsTable.component.html index 5ec5f42da0e..41b33b9c0ae 100644 --- a/console-webapp/src/app/registrar/registrarsTable.component.html +++ b/console-webapp/src/app/registrar/registrarsTable.component.html @@ -3,7 +3,7 @@ } @else {
-

Registrars

+

Registrars

@if (oteButtonVisible) {
- @if(!isEditing && !contactService.isContactNewView) { + @if(!isEditing() && !contactService.isContactNewView()) { }
- @if(isEditing || contactService.isContactNewView) { + @if(isEditing() || contactService.isContactNewView()) {

Contact Details

@@ -37,7 +40,8 @@

Contact Details

@@ -48,9 +52,9 @@

Contact Details

matInput [email]="true" [required]="true" - [(ngModel)]="contactService.contactInEdit.emailAddress" + [(ngModel)]="contact.emailAddress" [ngModelOptions]="{ standalone: true }" - [disabled]="emailAddressIsDisabled()" + [disabled]="emailAddressIsDisabled() || isUpdating()" [matTooltip]=" emailAddressIsDisabled() ? 'Reach out to registry customer support to update email address' @@ -62,7 +66,8 @@

Contact Details

Phone: @@ -71,7 +76,8 @@

Contact Details

Fax: @@ -88,7 +94,7 @@

Contact Type

{{ contactType.value }} @@ -99,21 +105,24 @@

Contact Type

RDAP Preferences

Show in Registrar RDAP record as admin contact
Show in Registrar RDAP record as technical contact
Show Phone and Email in Domain RDAP Record as registrar abuse contact (per CL&D requirements)RDAP Preferences

mat-flat-button color="primary" type="submit" + [disabled]="isUpdating()" aria-label="Save contact updates" > Save } @else { -

{{ contactService.contactInEdit.name }}

+

{{ contact.name }}

@@ -142,28 +152,24 @@

Contact details

Email {{ - contactService.contactInEdit.emailAddress + contact.emailAddress }} Phone - {{ - contactService.contactInEdit.phoneNumber - }} + {{ contact.phoneNumber }} Fax - {{ - contactService.contactInEdit.faxNumber - }} + {{ contact.faxNumber }} Type: {{ - contactService.contactInEdit.userFriendlyTypes.join(", ") + contact.userFriendlyTypes.join(", ") }}
@@ -175,22 +181,22 @@

Contact details

RDAP Preferences

- @if(contactService.contactInEdit.visibleInRdapAsAdmin) { + @if(contact.visibleInRdapAsAdmin) { Show in Registrar RDAP record as admin contact - } @if(contactService.contactInEdit.visibleInRdapAsTech) { + } @if(contact.visibleInRdapAsTech) { - @if (contactService.contactInEdit.visibleInRdapAsTech) { + @if (contact.visibleInRdapAsTech) { Show in Registrar RDAP record as technical contact - } } @if(contactService.contactInEdit.visibleInDomainRdapAsAbuse) { + } } @if(contact.visibleInDomainRdapAsAbuse) { + structuredClone(this.contactService.contactInEdit()) + ); + isEditing = signal(false); + isUpdating = signal(false); constructor( protected contactService: ContactService, - private _snackBar: MatSnackBar + private _snackBar: MatSnackBar, + protected globalLoader: GlobalLoaderService ) {} + loadingTimeout() { + this._snackBar.open('Timeout updating contact'); + } + deleteContact() { - if ( - confirm( - `Please confirm deletion of contact ${this.contactService.contactInEdit.name}` - ) - ) { + const contact = this.contactInEdit(); + if (!contact) return; + if (confirm(`Please confirm deletion of contact ${contact.name}`)) { + this.isUpdating.set(true); + this.globalLoader.startGlobalLoader(this); this.contactService - .deleteContact(this.contactService.contactInEdit) + .deleteContact(contact) .pipe(first()) .subscribe({ error: (err: HttpErrorResponse) => { - this._snackBar.open(err.error); + this._snackBar.open(err.error || err.message); + this.isUpdating.set(false); + this.globalLoader.stopGlobalLoader(this); }, complete: () => { + this.isUpdating.set(false); + this.globalLoader.stopGlobalLoader(this); this.goBack(); }, }); @@ -60,29 +86,42 @@ export class ContactDetailsComponent { } goBack() { - if (this.isEditing) { - this.isEditing = false; + if (this.isEditing()) { + this.contactInEdit.set( + structuredClone(this.contactService.contactInEdit()) + ); + this.isEditing.set(false); } else { - this.contactService.isContactNewView = false; - this.contactService.isContactDetailsView = false; + this.contactService.isContactNewView.set(false); + this.contactService.isContactDetailsView.set(false); } } save(e: SubmitEvent) { e.preventDefault(); - if ((this.contactService.contactInEdit.types || []).length === 0) { + const contact = this.contactInEdit(); + if (!contact || (contact.types || []).length === 0) { this._snackBar.open('Required to select contact type'); return; } - const request = this.contactService.isContactNewView - ? this.contactService.addContact(this.contactService.contactInEdit) - : this.contactService.updateContact(this.contactService.contactInEdit); + this.isUpdating.set(true); + this.globalLoader.startGlobalLoader(this); + const request = this.contactService.isContactNewView() + ? this.contactService.addContact(contact) + : this.contactService.updateContact(contact); request.subscribe({ complete: () => { + this.isUpdating.set(false); + this.globalLoader.stopGlobalLoader(this); + this.contactService.setEditableContact( + contactTypeToViewReadyContact(contact) + ); this.goBack(); }, error: (err: HttpErrorResponse) => { - this._snackBar.open(err.error); + this._snackBar.open(err.error || err.message); + this.isUpdating.set(false); + this.globalLoader.stopGlobalLoader(this); }, }); } @@ -92,33 +131,28 @@ export class ContactDetailsComponent { } checkboxIsChecked(type: string) { - return this.contactService.contactInEdit.types.includes( - type as contactType - ); + return !!this.contactInEdit()?.types.includes(type as contactType); } checkboxIsDisabled(type: string) { if (type === 'ADMIN') { return true; } - return ( - this.contactService.contactInEdit.types.length === 1 && - this.contactService.contactInEdit.types[0] === (type as contactType) - ); + const types = this.contactInEdit()?.types || []; + return types.length === 1 && types[0] === (type as contactType); } checkboxOnChange(event: MatCheckboxChange, type: string) { + const contact = this.contactInEdit(); + if (!contact) return; if (event.checked) { - this.contactService.contactInEdit.types.push(type as contactType); + contact.types.push(type as contactType); } else { - this.contactService.contactInEdit.types = - this.contactService.contactInEdit.types.filter( - (t) => t != (type as contactType) - ); + contact.types = contact.types.filter((t) => t != (type as contactType)); } } emailAddressIsDisabled() { - return this.contactService.contactInEdit.types.includes('ADMIN'); + return !!this.contactInEdit()?.types.includes('ADMIN'); } } diff --git a/console-webapp/src/app/settings/rdap/rdap.component.html b/console-webapp/src/app/settings/rdap/rdap.component.html index 4625f7a0e0e..4a6eb69088e 100644 --- a/console-webapp/src/app/settings/rdap/rdap.component.html +++ b/console-webapp/src/app/settings/rdap/rdap.component.html @@ -1,4 +1,4 @@ -@if(rdapService.editing) { +@if(rdapService.editing()) { } @else {
@@ -12,7 +12,7 @@ mat-flat-button color="primary" aria-label="Edit RDAP record" - (click)="rdapService.editing = true" + (click)="rdapService.editing.set(true)" > edit Edit diff --git a/console-webapp/src/app/settings/rdap/rdap.component.spec.ts b/console-webapp/src/app/settings/rdap/rdap.component.spec.ts index 0ee999ce2cb..a8385ca9d92 100644 --- a/console-webapp/src/app/settings/rdap/rdap.component.spec.ts +++ b/console-webapp/src/app/settings/rdap/rdap.component.spec.ts @@ -14,43 +14,151 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { provideHttpClient } from '@angular/common/http'; +import { HttpErrorResponse, provideHttpClient } from '@angular/common/http'; import { provideHttpClientTesting } from '@angular/common/http/testing'; import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; import { MaterialModule } from 'src/app/material.module'; -import { RegistrarService } from 'src/app/registrar/registrar.service'; +import { + Registrar, + RegistrarService, +} from 'src/app/registrar/registrar.service'; import { BackendService } from 'src/app/shared/services/backend.service'; -import RdapComponent from './rdap.component'; +import { GlobalLoaderService } from 'src/app/shared/services/globalLoader.service'; +import { UserDataService } from 'src/app/shared/services/userData.service'; +import { RdapComponent } from './rdap.component'; +import { RdapEditComponent } from './rdapEdit.component'; +import { RdapService } from './rdap.service'; +import { MatSnackBar } from '@angular/material/snack-bar'; +import { signal } from '@angular/core'; +import { of, Subject, throwError } from 'rxjs'; -describe('RdapComponent', () => { +describe('RdapComponent & RdapEditComponent & RdapService', () => { let component: RdapComponent; let fixture: ComponentFixture; + let rdapService: RdapService; + let backendService: BackendService; + let registrarSignal: ReturnType< + typeof signal | undefined> + >; + let loadRegistrarsSpy: ReturnType; + + const sampleRegistrar: Registrar = { + registrarId: 'reg-1', + registrarName: 'Example Registrar', + icannReferralEmail: 'referral@example.com', + url: 'https://example.com', + localizedAddress: { + street: ['100 Main St', 'Suite 200'], + city: 'New York', + state: 'NY', + countryCode: 'US', + zip: '10001', + }, + }; beforeEach(async () => { + registrarSignal = signal | undefined>(sampleRegistrar); + loadRegistrarsSpy = vi.fn().mockReturnValue(of([sampleRegistrar])); + await TestBed.configureTestingModule({ - declarations: [RdapComponent], - imports: [MaterialModule, BrowserAnimationsModule], + imports: [ + RdapComponent, + RdapEditComponent, + MaterialModule, + BrowserAnimationsModule, + ], providers: [ BackendService, + RdapService, { provide: RegistrarService, useValue: { - registrar: function () { - return {}; - }, + registrar: registrarSignal, + registrarId: signal('reg-1'), + registrars: signal([sampleRegistrar]), + loadRegistrars: loadRegistrarsSpy, }, }, + { + provide: UserDataService, + useValue: { userData: signal({ isAdmin: true }) }, + }, provideHttpClient(), provideHttpClientTesting(), ], }).compileComponents(); + rdapService = TestBed.inject(RdapService); + backendService = TestBed.inject(BackendService); fixture = TestBed.createComponent(RdapComponent); component = fixture.componentInstance; fixture.detectChanges(); }); - it('should create', () => { - expect(component).toBeTruthy(); + it('should format full and partial localized addresses in formattedAddress signal', () => { + expect(component.formattedAddress()).toBe( + '100 Main St Suite 200 New York NY US 10001' + ); + + registrarSignal.set({ + localizedAddress: { + city: 'London', + countryCode: 'GB', + }, + }); + expect(component.formattedAddress()).toBe('London GB '); + + registrarSignal.set(undefined); + expect(component.formattedAddress()).toBe(''); + }); + + it('should post RDAP registrar fields and reload registrars via RdapService', () => { + const postSpy = vi + .spyOn(backendService, 'postRdapRegistrarFields') + .mockReturnValue(of(sampleRegistrar)); + + rdapService.saveChanges(sampleRegistrar).subscribe(); + + expect(postSpy).toHaveBeenCalledWith(sampleRegistrar); + expect(loadRegistrarsSpy).toHaveBeenCalled(); + }); + + it('should clone active registrar in RdapEditComponent and save or handle errors', () => { + const editFixture = TestBed.createComponent(RdapEditComponent); + const snackBarSpy = vi + .spyOn(editFixture.debugElement.injector.get(MatSnackBar), 'open') + .mockReturnValue({} as any); + const editComponent = editFixture.componentInstance; + editFixture.detectChanges(); + + expect(editComponent.registrarInEdit()).toEqual(sampleRegistrar); + expect(editComponent.registrarInEdit()).not.toBe(sampleRegistrar); + + rdapService.editing.set(true); + const globalLoader = TestBed.inject(GlobalLoaderService); + const pending$ = new Subject(); + const saveSpy = vi + .spyOn(rdapService, 'saveChanges') + .mockReturnValueOnce(pending$.asObservable()) + .mockReturnValueOnce( + throwError(() => new HttpErrorResponse({ error: 'Invalid RDAP URL' })) + ); + + const submitEvent = { preventDefault: vi.fn() } as unknown as SubmitEvent; + editComponent.save(submitEvent); + expect(saveSpy).toHaveBeenCalledWith(sampleRegistrar); + expect(editComponent.isUpdating()).toBe(true); + expect(globalLoader.isLoading()).toBe(true); + + pending$.next([sampleRegistrar]); + pending$.complete(); + expect(editComponent.isUpdating()).toBe(false); + expect(globalLoader.isLoading()).toBe(false); + expect(rdapService.editing()).toBe(false); + + editComponent.save(submitEvent); + expect(editComponent.isUpdating()).toBe(false); + expect(globalLoader.isLoading()).toBe(false); + expect(snackBarSpy).toHaveBeenCalledWith('Invalid RDAP URL'); }); }); diff --git a/console-webapp/src/app/settings/rdap/rdap.component.ts b/console-webapp/src/app/settings/rdap/rdap.component.ts index 0599920bc40..2f8a400f746 100644 --- a/console-webapp/src/app/settings/rdap/rdap.component.ts +++ b/console-webapp/src/app/settings/rdap/rdap.component.ts @@ -13,17 +13,19 @@ // limitations under the License. import { Component, computed, ChangeDetectionStrategy } from '@angular/core'; +import { MaterialModule } from 'src/app/material.module'; import { RegistrarService } from 'src/app/registrar/registrar.service'; import { RdapService } from './rdap.service'; +import { RdapEditComponent } from './rdapEdit.component'; @Component({ selector: 'app-rdap', + imports: [MaterialModule, RdapEditComponent], templateUrl: './rdap.component.html', styleUrls: ['./rdap.component.scss'], - changeDetection: ChangeDetectionStrategy.Eager, - standalone: false, + changeDetection: ChangeDetectionStrategy.OnPush, }) -export default class RdapComponent { +export class RdapComponent { public static PATH = 'rdap'; formattedAddress = computed(() => { let result = ''; diff --git a/console-webapp/src/app/settings/rdap/rdap.service.ts b/console-webapp/src/app/settings/rdap/rdap.service.ts index 70c2c86123d..efca4201fd4 100644 --- a/console-webapp/src/app/settings/rdap/rdap.service.ts +++ b/console-webapp/src/app/settings/rdap/rdap.service.ts @@ -12,7 +12,7 @@ // See the License for the specific language governing permissions and // limitations under the License. -import { Injectable } from '@angular/core'; +import { Injectable, signal } from '@angular/core'; import { switchMap } from 'rxjs'; import { RegistrarService, @@ -24,7 +24,7 @@ import { BackendService } from 'src/app/shared/services/backend.service'; providedIn: 'root', }) export class RdapService { - editing: boolean = false; + editing = signal(false); constructor( private backend: BackendService, diff --git a/console-webapp/src/app/settings/rdap/rdapEdit.component.html b/console-webapp/src/app/settings/rdap/rdapEdit.component.html index 0f0a1e79ec0..f0a6361b652 100644 --- a/console-webapp/src/app/settings/rdap/rdapEdit.component.html +++ b/console-webapp/src/app/settings/rdap/rdapEdit.component.html @@ -1,4 +1,4 @@ -@if (registrarInEdit) { +@if (registrarInEdit(); as registrar) {
arrow_back @@ -27,7 +28,8 @@

Personal info

@@ -36,7 +38,8 @@

Personal info

@@ -45,7 +48,8 @@

Personal info

@@ -54,8 +58,9 @@

Personal info

@@ -63,8 +68,9 @@

Personal info

@@ -72,8 +78,9 @@

Personal info

@@ -81,8 +88,9 @@

Personal info

@@ -90,8 +98,9 @@

Personal info

@@ -99,14 +108,16 @@

Personal info

`, - changeDetection: ChangeDetectionStrategy.Eager, + changeDetection: ChangeDetectionStrategy.OnPush, imports: [MaterialModule], }) export class ResetEppPasswordComponent { @@ -58,12 +62,12 @@ export class ResetEppPasswordComponent { @Component({ selector: 'app-epp-password-edit', + imports: [MaterialModule, PasswordInputForm], templateUrl: './eppPasswordEdit.component.html', styleUrls: ['./eppPasswordEdit.component.scss'], - changeDetection: ChangeDetectionStrategy.Eager, - standalone: false, + changeDetection: ChangeDetectionStrategy.OnPush, }) -export default class EppPasswordEditComponent { +export class EppPasswordEditComponent implements GlobalLoader { static EPP_VALIDATORS = [ Validators.required, Validators.minLength(6), @@ -86,10 +90,16 @@ export default class EppPasswordEditComponent { protected userDataService: UserDataService, private backendService: BackendService, private resetPasswordDialog: MatDialog, - private _snackBar: MatSnackBar + private _snackBar: MatSnackBar, + protected globalLoader: GlobalLoaderService ) {} + loadingTimeout() { + this._snackBar.open('Timeout updating EPP password'); + } + save(passwordResults: PasswordResults) { + this.globalLoader.startGlobalLoader(this); this.securityService .saveEppPassword({ registrarId: this.registrarService.registrarId(), @@ -99,19 +109,22 @@ export default class EppPasswordEditComponent { }) .subscribe({ complete: () => { + this.globalLoader.stopGlobalLoader(this); this.goBack(); }, error: (err: HttpErrorResponse) => { this._snackBar.open(err.error); + this.globalLoader.stopGlobalLoader(this); }, }); } goBack() { - this.securityService.isEditingPassword = false; + this.securityService.isEditingPassword.set(false); } sendEppPasswordResetRequest() { + this.globalLoader.startGlobalLoader(this); return this.backendService.requestEppPasswordReset( this.registrarService.registrarId() ); @@ -127,9 +140,14 @@ export default class EppPasswordEditComponent { ) .pipe(switchMap((_) => this.sendEppPasswordResetRequest())) .subscribe({ - next: (_) => this.goBack(), - error: (err: HttpErrorResponse) => - this._snackBar.open(err.error || err.message), + next: (_) => { + this.globalLoader.stopGlobalLoader(this); + this.goBack(); + }, + error: (err: HttpErrorResponse) => { + this._snackBar.open(err.error || err.message); + this.globalLoader.stopGlobalLoader(this); + }, }); } } diff --git a/console-webapp/src/app/settings/security/security.component.html b/console-webapp/src/app/settings/security/security.component.html index bcca68964f7..b1c04b3ea04 100644 --- a/console-webapp/src/app/settings/security/security.component.html +++ b/console-webapp/src/app/settings/security/security.component.html @@ -1,6 +1,6 @@ -@if(securityService.isEditingSecurity) { +@if(securityService.isEditingSecurity()) { -} @else if(securityService.isEditingPassword) { +} @else if(securityService.isEditingPassword()) { } @else {
@@ -31,12 +31,12 @@

EPP Password

Password •••••••••••••• - @if(dataSource.eppPasswordLastUpdated) { + @if(dataSource().eppPasswordLastUpdated) { Last Changed {{ - dataSource.eppPasswordLastUpdated + dataSource().eppPasswordLastUpdated }} } @@ -68,7 +68,7 @@

IP Allowlist

> - @for (item of dataSource.ipAddressAllowList; track $index){ + @for (item of dataSource().ipAddressAllowList; track $index){ {{ item.value }} @@ -93,7 +93,7 @@

SSL Certificate

{{ - dataSource.clientCertificate || "No client certificate on file." + dataSource().clientCertificate || "No client certificate on file." }} @@ -111,7 +111,7 @@

Failover SSL Certificate

{{ - dataSource.failoverClientCertificate || + dataSource().failoverClientCertificate || "No failover certificate on file." }} diff --git a/console-webapp/src/app/settings/security/security.component.spec.ts b/console-webapp/src/app/settings/security/security.component.spec.ts index 74a1165c673..9af43448757 100644 --- a/console-webapp/src/app/settings/security/security.component.spec.ts +++ b/console-webapp/src/app/settings/security/security.component.spec.ts @@ -12,138 +12,178 @@ // See the License for the specific language governing permissions and // limitations under the License. -import { - ComponentFixture, - fakeAsync, - TestBed, - tick, - waitForAsync, -} from '@angular/core/testing'; - -import { provideHttpClient } from '@angular/common/http'; +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { HttpErrorResponse, provideHttpClient } from '@angular/common/http'; import { provideHttpClientTesting } from '@angular/common/http/testing'; import { FormsModule } from '@angular/forms'; import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; -import { of } from 'rxjs'; +import { of, throwError } from 'rxjs'; import { MaterialModule } from 'src/app/material.module'; import { RegistrarService } from 'src/app/registrar/registrar.service'; import { BackendService } from 'src/app/shared/services/backend.service'; -import SecurityComponent from './security.component'; +import { SecurityComponent } from './security.component'; import { SecurityService } from './security.service'; -import SecurityEditComponent from './securityEdit.component'; +import { SecurityEditComponent } from './securityEdit.component'; +import { EppPasswordEditComponent } from './eppPasswordEdit.component'; import { MOCK_REGISTRAR_SERVICE } from 'src/testdata/registrar/registrar.service.mock'; +import { MatDialog } from '@angular/material/dialog'; +import { MatSnackBar } from '@angular/material/snack-bar'; +import { signal, WritableSignal } from '@angular/core'; +import { UserDataService } from 'src/app/shared/services/userData.service'; +import { Registrar } from 'src/app/registrar/registrar.service'; -describe('SecurityComponent', () => { +describe('SecurityComponent & EppPasswordEditComponent', () => { let component: SecurityComponent; let fixture: ComponentFixture; - let saveSpy: Function; - let securityServiceStub: Partial; + let saveSpy: ReturnType; + let saveEppPasswordSpy: ReturnType; + let securityServiceStub: SecurityService; beforeEach(async () => { - securityServiceStub = { - isEditingSecurity: false, - isEditingPassword: false, - saveChanges: jasmine.createSpy('saveChanges').and.returnValue(of({})), - }; - await TestBed.configureTestingModule({ - declarations: [SecurityEditComponent, SecurityComponent], - imports: [MaterialModule, BrowserAnimationsModule, FormsModule], + imports: [ + SecurityEditComponent, + EppPasswordEditComponent, + SecurityComponent, + MaterialModule, + BrowserAnimationsModule, + FormsModule, + ], providers: [ BackendService, - { provide: SecurityService, useValue: securityServiceStub }, - { provide: RegistrarService, useValue: MOCK_REGISTRAR_SERVICE }, + SecurityService, + { + provide: RegistrarService, + useValue: { + ...MOCK_REGISTRAR_SERVICE, + registrarId: signal('reg-1'), + }, + }, + { + provide: UserDataService, + useValue: { userData: signal({ isAdmin: true }) }, + }, provideHttpClient(), provideHttpClientTesting(), ], }).compileComponents(); - saveSpy = securityServiceStub.saveChanges as jasmine.Spy; + securityServiceStub = TestBed.inject(SecurityService); + saveSpy = vi + .spyOn(securityServiceStub, 'saveChanges') + .mockReturnValue(of([])); + saveEppPasswordSpy = vi + .spyOn(securityServiceStub, 'saveEppPassword') + .mockReturnValue(of([])); fixture = TestBed.createComponent(SecurityComponent); component = fixture.componentInstance; }); - it('should create', () => { - expect(component).toBeTruthy(); + it('should render security elements', async () => { + fixture.detectChanges(); + await fixture.whenStable(); + const listElems: Array = Array.from( + fixture.nativeElement.querySelectorAll('span.console-app__list-value') + ); + expect(listElems).toHaveLength(8); + expect(listElems.map((e) => e.textContent)).toEqual([ + 'Change the password used for EPP logins', + '••••••••••••••', + 'Restrict access to EPP production servers to the following IP/IPv6 addresses, or ranges like 1.1.1.0/24', + '123.123.123.123', + 'X.509 PEM certificate for EPP production access', + 'No client certificate on file.', + 'X.509 PEM backup certificate for EPP production access', + 'No failover certificate on file.', + ]); }); - it('should render security elements', waitForAsync(() => { + it('should re-render IP allowlist when registrar signal updates asynchronously', async () => { + const registrarService = TestBed.inject(RegistrarService); + const registrarSignal = registrarService.registrar as WritableSignal< + Registrar | undefined + >; + const originalRegistrar = registrarSignal(); + registrarSignal.set(undefined); fixture.detectChanges(); - fixture.whenStable().then(() => { - let listElems: Array = Array.from( - fixture.nativeElement.querySelectorAll('span.console-app__list-value') - ); - expect(listElems).toHaveSize(8); - expect(listElems.map((e) => e.textContent)).toEqual([ - 'Change the password used for EPP logins', - '••••••••••••••', - 'Restrict access to EPP production servers to the following IP/IPv6 addresses, or ranges like 1.1.1.0/24', - '123.123.123.123', - 'X.509 PEM certificate for EPP production access', - 'No client certificate on file.', - 'X.509 PEM backup certificate for EPP production access', - 'No failover certificate on file.', - ]); + await fixture.whenStable(); + + expect(fixture.nativeElement.textContent).toContain( + 'No IP addresses on file.' + ); + + registrarSignal.set({ + ...originalRegistrar!, + ipAddressAllowList: ['1.1.1.0/24', '1.9.9.0/24'], }); - })); + fixture.detectChanges(); + await fixture.whenStable(); + + const listValues: Array = Array.from( + fixture.nativeElement.querySelectorAll('span.console-app__list-value'), + (e: HTMLElement) => e.textContent + ); + expect(listValues).toContain('1.1.1.0/24'); + expect(listValues).toContain('1.9.9.0/24'); + registrarSignal.set(originalRegistrar); + }); - it('should remove ip', fakeAsync(() => { + it('should remove ip', async () => { fixture.detectChanges(); - tick(); + await fixture.whenStable(); const editBtn = fixture.nativeElement.querySelector( 'button[aria-label="Edit security settings"]' ); editBtn.click(); - tick(); fixture.detectChanges(); + await fixture.whenStable(); const removeIpBtn = fixture.nativeElement.querySelector( '.console-app__removeIp' ); removeIpBtn.click(); - tick(); fixture.detectChanges(); + await fixture.whenStable(); const saveBtn = fixture.nativeElement.querySelector( '.settings-security__edit-save' ); saveBtn.click(); - tick(); fixture.detectChanges(); + await fixture.whenStable(); expect(saveSpy).toHaveBeenCalledWith({ ipAddressAllowList: [], }); - })); + }); - it('should toggle isEditingSecurity', () => { - expect(component.securityService.isEditingSecurity).toBeFalse(); + it('should toggle isEditingSecurity and isEditingPassword', () => { + expect(component.securityService.isEditingSecurity()).toBe(false); component.editSecurity(); - expect(component.securityService.isEditingSecurity).toBeTrue(); - }); + expect(component.securityService.isEditingSecurity()).toBe(true); - it('should toggle isEditingPassword', () => { - expect(component.securityService.isEditingPassword).toBeFalse(); + expect(component.securityService.isEditingPassword()).toBe(false); component.editEppPassword(); - expect(component.securityService.isEditingPassword).toBeTrue(); + expect(component.securityService.isEditingPassword()).toBe(true); }); - it('should call save', fakeAsync(() => { + it('should call save with updated clientCertificate', async () => { fixture.detectChanges(); - tick(); + await fixture.whenStable(); const editBtn = fixture.nativeElement.querySelector( 'button[aria-label="Edit security settings"]' ); editBtn.click(); - tick(); fixture.detectChanges(); + await fixture.whenStable(); const el = fixture.nativeElement.querySelector( '.console-app__clientCertificateValue' @@ -151,18 +191,66 @@ describe('SecurityComponent', () => { el.value = 'test'; el.dispatchEvent(new Event('input')); - tick(); fixture.detectChanges(); + await fixture.whenStable(); const saveBtn = fixture.nativeElement.querySelector( '.settings-security__edit-save' ); saveBtn.click(); - tick(); + await fixture.whenStable(); expect(saveSpy).toHaveBeenCalledWith({ ipAddressAllowList: [{ value: '123.123.123.123' }], clientCertificate: 'test', }); - })); + }); + + it('should save EPP password and request EPP password reset in EppPasswordEditComponent', () => { + const backendService = TestBed.inject(BackendService); + const resetSpy = vi + .spyOn(backendService, 'requestEppPasswordReset') + .mockReturnValue(of({})); + + const eppFixture = TestBed.createComponent(EppPasswordEditComponent); + const snackBarSpy = vi + .spyOn(eppFixture.debugElement.injector.get(MatSnackBar), 'open') + .mockReturnValue({} as any); + const dialogSpy = vi + .spyOn(eppFixture.debugElement.injector.get(MatDialog), 'open') + .mockReturnValue({ afterClosed: () => of(true) } as any); + const eppComponent = eppFixture.componentInstance; + eppFixture.detectChanges(); + + securityServiceStub.isEditingPassword.set(true); + eppComponent.save({ + oldPassword: 'oldPass123', + newPassword: 'newPass123', + newPasswordRepeat: 'newPass123', + }); + + expect(saveEppPasswordSpy).toHaveBeenCalledWith({ + registrarId: 'reg-1', + oldPassword: 'oldPass123', + newPassword: 'newPass123', + newPasswordRepeat: 'newPass123', + }); + expect(securityServiceStub.isEditingPassword()).toBe(false); + + securityServiceStub.isEditingPassword.set(true); + eppComponent.requestEppPasswordReset(); + expect(dialogSpy).toHaveBeenCalled(); + expect(resetSpy).toHaveBeenCalledWith('reg-1'); + expect(securityServiceStub.isEditingPassword()).toBe(false); + + saveEppPasswordSpy.mockReturnValueOnce( + throwError(() => new HttpErrorResponse({ error: 'Wrong old password' })) + ); + eppComponent.save({ + oldPassword: 'wrong', + newPassword: 'newPass123', + newPasswordRepeat: 'newPass123', + }); + expect(snackBarSpy).toHaveBeenCalledWith('Wrong old password'); + }); }); diff --git a/console-webapp/src/app/settings/security/security.component.ts b/console-webapp/src/app/settings/security/security.component.ts index 202ed5a811a..21d9872cb01 100644 --- a/console-webapp/src/app/settings/security/security.component.ts +++ b/console-webapp/src/app/settings/security/security.component.ts @@ -12,41 +12,51 @@ // See the License for the specific language governing permissions and // limitations under the License. -import { Component, effect, ChangeDetectionStrategy } from '@angular/core'; import { - RegistrarService, - SecuritySettings, -} from 'src/app/registrar/registrar.service'; + Component, + computed, + effect, + untracked, + ChangeDetectionStrategy, +} from '@angular/core'; +import { MaterialModule } from 'src/app/material.module'; +import { RegistrarService } from 'src/app/registrar/registrar.service'; +import { EppPasswordEditComponent } from './eppPasswordEdit.component'; import { SecurityService, apiToUiConverter } from './security.service'; +import { SecurityEditComponent } from './securityEdit.component'; @Component({ selector: 'app-security', + imports: [MaterialModule, SecurityEditComponent, EppPasswordEditComponent], templateUrl: './security.component.html', styleUrls: ['./security.component.scss'], - changeDetection: ChangeDetectionStrategy.Eager, - standalone: false, + changeDetection: ChangeDetectionStrategy.OnPush, }) -export default class SecurityComponent { +export class SecurityComponent { public static PATH = 'security'; - dataSource: SecuritySettings = {}; + dataSource = computed(() => + apiToUiConverter(this.registrarService.registrar()) + ); constructor( public securityService: SecurityService, public registrarService: RegistrarService ) { effect(() => { - if (this.registrarService.registrar()) { - this.dataSource = apiToUiConverter(this.registrarService.registrar()); - this.securityService.isEditingSecurity = false; - this.securityService.isEditingPassword = false; + const registrar = this.registrarService.registrar(); + if (registrar) { + untracked(() => { + this.securityService.isEditingSecurity.set(false); + this.securityService.isEditingPassword.set(false); + }); } }); } editSecurity() { - this.securityService.isEditingSecurity = true; + this.securityService.isEditingSecurity.set(true); } editEppPassword() { - this.securityService.isEditingPassword = true; + this.securityService.isEditingPassword.set(true); } } diff --git a/console-webapp/src/app/settings/security/security.service.spec.ts b/console-webapp/src/app/settings/security/security.service.spec.ts index 80347c48a55..48480212583 100644 --- a/console-webapp/src/app/settings/security/security.service.spec.ts +++ b/console-webapp/src/app/settings/security/security.service.spec.ts @@ -18,16 +18,19 @@ import { provideHttpClient } from '@angular/common/http'; import { provideHttpClientTesting } from '@angular/common/http/testing'; import { MatSnackBar } from '@angular/material/snack-bar'; import { BackendService } from 'src/app/shared/services/backend.service'; -import SecurityComponent from './security.component'; import { + EppPasswordBackendModel, SecurityService, apiToUiConverter, uiToApiConverter, } from './security.service'; import { + RegistrarService, SecuritySettings, SecuritySettingsBackendModel, } from 'src/app/registrar/registrar.service'; +import { signal } from '@angular/core'; +import { of } from 'rxjs'; describe('SecurityService', () => { const uiMockData: SecuritySettings = { @@ -42,31 +45,70 @@ describe('SecurityService', () => { }; let service: SecurityService; + let backendService: BackendService; + let loadRegistrarsSpy: ReturnType; beforeEach(() => { + loadRegistrarsSpy = vi.fn().mockReturnValue(of([])); TestBed.configureTestingModule({ - declarations: [SecurityComponent], - imports: [], providers: [ MatSnackBar, SecurityService, BackendService, + { + provide: RegistrarService, + useValue: { + registrarId: signal('reg-1'), + loadRegistrars: loadRegistrarsSpy, + }, + }, provideHttpClient(), provideHttpClientTesting(), ], }); + backendService = TestBed.inject(BackendService); service = TestBed.inject(SecurityService); }); - it('should be created', () => { - expect(service).toBeTruthy(); + it('should convert from api to ui including empty defaults', () => { + expect(apiToUiConverter(apiMockData)).toEqual(uiMockData); + expect(apiToUiConverter()).toEqual({ ipAddressAllowList: [] }); }); - it('should convert from api to ui', () => { - expect(apiToUiConverter(apiMockData)).toEqual(uiMockData); + it('should convert from ui to api and filter out empty IP entries', () => { + expect( + uiToApiConverter({ + ...uiMockData, + ipAddressAllowList: [{ value: '123.123.123.123' }, { value: '' }], + }) + ).toEqual(apiMockData); + }); + + it('should save security settings via backend and reload registrars', () => { + const postSpy = vi + .spyOn(backendService, 'postSecuritySettings') + .mockReturnValue(of(apiMockData)); + + service.saveChanges(uiMockData).subscribe(); + + expect(postSpy).toHaveBeenCalledWith('reg-1', apiMockData); + expect(loadRegistrarsSpy).toHaveBeenCalled(); }); - it('should convert from ui to api', () => { - expect(uiToApiConverter(uiMockData)).toEqual(apiMockData); + it('should save EPP password update via backend and reload registrars', () => { + const payload: EppPasswordBackendModel = { + registrarId: 'reg-1', + oldPassword: 'oldPassword1', + newPassword: 'newPassword1', + newPasswordRepeat: 'newPassword1', + }; + const postSpy = vi + .spyOn(backendService, 'postEppPasswordUpdate') + .mockReturnValue(of(payload)); + + service.saveEppPassword(payload).subscribe(); + + expect(postSpy).toHaveBeenCalledWith(payload); + expect(loadRegistrarsSpy).toHaveBeenCalled(); }); }); diff --git a/console-webapp/src/app/settings/security/security.service.ts b/console-webapp/src/app/settings/security/security.service.ts index 798c33dbf73..7de91330ef0 100644 --- a/console-webapp/src/app/settings/security/security.service.ts +++ b/console-webapp/src/app/settings/security/security.service.ts @@ -12,7 +12,7 @@ // See the License for the specific language governing permissions and // limitations under the License. -import { Injectable } from '@angular/core'; +import { Injectable, signal } from '@angular/core'; import { switchMap } from 'rxjs'; import { IpAllowListItem, @@ -54,8 +54,8 @@ export function uiToApiConverter( }) export class SecurityService { securitySettings: SecuritySettings = {}; - isEditingSecurity: boolean = false; - isEditingPassword: boolean = false; + isEditingSecurity = signal(false); + isEditingPassword = signal(false); constructor( private backend: BackendService, diff --git a/console-webapp/src/app/settings/security/securityEdit.component.html b/console-webapp/src/app/settings/security/securityEdit.component.html index c8da93f921b..3bbe881e7a8 100644 --- a/console-webapp/src/app/settings/security/securityEdit.component.html +++ b/console-webapp/src/app/settings/security/securityEdit.component.html @@ -9,12 +9,12 @@

IP Allowlist

addresses, or ranges like 1.1.1.0/24

- @for (ip of dataSource.ipAddressAllowList; track ip.value) { + @for (ip of dataSource().ipAddressAllowList; track ip) {
IP Allowlist class="console-app__removeIp" [attr.aria-label]="'Remove IP entry ' + ip.value" (click)="removeIpEntry(ip)" - [disabled]="isUpdating" + [disabled]="isUpdating()" type="button" > close @@ -35,7 +35,7 @@

IP Allowlist

}

-@if (isLoading) { +@if (isLoading()) {
-} @else if (errorMessage) { -

Failure

+} @else if (errorMessage()) { +

Failure

- An error occurred: {{ errorMessage }}.

Please double-check the + An error occurred: {{ errorMessage() }}.

Please double-check the verification code and try again.
} @else {
-

{{ type }} password reset

+

{{ type }} password reset

(undefined); requestVerificationCode = ''; passwordUpdateForm: FormGroup | null = null; @@ -68,8 +74,8 @@ export class PasswordResetVerifyComponent { .getPasswordResetInformation(this.requestVerificationCode) .subscribe({ error: (err: HttpErrorResponse) => { - this.isLoading = false; - this.errorMessage = err.error; + this.errorMessage.set(err.error); + this.isLoading.set(false); }, next: this.presentData.bind(this), }); @@ -88,7 +94,7 @@ export class PasswordResetVerifyComponent { newPassword: new FormControl('', validators), newPasswordRepeat: new FormControl('', validators), }); - this.isLoading = false; + this.isLoading.set(false); } save(passwordResults: PasswordResults) { @@ -99,8 +105,8 @@ export class PasswordResetVerifyComponent { ) .subscribe({ error: (err: HttpErrorResponse) => { - this.isLoading = false; - this.errorMessage = err.error; + this.errorMessage.set(err.error); + this.isLoading.set(false); }, next: (_) => { this.router.navigate(['']); diff --git a/console-webapp/src/app/shared/components/pocReminder/pocReminder.component.ts b/console-webapp/src/app/shared/components/pocReminder/pocReminder.component.ts index e18f67dd0fe..8264fc41ed7 100644 --- a/console-webapp/src/app/shared/components/pocReminder/pocReminder.component.ts +++ b/console-webapp/src/app/shared/components/pocReminder/pocReminder.component.ts @@ -14,15 +14,17 @@ import { Component, ChangeDetectionStrategy } from '@angular/core'; import { MatSnackBar, MatSnackBarRef } from '@angular/material/snack-bar'; +import { RouterModule } from '@angular/router'; +import { MaterialModule } from 'src/app/material.module'; import { RegistrarService } from '../../../registrar/registrar.service'; import { HttpErrorResponse } from '@angular/common/http'; @Component({ selector: 'app-poc-reminder', + imports: [MaterialModule, RouterModule], templateUrl: './pocReminder.component.html', styleUrls: ['./pocReminder.component.scss'], - changeDetection: ChangeDetectionStrategy.Eager, - standalone: false, + changeDetection: ChangeDetectionStrategy.OnPush, }) export class PocReminderComponent { constructor( diff --git a/console-webapp/src/app/shared/components/selectedRegistrarWrapper/selectedRegistrarWrapper.component.scss b/console-webapp/src/app/shared/components/selectedRegistrarWrapper/selectedRegistrarWrapper.component.scss index b1bb8fdaec6..861f405a9ec 100644 --- a/console-webapp/src/app/shared/components/selectedRegistrarWrapper/selectedRegistrarWrapper.component.scss +++ b/console-webapp/src/app/shared/components/selectedRegistrarWrapper/selectedRegistrarWrapper.component.scss @@ -14,5 +14,9 @@ transform: scale(3); margin-bottom: 0.5rem; } + + h4 { + font: inherit; + } } } diff --git a/console-webapp/src/app/shared/components/selectedRegistrarWrapper/selectedRegistrarWrapper.component.ts b/console-webapp/src/app/shared/components/selectedRegistrarWrapper/selectedRegistrarWrapper.component.ts index cfddccd83d1..43157bc6057 100644 --- a/console-webapp/src/app/shared/components/selectedRegistrarWrapper/selectedRegistrarWrapper.component.ts +++ b/console-webapp/src/app/shared/components/selectedRegistrarWrapper/selectedRegistrarWrapper.component.ts @@ -13,10 +13,12 @@ // limitations under the License. import { Component, ChangeDetectionStrategy } from '@angular/core'; +import { MaterialModule } from 'src/app/material.module'; import { RegistrarService } from 'src/app/registrar/registrar.service'; @Component({ selector: 'app-selected-registrar-wrapper', + imports: [MaterialModule], styleUrls: ['./selectedRegistrarWrapper.component.scss'], template: ` @if(registrarService.registrarId()){ @@ -27,12 +29,11 @@ import { RegistrarService } from 'src/app/registrar/registrar.service'; block

No registrar selected

-

Please select a registrar

+

Please select a registrar

} `, - changeDetection: ChangeDetectionStrategy.Eager, - standalone: false, + changeDetection: ChangeDetectionStrategy.OnPush, }) export class SelectedRegistrarWrapper { constructor(protected registrarService: RegistrarService) {} diff --git a/console-webapp/src/app/shared/directives/forceFocus.directive.ts b/console-webapp/src/app/shared/directives/forceFocus.directive.ts index 7dc539148ad..ea83ae76724 100644 --- a/console-webapp/src/app/shared/directives/forceFocus.directive.ts +++ b/console-webapp/src/app/shared/directives/forceFocus.directive.ts @@ -16,7 +16,6 @@ import { Directive, ElementRef, effect } from '@angular/core'; @Directive({ selector: '[forceFocus]', - standalone: false, }) export class ForceFocusDirective { constructor(private el: ElementRef) { diff --git a/console-webapp/src/app/shared/directives/locationBack.directive.ts b/console-webapp/src/app/shared/directives/locationBack.directive.ts index c97408f8dab..ccb6fc92f3e 100644 --- a/console-webapp/src/app/shared/directives/locationBack.directive.ts +++ b/console-webapp/src/app/shared/directives/locationBack.directive.ts @@ -17,7 +17,6 @@ import { Directive, HostListener } from '@angular/core'; @Directive({ selector: '[backButton]', - standalone: false, }) export class LocationBackDirective { constructor(private location: Location) {} diff --git a/console-webapp/src/app/shared/directives/userLevelVisiblity.directive.ts b/console-webapp/src/app/shared/directives/userLevelVisiblity.directive.ts index e1c06d1cc14..aff49ccde2c 100644 --- a/console-webapp/src/app/shared/directives/userLevelVisiblity.directive.ts +++ b/console-webapp/src/app/shared/directives/userLevelVisiblity.directive.ts @@ -36,7 +36,6 @@ export const DISABLED_ELEMENTS_PER_ROLE = { @Directive({ selector: '[elementId]', - standalone: false, }) export class UserLevelVisibility { @Input() elementId!: RESTRICTED_ELEMENTS | null; diff --git a/console-webapp/src/app/shared/services/globalLoader.service.ts b/console-webapp/src/app/shared/services/globalLoader.service.ts index 3c38882be3c..e6719198eea 100644 --- a/console-webapp/src/app/shared/services/globalLoader.service.ts +++ b/console-webapp/src/app/shared/services/globalLoader.service.ts @@ -12,7 +12,7 @@ // See the License for the specific language governing permissions and // limitations under the License. -import { Injectable } from '@angular/core'; +import { Injectable, signal } from '@angular/core'; import { Observable, Subscription, timeout } from 'rxjs'; export interface GlobalLoader { @@ -32,10 +32,10 @@ export interface GlobalLoader { export class GlobalLoaderService { private static readonly TIMEOUT_MS = 10000; private loaders = new Map(); - public isLoading: boolean = false; + public isLoading = signal(false); private syncLoading() { - this.isLoading = this.loaders.size > 0; + this.isLoading.set(this.loaders.size > 0); } startGlobalLoader(c: GlobalLoader) { diff --git a/console-webapp/src/app/shared/shared.spec.ts b/console-webapp/src/app/shared/shared.spec.ts new file mode 100644 index 00000000000..3f36c081df5 --- /dev/null +++ b/console-webapp/src/app/shared/shared.spec.ts @@ -0,0 +1,347 @@ +// Copyright 2026 The Nomulus Authors. All Rights Reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { Component, signal } from '@angular/core'; +import { Location } from '@angular/common'; +import { HttpErrorResponse, provideHttpClient } from '@angular/common/http'; +import { + HttpTestingController, + provideHttpClientTesting, +} from '@angular/common/http/testing'; +import { TestBed } from '@angular/core/testing'; +import { FormControl, FormGroup, Validators } from '@angular/forms'; +import { MatSnackBar, MatSnackBarRef } from '@angular/material/snack-bar'; +import { NoopAnimationsModule } from '@angular/platform-browser/animations'; +import { ActivatedRoute, convertToParamMap, Router } from '@angular/router'; +import { of, throwError } from 'rxjs'; +import { MaterialModule } from '../material.module'; +import { RegistrarService } from '../registrar/registrar.service'; +import { PasswordInputForm } from './components/passwordReset/passwordInputForm.component'; +import { PasswordResetVerifyComponent } from './components/passwordReset/passwordResetVerify.component'; +import { PocReminderComponent } from './components/pocReminder/pocReminder.component'; +import { ForceFocusDirective } from './directives/forceFocus.directive'; +import { LocationBackDirective } from './directives/locationBack.directive'; +import { + RESTRICTED_ELEMENTS, + UserLevelVisibility, +} from './directives/userLevelVisiblity.directive'; +import { BackendService } from './services/backend.service'; +import { + GlobalLoader, + GlobalLoaderService, +} from './services/globalLoader.service'; +import { UserData, UserDataService } from './services/userData.service'; + +@Component({ + imports: [UserLevelVisibility, LocationBackDirective, ForceFocusDirective], + template: ` +
+ + + `, +}) +class TestHostComponent { + restrictedElement: RESTRICTED_ELEMENTS | null = + RESTRICTED_ELEMENTS.REGISTRAR_ELEMENT; + isReverse = signal(false); +} + +describe('Shared Services, Directives & Components', () => { + let httpMock: HttpTestingController; + let backendService: BackendService; + let userDataSignal: ReturnType | undefined>>; + + beforeEach(async () => { + userDataSignal = signal | undefined>({ + globalRole: 'NONE', + }); + + await TestBed.configureTestingModule({ + imports: [ + TestHostComponent, + PasswordInputForm, + PasswordResetVerifyComponent, + PocReminderComponent, + MaterialModule, + NoopAnimationsModule, + ], + providers: [ + BackendService, + GlobalLoaderService, + { + provide: UserDataService, + useValue: { userData: userDataSignal }, + }, + { + provide: RegistrarService, + useValue: { + registrarId: signal('reg-1'), + registrar: signal({ + registrarId: 'reg-1', + registrarName: 'Reg One', + }), + updateRegistrar: vi.fn().mockReturnValue(of({})), + }, + }, + { + provide: ActivatedRoute, + useValue: { + queryParamMap: of( + convertToParamMap({ + resetRequestVerificationCode: 'reset-code-999', + }) + ), + }, + }, + { + provide: MatSnackBarRef, + useValue: { dismiss: vi.fn() }, + }, + provideHttpClient(), + provideHttpClientTesting(), + ], + }).compileComponents(); + + httpMock = TestBed.inject(HttpTestingController); + backendService = TestBed.inject(BackendService); + }); + + afterEach(() => { + vi.useRealTimers(); + httpMock.verify(); + }); + + describe('BackendService', () => { + it('should construct query URLs and send payloads for core API endpoints', () => { + backendService + .getDomains('reg-1', '2024-01-01T00:00:00Z', 2, 25, 100, 'example') + .subscribe(); + const domainReq = httpMock.expectOne( + '/console-api/domain-list?registrarId=reg-1&checkpointTime=2024-01-01T00:00:00Z&pageNumber=2&resultsPerPage=25&totalResults=100&searchTerm=example' + ); + expect(domainReq.request.method).toBe('GET'); + domainReq.flush({ checkpointTime: '', domains: [], totalResults: 0 }); + + backendService.getHistoryLog('reg-1', 'user@example.com').subscribe(); + const histReq = httpMock.expectOne( + '/console-api/history?registrarId=reg-1&consoleUserEmail=user@example.com' + ); + expect(histReq.request.method).toBe('GET'); + histReq.flush([]); + + backendService + .bulkDomainAction(['a.tld'], 'reason', 'DELETE', 'reg-1') + .subscribe(); + const bulkReq = httpMock.expectOne( + '/console-api/bulk-domain?registrarId=reg-1&bulkDomainAction=DELETE' + ); + expect(bulkReq.request.method).toBe('POST'); + expect(bulkReq.request.body).toEqual({ + domainList: ['a.tld'], + reason: 'reason', + }); + bulkReq.flush({}); + }); + + it('should return mockData or rethrow errors in errorCatcher', () => { + const consoleSpy = vi + .spyOn(console, 'error') + .mockImplementation(() => {}); + const err = new HttpErrorResponse({ + status: 500, + error: 'Internal failure', + }); + + let fallbackResult: string | undefined; + backendService + .errorCatcher(err, 'fallback') + .subscribe((res) => (fallbackResult = res)); + expect(fallbackResult).toBe('fallback'); + + let caughtError: HttpErrorResponse | undefined; + backendService.errorCatcher(err).subscribe({ + error: (e) => (caughtError = e), + }); + expect(caughtError).toBe(err); + consoleSpy.mockRestore(); + }); + }); + + describe('GlobalLoaderService', () => { + it('should track active loaders and trigger loadingTimeout after 10 seconds', () => { + vi.useFakeTimers(); + const loaderService = TestBed.inject(GlobalLoaderService); + const mockLoader: GlobalLoader = { loadingTimeout: vi.fn() }; + + loaderService.startGlobalLoader(mockLoader); + expect(loaderService.isLoading()).toBe(true); + + loaderService.stopGlobalLoader(mockLoader); + expect(loaderService.isLoading()).toBe(false); + + loaderService.startGlobalLoader(mockLoader); + vi.advanceTimersByTime(10000); + expect(mockLoader.loadingTimeout).toHaveBeenCalled(); + expect(loaderService.isLoading()).toBe(false); + }); + }); + + describe('Directives (UserLevelVisibility, LocationBackDirective, ForceFocusDirective)', () => { + it('should toggle display based on user globalRole and isReverse', () => { + const fixture = TestBed.createComponent(TestHostComponent); + fixture.detectChanges(); + const restrictedEl: HTMLElement = + fixture.nativeElement.querySelector('#restricted'); + + // NONE role disables REGISTRAR_ELEMENT + expect(restrictedEl.style.display).toBe('none'); + + userDataSignal.set({ globalRole: 'SUPPORT_LEAD' }); + TestBed.flushEffects(); + expect(restrictedEl.style.display).toBe(''); + + fixture.componentInstance.isReverse.set(true); + fixture.detectChanges(); + userDataSignal.set({ globalRole: 'NONE' }); + TestBed.flushEffects(); + expect(restrictedEl.style.display).toBe(''); + }); + + it('should call Location.back() on click and focus forceFocus element', () => { + const location = TestBed.inject(Location); + const backSpy = vi.spyOn(location, 'back').mockImplementation(() => {}); + + const fixture = TestBed.createComponent(TestHostComponent); + fixture.detectChanges(); + + const backBtn: HTMLButtonElement = + fixture.nativeElement.querySelector('#backBtn'); + backBtn.click(); + expect(backSpy).toHaveBeenCalled(); + + const focusInput: HTMLInputElement = + fixture.nativeElement.querySelector('#focusInput'); + expect(focusInput.tabIndex).toBe(-1); + }); + }); + + describe('PasswordInputForm, PasswordResetVerifyComponent & PocReminderComponent', () => { + it('should validate password matching and emit results in PasswordInputForm', () => { + vi.useFakeTimers(); + const formGroup = new FormGroup({ + oldPassword: new FormControl('oldPass1'), + newPassword: new FormControl('newPass1', [ + Validators.required, + PasswordInputForm.newPasswordsMatch, + ]), + newPasswordRepeat: new FormControl('mismatch', [ + Validators.required, + PasswordInputForm.newPasswordsMatch, + ]), + }); + + const fixture = TestBed.createComponent(PasswordInputForm); + const component = fixture.componentInstance; + fixture.componentRef.setInput('formGroup', formGroup); + fixture.componentRef.setInput('displayOldPasswordField', true); + fixture.detectChanges(); + + formGroup.get('newPasswordRepeat')?.updateValueAndValidity(); + vi.runAllTimers(); + expect(component.hasError('newPasswordRepeat')).toBe( + "Passwords don't match" + ); + + formGroup.get('newPasswordRepeat')?.setValue('newPass1'); + formGroup.get('newPasswordRepeat')?.updateValueAndValidity(); + expect(component.hasError('newPasswordRepeat')).toBe(''); + + let emitted: any; + component.submitResults.subscribe((res) => (emitted = res)); + component.save(); + expect(emitted).toEqual({ + oldPassword: 'oldPass1', + newPassword: 'newPass1', + newPasswordRepeat: 'newPass1', + }); + }); + + it('should verify password reset token and finalize password reset', () => { + const router = TestBed.inject(Router); + const navigateSpy = vi.spyOn(router, 'navigate').mockResolvedValue(true); + + vi.spyOn(backendService, 'getPasswordResetInformation').mockReturnValue( + of({ registrarId: 'reg-reset', type: 'EPP' }) + ); + const finalizeSpy = vi + .spyOn(backendService, 'finalizePasswordReset') + .mockReturnValue(of({})); + + const fixture = TestBed.createComponent(PasswordResetVerifyComponent); + const snackBarSpy = vi + .spyOn(fixture.debugElement.injector.get(MatSnackBar), 'open') + .mockReturnValue({} as any); + const component = fixture.componentInstance; + fixture.detectChanges(); + + expect(component.isLoading()).toBe(false); + expect(component.type).toBe('EPP'); + + component.save({ + oldPassword: null, + newPassword: 'newSecurePassword', + newPasswordRepeat: 'newSecurePassword', + }); + expect(finalizeSpy).toHaveBeenCalledWith( + 'reset-code-999', + 'newSecurePassword' + ); + expect(navigateSpy).toHaveBeenCalledWith(['']); + expect(snackBarSpy).toHaveBeenCalledWith( + 'Password reset completed successfully' + ); + }); + + it('should update registrar lastPocVerificationDate and dismiss snackbar in PocReminderComponent', () => { + const registrarService = TestBed.inject(RegistrarService); + const snackBarRef = TestBed.inject(MatSnackBarRef); + + const fixture = TestBed.createComponent(PocReminderComponent); + const snackBarSpy = vi + .spyOn(fixture.debugElement.injector.get(MatSnackBar), 'open') + .mockReturnValue({} as any); + const component = fixture.componentInstance; + fixture.detectChanges(); + + component.confirmReviewed(); + expect(registrarService.updateRegistrar).toHaveBeenCalledWith( + expect.objectContaining({ + registrarId: 'reg-1', + lastPocVerificationDate: expect.any(String), + }) + ); + expect(snackBarRef.dismiss).toHaveBeenCalled(); + + (registrarService.updateRegistrar as any).mockReturnValueOnce( + throwError(() => new HttpErrorResponse({ error: 'PoC update error' })) + ); + component.confirmReviewed(); + expect(snackBarSpy).toHaveBeenCalledWith('PoC update error'); + }); + }); +}); diff --git a/console-webapp/src/app/snackbar.module.ts b/console-webapp/src/app/snackbar.module.ts deleted file mode 100644 index 81993c4084f..00000000000 --- a/console-webapp/src/app/snackbar.module.ts +++ /dev/null @@ -1,24 +0,0 @@ -// Copyright 2024 The Nomulus Authors. All Rights Reserved. -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -import { NgModule } from '@angular/core'; -import { MAT_SNACK_BAR_DEFAULT_OPTIONS } from '@angular/material/snack-bar'; - -/** Provides a default set of options for the snack bar. */ -@NgModule({ - providers: [ - { provide: MAT_SNACK_BAR_DEFAULT_OPTIONS, useValue: { duration: 5000 } }, - ], -}) -export class SnackBarModule {} diff --git a/console-webapp/src/app/support/support.component.html b/console-webapp/src/app/support/support.component.html index a648513d715..71db6fafbee 100644 --- a/console-webapp/src/app/support/support.component.html +++ b/console-webapp/src/app/support/support.component.html @@ -1,5 +1,5 @@
-

Support

+

Support

Our support team can assist you with any technical or operational questions you may have regarding our registry services. diff --git a/console-webapp/src/app/support/support.component.ts b/console-webapp/src/app/support/support.component.ts index 59d9d49897b..fbe7750b17a 100644 --- a/console-webapp/src/app/support/support.component.ts +++ b/console-webapp/src/app/support/support.component.ts @@ -13,14 +13,15 @@ // limitations under the License. import { Component, ChangeDetectionStrategy } from '@angular/core'; +import { MaterialModule } from '../material.module'; import { UserDataService } from '../shared/services/userData.service'; @Component({ selector: 'app-support', + imports: [MaterialModule], templateUrl: './support.component.html', styleUrls: ['./support.component.scss'], - changeDetection: ChangeDetectionStrategy.Eager, - standalone: false, + changeDetection: ChangeDetectionStrategy.OnPush, }) export class SupportComponent { public static PATH = 'support'; diff --git a/console-webapp/src/app/users/userDetails.component.html b/console-webapp/src/app/users/userDetails.component.html index 1d6a413f3f4..279fc43e690 100644 --- a/console-webapp/src/app/users/userDetails.component.html +++ b/console-webapp/src/app/users/userDetails.component.html @@ -3,14 +3,14 @@ cdkTrapFocus [cdkTrapFocusAutoCapture]="true" > - @if(isEditing) { -

Editing {{ userDetails().emailAddress }}

+ @if(isEditing()) { +

Editing {{ userDetails().emailAddress }}

@@ -20,11 +20,11 @@

Editing {{ userDetails().emailAddress }}

(onEditComplete)="saveEdit($event)" /> } @else { @if(isNewUser) { -

+

{{ userDetails().emailAddress + " successfully created" }}

} @else { -

User details

+

User details

}
@@ -36,7 +36,7 @@

User details

mat-flat-button color="primary" aria-label="Edit User" - (click)="isEditing = true" + (click)="isEditing.set(true)" > edit Edit @@ -45,7 +45,7 @@

User details

mat-icon-button aria-label="Delete User" (click)="deleteUser()" - [disabled]="isLoading" + [disabled]="isLoading()" > delete @@ -56,7 +56,7 @@

User details

Please save the password. For your security, we do not store passwords in a recoverable format.
- } @if (isLoading) { + } @if (isLoading()) {

diff --git a/console-webapp/src/app/users/userDetails.component.ts b/console-webapp/src/app/users/userDetails.component.ts index 25f158e97b9..042920ef51b 100644 --- a/console-webapp/src/app/users/userDetails.component.ts +++ b/console-webapp/src/app/users/userDetails.component.ts @@ -12,12 +12,15 @@ // See the License for the specific language governing permissions and // limitations under the License. -import { Component, computed, ChangeDetectionStrategy } from '@angular/core'; +import { + Component, + computed, + signal, + ChangeDetectionStrategy, +} from '@angular/core'; import { MatSnackBar } from '@angular/material/snack-bar'; -import { SelectedRegistrarModule } from '../app.module'; import { MaterialModule } from '../material.module'; import { RegistrarService } from '../registrar/registrar.service'; -import { SnackBarModule } from '../snackbar.module'; import { UsersService, roleToDescription, User } from './users.service'; import { FormsModule } from '@angular/forms'; import { UserEditFormComponent } from './userEditForm.component'; @@ -26,21 +29,15 @@ import { UserEditFormComponent } from './userEditForm.component'; selector: 'app-user-edit', templateUrl: './userDetails.component.html', styleUrls: ['./userDetails.component.scss'], - imports: [ - FormsModule, - MaterialModule, - SnackBarModule, - SelectedRegistrarModule, - UserEditFormComponent, - ], - changeDetection: ChangeDetectionStrategy.Eager, + imports: [FormsModule, MaterialModule, UserEditFormComponent], + changeDetection: ChangeDetectionStrategy.OnPush, providers: [], }) export class UserDetailsComponent { - isEditing = false; + isEditing = signal(false); isPasswordVisible = false; isNewUser = false; - isLoading = false; + isLoading = signal(false); userDetails = computed(() => { return this.usersService @@ -72,14 +69,14 @@ export class UserDetailsComponent { this.userDetails().emailAddress ) ) { - this.isLoading = true; + this.isLoading.set(true); this.usersService.deleteUser(this.userDetails()).subscribe({ error: (err) => { this._snackBar.open(err.error || err.message); - this.isLoading = false; + this.isLoading.set(false); }, complete: () => { - this.isLoading = false; + this.isLoading.set(false); this.goBack(); }, }); @@ -91,15 +88,15 @@ export class UserDetailsComponent { } saveEdit(user: User) { - this.isLoading = true; + this.isLoading.set(true); this.usersService.updateUser(user).subscribe({ error: (err) => { this._snackBar.open(err.error || err.message); - this.isLoading = false; + this.isLoading.set(false); }, complete: () => { - this.isLoading = false; - this.isEditing = false; + this.isLoading.set(false); + this.isEditing.set(false); }, }); } diff --git a/console-webapp/src/app/users/userEditForm.component.ts b/console-webapp/src/app/users/userEditForm.component.ts index b35a8cbfe05..74745c8190d 100644 --- a/console-webapp/src/app/users/userEditForm.component.ts +++ b/console-webapp/src/app/users/userEditForm.component.ts @@ -50,7 +50,7 @@ import { HttpErrorResponse } from '@angular/common/http'; `, - changeDetection: ChangeDetectionStrategy.Eager, + changeDetection: ChangeDetectionStrategy.OnPush, imports: [MaterialModule], }) export class ResetRegistryLockPasswordComponent { @@ -74,7 +74,7 @@ export class ResetRegistryLockPasswordComponent { templateUrl: './userEditForm.component.html', styleUrls: ['./userEditForm.component.scss'], imports: [FormsModule, MaterialModule], - changeDetection: ChangeDetectionStrategy.Eager, + changeDetection: ChangeDetectionStrategy.OnPush, providers: [], }) export class UserEditFormComponent { diff --git a/console-webapp/src/app/users/users.component.html b/console-webapp/src/app/users/users.component.html index de1dcef3e77..9e9085ec527 100644 --- a/console-webapp/src/app/users/users.component.html +++ b/console-webapp/src/app/users/users.component.html @@ -1,16 +1,16 @@ - @if(isLoading) { + @if(isLoading()) {
- } @else if(selectingExistingUser) { + } @else if(selectingExistingUser()) {
-

Add existing user

+

Add existing user

@@ -32,9 +32,9 @@

Select registrar from which to add a new user

- @if(usersSelection.length) { + @if(usersSelection().length) {

@@ -50,7 +50,7 @@

Select registrar from which to add a new user

@@ -64,7 +64,7 @@

Select registrar from which to add a new user

} @else if(isNew) {
-

New User Form

+

New User Form