diff --git a/src-ui/src/app/components/common/edit-dialog/user-edit-dialog/user-edit-dialog.component.html b/src-ui/src/app/components/common/edit-dialog/user-edit-dialog/user-edit-dialog.component.html
index ca834a3ad..a2b3db67d 100644
--- a/src-ui/src/app/components/common/edit-dialog/user-edit-dialog/user-edit-dialog.component.html
+++ b/src-ui/src/app/components/common/edit-dialog/user-edit-dialog/user-edit-dialog.component.html
@@ -32,6 +32,20 @@
+
+ @if (object?.is_mfa_enabled && currentUserIsSuperUser) {
+
+
+
+ }
diff --git a/src-ui/src/app/components/common/edit-dialog/user-edit-dialog/user-edit-dialog.component.ts b/src-ui/src/app/components/common/edit-dialog/user-edit-dialog/user-edit-dialog.component.ts
index baadfa541..acd327d3a 100644
--- a/src-ui/src/app/components/common/edit-dialog/user-edit-dialog/user-edit-dialog.component.ts
+++ b/src-ui/src/app/components/common/edit-dialog/user-edit-dialog/user-edit-dialog.component.ts
@@ -5,9 +5,11 @@ import { first } from 'rxjs'
import { EditDialogComponent } from 'src/app/components/common/edit-dialog/edit-dialog.component'
import { Group } from 'src/app/data/group'
import { User } from 'src/app/data/user'
+import { PermissionsService } from 'src/app/services/permissions.service'
import { GroupService } from 'src/app/services/rest/group.service'
import { UserService } from 'src/app/services/rest/user.service'
import { SettingsService } from 'src/app/services/settings.service'
+import { ToastService } from 'src/app/services/toast.service'
@Component({
selector: 'pngx-user-edit-dialog',
@@ -20,12 +22,15 @@ export class UserEditDialogComponent
{
groups: Group[]
passwordIsSet: boolean = false
+ public totpLoading: boolean = false
constructor(
service: UserService,
activeModal: NgbActiveModal,
groupsService: GroupService,
- settingsService: SettingsService
+ settingsService: SettingsService,
+ private toastService: ToastService,
+ private permissionsService: PermissionsService
) {
super(service, activeModal, service, settingsService)
@@ -87,4 +92,30 @@ export class UserEditDialogComponent
.length > 0
super.save()
}
+
+ get currentUserIsSuperUser(): boolean {
+ return this.permissionsService.isSuperUser()
+ }
+
+ deactivateTotp() {
+ this.totpLoading = true
+ ;(this.service as UserService)
+ .deactivateTotp(this.object)
+ .pipe(first())
+ .subscribe({
+ next: (result) => {
+ this.totpLoading = false
+ if (result) {
+ this.toastService.showInfo($localize`Totp deactivated`)
+ this.object.is_mfa_enabled = false
+ } else {
+ this.toastService.showError($localize`Totp deactivation failed`)
+ }
+ },
+ error: (e) => {
+ this.totpLoading = false
+ this.toastService.showError($localize`Totp deactivation failed`, e)
+ },
+ })
+ }
}
diff --git a/src-ui/src/app/data/user.ts b/src-ui/src/app/data/user.ts
index 49216a274..6bf051c25 100644
--- a/src-ui/src/app/data/user.ts
+++ b/src-ui/src/app/data/user.ts
@@ -11,4 +11,5 @@ export interface User extends ObjectWithId {
groups?: number[] // Group[]
user_permissions?: string[]
inherited_permissions?: string[]
+ is_mfa_enabled?: boolean
}
diff --git a/src-ui/src/app/services/permissions.service.ts b/src-ui/src/app/services/permissions.service.ts
index c80bc763d..3d88b10cc 100644
--- a/src-ui/src/app/services/permissions.service.ts
+++ b/src-ui/src/app/services/permissions.service.ts
@@ -56,6 +56,10 @@ export class PermissionsService {
return this.currentUser?.is_staff
}
+ public isSuperUser(): boolean {
+ return this.currentUser?.is_superuser
+ }
+
public currentUserOwnsObject(object: ObjectWithPermissions): boolean {
return (
!object ||
diff --git a/src-ui/src/app/services/rest/user.service.ts b/src-ui/src/app/services/rest/user.service.ts
index 4fb02b1f7..ded7ae248 100644
--- a/src-ui/src/app/services/rest/user.service.ts
+++ b/src-ui/src/app/services/rest/user.service.ts
@@ -5,6 +5,7 @@ import { User } from 'src/app/data/user'
import { PermissionsService } from '../permissions.service'
import { AbstractNameFilterService } from './abstract-name-filter-service'
+const endpoint = 'users'
@Injectable({
providedIn: 'root',
})
@@ -13,7 +14,7 @@ export class UserService extends AbstractNameFilterService
{
http: HttpClient,
private permissionService: PermissionsService
) {
- super(http, 'users')
+ super(http, endpoint)
}
update(o: User): Observable {
@@ -31,4 +32,11 @@ export class UserService extends AbstractNameFilterService {
})
)
}
+
+ deactivateTotp(u: User): Observable {
+ return this.http.post(
+ `${this.getResourceUrl(u.id, 'deactivate_totp')}`,
+ null
+ )
+ }
}
diff --git a/src/paperless/serialisers.py b/src/paperless/serialisers.py
index 5106bcf08..d5acfe465 100644
--- a/src/paperless/serialisers.py
+++ b/src/paperless/serialisers.py
@@ -33,6 +33,11 @@ class UserSerializer(serializers.ModelSerializer):
required=False,
)
inherited_permissions = serializers.SerializerMethodField()
+ is_mfa_enabled = serializers.SerializerMethodField()
+
+ def get_is_mfa_enabled(self, user: User):
+ mfa_adapter = get_mfa_adapter()
+ return mfa_adapter.is_mfa_enabled(user)
class Meta:
model = User
@@ -50,6 +55,7 @@ class UserSerializer(serializers.ModelSerializer):
"groups",
"user_permissions",
"inherited_permissions",
+ "is_mfa_enabled",
)
def get_inherited_permissions(self, obj):
diff --git a/src/paperless/views.py b/src/paperless/views.py
index 0157b885e..ea9d0b89e 100644
--- a/src/paperless/views.py
+++ b/src/paperless/views.py
@@ -17,6 +17,7 @@ from django.http import HttpResponseBadRequest
from django.views.generic import View
from django_filters.rest_framework import DjangoFilterBackend
from rest_framework.authtoken.models import Token
+from rest_framework.decorators import action
from rest_framework.filters import OrderingFilter
from rest_framework.generics import GenericAPIView
from rest_framework.pagination import PageNumberPagination
@@ -106,6 +107,24 @@ class UserViewSet(ModelViewSet):
filterset_class = UserFilterSet
ordering_fields = ("username",)
+ @action(detail=True, methods=["post"])
+ def deactivate_totp(self, request, pk=None):
+ request_user = request.user
+ user = User.objects.get(pk=pk)
+ if not request_user.is_superuser and request_user != user:
+ return HttpResponseBadRequest(
+ "You do not have permission to deactivate TOTP for this user",
+ )
+ try:
+ authenticator = Authenticator.objects.filter(
+ user=user,
+ type=Authenticator.Type.TOTP,
+ ).first()
+ delete_and_cleanup(request, authenticator)
+ return Response(True)
+ except Authenticator.DoesNotExist:
+ return HttpResponseBadRequest("TOTP not found")
+
class GroupViewSet(ModelViewSet):
model = Group