Compare commits
	
		
			1 Commits
		
	
	
		
			safari-adm
			...
			admin/add-
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
| bb4602745e | 
| @ -1,11 +1,12 @@ | |||||||
| """User API Views""" | """User API Views""" | ||||||
|  |  | ||||||
| from datetime import timedelta | from datetime import datetime, timedelta | ||||||
|  | from hashlib import sha256 | ||||||
| from json import loads | from json import loads | ||||||
| from typing import Any | from typing import Any | ||||||
|  |  | ||||||
| from django.contrib.auth import update_session_auth_hash | from django.contrib.auth import update_session_auth_hash | ||||||
| from django.contrib.auth.models import Permission | from django.contrib.auth.models import AnonymousUser, Permission | ||||||
| from django.contrib.sessions.backends.cache import KEY_PREFIX | from django.contrib.sessions.backends.cache import KEY_PREFIX | ||||||
| from django.core.cache import cache | from django.core.cache import cache | ||||||
| from django.db.models.functions import ExtractHour | from django.db.models.functions import ExtractHour | ||||||
| @ -84,6 +85,7 @@ from authentik.flows.models import FlowToken | |||||||
| from authentik.flows.planner import PLAN_CONTEXT_PENDING_USER, FlowPlanner | from authentik.flows.planner import PLAN_CONTEXT_PENDING_USER, FlowPlanner | ||||||
| from authentik.flows.views.executor import QS_KEY_TOKEN | from authentik.flows.views.executor import QS_KEY_TOKEN | ||||||
| from authentik.lib.avatars import get_avatar | from authentik.lib.avatars import get_avatar | ||||||
|  | from authentik.lib.utils.time import timedelta_from_string, timedelta_string_validator | ||||||
| from authentik.rbac.decorators import permission_required | from authentik.rbac.decorators import permission_required | ||||||
| from authentik.rbac.models import get_permission_choices | from authentik.rbac.models import get_permission_choices | ||||||
| from authentik.stages.email.models import EmailStage | from authentik.stages.email.models import EmailStage | ||||||
| @ -446,15 +448,19 @@ class UserViewSet(UsedByMixin, ModelViewSet): | |||||||
|     def list(self, request, *args, **kwargs): |     def list(self, request, *args, **kwargs): | ||||||
|         return super().list(request, *args, **kwargs) |         return super().list(request, *args, **kwargs) | ||||||
|  |  | ||||||
|     def _create_recovery_link(self) -> tuple[str, Token]: |     def _create_recovery_link(self, expires: datetime) -> tuple[str, Token]: | ||||||
|         """Create a recovery link (when the current brand has a recovery flow set), |         """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""" |         that can either be shown to an admin or sent to the user directly""" | ||||||
|         brand: Brand = self.request._request.brand |         brand: Brand = self.request._request.brand | ||||||
|         # Check that there is a recovery flow, if not return an error |         # Check that there is a recovery flow, if not return an error | ||||||
|         flow = brand.flow_recovery |         flow = brand.flow_recovery | ||||||
|         if not flow: |         if not flow: | ||||||
|             raise ValidationError({"non_field_errors": "No recovery flow set."}) |             raise ValidationError( | ||||||
|  |                 {"non_field_errors": [_("Recovery flow is not set for this brand.")]} | ||||||
|  |             ) | ||||||
|  |         # Mimic an unauthenticated user navigating the recovery flow | ||||||
|         user: User = self.get_object() |         user: User = self.get_object() | ||||||
|  |         self.request._request.user = AnonymousUser() | ||||||
|         planner = FlowPlanner(flow) |         planner = FlowPlanner(flow) | ||||||
|         planner.allow_empty_flows = True |         planner.allow_empty_flows = True | ||||||
|         try: |         try: | ||||||
| @ -466,16 +472,16 @@ class UserViewSet(UsedByMixin, ModelViewSet): | |||||||
|             ) |             ) | ||||||
|         except FlowNonApplicableException: |         except FlowNonApplicableException: | ||||||
|             raise ValidationError( |             raise ValidationError( | ||||||
|                 {"non_field_errors": "Recovery flow not applicable to user"} |                 {"non_field_errors": [_("Recovery flow is not applicable to this user.")]} | ||||||
|             ) from None |             ) from None | ||||||
|         token, __ = FlowToken.objects.update_or_create( |         token = FlowToken.objects.create( | ||||||
|             identifier=f"{user.uid}-password-reset", |             identifier=f"{user.uid}-password-reset-{sha256(str(datetime.now()).encode('UTF-8')).hexdigest()[:8]}", | ||||||
|             defaults={ |             user=user, | ||||||
|                 "user": user, |             flow=flow, | ||||||
|                 "flow": flow, |             _plan=FlowToken.pickle(plan), | ||||||
|                 "_plan": FlowToken.pickle(plan), |             expires=expires, | ||||||
|             }, |  | ||||||
|         ) |         ) | ||||||
|  |  | ||||||
|         querystring = urlencode({QS_KEY_TOKEN: token.key}) |         querystring = urlencode({QS_KEY_TOKEN: token.key}) | ||||||
|         link = self.request.build_absolute_uri( |         link = self.request.build_absolute_uri( | ||||||
|             reverse_lazy("authentik_core:if-flow", kwargs={"flow_slug": flow.slug}) |             reverse_lazy("authentik_core:if-flow", kwargs={"flow_slug": flow.slug}) | ||||||
| @ -610,47 +616,53 @@ class UserViewSet(UsedByMixin, ModelViewSet): | |||||||
|  |  | ||||||
|     @permission_required("authentik_core.reset_user_password") |     @permission_required("authentik_core.reset_user_password") | ||||||
|     @extend_schema( |     @extend_schema( | ||||||
|  |         parameters=[ | ||||||
|  |             OpenApiParameter( | ||||||
|  |                 name="email_stage", | ||||||
|  |                 location=OpenApiParameter.QUERY, | ||||||
|  |                 type=OpenApiTypes.STR, | ||||||
|  |             ), | ||||||
|  |             OpenApiParameter( | ||||||
|  |                 name="token_duration", | ||||||
|  |                 location=OpenApiParameter.QUERY, | ||||||
|  |                 type=OpenApiTypes.STR, | ||||||
|  |                 required=True, | ||||||
|  |             ), | ||||||
|  |         ], | ||||||
|         responses={ |         responses={ | ||||||
|             "200": LinkSerializer(many=False), |             "200": LinkSerializer(many=False), | ||||||
|         }, |         }, | ||||||
|         request=None, |         request=None, | ||||||
|     ) |     ) | ||||||
|     @action(detail=True, pagination_class=None, filter_backends=[], methods=["POST"]) |     @action(detail=True, pagination_class=None, filter_backends=[], methods=["POST"]) | ||||||
|     def recovery(self, request: Request, pk: int) -> Response: |     def recovery_link(self, request: Request, pk: int) -> Response: | ||||||
|         """Create a temporary link that a user can use to recover their accounts""" |         """Create a temporary link that a user can use to recover their accounts""" | ||||||
|         link, _ = self._create_recovery_link() |         token_duration = request.query_params.get("token_duration", "") | ||||||
|         return Response({"link": link}) |         timedelta_string_validator(token_duration) | ||||||
|  |         expires = now() + timedelta_from_string(token_duration) | ||||||
|  |         link, token = self._create_recovery_link(expires) | ||||||
|  |  | ||||||
|     @permission_required("authentik_core.reset_user_password") |         if email_stage := request.query_params.get("email_stage"): | ||||||
|     @extend_schema( |  | ||||||
|         parameters=[ |  | ||||||
|             OpenApiParameter( |  | ||||||
|                 name="email_stage", |  | ||||||
|                 location=OpenApiParameter.QUERY, |  | ||||||
|                 type=OpenApiTypes.STR, |  | ||||||
|                 required=True, |  | ||||||
|             ) |  | ||||||
|         ], |  | ||||||
|         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: |  | ||||||
|         """Create a temporary link that a user can use to recover their accounts""" |  | ||||||
|             for_user: User = self.get_object() |             for_user: User = self.get_object() | ||||||
|             if for_user.email == "": |             if for_user.email == "": | ||||||
|                 LOGGER.debug("User doesn't have an email address") |                 LOGGER.debug("User doesn't have an email address") | ||||||
|             raise ValidationError({"non_field_errors": "User does not have an email address set."}) |                 raise ValidationError( | ||||||
|         link, token = self._create_recovery_link() |                     {"non_field_errors": [_("User does not have an email address set.")]} | ||||||
|  |                 ) | ||||||
|  |  | ||||||
|             # Lookup the email stage to assure the current user can access it |             # Lookup the email stage to assure the current user can access it | ||||||
|             stages = get_objects_for_user( |             stages = get_objects_for_user( | ||||||
|                 request.user, "authentik_stages_email.view_emailstage" |                 request.user, "authentik_stages_email.view_emailstage" | ||||||
|         ).filter(pk=request.query_params.get("email_stage")) |             ).filter(pk=email_stage) | ||||||
|             if not stages.exists(): |             if not stages.exists(): | ||||||
|             LOGGER.debug("Email stage does not exist/user has no permissions") |                 if stages := EmailStage.objects.filter(pk=email_stage).exists(): | ||||||
|             raise ValidationError({"non_field_errors": "Email stage does not exist."}) |                     raise ValidationError( | ||||||
|  |                         {"non_field_errors": [_("User has no permissions to this Email stage.")]} | ||||||
|  |                     ) | ||||||
|  |                 else: | ||||||
|  |                     raise ValidationError( | ||||||
|  |                         {"non_field_errors": [_("The given Email stage does not exist.")]} | ||||||
|  |                     ) | ||||||
|             email_stage: EmailStage = stages.first() |             email_stage: EmailStage = stages.first() | ||||||
|             message = TemplateEmailMessage( |             message = TemplateEmailMessage( | ||||||
|                 subject=_(email_stage.subject), |                 subject=_(email_stage.subject), | ||||||
| @ -664,7 +676,8 @@ class UserViewSet(UsedByMixin, ModelViewSet): | |||||||
|                 }, |                 }, | ||||||
|             ) |             ) | ||||||
|             send_mails(email_stage, message) |             send_mails(email_stage, message) | ||||||
|         return Response(status=204) |  | ||||||
|  |         return Response({"link": link}) | ||||||
|  |  | ||||||
|     @permission_required("authentik_core.impersonate") |     @permission_required("authentik_core.impersonate") | ||||||
|     @extend_schema( |     @extend_schema( | ||||||
|  | |||||||
| @ -36,6 +36,15 @@ class FlowAuthenticationRequirement(models.TextChoices): | |||||||
|     REQUIRE_REDIRECT = "require_redirect" |     REQUIRE_REDIRECT = "require_redirect" | ||||||
|     REQUIRE_OUTPOST = "require_outpost" |     REQUIRE_OUTPOST = "require_outpost" | ||||||
|  |  | ||||||
|  |     @property | ||||||
|  |     def possibly_unauthenticated(self) -> bool: | ||||||
|  |         """Check if unauthenticated users can run this flow. Flows like this may require additional | ||||||
|  |         hardening.""" | ||||||
|  |         return self in [ | ||||||
|  |             FlowAuthenticationRequirement.NONE, | ||||||
|  |             FlowAuthenticationRequirement.REQUIRE_UNAUTHENTICATED, | ||||||
|  |         ] | ||||||
|  |  | ||||||
|  |  | ||||||
| class NotConfiguredAction(models.TextChoices): | class NotConfiguredAction(models.TextChoices): | ||||||
|     """Decides how the FlowExecutor should proceed when a stage isn't configured""" |     """Decides how the FlowExecutor should proceed when a stage isn't configured""" | ||||||
|  | |||||||
| @ -31,7 +31,7 @@ def timedelta_string_validator(value: str): | |||||||
|  |  | ||||||
|  |  | ||||||
| def timedelta_from_string(expr: str) -> datetime.timedelta: | def timedelta_from_string(expr: str) -> datetime.timedelta: | ||||||
|     """Convert a string with the format of 'hours=1;minute=3;seconds=5' to a |     """Convert a string with the format of 'hours=1;minutes=3;seconds=5' to a | ||||||
|     `datetime.timedelta` Object with hours = 1, minutes = 3, seconds = 5""" |     `datetime.timedelta` Object with hours = 1, minutes = 3, seconds = 5""" | ||||||
|     kwargs = {} |     kwargs = {} | ||||||
|     for duration_pair in expr.split(";"): |     for duration_pair in expr.split(";"): | ||||||
|  | |||||||
| @ -17,7 +17,7 @@ from rest_framework.serializers import ValidationError | |||||||
| from authentik.events.models import Event, EventAction | from authentik.events.models import Event, EventAction | ||||||
| from authentik.flows.challenge import Challenge, ChallengeResponse | from authentik.flows.challenge import Challenge, ChallengeResponse | ||||||
| from authentik.flows.exceptions import StageInvalidException | from authentik.flows.exceptions import StageInvalidException | ||||||
| from authentik.flows.models import FlowDesignation, FlowToken | from authentik.flows.models import FlowAuthenticationRequirement, FlowToken | ||||||
| from authentik.flows.planner import PLAN_CONTEXT_IS_RESTORED, PLAN_CONTEXT_PENDING_USER | from authentik.flows.planner import PLAN_CONTEXT_IS_RESTORED, PLAN_CONTEXT_PENDING_USER | ||||||
| from authentik.flows.stage import ChallengeStageView | from authentik.flows.stage import ChallengeStageView | ||||||
| from authentik.flows.views.executor import QS_KEY_TOKEN, QS_QUERY | from authentik.flows.views.executor import QS_KEY_TOKEN, QS_QUERY | ||||||
| @ -97,14 +97,27 @@ class EmailStageView(ChallengeStageView): | |||||||
|         """Helper function that sends the actual email. Implies that you've |         """Helper function that sends the actual email. Implies that you've | ||||||
|         already checked that there is a pending user.""" |         already checked that there is a pending user.""" | ||||||
|         pending_user = self.get_pending_user() |         pending_user = self.get_pending_user() | ||||||
|         if not pending_user.pk and self.executor.flow.designation == FlowDesignation.RECOVERY: |         email = self.executor.plan.context.get(PLAN_CONTEXT_EMAIL_OVERRIDE, pending_user.email) | ||||||
|             # Pending user does not have a primary key, and we're in a recovery flow, |         if FlowAuthenticationRequirement( | ||||||
|             # which means the user entered an invalid identifier, so we pretend to send the |             self.executor.flow.authentication | ||||||
|             # email, to not disclose if the user exists |         ).possibly_unauthenticated: | ||||||
|  |             # In possibly unauthenticated flows, do not disclose whether user or their email exists | ||||||
|  |             # to prevent enumeration attacks | ||||||
|  |             if not pending_user.pk: | ||||||
|  |                 self.logger.debug( | ||||||
|  |                     "User object does not exist. Email not sent.", pending_user=pending_user | ||||||
|  |                 ) | ||||||
|                 return |                 return | ||||||
|         email = self.executor.plan.context.get(PLAN_CONTEXT_EMAIL_OVERRIDE, None) |  | ||||||
|             if not email: |             if not email: | ||||||
|             email = pending_user.email |                 self.logger.debug( | ||||||
|  |                     "No recipient email address could be determined. Email not sent.", | ||||||
|  |                     pending_user=pending_user, | ||||||
|  |                 ) | ||||||
|  |                 return | ||||||
|  |         if not email: | ||||||
|  |             raise StageInvalidException( | ||||||
|  |                 "No recipient email address could be determined. Email not sent." | ||||||
|  |             ) | ||||||
|         current_stage: EmailStage = self.executor.current_stage |         current_stage: EmailStage = self.executor.current_stage | ||||||
|         token = self.get_token() |         token = self.get_token() | ||||||
|         # Send mail to user |         # Send mail to user | ||||||
| @ -133,7 +146,9 @@ class EmailStageView(ChallengeStageView): | |||||||
|  |  | ||||||
|     def get(self, request: HttpRequest, *args, **kwargs) -> HttpResponse: |     def get(self, request: HttpRequest, *args, **kwargs) -> HttpResponse: | ||||||
|         # Check if the user came back from the email link to verify |         # Check if the user came back from the email link to verify | ||||||
|         restore_token: FlowToken = self.executor.plan.context.get(PLAN_CONTEXT_IS_RESTORED, None) |         restore_token: FlowToken | None = self.executor.plan.context.get( | ||||||
|  |             PLAN_CONTEXT_IS_RESTORED, None | ||||||
|  |         ) | ||||||
|         user = self.get_pending_user() |         user = self.get_pending_user() | ||||||
|         if restore_token: |         if restore_token: | ||||||
|             if restore_token.user != user: |             if restore_token.user != user: | ||||||
|  | |||||||
							
								
								
									
										48
									
								
								schema.yml
									
									
									
									
									
								
							
							
						
						
									
										48
									
								
								schema.yml
									
									
									
									
									
								
							| @ -6095,17 +6095,26 @@ paths: | |||||||
|               schema: |               schema: | ||||||
|                 $ref: '#/components/schemas/GenericError' |                 $ref: '#/components/schemas/GenericError' | ||||||
|           description: '' |           description: '' | ||||||
|   /core/users/{id}/recovery/: |   /core/users/{id}/recovery_link/: | ||||||
|     post: |     post: | ||||||
|       operationId: core_users_recovery_create |       operationId: core_users_recovery_link_create | ||||||
|       description: Create a temporary link that a user can use to recover their accounts |       description: Create a temporary link that a user can use to recover their accounts | ||||||
|       parameters: |       parameters: | ||||||
|  |       - in: query | ||||||
|  |         name: email_stage | ||||||
|  |         schema: | ||||||
|  |           type: string | ||||||
|       - in: path |       - in: path | ||||||
|         name: id |         name: id | ||||||
|         schema: |         schema: | ||||||
|           type: integer |           type: integer | ||||||
|         description: A unique integer value identifying this User. |         description: A unique integer value identifying this User. | ||||||
|         required: true |         required: true | ||||||
|  |       - in: query | ||||||
|  |         name: token_duration | ||||||
|  |         schema: | ||||||
|  |           type: string | ||||||
|  |         required: true | ||||||
|       tags: |       tags: | ||||||
|       - core |       - core | ||||||
|       security: |       security: | ||||||
| @ -6129,41 +6138,6 @@ paths: | |||||||
|               schema: |               schema: | ||||||
|                 $ref: '#/components/schemas/GenericError' |                 $ref: '#/components/schemas/GenericError' | ||||||
|           description: '' |           description: '' | ||||||
|   /core/users/{id}/recovery_email/: |  | ||||||
|     post: |  | ||||||
|       operationId: core_users_recovery_email_create |  | ||||||
|       description: Create a temporary link that a user can use to recover their accounts |  | ||||||
|       parameters: |  | ||||||
|       - in: query |  | ||||||
|         name: email_stage |  | ||||||
|         schema: |  | ||||||
|           type: string |  | ||||||
|         required: true |  | ||||||
|       - in: path |  | ||||||
|         name: id |  | ||||||
|         schema: |  | ||||||
|           type: integer |  | ||||||
|         description: A unique integer value identifying this User. |  | ||||||
|         required: true |  | ||||||
|       tags: |  | ||||||
|       - core |  | ||||||
|       security: |  | ||||||
|       - authentik: [] |  | ||||||
|       responses: |  | ||||||
|         '204': |  | ||||||
|           description: Successfully sent recover email |  | ||||||
|         '400': |  | ||||||
|           content: |  | ||||||
|             application/json: |  | ||||||
|               schema: |  | ||||||
|                 $ref: '#/components/schemas/ValidationError' |  | ||||||
|           description: '' |  | ||||||
|         '403': |  | ||||||
|           content: |  | ||||||
|             application/json: |  | ||||||
|               schema: |  | ||||||
|                 $ref: '#/components/schemas/GenericError' |  | ||||||
|           description: '' |  | ||||||
|   /core/users/{id}/set_password/: |   /core/users/{id}/set_password/: | ||||||
|     post: |     post: | ||||||
|       operationId: core_users_set_password_create |       operationId: core_users_set_password_create | ||||||
|  | |||||||
| @ -2,11 +2,14 @@ import "@goauthentik/admin/users/ServiceAccountForm"; | |||||||
| import "@goauthentik/admin/users/UserActiveForm"; | import "@goauthentik/admin/users/UserActiveForm"; | ||||||
| import "@goauthentik/admin/users/UserForm"; | import "@goauthentik/admin/users/UserForm"; | ||||||
| import "@goauthentik/admin/users/UserImpersonateForm"; | import "@goauthentik/admin/users/UserImpersonateForm"; | ||||||
|  | import { | ||||||
|  |     renderRecoveryEmailRequest, | ||||||
|  |     renderRecoveryLinkRequest, | ||||||
|  | } from "@goauthentik/admin/users/UserListPage"; | ||||||
| import "@goauthentik/admin/users/UserPasswordForm"; | import "@goauthentik/admin/users/UserPasswordForm"; | ||||||
| import "@goauthentik/admin/users/UserResetEmailForm"; | import "@goauthentik/admin/users/UserRecoveryLinkForm"; | ||||||
| import { DEFAULT_CONFIG } from "@goauthentik/common/api/config"; | import { DEFAULT_CONFIG } from "@goauthentik/common/api/config"; | ||||||
| import { PFSize } from "@goauthentik/common/enums.js"; | import { PFSize } from "@goauthentik/common/enums.js"; | ||||||
| import { MessageLevel } from "@goauthentik/common/messages"; |  | ||||||
| import { me } from "@goauthentik/common/users"; | import { me } from "@goauthentik/common/users"; | ||||||
| import { getRelativeTime } from "@goauthentik/common/utils"; | import { getRelativeTime } from "@goauthentik/common/utils"; | ||||||
| import "@goauthentik/components/ak-status-label"; | import "@goauthentik/components/ak-status-label"; | ||||||
| @ -21,7 +24,6 @@ import "@goauthentik/elements/forms/DeleteBulkForm"; | |||||||
| import { Form } from "@goauthentik/elements/forms/Form"; | import { Form } from "@goauthentik/elements/forms/Form"; | ||||||
| import "@goauthentik/elements/forms/HorizontalFormElement"; | import "@goauthentik/elements/forms/HorizontalFormElement"; | ||||||
| import "@goauthentik/elements/forms/ModalForm"; | import "@goauthentik/elements/forms/ModalForm"; | ||||||
| import { showMessage } from "@goauthentik/elements/messages/MessageContainer"; |  | ||||||
| import { getURLParam, updateURLParams } from "@goauthentik/elements/router/RouteMatch"; | import { getURLParam, updateURLParams } from "@goauthentik/elements/router/RouteMatch"; | ||||||
| import { PaginatedResponse } from "@goauthentik/elements/table/Table"; | import { PaginatedResponse } from "@goauthentik/elements/table/Table"; | ||||||
| import { Table, TableColumn } from "@goauthentik/elements/table/Table"; | import { Table, TableColumn } from "@goauthentik/elements/table/Table"; | ||||||
| @ -37,14 +39,7 @@ import PFAlert from "@patternfly/patternfly/components/Alert/alert.css"; | |||||||
| import PFBanner from "@patternfly/patternfly/components/Banner/banner.css"; | import PFBanner from "@patternfly/patternfly/components/Banner/banner.css"; | ||||||
| import PFDescriptionList from "@patternfly/patternfly/components/DescriptionList/description-list.css"; | import PFDescriptionList from "@patternfly/patternfly/components/DescriptionList/description-list.css"; | ||||||
|  |  | ||||||
| import { | import { CoreApi, CoreUsersListTypeEnum, Group, SessionUser, User } from "@goauthentik/api"; | ||||||
|     CoreApi, |  | ||||||
|     CoreUsersListTypeEnum, |  | ||||||
|     Group, |  | ||||||
|     ResponseError, |  | ||||||
|     SessionUser, |  | ||||||
|     User, |  | ||||||
| } from "@goauthentik/api"; |  | ||||||
|  |  | ||||||
| @customElement("ak-user-related-add") | @customElement("ak-user-related-add") | ||||||
| export class RelatedUserAdd extends Form<{ users: number[] }> { | export class RelatedUserAdd extends Form<{ users: number[] }> { | ||||||
| @ -301,60 +296,11 @@ export class RelatedUserList extends WithBrandConfig(WithCapabilitiesConfig(Tabl | |||||||
|                                             ${msg("Set password")} |                                             ${msg("Set password")} | ||||||
|                                         </button> |                                         </button> | ||||||
|                                     </ak-forms-modal> |                                     </ak-forms-modal> | ||||||
|                                     ${this.brand?.flowRecovery |                                     ${this.brand.flowRecovery | ||||||
|                                         ? html` |                                         ? html` | ||||||
|                                               <ak-action-button |                                               ${renderRecoveryLinkRequest(item)} | ||||||
|                                                   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((ex: ResponseError) => { |  | ||||||
|                                                               ex.response.json().then(() => { |  | ||||||
|                                                                   showMessage({ |  | ||||||
|                                                                       level: MessageLevel.error, |  | ||||||
|                                                                       message: msg( |  | ||||||
|                                                                           "No recovery flow is configured.", |  | ||||||
|                                                                       ), |  | ||||||
|                                                                   }); |  | ||||||
|                                                               }); |  | ||||||
|                                                           }); |  | ||||||
|                                                   }} |  | ||||||
|                                               > |  | ||||||
|                                                   ${msg("Copy recovery link")} |  | ||||||
|                                               </ak-action-button> |  | ||||||
|                                               ${item.email |                                               ${item.email | ||||||
|                                                   ? html`<ak-forms-modal |                                                   ? renderRecoveryEmailRequest(item) | ||||||
|                                                         .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 |                                                   : html`<span | ||||||
|                                                         >${msg( |                                                         >${msg( | ||||||
|                                                             "Recovery link cannot be emailed, user has no email address saved.", |                                                             "Recovery link cannot be emailed, user has no email address saved.", | ||||||
| @ -363,7 +309,7 @@ export class RelatedUserList extends WithBrandConfig(WithCapabilitiesConfig(Tabl | |||||||
|                                           ` |                                           ` | ||||||
|                                         : html` <p> |                                         : html` <p> | ||||||
|                                               ${msg( |                                               ${msg( | ||||||
|                                                   "To let a user directly reset a their password, configure a recovery flow on the currently active brand.", |                                                   "To let a user directly reset their password, configure a recovery flow on the currently active brand.", | ||||||
|                                               )} |                                               )} | ||||||
|                                           </p>`} |                                           </p>`} | ||||||
|                                 </div> |                                 </div> | ||||||
|  | |||||||
| @ -4,11 +4,10 @@ import "@goauthentik/admin/users/UserActiveForm"; | |||||||
| import "@goauthentik/admin/users/UserForm"; | import "@goauthentik/admin/users/UserForm"; | ||||||
| import "@goauthentik/admin/users/UserImpersonateForm"; | import "@goauthentik/admin/users/UserImpersonateForm"; | ||||||
| import "@goauthentik/admin/users/UserPasswordForm"; | import "@goauthentik/admin/users/UserPasswordForm"; | ||||||
| import "@goauthentik/admin/users/UserResetEmailForm"; | import "@goauthentik/admin/users/UserRecoveryLinkForm"; | ||||||
| import { DEFAULT_CONFIG } from "@goauthentik/common/api/config"; | import { DEFAULT_CONFIG } from "@goauthentik/common/api/config"; | ||||||
| import { PFSize } from "@goauthentik/common/enums.js"; | import { PFSize } from "@goauthentik/common/enums.js"; | ||||||
| import { userTypeToLabel } from "@goauthentik/common/labels"; | import { userTypeToLabel } from "@goauthentik/common/labels"; | ||||||
| import { MessageLevel } from "@goauthentik/common/messages"; |  | ||||||
| import { DefaultUIConfig, uiConfig } from "@goauthentik/common/ui/config"; | import { DefaultUIConfig, uiConfig } from "@goauthentik/common/ui/config"; | ||||||
| import { me } from "@goauthentik/common/users"; | import { me } from "@goauthentik/common/users"; | ||||||
| import { getRelativeTime } from "@goauthentik/common/utils"; | import { getRelativeTime } from "@goauthentik/common/utils"; | ||||||
| @ -23,12 +22,10 @@ import "@goauthentik/elements/TreeView"; | |||||||
| import "@goauthentik/elements/buttons/ActionButton"; | import "@goauthentik/elements/buttons/ActionButton"; | ||||||
| import "@goauthentik/elements/forms/DeleteBulkForm"; | import "@goauthentik/elements/forms/DeleteBulkForm"; | ||||||
| import "@goauthentik/elements/forms/ModalForm"; | import "@goauthentik/elements/forms/ModalForm"; | ||||||
| import { showMessage } from "@goauthentik/elements/messages/MessageContainer"; |  | ||||||
| import { getURLParam, updateURLParams } from "@goauthentik/elements/router/RouteMatch"; | import { getURLParam, updateURLParams } from "@goauthentik/elements/router/RouteMatch"; | ||||||
| import { PaginatedResponse } from "@goauthentik/elements/table/Table"; | import { PaginatedResponse } from "@goauthentik/elements/table/Table"; | ||||||
| import { TableColumn } from "@goauthentik/elements/table/Table"; | import { TableColumn } from "@goauthentik/elements/table/Table"; | ||||||
| import { TablePage } from "@goauthentik/elements/table/TablePage"; | import { TablePage } from "@goauthentik/elements/table/TablePage"; | ||||||
| import { writeToClipboard } from "@goauthentik/elements/utils/writeToClipboard"; |  | ||||||
| import "@patternfly/elements/pf-tooltip/pf-tooltip.js"; | import "@patternfly/elements/pf-tooltip/pf-tooltip.js"; | ||||||
|  |  | ||||||
| import { msg, str } from "@lit/localize"; | import { msg, str } from "@lit/localize"; | ||||||
| @ -39,40 +36,24 @@ import PFAlert from "@patternfly/patternfly/components/Alert/alert.css"; | |||||||
| import PFCard from "@patternfly/patternfly/components/Card/card.css"; | import PFCard from "@patternfly/patternfly/components/Card/card.css"; | ||||||
| import PFDescriptionList from "@patternfly/patternfly/components/DescriptionList/description-list.css"; | import PFDescriptionList from "@patternfly/patternfly/components/DescriptionList/description-list.css"; | ||||||
|  |  | ||||||
| import { CoreApi, ResponseError, SessionUser, User, UserPath } from "@goauthentik/api"; | import { CoreApi, SessionUser, User, UserPath } from "@goauthentik/api"; | ||||||
|  |  | ||||||
| export const requestRecoveryLink = (user: User) => | export const renderRecoveryLinkRequest = (user: User) => | ||||||
|     new CoreApi(DEFAULT_CONFIG) |     html`<ak-forms-modal .closeAfterSuccessfulSubmit=${false} id="ak-link-recovery-request"> | ||||||
|         .coreUsersRecoveryCreate({ |         <span slot="submit"> ${msg("Create link")} </span> | ||||||
|             id: user.pk, |         <span slot="header"> ${msg("Create recovery link")} </span> | ||||||
|         }) |         <ak-user-recovery-link-form slot="form" .user=${user}> </ak-user-recovery-link-form> | ||||||
|         .then((rec) => |         <button slot="trigger" class="pf-c-button pf-m-secondary"> | ||||||
|             writeToClipboard(rec.link).then((wroteToClipboard) => |             ${msg("Create recovery link")} | ||||||
|                 showMessage({ |         </button> | ||||||
|                     level: MessageLevel.success, |     </ak-forms-modal>`; | ||||||
|                     message: rec.link, |  | ||||||
|                     description: wroteToClipboard |  | ||||||
|                         ? msg("A copy of this recovery link has been placed in your clipboard") |  | ||||||
|                         : "", |  | ||||||
|                 }), |  | ||||||
|             ), |  | ||||||
|         ) |  | ||||||
|         .catch((ex: ResponseError) => |  | ||||||
|             ex.response.json().then(() => |  | ||||||
|                 showMessage({ |  | ||||||
|                     level: MessageLevel.error, |  | ||||||
|                     message: msg( |  | ||||||
|                         "The current brand must have a recovery flow configured to use a recovery link", |  | ||||||
|                     ), |  | ||||||
|                 }), |  | ||||||
|             ), |  | ||||||
|         ); |  | ||||||
|  |  | ||||||
| export const renderRecoveryEmailRequest = (user: User) => | export const renderRecoveryEmailRequest = (user: User) => | ||||||
|     html`<ak-forms-modal .closeAfterSuccessfulSubmit=${false} id="ak-email-recovery-request"> |     html`<ak-forms-modal .closeAfterSuccessfulSubmit=${false} id="ak-email-recovery-request"> | ||||||
|         <span slot="submit"> ${msg("Send link")} </span> |         <span slot="submit"> ${msg("Send link")} </span> | ||||||
|         <span slot="header"> ${msg("Send recovery link to user")} </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> |         <ak-user-recovery-link-form slot="form" .user=${user} .withEmailStage=${true}> | ||||||
|  |         </ak-user-recovery-link-form> | ||||||
|         <button slot="trigger" class="pf-c-button pf-m-secondary"> |         <button slot="trigger" class="pf-c-button pf-m-secondary"> | ||||||
|             ${msg("Email recovery link")} |             ${msg("Email recovery link")} | ||||||
|         </button> |         </button> | ||||||
| @ -362,12 +343,7 @@ export class UserListPage extends WithBrandConfig(WithCapabilitiesConfig(TablePa | |||||||
|                                     </ak-forms-modal> |                                     </ak-forms-modal> | ||||||
|                                     ${this.brand.flowRecovery |                                     ${this.brand.flowRecovery | ||||||
|                                         ? html` |                                         ? html` | ||||||
|                                               <ak-action-button |                                               ${renderRecoveryLinkRequest(item)} | ||||||
|                                                   class="pf-m-secondary" |  | ||||||
|                                                   .apiRequest=${() => requestRecoveryLink(item)} |  | ||||||
|                                               > |  | ||||||
|                                                   ${msg("Create recovery link")} |  | ||||||
|                                               </ak-action-button> |  | ||||||
|                                               ${item.email |                                               ${item.email | ||||||
|                                                   ? renderRecoveryEmailRequest(item) |                                                   ? renderRecoveryEmailRequest(item) | ||||||
|                                                   : html`<span |                                                   : html`<span | ||||||
|  | |||||||
							
								
								
									
										104
									
								
								web/src/admin/users/UserRecoveryLinkForm.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										104
									
								
								web/src/admin/users/UserRecoveryLinkForm.ts
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,104 @@ | |||||||
|  | import { DEFAULT_CONFIG } from "@goauthentik/common/api/config"; | ||||||
|  | import { groupBy } from "@goauthentik/common/utils"; | ||||||
|  | import "@goauthentik/components/ak-text-input"; | ||||||
|  | import { Form } from "@goauthentik/elements/forms/Form"; | ||||||
|  | import "@goauthentik/elements/forms/HorizontalFormElement"; | ||||||
|  | import "@goauthentik/elements/forms/SearchSelect"; | ||||||
|  | import { writeToClipboard } from "@goauthentik/elements/utils/writeToClipboard"; | ||||||
|  |  | ||||||
|  | import { msg } from "@lit/localize"; | ||||||
|  | import { TemplateResult, html } from "lit"; | ||||||
|  | import { customElement, property } from "lit/decorators.js"; | ||||||
|  |  | ||||||
|  | import { | ||||||
|  |     CoreApi, | ||||||
|  |     CoreUsersRecoveryLinkCreateRequest, | ||||||
|  |     Link, | ||||||
|  |     Stage, | ||||||
|  |     StagesAllListRequest, | ||||||
|  |     StagesApi, | ||||||
|  |     User, | ||||||
|  | } from "@goauthentik/api"; | ||||||
|  |  | ||||||
|  | @customElement("ak-user-recovery-link-form") | ||||||
|  | export class UserRecoveryLinkForm extends Form<CoreUsersRecoveryLinkCreateRequest> { | ||||||
|  |     @property({ attribute: false }) | ||||||
|  |     user!: User; | ||||||
|  |  | ||||||
|  |     @property({ type: Boolean }) | ||||||
|  |     withEmailStage = false; | ||||||
|  |  | ||||||
|  |     async send(data: CoreUsersRecoveryLinkCreateRequest): Promise<Link> { | ||||||
|  |         data.id = this.user.pk; | ||||||
|  |         const response = await new CoreApi(DEFAULT_CONFIG).coreUsersRecoveryLinkCreate(data); | ||||||
|  |  | ||||||
|  |         if (this.withEmailStage) { | ||||||
|  |             this.successMessage = msg("Successfully sent email."); | ||||||
|  |         } else { | ||||||
|  |             const wroteToClipboard = await writeToClipboard(response.link); | ||||||
|  |             if (wroteToClipboard) { | ||||||
|  |                 this.successMessage = msg( | ||||||
|  |                     `A copy of this recovery link has been placed in your clipboard: ${response.link}`, | ||||||
|  |                 ); | ||||||
|  |             } else { | ||||||
|  |                 this.successMessage = msg( | ||||||
|  |                     `authentik does not have access to your clipboard, please copy the recovery link manually: ${response.link}`, | ||||||
|  |                 ); | ||||||
|  |             } | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         return response; | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     renderEmailStageInput(): TemplateResult { | ||||||
|  |         if (!this.withEmailStage) return html``; | ||||||
|  |         return html` | ||||||
|  |             <ak-form-element-horizontal name="emailStage" label=${msg("Email stage")} required> | ||||||
|  |                 <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> | ||||||
|  |         `; | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     renderForm(): TemplateResult { | ||||||
|  |         return html` | ||||||
|  |             ${this.renderEmailStageInput()} | ||||||
|  |             <ak-text-input | ||||||
|  |                 name="tokenDuration" | ||||||
|  |                 label=${msg("Token duration")} | ||||||
|  |                 required | ||||||
|  |                 value="days=1" | ||||||
|  |                 .bighelp=${html`<p class="pf-c-form__helper-text"> | ||||||
|  |                     ${msg("Duration for generated token")} | ||||||
|  |                 </p>`} | ||||||
|  |             > | ||||||
|  |             </ak-text-input> | ||||||
|  |         `; | ||||||
|  |     } | ||||||
|  | } | ||||||
|  |  | ||||||
|  | declare global { | ||||||
|  |     interface HTMLElementTagNameMap { | ||||||
|  |         "ak-user-recovery-link-form": UserRecoveryLinkForm; | ||||||
|  |     } | ||||||
|  | } | ||||||
| @ -1,70 +0,0 @@ | |||||||
| import { DEFAULT_CONFIG } from "@goauthentik/common/api/config"; |  | ||||||
| import { groupBy } from "@goauthentik/common/utils"; |  | ||||||
| import { Form } from "@goauthentik/elements/forms/Form"; |  | ||||||
| import "@goauthentik/elements/forms/HorizontalFormElement"; |  | ||||||
| import "@goauthentik/elements/forms/SearchSelect"; |  | ||||||
|  |  | ||||||
| import { msg } from "@lit/localize"; |  | ||||||
| import { TemplateResult, html } from "lit"; |  | ||||||
| import { customElement, property } from "lit/decorators.js"; |  | ||||||
|  |  | ||||||
| import { |  | ||||||
|     CoreApi, |  | ||||||
|     CoreUsersRecoveryEmailCreateRequest, |  | ||||||
|     Stage, |  | ||||||
|     StagesAllListRequest, |  | ||||||
|     StagesApi, |  | ||||||
|     User, |  | ||||||
| } from "@goauthentik/api"; |  | ||||||
|  |  | ||||||
| @customElement("ak-user-reset-email-form") |  | ||||||
| export class UserResetEmailForm extends Form<CoreUsersRecoveryEmailCreateRequest> { |  | ||||||
|     @property({ attribute: false }) |  | ||||||
|     user!: User; |  | ||||||
|  |  | ||||||
|     getSuccessMessage(): string { |  | ||||||
|         return msg("Successfully sent email."); |  | ||||||
|     } |  | ||||||
|  |  | ||||||
|     async send(data: CoreUsersRecoveryEmailCreateRequest): Promise<void> { |  | ||||||
|         data.id = this.user.pk; |  | ||||||
|         return new CoreApi(DEFAULT_CONFIG).coreUsersRecoveryEmailCreate(data); |  | ||||||
|     } |  | ||||||
|  |  | ||||||
|     renderForm(): TemplateResult { |  | ||||||
|         return html`<ak-form-element-horizontal |  | ||||||
|             label=${msg("Email stage")} |  | ||||||
|             ?required=${true} |  | ||||||
|             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; |  | ||||||
|                 }} |  | ||||||
|             > |  | ||||||
|             </ak-search-select> |  | ||||||
|         </ak-form-element-horizontal>`; |  | ||||||
|     } |  | ||||||
| } |  | ||||||
|  |  | ||||||
| declare global { |  | ||||||
|     interface HTMLElementTagNameMap { |  | ||||||
|         "ak-user-reset-email-form": UserResetEmailForm; |  | ||||||
|     } |  | ||||||
| } |  | ||||||
| @ -8,7 +8,7 @@ import "@goauthentik/admin/users/UserForm"; | |||||||
| import "@goauthentik/admin/users/UserImpersonateForm"; | import "@goauthentik/admin/users/UserImpersonateForm"; | ||||||
| import { | import { | ||||||
|     renderRecoveryEmailRequest, |     renderRecoveryEmailRequest, | ||||||
|     requestRecoveryLink, |     renderRecoveryLinkRequest, | ||||||
| } from "@goauthentik/admin/users/UserListPage"; | } from "@goauthentik/admin/users/UserListPage"; | ||||||
| import "@goauthentik/admin/users/UserPasswordForm"; | import "@goauthentik/admin/users/UserPasswordForm"; | ||||||
| import { DEFAULT_CONFIG } from "@goauthentik/common/api/config"; | import { DEFAULT_CONFIG } from "@goauthentik/common/api/config"; | ||||||
| @ -110,11 +110,8 @@ export class UserViewPage extends WithCapabilitiesConfig(AKElement) { | |||||||
|                 .ak-button-collection > * { |                 .ak-button-collection > * { | ||||||
|                     flex: 1 0 100%; |                     flex: 1 0 100%; | ||||||
|                 } |                 } | ||||||
|                 #reset-password-button { |  | ||||||
|                     margin-right: 0; |  | ||||||
|                 } |  | ||||||
|  |  | ||||||
|                 #ak-email-recovery-request, |                 #ak-link-recovery-request .pf-c-button, | ||||||
|                 #update-password-request .pf-c-button, |                 #update-password-request .pf-c-button, | ||||||
|                 #ak-email-recovery-request .pf-c-button { |                 #ak-email-recovery-request .pf-c-button { | ||||||
|                     margin: 0; |                     margin: 0; | ||||||
| @ -248,18 +245,7 @@ export class UserViewPage extends WithCapabilitiesConfig(AKElement) { | |||||||
|                     </pf-tooltip> |                     </pf-tooltip> | ||||||
|                 </button> |                 </button> | ||||||
|             </ak-forms-modal> |             </ak-forms-modal> | ||||||
|             <ak-action-button |             ${renderRecoveryLinkRequest(user)} | ||||||
|                 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} |             ${user.email ? renderRecoveryEmailRequest(user) : nothing} | ||||||
|         </div> `; |         </div> `; | ||||||
|     } |     } | ||||||
|  | |||||||
| @ -15,7 +15,7 @@ sidebar_label: Slack | |||||||
|  |  | ||||||
| The following placeholder will be used: | The following placeholder will be used: | ||||||
|  |  | ||||||
| - You can use <kbd>slack.<em>company</em>></kbd> or <kbd><em>my-workspace</em>.slack.com</kbd> as the FQDN of your Slack instance. | - You can use <kbd>slack.<em>company</em></kbd> or <kbd><em>my-workspace</em>.slack.com</kbd> as the FQDN of your Slack instance. | ||||||
| - You can use <kbd>authentik.company</kbd> as the FQDN of the authentik installation. | - You can use <kbd>authentik.company</kbd> as the FQDN of the authentik installation. | ||||||
|  |  | ||||||
| :::note | :::note | ||||||
|  | |||||||
		Reference in New Issue
	
	Block a user
	