diff --git a/web/projects/shared/styles/taiga.scss b/web/projects/shared/styles/taiga.scss index 5cbeba841..82311fff4 100644 --- a/web/projects/shared/styles/taiga.scss +++ b/web/projects/shared/styles/taiga.scss @@ -103,8 +103,6 @@ tui-hint[data-appearance='onDark'] { tui-dropdown[data-appearance='start-os'][data-appearance='start-os'] { border: 0; backdrop-filter: blur(0.25rem); - border-radius: 0.325rem; - // TODO: Replace --tui-background-elevation-2 when Taiga UI is updated background-color: color-mix( in hsl, var(--tui-background-elevation-3) 75%, @@ -129,30 +127,6 @@ tui-dropdown[data-appearance='start-os'][data-appearance='start-os'] { inset 0 1px rgba(255, 255, 255, 0.15), inset 0 0 1rem rgba(0, 0, 0, 0.25), var(--tui-shadow-medium); - - tui-opt-group { - &::before { - background: var(--tui-background-neutral-1); - height: 1px; - } - - &::after { - display: none; - } - } - - [tuiOption] { - border-radius: 0.1875rem !important; - transition-property: background, box-shadow; - - &:focus, - &._with-dropdown { - box-shadow: - inset 0 -1px rgba(0, 0, 0, 0.3), - inset 0 1px rgba(255, 255, 255, 0.1), - inset 0 -3rem 4rem -2rem rgba(0, 0, 0, 0.3); - } - } } [tuiSidebar] > div.t-wrapper { 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/routes/portal/components/actions.component.ts b/web/projects/ui/src/app/routes/portal/components/actions.component.ts deleted file mode 100644 index 3b7186cbd..000000000 --- a/web/projects/ui/src/app/routes/portal/components/actions.component.ts +++ /dev/null @@ -1,63 +0,0 @@ -import { TuiDataList, TuiIcon } from '@taiga-ui/core' -import { CommonModule } from '@angular/common' -import { ChangeDetectionStrategy, Component, Input } from '@angular/core' - -export interface Action { - icon: string - label: string - action: () => void -} - -@Component({ - selector: 'app-actions', - template: ` - -

- - - -
- `, - styles: [ - ` - .title { - margin: 0; - padding: 0 0.5rem 0.25rem; - white-space: nowrap; - font: var(--tui-font-text-l); - font-weight: bold; - } - - .item { - justify-content: flex-start; - gap: 0.75rem; - } - - .icon { - opacity: var(--tui-disabled-opacity); - } - `, - ], - standalone: true, - changeDetection: ChangeDetectionStrategy.OnPush, - imports: [TuiDataList, CommonModule, TuiIcon], -}) -export class ActionsComponent { - @Input() - actions: Record = {} - - asIsOrder(a: any, b: any) { - return 0 - } -} 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 6b6369a31..d50276573 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 @@ -1,7 +1,16 @@ import { ChangeDetectionStrategy, Component, inject } from '@angular/core' import { RouterLink } from '@angular/router' +import { ErrorService, LoadingService } from '@start9labs/shared' import { TuiResponsiveDialogService } from '@taiga-ui/addon-mobile' -import { TuiButton, TuiDataList, TuiDropdown, TuiIcon } from '@taiga-ui/core' +import { + TuiButton, + TuiDataList, + TuiDialogOptions, + TuiDropdown, + TuiIcon, +} from '@taiga-ui/core' +import { TUI_CONFIRM, TuiConfirmData } from '@taiga-ui/kit' +import { filter } from 'rxjs' import { ApiService } from 'src/app/services/api/embassy-api.service' import { AuthService } from 'src/app/services/auth.service' import { STATUS } from 'src/app/services/status.service' @@ -28,34 +37,53 @@ import { ABOUT } from './about.component' } - -
- @for (link of links; track $index) { + + + + + @for (link of links; track $index) { + + {{ link.name }} + + } + + - {{ link.name }} + System Settings - } -
- - System Settings - -
- +
+ + + + +
`, @@ -98,6 +126,8 @@ export class HeaderMenuComponent { private readonly api = inject(ApiService) private readonly auth = inject(AuthService) private readonly dialogs = inject(TuiResponsiveDialogService) + private readonly loader = inject(LoadingService) + private readonly errorService = inject(ErrorService) open = false @@ -108,8 +138,53 @@ export class HeaderMenuComponent { this.dialogs.open(ABOUT, { label: 'About this server' }).subscribe() } + async promptPower(action: 'Restart' | 'Shutdown') { + this.dialogs + .open(TUI_CONFIRM, getOptions(action)) + .pipe(filter(Boolean)) + .subscribe(async () => { + const loader = this.loader.open(`Beginning ${action}...`).subscribe() + + try { + await this.api[ + action === 'Restart' ? 'restartServer' : 'shutdownServer' + ]({}) + } catch (e: any) { + this.errorService.handleError(e) + } finally { + loader.unsubscribe() + } + }) + } + logout() { this.api.logout({}).catch(e => console.error('Failed to log out', e)) this.auth.setUnverified() } } + +function getOptions( + operation: 'Restart' | 'Shutdown', +): Partial> { + return operation === 'Restart' + ? { + label: 'Restart', + size: 's', + data: { + content: + 'Are you sure you want to restart your server? It can take several minutes to come back online.', + yes: 'Restart', + no: 'Cancel', + }, + } + : { + label: 'Warning', + size: 's', + data: { + content: + 'Are you sure you want to power down your server? This can take several minutes, and your server will not come back online automatically. To power on again, You will need to physically unplug your server and plug it back in', + yes: 'Shutdown', + no: 'Cancel', + }, + } +} diff --git a/web/projects/ui/src/app/routes/portal/routes/services/components/placeholder.component.ts b/web/projects/ui/src/app/routes/portal/components/placeholder.component.ts similarity index 89% rename from web/projects/ui/src/app/routes/portal/routes/services/components/placeholder.component.ts rename to web/projects/ui/src/app/routes/portal/components/placeholder.component.ts index f565016df..155400d35 100644 --- a/web/projects/ui/src/app/routes/portal/routes/services/components/placeholder.component.ts +++ b/web/projects/ui/src/app/routes/portal/components/placeholder.component.ts @@ -3,7 +3,7 @@ import { TuiIcon } from '@taiga-ui/core' @Component({ standalone: true, - selector: 'service-placeholder', + selector: 'app-placeholder', template: '', styles: ` :host { @@ -26,6 +26,6 @@ import { TuiIcon } from '@taiga-ui/core' changeDetection: ChangeDetectionStrategy.OnPush, imports: [TuiIcon], }) -export class ServicePlaceholderComponent { +export class PlaceholderComponent { readonly icon = input.required() } diff --git a/web/projects/ui/src/app/routes/portal/components/tabs.component.ts b/web/projects/ui/src/app/routes/portal/components/tabs.component.ts index eed5739bc..a704d2e32 100644 --- a/web/projects/ui/src/app/routes/portal/components/tabs.component.ts +++ b/web/projects/ui/src/app/routes/portal/components/tabs.component.ts @@ -15,7 +15,7 @@ import { BadgeService } from 'src/app/services/badge.service' import { RESOURCES } from 'src/app/utils/resources' import { getMenu } from 'src/app/utils/system-utilities' -const FILTER = ['/portal/services', '/portal/settings', '/portal/marketplace'] +const FILTER = ['/portal/services', '/portal/system', '/portal/marketplace'] @Component({ standalone: true, @@ -43,12 +43,12 @@ const FILTER = ['/portal/services', '/portal/settings', '/portal/marketplace'] - Settings + System - - - - - -
- {{ button.title }} -
{{ button.description }}
- -
- -
- `, - styles: ` - :host:not(:last-child) { - display: block; - box-shadow: 0 1px var(--tui-background-neutral-1); - } - - button { - cursor: pointer; - } - `, - changeDetection: ChangeDetectionStrategy.OnPush, - standalone: true, - imports: [CommonModule, TuiIcon, TuiTitle, RouterLink], -}) -export class SettingsButtonComponent { - @Input({ required: true }) - button!: SettingBtn -} diff --git a/web/projects/ui/src/app/routes/portal/routes/settings/components/menu.component.ts b/web/projects/ui/src/app/routes/portal/routes/settings/components/menu.component.ts deleted file mode 100644 index 1d9d2e75d..000000000 --- a/web/projects/ui/src/app/routes/portal/routes/settings/components/menu.component.ts +++ /dev/null @@ -1,117 +0,0 @@ -import { CommonModule } from '@angular/common' -import { ChangeDetectionStrategy, Component, inject } from '@angular/core' -import { TuiAlertService, TuiLoader, TuiButton } from '@taiga-ui/core' -import { PatchDB } from 'patch-db-client' -import { DataModel } from 'src/app/services/patch-db/data-model' -import { ClientStorageService } from 'src/app/services/client-storage.service' -import { SettingsService } from '../settings.service' -import { SettingsSyncComponent } from './sync.component' -import { SettingsButtonComponent } from './button.component' -import { SettingsUpdateComponent } from './update.component' - -@Component({ - selector: 'settings-menu', - template: ` - - -
-

{{ cat.key }}

- - - - - - -
-
- - - - `, - styles: [ - ` - :host { - display: flex; - flex-direction: column; - gap: 1rem; - } - `, - ], - standalone: true, - changeDetection: ChangeDetectionStrategy.OnPush, - imports: [ - CommonModule, - TuiLoader, - TuiButton, - SettingsSyncComponent, - SettingsButtonComponent, - SettingsUpdateComponent, - ], -}) -export class SettingsMenuComponent { - private readonly clientStorageService = inject(ClientStorageService) - private readonly alerts = inject(TuiAlertService) - - readonly server$ = inject>(PatchDB).watch$('serverInfo') - readonly service = inject(SettingsService) - - manageClicks = 0 - powerClicks = 0 - - addClick(title: string) { - switch (title) { - case 'Security': - this.addSecurityClick() - break - case 'Power': - this.addPowerClick() - break - default: - return - } - } - - asIsOrder() { - return 0 - } - - private addSecurityClick() { - this.manageClicks++ - - if (this.manageClicks === 5) { - this.manageClicks = 0 - this.alerts - .open( - this.clientStorageService.toggleShowDevTools() - ? 'Dev tools unlocked' - : 'Dev tools hidden', - ) - .subscribe() - } - } - - private addPowerClick() { - this.powerClicks++ - if (this.powerClicks === 5) { - this.powerClicks = 0 - this.clientStorageService.toggleShowDiskRepair() - } - } -} diff --git a/web/projects/ui/src/app/routes/portal/routes/settings/settings.routes.ts b/web/projects/ui/src/app/routes/portal/routes/settings/settings.routes.ts deleted file mode 100644 index 756624490..000000000 --- a/web/projects/ui/src/app/routes/portal/routes/settings/settings.routes.ts +++ /dev/null @@ -1,73 +0,0 @@ -import { SettingsComponent } from './settings.component' - -export default [ - { - path: '', - component: SettingsComponent, - children: [ - { - path: 'acme', - loadComponent: () => - import('./routes/acme/acme.component').then( - m => m.SettingsACMEComponent, - ), - }, - { - path: 'email', - loadComponent: () => - import('./routes/email/email.component').then( - m => m.SettingsEmailComponent, - ), - }, - // { - // path: 'domains', - // loadComponent: () => - // import('./routes/domains/domains.component').then( - // m => m.SettingsDomainsComponent, - // ), - // }, - // { - // path: 'proxies', - // loadComponent: () => - // import('./routes/proxies/proxies.component').then( - // m => m.SettingsProxiesComponent, - // ), - // }, - // { - // path: 'router', - // loadComponent: () => - // import('./routes/router/router.component').then( - // m => m.SettingsRouterComponent, - // ), - // }, - { - path: 'wifi', - loadComponent: () => - import('./routes/wifi/wifi.component').then( - m => m.SettingsWifiComponent, - ), - }, - { - path: 'ui', - loadComponent: () => - import('./routes/interfaces/ui.component').then( - m => m.StartOsUiComponent, - ), - }, - { - path: 'ssh', - loadComponent: () => - import('./routes/ssh/ssh.component').then( - m => m.SettingsSSHComponent, - ), - }, - { - path: 'sessions', - loadComponent: () => - import('./routes/sessions/sessions.component').then( - m => m.SettingsSessionsComponent, - ), - }, - ], - }, -] diff --git a/web/projects/ui/src/app/routes/portal/routes/system/components/button.component.ts b/web/projects/ui/src/app/routes/portal/routes/system/components/button.component.ts new file mode 100644 index 000000000..8b04d4740 --- /dev/null +++ b/web/projects/ui/src/app/routes/portal/routes/system/components/button.component.ts @@ -0,0 +1,56 @@ +import { TuiIcon, TuiTitle } from '@taiga-ui/core' +import { CommonModule } from '@angular/common' +import { ChangeDetectionStrategy, Component, Input } from '@angular/core' +import { RouterLink } from '@angular/router' +import { TuiCell } from '@taiga-ui/layout' +import { SettingBtn } from '../system.types' + +@Component({ + selector: 'system-button', + template: ` + @if (button.action) { + + } + + @if (button.routerLink) { + + + + } + + + +
+ {{ button.title }} +
{{ button.description }}
+ +
+ @if (button.routerLink) { + + } +
+ `, + styles: ` + :host { + display: flex; + flex-direction: column; + + &:not(:last-child) { + box-shadow: 0 1px var(--tui-background-neutral-1); + } + } + + button { + cursor: pointer; + } + `, + changeDetection: ChangeDetectionStrategy.OnPush, + standalone: true, + imports: [CommonModule, TuiIcon, TuiTitle, RouterLink, TuiCell], +}) +export class SystemButtonComponent { + @Input({ required: true }) + button!: SettingBtn +} diff --git a/web/projects/ui/src/app/routes/portal/routes/system/components/menu.component.ts b/web/projects/ui/src/app/routes/portal/routes/system/components/menu.component.ts new file mode 100644 index 000000000..df11c37bb --- /dev/null +++ b/web/projects/ui/src/app/routes/portal/routes/system/components/menu.component.ts @@ -0,0 +1,114 @@ +import { CommonModule } from '@angular/common' +import { ChangeDetectionStrategy, Component, inject } from '@angular/core' +import { toSignal } from '@angular/core/rxjs-interop' +import { TuiAlertService, TuiLoader, TuiButton } from '@taiga-ui/core' +import { PatchDB } from 'patch-db-client' +import { DataModel } from 'src/app/services/patch-db/data-model' +import { ClientStorageService } from 'src/app/services/client-storage.service' +import { SystemService } from '../system.service' +import { SystemSyncComponent } from './sync.component' +import { SystemButtonComponent } from './button.component' +import { SystemUpdateComponent } from './update.component' + +@Component({ + selector: 'system-menu', + template: ` + @if (data(); as server) { + @if (!server.ntpSynced) { + + } + + @for (cat of service.settings | keyvalue: asIsOrder; track $index) { +
+
{{ cat.key }}
+ @if (cat.key === 'General') { + + } + + @for (btn of cat.value; track $index) { + + + + } +
+ } + } @else { + + } + `, + standalone: true, + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [ + CommonModule, + TuiLoader, + SystemSyncComponent, + SystemButtonComponent, + SystemUpdateComponent, + ], +}) +export class SystemMenuComponent { + private readonly clientStorageService = inject(ClientStorageService) + private readonly alerts = inject(TuiAlertService) + + readonly service = inject(SystemService) + readonly data = toSignal( + inject>(PatchDB).watch$('serverInfo'), + ) + + manageClicks = 0 + powerClicks = 0 + + addClick(title: string) { + switch (title) { + case 'Security': + this.addSecurityClick() + break + case 'Power': + this.addPowerClick() + break + default: + return + } + } + + asIsOrder() { + return 0 + } + + private addSecurityClick() { + this.manageClicks++ + + if (this.manageClicks === 5) { + this.manageClicks = 0 + this.alerts + .open( + this.clientStorageService.toggleShowDevTools() + ? 'Dev tools unlocked' + : 'Dev tools hidden', + ) + .subscribe() + } + } + + private addPowerClick() { + this.powerClicks++ + if (this.powerClicks === 5) { + this.powerClicks = 0 + this.clientStorageService.toggleShowDiskRepair() + } + } +} diff --git a/web/projects/ui/src/app/routes/portal/routes/settings/components/sync.component.ts b/web/projects/ui/src/app/routes/portal/routes/system/components/sync.component.ts similarity index 93% rename from web/projects/ui/src/app/routes/portal/routes/settings/components/sync.component.ts rename to web/projects/ui/src/app/routes/portal/routes/system/components/sync.component.ts index e8fb73e3a..55bddb134 100644 --- a/web/projects/ui/src/app/routes/portal/routes/settings/components/sync.component.ts +++ b/web/projects/ui/src/app/routes/portal/routes/system/components/sync.component.ts @@ -3,7 +3,7 @@ import { TuiTitle, TuiButton, TuiNotification } from '@taiga-ui/core' import { ChangeDetectionStrategy, Component } from '@angular/core' @Component({ - selector: 'settings-sync', + selector: 'system-sync', template: `
@@ -31,4 +31,4 @@ import { ChangeDetectionStrategy, Component } from '@angular/core' standalone: true, imports: [TuiButton, TuiCell, TuiNotification, TuiTitle], }) -export class SettingsSyncComponent {} +export class SystemSyncComponent {} diff --git a/web/projects/ui/src/app/routes/portal/routes/settings/components/update.component.ts b/web/projects/ui/src/app/routes/portal/routes/system/components/update.component.ts similarity index 72% rename from web/projects/ui/src/app/routes/portal/routes/settings/components/update.component.ts rename to web/projects/ui/src/app/routes/portal/routes/system/components/update.component.ts index 9decc9ae1..df197f365 100644 --- a/web/projects/ui/src/app/routes/portal/routes/settings/components/update.component.ts +++ b/web/projects/ui/src/app/routes/portal/routes/system/components/update.component.ts @@ -7,48 +7,46 @@ import { import { CommonModule } from '@angular/common' import { ErrorService, LoadingService } from '@start9labs/shared' import { TuiDialogService, TuiIcon, TuiTitle } from '@taiga-ui/core' +import { TuiCell } from '@taiga-ui/layout' import { EOSService } from 'src/app/services/eos.service' import { UPDATE } from '../modals/update.component' @Component({ - selector: 'settings-update', + selector: 'system-update', template: ` `, styles: ` :host { - display: block; + display: flex; + flex-direction: column; box-shadow: 0 1px var(--tui-background-neutral-1); } @@ -62,9 +60,9 @@ import { UPDATE } from '../modals/update.component' `, changeDetection: ChangeDetectionStrategy.OnPush, standalone: true, - imports: [CommonModule, TuiIcon, TuiTitle], + imports: [CommonModule, TuiIcon, TuiTitle, TuiCell], }) -export class SettingsUpdateComponent { +export class SystemUpdateComponent { private readonly dialogs = inject(TuiDialogService) private readonly loader = inject(LoadingService) private readonly errorService = inject(ErrorService) diff --git a/web/projects/ui/src/app/routes/portal/routes/settings/modals/update.component.ts b/web/projects/ui/src/app/routes/portal/routes/system/modals/update.component.ts similarity index 95% rename from web/projects/ui/src/app/routes/portal/routes/settings/modals/update.component.ts rename to web/projects/ui/src/app/routes/portal/routes/system/modals/update.component.ts index d47d75840..ca7dc98aa 100644 --- a/web/projects/ui/src/app/routes/portal/routes/settings/modals/update.component.ts +++ b/web/projects/ui/src/app/routes/portal/routes/system/modals/update.component.ts @@ -46,7 +46,7 @@ import { EOSService } from 'src/app/services/eos.service' TuiScrollbar, ], }) -export class SettingsUpdateModal { +export class SystemUpdateModal { readonly versions = Object.entries(this.eosService.osUpdate?.releaseNotes!) .sort(([a], [b]) => a.localeCompare(b)) .reverse() @@ -77,4 +77,4 @@ export class SettingsUpdateModal { } } -export const UPDATE = new PolymorpheusComponent(SettingsUpdateModal) +export const UPDATE = new PolymorpheusComponent(SystemUpdateModal) diff --git a/web/projects/ui/src/app/routes/portal/routes/settings/routes/acme/acme.component.ts b/web/projects/ui/src/app/routes/portal/routes/system/routes/acme/acme.component.ts similarity index 68% rename from web/projects/ui/src/app/routes/portal/routes/settings/routes/acme/acme.component.ts rename to web/projects/ui/src/app/routes/portal/routes/system/routes/acme/acme.component.ts index 7a6242339..46a299738 100644 --- a/web/projects/ui/src/app/routes/portal/routes/settings/routes/acme/acme.component.ts +++ b/web/projects/ui/src/app/routes/portal/routes/system/routes/acme/acme.component.ts @@ -1,47 +1,93 @@ import { ChangeDetectionStrategy, Component, inject } from '@angular/core' +import { toSignal } from '@angular/core/rxjs-interop' import { ErrorService, LoadingService } from '@start9labs/shared' -import { PatchDB } from 'patch-db-client' -import { ApiService } from 'src/app/services/api/embassy-api.service' import { ISB, utils } from '@start9labs/start-sdk' -import { knownACME, toAcmeName } from 'src/app/utils/acme' +import { TuiButton, TuiLoader, TuiTitle } from '@taiga-ui/core' +import { TuiCell } from '@taiga-ui/layout' +import { PatchDB } from 'patch-db-client' 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' +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 { knownACME, toAcmeName } from 'src/app/utils/acme' +import { configBuilderToSpec } from 'src/app/utils/configBuilderToSpec' +import { AcmeInfoComponent } from './info.component' @Component({ - selector: 'acme', - template: ``, - styles: [], + template: ` + +
+
+ Saved Providers + @if (acme(); as value) { + + } +
+ @if (acme(); as value) { + @for (provider of value; track $index) { +
+ + {{ toAcmeName(provider.url) }} + Contact: {{ provider.contactString }} + + + +
+ } + } @else { + + } +
+ `, changeDetection: ChangeDetectionStrategy.OnPush, standalone: true, - imports: [CommonModule], + imports: [TuiButton, TuiLoader, TuiCell, TuiTitle, AcmeInfoComponent], }) -export class SettingsACMEComponent { +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) - readonly docsUrl = 'https://docs.start9.com/0.3.6/user-manual/acme' - - acme$ = this.patch.watch$('serverInfo', 'network', 'acme').pipe( - map(acme => { - const providerUrls = Object.keys(acme) - return providerUrls.map(url => { - const contact = acme[url].contact.map(mailto => - mailto.replace('mailto:', ''), - ) - return { - url, - contact, - contactString: contact.join(', '), - } - }) - }), + 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 diff --git a/web/projects/ui/src/app/routes/portal/routes/system/routes/acme/info.component.ts b/web/projects/ui/src/app/routes/portal/routes/system/routes/acme/info.component.ts new file mode 100644 index 000000000..55371c836 --- /dev/null +++ b/web/projects/ui/src/app/routes/portal/routes/system/routes/acme/info.component.ts @@ -0,0 +1,24 @@ +import { ChangeDetectionStrategy, Component } from '@angular/core' +import { TuiLink, TuiNotification } from '@taiga-ui/core' + +@Component({ + selector: 'acme-info', + template: ` + + 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 + + + `, + changeDetection: ChangeDetectionStrategy.OnPush, + standalone: true, + imports: [TuiNotification, TuiLink], +}) +export class AcmeInfoComponent {} diff --git a/web/projects/ui/src/app/routes/portal/routes/settings/routes/domains/constants.ts b/web/projects/ui/src/app/routes/portal/routes/system/routes/domains/constants.ts similarity index 100% rename from web/projects/ui/src/app/routes/portal/routes/settings/routes/domains/constants.ts rename to web/projects/ui/src/app/routes/portal/routes/system/routes/domains/constants.ts diff --git a/web/projects/ui/src/app/routes/portal/routes/settings/routes/domains/domains.component.ts b/web/projects/ui/src/app/routes/portal/routes/system/routes/domains/domains.component.ts similarity index 97% rename from web/projects/ui/src/app/routes/portal/routes/settings/routes/domains/domains.component.ts rename to web/projects/ui/src/app/routes/portal/routes/system/routes/domains/domains.component.ts index e2996b865..cf9d723a9 100644 --- a/web/projects/ui/src/app/routes/portal/routes/settings/routes/domains/domains.component.ts +++ b/web/projects/ui/src/app/routes/portal/routes/system/routes/domains/domains.component.ts @@ -60,7 +60,7 @@ import { DomainsTableComponent } from './table.component' DomainsInfoComponent, ], }) -export class SettingsDomainsComponent { +export default class SystemDomainsComponent { private readonly loader = inject(LoadingService) private readonly errorService = inject(ErrorService) private readonly formDialog = inject(FormDialogService) @@ -103,7 +103,7 @@ export class SettingsDomainsComponent { buttons: [ { text: 'Manage proxies', - link: '/portal/settings/proxies', + link: '/portal/system/proxies', }, { text: 'Save', @@ -128,7 +128,7 @@ export class SettingsDomainsComponent { buttons: [ { text: 'Manage proxies', - link: '/portal/settings/proxies', + link: '/portal/system/proxies', }, { text: 'Save', diff --git a/web/projects/ui/src/app/routes/portal/routes/settings/routes/domains/info.component.ts b/web/projects/ui/src/app/routes/portal/routes/system/routes/domains/info.component.ts similarity index 82% rename from web/projects/ui/src/app/routes/portal/routes/settings/routes/domains/info.component.ts rename to web/projects/ui/src/app/routes/portal/routes/system/routes/domains/info.component.ts index 658ac512e..43b206809 100644 --- a/web/projects/ui/src/app/routes/portal/routes/settings/routes/domains/info.component.ts +++ b/web/projects/ui/src/app/routes/portal/routes/system/routes/domains/info.component.ts @@ -1,5 +1,5 @@ import { ChangeDetectionStrategy, Component } from '@angular/core' -import { TuiNotification } from '@taiga-ui/core' +import { TuiLink, TuiNotification } from '@taiga-ui/core' @Component({ selector: 'domains-info', @@ -7,6 +7,7 @@ import { TuiNotification } from '@taiga-ui/core' Adding domains permits accessing your server and services over clearnet. -
-

SMTP Credentials

+ +
SMTP Credentials
- - +
+ @if (isSaved) { + + } + +
-
-

Test Email

+ +
Send Test Email
- +
+ +
`, - styles: ['form { margin: auto; max-width: 30rem; }'], changeDetection: ChangeDetectionStrategy.OnPush, standalone: true, imports: [ @@ -89,7 +85,7 @@ import { EmailInfoComponent } from './info.component' TitleDirective, ], }) -export class SettingsEmailComponent { +export default class SystemEmailComponent { private readonly dialogs = inject(TuiDialogService) private readonly loader = inject(LoadingService) private readonly errorService = inject(ErrorService) @@ -103,13 +99,12 @@ export class SettingsEmailComponent { readonly spec: Promise = configBuilderToSpec( inputSpec.constants.customSmtp, ) - readonly form$ = this.patch - .watch$('serverInfo', 'smtp') - .pipe( - switchMap(async value => - this.formService.createForm(await this.spec, value), - ), - ) + 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, @@ -131,13 +126,13 @@ export class SettingsEmailComponent { } } - async sendTestEmail(form: UntypedFormGroup) { - const loader = this.loader.open('Sending...').subscribe() + async sendTestEmail(value: typeof inputSpec.constants.customSmtp._TYPE) { + const loader = this.loader.open('Sending email...').subscribe() try { await this.api.testSmtp({ to: this.testAddress, - ...form.value, + ...value, }) } catch (e: any) { this.errorService.handleError(e) diff --git a/web/projects/ui/src/app/routes/portal/routes/settings/routes/email/info.component.ts b/web/projects/ui/src/app/routes/portal/routes/system/routes/email/info.component.ts similarity index 82% rename from web/projects/ui/src/app/routes/portal/routes/settings/routes/email/info.component.ts rename to web/projects/ui/src/app/routes/portal/routes/system/routes/email/info.component.ts index 30ec96c12..ba8bd6ba2 100644 --- a/web/projects/ui/src/app/routes/portal/routes/settings/routes/email/info.component.ts +++ b/web/projects/ui/src/app/routes/portal/routes/system/routes/email/info.component.ts @@ -1,5 +1,5 @@ import { ChangeDetectionStrategy, Component } from '@angular/core' -import { TuiNotification } from '@taiga-ui/core' +import { TuiLink, TuiNotification } from '@taiga-ui/core' @Component({ selector: 'email-info', @@ -8,6 +8,7 @@ import { TuiNotification } from '@taiga-ui/core' Adding SMTP credentials to StartOS enables StartOS and some services to send you emails.
= inject>( diff --git a/web/projects/ui/src/app/routes/portal/routes/settings/routes/proxies/constants.ts b/web/projects/ui/src/app/routes/portal/routes/system/routes/proxies/constants.ts similarity index 100% rename from web/projects/ui/src/app/routes/portal/routes/settings/routes/proxies/constants.ts rename to web/projects/ui/src/app/routes/portal/routes/system/routes/proxies/constants.ts diff --git a/web/projects/ui/src/app/routes/portal/routes/settings/routes/proxies/info.component.ts b/web/projects/ui/src/app/routes/portal/routes/system/routes/proxies/info.component.ts similarity index 90% rename from web/projects/ui/src/app/routes/portal/routes/settings/routes/proxies/info.component.ts rename to web/projects/ui/src/app/routes/portal/routes/system/routes/proxies/info.component.ts index 3cd1a9556..3af589d65 100644 --- a/web/projects/ui/src/app/routes/portal/routes/settings/routes/proxies/info.component.ts +++ b/web/projects/ui/src/app/routes/portal/routes/system/routes/proxies/info.component.ts @@ -1,5 +1,5 @@ import { ChangeDetectionStrategy, Component } from '@angular/core' -import { TuiNotification } from '@taiga-ui/core' +import { TuiLink, TuiNotification } from '@taiga-ui/core' @Component({ selector: 'proxies-info', @@ -25,6 +25,7 @@ import { TuiNotification } from '@taiga-ui/core' - + @if (enabled) { UPnP Enabled!

The ports below have been @@ -16,14 +15,14 @@ import { TuiNotification } from '@taiga-ui/core' 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 @@ -33,19 +32,20 @@ import { TuiNotification } from '@taiga-ui/core' Alternatively, you can enable UPnP in your router for automatic configuration. View instructions - + } `, styles: ['strong { font-size: 1rem }'], changeDetection: ChangeDetectionStrategy.OnPush, standalone: true, - imports: [CommonModule, TuiNotification], + imports: [TuiNotification, TuiLink], }) export class RouterInfoComponent { @Input() diff --git a/web/projects/ui/src/app/routes/portal/routes/settings/routes/router/primary-ip.pipe.ts b/web/projects/ui/src/app/routes/portal/routes/system/routes/router/primary-ip.pipe.ts similarity index 100% rename from web/projects/ui/src/app/routes/portal/routes/settings/routes/router/primary-ip.pipe.ts rename to web/projects/ui/src/app/routes/portal/routes/system/routes/router/primary-ip.pipe.ts diff --git a/web/projects/ui/src/app/routes/portal/routes/settings/routes/router/router.component.ts b/web/projects/ui/src/app/routes/portal/routes/system/routes/router/router.component.ts similarity index 97% rename from web/projects/ui/src/app/routes/portal/routes/settings/routes/router/router.component.ts rename to web/projects/ui/src/app/routes/portal/routes/system/routes/router/router.component.ts index a6f21ab00..06097de51 100644 --- a/web/projects/ui/src/app/routes/portal/routes/settings/routes/router/router.component.ts +++ b/web/projects/ui/src/app/routes/portal/routes/system/routes/router/router.component.ts @@ -64,6 +64,6 @@ import { RouterPortComponent } from './table.component' PrimaryIpPipe, ], }) -export class SettingsRouterComponent { +export default class SystemRouterComponent { readonly server$ = inject>(PatchDB).watch$('serverInfo') } diff --git a/web/projects/ui/src/app/routes/portal/routes/settings/routes/router/table.component.ts b/web/projects/ui/src/app/routes/portal/routes/system/routes/router/table.component.ts similarity index 100% rename from web/projects/ui/src/app/routes/portal/routes/settings/routes/router/table.component.ts rename to web/projects/ui/src/app/routes/portal/routes/system/routes/router/table.component.ts diff --git a/web/projects/ui/src/app/routes/portal/routes/settings/routes/sessions/platform-info.pipe.ts b/web/projects/ui/src/app/routes/portal/routes/system/routes/sessions/platform-info.pipe.ts similarity index 100% rename from web/projects/ui/src/app/routes/portal/routes/settings/routes/sessions/platform-info.pipe.ts rename to web/projects/ui/src/app/routes/portal/routes/system/routes/sessions/platform-info.pipe.ts diff --git a/web/projects/ui/src/app/routes/portal/routes/settings/routes/sessions/sessions.component.ts b/web/projects/ui/src/app/routes/portal/routes/system/routes/sessions/sessions.component.ts similarity index 73% rename from web/projects/ui/src/app/routes/portal/routes/settings/routes/sessions/sessions.component.ts rename to web/projects/ui/src/app/routes/portal/routes/system/routes/sessions/sessions.component.ts index 2cd52933d..55de658e6 100644 --- a/web/projects/ui/src/app/routes/portal/routes/settings/routes/sessions/sessions.component.ts +++ b/web/projects/ui/src/app/routes/portal/routes/system/routes/sessions/sessions.component.ts @@ -1,4 +1,5 @@ import { RouterLink } from '@angular/router' +import { TuiTable } from '@taiga-ui/addon-table' import { TuiLet } from '@taiga-ui/cdk' import { TuiButton } from '@taiga-ui/core' import { CommonModule } from '@angular/common' @@ -16,29 +17,34 @@ import { SSHTableComponent } from './table.component' Back Active Sessions -

Current session

-
+
+
Current session
+
+
- -

+
+
Other sessions - -

-
-
+ @if (table.selected$ | async; as selected) { + + } + +
+ `, changeDetection: ChangeDetectionStrategy.OnPush, standalone: true, @@ -49,9 +55,10 @@ import { SSHTableComponent } from './table.component' TuiLet, RouterLink, TitleDirective, + TuiTable, ], }) -export class SettingsSessionsComponent { +export default class SystemSessionsComponent { private readonly loader = inject(LoadingService) private readonly errorService = inject(ErrorService) private readonly api = inject(ApiService) diff --git a/web/projects/ui/src/app/routes/portal/routes/settings/routes/sessions/table.component.ts b/web/projects/ui/src/app/routes/portal/routes/system/routes/sessions/table.component.ts similarity index 88% rename from web/projects/ui/src/app/routes/portal/routes/settings/routes/sessions/table.component.ts rename to web/projects/ui/src/app/routes/portal/routes/system/routes/sessions/table.component.ts index 5a37b7ed5..f6ab49941 100644 --- a/web/projects/ui/src/app/routes/portal/routes/settings/routes/sessions/table.component.ts +++ b/web/projects/ui/src/app/routes/portal/routes/system/routes/sessions/table.component.ts @@ -1,4 +1,3 @@ -import { TuiCheckbox, TuiFade, TuiSkeleton } from '@taiga-ui/kit' import { CommonModule } from '@angular/common' import { ChangeDetectionStrategy, @@ -7,7 +6,9 @@ import { OnChanges, } from '@angular/core' import { FormsModule } from '@angular/forms' -import { TuiIcon, TuiLink, TuiButton } from '@taiga-ui/core' +import { TuiTable } from '@taiga-ui/addon-table' +import { TuiIcon } from '@taiga-ui/core' +import { TuiCheckbox, TuiFade, TuiSkeleton } from '@taiga-ui/kit' import { BehaviorSubject } from 'rxjs' import { Session } from 'src/app/services/api/api.types' import { PlatformInfoPipe } from './platform-info.pipe' @@ -17,7 +18,11 @@ import { PlatformInfoPipe } from './platform-info.pipe' template: ` - + @if (!single) { - Platform - Last Active + Platform + Last Active @for (session of sessions; track $index) { - + @if (!single) { implements OnChanges { diff --git a/web/projects/ui/src/app/routes/portal/routes/settings/routes/ssh/info.component.ts b/web/projects/ui/src/app/routes/portal/routes/system/routes/ssh/info.component.ts similarity index 83% rename from web/projects/ui/src/app/routes/portal/routes/settings/routes/ssh/info.component.ts rename to web/projects/ui/src/app/routes/portal/routes/system/routes/ssh/info.component.ts index 790de0bdd..f68231cf2 100644 --- a/web/projects/ui/src/app/routes/portal/routes/settings/routes/ssh/info.component.ts +++ b/web/projects/ui/src/app/routes/portal/routes/system/routes/ssh/info.component.ts @@ -1,5 +1,5 @@ import { ChangeDetectionStrategy, Component } from '@angular/core' -import { TuiNotification } from '@taiga-ui/core' +import { TuiLink, TuiNotification } from '@taiga-ui/core' @Component({ selector: 'ssh-info', @@ -8,6 +8,7 @@ import { TuiNotification } from '@taiga-ui/core' Adding domains to StartOS enables you to access your server and service interfaces over clearnet. -

- Saved Keys - -

-
+
+
+ Saved Keys + +
+
+
`, changeDetection: ChangeDetectionStrategy.OnPush, standalone: true, @@ -38,9 +42,10 @@ import { SSHTableComponent } from './table.component' SSHInfoComponent, RouterLink, TitleDirective, + TuiTable, ], }) -export class SettingsSSHComponent { +export default class SystemSSHComponent { private readonly errorService = inject(ErrorService) private readonly api = inject(ApiService) diff --git a/web/projects/ui/src/app/routes/portal/routes/settings/routes/ssh/table.component.ts b/web/projects/ui/src/app/routes/portal/routes/system/routes/ssh/table.component.ts similarity index 93% rename from web/projects/ui/src/app/routes/portal/routes/settings/routes/ssh/table.component.ts rename to web/projects/ui/src/app/routes/portal/routes/system/routes/ssh/table.component.ts index dc88f0db3..496be4fb2 100644 --- a/web/projects/ui/src/app/routes/portal/routes/settings/routes/ssh/table.component.ts +++ b/web/projects/ui/src/app/routes/portal/routes/system/routes/ssh/table.component.ts @@ -7,6 +7,7 @@ import { Input, } from '@angular/core' import { ErrorService, LoadingService } from '@start9labs/shared' +import { TuiTable } from '@taiga-ui/addon-table' import { TuiDialogOptions, TuiDialogService, TuiButton } from '@taiga-ui/core' import { TuiConfirmData, @@ -24,11 +25,11 @@ import { ApiService } from 'src/app/services/api/embassy-api.service' template: ` - Hostname - Created At - Algorithm - Fingerprint - + Hostname + Created At + Algorithm + Fingerprint + @@ -108,7 +109,7 @@ import { ApiService } from 'src/app/services/api/embassy-api.service' `, standalone: true, changeDetection: ChangeDetectionStrategy.OnPush, - imports: [CommonModule, TuiButton, TuiFade, TuiSkeleton], + imports: [CommonModule, TuiButton, TuiFade, TuiSkeleton, TuiTable], }) export class SSHTableComponent { private readonly loader = inject(LoadingService) diff --git a/web/projects/ui/src/app/routes/portal/routes/settings/routes/wifi/info.component.ts b/web/projects/ui/src/app/routes/portal/routes/system/routes/wifi/info.component.ts similarity index 84% rename from web/projects/ui/src/app/routes/portal/routes/settings/routes/wifi/info.component.ts rename to web/projects/ui/src/app/routes/portal/routes/system/routes/wifi/info.component.ts index 4030aa6ad..29850035e 100644 --- a/web/projects/ui/src/app/routes/portal/routes/settings/routes/wifi/info.component.ts +++ b/web/projects/ui/src/app/routes/portal/routes/system/routes/wifi/info.component.ts @@ -1,5 +1,5 @@ import { ChangeDetectionStrategy, Component } from '@angular/core' -import { TuiNotification } from '@taiga-ui/core' +import { TuiLink, TuiNotification } from '@taiga-ui/core' @Component({ selector: 'wifi-info', @@ -9,6 +9,7 @@ import { TuiNotification } from '@taiga-ui/core' and move the device anywhere you want. StartOS will automatically connect to available networks.
- + {{ network.ssid }} @if (network.connected) { Connected @@ -34,12 +34,7 @@ import { SettingsWifiComponent } from './wifi.component'
@if (!network.connected) { - } @@ -72,8 +67,12 @@ import { SettingsWifiComponent } from './wifi.component' } } `, - host: { style: 'align-items: stretch' }, styles: ` + :host { + align-items: stretch; + white-space: nowrap; + } + tui-icon { width: 2rem; color: var(--tui-text-tertiary); @@ -81,14 +80,22 @@ import { SettingsWifiComponent } from './wifi.component' `, changeDetection: ChangeDetectionStrategy.OnPush, standalone: true, - imports: [CommonModule, TuiCell, TuiTitle, TuiBadge, TuiButton, TuiIcon], + imports: [ + CommonModule, + TuiCell, + TuiTitle, + TuiBadge, + TuiButton, + TuiIcon, + TuiFade, + ], }) export class WifiTableComponent { private readonly loader = inject(LoadingService) private readonly errorService = inject(ErrorService) private readonly api = inject(ApiService) private readonly formDialog = inject(FormDialogService) - private readonly component = inject(SettingsWifiComponent) + private readonly component = inject(SystemWifiComponent) private readonly cdr = inject(ChangeDetectorRef) @Input() diff --git a/web/projects/ui/src/app/routes/portal/routes/settings/routes/wifi/utils.ts b/web/projects/ui/src/app/routes/portal/routes/system/routes/wifi/utils.ts similarity index 100% rename from web/projects/ui/src/app/routes/portal/routes/settings/routes/wifi/utils.ts rename to web/projects/ui/src/app/routes/portal/routes/system/routes/wifi/utils.ts diff --git a/web/projects/ui/src/app/routes/portal/routes/settings/routes/wifi/wifi.component.ts b/web/projects/ui/src/app/routes/portal/routes/system/routes/wifi/wifi.component.ts similarity index 79% rename from web/projects/ui/src/app/routes/portal/routes/settings/routes/wifi/wifi.component.ts rename to web/projects/ui/src/app/routes/portal/routes/system/routes/wifi/wifi.component.ts index 25b4b73fa..f4fadffed 100644 --- a/web/projects/ui/src/app/routes/portal/routes/settings/routes/wifi/wifi.component.ts +++ b/web/projects/ui/src/app/routes/portal/routes/system/routes/wifi/wifi.component.ts @@ -23,6 +23,7 @@ import { FormComponent, FormContext, } from 'src/app/routes/portal/components/form.component' +import { PlaceholderComponent } from 'src/app/routes/portal/components/placeholder.component' import { ApiService } from 'src/app/services/api/embassy-api.service' import { FormDialogService } from 'src/app/services/form-dialog.service' import { DataModel } from 'src/app/services/patch-db/data-model' @@ -40,46 +41,51 @@ import { wifiSpec } from './wifi.const' @if (status()?.interface) { -

- Wi-Fi - -

- - @if (status()?.enabled) { - @if (wifi(); as data) { - @if (data.known.length) { -

Known Networks

-
+
+
+ Wi-Fi + +
+ @if (status()?.enabled) { + @if (wifi(); as data) { + @if (data.known.length) { +

KNOWN NETWORKS

+
+ } + @if (data.available.length) { +

OTHER NETWORKS

+
+ } +

+ +

+ } @else { + } - @if (data.available.length) { -

Other Networks

-
- } -

- -

} @else { - + WiFi is disabled } - } +
} @else { -

No wireless interface detected.

+ + No wireless interface detected + } `, changeDetection: ChangeDetectionStrategy.OnPush, @@ -95,9 +101,10 @@ import { wifiSpec } from './wifi.const' WifiTableComponent, TitleDirective, RouterLink, + PlaceholderComponent, ], }) -export class SettingsWifiComponent { +export default class SystemWifiComponent { private readonly loader = inject(LoadingService) private readonly errorService = inject(ErrorService) private readonly api = inject(ApiService) diff --git a/web/projects/ui/src/app/routes/portal/routes/settings/routes/wifi/wifi.const.ts b/web/projects/ui/src/app/routes/portal/routes/system/routes/wifi/wifi.const.ts similarity index 100% rename from web/projects/ui/src/app/routes/portal/routes/settings/routes/wifi/wifi.const.ts rename to web/projects/ui/src/app/routes/portal/routes/system/routes/wifi/wifi.const.ts diff --git a/web/projects/ui/src/app/routes/portal/routes/settings/settings.component.ts b/web/projects/ui/src/app/routes/portal/routes/system/system.component.ts similarity index 54% rename from web/projects/ui/src/app/routes/portal/routes/settings/settings.component.ts rename to web/projects/ui/src/app/routes/portal/routes/system/system.component.ts index e84b10a02..e80b94386 100644 --- a/web/projects/ui/src/app/routes/portal/routes/settings/settings.component.ts +++ b/web/projects/ui/src/app/routes/portal/routes/system/system.component.ts @@ -1,18 +1,12 @@ -import { TuiIcon } from '@taiga-ui/core' import { ChangeDetectionStrategy, Component } from '@angular/core' import { RouterModule } from '@angular/router' import { TitleDirective } from 'src/app/services/title.service' -import { SettingsMenuComponent } from './components/menu.component' +import { SystemMenuComponent } from './components/menu.component' @Component({ template: ` - Settings -
- + System System + `, styles: [ @@ -26,22 +20,24 @@ import { SettingsMenuComponent } from './components/menu.component' } } - a, span:not(:last-child), - settings-menu { + system-menu:not(:nth-last-child(2)) { display: none; } - ._current + settings-menu { + system-menu, + router-outlet + ::ng-deep * { display: flex; - max-width: 30rem; + flex-direction: column; + gap: 1rem; margin: 0 auto; + max-width: 45rem; } `, ], host: { class: 'g-page' }, changeDetection: ChangeDetectionStrategy.OnPush, standalone: true, - imports: [RouterModule, TuiIcon, SettingsMenuComponent, TitleDirective], + imports: [RouterModule, SystemMenuComponent, TitleDirective], }) -export class SettingsComponent {} +export class SystemComponent {} diff --git a/web/projects/ui/src/app/routes/portal/routes/system/system.routes.ts b/web/projects/ui/src/app/routes/portal/routes/system/system.routes.ts new file mode 100644 index 000000000..0a8cd9dcc --- /dev/null +++ b/web/projects/ui/src/app/routes/portal/routes/system/system.routes.ts @@ -0,0 +1,46 @@ +import { SystemComponent } from './system.component' + +export default [ + { + path: '', + component: SystemComponent, + children: [ + { + path: 'acme', + loadComponent: () => import('./routes/acme/acme.component'), + }, + { + path: 'email', + loadComponent: () => import('./routes/email/email.component'), + }, + // { + // path: 'domains', + // loadComponent: () => import('./routes/domains/domains.component') + // }, + // { + // path: 'proxies', + // loadComponent: () => import('./routes/proxies/proxies.component') + // }, + // { + // path: 'router', + // loadComponent: () => import('./routes/router/router.component') + // }, + { + path: 'wifi', + loadComponent: () => import('./routes/wifi/wifi.component'), + }, + { + path: 'ui', + loadComponent: () => import('./routes/interfaces/ui.component'), + }, + { + path: 'ssh', + loadComponent: () => import('./routes/ssh/ssh.component'), + }, + { + path: 'sessions', + loadComponent: () => import('./routes/sessions/sessions.component'), + }, + ], + }, +] diff --git a/web/projects/ui/src/app/routes/portal/routes/settings/settings.service.ts b/web/projects/ui/src/app/routes/portal/routes/system/system.service.ts similarity index 75% rename from web/projects/ui/src/app/routes/portal/routes/settings/settings.service.ts rename to web/projects/ui/src/app/routes/portal/routes/system/system.service.ts index 628f0f643..f97a9842c 100644 --- a/web/projects/ui/src/app/routes/portal/routes/settings/settings.service.ts +++ b/web/projects/ui/src/app/routes/portal/routes/system/system.service.ts @@ -5,33 +5,32 @@ import { Injectable, } from '@angular/core' import { FormsModule } from '@angular/forms' +import * as argon2 from '@start9labs/argon2' +import { ErrorService, LoadingService } from '@start9labs/shared' import { TuiAlertService, TuiDialogOptions, TuiDialogService, TuiLabel, } from '@taiga-ui/core' -import * as argon2 from '@start9labs/argon2' -import { ErrorService, LoadingService } from '@start9labs/shared' -import { TuiConfirmData, TUI_CONFIRM, TuiCheckbox } from '@taiga-ui/kit' +import { TUI_CONFIRM, TuiCheckbox, TuiConfirmData } from '@taiga-ui/kit' import { PolymorpheusComponent } from '@taiga-ui/polymorpheus' import { PatchDB } from 'patch-db-client' -import { filter, firstValueFrom, from, take } from 'rxjs' +import { filter, from, take } from 'rxjs' import { switchMap } from 'rxjs/operators' import { FormComponent } from 'src/app/routes/portal/components/form.component' import { PROMPT } from 'src/app/routes/portal/modals/prompt.component' -import { AuthService } from 'src/app/services/auth.service' -import { configBuilderToSpec } from 'src/app/utils/configBuilderToSpec' -import { getServerInfo } from 'src/app/utils/get-server-info' +import { ApiService } from 'src/app/services/api/embassy-api.service' +import { ConfigService } from 'src/app/services/config.service' import { FormDialogService } from 'src/app/services/form-dialog.service' import { DataModel } from 'src/app/services/patch-db/data-model' -import { ApiService } from 'src/app/services/api/embassy-api.service' +import { configBuilderToSpec } from 'src/app/utils/configBuilderToSpec' +import { getServerInfo } from 'src/app/utils/get-server-info' -import { passwordSpec, PasswordSpec, SettingBtn } from './settings.types' -import { ConfigService } from 'src/app/services/config.service' +import { passwordSpec, PasswordSpec, SettingBtn } from './system.types' @Injectable({ providedIn: 'root' }) -export class SettingsService { +export class SystemService { private readonly alerts = inject(TuiAlertService) private readonly dialogs = inject(TuiDialogService) private readonly loader = inject(LoadingService) @@ -39,7 +38,6 @@ export class SettingsService { private readonly formDialog = inject(FormDialogService) private readonly patch = inject>(PatchDB) private readonly api = inject(ApiService) - private readonly auth = inject(AuthService) private readonly isTor = inject(ConfigService).isTor() wipe = false @@ -48,17 +46,10 @@ export class SettingsService { General: [ { title: 'Email', - description: - 'Connect to an external SMTP server to send yourself emails', + description: 'Connect to an external SMTP server for sending emails', icon: '@tui.mail', routerLink: 'email', }, - { - title: 'Change Master Password', - description: `Change your StartOS master password`, - icon: '@tui.key', - action: () => this.promptNewPassword(), - }, ], Network: [ // { @@ -79,6 +70,19 @@ export class SettingsService { // icon: '@tui.radio', // routerLink: 'router', // }, + { + title: 'User Interface Addresses', + description: 'View and manage your Start OS UI addresses', + icon: '@tui.monitor', + routerLink: 'ui', + }, + { + title: 'ACME', + description: + 'Add ACME providers to create SSL certificates for clearnet access', + icon: '@tui.award', + routerLink: 'acme', + }, { title: 'WiFi', description: 'Add or remove WiFi networks', @@ -92,27 +96,27 @@ export class SettingsService { action: () => this.promptResetTor(), }, ], - 'StartOS UI': [ + Customize: [ { title: 'Browser Tab Title', description: `Customize the display name of your browser tab`, icon: '@tui.tag', action: () => this.setBrowserTab(), }, - { - title: 'Web Addresses', - description: 'View and manage web addresses for accessing this UI', - icon: '@tui.monitor', - routerLink: 'ui', - }, ], - 'Privacy and Security': [ + Security: [ // { // title: 'Outbound Proxy', // description: 'Proxy outbound traffic from the StartOS main process', // icon: '@tui.shield', // action: () => this.setOutboundProxy(), // }, + { + title: 'Active Sessions', + description: 'View and manage device access', + icon: '@tui.clock', + routerLink: 'sessions', + }, { title: 'SSH', description: @@ -121,33 +125,10 @@ export class SettingsService { routerLink: 'ssh', }, { - title: 'Active Sessions', - description: 'View and manage device access', - icon: '@tui.clock', - 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() - }, + title: 'Change Password', + description: `Change your StartOS master password`, + icon: '@tui.key', + action: () => this.promptNewPassword(), }, ], } @@ -174,25 +155,6 @@ export class SettingsService { .subscribe(() => this.resetTor(this.wipe)) } - private async promptPower(action: 'Restart' | 'Shutdown') { - this.dialogs - .open(TUI_CONFIRM, getOptions(action)) - .pipe(filter(Boolean)) - .subscribe(async () => { - const loader = this.loader.open(`Beginning ${action}...`).subscribe() - - try { - await this.api[ - action === 'Restart' ? 'restartServer' : 'shutdownServer' - ]({}) - } catch (e: any) { - this.errorService.handleError(e) - } finally { - loader.unsubscribe() - } - }) - } - private async resetTor(wipeState: boolean) { const loader = this.loader.open('Resetting Tor...').subscribe() @@ -340,31 +302,5 @@ export class SettingsService { }) class WipeComponent { readonly isTor = inject(ConfigService).isTor() - readonly service = inject(SettingsService) -} - -function getOptions( - operation: 'Restart' | 'Shutdown', -): Partial> { - return operation === 'Restart' - ? { - label: 'Restart', - size: 's', - data: { - content: - 'Are you sure you want to restart your server? It can take several minutes to come back online.', - yes: 'Restart', - no: 'Cancel', - }, - } - : { - label: 'Warning', - size: 's', - data: { - content: - 'Are you sure you want to power down your server? This can take several minutes, and your server will not come back online automatically. To power on again, You will need to physically unplug your server and plug it back in', - yes: 'Shutdown', - no: 'Cancel', - }, - } + readonly service = inject(SystemService) } diff --git a/web/projects/ui/src/app/routes/portal/routes/settings/settings.types.ts b/web/projects/ui/src/app/routes/portal/routes/system/system.types.ts similarity index 100% rename from web/projects/ui/src/app/routes/portal/routes/settings/settings.types.ts rename to web/projects/ui/src/app/routes/portal/routes/system/system.types.ts diff --git a/web/projects/ui/src/app/services/badge.service.ts b/web/projects/ui/src/app/services/badge.service.ts index 90670dcd5..a89eb8cf9 100644 --- a/web/projects/ui/src/app/services/badge.service.ts +++ b/web/projects/ui/src/app/services/badge.service.ts @@ -26,7 +26,7 @@ export class BadgeService { private readonly notifications = inject(NotificationService) private readonly exver = inject(Exver) private readonly patch = inject>(PatchDB) - private readonly settings$ = combineLatest([ + private readonly system$ = combineLatest([ this.patch.watch$('serverInfo', 'ntpSynced'), inject(EOSService).updateAvailable$, ]).pipe(map(([synced, update]) => Number(!synced) + Number(update))) @@ -83,8 +83,8 @@ export class BadgeService { switch (id) { // case '/portal/updates': // return this.updates$ - case '/portal/settings': - return this.settings$ + case '/portal/system': + return this.system$ case '/portal/notifications': return this.notifications.unreadCount$ default: diff --git a/web/projects/ui/src/app/services/eos.service.ts b/web/projects/ui/src/app/services/eos.service.ts index 7780261fd..fed507575 100644 --- a/web/projects/ui/src/app/services/eos.service.ts +++ b/web/projects/ui/src/app/services/eos.service.ts @@ -29,11 +29,7 @@ export class EOSService { readonly updatingOrBackingUp$ = combineLatest([ this.updating$, this.backingUp$, - ]).pipe( - map(([updating, backingUp]) => { - return updating || backingUp - }), - ) + ]).pipe(map(([updating, backingUp]) => updating || backingUp)) readonly showUpdate$ = combineLatest([ this.updateAvailable$, diff --git a/web/projects/ui/src/app/utils/system-utilities.ts b/web/projects/ui/src/app/utils/system-utilities.ts index 68990d23e..7956395cb 100644 --- a/web/projects/ui/src/app/utils/system-utilities.ts +++ b/web/projects/ui/src/app/utils/system-utilities.ts @@ -33,9 +33,9 @@ export const SYSTEM_UTILITIES: Record = icon: '@tui.file-text', title: 'Logs', }, - '/portal/settings': { - icon: '@tui.wrench', - title: 'Settings', + '/portal/system': { + icon: '@tui.settings', + title: 'System', }, '/portal/notifications': { icon: '@tui.bell', diff --git a/web/projects/ui/src/styles.scss b/web/projects/ui/src/styles.scss index a87aaef7b..9e6ea1c42 100644 --- a/web/projects/ui/src/styles.scss +++ b/web/projects/ui/src/styles.scss @@ -76,16 +76,16 @@ hr { position: relative; display: flex; flex-direction: column; - padding: 3.25rem 1rem 0.5rem; + padding: 3.125rem 1rem 0.5rem; border-radius: 0.5rem; overflow: hidden; background-color: color-mix(in hsl, var(--start9-base-1) 50%, transparent); background-image: linear-gradient( to bottom, - rgba(255, 255, 255, 0.15), + var(--tui-background-neutral-2), transparent ), - linear-gradient(to bottom, rgba(255, 255, 255, 0.15), transparent); + linear-gradient(to bottom, var(--tui-background-neutral-2), transparent); background-size: 1px 100%; background-repeat: no-repeat; background-position: @@ -101,8 +101,13 @@ hr { inset 0 1px rgba(255, 255, 255, 0.15), inset 0 0 1rem rgba(0, 0, 0, 0.25); - > [tuiCell] { - margin: 0 -0.5rem; + &:is(form) { + padding-top: 3.75rem; + } + + [tuiCell] { + margin: 0 -0.625rem; + border-radius: var(--tui-radius-s); &:not(:last-child)::after { content: ''; @@ -111,7 +116,7 @@ hr { left: 1rem; right: 1rem; height: 1px; - background: var(--tui-border-normal); + background: var(--tui-background-neutral-1); } } @@ -129,11 +134,20 @@ hr { top: 0; left: 0; right: 0; + display: flex; + align-items: center; padding: 0.5rem 1rem; background: var(--tui-background-neutral-1); font: var(--tui-font-text-l); font-weight: bold; } + + > footer { + display: flex; + gap: 1rem; + justify-content: flex-end; + padding: 1rem 0 0.5rem; + } } .g-table:not([tuiTable]) {