From bb4602745e86d81ea74be12ed1530502b4c1114c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simonyi=20Gerg=C5=91?= Date: Tue, 15 Oct 2024 11:35:18 +0200 Subject: [PATCH] clean up recovery process by admin --- authentik/core/api/users.py | 129 ++++++++++--------- authentik/flows/models.py | 9 ++ authentik/lib/utils/time.py | 2 +- authentik/stages/email/stage.py | 33 +++-- schema.yml | 48 ++----- web/src/admin/groups/RelatedUserList.ts | 74 ++--------- web/src/admin/users/UserListPage.ts | 52 ++------ web/src/admin/users/UserRecoveryLinkForm.ts | 104 +++++++++++++++ web/src/admin/users/UserResetEmailForm.ts | 70 ---------- web/src/admin/users/UserViewPage.ts | 20 +-- website/integrations/services/slack/index.md | 2 +- 11 files changed, 248 insertions(+), 295 deletions(-) create mode 100644 web/src/admin/users/UserRecoveryLinkForm.ts delete mode 100644 web/src/admin/users/UserResetEmailForm.ts diff --git a/authentik/core/api/users.py b/authentik/core/api/users.py index a1bef44dc5..a78890d5a9 100644 --- a/authentik/core/api/users.py +++ b/authentik/core/api/users.py @@ -1,11 +1,12 @@ """User API Views""" -from datetime import timedelta +from datetime import datetime, timedelta +from hashlib import sha256 from json import loads from typing import Any 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.core.cache import cache 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.views.executor import QS_KEY_TOKEN 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.models import get_permission_choices from authentik.stages.email.models import EmailStage @@ -446,15 +448,19 @@ class UserViewSet(UsedByMixin, ModelViewSet): def list(self, 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), that can either be shown to an admin or sent to the user directly""" brand: Brand = self.request._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": [_("Recovery flow is not set for this brand.")]} + ) + # Mimic an unauthenticated user navigating the recovery flow user: User = self.get_object() + self.request._request.user = AnonymousUser() planner = FlowPlanner(flow) planner.allow_empty_flows = True try: @@ -466,16 +472,16 @@ class UserViewSet(UsedByMixin, ModelViewSet): ) except FlowNonApplicableException: raise ValidationError( - {"non_field_errors": "Recovery flow not applicable to user"} + {"non_field_errors": [_("Recovery flow is not applicable to this user.")]} ) from None - token, __ = FlowToken.objects.update_or_create( - identifier=f"{user.uid}-password-reset", - defaults={ - "user": user, - "flow": flow, - "_plan": FlowToken.pickle(plan), - }, + token = FlowToken.objects.create( + identifier=f"{user.uid}-password-reset-{sha256(str(datetime.now()).encode('UTF-8')).hexdigest()[:8]}", + user=user, + flow=flow, + _plan=FlowToken.pickle(plan), + expires=expires, ) + querystring = urlencode({QS_KEY_TOKEN: token.key}) link = self.request.build_absolute_uri( reverse_lazy("authentik_core:if-flow", kwargs={"flow_slug": flow.slug}) @@ -610,61 +616,68 @@ class UserViewSet(UsedByMixin, ModelViewSet): @permission_required("authentik_core.reset_user_password") @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={ "200": LinkSerializer(many=False), }, request=None, ) @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""" - link, _ = self._create_recovery_link() - return Response({"link": link}) + token_duration = request.query_params.get("token_duration", "") + 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") - @extend_schema( - parameters=[ - OpenApiParameter( - name="email_stage", - location=OpenApiParameter.QUERY, - type=OpenApiTypes.STR, - required=True, + if email_stage := request.query_params.get("email_stage"): + for_user: User = self.get_object() + if for_user.email == "": + LOGGER.debug("User doesn't have an email address") + raise ValidationError( + {"non_field_errors": [_("User does not have an email address set.")]} + ) + + # 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=email_stage) + if not stages.exists(): + if stages := EmailStage.objects.filter(pk=email_stage).exists(): + 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() + message = TemplateEmailMessage( + subject=_(email_stage.subject), + to=[(for_user.name, for_user.email)], + template_name=email_stage.template, + language=for_user.locale(request), + template_context={ + "url": link, + "user": for_user, + "expires": token.expires, + }, ) - ], - 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() - if for_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() - # 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() - message = TemplateEmailMessage( - subject=_(email_stage.subject), - to=[(for_user.name, for_user.email)], - template_name=email_stage.template, - language=for_user.locale(request), - template_context={ - "url": link, - "user": for_user, - "expires": token.expires, - }, - ) - send_mails(email_stage, message) - return Response(status=204) + send_mails(email_stage, message) + + return Response({"link": link}) @permission_required("authentik_core.impersonate") @extend_schema( diff --git a/authentik/flows/models.py b/authentik/flows/models.py index d4d0369556..a5915eeecb 100644 --- a/authentik/flows/models.py +++ b/authentik/flows/models.py @@ -36,6 +36,15 @@ class FlowAuthenticationRequirement(models.TextChoices): REQUIRE_REDIRECT = "require_redirect" 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): """Decides how the FlowExecutor should proceed when a stage isn't configured""" diff --git a/authentik/lib/utils/time.py b/authentik/lib/utils/time.py index 4e7338cdb4..3f6d3b86bf 100644 --- a/authentik/lib/utils/time.py +++ b/authentik/lib/utils/time.py @@ -31,7 +31,7 @@ def timedelta_string_validator(value: str): 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""" kwargs = {} for duration_pair in expr.split(";"): diff --git a/authentik/stages/email/stage.py b/authentik/stages/email/stage.py index 062dae62ef..944fcf03d1 100644 --- a/authentik/stages/email/stage.py +++ b/authentik/stages/email/stage.py @@ -17,7 +17,7 @@ from rest_framework.serializers import ValidationError from authentik.events.models import Event, EventAction from authentik.flows.challenge import Challenge, ChallengeResponse 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.stage import ChallengeStageView 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 already checked that there is a pending user.""" pending_user = self.get_pending_user() - if not pending_user.pk and self.executor.flow.designation == FlowDesignation.RECOVERY: - # Pending user does not have a primary key, and we're in a recovery flow, - # which means the user entered an invalid identifier, so we pretend to send the - # email, to not disclose if the user exists - return - email = self.executor.plan.context.get(PLAN_CONTEXT_EMAIL_OVERRIDE, None) + email = self.executor.plan.context.get(PLAN_CONTEXT_EMAIL_OVERRIDE, pending_user.email) + if FlowAuthenticationRequirement( + self.executor.flow.authentication + ).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 + if not email: + self.logger.debug( + "No recipient email address could be determined. Email not sent.", + pending_user=pending_user, + ) + return if not email: - email = pending_user.email + raise StageInvalidException( + "No recipient email address could be determined. Email not sent." + ) current_stage: EmailStage = self.executor.current_stage token = self.get_token() # Send mail to user @@ -133,7 +146,9 @@ class EmailStageView(ChallengeStageView): def get(self, request: HttpRequest, *args, **kwargs) -> HttpResponse: # 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() if restore_token: if restore_token.user != user: diff --git a/schema.yml b/schema.yml index 9896329197..7cf3ed877a 100644 --- a/schema.yml +++ b/schema.yml @@ -6095,17 +6095,26 @@ paths: schema: $ref: '#/components/schemas/GenericError' description: '' - /core/users/{id}/recovery/: + /core/users/{id}/recovery_link/: 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 parameters: + - in: query + name: email_stage + schema: + type: string - in: path name: id schema: type: integer description: A unique integer value identifying this User. required: true + - in: query + name: token_duration + schema: + type: string + required: true tags: - core security: @@ -6129,41 +6138,6 @@ paths: schema: $ref: '#/components/schemas/GenericError' 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/: post: operationId: core_users_set_password_create diff --git a/web/src/admin/groups/RelatedUserList.ts b/web/src/admin/groups/RelatedUserList.ts index ed133ede3d..edc56aa045 100644 --- a/web/src/admin/groups/RelatedUserList.ts +++ b/web/src/admin/groups/RelatedUserList.ts @@ -2,11 +2,14 @@ import "@goauthentik/admin/users/ServiceAccountForm"; import "@goauthentik/admin/users/UserActiveForm"; import "@goauthentik/admin/users/UserForm"; import "@goauthentik/admin/users/UserImpersonateForm"; +import { + renderRecoveryEmailRequest, + renderRecoveryLinkRequest, +} from "@goauthentik/admin/users/UserListPage"; import "@goauthentik/admin/users/UserPasswordForm"; -import "@goauthentik/admin/users/UserResetEmailForm"; +import "@goauthentik/admin/users/UserRecoveryLinkForm"; import { DEFAULT_CONFIG } from "@goauthentik/common/api/config"; import { PFSize } from "@goauthentik/common/enums.js"; -import { MessageLevel } from "@goauthentik/common/messages"; import { me } from "@goauthentik/common/users"; import { getRelativeTime } from "@goauthentik/common/utils"; import "@goauthentik/components/ak-status-label"; @@ -21,7 +24,6 @@ import "@goauthentik/elements/forms/DeleteBulkForm"; import { Form } from "@goauthentik/elements/forms/Form"; import "@goauthentik/elements/forms/HorizontalFormElement"; import "@goauthentik/elements/forms/ModalForm"; -import { showMessage } from "@goauthentik/elements/messages/MessageContainer"; import { getURLParam, updateURLParams } from "@goauthentik/elements/router/RouteMatch"; import { PaginatedResponse } 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 PFDescriptionList from "@patternfly/patternfly/components/DescriptionList/description-list.css"; -import { - CoreApi, - CoreUsersListTypeEnum, - Group, - ResponseError, - SessionUser, - User, -} from "@goauthentik/api"; +import { CoreApi, CoreUsersListTypeEnum, Group, SessionUser, User } from "@goauthentik/api"; @customElement("ak-user-related-add") export class RelatedUserAdd extends Form<{ users: number[] }> { @@ -301,60 +296,11 @@ export class RelatedUserList extends WithBrandConfig(WithCapabilitiesConfig(Tabl ${msg("Set password")} - ${this.brand?.flowRecovery + ${this.brand.flowRecovery ? html` - { - 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")} - + ${renderRecoveryLinkRequest(item)} ${item.email - ? html` - - ${msg("Send link")} - - - ${msg("Send recovery link to user")} - - - - - ` + ? renderRecoveryEmailRequest(item) : html`${msg( "Recovery link cannot be emailed, user has no email address saved.", @@ -363,7 +309,7 @@ export class RelatedUserList extends WithBrandConfig(WithCapabilitiesConfig(Tabl ` : html`

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

`} diff --git a/web/src/admin/users/UserListPage.ts b/web/src/admin/users/UserListPage.ts index 64ee851ead..9da9eee8db 100644 --- a/web/src/admin/users/UserListPage.ts +++ b/web/src/admin/users/UserListPage.ts @@ -4,11 +4,10 @@ import "@goauthentik/admin/users/UserActiveForm"; import "@goauthentik/admin/users/UserForm"; import "@goauthentik/admin/users/UserImpersonateForm"; import "@goauthentik/admin/users/UserPasswordForm"; -import "@goauthentik/admin/users/UserResetEmailForm"; +import "@goauthentik/admin/users/UserRecoveryLinkForm"; import { DEFAULT_CONFIG } from "@goauthentik/common/api/config"; import { PFSize } from "@goauthentik/common/enums.js"; import { userTypeToLabel } from "@goauthentik/common/labels"; -import { MessageLevel } from "@goauthentik/common/messages"; import { DefaultUIConfig, uiConfig } from "@goauthentik/common/ui/config"; import { me } from "@goauthentik/common/users"; import { getRelativeTime } from "@goauthentik/common/utils"; @@ -23,12 +22,10 @@ import "@goauthentik/elements/TreeView"; import "@goauthentik/elements/buttons/ActionButton"; import "@goauthentik/elements/forms/DeleteBulkForm"; import "@goauthentik/elements/forms/ModalForm"; -import { showMessage } from "@goauthentik/elements/messages/MessageContainer"; import { getURLParam, updateURLParams } from "@goauthentik/elements/router/RouteMatch"; import { PaginatedResponse } from "@goauthentik/elements/table/Table"; import { TableColumn } from "@goauthentik/elements/table/Table"; import { TablePage } from "@goauthentik/elements/table/TablePage"; -import { writeToClipboard } from "@goauthentik/elements/utils/writeToClipboard"; import "@patternfly/elements/pf-tooltip/pf-tooltip.js"; 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 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) => - 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((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 renderRecoveryLinkRequest = (user: User) => + html` + ${msg("Create link")} + ${msg("Create recovery link")} + + + `; export const renderRecoveryEmailRequest = (user: User) => html` ${msg("Send link")} ${msg("Send recovery link to user")} - + + @@ -362,12 +343,7 @@ export class UserListPage extends WithBrandConfig(WithCapabilitiesConfig(TablePa ${this.brand.flowRecovery ? html` - requestRecoveryLink(item)} - > - ${msg("Create recovery link")} - + ${renderRecoveryLinkRequest(item)} ${item.email ? renderRecoveryEmailRequest(item) : html` { + @property({ attribute: false }) + user!: User; + + @property({ type: Boolean }) + withEmailStage = false; + + async send(data: CoreUsersRecoveryLinkCreateRequest): Promise { + 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` + + => { + 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; + }} + > + + + `; + } + + renderForm(): TemplateResult { + return html` + ${this.renderEmailStageInput()} + + ${msg("Duration for generated token")} +

`} + > +
+ `; + } +} + +declare global { + interface HTMLElementTagNameMap { + "ak-user-recovery-link-form": UserRecoveryLinkForm; + } +} diff --git a/web/src/admin/users/UserResetEmailForm.ts b/web/src/admin/users/UserResetEmailForm.ts deleted file mode 100644 index a684fdbd05..0000000000 --- a/web/src/admin/users/UserResetEmailForm.ts +++ /dev/null @@ -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 { - @property({ attribute: false }) - user!: User; - - getSuccessMessage(): string { - return msg("Successfully sent email."); - } - - async send(data: CoreUsersRecoveryEmailCreateRequest): Promise { - data.id = this.user.pk; - return new CoreApi(DEFAULT_CONFIG).coreUsersRecoveryEmailCreate(data); - } - - renderForm(): TemplateResult { - return html` - => { - 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; - }} - > - - `; - } -} - -declare global { - interface HTMLElementTagNameMap { - "ak-user-reset-email-form": UserResetEmailForm; - } -} diff --git a/web/src/admin/users/UserViewPage.ts b/web/src/admin/users/UserViewPage.ts index 02890c0c65..380b8c318e 100644 --- a/web/src/admin/users/UserViewPage.ts +++ b/web/src/admin/users/UserViewPage.ts @@ -8,7 +8,7 @@ import "@goauthentik/admin/users/UserForm"; import "@goauthentik/admin/users/UserImpersonateForm"; import { renderRecoveryEmailRequest, - requestRecoveryLink, + renderRecoveryLinkRequest, } from "@goauthentik/admin/users/UserListPage"; import "@goauthentik/admin/users/UserPasswordForm"; import { DEFAULT_CONFIG } from "@goauthentik/common/api/config"; @@ -110,11 +110,8 @@ export class UserViewPage extends WithCapabilitiesConfig(AKElement) { .ak-button-collection > * { 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, #ak-email-recovery-request .pf-c-button { margin: 0; @@ -248,18 +245,7 @@ export class UserViewPage extends WithCapabilitiesConfig(AKElement) { - requestRecoveryLink(user)} - > - - ${msg("Create Recovery Link")} - - + ${renderRecoveryLinkRequest(user)} ${user.email ? renderRecoveryEmailRequest(user) : nothing} `; } diff --git a/website/integrations/services/slack/index.md b/website/integrations/services/slack/index.md index a974dc0bbd..a4993305ae 100644 --- a/website/integrations/services/slack/index.md +++ b/website/integrations/services/slack/index.md @@ -15,7 +15,7 @@ sidebar_label: Slack The following placeholder will be used: -- You can use slack.company> or my-workspace.slack.com as the FQDN of your Slack instance. +- You can use slack.company or my-workspace.slack.com as the FQDN of your Slack instance. - You can use authentik.company as the FQDN of the authentik installation. :::note