core: add API to directly send recovery link to user

Signed-off-by: Jens Langhammer <jens.langhammer@beryju.org>
This commit is contained in:
Jens Langhammer 2021-08-10 13:54:59 +02:00
parent 68608087ec
commit 557724768a
8 changed files with 521 additions and 127 deletions

View file

@ -1,13 +1,16 @@
"""User API Views""" """User API Views"""
from json import loads from json import loads
from typing import Optional
from django.db.models.query import QuerySet from django.db.models.query import QuerySet
from django.urls import reverse_lazy from django.urls import reverse_lazy
from django.utils.http import urlencode from django.utils.http import urlencode
from django.utils.translation import gettext as _
from django_filters.filters import BooleanFilter, CharFilter from django_filters.filters import BooleanFilter, CharFilter
from django_filters.filterset import FilterSet from django_filters.filterset import FilterSet
from drf_spectacular.utils import extend_schema, extend_schema_field from drf_spectacular.types import OpenApiTypes
from guardian.utils import get_anonymous_user from drf_spectacular.utils import OpenApiParameter, extend_schema, extend_schema_field
from guardian.shortcuts import get_anonymous_user, get_objects_for_user
from rest_framework.decorators import action from rest_framework.decorators import action
from rest_framework.fields import CharField, JSONField, SerializerMethodField from rest_framework.fields import CharField, JSONField, SerializerMethodField
from rest_framework.permissions import IsAuthenticated from rest_framework.permissions import IsAuthenticated
@ -17,10 +20,12 @@ from rest_framework.serializers import (
BooleanField, BooleanField,
ListSerializer, ListSerializer,
ModelSerializer, ModelSerializer,
Serializer,
ValidationError, ValidationError,
) )
from rest_framework.viewsets import ModelViewSet from rest_framework.viewsets import ModelViewSet
from rest_framework_guardian.filters import ObjectPermissionsFilter from rest_framework_guardian.filters import ObjectPermissionsFilter
from structlog.stdlib import get_logger
from authentik.admin.api.metrics import CoordinateSerializer, get_events_per_1h from authentik.admin.api.metrics import CoordinateSerializer, get_events_per_1h
from authentik.api.decorators import permission_required from authentik.api.decorators import permission_required
@ -30,8 +35,13 @@ from authentik.core.api.utils import LinkSerializer, PassiveSerializer, is_dict
from authentik.core.middleware import SESSION_IMPERSONATE_ORIGINAL_USER, SESSION_IMPERSONATE_USER from authentik.core.middleware import SESSION_IMPERSONATE_ORIGINAL_USER, SESSION_IMPERSONATE_USER
from authentik.core.models import Token, TokenIntents, User from authentik.core.models import Token, TokenIntents, User
from authentik.events.models import EventAction from authentik.events.models import EventAction
from authentik.stages.email.models import EmailStage
from authentik.stages.email.tasks import send_mails
from authentik.stages.email.utils import TemplateEmailMessage
from authentik.tenants.models import Tenant from authentik.tenants.models import Tenant
LOGGER = get_logger()
class UserSerializer(ModelSerializer): class UserSerializer(ModelSerializer):
"""User Serializer""" """User Serializer"""
@ -171,6 +181,28 @@ class UserViewSet(UsedByMixin, ModelViewSet):
def get_queryset(self): # pragma: no cover def get_queryset(self): # pragma: no cover
return User.objects.all().exclude(pk=get_anonymous_user().pk) return User.objects.all().exclude(pk=get_anonymous_user().pk)
def _create_recovery_link(self) -> tuple[Optional[str], Optional[Token]]:
"""Create a recovery link (when the current tenant has a recovery flow set),
that can either be shown to an admin or sent to the user directly"""
tenant: Tenant = self.request._request.tenant
# Check that there is a recovery flow, if not return an error
flow = tenant.flow_recovery
if not flow:
LOGGER.debug("No recovery flow set")
return None, None
user: User = self.get_object()
token, __ = Token.objects.get_or_create(
identifier=f"{user.uid}-password-reset",
user=user,
intent=TokenIntents.INTENT_RECOVERY,
)
querystring = urlencode({"token": token.key})
link = self.request.build_absolute_uri(
reverse_lazy("authentik_core:if-flow", kwargs={"flow_slug": flow.slug})
+ f"?{querystring}"
)
return link, token
@extend_schema(responses={200: SessionUserSerializer(many=False)}) @extend_schema(responses={200: SessionUserSerializer(many=False)})
@action(detail=False, pagination_class=None, filter_backends=[]) @action(detail=False, pagination_class=None, filter_backends=[])
# pylint: disable=invalid-name # pylint: disable=invalid-name
@ -226,24 +258,60 @@ class UserViewSet(UsedByMixin, ModelViewSet):
# pylint: disable=invalid-name, unused-argument # pylint: disable=invalid-name, unused-argument
def recovery(self, request: Request, pk: int) -> Response: def recovery(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"""
tenant: Tenant = request._request.tenant link, _ = self._create_recovery_link()
# Check that there is a recovery flow, if not return an error if not link:
flow = tenant.flow_recovery LOGGER.debug("Couldn't create token")
if not flow:
return Response({"link": ""}, status=404) return Response({"link": ""}, status=404)
user: User = self.get_object()
token, __ = Token.objects.get_or_create(
identifier=f"{user.uid}-password-reset",
user=user,
intent=TokenIntents.INTENT_RECOVERY,
)
querystring = urlencode({"token": token.key})
link = request.build_absolute_uri(
reverse_lazy("authentik_core:if-flow", kwargs={"flow_slug": flow.slug})
+ f"?{querystring}"
)
return Response({"link": link}) 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,
)
],
responses={
"204": Serializer(),
"404": Serializer(),
},
)
@action(detail=True, pagination_class=None, filter_backends=[])
# pylint: disable=invalid-name, unused-argument
def recovery_email(self, request: Request, pk: int) -> Response:
"""Create a temporary link that a user can use to recover their accounts"""
for_user = self.get_object()
if for_user.email == "":
LOGGER.debug("User doesn't have an email address")
return Response(status=404)
link, token = self._create_recovery_link()
if not link:
LOGGER.debug("Couldn't create token")
return Response(status=404)
# 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")
return Response(status=404)
email_stage: EmailStage = stages.first()
message = TemplateEmailMessage(
subject=_(email_stage.subject),
template_name=email_stage.template,
to=[for_user.email],
template_context={
"url": link,
"user": for_user,
"expires": token.expires,
},
)
send_mails(email_stage, message)
return Response(status=204)
def _filter_queryset_for_list(self, queryset: QuerySet) -> QuerySet: def _filter_queryset_for_list(self, queryset: QuerySet) -> QuerySet:
"""Custom filter_queryset method which ignores guardian, but still supports sorting""" """Custom filter_queryset method which ignores guardian, but still supports sorting"""
for backend in list(self.filter_backends): for backend in list(self.filter_backends):

View file

@ -3,6 +3,9 @@ from django.urls.base import reverse
from rest_framework.test import APITestCase from rest_framework.test import APITestCase
from authentik.core.models import User from authentik.core.models import User
from authentik.flows.models import Flow, FlowDesignation
from authentik.stages.email.models import EmailStage
from authentik.tenants.models import Tenant
class TestUsersAPI(APITestCase): class TestUsersAPI(APITestCase):
@ -27,3 +30,78 @@ class TestUsersAPI(APITestCase):
reverse("authentik_api:user-metrics", kwargs={"pk": self.user.pk}) reverse("authentik_api:user-metrics", kwargs={"pk": self.user.pk})
) )
self.assertEqual(response.status_code, 403) self.assertEqual(response.status_code, 403)
def test_recovery_no_flow(self):
"""Test user recovery link (no recovery flow set)"""
self.client.force_login(self.admin)
response = self.client.get(
reverse("authentik_api:user-recovery", kwargs={"pk": self.user.pk})
)
self.assertEqual(response.status_code, 404)
def test_recovery(self):
"""Test user recovery link (no recovery flow set)"""
flow = Flow.objects.create(
name="test", title="test", slug="test", designation=FlowDesignation.RECOVERY
)
tenant: Tenant = Tenant.objects.first()
tenant.flow_recovery = flow
tenant.save()
self.client.force_login(self.admin)
response = self.client.get(
reverse("authentik_api:user-recovery", kwargs={"pk": self.user.pk})
)
self.assertEqual(response.status_code, 200)
def test_recovery_email_no_flow(self):
"""Test user recovery link (no recovery flow set)"""
self.client.force_login(self.admin)
response = self.client.get(
reverse("authentik_api:user-recovery-email", kwargs={"pk": self.user.pk})
)
self.assertEqual(response.status_code, 404)
self.user.email = "foo@bar.baz"
self.user.save()
response = self.client.get(
reverse("authentik_api:user-recovery-email", kwargs={"pk": self.user.pk})
)
self.assertEqual(response.status_code, 404)
def test_recovery_email_no_stage(self):
"""Test user recovery link (no email stage)"""
self.user.email = "foo@bar.baz"
self.user.save()
flow = Flow.objects.create(
name="test", title="test", slug="test", designation=FlowDesignation.RECOVERY
)
tenant: Tenant = Tenant.objects.first()
tenant.flow_recovery = flow
tenant.save()
self.client.force_login(self.admin)
response = self.client.get(
reverse("authentik_api:user-recovery-email", kwargs={"pk": self.user.pk})
)
self.assertEqual(response.status_code, 404)
def test_recovery_email(self):
"""Test user recovery link"""
self.user.email = "foo@bar.baz"
self.user.save()
flow = Flow.objects.create(
name="test", title="test", slug="test", designation=FlowDesignation.RECOVERY
)
tenant: Tenant = Tenant.objects.first()
tenant.flow_recovery = flow
tenant.save()
stage = EmailStage.objects.create(name="email")
self.client.force_login(self.admin)
response = self.client.get(
reverse(
"authentik_api:user-recovery-email",
kwargs={"pk": self.user.pk},
)
+ f"?email_stage={stage.pk}"
)
self.assertEqual(response.status_code, 204)

View file

@ -3185,6 +3185,36 @@ paths:
$ref: '#/components/schemas/ValidationError' $ref: '#/components/schemas/ValidationError'
'403': '403':
$ref: '#/components/schemas/GenericError' $ref: '#/components/schemas/GenericError'
/api/v2beta/core/users/{id}/recovery_email/:
get:
operationId: core_users_recovery_email_retrieve
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: []
- cookieAuth: []
responses:
'204':
description: No response body
'404':
description: No response body
'400':
$ref: '#/components/schemas/ValidationError'
'403':
$ref: '#/components/schemas/GenericError'
/api/v2beta/core/users/{id}/used_by/: /api/v2beta/core/users/{id}/used_by/:
get: get:
operationId: core_users_used_by_list operationId: core_users_used_by_list

View file

@ -122,8 +122,13 @@ msgstr "Actions"
msgid "Actions over the last 24 hours" msgid "Actions over the last 24 hours"
msgstr "Actions over the last 24 hours" msgstr "Actions over the last 24 hours"
#: src/pages/users/UserListPage.ts
msgid "Activate"
msgstr "Activate"
#: src/pages/groups/MemberSelectModal.ts #: src/pages/groups/MemberSelectModal.ts
#: src/pages/users/UserListPage.ts #: src/pages/users/UserListPage.ts
#: src/pages/users/UserListPage.ts
#: src/pages/users/UserViewPage.ts #: src/pages/users/UserViewPage.ts
msgid "Active" msgid "Active"
msgstr "Active" msgstr "Active"
@ -560,6 +565,10 @@ msgstr "Certificates"
msgid "Change password" msgid "Change password"
msgstr "Change password" msgstr "Change password"
#: src/pages/users/UserListPage.ts
msgid "Change status"
msgstr "Change status"
#: src/pages/user-settings/settings/UserSettingsPassword.ts #: src/pages/user-settings/settings/UserSettingsPassword.ts
msgid "Change your password" msgid "Change your password"
msgstr "Change your password" msgstr "Change your password"
@ -862,6 +871,10 @@ msgstr "Copy Key"
msgid "Copy download URL" msgid "Copy download URL"
msgstr "Copy download URL" msgstr "Copy download URL"
#: src/pages/users/UserListPage.ts
msgid "Copy recovery link"
msgstr "Copy recovery link"
#: src/pages/applications/ApplicationForm.ts #: src/pages/applications/ApplicationForm.ts
#: src/pages/applications/ApplicationListPage.ts #: src/pages/applications/ApplicationListPage.ts
#: src/pages/applications/ApplicationListPage.ts #: src/pages/applications/ApplicationListPage.ts
@ -1042,6 +1055,10 @@ msgstr "Date"
msgid "Date Time" msgid "Date Time"
msgstr "Date Time" msgstr "Date Time"
#: src/pages/users/UserListPage.ts
msgid "Deactivate"
msgstr "Deactivate"
#: src/pages/flows/FlowForm.ts #: src/pages/flows/FlowForm.ts
msgid "Decides what this Flow is used for. For example, the Authentication flow is redirect to when an un-authenticated user visits authentik." msgid "Decides what this Flow is used for. For example, the Authentication flow is redirect to when an un-authenticated user visits authentik."
msgstr "Decides what this Flow is used for. For example, the Authentication flow is redirect to when an un-authenticated user visits authentik." msgstr "Decides what this Flow is used for. For example, the Authentication flow is redirect to when an un-authenticated user visits authentik."
@ -1200,10 +1217,10 @@ msgstr "Digest algorithm"
msgid "Digits" msgid "Digits"
msgstr "Digits" msgstr "Digits"
#: src/pages/users/UserListPage.ts #:
#: src/pages/users/UserListPage.ts #:
msgid "Disable" #~ msgid "Disable"
msgstr "Disable" #~ msgstr "Disable"
#: src/pages/user-settings/settings/UserSettingsAuthenticatorDuo.ts #: src/pages/user-settings/settings/UserSettingsAuthenticatorDuo.ts
msgid "Disable Duo authenticator" msgid "Disable Duo authenticator"
@ -1286,7 +1303,6 @@ msgstr "Each provider has a different issuer, based on the application slug."
#: src/pages/sources/oauth/OAuthSourceViewPage.ts #: src/pages/sources/oauth/OAuthSourceViewPage.ts
#: src/pages/sources/plex/PlexSourceViewPage.ts #: src/pages/sources/plex/PlexSourceViewPage.ts
#: src/pages/sources/saml/SAMLSourceViewPage.ts #: src/pages/sources/saml/SAMLSourceViewPage.ts
#: src/pages/users/UserListPage.ts
#: src/pages/users/UserViewPage.ts #: src/pages/users/UserViewPage.ts
msgid "Edit" msgid "Edit"
msgstr "Edit" msgstr "Edit"
@ -1333,18 +1349,26 @@ msgstr "Email address"
msgid "Email info:" msgid "Email info:"
msgstr "Email info:" msgstr "Email info:"
#: src/pages/users/UserListPage.ts
msgid "Email recovery link"
msgstr "Email recovery link"
#: src/pages/events/utils.ts #: src/pages/events/utils.ts
msgid "Email sent" msgid "Email sent"
msgstr "Email sent" msgstr "Email sent"
#: src/pages/users/UserResetEmailForm.ts
msgid "Email stage"
msgstr "Email stage"
#: src/pages/stages/prompt/PromptForm.ts #: src/pages/stages/prompt/PromptForm.ts
msgid "Email: Text field with Email type." msgid "Email: Text field with Email type."
msgstr "Email: Text field with Email type." msgstr "Email: Text field with Email type."
#: src/pages/users/UserListPage.ts #:
#: src/pages/users/UserListPage.ts #:
msgid "Enable" #~ msgid "Enable"
msgstr "Enable" #~ msgstr "Enable"
#: src/pages/user-settings/settings/UserSettingsAuthenticatorDuo.ts #: src/pages/user-settings/settings/UserSettingsAuthenticatorDuo.ts
msgid "Enable Duo authenticator" msgid "Enable Duo authenticator"
@ -1942,6 +1966,10 @@ msgstr "Import certificates of external providers or create certificates to sign
msgid "In case you can't access any other method." msgid "In case you can't access any other method."
msgstr "In case you can't access any other method." msgstr "In case you can't access any other method."
#: src/pages/users/UserListPage.ts
msgid "Inactive"
msgstr "Inactive"
#: src/pages/providers/oauth2/OAuth2ProviderForm.ts #: src/pages/providers/oauth2/OAuth2ProviderForm.ts
msgid "Include User claims from scopes in the id_token, for applications that don't access the userinfo endpoint." msgid "Include User claims from scopes in the id_token, for applications that don't access the userinfo endpoint."
msgstr "Include User claims from scopes in the id_token, for applications that don't access the userinfo endpoint." msgstr "Include User claims from scopes in the id_token, for applications that don't access the userinfo endpoint."
@ -2210,6 +2238,7 @@ msgstr "Loading"
#: src/pages/tenants/TenantForm.ts #: src/pages/tenants/TenantForm.ts
#: src/pages/tenants/TenantForm.ts #: src/pages/tenants/TenantForm.ts
#: src/pages/tenants/TenantForm.ts #: src/pages/tenants/TenantForm.ts
#: src/pages/users/UserResetEmailForm.ts
msgid "Loading..." msgid "Loading..."
msgstr "Loading..." msgstr "Loading..."
@ -3066,6 +3095,7 @@ msgid "Receive a push notification on your phone to prove your identity."
msgstr "Receive a push notification on your phone to prove your identity." msgstr "Receive a push notification on your phone to prove your identity."
#: src/pages/flows/FlowForm.ts #: src/pages/flows/FlowForm.ts
#: src/pages/users/UserListPage.ts
msgid "Recovery" msgid "Recovery"
msgstr "Recovery" msgstr "Recovery"
@ -3082,6 +3112,10 @@ msgstr "Recovery flow. If left empty, the first applicable flow sorted by the sl
msgid "Recovery keys" msgid "Recovery keys"
msgstr "Recovery keys" msgstr "Recovery keys"
#: src/pages/users/UserListPage.ts
msgid "Recovery link cannot be emailed, user has no email address saved."
msgstr "Recovery link cannot be emailed, user has no email address saved."
#: src/pages/providers/saml/SAMLProviderForm.ts #: src/pages/providers/saml/SAMLProviderForm.ts
msgid "Redirect" msgid "Redirect"
msgstr "Redirect" msgstr "Redirect"
@ -3114,6 +3148,10 @@ msgstr "Register device"
msgid "Regular expressions for which authentication is not required. Each new line is interpreted as a new Regular Expression." msgid "Regular expressions for which authentication is not required. Each new line is interpreted as a new Regular Expression."
msgstr "Regular expressions for which authentication is not required. Each new line is interpreted as a new Regular Expression." msgstr "Regular expressions for which authentication is not required. Each new line is interpreted as a new Regular Expression."
#: src/pages/users/UserListPage.ts
msgid "Regular user"
msgstr "Regular user"
#: src/pages/applications/ApplicationViewPage.ts #: src/pages/applications/ApplicationViewPage.ts
#: src/pages/flows/FlowViewPage.ts #: src/pages/flows/FlowViewPage.ts
msgid "Related" msgid "Related"
@ -3161,7 +3199,6 @@ msgstr "Required"
msgid "Required. 150 characters or fewer. Letters, digits and @/./+/-/_ only." msgid "Required. 150 characters or fewer. Letters, digits and @/./+/-/_ only."
msgstr "Required. 150 characters or fewer. Letters, digits and @/./+/-/_ only." msgstr "Required. 150 characters or fewer. Letters, digits and @/./+/-/_ only."
#: src/pages/users/UserListPage.ts
#: src/pages/users/UserViewPage.ts #: src/pages/users/UserViewPage.ts
msgid "Reset Password" msgid "Reset Password"
msgstr "Reset Password" msgstr "Reset Password"
@ -3362,6 +3399,10 @@ msgstr "Selection of backends to test the password against."
msgid "Send Email again." msgid "Send Email again."
msgstr "Send Email again." msgstr "Send Email again."
#: src/pages/users/UserListPage.ts
msgid "Send link"
msgstr "Send link"
#: src/pages/events/RuleListPage.ts #: src/pages/events/RuleListPage.ts
msgid "Send notifications whenever a specific Event is created and matched by policies." msgid "Send notifications whenever a specific Event is created and matched by policies."
msgstr "Send notifications whenever a specific Event is created and matched by policies." msgstr "Send notifications whenever a specific Event is created and matched by policies."
@ -3370,6 +3411,10 @@ msgstr "Send notifications whenever a specific Event is created and matched by p
msgid "Send once" msgid "Send once"
msgstr "Send once" msgstr "Send once"
#: src/pages/users/UserListPage.ts
msgid "Send recovery link to user"
msgstr "Send recovery link to user"
#: src/pages/events/RuleListPage.ts #: src/pages/events/RuleListPage.ts
msgid "Sent to group" msgid "Sent to group"
msgstr "Sent to group" msgstr "Sent to group"
@ -3793,6 +3838,10 @@ msgstr "Successfully imported flow."
msgid "Successfully imported provider." msgid "Successfully imported provider."
msgstr "Successfully imported provider." msgstr "Successfully imported provider."
#: src/pages/users/UserResetEmailForm.ts
msgid "Successfully sent email."
msgstr "Successfully sent email."
#: src/pages/applications/ApplicationCheckAccessForm.ts #: src/pages/applications/ApplicationCheckAccessForm.ts
#: src/pages/policies/PolicyTestForm.ts #: src/pages/policies/PolicyTestForm.ts
#: src/pages/property-mappings/PropertyMappingTestForm.ts #: src/pages/property-mappings/PropertyMappingTestForm.ts
@ -3925,6 +3974,7 @@ msgstr "Successfully updated user."
msgid "Successfully updated {0} {1}" msgid "Successfully updated {0} {1}"
msgstr "Successfully updated {0} {1}" msgstr "Successfully updated {0} {1}"
#: src/pages/users/UserListPage.ts
#: src/pages/users/UserViewPage.ts #: src/pages/users/UserViewPage.ts
msgid "Superuser" msgid "Superuser"
msgstr "Superuser" msgstr "Superuser"
@ -4569,6 +4619,10 @@ msgstr "User object filter"
msgid "User password writeback" msgid "User password writeback"
msgstr "User password writeback" msgstr "User password writeback"
#: src/pages/users/UserListPage.ts
msgid "User status"
msgstr "User status"
#: src/pages/events/utils.ts #: src/pages/events/utils.ts
msgid "User was written to" msgid "User was written to"
msgstr "User was written to" msgstr "User was written to"

View file

@ -122,8 +122,13 @@ msgstr ""
msgid "Actions over the last 24 hours" msgid "Actions over the last 24 hours"
msgstr "" msgstr ""
#: src/pages/users/UserListPage.ts
msgid "Activate"
msgstr ""
#: src/pages/groups/MemberSelectModal.ts #: src/pages/groups/MemberSelectModal.ts
#: src/pages/users/UserListPage.ts #: src/pages/users/UserListPage.ts
#: src/pages/users/UserListPage.ts
#: src/pages/users/UserViewPage.ts #: src/pages/users/UserViewPage.ts
msgid "Active" msgid "Active"
msgstr "" msgstr ""
@ -556,6 +561,10 @@ msgstr ""
msgid "Change password" msgid "Change password"
msgstr "" msgstr ""
#: src/pages/users/UserListPage.ts
msgid "Change status"
msgstr ""
#: src/pages/user-settings/settings/UserSettingsPassword.ts #: src/pages/user-settings/settings/UserSettingsPassword.ts
msgid "Change your password" msgid "Change your password"
msgstr "" msgstr ""
@ -856,6 +865,10 @@ msgstr ""
msgid "Copy download URL" msgid "Copy download URL"
msgstr "" msgstr ""
#: src/pages/users/UserListPage.ts
msgid "Copy recovery link"
msgstr ""
#: src/pages/applications/ApplicationForm.ts #: src/pages/applications/ApplicationForm.ts
#: src/pages/applications/ApplicationListPage.ts #: src/pages/applications/ApplicationListPage.ts
#: src/pages/applications/ApplicationListPage.ts #: src/pages/applications/ApplicationListPage.ts
@ -1036,6 +1049,10 @@ msgstr ""
msgid "Date Time" msgid "Date Time"
msgstr "" msgstr ""
#: src/pages/users/UserListPage.ts
msgid "Deactivate"
msgstr ""
#: src/pages/flows/FlowForm.ts #: src/pages/flows/FlowForm.ts
msgid "Decides what this Flow is used for. For example, the Authentication flow is redirect to when an un-authenticated user visits authentik." msgid "Decides what this Flow is used for. For example, the Authentication flow is redirect to when an un-authenticated user visits authentik."
msgstr "" msgstr ""
@ -1192,10 +1209,10 @@ msgstr ""
msgid "Digits" msgid "Digits"
msgstr "" msgstr ""
#: src/pages/users/UserListPage.ts #:
#: src/pages/users/UserListPage.ts #:
msgid "Disable" #~ msgid "Disable"
msgstr "" #~ msgstr ""
#: src/pages/user-settings/settings/UserSettingsAuthenticatorDuo.ts #: src/pages/user-settings/settings/UserSettingsAuthenticatorDuo.ts
msgid "Disable Duo authenticator" msgid "Disable Duo authenticator"
@ -1278,7 +1295,6 @@ msgstr ""
#: src/pages/sources/oauth/OAuthSourceViewPage.ts #: src/pages/sources/oauth/OAuthSourceViewPage.ts
#: src/pages/sources/plex/PlexSourceViewPage.ts #: src/pages/sources/plex/PlexSourceViewPage.ts
#: src/pages/sources/saml/SAMLSourceViewPage.ts #: src/pages/sources/saml/SAMLSourceViewPage.ts
#: src/pages/users/UserListPage.ts
#: src/pages/users/UserViewPage.ts #: src/pages/users/UserViewPage.ts
msgid "Edit" msgid "Edit"
msgstr "" msgstr ""
@ -1325,18 +1341,26 @@ msgstr ""
msgid "Email info:" msgid "Email info:"
msgstr "" msgstr ""
#: src/pages/users/UserListPage.ts
msgid "Email recovery link"
msgstr ""
#: src/pages/events/utils.ts #: src/pages/events/utils.ts
msgid "Email sent" msgid "Email sent"
msgstr "" msgstr ""
#: src/pages/users/UserResetEmailForm.ts
msgid "Email stage"
msgstr ""
#: src/pages/stages/prompt/PromptForm.ts #: src/pages/stages/prompt/PromptForm.ts
msgid "Email: Text field with Email type." msgid "Email: Text field with Email type."
msgstr "" msgstr ""
#: src/pages/users/UserListPage.ts #:
#: src/pages/users/UserListPage.ts #:
msgid "Enable" #~ msgid "Enable"
msgstr "" #~ msgstr ""
#: src/pages/user-settings/settings/UserSettingsAuthenticatorDuo.ts #: src/pages/user-settings/settings/UserSettingsAuthenticatorDuo.ts
msgid "Enable Duo authenticator" msgid "Enable Duo authenticator"
@ -1934,6 +1958,10 @@ msgstr ""
msgid "In case you can't access any other method." msgid "In case you can't access any other method."
msgstr "" msgstr ""
#: src/pages/users/UserListPage.ts
msgid "Inactive"
msgstr ""
#: src/pages/providers/oauth2/OAuth2ProviderForm.ts #: src/pages/providers/oauth2/OAuth2ProviderForm.ts
msgid "Include User claims from scopes in the id_token, for applications that don't access the userinfo endpoint." msgid "Include User claims from scopes in the id_token, for applications that don't access the userinfo endpoint."
msgstr "" msgstr ""
@ -2202,6 +2230,7 @@ msgstr ""
#: src/pages/tenants/TenantForm.ts #: src/pages/tenants/TenantForm.ts
#: src/pages/tenants/TenantForm.ts #: src/pages/tenants/TenantForm.ts
#: src/pages/tenants/TenantForm.ts #: src/pages/tenants/TenantForm.ts
#: src/pages/users/UserResetEmailForm.ts
msgid "Loading..." msgid "Loading..."
msgstr "" msgstr ""
@ -3058,6 +3087,7 @@ msgid "Receive a push notification on your phone to prove your identity."
msgstr "" msgstr ""
#: src/pages/flows/FlowForm.ts #: src/pages/flows/FlowForm.ts
#: src/pages/users/UserListPage.ts
msgid "Recovery" msgid "Recovery"
msgstr "" msgstr ""
@ -3074,6 +3104,10 @@ msgstr ""
msgid "Recovery keys" msgid "Recovery keys"
msgstr "" msgstr ""
#: src/pages/users/UserListPage.ts
msgid "Recovery link cannot be emailed, user has no email address saved."
msgstr ""
#: src/pages/providers/saml/SAMLProviderForm.ts #: src/pages/providers/saml/SAMLProviderForm.ts
msgid "Redirect" msgid "Redirect"
msgstr "" msgstr ""
@ -3106,6 +3140,10 @@ msgstr ""
msgid "Regular expressions for which authentication is not required. Each new line is interpreted as a new Regular Expression." msgid "Regular expressions for which authentication is not required. Each new line is interpreted as a new Regular Expression."
msgstr "" msgstr ""
#: src/pages/users/UserListPage.ts
msgid "Regular user"
msgstr ""
#: src/pages/applications/ApplicationViewPage.ts #: src/pages/applications/ApplicationViewPage.ts
#: src/pages/flows/FlowViewPage.ts #: src/pages/flows/FlowViewPage.ts
msgid "Related" msgid "Related"
@ -3153,7 +3191,6 @@ msgstr ""
msgid "Required. 150 characters or fewer. Letters, digits and @/./+/-/_ only." msgid "Required. 150 characters or fewer. Letters, digits and @/./+/-/_ only."
msgstr "" msgstr ""
#: src/pages/users/UserListPage.ts
#: src/pages/users/UserViewPage.ts #: src/pages/users/UserViewPage.ts
msgid "Reset Password" msgid "Reset Password"
msgstr "" msgstr ""
@ -3354,6 +3391,10 @@ msgstr ""
msgid "Send Email again." msgid "Send Email again."
msgstr "" msgstr ""
#: src/pages/users/UserListPage.ts
msgid "Send link"
msgstr ""
#: src/pages/events/RuleListPage.ts #: src/pages/events/RuleListPage.ts
msgid "Send notifications whenever a specific Event is created and matched by policies." msgid "Send notifications whenever a specific Event is created and matched by policies."
msgstr "" msgstr ""
@ -3362,6 +3403,10 @@ msgstr ""
msgid "Send once" msgid "Send once"
msgstr "" msgstr ""
#: src/pages/users/UserListPage.ts
msgid "Send recovery link to user"
msgstr ""
#: src/pages/events/RuleListPage.ts #: src/pages/events/RuleListPage.ts
msgid "Sent to group" msgid "Sent to group"
msgstr "" msgstr ""
@ -3785,6 +3830,10 @@ msgstr ""
msgid "Successfully imported provider." msgid "Successfully imported provider."
msgstr "" msgstr ""
#: src/pages/users/UserResetEmailForm.ts
msgid "Successfully sent email."
msgstr ""
#: src/pages/applications/ApplicationCheckAccessForm.ts #: src/pages/applications/ApplicationCheckAccessForm.ts
#: src/pages/policies/PolicyTestForm.ts #: src/pages/policies/PolicyTestForm.ts
#: src/pages/property-mappings/PropertyMappingTestForm.ts #: src/pages/property-mappings/PropertyMappingTestForm.ts
@ -3917,6 +3966,7 @@ msgstr ""
msgid "Successfully updated {0} {1}" msgid "Successfully updated {0} {1}"
msgstr "" msgstr ""
#: src/pages/users/UserListPage.ts
#: src/pages/users/UserViewPage.ts #: src/pages/users/UserViewPage.ts
msgid "Superuser" msgid "Superuser"
msgstr "" msgstr ""
@ -4554,6 +4604,10 @@ msgstr ""
msgid "User password writeback" msgid "User password writeback"
msgstr "" msgstr ""
#: src/pages/users/UserListPage.ts
msgid "User status"
msgstr ""
#: src/pages/events/utils.ts #: src/pages/events/utils.ts
msgid "User was written to" msgid "User was written to"
msgstr "" msgstr ""

View file

@ -16,7 +16,7 @@ export class OutpostForm extends ModelForm<Outpost, string> {
type: OutpostTypeEnum = OutpostTypeEnum.Proxy; type: OutpostTypeEnum = OutpostTypeEnum.Proxy;
@property({ type: Boolean }) @property({ type: Boolean })
embedded: boolean = false; embedded = false;
loadInstance(pk: string): Promise<Outpost> { loadInstance(pk: string): Promise<Outpost> {
return new OutpostsApi(DEFAULT_CONFIG) return new OutpostsApi(DEFAULT_CONFIG)

View file

@ -1,10 +1,10 @@
import { t } from "@lingui/macro"; import { t } from "@lingui/macro";
import { customElement, html, property, TemplateResult } from "lit-element"; import { CSSResult, customElement, html, property, TemplateResult } from "lit-element";
import { AKResponse } from "../../api/Client"; import { AKResponse } from "../../api/Client";
import { TablePage } from "../../elements/table/TablePage"; import { TablePage } from "../../elements/table/TablePage";
import PFDescriptionList from "@patternfly/patternfly/components/DescriptionList/description-list.css";
import "../../elements/forms/ModalForm"; import "../../elements/forms/ModalForm";
import "../../elements/buttons/Dropdown";
import "../../elements/buttons/ActionButton"; import "../../elements/buttons/ActionButton";
import { TableColumn } from "../../elements/table/Table"; import { TableColumn } from "../../elements/table/Table";
import { PAGE_SIZE } from "../../constants"; import { PAGE_SIZE } from "../../constants";
@ -13,6 +13,7 @@ import { DEFAULT_CONFIG, tenant } from "../../api/Config";
import "../../elements/forms/DeleteForm"; import "../../elements/forms/DeleteForm";
import "./UserActiveForm"; import "./UserActiveForm";
import "./UserForm"; import "./UserForm";
import "./UserResetEmailForm";
import { showMessage } from "../../elements/messages/MessageContainer"; import { showMessage } from "../../elements/messages/MessageContainer";
import { MessageLevel } from "../../elements/messages/Message"; import { MessageLevel } from "../../elements/messages/Message";
import { first } from "../../utils"; import { first } from "../../utils";
@ -20,6 +21,9 @@ import { until } from "lit-html/directives/until";
@customElement("ak-user-list") @customElement("ak-user-list")
export class UserListPage extends TablePage<User> { export class UserListPage extends TablePage<User> {
expandable = true;
checkbox = true;
searchEnabled(): boolean { searchEnabled(): boolean {
return true; return true;
} }
@ -39,6 +43,10 @@ export class UserListPage extends TablePage<User> {
@property({ type: Boolean }) @property({ type: Boolean })
hideServiceAccounts = true; hideServiceAccounts = true;
static get styles(): CSSResult[] {
return super.styles.concat(PFDescriptionList);
}
apiEndpoint(page: number): Promise<AKResponse<User>> { apiEndpoint(page: number): Promise<AKResponse<User>> {
return new CoreApi(DEFAULT_CONFIG).coreUsersList({ return new CoreApi(DEFAULT_CONFIG).coreUsersList({
ordering: this.order, ordering: this.order,
@ -62,54 +70,10 @@ export class UserListPage extends TablePage<User> {
]; ];
} }
row(item: User): TemplateResult[] { renderToolbarSelected(): TemplateResult {
return [ const disabled = this.selectedElements.length !== 1;
html`<a href="#/identity/users/${item.pk}"> const item = this.selectedElements[0];
<div>${item.username}</div> return html` <ak-forms-delete
<small>${item.name}</small>
</a>`,
html`${item.isActive ? t`Yes` : t`No`}`,
html`${first(item.lastLogin?.toLocaleString(), "-")}`,
html` <ak-forms-modal>
<span slot="submit"> ${t`Update`} </span>
<span slot="header"> ${t`Update User`} </span>
<ak-user-form slot="form" .instancePk=${item.pk}> </ak-user-form>
<button slot="trigger" class="pf-m-secondary pf-c-button">${t`Edit`}</button>
</ak-forms-modal>
<ak-dropdown class="pf-c-dropdown">
<button class="pf-m-primary pf-c-dropdown__toggle" type="button">
<span class="pf-c-dropdown__toggle-text"
>${item.isActive ? t`Disable` : t`Enable`}</span
>
<i
class="fas fa-caret-down pf-c-dropdown__toggle-icon"
aria-hidden="true"
></i>
</button>
<ul class="pf-c-dropdown__menu" hidden>
<li>
<ak-user-active-form
.obj=${item}
objectLabel=${t`User`}
.delete=${() => {
return new CoreApi(DEFAULT_CONFIG).coreUsersPartialUpdate({
id: item.pk || 0,
patchedUserRequest: {
username: item.username,
name: item.name,
isActive: !item.isActive,
},
});
}}
>
<button slot="trigger" class="pf-c-dropdown__menu-item">
${item.isActive ? t`Disable` : t`Enable`}
</button>
</ak-user-active-form>
</li>
<li class="pf-c-divider" role="separator"></li>
<li>
<ak-forms-delete
.obj=${item} .obj=${item}
objectLabel=${t`User`} objectLabel=${t`User`}
.usedBy=${() => { .usedBy=${() => {
@ -123,17 +87,94 @@ export class UserListPage extends TablePage<User> {
}); });
}} }}
> >
<button slot="trigger" class="pf-c-dropdown__menu-item"> <button ?disabled=${disabled} slot="trigger" class="pf-c-button pf-m-danger">
${t`Delete`} ${t`Delete`}
</button> </button>
</ak-forms-delete> </ak-forms-delete>`;
</li> }
</ul>
</ak-dropdown> row(item: User): TemplateResult[] {
return [
html`<a href="#/identity/users/${item.pk}">
<div>${item.username}</div>
<small>${item.name}</small>
</a>`,
html`${item.isActive ? t`Yes` : t`No`}`,
html`${first(item.lastLogin?.toLocaleString(), "-")}`,
html` <ak-forms-modal>
<span slot="submit"> ${t`Update`} </span>
<span slot="header"> ${t`Update User`} </span>
<ak-user-form slot="form" .instancePk=${item.pk}> </ak-user-form>
<button slot="trigger" class="pf-c-button pf-m-plain">
<i class="fas fa-edit"></i>
</button>
</ak-forms-modal>
<a class="pf-c-button pf-m-tertiary" href="${`/-/impersonation/${item.pk}/`}">
${t`Impersonate`}
</a>`,
];
}
renderExpanded(item: User): TemplateResult {
return html`<td role="cell" colspan="3">
<div class="pf-c-table__expandable-row-content">
<dl class="pf-c-description-list pf-m-horizontal">
<div class="pf-c-description-list__group">
<dt class="pf-c-description-list__term">
<span class="pf-c-description-list__text">${t`User status`}</span>
</dt>
<dd class="pf-c-description-list__description">
<div class="pf-c-description-list__text">
${item.isActive ? t`Active` : t`Inactive`}
</div>
<div class="pf-c-description-list__text">
${item.isSuperuser ? t`Superuser` : t`Regular user`}
</div>
</dd>
</div>
<div class="pf-c-description-list__group">
<dt class="pf-c-description-list__term">
<span class="pf-c-description-list__text">${t`Change status`}</span>
</dt>
<dd class="pf-c-description-list__description">
<div class="pf-c-description-list__text">
<ak-user-active-form
.obj=${item}
objectLabel=${t`User`}
.delete=${() => {
return new CoreApi(
DEFAULT_CONFIG,
).coreUsersPartialUpdate({
id: item.pk || 0,
patchedUserRequest: {
username: item.username,
name: item.name,
isActive: !item.isActive,
},
});
}}
>
<button slot="trigger" class="pf-c-button pf-m-warning">
${item.isActive ? t`Deactivate` : t`Activate`}
</button>
</ak-user-active-form>
</div>
</dd>
</div>
${until( ${until(
tenant().then((te) => { tenant().then((te) => {
if (te.flowRecovery) { if (!te.flowRecovery) {
return html` <ak-action-button return html``;
}
return html`<div class="pf-c-description-list__group">
<dt class="pf-c-description-list__term">
<span class="pf-c-description-list__text"
>${t`Recovery`}</span
>
</dt>
<dd class="pf-c-description-list__description">
<div class="pf-c-description-list__text">
<ak-action-button
.apiRequest=${() => { .apiRequest=${() => {
return new CoreApi(DEFAULT_CONFIG) return new CoreApi(DEFAULT_CONFIG)
.coreUsersRecoveryRetrieve({ .coreUsersRecoveryRetrieve({
@ -156,16 +197,41 @@ export class UserListPage extends TablePage<User> {
}); });
}} }}
> >
${t`Reset Password`} ${t`Copy recovery link`}
</ak-action-button>`; </ak-action-button>
} ${item.email
return html``; ? html`<ak-forms-modal
.closeAfterSuccessfulSubmit=${false}
>
<span slot="submit"> ${t`Send link`} </span>
<span slot="header">
${t`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"
>
${t`Email recovery link`}
</button>
</ak-forms-modal>`
: html`<span
>${t`Recovery link cannot be emailed, user has no email address saved.`}</span
>`}
</div>
</dd>
</div>`;
}), }),
)} )}
<a class="pf-c-button pf-m-tertiary" href="${`/-/impersonation/${item.pk}/`}"> </dl>
${t`Impersonate`} </div>
</a>`, </td>
]; <td></td>
<td></td>`;
} }
renderToolbar(): TemplateResult { renderToolbar(): TemplateResult {

View file

@ -0,0 +1,44 @@
import { CoreApi, CoreUsersRecoveryEmailRetrieveRequest, StagesApi, User } from "authentik-api";
import { t } from "@lingui/macro";
import { customElement, property } from "lit-element";
import { html, TemplateResult } from "lit-html";
import { DEFAULT_CONFIG } from "../../api/Config";
import { Form } from "../../elements/forms/Form";
import { until } from "lit-html/directives/until";
import "../../elements/forms/HorizontalFormElement";
@customElement("ak-user-reset-email-form")
export class UserResetEmailForm extends Form<CoreUsersRecoveryEmailRetrieveRequest> {
@property({ attribute: false })
user!: User;
getSuccessMessage(): string {
return t`Successfully sent email.`;
}
send = (data: CoreUsersRecoveryEmailRetrieveRequest): Promise<void> => {
data.id = this.user.pk;
return new CoreApi(DEFAULT_CONFIG).coreUsersRecoveryEmailRetrieve(data);
};
renderForm(): TemplateResult {
return html`<form class="pf-c-form pf-m-horizontal">
<ak-form-element-horizontal label=${t`Email stage`} ?required=${true} name="emailStage">
<select class="pf-c-form-control">
${until(
new StagesApi(DEFAULT_CONFIG)
.stagesEmailList({
ordering: "name",
})
.then((stages) => {
return stages.results.map((stage) => {
return html`<option value=${stage.pk}>${stage.name}</option>`;
});
}),
html`<option>${t`Loading...`}</option>`,
)}
</select>
</ak-form-element-horizontal>
</form>`;
}
}