From 63323faa979ea774e7cd18b5029dac707bc5e4c2 Mon Sep 17 00:00:00 2001 From: Matt Hill Date: Mon, 11 Aug 2025 23:01:31 -0600 Subject: [PATCH] nix StartOS domains, implement public and private domains at interface scope --- .../shared/src/i18n/dictionaries/de.ts | 7 +- .../shared/src/i18n/dictionaries/en.ts | 11 +- .../shared/src/i18n/dictionaries/es.ts | 7 +- .../shared/src/i18n/dictionaries/fr.ts | 7 +- .../shared/src/i18n/dictionaries/pl.ts | 7 +- .../clearnet-domains.component.ts | 236 ---------------- .../clearnet-domains/item.component.ts | 121 --------- .../interfaces/interface.component.ts | 12 +- .../interfaces/interface.service.ts | 51 ++-- .../interfaces/private-domains.component.ts | 183 +++++++++++++ .../public-domains/dns.component.ts | 167 ++++++++++++ .../interfaces/public-domains/pd.component.ts | 86 ++++++ .../public-domains/pd.item.component.ts | 109 ++++++++ .../interfaces/public-domains/pd.service.ts | 253 ++++++++++++++++++ .../interfaces/tor-domains.component.ts | 8 +- .../services/routes/interface.component.ts | 5 +- .../system/routes/domains/dns.component.ts | 195 -------------- .../system/routes/domains/domain.service.ts | 188 ------------- .../routes/domains/domains.component.ts | 64 ----- .../system/routes/domains/item.component.ts | 88 ------ .../system/routes/domains/table.component.ts | 41 --- .../system/routes/gateways/item.component.ts | 2 +- .../routes/startos-ui/startos-ui.component.ts | 6 +- .../portal/routes/system/system.const.ts | 5 - .../portal/routes/system/system.routes.ts | 5 - .../ui/src/app/services/api/api.fixures.ts | 15 +- .../ui/src/app/services/api/api.types.ts | 75 +++--- .../app/services/api/embassy-api.service.ts | 52 ++-- .../services/api/embassy-live-api.service.ts | 87 ++++-- .../services/api/embassy-mock-api.service.ts | 200 +++++++++----- .../ui/src/app/services/api/mock-patch.ts | 28 +- .../ui/src/app/services/gateway.service.ts | 4 +- 32 files changed, 1162 insertions(+), 1163 deletions(-) delete mode 100644 web/projects/ui/src/app/routes/portal/components/interfaces/clearnet-domains/clearnet-domains.component.ts delete mode 100644 web/projects/ui/src/app/routes/portal/components/interfaces/clearnet-domains/item.component.ts create mode 100644 web/projects/ui/src/app/routes/portal/components/interfaces/private-domains.component.ts create mode 100644 web/projects/ui/src/app/routes/portal/components/interfaces/public-domains/dns.component.ts create mode 100644 web/projects/ui/src/app/routes/portal/components/interfaces/public-domains/pd.component.ts create mode 100644 web/projects/ui/src/app/routes/portal/components/interfaces/public-domains/pd.item.component.ts create mode 100644 web/projects/ui/src/app/routes/portal/components/interfaces/public-domains/pd.service.ts delete mode 100644 web/projects/ui/src/app/routes/portal/routes/system/routes/domains/dns.component.ts delete mode 100644 web/projects/ui/src/app/routes/portal/routes/system/routes/domains/domain.service.ts delete mode 100644 web/projects/ui/src/app/routes/portal/routes/system/routes/domains/domains.component.ts delete mode 100644 web/projects/ui/src/app/routes/portal/routes/system/routes/domains/item.component.ts delete mode 100644 web/projects/ui/src/app/routes/portal/routes/system/routes/domains/table.component.ts diff --git a/web/projects/shared/src/i18n/dictionaries/de.ts b/web/projects/shared/src/i18n/dictionaries/de.ts index 240bbe40d..4da0c4cbe 100644 --- a/web/projects/shared/src/i18n/dictionaries/de.ts +++ b/web/projects/shared/src/i18n/dictionaries/de.ts @@ -90,13 +90,12 @@ export default { 88: 'Aktionen', 89: 'nicht empfohlen', 90: 'Root-CA ist vertrauenswürdig!', - 96: 'Domain hinzufügen', + 96: '', 97: 'Wird entfernt', 100: 'Nicht gespeicherte Änderungen', 101: 'Sie haben nicht gespeicherte Änderungen. Möchten Sie die Seite wirklich verlassen?', 102: 'Verlassen', 103: 'Sind Sie sicher?', - 104: 'Domain auswählen', 108: 'Öffentlich', 109: 'privat', 111: 'Keine Onion-Domains', @@ -519,11 +518,13 @@ export default { 546: 'Anbieter', 547: '', 548: '', - 549: '', 550: '', 551: '', 552: '', 553: '', 554: '', 555: '', + 556: '', + 557: '', + 558: '', } satisfies i18n diff --git a/web/projects/shared/src/i18n/dictionaries/en.ts b/web/projects/shared/src/i18n/dictionaries/en.ts index 196105569..dcb133ffe 100644 --- a/web/projects/shared/src/i18n/dictionaries/en.ts +++ b/web/projects/shared/src/i18n/dictionaries/en.ts @@ -89,13 +89,12 @@ export const ENGLISH = { 'Actions': 88, // as in, actions available to the user 'not recommended': 89, 'Root CA Trusted!': 90, - 'Add domain': 96, + 'Add public domain': 96, 'Removing': 97, 'Unsaved changes': 100, 'You have unsaved changes. Are you sure you want to leave?': 101, 'Leave': 102, 'Are you sure?': 103, - 'Select domain': 104, 'public': 108, 'private': 109, 'No Tor domains': 111, @@ -513,16 +512,18 @@ export const ENGLISH = { 'Domain': 540, // as in, an internat domain name 'Gateway': 541, // as in, a device or software that connects two different networks 'Certificate Authority': 543, - 'Edit domain': 544, + 'Edit public domain': 544, 'No public domains': 545, 'Provider': 546, 'View DNS': 547, - 'Clearnet Domains': 548, - 'No clearnet domains': 549, + 'New public domain': 548, 'Addresses': 550, 'Common': 551, 'Uncommon': 552, 'No addresses': 553, 'Change CA': 554, 'Address details': 555, + 'Private Domains': 556, + 'No private domains': 557, + 'New private domain': 558 } as const diff --git a/web/projects/shared/src/i18n/dictionaries/es.ts b/web/projects/shared/src/i18n/dictionaries/es.ts index fc4b23130..aae862102 100644 --- a/web/projects/shared/src/i18n/dictionaries/es.ts +++ b/web/projects/shared/src/i18n/dictionaries/es.ts @@ -90,13 +90,12 @@ export default { 88: 'Acciones', 89: 'no recomendado', 90: '¡CA raíz confiable!', - 96: 'Agregar dominio', + 96: '', 97: 'Eliminando', 100: 'Cambios no guardados', 101: 'Tienes cambios no guardados. ¿Estás seguro de que deseas salir?', 102: 'Salir', 103: '¿Estás seguro?', - 104: 'Seleccionar dominio', 108: 'público', 109: 'privado', 111: 'Sin dominios onion', @@ -519,11 +518,13 @@ export default { 546: 'Proveedor', 547: '', 548: '', - 549: '', 550: '', 551: '', 552: '', 553: '', 554: '', 555: '', + 556: '', + 557: '', + 558: '', } satisfies i18n diff --git a/web/projects/shared/src/i18n/dictionaries/fr.ts b/web/projects/shared/src/i18n/dictionaries/fr.ts index 743609da1..34e89b2e3 100644 --- a/web/projects/shared/src/i18n/dictionaries/fr.ts +++ b/web/projects/shared/src/i18n/dictionaries/fr.ts @@ -90,13 +90,12 @@ export default { 88: 'Actions', 89: 'non recommandé', 90: 'Certificat racine approuvé !', - 96: 'Ajouter un domaine', + 96: '', 97: 'Suppression', 100: 'Modifications non enregistrées', 101: 'Vous avez des modifications non enregistrées. Voulez-vous vraiment quitter ?', 102: 'Quitter', 103: 'Êtes-vous sûr ?', - 104: 'Sélectionner un domaine', 108: 'public', 109: 'privé', 111: 'Aucune domaine onion', @@ -519,11 +518,13 @@ export default { 546: 'Fournisseur', 547: '', 548: '', - 549: '', 550: '', 551: '', 552: '', 553: '', 554: '', 555: '', + 556: '', + 557: '', + 558: '', } satisfies i18n diff --git a/web/projects/shared/src/i18n/dictionaries/pl.ts b/web/projects/shared/src/i18n/dictionaries/pl.ts index f89e84d56..a1879a235 100644 --- a/web/projects/shared/src/i18n/dictionaries/pl.ts +++ b/web/projects/shared/src/i18n/dictionaries/pl.ts @@ -90,13 +90,12 @@ export default { 88: 'Akcje', 89: 'niezalecane', 90: 'Główny certyfikat CA zaufany!', - 96: 'Dodaj domenę', + 96: '', 97: 'Usuwanie', 100: 'Niezapisane zmiany', 101: 'Masz niezapisane zmiany. Czy na pewno chcesz opuścić tę stronę?', 102: 'Opuść', 103: 'Czy jesteś pewien?', - 104: 'Wybierz domenę', 108: 'publiczny', 109: 'prywatny', 111: 'Brak domeny onion', @@ -519,11 +518,13 @@ export default { 546: 'Dostawca', 547: '', 548: '', - 549: '', 550: '', 551: '', 552: '', 553: '', 554: '', 555: '', + 556: '', + 557: '', + 558: '', } satisfies i18n diff --git a/web/projects/ui/src/app/routes/portal/components/interfaces/clearnet-domains/clearnet-domains.component.ts b/web/projects/ui/src/app/routes/portal/components/interfaces/clearnet-domains/clearnet-domains.component.ts deleted file mode 100644 index 60dd8da91..000000000 --- a/web/projects/ui/src/app/routes/portal/components/interfaces/clearnet-domains/clearnet-domains.component.ts +++ /dev/null @@ -1,236 +0,0 @@ -import { - ChangeDetectionStrategy, - Component, - computed, - inject, - input, -} from '@angular/core' -import { - DocsLinkDirective, - ErrorService, - i18nPipe, - LoadingService, -} from '@start9labs/shared' -import { TuiButton } from '@taiga-ui/core' -import { TuiSkeleton } from '@taiga-ui/kit' -import { PlaceholderComponent } from 'src/app/routes/portal/components/placeholder.component' -import { TableComponent } from 'src/app/routes/portal/components/table.component' -import { InterfaceClearnetDomainsItemComponent } from './item.component' -import { ClearnetDomain } from '../interface.service' -import { ISB, utils } from '@start9labs/start-sdk' -import { FormDialogService } from 'src/app/services/form-dialog.service' -import { FormComponent } from '../../form.component' -import { configBuilderToSpec } from 'src/app/utils/configBuilderToSpec' -import { PatchDB } from 'patch-db-client' -import { DataModel } from 'src/app/services/patch-db/data-model' -import { toSignal } from '@angular/core/rxjs-interop' -import { map } from 'rxjs' -import { toAuthorityName } from 'src/app/utils/acme' -import { ApiService } from 'src/app/services/api/embassy-api.service' -import { InterfaceComponent } from '../interface.component' - -// @TODO translations - -@Component({ - selector: 'section[clearnetDomains]', - template: ` -
- {{ 'Clearnet Domains' | i18n }} - - {{ 'Documentation' | i18n }} - - -
- - @for (domain of clearnetDomains(); track $index) { - - } @empty { - @if (clearnetDomains()) { - - - - } @else { - @for (_ of [0, 1]; track $index) { - - - - } - } - } -
- - {{ 'No clearnet domains' | i18n }} - -
-
{{ 'Loading' | i18n }}
-
- `, - styles: ` - :host { - grid-column: span 3; - } - `, - host: { class: 'g-card' }, - imports: [ - TuiButton, - TableComponent, - PlaceholderComponent, - i18nPipe, - DocsLinkDirective, - InterfaceClearnetDomainsItemComponent, - TuiSkeleton, - ], - changeDetection: ChangeDetectionStrategy.OnPush, -}) -export class InterfaceClearnetDomainsComponent { - private readonly formDialog = inject(FormDialogService) - private readonly patch = inject>(PatchDB) - private readonly api = inject(ApiService) - private readonly loader = inject(LoadingService) - private readonly errorService = inject(ErrorService) - private readonly interface = inject(InterfaceComponent) - - readonly clearnetDomains = input.required< - readonly ClearnetDomain[] | undefined - >() - - private readonly domains = toSignal( - this.patch.watch$('serverInfo', 'network', 'domains'), - ) - - private readonly acme = toSignal( - this.patch.watch$('serverInfo', 'network', 'acme').pipe( - map(acme => - Object.keys(acme).reduce>( - (obj, url) => ({ - ...obj, - [url]: toAuthorityName(url), - }), - { local: toAuthorityName(null) }, - ), - ), - ), - ) - - async add() { - const addSpec = ISB.InputSpec.of({ - type: ISB.Value.union({ - name: 'Type', - default: 'public', - description: - '- **Public**: the domain can be accessed by anyone with an Internet connection.\n- **Private**: the domain can only be accessed by people connected to the same Local Area Network (LAN) as the server, either physically or via VPN.', - variants: ISB.Variants.of({ - public: { - name: 'Public', - spec: ISB.InputSpec.of({ - domain: ISB.Value.select({ - name: 'Domain', - default: '', - values: Object.keys(this.domains() || {}).reduce< - Record - >( - (obj, domain) => ({ - ...obj, - [domain]: domain, - }), - {}, - ), - }), - subdomain: ISB.Value.text({ - name: 'Subdomain', - description: 'Optionally enter a subdomain', - required: false, - default: null, - patterns: [], // @TODO subdomain pattern - }), - ...this.acmeSpec(true), - }), - }, - private: { - name: 'Private', - spec: ISB.InputSpec.of({ - fqdn: ISB.Value.text({ - name: 'Domain', - description: - 'Enter a fully qualified domain name. For example, if you control domain.com, you could enter domain.com or subdomain.domain.com or another.subdomain.domain.com.', - required: true, - default: null, - patterns: [utils.Patterns.domain], - }), - ...this.acmeSpec(false), - }), - }, - }), - }), - }) - - this.formDialog.open(FormComponent, { - label: 'Add domain', - data: { - spec: await configBuilderToSpec(addSpec), - buttons: [ - { - text: 'Save', - handler: async (input: typeof addSpec._TYPE) => { - const loader = this.loader.open('Removing').subscribe() - const type = input.type.selection - const params = { - private: type === 'private', - fqdn: - type === 'public' - ? `${input.type.value.subdomain}.${input.type.value.domain}` - : input.type.value.fqdn, - acme: - input.type.value.authority === 'local' - ? null - : input.type.value.authority, - } - try { - if (this.interface.packageId()) { - await this.api.pkgAddDomain({ - ...params, - package: this.interface.packageId(), - host: this.interface.value()?.addressInfo.hostId || '', - }) - } else { - await this.api.osUiAddDomain(params) - } - return true - } catch (e: any) { - this.errorService.handleError(e) - return false - } finally { - loader.unsubscribe() - } - }, - }, - ], - }, - }) - } - - private acmeSpec(isPublic: boolean) { - return { - authority: ISB.Value.select({ - name: 'Certificate Authority', - description: - 'Select the Certificate Authority that will issue the SSL/TLS certificate for this domain', - values: this.acme()!, - default: isPublic ? '' : 'local', - }), - } - } -} diff --git a/web/projects/ui/src/app/routes/portal/components/interfaces/clearnet-domains/item.component.ts b/web/projects/ui/src/app/routes/portal/components/interfaces/clearnet-domains/item.component.ts deleted file mode 100644 index 9528ed61d..000000000 --- a/web/projects/ui/src/app/routes/portal/components/interfaces/clearnet-domains/item.component.ts +++ /dev/null @@ -1,121 +0,0 @@ -import { - ChangeDetectionStrategy, - Component, - inject, - input, -} from '@angular/core' -import { - DialogService, - ErrorService, - i18nPipe, - LoadingService, -} from '@start9labs/shared' -import { - TuiButton, - TuiDataList, - TuiDropdown, - TuiTextfield, -} from '@taiga-ui/core' -import { TuiBadge } from '@taiga-ui/kit' -import { filter } from 'rxjs' -import { ApiService } from 'src/app/services/api/embassy-api.service' -import { InterfaceComponent } from '../interface.component' -import { ClearnetDomain } from '../interface.service' - -@Component({ - selector: 'tr[domain]', - template: ` - {{ domain().fqdn }} - {{ domain().authority }} - - @if (domain().public) { - - {{ 'public' | i18n }} - - } @else { - - {{ 'private' | i18n }} - - } - - - - - `, - styles: ` - :host { - grid-template-columns: min-content 1fr min-content; - } - - td:nth-child(2) { - order: -1; - grid-column: span 2; - } - - td:last-child { - grid-area: 1 / 3 / 3; - align-self: center; - text-align: right; - } - - :host-context(tui-root._mobile) { - tui-badge { - vertical-align: bottom; - margin-inline-start: 0.25rem; - } - } - `, - changeDetection: ChangeDetectionStrategy.OnPush, - imports: [ - TuiButton, - TuiDataList, - TuiDropdown, - i18nPipe, - TuiTextfield, - TuiBadge, - ], -}) -export class InterfaceClearnetDomainsItemComponent { - private readonly dialog = inject(DialogService) - private readonly loader = inject(LoadingService) - private readonly errorService = inject(ErrorService) - private readonly api = inject(ApiService) - private readonly interface = inject(InterfaceComponent) - - readonly domain = input.required() - - remove() { - this.dialog - .openConfirm({ label: 'Are you sure?', size: 's' }) - .pipe(filter(Boolean)) - .subscribe(async () => { - const loader = this.loader.open('Removing').subscribe() - const params = { fqdn: this.domain().fqdn } - - try { - if (this.interface.packageId()) { - await this.api.pkgRemoveDomain({ - ...params, - package: this.interface.packageId(), - host: this.interface.value()?.addressInfo.hostId || '', - }) - } else { - await this.api.osUiRemoveDomain(params) - } - return true - } catch (e: any) { - this.errorService.handleError(e) - return false - } finally { - loader.unsubscribe() - } - }) - } -} diff --git a/web/projects/ui/src/app/routes/portal/components/interfaces/interface.component.ts b/web/projects/ui/src/app/routes/portal/components/interfaces/interface.component.ts index 86769f728..7236c19c5 100644 --- a/web/projects/ui/src/app/routes/portal/components/interfaces/interface.component.ts +++ b/web/projects/ui/src/app/routes/portal/components/interfaces/interface.component.ts @@ -3,20 +3,23 @@ import { tuiButtonOptionsProvider } from '@taiga-ui/core' import { MappedServiceInterface } from './interface.service' import { InterfaceGatewaysComponent } from './gateways.component' import { InterfaceTorDomainsComponent } from './tor-domains.component' -import { InterfaceClearnetDomainsComponent } from './clearnet-domains/clearnet-domains.component' +import { PublicDomainsComponent } from './public-domains/pd.component' +import { InterfacePrivateDomainsComponent } from './private-domains.component' import { InterfaceAddressesComponent } from './addresses/addresses.component' +// @TODO translations + @Component({ selector: 'service-interface', template: ` -
-
+
+

@@ -49,7 +52,8 @@ import { InterfaceAddressesComponent } from './addresses/addresses.component' imports: [ InterfaceGatewaysComponent, InterfaceTorDomainsComponent, - InterfaceClearnetDomainsComponent, + PublicDomainsComponent, + InterfacePrivateDomainsComponent, InterfaceAddressesComponent, ], }) diff --git a/web/projects/ui/src/app/routes/portal/components/interfaces/interface.service.ts b/web/projects/ui/src/app/routes/portal/components/interfaces/interface.service.ts index a6ae68b49..13cca2424 100644 --- a/web/projects/ui/src/app/routes/portal/components/interfaces/interface.service.ts +++ b/web/projects/ui/src/app/routes/portal/components/interfaces/interface.service.ts @@ -3,6 +3,7 @@ import { T, utils } from '@start9labs/start-sdk' import { ConfigService } from 'src/app/services/config.service' import { toAuthorityName } from 'src/app/utils/acme' import { GatewayPlus } from 'src/app/services/gateway.service' +import { PublicDomain } from './public-domains/pd.service' import { i18nKey } from '@start9labs/shared' type AddressWithInfo = { @@ -104,7 +105,8 @@ function cmpClearnet( function toDisplayAddress( { info, url }: AddressWithInfo, gateways: GatewayPlus[], - domains: Record, + publicDomains: Record, + privateDomains: string[], ): DisplayAddress { let access: DisplayAddress['access'] let gatewayName: DisplayAddress['gatewayName'] @@ -145,13 +147,13 @@ function toDisplayAddress( const gateway = gateways.find(g => g.id === info.gatewayId)! gatewayName = gateway.ipInfo.name - const gatewayIpv4 = gateway.ipv4[0] + const gatewayLanIpv4 = gateway.lanIpv4[0] const isWireguard = gateway.ipInfo.deviceType === 'wireguard' const localIdeal = 'Ideal for local access' const lanRequired = 'Requires being connected to the same Local Area Network (LAN) as your server, either physically or via VPN' - const staticRequired = `Requires setting a static IP address for ${gatewayIpv4} in your gateway` + const staticRequired = `Requires setting a static IP address for ${gatewayLanIpv4} in your gateway` const vpnAccess = 'Ideal for VPN access via your' // * Local * @@ -201,18 +203,17 @@ function toDisplayAddress( // * Domain * } else { type = 'Domain' - const domain = domains[info.hostname.value]! if (info.public) { access = 'public' bullets = [ - `Requires DNS record(s) for ${domains[info.hostname.value]?.root}, as shown in System -> Domains`, + `Requires a DNS record for ${info.hostname.value} that resolves to ${gateway.ipInfo.wanIp}`, `Requires port forwarding in gateway "${gatewayName}": ${port} -> ${info.hostname.value}:${port === 443 ? 5443 : port}`, ] - if (domain.acme) { + if (publicDomains[info.hostname.value]!) { bullets.unshift('Ideal for public access via the Internet') } else { bullets = [ - 'Can be used for personal access via the public Internet. VPN is more secure', + 'Can be used for personal access via the public Internet. VPN is more private and secure', rootCaRequired, ...bullets, ] @@ -220,24 +221,23 @@ function toDisplayAddress( } else { access = 'private' const ipPortBad = 'when using IP addresses and ports is undesirable' - const customDnsRequired = `Requires DNS record for ${info.hostname.value} that resolve to ${gatewayIpv4}` + const customDnsRequired = `Requires a DNS record for ${info.hostname.value} that resolves to ${gatewayLanIpv4}` if (isWireguard) { bullets = [ `${vpnAccess} StartTunnel (or similar) ${ipPortBad}`, customDnsRequired, + rootCaRequired, ] } else { bullets = [ `${localIdeal} ${ipPortBad}`, `${vpnAccess} router's Wireguard server ${ipPortBad}`, customDnsRequired, + rootCaRequired, lanRequired, staticRequired, ] } - if (domain.acme) { - bullets.push(rootCaRequired) - } } } } @@ -251,11 +251,10 @@ function toDisplayAddress( } } -export function getClearnetDomains(host: T.Host): ClearnetDomain[] { - return Object.entries(host.domains).map(([fqdn, info]) => ({ +export function getPublicDomains(publicDomains: any): PublicDomain[] { + return Object.entries(publicDomains).map(([fqdn, info]) => ({ fqdn, - authority: toAuthorityName(info.acme), - public: info.public, + ...info, })) } @@ -305,10 +304,19 @@ export class InterfaceService { }, [] as AddressWithInfo[]) return { - common: bestAddrs.map(a => toDisplayAddress(a, gateways, host.domains)), + common: bestAddrs.map(a => + toDisplayAddress(a, gateways, host.publicDomains, host.privateDomains), + ), uncommon: allAddressesWithInfo .filter(a => !bestAddrs.includes(a)) - .map(a => toDisplayAddress(a, gateways, host.domains)), + .map(a => + toDisplayAddress( + a, + gateways, + host.publicDomains, + host.privateDomains, + ), + ), } } @@ -453,7 +461,8 @@ export class InterfaceService { export type MappedServiceInterface = T.ServiceInterface & { gateways: InterfaceGateway[] torDomains: string[] - clearnetDomains: ClearnetDomain[] + publicDomains: PublicDomain[] + privateDomains: string[] addresses: { common: DisplayAddress[] uncommon: DisplayAddress[] @@ -465,12 +474,6 @@ export type InterfaceGateway = GatewayPlus & { enabled: boolean } -export type ClearnetDomain = { - fqdn: string - authority: string - public: boolean -} - export type DisplayAddress = { type: string access: 'public' | 'private' | null diff --git a/web/projects/ui/src/app/routes/portal/components/interfaces/private-domains.component.ts b/web/projects/ui/src/app/routes/portal/components/interfaces/private-domains.component.ts new file mode 100644 index 000000000..66d816f42 --- /dev/null +++ b/web/projects/ui/src/app/routes/portal/components/interfaces/private-domains.component.ts @@ -0,0 +1,183 @@ +import { + ChangeDetectionStrategy, + Component, + inject, + input, +} from '@angular/core' +import { + DialogService, + DocsLinkDirective, + ErrorService, + i18nPipe, + LoadingService, +} from '@start9labs/shared' +import { ISB, utils } from '@start9labs/start-sdk' +import { TuiButton, TuiTitle } from '@taiga-ui/core' +import { TuiSkeleton } from '@taiga-ui/kit' +import { TuiCell } from '@taiga-ui/layout' +import { filter } from 'rxjs' +import { + FormComponent, + FormContext, +} from 'src/app/routes/portal/components/form.component' +import { PlaceholderComponent } from 'src/app/routes/portal/components/placeholder.component' +import { ApiService } from 'src/app/services/api/embassy-api.service' +import { FormDialogService } from 'src/app/services/form-dialog.service' +import { configBuilderToSpec } from 'src/app/utils/configBuilderToSpec' +import { InterfaceComponent } from './interface.component' + +// @TODO translations + +@Component({ + selector: 'section[privateDomains]', + template: ` +
+ {{ 'Private Domains' | i18n }} + + {{ 'Documentation' | i18n }} + + +
+ @for (domain of privateDomains(); track $index) { +
+ {{ domain }} + +
+ } @empty { + @if (privateDomains()) { + + {{ 'No private domains' | i18n }} + + } @else { + @for (_ of [0, 1]; track $index) { + + } + } + } + `, + styles: ` + :host { + grid-column: span 2; + } + `, + host: { class: 'g-card' }, + imports: [ + TuiCell, + TuiTitle, + TuiButton, + PlaceholderComponent, + i18nPipe, + DocsLinkDirective, + TuiSkeleton, + ], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class InterfacePrivateDomainsComponent { + private readonly dialog = inject(DialogService) + private readonly formDialog = inject(FormDialogService) + private readonly loader = inject(LoadingService) + private readonly errorService = inject(ErrorService) + private readonly api = inject(ApiService) + private readonly interface = inject(InterfaceComponent) + private readonly i18n = inject(i18nPipe) + + readonly privateDomains = input.required() + + async add() { + this.formDialog.open>(FormComponent, { + label: 'New private domain', + data: { + spec: await configBuilderToSpec( + ISB.InputSpec.of({ + fqdn: ISB.Value.text({ + name: 'Domain', + description: + 'Enter a fully qualified domain name. Since the domain is for private use, it can be any domain you want, even one you do not control.', + required: true, + default: null, + patterns: [utils.Patterns.domain], + }), + }), + ), + buttons: [ + { + text: this.i18n.transform('Save')!, + handler: async value => this.save(value.fqdn), + }, + ], + }, + }) + } + + async remove(fqdn: string) { + this.dialog + .openConfirm({ label: 'Are you sure?', size: 's' }) + .pipe(filter(Boolean)) + .subscribe(async () => { + const loader = this.loader.open('Removing').subscribe() + + try { + if (this.interface.packageId()) { + await this.api.pkgRemovePrivateDomain({ + fqdn, + package: this.interface.packageId(), + host: this.interface.value()?.addressInfo.hostId || '', + }) + } else { + await this.api.osUiRemovePrivateDomain({ fqdn }) + } + return true + } catch (e: any) { + this.errorService.handleError(e) + return false + } finally { + loader.unsubscribe() + } + }) + } + + private async save(fqdn: string): Promise { + const loader = this.loader.open('Saving').subscribe() + + try { + if (this.interface.packageId) { + await this.api.pkgAddPrivateDomain({ + fqdn, + package: this.interface.packageId(), + host: this.interface.value()?.addressInfo.hostId || '', + }) + } else { + await this.api.osUiAddPrivateDomain({ fqdn }) + } + return true + } catch (e: any) { + this.errorService.handleError(e) + return false + } finally { + loader.unsubscribe() + } + } +} diff --git a/web/projects/ui/src/app/routes/portal/components/interfaces/public-domains/dns.component.ts b/web/projects/ui/src/app/routes/portal/components/interfaces/public-domains/dns.component.ts new file mode 100644 index 000000000..c5046f680 --- /dev/null +++ b/web/projects/ui/src/app/routes/portal/components/interfaces/public-domains/dns.component.ts @@ -0,0 +1,167 @@ +import { + ChangeDetectionStrategy, + Component, + computed, + inject, + signal, +} from '@angular/core' +import { FormsModule } from '@angular/forms' +import { ErrorService, i18nPipe } from '@start9labs/shared' +import { TuiButton, TuiDialogContext, TuiIcon } from '@taiga-ui/core' +import { + TuiButtonLoading, + TuiSwitch, + tuiSwitchOptionsProvider, +} from '@taiga-ui/kit' +import { injectContext, PolymorpheusComponent } from '@taiga-ui/polymorpheus' +import { TableComponent } from 'src/app/routes/portal/components/table.component' +import { ApiService } from 'src/app/services/api/embassy-api.service' +import { parse } from 'tldts' +import { GatewayWithId } from './pd.service' + +// @TODO translations + +@Component({ + selector: 'dns', + template: ` +

{{ context.data.message }}

+ + @let wanIp = context.data.gateway.ipInfo.wanIp || ('Error' | i18n); + + @if (context.data.gateway.ipInfo.deviceType !== 'wireguard') { + + } + + + @for (row of rows(); track $index) { + + + + + + + } +
+ @if (pass() === true) { + + } @else if (pass() === false) { + + } + {{ ddns ? 'ALIAS' : 'A' }} + {{ row.host }}{{ ddns ? '[DDNS Address]' : wanIp }}{{ row.purpose }}
+ +
+ +
+ `, + styles: ` + label { + display: flex; + gap: 0.75rem; + align-items: center; + margin: 1rem 0; + } + + tui-icon { + font-size: 1rem; + vertical-align: text-bottom; + } + `, + providers: [ + tuiSwitchOptionsProvider({ + appearance: () => 'primary', + icon: () => '', + }), + ], + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [ + TuiButton, + i18nPipe, + TableComponent, + TuiSwitch, + FormsModule, + TuiButtonLoading, + TuiIcon, + ], +}) +export class DnsComponent { + private readonly errorService = inject(ErrorService) + private readonly api = inject(ApiService) + + readonly ddns = false + + readonly context = + injectContext< + TuiDialogContext< + void, + { fqdn: string; gateway: GatewayWithId; message: string } + > + >() + + readonly loading = signal(false) + readonly pass = signal(undefined) + + readonly rows = computed<{ host: string; purpose: string }[]>(() => { + const { domain, subdomain } = parse(this.context.data.fqdn) + + if (!subdomain) { + return [ + { + host: '@', + purpose: domain!, + }, + ] + } + + const segments = subdomain.split('.') + + return [ + { + host: subdomain, + purpose: `only ${subdomain}`, + }, + ...segments.map((_, i) => { + const parent = segments.slice(i + 1).join('.') + return { + host: `*.${parent}`, + purpose: `subdomains of ${parent}`, + } + }), + { + host: '*', + purpose: `subdomains of ${domain}`, + }, + ] + }) + + async testDns() { + this.pass.set(undefined) + this.loading.set(true) + + try { + const ip = await this.api.testDns({ + fqdn: this.context.data.fqdn, + gateway: this.context.data.gateway.id, + }) + + this.pass.set(ip === this.context.data.gateway.ipInfo.wanIp) + } catch (e: any) { + this.errorService.handleError(e) + } finally { + this.loading.set(false) + } + } +} + +export const DNS = new PolymorpheusComponent(DnsComponent) diff --git a/web/projects/ui/src/app/routes/portal/components/interfaces/public-domains/pd.component.ts b/web/projects/ui/src/app/routes/portal/components/interfaces/public-domains/pd.component.ts new file mode 100644 index 000000000..33699bc27 --- /dev/null +++ b/web/projects/ui/src/app/routes/portal/components/interfaces/public-domains/pd.component.ts @@ -0,0 +1,86 @@ +import { + ChangeDetectionStrategy, + Component, + inject, + input, +} from '@angular/core' +import { DocsLinkDirective, i18nPipe } from '@start9labs/shared' +import { TuiButton } from '@taiga-ui/core' +import { TuiSkeleton } from '@taiga-ui/kit' +import { PlaceholderComponent } from 'src/app/routes/portal/components/placeholder.component' +import { TableComponent } from 'src/app/routes/portal/components/table.component' +import { PublicDomainsItemComponent } from './pd.item.component' +import { PublicDomain, PublicDomainService } from './pd.service' + +@Component({ + selector: 'section[publicDomains]', + template: ` +
+ {{ 'Public Domains' | i18n }} + + {{ 'Documentation' | i18n }} + + @if (service.data()) { + + } +
+ + @for (domain of publicDomains(); track $index) { + + } @empty { + @if (publicDomains()) { + + + + } @else { + @for (_ of [0]; track $index) { + + + + } + } + } +
+ + {{ 'No public domains' | i18n }} + +
+
{{ 'Loading' | i18n }}
+
+ `, + styles: ` + :host { + grid-column: span 3; + } + `, + host: { class: 'g-card' }, + providers: [PublicDomainService], + imports: [ + TuiButton, + TableComponent, + PlaceholderComponent, + i18nPipe, + DocsLinkDirective, + PublicDomainsItemComponent, + TuiSkeleton, + ], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class PublicDomainsComponent { + readonly service = inject(PublicDomainService) + + readonly publicDomains = input.required() +} diff --git a/web/projects/ui/src/app/routes/portal/components/interfaces/public-domains/pd.item.component.ts b/web/projects/ui/src/app/routes/portal/components/interfaces/public-domains/pd.item.component.ts new file mode 100644 index 000000000..22c808f23 --- /dev/null +++ b/web/projects/ui/src/app/routes/portal/components/interfaces/public-domains/pd.item.component.ts @@ -0,0 +1,109 @@ +import { + ChangeDetectionStrategy, + Component, + computed, + inject, + input, +} from '@angular/core' +import { i18nPipe, i18nKey } from '@start9labs/shared' +import { + TuiButton, + TuiDataList, + TuiDropdown, + TuiTextfield, +} from '@taiga-ui/core' +import { PublicDomain, PublicDomainService } from './pd.service' +import { toAuthorityName } from 'src/app/utils/acme' + +@Component({ + selector: 'tr[domain]', + template: ` + {{ domain().fqdn }} + {{ domain().gateway }} + {{ authority() }} + + + + + + + + + + + `, + styles: ` + :host { + grid-template-columns: min-content 1fr min-content; + } + + td:nth-child(2) { + order: -1; + grid-column: span 2; + } + + td:last-child { + grid-area: 1 / 3 / 3; + align-self: center; + text-align: right; + } + + :host-context(tui-root._mobile) { + tui-badge { + vertical-align: bottom; + margin-inline-start: 0.25rem; + } + } + `, + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [TuiButton, TuiDataList, TuiDropdown, i18nPipe, TuiTextfield], +}) +export class PublicDomainsItemComponent { + protected readonly service = inject(PublicDomainService) + + open = false + + readonly domain = input.required() + + readonly authority = computed(() => toAuthorityName(this.domain().acme)) + readonly dnsMessage = computed( + () => + `Create one of the DNS records below to cause ${this.domain().fqdn} to resolve to ${this.domain().gateway.ipInfo.wanIp}` as i18nKey, + ) +} diff --git a/web/projects/ui/src/app/routes/portal/components/interfaces/public-domains/pd.service.ts b/web/projects/ui/src/app/routes/portal/components/interfaces/public-domains/pd.service.ts new file mode 100644 index 000000000..29f364dce --- /dev/null +++ b/web/projects/ui/src/app/routes/portal/components/interfaces/public-domains/pd.service.ts @@ -0,0 +1,253 @@ +import { inject, Injectable } from '@angular/core' +import { + DialogService, + ErrorService, + i18nKey, + LoadingService, +} from '@start9labs/shared' +import { toSignal } from '@angular/core/rxjs-interop' +import { ISB, T, utils } from '@start9labs/start-sdk' +import { filter, map } from 'rxjs' +import { FormComponent } from 'src/app/routes/portal/components/form.component' +import { ApiService } from 'src/app/services/api/embassy-api.service' +import { FormDialogService } from 'src/app/services/form-dialog.service' +import { configBuilderToSpec } from 'src/app/utils/configBuilderToSpec' +import { PatchDB } from 'patch-db-client' +import { DataModel } from 'src/app/services/patch-db/data-model' +import { toAuthorityName } from 'src/app/utils/acme' +import { GatewayPlus } from 'src/app/services/gateway.service' +import { InterfaceComponent } from '../interface.component' +import { DNS } from './dns.component' + +// @TODO translations + +export type PublicDomain = { + fqdn: string + gateway: GatewayPlus + acme: string | null +} + +export type GatewayWithId = T.NetworkInterfaceInfo & { + id: string + ipInfo: T.IpInfo +} + +@Injectable() +export class PublicDomainService { + private readonly patch = inject>(PatchDB) + private readonly loader = inject(LoadingService) + private readonly errorService = inject(ErrorService) + private readonly api = inject(ApiService) + private readonly formDialog = inject(FormDialogService) + private readonly dialog = inject(DialogService) + private readonly interface = inject(InterfaceComponent) + + readonly data = toSignal( + this.patch.watch$('serverInfo', 'network').pipe( + map(({ gateways, acme }) => ({ + gateways: Object.entries(gateways) + .filter(([_, g]) => g.ipInfo) + .map(([id, g]) => ({ id, ...g })) as GatewayWithId[], + authorities: Object.keys(acme).reduce>( + (obj, url) => ({ + ...obj, + [url]: toAuthorityName(url), + }), + { local: toAuthorityName(null) }, + ), + })), + ), + ) + + async add() { + const addSpec = ISB.InputSpec.of({ + fqdn: ISB.Value.text({ + name: 'Domain', + description: + 'Enter a fully qualified domain name. For example, if you control domain.com, you could enter domain.com or subdomain.domain.com or another.subdomain.domain.com.', + required: true, + default: null, + patterns: [utils.Patterns.domain], + }), + ...this.gatewayAndAuthoritySpec(), + }) + + this.formDialog.open(FormComponent, { + label: 'Add public domain', + data: { + spec: await configBuilderToSpec(addSpec), + buttons: [ + { + text: 'Save', + handler: (input: typeof addSpec._TYPE) => + this.save(input.fqdn, input.gateway, input.authority), + }, + ], + }, + }) + } + + async edit(domain: PublicDomain) { + const editSpec = ISB.InputSpec.of({ + ...this.gatewayAndAuthoritySpec(), + }) + + this.formDialog.open(FormComponent, { + label: 'Edit public domain', + data: { + spec: await configBuilderToSpec(editSpec), + buttons: [ + { + text: 'Save', + handler: ({ gateway, authority }: typeof editSpec._TYPE) => + this.save(domain.fqdn, gateway, authority), + }, + ], + value: { + gateway: domain.gateway.id, + authority: domain.acme, + }, + }, + }) + } + + remove(fqdn: string) { + this.dialog + .openConfirm({ label: 'Are you sure?', size: 's' }) + .pipe(filter(Boolean)) + .subscribe(async () => { + const loader = this.loader.open('Deleting').subscribe() + + try { + if (this.interface.packageId()) { + await this.api.pkgRemovePublicDomain({ + fqdn, + package: this.interface.packageId(), + host: this.interface.value()?.addressInfo.hostId || '', + }) + } else { + await this.api.osUiRemovePublicDomain({ fqdn }) + } + return true + } catch (e: any) { + this.errorService.handleError(e) + return false + } finally { + loader.unsubscribe() + } + }) + } + + showDns(fqdn: string, gateway: GatewayWithId, message: i18nKey) { + this.dialog + .openComponent(DNS, { + label: 'DNS Records' as i18nKey, + size: 'l', + data: { + fqdn, + gateway, + message, + }, + }) + .subscribe() + } + + private async save( + fqdn: string, + gatewayId: string, + authority: 'local' | string, + ) { + const gateway = this.data()!.gateways.find(g => (g.id = gatewayId))! + + const loader = this.loader.open('Saving').subscribe() + const params = { + fqdn, + gateway: gatewayId, + acme: authority === 'local' ? null : authority, + } + try { + let ip: string | null + if (this.interface.packageId()) { + ip = await this.api.pkgAddPublicDomain({ + ...params, + package: this.interface.packageId(), + host: this.interface.value()?.addressInfo.hostId || '', + }) + } else { + ip = await this.api.osUiAddPublicDomain(params) + } + + const wanIp = gateway.ipInfo.wanIp + let message = `Create one of the DNS records below to cause ${fqdn} to resolve to ${wanIp}` + + if (!ip) { + setTimeout( + () => + this.showDns( + fqdn, + gateway, + `No DNS detected for ${fqdn}. ${message}` as i18nKey, + ), + 250, + ) + } else if (ip === wanIp) { + setTimeout( + () => + this.showDns( + fqdn, + gateway, + `Invalid DNS. ${fqdn} is currently resolving to ${ip}. ${message}` as i18nKey, + ), + 250, + ) + } else { + setTimeout( + () => + this.dialog.openAlert( + `${fqdn} is successfully resolving to ${wanIp}` as i18nKey, + { label: 'DNS detected!' as i18nKey, appearance: 'positive' }, + ), + 250, + ) + } + + return true + } catch (e: any) { + this.errorService.handleError(e) + return false + } finally { + loader.unsubscribe() + } + } + + private gatewayAndAuthoritySpec() { + const data = this.data()! + + return { + gateway: ISB.Value.dynamicSelect(() => ({ + name: 'Gateway', + description: 'Select a gateway to use for this domain.', + values: data.gateways.reduce>( + (obj, gateway) => ({ + ...obj, + [gateway.id]: gateway.ipInfo!.name, + }), + {}, + ), + default: '', + disabled: data.gateways + .filter( + g => !g.ipInfo.wanIp || g.ipInfo.wanIp.split('.').at(-1) === '100', + ) + .map(g => g.id), + })), + authority: ISB.Value.select({ + name: 'Certificate Authority', + description: + 'Select the Certificate Authority that will issue the SSL/TLS certificate for this domain', + values: data.authorities, + default: '', + }), + } + } +} diff --git a/web/projects/ui/src/app/routes/portal/components/interfaces/tor-domains.component.ts b/web/projects/ui/src/app/routes/portal/components/interfaces/tor-domains.component.ts index d0fb292b2..24d42281a 100644 --- a/web/projects/ui/src/app/routes/portal/components/interfaces/tor-domains.component.ts +++ b/web/projects/ui/src/app/routes/portal/components/interfaces/tor-domains.component.ts @@ -156,19 +156,19 @@ export class InterfaceTorDomainsComponent { buttons: [ { text: this.i18n.transform('Save')!, - handler: async value => this.save(value), + handler: async value => this.save(value.key), }, ], }, }) } - private async save(form: OnionForm): Promise { + private async save(key?: string): Promise { const loader = this.loader.open('Saving').subscribe() try { - let onion = form.key - ? await this.api.addTorKey({ key: form.key }) + let onion = key + ? await this.api.addTorKey({ key }) : await this.api.generateTorKey({}) onion = `${onion}.onion` diff --git a/web/projects/ui/src/app/routes/portal/routes/services/routes/interface.component.ts b/web/projects/ui/src/app/routes/portal/routes/services/routes/interface.component.ts index 5fe6ec4a2..39ec3fdad 100644 --- a/web/projects/ui/src/app/routes/portal/routes/services/routes/interface.component.ts +++ b/web/projects/ui/src/app/routes/portal/routes/services/routes/interface.component.ts @@ -18,7 +18,7 @@ import { InterfaceComponent } from 'src/app/routes/portal/components/interfaces/ import { DataModel } from 'src/app/services/patch-db/data-model' import { TitleDirective } from 'src/app/services/title.service' import { - getClearnetDomains, + getPublicDomains, InterfaceService, } from '../../../components/interfaces/interface.service' import { GatewayService } from 'src/app/services/gateway.service' @@ -143,7 +143,8 @@ export default class ServiceInterfaceRoute { ...g, })) || [], torDomains: host.onions.map(o => `${o}.onion`), - clearnetDomains: getClearnetDomains(host), + publicDomains: getPublicDomains(host.domains.public), + privateDomains: host.domains.private, isOs: false, } }) diff --git a/web/projects/ui/src/app/routes/portal/routes/system/routes/domains/dns.component.ts b/web/projects/ui/src/app/routes/portal/routes/system/routes/domains/dns.component.ts deleted file mode 100644 index 6655dc6bd..000000000 --- a/web/projects/ui/src/app/routes/portal/routes/system/routes/domains/dns.component.ts +++ /dev/null @@ -1,195 +0,0 @@ -import { NgTemplateOutlet } from '@angular/common' -import { - ChangeDetectionStrategy, - Component, - computed, - inject, - signal, -} from '@angular/core' -import { FormsModule } from '@angular/forms' -import { ErrorService, i18nKey, i18nPipe } from '@start9labs/shared' -import { TuiButton, TuiDialogContext, TuiIcon } from '@taiga-ui/core' -import { - TuiButtonLoading, - TuiSwitch, - tuiSwitchOptionsProvider, -} from '@taiga-ui/kit' -import { injectContext, PolymorpheusComponent } from '@taiga-ui/polymorpheus' -import { TableComponent } from 'src/app/routes/portal/components/table.component' -import { ApiService } from 'src/app/services/api/embassy-api.service' -import { parse } from 'tldts' -import { MappedDomain } from './domain.service' - -// @TODO translations - -@Component({ - selector: 'dns', - template: ` - @let wanIp = context.data.gateway.ipInfo?.wanIp || ('Error' | i18n); - - @if (context.data.gateway.ipInfo?.deviceType !== 'wireguard') { - - } - - - @if (ddns) { - - - - - - - - - - - - - } @else { - - - - - - - - - - - - - } -
- @if (root() !== undefined; as $implicit) { - - } - ALIAS - {{ subdomain() || '@' }}[DDNS Address]{{ purpose().root }}
- @if (wildcard() !== undefined; as $implicit) { - - } - ALIAS - {{ subdomain() ? '*.' + subdomain() : '*' }}[DDNS Address]{{ purpose().wildcard }}
- @if (root() !== undefined; as $implicit) { - - } - A - {{ subdomain() || '@' }}{{ wanIp }}{{ purpose().root }}
- @if (wildcard() !== undefined; as $implicit) { - - } - A - {{ subdomain() ? '*.' + subdomain() : '*' }}{{ wanIp }}{{ purpose().wildcard }}
- - - @if (result) { - - } @else { - - } - - -
- -
- `, - styles: ` - label { - display: flex; - gap: 0.75rem; - align-items: center; - margin: 1rem 0; - } - - tui-icon { - font-size: 1rem; - vertical-align: text-bottom; - } - `, - providers: [ - tuiSwitchOptionsProvider({ - appearance: () => 'primary', - icon: () => '', - }), - ], - changeDetection: ChangeDetectionStrategy.OnPush, - imports: [ - TuiButton, - i18nPipe, - TableComponent, - TuiSwitch, - FormsModule, - TuiButtonLoading, - NgTemplateOutlet, - TuiIcon, - ], -}) -export class DnsComponent { - private readonly errorService = inject(ErrorService) - private readonly api = inject(ApiService) - - ddns = false - - readonly context = injectContext>() - - readonly subdomain = computed(() => parse(this.context.data.fqdn).subdomain) - readonly loading = signal(false) - readonly root = signal(undefined) - readonly wildcard = signal(undefined) - - readonly purpose = computed(() => ({ - root: this.context.data.fqdn, - wildcard: `subdomains of ${this.context.data.fqdn}`, - })) - - async testDns() { - this.reset() - this.loading.set(true) - - try { - await this.api - .testDomain({ - fqdn: this.context.data.fqdn, - gateway: this.context.data.gateway.id, - }) - .then(({ root, wildcard }) => { - this.root.set(root) - this.wildcard.set(wildcard) - }) - } catch (e: any) { - this.errorService.handleError(e) - } finally { - this.loading.set(false) - } - } - - reset() { - this.root.set(undefined) - this.wildcard.set(undefined) - } -} - -export const DNS = new PolymorpheusComponent(DnsComponent) diff --git a/web/projects/ui/src/app/routes/portal/routes/system/routes/domains/domain.service.ts b/web/projects/ui/src/app/routes/portal/routes/system/routes/domains/domain.service.ts deleted file mode 100644 index 8cc068105..000000000 --- a/web/projects/ui/src/app/routes/portal/routes/system/routes/domains/domain.service.ts +++ /dev/null @@ -1,188 +0,0 @@ -import { inject, Injectable } from '@angular/core' -import { - DialogService, - ErrorService, - i18nKey, - i18nPipe, - LoadingService, -} from '@start9labs/shared' -import { toSignal } from '@angular/core/rxjs-interop' -import { ISB, T, utils } from '@start9labs/start-sdk' -import { filter, map } from 'rxjs' -import { FormComponent } from 'src/app/routes/portal/components/form.component' -import { ApiService } from 'src/app/services/api/embassy-api.service' -import { FormDialogService } from 'src/app/services/form-dialog.service' -import { configBuilderToSpec } from 'src/app/utils/configBuilderToSpec' -import { PatchDB } from 'patch-db-client' -import { DataModel } from 'src/app/services/patch-db/data-model' -import { RR } from 'src/app/services/api/api.types' -import { DNS } from './dns.component' - -// @TODO translations - -export type MappedDomain = { - fqdn: string - gateway: { - id: string - name: string | null - ipInfo: T.IpInfo | null - } -} - -type GatewayWithId = T.NetworkInterfaceInfo & { - id: string - ipInfo: T.IpInfo & { - wanIp: string - } -} - -@Injectable() -export class DomainService { - private readonly patch = inject>(PatchDB) - private readonly loader = inject(LoadingService) - private readonly errorService = inject(ErrorService) - private readonly api = inject(ApiService) - private readonly formDialog = inject(FormDialogService) - private readonly i18n = inject(i18nPipe) - private readonly dialog = inject(DialogService) - - readonly data = toSignal( - this.patch.watch$('serverInfo', 'network').pipe( - map(({ gateways, domains }) => ({ - gateways: Object.entries(gateways) - .filter(([_, g]) => g.ipInfo && g.ipInfo.wanIp) - .map(([id, g]) => ({ id, ...g })) as GatewayWithId[], - domains: Object.entries(domains).map( - ([fqdn, { gateway }]) => - ({ - fqdn, - gateway: { - id: gateway, - ipInfo: gateways[gateway]?.ipInfo || null, - }, - }) as MappedDomain, - ), - })), - ), - ) - - async add() { - const addSpec = ISB.InputSpec.of({ - fqdn: ISB.Value.text({ - name: 'Domain', - description: - 'Enter a fully qualified domain name. For example, if you control domain.com, you could enter domain.com or subdomain.domain.com or another.subdomain.domain.com. In any case, the domain you enter and all possible subdomains of the domain will be available for assignment in StartOS', - required: true, - default: null, - patterns: [utils.Patterns.domain], - }), - ...this.gatewaysSpec(), - }) - - this.formDialog.open(FormComponent, { - label: 'Add domain', - data: { - spec: await configBuilderToSpec(addSpec), - buttons: [ - { - text: 'Save', - handler: (input: typeof addSpec._TYPE) => - this.save({ - fqdn: input.fqdn, - gateway: input.gateway, - }), - }, - ], - }, - }) - } - - async edit(domain: MappedDomain) { - const editSpec = ISB.InputSpec.of({ - ...this.gatewaysSpec(), - }) - - this.formDialog.open(FormComponent, { - label: 'Edit domain', - data: { - spec: await configBuilderToSpec(editSpec), - buttons: [ - { - text: 'Save', - handler: (input: typeof editSpec._TYPE) => - this.save({ - fqdn: domain.fqdn, - gateway: input.gateway, - }), - }, - ], - value: { - gateway: domain.gateway.id, - }, - }, - }) - } - - remove(fqdn: string) { - this.dialog - .openConfirm({ label: 'Are you sure?', size: 's' }) - .pipe(filter(Boolean)) - .subscribe(async () => { - const loader = this.loader.open('Deleting').subscribe() - - try { - await this.api.removeDomain({ fqdn }) - } catch (e: any) { - this.errorService.handleError(e) - } finally { - loader.unsubscribe() - } - }) - } - - showDns(domain: MappedDomain) { - this.dialog - .openComponent(DNS, { - label: 'DNS Records' as i18nKey, - size: 'l', - data: domain, - }) - .subscribe() - } - - private async save(params: RR.AddDomainReq) { - const loader = this.loader.open('Saving').subscribe() - - try { - await this.api.addDomain(params) - return true - } catch (e: any) { - this.errorService.handleError(e) - return false - } finally { - loader.unsubscribe() - } - } - - private gatewaysSpec() { - const gateways = this.data()?.gateways || [] - - return { - gateway: ISB.Value.dynamicSelect(() => ({ - name: 'Gateway', - description: 'Select a gateway to use for this domain.', - values: gateways.reduce>( - (obj, gateway) => ({ - ...obj, - [gateway.id]: gateway.ipInfo!.name, - }), - {}, - ), - default: '', - disabled: gateways - .filter(g => g.ipInfo.wanIp.split('.').at(-1) === '100') - .map(g => g.id), - })), - } - } -} diff --git a/web/projects/ui/src/app/routes/portal/routes/system/routes/domains/domains.component.ts b/web/projects/ui/src/app/routes/portal/routes/system/routes/domains/domains.component.ts deleted file mode 100644 index a00d5a6e5..000000000 --- a/web/projects/ui/src/app/routes/portal/routes/system/routes/domains/domains.component.ts +++ /dev/null @@ -1,64 +0,0 @@ -import { ChangeDetectionStrategy, Component, inject } from '@angular/core' -import { RouterLink } from '@angular/router' -import { DocsLinkDirective, i18nPipe } from '@start9labs/shared' -import { TuiButton } from '@taiga-ui/core' -import { TitleDirective } from 'src/app/services/title.service' -import { DomainService } from './domain.service' -import { DomainsTableComponent } from './table.component' - -@Component({ - template: ` - - - {{ 'Back' | i18n }} - - {{ 'Public Domains' | i18n }} - - -
-
- {{ 'Public Domains' | i18n }} - - {{ 'Documentation' | i18n }} - - @if (domainService.data(); as value) { - - } -
- -
- `, - styles: ` - :host { - max-width: 48rem; - } - `, - changeDetection: ChangeDetectionStrategy.OnPush, - imports: [ - TuiButton, - RouterLink, - TitleDirective, - i18nPipe, - DocsLinkDirective, - DomainsTableComponent, - ], - providers: [DomainService], -}) -export default class SystemDomainsComponent { - protected readonly domainService = inject(DomainService) -} diff --git a/web/projects/ui/src/app/routes/portal/routes/system/routes/domains/item.component.ts b/web/projects/ui/src/app/routes/portal/routes/system/routes/domains/item.component.ts deleted file mode 100644 index 13f07c06e..000000000 --- a/web/projects/ui/src/app/routes/portal/routes/system/routes/domains/item.component.ts +++ /dev/null @@ -1,88 +0,0 @@ -import { - ChangeDetectionStrategy, - Component, - inject, - input, -} from '@angular/core' -import { i18nPipe } from '@start9labs/shared' -import { - TuiButton, - TuiDataList, - TuiDropdown, - TuiTextfield, -} from '@taiga-ui/core' -import { DomainService, MappedDomain } from './domain.service' - -@Component({ - selector: 'tr[domain]', - template: ` - @if (domain(); as domain) { - {{ domain.fqdn }} - {{ domain.gateway.ipInfo?.name || '-' }} - - - - - - - - - - - } - `, - styles: ` - td:last-child { - grid-area: 1 / 2 / 4; - align-self: center; - text-align: right; - } - - :host-context(tui-root._mobile) { - grid-template-columns: 1fr min-content; - } - `, - changeDetection: ChangeDetectionStrategy.OnPush, - imports: [TuiButton, i18nPipe, TuiDropdown, TuiDataList, TuiTextfield], -}) -export class DomainItemComponent { - protected readonly domainService = inject(DomainService) - - readonly domain = input.required() - - open = false -} diff --git a/web/projects/ui/src/app/routes/portal/routes/system/routes/domains/table.component.ts b/web/projects/ui/src/app/routes/portal/routes/system/routes/domains/table.component.ts deleted file mode 100644 index a38bc5bcc..000000000 --- a/web/projects/ui/src/app/routes/portal/routes/system/routes/domains/table.component.ts +++ /dev/null @@ -1,41 +0,0 @@ -import { ChangeDetectionStrategy, Component, inject } from '@angular/core' -import { i18nPipe } from '@start9labs/shared' -import { TuiSkeleton } from '@taiga-ui/kit' -import { PlaceholderComponent } from 'src/app/routes/portal/components/placeholder.component' -import { TableComponent } from 'src/app/routes/portal/components/table.component' -import { DomainItemComponent } from './item.component' -import { DomainService } from './domain.service' - -@Component({ - selector: 'domains-table', - template: ` - - @for (domain of domainService.data()?.domains; track $index) { - - } @empty { - - - - } -
- @if (domainService.data()?.domains) { - - {{ 'No public domains' | i18n }} - - } @else { -
{{ 'Loading' | i18n }}
- } -
- `, - changeDetection: ChangeDetectionStrategy.OnPush, - imports: [ - TuiSkeleton, - i18nPipe, - TableComponent, - PlaceholderComponent, - DomainItemComponent, - ], -}) -export class DomainsTableComponent { - protected readonly domainService = inject(DomainService) -} diff --git a/web/projects/ui/src/app/routes/portal/routes/system/routes/gateways/item.component.ts b/web/projects/ui/src/app/routes/portal/routes/system/routes/gateways/item.component.ts index 1a9b32c6c..43fb1fef5 100644 --- a/web/projects/ui/src/app/routes/portal/routes/system/routes/gateways/item.component.ts +++ b/web/projects/ui/src/app/routes/portal/routes/system/routes/gateways/item.component.ts @@ -41,7 +41,7 @@ import { GatewayPlus } from 'src/app/services/gateway.service' - } - {{ gateway.ipv4.join(', ') }} + {{ gateway.lanIpv4.join(', ') }}