diff --git a/web/projects/ui/src/app/apps/portal/components/interfaces/address-group.component.ts b/web/projects/ui/src/app/apps/portal/components/interfaces/address-group.component.ts new file mode 100644 index 000000000..dc423b42e --- /dev/null +++ b/web/projects/ui/src/app/apps/portal/components/interfaces/address-group.component.ts @@ -0,0 +1,55 @@ +import { + ChangeDetectionStrategy, + Component, + Input, + inject, +} from '@angular/core' +import { AddressItemComponent } from './address-item.component' +import { TuiButtonModule } from '@taiga-ui/experimental' +import { AddressDetails, AddressesService } from './interface.utils' + +@Component({ + standalone: true, + selector: 'app-address-group', + template: ` +
+ @if (addresses.length) { + + } + +
+ @for (address of addresses; track $index) { + + } @empty { + + } + `, + imports: [AddressItemComponent, TuiButtonModule], + changeDetection: ChangeDetectionStrategy.OnPush, + styles: ` + .icon-add-btn { + float: right; + margin-left: 2rem; + } + `, +}) +export class AddressGroupComponent { + readonly service = inject(AddressesService) + + @Input({ required: true }) addresses!: AddressDetails[] +} diff --git a/web/projects/ui/src/app/apps/portal/components/interfaces/interface-addresses.component.ts b/web/projects/ui/src/app/apps/portal/components/interfaces/address-item.component.ts similarity index 81% rename from web/projects/ui/src/app/apps/portal/components/interfaces/interface-addresses.component.ts rename to web/projects/ui/src/app/apps/portal/components/interfaces/address-item.component.ts index caf5d6ed1..647d833e8 100644 --- a/web/projects/ui/src/app/apps/portal/components/interfaces/interface-addresses.component.ts +++ b/web/projects/ui/src/app/apps/portal/components/interfaces/address-item.component.ts @@ -17,20 +17,24 @@ import { import { PolymorpheusComponent } from '@tinkoff/ng-polymorpheus' import { QRComponent } from 'src/app/common/qr.component' import { mask } from 'src/app/util/mask' +import { InterfaceComponent } from './interface.component' +import { AddressesService } from './interface.utils' @Component({ standalone: true, - selector: 'app-interface-address', + selector: 'app-address-item', template: `
{{ label }}

- {{ isMasked ? mask : address }} + + {{ interface.serviceInterface.masked ? mask : address }} +

@@ -73,15 +77,16 @@ import { mask } from 'src/app/util/mask' ], changeDetection: ChangeDetectionStrategy.OnPush, }) -export class InterfaceAddressComponent { +export class AddressItemComponent { private readonly window = inject(WINDOW) private readonly dialogs = inject(TuiDialogService) + + readonly service = inject(AddressesService) readonly copyService = inject(CopyService) + readonly interface = inject(InterfaceComponent) @Input() label?: string @Input({ required: true }) address!: string - @Input({ required: true }) isMasked!: boolean - @Input({ required: true }) isUi!: boolean get mask(): string { return mask(this.address, 64) @@ -99,6 +104,4 @@ export class InterfaceAddressComponent { }) .subscribe() } - - destroy() {} } diff --git a/web/projects/ui/src/app/apps/portal/components/interfaces/directives/clearnet.directive.ts b/web/projects/ui/src/app/apps/portal/components/interfaces/directives/clearnet.directive.ts new file mode 100644 index 000000000..4c55fe406 --- /dev/null +++ b/web/projects/ui/src/app/apps/portal/components/interfaces/directives/clearnet.directive.ts @@ -0,0 +1,79 @@ +import { Directive, Input } from '@angular/core' +import { AddressesService } from '../interface.utils' +import { inject } from '@angular/core' +import { ErrorService, LoadingService } from '@start9labs/shared' +import { TuiDialogOptions } from '@taiga-ui/core' +import { + FormComponent, + FormContext, +} from 'src/app/apps/portal/components/form.component' +import { getClearnetSpec } from 'src/app/apps/portal/components/interfaces/interface.utils' +import { ApiService } from 'src/app/services/api/embassy-api.service' +import { FormDialogService } from 'src/app/services/form-dialog.service' +import { NetworkInfo } from 'src/app/services/patch-db/data-model' +import { InterfaceComponent } from '../interface.component' + +type ClearnetForm = { + domain: string + subdomain: string | null +} + +@Directive({ + standalone: true, + selector: '[clearnetAddresses]', + providers: [ + { provide: AddressesService, useExisting: ClearnetAddressesDirective }, + ], +}) +export class ClearnetAddressesDirective implements AddressesService { + private readonly formDialog = inject(FormDialogService) + private readonly loader = inject(LoadingService) + private readonly errorService = inject(ErrorService) + private readonly api = inject(ApiService) + private readonly interface = inject(InterfaceComponent) + + @Input({ required: true }) network!: NetworkInfo + + async add() { + const options: Partial>> = { + label: 'Select Domain/Subdomain', + data: { + spec: await getClearnetSpec(this.network), + buttons: [ + { + text: 'Manage domains', + link: 'portal/system/settings/domains', + }, + { + text: 'Save', + handler: async value => this.save(value), + }, + ], + }, + } + this.formDialog.open(FormComponent, options) + } + + async remove() {} + + private async save(domainInfo: ClearnetForm): Promise { + const loader = this.loader.open('Saving...').subscribe() + + try { + if (this.interface.packageContext) { + await this.api.setInterfaceClearnetAddress({ + ...this.interface.packageContext, + domainInfo, + }) + } else { + await this.api.setServerClearnetAddress({ domainInfo }) + } + return true + } catch (e: any) { + this.errorService.handleError(e) + return false + } finally { + loader.unsubscribe() + } + } +} diff --git a/web/projects/ui/src/app/apps/portal/components/interfaces/directives/local.directive.ts b/web/projects/ui/src/app/apps/portal/components/interfaces/directives/local.directive.ts new file mode 100644 index 000000000..2c8283da1 --- /dev/null +++ b/web/projects/ui/src/app/apps/portal/components/interfaces/directives/local.directive.ts @@ -0,0 +1,14 @@ +import { Directive } from '@angular/core' +import { AddressesService } from '../interface.utils' + +@Directive({ + standalone: true, + selector: '[localAddresses]', + providers: [ + { provide: AddressesService, useExisting: LocalAddressesDirective }, + ], +}) +export class LocalAddressesDirective implements AddressesService { + async add() {} + async remove() {} +} diff --git a/web/projects/ui/src/app/apps/portal/components/interfaces/directives/tor.directive.ts b/web/projects/ui/src/app/apps/portal/components/interfaces/directives/tor.directive.ts new file mode 100644 index 000000000..741f09823 --- /dev/null +++ b/web/projects/ui/src/app/apps/portal/components/interfaces/directives/tor.directive.ts @@ -0,0 +1,14 @@ +import { Directive } from '@angular/core' +import { AddressesService } from '../interface.utils' + +@Directive({ + standalone: true, + selector: '[torAddresses]', + providers: [ + { provide: AddressesService, useExisting: TorAddressesDirective }, + ], +}) +export class TorAddressesDirective implements AddressesService { + async add() {} + async remove() {} +} diff --git a/web/projects/ui/src/app/apps/portal/components/interfaces/interface-clearnet.component.ts b/web/projects/ui/src/app/apps/portal/components/interfaces/interface-clearnet.component.ts deleted file mode 100644 index c60e67323..000000000 --- a/web/projects/ui/src/app/apps/portal/components/interfaces/interface-clearnet.component.ts +++ /dev/null @@ -1,144 +0,0 @@ -import { NgForOf, NgIf } from '@angular/common' -import { - ChangeDetectionStrategy, - Component, - inject, - Input, -} from '@angular/core' -import { ErrorService, LoadingService } from '@start9labs/shared' -import { TuiDialogOptions, TuiDialogService } from '@taiga-ui/core' -import { TuiButtonModule } from '@taiga-ui/experimental' -import { TUI_PROMPT } from '@taiga-ui/kit' -import { filter } from 'rxjs' -import { - FormComponent, - FormContext, -} from 'src/app/apps/portal/components/form.component' -import { - getClearnetSpec, - REMOVE, -} from 'src/app/apps/portal/components/interfaces/interface.utils' -import { ApiService } from 'src/app/services/api/embassy-api.service' -import { FormDialogService } from 'src/app/services/form-dialog.service' -import { NetworkInfo } from 'src/app/services/patch-db/data-model' -import { InterfaceAddressComponent } from './interface-addresses.component' -import { InterfaceComponent } from './interface.component' - -type ClearnetForm = { - domain: string - subdomain: string | null -} - -@Component({ - standalone: true, - selector: 'app-interface-clearnet', - template: ` - - Add clearnet to expose this interface to the public Internet. - - View instructions - - - @for ( - address of interface.serviceInterface.addresses.clearnet; - track $index - ) { - - } @empty { - - } - `, - imports: [NgForOf, InterfaceAddressComponent, NgIf, TuiButtonModule], - changeDetection: ChangeDetectionStrategy.OnPush, -}) -export class InterfaceClearnetComponent { - private readonly formDialog = inject(FormDialogService) - private readonly loader = inject(LoadingService) - private readonly errorService = inject(ErrorService) - private readonly api = inject(ApiService) - private readonly dialogs = inject(TuiDialogService) - readonly interface = inject(InterfaceComponent) - - @Input({ required: true }) network!: NetworkInfo - - async add() { - const options: Partial>> = { - label: 'Select Domain/Subdomain', - data: { - spec: await getClearnetSpec(this.network), - buttons: [ - { - text: 'Manage domains', - link: 'portal/system/settings/domains', - }, - { - text: 'Save', - handler: async value => this.save(value), - }, - ], - }, - } - this.formDialog.open(FormComponent, options) - } - - remove() { - this.dialogs - .open(TUI_PROMPT, REMOVE) - .pipe(filter(Boolean)) - .subscribe(async () => { - const loader = this.loader.open('Removing...').subscribe() - - try { - if (this.interface.packageContext) { - await this.api.setInterfaceClearnetAddress({ - ...this.interface.packageContext, - domainInfo: null, - }) - } else { - await this.api.setServerClearnetAddress({ domainInfo: null }) - } - } catch (e: any) { - this.errorService.handleError(e) - } finally { - loader.unsubscribe() - } - }) - } - - private async save(domainInfo: ClearnetForm): Promise { - const loader = this.loader.open('Saving...').subscribe() - - try { - if (this.interface.packageContext) { - await this.api.setInterfaceClearnetAddress({ - ...this.interface.packageContext, - domainInfo, - }) - } else { - await this.api.setServerClearnetAddress({ domainInfo }) - } - return true - } catch (e: any) { - this.errorService.handleError(e) - return false - } finally { - loader.unsubscribe() - } - } -} diff --git a/web/projects/ui/src/app/apps/portal/components/interfaces/interface-local.component.ts b/web/projects/ui/src/app/apps/portal/components/interfaces/interface-local.component.ts deleted file mode 100644 index ca54cf39e..000000000 --- a/web/projects/ui/src/app/apps/portal/components/interfaces/interface-local.component.ts +++ /dev/null @@ -1,50 +0,0 @@ -import { NgForOf, NgIf } from '@angular/common' -import { ChangeDetectionStrategy, Component, inject } from '@angular/core' -import { TuiButtonModule } from '@taiga-ui/experimental' -import { InterfaceComponent } from './interface.component' -import { InterfaceAddressComponent } from './interface-addresses.component' - -@Component({ - standalone: true, - selector: 'app-interface-local', - template: ` - - Local addresses can only be accessed while connected to the same Local - Area Network (LAN) as your server, either directly or using a VPN. - - View instructions - - - - @for (address of interface.serviceInterface.addresses.local; track $index) { - - } @empty { - - } - `, - imports: [NgForOf, NgIf, InterfaceAddressComponent, TuiButtonModule], - changeDetection: ChangeDetectionStrategy.OnPush, -}) -export class InterfaceLocalComponent { - readonly interface = inject(InterfaceComponent) - - async add() {} - - async remove() {} -} diff --git a/web/projects/ui/src/app/apps/portal/components/interfaces/interface-tor.component.ts b/web/projects/ui/src/app/apps/portal/components/interfaces/interface-tor.component.ts deleted file mode 100644 index e6e3e6d78..000000000 --- a/web/projects/ui/src/app/apps/portal/components/interfaces/interface-tor.component.ts +++ /dev/null @@ -1,50 +0,0 @@ -import { ChangeDetectionStrategy, Component, inject } from '@angular/core' -import { InterfaceAddressComponent } from './interface-addresses.component' -import { InterfaceComponent } from './interface.component' -import { NgForOf, NgIf } from '@angular/common' -import { TuiButtonModule } from '@taiga-ui/experimental' - -@Component({ - standalone: true, - selector: 'app-interface-tor', - template: ` - - Use a Tor-enabled browser to access this address. Tor connections can be - slow and unreliable. - - View instructions - - - - @for (address of interface.serviceInterface.addresses.tor; track $index) { - - } @empty { - - } - `, - imports: [NgForOf, NgIf, InterfaceAddressComponent, TuiButtonModule], - changeDetection: ChangeDetectionStrategy.OnPush, -}) -export class InterfaceTorComponent { - readonly interface = inject(InterfaceComponent) - - async add() {} - - async remove() {} -} diff --git a/web/projects/ui/src/app/apps/portal/components/interfaces/interface.component.ts b/web/projects/ui/src/app/apps/portal/components/interfaces/interface.component.ts index f76828afb..5aafafa9d 100644 --- a/web/projects/ui/src/app/apps/portal/components/interfaces/interface.component.ts +++ b/web/projects/ui/src/app/apps/portal/components/interfaces/interface.component.ts @@ -8,38 +8,89 @@ import { import { T } from '@start9labs/start-sdk' import { TuiCardModule, TuiSurfaceModule } from '@taiga-ui/experimental' import { PatchDB } from 'patch-db-client' -import { InterfaceClearnetComponent } from 'src/app/apps/portal/components/interfaces/interface-clearnet.component' -import { InterfaceLocalComponent } from 'src/app/apps/portal/components/interfaces/interface-local.component' -import { InterfaceTorComponent } from 'src/app/apps/portal/components/interfaces/interface-tor.component' +import { AddressGroupComponent } from 'src/app/apps/portal/components/interfaces/address-group.component' import { DataModel } from 'src/app/services/patch-db/data-model' import { AddressDetails } from './interface.utils' +import { ClearnetAddressesDirective } from './directives/clearnet.directive' +import { LocalAddressesDirective } from './directives/local.directive' +import { TorAddressesDirective } from './directives/tor.directive' @Component({ standalone: true, selector: 'app-interface', template: `

Clearnet

- + [addresses]="serviceInterface.addresses.clearnet" + > + + Add a clearnet address to expose this interface on the Internet. + Clearnet addresses are fully public and not anonymous. + + Learn More + + +

Tor

- + + + Add an onion address to anonymously expose this interface on the + darknet. Onion addresses can only be reached over the Tor network. + + Learn More + + +

Local

- + + + Add a local address to expose this interface on your Local Area Network + (LAN). Local addresses can only be accessed by devices connected to the + same LAN as your server, either directly or using a VPN. + + Learn More + + + `, changeDetection: ChangeDetectionStrategy.OnPush, imports: [ CommonModule, - InterfaceTorComponent, - InterfaceLocalComponent, - InterfaceClearnetComponent, + AddressGroupComponent, TuiCardModule, TuiSurfaceModule, + ClearnetAddressesDirective, + TorAddressesDirective, + LocalAddressesDirective, ], }) export class InterfaceComponent { diff --git a/web/projects/ui/src/app/apps/portal/components/interfaces/interface.utils.ts b/web/projects/ui/src/app/apps/portal/components/interfaces/interface.utils.ts index 752b6b0b0..f9b0d4c7d 100644 --- a/web/projects/ui/src/app/apps/portal/components/interfaces/interface.utils.ts +++ b/web/projects/ui/src/app/apps/portal/components/interfaces/interface.utils.ts @@ -4,6 +4,11 @@ import { TuiPromptData } from '@taiga-ui/kit' import { NetworkInfo } from 'src/app/services/patch-db/data-model' import { configBuilderToSpec } from 'src/app/util/configBuilderToSpec' +export abstract class AddressesService { + abstract add(): Promise + abstract remove(): Promise +} + export const REMOVE: Partial> = { label: 'Confirm', size: 's', diff --git a/web/projects/ui/src/app/apps/portal/routes/dashboard/controls.component.ts b/web/projects/ui/src/app/apps/portal/routes/dashboard/controls.component.ts index 34747cb0a..9fc5faa22 100644 --- a/web/projects/ui/src/app/apps/portal/routes/dashboard/controls.component.ts +++ b/web/projects/ui/src/app/apps/portal/routes/dashboard/controls.component.ts @@ -82,7 +82,7 @@ export class ControlsComponent { return this.errors.getPkgDepErrors$(id).pipe( map(errors => Object.keys(this.pkg.currentDependencies) - .map(id => !!(errors[id] as any)?.[id]) // @TODO fix + .map(id => !!(errors[id] as any)?.[id]) // @TODO-Alex fix .some(Boolean), ), ) diff --git a/web/projects/ui/src/app/apps/portal/routes/system/settings/settings.service.ts b/web/projects/ui/src/app/apps/portal/routes/system/settings/settings.service.ts index 73de9df70..5f2425a8b 100644 --- a/web/projects/ui/src/app/apps/portal/routes/system/settings/settings.service.ts +++ b/web/projects/ui/src/app/apps/portal/routes/system/settings/settings.service.ts @@ -122,7 +122,7 @@ export class SettingsService { await this.proxyService.presentModalSetOutboundProxy(proxy) } - // @TODO previous this was done in experimental settings using a template ref. + // @TODO-Alex previous this was done in experimental settings using a template ref. private promptResetTor() { this.dialogs .open(TUI_PROMPT, { diff --git a/web/projects/ui/src/app/apps/portal/routes/system/sideload/sideload.utils.ts b/web/projects/ui/src/app/apps/portal/routes/system/sideload/sideload.utils.ts index 11343029f..3ebbc33e8 100644 --- a/web/projects/ui/src/app/apps/portal/routes/system/sideload/sideload.utils.ts +++ b/web/projects/ui/src/app/apps/portal/routes/system/sideload/sideload.utils.ts @@ -27,7 +27,7 @@ export async function parseS9pk(file: File): Promise { const manifest = await getAsset(positions, file, 'manifest') const [icon] = await Promise.all([ - await getIcon(positions, file, manifest), + await getIcon(positions, file), // getAsset(positions, file, 'license'), // getAsset(positions, file, 'instructions'), ]) @@ -147,11 +147,7 @@ async function getAsset( return cbor.decode(data, true) } -async function getIcon( - positions: Positions, - file: Blob, - manifest: Manifest, -): Promise { +async function getIcon(positions: Positions, file: Blob): Promise { const contentType = '' // @TODO const data = file.slice( Number(positions['icon'][0]), diff --git a/web/projects/ui/src/app/apps/portal/services/badge.service.ts b/web/projects/ui/src/app/apps/portal/services/badge.service.ts index 69a388665..52b797f2e 100644 --- a/web/projects/ui/src/app/apps/portal/services/badge.service.ts +++ b/web/projects/ui/src/app/apps/portal/services/badge.service.ts @@ -73,7 +73,7 @@ export class BadgeService { new Set(), ).size, ), - // @TODO shareReplay is preventing the badge from decrementing + // @TODO-Alex shareReplay is preventing the badge from decrementing shareReplay(1), ) diff --git a/web/projects/ui/src/app/services/api/embassy-mock-api.service.ts b/web/projects/ui/src/app/services/api/embassy-mock-api.service.ts index 359bb5b55..96aa518aa 100644 --- a/web/projects/ui/src/app/services/api/embassy-mock-api.service.ts +++ b/web/projects/ui/src/app/services/api/embassy-mock-api.service.ts @@ -1283,7 +1283,7 @@ export class MockApiService extends ApiService { const patch = [ { op: PatchOp.REPLACE, - path: `/packageData/${params.packageId}/installed/interfaceInfo/${params.interfaceId}/addressInfo/domainInfo`, + path: `/packageData/${params.packageId}/serviceInterfaces/${params.interfaceId}/addressInfo/domainInfo`, value: params.domainInfo, }, ] @@ -1299,7 +1299,7 @@ export class MockApiService extends ApiService { const patch = [ { op: PatchOp.REPLACE, - path: `/packageData/${params.packageId}/installed/outboundProxy`, + path: `/packageData/${params.packageId}/outboundProxy`, value: params.proxy, }, ] diff --git a/web/projects/ui/src/app/services/patch-monitor.service.ts b/web/projects/ui/src/app/services/patch-monitor.service.ts index fcf59bcbd..7a01e4d20 100644 --- a/web/projects/ui/src/app/services/patch-monitor.service.ts +++ b/web/projects/ui/src/app/services/patch-monitor.service.ts @@ -10,7 +10,7 @@ import { LocalStorageBootstrap } from './patch-db/local-storage-bootstrap' providedIn: 'root', }) export class PatchMonitorService extends Observable { - // @TODO not happy with Observable + // @TODO-Alex not happy with Observable private readonly stream$ = this.authService.isVerified$.pipe( tap(verified => verified ? this.patch.start(this.bootstrapper) : this.patch.stop(),