enhance invitation wizard with email sending and flow creation options

- Add an "email step" to the invitation wizard, allowing users to send invitations directly after creation.
- Add an inline "Send email" button to the invitation success step and the invitation list.
- Add the ability to select the user type (internal vs. external) when the wizard creates a new enrollment flow.
- Improve UX by automatically skipping the flow selection step if there is only one eligible flow available.
- Add a button to create a new flow directly from the alert shown when no eligible flows exist.
- Add a new blueprint (`flows-invitation-enrollment-minimal.yaml`) for a minimal invitation enrollment flow.
This commit is contained in:
Marcelo Elizeche Landó
2026-04-24 02:43:47 -03:00
parent 5886b0c39b
commit c00fb59467
7 changed files with 353 additions and 52 deletions

View File

@@ -27,6 +27,15 @@ export class InvitationListLink extends AKElement {
@property()
selectedFlow?: string;
/**
* When true, the "Send via Email" button dispatches the
* `ak-invitation-send-email-inline` event instead of opening the nested
* email modal. Used by the invitation wizard's success step so the email
* form can be rendered as its own wizard step.
*/
@property({ type: Boolean, attribute: "inline-send-email" })
inlineSendEmail = false;
static styles: CSSResult[] = [
PFForm,
PFFormControl,
@@ -137,18 +146,32 @@ export class InvitationListLink extends AKElement {
>
${msg("Copy Link")}
</button>
<ak-forms-modal>
<span slot="submit">${msg("Send")}</span>
<span slot="header">${msg("Send Invitation via Email")}</span>
<ak-invitation-send-email-form
slot="form"
.invitation=${this.invitation}
>
</ak-invitation-send-email-form>
<button slot="trigger" class="pf-c-button pf-m-secondary">
${msg("Send via Email")}
</button>
</ak-forms-modal>
${this.inlineSendEmail
? html`<button
class="pf-c-button pf-m-secondary"
@click=${() => {
this.dispatchEvent(
new CustomEvent("ak-invitation-send-email-inline", {
bubbles: true,
composed: true,
}),
);
}}
>
${msg("Send via Email")}
</button>`
: html`<ak-forms-modal>
<span slot="submit">${msg("Send")}</span>
<span slot="header">${msg("Send Invitation via Email")}</span>
<ak-invitation-send-email-form
slot="form"
.invitation=${this.invitation}
>
</ak-invitation-send-email-form>
<button slot="trigger" class="pf-c-button pf-m-secondary">
${msg("Send via Email")}
</button>
</ak-forms-modal>`}
</div>
</dd>
</div>

View File

@@ -1,4 +1,5 @@
import "#admin/stages/invitation/wizard/InvitationWizardDetailsStep";
import "#admin/stages/invitation/wizard/InvitationWizardEmailStep";
import "#admin/stages/invitation/wizard/InvitationWizardFlowStep";
import "#admin/stages/invitation/wizard/InvitationWizardSuccessStep";
import "#elements/wizard/Wizard";
@@ -46,6 +47,10 @@ export class InvitationWizard extends AKElement implements TransclusionChildElem
slot="success-step"
headline=${msg("Invitation Link")}
></ak-invitation-wizard-success-step>
<ak-invitation-wizard-email-step
slot="email-step"
headline=${msg("Send via Email")}
></ak-invitation-wizard-email-step>
</ak-wizard>`;
}
}

View File

@@ -16,7 +16,7 @@ import { dateTimeLocal } from "#common/temporal";
import { showMessage } from "#elements/messages/MessageContainer";
import { WizardPage } from "#elements/wizard/WizardPage";
import { FlowDesignationEnum, FlowsApi, StagesApi } from "@goauthentik/api";
import { FlowsApi, ManagedApi, StagesApi } from "@goauthentik/api";
import YAML from "yaml";
@@ -28,6 +28,13 @@ import PFForm from "@patternfly/patternfly/components/Form/form.css";
import PFFormControl from "@patternfly/patternfly/components/FormControl/form-control.css";
import PFBase from "@patternfly/patternfly/patternfly-base.css";
// Slug used by blueprints/example/flows-invitation-enrollment-minimal.yaml when
// no context is supplied. Once the /managed/blueprints/import/ endpoint accepts
// a context parameter, the slug the user typed will take effect and this
// fallback can be removed.
const MINIMAL_BLUEPRINT_PATH = "example/flows-invitation-enrollment-minimal.yaml";
const MINIMAL_BLUEPRINT_DEFAULT_SLUG = "invitation-enrollment-flow";
@customElement("ak-invitation-wizard-details-step")
export class InvitationWizardDetailsStep extends WizardPage {
static styles: CSSResult[] = [PFBase, PFForm, PFFormControl];
@@ -93,51 +100,47 @@ export class InvitationWizardDetailsStep extends WizardPage {
wizardState.invitationFixedData = fixedData;
wizardState.invitationSingleUse = this.singleUse;
if (wizardState.needsStage) {
try {
const stage = await new StagesApi(DEFAULT_CONFIG).stagesInvitationStagesCreate({
invitationStageRequest: {
name: wizardState.newStageName!,
continueFlowWithoutInvitation: wizardState.continueFlowWithoutInvitation,
},
});
wizardState.createdStagePk = stage.pk;
wizardState.needsStage = false;
} catch (err) {
return this.#fail(msg("Creating invitation stage"), err);
}
}
if (wizardState.needsFlow) {
try {
const flow = await new FlowsApi(DEFAULT_CONFIG).flowsInstancesCreate({
flowRequest: {
name: wizardState.newFlowName!,
slug: wizardState.newFlowSlug!,
title: wizardState.newFlowName!,
designation: FlowDesignationEnum.Enrollment,
},
// TODO(@BeryJu context): once /managed/blueprints/import/ accepts a
// context parameter, pass { flow_name, flow_slug, stage_name,
// continue_flow_without_invitation, user_type } so the user's inputs
// drive the generated flow. Until then the blueprint's !Context
// defaults apply and the generated flow uses the fixed slug below.
const result = await new ManagedApi(DEFAULT_CONFIG).managedBlueprintsImportCreate({
path: MINIMAL_BLUEPRINT_PATH,
});
wizardState.createdFlowPk = flow.pk;
wizardState.createdFlowSlug = flow.slug;
wizardState.needsFlow = false;
} catch (err) {
return this.#fail(msg("Creating enrollment flow"), err);
}
}
if (!result.success) {
const logs = (result.logs || [])
.map((l) => l.event)
.filter((m) => !!m)
.join("\n");
return this.#fail(
msg("Importing enrollment flow blueprint"),
new Error(logs || msg("Blueprint validation failed")),
);
}
if (wizardState.needsBinding) {
try {
await new FlowsApi(DEFAULT_CONFIG).flowsBindingsCreate({
flowStageBindingRequest: {
target: wizardState.createdFlowPk!,
stage: wizardState.createdStagePk!,
order: 0,
},
const slugToLookup = MINIMAL_BLUEPRINT_DEFAULT_SLUG;
const flows = await new FlowsApi(DEFAULT_CONFIG).flowsInstancesList({
slug: slugToLookup,
});
const createdFlow = flows.results[0];
if (!createdFlow) {
return this.#fail(
msg("Importing enrollment flow blueprint"),
new Error(
msg(str`Flow with slug "${slugToLookup}" not found after import`),
),
);
}
wizardState.createdFlowPk = createdFlow.pk;
wizardState.createdFlowSlug = createdFlow.slug;
wizardState.needsFlow = false;
wizardState.needsStage = false;
wizardState.needsBinding = false;
} catch (err) {
return this.#fail(msg("Binding stage to flow"), err);
return this.#fail(msg("Importing enrollment flow blueprint"), err);
}
}

View File

@@ -0,0 +1,209 @@
import "#components/ak-textarea-input";
import "#elements/forms/HorizontalFormElement";
import type { InvitationWizardState } from "./types";
import { DEFAULT_CONFIG } from "#common/api/config";
import {
parseAPIResponseError,
pluckErrorDetail,
pluckFallbackFieldErrors,
} from "#common/errors/network";
import { AKRefreshEvent } from "#common/events";
import { MessageLevel } from "#common/messages";
import { showMessage } from "#elements/messages/MessageContainer";
import { WizardPage } from "#elements/wizard/WizardPage";
import { StagesApi, TypeCreate } from "@goauthentik/api";
import { msg, str } from "@lit/localize";
import { CSSResult, html, TemplateResult } from "lit";
import { customElement, state } from "lit/decorators.js";
import PFForm from "@patternfly/patternfly/components/Form/form.css";
import PFFormControl from "@patternfly/patternfly/components/FormControl/form-control.css";
import PFBase from "@patternfly/patternfly/patternfly-base.css";
@customElement("ak-invitation-wizard-email-step")
export class InvitationWizardEmailStep extends WizardPage {
static styles: CSSResult[] = [PFBase, PFForm, PFFormControl];
@state()
toAddresses = "";
@state()
ccAddresses = "";
@state()
bccAddresses = "";
@state()
template = "email/invitation.html";
@state()
availableTemplates: TypeCreate[] = [];
activeCallback = async (): Promise<void> => {
this.host.valid = this.toAddresses.trim().length > 0;
try {
this.availableTemplates = await new StagesApi(
DEFAULT_CONFIG,
).stagesEmailTemplatesList();
} catch {
this.availableTemplates = [];
}
};
parseEmailAddresses(raw: string): string[] {
return raw
.split(/[\n,;]/)
.map((value) => value.trim())
.filter((value) => value.length > 0);
}
validate(): void {
this.host.valid = this.parseEmailAddresses(this.toAddresses).length > 0;
}
nextCallback = async (): Promise<boolean> => {
const wizardState = this.host.state as unknown as InvitationWizardState;
const invitationPk = wizardState.createdInvitationPk;
if (!invitationPk) {
showMessage({
level: MessageLevel.error,
message: msg("No invitation available to send"),
});
return false;
}
const to = this.parseEmailAddresses(this.toAddresses);
if (to.length === 0) {
showMessage({
level: MessageLevel.error,
message: msg("Please enter at least one email address"),
});
return false;
}
const cc = this.parseEmailAddresses(this.ccAddresses);
const bcc = this.parseEmailAddresses(this.bccAddresses);
try {
await new StagesApi(DEFAULT_CONFIG).stagesInvitationInvitationsSendEmailCreate({
inviteUuid: invitationPk,
invitationSendEmailRequest: {
emailAddresses: to,
ccAddresses: cc.length > 0 ? cc : undefined,
bccAddresses: bcc.length > 0 ? bcc : undefined,
template: this.template,
},
});
} catch (err) {
const parsed = await parseAPIResponseError(err);
const fieldErrors = pluckFallbackFieldErrors(parsed);
const detail =
fieldErrors.length > 0 ? fieldErrors.join(" ") : pluckErrorDetail(parsed);
showMessage({
level: MessageLevel.error,
message: msg("Failed to queue invitation emails"),
description: detail,
});
return false;
}
showMessage({
level: MessageLevel.success,
message: msg(
str`Invitation emails queued for sending to ${to.length} recipient(s). Check the System Tasks for more information.`,
),
});
this.dispatchEvent(new AKRefreshEvent());
return true;
};
override reset(): void {
this.toAddresses = "";
this.ccAddresses = "";
this.bccAddresses = "";
this.template = "email/invitation.html";
}
render(): TemplateResult {
return html`<form class="pf-c-form pf-m-horizontal">
<ak-form-element-horizontal label=${msg("To")} required>
<textarea
class="pf-c-form-control"
required
rows="3"
.value=${this.toAddresses}
@input=${(ev: InputEvent) => {
this.toAddresses = (ev.target as HTMLTextAreaElement).value;
this.validate();
}}
></textarea>
<p class="pf-c-form__helper-text">
${msg(
"One email address per line, or comma/semicolon separated. Each recipient will receive a separate email with an invitation link.",
)}
</p>
</ak-form-element-horizontal>
<ak-form-element-horizontal label=${msg("CC")}>
<textarea
class="pf-c-form-control"
rows="2"
.value=${this.ccAddresses}
@input=${(ev: InputEvent) => {
this.ccAddresses = (ev.target as HTMLTextAreaElement).value;
}}
></textarea>
<p class="pf-c-form__helper-text">
${msg(
"A comma-separated list of addresses to receive copies of the invitation. Recipients will receive the full list of other addresses in this list.",
)}
</p>
</ak-form-element-horizontal>
<ak-form-element-horizontal label=${msg("BCC")}>
<textarea
class="pf-c-form-control"
rows="2"
.value=${this.bccAddresses}
@input=${(ev: InputEvent) => {
this.bccAddresses = (ev.target as HTMLTextAreaElement).value;
}}
></textarea>
<p class="pf-c-form__helper-text">
${msg(
"A comma-separated list of addresses to receive copies of the invitation. Recipients will not receive the addresses of other recipients.",
)}
</p>
</ak-form-element-horizontal>
<ak-form-element-horizontal label=${msg("Template")} required>
<select
class="pf-c-form-control"
@change=${(ev: Event) => {
this.template = (ev.target as HTMLSelectElement).value;
}}
>
${this.availableTemplates.map(
(template) =>
html`<option
value=${template.name}
?selected=${template.name === this.template}
>
${template.description}
</option>`,
)}
</select>
<p class="pf-c-form__helper-text">
${msg("Select the email template to use for sending invitations.")}
</p>
</ak-form-element-horizontal>
</form>`;
}
}
declare global {
interface HTMLElementTagNameMap {
"ak-invitation-wizard-email-step": InvitationWizardEmailStep;
}
}

View File

@@ -1,3 +1,4 @@
import "#components/ak-radio-input";
import "#components/ak-switch-input";
import "#elements/forms/HorizontalFormElement";
import "#elements/forms/SearchSelect/index";
@@ -59,6 +60,9 @@ export class InvitationWizardFlowStep extends WizardPage {
@state()
newStageName = "invitation-stage";
@state()
newUserType: "external" | "internal" = "external";
@state()
continueFlowWithoutInvitation = true;
@@ -106,6 +110,17 @@ export class InvitationWizardFlowStep extends WizardPage {
}
this.loading = false;
// If there's exactly one eligible flow, skip this step so the user goes
// straight to the invitation details. Drop ourselves from the step list
// so the back button from the next step doesn't bounce back here.
if (this.mode === "existing" && this.enrollmentFlows.length === 1) {
const currentSlot = this.slot;
const advanced = await this.host.navigateNext();
if (advanced) {
this.host.steps = this.host.steps.filter((s) => s !== currentSlot);
}
}
};
validate(): void {
@@ -136,6 +151,7 @@ export class InvitationWizardFlowStep extends WizardPage {
state.newFlowName = this.newFlowName;
state.newFlowSlug = this.newFlowSlug;
state.newStageName = this.newStageName;
state.newUserType = this.newUserType;
state.continueFlowWithoutInvitation = this.continueFlowWithoutInvitation;
state.needsFlow = true;
state.needsStage = true;
@@ -153,6 +169,7 @@ export class InvitationWizardFlowStep extends WizardPage {
this.newFlowName = "Enrollment with invitation";
this.newFlowSlug = "enrollment-with-invitation";
this.newStageName = "invitation-stage";
this.newUserType = "external";
this.continueFlowWithoutInvitation = true;
}
@@ -176,9 +193,19 @@ export class InvitationWizardFlowStep extends WizardPage {
<div class="pf-c-alert__description">
<p>
${msg(
"Cancel this wizard and select 'with New Enrollment Flow and Invitation Stage' from the dropdown. Alternatively, you can manually create an enrollment flow and bind an invitation stage to it.",
"You can create a new enrollment flow and invitation stage right here, or cancel and bind an invitation stage to an existing flow manually.",
)}
</p>
<button
type="button"
class="pf-c-button pf-m-primary"
@click=${() => {
this.mode = "create";
this.validate();
}}
>
${msg("Create a new enrollment flow")}
</button>
</div>
</div>
`;
@@ -262,6 +289,29 @@ export class InvitationWizardFlowStep extends WizardPage {
/>
<p class="pf-c-form__helper-text">${msg("Name for the new invitation stage.")}</p>
</ak-form-element-horizontal>
<ak-radio-input
label=${msg("User type")}
.value=${this.newUserType}
.options=${[
{
label: msg("External"),
value: "external",
description: html`${msg(
"Enrolled users are created as external (e.g. customers, guests). New users will be placed under users/external.",
)}`,
},
{
label: msg("Internal"),
value: "internal",
description: html`${msg(
"Enrolled users are created as internal (e.g. employees). New users will be placed under users/internal.",
)}`,
},
]}
@input=${(ev: CustomEvent<{ value: "external" | "internal" }>) => {
this.newUserType = ev.detail.value;
}}
></ak-radio-input>
<ak-switch-input
label=${msg("Continue flow without invitation")}
?checked=${this.continueFlowWithoutInvitation}

View File

@@ -80,9 +80,19 @@ export class InvitationWizardSuccessStep extends WizardPage {
return html`
<ak-stage-invitation-list-link
.invitation=${invitation}
?inline-send-email=${true}
@ak-invitation-send-email-inline=${this.onSendViaEmail}
></ak-stage-invitation-list-link>
`;
}
onSendViaEmail = async (): Promise<void> => {
const steps = this.host.steps;
if (!steps.includes("email-step")) {
this.host.steps = [...steps, "email-step"];
}
await this.host.navigateNext();
};
}
declare global {

View File

@@ -8,6 +8,7 @@ export interface InvitationWizardState {
newFlowName?: string;
newFlowSlug?: string;
newStageName?: string;
newUserType?: "external" | "internal";
continueFlowWithoutInvitation: boolean;
// Flags for which API calls to make