better acme ux (#2820)

* better acme ux

* fix patching arrays... again

---------

Co-authored-by: Aiden McClelland <me@drbonez.dev>
This commit is contained in:
Matt Hill
2025-01-27 11:40:26 -07:00
committed by GitHub
parent e28fa26c43
commit e8d727c07a
7 changed files with 149 additions and 90 deletions

View File

@@ -19,7 +19,7 @@ import { ApiService } from 'src/app/services/api/embassy-api.service'
import { FormDialogService } from 'src/app/services/form-dialog.service' import { FormDialogService } from 'src/app/services/form-dialog.service'
import { FormComponent } from 'src/app/components/form.component' import { FormComponent } from 'src/app/components/form.component'
import { configBuilderToSpec } from 'src/app/util/configBuilderToSpec' 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' import { ConfigService } from 'src/app/services/config.service'
export type MappedInterface = T.ServiceInterface & { export type MappedInterface = T.ServiceInterface & {
@@ -167,7 +167,7 @@ export class InterfaceInfoComponent {
} }
} }
async showAcme(url: ACME_URL | string | null): Promise<void> { async showAcme(url: string | null): Promise<void> {
const alert = await this.alertCtrl.create({ const alert = await this.alertCtrl.create({
header: 'ACME Provider', header: 'ACME Provider',
message: toAcmeName(url), message: toAcmeName(url),

View File

@@ -25,31 +25,28 @@
<ion-item-divider>Saved Providers</ion-item-divider> <ion-item-divider>Saved Providers</ion-item-divider>
<ion-item button detail="false" (click)="presentFormAcme()">
<ion-icon slot="start" name="add" color="dark"></ion-icon>
<ion-label>
<b>Add Provider</b>
</ion-label>
</ion-item>
<ng-container *ngIf="acme$ | async as acme"> <ng-container *ngIf="acme$ | async as acme">
<ion-item *ngFor="let provider of acme | keyvalue"> <ion-item button detail="false" (click)="addAcme(acme)">
<ion-icon slot="start" name="add" color="dark"></ion-icon>
<ion-label>
<b>Add Provider</b>
</ion-label>
</ion-item>
<ion-item *ngFor="let provider of acme">
<ion-icon slot="start" name="finger-print" size="medium"></ion-icon> <ion-icon slot="start" name="finger-print" size="medium"></ion-icon>
<ion-label> <ion-label>
<h2>{{ toAcmeName(provider.key) }}</h2> <h2>{{ toAcmeName(provider.url) }}</h2>
<p *ngFor="let contact of provider.value.contact"> <p>Contact: {{ provider.contactString }}</p>
Contact: {{ contact }}
</p>
</ion-label> </ion-label>
<ion-button <ion-buttons slot="end">
slot="end" <ion-button (click)="editAcme(provider.url, provider.contact)">
fill="clear" <ion-icon slot="start" name="pencil"></ion-icon>
color="danger" </ion-button>
(click)="removeAcme(provider.key)" <ion-button (click)="removeAcme(provider.url)">
> <ion-icon slot="start" name="trash-outline"></ion-icon>
<ion-icon slot="start" name="close"></ion-icon> </ion-button>
Remove </ion-buttons>
</ion-button>
</ion-item> </ion-item>
</ng-container> </ng-container>
</ion-item-group> </ion-item-group>

View File

@@ -7,7 +7,8 @@ import { FormDialogService } from '../../../services/form-dialog.service'
import { FormComponent } from '../../../components/form.component' import { FormComponent } from '../../../components/form.component'
import { configBuilderToSpec } from '../../../util/configBuilderToSpec' import { configBuilderToSpec } from '../../../util/configBuilderToSpec'
import { ISB, utils } from '@start9labs/start-sdk' 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({ @Component({
selector: 'acme', selector: 'acme',
@@ -17,7 +18,21 @@ import { ACME_Name, ACME_URL, knownACME, toAcmeName } from 'src/app/util/acme'
export class ACMEPage { export class ACMEPage {
readonly docsUrl = 'https://docs.start9.com/0.3.6/user-manual/acme' 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 toAcmeName = toAcmeName
@@ -29,21 +44,55 @@ export class ACMEPage {
private readonly formDialog: FormDialogService, private readonly formDialog: FormDialogService,
) {} ) {}
async presentFormAcme() { async addAcme(
providers: {
url: string
contact: string[]
contactString: string
}[],
) {
this.formDialog.open(FormComponent, { this.formDialog.open(FormComponent, {
label: 'Add ACME Provider', label: 'Add ACME Provider',
data: { data: {
spec: await configBuilderToSpec(acmeSpec), spec: await configBuilderToSpec(
getAddAcmeSpec(providers.map(p => p.url)),
),
buttons: [ buttons: [
{ {
text: 'Save', text: 'Save',
handler: async (val: typeof acmeSpec._TYPE) => this.saveAcme(val), handler: async (
val: ReturnType<typeof getAddAcmeSpec>['_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) { async removeAcme(provider: string) {
const loader = this.loader.open('Removing').subscribe() 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 loader = this.loader.open('Saving').subscribe()
const rawUrl =
val.provider.selection === 'other'
? val.provider.value.url
: val.provider.selection
try { try {
await this.api.initAcme({ await this.api.initAcme({
provider: new URL(rawUrl).href, provider: new URL(providerUrl).href,
contact: [`mailto:${val.contact}`], contact: contact.map(address => `mailto:${address}`),
}) })
return true return true
} catch (e: any) { } catch (e: any) {
@@ -79,39 +124,56 @@ export class ACMEPage {
} }
} }
const acmeSpec = ISB.InputSpec.of({ const emailListSpec = ISB.Value.list(
provider: ISB.Value.union( ISB.List.text(
{ name: 'Provider', default: knownACME['Let\'s Encrypt'] as any }, {
ISB.Variants.of({ name: 'Contact Emails',
...Object.entries(knownACME).reduce( description:
(obj, [name, url]) => ({ 'Needed to obtain a certificate from a Certificate Authority',
...obj, minLength: 1,
[url]: { },
name, {
spec: ISB.InputSpec.of({}), inputmode: 'email',
}, patterns: [utils.Patterns.email],
}), },
{},
),
other: {
name: 'Other',
spec: ISB.InputSpec.of({
url: ISB.Value.text({
name: 'URL',
default: null,
required: true,
inputmode: 'url',
patterns: [utils.Patterns.url],
}),
}),
},
}),
), ),
contact: ISB.Value.text({ )
name: 'Contact Email',
default: null, function getAddAcmeSpec(providers: string[]) {
required: true, const availableAcme = knownACME.filter(acme => !providers.includes(acme.url))
inputmode: 'email',
patterns: [utils.Patterns.email], 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,
}) })

View File

@@ -1064,7 +1064,7 @@ export class MockApiService extends ApiService {
op: PatchOp.ADD, op: PatchOp.ADD,
path: `/serverInfo/acme`, path: `/serverInfo/acme`,
value: { value: {
[toAcmeUrl(params.provider)]: { contact: [params.contact] }, [toAcmeUrl(params.provider)]: { contact: params.contact },
}, },
}, },
] ]

View File

@@ -65,7 +65,7 @@ export const mockPatchData: DataModel = {
}, },
}, },
acme: { acme: {
[Object.keys(knownACME)[0]]: { [knownACME[0].url]: {
contact: ['mailto:support@start9.com'], contact: ['mailto:support@start9.com'],
}, },
}, },

View File

@@ -1,21 +1,21 @@
export function toAcmeName(url: ACME_URL | string | null): ACME_Name | string { export function toAcmeName(url: string | null): string | 'System CA' {
return ( return knownACME.find(acme => acme.url === url)?.name || url || 'System CA'
Object.entries(knownACME).find(([_, val]) => val === url)?.[0] ||
url ||
'System CA'
)
} }
export function toAcmeUrl(name: ACME_Name | string): ACME_URL | string { export function toAcmeUrl(name: string): string {
return knownACME[name as ACME_Name] || name return knownACME.find(acme => acme.name === name)?.url || name
} }
export const knownACME = { export const knownACME: {
'Let\'s Encrypt': 'https://acme-v02.api.letsencrypt.org/directory', name: string
'Let\'s Encrypt (Staging)': url: string
'https://acme-staging-v02.api.letsencrypt.org/directory', }[] = [
} {
name: `Let's Encrypt`,
export type ACME_Name = keyof typeof knownACME url: 'https://acme-v02.api.letsencrypt.org/directory',
},
export type ACME_URL = (typeof knownACME)[ACME_Name] {
name: `Let's Encrypt (Staging)`,
url: 'https://acme-staging-v02.api.letsencrypt.org/directory',
},
]