From daf584b33ee4babdbc8b523417793d243e853435 Mon Sep 17 00:00:00 2001 From: Matt Hill Date: Wed, 30 Jul 2025 15:33:13 -0600 Subject: [PATCH 1/8] add domains and gateways, remove routers, fix docs links --- .../app/components/documentation.component.ts | 4 +- .../src/directives/docs-link.directive.ts | 8 +- .../shared/src/i18n/dictionaries/de.ts | 23 +- .../shared/src/i18n/dictionaries/en.ts | 23 +- .../shared/src/i18n/dictionaries/es.ts | 23 +- .../shared/src/i18n/dictionaries/fr.ts | 23 +- .../shared/src/i18n/dictionaries/pl.ts | 23 +- .../login/ca-wizard/ca-wizard.component.html | 2 +- .../components/header/menu.component.ts | 2 +- .../interfaces/clearnet.component.ts | 2 +- .../components/interfaces/local.component.ts | 2 +- .../components/interfaces/tor.component.ts | 2 +- .../routes/backups/modals/jobs.component.ts | 2 +- .../backups/modals/targets.component.ts | 2 +- .../portal/routes/metrics/time.component.ts | 3 +- .../system/routes/acme/acme.component.ts | 290 -------------- .../routes/backups/backups.component.ts | 7 +- .../routes/system/routes/domains/constants.ts | 134 ------- .../routes/domains/domains.component.ts | 379 ++++++++++++------ .../system/routes/domains/info.component.ts | 16 - .../system/routes/domains/item.component.ts | 94 +++++ .../system/routes/domains/table.component.ts | 208 +++++----- .../system/routes/email/email.component.ts | 2 +- .../gateways.component.ts} | 110 +++-- .../{proxies => gateways}/item.component.ts | 48 +-- .../{proxies => gateways}/table.component.ts | 49 +-- .../system/routes/router/info.component.ts | 43 -- .../system/routes/router/primary-ip.pipe.ts | 15 - .../system/routes/router/router.component.ts | 68 ---- .../system/routes/router/table.component.ts | 143 ------- .../routes/system/routes/ssh/ssh.component.ts | 2 +- .../portal/routes/system/system.const.ts | 12 +- .../portal/routes/system/system.routes.ts | 22 +- .../ui/src/app/services/api/mock-patch.ts | 4 +- 34 files changed, 645 insertions(+), 1145 deletions(-) delete mode 100644 web/projects/ui/src/app/routes/portal/routes/system/routes/acme/acme.component.ts delete mode 100644 web/projects/ui/src/app/routes/portal/routes/system/routes/domains/constants.ts delete mode 100644 web/projects/ui/src/app/routes/portal/routes/system/routes/domains/info.component.ts create mode 100644 web/projects/ui/src/app/routes/portal/routes/system/routes/domains/item.component.ts rename web/projects/ui/src/app/routes/portal/routes/system/routes/{proxies/proxies.component.ts => gateways/gateways.component.ts} (56%) rename web/projects/ui/src/app/routes/portal/routes/system/routes/{proxies => gateways}/item.component.ts (66%) rename web/projects/ui/src/app/routes/portal/routes/system/routes/{proxies => gateways}/table.component.ts (73%) delete mode 100644 web/projects/ui/src/app/routes/portal/routes/system/routes/router/info.component.ts delete mode 100644 web/projects/ui/src/app/routes/portal/routes/system/routes/router/primary-ip.pipe.ts delete mode 100644 web/projects/ui/src/app/routes/portal/routes/system/routes/router/router.component.ts delete mode 100644 web/projects/ui/src/app/routes/portal/routes/system/routes/router/table.component.ts diff --git a/web/projects/setup-wizard/src/app/components/documentation.component.ts b/web/projects/setup-wizard/src/app/components/documentation.component.ts index aa9a2b442..9d127e4c4 100644 --- a/web/projects/setup-wizard/src/app/components/documentation.component.ts +++ b/web/projects/setup-wizard/src/app/components/documentation.component.ts @@ -44,7 +44,7 @@ import { DocsLinkDirective } from '@start9labs/shared' Download your server's Root CA and follow the instructions @@ -110,7 +110,7 @@ import { DocsLinkDirective } from '@start9labs/shared' This address will only work from a Tor-enabled browser. Follow the instructions diff --git a/web/projects/shared/src/directives/docs-link.directive.ts b/web/projects/shared/src/directives/docs-link.directive.ts index 72b3b4c9b..e98b76b8c 100644 --- a/web/projects/shared/src/directives/docs-link.directive.ts +++ b/web/projects/shared/src/directives/docs-link.directive.ts @@ -19,11 +19,13 @@ export const VERSION = new InjectionToken('VERSION') export class DocsLinkDirective { private readonly version = inject(VERSION) - readonly href = input.required() + readonly path = input.required() + + readonly fragment = input('') protected readonly url = computed(() => { - const path = this.href() + const path = this.path() const relative = path.startsWith('/') ? path : `/${path}` - return `https://docs.start9.com${relative}?os=${this.version}` + return `https://docs.start9.com${relative}?os=${this.version}${this.fragment()}` }) } diff --git a/web/projects/shared/src/i18n/dictionaries/de.ts b/web/projects/shared/src/i18n/dictionaries/de.ts index 8f865edbe..a404d9733 100644 --- a/web/projects/shared/src/i18n/dictionaries/de.ts +++ b/web/projects/shared/src/i18n/dictionaries/de.ts @@ -296,8 +296,6 @@ export default { 298: 'Ungültige Paketdatei', 299: 'Fügen Sie ACME-Anbieter hinzu, um SSL-(https)-Zertifikate für den Clearnet-Zugriff zu generieren.', 300: 'Anleitung anzeigen', - 301: 'Gespeicherte Anbieter', - 302: 'Anbieter hinzufügen', 303: 'Kontakt', 304: 'Bearbeiten', 305: 'ACME-Anbieter hinzufügen', @@ -528,12 +526,17 @@ export default { 530: 'StartOS-Paket', 531: 'Fehler beim Initialisieren des Servers', 532: 'Abgeschlossen', - 533: 'Eingehende Proxys', - 534: 'Eingehende Proxys ermöglichen den Fernzugriff auf Ihren Server und installierte Dienste.', - 535: 'Gespeicherte Proxys', - 536: 'Proxy hinzufügen', - 537: 'Bezeichnung', - 538: 'Keine Proxys', - 539: 'Bezeichnung aktualisieren', - 540: 'Umbenennen', + 533: 'Gateways', + 534: 'Gateways verbinden Ihren Server mit dem Internet. Sie verarbeiten ausgehenden Datenverkehr und erlauben unter bestimmten Bedingungen auch eingehenden Verkehr.', + 535: 'Gateway hinzufügen', + 536: 'Umbenennen', + 537: 'Zugriff', + 538: 'Domains', + 539: 'ACME-Anbieter', + 540: 'Domain', + 541: 'Gateway', + 542: 'Standard-ACME', + 543: 'Gateway ändern', + 544: 'Standard-ACME ändern', + 545: 'Keine Domains', } satisfies i18n diff --git a/web/projects/shared/src/i18n/dictionaries/en.ts b/web/projects/shared/src/i18n/dictionaries/en.ts index 0e34f02d4..52425b7ae 100644 --- a/web/projects/shared/src/i18n/dictionaries/en.ts +++ b/web/projects/shared/src/i18n/dictionaries/en.ts @@ -295,8 +295,6 @@ export const ENGLISH = { 'Invalid package file': 298, 'Add ACME providers in order to generate SSL (https) certificates for clearnet access.': 299, 'View instructions': 300, - 'Saved Providers': 301, // as in, ACME service provider, such as Let's Encrypt - 'Add Provider': 302, 'Contact': 303, // as in, "contact us" 'Edit': 304, 'Add ACME Provider': 305, @@ -527,12 +525,17 @@ export const ENGLISH = { 'StartOS package': 530, // as in, the URL of the source code for the StartOS package 'Error initializing server': 531, 'Finished': 532, // an in, complete - 'Inbound Proxies': 533, // as in a service used to proxy internet traffic - 'Inbound proxies provide remote access to your server and installed services.': 534, - 'Saved Proxies': 535, // as in, a list of proxies already added to StartOS - 'Add Proxy': 536, // as in, add a new proxy to StartOS - 'Label': 537, // as in, a name given to something - 'No proxies': 538, - 'Update Label': 539, - 'Rename': 540 + 'Gateways': 533, // as in, a device or software that connects two different networks + 'Gateways connect your server to the Internet. They process outbound traffic, and under certain conditions, they also permit inbound traffic.': 534, + 'Add Gateway': 535, // as in, add a new network gateway to StartOS + 'Rename': 536, + 'Access': 537, // as in, public or private access, almost "permission" + 'Domains': 538, // as in, internet domains + 'ACME Providers': 539, + 'Domain': 540, // as in, an internat domain name + 'Gateway': 541, // as in, a device or software that connects two different networks + 'Default ACME': 542, // as in, the default ACME provider for signing certificates + 'Change gateway': 543, // as in, change the network gateway for a computer + 'Change default ACME': 544, // as in, change the default ACME provider for a domain + 'No domains': 545, } as const diff --git a/web/projects/shared/src/i18n/dictionaries/es.ts b/web/projects/shared/src/i18n/dictionaries/es.ts index fbe4cd85f..882aa177e 100644 --- a/web/projects/shared/src/i18n/dictionaries/es.ts +++ b/web/projects/shared/src/i18n/dictionaries/es.ts @@ -296,8 +296,6 @@ export default { 298: 'Archivo de paquete inválido', 299: 'Agrega proveedores ACME para generar certificados SSL (https) para el acceso desde clearnet.', 300: 'Ver instrucciones', - 301: 'Proveedores guardados', - 302: 'Agregar proveedor', 303: 'Contacto', 304: 'Editar', 305: 'Agregar proveedor ACME', @@ -528,12 +526,17 @@ export default { 530: 'Paquete StartOS', 531: 'Error al inicializar el servidor', 532: 'Finalizado', - 533: 'Proxies entrantes', - 534: 'Los proxies entrantes proporcionan acceso remoto a su servidor y servicios instalados.', - 535: 'Proxies guardados', - 536: 'Agregar proxy', - 537: 'Etiqueta', - 538: 'Sin proxies', - 539: 'Actualizar etiqueta', - 540: 'Renombrar', + 533: 'Puertas de enlace', + 534: 'Las puertas de enlace conectan su servidor a Internet. Procesan el tráfico saliente y, en ciertas condiciones, también permiten tráfico entrante.', + 535: 'Agregar puerta de enlace', + 536: 'Renombrar', + 537: 'Acceso', + 538: 'Dominios', + 539: 'Proveedores ACME', + 540: 'Dominio', + 541: 'Puerta de enlace', + 542: 'ACME predeterminado', + 543: 'Cambiar puerta de enlace', + 544: 'Cambiar ACME predeterminado', + 545: 'Sin dominios', } satisfies i18n diff --git a/web/projects/shared/src/i18n/dictionaries/fr.ts b/web/projects/shared/src/i18n/dictionaries/fr.ts index 5137d7dc9..05b461f46 100644 --- a/web/projects/shared/src/i18n/dictionaries/fr.ts +++ b/web/projects/shared/src/i18n/dictionaries/fr.ts @@ -296,8 +296,6 @@ export default { 298: 'Fichier paquet invalide', 299: 'Ajoutez des fournisseurs ACME pour générer des certificats SSL (https) pour l’accès clearnet.', 300: 'Voir les instructions', - 301: 'Fournisseurs enregistrés', - 302: 'Ajouter un fournisseur', 303: 'Contact', 304: 'Modifier', 305: 'Ajouter un fournisseur ACME', @@ -528,12 +526,17 @@ export default { 530: 'Paquet StartOS', 531: "Erreur lors de l'initialisation du serveur", 532: 'Terminé', - 533: 'Proxies entrants', - 534: 'Les proxies entrants permettent un accès à distance à votre serveur et aux services installés.', - 535: 'Proxies enregistrés', - 536: 'Ajouter un proxy', - 537: 'Étiquette', - 538: 'Aucun proxy', - 539: 'Mettre à jour l’étiquette', - 540: 'Renommer', + 533: 'Passerelles', + 534: 'Les passerelles connectent votre serveur à Internet. Elles traitent le trafic sortant et, dans certaines conditions, autorisent également le trafic entrant.', + 535: 'Ajouter une passerelle', + 536: 'Renommer', + 537: 'Accès', + 538: 'Domaines', + 539: 'Fournisseurs ACME', + 540: 'Domaine', + 541: 'Passerelle', + 542: 'ACME par défaut', + 543: 'Changer de passerelle', + 544: 'Changer l’ACME par défaut', + 545: 'Aucun domaine', } satisfies i18n diff --git a/web/projects/shared/src/i18n/dictionaries/pl.ts b/web/projects/shared/src/i18n/dictionaries/pl.ts index 7f922a77b..745506475 100644 --- a/web/projects/shared/src/i18n/dictionaries/pl.ts +++ b/web/projects/shared/src/i18n/dictionaries/pl.ts @@ -296,8 +296,6 @@ export default { 298: 'Nieprawidłowy plik pakietu', 299: 'Dodaj dostawców ACME, aby wygenerować certyfikaty SSL (https) dla dostępu przez clearnet.', 300: 'Zobacz instrukcje', - 301: 'Zapisani dostawcy', - 302: 'Dodaj dostawcę', 303: 'Kontakt', 304: 'Edytuj', 305: 'Dodaj dostawcę ACME', @@ -528,12 +526,17 @@ export default { 530: 'Pakiet StartOS', 531: 'Błąd inicjalizacji serwera', 532: 'Zakończono', - 533: 'Proksy przychodzące', - 534: 'Proksy przychodzące zapewniają zdalny dostęp do twojego serwera i zainstalowanych usług.', - 535: 'Zapisane proksy', - 536: 'Dodaj proksy', - 537: 'Etykieta', - 538: 'Brak proksy', - 539: 'Aktualizuj etykietę', - 540: 'Zmień nazwę', + 533: 'Bramy sieciowe', + 534: 'Bramy łączą twój serwer z Internetem. Przetwarzają ruch wychodzący, a w pewnych warunkach również dopuszczają ruch przychodzący.', + 535: 'Dodaj bramę', + 536: 'Zmień nazwę', + 537: 'Dostęp', + 538: 'Domeny', + 539: 'Dostawcy ACME', + 540: 'Domena', + 541: 'Brama', + 542: 'Domyślny ACME', + 543: 'Zmień bramę', + 544: 'Zmień domyślny ACME', + 545: 'Brak domen', } satisfies i18n diff --git a/web/projects/ui/src/app/routes/login/ca-wizard/ca-wizard.component.html b/web/projects/ui/src/app/routes/login/ca-wizard/ca-wizard.component.html index 3c7d97d45..3d2b796b3 100644 --- a/web/projects/ui/src/app/routes/login/ca-wizard/ca-wizard.component.html +++ b/web/projects/ui/src/app/routes/login/ca-wizard/ca-wizard.component.html @@ -46,7 +46,7 @@ tuiButton docsLink size="s" - href="/user-manual/trust-ca.html" + path="/user-manual/trust-ca.html" iconEnd="@tui.external-link" > {{ 'View instructions' | i18n }} diff --git a/web/projects/ui/src/app/routes/portal/components/header/menu.component.ts b/web/projects/ui/src/app/routes/portal/components/header/menu.component.ts index 73809b743..c3e9c47f0 100644 --- a/web/projects/ui/src/app/routes/portal/components/header/menu.component.ts +++ b/web/projects/ui/src/app/routes/portal/components/header/menu.component.ts @@ -50,7 +50,7 @@ import { ABOUT } from './about.component' - + {{ 'User manual' | i18n }} {{ 'Learn more' | i18n }} diff --git a/web/projects/ui/src/app/routes/portal/components/interfaces/local.component.ts b/web/projects/ui/src/app/routes/portal/components/interfaces/local.component.ts index 10edaeda9..03f2b184b 100644 --- a/web/projects/ui/src/app/routes/portal/components/interfaces/local.component.ts +++ b/web/projects/ui/src/app/routes/portal/components/interfaces/local.component.ts @@ -18,7 +18,7 @@ import { DocsLinkDirective, i18nPipe } from '@start9labs/shared' 'Local addresses can only be accessed by devices connected to the same LAN as your server, either directly or using a VPN.' | i18n }} - + {{ 'Learn More' | i18n }} diff --git a/web/projects/ui/src/app/routes/portal/components/interfaces/tor.component.ts b/web/projects/ui/src/app/routes/portal/components/interfaces/tor.component.ts index 1ae620b98..b812470c8 100644 --- a/web/projects/ui/src/app/routes/portal/components/interfaces/tor.component.ts +++ b/web/projects/ui/src/app/routes/portal/components/interfaces/tor.component.ts @@ -50,7 +50,7 @@ type OnionForm = { 'Add an onion address to anonymously expose this interface on the darknet. Onion addresses can only be reached over the Tor network.' | i18n }} - + {{ 'Learn More' | i18n }} diff --git a/web/projects/ui/src/app/routes/portal/routes/backups/modals/jobs.component.ts b/web/projects/ui/src/app/routes/portal/routes/backups/modals/jobs.component.ts index 84be0326b..72c67e8d9 100644 --- a/web/projects/ui/src/app/routes/portal/routes/backups/modals/jobs.component.ts +++ b/web/projects/ui/src/app/routes/portal/routes/backups/modals/jobs.component.ts @@ -26,7 +26,7 @@ import { DocsLinkDirective } from 'projects/shared/src/public-api' Scheduling automatic backups is an excellent way to ensure your StartOS data is safely backed up. StartOS will issue a notification whenever one of your scheduled backups succeeds or fails. - View instructions + View instructions

Saved Jobs diff --git a/web/projects/ui/src/app/routes/portal/routes/backups/modals/targets.component.ts b/web/projects/ui/src/app/routes/portal/routes/backups/modals/targets.component.ts index c390247b7..07f96b917 100644 --- a/web/projects/ui/src/app/routes/portal/routes/backups/modals/targets.component.ts +++ b/web/projects/ui/src/app/routes/portal/routes/backups/modals/targets.component.ts @@ -31,7 +31,7 @@ import { DocsLinkDirective } from 'projects/shared/src/public-api' backups. They can be physical drives plugged into your server, shared folders on your Local Area Network (LAN), or third party clouds such as Dropbox or Google Drive. - View instructions + View instructions

Unknown Physical Drives diff --git a/web/projects/ui/src/app/routes/portal/routes/metrics/time.component.ts b/web/projects/ui/src/app/routes/portal/routes/metrics/time.component.ts index 00d97e853..73cae2664 100644 --- a/web/projects/ui/src/app/routes/portal/routes/metrics/time.component.ts +++ b/web/projects/ui/src/app/routes/portal/routes/metrics/time.component.ts @@ -49,7 +49,8 @@ import { TimeService } from 'src/app/services/time.service' docsLink iconEnd="@tui.external-link" appearance="" - href="/help/common-issues.html#clock-sync-failure" + path="/help/common-issues.html" + fragment="#clock-sync-failure" [pseudo]="true" [textContent]="'the docs' | i18n" > diff --git a/web/projects/ui/src/app/routes/portal/routes/system/routes/acme/acme.component.ts b/web/projects/ui/src/app/routes/portal/routes/system/routes/acme/acme.component.ts deleted file mode 100644 index 0e0f66f98..000000000 --- a/web/projects/ui/src/app/routes/portal/routes/system/routes/acme/acme.component.ts +++ /dev/null @@ -1,290 +0,0 @@ -import { ChangeDetectionStrategy, Component, inject } from '@angular/core' -import { toSignal } from '@angular/core/rxjs-interop' -import { RouterLink } from '@angular/router' -import { - DocsLinkDirective, - ErrorService, - i18nPipe, - LoadingService, -} from '@start9labs/shared' -import { ISB, utils } from '@start9labs/start-sdk' -import { TuiButton, TuiLink, TuiLoader, TuiTitle } from '@taiga-ui/core' -import { TuiCell, TuiHeader } from '@taiga-ui/layout' -import { PatchDB } from 'patch-db-client' -import { map } from 'rxjs' -import { FormComponent } from 'src/app/routes/portal/components/form.component' -import { PlaceholderComponent } from 'src/app/routes/portal/components/placeholder.component' -import { ApiService } from 'src/app/services/api/embassy-api.service' -import { FormDialogService } from 'src/app/services/form-dialog.service' -import { DataModel } from 'src/app/services/patch-db/data-model' -import { TitleDirective } from 'src/app/services/title.service' -import { knownACME, toAcmeName } from 'src/app/utils/acme' -import { configBuilderToSpec } from 'src/app/utils/configBuilderToSpec' - -@Component({ - template: ` - - - {{ 'Back' | i18n }} - - ACME - -
-
-

ACME

-

- {{ - 'Add ACME providers in order to generate SSL (https) certificates for clearnet access.' - | i18n - }} - -

-
-
-
-
- {{ 'Saved Providers' | i18n }} - @if (acme(); as value) { - - } -
- @if (acme(); as value) { - @for (provider of value; track $index) { -
- - {{ toAcmeName(provider.url) }} - - {{ 'Contact' | i18n }}: {{ provider.contactString }} - - - - -
- } @empty { - - {{ 'No saved providers' | i18n }} - - } - } @else { - - } -
- `, - styles: ` - :host { - max-width: 36rem; - } - `, - changeDetection: ChangeDetectionStrategy.OnPush, - imports: [ - TuiButton, - TuiLoader, - TuiCell, - TuiTitle, - TuiHeader, - TuiLink, - RouterLink, - TitleDirective, - i18nPipe, - DocsLinkDirective, - PlaceholderComponent, - ], -}) -export default class SystemAcmeComponent { - private readonly formDialog = inject(FormDialogService) - private readonly loader = inject(LoadingService) - private readonly errorService = inject(ErrorService) - private readonly patch = inject>(PatchDB) - private readonly api = inject(ApiService) - private readonly i18n = inject(i18nPipe) - - acme = toSignal( - this.patch.watch$('serverInfo', 'network', 'acme').pipe( - map(acme => - Object.keys(acme).map(url => { - const contact = - acme[url]?.contact.map(mailto => mailto.replace('mailto:', '')) || - [] - return { - url, - contact, - contactString: contact.join(', '), - } - }), - ), - ), - ) - - toAcmeName = toAcmeName - - async addAcme( - providers: { - url: string - contact: string[] - contactString: string - }[], - ) { - this.formDialog.open(FormComponent, { - label: 'Add ACME Provider', - data: { - spec: await configBuilderToSpec( - this.addAcmeSpec(providers.map(p => p.url)), - ), - buttons: [ - { - text: this.i18n.transform('Save'), - handler: async ( - val: ReturnType['_TYPE'], - ) => { - const providerUrl = - val.provider.selection === 'other' - ? val.provider.value.url - : val.provider.selection - - return this.saveAcme(providerUrl, val.contact) - }, - }, - ], - }, - }) - } - - async editAcme(provider: string, contact: string[]) { - this.formDialog.open(FormComponent, { - label: 'Edit ACME Provider', - data: { - spec: await configBuilderToSpec(this.editAcmeSpec()), - buttons: [ - { - text: this.i18n.transform('Save'), - handler: async ( - val: ReturnType['_TYPE'], - ) => this.saveAcme(provider, val.contact), - }, - ], - value: { contact }, - }, - }) - } - - async removeAcme(provider: string) { - const loader = this.loader.open('Removing').subscribe() - - try { - await this.api.removeAcme({ provider }) - } catch (e: any) { - this.errorService.handleError(e) - } finally { - loader.unsubscribe() - } - } - - private async saveAcme(providerUrl: string, contact: string[]) { - const loader = this.loader.open('Saving').subscribe() - - try { - await this.api.initAcme({ - provider: new URL(providerUrl).href, - contact: contact.map(address => `mailto:${address}`), - }) - return true - } catch (e: any) { - this.errorService.handleError(e) - return false - } finally { - loader.unsubscribe() - } - } - - private addAcmeSpec(providers: string[]) { - const availableAcme = knownACME.filter( - acme => !providers.includes(acme.url), - ) - - return ISB.InputSpec.of({ - provider: ISB.Value.union({ - name: 'Provider', - default: (availableAcme[0]?.url as any) || 'other', - variants: ISB.Variants.of({ - ...availableAcme.reduce( - (obj, curr) => ({ - ...obj, - [curr.url]: { - name: curr.name, - spec: ISB.InputSpec.of({}), - }, - }), - {}, - ), - other: { - name: 'Other', - spec: ISB.InputSpec.of({ - url: ISB.Value.text({ - name: 'URL', - default: null, - required: true, - inputmode: 'url', - patterns: [utils.Patterns.url], - }), - }), - }, - }), - }), - contact: this.emailListSpec(), - }) - } - - private editAcmeSpec() { - return ISB.InputSpec.of({ - contact: this.emailListSpec(), - }) - } - - private emailListSpec() { - return ISB.Value.list( - ISB.List.text( - { - name: this.i18n.transform('Contact Emails')!, - description: this.i18n.transform( - 'Needed to obtain a certificate from a Certificate Authority', - ), - minLength: 1, - }, - { - inputmode: 'email', - patterns: [utils.Patterns.email], - }, - ), - ) - } -} diff --git a/web/projects/ui/src/app/routes/portal/routes/system/routes/backups/backups.component.ts b/web/projects/ui/src/app/routes/portal/routes/system/routes/backups/backups.component.ts index 68f75d848..179699140 100644 --- a/web/projects/ui/src/app/routes/portal/routes/system/routes/backups/backups.component.ts +++ b/web/projects/ui/src/app/routes/portal/routes/system/routes/backups/backups.component.ts @@ -66,7 +66,7 @@ import { BACKUP_RESTORE } from './restore.component' = proxies - .filter(p => p.type === 'inbound-outbound') - .reduce( - (prev, curr) => ({ - [curr.id]: curr.name, - ...prev, - }), - {}, - ) - - return ISB.Value.union( - { - name: 'Networking Strategy', - default: 'local', - description: `
Local
Select this option if you do not mind exposing your home/business IP address to the Internet. This option requires configuring router settings, which StartOS can do automatically if you have an OpenWRT router -
Proxy
Select this option is you prefer to hide your home/business IP address from the Internet. This option requires running your own Virtual Private Server (VPS) or paying service provider such as Static Wire -`, - }, - ISB.Variants.of({ - local: { - name: 'Local', - spec: ISB.InputSpec.of({ - ipStrategy: ISB.Value.select({ - name: 'IP Strategy', - description: `
IPv6 Only (recommended)
Requirements:
  1. ISP IPv6 support
  2. OpenWRT (recommended) or Linksys router
Pros: Ready for IPv6 Internet. Enhanced privacy. Run multiple clearnet servers from the same network -Cons: Interfaces using this domain will only be accessible to people whose ISP supports IPv6 -
IPv6 and IPv4
Pros: Ready for IPv6 Internet. Accessible by anyone -Cons: Less private, as IPv4 addresses are closely correlated with geographic areas. Cannot run multiple clearnet servers from the same network -
IPv4 Only
Pros: Accessible by anyone -Cons: Less private, as IPv4 addresses are closely correlated with geographic areas. Cannot run multiple clearnet servers from the same network -`, - default: 'ipv6', - values: { - ipv6: 'IPv6 Only', - ipv4: 'IPv4 Only', - dualstack: 'IPv6 and IPv4', - }, - }), - }), - }, - proxy: { - name: 'Proxy', - spec: ISB.InputSpec.of({ - proxyId: ISB.Value.select({ - name: 'Select Proxy', - default: proxies.filter(p => p.type === 'inbound-outbound')[0].id, - values: inboundProxies, - }), - }), - }, - }), - ) -} - -export function getStart9ToSpec(proxies: Proxy[]) { - return configBuilderToSpec( - ISB.InputSpec.of({ - strategy: getStrategyUnion(proxies), - }), - ) -} - -export function getCustomSpec(proxies: Proxy[]) { - return configBuilderToSpec( - ISB.InputSpec.of({ - hostname: ISB.Value.text({ - name: 'Hostname', - required: true, - default: null, - placeholder: 'yourdomain.com', - }), - provider: ISB.Value.union( - { - name: 'Dynamic DNS Provider', - default: 'start9', - }, - ISB.Variants.of({ - start9: { - name: 'Start9', - spec: ISB.InputSpec.of({}), - }, - njalla: { - name: 'Njalla', - spec: auth, - }, - duckdns: { - name: 'Duck DNS', - spec: auth, - }, - dyn: { - name: 'DynDNS', - spec: auth, - }, - easydns: { - name: 'easyDNS', - spec: auth, - }, - zoneedit: { - name: 'Zoneedit', - spec: auth, - }, - googledomains: { - name: 'Google Domains (IPv4 or IPv6)', - spec: auth, - }, - namecheap: { - name: 'Namecheap (IPv4 only)', - spec: auth, - }, - }), - ), - strategy: getStrategyUnion(proxies), - }), - ) -} 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 index c6e713587..e2fc0ad6d 100644 --- 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 @@ -1,190 +1,245 @@ -import { TUI_CONFIRM } from '@taiga-ui/kit' -import { CommonModule } from '@angular/common' -import { ChangeDetectionStrategy, Component, inject } from '@angular/core' -import { ErrorService, LoadingService } from '@start9labs/shared' -import { TuiDialogOptions, TuiDialogService, TuiButton } from '@taiga-ui/core' -import { PatchDB } from 'patch-db-client' -import { filter, firstValueFrom, map } from 'rxjs' import { - FormComponent, - FormContext, -} from 'src/app/routes/portal/components/form.component' + ChangeDetectionStrategy, + Component, + inject, + signal, +} from '@angular/core' +import { toSignal } from '@angular/core/rxjs-interop' +import { RouterLink } from '@angular/router' +import { + DocsLinkDirective, + ErrorService, + i18nPipe, + LoadingService, +} from '@start9labs/shared' +import { ISB, utils } from '@start9labs/start-sdk' +import { TuiButton, TuiLink, TuiLoader, TuiTitle } from '@taiga-ui/core' +import { TuiCell, TuiHeader } from '@taiga-ui/layout' +import { PatchDB } from 'patch-db-client' +import { map } from 'rxjs' +import { FormComponent } from 'src/app/routes/portal/components/form.component' +import { PlaceholderComponent } from 'src/app/routes/portal/components/placeholder.component' import { ApiService } from 'src/app/services/api/embassy-api.service' import { FormDialogService } from 'src/app/services/form-dialog.service' import { DataModel } from 'src/app/services/patch-db/data-model' -import { getCustomSpec, getStart9ToSpec } from './constants' -import { DomainsInfoComponent } from './info.component' +import { TitleDirective } from 'src/app/services/title.service' +import { knownACME, toAcmeName } from 'src/app/utils/acme' +import { configBuilderToSpec } from 'src/app/utils/configBuilderToSpec' import { DomainsTableComponent } from './table.component' @Component({ template: ` - - @if (domains$ | async; as domains) { -

- Start9.to - @if (!domains.start9To.length) { - } -

-
-

- Custom Domains - + + + } @empty { + + {{ 'No saved providers' | i18n }} + + } + } @else { + + } + + +
+
+ {{ 'Domains' | i18n }} + -

-
- } + +
+ `, + styles: ``, changeDetection: ChangeDetectionStrategy.OnPush, imports: [ - CommonModule, TuiButton, + TuiLoader, + TuiCell, + TuiTitle, + TuiHeader, + TuiLink, + RouterLink, + TitleDirective, + i18nPipe, + DocsLinkDirective, + PlaceholderComponent, DomainsTableComponent, - DomainsInfoComponent, ], }) export default class SystemDomainsComponent { + private readonly formDialog = inject(FormDialogService) private readonly loader = inject(LoadingService) private readonly errorService = inject(ErrorService) - private readonly formDialog = inject(FormDialogService) private readonly patch = inject>(PatchDB) private readonly api = inject(ApiService) - private readonly dialogs = inject(TuiDialogService) + private readonly i18n = inject(i18nPipe) - private readonly start9To$ = this.patch.watch$( - 'serverInfo', - 'network', - 'start9To', + acme = toSignal( + this.patch.watch$('serverInfo', 'network', 'acme').pipe( + map(acme => + Object.keys(acme).map(url => { + const contact = + acme[url]?.contact.map(mailto => mailto.replace('mailto:', '')) || + [] + return { + url, + contact, + contactString: contact.join(', '), + } + }), + ), + ), ) - readonly domains$ = this.patch.watch$('serverInfo', 'network', 'domains') + domains = signal([]) - delete(hostname?: string) { - this.dialogs - .open(TUI_CONFIRM, { - label: 'Confirm', - size: 's', - data: { - content: `Delete ${hostname || 'start9.to'} domain?`, - yes: 'Delete', - no: 'Cancel', - }, - }) - .pipe(filter(Boolean)) - .subscribe(() => this.deleteDomain(hostname)) - } + toAcmeName = toAcmeName - async add() { - const proxies = await firstValueFrom( - this.patch.watch$('serverInfo', 'network', 'proxies'), - ) - - const options: Partial>> = { - label: 'Custom Domain', + async addAcme( + providers: { + url: string + contact: string[] + contactString: string + }[], + ) { + this.formDialog.open(FormComponent, { + label: 'Add ACME Provider', data: { - spec: await getCustomSpec(proxies), + spec: await configBuilderToSpec( + this.addAcmeSpec(providers.map(p => p.url)), + ), buttons: [ { - text: 'Manage proxies', - link: '/system/proxies', - }, - { - text: 'Save', - handler: async value => this.save(value), + text: this.i18n.transform('Save'), + handler: async ( + val: ReturnType['_TYPE'], + ) => { + const providerUrl = + val.provider.selection === 'other' + ? val.provider.value.url + : val.provider.selection + + return this.saveAcme(providerUrl, val.contact) + }, }, ], }, - } - - this.formDialog.open(FormComponent, options) + }) } - async claim() { - const proxies = await firstValueFrom( - this.patch.watch$('serverInfo', 'network', 'proxies'), - ) + async addDomain() {} - const options: Partial>> = { - label: 'start9.to', + async editAcme(provider: string, contact: string[]) { + this.formDialog.open(FormComponent, { + label: 'Edit ACME Provider', data: { - spec: await getStart9ToSpec(proxies), + spec: await configBuilderToSpec(this.editAcmeSpec()), buttons: [ { - text: 'Manage proxies', - link: '/system/proxies', - }, - { - text: 'Save', - handler: async value => this.claimDomain(value), + text: this.i18n.transform('Save'), + handler: async ( + val: ReturnType['_TYPE'], + ) => this.saveAcme(provider, val.contact), }, ], + value: { contact }, }, - } - - this.formDialog.open(FormComponent, options) - } - // @TODO 041 figure out how to get types here - private getNetworkStrategy(strategy: any) { - return strategy.selection === 'local' - ? { ipStrategy: strategy.value.ipStrategy } - : { proxy: strategy.value.proxyId } + }) } - private async deleteDomain(hostname?: string) { - const loader = this.loader.open('Deleting').subscribe() + async removeAcme(provider: string) { + const loader = this.loader.open('Removing').subscribe() try { - if (hostname) { - await this.api.deleteDomain({ hostname }) - } else { - await this.api.deleteStart9ToDomain({}) - } + await this.api.removeAcme({ provider }) } catch (e: any) { this.errorService.handleError(e) } finally { loader.unsubscribe() } } - // @TODO 041 figure out how to get types here - private async claimDomain({ strategy }: any): Promise { + + private async saveAcme(providerUrl: string, contact: string[]) { const loader = this.loader.open('Saving').subscribe() - const networkStrategy = this.getNetworkStrategy(strategy) try { - await this.api.claimStart9ToDomain({ networkStrategy }) - return true - } catch (e: any) { - this.errorService.handleError(e) - return false - } finally { - loader.unsubscribe() - } - } - // @TODO 041 figure out how to get types here - private async save({ provider, strategy, hostname }: any): Promise { - const loader = this.loader.open('Saving').subscribe() - const name = provider.selection - - try { - await this.api.addDomain({ - hostname, - networkStrategy: this.getNetworkStrategy(strategy), - provider: { - name, - username: name === 'start9' ? null : provider.value.username, - password: name === 'start9' ? null : provider.value.password, - }, + await this.api.initAcme({ + provider: new URL(providerUrl).href, + contact: contact.map(address => `mailto:${address}`), }) return true } catch (e: any) { @@ -194,4 +249,66 @@ export default class SystemDomainsComponent { loader.unsubscribe() } } + + private addAcmeSpec(providers: string[]) { + const availableAcme = knownACME.filter( + acme => !providers.includes(acme.url), + ) + + return ISB.InputSpec.of({ + provider: ISB.Value.union({ + name: 'Provider', + default: (availableAcme[0]?.url as any) || 'other', + variants: ISB.Variants.of({ + ...availableAcme.reduce( + (obj, curr) => ({ + ...obj, + [curr.url]: { + name: curr.name, + spec: ISB.InputSpec.of({}), + }, + }), + {}, + ), + other: { + name: 'Other', + spec: ISB.InputSpec.of({ + url: ISB.Value.text({ + name: 'URL', + default: null, + required: true, + inputmode: 'url', + patterns: [utils.Patterns.url], + }), + }), + }, + }), + }), + contact: this.emailListSpec(), + }) + } + + private editAcmeSpec() { + return ISB.InputSpec.of({ + contact: this.emailListSpec(), + }) + } + + private emailListSpec() { + return ISB.Value.list( + ISB.List.text( + { + name: this.i18n.transform('Contact Emails')!, + description: this.i18n.transform( + 'Needed to obtain a certificate from a Certificate Authority', + ), + minLength: 1, + }, + { + inputmode: 'email', + patterns: [utils.Patterns.email], + }, + ), + ) + } } diff --git a/web/projects/ui/src/app/routes/portal/routes/system/routes/domains/info.component.ts b/web/projects/ui/src/app/routes/portal/routes/system/routes/domains/info.component.ts deleted file mode 100644 index b21dd143b..000000000 --- a/web/projects/ui/src/app/routes/portal/routes/system/routes/domains/info.component.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { ChangeDetectionStrategy, Component } from '@angular/core' -import { TuiLink, TuiNotification } from '@taiga-ui/core' -import { DocsLinkDirective } from 'projects/shared/src/public-api' - -@Component({ - selector: 'domains-info', - template: ` - - Adding domains permits accessing your server and services over clearnet. - View instructions - - `, - changeDetection: ChangeDetectionStrategy.OnPush, - imports: [TuiNotification, TuiLink, DocsLinkDirective], -}) -export class DomainsInfoComponent {} 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 new file mode 100644 index 000000000..a00bddad6 --- /dev/null +++ b/web/projects/ui/src/app/routes/portal/routes/system/routes/domains/item.component.ts @@ -0,0 +1,94 @@ +import { + ChangeDetectionStrategy, + Component, + input, + output, +} from '@angular/core' +import { i18nPipe } from '@start9labs/shared' +import { + TuiButton, + TuiDataList, + TuiDropdown, + TuiOptGroup, +} from '@taiga-ui/core' + +@Component({ + selector: 'tr[domain]', + template: ` + + + + + + + + + + + + + + + + `, + styles: ` + td:last-child { + grid-area: 3 / span 4; + white-space: nowrap; + text-align: right; + flex-direction: row-reverse; + justify-content: flex-end; + gap: 0.5rem; + } + + :host-context(tui-root._mobile) { + display: grid; + grid-template-columns: repeat(3, min-content) 1fr; + align-items: center; + padding: 1rem 0.5rem; + gap: 0.5rem; + + td { + display: flex; + padding: 0; + } + } + `, + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [TuiButton, i18nPipe, TuiDropdown, TuiDataList, TuiOptGroup], +}) +export class DomainsItemComponent { + readonly domain = input.required() + + onGateway = output() + onAcme = output() + onRemove = output() + + 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 index e8f136e99..73fe7552e 100644 --- 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 @@ -1,134 +1,122 @@ import { ChangeDetectionStrategy, Component, - EventEmitter, inject, - Input, - Output, + input, } from '@angular/core' -import { TuiDialogService, TuiLink, TuiButton } from '@taiga-ui/core' -import { Domain } from 'src/app/services/patch-db/data-model' +import { + DialogService, + ErrorService, + i18nPipe, + LoadingService, +} from '@start9labs/shared' +import { ISB } from '@start9labs/start-sdk' +import { TuiSkeleton } from '@taiga-ui/kit' +import { filter } from 'rxjs' +import { FormComponent } from 'src/app/routes/portal/components/form.component' +import { ApiService } from 'src/app/services/api/embassy-api.service' +import { FormDialogService } from 'src/app/services/form-dialog.service' +import { configBuilderToSpec } from 'src/app/utils/configBuilderToSpec' +import { TableComponent } from 'src/app/routes/portal/components/table.component' +import { DomainsItemComponent } from './item.component' +import { PlaceholderComponent } from 'src/app/routes/portal/components/placeholder.component' @Component({ - selector: 'table[domains]', + selector: '[domains]', template: ` - - - Domain - DDNS Provider - Network Strategy - Used By - - - - - @for (domain of domains; track $index) { - - {{ domain.value }} - {{ domain.provider }} - {{ getStrategy(domain) }} - - @if (domain.usedBy.length; as qty) { - - } @else { - N/A - } - - - - - + + @for (domain of domains(); track $index) { + } @empty { - + @if (domains()) { + + {{ 'No domains' | i18n }} + + } @else { + + + + } } - +
No domains
+
{{ 'Loading' | i18n }}
+
`, styles: ` - :host-context(tui-root._mobile) { - tr { - grid-template-columns: 2fr 1fr; - } - - td:only-child { - grid-column: span 2; - } - - .title { - order: 1; - font-weight: bold; - } - - .actions { - order: 2; - padding: 0; - text-align: right; - } - - .strategy { - order: 3; - grid-column: span 2; - - &::before { - content: 'Strategy: '; - color: var(--tui-text-secondary); - } - } - - .provider { - order: 4; - - &::before { - content: 'DDNS: '; - color: var(--tui-text-secondary); - } - } - - .used { - order: 5; - text-align: right; - - &:not(:has(button)) { - display: none; - } - } + :host { + grid-column: span 6; } `, changeDetection: ChangeDetectionStrategy.OnPush, - imports: [TuiButton, TuiLink], + imports: [ + TuiSkeleton, + i18nPipe, + TableComponent, + DomainsItemComponent, + PlaceholderComponent, + ], }) -export class DomainsTableComponent { - private readonly dialogs = inject(TuiDialogService) +export class DomainsTableComponent { + readonly domains = input(null) - @Input() - domains: readonly Domain[] = [] + private readonly dialog = inject(DialogService) + private readonly loader = inject(LoadingService) + private readonly errorService = inject(ErrorService) + private readonly api = inject(ApiService) + private readonly formDialog = inject(FormDialogService) - @Output() - readonly delete = new EventEmitter() + remove(domain: any) { + this.dialog + .openConfirm({ label: 'Are you sure?', size: 's' }) + .pipe(filter(Boolean)) + .subscribe(async () => { + const loader = this.loader.open('Deleting').subscribe() - getStrategy(domain: any) { - return domain.networkStrategy.ipStrategy || domain.networkStrategy.proxy + try { + } catch (e: any) { + this.errorService.handleError(e) + } finally { + loader.unsubscribe() + } + }) } - onUsedBy({ value, usedBy }: Domain) { - const interfaces = usedBy.map(u => - u.interfaces.map(i => `
  • ${u.service.title} - ${i.title}
  • `), - ) + async changeGateway(domain: any) { + const renameSpec = ISB.InputSpec.of({}) - this.dialogs - .open(`${value} is currently being used by:
      ${interfaces}
    `, { - label: 'Used by', - size: 's', - }) - .subscribe() + this.formDialog.open(FormComponent, { + label: 'Change gateway', + data: { + spec: await configBuilderToSpec(renameSpec), + buttons: [ + { + text: 'Save', + handler: (value: typeof renameSpec._TYPE) => {}, + }, + ], + }, + }) + } + + async changeAcme(domain: any) { + const renameSpec = ISB.InputSpec.of({}) + + this.formDialog.open(FormComponent, { + label: 'Change default ACME', + data: { + spec: await configBuilderToSpec(renameSpec), + buttons: [ + { + text: 'Save', + handler: (value: typeof renameSpec._TYPE) => {}, + }, + ], + }, + }) } } diff --git a/web/projects/ui/src/app/routes/portal/routes/system/routes/email/email.component.ts b/web/projects/ui/src/app/routes/portal/routes/system/routes/email/email.component.ts index 0b1d67384..0471f136b 100644 --- a/web/projects/ui/src/app/routes/portal/routes/system/routes/email/email.component.ts +++ b/web/projects/ui/src/app/routes/portal/routes/system/routes/email/email.component.ts @@ -42,7 +42,7 @@ import { configBuilderToSpec } from 'src/app/utils/configBuilderToSpec' {{ 'Back' | i18n }} - {{ 'Inbound Proxies' | i18n }} + {{ 'Gateways' | i18n }}
    -

    {{ 'Inbound Proxies' | i18n }}

    +

    {{ 'Gateways' | i18n }}

    {{ - 'Inbound proxies provide remote access to your server and installed services.' + 'Gateways connect your server to the Internet. They process outbound traffic, and under certain conditions, they also permit inbound traffic.' | i18n }}

    @@ -51,19 +51,25 @@ import { WireguardIpInfo, WireguardProxy } from './item.component'
    - {{ 'Saved Proxies' | i18n }} -
    -
    +
    `, changeDetection: ChangeDetectionStrategy.OnPush, imports: [ CommonModule, TuiButton, - ProxiesTableComponent, + GatewaysTableComponent, TuiHeader, TitleDirective, i18nPipe, @@ -71,46 +77,37 @@ import { WireguardIpInfo, WireguardProxy } from './item.component' DocsLinkDirective, ], }) -export default class ProxiesComponent { +export default class GatewaysComponent { private readonly loader = inject(LoadingService) private readonly errorService = inject(ErrorService) private readonly api = inject(ApiService) private readonly formDialog = inject(FormDialogService) - readonly proxies$ = inject>(PatchDB) - .watch$('serverInfo', 'network') + readonly gateways$ = inject>(PatchDB) + .watch$('serverInfo', 'network', 'networkInterfaces') .pipe( - map(network => - Object.entries(network.networkInterfaces) - .filter( - ( - record, - ): record is [ - string, - T.NetworkInterfaceInfo & { ipInfo: WireguardIpInfo }, - ] => record[1].ipInfo?.deviceType === 'wireguard', - ) - .map( - ([id, val]) => - ({ - ...val, - id, - }) as WireguardProxy, - ), + map(gateways => + Object.entries(gateways).map( + ([id, val]) => + ({ + ...val, + id, + }) as GatewayWithID, + ), ), ) - readonly wireguardSpec = ISB.InputSpec.of({ - label: ISB.Value.text({ - name: 'Label', - description: 'To help identify this proxy', + readonly gatewaySpec = ISB.InputSpec.of({ + name: ISB.Value.text({ + name: 'Name', + description: 'A name to easily identify the gateway', required: true, default: null, }), type: ISB.Value.select({ name: 'Type', description: - '-**Private**: a private inbound proxy is used to access your server and installed services privately. Only clients configured and authorized to use the proxy will be granted access.\n-**Public**: a public inbound proxy is used to expose service interfaces on a case-by-case basis to the public Internet without exposing your home IP address. Only service interfaces explicitly marked "Public" will be accessible via the proxy.', + '-**Private**: select this option if the gateway is configured for private access to authorized clients only, which usually means ports are closed and traffic blocked otherwise. StartTunnel is a private gateway.\n-**Public**: select this option if the gateway is configured for unfettered public access, which usually means ports are open and traffic forwarded.', default: 'private', values: { private: 'Private', @@ -118,21 +115,11 @@ export default class ProxiesComponent { }, }), config: ISB.Value.union({ - name: 'Config', - default: 'upload', + name: 'Wireguard Config', + default: 'paste', variants: ISB.Variants.of({ - upload: { - name: 'File', - spec: ISB.InputSpec.of({ - file: ISB.Value.file({ - name: 'Wiregaurd Config', - required: true, - extensions: ['.conf'], - }), - }), - }, paste: { - name: 'Copy/Paste', + name: 'Paste File Contents', spec: ISB.InputSpec.of({ file: ISB.Value.textarea({ name: 'Paste File Contents', @@ -141,33 +128,42 @@ export default class ProxiesComponent { }), }), }, + upload: { + name: 'Upload File', + spec: ISB.InputSpec.of({ + file: ISB.Value.file({ + name: 'File', + required: true, + extensions: ['.conf'], + }), + }), + }, }), }), }) async add() { this.formDialog.open(FormComponent, { - label: 'Add Proxy', + label: 'Add Gateway', data: { - spec: await configBuilderToSpec(this.wireguardSpec), + spec: await configBuilderToSpec(this.gatewaySpec), buttons: [ { text: 'Save', - handler: (input: typeof this.wireguardSpec._TYPE) => - this.save(input), + handler: (input: typeof this.gatewaySpec._TYPE) => this.save(input), }, ], }, }) } - private async save(input: typeof this.wireguardSpec._TYPE): Promise { + private async save(input: typeof this.gatewaySpec._TYPE): Promise { const loader = this.loader.open('Saving').subscribe() try { await this.api.addTunnel({ - name: input.label, - config: input.config.value.file as string, // @TODO alex this is the file represented as a string + name: input.name, + config: '' as string, // @TODO alex/matt when types arrive public: input.type === 'public', }) return true diff --git a/web/projects/ui/src/app/routes/portal/routes/system/routes/proxies/item.component.ts b/web/projects/ui/src/app/routes/portal/routes/system/routes/gateways/item.component.ts similarity index 66% rename from web/projects/ui/src/app/routes/portal/routes/system/routes/proxies/item.component.ts rename to web/projects/ui/src/app/routes/portal/routes/system/routes/gateways/item.component.ts index 8e426d2f9..efa883d65 100644 --- a/web/projects/ui/src/app/routes/portal/routes/system/routes/proxies/item.component.ts +++ b/web/projects/ui/src/app/routes/portal/routes/system/routes/gateways/item.component.ts @@ -13,23 +13,23 @@ import { TuiOptGroup, } from '@taiga-ui/core' -export type WireguardProxy = T.NetworkInterfaceInfo & { +export type GatewayWithID = T.NetworkInterfaceInfo & { id: string - ipInfo: WireguardIpInfo -} - -export type WireguardIpInfo = T.IpInfo & { - deviceType: 'wireguard' + ipInfo: T.IpInfo } @Component({ selector: 'tr[proxy]', template: ` - {{ proxy().ipInfo.name }} - + {{ proxy().ipInfo.name }} + {{ proxy().ipInfo.deviceType || '-' }} + {{ proxy().public ? ('Public' | i18n) : ('Private' | i18n) }} - + + {{ proxy().ipInfo.subnets[0] }} + {{ proxy().ipInfo.wanIp }} + + > @@ -50,14 +48,16 @@ export type WireguardIpInfo = T.IpInfo & { > {{ 'Rename' | i18n }} - + @if (proxy().ipInfo.deviceType === 'wireguard') { + + } @@ -89,11 +89,11 @@ export type WireguardIpInfo = T.IpInfo & { changeDetection: ChangeDetectionStrategy.OnPush, imports: [TuiButton, i18nPipe, TuiDropdown, TuiDataList, TuiOptGroup], }) -export class ProxiesItemComponent { - readonly proxy = input.required() +export class GatewaysItemComponent { + readonly proxy = input.required() - onRename = output() - onRemove = output() + onRename = output() + onRemove = output() open = false } diff --git a/web/projects/ui/src/app/routes/portal/routes/system/routes/proxies/table.component.ts b/web/projects/ui/src/app/routes/portal/routes/system/routes/gateways/table.component.ts similarity index 73% rename from web/projects/ui/src/app/routes/portal/routes/system/routes/proxies/table.component.ts rename to web/projects/ui/src/app/routes/portal/routes/system/routes/gateways/table.component.ts index 75e874b29..c91062657 100644 --- a/web/projects/ui/src/app/routes/portal/routes/system/routes/proxies/table.component.ts +++ b/web/projects/ui/src/app/routes/portal/routes/system/routes/gateways/table.component.ts @@ -18,31 +18,34 @@ import { ApiService } from 'src/app/services/api/embassy-api.service' import { FormDialogService } from 'src/app/services/form-dialog.service' import { configBuilderToSpec } from 'src/app/utils/configBuilderToSpec' import { TableComponent } from 'src/app/routes/portal/components/table.component' -import { WireguardProxy } from './item.component' -import { ProxiesItemComponent } from './item.component' +import { GatewayWithID } from './item.component' +import { GatewaysItemComponent } from './item.component' @Component({ - selector: '[proxies]', + selector: '[gateways]', template: ` - - @for (proxy of proxies(); track $index) { +
    + @for (proxy of gateways(); track $index) { } @empty { - @if (proxies()) { - - - - } @else { - - - - } + + + }
    {{ 'No proxies' | i18n }}
    -
    {{ 'Loading' | i18n }}
    -
    +
    {{ 'Loading' | i18n }}
    +
    `, @@ -52,10 +55,10 @@ import { ProxiesItemComponent } from './item.component' } `, changeDetection: ChangeDetectionStrategy.OnPush, - imports: [TuiSkeleton, i18nPipe, TableComponent, ProxiesItemComponent], + imports: [TuiSkeleton, i18nPipe, TableComponent, GatewaysItemComponent], }) -export class ProxiesTableComponent { - readonly proxies = input(null) +export class GatewaysTableComponent { + readonly gateways = input(null) private readonly dialog = inject(DialogService) private readonly loader = inject(LoadingService) @@ -80,24 +83,24 @@ export class ProxiesTableComponent { }) } - async rename(proxy: WireguardProxy) { + async rename(gateway: GatewayWithID) { const renameSpec = ISB.InputSpec.of({ label: ISB.Value.text({ name: 'Label', required: true, - default: proxy.ipInfo?.name || null, + default: gateway.ipInfo?.name || null, }), }) this.formDialog.open(FormComponent, { - label: 'Update Label', + label: 'Rename', data: { spec: await configBuilderToSpec(renameSpec), buttons: [ { text: 'Save', handler: (value: typeof renameSpec._TYPE) => - this.update(proxy.id, value.label), + this.update(gateway.id, value.label), }, ], }, diff --git a/web/projects/ui/src/app/routes/portal/routes/system/routes/router/info.component.ts b/web/projects/ui/src/app/routes/portal/routes/system/routes/router/info.component.ts deleted file mode 100644 index 0055a5c34..000000000 --- a/web/projects/ui/src/app/routes/portal/routes/system/routes/router/info.component.ts +++ /dev/null @@ -1,43 +0,0 @@ -import { ChangeDetectionStrategy, Component, Input } from '@angular/core' -import { TuiLink, TuiNotification } from '@taiga-ui/core' -import { DocsLinkDirective } from 'projects/shared/src/public-api' - -@Component({ - selector: 'router-info', - template: ` - - @if (enabled) { - UPnP Enabled! -

    - The ports below have been - automatically - forwarded in your router. -

    - If you are running multiple servers, you may want to override specific - ports to suite your needs. - View instructions - } @else { - UPnP Disabled -

    - Below are a list of ports that must be - manually - forwarded in your router in order to enable clearnet access. -

    - Alternatively, you can enable UPnP in your router for automatic - configuration. - View instructions - } -
    - `, - styles: ` - strong { - font-size: 1rem; - } - `, - changeDetection: ChangeDetectionStrategy.OnPush, - imports: [TuiNotification, TuiLink, DocsLinkDirective], -}) -export class RouterInfoComponent { - @Input() - enabled = false -} diff --git a/web/projects/ui/src/app/routes/portal/routes/system/routes/router/primary-ip.pipe.ts b/web/projects/ui/src/app/routes/portal/routes/system/routes/router/primary-ip.pipe.ts deleted file mode 100644 index 0564743fd..000000000 --- a/web/projects/ui/src/app/routes/portal/routes/system/routes/router/primary-ip.pipe.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { Pipe, PipeTransform } from '@angular/core' -import { T } from '@start9labs/start-sdk' - -@Pipe({ - name: 'primaryIp', -}) -export class PrimaryIpPipe implements PipeTransform { - transform(hostnames: T.HostnameInfo[]): string { - return ( - hostnames.map( - h => h.kind === 'ip' && h.hostname.kind === 'ipv4' && h.hostname.value, - )[0] || '' - ) - } -} diff --git a/web/projects/ui/src/app/routes/portal/routes/system/routes/router/router.component.ts b/web/projects/ui/src/app/routes/portal/routes/system/routes/router/router.component.ts deleted file mode 100644 index fe98986a9..000000000 --- a/web/projects/ui/src/app/routes/portal/routes/system/routes/router/router.component.ts +++ /dev/null @@ -1,68 +0,0 @@ -import { TuiTextfieldControllerModule } from '@taiga-ui/legacy' -import { CommonModule } from '@angular/common' -import { ChangeDetectionStrategy, Component, inject } from '@angular/core' -import { PatchDB } from 'patch-db-client' -import { DataModel } from 'src/app/services/patch-db/data-model' -import { RouterInfoComponent } from './info.component' -import { PrimaryIpPipe } from './primary-ip.pipe' -import { RouterPortComponent } from './table.component' - -@Component({ - template: ` - @if (server$ | async; as server) { - - @if (server.host.hostnameInfo[80] | primaryIp; as ip) { - - - - - - - - - - - @for ( - portForward of server.network.wanConfig.forwards; - track portForward - ) { - - } - -
    -
    Port
    -
    -
    Target
    -
    - } - } - `, - changeDetection: ChangeDetectionStrategy.OnPush, - styles: ` - table { - width: 100%; - min-width: 30rem; - max-width: 40rem; - table-layout: fixed; - background: var(--tui-background-base-alt); - border-radius: 0.75rem; - font-size: 1rem; - margin: 2rem 0; - box-shadow: 0 1rem var(--tui-background-base-alt); - } - `, - imports: [ - CommonModule, - RouterInfoComponent, - RouterPortComponent, - TuiTextfieldControllerModule, - PrimaryIpPipe, - ], -}) -export default class SystemRouterComponent { - readonly server$ = inject>(PatchDB).watch$('serverInfo') -} diff --git a/web/projects/ui/src/app/routes/portal/routes/system/routes/router/table.component.ts b/web/projects/ui/src/app/routes/portal/routes/system/routes/router/table.component.ts deleted file mode 100644 index b57e54619..000000000 --- a/web/projects/ui/src/app/routes/portal/routes/system/routes/router/table.component.ts +++ /dev/null @@ -1,143 +0,0 @@ -import { - ChangeDetectionStrategy, - Component, - inject, - Input, - OnChanges, -} from '@angular/core' -import { FormsModule } from '@angular/forms' -import { CopyService, ErrorService, LoadingService } from '@start9labs/shared' -import { TuiButton, TuiIcon, TuiNumberFormat } from '@taiga-ui/core' -import { - TuiInputModule, - TuiInputNumberModule, - TuiTextfieldControllerModule, -} from '@taiga-ui/legacy' -import { ApiService } from 'src/app/services/api/embassy-api.service' -import { PortForward } from 'src/app/services/patch-db/data-model' - -@Component({ - selector: 'tr[portForward]', - template: ` - - @if (portForward.error) { - - } @else { - - } - - - - - - - @if (!editing) { - - } @else { - - - } - - - {{ ip }}:{{ portForward.target }} - - - - `, - styles: ` - button { - pointer-events: auto; - } - `, - changeDetection: ChangeDetectionStrategy.OnPush, - imports: [ - FormsModule, - TuiIcon, - TuiInputModule, - TuiButton, - TuiInputNumberModule, - TuiTextfieldControllerModule, - TuiNumberFormat, - ], -}) -export class RouterPortComponent implements OnChanges { - private readonly loader = inject(LoadingService) - private readonly errorService = inject(ErrorService) - private readonly api = inject(ApiService) - readonly copyService = inject(CopyService) - - @Input({ required: true }) - portForward!: PortForward - - @Input() - ip = '' - - value = NaN - editing = false - - ngOnChanges() { - this.value = this.portForward.override || this.portForward.assigned - } - - toggle(editing: boolean) { - this.editing = editing - this.value = this.portForward.override || this.portForward.assigned - } - - async save() { - const loader = this.loader.open('Saving').subscribe() - const { target } = this.portForward - - try { - await this.api.overridePortForward({ target, port: this.value }) - this.portForward.override = this.value - this.editing = false - } catch (e: any) { - this.errorService.handleError(e) - } finally { - loader.unsubscribe() - } - } -} diff --git a/web/projects/ui/src/app/routes/portal/routes/system/routes/ssh/ssh.component.ts b/web/projects/ui/src/app/routes/portal/routes/system/routes/ssh/ssh.component.ts index 6b07be383..ccc8d3aa0 100644 --- a/web/projects/ui/src/app/routes/portal/routes/system/routes/ssh/ssh.component.ts +++ b/web/projects/ui/src/app/routes/portal/routes/system/routes/ssh/ssh.component.ts @@ -44,7 +44,7 @@ import { SSHTableComponent } from './table.component' import('./routes/startos-ui/startos-ui.component'), }, - { - path: 'acme', - title: titleResolver, - loadComponent: () => import('./routes/acme/acme.component'), - }, { path: 'wifi', title: titleResolver, @@ -73,17 +68,14 @@ export default [ loadComponent: () => import('./routes/password/password.component'), }, { - path: 'proxies', - loadComponent: () => import('./routes/proxies/proxies.component'), + path: 'gateways', + loadComponent: () => import('./routes/gateways/gateways.component'), + }, + { + path: 'domains', + title: titleResolver, + loadComponent: () => import('./routes/domains/domains.component'), }, - // { - // path: 'domains', - // loadComponent: () => import('./routes/domains/domains.component') - // }, - // { - // path: 'router', - // loadComponent: () => import('./routes/router/router.component') - // }, ], }, ] satisfies Routes diff --git a/web/projects/ui/src/app/services/api/mock-patch.ts b/web/projects/ui/src/app/services/api/mock-patch.ts index 012a2d301..40937ad49 100644 --- a/web/projects/ui/src/app/services/api/mock-patch.ts +++ b/web/projects/ui/src/app/services/api/mock-patch.ts @@ -142,7 +142,7 @@ export const mockPatchData: DataModel = { scopeId: 1, deviceType: 'ethernet', subnets: ['10.0.0.2/24'], - wanIp: null, + wanIp: '203.0.113.45', ntpServers: [], }, }, @@ -156,7 +156,7 @@ export const mockPatchData: DataModel = { '10.0.90.12/24', 'fe80::cd00:0000:0cde:1257:0000:211e:72cd/64', ], - wanIp: null, + wanIp: '203.0.113.45', ntpServers: [], }, }, From f23659f4ea128dad7d885adf8a5574de2037888c Mon Sep 17 00:00:00 2001 From: Matt Hill Date: Thu, 31 Jul 2025 13:42:43 -0600 Subject: [PATCH 2/8] dont show hidden actions --- .../routes/portal/routes/services/routes/actions.component.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/web/projects/ui/src/app/routes/portal/routes/services/routes/actions.component.ts b/web/projects/ui/src/app/routes/portal/routes/services/routes/actions.component.ts index 5da83eed7..33a332a16 100644 --- a/web/projects/ui/src/app/routes/portal/routes/services/routes/actions.component.ts +++ b/web/projects/ui/src/app/routes/portal/routes/services/routes/actions.component.ts @@ -83,6 +83,7 @@ export default class ServiceActionsRoute { icon: pkg.icon, manifest: getManifest(pkg), actions: Object.entries(pkg.actions) + .filter(([_, action]) => action.visibility !== 'hidden') .map(([id, action]) => ({ ...action, id, From 716ed64aa8fb61d75f7e03d16bb7a53a0cb36ea5 Mon Sep 17 00:00:00 2001 From: Matt Hill Date: Thu, 31 Jul 2025 19:57:04 -0600 Subject: [PATCH 3/8] show and test dns --- .../system/routes/domains/item.component.ts | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) 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 index a00bddad6..2bdc8a59d 100644 --- 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 @@ -44,6 +44,20 @@ import { > Change default ACME + + - + + + + + + + + `, + styles: ` + td:last-child { + grid-area: 1 / 2 / 3; + align-self: center; + text-align: right; + } + + :host-context(tui-root._mobile) { + grid-template-columns: 1fr min-content; + + td:first-child { + font: var(--tui-font-text-m); + font-weight: bold; + } + } + `, + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [TuiButton, i18nPipe, TuiDropdown, TuiDataList, TuiTextfield], +}) +export class DomainsAcmeComponent { + protected readonly service = inject(AcmeService) + + readonly acme = input.required<{ url: string; contact: readonly string[] }>() + + open = false + + toAcmeName = toAcmeName +} diff --git a/web/projects/ui/src/app/routes/portal/routes/system/routes/domains/acme.service.ts b/web/projects/ui/src/app/routes/portal/routes/system/routes/domains/acme.service.ts new file mode 100644 index 000000000..cd2158cb3 --- /dev/null +++ b/web/projects/ui/src/app/routes/portal/routes/system/routes/domains/acme.service.ts @@ -0,0 +1,163 @@ +import { inject, Injectable } from '@angular/core' +import { + DialogService, + ErrorService, + i18nPipe, + LoadingService, +} from '@start9labs/shared' +import { ISB, utils } from '@start9labs/start-sdk' +import { filter } from 'rxjs' +import { FormComponent } from 'src/app/routes/portal/components/form.component' +import { ApiService } from 'src/app/services/api/embassy-api.service' +import { FormDialogService } from 'src/app/services/form-dialog.service' +import { knownACME } from 'src/app/utils/acme' +import { configBuilderToSpec } from 'src/app/utils/configBuilderToSpec' + +@Injectable({ + providedIn: 'root', +}) +export class AcmeService { + 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) + + async add(providers: { url: string; contact: string[] }[]) { + this.formDialog.open(FormComponent, { + label: 'Add ACME Provider', + data: { + spec: await configBuilderToSpec( + this.addSpec(providers.map(p => p.url)), + ), + buttons: [ + { + text: this.i18n.transform('Save'), + handler: async (val: ReturnType['_TYPE']) => { + const providerUrl = + val.provider.selection === 'other' + ? val.provider.value.url + : val.provider.selection + + return this.save(providerUrl, val.contact) + }, + }, + ], + }, + }) + } + + async edit({ url, contact }: { url: string; contact: readonly string[] }) { + this.formDialog.open(FormComponent, { + label: 'Edit ACME Provider', + data: { + spec: await configBuilderToSpec(this.editSpec()), + buttons: [ + { + text: this.i18n.transform('Save'), + handler: async (val: ReturnType['_TYPE']) => + this.save(url, val.contact), + }, + ], + value: { contact }, + }, + }) + } + + remove({ url }: { url: string }) { + this.dialog + .openConfirm({ label: 'Are you sure?', size: 's' }) + .pipe(filter(Boolean)) + .subscribe(async () => { + const loader = this.loader.open('Removing').subscribe() + + try { + await this.api.removeAcme({ provider: url }) + } catch (e: any) { + this.errorService.handleError(e) + } finally { + loader.unsubscribe() + } + }) + } + + private async save(providerUrl: string, contact: readonly string[]) { + const loader = this.loader.open('Saving').subscribe() + + try { + await this.api.initAcme({ + provider: new URL(providerUrl).href, + contact: contact.map(address => `mailto:${address}`), + }) + return true + } catch (e: any) { + this.errorService.handleError(e) + return false + } finally { + loader.unsubscribe() + } + } + + private addSpec(providers: string[]) { + const availableAcme = knownACME.filter( + acme => !providers.includes(acme.url), + ) + + return ISB.InputSpec.of({ + provider: ISB.Value.union({ + name: 'Provider', + default: (availableAcme[0]?.url as any) || 'other', + variants: ISB.Variants.of({ + ...availableAcme.reduce( + (obj, curr) => ({ + ...obj, + [curr.url]: { + name: curr.name, + spec: ISB.InputSpec.of({}), + }, + }), + {}, + ), + other: { + name: 'Other', + spec: ISB.InputSpec.of({ + url: ISB.Value.text({ + name: 'URL', + default: null, + required: true, + inputmode: 'url', + patterns: [utils.Patterns.url], + }), + }), + }, + }), + }), + contact: this.emailListSpec(), + }) + } + + private editSpec() { + return ISB.InputSpec.of({ + contact: this.emailListSpec(), + }) + } + + private emailListSpec() { + return ISB.Value.list( + ISB.List.text( + { + name: this.i18n.transform('Contact Emails')!, + description: this.i18n.transform( + 'Needed to obtain a certificate from a Certificate Authority', + ), + minLength: 1, + }, + { + inputmode: 'email', + patterns: [utils.Patterns.email], + }, + ), + ) + } +} diff --git a/web/projects/ui/src/app/routes/portal/routes/system/routes/domains/domain.component.ts b/web/projects/ui/src/app/routes/portal/routes/system/routes/domains/domain.component.ts new file mode 100644 index 000000000..11251bc3e --- /dev/null +++ b/web/projects/ui/src/app/routes/portal/routes/system/routes/domains/domain.component.ts @@ -0,0 +1,134 @@ +import { + ChangeDetectionStrategy, + Component, + inject, + input, +} from '@angular/core' +import { + DialogService, + ErrorService, + i18nPipe, + LoadingService, +} from '@start9labs/shared' +import { ISB } from '@start9labs/start-sdk' +import { + TuiButton, + TuiDataList, + TuiDropdown, + TuiTextfield, +} from '@taiga-ui/core' +import { filter } from 'rxjs' +import { FormComponent } from 'src/app/routes/portal/components/form.component' +import { FormDialogService } from 'src/app/services/form-dialog.service' +import { configBuilderToSpec } from 'src/app/utils/configBuilderToSpec' + +@Component({ + selector: 'tr[domain]', + template: ` + {{ domain().domain }} + {{ domain().gateway }} + {{ domain().acme }} + + + + + + + + + + + + `, + styles: ` + td:last-child { + grid-area: 1 / 2 / 4; + align-self: center; + text-align: right; + } + + :host-context(tui-root._mobile) { + grid-template-columns: 1fr min-content; + + td:first-child { + font: var(--tui-font-text-m); + font-weight: bold; + } + } + `, + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [TuiButton, i18nPipe, TuiDropdown, TuiDataList, TuiTextfield], +}) +export class DomainsDomainComponent { + private readonly dialog = inject(DialogService) + private readonly loader = inject(LoadingService) + private readonly errorService = inject(ErrorService) + private readonly formDialog = inject(FormDialogService) + + readonly domain = input.required() + + open = false + + remove() { + this.dialog + .openConfirm({ label: 'Are you sure?', size: 's' }) + .pipe(filter(Boolean)) + .subscribe(async () => { + const loader = this.loader.open('Deleting').subscribe() + + try { + } catch (e: any) { + this.errorService.handleError(e) + } finally { + loader.unsubscribe() + } + }) + } + + async edit() { + const renameSpec = ISB.InputSpec.of({}) + + this.formDialog.open(FormComponent, { + label: 'Edit', + data: { + spec: await configBuilderToSpec(renameSpec), + buttons: [ + { + text: 'Save', + handler: (value: typeof renameSpec._TYPE) => {}, + }, + ], + }, + }) + } + + async showDns() {} + + async testDns() {} +} 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 index e2fc0ad6d..475769cb2 100644 --- 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 @@ -6,25 +6,15 @@ import { } from '@angular/core' import { toSignal } from '@angular/core/rxjs-interop' import { RouterLink } from '@angular/router' -import { - DocsLinkDirective, - ErrorService, - i18nPipe, - LoadingService, -} from '@start9labs/shared' -import { ISB, utils } from '@start9labs/start-sdk' -import { TuiButton, TuiLink, TuiLoader, TuiTitle } from '@taiga-ui/core' -import { TuiCell, TuiHeader } from '@taiga-ui/layout' +import { DocsLinkDirective, i18nPipe } from '@start9labs/shared' +import { TuiButton, TuiLink, TuiTitle } from '@taiga-ui/core' +import { TuiHeader } from '@taiga-ui/layout' import { PatchDB } from 'patch-db-client' import { map } from 'rxjs' -import { FormComponent } from 'src/app/routes/portal/components/form.component' -import { PlaceholderComponent } from 'src/app/routes/portal/components/placeholder.component' -import { ApiService } from 'src/app/services/api/embassy-api.service' -import { FormDialogService } from 'src/app/services/form-dialog.service' import { DataModel } from 'src/app/services/patch-db/data-model' import { TitleDirective } from 'src/app/services/title.service' -import { knownACME, toAcmeName } from 'src/app/utils/acme' -import { configBuilderToSpec } from 'src/app/utils/configBuilderToSpec' + +import { AcmeService } from './acme.service' import { DomainsTableComponent } from './table.component' @Component({ @@ -65,46 +55,13 @@ import { DomainsTableComponent } from './table.component' size="xs" iconStart="@tui.plus" [style.margin-inline-start]="'auto'" - (click)="addAcme(value)" + (click)="service.add(value)" > {{ 'Add' | i18n }} }
    - @if (acme(); as value) { - @for (provider of value; track $index) { -
    - - {{ toAcmeName(provider.url) }} - - {{ 'Contact' | i18n }}: {{ provider.contactString }} - - - - -
    - } @empty { - - {{ 'No saved providers' | i18n }} - - } - } @else { - - } +
    @@ -113,22 +70,20 @@ import { DomainsTableComponent } from './table.component' -
    +
    `, styles: ``, changeDetection: ChangeDetectionStrategy.OnPush, imports: [ TuiButton, - TuiLoader, - TuiCell, TuiTitle, TuiHeader, TuiLink, @@ -136,179 +91,38 @@ import { DomainsTableComponent } from './table.component' TitleDirective, i18nPipe, DocsLinkDirective, - PlaceholderComponent, DomainsTableComponent, ], }) export default class SystemDomainsComponent { - private readonly formDialog = inject(FormDialogService) - private readonly loader = inject(LoadingService) - private readonly errorService = inject(ErrorService) - private readonly patch = inject>(PatchDB) - private readonly api = inject(ApiService) - private readonly i18n = inject(i18nPipe) + protected readonly patch = inject>(PatchDB) + protected readonly service = inject(AcmeService) - acme = toSignal( + readonly acme = toSignal( this.patch.watch$('serverInfo', 'network', 'acme').pipe( map(acme => - Object.keys(acme).map(url => { - const contact = + Object.keys(acme).map(url => ({ + url, + contact: acme[url]?.contact.map(mailto => mailto.replace('mailto:', '')) || - [] - return { - url, - contact, - contactString: contact.join(', '), - } - }), + [], + })), ), ), ) - domains = signal([]) - - toAcmeName = toAcmeName - - async addAcme( - providers: { - url: string - contact: string[] - contactString: string - }[], - ) { - this.formDialog.open(FormComponent, { - label: 'Add ACME Provider', - data: { - spec: await configBuilderToSpec( - this.addAcmeSpec(providers.map(p => p.url)), - ), - buttons: [ - { - text: this.i18n.transform('Save'), - handler: async ( - val: ReturnType['_TYPE'], - ) => { - const providerUrl = - val.provider.selection === 'other' - ? val.provider.value.url - : val.provider.selection - - return this.saveAcme(providerUrl, val.contact) - }, - }, - ], - }, - }) - } + readonly domains = signal([ + { + domain: 'blog.mydomain.com', + gateway: 'StartTunnel', + acme: 'System', + }, + { + domain: 'blog. mydomain.com', + gateway: 'StartTunnel', + acme: 'System', + }, + ]) async addDomain() {} - - async editAcme(provider: string, contact: string[]) { - this.formDialog.open(FormComponent, { - label: 'Edit ACME Provider', - data: { - spec: await configBuilderToSpec(this.editAcmeSpec()), - buttons: [ - { - text: this.i18n.transform('Save'), - handler: async ( - val: ReturnType['_TYPE'], - ) => this.saveAcme(provider, val.contact), - }, - ], - value: { contact }, - }, - }) - } - - async removeAcme(provider: string) { - const loader = this.loader.open('Removing').subscribe() - - try { - await this.api.removeAcme({ provider }) - } catch (e: any) { - this.errorService.handleError(e) - } finally { - loader.unsubscribe() - } - } - - private async saveAcme(providerUrl: string, contact: string[]) { - const loader = this.loader.open('Saving').subscribe() - - try { - await this.api.initAcme({ - provider: new URL(providerUrl).href, - contact: contact.map(address => `mailto:${address}`), - }) - return true - } catch (e: any) { - this.errorService.handleError(e) - return false - } finally { - loader.unsubscribe() - } - } - - private addAcmeSpec(providers: string[]) { - const availableAcme = knownACME.filter( - acme => !providers.includes(acme.url), - ) - - return ISB.InputSpec.of({ - provider: ISB.Value.union({ - name: 'Provider', - default: (availableAcme[0]?.url as any) || 'other', - variants: ISB.Variants.of({ - ...availableAcme.reduce( - (obj, curr) => ({ - ...obj, - [curr.url]: { - name: curr.name, - spec: ISB.InputSpec.of({}), - }, - }), - {}, - ), - other: { - name: 'Other', - spec: ISB.InputSpec.of({ - url: ISB.Value.text({ - name: 'URL', - default: null, - required: true, - inputmode: 'url', - patterns: [utils.Patterns.url], - }), - }), - }, - }), - }), - contact: this.emailListSpec(), - }) - } - - private editAcmeSpec() { - return ISB.InputSpec.of({ - contact: this.emailListSpec(), - }) - } - - private emailListSpec() { - return ISB.Value.list( - ISB.List.text( - { - name: this.i18n.transform('Contact Emails')!, - description: this.i18n.transform( - 'Needed to obtain a certificate from a Certificate Authority', - ), - minLength: 1, - }, - { - inputmode: 'email', - patterns: [utils.Patterns.email], - }, - ), - ) - } } 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 f9e6d9ac0..000000000 --- a/web/projects/ui/src/app/routes/portal/routes/system/routes/domains/item.component.ts +++ /dev/null @@ -1,102 +0,0 @@ -import { - ChangeDetectionStrategy, - Component, - input, - output, -} from '@angular/core' -import { i18nPipe } from '@start9labs/shared' -import { - TuiButton, - TuiDataList, - TuiDropdown, - TuiOptGroup, -} from '@taiga-ui/core' - -@Component({ - selector: 'tr[domain]', - template: ` - - - - - - - - - - - - - - - - - `, - styles: ` - td:last-child { - grid-area: 3 / span 4; - white-space: nowrap; - text-align: right; - flex-direction: row-reverse; - justify-content: flex-end; - gap: 0.5rem; - } - - :host-context(tui-root._mobile) { - display: grid; - grid-template-columns: repeat(3, min-content) 1fr; - align-items: center; - padding: 1rem 0.5rem; - gap: 0.5rem; - - td { - display: flex; - padding: 0; - } - } - `, - changeDetection: ChangeDetectionStrategy.OnPush, - imports: [TuiButton, i18nPipe, TuiDropdown, TuiDataList, TuiOptGroup], -}) -export class DomainsItemComponent { - readonly domain = input.required() - - onEdit = output() - onShowDns = output() - onTestDns = output() - onRemove = output() - - 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 index 6a0de08db..4c0dcf5e3 100644 --- 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 @@ -1,110 +1,68 @@ import { ChangeDetectionStrategy, Component, - inject, + computed, input, } from '@angular/core' -import { - DialogService, - ErrorService, - i18nPipe, - LoadingService, -} from '@start9labs/shared' -import { ISB } from '@start9labs/start-sdk' +import { i18nPipe } from '@start9labs/shared' import { TuiSkeleton } from '@taiga-ui/kit' -import { filter } from 'rxjs' -import { FormComponent } from 'src/app/routes/portal/components/form.component' -import { ApiService } from 'src/app/services/api/embassy-api.service' -import { FormDialogService } from 'src/app/services/form-dialog.service' -import { configBuilderToSpec } from 'src/app/utils/configBuilderToSpec' -import { TableComponent } from 'src/app/routes/portal/components/table.component' -import { DomainsItemComponent } from './item.component' import { PlaceholderComponent } from 'src/app/routes/portal/components/placeholder.component' +import { TableComponent } from 'src/app/routes/portal/components/table.component' + +import { DomainsAcmeComponent } from './acme.component' +import { DomainsDomainComponent } from './domain.component' @Component({ - selector: '[domains]', + selector: 'domains-table', template: ` - - @for (domain of domains(); track $index) { - - } @empty { - @if (domains()) { - - {{ 'No domains' | i18n }} - - } @else { - - - +
    -
    {{ 'Loading' | i18n }}
    -
    + @for (item of items(); track $index) { + @if (mode() === 'domains') { + + } @else if (mode() === 'acme') { + } + } @empty { + + + }
    + @if (items()) { + + @if (mode() === 'domains') { + {{ 'No domains' | i18n }} + } @else { + {{ 'No saved providers' | i18n }} + } + + } @else { +
    {{ 'Loading' | i18n }}
    + } +
    `, - styles: ` - :host { - grid-column: span 6; - } - `, changeDetection: ChangeDetectionStrategy.OnPush, imports: [ TuiSkeleton, i18nPipe, TableComponent, - DomainsItemComponent, PlaceholderComponent, + DomainsDomainComponent, + DomainsAcmeComponent, ], }) -export class DomainsTableComponent { - readonly domains = input(null) +export class DomainsTableComponent { + // @TODO Alex proper types + readonly items = input() + readonly mode = input<'domains' | 'acme'>('domains') - private readonly dialog = inject(DialogService) - private readonly loader = inject(LoadingService) - private readonly errorService = inject(ErrorService) - private readonly api = inject(ApiService) - private readonly formDialog = inject(FormDialogService) + readonly titles = computed(() => + this.mode() === 'domains' + ? (['Domain', 'Gateway', 'Default ACME', null] as const) + : (['Provider', 'Contact', null] as const), + ) - remove(domain: any) { - this.dialog - .openConfirm({ label: 'Are you sure?', size: 's' }) - .pipe(filter(Boolean)) - .subscribe(async () => { - const loader = this.loader.open('Deleting').subscribe() - - try { - } catch (e: any) { - this.errorService.handleError(e) - } finally { - loader.unsubscribe() - } - }) - } - - async edit(domain: any) { - const renameSpec = ISB.InputSpec.of({}) - - this.formDialog.open(FormComponent, { - label: 'Edit', - data: { - spec: await configBuilderToSpec(renameSpec), - buttons: [ - { - text: 'Save', - handler: (value: typeof renameSpec._TYPE) => {}, - }, - ], - }, - }) - } - - async showDns(domain: any) {} - - async testDns(domain: any) {} + readonly icon = computed(() => + this.mode() === 'domains' ? '@tui.globe' : '@tui.shield-question', + ) } diff --git a/web/projects/ui/src/app/routes/portal/routes/system/system.const.ts b/web/projects/ui/src/app/routes/portal/routes/system/system.const.ts index a1f527bcd..07a3485b2 100644 --- a/web/projects/ui/src/app/routes/portal/routes/system/system.const.ts +++ b/web/projects/ui/src/app/routes/portal/routes/system/system.const.ts @@ -40,14 +40,14 @@ export const SYSTEM_MENU = [ [ { icon: '@tui.globe', - item: 'Gateways', - link: 'gateways', - }, - { - icon: '@tui.award', item: 'Domains', link: 'domains', }, + { + icon: '@tui.door-open', + item: 'Gateways', + link: 'gateways', + }, ], [ { From 86dbf26253c8b1be542c9ba196f8632ada84577e Mon Sep 17 00:00:00 2001 From: waterplea Date: Tue, 5 Aug 2025 17:39:48 +0700 Subject: [PATCH 6/8] refactor: gateways page --- .../form/form-file/form-file.component.html | 27 ++- .../form/form-file/form-file.component.scss | 3 +- .../portal/components/form/form.module.ts | 8 +- .../routes/gateways/gateways.component.ts | 4 +- .../system/routes/gateways/item.component.ts | 184 +++++++++++++----- .../system/routes/gateways/table.component.ts | 113 ++--------- 6 files changed, 186 insertions(+), 153 deletions(-) diff --git a/web/projects/ui/src/app/routes/portal/components/form/form-file/form-file.component.html b/web/projects/ui/src/app/routes/portal/components/form/form-file/form-file.component.html index 9ea93323f..e8b3cd99b 100644 --- a/web/projects/ui/src/app/routes/portal/components/form/form-file/form-file.component.html +++ b/web/projects/ui/src/app/routes/portal/components/form/form-file/form-file.component.html @@ -1,8 +1,9 @@ -

    @@ -62,7 +62,7 @@ import { GatewayWithID } from './item.component' Add -
    +
    `, changeDetection: ChangeDetectionStrategy.OnPush, 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 efa883d65..ab07129d3 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 @@ -1,17 +1,28 @@ import { ChangeDetectionStrategy, Component, + inject, input, - output, } from '@angular/core' -import { i18nPipe } from '@start9labs/shared' -import { T } from '@start9labs/start-sdk' +import { + DialogService, + ErrorService, + i18nPipe, + LoadingService, +} from '@start9labs/shared' +import { ISB, T } from '@start9labs/start-sdk' import { TuiButton, TuiDataList, TuiDropdown, TuiOptGroup, + TuiTextfield, } from '@taiga-ui/core' +import { filter } from 'rxjs' +import { FormComponent } from 'src/app/routes/portal/components/form.component' +import { ApiService } from 'src/app/services/api/embassy-api.service' +import { FormDialogService } from 'src/app/services/form-dialog.service' +import { configBuilderToSpec } from 'src/app/utils/configBuilderToSpec' export type GatewayWithID = T.NetworkInterfaceInfo & { id: string @@ -21,79 +32,164 @@ export type GatewayWithID = T.NetworkInterfaceInfo & { @Component({ selector: 'tr[proxy]', template: ` - {{ proxy().ipInfo.name }} - {{ proxy().ipInfo.deviceType || '-' }} - + {{ proxy().ipInfo.name }} + {{ proxy().ipInfo.deviceType || '-' }} + {{ proxy().public ? ('Public' | i18n) : ('Private' | i18n) }} - {{ proxy().ipInfo.subnets[0] }} - {{ proxy().ipInfo.wanIp }} + {{ proxy().ipInfo.subnets[0] }} + {{ proxy().ipInfo.wanIp }} - - + > + {{ 'More' | i18n }} + - - @if (proxy().ipInfo.deviceType === 'wireguard') { + + @if (proxy().ipInfo.deviceType === 'wireguard') { + - } - + + } - + `, styles: ` td:last-child { - grid-area: 3 / span 4; - white-space: nowrap; + grid-area: 1 / 3 / 5; + align-self: center; text-align: right; - flex-direction: row-reverse; - justify-content: flex-end; - gap: 0.5rem; } :host-context(tui-root._mobile) { - display: grid; - grid-template-columns: repeat(3, min-content) 1fr; - align-items: center; - padding: 1rem 0.5rem; - gap: 0.5rem; + grid-template-columns: min-content 1fr min-content; - td { - display: flex; - padding: 0; + td:first-child { + font: var(--tui-font-text-m); + font-weight: bold; + } + + .type { + order: -1; + + &::before { + content: '\\00A0('; + } + + &::after { + content: ')'; + } + } + + .lan, + .wan { + grid-column: span 2; + + &::before { + content: 'LAN IPs: '; + color: var(--tui-text-primary); + } + } + + .wan::before { + content: 'WAN IP: '; } } `, changeDetection: ChangeDetectionStrategy.OnPush, - imports: [TuiButton, i18nPipe, TuiDropdown, TuiDataList, TuiOptGroup], + imports: [ + TuiButton, + TuiDropdown, + TuiDataList, + TuiOptGroup, + TuiTextfield, + i18nPipe, + ], }) export class GatewaysItemComponent { + private readonly dialog = inject(DialogService) + private readonly loader = inject(LoadingService) + private readonly errorService = inject(ErrorService) + private readonly api = inject(ApiService) + private readonly formDialog = inject(FormDialogService) + readonly proxy = input.required() - onRename = output() - onRemove = output() - open = false + + remove() { + this.dialog + .openConfirm({ label: 'Are you sure?', size: 's' }) + .pipe(filter(Boolean)) + .subscribe(async () => { + const loader = this.loader.open('Deleting').subscribe() + + try { + await this.api.removeTunnel({ id: this.proxy().id }) + } catch (e: any) { + this.errorService.handleError(e) + } finally { + loader.unsubscribe() + } + }) + } + + async rename() { + const { ipInfo, id } = this.proxy() + const renameSpec = ISB.InputSpec.of({ + label: ISB.Value.text({ + name: 'Label', + required: true, + default: ipInfo?.name || null, + }), + }) + + this.formDialog.open(FormComponent, { + label: 'Rename', + data: { + spec: await configBuilderToSpec(renameSpec), + buttons: [ + { + text: 'Save', + handler: (value: typeof renameSpec._TYPE) => + this.update(id, value.label), + }, + ], + }, + }) + } + + private async update(id: string, name: string): Promise { + const loader = this.loader.open('Saving').subscribe() + + try { + await this.api.updateTunnel({ id, name }) + return true + } catch (e: any) { + this.errorService.handleError(e) + return false + } finally { + loader.unsubscribe() + } + } } diff --git a/web/projects/ui/src/app/routes/portal/routes/system/routes/gateways/table.component.ts b/web/projects/ui/src/app/routes/portal/routes/system/routes/gateways/table.component.ts index c91062657..87a0c19ec 100644 --- a/web/projects/ui/src/app/routes/portal/routes/system/routes/gateways/table.component.ts +++ b/web/projects/ui/src/app/routes/portal/routes/system/routes/gateways/table.component.ts @@ -1,25 +1,9 @@ -import { - ChangeDetectionStrategy, - Component, - inject, - input, -} from '@angular/core' -import { - DialogService, - ErrorService, - i18nPipe, - LoadingService, -} from '@start9labs/shared' -import { ISB } from '@start9labs/start-sdk' +import { ChangeDetectionStrategy, Component, input } from '@angular/core' +import { i18nPipe } from '@start9labs/shared' import { TuiSkeleton } from '@taiga-ui/kit' -import { filter } from 'rxjs' -import { FormComponent } from 'src/app/routes/portal/components/form.component' -import { ApiService } from 'src/app/services/api/embassy-api.service' -import { FormDialogService } from 'src/app/services/form-dialog.service' -import { configBuilderToSpec } from 'src/app/utils/configBuilderToSpec' +import { PlaceholderComponent } from 'src/app/routes/portal/components/placeholder.component' import { TableComponent } from 'src/app/routes/portal/components/table.component' -import { GatewayWithID } from './item.component' -import { GatewaysItemComponent } from './item.component' +import { GatewaysItemComponent, GatewayWithID } from './item.component' @Component({ selector: '[gateways]', @@ -35,89 +19,32 @@ import { GatewaysItemComponent } from './item.component' ]" > @for (proxy of gateways(); track $index) { - + } @empty { -
    {{ 'Loading' | i18n }}
    + @if (gateways()) { + + + No gateways + + } @else { +
    {{ 'Loading' | i18n }}
    + } } `, - styles: ` - :host { - grid-column: span 6; - } - `, changeDetection: ChangeDetectionStrategy.OnPush, - imports: [TuiSkeleton, i18nPipe, TableComponent, GatewaysItemComponent], + imports: [ + TuiSkeleton, + i18nPipe, + TableComponent, + GatewaysItemComponent, + PlaceholderComponent, + ], }) export class GatewaysTableComponent { readonly gateways = input(null) - - private readonly dialog = inject(DialogService) - private readonly loader = inject(LoadingService) - private readonly errorService = inject(ErrorService) - private readonly api = inject(ApiService) - private readonly formDialog = inject(FormDialogService) - - remove(id: string) { - this.dialog - .openConfirm({ label: 'Are you sure?', size: 's' }) - .pipe(filter(Boolean)) - .subscribe(async () => { - const loader = this.loader.open('Deleting').subscribe() - - try { - await this.api.removeTunnel({ id }) - } catch (e: any) { - this.errorService.handleError(e) - } finally { - loader.unsubscribe() - } - }) - } - - async rename(gateway: GatewayWithID) { - const renameSpec = ISB.InputSpec.of({ - label: ISB.Value.text({ - name: 'Label', - required: true, - default: gateway.ipInfo?.name || null, - }), - }) - - this.formDialog.open(FormComponent, { - label: 'Rename', - data: { - spec: await configBuilderToSpec(renameSpec), - buttons: [ - { - text: 'Save', - handler: (value: typeof renameSpec._TYPE) => - this.update(gateway.id, value.label), - }, - ], - }, - }) - } - - private async update(id: string, label: string): Promise { - const loader = this.loader.open('Saving').subscribe() - - try { - await this.api.updateTunnel({ id, name: label }) - return true - } catch (e: any) { - this.errorService.handleError(e) - return false - } finally { - loader.unsubscribe() - } - } } From 4a2777c52f1c5af41b45d2c2218b65e2e9249e85 Mon Sep 17 00:00:00 2001 From: Matt Hill Date: Tue, 5 Aug 2025 09:29:04 -0600 Subject: [PATCH 7/8] domains and acme refactor --- .../interfaces/clearnet.component.ts | 4 +- .../routes/domains/{ => acme}/acme.service.ts | 183 +++++++++-------- .../item.component.ts} | 12 +- .../routes/domains/acme/table.component.ts | 41 ++++ .../system/routes/domains/domain.component.ts | 134 ------------ .../routes/domains/domains.component.ts | 89 +++----- .../routes/domains/domains/domains.service.ts | 193 ++++++++++++++++++ .../routes/domains/domains/item.component.ts | 100 +++++++++ .../routes/domains/domains/table.component.ts | 41 ++++ .../system/routes/domains/table.component.ts | 68 ------ .../routes/gateways/gateways.component.ts | 96 ++++----- 11 files changed, 557 insertions(+), 404 deletions(-) rename web/projects/ui/src/app/routes/portal/routes/system/routes/domains/{ => acme}/acme.service.ts (73%) rename web/projects/ui/src/app/routes/portal/routes/system/routes/domains/{acme.component.ts => acme/item.component.ts} (86%) create mode 100644 web/projects/ui/src/app/routes/portal/routes/system/routes/domains/acme/table.component.ts delete mode 100644 web/projects/ui/src/app/routes/portal/routes/system/routes/domains/domain.component.ts create mode 100644 web/projects/ui/src/app/routes/portal/routes/system/routes/domains/domains/domains.service.ts create mode 100644 web/projects/ui/src/app/routes/portal/routes/system/routes/domains/domains/item.component.ts create mode 100644 web/projects/ui/src/app/routes/portal/routes/system/routes/domains/domains/table.component.ts delete mode 100644 web/projects/ui/src/app/routes/portal/routes/system/routes/domains/table.component.ts diff --git a/web/projects/ui/src/app/routes/portal/components/interfaces/clearnet.component.ts b/web/projects/ui/src/app/routes/portal/components/interfaces/clearnet.component.ts index 6d13f4ea0..e6bbe3e85 100644 --- a/web/projects/ui/src/app/routes/portal/components/interfaces/clearnet.component.ts +++ b/web/projects/ui/src/app/routes/portal/components/interfaces/clearnet.component.ts @@ -241,12 +241,12 @@ export class InterfaceClearnetComponent { name: 'ACME Provider', description: 'Select which ACME provider to use for obtaining your SSL certificate. Add new ACME providers in the System tab. Optionally use your system Root CA. Note: only devices that have trusted your Root CA will be able to access the domain without security warnings.', - values: this.acme().reduce( + values: this.acme().reduce>( (obj, url) => ({ ...obj, [url]: toAcmeName(url), }), - { none: 'None (use system Root CA)' } as Record, + { none: 'None (use system Root CA)' }, ), default: '', }) diff --git a/web/projects/ui/src/app/routes/portal/routes/system/routes/domains/acme.service.ts b/web/projects/ui/src/app/routes/portal/routes/system/routes/domains/acme/acme.service.ts similarity index 73% rename from web/projects/ui/src/app/routes/portal/routes/system/routes/domains/acme.service.ts rename to web/projects/ui/src/app/routes/portal/routes/system/routes/domains/acme/acme.service.ts index cd2158cb3..64d79c5d9 100644 --- a/web/projects/ui/src/app/routes/portal/routes/system/routes/domains/acme.service.ts +++ b/web/projects/ui/src/app/routes/portal/routes/system/routes/domains/acme/acme.service.ts @@ -5,18 +5,27 @@ import { i18nPipe, LoadingService, } from '@start9labs/shared' +import { toSignal } from '@angular/core/rxjs-interop' import { ISB, utils } from '@start9labs/start-sdk' -import { filter } from 'rxjs' +import { filter, map } from 'rxjs' import { FormComponent } from 'src/app/routes/portal/components/form.component' import { ApiService } from 'src/app/services/api/embassy-api.service' import { FormDialogService } from 'src/app/services/form-dialog.service' +import { PatchDB } from 'patch-db-client' +import { DataModel } from 'src/app/services/patch-db/data-model' import { knownACME } from 'src/app/utils/acme' import { configBuilderToSpec } from 'src/app/utils/configBuilderToSpec' +import { toAcmeName } from 'src/app/utils/acme' -@Injectable({ - providedIn: 'root', -}) +export type ACMEInfo = { + name: string + url: string + contact: readonly string[] +} + +@Injectable() export class AcmeService { + private readonly patch = inject>(PatchDB) private readonly loader = inject(LoadingService) private readonly errorService = inject(ErrorService) private readonly api = inject(ApiService) @@ -24,87 +33,26 @@ export class AcmeService { private readonly i18n = inject(i18nPipe) private readonly dialog = inject(DialogService) - async add(providers: { url: string; contact: string[] }[]) { - this.formDialog.open(FormComponent, { - label: 'Add ACME Provider', - data: { - spec: await configBuilderToSpec( - this.addSpec(providers.map(p => p.url)), - ), - buttons: [ - { - text: this.i18n.transform('Save'), - handler: async (val: ReturnType['_TYPE']) => { - const providerUrl = - val.provider.selection === 'other' - ? val.provider.value.url - : val.provider.selection + readonly acmes = toSignal( + this.patch.watch$('serverInfo', 'network', 'acme').pipe( + map(acme => + Object.keys(acme).map(url => ({ + url, + name: toAcmeName(url), + contact: + acme[url]?.contact.map(mailto => mailto.replace('mailto:', '')) || + [], + })), + ), + ), + ) - return this.save(providerUrl, val.contact) - }, - }, - ], - }, - }) - } - - async edit({ url, contact }: { url: string; contact: readonly string[] }) { - this.formDialog.open(FormComponent, { - label: 'Edit ACME Provider', - data: { - spec: await configBuilderToSpec(this.editSpec()), - buttons: [ - { - text: this.i18n.transform('Save'), - handler: async (val: ReturnType['_TYPE']) => - this.save(url, val.contact), - }, - ], - value: { contact }, - }, - }) - } - - remove({ url }: { url: string }) { - this.dialog - .openConfirm({ label: 'Are you sure?', size: 's' }) - .pipe(filter(Boolean)) - .subscribe(async () => { - const loader = this.loader.open('Removing').subscribe() - - try { - await this.api.removeAcme({ provider: url }) - } catch (e: any) { - this.errorService.handleError(e) - } finally { - loader.unsubscribe() - } - }) - } - - private async save(providerUrl: string, contact: readonly string[]) { - const loader = this.loader.open('Saving').subscribe() - - try { - await this.api.initAcme({ - provider: new URL(providerUrl).href, - contact: contact.map(address => `mailto:${address}`), - }) - return true - } catch (e: any) { - this.errorService.handleError(e) - return false - } finally { - loader.unsubscribe() - } - } - - private addSpec(providers: string[]) { + async add(acmes: ACMEInfo[]) { const availableAcme = knownACME.filter( - acme => !providers.includes(acme.url), + acme => !acmes.map(a => a.url).includes(acme.url), ) - return ISB.InputSpec.of({ + const addSpec = ISB.InputSpec.of({ provider: ISB.Value.union({ name: 'Provider', default: (availableAcme[0]?.url as any) || 'other', @@ -135,12 +83,81 @@ export class AcmeService { }), contact: this.emailListSpec(), }) + + this.formDialog.open(FormComponent, { + label: 'Add ACME Provider', + data: { + spec: await configBuilderToSpec(addSpec), + buttons: [ + { + text: this.i18n.transform('Save'), + handler: async (val: typeof addSpec._TYPE) => { + const providerUrl = + val.provider.selection === 'other' + ? val.provider.value.url + : val.provider.selection + + return this.save(providerUrl, val.contact) + }, + }, + ], + }, + }) } - private editSpec() { - return ISB.InputSpec.of({ + async edit({ url, contact }: ACMEInfo) { + const editSpec = ISB.InputSpec.of({ contact: this.emailListSpec(), }) + + this.formDialog.open(FormComponent, { + label: 'Edit ACME Provider', + data: { + spec: await configBuilderToSpec(editSpec), + buttons: [ + { + text: this.i18n.transform('Save'), + handler: async (val: typeof editSpec._TYPE) => + this.save(url, val.contact), + }, + ], + value: { contact }, + }, + }) + } + + remove({ url }: ACMEInfo) { + this.dialog + .openConfirm({ label: 'Are you sure?', size: 's' }) + .pipe(filter(Boolean)) + .subscribe(async () => { + const loader = this.loader.open('Removing').subscribe() + + try { + await this.api.removeAcme({ provider: url }) + } catch (e: any) { + this.errorService.handleError(e) + } finally { + loader.unsubscribe() + } + }) + } + + private async save(url: string, contact: readonly string[]) { + const loader = this.loader.open('Saving').subscribe() + + try { + await this.api.initAcme({ + provider: new URL(url).href, + contact: contact.map(address => `mailto:${address}`), + }) + return true + } catch (e: any) { + this.errorService.handleError(e) + return false + } finally { + loader.unsubscribe() + } } private emailListSpec() { diff --git a/web/projects/ui/src/app/routes/portal/routes/system/routes/domains/acme.component.ts b/web/projects/ui/src/app/routes/portal/routes/system/routes/domains/acme/item.component.ts similarity index 86% rename from web/projects/ui/src/app/routes/portal/routes/system/routes/domains/acme.component.ts rename to web/projects/ui/src/app/routes/portal/routes/system/routes/domains/acme/item.component.ts index c3b67bd72..0894d37cc 100644 --- a/web/projects/ui/src/app/routes/portal/routes/system/routes/domains/acme.component.ts +++ b/web/projects/ui/src/app/routes/portal/routes/system/routes/domains/acme/item.component.ts @@ -11,14 +11,12 @@ import { TuiDropdown, TuiTextfield, } from '@taiga-ui/core' -import { toAcmeName } from 'src/app/utils/acme' - -import { AcmeService } from './acme.service' +import { ACMEInfo, AcmeService } from './acme.service' @Component({ selector: 'tr[acme]', template: ` - {{ toAcmeName(acme().url) }} + {{ acme().name }} {{ acme().contact.join(', ') }} - - - - - - - - - - `, - styles: ` - td:last-child { - grid-area: 1 / 2 / 4; - align-self: center; - text-align: right; - } - - :host-context(tui-root._mobile) { - grid-template-columns: 1fr min-content; - - td:first-child { - font: var(--tui-font-text-m); - font-weight: bold; - } - } - `, - changeDetection: ChangeDetectionStrategy.OnPush, - imports: [TuiButton, i18nPipe, TuiDropdown, TuiDataList, TuiTextfield], -}) -export class DomainsDomainComponent { - private readonly dialog = inject(DialogService) - private readonly loader = inject(LoadingService) - private readonly errorService = inject(ErrorService) - private readonly formDialog = inject(FormDialogService) - - readonly domain = input.required() - - open = false - - remove() { - this.dialog - .openConfirm({ label: 'Are you sure?', size: 's' }) - .pipe(filter(Boolean)) - .subscribe(async () => { - const loader = this.loader.open('Deleting').subscribe() - - try { - } catch (e: any) { - this.errorService.handleError(e) - } finally { - loader.unsubscribe() - } - }) - } - - async edit() { - const renameSpec = ISB.InputSpec.of({}) - - this.formDialog.open(FormComponent, { - label: 'Edit', - data: { - spec: await configBuilderToSpec(renameSpec), - buttons: [ - { - text: 'Save', - handler: (value: typeof renameSpec._TYPE) => {}, - }, - ], - }, - }) - } - - async showDns() {} - - async testDns() {} -} 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 index 475769cb2..c31c24640 100644 --- 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 @@ -1,21 +1,13 @@ -import { - ChangeDetectionStrategy, - Component, - inject, - signal, -} from '@angular/core' -import { toSignal } from '@angular/core/rxjs-interop' +import { ChangeDetectionStrategy, Component, inject } from '@angular/core' import { RouterLink } from '@angular/router' import { DocsLinkDirective, i18nPipe } from '@start9labs/shared' import { TuiButton, TuiLink, TuiTitle } from '@taiga-ui/core' import { TuiHeader } from '@taiga-ui/layout' -import { PatchDB } from 'patch-db-client' -import { map } from 'rxjs' -import { DataModel } from 'src/app/services/patch-db/data-model' import { TitleDirective } from 'src/app/services/title.service' - -import { AcmeService } from './acme.service' -import { DomainsTableComponent } from './table.component' +import { AcmeService } from './acme/acme.service' +import { DomainsService } from './domains/domains.service' +import { DomainsTableComponent } from './domains/table.component' +import { AcmeTableComponent } from './acme/table.component' @Component({ template: ` @@ -29,15 +21,14 @@ import { DomainsTableComponent } from './table.component'

    {{ 'Domains' | i18n }}

    + {{ - 'Add ACME providers in order to generate SSL (https) certificates for clearnet access.' - | i18n + 'Adding a domain to StartOS means you can use it and its subdomains to host service interfaces on the public Internet.' }}

    +
    {{ 'ACME Providers' | i18n }} - @if (acme(); as value) { + @if (acmeService.acmes(); as acmes) { }
    - +
    {{ 'Domains' | i18n }} - + @if (domainsService.data(); as value) { + + }
    - +
    `, - styles: ``, changeDetection: ChangeDetectionStrategy.OnPush, imports: [ TuiButton, @@ -92,37 +85,11 @@ import { DomainsTableComponent } from './table.component' i18nPipe, DocsLinkDirective, DomainsTableComponent, + AcmeTableComponent, ], + providers: [AcmeService, DomainsService], }) export default class SystemDomainsComponent { - protected readonly patch = inject>(PatchDB) - protected readonly service = inject(AcmeService) - - readonly acme = toSignal( - this.patch.watch$('serverInfo', 'network', 'acme').pipe( - map(acme => - Object.keys(acme).map(url => ({ - url, - contact: - acme[url]?.contact.map(mailto => mailto.replace('mailto:', '')) || - [], - })), - ), - ), - ) - - readonly domains = signal([ - { - domain: 'blog.mydomain.com', - gateway: 'StartTunnel', - acme: 'System', - }, - { - domain: 'blog. mydomain.com', - gateway: 'StartTunnel', - acme: 'System', - }, - ]) - - async addDomain() {} + protected readonly acmeService = inject(AcmeService) + protected readonly domainsService = inject(DomainsService) } diff --git a/web/projects/ui/src/app/routes/portal/routes/system/routes/domains/domains/domains.service.ts b/web/projects/ui/src/app/routes/portal/routes/system/routes/domains/domains/domains.service.ts new file mode 100644 index 000000000..acdd391c2 --- /dev/null +++ b/web/projects/ui/src/app/routes/portal/routes/system/routes/domains/domains/domains.service.ts @@ -0,0 +1,193 @@ +import { inject, Injectable } from '@angular/core' +import { + DialogService, + ErrorService, + i18nPipe, + LoadingService, +} from '@start9labs/shared' +import { toSignal } from '@angular/core/rxjs-interop' +import { ISB, utils } from '@start9labs/start-sdk' +import { filter, map } from 'rxjs' +import { FormComponent } from 'src/app/routes/portal/components/form.component' +import { ApiService } from 'src/app/services/api/embassy-api.service' +import { FormDialogService } from 'src/app/services/form-dialog.service' +import { configBuilderToSpec } from 'src/app/utils/configBuilderToSpec' +import { PatchDB } from 'patch-db-client' +import { DataModel } from 'src/app/services/patch-db/data-model' +import { toAcmeName } from 'src/app/utils/acme' + +// @TODO translations + +@Injectable() +export class DomainsService { + 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(network => { + return { + gateways: Object.entries(network.networkInterfaces).reduce< + Record + >( + (obj, [id, n]) => ({ + ...obj, + [id]: n.ipInfo?.name || '', + }), + {}, + ), + // @TODO use real data + domains: [ + { + domain: 'blog.mydomain.com', + gateway: { + id: '', + name: 'StartTunnel', + }, + acme: { + url: '', + name: `Lert's Encrypt`, + }, + }, + { + domain: 'store.mydomain.com', + gateway: { + id: '', + name: 'Ethernet', + }, + acme: { + url: null, + name: 'System', + }, + }, + ], + acme: Object.keys(network.acme).reduce>( + (obj, url) => ({ + ...obj, + [url]: toAcmeName(url), + }), + { none: 'None (use system Root CA)' }, + ), + } + }), + ), + ) + + async add() { + const addSpec = ISB.InputSpec.of({ + domain: ISB.Value.text({ + name: 'Domain', + description: + 'Enter a domain/subdomain. For example, if you control domain.com, you could enter domain.com or subdomain.domain.com or another.subdomain.domain.com. In any case, the domain you enter and all possible subdomains of the domain will be available for assignment in StartOS', + required: true, + default: null, + patterns: [utils.Patterns.domain], + }), + ...this.gatewaysAndAcme(), + }) + + this.formDialog.open(FormComponent, { + label: 'Add Domain' as any, + data: { + spec: await configBuilderToSpec(addSpec), + buttons: [ + { + text: 'Save', + handler: (input: typeof addSpec._TYPE) => this.save(input), + }, + ], + }, + }) + } + + async edit(domain: any) { + const editSpec = ISB.InputSpec.of({ + ...this.gatewaysAndAcme(), + }) + + this.formDialog.open(FormComponent, { + label: 'Edit Domain' as any, // @TODO translation + data: { + spec: await configBuilderToSpec(editSpec), + buttons: [ + { + text: 'Save', + handler: (input: typeof editSpec._TYPE) => + this.save({ + domain: domain.domain, + ...input, + }), + }, + ], + value: { + gateway: domain.gateway.id, + acme: domain.acme.url, + }, + }, + }) + } + + remove(domain: any) { + this.dialog + .openConfirm({ label: 'Are you sure?', size: 's' }) + .pipe(filter(Boolean)) + .subscribe(async () => { + const loader = this.loader.open('Deleting').subscribe() + + try { + // @TODO API + } catch (e: any) { + this.errorService.handleError(e) + } finally { + loader.unsubscribe() + } + }) + } + + showDns(domain: any) { + // @TODO + } + + testDns(domain: any) { + // @TODO + } + + // @TODO different endpoints for create and edit? + private async save(params: any) { + const loader = this.loader.open('Saving').subscribe() + + try { + // @TODO API + return true + } catch (e: any) { + this.errorService.handleError(e) + return false + } finally { + loader.unsubscribe() + } + } + + private gatewaysAndAcme() { + return { + gateway: ISB.Value.select({ + name: 'Gateway', + description: + 'Select the public gateway for this domain. Whichever gateway you select is the IP address that will be exposed to the Internet.', + values: this.data()!.gateways, + default: '', + }), + acme: ISB.Value.select({ + name: 'Default ACME', + description: + 'Select the default ACME provider for this domain. This can be overridden on a case-by-case basis.', + values: this.data()!.acme, + default: '', + }), + } + } +} diff --git a/web/projects/ui/src/app/routes/portal/routes/system/routes/domains/domains/item.component.ts b/web/projects/ui/src/app/routes/portal/routes/system/routes/domains/domains/item.component.ts new file mode 100644 index 000000000..ad035b910 --- /dev/null +++ b/web/projects/ui/src/app/routes/portal/routes/system/routes/domains/domains/item.component.ts @@ -0,0 +1,100 @@ +import { + ChangeDetectionStrategy, + Component, + inject, + input, +} from '@angular/core' +import { i18nPipe } from '@start9labs/shared' +import { + TuiButton, + TuiDataList, + TuiDropdown, + TuiTextfield, +} from '@taiga-ui/core' +import { DomainsService } from './domains.service' + +@Component({ + selector: 'tr[domain]', + template: ` + {{ domain().domain }} + {{ domain().gateway.name }} + {{ domain().acme.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; + + td:first-child { + font: var(--tui-font-text-m); + font-weight: bold; + } + } + `, + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [TuiButton, i18nPipe, TuiDropdown, TuiDataList, TuiTextfield], +}) +export class DomainsItemComponent { + protected readonly domainsService = inject(DomainsService) + + readonly domain = input.required() + + open = false +} diff --git a/web/projects/ui/src/app/routes/portal/routes/system/routes/domains/domains/table.component.ts b/web/projects/ui/src/app/routes/portal/routes/system/routes/domains/domains/table.component.ts new file mode 100644 index 000000000..b82485ebf --- /dev/null +++ b/web/projects/ui/src/app/routes/portal/routes/system/routes/domains/domains/table.component.ts @@ -0,0 +1,41 @@ +import { ChangeDetectionStrategy, Component, input } from '@angular/core' +import { i18nPipe } from '@start9labs/shared' +import { TuiSkeleton } from '@taiga-ui/kit' +import { PlaceholderComponent } from 'src/app/routes/portal/components/placeholder.component' +import { TableComponent } from 'src/app/routes/portal/components/table.component' +import { DomainsItemComponent } from './item.component' + +@Component({ + selector: 'domains-table', + template: ` + + @for (domain of domains(); track $index) { + + } @empty { + + + + } +
    + @if (domains()) { + + {{ 'No domains' | i18n }} + + } @else { +
    {{ 'Loading' | i18n }}
    + } +
    + `, + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [ + TuiSkeleton, + i18nPipe, + TableComponent, + PlaceholderComponent, + DomainsItemComponent, + ], +}) +export class DomainsTableComponent { + // @TODO Alex proper types + readonly domains = input() +} 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 4c0dcf5e3..000000000 --- a/web/projects/ui/src/app/routes/portal/routes/system/routes/domains/table.component.ts +++ /dev/null @@ -1,68 +0,0 @@ -import { - ChangeDetectionStrategy, - Component, - computed, - input, -} from '@angular/core' -import { i18nPipe } from '@start9labs/shared' -import { TuiSkeleton } from '@taiga-ui/kit' -import { PlaceholderComponent } from 'src/app/routes/portal/components/placeholder.component' -import { TableComponent } from 'src/app/routes/portal/components/table.component' - -import { DomainsAcmeComponent } from './acme.component' -import { DomainsDomainComponent } from './domain.component' - -@Component({ - selector: 'domains-table', - template: ` - - @for (item of items(); track $index) { - @if (mode() === 'domains') { - - } @else if (mode() === 'acme') { - - } - } @empty { - - - - } -
    - @if (items()) { - - @if (mode() === 'domains') { - {{ 'No domains' | i18n }} - } @else { - {{ 'No saved providers' | i18n }} - } - - } @else { -
    {{ 'Loading' | i18n }}
    - } -
    - `, - changeDetection: ChangeDetectionStrategy.OnPush, - imports: [ - TuiSkeleton, - i18nPipe, - TableComponent, - PlaceholderComponent, - DomainsDomainComponent, - DomainsAcmeComponent, - ], -}) -export class DomainsTableComponent { - // @TODO Alex proper types - readonly items = input() - readonly mode = input<'domains' | 'acme'>('domains') - - readonly titles = computed(() => - this.mode() === 'domains' - ? (['Domain', 'Gateway', 'Default ACME', null] as const) - : (['Provider', 'Contact', null] as const), - ) - - readonly icon = computed(() => - this.mode() === 'domains' ? '@tui.globe' : '@tui.shield-question', - ) -} diff --git a/web/projects/ui/src/app/routes/portal/routes/system/routes/gateways/gateways.component.ts b/web/projects/ui/src/app/routes/portal/routes/system/routes/gateways/gateways.component.ts index 1fbadbb33..fd0824214 100644 --- a/web/projects/ui/src/app/routes/portal/routes/system/routes/gateways/gateways.component.ts +++ b/web/projects/ui/src/app/routes/portal/routes/system/routes/gateways/gateways.component.ts @@ -97,67 +97,22 @@ export default class GatewaysComponent { ), ) - readonly gatewaySpec = ISB.InputSpec.of({ - name: ISB.Value.text({ - name: 'Name', - description: 'A name to easily identify the gateway', - required: true, - default: null, - }), - type: ISB.Value.select({ - name: 'Type', - description: - '-**Private**: select this option if the gateway is configured for private access to authorized clients only, which usually means ports are closed and traffic blocked otherwise. StartTunnel is a private gateway.\n-**Public**: select this option if the gateway is configured for unfettered public access, which usually means ports are open and traffic forwarded.', - default: 'private', - values: { - private: 'Private', - public: 'Public', - }, - }), - config: ISB.Value.union({ - name: 'Wireguard Config', - default: 'paste', - variants: ISB.Variants.of({ - paste: { - name: 'Paste File Contents', - spec: ISB.InputSpec.of({ - file: ISB.Value.textarea({ - name: 'Paste File Contents', - default: null, - required: true, - }), - }), - }, - upload: { - name: 'Upload File', - spec: ISB.InputSpec.of({ - file: ISB.Value.file({ - name: 'File', - required: true, - extensions: ['.conf'], - }), - }), - }, - }), - }), - }) - async add() { this.formDialog.open(FormComponent, { label: 'Add Gateway', data: { - spec: await configBuilderToSpec(this.gatewaySpec), + spec: await configBuilderToSpec(gatewaySpec), buttons: [ { text: 'Save', - handler: (input: typeof this.gatewaySpec._TYPE) => this.save(input), + handler: (input: typeof gatewaySpec._TYPE) => this.save(input), }, ], }, }) } - private async save(input: typeof this.gatewaySpec._TYPE): Promise { + private async save(input: typeof gatewaySpec._TYPE): Promise { const loader = this.loader.open('Saving').subscribe() try { @@ -175,3 +130,48 @@ export default class GatewaysComponent { } } } + +const gatewaySpec = ISB.InputSpec.of({ + name: ISB.Value.text({ + name: 'Name', + description: 'A name to easily identify the gateway', + required: true, + default: null, + }), + type: ISB.Value.select({ + name: 'Type', + description: + '-**Private**: select this option if the gateway is configured for private access to authorized clients only. StartTunnel is a private gateway.\n-**Public**: select this option if the gateway is configured for unfettered public access.', + default: 'private', + values: { + private: 'Private', + public: 'Public', + }, + }), + config: ISB.Value.union({ + name: 'Wireguard Config', + default: 'paste', + variants: ISB.Variants.of({ + paste: { + name: 'Paste File Contents', + spec: ISB.InputSpec.of({ + file: ISB.Value.textarea({ + name: 'Paste File Contents', + default: null, + required: true, + }), + }), + }, + upload: { + name: 'Upload File', + spec: ISB.InputSpec.of({ + file: ISB.Value.file({ + name: 'File', + required: true, + extensions: ['.conf'], + }), + }), + }, + }), + }), +}) From f8b03ea917d93d76615d29b7b2fc6bcdc9a91aae Mon Sep 17 00:00:00 2001 From: Matt Hill Date: Tue, 5 Aug 2025 13:03:04 -0600 Subject: [PATCH 8/8] certificate authorities --- .../shared/src/i18n/dictionaries/de.ts | 15 ++- .../shared/src/i18n/dictionaries/en.ts | 15 ++- .../shared/src/i18n/dictionaries/es.ts | 16 +-- .../shared/src/i18n/dictionaries/fr.ts | 15 ++- .../shared/src/i18n/dictionaries/pl.ts | 15 ++- .../portal/components/interfaces/acme.pipe.ts | 8 +- .../interfaces/clearnet.component.ts | 36 +++--- .../components/interfaces/interface.utils.ts | 4 +- .../routes/domains/acme/item.component.ts | 83 ------------ .../routes/domains/acme/table.component.ts | 41 ------ .../authority.service.ts} | 48 +++---- .../domains/authorities/item.component.ts | 99 +++++++++++++++ .../domains/authorities/table.component.ts | 26 ++++ .../routes/domains/domains.component.ts | 30 ++--- .../{domains.service.ts => domain.service.ts} | 44 +++---- .../routes/domains/domains/item.component.ts | 118 +++++++++--------- .../routes/domains/domains/table.component.ts | 18 +-- .../system/routes/sessions/table.component.ts | 3 - .../portal/routes/system/system.const.ts | 10 +- .../services/api/embassy-mock-api.service.ts | 4 +- .../ui/src/app/services/api/mock-patch.ts | 18 ++- web/projects/ui/src/app/utils/acme.ts | 12 +- 22 files changed, 351 insertions(+), 327 deletions(-) delete mode 100644 web/projects/ui/src/app/routes/portal/routes/system/routes/domains/acme/item.component.ts delete mode 100644 web/projects/ui/src/app/routes/portal/routes/system/routes/domains/acme/table.component.ts rename web/projects/ui/src/app/routes/portal/routes/system/routes/domains/{acme/acme.service.ts => authorities/authority.service.ts} (81%) create mode 100644 web/projects/ui/src/app/routes/portal/routes/system/routes/domains/authorities/item.component.ts create mode 100644 web/projects/ui/src/app/routes/portal/routes/system/routes/domains/authorities/table.component.ts rename web/projects/ui/src/app/routes/portal/routes/system/routes/domains/domains/{domains.service.ts => domain.service.ts} (81%) diff --git a/web/projects/shared/src/i18n/dictionaries/de.ts b/web/projects/shared/src/i18n/dictionaries/de.ts index 6f0969a99..b6f638dc0 100644 --- a/web/projects/shared/src/i18n/dictionaries/de.ts +++ b/web/projects/shared/src/i18n/dictionaries/de.ts @@ -179,7 +179,6 @@ export default { 177: 'Kernelspeicher', 178: 'Leerlauf', 179: 'I/O-Wartezeit', - 180: 'ACME', 181: 'Gesamt', 182: 'Verwendet', 183: 'Verfügbar', @@ -294,12 +293,12 @@ export default { 296: 'Hochladen', 297: 'Version 1 s9pk erkannt. Dieses Format ist veraltet. Falls nötig, kann ein V1 s9pk über start-cli installiert werden.', 298: 'Ungültige Paketdatei', - 299: 'Fügen Sie ACME-Anbieter hinzu, um SSL-(https)-Zertifikate für den Clearnet-Zugriff zu generieren.', + 299: 'Das Hinzufügen einer Domain zu StartOS bedeutet, dass du sie und ihre Subdomains verwenden kannst, um Service-Oberflächen im öffentlichen Internet zu hosten.', 300: 'Anleitung anzeigen', 303: 'Kontakt', 304: 'Bearbeiten', - 305: 'ACME-Anbieter hinzufügen', - 306: 'ACME-Anbieter bearbeiten', + 305: 'Zertifizierungsstelle hinzufügen', + 306: 'Kontaktinformationen bearbeiten', 307: 'Kontakt-E-Mails', 308: 'Erforderlich, um ein Zertifikat von einer Zertifizierungsstelle zu erhalten', 309: 'Alle umschalten', @@ -532,12 +531,12 @@ export default { 536: 'Umbenennen', 537: 'Zugriff', 538: 'Domains', - 539: 'ACME-Anbieter', + 539: 'Zertifizierungsstellen', 540: 'Domain', 541: 'Gateway', - 542: 'Standard-ACME', - 543: 'Gateway ändern', - 544: 'Standard-ACME ändern', + 542: 'Standard-Zertifizierungsstelle', + 543: 'Zertifizierungsstelle', + 544: 'Domain bearbeiten', 545: 'Keine Domains', 546: 'Anbieter', 547: 'DNS anzeigen', diff --git a/web/projects/shared/src/i18n/dictionaries/en.ts b/web/projects/shared/src/i18n/dictionaries/en.ts index 3e41596cc..e621ae040 100644 --- a/web/projects/shared/src/i18n/dictionaries/en.ts +++ b/web/projects/shared/src/i18n/dictionaries/en.ts @@ -178,7 +178,6 @@ export const ENGLISH = { 'Kernel space': 177, 'Idle': 178, // a CPU metric 'I/O wait': 179, - 'ACME': 180, 'Total': 181, 'Used': 182, 'Available': 183, @@ -293,12 +292,12 @@ export const ENGLISH = { 'Upload': 296, 'Version 1 s9pk detected. This package format is deprecated. You can sideload a V1 s9pk via start-cli if necessary.': 297, 'Invalid package file': 298, - 'Add ACME providers in order to generate SSL (https) certificates for clearnet access.': 299, + 'Adding a domain to StartOS means you can use it and its subdomains to host service interfaces on the public Internet.': 299, 'View instructions': 300, 'Contact': 303, // as in, "contact us" 'Edit': 304, - 'Add ACME Provider': 305, - 'Edit ACME Provider': 306, + 'Add Certificate Authority': 305, + 'Edit Contact Info': 306, 'Contact Emails': 307, 'Needed to obtain a certificate from a Certificate Authority': 308, 'Toggle all': 309, @@ -531,12 +530,12 @@ export const ENGLISH = { 'Rename': 536, 'Access': 537, // as in, public or private access, almost "permission" 'Domains': 538, // as in, internet domains - 'ACME Providers': 539, + 'Certificate Authorities': 539, 'Domain': 540, // as in, an internat domain name 'Gateway': 541, // as in, a device or software that connects two different networks - 'Default ACME': 542, // as in, the default ACME provider for signing certificates - 'Change gateway': 543, // as in, change the network gateway for a computer - 'Change default ACME': 544, // as in, change the default ACME provider for a domain + 'Default Certificate Authority': 542, + 'Certificate Authority': 543, + 'Edit Domain': 544, 'No domains': 545, 'Provider': 546, 'Show DNS': 547, diff --git a/web/projects/shared/src/i18n/dictionaries/es.ts b/web/projects/shared/src/i18n/dictionaries/es.ts index ab18c0415..346b656ce 100644 --- a/web/projects/shared/src/i18n/dictionaries/es.ts +++ b/web/projects/shared/src/i18n/dictionaries/es.ts @@ -179,7 +179,6 @@ export default { 177: 'Espacio del kernel', 178: 'Inactivo', 179: 'Espera de E/S', - 180: 'ACME', 181: 'Total', 182: 'Usado', 183: 'Disponible', @@ -294,12 +293,13 @@ export default { 296: 'Subir', 297: 'Se detectó un paquete s9pk de versión 1. Este formato está obsoleto. Puedes instalarlo manualmente con start-cli si es necesario.', 298: 'Archivo de paquete inválido', - 299: 'Agrega proveedores ACME para generar certificados SSL (https) para el acceso desde clearnet.', + 299: 'Agregar un dominio a StartOS significa que puedes usarlo y sus subdominios para alojar interfaces de servicios en Internet público.', + 300: 'Ver instrucciones', 303: 'Contacto', 304: 'Editar', - 305: 'Agregar proveedor ACME', - 306: 'Editar proveedor ACME', + 305: 'Agregar autoridad certificadora', + 306: 'Editar información de contacto', 307: 'Correos de contacto', 308: 'Necesarios para obtener un certificado de una Autoridad Certificadora', 309: 'Alternar todo', @@ -532,12 +532,12 @@ export default { 536: 'Renombrar', 537: 'Acceso', 538: 'Dominios', - 539: 'Proveedores ACME', + 539: 'Autoridades certificadoras', 540: 'Dominio', 541: 'Puerta de enlace', - 542: 'ACME predeterminado', - 543: 'Cambiar puerta de enlace', - 544: 'Cambiar ACME predeterminado', + 542: 'Autoridad certificadora predeterminada', + 543: 'Autoridad certificadora', + 544: 'Editar dominio', 545: 'Sin dominios', 546: 'Proveedor', 547: 'Mostrar DNS', diff --git a/web/projects/shared/src/i18n/dictionaries/fr.ts b/web/projects/shared/src/i18n/dictionaries/fr.ts index c780e1362..e9b498a14 100644 --- a/web/projects/shared/src/i18n/dictionaries/fr.ts +++ b/web/projects/shared/src/i18n/dictionaries/fr.ts @@ -179,7 +179,6 @@ export default { 177: 'Espace noyau', 178: 'Inactif', 179: 'Attente E/S', - 180: 'ACME', 181: 'Total', 182: 'Utilisé', 183: 'Disponible', @@ -294,12 +293,12 @@ export default { 296: 'Téléverser', 297: 'Version 1 de s9pk détectée. Ce format de paquet est obsolète. Vous pouvez installer manuellement un s9pk V1 via start-cli si nécessaire.', 298: 'Fichier paquet invalide', - 299: 'Ajoutez des fournisseurs ACME pour générer des certificats SSL (https) pour l’accès clearnet.', + 299: 'Ajouter un domaine à StartOS signifie que vous pouvez l’utiliser, ainsi que ses sous-domaines, pour héberger des interfaces de services sur Internet public.', 300: 'Voir les instructions', 303: 'Contact', 304: 'Modifier', - 305: 'Ajouter un fournisseur ACME', - 306: 'Modifier le fournisseur ACME', + 305: 'Ajouter une autorité de certification', + 306: 'Modifier les informations de contact', 307: 'Emails de contact', 308: 'Nécessaire pour obtenir un certificat d’une autorité de certification', 309: 'Tout cocher', @@ -532,12 +531,12 @@ export default { 536: 'Renommer', 537: 'Accès', 538: 'Domaines', - 539: 'Fournisseurs ACME', + 539: 'Autorités de certification', 540: 'Domaine', 541: 'Passerelle', - 542: 'ACME par défaut', - 543: 'Changer de passerelle', - 544: 'Changer l’ACME par défaut', + 542: 'Autorité de certification par défaut', + 543: 'Autorité de certification', + 544: 'Modifier le domaine', 545: 'Aucun domaine', 546: 'Fournisseur', 547: 'Afficher le DNS', diff --git a/web/projects/shared/src/i18n/dictionaries/pl.ts b/web/projects/shared/src/i18n/dictionaries/pl.ts index 0feb88469..121856c07 100644 --- a/web/projects/shared/src/i18n/dictionaries/pl.ts +++ b/web/projects/shared/src/i18n/dictionaries/pl.ts @@ -179,7 +179,6 @@ export default { 177: 'Przestrzeń jądra', 178: 'Bezczynność', 179: 'Oczekiwanie na I/O', - 180: 'ACME', 181: 'Łącznie', 182: 'Wykorzystane', 183: 'Dostępne', @@ -294,12 +293,12 @@ export default { 296: 'Prześlij', 297: 'Wykryto pakiet s9pk w wersji 1. Ten format pakietu jest przestarzały. Możesz zainstalować pakiet s9pk V1 przez start-cli, jeśli to konieczne.', 298: 'Nieprawidłowy plik pakietu', - 299: 'Dodaj dostawców ACME, aby wygenerować certyfikaty SSL (https) dla dostępu przez clearnet.', + 299: 'Dodanie domeny do StartOS oznacza, że możesz używać jej i jej subdomen do hostowania interfejsów usług w publicznym Internecie.', 300: 'Zobacz instrukcje', 303: 'Kontakt', 304: 'Edytuj', - 305: 'Dodaj dostawcę ACME', - 306: 'Edytuj dostawcę ACME', + 305: 'Dodaj urząd certyfikacji', + 306: 'Edytuj dane kontaktowe', 307: 'Adresy e-mail kontaktowe', 308: 'Wymagane do uzyskania certyfikatu od urzędu certyfikacji', 309: 'Zaznacz wszystkie', @@ -532,12 +531,12 @@ export default { 536: 'Zmień nazwę', 537: 'Dostęp', 538: 'Domeny', - 539: 'Dostawcy ACME', + 539: 'Urzędy certyfikacji', 540: 'Domena', 541: 'Brama', - 542: 'Domyślny ACME', - 543: 'Zmień bramę', - 544: 'Zmień domyślny ACME', + 542: 'Domyślny urząd certyfikacji', + 543: 'Urząd certyfikacji', + 544: 'Edytuj domenę', 545: 'Brak domen', 546: 'Dostawca', 547: 'Pokaż DNS', diff --git a/web/projects/ui/src/app/routes/portal/components/interfaces/acme.pipe.ts b/web/projects/ui/src/app/routes/portal/components/interfaces/acme.pipe.ts index 08c385d2d..420ef1da8 100644 --- a/web/projects/ui/src/app/routes/portal/components/interfaces/acme.pipe.ts +++ b/web/projects/ui/src/app/routes/portal/components/interfaces/acme.pipe.ts @@ -1,11 +1,11 @@ import { Pipe, PipeTransform } from '@angular/core' -import { toAcmeName } from 'src/app/utils/acme' +import { toAuthorityName } from 'src/app/utils/acme' @Pipe({ - name: 'acme', + name: 'authorityName', }) -export class AcmePipe implements PipeTransform { +export class AuthorityNamePipe implements PipeTransform { transform(value: string | null = null): string { - return toAcmeName(value) + return toAuthorityName(value) } } diff --git a/web/projects/ui/src/app/routes/portal/components/interfaces/clearnet.component.ts b/web/projects/ui/src/app/routes/portal/components/interfaces/clearnet.component.ts index e6bbe3e85..65ff42b08 100644 --- a/web/projects/ui/src/app/routes/portal/components/interfaces/clearnet.component.ts +++ b/web/projects/ui/src/app/routes/portal/components/interfaces/clearnet.component.ts @@ -28,14 +28,14 @@ import { FormComponent, FormContext, } from 'src/app/routes/portal/components/form.component' -import { AcmePipe } from 'src/app/routes/portal/components/interfaces/acme.pipe' +import { AuthorityNamePipe } from 'src/app/routes/portal/components/interfaces/acme.pipe' import { InterfaceComponent } from 'src/app/routes/portal/components/interfaces/interface.component' import { PlaceholderComponent } from 'src/app/routes/portal/components/placeholder.component' import { TableComponent } from 'src/app/routes/portal/components/table.component' import { ApiService } from 'src/app/services/api/embassy-api.service' import { FormDialogService } from 'src/app/services/form-dialog.service' import { DataModel } from 'src/app/services/patch-db/data-model' -import { toAcmeName } from 'src/app/utils/acme' +import { toAuthorityName } from 'src/app/utils/acme' import { configBuilderToSpec } from 'src/app/utils/configBuilderToSpec' import { InterfaceActionsComponent } from './actions.component' import { ClearnetAddress } from './interface.utils' @@ -43,7 +43,7 @@ import { MaskPipe } from './mask.pipe' type ClearnetForm = { domain: string - acme: string + authority: string } @Component({ @@ -85,11 +85,15 @@ type ClearnetForm = { }} } - +
    @for (address of clearnet(); track $index) { - - - `, - styles: ` - td:last-child { - grid-area: 1 / 2 / 3; - align-self: center; - text-align: right; - } - - :host-context(tui-root._mobile) { - grid-template-columns: 1fr min-content; - - td:first-child { - font: var(--tui-font-text-m); - font-weight: bold; - } - } - `, - changeDetection: ChangeDetectionStrategy.OnPush, - imports: [TuiButton, i18nPipe, TuiDropdown, TuiDataList, TuiTextfield], -}) -export class AcmeItemComponent { - protected readonly service = inject(AcmeService) - - readonly acme = input.required() - - open = false -} diff --git a/web/projects/ui/src/app/routes/portal/routes/system/routes/domains/acme/table.component.ts b/web/projects/ui/src/app/routes/portal/routes/system/routes/domains/acme/table.component.ts deleted file mode 100644 index 34541293e..000000000 --- a/web/projects/ui/src/app/routes/portal/routes/system/routes/domains/acme/table.component.ts +++ /dev/null @@ -1,41 +0,0 @@ -import { ChangeDetectionStrategy, Component, input } from '@angular/core' -import { i18nPipe } from '@start9labs/shared' -import { TuiSkeleton } from '@taiga-ui/kit' -import { PlaceholderComponent } from 'src/app/routes/portal/components/placeholder.component' -import { TableComponent } from 'src/app/routes/portal/components/table.component' -import { AcmeItemComponent } from './item.component' -import { ACMEInfo } from './acme.service' - -@Component({ - selector: 'acme-table', - template: ` -
    - {{ interface.value().addSsl ? (address.acme | acme) : '-' }} + {{ + interface.value().addSsl + ? (address.authority | authorityName) + : '-' + }} {{ address.url | mask }} () readonly isPublic = input.required() - readonly acme = toSignal( + readonly authorityUrls = toSignal( inject>(PatchDB) .watch$('serverInfo', 'network', 'acme') .pipe(map(acme => Object.keys(acme))), @@ -237,16 +241,16 @@ export class InterfaceClearnetComponent { default: null, patterns: [utils.Patterns.domain], }) - const acme = ISB.Value.select({ - name: 'ACME Provider', + const authority = ISB.Value.select({ + name: 'Certificate Authority', description: - 'Select which ACME provider to use for obtaining your SSL certificate. Add new ACME providers in the System tab. Optionally use your system Root CA. Note: only devices that have trusted your Root CA will be able to access the domain without security warnings.', - values: this.acme().reduce>( + 'Select which Certificate authority to use for obtaining your SSL certificate. Add new authority in the System tab. Optionally use your local= Root CA. Note: only devices that have trusted your Root CA will be able to access the domain without security warnings.', + values: this.authorityUrls().reduce>( (obj, url) => ({ ...obj, - [url]: toAcmeName(url), + [url]: toAuthorityName(url), }), - { none: 'None (use system Root CA)' }, + { local: toAuthorityName(null) }, ), default: '', }) @@ -256,7 +260,7 @@ export class InterfaceClearnetComponent { data: { spec: await configBuilderToSpec( ISB.InputSpec.of( - this.interface.value().addSsl ? { domain, acme } : { domain }, + this.interface.value().addSsl ? { domain, authority } : { domain }, ), ), buttons: [ @@ -272,11 +276,11 @@ export class InterfaceClearnetComponent { private async save(domainInfo: ClearnetForm): Promise { const loader = this.loader.open('Saving').subscribe() - const { domain, acme } = domainInfo + const { domain, authority } = domainInfo const params = { domain, - acme: acme === 'none' ? null : acme, + acme: authority === 'local' ? null : authority, private: false, } diff --git a/web/projects/ui/src/app/routes/portal/components/interfaces/interface.utils.ts b/web/projects/ui/src/app/routes/portal/components/interfaces/interface.utils.ts index 7d366df10..22de11903 100644 --- a/web/projects/ui/src/app/routes/portal/components/interfaces/interface.utils.ts +++ b/web/projects/ui/src/app/routes/portal/components/interfaces/interface.utils.ts @@ -72,7 +72,7 @@ export function getAddresses( url, disabled: !h.public, isDomain: hostnameKind == 'domain', - acme: + authority: hostnameKind == 'domain' ? host.domains[h.hostname.domain]?.acme || null : null, @@ -118,7 +118,7 @@ export type MappedServiceInterface = T.ServiceInterface & { export type ClearnetAddress = { url: string - acme: string | null + authority: string | null isDomain: boolean disabled: boolean } diff --git a/web/projects/ui/src/app/routes/portal/routes/system/routes/domains/acme/item.component.ts b/web/projects/ui/src/app/routes/portal/routes/system/routes/domains/acme/item.component.ts deleted file mode 100644 index 0894d37cc..000000000 --- a/web/projects/ui/src/app/routes/portal/routes/system/routes/domains/acme/item.component.ts +++ /dev/null @@ -1,83 +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 { ACMEInfo, AcmeService } from './acme.service' - -@Component({ - selector: 'tr[acme]', - template: ` - {{ acme().name }}{{ acme().contact.join(', ') }} - - - - - - - -
    - @for (acme of acmes(); track $index) { - - } @empty { - - - - } -
    - @if (acmes()) { - - {{ 'No saved providers' | i18n }} - - } @else { -
    {{ 'Loading' | i18n }}
    - } -
    - `, - changeDetection: ChangeDetectionStrategy.OnPush, - imports: [ - TuiSkeleton, - i18nPipe, - TableComponent, - PlaceholderComponent, - AcmeItemComponent, - ], -}) -export class AcmeTableComponent { - readonly acmes = input() -} diff --git a/web/projects/ui/src/app/routes/portal/routes/system/routes/domains/acme/acme.service.ts b/web/projects/ui/src/app/routes/portal/routes/system/routes/domains/authorities/authority.service.ts similarity index 81% rename from web/projects/ui/src/app/routes/portal/routes/system/routes/domains/acme/acme.service.ts rename to web/projects/ui/src/app/routes/portal/routes/system/routes/domains/authorities/authority.service.ts index 64d79c5d9..52de892d7 100644 --- a/web/projects/ui/src/app/routes/portal/routes/system/routes/domains/acme/acme.service.ts +++ b/web/projects/ui/src/app/routes/portal/routes/system/routes/domains/authorities/authority.service.ts @@ -13,18 +13,19 @@ import { ApiService } from 'src/app/services/api/embassy-api.service' import { FormDialogService } from 'src/app/services/form-dialog.service' import { PatchDB } from 'patch-db-client' import { DataModel } from 'src/app/services/patch-db/data-model' -import { knownACME } from 'src/app/utils/acme' +import { knownAuthorities, toAuthorityName } from 'src/app/utils/acme' import { configBuilderToSpec } from 'src/app/utils/configBuilderToSpec' -import { toAcmeName } from 'src/app/utils/acme' -export type ACMEInfo = { +export type Authority = { + url: string | null name: string - url: string - contact: readonly string[] + contact: readonly string[] | null } +export type RemoteAuthority = Authority & { url: string } + @Injectable() -export class AcmeService { +export class AuthorityService { private readonly patch = inject>(PatchDB) private readonly loader = inject(LoadingService) private readonly errorService = inject(ErrorService) @@ -33,31 +34,36 @@ export class AcmeService { private readonly i18n = inject(i18nPipe) private readonly dialog = inject(DialogService) - readonly acmes = toSignal( + readonly authorities = toSignal( this.patch.watch$('serverInfo', 'network', 'acme').pipe( - map(acme => - Object.keys(acme).map(url => ({ + map(acme => [ + { + url: null, + name: toAuthorityName(null), + contact: null, + }, + ...Object.keys(acme).map(url => ({ url, - name: toAcmeName(url), + name: toAuthorityName(url), contact: acme[url]?.contact.map(mailto => mailto.replace('mailto:', '')) || - [], + null, })), - ), + ]), ), ) - async add(acmes: ACMEInfo[]) { - const availableAcme = knownACME.filter( - acme => !acmes.map(a => a.url).includes(acme.url), + async add(authorities: Authority[]) { + const availableAuthorities = knownAuthorities.filter( + ca => !authorities.map(a => a.url).includes(ca.url), ) const addSpec = ISB.InputSpec.of({ provider: ISB.Value.union({ name: 'Provider', - default: (availableAcme[0]?.url as any) || 'other', + default: (availableAuthorities[0]?.url as any) || 'other', variants: ISB.Variants.of({ - ...availableAcme.reduce( + ...availableAuthorities.reduce( (obj, curr) => ({ ...obj, [curr.url]: { @@ -85,7 +91,7 @@ export class AcmeService { }) this.formDialog.open(FormComponent, { - label: 'Add ACME Provider', + label: 'Add Certificate Authority', data: { spec: await configBuilderToSpec(addSpec), buttons: [ @@ -105,13 +111,13 @@ export class AcmeService { }) } - async edit({ url, contact }: ACMEInfo) { + async edit({ url, contact }: RemoteAuthority) { const editSpec = ISB.InputSpec.of({ contact: this.emailListSpec(), }) this.formDialog.open(FormComponent, { - label: 'Edit ACME Provider', + label: 'Edit Contact Info', data: { spec: await configBuilderToSpec(editSpec), buttons: [ @@ -126,7 +132,7 @@ export class AcmeService { }) } - remove({ url }: ACMEInfo) { + remove({ url }: RemoteAuthority) { this.dialog .openConfirm({ label: 'Are you sure?', size: 's' }) .pipe(filter(Boolean)) diff --git a/web/projects/ui/src/app/routes/portal/routes/system/routes/domains/authorities/item.component.ts b/web/projects/ui/src/app/routes/portal/routes/system/routes/domains/authorities/item.component.ts new file mode 100644 index 000000000..c1aa0ab7b --- /dev/null +++ b/web/projects/ui/src/app/routes/portal/routes/system/routes/domains/authorities/item.component.ts @@ -0,0 +1,99 @@ +import { + ChangeDetectionStrategy, + Component, + inject, + input, +} from '@angular/core' +import { i18nPipe } from '@start9labs/shared' +import { + TuiButton, + TuiDataList, + TuiDropdown, + TuiTextfield, +} from '@taiga-ui/core' +import { Authority, AuthorityService } from './authority.service' + +@Component({ + selector: 'tr[authority]', + template: ` + @if (authority(); as authority) { + {{ authority.name }} + {{ authority.url || '-' }} + {{ authority.contact ? authority.contact.join(', ') : '-' }} + + + + + + + } @else { + +
    + {{ 'Download your Root CA' | i18n }} + + + } + + + + } + `, + styles: ` + td:last-child { + grid-area: 1 / 2 / 3; + align-self: center; + text-align: right; + } + + :host-context(tui-root._mobile) { + grid-template-columns: 1fr min-content; + + td:first-child { + font: var(--tui-font-text-m); + font-weight: bold; + } + } + `, + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [TuiButton, i18nPipe, TuiDropdown, TuiDataList, TuiTextfield], +}) +export class AuthorityItemComponent { + protected readonly service = inject(AuthorityService) + + readonly authority = input.required() + + open = false +} diff --git a/web/projects/ui/src/app/routes/portal/routes/system/routes/domains/authorities/table.component.ts b/web/projects/ui/src/app/routes/portal/routes/system/routes/domains/authorities/table.component.ts new file mode 100644 index 000000000..a1e8cbae2 --- /dev/null +++ b/web/projects/ui/src/app/routes/portal/routes/system/routes/domains/authorities/table.component.ts @@ -0,0 +1,26 @@ +import { ChangeDetectionStrategy, Component, inject } from '@angular/core' +import { i18nPipe } from '@start9labs/shared' +import { TuiSkeleton } from '@taiga-ui/kit' +import { TableComponent } from 'src/app/routes/portal/components/table.component' +import { AuthorityItemComponent } from './item.component' +import { AuthorityService } from './authority.service' + +@Component({ + selector: 'authorities-table', + template: ` + + @for (authority of authorityService.authorities(); track $index) { + + } @empty { + + } +
    +
    {{ 'Loading' | i18n }}
    +
    + `, + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [TuiSkeleton, i18nPipe, TableComponent, AuthorityItemComponent], +}) +export class AuthoritiesTableComponent { + protected readonly authorityService = inject(AuthorityService) +} 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 index c31c24640..61f2c208e 100644 --- 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 @@ -4,10 +4,10 @@ import { DocsLinkDirective, i18nPipe } from '@start9labs/shared' import { TuiButton, TuiLink, TuiTitle } from '@taiga-ui/core' import { TuiHeader } from '@taiga-ui/layout' import { TitleDirective } from 'src/app/services/title.service' -import { AcmeService } from './acme/acme.service' -import { DomainsService } from './domains/domains.service' +import { AuthorityService } from './authorities/authority.service' +import { DomainService } from './domains/domain.service' import { DomainsTableComponent } from './domains/table.component' -import { AcmeTableComponent } from './acme/table.component' +import { AuthoritiesTableComponent } from './authorities/table.component' @Component({ template: ` @@ -21,9 +21,9 @@ import { AcmeTableComponent } from './acme/table.component'

    {{ 'Domains' | i18n }}

    - {{ 'Adding a domain to StartOS means you can use it and its subdomains to host service interfaces on the public Internet.' + | i18n }}

    - {{ 'ACME Providers' | i18n }} - @if (acmeService.acmes(); as acmes) { + {{ 'Certificate Authorities' | i18n }} + @if (authorityService.authorities(); as authorities) { }
    - +
    {{ 'Domains' | i18n }} - @if (domainsService.data(); as value) { + @if (domainService.data(); as value) { }
    - +
    `, changeDetection: ChangeDetectionStrategy.OnPush, @@ -85,11 +85,11 @@ import { AcmeTableComponent } from './acme/table.component' i18nPipe, DocsLinkDirective, DomainsTableComponent, - AcmeTableComponent, + AuthoritiesTableComponent, ], - providers: [AcmeService, DomainsService], + providers: [AuthorityService, DomainService], }) export default class SystemDomainsComponent { - protected readonly acmeService = inject(AcmeService) - protected readonly domainsService = inject(DomainsService) + protected readonly authorityService = inject(AuthorityService) + protected readonly domainService = inject(DomainService) } diff --git a/web/projects/ui/src/app/routes/portal/routes/system/routes/domains/domains/domains.service.ts b/web/projects/ui/src/app/routes/portal/routes/system/routes/domains/domains/domain.service.ts similarity index 81% rename from web/projects/ui/src/app/routes/portal/routes/system/routes/domains/domains/domains.service.ts rename to web/projects/ui/src/app/routes/portal/routes/system/routes/domains/domains/domain.service.ts index acdd391c2..59f6a3ec1 100644 --- a/web/projects/ui/src/app/routes/portal/routes/system/routes/domains/domains/domains.service.ts +++ b/web/projects/ui/src/app/routes/portal/routes/system/routes/domains/domains/domain.service.ts @@ -14,12 +14,12 @@ 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 { toAcmeName } from 'src/app/utils/acme' +import { toAuthorityName } from 'src/app/utils/acme' // @TODO translations @Injectable() -export class DomainsService { +export class DomainService { private readonly patch = inject>(PatchDB) private readonly loader = inject(LoadingService) private readonly errorService = inject(ErrorService) @@ -46,32 +46,32 @@ export class DomainsService { { domain: 'blog.mydomain.com', gateway: { - id: '', + id: 'wireguard1', name: 'StartTunnel', }, - acme: { - url: '', - name: `Lert's Encrypt`, + authority: { + url: 'https://acme-v02.api.letsencrypt.org/directory', + name: `Let's Encrypt`, }, }, { domain: 'store.mydomain.com', gateway: { - id: '', + id: 'eth0', name: 'Ethernet', }, - acme: { - url: null, - name: 'System', + authority: { + url: 'local', + name: toAuthorityName(null), }, }, ], - acme: Object.keys(network.acme).reduce>( + authorities: Object.keys(network.acme).reduce>( (obj, url) => ({ ...obj, - [url]: toAcmeName(url), + [url]: toAuthorityName(url), }), - { none: 'None (use system Root CA)' }, + { local: toAuthorityName(null) }, ), } }), @@ -88,7 +88,7 @@ export class DomainsService { default: null, patterns: [utils.Patterns.domain], }), - ...this.gatewaysAndAcme(), + ...this.gatewaysAndAuthorities(), }) this.formDialog.open(FormComponent, { @@ -107,11 +107,11 @@ export class DomainsService { async edit(domain: any) { const editSpec = ISB.InputSpec.of({ - ...this.gatewaysAndAcme(), + ...this.gatewaysAndAuthorities(), }) this.formDialog.open(FormComponent, { - label: 'Edit Domain' as any, // @TODO translation + label: 'Edit Domain', data: { spec: await configBuilderToSpec(editSpec), buttons: [ @@ -126,7 +126,7 @@ export class DomainsService { ], value: { gateway: domain.gateway.id, - acme: domain.acme.url, + authority: domain.authority.url, }, }, }) @@ -172,7 +172,7 @@ export class DomainsService { } } - private gatewaysAndAcme() { + private gatewaysAndAuthorities() { return { gateway: ISB.Value.select({ name: 'Gateway', @@ -181,11 +181,11 @@ export class DomainsService { values: this.data()!.gateways, default: '', }), - acme: ISB.Value.select({ - name: 'Default ACME', + authority: ISB.Value.select({ + name: 'Default Certificate Authority', description: - 'Select the default ACME provider for this domain. This can be overridden on a case-by-case basis.', - values: this.data()!.acme, + 'Select the default certificate authority that will sign certificates for this domain. You can override this on a case-by-case basis.', + values: this.data()!.authorities, default: '', }), } diff --git a/web/projects/ui/src/app/routes/portal/routes/system/routes/domains/domains/item.component.ts b/web/projects/ui/src/app/routes/portal/routes/system/routes/domains/domains/item.component.ts index ad035b910..8694a0cf5 100644 --- a/web/projects/ui/src/app/routes/portal/routes/system/routes/domains/domains/item.component.ts +++ b/web/projects/ui/src/app/routes/portal/routes/system/routes/domains/domains/item.component.ts @@ -11,66 +11,68 @@ import { TuiDropdown, TuiTextfield, } from '@taiga-ui/core' -import { DomainsService } from './domains.service' +import { DomainService } from './domain.service' @Component({ selector: 'tr[domain]', template: ` - {{ domain().domain }} - {{ domain().gateway.name }} - {{ domain().acme.name }} - - - - - - - - - - - + @if (domain(); as domain) { + {{ domain.domain }} + {{ domain.gateway.name }} + {{ domain.authority.name }} + + + + + + + + + + + + } `, styles: ` td:last-child { @@ -91,8 +93,8 @@ import { DomainsService } from './domains.service' changeDetection: ChangeDetectionStrategy.OnPush, imports: [TuiButton, i18nPipe, TuiDropdown, TuiDataList, TuiTextfield], }) -export class DomainsItemComponent { - protected readonly domainsService = inject(DomainsService) +export class DomainItemComponent { + protected readonly domainService = inject(DomainService) readonly domain = input.required() diff --git a/web/projects/ui/src/app/routes/portal/routes/system/routes/domains/domains/table.component.ts b/web/projects/ui/src/app/routes/portal/routes/system/routes/domains/domains/table.component.ts index b82485ebf..3be3ca335 100644 --- a/web/projects/ui/src/app/routes/portal/routes/system/routes/domains/domains/table.component.ts +++ b/web/projects/ui/src/app/routes/portal/routes/system/routes/domains/domains/table.component.ts @@ -1,20 +1,23 @@ -import { ChangeDetectionStrategy, Component, input } from '@angular/core' +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 { DomainsItemComponent } from './item.component' +import { DomainItemComponent } from './item.component' +import { DomainService } from './domain.service' @Component({ selector: 'domains-table', template: ` - - @for (domain of domains(); track $index) { +
    + @for (domain of domainService.data()?.domains; track $index) { } @empty {
    - @if (domains()) { + @if (domainService.data()?.domains) { {{ 'No domains' | i18n }} @@ -32,10 +35,9 @@ import { DomainsItemComponent } from './item.component' i18nPipe, TableComponent, PlaceholderComponent, - DomainsItemComponent, + DomainItemComponent, ], }) export class DomainsTableComponent { - // @TODO Alex proper types - readonly domains = input() + protected readonly domainService = inject(DomainService) } diff --git a/web/projects/ui/src/app/routes/portal/routes/system/routes/sessions/table.component.ts b/web/projects/ui/src/app/routes/portal/routes/system/routes/sessions/table.component.ts index d33a5a98f..3a68683b4 100644 --- a/web/projects/ui/src/app/routes/portal/routes/system/routes/sessions/table.component.ts +++ b/web/projects/ui/src/app/routes/portal/routes/system/routes/sessions/table.component.ts @@ -12,7 +12,6 @@ import { TuiIcon } from '@taiga-ui/core' import { TuiCheckbox, TuiFade, TuiSkeleton } from '@taiga-ui/kit' import { TableComponent } from 'src/app/routes/portal/components/table.component' import { Session } from 'src/app/services/api/api.types' -import { toAcmeName } from 'src/app/utils/acme' import { PlatformInfoPipe } from './platform-info.pipe' import { i18nPipe } from '@start9labs/shared' @@ -182,6 +181,4 @@ export class SessionsTableComponent implements OnChanges { this.selected.update(selected => [...selected, session]) } } - - protected readonly toAcmeName = toAcmeName } diff --git a/web/projects/ui/src/app/routes/portal/routes/system/system.const.ts b/web/projects/ui/src/app/routes/portal/routes/system/system.const.ts index 07a3485b2..2571c49e6 100644 --- a/web/projects/ui/src/app/routes/portal/routes/system/system.const.ts +++ b/web/projects/ui/src/app/routes/portal/routes/system/system.const.ts @@ -38,16 +38,16 @@ export const SYSTEM_MENU = [ }, ], [ - { - icon: '@tui.globe', - item: 'Domains', - link: 'domains', - }, { icon: '@tui.door-open', item: 'Gateways', link: 'gateways', }, + { + icon: '@tui.globe', + item: 'Domains', + link: 'domains', + }, ], [ { diff --git a/web/projects/ui/src/app/services/api/embassy-mock-api.service.ts b/web/projects/ui/src/app/services/api/embassy-mock-api.service.ts index 60f6d9478..07c0a8d9b 100644 --- a/web/projects/ui/src/app/services/api/embassy-mock-api.service.ts +++ b/web/projects/ui/src/app/services/api/embassy-mock-api.service.ts @@ -24,7 +24,7 @@ import { AuthService } from '../auth.service' import { T } from '@start9labs/start-sdk' import { MarketplacePkg } from '@start9labs/marketplace' import { WebSocketSubject } from 'rxjs/webSocket' -import { toAcmeUrl } from 'src/app/utils/acme' +import { toAuthorityUrl } from 'src/app/utils/acme' import markdown from './md-sample.md' @@ -1396,7 +1396,7 @@ export class MockApiService extends ApiService { op: PatchOp.ADD, path: `/serverInfo/acme`, value: { - [toAcmeUrl(params.provider)]: { contact: params.contact }, + [toAuthorityUrl(params.provider)]: { contact: params.contact }, }, }, ] diff --git a/web/projects/ui/src/app/services/api/mock-patch.ts b/web/projects/ui/src/app/services/api/mock-patch.ts index 40937ad49..a9568e7cb 100644 --- a/web/projects/ui/src/app/services/api/mock-patch.ts +++ b/web/projects/ui/src/app/services/api/mock-patch.ts @@ -1,6 +1,6 @@ import { DataModel } from 'src/app/services/patch-db/data-model' import { Mock } from './api.fixures' -import { knownACME } from 'src/app/utils/acme' +import { knownAuthorities } from 'src/app/utils/acme' const version = require('../../../../../../package.json').version export const mockPatchData: DataModel = { @@ -28,7 +28,7 @@ export const mockPatchData: DataModel = { lastRegion: null, }, acme: { - [knownACME[0].url]: { + [knownAuthorities[0].url]: { contact: ['mailto:support@start9.com'], }, }, @@ -160,6 +160,20 @@ export const mockPatchData: DataModel = { ntpServers: [], }, }, + wireguard1: { + public: false, + ipInfo: { + name: 'StartTunnel', + scopeId: 2, + deviceType: 'wireguard', + subnets: [ + '10.0.90.12/24', + 'fe80::cd00:0000:0cde:1257:0000:211e:72cd/64', + ], + wanIp: '203.0.113.45', + ntpServers: [], + }, + }, }, }, unreadNotificationCount: 4, diff --git a/web/projects/ui/src/app/utils/acme.ts b/web/projects/ui/src/app/utils/acme.ts index c3a2dee4b..c18d35b86 100644 --- a/web/projects/ui/src/app/utils/acme.ts +++ b/web/projects/ui/src/app/utils/acme.ts @@ -1,12 +1,14 @@ -export function toAcmeName(url: string | null): string | 'System CA' { - return knownACME.find(acme => acme.url === url)?.name || url || 'System CA' +export function toAuthorityName(url: string | null): string | 'Local Root CA' { + return ( + knownAuthorities.find(ca => ca.url === url)?.name || url || 'Local Root CA' + ) } -export function toAcmeUrl(name: string): string { - return knownACME.find(acme => acme.name === name)?.url || name +export function toAuthorityUrl(name: string): string { + return knownAuthorities.find(ca => ca.name === name)?.url || name } -export const knownACME = [ +export const knownAuthorities = [ { name: `Let's Encrypt`, url: 'https://acme-v02.api.letsencrypt.org/directory',