mirror of
https://github.com/goauthentik/authentik.git
synced 2026-06-17 19:09:11 +03:00
stages/invitation: Invitation wizard (#20399)
This commit is contained in:
committed by
GitHub
parent
befc15ad92
commit
a8db2882ec
@@ -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)
|
||||
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user