diff --git a/web/package-lock.json b/web/package-lock.json index 7dca1bc4c..ffed0a59b 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -25,18 +25,18 @@ "@noble/hashes": "^1.4.0", "@start9labs/argon2": "^0.2.2", "@start9labs/start-sdk": "file:../sdk/baseDist", - "@taiga-ui/addon-charts": "4.24.0", - "@taiga-ui/addon-commerce": "4.24.0", - "@taiga-ui/addon-mobile": "4.24.0", - "@taiga-ui/addon-table": "4.24.0", - "@taiga-ui/cdk": "4.24.0", - "@taiga-ui/core": "4.24.0", - "@taiga-ui/event-plugins": "4.4.0", - "@taiga-ui/icons": "4.24.0", - "@taiga-ui/kit": "4.24.0", - "@taiga-ui/layout": "4.24.0", - "@taiga-ui/legacy": "4.24.0", - "@taiga-ui/polymorpheus": "4.8.0", + "@taiga-ui/addon-charts": "4.28.0", + "@taiga-ui/addon-commerce": "4.28.0", + "@taiga-ui/addon-mobile": "4.28.0", + "@taiga-ui/addon-table": "4.28.0", + "@taiga-ui/cdk": "4.28.0", + "@taiga-ui/core": "4.28.0", + "@taiga-ui/event-plugins": "4.4.1", + "@taiga-ui/icons": "4.28.0", + "@taiga-ui/kit": "4.28.0", + "@taiga-ui/layout": "4.28.0", + "@taiga-ui/legacy": "4.28.0", + "@taiga-ui/polymorpheus": "4.9.0", "@tinkoff/ng-dompurify": "4.0.0", "ansi-to-html": "^0.7.2", "base64-js": "^1.5.1", @@ -3420,9 +3420,9 @@ } }, "node_modules/@maskito/angular": { - "version": "3.2.1", - "resolved": "https://registry.npmjs.org/@maskito/angular/-/angular-3.2.1.tgz", - "integrity": "sha512-Qb9qY6AeG23KWuROF2gcHzUxXiaaMIFuoA/ekqI4TAtBDEO2D7ImQ0oTkdsFfbDLZoI3phmy70H4f+gidEnfag==", + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/@maskito/angular/-/angular-3.4.0.tgz", + "integrity": "sha512-iMFP/siEgU9Ki+g1PReZlA5+LlBMp6inqXGG5KCezhmDleZnG5lL9gxk3+ktJvKu+2kayLcwyBeUKXPwMBVt9w==", "license": "Apache-2.0", "peer": true, "dependencies": { @@ -3431,35 +3431,35 @@ "peerDependencies": { "@angular/core": ">=16.0.0", "@angular/forms": ">=16.0.0", - "@maskito/core": "^3.2.1" + "@maskito/core": "^3.4.0" } }, "node_modules/@maskito/core": { - "version": "3.2.1", - "resolved": "https://registry.npmjs.org/@maskito/core/-/core-3.2.1.tgz", - "integrity": "sha512-gpoJbFFsq2CFz9smqEvZfTPVMt/gkRoiL14idL1sLXEDbWZyeKce0SgusHzHXaOkEegly4BIJGazCNG2hsPNYg==", + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/@maskito/core/-/core-3.4.0.tgz", + "integrity": "sha512-gFM6qk675YwOEGhxu9Xm6/sl1TZBRab6+B3Gstqml7xJopHHZ0rUOrWXwmX0z2JI+1PsgUL/ftV/CSZ8CpIONg==", "license": "Apache-2.0", "peer": true }, "node_modules/@maskito/kit": { - "version": "3.2.1", - "resolved": "https://registry.npmjs.org/@maskito/kit/-/kit-3.2.1.tgz", - "integrity": "sha512-p9Tmr4BMZs7geNWMYMQaP4Dc+Sre8OeE6Vynz1RGsJJdjxmocY2mihXKldecS8c9aXfHvLU658F9YtorL3Akjg==", + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/@maskito/kit/-/kit-3.4.0.tgz", + "integrity": "sha512-jkexr7wjAqFeMpyc7s0IlinL+3F9xC4BYUHDQcEqlAJisDgVFtGCZZK/RvV1C+HGDn2gtzzVrJ3G/OY66k6EXg==", "license": "Apache-2.0", "peer": true, "peerDependencies": { - "@maskito/core": "^3.2.1" + "@maskito/core": "^3.4.0" } }, "node_modules/@maskito/phone": { - "version": "3.2.1", - "resolved": "https://registry.npmjs.org/@maskito/phone/-/phone-3.2.1.tgz", - "integrity": "sha512-F1hrnpP1UyMhVO0nZAMoA0kkS8rVTc4kVUZ8mcxz98weIAKYo3wiuLOMXb1cg7/ejcpEnqDTRe1rzBxwtRAKeQ==", + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/@maskito/phone/-/phone-3.4.0.tgz", + "integrity": "sha512-KR6JuuWhTumIOCUV3CzPhh1niCXcuqsogNsLW3YfdmeVo8GygS9isnHNbSaAA/b9OnmIEkh25mur6x3yEJuYjA==", "license": "Apache-2.0", "peer": true, "peerDependencies": { - "@maskito/core": "^3.2.1", - "@maskito/kit": "^3.2.1", + "@maskito/core": "^3.4.0", + "@maskito/kit": "^3.4.0", "libphonenumber-js": ">=1.0.0" } }, @@ -4415,9 +4415,9 @@ "link": true }, "node_modules/@taiga-ui/addon-charts": { - "version": "4.24.0", - "resolved": "https://registry.npmjs.org/@taiga-ui/addon-charts/-/addon-charts-4.24.0.tgz", - "integrity": "sha512-x75l4Dj1l7ForatbIdk4O8NzM6fQlKRp9UsXzMrKSoKj9O3jGHsFyMV67FhBS9oVitU4+3MIgPLuj09qpG1gBg==", + "version": "4.28.0", + "resolved": "https://registry.npmjs.org/@taiga-ui/addon-charts/-/addon-charts-4.28.0.tgz", + "integrity": "sha512-Lvi2R8Y50kBbfbru31YHon+CEpnOzAx0G4GnqjN2goTLNQ6iX7pgUeyRyiXI4ay1yLrzVIOZJhSmBwWSDocZEg==", "license": "Apache-2.0", "dependencies": { "tslib": ">=2.8.1" @@ -4426,15 +4426,15 @@ "@angular/common": ">=16.0.0", "@angular/core": ">=16.0.0", "@ng-web-apis/common": "^4.11.1", - "@taiga-ui/cdk": "^4.24.0", - "@taiga-ui/core": "^4.24.0", + "@taiga-ui/cdk": "^4.28.0", + "@taiga-ui/core": "^4.28.0", "@taiga-ui/polymorpheus": "^4.8.0" } }, "node_modules/@taiga-ui/addon-commerce": { - "version": "4.24.0", - "resolved": "https://registry.npmjs.org/@taiga-ui/addon-commerce/-/addon-commerce-4.24.0.tgz", - "integrity": "sha512-YAbhTZHXnN5TT9oO+KJX/NAehuHgMZ/QoVIuzQR16HiIoKTQQWznSipThnX2hinXrLNBas0O2Teo4Ij5kGIEOQ==", + "version": "4.28.0", + "resolved": "https://registry.npmjs.org/@taiga-ui/addon-commerce/-/addon-commerce-4.28.0.tgz", + "integrity": "sha512-VYygBL7oySCZYLBimGJPx/VGGtUGhpes3XwBHAPBmmyiVxct0kxXzhCQdAvNMQcSvDzXDBjg3wmJiUbZA/uHGQ==", "license": "Apache-2.0", "dependencies": { "tslib": ">=2.8.1" @@ -4443,22 +4443,22 @@ "@angular/common": ">=16.0.0", "@angular/core": ">=16.0.0", "@angular/forms": ">=16.0.0", - "@maskito/angular": "^3.2.1", - "@maskito/core": "^3.2.1", - "@maskito/kit": "^3.2.1", + "@maskito/angular": "^3.4.0", + "@maskito/core": "^3.4.0", + "@maskito/kit": "^3.4.0", "@ng-web-apis/common": "^4.11.1", - "@taiga-ui/cdk": "^4.24.0", - "@taiga-ui/core": "^4.24.0", - "@taiga-ui/i18n": "^4.24.0", - "@taiga-ui/kit": "^4.24.0", + "@taiga-ui/cdk": "^4.28.0", + "@taiga-ui/core": "^4.28.0", + "@taiga-ui/i18n": "^4.28.0", + "@taiga-ui/kit": "^4.28.0", "@taiga-ui/polymorpheus": "^4.8.0", "rxjs": ">=7.0.0" } }, "node_modules/@taiga-ui/addon-mobile": { - "version": "4.24.0", - "resolved": "https://registry.npmjs.org/@taiga-ui/addon-mobile/-/addon-mobile-4.24.0.tgz", - "integrity": "sha512-YdkhhgWsmtgi85sPbjibqa/BxdqKkETav/6O0KgFmBGvS4MF/Hr2sOXWMDUfskSALee9BJBNPdf8NrV/zNpcXw==", + "version": "4.28.0", + "resolved": "https://registry.npmjs.org/@taiga-ui/addon-mobile/-/addon-mobile-4.28.0.tgz", + "integrity": "sha512-1RRaX37Ddl24q4nHrMEz6iDqHWi/mkTyXQ+kADX7+ydx9JkbU2H4R+qXrOx4+GUi93Y05HvAWCNCToBu3Ytt2A==", "license": "Apache-2.0", "dependencies": { "tslib": ">=2.8.1" @@ -4468,18 +4468,18 @@ "@angular/common": ">=16.0.0", "@angular/core": ">=16.0.0", "@ng-web-apis/common": "^4.11.1", - "@taiga-ui/cdk": "^4.24.0", - "@taiga-ui/core": "^4.24.0", - "@taiga-ui/kit": "^4.24.0", - "@taiga-ui/layout": "^4.24.0", + "@taiga-ui/cdk": "^4.28.0", + "@taiga-ui/core": "^4.28.0", + "@taiga-ui/kit": "^4.28.0", + "@taiga-ui/layout": "^4.28.0", "@taiga-ui/polymorpheus": "^4.8.0", "rxjs": ">=7.0.0" } }, "node_modules/@taiga-ui/addon-table": { - "version": "4.24.0", - "resolved": "https://registry.npmjs.org/@taiga-ui/addon-table/-/addon-table-4.24.0.tgz", - "integrity": "sha512-da/5FUmyrG6+pS9XgBY7B78EU6O1M9GPh9fxTtYTDjRgEpFdb/4TX0J79v5InSTGQV77ITVkpqbTB1moRgUVfA==", + "version": "4.28.0", + "resolved": "https://registry.npmjs.org/@taiga-ui/addon-table/-/addon-table-4.28.0.tgz", + "integrity": "sha512-C8MW6kJ3T9zy51rSxqYApll+S84oizK6C85gZyDM3gEV2RAlK2DP+r657ZlEwEgobrFCtBZe++TT7ZoKpQBBHg==", "license": "Apache-2.0", "dependencies": { "tslib": ">=2.8.1" @@ -4488,18 +4488,18 @@ "@angular/common": ">=16.0.0", "@angular/core": ">=16.0.0", "@ng-web-apis/intersection-observer": "^4.11.1", - "@taiga-ui/cdk": "^4.24.0", - "@taiga-ui/core": "^4.24.0", - "@taiga-ui/i18n": "^4.24.0", - "@taiga-ui/kit": "^4.24.0", + "@taiga-ui/cdk": "^4.28.0", + "@taiga-ui/core": "^4.28.0", + "@taiga-ui/i18n": "^4.28.0", + "@taiga-ui/kit": "^4.28.0", "@taiga-ui/polymorpheus": "^4.8.0", "rxjs": ">=7.0.0" } }, "node_modules/@taiga-ui/cdk": { - "version": "4.24.0", - "resolved": "https://registry.npmjs.org/@taiga-ui/cdk/-/cdk-4.24.0.tgz", - "integrity": "sha512-vsI+Jyzj4yfMolCg42OOyDvT30g1WFoupfOGlUk8A9R2HTVhMTeFeoA186A7c5KLI3e9fxj/60Rc9LbIbacnDA==", + "version": "4.28.0", + "resolved": "https://registry.npmjs.org/@taiga-ui/cdk/-/cdk-4.28.0.tgz", + "integrity": "sha512-P2vK+4WDnSt/nnilqxvDS4lyMAEH/M73z9YSzyH5mEwVTNxD3m82jJgpHqV5Re7geooAyaKqS6MJwDxaN0+9eQ==", "license": "Apache-2.0", "dependencies": { "tslib": "2.8.1" @@ -4522,15 +4522,15 @@ "@ng-web-apis/platform": "^4.11.1", "@ng-web-apis/resize-observer": "^4.11.1", "@ng-web-apis/screen-orientation": "^4.11.1", - "@taiga-ui/event-plugins": "^4.4.0", + "@taiga-ui/event-plugins": "^4.4.1", "@taiga-ui/polymorpheus": "^4.8.0", "rxjs": ">=7.0.0" } }, "node_modules/@taiga-ui/core": { - "version": "4.24.0", - "resolved": "https://registry.npmjs.org/@taiga-ui/core/-/core-4.24.0.tgz", - "integrity": "sha512-tHHxdwXtvMI8W2Ct1YgLZGg5UEDRPq3j3fYwLV0P/G+17TU1rVeF9/JxVe8ZdZ1Mo55LNLDflTnenC13GK0R/w==", + "version": "4.28.0", + "resolved": "https://registry.npmjs.org/@taiga-ui/core/-/core-4.28.0.tgz", + "integrity": "sha512-4eP6PJvmHZCrV/9apxfu6Bgj7L72yjVg1R5c4j1MsVmMESLCCRGlk0hPPvuxVQ+ZYrOZwNeWyKHPZDPL5uQawA==", "license": "Apache-2.0", "dependencies": { "tslib": ">=2.8.1" @@ -4544,17 +4544,17 @@ "@angular/router": ">=16.0.0", "@ng-web-apis/common": "^4.11.1", "@ng-web-apis/mutation-observer": "^4.11.1", - "@taiga-ui/cdk": "^4.24.0", - "@taiga-ui/event-plugins": "^4.4.0", - "@taiga-ui/i18n": "^4.24.0", + "@taiga-ui/cdk": "^4.28.0", + "@taiga-ui/event-plugins": "^4.4.1", + "@taiga-ui/i18n": "^4.28.0", "@taiga-ui/polymorpheus": "^4.8.0", "rxjs": ">=7.0.0" } }, "node_modules/@taiga-ui/event-plugins": { - "version": "4.4.0", - "resolved": "https://registry.npmjs.org/@taiga-ui/event-plugins/-/event-plugins-4.4.0.tgz", - "integrity": "sha512-Tv8C0p5EZXl7s1Vc+MrLbAblbYvyswomY/xvyFcI9NgMj6JyfsStu6jpCiRMfzojz3G70PRFsk0+WwI19lRJCQ==", + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/@taiga-ui/event-plugins/-/event-plugins-4.4.1.tgz", + "integrity": "sha512-gwEkgyZsbAdRfmb98KlKWivYVF88eP0bOtbHwfj8Ec8DgJ5809qFqeWvJEIxZZ829iox1m8z2UuVrqN2/tI1tQ==", "license": "Apache-2.0", "dependencies": { "tslib": "^2.3.0" @@ -4566,9 +4566,9 @@ } }, "node_modules/@taiga-ui/i18n": { - "version": "4.24.0", - "resolved": "https://registry.npmjs.org/@taiga-ui/i18n/-/i18n-4.24.0.tgz", - "integrity": "sha512-F3s+SGJ6QkGIWDpTcJvqiE++WHHF95o11tokQvzFq9pCLfMrxehNx38Dv/JnZK3FA2vYlVqdaQc0OsRMfMDRMw==", + "version": "4.28.0", + "resolved": "https://registry.npmjs.org/@taiga-ui/i18n/-/i18n-4.28.0.tgz", + "integrity": "sha512-kM7bbqllzir4nEk3X+YMKATm23UoKJeWSGmwnjLEmhWkpNAGqfErDRbE2puf+jXy7eufGhaB7ht/mK4+HkLXbw==", "license": "Apache-2.0", "peer": true, "dependencies": { @@ -4581,18 +4581,18 @@ } }, "node_modules/@taiga-ui/icons": { - "version": "4.24.0", - "resolved": "https://registry.npmjs.org/@taiga-ui/icons/-/icons-4.24.0.tgz", - "integrity": "sha512-VGWKMuVayab+/MJ60JuxRM2zTJG2WelDmUuTMdYqnh+zAWTn7FoPjDgAREiT7m84XryZdKtkUqGK6aLI+J62Sg==", + "version": "4.28.0", + "resolved": "https://registry.npmjs.org/@taiga-ui/icons/-/icons-4.28.0.tgz", + "integrity": "sha512-1QS7gvYHuTRUUodE58OXm+4Ree5FhFe0co0Lj+3sqeqkYb495z5q3CXBNiXD3y8IcDTjNuYkxKxEthbPnQrsVQ==", "license": "Apache-2.0", "dependencies": { "tslib": "^2.3.0" } }, "node_modules/@taiga-ui/kit": { - "version": "4.24.0", - "resolved": "https://registry.npmjs.org/@taiga-ui/kit/-/kit-4.24.0.tgz", - "integrity": "sha512-qz+OpsAyHWUT6fcfBPd+RJTv98J5USezh028B7TpSLSSyRE6mbklKIj2gteJQVnwVS0AXwm69WgQDXMVolbJnw==", + "version": "4.28.0", + "resolved": "https://registry.npmjs.org/@taiga-ui/kit/-/kit-4.28.0.tgz", + "integrity": "sha512-JEHUZhWU0vgPorvO3l9POzWKPbFQA57jFh9Iv5/RlWxMI8EUI+OKH5J8z1ptX+RJE2dWB9+Yi84zasgr8TWcSA==", "license": "Apache-2.0", "dependencies": { "tslib": ">=2.8.1" @@ -4602,25 +4602,25 @@ "@angular/core": ">=16.0.0", "@angular/forms": ">=16.0.0", "@angular/router": ">=16.0.0", - "@maskito/angular": "^3.2.1", - "@maskito/core": "^3.2.1", - "@maskito/kit": "^3.2.1", - "@maskito/phone": "^3.2.1", + "@maskito/angular": "^3.4.0", + "@maskito/core": "^3.4.0", + "@maskito/kit": "^3.4.0", + "@maskito/phone": "^3.4.0", "@ng-web-apis/common": "^4.11.1", "@ng-web-apis/intersection-observer": "^4.11.1", "@ng-web-apis/mutation-observer": "^4.11.1", "@ng-web-apis/resize-observer": "^4.11.1", - "@taiga-ui/cdk": "^4.24.0", - "@taiga-ui/core": "^4.24.0", - "@taiga-ui/i18n": "^4.24.0", + "@taiga-ui/cdk": "^4.28.0", + "@taiga-ui/core": "^4.28.0", + "@taiga-ui/i18n": "^4.28.0", "@taiga-ui/polymorpheus": "^4.8.0", "rxjs": ">=7.0.0" } }, "node_modules/@taiga-ui/layout": { - "version": "4.24.0", - "resolved": "https://registry.npmjs.org/@taiga-ui/layout/-/layout-4.24.0.tgz", - "integrity": "sha512-xwWB2iXeQWipzw0jTvqFL6V/20bhyM5lNT2AyAo6EcI8fX1N8x/RThaz9crf1jexajynHm4qirGT1M8tblg+Pw==", + "version": "4.28.0", + "resolved": "https://registry.npmjs.org/@taiga-ui/layout/-/layout-4.28.0.tgz", + "integrity": "sha512-NlXdEmXGhYvTWeSSpGlT9XS0SU1aQDuFAMFBSDVsZqLPWh2DTnNsxSf1/b6UYMmX5JKXhH/bRVvX97N5L5XZqQ==", "license": "Apache-2.0", "dependencies": { "tslib": ">=2.8.1" @@ -4628,17 +4628,17 @@ "peerDependencies": { "@angular/common": ">=16.0.0", "@angular/core": ">=16.0.0", - "@taiga-ui/cdk": "^4.24.0", - "@taiga-ui/core": "^4.24.0", - "@taiga-ui/kit": "^4.24.0", + "@taiga-ui/cdk": "^4.28.0", + "@taiga-ui/core": "^4.28.0", + "@taiga-ui/kit": "^4.28.0", "@taiga-ui/polymorpheus": "^4.8.0", "rxjs": ">=7.0.0" } }, "node_modules/@taiga-ui/legacy": { - "version": "4.24.0", - "resolved": "https://registry.npmjs.org/@taiga-ui/legacy/-/legacy-4.24.0.tgz", - "integrity": "sha512-J3CJwDuaVV1zMUunZWru6Syg1cFPL0uF8qWyAis+tgllfYxFpUstvJfKhqezSauXifFsDqcLJYKGu3/fMLIsmA==", + "version": "4.28.0", + "resolved": "https://registry.npmjs.org/@taiga-ui/legacy/-/legacy-4.28.0.tgz", + "integrity": "sha512-mWE5w7alYsT8GMBNTfcvrf/sJjh1li2/mTykH/aoWklgYHHmSt6moY4Myi8wKdlRFBzi82eXsvJcUSCwD8Y5ew==", "license": "Apache-2.0", "dependencies": { "tslib": ">=2.8.1" @@ -4648,9 +4648,9 @@ } }, "node_modules/@taiga-ui/polymorpheus": { - "version": "4.8.0", - "resolved": "https://registry.npmjs.org/@taiga-ui/polymorpheus/-/polymorpheus-4.8.0.tgz", - "integrity": "sha512-gNXk8SVxXf/5wtmm6XeFMQ9RzY0xbM9E4vFxSGwnNegVZtv3T08YX2uoxPgUbgck2/GS9N5B5KvjjbVa0T0L9A==", + "version": "4.9.0", + "resolved": "https://registry.npmjs.org/@taiga-ui/polymorpheus/-/polymorpheus-4.9.0.tgz", + "integrity": "sha512-TbIIwslbEnxunKuL9OyPZdmefrvJEK6HYiADEKQHUMUs4Pk2UbhMckUieURo83yPDamk/Mww+Nu/g60J/4uh2w==", "license": "Apache-2.0", "dependencies": { "tslib": "^2.8.1" @@ -9496,9 +9496,9 @@ } }, "node_modules/libphonenumber-js": { - "version": "1.11.19", - "resolved": "https://registry.npmjs.org/libphonenumber-js/-/libphonenumber-js-1.11.19.tgz", - "integrity": "sha512-bW/Yp/9dod6fmyR+XqSUL1N5JE7QRxQ3KrBIbYS1FTv32e5i3SEtQVX+71CYNv8maWNSOgnlCoNp9X78f/cKiA==", + "version": "1.12.6", + "resolved": "https://registry.npmjs.org/libphonenumber-js/-/libphonenumber-js-1.12.6.tgz", + "integrity": "sha512-PJiS4ETaUfCOFLpmtKzAbqZQjCCKVu2OhTV4SVNNE7c2nu/dACvtCqj4L0i/KWNnIgRv7yrILvBj5Lonv5Ncxw==", "license": "MIT", "peer": true }, diff --git a/web/package.json b/web/package.json index 0d0d76153..699ade972 100644 --- a/web/package.json +++ b/web/package.json @@ -47,18 +47,18 @@ "@noble/hashes": "^1.4.0", "@start9labs/argon2": "^0.2.2", "@start9labs/start-sdk": "file:../sdk/baseDist", - "@taiga-ui/addon-charts": "4.24.0", - "@taiga-ui/addon-commerce": "4.24.0", - "@taiga-ui/addon-mobile": "4.24.0", - "@taiga-ui/addon-table": "4.24.0", - "@taiga-ui/cdk": "4.24.0", - "@taiga-ui/core": "4.24.0", - "@taiga-ui/event-plugins": "4.4.0", - "@taiga-ui/icons": "4.24.0", - "@taiga-ui/kit": "4.24.0", - "@taiga-ui/layout": "4.24.0", - "@taiga-ui/legacy": "4.24.0", - "@taiga-ui/polymorpheus": "4.8.0", + "@taiga-ui/addon-charts": "4.28.0", + "@taiga-ui/addon-commerce": "4.28.0", + "@taiga-ui/addon-mobile": "4.28.0", + "@taiga-ui/addon-table": "4.28.0", + "@taiga-ui/cdk": "4.28.0", + "@taiga-ui/core": "4.28.0", + "@taiga-ui/event-plugins": "4.4.1", + "@taiga-ui/icons": "4.28.0", + "@taiga-ui/kit": "4.28.0", + "@taiga-ui/layout": "4.28.0", + "@taiga-ui/legacy": "4.28.0", + "@taiga-ui/polymorpheus": "4.9.0", "@tinkoff/ng-dompurify": "4.0.0", "ansi-to-html": "^0.7.2", "base64-js": "^1.5.1", diff --git a/web/projects/shared/styles/taiga.scss b/web/projects/shared/styles/taiga.scss index 82311fff4..68b4b97ca 100644 --- a/web/projects/shared/styles/taiga.scss +++ b/web/projects/shared/styles/taiga.scss @@ -127,6 +127,11 @@ tui-dropdown[data-appearance='start-os'][data-appearance='start-os'] { inset 0 1px rgba(255, 255, 255, 0.15), inset 0 0 1rem rgba(0, 0, 0, 0.25), var(--tui-shadow-medium); + + [tuiOption] { + justify-content: flex-start; + gap: 0.5rem; + } } [tuiSidebar] > div.t-wrapper { diff --git a/web/projects/ui/src/app/routes/portal/components/header/menu.component.ts b/web/projects/ui/src/app/routes/portal/components/header/menu.component.ts index d50276573..47cff2b07 100644 --- a/web/projects/ui/src/app/routes/portal/components/header/menu.component.ts +++ b/web/projects/ui/src/app/routes/portal/components/header/menu.component.ts @@ -105,11 +105,6 @@ import { ABOUT } from './about.component' opacity: 0.5; } - [tuiOption] { - justify-content: flex-start; - gap: 0.5rem; - } - :host-context(tui-root._mobile) { [tuiIconButton] { box-shadow: inset -1.25rem 0 0 -1rem var(--status); diff --git a/web/projects/ui/src/app/routes/portal/components/interfaces/acme.pipe.ts b/web/projects/ui/src/app/routes/portal/components/interfaces/acme.pipe.ts new file mode 100644 index 000000000..b30bc9221 --- /dev/null +++ b/web/projects/ui/src/app/routes/portal/components/interfaces/acme.pipe.ts @@ -0,0 +1,12 @@ +import { Pipe, PipeTransform } from '@angular/core' +import { toAcmeName } from 'src/app/utils/acme' + +@Pipe({ + standalone: true, + name: 'acme', +}) +export class AcmePipe implements PipeTransform { + transform(value: string | null = null): string { + return toAcmeName(value) + } +} diff --git a/web/projects/ui/src/app/routes/portal/components/interfaces/actions.component.ts b/web/projects/ui/src/app/routes/portal/components/interfaces/actions.component.ts new file mode 100644 index 000000000..7f5c97ea7 --- /dev/null +++ b/web/projects/ui/src/app/routes/portal/components/interfaces/actions.component.ts @@ -0,0 +1,129 @@ +import { + ChangeDetectionStrategy, + Component, + inject, + input, +} from '@angular/core' +import { CopyService } from '@start9labs/shared' +import { TuiResponsiveDialogService } from '@taiga-ui/addon-mobile' +import { + TuiButton, + tuiButtonOptionsProvider, + TuiDataList, + TuiDropdown, +} from '@taiga-ui/core' +import { PolymorpheusComponent } from '@taiga-ui/polymorpheus' +import { QRModal } from 'src/app/routes/portal/modals/qr.component' +import { InterfaceComponent } from './interface.component' + +@Component({ + standalone: true, + selector: 'td[actions]', + template: ` +
+ + @if (interface.serviceInterface().type === 'ui') { + + Launch UI + + } + + +
+
+ + + } + + + + + +
+ `, + styles: ` + :host { + text-align: right; + grid-area: 1 / 2 / 3 / 3; + } + + .desktop { + } + + .mobile { + display: none; + } + + :host-context(tui-root._mobile) { + .desktop { + display: none; + } + + .mobile { + display: block; + } + } + `, + imports: [TuiButton, TuiDropdown, TuiDataList], + providers: [tuiButtonOptionsProvider({ appearance: 'icon' })], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class InterfaceActionsComponent { + readonly dialogs = inject(TuiResponsiveDialogService) + readonly copyService = inject(CopyService) + readonly interface = inject(InterfaceComponent) + + readonly actions = input.required() + + showQR() { + this.dialogs + .open(new PolymorpheusComponent(QRModal), { + size: 'auto', + label: 'Interface URL', + data: this.actions(), + }) + .subscribe() + } +} diff --git a/web/projects/ui/src/app/routes/portal/components/interfaces/address-group.component.ts b/web/projects/ui/src/app/routes/portal/components/interfaces/address-group.component.ts deleted file mode 100644 index 98191c43f..000000000 --- a/web/projects/ui/src/app/routes/portal/components/interfaces/address-group.component.ts +++ /dev/null @@ -1,54 +0,0 @@ -import { TuiButton } from '@taiga-ui/core' -import { - ChangeDetectionStrategy, - Component, - Input, - inject, -} from '@angular/core' -import { AddressItemComponent } from './address-item.component' -import { AddressDetails, AddressesService } from './interface.utils' - -@Component({ - standalone: true, - selector: 'app-address-group', - template: ` -
- @if (addresses.length && !service.static) { - - } - -
- @for (address of addresses; track $index) { - - } @empty { - @if (!service.static) { - - } - } - `, - imports: [AddressItemComponent, TuiButton], - changeDetection: ChangeDetectionStrategy.OnPush, - styles: ` - .icon-add-btn { - float: right; - margin-left: 2rem; - } - `, -}) -export class AddressGroupComponent { - readonly service = inject(AddressesService) - - @Input({ required: true }) addresses!: AddressDetails[] -} diff --git a/web/projects/ui/src/app/routes/portal/components/interfaces/address-item.component.ts b/web/projects/ui/src/app/routes/portal/components/interfaces/address-item.component.ts deleted file mode 100644 index 70eafc03e..000000000 --- a/web/projects/ui/src/app/routes/portal/components/interfaces/address-item.component.ts +++ /dev/null @@ -1,97 +0,0 @@ -import { TuiCell } from '@taiga-ui/layout' -import { TuiBadge } from '@taiga-ui/kit' -import { NgIf } from '@angular/common' -import { - ChangeDetectionStrategy, - Component, - inject, - Input, -} from '@angular/core' -import { WA_WINDOW } from '@ng-web-apis/common' -import { CopyService } from '@start9labs/shared' -import { TuiDialogService, TuiTitle, TuiButton } from '@taiga-ui/core' -import { PolymorpheusComponent } from '@taiga-ui/polymorpheus' -import { QRModal } from 'src/app/routes/portal/modals/qr.component' -import { mask } from 'src/app/utils/mask' -import { InterfaceComponent } from './interface.component' -import { AddressesService } from './interface.utils' - -@Component({ - standalone: true, - selector: 'app-address-item', - template: ` -
- - {{ label }} - -

- - {{ interface.serviceInterface.masked ? mask : address }} - -

- - - - -
- `, - imports: [NgIf, TuiCell, TuiTitle, TuiButton, TuiBadge], - changeDetection: ChangeDetectionStrategy.OnPush, -}) -export class AddressItemComponent { - private readonly window = inject(WA_WINDOW) - private readonly dialogs = inject(TuiDialogService) - - readonly service = inject(AddressesService) - readonly copyService = inject(CopyService) - readonly interface = inject(InterfaceComponent) - - @Input() label?: string - @Input({ required: true }) address!: string - - get mask(): string { - return mask(this.address, 64) - } - - launch(url: string): void { - this.window.open(url, '_blank', 'noreferrer') - } - - showQR(data: string) { - this.dialogs - .open(new PolymorpheusComponent(QRModal), { - size: 'auto', - data, - }) - .subscribe() - } -} diff --git a/web/projects/ui/src/app/routes/portal/components/interfaces/clearnet.component.ts b/web/projects/ui/src/app/routes/portal/components/interfaces/clearnet.component.ts new file mode 100644 index 000000000..12eba0df4 --- /dev/null +++ b/web/projects/ui/src/app/routes/portal/components/interfaces/clearnet.component.ts @@ -0,0 +1,281 @@ +import { + ChangeDetectionStrategy, + Component, + computed, + inject, + input, +} from '@angular/core' +import { toSignal } from '@angular/core/rxjs-interop' +import { ErrorService, LoadingService } from '@start9labs/shared' +import { ISB, utils } from '@start9labs/start-sdk' +import { TuiResponsiveDialogService } from '@taiga-ui/addon-mobile' +import { + TuiAppearance, + TuiButton, + TuiDataList, + TuiDialogOptions, + TuiIcon, + TuiLink, +} from '@taiga-ui/core' +import { TUI_CONFIRM, TuiTooltip } from '@taiga-ui/kit' +import { PatchDB } from 'patch-db-client' +import { defaultIfEmpty, firstValueFrom, map } from 'rxjs' +import { + FormComponent, + FormContext, +} from 'src/app/routes/portal/components/form.component' +import { AcmePipe } from 'src/app/routes/portal/components/interfaces/acme.pipe' +import { InterfaceComponent } from 'src/app/routes/portal/components/interfaces/interface.component' +import { PlaceholderComponent } from 'src/app/routes/portal/components/placeholder.component' +import { TableComponent } from 'src/app/routes/portal/components/table.component' +import { ApiService } from 'src/app/services/api/embassy-api.service' +import { FormDialogService } from 'src/app/services/form-dialog.service' +import { DataModel } from 'src/app/services/patch-db/data-model' +import { toAcmeName } from 'src/app/utils/acme' +import { configBuilderToSpec } from 'src/app/utils/configBuilderToSpec' +import { InterfaceActionsComponent } from './actions.component' +import { AddressDetails } from './interface.utils' +import { MaskPipe } from './mask.pipe' + +type ClearnetForm = { + domain: string + acme: string +} + +@Component({ + standalone: true, + selector: 'section[clearnet]', + template: ` +
+ Clearnet + + + Add a clearnet address to expose this interface on the Internet. + Clearnet addresses are fully public and not anonymous. + + Learn More + + + @if (clearnet().length) { + + + } +
+ @if (clearnet().length) { + + @for (address of clearnet(); track $index) { + + + + + + + } +
{{ address.label }}{{ address.acme | acme }}{{ address.url | mask }} + + +
+ } @else { + + No interfaces available + + + } + `, + host: { class: 'g-card' }, + imports: [ + TuiButton, + TuiIcon, + TuiTooltip, + TuiLink, + TuiDataList, + TuiAppearance, + PlaceholderComponent, + TableComponent, + MaskPipe, + AcmePipe, + InterfaceActionsComponent, + ], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class InterfaceClearnetComponent { + private readonly dialogs = inject(TuiResponsiveDialogService) + private readonly formDialog = inject(FormDialogService) + private readonly loader = inject(LoadingService) + private readonly errorService = inject(ErrorService) + private readonly api = inject(ApiService) + + readonly interface = inject(InterfaceComponent) + readonly isPublic = computed(() => this.interface.serviceInterface().public) + + readonly clearnet = input.required() + readonly acme = toSignal( + inject>(PatchDB) + .watch$('serverInfo', 'network', 'acme') + .pipe(map(acme => Object.keys(acme))), + { initialValue: [] }, + ) + + async remove({ url }: AddressDetails) { + const confirm = await firstValueFrom( + this.dialogs + .open(TUI_CONFIRM, { label: 'Are you sure?', size: 's' }) + .pipe(defaultIfEmpty(false)), + ) + + if (!confirm) { + return + } + + const loader = this.loader.open('Removing').subscribe() + const params = { domain: new URL(url).hostname } + + try { + if (this.interface.packageId()) { + await this.api.pkgRemoveDomain({ + ...params, + package: this.interface.packageId(), + host: this.interface.serviceInterface().addressInfo.hostId, + }) + } else { + await this.api.serverRemoveDomain(params) + } + return true + } catch (e: any) { + this.errorService.handleError(e) + return false + } finally { + loader.unsubscribe() + } + } + + async toggle() { + const loader = this.loader + .open(`Making ${this.isPublic() ? 'private' : 'public'}`) + .subscribe() + + const params = { + internalPort: this.interface.serviceInterface().addressInfo.internalPort, + public: !this.isPublic(), + } + + try { + if (this.interface.packageId()) { + await this.api.pkgBindingSetPubic({ + ...params, + host: this.interface.serviceInterface().addressInfo.hostId, + package: this.interface.packageId(), + }) + } else { + await this.api.serverBindingSetPubic(params) + } + } catch (e: any) { + this.errorService.handleError(e) + } finally { + loader.unsubscribe() + } + } + + async add() { + const options: Partial>> = { + label: 'Select Domain/Subdomain', + data: { + spec: await configBuilderToSpec( + ISB.InputSpec.of({ + domain: ISB.Value.text({ + name: 'Domain', + description: 'The domain or subdomain you want to use', + placeholder: `e.g. 'mydomain.com' or 'sub.mydomain.com'`, + required: true, + default: null, + patterns: [utils.Patterns.domain], + }), + acme: ISB.Value.select({ + name: 'ACME Provider', + description: + 'Select which ACME provider to use for obtaining your SSL certificate. Add new ACME providers in the System tab. Optionally use your system Root CA. Note: only devices that have trusted your Root CA will be able to access the domain without security warnings.', + values: this.acme().reduce( + (obj, url) => ({ + ...obj, + [url]: toAcmeName(url), + }), + { none: 'None (use system Root CA)' } as Record, + ), + default: '', + }), + }), + ), + buttons: [ + { + text: 'Save', + handler: async value => this.save(value), + }, + ], + }, + } + this.formDialog.open(FormComponent, options) + } + + private async save(domainInfo: ClearnetForm): Promise { + const loader = this.loader.open('Saving...').subscribe() + + const { domain, acme } = domainInfo + + const params = { + domain, + acme: acme === 'none' ? null : acme, + private: false, + } + + try { + if (this.interface.packageId()) { + await this.api.pkgAddDomain({ + ...params, + package: this.interface.packageId(), + host: this.interface.serviceInterface().addressInfo.hostId, + }) + } else { + await this.api.serverAddDomain(params) + } + return true + } catch (e: any) { + this.errorService.handleError(e) + return false + } finally { + loader.unsubscribe() + } + } +} diff --git a/web/projects/ui/src/app/routes/portal/components/interfaces/directives/clearnet.directive.ts b/web/projects/ui/src/app/routes/portal/components/interfaces/directives/clearnet.directive.ts deleted file mode 100644 index 3edd47e39..000000000 --- a/web/projects/ui/src/app/routes/portal/components/interfaces/directives/clearnet.directive.ts +++ /dev/null @@ -1,111 +0,0 @@ -import { Directive, Input } from '@angular/core' -import { AddressesService } from '../interface.utils' -import { inject } from '@angular/core' -import { ErrorService, LoadingService } from '@start9labs/shared' -import { TuiDialogOptions } from '@taiga-ui/core' -import { - FormComponent, - FormContext, -} from 'src/app/routes/portal/components/form.component' -import { ApiService } from 'src/app/services/api/embassy-api.service' -import { FormDialogService } from 'src/app/services/form-dialog.service' -import { InterfaceComponent } from '../interface.component' -import { configBuilderToSpec } from 'src/app/utils/configBuilderToSpec' -import { ISB, utils } from '@start9labs/start-sdk' -import { toAcmeName } from 'src/app/utils/acme' - -type ClearnetForm = { - domain: string - acme: string -} - -@Directive({ - standalone: true, - selector: '[clearnetAddresses]', - providers: [ - { provide: AddressesService, useExisting: ClearnetAddressesDirective }, - ], -}) -export class ClearnetAddressesDirective implements AddressesService { - private readonly formDialog = inject(FormDialogService) - private readonly loader = inject(LoadingService) - private readonly errorService = inject(ErrorService) - private readonly api = inject(ApiService) - private readonly interface = inject(InterfaceComponent) - - @Input({ required: true }) acme!: string[] - - static = false - - async add() { - const options: Partial>> = { - label: 'Select Domain/Subdomain', - data: { - spec: await configBuilderToSpec( - ISB.InputSpec.of({ - domain: ISB.Value.text({ - name: 'Domain', - description: 'The domain or subdomain you want to use', - placeholder: `e.g. 'mydomain.com' or 'sub.mydomain.com'`, - required: true, - default: null, - patterns: [utils.Patterns.domain], - }), - acme: ISB.Value.select({ - name: 'ACME Provider', - description: - 'Select which ACME provider to use for obtaining your SSL certificate. Add new ACME providers in the System tab. Optionally use your system Root CA. Note: only devices that have trusted your Root CA will be able to access the domain without security warnings.', - values: this.acme.reduce( - (obj, url) => ({ - ...obj, - [url]: toAcmeName(url), - }), - { none: 'None (use system Root CA)' } as Record, - ), - default: '', - }), - }), - ), - buttons: [ - { - text: 'Save', - handler: async value => this.save(value), - }, - ], - }, - } - this.formDialog.open(FormComponent, options) - } - - async remove() {} - - private async save(domainInfo: ClearnetForm): Promise { - const loader = this.loader.open('Saving...').subscribe() - - const { domain, acme } = domainInfo - - const params = { - domain, - acme: acme === 'none' ? null : acme, - private: false, - } - - try { - if (this.interface.packageId) { - await this.api.pkgAddDomain({ - ...params, - package: this.interface.packageId, - host: this.interface.serviceInterface.addressInfo.hostId, - }) - } else { - await this.api.serverAddDomain(params) - } - return true - } catch (e: any) { - this.errorService.handleError(e) - return false - } finally { - loader.unsubscribe() - } - } -} diff --git a/web/projects/ui/src/app/routes/portal/components/interfaces/directives/local.directive.ts b/web/projects/ui/src/app/routes/portal/components/interfaces/directives/local.directive.ts deleted file mode 100644 index 38a5a973c..000000000 --- a/web/projects/ui/src/app/routes/portal/components/interfaces/directives/local.directive.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { Directive } from '@angular/core' -import { AddressesService } from '../interface.utils' - -@Directive({ - standalone: true, - selector: '[localAddresses]', - providers: [ - { provide: AddressesService, useExisting: LocalAddressesDirective }, - ], -}) -export class LocalAddressesDirective implements AddressesService { - static = true - async add() {} - async remove() {} -} diff --git a/web/projects/ui/src/app/routes/portal/components/interfaces/directives/tor.directive.ts b/web/projects/ui/src/app/routes/portal/components/interfaces/directives/tor.directive.ts deleted file mode 100644 index 133bae9aa..000000000 --- a/web/projects/ui/src/app/routes/portal/components/interfaces/directives/tor.directive.ts +++ /dev/null @@ -1,87 +0,0 @@ -import { Directive, inject } from '@angular/core' -import { AddressesService } from '../interface.utils' -import { configBuilderToSpec } from 'src/app/utils/configBuilderToSpec' -import { TuiDialogOptions } from '@taiga-ui/core' -import { FormComponent, FormContext } from '../../form.component' -import { ISB, utils } from '@start9labs/start-sdk' -import { FormDialogService } from 'src/app/services/form-dialog.service' -import { ErrorService, LoadingService } from '@start9labs/shared' -import { ApiService } from 'src/app/services/api/embassy-api.service' -import { InterfaceComponent } from '../interface.component' - -type OnionForm = { - key: string -} - -@Directive({ - standalone: true, - selector: '[torAddresses]', - providers: [ - { provide: AddressesService, useExisting: TorAddressesDirective }, - ], -}) -export class TorAddressesDirective implements AddressesService { - private readonly formDialog = inject(FormDialogService) - private readonly loader = inject(LoadingService) - private readonly errorService = inject(ErrorService) - private readonly api = inject(ApiService) - private readonly interface = inject(InterfaceComponent) - - static = false - - async add() { - const options: Partial>> = { - label: 'Select Domain/Subdomain', - data: { - spec: await configBuilderToSpec( - ISB.InputSpec.of({ - key: ISB.Value.text({ - name: 'Private Key (optional)', - description: - 'Optionally provide a base64-encoded ed25519 private key for generating the Tor V3 (.onion) address. If not provided, a random key will be generated and used.', - required: false, - default: null, - patterns: [utils.Patterns.base64], - }), - }), - ), - buttons: [ - { - text: 'Save', - handler: async value => this.save(value), - }, - ], - }, - } - this.formDialog.open(FormComponent, options) - } - - async remove() {} - - private async save(form: OnionForm): Promise { - const loader = this.loader.open('Saving...').subscribe() - - try { - let onion = form.key - ? await this.api.addTorKey({ key: form.key }) - : await this.api.generateTorKey({}) - onion = `${onion}.onion` - - if (this.interface.packageId) { - await this.api.pkgAddOnion({ - onion, - package: this.interface.packageId, - host: this.interface.serviceInterface.addressInfo.hostId, - }) - } else { - await this.api.serverAddOnion({ onion }) - } - return true - } catch (e: any) { - this.errorService.handleError(e) - return false - } finally { - loader.unsubscribe() - } - } -} diff --git a/web/projects/ui/src/app/routes/portal/components/interfaces/interface.component.ts b/web/projects/ui/src/app/routes/portal/components/interfaces/interface.component.ts index 46a0074b0..1f051808c 100644 --- a/web/projects/ui/src/app/routes/portal/components/interfaces/interface.component.ts +++ b/web/projects/ui/src/app/routes/portal/components/interfaces/interface.component.ts @@ -1,112 +1,42 @@ -import { CommonModule } from '@angular/common' -import { - ChangeDetectionStrategy, - Component, - inject, - Input, -} from '@angular/core' -import { T } from '@start9labs/start-sdk' -import { TuiSurface } from '@taiga-ui/core' -import { TuiCardLarge } from '@taiga-ui/layout' -import { PatchDB } from 'patch-db-client' -import { AddressGroupComponent } from 'src/app/routes/portal/components/interfaces/address-group.component' -import { DataModel } from 'src/app/services/patch-db/data-model' -import { ClearnetAddressesDirective } from './directives/clearnet.directive' -import { TorAddressesDirective } from './directives/tor.directive' -import { LocalAddressesDirective } from './directives/local.directive' -import { AddressDetails } from './interface.utils' -import { map } from 'rxjs' +import { ChangeDetectionStrategy, Component, input, Input } from '@angular/core' +import { tuiButtonOptionsProvider } from '@taiga-ui/core' +import { InterfaceClearnetComponent } from 'src/app/routes/portal/components/interfaces/clearnet.component' +import { InterfaceLocalComponent } from 'src/app/routes/portal/components/interfaces/local.component' +import { InterfaceTorComponent } from 'src/app/routes/portal/components/interfaces/tor.component' +import { MappedServiceInterface } from './interface.utils' @Component({ standalone: true, selector: 'app-interface', template: ` -

Clearnet

- - - Add a clearnet address to expose this interface on the Internet. - Clearnet addresses are fully public and not anonymous. - - Learn More - - - - -

Tor

- - - Add an onion address to anonymously expose this interface on the - darknet. Onion addresses can only be reached over the Tor network. - - Learn More - - - - -

Local

- - - Local addresses can only be accessed by devices connected to the same - LAN as your server, either directly or using a VPN. - - Learn More - - - +
+
+
`, + styles: ` + :host { + display: flex; + flex-direction: column; + gap: 1rem; + } + + section { + padding-block-end: 1rem; + } + + :host-context(tui-root:not(._mobile)) section ::ng-deep > header { + background: none; + } + `, + providers: [tuiButtonOptionsProvider({ size: 'xs' })], changeDetection: ChangeDetectionStrategy.OnPush, imports: [ - CommonModule, - AddressGroupComponent, - TuiCardLarge, - TuiSurface, - ClearnetAddressesDirective, - TorAddressesDirective, - LocalAddressesDirective, + InterfaceClearnetComponent, + InterfaceTorComponent, + InterfaceLocalComponent, ], }) export class InterfaceComponent { - readonly acme$ = inject>(PatchDB) - .watch$('serverInfo', 'network', 'acme') - .pipe(map(acme => Object.keys(acme))) - - @Input() packageId?: string - @Input({ required: true }) serviceInterface!: MappedServiceInterface -} - -export type MappedServiceInterface = T.ServiceInterface & { - addresses: { - clearnet: AddressDetails[] - local: AddressDetails[] - tor: AddressDetails[] - } + readonly packageId = input('') + readonly serviceInterface = input.required() } diff --git a/web/projects/ui/src/app/routes/portal/components/interfaces/interface.utils.ts b/web/projects/ui/src/app/routes/portal/components/interfaces/interface.utils.ts index 5aa45aebd..bb718d8c8 100644 --- a/web/projects/ui/src/app/routes/portal/components/interfaces/interface.utils.ts +++ b/web/projects/ui/src/app/routes/portal/components/interfaces/interface.utils.ts @@ -29,10 +29,7 @@ export function getAddresses( tor: AddressDetails[] } { const addressInfo = serviceInterface.addressInfo - - let hostnames = host.hostnameInfo[addressInfo.internalPort] - - hostnames = hostnames.filter( + const hostnames = host.hostnameInfo[addressInfo.internalPort].filter( h => config.isLocalhost() || h.kind !== 'ip' || @@ -121,7 +118,17 @@ export function getAddresses( } } +export type MappedServiceInterface = T.ServiceInterface & { + public: boolean + addresses: { + clearnet: AddressDetails[] + local: AddressDetails[] + tor: AddressDetails[] + } +} + export type AddressDetails = { label: string url: string + acme?: string | null } diff --git a/web/projects/ui/src/app/routes/portal/components/interfaces/local.component.ts b/web/projects/ui/src/app/routes/portal/components/interfaces/local.component.ts new file mode 100644 index 000000000..f1a5e753b --- /dev/null +++ b/web/projects/ui/src/app/routes/portal/components/interfaces/local.component.ts @@ -0,0 +1,52 @@ +import { ChangeDetectionStrategy, Component, input } from '@angular/core' +import { TuiIcon, TuiLink } from '@taiga-ui/core' +import { TuiTooltip } from '@taiga-ui/kit' +import { TableComponent } from 'src/app/routes/portal/components/table.component' +import { InterfaceActionsComponent } from './actions.component' +import { AddressDetails } from './interface.utils' +import { MaskPipe } from './mask.pipe' + +@Component({ + standalone: true, + selector: 'section[local]', + template: ` +
+ Local + + + Local addresses can only be accessed by devices connected to the same + LAN as your server, either directly or using a VPN. + + Learn More + + +
+ + @for (address of local(); track $index) { + + + + + + } +
{{ address.label }}{{ address.url | mask }}
+ `, + host: { class: 'g-card' }, + imports: [ + TuiIcon, + TuiTooltip, + TuiLink, + TableComponent, + InterfaceActionsComponent, + MaskPipe, + ], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class InterfaceLocalComponent { + readonly local = input.required() +} diff --git a/web/projects/ui/src/app/routes/portal/components/interfaces/mask.pipe.ts b/web/projects/ui/src/app/routes/portal/components/interfaces/mask.pipe.ts new file mode 100644 index 000000000..2a47a88b3 --- /dev/null +++ b/web/projects/ui/src/app/routes/portal/components/interfaces/mask.pipe.ts @@ -0,0 +1,16 @@ +import { inject, Pipe, PipeTransform } from '@angular/core' +import { InterfaceComponent } from './interface.component' + +@Pipe({ + standalone: true, + name: 'mask', +}) +export class MaskPipe implements PipeTransform { + private readonly interface = inject(InterfaceComponent) + + transform(value: string): string { + return this.interface.serviceInterface().masked + ? '●'.repeat(Math.min(64, value.length)) + : value + } +} diff --git a/web/projects/ui/src/app/routes/portal/components/interfaces/tor.component.ts b/web/projects/ui/src/app/routes/portal/components/interfaces/tor.component.ts new file mode 100644 index 000000000..6687420a6 --- /dev/null +++ b/web/projects/ui/src/app/routes/portal/components/interfaces/tor.component.ts @@ -0,0 +1,203 @@ +import { + ChangeDetectionStrategy, + Component, + inject, + input, +} from '@angular/core' +import { ErrorService, LoadingService } from '@start9labs/shared' +import { ISB, utils } from '@start9labs/start-sdk' +import { TuiResponsiveDialogService } from '@taiga-ui/addon-mobile' +import { + TuiAppearance, + TuiButton, + TuiDialogOptions, + TuiIcon, + TuiLink, + TuiOption, +} from '@taiga-ui/core' +import { TUI_CONFIRM, TuiTooltip } from '@taiga-ui/kit' +import { defaultIfEmpty, firstValueFrom } from 'rxjs' +import { + FormComponent, + FormContext, +} from 'src/app/routes/portal/components/form.component' +import { InterfaceComponent } from 'src/app/routes/portal/components/interfaces/interface.component' +import { PlaceholderComponent } from 'src/app/routes/portal/components/placeholder.component' +import { TableComponent } from 'src/app/routes/portal/components/table.component' +import { ApiService } from 'src/app/services/api/embassy-api.service' +import { FormDialogService } from 'src/app/services/form-dialog.service' +import { configBuilderToSpec } from 'src/app/utils/configBuilderToSpec' +import { InterfaceActionsComponent } from './actions.component' +import { AddressDetails } from './interface.utils' +import { MaskPipe } from './mask.pipe' + +type OnionForm = { + key: string +} + +@Component({ + standalone: true, + selector: 'section[tor]', + template: ` +
+ Tor + + + Add an onion address to anonymously expose this interface on the + darknet. Onion addresses can only be reached over the Tor network. + + Learn More + + +
+ @if (tor().length) { + + @for (address of tor(); track $index) { + + + + + + } +
{{ address.label }}{{ address.url | mask }} + + +
+ } @else { + + No Tor addresses available + + + } + `, + host: { class: 'g-card' }, + imports: [ + TuiButton, + TuiIcon, + TuiTooltip, + TuiLink, + TuiAppearance, + TuiOption, + TableComponent, + PlaceholderComponent, + MaskPipe, + InterfaceActionsComponent, + ], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class InterfaceTorComponent { + private readonly dialogs = inject(TuiResponsiveDialogService) + private readonly formDialog = inject(FormDialogService) + private readonly loader = inject(LoadingService) + private readonly errorService = inject(ErrorService) + private readonly api = inject(ApiService) + private readonly interface = inject(InterfaceComponent) + + readonly tor = input.required() + + async remove({ url }: AddressDetails) { + const confirm = await firstValueFrom( + this.dialogs + .open(TUI_CONFIRM, { label: 'Are you sure?', size: 's' }) + .pipe(defaultIfEmpty(false)), + ) + + if (!confirm) { + return + } + + const loader = this.loader.open('Removing').subscribe() + const params = { onion: new URL(url).hostname } + + try { + if (this.interface.packageId()) { + await this.api.pkgRemoveOnion({ + ...params, + package: this.interface.packageId(), + host: this.interface.serviceInterface().addressInfo.hostId, + }) + } else { + await this.api.serverRemoveOnion(params) + } + return true + } catch (e: any) { + this.errorService.handleError(e) + return false + } finally { + loader.unsubscribe() + } + } + + async add() { + const options: Partial>> = { + label: 'Select Domain/Subdomain', + data: { + spec: await configBuilderToSpec( + ISB.InputSpec.of({ + key: ISB.Value.text({ + name: 'Private Key (optional)', + description: + 'Optionally provide a base64-encoded ed25519 private key for generating the Tor V3 (.onion) address. If not provided, a random key will be generated and used.', + required: false, + default: null, + patterns: [utils.Patterns.base64], + }), + }), + ), + buttons: [ + { + text: 'Save', + handler: async value => this.save(value), + }, + ], + }, + } + this.formDialog.open(FormComponent, options) + } + + private async save(form: OnionForm): Promise { + const loader = this.loader.open('Saving...').subscribe() + + try { + let onion = form.key + ? await this.api.addTorKey({ key: form.key }) + : await this.api.generateTorKey({}) + onion = `${onion}.onion` + + if (this.interface.packageId) { + await this.api.pkgAddOnion({ + onion, + package: this.interface.packageId(), + host: this.interface.serviceInterface().addressInfo.hostId, + }) + } else { + await this.api.serverAddOnion({ onion }) + } + return true + } catch (e: any) { + this.errorService.handleError(e) + return false + } finally { + loader.unsubscribe() + } + } +} diff --git a/web/projects/ui/src/app/routes/portal/components/table.component.ts b/web/projects/ui/src/app/routes/portal/components/table.component.ts new file mode 100644 index 000000000..14dc29dd3 --- /dev/null +++ b/web/projects/ui/src/app/routes/portal/components/table.component.ts @@ -0,0 +1,21 @@ +import { ChangeDetectionStrategy, Component, input } from '@angular/core' + +@Component({ + standalone: true, + selector: 'table[appTable]', + template: ` + + + @for (header of appTable(); track $index) { + {{ header }} + } + + + + `, + host: { class: 'g-table' }, + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class TableComponent { + readonly appTable = input.required() +} diff --git a/web/projects/ui/src/app/routes/portal/modals/qr.component.ts b/web/projects/ui/src/app/routes/portal/modals/qr.component.ts index 2da7b15db..31b8043f7 100644 --- a/web/projects/ui/src/app/routes/portal/modals/qr.component.ts +++ b/web/projects/ui/src/app/routes/portal/modals/qr.component.ts @@ -6,7 +6,7 @@ import { QrCodeModule } from 'ng-qrcode' @Component({ standalone: true, selector: 'qr', - template: '', + template: '', changeDetection: ChangeDetectionStrategy.OnPush, imports: [QrCodeModule], }) diff --git a/web/projects/ui/src/app/routes/portal/routes/services/dashboard/dashboard.component.ts b/web/projects/ui/src/app/routes/portal/routes/services/dashboard/dashboard.component.ts index c8c9b23a4..d2067a72a 100644 --- a/web/projects/ui/src/app/routes/portal/routes/services/dashboard/dashboard.component.ts +++ b/web/projects/ui/src/app/routes/portal/routes/services/dashboard/dashboard.component.ts @@ -73,7 +73,7 @@ export default class DashboardComponent { getInstalledPrimaryStatus(b) > getInstalledPrimaryStatus(a) ? -1 : 1 readonly uptime: TuiComparator = (a, b) => - a.startedAt || '' > b.startedAt || '' ? -1 : 1 + a.status.started || '' > a.status.started || '' ? -1 : 1 sorter = this.name } diff --git a/web/projects/ui/src/app/routes/portal/routes/services/dashboard/service.component.ts b/web/projects/ui/src/app/routes/portal/routes/services/dashboard/service.component.ts index 30f37119b..45d514a8f 100644 --- a/web/projects/ui/src/app/routes/portal/routes/services/dashboard/service.component.ts +++ b/web/projects/ui/src/app/routes/portal/routes/services/dashboard/service.component.ts @@ -27,7 +27,7 @@ import { StatusComponent } from './status.component' {{ manifest.title }} {{ manifest.version }} - + table { + > table[tuiTable] { margin: 0 -0.5rem; td:empty, @@ -131,15 +131,15 @@ hr { > header { position: absolute; - top: 0; - left: 0; - right: 0; + inset: 0 0 auto 0; + height: 3rem; display: flex; align-items: center; - padding: 0.5rem 1rem; - background: var(--tui-background-neutral-1); + gap: 0.25rem; + padding: 0 1rem; font: var(--tui-font-text-l); font-weight: bold; + background: var(--tui-background-neutral-1); } > footer { @@ -152,45 +152,51 @@ hr { .g-table:not([tuiTable]) { width: stretch; - min-width: 40rem; + border: 1px solid var(--tui-background-neutral-1); border-spacing: 0; + border-collapse: collapse; + border-radius: var(--tui-radius-s); + overflow: hidden; + box-shadow: inset 0 0 0 1px var(--tui-background-neutral-1); td, th { position: relative; font: var(--tui-font-text-s); - text-align: left; height: 2rem; - padding: 0 0.25rem; - box-shadow: inset 0 -1px var(--tui-background-neutral-1); + padding: 0.5rem 0.75rem; + border: 1px solid var(--tui-background-neutral-1); + border-left: 0; + border-right: 0; text-overflow: ellipsis; } th { background: var(--tui-background-neutral-1); font-weight: bold; + text-align: left; } tui-root._mobile & { min-width: 0; + border: none; + box-shadow: none; + border-radius: 0; + color: var(--tui-text-secondary); thead { display: none; } - tbody { - display: flex; - flex-direction: column; - gap: 0.5rem; - } - tr { position: relative; display: grid; - border-radius: var(--tui-radius-l); - padding: 0.375rem 0.5rem; - // TODO: Theme - background: rgba(0, 0, 0, 0.2); + padding: 0.5rem 0; + box-shadow: inset 0 -1px var(--tui-background-neutral-1); + + &:last-child { + box-shadow: none; + } } tr:has(:checked) { @@ -200,10 +206,13 @@ hr { td, th { position: static; - height: auto; - min-height: 1.5rem; - align-content: center; - box-shadow: none; + border: none; + padding: 0; + + &:first-child { + font-weight: bold; + color: var(--tui-text-primary); + } &:not([tuiFade]) { overflow: hidden;