From e8d727c07a050252cc55bf235bcf5632ffe2d55e Mon Sep 17 00:00:00 2001 From: Matt Hill Date: Mon, 27 Jan 2025 11:40:26 -0700 Subject: [PATCH] better acme ux (#2820) * better acme ux * fix patching arrays... again --------- Co-authored-by: Aiden McClelland --- patch-db | 2 +- .../interface-info.component.ts | 4 +- .../pages/server-routes/acme/acme.page.html | 39 ++--- .../app/pages/server-routes/acme/acme.page.ts | 156 ++++++++++++------ .../services/api/embassy-mock-api.service.ts | 2 +- .../ui/src/app/services/api/mock-patch.ts | 2 +- web/projects/ui/src/app/util/acme.ts | 34 ++-- 7 files changed, 149 insertions(+), 90 deletions(-) diff --git a/patch-db b/patch-db index 36eb59b79..0df18c651 160000 --- a/patch-db +++ b/patch-db @@ -1 +1 @@ -Subproject commit 36eb59b79efec614fb4f94ed925336e97881a2f7 +Subproject commit 0df18c651f2311e2e26f3e6c8535a9e40b71502f diff --git a/web/projects/ui/src/app/components/interface-info/interface-info.component.ts b/web/projects/ui/src/app/components/interface-info/interface-info.component.ts index a6988e852..932b1f767 100644 --- a/web/projects/ui/src/app/components/interface-info/interface-info.component.ts +++ b/web/projects/ui/src/app/components/interface-info/interface-info.component.ts @@ -19,7 +19,7 @@ import { ApiService } from 'src/app/services/api/embassy-api.service' import { FormDialogService } from 'src/app/services/form-dialog.service' import { FormComponent } from 'src/app/components/form.component' import { configBuilderToSpec } from 'src/app/util/configBuilderToSpec' -import { ACME_URL, toAcmeName } from 'src/app/util/acme' +import { toAcmeName } from 'src/app/util/acme' import { ConfigService } from 'src/app/services/config.service' export type MappedInterface = T.ServiceInterface & { @@ -167,7 +167,7 @@ export class InterfaceInfoComponent { } } - async showAcme(url: ACME_URL | string | null): Promise { + async showAcme(url: string | null): Promise { const alert = await this.alertCtrl.create({ header: 'ACME Provider', message: toAcmeName(url), diff --git a/web/projects/ui/src/app/pages/server-routes/acme/acme.page.html b/web/projects/ui/src/app/pages/server-routes/acme/acme.page.html index 5269f04c1..ab7ad3a01 100644 --- a/web/projects/ui/src/app/pages/server-routes/acme/acme.page.html +++ b/web/projects/ui/src/app/pages/server-routes/acme/acme.page.html @@ -25,31 +25,28 @@ Saved Providers - - - - Add Provider - - - - + + + + Add Provider + + + + -

{{ toAcmeName(provider.key) }}

-

- Contact: {{ contact }} -

+

{{ toAcmeName(provider.url) }}

+

Contact: {{ provider.contactString }}

- - - Remove - + + + + + + + +
diff --git a/web/projects/ui/src/app/pages/server-routes/acme/acme.page.ts b/web/projects/ui/src/app/pages/server-routes/acme/acme.page.ts index 224fdfba5..5416194aa 100644 --- a/web/projects/ui/src/app/pages/server-routes/acme/acme.page.ts +++ b/web/projects/ui/src/app/pages/server-routes/acme/acme.page.ts @@ -7,7 +7,8 @@ import { FormDialogService } from '../../../services/form-dialog.service' import { FormComponent } from '../../../components/form.component' import { configBuilderToSpec } from '../../../util/configBuilderToSpec' import { ISB, utils } from '@start9labs/start-sdk' -import { ACME_Name, ACME_URL, knownACME, toAcmeName } from 'src/app/util/acme' +import { knownACME, toAcmeName } from 'src/app/util/acme' +import { map } from 'rxjs' @Component({ selector: 'acme', @@ -17,7 +18,21 @@ import { ACME_Name, ACME_URL, knownACME, toAcmeName } from 'src/app/util/acme' export class ACMEPage { readonly docsUrl = 'https://docs.start9.com/0.3.6/user-manual/acme' - acme$ = this.patch.watch$('serverInfo', 'acme') + acme$ = this.patch.watch$('serverInfo', 'acme').pipe( + map(acme => { + const providerUrls = Object.keys(acme) + return providerUrls.map(url => { + const contact = acme[url].contact.map(mailto => + mailto.replace('mailto:', ''), + ) + return { + url, + contact, + contactString: contact.join(', '), + } + }) + }), + ) toAcmeName = toAcmeName @@ -29,21 +44,55 @@ export class ACMEPage { private readonly formDialog: FormDialogService, ) {} - async presentFormAcme() { + async addAcme( + providers: { + url: string + contact: string[] + contactString: string + }[], + ) { this.formDialog.open(FormComponent, { label: 'Add ACME Provider', data: { - spec: await configBuilderToSpec(acmeSpec), + spec: await configBuilderToSpec( + getAddAcmeSpec(providers.map(p => p.url)), + ), buttons: [ { text: 'Save', - handler: async (val: typeof acmeSpec._TYPE) => this.saveAcme(val), + handler: async ( + val: ReturnType['_TYPE'], + ) => { + const providerUrl = + val.provider.selection === 'other' + ? val.provider.value.url + : val.provider.selection + + return this.saveAcme(providerUrl, val.contact) + }, }, ], }, }) } + async editAcme(provider: string, contact: string[]) { + this.formDialog.open(FormComponent, { + label: 'Edit ACME Provider', + data: { + spec: await configBuilderToSpec(editAcmeSpec), + buttons: [ + { + text: 'Save', + handler: async (val: typeof editAcmeSpec._TYPE) => + this.saveAcme(provider, val.contact), + }, + ], + value: { contact }, + }, + }) + } + async removeAcme(provider: string) { const loader = this.loader.open('Removing').subscribe() @@ -56,18 +105,14 @@ export class ACMEPage { } } - private async saveAcme(val: typeof acmeSpec._TYPE) { + private async saveAcme(providerUrl: string, contact: string[]) { + console.log(providerUrl, contact) const loader = this.loader.open('Saving').subscribe() - const rawUrl = - val.provider.selection === 'other' - ? val.provider.value.url - : val.provider.selection - try { await this.api.initAcme({ - provider: new URL(rawUrl).href, - contact: [`mailto:${val.contact}`], + provider: new URL(providerUrl).href, + contact: contact.map(address => `mailto:${address}`), }) return true } catch (e: any) { @@ -79,39 +124,56 @@ export class ACMEPage { } } -const acmeSpec = ISB.InputSpec.of({ - provider: ISB.Value.union( - { name: 'Provider', default: knownACME['Let\'s Encrypt'] as any }, - ISB.Variants.of({ - ...Object.entries(knownACME).reduce( - (obj, [name, url]) => ({ - ...obj, - [url]: { - name, - spec: ISB.InputSpec.of({}), - }, - }), - {}, - ), - other: { - name: 'Other', - spec: ISB.InputSpec.of({ - url: ISB.Value.text({ - name: 'URL', - default: null, - required: true, - inputmode: 'url', - patterns: [utils.Patterns.url], - }), - }), - }, - }), +const emailListSpec = ISB.Value.list( + ISB.List.text( + { + name: 'Contact Emails', + description: + 'Needed to obtain a certificate from a Certificate Authority', + minLength: 1, + }, + { + inputmode: 'email', + patterns: [utils.Patterns.email], + }, ), - contact: ISB.Value.text({ - name: 'Contact Email', - default: null, - required: true, - inputmode: 'email', - patterns: [utils.Patterns.email], - }), +) + +function getAddAcmeSpec(providers: string[]) { + const availableAcme = knownACME.filter(acme => !providers.includes(acme.url)) + + return ISB.InputSpec.of({ + provider: ISB.Value.union( + { name: 'Provider', default: (availableAcme[0]?.url as any) || 'other' }, + ISB.Variants.of({ + ...availableAcme.reduce( + (obj, curr) => ({ + ...obj, + [curr.url]: { + name: curr.name, + spec: ISB.InputSpec.of({}), + }, + }), + {}, + ), + other: { + name: 'Other', + spec: ISB.InputSpec.of({ + url: ISB.Value.text({ + name: 'URL', + default: null, + required: true, + inputmode: 'url', + patterns: [utils.Patterns.url], + }), + }), + }, + }), + ), + contact: emailListSpec, + }) +} + +const editAcmeSpec = ISB.InputSpec.of({ + contact: emailListSpec, }) diff --git a/web/projects/ui/src/app/services/api/embassy-mock-api.service.ts b/web/projects/ui/src/app/services/api/embassy-mock-api.service.ts index a71e916aa..31a021416 100644 --- a/web/projects/ui/src/app/services/api/embassy-mock-api.service.ts +++ b/web/projects/ui/src/app/services/api/embassy-mock-api.service.ts @@ -1064,7 +1064,7 @@ export class MockApiService extends ApiService { op: PatchOp.ADD, path: `/serverInfo/acme`, value: { - [toAcmeUrl(params.provider)]: { contact: [params.contact] }, + [toAcmeUrl(params.provider)]: { contact: params.contact }, }, }, ] diff --git a/web/projects/ui/src/app/services/api/mock-patch.ts b/web/projects/ui/src/app/services/api/mock-patch.ts index 23bed9832..c843f8f5f 100644 --- a/web/projects/ui/src/app/services/api/mock-patch.ts +++ b/web/projects/ui/src/app/services/api/mock-patch.ts @@ -65,7 +65,7 @@ export const mockPatchData: DataModel = { }, }, acme: { - [Object.keys(knownACME)[0]]: { + [knownACME[0].url]: { contact: ['mailto:support@start9.com'], }, }, diff --git a/web/projects/ui/src/app/util/acme.ts b/web/projects/ui/src/app/util/acme.ts index 79ae84faf..516472e11 100644 --- a/web/projects/ui/src/app/util/acme.ts +++ b/web/projects/ui/src/app/util/acme.ts @@ -1,21 +1,21 @@ -export function toAcmeName(url: ACME_URL | string | null): ACME_Name | string { - return ( - Object.entries(knownACME).find(([_, val]) => val === url)?.[0] || - url || - 'System CA' - ) +export function toAcmeName(url: string | null): string | 'System CA' { + return knownACME.find(acme => acme.url === url)?.name || url || 'System CA' } -export function toAcmeUrl(name: ACME_Name | string): ACME_URL | string { - return knownACME[name as ACME_Name] || name +export function toAcmeUrl(name: string): string { + return knownACME.find(acme => acme.name === name)?.url || name } -export const knownACME = { - 'Let\'s Encrypt': 'https://acme-v02.api.letsencrypt.org/directory', - 'Let\'s Encrypt (Staging)': - 'https://acme-staging-v02.api.letsencrypt.org/directory', -} - -export type ACME_Name = keyof typeof knownACME - -export type ACME_URL = (typeof knownACME)[ACME_Name] +export const knownACME: { + name: string + url: string +}[] = [ + { + name: `Let's Encrypt`, + url: 'https://acme-v02.api.letsencrypt.org/directory', + }, + { + name: `Let's Encrypt (Staging)`, + url: 'https://acme-staging-v02.api.letsencrypt.org/directory', + }, +]