Compare commits

..

54 Commits

Author SHA1 Message Date
Teffen Ellis
a440cd3669 Tidy. Fix colors. 2026-02-04 00:32:59 +01:00
Tana M Berry
d6b07d5348 more dewi and dominic edits 2026-02-03 11:28:58 -06:00
Tana M Berry
6f87b9c165 Merge branch 'main' into docs-first-steps 2026-02-03 10:55:49 -05:00
Simonyi Gergő
68f70a0953 core: ask for token duration on recovery link/email by admin (#19875)
* add translations to `ValidationError`s in user api

* deduplicate recovery buttons

* refactor `recovery_email`

* simplify request.brand call

* ask for token duration on recovery link/email by admin

* use `@validate` decorator for admin recovery

* stylize if/else

* return uniform error message on no `view_` permission

* clarify wording on email success
2026-02-03 16:48:51 +01:00
dependabot[bot]
ad6ce84e06 core: bump aws-cdk-lib from 2.236.0 to 2.237.0 (#19958)
Bumps [aws-cdk-lib](https://github.com/aws/aws-cdk) from 2.236.0 to 2.237.0.
- [Release notes](https://github.com/aws/aws-cdk/releases)
- [Changelog](https://github.com/aws/aws-cdk/blob/main/CHANGELOG.v2.alpha.md)
- [Commits](https://github.com/aws/aws-cdk/compare/v2.236.0...v2.237.0)

---
updated-dependencies:
- dependency-name: aws-cdk-lib
  dependency-version: 2.237.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-02-03 16:43:56 +01:00
dependabot[bot]
239f4a84a1 web: bump the storybook group across 1 directory with 5 updates (#19960)
Bumps the storybook group with 4 updates in the /web directory: [@storybook/addon-docs](https://github.com/storybookjs/storybook/tree/HEAD/code/addons/docs), [@storybook/addon-links](https://github.com/storybookjs/storybook/tree/HEAD/code/addons/links), [@storybook/web-components](https://github.com/storybookjs/storybook/tree/HEAD/code/renderers/web-components) and [@storybook/web-components-vite](https://github.com/storybookjs/storybook/tree/HEAD/code/frameworks/web-components-vite).


Updates `@storybook/addon-docs` from 10.2.3 to 10.2.4
- [Release notes](https://github.com/storybookjs/storybook/releases)
- [Changelog](https://github.com/storybookjs/storybook/blob/next/CHANGELOG.md)
- [Commits](https://github.com/storybookjs/storybook/commits/v10.2.4/code/addons/docs)

Updates `@storybook/addon-links` from 10.2.3 to 10.2.4
- [Release notes](https://github.com/storybookjs/storybook/releases)
- [Changelog](https://github.com/storybookjs/storybook/blob/next/CHANGELOG.md)
- [Commits](https://github.com/storybookjs/storybook/commits/v10.2.4/code/addons/links)

Updates `@storybook/web-components` from 10.2.3 to 10.2.4
- [Release notes](https://github.com/storybookjs/storybook/releases)
- [Changelog](https://github.com/storybookjs/storybook/blob/next/CHANGELOG.md)
- [Commits](https://github.com/storybookjs/storybook/commits/v10.2.4/code/renderers/web-components)

Updates `@storybook/web-components-vite` from 10.2.3 to 10.2.4
- [Release notes](https://github.com/storybookjs/storybook/releases)
- [Changelog](https://github.com/storybookjs/storybook/blob/next/CHANGELOG.md)
- [Commits](https://github.com/storybookjs/storybook/commits/v10.2.4/code/frameworks/web-components-vite)

Updates `storybook` from 10.2.3 to 10.2.4
- [Release notes](https://github.com/storybookjs/storybook/releases)
- [Changelog](https://github.com/storybookjs/storybook/blob/next/CHANGELOG.md)
- [Commits](https://github.com/storybookjs/storybook/commits/v10.2.4/code/core)

---
updated-dependencies:
- dependency-name: "@storybook/addon-docs"
  dependency-version: 10.2.4
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: storybook
- dependency-name: "@storybook/addon-links"
  dependency-version: 10.2.4
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: storybook
- dependency-name: "@storybook/web-components"
  dependency-version: 10.2.4
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: storybook
- dependency-name: "@storybook/web-components-vite"
  dependency-version: 10.2.4
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: storybook
- dependency-name: storybook
  dependency-version: 10.2.4
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: storybook
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-02-03 16:43:34 +01:00
dependabot[bot]
83b6112f8d core: bump library/nginx from c881927 to 7fe5dda in /website (#19961)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-02-03 16:33:51 +01:00
dependabot[bot]
a75c2fa77e core: bump gunicorn from 25.0.0 to 25.0.1 (#19959)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-02-03 16:33:28 +01:00
Marc 'risson' Schmitt
d76b5d804d core: bump goauthentik.io/api/v3 to 3.2026.2.0-rc1-1770129730 (#19973) 2026-02-03 15:11:51 +00:00
Jens L.
248756363a lifecycle: bump shm size (#19369)
Co-authored-by: Marc 'risson' Schmitt <marc.schmitt@risson.space>
2026-02-03 14:39:50 +00:00
Connor Peshek
ff87929dcf crypto: Add ED25519 and ED448 support to the certificate builder (#19465)
* Add ED25519 and ED448 support to the certificate builder.

* retain cert format for non ed certs.
2026-02-03 14:29:33 +01:00
Teffen Ellis
742472c60c web/admin: Register stage elements. Fix linter warnings (#19948)
* Register stage elements.

* Clean up warnings.

* Fix duplicate form actions.

* Normalize attribute casing.

* Fix permissions tab nesting.

* Fix ARIA warnings, click handlers on menus.

* Fix clipboard permissions on Safari.
2026-02-03 07:53:35 +00:00
dependabot[bot]
3b0fa0b076 web: bump knip from 5.82.1 to 5.83.0 in /web (#19962)
Bumps [knip](https://github.com/webpro-nl/knip/tree/HEAD/packages/knip) from 5.82.1 to 5.83.0.
- [Release notes](https://github.com/webpro-nl/knip/releases)
- [Commits](https://github.com/webpro-nl/knip/commits/knip@5.83.0/packages/knip)

---
updated-dependencies:
- dependency-name: knip
  dependency-version: 5.83.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-02-03 07:23:17 +00:00
authentik-automation[bot]
6d7afa44fe core, web: update translations (#19954)
Signed-off-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: authentik-automation[bot] <135050075+authentik-automation[bot]@users.noreply.github.com>
2026-02-03 07:58:08 +01:00
Dominic R
f1089bded8 web: refactor TOTP clipboard handlers and secret parsing (#19953)
* web: refactor TOTP clipboard handlers and secret parsing

* Clean up duplicate clipboard write functions. Flesh out labels.

* Fix token form ARIA.

* Skip model loading when form is hidden and viewport check is enabled.

- Fixes runtime error after changing forms which modify their own slug, such as tokens.

* Fix types, labels.

---------

Co-authored-by: Teffen Ellis <592134+GirlBossRush@users.noreply.github.com>
2026-02-03 07:19:21 +01:00
Simonyi Gergő
6de1affa22 root: fix NPM_VERSION in Makefile (#19844)
* root: fix NPM_VERSION in Makefile

Some of us only have `python` through `uv` :)

* move NPM_VERSION declaraton to after UV

* correctly assign `NPM_VERSION` in both uv and non-uv environments
2026-02-03 01:23:56 +01:00
Tana M Berry
bd72cb2815 Merge branch 'main' into docs-first-steps
Signed-off-by: Tana M Berry <tanamarieberry@yahoo.com>
2026-02-02 14:58:55 -05:00
Tana M Berry
c42dad58b0 more dewi and dominic edits 2026-02-02 13:56:32 -06:00
Tana M Berry
1e7e4d11d7 fix heading size 2026-02-02 13:48:59 -06:00
Tana M Berry
45cdf97f0e more link fixing 2026-02-02 13:44:36 -06:00
Tana M Berry
86e37201c1 tweak 2026-02-02 13:38:33 -06:00
Tana M Berry
ed8c373427 fix sidebar, tweaks 2026-02-02 13:36:56 -06:00
Tana M Berry
2e4abd410c add mermaid diagram, more links, more content 2026-02-02 13:35:02 -06:00
Tana M Berry
dbaff40c60 more edits, more TODOs done 2026-01-30 17:37:00 -06:00
Tana M Berry
f956ca1300 links, more tweaks 2026-01-28 21:31:35 -06:00
Tana M Berry
d990f53596 work on bindings docs that was needed for the first steps docs 2026-01-28 21:12:41 -06:00
Tana M Berry
dfbbbe03c2 changes from Jens 2026-01-28 11:56:13 -06:00
Jens Langhammer
4da35e143e fix some alignments
Signed-off-by: Jens Langhammer <jens@goauthentik.io>
2026-01-28 18:50:33 +01:00
Tana M Berry
bc06779f4f formatting fights on tips 2026-01-27 21:13:11 -06:00
Tana M Berry
727ba71280 few more dominic edits, tweaks 2026-01-27 20:46:58 -06:00
Tana M Berry
7480cb3e86 Merge branch 'main' into docs-first-steps 2026-01-27 21:39:26 -05:00
Tana M Berry
7027b7c4bf new styles, more content 2026-01-27 20:35:18 -06:00
Tana M Berry
b47016cd30 thanks Teffen 2026-01-27 09:34:27 -06:00
Tana M Berry
d18ab068c4 tweaks 2026-01-26 10:18:56 -06:00
Jens Langhammer
3b552530f1 a bunch of things
Signed-off-by: Jens Langhammer <jens@goauthentik.io>
2026-01-26 17:04:19 +01:00
Tana M Berry
cd46166c8e more dewi and dominic edits, links 2026-01-23 15:03:49 -06:00
Dewi Roberts
8e37908ecb Merge branch 'main' into docs-first-steps 2026-01-22 09:58:18 +00:00
Tana M Berry
9ec9f116b0 another fine Dominic edit 2026-01-21 18:01:23 -06:00
Tana M Berry
942a0029a0 dominic's eedits, more content 2026-01-21 17:56:00 -06:00
Tana M Berry
597a67075c conflicts? 2026-01-21 14:30:12 -06:00
Tana M Berry
b31517cd79 Merge branch 'main' into docs-first-steps 2026-01-21 15:19:28 -05:00
authentik-automation[bot]
a473d0618a Optimised images with calibre/image-actions 2026-01-14 23:10:00 +00:00
authentik-automation[bot]
7858ab874d Optimised images with calibre/image-actions 2026-01-14 23:09:33 +00:00
authentik-automation[bot]
0fd8069593 Optimised images with calibre/image-actions 2026-01-14 23:09:02 +00:00
Tana M Berry
9d210d92f3 Merge branch 'main' into docs-first-steps
Signed-off-by: Tana M Berry <tanamarieberry@yahoo.com>
2026-01-14 18:08:35 -05:00
Tana M Berry
9ce75e06be more content, green tips, other fixes 2026-01-14 16:52:18 -06:00
Tana M Berry
ee11194957 added Dewi ideas, more content, tweaks 2026-01-08 20:19:54 -06:00
Tana M Berry
297d4ca38b dewis edits 2026-01-07 16:13:41 -06:00
Tana M Berry
f120e179f4 more content, tweaks 2026-01-07 16:02:48 -06:00
Tana M Berry
c9a2405247 moved sections and retitled some 2026-01-06 17:12:38 -06:00
Tana M Berry
c58d7ff13e first draft 2026-01-06 17:02:10 -06:00
Tana M Berry
e8996c9b8d Merge branch 'main' into docs-first-steps 2026-01-06 08:35:44 -06:00
Tana M Berry
ec2b9ba0e0 moved email config up to match Docker 2026-01-05 17:32:47 -06:00
Tana M Berry
8bb702b95a new first steps docs 2026-01-05 17:30:27 -06:00
171 changed files with 1912 additions and 938 deletions

View File

@@ -5,7 +5,6 @@ SHELL := /usr/bin/env bash
PWD = $(shell pwd)
UID = $(shell id -u)
GID = $(shell id -g)
NPM_VERSION = $(shell python -m scripts.generate_semver)
PY_SOURCES = authentik packages tests scripts lifecycle .github
DOCKER_IMAGE ?= "authentik:test"
@@ -50,6 +49,14 @@ ifeq ($(UNAME_S),Darwin)
endif
endif
NPM_VERSION :=
UV_EXISTS := $(shell command -v uv 2> /dev/null)
ifdef UV_EXISTS
NPM_VERSION := $(shell $(UV) run python -m scripts.generate_semver)
else
NPM_VERSION = $(shell python -m scripts.generate_semver)
endif
all: lint-fix lint gen web test ## Lint, build, and test everything
HELP_WIDTH := $(shell grep -h '^[a-z][^ ]*:.*\#\#' $(MAKEFILE_LIST) 2>/dev/null | \

View File

@@ -30,7 +30,6 @@ from drf_spectacular.utils import (
extend_schema_field,
inline_serializer,
)
from guardian.shortcuts import get_objects_for_user
from rest_framework.authentication import SessionAuthentication
from rest_framework.decorators import action
from rest_framework.exceptions import ValidationError
@@ -42,6 +41,7 @@ from rest_framework.fields import (
IntegerField,
ListField,
SerializerMethodField,
UUIDField,
)
from rest_framework.permissions import IsAuthenticated
from rest_framework.request import Request
@@ -78,6 +78,7 @@ from authentik.core.models import (
TokenIntents,
User,
UserTypes,
default_token_duration,
)
from authentik.endpoints.connectors.agent.auth import AgentAuth
from authentik.events.models import Event, EventAction
@@ -87,6 +88,7 @@ from authentik.flows.planner import PLAN_CONTEXT_PENDING_USER, FlowPlanner
from authentik.flows.views.executor import QS_KEY_TOKEN
from authentik.lib.avatars import get_avatar
from authentik.lib.utils.reflection import ConditionalInheritance
from authentik.lib.utils.time import timedelta_from_string, timedelta_string_validator
from authentik.rbac.api.roles import RoleSerializer
from authentik.rbac.decorators import permission_required
from authentik.rbac.models import Role, get_permission_choices
@@ -238,14 +240,14 @@ class UserSerializer(ModelSerializer):
and self.instance.type == UserTypes.INTERNAL_SERVICE_ACCOUNT
and user_type != UserTypes.INTERNAL_SERVICE_ACCOUNT.value
):
raise ValidationError("Can't change internal service account to other user type.")
raise ValidationError(_("Can't change internal service account to other user type."))
if not self.instance and user_type == UserTypes.INTERNAL_SERVICE_ACCOUNT.value:
raise ValidationError("Setting a user to internal service account is not allowed.")
raise ValidationError(_("Setting a user to internal service account is not allowed."))
return user_type
def validate(self, attrs: dict) -> dict:
if self.instance and self.instance.type == UserTypes.INTERNAL_SERVICE_ACCOUNT:
raise ValidationError("Can't modify internal service account users")
raise ValidationError(_("Can't modify internal service account users"))
return super().validate(attrs)
class Meta:
@@ -397,6 +399,18 @@ class UserServiceAccountSerializer(PassiveSerializer):
)
class UserRecoveryLinkSerializer(PassiveSerializer):
"""Payload to create a recovery link"""
token_duration = CharField(required=False)
class UserRecoveryEmailSerializer(UserRecoveryLinkSerializer):
"""Payload to create and email a recovery link"""
email_stage = UUIDField()
class UsersFilter(FilterSet):
"""Filter for users"""
@@ -458,14 +472,14 @@ class UsersFilter(FilterSet):
try:
value = loads(value)
except ValueError:
raise ValidationError(detail="filter: failed to parse JSON") from None
raise ValidationError(_("filter: failed to parse JSON")) from None
if not isinstance(value, dict):
raise ValidationError(detail="filter: value must be key:value mapping")
raise ValidationError(_("filter: value must be key:value mapping"))
qs = {}
for key, _value in value.items():
qs[f"attributes__{key}"] = _value
try:
_ = len(queryset.filter(**qs))
__ = len(queryset.filter(**qs))
return queryset.filter(**qs)
except ValueError:
return queryset
@@ -543,14 +557,16 @@ class UserViewSet(
def list(self, request, *args, **kwargs):
return super().list(request, *args, **kwargs)
def _create_recovery_link(self, for_email=False) -> tuple[str, Token]:
def _create_recovery_link(
self, token_duration: str | None, for_email=False
) -> tuple[str, Token]:
"""Create a recovery link (when the current brand has a recovery flow set),
that can either be shown to an admin or sent to the user directly"""
brand: Brand = self.request._request.brand
brand: Brand = self.request.brand
# Check that there is a recovery flow, if not return an error
flow = brand.flow_recovery
if not flow:
raise ValidationError({"non_field_errors": "No recovery flow set."})
raise ValidationError({"non_field_errors": _("No recovery flow set.")})
user: User = self.get_object()
planner = FlowPlanner(flow)
planner.allow_empty_flows = True
@@ -564,11 +580,15 @@ class UserViewSet(
)
except FlowNonApplicableException:
raise ValidationError(
{"non_field_errors": "Recovery flow not applicable to user"}
{"non_field_errors": _("Recovery flow not applicable to user")}
) from None
_plan = FlowToken.pickle(plan)
if for_email:
_plan = pickle_flow_token_for_email(plan)
expires = default_token_duration()
if token_duration:
timedelta_string_validator(token_duration)
expires = now() + timedelta_from_string(token_duration)
token, __ = FlowToken.objects.update_or_create(
identifier=f"{user.uid}-password-reset",
defaults={
@@ -576,6 +596,7 @@ class UserViewSet(
"flow": flow,
"_plan": _plan,
"revoke_on_execution": not for_email,
"expires": expires,
},
)
querystring = urlencode({QS_KEY_TOKEN: token.key})
@@ -723,60 +744,60 @@ class UserViewSet(
@permission_required("authentik_core.reset_user_password")
@extend_schema(
request=UserRecoveryLinkSerializer,
responses={
"200": LinkSerializer(many=False),
},
request=None,
)
@action(detail=True, pagination_class=None, filter_backends=[], methods=["POST"])
def recovery(self, request: Request, pk: int) -> Response:
@validate(UserRecoveryLinkSerializer)
def recovery(self, request: Request, pk: int, body: UserRecoveryLinkSerializer) -> Response:
"""Create a temporary link that a user can use to recover their account"""
link, _ = self._create_recovery_link()
link, _ = self._create_recovery_link(
token_duration=body.validated_data.get("token_duration")
)
return Response({"link": link})
@permission_required("authentik_core.reset_user_password")
@extend_schema(
parameters=[
OpenApiParameter(
name="email_stage",
location=OpenApiParameter.QUERY,
type=OpenApiTypes.STR,
required=True,
)
],
request=UserRecoveryEmailSerializer,
responses={
"204": OpenApiResponse(description="Successfully sent recover email"),
},
request=None,
)
@action(detail=True, pagination_class=None, filter_backends=[], methods=["POST"])
def recovery_email(self, request: Request, pk: int) -> Response:
@validate(UserRecoveryEmailSerializer)
def recovery_email(
self, request: Request, pk: int, body: UserRecoveryEmailSerializer
) -> Response:
"""Send an email with a temporary link that a user can use to recover their account"""
for_user: User = self.get_object()
if for_user.email == "":
email_error_message = _("User does not have an email address set.")
stage_error_message = _("Email stage not found.")
user: User = self.get_object()
if not user.email:
LOGGER.debug("User doesn't have an email address")
raise ValidationError({"non_field_errors": "User does not have an email address set."})
link, token = self._create_recovery_link(for_email=True)
# Lookup the email stage to assure the current user can access it
stages = get_objects_for_user(
request.user, "authentik_stages_email.view_emailstage"
).filter(pk=request.query_params.get("email_stage"))
if not stages.exists():
LOGGER.debug("Email stage does not exist/user has no permissions")
raise ValidationError({"non_field_errors": "Email stage does not exist."})
email_stage: EmailStage = stages.first()
raise ValidationError({"non_field_errors": email_error_message})
if not (stage := EmailStage.objects.filter(pk=body.validated_data["email_stage"]).first()):
LOGGER.debug("Email stage does not exist")
raise ValidationError({"non_field_errors": stage_error_message})
if not request.user.has_perm("authentik_stages_email.view_emailstage", stage):
LOGGER.debug("User has no view access to email stage")
raise ValidationError({"non_field_errors": stage_error_message})
link, token = self._create_recovery_link(
token_duration=body.validated_data.get("token_duration"), for_email=True
)
message = TemplateEmailMessage(
subject=_(email_stage.subject),
to=[(for_user.name, for_user.email)],
template_name=email_stage.template,
language=for_user.locale(request),
subject=_(stage.subject),
to=[(user.name, user.email)],
template_name=stage.template,
language=user.locale(request),
template_context={
"url": link,
"user": for_user,
"user": user,
"expires": token.expires,
},
)
send_mails(email_stage, message)
send_mails(stage, message)
return Response(status=204)
@permission_required("authentik_core.impersonate")

View File

@@ -1,9 +1,10 @@
"""Test Users API"""
from datetime import datetime
from datetime import datetime, timedelta
from json import loads
from django.urls.base import reverse
from django.utils.timezone import now
from rest_framework.test import APITestCase
from authentik.brands.models import Brand
@@ -127,13 +128,62 @@ class TestUsersAPI(APITestCase):
)
self.assertEqual(response.status_code, 200)
def test_recovery_duration(self):
"""Test user recovery token duration"""
Token.objects.all().delete()
flow = create_test_flow(
FlowDesignation.RECOVERY,
authentication=FlowAuthenticationRequirement.REQUIRE_UNAUTHENTICATED,
)
brand: Brand = create_test_brand()
brand.flow_recovery = flow
brand.save()
self.client.force_login(self.admin)
response = self.client.post(
reverse("authentik_api:user-recovery", kwargs={"pk": self.user.pk}),
data={"token_duration": "days=33"},
)
self.assertEqual(response.status_code, 200)
expires = Token.objects.first().expires
expected_expires = now() + timedelta(days=33)
self.assertTrue(timedelta(minutes=-1) < expected_expires - expires < timedelta(minutes=1))
def test_recovery_duration_update(self):
"""Test user recovery token duration update"""
Token.objects.all().delete()
flow = create_test_flow(
FlowDesignation.RECOVERY,
authentication=FlowAuthenticationRequirement.REQUIRE_UNAUTHENTICATED,
)
brand: Brand = create_test_brand()
brand.flow_recovery = flow
brand.save()
self.client.force_login(self.admin)
response = self.client.post(
reverse("authentik_api:user-recovery", kwargs={"pk": self.user.pk}),
data={"token_duration": "days=33"},
)
self.assertEqual(response.status_code, 200)
expires = Token.objects.first().expires
expected_expires = now() + timedelta(days=33)
self.assertTrue(timedelta(minutes=-1) < expected_expires - expires < timedelta(minutes=1))
response = self.client.post(
reverse("authentik_api:user-recovery", kwargs={"pk": self.user.pk}),
data={"token_duration": "days=66"},
)
expires = Token.objects.first().expires
expected_expires = now() + timedelta(days=66)
self.assertTrue(timedelta(minutes=-1) < expected_expires - expires < timedelta(minutes=1))
def test_recovery_email_no_flow(self):
"""Test user recovery link (no recovery flow set)"""
self.client.force_login(self.admin)
self.user.email = ""
self.user.save()
stage = EmailStage.objects.create(name="email")
response = self.client.post(
reverse("authentik_api:user-recovery-email", kwargs={"pk": self.user.pk})
reverse("authentik_api:user-recovery-email", kwargs={"pk": self.user.pk}),
data={"email_stage": stage.pk},
)
self.assertEqual(response.status_code, 400)
self.assertJSONEqual(
@@ -142,7 +192,8 @@ class TestUsersAPI(APITestCase):
self.user.email = "foo@bar.baz"
self.user.save()
response = self.client.post(
reverse("authentik_api:user-recovery-email", kwargs={"pk": self.user.pk})
reverse("authentik_api:user-recovery-email", kwargs={"pk": self.user.pk}),
data={"email_stage": stage.pk},
)
self.assertEqual(response.status_code, 400)
self.assertJSONEqual(response.content, {"non_field_errors": "No recovery flow set."})
@@ -160,7 +211,7 @@ class TestUsersAPI(APITestCase):
reverse("authentik_api:user-recovery-email", kwargs={"pk": self.user.pk})
)
self.assertEqual(response.status_code, 400)
self.assertJSONEqual(response.content, {"non_field_errors": "Email stage does not exist."})
self.assertJSONEqual(response.content, {"email_stage": ["This field is required."]})
def test_recovery_email(self):
"""Test user recovery link"""
@@ -178,8 +229,8 @@ class TestUsersAPI(APITestCase):
reverse(
"authentik_api:user-recovery-email",
kwargs={"pk": self.user.pk},
)
+ f"?email_stage={stage.pk}"
),
data={"email_stage": stage.pk},
)
self.assertEqual(response.status_code, 204)

View File

@@ -7,6 +7,8 @@ from cryptography import x509
from cryptography.hazmat.backends import default_backend
from cryptography.hazmat.primitives import hashes, serialization
from cryptography.hazmat.primitives.asymmetric import ec, rsa
from cryptography.hazmat.primitives.asymmetric.ed448 import Ed448PrivateKey
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
from cryptography.hazmat.primitives.asymmetric.types import PrivateKeyTypes
from cryptography.x509.oid import NameOID
from django.db import models
@@ -21,6 +23,8 @@ class PrivateKeyAlg(models.TextChoices):
RSA = "rsa", _("rsa")
ECDSA = "ecdsa", _("ecdsa")
ED25519 = "ed25519", _("Ed25519")
ED448 = "ed448", _("Ed448")
class CertificateBuilder:
@@ -56,6 +60,10 @@ class CertificateBuilder:
return rsa.generate_private_key(
public_exponent=65537, key_size=4096, backend=default_backend()
)
if self.alg == PrivateKeyAlg.ED25519:
return Ed25519PrivateKey.generate()
if self.alg == PrivateKeyAlg.ED448:
return Ed448PrivateKey.generate()
raise ValueError(f"Invalid alg: {self.alg}")
def build(
@@ -98,18 +106,25 @@ class CertificateBuilder:
self.__builder = self.__builder.add_extension(
x509.SubjectAlternativeName(alt_names), critical=True
)
algo = hashes.SHA256()
# EdDSA doesn't take a hash algorithm
if isinstance(self.__private_key, (Ed25519PrivateKey | Ed448PrivateKey)):
algo = None
self.__certificate = self.__builder.sign(
private_key=self.__private_key,
algorithm=hashes.SHA256(),
algorithm=algo,
backend=default_backend(),
)
@property
def private_key(self):
"""Return private key in PEM format"""
format = serialization.PrivateFormat.TraditionalOpenSSL
if isinstance(self.__private_key, (Ed25519PrivateKey | Ed448PrivateKey)):
format = serialization.PrivateFormat.PKCS8
return self.__private_key.private_bytes(
encoding=serialization.Encoding.PEM,
format=serialization.PrivateFormat.TraditionalOpenSSL,
format=format,
encryption_algorithm=serialization.NoEncryption(),
).decode("utf-8")

View File

@@ -7,8 +7,6 @@ from rest_framework.fields import (
)
from authentik.api.v3.config import ConfigSerializer, ConfigView
from authentik.brands.api import CurrentBrandSerializer
from authentik.brands.models import Brand
from authentik.core.api.utils import PassiveSerializer
from authentik.crypto.apps import MANAGED_KEY
from authentik.crypto.models import CertificateKeyPair
@@ -39,7 +37,6 @@ class AgentConfigSerializer(PassiveSerializer):
system_config = SerializerMethodField()
license_status = SerializerMethodField(required=False, allow_null=True)
brand = SerializerMethodField(required=False, allow_null=True)
def get_device_id(self, instance: AgentConnector) -> str:
device: Device = self.context["device"]
@@ -73,10 +70,6 @@ class AgentConfigSerializer(PassiveSerializer):
except ModuleNotFoundError:
return None
def get_brand(self, instance: AgentConnector) -> CurrentBrandSerializer:
brand: Brand = self.context["request"]._request.brand
return CurrentBrandSerializer(brand, context=self.context).data
class EnrollSerializer(PassiveSerializer):

2
go.mod
View File

@@ -30,7 +30,7 @@ require (
github.com/spf13/cobra v1.10.2
github.com/stretchr/testify v1.11.1
github.com/wwt/guac v1.3.2
goauthentik.io/api/v3 v3.2026020.17-0.20260126165226-52b0b9497497
goauthentik.io/api/v3 v3.2026020.17-0.20260203144237-cf0a7b7393e7
golang.org/x/exp v0.0.0-20230210204819-062eb4c674ab
golang.org/x/oauth2 v0.34.0
golang.org/x/sync v0.19.0

2
go.sum
View File

@@ -216,6 +216,8 @@ go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
goauthentik.io/api/v3 v3.2026020.17-0.20260126165226-52b0b9497497 h1:uebnevXt0MnVIdmBPh39hCggT5Mz/DW8diDvv1n9W50=
goauthentik.io/api/v3 v3.2026020.17-0.20260126165226-52b0b9497497/go.mod h1:uYa+yGMglhJy8ymyUQ8KQiJjOb3UZTuPQ24Ot2s9BCo=
goauthentik.io/api/v3 v3.2026020.17-0.20260203144237-cf0a7b7393e7 h1:0dDYUvv3LXNgYgY0uSpws78J0EPBWGyk6hw45OZwFmY=
goauthentik.io/api/v3 v3.2026020.17-0.20260203144237-cf0a7b7393e7/go.mod h1:uYa+yGMglhJy8ymyUQ8KQiJjOb3UZTuPQ24Ot2s9BCo=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc=

View File

@@ -36,6 +36,7 @@ services:
- ${COMPOSE_PORT_HTTP:-9000}:9000
- ${COMPOSE_PORT_HTTPS:-9443}:9443
restart: unless-stopped
shm_size: 512mb
volumes:
- ./data:/data
- ./custom-templates:/templates
@@ -54,6 +55,7 @@ services:
AUTHENTIK_SECRET_KEY: ${AUTHENTIK_SECRET_KEY:?secret key required}
image: ${AUTHENTIK_IMAGE:-ghcr.io/goauthentik/server}:${AUTHENTIK_TAG:-2026.2.0-rc1}
restart: unless-stopped
shm_size: 512mb
user: root
volumes:
- /var/run/docker.sock:/var/run/docker.sock

View File

@@ -39,7 +39,7 @@ dependencies = [
"geopy==2.4.1",
"google-api-python-client==2.188.0",
"gssapi==1.11.1",
"gunicorn==25.0.0",
"gunicorn==25.0.1",
"jsonpatch==1.33",
"jwcrypto==1.5.6",
"kubernetes==35.0.0",
@@ -77,7 +77,7 @@ dependencies = [
[dependency-groups]
dev = [
"aws-cdk-lib==2.236.0",
"aws-cdk-lib==2.237.0",
"bandit==1.9.3",
"black==26.1.0",
"bpython==0.26",

View File

@@ -4459,6 +4459,11 @@ paths:
required: true
tags:
- core
requestBody:
content:
application/json:
schema:
$ref: '#/components/schemas/UserRecoveryLinkRequest'
security:
- authentik: []
responses:
@@ -4478,11 +4483,6 @@ paths:
description: Send an email with a temporary link that a user can use to recover
their account
parameters:
- in: query
name: email_stage
schema:
type: string
required: true
- in: path
name: id
schema:
@@ -4491,6 +4491,12 @@ paths:
required: true
tags:
- core
requestBody:
content:
application/json:
schema:
$ref: '#/components/schemas/UserRecoveryEmailRequest'
required: true
security:
- authentik: []
responses:
@@ -33466,15 +33472,9 @@ components:
- $ref: '#/components/schemas/LicenseStatusEnum'
readOnly: true
nullable: true
brand:
allOf:
- $ref: '#/components/schemas/CurrentBrand'
readOnly: true
nullable: true
required:
- auth_terminate_session_on_expiry
- authorization_flow
- brand
- device_id
- jwks_auth
- jwks_challenge
@@ -33671,6 +33671,8 @@ components:
enum:
- rsa
- ecdsa
- ed25519
- ed448
type: string
App:
type: object
@@ -56522,6 +56524,25 @@ components:
- plex_token
- source
- user
UserRecoveryEmailRequest:
type: object
description: Payload to create and email a recovery link
properties:
token_duration:
type: string
minLength: 1
email_stage:
type: string
format: uuid
required:
- email_stage
UserRecoveryLinkRequest:
type: object
description: Payload to create a recovery link
properties:
token_duration:
type: string
minLength: 1
UserRequest:
type: object
description: User Serializer

View File

@@ -41,6 +41,7 @@ base = {
"AUTHENTIK_POSTGRESQL__USER": "${PG_USER:-authentik}",
"AUTHENTIK_SECRET_KEY": "${AUTHENTIK_SECRET_KEY:?secret key required}",
},
"shm_size": "512mb",
"image": authentik_image,
"ports": ["${COMPOSE_PORT_HTTP:-9000}:9000", "${COMPOSE_PORT_HTTPS:-9443}:9443"],
"restart": "unless-stopped",
@@ -59,6 +60,7 @@ base = {
"AUTHENTIK_POSTGRESQL__USER": "${PG_USER:-authentik}",
"AUTHENTIK_SECRET_KEY": "${AUTHENTIK_SECRET_KEY:?secret key required}",
},
"shm_size": "512mb",
"image": authentik_image,
"restart": "unless-stopped",
"user": "root",

16
uv.lock generated
View File

@@ -368,7 +368,7 @@ requires-dist = [
{ name = "geopy", specifier = "==2.4.1" },
{ name = "google-api-python-client", specifier = "==2.188.0" },
{ name = "gssapi", specifier = "==1.11.1" },
{ name = "gunicorn", specifier = "==25.0.0" },
{ name = "gunicorn", specifier = "==25.0.1" },
{ name = "jsonpatch", specifier = "==1.33" },
{ name = "jwcrypto", specifier = "==1.5.6" },
{ name = "kubernetes", specifier = "==35.0.0" },
@@ -406,7 +406,7 @@ requires-dist = [
[package.metadata.requires-dev]
dev = [
{ name = "aws-cdk-lib", specifier = "==2.236.0" },
{ name = "aws-cdk-lib", specifier = "==2.237.0" },
{ name = "bandit", specifier = "==1.9.3" },
{ name = "black", specifier = "==26.1.0" },
{ name = "bpython", specifier = "==0.26" },
@@ -517,7 +517,7 @@ wheels = [
[[package]]
name = "aws-cdk-lib"
version = "2.236.0"
version = "2.237.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "aws-cdk-asset-awscli-v1" },
@@ -528,9 +528,9 @@ dependencies = [
{ name = "publication" },
{ name = "typeguard" },
]
sdist = { url = "https://files.pythonhosted.org/packages/ce/6d/2b54697444a806257f19ed603ef34ff71b6c3beb6f916587087ffb016ff5/aws_cdk_lib-2.236.0.tar.gz", hash = "sha256:1ed9f3798101d3271fd219bc101b9eab41dead3a25dde516a3c16b8274e0e77a", size = 47209293, upload-time = "2026-01-23T17:39:34.624Z" }
sdist = { url = "https://files.pythonhosted.org/packages/fd/dc/d8f8a177b586b7724a6bf72d7273fefee6642e956816a6c6d972548a2d47/aws_cdk_lib-2.237.0.tar.gz", hash = "sha256:82b0d9880c2352ea71d9e62e4d55331150c95795e1c9429b7ce2bd47cda8ff9d", size = 47318762, upload-time = "2026-02-02T13:44:05.471Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/53/82/6c47d68033aab1e778e226477d2863663ee59bac4345560a83f34f737e77/aws_cdk_lib-2.236.0-py3-none-any.whl", hash = "sha256:b724c1313a184ce5d62ff63f0594a1a4e12d098f7036fe07e7557a73229d0037", size = 47857717, upload-time = "2026-01-23T17:38:45.327Z" },
{ url = "https://files.pythonhosted.org/packages/69/63/ff0f4df41a8c29266e3960269b7b1dee4c11ae68a897d6c7f90b6a1cd32d/aws_cdk_lib-2.237.0-py3-none-any.whl", hash = "sha256:10e60c9060c9b461234afcc2932273a08033e2b796ca7f732b64a7be40d295d3", size = 47970102, upload-time = "2026-02-02T13:43:27.586Z" },
]
[[package]]
@@ -1748,14 +1748,14 @@ wheels = [
[[package]]
name = "gunicorn"
version = "25.0.0"
version = "25.0.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "packaging" },
]
sdist = { url = "https://files.pythonhosted.org/packages/46/4d/74e685d22a7d8dcc920e4e84da2842c057141236757143e1c167e7bd64df/gunicorn-25.0.0.tar.gz", hash = "sha256:4b1ab820ffc316352da7146005a9e66044a131434b17a7d2a9fb0604f1268864", size = 9618408, upload-time = "2026-02-01T13:34:59.463Z" }
sdist = { url = "https://files.pythonhosted.org/packages/9e/83/e8327358129ca4dffd4fa6b6004aa5085dc80e913dec9b253401d6bd23ad/gunicorn-25.0.1.tar.gz", hash = "sha256:573e053aa950246e307ea908bd7ddce1870d41a40aec0c935938c586f0b9b946", size = 9693127, upload-time = "2026-02-02T13:34:05.767Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/bf/9e/1be849edbea786424848e206e38a8b6025d140b7b9430037c5dc91e8a83f/gunicorn-25.0.0-py3-none-any.whl", hash = "sha256:b9b88f0ebc5a0fcd1e9e29a1f045f56f5402c6423ee3e3f675f679d0db5345d1", size = 169675, upload-time = "2026-02-01T13:34:54.617Z" },
{ url = "https://files.pythonhosted.org/packages/e0/dc/f1da097b7e0de5cd7552c10667305879093125cd62ff7372ad07d184ed8f/gunicorn-25.0.1-py3-none-any.whl", hash = "sha256:23cbe968c6ae3c8efc3d118c8353fa0763efc2102d89d0d3cea696cede7ff6b1", size = 169961, upload-time = "2026-02-02T13:34:02.744Z" },
]
[[package]]

88
web/package-lock.json generated
View File

@@ -44,10 +44,10 @@
"@patternfly/patternfly": "^4.224.2",
"@playwright/test": "^1.58.1",
"@sentry/browser": "^10.38.0",
"@storybook/addon-docs": "^10.2.3",
"@storybook/addon-links": "^10.2.3",
"@storybook/web-components": "^10.2.3",
"@storybook/web-components-vite": "^10.2.3",
"@storybook/addon-docs": "^10.2.4",
"@storybook/addon-links": "^10.2.4",
"@storybook/web-components": "^10.2.4",
"@storybook/web-components-vite": "^10.2.4",
"@types/codemirror": "^5.60.17",
"@types/grecaptcha": "^3.0.9",
"@types/guacamole-common-js": "^1.5.5",
@@ -77,7 +77,7 @@
"globals": "^17.3.0",
"guacamole-common-js": "^1.5.0",
"hastscript": "^9.0.1",
"knip": "^5.82.1",
"knip": "^5.83.0",
"lex": "^2025.11.0",
"lit": "^3.3.2",
"lit-analyzer": "^2.0.3",
@@ -3188,15 +3188,15 @@
"license": "MIT"
},
"node_modules/@storybook/addon-docs": {
"version": "10.2.3",
"resolved": "https://registry.npmjs.org/@storybook/addon-docs/-/addon-docs-10.2.3.tgz",
"integrity": "sha512-IPprt2qp4HN1uyE1Ki1sH0ZOE5B6z5sKzEMfrKMGokYKYk/AAJVfSiVIKju3q525GrBFlNhRW2+fB4pQfklv2w==",
"version": "10.2.4",
"resolved": "https://registry.npmjs.org/@storybook/addon-docs/-/addon-docs-10.2.4.tgz",
"integrity": "sha512-FzscAmdBiOGnGrxiEM+8eTg43kjqgjLfObg+lbJVRR/a0DmZ3xfAPNB0+VKYQbN0FacNcWLM9LZ/7U0hRBPBnQ==",
"license": "MIT",
"dependencies": {
"@mdx-js/react": "^3.0.0",
"@storybook/csf-plugin": "10.2.3",
"@storybook/csf-plugin": "10.2.4",
"@storybook/icons": "^2.0.1",
"@storybook/react-dom-shim": "10.2.3",
"@storybook/react-dom-shim": "10.2.4",
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"ts-dedent": "^2.0.0"
@@ -3206,13 +3206,13 @@
"url": "https://opencollective.com/storybook"
},
"peerDependencies": {
"storybook": "^10.2.3"
"storybook": "^10.2.4"
}
},
"node_modules/@storybook/addon-links": {
"version": "10.2.3",
"resolved": "https://registry.npmjs.org/@storybook/addon-links/-/addon-links-10.2.3.tgz",
"integrity": "sha512-ewOUga9zhcGQRGTTl7PyaV8kwLL4Jj1oeXWF2fq4fx+Fhzcn+d99gu3uV+zrGZa1gueBIRwf+p6NJTO//xSVUw==",
"version": "10.2.4",
"resolved": "https://registry.npmjs.org/@storybook/addon-links/-/addon-links-10.2.4.tgz",
"integrity": "sha512-4ifXpDmCmDyWS6LPr5KYsriJBMX46G35ZzYeQQdMks4OaXjZlEjL07fgxmH6Jen9ijDNqWlqWAMX/NVo3QUxUg==",
"license": "MIT",
"dependencies": {
"@storybook/global": "^5.0.0"
@@ -3223,7 +3223,7 @@
},
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"storybook": "^10.2.3"
"storybook": "^10.2.4"
},
"peerDependenciesMeta": {
"react": {
@@ -3232,12 +3232,12 @@
}
},
"node_modules/@storybook/builder-vite": {
"version": "10.2.3",
"resolved": "https://registry.npmjs.org/@storybook/builder-vite/-/builder-vite-10.2.3.tgz",
"integrity": "sha512-sKSccERL23gqC+nkTD+4io3ZF8vvNXkNiSi16X6BC29sGsbgWohw3Nv6tIGwVkIlQh0b1z24EQgXYWbdqivHGw==",
"version": "10.2.4",
"resolved": "https://registry.npmjs.org/@storybook/builder-vite/-/builder-vite-10.2.4.tgz",
"integrity": "sha512-/hcT1xj3CL5GkJ5v5/EguZdttDwNE6weNXK7vKzp034tnGcLycOossDsTiUQkBowSL+Ylc8aKj+ZgvddPNfOig==",
"license": "MIT",
"dependencies": {
"@storybook/csf-plugin": "10.2.3",
"@storybook/csf-plugin": "10.2.4",
"ts-dedent": "^2.0.0"
},
"funding": {
@@ -3245,14 +3245,14 @@
"url": "https://opencollective.com/storybook"
},
"peerDependencies": {
"storybook": "^10.2.3",
"storybook": "^10.2.4",
"vite": "^5.0.0 || ^6.0.0 || ^7.0.0"
}
},
"node_modules/@storybook/csf-plugin": {
"version": "10.2.3",
"resolved": "https://registry.npmjs.org/@storybook/csf-plugin/-/csf-plugin-10.2.3.tgz",
"integrity": "sha512-/b/C8C40ukzXs3Xauud2+yOJqwBdOkADfRtJ9O4TzrhftzkEdqsNI03xXZySeh7eXW8eI3Vq4t75Ljuj27Xytw==",
"version": "10.2.4",
"resolved": "https://registry.npmjs.org/@storybook/csf-plugin/-/csf-plugin-10.2.4.tgz",
"integrity": "sha512-kupPQEV+4N9mzsZHYaokvhO/KHBjYdWda9PNmPQwy0TR7r2mzthgaNH72TjmgN1L6DIbsuyOG1wtczcPJn4+Jg==",
"license": "MIT",
"dependencies": {
"unplugin": "^2.3.5"
@@ -3264,7 +3264,7 @@
"peerDependencies": {
"esbuild": "*",
"rollup": "*",
"storybook": "^10.2.3",
"storybook": "^10.2.4",
"vite": "*",
"webpack": "*"
},
@@ -3300,9 +3300,9 @@
}
},
"node_modules/@storybook/react-dom-shim": {
"version": "10.2.3",
"resolved": "https://registry.npmjs.org/@storybook/react-dom-shim/-/react-dom-shim-10.2.3.tgz",
"integrity": "sha512-xMZXvjfQCsmzOTqFCRQ1/gxs//jDGLlnmBCikH4NSGPPogRPaNUkxgdNjOResd6pB+G3ZYAOspJkmGEEbq8dVw==",
"version": "10.2.4",
"resolved": "https://registry.npmjs.org/@storybook/react-dom-shim/-/react-dom-shim-10.2.4.tgz",
"integrity": "sha512-i22OtrZ7GeZPt/odLf0vqyDhRSKyaLsHkkKSBcANQfzRRnBZmiz2FchOtWm9uvoDWybQsTruZq7kTdtpEhwyGw==",
"license": "MIT",
"funding": {
"type": "opencollective",
@@ -3311,13 +3311,13 @@
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"storybook": "^10.2.3"
"storybook": "^10.2.4"
}
},
"node_modules/@storybook/web-components": {
"version": "10.2.3",
"resolved": "https://registry.npmjs.org/@storybook/web-components/-/web-components-10.2.3.tgz",
"integrity": "sha512-yrypL1aEQR6b9HQYX+xDCXZ05syy+RrYBe6e8KGaL2e3w9hdmB3Zb4SKGmaZsTTkY6UuSeLYG7Rk5IK6SyfhwA==",
"version": "10.2.4",
"resolved": "https://registry.npmjs.org/@storybook/web-components/-/web-components-10.2.4.tgz",
"integrity": "sha512-UotANIirYETFuK4IkFLGnmBzTfHsK9rGTXi74IdSPkbKTFCg7b/vpd90f9oe/zjUpkNPjrZivkxfaUirm78uyw==",
"license": "MIT",
"dependencies": {
"@storybook/global": "^5.0.0",
@@ -3330,24 +3330,24 @@
},
"peerDependencies": {
"lit": "^2.0.0 || ^3.0.0",
"storybook": "^10.2.3"
"storybook": "^10.2.4"
}
},
"node_modules/@storybook/web-components-vite": {
"version": "10.2.3",
"resolved": "https://registry.npmjs.org/@storybook/web-components-vite/-/web-components-vite-10.2.3.tgz",
"integrity": "sha512-eJsu4v8G8s/xRkaeRj/tgGrJXDkUXDagAZ3xEFgahhShrYLc+wEUZ2fDj0ZE33UN+RGKsR0a/r0vdM5N6S3Mlw==",
"version": "10.2.4",
"resolved": "https://registry.npmjs.org/@storybook/web-components-vite/-/web-components-vite-10.2.4.tgz",
"integrity": "sha512-SAd/mtxMRv055IEp80J23DYfPn29izNXdcp4B7a44UKCVN0H3/enotie5fYQnG+jU3oPwg5JRPtFz3hyzrb+sw==",
"license": "MIT",
"dependencies": {
"@storybook/builder-vite": "10.2.3",
"@storybook/web-components": "10.2.3"
"@storybook/builder-vite": "10.2.4",
"@storybook/web-components": "10.2.4"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/storybook"
},
"peerDependencies": {
"storybook": "^10.2.3"
"storybook": "^10.2.4"
}
},
"node_modules/@swagger-api/apidom-ast": {
@@ -10741,9 +10741,9 @@
}
},
"node_modules/knip": {
"version": "5.82.1",
"resolved": "https://registry.npmjs.org/knip/-/knip-5.82.1.tgz",
"integrity": "sha512-1nQk+5AcnkqL40kGQXfouzAEXkTR+eSrgo/8m1d0BMei4eAzFwghoXC4gOKbACgBiCof7hE8wkBVDsEvznf85w==",
"version": "5.83.0",
"resolved": "https://registry.npmjs.org/knip/-/knip-5.83.0.tgz",
"integrity": "sha512-FfmaHMntpZB13B1oJQMSs1hTOZxd0TOn+FYB3oWEI02XlxTW3RH4H7d8z5Us3g0ziHCYyl7z0B1xi8ENP3QEKA==",
"funding": [
{
"type": "github",
@@ -15292,9 +15292,9 @@
}
},
"node_modules/storybook": {
"version": "10.2.3",
"resolved": "https://registry.npmjs.org/storybook/-/storybook-10.2.3.tgz",
"integrity": "sha512-kjsJ0hctkTO0ipHiyv1MY39wP4tAyVM7rPQGyVMU1iQ7NYHxthiiCHhFB/szmVjXdJa58fu3ZH5cwENMn8Y5eA==",
"version": "10.2.4",
"resolved": "https://registry.npmjs.org/storybook/-/storybook-10.2.4.tgz",
"integrity": "sha512-LwF0VZsT4qkgx66Ad/q0QgZZrU2a5WftaADDEcJ3bGq3O2fHvwWPlSZjM1HiXD4vqP9U5JiMqQkV1gkyH0XJkw==",
"license": "MIT",
"dependencies": {
"@storybook/global": "^5.0.0",

View File

@@ -119,10 +119,10 @@
"@patternfly/patternfly": "^4.224.2",
"@playwright/test": "^1.58.1",
"@sentry/browser": "^10.38.0",
"@storybook/addon-docs": "^10.2.3",
"@storybook/addon-links": "^10.2.3",
"@storybook/web-components": "^10.2.3",
"@storybook/web-components-vite": "^10.2.3",
"@storybook/addon-docs": "^10.2.4",
"@storybook/addon-links": "^10.2.4",
"@storybook/web-components": "^10.2.4",
"@storybook/web-components-vite": "^10.2.4",
"@types/codemirror": "^5.60.17",
"@types/grecaptcha": "^3.0.9",
"@types/guacamole-common-js": "^1.5.5",
@@ -152,7 +152,7 @@
"globals": "^17.3.0",
"guacamole-common-js": "^1.5.0",
"hastscript": "^9.0.1",
"knip": "^5.82.1",
"knip": "^5.83.0",
"lex": "^2025.11.0",
"lit": "^3.3.2",
"lit-analyzer": "^2.0.3",

View File

@@ -53,8 +53,6 @@ export class AdminSettingsForm extends Form<SettingsRequest> {
settingsRequest,
});
this.dispatchEvent(new CustomEvent("ak-admin-setting-changed"));
return result;
}

View File

@@ -51,12 +51,9 @@ export class AdminSettingsPage extends AKElement {
@state()
protected settings?: Settings;
constructor() {
super();
public override connectedCallback(): void {
super.connectedCallback();
this.#refresh();
this.addEventListener("ak-admin-setting-changed", this.#refresh);
}
#refresh = () => {
@@ -65,14 +62,6 @@ export class AdminSettingsPage extends AKElement {
});
};
#save = () => {
return this.form?.submit(new SubmitEvent("submit")).then(this.#refresh);
};
#reset = () => {
return this.form?.reset();
};
render() {
if (!this.settings) return nothing;
@@ -80,17 +69,14 @@ export class AdminSettingsPage extends AKElement {
<section class="pf-c-page__main-section pf-m-no-padding-mobile pf-l-grid pf-m-gutter">
<div class="pf-c-card">
<div class="pf-c-card__body">
<ak-admin-settings-form id="form" .settings=${this.settings}>
<ak-admin-settings-form
id="form"
.settings=${this.settings}
action-label=${msg("Update settings")}
@ak-form-submitted=${{ handleEvent: this.#refresh, passive: true }}
>
</ak-admin-settings-form>
</div>
<div class="pf-c-card__footer">
<ak-spinner-button .callAction=${this.#save} class="pf-m-primary"
>${msg("Save")}</ak-spinner-button
>
<ak-spinner-button .callAction=${this.#reset} class="pf-m-secondary"
>${msg("Cancel")}</ak-spinner-button
>
</div>
</div>
</section>
`;

View File

@@ -94,7 +94,7 @@ export class ApplicationListPage extends WithBrandConfig(TablePage<Application>)
renderToolbarSelected(): TemplateResult {
const disabled = this.selectedElements.length < 1;
return html`<ak-forms-delete-bulk
objectLabel=${msg("Application(s)")}
object-label=${msg("Application(s)")}
.objects=${this.selectedElements}
.usedBy=${(item: Application) => {
return new CoreApi(DEFAULT_CONFIG).coreApplicationsUsedByList({

View File

@@ -400,6 +400,7 @@ export class ApplicationViewPage extends AKElement {
</div>
</section>
<ak-rbac-object-permission-page
class="pf-c-page__main-section pf-m-no-padding-mobile"
role="tabpanel"
tabindex="0"
slot="page-permissions"

View File

@@ -54,7 +54,7 @@ export class ApplicationEntitlementsPage extends Table<ApplicationEntitlement> {
renderToolbarSelected(): TemplateResult {
const disabled = this.selectedElements.length < 1;
return html`<ak-forms-delete-bulk
objectLabel=${msg("Application entitlement(s)")}
object-label=${msg("Application entitlement(s)")}
.objects=${this.selectedElements}
.usedBy=${(item: ApplicationEntitlement) => {
return new CoreApi(DEFAULT_CONFIG).coreApplicationEntitlementsUsedByList({

View File

@@ -88,7 +88,7 @@ export class BlueprintListPage extends TablePage<BlueprintInstance> {
renderToolbarSelected(): TemplateResult {
const disabled = this.selectedElements.length < 1;
return html`<ak-forms-delete-bulk
objectLabel=${msg("Blueprint(s)")}
object-label=${msg("Blueprint(s)")}
.objects=${this.selectedElements}
.metadata=${(item: BlueprintInstance) => {
return [{ key: msg("Name"), value: item.name }];

View File

@@ -50,7 +50,7 @@ export class BrandListPage extends TablePage<Brand> {
renderToolbarSelected(): TemplateResult {
const disabled = this.selectedElements.length < 1;
return html`<ak-forms-delete-bulk
objectLabel=${msg("Brand(s)")}
object-label=${msg("Brand(s)")}
.objects=${this.selectedElements}
.metadata=${(item: Brand) => {
return [{ key: msg("Domain"), value: item.domain }];

View File

@@ -57,6 +57,14 @@ export class CertificateKeyPairForm extends Form<CertificateGenerationRequest> {
label: msg("ECDSA"),
value: AlgEnum.Ecdsa,
},
{
label: msg("ED25519"),
value: AlgEnum.Ed25519,
},
{
label: msg("ED448"),
value: AlgEnum.Ed448,
},
]}
>
</ak-radio>

View File

@@ -61,7 +61,7 @@ export class CertificateKeyPairListPage extends TablePage<CertificateKeyPair> {
const disabled = this.selectedElements.length < 1;
const count = this.selectedElements.length;
return html`<ak-forms-delete-bulk
objectLabel=${count === 1 ? msg("Certificate-Key Pair") : msg("Certificate-Key Pairs")}
object-label=${count === 1 ? msg("Certificate-Key Pair") : msg("Certificate-Key Pairs")}
.objects=${this.selectedElements}
.metadata=${(item: CertificateKeyPair) => {
return [

View File

@@ -76,7 +76,7 @@ export class DeviceAccessGroupsListPage extends TablePage<DeviceAccessGroup> {
renderToolbarSelected() {
const disabled = this.selectedElements.length < 1;
return html`<ak-forms-delete-bulk
objectLabel=${msg("Device Group(s)")}
object-label=${msg("Device Group(s)")}
.objects=${this.selectedElements}
.metadata=${(item: DeviceAccessGroup) => {
return [{ key: msg("Name"), value: item.name }];

View File

@@ -72,7 +72,7 @@ export class ConnectorsListPage extends TablePage<Connector> {
renderToolbarSelected() {
const disabled = this.selectedElements.length < 1;
return html`<ak-forms-delete-bulk
objectLabel=${msg("Connector(s)")}
object-label=${msg("Connector(s)")}
.objects=${this.selectedElements}
.metadata=${(item: Connector) => {
return [{ key: msg("Name"), value: item.name }];

View File

@@ -54,7 +54,7 @@ export class EnrollmentTokenListPage extends Table<EnrollmentToken> {
renderToolbarSelected(): TemplateResult {
const disabled = this.selectedElements.length < 1;
return html`<ak-forms-delete-bulk
objectLabel=${msg("Enrollment Token(s)")}
object-label=${msg("Enrollment Token(s)")}
.objects=${this.selectedElements}
.metadata=${(item: EnrollmentToken) => {
return [

View File

@@ -1,21 +1,33 @@
import { DEFAULT_CONFIG } from "#common/api/config";
import { writeToClipboard } from "#common/clipboard";
import TokenCopyButton from "#elements/buttons/TokenCopyButton/ak-token-copy-button";
import { EndpointsApi } from "@goauthentik/api";
import { msg } from "@lit/localize";
import { customElement } from "lit/decorators.js";
@customElement("ak-enrollment-token-copy-button")
export class EnrollmentTokenCopyButton extends TokenCopyButton {
callAction: () => Promise<unknown> = () => {
public override entityLabel = msg("Enrollment Token");
public override callAction(): Promise<null> {
if (!this.identifier) {
return Promise.reject();
throw new TypeError("No `identifier` set for `EnrollmentTokenCopyButton`");
}
return new EndpointsApi(DEFAULT_CONFIG).endpointsAgentsEnrollmentTokensViewKeyRetrieve({
tokenUuid: this.identifier,
// Safari permission hack.
const text = new ClipboardItem({
"text/plain": new EndpointsApi(DEFAULT_CONFIG)
.endpointsAgentsEnrollmentTokensViewKeyRetrieve({
tokenUuid: this.identifier,
})
.then((tokenView) => new Blob([tokenView.key], { type: "text/plain" })),
});
};
return writeToClipboard(text, this.entityLabel).then(() => null);
}
}
declare global {

View File

@@ -148,7 +148,7 @@ export class DeviceListPage extends TablePage<EndpointDevice> {
renderToolbarSelected() {
const disabled = this.selectedElements.length < 1;
return html`<ak-forms-delete-bulk
objectLabel=${msg("Endpoint Device(s)")}
object-label=${msg("Endpoint Device(s)")}
.objects=${this.selectedElements}
.metadata=${(item: EndpointDevice) => {
return [{ key: msg("Name"), value: item.name }];

View File

@@ -113,7 +113,7 @@ export class EnterpriseLicenseListPage extends TablePage<License> {
renderToolbarSelected(): TemplateResult {
const disabled = this.selectedElements.length < 1;
return html`<ak-forms-delete-bulk
objectLabel=${msg("License(s)")}
object-label=${msg("License(s)")}
.objects=${this.selectedElements}
.metadata=${(item: License) => {
return [

View File

@@ -57,7 +57,7 @@ export class DataExportListPage extends TablePage<DataExport> {
renderToolbarSelected(): TemplateResult {
const disabled = this.selectedElements.length < 1;
return html`<ak-forms-delete-bulk
objectLabel=${msg("Data export(s)")}
object-label=${msg("Data export(s)")}
.objects=${this.selectedElements}
.metadata=${(item: DataExport) => {
return [

View File

@@ -57,7 +57,7 @@ export class RuleListPage extends TablePage<NotificationRule> {
renderToolbarSelected(): TemplateResult {
const disabled = this.selectedElements.length < 1;
return html`<ak-forms-delete-bulk
objectLabel=${msg("Notification rule(s)")}
object-label=${msg("Notification rule(s)")}
.objects=${this.selectedElements}
.usedBy=${(item: NotificationRule) => {
return new EventsApi(DEFAULT_CONFIG).eventsRulesUsedByList({

View File

@@ -55,7 +55,7 @@ export class TransportListPage extends TablePage<NotificationTransport> {
renderToolbarSelected(): TemplateResult {
const disabled = this.selectedElements.length < 1;
return html`<ak-forms-delete-bulk
objectLabel=${msg("Notification transport(s)")}
object-label=${msg("Notification transport(s)")}
.objects=${this.selectedElements}
.usedBy=${(item: NotificationTransport) => {
return new EventsApi(DEFAULT_CONFIG).eventsTransportsUsedByList({

View File

@@ -77,7 +77,7 @@ export class FileListPage extends WithCapabilitiesConfig(TablePage<FileItem>) {
const disabled = !this.selectedElements.length;
const count = this.selectedElements.length;
return html`<ak-forms-delete-bulk
objectLabel=${count === 1 ? msg("file") : msg("files")}
object-label=${count === 1 ? msg("file") : msg("files")}
.objects=${this.selectedElements}
.metadata=${(item: FileItem) => {
return [

View File

@@ -56,7 +56,7 @@ export class BoundStagesList extends Table<FlowStageBinding> {
renderToolbarSelected(): TemplateResult {
const disabled = this.selectedElements.length < 1;
return html`<ak-forms-delete-bulk
objectLabel=${msg("Stage binding(s)")}
object-label=${msg("Stage binding(s)")}
.objects=${this.selectedElements}
.metadata=${(item: FlowStageBinding) => {
return [

View File

@@ -61,7 +61,7 @@ export class FlowListPage extends TablePage<Flow> {
renderToolbarSelected(): TemplateResult {
const disabled = this.selectedElements.length < 1;
return html`<ak-forms-delete-bulk
objectLabel=${msg("Flow(s)")}
object-label=${msg("Flow(s)")}
.objects=${this.selectedElements}
.usedBy=${(item: Flow) => {
return new FlowsApi(DEFAULT_CONFIG).flowsInstancesUsedByList({

View File

@@ -298,6 +298,7 @@ export class FlowViewPage extends AKElement {
</div>
</div>
<ak-rbac-object-permission-page
class="pf-c-page__main-section pf-m-no-padding-mobile"
role="tabpanel"
tabindex="0"
slot="page-permissions"

View File

@@ -51,7 +51,7 @@ export class GroupListPage extends TablePage<Group> {
renderToolbarSelected(): TemplateResult {
const disabled = this.selectedElements.length < 1;
return html`<ak-forms-delete-bulk
objectLabel=${msg("Group(s)")}
object-label=${msg("Group(s)")}
.objects=${this.selectedElements}
.usedBy=${(item: Group) => {
return new CoreApi(DEFAULT_CONFIG).coreGroupsUsedByList({

View File

@@ -247,6 +247,7 @@ export class GroupViewPage extends AKElement {
${this.renderTabRoles(this.group)}
</section>
<ak-rbac-object-permission-page
class="pf-c-page__main-section pf-m-no-padding-mobile"
role="tabpanel"
tabindex="0"
slot="page-permissions"

View File

@@ -113,12 +113,12 @@ export class RelatedGroupList extends Table<Group> {
renderToolbarSelected(): TemplateResult {
const disabled = this.selectedElements.length < 1;
return html`<ak-forms-delete-bulk
objectLabel=${msg("Group(s)")}
actionLabel=${msg("Remove from Group(s)")}
actionSubtext=${msg(
object-label=${msg("Group(s)")}
action-label=${msg("Remove from Group(s)")}
action-subtext=${msg(
str`Are you sure you want to remove user ${this.targetUser?.username} from the following groups?`,
)}
buttonLabel=${msg("Remove")}
button-label=${msg("Remove")}
.objects=${this.selectedElements}
.delete=${(item: Group) => {
if (!this.targetUser) return;

View File

@@ -15,11 +15,8 @@ import "@patternfly/elements/pf-tooltip/pf-tooltip.js";
import { DEFAULT_CONFIG } from "#common/api/config";
import { PFSize } from "#common/enums";
import { parseAPIResponseError, pluckErrorDetail } from "#common/errors/network";
import { MessageLevel } from "#common/messages";
import { Form } from "#elements/forms/Form";
import { showMessage } from "#elements/messages/MessageContainer";
import { WithBrandConfig } from "#elements/mixins/branding";
import { CapabilitiesEnum, WithCapabilitiesConfig } from "#elements/mixins/capabilities";
import { getURLParam, updateURLParams } from "#elements/router/RouteMatch";
@@ -29,6 +26,8 @@ import { UserOption } from "#elements/user/utils";
import { AKLabel } from "#components/ak-label";
import { renderRecoveryButtons } from "#admin/users/UserListPage";
import { CoreApi, CoreUsersListTypeEnum, Group, RbacApi, Role, User } from "@goauthentik/api";
import { msg, str } from "@lit/localize";
@@ -195,10 +194,10 @@ export class RelatedUserList extends WithBrandConfig(WithCapabilitiesConfig(Tabl
const disabled = this.selectedElements.length < 1;
const targetLabel = this.targetGroup?.name || this.targetRole?.name;
return html`<ak-forms-delete-bulk
objectLabel=${msg("User(s)")}
actionLabel=${msg("Remove User(s)")}
object-label=${msg("User(s)")}
action-label=${msg("Remove User(s)")}
action=${msg("removed")}
actionSubtext=${targetLabel
action-subtext=${targetLabel
? msg(str`Are you sure you want to remove the selected users from ${targetLabel}?`)
: msg("Are you sure you want to remove the selected users?")}
.objects=${this.selectedElements}
@@ -303,7 +302,7 @@ export class RelatedUserList extends WithBrandConfig(WithCapabilitiesConfig(Tabl
<div class="pf-c-description-list__text">
<ak-user-active-form
.obj=${item}
objectLabel=${msg("User")}
object-label=${msg("User")}
.delete=${() => {
return new CoreApi(DEFAULT_CONFIG).coreUsersPartialUpdate({
id: item.pk || 0,
@@ -326,78 +325,10 @@ export class RelatedUserList extends WithBrandConfig(WithCapabilitiesConfig(Tabl
</dt>
<dd class="pf-c-description-list__description">
<div class="pf-c-description-list__text">
<ak-forms-modal>
<span slot="submit">${msg("Update password")}</span>
<span slot="header">
${msg(str`Update ${item.name || item.username}'s password`)}
</span>
<ak-user-password-form
username=${item.username}
email=${ifDefined(item.email)}
slot="form"
.instancePk=${item.pk}
></ak-user-password-form>
<button slot="trigger" class="pf-c-button pf-m-secondary">
${msg("Set password")}
</button>
</ak-forms-modal>
${this.brand.flowRecovery
? html`
<ak-action-button
class="pf-m-secondary"
.apiRequest=${() => {
return new CoreApi(DEFAULT_CONFIG)
.coreUsersRecoveryCreate({
id: item.pk,
})
.then((rec) => {
showMessage({
level: MessageLevel.success,
message: msg(
"Successfully generated recovery link",
),
description: rec.link,
});
})
.catch(async (error: unknown) => {
const parsedError =
await parseAPIResponseError(error);
showMessage({
level: MessageLevel.error,
message: pluckErrorDetail(parsedError),
});
});
}}
>
${msg("Copy recovery link")}
</ak-action-button>
${item.email
? html`<ak-forms-modal .closeAfterSuccessfulSubmit=${false}>
<span slot="submit"> ${msg("Send link")} </span>
<span slot="header">
${msg("Send recovery link to user")}
</span>
<ak-user-reset-email-form slot="form" .user=${item}>
</ak-user-reset-email-form>
<button
slot="trigger"
class="pf-c-button pf-m-secondary"
>
${msg("Email recovery link")}
</button>
</ak-forms-modal>`
: html`<span
>${msg(
"Recovery link cannot be emailed, user has no email address saved.",
)}</span
>`}
`
: html` <p>
${msg(
"To let a user directly reset a their password, configure a recovery flow on the currently active brand.",
)}
</p>`}
${renderRecoveryButtons({
user: item,
brandHasRecoveryFlow: Boolean(this.brand.flowRecovery),
})}
</div>
</dd>
</div>
@@ -439,7 +370,7 @@ export class RelatedUserList extends WithBrandConfig(WithCapabilitiesConfig(Tabl
: nothing}
<ak-dropdown class="pf-c-dropdown">
<button
class="pf-m-secondary pf-c-dropdown__toggle"
class="pf-c-button pf-m-secondary pf-c-dropdown__toggle"
type="button"
id="add-user-toggle"
aria-haspopup="menu"
@@ -449,10 +380,9 @@ export class RelatedUserList extends WithBrandConfig(WithCapabilitiesConfig(Tabl
<span class="pf-c-dropdown__toggle-text">${msg("Add new user")}</span>
<i class="fas fa-caret-down pf-c-dropdown__toggle-icon" aria-hidden="true"></i>
</button>
<ul
<menu
class="pf-c-dropdown__menu"
hidden
role="menu"
id="add-user-menu"
aria-labelledby="add-user-toggle"
tabindex="-1"
@@ -526,7 +456,7 @@ export class RelatedUserList extends WithBrandConfig(WithCapabilitiesConfig(Tabl
</a>
</ak-forms-modal>
</li>
</ul>
</menu>
</ak-dropdown>
${super.renderToolbar()}
`;

View File

@@ -211,7 +211,7 @@ export class OutpostListPage extends TablePage<Outpost> {
renderToolbarSelected(): TemplateResult {
const disabled = this.selectedElements.length < 1;
return html`<ak-forms-delete-bulk
objectLabel=${msg("Outpost(s)")}
object-label=${msg("Outpost(s)")}
.objects=${this.selectedElements}
.usedBy=${(item: Outpost) => {
return new OutpostsApi(DEFAULT_CONFIG).outpostsInstancesUsedByList({

View File

@@ -143,7 +143,7 @@ export class OutpostServiceConnectionListPage extends TablePage<ServiceConnectio
renderToolbarSelected(): TemplateResult {
const disabled = this.selectedElements.length < 1;
return html`<ak-forms-delete-bulk
objectLabel=${msg("Outpost integration(s)")}
object-label=${msg("Outpost integration(s)")}
.objects=${this.selectedElements}
.usedBy=${(item: ServiceConnection) => {
return new OutpostsApi(DEFAULT_CONFIG).outpostsServiceConnectionsAllUsedByList({

View File

@@ -148,7 +148,7 @@ export class BoundPoliciesList<T extends PolicyBinding = PolicyBinding> extends
renderToolbarSelected(): TemplateResult {
const disabled = this.selectedElements.length < 1;
return html`<ak-forms-delete-bulk
objectLabel=${msg("Policy binding(s)")}
object-label=${msg("Policy binding(s)")}
.objects=${this.selectedElements}
.metadata=${(item: PolicyBinding) => {
return [

View File

@@ -99,7 +99,7 @@ export class PolicyListPage extends TablePage<Policy> {
renderToolbarSelected(): TemplateResult {
const disabled = this.selectedElements.length < 1;
return html`<ak-forms-delete-bulk
objectLabel=${msg("Policy / Policies")}
object-label=${msg("Policy / Policies")}
.objects=${this.selectedElements}
.usedBy=${(item: Policy) => {
return new PoliciesApi(DEFAULT_CONFIG).policiesAllUsedByList({

View File

@@ -58,7 +58,7 @@ export class ReputationListPage extends TablePage<Reputation> {
renderToolbarSelected(): TemplateResult {
const disabled = this.selectedElements.length < 1;
return html`<ak-forms-delete-bulk
objectLabel=${msg("Reputation")}
object-label=${msg("Reputation")}
.objects=${this.selectedElements}
.usedBy=${(item: Reputation) => {
return new PoliciesApi(DEFAULT_CONFIG).policiesReputationScoresUsedByList({

View File

@@ -67,7 +67,7 @@ export class PropertyMappingListPage extends TablePage<PropertyMapping> {
renderToolbarSelected(): TemplateResult {
const disabled = this.selectedElements.length < 1;
return html`<ak-forms-delete-bulk
objectLabel=${msg("Property Mapping(s)")}
object-label=${msg("Property Mapping(s)")}
.objects=${this.selectedElements}
.usedBy=${(item: PropertyMapping) => {
return new PropertymappingsApi(DEFAULT_CONFIG).propertymappingsAllUsedByList({

View File

@@ -68,7 +68,7 @@ export class ProviderListPage extends TablePage<Provider> {
const disabled = this.selectedElements.length < 1;
return html`<ak-forms-delete-bulk
objectLabel=${msg("Provider(s)")}
object-label=${msg("Provider(s)")}
.objects=${this.selectedElements}
.usedBy=${(item: Provider) => {
return new ProvidersApi(DEFAULT_CONFIG).providersAllUsedByList({

View File

@@ -53,7 +53,7 @@ export class GoogleWorkspaceProviderGroupList extends Table<GoogleWorkspaceProvi
renderToolbarSelected(): TemplateResult {
const disabled = this.selectedElements.length < 1;
return html`<ak-forms-delete-bulk
objectLabel=${msg("Google Workspace Group(s)")}
object-label=${msg("Google Workspace Group(s)")}
.objects=${this.selectedElements}
.delete=${(item: GoogleWorkspaceProviderGroup) => {
return new ProvidersApi(DEFAULT_CONFIG).providersGoogleWorkspaceGroupsDestroy({

View File

@@ -53,7 +53,7 @@ export class GoogleWorkspaceProviderUserList extends Table<GoogleWorkspaceProvid
renderToolbarSelected(): TemplateResult {
const disabled = this.selectedElements.length < 1;
return html`<ak-forms-delete-bulk
objectLabel=${msg("Google Workspace User(s)")}
object-label=${msg("Google Workspace User(s)")}
.objects=${this.selectedElements}
.delete=${(item: GoogleWorkspaceProviderUser) => {
return new ProvidersApi(DEFAULT_CONFIG).providersGoogleWorkspaceUsersDestroy({

View File

@@ -142,6 +142,7 @@ export class GoogleWorkspaceProviderViewPage extends AKElement {
</div>
</section>
<ak-rbac-object-permission-page
class="pf-c-page__main-section pf-m-no-padding-mobile"
role="tabpanel"
tabindex="0"
slot="page-permissions"

View File

@@ -111,6 +111,7 @@ export class LDAPProviderViewPage extends WithSession(AKElement) {
</div>
</div>
<ak-rbac-object-permission-page
class="pf-c-page__main-section pf-m-no-padding-mobile"
role="tabpanel"
tabindex="0"
slot="page-permissions"

View File

@@ -50,7 +50,7 @@ export class MicrosoftEntraProviderGroupList extends Table<MicrosoftEntraProvide
renderToolbarSelected(): TemplateResult {
const disabled = this.selectedElements.length < 1;
return html`<ak-forms-delete-bulk
objectLabel=${msg("Microsoft Entra Group(s)")}
object-label=${msg("Microsoft Entra Group(s)")}
.objects=${this.selectedElements}
.delete=${(item: MicrosoftEntraProviderGroup) => {
return new ProvidersApi(DEFAULT_CONFIG).providersMicrosoftEntraGroupsDestroy({

View File

@@ -53,7 +53,7 @@ export class MicrosoftEntraProviderUserList extends Table<MicrosoftEntraProvider
renderToolbarSelected(): TemplateResult {
const disabled = this.selectedElements.length < 1;
return html`<ak-forms-delete-bulk
objectLabel=${msg("Microsoft Entra User(s)")}
object-label=${msg("Microsoft Entra User(s)")}
.objects=${this.selectedElements}
.delete=${(item: MicrosoftEntraProviderUser) => {
return new ProvidersApi(DEFAULT_CONFIG).providersMicrosoftEntraUsersDestroy({

View File

@@ -142,6 +142,7 @@ export class MicrosoftEntraProviderViewPage extends AKElement {
</div>
</div>
<ak-rbac-object-permission-page
class="pf-c-page__main-section pf-m-no-padding-mobile"
role="tabpanel"
tabindex="0"
slot="page-permissions"

View File

@@ -172,6 +172,7 @@ export class OAuth2ProviderViewPage extends AKElement {
</div>
</div>
<ak-rbac-object-permission-page
class="pf-c-page__main-section pf-m-no-padding-mobile"
role="tabpanel"
tabindex="0"
slot="page-permissions"

View File

@@ -239,6 +239,7 @@ export class ProxyProviderViewPage extends AKElement {
</div>
</div>
<ak-rbac-object-permission-page
class="pf-c-page__main-section pf-m-no-padding-mobile"
role="tabpanel"
tabindex="0"
slot="page-permissions"

View File

@@ -45,7 +45,7 @@ export class ConnectionTokenListPage extends Table<ConnectionToken> {
renderToolbarSelected(): TemplateResult {
const disabled = this.selectedElements.length < 1;
return html`<ak-forms-delete-bulk
objectLabel=${msg("Connection Token(s)")}
object-label=${msg("Connection Token(s)")}
.objects=${this.selectedElements}
.metadata=${(item: ConnectionToken) => {
return [

View File

@@ -57,7 +57,7 @@ export class EndpointListPage extends Table<Endpoint> {
renderToolbarSelected(): TemplateResult {
const disabled = this.selectedElements.length < 1;
return html`<ak-forms-delete-bulk
objectLabel=${msg("Endpoint(s)")}
object-label=${msg("Endpoint(s)")}
.objects=${this.selectedElements}
.metadata=${(item: Endpoint) => {
return [

View File

@@ -129,6 +129,7 @@ export class RACProviderViewPage extends AKElement {
</div>
</div>
<ak-rbac-object-permission-page
class="pf-c-page__main-section pf-m-no-padding-mobile"
role="tabpanel"
tabindex="0"
slot="page-permissions"

View File

@@ -175,6 +175,7 @@ export class RadiusProviderViewPage extends AKElement {
</div>
</div>
<ak-rbac-object-permission-page
class="pf-c-page__main-section pf-m-no-padding-mobile"
role="tabpanel"
tabindex="0"
slot="page-permissions"

View File

@@ -269,6 +269,7 @@ export class SAMLProviderViewPage extends AKElement {
</div>
</div>
<ak-rbac-object-permission-page
class="pf-c-page__main-section pf-m-no-padding-mobile"
role="tabpanel"
tabindex="0"
slot="page-permissions"

View File

@@ -50,7 +50,7 @@ export class SCIMProviderGroupList extends Table<SCIMProviderGroup> {
renderToolbarSelected(): TemplateResult {
const disabled = this.selectedElements.length < 1;
return html`<ak-forms-delete-bulk
objectLabel=${msg("SCIM Group(s)")}
object-label=${msg("SCIM Group(s)")}
.objects=${this.selectedElements}
.delete=${(item: SCIMProviderGroup) => {
return new ProvidersApi(DEFAULT_CONFIG).providersScimGroupsDestroy({

View File

@@ -50,7 +50,7 @@ export class SCIMProviderUserList extends Table<SCIMProviderUser> {
renderToolbarSelected(): TemplateResult {
const disabled = this.selectedElements.length < 1;
return html`<ak-forms-delete-bulk
objectLabel=${msg("SCIM User(s)")}
object-label=${msg("SCIM User(s)")}
.objects=${this.selectedElements}
.delete=${(item: SCIMProviderUser) => {
return new ProvidersApi(DEFAULT_CONFIG).providersScimUsersDestroy({

View File

@@ -148,6 +148,7 @@ export class SCIMProviderViewPage extends AKElement {
</div>
</div>
<ak-rbac-object-permission-page
class="pf-c-page__main-section pf-m-no-padding-mobile"
role="tabpanel"
tabindex="0"
slot="page-permissions"

View File

@@ -108,6 +108,7 @@ export class SSFProviderViewPage extends AKElement {
</div>
</div>
<ak-rbac-object-permission-page
class="pf-c-page__main-section pf-m-no-padding-mobile"
role="tabpanel"
tabindex="0"
slot="page-permissions"

View File

@@ -250,6 +250,7 @@ export class WSFederationProviderViewPage extends AKElement {
</div>
</div>
<ak-rbac-object-permission-page
class="pf-c-page__main-section pf-m-no-padding-mobile"
role="tabpanel"
tabindex="0"
slot="page-permissions"

View File

@@ -45,7 +45,7 @@ export class InitialPermissionsListPage extends TablePage<InitialPermissions> {
renderToolbarSelected(): TemplateResult {
const disabled = this.selectedElements.length < 1;
return html`<ak-forms-delete-bulk
objectLabel=${msg("Initial Permissions")}
object-label=${msg("Initial Permissions")}
.objects=${this.selectedElements}
.usedBy=${(item: InitialPermissions) => {
return new RbacApi(DEFAULT_CONFIG).rbacInitialPermissionsUsedByList({

View File

@@ -8,7 +8,7 @@ import { AKElement } from "#elements/Base";
import { RbacPermissionsAssignedByRolesListModelEnum } from "@goauthentik/api";
import { msg } from "@lit/localize";
import { html } from "lit";
import { css, html } from "lit";
import { customElement, property } from "lit/decorators.js";
import PFCard from "@patternfly/patternfly/components/Card/card.css";
@@ -17,53 +17,42 @@ import PFGrid from "@patternfly/patternfly/layouts/Grid/grid.css";
@customElement("ak-rbac-object-permission-page")
export class ObjectPermissionPage extends AKElement {
static styles = [
PFGrid,
PFPage,
PFCard,
css`
:host {
display: block;
}
`,
];
@property()
public model?: RbacPermissionsAssignedByRolesListModelEnum;
@property()
objectPk?: string | number;
public objectPk?: string | number;
@property({ type: Boolean })
embedded = false;
static styles = [PFGrid, PFPage, PFCard];
public embedded = false;
render() {
return this.model === RbacPermissionsAssignedByRolesListModelEnum.AuthentikRbacRole
? html`<ak-tabs pageIdentifier="permissionPage" ?vertical=${!this.embedded}>
${this.renderPermissionsAssignedToRole()} ${this.renderPermissionsOnObject()}
${this.renderPermissionsAssignedToRole()}
</ak-tabs>`
: this.renderPermissionsOnObject();
}
renderPermissionsOnObject() {
return html` <div
role="tabpanel"
tabindex="0"
slot="page-object-role"
id="page-object-role"
aria-label="${msg("Permissions on this object")}"
class="pf-c-page__main-section pf-m-no-padding-mobile"
>
<div class="pf-l-grid pf-m-gutter">
<div class="pf-c-card pf-l-grid__item pf-m-12-col">
<div class="pf-c-card__title">${msg("Permissions on this object")}</div>
<div class="pf-c-card__body">
${msg("Permissions set on roles which affect this object.")}
</div>
<div class="pf-c-card__body">
<ak-rbac-role-object-permission-table
.model=${this.model}
.objectPk=${this.objectPk}
>
</ak-rbac-role-object-permission-table>
</div>
</div>
</div>
return html`<div class="pf-c-card__body">
<ak-rbac-role-object-permission-table .model=${this.model} .objectPk=${this.objectPk}>
</ak-rbac-role-object-permission-table>
</div>`;
}
renderPermissionsAssignedToRole() {
protected renderPermissionsAssignedToRole() {
return html`
<div
role="tabpanel"
@@ -111,6 +100,24 @@ export class ObjectPermissionPage extends AKElement {
</div>
</div>
</div>
<div
role="tabpanel"
tabindex="0"
slot="page-object-role"
id="page-object-role"
aria-label="${msg("Permissions on this object")}"
class="pf-c-page__main-section pf-m-no-padding-mobile"
>
<div class="pf-l-grid pf-m-gutter">
<div class="pf-c-card pf-l-grid__item pf-m-12-col">
<div class="pf-c-card__title">${msg("Permissions on this object")}</div>
<div class="pf-c-card__body">
${msg("Permissions set on roles which affect this object.")}
</div>
${this.renderPermissionsOnObject()}
</div>
</div>
</div>
`;
}
}

View File

@@ -103,7 +103,7 @@ export class RoleAssignedObjectPermissionTable extends Table<RoleAssignedObjectP
renderToolbarSelected(): TemplateResult {
const disabled = this.selectedElements.length < 1;
return html`<ak-forms-delete-bulk
objectLabel=${msg("Permission(s)")}
object-label=${msg("Permission(s)")}
.objects=${this.selectedElements}
.metadata=${(item: RoleAssignedObjectPermission) => {
return [{ key: msg("Permission"), value: item.name }];

View File

@@ -150,12 +150,12 @@ export class RelatedRoleList extends Table<Role> {
}
const disabled = !this.selectedElements.length;
return html`<ak-forms-delete-bulk
objectLabel=${msg("Role(s)")}
actionLabel=${msg("Remove from Role(s)")}
actionSubtext=${msg(
object-label=${msg("Role(s)")}
action-label=${msg("Remove from Role(s)")}
action-subtext=${msg(
str`Are you sure you want to remove user ${this.targetUser?.username} from the following roles?`,
)}
buttonLabel=${msg("Remove")}
button-label=${msg("Remove")}
.objects=${this.selectedElements}
.delete=${(item: Role) => {
if (!this.targetUser) return;

View File

@@ -64,7 +64,7 @@ export class RoleAssignedGlobalPermissionsTable extends Table<Permission> {
renderToolbarSelected(): TemplateResult {
const disabled = this.selectedElements.length < 1;
return html`<ak-forms-delete-bulk
objectLabel=${msg("Permission(s)")}
object-label=${msg("Permission(s)")}
.objects=${this.selectedElements}
.delete=${(item: Permission) => {
return new RbacApi(

View File

@@ -46,7 +46,7 @@ export class RoleAssignedObjectPermissionTable extends Table<ExtraRoleObjectPerm
renderToolbarSelected(): TemplateResult {
const disabled = this.selectedElements.length < 1;
return html`<ak-forms-delete-bulk
objectLabel=${msg("Permission(s)")}
object-label=${msg("Permission(s)")}
.objects=${this.selectedElements}
.metadata=${(item: ExtraRoleObjectPermission) => {
return [

View File

@@ -52,7 +52,7 @@ export class RoleListPage extends TablePage<Role> {
renderToolbarSelected(): TemplateResult {
const disabled = this.selectedElements.length < 1;
return html`<ak-forms-delete-bulk
objectLabel=${msg("Role(s)")}
object-label=${msg("Role(s)")}
.objects=${this.selectedElements}
.usedBy=${(item: Role) => {
return new RbacApi(DEFAULT_CONFIG).rbacRolesUsedByList({

View File

@@ -141,6 +141,7 @@ export class RoleViewPage extends AKElement {
</div>
</section>
<ak-rbac-object-permission-page
class="pf-c-page__main-section pf-m-no-padding-mobile"
role="tabpanel"
tabindex="0"
slot="page-permissions"

View File

@@ -55,7 +55,7 @@ export class SourceListPage extends TablePage<Source> {
this.selectedElements.some((item) => item.component === "");
const nonBuiltInSources = this.selectedElements.filter((item) => item.component !== "");
return html`<ak-forms-delete-bulk
objectLabel=${msg("Source(s)")}
object-label=${msg("Source(s)")}
.objects=${nonBuiltInSources}
.usedBy=${(item: Source) => {
return new SourcesApi(DEFAULT_CONFIG).sourcesAllUsedByList({

View File

@@ -206,6 +206,7 @@ export class KerberosSourceViewPage extends AKElement {
</div>
</div>
<ak-rbac-object-permission-page
class="pf-c-page__main-section pf-m-no-padding-mobile"
role="tabpanel"
tabindex="0"
slot="page-permissions"

View File

@@ -204,6 +204,7 @@ export class LDAPSourceViewPage extends AKElement {
</div>
</div>
<ak-rbac-object-permission-page
class="pf-c-page__main-section pf-m-no-padding-mobile"
role="tabpanel"
tabindex="0"
slot="page-permissions"

View File

@@ -272,6 +272,7 @@ export class OAuthSourceViewPage extends AKElement {
</div>
</div>
<ak-rbac-object-permission-page
class="pf-c-page__main-section pf-m-no-padding-mobile"
role="tabpanel"
tabindex="0"
slot="page-permissions"

View File

@@ -156,6 +156,7 @@ export class PlexSourceViewPage extends AKElement {
</div>
</div>
<ak-rbac-object-permission-page
class="pf-c-page__main-section pf-m-no-padding-mobile"
role="tabpanel"
tabindex="0"
slot="page-permissions"

View File

@@ -235,6 +235,7 @@ export class SAMLSourceViewPage extends AKElement {
</div>
</div>
<ak-rbac-object-permission-page
class="pf-c-page__main-section pf-m-no-padding-mobile"
role="tabpanel"
tabindex="0"
slot="page-permissions"

View File

@@ -217,6 +217,7 @@ export class SCIMSourceViewPage extends AKElement {
</div>
</div>
<ak-rbac-object-permission-page
class="pf-c-page__main-section pf-m-no-padding-mobile"
role="tabpanel"
tabindex="0"
slot="page-permissions"

View File

@@ -1,31 +1,5 @@
import "#admin/stages/register";
import "#admin/rbac/ObjectPermissionModal";
import "#admin/stages/StageWizard";
import "#admin/stages/authenticator_duo/AuthenticatorDuoStageForm";
import "#admin/stages/authenticator_duo/DuoDeviceImportForm";
import "#admin/stages/authenticator_email/AuthenticatorEmailStageForm";
import "#admin/stages/authenticator_endpoint_gdtc/AuthenticatorEndpointGDTCStageForm";
import "#admin/stages/authenticator_sms/AuthenticatorSMSStageForm";
import "#admin/stages/authenticator_static/AuthenticatorStaticStageForm";
import "#admin/stages/authenticator_totp/AuthenticatorTOTPStageForm";
import "#admin/stages/authenticator_validate/AuthenticatorValidateStageForm";
import "#admin/stages/authenticator_webauthn/AuthenticatorWebAuthnStageForm";
import "#admin/stages/captcha/CaptchaStageForm";
import "#admin/stages/consent/ConsentStageForm";
import "#admin/stages/deny/DenyStageForm";
import "#admin/stages/dummy/DummyStageForm";
import "#admin/stages/email/EmailStageForm";
import "#admin/stages/endpoint/EndpointStageForm";
import "#admin/stages/identification/IdentificationStageForm";
import "#admin/stages/invitation/InvitationStageForm";
import "#admin/stages/mtls/MTLSStageForm";
import "#admin/stages/password/PasswordStageForm";
import "#admin/stages/prompt/PromptStageForm";
import "#admin/stages/redirect/RedirectStageForm";
import "#admin/stages/source/SourceStageForm";
import "#admin/stages/user_delete/UserDeleteStageForm";
import "#admin/stages/user_login/UserLoginStageForm";
import "#admin/stages/user_logout/UserLogoutStageForm";
import "#admin/stages/user_write/UserWriteStageForm";
import "#elements/forms/DeleteBulkForm";
import "#elements/forms/ModalForm";
import "@patternfly/elements/pf-tooltip/pf-tooltip.js";
@@ -73,7 +47,7 @@ export class StageListPage extends TablePage<Stage> {
renderToolbarSelected(): TemplateResult {
const disabled = this.selectedElements.length < 1;
return html`<ak-forms-delete-bulk
objectLabel=${msg("Stage(s)")}
object-label=${msg("Stage(s)")}
.objects=${this.selectedElements}
.usedBy=${(item: Stage) => {
return new StagesApi(DEFAULT_CONFIG).stagesAllUsedByList({

View File

@@ -1,28 +1,5 @@
import "#admin/stages/register";
import "#admin/common/ak-license-notice";
import "#admin/stages/authenticator_duo/AuthenticatorDuoStageForm";
import "#admin/stages/authenticator_email/AuthenticatorEmailStageForm";
import "#admin/stages/authenticator_sms/AuthenticatorSMSStageForm";
import "#admin/stages/authenticator_static/AuthenticatorStaticStageForm";
import "#admin/stages/authenticator_totp/AuthenticatorTOTPStageForm";
import "#admin/stages/authenticator_validate/AuthenticatorValidateStageForm";
import "#admin/stages/authenticator_webauthn/AuthenticatorWebAuthnStageForm";
import "#admin/stages/captcha/CaptchaStageForm";
import "#admin/stages/consent/ConsentStageForm";
import "#admin/stages/deny/DenyStageForm";
import "#admin/stages/dummy/DummyStageForm";
import "#admin/stages/email/EmailStageForm";
import "#admin/stages/identification/IdentificationStageForm";
import "#admin/stages/invitation/InvitationStageForm";
import "#admin/stages/mtls/MTLSStageForm";
import "#admin/stages/endpoint/EndpointStageForm";
import "#admin/stages/password/PasswordStageForm";
import "#admin/stages/prompt/PromptStageForm";
import "#admin/stages/redirect/RedirectStageForm";
import "#admin/stages/source/SourceStageForm";
import "#admin/stages/user_delete/UserDeleteStageForm";
import "#admin/stages/user_login/UserLoginStageForm";
import "#admin/stages/user_logout/UserLogoutStageForm";
import "#admin/stages/user_write/UserWriteStageForm";
import "#elements/wizard/FormWizardPage";
import "#elements/wizard/TypeCreateWizardPage";
import "#elements/wizard/Wizard";

View File

@@ -88,7 +88,7 @@ export class InvitationListPage extends TablePage<Invitation> {
renderToolbarSelected(): TemplateResult {
const disabled = this.selectedElements.length < 1;
return html`<ak-forms-delete-bulk
objectLabel=${msg("Invitation(s)")}
object-label=${msg("Invitation(s)")}
.objects=${this.selectedElements}
.usedBy=${(item: Invitation) => {
return new StagesApi(DEFAULT_CONFIG).stagesInvitationInvitationsUsedByList({

View File

@@ -50,7 +50,7 @@ export class PromptListPage extends TablePage<Prompt> {
renderToolbarSelected(): TemplateResult {
const disabled = this.selectedElements.length < 1;
return html`<ak-forms-delete-bulk
objectLabel=${msg("Prompt(s)")}
object-label=${msg("Prompt(s)")}
.objects=${this.selectedElements}
.usedBy=${(item: Prompt) => {
return new StagesApi(DEFAULT_CONFIG).stagesPromptPromptsUsedByList({

View File

@@ -0,0 +1,31 @@
/**
* @file Register all stage forms for admin interface.
*/
import "#admin/stages/StageWizard";
import "#admin/stages/authenticator_duo/AuthenticatorDuoStageForm";
import "#admin/stages/authenticator_email/AuthenticatorEmailStageForm";
import "#admin/stages/authenticator_sms/AuthenticatorSMSStageForm";
import "#admin/stages/authenticator_static/AuthenticatorStaticStageForm";
import "#admin/stages/authenticator_totp/AuthenticatorTOTPStageForm";
import "#admin/stages/authenticator_validate/AuthenticatorValidateStageForm";
import "#admin/stages/authenticator_webauthn/AuthenticatorWebAuthnStageForm";
import "#admin/stages/captcha/CaptchaStageForm";
import "#admin/stages/consent/ConsentStageForm";
import "#admin/stages/deny/DenyStageForm";
import "#admin/stages/dummy/DummyStageForm";
import "#admin/stages/email/EmailStageForm";
import "#admin/stages/identification/IdentificationStageForm";
import "#admin/stages/invitation/InvitationStageForm";
import "#admin/stages/mtls/MTLSStageForm";
import "#admin/stages/endpoint/EndpointStageForm";
import "#admin/stages/password/PasswordStageForm";
import "#admin/stages/prompt/PromptStageForm";
import "#admin/stages/redirect/RedirectStageForm";
import "#admin/stages/source/SourceStageForm";
import "#admin/stages/user_delete/UserDeleteStageForm";
import "#admin/stages/user_login/UserLoginStageForm";
import "#admin/stages/user_logout/UserLogoutStageForm";
import "#admin/stages/user_write/UserWriteStageForm";
import "#admin/stages/authenticator_duo/DuoDeviceImportForm";
import "#admin/stages/authenticator_endpoint_gdtc/AuthenticatorEndpointGDTCStageForm";

View File

@@ -60,7 +60,7 @@ export class TokenListPage extends TablePage<Token> {
renderToolbarSelected(): TemplateResult {
const disabled = this.selectedElements.length < 1;
return html`<ak-forms-delete-bulk
objectLabel=${msg("Token(s)")}
object-label=${msg("Token(s)")}
.objects=${this.selectedElements}
.metadata=${(item: Token) => {
return [{ key: msg("Identifier"), value: item.identifier }];
@@ -86,7 +86,7 @@ export class TokenListPage extends TablePage<Token> {
return html`
<ak-forms-modal>
<span slot="submit">${msg("Create")}</span>
<span slot="header">${msg("Create Token")}</span>
<span slot="header">${msg("New Token")}</span>
<ak-token-form slot="form"> </ak-token-form>
<button slot="trigger" class="pf-c-button pf-m-primary">${msg("Create")}</button>
</ak-forms-modal>

View File

@@ -76,7 +76,7 @@ export class UserDeviceTable extends Table<Device> {
renderToolbarSelected(): TemplateResult {
const disabled = this.selectedElements.length < 1;
return html`<ak-forms-delete-bulk
objectLabel=${msg("Device(s)")}
object-label=${msg("Device(s)")}
.objects=${this.selectedElements}
.delete=${(item: Device) => {
return this.deleteWrapper(item);

View File

@@ -6,6 +6,7 @@ import "#admin/users/UserForm";
import "#admin/users/UserImpersonateForm";
import "#admin/users/UserPasswordForm";
import "#admin/users/UserResetEmailForm";
import "#admin/users/UserRecoveryLinkForm";
import "#components/ak-status-label";
import "#elements/TreeView";
import "#elements/buttons/ActionButton/index";
@@ -15,12 +16,9 @@ import "@patternfly/elements/pf-tooltip/pf-tooltip.js";
import { DEFAULT_CONFIG } from "#common/api/config";
import { PFSize } from "#common/enums";
import { parseAPIResponseError } from "#common/errors/network";
import { userTypeToLabel } from "#common/labels";
import { MessageLevel } from "#common/messages";
import { DefaultUIConfig } from "#common/ui/config";
import { showAPIErrorMessage, showMessage } from "#elements/messages/MessageContainer";
import { WithBrandConfig } from "#elements/mixins/branding";
import { CapabilitiesEnum, WithCapabilitiesConfig } from "#elements/mixins/capabilities";
import { WithSession } from "#elements/mixins/session";
@@ -28,7 +26,6 @@ import { getURLParam, updateURLParams } from "#elements/router/RouteMatch";
import { PaginatedResponse, TableColumn, Timestamp } from "#elements/table/Table";
import { TablePage } from "#elements/table/TablePage";
import { SlottedTemplateResult } from "#elements/types";
import { writeToClipboard } from "#elements/utils/writeToClipboard";
import { CoreApi, CoreUsersExportCreateRequest, User, UserPath } from "@goauthentik/api";
@@ -41,33 +38,56 @@ import PFAlert from "@patternfly/patternfly/components/Alert/alert.css";
import PFCard from "@patternfly/patternfly/components/Card/card.css";
import PFDescriptionList from "@patternfly/patternfly/components/DescriptionList/description-list.css";
export const requestRecoveryLink = (user: User) =>
new CoreApi(DEFAULT_CONFIG)
.coreUsersRecoveryCreate({
id: user.pk,
})
.then((rec) =>
writeToClipboard(rec.link).then((wroteToClipboard) =>
showMessage({
level: MessageLevel.success,
message: rec.link,
description: wroteToClipboard
? msg("A copy of this recovery link has been placed in your clipboard")
: "",
}),
),
)
.catch((error: unknown) => parseAPIResponseError(error).then(showAPIErrorMessage));
export const renderRecoveryEmailRequest = (user: User) =>
html`<ak-forms-modal .closeAfterSuccessfulSubmit=${false} id="ak-email-recovery-request">
<span slot="submit">${msg("Send link")}</span>
<span slot="header">${msg("Send recovery link to user")}</span>
<ak-user-reset-email-form slot="form" .user=${user}> </ak-user-reset-email-form>
<button slot="trigger" class="pf-c-button pf-m-secondary">
${msg("Email recovery link")}
</button>
</ak-forms-modal>`;
export const renderRecoveryButtons = ({
user,
brandHasRecoveryFlow,
}: {
user: User;
brandHasRecoveryFlow: boolean;
}) =>
html` <ak-forms-modal size=${PFSize.Medium} id="update-password-request">
<span slot="submit">${msg("Update password")}</span>
<span slot="header">
${msg(str`Update ${user.name || user.username}'s password`)}
</span>
<ak-user-password-form
username=${user.username}
email=${ifDefined(user.email)}
slot="form"
.instancePk=${user.pk}
></ak-user-password-form>
<button slot="trigger" class="pf-c-button pf-m-secondary">
${msg("Set password")}
</button>
</ak-forms-modal>
${brandHasRecoveryFlow
? html`
<ak-forms-modal id="ak-link-recovery-request">
<span slot="submit"> ${msg("Create link")} </span>
<span slot="header"> ${msg("Create recovery link")} </span>
<ak-user-recovery-link-form slot="form" .user=${user}>
</ak-user-recovery-link-form>
<button slot="trigger" class="pf-c-button pf-m-secondary">
${msg("Create recovery link")}
</button>
</ak-forms-modal>
${user.email
? html`<ak-forms-modal id="ak-email-recovery-request">
<span slot="submit">${msg("Send link")}</span>
<span slot="header">${msg("Send recovery link to user")}</span>
<ak-user-reset-email-form slot="form" .user=${user}>
</ak-user-reset-email-form>
<button slot="trigger" class="pf-c-button pf-m-secondary">
${msg("Email recovery link")}
</button>
</ak-forms-modal>`
: html`<p>
${msg("To email a recovery link, set an email address for this user.")}
</p>`}
`
: html` <p>
${msg("To create a recovery link, set a recovery flow for the current brand.")}
</p>`}`;
const recoveryButtonStyles = css`
#recovery-request-buttons {
@@ -170,7 +190,7 @@ export class UserListPage extends WithBrandConfig(
</button>
</ak-user-bulk-revoke-sessions>
<ak-forms-delete-bulk
objectLabel=${msg("User(s)")}
object-label=${msg("User(s)")}
.objects=${this.selectedElements}
.metadata=${(item: User) => {
return [
@@ -318,8 +338,8 @@ export class UserListPage extends WithBrandConfig(
<dd class="pf-c-description-list__description">
<div class="pf-c-description-list__text">
<ak-user-active-form
object-label=${msg("User")}
.obj=${item}
objectLabel=${msg("User")}
.delete=${() => {
return new CoreApi(DEFAULT_CONFIG).coreUsersPartialUpdate({
id: item.pk,
@@ -342,42 +362,10 @@ export class UserListPage extends WithBrandConfig(
</dt>
<dd class="pf-c-description-list__description">
<div class="pf-c-description-list__text" id="recovery-request-buttons">
<ak-forms-modal size=${PFSize.Medium} id="update-password-request">
<span slot="submit">${msg("Update password")}</span>
<span slot="header">
${msg(str`Update ${item.name || item.username}'s password`)}
</span>
<ak-user-password-form
username=${item.username}
email=${ifDefined(item.email)}
slot="form"
.instancePk=${item.pk}
></ak-user-password-form>
<button slot="trigger" class="pf-c-button pf-m-secondary">
${msg("Set password")}
</button>
</ak-forms-modal>
${this.brand.flowRecovery
? html`
<ak-action-button
class="pf-m-secondary"
.apiRequest=${() => requestRecoveryLink(item)}
>
${msg("Create recovery link")}
</ak-action-button>
${item.email
? renderRecoveryEmailRequest(item)
: html`<span
>${msg(
"Recovery link cannot be emailed, user has no email address saved.",
)}</span
>`}
`
: html` <p>
${msg(
"To let a user directly reset their password, configure a recovery flow on the currently active brand.",
)}
</p>`}
${renderRecoveryButtons({
user: item,
brandHasRecoveryFlow: Boolean(this.brand.flowRecovery),
})}
</div>
</dd>
</div>

View File

@@ -0,0 +1,49 @@
import "#components/ak-text-input";
import { DEFAULT_CONFIG } from "#common/api/config";
import { writeToClipboard } from "#common/clipboard";
import { Form } from "#elements/forms/Form";
import { CoreApi, Link, User, UserRecoveryLinkRequest } from "@goauthentik/api";
import { msg } from "@lit/localize";
import { html, TemplateResult } from "lit";
import { customElement, property } from "lit/decorators.js";
@customElement("ak-user-recovery-link-form")
export class UserRecoveryLinkForm extends Form<UserRecoveryLinkRequest> {
@property({ attribute: false })
user!: User;
async send(data: UserRecoveryLinkRequest): Promise<Link> {
const response = await new CoreApi(DEFAULT_CONFIG).coreUsersRecoveryCreate({
id: this.user.pk,
userRecoveryLinkRequest: data,
});
await writeToClipboard(response.link, msg("Recovery link"));
return response;
}
renderForm(): TemplateResult {
return html`
<ak-text-input
name="tokenDuration"
label=${msg("Token duration")}
value="days=1"
.bighelp=${html`<p class="pf-c-form__helper-text">
${msg("If a recovery token already exists, its duration is updated.")}
</p>`}
>
</ak-text-input>
`;
}
}
declare global {
interface HTMLElementTagNameMap {
"ak-user-recovery-link-form": UserRecoveryLinkForm;
}
}

View File

@@ -1,3 +1,4 @@
import "#components/ak-text-input";
import "#elements/forms/HorizontalFormElement";
import "#elements/forms/SearchSelect/index";
@@ -8,11 +9,11 @@ import { Form } from "#elements/forms/Form";
import {
CoreApi,
CoreUsersRecoveryEmailCreateRequest,
Stage,
StagesAllListRequest,
StagesApi,
User,
UserRecoveryEmailRequest,
} from "@goauthentik/api";
import { msg } from "@lit/localize";
@@ -20,48 +21,59 @@ import { html, TemplateResult } from "lit";
import { customElement, property } from "lit/decorators.js";
@customElement("ak-user-reset-email-form")
export class UserResetEmailForm extends Form<CoreUsersRecoveryEmailCreateRequest> {
export class UserResetEmailForm extends Form<UserRecoveryEmailRequest> {
@property({ attribute: false })
user!: User;
getSuccessMessage(): string {
return msg("Successfully sent email.");
return msg("Successfully queued email.");
}
async send(data: CoreUsersRecoveryEmailCreateRequest): Promise<void> {
data.id = this.user.pk;
return new CoreApi(DEFAULT_CONFIG).coreUsersRecoveryEmailCreate(data);
async send(data: UserRecoveryEmailRequest): Promise<void> {
return new CoreApi(DEFAULT_CONFIG).coreUsersRecoveryEmailCreate({
id: this.user.pk,
userRecoveryEmailRequest: data,
});
}
protected override renderForm(): TemplateResult {
return html`<ak-form-element-horizontal
label=${msg("Email stage")}
required
name="emailStage"
>
<ak-search-select
.fetchObjects=${async (query?: string): Promise<Stage[]> => {
const args: StagesAllListRequest = {
ordering: "name",
};
if (query !== undefined) {
args.search = query;
}
const stages = await new StagesApi(DEFAULT_CONFIG).stagesEmailList(args);
return stages.results;
}}
.groupBy=${(items: Stage[]) => {
return groupBy(items, (stage) => stage.verboseNamePlural);
}}
.renderElement=${(stage: Stage): string => {
return stage.name;
}}
.value=${(stage: Stage | undefined): string | undefined => {
return stage?.pk;
}}
label=${msg("Email stage")}
required
name="emailStage"
>
</ak-search-select>
</ak-form-element-horizontal>`;
<ak-search-select
.fetchObjects=${async (query?: string): Promise<Stage[]> => {
const args: StagesAllListRequest = {
ordering: "name",
};
if (query !== undefined) {
args.search = query;
}
const stages = await new StagesApi(DEFAULT_CONFIG).stagesEmailList(args);
return stages.results;
}}
.groupBy=${(items: Stage[]) => {
return groupBy(items, (stage) => stage.verboseNamePlural);
}}
.renderElement=${(stage: Stage): string => {
return stage.name;
}}
.value=${(stage: Stage | undefined): string | undefined => {
return stage?.pk;
}}
>
</ak-search-select>
</ak-form-element-horizontal>
<ak-text-input
name="tokenDuration"
label=${msg("Token duration")}
value="days=1"
.bighelp=${html`<p class="pf-c-form__helper-text">
${msg("If a recovery token already exists, its duration is updated.")}
</p>`}
>
</ak-text-input>`;
}
}

View File

@@ -32,6 +32,7 @@ import { PFSize } from "#common/enums";
import { userTypeToLabel } from "#common/labels";
import { AKElement } from "#elements/Base";
import { WithBrandConfig } from "#elements/mixins/branding";
import { WithCapabilitiesConfig } from "#elements/mixins/capabilities";
import { WithSession } from "#elements/mixins/session";
import { Timestamp } from "#elements/table/shared";
@@ -39,7 +40,7 @@ import { Timestamp } from "#elements/table/shared";
import { setPageDetails } from "#components/ak-page-navbar";
import { type DescriptionPair, renderDescriptionList } from "#components/DescriptionList";
import { renderRecoveryEmailRequest, requestRecoveryLink } from "#admin/users/UserListPage";
import { renderRecoveryButtons } from "#admin/users/UserListPage";
import {
CapabilitiesEnum,
@@ -64,7 +65,7 @@ import PFDisplay from "@patternfly/patternfly/utilities/Display/display.css";
import PFSizing from "@patternfly/patternfly/utilities/Sizing/sizing.css";
@customElement("ak-user-view")
export class UserViewPage extends WithCapabilitiesConfig(WithSession(AKElement)) {
export class UserViewPage extends WithBrandConfig(WithCapabilitiesConfig(WithSession(AKElement))) {
@property({ type: Number })
set userId(id: number) {
new CoreApi(DEFAULT_CONFIG)
@@ -103,9 +104,9 @@ export class UserViewPage extends WithCapabilitiesConfig(WithSession(AKElement))
margin-right: 0;
}
#ak-email-recovery-request,
#update-password-request .pf-c-button,
#ak-email-recovery-request .pf-c-button {
#ak-email-recovery-request .pf-c-button,
#ak-link-recovery-request .pf-c-button {
width: 100%;
}
`,
@@ -129,7 +130,7 @@ export class UserViewPage extends WithCapabilitiesConfig(WithSession(AKElement))
[msg("Type"), userTypeToLabel(user.type)],
[msg("Superuser"), html`<ak-status-label type="warning" ?good=${user.isSuperuser}></ak-status-label>`],
[msg("Actions"), this.renderActionButtons(user)],
[msg("Recovery"), this.renderRecoveryButtons(user)],
[msg("Recovery"), renderRecoveryButtons({user, brandHasRecoveryFlow: Boolean(this.brand.flowRecovery)})],
];
return html`
@@ -155,7 +156,7 @@ export class UserViewPage extends WithCapabilitiesConfig(WithSession(AKElement))
</ak-forms-modal>
<ak-user-active-form
.obj=${user}
objectLabel=${msg("User")}
object-label=${msg("User")}
.delete=${() => {
return new CoreApi(DEFAULT_CONFIG).coreUsersPartialUpdate({
id: user.pk,
@@ -199,43 +200,6 @@ export class UserViewPage extends WithCapabilitiesConfig(WithSession(AKElement))
</div> `;
}
renderRecoveryButtons(user: User) {
return html`<div class="ak-button-collection">
<ak-forms-modal size=${PFSize.Medium} id="update-password-request">
<span slot="submit">${msg("Update password")}</span>
<span slot="header">
${msg(str`Update ${user.name || user.username}'s password`)}
</span>
<ak-user-password-form
username=${user.username}
email=${ifDefined(user.email)}
slot="form"
.instancePk=${user.pk}
>
</ak-user-password-form>
<button slot="trigger" class="pf-c-button pf-m-secondary pf-m-block">
<pf-tooltip position="top" content=${msg("Enter a new password for this user")}>
${msg("Set password")}
</pf-tooltip>
</button>
</ak-forms-modal>
<ak-action-button
id="reset-password-button"
class="pf-m-secondary pf-m-block"
.apiRequest=${() => requestRecoveryLink(user)}
>
<pf-tooltip
position="top"
content=${msg("Create a link for this user to reset their password")}
>
${msg("Create Recovery Link")}
</pf-tooltip>
</ak-action-button>
${user.email ? renderRecoveryEmailRequest(user) : nothing}
</div> `;
}
renderTabCredentialsToken(user: User): TemplateResult {
return html`
<ak-tabs pageIdentifier="userCredentialsTokens" vertical>
@@ -529,6 +493,7 @@ export class UserViewPage extends WithCapabilitiesConfig(WithSession(AKElement))
${this.renderTabApplications(this.user)}
</div>
<ak-rbac-object-permission-page
class="pf-c-page__main-section pf-m-no-padding-mobile"
role="tabpanel"
tabindex="0"
slot="page-permissions"

View File

@@ -131,12 +131,15 @@ export class DevRepeatedRequestsMiddleware implements Middleware, Disposable {
this.#requests.push(reqSig);
if (count > 2) {
showMessage({
level: MessageLevel.warning,
message: "[Dev] Consecutive requests detected",
description: html`${count} identical requests to
<pre>${reqSig}</pre>`,
});
showMessage(
{
level: MessageLevel.warning,
message: "[Dev] Consecutive requests detected",
description: html`${count} identical requests to
<pre>${reqSig}</pre>`,
},
true,
);
this.#logger.trace("Repeated request", reqSig);
}

View File

@@ -0,0 +1,94 @@
import { APIMessage, MessageLevel } from "#common/messages";
import { showMessage } from "#elements/messages/MessageContainer";
import { msg, str } from "@lit/localize";
function castToClipboardItem(input: string, mimeType = "text/plain"): ClipboardItem {
return new ClipboardItem({
[mimeType]: new Blob([input], {
type: mimeType,
}),
});
}
export async function doWriteToClipboard(...data: string[] | ClipboardItem[]): Promise<void> {
if (data.every((item) => typeof item === "string")) {
return navigator.clipboard.write(data.map((item) => castToClipboardItem(item)));
}
return navigator.clipboard.write(data);
}
/**
* Copies data to the clipboard.
*
* @param data The data to copy. Either a plain-text `string` or a {@linkcode ClipboardItem}.
* @param entityLabel Localized label for the copied entity, used in success message.
* @param description Optional description for the success message.
*
* @return A promise resolving to `true` on success, `false` on failure.
*/
export function writeToClipboard(
data: string | ClipboardItem | string[] | ClipboardItem[] | null | undefined,
entityLabel?: string,
description?: string,
): Promise<boolean> {
if (!data || (Array.isArray(data) && data.length === 0)) {
console.warn("Cannot write empty data to clipboard");
return Promise.resolve(false);
}
const normalized = typeof data === "string" ? castToClipboardItem(data) : data;
return doWriteToClipboard(...(Array.isArray(normalized) ? normalized : [normalized]))
.then(() => {
const message: APIMessage = {
level: MessageLevel.info,
icon: "fas fa-clipboard-check",
message: entityLabel
? msg(str`${entityLabel} copied to clipboard.`, {
id: "clipboard.write.success.message.entity",
})
: msg("Copied to clipboard.", {
id: "clipboard.write.success.generic",
}),
description,
};
showMessage(message, true);
return true;
})
.catch((error) => {
console.error("Failed to write to clipboard:", error);
const fallbackDescription = msg(
"Clipboard not available. Please copy the value manually.",
{
id: "clipboard.write.failure.description",
},
);
if (typeof data === "string") {
showMessage(
{
level: MessageLevel.warning,
message: data,
description: fallbackDescription,
},
true,
);
} else {
showMessage(
{
level: MessageLevel.warning,
message: fallbackDescription,
},
true,
);
}
return false;
});
}

View File

@@ -22,6 +22,13 @@ export class AKEnterpriseRefreshEvent extends Event {
}
}
declare global {
interface WindowEventMap {
[AKRefreshEvent.eventName]: AKRefreshEvent;
[AKEnterpriseRefreshEvent.eventName]: AKEnterpriseRefreshEvent;
}
}
export interface EventUser {
pk: number;
email?: string;

View File

@@ -19,6 +19,7 @@ export interface APIMessage {
level: MessageLevel;
message: string;
description?: string | TemplateResult;
icon?: string;
}
export class AKMessageEvent extends Event {

View File

@@ -319,10 +319,9 @@ export class ListSelect extends AKElement implements IListSelect {
tabindex="1"
part="ak-list-select-wrapper"
>
<ul
<menu
class="pf-c-dropdown__menu pf-m-static"
id="ak-list-select-list"
role="listbox"
tabindex="0"
part="ak-list-select"
>
@@ -330,7 +329,7 @@ export class ListSelect extends AKElement implements IListSelect {
${this.#options.grouped
? this.renderMenuGroups(this.#options.options)
: this.renderMenuItems(this.#options.options)}
</ul>
</menu>
</div> `;
}

View File

@@ -1,3 +1,4 @@
import { parseAPIResponseError, pluckErrorDetail } from "#common/errors/network";
import { MessageLevel } from "#common/messages";
import { BaseTaskButton } from "#elements/buttons/SpinnerButton/BaseTaskButton";
@@ -20,7 +21,7 @@ import { customElement, property } from "lit/decorators.js";
*/
@customElement("ak-action-button")
export class ActionButton extends BaseTaskButton {
export class ActionButton<R = unknown> extends BaseTaskButton<R> {
/**
* The command to run when the button is pressed. Must return a promise. If the promise is a
* reject or throw, we process the content of the promise and deliver it to the Notification
@@ -28,27 +29,22 @@ export class ActionButton extends BaseTaskButton {
*
* @attr
*/
@property({ attribute: false })
apiRequest: () => Promise<unknown> = () => {
throw new Error();
public apiRequest: () => Promise<R> = () => {
throw new TypeError("No API request defined for ActionButton");
};
constructor() {
super();
this.onError = this.onError.bind(this);
public override callAction(): Promise<R> {
return this.apiRequest();
}
callAction = (): Promise<unknown> => {
return this.apiRequest();
};
async onError(error: Error | Response) {
protected async onError(error: unknown) {
super.onError(error);
const message = error instanceof Error ? error.toString() : await error.text();
const parsedError = await parseAPIResponseError(error);
showMessage({
level: MessageLevel.error,
message: message,
message: pluckErrorDetail(parsedError),
});
}
}

View File

@@ -1,53 +1,116 @@
import { EVENT_REFRESH } from "#common/constants";
import { AKRefreshEvent } from "#common/events";
import { AKElement } from "#elements/Base";
import { listen } from "#elements/decorators/listen";
import { ConsoleLogger } from "#logger/browser";
import { html, TemplateResult } from "lit";
import { customElement } from "lit/decorators.js";
@customElement("ak-dropdown")
export class DropdownButton extends AKElement {
menu: HTMLElement | null = null;
constructor() {
super();
window.addEventListener(EVENT_REFRESH, this.show);
}
public show = (): void => {
if (!this.menu) return;
this.menu.hidden = true;
public static override shadowRootOptions: ShadowRootInit = {
...AKElement.shadowRootOptions,
delegatesFocus: true,
};
connectedCallback() {
super.connectedCallback();
protected createRenderRoot(): HTMLElement | DocumentFragment {
return this;
}
const menu = this.querySelector<HTMLElement>(".pf-c-dropdown__menu");
#menu: HTMLMenuElement | null = null;
#toggleButton: HTMLButtonElement | null = null;
#abortController: AbortController | null = null;
if (!menu) {
console.warn("authentik/dropdown: No menu found");
protected logger = ConsoleLogger.prefix("dropdown");
@listen(AKRefreshEvent)
public hide = (): void => {
if (!this.#menu || !this.#toggleButton) return;
this.#menu.hidden = true;
this.#toggleButton.ariaExpanded = "false";
};
public toggleMenu = (event: MouseEvent): void => {
if (!this.#menu) return;
const button = event.currentTarget as HTMLButtonElement;
this.#menu.hidden = !this.#menu.hidden;
button.ariaExpanded = this.#menu.hidden.toString();
event.stopPropagation();
};
@listen("click", {
passive: true,
})
protected clickHandler = (event: Event): void => {
if (!this.#menu) return;
if (this.#menu.hidden) {
return;
}
this.menu = menu;
if (event.defaultPrevented) {
return;
}
this.querySelectorAll("button.pf-c-dropdown__toggle").forEach((btn) => {
btn.addEventListener("click", () => {
if (!this.menu) return;
const target = event.target as HTMLElement;
if (this.#menu.contains(target)) {
return;
}
const toggle = this.querySelector<HTMLElement>("button.pf-c-dropdown__toggle");
if (toggle && toggle.contains(target)) {
return;
}
this.menu.hidden = !this.menu.hidden;
btn.ariaExpanded = (!this.menu.hidden).toString();
});
this.hide();
};
public override connectedCallback() {
super.connectedCallback();
this.#abortController = new AbortController();
this.#menu = this.querySelector<HTMLMenuElement>("menu.pf-c-dropdown__menu");
if (!this.#menu) {
this.logger.warn("No menu found");
return;
}
this.#toggleButton = this.querySelector<HTMLButtonElement>("button.pf-c-dropdown__toggle");
if (!this.#toggleButton) {
this.logger.warn("No toggle button found");
return;
}
this.#menu.hidden = true;
this.#toggleButton.ariaExpanded = "false";
this.#toggleButton.addEventListener("click", this.toggleMenu, {
capture: true,
signal: this.#abortController.signal,
});
// TODO: Enable this after native <dialog> modals are used.
// If enabled now, this would close the modal since it's technically within the dropdown.
// const menuItemButtons = this.querySelectorAll<HTMLElement>(".pf-c-dropdown__menu-item");
// for (const menuItemButton of menuItemButtons) {
// menuItemButton.addEventListener("click", this.hide, {
// capture: true,
// signal: this.#abortController.signal,
// });
// }
}
disconnectedCallback(): void {
public override disconnectedCallback(): void {
super.disconnectedCallback();
window.removeEventListener(EVENT_REFRESH, this.show);
}
render(): TemplateResult {
return html`<slot></slot>`;
this.#abortController?.abort();
}
}

View File

@@ -59,14 +59,12 @@ const SPINNER_TIMEOUT = 1000 * 1.5; // milliseconds
*
*/
export abstract class BaseTaskButton extends CustomEmitterElement(AKElement) {
eventPrefix = "ak-button";
export abstract class BaseTaskButton<R = unknown> extends CustomEmitterElement(AKElement) {
public eventPrefix = "ak-button";
static styles = [...buttonStyles];
public static styles = [...buttonStyles];
callAction!: () => Promise<unknown>;
actionTask: Task;
public callAction?(): Promise<R>;
@property({ type: Boolean })
public disabled = false;
@@ -74,25 +72,24 @@ export abstract class BaseTaskButton extends CustomEmitterElement(AKElement) {
@property({ type: String })
public label: string | null = null;
constructor() {
super();
this.onSuccess = this.onSuccess.bind(this);
this.onError = this.onError.bind(this);
this.onClick = this.onClick.bind(this);
this.actionTask = this.buildTask();
}
buildTask() {
return new Task(this, {
task: () => this.callAction(),
protected buildTask() {
return new Task<[], R>(this, {
task: () => {
if (typeof this.callAction !== "function") {
throw new TypeError("No action defined for SpinnerButton");
}
return this.callAction();
},
args: () => [],
autoRun: false,
onComplete: (r: unknown) => this.onSuccess(r),
onError: (r: unknown) => this.onError(r),
onComplete: (r: R) => this.onSuccess(r),
onError: (error) => this.onError(error),
});
}
onComplete() {
protected actionTask: Task = this.buildTask();
protected onComplete() {
setTimeout(() => {
this.dispatchCustomEvent(`${this.eventPrefix}-reset`);
// set-up for the next task...
@@ -101,34 +98,35 @@ export abstract class BaseTaskButton extends CustomEmitterElement(AKElement) {
}, SPINNER_TIMEOUT);
}
onSuccess(r: unknown) {
protected onSuccess(result: R): void {
this.dispatchCustomEvent(`${this.eventPrefix}-success`, {
result: r,
result,
});
this.onComplete();
}
onError(error: unknown) {
protected onError(error: unknown) {
this.dispatchCustomEvent(`${this.eventPrefix}-failure`, {
error,
});
this.onComplete();
}
onClick() {
protected onClick() {
// Don't accept clicks when a task is in progress..
if (this.actionTask.status === TaskStatus.PENDING) {
return;
}
this.dispatchCustomEvent(`${this.eventPrefix}-click`);
this.actionTask.run();
}
private spinner = html`<span class="pf-c-button__progress">
#spinner = html`<span class="pf-c-button__progress">
<ak-spinner size=${PFSize.Medium}></ak-spinner>
</span>`;
get buttonClasses() {
public get buttonClasses() {
return [
...this.classList,
StatusMap[this.actionTask.status],
@@ -138,7 +136,7 @@ export abstract class BaseTaskButton extends CustomEmitterElement(AKElement) {
.trim();
}
render() {
protected override render() {
return html`<button
id="spinner-button"
part="spinner-button"
@@ -150,7 +148,7 @@ export abstract class BaseTaskButton extends CustomEmitterElement(AKElement) {
?disabled=${this.disabled}
>
${this.actionTask.render({
pending: () => this.spinner,
pending: () => this.#spinner,
})}
<slot></slot>
</button>`;

Some files were not shown because too many files have changed in this diff Show More