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
+
+ }
+
+ Show QR
+
+
+ Copy URL
+
+
+
+
+ Actions
+
+
+
+ @if (interface.serviceInterface().type === 'ui') {
+
+ Launch UI
+
+
+ Show QR
+
+
+ Copy URL
+
+ }
+
+
+
+
+
+
+ `,
+ 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) {
-
- Add Address
-
- }
- }
- `,
- 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 }}
-
-
-
- Launch
-
-
- Show QR code
-
-
- Copy URL
-
-
- Destroy
-
-
- `,
- 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) {
+
+ Add
+
+
+ Make {{ isPublic() ? 'private' : 'public' }}
+
+ }
+
+ @if (clearnet().length) {
+
+ @for (address of clearnet(); track $index) {
+
+ {{ address.label }}
+ {{ address.acme | acme }}
+ {{ address.url | mask }}
+
+
+ Delete
+
+
+ Delete
+
+
+
+ }
+
+ } @else {
+
+ No interfaces available
+ Add
+
+ }
+ `,
+ 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 }}
+
+
+ Delete
+
+
+ Delete
+
+
+
+ }
+
+ } @else {
+
+ No Tor addresses available
+ Add
+
+ }
+ `,
+ 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;