stages/invitation: Invitation wizard (#20399)

This commit is contained in:
Marcelo Elizeche Landó
2026-05-05 13:47:31 -03:00
committed by GitHub
parent befc15ad92
commit a8db2882ec
15 changed files with 1518 additions and 22 deletions
+16 -1
View File
@@ -1,5 +1,6 @@
"""Serializer mixin for managed models"""
from json import JSONDecodeError, loads
from typing import cast
from django.conf import settings
@@ -44,6 +45,7 @@ class BlueprintUploadSerializer(PassiveSerializer):
file = FileField(required=False)
path = CharField(required=False)
context = CharField(required=False, allow_blank=True)
def validate_path(self, path: str) -> str:
"""Ensure the path (if set) specified is retrievable"""
@@ -54,6 +56,18 @@ class BlueprintUploadSerializer(PassiveSerializer):
raise ValidationError(_("Blueprint file does not exist"))
return path
def validate_context(self, context: str) -> dict:
"""Parse context as a JSON object"""
if not context:
return {}
try:
parsed = loads(context)
except JSONDecodeError as exc:
raise ValidationError(_("Context must be valid JSON")) from exc
if not isinstance(parsed, dict):
raise ValidationError(_("Context must be a JSON object"))
return parsed
class ManagedSerializer:
"""Managed Serializer"""
@@ -224,7 +238,8 @@ class BlueprintInstanceViewSet(UsedByMixin, ModelViewSet):
).retrieve_file()
else:
raise ValidationError("Either path or file must be set")
importer = Importer.from_string(string_contents)
context = body.validated_data.get("context") or {}
importer = Importer.from_string(string_contents, context)
check_blueprint_perms(importer.blueprint, request.user)
+109 -1
View File
@@ -1,6 +1,6 @@
"""Test blueprints v1 api"""
from json import loads
from json import dumps, loads
from tempfile import NamedTemporaryFile, mkdtemp
from django.urls import reverse
@@ -8,7 +8,11 @@ from rest_framework.test import APITestCase
from yaml import dump
from authentik.core.tests.utils import create_test_admin_user
from authentik.flows.models import Flow
from authentik.lib.config import CONFIG
from authentik.lib.generators import generate_id
from authentik.stages.invitation.models import InvitationStage
from authentik.stages.user_write.models import UserWriteStage
TMP = mkdtemp("authentik-blueprints")
@@ -80,3 +84,107 @@ class TestBlueprintsV1API(APITestCase):
res.content.decode(),
{"content": ["Failed to validate blueprint", "- Invalid blueprint version"]},
)
def test_api_import_with_context(self):
"""Test that the import endpoint applies the supplied context to the real blueprint"""
slug = f"invitation-enrollment-{generate_id()}"
flow_name = f"Invitation Enrollment {generate_id()}"
stage_name = f"invitation-stage-{generate_id()}"
user_type = "internal"
continue_without_invitation = True
res = self.client.post(
reverse("authentik_api:blueprintinstance-import-"),
data={
"path": "example/flows-invitation-enrollment-minimal.yaml",
"context": dumps(
{
"flow_slug": slug,
"flow_name": flow_name,
"stage_name": stage_name,
"continue_flow_without_invitation": continue_without_invitation,
"user_type": user_type,
}
),
},
format="multipart",
)
self.assertEqual(res.status_code, 200)
self.assertTrue(res.json()["success"])
flow = Flow.objects.get(slug=slug)
self.assertEqual(flow.name, flow_name)
self.assertEqual(flow.title, flow_name)
invitation_stage = InvitationStage.objects.get(name=stage_name)
self.assertEqual(
invitation_stage.continue_flow_without_invitation,
continue_without_invitation,
)
user_write_stage = UserWriteStage.objects.get(
name=f"invitation-enrollment-user-write-{slug}"
)
self.assertEqual(user_write_stage.user_type, user_type)
self.assertEqual(user_write_stage.user_path_template, f"users/{user_type}")
def test_api_import_blank_path(self):
"""Validator returns empty path unchanged (covers api.py:53)."""
with NamedTemporaryFile(mode="w+", suffix=".yaml") as file:
file.write(dump({"version": 1, "entries": []}))
file.flush()
file.seek(0)
res = self.client.post(
reverse("authentik_api:blueprintinstance-import-"),
data={"path": "", "file": file},
format="multipart",
)
self.assertEqual(res.status_code, 200)
def test_api_import_unknown_path(self):
"""Path not in available blueprints is rejected (covers api.py:56)."""
res = self.client.post(
reverse("authentik_api:blueprintinstance-import-"),
data={"path": "does/not/exist.yaml"},
format="multipart",
)
self.assertEqual(res.status_code, 400)
self.assertIn("Blueprint file does not exist", res.content.decode())
def test_api_import_blank_context(self):
"""Blank context is normalized to empty dict (covers api.py:62)."""
res = self.client.post(
reverse("authentik_api:blueprintinstance-import-"),
data={
"path": "example/flows-invitation-enrollment-minimal.yaml",
"context": "",
},
format="multipart",
)
self.assertEqual(res.status_code, 200)
def test_api_import_invalid_json_context(self):
"""Malformed JSON context raises ValidationError (covers api.py:65-66)."""
res = self.client.post(
reverse("authentik_api:blueprintinstance-import-"),
data={
"path": "example/flows-invitation-enrollment-minimal.yaml",
"context": "{not json",
},
format="multipart",
)
self.assertEqual(res.status_code, 400)
self.assertIn("Context must be valid JSON", res.content.decode())
def test_api_import_non_object_context(self):
"""JSON context that isn't an object is rejected (covers api.py:68)."""
res = self.client.post(
reverse("authentik_api:blueprintinstance-import-"),
data={
"path": "example/flows-invitation-enrollment-minimal.yaml",
"context": "[1, 2, 3]",
},
format="multipart",
)
self.assertEqual(res.status_code, 400)
self.assertIn("Context must be a JSON object", res.content.decode())
@@ -0,0 +1,211 @@
# Minimal Invitation-based Enrollment Blueprint
#
# Companion to flows-invitation-enrollment.yaml, intended for the "New Invitation"
# wizard in the admin UI. Creates a single enrollment flow with an invitation stage
# bound to it, plus the supporting prompt/user-write/user-login stages.
#
# All user-facing fields are parameterized via !Context with fallback defaults, so
# this blueprint can be imported directly (without context) or through the wizard
# with custom values.
#
# Context keys (all optional):
# flow_name Display name of the enrollment flow.
# flow_slug URL slug of the flow and suffix for sub-entity
# identifiers (so repeated imports with different
# slugs don't overwrite each other).
# stage_name Name of the invitation stage.
# continue_flow_without_invitation Whether the flow continues when no invitation
# is supplied (default: false).
# user_type "external" or "internal" (default: "external").
# Drives the user-write stage's user_type and
# user_path_template.
version: 1
metadata:
labels:
blueprints.goauthentik.io/instantiate: "false"
name: Invitation-based Enrollment (minimal)
entries:
- identifiers:
slug: !Context [flow_slug, invitation-enrollment-flow]
model: authentik_flows.flow
id: flow
attrs:
name: !Context [flow_name, Invitation Enrollment Flow]
title: !Context [flow_name, Invitation Enrollment Flow]
designation: enrollment
authentication: require_unauthenticated
- identifiers:
name: !Context [stage_name, invitation-stage]
id: invitation-stage
model: authentik_stages_invitation.invitationstage
attrs:
continue_flow_without_invitation: !Context [continue_flow_without_invitation, false]
- identifiers:
name:
!Format [
"invitation-enrollment-field-username-%s",
!Context [flow_slug, invitation-enrollment-flow],
]
id: prompt-field-username
model: authentik_stages_prompt.prompt
attrs:
field_key: username
label: Username
type: username
required: true
placeholder: Username
placeholder_expression: false
order: 0
- identifiers:
name:
!Format [
"invitation-enrollment-field-password-%s",
!Context [flow_slug, invitation-enrollment-flow],
]
id: prompt-field-password
model: authentik_stages_prompt.prompt
attrs:
field_key: password
label: Password
type: password
required: true
placeholder: Password
placeholder_expression: false
order: 1
- identifiers:
name:
!Format [
"invitation-enrollment-field-password-repeat-%s",
!Context [flow_slug, invitation-enrollment-flow],
]
id: prompt-field-password-repeat
model: authentik_stages_prompt.prompt
attrs:
field_key: password_repeat
label: Password (repeat)
type: password
required: true
placeholder: Password (repeat)
placeholder_expression: false
order: 2
- identifiers:
name:
!Format [
"invitation-enrollment-field-name-%s",
!Context [flow_slug, invitation-enrollment-flow],
]
id: prompt-field-name
model: authentik_stages_prompt.prompt
attrs:
field_key: name
label: Name
type: text
required: true
placeholder: Name
placeholder_expression: false
order: 0
- identifiers:
name:
!Format [
"invitation-enrollment-field-email-%s",
!Context [flow_slug, invitation-enrollment-flow],
]
id: prompt-field-email
model: authentik_stages_prompt.prompt
attrs:
field_key: email
label: Email
type: email
required: true
placeholder: Email
placeholder_expression: false
order: 1
- identifiers:
name:
!Format [
"invitation-enrollment-prompt-credentials-%s",
!Context [flow_slug, invitation-enrollment-flow],
]
id: prompt-stage-credentials
model: authentik_stages_prompt.promptstage
attrs:
fields:
- !KeyOf prompt-field-username
- !KeyOf prompt-field-password
- !KeyOf prompt-field-password-repeat
- identifiers:
name:
!Format [
"invitation-enrollment-prompt-details-%s",
!Context [flow_slug, invitation-enrollment-flow],
]
id: prompt-stage-details
model: authentik_stages_prompt.promptstage
attrs:
fields:
- !KeyOf prompt-field-name
- !KeyOf prompt-field-email
- identifiers:
name:
!Format [
"invitation-enrollment-user-write-%s",
!Context [flow_slug, invitation-enrollment-flow],
]
id: user-write-stage
model: authentik_stages_user_write.userwritestage
attrs:
user_creation_mode: always_create
user_type: !Context [user_type, external]
user_path_template:
!Format ["users/%s", !Context [user_type, external]]
- identifiers:
name:
!Format [
"invitation-enrollment-user-login-%s",
!Context [flow_slug, invitation-enrollment-flow],
]
id: user-login-stage
model: authentik_stages_user_login.userloginstage
- identifiers:
target: !KeyOf flow
stage: !KeyOf invitation-stage
order: 5
model: authentik_flows.flowstagebinding
attrs:
evaluate_on_plan: true
re_evaluate_policies: true
- identifiers:
target: !KeyOf flow
stage: !KeyOf prompt-stage-credentials
order: 10
model: authentik_flows.flowstagebinding
- identifiers:
target: !KeyOf flow
stage: !KeyOf prompt-stage-details
order: 15
model: authentik_flows.flowstagebinding
- identifiers:
target: !KeyOf flow
stage: !KeyOf user-write-stage
order: 20
model: authentik_flows.flowstagebinding
- identifiers:
target: !KeyOf flow
stage: !KeyOf user-login-stage
order: 100
model: authentik_flows.flowstagebinding
+5
View File
@@ -47,6 +47,7 @@ export interface ManagedBlueprintsDestroyRequest {
export interface ManagedBlueprintsImportCreateRequest {
file?: Blob;
path?: string;
context?: string;
}
export interface ManagedBlueprintsListRequest {
@@ -369,6 +370,10 @@ export class ManagedApi extends runtime.BaseAPI {
formParams.append("path", requestParameters["path"] as any);
}
if (requestParameters["context"] != null) {
formParams.append("context", requestParameters["context"] as any);
}
let urlPath = `/managed/blueprints/import/`;
return {
+2
View File
@@ -36050,6 +36050,8 @@ components:
path:
type: string
minLength: 1
context:
type: string
Brand:
type: object
description: Brand Serializer
@@ -10,7 +10,7 @@ import { AKElement } from "#elements/Base";
import { Invitation, StagesApi } from "@goauthentik/api";
import { msg } from "@lit/localize";
import { CSSResult, html, nothing, TemplateResult } from "lit";
import { css, CSSResult, html, nothing, TemplateResult } from "lit";
import { customElement, property } from "lit/decorators.js";
import { until } from "lit/directives/until.js";
@@ -27,7 +27,30 @@ export class InvitationListLink extends AKElement {
@property()
selectedFlow?: string;
static styles: CSSResult[] = [PFForm, PFFormControl, PFDescriptionList, PFButton];
/**
* 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,
PFDescriptionList,
PFButton,
css`
:host {
display: block;
width: 100%;
}
input.pf-c-form-control {
width: 100%;
}
`,
];
renderLink(): string {
if (this.invitation?.flowObj) {
@@ -103,6 +126,7 @@ export class InvitationListLink extends AKElement {
class="pf-c-form-control"
readonly
type="text"
style="width: 100%;"
value=${this.renderLink()}
/>
</div>
@@ -122,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,6 +1,8 @@
import "#admin/rbac/ObjectPermissionModal";
import "#admin/stages/invitation/InvitationForm";
import "#admin/stages/invitation/InvitationListLink";
import "#admin/stages/invitation/wizard/InvitationWizard";
import "#elements/buttons/Dropdown";
import "#elements/buttons/ModalButton";
import "#elements/buttons/SpinnerButton/ak-spinner-button";
import "#elements/forms/DeleteBulkForm";
@@ -9,7 +11,7 @@ import "@patternfly/elements/pf-tooltip/pf-tooltip.js";
import { DEFAULT_CONFIG } from "#common/api/config";
import { IconEditButton, ModalInvokerButton } from "#elements/dialogs";
import { IconEditButton, modalInvoker } from "#elements/dialogs";
import { PFColor } from "#elements/Label";
import { PaginatedResponse, TableColumn } from "#elements/table/Table";
import { TablePage } from "#elements/table/TablePage";
@@ -18,11 +20,12 @@ import { SlottedTemplateResult } from "#elements/types";
import { setPageDetails } from "#components/ak-page-navbar";
import { InvitationForm } from "#admin/stages/invitation/InvitationForm";
import { InvitationWizard } from "#admin/stages/invitation/wizard/InvitationWizard";
import { FlowDesignationEnum, Invitation, ModelEnum, StagesApi } from "@goauthentik/api";
import { msg } from "@lit/localize";
import { CSSResult, html, PropertyValues } from "lit";
import { CSSResult, html, PropertyValues, TemplateResult } from "lit";
import { customElement, state } from "lit/decorators.js";
import PFBanner from "@patternfly/patternfly/components/Banner/banner.css";
@@ -139,7 +142,66 @@ export class InvitationListPage extends TablePage<Invitation> {
}
protected override renderObjectCreate(): SlottedTemplateResult {
return ModalInvokerButton(InvitationForm);
return html`${this.renderNewInvitationDropdown()}`;
}
protected renderNewInvitationDropdown(): TemplateResult {
return html`<ak-dropdown class="pf-c-dropdown">
<div class="pf-c-dropdown__toggle pf-m-primary pf-m-split-button pf-m-action">
<button
class="pf-c-dropdown__toggle-button"
type="button"
${modalInvoker(InvitationWizard, { mode: "existing" })}
>
${msg("New Invitation")}
</button>
<button
class="pf-c-dropdown__toggle-button"
type="button"
id="new-invitation-toggle"
aria-haspopup="menu"
aria-controls="new-invitation-menu"
tabindex="0"
aria-label=${msg("New Invitation options")}
>
<i class="fas fa-caret-down" aria-hidden="true"></i>
</button>
</div>
<menu
class="pf-c-dropdown__menu"
hidden
id="new-invitation-menu"
aria-labelledby="new-invitation-toggle"
tabindex="-1"
>
<li role="presentation">
<button
type="button"
role="menuitem"
class="pf-c-dropdown__menu-item"
${modalInvoker(InvitationWizard, { mode: "existing" })}
aria-description=${msg(
"Opens the new invitation wizard and binds the invitation to an existing enrollment flow.",
)}
>
${msg("with Existing Enrollment Flow...")}
</button>
</li>
<li role="presentation">
<button
type="button"
role="menuitem"
class="pf-c-dropdown__menu-item"
${modalInvoker(InvitationWizard, { mode: "create" })}
aria-description=${msg(
"Opens the new invitation wizard, which will create a new enrollment flow and invitation stage.",
)}
>
${msg("with New Enrollment Flow and Invitation Stage...")}
</button>
</li>
</menu>
</ak-dropdown>`;
}
protected override render(): SlottedTemplateResult {
@@ -0,0 +1,62 @@
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";
import { AKElement } from "#elements/Base";
import { TransclusionChildElement, TransclusionChildSymbol } from "#elements/dialogs";
import { SlottedTemplateResult } from "#elements/types";
import { msg } from "@lit/localize";
import { customElement } from "@lit/reactive-element/decorators/custom-element.js";
import { property } from "@lit/reactive-element/decorators/property.js";
import { html } from "lit";
export type InvitationWizardFlowMode = "existing" | "create";
@customElement("ak-invitation-wizard")
export class InvitationWizard extends AKElement implements TransclusionChildElement {
public static verboseName = msg("Invitation");
public [TransclusionChildSymbol] = true;
@property({ type: String })
public mode: InvitationWizardFlowMode = "existing";
protected override createRenderRoot(): HTMLElement | DocumentFragment {
return this;
}
protected override render(): SlottedTemplateResult {
return html`<ak-wizard
entity-singular=${msg("Invitation")}
description=${msg("Create a new invitation with an enrollment flow.")}
.initialSteps=${["flow-step", "details-step", "success-step"]}
>
<ak-invitation-wizard-flow-step
slot="flow-step"
headline=${msg("Enrollment Flow")}
.mode=${this.mode}
></ak-invitation-wizard-flow-step>
<ak-invitation-wizard-details-step
slot="details-step"
headline=${msg("Invitation Details")}
></ak-invitation-wizard-details-step>
<ak-invitation-wizard-success-step
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>`;
}
}
declare global {
interface HTMLElementTagNameMap {
"ak-invitation-wizard": InvitationWizard;
}
}
@@ -0,0 +1,261 @@
import "#components/ak-switch-input";
import "#elements/CodeMirror";
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 { MessageLevel } from "#common/messages";
import { dateTimeLocal } from "#common/temporal";
import { showMessage } from "#elements/messages/MessageContainer";
import { WizardPage } from "#elements/wizard/WizardPage";
import { FlowsApi, ManagedApi, StagesApi } from "@goauthentik/api";
import YAML from "yaml";
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";
const MINIMAL_BLUEPRINT_PATH = "example/flows-invitation-enrollment-minimal.yaml";
@customElement("ak-invitation-wizard-details-step")
export class InvitationWizardDetailsStep extends WizardPage {
static styles: CSSResult[] = [PFBase, PFForm, PFFormControl];
@state()
invitationName = "";
@state()
invitationExpires: string = dateTimeLocal(new Date(Date.now() + 48 * 60 * 60 * 1000));
@state()
fixedDataRaw = "{}";
@state()
singleUse = true;
activeCallback = async (): Promise<void> => {
this.host.valid = this.invitationName.length > 0;
};
async #fail(step: string, err: unknown): Promise<false> {
const parsed = await parseAPIResponseError(err);
const fieldErrors = pluckFallbackFieldErrors(parsed);
const detail = fieldErrors.length > 0 ? fieldErrors.join(" ") : pluckErrorDetail(parsed);
showMessage({
level: MessageLevel.error,
message: msg(str`${step} failed`),
description: detail,
});
this.logger.error("Invitation wizard step failed", { step, error: err });
return false;
}
validate(): void {
let validYaml = true;
try {
YAML.parse(this.fixedDataRaw);
} catch {
validYaml = false;
}
this.host.valid =
this.invitationName.length > 0 && this.invitationExpires.length > 0 && validYaml;
}
nextCallback = async (): Promise<boolean> => {
if (!this.invitationName) return false;
let fixedData: Record<string, unknown> = {};
try {
fixedData = YAML.parse(this.fixedDataRaw) || {};
} catch {
return false;
}
const wizardState = this.host.state as unknown as InvitationWizardState;
if (wizardState.createdInvitationPk) {
return true;
}
wizardState.invitationName = this.invitationName;
wizardState.invitationExpires = this.invitationExpires;
wizardState.invitationFixedData = fixedData;
wizardState.invitationSingleUse = this.singleUse;
if (wizardState.needsFlow) {
try {
const result = await new ManagedApi(DEFAULT_CONFIG).managedBlueprintsImportCreate({
path: MINIMAL_BLUEPRINT_PATH,
context: JSON.stringify({
flow_name: wizardState.newFlowName,
flow_slug: wizardState.newFlowSlug,
stage_name: wizardState.newStageName,
continue_flow_without_invitation: wizardState.continueFlowWithoutInvitation,
user_type: wizardState.newUserType,
}),
});
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")),
);
}
const slugToLookup = wizardState.newFlowSlug!;
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("Importing enrollment flow blueprint"), err);
}
}
try {
const flowPk = wizardState.createdFlowPk || wizardState.selectedFlowPk || undefined;
const invitation = await new StagesApi(
DEFAULT_CONFIG,
).stagesInvitationInvitationsCreate({
invitationRequest: {
name: wizardState.invitationName!,
expires: wizardState.invitationExpires
? new Date(wizardState.invitationExpires)
: undefined,
fixedData: wizardState.invitationFixedData,
singleUse: wizardState.invitationSingleUse,
flow: flowPk || null,
},
});
wizardState.createdInvitationPk = invitation.pk;
wizardState.createdInvitation = invitation;
} catch (err) {
return this.#fail(msg("Creating invitation"), err);
}
return true;
};
override reset(): void {
this.invitationName = "";
this.invitationExpires = dateTimeLocal(new Date(Date.now() + 48 * 60 * 60 * 1000));
this.fixedDataRaw = "{}";
this.singleUse = true;
}
render(): TemplateResult {
const wizardState = this.host.state as unknown as InvitationWizardState;
const flowDisplay =
wizardState.flowMode === "existing"
? wizardState.selectedFlowSlug
: wizardState.newFlowSlug;
return html`<form class="pf-c-form pf-m-horizontal">
<ak-form-element-horizontal label=${msg("Name")} required>
<input
type="text"
class="pf-c-form-control"
required
.value=${this.invitationName}
@input=${(ev: InputEvent) => {
const target = ev.target as HTMLInputElement;
this.invitationName = target.value.replace(/[^a-z0-9-]/g, "");
target.value = this.invitationName;
this.validate();
}}
/>
<p class="pf-c-form__helper-text">
${msg(
"The name of an invitation must be a slug: only lower case letters, numbers, and the hyphen are permitted here.",
)}
</p>
</ak-form-element-horizontal>
<ak-form-element-horizontal label=${msg("Expires")} required>
<input
type="datetime-local"
data-type="datetime-local"
class="pf-c-form-control"
required
.value=${this.invitationExpires}
@input=${(ev: InputEvent) => {
this.invitationExpires = (ev.target as HTMLInputElement).value;
this.validate();
}}
/>
</ak-form-element-horizontal>
<ak-form-element-horizontal label=${msg("Flow")}>
<input
type="text"
class="pf-c-form-control"
readonly
disabled
.value=${flowDisplay || ""}
/>
<p class="pf-c-form__helper-text">
${msg(
"The flow selected in the previous step. The invitation will be bound to this flow.",
)}
</p>
</ak-form-element-horizontal>
<ak-form-element-horizontal label=${msg("Custom attributes")}>
<ak-codemirror
mode="yaml"
.value=${this.fixedDataRaw}
@change=${(ev: CustomEvent) => {
this.fixedDataRaw = ev.detail.value;
this.validate();
}}
>
</ak-codemirror>
<p class="pf-c-form__helper-text">
${msg(
"Optional data which is loaded into the flow's 'prompt_data' context variable. YAML or JSON.",
)}
</p>
</ak-form-element-horizontal>
<ak-switch-input
label=${msg("Single use")}
?checked=${this.singleUse}
@change=${(ev: Event) => {
this.singleUse = (ev.target as HTMLInputElement).checked;
}}
help=${msg("When enabled, the invitation will be deleted after usage.")}
></ak-switch-input>
</form>`;
}
}
declare global {
interface HTMLElementTagNameMap {
"ak-invitation-wizard-details-step": InvitationWizardDetailsStep;
}
}
@@ -0,0 +1,217 @@
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 { SlottedTemplateResult } from "#elements/types";
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[] = [];
override formatNextLabel(): SlottedTemplateResult {
return html`${msg("Send")}
<span class="pf-c-button__icon pf-m-end">
<i class="fas fa-paper-plane" aria-hidden="true"></i>
</span>`;
}
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;
}
}
@@ -0,0 +1,347 @@
import "#components/ak-radio-input";
import "#components/ak-switch-input";
import "#elements/forms/HorizontalFormElement";
import "#elements/forms/SearchSelect/index";
import type { InvitationWizardState } from "./types";
import { DEFAULT_CONFIG } from "#common/api/config";
import { WizardPage } from "#elements/wizard/WizardPage";
import {
FlowDesignationEnum,
type FlowSet,
type InvitationStage,
StagesApi,
} from "@goauthentik/api";
import { msg } from "@lit/localize";
import { CSSResult, html, TemplateResult } from "lit";
import { customElement, property, state } from "lit/decorators.js";
import PFAlert from "@patternfly/patternfly/components/Alert/alert.css";
import PFButton from "@patternfly/patternfly/components/Button/button.css";
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";
interface EnrollmentFlow {
slug: string;
pk: string;
name: string;
}
@customElement("ak-invitation-wizard-flow-step")
export class InvitationWizardFlowStep extends WizardPage {
static styles: CSSResult[] = [PFBase, PFForm, PFFormControl, PFButton, PFAlert];
@property({ type: String })
public mode: "existing" | "create" = "existing";
@state()
enrollmentFlows: EnrollmentFlow[] = [];
@state()
loading = true;
@state()
selectedFlowSlug?: string;
@state()
selectedFlowPk?: string;
@state()
newFlowName = "Enrollment with invitation";
@state()
newFlowSlug = "enrollment-with-invitation";
@state()
newStageName = "invitation-stage";
@state()
newUserType: "external" | "internal" = "external";
@state()
continueFlowWithoutInvitation = true;
activeCallback = async (): Promise<void> => {
this.host.valid = false;
if (this.mode === "create") {
this.loading = false;
this.validate();
return;
}
this.loading = true;
try {
const stages = await new StagesApi(DEFAULT_CONFIG).stagesInvitationStagesList({
noFlows: false,
});
const flowMap = new Map<string, EnrollmentFlow>();
stages.results.forEach((stage: InvitationStage) => {
(stage.flowSet || [])
.filter((flow: FlowSet) => flow.designation === FlowDesignationEnum.Enrollment)
.forEach((flow: FlowSet) => {
if (!flowMap.has(flow.slug)) {
flowMap.set(flow.slug, {
slug: flow.slug,
pk: flow.pk,
name: flow.name,
});
}
});
});
this.enrollmentFlows = Array.from(flowMap.values());
if (this.enrollmentFlows.length > 0) {
this.selectedFlowSlug = this.enrollmentFlows[0].slug;
this.selectedFlowPk = this.enrollmentFlows[0].pk;
this.host.valid = true;
}
} catch {
this.enrollmentFlows = [];
}
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 {
if (this.mode === "existing") {
this.host.valid = !!this.selectedFlowSlug;
} else {
this.host.valid =
this.newFlowName.length > 0 &&
this.newFlowSlug.length > 0 &&
this.newStageName.length > 0;
}
}
nextCallback = async (): Promise<boolean> => {
const state = this.host.state as unknown as InvitationWizardState;
state.flowMode = this.mode;
if (this.mode === "existing") {
if (!this.selectedFlowSlug) return false;
state.selectedFlowSlug = this.selectedFlowSlug;
state.selectedFlowPk = this.selectedFlowPk;
state.needsFlow = false;
state.needsStage = false;
state.needsBinding = false;
} else {
if (!this.newFlowName || !this.newFlowSlug || !this.newStageName) return false;
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;
state.needsBinding = true;
}
return true;
};
override reset(): void {
this.enrollmentFlows = [];
this.loading = true;
this.selectedFlowSlug = undefined;
this.selectedFlowPk = undefined;
this.newFlowName = "Enrollment with invitation";
this.newFlowSlug = "enrollment-with-invitation";
this.newStageName = "invitation-stage";
this.newUserType = "external";
this.continueFlowWithoutInvitation = true;
}
slugify(value: string): string {
return value
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-|-$/g, "");
}
renderExistingFlowSelector(): TemplateResult {
if (this.enrollmentFlows.length === 0) {
return html`
<div class="pf-c-alert pf-m-warning pf-m-inline">
<div class="pf-c-alert__icon">
<i class="fas fa-exclamation-triangle" aria-hidden="true"></i>
</div>
<h4 class="pf-c-alert__title">
${msg("No enrollment flows with invitation stages found")}
</h4>
<div class="pf-c-alert__description">
<p>
${msg(
"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>
`;
}
return html`
<ak-form-element-horizontal label=${msg("Enrollment flow")} required>
<ak-search-select
.fetchObjects=${async (query?: string): Promise<EnrollmentFlow[]> => {
if (!query) return this.enrollmentFlows;
const needle = query.toLowerCase();
return this.enrollmentFlows.filter(
(flow) =>
flow.name.toLowerCase().includes(needle) ||
flow.slug.toLowerCase().includes(needle),
);
}}
.renderElement=${(flow: EnrollmentFlow): string => flow.name}
.renderDescription=${(flow: EnrollmentFlow): TemplateResult =>
html`${flow.slug}`}
.value=${(flow: EnrollmentFlow | undefined): string | undefined => flow?.pk}
.selected=${(flow: EnrollmentFlow): boolean => flow.pk === this.selectedFlowPk}
@ak-change=${(ev: CustomEvent<{ value: EnrollmentFlow | null }>) => {
const flow = ev.detail.value;
this.selectedFlowSlug = flow?.slug;
this.selectedFlowPk = flow?.pk;
this.validate();
}}
style="display: block; width: 100%;"
></ak-search-select>
<p class="pf-c-form__helper-text">
${msg(
"Only enrollment flows that have an invitation stage bound to them are listed here.",
)}
</p>
</ak-form-element-horizontal>
`;
}
renderCreateForm(): TemplateResult {
return html`
<ak-form-element-horizontal label=${msg("Flow name")} required>
<input
type="text"
class="pf-c-form-control"
required
.value=${this.newFlowName}
@input=${(ev: InputEvent) => {
const target = ev.target as HTMLInputElement;
this.newFlowName = target.value;
this.newFlowSlug = this.slugify(target.value);
this.validate();
}}
/>
<p class="pf-c-form__helper-text">${msg("Name for the new enrollment flow.")}</p>
</ak-form-element-horizontal>
<ak-form-element-horizontal label=${msg("Flow slug")} required>
<input
type="text"
class="pf-c-form-control"
required
.value=${this.newFlowSlug}
@input=${(ev: InputEvent) => {
const target = ev.target as HTMLInputElement;
this.newFlowSlug = target.value.replace(/[^a-z0-9-]/g, "");
this.validate();
}}
/>
<p class="pf-c-form__helper-text">${msg("Visible in the URL.")}</p>
</ak-form-element-horizontal>
<ak-form-element-horizontal label=${msg("Invitation stage name")} required>
<input
type="text"
class="pf-c-form-control"
required
.value=${this.newStageName}
@input=${(ev: InputEvent) => {
this.newStageName = (ev.target as HTMLInputElement).value;
this.validate();
}}
/>
<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}
@change=${(ev: Event) => {
this.continueFlowWithoutInvitation = (ev.target as HTMLInputElement).checked;
}}
help=${msg(
"If enabled, the stage will jump to the next stage when no invitation is given. If disabled, the flow will be cancelled without a valid invitation.",
)}
></ak-switch-input>
`;
}
render(): TemplateResult {
if (this.loading) {
return html`<div class="pf-c-form">
<p>${msg("Loading...")}</p>
</div>`;
}
return html`<form class="pf-c-form pf-m-horizontal">
${this.mode === "existing"
? this.renderExistingFlowSelector()
: this.renderCreateForm()}
</form>`;
}
}
declare global {
interface HTMLElementTagNameMap {
"ak-invitation-wizard-flow-step": InvitationWizardFlowStep;
}
}
@@ -0,0 +1,102 @@
import "#admin/stages/invitation/InvitationListLink";
import type { InvitationWizardState } from "./types";
import { AKRefreshEvent } from "#common/events";
import { MessageLevel } from "#common/messages";
import { showMessage } from "#elements/messages/MessageContainer";
import { WizardPage } from "#elements/wizard/WizardPage";
import { Invitation } from "@goauthentik/api";
import { msg } from "@lit/localize";
import { css, CSSResult, html, TemplateResult } from "lit";
import { customElement, state } from "lit/decorators.js";
import PFAlert from "@patternfly/patternfly/components/Alert/alert.css";
import PFForm from "@patternfly/patternfly/components/Form/form.css";
import PFBase from "@patternfly/patternfly/patternfly-base.css";
@customElement("ak-invitation-wizard-success-step")
export class InvitationWizardSuccessStep extends WizardPage {
static styles: CSSResult[] = [
PFBase,
PFForm,
PFAlert,
css`
:host {
display: block;
width: 100%;
}
ak-stage-invitation-list-link {
display: block;
width: 100%;
}
`,
];
@state()
invitation?: Invitation;
#notified = false;
activeCallback = async (): Promise<void> => {
const wizardState = this.host.state as unknown as InvitationWizardState;
this.invitation = wizardState.createdInvitation;
this.host.valid = true;
if (this.invitation && !this.#notified) {
showMessage({
level: MessageLevel.success,
message: msg("Successfully created invitation."),
});
this.#notified = true;
}
};
nextCallback = async (): Promise<boolean> => {
this.dispatchEvent(new AKRefreshEvent());
return true;
};
override reset(): void {
this.invitation = undefined;
this.#notified = false;
}
render(): TemplateResult {
const invitation = this.invitation;
if (!invitation) {
return html`<div class="pf-c-alert pf-m-warning pf-m-inline">
<div class="pf-c-alert__icon">
<i class="fas fa-exclamation-triangle" aria-hidden="true"></i>
</div>
<h4 class="pf-c-alert__title">${msg("No invitation was created.")}</h4>
</div>`;
}
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 {
interface HTMLElementTagNameMap {
"ak-invitation-wizard-success-step": InvitationWizardSuccessStep;
}
}
@@ -0,0 +1,31 @@
import type { Invitation } from "@goauthentik/api";
export interface InvitationWizardState {
// Step 1: Flow selection
flowMode: "existing" | "create";
selectedFlowSlug?: string;
selectedFlowPk?: string;
newFlowName?: string;
newFlowSlug?: string;
newStageName?: string;
newUserType?: "external" | "internal";
continueFlowWithoutInvitation: boolean;
// Flags for which API calls to make
needsFlow: boolean;
needsStage: boolean;
needsBinding: boolean;
// Step 2: Invitation details
invitationName?: string;
invitationExpires?: string;
invitationFixedData?: Record<string, unknown>;
invitationSingleUse: boolean;
// Results from API calls
createdStagePk?: string;
createdFlowPk?: string;
createdFlowSlug?: string;
createdInvitationPk?: string;
createdInvitation?: Invitation;
}
+28 -3
View File
@@ -182,8 +182,31 @@ export class AKWizard<S = Record<string, unknown>> extends AKElement {
/**
* Actions to display at the end of the wizard.
*/
private _actions: WizardAction[] = [];
@property({ attribute: false })
public actions: WizardAction[] = [];
public get actions(): WizardAction[] {
return this._actions;
}
public set actions(value: WizardAction[]) {
const oldValue = this._actions;
this._actions = value;
if (this._actions.length > 0) {
if (!this.querySelector(`[slot="ak-wizard-page-action"]`)) {
const actionPage = document.createElement("ak-wizard-page-action");
actionPage.slot = "ak-wizard-page-action";
actionPage.dataset.wizardmanaged = "true";
this.appendChild(actionPage);
}
if (!this.steps.includes("ak-wizard-page-action")) {
this.steps = [...this.steps, "ak-wizard-page-action"];
}
}
this.requestUpdate("actions", oldValue);
}
@property({ attribute: false })
public finalHandler?: () => Promise<void>;
@@ -530,12 +553,14 @@ export class AKWizard<S = Record<string, unknown>> extends AKElement {
return guard(
[activeStepIndex, lastPage, canBack, cancelable, valid, childElementCount],
() => {
const customLabel = this.activeStepElement?.formatNextLabel();
const nextLabel =
lastPage && activeStepIndex > 0
customLabel ??
(lastPage && activeStepIndex > 0
? this.cancelable
? ButtonKindLabelRecord.create()
: ButtonKindLabelRecord.finish()
: ButtonKindLabelRecord.next();
: ButtonKindLabelRecord.next());
return [
cancelable
+10
View File
@@ -70,6 +70,16 @@ export abstract class WizardPage<S = WizardPageState> extends AKElement {
return html`<div part="sidebar-label-headline">${this.headline ?? msg("UNNAMED")}</div>`;
}
/**
* Optional override for the wizard's next-button label while this page is active.
*
* Return `null` (the default) to keep the wizard's default labeling
* (Next/Finish/Create).
*/
public formatNextLabel(): SlottedTemplateResult | null {
return null;
}
/**
* Called when the `next` button on the wizard is pressed. For forms, results in the submission
* of the current form to the back-end before being allowed to proceed to the next page. This is