mirror of
https://github.com/goauthentik/authentik
synced 2026-05-06 15:12:13 +02:00
Compare commits
15 Commits
rust-proxy
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9aabe91119 | ||
|
|
77fae18259 | ||
|
|
f6ef4d5479 | ||
|
|
b3ac4f9c4e | ||
|
|
86658f6f03 | ||
|
|
548ab05628 | ||
|
|
459fa8e219 | ||
|
|
e40187179d | ||
|
|
f6024a23ef | ||
|
|
a8db2882ec | ||
|
|
befc15ad92 | ||
|
|
2b48c27760 | ||
|
|
6be7b2f7b7 | ||
|
|
7cffbb4d07 | ||
|
|
5d629bec9b |
2
.github/actions/setup/action.yml
vendored
2
.github/actions/setup/action.yml
vendored
@@ -64,7 +64,7 @@ runs:
|
||||
rustflags: ""
|
||||
- name: Setup rust dependencies
|
||||
if: ${{ contains(inputs.dependencies, 'rust') }}
|
||||
uses: taiki-e/install-action@b5fddbb5361bce8a06fb168c9d403a6cc552b084 # v2
|
||||
uses: taiki-e/install-action@db5fb34fa772531a3ece57ca434f579eb334e0fb # v2
|
||||
with:
|
||||
tool: cargo-deny cargo-machete cargo-llvm-cov nextest
|
||||
- name: Setup node (web)
|
||||
|
||||
110
Cargo.lock
generated
110
Cargo.lock
generated
@@ -176,12 +176,10 @@ dependencies = [
|
||||
"arc-swap",
|
||||
"argh",
|
||||
"authentik-axum",
|
||||
"authentik-client",
|
||||
"authentik-common",
|
||||
"axum",
|
||||
"color-eyre",
|
||||
"eyre",
|
||||
"futures",
|
||||
"hyper-unix-socket",
|
||||
"hyper-util",
|
||||
"metrics",
|
||||
@@ -189,18 +187,9 @@ dependencies = [
|
||||
"nix 0.31.2",
|
||||
"pyo3",
|
||||
"pyo3-build-config",
|
||||
"rand 0.10.1",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"serde_repr",
|
||||
"sqlx",
|
||||
"time",
|
||||
"tokio",
|
||||
"tokio-retry2",
|
||||
"tokio-tungstenite",
|
||||
"tower",
|
||||
"tracing",
|
||||
"url",
|
||||
"uuid",
|
||||
"which",
|
||||
]
|
||||
@@ -553,17 +542,6 @@ version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724"
|
||||
|
||||
[[package]]
|
||||
name = "chacha20"
|
||||
version = "0.10.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6f8d983286843e49675a4b7a2d174efe136dc93a18d69130dd18198a6c167601"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"cpufeatures 0.3.0",
|
||||
"rand_core 0.10.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "chrono"
|
||||
version = "0.4.44"
|
||||
@@ -801,15 +779,6 @@ dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cpufeatures"
|
||||
version = "0.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201"
|
||||
dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crc"
|
||||
version = "3.4.0"
|
||||
@@ -1322,7 +1291,6 @@ dependencies = [
|
||||
"cfg-if",
|
||||
"libc",
|
||||
"r-efi 6.0.0",
|
||||
"rand_core 0.10.1",
|
||||
"wasip2",
|
||||
"wasip3",
|
||||
]
|
||||
@@ -2089,7 +2057,7 @@ dependencies = [
|
||||
"hashbrown 0.16.1",
|
||||
"metrics",
|
||||
"quanta",
|
||||
"rand 0.9.2",
|
||||
"rand 0.9.4",
|
||||
"rand_xoshiro",
|
||||
"sketches-ddsketch",
|
||||
]
|
||||
@@ -2776,7 +2744,7 @@ dependencies = [
|
||||
"bytes",
|
||||
"getrandom 0.3.4",
|
||||
"lru-slab",
|
||||
"rand 0.9.2",
|
||||
"rand 0.9.4",
|
||||
"ring",
|
||||
"rustc-hash",
|
||||
"rustls",
|
||||
@@ -2836,25 +2804,14 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "rand"
|
||||
version = "0.9.2"
|
||||
version = "0.9.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1"
|
||||
checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea"
|
||||
dependencies = [
|
||||
"rand_chacha 0.9.0",
|
||||
"rand_core 0.9.5",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rand"
|
||||
version = "0.10.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d2e8e8bcc7961af1fdac401278c6a831614941f6164ee3bf4ce61b7edb162207"
|
||||
dependencies = [
|
||||
"chacha20",
|
||||
"getrandom 0.4.2",
|
||||
"rand_core 0.10.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rand_chacha"
|
||||
version = "0.3.1"
|
||||
@@ -2893,12 +2850,6 @@ dependencies = [
|
||||
"getrandom 0.3.4",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rand_core"
|
||||
version = "0.10.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69"
|
||||
|
||||
[[package]]
|
||||
name = "rand_xoshiro"
|
||||
version = "0.7.0"
|
||||
@@ -3252,9 +3203,9 @@ checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2"
|
||||
|
||||
[[package]]
|
||||
name = "sentry"
|
||||
version = "0.47.0"
|
||||
version = "0.48.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "eb25f439f97d26fea01d717fa626167ceffcd981addaa670001e70505b72acbb"
|
||||
checksum = "e8ac94aab850a23d7507307cc505332ed2bafd36c65930dfc5c43610f9e9b477"
|
||||
dependencies = [
|
||||
"cfg_aliases",
|
||||
"httpdate",
|
||||
@@ -3273,9 +3224,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "sentry-backtrace"
|
||||
version = "0.47.0"
|
||||
version = "0.48.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "46a8c2c1bd5c1f735e84f28b48e7d72efcaafc362b7541bc8253e60e8fcdffc6"
|
||||
checksum = "dc84c325ace9ca2388e510fe7d6672b5d60cd8b3bd0eb4bb4ee8314c323cd686"
|
||||
dependencies = [
|
||||
"backtrace",
|
||||
"regex",
|
||||
@@ -3284,9 +3235,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "sentry-contexts"
|
||||
version = "0.47.0"
|
||||
version = "0.48.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9b88a90baa654d7f0e1f4b667f6b434293d9f72c71bef16b197c76af5b7d5803"
|
||||
checksum = "896c1ab62dbfe1746fb262bbf72e6feb2fb9dfb2c14709077bf71beb532e44b2"
|
||||
dependencies = [
|
||||
"hostname",
|
||||
"libc",
|
||||
@@ -3298,11 +3249,11 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "sentry-core"
|
||||
version = "0.47.0"
|
||||
version = "0.48.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0ac170a5bba8bec6e3339c90432569d89641fa7a3d3e4f44987d24f0762e6adf"
|
||||
checksum = "d5f5abf20c42cb1593ec1638976e2647da55f79bccac956444c1707b6cce259a"
|
||||
dependencies = [
|
||||
"rand 0.9.2",
|
||||
"rand 0.9.4",
|
||||
"sentry-types",
|
||||
"serde",
|
||||
"serde_json",
|
||||
@@ -3311,9 +3262,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "sentry-debug-images"
|
||||
version = "0.47.0"
|
||||
version = "0.48.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "dd9646a972b57896d4a92ed200cf76139f8e30b3cfd03b6662ae59926d26633c"
|
||||
checksum = "4b88bbe6a760d5724bb40689827e82e8db1e275947df2c59abe171bfc30bb671"
|
||||
dependencies = [
|
||||
"findshlibs",
|
||||
"sentry-core",
|
||||
@@ -3321,9 +3272,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "sentry-panic"
|
||||
version = "0.47.0"
|
||||
version = "0.48.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6127d3d304ba5ce0409401e85aae538e303a569f8dbb031bf64f9ba0f7174346"
|
||||
checksum = "0260dcb52562b6a79ae7702312a26dba94b79fb5baee7301087529e5ca4e872e"
|
||||
dependencies = [
|
||||
"sentry-backtrace",
|
||||
"sentry-core",
|
||||
@@ -3331,9 +3282,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "sentry-tower"
|
||||
version = "0.47.0"
|
||||
version = "0.48.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "61c5253dc4ad89863a866b93aeaaac1c9d60f2f774663b5024afe2d57e0a101c"
|
||||
checksum = "d669616d5d5279b5712febfc80c343acc3695e499de0d101ed70fceacadf37f2"
|
||||
dependencies = [
|
||||
"sentry-core",
|
||||
"tower-layer",
|
||||
@@ -3342,9 +3293,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "sentry-tracing"
|
||||
version = "0.47.0"
|
||||
version = "0.48.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "27701acc51e68db5281802b709010395bfcbcb128b1d0a4e5873680d3b47ff0c"
|
||||
checksum = "a1c035f3a0a8671ae1a231c5b457abb68b71acba2bf3054dab2a09a9d4ea487e"
|
||||
dependencies = [
|
||||
"bitflags 2.11.0",
|
||||
"sentry-backtrace",
|
||||
@@ -3355,13 +3306,13 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "sentry-types"
|
||||
version = "0.47.0"
|
||||
version = "0.48.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "56780cb5597d676bf22e6c11d1f062eb4def46390ea3bfb047bcbcf7dfd19bdb"
|
||||
checksum = "82d8e81058ec155992191f61c7b29bfa7b2cf12012131e7cdc0678020898a7c9"
|
||||
dependencies = [
|
||||
"debugid",
|
||||
"hex",
|
||||
"rand 0.9.2",
|
||||
"rand 0.9.4",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"thiserror 2.0.18",
|
||||
@@ -3468,7 +3419,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"cpufeatures 0.2.17",
|
||||
"cpufeatures",
|
||||
"digest",
|
||||
]
|
||||
|
||||
@@ -3479,7 +3430,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"cpufeatures 0.2.17",
|
||||
"cpufeatures",
|
||||
"digest",
|
||||
]
|
||||
|
||||
@@ -4049,12 +4000,8 @@ checksum = "8f72a05e828585856dacd553fba484c242c46e391fb0e58917c942ee9202915c"
|
||||
dependencies = [
|
||||
"futures-util",
|
||||
"log",
|
||||
"rustls",
|
||||
"rustls-pki-types",
|
||||
"tokio",
|
||||
"tokio-rustls",
|
||||
"tungstenite",
|
||||
"webpki-roots 0.26.11",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -4267,12 +4214,9 @@ dependencies = [
|
||||
"http",
|
||||
"httparse",
|
||||
"log",
|
||||
"rand 0.9.2",
|
||||
"rustls",
|
||||
"rustls-pki-types",
|
||||
"rand 0.9.4",
|
||||
"sha1",
|
||||
"thiserror 2.0.18",
|
||||
"url",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
22
Cargo.toml
22
Cargo.toml
@@ -50,7 +50,6 @@ notify = "= 8.2.0"
|
||||
pin-project-lite = "= 0.2.17"
|
||||
pyo3 = "= 0.28.3"
|
||||
pyo3-build-config = "= 0.28.3"
|
||||
rand = "= 0.10.1"
|
||||
regex = "= 1.12.3"
|
||||
reqwest = { version = "= 0.13.3", features = [
|
||||
"form",
|
||||
@@ -68,7 +67,7 @@ reqwest-middleware = { version = "= 0.5.1", features = [
|
||||
"rustls",
|
||||
] }
|
||||
rustls = { version = "= 0.23.40", features = ["fips"] }
|
||||
sentry = { version = "= 0.47.0", default-features = false, features = [
|
||||
sentry = { version = "= 0.48.0", default-features = false, features = [
|
||||
"backtrace",
|
||||
"contexts",
|
||||
"debug-images",
|
||||
@@ -101,10 +100,6 @@ time = { version = "= 0.3.47", features = ["macros"] }
|
||||
tokio = { version = "= 1.52.1", features = ["full", "tracing"] }
|
||||
tokio-retry2 = "= 0.9.1"
|
||||
tokio-rustls = "= 0.26.4"
|
||||
tokio-tungstenite = { version = "= 0.29.0", features = [
|
||||
"rustls-tls-webpki-roots",
|
||||
"url",
|
||||
] }
|
||||
tokio-util = { version = "= 0.7.18", features = ["full"] }
|
||||
tower = "= 0.5.3"
|
||||
tower-http = { version = "= 0.6.8", features = ["timeout"] }
|
||||
@@ -265,39 +260,28 @@ publish.workspace = true
|
||||
[features]
|
||||
default = ["core", "proxy"]
|
||||
core = ["ak-common/core", "dep:pyo3", "dep:sqlx"]
|
||||
proxy = ["ak-common/proxy", "dep:ak-client"]
|
||||
proxy = ["ak-common/proxy"]
|
||||
|
||||
[build-dependencies]
|
||||
pyo3-build-config.workspace = true
|
||||
|
||||
[dependencies]
|
||||
ak-axum.workspace = true
|
||||
ak-client = { workspace = true, optional = true }
|
||||
ak-common.workspace = true
|
||||
arc-swap.workspace = true
|
||||
argh.workspace = true
|
||||
axum.workspace = true
|
||||
color-eyre.workspace = true
|
||||
eyre.workspace = true
|
||||
futures.workspace = true
|
||||
hyper-unix-socket.workspace = true
|
||||
hyper-util.workspace = true
|
||||
metrics-exporter-prometheus.workspace = true
|
||||
metrics.workspace = true
|
||||
metrics-exporter-prometheus.workspace = true
|
||||
nix.workspace = true
|
||||
pyo3 = { workspace = true, optional = true }
|
||||
rand.workspace = true
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
serde_repr.workspace = true
|
||||
sqlx = { workspace = true, optional = true }
|
||||
time.workspace = true
|
||||
tokio-retry2.workspace = true
|
||||
tokio-tungstenite.workspace = true
|
||||
tokio.workspace = true
|
||||
tower.workspace = true
|
||||
tracing.workspace = true
|
||||
url.workspace = true
|
||||
uuid.workspace = true
|
||||
which.workspace = true
|
||||
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -29,6 +29,7 @@ class RefreshOtherFlowsAfterAuthentication(Flag[bool], key="flows_refresh_others
|
||||
default = False
|
||||
visibility = "public"
|
||||
description = _("Refresh other tabs after successful authentication.")
|
||||
deprecated = True
|
||||
|
||||
|
||||
class ContinuousLogin(Flag[bool], key="flows_continuous_login"):
|
||||
|
||||
@@ -59,6 +59,8 @@ class FlagsJSONExtension(OpenApiSerializerFieldExtension):
|
||||
props[_flag.key] = build_basic_type(get_args(_flag.__orig_bases__[0])[0])
|
||||
if _flag.description:
|
||||
props[_flag.key]["description"] = _flag.description
|
||||
if _flag.deprecated:
|
||||
props[_flag.key]["deprecated"] = _flag.deprecated
|
||||
return build_object_type(props, required=props.keys())
|
||||
|
||||
|
||||
|
||||
@@ -18,6 +18,7 @@ class Flag[T]:
|
||||
Literal["none"] | Literal["public"] | Literal["authenticated"] | Literal["system"]
|
||||
) = "none"
|
||||
description: str | None = None
|
||||
deprecated = False
|
||||
|
||||
def __init_subclass__(cls, key: str, **kwargs):
|
||||
cls.__key = key
|
||||
|
||||
211
blueprints/example/flows-invitation-enrollment-minimal.yaml
Normal file
211
blueprints/example/flows-invitation-enrollment-minimal.yaml
Normal file
@@ -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
|
||||
@@ -101,6 +101,8 @@ RUN --mount=type=bind,target=rust-toolchain.toml,src=rust-toolchain.toml \
|
||||
rustc --version && \
|
||||
cargo --version
|
||||
|
||||
RUN cat /root/.rustup/settings.toml
|
||||
|
||||
# Stage: Download uv
|
||||
FROM ghcr.io/astral-sh/uv:0.11.5@sha256:555ac94f9a22e656fc5f2ce5dfee13b04e94d099e46bb8dd3a73ec7263f2e484 AS uv
|
||||
# Stage: Base python image
|
||||
|
||||
@@ -21,45 +21,33 @@ COPY web .
|
||||
RUN npm run build-proxy
|
||||
|
||||
# Stage 2: Build
|
||||
FROM ghcr.io/goauthentik/fips-debian:trixie-slim-fips@sha256:7726387c78b5787d2146868c2ccc8948a3591d0a5a6436f7780c8c28acc76341 AS builder
|
||||
FROM --platform=${BUILDPLATFORM} docker.io/library/golang:1.26.2-trixie@sha256:4a7137ea573f79c86ae451ff05817ed762ef5597fcf732259e97abeb3108d873 AS builder
|
||||
|
||||
ARG TARGETOS
|
||||
ARG TARGETARCH
|
||||
ARG TARGETVARIANT
|
||||
|
||||
ENV PATH="/root/.cargo/bin:$PATH"
|
||||
SHELL ["/bin/sh", "-o", "pipefail", "-c"]
|
||||
RUN rm -f /etc/apt/apt.conf.d/docker-clean; echo 'Binary::apt::APT::Keep-Downloaded-Packages "true";' > /etc/apt/apt.conf.d/keep-cache
|
||||
RUN --mount=type=cache,id=apt-$TARGETARCH$TARGETVARIANT,sharing=locked,target=/var/cache/apt \
|
||||
--mount=type=bind,target=rust-toolchain.toml,src=rust-toolchain.toml \
|
||||
apt-get update && \
|
||||
# Required for installing pip packages
|
||||
apt-get install -y --no-install-recommends \
|
||||
# Build essentials
|
||||
build-essential \
|
||||
# aws-lc deps
|
||||
cmake clang golang && \
|
||||
curl https://sh.rustup.rs -sSf | sh -s -- -y --profile minimal --default-toolchain none && \
|
||||
rustup install && \
|
||||
rustup default "$(sed -n 's/channel = "\(.*\)"/\1/p' rust-toolchain.toml)" && \
|
||||
rustc --version && \
|
||||
cargo --version
|
||||
# See https://github.com/aws/aws-lc-rs/issues/569
|
||||
ENV AWS_LC_FIPS_SYS_CC=clang
|
||||
ARG GOOS=$TARGETOS
|
||||
ARG GOARCH=$TARGETARCH
|
||||
|
||||
RUN --mount=type=bind,target=rust-toolchain.toml,src=rust-toolchain.toml \
|
||||
--mount=type=bind,target=Cargo.toml,src=Cargo.toml \
|
||||
--mount=type=bind,target=Cargo.lock,src=Cargo.lock \
|
||||
--mount=type=bind,target=.cargo/,src=.cargo/ \
|
||||
--mount=type=bind,target=src/,src=src/ \
|
||||
--mount=type=bind,target=packages/,src=packages/ \
|
||||
--mount=type=bind,target=authentik/lib/default.yml,src=authentik/lib/default.yml \
|
||||
# Required otherwise workspace discovery fails
|
||||
--mount=type=bind,target=website/scripts/docsmg/,src=website/scripts/docsmg/ \
|
||||
--mount=type=cache,id=cargo-git-db-$TARGETARCH$TARGETVARIANT,target=/root/.cargo/git/db/ \
|
||||
--mount=type=cache,id=cargo-registry-$TARGETARCH$TARGETVARIANT,target=/root/.cargo/registry/ \
|
||||
--mount=type=cache,id=rust-target-$TARGETARCH$TARGETVARIANT,target=/build/target/ \
|
||||
cargo build --package authentik --no-default-features --features proxy --locked --release && \
|
||||
cp ./target/release/authentik /bin/authentik
|
||||
WORKDIR /go/src/goauthentik.io
|
||||
|
||||
RUN --mount=type=cache,id=apt-$TARGETARCH$TARGETVARIANT,sharing=locked,target=/var/cache/apt \
|
||||
dpkg --add-architecture arm64 && \
|
||||
apt-get update && \
|
||||
apt-get install -y --no-install-recommends crossbuild-essential-arm64 gcc-aarch64-linux-gnu
|
||||
|
||||
RUN --mount=type=bind,target=/go/src/goauthentik.io/go.mod,src=./go.mod \
|
||||
--mount=type=bind,target=/go/src/goauthentik.io/go.sum,src=./go.sum \
|
||||
--mount=type=cache,target=/go/pkg/mod \
|
||||
go mod download
|
||||
|
||||
COPY . .
|
||||
RUN --mount=type=cache,sharing=locked,target=/go/pkg/mod \
|
||||
--mount=type=cache,id=go-build-$TARGETARCH$TARGETVARIANT,sharing=locked,target=/root/.cache/go-build \
|
||||
if [ "$TARGETARCH" = "arm64" ]; then export CC=aarch64-linux-gnu-gcc && export CC_FOR_TARGET=gcc-aarch64-linux-gnu; fi && \
|
||||
CGO_ENABLED=1 GOFIPS140=latest GOARM="${TARGETVARIANT#v}" \
|
||||
go build -o /go/proxy ./cmd/proxy
|
||||
|
||||
# Stage 3: Run
|
||||
FROM ghcr.io/goauthentik/fips-debian:trixie-slim-fips@sha256:7726387c78b5787d2146868c2ccc8948a3591d0a5a6436f7780c8c28acc76341
|
||||
@@ -84,13 +72,13 @@ RUN apt-get update && \
|
||||
apt-get clean && \
|
||||
rm -rf /tmp/* /var/lib/apt/lists/*
|
||||
|
||||
COPY --from=builder /bin/authentik /
|
||||
COPY --from=builder /go/proxy /
|
||||
COPY --from=web-builder /static/robots.txt /web/robots.txt
|
||||
COPY --from=web-builder /static/security.txt /web/security.txt
|
||||
COPY --from=web-builder /static/dist/ /web/dist/
|
||||
COPY --from=web-builder /static/authentik/ /web/authentik/
|
||||
|
||||
HEALTHCHECK --interval=5s --retries=20 --start-period=3s CMD [ "/authentik", "healthcheck" ]
|
||||
HEALTHCHECK --interval=5s --retries=20 --start-period=3s CMD [ "/proxy", "healthcheck" ]
|
||||
|
||||
EXPOSE 9000 9300 9443
|
||||
|
||||
@@ -99,4 +87,4 @@ USER 1000
|
||||
ENV TMPDIR=/dev/shm/ \
|
||||
GOFIPS=1
|
||||
|
||||
ENTRYPOINT ["/authentik", "proxy"]
|
||||
ENTRYPOINT ["/proxy"]
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
//! Utilities for working with the authentik API client.
|
||||
|
||||
use ak_client::{apis::configuration::Configuration, models::Pagination};
|
||||
use ak_client::apis::configuration::Configuration;
|
||||
use eyre::{Result, eyre};
|
||||
use url::Url;
|
||||
|
||||
@@ -60,42 +60,6 @@ pub fn make_config() -> Result<Configuration> {
|
||||
})
|
||||
}
|
||||
|
||||
/// Fetch all pages from a paginated API endpoint, returning all results combined.
|
||||
///
|
||||
/// - `fetch`: a function that takes a page number and returns a future resolving to a paginated
|
||||
/// response.
|
||||
/// - `get_pagination`: a function that extracts the [`Pagination`] metadata from a response.
|
||||
/// - `get_results`: a function that extracts the result items from a response.
|
||||
pub async fn fetch_all<T, R, E, F, Fut>(
|
||||
fetch: F,
|
||||
get_pagination: impl Fn(&R) -> &Pagination,
|
||||
get_results: impl Fn(R) -> Vec<T>,
|
||||
) -> std::result::Result<Vec<T>, E>
|
||||
where
|
||||
F: Fn(i32) -> Fut,
|
||||
Fut: Future<Output = std::result::Result<R, E>>,
|
||||
{
|
||||
let mut page = 1;
|
||||
let mut results = Vec::with_capacity(0);
|
||||
|
||||
loop {
|
||||
let response = fetch(page).await?;
|
||||
let next = get_pagination(&response).next;
|
||||
if page == 1 {
|
||||
let count = get_pagination(&response).count as usize;
|
||||
results.reserve(count);
|
||||
}
|
||||
results.extend(get_results(response));
|
||||
if next > 0.0 {
|
||||
page += 1;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(results)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use serde_json::json;
|
||||
|
||||
@@ -30,12 +30,12 @@ pub fn install() -> Result<()> {
|
||||
}
|
||||
|
||||
if config.debug {
|
||||
// let console_layer = console_subscriber::ConsoleLayer::builder()
|
||||
// .server_addr(config.listen.debug_tokio)
|
||||
// .spawn();
|
||||
let console_layer = console_subscriber::ConsoleLayer::builder()
|
||||
.server_addr(config.listen.debug_tokio)
|
||||
.spawn();
|
||||
tracing_subscriber::registry()
|
||||
.with(ErrorLayer::default())
|
||||
// .with(console_layer)
|
||||
.with(console_layer)
|
||||
.with(
|
||||
fmt::layer()
|
||||
.compact()
|
||||
@@ -187,10 +187,13 @@ pub mod sentry {
|
||||
environment: config.environment,
|
||||
send_pii: config.send_pii,
|
||||
#[expect(
|
||||
clippy::as_conversions,
|
||||
clippy::cast_possible_truncation,
|
||||
reason = "This is fine, we'll never get big values here."
|
||||
)]
|
||||
#[expect(
|
||||
clippy::as_conversions,
|
||||
reason = "This is fine, we'll never get big values here."
|
||||
)]
|
||||
sample_rate: config.traces_sample_rate as f32,
|
||||
})
|
||||
}
|
||||
|
||||
3
packages/client-ts/package.json
generated
3
packages/client-ts/package.json
generated
@@ -8,7 +8,8 @@
|
||||
"url": "https://github.com/goauthentik/authentik.git"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "tsc && tsc -p tsconfig.esm.json",
|
||||
"clean": "tsc -b --clean tsconfig.json tsconfig.esm.json",
|
||||
"build": "npm run clean && tsc -b tsconfig.json tsconfig.esm.json",
|
||||
"prepare": "npm run build"
|
||||
},
|
||||
"main": "./dist/index.js",
|
||||
|
||||
5
packages/client-ts/src/apis/ManagedApi.ts
generated
5
packages/client-ts/src/apis/ManagedApi.ts
generated
@@ -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 {
|
||||
|
||||
@@ -40,6 +40,7 @@ export interface CurrentBrandFlags {
|
||||
* Refresh other tabs after successful authentication.
|
||||
* @type {boolean}
|
||||
* @memberof CurrentBrandFlags
|
||||
* @deprecated
|
||||
*/
|
||||
flowsRefreshOthers: boolean;
|
||||
}
|
||||
|
||||
@@ -40,6 +40,7 @@ export interface PatchedSettingsRequestFlags {
|
||||
* Refresh other tabs after successful authentication.
|
||||
* @type {boolean}
|
||||
* @memberof PatchedSettingsRequestFlags
|
||||
* @deprecated
|
||||
*/
|
||||
flowsRefreshOthers: boolean;
|
||||
}
|
||||
|
||||
2
packages/client-ts/templates/tsconfig.mustache
generated
2
packages/client-ts/templates/tsconfig.mustache
generated
@@ -1,9 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/tsconfig",
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"isolatedModules": true,
|
||||
"incremental": true,
|
||||
"rootDir": "src",
|
||||
"strict": true,
|
||||
"newLine": "lf",
|
||||
|
||||
2
packages/client-ts/tsconfig.json
generated
2
packages/client-ts/tsconfig.json
generated
@@ -1,9 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/tsconfig",
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"isolatedModules": true,
|
||||
"incremental": true,
|
||||
"rootDir": "src",
|
||||
"strict": true,
|
||||
"newLine": "lf",
|
||||
|
||||
@@ -25,7 +25,7 @@ dependencies = [
|
||||
"django-prometheus==2.4.1",
|
||||
"django-storages[s3]==1.14.6",
|
||||
"django-tenants==3.10.1",
|
||||
"django==5.2.13",
|
||||
"django==5.2.14",
|
||||
"djangoql==0.19.1",
|
||||
"djangorestframework==3.17.1",
|
||||
"docker==7.1.0",
|
||||
|
||||
@@ -36050,6 +36050,8 @@ components:
|
||||
path:
|
||||
type: string
|
||||
minLength: 1
|
||||
context:
|
||||
type: string
|
||||
Brand:
|
||||
type: object
|
||||
description: Brand Serializer
|
||||
@@ -37189,6 +37191,7 @@ components:
|
||||
flows_refresh_others:
|
||||
type: boolean
|
||||
description: Refresh other tabs after successful authentication.
|
||||
deprecated: true
|
||||
required:
|
||||
- core_default_app_access
|
||||
- enterprise_audit_include_expanded_diff
|
||||
@@ -51199,6 +51202,7 @@ components:
|
||||
flows_refresh_others:
|
||||
type: boolean
|
||||
description: Refresh other tabs after successful authentication.
|
||||
deprecated: true
|
||||
required:
|
||||
- core_default_app_access
|
||||
- enterprise_audit_include_expanded_diff
|
||||
@@ -55981,6 +55985,7 @@ components:
|
||||
flows_refresh_others:
|
||||
type: boolean
|
||||
description: Refresh other tabs after successful authentication.
|
||||
deprecated: true
|
||||
required:
|
||||
- core_default_app_access
|
||||
- enterprise_audit_include_expanded_diff
|
||||
@@ -56069,6 +56074,7 @@ components:
|
||||
flows_refresh_others:
|
||||
type: boolean
|
||||
description: Refresh other tabs after successful authentication.
|
||||
deprecated: true
|
||||
required:
|
||||
- core_default_app_access
|
||||
- enterprise_audit_include_expanded_diff
|
||||
|
||||
10
src/main.rs
10
src/main.rs
@@ -8,8 +8,6 @@ use eyre::{Result, eyre};
|
||||
use tracing::{error, info, trace};
|
||||
|
||||
mod metrics;
|
||||
#[cfg(feature = "proxy")]
|
||||
mod outpost;
|
||||
#[cfg(feature = "core")]
|
||||
mod server;
|
||||
#[cfg(feature = "core")]
|
||||
@@ -31,8 +29,6 @@ enum Command {
|
||||
Server(server::Cli),
|
||||
#[cfg(feature = "core")]
|
||||
Worker(worker::Cli),
|
||||
#[cfg(feature = "proxy")]
|
||||
Proxy(outpost::proxy::Cli),
|
||||
}
|
||||
|
||||
#[derive(Debug, FromArgs, PartialEq)]
|
||||
@@ -57,8 +53,6 @@ fn main() -> Result<()> {
|
||||
Command::Server(_) => Mode::set(Mode::Server)?,
|
||||
#[cfg(feature = "core")]
|
||||
Command::Worker(_) => Mode::set(Mode::Worker)?,
|
||||
#[cfg(feature = "proxy")]
|
||||
Command::Proxy(_) => Mode::set(Mode::Proxy)?,
|
||||
}
|
||||
|
||||
trace!("installing error formatting");
|
||||
@@ -114,10 +108,6 @@ fn main() -> Result<()> {
|
||||
let workers = worker::start(args, &mut tasks)?;
|
||||
metrics.workers.store(Some(workers));
|
||||
}
|
||||
#[cfg(feature = "proxy")]
|
||||
Command::Proxy(args) => {
|
||||
outpost::start::<outpost::proxy::ProxyOutpost>(args, &mut tasks).await?;
|
||||
}
|
||||
}
|
||||
|
||||
let errors = tasks.run().await;
|
||||
|
||||
@@ -1,312 +0,0 @@
|
||||
use std::{fmt::Display, sync::Arc};
|
||||
|
||||
use ak_common::{Arbiter, Tasks, VERSION, api, arbiter, authentik_build_hash};
|
||||
use axum::http::{HeaderValue, header::AUTHORIZATION};
|
||||
use eyre::{Result, eyre};
|
||||
use futures::{SinkExt as _, StreamExt as _};
|
||||
use nix::unistd::gethostname;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_repr::{Deserialize_repr, Serialize_repr};
|
||||
use time::UtcDateTime;
|
||||
use tokio::{
|
||||
signal::unix::SignalKind,
|
||||
time::{Duration, interval, sleep},
|
||||
};
|
||||
use tokio_tungstenite::tungstenite::{Message, client::IntoClientRequest as _};
|
||||
use tracing::{debug, info, instrument, trace, warn};
|
||||
use url::Url;
|
||||
|
||||
use crate::outpost::{Outpost, OutpostController};
|
||||
|
||||
#[derive(Serialize_repr, Deserialize_repr, PartialEq, Debug, Clone, Copy, Eq)]
|
||||
#[repr(u8)]
|
||||
enum EventKind {
|
||||
/// Code used to acknowledge a previous message.
|
||||
Ack = 0,
|
||||
/// Code used to send a healthcheck keepalive.
|
||||
Hello = 1,
|
||||
/// Code received to trigger a config update.
|
||||
TriggerUpdate = 2,
|
||||
/// Code received to trigger some provider specific function.
|
||||
ProviderSpecific = 3,
|
||||
/// Code received to identify the end of a session.
|
||||
SessionEnd = 4,
|
||||
}
|
||||
|
||||
impl Display for EventKind {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
Self::Ack => write!(f, "Ack"),
|
||||
Self::Hello => write!(f, "Hello"),
|
||||
Self::TriggerUpdate => write!(f, "TriggerUpdate"),
|
||||
Self::ProviderSpecific => write!(f, "ProviderSpecific"),
|
||||
Self::SessionEnd => write!(f, "SessionEnd"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
struct Event {
|
||||
instruction: EventKind,
|
||||
args: serde_json::Value,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub(crate) struct EventSessionEnd {
|
||||
session_id: String,
|
||||
}
|
||||
|
||||
fn build_ws_url(mut url: Url, outpost_pk: &str, instance_uuid: &str, attempt: u32) -> Result<Url> {
|
||||
let ws_scheme = match url.scheme() {
|
||||
"https" => "wss",
|
||||
"http" => "ws",
|
||||
other => return Err(eyre!("Unsupported scheme for WebSocket URL: {other}")),
|
||||
};
|
||||
|
||||
url.set_scheme(ws_scheme)
|
||||
.map_err(|()| eyre!("Failed to set URL scheme to {ws_scheme}"))?;
|
||||
url.set_path(&format!("{}ws/outpost/{outpost_pk}/", url.path()));
|
||||
url.query_pairs_mut()
|
||||
.append_pair("instance_uuid", instance_uuid)
|
||||
.append_pair("attempt", &attempt.to_string());
|
||||
|
||||
Ok(url)
|
||||
}
|
||||
|
||||
fn hello_args(instance_uuid: &str) -> serde_json::Value {
|
||||
let raw_hostname = gethostname().unwrap_or_default();
|
||||
let hostname = raw_hostname.to_string_lossy();
|
||||
|
||||
serde_json::json!({
|
||||
"version": VERSION,
|
||||
"buildHash": authentik_build_hash(None),
|
||||
"uuid": instance_uuid,
|
||||
// TODO: rust version and AWS-LC versions
|
||||
"hostname": hostname,
|
||||
})
|
||||
}
|
||||
|
||||
#[instrument(skip_all)]
|
||||
async fn handle_event<O: Outpost>(
|
||||
controller: Arc<OutpostController>,
|
||||
outpost: Arc<O>,
|
||||
event: Event,
|
||||
) -> Result<()> {
|
||||
match event.instruction {
|
||||
EventKind::Ack | EventKind::Hello => {}
|
||||
EventKind::TriggerUpdate => {
|
||||
info!("received update trigger, refreshing outpost");
|
||||
sleep(controller.reload_offset).await;
|
||||
controller.refresh().await?;
|
||||
debug!("outpost controller has been refreshed");
|
||||
outpost.refresh().await?;
|
||||
debug!("outpost has been refreshed");
|
||||
#[expect(
|
||||
clippy::as_conversions,
|
||||
clippy::cast_precision_loss,
|
||||
reason = "This is fine, we'll never get big values here."
|
||||
)]
|
||||
controller
|
||||
.m_last_update
|
||||
.set(UtcDateTime::now().unix_timestamp() as f64);
|
||||
}
|
||||
EventKind::SessionEnd => {
|
||||
let event: EventSessionEnd = serde_json::from_value(event.args)?;
|
||||
outpost.end_session(event).await?;
|
||||
}
|
||||
#[expect(
|
||||
clippy::unimplemented,
|
||||
reason = "this is only relevant for the RAC provider"
|
||||
)]
|
||||
EventKind::ProviderSpecific => unimplemented!(),
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn watch_events_inner<O: Outpost>(
|
||||
arbiter: Arbiter,
|
||||
controller: Arc<OutpostController>,
|
||||
outpost: Arc<O>,
|
||||
attempt: u32,
|
||||
) -> Result<()> {
|
||||
let server_config = api::ServerConfig::new()?;
|
||||
let ws_url = build_ws_url(
|
||||
server_config.host,
|
||||
&controller.outpost.load().pk.to_string(),
|
||||
&controller.instance_uuid.to_string(),
|
||||
attempt,
|
||||
)?;
|
||||
|
||||
debug!(url = %ws_url, "connecting to websocket");
|
||||
let mut request = ws_url.into_client_request()?;
|
||||
let token = controller
|
||||
.api_config
|
||||
.bearer_access_token
|
||||
.as_deref()
|
||||
.unwrap_or("");
|
||||
request.headers_mut().insert(
|
||||
AUTHORIZATION,
|
||||
HeaderValue::from_str(&format!("Bearer {token}"))?,
|
||||
);
|
||||
|
||||
let (ws_stream, _response) = tokio_tungstenite::connect_async(request).await?;
|
||||
let (mut ws_write, mut ws_read) = ws_stream.split();
|
||||
|
||||
info!(
|
||||
outpost = %controller.outpost.load().pk,
|
||||
"connected to websocket"
|
||||
);
|
||||
controller.m_connection.set(1_u8);
|
||||
|
||||
let get_refresh_interval = || {
|
||||
let mut interval = controller.outpost.load().refresh_interval_s;
|
||||
// Ensure timer interval is not negative or 0.
|
||||
// If it is, we default to 5 minutes.
|
||||
if interval <= 0_i32 {
|
||||
interval = 60_i32 * 5_i32;
|
||||
}
|
||||
// Clamp interval to be at least 30 seconds.
|
||||
if interval < 30_i32 {
|
||||
interval = 30_i32;
|
||||
}
|
||||
// infallible because we bound it to be positive above
|
||||
Duration::from_secs(interval.try_into().expect("infallible"))
|
||||
};
|
||||
let mut refresh_interval = interval(get_refresh_interval());
|
||||
let mut heartbeat_interval = interval(Duration::from_secs(10));
|
||||
|
||||
let mut events_rx = arbiter.events_subscribe();
|
||||
|
||||
loop {
|
||||
tokio::select! {
|
||||
_ = refresh_interval.tick() => {
|
||||
info!("refreshing outpost on interval");
|
||||
if let Err(err) = handle_event(
|
||||
Arc::clone(&controller),
|
||||
Arc::clone(&outpost),
|
||||
Event {
|
||||
instruction: EventKind::TriggerUpdate,
|
||||
args: serde_json::Value::Null
|
||||
}
|
||||
).await {
|
||||
warn!(?err, "failed to refresh");
|
||||
}
|
||||
refresh_interval = interval(get_refresh_interval());
|
||||
// Since we re-create the interval, we need to make it tick instantly to avoid
|
||||
// ending up in a never-ending tick-loop.
|
||||
refresh_interval.tick().await;
|
||||
},
|
||||
_ = heartbeat_interval.tick() => {
|
||||
let ping = Event {
|
||||
instruction: EventKind::Hello,
|
||||
args: hello_args(&controller.instance_uuid.to_string()),
|
||||
};
|
||||
ws_write.send(Message::text(serde_json::to_string(&ping)?)).await?;
|
||||
trace!("sent websocket hello (heartbeat)");
|
||||
},
|
||||
Ok(arbiter::Event::Signal(signal)) = events_rx.recv() => {
|
||||
if signal == SignalKind::user_defined1() {
|
||||
info!("refreshing outpost on signal");
|
||||
if let Err(err) = handle_event(
|
||||
Arc::clone(&controller),
|
||||
Arc::clone(&outpost),
|
||||
Event {
|
||||
instruction: EventKind::TriggerUpdate,
|
||||
args: serde_json::Value::Null
|
||||
}
|
||||
).await {
|
||||
warn!(?err, "failed to refresh");
|
||||
}
|
||||
}
|
||||
},
|
||||
msg = ws_read.next() => {
|
||||
let Some(msg) = msg else {
|
||||
break;
|
||||
};
|
||||
let msg = msg?;
|
||||
match msg {
|
||||
Message::Text(text) => {
|
||||
let Ok(event): Result<Event, _> = serde_json::from_str(&text) else {
|
||||
warn!(data = text.as_str(), "failed to parse event");
|
||||
continue;
|
||||
};
|
||||
trace!(event = %event.instruction, "received websocket event");
|
||||
if let Err(err) = handle_event(
|
||||
Arc::clone(&controller),
|
||||
Arc::clone(&outpost),
|
||||
event,
|
||||
).await {
|
||||
warn!(?err, "failed to handle event");
|
||||
}
|
||||
},
|
||||
Message::Ping(data) => {
|
||||
ws_write.send(Message::Pong(data)).await?;
|
||||
},
|
||||
Message::Close(_) => {
|
||||
break;
|
||||
},
|
||||
_ => {},
|
||||
}
|
||||
},
|
||||
() = arbiter.shutdown() => break,
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn watch_events<O: Outpost>(
|
||||
arbiter: Arbiter,
|
||||
controller: Arc<OutpostController>,
|
||||
outpost: Arc<O>,
|
||||
) -> Result<()> {
|
||||
const MAX_BACKOFF: Duration = Duration::from_mins(5);
|
||||
let mut backoff = Duration::from_secs(1);
|
||||
let mut attempt: u32 = 0;
|
||||
|
||||
loop {
|
||||
tokio::select! {
|
||||
() = arbiter.shutdown() => break,
|
||||
res = watch_events_inner(
|
||||
arbiter.clone(),
|
||||
Arc::clone(&controller),
|
||||
Arc::clone(&outpost),
|
||||
attempt
|
||||
) => {
|
||||
controller.m_connection.set(0_u8);
|
||||
match res {
|
||||
Ok(()) => debug!("websocket disconnected cleanly"),
|
||||
Err(err) => warn!(?err, attempt, "websocket error"),
|
||||
}
|
||||
|
||||
info!(attempt, delay = backoff.as_secs(), "reconnecting websocket in {}s...", backoff.as_secs());
|
||||
|
||||
tokio::select! {
|
||||
() = arbiter.shutdown() => break,
|
||||
() = sleep(backoff) => {}
|
||||
}
|
||||
|
||||
backoff = (backoff * 2).min(MAX_BACKOFF);
|
||||
attempt += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
info!("stopping event watcher");
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn start<O: Outpost + 'static>(
|
||||
tasks: &mut Tasks,
|
||||
controller: Arc<OutpostController>,
|
||||
outpost: Arc<O>,
|
||||
) -> Result<()> {
|
||||
let arbiter = tasks.arbiter();
|
||||
tasks
|
||||
.build_task()
|
||||
.name(&format!("{}::watch_events", module_path!()))
|
||||
.spawn(watch_events(arbiter, controller, outpost))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -1,123 +0,0 @@
|
||||
use std::{sync::Arc, time::Duration};
|
||||
|
||||
use ak_client::{
|
||||
apis::{configuration::Configuration, outposts_api::outposts_instances_list},
|
||||
models::Outpost as OutpostModel,
|
||||
};
|
||||
use ak_common::{Tasks, VERSION, api, authentik_build_hash};
|
||||
use arc_swap::ArcSwap;
|
||||
use eyre::{Result, eyre};
|
||||
use tracing::{debug, info, instrument};
|
||||
use uuid::Uuid;
|
||||
|
||||
pub(crate) mod event;
|
||||
#[cfg(feature = "proxy")]
|
||||
pub(crate) mod proxy;
|
||||
|
||||
pub(crate) trait Outpost: Send + Sync + Sized {
|
||||
const OUTPOST_TYPE: &'static str;
|
||||
type Cli: Send + Sync;
|
||||
|
||||
async fn new(controller: Arc<OutpostController>) -> Result<Self>;
|
||||
|
||||
fn start(self: Arc<Self>, tasks: &mut Tasks) -> Result<()>;
|
||||
fn refresh(&self) -> impl Future<Output = Result<()>> + Send;
|
||||
|
||||
fn end_session(&self, event: event::EventSessionEnd)
|
||||
-> impl Future<Output = Result<()>> + Send;
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub(crate) struct OutpostController {
|
||||
api_config: Configuration,
|
||||
outpost: ArcSwap<OutpostModel>,
|
||||
instance_uuid: Uuid,
|
||||
reload_offset: Duration,
|
||||
m_info: metrics::Gauge,
|
||||
m_last_update: metrics::Gauge,
|
||||
m_connection: metrics::Gauge,
|
||||
}
|
||||
|
||||
impl OutpostController {
|
||||
#[instrument(skip_all)]
|
||||
async fn get_outpost(api_config: &Configuration) -> Result<OutpostModel> {
|
||||
let outposts = outposts_instances_list(
|
||||
api_config, None, None, None, None, None, None, None, None, None, None, None, None,
|
||||
)
|
||||
.await?;
|
||||
|
||||
let Some(outpost) = outposts.results.into_iter().next() else {
|
||||
return Err(eyre!(
|
||||
"No outposts found with given token, ensure the given token corresponds to an \
|
||||
authentik Outpost"
|
||||
));
|
||||
};
|
||||
debug!(name = outpost.name, "fetched outpost configuration");
|
||||
|
||||
Ok(outpost)
|
||||
}
|
||||
|
||||
#[instrument(skip_all)]
|
||||
async fn new<O: Outpost>() -> Result<Self> {
|
||||
let api_config = api::make_config()?;
|
||||
let outpost = Self::get_outpost(&api_config).await?;
|
||||
let instance_uuid = Uuid::new_v4();
|
||||
|
||||
let m_labels = [
|
||||
("outpost_name", outpost.name.clone()),
|
||||
("outpost_type", O::OUTPOST_TYPE.to_owned()),
|
||||
("uuid", instance_uuid.to_string()),
|
||||
("version", VERSION.to_owned()),
|
||||
("build", authentik_build_hash(None)),
|
||||
];
|
||||
metrics::describe_gauge!("authentik_outpost_info", "Outpost info");
|
||||
let m_info = metrics::gauge!("authentik_outpost_info", &m_labels);
|
||||
metrics::describe_gauge!("authentik_outpost_last_update", "Time of last update");
|
||||
let m_last_update = metrics::gauge!("authentik_outpost_last_update", &m_labels);
|
||||
metrics::describe_gauge!("authentik_outpost_connection", "Connection status");
|
||||
let m_connection = metrics::gauge!("authentik_outpost_connection", &m_labels);
|
||||
|
||||
let reload_offset = Duration::from_secs(rand::random_range(0..10));
|
||||
let controller = Self {
|
||||
api_config,
|
||||
outpost: ArcSwap::from_pointee(outpost),
|
||||
instance_uuid,
|
||||
reload_offset,
|
||||
m_info,
|
||||
m_last_update,
|
||||
m_connection,
|
||||
};
|
||||
|
||||
info!(embedded = controller.is_embedded(), "outpost mode");
|
||||
debug!(?reload_offset, "HA Reload offset");
|
||||
|
||||
Ok(controller)
|
||||
}
|
||||
|
||||
fn is_embedded(&self) -> bool {
|
||||
self.outpost
|
||||
.load()
|
||||
.managed
|
||||
.as_ref()
|
||||
.and_then(|m| m.as_deref())
|
||||
.is_some_and(|m| m == "goauthentik.io/outposts/embedded")
|
||||
}
|
||||
|
||||
async fn refresh(&self) -> Result<()> {
|
||||
let outpost = Self::get_outpost(&self.api_config).await?;
|
||||
self.outpost.swap(Arc::new(outpost));
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[instrument(skip_all)]
|
||||
pub(crate) async fn start<O: Outpost + 'static>(_cli: O::Cli, tasks: &mut Tasks) -> Result<()> {
|
||||
let controller = Arc::new(OutpostController::new::<O>().await?);
|
||||
let outpost = Arc::new(O::new(Arc::clone(&controller)).await?);
|
||||
|
||||
event::start(tasks, Arc::clone(&controller), Arc::clone(&outpost))?;
|
||||
outpost.start(tasks)?;
|
||||
controller.m_info.set(1_u8);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -1,41 +0,0 @@
|
||||
use ak_client::models::ProxyOutpostConfig;
|
||||
use axum::Router;
|
||||
use eyre::{Result, eyre};
|
||||
use tracing::instrument;
|
||||
use url::Url;
|
||||
|
||||
use crate::outpost::proxy::ProxyOutpost;
|
||||
|
||||
const REDIRECT_PARAM: &str = "rd";
|
||||
const CALLBACK_SIGNATURE: &str = "X-authentik-auth-callback";
|
||||
const LOGOUT_SIGNATURE: &str = "X-authentik-logout";
|
||||
|
||||
#[derive(Debug)]
|
||||
pub(super) struct Application {
|
||||
pub(super) host: String,
|
||||
pub(super) provider: ProxyOutpostConfig,
|
||||
pub(super) router: Router,
|
||||
}
|
||||
|
||||
impl Application {
|
||||
#[instrument(skip_all)]
|
||||
pub(super) fn new(_existing_apps: &ProxyOutpost, provider: ProxyOutpostConfig) -> Result<Self> {
|
||||
let external_url = Url::parse(&provider.external_host)?;
|
||||
if !external_url.has_authority() {
|
||||
return Err(eyre!("no host in external host"));
|
||||
}
|
||||
let external_host = external_url.authority();
|
||||
|
||||
let _redirect_url = {
|
||||
let mut redirect_url = external_url.join("outpost.goauthentik.io/callback")?;
|
||||
redirect_url.set_query(Some(&format!("{CALLBACK_SIGNATURE}=true")));
|
||||
redirect_url
|
||||
};
|
||||
|
||||
Ok(Self {
|
||||
host: external_host.to_owned(),
|
||||
provider,
|
||||
router: Router::new(),
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,76 +0,0 @@
|
||||
use std::sync::Arc;
|
||||
use tower::util::ServiceExt as _;
|
||||
|
||||
use ak_axum::{error::Result, extract::host::Host};
|
||||
use axum::{
|
||||
extract::{Request, State},
|
||||
http::{Method, StatusCode, header::CONTENT_TYPE},
|
||||
response::{IntoResponse as _, Response},
|
||||
};
|
||||
use metrics::histogram;
|
||||
use serde_json::json;
|
||||
use tokio::time::Instant;
|
||||
use tracing::{Instrument as _, field, info_span, instrument, trace, warn};
|
||||
|
||||
use crate::outpost::proxy::ProxyOutpost;
|
||||
|
||||
#[instrument(skip_all)]
|
||||
pub(super) async fn handle_ping(
|
||||
method: Method,
|
||||
Host(host): Host,
|
||||
State(outpost): State<Arc<ProxyOutpost>>,
|
||||
) -> Response {
|
||||
let start = Instant::now();
|
||||
histogram!(
|
||||
"authentik_outpost_proxy_request_duration_seconds",
|
||||
"outpost_name" => outpost.controller.outpost.load().name.clone(),
|
||||
"method" => method.to_string(),
|
||||
"host" => host,
|
||||
"type" => "ping",
|
||||
)
|
||||
.record(start.elapsed().as_secs_f64());
|
||||
StatusCode::NO_CONTENT.into_response()
|
||||
}
|
||||
|
||||
#[instrument(skip_all)]
|
||||
pub(super) async fn default(
|
||||
method: Method,
|
||||
Host(host): Host,
|
||||
State(outpost): State<Arc<ProxyOutpost>>,
|
||||
request: Request,
|
||||
) -> Result<Response> {
|
||||
let span = info_span!("proxy outpost request", user = field::Empty);
|
||||
let start = Instant::now();
|
||||
|
||||
let Some(app) = outpost.lookup_app(&host) else {
|
||||
trace!(headers = ?request.headers(), "tracing headers for no hostname match");
|
||||
warn!("no app for hostname");
|
||||
return Ok(Response::builder()
|
||||
.status(StatusCode::BAD_REQUEST)
|
||||
.header(CONTENT_TYPE, "application/json")
|
||||
.body(
|
||||
json!({
|
||||
"message": "no app for hostname",
|
||||
"host": host,
|
||||
"detail": format!("check the outpost settings and make sure '{host}' is included."),
|
||||
})
|
||||
.to_string()
|
||||
.into(),
|
||||
)
|
||||
.expect("infallible"));
|
||||
};
|
||||
|
||||
trace!("passing to application");
|
||||
let response = app.router.clone().oneshot(request).instrument(span).await?;
|
||||
|
||||
histogram!(
|
||||
"authentik_outpost_proxy_request_duration_seconds",
|
||||
"outpost_name" => outpost.controller.outpost.load().name.clone(),
|
||||
"method" => method.to_string(),
|
||||
"host" => host,
|
||||
"type" => "app",
|
||||
)
|
||||
.record(start.elapsed().as_secs_f64());
|
||||
|
||||
Ok(response)
|
||||
}
|
||||
@@ -1,187 +0,0 @@
|
||||
use std::{collections::HashMap, sync::Arc};
|
||||
|
||||
use ak_axum::router::wrap_router;
|
||||
use ak_client::{apis::outposts_api::outposts_proxy_list, models::ProxyMode};
|
||||
use ak_common::{Tasks, api::fetch_all, config};
|
||||
use arc_swap::ArcSwap;
|
||||
use argh::FromArgs;
|
||||
use axum::Router;
|
||||
use eyre::Result;
|
||||
use tracing::{debug, error, info, instrument, warn};
|
||||
|
||||
use crate::outpost::{Outpost, OutpostController, proxy::application::Application};
|
||||
|
||||
mod application;
|
||||
mod handlers;
|
||||
|
||||
#[derive(Debug, Default, FromArgs, PartialEq, Eq)]
|
||||
/// Run the authentik proxy outpost.
|
||||
#[argh(subcommand, name = "proxy")]
|
||||
#[expect(
|
||||
clippy::empty_structs_with_brackets,
|
||||
reason = "argh doesn't support unit structs"
|
||||
)]
|
||||
pub(crate) struct Cli {}
|
||||
|
||||
pub(crate) struct ProxyOutpost {
|
||||
controller: Arc<OutpostController>,
|
||||
apps: ArcSwap<HashMap<String, Arc<Application>>>,
|
||||
}
|
||||
|
||||
impl Outpost for ProxyOutpost {
|
||||
type Cli = Cli;
|
||||
|
||||
const OUTPOST_TYPE: &'static str = "proxy";
|
||||
|
||||
#[instrument(skip_all)]
|
||||
async fn new(controller: Arc<OutpostController>) -> Result<Self> {
|
||||
Ok(Self {
|
||||
controller,
|
||||
apps: ArcSwap::from_pointee(HashMap::with_capacity(0)),
|
||||
})
|
||||
}
|
||||
|
||||
fn start(self: Arc<Self>, tasks: &mut Tasks) -> Result<()> {
|
||||
let router = build_router(self);
|
||||
|
||||
for addr in config::get().listen.http.iter().copied() {
|
||||
ak_axum::server::start_plain(tasks, "proxy-outpost", router.clone(), addr, false)?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[instrument(skip_all)]
|
||||
async fn refresh(&self) -> Result<()> {
|
||||
debug!(
|
||||
outpost_pk = %self.controller.outpost.load().pk,
|
||||
"requesting providers for outpost"
|
||||
);
|
||||
|
||||
let providers = fetch_all(
|
||||
|page| {
|
||||
outposts_proxy_list(
|
||||
&self.controller.api_config,
|
||||
None,
|
||||
None,
|
||||
Some(page),
|
||||
Some(100_i32),
|
||||
None,
|
||||
)
|
||||
},
|
||||
|r| &r.pagination,
|
||||
|r| r.results,
|
||||
)
|
||||
.await
|
||||
.inspect_err(|err| error!(?err, "failed to fetch providers"))?;
|
||||
debug!(count = providers.len(), "fetched providers");
|
||||
|
||||
if providers.is_empty() && !self.controller.is_embedded() {
|
||||
warn!(
|
||||
"no providers assigned to this outpost, check outpost configuration in authentik"
|
||||
);
|
||||
}
|
||||
|
||||
for (i, provider) in providers.iter().enumerate() {
|
||||
debug!(
|
||||
index = i,
|
||||
name = provider.name,
|
||||
external_host = provider.external_host,
|
||||
assigned_to_app = provider.assigned_application_name,
|
||||
"provider details"
|
||||
);
|
||||
}
|
||||
|
||||
let mut apps = HashMap::with_capacity(providers.len());
|
||||
|
||||
for provider in providers {
|
||||
let name = provider.name.clone();
|
||||
let Ok(application) = Application::new(self, provider)
|
||||
.inspect_err(|err| warn!(?err, "failed to setup application, skipping provider"))
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
info!(name, host = application.host, "loaded application");
|
||||
|
||||
apps.insert(application.host.clone(), Arc::new(application));
|
||||
}
|
||||
|
||||
self.apps.store(Arc::new(apps));
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn end_session(&self, _event: super::event::EventSessionEnd) -> Result<()> {
|
||||
// todo!()
|
||||
warn!(?_event, "removing session");
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl ProxyOutpost {
|
||||
#[instrument(skip(self))]
|
||||
fn lookup_app(&self, host: &str) -> Option<Arc<Application>> {
|
||||
let apps = self.apps.load();
|
||||
|
||||
// If we only have a single app, host name switching doesn't matter.
|
||||
if apps.len() == 1
|
||||
&& let Some(app) = apps.values().next()
|
||||
{
|
||||
debug!(app = app.provider.name, "found a single app, using it");
|
||||
return Some(Arc::clone(app));
|
||||
}
|
||||
|
||||
if let Some(app) = apps.get(host) {
|
||||
debug!(app = app.provider.name, "found app based direct host match");
|
||||
return Some(Arc::clone(app));
|
||||
}
|
||||
|
||||
// For forward_auth_domain, we don't have a direct app to domain relationship.
|
||||
// Check through all apps, and check how much of their cookie domain matches the host.
|
||||
// Return the application that has the longest match.
|
||||
let mut longest_match = None;
|
||||
let mut longest_len = 0_usize;
|
||||
|
||||
for app in apps.values() {
|
||||
if app.provider.mode != Some(ProxyMode::ForwardDomain) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if let Some(cookie_domain) = app.provider.cookie_domain.as_deref() {
|
||||
// Check if the cookie domain has a leading period for a wildcard.
|
||||
// This will decrease the weight of a wildcard domain, but a request to example.com
|
||||
// with the cookie domain set to example.com will still be routed correctly.
|
||||
let domain = cookie_domain.trim_start_matches('.');
|
||||
|
||||
if host.ends_with(domain) && domain.len() > longest_len {
|
||||
longest_len = domain.len();
|
||||
longest_match = Some(Arc::clone(app));
|
||||
}
|
||||
// For forward_auth_domain, we need to response on the external domain too.
|
||||
if app.provider.external_host == host {
|
||||
debug!(app = app.provider.name, "found app based on external_host");
|
||||
return Some(Arc::clone(app));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(app) = &longest_match {
|
||||
debug!(app = app.provider.name, "found app based on cookie domain");
|
||||
}
|
||||
|
||||
longest_match
|
||||
}
|
||||
}
|
||||
|
||||
fn build_router(outpost: Arc<ProxyOutpost>) -> Router {
|
||||
wrap_router(
|
||||
Router::new()
|
||||
.nest(
|
||||
"/outpost.goauthentik.io/ping",
|
||||
Router::new().fallback(handlers::handle_ping),
|
||||
)
|
||||
.fallback(handlers::default)
|
||||
.with_state(outpost),
|
||||
true,
|
||||
)
|
||||
}
|
||||
@@ -111,8 +111,12 @@ class TestFlowsEnroll(SeleniumTestCase):
|
||||
identification_stage = self.get_shadow_root("ak-stage-identification", flow_executor)
|
||||
wait = WebDriverWait(identification_stage, self.wait_timeout)
|
||||
|
||||
wait.until(ec.presence_of_element_located((By.CSS_SELECTOR, "a[ouiaId='enroll']")))
|
||||
identification_stage.find_element(By.CSS_SELECTOR, "a[ouiaId='enroll']").click()
|
||||
wait.until(
|
||||
ec.presence_of_element_located((By.CSS_SELECTOR, "a[data-ouia-component-id='enroll']"))
|
||||
)
|
||||
identification_stage.find_element(
|
||||
By.CSS_SELECTOR, "a[data-ouia-component-id='enroll']"
|
||||
).click()
|
||||
|
||||
# First prompt stage
|
||||
flow_executor = self.get_shadow_root("ak-flow-executor")
|
||||
|
||||
@@ -27,8 +27,14 @@ class TestFlowsRecovery(SeleniumTestCase):
|
||||
identification_stage = self.get_shadow_root("ak-stage-identification", flow_executor)
|
||||
wait = WebDriverWait(identification_stage, self.wait_timeout)
|
||||
|
||||
wait.until(ec.presence_of_element_located((By.CSS_SELECTOR, "a[ouiaId='recovery']")))
|
||||
identification_stage.find_element(By.CSS_SELECTOR, "a[ouiaId='recovery']").click()
|
||||
wait.until(
|
||||
ec.presence_of_element_located(
|
||||
(By.CSS_SELECTOR, "a[data-ouia-component-id='recovery']")
|
||||
)
|
||||
)
|
||||
identification_stage.find_element(
|
||||
By.CSS_SELECTOR, "a[data-ouia-component-id='recovery']"
|
||||
).click()
|
||||
|
||||
# First prompt stage
|
||||
flow_executor = self.get_shadow_root("ak-flow-executor")
|
||||
|
||||
8
uv.lock
generated
8
uv.lock
generated
@@ -322,7 +322,7 @@ requires-dist = [
|
||||
{ name = "dacite", specifier = "==1.9.2" },
|
||||
{ name = "deepmerge", specifier = "==2.0" },
|
||||
{ name = "defusedxml", specifier = "==0.7.1" },
|
||||
{ name = "django", specifier = "==5.2.13" },
|
||||
{ name = "django", specifier = "==5.2.14" },
|
||||
{ name = "django-channels-postgres", editable = "packages/django-channels-postgres" },
|
||||
{ name = "django-countries", specifier = "==8.2.0" },
|
||||
{ name = "django-dramatiq-postgres", editable = "packages/django-dramatiq-postgres" },
|
||||
@@ -1075,16 +1075,16 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "django"
|
||||
version = "5.2.13"
|
||||
version = "5.2.14"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "asgiref" },
|
||||
{ name = "sqlparse" },
|
||||
{ name = "tzdata", marker = "sys_platform == 'win32'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/1f/c5/c69e338eb2959f641045802e5ea87ca4bf5ac90c5fd08953ca10742fad51/django-5.2.13.tar.gz", hash = "sha256:a31589db5188d074c63f0945c3888fad104627dfcc236fb2b97f71f89da33bc4", size = 10890368, upload-time = "2026-04-07T14:02:15.072Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/65/95/95f7faa0950867afaa0bef2460c6263afd6a2c78cc9434046ed28160b015/django-5.2.14.tar.gz", hash = "sha256:58a63ba841662e5c686b57ba1fec52ddd68c0b93bd96ac3029d55728f00bf8a2", size = 10895118, upload-time = "2026-05-05T13:57:31.104Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/59/b1/51ab36b2eefcf8cdb9338c7188668a157e29e30306bfc98a379704c9e10d/django-5.2.13-py3-none-any.whl", hash = "sha256:5788fce61da23788a8ce6f02583765ab060d396720924789f97fa42119d37f7a", size = 8310982, upload-time = "2026-04-07T14:02:08.883Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/14/44/f172870cf87aa25afef48fb72adba89ee8b77fcab6f3b23d240b923f1528/django-5.2.14-py3-none-any.whl", hash = "sha256:6f712143bd3064310d1f50fac859c3e9a274bdcfc9595339853be7779297fc76", size = 8311320, upload-time = "2026-05-05T13:57:25.795Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
126
web/package-lock.json
generated
126
web/package-lock.json
generated
@@ -66,7 +66,7 @@
|
||||
"chartjs-adapter-date-fns": "^3.0.0",
|
||||
"codemirror": "^6.0.2",
|
||||
"core-js": "^3.49.0",
|
||||
"country-flag-icons": "^1.6.16",
|
||||
"country-flag-icons": "^1.6.17",
|
||||
"date-fns": "^4.1.0",
|
||||
"deepmerge-ts": "^7.1.5",
|
||||
"dompurify": "^3.4.2",
|
||||
@@ -118,7 +118,7 @@
|
||||
"vitest": "^4.1.1",
|
||||
"webcomponent-qr-code": "^1.3.0",
|
||||
"wireit": "^0.14.12",
|
||||
"yaml": "^2.8.3"
|
||||
"yaml": "^2.8.4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=24",
|
||||
@@ -4577,9 +4577,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@swc/core": {
|
||||
"version": "1.15.32",
|
||||
"resolved": "https://registry.npmjs.org/@swc/core/-/core-1.15.32.tgz",
|
||||
"integrity": "sha512-/eWL0n43D64QWEUHLtTE+jDqjkJhyidjkDhv6f0uJohOUAhywxQ9wXYp845DNNds0JpCdI4Uo0a9bl+vbXf+ew==",
|
||||
"version": "1.15.33",
|
||||
"resolved": "https://registry.npmjs.org/@swc/core/-/core-1.15.33.tgz",
|
||||
"integrity": "sha512-jOlwnFV2xhuuZeAUILGFULeR6vDPfijEJ57evfocwznQldLU3w2cZ9bSDryY9ip+AsM3r1NJKzf47V2NXebkeQ==",
|
||||
"hasInstallScript": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
@@ -4594,18 +4594,18 @@
|
||||
"url": "https://opencollective.com/swc"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@swc/core-darwin-arm64": "1.15.32",
|
||||
"@swc/core-darwin-x64": "1.15.32",
|
||||
"@swc/core-linux-arm-gnueabihf": "1.15.32",
|
||||
"@swc/core-linux-arm64-gnu": "1.15.32",
|
||||
"@swc/core-linux-arm64-musl": "1.15.32",
|
||||
"@swc/core-linux-ppc64-gnu": "1.15.32",
|
||||
"@swc/core-linux-s390x-gnu": "1.15.32",
|
||||
"@swc/core-linux-x64-gnu": "1.15.32",
|
||||
"@swc/core-linux-x64-musl": "1.15.32",
|
||||
"@swc/core-win32-arm64-msvc": "1.15.32",
|
||||
"@swc/core-win32-ia32-msvc": "1.15.32",
|
||||
"@swc/core-win32-x64-msvc": "1.15.32"
|
||||
"@swc/core-darwin-arm64": "1.15.33",
|
||||
"@swc/core-darwin-x64": "1.15.33",
|
||||
"@swc/core-linux-arm-gnueabihf": "1.15.33",
|
||||
"@swc/core-linux-arm64-gnu": "1.15.33",
|
||||
"@swc/core-linux-arm64-musl": "1.15.33",
|
||||
"@swc/core-linux-ppc64-gnu": "1.15.33",
|
||||
"@swc/core-linux-s390x-gnu": "1.15.33",
|
||||
"@swc/core-linux-x64-gnu": "1.15.33",
|
||||
"@swc/core-linux-x64-musl": "1.15.33",
|
||||
"@swc/core-win32-arm64-msvc": "1.15.33",
|
||||
"@swc/core-win32-ia32-msvc": "1.15.33",
|
||||
"@swc/core-win32-x64-msvc": "1.15.33"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@swc/helpers": ">=0.5.17"
|
||||
@@ -4617,9 +4617,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@swc/core-darwin-arm64": {
|
||||
"version": "1.15.32",
|
||||
"resolved": "https://registry.npmjs.org/@swc/core-darwin-arm64/-/core-darwin-arm64-1.15.32.tgz",
|
||||
"integrity": "sha512-/YWMvJDPu+AAwuUsM2G+DNQ/7zhodURGzdQyewEqcvgklAdDHs3LwQmLLnyn6SJl8DT8UOxkbzK+D1PmPeelRg==",
|
||||
"version": "1.15.33",
|
||||
"resolved": "https://registry.npmjs.org/@swc/core-darwin-arm64/-/core-darwin-arm64-1.15.33.tgz",
|
||||
"integrity": "sha512-N+L0uXhuO7FIfzqwgxmzv0zIpV0qEp8wPX3QQs2p4atjMoywup2JTeDlXPw+z9pWJGCae3JjM+tZ6myclI+2gA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -4633,9 +4633,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@swc/core-darwin-x64": {
|
||||
"version": "1.15.32",
|
||||
"resolved": "https://registry.npmjs.org/@swc/core-darwin-x64/-/core-darwin-x64-1.15.32.tgz",
|
||||
"integrity": "sha512-KOTXJXdAhWL+hZ77MYP3z+4pcMFaQhQ74yqyN1uz093q0YnbxpqMtYpPISbYvMHzVRNNx5kN+9RZAXEaadhWVA==",
|
||||
"version": "1.15.33",
|
||||
"resolved": "https://registry.npmjs.org/@swc/core-darwin-x64/-/core-darwin-x64-1.15.33.tgz",
|
||||
"integrity": "sha512-/Il4QHSOhV4FekbsDtkrNmKbsX26oSysvgrRswa/RYOHXAkwXDbB4jaeKq6PsJLSPkzJ2KzQ061gtBnk0vNHfA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -4649,9 +4649,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@swc/core-linux-arm-gnueabihf": {
|
||||
"version": "1.15.32",
|
||||
"resolved": "https://registry.npmjs.org/@swc/core-linux-arm-gnueabihf/-/core-linux-arm-gnueabihf-1.15.32.tgz",
|
||||
"integrity": "sha512-oOoxLweljlc0A4X8ybsgxV7cVaYTwBOg2iMDJcFR3Sr48C+lsv9VzSmqdK/IVIXF4W4GjLc3VqTAdSMXlfVLuQ==",
|
||||
"version": "1.15.33",
|
||||
"resolved": "https://registry.npmjs.org/@swc/core-linux-arm-gnueabihf/-/core-linux-arm-gnueabihf-1.15.33.tgz",
|
||||
"integrity": "sha512-C64hBnBxq4viOPQ8hlx+2lJ23bzZBGnjw7ryALmS+0Q3zHmwO8lw1/DArLENw4Q18/0w5wdEO1k3m1wWNtKGqQ==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
@@ -4665,9 +4665,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@swc/core-linux-arm64-gnu": {
|
||||
"version": "1.15.32",
|
||||
"resolved": "https://registry.npmjs.org/@swc/core-linux-arm64-gnu/-/core-linux-arm64-gnu-1.15.32.tgz",
|
||||
"integrity": "sha512-oDzEkdl6D6BAWdMtU5KGO7y3HR5fJcvByNLyEk9+ugj8nP5Ovb7P4kBcStBXc4MPExFGQryehiINMlmY8HlclA==",
|
||||
"version": "1.15.33",
|
||||
"resolved": "https://registry.npmjs.org/@swc/core-linux-arm64-gnu/-/core-linux-arm64-gnu-1.15.33.tgz",
|
||||
"integrity": "sha512-TRJfnJbX3jqpxRDRoieMzRiCBS5jOmXNb3iQXmcgjFEHKLnAgK1RZRU8Cq1MsPqO4jAJp/ld1G4O3fXuxv85uw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -4681,9 +4681,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@swc/core-linux-arm64-musl": {
|
||||
"version": "1.15.32",
|
||||
"resolved": "https://registry.npmjs.org/@swc/core-linux-arm64-musl/-/core-linux-arm64-musl-1.15.32.tgz",
|
||||
"integrity": "sha512-omcqjoZP/b8D8PuczVoRwJieC6ibj7qIxTftNYokz4/aSmKFHvsd7nIFfPk5ZvtzncbH4AY7+Dkr/Lp2gWxYeA==",
|
||||
"version": "1.15.33",
|
||||
"resolved": "https://registry.npmjs.org/@swc/core-linux-arm64-musl/-/core-linux-arm64-musl-1.15.33.tgz",
|
||||
"integrity": "sha512-il7tYM+CpUNzieQbwAjFT1P8zqAhmGWNAGhQZBnxurXZ0aNn+5nqYFTEUKNZl7QibtT0uQXzTZrNGHCIj6Y1Og==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -4697,9 +4697,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@swc/core-linux-ppc64-gnu": {
|
||||
"version": "1.15.32",
|
||||
"resolved": "https://registry.npmjs.org/@swc/core-linux-ppc64-gnu/-/core-linux-ppc64-gnu-1.15.32.tgz",
|
||||
"integrity": "sha512-KGkTMyz/Tbn3PBNu0AVZ4GTDFKnICrYcTiNPZq8DrvK42pnFsf3GNDrIG9E5AtQlTmC0YigkWKmu0eMcfTrmgA==",
|
||||
"version": "1.15.33",
|
||||
"resolved": "https://registry.npmjs.org/@swc/core-linux-ppc64-gnu/-/core-linux-ppc64-gnu-1.15.33.tgz",
|
||||
"integrity": "sha512-ZtNBwN0Z7CFj9Il0FcPaKdjgP7URyKu/3RfH46vq+0paOBqLj4NYldD6Qo//Duif/7IOtAraUfDOmp0PLAufog==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
@@ -4713,9 +4713,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@swc/core-linux-s390x-gnu": {
|
||||
"version": "1.15.32",
|
||||
"resolved": "https://registry.npmjs.org/@swc/core-linux-s390x-gnu/-/core-linux-s390x-gnu-1.15.32.tgz",
|
||||
"integrity": "sha512-G3Aa4tVS/3OGZBkoNIwUF9F6RAy+Osb4GOlo62SinLmDiErz/ykmM7KH0wkz6l9kM8jJq1HyAM6atJTUEbBk7g==",
|
||||
"version": "1.15.33",
|
||||
"resolved": "https://registry.npmjs.org/@swc/core-linux-s390x-gnu/-/core-linux-s390x-gnu-1.15.33.tgz",
|
||||
"integrity": "sha512-De1IyajoOmhOYYjw/lx66bKlyDpHZTueqwpDrWgf5O7T6d1ODeJJO9/OqMBmrBQc5C+dNnlmIufHsp4QVCWufA==",
|
||||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
@@ -4729,9 +4729,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@swc/core-linux-x64-gnu": {
|
||||
"version": "1.15.32",
|
||||
"resolved": "https://registry.npmjs.org/@swc/core-linux-x64-gnu/-/core-linux-x64-gnu-1.15.32.tgz",
|
||||
"integrity": "sha512-ERsjfGcj6CBmj3vJnGDO8m8rTvw6RqMcWo1dogOtNx3/+/0+NNpJiXDobJrr1GwInI/BHAEkvSFIH6d2LqPcUQ==",
|
||||
"version": "1.15.33",
|
||||
"resolved": "https://registry.npmjs.org/@swc/core-linux-x64-gnu/-/core-linux-x64-gnu-1.15.33.tgz",
|
||||
"integrity": "sha512-mGTH0YxmUN+x6vRN/I6NOk5X0ogNktkwPnJ94IMvR7QjhRDwL0O8RXEDhyUM0YtwWrryBOqaJQBX4zruxEPRGw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -4745,9 +4745,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@swc/core-linux-x64-musl": {
|
||||
"version": "1.15.32",
|
||||
"resolved": "https://registry.npmjs.org/@swc/core-linux-x64-musl/-/core-linux-x64-musl-1.15.32.tgz",
|
||||
"integrity": "sha512-N4Ggahe/8SUbTX50P6EdhbW9YWcgbZVb52R4cq6MK+zsoMjRq7rGvV5ztA05QnbaCYqMYx8rTY7KAIA3Crdo4Q==",
|
||||
"version": "1.15.33",
|
||||
"resolved": "https://registry.npmjs.org/@swc/core-linux-x64-musl/-/core-linux-x64-musl-1.15.33.tgz",
|
||||
"integrity": "sha512-hj628ZkSEJf6zMf5VMbYrG2O6QqyTIp2qwY6VlCjvIa9lAEZ5c2lfPblCLVGYubTeLJDxadLB/CxqQYOQABeEQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -4761,9 +4761,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@swc/core-win32-arm64-msvc": {
|
||||
"version": "1.15.32",
|
||||
"resolved": "https://registry.npmjs.org/@swc/core-win32-arm64-msvc/-/core-win32-arm64-msvc-1.15.32.tgz",
|
||||
"integrity": "sha512-01yN0o9jvo8xBTP12aPK2wW8b41jmOlGbDDlAnoynotc4pO6xA0zby9f1z6j++qXDpGBttLySq1omgVrlQKYcw==",
|
||||
"version": "1.15.33",
|
||||
"resolved": "https://registry.npmjs.org/@swc/core-win32-arm64-msvc/-/core-win32-arm64-msvc-1.15.33.tgz",
|
||||
"integrity": "sha512-GV2oohtN2/5+KSccl86VULu3aT+LrISC8uzgSq0FRnikpD+Zwc+sBlXmoKQ+Db6jI57ITUOIB8jRkdGMABC29g==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -4777,9 +4777,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@swc/core-win32-ia32-msvc": {
|
||||
"version": "1.15.32",
|
||||
"resolved": "https://registry.npmjs.org/@swc/core-win32-ia32-msvc/-/core-win32-ia32-msvc-1.15.32.tgz",
|
||||
"integrity": "sha512-fLagI9XZYNpTcmlqAcp3KBtmj7E19WCmYD80Jxj1Kn5tGNa7yxNLd3NNdWxuZGUPl5iC0/KqZru7g08gF6Fsrw==",
|
||||
"version": "1.15.33",
|
||||
"resolved": "https://registry.npmjs.org/@swc/core-win32-ia32-msvc/-/core-win32-ia32-msvc-1.15.33.tgz",
|
||||
"integrity": "sha512-gtyvzSNR8DHKfFEA2uqb8Ld1myqi6uEg2jyeUq3ikn5ytYs7H8RpZYC8mdy4NXr8hfcdJfCLXPlYaqqfBXpoEQ==",
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
@@ -4793,9 +4793,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@swc/core-win32-x64-msvc": {
|
||||
"version": "1.15.32",
|
||||
"resolved": "https://registry.npmjs.org/@swc/core-win32-x64-msvc/-/core-win32-x64-msvc-1.15.32.tgz",
|
||||
"integrity": "sha512-gbc2bQ/T2CiR+w0OvcVKwLOFAcPZBvmWmolbwpg1E8UrpeC03DGtyMUApOHNXNYWA3SHFrYXCQtosrcMza1YFg==",
|
||||
"version": "1.15.33",
|
||||
"resolved": "https://registry.npmjs.org/@swc/core-win32-x64-msvc/-/core-win32-x64-msvc-1.15.33.tgz",
|
||||
"integrity": "sha512-d6fRqQSkJI+kmMEBWaDQ7TMl8+YjLYbwRUPZQ9DY0ORBJeTzOrG0twvfvlZ2xgw6jA0ScQKgfBm4vHLSLl5Hqg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -7475,9 +7475,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/country-flag-icons": {
|
||||
"version": "1.6.16",
|
||||
"resolved": "https://registry.npmjs.org/country-flag-icons/-/country-flag-icons-1.6.16.tgz",
|
||||
"integrity": "sha512-HxJVoE/aaZGcUMx1vK/u9430uKGB3ODZDDZJJOqVJQzoHk5v42c0fSp1rk4tDfyr1dVOJjwxRiaBPliBMo2Liw==",
|
||||
"version": "1.6.17",
|
||||
"resolved": "https://registry.npmjs.org/country-flag-icons/-/country-flag-icons-1.6.17.tgz",
|
||||
"integrity": "sha512-Nmik0289ZVZSI3c7mJR/amg6DyY7Z59b0sTFSKayeX72mHfPzCPJygwJs2pYgQULzuAyWeCUgwAJ+Dq8OR+JFw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/crelt": {
|
||||
@@ -10748,9 +10748,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/ip-address": {
|
||||
"version": "10.1.0",
|
||||
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.1.0.tgz",
|
||||
"integrity": "sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q==",
|
||||
"version": "10.2.0",
|
||||
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.2.0.tgz",
|
||||
"integrity": "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"engines": {
|
||||
@@ -19093,9 +19093,9 @@
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/yaml": {
|
||||
"version": "2.8.3",
|
||||
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.3.tgz",
|
||||
"integrity": "sha512-AvbaCLOO2Otw/lW5bmh9d/WEdcDFdQp2Z2ZUH3pX9U2ihyUY0nvLv7J6TrWowklRGPYbB/IuIMfYgxaCPg5Bpg==",
|
||||
"version": "2.8.4",
|
||||
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.4.tgz",
|
||||
"integrity": "sha512-ml/JPOj9fOQK8RNnWojA67GbZ0ApXAUlN2UQclwv2eVgTgn7O9gg9o7paZWKMp4g0H3nTLtS9LVzhkpOFIKzog==",
|
||||
"license": "ISC",
|
||||
"bin": {
|
||||
"yaml": "bin.mjs"
|
||||
@@ -19280,7 +19280,7 @@
|
||||
"@rollup/plugin-node-resolve": "^16.0.3",
|
||||
"@rollup/plugin-swc": "^0.4.0",
|
||||
"@swc/cli": "^0.8.1",
|
||||
"@swc/core": "^1.15.32",
|
||||
"@swc/core": "^1.15.33",
|
||||
"@webcomponents/template": "^1.5.1",
|
||||
"base64-js": "^1.5.1",
|
||||
"core-js": "^3.49.0",
|
||||
|
||||
@@ -142,7 +142,7 @@
|
||||
"chartjs-adapter-date-fns": "^3.0.0",
|
||||
"codemirror": "^6.0.2",
|
||||
"core-js": "^3.49.0",
|
||||
"country-flag-icons": "^1.6.16",
|
||||
"country-flag-icons": "^1.6.17",
|
||||
"date-fns": "^4.1.0",
|
||||
"deepmerge-ts": "^7.1.5",
|
||||
"dompurify": "^3.4.2",
|
||||
@@ -194,7 +194,7 @@
|
||||
"vitest": "^4.1.1",
|
||||
"webcomponent-qr-code": "^1.3.0",
|
||||
"wireit": "^0.14.12",
|
||||
"yaml": "^2.8.3"
|
||||
"yaml": "^2.8.4"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@esbuild/darwin-arm64": "^0.28.0",
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
"@rollup/plugin-node-resolve": "^16.0.3",
|
||||
"@rollup/plugin-swc": "^0.4.0",
|
||||
"@swc/cli": "^0.8.1",
|
||||
"@swc/core": "^1.15.32",
|
||||
"@swc/core": "^1.15.33",
|
||||
"@webcomponents/template": "^1.5.1",
|
||||
"base64-js": "^1.5.1",
|
||||
"core-js": "^3.49.0",
|
||||
|
||||
@@ -8,6 +8,7 @@ import "#elements/forms/Radio";
|
||||
import "#elements/forms/SearchSelect/index";
|
||||
import "#elements/utils/TimeDeltaHelp";
|
||||
import "./AdminSettingsFooterLinks.js";
|
||||
import "#elements/Alert";
|
||||
|
||||
import { akFooterLinkInput, IFooterLinkInput } from "./AdminSettingsFooterLinks.js";
|
||||
|
||||
@@ -287,6 +288,9 @@ export class AdminSettingsForm extends Form<SettingsRequest> {
|
||||
help=${msg(
|
||||
"When enabled, other flow tabs in a session will refresh upon a successful authentication.",
|
||||
)}
|
||||
.bighelp=${html`<ak-alert class="pf-c-radio__description" inline plain>
|
||||
${msg("This flag is deprecated.")}
|
||||
</ak-alert>`}
|
||||
>
|
||||
</ak-switch-input>
|
||||
<ak-switch-input
|
||||
|
||||
@@ -124,19 +124,22 @@ export class ApplicationWizardBindingsStep extends ApplicationWizardStep {
|
||||
order="order"
|
||||
.columns=${COLUMNS}
|
||||
.content=${[]}
|
||||
></ak-select-table>
|
||||
<ak-empty-state icon="pf-icon-module"
|
||||
><span>${msg("No bound policies.")}</span>
|
||||
<div slot="body">${msg("No policies are currently bound to this object.")}</div>
|
||||
<div slot="primary">
|
||||
<button
|
||||
@click=${() => this.onBindingEvent()}
|
||||
class="pf-c-button pf-m-primary"
|
||||
>
|
||||
${msg("Bind policy/group/user")}
|
||||
</button>
|
||||
</div>
|
||||
</ak-empty-state>
|
||||
>
|
||||
<ak-empty-state slot="empty-table" icon="pf-icon-module"
|
||||
><span>${msg("No bound policies.")}</span>
|
||||
<div slot="body">
|
||||
${msg("No policies are currently bound to this object.")}
|
||||
</div>
|
||||
<div slot="primary">
|
||||
<button
|
||||
@click=${() => this.onBindingEvent()}
|
||||
class="pf-c-button pf-m-primary"
|
||||
>
|
||||
${msg("Bind policy/group/user")}
|
||||
</button>
|
||||
</div>
|
||||
</ak-empty-state>
|
||||
</ak-select-table>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
|
||||
@@ -158,24 +158,33 @@ export class AuthenticatorWebAuthnStageForm extends BaseStageForm<AuthenticatorW
|
||||
<ak-radio
|
||||
.options=${[
|
||||
{
|
||||
label: msg("No preference is sent"),
|
||||
label: msg(
|
||||
"No preference: the browser may offer any available authenticator",
|
||||
),
|
||||
value: null,
|
||||
default: true,
|
||||
},
|
||||
{
|
||||
label: msg(
|
||||
"A non-removable authenticator, like TouchID or Windows Hello",
|
||||
"Platform: a non-removable authenticator built into the device, such as Touch ID, Face ID, or Windows Hello",
|
||||
),
|
||||
value: AuthenticatorAttachmentEnum.Platform,
|
||||
},
|
||||
{
|
||||
label: msg('A "roaming" authenticator, like a YubiKey'),
|
||||
label: msg(
|
||||
"Cross-platform: a roaming authenticator, such as a YubiKey or Google Titan",
|
||||
),
|
||||
value: AuthenticatorAttachmentEnum.CrossPlatform,
|
||||
},
|
||||
]}
|
||||
.value=${this.instance?.authenticatorAttachment}
|
||||
>
|
||||
</ak-radio>
|
||||
<p class="pf-c-form__helper-text">
|
||||
${msg(
|
||||
"Controls the authenticatorAttachment parameter sent to the browser during WebAuthn registration. If Hints are configured and this is left as 'No preference', a value is inferred from the selected hints for backward compatibility with older browsers.",
|
||||
)}
|
||||
</p>
|
||||
</ak-form-element-horizontal>
|
||||
<ak-form-element-horizontal label=${msg("Hints")} name="hints">
|
||||
<ak-dual-select-provider
|
||||
|
||||
@@ -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 {
|
||||
|
||||
62
web/src/admin/stages/invitation/wizard/InvitationWizard.ts
Normal file
62
web/src/admin/stages/invitation/wizard/InvitationWizard.ts
Normal file
@@ -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;
|
||||
}
|
||||
}
|
||||
31
web/src/admin/stages/invitation/wizard/types.ts
Normal file
31
web/src/admin/stages/invitation/wizard/types.ts
Normal file
@@ -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;
|
||||
}
|
||||
@@ -74,6 +74,10 @@ svg[id^="mermaid-svg-"] {
|
||||
}
|
||||
}
|
||||
|
||||
ak-alert + :is(h2, p) {
|
||||
padding-top: var(--pf-global--spacer--md);
|
||||
}
|
||||
|
||||
/* #region Dark Theme */
|
||||
|
||||
:host([theme="dark"]) {
|
||||
|
||||
@@ -211,9 +211,11 @@ export class SimpleTable
|
||||
);
|
||||
|
||||
return html`<tr role="presentation">
|
||||
<td role="presentation" colspan=${columnCount}>
|
||||
<td role="presentation" colspan=${columnCount + 1}>
|
||||
<div class="pf-l-bullseye">
|
||||
<ak-empty-state><span>${message}</span></ak-empty-state>
|
||||
<slot name="empty-table">
|
||||
<ak-empty-state><span>${message}</span></ak-empty-state>
|
||||
</slot>
|
||||
</div>
|
||||
</td>
|
||||
</tr>`;
|
||||
|
||||
@@ -3,6 +3,7 @@ import { checkObjectShallowEquality } from "#common/collections";
|
||||
import { AKElement } from "#elements/Base";
|
||||
import { asInvoker, type ModalTemplate } from "#elements/dialogs/invokers";
|
||||
import type { DialogInit, TransclusionElementConstructor } from "#elements/dialogs/shared";
|
||||
import { ElementConstructorBoundary } from "#elements/errors/boundaries";
|
||||
import type { LitPropertyRecord } from "#elements/types";
|
||||
import { isAKElementConstructor, StrictUnsafe } from "#elements/utils/unsafe";
|
||||
|
||||
@@ -159,10 +160,22 @@ export function lookupElementConstructor<T extends CustomElementConstructor>(
|
||||
tagName: string,
|
||||
registry: CustomElementRegistry = window.customElements,
|
||||
): T {
|
||||
if (!tagName) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.trace(
|
||||
"No tag name provided for lookup. Did this value come from a different version of authentik?",
|
||||
);
|
||||
|
||||
return ElementConstructorBoundary as unknown as T;
|
||||
}
|
||||
|
||||
const ElementConstructor = registry.get(tagName);
|
||||
|
||||
if (!ElementConstructor) {
|
||||
throw new TypeError(`No custom element defined for tag name: ${tagName}`);
|
||||
// eslint-disable-next-line no-console
|
||||
console.trace(`No custom element defined for tag name: ${tagName}`);
|
||||
|
||||
return ElementConstructorBoundary as unknown as T;
|
||||
}
|
||||
|
||||
return ElementConstructor as unknown as T;
|
||||
|
||||
41
web/src/elements/errors/boundaries.ts
Normal file
41
web/src/elements/errors/boundaries.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import { globalAK } from "#common/global";
|
||||
|
||||
import { AKElement } from "#elements/Base";
|
||||
import { SlottedTemplateResult } from "#elements/types";
|
||||
|
||||
import { CapabilitiesEnum } from "@goauthentik/api";
|
||||
|
||||
import { msg } from "@lit/localize";
|
||||
import { customElement } from "@lit/reactive-element/decorators/custom-element.js";
|
||||
import { html } from "lit-html";
|
||||
|
||||
import PFAlert from "@patternfly/patternfly/components/Alert/alert.css";
|
||||
|
||||
/**
|
||||
* A fallback element to render when a custom element fails to load, either due to a missing import,
|
||||
* or a version mismatch between the element's definition and its usage.
|
||||
*/
|
||||
@customElement("ak-element-missing")
|
||||
export class ElementConstructorBoundary extends AKElement {
|
||||
public styles = [PFAlert];
|
||||
|
||||
protected override render(): SlottedTemplateResult {
|
||||
const debug = globalAK().config.capabilities.includes(CapabilitiesEnum.CanDebug);
|
||||
|
||||
const description = debug
|
||||
? msg(
|
||||
"The element could not be loaded. This may be due to a missing import or a version mismatch.",
|
||||
)
|
||||
: msg(
|
||||
"An element could not be loaded. Please try refreshing the page or clearing your cache.",
|
||||
);
|
||||
|
||||
return html`<div class="pf-c-alert pf-m-danger" role="alert">
|
||||
<div class="pf-c-alert__icon">
|
||||
<i class="fas fa-exclamation-triangle" aria-hidden="true"></i>
|
||||
</div>
|
||||
<h4 class="pf-c-alert__title">${msg("Failed to load element")}</h4>
|
||||
<div class="pf-c-alert__description">${description}</div>
|
||||
</div>`;
|
||||
}
|
||||
}
|
||||
@@ -20,6 +20,14 @@
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* P5 puts a line separating the entries, but this looks odd in our stacked usage. Specifying
|
||||
* `.pf-m-stack` here also raises the specificity above the P4 default.
|
||||
*/
|
||||
.pf-m-stack label.pf-c-radio:not(:last-child) {
|
||||
--pf-c-radio--BoxShadowColor: transparent;
|
||||
}
|
||||
|
||||
.pf-c-radio__description {
|
||||
text-wrap: balance;
|
||||
text-wrap: pretty;
|
||||
|
||||
@@ -996,7 +996,9 @@ export abstract class Table<T extends object, D = T>
|
||||
* A simple pagination display, shown at both the top and bottom of the page.
|
||||
*/
|
||||
protected renderTablePagination(): SlottedTemplateResult {
|
||||
if (!this.paginated) return nothing;
|
||||
if (!this.paginated || !this.data || this.data?.pagination.totalPages < 2) {
|
||||
return nothing;
|
||||
}
|
||||
|
||||
const handler = (page: number) => {
|
||||
this.page = page;
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
|
||||
.empty-state-primary {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: var(--pf-global--spacer--sm);
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -386,7 +386,7 @@ export class IdentificationStage extends BaseStage<
|
||||
return html`<a
|
||||
href=${url}
|
||||
class="pf-c-button pf-m-secondary pf-m-block"
|
||||
ouiaId="passwordless"
|
||||
data-ouia-component-id="passwordless"
|
||||
>
|
||||
${msg("Use a security key")}
|
||||
</a> `;
|
||||
@@ -475,12 +475,12 @@ export class IdentificationStage extends BaseStage<
|
||||
${enrollUrl
|
||||
? html`<div class="pf-c-login__main-footer-band-item">
|
||||
${msg("Need an account?")}
|
||||
<a href="${enrollUrl}" ouiaId="enroll">${msg("Sign up.")}</a>
|
||||
<a href="${enrollUrl}" data-ouia-component-id="enroll">${msg("Sign up.")}</a>
|
||||
</div>`
|
||||
: nothing}
|
||||
${recoveryUrl
|
||||
? html`<div class="pf-c-login__main-footer-band-item">
|
||||
<a href="${recoveryUrl}" ouiaId="recovery"
|
||||
<a href="${recoveryUrl}" data-ouia-component-id="recovery"
|
||||
>${msg("Forgot username or password?")}</a
|
||||
>
|
||||
</div>`
|
||||
|
||||
@@ -51,12 +51,12 @@
|
||||
"@rspack/binding-darwin-arm64": "1.7.11",
|
||||
"@rspack/binding-linux-arm64-gnu": "1.7.11",
|
||||
"@rspack/binding-linux-x64-gnu": "1.7.11",
|
||||
"@swc/core-darwin-arm64": "1.15.32",
|
||||
"@swc/core-linux-arm64-gnu": "1.15.32",
|
||||
"@swc/core-linux-x64-gnu": "1.15.32",
|
||||
"@swc/html-darwin-arm64": "1.15.32",
|
||||
"@swc/html-linux-arm64-gnu": "1.15.32",
|
||||
"@swc/html-linux-x64-gnu": "1.15.32",
|
||||
"@swc/core-darwin-arm64": "1.15.33",
|
||||
"@swc/core-linux-arm64-gnu": "1.15.33",
|
||||
"@swc/core-linux-x64-gnu": "1.15.33",
|
||||
"@swc/html-darwin-arm64": "1.15.33",
|
||||
"@swc/html-linux-arm64-gnu": "1.15.33",
|
||||
"@swc/html-linux-x64-gnu": "1.15.33",
|
||||
"lightningcss-darwin-arm64": "1.32.0",
|
||||
"lightningcss-linux-arm64-gnu": "1.32.0",
|
||||
"lightningcss-linux-x64-gnu": "1.32.0"
|
||||
|
||||
60
website/package-lock.json
generated
60
website/package-lock.json
generated
@@ -41,12 +41,12 @@
|
||||
"@rspack/binding-darwin-arm64": "1.7.11",
|
||||
"@rspack/binding-linux-arm64-gnu": "1.7.11",
|
||||
"@rspack/binding-linux-x64-gnu": "1.7.11",
|
||||
"@swc/core-darwin-arm64": "1.15.32",
|
||||
"@swc/core-linux-arm64-gnu": "1.15.32",
|
||||
"@swc/core-linux-x64-gnu": "1.15.32",
|
||||
"@swc/html-darwin-arm64": "1.15.32",
|
||||
"@swc/html-linux-arm64-gnu": "1.15.32",
|
||||
"@swc/html-linux-x64-gnu": "1.15.32",
|
||||
"@swc/core-darwin-arm64": "1.15.33",
|
||||
"@swc/core-linux-arm64-gnu": "1.15.33",
|
||||
"@swc/core-linux-x64-gnu": "1.15.33",
|
||||
"@swc/html-darwin-arm64": "1.15.33",
|
||||
"@swc/html-linux-arm64-gnu": "1.15.33",
|
||||
"@swc/html-linux-x64-gnu": "1.15.33",
|
||||
"lightningcss-darwin-arm64": "1.32.0",
|
||||
"lightningcss-linux-arm64-gnu": "1.32.0",
|
||||
"lightningcss-linux-x64-gnu": "1.32.0"
|
||||
@@ -163,12 +163,12 @@
|
||||
"@rspack/binding-darwin-arm64": "1.7.11",
|
||||
"@rspack/binding-linux-arm64-gnu": "1.7.11",
|
||||
"@rspack/binding-linux-x64-gnu": "1.7.11",
|
||||
"@swc/core-darwin-arm64": "1.15.32",
|
||||
"@swc/core-linux-arm64-gnu": "1.15.32",
|
||||
"@swc/core-linux-x64-gnu": "1.15.32",
|
||||
"@swc/html-darwin-arm64": "1.15.32",
|
||||
"@swc/html-linux-arm64-gnu": "1.15.32",
|
||||
"@swc/html-linux-x64-gnu": "1.15.32",
|
||||
"@swc/core-darwin-arm64": "1.15.33",
|
||||
"@swc/core-linux-arm64-gnu": "1.15.33",
|
||||
"@swc/core-linux-x64-gnu": "1.15.33",
|
||||
"@swc/html-darwin-arm64": "1.15.33",
|
||||
"@swc/html-linux-arm64-gnu": "1.15.33",
|
||||
"@swc/html-linux-x64-gnu": "1.15.33",
|
||||
"lightningcss-darwin-arm64": "1.32.0",
|
||||
"lightningcss-linux-arm64-gnu": "1.32.0",
|
||||
"lightningcss-linux-x64-gnu": "1.32.0"
|
||||
@@ -6849,9 +6849,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@swc/core-darwin-arm64": {
|
||||
"version": "1.15.32",
|
||||
"resolved": "https://registry.npmjs.org/@swc/core-darwin-arm64/-/core-darwin-arm64-1.15.32.tgz",
|
||||
"integrity": "sha512-/YWMvJDPu+AAwuUsM2G+DNQ/7zhodURGzdQyewEqcvgklAdDHs3LwQmLLnyn6SJl8DT8UOxkbzK+D1PmPeelRg==",
|
||||
"version": "1.15.33",
|
||||
"resolved": "https://registry.npmjs.org/@swc/core-darwin-arm64/-/core-darwin-arm64-1.15.33.tgz",
|
||||
"integrity": "sha512-N+L0uXhuO7FIfzqwgxmzv0zIpV0qEp8wPX3QQs2p4atjMoywup2JTeDlXPw+z9pWJGCae3JjM+tZ6myclI+2gA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -6897,9 +6897,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@swc/core-linux-arm64-gnu": {
|
||||
"version": "1.15.32",
|
||||
"resolved": "https://registry.npmjs.org/@swc/core-linux-arm64-gnu/-/core-linux-arm64-gnu-1.15.32.tgz",
|
||||
"integrity": "sha512-oDzEkdl6D6BAWdMtU5KGO7y3HR5fJcvByNLyEk9+ugj8nP5Ovb7P4kBcStBXc4MPExFGQryehiINMlmY8HlclA==",
|
||||
"version": "1.15.33",
|
||||
"resolved": "https://registry.npmjs.org/@swc/core-linux-arm64-gnu/-/core-linux-arm64-gnu-1.15.33.tgz",
|
||||
"integrity": "sha512-TRJfnJbX3jqpxRDRoieMzRiCBS5jOmXNb3iQXmcgjFEHKLnAgK1RZRU8Cq1MsPqO4jAJp/ld1G4O3fXuxv85uw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -6964,9 +6964,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@swc/core-linux-x64-gnu": {
|
||||
"version": "1.15.32",
|
||||
"resolved": "https://registry.npmjs.org/@swc/core-linux-x64-gnu/-/core-linux-x64-gnu-1.15.32.tgz",
|
||||
"integrity": "sha512-ERsjfGcj6CBmj3vJnGDO8m8rTvw6RqMcWo1dogOtNx3/+/0+NNpJiXDobJrr1GwInI/BHAEkvSFIH6d2LqPcUQ==",
|
||||
"version": "1.15.33",
|
||||
"resolved": "https://registry.npmjs.org/@swc/core-linux-x64-gnu/-/core-linux-x64-gnu-1.15.33.tgz",
|
||||
"integrity": "sha512-mGTH0YxmUN+x6vRN/I6NOk5X0ogNktkwPnJ94IMvR7QjhRDwL0O8RXEDhyUM0YtwWrryBOqaJQBX4zruxEPRGw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -7127,9 +7127,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@swc/html-darwin-arm64": {
|
||||
"version": "1.15.32",
|
||||
"resolved": "https://registry.npmjs.org/@swc/html-darwin-arm64/-/html-darwin-arm64-1.15.32.tgz",
|
||||
"integrity": "sha512-WgY386nwyz24cTJ+Nztd4cKvfPJexLYAzurSYDmuYxS3HihWoTFZWMDomTfM8yr2UCi8SwW+zTNAWxJxUaKESg==",
|
||||
"version": "1.15.33",
|
||||
"resolved": "https://registry.npmjs.org/@swc/html-darwin-arm64/-/html-darwin-arm64-1.15.33.tgz",
|
||||
"integrity": "sha512-zyO6uMBfLyCh55wundAxKX+8P/f98ecuyir4VX6nTmn6y7x37ndB8f01LUrd9Tiq6eEAvDXLiqEUvuGjEc7Pmg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -7175,9 +7175,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@swc/html-linux-arm64-gnu": {
|
||||
"version": "1.15.32",
|
||||
"resolved": "https://registry.npmjs.org/@swc/html-linux-arm64-gnu/-/html-linux-arm64-gnu-1.15.32.tgz",
|
||||
"integrity": "sha512-gvlByySjNDWX2FUIGVBWOhd00rySz0AOydQpuXCK0ldYbFVMby9oXbp2JVmE5UsB6J4YZqZh4ajmmqCGvFHi4Q==",
|
||||
"version": "1.15.33",
|
||||
"resolved": "https://registry.npmjs.org/@swc/html-linux-arm64-gnu/-/html-linux-arm64-gnu-1.15.33.tgz",
|
||||
"integrity": "sha512-7tZ0IgmUslI9Extu/TpxJS0GjJoDx0j9zeq2cIidPdM/njSBpyRB7n4B292Q5WFVh7PcZl7WXqqqMczibQ27aA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -7242,9 +7242,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@swc/html-linux-x64-gnu": {
|
||||
"version": "1.15.32",
|
||||
"resolved": "https://registry.npmjs.org/@swc/html-linux-x64-gnu/-/html-linux-x64-gnu-1.15.32.tgz",
|
||||
"integrity": "sha512-IveuScZfAwDZEBs6pTvdG/MwGyMPuxp74l9ngp2PbUboVBIfUS894kATBaBuSBYXajZ4v4wqv01PGM81rUhGQg==",
|
||||
"version": "1.15.33",
|
||||
"resolved": "https://registry.npmjs.org/@swc/html-linux-x64-gnu/-/html-linux-x64-gnu-1.15.33.tgz",
|
||||
"integrity": "sha512-JDNb4Uq+7g+23QuOtwWnP0/EqztWIHFFdQdeBIS5zx83YBG2dYRMdPAjnHJWh2YRZxdepd8q6S9MUIxpSrouAg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
|
||||
@@ -39,12 +39,12 @@
|
||||
"@rspack/binding-darwin-arm64": "1.7.11",
|
||||
"@rspack/binding-linux-arm64-gnu": "1.7.11",
|
||||
"@rspack/binding-linux-x64-gnu": "1.7.11",
|
||||
"@swc/core-darwin-arm64": "1.15.32",
|
||||
"@swc/core-linux-arm64-gnu": "1.15.32",
|
||||
"@swc/core-linux-x64-gnu": "1.15.32",
|
||||
"@swc/html-darwin-arm64": "1.15.32",
|
||||
"@swc/html-linux-arm64-gnu": "1.15.32",
|
||||
"@swc/html-linux-x64-gnu": "1.15.32",
|
||||
"@swc/core-darwin-arm64": "1.15.33",
|
||||
"@swc/core-linux-arm64-gnu": "1.15.33",
|
||||
"@swc/core-linux-x64-gnu": "1.15.33",
|
||||
"@swc/html-darwin-arm64": "1.15.33",
|
||||
"@swc/html-linux-arm64-gnu": "1.15.33",
|
||||
"@swc/html-linux-x64-gnu": "1.15.33",
|
||||
"lightningcss-darwin-arm64": "1.32.0",
|
||||
"lightningcss-linux-arm64-gnu": "1.32.0",
|
||||
"lightningcss-linux-x64-gnu": "1.32.0"
|
||||
|
||||
Reference in New Issue
Block a user