certificate authorities

This commit is contained in:
Matt Hill
2025-08-05 13:03:04 -06:00
parent 4a2777c52f
commit f8b03ea917
22 changed files with 351 additions and 327 deletions

View File

@@ -1,11 +1,11 @@
import { Pipe, PipeTransform } from '@angular/core'
import { toAcmeName } from 'src/app/utils/acme'
import { toAuthorityName } from 'src/app/utils/acme'
@Pipe({
name: 'acme',
name: 'authorityName',
})
export class AcmePipe implements PipeTransform {
export class AuthorityNamePipe implements PipeTransform {
transform(value: string | null = null): string {
return toAcmeName(value)
return toAuthorityName(value)
}
}

View File

@@ -28,14 +28,14 @@ import {
FormComponent,
FormContext,
} from 'src/app/routes/portal/components/form.component'
import { AcmePipe } from 'src/app/routes/portal/components/interfaces/acme.pipe'
import { AuthorityNamePipe } from 'src/app/routes/portal/components/interfaces/acme.pipe'
import { InterfaceComponent } from 'src/app/routes/portal/components/interfaces/interface.component'
import { PlaceholderComponent } from 'src/app/routes/portal/components/placeholder.component'
import { TableComponent } from 'src/app/routes/portal/components/table.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 { toAcmeName } from 'src/app/utils/acme'
import { toAuthorityName } from 'src/app/utils/acme'
import { configBuilderToSpec } from 'src/app/utils/configBuilderToSpec'
import { InterfaceActionsComponent } from './actions.component'
import { ClearnetAddress } from './interface.utils'
@@ -43,7 +43,7 @@ import { MaskPipe } from './mask.pipe'
type ClearnetForm = {
domain: string
acme: string
authority: string
}
@Component({
@@ -85,11 +85,15 @@ type ClearnetForm = {
}}
</tui-notification>
}
<table [appTable]="['ACME', 'URL', null]">
<table [appTable]="['Certificate Authority', 'URL', null]">
@for (address of clearnet(); track $index) {
<tr>
<td [style.width.rem]="12">
{{ interface.value().addSsl ? (address.acme | acme) : '-' }}
{{
interface.value().addSsl
? (address.authority | authorityName)
: '-'
}}
</td>
<td [style.order]="-1">{{ address.url | mask }}</td>
<td
@@ -154,7 +158,7 @@ type ClearnetForm = {
PlaceholderComponent,
TableComponent,
MaskPipe,
AcmePipe,
AuthorityNamePipe,
InterfaceActionsComponent,
i18nPipe,
DocsLinkDirective,
@@ -175,7 +179,7 @@ export class InterfaceClearnetComponent {
readonly isRunning = input.required<boolean>()
readonly isPublic = input.required<boolean>()
readonly acme = toSignal(
readonly authorityUrls = toSignal(
inject<PatchDB<DataModel>>(PatchDB)
.watch$('serverInfo', 'network', 'acme')
.pipe(map(acme => Object.keys(acme))),
@@ -237,16 +241,16 @@ export class InterfaceClearnetComponent {
default: null,
patterns: [utils.Patterns.domain],
})
const acme = ISB.Value.select({
name: 'ACME Provider',
const authority = ISB.Value.select({
name: 'Certificate Authority',
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<Record<string, string>>(
'Select which Certificate authority to use for obtaining your SSL certificate. Add new authority in the System tab. Optionally use your local= Root CA. Note: only devices that have trusted your Root CA will be able to access the domain without security warnings.',
values: this.authorityUrls().reduce<Record<string, string>>(
(obj, url) => ({
...obj,
[url]: toAcmeName(url),
[url]: toAuthorityName(url),
}),
{ none: 'None (use system Root CA)' },
{ local: toAuthorityName(null) },
),
default: '',
})
@@ -256,7 +260,7 @@ export class InterfaceClearnetComponent {
data: {
spec: await configBuilderToSpec(
ISB.InputSpec.of(
this.interface.value().addSsl ? { domain, acme } : { domain },
this.interface.value().addSsl ? { domain, authority } : { domain },
),
),
buttons: [
@@ -272,11 +276,11 @@ export class InterfaceClearnetComponent {
private async save(domainInfo: ClearnetForm): Promise<boolean> {
const loader = this.loader.open('Saving').subscribe()
const { domain, acme } = domainInfo
const { domain, authority } = domainInfo
const params = {
domain,
acme: acme === 'none' ? null : acme,
acme: authority === 'local' ? null : authority,
private: false,
}

View File

@@ -72,7 +72,7 @@ export function getAddresses(
url,
disabled: !h.public,
isDomain: hostnameKind == 'domain',
acme:
authority:
hostnameKind == 'domain'
? host.domains[h.hostname.domain]?.acme || null
: null,
@@ -118,7 +118,7 @@ export type MappedServiceInterface = T.ServiceInterface & {
export type ClearnetAddress = {
url: string
acme: string | null
authority: string | null
isDomain: boolean
disabled: boolean
}

View File

@@ -1,83 +0,0 @@
import {
ChangeDetectionStrategy,
Component,
inject,
input,
} from '@angular/core'
import { i18nPipe } from '@start9labs/shared'
import {
TuiButton,
TuiDataList,
TuiDropdown,
TuiTextfield,
} from '@taiga-ui/core'
import { ACMEInfo, AcmeService } from './acme.service'
@Component({
selector: 'tr[acme]',
template: `
<td>{{ acme().name }}</td>
<td>{{ acme().contact.join(', ') }}</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)="service.edit(acme())"
>
{{ 'Edit' | i18n }}
</button>
</tui-opt-group>
<tui-opt-group>
<button
tuiOption
new
iconStart="@tui.trash"
class="g-negative"
(click)="service.remove(acme())"
>
{{ 'Delete' | i18n }}
</button>
</tui-opt-group>
</tui-data-list>
</button>
</td>
`,
styles: `
td:last-child {
grid-area: 1 / 2 / 3;
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 AcmeItemComponent {
protected readonly service = inject(AcmeService)
readonly acme = input.required<ACMEInfo>()
open = false
}

View File

@@ -1,41 +0,0 @@
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

@@ -13,18 +13,19 @@ 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 { knownAuthorities, toAuthorityName } from 'src/app/utils/acme'
import { configBuilderToSpec } from 'src/app/utils/configBuilderToSpec'
import { toAcmeName } from 'src/app/utils/acme'
export type ACMEInfo = {
export type Authority = {
url: string | null
name: string
url: string
contact: readonly string[]
contact: readonly string[] | null
}
export type RemoteAuthority = Authority & { url: string }
@Injectable()
export class AcmeService {
export class AuthorityService {
private readonly patch = inject<PatchDB<DataModel>>(PatchDB)
private readonly loader = inject(LoadingService)
private readonly errorService = inject(ErrorService)
@@ -33,31 +34,36 @@ export class AcmeService {
private readonly i18n = inject(i18nPipe)
private readonly dialog = inject(DialogService)
readonly acmes = toSignal<ACMEInfo[]>(
readonly authorities = toSignal<Authority[]>(
this.patch.watch$('serverInfo', 'network', 'acme').pipe(
map(acme =>
Object.keys(acme).map(url => ({
map(acme => [
{
url: null,
name: toAuthorityName(null),
contact: null,
},
...Object.keys(acme).map(url => ({
url,
name: toAcmeName(url),
name: toAuthorityName(url),
contact:
acme[url]?.contact.map(mailto => mailto.replace('mailto:', '')) ||
[],
null,
})),
),
]),
),
)
async add(acmes: ACMEInfo[]) {
const availableAcme = knownACME.filter(
acme => !acmes.map(a => a.url).includes(acme.url),
async add(authorities: Authority[]) {
const availableAuthorities = knownAuthorities.filter(
ca => !authorities.map(a => a.url).includes(ca.url),
)
const addSpec = ISB.InputSpec.of({
provider: ISB.Value.union({
name: 'Provider',
default: (availableAcme[0]?.url as any) || 'other',
default: (availableAuthorities[0]?.url as any) || 'other',
variants: ISB.Variants.of({
...availableAcme.reduce(
...availableAuthorities.reduce(
(obj, curr) => ({
...obj,
[curr.url]: {
@@ -85,7 +91,7 @@ export class AcmeService {
})
this.formDialog.open(FormComponent, {
label: 'Add ACME Provider',
label: 'Add Certificate Authority',
data: {
spec: await configBuilderToSpec(addSpec),
buttons: [
@@ -105,13 +111,13 @@ export class AcmeService {
})
}
async edit({ url, contact }: ACMEInfo) {
async edit({ url, contact }: RemoteAuthority) {
const editSpec = ISB.InputSpec.of({
contact: this.emailListSpec(),
})
this.formDialog.open(FormComponent, {
label: 'Edit ACME Provider',
label: 'Edit Contact Info',
data: {
spec: await configBuilderToSpec(editSpec),
buttons: [
@@ -126,7 +132,7 @@ export class AcmeService {
})
}
remove({ url }: ACMEInfo) {
remove({ url }: RemoteAuthority) {
this.dialog
.openConfirm({ label: 'Are you sure?', size: 's' })
.pipe(filter(Boolean))

View File

@@ -0,0 +1,99 @@
import {
ChangeDetectionStrategy,
Component,
inject,
input,
} from '@angular/core'
import { i18nPipe } from '@start9labs/shared'
import {
TuiButton,
TuiDataList,
TuiDropdown,
TuiTextfield,
} from '@taiga-ui/core'
import { Authority, AuthorityService } from './authority.service'
@Component({
selector: 'tr[authority]',
template: `
@if (authority(); as authority) {
<td>{{ authority.name }}</td>
<td>{{ authority.url || '-' }}</td>
<td>{{ authority.contact ? authority.contact.join(', ') : '-' }}</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>
@if (authority.url) {
<tui-opt-group>
<button
tuiOption
new
iconStart="@tui.pencil"
(click)="service.edit($any(authority))"
>
{{ 'Edit' | i18n }}
</button>
</tui-opt-group>
<tui-opt-group>
<button
tuiOption
new
iconStart="@tui.trash"
class="g-negative"
(click)="service.remove($any(authority))"
>
{{ 'Delete' | i18n }}
</button>
</tui-opt-group>
} @else {
<tui-opt-group>
<a
tuiOption
new
iconStart="@tui.download"
href="/static/local-root-ca.crt"
>
{{ 'Download your Root CA' | i18n }}
</a>
</tui-opt-group>
}
</tui-data-list>
</button>
</td>
}
`,
styles: `
td:last-child {
grid-area: 1 / 2 / 3;
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 AuthorityItemComponent {
protected readonly service = inject(AuthorityService)
readonly authority = input.required<Authority>()
open = false
}

View File

@@ -0,0 +1,26 @@
import { ChangeDetectionStrategy, Component, inject } from '@angular/core'
import { i18nPipe } from '@start9labs/shared'
import { TuiSkeleton } from '@taiga-ui/kit'
import { TableComponent } from 'src/app/routes/portal/components/table.component'
import { AuthorityItemComponent } from './item.component'
import { AuthorityService } from './authority.service'
@Component({
selector: 'authorities-table',
template: `
<table [appTable]="['Provider', 'URL', 'Contact', null]">
@for (authority of authorityService.authorities(); track $index) {
<tr [authority]="authority"></tr>
} @empty {
<td [attr.colspan]="4">
<div [tuiSkeleton]="true">{{ 'Loading' | i18n }}</div>
</td>
}
</table>
`,
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [TuiSkeleton, i18nPipe, TableComponent, AuthorityItemComponent],
})
export class AuthoritiesTableComponent {
protected readonly authorityService = inject(AuthorityService)
}

View File

@@ -4,10 +4,10 @@ import { DocsLinkDirective, i18nPipe } from '@start9labs/shared'
import { TuiButton, TuiLink, TuiTitle } from '@taiga-ui/core'
import { TuiHeader } from '@taiga-ui/layout'
import { TitleDirective } from 'src/app/services/title.service'
import { AcmeService } from './acme/acme.service'
import { DomainsService } from './domains/domains.service'
import { AuthorityService } from './authorities/authority.service'
import { DomainService } from './domains/domain.service'
import { DomainsTableComponent } from './domains/table.component'
import { AcmeTableComponent } from './acme/table.component'
import { AuthoritiesTableComponent } from './authorities/table.component'
@Component({
template: `
@@ -21,9 +21,9 @@ import { AcmeTableComponent } from './acme/table.component'
<hgroup tuiTitle>
<h3>{{ 'Domains' | i18n }}</h3>
<p tuiSubtitle>
<!-- @TODO translation -->
{{
'Adding a domain to StartOS means you can use it and its subdomains to host service interfaces on the public Internet.'
| i18n
}}
<a
tuiLink
@@ -40,38 +40,38 @@ import { AcmeTableComponent } from './acme/table.component'
<section class="g-card">
<header>
{{ 'ACME Providers' | i18n }}
@if (acmeService.acmes(); as acmes) {
{{ 'Certificate Authorities' | i18n }}
@if (authorityService.authorities(); as authorities) {
<button
tuiButton
size="xs"
iconStart="@tui.plus"
[style.margin-inline-start]="'auto'"
(click)="acmeService.add(acmes)"
(click)="authorityService.add(authorities)"
>
{{ 'Add' | i18n }}
</button>
}
</header>
<acme-table [acmes]="acmeService.acmes()" />
<authorities-table />
</section>
<section class="g-card">
<header>
{{ 'Domains' | i18n }}
@if (domainsService.data(); as value) {
@if (domainService.data(); as value) {
<button
tuiButton
size="xs"
iconStart="@tui.plus"
[style.margin-inline-start]="'auto'"
(click)="domainsService.add()"
(click)="domainService.add()"
>
Add
</button>
}
</header>
<domains-table [domains]="domainsService.data()?.domains" />
<domains-table />
</section>
`,
changeDetection: ChangeDetectionStrategy.OnPush,
@@ -85,11 +85,11 @@ import { AcmeTableComponent } from './acme/table.component'
i18nPipe,
DocsLinkDirective,
DomainsTableComponent,
AcmeTableComponent,
AuthoritiesTableComponent,
],
providers: [AcmeService, DomainsService],
providers: [AuthorityService, DomainService],
})
export default class SystemDomainsComponent {
protected readonly acmeService = inject(AcmeService)
protected readonly domainsService = inject(DomainsService)
protected readonly authorityService = inject(AuthorityService)
protected readonly domainService = inject(DomainService)
}

View File

@@ -14,12 +14,12 @@ 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'
import { toAuthorityName } from 'src/app/utils/acme'
// @TODO translations
@Injectable()
export class DomainsService {
export class DomainService {
private readonly patch = inject<PatchDB<DataModel>>(PatchDB)
private readonly loader = inject(LoadingService)
private readonly errorService = inject(ErrorService)
@@ -46,32 +46,32 @@ export class DomainsService {
{
domain: 'blog.mydomain.com',
gateway: {
id: '',
id: 'wireguard1',
name: 'StartTunnel',
},
acme: {
url: '',
name: `Lert's Encrypt`,
authority: {
url: 'https://acme-v02.api.letsencrypt.org/directory',
name: `Let's Encrypt`,
},
},
{
domain: 'store.mydomain.com',
gateway: {
id: '',
id: 'eth0',
name: 'Ethernet',
},
acme: {
url: null,
name: 'System',
authority: {
url: 'local',
name: toAuthorityName(null),
},
},
],
acme: Object.keys(network.acme).reduce<Record<string, string>>(
authorities: Object.keys(network.acme).reduce<Record<string, string>>(
(obj, url) => ({
...obj,
[url]: toAcmeName(url),
[url]: toAuthorityName(url),
}),
{ none: 'None (use system Root CA)' },
{ local: toAuthorityName(null) },
),
}
}),
@@ -88,7 +88,7 @@ export class DomainsService {
default: null,
patterns: [utils.Patterns.domain],
}),
...this.gatewaysAndAcme(),
...this.gatewaysAndAuthorities(),
})
this.formDialog.open(FormComponent, {
@@ -107,11 +107,11 @@ export class DomainsService {
async edit(domain: any) {
const editSpec = ISB.InputSpec.of({
...this.gatewaysAndAcme(),
...this.gatewaysAndAuthorities(),
})
this.formDialog.open(FormComponent, {
label: 'Edit Domain' as any, // @TODO translation
label: 'Edit Domain',
data: {
spec: await configBuilderToSpec(editSpec),
buttons: [
@@ -126,7 +126,7 @@ export class DomainsService {
],
value: {
gateway: domain.gateway.id,
acme: domain.acme.url,
authority: domain.authority.url,
},
},
})
@@ -172,7 +172,7 @@ export class DomainsService {
}
}
private gatewaysAndAcme() {
private gatewaysAndAuthorities() {
return {
gateway: ISB.Value.select({
name: 'Gateway',
@@ -181,11 +181,11 @@ export class DomainsService {
values: this.data()!.gateways,
default: '',
}),
acme: ISB.Value.select({
name: 'Default ACME',
authority: ISB.Value.select({
name: 'Default Certificate Authority',
description:
'Select the default ACME provider for this domain. This can be overridden on a case-by-case basis.',
values: this.data()!.acme,
'Select the default certificate authority that will sign certificates for this domain. You can override this on a case-by-case basis.',
values: this.data()!.authorities,
default: '',
}),
}

View File

@@ -11,66 +11,68 @@ import {
TuiDropdown,
TuiTextfield,
} from '@taiga-ui/core'
import { DomainsService } from './domains.service'
import { DomainService } from './domain.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>
@if (domain(); as domain) {
<td>{{ domain.domain }}</td>
<td [style.order]="-1">{{ domain.gateway.name }}</td>
<td>{{ domain.authority.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)="domainService.edit(domain)"
>
{{ 'Edit' | i18n }}
</button>
<button
tuiOption
new
iconStart="@tui.eye"
(click)="domainService.showDns(domain)"
>
{{ 'Show DNS' | i18n }}
</button>
<button
tuiOption
new
iconStart="@tui.arrow-up-down"
(click)="domainService.testDns(domain)"
>
{{ 'Test DNS' | i18n }}
</button>
</tui-opt-group>
<tui-opt-group>
<button
tuiOption
new
iconStart="@tui.trash"
class="g-negative"
(click)="domainService.remove(domain)"
>
{{ 'Delete' | i18n }}
</button>
</tui-opt-group>
</tui-data-list>
</button>
</td>
}
`,
styles: `
td:last-child {
@@ -91,8 +93,8 @@ import { DomainsService } from './domains.service'
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [TuiButton, i18nPipe, TuiDropdown, TuiDataList, TuiTextfield],
})
export class DomainsItemComponent {
protected readonly domainsService = inject(DomainsService)
export class DomainItemComponent {
protected readonly domainService = inject(DomainService)
readonly domain = input.required<any>()

View File

@@ -1,20 +1,23 @@
import { ChangeDetectionStrategy, Component, input } from '@angular/core'
import { ChangeDetectionStrategy, Component, inject } 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'
import { DomainItemComponent } from './item.component'
import { DomainService } from './domain.service'
@Component({
selector: 'domains-table',
template: `
<table [appTable]="['Domain', 'Gateway', 'Default ACME', null]">
@for (domain of domains(); track $index) {
<table
[appTable]="['Domain', 'Gateway', 'Default Certificate Authority', null]"
>
@for (domain of domainService.data()?.domains; track $index) {
<tr [domain]="domain"></tr>
} @empty {
<tr>
<td [attr.colspan]="4">
@if (domains()) {
@if (domainService.data()?.domains) {
<app-placeholder icon="@tui.globe">
{{ 'No domains' | i18n }}
</app-placeholder>
@@ -32,10 +35,9 @@ import { DomainsItemComponent } from './item.component'
i18nPipe,
TableComponent,
PlaceholderComponent,
DomainsItemComponent,
DomainItemComponent,
],
})
export class DomainsTableComponent {
// @TODO Alex proper types
readonly domains = input<readonly any[] | null>()
protected readonly domainService = inject(DomainService)
}

View File

@@ -12,7 +12,6 @@ import { TuiIcon } from '@taiga-ui/core'
import { TuiCheckbox, TuiFade, TuiSkeleton } from '@taiga-ui/kit'
import { TableComponent } from 'src/app/routes/portal/components/table.component'
import { Session } from 'src/app/services/api/api.types'
import { toAcmeName } from 'src/app/utils/acme'
import { PlatformInfoPipe } from './platform-info.pipe'
import { i18nPipe } from '@start9labs/shared'
@@ -182,6 +181,4 @@ export class SessionsTableComponent<T extends Session> implements OnChanges {
this.selected.update(selected => [...selected, session])
}
}
protected readonly toAcmeName = toAcmeName
}

View File

@@ -38,16 +38,16 @@ export const SYSTEM_MENU = [
},
],
[
{
icon: '@tui.globe',
item: 'Domains',
link: 'domains',
},
{
icon: '@tui.door-open',
item: 'Gateways',
link: 'gateways',
},
{
icon: '@tui.globe',
item: 'Domains',
link: 'domains',
},
],
[
{

View File

@@ -24,7 +24,7 @@ import { AuthService } from '../auth.service'
import { T } from '@start9labs/start-sdk'
import { MarketplacePkg } from '@start9labs/marketplace'
import { WebSocketSubject } from 'rxjs/webSocket'
import { toAcmeUrl } from 'src/app/utils/acme'
import { toAuthorityUrl } from 'src/app/utils/acme'
import markdown from './md-sample.md'
@@ -1396,7 +1396,7 @@ export class MockApiService extends ApiService {
op: PatchOp.ADD,
path: `/serverInfo/acme`,
value: {
[toAcmeUrl(params.provider)]: { contact: params.contact },
[toAuthorityUrl(params.provider)]: { contact: params.contact },
},
},
]

View File

@@ -1,6 +1,6 @@
import { DataModel } from 'src/app/services/patch-db/data-model'
import { Mock } from './api.fixures'
import { knownACME } from 'src/app/utils/acme'
import { knownAuthorities } from 'src/app/utils/acme'
const version = require('../../../../../../package.json').version
export const mockPatchData: DataModel = {
@@ -28,7 +28,7 @@ export const mockPatchData: DataModel = {
lastRegion: null,
},
acme: {
[knownACME[0].url]: {
[knownAuthorities[0].url]: {
contact: ['mailto:support@start9.com'],
},
},
@@ -160,6 +160,20 @@ export const mockPatchData: DataModel = {
ntpServers: [],
},
},
wireguard1: {
public: false,
ipInfo: {
name: 'StartTunnel',
scopeId: 2,
deviceType: 'wireguard',
subnets: [
'10.0.90.12/24',
'fe80::cd00:0000:0cde:1257:0000:211e:72cd/64',
],
wanIp: '203.0.113.45',
ntpServers: [],
},
},
},
},
unreadNotificationCount: 4,

View File

@@ -1,12 +1,14 @@
export function toAcmeName(url: string | null): string | 'System CA' {
return knownACME.find(acme => acme.url === url)?.name || url || 'System CA'
export function toAuthorityName(url: string | null): string | 'Local Root CA' {
return (
knownAuthorities.find(ca => ca.url === url)?.name || url || 'Local Root CA'
)
}
export function toAcmeUrl(name: string): string {
return knownACME.find(acme => acme.name === name)?.url || name
export function toAuthorityUrl(name: string): string {
return knownAuthorities.find(ca => ca.name === name)?.url || name
}
export const knownACME = [
export const knownAuthorities = [
{
name: `Let's Encrypt`,
url: 'https://acme-v02.api.letsencrypt.org/directory',