diff --git a/sdk/base/lib/actions/input/inputSpecConstants.ts b/sdk/base/lib/actions/input/inputSpecConstants.ts index e2992740a..28d3c4f84 100644 --- a/sdk/base/lib/actions/input/inputSpecConstants.ts +++ b/sdk/base/lib/actions/input/inputSpecConstants.ts @@ -1,41 +1,57 @@ -import { SmtpValue } from '../../types' import { GetSystemSmtp, Patterns } from '../../util' -import { InputSpec, InputSpecOf } from './builder/inputSpec' +import { InputSpec } from './builder/inputSpec' import { Value } from './builder/value' import { Variants } from './builder/variants' +const securityVariants = Variants.of({ + tls: { + name: 'TLS', + spec: InputSpec.of({ + port: Value.dynamicText(async () => ({ + name: 'Port', + required: true, + default: '465', + disabled: 'Fixed for TLS', + })), + }), + }, + starttls: { + name: 'STARTTLS', + spec: InputSpec.of({ + port: Value.select({ + name: 'Port', + default: '587', + values: { '25': '25', '587': '587', '2525': '2525' }, + }), + }), + }, +}) + /** * Creates an SMTP field spec with provider-specific defaults pre-filled. */ function smtpFields( defaults: { host?: string - port?: number security?: 'starttls' | 'tls' + hostDisabled?: boolean } = {}, -): InputSpec { - return InputSpec.of>({ - host: Value.text({ - name: 'Host', - required: true, - default: defaults.host ?? null, - placeholder: 'smtp.example.com', - }), - port: Value.number({ - name: 'Port', - required: true, - default: defaults.port ?? 587, - min: 1, - max: 65535, - integer: true, - }), - security: Value.select({ +) { + const hostSpec = Value.text({ + name: 'Host', + required: true, + default: defaults.host ?? null, + placeholder: 'smtp.example.com', + }) + + return InputSpec.of({ + host: defaults.hostDisabled + ? hostSpec.withDisabled('Fixed for this provider') + : hostSpec, + security: Value.union({ name: 'Connection Security', - default: defaults.security ?? 'starttls', - values: { - starttls: 'STARTTLS', - tls: 'TLS', - }, + default: defaults.security ?? 'tls', + variants: securityVariants, }), from: Value.text({ name: 'From Address', @@ -68,44 +84,47 @@ export const customSmtp = smtpFields() * Each variant has SMTP fields pre-filled with the provider's recommended settings. */ export const smtpProviderVariants = Variants.of({ + none: { + name: 'None', + spec: InputSpec.of({}), + }, gmail: { name: 'Gmail', spec: smtpFields({ host: 'smtp.gmail.com', - port: 587, - security: 'starttls', + security: 'tls', + hostDisabled: true, }), }, ses: { name: 'Amazon SES', spec: smtpFields({ host: 'email-smtp.us-east-1.amazonaws.com', - port: 587, - security: 'starttls', + security: 'tls', }), }, sendgrid: { name: 'SendGrid', spec: smtpFields({ host: 'smtp.sendgrid.net', - port: 587, - security: 'starttls', + security: 'tls', + hostDisabled: true, }), }, mailgun: { name: 'Mailgun', spec: smtpFields({ host: 'smtp.mailgun.org', - port: 587, - security: 'starttls', + security: 'tls', + hostDisabled: true, }), }, protonmail: { name: 'Proton Mail', spec: smtpFields({ host: 'smtp.protonmail.ch', - port: 587, - security: 'starttls', + security: 'tls', + hostDisabled: true, }), }, other: { @@ -121,7 +140,7 @@ export const smtpProviderVariants = Variants.of({ export const systemSmtpSpec = InputSpec.of({ provider: Value.union({ name: 'Provider', - default: null as any, + default: 'none', variants: smtpProviderVariants, }), }) diff --git a/sdk/base/lib/types.ts b/sdk/base/lib/types.ts index 5d6e3756f..f552f89aa 100644 --- a/sdk/base/lib/types.ts +++ b/sdk/base/lib/types.ts @@ -1,25 +1,25 @@ export * as inputSpecTypes from './actions/input/inputSpecTypes' +export { + CurrentDependenciesResult, + OptionalDependenciesOf as OptionalDependencies, + RequiredDependenciesOf as RequiredDependencies, +} from './dependencies/setupDependencies' +export * from './osBindings' +export { SDKManifest } from './types/ManifestTypes' +export { Effects } import { InputSpec as InputSpecClass } from './actions/input/builder/inputSpec' -import { - DependencyRequirement, - NamedHealthCheckResult, - Manifest, - ServiceInterface, - ActionId, -} from './osBindings' -import { Affine, StringObject, ToKebab } from './util' import { Action, Actions } from './actions/setupActions' import { Effects } from './Effects' import { ExtendedVersion, VersionRange } from './exver' -export { Effects } -export * from './osBindings' -export { SDKManifest } from './types/ManifestTypes' -export { - RequiredDependenciesOf as RequiredDependencies, - OptionalDependenciesOf as OptionalDependencies, - CurrentDependenciesResult, -} from './dependencies/setupDependencies' +import { + ActionId, + DependencyRequirement, + Manifest, + NamedHealthCheckResult, + ServiceInterface, +} from './osBindings' +import { StringObject, ToKebab } from './util' /** An object that can be built into a terminable daemon process. */ export type DaemonBuildable = { diff --git a/sdk/base/lib/util/getServiceInterface.ts b/sdk/base/lib/util/getServiceInterface.ts index 151cf8960..e0cedc529 100644 --- a/sdk/base/lib/util/getServiceInterface.ts +++ b/sdk/base/lib/util/getServiceInterface.ts @@ -324,6 +324,14 @@ function enabledAddresses(addr: DerivedAddressInfo): HostnameInfo[] { }) } +/** + * Filters out localhost and IPv6 link-local hostnames from a list. + * Equivalent to the `nonLocal` filter on `Filled` addresses. + */ +export function filterNonLocal(hostnames: HostnameInfo[]): HostnameInfo[] { + return filterRec(hostnames, nonLocalFilter, false) +} + export const filledAddress = ( host: Host, addressInfo: AddressInfo, diff --git a/sdk/base/lib/util/index.ts b/sdk/base/lib/util/index.ts index bad134501..e156cb97b 100644 --- a/sdk/base/lib/util/index.ts +++ b/sdk/base/lib/util/index.ts @@ -8,6 +8,7 @@ export { GetServiceInterface, getServiceInterface, filledAddress, + filterNonLocal, } from './getServiceInterface' export { getServiceInterfaces } from './getServiceInterfaces' export { once } from './once' diff --git a/web/projects/setup-wizard/src/app/pages/drives.page.ts b/web/projects/setup-wizard/src/app/pages/drives.page.ts index 2790fc306..1d20848c1 100644 --- a/web/projects/setup-wizard/src/app/pages/drives.page.ts +++ b/web/projects/setup-wizard/src/app/pages/drives.page.ts @@ -34,110 +34,121 @@ import { PreserveOverwriteDialog } from '../components/preserve-overwrite.dialog @Component({ template: ` @if (!shuttingDown) { -
-
-

{{ 'Select Drives' | i18n }}

-
+
+
+

{{ 'Select Drives' | i18n }}

+
- @if (loading) { - - } @else if (drives.length === 0) { -

- {{ - 'No drives found. Please connect a drive and click Refresh.' | i18n - }} -

- } @else { - - - @if (mobile) { - - } @else { - - } - @if (!mobile) { - - } - - - - - - @if (mobile) { - - } @else { - - } - @if (!mobile) { - - } - @if (preserveData === true) { - - } - @if (preserveData === false) { - - } - - - - -
- - {{ drive.vendor || ('Unknown' | i18n) }} - {{ drive.model || ('Drive' | i18n) }} - - - {{ formatCapacity(drive.capacity) }} · {{ drive.logicalname }} - -
-
- } - -
- @if (drives.length === 0) { - + @if (loading) { + + } @else if (drives.length === 0) { +

+ {{ + 'No drives found. Please connect a drive and click Refresh.' + | i18n + }} +

} @else { - + + @if (mobile) { + + } @else { + + } + @if (!mobile) { + + } + + + + + + @if (mobile) { + + } @else { + + } + @if (!mobile) { + + } + @if (preserveData === true) { + + } + @if (preserveData === false) { + + } + + + + +
+ + {{ driveName(drive) }} + + + {{ formatCapacity(drive.capacity) }} · {{ drive.logicalname }} + +
+
} -
-
+ +
+ @if (drives.length === 0) { + + } @else { + + } +
+
} `, styles: ` @@ -198,6 +209,10 @@ export default class DrivesPage { 'The drive where your StartOS data (services, settings, etc.) will be stored. This can be the same as the OS drive or a separate drive.', ) + private readonly MIN_OS = 18 * 2 ** 30 // 18 GiB + private readonly MIN_DATA = 20 * 2 ** 30 // 20 GiB + private readonly MIN_BOTH = 38 * 2 ** 30 // 38 GiB + drives: DiskInfo[] = [] loading = true shuttingDown = false @@ -206,10 +221,17 @@ export default class DrivesPage { selectedDataDrive: DiskInfo | null = null preserveData: boolean | null = null + readonly osDisabled = (drive: DiskInfo): boolean => + drive.capacity < this.MIN_OS + + dataDisabled = (drive: DiskInfo): boolean => drive.capacity < this.MIN_DATA + + readonly driveName = (drive: DiskInfo): string => + [drive.vendor, drive.model].filter(Boolean).join(' ') || + this.i18n.transform('Unknown Drive') + readonly stringify = (drive: DiskInfo | null) => - drive - ? `${drive.vendor || this.i18n.transform('Unknown')} ${drive.model || this.i18n.transform('Drive')}` - : '' + drive ? this.driveName(drive) : '' formatCapacity(bytes: number): string { const gb = bytes / 1e9 @@ -231,6 +253,22 @@ export default class DrivesPage { await this.loadDrives() } + onOsDriveChange(osDrive: DiskInfo | null) { + this.selectedOsDrive = osDrive + this.dataDisabled = (drive: DiskInfo) => { + if (osDrive && drive.logicalname === osDrive.logicalname) { + return drive.capacity < this.MIN_BOTH + } + return drive.capacity < this.MIN_DATA + } + + // Clear data drive if it's now invalid + if (this.selectedDataDrive && this.dataDisabled(this.selectedDataDrive)) { + this.selectedDataDrive = null + this.preserveData = null + } + } + onDataDriveChange(drive: DiskInfo | null) { this.preserveData = null @@ -400,7 +438,7 @@ export default class DrivesPage { private async loadDrives() { try { - this.drives = await this.api.getDisks() + this.drives = (await this.api.getDisks()).filter(d => d.capacity > 0) } catch (e: any) { this.errorService.handleError(e) } finally { diff --git a/web/projects/setup-wizard/src/app/services/mock-api.service.ts b/web/projects/setup-wizard/src/app/services/mock-api.service.ts index 47a64babf..743977e94 100644 --- a/web/projects/setup-wizard/src/app/services/mock-api.service.ts +++ b/web/projects/setup-wizard/src/app/services/mock-api.service.ts @@ -191,7 +191,118 @@ export class MockApiService extends ApiService { } } +const GiB = 2 ** 30 + const MOCK_DISKS: DiskInfo[] = [ + // 0 capacity - should be hidden entirely + { + logicalname: '/dev/sdd', + vendor: 'Generic', + model: 'Card Reader', + partitions: [], + capacity: 0, + guid: null, + }, + // 10 GiB - too small for OS and data; also tests both vendor+model null + { + logicalname: '/dev/sde', + vendor: null, + model: null, + partitions: [ + { + logicalname: '/dev/sde1', + label: null, + capacity: 10 * GiB, + used: null, + startOs: {}, + guid: null, + }, + ], + capacity: 10 * GiB, + guid: null, + }, + // 18 GiB - exact OS boundary; tests vendor null with model present + { + logicalname: '/dev/sdf', + vendor: null, + model: 'SATA Flash Drive', + partitions: [ + { + logicalname: '/dev/sdf1', + label: null, + capacity: 18 * GiB, + used: null, + startOs: {}, + guid: null, + }, + ], + capacity: 18 * GiB, + guid: null, + }, + // 20 GiB - exact data boundary; tests vendor present with model null + { + logicalname: '/dev/sdg', + vendor: 'PNY', + model: null, + partitions: [ + { + logicalname: '/dev/sdg1', + label: null, + capacity: 20 * GiB, + used: null, + startOs: {}, + guid: null, + }, + ], + capacity: 20 * GiB, + guid: null, + }, + // 30 GiB - OK for OS or data alone, too small for both (< 38 GiB) + { + logicalname: '/dev/sdh', + vendor: 'SanDisk', + model: 'Ultra', + partitions: [ + { + logicalname: '/dev/sdh1', + label: null, + capacity: 30 * GiB, + used: null, + startOs: {}, + guid: null, + }, + ], + capacity: 30 * GiB, + guid: null, + }, + // 30 GiB with existing StartOS data - tests preserve/overwrite + capacity constraint + { + logicalname: '/dev/sdi', + vendor: 'Kingston', + model: 'A400', + partitions: [ + { + logicalname: '/dev/sdi1', + label: null, + capacity: 30 * GiB, + used: null, + startOs: { + 'small-server-id': { + hostname: 'small-server', + version: '0.3.6', + timestamp: new Date().toISOString(), + passwordHash: + '$argon2d$v=19$m=1024,t=1,p=1$YXNkZmFzZGZhc2RmYXNkZg$Ceev1I901G6UwU+hY0sHrFZ56D+o+LNJ', + wrappedKey: null, + }, + }, + guid: 'small-existing-guid', + }, + ], + capacity: 30 * GiB, + guid: 'small-existing-guid', + }, + // 500 GB - large, always OK { logicalname: '/dev/sda', vendor: 'Samsung', @@ -209,6 +320,7 @@ const MOCK_DISKS: DiskInfo[] = [ capacity: 500000000000, guid: null, }, + // 1 TB with existing StartOS data { logicalname: '/dev/sdb', vendor: 'Crucial', @@ -235,6 +347,7 @@ const MOCK_DISKS: DiskInfo[] = [ capacity: 1000000000000, guid: 'existing-guid', }, + // 2 TB { logicalname: '/dev/sdc', vendor: 'WD', diff --git a/web/projects/shared/src/i18n/dictionaries/de.ts b/web/projects/shared/src/i18n/dictionaries/de.ts index ce85f29bf..8f01bf86e 100644 --- a/web/projects/shared/src/i18n/dictionaries/de.ts +++ b/web/projects/shared/src/i18n/dictionaries/de.ts @@ -644,7 +644,6 @@ export default { 706: 'Beibehalten', 707: 'Überschreiben', 708: 'Entsperren', - 709: 'Laufwerk', 710: 'Übertragen', 711: 'Die Liste ist leer', 712: 'Jetzt neu starten', @@ -709,4 +708,6 @@ export default { 779: 'Öffentlich', 780: 'Privat', 781: 'Lokal', + 782: 'Unbekanntes Laufwerk', + 783: 'Muss eine gültige E-Mail-Adresse sein', } satisfies i18n diff --git a/web/projects/shared/src/i18n/dictionaries/en.ts b/web/projects/shared/src/i18n/dictionaries/en.ts index 44edb95db..a02636381 100644 --- a/web/projects/shared/src/i18n/dictionaries/en.ts +++ b/web/projects/shared/src/i18n/dictionaries/en.ts @@ -644,7 +644,6 @@ export const ENGLISH: Record = { 'Preserve': 706, 'Overwrite': 707, 'Unlock': 708, - 'Drive': 709, // the noun, a storage device 'Transfer': 710, // the verb 'The list is empty': 711, 'Restart now': 712, @@ -709,4 +708,6 @@ export const ENGLISH: Record = { 'Public': 779, // as in, publicly accessible 'Private': 780, // as in, privately accessible 'Local': 781, // as in, locally accessible + 'Unknown Drive': 782, + 'Must be a valid email address': 783, } diff --git a/web/projects/shared/src/i18n/dictionaries/es.ts b/web/projects/shared/src/i18n/dictionaries/es.ts index 43510212e..749c9bb1a 100644 --- a/web/projects/shared/src/i18n/dictionaries/es.ts +++ b/web/projects/shared/src/i18n/dictionaries/es.ts @@ -644,7 +644,6 @@ export default { 706: 'Conservar', 707: 'Sobrescribir', 708: 'Desbloquear', - 709: 'Unidad', 710: 'Transferir', 711: 'La lista está vacía', 712: 'Reiniciar ahora', @@ -709,4 +708,6 @@ export default { 779: 'Público', 780: 'Privado', 781: 'Local', + 782: 'Unidad desconocida', + 783: 'Debe ser una dirección de correo electrónico válida', } satisfies i18n diff --git a/web/projects/shared/src/i18n/dictionaries/fr.ts b/web/projects/shared/src/i18n/dictionaries/fr.ts index bba4065af..31b957bbf 100644 --- a/web/projects/shared/src/i18n/dictionaries/fr.ts +++ b/web/projects/shared/src/i18n/dictionaries/fr.ts @@ -644,7 +644,6 @@ export default { 706: 'Conserver', 707: 'Écraser', 708: 'Déverrouiller', - 709: 'Disque', 710: 'Transférer', 711: 'La liste est vide', 712: 'Redémarrer maintenant', @@ -709,4 +708,6 @@ export default { 779: 'Public', 780: 'Privé', 781: 'Local', + 782: 'Lecteur inconnu', + 783: 'Doit être une adresse e-mail valide', } satisfies i18n diff --git a/web/projects/shared/src/i18n/dictionaries/pl.ts b/web/projects/shared/src/i18n/dictionaries/pl.ts index 4e88712d3..f85349560 100644 --- a/web/projects/shared/src/i18n/dictionaries/pl.ts +++ b/web/projects/shared/src/i18n/dictionaries/pl.ts @@ -644,7 +644,6 @@ export default { 706: 'Zachowaj', 707: 'Nadpisz', 708: 'Odblokuj', - 709: 'Dysk', 710: 'Przenieś', 711: 'Lista jest pusta', 712: 'Uruchom ponownie teraz', @@ -709,4 +708,6 @@ export default { 779: 'Publiczny', 780: 'Prywatny', 781: 'Lokalny', + 782: 'Nieznany dysk', + 783: 'Musi być prawidłowy adres e-mail', } satisfies i18n diff --git a/web/projects/ui/src/app/routes/portal/components/interfaces/interface.service.ts b/web/projects/ui/src/app/routes/portal/components/interfaces/interface.service.ts index c722636b0..7a084ed25 100644 --- a/web/projects/ui/src/app/routes/portal/components/interfaces/interface.service.ts +++ b/web/projects/ui/src/app/routes/portal/components/interfaces/interface.service.ts @@ -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) diff --git a/web/projects/ui/src/app/routes/portal/routes/services/components/action.component.ts b/web/projects/ui/src/app/routes/portal/routes/services/components/action.component.ts index a76439c69..cd7e56f1f 100644 --- a/web/projects/ui/src/app/routes/portal/routes/services/components/action.component.ts +++ b/web/projects/ui/src/app/routes/portal/routes/services/components/action.component.ts @@ -28,7 +28,7 @@ interface ActionItem { changeDetection: ChangeDetectionStrategy.OnPush, imports: [TuiTitle], host: { - '[disabled]': '!!disabled() || inactive()', + '[attr.disabled]': '(!!disabled() || inactive()) || null', }, }) export class ServiceActionComponent { diff --git a/web/projects/ui/src/app/routes/portal/routes/system/routes/email/email.component.ts b/web/projects/ui/src/app/routes/portal/routes/system/routes/email/email.component.ts index c354778c6..0340ae2b7 100644 --- a/web/projects/ui/src/app/routes/portal/routes/system/routes/email/email.component.ts +++ b/web/projects/ui/src/app/routes/portal/routes/system/routes/email/email.component.ts @@ -1,11 +1,6 @@ import { CommonModule } from '@angular/common' -import { - ChangeDetectionStrategy, - Component, - inject, - signal, -} from '@angular/core' -import { FormsModule, ReactiveFormsModule } from '@angular/forms' +import { ChangeDetectionStrategy, Component, inject } from '@angular/core' +import { FormControl, ReactiveFormsModule } from '@angular/forms' import { RouterLink } from '@angular/router' import { DialogService, @@ -15,11 +10,11 @@ import { i18nPipe, LoadingService, } from '@start9labs/shared' -import { inputSpec } from '@start9labs/start-sdk' -import { TuiButton, TuiTextfield, TuiTitle } from '@taiga-ui/core' +import { inputSpec, 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 { Subscription, switchMap, tap } from 'rxjs' +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' @@ -27,17 +22,6 @@ 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 = { - 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 = { @@ -61,8 +45,8 @@ function detectProviderKey(host: string | undefined): string { {{ 'SMTP' | i18n }} - @if (form$ | async; as form) { -
+ @if (form$ | async; as data) { +

@@ -80,59 +64,54 @@ function detectProviderKey(host: string | undefined): string {

- @if (spec | async; as resolved) { - - } - @if (providerHint()) { -

{{ providerHint() }}

- } +
- @if (isSaved) { - - }
-
-
-

- {{ 'Send test email' | i18n }} -

-
- - - +
+

+ {{ 'Send test email' | i18n }} +

+
+ + + + + -
-
- -
-
+
+ +
+ + } } `, styles: ` @@ -150,20 +129,14 @@ function detectProviderKey(host: string | undefined): string { 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, + TuiError, TuiTextfield, TuiHeader, TuiTitle, @@ -182,49 +155,58 @@ export default class SystemEmailComponent { private readonly api = inject(ApiService) private readonly i18n = inject(i18nPipe) - readonly providerHint = signal('') - private providerSub: Subscription | null = null + private readonly emailRegex = new RegExp(utils.Patterns.email.regex) + readonly testEmailControl = new FormControl('') - testAddress = '' - isSaved = false - - readonly spec = configBuilderToSpec(inputSpec.constants.systemSmtpSpec) + get isEmailInvalid(): boolean { + const value = this.testEmailControl.value + return !!value && !this.emailRegex.test(value) + } readonly form$ = this.patch.watch$('serverInfo', 'smtp').pipe( - tap(value => { - this.isSaved = !!value - }), switchMap(async value => { - const spec = await this.spec + const spec = await configBuilderToSpec(inputSpec.constants.systemSmtpSpec) + const formData = value - ? { provider: { selection: detectProviderKey(value.host), 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) - // 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 + return { form, spec } }), ) - async save(formValue: Record | null): Promise { + private getSmtpValue(formValue: Record) { + const { security, ...rest } = formValue['provider'].value + return { + ...rest, + security: security.selection, + port: Number(security.value.port), + } + } + + async save(formValue: Record): Promise { const loader = this.loader.open('Saving').subscribe() try { - if (formValue) { - await this.api.setSmtp(formValue['provider'].value) - this.isSaved = true - } else { + if (formValue['provider'].selection === 'none') { await this.api.clearSmtp({}) - this.isSaved = false + } else { + await this.api.setSmtp(this.getSmtpValue(formValue)) } } catch (e: any) { this.errorService.handleError(e) @@ -234,21 +216,22 @@ export default class SystemEmailComponent { } async sendTestEmail(formValue: Record) { - const smtpValue = formValue['provider'].value + 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')} ${this.testAddress}. ${this.i18n.transform('Check your spam folder and mark as not spam.')}` as i18nKey + `${this.i18n.transform('A test email has been sent to')} ${address}. ${this.i18n.transform('Check your spam folder and mark as not spam.')}` as i18nKey try { await this.api.testSmtp({ ...smtpValue, password: smtpValue.password || '', - to: this.testAddress, + to: address, }) this.dialog .openAlert(success, { label: 'Success', size: 's' }) .subscribe() - this.testAddress = '' + this.testEmailControl.reset() } catch (e: any) { this.errorService.handleError(e) } finally { diff --git a/web/projects/ui/src/app/services/api/embassy-live-api.service.ts b/web/projects/ui/src/app/services/api/embassy-live-api.service.ts index 3d8b0fd99..28ab2ad3b 100644 --- a/web/projects/ui/src/app/services/api/embassy-live-api.service.ts +++ b/web/projects/ui/src/app/services/api/embassy-live-api.service.ts @@ -393,7 +393,7 @@ export class LiveApiService extends ApiService { // wifi async enableWifi(params: T.SetWifiEnabledParams): Promise { - return this.rpcRequest({ method: 'wifi.enable', params }) + return this.rpcRequest({ method: 'wifi.set-enabled', params }) } async getWifi(params: {}, timeout?: number): Promise { @@ -685,9 +685,7 @@ export class LiveApiService extends ApiService { }) } - async pkgAddPrivateDomain( - params: PkgAddPrivateDomainReq, - ): Promise { + async pkgAddPrivateDomain(params: PkgAddPrivateDomainReq): Promise { return this.rpcRequest({ method: 'package.host.address.domain.private.add', params,