diff --git a/authentik/blueprints/api.py b/authentik/blueprints/api.py index 6a4148cc2d..764945f7b4 100644 --- a/authentik/blueprints/api.py +++ b/authentik/blueprints/api.py @@ -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) diff --git a/authentik/blueprints/tests/test_v1_api.py b/authentik/blueprints/tests/test_v1_api.py index f4e6d0fb0c..cf3d3aaa99 100644 --- a/authentik/blueprints/tests/test_v1_api.py +++ b/authentik/blueprints/tests/test_v1_api.py @@ -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()) diff --git a/blueprints/example/flows-invitation-enrollment-minimal.yaml b/blueprints/example/flows-invitation-enrollment-minimal.yaml new file mode 100644 index 0000000000..90a20a4b90 --- /dev/null +++ b/blueprints/example/flows-invitation-enrollment-minimal.yaml @@ -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 diff --git a/packages/client-ts/src/apis/ManagedApi.ts b/packages/client-ts/src/apis/ManagedApi.ts index 19b38df78e..7161ffd8ea 100644 --- a/packages/client-ts/src/apis/ManagedApi.ts +++ b/packages/client-ts/src/apis/ManagedApi.ts @@ -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 { diff --git a/schema.yml b/schema.yml index d5a9821dec..cf6551d619 100644 --- a/schema.yml +++ b/schema.yml @@ -36050,6 +36050,8 @@ components: path: type: string minLength: 1 + context: + type: string Brand: type: object description: Brand Serializer diff --git a/web/src/admin/stages/invitation/InvitationListLink.ts b/web/src/admin/stages/invitation/InvitationListLink.ts index 80b5b18c3b..48de202694 100644 --- a/web/src/admin/stages/invitation/InvitationListLink.ts +++ b/web/src/admin/stages/invitation/InvitationListLink.ts @@ -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()} /> @@ -122,18 +146,32 @@ export class InvitationListLink extends AKElement { > ${msg("Copy Link")} - - ${msg("Send")} - ${msg("Send Invitation via Email")} - - - - + ${this.inlineSendEmail + ? html`` + : html` + ${msg("Send")} + ${msg("Send Invitation via Email")} + + + + `} diff --git a/web/src/admin/stages/invitation/InvitationListPage.ts b/web/src/admin/stages/invitation/InvitationListPage.ts index d1046da4b8..d60288b2b3 100644 --- a/web/src/admin/stages/invitation/InvitationListPage.ts +++ b/web/src/admin/stages/invitation/InvitationListPage.ts @@ -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 { } protected override renderObjectCreate(): SlottedTemplateResult { - return ModalInvokerButton(InvitationForm); + return html`${this.renderNewInvitationDropdown()}`; + } + + protected renderNewInvitationDropdown(): TemplateResult { + return html` +
+ + +
+ +
`; } protected override render(): SlottedTemplateResult { diff --git a/web/src/admin/stages/invitation/wizard/InvitationWizard.ts b/web/src/admin/stages/invitation/wizard/InvitationWizard.ts new file mode 100644 index 0000000000..1757921ad8 --- /dev/null +++ b/web/src/admin/stages/invitation/wizard/InvitationWizard.ts @@ -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` + + + + + `; + } +} + +declare global { + interface HTMLElementTagNameMap { + "ak-invitation-wizard": InvitationWizard; + } +} diff --git a/web/src/admin/stages/invitation/wizard/InvitationWizardDetailsStep.ts b/web/src/admin/stages/invitation/wizard/InvitationWizardDetailsStep.ts new file mode 100644 index 0000000000..8ff43cdf4a --- /dev/null +++ b/web/src/admin/stages/invitation/wizard/InvitationWizardDetailsStep.ts @@ -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 => { + this.host.valid = this.invitationName.length > 0; + }; + + async #fail(step: string, err: unknown): Promise { + 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 => { + if (!this.invitationName) return false; + + let fixedData: Record = {}; + 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`
+ + { + const target = ev.target as HTMLInputElement; + this.invitationName = target.value.replace(/[^a-z0-9-]/g, ""); + target.value = this.invitationName; + this.validate(); + }} + /> +

+ ${msg( + "The name of an invitation must be a slug: only lower case letters, numbers, and the hyphen are permitted here.", + )} +

+
+ + { + this.invitationExpires = (ev.target as HTMLInputElement).value; + this.validate(); + }} + /> + + + +

+ ${msg( + "The flow selected in the previous step. The invitation will be bound to this flow.", + )} +

+
+ + { + this.fixedDataRaw = ev.detail.value; + this.validate(); + }} + > + +

+ ${msg( + "Optional data which is loaded into the flow's 'prompt_data' context variable. YAML or JSON.", + )} +

+
+ { + this.singleUse = (ev.target as HTMLInputElement).checked; + }} + help=${msg("When enabled, the invitation will be deleted after usage.")} + > +
`; + } +} + +declare global { + interface HTMLElementTagNameMap { + "ak-invitation-wizard-details-step": InvitationWizardDetailsStep; + } +} diff --git a/web/src/admin/stages/invitation/wizard/InvitationWizardEmailStep.ts b/web/src/admin/stages/invitation/wizard/InvitationWizardEmailStep.ts new file mode 100644 index 0000000000..c73c164c63 --- /dev/null +++ b/web/src/admin/stages/invitation/wizard/InvitationWizardEmailStep.ts @@ -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")} + + + `; + } + + activeCallback = async (): Promise => { + 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 => { + 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`
+ + +

+ ${msg( + "One email address per line, or comma/semicolon separated. Each recipient will receive a separate email with an invitation link.", + )} +

+
+ + +

+ ${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.", + )} +

+
+ + +

+ ${msg( + "A comma-separated list of addresses to receive copies of the invitation. Recipients will not receive the addresses of other recipients.", + )} +

+
+ + +

+ ${msg("Select the email template to use for sending invitations.")} +

+
+
`; + } +} + +declare global { + interface HTMLElementTagNameMap { + "ak-invitation-wizard-email-step": InvitationWizardEmailStep; + } +} diff --git a/web/src/admin/stages/invitation/wizard/InvitationWizardFlowStep.ts b/web/src/admin/stages/invitation/wizard/InvitationWizardFlowStep.ts new file mode 100644 index 0000000000..05900faee6 --- /dev/null +++ b/web/src/admin/stages/invitation/wizard/InvitationWizardFlowStep.ts @@ -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 => { + 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(); + + 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 => { + 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` +
+
+ +
+

+ ${msg("No enrollment flows with invitation stages found")} +

+
+

+ ${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.", + )} +

+ +
+
+ `; + } + + return html` + + => { + 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%;" + > +

+ ${msg( + "Only enrollment flows that have an invitation stage bound to them are listed here.", + )} +

+
+ `; + } + + renderCreateForm(): TemplateResult { + return html` + + { + const target = ev.target as HTMLInputElement; + this.newFlowName = target.value; + this.newFlowSlug = this.slugify(target.value); + this.validate(); + }} + /> +

${msg("Name for the new enrollment flow.")}

+
+ + { + const target = ev.target as HTMLInputElement; + this.newFlowSlug = target.value.replace(/[^a-z0-9-]/g, ""); + this.validate(); + }} + /> +

${msg("Visible in the URL.")}

+
+ + { + this.newStageName = (ev.target as HTMLInputElement).value; + this.validate(); + }} + /> +

${msg("Name for the new invitation stage.")}

+
+ ) => { + this.newUserType = ev.detail.value; + }} + > + { + 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.", + )} + > + `; + } + + render(): TemplateResult { + if (this.loading) { + return html`
+

${msg("Loading...")}

+
`; + } + + return html`
+ ${this.mode === "existing" + ? this.renderExistingFlowSelector() + : this.renderCreateForm()} +
`; + } +} + +declare global { + interface HTMLElementTagNameMap { + "ak-invitation-wizard-flow-step": InvitationWizardFlowStep; + } +} diff --git a/web/src/admin/stages/invitation/wizard/InvitationWizardSuccessStep.ts b/web/src/admin/stages/invitation/wizard/InvitationWizardSuccessStep.ts new file mode 100644 index 0000000000..16bb2af37e --- /dev/null +++ b/web/src/admin/stages/invitation/wizard/InvitationWizardSuccessStep.ts @@ -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 => { + 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 => { + 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`
+
+ +
+

${msg("No invitation was created.")}

+
`; + } + + return html` + + `; + } + + onSendViaEmail = async (): Promise => { + 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; + } +} diff --git a/web/src/admin/stages/invitation/wizard/types.ts b/web/src/admin/stages/invitation/wizard/types.ts new file mode 100644 index 0000000000..cc82305341 --- /dev/null +++ b/web/src/admin/stages/invitation/wizard/types.ts @@ -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; + invitationSingleUse: boolean; + + // Results from API calls + createdStagePk?: string; + createdFlowPk?: string; + createdFlowSlug?: string; + createdInvitationPk?: string; + createdInvitation?: Invitation; +} diff --git a/web/src/elements/wizard/Wizard.ts b/web/src/elements/wizard/Wizard.ts index 7bc6851541..e92cab28ed 100644 --- a/web/src/elements/wizard/Wizard.ts +++ b/web/src/elements/wizard/Wizard.ts @@ -182,8 +182,31 @@ export class AKWizard> 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; @@ -530,12 +553,14 @@ export class AKWizard> 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 diff --git a/web/src/elements/wizard/WizardPage.ts b/web/src/elements/wizard/WizardPage.ts index eb8f147a3c..9a0b34d83f 100644 --- a/web/src/elements/wizard/WizardPage.ts +++ b/web/src/elements/wizard/WizardPage.ts @@ -70,6 +70,16 @@ export abstract class WizardPage extends AKElement { return html`
${this.headline ?? msg("UNNAMED")}
`; } + /** + * 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