Merge branch 'next/minor' of github.com:Start9Labs/start-os into next/major

This commit is contained in:
Aiden McClelland
2025-03-03 12:51:40 -07:00
213 changed files with 53468 additions and 12274 deletions

12
web/package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "startos-ui",
"version": "0.3.6-alpha.13",
"version": "0.3.6-alpha.15",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "startos-ui",
"version": "0.3.6-alpha.13",
"version": "0.3.6-alpha.15",
"license": "MIT",
"dependencies": {
"@angular/animations": "^17.3.1",
@@ -141,7 +141,7 @@
"ts-node": "^10.9.1",
"ts-pegjs": "^4.2.1",
"tsx": "^4.7.1",
"typescript": "^5.0.4"
"typescript": "^5.7.3"
}
},
"node_modules/@ampproject/remapping": {
@@ -6112,9 +6112,9 @@
}
},
"node_modules/call-bind-apply-helpers": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.1.tgz",
"integrity": "sha512-BhYE+WDaywFg2TBWYNXAE+8B1ATnThNBqXHP5nQu0jWJdVvY2hvkpyB3qOmtmDePiS5/BDQ8wASEWGMWRG148g==",
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
"integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
"devOptional": true,
"license": "MIT",
"dependencies": {

View File

@@ -1,6 +1,6 @@
{
"name": "startos-ui",
"version": "0.3.6-alpha.13",
"version": "0.3.6-alpha.15",
"author": "Start9 Labs, Inc",
"homepage": "https://start9.com/",
"license": "MIT",

View File

@@ -0,0 +1,62 @@
<ion-item *ngIf="iFace">
<ion-icon
slot="start"
size="large"
[name]="
iFace.type === 'ui'
? 'desktop-outline'
: iFace.type === 'api'
? 'terminal-outline'
: 'people-outline'
"
></ion-icon>
<ion-label>
<h1>{{ iFace.name }}</h1>
<h2>{{ iFace.description }}</h2>
<ion-button style="margin-right: 8px" (click)="presentDomainForm()">
Add Domain
</ion-button>
<ion-button
[color]="iFace.public ? 'danger' : 'success'"
(click)="togglePublic()"
>
Make {{ iFace.public ? 'Private' : 'Public' }}
</ion-button>
</ion-label>
</ion-item>
<div *ngIf="iFace" style="padding-left: 64px">
<ion-item *ngFor="let address of iFace.addresses">
<ion-label>
<h2>{{ address.name }}</h2>
<p>{{ address.url }}</p>
<ion-button
*ngIf="address.isDomain"
color="danger"
(click)="removeStandard(address.url)"
>
Remove
</ion-button>
<ion-button
*ngIf="address.isOnion"
color="danger"
(click)="removeOnion(address.url)"
>
Remove
</ion-button>
</ion-label>
<ion-buttons slot="end">
<ion-button *ngIf="address.isDomain" (click)="showAcme(address.acme)">
<ion-icon name="finger-print"></ion-icon>
</ion-button>
<ion-button *ngIf="iFace.type === 'ui'" (click)="launch(address.url)">
<ion-icon name="open-outline"></ion-icon>
</ion-button>
<ion-button (click)="showQR(address.url)">
<ion-icon name="qr-code-outline"></ion-icon>
</ion-button>
<ion-button (click)="copy(address.url)">
<ion-icon name="copy-outline"></ion-icon>
</ion-button>
</ion-buttons>
</ion-item>
</div>

View File

@@ -0,0 +1,3 @@
p {
font-family: 'Courier New';
}

View File

@@ -0,0 +1,393 @@
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<DataModel>,
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<void> {
const alert = await this.alertCtrl.create({
header: 'ACME Provider',
message: toAcmeName(url),
})
await alert.present()
}
async showQR(text: string): Promise<void> {
const modal = await this.modalCtrl.create({
component: QRComponent,
componentProps: {
text,
},
cssClass: 'qr-modal',
})
await modal.present()
}
async copy(address: string): Promise<void> {
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<string, string>,
),
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),
)
}

View File

@@ -0,0 +1,11 @@
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 {}

View File

@@ -0,0 +1,18 @@
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 {}

View File

@@ -0,0 +1,53 @@
<ion-header>
<ion-toolbar>
<ion-buttons slot="start">
<ion-back-button defaultHref="system"></ion-back-button>
</ion-buttons>
<ion-title>ACME</ion-title>
</ion-toolbar>
</ion-header>
<ion-content class="ion-padding-top with-widgets">
<ion-item-group>
<!-- always -->
<ion-item>
<ion-label>
<h2>
Register with one or more ACME providers such as Let's Encrypt in
order to generate SSL (https) certificates on-demand for clearnet
hosting
<a [href]="docsUrl" target="_blank" rel="noreferrer">
View instructions
</a>
</h2>
</ion-label>
</ion-item>
<ion-item-divider>Saved Providers</ion-item-divider>
<ng-container *ngIf="acme$ | async as acme">
<ion-item button detail="false" (click)="addAcme(acme)">
<ion-icon slot="start" name="add" color="dark"></ion-icon>
<ion-label>
<b>Add Provider</b>
</ion-label>
</ion-item>
<ion-item *ngFor="let provider of acme">
<ion-icon slot="start" name="finger-print" size="medium"></ion-icon>
<ion-label>
<h2>{{ toAcmeName(provider.url) }}</h2>
<p>Contact: {{ provider.contactString }}</p>
</ion-label>
<ion-buttons slot="end">
<ion-button (click)="editAcme(provider.url, provider.contact)">
<ion-icon slot="start" name="pencil"></ion-icon>
</ion-button>
<ion-button (click)="removeAcme(provider.url)">
<ion-icon slot="start" name="trash-outline"></ion-icon>
</ion-button>
</ion-buttons>
</ion-item>
</ng-container>
</ion-item-group>
</ion-content>

View File

@@ -0,0 +1,179 @@
import { Component } 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 { map } from 'rxjs'
@Component({
selector: 'acme',
templateUrl: 'acme.page.html',
styleUrls: ['acme.page.scss'],
})
export class ACMEPage {
readonly docsUrl = 'https://docs.start9.com/0.3.6/user-manual/acme'
acme$ = this.patch.watch$('serverInfo', '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(', '),
}
})
}),
)
toAcmeName = toAcmeName
constructor(
private readonly loader: LoadingService,
private readonly errorService: ErrorService,
private readonly api: ApiService,
private readonly patch: PatchDB<DataModel>,
private readonly formDialog: FormDialogService,
) {}
async addAcme(
providers: {
url: string
contact: string[]
contactString: string
}[],
) {
this.formDialog.open(FormComponent, {
label: 'Add ACME Provider',
data: {
spec: await configBuilderToSpec(
getAddAcmeSpec(providers.map(p => p.url)),
),
buttons: [
{
text: 'Save',
handler: async (
val: ReturnType<typeof getAddAcmeSpec>['_TYPE'],
) => {
const providerUrl =
val.provider.selection === 'other'
? val.provider.value.url
: val.provider.selection
return this.saveAcme(providerUrl, val.contact)
},
},
],
},
})
}
async editAcme(provider: string, contact: string[]) {
this.formDialog.open(FormComponent, {
label: 'Edit ACME Provider',
data: {
spec: await configBuilderToSpec(editAcmeSpec),
buttons: [
{
text: 'Save',
handler: async (val: typeof editAcmeSpec._TYPE) =>
this.saveAcme(provider, val.contact),
},
],
value: { contact },
},
})
}
async removeAcme(provider: string) {
const loader = this.loader.open('Removing').subscribe()
try {
await this.api.removeAcme({ provider })
} catch (e: any) {
this.errorService.handleError(e)
} finally {
loader.unsubscribe()
}
}
private async saveAcme(providerUrl: string, contact: string[]) {
console.log(providerUrl, contact)
const loader = this.loader.open('Saving').subscribe()
try {
await this.api.initAcme({
provider: new URL(providerUrl).href,
contact: contact.map(address => `mailto:${address}`),
})
return true
} catch (e: any) {
this.errorService.handleError(e)
return false
} finally {
loader.unsubscribe()
}
}
}
const emailListSpec = ISB.Value.list(
ISB.List.text(
{
name: 'Contact Emails',
description:
'Needed to obtain a certificate from a Certificate Authority',
minLength: 1,
},
{
inputmode: 'email',
patterns: [utils.Patterns.email],
},
),
)
function getAddAcmeSpec(providers: string[]) {
const availableAcme = knownACME.filter(acme => !providers.includes(acme.url))
return ISB.InputSpec.of({
provider: ISB.Value.union(
{ name: 'Provider', default: (availableAcme[0]?.url as any) || 'other' },
ISB.Variants.of({
...availableAcme.reduce(
(obj, curr) => ({
...obj,
[curr.url]: {
name: curr.name,
spec: ISB.InputSpec.of({}),
},
}),
{},
),
other: {
name: 'Other',
spec: ISB.InputSpec.of({
url: ISB.Value.text({
name: 'URL',
default: null,
required: true,
inputmode: 'url',
patterns: [utils.Patterns.url],
}),
}),
},
}),
),
contact: emailListSpec,
})
}
const editAcmeSpec = ISB.InputSpec.of({
contact: emailListSpec,
})

View File

@@ -0,0 +1,42 @@
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 {}

View File

@@ -0,0 +1,70 @@
<ion-header>
<ion-toolbar>
<ion-title>Email</ion-title>
<ion-buttons slot="start">
<ion-back-button defaultHref="system"></ion-back-button>
</ion-buttons>
</ion-toolbar>
</ion-header>
<ion-content class="ion-padding">
<tui-notification>
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.
<a
href="https://docs.start9.com/latest/user-manual/0.3.5.x/smtp"
target="_blank"
rel="noreferrer"
>
View instructions
</a>
</tui-notification>
<ng-container *ngIf="form$ | async as form">
<form [formGroup]="form" [style.text-align]="'right'">
<h3 class="g-title">SMTP Credentials</h3>
<form-group
*ngIf="spec | async as resolved"
[spec]="resolved"
></form-group>
<button
*ngIf="isSaved"
tuiButton
appearance="destructive"
[style.margin-top.rem]="1"
[style.margin-right.rem]="1"
(click)="save(null)"
>
Delete
</button>
<button
tuiButton
[style.margin-top.rem]="1"
[disabled]="form.invalid"
(click)="save(form.value)"
>
Save
</button>
</form>
<form [style.text-align]="'right'">
<h3 class="g-title">Send Test Email</h3>
<tui-input
[(ngModel)]="testAddress"
[ngModelOptions]="{ standalone: true }"
>
To Address
<input tuiTextfield inputmode="email" />
</tui-input>
<button
tuiButton
appearance="secondary"
[style.margin-top.rem]="1"
[disabled]="!testAddress || form.invalid"
(click)="sendTestEmail(form.value)"
>
Send
</button>
</form>
</ng-container>
</ion-content>

View File

@@ -0,0 +1,9 @@
form {
padding-top: 24px;
margin: auto;
max-width: 30rem;
}
h3 {
display: flex;
}

View File

@@ -0,0 +1,83 @@
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<DataModel>>(PatchDB)
private readonly api = inject(ApiService)
isSaved = false
testAddress = ''
readonly spec: Promise<IST.InputSpec> = 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<void> {
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}.<br /><br /><b>Check your spam folder and mark as not spam</b>`,
{
label: 'Success',
size: 's',
},
)
.subscribe()
}
}

View File

@@ -532,10 +532,10 @@ export type NotificationLevel = 'success' | 'info' | 'warning' | 'error'
export type NotificationData<T> = T extends 0
? null
: T extends 1
? BackupReport
: T extends 2
? string
: any
? BackupReport
: T extends 2
? string
: any
export type BackupReport = {
server: {

View File

@@ -352,7 +352,7 @@ export class LiveApiService extends ApiService {
async deleteNotifications(
params: RR.DeleteNotificationReq,
): Promise<RR.DeleteNotificationRes> {
return this.rpcRequest({ method: 'notification.delete', params })
return this.rpcRequest({ method: 'notification.remove', params })
}
async markSeenNotifications(

View File

@@ -191,6 +191,108 @@ export const mockPatchData: DataModel = {
backupProgress: {},
},
hostname: 'random-words',
host: {
bindings: {
80: {
enabled: true,
net: {
assignedPort: null,
assignedSslPort: 443,
public: false,
},
options: {
preferredExternalPort: 80,
addSsl: {
preferredExternalPort: 443,
alpn: { specified: ['http/1.1', 'h2'] },
},
secure: null,
},
},
},
domains: {},
onions: ['myveryownspecialtoraddress'],
hostnameInfo: {
80: [
{
kind: 'ip',
networkInterfaceId: 'eth0',
public: false,
hostname: {
kind: 'local',
value: 'adjective-noun.local',
port: null,
sslPort: 443,
},
},
{
kind: 'ip',
networkInterfaceId: 'wlan0',
public: false,
hostname: {
kind: 'local',
value: 'adjective-noun.local',
port: null,
sslPort: 443,
},
},
{
kind: 'ip',
networkInterfaceId: 'eth0',
public: false,
hostname: {
kind: 'ipv4',
value: '10.0.0.1',
port: null,
sslPort: 443,
},
},
{
kind: 'ip',
networkInterfaceId: 'wlan0',
public: false,
hostname: {
kind: 'ipv4',
value: '10.0.0.2',
port: null,
sslPort: 443,
},
},
{
kind: 'ip',
networkInterfaceId: 'eth0',
public: false,
hostname: {
kind: 'ipv6',
value: 'fe80::cd00:0000:0cde:1257:0000:211e:72cd',
scopeId: 2,
port: null,
sslPort: 443,
},
},
{
kind: 'ip',
networkInterfaceId: 'wlan0',
public: false,
hostname: {
kind: 'ipv6',
value: 'fe80::cd00:0000:0cde:1257:0000:211e:1234',
scopeId: 3,
port: null,
sslPort: 443,
},
},
{
kind: 'onion',
hostname: {
value: 'myveryownspecialtoraddress.onion',
port: 80,
sslPort: 443,
},
},
],
},
},
pubkey: 'npub1sg6plzptd64u62a878hep2kev88swjh3tw00gjsfl8f237lmu63q0uf63m',
caFingerprint: 'SHA-256: 63 2B 11 99 44 40 17 DF 37 FC C3 DF 0F 3D 15',
ntpSynced: false,