mirror of
https://github.com/Start9Labs/start-os.git
synced 2026-03-26 10:21:52 +00:00
task fix and keyboard fix (#3130)
* task fix and keyboard fix
* fixes for build scripts
* passthrough feature
* feat: inline domain health checks and improve address UX
- addPublicDomain returns DNS query + port check results (AddPublicDomainRes)
so frontend skips separate API calls after adding a domain
- addPrivateDomain returns check_dns result for the gateway
- Support multiple ports per domain in validation modal (deduplicated)
- Run port checks concurrently via futures::future::join_all
- Add note to add-domain dialog showing other interfaces on same host
- Add addXForwardedHeaders to knownProtocols in SDK Host.ts
- Add plugin filter kind, pluginId filter, matchesAny, and docs to
getServiceInterface.ts
- Add PassthroughInfo type and passthroughs field to NetworkInfo
- Pluralize "port forwarding rules" in i18n dictionaries
* feat: add shared host note to private domain dialog with i18n
* fix: scope public domain to single binding and return single port check
Accept internalPort in AddPublicDomainParams to target a specific
binding. Disable the domain on all other bindings. Return a single
CheckPortRes instead of Vec. Revert multi-port UI to singular port
display from 0f8a66b35.
* better shared hostname approach, and improve look-feel of addresses tables
* fix starttls
* preserve usb as top efi boot option
* fix race condition in wan ip check
* sdk beta.56
* various bug, improve smtp
* multiple bugs, better outbound gateway UX
* remove non option from smtp for better package compat
* bump sdk
---------
Co-authored-by: Aiden McClelland <me@drbonez.dev>
This commit is contained in:
@@ -31,6 +31,7 @@ export interface FormContext<T> {
|
||||
buttons: ActionButton<T>[]
|
||||
value?: T
|
||||
operations?: Operation[]
|
||||
note?: string
|
||||
}
|
||||
|
||||
@Component({
|
||||
@@ -43,6 +44,9 @@ export interface FormContext<T> {
|
||||
(tuiValueChanges)="markAsDirty()"
|
||||
>
|
||||
<form-group [spec]="spec" />
|
||||
@if (note) {
|
||||
<p class="note">{{ note }}</p>
|
||||
}
|
||||
<footer>
|
||||
<ng-content />
|
||||
@for (button of buttons; track $index) {
|
||||
@@ -70,6 +74,12 @@ export interface FormContext<T> {
|
||||
</form>
|
||||
`,
|
||||
styles: `
|
||||
.note {
|
||||
color: var(--tui-text-secondary);
|
||||
font: var(--tui-font-text-s);
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
footer {
|
||||
position: sticky;
|
||||
bottom: 0;
|
||||
@@ -106,6 +116,7 @@ export class FormComponent<T extends Record<string, any>> implements OnInit {
|
||||
@Input() buttons = this.context?.data.buttons || []
|
||||
@Input() operations = this.context?.data.operations || []
|
||||
@Input() value?: T = this.context?.data.value
|
||||
@Input() note = this.context?.data.note || ''
|
||||
|
||||
form = new FormGroup({})
|
||||
|
||||
|
||||
@@ -45,7 +45,7 @@ import { ABOUT } from './about.component'
|
||||
}
|
||||
<tui-data-list [style.width.rem]="13">
|
||||
<tui-opt-group>
|
||||
<button tuiOption iconStart="@tui.info" (click)="about()">
|
||||
<button tuiOption iconStart="@tui.info" new (click)="about()">
|
||||
{{ 'About this server' | i18n }}
|
||||
</button>
|
||||
</tui-opt-group>
|
||||
@@ -53,13 +53,15 @@ import { ABOUT } from './about.component'
|
||||
<a
|
||||
tuiOption
|
||||
docsLink
|
||||
iconStart="@tui.book-open"
|
||||
path="/start-os/user-manual/index.html"
|
||||
new
|
||||
iconStart="@tui.book-open-text"
|
||||
path="/start-os/user-manual"
|
||||
>
|
||||
{{ 'User manual' | i18n }}
|
||||
</a>
|
||||
<a
|
||||
tuiOption
|
||||
new
|
||||
iconStart="@tui.headphones"
|
||||
href="https://start9.com/contact"
|
||||
>
|
||||
@@ -67,6 +69,7 @@ import { ABOUT } from './about.component'
|
||||
</a>
|
||||
<a
|
||||
tuiOption
|
||||
new
|
||||
iconStart="@tui.dollar-sign"
|
||||
href="https://donate.start9.com"
|
||||
>
|
||||
@@ -76,6 +79,7 @@ import { ABOUT } from './about.component'
|
||||
<tui-opt-group label="">
|
||||
<a
|
||||
tuiOption
|
||||
new
|
||||
iconStart="@tui.settings"
|
||||
routerLink="/system"
|
||||
(click)="open = false"
|
||||
@@ -86,6 +90,7 @@ import { ABOUT } from './about.component'
|
||||
<tui-opt-group label="">
|
||||
<button
|
||||
tuiOption
|
||||
new
|
||||
iconStart="@tui.refresh-cw"
|
||||
(click)="promptPower('restart')"
|
||||
>
|
||||
@@ -93,12 +98,13 @@ import { ABOUT } from './about.component'
|
||||
</button>
|
||||
<button
|
||||
tuiOption
|
||||
new
|
||||
iconStart="@tui.power"
|
||||
(click)="promptPower('shutdown')"
|
||||
>
|
||||
{{ 'Shutdown' | i18n }}
|
||||
</button>
|
||||
<button tuiOption iconStart="@tui.log-out" (click)="logout()">
|
||||
<button tuiOption new iconStart="@tui.log-out" (click)="logout()">
|
||||
{{ 'Logout' | i18n }}
|
||||
</button>
|
||||
</tui-opt-group>
|
||||
|
||||
@@ -30,19 +30,6 @@ import { DomainHealthService } from './domain-health.service'
|
||||
selector: 'td[actions]',
|
||||
template: `
|
||||
<div class="desktop">
|
||||
@if (address().ui) {
|
||||
<a
|
||||
tuiIconButton
|
||||
appearance="flat-grayscale"
|
||||
iconStart="@tui.external-link"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
[attr.href]="address().enabled ? address().url : null"
|
||||
[class.disabled]="!address().enabled"
|
||||
>
|
||||
{{ 'Open UI' | i18n }}
|
||||
</a>
|
||||
}
|
||||
@if (address().deletable) {
|
||||
<button
|
||||
tuiIconButton
|
||||
@@ -87,6 +74,19 @@ import { DomainHealthService } from './domain-health.service'
|
||||
{{ 'Address Requirements' | i18n }}
|
||||
</button>
|
||||
}
|
||||
@if (address().ui) {
|
||||
<a
|
||||
tuiIconButton
|
||||
appearance="flat-grayscale"
|
||||
iconStart="@tui.external-link"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
[attr.href]="address().enabled ? address().url : null"
|
||||
[class.disabled]="!address().enabled"
|
||||
>
|
||||
{{ 'Open UI' | i18n }}
|
||||
</a>
|
||||
}
|
||||
<button
|
||||
tuiIconButton
|
||||
appearance="flat-grayscale"
|
||||
|
||||
@@ -37,7 +37,7 @@ import { InterfaceAddressItemComponent } from './item.component'
|
||||
selector: 'section[gatewayGroup]',
|
||||
template: `
|
||||
<header>
|
||||
{{ gatewayGroup().gatewayName }}
|
||||
{{ 'Gateway' | i18n }}: {{ gatewayGroup().gatewayName }}
|
||||
<button
|
||||
tuiDropdown
|
||||
tuiButton
|
||||
@@ -57,7 +57,14 @@ import { InterfaceAddressItemComponent } from './item.component'
|
||||
</button>
|
||||
</header>
|
||||
<table
|
||||
[appTable]="['Enabled', 'Type', 'Certificate Authority', 'URL', null]"
|
||||
[appTable]="[
|
||||
null,
|
||||
'Access',
|
||||
'Type',
|
||||
'Certificate Authority',
|
||||
'URL',
|
||||
null,
|
||||
]"
|
||||
>
|
||||
@for (address of gatewayGroup().addresses; track $index) {
|
||||
<tr
|
||||
@@ -69,7 +76,7 @@ import { InterfaceAddressItemComponent } from './item.component'
|
||||
></tr>
|
||||
} @empty {
|
||||
<tr>
|
||||
<td colspan="5">
|
||||
<td colspan="6">
|
||||
<app-placeholder icon="@tui.list-x">
|
||||
{{ 'No addresses' | i18n }}
|
||||
</app-placeholder>
|
||||
@@ -132,6 +139,7 @@ export class InterfaceAddressesComponent {
|
||||
}),
|
||||
}),
|
||||
),
|
||||
note: this.getSharedHostNote(),
|
||||
buttons: [
|
||||
{
|
||||
text: this.i18n.transform('Save')!,
|
||||
@@ -190,6 +198,7 @@ export class InterfaceAddressesComponent {
|
||||
size: 's',
|
||||
data: {
|
||||
spec: await configBuilderToSpec(addSpec),
|
||||
note: this.getSharedHostNote(),
|
||||
buttons: [
|
||||
{
|
||||
text: this.i18n.transform('Save')!,
|
||||
@@ -207,18 +216,22 @@ export class InterfaceAddressesComponent {
|
||||
const loader = this.loader.open('Saving').subscribe()
|
||||
|
||||
try {
|
||||
let configured: boolean
|
||||
if (this.packageId()) {
|
||||
await this.api.pkgAddPrivateDomain({
|
||||
configured = await this.api.pkgAddPrivateDomain({
|
||||
fqdn,
|
||||
gateway: gatewayId,
|
||||
package: this.packageId(),
|
||||
host: iface?.addressInfo.hostId || '',
|
||||
})
|
||||
} else {
|
||||
await this.api.osUiAddPrivateDomain({ fqdn, gateway: gatewayId })
|
||||
configured = await this.api.osUiAddPrivateDomain({
|
||||
fqdn,
|
||||
gateway: gatewayId,
|
||||
})
|
||||
}
|
||||
|
||||
await this.domainHealth.checkPrivateDomain(gatewayId)
|
||||
await this.domainHealth.checkPrivateDomain(gatewayId, configured)
|
||||
|
||||
return true
|
||||
} catch (e: any) {
|
||||
@@ -229,6 +242,13 @@ export class InterfaceAddressesComponent {
|
||||
}
|
||||
}
|
||||
|
||||
private getSharedHostNote(): string {
|
||||
const names = this.value()?.sharedHostNames
|
||||
if (!names?.length) return ''
|
||||
|
||||
return `${this.i18n.transform('This domain will also apply to')} ${names.join(', ')}`
|
||||
}
|
||||
|
||||
private async savePublicDomain(
|
||||
fqdn: string,
|
||||
authority?: 'local' | string,
|
||||
@@ -241,26 +261,22 @@ export class InterfaceAddressesComponent {
|
||||
fqdn,
|
||||
gateway: gatewayId,
|
||||
acme: !authority || authority === 'local' ? null : authority,
|
||||
internalPort: iface?.addressInfo.internalPort || 80,
|
||||
}
|
||||
|
||||
try {
|
||||
let res
|
||||
if (this.packageId()) {
|
||||
await this.api.pkgAddPublicDomain({
|
||||
res = await this.api.pkgAddPublicDomain({
|
||||
...params,
|
||||
package: this.packageId(),
|
||||
host: iface?.addressInfo.hostId || '',
|
||||
})
|
||||
} else {
|
||||
await this.api.osUiAddPublicDomain(params)
|
||||
res = await this.api.osUiAddPublicDomain(params)
|
||||
}
|
||||
|
||||
const port = this.gatewayGroup().addresses.find(
|
||||
a => a.access === 'public' && a.hostnameInfo.port !== null,
|
||||
)?.hostnameInfo.port
|
||||
|
||||
if (port !== undefined && port !== null) {
|
||||
await this.domainHealth.checkPublicDomain(fqdn, gatewayId, port)
|
||||
}
|
||||
await this.domainHealth.checkPublicDomain(fqdn, gatewayId, res)
|
||||
|
||||
return true
|
||||
} catch (e: any) {
|
||||
|
||||
@@ -19,21 +19,34 @@ export class DomainHealthService {
|
||||
async checkPublicDomain(
|
||||
fqdn: string,
|
||||
gatewayId: string,
|
||||
port: number,
|
||||
portOrRes: number | T.AddPublicDomainRes,
|
||||
): Promise<void> {
|
||||
try {
|
||||
const gateway = await this.getGatewayData(gatewayId)
|
||||
if (!gateway) return
|
||||
|
||||
const [dnsPass, portResult] = await Promise.all([
|
||||
this.api
|
||||
.queryDns({ fqdn })
|
||||
.then(ip => ip === gateway.ipInfo.wanIp)
|
||||
.catch(() => false),
|
||||
this.api
|
||||
.checkPort({ gateway: gatewayId, port })
|
||||
.catch((): null => null),
|
||||
])
|
||||
let dnsPass: boolean
|
||||
let port: number
|
||||
let portResult: T.CheckPortRes | null
|
||||
|
||||
if (typeof portOrRes === 'number') {
|
||||
port = portOrRes
|
||||
const [dns, portRes] = await Promise.all([
|
||||
this.api
|
||||
.queryDns({ fqdn })
|
||||
.then(ip => ip === gateway.ipInfo.wanIp)
|
||||
.catch(() => false),
|
||||
this.api
|
||||
.checkPort({ gateway: gatewayId, port: portOrRes })
|
||||
.catch((): null => null),
|
||||
])
|
||||
dnsPass = dns
|
||||
portResult = portRes
|
||||
} else {
|
||||
dnsPass = portOrRes.dns === gateway.ipInfo.wanIp
|
||||
port = portOrRes.port.port
|
||||
portResult = portOrRes.port
|
||||
}
|
||||
|
||||
const portOk =
|
||||
!!portResult?.openInternally &&
|
||||
@@ -55,14 +68,17 @@ export class DomainHealthService {
|
||||
}
|
||||
}
|
||||
|
||||
async checkPrivateDomain(gatewayId: string): Promise<void> {
|
||||
async checkPrivateDomain(
|
||||
gatewayId: string,
|
||||
prefetchedConfigured?: boolean,
|
||||
): Promise<void> {
|
||||
try {
|
||||
const gateway = await this.getGatewayData(gatewayId)
|
||||
if (!gateway) return
|
||||
|
||||
const configured = await this.api
|
||||
.checkDns({ gateway: gatewayId })
|
||||
.catch(() => false)
|
||||
const configured =
|
||||
prefetchedConfigured ??
|
||||
(await this.api.checkDns({ gateway: gatewayId }).catch(() => false))
|
||||
|
||||
if (!configured) {
|
||||
setTimeout(
|
||||
@@ -150,7 +166,10 @@ export class DomainHealthService {
|
||||
fqdn: string,
|
||||
gateway: DnsGateway,
|
||||
port: number,
|
||||
initialResults?: { dnsPass: boolean; portResult: T.CheckPortRes | null },
|
||||
initialResults?: {
|
||||
dnsPass: boolean
|
||||
portResult: T.CheckPortRes | null
|
||||
},
|
||||
) {
|
||||
this.dialog
|
||||
.openComponent(DOMAIN_VALIDATION, {
|
||||
|
||||
@@ -10,7 +10,7 @@ import { ErrorService, i18nPipe, LoadingService } from '@start9labs/shared'
|
||||
import { TuiObfuscatePipe } from '@taiga-ui/cdk'
|
||||
import { TuiButton, TuiIcon } from '@taiga-ui/core'
|
||||
import { FormsModule } from '@angular/forms'
|
||||
import { TuiSwitch } from '@taiga-ui/kit'
|
||||
import { TuiBadge, TuiSwitch } from '@taiga-ui/kit'
|
||||
import { ApiService } from 'src/app/services/api/embassy-api.service'
|
||||
import { GatewayAddress, MappedServiceInterface } from '../interface.service'
|
||||
import { AddressActionsComponent } from './actions.component'
|
||||
@@ -36,22 +36,51 @@ import { DomainHealthService } from './domain-health.service'
|
||||
(ngModelChange)="onToggleEnabled()"
|
||||
/>
|
||||
</td>
|
||||
<td class="type">
|
||||
<td class="access">
|
||||
<tui-icon
|
||||
[icon]="address.access === 'public' ? '@tui.globe' : '@tui.house'"
|
||||
/>
|
||||
{{ address.type }}
|
||||
<span>
|
||||
{{ (address.access === 'public' ? 'Public' : 'Local') | i18n }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="type">
|
||||
<tui-badge
|
||||
size="s"
|
||||
[appearance]="typeAppearance(address.hostnameInfo.metadata.kind)"
|
||||
>
|
||||
{{ address.type }}
|
||||
</tui-badge>
|
||||
</td>
|
||||
<td>
|
||||
{{ address.certificate }}
|
||||
<div class="cert">
|
||||
@if (address.certificate === 'Root CA') {
|
||||
<img src="assets/icons/favicon.svg" alt="" class="cert-icon" />
|
||||
} @else if (address.certificate.startsWith("Let's Encrypt")) {
|
||||
<img src="assets/icons/letsencrypt.svg" alt="" class="cert-icon" />
|
||||
} @else if (
|
||||
address.certificate !== '-' && address.certificate !== 'Self signed'
|
||||
) {
|
||||
<tui-icon icon="@tui.shield" class="cert-icon" />
|
||||
}
|
||||
{{ address.certificate }}
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<div class="url">
|
||||
<span
|
||||
[title]="address.masked && currentlyMasked() ? '' : address.url"
|
||||
>
|
||||
{{ address.url | tuiObfuscate: recipe() }}
|
||||
</span>
|
||||
@if (address.masked && currentlyMasked()) {
|
||||
<span>{{ address.url | tuiObfuscate: 'mask' }}</span>
|
||||
} @else {
|
||||
<span [title]="address.url">
|
||||
@if (urlParts(); as parts) {
|
||||
{{ parts.prefix }}
|
||||
<b>{{ parts.hostname }}</b>
|
||||
{{ parts.suffix }}
|
||||
} @else {
|
||||
{{ address.url }}
|
||||
}
|
||||
</span>
|
||||
}
|
||||
@if (address.masked) {
|
||||
<button
|
||||
tuiIconButton
|
||||
@@ -81,12 +110,28 @@ import { DomainHealthService } from './domain-health.service'
|
||||
grid-template-columns: fit-content(10rem) 1fr 2rem 2rem;
|
||||
}
|
||||
|
||||
.type tui-icon {
|
||||
.access tui-icon {
|
||||
font-size: 1.3rem;
|
||||
margin-right: 0.7rem;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.cert {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.cert-icon {
|
||||
height: 1.25rem;
|
||||
width: 1.25rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
tui-icon.cert-icon {
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
|
||||
.url {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -104,6 +149,7 @@ import { DomainHealthService } from './domain-health.service'
|
||||
|
||||
:host-context(tui-root._mobile) {
|
||||
padding-inline-start: 0.75rem !important;
|
||||
row-gap: 0.25rem;
|
||||
|
||||
&::before {
|
||||
content: '';
|
||||
@@ -129,18 +175,32 @@ import { DomainHealthService } from './domain-health.service'
|
||||
display: none;
|
||||
}
|
||||
|
||||
td:nth-child(2) {
|
||||
.access {
|
||||
padding-right: 0;
|
||||
font: var(--tui-font-text-m);
|
||||
font-weight: bold;
|
||||
|
||||
tui-icon {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.type {
|
||||
font: var(--tui-font-text-m);
|
||||
font-weight: bold;
|
||||
color: var(--tui-text-primary);
|
||||
padding-inline-end: 0.5rem;
|
||||
}
|
||||
|
||||
td:nth-child(3) {
|
||||
td:nth-child(4) {
|
||||
grid-area: 2 / 1 / 2 / 3;
|
||||
|
||||
.cert-icon {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
td:nth-child(4) {
|
||||
td:nth-child(5) {
|
||||
grid-area: 3 / 1 / 3 / 3;
|
||||
}
|
||||
|
||||
@@ -154,6 +214,7 @@ import { DomainHealthService } from './domain-health.service'
|
||||
imports: [
|
||||
i18nPipe,
|
||||
AddressActionsComponent,
|
||||
TuiBadge,
|
||||
TuiButton,
|
||||
TuiIcon,
|
||||
TuiObfuscatePipe,
|
||||
@@ -180,6 +241,33 @@ export class InterfaceAddressItemComponent {
|
||||
this.address()?.masked && this.currentlyMasked() ? 'mask' : 'none',
|
||||
)
|
||||
|
||||
readonly urlParts = computed(() => {
|
||||
const { url, hostnameInfo } = this.address()
|
||||
const idx = url.indexOf(hostnameInfo.hostname)
|
||||
if (idx === -1) return null
|
||||
return {
|
||||
prefix: url.slice(0, idx),
|
||||
hostname: hostnameInfo.hostname,
|
||||
suffix: url.slice(idx + hostnameInfo.hostname.length),
|
||||
}
|
||||
})
|
||||
|
||||
typeAppearance(kind: string): string {
|
||||
switch (kind) {
|
||||
case 'public-domain':
|
||||
case 'private-domain':
|
||||
return 'info'
|
||||
case 'mdns':
|
||||
return 'positive'
|
||||
case 'ipv4':
|
||||
return 'warning'
|
||||
case 'ipv6':
|
||||
return 'neutral'
|
||||
default:
|
||||
return 'neutral'
|
||||
}
|
||||
}
|
||||
|
||||
async onToggleEnabled() {
|
||||
const addr = this.address()
|
||||
const iface = this.value()
|
||||
|
||||
@@ -32,7 +32,7 @@ import {
|
||||
@if (pluginGroup().pluginPkgInfo; as pkgInfo) {
|
||||
<img [src]="pkgInfo.icon" alt="" class="plugin-icon" />
|
||||
}
|
||||
{{ pluginGroup().pluginName }}
|
||||
{{ 'Plugin' | i18n }}: {{ pluginGroup().pluginName }}
|
||||
@if (pluginGroup().tableAction; as action) {
|
||||
<button
|
||||
tuiButton
|
||||
|
||||
@@ -81,7 +81,7 @@ function getAddressType(h: T.HostnameInfo): string {
|
||||
return 'IPv6'
|
||||
case 'public-domain':
|
||||
case 'private-domain':
|
||||
return h.hostname
|
||||
return 'Domain'
|
||||
case 'mdns':
|
||||
return 'mDNS'
|
||||
case 'plugin':
|
||||
@@ -116,7 +116,12 @@ export class InterfaceService {
|
||||
gatewayMap.set(gateway.id, gateway)
|
||||
}
|
||||
|
||||
for (const h of addr.available) {
|
||||
const available =
|
||||
this.config.accessType === 'localhost'
|
||||
? addr.available
|
||||
: utils.filterNonLocal(addr.available)
|
||||
|
||||
for (const h of available) {
|
||||
const gatewayIds = getGatewayIds(h)
|
||||
for (const gid of gatewayIds) {
|
||||
const list = groupMap.get(gid)
|
||||
@@ -337,4 +342,5 @@ export type MappedServiceInterface = T.ServiceInterface & {
|
||||
gatewayGroups: GatewayAddressGroup[]
|
||||
pluginGroups: PluginAddressGroup[]
|
||||
addSsl: boolean
|
||||
sharedHostNames: string[]
|
||||
}
|
||||
|
||||
@@ -28,7 +28,7 @@ interface ActionItem {
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
imports: [TuiTitle],
|
||||
host: {
|
||||
'[disabled]': '!!disabled() || inactive()',
|
||||
'[attr.disabled]': '(!!disabled() || inactive()) || null',
|
||||
},
|
||||
})
|
||||
export class ServiceActionComponent {
|
||||
|
||||
@@ -23,7 +23,6 @@ import {
|
||||
ALLOWED_STATUSES,
|
||||
getInstalledBaseStatus,
|
||||
INACTIVE_STATUSES,
|
||||
renderPkgStatus,
|
||||
} from 'src/app/services/pkg-status-rendering.service'
|
||||
import { getManifest } from 'src/app/utils/get-package-data'
|
||||
|
||||
@@ -153,7 +152,7 @@ export class ServiceTaskComponent {
|
||||
const action = pkg.actions[this.task().actionId]
|
||||
if (!action) return this.i18n.transform('Action not found')!
|
||||
|
||||
const status = renderPkgStatus(pkg).primary
|
||||
const status = getInstalledBaseStatus(pkg.statusInfo)
|
||||
|
||||
if (INACTIVE_STATUSES.includes(status)) return status as string
|
||||
|
||||
|
||||
@@ -24,9 +24,9 @@ import { getManifest } from 'src/app/utils/get-package-data'
|
||||
import { ServiceActionComponent } from '../components/action.component'
|
||||
import {
|
||||
ALLOWED_STATUSES,
|
||||
BaseStatus,
|
||||
getInstalledBaseStatus,
|
||||
INACTIVE_STATUSES,
|
||||
PrimaryStatus,
|
||||
renderPkgStatus,
|
||||
} from 'src/app/services/pkg-status-rendering.service'
|
||||
import { FormDialogService } from 'src/app/services/form-dialog.service'
|
||||
import { FormComponent } from 'src/app/routes/portal/components/form.component'
|
||||
@@ -108,7 +108,7 @@ export default class ServiceActionsRoute {
|
||||
const specialGroup = Object.values(pkg.actions).some(a => !!a.group)
|
||||
? 'Other'
|
||||
: 'General'
|
||||
const status = renderPkgStatus(pkg).primary
|
||||
const status = getInstalledBaseStatus(pkg.statusInfo)
|
||||
return {
|
||||
status,
|
||||
icon: pkg.icon,
|
||||
@@ -187,7 +187,7 @@ export default class ServiceActionsRoute {
|
||||
}
|
||||
|
||||
handle(
|
||||
status: PrimaryStatus,
|
||||
status: BaseStatus,
|
||||
icon: string,
|
||||
{ id, title }: T.Manifest,
|
||||
action: T.ActionMetadata & { id: string },
|
||||
|
||||
@@ -125,6 +125,10 @@ export default class ServiceInterfaceRoute {
|
||||
const binding = host.bindings[port]
|
||||
const gateways = this.gatewayService.gateways() || []
|
||||
|
||||
const sharedHostNames = Object.values(serviceInterfaces)
|
||||
.filter(si => si.addressInfo.hostId === key && si.id !== iFace.id)
|
||||
.map(si => si.name)
|
||||
|
||||
return {
|
||||
...iFace,
|
||||
gatewayGroups: this.interfaceService.getGatewayGroups(
|
||||
@@ -132,8 +136,13 @@ export default class ServiceInterfaceRoute {
|
||||
host,
|
||||
gateways,
|
||||
),
|
||||
pluginGroups: this.interfaceService.getPluginGroups(iFace, host, this.allPackageData()),
|
||||
pluginGroups: this.interfaceService.getPluginGroups(
|
||||
iFace,
|
||||
host,
|
||||
this.allPackageData(),
|
||||
),
|
||||
addSsl: !!binding?.options.addSsl,
|
||||
sharedHostNames,
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -23,7 +23,7 @@ import { AuthoritiesTableComponent } from './table.component'
|
||||
docsLink
|
||||
path="/start-os/user-manual/trust-ca.html"
|
||||
appearance="icon"
|
||||
iconStart="@tui.external-link"
|
||||
iconStart="@tui.book-open-text"
|
||||
>
|
||||
{{ 'Documentation' | i18n }}
|
||||
</a>
|
||||
|
||||
@@ -49,7 +49,7 @@ const ipv6 =
|
||||
docsLink
|
||||
path="/start-os/user-manual/dns.html"
|
||||
appearance="icon"
|
||||
iconStart="@tui.external-link"
|
||||
iconStart="@tui.book-open-text"
|
||||
>
|
||||
{{ 'Documentation' | i18n }}
|
||||
</a>
|
||||
|
||||
@@ -1,258 +0,0 @@
|
||||
import { CommonModule } from '@angular/common'
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
inject,
|
||||
signal,
|
||||
} from '@angular/core'
|
||||
import { FormsModule, ReactiveFormsModule } from '@angular/forms'
|
||||
import { RouterLink } from '@angular/router'
|
||||
import {
|
||||
DialogService,
|
||||
DocsLinkDirective,
|
||||
ErrorService,
|
||||
i18nKey,
|
||||
i18nPipe,
|
||||
LoadingService,
|
||||
} from '@start9labs/shared'
|
||||
import { inputSpec } from '@start9labs/start-sdk'
|
||||
import { TuiButton, TuiTextfield, TuiTitle } from '@taiga-ui/core'
|
||||
import { TuiHeader } from '@taiga-ui/layout'
|
||||
import { PatchDB } from 'patch-db-client'
|
||||
import { Subscription, switchMap, tap } from 'rxjs'
|
||||
import { FormGroupComponent } from 'src/app/routes/portal/components/form/containers/group.component'
|
||||
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 { TitleDirective } from 'src/app/services/title.service'
|
||||
import { configBuilderToSpec } from 'src/app/utils/configBuilderToSpec'
|
||||
|
||||
const PROVIDER_HINTS: Record<string, string> = {
|
||||
gmail:
|
||||
'Requires an App Password. Enable 2FA in your Google account, then generate an App Password.',
|
||||
ses: 'Use SMTP credentials (not IAM credentials). Update the host to match your SES region.',
|
||||
sendgrid:
|
||||
"Username is 'apikey' (literal). Password is your SendGrid API key.",
|
||||
mailgun: 'Use SMTP credentials from your Mailgun domain settings.',
|
||||
protonmail:
|
||||
'Requires a Proton for Business account. Use your Proton email as username.',
|
||||
}
|
||||
|
||||
function detectProviderKey(host: string | undefined): string {
|
||||
if (!host) return 'other'
|
||||
const providers: Record<string, string> = {
|
||||
'smtp.gmail.com': 'gmail',
|
||||
'smtp.sendgrid.net': 'sendgrid',
|
||||
'smtp.mailgun.org': 'mailgun',
|
||||
'smtp.protonmail.ch': 'protonmail',
|
||||
}
|
||||
for (const [h, key] of Object.entries(providers)) {
|
||||
if (host === h) return key
|
||||
}
|
||||
if (host.endsWith('.amazonaws.com')) return 'ses'
|
||||
return 'other'
|
||||
}
|
||||
|
||||
@Component({
|
||||
template: `
|
||||
<ng-container *title>
|
||||
<a routerLink=".." tuiIconButton iconStart="@tui.arrow-left">
|
||||
{{ 'Back' | i18n }}
|
||||
</a>
|
||||
{{ 'SMTP' | i18n }}
|
||||
</ng-container>
|
||||
@if (form$ | async; as form) {
|
||||
<form [formGroup]="form">
|
||||
<header tuiHeader="body-l">
|
||||
<h3 tuiTitle>
|
||||
<b>
|
||||
{{ 'SMTP Credentials' | i18n }}
|
||||
<a
|
||||
tuiIconButton
|
||||
size="xs"
|
||||
docsLink
|
||||
path="/start-os/user-manual/smtp.html"
|
||||
appearance="icon"
|
||||
iconStart="@tui.external-link"
|
||||
>
|
||||
{{ 'Documentation' | i18n }}
|
||||
</a>
|
||||
</b>
|
||||
</h3>
|
||||
</header>
|
||||
@if (spec | async; as resolved) {
|
||||
<form-group [spec]="resolved" />
|
||||
}
|
||||
@if (providerHint()) {
|
||||
<p class="provider-hint">{{ providerHint() }}</p>
|
||||
}
|
||||
<footer>
|
||||
@if (isSaved) {
|
||||
<button
|
||||
tuiButton
|
||||
size="l"
|
||||
appearance="secondary-destructive"
|
||||
(click)="save(null)"
|
||||
>
|
||||
{{ 'Delete' | i18n }}
|
||||
</button>
|
||||
}
|
||||
<button
|
||||
tuiButton
|
||||
size="l"
|
||||
[disabled]="form.invalid || form.pristine"
|
||||
(click)="save(form.value)"
|
||||
>
|
||||
{{ 'Save' | i18n }}
|
||||
</button>
|
||||
</footer>
|
||||
</form>
|
||||
<form>
|
||||
<header tuiHeader="body-l">
|
||||
<h3 tuiTitle>
|
||||
<b>{{ 'Send test email' | i18n }}</b>
|
||||
</h3>
|
||||
</header>
|
||||
<tui-textfield>
|
||||
<label tuiLabel>Name Lastname <email@example.com></label>
|
||||
<input
|
||||
tuiTextfield
|
||||
inputmode="email"
|
||||
[(ngModel)]="testAddress"
|
||||
[ngModelOptions]="{ standalone: true }"
|
||||
/>
|
||||
</tui-textfield>
|
||||
<footer>
|
||||
<button
|
||||
tuiButton
|
||||
size="l"
|
||||
[disabled]="!testAddress || form.invalid"
|
||||
(click)="sendTestEmail(form.value)"
|
||||
>
|
||||
{{ 'Send' | i18n }}
|
||||
</button>
|
||||
</footer>
|
||||
</form>
|
||||
}
|
||||
`,
|
||||
styles: `
|
||||
:host {
|
||||
max-width: 36rem;
|
||||
}
|
||||
|
||||
form header,
|
||||
form footer {
|
||||
margin: 1rem 0;
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
footer {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.provider-hint {
|
||||
margin: 0.5rem 0 0;
|
||||
font-size: 0.85rem;
|
||||
opacity: 0.7;
|
||||
}
|
||||
`,
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
imports: [
|
||||
CommonModule,
|
||||
FormsModule,
|
||||
ReactiveFormsModule,
|
||||
FormGroupComponent,
|
||||
TuiButton,
|
||||
TuiTextfield,
|
||||
TuiHeader,
|
||||
TuiTitle,
|
||||
RouterLink,
|
||||
TitleDirective,
|
||||
i18nPipe,
|
||||
DocsLinkDirective,
|
||||
],
|
||||
})
|
||||
export default class SystemEmailComponent {
|
||||
private readonly dialog = inject(DialogService)
|
||||
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)
|
||||
private readonly i18n = inject(i18nPipe)
|
||||
|
||||
readonly providerHint = signal('')
|
||||
private providerSub: Subscription | null = null
|
||||
|
||||
testAddress = ''
|
||||
isSaved = false
|
||||
|
||||
readonly spec = configBuilderToSpec(inputSpec.constants.systemSmtpSpec)
|
||||
|
||||
readonly form$ = this.patch.watch$('serverInfo', 'smtp').pipe(
|
||||
tap(value => {
|
||||
this.isSaved = !!value
|
||||
}),
|
||||
switchMap(async value => {
|
||||
const spec = await this.spec
|
||||
const formData = value
|
||||
? { provider: { selection: detectProviderKey(value.host), value } }
|
||||
: undefined
|
||||
const form = this.formService.createForm(spec, formData)
|
||||
|
||||
// Watch provider selection for hints
|
||||
this.providerSub?.unsubscribe()
|
||||
const selectionCtrl = form.get('provider.selection')
|
||||
if (selectionCtrl) {
|
||||
this.providerHint.set(PROVIDER_HINTS[selectionCtrl.value] || '')
|
||||
this.providerSub = selectionCtrl.valueChanges.subscribe(key => {
|
||||
this.providerHint.set(PROVIDER_HINTS[key] || '')
|
||||
})
|
||||
}
|
||||
|
||||
return form
|
||||
}),
|
||||
)
|
||||
|
||||
async save(formValue: Record<string, any> | null): Promise<void> {
|
||||
const loader = this.loader.open('Saving').subscribe()
|
||||
|
||||
try {
|
||||
if (formValue) {
|
||||
await this.api.setSmtp(formValue['provider'].value)
|
||||
this.isSaved = true
|
||||
} else {
|
||||
await this.api.clearSmtp({})
|
||||
this.isSaved = false
|
||||
}
|
||||
} catch (e: any) {
|
||||
this.errorService.handleError(e)
|
||||
} finally {
|
||||
loader.unsubscribe()
|
||||
}
|
||||
}
|
||||
|
||||
async sendTestEmail(formValue: Record<string, any>) {
|
||||
const smtpValue = formValue['provider'].value
|
||||
const loader = this.loader.open('Sending email').subscribe()
|
||||
const success =
|
||||
`${this.i18n.transform('A test email has been sent to')} ${this.testAddress}. <i>${this.i18n.transform('Check your spam folder and mark as not spam.')}</i>` as i18nKey
|
||||
|
||||
try {
|
||||
await this.api.testSmtp({
|
||||
...smtpValue,
|
||||
password: smtpValue.password || '',
|
||||
to: this.testAddress,
|
||||
})
|
||||
this.dialog
|
||||
.openAlert(success, { label: 'Success', size: 's' })
|
||||
.subscribe()
|
||||
this.testAddress = ''
|
||||
} catch (e: any) {
|
||||
this.errorService.handleError(e)
|
||||
} finally {
|
||||
loader.unsubscribe()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,12 @@
|
||||
import { CommonModule } from '@angular/common'
|
||||
import { ChangeDetectionStrategy, Component, inject } from '@angular/core'
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
computed,
|
||||
inject,
|
||||
linkedSignal,
|
||||
} from '@angular/core'
|
||||
import { FormsModule } from '@angular/forms'
|
||||
import { RouterLink } from '@angular/router'
|
||||
import {
|
||||
DocsLinkDirective,
|
||||
@@ -7,14 +14,18 @@ import {
|
||||
i18nPipe,
|
||||
LoadingService,
|
||||
} from '@start9labs/shared'
|
||||
import { TuiButton } from '@taiga-ui/core'
|
||||
import { FormComponent } from 'src/app/routes/portal/components/form.component'
|
||||
import { FormDialogService } from 'src/app/services/form-dialog.service'
|
||||
import { ApiService } from 'src/app/services/api/embassy-api.service'
|
||||
import { GatewaysTableComponent } from './table.component'
|
||||
import { configBuilderToSpec } from 'src/app/utils/configBuilderToSpec'
|
||||
import { TitleDirective } from 'src/app/services/title.service'
|
||||
import { ISB } from '@start9labs/start-sdk'
|
||||
import { TUI_IS_MOBILE } from '@taiga-ui/cdk'
|
||||
import { TuiButton, TuiTextfield, TuiTitle } from '@taiga-ui/core'
|
||||
import { TuiChevron, TuiDataListWrapper, TuiSelect } from '@taiga-ui/kit'
|
||||
import { TuiHeader } from '@taiga-ui/layout'
|
||||
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 { GatewayService } from 'src/app/services/gateway.service'
|
||||
import { TitleDirective } from 'src/app/services/title.service'
|
||||
import { configBuilderToSpec } from 'src/app/utils/configBuilderToSpec'
|
||||
import { GatewaysTableComponent } from './table.component'
|
||||
|
||||
@Component({
|
||||
template: `
|
||||
@@ -34,7 +45,7 @@ import { ISB } from '@start9labs/start-sdk'
|
||||
docsLink
|
||||
path="/start-os/user-manual/gateways.html"
|
||||
appearance="icon"
|
||||
iconStart="@tui.external-link"
|
||||
iconStart="@tui.book-open-text"
|
||||
>
|
||||
{{ 'Documentation' | i18n }}
|
||||
</a>
|
||||
@@ -50,12 +61,99 @@ import { ISB } from '@start9labs/start-sdk'
|
||||
</header>
|
||||
<gateways-table />
|
||||
</section>
|
||||
|
||||
@if (outboundOptions(); as options) {
|
||||
<section class="outbound">
|
||||
<header tuiHeader="body-l">
|
||||
<h3 tuiTitle>
|
||||
<b>
|
||||
{{ 'Outbound Traffic' | i18n }}
|
||||
<a
|
||||
tuiIconButton
|
||||
size="xs"
|
||||
docsLink
|
||||
path="/start-os/user-manual/gateways.html"
|
||||
fragment="#outbound-traffic"
|
||||
appearance="icon"
|
||||
iconStart="@tui.book-open-text"
|
||||
>
|
||||
{{ 'Documentation' | i18n }}
|
||||
</a>
|
||||
</b>
|
||||
</h3>
|
||||
</header>
|
||||
<tui-textfield
|
||||
tuiChevron
|
||||
[stringify]="stringifyOutbound"
|
||||
[tuiTextfieldCleaner]="false"
|
||||
>
|
||||
<label tuiLabel>{{ 'Use gateway' | i18n }}</label>
|
||||
@if (mobile) {
|
||||
<select
|
||||
tuiSelect
|
||||
[ngModel]="selectedOutbound()"
|
||||
(ngModelChange)="selectedOutbound.set($event)"
|
||||
[items]="options"
|
||||
></select>
|
||||
} @else {
|
||||
<input
|
||||
tuiSelect
|
||||
[ngModel]="selectedOutbound()"
|
||||
(ngModelChange)="selectedOutbound.set($event)"
|
||||
/>
|
||||
}
|
||||
@if (!mobile) {
|
||||
<tui-data-list-wrapper
|
||||
new
|
||||
*tuiTextfieldDropdown
|
||||
[items]="options"
|
||||
/>
|
||||
}
|
||||
</tui-textfield>
|
||||
<footer>
|
||||
<button
|
||||
tuiButton
|
||||
[disabled]="
|
||||
selectedOutbound()?.id ===
|
||||
(gatewayService.defaultOutbound() ?? null)
|
||||
"
|
||||
(click)="saveOutbound()"
|
||||
>
|
||||
{{ 'Save' | i18n }}
|
||||
</button>
|
||||
</footer>
|
||||
</section>
|
||||
}
|
||||
`,
|
||||
styles: `
|
||||
.outbound {
|
||||
max-width: 24rem;
|
||||
margin-top: 2rem;
|
||||
}
|
||||
|
||||
.outbound header {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.outbound footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
`,
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
providers: [GatewayService],
|
||||
imports: [
|
||||
CommonModule,
|
||||
FormsModule,
|
||||
RouterLink,
|
||||
TuiButton,
|
||||
TuiTextfield,
|
||||
TuiTitle,
|
||||
TuiChevron,
|
||||
TuiSelect,
|
||||
TuiDataListWrapper,
|
||||
TuiHeader,
|
||||
GatewaysTableComponent,
|
||||
TitleDirective,
|
||||
i18nPipe,
|
||||
@@ -68,6 +166,48 @@ export default class GatewaysComponent {
|
||||
private readonly api = inject(ApiService)
|
||||
private readonly formDialog = inject(FormDialogService)
|
||||
private readonly i18n = inject(i18nPipe)
|
||||
readonly gatewayService = inject(GatewayService)
|
||||
readonly mobile = inject(TUI_IS_MOBILE)
|
||||
|
||||
private readonly autoOption = {
|
||||
id: null,
|
||||
name: this.i18n.transform('Auto') ?? 'Auto',
|
||||
}
|
||||
|
||||
readonly outboundOptions = computed(() => {
|
||||
const gateways = this.gatewayService.gateways()
|
||||
if (!gateways) return null
|
||||
return [
|
||||
this.autoOption,
|
||||
...gateways.map(g => ({ id: g.id as string | null, name: g.name })),
|
||||
]
|
||||
})
|
||||
|
||||
readonly selectedOutbound = linkedSignal(() => {
|
||||
const options = this.outboundOptions()
|
||||
const defaultId = this.gatewayService.defaultOutbound() ?? null
|
||||
if (options) {
|
||||
return options.find(o => o.id === defaultId) ?? options[0]
|
||||
}
|
||||
return this.autoOption
|
||||
})
|
||||
|
||||
readonly stringifyOutbound = (opt: { id: string | null; name: string }) =>
|
||||
opt.name
|
||||
|
||||
async saveOutbound() {
|
||||
const loader = this.loader.open('Saving').subscribe()
|
||||
|
||||
try {
|
||||
await this.api.setDefaultOutbound({
|
||||
gateway: this.selectedOutbound()?.id ?? null,
|
||||
})
|
||||
} catch (e: any) {
|
||||
this.errorService.handleError(e)
|
||||
} finally {
|
||||
loader.unsubscribe()
|
||||
}
|
||||
}
|
||||
|
||||
async add() {
|
||||
const spec = ISB.InputSpec.of({
|
||||
@@ -108,13 +248,6 @@ export default class GatewaysComponent {
|
||||
},
|
||||
}),
|
||||
}),
|
||||
setAsDefaultOutbound: ISB.Value.toggle({
|
||||
name: this.i18n.transform('Set as default outbound'),
|
||||
description: this.i18n.transform(
|
||||
'Route all outbound traffic through this gateway',
|
||||
),
|
||||
default: false,
|
||||
}),
|
||||
})
|
||||
|
||||
this.formDialog.open(FormComponent, {
|
||||
@@ -135,7 +268,7 @@ export default class GatewaysComponent {
|
||||
? input.config.value.file
|
||||
: await (input.config.value.file as any as File).text(),
|
||||
type: null, // @TODO Aiden why is attr here?
|
||||
setAsDefaultOutbound: input.setAsDefaultOutbound,
|
||||
setAsDefaultOutbound: false,
|
||||
})
|
||||
return true
|
||||
} catch (e: any) {
|
||||
|
||||
@@ -23,9 +23,8 @@ import { filter } 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 { GatewayPlus } from 'src/app/services/gateway.service'
|
||||
import { TuiBadge } from '@taiga-ui/kit'
|
||||
import { configBuilderToSpec } from 'src/app/utils/configBuilderToSpec'
|
||||
import { PORT_FORWARDS_MODAL } from './port-forwards.component'
|
||||
|
||||
@Component({
|
||||
@@ -45,11 +44,6 @@ import { PORT_FORWARDS_MODAL } from './port-forwards.component'
|
||||
}
|
||||
}
|
||||
{{ gateway.name }}
|
||||
@if (gateway.isDefaultOutbound) {
|
||||
<tui-badge appearance="primary-success">
|
||||
{{ 'default outbound' | i18n }}
|
||||
</tui-badge>
|
||||
}
|
||||
</td>
|
||||
<td>
|
||||
@if (gateway.type === 'outbound-only') {
|
||||
@@ -91,13 +85,6 @@ import { PORT_FORWARDS_MODAL } from './port-forwards.component'
|
||||
</button>
|
||||
</tui-opt-group>
|
||||
}
|
||||
@if (!gateway.isDefaultOutbound) {
|
||||
<tui-opt-group>
|
||||
<button tuiOption new (click)="setDefaultOutbound()">
|
||||
{{ 'Set as default outbound' | i18n }}
|
||||
</button>
|
||||
</tui-opt-group>
|
||||
}
|
||||
@if (gateway.ipInfo.deviceType === 'wireguard') {
|
||||
<tui-opt-group>
|
||||
<button tuiOption new class="g-negative" (click)="remove()">
|
||||
@@ -116,8 +103,8 @@ import { PORT_FORWARDS_MODAL } from './port-forwards.component'
|
||||
margin-right: 0.7rem;
|
||||
}
|
||||
|
||||
tui-badge {
|
||||
margin-left: 1rem;
|
||||
td:first-child {
|
||||
width: 24rem;
|
||||
}
|
||||
|
||||
td:last-child {
|
||||
@@ -171,7 +158,6 @@ import { PORT_FORWARDS_MODAL } from './port-forwards.component'
|
||||
TuiOptGroup,
|
||||
TuiTextfield,
|
||||
i18nPipe,
|
||||
TuiBadge,
|
||||
],
|
||||
})
|
||||
export class GatewaysItemComponent {
|
||||
@@ -214,18 +200,6 @@ export class GatewaysItemComponent {
|
||||
})
|
||||
}
|
||||
|
||||
async setDefaultOutbound() {
|
||||
const loader = this.loader.open().subscribe()
|
||||
|
||||
try {
|
||||
await this.api.setDefaultOutbound({ gateway: this.gateway().id })
|
||||
} catch (e: any) {
|
||||
this.errorService.handleError(e)
|
||||
} finally {
|
||||
loader.unsubscribe()
|
||||
}
|
||||
}
|
||||
|
||||
async rename() {
|
||||
const { id, name } = this.gateway()
|
||||
const renameSpec = ISB.InputSpec.of({
|
||||
|
||||
@@ -21,7 +21,6 @@ import { GatewayService } from 'src/app/services/gateway.service'
|
||||
</table>
|
||||
`,
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
providers: [GatewayService],
|
||||
imports: [TuiSkeleton, i18nPipe, TableComponent, GatewaysItemComponent],
|
||||
})
|
||||
export class GatewaysTableComponent {
|
||||
|
||||
@@ -0,0 +1,280 @@
|
||||
import { CommonModule } from '@angular/common'
|
||||
import { ChangeDetectionStrategy, Component, inject } from '@angular/core'
|
||||
import { FormControl, ReactiveFormsModule } from '@angular/forms'
|
||||
import { RouterLink } from '@angular/router'
|
||||
import {
|
||||
DialogService,
|
||||
DocsLinkDirective,
|
||||
ErrorService,
|
||||
i18nKey,
|
||||
i18nPipe,
|
||||
LoadingService,
|
||||
} from '@start9labs/shared'
|
||||
import { inputSpec, ISB, utils } from '@start9labs/start-sdk'
|
||||
import { TuiButton, TuiError, TuiTextfield, TuiTitle } from '@taiga-ui/core'
|
||||
import { TuiHeader } from '@taiga-ui/layout'
|
||||
import { PatchDB } from 'patch-db-client'
|
||||
import { switchMap } from 'rxjs'
|
||||
import { FormGroupComponent } from 'src/app/routes/portal/components/form/containers/group.component'
|
||||
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 { TitleDirective } from 'src/app/services/title.service'
|
||||
import { configBuilderToSpec } from 'src/app/utils/configBuilderToSpec'
|
||||
|
||||
function detectProviderKey(host: string | undefined): string {
|
||||
if (!host) return 'other'
|
||||
const providers: Record<string, string> = {
|
||||
'smtp.gmail.com': 'gmail',
|
||||
'smtp.sendgrid.net': 'sendgrid',
|
||||
'smtp.mailgun.org': 'mailgun',
|
||||
'smtp.protonmail.ch': 'protonmail',
|
||||
}
|
||||
for (const [h, key] of Object.entries(providers)) {
|
||||
if (host === h) return key
|
||||
}
|
||||
if (host.endsWith('.amazonaws.com')) return 'ses'
|
||||
return 'other'
|
||||
}
|
||||
|
||||
@Component({
|
||||
template: `
|
||||
<ng-container *title>
|
||||
<a routerLink=".." tuiIconButton iconStart="@tui.arrow-left">
|
||||
{{ 'Back' | i18n }}
|
||||
</a>
|
||||
SMTP
|
||||
</ng-container>
|
||||
@if (form$ | async; as data) {
|
||||
<form [formGroup]="data.form">
|
||||
<header tuiHeader="body-l">
|
||||
<h3 tuiTitle>
|
||||
<b>
|
||||
SMTP
|
||||
<a
|
||||
tuiIconButton
|
||||
size="xs"
|
||||
docsLink
|
||||
path="/start-os/user-manual/smtp.html"
|
||||
appearance="icon"
|
||||
iconStart="@tui.book-open-text"
|
||||
>
|
||||
{{ 'Documentation' | i18n }}
|
||||
</a>
|
||||
</b>
|
||||
</h3>
|
||||
</header>
|
||||
<form-group [spec]="data.spec" />
|
||||
<footer>
|
||||
@if (!data.form.pristine) {
|
||||
<button
|
||||
tuiButton
|
||||
size="l"
|
||||
appearance="secondary"
|
||||
(click)="cancel(data)"
|
||||
>
|
||||
{{ 'Cancel' | i18n }}
|
||||
</button>
|
||||
}
|
||||
<button
|
||||
tuiButton
|
||||
size="l"
|
||||
[disabled]="data.form.invalid || data.form.pristine"
|
||||
(click)="save(data.form.value)"
|
||||
>
|
||||
{{ 'Save' | i18n }}
|
||||
</button>
|
||||
</footer>
|
||||
</form>
|
||||
@if (data.form.value.smtp?.selection === 'enabled') {
|
||||
<form>
|
||||
<header tuiHeader="body-l">
|
||||
<h3 tuiTitle>
|
||||
<b>{{ 'Send test email' | i18n }}</b>
|
||||
</h3>
|
||||
</header>
|
||||
<tui-textfield>
|
||||
<label tuiLabel>email@example.com</label>
|
||||
<input
|
||||
tuiTextfield
|
||||
inputmode="email"
|
||||
[formControl]="testEmailControl"
|
||||
/>
|
||||
</tui-textfield>
|
||||
<tui-error
|
||||
[error]="
|
||||
!testEmailControl.pristine && isEmailInvalid
|
||||
? ('Must be a valid email address' | i18n)
|
||||
: null
|
||||
"
|
||||
/>
|
||||
<footer>
|
||||
<button
|
||||
tuiButton
|
||||
size="l"
|
||||
[disabled]="
|
||||
!testEmailControl.value || isEmailInvalid || data.form.invalid
|
||||
"
|
||||
(click)="sendTestEmail(data.form.value)"
|
||||
>
|
||||
{{ 'Send' | i18n }}
|
||||
</button>
|
||||
</footer>
|
||||
</form>
|
||||
}
|
||||
}
|
||||
`,
|
||||
styles: `
|
||||
:host {
|
||||
max-width: 36rem;
|
||||
}
|
||||
|
||||
form header,
|
||||
form footer {
|
||||
margin: 1rem 0;
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
footer {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
`,
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
imports: [
|
||||
CommonModule,
|
||||
ReactiveFormsModule,
|
||||
FormGroupComponent,
|
||||
TuiButton,
|
||||
TuiError,
|
||||
TuiTextfield,
|
||||
TuiHeader,
|
||||
TuiTitle,
|
||||
RouterLink,
|
||||
TitleDirective,
|
||||
i18nPipe,
|
||||
DocsLinkDirective,
|
||||
],
|
||||
})
|
||||
export default class SystemEmailComponent {
|
||||
private readonly dialog = inject(DialogService)
|
||||
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)
|
||||
private readonly i18n = inject(i18nPipe)
|
||||
|
||||
private readonly emailRegex = new RegExp(utils.Patterns.email.regex)
|
||||
readonly testEmailControl = new FormControl('')
|
||||
|
||||
get isEmailInvalid(): boolean {
|
||||
const value = this.testEmailControl.value
|
||||
return !!value && !this.emailRegex.test(value)
|
||||
}
|
||||
|
||||
private readonly smtpSpec = ISB.InputSpec.of({
|
||||
smtp: ISB.Value.union({
|
||||
name: this.i18n.transform('SMTP'),
|
||||
default: 'disabled',
|
||||
variants: ISB.Variants.of({
|
||||
disabled: {
|
||||
name: this.i18n.transform('Disabled'),
|
||||
spec: ISB.InputSpec.of({}),
|
||||
},
|
||||
enabled: {
|
||||
name: this.i18n.transform('Enabled'),
|
||||
spec: inputSpec.constants.systemSmtpSpec,
|
||||
},
|
||||
}),
|
||||
}),
|
||||
})
|
||||
|
||||
readonly form$ = this.patch.watch$('serverInfo', 'smtp').pipe(
|
||||
switchMap(async value => {
|
||||
const spec = await configBuilderToSpec(this.smtpSpec)
|
||||
|
||||
const formData = value
|
||||
? {
|
||||
smtp: {
|
||||
selection: 'enabled' as const,
|
||||
value: {
|
||||
provider: {
|
||||
selection: detectProviderKey(value.host),
|
||||
value: {
|
||||
host: value.host,
|
||||
security: {
|
||||
selection: value.security,
|
||||
value: { port: String(value.port) },
|
||||
},
|
||||
from: value.from,
|
||||
username: value.username,
|
||||
password: value.password,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
: undefined
|
||||
const form = this.formService.createForm(spec, formData)
|
||||
|
||||
return { form, spec, formData }
|
||||
}),
|
||||
)
|
||||
|
||||
private getSmtpValue(formValue: Record<string, any>) {
|
||||
const { security, ...rest } = formValue['smtp'].value.provider.value
|
||||
return {
|
||||
...rest,
|
||||
security: security.selection,
|
||||
port: Number(security.value.port),
|
||||
}
|
||||
}
|
||||
|
||||
async save(formValue: Record<string, any>): Promise<void> {
|
||||
const loader = this.loader.open('Saving').subscribe()
|
||||
|
||||
try {
|
||||
if (formValue['smtp'].selection === 'disabled') {
|
||||
await this.api.clearSmtp({})
|
||||
} else {
|
||||
await this.api.setSmtp(this.getSmtpValue(formValue))
|
||||
}
|
||||
} catch (e: any) {
|
||||
this.errorService.handleError(e)
|
||||
} finally {
|
||||
loader.unsubscribe()
|
||||
}
|
||||
}
|
||||
|
||||
cancel(data: {
|
||||
form: ReturnType<FormService['createForm']>
|
||||
formData: Record<string, any> | undefined
|
||||
}) {
|
||||
data.form.reset(data.formData)
|
||||
}
|
||||
|
||||
async sendTestEmail(formValue: Record<string, any>) {
|
||||
const smtpValue = this.getSmtpValue(formValue)
|
||||
const address = this.testEmailControl.value!
|
||||
const loader = this.loader.open('Sending email').subscribe()
|
||||
const success =
|
||||
`${this.i18n.transform('A test email has been sent to')} ${address}. <i>${this.i18n.transform('Check your spam folder and mark as not spam.')}</i>` as i18nKey
|
||||
|
||||
try {
|
||||
await this.api.testSmtp({
|
||||
...smtpValue,
|
||||
password: smtpValue.password || '',
|
||||
to: address,
|
||||
})
|
||||
this.dialog
|
||||
.openAlert(success, { label: 'Success', size: 's' })
|
||||
.subscribe()
|
||||
this.testEmailControl.reset()
|
||||
} catch (e: any) {
|
||||
this.errorService.handleError(e)
|
||||
} finally {
|
||||
loader.unsubscribe()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -41,7 +41,7 @@ import { SSHTableComponent } from './table.component'
|
||||
docsLink
|
||||
path="/start-os/user-manual/ssh.html"
|
||||
appearance="icon"
|
||||
iconStart="@tui.external-link"
|
||||
iconStart="@tui.book-open-text"
|
||||
>
|
||||
{{ 'Documentation' | i18n }}
|
||||
</a>
|
||||
|
||||
@@ -73,9 +73,7 @@ export default class StartOsUiComponent {
|
||||
|
||||
private readonly patch = inject<PatchDB<DataModel>>(PatchDB)
|
||||
|
||||
readonly network = toSignal(
|
||||
this.patch.watch$('serverInfo', 'network'),
|
||||
)
|
||||
readonly network = toSignal(this.patch.watch$('serverInfo', 'network'))
|
||||
|
||||
readonly allPackageData = toSignal(this.patch.watch$('packageData'))
|
||||
|
||||
@@ -98,6 +96,7 @@ export default class StartOsUiComponent {
|
||||
this.allPackageData(),
|
||||
),
|
||||
addSsl: true,
|
||||
sharedHostNames: [],
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -56,7 +56,7 @@ import { wifiSpec } from './wifi.const'
|
||||
docsLink
|
||||
path="/start-os/user-manual/wifi.html"
|
||||
appearance="icon"
|
||||
iconStart="@tui.external-link"
|
||||
iconStart="@tui.book-open-text"
|
||||
>
|
||||
{{ 'Documentation' | i18n }}
|
||||
</a>
|
||||
|
||||
@@ -28,7 +28,7 @@ export default [
|
||||
{
|
||||
path: 'email',
|
||||
title: titleResolver,
|
||||
loadComponent: () => import('./routes/email/email.component'),
|
||||
loadComponent: () => import('./routes/smtp/smtp.component'),
|
||||
},
|
||||
{
|
||||
path: 'backup',
|
||||
|
||||
@@ -340,11 +340,13 @@ export abstract class ApiService {
|
||||
|
||||
abstract osUiAddPublicDomain(
|
||||
params: T.AddPublicDomainParams,
|
||||
): Promise<string | null>
|
||||
): Promise<T.AddPublicDomainRes>
|
||||
|
||||
abstract osUiRemovePublicDomain(params: T.RemoveDomainParams): Promise<null>
|
||||
|
||||
abstract osUiAddPrivateDomain(params: T.AddPrivateDomainParams): Promise<null>
|
||||
abstract osUiAddPrivateDomain(
|
||||
params: T.AddPrivateDomainParams,
|
||||
): Promise<boolean>
|
||||
|
||||
abstract osUiRemovePrivateDomain(params: T.RemoveDomainParams): Promise<null>
|
||||
|
||||
@@ -354,13 +356,15 @@ export abstract class ApiService {
|
||||
|
||||
abstract pkgAddPublicDomain(
|
||||
params: PkgAddPublicDomainReq,
|
||||
): Promise<string | null>
|
||||
): Promise<T.AddPublicDomainRes>
|
||||
|
||||
abstract pkgRemovePublicDomain(
|
||||
params: PkgRemovePublicDomainReq,
|
||||
): Promise<null>
|
||||
|
||||
abstract pkgAddPrivateDomain(params: PkgAddPrivateDomainReq): Promise<null>
|
||||
abstract pkgAddPrivateDomain(
|
||||
params: PkgAddPrivateDomainReq,
|
||||
): Promise<boolean>
|
||||
|
||||
abstract pkgRemovePrivateDomain(
|
||||
params: PkgRemovePrivateDomainReq,
|
||||
|
||||
@@ -393,7 +393,7 @@ export class LiveApiService extends ApiService {
|
||||
// wifi
|
||||
|
||||
async enableWifi(params: T.SetWifiEnabledParams): Promise<null> {
|
||||
return this.rpcRequest({ method: 'wifi.enable', params })
|
||||
return this.rpcRequest({ method: 'wifi.set-enabled', params })
|
||||
}
|
||||
|
||||
async getWifi(params: {}, timeout?: number): Promise<T.WifiListInfo> {
|
||||
@@ -630,7 +630,7 @@ export class LiveApiService extends ApiService {
|
||||
|
||||
async osUiAddPublicDomain(
|
||||
params: T.AddPublicDomainParams,
|
||||
): Promise<string | null> {
|
||||
): Promise<T.AddPublicDomainRes> {
|
||||
return this.rpcRequest({
|
||||
method: 'server.host.address.domain.public.add',
|
||||
params,
|
||||
@@ -644,7 +644,9 @@ export class LiveApiService extends ApiService {
|
||||
})
|
||||
}
|
||||
|
||||
async osUiAddPrivateDomain(params: T.AddPrivateDomainParams): Promise<null> {
|
||||
async osUiAddPrivateDomain(
|
||||
params: T.AddPrivateDomainParams,
|
||||
): Promise<boolean> {
|
||||
return this.rpcRequest({
|
||||
method: 'server.host.address.domain.private.add',
|
||||
params,
|
||||
@@ -669,7 +671,7 @@ export class LiveApiService extends ApiService {
|
||||
|
||||
async pkgAddPublicDomain(
|
||||
params: PkgAddPublicDomainReq,
|
||||
): Promise<string | null> {
|
||||
): Promise<T.AddPublicDomainRes> {
|
||||
return this.rpcRequest({
|
||||
method: 'package.host.address.domain.public.add',
|
||||
params,
|
||||
@@ -683,7 +685,7 @@ export class LiveApiService extends ApiService {
|
||||
})
|
||||
}
|
||||
|
||||
async pkgAddPrivateDomain(params: PkgAddPrivateDomainReq): Promise<null> {
|
||||
async pkgAddPrivateDomain(params: PkgAddPrivateDomainReq): Promise<boolean> {
|
||||
return this.rpcRequest({
|
||||
method: 'package.host.address.domain.private.add',
|
||||
params,
|
||||
|
||||
@@ -1440,7 +1440,7 @@ export class MockApiService extends ApiService {
|
||||
|
||||
async osUiAddPublicDomain(
|
||||
params: T.AddPublicDomainParams,
|
||||
): Promise<string | null> {
|
||||
): Promise<T.AddPublicDomainRes> {
|
||||
await pauseFor(2000)
|
||||
|
||||
const patch: Operation<any>[] = [
|
||||
@@ -1465,7 +1465,16 @@ export class MockApiService extends ApiService {
|
||||
]
|
||||
this.mockRevision(patch)
|
||||
|
||||
return null
|
||||
return {
|
||||
dns: null,
|
||||
port: {
|
||||
ip: '0.0.0.0',
|
||||
port: 443,
|
||||
openExternally: false,
|
||||
openInternally: false,
|
||||
hairpinning: false,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
async osUiRemovePublicDomain(params: T.RemoveDomainParams): Promise<null> {
|
||||
@@ -1482,7 +1491,9 @@ export class MockApiService extends ApiService {
|
||||
return null
|
||||
}
|
||||
|
||||
async osUiAddPrivateDomain(params: T.AddPrivateDomainParams): Promise<null> {
|
||||
async osUiAddPrivateDomain(
|
||||
params: T.AddPrivateDomainParams,
|
||||
): Promise<boolean> {
|
||||
await pauseFor(2000)
|
||||
|
||||
const patch: Operation<any>[] = [
|
||||
@@ -1505,7 +1516,7 @@ export class MockApiService extends ApiService {
|
||||
]
|
||||
this.mockRevision(patch)
|
||||
|
||||
return null
|
||||
return false
|
||||
}
|
||||
|
||||
async osUiRemovePrivateDomain(params: T.RemoveDomainParams): Promise<null> {
|
||||
@@ -1535,7 +1546,7 @@ export class MockApiService extends ApiService {
|
||||
|
||||
async pkgAddPublicDomain(
|
||||
params: PkgAddPublicDomainReq,
|
||||
): Promise<string | null> {
|
||||
): Promise<T.AddPublicDomainRes> {
|
||||
await pauseFor(2000)
|
||||
|
||||
const patch: Operation<any>[] = [
|
||||
@@ -1560,7 +1571,16 @@ export class MockApiService extends ApiService {
|
||||
]
|
||||
this.mockRevision(patch)
|
||||
|
||||
return null
|
||||
return {
|
||||
dns: null,
|
||||
port: {
|
||||
ip: '0.0.0.0',
|
||||
port: 443,
|
||||
openExternally: false,
|
||||
openInternally: false,
|
||||
hairpinning: false,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
async pkgRemovePublicDomain(params: PkgRemovePublicDomainReq): Promise<null> {
|
||||
@@ -1577,7 +1597,9 @@ export class MockApiService extends ApiService {
|
||||
return null
|
||||
}
|
||||
|
||||
async pkgAddPrivateDomain(params: PkgAddPrivateDomainReq): Promise<null> {
|
||||
async pkgAddPrivateDomain(
|
||||
params: PkgAddPrivateDomainReq,
|
||||
): Promise<boolean> {
|
||||
await pauseFor(2000)
|
||||
|
||||
const patch: Operation<any>[] = [
|
||||
@@ -1600,7 +1622,7 @@ export class MockApiService extends ApiService {
|
||||
]
|
||||
this.mockRevision(patch)
|
||||
|
||||
return null
|
||||
return false
|
||||
}
|
||||
|
||||
async pkgRemovePrivateDomain(
|
||||
|
||||
@@ -212,6 +212,7 @@ export const mockPatchData: DataModel = {
|
||||
},
|
||||
},
|
||||
},
|
||||
passthroughs: [],
|
||||
defaultOutbound: 'eth0',
|
||||
dns: {
|
||||
dhcpServers: ['1.1.1.1', '8.8.8.8'],
|
||||
@@ -651,7 +652,7 @@ export const mockPatchData: DataModel = {
|
||||
publicDomains: {
|
||||
'bitcoin.example.com': {
|
||||
gateway: 'eth0',
|
||||
acme: null,
|
||||
acme: 'https://acme-v02.api.letsencrypt.org/directory',
|
||||
},
|
||||
},
|
||||
privateDomains: {
|
||||
|
||||
@@ -12,7 +12,6 @@ export type GatewayPlus = T.NetworkInterfaceInfo & {
|
||||
subnets: utils.IpNet[]
|
||||
lanIpv4: string[]
|
||||
wanIp?: utils.IpAddress
|
||||
isDefaultOutbound: boolean
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
@@ -29,7 +28,6 @@ export class GatewayService {
|
||||
this.network$.pipe(
|
||||
map(network => {
|
||||
const gateways = network.gateways
|
||||
const defaultOutbound = network.defaultOutbound
|
||||
return Object.entries(gateways)
|
||||
.filter(([_, val]) => !!val?.ipInfo)
|
||||
.filter(
|
||||
@@ -49,7 +47,6 @@ export class GatewayService {
|
||||
lanIpv4: subnets.filter(s => s.isIpv4()).map(s => s.address),
|
||||
wanIp:
|
||||
val.ipInfo?.wanIp && utils.IpAddress.parse(val.ipInfo?.wanIp),
|
||||
isDefaultOutbound: id === defaultOutbound,
|
||||
} as GatewayPlus
|
||||
})
|
||||
}),
|
||||
|
||||
@@ -161,7 +161,6 @@ export class MarketplaceService {
|
||||
}
|
||||
|
||||
private fetchRegistry$(url: string): Observable<StoreDataWithUrl | null> {
|
||||
console.log('FETCHING REGISTRY: ', url)
|
||||
return combineLatest([this.fetchInfo$(url), this.fetchPackages$(url)]).pipe(
|
||||
map(([info, packages]) => ({ info, packages, url })),
|
||||
catchError(e => {
|
||||
|
||||
@@ -70,6 +70,12 @@ hr {
|
||||
min-height: fit-content;
|
||||
flex: 1;
|
||||
padding: 1rem;
|
||||
|
||||
&::after {
|
||||
content: '';
|
||||
display: block;
|
||||
height: 1rem;
|
||||
}
|
||||
}
|
||||
|
||||
.g-aside {
|
||||
|
||||
Reference in New Issue
Block a user