mirror of
https://github.com/goauthentik/authentik
synced 2026-04-25 17:15:26 +02:00
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:
@@ -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>
|
||||
|
||||
@@ -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>`;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user