domains and acme refactor

This commit is contained in:
Matt Hill
2025-08-05 09:29:04 -06:00
parent 86dbf26253
commit 4a2777c52f
11 changed files with 557 additions and 404 deletions

View File

@@ -241,12 +241,12 @@ export class InterfaceClearnetComponent {
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: this.acme().reduce(
values: this.acme().reduce<Record<string, string>>(
(obj, url) => ({
...obj,
[url]: toAcmeName(url),
}),
{ none: 'None (use system Root CA)' } as Record<string, string>,
{ none: 'None (use system Root CA)' },
),
default: '',
})

View File

@@ -5,18 +5,27 @@ import {
i18nPipe,
LoadingService,
} from '@start9labs/shared'
import { toSignal } from '@angular/core/rxjs-interop'
import { ISB, utils } from '@start9labs/start-sdk'
import { filter } from 'rxjs'
import { filter, map } from 'rxjs'
import { FormComponent } from 'src/app/routes/portal/components/form.component'
import { ApiService } from 'src/app/services/api/embassy-api.service'
import { FormDialogService } from 'src/app/services/form-dialog.service'
import { PatchDB } from 'patch-db-client'
import { DataModel } from 'src/app/services/patch-db/data-model'
import { knownACME } from 'src/app/utils/acme'
import { configBuilderToSpec } from 'src/app/utils/configBuilderToSpec'
import { toAcmeName } from 'src/app/utils/acme'
@Injectable({
providedIn: 'root',
})
export type ACMEInfo = {
name: string
url: string
contact: readonly string[]
}
@Injectable()
export class AcmeService {
private readonly patch = inject<PatchDB<DataModel>>(PatchDB)
private readonly loader = inject(LoadingService)
private readonly errorService = inject(ErrorService)
private readonly api = inject(ApiService)
@@ -24,87 +33,26 @@ export class AcmeService {
private readonly i18n = inject(i18nPipe)
private readonly dialog = inject(DialogService)
async add(providers: { url: string; contact: string[] }[]) {
this.formDialog.open(FormComponent, {
label: 'Add ACME Provider',
data: {
spec: await configBuilderToSpec(
this.addSpec(providers.map(p => p.url)),
),
buttons: [
{
text: this.i18n.transform('Save'),
handler: async (val: ReturnType<typeof this.addSpec>['_TYPE']) => {
const providerUrl =
val.provider.selection === 'other'
? val.provider.value.url
: val.provider.selection
readonly acmes = toSignal<ACMEInfo[]>(
this.patch.watch$('serverInfo', 'network', 'acme').pipe(
map(acme =>
Object.keys(acme).map(url => ({
url,
name: toAcmeName(url),
contact:
acme[url]?.contact.map(mailto => mailto.replace('mailto:', '')) ||
[],
})),
),
),
)
return this.save(providerUrl, val.contact)
},
},
],
},
})
}
async edit({ url, contact }: { url: string; contact: readonly string[] }) {
this.formDialog.open(FormComponent, {
label: 'Edit ACME Provider',
data: {
spec: await configBuilderToSpec(this.editSpec()),
buttons: [
{
text: this.i18n.transform('Save'),
handler: async (val: ReturnType<typeof this.editSpec>['_TYPE']) =>
this.save(url, val.contact),
},
],
value: { contact },
},
})
}
remove({ url }: { url: string }) {
this.dialog
.openConfirm({ label: 'Are you sure?', size: 's' })
.pipe(filter(Boolean))
.subscribe(async () => {
const loader = this.loader.open('Removing').subscribe()
try {
await this.api.removeAcme({ provider: url })
} catch (e: any) {
this.errorService.handleError(e)
} finally {
loader.unsubscribe()
}
})
}
private async save(providerUrl: string, contact: readonly string[]) {
const loader = this.loader.open('Saving').subscribe()
try {
await this.api.initAcme({
provider: new URL(providerUrl).href,
contact: contact.map(address => `mailto:${address}`),
})
return true
} catch (e: any) {
this.errorService.handleError(e)
return false
} finally {
loader.unsubscribe()
}
}
private addSpec(providers: string[]) {
async add(acmes: ACMEInfo[]) {
const availableAcme = knownACME.filter(
acme => !providers.includes(acme.url),
acme => !acmes.map(a => a.url).includes(acme.url),
)
return ISB.InputSpec.of({
const addSpec = ISB.InputSpec.of({
provider: ISB.Value.union({
name: 'Provider',
default: (availableAcme[0]?.url as any) || 'other',
@@ -135,12 +83,81 @@ export class AcmeService {
}),
contact: this.emailListSpec(),
})
this.formDialog.open(FormComponent, {
label: 'Add ACME Provider',
data: {
spec: await configBuilderToSpec(addSpec),
buttons: [
{
text: this.i18n.transform('Save'),
handler: async (val: typeof addSpec._TYPE) => {
const providerUrl =
val.provider.selection === 'other'
? val.provider.value.url
: val.provider.selection
return this.save(providerUrl, val.contact)
},
},
],
},
})
}
private editSpec() {
return ISB.InputSpec.of({
async edit({ url, contact }: ACMEInfo) {
const editSpec = ISB.InputSpec.of({
contact: this.emailListSpec(),
})
this.formDialog.open(FormComponent, {
label: 'Edit ACME Provider',
data: {
spec: await configBuilderToSpec(editSpec),
buttons: [
{
text: this.i18n.transform('Save'),
handler: async (val: typeof editSpec._TYPE) =>
this.save(url, val.contact),
},
],
value: { contact },
},
})
}
remove({ url }: ACMEInfo) {
this.dialog
.openConfirm({ label: 'Are you sure?', size: 's' })
.pipe(filter(Boolean))
.subscribe(async () => {
const loader = this.loader.open('Removing').subscribe()
try {
await this.api.removeAcme({ provider: url })
} catch (e: any) {
this.errorService.handleError(e)
} finally {
loader.unsubscribe()
}
})
}
private async save(url: string, contact: readonly string[]) {
const loader = this.loader.open('Saving').subscribe()
try {
await this.api.initAcme({
provider: new URL(url).href,
contact: contact.map(address => `mailto:${address}`),
})
return true
} catch (e: any) {
this.errorService.handleError(e)
return false
} finally {
loader.unsubscribe()
}
}
private emailListSpec() {

View File

@@ -11,14 +11,12 @@ import {
TuiDropdown,
TuiTextfield,
} from '@taiga-ui/core'
import { toAcmeName } from 'src/app/utils/acme'
import { AcmeService } from './acme.service'
import { ACMEInfo, AcmeService } from './acme.service'
@Component({
selector: 'tr[acme]',
template: `
<td>{{ toAcmeName(acme().url) }}</td>
<td>{{ acme().name }}</td>
<td>{{ acme().contact.join(', ') }}</td>
<td>
<button
@@ -76,12 +74,10 @@ import { AcmeService } from './acme.service'
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [TuiButton, i18nPipe, TuiDropdown, TuiDataList, TuiTextfield],
})
export class DomainsAcmeComponent {
export class AcmeItemComponent {
protected readonly service = inject(AcmeService)
readonly acme = input.required<{ url: string; contact: readonly string[] }>()
readonly acme = input.required<ACMEInfo>()
open = false
toAcmeName = toAcmeName
}

View File

@@ -0,0 +1,41 @@
import { ChangeDetectionStrategy, Component, input } from '@angular/core'
import { i18nPipe } from '@start9labs/shared'
import { TuiSkeleton } from '@taiga-ui/kit'
import { PlaceholderComponent } from 'src/app/routes/portal/components/placeholder.component'
import { TableComponent } from 'src/app/routes/portal/components/table.component'
import { AcmeItemComponent } from './item.component'
import { ACMEInfo } from './acme.service'
@Component({
selector: 'acme-table',
template: `
<table [appTable]="['Provider', 'Contact', null]">
@for (acme of acmes(); track $index) {
<tr [acme]="acme"></tr>
} @empty {
<tr>
<td [attr.colspan]="3">
@if (acmes()) {
<app-placeholder icon="@tui.shield-question">
{{ 'No saved providers' | i18n }}
</app-placeholder>
} @else {
<div [tuiSkeleton]="true">{{ 'Loading' | i18n }}</div>
}
</td>
</tr>
}
</table>
`,
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [
TuiSkeleton,
i18nPipe,
TableComponent,
PlaceholderComponent,
AcmeItemComponent,
],
})
export class AcmeTableComponent {
readonly acmes = input<ACMEInfo[]>()
}

View File

@@ -1,134 +0,0 @@
import {
ChangeDetectionStrategy,
Component,
inject,
input,
} from '@angular/core'
import {
DialogService,
ErrorService,
i18nPipe,
LoadingService,
} from '@start9labs/shared'
import { ISB } from '@start9labs/start-sdk'
import {
TuiButton,
TuiDataList,
TuiDropdown,
TuiTextfield,
} from '@taiga-ui/core'
import { filter } from 'rxjs'
import { FormComponent } from 'src/app/routes/portal/components/form.component'
import { FormDialogService } from 'src/app/services/form-dialog.service'
import { configBuilderToSpec } from 'src/app/utils/configBuilderToSpec'
@Component({
selector: 'tr[domain]',
template: `
<td>{{ domain().domain }}</td>
<td [style.order]="-1">{{ domain().gateway }}</td>
<td>{{ domain().acme }}</td>
<td>
<button
tuiIconButton
tuiDropdown
size="s"
appearance="flat-grayscale"
iconStart="@tui.ellipsis-vertical"
[tuiAppearanceState]="open ? 'hover' : null"
[(tuiDropdownOpen)]="open"
>
{{ 'More' | i18n }}
<tui-data-list size="s" *tuiTextfieldDropdown>
<tui-opt-group>
<button tuiOption new iconStart="@tui.pencil" (click)="edit()">
{{ 'Edit' | i18n }}
</button>
<button tuiOption new iconStart="@tui.shield" (click)="showDns()">
{{ 'Show DNS' | i18n }}
</button>
<button tuiOption new iconStart="@tui.shield" (click)="testDns()">
{{ 'Test DNS' | i18n }}
</button>
</tui-opt-group>
<tui-opt-group>
<button
tuiOption
new
iconStart="@tui.trash"
class="g-negative"
(click)="remove()"
>
{{ 'Delete' | i18n }}
</button>
</tui-opt-group>
</tui-data-list>
</button>
</td>
`,
styles: `
td:last-child {
grid-area: 1 / 2 / 4;
align-self: center;
text-align: right;
}
:host-context(tui-root._mobile) {
grid-template-columns: 1fr min-content;
td:first-child {
font: var(--tui-font-text-m);
font-weight: bold;
}
}
`,
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [TuiButton, i18nPipe, TuiDropdown, TuiDataList, TuiTextfield],
})
export class DomainsDomainComponent {
private readonly dialog = inject(DialogService)
private readonly loader = inject(LoadingService)
private readonly errorService = inject(ErrorService)
private readonly formDialog = inject(FormDialogService)
readonly domain = input.required<any>()
open = false
remove() {
this.dialog
.openConfirm({ label: 'Are you sure?', size: 's' })
.pipe(filter(Boolean))
.subscribe(async () => {
const loader = this.loader.open('Deleting').subscribe()
try {
} catch (e: any) {
this.errorService.handleError(e)
} finally {
loader.unsubscribe()
}
})
}
async edit() {
const renameSpec = ISB.InputSpec.of({})
this.formDialog.open(FormComponent, {
label: 'Edit',
data: {
spec: await configBuilderToSpec(renameSpec),
buttons: [
{
text: 'Save',
handler: (value: typeof renameSpec._TYPE) => {},
},
],
},
})
}
async showDns() {}
async testDns() {}
}

View File

@@ -1,21 +1,13 @@
import {
ChangeDetectionStrategy,
Component,
inject,
signal,
} from '@angular/core'
import { toSignal } from '@angular/core/rxjs-interop'
import { ChangeDetectionStrategy, Component, inject } from '@angular/core'
import { RouterLink } from '@angular/router'
import { DocsLinkDirective, i18nPipe } from '@start9labs/shared'
import { TuiButton, TuiLink, TuiTitle } from '@taiga-ui/core'
import { TuiHeader } from '@taiga-ui/layout'
import { PatchDB } from 'patch-db-client'
import { map } from 'rxjs'
import { DataModel } from 'src/app/services/patch-db/data-model'
import { TitleDirective } from 'src/app/services/title.service'
import { AcmeService } from './acme.service'
import { DomainsTableComponent } from './table.component'
import { AcmeService } from './acme/acme.service'
import { DomainsService } from './domains/domains.service'
import { DomainsTableComponent } from './domains/table.component'
import { AcmeTableComponent } from './acme/table.component'
@Component({
template: `
@@ -29,15 +21,14 @@ import { DomainsTableComponent } from './table.component'
<hgroup tuiTitle>
<h3>{{ 'Domains' | i18n }}</h3>
<p tuiSubtitle>
<!-- @TODO translation -->
{{
'Add ACME providers in order to generate SSL (https) certificates for clearnet access.'
| i18n
'Adding a domain to StartOS means you can use it and its subdomains to host service interfaces on the public Internet.'
}}
<a
tuiLink
docsLink
path="/user-manual/connecting-remotely/clearnet.html"
fragment="#adding-acme"
path="/user-manual/domains.html"
appearance="action-grayscale"
iconEnd="@tui.external-link"
[pseudo]="true"
@@ -46,41 +37,43 @@ import { DomainsTableComponent } from './table.component'
</p>
</hgroup>
</header>
<section class="g-card">
<header>
{{ 'ACME Providers' | i18n }}
@if (acme(); as value) {
@if (acmeService.acmes(); as acmes) {
<button
tuiButton
size="xs"
iconStart="@tui.plus"
[style.margin-inline-start]="'auto'"
(click)="service.add(value)"
(click)="acmeService.add(acmes)"
>
{{ 'Add' | i18n }}
</button>
}
</header>
<domains-table mode="acme" [items]="acme()" />
<acme-table [acmes]="acmeService.acmes()" />
</section>
<section class="g-card">
<header>
{{ 'Domains' | i18n }}
<button
tuiButton
size="xs"
iconStart="@tui.plus"
[style.margin-inline-start]="'auto'"
(click)="addDomain()"
>
Add
</button>
@if (domainsService.data(); as value) {
<button
tuiButton
size="xs"
iconStart="@tui.plus"
[style.margin-inline-start]="'auto'"
(click)="domainsService.add()"
>
Add
</button>
}
</header>
<domains-table mode="domains" [items]="domains()" />
<domains-table [domains]="domainsService.data()?.domains" />
</section>
`,
styles: ``,
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [
TuiButton,
@@ -92,37 +85,11 @@ import { DomainsTableComponent } from './table.component'
i18nPipe,
DocsLinkDirective,
DomainsTableComponent,
AcmeTableComponent,
],
providers: [AcmeService, DomainsService],
})
export default class SystemDomainsComponent {
protected readonly patch = inject<PatchDB<DataModel>>(PatchDB)
protected readonly service = inject(AcmeService)
readonly acme = toSignal(
this.patch.watch$('serverInfo', 'network', 'acme').pipe(
map(acme =>
Object.keys(acme).map(url => ({
url,
contact:
acme[url]?.contact.map(mailto => mailto.replace('mailto:', '')) ||
[],
})),
),
),
)
readonly domains = signal([
{
domain: 'blog.mydomain.com',
gateway: 'StartTunnel',
acme: 'System',
},
{
domain: 'blog. mydomain.com',
gateway: 'StartTunnel',
acme: 'System',
},
])
async addDomain() {}
protected readonly acmeService = inject(AcmeService)
protected readonly domainsService = inject(DomainsService)
}

View File

@@ -0,0 +1,193 @@
import { inject, Injectable } from '@angular/core'
import {
DialogService,
ErrorService,
i18nPipe,
LoadingService,
} from '@start9labs/shared'
import { toSignal } from '@angular/core/rxjs-interop'
import { ISB, utils } from '@start9labs/start-sdk'
import { filter, map } from 'rxjs'
import { FormComponent } from 'src/app/routes/portal/components/form.component'
import { ApiService } from 'src/app/services/api/embassy-api.service'
import { FormDialogService } from 'src/app/services/form-dialog.service'
import { configBuilderToSpec } from 'src/app/utils/configBuilderToSpec'
import { PatchDB } from 'patch-db-client'
import { DataModel } from 'src/app/services/patch-db/data-model'
import { toAcmeName } from 'src/app/utils/acme'
// @TODO translations
@Injectable()
export class DomainsService {
private readonly patch = inject<PatchDB<DataModel>>(PatchDB)
private readonly loader = inject(LoadingService)
private readonly errorService = inject(ErrorService)
private readonly api = inject(ApiService)
private readonly formDialog = inject(FormDialogService)
private readonly i18n = inject(i18nPipe)
private readonly dialog = inject(DialogService)
readonly data = toSignal(
this.patch.watch$('serverInfo', 'network').pipe(
map(network => {
return {
gateways: Object.entries(network.networkInterfaces).reduce<
Record<string, string>
>(
(obj, [id, n]) => ({
...obj,
[id]: n.ipInfo?.name || '',
}),
{},
),
// @TODO use real data
domains: [
{
domain: 'blog.mydomain.com',
gateway: {
id: '',
name: 'StartTunnel',
},
acme: {
url: '',
name: `Lert's Encrypt`,
},
},
{
domain: 'store.mydomain.com',
gateway: {
id: '',
name: 'Ethernet',
},
acme: {
url: null,
name: 'System',
},
},
],
acme: Object.keys(network.acme).reduce<Record<string, string>>(
(obj, url) => ({
...obj,
[url]: toAcmeName(url),
}),
{ none: 'None (use system Root CA)' },
),
}
}),
),
)
async add() {
const addSpec = ISB.InputSpec.of({
domain: ISB.Value.text({
name: 'Domain',
description:
'Enter a domain/subdomain. For example, if you control domain.com, you could enter domain.com or subdomain.domain.com or another.subdomain.domain.com. In any case, the domain you enter and all possible subdomains of the domain will be available for assignment in StartOS',
required: true,
default: null,
patterns: [utils.Patterns.domain],
}),
...this.gatewaysAndAcme(),
})
this.formDialog.open(FormComponent, {
label: 'Add Domain' as any,
data: {
spec: await configBuilderToSpec(addSpec),
buttons: [
{
text: 'Save',
handler: (input: typeof addSpec._TYPE) => this.save(input),
},
],
},
})
}
async edit(domain: any) {
const editSpec = ISB.InputSpec.of({
...this.gatewaysAndAcme(),
})
this.formDialog.open(FormComponent, {
label: 'Edit Domain' as any, // @TODO translation
data: {
spec: await configBuilderToSpec(editSpec),
buttons: [
{
text: 'Save',
handler: (input: typeof editSpec._TYPE) =>
this.save({
domain: domain.domain,
...input,
}),
},
],
value: {
gateway: domain.gateway.id,
acme: domain.acme.url,
},
},
})
}
remove(domain: any) {
this.dialog
.openConfirm({ label: 'Are you sure?', size: 's' })
.pipe(filter(Boolean))
.subscribe(async () => {
const loader = this.loader.open('Deleting').subscribe()
try {
// @TODO API
} catch (e: any) {
this.errorService.handleError(e)
} finally {
loader.unsubscribe()
}
})
}
showDns(domain: any) {
// @TODO
}
testDns(domain: any) {
// @TODO
}
// @TODO different endpoints for create and edit?
private async save(params: any) {
const loader = this.loader.open('Saving').subscribe()
try {
// @TODO API
return true
} catch (e: any) {
this.errorService.handleError(e)
return false
} finally {
loader.unsubscribe()
}
}
private gatewaysAndAcme() {
return {
gateway: ISB.Value.select({
name: 'Gateway',
description:
'Select the public gateway for this domain. Whichever gateway you select is the IP address that will be exposed to the Internet.',
values: this.data()!.gateways,
default: '',
}),
acme: ISB.Value.select({
name: 'Default ACME',
description:
'Select the default ACME provider for this domain. This can be overridden on a case-by-case basis.',
values: this.data()!.acme,
default: '',
}),
}
}
}

View File

@@ -0,0 +1,100 @@
import {
ChangeDetectionStrategy,
Component,
inject,
input,
} from '@angular/core'
import { i18nPipe } from '@start9labs/shared'
import {
TuiButton,
TuiDataList,
TuiDropdown,
TuiTextfield,
} from '@taiga-ui/core'
import { DomainsService } from './domains.service'
@Component({
selector: 'tr[domain]',
template: `
<td>{{ domain().domain }}</td>
<td [style.order]="-1">{{ domain().gateway.name }}</td>
<td>{{ domain().acme.name }}</td>
<td>
<button
tuiIconButton
tuiDropdown
size="s"
appearance="flat-grayscale"
iconStart="@tui.ellipsis-vertical"
[tuiAppearanceState]="open ? 'hover' : null"
[(tuiDropdownOpen)]="open"
>
{{ 'More' | i18n }}
<tui-data-list size="s" *tuiTextfieldDropdown>
<tui-opt-group>
<button
tuiOption
new
iconStart="@tui.pencil"
(click)="domainsService.edit(domain())"
>
{{ 'Edit' | i18n }}
</button>
<button
tuiOption
new
iconStart="@tui.shield"
(click)="domainsService.showDns(domain())"
>
{{ 'Show DNS' | i18n }}
</button>
<button
tuiOption
new
iconStart="@tui.shield"
(click)="domainsService.testDns(domain())"
>
{{ 'Test DNS' | i18n }}
</button>
</tui-opt-group>
<tui-opt-group>
<button
tuiOption
new
iconStart="@tui.trash"
class="g-negative"
(click)="domainsService.remove(domain())"
>
{{ 'Delete' | i18n }}
</button>
</tui-opt-group>
</tui-data-list>
</button>
</td>
`,
styles: `
td:last-child {
grid-area: 1 / 2 / 4;
align-self: center;
text-align: right;
}
:host-context(tui-root._mobile) {
grid-template-columns: 1fr min-content;
td:first-child {
font: var(--tui-font-text-m);
font-weight: bold;
}
}
`,
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [TuiButton, i18nPipe, TuiDropdown, TuiDataList, TuiTextfield],
})
export class DomainsItemComponent {
protected readonly domainsService = inject(DomainsService)
readonly domain = input.required<any>()
open = false
}

View File

@@ -0,0 +1,41 @@
import { ChangeDetectionStrategy, Component, input } from '@angular/core'
import { i18nPipe } from '@start9labs/shared'
import { TuiSkeleton } from '@taiga-ui/kit'
import { PlaceholderComponent } from 'src/app/routes/portal/components/placeholder.component'
import { TableComponent } from 'src/app/routes/portal/components/table.component'
import { DomainsItemComponent } from './item.component'
@Component({
selector: 'domains-table',
template: `
<table [appTable]="['Domain', 'Gateway', 'Default ACME', null]">
@for (domain of domains(); track $index) {
<tr [domain]="domain"></tr>
} @empty {
<tr>
<td [attr.colspan]="4">
@if (domains()) {
<app-placeholder icon="@tui.globe">
{{ 'No domains' | i18n }}
</app-placeholder>
} @else {
<div [tuiSkeleton]="true">{{ 'Loading' | i18n }}</div>
}
</td>
</tr>
}
</table>
`,
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [
TuiSkeleton,
i18nPipe,
TableComponent,
PlaceholderComponent,
DomainsItemComponent,
],
})
export class DomainsTableComponent {
// @TODO Alex proper types
readonly domains = input<readonly any[] | null>()
}

View File

@@ -1,68 +0,0 @@
import {
ChangeDetectionStrategy,
Component,
computed,
input,
} from '@angular/core'
import { i18nPipe } from '@start9labs/shared'
import { TuiSkeleton } from '@taiga-ui/kit'
import { PlaceholderComponent } from 'src/app/routes/portal/components/placeholder.component'
import { TableComponent } from 'src/app/routes/portal/components/table.component'
import { DomainsAcmeComponent } from './acme.component'
import { DomainsDomainComponent } from './domain.component'
@Component({
selector: 'domains-table',
template: `
<table [appTable]="titles()">
@for (item of items(); track $index) {
@if (mode() === 'domains') {
<tr [domain]="item"></tr>
} @else if (mode() === 'acme') {
<tr [acme]="item"></tr>
}
} @empty {
<tr>
<td [attr.colspan]="titles().length">
@if (items()) {
<app-placeholder icon="@tui.globe">
@if (mode() === 'domains') {
{{ 'No domains' | i18n }}
} @else {
{{ 'No saved providers' | i18n }}
}
</app-placeholder>
} @else {
<div [tuiSkeleton]="true">{{ 'Loading' | i18n }}</div>
}
</td>
</tr>
}
</table>
`,
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [
TuiSkeleton,
i18nPipe,
TableComponent,
PlaceholderComponent,
DomainsDomainComponent,
DomainsAcmeComponent,
],
})
export class DomainsTableComponent {
// @TODO Alex proper types
readonly items = input<readonly any[] | null>()
readonly mode = input<'domains' | 'acme'>('domains')
readonly titles = computed(() =>
this.mode() === 'domains'
? (['Domain', 'Gateway', 'Default ACME', null] as const)
: (['Provider', 'Contact', null] as const),
)
readonly icon = computed(() =>
this.mode() === 'domains' ? '@tui.globe' : '@tui.shield-question',
)
}

View File

@@ -97,67 +97,22 @@ export default class GatewaysComponent {
),
)
readonly gatewaySpec = ISB.InputSpec.of({
name: ISB.Value.text({
name: 'Name',
description: 'A name to easily identify the gateway',
required: true,
default: null,
}),
type: ISB.Value.select({
name: 'Type',
description:
'-**Private**: select this option if the gateway is configured for private access to authorized clients only, which usually means ports are closed and traffic blocked otherwise. StartTunnel is a private gateway.\n-**Public**: select this option if the gateway is configured for unfettered public access, which usually means ports are open and traffic forwarded.',
default: 'private',
values: {
private: 'Private',
public: 'Public',
},
}),
config: ISB.Value.union({
name: 'Wireguard Config',
default: 'paste',
variants: ISB.Variants.of({
paste: {
name: 'Paste File Contents',
spec: ISB.InputSpec.of({
file: ISB.Value.textarea({
name: 'Paste File Contents',
default: null,
required: true,
}),
}),
},
upload: {
name: 'Upload File',
spec: ISB.InputSpec.of({
file: ISB.Value.file({
name: 'File',
required: true,
extensions: ['.conf'],
}),
}),
},
}),
}),
})
async add() {
this.formDialog.open(FormComponent, {
label: 'Add Gateway',
data: {
spec: await configBuilderToSpec(this.gatewaySpec),
spec: await configBuilderToSpec(gatewaySpec),
buttons: [
{
text: 'Save',
handler: (input: typeof this.gatewaySpec._TYPE) => this.save(input),
handler: (input: typeof gatewaySpec._TYPE) => this.save(input),
},
],
},
})
}
private async save(input: typeof this.gatewaySpec._TYPE): Promise<boolean> {
private async save(input: typeof gatewaySpec._TYPE): Promise<boolean> {
const loader = this.loader.open('Saving').subscribe()
try {
@@ -175,3 +130,48 @@ export default class GatewaysComponent {
}
}
}
const gatewaySpec = ISB.InputSpec.of({
name: ISB.Value.text({
name: 'Name',
description: 'A name to easily identify the gateway',
required: true,
default: null,
}),
type: ISB.Value.select({
name: 'Type',
description:
'-**Private**: select this option if the gateway is configured for private access to authorized clients only. StartTunnel is a private gateway.\n-**Public**: select this option if the gateway is configured for unfettered public access.',
default: 'private',
values: {
private: 'Private',
public: 'Public',
},
}),
config: ISB.Value.union({
name: 'Wireguard Config',
default: 'paste',
variants: ISB.Variants.of({
paste: {
name: 'Paste File Contents',
spec: ISB.InputSpec.of({
file: ISB.Value.textarea({
name: 'Paste File Contents',
default: null,
required: true,
}),
}),
},
upload: {
name: 'Upload File',
spec: ISB.InputSpec.of({
file: ISB.Value.file({
name: 'File',
required: true,
extensions: ['.conf'],
}),
}),
},
}),
}),
})