diff --git a/web/projects/ui/src/app/components/interface-info/interface-info.component.html b/web/projects/ui/src/app/components/interface-info/interface-info.component.html deleted file mode 100644 index 66d773063..000000000 --- a/web/projects/ui/src/app/components/interface-info/interface-info.component.html +++ /dev/null @@ -1,62 +0,0 @@ - - - -

{{ iFace.name }}

-

{{ iFace.description }}

- - Add Domain - - - Make {{ iFace.public ? 'Private' : 'Public' }} - -
-
-
- - -

{{ address.name }}

-

{{ address.url }}

- - Remove - - - Remove - -
- - - - - - - - - - - - - - -
-
diff --git a/web/projects/ui/src/app/components/interface-info/interface-info.component.scss b/web/projects/ui/src/app/components/interface-info/interface-info.component.scss deleted file mode 100644 index 61ead3b94..000000000 --- a/web/projects/ui/src/app/components/interface-info/interface-info.component.scss +++ /dev/null @@ -1,3 +0,0 @@ -p { - font-family: 'Courier New'; -} \ No newline at end of file diff --git a/web/projects/ui/src/app/components/interface-info/interface-info.component.ts b/web/projects/ui/src/app/components/interface-info/interface-info.component.ts deleted file mode 100644 index 932b1f767..000000000 --- a/web/projects/ui/src/app/components/interface-info/interface-info.component.ts +++ /dev/null @@ -1,393 +0,0 @@ -import { Component, Inject, Input } from '@angular/core' -import { WINDOW } from '@ng-web-apis/common' -import { - AlertController, - ModalController, - ToastController, -} from '@ionic/angular' -import { - copyToClipboard, - ErrorService, - LoadingService, -} from '@start9labs/shared' -import { DataModel } from 'src/app/services/patch-db/data-model' -import { PatchDB } from 'patch-db-client' -import { QRComponent } from 'src/app/components/qr/qr.component' -import { firstValueFrom } from 'rxjs' -import { ISB, T, utils } from '@start9labs/start-sdk' -import { ApiService } from 'src/app/services/api/embassy-api.service' -import { FormDialogService } from 'src/app/services/form-dialog.service' -import { FormComponent } from 'src/app/components/form.component' -import { configBuilderToSpec } from 'src/app/util/configBuilderToSpec' -import { toAcmeName } from 'src/app/util/acme' -import { ConfigService } from 'src/app/services/config.service' - -export type MappedInterface = T.ServiceInterface & { - addresses: MappedAddress[] - public: boolean -} -export type MappedAddress = { - name: string - url: string - isDomain: boolean - isOnion: boolean - acme: string | null -} - -@Component({ - selector: 'interface-info', - templateUrl: './interface-info.component.html', - styleUrls: ['./interface-info.component.scss'], -}) -export class InterfaceInfoComponent { - @Input() pkgId?: string - @Input() iFace!: MappedInterface - - constructor( - private readonly toastCtrl: ToastController, - private readonly modalCtrl: ModalController, - private readonly errorService: ErrorService, - private readonly loader: LoadingService, - private readonly api: ApiService, - private readonly formDialog: FormDialogService, - private readonly alertCtrl: AlertController, - private readonly patch: PatchDB, - private readonly config: ConfigService, - @Inject(WINDOW) private readonly windowRef: Window, - ) {} - - launch(url: string): void { - this.windowRef.open(url, '_blank', 'noreferrer') - } - - async togglePublic() { - const loader = this.loader - .open(`Making ${this.iFace.public ? 'private' : 'public'}`) - .subscribe() - - const params = { - internalPort: this.iFace.addressInfo.internalPort, - public: !this.iFace.public, - } - - try { - if (this.pkgId) { - await this.api.pkgBindingSetPubic({ - ...params, - host: this.iFace.addressInfo.hostId, - package: this.pkgId, - }) - } else { - await this.api.serverBindingSetPubic(params) - } - } catch (e: any) { - this.errorService.handleError(e) - } finally { - loader.unsubscribe() - } - } - - async presentDomainForm() { - const acme = await firstValueFrom(this.patch.watch$('serverInfo', 'acme')) - - const spec = getDomainSpec(Object.keys(acme)) - - this.formDialog.open(FormComponent, { - label: 'Add Domain', - data: { - spec: await configBuilderToSpec(spec), - buttons: [ - { - text: 'Save', - handler: async (val: typeof spec._TYPE) => { - if (val.type.selection === 'standard') { - return this.saveStandard( - val.type.value.domain, - val.type.value.acme, - ) - } else { - return this.saveTor(val.type.value.key) - } - }, - }, - ], - }, - }) - } - - async removeStandard(url: string) { - const loader = this.loader.open('Removing').subscribe() - - const params = { - domain: new URL(url).hostname, - } - - try { - if (this.pkgId) { - await this.api.pkgRemoveDomain({ - ...params, - package: this.pkgId, - host: this.iFace.addressInfo.hostId, - }) - } else { - await this.api.serverRemoveDomain(params) - } - return true - } catch (e: any) { - this.errorService.handleError(e) - return false - } finally { - loader.unsubscribe() - } - } - - async removeOnion(url: string) { - const loader = this.loader.open('Removing').subscribe() - - const params = { - onion: new URL(url).hostname, - } - - try { - if (this.pkgId) { - await this.api.pkgRemoveOnion({ - ...params, - package: this.pkgId, - host: this.iFace.addressInfo.hostId, - }) - } else { - await this.api.serverRemoveOnion(params) - } - return true - } catch (e: any) { - this.errorService.handleError(e) - return false - } finally { - loader.unsubscribe() - } - } - - async showAcme(url: string | null): Promise { - const alert = await this.alertCtrl.create({ - header: 'ACME Provider', - message: toAcmeName(url), - }) - await alert.present() - } - - async showQR(text: string): Promise { - const modal = await this.modalCtrl.create({ - component: QRComponent, - componentProps: { - text, - }, - cssClass: 'qr-modal', - }) - await modal.present() - } - - async copy(address: string): Promise { - let message = '' - await copyToClipboard(address || '').then(success => { - message = success - ? 'Copied to clipboard!' - : 'Failed to copy to clipboard.' - }) - - const toast = await this.toastCtrl.create({ - header: message, - position: 'bottom', - duration: 1000, - }) - await toast.present() - } - - private async saveStandard(domain: string, acme: string) { - const loader = this.loader.open('Saving').subscribe() - - const params = { - domain, - acme: acme === 'none' ? null : acme, - private: false, - } - - try { - if (this.pkgId) { - await this.api.pkgAddDomain({ - ...params, - package: this.pkgId, - host: this.iFace.addressInfo.hostId, - }) - } else { - await this.api.serverAddDomain(params) - } - return true - } catch (e: any) { - this.errorService.handleError(e) - return false - } finally { - loader.unsubscribe() - } - } - - private async saveTor(key: string | null) { - const loader = this.loader.open('Creating onion address').subscribe() - - try { - let onion = key - ? await this.api.addTorKey({ key }) - : await this.api.generateTorKey({}) - onion = `${onion}.onion` - - if (this.pkgId) { - await this.api.pkgAddOnion({ - onion, - package: this.pkgId, - host: this.iFace.addressInfo.hostId, - }) - } else { - await this.api.serverAddOnion({ onion }) - } - return true - } catch (e: any) { - this.errorService.handleError(e) - return false - } finally { - loader.unsubscribe() - } - } -} - -function getDomainSpec(acme: string[]) { - return ISB.InputSpec.of({ - type: ISB.Value.union( - { name: 'Type', default: 'standard' }, - ISB.Variants.of({ - standard: { - name: 'Standard', - spec: ISB.InputSpec.of({ - domain: ISB.Value.text({ - name: 'Domain', - description: 'The domain or subdomain you want to use', - placeholder: `e.g. 'mydomain.com' or 'sub.mydomain.com'`, - required: true, - default: null, - patterns: [utils.Patterns.domain], - }), - acme: ISB.Value.select({ - 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: acme.reduce( - (obj, url) => ({ - ...obj, - [url]: toAcmeName(url), - }), - { none: 'None (use system Root CA)' } as Record, - ), - default: '', - }), - }), - }, - onion: { - name: 'Onion', - spec: ISB.InputSpec.of({ - key: ISB.Value.text({ - name: 'Private Key (optional)', - description: - 'Optionally provide a base64-encoded ed25519 private key for generating the Tor V3 (.onion) address. If not provided, a random key will be generated and used.', - required: false, - default: null, - patterns: [utils.Patterns.base64], - }), - }), - }, - }), - ), - }) -} - -export function getAddresses( - serviceInterface: T.ServiceInterface, - host: T.Host, - config: ConfigService, -): MappedAddress[] { - const addressInfo = serviceInterface.addressInfo - - let hostnames = host.hostnameInfo[addressInfo.internalPort] - - hostnames = hostnames.filter( - h => - config.isLocalhost() || - h.kind !== 'ip' || - h.hostname.kind !== 'ipv6' || - !h.hostname.value.startsWith('fe80::'), - ) - if (config.isLocalhost()) { - const local = hostnames.find( - h => h.kind === 'ip' && h.hostname.kind === 'local', - ) - if (local) { - hostnames.unshift({ - kind: 'ip', - networkInterfaceId: 'lo', - public: false, - hostname: { - kind: 'local', - port: local.hostname.port, - sslPort: local.hostname.sslPort, - value: 'localhost', - }, - }) - } - } - const mappedAddresses = hostnames.flatMap(h => { - let name = '' - let isDomain = false - let isOnion = false - let acme: string | null = null - - if (h.kind === 'onion') { - name = `Tor` - isOnion = true - } else { - const hostnameKind = h.hostname.kind - - if (hostnameKind === 'domain') { - name = 'Domain' - isDomain = true - acme = host.domains[h.hostname.domain]?.acme - } else { - name = - hostnameKind === 'local' - ? 'Local' - : `${h.networkInterfaceId} (${hostnameKind})` - } - } - - const addresses = utils.addressHostToUrl(addressInfo, h) - if (addresses.length > 1) { - return addresses.map(url => ({ - name: `${name} (${new URL(url).protocol - .replace(':', '') - .toUpperCase()})`, - url, - isDomain, - isOnion, - acme, - })) - } else { - return addresses.map(url => ({ - name, - url, - isDomain, - isOnion, - acme, - })) - } - }) - - return mappedAddresses.filter( - (value, index, self) => index === self.findIndex(t => t.url === value.url), - ) -} diff --git a/web/projects/ui/src/app/components/interface-info/interface-info.module.ts b/web/projects/ui/src/app/components/interface-info/interface-info.module.ts deleted file mode 100644 index c31a6ae07..000000000 --- a/web/projects/ui/src/app/components/interface-info/interface-info.module.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { NgModule } from '@angular/core' -import { CommonModule } from '@angular/common' -import { IonicModule } from '@ionic/angular' -import { InterfaceInfoComponent } from './interface-info.component' - -@NgModule({ - declarations: [InterfaceInfoComponent], - imports: [CommonModule, IonicModule], - exports: [InterfaceInfoComponent], -}) -export class InterfaceInfoModule {} diff --git a/web/projects/ui/src/app/pages/server-routes/acme/acme.module.ts b/web/projects/ui/src/app/pages/server-routes/acme/acme.module.ts deleted file mode 100644 index f00171f05..000000000 --- a/web/projects/ui/src/app/pages/server-routes/acme/acme.module.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { NgModule } from '@angular/core' -import { CommonModule } from '@angular/common' -import { IonicModule } from '@ionic/angular' -import { RouterModule, Routes } from '@angular/router' -import { ACMEPage } from './acme.page' - -const routes: Routes = [ - { - path: '', - component: ACMEPage, - }, -] - -@NgModule({ - imports: [CommonModule, IonicModule, RouterModule.forChild(routes)], - declarations: [ACMEPage], -}) -export class ACMEPageModule {} diff --git a/web/projects/ui/src/app/pages/server-routes/acme/acme.page.html b/web/projects/ui/src/app/pages/server-routes/acme/acme.page.html deleted file mode 100644 index ab7ad3a01..000000000 --- a/web/projects/ui/src/app/pages/server-routes/acme/acme.page.html +++ /dev/null @@ -1,53 +0,0 @@ - - - - - - ACME - - - - - - - - -

- Register with one or more ACME providers such as Let's Encrypt in - order to generate SSL (https) certificates on-demand for clearnet - hosting - - View instructions - -

-
-
- - Saved Providers - - - - - - Add Provider - - - - - - -

{{ toAcmeName(provider.url) }}

-

Contact: {{ provider.contactString }}

-
- - - - - - - - -
-
-
-
diff --git a/web/projects/ui/src/app/pages/server-routes/acme/acme.page.scss b/web/projects/ui/src/app/pages/server-routes/acme/acme.page.scss deleted file mode 100644 index e69de29bb..000000000 diff --git a/web/projects/ui/src/app/pages/server-routes/email/email.module.ts b/web/projects/ui/src/app/pages/server-routes/email/email.module.ts deleted file mode 100644 index f6b0c735d..000000000 --- a/web/projects/ui/src/app/pages/server-routes/email/email.module.ts +++ /dev/null @@ -1,42 +0,0 @@ -import { NgModule } from '@angular/core' -import { CommonModule } from '@angular/common' -import { Routes, RouterModule } from '@angular/router' -import { TuiInputModule } from '@taiga-ui/kit' -import { - TuiNotificationModule, - TuiTextfieldControllerModule, -} from '@taiga-ui/core' -import { FormsModule, ReactiveFormsModule } from '@angular/forms' -import { EmailPage } from './email.page' -import { FormModule } from 'src/app/components/form/form.module' -import { IonicModule } from '@ionic/angular' -import { TuiErrorModule, TuiModeModule } from '@taiga-ui/core' -import { TuiAppearanceModule, TuiButtonModule } from '@taiga-ui/experimental' - -const routes: Routes = [ - { - path: '', - component: EmailPage, - }, -] - -@NgModule({ - imports: [ - CommonModule, - IonicModule, - RouterModule.forChild(routes), - CommonModule, - FormsModule, - ReactiveFormsModule, - TuiButtonModule, - TuiInputModule, - FormModule, - TuiNotificationModule, - TuiTextfieldControllerModule, - TuiAppearanceModule, - TuiModeModule, - TuiErrorModule, - ], - declarations: [EmailPage], -}) -export class EmailPageModule {} diff --git a/web/projects/ui/src/app/pages/server-routes/email/email.page.html b/web/projects/ui/src/app/pages/server-routes/email/email.page.html deleted file mode 100644 index 5e0e58fa4..000000000 --- a/web/projects/ui/src/app/pages/server-routes/email/email.page.html +++ /dev/null @@ -1,70 +0,0 @@ - - - Email - - - - - - - - - Fill out the form below to connect to an external SMTP server. With your - permission, installed services can use the SMTP server to send emails. To - grant permission to a particular service, visit that service's "Actions" - page. Not all services support sending emails. - - View instructions - - - -
-

SMTP Credentials

- - - -
-
-

Send Test Email

- - To Address - - - -
-
-
diff --git a/web/projects/ui/src/app/pages/server-routes/email/email.page.scss b/web/projects/ui/src/app/pages/server-routes/email/email.page.scss deleted file mode 100644 index b15986fc9..000000000 --- a/web/projects/ui/src/app/pages/server-routes/email/email.page.scss +++ /dev/null @@ -1,9 +0,0 @@ -form { - padding-top: 24px; - margin: auto; - max-width: 30rem; -} - -h3 { - display: flex; -} \ No newline at end of file diff --git a/web/projects/ui/src/app/pages/server-routes/email/email.page.ts b/web/projects/ui/src/app/pages/server-routes/email/email.page.ts deleted file mode 100644 index e52bf32dd..000000000 --- a/web/projects/ui/src/app/pages/server-routes/email/email.page.ts +++ /dev/null @@ -1,83 +0,0 @@ -import { ChangeDetectionStrategy, Component, inject } from '@angular/core' -import { ErrorService, LoadingService } from '@start9labs/shared' -import { IST, inputSpec } from '@start9labs/start-sdk' -import { TuiDialogService } from '@taiga-ui/core' -import { PatchDB } from 'patch-db-client' -import { switchMap, tap } from 'rxjs' -import { ApiService } from 'src/app/services/api/embassy-api.service' -import { FormService } from 'src/app/services/form.service' -import { DataModel } from 'src/app/services/patch-db/data-model' -import { configBuilderToSpec } from 'src/app/util/configBuilderToSpec' - -@Component({ - selector: 'email-page', - templateUrl: './email.page.html', - styleUrls: ['./email.page.scss'], - changeDetection: ChangeDetectionStrategy.OnPush, -}) -export class EmailPage { - private readonly dialogs = inject(TuiDialogService) - private readonly loader = inject(LoadingService) - private readonly errorService = inject(ErrorService) - private readonly formService = inject(FormService) - private readonly patch = inject>(PatchDB) - private readonly api = inject(ApiService) - - isSaved = false - testAddress = '' - - readonly spec: Promise = configBuilderToSpec( - inputSpec.constants.customSmtp, - ) - readonly form$ = this.patch.watch$('serverInfo', 'smtp').pipe( - tap(value => (this.isSaved = !!value)), - switchMap(async value => - this.formService.createForm(await this.spec, value), - ), - ) - - async save( - value: typeof inputSpec.constants.customSmtp._TYPE | null, - ): Promise { - const loader = this.loader.open('Saving...').subscribe() - - try { - if (value) { - await this.api.setSmtp(value) - this.isSaved = true - } else { - await this.api.clearSmtp({}) - this.isSaved = false - } - } catch (e: any) { - this.errorService.handleError(e) - } finally { - loader.unsubscribe() - } - } - - async sendTestEmail(value: typeof inputSpec.constants.customSmtp._TYPE) { - const loader = this.loader.open('Sending email...').subscribe() - - try { - await this.api.testSmtp({ - to: this.testAddress, - ...value, - }) - } catch (e: any) { - return this.errorService.handleError(e) - } finally { - loader.unsubscribe() - } - - this.dialogs - .open( - `A test email has been sent to ${this.testAddress}.

Check your spam folder and mark as not spam`, - { - label: 'Success', - size: 's', - }, - ) - .subscribe() - } -} diff --git a/web/projects/ui/src/app/routes/portal/components/interfaces/interface.component.ts b/web/projects/ui/src/app/routes/portal/components/interfaces/interface.component.ts index adc5172f6..708914c1d 100644 --- a/web/projects/ui/src/app/routes/portal/components/interfaces/interface.component.ts +++ b/web/projects/ui/src/app/routes/portal/components/interfaces/interface.component.ts @@ -104,10 +104,10 @@ export class InterfaceComponent { packageId: string interfaceId: string } - @Input({ required: true }) serviceInterface!: ServiceInterfaceWithAddresses + @Input({ required: true }) serviceInterface!: MappedServiceInterface } -export type ServiceInterfaceWithAddresses = T.ServiceInterface & { +export type MappedServiceInterface = T.ServiceInterface & { addresses: { clearnet: AddressDetails[] local: AddressDetails[] 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 78389ce42..930826c4f 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 @@ -1,6 +1,7 @@ import { ISB, IST, T, utils } from '@start9labs/start-sdk' import { TuiDialogOptions } from '@taiga-ui/core' import { TuiConfirmData } from '@taiga-ui/kit' +import { ConfigService } from 'src/app/services/config.service' import { NetworkInfo } from 'src/app/services/patch-db/data-model' import { configBuilderToSpec } from 'src/app/utils/configBuilderToSpec' @@ -49,44 +50,80 @@ export function getClearnetSpec({ ) } -export type AddressDetails = { - label?: string - url: string -} - -export function getMultihostAddresses( +// @TODO Aiden audit +export function getAddresses( serviceInterface: T.ServiceInterface, host: T.Host, + config: ConfigService, ): { - clearnet: AddressDetails[] + clearnet: (AddressDetails & { acme: string | null })[] local: AddressDetails[] tor: AddressDetails[] } { const addressInfo = serviceInterface.addressInfo - const hostnamesInfo = host.hostnameInfo[addressInfo.internalPort] - const clearnet: AddressDetails[] = [] + let hostnames = host.hostnameInfo[addressInfo.internalPort] + + hostnames = hostnames.filter( + h => + config.isLocalhost() || + h.kind !== 'ip' || + h.hostname.kind !== 'ipv6' || + !h.hostname.value.startsWith('fe80::'), + ) + + if (config.isLocalhost()) { + const local = hostnames.find( + h => h.kind === 'ip' && h.hostname.kind === 'local', + ) + + if (local) { + hostnames.unshift({ + kind: 'ip', + networkInterfaceId: 'lo', + public: false, + hostname: { + kind: 'local', + port: local.hostname.port, + sslPort: local.hostname.sslPort, + value: 'localhost', + }, + }) + } + } + + const clearnet: (AddressDetails & { acme: string | null })[] = [] const local: AddressDetails[] = [] const tor: AddressDetails[] = [] - hostnamesInfo.forEach(hostnameInfo => { - utils.addressHostToUrl(addressInfo, hostnameInfo).forEach(url => { - // Onion - if (hostnameInfo.kind === 'onion') { - tor.push({ url }) - // IP + hostnames.forEach(h => { + const addresses = utils.addressHostToUrl(addressInfo, h) + + addresses.forEach(url => { + if (h.kind === 'onion') { + tor.push({ + label: `Tor${ + addresses.length > 1 + ? ` (${new URL(url).protocol.replace(':', '').toUpperCase()})` + : '' + }`, + url, + }) } else { - // Domain - if (hostnameInfo.hostname.kind === 'domain') { - clearnet.push({ url }) - // Local + const hostnameKind = h.hostname.kind + + if (hostnameKind === 'domain') { + clearnet.push({ + label: 'Domain', + url, + acme: host.domains[h.hostname.domain]?.acme, + }) } else { - const hostnameKind = hostnameInfo.hostname.kind local.push({ label: hostnameKind === 'local' ? 'Local' - : `${hostnameInfo.networkInterfaceId} (${hostnameKind})`, + : `${h.networkInterfaceId} (${hostnameKind})`, url, }) } @@ -99,4 +136,16 @@ export function getMultihostAddresses( local, tor, } + + // @TODO Aiden what was going on here in 036? + // return mappedAddresses.filter( + // (value, index, self) => index === self.findIndex(t => t.url === value.url), + // ) +} + +function getLabel(name: string, url: string, multiple: boolean) {} + +export type AddressDetails = { + label: string + url: string } diff --git a/web/projects/ui/src/app/routes/portal/routes/service/dashboard/ui.component.ts b/web/projects/ui/src/app/routes/portal/routes/service/dashboard/ui.component.ts index 0a03d8e1b..6053ce806 100644 --- a/web/projects/ui/src/app/routes/portal/routes/service/dashboard/ui.component.ts +++ b/web/projects/ui/src/app/routes/portal/routes/service/dashboard/ui.component.ts @@ -79,13 +79,18 @@ export class UILaunchComponent { @tuiPure getInterfaces(pkg?: PackageDataEntry): T.ServiceInterface[] { return pkg - ? Object.values(pkg.serviceInterfaces).filter(({ type }) => type === 'ui') + ? Object.values(pkg.serviceInterfaces).filter( + i => + i.type === 'ui' && + (i.addressInfo.scheme === 'http' || + i.addressInfo.sslScheme === 'https'), + ) : [] } - getHref(info?: T.ServiceInterface): string | null { - return info && this.isRunning - ? this.config.launchableAddress(info, this.pkg.hosts) + getHref(ui?: T.ServiceInterface): string | null { + return ui && this.isRunning + ? this.config.launchableAddress(ui, this.pkg.hosts) : null } } diff --git a/web/projects/ui/src/app/routes/portal/routes/service/routes/interface.component.ts b/web/projects/ui/src/app/routes/portal/routes/service/routes/interface.component.ts index 5c5be1786..99643f5ca 100644 --- a/web/projects/ui/src/app/routes/portal/routes/service/routes/interface.component.ts +++ b/web/projects/ui/src/app/routes/portal/routes/service/routes/interface.component.ts @@ -6,7 +6,8 @@ import { PatchDB } from 'patch-db-client' import { combineLatest, map } from 'rxjs' import { InterfaceComponent } from 'src/app/routes/portal/components/interfaces/interface.component' import { DataModel } from 'src/app/services/patch-db/data-model' -import { getMultihostAddresses } from '../../../components/interfaces/interface.utils' +import { getAddresses } from '../../../components/interfaces/interface.utils' +import { ConfigService } from 'src/app/services/config.service' @Component({ template: ` @@ -22,6 +23,7 @@ import { getMultihostAddresses } from '../../../components/interfaces/interface. }) export class ServiceInterfaceRoute { private readonly patch = inject>(PatchDB) + private readonly config = inject(ConfigService) readonly context = { packageId: getPkgId(), @@ -40,7 +42,11 @@ export class ServiceInterfaceRoute { ]).pipe( map(([iFace, hosts]) => ({ ...iFace, - addresses: getMultihostAddresses(iFace, hosts[iFace.addressInfo.hostId]), + addresses: getAddresses( + iFace, + hosts[iFace.addressInfo.hostId], + this.config, + ), })), ) } diff --git a/web/projects/ui/src/app/routes/portal/routes/system/backups/components/status.component.ts b/web/projects/ui/src/app/routes/portal/routes/system/backups/components/status.component.ts index 0843c38ba..c509b02ed 100644 --- a/web/projects/ui/src/app/routes/portal/routes/system/backups/components/status.component.ts +++ b/web/projects/ui/src/app/routes/portal/routes/system/backups/components/status.component.ts @@ -1,12 +1,5 @@ -import { - ChangeDetectionStrategy, - Component, - inject, - Input, -} from '@angular/core' -import { Exver } from '@start9labs/shared' +import { ChangeDetectionStrategy, Component, Input } from '@angular/core' import { TuiIcon } from '@taiga-ui/core' -import { BackupTarget } from 'src/app/services/api/api.types' import { BackupType } from '../types/backup-type' @Component({ diff --git a/web/projects/ui/src/app/routes/portal/routes/system/backups/components/upcoming.component.ts b/web/projects/ui/src/app/routes/portal/routes/system/backups/components/upcoming.component.ts index 1fecced81..385ee3c5f 100644 --- a/web/projects/ui/src/app/routes/portal/routes/system/backups/components/upcoming.component.ts +++ b/web/projects/ui/src/app/routes/portal/routes/system/backups/components/upcoming.component.ts @@ -102,7 +102,8 @@ export class BackupsUpcomingComponent { readonly targets = toSignal(from(this.api.getBackupTargets({}))) readonly current = toSignal( inject>(PatchDB) - .watch$('serverInfo', 'statusInfo', 'currentBackup', 'job') + // @TODO remove "as any" once this feature is real + .watch$('serverInfo', 'statusInfo', 'currentBackup' as any, 'job') .pipe(map(job => job || {})), ) diff --git a/web/projects/ui/src/app/routes/portal/routes/system/marketplace/components/controls.component.ts b/web/projects/ui/src/app/routes/portal/routes/system/marketplace/components/controls.component.ts index 1291b3aff..8f724246b 100644 --- a/web/projects/ui/src/app/routes/portal/routes/system/marketplace/components/controls.component.ts +++ b/web/projects/ui/src/app/routes/portal/routes/system/marketplace/components/controls.component.ts @@ -22,7 +22,6 @@ import { DataModel, PackageDataEntry, } from 'src/app/services/patch-db/data-model' -import { ClientStorageService } from 'src/app/services/client-storage.service' import { MarketplaceService } from 'src/app/services/marketplace.service' import { hasCurrentDeps } from 'src/app/utils/has-deps' import { getAllPackages, getManifest } from 'src/app/utils/get-package-data' @@ -60,16 +59,14 @@ import { ToManifestPipe } from 'src/app/routes/portal/pipes/to-manifest' } @case (0) { - @if (showDevTools$ | async) { - - } + } } } @@ -114,8 +111,6 @@ export class MarketplaceControlsComponent { @Input() localFlavor!: boolean - readonly showDevTools$ = inject(ClientStorageService).showDevTools$ - async tryInstall() { const currentUrl = await firstValueFrom( this.marketplaceService.getRegistryUrl$(), diff --git a/web/projects/ui/src/app/pages/server-routes/acme/acme.page.ts b/web/projects/ui/src/app/routes/portal/routes/system/settings/routes/acme/acme.component.ts similarity index 81% rename from web/projects/ui/src/app/pages/server-routes/acme/acme.page.ts rename to web/projects/ui/src/app/routes/portal/routes/system/settings/routes/acme/acme.component.ts index 5416194aa..7d017e5c9 100644 --- a/web/projects/ui/src/app/pages/server-routes/acme/acme.page.ts +++ b/web/projects/ui/src/app/routes/portal/routes/system/settings/routes/acme/acme.component.ts @@ -1,21 +1,31 @@ -import { Component } from '@angular/core' +import { ChangeDetectionStrategy, Component, inject } from '@angular/core' import { ErrorService, LoadingService } from '@start9labs/shared' import { PatchDB } from 'patch-db-client' import { ApiService } from 'src/app/services/api/embassy-api.service' -import { DataModel } from '../../../services/patch-db/data-model' -import { FormDialogService } from '../../../services/form-dialog.service' -import { FormComponent } from '../../../components/form.component' -import { configBuilderToSpec } from '../../../util/configBuilderToSpec' import { ISB, utils } from '@start9labs/start-sdk' -import { knownACME, toAcmeName } from 'src/app/util/acme' +import { knownACME, toAcmeName } from 'src/app/utils/acme' import { map } from 'rxjs' +import { CommonModule } from '@angular/common' +import { DataModel } from 'src/app/services/patch-db/data-model' +import { configBuilderToSpec } from 'src/app/utils/configBuilderToSpec' +import { FormDialogService } from 'src/app/services/form-dialog.service' +import { FormComponent } from 'src/app/routes/portal/components/form.component' @Component({ selector: 'acme', - templateUrl: 'acme.page.html', - styleUrls: ['acme.page.scss'], + template: ``, + styles: [], + changeDetection: ChangeDetectionStrategy.OnPush, + standalone: true, + imports: [CommonModule], }) -export class ACMEPage { +export class SettingsACMEComponent { + 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) + readonly docsUrl = 'https://docs.start9.com/0.3.6/user-manual/acme' acme$ = this.patch.watch$('serverInfo', 'acme').pipe( @@ -36,14 +46,6 @@ export class ACMEPage { toAcmeName = toAcmeName - constructor( - private readonly loader: LoadingService, - private readonly errorService: ErrorService, - private readonly api: ApiService, - private readonly patch: PatchDB, - private readonly formDialog: FormDialogService, - ) {} - async addAcme( providers: { url: string diff --git a/web/projects/ui/src/app/routes/portal/routes/system/settings/routes/email/email.component.ts b/web/projects/ui/src/app/routes/portal/routes/system/settings/routes/email/email.component.ts index 404008d23..7668fbebd 100644 --- a/web/projects/ui/src/app/routes/portal/routes/system/settings/routes/email/email.component.ts +++ b/web/projects/ui/src/app/routes/portal/routes/system/settings/routes/email/email.component.ts @@ -28,6 +28,16 @@ import { EmailInfoComponent } from './info.component' *ngIf="spec | async as resolved" [spec]="resolved" > + - - - - -

- You are currently connected over Tor. If you reset the Tor daemon, you - will lose connectivity until it comes back online. -

-

Reset Tor?

-

- Optionally wipe state to forcibly acquire new guard nodes. It is - recommended to try without wiping state first. -

- -
- `, - changeDetection: ChangeDetectionStrategy.OnPush, - standalone: true, - imports: [ - CommonModule, - FormsModule, - TuiTitle, - TuiIcon, - TuiLabel, - TuiCheckbox, - ], -}) -export class SettingsExperimentalComponent { - private readonly loader = inject(LoadingService) - private readonly errorService = inject(ErrorService) - private readonly api = inject(ApiService) - private readonly dialogs = inject(TuiDialogService) - private readonly alerts = inject(TuiAlertService) - - readonly server$ = inject>(PatchDB).watch$('server-info') - readonly isTor = inject(ConfigService).isTor() - - wipe = false - - reset(content: TemplateRef) { - this.wipe = false - this.dialogs - .open(TUI_CONFIRM, { - label: this.isTor ? 'Warning' : 'Confirm', - data: { - content, - yes: 'Reset', - no: 'Cancel', - }, - }) - .pipe(filter(Boolean)) - .subscribe(() => this.resetTor(this.wipe)) - } - - private async resetTor(wipeState: boolean) { - const loader = this.loader.open('Resetting Tor...').subscribe() - - try { - await this.api.resetTor({ - 'wipe-state': wipeState, - reason: 'User triggered', - }) - this.alerts.open('Tor reset in progress').subscribe() - } catch (e: any) { - this.errorService.handleError(e) - } finally { - loader.unsubscribe() - } - } -} diff --git a/web/projects/ui/src/app/routes/portal/routes/system/settings/routes/interfaces/ui.component.ts b/web/projects/ui/src/app/routes/portal/routes/system/settings/routes/interfaces/ui.component.ts index 188c1118c..f3e913ad0 100644 --- a/web/projects/ui/src/app/routes/portal/routes/system/settings/routes/interfaces/ui.component.ts +++ b/web/projects/ui/src/app/routes/portal/routes/system/settings/routes/interfaces/ui.component.ts @@ -5,11 +5,29 @@ import { PatchDB } from 'patch-db-client' import { Observable, map } from 'rxjs' import { InterfaceComponent, - ServiceInterfaceWithAddresses, + MappedServiceInterface, } from 'src/app/routes/portal/components/interfaces/interface.component' -import { getMultihostAddresses } from 'src/app/routes/portal/components/interfaces/interface.utils' +import { getAddresses } from 'src/app/routes/portal/components/interfaces/interface.utils' +import { ConfigService } from 'src/app/services/config.service' import { DataModel } from 'src/app/services/patch-db/data-model' +const iface: T.ServiceInterface = { + id: '', + name: 'StartOS User Interface', + description: + 'The primary user interface for your StartOS server, accessible from any browser.', + type: 'ui' as const, + masked: false, + addressInfo: { + hostId: '', + internalPort: 80, + scheme: 'http', + sslScheme: 'https', + suffix: '', + username: null, + }, +} + @Component({ template: ` = inject( - PatchDB, + private readonly config = inject(ConfigService) + + readonly ui$: Observable = inject>( + PatchDB, ) - .watch$('serverInfo', 'ui') + .watch$('serverInfo') .pipe( - map(hosts => { - const serviceInterface: T.ServiceInterface = { - id: 'startos-ui', - name: 'StartOS UI', - description: 'The primary web user interface for StartOS', - type: 'ui', - hasPrimary: false, - masked: false, - addressInfo: { - hostId: '', - username: null, - internalPort: 80, - scheme: 'http', - sslScheme: 'https', - suffix: '', - }, - } - - // @TODO Aiden confirm this is correct - const host: T.Host = { - kind: 'multi', - bindings: {}, - hostnameInfo: { - 80: hosts, - }, - addresses: [], - } - - return { - ...serviceInterface, - addresses: getMultihostAddresses(serviceInterface, host), - } - }), + map(server => ({ + ...iface, + public: server.host.bindings[iface.addressInfo.internalPort].net.public, + addresses: getAddresses(iface, server.host, this.config), + })), ) } diff --git a/web/projects/ui/src/app/routes/portal/routes/system/settings/routes/router/router.component.ts b/web/projects/ui/src/app/routes/portal/routes/system/settings/routes/router/router.component.ts index bd365966b..a6f21ab00 100644 --- a/web/projects/ui/src/app/routes/portal/routes/system/settings/routes/router/router.component.ts +++ b/web/projects/ui/src/app/routes/portal/routes/system/settings/routes/router/router.component.ts @@ -12,7 +12,7 @@ import { RouterPortComponent } from './table.component' + import('./routes/acme/acme.component').then( + m => m.SettingsACMEComponent, + ), + }, { path: 'email', loadComponent: () => diff --git a/web/projects/ui/src/app/routes/portal/routes/system/settings/settings.service.ts b/web/projects/ui/src/app/routes/portal/routes/system/settings/settings.service.ts index 32cf7e5fd..19ea7f45e 100644 --- a/web/projects/ui/src/app/routes/portal/routes/system/settings/settings.service.ts +++ b/web/projects/ui/src/app/routes/portal/routes/system/settings/settings.service.ts @@ -107,27 +107,6 @@ export class SettingsService { icon: '@tui.monitor', routerLink: 'ui', }, - { - title: 'Restart', - icon: '@tui.refresh-cw', - description: 'Restart Start OS server', - action: () => this.promptPower('Restart'), - }, - { - title: 'Shutdown', - icon: '@tui.power', - description: 'Turn Start OS server off', - action: () => this.promptPower('Shutdown'), - }, - { - title: 'Logout', - icon: '@tui.log-out', - description: 'Log off from Start OS', - action: () => { - this.api.logout({}).catch(e => console.error('Failed to log out', e)) - this.auth.setUnverified() - }, - }, ], 'Privacy and Security': [ { @@ -150,6 +129,29 @@ export class SettingsService { routerLink: 'sessions', }, ], + Power: [ + { + title: 'Restart', + icon: '@tui.refresh-cw', + description: 'Restart Start OS server', + action: () => this.promptPower('Restart'), + }, + { + title: 'Shutdown', + icon: '@tui.power', + description: 'Turn Start OS server off', + action: () => this.promptPower('Shutdown'), + }, + { + title: 'Logout', + icon: '@tui.log-out', + description: 'Log off from Start OS', + action: () => { + this.api.logout({}).catch(e => console.error('Failed to log out', e)) + this.auth.setUnverified() + }, + }, + ], } private async setOutboundProxy(): Promise { diff --git a/web/projects/ui/src/app/routing.module.ts b/web/projects/ui/src/app/routing.module.ts index 0c6c0c89a..4432ac14d 100644 --- a/web/projects/ui/src/app/routing.module.ts +++ b/web/projects/ui/src/app/routing.module.ts @@ -17,7 +17,7 @@ const routes: Routes = [ }, { path: 'login', - canActivate: [UnauthGuard], + canActivate: [UnauthGuard, stateNot(['error', 'initializing'])], loadChildren: () => import('./routes/login/login.module').then(m => m.LoginPageModule), }, 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 ec0c3c65f..70d34d5b9 100644 --- a/web/projects/ui/src/app/services/api/mock-patch.ts +++ b/web/projects/ui/src/app/services/api/mock-patch.ts @@ -7,7 +7,6 @@ export const mockPatchData: DataModel = { ui: { name: `Matt's Server`, theme: 'Dark', - desktop: ['lnd'], marketplace: { selectedUrl: 'https://registry.start9.com/', knownHosts: { @@ -32,6 +31,23 @@ export const mockPatchData: DataModel = { id: 'abcdefgh', version, lastBackup: new Date(new Date().valueOf() - 604800001).toISOString(), + network: { + wifi: { + enabled: false, + interface: 'wlan0', + ssids: [], + selected: null, + lastRegion: null, + }, + start9ToSubdomain: null, + domains: [], + wanConfig: { + upnp: true, + forwards: [], + }, + proxies: [], + outboundProxy: null, + }, networkInterfaces: { eth0: { public: false, @@ -72,11 +88,12 @@ export const mockPatchData: DataModel = { packageVersionCompat: '>=0.3.0 <=0.3.6', postInitMigrationTodos: [], statusInfo: { - currentBackup: null, + // currentBackup: null, updated: false, updateProgress: null, restarting: false, shuttingDown: false, + backupProgress: {}, }, hostname: 'random-words', host: { @@ -194,9 +211,8 @@ export const mockPatchData: DataModel = { platform: 'x86_64-nonfree', zram: true, governor: 'performance', - passwordHash: - '$argon2d$v=19$m=1024,t=1,p=1$YXNkZmFzZGZhc2RmYXNkZg$Ceev1I901G6UwU+hY0sHrFZ56D+o+LNJ', - arch: 'x86_64', + ram: 8 * 1024 * 1024 * 1024, + devices: [], }, packageData: { bitcoind: { diff --git a/web/projects/ui/src/app/services/config.service.ts b/web/projects/ui/src/app/services/config.service.ts index 35509f6f1..b4de3405f 100644 --- a/web/projects/ui/src/app/services/config.service.ts +++ b/web/projects/ui/src/app/services/config.service.ts @@ -101,19 +101,7 @@ export class ConfigService { } /** ${scheme}://${username}@${host}:${externalPort}${suffix} */ - launchableAddress( - interfaces: PackageDataEntry['serviceInterfaces'], - hosts: T.Hosts, - ): string { - const ui = Object.values(interfaces).find( - i => - i.type === 'ui' && - (i.addressInfo.scheme === 'http' || - i.addressInfo.sslScheme === 'https'), - ) - - if (!ui) return '' - + launchableAddress(ui: T.ServiceInterface, hosts: T.Hosts): string { const host = hosts[ui.addressInfo.hostId] if (!host) return '' diff --git a/web/projects/ui/src/app/services/eos.service.ts b/web/projects/ui/src/app/services/eos.service.ts index c3331d1a5..7780261fd 100644 --- a/web/projects/ui/src/app/services/eos.service.ts +++ b/web/projects/ui/src/app/services/eos.service.ts @@ -20,7 +20,7 @@ export class EOSService { ) readonly backingUp$ = this.patch - .watch$('serverInfo', 'statusInfo', 'currentBackup') + .watch$('serverInfo', 'statusInfo', 'backupProgress') .pipe( map(obj => !!obj), distinctUntilChanged(), diff --git a/web/projects/ui/src/app/services/marketplace.service.ts b/web/projects/ui/src/app/services/marketplace.service.ts index 3307090c1..b760c50a7 100644 --- a/web/projects/ui/src/app/services/marketplace.service.ts +++ b/web/projects/ui/src/app/services/marketplace.service.ts @@ -19,7 +19,6 @@ import { switchMap, distinctUntilChanged, ReplaySubject, - tap, } from 'rxjs' import { RR } from 'src/app/services/api/api.types' import { ApiService } from 'src/app/services/api/embassy-api.service' diff --git a/web/projects/ui/src/app/services/patch-db/data-model.ts b/web/projects/ui/src/app/services/patch-db/data-model.ts index ec94381ac..678d2448d 100644 --- a/web/projects/ui/src/app/services/patch-db/data-model.ts +++ b/web/projects/ui/src/app/services/patch-db/data-model.ts @@ -3,7 +3,16 @@ import { T } from '@start9labs/start-sdk' export type DataModel = { ui: UIData - serverInfo: ServerInfo + serverInfo: Omit< + T.Public['serverInfo'], + 'wifi' | 'unreadNotificationCount' + > & { + network: NetworkInfo + unreadNotifications: { + count: number + recent: ServerNotifications + } + } packageData: Record } @@ -17,7 +26,6 @@ export type UIData = { } ackInstructions: Record theme: string - desktop: readonly string[] } export type UIMarketplaceData = { @@ -33,30 +41,6 @@ export type UIStore = { name?: string } -export type ServerInfo = { - id: string - version: string - country: string - ui: T.HostnameInfo[] - network: NetworkInfo - lastBackup: string | null - unreadNotifications: { - count: number - recent: ServerNotifications - } - statusInfo: ServerStatusInfo - eosVersionCompat: string - pubkey: string - caFingerprint: string - ntpSynced: boolean - smtp: T.SmtpValue | null - passwordHash: string - platform: string - arch: string - governor: string | null - zram: boolean -} - export type NetworkInfo = { wifi: WiFiInfo start9ToSubdomain: Omit | null diff --git a/web/projects/ui/src/app/utils/get-server-info.ts b/web/projects/ui/src/app/utils/get-server-info.ts index 08eecd451..bf0880f91 100644 --- a/web/projects/ui/src/app/utils/get-server-info.ts +++ b/web/projects/ui/src/app/utils/get-server-info.ts @@ -1,9 +1,9 @@ import { PatchDB } from 'patch-db-client' -import { DataModel, ServerInfo } from 'src/app/services/patch-db/data-model' +import { DataModel } from 'src/app/services/patch-db/data-model' import { firstValueFrom } from 'rxjs' export async function getServerInfo( patch: PatchDB, -): Promise { +): Promise { return firstValueFrom(patch.watch$('serverInfo')) }