From daf584b33ee4babdbc8b523417793d243e853435 Mon Sep 17 00:00:00 2001 From: Matt Hill Date: Wed, 30 Jul 2025 15:33:13 -0600 Subject: [PATCH] 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: [], }, },