mirror of
https://github.com/Start9Labs/start-os.git
synced 2026-03-26 10:21:52 +00:00
domains and acme refactor
This commit is contained in:
@@ -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: '',
|
||||
})
|
||||
|
||||
@@ -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() {
|
||||
@@ -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
|
||||
}
|
||||
@@ -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[]>()
|
||||
}
|
||||
@@ -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() {}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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: '',
|
||||
}),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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>()
|
||||
}
|
||||
@@ -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',
|
||||
)
|
||||
}
|
||||
@@ -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'],
|
||||
}),
|
||||
}),
|
||||
},
|
||||
}),
|
||||
}),
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user