diff --git a/web/src/admin/applications/wizard/BasePanel.ts b/web/src/admin/applications/wizard/BasePanel.ts index 3601b89f6..33cd79f03 100644 --- a/web/src/admin/applications/wizard/BasePanel.ts +++ b/web/src/admin/applications/wizard/BasePanel.ts @@ -2,7 +2,7 @@ import { AKElement } from "@goauthentik/elements/Base"; import { CustomEmitterElement } from "@goauthentik/elements/utils/eventEmitter"; import { consume } from "@lit-labs/context"; -import { query, state } from "@lit/reactive-element/decorators.js"; +import { query } from "@lit/reactive-element/decorators.js"; import { styles as AwadStyles } from "./BasePanel.css"; @@ -23,21 +23,9 @@ export class ApplicationWizardPageBase extends CustomEmitterElement(AKElement) { @consume({ context: applicationWizardContext }) public wizard!: WizardState; - shouldUpdate(changedProperties: Map) { - if (!this.rendered) { - this.rendered = true; - return true; - } - return (changedProperties.size !== 0) - } - + // This used to be more complex; now it just establishes the event name. dispatchWizardUpdate(update: Partial) { - // TODO: Incorporate this into the message heading upward: "the current step is valid." - - this.dispatchCustomEvent("ak-application-wizard-update", { - ...this.wizard, - ...update, - }); + this.dispatchCustomEvent("ak-application-wizard-update", { update }); } } diff --git a/web/src/admin/applications/wizard/ak-application-wizard.ts b/web/src/admin/applications/wizard/ak-application-wizard.ts index 6882c2743..0a2320f8e 100644 --- a/web/src/admin/applications/wizard/ak-application-wizard.ts +++ b/web/src/admin/applications/wizard/ak-application-wizard.ts @@ -1,3 +1,4 @@ +import { merge } from "@goauthentik/common/merge"; import "@goauthentik/components/ak-wizard-main"; import { AKElement } from "@goauthentik/elements/Base"; import { CustomListenerElement } from "@goauthentik/elements/utils/eventEmitter"; @@ -13,7 +14,7 @@ import PFBase from "@patternfly/patternfly/patternfly-base.css"; import applicationWizardContext from "./ak-application-wizard-context-name"; import { steps } from "./steps"; -import { WizardState, WizardStateEvent } from "./types"; +import { OneOfProvider, WizardState, WizardStateEvent } from "./types"; // my-context.ts @@ -30,6 +31,7 @@ export class ApplicationWizard extends CustomListenerElement(AKElement) { * Providing a context at the root element */ @provide({ context: applicationWizardContext }) + @state() wizardState: WizardState = { step: 0, providerType: "", @@ -43,6 +45,8 @@ export class ApplicationWizard extends CustomListenerElement(AKElement) { @property() prompt = msg("Create"); + providerCache: Map = new Map(); + constructor() { super(); this.handleUpdate = this.handleUpdate.bind(this); @@ -60,12 +64,19 @@ export class ApplicationWizard extends CustomListenerElement(AKElement) { // And this is where all the special cases go... handleUpdate(event: CustomEvent) { - delete event.detail.target; - const newWizardState: WizardState = event.detail; + const update = event.detail.update; - // When the user sets the authentication method type, the corresponding authentication - // method page becomes available. - if (newWizardState.providerType !== "") { + // Are we changing provider type? If so, swap the caches of the various provider types the + // user may have filled in, and enable the next step. + const providerType = update.providerType; + if ( + providerType && + typeof providerType === "string" && + providerType !== this.wizardState.providerType + ) { + this.providerCache.set(this.wizardState.providerType, this.wizardState.provider); + const prevProvider = this.providerCache.get(providerType); + this.wizardState.provider = prevProvider ?? {}; const newSteps = [...this.steps]; const method = newSteps.find(({ id }) => id === "auth-method"); if (!method) { @@ -74,7 +85,9 @@ export class ApplicationWizard extends CustomListenerElement(AKElement) { method.disabled = false; this.steps = newSteps; } - this.wizardState = newWizardState; + + this.wizardState = merge(this.wizardState, update) as WizardState; + console.log(JSON.stringify(this.wizardState, null, 2)); } render(): TemplateResult { diff --git a/web/src/admin/applications/wizard/application/ak-application-wizard-application-details.ts b/web/src/admin/applications/wizard/application/ak-application-wizard-application-details.ts index 2027caf86..badcc3a6d 100644 --- a/web/src/admin/applications/wizard/application/ak-application-wizard-application-details.ts +++ b/web/src/admin/applications/wizard/application/ak-application-wizard-application-details.ts @@ -25,15 +25,12 @@ export class ApplicationWizardApplicationDetails extends BasePanel { const value = target.type === "checkbox" ? target.checked : target.value; this.dispatchWizardUpdate({ application: { - ...this.wizard.application, [target.name]: value, }, }); } - render(): TemplateResult { - console.log("HUH?"); return html`
+ + + + + + ${msg("UI settings")} +
+ + + +
+
+ `; + } +} + +export default ApplicationWizardApplicationDetails; diff --git a/web/src/admin/applications/wizard/methods/BaseProviderPanel.ts b/web/src/admin/applications/wizard/methods/BaseProviderPanel.ts index 2558a3b6d..f0e44507c 100644 --- a/web/src/admin/applications/wizard/methods/BaseProviderPanel.ts +++ b/web/src/admin/applications/wizard/methods/BaseProviderPanel.ts @@ -10,7 +10,6 @@ export class ApplicationWizardProviderPageBase extends BasePanel { const value = target.type === "checkbox" ? target.checked : target.value; this.dispatchWizardUpdate({ provider: { - ...this.wizard.provider, [target.name]: value, }, }); diff --git a/web/src/admin/applications/wizard/steps.ts b/web/src/admin/applications/wizard/steps.ts index 1b7a5a072..52e76e289 100644 --- a/web/src/admin/applications/wizard/steps.ts +++ b/web/src/admin/applications/wizard/steps.ts @@ -33,8 +33,19 @@ export const steps: WizardStep[] = [ renderer: () => html``, disabled: true, + nextButtonLabel: msg("Next"), + backButtonLabel: msg("Back"), + valid: true, + }, + { + id: "commit-application", + label: "Submit New Application", + renderer: () => + html``, + disabled: true, nextButtonLabel: msg("Submit"), backButtonLabel: msg("Back"), valid: true, }, + ]; diff --git a/web/src/admin/applications/wizard/types.ts b/web/src/admin/applications/wizard/types.ts index c850b5111..ca5705ece 100644 --- a/web/src/admin/applications/wizard/types.ts +++ b/web/src/admin/applications/wizard/types.ts @@ -23,4 +23,4 @@ export interface WizardState { provider: OneOfProvider; } -export type WizardStateEvent = WizardState & { target?: HTMLInputElement }; +export type WizardStateEvent = { update: Partial }; diff --git a/web/src/admin/applications/wizard/wizardController.ts b/web/src/admin/applications/wizard/wizardController.ts new file mode 100644 index 000000000..86a42050d --- /dev/null +++ b/web/src/admin/applications/wizard/wizardController.ts @@ -0,0 +1,28 @@ +import { ReactiveController } from 'lit'; +import type { ReactiveControllerHost } from 'lit'; + +export class ApplicationWizardController implements ReactiveController { + host: ReactiveControllerHost; + + value = new Date(); + timeout: number; + private _timerID?: number; + + constructor(host: ReactiveControllerHost, timeout = 1000) { + (this.host = host).addController(this); + this.timeout = timeout; + } + hostConnected() { + // Start a timer when the host is connected + this._timerID = setInterval(() => { + this.value = new Date(); + // Update the host with new value + this.host.requestUpdate(); + }, this.timeout); + } + hostDisconnected() { + // Clear the timer when the host is disconnected + clearInterval(this._timerID); + this._timerID = undefined; + } +} diff --git a/web/src/common/merge.ts b/web/src/common/merge.ts new file mode 100644 index 000000000..4e60e856c --- /dev/null +++ b/web/src/common/merge.ts @@ -0,0 +1,120 @@ +/** Taken from: https://github.com/zellwk/javascript/tree/master + * + * We have added some typescript annotations, but this is such a rich feature with deep nesting + * we'll just have to watch it closely for any issues. So far there don't seem to be any. + * + */ + +function objectType(value: T) { + return Object.prototype.toString.call(value); +} + +// Creates a deep clone for each value +// eslint-disable-next-line @typescript-eslint/no-explicit-any +function cloneDescriptorValue(value: any) { + // Arrays + if (objectType(value) === "[object Array]") { + const array = []; + for (let v of value) { + v = cloneDescriptorValue(v); + array.push(v); + } + return array; + } + + // Objects + if (objectType(value) === "[object Object]") { + const obj = {}; + const props = Object.keys(value); + for (const prop of props) { + const descriptor = Object.getOwnPropertyDescriptor(value, prop); + if (!descriptor) { + continue; + } + + if (descriptor.value) { + descriptor.value = cloneDescriptorValue(descriptor.value); + } + Object.defineProperty(obj, prop, descriptor); + } + return obj; + } + + // Other Types of Objects + if (objectType(value) === "[object Date]") { + return new Date(value.getTime()); + } + + if (objectType(value) === "[object Map]") { + const map = new Map(); + for (const entry of value) { + map.set(entry[0], cloneDescriptorValue(entry[1])); + } + return map; + } + + if (objectType(value) === "[object Set]") { + const set = new Set(); + for (const entry of value.entries()) { + set.add(cloneDescriptorValue(entry[0])); + } + return set; + } + + // Types we don't need to clone or cannot clone. + // Examples: + // - Primitives don't need to clone + // - Functions cannot clone + return value; +} + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +function _merge(output: Record, input: Record) { + const props = Object.keys(input); + + for (const prop of props) { + // Prevents Prototype Pollution + if (prop === "__proto__") continue; + + const descriptor = Object.getOwnPropertyDescriptor(input, prop); + if (!descriptor) { + continue; + } + + const value = descriptor.value; + if (value) descriptor.value = cloneDescriptorValue(value); + + // If don't have prop => Define property + // [ken@goauthentik] Using `hasOwn` is preferable over + // the basic identity test, according to Typescript. + if (!Object.hasOwn(output, prop)) { + Object.defineProperty(output, prop, descriptor); + continue; + } + + // If have prop, but type is not object => Overwrite by redefining property + if (typeof output[prop] !== "object") { + Object.defineProperty(output, prop, descriptor); + continue; + } + + // If have prop, but type is Object => Concat the arrays together. + if (objectType(descriptor.value) === "[object Array]") { + output[prop] = output[prop].concat(descriptor.value); + continue; + } + + // If have prop, but type is Object => Merge. + _merge(output[prop], descriptor.value); + } +} + +export function merge(...sources: Array) { + const result = {}; + for (const source of sources) { + _merge(result, source); + } + return result; +} + +export default merge;