more translations

This commit is contained in:
Matt Hill
2025-08-20 11:45:17 -06:00
parent 931505ff08
commit d564471825
16 changed files with 576 additions and 281 deletions

View File

@@ -2,7 +2,7 @@ import { ChangeDetectionStrategy, Component, inject } from '@angular/core'
import { toSignal } from '@angular/core/rxjs-interop'
import { SwUpdate } from '@angular/service-worker'
import { WA_WINDOW } from '@ng-web-apis/common'
import { LoadingService } from '@start9labs/shared'
import { i18nPipe, LoadingService } from '@start9labs/shared'
import { Version } from '@start9labs/start-sdk'
import { TuiResponsiveDialog } from '@taiga-ui/addon-mobile'
import { TuiAutoFocus } from '@taiga-ui/cdk'
@@ -12,21 +12,23 @@ import { distinctUntilChanged, map, merge, Subject } from 'rxjs'
import { ConfigService } from 'src/app/services/config.service'
import { DataModel } from 'src/app/services/patch-db/data-model'
// @TODO translations
@Component({
selector: 'refresh-alert',
template: `
<ng-template
[tuiResponsiveDialog]="show()"
[tuiResponsiveDialogOptions]="{ label: 'Refresh Needed', size: 's' }"
[tuiResponsiveDialogOptions]="{
label: i18n.transform('Refresh Needed'),
size: 's',
}"
(tuiResponsiveDialogChange)="dismiss$.next()"
>
@if (isPwa) {
<p>
Your user interface is cached and out of date. Attempt to reload the
PWA using the button below. If you continue to see this message,
uninstall and reinstall the PWA.
{{
'Your user interface is cached and out of date. Attempt to reload the PWA using the button below. If you continue to see this message, uninstall and reinstall the PWA.'
| i18n
}}
</p>
<button
tuiButton
@@ -36,11 +38,13 @@ import { DataModel } from 'src/app/services/patch-db/data-model'
[tuiAppearanceFocus]="false"
(click)="pwaReload()"
>
Reload
{{ 'Refresh' | i18n }}
</button>
} @else {
Your user interface is cached and out of date. Hard refresh the page to
get the latest UI.
{{
'Your user interface is cached and out of date. Hard refresh the page to get the latest UI.'
| i18n
}}
<ul>
<li>
<b>On Mac</b>
@@ -59,13 +63,13 @@ import { DataModel } from 'src/app/services/patch-db/data-model'
[tuiAppearanceFocus]="false"
(click)="dismiss$.next()"
>
Ok
{{ 'Ok' | i18n }}
</button>
}
</ng-template>
`,
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [TuiResponsiveDialog, TuiButton, TuiAutoFocus],
imports: [TuiResponsiveDialog, TuiButton, TuiAutoFocus, i18nPipe],
})
export class RefreshAlertComponent {
private readonly win = inject(WA_WINDOW)
@@ -73,6 +77,8 @@ export class RefreshAlertComponent {
private readonly loader = inject(LoadingService)
private readonly version = Version.parse(inject(ConfigService).version)
readonly i18n = inject(i18nPipe)
readonly dismiss$ = new Subject<void>()
readonly isPwa = this.win.matchMedia('(display-mode: standalone)').matches

View File

@@ -7,8 +7,6 @@ import { PublicDomainsComponent } from './public-domains/pd.component'
import { InterfacePrivateDomainsComponent } from './private-domains.component'
import { InterfaceAddressesComponent } from './addresses/addresses.component'
// @TODO translations
@Component({
selector: 'service-interface',
template: `

View File

@@ -3,7 +3,7 @@ import { T, utils } from '@start9labs/start-sdk'
import { ConfigService } from 'src/app/services/config.service'
import { GatewayPlus } from 'src/app/services/gateway.service'
import { PublicDomain } from './public-domains/pd.service'
import { i18nKey } from '@start9labs/shared'
import { i18nKey, i18nPipe } from '@start9labs/shared'
type AddressWithInfo = {
url: URL
@@ -98,155 +98,6 @@ function cmpClearnet(
])
}
// @TODO translations
function toDisplayAddress(
{ info, url }: AddressWithInfo,
gateways: GatewayPlus[],
publicDomains: Record<string, T.PublicDomainConfig>,
): DisplayAddress {
let access: DisplayAddress['access']
let gatewayName: DisplayAddress['gatewayName']
let type: DisplayAddress['type']
let bullets: any[]
// let bullets: DisplayAddress['bullets']
const rootCaRequired = `Requires trusting your server's Root CA`
// ** Tor **
if (info.kind === 'onion') {
access = null
gatewayName = null
type = 'Tor'
bullets = [
'Connections can be slow or unreliable at times',
'Public if you share the address publicly, otherwise private',
'Requires using a Tor-enabled device or browser',
]
// Tor (HTTPS)
if (url.protocol.startsWith('https')) {
type = `${type} (HTTPS)`
bullets = [
'Only useful for clients that enforce HTTPS',
rootCaRequired,
...bullets,
]
// Tor (HTTP)
} else {
bullets.unshift(
'Ideal for anonymous, censorship-resistant hosting and remote access',
)
type = `${type} (HTTP)`
}
// ** Not Tor **
} else {
const port = info.hostname.sslPort || info.hostname.port
const gateway = gateways.find(g => g.id === info.gatewayId)!
gatewayName = gateway.ipInfo.name
const gatewayLanIpv4 = gateway.lanIpv4[0]
const isWireguard = gateway.ipInfo.deviceType === 'wireguard'
const localIdeal = 'Ideal for local access'
const lanRequired =
'Requires being connected to the same Local Area Network (LAN) as your server, either physically or via VPN'
const staticRequired = `Requires setting a static IP address for ${gatewayLanIpv4} in your gateway`
const vpnAccess = 'Ideal for VPN access via your'
// * Local *
if (info.hostname.kind === 'local') {
type = 'Local'
access = 'private'
bullets = [
localIdeal,
'Not recommended for VPN access. VPNs do not support ".local" domains without advanced configuration',
lanRequired,
rootCaRequired,
]
// * IPv4 *
} else if (info.hostname.kind === 'ipv4') {
type = 'IPv4'
if (info.public) {
access = 'public'
bullets = [
'Can be used for clearnet access',
'Not recommended in most cases. Clearnet domains are preferred',
rootCaRequired,
]
if (!gateway.public) {
bullets.push(
`Requires port forwarding in gateway "${gatewayName}": ${port} -> ${info.hostname.value}:${port}`,
)
}
} else {
access = 'private'
if (isWireguard) {
bullets = [`${vpnAccess} StartTunnel (or similar)`, rootCaRequired]
} else {
bullets = [
localIdeal,
`${vpnAccess} router's Wireguard server`,
lanRequired,
rootCaRequired,
staticRequired,
]
}
}
// * IPv6 *
} else if (info.hostname.kind === 'ipv6') {
type = 'IPv6'
access = 'private'
bullets = ['Can be used for local access', lanRequired, rootCaRequired]
// * Domain *
} else {
type = 'Domain'
if (info.public) {
access = 'public'
bullets = [
`Requires a DNS record for ${info.hostname.value} that resolves to ${gateway.ipInfo.wanIp}`,
`Requires port forwarding in gateway "${gatewayName}": ${port} -> ${info.hostname.value}:${port === 443 ? 5443 : port}`,
]
if (publicDomains[info.hostname.value]?.acme) {
bullets.unshift('Ideal for public access via the Internet')
} else {
bullets = [
'Can be used for personal access via the public Internet. VPN is more private and secure',
rootCaRequired,
...bullets,
]
}
} else {
access = 'private'
const ipPortBad = 'when using IP addresses and ports is undesirable'
const customDnsRequired = `Requires a DNS record for ${info.hostname.value} that resolves to ${gatewayLanIpv4}`
if (isWireguard) {
bullets = [
`${vpnAccess} StartTunnel (or similar) ${ipPortBad}`,
customDnsRequired,
rootCaRequired,
]
} else {
bullets = [
`${localIdeal} ${ipPortBad}`,
`${vpnAccess} router's Wireguard server ${ipPortBad}`,
customDnsRequired,
rootCaRequired,
lanRequired,
staticRequired,
]
}
}
}
}
return {
url: url.href,
access,
gatewayName,
type,
bullets,
}
}
export function getPublicDomains(
publicDomains: Record<string, T.PublicDomainConfig>,
gateways: GatewayPlus[],
@@ -263,6 +114,7 @@ export function getPublicDomains(
})
export class InterfaceService {
private readonly config = inject(ConfigService)
private readonly i18n = inject(i18nPipe)
getAddresses(
serviceInterface: T.ServiceInterface,
@@ -304,11 +156,11 @@ export class InterfaceService {
return {
common: bestAddrs.map(a =>
toDisplayAddress(a, gateways, host.publicDomains),
this.toDisplayAddress(a, gateways, host.publicDomains),
),
uncommon: allAddressesWithInfo
.filter(a => !bestAddrs.includes(a))
.map(a => toDisplayAddress(a, gateways, host.publicDomains)),
.map(a => this.toDisplayAddress(a, gateways, host.publicDomains)),
}
}
@@ -448,6 +300,183 @@ export class InterfaceService {
) || []
)
}
private toDisplayAddress(
{ info, url }: AddressWithInfo,
gateways: GatewayPlus[],
publicDomains: Record<string, T.PublicDomainConfig>,
): DisplayAddress {
let access: DisplayAddress['access']
let gatewayName: DisplayAddress['gatewayName']
let type: DisplayAddress['type']
let bullets: any[]
// let bullets: DisplayAddress['bullets']
const rootCaRequired = this.i18n.transform(
"Requires trusting your server's Root CA",
)
// ** Tor **
if (info.kind === 'onion') {
access = null
gatewayName = null
type = 'Tor'
bullets = [
this.i18n.transform('Connections can be slow or unreliable at times'),
this.i18n.transform(
'Public if you share the address publicly, otherwise private',
),
this.i18n.transform('Requires using a Tor-enabled device or browser'),
]
// Tor (HTTPS)
if (url.protocol.startsWith('https')) {
type = `${type} (HTTPS)`
bullets = [
this.i18n.transform('Only useful for clients that enforce HTTPS'),
rootCaRequired,
...bullets,
]
// Tor (HTTP)
} else {
bullets.unshift(
this.i18n.transform(
'Ideal for anonymous, censorship-resistant hosting and remote access',
),
)
type = `${type} (HTTP)`
}
// ** Not Tor **
} else {
const port = info.hostname.sslPort || info.hostname.port
const gateway = gateways.find(g => g.id === info.gatewayId)!
gatewayName = gateway.ipInfo.name
const gatewayLanIpv4 = gateway.lanIpv4[0]
const isWireguard = gateway.ipInfo.deviceType === 'wireguard'
const localIdeal = this.i18n.transform('Ideal for local access')
const lanRequired = this.i18n.transform(
'Requires being connected to the same Local Area Network (LAN) as your server, either physically or via VPN',
)
const staticRequired = `${this.i18n.transform('Requires setting a static IP address for')} ${gatewayLanIpv4} ${this.i18n.transform('in your gateway')}`
const vpnAccess = this.i18n.transform('Ideal for VPN access via')
const routerWireguard = this.i18n.transform(
"your router's Wireguard server",
)
const portForwarding = this.i18n.transform(
'Requires port forwarding in gateway',
)
const dnsFor = this.i18n.transform('Requires a DNS record for')
const resolvesTo = this.i18n.transform('that resolves to')
// * Local *
if (info.hostname.kind === 'local') {
type = this.i18n.transform('Local')
access = 'private'
bullets = [
localIdeal,
this.i18n.transform(
'Not recommended for VPN access. VPNs do not support ".local" domains without advanced configuration',
),
lanRequired,
rootCaRequired,
]
// * IPv4 *
} else if (info.hostname.kind === 'ipv4') {
type = 'IPv4'
if (info.public) {
access = 'public'
bullets = [
this.i18n.transform('Can be used for clearnet access'),
this.i18n.transform(
'Not recommended in most cases. Clearnet domains are preferred',
),
rootCaRequired,
]
if (!gateway.public) {
bullets.push(
`${portForwarding} "${gatewayName}": ${port} -> ${info.hostname.value}:${port}`,
)
}
} else {
access = 'private'
if (isWireguard) {
bullets = [`${vpnAccess} StartTunnel`, rootCaRequired]
} else {
bullets = [
localIdeal,
`${vpnAccess} ${routerWireguard}`,
lanRequired,
rootCaRequired,
staticRequired,
]
}
}
// * IPv6 *
} else if (info.hostname.kind === 'ipv6') {
type = 'IPv6'
access = 'private'
bullets = [
this.i18n.transform('Can be used for local access'),
lanRequired,
rootCaRequired,
]
// * Domain *
} else {
type = this.i18n.transform('Domain')
if (info.public) {
access = 'public'
bullets = [
`${dnsFor} ${info.hostname.value} ${resolvesTo} ${gateway.ipInfo.wanIp}`,
`${portForwarding} "${gatewayName}": ${port} -> ${info.hostname.value}:${port === 443 ? 5443 : port}`,
]
if (publicDomains[info.hostname.value]?.acme) {
bullets.unshift(
this.i18n.transform('Ideal for public access via the Internet'),
)
} else {
bullets = [
this.i18n.transform(
'Can be used for personal access via the public Internet. VPN is more private and secure',
),
rootCaRequired,
...bullets,
]
}
} else {
access = 'private'
const ipPortBad = this.i18n.transform(
'when using IP addresses and ports is undesirable',
)
const customDnsRequired = `${dnsFor} ${info.hostname.value} ${resolvesTo} ${gatewayLanIpv4}`
if (isWireguard) {
bullets = [
`${vpnAccess} StartTunnel ${ipPortBad}`,
customDnsRequired,
rootCaRequired,
]
} else {
bullets = [
`${localIdeal} ${ipPortBad}`,
`${vpnAccess} ${routerWireguard} ${ipPortBad}`,
customDnsRequired,
rootCaRequired,
lanRequired,
staticRequired,
]
}
}
}
}
return {
url: url.href,
access,
gatewayName,
type,
bullets,
}
}
}
export type MappedServiceInterface = T.ServiceInterface & {

View File

@@ -26,8 +26,6 @@ import { FormDialogService } from 'src/app/services/form-dialog.service'
import { configBuilderToSpec } from 'src/app/utils/configBuilderToSpec'
import { InterfaceComponent } from './interface.component'
// @TODO translations
@Component({
selector: 'section[privateDomains]',
template: `
@@ -113,9 +111,10 @@ export class InterfacePrivateDomainsComponent {
spec: await configBuilderToSpec(
ISB.InputSpec.of({
fqdn: ISB.Value.text({
name: 'Domain',
description:
name: this.i18n.transform('Domain'),
description: this.i18n.transform(
'Enter a fully qualified domain name. Since the domain is for private use, it can be any domain you want, even one you do not control.',
),
required: true,
default: null,
patterns: [utils.Patterns.domain],

View File

@@ -19,8 +19,6 @@ import { ApiService } from 'src/app/services/api/embassy-api.service'
import { parse } from 'tldts'
import { GatewayWithId } from './pd.service'
// @TODO translations
@Component({
selector: 'dns',
template: `
@@ -37,11 +35,11 @@ import { GatewayWithId } from './pd.service'
[(ngModel)]="ddns"
(ngModelChange)="pass.set(undefined)"
/>
Dynamic DNS
{{ 'Dynamic DNS' | i18n }}
</label>
}
<table [appTable]="['Type', $any('Host'), 'Value', 'Purpose']">
<table [appTable]="['Type', 'Host', 'Value', 'Purpose']">
@for (row of rows(); track $index) {
<tr>
<td>
@@ -98,6 +96,7 @@ import { GatewayWithId } from './pd.service'
export class DnsComponent {
private readonly errorService = inject(ErrorService)
private readonly api = inject(ApiService)
private readonly i18n = inject(i18nPipe)
readonly ddns = false
@@ -126,6 +125,8 @@ export class DnsComponent {
const segments = subdomain.split('.')
const subdomains = this.i18n.transform('subdomains of')
return [
{
host: subdomain,
@@ -135,12 +136,12 @@ export class DnsComponent {
const parent = segments.slice(i + 1).join('.')
return {
host: `*.${parent}`,
purpose: `subdomains of ${parent}`,
purpose: `${subdomains} ${parent}`,
}
}),
{
host: '*',
purpose: `subdomains of ${domain}`,
purpose: `${subdomains} ${domain}`,
},
]
})

View File

@@ -4,6 +4,7 @@ import {
ErrorService,
i18nKey,
LoadingService,
i18nPipe,
} from '@start9labs/shared'
import { toSignal } from '@angular/core/rxjs-interop'
import { ISB, T, utils } from '@start9labs/start-sdk'
@@ -18,8 +19,6 @@ import { toAuthorityName } from 'src/app/utils/acme'
import { InterfaceComponent } from '../interface.component'
import { DNS } from './dns.component'
// @TODO translations
export type PublicDomain = {
fqdn: string
gateway: GatewayWithId | null
@@ -40,6 +39,7 @@ export class PublicDomainService {
private readonly formDialog = inject(FormDialogService)
private readonly dialog = inject(DialogService)
private readonly interface = inject(InterfaceComponent)
private readonly i18n = inject(i18nPipe)
readonly data = toSignal(
this.patch.watch$('serverInfo', 'network').pipe(
@@ -61,9 +61,10 @@ export class PublicDomainService {
async add() {
const addSpec = ISB.InputSpec.of({
fqdn: ISB.Value.text({
name: 'Domain',
description:
name: this.i18n.transform('Domain'),
description: this.i18n.transform(
'Enter a fully qualified domain name. For example, if you control domain.com, you could enter domain.com or subdomain.domain.com or another.subdomain.domain.com.',
),
required: true,
default: null,
patterns: [utils.Patterns.domain],
@@ -140,7 +141,7 @@ export class PublicDomainService {
showDns(fqdn: string, gateway: GatewayWithId, message: i18nKey) {
this.dialog
.openComponent(DNS, {
label: 'DNS Records' as i18nKey,
label: 'DNS Records',
size: 'l',
data: {
fqdn,
@@ -177,7 +178,9 @@ export class PublicDomainService {
}
const wanIp = gateway.ipInfo.wanIp
let message = `Create one of the DNS records below to cause ${fqdn} to resolve to ${wanIp}`
let message = this.i18n.transform(
'Create one of the DNS records below.',
) as i18nKey
if (!ip) {
setTimeout(
@@ -185,7 +188,7 @@ export class PublicDomainService {
this.showDns(
fqdn,
gateway,
`No DNS detected for ${fqdn}. ${message}` as i18nKey,
`${this.i18n.transform('No DNS record detected for')} ${fqdn}. ${message}` as i18nKey,
),
250,
)
@@ -195,7 +198,7 @@ export class PublicDomainService {
this.showDns(
fqdn,
gateway,
`Invalid DNS. ${fqdn} is currently resolving to ${ip}. ${message}` as i18nKey,
`${this.i18n.transform('Invalid DNS record')}. ${fqdn} ${this.i18n.transform('resolves to')} ${ip}. ${message}` as i18nKey,
),
250,
)
@@ -203,8 +206,8 @@ export class PublicDomainService {
setTimeout(
() =>
this.dialog.openAlert(
`${fqdn} is successfully resolving to ${wanIp}` as i18nKey,
{ label: 'DNS detected!' as i18nKey, appearance: 'positive' },
`${fqdn} ${this.i18n.transform('resolves to')} ${wanIp}` as i18nKey,
{ label: 'DNS record detected!', appearance: 'positive' },
),
250,
)
@@ -224,8 +227,10 @@ export class PublicDomainService {
return {
gateway: ISB.Value.dynamicSelect(() => ({
name: 'Gateway',
description: 'Select a gateway to use for this domain.',
name: this.i18n.transform('Gateway'),
description: this.i18n.transform(
'Select a gateway to use for this domain.',
),
values: data.gateways.reduce<Record<string, string>>(
(obj, gateway) => ({
...obj,
@@ -241,9 +246,10 @@ export class PublicDomainService {
.map(g => g.id),
})),
authority: ISB.Value.select({
name: 'Certificate Authority',
description:
'Select the Certificate Authority that will issue the SSL/TLS certificate for this domain',
name: this.i18n.transform('Certificate Authority'),
description: this.i18n.transform(
'Select a Certificate Authority to issue SSL/TLS certificates for this domain',
),
values: data.authorities,
default: '',
}),

View File

@@ -55,7 +55,7 @@ export class AuthorityService {
const addSpec = ISB.InputSpec.of({
provider: ISB.Value.union({
name: 'Provider',
name: this.i18n.transform('Provider'),
default: (availableAuthorities[0]?.url as any) || 'other',
variants: ISB.Variants.of({
...availableAuthorities.reduce(
@@ -69,7 +69,7 @@ export class AuthorityService {
{},
),
other: {
name: 'Other',
name: this.i18n.transform('Other'),
spec: ISB.InputSpec.of({
url: ISB.Value.text({
name: 'URL',

View File

@@ -72,82 +72,82 @@ export default class GatewaysComponent {
private readonly errorService = inject(ErrorService)
private readonly api = inject(ApiService)
private readonly formDialog = inject(FormDialogService)
private readonly i18n = inject(i18nPipe)
async add() {
const spec = ISB.InputSpec.of({
name: ISB.Value.text({
name: this.i18n.transform('Name'),
description: this.i18n.transform(
'A name to easily identify the gateway',
),
required: true,
default: null,
}),
type: ISB.Value.select({
name: this.i18n.transform('Type'),
description: `-**${this.i18n.transform('private')}**: ${this.i18n.transform('select this option if the gateway is configured for private access to authorized clients only. StartTunnel is a private gateway.')}\n-**${this.i18n.transform('public')}**: ${this.i18n.transform('select this option if the gateway is configured for unfettered public access.')}`,
default: 'private',
values: {
private: this.i18n.transform('private'),
public: this.i18n.transform('public'),
},
}),
config: ISB.Value.union({
name: this.i18n.transform('Wireguard Config File'),
default: 'paste',
variants: ISB.Variants.of({
paste: {
name: this.i18n.transform('Copy/Paste'),
spec: ISB.InputSpec.of({
file: ISB.Value.textarea({
name: this.i18n.transform('File Contents'),
default: null,
required: true,
}),
}),
},
upload: {
name: this.i18n.transform('Upload'),
spec: ISB.InputSpec.of({
file: ISB.Value.file({
name: this.i18n.transform('File'),
required: true,
extensions: ['.conf'],
}),
}),
},
}),
}),
})
this.formDialog.open(FormComponent, {
label: 'Add gateway',
data: {
spec: await configBuilderToSpec(gatewaySpec),
spec: await configBuilderToSpec(spec),
buttons: [
{
text: 'Save',
handler: (input: typeof gatewaySpec._TYPE) => this.save(input),
text: this.i18n.transform('Save'),
handler: async (input: typeof spec._TYPE) => {
const loader = this.loader.open('Saving').subscribe()
try {
await this.api.addTunnel({
name: input.name,
config: '' as string, // @TODO alex/matt when types arrive
public: input.type === 'public',
})
return true
} catch (e: any) {
this.errorService.handleError(e)
return false
} finally {
loader.unsubscribe()
}
},
},
],
},
})
}
private async save(input: typeof gatewaySpec._TYPE): Promise<boolean> {
const loader = this.loader.open('Saving').subscribe()
try {
await this.api.addTunnel({
name: input.name,
config: '' as string, // @TODO alex/matt when types arrive
public: input.type === 'public',
})
return true
} catch (e: any) {
this.errorService.handleError(e)
return false
} finally {
loader.unsubscribe()
}
}
}
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'],
}),
}),
},
}),
}),
})

View File

@@ -143,6 +143,7 @@ export class GatewaysItemComponent {
private readonly errorService = inject(ErrorService)
private readonly api = inject(ApiService)
private readonly formDialog = inject(FormDialogService)
private readonly i18n = inject(i18nPipe)
readonly gateway = input.required<GatewayPlus>()
@@ -169,7 +170,7 @@ export class GatewaysItemComponent {
const { ipInfo, id } = this.gateway()
const renameSpec = ISB.InputSpec.of({
label: ISB.Value.text({
name: 'Label',
name: this.i18n.transform('Name'),
required: true,
default: ipInfo?.name || null,
}),

View File

@@ -107,14 +107,29 @@ export default class SystemSSHComponent {
protected tableKeys = viewChild<SSHTableComponent<SSHKey>>('table')
async add(all: readonly SSHKey[]) {
const spec = ISB.InputSpec.of({
key: ISB.Value.text({
name: this.i18n.transform('Public Key'),
required: true,
default: null,
patterns: [
{
regex:
'^(ssh-(rsa|ed25519|dss|ecdsa)|ecdsa-sha2-nistp(256|384|521))\\s+[A-Za-z0-9+/=]+(\\s[^\\s]+)?$',
description: this.i18n.transform('must be a valid SSH public key'),
},
],
}),
})
this.formDialog.open(FormComponent, {
label: 'Add SSH key',
data: {
spec: await configBuilderToSpec(SSHSpec),
spec: await configBuilderToSpec(spec),
buttons: [
{
text: this.i18n.transform('Save'),
handler: async ({ key }: typeof SSHSpec._TYPE) => {
handler: async ({ key }: typeof spec._TYPE) => {
const loader = this.loader.open('Saving').subscribe()
try {
@@ -157,18 +172,3 @@ export default class SystemSSHComponent {
})
}
}
const SSHSpec = ISB.InputSpec.of({
key: ISB.Value.text({
name: 'Public Key',
required: true,
default: null,
patterns: [
{
regex:
'^(ssh-(rsa|ed25519|dss|ecdsa)|ecdsa-sha2-nistp(256|384|521))\\s+[A-Za-z0-9+/=]+(\\s[^\\s]+)?$',
description: 'must be a valid SSH public key',
},
],
}),
})