mirror of
https://github.com/Start9Labs/start-os.git
synced 2026-03-26 10:21:52 +00:00
Merge branch 'feature/proxies' of github.com:Start9Labs/start-os into feature/proxies
This commit is contained in:
200
web/package-lock.json
generated
200
web/package-lock.json
generated
@@ -25,19 +25,19 @@
|
||||
"@noble/hashes": "^1.4.0",
|
||||
"@start9labs/argon2": "^0.3.0",
|
||||
"@start9labs/start-sdk": "file:../sdk/baseDist",
|
||||
"@taiga-ui/addon-charts": "4.44.0",
|
||||
"@taiga-ui/addon-commerce": "4.44.0",
|
||||
"@taiga-ui/addon-mobile": "4.44.0",
|
||||
"@taiga-ui/addon-table": "4.44.0",
|
||||
"@taiga-ui/cdk": "4.44.0",
|
||||
"@taiga-ui/core": "4.44.0",
|
||||
"@taiga-ui/addon-charts": "4.47.0",
|
||||
"@taiga-ui/addon-commerce": "4.47.0",
|
||||
"@taiga-ui/addon-mobile": "4.47.0",
|
||||
"@taiga-ui/addon-table": "4.47.0",
|
||||
"@taiga-ui/cdk": "4.47.0",
|
||||
"@taiga-ui/core": "4.47.0",
|
||||
"@taiga-ui/dompurify": "4.1.11",
|
||||
"@taiga-ui/event-plugins": "4.6.0",
|
||||
"@taiga-ui/experimental": "4.44.0",
|
||||
"@taiga-ui/icons": "4.44.0",
|
||||
"@taiga-ui/kit": "4.44.0",
|
||||
"@taiga-ui/layout": "4.44.0",
|
||||
"@taiga-ui/legacy": "4.44.0",
|
||||
"@taiga-ui/experimental": "4.47.0",
|
||||
"@taiga-ui/icons": "4.47.0",
|
||||
"@taiga-ui/kit": "4.47.0",
|
||||
"@taiga-ui/layout": "4.47.0",
|
||||
"@taiga-ui/legacy": "4.47.0",
|
||||
"@taiga-ui/polymorpheus": "4.9.0",
|
||||
"ansi-to-html": "^0.7.2",
|
||||
"base64-js": "^1.5.1",
|
||||
@@ -2978,9 +2978,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@maskito/angular": {
|
||||
"version": "3.10.0",
|
||||
"resolved": "https://registry.npmjs.org/@maskito/angular/-/angular-3.10.0.tgz",
|
||||
"integrity": "sha512-5WwzV12MLJoCUD4ROEafUmyrElzGesWI4BqAFkh9jzzQRtrF1QNomK9tOVBXmXUBWb5sohiiNViAvRCtGdyXiA==",
|
||||
"version": "3.10.2",
|
||||
"resolved": "https://registry.npmjs.org/@maskito/angular/-/angular-3.10.2.tgz",
|
||||
"integrity": "sha512-+CQ7KQGmu35THj/59Uex+GotMFzdLHFUlPj5X5qphl+tHX09atmRzx7SEUCSEErbftTLafAFeR5N5t1fVTJvmw==",
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
@@ -2989,35 +2989,35 @@
|
||||
"peerDependencies": {
|
||||
"@angular/core": ">=16.0.0",
|
||||
"@angular/forms": ">=16.0.0",
|
||||
"@maskito/core": "^3.10.0"
|
||||
"@maskito/core": "^3.10.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@maskito/core": {
|
||||
"version": "3.10.0",
|
||||
"resolved": "https://registry.npmjs.org/@maskito/core/-/core-3.10.0.tgz",
|
||||
"integrity": "sha512-T3PaMb4ipMmN9hkaAj8uyN0Mqj8XcXMZ1GRZ2WfZePRPHoi/L3tEEEh7vjg1m4TpI3lReRkNQs9yaPZV9ce8HA==",
|
||||
"version": "3.10.2",
|
||||
"resolved": "https://registry.npmjs.org/@maskito/core/-/core-3.10.2.tgz",
|
||||
"integrity": "sha512-LKh/PrG5wtMQ4AFYrWkKVGJUQB2CJcIt59qMPhntYIBpjw/OHWboHD4WWWQ94GvkYKjKQyjMcS/zvx+JaDrx2A==",
|
||||
"license": "Apache-2.0",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/@maskito/kit": {
|
||||
"version": "3.10.0",
|
||||
"resolved": "https://registry.npmjs.org/@maskito/kit/-/kit-3.10.0.tgz",
|
||||
"integrity": "sha512-b/aN200U0w/tNfLPRiXJaHGZRNVimq7UnhjKYoLXejX1+pKKhQ6S/dVg9k0+30IXdmUJ5Uk29y5X3UBc5d1w8A==",
|
||||
"version": "3.10.2",
|
||||
"resolved": "https://registry.npmjs.org/@maskito/kit/-/kit-3.10.2.tgz",
|
||||
"integrity": "sha512-d0YHheVt+DYZDL+A4uwoF0pF/rofczHz0KKYEuQrSdbKlRxOdyckQrj9iMCsmD73Hwne7LbjLL/rViHL4aFL2Q==",
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"peerDependencies": {
|
||||
"@maskito/core": "^3.10.0"
|
||||
"@maskito/core": "^3.10.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@maskito/phone": {
|
||||
"version": "3.10.0",
|
||||
"resolved": "https://registry.npmjs.org/@maskito/phone/-/phone-3.10.0.tgz",
|
||||
"integrity": "sha512-FrjC0l/SyLvSH7w+MG9v3lVT3OnD098dVCBR8HZlL6l5oI1Y69LTEBgRrNWIso841V8AEmj1ryJwrjHWX/zF5Q==",
|
||||
"version": "3.10.2",
|
||||
"resolved": "https://registry.npmjs.org/@maskito/phone/-/phone-3.10.2.tgz",
|
||||
"integrity": "sha512-XP/mp7CTHYriy6U+zoIitlJCGCmMr+yxtJ/u5y9+S4H3T1siILU3K8CAqetxpK//8/Zopco8lyz1D7ASKofdRg==",
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"peerDependencies": {
|
||||
"@maskito/core": "^3.10.0",
|
||||
"@maskito/kit": "^3.10.0",
|
||||
"@maskito/core": "^3.10.2",
|
||||
"@maskito/kit": "^3.10.2",
|
||||
"libphonenumber-js": ">=1.0.0"
|
||||
}
|
||||
},
|
||||
@@ -4713,9 +4713,9 @@
|
||||
"link": true
|
||||
},
|
||||
"node_modules/@taiga-ui/addon-charts": {
|
||||
"version": "4.44.0",
|
||||
"resolved": "https://registry.npmjs.org/@taiga-ui/addon-charts/-/addon-charts-4.44.0.tgz",
|
||||
"integrity": "sha512-NiwY1P1NkDEOiSWgo3EGmXBWFmltmKA+Xkbu7fHnH7+8oenYLWd/orNvQEU5ey6qiSGj6wVr7kyeQMP8aau3NQ==",
|
||||
"version": "4.47.0",
|
||||
"resolved": "https://registry.npmjs.org/@taiga-ui/addon-charts/-/addon-charts-4.47.0.tgz",
|
||||
"integrity": "sha512-BLMw9zNBJp2tC9PyuG0+7j5VrAL4QFrngGvVlSALjWy9Caj/4mHaoDp9PUwAQrsuoFIMc6BwTbDdbO6/DJeVUQ==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"tslib": ">=2.8.1"
|
||||
@@ -4724,15 +4724,15 @@
|
||||
"@angular/common": ">=16.0.0",
|
||||
"@angular/core": ">=16.0.0",
|
||||
"@ng-web-apis/common": "^4.12.0",
|
||||
"@taiga-ui/cdk": "^4.44.0",
|
||||
"@taiga-ui/core": "^4.44.0",
|
||||
"@taiga-ui/cdk": "^4.47.0",
|
||||
"@taiga-ui/core": "^4.47.0",
|
||||
"@taiga-ui/polymorpheus": "^4.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@taiga-ui/addon-commerce": {
|
||||
"version": "4.44.0",
|
||||
"resolved": "https://registry.npmjs.org/@taiga-ui/addon-commerce/-/addon-commerce-4.44.0.tgz",
|
||||
"integrity": "sha512-hpYDis6cFDewm1PR6CTXDoOvGmPbqJwcrl1wOyAfmGWCY4rle9+Jj1P1fW4tDMkOY/TVhQ3GIuSUYn/UZN9v4A==",
|
||||
"version": "4.47.0",
|
||||
"resolved": "https://registry.npmjs.org/@taiga-ui/addon-commerce/-/addon-commerce-4.47.0.tgz",
|
||||
"integrity": "sha512-Vh9kbQ47mUT6et3gc2/yJ7N6vebXDPRLHWBhpXxrXNVwel/dQT84NkvNBRJEPeSi2KRQNAy/qlxDdBVWUOFInw==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"tslib": ">=2.8.1"
|
||||
@@ -4741,22 +4741,22 @@
|
||||
"@angular/common": ">=16.0.0",
|
||||
"@angular/core": ">=16.0.0",
|
||||
"@angular/forms": ">=16.0.0",
|
||||
"@maskito/angular": "^3.10.0",
|
||||
"@maskito/core": "^3.10.0",
|
||||
"@maskito/kit": "^3.10.0",
|
||||
"@maskito/angular": "^3.10.2",
|
||||
"@maskito/core": "^3.10.2",
|
||||
"@maskito/kit": "^3.10.2",
|
||||
"@ng-web-apis/common": "^4.12.0",
|
||||
"@taiga-ui/cdk": "^4.44.0",
|
||||
"@taiga-ui/core": "^4.44.0",
|
||||
"@taiga-ui/i18n": "^4.44.0",
|
||||
"@taiga-ui/kit": "^4.44.0",
|
||||
"@taiga-ui/cdk": "^4.47.0",
|
||||
"@taiga-ui/core": "^4.47.0",
|
||||
"@taiga-ui/i18n": "^4.47.0",
|
||||
"@taiga-ui/kit": "^4.47.0",
|
||||
"@taiga-ui/polymorpheus": "^4.9.0",
|
||||
"rxjs": ">=7.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@taiga-ui/addon-mobile": {
|
||||
"version": "4.44.0",
|
||||
"resolved": "https://registry.npmjs.org/@taiga-ui/addon-mobile/-/addon-mobile-4.44.0.tgz",
|
||||
"integrity": "sha512-NTV6DpyrI6Pv9FuXiYwOsOljyKkrKX+HJ35SRev1hrxzw9ECKrnOA1Q0aUCn09PgWIUY7rV+OXUZJ/w/Hmlm5Q==",
|
||||
"version": "4.47.0",
|
||||
"resolved": "https://registry.npmjs.org/@taiga-ui/addon-mobile/-/addon-mobile-4.47.0.tgz",
|
||||
"integrity": "sha512-rJeJUpXgEJyNNriiqLmVB7w8H+dSiECXjj1LRvSPffuL5bmvtBJKq8nw5Lpy+M3xeZz5qIMndJPNxSsKmkB0JA==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"tslib": ">=2.8.1"
|
||||
@@ -4766,18 +4766,18 @@
|
||||
"@angular/common": ">=16.0.0",
|
||||
"@angular/core": ">=16.0.0",
|
||||
"@ng-web-apis/common": "^4.12.0",
|
||||
"@taiga-ui/cdk": "^4.44.0",
|
||||
"@taiga-ui/core": "^4.44.0",
|
||||
"@taiga-ui/kit": "^4.44.0",
|
||||
"@taiga-ui/layout": "^4.44.0",
|
||||
"@taiga-ui/cdk": "^4.47.0",
|
||||
"@taiga-ui/core": "^4.47.0",
|
||||
"@taiga-ui/kit": "^4.47.0",
|
||||
"@taiga-ui/layout": "^4.47.0",
|
||||
"@taiga-ui/polymorpheus": "^4.9.0",
|
||||
"rxjs": ">=7.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@taiga-ui/addon-table": {
|
||||
"version": "4.44.0",
|
||||
"resolved": "https://registry.npmjs.org/@taiga-ui/addon-table/-/addon-table-4.44.0.tgz",
|
||||
"integrity": "sha512-DmWZCPouoF21gWOpABepncWdg8W4Enk2z8FTiwGMsN0nGXmQ1SnU2QwEHCLumW2XNiNP8EHUpU42MZf9CuwIeA==",
|
||||
"version": "4.47.0",
|
||||
"resolved": "https://registry.npmjs.org/@taiga-ui/addon-table/-/addon-table-4.47.0.tgz",
|
||||
"integrity": "sha512-5ZarUauEfPhr+S+nJIXZjyifcvim6Yi00cADI+0PmgitolArjHEB4ZrdosS+Iqfqj9znKF6gPkcmDlv6i0eMCg==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"tslib": ">=2.8.1"
|
||||
@@ -4786,18 +4786,18 @@
|
||||
"@angular/common": ">=16.0.0",
|
||||
"@angular/core": ">=16.0.0",
|
||||
"@ng-web-apis/intersection-observer": "^4.12.0",
|
||||
"@taiga-ui/cdk": "^4.44.0",
|
||||
"@taiga-ui/core": "^4.44.0",
|
||||
"@taiga-ui/i18n": "^4.44.0",
|
||||
"@taiga-ui/kit": "^4.44.0",
|
||||
"@taiga-ui/cdk": "^4.47.0",
|
||||
"@taiga-ui/core": "^4.47.0",
|
||||
"@taiga-ui/i18n": "^4.47.0",
|
||||
"@taiga-ui/kit": "^4.47.0",
|
||||
"@taiga-ui/polymorpheus": "^4.9.0",
|
||||
"rxjs": ">=7.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@taiga-ui/cdk": {
|
||||
"version": "4.44.0",
|
||||
"resolved": "https://registry.npmjs.org/@taiga-ui/cdk/-/cdk-4.44.0.tgz",
|
||||
"integrity": "sha512-4x0ISp+0oYhTC2E9SN2yDhA+rFC707m/rL4lf7RSTIbdefMyP3TdmsN2Emuhc0WSXcNs0OgYYEOaIx9jr7OqDw==",
|
||||
"version": "4.47.0",
|
||||
"resolved": "https://registry.npmjs.org/@taiga-ui/cdk/-/cdk-4.47.0.tgz",
|
||||
"integrity": "sha512-TqUg+7p/IZqlk34IB4/ZqTfw7HXifX2SqL9psCEmtW5Pg9zWN9of0S9g6Ccj9ALF+4Q4JxuHQf/xQdtAXdYdqg==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"tslib": "2.8.1"
|
||||
@@ -4807,7 +4807,7 @@
|
||||
"@angular-devkit/schematics": ">=16.0.0",
|
||||
"@schematics/angular": ">=16.0.0",
|
||||
"ng-morph": "^4.8.4",
|
||||
"parse5": ">=7.3.0"
|
||||
"parse5": "^7.3.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@angular/animations": ">=16.0.0",
|
||||
@@ -4826,9 +4826,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@taiga-ui/core": {
|
||||
"version": "4.44.0",
|
||||
"resolved": "https://registry.npmjs.org/@taiga-ui/core/-/core-4.44.0.tgz",
|
||||
"integrity": "sha512-KQPD63ZoFJKBZS/m1XkyTXDzUH3IYDcgNE3CVeZ9bmEkRwS0x7/fpFFdbr06sR/ej/eEzgop3LKOk4JHoQgFCA==",
|
||||
"version": "4.47.0",
|
||||
"resolved": "https://registry.npmjs.org/@taiga-ui/core/-/core-4.47.0.tgz",
|
||||
"integrity": "sha512-Z/6djJcMWn4/gFcdW6BDd7GU5tkGnidmfVFhSMCFoRYIY2YU7USjp6wYkjC9jhpRYZZVWVwY3IX4/GJGG5gLQg==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"tslib": ">=2.8.1"
|
||||
@@ -4842,9 +4842,9 @@
|
||||
"@angular/router": ">=16.0.0",
|
||||
"@ng-web-apis/common": "^4.12.0",
|
||||
"@ng-web-apis/mutation-observer": "^4.12.0",
|
||||
"@taiga-ui/cdk": "^4.44.0",
|
||||
"@taiga-ui/cdk": "^4.47.0",
|
||||
"@taiga-ui/event-plugins": "^4.6.0",
|
||||
"@taiga-ui/i18n": "^4.44.0",
|
||||
"@taiga-ui/i18n": "^4.47.0",
|
||||
"@taiga-ui/polymorpheus": "^4.9.0",
|
||||
"rxjs": ">=7.0.0"
|
||||
}
|
||||
@@ -4879,9 +4879,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@taiga-ui/experimental": {
|
||||
"version": "4.44.0",
|
||||
"resolved": "https://registry.npmjs.org/@taiga-ui/experimental/-/experimental-4.44.0.tgz",
|
||||
"integrity": "sha512-ef/fSFjSOTwgsqRoWlwlXQkQrPcbuAlmEyGj4gMhdZC99HvKuYzUdihH4eZX4nx8fDyBOOZbM7QnW7ETQbKWHg==",
|
||||
"version": "4.47.0",
|
||||
"resolved": "https://registry.npmjs.org/@taiga-ui/experimental/-/experimental-4.47.0.tgz",
|
||||
"integrity": "sha512-EkswWcDKwtDhjA5A5oMtYHw9fnRBITpVb3cOwG45lQrlFBP0tipmx2QVlKKu0ga8VPjcv3W+b/pZsOWdp7UkJQ==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"tslib": ">=2.8.1"
|
||||
@@ -4889,18 +4889,18 @@
|
||||
"peerDependencies": {
|
||||
"@angular/common": ">=16.0.0",
|
||||
"@angular/core": ">=16.0.0",
|
||||
"@taiga-ui/addon-commerce": "^4.44.0",
|
||||
"@taiga-ui/cdk": "^4.44.0",
|
||||
"@taiga-ui/core": "^4.44.0",
|
||||
"@taiga-ui/kit": "^4.44.0",
|
||||
"@taiga-ui/addon-commerce": "^4.47.0",
|
||||
"@taiga-ui/cdk": "^4.47.0",
|
||||
"@taiga-ui/core": "^4.47.0",
|
||||
"@taiga-ui/kit": "^4.47.0",
|
||||
"@taiga-ui/polymorpheus": "^4.9.0",
|
||||
"rxjs": ">=7.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@taiga-ui/i18n": {
|
||||
"version": "4.44.0",
|
||||
"resolved": "https://registry.npmjs.org/@taiga-ui/i18n/-/i18n-4.44.0.tgz",
|
||||
"integrity": "sha512-SnRQQPKI1k3Tkn17yByuB1ix4Nj9v70RLxVM84GX3okgQk2NIAj9hn8WXd36ljZUlcy7TqtJ+xlii0FqQVv7Sw==",
|
||||
"version": "4.47.0",
|
||||
"resolved": "https://registry.npmjs.org/@taiga-ui/i18n/-/i18n-4.47.0.tgz",
|
||||
"integrity": "sha512-TC9BugM8W7IgIXy3IoLtEWlxIb0xAxm17bfAtVLH9M8BfuQY6Jk0yHVQAspmaEz8pmaoLx9Tl2bXV9ugYG/ypA==",
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
@@ -4913,18 +4913,18 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@taiga-ui/icons": {
|
||||
"version": "4.44.0",
|
||||
"resolved": "https://registry.npmjs.org/@taiga-ui/icons/-/icons-4.44.0.tgz",
|
||||
"integrity": "sha512-KiIxxkG59kJDFV/g6e1YAbBff7y7EynNbmKY6sGo+CLcAQ52Y7Cz6i9us6ySf8fbOQRm4hKDXL3M10TKDeS+cg==",
|
||||
"version": "4.47.0",
|
||||
"resolved": "https://registry.npmjs.org/@taiga-ui/icons/-/icons-4.47.0.tgz",
|
||||
"integrity": "sha512-/SV6RdsCZoX5uUIlHiAPMLNNY7j8nxmE8NhGFtj4E5szx+V84LIKh2oES+zawPa7lIcVUT5M+FtXAtqdMiQa2g==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"tslib": "^2.3.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@taiga-ui/kit": {
|
||||
"version": "4.44.0",
|
||||
"resolved": "https://registry.npmjs.org/@taiga-ui/kit/-/kit-4.44.0.tgz",
|
||||
"integrity": "sha512-X8JNEE/WR7ftEx/bmibe2JTPOwge/o1VYHk1Z2vDx53e8JWWG+lLce2xx7NYexFPqP+4Ey2bPxn1Vx+rgGZYgA==",
|
||||
"version": "4.47.0",
|
||||
"resolved": "https://registry.npmjs.org/@taiga-ui/kit/-/kit-4.47.0.tgz",
|
||||
"integrity": "sha512-7LZNA4QInvB76Q38DRD1Ba2vIiKrpk4b/IoSmpahojYU1nrYByY69jGGD7ImxmCMJaIxymaODwHTcEJd3hW4Sw==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"tslib": ">=2.8.1"
|
||||
@@ -4934,25 +4934,25 @@
|
||||
"@angular/core": ">=16.0.0",
|
||||
"@angular/forms": ">=16.0.0",
|
||||
"@angular/router": ">=16.0.0",
|
||||
"@maskito/angular": "^3.10.0",
|
||||
"@maskito/core": "^3.10.0",
|
||||
"@maskito/kit": "^3.10.0",
|
||||
"@maskito/phone": "^3.10.0",
|
||||
"@maskito/angular": "^3.10.2",
|
||||
"@maskito/core": "^3.10.2",
|
||||
"@maskito/kit": "^3.10.2",
|
||||
"@maskito/phone": "^3.10.2",
|
||||
"@ng-web-apis/common": "^4.12.0",
|
||||
"@ng-web-apis/intersection-observer": "^4.12.0",
|
||||
"@ng-web-apis/mutation-observer": "^4.12.0",
|
||||
"@ng-web-apis/resize-observer": "^4.12.0",
|
||||
"@taiga-ui/cdk": "^4.44.0",
|
||||
"@taiga-ui/core": "^4.44.0",
|
||||
"@taiga-ui/i18n": "^4.44.0",
|
||||
"@taiga-ui/cdk": "^4.47.0",
|
||||
"@taiga-ui/core": "^4.47.0",
|
||||
"@taiga-ui/i18n": "^4.47.0",
|
||||
"@taiga-ui/polymorpheus": "^4.9.0",
|
||||
"rxjs": ">=7.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@taiga-ui/layout": {
|
||||
"version": "4.44.0",
|
||||
"resolved": "https://registry.npmjs.org/@taiga-ui/layout/-/layout-4.44.0.tgz",
|
||||
"integrity": "sha512-aidH7MgEAcz0nxauc5tm+0a5Z7PmG9bsSq+tsfkiOd6mV4BOf5xyYlpDyQn9gUBK8UYSxtMojp9Zs/2jH57gmw==",
|
||||
"version": "4.47.0",
|
||||
"resolved": "https://registry.npmjs.org/@taiga-ui/layout/-/layout-4.47.0.tgz",
|
||||
"integrity": "sha512-/P1dhno9/gvUT8lkbJGbNE8etm2D4G+2nRJAQwgj2Az2VePdR39nTOq0NKlGPyyjvSGi21/Mq2Hr5rRig3aglw==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"tslib": ">=2.8.1"
|
||||
@@ -4960,17 +4960,17 @@
|
||||
"peerDependencies": {
|
||||
"@angular/common": ">=16.0.0",
|
||||
"@angular/core": ">=16.0.0",
|
||||
"@taiga-ui/cdk": "^4.44.0",
|
||||
"@taiga-ui/core": "^4.44.0",
|
||||
"@taiga-ui/kit": "^4.44.0",
|
||||
"@taiga-ui/cdk": "^4.47.0",
|
||||
"@taiga-ui/core": "^4.47.0",
|
||||
"@taiga-ui/kit": "^4.47.0",
|
||||
"@taiga-ui/polymorpheus": "^4.9.0",
|
||||
"rxjs": ">=7.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@taiga-ui/legacy": {
|
||||
"version": "4.44.0",
|
||||
"resolved": "https://registry.npmjs.org/@taiga-ui/legacy/-/legacy-4.44.0.tgz",
|
||||
"integrity": "sha512-vVXEj2BkKAS3ShOQ8OV6+j2yGFcRqtXMxZEU3kX8iM4fWRYS3C1669LLK7f0VzZd1miNex79SZx3NJM4Rvx7Kg==",
|
||||
"version": "4.47.0",
|
||||
"resolved": "https://registry.npmjs.org/@taiga-ui/legacy/-/legacy-4.47.0.tgz",
|
||||
"integrity": "sha512-+82AOSKr2D/d8WSwdHZxW1BdL1fAh7HQEyhjCNT00hCaeQAeQ5QZNzDRX3qxFKwWZPWkC/sG8a/AtDtjkjB7Ew==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"tslib": ">=2.8.1"
|
||||
@@ -8456,9 +8456,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/libphonenumber-js": {
|
||||
"version": "1.12.9",
|
||||
"resolved": "https://registry.npmjs.org/libphonenumber-js/-/libphonenumber-js-1.12.9.tgz",
|
||||
"integrity": "sha512-VWwAdNeJgN7jFOD+wN4qx83DTPMVPPAUyx9/TUkBXKLiNkuWWk6anV0439tgdtwaJDrEdqkvdN22iA6J4bUCZg==",
|
||||
"version": "1.12.10",
|
||||
"resolved": "https://registry.npmjs.org/libphonenumber-js/-/libphonenumber-js-1.12.10.tgz",
|
||||
"integrity": "sha512-E91vHJD61jekHHR/RF/E83T/CMoaLXT7cwYA75T4gim4FZjnM6hbJjVIGg7chqlSqRsSvQ3izGmOjHy1SQzcGQ==",
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
|
||||
@@ -46,18 +46,18 @@
|
||||
"@noble/hashes": "^1.4.0",
|
||||
"@start9labs/argon2": "^0.3.0",
|
||||
"@start9labs/start-sdk": "file:../sdk/baseDist",
|
||||
"@taiga-ui/addon-charts": "4.44.0",
|
||||
"@taiga-ui/addon-commerce": "4.44.0",
|
||||
"@taiga-ui/addon-mobile": "4.44.0",
|
||||
"@taiga-ui/addon-table": "4.44.0",
|
||||
"@taiga-ui/cdk": "4.44.0",
|
||||
"@taiga-ui/core": "4.44.0",
|
||||
"@taiga-ui/addon-charts": "4.47.0",
|
||||
"@taiga-ui/addon-commerce": "4.47.0",
|
||||
"@taiga-ui/addon-mobile": "4.47.0",
|
||||
"@taiga-ui/addon-table": "4.47.0",
|
||||
"@taiga-ui/cdk": "4.47.0",
|
||||
"@taiga-ui/core": "4.47.0",
|
||||
"@taiga-ui/event-plugins": "4.6.0",
|
||||
"@taiga-ui/experimental": "4.44.0",
|
||||
"@taiga-ui/icons": "4.44.0",
|
||||
"@taiga-ui/kit": "4.44.0",
|
||||
"@taiga-ui/layout": "4.44.0",
|
||||
"@taiga-ui/legacy": "4.44.0",
|
||||
"@taiga-ui/experimental": "4.47.0",
|
||||
"@taiga-ui/icons": "4.47.0",
|
||||
"@taiga-ui/kit": "4.47.0",
|
||||
"@taiga-ui/layout": "4.47.0",
|
||||
"@taiga-ui/legacy": "4.47.0",
|
||||
"@taiga-ui/polymorpheus": "4.9.0",
|
||||
"@taiga-ui/dompurify": "4.1.11",
|
||||
"ansi-to-html": "^0.7.2",
|
||||
|
||||
@@ -44,7 +44,7 @@ import { DocsLinkDirective } from '@start9labs/shared'
|
||||
Download your server's Root CA and
|
||||
<a
|
||||
docsLink
|
||||
href="/user-manual/trust-ca.html"
|
||||
path="/user-manual/trust-ca.html"
|
||||
style="color: #6866cc; font-weight: bold; text-decoration: none"
|
||||
>
|
||||
follow the instructions
|
||||
@@ -110,7 +110,7 @@ import { DocsLinkDirective } from '@start9labs/shared'
|
||||
This address will only work from a Tor-enabled browser.
|
||||
<a
|
||||
docsLink
|
||||
href="/user-manual/connecting-remotely/tor.html"
|
||||
path="/user-manual/connecting-remotely/tor.html"
|
||||
style="color: #6866cc; font-weight: bold; text-decoration: none"
|
||||
>
|
||||
Follow the instructions
|
||||
|
||||
@@ -19,11 +19,13 @@ export const VERSION = new InjectionToken<string>('VERSION')
|
||||
export class DocsLinkDirective {
|
||||
private readonly version = inject(VERSION)
|
||||
|
||||
readonly href = input.required<string>()
|
||||
readonly path = input.required<string>()
|
||||
|
||||
readonly fragment = input<string>('')
|
||||
|
||||
protected readonly url = computed(() => {
|
||||
const path = this.href()
|
||||
const path = this.path()
|
||||
const relative = path.startsWith('/') ? path : `/${path}`
|
||||
return `https://docs.start9.com${relative}?os=${this.version}`
|
||||
return `https://docs.start9.com${relative}?os=${this.version}${this.fragment()}`
|
||||
})
|
||||
}
|
||||
|
||||
@@ -179,7 +179,6 @@ export default {
|
||||
177: 'Kernelspeicher',
|
||||
178: 'Leerlauf',
|
||||
179: 'I/O-Wartezeit',
|
||||
180: 'ACME',
|
||||
181: 'Gesamt',
|
||||
182: 'Verwendet',
|
||||
183: 'Verfügbar',
|
||||
@@ -294,14 +293,12 @@ export default {
|
||||
296: 'Hochladen',
|
||||
297: 'Version 1 s9pk erkannt. Dieses Format ist veraltet. Falls nötig, kann ein V1 s9pk über start-cli installiert werden.',
|
||||
298: 'Ungültige Paketdatei',
|
||||
299: 'Fügen Sie ACME-Anbieter hinzu, um SSL-(https)-Zertifikate für den Clearnet-Zugriff zu generieren.',
|
||||
299: 'Das Hinzufügen einer Domain zu StartOS bedeutet, dass du sie und ihre Subdomains verwenden kannst, um Service-Oberflächen im öffentlichen Internet zu hosten.',
|
||||
300: 'Anleitung anzeigen',
|
||||
301: 'Gespeicherte Anbieter',
|
||||
302: 'Anbieter hinzufügen',
|
||||
303: 'Kontakt',
|
||||
304: 'Bearbeiten',
|
||||
305: 'ACME-Anbieter hinzufügen',
|
||||
306: 'ACME-Anbieter bearbeiten',
|
||||
305: 'Zertifizierungsstelle hinzufügen',
|
||||
306: 'Kontaktinformationen bearbeiten',
|
||||
307: 'Kontakt-E-Mails',
|
||||
308: 'Erforderlich, um ein Zertifikat von einer Zertifizierungsstelle zu erhalten',
|
||||
309: 'Alle umschalten',
|
||||
@@ -528,12 +525,20 @@ export default {
|
||||
530: 'StartOS-Paket',
|
||||
531: 'Fehler beim Initialisieren des Servers',
|
||||
532: 'Abgeschlossen',
|
||||
533: 'Eingehende Proxys',
|
||||
534: 'Eingehende Proxys ermöglichen den Fernzugriff auf Ihren Server und installierte Dienste.',
|
||||
535: 'Gespeicherte Proxys',
|
||||
536: 'Proxy hinzufügen',
|
||||
537: 'Bezeichnung',
|
||||
538: 'Keine Proxys',
|
||||
539: 'Bezeichnung aktualisieren',
|
||||
540: 'Umbenennen',
|
||||
533: 'Gateways',
|
||||
534: 'Gateways verbinden Ihren Server mit dem Internet. Sie verarbeiten ausgehenden Datenverkehr und erlauben unter bestimmten Bedingungen auch eingehenden Verkehr.',
|
||||
535: 'Gateway hinzufügen',
|
||||
536: 'Umbenennen',
|
||||
537: 'Zugriff',
|
||||
538: 'Domains',
|
||||
539: 'Zertifizierungsstellen',
|
||||
540: 'Domain',
|
||||
541: 'Gateway',
|
||||
542: 'Standard-Zertifizierungsstelle',
|
||||
543: 'Zertifizierungsstelle',
|
||||
544: 'Domain bearbeiten',
|
||||
545: 'Keine Domains',
|
||||
546: 'Anbieter',
|
||||
547: 'DNS anzeigen',
|
||||
548: 'DNS testen',
|
||||
} satisfies i18n
|
||||
|
||||
@@ -178,7 +178,6 @@ export const ENGLISH = {
|
||||
'Kernel space': 177,
|
||||
'Idle': 178, // a CPU metric
|
||||
'I/O wait': 179,
|
||||
'ACME': 180,
|
||||
'Total': 181,
|
||||
'Used': 182,
|
||||
'Available': 183,
|
||||
@@ -293,14 +292,12 @@ export const ENGLISH = {
|
||||
'Upload': 296,
|
||||
'Version 1 s9pk detected. This package format is deprecated. You can sideload a V1 s9pk via start-cli if necessary.': 297,
|
||||
'Invalid package file': 298,
|
||||
'Add ACME providers in order to generate SSL (https) certificates for clearnet access.': 299,
|
||||
'Adding a domain to StartOS means you can use it and its subdomains to host service interfaces on the public Internet.': 299,
|
||||
'View instructions': 300,
|
||||
'Saved Providers': 301, // as in, ACME service provider, such as Let's Encrypt
|
||||
'Add Provider': 302,
|
||||
'Contact': 303, // as in, "contact us"
|
||||
'Edit': 304,
|
||||
'Add ACME Provider': 305,
|
||||
'Edit ACME Provider': 306,
|
||||
'Add Certificate Authority': 305,
|
||||
'Edit Contact Info': 306,
|
||||
'Contact Emails': 307,
|
||||
'Needed to obtain a certificate from a Certificate Authority': 308,
|
||||
'Toggle all': 309,
|
||||
@@ -527,12 +524,20 @@ export const ENGLISH = {
|
||||
'StartOS package': 530, // as in, the URL of the source code for the StartOS package
|
||||
'Error initializing server': 531,
|
||||
'Finished': 532, // an in, complete
|
||||
'Inbound Proxies': 533, // as in a service used to proxy internet traffic
|
||||
'Inbound proxies provide remote access to your server and installed services.': 534,
|
||||
'Saved Proxies': 535, // as in, a list of proxies already added to StartOS
|
||||
'Add Proxy': 536, // as in, add a new proxy to StartOS
|
||||
'Label': 537, // as in, a name given to something
|
||||
'No proxies': 538,
|
||||
'Update Label': 539,
|
||||
'Rename': 540
|
||||
'Gateways': 533, // as in, a device or software that connects two different networks
|
||||
'Gateways connect your server to the Internet. They process outbound traffic, and under certain conditions, they also permit inbound traffic.': 534,
|
||||
'Add Gateway': 535, // as in, add a new network gateway to StartOS
|
||||
'Rename': 536,
|
||||
'Access': 537, // as in, public or private access, almost "permission"
|
||||
'Domains': 538, // as in, internet domains
|
||||
'Certificate Authorities': 539,
|
||||
'Domain': 540, // as in, an internat domain name
|
||||
'Gateway': 541, // as in, a device or software that connects two different networks
|
||||
'Default Certificate Authority': 542,
|
||||
'Certificate Authority': 543,
|
||||
'Edit Domain': 544,
|
||||
'No domains': 545,
|
||||
'Provider': 546,
|
||||
'Show DNS': 547,
|
||||
'Test DNS': 548,
|
||||
} as const
|
||||
|
||||
@@ -179,7 +179,6 @@ export default {
|
||||
177: 'Espacio del kernel',
|
||||
178: 'Inactivo',
|
||||
179: 'Espera de E/S',
|
||||
180: 'ACME',
|
||||
181: 'Total',
|
||||
182: 'Usado',
|
||||
183: 'Disponible',
|
||||
@@ -294,14 +293,13 @@ export default {
|
||||
296: 'Subir',
|
||||
297: 'Se detectó un paquete s9pk de versión 1. Este formato está obsoleto. Puedes instalarlo manualmente con start-cli si es necesario.',
|
||||
298: 'Archivo de paquete inválido',
|
||||
299: 'Agrega proveedores ACME para generar certificados SSL (https) para el acceso desde clearnet.',
|
||||
299: 'Agregar un dominio a StartOS significa que puedes usarlo y sus subdominios para alojar interfaces de servicios en Internet público.',
|
||||
|
||||
300: 'Ver instrucciones',
|
||||
301: 'Proveedores guardados',
|
||||
302: 'Agregar proveedor',
|
||||
303: 'Contacto',
|
||||
304: 'Editar',
|
||||
305: 'Agregar proveedor ACME',
|
||||
306: 'Editar proveedor ACME',
|
||||
305: 'Agregar autoridad certificadora',
|
||||
306: 'Editar información de contacto',
|
||||
307: 'Correos de contacto',
|
||||
308: 'Necesarios para obtener un certificado de una Autoridad Certificadora',
|
||||
309: 'Alternar todo',
|
||||
@@ -528,12 +526,20 @@ export default {
|
||||
530: 'Paquete StartOS',
|
||||
531: 'Error al inicializar el servidor',
|
||||
532: 'Finalizado',
|
||||
533: 'Proxies entrantes',
|
||||
534: 'Los proxies entrantes proporcionan acceso remoto a su servidor y servicios instalados.',
|
||||
535: 'Proxies guardados',
|
||||
536: 'Agregar proxy',
|
||||
537: 'Etiqueta',
|
||||
538: 'Sin proxies',
|
||||
539: 'Actualizar etiqueta',
|
||||
540: 'Renombrar',
|
||||
533: 'Puertas de enlace',
|
||||
534: 'Las puertas de enlace conectan su servidor a Internet. Procesan el tráfico saliente y, en ciertas condiciones, también permiten tráfico entrante.',
|
||||
535: 'Agregar puerta de enlace',
|
||||
536: 'Renombrar',
|
||||
537: 'Acceso',
|
||||
538: 'Dominios',
|
||||
539: 'Autoridades certificadoras',
|
||||
540: 'Dominio',
|
||||
541: 'Puerta de enlace',
|
||||
542: 'Autoridad certificadora predeterminada',
|
||||
543: 'Autoridad certificadora',
|
||||
544: 'Editar dominio',
|
||||
545: 'Sin dominios',
|
||||
546: 'Proveedor',
|
||||
547: 'Mostrar DNS',
|
||||
548: 'Probar DNS',
|
||||
} satisfies i18n
|
||||
|
||||
@@ -179,7 +179,6 @@ export default {
|
||||
177: 'Espace noyau',
|
||||
178: 'Inactif',
|
||||
179: 'Attente E/S',
|
||||
180: 'ACME',
|
||||
181: 'Total',
|
||||
182: 'Utilisé',
|
||||
183: 'Disponible',
|
||||
@@ -294,14 +293,12 @@ export default {
|
||||
296: 'Téléverser',
|
||||
297: 'Version 1 de s9pk détectée. Ce format de paquet est obsolète. Vous pouvez installer manuellement un s9pk V1 via start-cli si nécessaire.',
|
||||
298: 'Fichier paquet invalide',
|
||||
299: 'Ajoutez des fournisseurs ACME pour générer des certificats SSL (https) pour l’accès clearnet.',
|
||||
299: 'Ajouter un domaine à StartOS signifie que vous pouvez l’utiliser, ainsi que ses sous-domaines, pour héberger des interfaces de services sur Internet public.',
|
||||
300: 'Voir les instructions',
|
||||
301: 'Fournisseurs enregistrés',
|
||||
302: 'Ajouter un fournisseur',
|
||||
303: 'Contact',
|
||||
304: 'Modifier',
|
||||
305: 'Ajouter un fournisseur ACME',
|
||||
306: 'Modifier le fournisseur ACME',
|
||||
305: 'Ajouter une autorité de certification',
|
||||
306: 'Modifier les informations de contact',
|
||||
307: 'Emails de contact',
|
||||
308: 'Nécessaire pour obtenir un certificat d’une autorité de certification',
|
||||
309: 'Tout cocher',
|
||||
@@ -528,12 +525,20 @@ export default {
|
||||
530: 'Paquet StartOS',
|
||||
531: "Erreur lors de l'initialisation du serveur",
|
||||
532: 'Terminé',
|
||||
533: 'Proxies entrants',
|
||||
534: 'Les proxies entrants permettent un accès à distance à votre serveur et aux services installés.',
|
||||
535: 'Proxies enregistrés',
|
||||
536: 'Ajouter un proxy',
|
||||
537: 'Étiquette',
|
||||
538: 'Aucun proxy',
|
||||
539: 'Mettre à jour l’étiquette',
|
||||
540: 'Renommer',
|
||||
533: 'Passerelles',
|
||||
534: 'Les passerelles connectent votre serveur à Internet. Elles traitent le trafic sortant et, dans certaines conditions, autorisent également le trafic entrant.',
|
||||
535: 'Ajouter une passerelle',
|
||||
536: 'Renommer',
|
||||
537: 'Accès',
|
||||
538: 'Domaines',
|
||||
539: 'Autorités de certification',
|
||||
540: 'Domaine',
|
||||
541: 'Passerelle',
|
||||
542: 'Autorité de certification par défaut',
|
||||
543: 'Autorité de certification',
|
||||
544: 'Modifier le domaine',
|
||||
545: 'Aucun domaine',
|
||||
546: 'Fournisseur',
|
||||
547: 'Afficher le DNS',
|
||||
548: 'Tester le DNS',
|
||||
} satisfies i18n
|
||||
|
||||
@@ -179,7 +179,6 @@ export default {
|
||||
177: 'Przestrzeń jądra',
|
||||
178: 'Bezczynność',
|
||||
179: 'Oczekiwanie na I/O',
|
||||
180: 'ACME',
|
||||
181: 'Łącznie',
|
||||
182: 'Wykorzystane',
|
||||
183: 'Dostępne',
|
||||
@@ -294,14 +293,12 @@ export default {
|
||||
296: 'Prześlij',
|
||||
297: 'Wykryto pakiet s9pk w wersji 1. Ten format pakietu jest przestarzały. Możesz zainstalować pakiet s9pk V1 przez start-cli, jeśli to konieczne.',
|
||||
298: 'Nieprawidłowy plik pakietu',
|
||||
299: 'Dodaj dostawców ACME, aby wygenerować certyfikaty SSL (https) dla dostępu przez clearnet.',
|
||||
299: 'Dodanie domeny do StartOS oznacza, że możesz używać jej i jej subdomen do hostowania interfejsów usług w publicznym Internecie.',
|
||||
300: 'Zobacz instrukcje',
|
||||
301: 'Zapisani dostawcy',
|
||||
302: 'Dodaj dostawcę',
|
||||
303: 'Kontakt',
|
||||
304: 'Edytuj',
|
||||
305: 'Dodaj dostawcę ACME',
|
||||
306: 'Edytuj dostawcę ACME',
|
||||
305: 'Dodaj urząd certyfikacji',
|
||||
306: 'Edytuj dane kontaktowe',
|
||||
307: 'Adresy e-mail kontaktowe',
|
||||
308: 'Wymagane do uzyskania certyfikatu od urzędu certyfikacji',
|
||||
309: 'Zaznacz wszystkie',
|
||||
@@ -528,12 +525,20 @@ export default {
|
||||
530: 'Pakiet StartOS',
|
||||
531: 'Błąd inicjalizacji serwera',
|
||||
532: 'Zakończono',
|
||||
533: 'Proksy przychodzące',
|
||||
534: 'Proksy przychodzące zapewniają zdalny dostęp do twojego serwera i zainstalowanych usług.',
|
||||
535: 'Zapisane proksy',
|
||||
536: 'Dodaj proksy',
|
||||
537: 'Etykieta',
|
||||
538: 'Brak proksy',
|
||||
539: 'Aktualizuj etykietę',
|
||||
540: 'Zmień nazwę',
|
||||
533: 'Bramy sieciowe',
|
||||
534: 'Bramy łączą twój serwer z Internetem. Przetwarzają ruch wychodzący, a w pewnych warunkach również dopuszczają ruch przychodzący.',
|
||||
535: 'Dodaj bramę',
|
||||
536: 'Zmień nazwę',
|
||||
537: 'Dostęp',
|
||||
538: 'Domeny',
|
||||
539: 'Urzędy certyfikacji',
|
||||
540: 'Domena',
|
||||
541: 'Brama',
|
||||
542: 'Domyślny urząd certyfikacji',
|
||||
543: 'Urząd certyfikacji',
|
||||
544: 'Edytuj domenę',
|
||||
545: 'Brak domen',
|
||||
546: 'Dostawca',
|
||||
547: 'Pokaż DNS',
|
||||
548: 'Test DNS',
|
||||
} satisfies i18n
|
||||
|
||||
@@ -49,13 +49,13 @@ export class DialogService {
|
||||
}
|
||||
},
|
||||
) {
|
||||
options.data = options.data || {}
|
||||
const { content, yes, no } = options.data
|
||||
const { content, yes, no } = options.data || {}
|
||||
|
||||
return this.dialogs.open<T>(TUI_CONFIRM, {
|
||||
...options,
|
||||
label: this.i18n.transform(options.label),
|
||||
data: {
|
||||
...options.data,
|
||||
...(options.data || {}),
|
||||
content: isI18n(content) ? this.i18n.transform(content) : content,
|
||||
yes: this.i18n.transform(yes),
|
||||
no: this.i18n.transform(no),
|
||||
|
||||
@@ -46,7 +46,7 @@
|
||||
tuiButton
|
||||
docsLink
|
||||
size="s"
|
||||
href="/user-manual/trust-ca.html"
|
||||
path="/user-manual/trust-ca.html"
|
||||
iconEnd="@tui.external-link"
|
||||
>
|
||||
{{ 'View instructions' | i18n }}
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
<label tuiInputFiles [(ngModel)]="value">
|
||||
<label tuiInputFiles>
|
||||
<input
|
||||
tuiInputFiles
|
||||
[invalid]="invalid"
|
||||
[accept]="spec.extensions.join(',')"
|
||||
[(ngModel)]="value"
|
||||
(blur)="onFocus(false)"
|
||||
/>
|
||||
<ng-template let-drop>
|
||||
@@ -17,17 +18,25 @@
|
||||
}
|
||||
</div>
|
||||
@if (value) {
|
||||
<tui-tag
|
||||
class="file"
|
||||
size="l"
|
||||
[value]="value.name"
|
||||
[removable]="true"
|
||||
(edited)="value = null"
|
||||
/>
|
||||
<tui-chip>
|
||||
{{ value.name }}
|
||||
<button
|
||||
tuiIconButton
|
||||
type="button"
|
||||
appearance="icon"
|
||||
size="xs"
|
||||
iconStart="@tui.x"
|
||||
(click.stop)="value = null"
|
||||
>
|
||||
{{ 'Delete' | i18n }}
|
||||
</button>
|
||||
</tui-chip>
|
||||
} @else {
|
||||
<small>{{ 'Click or drop file here' | i18n }}</small>
|
||||
}
|
||||
</div>
|
||||
<div class="drop" [class.drop_hidden]="!drop">{{ 'Drop file here' | i18n }}</div>
|
||||
<div class="drop" [class.drop_hidden]="!drop">
|
||||
{{ 'Drop file here' | i18n }}
|
||||
</div>
|
||||
</ng-template>
|
||||
</label>
|
||||
|
||||
@@ -40,7 +40,8 @@ small {
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
tui-tag {
|
||||
tui-chip {
|
||||
z-index: 1;
|
||||
margin: -0.25rem -0.25rem -0.25rem auto;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { CommonModule } from '@angular/common'
|
||||
import { NgModule } from '@angular/core'
|
||||
import { FormsModule, ReactiveFormsModule } from '@angular/forms'
|
||||
import { MaskitoDirective } from '@maskito/angular'
|
||||
import { i18nPipe } from '@start9labs/shared'
|
||||
import { TuiMapperPipe, TuiValueChanges } from '@taiga-ui/cdk'
|
||||
import {
|
||||
TuiAppearance,
|
||||
@@ -14,6 +15,7 @@ import {
|
||||
TuiNumberFormat,
|
||||
} from '@taiga-ui/core'
|
||||
import {
|
||||
TuiChip,
|
||||
TuiElasticContainer,
|
||||
TuiFieldErrorPipe,
|
||||
TuiFiles,
|
||||
@@ -28,11 +30,11 @@ import {
|
||||
TuiInputTimeModule,
|
||||
TuiMultiSelectModule,
|
||||
TuiSelectModule,
|
||||
TuiTagModule,
|
||||
TuiTextareaModule,
|
||||
TuiTextfieldControllerModule,
|
||||
} from '@taiga-ui/legacy'
|
||||
import { ControlDirective } from './control.directive'
|
||||
import { FilterHiddenPipe } from './filter-hidden.pipe'
|
||||
import { FormArrayComponent } from './form-array/form-array.component'
|
||||
import { FormColorComponent } from './form-color/form-color.component'
|
||||
import { FormControlComponent } from './form-control/form-control.component'
|
||||
@@ -49,8 +51,6 @@ import { FormToggleComponent } from './form-toggle/form-toggle.component'
|
||||
import { FormUnionComponent } from './form-union/form-union.component'
|
||||
import { HintPipe } from './hint.pipe'
|
||||
import { MustachePipe } from './mustache.pipe'
|
||||
import { FilterHiddenPipe } from './filter-hidden.pipe'
|
||||
import { i18nPipe } from '@start9labs/shared'
|
||||
|
||||
@NgModule({
|
||||
imports: [
|
||||
@@ -66,7 +66,7 @@ import { i18nPipe } from '@start9labs/shared'
|
||||
TuiSwitch,
|
||||
TuiTooltip,
|
||||
...TuiHint,
|
||||
TuiTagModule,
|
||||
TuiChip,
|
||||
TuiButton,
|
||||
...TuiExpand,
|
||||
TuiTextfieldControllerModule,
|
||||
|
||||
@@ -50,7 +50,7 @@ import { ABOUT } from './about.component'
|
||||
</button>
|
||||
</tui-opt-group>
|
||||
<tui-opt-group label="" safeLinks>
|
||||
<a tuiOption docsLink iconStart="@tui.book-open" href="/user-manual">
|
||||
<a tuiOption docsLink iconStart="@tui.book-open" path="/user-manual">
|
||||
{{ 'User manual' | i18n }}
|
||||
</a>
|
||||
<a
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { Pipe, PipeTransform } from '@angular/core'
|
||||
import { toAcmeName } from 'src/app/utils/acme'
|
||||
import { toAuthorityName } from 'src/app/utils/acme'
|
||||
|
||||
@Pipe({
|
||||
name: 'acme',
|
||||
name: 'authorityName',
|
||||
})
|
||||
export class AcmePipe implements PipeTransform {
|
||||
export class AuthorityNamePipe implements PipeTransform {
|
||||
transform(value: string | null = null): string {
|
||||
return toAcmeName(value)
|
||||
return toAuthorityName(value)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,14 +28,14 @@ import {
|
||||
FormComponent,
|
||||
FormContext,
|
||||
} from 'src/app/routes/portal/components/form.component'
|
||||
import { AcmePipe } from 'src/app/routes/portal/components/interfaces/acme.pipe'
|
||||
import { AuthorityNamePipe } 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 { toAuthorityName } from 'src/app/utils/acme'
|
||||
import { configBuilderToSpec } from 'src/app/utils/configBuilderToSpec'
|
||||
import { InterfaceActionsComponent } from './actions.component'
|
||||
import { ClearnetAddress } from './interface.utils'
|
||||
@@ -43,7 +43,7 @@ import { MaskPipe } from './mask.pipe'
|
||||
|
||||
type ClearnetForm = {
|
||||
domain: string
|
||||
acme: string
|
||||
authority: string
|
||||
}
|
||||
|
||||
@Component({
|
||||
@@ -60,7 +60,7 @@ type ClearnetForm = {
|
||||
<a
|
||||
tuiLink
|
||||
docsLink
|
||||
href="/user-manual/connecting-remotely/clearnet.html"
|
||||
path="/user-manual/connecting-remotely/clearnet.html"
|
||||
>
|
||||
{{ 'Learn more' | i18n }}
|
||||
</a>
|
||||
@@ -85,11 +85,15 @@ type ClearnetForm = {
|
||||
}}
|
||||
</tui-notification>
|
||||
}
|
||||
<table [appTable]="['ACME', 'URL', null]">
|
||||
<table [appTable]="['Certificate Authority', 'URL', null]">
|
||||
@for (address of clearnet(); track $index) {
|
||||
<tr>
|
||||
<td [style.width.rem]="12">
|
||||
{{ interface.value().addSsl ? (address.acme | acme) : '-' }}
|
||||
{{
|
||||
interface.value().addSsl
|
||||
? (address.authority | authorityName)
|
||||
: '-'
|
||||
}}
|
||||
</td>
|
||||
<td [style.order]="-1">{{ address.url | mask }}</td>
|
||||
<td
|
||||
@@ -154,7 +158,7 @@ type ClearnetForm = {
|
||||
PlaceholderComponent,
|
||||
TableComponent,
|
||||
MaskPipe,
|
||||
AcmePipe,
|
||||
AuthorityNamePipe,
|
||||
InterfaceActionsComponent,
|
||||
i18nPipe,
|
||||
DocsLinkDirective,
|
||||
@@ -175,7 +179,7 @@ export class InterfaceClearnetComponent {
|
||||
readonly isRunning = input.required<boolean>()
|
||||
readonly isPublic = input.required<boolean>()
|
||||
|
||||
readonly acme = toSignal(
|
||||
readonly authorityUrls = toSignal(
|
||||
inject<PatchDB<DataModel>>(PatchDB)
|
||||
.watch$('serverInfo', 'network', 'acme')
|
||||
.pipe(map(acme => Object.keys(acme))),
|
||||
@@ -237,16 +241,16 @@ export class InterfaceClearnetComponent {
|
||||
default: null,
|
||||
patterns: [utils.Patterns.domain],
|
||||
})
|
||||
const acme = ISB.Value.select({
|
||||
name: 'ACME Provider',
|
||||
const authority = ISB.Value.select({
|
||||
name: 'Certificate Authority',
|
||||
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(
|
||||
'Select which Certificate authority to use for obtaining your SSL certificate. Add new authority in the System tab. Optionally use your local= Root CA. Note: only devices that have trusted your Root CA will be able to access the domain without security warnings.',
|
||||
values: this.authorityUrls().reduce<Record<string, string>>(
|
||||
(obj, url) => ({
|
||||
...obj,
|
||||
[url]: toAcmeName(url),
|
||||
[url]: toAuthorityName(url),
|
||||
}),
|
||||
{ none: 'None (use system Root CA)' } as Record<string, string>,
|
||||
{ local: toAuthorityName(null) },
|
||||
),
|
||||
default: '',
|
||||
})
|
||||
@@ -256,7 +260,7 @@ export class InterfaceClearnetComponent {
|
||||
data: {
|
||||
spec: await configBuilderToSpec(
|
||||
ISB.InputSpec.of(
|
||||
this.interface.value().addSsl ? { domain, acme } : { domain },
|
||||
this.interface.value().addSsl ? { domain, authority } : { domain },
|
||||
),
|
||||
),
|
||||
buttons: [
|
||||
@@ -272,11 +276,11 @@ export class InterfaceClearnetComponent {
|
||||
private async save(domainInfo: ClearnetForm): Promise<boolean> {
|
||||
const loader = this.loader.open('Saving').subscribe()
|
||||
|
||||
const { domain, acme } = domainInfo
|
||||
const { domain, authority } = domainInfo
|
||||
|
||||
const params = {
|
||||
domain,
|
||||
acme: acme === 'none' ? null : acme,
|
||||
acme: authority === 'local' ? null : authority,
|
||||
private: false,
|
||||
}
|
||||
|
||||
|
||||
@@ -72,7 +72,7 @@ export function getAddresses(
|
||||
url,
|
||||
disabled: !h.public,
|
||||
isDomain: hostnameKind == 'domain',
|
||||
acme:
|
||||
authority:
|
||||
hostnameKind == 'domain'
|
||||
? host.domains[h.hostname.domain]?.acme || null
|
||||
: null,
|
||||
@@ -118,7 +118,7 @@ export type MappedServiceInterface = T.ServiceInterface & {
|
||||
|
||||
export type ClearnetAddress = {
|
||||
url: string
|
||||
acme: string | null
|
||||
authority: string | null
|
||||
isDomain: boolean
|
||||
disabled: boolean
|
||||
}
|
||||
|
||||
@@ -18,7 +18,7 @@ import { DocsLinkDirective, i18nPipe } from '@start9labs/shared'
|
||||
'Local addresses can only be accessed by devices connected to the same LAN as your server, either directly or using a VPN.'
|
||||
| i18n
|
||||
}}
|
||||
<a tuiLink docsLink href="/user-manual/connecting-locally.html">
|
||||
<a tuiLink docsLink path="/user-manual/connecting-locally.html">
|
||||
{{ 'Learn More' | i18n }}
|
||||
</a>
|
||||
</ng-template>
|
||||
|
||||
@@ -50,7 +50,7 @@ type OnionForm = {
|
||||
'Add an onion address to anonymously expose this interface on the darknet. Onion addresses can only be reached over the Tor network.'
|
||||
| i18n
|
||||
}}
|
||||
<a tuiLink docsLink href="/user-manual/connecting-remotely/tor.html">
|
||||
<a tuiLink docsLink path="/user-manual/connecting-remotely/tor.html">
|
||||
{{ 'Learn More' | i18n }}
|
||||
</a>
|
||||
</ng-template>
|
||||
|
||||
@@ -24,5 +24,5 @@ import { i18nKey, i18nPipe } from '@start9labs/shared'
|
||||
imports: [i18nPipe],
|
||||
})
|
||||
export class TableComponent {
|
||||
readonly appTable = input.required<Array<i18nKey | null>>()
|
||||
readonly appTable = input.required<ReadonlyArray<i18nKey | null>>()
|
||||
}
|
||||
|
||||
@@ -26,7 +26,7 @@ import { DocsLinkDirective } from 'projects/shared/src/public-api'
|
||||
Scheduling automatic backups is an excellent way to ensure your StartOS
|
||||
data is safely backed up. StartOS will issue a notification whenever one
|
||||
of your scheduled backups succeeds or fails.
|
||||
<a tuiLink docsLink href="/@TODO">View instructions</a>
|
||||
<a tuiLink docsLink path="/@TODO">View instructions</a>
|
||||
</tui-notification>
|
||||
<h3 class="g-title">
|
||||
Saved Jobs
|
||||
|
||||
@@ -31,7 +31,7 @@ import { DocsLinkDirective } from 'projects/shared/src/public-api'
|
||||
backups. They can be physical drives plugged into your server, shared
|
||||
folders on your Local Area Network (LAN), or third party clouds such as
|
||||
Dropbox or Google Drive.
|
||||
<a tuiLink docsLink href="/@TODO">View instructions</a>
|
||||
<a tuiLink docsLink path="/@TODO">View instructions</a>
|
||||
</tui-notification>
|
||||
<h3 class="g-title">
|
||||
Unknown Physical Drives
|
||||
|
||||
@@ -49,7 +49,8 @@ import { TimeService } from 'src/app/services/time.service'
|
||||
docsLink
|
||||
iconEnd="@tui.external-link"
|
||||
appearance=""
|
||||
href="/help/common-issues.html#clock-sync-failure"
|
||||
path="/help/common-issues.html"
|
||||
fragment="#clock-sync-failure"
|
||||
[pseudo]="true"
|
||||
[textContent]="'the docs' | i18n"
|
||||
></a>
|
||||
|
||||
@@ -83,6 +83,7 @@ export default class ServiceActionsRoute {
|
||||
icon: pkg.icon,
|
||||
manifest: getManifest(pkg),
|
||||
actions: Object.entries(pkg.actions)
|
||||
.filter(([_, action]) => action.visibility !== 'hidden')
|
||||
.map(([id, action]) => ({
|
||||
...action,
|
||||
id,
|
||||
|
||||
@@ -1,290 +0,0 @@
|
||||
import { ChangeDetectionStrategy, Component, inject } from '@angular/core'
|
||||
import { toSignal } from '@angular/core/rxjs-interop'
|
||||
import { RouterLink } from '@angular/router'
|
||||
import {
|
||||
DocsLinkDirective,
|
||||
ErrorService,
|
||||
i18nPipe,
|
||||
LoadingService,
|
||||
} from '@start9labs/shared'
|
||||
import { ISB, utils } from '@start9labs/start-sdk'
|
||||
import { TuiButton, TuiLink, TuiLoader, TuiTitle } from '@taiga-ui/core'
|
||||
import { TuiCell, TuiHeader } from '@taiga-ui/layout'
|
||||
import { PatchDB } from 'patch-db-client'
|
||||
import { map } from 'rxjs'
|
||||
import { FormComponent } from 'src/app/routes/portal/components/form.component'
|
||||
import { PlaceholderComponent } from 'src/app/routes/portal/components/placeholder.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 { TitleDirective } from 'src/app/services/title.service'
|
||||
import { knownACME, toAcmeName } from 'src/app/utils/acme'
|
||||
import { configBuilderToSpec } from 'src/app/utils/configBuilderToSpec'
|
||||
|
||||
@Component({
|
||||
template: `
|
||||
<ng-container *title>
|
||||
<a routerLink=".." tuiIconButton iconStart="@tui.arrow-left">
|
||||
{{ 'Back' | i18n }}
|
||||
</a>
|
||||
ACME
|
||||
</ng-container>
|
||||
<header tuiHeader>
|
||||
<hgroup tuiTitle>
|
||||
<h3>ACME</h3>
|
||||
<p tuiSubtitle>
|
||||
{{
|
||||
'Add ACME providers in order to generate SSL (https) certificates for clearnet access.'
|
||||
| i18n
|
||||
}}
|
||||
<a
|
||||
tuiLink
|
||||
docsLink
|
||||
href="/user-manual/connecting-remotely/clearnet.html#adding-acme"
|
||||
appearance="action-grayscale"
|
||||
iconEnd="@tui.external-link"
|
||||
[pseudo]="true"
|
||||
[textContent]="'View instructions' | i18n"
|
||||
></a>
|
||||
</p>
|
||||
</hgroup>
|
||||
</header>
|
||||
<section class="g-card">
|
||||
<header>
|
||||
{{ 'Saved Providers' | i18n }}
|
||||
@if (acme(); as value) {
|
||||
<button
|
||||
tuiButton
|
||||
size="xs"
|
||||
iconStart="@tui.plus"
|
||||
[style.margin-inline-start]="'auto'"
|
||||
(click)="addAcme(value)"
|
||||
>
|
||||
{{ 'Add Provider' | i18n }}
|
||||
</button>
|
||||
}
|
||||
</header>
|
||||
@if (acme(); as value) {
|
||||
@for (provider of value; track $index) {
|
||||
<div tuiCell>
|
||||
<span tuiTitle>
|
||||
<strong>{{ toAcmeName(provider.url) }}</strong>
|
||||
<span tuiSubtitle>
|
||||
{{ 'Contact' | i18n }}: {{ provider.contactString }}
|
||||
</span>
|
||||
</span>
|
||||
<button
|
||||
tuiIconButton
|
||||
iconStart="@tui.pencil"
|
||||
appearance="icon"
|
||||
(click)="editAcme(provider.url, provider.contact)"
|
||||
>
|
||||
{{ 'Edit' | i18n }}
|
||||
</button>
|
||||
<button
|
||||
tuiIconButton
|
||||
iconStart="@tui.trash"
|
||||
appearance="icon"
|
||||
(click)="removeAcme(provider.url)"
|
||||
>
|
||||
{{ 'Edit' | i18n }}
|
||||
</button>
|
||||
</div>
|
||||
} @empty {
|
||||
<app-placeholder icon="@tui.shield-question">
|
||||
{{ 'No saved providers' | i18n }}
|
||||
</app-placeholder>
|
||||
}
|
||||
} @else {
|
||||
<tui-loader [style.height.rem]="5" />
|
||||
}
|
||||
</section>
|
||||
`,
|
||||
styles: `
|
||||
:host {
|
||||
max-width: 36rem;
|
||||
}
|
||||
`,
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
imports: [
|
||||
TuiButton,
|
||||
TuiLoader,
|
||||
TuiCell,
|
||||
TuiTitle,
|
||||
TuiHeader,
|
||||
TuiLink,
|
||||
RouterLink,
|
||||
TitleDirective,
|
||||
i18nPipe,
|
||||
DocsLinkDirective,
|
||||
PlaceholderComponent,
|
||||
],
|
||||
})
|
||||
export default class SystemAcmeComponent {
|
||||
private readonly formDialog = inject(FormDialogService)
|
||||
private readonly loader = inject(LoadingService)
|
||||
private readonly errorService = inject(ErrorService)
|
||||
private readonly patch = inject<PatchDB<DataModel>>(PatchDB)
|
||||
private readonly api = inject(ApiService)
|
||||
private readonly i18n = inject(i18nPipe)
|
||||
|
||||
acme = toSignal(
|
||||
this.patch.watch$('serverInfo', 'network', 'acme').pipe(
|
||||
map(acme =>
|
||||
Object.keys(acme).map(url => {
|
||||
const contact =
|
||||
acme[url]?.contact.map(mailto => mailto.replace('mailto:', '')) ||
|
||||
[]
|
||||
return {
|
||||
url,
|
||||
contact,
|
||||
contactString: contact.join(', '),
|
||||
}
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
toAcmeName = toAcmeName
|
||||
|
||||
async addAcme(
|
||||
providers: {
|
||||
url: string
|
||||
contact: string[]
|
||||
contactString: string
|
||||
}[],
|
||||
) {
|
||||
this.formDialog.open(FormComponent, {
|
||||
label: 'Add ACME Provider',
|
||||
data: {
|
||||
spec: await configBuilderToSpec(
|
||||
this.addAcmeSpec(providers.map(p => p.url)),
|
||||
),
|
||||
buttons: [
|
||||
{
|
||||
text: this.i18n.transform('Save'),
|
||||
handler: async (
|
||||
val: ReturnType<typeof this.addAcmeSpec>['_TYPE'],
|
||||
) => {
|
||||
const providerUrl =
|
||||
val.provider.selection === 'other'
|
||||
? val.provider.value.url
|
||||
: val.provider.selection
|
||||
|
||||
return this.saveAcme(providerUrl, val.contact)
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
async editAcme(provider: string, contact: string[]) {
|
||||
this.formDialog.open(FormComponent, {
|
||||
label: 'Edit ACME Provider',
|
||||
data: {
|
||||
spec: await configBuilderToSpec(this.editAcmeSpec()),
|
||||
buttons: [
|
||||
{
|
||||
text: this.i18n.transform('Save'),
|
||||
handler: async (
|
||||
val: ReturnType<typeof this.editAcmeSpec>['_TYPE'],
|
||||
) => this.saveAcme(provider, val.contact),
|
||||
},
|
||||
],
|
||||
value: { contact },
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
async removeAcme(provider: string) {
|
||||
const loader = this.loader.open('Removing').subscribe()
|
||||
|
||||
try {
|
||||
await this.api.removeAcme({ provider })
|
||||
} catch (e: any) {
|
||||
this.errorService.handleError(e)
|
||||
} finally {
|
||||
loader.unsubscribe()
|
||||
}
|
||||
}
|
||||
|
||||
private async saveAcme(providerUrl: string, contact: string[]) {
|
||||
const loader = this.loader.open('Saving').subscribe()
|
||||
|
||||
try {
|
||||
await this.api.initAcme({
|
||||
provider: new URL(providerUrl).href,
|
||||
contact: contact.map(address => `mailto:${address}`),
|
||||
})
|
||||
return true
|
||||
} catch (e: any) {
|
||||
this.errorService.handleError(e)
|
||||
return false
|
||||
} finally {
|
||||
loader.unsubscribe()
|
||||
}
|
||||
}
|
||||
|
||||
private addAcmeSpec(providers: string[]) {
|
||||
const availableAcme = knownACME.filter(
|
||||
acme => !providers.includes(acme.url),
|
||||
)
|
||||
|
||||
return ISB.InputSpec.of({
|
||||
provider: ISB.Value.union({
|
||||
name: 'Provider',
|
||||
default: (availableAcme[0]?.url as any) || 'other',
|
||||
variants: ISB.Variants.of({
|
||||
...availableAcme.reduce(
|
||||
(obj, curr) => ({
|
||||
...obj,
|
||||
[curr.url]: {
|
||||
name: curr.name,
|
||||
spec: ISB.InputSpec.of({}),
|
||||
},
|
||||
}),
|
||||
{},
|
||||
),
|
||||
other: {
|
||||
name: 'Other',
|
||||
spec: ISB.InputSpec.of({
|
||||
url: ISB.Value.text({
|
||||
name: 'URL',
|
||||
default: null,
|
||||
required: true,
|
||||
inputmode: 'url',
|
||||
patterns: [utils.Patterns.url],
|
||||
}),
|
||||
}),
|
||||
},
|
||||
}),
|
||||
}),
|
||||
contact: this.emailListSpec(),
|
||||
})
|
||||
}
|
||||
|
||||
private editAcmeSpec() {
|
||||
return ISB.InputSpec.of({
|
||||
contact: this.emailListSpec(),
|
||||
})
|
||||
}
|
||||
|
||||
private emailListSpec() {
|
||||
return ISB.Value.list(
|
||||
ISB.List.text(
|
||||
{
|
||||
name: this.i18n.transform('Contact Emails')!,
|
||||
description: this.i18n.transform(
|
||||
'Needed to obtain a certificate from a Certificate Authority',
|
||||
),
|
||||
minLength: 1,
|
||||
},
|
||||
{
|
||||
inputmode: 'email',
|
||||
patterns: [utils.Patterns.email],
|
||||
},
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -66,7 +66,7 @@ import { BACKUP_RESTORE } from './restore.component'
|
||||
<a
|
||||
tuiLink
|
||||
docsLink
|
||||
href="/user-manual/backup-create.html"
|
||||
path="/user-manual/backup-create.html"
|
||||
appearance="action-grayscale"
|
||||
iconEnd="@tui.external-link"
|
||||
[pseudo]="true"
|
||||
@@ -80,7 +80,7 @@ import { BACKUP_RESTORE } from './restore.component'
|
||||
<a
|
||||
tuiLink
|
||||
docsLink
|
||||
href="/user-manual/backup-restore.html"
|
||||
path="/user-manual/backup-restore.html"
|
||||
appearance="action-grayscale"
|
||||
iconEnd="@tui.external-link"
|
||||
[pseudo]="true"
|
||||
@@ -123,7 +123,8 @@ import { BACKUP_RESTORE } from './restore.component'
|
||||
<a
|
||||
tuiLink
|
||||
docsLink
|
||||
href="/user-manual/backup-create.html#network-folder"
|
||||
path="/user-manual/backup-create.html"
|
||||
fragment="#network-folder"
|
||||
appearance="action-grayscale"
|
||||
iconEnd="@tui.external-link"
|
||||
[pseudo]="true"
|
||||
|
||||
@@ -0,0 +1,186 @@
|
||||
import { inject, Injectable } from '@angular/core'
|
||||
import {
|
||||
DialogService,
|
||||
ErrorService,
|
||||
i18nPipe,
|
||||
LoadingService,
|
||||
} from '@start9labs/shared'
|
||||
import { toSignal } from '@angular/core/rxjs-interop'
|
||||
import { ISB, utils } from '@start9labs/start-sdk'
|
||||
import { filter, map } from 'rxjs'
|
||||
import { FormComponent } 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 { PatchDB } from 'patch-db-client'
|
||||
import { DataModel } from 'src/app/services/patch-db/data-model'
|
||||
import { knownAuthorities, toAuthorityName } from 'src/app/utils/acme'
|
||||
import { configBuilderToSpec } from 'src/app/utils/configBuilderToSpec'
|
||||
|
||||
export type Authority = {
|
||||
url: string | null
|
||||
name: string
|
||||
contact: readonly string[] | null
|
||||
}
|
||||
|
||||
export type RemoteAuthority = Authority & { url: string }
|
||||
|
||||
@Injectable()
|
||||
export class AuthorityService {
|
||||
private readonly patch = inject<PatchDB<DataModel>>(PatchDB)
|
||||
private readonly loader = inject(LoadingService)
|
||||
private readonly errorService = inject(ErrorService)
|
||||
private readonly api = inject(ApiService)
|
||||
private readonly formDialog = inject(FormDialogService)
|
||||
private readonly i18n = inject(i18nPipe)
|
||||
private readonly dialog = inject(DialogService)
|
||||
|
||||
readonly authorities = toSignal<Authority[]>(
|
||||
this.patch.watch$('serverInfo', 'network', 'acme').pipe(
|
||||
map(acme => [
|
||||
{
|
||||
url: null,
|
||||
name: toAuthorityName(null),
|
||||
contact: null,
|
||||
},
|
||||
...Object.keys(acme).map(url => ({
|
||||
url,
|
||||
name: toAuthorityName(url),
|
||||
contact:
|
||||
acme[url]?.contact.map(mailto => mailto.replace('mailto:', '')) ||
|
||||
null,
|
||||
})),
|
||||
]),
|
||||
),
|
||||
)
|
||||
|
||||
async add(authorities: Authority[]) {
|
||||
const availableAuthorities = knownAuthorities.filter(
|
||||
ca => !authorities.map(a => a.url).includes(ca.url),
|
||||
)
|
||||
|
||||
const addSpec = ISB.InputSpec.of({
|
||||
provider: ISB.Value.union({
|
||||
name: 'Provider',
|
||||
default: (availableAuthorities[0]?.url as any) || 'other',
|
||||
variants: ISB.Variants.of({
|
||||
...availableAuthorities.reduce(
|
||||
(obj, curr) => ({
|
||||
...obj,
|
||||
[curr.url]: {
|
||||
name: curr.name,
|
||||
spec: ISB.InputSpec.of({}),
|
||||
},
|
||||
}),
|
||||
{},
|
||||
),
|
||||
other: {
|
||||
name: 'Other',
|
||||
spec: ISB.InputSpec.of({
|
||||
url: ISB.Value.text({
|
||||
name: 'URL',
|
||||
default: null,
|
||||
required: true,
|
||||
inputmode: 'url',
|
||||
patterns: [utils.Patterns.url],
|
||||
}),
|
||||
}),
|
||||
},
|
||||
}),
|
||||
}),
|
||||
contact: this.emailListSpec(),
|
||||
})
|
||||
|
||||
this.formDialog.open(FormComponent, {
|
||||
label: 'Add Certificate Authority',
|
||||
data: {
|
||||
spec: await configBuilderToSpec(addSpec),
|
||||
buttons: [
|
||||
{
|
||||
text: this.i18n.transform('Save'),
|
||||
handler: async (val: typeof addSpec._TYPE) => {
|
||||
const providerUrl =
|
||||
val.provider.selection === 'other'
|
||||
? val.provider.value.url
|
||||
: val.provider.selection
|
||||
|
||||
return this.save(providerUrl, val.contact)
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
async edit({ url, contact }: RemoteAuthority) {
|
||||
const editSpec = ISB.InputSpec.of({
|
||||
contact: this.emailListSpec(),
|
||||
})
|
||||
|
||||
this.formDialog.open(FormComponent, {
|
||||
label: 'Edit Contact Info',
|
||||
data: {
|
||||
spec: await configBuilderToSpec(editSpec),
|
||||
buttons: [
|
||||
{
|
||||
text: this.i18n.transform('Save'),
|
||||
handler: async (val: typeof editSpec._TYPE) =>
|
||||
this.save(url, val.contact),
|
||||
},
|
||||
],
|
||||
value: { contact },
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
remove({ url }: RemoteAuthority) {
|
||||
this.dialog
|
||||
.openConfirm({ label: 'Are you sure?', size: 's' })
|
||||
.pipe(filter(Boolean))
|
||||
.subscribe(async () => {
|
||||
const loader = this.loader.open('Removing').subscribe()
|
||||
|
||||
try {
|
||||
await this.api.removeAcme({ provider: url })
|
||||
} catch (e: any) {
|
||||
this.errorService.handleError(e)
|
||||
} finally {
|
||||
loader.unsubscribe()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
private async save(url: string, contact: readonly string[]) {
|
||||
const loader = this.loader.open('Saving').subscribe()
|
||||
|
||||
try {
|
||||
await this.api.initAcme({
|
||||
provider: new URL(url).href,
|
||||
contact: contact.map(address => `mailto:${address}`),
|
||||
})
|
||||
return true
|
||||
} catch (e: any) {
|
||||
this.errorService.handleError(e)
|
||||
return false
|
||||
} finally {
|
||||
loader.unsubscribe()
|
||||
}
|
||||
}
|
||||
|
||||
private emailListSpec() {
|
||||
return ISB.Value.list(
|
||||
ISB.List.text(
|
||||
{
|
||||
name: this.i18n.transform('Contact Emails')!,
|
||||
description: this.i18n.transform(
|
||||
'Needed to obtain a certificate from a Certificate Authority',
|
||||
),
|
||||
minLength: 1,
|
||||
},
|
||||
{
|
||||
inputmode: 'email',
|
||||
patterns: [utils.Patterns.email],
|
||||
},
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
inject,
|
||||
input,
|
||||
} from '@angular/core'
|
||||
import { i18nPipe } from '@start9labs/shared'
|
||||
import {
|
||||
TuiButton,
|
||||
TuiDataList,
|
||||
TuiDropdown,
|
||||
TuiTextfield,
|
||||
} from '@taiga-ui/core'
|
||||
import { Authority, AuthorityService } from './authority.service'
|
||||
|
||||
@Component({
|
||||
selector: 'tr[authority]',
|
||||
template: `
|
||||
@if (authority(); as authority) {
|
||||
<td>{{ authority.name }}</td>
|
||||
<td>{{ authority.url || '-' }}</td>
|
||||
<td>{{ authority.contact ? authority.contact.join(', ') : '-' }}</td>
|
||||
<td>
|
||||
<button
|
||||
tuiIconButton
|
||||
tuiDropdown
|
||||
size="s"
|
||||
appearance="flat-grayscale"
|
||||
iconStart="@tui.ellipsis-vertical"
|
||||
[tuiAppearanceState]="open ? 'hover' : null"
|
||||
[(tuiDropdownOpen)]="open"
|
||||
>
|
||||
{{ 'More' | i18n }}
|
||||
<tui-data-list size="s" *tuiTextfieldDropdown>
|
||||
@if (authority.url) {
|
||||
<tui-opt-group>
|
||||
<button
|
||||
tuiOption
|
||||
new
|
||||
iconStart="@tui.pencil"
|
||||
(click)="service.edit($any(authority))"
|
||||
>
|
||||
{{ 'Edit' | i18n }}
|
||||
</button>
|
||||
</tui-opt-group>
|
||||
<tui-opt-group>
|
||||
<button
|
||||
tuiOption
|
||||
new
|
||||
iconStart="@tui.trash"
|
||||
class="g-negative"
|
||||
(click)="service.remove($any(authority))"
|
||||
>
|
||||
{{ 'Delete' | i18n }}
|
||||
</button>
|
||||
</tui-opt-group>
|
||||
} @else {
|
||||
<tui-opt-group>
|
||||
<a
|
||||
tuiOption
|
||||
new
|
||||
iconStart="@tui.download"
|
||||
href="/static/local-root-ca.crt"
|
||||
>
|
||||
{{ 'Download your Root CA' | i18n }}
|
||||
</a>
|
||||
</tui-opt-group>
|
||||
}
|
||||
</tui-data-list>
|
||||
</button>
|
||||
</td>
|
||||
}
|
||||
`,
|
||||
styles: `
|
||||
td:last-child {
|
||||
grid-area: 1 / 2 / 3;
|
||||
align-self: center;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
:host-context(tui-root._mobile) {
|
||||
grid-template-columns: 1fr min-content;
|
||||
|
||||
td:first-child {
|
||||
font: var(--tui-font-text-m);
|
||||
font-weight: bold;
|
||||
}
|
||||
}
|
||||
`,
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
imports: [TuiButton, i18nPipe, TuiDropdown, TuiDataList, TuiTextfield],
|
||||
})
|
||||
export class AuthorityItemComponent {
|
||||
protected readonly service = inject(AuthorityService)
|
||||
|
||||
readonly authority = input.required<Authority>()
|
||||
|
||||
open = false
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { ChangeDetectionStrategy, Component, inject } from '@angular/core'
|
||||
import { i18nPipe } from '@start9labs/shared'
|
||||
import { TuiSkeleton } from '@taiga-ui/kit'
|
||||
import { TableComponent } from 'src/app/routes/portal/components/table.component'
|
||||
import { AuthorityItemComponent } from './item.component'
|
||||
import { AuthorityService } from './authority.service'
|
||||
|
||||
@Component({
|
||||
selector: 'authorities-table',
|
||||
template: `
|
||||
<table [appTable]="['Provider', 'URL', 'Contact', null]">
|
||||
@for (authority of authorityService.authorities(); track $index) {
|
||||
<tr [authority]="authority"></tr>
|
||||
} @empty {
|
||||
<td [attr.colspan]="4">
|
||||
<div [tuiSkeleton]="true">{{ 'Loading' | i18n }}</div>
|
||||
</td>
|
||||
}
|
||||
</table>
|
||||
`,
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
imports: [TuiSkeleton, i18nPipe, TableComponent, AuthorityItemComponent],
|
||||
})
|
||||
export class AuthoritiesTableComponent {
|
||||
protected readonly authorityService = inject(AuthorityService)
|
||||
}
|
||||
@@ -1,134 +0,0 @@
|
||||
import { ISB } from '@start9labs/start-sdk'
|
||||
import { Proxy } from 'src/app/services/patch-db/data-model'
|
||||
import { configBuilderToSpec } from 'src/app/utils/configBuilderToSpec'
|
||||
|
||||
const auth = ISB.InputSpec.of({
|
||||
username: ISB.Value.text({
|
||||
name: 'Username',
|
||||
required: true,
|
||||
default: null,
|
||||
}),
|
||||
password: ISB.Value.text({
|
||||
name: 'Password',
|
||||
required: true,
|
||||
default: null,
|
||||
masked: true,
|
||||
}),
|
||||
})
|
||||
|
||||
function getStrategyUnion(proxies: Proxy[]) {
|
||||
const inboundProxies: Record<string, string> = proxies
|
||||
.filter(p => p.type === 'inbound-outbound')
|
||||
.reduce(
|
||||
(prev, curr) => ({
|
||||
[curr.id]: curr.name,
|
||||
...prev,
|
||||
}),
|
||||
{},
|
||||
)
|
||||
|
||||
return ISB.Value.union(
|
||||
{
|
||||
name: 'Networking Strategy',
|
||||
default: 'local',
|
||||
description: `<h5>Local</h5>Select this option if you do not mind exposing your home/business IP address to the Internet. This option requires configuring router settings, which StartOS can do automatically if you have an OpenWRT router
|
||||
<h5>Proxy</h5>Select this option is you prefer to hide your home/business IP address from the Internet. This option requires running your own Virtual Private Server (VPS) <i>or</i> paying service provider such as Static Wire
|
||||
`,
|
||||
},
|
||||
ISB.Variants.of({
|
||||
local: {
|
||||
name: 'Local',
|
||||
spec: ISB.InputSpec.of({
|
||||
ipStrategy: ISB.Value.select({
|
||||
name: 'IP Strategy',
|
||||
description: `<h5>IPv6 Only (recommended)</h5><b>Requirements</b>:<ol><li>ISP IPv6 support</li><li>OpenWRT (recommended) or Linksys router</li></ol><b>Pros</b>: Ready for IPv6 Internet. Enhanced privacy. Run multiple clearnet servers from the same network
|
||||
<b>Cons</b>: Interfaces using this domain will only be accessible to people whose ISP supports IPv6
|
||||
<h5>IPv6 and IPv4</h5><b>Pros</b>: Ready for IPv6 Internet. Accessible by anyone
|
||||
<b>Cons</b>: Less private, as IPv4 addresses are closely correlated with geographic areas. Cannot run multiple clearnet servers from the same network
|
||||
<h5>IPv4 Only</h5><b>Pros</b>: Accessible by anyone
|
||||
<b>Cons</b>: Less private, as IPv4 addresses are closely correlated with geographic areas. Cannot run multiple clearnet servers from the same network
|
||||
`,
|
||||
default: 'ipv6',
|
||||
values: {
|
||||
ipv6: 'IPv6 Only',
|
||||
ipv4: 'IPv4 Only',
|
||||
dualstack: 'IPv6 and IPv4',
|
||||
},
|
||||
}),
|
||||
}),
|
||||
},
|
||||
proxy: {
|
||||
name: 'Proxy',
|
||||
spec: ISB.InputSpec.of({
|
||||
proxyId: ISB.Value.select({
|
||||
name: 'Select Proxy',
|
||||
default: proxies.filter(p => p.type === 'inbound-outbound')[0].id,
|
||||
values: inboundProxies,
|
||||
}),
|
||||
}),
|
||||
},
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
export function getStart9ToSpec(proxies: Proxy[]) {
|
||||
return configBuilderToSpec(
|
||||
ISB.InputSpec.of({
|
||||
strategy: getStrategyUnion(proxies),
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
export function getCustomSpec(proxies: Proxy[]) {
|
||||
return configBuilderToSpec(
|
||||
ISB.InputSpec.of({
|
||||
hostname: ISB.Value.text({
|
||||
name: 'Hostname',
|
||||
required: true,
|
||||
default: null,
|
||||
placeholder: 'yourdomain.com',
|
||||
}),
|
||||
provider: ISB.Value.union(
|
||||
{
|
||||
name: 'Dynamic DNS Provider',
|
||||
default: 'start9',
|
||||
},
|
||||
ISB.Variants.of({
|
||||
start9: {
|
||||
name: 'Start9',
|
||||
spec: ISB.InputSpec.of({}),
|
||||
},
|
||||
njalla: {
|
||||
name: 'Njalla',
|
||||
spec: auth,
|
||||
},
|
||||
duckdns: {
|
||||
name: 'Duck DNS',
|
||||
spec: auth,
|
||||
},
|
||||
dyn: {
|
||||
name: 'DynDNS',
|
||||
spec: auth,
|
||||
},
|
||||
easydns: {
|
||||
name: 'easyDNS',
|
||||
spec: auth,
|
||||
},
|
||||
zoneedit: {
|
||||
name: 'Zoneedit',
|
||||
spec: auth,
|
||||
},
|
||||
googledomains: {
|
||||
name: 'Google Domains (IPv4 or IPv6)',
|
||||
spec: auth,
|
||||
},
|
||||
namecheap: {
|
||||
name: 'Namecheap (IPv4 only)',
|
||||
spec: auth,
|
||||
},
|
||||
}),
|
||||
),
|
||||
strategy: getStrategyUnion(proxies),
|
||||
}),
|
||||
)
|
||||
}
|
||||
@@ -1,197 +1,95 @@
|
||||
import { TUI_CONFIRM } from '@taiga-ui/kit'
|
||||
import { CommonModule } from '@angular/common'
|
||||
import { ChangeDetectionStrategy, Component, inject } from '@angular/core'
|
||||
import { ErrorService, LoadingService } from '@start9labs/shared'
|
||||
import { TuiDialogOptions, TuiDialogService, TuiButton } from '@taiga-ui/core'
|
||||
import { PatchDB } from 'patch-db-client'
|
||||
import { filter, firstValueFrom, map } from 'rxjs'
|
||||
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 { DataModel } from 'src/app/services/patch-db/data-model'
|
||||
import { getCustomSpec, getStart9ToSpec } from './constants'
|
||||
import { DomainsInfoComponent } from './info.component'
|
||||
import { DomainsTableComponent } from './table.component'
|
||||
import { RouterLink } from '@angular/router'
|
||||
import { DocsLinkDirective, i18nPipe } from '@start9labs/shared'
|
||||
import { TuiButton, TuiLink, TuiTitle } from '@taiga-ui/core'
|
||||
import { TuiHeader } from '@taiga-ui/layout'
|
||||
import { TitleDirective } from 'src/app/services/title.service'
|
||||
import { AuthorityService } from './authorities/authority.service'
|
||||
import { DomainService } from './domains/domain.service'
|
||||
import { DomainsTableComponent } from './domains/table.component'
|
||||
import { AuthoritiesTableComponent } from './authorities/table.component'
|
||||
|
||||
@Component({
|
||||
template: `
|
||||
<domains-info />
|
||||
@if (domains$ | async; as domains) {
|
||||
<h3 class="g-title">
|
||||
Start9.to
|
||||
@if (!domains.start9To.length) {
|
||||
<button tuiButton size="xs" iconStart="@tui.plus" (click)="claim()">
|
||||
Claim
|
||||
<ng-container *title>
|
||||
<a routerLink=".." tuiIconButton iconStart="@tui.arrow-left">
|
||||
{{ 'Back' | i18n }}
|
||||
</a>
|
||||
{{ 'Domains' | i18n }}
|
||||
</ng-container>
|
||||
<header tuiHeader>
|
||||
<hgroup tuiTitle>
|
||||
<h3>{{ 'Domains' | i18n }}</h3>
|
||||
<p tuiSubtitle>
|
||||
{{
|
||||
'Adding a domain to StartOS means you can use it and its subdomains to host service interfaces on the public Internet.'
|
||||
| i18n
|
||||
}}
|
||||
<a
|
||||
tuiLink
|
||||
docsLink
|
||||
path="/user-manual/domains.html"
|
||||
appearance="action-grayscale"
|
||||
iconEnd="@tui.external-link"
|
||||
[pseudo]="true"
|
||||
[textContent]="'View instructions' | i18n"
|
||||
></a>
|
||||
</p>
|
||||
</hgroup>
|
||||
</header>
|
||||
|
||||
<section class="g-card">
|
||||
<header>
|
||||
{{ 'Certificate Authorities' | i18n }}
|
||||
@if (authorityService.authorities(); as authorities) {
|
||||
<button
|
||||
tuiButton
|
||||
size="xs"
|
||||
iconStart="@tui.plus"
|
||||
[style.margin-inline-start]="'auto'"
|
||||
(click)="authorityService.add(authorities)"
|
||||
>
|
||||
{{ 'Add' | i18n }}
|
||||
</button>
|
||||
}
|
||||
</h3>
|
||||
<table
|
||||
class="g-table"
|
||||
[domains]="domains.start9To"
|
||||
(delete)="delete()"
|
||||
></table>
|
||||
<h3 class="g-title">
|
||||
Custom Domains
|
||||
<button tuiButton size="xs" iconStart="@tui.plus" (click)="add()">
|
||||
Add Domain
|
||||
</button>
|
||||
</h3>
|
||||
<table
|
||||
class="g-table"
|
||||
[domains]="domains.custom"
|
||||
(delete)="delete($event.value)"
|
||||
></table>
|
||||
}
|
||||
</header>
|
||||
<authorities-table />
|
||||
</section>
|
||||
|
||||
<section class="g-card">
|
||||
<header>
|
||||
{{ 'Domains' | i18n }}
|
||||
@if (domainService.data(); as value) {
|
||||
<button
|
||||
tuiButton
|
||||
size="xs"
|
||||
iconStart="@tui.plus"
|
||||
[style.margin-inline-start]="'auto'"
|
||||
(click)="domainService.add()"
|
||||
>
|
||||
Add
|
||||
</button>
|
||||
}
|
||||
</header>
|
||||
<domains-table />
|
||||
</section>
|
||||
`,
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
imports: [
|
||||
CommonModule,
|
||||
TuiButton,
|
||||
TuiTitle,
|
||||
TuiHeader,
|
||||
TuiLink,
|
||||
RouterLink,
|
||||
TitleDirective,
|
||||
i18nPipe,
|
||||
DocsLinkDirective,
|
||||
DomainsTableComponent,
|
||||
DomainsInfoComponent,
|
||||
AuthoritiesTableComponent,
|
||||
],
|
||||
providers: [AuthorityService, DomainService],
|
||||
})
|
||||
export default class SystemDomainsComponent {
|
||||
private readonly loader = inject(LoadingService)
|
||||
private readonly errorService = inject(ErrorService)
|
||||
private readonly formDialog = inject(FormDialogService)
|
||||
private readonly patch = inject<PatchDB<DataModel>>(PatchDB)
|
||||
private readonly api = inject(ApiService)
|
||||
private readonly dialogs = inject(TuiDialogService)
|
||||
|
||||
private readonly start9To$ = this.patch.watch$(
|
||||
'serverInfo',
|
||||
'network',
|
||||
'start9To',
|
||||
)
|
||||
|
||||
readonly domains$ = this.patch.watch$('serverInfo', 'network', 'domains')
|
||||
|
||||
delete(hostname?: string) {
|
||||
this.dialogs
|
||||
.open(TUI_CONFIRM, {
|
||||
label: 'Confirm',
|
||||
size: 's',
|
||||
data: {
|
||||
content: `Delete ${hostname || 'start9.to'} domain?`,
|
||||
yes: 'Delete',
|
||||
no: 'Cancel',
|
||||
},
|
||||
})
|
||||
.pipe(filter(Boolean))
|
||||
.subscribe(() => this.deleteDomain(hostname))
|
||||
}
|
||||
|
||||
async add() {
|
||||
const proxies = await firstValueFrom(
|
||||
this.patch.watch$('serverInfo', 'network', 'proxies'),
|
||||
)
|
||||
|
||||
const options: Partial<TuiDialogOptions<FormContext<any>>> = {
|
||||
label: 'Custom Domain',
|
||||
data: {
|
||||
spec: await getCustomSpec(proxies),
|
||||
buttons: [
|
||||
{
|
||||
text: 'Manage proxies',
|
||||
link: '/system/proxies',
|
||||
},
|
||||
{
|
||||
text: 'Save',
|
||||
handler: async value => this.save(value),
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
this.formDialog.open(FormComponent, options)
|
||||
}
|
||||
|
||||
async claim() {
|
||||
const proxies = await firstValueFrom(
|
||||
this.patch.watch$('serverInfo', 'network', 'proxies'),
|
||||
)
|
||||
|
||||
const options: Partial<TuiDialogOptions<FormContext<any>>> = {
|
||||
label: 'start9.to',
|
||||
data: {
|
||||
spec: await getStart9ToSpec(proxies),
|
||||
buttons: [
|
||||
{
|
||||
text: 'Manage proxies',
|
||||
link: '/system/proxies',
|
||||
},
|
||||
{
|
||||
text: 'Save',
|
||||
handler: async value => this.claimDomain(value),
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
this.formDialog.open(FormComponent, options)
|
||||
}
|
||||
// @TODO 041 figure out how to get types here
|
||||
private getNetworkStrategy(strategy: any) {
|
||||
return strategy.selection === 'local'
|
||||
? { ipStrategy: strategy.value.ipStrategy }
|
||||
: { proxy: strategy.value.proxyId }
|
||||
}
|
||||
|
||||
private async deleteDomain(hostname?: string) {
|
||||
const loader = this.loader.open('Deleting').subscribe()
|
||||
|
||||
try {
|
||||
if (hostname) {
|
||||
await this.api.deleteDomain({ hostname })
|
||||
} else {
|
||||
await this.api.deleteStart9ToDomain({})
|
||||
}
|
||||
} catch (e: any) {
|
||||
this.errorService.handleError(e)
|
||||
} finally {
|
||||
loader.unsubscribe()
|
||||
}
|
||||
}
|
||||
// @TODO 041 figure out how to get types here
|
||||
private async claimDomain({ strategy }: any): Promise<boolean> {
|
||||
const loader = this.loader.open('Saving').subscribe()
|
||||
const networkStrategy = this.getNetworkStrategy(strategy)
|
||||
|
||||
try {
|
||||
await this.api.claimStart9ToDomain({ networkStrategy })
|
||||
return true
|
||||
} catch (e: any) {
|
||||
this.errorService.handleError(e)
|
||||
return false
|
||||
} finally {
|
||||
loader.unsubscribe()
|
||||
}
|
||||
}
|
||||
// @TODO 041 figure out how to get types here
|
||||
private async save({ provider, strategy, hostname }: any): Promise<boolean> {
|
||||
const loader = this.loader.open('Saving').subscribe()
|
||||
const name = provider.selection
|
||||
|
||||
try {
|
||||
await this.api.addDomain({
|
||||
hostname,
|
||||
networkStrategy: this.getNetworkStrategy(strategy),
|
||||
provider: {
|
||||
name,
|
||||
username: name === 'start9' ? null : provider.value.username,
|
||||
password: name === 'start9' ? null : provider.value.password,
|
||||
},
|
||||
})
|
||||
return true
|
||||
} catch (e: any) {
|
||||
this.errorService.handleError(e)
|
||||
return false
|
||||
} finally {
|
||||
loader.unsubscribe()
|
||||
}
|
||||
}
|
||||
protected readonly authorityService = inject(AuthorityService)
|
||||
protected readonly domainService = inject(DomainService)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,193 @@
|
||||
import { inject, Injectable } from '@angular/core'
|
||||
import {
|
||||
DialogService,
|
||||
ErrorService,
|
||||
i18nPipe,
|
||||
LoadingService,
|
||||
} from '@start9labs/shared'
|
||||
import { toSignal } from '@angular/core/rxjs-interop'
|
||||
import { ISB, utils } from '@start9labs/start-sdk'
|
||||
import { filter, map } from 'rxjs'
|
||||
import { FormComponent } 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 { configBuilderToSpec } from 'src/app/utils/configBuilderToSpec'
|
||||
import { PatchDB } from 'patch-db-client'
|
||||
import { DataModel } from 'src/app/services/patch-db/data-model'
|
||||
import { toAuthorityName } from 'src/app/utils/acme'
|
||||
|
||||
// @TODO translations
|
||||
|
||||
@Injectable()
|
||||
export class DomainService {
|
||||
private readonly patch = inject<PatchDB<DataModel>>(PatchDB)
|
||||
private readonly loader = inject(LoadingService)
|
||||
private readonly errorService = inject(ErrorService)
|
||||
private readonly api = inject(ApiService)
|
||||
private readonly formDialog = inject(FormDialogService)
|
||||
private readonly i18n = inject(i18nPipe)
|
||||
private readonly dialog = inject(DialogService)
|
||||
|
||||
readonly data = toSignal(
|
||||
this.patch.watch$('serverInfo', 'network').pipe(
|
||||
map(network => {
|
||||
return {
|
||||
gateways: Object.entries(network.networkInterfaces).reduce<
|
||||
Record<string, string>
|
||||
>(
|
||||
(obj, [id, n]) => ({
|
||||
...obj,
|
||||
[id]: n.ipInfo?.name || '',
|
||||
}),
|
||||
{},
|
||||
),
|
||||
// @TODO use real data
|
||||
domains: [
|
||||
{
|
||||
domain: 'blog.mydomain.com',
|
||||
gateway: {
|
||||
id: 'wireguard1',
|
||||
name: 'StartTunnel',
|
||||
},
|
||||
authority: {
|
||||
url: 'https://acme-v02.api.letsencrypt.org/directory',
|
||||
name: `Let's Encrypt`,
|
||||
},
|
||||
},
|
||||
{
|
||||
domain: 'store.mydomain.com',
|
||||
gateway: {
|
||||
id: 'eth0',
|
||||
name: 'Ethernet',
|
||||
},
|
||||
authority: {
|
||||
url: 'local',
|
||||
name: toAuthorityName(null),
|
||||
},
|
||||
},
|
||||
],
|
||||
authorities: Object.keys(network.acme).reduce<Record<string, string>>(
|
||||
(obj, url) => ({
|
||||
...obj,
|
||||
[url]: toAuthorityName(url),
|
||||
}),
|
||||
{ local: toAuthorityName(null) },
|
||||
),
|
||||
}
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
async add() {
|
||||
const addSpec = ISB.InputSpec.of({
|
||||
domain: ISB.Value.text({
|
||||
name: 'Domain',
|
||||
description:
|
||||
'Enter a domain/subdomain. For example, if you control domain.com, you could enter domain.com or subdomain.domain.com or another.subdomain.domain.com. In any case, the domain you enter and all possible subdomains of the domain will be available for assignment in StartOS',
|
||||
required: true,
|
||||
default: null,
|
||||
patterns: [utils.Patterns.domain],
|
||||
}),
|
||||
...this.gatewaysAndAuthorities(),
|
||||
})
|
||||
|
||||
this.formDialog.open(FormComponent, {
|
||||
label: 'Add Domain' as any,
|
||||
data: {
|
||||
spec: await configBuilderToSpec(addSpec),
|
||||
buttons: [
|
||||
{
|
||||
text: 'Save',
|
||||
handler: (input: typeof addSpec._TYPE) => this.save(input),
|
||||
},
|
||||
],
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
async edit(domain: any) {
|
||||
const editSpec = ISB.InputSpec.of({
|
||||
...this.gatewaysAndAuthorities(),
|
||||
})
|
||||
|
||||
this.formDialog.open(FormComponent, {
|
||||
label: 'Edit Domain',
|
||||
data: {
|
||||
spec: await configBuilderToSpec(editSpec),
|
||||
buttons: [
|
||||
{
|
||||
text: 'Save',
|
||||
handler: (input: typeof editSpec._TYPE) =>
|
||||
this.save({
|
||||
domain: domain.domain,
|
||||
...input,
|
||||
}),
|
||||
},
|
||||
],
|
||||
value: {
|
||||
gateway: domain.gateway.id,
|
||||
authority: domain.authority.url,
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
remove(domain: any) {
|
||||
this.dialog
|
||||
.openConfirm({ label: 'Are you sure?', size: 's' })
|
||||
.pipe(filter(Boolean))
|
||||
.subscribe(async () => {
|
||||
const loader = this.loader.open('Deleting').subscribe()
|
||||
|
||||
try {
|
||||
// @TODO API
|
||||
} catch (e: any) {
|
||||
this.errorService.handleError(e)
|
||||
} finally {
|
||||
loader.unsubscribe()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
showDns(domain: any) {
|
||||
// @TODO
|
||||
}
|
||||
|
||||
testDns(domain: any) {
|
||||
// @TODO
|
||||
}
|
||||
|
||||
// @TODO different endpoints for create and edit?
|
||||
private async save(params: any) {
|
||||
const loader = this.loader.open('Saving').subscribe()
|
||||
|
||||
try {
|
||||
// @TODO API
|
||||
return true
|
||||
} catch (e: any) {
|
||||
this.errorService.handleError(e)
|
||||
return false
|
||||
} finally {
|
||||
loader.unsubscribe()
|
||||
}
|
||||
}
|
||||
|
||||
private gatewaysAndAuthorities() {
|
||||
return {
|
||||
gateway: ISB.Value.select({
|
||||
name: 'Gateway',
|
||||
description:
|
||||
'Select the public gateway for this domain. Whichever gateway you select is the IP address that will be exposed to the Internet.',
|
||||
values: this.data()!.gateways,
|
||||
default: '',
|
||||
}),
|
||||
authority: ISB.Value.select({
|
||||
name: 'Default Certificate Authority',
|
||||
description:
|
||||
'Select the default certificate authority that will sign certificates for this domain. You can override this on a case-by-case basis.',
|
||||
values: this.data()!.authorities,
|
||||
default: '',
|
||||
}),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
inject,
|
||||
input,
|
||||
} from '@angular/core'
|
||||
import { i18nPipe } from '@start9labs/shared'
|
||||
import {
|
||||
TuiButton,
|
||||
TuiDataList,
|
||||
TuiDropdown,
|
||||
TuiTextfield,
|
||||
} from '@taiga-ui/core'
|
||||
import { DomainService } from './domain.service'
|
||||
|
||||
@Component({
|
||||
selector: 'tr[domain]',
|
||||
template: `
|
||||
@if (domain(); as domain) {
|
||||
<td>{{ domain.domain }}</td>
|
||||
<td [style.order]="-1">{{ domain.gateway.name }}</td>
|
||||
<td>{{ domain.authority.name }}</td>
|
||||
<td>
|
||||
<button
|
||||
tuiIconButton
|
||||
tuiDropdown
|
||||
size="s"
|
||||
appearance="flat-grayscale"
|
||||
iconStart="@tui.ellipsis-vertical"
|
||||
[tuiAppearanceState]="open ? 'hover' : null"
|
||||
[(tuiDropdownOpen)]="open"
|
||||
>
|
||||
{{ 'More' | i18n }}
|
||||
<tui-data-list size="s" *tuiTextfieldDropdown>
|
||||
<tui-opt-group>
|
||||
<button
|
||||
tuiOption
|
||||
new
|
||||
iconStart="@tui.pencil"
|
||||
(click)="domainService.edit(domain)"
|
||||
>
|
||||
{{ 'Edit' | i18n }}
|
||||
</button>
|
||||
<button
|
||||
tuiOption
|
||||
new
|
||||
iconStart="@tui.eye"
|
||||
(click)="domainService.showDns(domain)"
|
||||
>
|
||||
{{ 'Show DNS' | i18n }}
|
||||
</button>
|
||||
<button
|
||||
tuiOption
|
||||
new
|
||||
iconStart="@tui.arrow-up-down"
|
||||
(click)="domainService.testDns(domain)"
|
||||
>
|
||||
{{ 'Test DNS' | i18n }}
|
||||
</button>
|
||||
</tui-opt-group>
|
||||
<tui-opt-group>
|
||||
<button
|
||||
tuiOption
|
||||
new
|
||||
iconStart="@tui.trash"
|
||||
class="g-negative"
|
||||
(click)="domainService.remove(domain)"
|
||||
>
|
||||
{{ 'Delete' | i18n }}
|
||||
</button>
|
||||
</tui-opt-group>
|
||||
</tui-data-list>
|
||||
</button>
|
||||
</td>
|
||||
}
|
||||
`,
|
||||
styles: `
|
||||
td:last-child {
|
||||
grid-area: 1 / 2 / 4;
|
||||
align-self: center;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
:host-context(tui-root._mobile) {
|
||||
grid-template-columns: 1fr min-content;
|
||||
|
||||
td:first-child {
|
||||
font: var(--tui-font-text-m);
|
||||
font-weight: bold;
|
||||
}
|
||||
}
|
||||
`,
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
imports: [TuiButton, i18nPipe, TuiDropdown, TuiDataList, TuiTextfield],
|
||||
})
|
||||
export class DomainItemComponent {
|
||||
protected readonly domainService = inject(DomainService)
|
||||
|
||||
readonly domain = input.required<any>()
|
||||
|
||||
open = false
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import { ChangeDetectionStrategy, Component, inject } from '@angular/core'
|
||||
import { i18nPipe } from '@start9labs/shared'
|
||||
import { TuiSkeleton } from '@taiga-ui/kit'
|
||||
import { PlaceholderComponent } from 'src/app/routes/portal/components/placeholder.component'
|
||||
import { TableComponent } from 'src/app/routes/portal/components/table.component'
|
||||
import { DomainItemComponent } from './item.component'
|
||||
import { DomainService } from './domain.service'
|
||||
|
||||
@Component({
|
||||
selector: 'domains-table',
|
||||
template: `
|
||||
<table
|
||||
[appTable]="['Domain', 'Gateway', 'Default Certificate Authority', null]"
|
||||
>
|
||||
@for (domain of domainService.data()?.domains; track $index) {
|
||||
<tr [domain]="domain"></tr>
|
||||
} @empty {
|
||||
<tr>
|
||||
<td [attr.colspan]="4">
|
||||
@if (domainService.data()?.domains) {
|
||||
<app-placeholder icon="@tui.globe">
|
||||
{{ 'No domains' | i18n }}
|
||||
</app-placeholder>
|
||||
} @else {
|
||||
<div [tuiSkeleton]="true">{{ 'Loading' | i18n }}</div>
|
||||
}
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</table>
|
||||
`,
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
imports: [
|
||||
TuiSkeleton,
|
||||
i18nPipe,
|
||||
TableComponent,
|
||||
PlaceholderComponent,
|
||||
DomainItemComponent,
|
||||
],
|
||||
})
|
||||
export class DomainsTableComponent {
|
||||
protected readonly domainService = inject(DomainService)
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
import { ChangeDetectionStrategy, Component } from '@angular/core'
|
||||
import { TuiLink, TuiNotification } from '@taiga-ui/core'
|
||||
import { DocsLinkDirective } from 'projects/shared/src/public-api'
|
||||
|
||||
@Component({
|
||||
selector: 'domains-info',
|
||||
template: `
|
||||
<tui-notification>
|
||||
Adding domains permits accessing your server and services over clearnet.
|
||||
<a tuiLink docsLink href="/@TODO">View instructions</a>
|
||||
</tui-notification>
|
||||
`,
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
imports: [TuiNotification, TuiLink, DocsLinkDirective],
|
||||
})
|
||||
export class DomainsInfoComponent {}
|
||||
@@ -1,134 +0,0 @@
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
EventEmitter,
|
||||
inject,
|
||||
Input,
|
||||
Output,
|
||||
} from '@angular/core'
|
||||
import { TuiDialogService, TuiLink, TuiButton } from '@taiga-ui/core'
|
||||
import { Domain } from 'src/app/services/patch-db/data-model'
|
||||
|
||||
@Component({
|
||||
selector: 'table[domains]',
|
||||
template: `
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Domain</th>
|
||||
<th>DDNS Provider</th>
|
||||
<th>Network Strategy</th>
|
||||
<th>Used By</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (domain of domains; track $index) {
|
||||
<tr>
|
||||
<td class="title">{{ domain.value }}</td>
|
||||
<td class="provider">{{ domain.provider }}</td>
|
||||
<td class="strategy">{{ getStrategy(domain) }}</td>
|
||||
<td class="used">
|
||||
@if (domain.usedBy.length; as qty) {
|
||||
<button tuiLink (click)="onUsedBy(domain)">
|
||||
Used by: {{ qty }}
|
||||
</button>
|
||||
} @else {
|
||||
N/A
|
||||
}
|
||||
</td>
|
||||
<td class="actions">
|
||||
<button
|
||||
tuiIconButton
|
||||
size="xs"
|
||||
appearance="icon"
|
||||
iconStart="@tui.trash-2"
|
||||
(click)="delete.emit(domain)"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
} @empty {
|
||||
<tr><td colspan="6">No domains</td></tr>
|
||||
}
|
||||
</tbody>
|
||||
`,
|
||||
styles: `
|
||||
:host-context(tui-root._mobile) {
|
||||
tr {
|
||||
grid-template-columns: 2fr 1fr;
|
||||
}
|
||||
|
||||
td:only-child {
|
||||
grid-column: span 2;
|
||||
}
|
||||
|
||||
.title {
|
||||
order: 1;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.actions {
|
||||
order: 2;
|
||||
padding: 0;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.strategy {
|
||||
order: 3;
|
||||
grid-column: span 2;
|
||||
|
||||
&::before {
|
||||
content: 'Strategy: ';
|
||||
color: var(--tui-text-secondary);
|
||||
}
|
||||
}
|
||||
|
||||
.provider {
|
||||
order: 4;
|
||||
|
||||
&::before {
|
||||
content: 'DDNS: ';
|
||||
color: var(--tui-text-secondary);
|
||||
}
|
||||
}
|
||||
|
||||
.used {
|
||||
order: 5;
|
||||
text-align: right;
|
||||
|
||||
&:not(:has(button)) {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
`,
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
imports: [TuiButton, TuiLink],
|
||||
})
|
||||
export class DomainsTableComponent {
|
||||
private readonly dialogs = inject(TuiDialogService)
|
||||
|
||||
@Input()
|
||||
domains: readonly Domain[] = []
|
||||
|
||||
@Output()
|
||||
readonly delete = new EventEmitter<Domain>()
|
||||
|
||||
getStrategy(domain: any) {
|
||||
return domain.networkStrategy.ipStrategy || domain.networkStrategy.proxy
|
||||
}
|
||||
|
||||
onUsedBy({ value, usedBy }: Domain) {
|
||||
const interfaces = usedBy.map(u =>
|
||||
u.interfaces.map(i => `<li>${u.service.title} - ${i.title}</li>`),
|
||||
)
|
||||
|
||||
this.dialogs
|
||||
.open(`${value} is currently being used by:<ul>${interfaces}</ul>`, {
|
||||
label: 'Used by',
|
||||
size: 's',
|
||||
})
|
||||
.subscribe()
|
||||
}
|
||||
}
|
||||
@@ -42,7 +42,7 @@ import { configBuilderToSpec } from 'src/app/utils/configBuilderToSpec'
|
||||
<a
|
||||
tuiLink
|
||||
docsLink
|
||||
href="/user-manual/smtp"
|
||||
path="/user-manual/smtp.html"
|
||||
appearance="action-grayscale"
|
||||
iconEnd="@tui.external-link"
|
||||
[pseudo]="true"
|
||||
|
||||
@@ -0,0 +1,177 @@
|
||||
import { CommonModule } from '@angular/common'
|
||||
import { ChangeDetectionStrategy, Component, inject } from '@angular/core'
|
||||
import {
|
||||
DocsLinkDirective,
|
||||
ErrorService,
|
||||
i18nPipe,
|
||||
LoadingService,
|
||||
} from '@start9labs/shared'
|
||||
import { TuiButton, TuiLink } from '@taiga-ui/core'
|
||||
import { PatchDB } from 'patch-db-client'
|
||||
import { FormComponent } from 'src/app/routes/portal/components/form.component'
|
||||
import { DataModel } from 'src/app/services/patch-db/data-model'
|
||||
import { FormDialogService } from 'src/app/services/form-dialog.service'
|
||||
import { ApiService } from 'src/app/services/api/embassy-api.service'
|
||||
import { GatewaysTableComponent } from './table.component'
|
||||
import { configBuilderToSpec } from 'src/app/utils/configBuilderToSpec'
|
||||
import { TitleDirective } from 'src/app/services/title.service'
|
||||
import { TuiHeader } from '@taiga-ui/layout'
|
||||
import { map } from 'rxjs'
|
||||
import { ISB } from '@start9labs/start-sdk'
|
||||
import { GatewayWithID } from './item.component'
|
||||
|
||||
@Component({
|
||||
template: `
|
||||
<ng-container *title>
|
||||
<a routerLink=".." tuiIconButton iconStart="@tui.arrow-left">
|
||||
{{ 'Back' | i18n }}
|
||||
</a>
|
||||
{{ 'Gateways' | i18n }}
|
||||
</ng-container>
|
||||
<header tuiHeader>
|
||||
<hgroup tuiTitle>
|
||||
<h3>{{ 'Gateways' | i18n }}</h3>
|
||||
<p tuiSubtitle>
|
||||
{{
|
||||
'Gateways connect your server to the Internet. They process outbound traffic, and under certain conditions, they also permit inbound traffic.'
|
||||
| i18n
|
||||
}}
|
||||
<a
|
||||
tuiLink
|
||||
docsLink
|
||||
path="/user-manual/gateways.html"
|
||||
appearance="action-grayscale"
|
||||
iconEnd="@tui.external-link"
|
||||
[pseudo]="true"
|
||||
[textContent]="'View instructions' | i18n"
|
||||
></a>
|
||||
</p>
|
||||
</hgroup>
|
||||
</header>
|
||||
|
||||
<section class="g-card">
|
||||
<header>
|
||||
{{ 'Gateways' | i18n }}
|
||||
<button
|
||||
tuiButton
|
||||
size="xs"
|
||||
[style.margin]="'0 0.5rem 0 auto'"
|
||||
iconStart="@tui.plus"
|
||||
(click)="add()"
|
||||
>
|
||||
Add
|
||||
</button>
|
||||
</header>
|
||||
<div [gateways]="gateways$ | async"></div>
|
||||
</section>
|
||||
`,
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
imports: [
|
||||
CommonModule,
|
||||
TuiButton,
|
||||
GatewaysTableComponent,
|
||||
TuiHeader,
|
||||
TitleDirective,
|
||||
i18nPipe,
|
||||
TuiLink,
|
||||
DocsLinkDirective,
|
||||
],
|
||||
})
|
||||
export default class GatewaysComponent {
|
||||
private readonly loader = inject(LoadingService)
|
||||
private readonly errorService = inject(ErrorService)
|
||||
private readonly api = inject(ApiService)
|
||||
private readonly formDialog = inject(FormDialogService)
|
||||
|
||||
readonly gateways$ = inject<PatchDB<DataModel>>(PatchDB)
|
||||
.watch$('serverInfo', 'network', 'networkInterfaces')
|
||||
.pipe(
|
||||
map(gateways =>
|
||||
Object.entries(gateways).map(
|
||||
([id, val]) =>
|
||||
({
|
||||
...val,
|
||||
id,
|
||||
}) as GatewayWithID,
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
async add() {
|
||||
this.formDialog.open(FormComponent, {
|
||||
label: 'Add Gateway',
|
||||
data: {
|
||||
spec: await configBuilderToSpec(gatewaySpec),
|
||||
buttons: [
|
||||
{
|
||||
text: 'Save',
|
||||
handler: (input: typeof gatewaySpec._TYPE) => this.save(input),
|
||||
},
|
||||
],
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
private async save(input: typeof gatewaySpec._TYPE): Promise<boolean> {
|
||||
const loader = this.loader.open('Saving').subscribe()
|
||||
|
||||
try {
|
||||
await this.api.addTunnel({
|
||||
name: input.name,
|
||||
config: '' as string, // @TODO alex/matt when types arrive
|
||||
public: input.type === 'public',
|
||||
})
|
||||
return true
|
||||
} catch (e: any) {
|
||||
this.errorService.handleError(e)
|
||||
return false
|
||||
} finally {
|
||||
loader.unsubscribe()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const gatewaySpec = ISB.InputSpec.of({
|
||||
name: ISB.Value.text({
|
||||
name: 'Name',
|
||||
description: 'A name to easily identify the gateway',
|
||||
required: true,
|
||||
default: null,
|
||||
}),
|
||||
type: ISB.Value.select({
|
||||
name: 'Type',
|
||||
description:
|
||||
'-**Private**: select this option if the gateway is configured for private access to authorized clients only. StartTunnel is a private gateway.\n-**Public**: select this option if the gateway is configured for unfettered public access.',
|
||||
default: 'private',
|
||||
values: {
|
||||
private: 'Private',
|
||||
public: 'Public',
|
||||
},
|
||||
}),
|
||||
config: ISB.Value.union({
|
||||
name: 'Wireguard Config',
|
||||
default: 'paste',
|
||||
variants: ISB.Variants.of({
|
||||
paste: {
|
||||
name: 'Paste File Contents',
|
||||
spec: ISB.InputSpec.of({
|
||||
file: ISB.Value.textarea({
|
||||
name: 'Paste File Contents',
|
||||
default: null,
|
||||
required: true,
|
||||
}),
|
||||
}),
|
||||
},
|
||||
upload: {
|
||||
name: 'Upload File',
|
||||
spec: ISB.InputSpec.of({
|
||||
file: ISB.Value.file({
|
||||
name: 'File',
|
||||
required: true,
|
||||
extensions: ['.conf'],
|
||||
}),
|
||||
}),
|
||||
},
|
||||
}),
|
||||
}),
|
||||
})
|
||||
@@ -0,0 +1,195 @@
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
inject,
|
||||
input,
|
||||
} from '@angular/core'
|
||||
import {
|
||||
DialogService,
|
||||
ErrorService,
|
||||
i18nPipe,
|
||||
LoadingService,
|
||||
} from '@start9labs/shared'
|
||||
import { ISB, T } from '@start9labs/start-sdk'
|
||||
import {
|
||||
TuiButton,
|
||||
TuiDataList,
|
||||
TuiDropdown,
|
||||
TuiOptGroup,
|
||||
TuiTextfield,
|
||||
} from '@taiga-ui/core'
|
||||
import { filter } from 'rxjs'
|
||||
import { FormComponent } 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 { configBuilderToSpec } from 'src/app/utils/configBuilderToSpec'
|
||||
|
||||
export type GatewayWithID = T.NetworkInterfaceInfo & {
|
||||
id: string
|
||||
ipInfo: T.IpInfo
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'tr[proxy]',
|
||||
template: `
|
||||
<td [style.grid-column]="'span 2'">{{ proxy().ipInfo.name }}</td>
|
||||
<td class="type">{{ proxy().ipInfo.deviceType || '-' }}</td>
|
||||
<td [style.order]="-2">
|
||||
{{ proxy().public ? ('Public' | i18n) : ('Private' | i18n) }}
|
||||
</td>
|
||||
<!-- // @TODO show both LAN IPs? -->
|
||||
<td class="lan">{{ proxy().ipInfo.subnets[0] }}</td>
|
||||
<td class="wan">{{ proxy().ipInfo.wanIp }}</td>
|
||||
<td>
|
||||
<button
|
||||
tuiIconButton
|
||||
tuiDropdown
|
||||
size="s"
|
||||
appearance="flat-grayscale"
|
||||
iconStart="@tui.ellipsis-vertical"
|
||||
[tuiAppearanceState]="open ? 'hover' : null"
|
||||
[(tuiDropdownOpen)]="open"
|
||||
>
|
||||
{{ 'More' | i18n }}
|
||||
<tui-data-list size="s" *tuiTextfieldDropdown>
|
||||
<tui-opt-group>
|
||||
<button tuiOption new iconStart="@tui.pencil" (click)="rename()">
|
||||
{{ 'Rename' | i18n }}
|
||||
</button>
|
||||
</tui-opt-group>
|
||||
@if (proxy().ipInfo.deviceType === 'wireguard') {
|
||||
<tui-opt-group>
|
||||
<button
|
||||
tuiOption
|
||||
new
|
||||
iconStart="@tui.trash"
|
||||
class="g-negative"
|
||||
(click)="remove()"
|
||||
>
|
||||
{{ 'Delete' | i18n }}
|
||||
</button>
|
||||
</tui-opt-group>
|
||||
}
|
||||
</tui-data-list>
|
||||
</button>
|
||||
</td>
|
||||
`,
|
||||
styles: `
|
||||
td:last-child {
|
||||
grid-area: 1 / 3 / 5;
|
||||
align-self: center;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
:host-context(tui-root._mobile) {
|
||||
grid-template-columns: min-content 1fr min-content;
|
||||
|
||||
td:first-child {
|
||||
font: var(--tui-font-text-m);
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.type {
|
||||
order: -1;
|
||||
|
||||
&::before {
|
||||
content: '\\00A0(';
|
||||
}
|
||||
|
||||
&::after {
|
||||
content: ')';
|
||||
}
|
||||
}
|
||||
|
||||
.lan,
|
||||
.wan {
|
||||
grid-column: span 2;
|
||||
|
||||
&::before {
|
||||
content: 'LAN IPs: ';
|
||||
color: var(--tui-text-primary);
|
||||
}
|
||||
}
|
||||
|
||||
.wan::before {
|
||||
content: 'WAN IP: ';
|
||||
}
|
||||
}
|
||||
`,
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
imports: [
|
||||
TuiButton,
|
||||
TuiDropdown,
|
||||
TuiDataList,
|
||||
TuiOptGroup,
|
||||
TuiTextfield,
|
||||
i18nPipe,
|
||||
],
|
||||
})
|
||||
export class GatewaysItemComponent {
|
||||
private readonly dialog = inject(DialogService)
|
||||
private readonly loader = inject(LoadingService)
|
||||
private readonly errorService = inject(ErrorService)
|
||||
private readonly api = inject(ApiService)
|
||||
private readonly formDialog = inject(FormDialogService)
|
||||
|
||||
readonly proxy = input.required<GatewayWithID>()
|
||||
|
||||
open = false
|
||||
|
||||
remove() {
|
||||
this.dialog
|
||||
.openConfirm({ label: 'Are you sure?', size: 's' })
|
||||
.pipe(filter(Boolean))
|
||||
.subscribe(async () => {
|
||||
const loader = this.loader.open('Deleting').subscribe()
|
||||
|
||||
try {
|
||||
await this.api.removeTunnel({ id: this.proxy().id })
|
||||
} catch (e: any) {
|
||||
this.errorService.handleError(e)
|
||||
} finally {
|
||||
loader.unsubscribe()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
async rename() {
|
||||
const { ipInfo, id } = this.proxy()
|
||||
const renameSpec = ISB.InputSpec.of({
|
||||
label: ISB.Value.text({
|
||||
name: 'Label',
|
||||
required: true,
|
||||
default: ipInfo?.name || null,
|
||||
}),
|
||||
})
|
||||
|
||||
this.formDialog.open(FormComponent, {
|
||||
label: 'Rename',
|
||||
data: {
|
||||
spec: await configBuilderToSpec(renameSpec),
|
||||
buttons: [
|
||||
{
|
||||
text: 'Save',
|
||||
handler: (value: typeof renameSpec._TYPE) =>
|
||||
this.update(id, value.label),
|
||||
},
|
||||
],
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
private async update(id: string, name: string): Promise<boolean> {
|
||||
const loader = this.loader.open('Saving').subscribe()
|
||||
|
||||
try {
|
||||
await this.api.updateTunnel({ id, name })
|
||||
return true
|
||||
} catch (e: any) {
|
||||
this.errorService.handleError(e)
|
||||
return false
|
||||
} finally {
|
||||
loader.unsubscribe()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import { ChangeDetectionStrategy, Component, input } from '@angular/core'
|
||||
import { i18nPipe } from '@start9labs/shared'
|
||||
import { TuiSkeleton } from '@taiga-ui/kit'
|
||||
import { PlaceholderComponent } from 'src/app/routes/portal/components/placeholder.component'
|
||||
import { TableComponent } from 'src/app/routes/portal/components/table.component'
|
||||
import { GatewaysItemComponent, GatewayWithID } from './item.component'
|
||||
|
||||
@Component({
|
||||
selector: '[gateways]',
|
||||
template: `
|
||||
<table
|
||||
[appTable]="[
|
||||
'Name',
|
||||
'Type',
|
||||
'Access',
|
||||
$any('LAN IPs'),
|
||||
$any('WAN IP'),
|
||||
null,
|
||||
]"
|
||||
>
|
||||
@for (proxy of gateways(); track $index) {
|
||||
<tr [proxy]="proxy"></tr>
|
||||
} @empty {
|
||||
<tr>
|
||||
<td colspan="5">
|
||||
@if (gateways()) {
|
||||
<app-placeholder icon="@tui.door-closed-locked">
|
||||
<!-- @TODO Matt finalize text and add translations -->
|
||||
No gateways
|
||||
</app-placeholder>
|
||||
} @else {
|
||||
<div [tuiSkeleton]="true">{{ 'Loading' | i18n }}</div>
|
||||
}
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</table>
|
||||
`,
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
imports: [
|
||||
TuiSkeleton,
|
||||
i18nPipe,
|
||||
TableComponent,
|
||||
GatewaysItemComponent,
|
||||
PlaceholderComponent,
|
||||
],
|
||||
})
|
||||
export class GatewaysTableComponent<T extends GatewayWithID> {
|
||||
readonly gateways = input<readonly T[] | null>(null)
|
||||
}
|
||||
@@ -1,99 +0,0 @@
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
input,
|
||||
output,
|
||||
} from '@angular/core'
|
||||
import { i18nPipe } from '@start9labs/shared'
|
||||
import { T } from '@start9labs/start-sdk'
|
||||
import {
|
||||
TuiButton,
|
||||
TuiDataList,
|
||||
TuiDropdown,
|
||||
TuiOptGroup,
|
||||
} from '@taiga-ui/core'
|
||||
|
||||
export type WireguardProxy = T.NetworkInterfaceInfo & {
|
||||
id: string
|
||||
ipInfo: WireguardIpInfo
|
||||
}
|
||||
|
||||
export type WireguardIpInfo = T.IpInfo & {
|
||||
deviceType: 'wireguard'
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'tr[proxy]',
|
||||
template: `
|
||||
<td class="label">{{ proxy().ipInfo.name }}</td>
|
||||
<td class="type">
|
||||
{{ proxy().public ? ('Public' | i18n) : ('Private' | i18n) }}
|
||||
</td>
|
||||
<td class="actions">
|
||||
<button
|
||||
tuiIconButton
|
||||
iconStart="@tui.ellipsis"
|
||||
appearance="icon"
|
||||
[tuiDropdown]="content"
|
||||
[(tuiDropdownOpen)]="open"
|
||||
[tuiDropdownMaxHeight]="9999"
|
||||
>
|
||||
<img [style.max-width.%]="60" src="assets/img/icon.png" alt="StartOS" />
|
||||
</button>
|
||||
<ng-template #content>
|
||||
<tui-data-list [style.width.rem]="13">
|
||||
<tui-opt-group>
|
||||
<button
|
||||
tuiOption
|
||||
iconStart="@tui.pencil"
|
||||
(click)="onRename.emit(proxy())"
|
||||
>
|
||||
{{ 'Rename' | i18n }}
|
||||
</button>
|
||||
<button
|
||||
tuiOption
|
||||
appearance="negative"
|
||||
iconStart="@tui.trash-2"
|
||||
(click)="onRemove.emit(proxy())"
|
||||
>
|
||||
{{ 'Delete' | i18n }}
|
||||
</button>
|
||||
</tui-opt-group>
|
||||
</tui-data-list>
|
||||
</ng-template>
|
||||
</td>
|
||||
`,
|
||||
styles: `
|
||||
td:last-child {
|
||||
grid-area: 3 / span 4;
|
||||
white-space: nowrap;
|
||||
text-align: right;
|
||||
flex-direction: row-reverse;
|
||||
justify-content: flex-end;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
:host-context(tui-root._mobile) {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, min-content) 1fr;
|
||||
align-items: center;
|
||||
padding: 1rem 0.5rem;
|
||||
gap: 0.5rem;
|
||||
|
||||
td {
|
||||
display: flex;
|
||||
padding: 0;
|
||||
}
|
||||
}
|
||||
`,
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
imports: [TuiButton, i18nPipe, TuiDropdown, TuiDataList, TuiOptGroup],
|
||||
})
|
||||
export class ProxiesItemComponent {
|
||||
readonly proxy = input.required<WireguardProxy>()
|
||||
|
||||
onRename = output<WireguardProxy>()
|
||||
onRemove = output<WireguardProxy>()
|
||||
|
||||
open = false
|
||||
}
|
||||
@@ -1,181 +0,0 @@
|
||||
import { CommonModule } from '@angular/common'
|
||||
import { ChangeDetectionStrategy, Component, inject } from '@angular/core'
|
||||
import {
|
||||
DocsLinkDirective,
|
||||
ErrorService,
|
||||
i18nPipe,
|
||||
LoadingService,
|
||||
} from '@start9labs/shared'
|
||||
import { TuiButton, TuiLink } from '@taiga-ui/core'
|
||||
import { PatchDB } from 'patch-db-client'
|
||||
import { FormComponent } from 'src/app/routes/portal/components/form.component'
|
||||
import { DataModel } from 'src/app/services/patch-db/data-model'
|
||||
import { FormDialogService } from 'src/app/services/form-dialog.service'
|
||||
import { ApiService } from 'src/app/services/api/embassy-api.service'
|
||||
import { ProxiesTableComponent } from './table.component'
|
||||
import { configBuilderToSpec } from 'src/app/utils/configBuilderToSpec'
|
||||
import { TitleDirective } from 'src/app/services/title.service'
|
||||
import { TuiHeader } from '@taiga-ui/layout'
|
||||
import { map } from 'rxjs'
|
||||
import { ISB, T } from '@start9labs/start-sdk'
|
||||
import { WireguardIpInfo, WireguardProxy } from './item.component'
|
||||
|
||||
@Component({
|
||||
template: `
|
||||
<ng-container *title>
|
||||
<a routerLink=".." tuiIconButton iconStart="@tui.arrow-left">
|
||||
{{ 'Back' | i18n }}
|
||||
</a>
|
||||
{{ 'Inbound Proxies' | i18n }}
|
||||
</ng-container>
|
||||
<header tuiHeader>
|
||||
<hgroup tuiTitle>
|
||||
<h3>{{ 'Inbound Proxies' | i18n }}</h3>
|
||||
<p tuiSubtitle>
|
||||
{{
|
||||
'Inbound proxies provide remote access to your server and installed services.'
|
||||
| i18n
|
||||
}}
|
||||
<a
|
||||
tuiLink
|
||||
docsLink
|
||||
href="/user-manual/inbound-proxies"
|
||||
appearance="action-grayscale"
|
||||
iconEnd="@tui.external-link"
|
||||
[pseudo]="true"
|
||||
[textContent]="'View instructions'"
|
||||
></a>
|
||||
</p>
|
||||
</hgroup>
|
||||
</header>
|
||||
|
||||
<section class="g-card">
|
||||
<header>
|
||||
{{ 'Saved Proxies' | i18n }}
|
||||
<button tuiButton size="xs" iconStart="@tui.plus" (click)="add()">
|
||||
Add
|
||||
</button>
|
||||
</header>
|
||||
<div #table [proxies]="proxies$ | async"></div>
|
||||
</section>
|
||||
`,
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
imports: [
|
||||
CommonModule,
|
||||
TuiButton,
|
||||
ProxiesTableComponent,
|
||||
TuiHeader,
|
||||
TitleDirective,
|
||||
i18nPipe,
|
||||
TuiLink,
|
||||
DocsLinkDirective,
|
||||
],
|
||||
})
|
||||
export default class ProxiesComponent {
|
||||
private readonly loader = inject(LoadingService)
|
||||
private readonly errorService = inject(ErrorService)
|
||||
private readonly api = inject(ApiService)
|
||||
private readonly formDialog = inject(FormDialogService)
|
||||
|
||||
readonly proxies$ = inject<PatchDB<DataModel>>(PatchDB)
|
||||
.watch$('serverInfo', 'network')
|
||||
.pipe(
|
||||
map(network =>
|
||||
Object.entries(network.networkInterfaces)
|
||||
.filter(
|
||||
(
|
||||
record,
|
||||
): record is [
|
||||
string,
|
||||
T.NetworkInterfaceInfo & { ipInfo: WireguardIpInfo },
|
||||
] => record[1].ipInfo?.deviceType === 'wireguard',
|
||||
)
|
||||
.map(
|
||||
([id, val]) =>
|
||||
({
|
||||
...val,
|
||||
id,
|
||||
}) as WireguardProxy,
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
readonly wireguardSpec = ISB.InputSpec.of({
|
||||
label: ISB.Value.text({
|
||||
name: 'Label',
|
||||
description: 'To help identify this proxy',
|
||||
required: true,
|
||||
default: null,
|
||||
}),
|
||||
type: ISB.Value.select({
|
||||
name: 'Type',
|
||||
description:
|
||||
'-**Private**: a private inbound proxy is used to access your server and installed services privately. Only clients configured and authorized to use the proxy will be granted access.\n-**Public**: a public inbound proxy is used to expose service interfaces on a case-by-case basis to the public Internet without exposing your home IP address. Only service interfaces explicitly marked "Public" will be accessible via the proxy.',
|
||||
default: 'private',
|
||||
values: {
|
||||
private: 'Private',
|
||||
public: 'Public',
|
||||
},
|
||||
}),
|
||||
config: ISB.Value.union({
|
||||
name: 'Config',
|
||||
default: 'upload',
|
||||
variants: ISB.Variants.of({
|
||||
upload: {
|
||||
name: 'File',
|
||||
spec: ISB.InputSpec.of({
|
||||
file: ISB.Value.file({
|
||||
name: 'Wiregaurd Config',
|
||||
required: true,
|
||||
extensions: ['.conf'],
|
||||
}),
|
||||
}),
|
||||
},
|
||||
paste: {
|
||||
name: 'Copy/Paste',
|
||||
spec: ISB.InputSpec.of({
|
||||
file: ISB.Value.textarea({
|
||||
name: 'Paste File Contents',
|
||||
default: null,
|
||||
required: true,
|
||||
}),
|
||||
}),
|
||||
},
|
||||
}),
|
||||
}),
|
||||
})
|
||||
|
||||
async add() {
|
||||
this.formDialog.open(FormComponent, {
|
||||
label: 'Add Proxy',
|
||||
data: {
|
||||
spec: await configBuilderToSpec(this.wireguardSpec),
|
||||
buttons: [
|
||||
{
|
||||
text: 'Save',
|
||||
handler: (input: typeof this.wireguardSpec._TYPE) =>
|
||||
this.save(input),
|
||||
},
|
||||
],
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
private async save(input: typeof this.wireguardSpec._TYPE): Promise<boolean> {
|
||||
const loader = this.loader.open('Saving').subscribe()
|
||||
|
||||
try {
|
||||
await this.api.addTunnel({
|
||||
name: input.label,
|
||||
config: input.config.value.file as string, // @TODO alex this is the file represented as a string
|
||||
public: input.type === 'public',
|
||||
})
|
||||
return true
|
||||
} catch (e: any) {
|
||||
this.errorService.handleError(e)
|
||||
return false
|
||||
} finally {
|
||||
loader.unsubscribe()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,120 +0,0 @@
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
inject,
|
||||
input,
|
||||
} from '@angular/core'
|
||||
import {
|
||||
DialogService,
|
||||
ErrorService,
|
||||
i18nPipe,
|
||||
LoadingService,
|
||||
} from '@start9labs/shared'
|
||||
import { ISB } from '@start9labs/start-sdk'
|
||||
import { TuiSkeleton } from '@taiga-ui/kit'
|
||||
import { filter } from 'rxjs'
|
||||
import { FormComponent } 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 { configBuilderToSpec } from 'src/app/utils/configBuilderToSpec'
|
||||
import { TableComponent } from 'src/app/routes/portal/components/table.component'
|
||||
import { WireguardProxy } from './item.component'
|
||||
import { ProxiesItemComponent } from './item.component'
|
||||
|
||||
@Component({
|
||||
selector: '[proxies]',
|
||||
template: `
|
||||
<table [appTable]="['Label', 'Type', null]">
|
||||
@for (proxy of proxies(); track $index) {
|
||||
<tr
|
||||
[proxy]="proxy"
|
||||
(onRename)="rename($event)"
|
||||
(onRemove)="remove($event.id)"
|
||||
></tr>
|
||||
} @empty {
|
||||
@if (proxies()) {
|
||||
<tr>
|
||||
<td colspan="5">{{ 'No proxies' | i18n }}</td>
|
||||
</tr>
|
||||
} @else {
|
||||
<tr>
|
||||
<td colspan="5">
|
||||
<div [tuiSkeleton]="true">{{ 'Loading' | i18n }}</div>
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
}
|
||||
</table>
|
||||
`,
|
||||
styles: `
|
||||
:host {
|
||||
grid-column: span 6;
|
||||
}
|
||||
`,
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
imports: [TuiSkeleton, i18nPipe, TableComponent, ProxiesItemComponent],
|
||||
})
|
||||
export class ProxiesTableComponent<T extends WireguardProxy> {
|
||||
readonly proxies = input<readonly T[] | null>(null)
|
||||
|
||||
private readonly dialog = inject(DialogService)
|
||||
private readonly loader = inject(LoadingService)
|
||||
private readonly errorService = inject(ErrorService)
|
||||
private readonly api = inject(ApiService)
|
||||
private readonly formDialog = inject(FormDialogService)
|
||||
|
||||
remove(id: string) {
|
||||
this.dialog
|
||||
.openConfirm({ label: 'Are you sure?', size: 's' })
|
||||
.pipe(filter(Boolean))
|
||||
.subscribe(async () => {
|
||||
const loader = this.loader.open('Deleting').subscribe()
|
||||
|
||||
try {
|
||||
await this.api.removeTunnel({ id })
|
||||
} catch (e: any) {
|
||||
this.errorService.handleError(e)
|
||||
} finally {
|
||||
loader.unsubscribe()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
async rename(proxy: WireguardProxy) {
|
||||
const renameSpec = ISB.InputSpec.of({
|
||||
label: ISB.Value.text({
|
||||
name: 'Label',
|
||||
required: true,
|
||||
default: proxy.ipInfo?.name || null,
|
||||
}),
|
||||
})
|
||||
|
||||
this.formDialog.open(FormComponent, {
|
||||
label: 'Update Label',
|
||||
data: {
|
||||
spec: await configBuilderToSpec(renameSpec),
|
||||
buttons: [
|
||||
{
|
||||
text: 'Save',
|
||||
handler: (value: typeof renameSpec._TYPE) =>
|
||||
this.update(proxy.id, value.label),
|
||||
},
|
||||
],
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
private async update(id: string, label: string): Promise<boolean> {
|
||||
const loader = this.loader.open('Saving').subscribe()
|
||||
|
||||
try {
|
||||
await this.api.updateTunnel({ id, name: label })
|
||||
return true
|
||||
} catch (e: any) {
|
||||
this.errorService.handleError(e)
|
||||
return false
|
||||
} finally {
|
||||
loader.unsubscribe()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,43 +0,0 @@
|
||||
import { ChangeDetectionStrategy, Component, Input } from '@angular/core'
|
||||
import { TuiLink, TuiNotification } from '@taiga-ui/core'
|
||||
import { DocsLinkDirective } from 'projects/shared/src/public-api'
|
||||
|
||||
@Component({
|
||||
selector: 'router-info',
|
||||
template: `
|
||||
<tui-notification [appearance]="enabled ? 'positive' : 'warning'">
|
||||
@if (enabled) {
|
||||
<strong>UPnP Enabled!</strong>
|
||||
<p>
|
||||
The ports below have been
|
||||
<i>automatically</i>
|
||||
forwarded in your router.
|
||||
</p>
|
||||
If you are running multiple servers, you may want to override specific
|
||||
ports to suite your needs.
|
||||
<a tuiLink docsLink href="/@TODO">View instructions</a>
|
||||
} @else {
|
||||
<strong>UPnP Disabled</strong>
|
||||
<p>
|
||||
Below are a list of ports that must be
|
||||
<i>manually</i>
|
||||
forwarded in your router in order to enable clearnet access.
|
||||
</p>
|
||||
Alternatively, you can enable UPnP in your router for automatic
|
||||
configuration.
|
||||
<a tuiLink docsLink href="/@TODO">View instructions</a>
|
||||
}
|
||||
</tui-notification>
|
||||
`,
|
||||
styles: `
|
||||
strong {
|
||||
font-size: 1rem;
|
||||
}
|
||||
`,
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
imports: [TuiNotification, TuiLink, DocsLinkDirective],
|
||||
})
|
||||
export class RouterInfoComponent {
|
||||
@Input()
|
||||
enabled = false
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
import { Pipe, PipeTransform } from '@angular/core'
|
||||
import { T } from '@start9labs/start-sdk'
|
||||
|
||||
@Pipe({
|
||||
name: 'primaryIp',
|
||||
})
|
||||
export class PrimaryIpPipe implements PipeTransform {
|
||||
transform(hostnames: T.HostnameInfo[]): string {
|
||||
return (
|
||||
hostnames.map(
|
||||
h => h.kind === 'ip' && h.hostname.kind === 'ipv4' && h.hostname.value,
|
||||
)[0] || ''
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,68 +0,0 @@
|
||||
import { TuiTextfieldControllerModule } from '@taiga-ui/legacy'
|
||||
import { CommonModule } from '@angular/common'
|
||||
import { ChangeDetectionStrategy, Component, inject } from '@angular/core'
|
||||
import { PatchDB } from 'patch-db-client'
|
||||
import { DataModel } from 'src/app/services/patch-db/data-model'
|
||||
import { RouterInfoComponent } from './info.component'
|
||||
import { PrimaryIpPipe } from './primary-ip.pipe'
|
||||
import { RouterPortComponent } from './table.component'
|
||||
|
||||
@Component({
|
||||
template: `
|
||||
@if (server$ | async; as server) {
|
||||
<router-info [enabled]="!server.network.wanConfig.upnp" />
|
||||
@if (server.host.hostnameInfo[80] | primaryIp; as ip) {
|
||||
<table
|
||||
tuiTextfieldAppearance="unstyled"
|
||||
tuiTextfieldSize="m"
|
||||
[tuiTextfieldLabelOutside]="true"
|
||||
>
|
||||
<thead>
|
||||
<tr>
|
||||
<th [style.width.rem]="2.5"></th>
|
||||
<th [style.padding-left.rem]="0.75">
|
||||
<div class="g-title">Port</div>
|
||||
</th>
|
||||
<th>
|
||||
<div class="g-title">Target</div>
|
||||
</th>
|
||||
<th [style.width.rem]="3"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (
|
||||
portForward of server.network.wanConfig.forwards;
|
||||
track portForward
|
||||
) {
|
||||
<tr [portForward]="portForward" [ip]="ip"></tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
}
|
||||
}
|
||||
`,
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
styles: `
|
||||
table {
|
||||
width: 100%;
|
||||
min-width: 30rem;
|
||||
max-width: 40rem;
|
||||
table-layout: fixed;
|
||||
background: var(--tui-background-base-alt);
|
||||
border-radius: 0.75rem;
|
||||
font-size: 1rem;
|
||||
margin: 2rem 0;
|
||||
box-shadow: 0 1rem var(--tui-background-base-alt);
|
||||
}
|
||||
`,
|
||||
imports: [
|
||||
CommonModule,
|
||||
RouterInfoComponent,
|
||||
RouterPortComponent,
|
||||
TuiTextfieldControllerModule,
|
||||
PrimaryIpPipe,
|
||||
],
|
||||
})
|
||||
export default class SystemRouterComponent {
|
||||
readonly server$ = inject<PatchDB<DataModel>>(PatchDB).watch$('serverInfo')
|
||||
}
|
||||
@@ -1,143 +0,0 @@
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
inject,
|
||||
Input,
|
||||
OnChanges,
|
||||
} from '@angular/core'
|
||||
import { FormsModule } from '@angular/forms'
|
||||
import { CopyService, ErrorService, LoadingService } from '@start9labs/shared'
|
||||
import { TuiButton, TuiIcon, TuiNumberFormat } from '@taiga-ui/core'
|
||||
import {
|
||||
TuiInputModule,
|
||||
TuiInputNumberModule,
|
||||
TuiTextfieldControllerModule,
|
||||
} from '@taiga-ui/legacy'
|
||||
import { ApiService } from 'src/app/services/api/embassy-api.service'
|
||||
import { PortForward } from 'src/app/services/patch-db/data-model'
|
||||
|
||||
@Component({
|
||||
selector: 'tr[portForward]',
|
||||
template: `
|
||||
<td [style.text-align]="'right'">
|
||||
@if (portForward.error) {
|
||||
<tui-icon icon="@tui.x" [style.color]="'var(--tui-text-negative)'" />
|
||||
} @else {
|
||||
<tui-icon
|
||||
icon="@tui.check"
|
||||
[style.color]="'var(--tui-text-positive)'"
|
||||
/>
|
||||
}
|
||||
</td>
|
||||
<td>
|
||||
<tui-input-number
|
||||
[tuiNumberFormat]="{ precision: 0 }"
|
||||
[(ngModel)]="value"
|
||||
[readOnly]="!editing"
|
||||
[min]="0"
|
||||
[tuiTextfieldCustomContent]="buttons"
|
||||
>
|
||||
<input tuiTextfieldLegacy type="text" />
|
||||
</tui-input-number>
|
||||
<ng-template #buttons>
|
||||
@if (!editing) {
|
||||
<button
|
||||
tuiIconButton
|
||||
appearance="icon"
|
||||
iconStart="@tui.pencil"
|
||||
size="s"
|
||||
(click)="toggle(true)"
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
} @else {
|
||||
<button
|
||||
tuiIconButton
|
||||
appearance="icon"
|
||||
iconStart="@tui.x"
|
||||
size="s"
|
||||
(click)="toggle(false)"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
tuiIconButton
|
||||
appearance="icon"
|
||||
iconStart="@tui.check"
|
||||
size="s"
|
||||
[disabled]="!value"
|
||||
(click)="save()"
|
||||
>
|
||||
Save
|
||||
</button>
|
||||
}
|
||||
</ng-template>
|
||||
</td>
|
||||
<td>{{ ip }}:{{ portForward.target }}</td>
|
||||
<td>
|
||||
<button
|
||||
tuiIconButton
|
||||
appearance="icon"
|
||||
iconStart="@tui.copy"
|
||||
size="s"
|
||||
(click)="copyService.copy(ip + ':' + portForward.target)"
|
||||
>
|
||||
Copy
|
||||
</button>
|
||||
</td>
|
||||
`,
|
||||
styles: `
|
||||
button {
|
||||
pointer-events: auto;
|
||||
}
|
||||
`,
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
imports: [
|
||||
FormsModule,
|
||||
TuiIcon,
|
||||
TuiInputModule,
|
||||
TuiButton,
|
||||
TuiInputNumberModule,
|
||||
TuiTextfieldControllerModule,
|
||||
TuiNumberFormat,
|
||||
],
|
||||
})
|
||||
export class RouterPortComponent implements OnChanges {
|
||||
private readonly loader = inject(LoadingService)
|
||||
private readonly errorService = inject(ErrorService)
|
||||
private readonly api = inject(ApiService)
|
||||
readonly copyService = inject(CopyService)
|
||||
|
||||
@Input({ required: true })
|
||||
portForward!: PortForward
|
||||
|
||||
@Input()
|
||||
ip = ''
|
||||
|
||||
value = NaN
|
||||
editing = false
|
||||
|
||||
ngOnChanges() {
|
||||
this.value = this.portForward.override || this.portForward.assigned
|
||||
}
|
||||
|
||||
toggle(editing: boolean) {
|
||||
this.editing = editing
|
||||
this.value = this.portForward.override || this.portForward.assigned
|
||||
}
|
||||
|
||||
async save() {
|
||||
const loader = this.loader.open('Saving').subscribe()
|
||||
const { target } = this.portForward
|
||||
|
||||
try {
|
||||
await this.api.overridePortForward({ target, port: this.value })
|
||||
this.portForward.override = this.value
|
||||
this.editing = false
|
||||
} catch (e: any) {
|
||||
this.errorService.handleError(e)
|
||||
} finally {
|
||||
loader.unsubscribe()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -12,7 +12,6 @@ import { TuiIcon } from '@taiga-ui/core'
|
||||
import { TuiCheckbox, TuiFade, TuiSkeleton } from '@taiga-ui/kit'
|
||||
import { TableComponent } from 'src/app/routes/portal/components/table.component'
|
||||
import { Session } from 'src/app/services/api/api.types'
|
||||
import { toAcmeName } from 'src/app/utils/acme'
|
||||
import { PlatformInfoPipe } from './platform-info.pipe'
|
||||
import { i18nPipe } from '@start9labs/shared'
|
||||
|
||||
@@ -182,6 +181,4 @@ export class SessionsTableComponent<T extends Session> implements OnChanges {
|
||||
this.selected.update(selected => [...selected, session])
|
||||
}
|
||||
}
|
||||
|
||||
protected readonly toAcmeName = toAcmeName
|
||||
}
|
||||
|
||||
@@ -44,7 +44,7 @@ import { SSHTableComponent } from './table.component'
|
||||
<a
|
||||
tuiLink
|
||||
docsLink
|
||||
href="/user-manual/ssh"
|
||||
path="/user-manual/ssh.html"
|
||||
appearance="action-grayscale"
|
||||
iconEnd="@tui.external-link"
|
||||
[pseudo]="true"
|
||||
|
||||
@@ -39,14 +39,14 @@ export const SYSTEM_MENU = [
|
||||
],
|
||||
[
|
||||
{
|
||||
icon: '@tui.award',
|
||||
item: 'ACME',
|
||||
link: 'acme',
|
||||
icon: '@tui.door-open',
|
||||
item: 'Gateways',
|
||||
link: 'gateways',
|
||||
},
|
||||
{
|
||||
icon: '@tui.hard-drive-download',
|
||||
item: 'Inbound Proxies',
|
||||
link: 'proxies',
|
||||
icon: '@tui.globe',
|
||||
item: 'Domains',
|
||||
link: 'domains',
|
||||
},
|
||||
],
|
||||
[
|
||||
|
||||
@@ -47,11 +47,6 @@ export default [
|
||||
title: titleResolver,
|
||||
loadComponent: () => import('./routes/startos-ui/startos-ui.component'),
|
||||
},
|
||||
{
|
||||
path: 'acme',
|
||||
title: titleResolver,
|
||||
loadComponent: () => import('./routes/acme/acme.component'),
|
||||
},
|
||||
{
|
||||
path: 'wifi',
|
||||
title: titleResolver,
|
||||
@@ -73,17 +68,14 @@ export default [
|
||||
loadComponent: () => import('./routes/password/password.component'),
|
||||
},
|
||||
{
|
||||
path: 'proxies',
|
||||
loadComponent: () => import('./routes/proxies/proxies.component'),
|
||||
path: 'gateways',
|
||||
loadComponent: () => import('./routes/gateways/gateways.component'),
|
||||
},
|
||||
{
|
||||
path: 'domains',
|
||||
title: titleResolver,
|
||||
loadComponent: () => import('./routes/domains/domains.component'),
|
||||
},
|
||||
// {
|
||||
// path: 'domains',
|
||||
// loadComponent: () => import('./routes/domains/domains.component')
|
||||
// },
|
||||
// {
|
||||
// path: 'router',
|
||||
// loadComponent: () => import('./routes/router/router.component')
|
||||
// },
|
||||
],
|
||||
},
|
||||
] satisfies Routes
|
||||
|
||||
@@ -24,7 +24,7 @@ import { AuthService } from '../auth.service'
|
||||
import { T } from '@start9labs/start-sdk'
|
||||
import { MarketplacePkg } from '@start9labs/marketplace'
|
||||
import { WebSocketSubject } from 'rxjs/webSocket'
|
||||
import { toAcmeUrl } from 'src/app/utils/acme'
|
||||
import { toAuthorityUrl } from 'src/app/utils/acme'
|
||||
|
||||
import markdown from './md-sample.md'
|
||||
|
||||
@@ -1396,7 +1396,7 @@ export class MockApiService extends ApiService {
|
||||
op: PatchOp.ADD,
|
||||
path: `/serverInfo/acme`,
|
||||
value: {
|
||||
[toAcmeUrl(params.provider)]: { contact: params.contact },
|
||||
[toAuthorityUrl(params.provider)]: { contact: params.contact },
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { DataModel } from 'src/app/services/patch-db/data-model'
|
||||
import { Mock } from './api.fixures'
|
||||
import { knownACME } from 'src/app/utils/acme'
|
||||
import { knownAuthorities } from 'src/app/utils/acme'
|
||||
const version = require('../../../../../../package.json').version
|
||||
|
||||
export const mockPatchData: DataModel = {
|
||||
@@ -28,7 +28,7 @@ export const mockPatchData: DataModel = {
|
||||
lastRegion: null,
|
||||
},
|
||||
acme: {
|
||||
[knownACME[0].url]: {
|
||||
[knownAuthorities[0].url]: {
|
||||
contact: ['mailto:support@start9.com'],
|
||||
},
|
||||
},
|
||||
@@ -142,7 +142,7 @@ export const mockPatchData: DataModel = {
|
||||
scopeId: 1,
|
||||
deviceType: 'ethernet',
|
||||
subnets: ['10.0.0.2/24'],
|
||||
wanIp: null,
|
||||
wanIp: '203.0.113.45',
|
||||
ntpServers: [],
|
||||
},
|
||||
},
|
||||
@@ -156,7 +156,21 @@ export const mockPatchData: DataModel = {
|
||||
'10.0.90.12/24',
|
||||
'fe80::cd00:0000:0cde:1257:0000:211e:72cd/64',
|
||||
],
|
||||
wanIp: null,
|
||||
wanIp: '203.0.113.45',
|
||||
ntpServers: [],
|
||||
},
|
||||
},
|
||||
wireguard1: {
|
||||
public: false,
|
||||
ipInfo: {
|
||||
name: 'StartTunnel',
|
||||
scopeId: 2,
|
||||
deviceType: 'wireguard',
|
||||
subnets: [
|
||||
'10.0.90.12/24',
|
||||
'fe80::cd00:0000:0cde:1257:0000:211e:72cd/64',
|
||||
],
|
||||
wanIp: '203.0.113.45',
|
||||
ntpServers: [],
|
||||
},
|
||||
},
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
export function toAcmeName(url: string | null): string | 'System CA' {
|
||||
return knownACME.find(acme => acme.url === url)?.name || url || 'System CA'
|
||||
export function toAuthorityName(url: string | null): string | 'Local Root CA' {
|
||||
return (
|
||||
knownAuthorities.find(ca => ca.url === url)?.name || url || 'Local Root CA'
|
||||
)
|
||||
}
|
||||
|
||||
export function toAcmeUrl(name: string): string {
|
||||
return knownACME.find(acme => acme.name === name)?.url || name
|
||||
export function toAuthorityUrl(name: string): string {
|
||||
return knownAuthorities.find(ca => ca.name === name)?.url || name
|
||||
}
|
||||
|
||||
export const knownACME = [
|
||||
export const knownAuthorities = [
|
||||
{
|
||||
name: `Let's Encrypt`,
|
||||
url: 'https://acme-v02.api.letsencrypt.org/directory',
|
||||
|
||||
Reference in New Issue
Block a user