Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 1fdff79dd0 | |||
| 59b52d08fa | |||
| 2cdc392a40 | |||
| 433047bbf1 | |||
| 0b81c95de2 | |||
| 196e5dfc1b | |||
| 60d095cd78 | |||
| 2861511d20 |
29
changelog.md
29
changelog.md
@@ -1,5 +1,34 @@
|
||||
# Changelog
|
||||
|
||||
## 2026-04-12 - 13.12.0 - feat(email-domains)
|
||||
support creating email domains on optional subdomains
|
||||
|
||||
- Add optional subdomain support to email domain creation, persistence, and API interfaces.
|
||||
- Update the ops UI to collect and submit a subdomain prefix when creating an email domain.
|
||||
- Bump @design.estate/dees-catalog from ^3.78.0 to ^3.78.2.
|
||||
|
||||
## 2026-04-12 - 13.11.0 - feat(email-domains)
|
||||
add email domain management with DNS provisioning, validation, and ops dashboard support
|
||||
|
||||
- Introduce EmailDomainManager with persisted email domain records, DKIM configuration, DNS record generation, provisioning, and validation.
|
||||
- Add opsserver typed request handlers and shared interfaces for listing, creating, updating, deleting, validating, and provisioning email domains.
|
||||
- Add ops dashboard email domains view and app state integration for managing domains and inspecting required DNS records.
|
||||
|
||||
## 2026-04-12 - 13.10.0 - feat(web-ui)
|
||||
standardize settings views for ACME and email security panels
|
||||
|
||||
- replace custom ACME settings layouts with the reusable dees-settings component for configured and empty states
|
||||
- update the email security view to present settings through dees-settings and open a modal-based read-only edit dialog
|
||||
- bump @design.estate/dees-catalog to ^3.78.0 to support the updated UI components
|
||||
|
||||
## 2026-04-12 - 13.9.2 - fix(web-ui)
|
||||
improve form field descriptions and align certificate settings with tile components
|
||||
|
||||
- Refines labels and adds descriptive helper text across API token, DNS, domain, route, edge, target profile, and VPN forms for clearer operator input
|
||||
- Updates the DNS provider form to surface provider and credential guidance through built-in input metadata instead of custom help blocks
|
||||
- Restyles the certificates ACME settings section to use tile-based layout and improves related form wording and file upload metadata
|
||||
- Refreshes the Cloudflare DNS provider description and bumps UI-related dependencies
|
||||
|
||||
## 2026-04-08 - 13.9.1 - fix(network-ui)
|
||||
enable flashing table updates for network activity, remote ingress, and VPN views
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@serve.zone/dcrouter",
|
||||
"private": false,
|
||||
"version": "13.9.1",
|
||||
"version": "13.12.0",
|
||||
"description": "A multifaceted routing service handling mail and SMS delivery functions.",
|
||||
"type": "module",
|
||||
"exports": {
|
||||
@@ -27,7 +27,7 @@
|
||||
"@git.zone/tsrun": "^2.0.2",
|
||||
"@git.zone/tstest": "^3.6.3",
|
||||
"@git.zone/tswatch": "^3.3.2",
|
||||
"@types/node": "^25.5.2"
|
||||
"@types/node": "^25.6.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"@api.global/typedrequest": "^3.3.0",
|
||||
@@ -35,7 +35,7 @@
|
||||
"@api.global/typedserver": "^8.4.6",
|
||||
"@api.global/typedsocket": "^4.1.2",
|
||||
"@apiclient.xyz/cloudflare": "^7.1.0",
|
||||
"@design.estate/dees-catalog": "^3.70.0",
|
||||
"@design.estate/dees-catalog": "^3.78.2",
|
||||
"@design.estate/dees-element": "^2.2.4",
|
||||
"@push.rocks/lik": "^6.4.0",
|
||||
"@push.rocks/projectinfo": "^5.1.0",
|
||||
@@ -67,7 +67,7 @@
|
||||
"@serve.zone/remoteingress": "^4.15.3",
|
||||
"@tsclass/tsclass": "^9.5.0",
|
||||
"@types/qrcode": "^1.5.6",
|
||||
"lru-cache": "^11.3.2",
|
||||
"lru-cache": "^11.3.3",
|
||||
"qrcode": "^1.5.4",
|
||||
"uuid": "^13.0.0"
|
||||
},
|
||||
|
||||
124
pnpm-lock.yaml
generated
124
pnpm-lock.yaml
generated
@@ -24,8 +24,8 @@ importers:
|
||||
specifier: ^7.1.0
|
||||
version: 7.1.0
|
||||
'@design.estate/dees-catalog':
|
||||
specifier: ^3.70.0
|
||||
version: 3.70.0(@tiptap/pm@2.27.2)
|
||||
specifier: ^3.78.2
|
||||
version: 3.78.2(@tiptap/pm@2.27.2)
|
||||
'@design.estate/dees-element':
|
||||
specifier: ^2.2.4
|
||||
version: 2.2.4
|
||||
@@ -120,8 +120,8 @@ importers:
|
||||
specifier: ^1.5.6
|
||||
version: 1.5.6
|
||||
lru-cache:
|
||||
specifier: ^11.3.2
|
||||
version: 11.3.2
|
||||
specifier: ^11.3.3
|
||||
version: 11.3.3
|
||||
qrcode:
|
||||
specifier: ^1.5.4
|
||||
version: 1.5.4
|
||||
@@ -145,8 +145,8 @@ importers:
|
||||
specifier: ^3.3.2
|
||||
version: 3.3.2(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)(@tiptap/pm@2.27.2)
|
||||
'@types/node':
|
||||
specifier: ^25.5.2
|
||||
version: 25.5.2
|
||||
specifier: ^25.6.0
|
||||
version: 25.6.0
|
||||
|
||||
packages:
|
||||
|
||||
@@ -353,8 +353,8 @@ packages:
|
||||
'@configvault.io/interfaces@1.0.17':
|
||||
resolution: {integrity: sha512-bEcCUR2VBDJsTin8HQh8Uw/mlYl2v8A3jMIaQ+MTB9Hrqd6CZL2dL7iJdWyFl/3EIX+LDxWFR+Oq7liIq7w+1Q==}
|
||||
|
||||
'@design.estate/dees-catalog@3.70.0':
|
||||
resolution: {integrity: sha512-bNqOxxl83FNCCV+7QoUj6oeRC0VTExWOClrLrHNMoLIU0TCtzhcmQqiuJhdWrcCwZ5RBhXHGMSFsR62d2RcWpw==}
|
||||
'@design.estate/dees-catalog@3.78.2':
|
||||
resolution: {integrity: sha512-9MKKCvx+vxoIp6UpqVQklreokdg7ZSSODz4FlKyNFqjfZiDDme6pjwxWoMSA+Tn4bkboYyCBosUrVfc0nxa1HA==}
|
||||
|
||||
'@design.estate/dees-comms@1.0.30':
|
||||
resolution: {integrity: sha512-KchMlklJfKAjQiJiR0xmofXtQ27VgZtBIxcMwPE9d+h3jJRv+lPZxzBQVOM0eyM0uS44S5vJMZ11IeV4uDXSHg==}
|
||||
@@ -368,6 +368,9 @@ packages:
|
||||
'@design.estate/dees-wcctools@3.8.0':
|
||||
resolution: {integrity: sha512-CC14iVKUrguzD9jIrdPBd9fZ4egVJEZMxl5y8iy0l7WLumeoYvGsoXj5INVkRPLRVLqziIdi4Je1hXqHt2NU+g==}
|
||||
|
||||
'@design.estate/dees-wcctools@3.9.0':
|
||||
resolution: {integrity: sha512-0vZBaGBEGIbl8hx+8BezIIea3U5T7iSHHF9VqlJZGf+nOFIW4zBAxcCljH8YzZ1Yayp6BEjxp/pQXjHN2YB3Jg==}
|
||||
|
||||
'@emnapi/core@1.9.2':
|
||||
resolution: {integrity: sha512-UC+ZhH3XtczQYfOlu3lNEkdW/p4dsJ1r/bP7H8+rhao3TTTMO1ATq/4DdIi23XuGoFY+Cz0JmCbdVl0hz9jZcA==}
|
||||
|
||||
@@ -1994,6 +1997,12 @@ packages:
|
||||
'@types/debug@4.1.13':
|
||||
resolution: {integrity: sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw==}
|
||||
|
||||
'@types/dom-mediacapture-transform@0.1.11':
|
||||
resolution: {integrity: sha512-Y2p+nGf1bF2XMttBnsVPHUWzRRZzqUoJAKmiP10b5umnO6DDrWI0BrGDJy1pOHoOULVmGSfFNkQrAlC5dcj6nQ==}
|
||||
|
||||
'@types/dom-webcodecs@0.1.13':
|
||||
resolution: {integrity: sha512-O5hkiFIcjjszPIYyUSyvScyvrBoV3NOEEZx/pMlsu44TKzWNkLVBBxnxJz42in5n3QIolYOcBYFCPZZ0h8SkwQ==}
|
||||
|
||||
'@types/fs-extra@11.0.4':
|
||||
resolution: {integrity: sha512-yTbItCNreRooED33qjunPthRcSjERP1r4MqCZc7wv0u2sUkzTFp45tgUfS5+r7FrZPdmCCNflLhVSP/o+SemsQ==}
|
||||
|
||||
@@ -2054,8 +2063,8 @@ packages:
|
||||
'@types/node@22.19.17':
|
||||
resolution: {integrity: sha512-wGdMcf+vPYM6jikpS/qhg6WiqSV/OhG+jeeHT/KlVqxYfD40iYJf9/AE1uQxVWFvU7MipKRkRv8NSHiCGgPr8Q==}
|
||||
|
||||
'@types/node@25.5.2':
|
||||
resolution: {integrity: sha512-tO4ZIRKNC+MDWV4qKVZe3Ql/woTnmHDr5JD8UI5hn2pwBrHEwOEMZK7WlNb5RKB6EoJ02gwmQS9OrjuFnZYdpg==}
|
||||
'@types/node@25.6.0':
|
||||
resolution: {integrity: sha512-+qIYRKdNYJwY3vRCZMdJbPLJAtGjQBudzZzdzwQYkEPQd+PJGixUL5QfvCLDaULoLv+RhT3LDkwEfKaAkgSmNQ==}
|
||||
|
||||
'@types/qrcode@1.5.6':
|
||||
resolution: {integrity: sha512-te7NQcV2BOvdj2b1hCAHzAoMNuj65kNBMz0KBaxM6c3VGBOhU0dURQKOtH8CFNI/dsKkwlv32p26qYQTWoB5bw==}
|
||||
@@ -2858,8 +2867,8 @@ packages:
|
||||
humanize-ms@1.2.1:
|
||||
resolution: {integrity: sha1-xG4xWaKT9riW2ikxbYtv6Lt5u+0=}
|
||||
|
||||
ibantools@4.5.2:
|
||||
resolution: {integrity: sha512-is+8TgZcKS/AMv/z9nW1zz0bhjhoyjpA1p0nc3A6GkW/InOdcQiUZpkufADzh/aO/LY/TOD/P3oPWncNRn5QMA==}
|
||||
ibantools@4.5.4:
|
||||
resolution: {integrity: sha512-6jX1gh4aH6XH+o0ey+wtkMTzkcvsEta7DakIOZSng9voZYpMw3U+gK1+tZChk3aRcPcloEt0NOzksjaRZiqXbw==}
|
||||
|
||||
iconv-lite@0.4.24:
|
||||
resolution: {integrity: sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==}
|
||||
@@ -3076,16 +3085,16 @@ packages:
|
||||
resolution: {integrity: sha512-ozCC6gdQ+glXOQsveKD0YsDy8DSQFjDTz4zyzEHNV5+JP5D62LmfDZ6o1cycFx9ouG940M5dE8C8CTewdj2YWQ==}
|
||||
engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0}
|
||||
|
||||
lru-cache@11.3.2:
|
||||
resolution: {integrity: sha512-wgWa6FWQ3QRRJbIjbsldRJZxdxYngT/dO0I5Ynmlnin8qy7tC6xYzbcJjtN4wHLXtkbVwHzk0C+OejVw1XM+DQ==}
|
||||
lru-cache@11.3.3:
|
||||
resolution: {integrity: sha512-JvNw9Y81y33E+BEYPr0U7omo+U9AySnsMsEiXgwT6yqd31VQWTLNQqmT4ou5eqPFUrTfIDFta2wKhB1hyohtAQ==}
|
||||
engines: {node: 20 || >=22}
|
||||
|
||||
lru-cache@7.18.3:
|
||||
resolution: {integrity: sha512-jumlc0BIUrS3qJGgIkWZsyfAM7NCWiBcCDhnd+3NNM5KbBmLTgHVfWBcg6W+rLUsIpzpERPsvwUP7CckAQSOoA==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
lucide@0.577.0:
|
||||
resolution: {integrity: sha512-PpC/m5eOItp/WU/GlQPFBXDOhq6HibL73KzYP37OX3LM7VmzWQF8voEj8QRWUFvy9FIKfeDQkWYoyS1D/MdWFA==}
|
||||
lucide@1.8.0:
|
||||
resolution: {integrity: sha512-JjV/QnadgFLj1Pyu9IKl0lknrolFEzo04B64QcYLLeRzZl/iEHpdbSrRRKbyXcv45SZNv+WGjIUCT33e7xHO6Q==}
|
||||
|
||||
mailparser@3.9.6:
|
||||
resolution: {integrity: sha512-EJYTDWMrOS1kddK1mTsRkrx2Ngh2nYsg54SRMWVVWGVEGbHH4tod8tqqU9hIRPgGQVboSjFubDn9cboSitbM3Q==}
|
||||
@@ -3163,6 +3172,9 @@ packages:
|
||||
mdurl@2.0.0:
|
||||
resolution: {integrity: sha512-Lf+9+2r+Tdp5wXDXC4PcIBjTDtq4UKjCPMQhKIuzpJNW0b96kVqSwW0bT7FhRSfmAiFYgP+SCRvdrDozfh0U5w==}
|
||||
|
||||
mediabunny@1.40.1:
|
||||
resolution: {integrity: sha512-HU/stGzAkdWaJIly6ypbUVgAUvT9kt39DIg0IaErR7/1fwtTmgUYs4i8uEPYcgcjPjbB9gtBmUXOLnXi6J2LDw==}
|
||||
|
||||
memory-pager@1.5.0:
|
||||
resolution: {integrity: sha512-ZS4Bp4r/Zoeq6+NLJpP+0Zzm0pR8whtGPf1XExKLJBAczGMnSi3It14OiNCStjQjM6NU1okjQGSxgEZN8eBYKg==}
|
||||
|
||||
@@ -4098,8 +4110,8 @@ packages:
|
||||
undici-types@6.21.0:
|
||||
resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==}
|
||||
|
||||
undici-types@7.18.2:
|
||||
resolution: {integrity: sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==}
|
||||
undici-types@7.19.2:
|
||||
resolution: {integrity: sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg==}
|
||||
|
||||
unified@11.0.5:
|
||||
resolution: {integrity: sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA==}
|
||||
@@ -4315,7 +4327,7 @@ snapshots:
|
||||
'@api.global/typedrequest-interfaces': 3.0.19
|
||||
'@api.global/typedsocket': 4.1.2(@push.rocks/smartserve@2.0.3)
|
||||
'@cloudflare/workers-types': 4.20260405.1
|
||||
'@design.estate/dees-catalog': 3.70.0(@tiptap/pm@2.27.2)
|
||||
'@design.estate/dees-catalog': 3.78.2(@tiptap/pm@2.27.2)
|
||||
'@design.estate/dees-comms': 1.0.30
|
||||
'@push.rocks/lik': 6.4.0
|
||||
'@push.rocks/smartdelay': 3.0.5
|
||||
@@ -4844,11 +4856,11 @@ snapshots:
|
||||
dependencies:
|
||||
'@api.global/typedrequest-interfaces': 3.0.19
|
||||
|
||||
'@design.estate/dees-catalog@3.70.0(@tiptap/pm@2.27.2)':
|
||||
'@design.estate/dees-catalog@3.78.2(@tiptap/pm@2.27.2)':
|
||||
dependencies:
|
||||
'@design.estate/dees-domtools': 2.5.4
|
||||
'@design.estate/dees-element': 2.2.4
|
||||
'@design.estate/dees-wcctools': 3.8.0
|
||||
'@design.estate/dees-wcctools': 3.9.0
|
||||
'@fortawesome/fontawesome-svg-core': 7.2.0
|
||||
'@fortawesome/free-brands-svg-icons': 7.2.0
|
||||
'@fortawesome/free-regular-svg-icons': 7.2.0
|
||||
@@ -4866,9 +4878,9 @@ snapshots:
|
||||
'@tsclass/tsclass': 9.5.0
|
||||
echarts: 5.6.0
|
||||
highlight.js: 11.11.1
|
||||
ibantools: 4.5.2
|
||||
ibantools: 4.5.4
|
||||
lightweight-charts: 5.1.0
|
||||
lucide: 0.577.0
|
||||
lucide: 1.8.0
|
||||
monaco-editor: 0.55.1
|
||||
pdfjs-dist: 4.10.38
|
||||
xterm: 5.3.0
|
||||
@@ -4937,6 +4949,19 @@ snapshots:
|
||||
- supports-color
|
||||
- vue
|
||||
|
||||
'@design.estate/dees-wcctools@3.9.0':
|
||||
dependencies:
|
||||
'@design.estate/dees-domtools': 2.5.4
|
||||
'@design.estate/dees-element': 2.2.4
|
||||
'@push.rocks/smartdelay': 3.0.5
|
||||
lit: 3.3.2
|
||||
mediabunny: 1.40.1
|
||||
transitivePeerDependencies:
|
||||
- '@nuxt/kit'
|
||||
- react
|
||||
- supports-color
|
||||
- vue
|
||||
|
||||
'@emnapi/core@1.9.2':
|
||||
dependencies:
|
||||
'@emnapi/wasi-threads': 1.2.1
|
||||
@@ -6404,7 +6429,7 @@ snapshots:
|
||||
'@push.rocks/smartpath': 6.0.0
|
||||
'@push.rocks/smartrust': 1.3.2
|
||||
'@tsclass/tsclass': 9.5.0
|
||||
lru-cache: 11.3.2
|
||||
lru-cache: 11.3.3
|
||||
mailparser: 3.9.6
|
||||
uuid: 13.0.0
|
||||
transitivePeerDependencies:
|
||||
@@ -6900,7 +6925,7 @@ snapshots:
|
||||
|
||||
'@serve.zone/catalog@2.12.3(@tiptap/pm@2.27.2)':
|
||||
dependencies:
|
||||
'@design.estate/dees-catalog': 3.70.0(@tiptap/pm@2.27.2)
|
||||
'@design.estate/dees-catalog': 3.78.2(@tiptap/pm@2.27.2)
|
||||
'@design.estate/dees-domtools': 2.5.4
|
||||
'@design.estate/dees-element': 2.2.4
|
||||
'@design.estate/dees-wcctools': 3.8.0
|
||||
@@ -7442,17 +7467,23 @@ snapshots:
|
||||
|
||||
'@types/clean-css@4.2.11':
|
||||
dependencies:
|
||||
'@types/node': 25.5.2
|
||||
'@types/node': 25.6.0
|
||||
source-map: 0.6.1
|
||||
|
||||
'@types/debug@4.1.13':
|
||||
dependencies:
|
||||
'@types/ms': 2.1.0
|
||||
|
||||
'@types/dom-mediacapture-transform@0.1.11':
|
||||
dependencies:
|
||||
'@types/dom-webcodecs': 0.1.13
|
||||
|
||||
'@types/dom-webcodecs@0.1.13': {}
|
||||
|
||||
'@types/fs-extra@11.0.4':
|
||||
dependencies:
|
||||
'@types/jsonfile': 6.1.4
|
||||
'@types/node': 25.5.2
|
||||
'@types/node': 25.6.0
|
||||
|
||||
'@types/hast@3.0.4':
|
||||
dependencies:
|
||||
@@ -7472,12 +7503,12 @@ snapshots:
|
||||
|
||||
'@types/jsonfile@6.1.4':
|
||||
dependencies:
|
||||
'@types/node': 25.5.2
|
||||
'@types/node': 25.6.0
|
||||
|
||||
'@types/jsonwebtoken@9.0.10':
|
||||
dependencies:
|
||||
'@types/ms': 2.1.0
|
||||
'@types/node': 25.5.2
|
||||
'@types/node': 25.6.0
|
||||
|
||||
'@types/linkify-it@5.0.0': {}
|
||||
|
||||
@@ -7498,16 +7529,16 @@ snapshots:
|
||||
|
||||
'@types/mute-stream@0.0.4':
|
||||
dependencies:
|
||||
'@types/node': 25.5.2
|
||||
'@types/node': 25.6.0
|
||||
|
||||
'@types/node-fetch@2.6.13':
|
||||
dependencies:
|
||||
'@types/node': 25.5.2
|
||||
'@types/node': 25.6.0
|
||||
form-data: 4.0.5
|
||||
|
||||
'@types/node-forge@1.3.14':
|
||||
dependencies:
|
||||
'@types/node': 25.5.2
|
||||
'@types/node': 25.6.0
|
||||
|
||||
'@types/node@16.9.1': {}
|
||||
|
||||
@@ -7519,13 +7550,13 @@ snapshots:
|
||||
dependencies:
|
||||
undici-types: 6.21.0
|
||||
|
||||
'@types/node@25.5.2':
|
||||
'@types/node@25.6.0':
|
||||
dependencies:
|
||||
undici-types: 7.18.2
|
||||
undici-types: 7.19.2
|
||||
|
||||
'@types/qrcode@1.5.6':
|
||||
dependencies:
|
||||
'@types/node': 25.5.2
|
||||
'@types/node': 25.6.0
|
||||
|
||||
'@types/randomatic@3.1.5': {}
|
||||
|
||||
@@ -7535,11 +7566,11 @@ snapshots:
|
||||
|
||||
'@types/tar-stream@3.1.4':
|
||||
dependencies:
|
||||
'@types/node': 25.5.2
|
||||
'@types/node': 25.6.0
|
||||
|
||||
'@types/through2@2.0.41':
|
||||
dependencies:
|
||||
'@types/node': 25.5.2
|
||||
'@types/node': 25.6.0
|
||||
|
||||
'@types/trusted-types@2.0.7': {}
|
||||
|
||||
@@ -7569,11 +7600,11 @@ snapshots:
|
||||
|
||||
'@types/ws@8.18.1':
|
||||
dependencies:
|
||||
'@types/node': 25.5.2
|
||||
'@types/node': 25.6.0
|
||||
|
||||
'@types/yauzl@2.10.3':
|
||||
dependencies:
|
||||
'@types/node': 25.5.2
|
||||
'@types/node': 25.6.0
|
||||
optional: true
|
||||
|
||||
'@ungap/structured-clone@1.3.0': {}
|
||||
@@ -8390,7 +8421,7 @@ snapshots:
|
||||
dependencies:
|
||||
ms: 2.1.3
|
||||
|
||||
ibantools@4.5.2: {}
|
||||
ibantools@4.5.4: {}
|
||||
|
||||
iconv-lite@0.4.24:
|
||||
dependencies:
|
||||
@@ -8628,11 +8659,11 @@ snapshots:
|
||||
|
||||
lowercase-keys@3.0.0: {}
|
||||
|
||||
lru-cache@11.3.2: {}
|
||||
lru-cache@11.3.3: {}
|
||||
|
||||
lru-cache@7.18.3: {}
|
||||
|
||||
lucide@0.577.0: {}
|
||||
lucide@1.8.0: {}
|
||||
|
||||
mailparser@3.9.6:
|
||||
dependencies:
|
||||
@@ -8804,6 +8835,11 @@ snapshots:
|
||||
|
||||
mdurl@2.0.0: {}
|
||||
|
||||
mediabunny@1.40.1:
|
||||
dependencies:
|
||||
'@types/dom-mediacapture-transform': 0.1.11
|
||||
'@types/dom-webcodecs': 0.1.13
|
||||
|
||||
memory-pager@1.5.0: {}
|
||||
|
||||
micromark-core-commonmark@2.0.3:
|
||||
@@ -9268,7 +9304,7 @@ snapshots:
|
||||
|
||||
path-scurry@2.0.2:
|
||||
dependencies:
|
||||
lru-cache: 11.3.2
|
||||
lru-cache: 11.3.3
|
||||
minipass: 7.1.3
|
||||
|
||||
path-to-regexp@8.4.2: {}
|
||||
@@ -9942,7 +9978,7 @@ snapshots:
|
||||
|
||||
undici-types@6.21.0: {}
|
||||
|
||||
undici-types@7.18.2: {}
|
||||
undici-types@7.19.2: {}
|
||||
|
||||
unified@11.0.5:
|
||||
dependencies:
|
||||
|
||||
@@ -3,6 +3,6 @@
|
||||
*/
|
||||
export const commitinfo = {
|
||||
name: '@serve.zone/dcrouter',
|
||||
version: '13.9.1',
|
||||
version: '13.12.0',
|
||||
description: 'A multifaceted routing service handling mail and SMS delivery functions.'
|
||||
}
|
||||
|
||||
@@ -29,6 +29,7 @@ import { SecurityLogger, ContentScanner, IPReputationChecker } from './security/
|
||||
import { type IHttp3Config, augmentRoutesWithHttp3 } from './http3/index.js';
|
||||
import { DnsManager } from './dns/manager.dns.js';
|
||||
import { AcmeConfigManager } from './acme/manager.acme-config.js';
|
||||
import { EmailDomainManager } from './email/classes.email-domain.manager.js';
|
||||
|
||||
export interface IDcRouterOptions {
|
||||
/** Base directory for all dcrouter data. Defaults to ~/.serve.zone/dcrouter */
|
||||
@@ -279,6 +280,7 @@ export class DcRouter {
|
||||
|
||||
// ACME configuration (DB-backed singleton, replaces tls.contactEmail)
|
||||
public acmeConfigManager?: AcmeConfigManager;
|
||||
public emailDomainManager?: EmailDomainManager;
|
||||
|
||||
// Auto-discovered public IP (populated by generateAuthoritativeRecords)
|
||||
public detectedPublicIp: string | null = null;
|
||||
@@ -439,6 +441,21 @@ export class DcRouter {
|
||||
);
|
||||
}
|
||||
|
||||
// Email Domain Manager: optional, depends on DcRouterDb
|
||||
if (this.options.dbConfig?.enabled !== false) {
|
||||
this.serviceManager.addService(
|
||||
new plugins.taskbuffer.Service('EmailDomainManager')
|
||||
.optional()
|
||||
.dependsOn('DcRouterDb')
|
||||
.withStart(async () => {
|
||||
this.emailDomainManager = new EmailDomainManager(this);
|
||||
})
|
||||
.withStop(async () => {
|
||||
this.emailDomainManager = undefined;
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
// SmartProxy: critical, depends on DcRouterDb + DnsManager + AcmeConfigManager (if enabled)
|
||||
const smartProxyDeps: string[] = [];
|
||||
if (this.options.dbConfig?.enabled !== false) {
|
||||
|
||||
56
ts/db/documents/classes.email-domain.doc.ts
Normal file
56
ts/db/documents/classes.email-domain.doc.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
import * as plugins from '../../plugins.js';
|
||||
import { DcRouterDb } from '../classes.dcrouter-db.js';
|
||||
import type {
|
||||
IEmailDomainDkim,
|
||||
IEmailDomainRateLimits,
|
||||
IEmailDomainDnsStatus,
|
||||
} from '../../../ts_interfaces/data/email-domain.js';
|
||||
|
||||
const getDb = () => DcRouterDb.getInstance().getDb();
|
||||
|
||||
@plugins.smartdata.Collection(() => getDb())
|
||||
export class EmailDomainDoc extends plugins.smartdata.SmartDataDbDoc<EmailDomainDoc, EmailDomainDoc> {
|
||||
@plugins.smartdata.unI()
|
||||
@plugins.smartdata.svDb()
|
||||
public id!: string;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public domain: string = '';
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public linkedDomainId: string = '';
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public subdomain?: string;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public dkim!: IEmailDomainDkim;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public rateLimits?: IEmailDomainRateLimits;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public dnsStatus!: IEmailDomainDnsStatus;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public createdAt!: string;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public updatedAt!: string;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
}
|
||||
|
||||
public static async findById(id: string): Promise<EmailDomainDoc | null> {
|
||||
return await EmailDomainDoc.getInstance({ id });
|
||||
}
|
||||
|
||||
public static async findByDomain(domain: string): Promise<EmailDomainDoc | null> {
|
||||
return await EmailDomainDoc.getInstance({ domain: domain.toLowerCase() });
|
||||
}
|
||||
|
||||
public static async findAll(): Promise<EmailDomainDoc[]> {
|
||||
return await EmailDomainDoc.getInstances({});
|
||||
}
|
||||
}
|
||||
@@ -33,3 +33,6 @@ export * from './classes.dns-record.doc.js';
|
||||
|
||||
// ACME configuration (singleton)
|
||||
export * from './classes.acme-config.doc.js';
|
||||
|
||||
// Email domain management
|
||||
export * from './classes.email-domain.doc.js';
|
||||
|
||||
321
ts/email/classes.email-domain.manager.ts
Normal file
321
ts/email/classes.email-domain.manager.ts
Normal file
@@ -0,0 +1,321 @@
|
||||
import * as plugins from '../plugins.js';
|
||||
import { logger } from '../logger.js';
|
||||
import { EmailDomainDoc } from '../db/documents/classes.email-domain.doc.js';
|
||||
import { DomainDoc } from '../db/documents/classes.domain.doc.js';
|
||||
import { DnsRecordDoc } from '../db/documents/classes.dns-record.doc.js';
|
||||
import type { DnsManager } from '../dns/manager.dns.js';
|
||||
import type { IEmailDomain, IEmailDnsRecord, TDnsRecordStatus } from '../../ts_interfaces/data/email-domain.js';
|
||||
|
||||
/**
|
||||
* EmailDomainManager — orchestrates email domain setup.
|
||||
*
|
||||
* Wires smartmta's DKIMCreator (key generation) with dcrouter's DnsManager
|
||||
* (record creation for dcrouter-hosted and provider-managed zones) to provide
|
||||
* a single entry point for setting up an email domain from A to Z.
|
||||
*/
|
||||
export class EmailDomainManager {
|
||||
private dcRouter: any; // DcRouter — avoids circular import
|
||||
|
||||
constructor(dcRouterRef: any) {
|
||||
this.dcRouter = dcRouterRef;
|
||||
}
|
||||
|
||||
private get dnsManager(): DnsManager | undefined {
|
||||
return this.dcRouter.dnsManager;
|
||||
}
|
||||
|
||||
private get dkimCreator(): any | undefined {
|
||||
return this.dcRouter.emailServer?.dkimCreator;
|
||||
}
|
||||
|
||||
private get emailHostname(): string {
|
||||
return this.dcRouter.options?.emailConfig?.hostname || this.dcRouter.options?.tls?.domain || 'localhost';
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// CRUD
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
public async getAll(): Promise<IEmailDomain[]> {
|
||||
const docs = await EmailDomainDoc.findAll();
|
||||
return docs.map((d) => this.docToInterface(d));
|
||||
}
|
||||
|
||||
public async getById(id: string): Promise<IEmailDomain | null> {
|
||||
const doc = await EmailDomainDoc.findById(id);
|
||||
return doc ? this.docToInterface(doc) : null;
|
||||
}
|
||||
|
||||
public async createEmailDomain(opts: {
|
||||
linkedDomainId: string;
|
||||
subdomain?: string;
|
||||
dkimSelector?: string;
|
||||
dkimKeySize?: number;
|
||||
rotateKeys?: boolean;
|
||||
rotationIntervalDays?: number;
|
||||
}): Promise<IEmailDomain> {
|
||||
// Resolve the linked DNS domain
|
||||
const domainDoc = await DomainDoc.findById(opts.linkedDomainId);
|
||||
if (!domainDoc) {
|
||||
throw new Error(`DNS domain not found: ${opts.linkedDomainId}`);
|
||||
}
|
||||
const baseDomain = domainDoc.name;
|
||||
const subdomain = opts.subdomain?.trim() || undefined;
|
||||
const domainName = subdomain ? `${subdomain}.${baseDomain}` : baseDomain;
|
||||
|
||||
// Check for duplicates
|
||||
const existing = await EmailDomainDoc.findByDomain(domainName);
|
||||
if (existing) {
|
||||
throw new Error(`Email domain already exists for ${domainName}`);
|
||||
}
|
||||
|
||||
const selector = opts.dkimSelector || 'default';
|
||||
const keySize = opts.dkimKeySize || 2048;
|
||||
const now = new Date().toISOString();
|
||||
|
||||
// Generate DKIM keys
|
||||
let publicKey: string | undefined;
|
||||
if (this.dkimCreator) {
|
||||
try {
|
||||
await this.dkimCreator.handleDKIMKeysForDomain(domainName);
|
||||
const dnsRecord = await this.dkimCreator.getDNSRecordForSelector(domainName, selector);
|
||||
// Extract public key from the DNS record value
|
||||
const match = dnsRecord?.value?.match(/p=([A-Za-z0-9+/=]+)/);
|
||||
publicKey = match ? match[1] : undefined;
|
||||
logger.log('info', `DKIM keys generated for ${domainName} (selector: ${selector})`);
|
||||
} catch (err: unknown) {
|
||||
logger.log('warn', `DKIM key generation failed for ${domainName}: ${(err as Error).message}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Create the document
|
||||
const doc = new EmailDomainDoc();
|
||||
doc.id = plugins.smartunique.shortId();
|
||||
doc.domain = domainName.toLowerCase();
|
||||
doc.linkedDomainId = opts.linkedDomainId;
|
||||
doc.subdomain = subdomain;
|
||||
doc.dkim = {
|
||||
selector,
|
||||
keySize,
|
||||
publicKey,
|
||||
rotateKeys: opts.rotateKeys ?? false,
|
||||
rotationIntervalDays: opts.rotationIntervalDays ?? 90,
|
||||
};
|
||||
doc.dnsStatus = {
|
||||
mx: 'unchecked',
|
||||
spf: 'unchecked',
|
||||
dkim: 'unchecked',
|
||||
dmarc: 'unchecked',
|
||||
};
|
||||
doc.createdAt = now;
|
||||
doc.updatedAt = now;
|
||||
await doc.save();
|
||||
|
||||
logger.log('info', `Email domain created: ${domainName}`);
|
||||
return this.docToInterface(doc);
|
||||
}
|
||||
|
||||
public async updateEmailDomain(
|
||||
id: string,
|
||||
changes: {
|
||||
rotateKeys?: boolean;
|
||||
rotationIntervalDays?: number;
|
||||
rateLimits?: IEmailDomain['rateLimits'];
|
||||
},
|
||||
): Promise<void> {
|
||||
const doc = await EmailDomainDoc.findById(id);
|
||||
if (!doc) throw new Error(`Email domain not found: ${id}`);
|
||||
|
||||
if (changes.rotateKeys !== undefined) doc.dkim.rotateKeys = changes.rotateKeys;
|
||||
if (changes.rotationIntervalDays !== undefined) doc.dkim.rotationIntervalDays = changes.rotationIntervalDays;
|
||||
if (changes.rateLimits !== undefined) doc.rateLimits = changes.rateLimits;
|
||||
doc.updatedAt = new Date().toISOString();
|
||||
await doc.save();
|
||||
}
|
||||
|
||||
public async deleteEmailDomain(id: string): Promise<void> {
|
||||
const doc = await EmailDomainDoc.findById(id);
|
||||
if (!doc) throw new Error(`Email domain not found: ${id}`);
|
||||
await doc.delete();
|
||||
logger.log('info', `Email domain deleted: ${doc.domain}`);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// DNS record computation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Compute the 4 required DNS records for an email domain.
|
||||
*/
|
||||
public async getRequiredDnsRecords(id: string): Promise<IEmailDnsRecord[]> {
|
||||
const doc = await EmailDomainDoc.findById(id);
|
||||
if (!doc) throw new Error(`Email domain not found: ${id}`);
|
||||
|
||||
const domain = doc.domain;
|
||||
const selector = doc.dkim.selector;
|
||||
const publicKey = doc.dkim.publicKey || '';
|
||||
const hostname = this.emailHostname;
|
||||
|
||||
const records: IEmailDnsRecord[] = [
|
||||
{
|
||||
type: 'MX',
|
||||
name: domain,
|
||||
value: `10 ${hostname}`,
|
||||
status: doc.dnsStatus.mx,
|
||||
},
|
||||
{
|
||||
type: 'TXT',
|
||||
name: domain,
|
||||
value: 'v=spf1 a mx ~all',
|
||||
status: doc.dnsStatus.spf,
|
||||
},
|
||||
{
|
||||
type: 'TXT',
|
||||
name: `${selector}._domainkey.${domain}`,
|
||||
value: `v=DKIM1; h=sha256; k=rsa; p=${publicKey}`,
|
||||
status: doc.dnsStatus.dkim,
|
||||
},
|
||||
{
|
||||
type: 'TXT',
|
||||
name: `_dmarc.${domain}`,
|
||||
value: `v=DMARC1; p=none; rua=mailto:dmarc@${domain}`,
|
||||
status: doc.dnsStatus.dmarc,
|
||||
},
|
||||
];
|
||||
|
||||
return records;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// DNS provisioning
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Auto-create missing DNS records via the linked domain's DNS path.
|
||||
*/
|
||||
public async provisionDnsRecords(id: string): Promise<number> {
|
||||
const doc = await EmailDomainDoc.findById(id);
|
||||
if (!doc) throw new Error(`Email domain not found: ${id}`);
|
||||
if (!this.dnsManager) throw new Error('DnsManager not available');
|
||||
|
||||
const requiredRecords = await this.getRequiredDnsRecords(id);
|
||||
const domainId = doc.linkedDomainId;
|
||||
|
||||
// Get existing DNS records for the linked domain
|
||||
const existingRecords = await DnsRecordDoc.findByDomainId(domainId);
|
||||
let provisioned = 0;
|
||||
|
||||
for (const required of requiredRecords) {
|
||||
// Check if a matching record already exists
|
||||
const exists = existingRecords.some((r) => {
|
||||
if (required.type === 'MX') {
|
||||
return r.type === 'MX' && r.name.toLowerCase() === required.name.toLowerCase();
|
||||
}
|
||||
// For TXT records, match by name AND check value prefix (v=spf1, v=DKIM1, v=DMARC1)
|
||||
if (r.type !== 'TXT' || r.name.toLowerCase() !== required.name.toLowerCase()) return false;
|
||||
if (required.value.startsWith('v=spf1')) return r.value.includes('v=spf1');
|
||||
if (required.value.startsWith('v=DKIM1')) return r.value.includes('v=DKIM1');
|
||||
if (required.value.startsWith('v=DMARC1')) return r.value.includes('v=DMARC1');
|
||||
return false;
|
||||
});
|
||||
|
||||
if (!exists) {
|
||||
try {
|
||||
await this.dnsManager.createRecord({
|
||||
domainId,
|
||||
name: required.name,
|
||||
type: required.type as any,
|
||||
value: required.value,
|
||||
ttl: 3600,
|
||||
createdBy: 'email-domain-manager',
|
||||
});
|
||||
provisioned++;
|
||||
logger.log('info', `Provisioned ${required.type} record for ${required.name}`);
|
||||
} catch (err: unknown) {
|
||||
logger.log('warn', `Failed to provision ${required.type} for ${required.name}: ${(err as Error).message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Re-validate after provisioning
|
||||
await this.validateDns(id);
|
||||
|
||||
return provisioned;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// DNS validation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Validate DNS records via live lookups.
|
||||
*/
|
||||
public async validateDns(id: string): Promise<IEmailDnsRecord[]> {
|
||||
const doc = await EmailDomainDoc.findById(id);
|
||||
if (!doc) throw new Error(`Email domain not found: ${id}`);
|
||||
|
||||
const domain = doc.domain;
|
||||
const selector = doc.dkim.selector;
|
||||
const resolver = new plugins.dns.promises.Resolver();
|
||||
|
||||
// MX check
|
||||
doc.dnsStatus.mx = await this.checkMx(resolver, domain);
|
||||
|
||||
// SPF check
|
||||
doc.dnsStatus.spf = await this.checkTxtRecord(resolver, domain, 'v=spf1');
|
||||
|
||||
// DKIM check
|
||||
doc.dnsStatus.dkim = await this.checkTxtRecord(resolver, `${selector}._domainkey.${domain}`, 'v=DKIM1');
|
||||
|
||||
// DMARC check
|
||||
doc.dnsStatus.dmarc = await this.checkTxtRecord(resolver, `_dmarc.${domain}`, 'v=DMARC1');
|
||||
|
||||
doc.dnsStatus.lastCheckedAt = new Date().toISOString();
|
||||
doc.updatedAt = new Date().toISOString();
|
||||
await doc.save();
|
||||
|
||||
return this.getRequiredDnsRecords(id);
|
||||
}
|
||||
|
||||
private async checkMx(resolver: plugins.dns.promises.Resolver, domain: string): Promise<TDnsRecordStatus> {
|
||||
try {
|
||||
const records = await resolver.resolveMx(domain);
|
||||
return records && records.length > 0 ? 'valid' : 'missing';
|
||||
} catch {
|
||||
return 'missing';
|
||||
}
|
||||
}
|
||||
|
||||
private async checkTxtRecord(
|
||||
resolver: plugins.dns.promises.Resolver,
|
||||
name: string,
|
||||
prefix: string,
|
||||
): Promise<TDnsRecordStatus> {
|
||||
try {
|
||||
const records = await resolver.resolveTxt(name);
|
||||
const flat = records.map((r) => r.join(''));
|
||||
const found = flat.some((r) => r.startsWith(prefix));
|
||||
return found ? 'valid' : 'missing';
|
||||
} catch {
|
||||
return 'missing';
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
private docToInterface(doc: EmailDomainDoc): IEmailDomain {
|
||||
return {
|
||||
id: doc.id,
|
||||
domain: doc.domain,
|
||||
linkedDomainId: doc.linkedDomainId,
|
||||
subdomain: doc.subdomain,
|
||||
dkim: doc.dkim,
|
||||
rateLimits: doc.rateLimits,
|
||||
dnsStatus: doc.dnsStatus,
|
||||
createdAt: doc.createdAt,
|
||||
updatedAt: doc.updatedAt,
|
||||
};
|
||||
}
|
||||
}
|
||||
1
ts/email/index.ts
Normal file
1
ts/email/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './classes.email-domain.manager.js';
|
||||
@@ -37,6 +37,7 @@ export class OpsServer {
|
||||
private domainHandler!: handlers.DomainHandler;
|
||||
private dnsRecordHandler!: handlers.DnsRecordHandler;
|
||||
private acmeConfigHandler!: handlers.AcmeConfigHandler;
|
||||
private emailDomainHandler!: handlers.EmailDomainHandler;
|
||||
|
||||
constructor(dcRouterRefArg: DcRouter) {
|
||||
this.dcRouterRef = dcRouterRefArg;
|
||||
@@ -104,6 +105,7 @@ export class OpsServer {
|
||||
this.domainHandler = new handlers.DomainHandler(this);
|
||||
this.dnsRecordHandler = new handlers.DnsRecordHandler(this);
|
||||
this.acmeConfigHandler = new handlers.AcmeConfigHandler(this);
|
||||
this.emailDomainHandler = new handlers.EmailDomainHandler(this);
|
||||
|
||||
console.log('✅ OpsServer TypedRequest handlers initialized');
|
||||
}
|
||||
|
||||
195
ts/opsserver/handlers/email-domain.handler.ts
Normal file
195
ts/opsserver/handlers/email-domain.handler.ts
Normal file
@@ -0,0 +1,195 @@
|
||||
import * as plugins from '../../plugins.js';
|
||||
import type { OpsServer } from '../classes.opsserver.js';
|
||||
import * as interfaces from '../../../ts_interfaces/index.js';
|
||||
|
||||
/**
|
||||
* CRUD + DNS provisioning handler for email domains.
|
||||
*
|
||||
* Auth: admin JWT or API token with `email-domains:read` / `email-domains:write` scope.
|
||||
*/
|
||||
export class EmailDomainHandler {
|
||||
public typedrouter = new plugins.typedrequest.TypedRouter();
|
||||
|
||||
constructor(private opsServerRef: OpsServer) {
|
||||
this.opsServerRef.typedrouter.addTypedRouter(this.typedrouter);
|
||||
this.registerHandlers();
|
||||
}
|
||||
|
||||
private async requireAuth(
|
||||
request: { identity?: interfaces.data.IIdentity; apiToken?: string },
|
||||
requiredScope?: interfaces.data.TApiTokenScope,
|
||||
): Promise<string> {
|
||||
if (request.identity?.jwt) {
|
||||
try {
|
||||
const isAdmin = await this.opsServerRef.adminHandler.adminIdentityGuard.exec({
|
||||
identity: request.identity,
|
||||
});
|
||||
if (isAdmin) return request.identity.userId;
|
||||
} catch { /* fall through */ }
|
||||
}
|
||||
|
||||
if (request.apiToken) {
|
||||
const tokenManager = this.opsServerRef.dcRouterRef.apiTokenManager;
|
||||
if (tokenManager) {
|
||||
const token = await tokenManager.validateToken(request.apiToken);
|
||||
if (token) {
|
||||
if (!requiredScope || tokenManager.hasScope(token, requiredScope)) {
|
||||
return token.createdBy;
|
||||
}
|
||||
throw new plugins.typedrequest.TypedResponseError('insufficient scope');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
throw new plugins.typedrequest.TypedResponseError('unauthorized');
|
||||
}
|
||||
|
||||
private get manager() {
|
||||
return this.opsServerRef.dcRouterRef.emailDomainManager;
|
||||
}
|
||||
|
||||
private registerHandlers(): void {
|
||||
// List all email domains
|
||||
this.typedrouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetEmailDomains>(
|
||||
'getEmailDomains',
|
||||
async (dataArg) => {
|
||||
await this.requireAuth(dataArg, 'email-domains:read' as any);
|
||||
if (!this.manager) return { domains: [] };
|
||||
return { domains: await this.manager.getAll() };
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
// Get single email domain
|
||||
this.typedrouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetEmailDomain>(
|
||||
'getEmailDomain',
|
||||
async (dataArg) => {
|
||||
await this.requireAuth(dataArg, 'email-domains:read' as any);
|
||||
if (!this.manager) return { domain: null };
|
||||
return { domain: await this.manager.getById(dataArg.id) };
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
// Create email domain
|
||||
this.typedrouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_CreateEmailDomain>(
|
||||
'createEmailDomain',
|
||||
async (dataArg) => {
|
||||
await this.requireAuth(dataArg, 'email-domains:write' as any);
|
||||
if (!this.manager) {
|
||||
return { success: false, message: 'EmailDomainManager not initialized' };
|
||||
}
|
||||
try {
|
||||
const domain = await this.manager.createEmailDomain({
|
||||
linkedDomainId: dataArg.linkedDomainId,
|
||||
subdomain: dataArg.subdomain,
|
||||
dkimSelector: dataArg.dkimSelector,
|
||||
dkimKeySize: dataArg.dkimKeySize,
|
||||
rotateKeys: dataArg.rotateKeys,
|
||||
rotationIntervalDays: dataArg.rotationIntervalDays,
|
||||
});
|
||||
return { success: true, domain };
|
||||
} catch (err: unknown) {
|
||||
return { success: false, message: (err as Error).message };
|
||||
}
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
// Update email domain
|
||||
this.typedrouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_UpdateEmailDomain>(
|
||||
'updateEmailDomain',
|
||||
async (dataArg) => {
|
||||
await this.requireAuth(dataArg, 'email-domains:write' as any);
|
||||
if (!this.manager) {
|
||||
return { success: false, message: 'EmailDomainManager not initialized' };
|
||||
}
|
||||
try {
|
||||
await this.manager.updateEmailDomain(dataArg.id, {
|
||||
rotateKeys: dataArg.rotateKeys,
|
||||
rotationIntervalDays: dataArg.rotationIntervalDays,
|
||||
rateLimits: dataArg.rateLimits,
|
||||
});
|
||||
return { success: true };
|
||||
} catch (err: unknown) {
|
||||
return { success: false, message: (err as Error).message };
|
||||
}
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
// Delete email domain
|
||||
this.typedrouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_DeleteEmailDomain>(
|
||||
'deleteEmailDomain',
|
||||
async (dataArg) => {
|
||||
await this.requireAuth(dataArg, 'email-domains:write' as any);
|
||||
if (!this.manager) {
|
||||
return { success: false, message: 'EmailDomainManager not initialized' };
|
||||
}
|
||||
try {
|
||||
await this.manager.deleteEmailDomain(dataArg.id);
|
||||
return { success: true };
|
||||
} catch (err: unknown) {
|
||||
return { success: false, message: (err as Error).message };
|
||||
}
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
// Validate DNS records
|
||||
this.typedrouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_ValidateEmailDomain>(
|
||||
'validateEmailDomain',
|
||||
async (dataArg) => {
|
||||
await this.requireAuth(dataArg, 'email-domains:read' as any);
|
||||
if (!this.manager) {
|
||||
return { success: false, message: 'EmailDomainManager not initialized' };
|
||||
}
|
||||
try {
|
||||
const records = await this.manager.validateDns(dataArg.id);
|
||||
const domain = await this.manager.getById(dataArg.id);
|
||||
return { success: true, domain: domain ?? undefined, records };
|
||||
} catch (err: unknown) {
|
||||
return { success: false, message: (err as Error).message };
|
||||
}
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
// Get required DNS records
|
||||
this.typedrouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetEmailDomainDnsRecords>(
|
||||
'getEmailDomainDnsRecords',
|
||||
async (dataArg) => {
|
||||
await this.requireAuth(dataArg, 'email-domains:read' as any);
|
||||
if (!this.manager) return { records: [] };
|
||||
return { records: await this.manager.getRequiredDnsRecords(dataArg.id) };
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
// Auto-provision DNS records
|
||||
this.typedrouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_ProvisionEmailDomainDns>(
|
||||
'provisionEmailDomainDns',
|
||||
async (dataArg) => {
|
||||
await this.requireAuth(dataArg, 'email-domains:write' as any);
|
||||
if (!this.manager) {
|
||||
return { success: false, message: 'EmailDomainManager not initialized' };
|
||||
}
|
||||
try {
|
||||
const provisioned = await this.manager.provisionDnsRecords(dataArg.id);
|
||||
return { success: true, provisioned };
|
||||
} catch (err: unknown) {
|
||||
return { success: false, message: (err as Error).message };
|
||||
}
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -17,4 +17,5 @@ export * from './users.handler.js';
|
||||
export * from './dns-provider.handler.js';
|
||||
export * from './domain.handler.js';
|
||||
export * from './dns-record.handler.js';
|
||||
export * from './acme-config.handler.js';
|
||||
export * from './acme-config.handler.js';
|
||||
export * from './email-domain.handler.js';
|
||||
@@ -150,7 +150,7 @@ export const dnsProviderTypeDescriptors: ReadonlyArray<IDnsProviderTypeDescripto
|
||||
type: 'cloudflare',
|
||||
displayName: 'Cloudflare',
|
||||
description:
|
||||
'Manages records via the Cloudflare API. Provider stays authoritative; dcrouter pushes record changes.',
|
||||
'External DNS provider. The provider stays authoritative; dcrouter pushes record changes via its API.',
|
||||
credentialFields: [
|
||||
{
|
||||
key: 'apiToken',
|
||||
|
||||
75
ts_interfaces/data/email-domain.ts
Normal file
75
ts_interfaces/data/email-domain.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
/**
|
||||
* DNS record validation status for a single email-related record (MX, SPF, DKIM, DMARC).
|
||||
*/
|
||||
export type TDnsRecordStatus = 'valid' | 'missing' | 'invalid' | 'unchecked';
|
||||
|
||||
/**
|
||||
* An email domain managed by dcrouter.
|
||||
*
|
||||
* Each email domain is linked to an existing dcrouter DNS domain (dcrouter-hosted
|
||||
* or provider-managed). The DNS management path is inherited from the linked domain
|
||||
* — no separate DNS mode is needed.
|
||||
*/
|
||||
export interface IEmailDomain {
|
||||
id: string;
|
||||
/** Fully qualified email domain name (e.g. 'example.com' or 'mail.example.com'). */
|
||||
domain: string;
|
||||
/** ID of the linked dcrouter DNS domain — determines how DNS records are managed. */
|
||||
linkedDomainId: string;
|
||||
/** Optional subdomain prefix (e.g. 'mail' for mail.example.com). Empty/undefined = bare domain. */
|
||||
subdomain?: string;
|
||||
/** DKIM configuration and key state. */
|
||||
dkim: IEmailDomainDkim;
|
||||
/** Optional per-domain rate limits. */
|
||||
rateLimits?: IEmailDomainRateLimits;
|
||||
/** DNS record validation status — populated by validateDns(). */
|
||||
dnsStatus: IEmailDomainDnsStatus;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface IEmailDomainDkim {
|
||||
/** DKIM selector (default: 'default'). */
|
||||
selector: string;
|
||||
/** RSA key size in bits (default: 2048). */
|
||||
keySize: number;
|
||||
/** Base64-encoded public key — populated after key generation. */
|
||||
publicKey?: string;
|
||||
/** Whether automatic key rotation is enabled. */
|
||||
rotateKeys: boolean;
|
||||
/** Days between key rotations (default: 90). */
|
||||
rotationIntervalDays: number;
|
||||
/** ISO date of last key rotation. */
|
||||
lastRotatedAt?: string;
|
||||
}
|
||||
|
||||
export interface IEmailDomainRateLimits {
|
||||
outbound?: {
|
||||
messagesPerMinute?: number;
|
||||
messagesPerHour?: number;
|
||||
messagesPerDay?: number;
|
||||
};
|
||||
inbound?: {
|
||||
messagesPerMinute?: number;
|
||||
connectionsPerIp?: number;
|
||||
recipientsPerMessage?: number;
|
||||
};
|
||||
}
|
||||
|
||||
export interface IEmailDomainDnsStatus {
|
||||
mx: TDnsRecordStatus;
|
||||
spf: TDnsRecordStatus;
|
||||
dkim: TDnsRecordStatus;
|
||||
dmarc: TDnsRecordStatus;
|
||||
lastCheckedAt?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* A single required DNS record for an email domain — used for display / copy-paste.
|
||||
*/
|
||||
export interface IEmailDnsRecord {
|
||||
type: 'MX' | 'TXT';
|
||||
name: string;
|
||||
value: string;
|
||||
status: TDnsRecordStatus;
|
||||
}
|
||||
@@ -7,4 +7,5 @@ export * from './vpn.js';
|
||||
export * from './dns-provider.js';
|
||||
export * from './domain.js';
|
||||
export * from './dns-record.js';
|
||||
export * from './acme-config.js';
|
||||
export * from './acme-config.js';
|
||||
export * from './email-domain.js';
|
||||
178
ts_interfaces/requests/email-domains.ts
Normal file
178
ts_interfaces/requests/email-domains.ts
Normal file
@@ -0,0 +1,178 @@
|
||||
import * as plugins from '../plugins.js';
|
||||
import type * as authInterfaces from '../data/auth.js';
|
||||
import type { IEmailDomain, IEmailDnsRecord } from '../data/email-domain.js';
|
||||
|
||||
// ============================================================================
|
||||
// Email Domain Endpoints
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* List all email domains.
|
||||
*/
|
||||
export interface IReq_GetEmailDomains extends plugins.typedrequestInterfaces.implementsTR<
|
||||
plugins.typedrequestInterfaces.ITypedRequest,
|
||||
IReq_GetEmailDomains
|
||||
> {
|
||||
method: 'getEmailDomains';
|
||||
request: {
|
||||
identity?: authInterfaces.IIdentity;
|
||||
apiToken?: string;
|
||||
};
|
||||
response: {
|
||||
domains: IEmailDomain[];
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a single email domain by id.
|
||||
*/
|
||||
export interface IReq_GetEmailDomain extends plugins.typedrequestInterfaces.implementsTR<
|
||||
plugins.typedrequestInterfaces.ITypedRequest,
|
||||
IReq_GetEmailDomain
|
||||
> {
|
||||
method: 'getEmailDomain';
|
||||
request: {
|
||||
identity?: authInterfaces.IIdentity;
|
||||
apiToken?: string;
|
||||
id: string;
|
||||
};
|
||||
response: {
|
||||
domain: IEmailDomain | null;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an email domain. Links to an existing dcrouter DNS domain.
|
||||
* Generates DKIM keys and computes the required DNS records.
|
||||
*/
|
||||
export interface IReq_CreateEmailDomain extends plugins.typedrequestInterfaces.implementsTR<
|
||||
plugins.typedrequestInterfaces.ITypedRequest,
|
||||
IReq_CreateEmailDomain
|
||||
> {
|
||||
method: 'createEmailDomain';
|
||||
request: {
|
||||
identity?: authInterfaces.IIdentity;
|
||||
apiToken?: string;
|
||||
/** ID of the existing dcrouter DNS domain to link to. */
|
||||
linkedDomainId: string;
|
||||
/** Optional subdomain prefix (e.g. 'mail' for mail.example.com). Leave empty for bare domain. */
|
||||
subdomain?: string;
|
||||
/** DKIM selector (default: 'default'). */
|
||||
dkimSelector?: string;
|
||||
/** RSA key size (default: 2048). */
|
||||
dkimKeySize?: number;
|
||||
/** Enable automatic key rotation (default: false). */
|
||||
rotateKeys?: boolean;
|
||||
/** Days between rotations (default: 90). */
|
||||
rotationIntervalDays?: number;
|
||||
};
|
||||
response: {
|
||||
success: boolean;
|
||||
domain?: IEmailDomain;
|
||||
message?: string;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Update an email domain's configuration.
|
||||
*/
|
||||
export interface IReq_UpdateEmailDomain extends plugins.typedrequestInterfaces.implementsTR<
|
||||
plugins.typedrequestInterfaces.ITypedRequest,
|
||||
IReq_UpdateEmailDomain
|
||||
> {
|
||||
method: 'updateEmailDomain';
|
||||
request: {
|
||||
identity?: authInterfaces.IIdentity;
|
||||
apiToken?: string;
|
||||
id: string;
|
||||
rotateKeys?: boolean;
|
||||
rotationIntervalDays?: number;
|
||||
rateLimits?: IEmailDomain['rateLimits'];
|
||||
};
|
||||
response: {
|
||||
success: boolean;
|
||||
message?: string;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete an email domain.
|
||||
*/
|
||||
export interface IReq_DeleteEmailDomain extends plugins.typedrequestInterfaces.implementsTR<
|
||||
plugins.typedrequestInterfaces.ITypedRequest,
|
||||
IReq_DeleteEmailDomain
|
||||
> {
|
||||
method: 'deleteEmailDomain';
|
||||
request: {
|
||||
identity?: authInterfaces.IIdentity;
|
||||
apiToken?: string;
|
||||
id: string;
|
||||
};
|
||||
response: {
|
||||
success: boolean;
|
||||
message?: string;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Trigger DNS validation for an email domain.
|
||||
* Performs live lookups for MX, SPF, DKIM, and DMARC records.
|
||||
*/
|
||||
export interface IReq_ValidateEmailDomain extends plugins.typedrequestInterfaces.implementsTR<
|
||||
plugins.typedrequestInterfaces.ITypedRequest,
|
||||
IReq_ValidateEmailDomain
|
||||
> {
|
||||
method: 'validateEmailDomain';
|
||||
request: {
|
||||
identity?: authInterfaces.IIdentity;
|
||||
apiToken?: string;
|
||||
id: string;
|
||||
};
|
||||
response: {
|
||||
success: boolean;
|
||||
domain?: IEmailDomain;
|
||||
records?: IEmailDnsRecord[];
|
||||
message?: string;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the required DNS records for an email domain (for display / copy-paste).
|
||||
*/
|
||||
export interface IReq_GetEmailDomainDnsRecords extends plugins.typedrequestInterfaces.implementsTR<
|
||||
plugins.typedrequestInterfaces.ITypedRequest,
|
||||
IReq_GetEmailDomainDnsRecords
|
||||
> {
|
||||
method: 'getEmailDomainDnsRecords';
|
||||
request: {
|
||||
identity?: authInterfaces.IIdentity;
|
||||
apiToken?: string;
|
||||
id: string;
|
||||
};
|
||||
response: {
|
||||
records: IEmailDnsRecord[];
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Auto-provision DNS records for an email domain.
|
||||
* Creates any missing MX, SPF, DKIM, and DMARC records via the linked
|
||||
* domain's DNS path (dcrouter zone or provider API).
|
||||
*/
|
||||
export interface IReq_ProvisionEmailDomainDns extends plugins.typedrequestInterfaces.implementsTR<
|
||||
plugins.typedrequestInterfaces.ITypedRequest,
|
||||
IReq_ProvisionEmailDomainDns
|
||||
> {
|
||||
method: 'provisionEmailDomainDns';
|
||||
request: {
|
||||
identity?: authInterfaces.IIdentity;
|
||||
apiToken?: string;
|
||||
id: string;
|
||||
};
|
||||
response: {
|
||||
success: boolean;
|
||||
/** Number of records created. */
|
||||
provisioned?: number;
|
||||
message?: string;
|
||||
};
|
||||
}
|
||||
@@ -17,4 +17,5 @@ export * from './users.js';
|
||||
export * from './dns-providers.js';
|
||||
export * from './domains.js';
|
||||
export * from './dns-records.js';
|
||||
export * from './acme-config.js';
|
||||
export * from './acme-config.js';
|
||||
export * from './email-domains.js';
|
||||
@@ -3,6 +3,6 @@
|
||||
*/
|
||||
export const commitinfo = {
|
||||
name: '@serve.zone/dcrouter',
|
||||
version: '13.9.1',
|
||||
version: '13.12.0',
|
||||
description: 'A multifaceted routing service handling mail and SMS delivery functions.'
|
||||
}
|
||||
|
||||
@@ -2377,6 +2377,130 @@ export const toggleApiTokenAction = routeManagementStatePart.createAction<{
|
||||
}
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// Email Domains State
|
||||
// ============================================================================
|
||||
|
||||
export interface IEmailDomainsState {
|
||||
domains: interfaces.data.IEmailDomain[];
|
||||
isLoading: boolean;
|
||||
lastUpdated: number;
|
||||
}
|
||||
|
||||
export const emailDomainsStatePart = await appState.getStatePart<IEmailDomainsState>(
|
||||
'emailDomains',
|
||||
{
|
||||
domains: [],
|
||||
isLoading: false,
|
||||
lastUpdated: 0,
|
||||
},
|
||||
'soft',
|
||||
);
|
||||
|
||||
export const fetchEmailDomainsAction = emailDomainsStatePart.createAction(
|
||||
async (statePartArg): Promise<IEmailDomainsState> => {
|
||||
const context = getActionContext();
|
||||
const currentState = statePartArg.getState()!;
|
||||
if (!context.identity) return currentState;
|
||||
|
||||
try {
|
||||
const request = new plugins.domtools.plugins.typedrequest.TypedRequest<
|
||||
interfaces.requests.IReq_GetEmailDomains
|
||||
>('/typedrequest', 'getEmailDomains');
|
||||
const response = await request.fire({ identity: context.identity });
|
||||
return {
|
||||
...currentState,
|
||||
domains: response.domains,
|
||||
isLoading: false,
|
||||
lastUpdated: Date.now(),
|
||||
};
|
||||
} catch {
|
||||
return { ...currentState, isLoading: false };
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
export const createEmailDomainAction = emailDomainsStatePart.createAction<{
|
||||
linkedDomainId: string;
|
||||
subdomain?: string;
|
||||
dkimSelector?: string;
|
||||
dkimKeySize?: number;
|
||||
rotateKeys?: boolean;
|
||||
rotationIntervalDays?: number;
|
||||
}>(async (statePartArg, args, actionContext) => {
|
||||
const context = getActionContext();
|
||||
const currentState = statePartArg.getState()!;
|
||||
try {
|
||||
const request = new plugins.domtools.plugins.typedrequest.TypedRequest<
|
||||
interfaces.requests.IReq_CreateEmailDomain
|
||||
>('/typedrequest', 'createEmailDomain');
|
||||
await request.fire({ identity: context.identity!, ...args });
|
||||
return await actionContext!.dispatch(fetchEmailDomainsAction, null);
|
||||
} catch {
|
||||
return currentState;
|
||||
}
|
||||
});
|
||||
|
||||
export const deleteEmailDomainAction = emailDomainsStatePart.createAction<string>(
|
||||
async (statePartArg, id, actionContext) => {
|
||||
const context = getActionContext();
|
||||
const currentState = statePartArg.getState()!;
|
||||
try {
|
||||
const request = new plugins.domtools.plugins.typedrequest.TypedRequest<
|
||||
interfaces.requests.IReq_DeleteEmailDomain
|
||||
>('/typedrequest', 'deleteEmailDomain');
|
||||
await request.fire({ identity: context.identity!, id });
|
||||
return await actionContext!.dispatch(fetchEmailDomainsAction, null);
|
||||
} catch {
|
||||
return currentState;
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
export const validateEmailDomainAction = emailDomainsStatePart.createAction<string>(
|
||||
async (statePartArg, id, actionContext) => {
|
||||
const context = getActionContext();
|
||||
const currentState = statePartArg.getState()!;
|
||||
try {
|
||||
const request = new plugins.domtools.plugins.typedrequest.TypedRequest<
|
||||
interfaces.requests.IReq_ValidateEmailDomain
|
||||
>('/typedrequest', 'validateEmailDomain');
|
||||
await request.fire({ identity: context.identity!, id });
|
||||
return await actionContext!.dispatch(fetchEmailDomainsAction, null);
|
||||
} catch {
|
||||
return currentState;
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
export const provisionEmailDomainDnsAction = emailDomainsStatePart.createAction<string>(
|
||||
async (statePartArg, id, actionContext) => {
|
||||
const context = getActionContext();
|
||||
const currentState = statePartArg.getState()!;
|
||||
try {
|
||||
const request = new plugins.domtools.plugins.typedrequest.TypedRequest<
|
||||
interfaces.requests.IReq_ProvisionEmailDomainDns
|
||||
>('/typedrequest', 'provisionEmailDomainDns');
|
||||
await request.fire({ identity: context.identity!, id });
|
||||
return await actionContext!.dispatch(fetchEmailDomainsAction, null);
|
||||
} catch {
|
||||
return currentState;
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// ============================================================================
|
||||
// Email Domain Standalone Functions
|
||||
// ============================================================================
|
||||
|
||||
export async function fetchEmailDomainDnsRecords(id: string) {
|
||||
const context = getActionContext();
|
||||
const request = new plugins.domtools.plugins.typedrequest.TypedRequest<
|
||||
interfaces.requests.IReq_GetEmailDomainDnsRecords
|
||||
>('/typedrequest', 'getEmailDomainDnsRecords');
|
||||
return request.fire({ identity: context.identity!, id });
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// TypedSocket Client for Real-time Log Streaming
|
||||
// ============================================================================
|
||||
|
||||
@@ -222,7 +222,7 @@ export class OpsViewApiTokens extends DeesElement {
|
||||
.suggestions=${allScopes}
|
||||
.required=${true}
|
||||
></dees-input-tags>
|
||||
<dees-input-text .key=${'expiresInDays'} .label=${'Expires in (days, blank = never)'}></dees-input-text>
|
||||
<dees-input-text .key=${'expiresInDays'} .label=${'Expires in'} .description=${'Number of days; leave blank for no expiration'}></dees-input-text>
|
||||
</dees-form>
|
||||
`,
|
||||
menuOptions: [
|
||||
|
||||
@@ -80,22 +80,6 @@ export class DnsProviderForm extends DeesElement {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.helpText {
|
||||
font-size: 12px;
|
||||
opacity: 0.7;
|
||||
margin-top: -6px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.typeDescription {
|
||||
font-size: 12px;
|
||||
opacity: 0.8;
|
||||
margin: 4px 0 16px;
|
||||
padding: 8px 12px;
|
||||
background: ${cssManager.bdTheme('#f3f4f6', '#1f2937')};
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.credentialsHint {
|
||||
font-size: 12px;
|
||||
opacity: 0.7;
|
||||
@@ -130,6 +114,7 @@ export class DnsProviderForm extends DeesElement {
|
||||
<dees-input-text
|
||||
.key=${'__type_display'}
|
||||
.label=${'Type'}
|
||||
.infoText=${descriptor?.description || ''}
|
||||
.value=${descriptor?.displayName ?? this.selectedType}
|
||||
.disabled=${true}
|
||||
></dees-input-text>
|
||||
@@ -140,6 +125,7 @@ export class DnsProviderForm extends DeesElement {
|
||||
<dees-input-dropdown
|
||||
.key=${'__type'}
|
||||
.label=${'Provider type'}
|
||||
.infoText=${descriptor?.description || ''}
|
||||
.options=${descriptors.map((d) => ({ option: d.displayName, key: d.type }))}
|
||||
.selectedOption=${descriptor
|
||||
? { option: descriptor.displayName, key: descriptor.type }
|
||||
@@ -158,7 +144,6 @@ export class DnsProviderForm extends DeesElement {
|
||||
`}
|
||||
${descriptor
|
||||
? html`
|
||||
<div class="typeDescription">${descriptor.description}</div>
|
||||
${this.credentialsHint
|
||||
? html`<div class="credentialsHint">${this.credentialsHint}</div>`
|
||||
: ''}
|
||||
@@ -168,9 +153,9 @@ export class DnsProviderForm extends DeesElement {
|
||||
<dees-input-text
|
||||
.key=${f.key}
|
||||
.label=${f.label}
|
||||
.description=${f.helpText || ''}
|
||||
.required=${f.required && !this.lockType}
|
||||
></dees-input-text>
|
||||
${f.helpText ? html`<div class="helpText">${f.helpText}</div>` : ''}
|
||||
</div>
|
||||
`,
|
||||
)}
|
||||
|
||||
@@ -54,62 +54,6 @@ export class OpsViewCertificates extends DeesElement {
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
.acmeCard {
|
||||
padding: 16px 20px;
|
||||
background: ${cssManager.bdTheme('#f9fafb', '#111827')};
|
||||
border: 1px solid ${cssManager.bdTheme('#e5e7eb', '#374151')};
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.acmeCard.acmeCardEmpty {
|
||||
background: ${cssManager.bdTheme('#fffbeb', '#1c1917')};
|
||||
border-color: ${cssManager.bdTheme('#fde68a', '#78350f')};
|
||||
}
|
||||
|
||||
.acmeCardHeader {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.acmeCardTitle {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: ${cssManager.bdTheme('#111827', '#f3f4f6')};
|
||||
}
|
||||
|
||||
.acmeGrid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
||||
gap: 12px 24px;
|
||||
}
|
||||
|
||||
.acmeField {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.acmeLabel {
|
||||
font-size: 11px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.03em;
|
||||
color: ${cssManager.bdTheme('#6b7280', '#9ca3af')};
|
||||
}
|
||||
|
||||
.acmeValue {
|
||||
font-size: 13px;
|
||||
color: ${cssManager.bdTheme('#111827', '#f3f4f6')};
|
||||
}
|
||||
|
||||
.acmeEmptyHint {
|
||||
margin: 0;
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
color: ${cssManager.bdTheme('#78350f', '#fde68a')};
|
||||
}
|
||||
|
||||
.statusBadge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
@@ -226,73 +170,38 @@ export class OpsViewCertificates extends DeesElement {
|
||||
<dees-heading level="3">Certificates</dees-heading>
|
||||
|
||||
<div class="certificatesContainer">
|
||||
${this.renderAcmeSettingsCard()}
|
||||
${this.renderStatsTiles(summary)}
|
||||
${this.renderAcmeSettingsTile()}
|
||||
${this.renderCertificateTable()}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private renderAcmeSettingsCard(): TemplateResult {
|
||||
private renderAcmeSettingsTile(): TemplateResult {
|
||||
const config = this.acmeState.config;
|
||||
|
||||
if (!config) {
|
||||
return html`
|
||||
<div class="acmeCard acmeCardEmpty">
|
||||
<div class="acmeCardHeader">
|
||||
<span class="acmeCardTitle">ACME Settings</span>
|
||||
<dees-button
|
||||
eventName="edit-acme"
|
||||
@click=${() => this.showEditAcmeDialog()}
|
||||
.type=${'highlighted'}
|
||||
>Configure</dees-button>
|
||||
</div>
|
||||
<p class="acmeEmptyHint">
|
||||
No ACME configuration yet. Click <strong>Configure</strong> to set up automated TLS
|
||||
certificate issuance via Let's Encrypt. You'll also need at least one DNS provider
|
||||
under <strong>Domains > Providers</strong>.
|
||||
</p>
|
||||
</div>
|
||||
<dees-settings
|
||||
.heading=${'ACME Settings'}
|
||||
.description=${'No ACME configuration yet. Click Configure to set up automated TLS certificate issuance via Let\'s Encrypt. You\'ll also need at least one DNS provider under Domains > Providers.'}
|
||||
.actions=${[{ name: 'Configure', action: () => this.showEditAcmeDialog() }]}
|
||||
></dees-settings>
|
||||
`;
|
||||
}
|
||||
|
||||
return html`
|
||||
<div class="acmeCard">
|
||||
<div class="acmeCardHeader">
|
||||
<span class="acmeCardTitle">ACME Settings</span>
|
||||
<dees-button eventName="edit-acme" @click=${() => this.showEditAcmeDialog()}>Edit</dees-button>
|
||||
</div>
|
||||
<div class="acmeGrid">
|
||||
<div class="acmeField">
|
||||
<span class="acmeLabel">Account email</span>
|
||||
<span class="acmeValue">${config.accountEmail || '(not set)'}</span>
|
||||
</div>
|
||||
<div class="acmeField">
|
||||
<span class="acmeLabel">Status</span>
|
||||
<span class="acmeValue">
|
||||
<span class="statusBadge ${config.enabled ? 'valid' : 'unknown'}">
|
||||
${config.enabled ? 'enabled' : 'disabled'}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
<div class="acmeField">
|
||||
<span class="acmeLabel">Mode</span>
|
||||
<span class="acmeValue">
|
||||
<span class="statusBadge ${config.useProduction ? 'valid' : 'provisioning'}">
|
||||
${config.useProduction ? 'production' : 'staging'}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
<div class="acmeField">
|
||||
<span class="acmeLabel">Auto-renew</span>
|
||||
<span class="acmeValue">${config.autoRenew ? 'on' : 'off'}</span>
|
||||
</div>
|
||||
<div class="acmeField">
|
||||
<span class="acmeLabel">Renewal threshold</span>
|
||||
<span class="acmeValue">${config.renewThresholdDays} days</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<dees-settings
|
||||
.heading=${'ACME Settings'}
|
||||
.settingsFields=${[
|
||||
{ key: 'email', label: 'Account email', value: config.accountEmail || '(not set)' },
|
||||
{ key: 'status', label: 'Status', value: config.enabled ? 'enabled' : 'disabled' },
|
||||
{ key: 'mode', label: 'Mode', value: config.useProduction ? 'production' : 'staging' },
|
||||
{ key: 'autoRenew', label: 'Auto-renew', value: config.autoRenew ? 'on' : 'off' },
|
||||
{ key: 'threshold', label: 'Renewal threshold', value: `${config.renewThresholdDays} days` },
|
||||
]}
|
||||
.actions=${[{ name: 'Edit', action: () => this.showEditAcmeDialog() }]}
|
||||
></dees-settings>
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -317,7 +226,8 @@ export class OpsViewCertificates extends DeesElement {
|
||||
></dees-input-checkbox>
|
||||
<dees-input-checkbox
|
||||
.key=${'useProduction'}
|
||||
.label=${"Use Let's Encrypt production (uncheck for staging)"}
|
||||
.label=${"Use Let's Encrypt production"}
|
||||
.description=${'Uncheck to use the staging environment'}
|
||||
.value=${current?.useProduction ?? true}
|
||||
></dees-input-checkbox>
|
||||
<dees-input-checkbox
|
||||
@@ -327,7 +237,8 @@ export class OpsViewCertificates extends DeesElement {
|
||||
></dees-input-checkbox>
|
||||
<dees-input-text
|
||||
.key=${'renewThresholdDays'}
|
||||
.label=${'Renewal threshold (days)'}
|
||||
.label=${'Renewal threshold'}
|
||||
.description=${'Number of days before expiry to trigger renewal'}
|
||||
.value=${String(current?.renewThresholdDays ?? 30)}
|
||||
></dees-input-text>
|
||||
</dees-form>
|
||||
@@ -456,11 +367,12 @@ export class OpsViewCertificates extends DeesElement {
|
||||
content: html`
|
||||
<dees-form>
|
||||
<dees-input-fileupload
|
||||
key="certJsonFile"
|
||||
label="Certificate JSON (.tsclass.cert.json)"
|
||||
accept=".json"
|
||||
.key=${'certJsonFile'}
|
||||
.label=${'Certificate JSON'}
|
||||
.description=${'Upload a .tsclass.cert.json file'}
|
||||
.accept=${'.json'}
|
||||
.multiple=${false}
|
||||
required
|
||||
.required=${true}
|
||||
></dees-input-fileupload>
|
||||
</dees-form>
|
||||
`,
|
||||
|
||||
@@ -101,8 +101,8 @@ export class OpsViewDns extends DeesElement {
|
||||
<dees-heading level="3">DNS Records</dees-heading>
|
||||
<div class="dnsContainer">
|
||||
<div class="domainPicker">
|
||||
<span>Domain:</span>
|
||||
<dees-input-dropdown
|
||||
.label=${'Domain'}
|
||||
.options=${domains.map((d) => ({ option: d.name, key: d.id }))}
|
||||
.selectedOption=${selectedId
|
||||
? { option: domains.find((d) => d.id === selectedId)?.name || '', key: selectedId }
|
||||
@@ -196,7 +196,7 @@ export class OpsViewDns extends DeesElement {
|
||||
heading: 'Add DNS Record',
|
||||
content: html`
|
||||
<dees-form>
|
||||
<dees-input-text .key=${'name'} .label=${'Name (FQDN)'} .required=${true}></dees-input-text>
|
||||
<dees-input-text .key=${'name'} .label=${'Name'} .description=${'Fully qualified domain name'} .required=${true}></dees-input-text>
|
||||
<dees-input-dropdown
|
||||
.key=${'type'}
|
||||
.label=${'Type'}
|
||||
@@ -205,10 +205,11 @@ export class OpsViewDns extends DeesElement {
|
||||
></dees-input-dropdown>
|
||||
<dees-input-text
|
||||
.key=${'value'}
|
||||
.label=${'Value (for MX use "10 mail.example.com")'}
|
||||
.label=${'Value'}
|
||||
.description=${'For MX records use priority format, e.g. "10 mail.example.com"'}
|
||||
.required=${true}
|
||||
></dees-input-text>
|
||||
<dees-input-text .key=${'ttl'} .label=${'TTL (seconds)'} .value=${'300'}></dees-input-text>
|
||||
<dees-input-text .key=${'ttl'} .label=${'TTL'} .description=${'Time to live in seconds'} .value=${'300'}></dees-input-text>
|
||||
</dees-form>
|
||||
`,
|
||||
menuOptions: [
|
||||
@@ -242,9 +243,9 @@ export class OpsViewDns extends DeesElement {
|
||||
heading: `Edit ${rec.type} ${rec.name}`,
|
||||
content: html`
|
||||
<dees-form>
|
||||
<dees-input-text .key=${'name'} .label=${'Name (FQDN)'} .value=${rec.name}></dees-input-text>
|
||||
<dees-input-text .key=${'name'} .label=${'Name'} .description=${'Fully qualified domain name'} .value=${rec.name}></dees-input-text>
|
||||
<dees-input-text .key=${'value'} .label=${'Value'} .value=${rec.value}></dees-input-text>
|
||||
<dees-input-text .key=${'ttl'} .label=${'TTL (seconds)'} .value=${String(rec.ttl)}></dees-input-text>
|
||||
<dees-input-text .key=${'ttl'} .label=${'TTL'} .description=${'Time to live in seconds'} .value=${String(rec.ttl)}></dees-input-text>
|
||||
</dees-form>
|
||||
`,
|
||||
menuOptions: [
|
||||
|
||||
@@ -181,8 +181,8 @@ export class OpsViewDomains extends DeesElement {
|
||||
heading: 'Add DcRouter Domain',
|
||||
content: html`
|
||||
<dees-form>
|
||||
<dees-input-text .key=${'name'} .label=${'FQDN (e.g. example.com)'} .required=${true}></dees-input-text>
|
||||
<dees-input-text .key=${'description'} .label=${'Description (optional)'}></dees-input-text>
|
||||
<dees-input-text .key=${'name'} .label=${'FQDN'} .description=${'e.g. example.com'} .required=${true}></dees-input-text>
|
||||
<dees-input-text .key=${'description'} .label=${'Description'}></dees-input-text>
|
||||
</dees-form>
|
||||
<p style="margin-top: 12px; font-size: 12px; opacity: 0.7;">
|
||||
dcrouter will become the authoritative DNS server for this domain. You'll need to
|
||||
@@ -235,7 +235,8 @@ export class OpsViewDomains extends DeesElement {
|
||||
></dees-input-dropdown>
|
||||
<dees-input-text
|
||||
.key=${'domainNames'}
|
||||
.label=${'Comma-separated FQDNs to import (e.g. example.com, foo.com)'}
|
||||
.label=${'Domain Names'}
|
||||
.description=${'Comma-separated FQDNs, e.g. example.com, foo.com'}
|
||||
.required=${true}
|
||||
></dees-input-text>
|
||||
</dees-form>
|
||||
|
||||
@@ -1,2 +1,3 @@
|
||||
export * from './ops-view-emails.js';
|
||||
export * from './ops-view-email-security.js';
|
||||
export * from './ops-view-email-domains.js';
|
||||
|
||||
396
ts_web/elements/email/ops-view-email-domains.ts
Normal file
396
ts_web/elements/email/ops-view-email-domains.ts
Normal file
@@ -0,0 +1,396 @@
|
||||
import {
|
||||
DeesElement,
|
||||
html,
|
||||
customElement,
|
||||
type TemplateResult,
|
||||
css,
|
||||
state,
|
||||
cssManager,
|
||||
} from '@design.estate/dees-element';
|
||||
import * as appstate from '../../appstate.js';
|
||||
import * as interfaces from '../../../dist_ts_interfaces/index.js';
|
||||
import { viewHostCss } from '../shared/css.js';
|
||||
import { type IStatsTile } from '@design.estate/dees-catalog';
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'ops-view-email-domains': OpsViewEmailDomains;
|
||||
}
|
||||
}
|
||||
|
||||
@customElement('ops-view-email-domains')
|
||||
export class OpsViewEmailDomains extends DeesElement {
|
||||
@state()
|
||||
accessor emailDomainsState: appstate.IEmailDomainsState =
|
||||
appstate.emailDomainsStatePart.getState()!;
|
||||
|
||||
@state()
|
||||
accessor domainsState: appstate.IDomainsState = appstate.domainsStatePart.getState()!;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
const sub = appstate.emailDomainsStatePart.select().subscribe((s) => {
|
||||
this.emailDomainsState = s;
|
||||
});
|
||||
this.rxSubscriptions.push(sub);
|
||||
const domSub = appstate.domainsStatePart.select().subscribe((s) => {
|
||||
this.domainsState = s;
|
||||
});
|
||||
this.rxSubscriptions.push(domSub);
|
||||
}
|
||||
|
||||
async connectedCallback() {
|
||||
await super.connectedCallback();
|
||||
await appstate.emailDomainsStatePart.dispatchAction(appstate.fetchEmailDomainsAction, null);
|
||||
await appstate.domainsStatePart.dispatchAction(appstate.fetchDomainsAndProvidersAction, null);
|
||||
}
|
||||
|
||||
public static styles = [
|
||||
cssManager.defaultStyles,
|
||||
viewHostCss,
|
||||
css`
|
||||
.emailDomainsContainer {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
.statusBadge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 3px 10px;
|
||||
border-radius: 12px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.02em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.statusBadge.valid {
|
||||
background: ${cssManager.bdTheme('#dcfce7', '#14532d')};
|
||||
color: ${cssManager.bdTheme('#166534', '#4ade80')};
|
||||
}
|
||||
|
||||
.statusBadge.missing {
|
||||
background: ${cssManager.bdTheme('#fef2f2', '#450a0a')};
|
||||
color: ${cssManager.bdTheme('#991b1b', '#f87171')};
|
||||
}
|
||||
|
||||
.statusBadge.invalid {
|
||||
background: ${cssManager.bdTheme('#fff7ed', '#431407')};
|
||||
color: ${cssManager.bdTheme('#9a3412', '#fb923c')};
|
||||
}
|
||||
|
||||
.statusBadge.unchecked {
|
||||
background: ${cssManager.bdTheme('#f3f4f6', '#1f2937')};
|
||||
color: ${cssManager.bdTheme('#4b5563', '#9ca3af')};
|
||||
}
|
||||
|
||||
.sourceBadge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 3px 8px;
|
||||
border-radius: 4px;
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
background: ${cssManager.bdTheme('#f3f4f6', '#1f2937')};
|
||||
color: ${cssManager.bdTheme('#374151', '#d1d5db')};
|
||||
}
|
||||
`,
|
||||
];
|
||||
|
||||
public render(): TemplateResult {
|
||||
const domains = this.emailDomainsState.domains;
|
||||
const validCount = domains.filter(
|
||||
(d) =>
|
||||
d.dnsStatus.mx === 'valid' &&
|
||||
d.dnsStatus.spf === 'valid' &&
|
||||
d.dnsStatus.dkim === 'valid' &&
|
||||
d.dnsStatus.dmarc === 'valid',
|
||||
).length;
|
||||
const issueCount = domains.length - validCount;
|
||||
|
||||
const tiles: IStatsTile[] = [
|
||||
{
|
||||
id: 'total',
|
||||
title: 'Total Domains',
|
||||
value: domains.length,
|
||||
type: 'number',
|
||||
icon: 'lucide:globe',
|
||||
color: '#3b82f6',
|
||||
},
|
||||
{
|
||||
id: 'valid',
|
||||
title: 'Valid DNS',
|
||||
value: validCount,
|
||||
type: 'number',
|
||||
icon: 'lucide:Check',
|
||||
color: '#22c55e',
|
||||
},
|
||||
{
|
||||
id: 'issues',
|
||||
title: 'Issues',
|
||||
value: issueCount,
|
||||
type: 'number',
|
||||
icon: 'lucide:TriangleAlert',
|
||||
color: issueCount > 0 ? '#ef4444' : '#22c55e',
|
||||
},
|
||||
{
|
||||
id: 'dkim',
|
||||
title: 'DKIM Active',
|
||||
value: domains.filter((d) => d.dkim.publicKey).length,
|
||||
type: 'number',
|
||||
icon: 'lucide:KeyRound',
|
||||
color: '#8b5cf6',
|
||||
},
|
||||
];
|
||||
|
||||
return html`
|
||||
<dees-heading level="3">Email Domains</dees-heading>
|
||||
|
||||
<div class="emailDomainsContainer">
|
||||
<dees-statsgrid
|
||||
.tiles=${tiles}
|
||||
.minTileWidth=${200}
|
||||
.gridActions=${[
|
||||
{
|
||||
name: 'Refresh',
|
||||
iconName: 'lucide:RefreshCw',
|
||||
action: async () => {
|
||||
await appstate.emailDomainsStatePart.dispatchAction(
|
||||
appstate.fetchEmailDomainsAction,
|
||||
null,
|
||||
);
|
||||
},
|
||||
},
|
||||
]}
|
||||
></dees-statsgrid>
|
||||
|
||||
<dees-table
|
||||
.heading1=${'Email Domains'}
|
||||
.heading2=${'DKIM, SPF, DMARC and MX management'}
|
||||
.data=${domains}
|
||||
.showColumnFilters=${true}
|
||||
.displayFunction=${(d: interfaces.data.IEmailDomain) => ({
|
||||
Domain: d.domain,
|
||||
Source: this.renderSourceBadge(d.linkedDomainId),
|
||||
MX: this.renderDnsStatus(d.dnsStatus.mx),
|
||||
SPF: this.renderDnsStatus(d.dnsStatus.spf),
|
||||
DKIM: this.renderDnsStatus(d.dnsStatus.dkim),
|
||||
DMARC: this.renderDnsStatus(d.dnsStatus.dmarc),
|
||||
})}
|
||||
.dataActions=${[
|
||||
{
|
||||
name: 'Add Email Domain',
|
||||
iconName: 'lucide:plus',
|
||||
type: ['header'] as any,
|
||||
actionFunc: async () => {
|
||||
await this.showCreateDialog();
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'Validate DNS',
|
||||
iconName: 'lucide:search-check',
|
||||
type: ['inRow', 'contextmenu'] as any,
|
||||
actionFunc: async (actionData: any) => {
|
||||
const d = actionData.item as interfaces.data.IEmailDomain;
|
||||
await appstate.emailDomainsStatePart.dispatchAction(
|
||||
appstate.validateEmailDomainAction,
|
||||
d.id,
|
||||
);
|
||||
const { DeesToast } = await import('@design.estate/dees-catalog');
|
||||
DeesToast.show({ message: `DNS validated for ${d.domain}`, type: 'success', duration: 2500 });
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'Provision DNS',
|
||||
iconName: 'lucide:wand-sparkles',
|
||||
type: ['inRow', 'contextmenu'] as any,
|
||||
actionFunc: async (actionData: any) => {
|
||||
const d = actionData.item as interfaces.data.IEmailDomain;
|
||||
await appstate.emailDomainsStatePart.dispatchAction(
|
||||
appstate.provisionEmailDomainDnsAction,
|
||||
d.id,
|
||||
);
|
||||
const { DeesToast } = await import('@design.estate/dees-catalog');
|
||||
DeesToast.show({ message: `DNS records provisioned for ${d.domain}`, type: 'success', duration: 2500 });
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'View DNS Records',
|
||||
iconName: 'lucide:list',
|
||||
type: ['inRow', 'contextmenu'] as any,
|
||||
actionFunc: async (actionData: any) => {
|
||||
const d = actionData.item as interfaces.data.IEmailDomain;
|
||||
await this.showDnsRecordsDialog(d);
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'Delete',
|
||||
iconName: 'lucide:trash2',
|
||||
type: ['inRow', 'contextmenu'] as any,
|
||||
actionFunc: async (actionData: any) => {
|
||||
const d = actionData.item as interfaces.data.IEmailDomain;
|
||||
await appstate.emailDomainsStatePart.dispatchAction(
|
||||
appstate.deleteEmailDomainAction,
|
||||
d.id,
|
||||
);
|
||||
},
|
||||
},
|
||||
]}
|
||||
dataName="email domain"
|
||||
></dees-table>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private renderDnsStatus(status: interfaces.data.TDnsRecordStatus): TemplateResult {
|
||||
return html`<span class="statusBadge ${status}">${status}</span>`;
|
||||
}
|
||||
|
||||
private renderSourceBadge(linkedDomainId: string): TemplateResult {
|
||||
const domain = this.domainsState.domains.find((d) => d.id === linkedDomainId);
|
||||
if (!domain) return html`<span class="sourceBadge">unknown</span>`;
|
||||
const label =
|
||||
domain.source === 'dcrouter'
|
||||
? 'dcrouter'
|
||||
: this.domainsState.providers.find((p) => p.id === domain.providerId)?.name || 'provider';
|
||||
return html`<span class="sourceBadge">${label}</span>`;
|
||||
}
|
||||
|
||||
private async showCreateDialog() {
|
||||
const { DeesModal } = await import('@design.estate/dees-catalog');
|
||||
const domainOptions = this.domainsState.domains.map((d) => ({
|
||||
option: `${d.name} (${d.source})`,
|
||||
key: d.id,
|
||||
}));
|
||||
|
||||
DeesModal.createAndShow({
|
||||
heading: 'Add Email Domain',
|
||||
content: html`
|
||||
<dees-form>
|
||||
<dees-input-dropdown
|
||||
.key=${'linkedDomainId'}
|
||||
.label=${'Domain'}
|
||||
.description=${'Select an existing DNS domain'}
|
||||
.options=${domainOptions}
|
||||
.required=${true}
|
||||
></dees-input-dropdown>
|
||||
<dees-input-text
|
||||
.key=${'subdomain'}
|
||||
.label=${'Subdomain'}
|
||||
.description=${'Leave empty for bare domain, e.g. "mail" for mail.example.com'}
|
||||
></dees-input-text>
|
||||
<dees-input-text
|
||||
.key=${'dkimSelector'}
|
||||
.label=${'DKIM Selector'}
|
||||
.description=${'Identifier used in DNS record name'}
|
||||
.value=${'default'}
|
||||
></dees-input-text>
|
||||
<dees-input-dropdown
|
||||
.key=${'dkimKeySize'}
|
||||
.label=${'DKIM Key Size'}
|
||||
.options=${[
|
||||
{ option: '2048 (recommended)', key: '2048' },
|
||||
{ option: '1024', key: '1024' },
|
||||
{ option: '4096', key: '4096' },
|
||||
]}
|
||||
.selectedOption=${{ option: '2048 (recommended)', key: '2048' }}
|
||||
></dees-input-dropdown>
|
||||
<dees-input-checkbox
|
||||
.key=${'rotateKeys'}
|
||||
.label=${'Auto-rotate DKIM keys'}
|
||||
.value=${false}
|
||||
></dees-input-checkbox>
|
||||
</dees-form>
|
||||
`,
|
||||
menuOptions: [
|
||||
{ name: 'Cancel', action: async (m: any) => m.destroy() },
|
||||
{
|
||||
name: 'Create',
|
||||
action: async (m: any) => {
|
||||
const form = m.shadowRoot?.querySelector('.content')?.querySelector('dees-form');
|
||||
if (!form) return;
|
||||
const data = await form.collectFormData();
|
||||
const linkedDomainId =
|
||||
typeof data.linkedDomainId === 'object'
|
||||
? data.linkedDomainId.key
|
||||
: data.linkedDomainId;
|
||||
const keySize =
|
||||
typeof data.dkimKeySize === 'object'
|
||||
? parseInt(data.dkimKeySize.key, 10)
|
||||
: parseInt(data.dkimKeySize || '2048', 10);
|
||||
|
||||
const subdomain = data.subdomain?.trim() || undefined;
|
||||
await appstate.emailDomainsStatePart.dispatchAction(
|
||||
appstate.createEmailDomainAction,
|
||||
{
|
||||
linkedDomainId,
|
||||
subdomain,
|
||||
dkimSelector: data.dkimSelector || 'default',
|
||||
dkimKeySize: keySize,
|
||||
rotateKeys: Boolean(data.rotateKeys),
|
||||
},
|
||||
);
|
||||
m.destroy();
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
private async showDnsRecordsDialog(emailDomain: interfaces.data.IEmailDomain) {
|
||||
const { DeesModal, DeesToast } = await import('@design.estate/dees-catalog');
|
||||
|
||||
// Fetch required DNS records
|
||||
let records: interfaces.data.IEmailDnsRecord[] = [];
|
||||
try {
|
||||
const response = await appstate.fetchEmailDomainDnsRecords(emailDomain.id);
|
||||
records = response.records;
|
||||
} catch {
|
||||
records = [];
|
||||
}
|
||||
|
||||
DeesModal.createAndShow({
|
||||
heading: `DNS Records: ${emailDomain.domain}`,
|
||||
content: html`
|
||||
<dees-table
|
||||
.data=${records}
|
||||
.displayFunction=${(r: interfaces.data.IEmailDnsRecord) => ({
|
||||
Type: r.type,
|
||||
Name: r.name,
|
||||
Value: r.value,
|
||||
Status: html`<span class="statusBadge ${r.status}">${r.status}</span>`,
|
||||
})}
|
||||
.dataActions=${[
|
||||
{
|
||||
name: 'Copy Value',
|
||||
iconName: 'lucide:copy',
|
||||
type: ['inRow'] as any,
|
||||
actionFunc: async (actionData: any) => {
|
||||
const rec = actionData.item as interfaces.data.IEmailDnsRecord;
|
||||
await navigator.clipboard.writeText(rec.value);
|
||||
DeesToast.show({ message: 'Copied to clipboard', type: 'success', duration: 1500 });
|
||||
},
|
||||
},
|
||||
]}
|
||||
dataName="DNS record"
|
||||
></dees-table>
|
||||
`,
|
||||
menuOptions: [
|
||||
{
|
||||
name: 'Auto-Provision All',
|
||||
action: async (m: any) => {
|
||||
await appstate.emailDomainsStatePart.dispatchAction(
|
||||
appstate.provisionEmailDomainDnsAction,
|
||||
emailDomain.id,
|
||||
);
|
||||
DeesToast.show({ message: 'DNS records provisioned', type: 'success', duration: 2500 });
|
||||
m.destroy();
|
||||
},
|
||||
},
|
||||
{ name: 'Close', action: async (m: any) => m.destroy() },
|
||||
],
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -37,25 +37,10 @@ export class OpsViewEmailSecurity extends DeesElement {
|
||||
cssManager.defaultStyles,
|
||||
viewHostCss,
|
||||
css`
|
||||
h2 {
|
||||
margin: 32px 0 16px 0;
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
color: ${cssManager.bdTheme('#333', '#ccc')};
|
||||
}
|
||||
dees-statsgrid {
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
.securityCard {
|
||||
background: ${cssManager.bdTheme('#fff', '#222')};
|
||||
border: 1px solid ${cssManager.bdTheme('#e9ecef', '#333')};
|
||||
border-radius: 8px;
|
||||
padding: 24px;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
.actionButton {
|
||||
margin-top: 16px;
|
||||
.securityContainer {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 24px;
|
||||
}
|
||||
`,
|
||||
];
|
||||
@@ -113,48 +98,44 @@ export class OpsViewEmailSecurity extends DeesElement {
|
||||
return html`
|
||||
<dees-heading level="3">Email Security</dees-heading>
|
||||
|
||||
<dees-statsgrid
|
||||
.tiles=${tiles}
|
||||
.minTileWidth=${200}
|
||||
></dees-statsgrid>
|
||||
<div class="securityContainer">
|
||||
<dees-statsgrid
|
||||
.tiles=${tiles}
|
||||
.minTileWidth=${200}
|
||||
></dees-statsgrid>
|
||||
|
||||
<h2>Email Security Configuration</h2>
|
||||
<div class="securityCard">
|
||||
<dees-form>
|
||||
<dees-input-checkbox
|
||||
.key=${'enableSPF'}
|
||||
.label=${'Enable SPF checking'}
|
||||
.value=${true}
|
||||
></dees-input-checkbox>
|
||||
<dees-input-checkbox
|
||||
.key=${'enableDKIM'}
|
||||
.label=${'Enable DKIM validation'}
|
||||
.value=${true}
|
||||
></dees-input-checkbox>
|
||||
<dees-input-checkbox
|
||||
.key=${'enableDMARC'}
|
||||
.label=${'Enable DMARC policy enforcement'}
|
||||
.value=${true}
|
||||
></dees-input-checkbox>
|
||||
<dees-input-checkbox
|
||||
.key=${'enableSpamFilter'}
|
||||
.label=${'Enable spam filtering'}
|
||||
.value=${true}
|
||||
></dees-input-checkbox>
|
||||
</dees-form>
|
||||
<dees-button
|
||||
class="actionButton"
|
||||
type="highlighted"
|
||||
@click=${() => this.saveEmailSecuritySettings()}
|
||||
>
|
||||
Save Settings
|
||||
</dees-button>
|
||||
<dees-settings
|
||||
.heading=${'Security Configuration'}
|
||||
.settingsFields=${[
|
||||
{ key: 'spf', label: 'SPF checking', value: 'enabled' },
|
||||
{ key: 'dkim', label: 'DKIM validation', value: 'enabled' },
|
||||
{ key: 'dmarc', label: 'DMARC policy', value: 'enabled' },
|
||||
{ key: 'spam', label: 'Spam filtering', value: 'enabled' },
|
||||
]}
|
||||
.actions=${[{ name: 'Edit', action: () => this.showEditSecurityDialog() }]}
|
||||
></dees-settings>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private async saveEmailSecuritySettings() {
|
||||
// Config is read-only from the UI for now
|
||||
alert('Email security settings are read-only. Update the dcrouter configuration file to change these settings.');
|
||||
private async showEditSecurityDialog() {
|
||||
const { DeesModal } = await import('@design.estate/dees-catalog');
|
||||
DeesModal.createAndShow({
|
||||
heading: 'Edit Security Configuration',
|
||||
content: html`
|
||||
<dees-form>
|
||||
<dees-input-checkbox .key=${'enableSPF'} .label=${'SPF checking'} .value=${true}></dees-input-checkbox>
|
||||
<dees-input-checkbox .key=${'enableDKIM'} .label=${'DKIM validation'} .value=${true}></dees-input-checkbox>
|
||||
<dees-input-checkbox .key=${'enableDMARC'} .label=${'DMARC policy enforcement'} .value=${true}></dees-input-checkbox>
|
||||
<dees-input-checkbox .key=${'enableSpamFilter'} .label=${'Spam filtering'} .value=${true}></dees-input-checkbox>
|
||||
</dees-form>
|
||||
<p style="margin-top: 12px; font-size: 12px; opacity: 0.7;">
|
||||
These settings are read-only for now. Update the dcrouter configuration to change them.
|
||||
</p>
|
||||
`,
|
||||
menuOptions: [
|
||||
{ name: 'Close', action: async (modalArg: any) => modalArg.destroy() },
|
||||
],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -243,9 +243,9 @@ export class OpsViewRemoteIngress extends DeesElement {
|
||||
content: html`
|
||||
<dees-form>
|
||||
<dees-input-text .key=${'name'} .label=${'Name'} .required=${true}></dees-input-text>
|
||||
<dees-input-text .key=${'listenPorts'} .label=${'Additional Manual Ports (comma-separated, optional)'}></dees-input-text>
|
||||
<dees-input-text .key=${'listenPorts'} .label=${'Manual Ports'} .description=${'Comma-separated port numbers, optional'}></dees-input-text>
|
||||
<dees-input-checkbox .key=${'autoDerivePorts'} .label=${'Auto-derive ports from routes'} .value=${true}></dees-input-checkbox>
|
||||
<dees-input-text .key=${'tags'} .label=${'Tags (comma-separated, optional)'}></dees-input-text>
|
||||
<dees-input-text .key=${'tags'} .label=${'Tags'} .description=${'Comma-separated, optional'}></dees-input-text>
|
||||
</dees-form>
|
||||
`,
|
||||
menuOptions: [
|
||||
@@ -320,9 +320,9 @@ export class OpsViewRemoteIngress extends DeesElement {
|
||||
content: html`
|
||||
<dees-form>
|
||||
<dees-input-text .key=${'name'} .label=${'Name'} .value=${edge.name}></dees-input-text>
|
||||
<dees-input-text .key=${'listenPorts'} .label=${'Manual Ports (comma-separated)'} .value=${(edge.listenPorts || []).join(', ')}></dees-input-text>
|
||||
<dees-input-text .key=${'listenPorts'} .label=${'Manual Ports'} .description=${'Comma-separated port numbers'} .value=${(edge.listenPorts || []).join(', ')}></dees-input-text>
|
||||
<dees-input-checkbox .key=${'autoDerivePorts'} .label=${'Auto-derive ports from routes'} .value=${edge.autoDerivePorts !== false}></dees-input-checkbox>
|
||||
<dees-input-text .key=${'tags'} .label=${'Tags (comma-separated)'} .value=${(edge.tags || []).join(', ')}></dees-input-text>
|
||||
<dees-input-text .key=${'tags'} .label=${'Tags'} .description=${'Comma-separated'} .value=${(edge.tags || []).join(', ')}></dees-input-text>
|
||||
</dees-form>
|
||||
`,
|
||||
menuOptions: [
|
||||
|
||||
@@ -473,19 +473,19 @@ export class OpsViewRoutes extends DeesElement {
|
||||
content: html`
|
||||
<dees-form>
|
||||
<dees-input-text .key=${'name'} .label=${'Route Name'} .value=${route.name || ''} .required=${true}></dees-input-text>
|
||||
<dees-input-text .key=${'ports'} .label=${'Ports (comma-separated)'} .value=${currentPorts} .required=${true}></dees-input-text>
|
||||
<dees-input-text .key=${'ports'} .label=${'Ports'} .description=${'Comma-separated, e.g. 80, 443'} .value=${currentPorts} .required=${true}></dees-input-text>
|
||||
<dees-input-list .key=${'domains'} .label=${'Domains'} .placeholder=${'Add domain...'} .value=${currentDomains}></dees-input-list>
|
||||
<dees-input-text .key=${'priority'} .label=${'Priority (higher = matched first)'} .value=${route.priority != null ? String(route.priority) : ''}></dees-input-text>
|
||||
<dees-input-text .key=${'priority'} .label=${'Priority'} .description=${'Higher values are matched first'} .value=${route.priority != null ? String(route.priority) : ''}></dees-input-text>
|
||||
<dees-input-dropdown .key=${'sourceProfileRef'} .label=${'Source Profile'} .options=${profileOptions} .selectedOption=${profileOptions.find((o) => o.key === (merged.metadata?.sourceProfileRef || '')) || null}></dees-input-dropdown>
|
||||
<dees-input-dropdown .key=${'networkTargetRef'} .label=${'Network Target'} .options=${targetOptions} .selectedOption=${targetOptions.find((o) => o.key === (merged.metadata?.networkTargetRef || '')) || null}></dees-input-dropdown>
|
||||
<dees-input-text .key=${'targetHost'} .label=${'Target Host (if no target selected)'} .value=${currentTargetHost}></dees-input-text>
|
||||
<dees-input-text .key=${'targetPort'} .label=${'Target Port (if no target selected)'} .value=${currentTargetPort}></dees-input-text>
|
||||
<dees-input-text .key=${'targetHost'} .label=${'Target Host'} .description=${'Used when no network target is selected'} .value=${currentTargetHost}></dees-input-text>
|
||||
<dees-input-text .key=${'targetPort'} .label=${'Target Port'} .description=${'Used when no network target is selected'} .value=${currentTargetPort}></dees-input-text>
|
||||
<dees-input-dropdown .key=${'tlsMode'} .label=${'TLS Mode'} .options=${tlsModeOptions} .selectedOption=${tlsModeOptions.find((o) => o.key === currentTlsMode) || tlsModeOptions[0]}></dees-input-dropdown>
|
||||
<div class="tlsCertificateGroup" style="display: ${needsCert ? 'flex' : 'none'}; flex-direction: column; gap: 16px;">
|
||||
<dees-input-dropdown .key=${'tlsCertificate'} .label=${'Certificate'} .options=${tlsCertOptions} .selectedOption=${tlsCertOptions.find((o) => o.key === currentTlsCert) || tlsCertOptions[0]}></dees-input-dropdown>
|
||||
<div class="tlsCustomCertGroup" style="display: ${needsCert && isCustom ? 'flex' : 'none'}; flex-direction: column; gap: 16px;">
|
||||
<dees-input-text .key=${'tlsCertKey'} .label=${'Private Key (PEM)'} .value=${currentCustomKey}></dees-input-text>
|
||||
<dees-input-text .key=${'tlsCertCert'} .label=${'Certificate (PEM)'} .value=${currentCustomCert}></dees-input-text>
|
||||
<dees-input-text .key=${'tlsCertKey'} .label=${'Private Key'} .description=${'PEM-encoded private key'} .value=${currentCustomKey}></dees-input-text>
|
||||
<dees-input-text .key=${'tlsCertCert'} .label=${'Certificate'} .description=${'PEM-encoded certificate'} .value=${currentCustomCert}></dees-input-text>
|
||||
</div>
|
||||
</div>
|
||||
</dees-form>
|
||||
@@ -607,19 +607,19 @@ export class OpsViewRoutes extends DeesElement {
|
||||
content: html`
|
||||
<dees-form>
|
||||
<dees-input-text .key=${'name'} .label=${'Route Name'} .required=${true}></dees-input-text>
|
||||
<dees-input-text .key=${'ports'} .label=${'Ports (comma-separated)'} .required=${true}></dees-input-text>
|
||||
<dees-input-text .key=${'ports'} .label=${'Ports'} .description=${'Comma-separated, e.g. 80, 443'} .required=${true}></dees-input-text>
|
||||
<dees-input-list .key=${'domains'} .label=${'Domains'} .placeholder=${'Add domain...'}></dees-input-list>
|
||||
<dees-input-text .key=${'priority'} .label=${'Priority (higher = matched first)'}></dees-input-text>
|
||||
<dees-input-text .key=${'priority'} .label=${'Priority'} .description=${'Higher values are matched first'}></dees-input-text>
|
||||
<dees-input-dropdown .key=${'sourceProfileRef'} .label=${'Source Profile'} .options=${profileOptions}></dees-input-dropdown>
|
||||
<dees-input-dropdown .key=${'networkTargetRef'} .label=${'Network Target'} .options=${targetOptions}></dees-input-dropdown>
|
||||
<dees-input-text .key=${'targetHost'} .label=${'Target Host (if no target selected)'} .value=${'localhost'}></dees-input-text>
|
||||
<dees-input-text .key=${'targetPort'} .label=${'Target Port (if no target selected)'}></dees-input-text>
|
||||
<dees-input-text .key=${'targetHost'} .label=${'Target Host'} .description=${'Used when no network target is selected'} .value=${'localhost'}></dees-input-text>
|
||||
<dees-input-text .key=${'targetPort'} .label=${'Target Port'} .description=${'Used when no network target is selected'}></dees-input-text>
|
||||
<dees-input-dropdown .key=${'tlsMode'} .label=${'TLS Mode'} .options=${tlsModeOptions} .selectedOption=${tlsModeOptions[0]}></dees-input-dropdown>
|
||||
<div class="tlsCertificateGroup" style="display: none; flex-direction: column; gap: 16px;">
|
||||
<dees-input-dropdown .key=${'tlsCertificate'} .label=${'Certificate'} .options=${tlsCertOptions} .selectedOption=${tlsCertOptions[0]}></dees-input-dropdown>
|
||||
<div class="tlsCustomCertGroup" style="display: none; flex-direction: column; gap: 16px;">
|
||||
<dees-input-text .key=${'tlsCertKey'} .label=${'Private Key (PEM)'}></dees-input-text>
|
||||
<dees-input-text .key=${'tlsCertCert'} .label=${'Certificate (PEM)'}></dees-input-text>
|
||||
<dees-input-text .key=${'tlsCertKey'} .label=${'Private Key'} .description=${'PEM-encoded private key'}></dees-input-text>
|
||||
<dees-input-text .key=${'tlsCertCert'} .label=${'Certificate'} .description=${'PEM-encoded certificate'}></dees-input-text>
|
||||
</div>
|
||||
</div>
|
||||
</dees-form>
|
||||
|
||||
@@ -176,7 +176,7 @@ export class OpsViewTargetProfiles extends DeesElement {
|
||||
<dees-input-text .key=${'name'} .label=${'Name'} .required=${true}></dees-input-text>
|
||||
<dees-input-text .key=${'description'} .label=${'Description'}></dees-input-text>
|
||||
<dees-input-list .key=${'domains'} .label=${'Domains'} .placeholder=${'e.g. *.example.com'} .allowFreeform=${true}></dees-input-list>
|
||||
<dees-input-list .key=${'targets'} .label=${'Targets (ip:port)'} .placeholder=${'e.g. 10.0.0.1:443'} .allowFreeform=${true}></dees-input-list>
|
||||
<dees-input-list .key=${'targets'} .label=${'Targets'} .description=${'Format: ip:port, e.g. 10.0.0.1:443'} .placeholder=${'e.g. 10.0.0.1:443'} .allowFreeform=${true}></dees-input-list>
|
||||
<dees-input-list .key=${'routeRefs'} .label=${'Route Refs'} .placeholder=${'Type to search routes...'} .candidates=${routeCandidates} .allowFreeform=${true}></dees-input-list>
|
||||
</dees-form>
|
||||
`,
|
||||
@@ -235,7 +235,7 @@ export class OpsViewTargetProfiles extends DeesElement {
|
||||
<dees-input-text .key=${'name'} .label=${'Name'} .value=${profile.name}></dees-input-text>
|
||||
<dees-input-text .key=${'description'} .label=${'Description'} .value=${profile.description || ''}></dees-input-text>
|
||||
<dees-input-list .key=${'domains'} .label=${'Domains'} .placeholder=${'e.g. *.example.com'} .allowFreeform=${true} .value=${currentDomains}></dees-input-list>
|
||||
<dees-input-list .key=${'targets'} .label=${'Targets (ip:port)'} .placeholder=${'e.g. 10.0.0.1:443'} .allowFreeform=${true} .value=${currentTargets}></dees-input-list>
|
||||
<dees-input-list .key=${'targets'} .label=${'Targets'} .description=${'Format: ip:port, e.g. 10.0.0.1:443'} .placeholder=${'e.g. 10.0.0.1:443'} .allowFreeform=${true} .value=${currentTargets}></dees-input-list>
|
||||
<dees-input-list .key=${'routeRefs'} .label=${'Route Refs'} .placeholder=${'Type to search routes...'} .candidates=${routeCandidates} .allowFreeform=${true} .value=${currentRouteRefs}></dees-input-list>
|
||||
</dees-form>
|
||||
`,
|
||||
|
||||
@@ -371,8 +371,8 @@ export class OpsViewVpn extends DeesElement {
|
||||
</div>
|
||||
<dees-input-checkbox .key=${'allowAdditionalAcls'} .label=${'Allow additional ACLs'} .value=${false}></dees-input-checkbox>
|
||||
<div class="aclGroup" style="display: none; flex-direction: column; gap: 16px;">
|
||||
<dees-input-text .key=${'destinationAllowList'} .label=${'Destination Allow List (comma-separated IPs/CIDRs)'}></dees-input-text>
|
||||
<dees-input-text .key=${'destinationBlockList'} .label=${'Destination Block List (comma-separated IPs/CIDRs)'}></dees-input-text>
|
||||
<dees-input-text .key=${'destinationAllowList'} .label=${'Destination Allow List'} .description=${'Comma-separated IPs or CIDRs'}></dees-input-text>
|
||||
<dees-input-text .key=${'destinationBlockList'} .label=${'Destination Block List'} .description=${'Comma-separated IPs or CIDRs'}></dees-input-text>
|
||||
</div>
|
||||
</dees-form>
|
||||
`,
|
||||
@@ -681,8 +681,8 @@ export class OpsViewVpn extends DeesElement {
|
||||
</div>
|
||||
<dees-input-checkbox .key=${'allowAdditionalAcls'} .label=${'Allow additional ACLs'} .value=${currentAllowAcls}></dees-input-checkbox>
|
||||
<div class="aclGroup" style="display: ${currentAllowAcls ? 'flex' : 'none'}; flex-direction: column; gap: 16px;">
|
||||
<dees-input-text .key=${'destinationAllowList'} .label=${'Destination Allow List (comma-separated IPs/CIDRs)'} .value=${currentAllowList}></dees-input-text>
|
||||
<dees-input-text .key=${'destinationBlockList'} .label=${'Destination Block List (comma-separated IPs/CIDRs)'} .value=${currentBlockList}></dees-input-text>
|
||||
<dees-input-text .key=${'destinationAllowList'} .label=${'Destination Allow List'} .description=${'Comma-separated IPs or CIDRs'} .value=${currentAllowList}></dees-input-text>
|
||||
<dees-input-text .key=${'destinationBlockList'} .label=${'Destination Block List'} .description=${'Comma-separated IPs or CIDRs'} .value=${currentBlockList}></dees-input-text>
|
||||
</div>
|
||||
</dees-form>
|
||||
`,
|
||||
|
||||
@@ -32,6 +32,7 @@ import { OpsViewVpn } from './network/ops-view-vpn.js';
|
||||
// Email group
|
||||
import { OpsViewEmails } from './email/ops-view-emails.js';
|
||||
import { OpsViewEmailSecurity } from './email/ops-view-email-security.js';
|
||||
import { OpsViewEmailDomains } from './email/ops-view-email-domains.js';
|
||||
|
||||
// Access group
|
||||
import { OpsViewApiTokens } from './access/ops-view-apitokens.js';
|
||||
@@ -108,6 +109,7 @@ export class OpsDashboard extends DeesElement {
|
||||
subViews: [
|
||||
{ slug: 'log', name: 'Email Log', iconName: 'lucide:scrollText', element: OpsViewEmails },
|
||||
{ slug: 'security', name: 'Email Security', iconName: 'lucide:shieldCheck', element: OpsViewEmailSecurity },
|
||||
{ slug: 'domains', name: 'Email Domains', iconName: 'lucide:globe', element: OpsViewEmailDomains },
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -10,7 +10,7 @@ const flatViews = ['logs'] as const;
|
||||
const subviewMap: Record<string, readonly string[]> = {
|
||||
overview: ['stats', 'configuration'] as const,
|
||||
network: ['activity', 'routes', 'sourceprofiles', 'networktargets', 'targetprofiles', 'remoteingress', 'vpn'] as const,
|
||||
email: ['log', 'security'] as const,
|
||||
email: ['log', 'security', 'domains'] as const,
|
||||
access: ['apitokens', 'users'] as const,
|
||||
security: ['overview', 'blocked', 'authentication'] as const,
|
||||
domains: ['providers', 'domains', 'dns', 'certificates'] as const,
|
||||
|
||||
Reference in New Issue
Block a user