diff --git a/web/projects/ui/src/app/routes/portal/components/interfaces/clearnet.component.ts b/web/projects/ui/src/app/routes/portal/components/interfaces/clearnet.component.ts index 6d13f4ea0..e6bbe3e85 100644 --- a/web/projects/ui/src/app/routes/portal/components/interfaces/clearnet.component.ts +++ b/web/projects/ui/src/app/routes/portal/components/interfaces/clearnet.component.ts @@ -241,12 +241,12 @@ export class InterfaceClearnetComponent { name: 'ACME Provider', description: 'Select which ACME provider to use for obtaining your SSL certificate. Add new ACME providers in the System tab. Optionally use your system Root CA. Note: only devices that have trusted your Root CA will be able to access the domain without security warnings.', - values: this.acme().reduce( + values: this.acme().reduce>( (obj, url) => ({ ...obj, [url]: toAcmeName(url), }), - { none: 'None (use system Root CA)' } as Record, + { none: 'None (use system Root CA)' }, ), default: '', }) diff --git a/web/projects/ui/src/app/routes/portal/routes/system/routes/domains/acme.service.ts b/web/projects/ui/src/app/routes/portal/routes/system/routes/domains/acme/acme.service.ts similarity index 73% rename from web/projects/ui/src/app/routes/portal/routes/system/routes/domains/acme.service.ts rename to web/projects/ui/src/app/routes/portal/routes/system/routes/domains/acme/acme.service.ts index cd2158cb3..64d79c5d9 100644 --- a/web/projects/ui/src/app/routes/portal/routes/system/routes/domains/acme.service.ts +++ b/web/projects/ui/src/app/routes/portal/routes/system/routes/domains/acme/acme.service.ts @@ -5,18 +5,27 @@ import { i18nPipe, LoadingService, } from '@start9labs/shared' +import { toSignal } from '@angular/core/rxjs-interop' import { ISB, utils } from '@start9labs/start-sdk' -import { filter } from 'rxjs' +import { filter, map } from 'rxjs' import { FormComponent } from 'src/app/routes/portal/components/form.component' import { ApiService } from 'src/app/services/api/embassy-api.service' import { FormDialogService } from 'src/app/services/form-dialog.service' +import { PatchDB } from 'patch-db-client' +import { DataModel } from 'src/app/services/patch-db/data-model' import { knownACME } from 'src/app/utils/acme' import { configBuilderToSpec } from 'src/app/utils/configBuilderToSpec' +import { toAcmeName } from 'src/app/utils/acme' -@Injectable({ - providedIn: 'root', -}) +export type ACMEInfo = { + name: string + url: string + contact: readonly string[] +} + +@Injectable() export class AcmeService { + private readonly patch = inject>(PatchDB) private readonly loader = inject(LoadingService) private readonly errorService = inject(ErrorService) private readonly api = inject(ApiService) @@ -24,87 +33,26 @@ export class AcmeService { private readonly i18n = inject(i18nPipe) private readonly dialog = inject(DialogService) - async add(providers: { url: string; contact: string[] }[]) { - this.formDialog.open(FormComponent, { - label: 'Add ACME Provider', - data: { - spec: await configBuilderToSpec( - this.addSpec(providers.map(p => p.url)), - ), - buttons: [ - { - text: this.i18n.transform('Save'), - handler: async (val: ReturnType['_TYPE']) => { - const providerUrl = - val.provider.selection === 'other' - ? val.provider.value.url - : val.provider.selection + readonly acmes = toSignal( + this.patch.watch$('serverInfo', 'network', 'acme').pipe( + map(acme => + Object.keys(acme).map(url => ({ + url, + name: toAcmeName(url), + contact: + acme[url]?.contact.map(mailto => mailto.replace('mailto:', '')) || + [], + })), + ), + ), + ) - return this.save(providerUrl, val.contact) - }, - }, - ], - }, - }) - } - - async edit({ url, contact }: { url: string; contact: readonly string[] }) { - this.formDialog.open(FormComponent, { - label: 'Edit ACME Provider', - data: { - spec: await configBuilderToSpec(this.editSpec()), - buttons: [ - { - text: this.i18n.transform('Save'), - handler: async (val: ReturnType['_TYPE']) => - this.save(url, val.contact), - }, - ], - value: { contact }, - }, - }) - } - - remove({ url }: { url: string }) { - this.dialog - .openConfirm({ label: 'Are you sure?', size: 's' }) - .pipe(filter(Boolean)) - .subscribe(async () => { - const loader = this.loader.open('Removing').subscribe() - - try { - await this.api.removeAcme({ provider: url }) - } catch (e: any) { - this.errorService.handleError(e) - } finally { - loader.unsubscribe() - } - }) - } - - private async save(providerUrl: string, contact: readonly string[]) { - const loader = this.loader.open('Saving').subscribe() - - try { - await this.api.initAcme({ - provider: new URL(providerUrl).href, - contact: contact.map(address => `mailto:${address}`), - }) - return true - } catch (e: any) { - this.errorService.handleError(e) - return false - } finally { - loader.unsubscribe() - } - } - - private addSpec(providers: string[]) { + async add(acmes: ACMEInfo[]) { const availableAcme = knownACME.filter( - acme => !providers.includes(acme.url), + acme => !acmes.map(a => a.url).includes(acme.url), ) - return ISB.InputSpec.of({ + const addSpec = ISB.InputSpec.of({ provider: ISB.Value.union({ name: 'Provider', default: (availableAcme[0]?.url as any) || 'other', @@ -135,12 +83,81 @@ export class AcmeService { }), contact: this.emailListSpec(), }) + + this.formDialog.open(FormComponent, { + label: 'Add ACME Provider', + data: { + spec: await configBuilderToSpec(addSpec), + buttons: [ + { + text: this.i18n.transform('Save'), + handler: async (val: typeof addSpec._TYPE) => { + const providerUrl = + val.provider.selection === 'other' + ? val.provider.value.url + : val.provider.selection + + return this.save(providerUrl, val.contact) + }, + }, + ], + }, + }) } - private editSpec() { - return ISB.InputSpec.of({ + async edit({ url, contact }: ACMEInfo) { + const editSpec = ISB.InputSpec.of({ contact: this.emailListSpec(), }) + + this.formDialog.open(FormComponent, { + label: 'Edit ACME Provider', + data: { + spec: await configBuilderToSpec(editSpec), + buttons: [ + { + text: this.i18n.transform('Save'), + handler: async (val: typeof editSpec._TYPE) => + this.save(url, val.contact), + }, + ], + value: { contact }, + }, + }) + } + + remove({ url }: ACMEInfo) { + this.dialog + .openConfirm({ label: 'Are you sure?', size: 's' }) + .pipe(filter(Boolean)) + .subscribe(async () => { + const loader = this.loader.open('Removing').subscribe() + + try { + await this.api.removeAcme({ provider: url }) + } catch (e: any) { + this.errorService.handleError(e) + } finally { + loader.unsubscribe() + } + }) + } + + private async save(url: string, contact: readonly string[]) { + const loader = this.loader.open('Saving').subscribe() + + try { + await this.api.initAcme({ + provider: new URL(url).href, + contact: contact.map(address => `mailto:${address}`), + }) + return true + } catch (e: any) { + this.errorService.handleError(e) + return false + } finally { + loader.unsubscribe() + } } private emailListSpec() { diff --git a/web/projects/ui/src/app/routes/portal/routes/system/routes/domains/acme.component.ts b/web/projects/ui/src/app/routes/portal/routes/system/routes/domains/acme/item.component.ts similarity index 86% rename from web/projects/ui/src/app/routes/portal/routes/system/routes/domains/acme.component.ts rename to web/projects/ui/src/app/routes/portal/routes/system/routes/domains/acme/item.component.ts index c3b67bd72..0894d37cc 100644 --- a/web/projects/ui/src/app/routes/portal/routes/system/routes/domains/acme.component.ts +++ b/web/projects/ui/src/app/routes/portal/routes/system/routes/domains/acme/item.component.ts @@ -11,14 +11,12 @@ import { TuiDropdown, TuiTextfield, } from '@taiga-ui/core' -import { toAcmeName } from 'src/app/utils/acme' - -import { AcmeService } from './acme.service' +import { ACMEInfo, AcmeService } from './acme.service' @Component({ selector: 'tr[acme]', template: ` - {{ toAcmeName(acme().url) }} + {{ acme().name }} {{ acme().contact.join(', ') }} - - - - - - - - - - `, - styles: ` - td:last-child { - grid-area: 1 / 2 / 4; - align-self: center; - text-align: right; - } - - :host-context(tui-root._mobile) { - grid-template-columns: 1fr min-content; - - td:first-child { - font: var(--tui-font-text-m); - font-weight: bold; - } - } - `, - changeDetection: ChangeDetectionStrategy.OnPush, - imports: [TuiButton, i18nPipe, TuiDropdown, TuiDataList, TuiTextfield], -}) -export class DomainsDomainComponent { - private readonly dialog = inject(DialogService) - private readonly loader = inject(LoadingService) - private readonly errorService = inject(ErrorService) - private readonly formDialog = inject(FormDialogService) - - readonly domain = input.required() - - open = false - - remove() { - this.dialog - .openConfirm({ label: 'Are you sure?', size: 's' }) - .pipe(filter(Boolean)) - .subscribe(async () => { - const loader = this.loader.open('Deleting').subscribe() - - try { - } catch (e: any) { - this.errorService.handleError(e) - } finally { - loader.unsubscribe() - } - }) - } - - async edit() { - const renameSpec = ISB.InputSpec.of({}) - - this.formDialog.open(FormComponent, { - label: 'Edit', - data: { - spec: await configBuilderToSpec(renameSpec), - buttons: [ - { - text: 'Save', - handler: (value: typeof renameSpec._TYPE) => {}, - }, - ], - }, - }) - } - - async showDns() {} - - async testDns() {} -} diff --git a/web/projects/ui/src/app/routes/portal/routes/system/routes/domains/domains.component.ts b/web/projects/ui/src/app/routes/portal/routes/system/routes/domains/domains.component.ts index 475769cb2..c31c24640 100644 --- a/web/projects/ui/src/app/routes/portal/routes/system/routes/domains/domains.component.ts +++ b/web/projects/ui/src/app/routes/portal/routes/system/routes/domains/domains.component.ts @@ -1,21 +1,13 @@ -import { - ChangeDetectionStrategy, - Component, - inject, - signal, -} from '@angular/core' -import { toSignal } from '@angular/core/rxjs-interop' +import { ChangeDetectionStrategy, Component, inject } from '@angular/core' import { RouterLink } from '@angular/router' import { DocsLinkDirective, i18nPipe } from '@start9labs/shared' import { TuiButton, TuiLink, TuiTitle } from '@taiga-ui/core' import { TuiHeader } from '@taiga-ui/layout' -import { PatchDB } from 'patch-db-client' -import { map } from 'rxjs' -import { DataModel } from 'src/app/services/patch-db/data-model' import { TitleDirective } from 'src/app/services/title.service' - -import { AcmeService } from './acme.service' -import { DomainsTableComponent } from './table.component' +import { AcmeService } from './acme/acme.service' +import { DomainsService } from './domains/domains.service' +import { DomainsTableComponent } from './domains/table.component' +import { AcmeTableComponent } from './acme/table.component' @Component({ template: ` @@ -29,15 +21,14 @@ import { DomainsTableComponent } from './table.component'

{{ 'Domains' | i18n }}

+ {{ - 'Add ACME providers in order to generate SSL (https) certificates for clearnet access.' - | i18n + 'Adding a domain to StartOS means you can use it and its subdomains to host service interfaces on the public Internet.' }}

+
{{ 'ACME Providers' | i18n }} - @if (acme(); as value) { + @if (acmeService.acmes(); as acmes) { }
- +
{{ 'Domains' | i18n }} - + @if (domainsService.data(); as value) { + + }
- +
`, - styles: ``, changeDetection: ChangeDetectionStrategy.OnPush, imports: [ TuiButton, @@ -92,37 +85,11 @@ import { DomainsTableComponent } from './table.component' i18nPipe, DocsLinkDirective, DomainsTableComponent, + AcmeTableComponent, ], + providers: [AcmeService, DomainsService], }) export default class SystemDomainsComponent { - protected readonly patch = inject>(PatchDB) - protected readonly service = inject(AcmeService) - - readonly acme = toSignal( - this.patch.watch$('serverInfo', 'network', 'acme').pipe( - map(acme => - Object.keys(acme).map(url => ({ - url, - contact: - acme[url]?.contact.map(mailto => mailto.replace('mailto:', '')) || - [], - })), - ), - ), - ) - - readonly domains = signal([ - { - domain: 'blog.mydomain.com', - gateway: 'StartTunnel', - acme: 'System', - }, - { - domain: 'blog. mydomain.com', - gateway: 'StartTunnel', - acme: 'System', - }, - ]) - - async addDomain() {} + protected readonly acmeService = inject(AcmeService) + protected readonly domainsService = inject(DomainsService) } diff --git a/web/projects/ui/src/app/routes/portal/routes/system/routes/domains/domains/domains.service.ts b/web/projects/ui/src/app/routes/portal/routes/system/routes/domains/domains/domains.service.ts new file mode 100644 index 000000000..acdd391c2 --- /dev/null +++ b/web/projects/ui/src/app/routes/portal/routes/system/routes/domains/domains/domains.service.ts @@ -0,0 +1,193 @@ +import { inject, Injectable } from '@angular/core' +import { + DialogService, + ErrorService, + i18nPipe, + LoadingService, +} from '@start9labs/shared' +import { toSignal } from '@angular/core/rxjs-interop' +import { ISB, utils } from '@start9labs/start-sdk' +import { filter, map } from 'rxjs' +import { FormComponent } from 'src/app/routes/portal/components/form.component' +import { ApiService } from 'src/app/services/api/embassy-api.service' +import { FormDialogService } from 'src/app/services/form-dialog.service' +import { configBuilderToSpec } from 'src/app/utils/configBuilderToSpec' +import { PatchDB } from 'patch-db-client' +import { DataModel } from 'src/app/services/patch-db/data-model' +import { toAcmeName } from 'src/app/utils/acme' + +// @TODO translations + +@Injectable() +export class DomainsService { + private readonly patch = inject>(PatchDB) + private readonly loader = inject(LoadingService) + private readonly errorService = inject(ErrorService) + private readonly api = inject(ApiService) + private readonly formDialog = inject(FormDialogService) + private readonly i18n = inject(i18nPipe) + private readonly dialog = inject(DialogService) + + readonly data = toSignal( + this.patch.watch$('serverInfo', 'network').pipe( + map(network => { + return { + gateways: Object.entries(network.networkInterfaces).reduce< + Record + >( + (obj, [id, n]) => ({ + ...obj, + [id]: n.ipInfo?.name || '', + }), + {}, + ), + // @TODO use real data + domains: [ + { + domain: 'blog.mydomain.com', + gateway: { + id: '', + name: 'StartTunnel', + }, + acme: { + url: '', + name: `Lert's Encrypt`, + }, + }, + { + domain: 'store.mydomain.com', + gateway: { + id: '', + name: 'Ethernet', + }, + acme: { + url: null, + name: 'System', + }, + }, + ], + acme: Object.keys(network.acme).reduce>( + (obj, url) => ({ + ...obj, + [url]: toAcmeName(url), + }), + { none: 'None (use system Root CA)' }, + ), + } + }), + ), + ) + + async add() { + const addSpec = ISB.InputSpec.of({ + domain: ISB.Value.text({ + name: 'Domain', + description: + 'Enter a domain/subdomain. For example, if you control domain.com, you could enter domain.com or subdomain.domain.com or another.subdomain.domain.com. In any case, the domain you enter and all possible subdomains of the domain will be available for assignment in StartOS', + required: true, + default: null, + patterns: [utils.Patterns.domain], + }), + ...this.gatewaysAndAcme(), + }) + + this.formDialog.open(FormComponent, { + label: 'Add Domain' as any, + data: { + spec: await configBuilderToSpec(addSpec), + buttons: [ + { + text: 'Save', + handler: (input: typeof addSpec._TYPE) => this.save(input), + }, + ], + }, + }) + } + + async edit(domain: any) { + const editSpec = ISB.InputSpec.of({ + ...this.gatewaysAndAcme(), + }) + + this.formDialog.open(FormComponent, { + label: 'Edit Domain' as any, // @TODO translation + data: { + spec: await configBuilderToSpec(editSpec), + buttons: [ + { + text: 'Save', + handler: (input: typeof editSpec._TYPE) => + this.save({ + domain: domain.domain, + ...input, + }), + }, + ], + value: { + gateway: domain.gateway.id, + acme: domain.acme.url, + }, + }, + }) + } + + remove(domain: any) { + this.dialog + .openConfirm({ label: 'Are you sure?', size: 's' }) + .pipe(filter(Boolean)) + .subscribe(async () => { + const loader = this.loader.open('Deleting').subscribe() + + try { + // @TODO API + } catch (e: any) { + this.errorService.handleError(e) + } finally { + loader.unsubscribe() + } + }) + } + + showDns(domain: any) { + // @TODO + } + + testDns(domain: any) { + // @TODO + } + + // @TODO different endpoints for create and edit? + private async save(params: any) { + const loader = this.loader.open('Saving').subscribe() + + try { + // @TODO API + return true + } catch (e: any) { + this.errorService.handleError(e) + return false + } finally { + loader.unsubscribe() + } + } + + private gatewaysAndAcme() { + return { + gateway: ISB.Value.select({ + name: 'Gateway', + description: + 'Select the public gateway for this domain. Whichever gateway you select is the IP address that will be exposed to the Internet.', + values: this.data()!.gateways, + default: '', + }), + acme: ISB.Value.select({ + name: 'Default ACME', + description: + 'Select the default ACME provider for this domain. This can be overridden on a case-by-case basis.', + values: this.data()!.acme, + default: '', + }), + } + } +} diff --git a/web/projects/ui/src/app/routes/portal/routes/system/routes/domains/domains/item.component.ts b/web/projects/ui/src/app/routes/portal/routes/system/routes/domains/domains/item.component.ts new file mode 100644 index 000000000..ad035b910 --- /dev/null +++ b/web/projects/ui/src/app/routes/portal/routes/system/routes/domains/domains/item.component.ts @@ -0,0 +1,100 @@ +import { + ChangeDetectionStrategy, + Component, + inject, + input, +} from '@angular/core' +import { i18nPipe } from '@start9labs/shared' +import { + TuiButton, + TuiDataList, + TuiDropdown, + TuiTextfield, +} from '@taiga-ui/core' +import { DomainsService } from './domains.service' + +@Component({ + selector: 'tr[domain]', + template: ` + {{ domain().domain }} + {{ domain().gateway.name }} + {{ domain().acme.name }} + + + + + + + + + + + + `, + styles: ` + td:last-child { + grid-area: 1 / 2 / 4; + align-self: center; + text-align: right; + } + + :host-context(tui-root._mobile) { + grid-template-columns: 1fr min-content; + + td:first-child { + font: var(--tui-font-text-m); + font-weight: bold; + } + } + `, + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [TuiButton, i18nPipe, TuiDropdown, TuiDataList, TuiTextfield], +}) +export class DomainsItemComponent { + protected readonly domainsService = inject(DomainsService) + + readonly domain = input.required() + + open = false +} diff --git a/web/projects/ui/src/app/routes/portal/routes/system/routes/domains/domains/table.component.ts b/web/projects/ui/src/app/routes/portal/routes/system/routes/domains/domains/table.component.ts new file mode 100644 index 000000000..b82485ebf --- /dev/null +++ b/web/projects/ui/src/app/routes/portal/routes/system/routes/domains/domains/table.component.ts @@ -0,0 +1,41 @@ +import { ChangeDetectionStrategy, Component, input } from '@angular/core' +import { i18nPipe } from '@start9labs/shared' +import { TuiSkeleton } from '@taiga-ui/kit' +import { PlaceholderComponent } from 'src/app/routes/portal/components/placeholder.component' +import { TableComponent } from 'src/app/routes/portal/components/table.component' +import { DomainsItemComponent } from './item.component' + +@Component({ + selector: 'domains-table', + template: ` + + @for (domain of domains(); track $index) { + + } @empty { + + + + } +
+ @if (domains()) { + + {{ 'No domains' | i18n }} + + } @else { +
{{ 'Loading' | i18n }}
+ } +
+ `, + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [ + TuiSkeleton, + i18nPipe, + TableComponent, + PlaceholderComponent, + DomainsItemComponent, + ], +}) +export class DomainsTableComponent { + // @TODO Alex proper types + readonly domains = input() +} diff --git a/web/projects/ui/src/app/routes/portal/routes/system/routes/domains/table.component.ts b/web/projects/ui/src/app/routes/portal/routes/system/routes/domains/table.component.ts deleted file mode 100644 index 4c0dcf5e3..000000000 --- a/web/projects/ui/src/app/routes/portal/routes/system/routes/domains/table.component.ts +++ /dev/null @@ -1,68 +0,0 @@ -import { - ChangeDetectionStrategy, - Component, - computed, - input, -} from '@angular/core' -import { i18nPipe } from '@start9labs/shared' -import { TuiSkeleton } from '@taiga-ui/kit' -import { PlaceholderComponent } from 'src/app/routes/portal/components/placeholder.component' -import { TableComponent } from 'src/app/routes/portal/components/table.component' - -import { DomainsAcmeComponent } from './acme.component' -import { DomainsDomainComponent } from './domain.component' - -@Component({ - selector: 'domains-table', - template: ` - - @for (item of items(); track $index) { - @if (mode() === 'domains') { - - } @else if (mode() === 'acme') { - - } - } @empty { - - - - } -
- @if (items()) { - - @if (mode() === 'domains') { - {{ 'No domains' | i18n }} - } @else { - {{ 'No saved providers' | i18n }} - } - - } @else { -
{{ 'Loading' | i18n }}
- } -
- `, - changeDetection: ChangeDetectionStrategy.OnPush, - imports: [ - TuiSkeleton, - i18nPipe, - TableComponent, - PlaceholderComponent, - DomainsDomainComponent, - DomainsAcmeComponent, - ], -}) -export class DomainsTableComponent { - // @TODO Alex proper types - readonly items = input() - readonly mode = input<'domains' | 'acme'>('domains') - - readonly titles = computed(() => - this.mode() === 'domains' - ? (['Domain', 'Gateway', 'Default ACME', null] as const) - : (['Provider', 'Contact', null] as const), - ) - - readonly icon = computed(() => - this.mode() === 'domains' ? '@tui.globe' : '@tui.shield-question', - ) -} diff --git a/web/projects/ui/src/app/routes/portal/routes/system/routes/gateways/gateways.component.ts b/web/projects/ui/src/app/routes/portal/routes/system/routes/gateways/gateways.component.ts index 1fbadbb33..fd0824214 100644 --- a/web/projects/ui/src/app/routes/portal/routes/system/routes/gateways/gateways.component.ts +++ b/web/projects/ui/src/app/routes/portal/routes/system/routes/gateways/gateways.component.ts @@ -97,67 +97,22 @@ export default class GatewaysComponent { ), ) - readonly gatewaySpec = ISB.InputSpec.of({ - name: ISB.Value.text({ - name: 'Name', - description: 'A name to easily identify the gateway', - required: true, - default: null, - }), - type: ISB.Value.select({ - name: 'Type', - description: - '-**Private**: select this option if the gateway is configured for private access to authorized clients only, which usually means ports are closed and traffic blocked otherwise. StartTunnel is a private gateway.\n-**Public**: select this option if the gateway is configured for unfettered public access, which usually means ports are open and traffic forwarded.', - default: 'private', - values: { - private: 'Private', - public: 'Public', - }, - }), - config: ISB.Value.union({ - name: 'Wireguard Config', - default: 'paste', - variants: ISB.Variants.of({ - paste: { - name: 'Paste File Contents', - spec: ISB.InputSpec.of({ - file: ISB.Value.textarea({ - name: 'Paste File Contents', - default: null, - required: true, - }), - }), - }, - upload: { - name: 'Upload File', - spec: ISB.InputSpec.of({ - file: ISB.Value.file({ - name: 'File', - required: true, - extensions: ['.conf'], - }), - }), - }, - }), - }), - }) - async add() { this.formDialog.open(FormComponent, { label: 'Add Gateway', data: { - spec: await configBuilderToSpec(this.gatewaySpec), + spec: await configBuilderToSpec(gatewaySpec), buttons: [ { text: 'Save', - handler: (input: typeof this.gatewaySpec._TYPE) => this.save(input), + handler: (input: typeof gatewaySpec._TYPE) => this.save(input), }, ], }, }) } - private async save(input: typeof this.gatewaySpec._TYPE): Promise { + private async save(input: typeof gatewaySpec._TYPE): Promise { const loader = this.loader.open('Saving').subscribe() try { @@ -175,3 +130,48 @@ export default class GatewaysComponent { } } } + +const gatewaySpec = ISB.InputSpec.of({ + name: ISB.Value.text({ + name: 'Name', + description: 'A name to easily identify the gateway', + required: true, + default: null, + }), + type: ISB.Value.select({ + name: 'Type', + description: + '-**Private**: select this option if the gateway is configured for private access to authorized clients only. StartTunnel is a private gateway.\n-**Public**: select this option if the gateway is configured for unfettered public access.', + default: 'private', + values: { + private: 'Private', + public: 'Public', + }, + }), + config: ISB.Value.union({ + name: 'Wireguard Config', + default: 'paste', + variants: ISB.Variants.of({ + paste: { + name: 'Paste File Contents', + spec: ISB.InputSpec.of({ + file: ISB.Value.textarea({ + name: 'Paste File Contents', + default: null, + required: true, + }), + }), + }, + upload: { + name: 'Upload File', + spec: ISB.InputSpec.of({ + file: ISB.Value.file({ + name: 'File', + required: true, + extensions: ['.conf'], + }), + }), + }, + }), + }), +})