(admin) add mandatory TOTP field + search field (#667)

To enable syncing a role to many users, we had to add a custom
Keycloak plugin.
This commit is contained in:
Sylvain Zimmer
2026-05-20 20:48:00 +02:00
committed by jbpenrath
parent ebab36d577
commit 3576968e38
56 changed files with 3499 additions and 156 deletions
+22
View File
@@ -580,6 +580,28 @@ search-index: ## Create and/or reindex opensearch data
@$(MANAGE) search_reindex --all --recreate-index
.PHONY: search-index
build-keycloak: ## Build the custom Keycloak provider JARs (writes JAR alongside pom.xml so it can be committed)
@docker volume create st-messages-keycloak-mvn-cache >/dev/null
@docker run --rm \
-v "$(PWD)/src/keycloak/bulk-role-membership":/build \
-v st-messages-keycloak-mvn-cache:/root/.m2 \
-w /build \
maven:3.9-eclipse-temurin-21 \
mvn -B -q -o package -DskipTests 2>/dev/null \
|| docker run --rm \
-v "$(PWD)/src/keycloak/bulk-role-membership":/build \
-v st-messages-keycloak-mvn-cache:/root/.m2 \
-w /build \
maven:3.9-eclipse-temurin-21 \
mvn -B -q package -DskipTests
@cp src/keycloak/bulk-role-membership/target/bulk-role-membership.jar \
src/keycloak/bulk-role-membership/bulk-role-membership.jar
.PHONY: build-keycloak
test-keycloak: ## run all Keycloak provider tests (builds JARs, brings up Keycloak)
@bin/test-keycloak
.PHONY: test-keycloak
deps-lock-mta-in: ## lock the dependencies
@$(COMPOSE) run --rm --build mta-in-uv uv lock
.PHONY: deps-lock-mta-in
+67
View File
@@ -0,0 +1,67 @@
#!/usr/bin/env bash
#
# Run every Keycloak test script in src/keycloak/tests/, self-contained.
#
# - Builds custom provider JARs first (so compose mounts a fresh one).
# - Brings Keycloak up implicitly via the backend-dev compose dependency.
# - Each test_*.py runs inside the backend-dev container so it has
# python-keycloak and the right KEYCLOAK_URL env already.
#
# Add a new test by dropping `src/keycloak/tests/test_<name>.py` —
# this runner will pick it up automatically on the next invocation.
set -eo pipefail
source "$(dirname "${BASH_SOURCE[0]}")/_config.sh"
# Only rebuild when a build input is newer than the committed JAR. The
# JAR is tracked in git, so a clean checkout already has a working copy
# and the test runner doesn't pay the Maven cold-start cost on every run.
# Inputs that affect the built artifact: .java sources, resource files
# (the SPI service descriptor lives under src/main/resources), and pom.xml.
jar="$REPO_DIR/src/keycloak/bulk-role-membership/bulk-role-membership.jar"
src_dir="$REPO_DIR/src/keycloak/bulk-role-membership/src"
pom="$REPO_DIR/src/keycloak/bulk-role-membership/pom.xml"
if [ ! -f "$jar" ] \
|| [ "$pom" -nt "$jar" ] \
|| [ -n "$(find "$src_dir" -type f -newer "$jar" -print -quit 2>/dev/null)" ]; then
echo "📦 (re)building JAR — build input changed or JAR missing"
make -C "$REPO_DIR" --no-print-directory build-keycloak
docker compose restart keycloak
fi
# Always block until Keycloak is serving the realm — covers a fresh
# `compose up`, an in-flight restart from this run, or any restart still
# settling from a previous invocation. Bounded so a stuck Keycloak fails
# the run fast instead of hanging indefinitely.
port="${KEYCLOAK_HOST_PORT:-8902}"
timeout="${KEYCLOAK_WAIT_TIMEOUT:-120}"
echo "⏳ waiting up to ${timeout}s for Keycloak on localhost:$port"
for ((waited = 0; waited < timeout; waited++)); do
if curl -fsS -o /dev/null "http://localhost:$port/realms/messages/.well-known/openid-configuration" 2>/dev/null; then
break
fi
sleep 1
done
if [ "$waited" -ge "$timeout" ]; then
echo "❌ Keycloak did not become ready within ${timeout}s on localhost:$port" >&2
exit 1
fi
shopt -s nullglob
tests=("$REPO_DIR"/src/keycloak/tests/test_*.py)
shopt -u nullglob
if [ ${#tests[@]} -eq 0 ]; then
echo "no Keycloak test scripts found in src/keycloak/tests/"
exit 1
fi
for test in "${tests[@]}"; do
name="$(basename "$test")"
echo "🧪 $name"
_dc_run \
-v "$REPO_DIR/src/keycloak/tests:/keycloak-tests:ro" \
backend-dev \
python "/keycloak-tests/$name"
done
+1
View File
@@ -338,6 +338,7 @@ services:
volumes:
- ./src/keycloak/realm.json:/opt/keycloak/data/import/realm.json:ro
- ./src/keycloak/themes/dsfr-2.2.1.jar:/opt/keycloak/providers/keycloak-theme.jar:ro
- ./src/keycloak/bulk-role-membership/bulk-role-membership.jar:/opt/keycloak/providers/bulk-role-membership.jar:ro
environment:
- HOST=http://localhost:8902
- ADMIN_HOST=http://localhost:8902
+200 -2
View File
@@ -260,6 +260,10 @@
"type": "boolean",
"readOnly": true
},
"FEATURE_MAILDOMAIN_MANAGE_TOTP": {
"type": "boolean",
"readOnly": true
},
"MESSAGES_MANUAL_RETRY_MAX_AGE": {
"type": "integer",
"description": "Maximum age in seconds for a message to be eligible for manual retry of failed deliveries",
@@ -290,6 +294,7 @@
"FEATURE_MAILDOMAIN_CREATE",
"FEATURE_MAILDOMAIN_MANAGE_ACCESSES",
"FEATURE_THREAD_SPLIT",
"FEATURE_MAILDOMAIN_MANAGE_TOTP",
"MESSAGES_MANUAL_RETRY_MAX_AGE",
"FRONTEND_SILENT_LOGIN_ENABLED"
]
@@ -3391,7 +3396,7 @@
"/api/v1.0/maildomains/": {
"get": {
"operationId": "maildomains_list",
"description": "ViewSet for listing MailDomains the user administers.\nProvides a top-level entry for mail domain administration.\nEndpoint: /maildomains/<maildomain_pk>/",
"description": "List mail domains, optionally filtered by name with the `q` parameter.",
"parameters": [
{
"name": "page",
@@ -3401,6 +3406,14 @@
"schema": {
"type": "integer"
}
},
{
"in": "query",
"name": "q",
"schema": {
"type": "string"
},
"description": "Filter domains whose name contains this value (case-insensitive)."
}
],
"tags": [
@@ -3714,7 +3727,7 @@
"/api/v1.0/maildomains/{maildomain_pk}/mailboxes/": {
"get": {
"operationId": "maildomains_mailboxes_list",
"description": "ViewSet for managing Mailboxes within a specific MailDomain.\nNested under /maildomains/{maildomain_pk}/mailboxes/\nPermissions are checked by IsMailDomainAdmin for the maildomain_pk.\n\nThis viewset serves a different purpose than the one in mailbox.py (/api/v1.0/mailboxes/).\nThat other one is for listing the mailboxes a user has access to in regular app use.\nThis one is for managing mailboxes within a specific maildomain in the admin interface.",
"description": "List mailboxes, optionally filtered by local part / contact name.",
"parameters": [
{
"in": "path",
@@ -3733,6 +3746,14 @@
"schema": {
"type": "integer"
}
},
{
"in": "query",
"name": "q",
"schema": {
"type": "string"
},
"description": "Filter mailboxes whose local part or contact name contains this value (case-insensitive)."
}
],
"tags": [
@@ -3951,6 +3972,68 @@
}
}
},
"/api/v1.0/maildomains/{maildomain_pk}/mailboxes/{id}/mandatory-totp/": {
"post": {
"operationId": "maildomains_mailboxes_set_mandatory_totp",
"description": "Toggle the Keycloak realm role indicated by KEYCLOAK_TOTP_ROLE_ID on the user backing this mailbox.",
"parameters": [
{
"in": "path",
"name": "id",
"schema": {
"type": "string",
"format": "uuid",
"description": "primary key for the record as UUID"
},
"required": true
},
{
"in": "path",
"name": "maildomain_pk",
"schema": {
"type": "string",
"format": "uuid"
},
"required": true
}
],
"tags": [
"maildomains"
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/MailboxAdminMandatoryTotpPayloadRequest"
}
},
"multipart/form-data": {
"schema": {
"$ref": "#/components/schemas/MailboxAdminMandatoryTotpPayloadRequest"
}
}
},
"required": true
},
"security": [
{
"cookieAuth": []
}
],
"responses": {
"200": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/MailboxAdminMandatoryTotpResponse"
}
}
},
"description": ""
}
}
}
},
"/api/v1.0/maildomains/{maildomain_pk}/mailboxes/{id}/reset-password/": {
"patch": {
"operationId": "maildomains_mailboxes_reset_password",
@@ -4028,6 +4111,53 @@
}
}
},
"/api/v1.0/maildomains/{maildomain_pk}/mailboxes/{id}/reset-totp/": {
"patch": {
"operationId": "maildomains_mailboxes_reset_totp",
"description": "Remove existing OTP credentials and require the user to re-enroll in TOTP on next login.",
"parameters": [
{
"in": "path",
"name": "id",
"schema": {
"type": "string",
"format": "uuid",
"description": "primary key for the record as UUID"
},
"required": true
},
{
"in": "path",
"name": "maildomain_pk",
"schema": {
"type": "string",
"format": "uuid"
},
"required": true
}
],
"tags": [
"maildomains"
],
"security": [
{
"cookieAuth": []
}
],
"responses": {
"200": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/MailboxAdminResetTotpResponse"
}
}
},
"description": ""
}
}
}
},
"/api/v1.0/maildomains/{maildomain_pk}/message-templates/": {
"get": {
"operationId": "maildomains_message_templates_list",
@@ -7080,6 +7210,10 @@
"type": "string",
"readOnly": true
},
"mailbox_count": {
"type": "string",
"readOnly": true
},
"identity_sync": {
"type": "boolean",
"readOnly": true,
@@ -7136,6 +7270,7 @@
"expected_dns_records",
"id",
"identity_sync",
"mailbox_count",
"name",
"updated_at"
]
@@ -7553,6 +7688,19 @@
}
],
"readOnly": true
},
"last_accessed_at": {
"type": "string",
"format": "date-time",
"nullable": true,
"readOnly": true,
"description": "Most recent ``accessed_at`` across all mailbox accesses."
},
"has_mandatory_totp": {
"type": "boolean",
"nullable": true,
"readOnly": true,
"description": "Whether the Keycloak user backing this mailbox carries the KEYCLOAK_TOTP_ROLE_ID realm role. ``null`` when the feature is disabled or the role id isn't configured."
}
},
"required": [
@@ -7561,8 +7709,10 @@
"contact",
"created_at",
"domain_name",
"has_mandatory_totp",
"id",
"is_identity",
"last_accessed_at",
"local_part",
"updated_at"
]
@@ -7629,6 +7779,19 @@
],
"readOnly": true
},
"last_accessed_at": {
"type": "string",
"format": "date-time",
"nullable": true,
"readOnly": true,
"description": "Most recent ``accessed_at`` across all mailbox accesses."
},
"has_mandatory_totp": {
"type": "boolean",
"nullable": true,
"readOnly": true,
"description": "Whether the Keycloak user backing this mailbox carries the KEYCLOAK_TOTP_ROLE_ID realm role. ``null`` when the feature is disabled or the role id isn't configured."
},
"one_time_password": {
"type": "string",
"nullable": true,
@@ -7642,8 +7805,10 @@
"contact",
"created_at",
"domain_name",
"has_mandatory_totp",
"id",
"is_identity",
"last_accessed_at",
"local_part",
"one_time_password",
"updated_at"
@@ -7699,6 +7864,39 @@
"metadata"
]
},
"MailboxAdminMandatoryTotpPayloadRequest": {
"type": "object",
"properties": {
"enabled": {
"type": "boolean"
}
},
"required": [
"enabled"
]
},
"MailboxAdminMandatoryTotpResponse": {
"type": "object",
"properties": {
"enabled": {
"type": "boolean"
}
},
"required": [
"enabled"
]
},
"MailboxAdminResetTotpResponse": {
"type": "object",
"properties": {
"removed_credentials": {
"type": "integer"
}
},
"required": [
"removed_credentials"
]
},
"MailboxAdminUpdateMetadataRequest": {
"type": "object",
"properties": {
+72 -11
View File
@@ -8,7 +8,7 @@ import uuid
from django.conf import settings
from django.core.exceptions import ValidationError as DjangoValidationError
from django.db import transaction
from django.db.models import Count, Q
from django.db.models import Count, Max, Q
from drf_spectacular.utils import PolymorphicProxySerializer, extend_schema_field
from rest_framework import serializers
@@ -17,6 +17,7 @@ from rest_framework.exceptions import PermissionDenied
from core import enums, models
from core.mda.rfc5322 import extract_base64_images_from_html
from core.services.blob_gc import schedule_for_gc
from core.services.identity import keycloak as keycloak_service
class CreateOnlyFieldsMixin:
@@ -1370,16 +1371,7 @@ class MailDomainAdminSerializer(AbilitiesModelSerializer):
"""Serialize mail domains for admin view."""
expected_dns_records = serializers.SerializerMethodField(read_only=True)
def get_expected_dns_records(self, instance):
"""Return the expected DNS records for the mail domain, only in detail views."""
# Only include DNS records in detail views, not in list views
view = self.context.get("view")
if view and hasattr(view, "action") and view.action == "retrieve":
return instance.get_expected_dns_records()
return None
mailbox_count = serializers.SerializerMethodField(read_only=True)
class Meta:
model = models.MailDomain
@@ -1389,6 +1381,7 @@ class MailDomainAdminSerializer(AbilitiesModelSerializer):
"created_at",
"updated_at",
"expected_dns_records",
"mailbox_count",
"identity_sync",
]
read_only_fields = fields
@@ -1411,6 +1404,27 @@ class MailDomainAdminSerializer(AbilitiesModelSerializer):
"""Return the abilities for the mail domain."""
return super().get_abilities(instance)
def get_expected_dns_records(self, instance):
"""Return the expected DNS records for the mail domain, only in detail views."""
# Only include DNS records in detail views, not in list views
view = self.context.get("view")
if view and hasattr(view, "action") and view.action == "retrieve":
return instance.get_expected_dns_records()
return None
def get_mailbox_count(self, instance):
"""Return the number of mailboxes for the mail domain.
Relies on the `mailbox_count` annotation provided by the viewset queryset
to avoid an N+1 COUNT query per domain when listing.
"""
annotated = getattr(instance, "mailbox_count", None)
if annotated is not None:
return annotated
return instance.mailbox_set.count()
class MaildomainAccessReadSerializer(serializers.ModelSerializer):
"""
@@ -1502,6 +1516,49 @@ class MailboxAdminSerializer(serializers.ModelSerializer):
alias_of = serializers.PrimaryKeyRelatedField(
required=False, allow_null=True, queryset=models.Mailbox.objects.none()
)
last_accessed_at = serializers.SerializerMethodField(
help_text="Most recent ``accessed_at`` across all mailbox accesses."
)
has_mandatory_totp = serializers.SerializerMethodField(
help_text=(
"Whether the Keycloak user backing this mailbox carries the "
"KEYCLOAK_TOTP_ROLE_ID realm role. ``null`` when the feature is "
"disabled or the role id isn't configured."
)
)
@extend_schema_field(serializers.DateTimeField(allow_null=True))
def get_last_accessed_at(self, obj):
"""Prefers the ``last_accessed_at`` annotation set by the admin list
viewset (single SQL aggregate). Falls back to a per-instance query so
the field still resolves on create/retrieve/update responses where the
annotation isn't present."""
if hasattr(obj, "last_accessed_at"):
return obj.last_accessed_at
return obj.accesses.aggregate(value=Max("accessed_at"))["value"]
@extend_schema_field(serializers.BooleanField(allow_null=True))
def get_has_mandatory_totp(self, obj):
"""Whether the mailbox user has the configured TOTP realm role.
Returns ``None`` when the feature is disabled, prerequisites aren't
met (no role configured, identity sync off, not a personal mailbox),
or the serializer is rendering outside the admin list flow — the
field is only populated when the viewset pre-resolves it for the
whole page (one pipelined Redis call) and stashes the result in
``context["mandatory_totp_membership"]``.
"""
if (
not keycloak_service.is_mandatory_totp_enabled()
or not obj.is_identity
or not obj.domain.identity_sync
):
return None
membership = self.context.get("mandatory_totp_membership")
if membership is None:
return None
return membership.get(str(obj))
class Meta:
model = models.Mailbox
@@ -1516,6 +1573,8 @@ class MailboxAdminSerializer(serializers.ModelSerializer):
"updated_at",
"can_reset_password",
"contact",
"last_accessed_at",
"has_mandatory_totp",
]
read_only_fields = [
"id",
@@ -1526,6 +1585,8 @@ class MailboxAdminSerializer(serializers.ModelSerializer):
"updated_at",
"can_reset_password",
"contact",
"last_accessed_at",
"has_mandatory_totp",
]
def __init__(self, *args, **kwargs):
+12
View File
@@ -7,6 +7,7 @@ from drf_spectacular.utils import OpenApiResponse, extend_schema
from rest_framework.permissions import AllowAny
from core.ai.utils import is_ai_enabled, is_ai_summary_enabled, is_auto_labels_enabled
from core.services.identity.keycloak import is_mandatory_totp_enabled
class ConfigView(drf.views.APIView):
@@ -123,6 +124,10 @@ class ConfigView(drf.views.APIView):
"type": "boolean",
"readOnly": True,
},
"FEATURE_MAILDOMAIN_MANAGE_TOTP": {
"type": "boolean",
"readOnly": True,
},
"MESSAGES_MANUAL_RETRY_MAX_AGE": {
"type": "integer",
"description": (
@@ -156,6 +161,7 @@ class ConfigView(drf.views.APIView):
"FEATURE_MAILDOMAIN_CREATE",
"FEATURE_MAILDOMAIN_MANAGE_ACCESSES",
"FEATURE_THREAD_SPLIT",
"FEATURE_MAILDOMAIN_MANAGE_TOTP",
"MESSAGES_MANUAL_RETRY_MAX_AGE",
"FRONTEND_SILENT_LOGIN_ENABLED",
],
@@ -193,6 +199,12 @@ class ConfigView(drf.views.APIView):
if hasattr(settings, setting):
dict_settings[setting] = getattr(settings, setting)
# Expose the *effective* mandatory-TOTP capability rather than the raw
# flag: the feature also requires IDENTITY_PROVIDER == "keycloak" and a
# populated KEYCLOAK_TOTP_ROLE_ID. Surfacing the raw flag would let the
# frontend render TOTP affordances that the backend silently refuses.
dict_settings["FEATURE_MAILDOMAIN_MANAGE_TOTP"] = is_mandatory_totp_enabled()
# AI Features
dict_settings["AI_ENABLED"] = is_ai_enabled()
dict_settings["FEATURE_AI_SUMMARY"] = is_ai_summary_enabled()
+213 -14
View File
@@ -4,7 +4,7 @@ from logging import getLogger
from django.conf import settings
from django.db import transaction
from django.db.models import F
from django.db.models import Count, F, Max, Q
from django.shortcuts import get_object_or_404
from drf_spectacular.utils import (
@@ -23,6 +23,7 @@ from rest_framework import (
serializers as drf_serializers,
)
from rest_framework.decorators import action
from rest_framework.exceptions import NotFound, PermissionDenied, ValidationError
from rest_framework.response import Response
from core import models
@@ -30,12 +31,19 @@ from core.api import permissions as core_permissions
from core.api import serializers as core_serializers
from core.api.viewsets.message_template import BODIES_PARAMETER
from core.api.viewsets.mixins import MessageTemplateResponseMixin
from core.enums import MessageTemplateTypeChoices
from core.enums import MailDomainAbilities, MessageTemplateTypeChoices
from core.services.dns.check import check_dns_records, invalidate_spf_check_cache
from core.services.identity import keycloak as keycloak_service
logger = getLogger(__name__)
class _MandatoryTotpPayloadSerializer(drf_serializers.Serializer): # pylint: disable=abstract-method
"""Strict validation for the ``set_mandatory_totp`` request body."""
enabled = drf_serializers.BooleanField()
class AdminMailDomainViewSet(
mixins.ListModelMixin,
mixins.RetrieveModelMixin,
@@ -74,19 +82,44 @@ class AdminMailDomainViewSet(
if user.is_superuser:
# For superusers, preload accesses to avoid N+1 queries in get_abilities
return models.MailDomain.objects.prefetch_related("accesses").order_by(
"name"
queryset = (
models.MailDomain.objects.prefetch_related("accesses")
.annotate(mailbox_count=Count("mailbox"))
.order_by("name")
)
# Optimization : one query with JOIN and annotation
return (
models.MailDomain.objects.filter(
accesses__user=user,
accesses__role=models.MailDomainAccessRoleChoices.ADMIN,
else:
# Optimization : one query with JOIN and annotation
queryset = (
models.MailDomain.objects.filter(
accesses__user=user,
accesses__role=models.MailDomainAccessRoleChoices.ADMIN,
)
.annotate(
user_role=F("accesses__role"),
mailbox_count=Count("mailbox"),
)
.distinct()
.order_by("name")
)
.annotate(user_role=F("accesses__role"))
.distinct()
.order_by("name")
)
search = (self.request.query_params.get("q") or "").strip()
if search:
queryset = queryset.filter(name__icontains=search)
return queryset
@extend_schema(
parameters=[
OpenApiParameter(
name="q",
type=OpenApiTypes.STR,
location=OpenApiParameter.QUERY,
description="Filter domains whose name contains this value (case-insensitive).",
),
],
)
def list(self, request, *args, **kwargs):
"""List mail domains, optionally filtered by name with the `q` parameter."""
return super().list(request, *args, **kwargs)
@extend_schema(
description="Check DNS records for a specific mail domain.",
@@ -168,7 +201,64 @@ class AdminMailDomainMailboxViewSet(
def get_queryset(self):
maildomain_pk = self.kwargs.get("maildomain_pk")
return models.Mailbox.objects.filter(domain_id=maildomain_pk)
queryset = (
models.Mailbox.objects.filter(domain_id=maildomain_pk)
.select_related("domain")
.annotate(last_accessed_at=Max("accesses__accessed_at"))
.order_by("local_part")
)
search = (self.request.query_params.get("q") or "").strip()
if search:
queryset = queryset.filter(
Q(local_part__icontains=search) | Q(contact__name__icontains=search)
).distinct()
return queryset
def get_serializer(self, *args, **kwargs):
"""Inject the per-page mandatory TOTP membership dict when listing.
Letting the standard ``ListModelMixin.list()`` paginate first means we
hook in once with the page's rows already in ``args[0]`` and resolve
membership in a single round-trip to the custom Keycloak provider.
"""
if (
kwargs.get("many")
and self.action == "list"
and args
and keycloak_service.is_mandatory_totp_enabled()
):
rows = args[0]
usernames = [
str(m) for m in rows if m.is_identity and m.domain.identity_sync
]
try:
membership = keycloak_service.batch_realm_role_membership(
usernames, settings.KEYCLOAK_TOTP_ROLE_ID
)
except Exception as e: # pylint: disable=broad-exception-caught
# Don't block the page; serializer renders `null` for these rows.
logger.warning("Could not batch mandatory TOTP membership: %s", e)
membership = None
context = kwargs.setdefault("context", self.get_serializer_context())
context["mandatory_totp_membership"] = membership
return super().get_serializer(*args, **kwargs)
@extend_schema(
parameters=[
OpenApiParameter(
name="q",
type=OpenApiTypes.STR,
location=OpenApiParameter.QUERY,
description=(
"Filter mailboxes whose local part or contact name contains this "
"value (case-insensitive)."
),
),
],
)
def list(self, request, *args, **kwargs):
"""List mailboxes, optionally filtered by local part / contact name."""
return super().list(request, *args, **kwargs)
@extend_schema(
description="Create new mailbox in a specific maildomain.",
@@ -339,6 +429,115 @@ class AdminMailDomainMailboxViewSet(
{"one_time_password": mailbox_password}, status=status.HTTP_200_OK
)
def _assert_mandatory_totp_available(self, mailbox):
"""Raise the appropriate DRF exception if the action can't proceed.
- feature off / misconfigured → ``NotFound``
- mailbox not eligible → ``ValidationError``
- caller lacks the manage-mailboxes ability → ``PermissionDenied``
``IsMailDomainAdmin`` (the viewset permission) and the
``manage_mailboxes`` ability are equivalent today, but the explicit
check pins the contract: this action requires the same ability the
frontend gates the UI on.
"""
if not keycloak_service.is_mandatory_totp_enabled():
raise NotFound("Mandatory TOTP feature is not enabled.")
if not mailbox.is_identity or not mailbox.domain.identity_sync:
raise ValidationError(
"Mandatory TOTP can only be set on personal mailboxes "
"in identity-synced domains."
)
abilities = mailbox.domain.get_abilities(self.request.user)
if not abilities.get(MailDomainAbilities.CAN_MANAGE_MAILBOXES):
raise PermissionDenied("You cannot manage mailboxes in this domain.")
@extend_schema(
operation_id="maildomains_mailboxes_set_mandatory_totp",
description=(
"Toggle the Keycloak realm role indicated by KEYCLOAK_TOTP_ROLE_ID "
"on the user backing this mailbox."
),
request=inline_serializer(
name="MailboxAdminMandatoryTotpPayload",
fields={"enabled": drf_serializers.BooleanField()},
),
responses={
200: inline_serializer(
name="MailboxAdminMandatoryTotpResponse",
fields={"enabled": drf_serializers.BooleanField()},
),
},
)
@action(detail=True, methods=["post"], url_path="mandatory-totp")
def set_mandatory_totp(self, request, *args, **kwargs):
"""Assign or remove the configured TOTP realm role on the mailbox user."""
# Validate the payload before doing any per-mailbox work — a malformed
# body should always surface as 400 regardless of mailbox eligibility.
payload = _MandatoryTotpPayloadSerializer(data=request.data)
payload.is_valid(raise_exception=True)
enabled = payload.validated_data["enabled"]
mailbox = self.get_object()
self._assert_mandatory_totp_available(mailbox)
try:
keycloak_service.set_realm_role(
str(mailbox), settings.KEYCLOAK_TOTP_ROLE_ID, assigned=enabled
)
except ValueError as e:
# set_realm_role raises ValueError when the Keycloak user or role
# can't be found — that's a 404 (resource doesn't exist),
# not a server fault. Don't surface the raw Keycloak text — it can
# carry the username (PII) or the configured role id.
logger.warning("Mandatory TOTP target missing for mailbox %s", mailbox.id)
raise NotFound("Keycloak resource not found for mailbox.") from e
except Exception: # pylint: disable=broad-exception-caught
logger.exception("Error setting mandatory TOTP for mailbox %s", mailbox.id)
return Response(
{"error": "Could not update mandatory TOTP."},
status=status.HTTP_500_INTERNAL_SERVER_ERROR,
)
return Response({"enabled": enabled}, status=status.HTTP_200_OK)
@extend_schema(
operation_id="maildomains_mailboxes_reset_totp",
description=(
"Remove existing OTP credentials and require the user to re-enroll "
"in TOTP on next login."
),
request=inline_serializer(
name="MailboxAdminResetTotpPayload",
fields={},
),
responses={
200: inline_serializer(
name="MailboxAdminResetTotpResponse",
fields={"removed_credentials": drf_serializers.IntegerField()},
),
},
)
@action(detail=True, methods=["patch"], url_path="reset-totp")
def reset_totp(self, request, *args, **kwargs):
"""Force-reset the TOTP enrollment for the mailbox user."""
mailbox = self.get_object()
self._assert_mandatory_totp_available(mailbox)
try:
result = keycloak_service.reset_keycloak_user_totp(str(mailbox))
except ValueError as e:
logger.warning("Reset TOTP target missing for mailbox %s", mailbox.id)
raise NotFound("Keycloak resource not found for mailbox.") from e
except Exception: # pylint: disable=broad-exception-caught
logger.exception("Error resetting TOTP for mailbox %s", mailbox.id)
return Response(
{"error": "Could not reset TOTP."},
status=status.HTTP_500_INTERNAL_SERVER_ERROR,
)
return Response(result, status=status.HTTP_200_OK)
# pylint: disable=too-many-ancestors
class AdminMailDomainMessageTemplateViewSet(
+3 -12
View File
@@ -44,6 +44,7 @@ from redis.exceptions import RedisError
from core.enums import BlobStorageLocationChoices
from core.models import UPLOAD_RESERVATION_TTL, Blob, MailboxBlob
from core.services.tiered_storage import TieredStorageService, sha256_advisory_lock
from core.utils import get_redis_client
from messages.celery_app import app as celery_app
@@ -72,16 +73,6 @@ def _is_redis_backend() -> bool:
return "django_redis" in backend
def _redis_client():
# Lazy import: django_redis is an optional dependency for environments
# that don't use Redis. The system check refuses to boot when blob
# lifecycle features are enabled without it.
# pylint: disable-next=import-outside-toplevel
from django_redis import get_redis_connection
return get_redis_connection("default")
# --------------------------------------------------------------------
# Candidate set
# --------------------------------------------------------------------
@@ -127,7 +118,7 @@ def schedule_for_gc(blob_id) -> None:
def _push():
try:
_redis_client().sadd(_GC_CANDIDATES_KEY, value)
get_redis_client().sadd(_GC_CANDIDATES_KEY, value)
except RedisError as exc:
logger.error(
"Redis unavailable while enqueuing blob %s for GC (%s: %s); "
@@ -147,7 +138,7 @@ def _drain_candidates(batch_size: int) -> list[str]:
if not _is_redis_backend():
return []
try:
popped = _redis_client().spop(_GC_CANDIDATES_KEY, count=batch_size)
popped = get_redis_client().spop(_GC_CANDIDATES_KEY, count=batch_size)
return [
bid.decode() if isinstance(bid, bytes) else str(bid)
for bid in (popped or [])
+180 -4
View File
@@ -1,7 +1,9 @@
"""Keycloak identity management integration."""
import json
import logging
import secrets
import time
from django.conf import settings
@@ -12,11 +14,28 @@ from core.models import Mailbox, MailDomain
logger = logging.getLogger(__name__)
# Reuse a single admin client across calls so each one doesn't pay a fresh
# OIDC client_credentials round trip. The grant has no refresh token, so we
# cache against the access token's own ``expires_in`` and refresh slightly
# before actual expiry to give any in-flight operation headroom. A handful
# of concurrent misses re-fetch — acceptable, the only cost is a duplicate
# token issuance.
_TOKEN_EXPIRY_SAFETY_MARGIN = 30 # seconds shaved off advertised expires_in
_TOKEN_MIN_TTL = 10 # floor so a near-zero expires_in still caches briefly
_admin_client_cache: dict = {"client": None, "expires_at": 0.0}
def get_keycloak_admin_client():
"""Return a KeycloakAdmin client backed by the rest-api service account.
Cached at module level until the access token is close to expiry. The
cache is process-local; under thread contention a few concurrent misses
may each fetch a token, which is harmless.
"""
Get a KeycloakAdmin client using the rest-api service account.
"""
now = time.monotonic()
cached = _admin_client_cache["client"]
if cached is not None and _admin_client_cache["expires_at"] > now:
return cached
keycloak_openid = KeycloakOpenID(
server_url=settings.KEYCLOAK_URL,
@@ -24,7 +43,6 @@ def get_keycloak_admin_client():
client_id=settings.KEYCLOAK_CLIENT_ID,
client_secret_key=settings.KEYCLOAK_CLIENT_SECRET,
)
token = keycloak_openid.token(grant_type="client_credentials")
keycloak_admin = KeycloakAdmin(
@@ -34,6 +52,10 @@ def get_keycloak_admin_client():
token=token,
)
expires_in = int(token.get("expires_in", 60))
ttl = max(expires_in - _TOKEN_EXPIRY_SAFETY_MARGIN, _TOKEN_MIN_TTL)
_admin_client_cache["client"] = keycloak_admin
_admin_client_cache["expires_at"] = now + ttl
return keycloak_admin
@@ -252,6 +274,10 @@ def list_keycloak_users(limit=100):
def reset_keycloak_user_password(username, new_password=None):
"""
Reset a user's password in Keycloak with a one-time new password.
Also re-enables the account if it was disabled and clears any brute-force
lockout, so admins can recover users who got locked out after too many
failed login attempts in one operation.
"""
if not new_password:
new_password = generate_password()
@@ -267,6 +293,23 @@ def reset_keycloak_user_password(username, new_password=None):
user = users[0]
user_id = user["id"]
# If the account was disabled (e.g. by an admin), re-enable it before
# the new password becomes useful.
if not user.get("enabled", True):
keycloak_admin.update_user(user_id=user_id, payload={"enabled": True})
logger.info("Re-enabled Keycloak user: %s", username)
# Clear any brute-force lockout. This is a no-op when there are no
# recorded failures, and ensures a fresh password is immediately usable.
try:
keycloak_admin.clear_bruteforce_attempts_for_user(user_id=user_id)
except KeycloakError as e:
# Don't fail the whole password reset if the brute-force clear
# endpoint hiccups (e.g. policy disabled in some realms).
logger.warning(
"Could not clear brute-force attempts for %s: %s", username, e
)
# Set new temporary password
keycloak_admin.set_user_password(
user_id=user_id, password=new_password, temporary=True
@@ -276,7 +319,17 @@ def reset_keycloak_user_password(username, new_password=None):
return new_password
except KeycloakError as e:
logger.error("Keycloak error resetting password for %s: %s", username, e)
# Deliberately do not log the exception's body/message: while Keycloak
# itself does not echo the request password back in error responses,
# ``KeycloakError.__str__`` and ``response_body`` include the raw HTTP
# response, which we keep out of logs as defense-in-depth on a path
# that handles a brand-new password.
response_code = getattr(e, "response_code", None)
logger.error(
"Keycloak error resetting password for %s (status=%s)",
username,
response_code,
)
raise
@@ -334,3 +387,126 @@ def generate_password(length=12):
# Shuffle to avoid predictable positions
secrets.SystemRandom().shuffle(password_chars)
return "".join(password_chars)
def _get_keycloak_user_id(username):
"""Look up a Keycloak user id by username, raising ValueError if absent
or ambiguous.
``exact=True`` keeps Keycloak from substring-matching the username — by
default ``/users?username=foo`` returns anything containing ``foo``,
which at scale can surface the wrong user. We additionally require
exactly one result so an unexpected collision raises instead of
silently picking ``users[0]``.
"""
keycloak_admin = get_keycloak_admin_client()
users = keycloak_admin.get_users({"username": username, "exact": True})
if not users:
raise ValueError(f'User with username "{username}" not found.')
if len(users) > 1:
raise ValueError(
f'Ambiguous username "{username}": {len(users)} Keycloak users matched.'
)
return keycloak_admin, users[0]["id"]
def has_realm_role(username, role_id):
"""Return True if the Keycloak user has the realm role with this id assigned."""
keycloak_admin, user_id = _get_keycloak_user_id(username)
user_roles = keycloak_admin.get_realm_roles_of_user(user_id=user_id)
return any(role.get("id") == role_id for role in user_roles)
def is_mandatory_totp_enabled():
"""All three settings required for the mandatory TOTP feature are present."""
return bool(
settings.FEATURE_MAILDOMAIN_MANAGE_TOTP
and settings.KEYCLOAK_TOTP_ROLE_ID
and settings.IDENTITY_PROVIDER == "keycloak"
)
def batch_realm_role_membership(usernames, role_id):
"""Return ``{username: bool}`` indicating which of ``usernames`` hold
the realm role with id ``role_id``.
Routed through the ``bulk-role-membership`` custom Keycloak provider
(see ``src/keycloak/bulk-role-membership``) which answers the whole
page in one indexed DB query inside Keycloak. Keycloak's stock admin
API has no equivalent: every alternative is either O(N) round trips
or fetches the full role membership list.
Usernames in the response are compared case-insensitively (Keycloak's
canonical form is lowercased), so the returned dict is keyed back by
the input strings exactly as provided.
"""
if not usernames:
return {}
keycloak_admin = get_keycloak_admin_client()
response = keycloak_admin.connection.raw_post(
f"/realms/{settings.KEYCLOAK_REALM}/bulk-role-membership/check",
data=json.dumps({"role_id": role_id, "usernames": list(usernames)}),
)
response.raise_for_status()
matched = {m.lower() for m in response.json().get("members", [])}
return {u: u.lower() in matched for u in usernames}
def set_realm_role(username, role_id, *, assigned):
"""Assign or remove a realm role (looked up by id) for the Keycloak user.
Idempotent: if the role is already in the desired state, this is a no-op.
"""
keycloak_admin, user_id = _get_keycloak_user_id(username)
role = keycloak_admin.get_realm_role_by_id(role_id=role_id)
if not role:
raise ValueError(f'Realm role with id "{role_id}" not found.')
user_roles = keycloak_admin.get_realm_roles_of_user(user_id=user_id)
currently_assigned = any(r.get("id") == role_id for r in user_roles)
if assigned and not currently_assigned:
keycloak_admin.assign_realm_roles(user_id=user_id, roles=[role])
logger.info(
"Assigned realm role %s to Keycloak user %s", role.get("name"), username
)
elif not assigned and currently_assigned:
keycloak_admin.delete_realm_roles_of_user(user_id=user_id, roles=[role])
logger.info(
"Removed realm role %s from Keycloak user %s", role.get("name"), username
)
def reset_keycloak_user_totp(username):
"""Reset a user's TOTP enrollment.
Deletes any OTP credentials they have on file and registers
``CONFIGURE_TOTP`` as a required action so they re-enroll on next login.
"""
keycloak_admin, user_id = _get_keycloak_user_id(username)
credentials = keycloak_admin.get_credentials(user_id=user_id) or []
deleted = 0
for credential in credentials:
# Keycloak labels OTP creds with type "otp"; covers TOTP & HOTP.
if (credential.get("type") or "").lower() == "otp":
keycloak_admin.delete_credential(
user_id=user_id, credential_id=credential["id"]
)
deleted += 1
user = keycloak_admin.get_user(user_id=user_id) or {}
required_actions = list(user.get("requiredActions") or [])
if "CONFIGURE_TOTP" not in required_actions:
required_actions.append("CONFIGURE_TOTP")
keycloak_admin.update_user(
user_id=user_id, payload={"requiredActions": required_actions}
)
logger.info(
"Reset TOTP for Keycloak user %s (removed %d OTP credentials)",
username,
deleted,
)
return {"removed_credentials": deleted}
+5 -10
View File
@@ -42,6 +42,8 @@ from django.conf import settings
from redis.exceptions import RedisError
from core.utils import get_redis_client
logger = logging.getLogger(__name__)
PENDING_REINDEX_KEY = "search:pending_reindex_threads"
@@ -59,13 +61,6 @@ def _is_redis_backend() -> bool:
return "django_redis" in backend
def _redis_client():
# pylint: disable-next=import-outside-toplevel
from django_redis import get_redis_connection
return get_redis_connection("default")
def _enqueue(key: str, value) -> None:
"""Add ``value`` to the pending set at ``key``."""
if value is None:
@@ -80,7 +75,7 @@ def _enqueue(key: str, value) -> None:
)
return
try:
_redis_client().sadd(key, str(value))
get_redis_client().sadd(key, str(value))
except RedisError as exc:
logger.error(
"Redis unavailable while enqueuing %s into %s (%s: %s); "
@@ -129,7 +124,7 @@ def _drain_batch(key: str, batch_size: int) -> list | None:
or ``None`` if the drain itself failed — signalling the caller to stop.
"""
try:
drained = _redis_client().spop(key, count=batch_size)
drained = get_redis_client().spop(key, count=batch_size)
return [
tid.decode() if isinstance(tid, bytes) else str(tid)
for tid in (drained or [])
@@ -152,7 +147,7 @@ def _drain_batch(key: str, batch_size: int) -> list | None:
def _restore_batch(key: str, thread_ids: list) -> None:
"""Push ``thread_ids`` back into the pending set at ``key``."""
try:
_redis_client().sadd(key, *thread_ids)
get_redis_client().sadd(key, *thread_ids)
except RedisError as exc:
logger.error(
"Redis unavailable while restoring %d drained IDs to %s (%s: %s); "
@@ -273,6 +273,158 @@ class TestAdminMailDomainViewSet:
assert response.status_code == status.HTTP_200_OK
assert response.data["count"] == 10
def test_admin_maildomains_list_search_by_name(
self,
api_client,
domain_admin_user,
):
"""`?q=` filters domains by case-insensitive name substring."""
for name in ("alpha.example.com", "beta.example.com", "alphabeta.org"):
domain = factories.MailDomainFactory(name=name)
factories.MailDomainAccessFactory(
user=domain_admin_user,
maildomain=domain,
role=MailDomainAccessRoleChoices.ADMIN,
)
api_client.force_authenticate(user=domain_admin_user)
response = api_client.get(f"{self.LIST_DOMAINS_URL}?q=alpha")
assert response.status_code == status.HTTP_200_OK
names = {item["name"] for item in response.data["results"]}
assert names == {"alpha.example.com", "alphabeta.org"}
# Case-insensitive
response = api_client.get(f"{self.LIST_DOMAINS_URL}?q=BETA")
assert response.status_code == status.HTTP_200_OK
names = {item["name"] for item in response.data["results"]}
assert names == {"beta.example.com", "alphabeta.org"}
# Empty / whitespace-only query returns all administered domains
response = api_client.get(f"{self.LIST_DOMAINS_URL}?q= ")
assert response.status_code == status.HTTP_200_OK
assert response.data["count"] == 3
# No match
response = api_client.get(f"{self.LIST_DOMAINS_URL}?q=zzz")
assert response.status_code == status.HTTP_200_OK
assert response.data["count"] == 0
def test_admin_maildomains_list_search_superuser(
self,
api_client,
mail_domain1,
mail_domain2,
unmanaged_domain,
):
"""Superusers can also filter the full domain list with `?q=`."""
superuser = factories.UserFactory(is_superuser=True)
api_client.force_authenticate(user=superuser)
response = api_client.get(f"{self.LIST_DOMAINS_URL}?q=admin-domain1")
assert response.status_code == status.HTTP_200_OK
names = {item["name"] for item in response.data["results"]}
assert names == {mail_domain1.name}
def test_maildomain_mailbox_count_in_list(
self,
api_client,
domain_admin_user,
domain_admin_access1,
domain_admin_access2,
mail_domain1,
mail_domain2,
mailbox1_domain1,
mailbox2_domain1,
mailbox1_domain2,
):
"""`mailbox_count` reflects the number of mailboxes per domain in list view."""
api_client.force_authenticate(user=domain_admin_user)
response = api_client.get(self.LIST_DOMAINS_URL)
assert response.status_code == status.HTTP_200_OK
counts_by_id = {
item["id"]: item["mailbox_count"] for item in response.data["results"]
}
assert counts_by_id[str(mail_domain1.id)] == 2
assert counts_by_id[str(mail_domain2.id)] == 1
def test_maildomain_mailbox_count_in_detail(
self,
api_client,
domain_admin_user,
domain_admin_access1,
mail_domain1,
mailbox1_domain1,
mailbox2_domain1,
):
"""`mailbox_count` is exposed on the detail endpoint with the right count."""
api_client.force_authenticate(user=domain_admin_user)
response = api_client.get(f"{self.LIST_DOMAINS_URL}{mail_domain1.id}/")
assert response.status_code == status.HTTP_200_OK
assert response.data["mailbox_count"] == 2
def test_maildomain_mailbox_count_zero_when_no_mailbox(
self,
api_client,
domain_admin_user,
domain_admin_access1,
mail_domain1,
):
"""`mailbox_count` is 0 for a domain that has no mailbox."""
api_client.force_authenticate(user=domain_admin_user)
response = api_client.get(f"{self.LIST_DOMAINS_URL}{mail_domain1.id}/")
assert response.status_code == status.HTTP_200_OK
assert response.data["mailbox_count"] == 0
def test_maildomain_mailbox_count_no_n_plus_one(
self,
api_client,
domain_admin_user,
django_assert_num_queries,
):
"""Adding mailboxes must not increase the query count of the list endpoint."""
for i in range(3):
maildomain = factories.MailDomainFactory(name=f"count-domain{i}.com")
models.MailDomainAccess.objects.create(
maildomain=maildomain,
user=domain_admin_user,
role=models.MailDomainAccessRoleChoices.ADMIN,
)
for j in range(4):
factories.MailboxFactory(domain=maildomain, local_part=f"box{j}")
api_client.force_authenticate(user=domain_admin_user)
with django_assert_num_queries(
3
): # 1 for permission + 1 for list + 1 for pagination
response = api_client.get(self.LIST_DOMAINS_URL)
assert response.status_code == status.HTTP_200_OK
assert response.data["count"] == 3
for item in response.data["results"]:
assert item["mailbox_count"] == 4
def test_maildomain_mailbox_count_scoped_to_domain(
self,
api_client,
domain_admin_user,
domain_admin_access1,
mail_domain1,
mail_domain2,
mailbox1_domain1,
mailbox1_domain2,
):
"""Mailboxes from other domains must not leak into `mailbox_count`."""
api_client.force_authenticate(user=domain_admin_user)
response = api_client.get(f"{self.LIST_DOMAINS_URL}{mail_domain1.id}/")
assert response.status_code == status.HTTP_200_OK
assert response.data["mailbox_count"] == 1
def test_maildomain_expected_dns_records_in_response(
self,
api_client,
@@ -2,6 +2,7 @@
# pylint: disable=unused-argument, too-many-lines
import uuid
from datetime import datetime, timezone
from unittest.mock import patch
from django.test import override_settings
@@ -158,6 +159,20 @@ class TestAdminMailDomainMailboxViewSet:
kwargs={"maildomain_pk": maildomain_pk, "pk": mailbox_pk},
)
def mandatory_totp_url(self, maildomain_pk, mailbox_pk):
"""Generate URL for set-mandatory-totp action on a mailbox in a specific domain."""
return reverse(
"admin-maildomains-mailbox-set-mandatory-totp",
kwargs={"maildomain_pk": maildomain_pk, "pk": mailbox_pk},
)
def reset_totp_url(self, maildomain_pk, mailbox_pk):
"""Generate URL for reset-totp action on a mailbox in a specific domain."""
return reverse(
"admin-maildomains-mailbox-reset-totp",
kwargs={"maildomain_pk": maildomain_pk, "pk": mailbox_pk},
)
# pylint: disable=too-many-arguments
def test_admin_maildomains_mailbox_list_for_domain_success(
self,
@@ -239,6 +254,154 @@ class TestAdminMailDomainMailboxViewSet:
response = api_client.get(url)
assert response.status_code == status.HTTP_401_UNAUTHORIZED
def test_admin_maildomains_mailbox_list_default_alphabetical_order(
self,
api_client,
domain_admin_user,
domain_admin_access1,
mail_domain1,
):
"""Mailboxes are returned ordered alphabetically by local_part."""
for local_part in ("zeta", "alpha", "mango", "beta"):
factories.MailboxFactory(domain=mail_domain1, local_part=local_part)
api_client.force_authenticate(user=domain_admin_user)
url = self.mailboxes_url(mail_domain1.pk)
response = api_client.get(url)
assert response.status_code == status.HTTP_200_OK
local_parts = [item["local_part"] for item in response.data["results"]]
assert local_parts == sorted(local_parts)
assert local_parts == ["alpha", "beta", "mango", "zeta"]
def test_admin_maildomains_mailbox_list_search_by_local_part(
self,
api_client,
domain_admin_user,
domain_admin_access1,
mail_domain1,
mail_domain2,
domain_admin_access2,
):
"""`?q=` filters mailboxes by case-insensitive local_part substring,
scoped to the requested domain."""
factories.MailboxFactory(domain=mail_domain1, local_part="john.doe")
factories.MailboxFactory(domain=mail_domain1, local_part="jane.doe")
factories.MailboxFactory(domain=mail_domain1, local_part="bob")
# Different domain: should never appear when querying mail_domain1
factories.MailboxFactory(domain=mail_domain2, local_part="john.smith")
api_client.force_authenticate(user=domain_admin_user)
url = self.mailboxes_url(mail_domain1.pk)
response = api_client.get(f"{url}?q=doe")
assert response.status_code == status.HTTP_200_OK
local_parts = sorted(item["local_part"] for item in response.data["results"])
assert local_parts == ["jane.doe", "john.doe"]
# Case-insensitive
response = api_client.get(f"{url}?q=JOHN")
assert response.status_code == status.HTTP_200_OK
local_parts = [item["local_part"] for item in response.data["results"]]
assert local_parts == ["john.doe"]
# Whitespace-only query is treated as no filter
response = api_client.get(f"{url}?q= ")
assert response.status_code == status.HTTP_200_OK
assert response.data["count"] == 3
# No match
response = api_client.get(f"{url}?q=nope")
assert response.status_code == status.HTTP_200_OK
assert response.data["count"] == 0
def test_admin_maildomains_mailbox_list_search_by_contact_name(
self,
api_client,
domain_admin_user,
domain_admin_access1,
mail_domain1,
):
"""`?q=` also matches against the linked contact's name."""
mb_alice = factories.MailboxFactory(
domain=mail_domain1,
local_part="aaa",
contact=factories.ContactFactory(
email=f"aaa@{mail_domain1.name}", name="Alice Wonderland"
),
)
mb_bob = factories.MailboxFactory(
domain=mail_domain1,
local_part="bbb",
contact=factories.ContactFactory(
email=f"bbb@{mail_domain1.name}", name="Bob Builder"
),
)
factories.MailboxFactory(
domain=mail_domain1,
local_part="ccc",
contact=factories.ContactFactory(
email=f"ccc@{mail_domain1.name}", name="Charlie Chaplin"
),
)
api_client.force_authenticate(user=domain_admin_user)
url = self.mailboxes_url(mail_domain1.pk)
# Match by contact name (case-insensitive)
response = api_client.get(f"{url}?q=wonderland")
assert response.status_code == status.HTTP_200_OK
ids = {item["id"] for item in response.data["results"]}
assert ids == {str(mb_alice.id)}
# Match local_part OR contact name, deduped
response = api_client.get(
f"{url}?q=b"
) # 'b' in bbb local_part AND in "Builder"/"Bob"
assert response.status_code == status.HTTP_200_OK
ids = [item["id"] for item in response.data["results"]]
assert sorted(ids) == sorted({str(mb_bob.id)})
def test_admin_maildomains_mailbox_list_includes_last_accessed_at(
self,
api_client,
domain_admin_user,
domain_admin_access1,
mail_domain1,
user_for_access1,
user_for_access2,
):
"""Each mailbox payload exposes `last_accessed_at` (max accessed_at
across its accesses), null when no access has ever been recorded."""
mb = factories.MailboxFactory(domain=mail_domain1, local_part="mb1")
never_accessed = factories.MailboxFactory(domain=mail_domain1, local_part="mb0")
older = datetime(2026, 1, 1, 12, 0, tzinfo=timezone.utc)
newer = datetime(2026, 5, 1, 12, 0, tzinfo=timezone.utc)
factories.MailboxAccessFactory(
mailbox=mb,
user=user_for_access1,
role=MailboxRoleChoices.EDITOR,
accessed_at=older,
)
factories.MailboxAccessFactory(
mailbox=mb,
user=user_for_access2,
role=MailboxRoleChoices.VIEWER,
accessed_at=newer,
)
api_client.force_authenticate(user=domain_admin_user)
url = self.mailboxes_url(mail_domain1.pk)
response = api_client.get(url)
assert response.status_code == status.HTTP_200_OK
by_id = {item["id"]: item for item in response.data["results"]}
assert "last_accessed_at" in by_id[str(mb.id)]
# The annotation returns the most recent access timestamp.
assert by_id[str(mb.id)]["last_accessed_at"] == newer
assert by_id[str(never_accessed.id)]["last_accessed_at"] is None
# --- EXCLUDE ABILITIES Tests ---
def test_admin_maildomains_mailbox_list_excludes_abilities_from_nested_users(
self,
@@ -1210,3 +1373,353 @@ class TestAdminMailDomainMailboxViewSet:
response = api_client.patch(url)
assert response.status_code == status.HTTP_403_FORBIDDEN
# --- Mandatory TOTP tests ------------------------------------------------
@patch("core.services.identity.keycloak.set_realm_role")
def test_admin_maildomains_mailbox_mandatory_totp_assigns_role(
self,
mock_set_role,
api_client,
domain_admin_user,
domain_admin_access1,
mail_domain1,
mailbox1_domain1,
):
"""Setting `enabled=true` calls set_realm_role with assigned=True."""
api_client.force_authenticate(user=domain_admin_user)
url = self.mandatory_totp_url(mail_domain1.pk, mailbox1_domain1.pk)
with override_settings(
IDENTITY_PROVIDER="keycloak",
FEATURE_MAILDOMAIN_MANAGE_TOTP=True,
KEYCLOAK_TOTP_ROLE_ID="role-id-123",
):
response = api_client.post(url, data={"enabled": True}, format="json")
username = f"{mailbox1_domain1.local_part}@{mail_domain1.name}"
assert response.status_code == status.HTTP_200_OK
assert response.data == {"enabled": True}
mock_set_role.assert_called_once_with(username, "role-id-123", assigned=True)
@patch("core.services.identity.keycloak.set_realm_role")
def test_admin_maildomains_mailbox_mandatory_totp_removes_role(
self,
mock_set_role,
api_client,
domain_admin_user,
domain_admin_access1,
mail_domain1,
mailbox1_domain1,
):
"""Disabling mandatory TOTP removes the Keycloak role from the user."""
api_client.force_authenticate(user=domain_admin_user)
url = self.mandatory_totp_url(mail_domain1.pk, mailbox1_domain1.pk)
with override_settings(
IDENTITY_PROVIDER="keycloak",
FEATURE_MAILDOMAIN_MANAGE_TOTP=True,
KEYCLOAK_TOTP_ROLE_ID="role-id-123",
):
response = api_client.post(url, data={"enabled": False}, format="json")
username = f"{mailbox1_domain1.local_part}@{mail_domain1.name}"
assert response.status_code == status.HTTP_200_OK
assert response.data == {"enabled": False}
mock_set_role.assert_called_once_with(username, "role-id-123", assigned=False)
@patch("core.services.identity.keycloak.set_realm_role")
def test_admin_maildomains_mailbox_mandatory_totp_rejects_invalid_payload(
self,
mock_set_role,
api_client,
domain_admin_user,
domain_admin_access1,
mail_domain1,
mailbox1_domain1,
):
"""Garbage payloads (string ``"false"``, missing key) are rejected.
Without strict validation the previous ``bool(request.data.get(...))``
would have treated the truthy string ``"false"`` as enable-TOTP.
"""
api_client.force_authenticate(user=domain_admin_user)
url = self.mandatory_totp_url(mail_domain1.pk, mailbox1_domain1.pk)
with override_settings(
IDENTITY_PROVIDER="keycloak",
FEATURE_MAILDOMAIN_MANAGE_TOTP=True,
KEYCLOAK_TOTP_ROLE_ID="role-id-123",
):
# Missing required field
response = api_client.post(url, data={}, format="json")
assert response.status_code == status.HTTP_400_BAD_REQUEST
# Garbage string accepted by DRF (it parses "true"/"false")
# so we instead test something that's actually invalid:
response = api_client.post(
url, data={"enabled": "not-a-bool"}, format="json"
)
assert response.status_code == status.HTTP_400_BAD_REQUEST
mock_set_role.assert_not_called()
def test_admin_maildomains_mailbox_mandatory_totp_500_does_not_leak_exception(
self,
api_client,
domain_admin_user,
domain_admin_access1,
mail_domain1,
mailbox1_domain1,
):
"""On a Keycloak failure the response carries a generic error string."""
api_client.force_authenticate(user=domain_admin_user)
url = self.mandatory_totp_url(mail_domain1.pk, mailbox1_domain1.pk)
with (
patch(
"core.services.identity.keycloak.set_realm_role",
side_effect=Exception("Sensitive internal trace at /app/foo.py:42"),
),
override_settings(
IDENTITY_PROVIDER="keycloak",
FEATURE_MAILDOMAIN_MANAGE_TOTP=True,
KEYCLOAK_TOTP_ROLE_ID="role-id-123",
),
):
response = api_client.post(url, data={"enabled": True}, format="json")
assert response.status_code == status.HTTP_500_INTERNAL_SERVER_ERROR
assert "Sensitive internal trace" not in response.data["error"]
assert response.data["error"] == "Could not update mandatory TOTP."
@patch(
"core.services.identity.keycloak.set_realm_role",
side_effect=ValueError('User with username "x@y" not found.'),
)
def test_admin_maildomains_mailbox_mandatory_totp_404_when_keycloak_user_missing(
self,
_mock_set_role,
api_client,
domain_admin_user,
domain_admin_access1,
mail_domain1,
mailbox1_domain1,
):
"""Missing Keycloak user/role surfaces as 404, not a generic 500."""
api_client.force_authenticate(user=domain_admin_user)
url = self.mandatory_totp_url(mail_domain1.pk, mailbox1_domain1.pk)
with override_settings(
IDENTITY_PROVIDER="keycloak",
FEATURE_MAILDOMAIN_MANAGE_TOTP=True,
KEYCLOAK_TOTP_ROLE_ID="role-id-123",
):
response = api_client.post(url, data={"enabled": True}, format="json")
assert response.status_code == status.HTTP_404_NOT_FOUND
def test_admin_maildomains_mailbox_mandatory_totp_validates_payload_before_eligibility(
self,
api_client,
domain_admin_user,
domain_admin_access1,
mail_domain1,
):
"""Malformed body returns 400 even when the mailbox is ineligible.
Without "validate first", an ineligible mailbox could mask a missing
``enabled`` field. The contract: bad input always 400.
"""
# Build an ineligible mailbox (shared, not personal).
ineligible = factories.MailboxFactory(
domain=mail_domain1, local_part="shared", is_identity=False
)
api_client.force_authenticate(user=domain_admin_user)
url = self.mandatory_totp_url(mail_domain1.pk, ineligible.pk)
with override_settings(
IDENTITY_PROVIDER="keycloak",
FEATURE_MAILDOMAIN_MANAGE_TOTP=True,
KEYCLOAK_TOTP_ROLE_ID="role-id-123",
):
response = api_client.post(url, data={}, format="json")
assert response.status_code == status.HTTP_400_BAD_REQUEST
# The error must be about the missing field, not eligibility.
assert "enabled" in str(response.data).lower()
def test_admin_maildomains_mailbox_mandatory_totp_requires_manage_mailboxes_ability(
self,
api_client,
other_user,
mail_domain1,
mailbox1_domain1,
):
"""Non-admin domain members can't toggle TOTP.
Pins the backend contract that the action requires the same
``manage_mailboxes`` ability the frontend gates the UI on.
"""
# Give `other_user` non-admin domain access (would fail IsMailDomainAdmin).
api_client.force_authenticate(user=other_user)
url = self.mandatory_totp_url(mail_domain1.pk, mailbox1_domain1.pk)
with override_settings(
IDENTITY_PROVIDER="keycloak",
FEATURE_MAILDOMAIN_MANAGE_TOTP=True,
KEYCLOAK_TOTP_ROLE_ID="role-id-123",
):
response = api_client.post(url, data={"enabled": True}, format="json")
assert response.status_code == status.HTTP_403_FORBIDDEN
def test_admin_maildomains_mailbox_mandatory_totp_404_when_feature_disabled(
self,
api_client,
domain_admin_user,
domain_admin_access1,
mail_domain1,
mailbox1_domain1,
):
"""The endpoint returns 404 when the mandatory TOTP feature flag is disabled."""
api_client.force_authenticate(user=domain_admin_user)
url = self.mandatory_totp_url(mail_domain1.pk, mailbox1_domain1.pk)
with override_settings(
IDENTITY_PROVIDER="keycloak",
FEATURE_MAILDOMAIN_MANAGE_TOTP=False,
KEYCLOAK_TOTP_ROLE_ID="role-id-123",
):
response = api_client.post(url, data={"enabled": True}, format="json")
assert response.status_code == status.HTTP_404_NOT_FOUND
def test_admin_maildomains_mailbox_mandatory_totp_404_when_role_id_missing(
self,
api_client,
domain_admin_user,
domain_admin_access1,
mail_domain1,
mailbox1_domain1,
):
"""Missing role id is treated as feature-not-enabled (404).
The frontend gates the UI on the feature flag, so distinguishing
misconfiguration from disablement isn't worth a separate status code.
"""
api_client.force_authenticate(user=domain_admin_user)
url = self.mandatory_totp_url(mail_domain1.pk, mailbox1_domain1.pk)
with override_settings(
IDENTITY_PROVIDER="keycloak",
FEATURE_MAILDOMAIN_MANAGE_TOTP=True,
KEYCLOAK_TOTP_ROLE_ID=None,
):
response = api_client.post(url, data={"enabled": True}, format="json")
assert response.status_code == status.HTTP_404_NOT_FOUND
@patch("core.api.viewsets.maildomain.keycloak_service.batch_realm_role_membership")
def test_admin_maildomains_mailbox_list_batches_totp_lookup(
self,
mock_batch,
api_client,
domain_admin_user,
domain_admin_access1,
mail_domain1,
):
"""Single batched call to the bulk-role-membership endpoint per page."""
mailboxes = [
factories.MailboxFactory(
domain=mail_domain1, local_part=f"mb{i}", is_identity=True
)
for i in range(5)
]
usernames = [f"{m.local_part}@{mail_domain1.name}" for m in mailboxes]
# First two carry the TOTP role.
mock_batch.return_value = {
usernames[0]: True,
usernames[1]: True,
usernames[2]: False,
usernames[3]: False,
usernames[4]: False,
}
api_client.force_authenticate(user=domain_admin_user)
url = self.mailboxes_url(mail_domain1.pk)
with override_settings(
IDENTITY_PROVIDER="keycloak",
FEATURE_MAILDOMAIN_MANAGE_TOTP=True,
KEYCLOAK_TOTP_ROLE_ID="role-id-123",
):
response = api_client.get(url)
assert response.status_code == status.HTTP_200_OK
# Exactly one batched call, scoped to the page's usernames.
mock_batch.assert_called_once()
call_usernames, call_role_id = mock_batch.call_args[0]
assert sorted(call_usernames) == sorted(usernames)
assert call_role_id == "role-id-123"
by_local = {row["local_part"]: row for row in response.data["results"]}
assert by_local["mb0"]["has_mandatory_totp"] is True
assert by_local["mb1"]["has_mandatory_totp"] is True
assert by_local["mb2"]["has_mandatory_totp"] is False
@patch("core.api.viewsets.maildomain.keycloak_service.batch_realm_role_membership")
def test_admin_maildomains_mailbox_list_keycloak_failure_returns_null(
self,
mock_get_members,
api_client,
domain_admin_user,
domain_admin_access1,
mail_domain1,
mailbox1_domain1,
):
"""If the batched Keycloak fetch fails, rows still render with `null`."""
mock_get_members.side_effect = Exception("keycloak down")
api_client.force_authenticate(user=domain_admin_user)
url = self.mailboxes_url(mail_domain1.pk)
with override_settings(
IDENTITY_PROVIDER="keycloak",
FEATURE_MAILDOMAIN_MANAGE_TOTP=True,
KEYCLOAK_TOTP_ROLE_ID="role-id-123",
):
response = api_client.get(url)
assert response.status_code == status.HTTP_200_OK
for row in response.data["results"]:
assert row["has_mandatory_totp"] is None
@patch("core.services.identity.keycloak.reset_keycloak_user_totp")
def test_admin_maildomains_mailbox_reset_totp_success(
self,
mock_reset_totp,
api_client,
domain_admin_user,
domain_admin_access1,
mail_domain1,
mailbox1_domain1,
):
"""Reset-TOTP endpoint forwards the call to Keycloak and returns the removal count."""
mock_reset_totp.return_value = {"removed_credentials": 1}
api_client.force_authenticate(user=domain_admin_user)
url = self.reset_totp_url(mail_domain1.pk, mailbox1_domain1.pk)
with override_settings(
IDENTITY_PROVIDER="keycloak",
FEATURE_MAILDOMAIN_MANAGE_TOTP=True,
KEYCLOAK_TOTP_ROLE_ID="role-id-123",
):
response = api_client.patch(url)
assert response.status_code == status.HTTP_200_OK
assert response.data == {"removed_credentials": 1}
mock_reset_totp.assert_called_once_with(
f"{mailbox1_domain1.local_part}@{mail_domain1.name}"
)
+19
View File
@@ -27,6 +27,7 @@ pytestmark = pytest.mark.django_db
FEATURE_MAILDOMAIN_CREATE=True,
FEATURE_MAILDOMAIN_MANAGE_ACCESSES=True,
FEATURE_THREAD_SPLIT=True,
FEATURE_MAILDOMAIN_MANAGE_TOTP=False,
DRIVE_CONFIG={"base_url": None, "app_name": "Drive"},
MAX_OUTGOING_ATTACHMENT_SIZE=20971520, # 20MB
MAX_OUTGOING_BODY_SIZE=5242880, # 5MB
@@ -59,6 +60,7 @@ def test_api_config(is_authenticated):
"FEATURE_MAILDOMAIN_CREATE": True,
"FEATURE_MAILDOMAIN_MANAGE_ACCESSES": True,
"FEATURE_THREAD_SPLIT": True,
"FEATURE_MAILDOMAIN_MANAGE_TOTP": False,
"SCHEMA_CUSTOM_ATTRIBUTES_USER": {},
"SCHEMA_CUSTOM_ATTRIBUTES_MAILDOMAIN": {},
"MAX_INCOMING_EMAIL_SIZE": 10485760,
@@ -93,3 +95,20 @@ def test_api_config_with_external_services():
"file_url": "http://localhost:8902/explorer/items/files",
"app_name": "Drive App",
}
@override_settings(
FEATURE_MAILDOMAIN_MANAGE_TOTP=True,
KEYCLOAK_TOTP_ROLE_ID=None,
IDENTITY_PROVIDER="keycloak",
)
def test_api_config_totp_flag_is_effective_not_raw():
"""Frontend must not see TOTP enabled when the backend can't enforce it.
The raw ``FEATURE_MAILDOMAIN_MANAGE_TOTP`` flag is True here, but a
missing role id means the backend cannot actually carry out toggles —
so the config endpoint must report False.
"""
response = APIClient().get("/api/v1.0/config/")
assert response.status_code == HTTP_200_OK
assert response.json()["FEATURE_MAILDOMAIN_MANAGE_TOTP"] is False
+5 -5
View File
@@ -25,9 +25,9 @@ def redis_cache(settings):
already declares ``redis`` as a ``depends_on``, so the service is
reachable at ``redis:6379`` (overridable via ``REDIS_URL``).
Coalescer / blob_gc call ``get_redis_connection("default").sadd(...)``
directly with hard-coded keys, so Django's ``KEY_PREFIX`` doesn't
isolate them. We isolate parallel xdist workers by routing each
Coalescer / blob_gc call ``get_redis_client().sadd(...)`` directly
with hard-coded keys, so Django's ``KEY_PREFIX`` doesn't isolate
them. We isolate parallel xdist workers by routing each
worker to a distinct Redis DB (``gw0`` → DB 1, ``gw1`` → DB 2, …;
non-xdist runs land on DB 1). ``flushdb`` at setup and teardown
keeps the slot clean. Default Redis ships with 16 DBs, enough for
@@ -50,9 +50,9 @@ def redis_cache(settings):
},
}
from django_redis import get_redis_connection
from core.utils import get_redis_client
client = get_redis_connection("default")
client = get_redis_client()
client.flushdb()
yield client
@@ -0,0 +1,277 @@
"""Tests for the Keycloak identity service helpers."""
# pylint: disable=unused-argument
import json
from unittest.mock import MagicMock, patch
from django.test import override_settings
import pytest
from keycloak.exceptions import KeycloakError
from core.services.identity import keycloak as keycloak_service
@pytest.fixture(name="keycloak_admin_mock")
def fixture_keycloak_admin_mock():
"""Patch get_keycloak_admin_client and yield the returned mock client."""
with patch.object(keycloak_service, "get_keycloak_admin_client") as factory:
client = MagicMock()
factory.return_value = client
yield client
def test_reset_password_re_enables_disabled_user(keycloak_admin_mock):
"""A disabled Keycloak user is re-enabled before the password is reset."""
keycloak_admin_mock.get_users.return_value = [
{"id": "kc-user-id", "enabled": False}
]
new_password = keycloak_service.reset_keycloak_user_password(
"user@example.local", new_password="NewPass123!"
)
assert new_password == "NewPass123!"
keycloak_admin_mock.update_user.assert_called_once_with(
user_id="kc-user-id", payload={"enabled": True}
)
keycloak_admin_mock.clear_bruteforce_attempts_for_user.assert_called_once_with(
user_id="kc-user-id"
)
keycloak_admin_mock.set_user_password.assert_called_once_with(
user_id="kc-user-id", password="NewPass123!", temporary=True
)
def test_reset_password_skips_re_enable_when_already_enabled(keycloak_admin_mock):
"""An already-enabled user is not re-enabled, but brute-force is still cleared."""
keycloak_admin_mock.get_users.return_value = [{"id": "kc-user-id", "enabled": True}]
keycloak_service.reset_keycloak_user_password(
"user@example.local", new_password="NewPass123!"
)
keycloak_admin_mock.update_user.assert_not_called()
keycloak_admin_mock.clear_bruteforce_attempts_for_user.assert_called_once()
keycloak_admin_mock.set_user_password.assert_called_once()
def test_reset_password_swallows_brute_force_error(keycloak_admin_mock):
"""Failure to clear brute-force counters must not abort the password reset."""
keycloak_admin_mock.get_users.return_value = [{"id": "kc-user-id", "enabled": True}]
keycloak_admin_mock.clear_bruteforce_attempts_for_user.side_effect = KeycloakError(
"boom"
)
keycloak_service.reset_keycloak_user_password(
"user@example.local", new_password="NewPass123!"
)
keycloak_admin_mock.set_user_password.assert_called_once()
def test_reset_password_raises_when_user_not_found(keycloak_admin_mock):
"""A missing user surfaces a clear ValueError; nothing is mutated."""
keycloak_admin_mock.get_users.return_value = []
with pytest.raises(ValueError, match="not found"):
keycloak_service.reset_keycloak_user_password("ghost@example.local")
keycloak_admin_mock.set_user_password.assert_not_called()
def test_set_realm_role_assigns_when_missing(keycloak_admin_mock):
"""The role is assigned only when the user does not already have it."""
role = {"id": "role-id", "name": "mandatory-totp"}
keycloak_admin_mock.get_users.return_value = [{"id": "kc-user-id"}]
keycloak_admin_mock.get_realm_role_by_id.return_value = role
keycloak_admin_mock.get_realm_roles_of_user.return_value = []
keycloak_service.set_realm_role("user@example.local", "role-id", assigned=True)
keycloak_admin_mock.assign_realm_roles.assert_called_once_with(
user_id="kc-user-id", roles=[role]
)
keycloak_admin_mock.delete_realm_roles_of_user.assert_not_called()
def test_set_realm_role_is_idempotent_when_already_assigned(keycloak_admin_mock):
"""Re-assigning a role the user already carries is a no-op."""
role = {"id": "role-id", "name": "mandatory-totp"}
keycloak_admin_mock.get_users.return_value = [{"id": "kc-user-id"}]
keycloak_admin_mock.get_realm_role_by_id.return_value = role
keycloak_admin_mock.get_realm_roles_of_user.return_value = [role]
keycloak_service.set_realm_role("user@example.local", "role-id", assigned=True)
keycloak_admin_mock.assign_realm_roles.assert_not_called()
def test_set_realm_role_removes_when_present(keycloak_admin_mock):
"""Removing a role that the user carries triggers a single delete call."""
role = {"id": "role-id", "name": "mandatory-totp"}
keycloak_admin_mock.get_users.return_value = [{"id": "kc-user-id"}]
keycloak_admin_mock.get_realm_role_by_id.return_value = role
keycloak_admin_mock.get_realm_roles_of_user.return_value = [role]
keycloak_service.set_realm_role("user@example.local", "role-id", assigned=False)
keycloak_admin_mock.delete_realm_roles_of_user.assert_called_once_with(
user_id="kc-user-id", roles=[role]
)
def test_is_mandatory_totp_enabled():
"""All three settings must be present and the IDP must be Keycloak."""
with override_settings(
FEATURE_MAILDOMAIN_MANAGE_TOTP=True,
KEYCLOAK_TOTP_ROLE_ID="role-id",
IDENTITY_PROVIDER="keycloak",
):
assert keycloak_service.is_mandatory_totp_enabled() is True
with override_settings(
FEATURE_MAILDOMAIN_MANAGE_TOTP=False,
KEYCLOAK_TOTP_ROLE_ID="role-id",
IDENTITY_PROVIDER="keycloak",
):
assert keycloak_service.is_mandatory_totp_enabled() is False
with override_settings(
FEATURE_MAILDOMAIN_MANAGE_TOTP=True,
KEYCLOAK_TOTP_ROLE_ID=None,
IDENTITY_PROVIDER="keycloak",
):
assert keycloak_service.is_mandatory_totp_enabled() is False
with override_settings(
FEATURE_MAILDOMAIN_MANAGE_TOTP=True,
KEYCLOAK_TOTP_ROLE_ID="role-id",
IDENTITY_PROVIDER="oidc",
):
assert keycloak_service.is_mandatory_totp_enabled() is False
def _stub_bulk_response(keycloak_admin_mock, *, members):
"""Wire keycloak_admin.connection.raw_post to return a 200 with these members."""
response = MagicMock()
response.status_code = 200
response.json.return_value = {"members": list(members)}
keycloak_admin_mock.connection.raw_post.return_value = response
return response
def test_batch_realm_role_membership_calls_custom_endpoint(keycloak_admin_mock):
"""One POST to the custom endpoint resolves the whole page."""
_stub_bulk_response(keycloak_admin_mock, members=["alice@example.local"])
result = keycloak_service.batch_realm_role_membership(
["alice@example.local", "bob@example.local"], "role-id"
)
assert result == {"alice@example.local": True, "bob@example.local": False}
call = keycloak_admin_mock.connection.raw_post.call_args
assert call.args[0].endswith("/bulk-role-membership/check")
body = json.loads(call.kwargs["data"])
assert body == {
"role_id": "role-id",
"usernames": ["alice@example.local", "bob@example.local"],
}
def test_batch_realm_role_membership_empty_input_skips_endpoint(keycloak_admin_mock):
"""An empty input short-circuits before any HTTP call is issued."""
assert keycloak_service.batch_realm_role_membership([], "role-id") == {}
keycloak_admin_mock.connection.raw_post.assert_not_called()
def test_batch_realm_role_membership_is_case_insensitive(keycloak_admin_mock):
"""Caller casing is preserved as keys; membership decision is lowercased."""
_stub_bulk_response(keycloak_admin_mock, members=["alice@example.local"])
result = keycloak_service.batch_realm_role_membership(
["Alice@Example.local", "Carol@Example.local"], "role-id"
)
assert result == {
"Alice@Example.local": True,
"Carol@Example.local": False,
}
def test_batch_realm_role_membership_raises_on_http_error(keycloak_admin_mock):
"""A non-2xx response surfaces as an exception — the caller wraps it."""
response = MagicMock()
response.raise_for_status.side_effect = RuntimeError("HTTP 500")
keycloak_admin_mock.connection.raw_post.return_value = response
with pytest.raises(RuntimeError):
keycloak_service.batch_realm_role_membership(["alice@example.local"], "role-id")
def test_has_realm_role(keycloak_admin_mock):
"""has_realm_role returns True when the role id is in the user's role list."""
keycloak_admin_mock.get_users.return_value = [{"id": "kc-user-id"}]
keycloak_admin_mock.get_realm_roles_of_user.return_value = [
{"id": "other-role"},
{"id": "role-id"},
]
assert keycloak_service.has_realm_role("user@example.local", "role-id") is True
keycloak_admin_mock.get_realm_roles_of_user.return_value = [{"id": "other-role"}]
assert keycloak_service.has_realm_role("user@example.local", "role-id") is False
def test_get_keycloak_user_id_uses_exact_match(keycloak_admin_mock):
"""The lookup must pass ``exact=True`` so substring matches don't slip in."""
keycloak_admin_mock.get_users.return_value = [{"id": "kc-user-id"}]
keycloak_service._get_keycloak_user_id("user@example.local") # pylint: disable=protected-access
keycloak_admin_mock.get_users.assert_called_once_with(
{"username": "user@example.local", "exact": True}
)
def test_get_keycloak_user_id_raises_on_ambiguous(keycloak_admin_mock):
"""Two matches means upstream search returned junk — raise, don't guess."""
keycloak_admin_mock.get_users.return_value = [
{"id": "id-a"},
{"id": "id-b"},
]
with pytest.raises(ValueError, match="Ambiguous"):
keycloak_service._get_keycloak_user_id("user@example.local") # pylint: disable=protected-access
def test_reset_totp_deletes_otp_credentials_and_adds_required_action(
keycloak_admin_mock,
):
"""OTP credentials are removed and CONFIGURE_TOTP becomes a required action."""
keycloak_admin_mock.get_users.return_value = [{"id": "kc-user-id"}]
keycloak_admin_mock.get_credentials.return_value = [
{"id": "cred-1", "type": "password"},
{"id": "cred-2", "type": "otp"},
{"id": "cred-3", "type": "OTP"},
]
keycloak_admin_mock.get_user.return_value = {"requiredActions": []}
result = keycloak_service.reset_keycloak_user_totp("user@example.local")
assert result == {"removed_credentials": 2}
delete_calls = keycloak_admin_mock.delete_credential.call_args_list
deleted_ids = sorted(c.kwargs["credential_id"] for c in delete_calls)
assert deleted_ids == ["cred-2", "cred-3"]
keycloak_admin_mock.update_user.assert_called_once_with(
user_id="kc-user-id",
payload={"requiredActions": ["CONFIGURE_TOTP"]},
)
def test_reset_totp_skips_required_action_update_when_already_present(
keycloak_admin_mock,
):
"""If CONFIGURE_TOTP is already required, update_user is not called again."""
keycloak_admin_mock.get_users.return_value = [{"id": "kc-user-id"}]
keycloak_admin_mock.get_credentials.return_value = []
keycloak_admin_mock.get_user.return_value = {"requiredActions": ["CONFIGURE_TOTP"]}
keycloak_service.reset_keycloak_user_totp("user@example.local")
keycloak_admin_mock.update_user.assert_not_called()
+19 -19
View File
@@ -562,7 +562,7 @@ class TestCoalescerRedisBackend:
spop_side_effect.calls = 0
with (
patch("core.services.search.coalescer._redis_client") as mock_client,
patch("core.services.search.coalescer.get_redis_client") as mock_client,
patch(
"core.services.search.tasks.bulk_reindex_threads_task.delay"
) as mock_bulk,
@@ -596,7 +596,7 @@ class TestCoalescerRedisBackend:
spop_side_effect.calls = 0
with (
patch("core.services.search.coalescer._redis_client") as mock_client,
patch("core.services.search.coalescer.get_redis_client") as mock_client,
patch(
"core.services.search.tasks.bulk_delete_threads_task.delay"
) as mock_delete,
@@ -649,7 +649,7 @@ class TestCoalescerRedisBackend:
return []
with (
patch("core.services.search.coalescer._redis_client") as mock_client,
patch("core.services.search.coalescer.get_redis_client") as mock_client,
patch(
"core.services.search.tasks.bulk_reindex_threads_task.delay"
) as mock_reindex,
@@ -671,7 +671,7 @@ class TestCoalescerRedisBackend:
from core.services.search.coalescer import process_pending_reindex
with (
patch("core.services.search.coalescer._redis_client") as mock_client,
patch("core.services.search.coalescer.get_redis_client") as mock_client,
patch(
"core.services.search.tasks.bulk_reindex_threads_task.delay"
) as mock_reindex,
@@ -692,7 +692,7 @@ class TestCoalescerRedisBackend:
# pylint: disable-next=import-outside-toplevel
from core.services.search.coalescer import enqueue_thread_reindex
with patch("core.services.search.coalescer._redis_client") as mock_client:
with patch("core.services.search.coalescer.get_redis_client") as mock_client:
enqueue_thread_reindex(None)
mock_client.assert_not_called()
@@ -701,7 +701,7 @@ class TestCoalescerRedisBackend:
# pylint: disable-next=import-outside-toplevel
from core.services.search.coalescer import enqueue_thread_delete
with patch("core.services.search.coalescer._redis_client") as mock_client:
with patch("core.services.search.coalescer.get_redis_client") as mock_client:
enqueue_thread_delete(None)
mock_client.assert_not_called()
@@ -713,7 +713,7 @@ class TestCoalescerRedisBackend:
enqueue_message_delete,
)
with patch("core.services.search.coalescer._redis_client") as mock_client:
with patch("core.services.search.coalescer.get_redis_client") as mock_client:
enqueue_message_delete("thread-a", "msg-1")
mock_client.return_value.sadd.assert_called_once_with(
@@ -725,7 +725,7 @@ class TestCoalescerRedisBackend:
# pylint: disable-next=import-outside-toplevel
from core.services.search.coalescer import enqueue_message_delete
with patch("core.services.search.coalescer._redis_client") as mock_client:
with patch("core.services.search.coalescer.get_redis_client") as mock_client:
enqueue_message_delete(None, "msg-1")
enqueue_message_delete("thread-a", None)
enqueue_message_delete(None, None)
@@ -751,7 +751,7 @@ class TestCoalescerRedisBackend:
spop_side_effect.calls = 0
with (
patch("core.services.search.coalescer._redis_client") as mock_client,
patch("core.services.search.coalescer.get_redis_client") as mock_client,
patch(
"core.services.search.tasks.bulk_delete_messages_task.delay"
) as mock_delete_messages,
@@ -787,7 +787,7 @@ class TestCoalescerRedisBackend:
return []
with (
patch("core.services.search.coalescer._redis_client") as mock_client,
patch("core.services.search.coalescer.get_redis_client") as mock_client,
patch(
"core.services.search.tasks.bulk_delete_messages_task.delay",
side_effect=Exception("broker down"),
@@ -817,7 +817,7 @@ class TestCoalescerRedisBackend:
)
with patch(
"core.services.search.coalescer._redis_client",
"core.services.search.coalescer.get_redis_client",
side_effect=Exception("redis down"),
):
# Must not raise.
@@ -840,7 +840,7 @@ class TestCoalescerRedisBackend:
enqueue_thread_reindex,
)
with patch("core.services.search.coalescer._redis_client") as mock_client:
with patch("core.services.search.coalescer.get_redis_client") as mock_client:
mock_client.return_value.sadd.side_effect = RedisConnectionError(
"connection refused"
)
@@ -865,7 +865,7 @@ class TestCoalescerRedisBackend:
)
with (
patch("core.services.search.coalescer._redis_client") as mock_client,
patch("core.services.search.coalescer.get_redis_client") as mock_client,
patch(
"core.services.search.tasks.bulk_delete_threads_task.delay"
) as mock_delete,
@@ -907,7 +907,7 @@ class TestCoalescerRedisBackend:
return []
with (
patch("core.services.search.coalescer._redis_client") as mock_client,
patch("core.services.search.coalescer.get_redis_client") as mock_client,
patch(
"core.services.search.tasks.bulk_reindex_threads_task.delay",
side_effect=Exception("broker down"),
@@ -955,7 +955,7 @@ class TestCoalescerRedisBackend:
return []
with (
patch("core.services.search.coalescer._redis_client") as mock_client,
patch("core.services.search.coalescer.get_redis_client") as mock_client,
patch(
"core.services.search.tasks.bulk_reindex_threads_task.delay",
side_effect=Exception("broker down"),
@@ -988,7 +988,7 @@ class TestCoalescerRedisBackend:
return []
with (
patch("core.services.search.coalescer._redis_client") as mock_client,
patch("core.services.search.coalescer.get_redis_client") as mock_client,
patch(
"core.services.search.tasks.bulk_delete_threads_task.delay",
side_effect=Exception("broker down"),
@@ -1021,7 +1021,7 @@ class TestCoalescerRedisBackend:
return []
with (
patch("core.services.search.coalescer._redis_client") as mock_client,
patch("core.services.search.coalescer.get_redis_client") as mock_client,
patch(
"core.services.search.tasks.bulk_reindex_threads_task.delay",
side_effect=Exception("broker down"),
@@ -1488,14 +1488,14 @@ class TestCoalescerNonRedisGuard:
],
)
def test_enqueue_warns_and_skips_redis_call(self, settings, backend):
"""``_enqueue`` logs a warning and never reaches ``_redis_client``."""
"""``_enqueue`` logs a warning and never reaches ``get_redis_client``."""
settings.CACHES = {"default": {"BACKEND": backend}}
# pylint: disable-next=import-outside-toplevel
from core.services.search.coalescer import enqueue_thread_reindex
with (
patch("core.services.search.coalescer.logger") as mock_logger,
patch("core.services.search.coalescer._redis_client") as mock_client,
patch("core.services.search.coalescer.get_redis_client") as mock_client,
):
enqueue_thread_reindex("thread-a")
+17
View File
@@ -18,6 +18,23 @@ logger = logging.getLogger(__name__)
SNIPPET_MAX_LENGTH = 140
def get_redis_client():
"""Return the django-redis client bound to the ``default`` cache.
The single accessor for ``get_redis_connection("default")`` across the
project — every module that wants raw Redis primitives (SADD/SPOP/…)
routes through here so we have one place to swap the backend or wrap
instrumentation. Raises if django_redis isn't configured for the
default cache (e.g. ``NotImplementedError`` on a LocMem backend);
callers are expected to gate with ``settings.CACHES`` checks or to
catch broadly in their own error path.
"""
# pylint: disable-next=import-outside-toplevel
from django_redis import get_redis_connection
return get_redis_connection("default")
def extract_snippet(parsed_data: dict[str, Any], fallback: str = "") -> str:
"""Extract a text snippet from parsed email/message data.
+14
View File
@@ -1030,6 +1030,20 @@ class Base(Configuration):
default=True, environ_name="FEATURE_THREAD_SPLIT", environ_prefix=None
)
# Mandatory TOTP admin column. When True, the mailbox admin DataGrid shows
# a "Mandatory 2FA" column with a per-mailbox switch and a "Reset 2FA"
# row action. Both rely on KEYCLOAK_TOTP_ROLE_ID.
FEATURE_MAILDOMAIN_MANAGE_TOTP = values.BooleanValue(
default=False,
environ_name="FEATURE_MAILDOMAIN_MANAGE_TOTP",
environ_prefix=None,
)
# ID of the realm role assigned in Keycloak when "Mandatory 2FA" is
# toggled on for a mailbox.
KEYCLOAK_TOTP_ROLE_ID = values.Value(
default=None, environ_name="KEYCLOAK_TOTP_ROLE_ID", environ_prefix=None
)
# Logging
# We want to make it easy to log to console but by default we log production
# to Sentry and don't want to log to console.
@@ -33,6 +33,8 @@
"{{count}} days ago_other": "{{count}} days ago",
"{{count}} hours ago_one": "{{count}} hour ago",
"{{count}} hours ago_other": "{{count}} hours ago",
"{{count}} mailboxes_one": "{{count}} mailboxes",
"{{count}} mailboxes_other": "{{count}} mailboxes",
"{{count}} messages_one": "{{count}} message",
"{{count}} messages_other": "{{count}} messages",
"{{count}} messages are now starred._one": "The message is now starred.",
@@ -131,11 +133,14 @@
"{{count}} weeks ago_other": "{{count}} weeks ago",
"{{count}} years ago_one": "{{count}} year ago",
"{{count}} years ago_other": "{{count}} years ago",
"{{count}}/{{total}} mailboxes_one": "{{count}}/{{total}} mailboxes",
"{{count}}/{{total}} mailboxes_other": "{{count}}/{{total}} mailboxes",
"{{date}} at {{time}}": "{{date}} at {{time}}",
"{{name}} assigned to this thread": "{{name}} assigned to this thread",
"{{name}} unassigned from this thread": "{{name}} unassigned from this thread",
"{{progress}}% imported": "{{progress}}% imported",
"2 columns": "2 columns",
"2FA has been reset for {{mailbox}}.": "2FA has been reset for {{mailbox}}.",
"Abort upload": "Abort upload",
"Accepted": "Accepted",
"Accesses": "Accesses",
@@ -341,6 +346,7 @@
"Every {{count}} weeks_other": "Every {{count}} weeks",
"Every {{count}} years_one": "Every {{count}} years",
"Every {{count}} years_other": "Every {{count}} years",
"Existing 2FA credentials will be removed. The user will be asked to re-enroll on next login.": "Existing 2FA credentials will be removed. The user will be asked to re-enroll on next login.",
"Expand": "Expand",
"Expand {{name}}": "Expand {{name}}",
"Expand all": "Expand all",
@@ -423,6 +429,7 @@
"Label \"{{label}}\" removed from this conversation.": "Label \"{{label}}\" removed from this conversation.",
"Label name": "Label name",
"Labels": "Labels",
"Last access": "Last access",
"Last name": "Last name",
"Last name is required.": "Last name is required.",
"Last saved {{relativeTime}}": "Last saved {{relativeTime}}",
@@ -448,10 +455,14 @@
"Loading…": "Loading…",
"Logout": "Logout",
"Mailbox {{mailbox}} has been deleted successfully.": "Mailbox {{mailbox}} has been deleted successfully.",
"Mailbox count": "Mailbox count",
"Mailbox is required.": "Mailbox is required.",
"Maildomains management": "Maildomains management",
"Manage {{entity}} accesses": "Manage {{entity}} accesses",
"Manage accesses": "Manage accesses",
"Mandatory 2FA": "Mandatory 2FA",
"Mandatory 2FA disabled for {{mailbox}}.": "Mandatory 2FA disabled for {{mailbox}}.",
"Mandatory 2FA enabled for {{mailbox}}.": "Mandatory 2FA enabled for {{mailbox}}.",
"Mark all as read": "Mark all as read",
"Mark all as unread": "Mark all as unread",
"Mark as read": "Mark as read",
@@ -517,6 +528,7 @@
"OK": "OK",
"Older": "Older",
"On going": "On going",
"Only available for personal mailboxes in identity-synced domains.": "Only available for personal mailboxes in identity-synced domains.",
"Open {{driveAppName}} preview": "Open {{driveAppName}} preview",
"Open filters": "Open filters",
"Open the menu": "Open the menu",
@@ -551,6 +563,8 @@
"Reply all": "Reply all",
"Report as spam": "Report as spam",
"Reset": "Reset",
"Reset 2FA": "Reset 2FA",
"Reset 2FA for {{mailbox}}": "Reset 2FA for {{mailbox}}",
"Reset password": "Reset password",
"Reset password of {{mailbox}}": "Reset password of {{mailbox}}",
"Retry": "Retry",
@@ -562,9 +576,13 @@
"Schedule": "Schedule",
"Scheduled": "Scheduled",
"Search": "Search",
"Search a domain": "Search a domain",
"Search a label": "Search a label",
"Search a mailbox": "Search a mailbox",
"Search a mailbox to share this thread with": "Search a mailbox to share this thread with",
"Search a tag": "Search a tag",
"Search by domain name…": "Search by domain name…",
"Search by name or address…": "Search by name or address…",
"Search in messages...": "Search in messages...",
"Search results": "Search results",
"Search users": "Search users",
@@ -47,6 +47,9 @@
"{{count}} hours ago_one": "il y a {{count}} heure",
"{{count}} hours ago_many": "il y a {{count}} heures",
"{{count}} hours ago_other": "il y a {{count}} heures",
"{{count}} mailboxes_one": "{{count}} boîte aux lettres",
"{{count}} mailboxes_many": "{{count}} boîtes aux lettres",
"{{count}} mailboxes_other": "{{count}} boîtes aux lettres",
"{{count}} messages_one": "{{count}} message",
"{{count}} messages_many": "{{count}} messages",
"{{count}} messages_other": "{{count}} messages",
@@ -194,11 +197,15 @@
"{{count}} years ago_one": "il y a {{count}} an",
"{{count}} years ago_many": "il y a {{count}} ans",
"{{count}} years ago_other": "il y a {{count}} ans",
"{{count}}/{{total}} mailboxes_one": "{{count}}/{{total}} boîte aux lettres",
"{{count}}/{{total}} mailboxes_many": "{{count}}/{{total}} boîtes aux lettres",
"{{count}}/{{total}} mailboxes_other": "{{count}}/{{total}} boîtes aux lettres",
"{{date}} at {{time}}": "{{date}} à {{time}}",
"{{name}} assigned to this thread": "{{name}} assigné à cette conversation",
"{{name}} unassigned from this thread": "{{name}} désassigné de cette conversation",
"{{progress}}% imported": "{{progress}}% importés",
"2 columns": "2 colonnes",
"2FA has been reset for {{mailbox}}.": "La 2FA a été réinitialisée pour {{mailbox}}.",
"Abort upload": "Annuler le téléversement",
"Accepted": "Accepté",
"Accesses": "Accès",
@@ -410,6 +417,7 @@
"Every {{count}} years_one": "Tous les ans",
"Every {{count}} years_many": "Tous les {{count}} ans",
"Every {{count}} years_other": "Tous les {{count}} ans",
"Existing 2FA credentials will be removed. The user will be asked to re-enroll on next login.": "Les identifiants 2FA existants seront supprimés. L'utilisateur devra se ré-inscrire à la prochaine connexion.",
"Expand": "Développer",
"Expand {{name}}": "Développer {{name}}",
"Expand all": "Tout développer",
@@ -495,6 +503,7 @@
"Label \"{{label}}\" removed from this conversation.": "Libellé \"{{label}}\" retiré de cette conversation.",
"Label name": "Nom du libellé",
"Labels": "Libellés",
"Last access": "Dernier accès",
"Last name": "Nom",
"Last name is required.": "Un nom est requis.",
"Last saved {{relativeTime}}": "Dernière sauvegarde {{relativeTime}}",
@@ -520,10 +529,14 @@
"Loading…": "Chargement…",
"Logout": "Déconnexion",
"Mailbox {{mailbox}} has been deleted successfully.": "La boîte aux lettres {{mailbox}} a été supprimée avec succès.",
"Mailbox count": "Nombre de BAL",
"Mailbox is required.": "Vous devez choisir une boîte d'envoi.",
"Maildomains management": "Gestion des domaines",
"Manage {{entity}} accesses": "Gérer les accès à {{entity}}",
"Manage accesses": "Gérer les accès",
"Mandatory 2FA": "2FA obligatoire",
"Mandatory 2FA disabled for {{mailbox}}.": "2FA obligatoire désactivée pour {{mailbox}}.",
"Mandatory 2FA enabled for {{mailbox}}.": "2FA obligatoire activée pour {{mailbox}}.",
"Mark all as read": "Tout marquer comme lu",
"Mark all as unread": "Tout marquer comme non lu",
"Mark as read": "Marquer comme lu",
@@ -590,6 +603,7 @@
"OK": "OK",
"Older": "Plus ancien",
"On going": "En cours",
"Only available for personal mailboxes in identity-synced domains.": "Disponible uniquement pour les boîtes personnelles dans les domaines synchronisés avec l'annuaire.",
"Open {{driveAppName}} preview": "Ouvrir l'aperçu dans {{driveAppName}}",
"Open filters": "Ouvrir les filtres",
"Open the menu": "Ouvrir le menu",
@@ -624,6 +638,8 @@
"Reply all": "Répondre à tous",
"Report as spam": "Signaler comme spam",
"Reset": "Réinitialiser",
"Reset 2FA": "Réinitialiser la 2FA",
"Reset 2FA for {{mailbox}}": "Réinitialiser la 2FA pour {{mailbox}}",
"Reset password": "Réinitialiser le mot de passe",
"Reset password of {{mailbox}}": "Réinitialiser le mot de passe de {{mailbox}}",
"Retry": "Réessayer",
@@ -635,9 +651,13 @@
"Schedule": "Planification",
"Scheduled": "Planifiée",
"Search": "Rechercher",
"Search a domain": "Rechercher un domaine",
"Search a label": "Rechercher un libellé",
"Search a mailbox": "Rechercher une adresse",
"Search a mailbox to share this thread with": "Rechercher une boîte à qui partager cette conversation",
"Search a tag": "Rechercher un libellé",
"Search by domain name…": "Rechercher par nom de domaine…",
"Search by name or address…": "Rechercher par nom ou adresse…",
"Search in messages...": "Rechercher dans vos messages...",
"Search results": "Résultats de la recherche",
"Search users": "Rechercher des utilisateurs",
+169 -4
View File
@@ -9,6 +9,8 @@
"{{count}} hours ago_other": "{{count}} uur geleden",
"{{count}} messages_one": "{{count}} bericht",
"{{count}} messages_other": "{{count}} berichten",
"{{count}} messages are now starred._one": "Het bericht is met een ster gemarkeerd.",
"{{count}} messages are now starred._other": "{{count}} berichten zijn met een ster gemarkeerd.",
"{{count}} messages have been archived._one": "Het bericht is gearchiveerd.",
"{{count}} messages have been archived._other": "{{count}} berichten zijn gearchiveerd.",
"{{count}} messages have been deleted._one": "Het bericht is verwijderd.",
@@ -19,18 +21,50 @@
"{{count}} messages have been updated._other": "{{count}} berichten zijn bijgewerkt.",
"{{count}} messages imported_one": "{{count}} bericht geïmporteerd",
"{{count}} messages imported_other": "{{count}} berichten geïmporteerd",
"{{count}} messages mentioning you_one": "{{count}} bericht waarin u wordt vermeld",
"{{count}} messages mentioning you_other": "{{count}} berichten waarin u wordt vermeld",
"{{count}} messages of this thread have been deleted._one": "{{count}} bericht van dit kanaal is verwijderd.",
"{{count}} messages of this thread have been deleted._other": "{{count}} berichten van dit kanaal is verwijderd.",
"{{count}} messages were imported before the error._one": "{{count}} bericht is geïmporteerd voor de fout optrad.",
"{{count}} messages were imported before the error._other": "{{count}} berichten zijn geïmporteerd voor de fout optrad.",
"{{count}} minutes ago_one": "{{count}} minuut geleden",
"{{count}} minutes ago_other": "{{count}} minuten geleden",
"{{count}} months ago_one": "{{count}} maand geleden",
"{{count}} months ago_other": "{{count}} maanden geleden",
"{{count}} occurrences_one": "{{count}} gebeurtenis",
"{{count}} occurrences_other": "{{count}} gebeurtenisen",
"{{count}} out of {{total}} messages are now starred._one": "{{count}} van de {{total}} berichten is met een ster gemarkeerd.",
"{{count}} out of {{total}} messages are now starred._other": "{{count}} van de {{total}} berichten zijn met een ster gemarkeerd.",
"{{count}} out of {{total}} messages have been archived._one": "{{count}} van de {{total}} berichten is gearchiveerd.",
"{{count}} out of {{total}} messages have been archived._other": "{{count}} van de {{total}} berichten zijn gearchiveerd.",
"{{count}} out of {{total}} messages have been deleted._one": "{{count}} van de {{total}} berichten is verwijderd.",
"{{count}} out of {{total}} messages have been deleted._other": "{{count}} van de {{total}} berichten zijn verwijderd.",
"{{count}} out of {{total}} messages have been reported as spam._one": "{{count}} van de {{total}} berichten is gerapporteerd als spam.",
"{{count}} out of {{total}} messages have been reported as spam._other": "{{count}} van de {{total}} berichten zijn gerapporteerd als spam.",
"{{count}} out of {{total}} threads are now starred._one": "{{count}} van de {{total}} threads is met een ster gemarkeerd.",
"{{count}} out of {{total}} threads are now starred._other": "{{count}} van de {{total}} threads zijn met een ster gemarkeerd.",
"{{count}} out of {{total}} threads have been archived._one": "{{count}} van de {{total}} threads is gearchiveerd.",
"{{count}} out of {{total}} threads have been archived._other": "{{count}} van de {{total}} threads zijn gearchiveerd.",
"{{count}} out of {{total}} threads have been deleted._one": "{{count}} van de {{total}} threads is verwijderd.",
"{{count}} out of {{total}} threads have been deleted._other": "{{count}} van de {{total}} threads zijn verwijderd.",
"{{count}} out of {{total}} threads have been reported as spam._one": "{{count}} van de {{total}} threads is gerapporteerd als spam.",
"{{count}} out of {{total}} threads have been reported as spam._other": "{{count}} van de {{total}} threads zijn gerapporteerd als spam.",
"{{count}} results_one": "{{count}} resultaat",
"{{count}} results_other": "{{count}} resultaten",
"{{count}} results mentioning you_one": "{{count}} resultaat waarin u wordt vermeld",
"{{count}} results mentioning you_other": "{{count}} resultaten waarin u wordt vermeld",
"{{count}} selected threads_one": "{{count}} geselecteerde thread",
"{{count}} selected threads_other": "{{count}} geselecteerde threads",
"{{count}} starred messages_one": "{{count}} bericht met ster",
"{{count}} starred messages_other": "{{count}} berichten met ster",
"{{count}} starred messages mentioning you_one": "{{count}} bericht met ster waarin u wordt vermeld",
"{{count}} starred messages mentioning you_other": "{{count}} berichten met ster waarin u wordt vermeld",
"{{count}} starred results_one": "{{count}} resultaat met ster",
"{{count}} starred results_other": "{{count}} resultaten met ster",
"{{count}} starred results mentioning you_one": "{{count}} resultaat met ster waarin u wordt vermeld",
"{{count}} starred results mentioning you_other": "{{count}} resultaten met ster waarin u wordt vermeld",
"{{count}} threads are now starred._one": "De thread is met een ster gemarkeerd.",
"{{count}} threads are now starred._other": "{{count}} threads zijn met een ster gemarkeerd.",
"{{count}} threads have been archived._one": "De thread is gearchiveerd.",
"{{count}} threads have been archived._other": "{{count}} berichten zijn gearchiveerd.",
"{{count}} threads have been deleted._one": "De thread is verwijderd.",
@@ -43,8 +77,22 @@
"{{count}} threads have been unarchived._other": "{{count}} threads zijn gedearchiveerd.",
"{{count}} threads have been updated._one": "De thread is bijgewerkt.",
"{{count}} threads have been updated._other": "{{count}} threads zijn bijgewerkt.",
"{{count}} threads selected_one": "{{count}} thread geselecteerd",
"{{count}} threads selected_other": "{{count}} thread geselecteerd",
"{{count}} unread messages_one": "{{count}} ongelezen bericht",
"{{count}} unread messages_other": "{{count}} ongelezen berichten",
"{{count}} unread messages mentioning you_one": "{{count}} ongelezen bericht waarin u wordt vermeld",
"{{count}} unread messages mentioning you_other": "{{count}} ongelezen berichten waarin u wordt vermeld",
"{{count}} unread results_one": "{{count}} ongelezen resultaat",
"{{count}} unread results_other": "{{count}} ongelezen resultaten",
"{{count}} unread results mentioning you_one": "{{count}} ongelezen resultaat waarin u wordt vermeld",
"{{count}} unread results mentioning you_other": "{{count}} ongelezen resultaten waarin u wordt vermeld",
"{{count}} unread starred messages_one": "{{count}} ongelezen bericht met ster",
"{{count}} unread starred messages_other": "{{count}} ongelezen berichten met ster",
"{{count}} unread starred messages mentioning you_one": "{{count}} ongelezen bericht met ster waarin u wordt vermeld",
"{{count}} unread starred messages mentioning you_other": "{{count}} ongelezen berichten met ster waarin u wordt vermeld",
"{{count}} unread starred results_one": "{{count}} ongelezen resultaat met ster",
"{{count}} unread starred results_other": "{{count}} ongelezen resultaten met ster",
"{{count}} unread starred results mentioning you_one": "{{count}} ongelezen resultaat met ster waarin u wordt vermeld",
"{{count}} unread starred results mentioning you_other": "{{count}} ongelezen resultaten met ster waarin u wordt vermeld",
"{{count}} weeks ago_one": "{{count}} week geleden",
"{{count}} weeks ago_other": "{{count}} weken geleden",
"{{count}} years ago_one": "{{count}} jaar geleden",
@@ -52,16 +100,19 @@
"{{date}} at {{time}}": "{{date}} om {{time}}",
"{{progress}}% imported": "{{progress}}% geïmporteerd",
"2 columns": "2 kolommen",
"2FA has been reset for {{mailbox}}.": "2FA is opnieuw ingesteld voor {{mailbox}}.",
"Abort upload": "Upload Afbreken",
"Accepted": "Geaccepteerd",
"Accesses": "Toegang",
"Actions": "Acties",
"Active": "Actief",
"Active filters: {{filters}}": "Actieve filters: {{filters}}",
"Add a contact form widget to your website to receive messages directly in your mailbox.": "Voeg een contactformulier widget toe aan je website om direct berichten in je mailbox te ontvangen.",
"Add a domain": "Domein toevoegen",
"Add a sub-label": "Een sublabel toevoegen",
"Add attachment from {{driveAppName}}": "Bijlage toevoegen van {{driveAppName}}",
"Add attachments": "Bijlage toevoegen",
"Add internal comment...": "Interne opmerking toevoegen...",
"Add label": "Label toevoegen",
"Add labels": "Labels toevoegen",
"Add tags": "Labels toevoegen",
@@ -70,6 +121,8 @@
"Addresses": "Adressen",
"After creating the widget, you will receive the installation code to add to your website.": "Na het maken van de widget ontvangt u de installatiecode om uw website toe te voegen.",
"All messages": "Alle berichten",
"All users with access to the mailbox \"{{mailboxName}}\" will no longer see this thread.": "Alle gebruikers met toegang tot de mailbox \"{{mailboxName}}\" zullen deze thread niet meer zien.",
"Always": "Altijd",
"An address with this prefix already exists in this domain.": "Een adres met dit voorvoegsel bestaat al in dit domein.",
"An archive is uploading": "Een archief wordt geüpload",
"An error occurred while creating the address.": "Fout opgetreden tijdens het aanmaken van uw adres.",
@@ -87,13 +140,17 @@
"Archive": "Archief",
"Archives": "Archieven",
"Are you sure you want to close this dialog? Your upload will be aborted!": "Weet u zeker dat u deze dialoog wilt sluiten? Uw upload wordt afgebroken!",
"Are you sure you want to delete this auto-reply? This action is irreversible!": "Weet u zeker dat u dit automatische antwoord wilt verwijderen? Deze actie kan niet ongedaan worden gemaakt!",
"Are you sure you want to delete this draft? This action cannot be undone.": "Weet u zeker dat u dit concept wilt verwijderen? Deze actie kan niet ongedaan worden gemaakt.",
"Are you sure you want to delete this integration? This action is irreversible!": "Weet je zeker dat je deze integratie wilt verwijderen? Deze actie is onomkeerbaar!",
"Are you sure you want to delete this internal comment? It will be deleted for all users. This action cannot be undone.": "Weet u zeker dat u deze interne opmerking wilt verwijderen? Hij wordt verwijderd voor alle gebruikers. Deze actie kan niet ongedaan worden gemaakt.",
"Are you sure you want to delete this label? This action is irreversible!": "Weet je zeker dat je dit label wilt verwijderen? Deze actie is onomkeerbaar!",
"Are you sure you want to delete this mailbox? This action is irreversible!": "Weet u zeker dat u deze mailbox wilt verwijderen? Deze actie is onomkeerbaar!",
"Are you sure you want to delete this signature? This action is irreversible!": "Weet je zeker dat je deze handtekening wilt verwijderen? Deze actie is onomkeerbaar!",
"Are you sure you want to delete this template? This action is irreversible!": "Weet je zeker dat je deze template wilt verwijderen? Deze actie is onomkeerbaar!",
"Are you sure you want to reset the password?": "Weet je zeker dat je het wachtwoord wilt resetten?",
"Assign this label": "Dit label toewijzen",
"Assign this label and archive": "Dit label toewijzen en archiveren",
"At least one recipient is required.": "Ten minste één ontvanger is vereist.",
"Attachment failed to be saved into your {{driveAppName}}'s workspace.": "Bijlage kon niet worden opgeslagen in de {{driveAppName}}'s werkruimte.",
"Attachment saved into your {{driveAppName}}'s workspace.": "Bijlage opgeslagen in uw {{driveAppName}}'s werkruimte.",
@@ -102,6 +159,12 @@
"Attachments must be less than {{size}}.": "Bijlagen moeten minder zijn dan {{size}}.",
"Authentication failed. Please check your credentials and ensure you have enabled IMAP connections in your account.": "Verificatie is mislukt. Controleer uw inloggegevens en zorg ervoor dat IMAP-verbindingen in uw account zijn ingeschakeld.",
"Auto-labeling": "Auto-labeling",
"Auto-replies are configured per mailbox. Only one auto-reply can be active at a time.": "Automatische antwoorden worden per mailbox geconfigureerd. Er kan slechts één automatisch antwoord tegelijk actief zijn.",
"Auto-replies for {{mailbox}}": "Automatische antwoorden voor {{mailbox}}",
"Auto-reply created!": "Automatisch antwoord aangemaakt!",
"Auto-reply deleted!": "Automatisch antwoord verwijderd!",
"Auto-reply is active": "Automatisch antwoord is actief",
"Auto-reply updated!": "Automatisch antwoord bijgewerkt!",
"Automatically create mailboxes according to OIDC emails": "Automatisch mailboxen aanmaken volgens OIDC e-mails",
"Awaiting response": "In afwachting van antwoord",
"Back": "Terug",
@@ -117,6 +180,7 @@
"Check DNS again": "Controleer DNS opnieuw",
"Checking DNS records...": "DNS-records controleren...",
"Choose the type of integration you want to create": "Kies het type integratie dat u wilt maken",
"Clear filters": "Filters wissen",
"Clear selected items": "Geselecteerde items wissen",
"Click to add accesses": "Klik om toegang toe te voegen",
"Close": "Sluit",
@@ -125,6 +189,7 @@
"Close the menu": "Menu sluiten",
"Close this thread": "Sluit dit kanaal",
"Collapse": "Inklappen",
"Collapse {{name}}": "{{name}} inklappen",
"Collapse all": "Alles inklappen",
"Color: ": "Kleur: ",
"Coming soon": "Binnenkort beschikbaar",
@@ -142,6 +207,7 @@
"Create": "Creëren",
"Create a Label": "Label aanmaken",
"Create a new address @{{domain}}": "Maak een nieuw adres @{{domain}}",
"Create a new auto-reply": "Nieuw automatisch antwoord aanmaken",
"Create a new integration": "Een nieuwe integratie aanmaken",
"Create a new label": "Nieuw label aanmaken",
"Create a new personal mailbox": "Maak een nieuwe persoonlijke mailbox",
@@ -159,6 +225,7 @@
"Credentials copied!": "Gegevens gekopieerd!",
"Current status": "Huidige status",
"Daily": "Dagelijks",
"Date range": "Datumbereik",
"Date:": "Datum:",
"Date: ": "Datum: ",
"Declined": "Afgewezen",
@@ -166,8 +233,10 @@
"Default signature": "Standaard handtekening",
"Delegated": "Overgedragen",
"Delete": "Verwijderen",
"Delete auto-reply \"{{autoreply}}\"": "Automatisch antwoord \"{{autoreply}}\" verwijderen",
"Delete draft": "Concept verwijderen",
"Delete integration \"{{name}}\"": "Integratie verwijderen \"{{name}}\"",
"Delete internal comment": "Interne opmerking verwijderen",
"Delete label \"{{label}}\"": "Verwijder label \"{{label}}\"",
"Delete mailbox {{mailbox}}": "Verwijder mailbox {{mailbox}}",
"Delete signature \"{{signature}}\"": "Verwijder handtekening \"{{signature}}\"",
@@ -200,14 +269,24 @@
"Duplicate": "Dupliceer",
"Edit": "Bewerken",
"Edit {{mailbox}} address": "Bewerk {{mailbox}} adres",
"Edit auto-reply \"{{autoreply}}\"": "Automatisch antwoord \"{{autoreply}}\" bewerken",
"Edit signature \"{{signature}}\"": "Wijzig handtekening \"{{signature}}\"",
"Edit template \"{{template}}\"": "Sjabloon \"{{template}} \" verwijderen",
"Edit Widget": "Widget bewerken",
"edited": "bewerkt",
"Editing message": "Bericht bewerken",
"Email address": "E-mail adres",
"EML, MBOX or PST": "EML, MBOX of PST",
"End date": "Einddatum",
"End date is required": "Einddatum is verplicht",
"End day": "Einddag",
"End day is required": "Einddag is verplicht",
"End time": "Eindtijd",
"End time is required": "Eindtijd is verplicht",
"Enter the email addresses of the recipients separated by commas": "Voer de e-mailadressen in van de geadresseerden gescheiden door komma's",
"Error while checking DNS records": "Fout bij het controleren van DNS records",
"Error while loading addresses": "Fout bij het laden van adressen",
"Error while loading auto-replies": "Fout bij het laden van automatische antwoorden",
"Error while loading integrations": "Fout tijdens het laden van integraties",
"Error while loading signatures": "Fout bij het laden van handtekeningen",
"Error while loading templates": "Fout bij het laden van sjablonen",
@@ -219,18 +298,28 @@
"Every {{count}} weeks_other": "Elke {{count}} weken",
"Every {{count}} years_one": "Elke {{count}} jaar",
"Every {{count}} years_other": "Elke {{count}} jaren",
"Existing 2FA credentials will be removed. The user will be asked to re-enroll on next login.": "Bestaande 2FA-gegevens worden verwijderd. De gebruiker moet zich bij de volgende aanmelding opnieuw registreren.",
"Expand": "Uitklappen",
"Expand {{name}}": "{{name}} uitklappen",
"Expand all": "Alles uitklappen",
"Failed to delete auto-reply.": "Verwijderen van automatisch antwoord mislukt.",
"Failed to delete integration.": "Verwijderen van integratie mislukt.",
"Failed to delete signature.": "Verwijderen van handtekening is mislukt.",
"Failed to delete template.": "Verwijderen van sjabloon mislukt.",
"Failed to load auto-reply. Please try again.": "Laden van automatisch antwoord mislukt. Probeer het opnieuw.",
"Failed to load calendar invite": "Laden agenda-uitnodiging mislukt",
"Failed to load signature. Please try again.": "Laden van handtekening mislukt. Probeer het opnieuw.",
"Failed to load template. Please try again.": "Laden sjabloon mislukt. Probeer het opnieuw.",
"Failed to refresh summary.": "Vernieuwen samenvatting mislukt.",
"Failed to reset 2FA. Please try again.": "Het opnieuw instellen van 2FA is mislukt. Probeer het opnieuw.",
"Failed to save auto-reply. Please try again.": "Opslaan van automatisch antwoord mislukt. Probeer het opnieuw.",
"Failed to save signature. Please try again.": "Handtekening opslaan mislukt. Probeer het opnieuw.",
"Failed to save template. Please try again.": "Opslaan sjabloon mislukt. Probeer het opnieuw.",
"Failed to update auto-reply.": "Bijwerken van automatisch antwoord mislukt.",
"Failed to update Mandatory 2FA. Please try again.": "Verplichte 2FA bijwerken is mislukt. Probeer het opnieuw.",
"Failed to update signature.": "Bijwerken handtekening mislukt.",
"Filter by: {{filters}}": "Filteren op: {{filters}}",
"Filter threads": "Threads filteren",
"First name": "Voornaam",
"First name is required.": "Voornaam is vereist.",
"First, we need some information about your old mailbox": "Eerst hebben we wat informatie nodig over je oude mailbox",
@@ -241,6 +330,7 @@
"Forced signature": "Geforceerde handtekening",
"Forward": "Doorsturen",
"Forwarded message": "Doorgestuurd bericht",
"Friday": "Vrijdag",
"From": "Van",
"From:": "Van:",
"From: ": "Van: ",
@@ -282,18 +372,28 @@
"Integration updated!": "Integratie bijgewerkt!",
"Integrations": "Integraties",
"just now": "zojuist",
"Label \"{{label}}\" assigned and {{count}} threads archived._one": "Label \"{{label}}\" toegewezen en {{count}} thread gearchiveerd.",
"Label \"{{label}}\" assigned and {{count}} threads archived._other": "Label \"{{label}}\" toegewezen en {{count}} threads gearchiveerd.",
"Label \"{{label}}\" assigned to {{count}} threads._one": "Label \"{{label}}\" toegewezen aan dit gesprek.",
"Label \"{{label}}\" assigned to {{count}} threads._other": "Label \"{{label}}\" toegewezen aan {{count}} threads.",
"Label \"{{label}}\" assigned, but no threads could be archived.": "Label \"{{label}}\" toegewezen, maar geen enkele thread kon worden gearchiveerd.",
"Label \"{{label}}\" assigned. {{count}} of {{total}} threads archived._one": "Label \"{{label}}\" toegewezen. {{count}} van de {{total}} threads gearchiveerd.",
"Label \"{{label}}\" assigned. {{count}} of {{total}} threads archived._other": "Label \"{{label}}\" toegewezen. {{count}} van de {{total}} threads gearchiveerd.",
"Label \"{{label}}\" removed from this conversation.": "Label \"{{label}}\" verwijderd uit dit gesprek.",
"Label name": "Label naam",
"Labels": "Labels",
"Last access": "Laatste toegang",
"Last name": "Achternaam",
"Last name is required.": "Achternaam is vereist.",
"Last saved {{relativeTime}}": "Laatst opgeslagen {{relativeTime}}",
"Last update: {{timestamp}}": "Laatst bijgewerkt: {{timestamp}}",
"Layout": "Layout",
"Leave this thread": "Deze thread verlaten",
"Leave this thread?": "Deze thread verlaten?",
"less than a minute ago": "minder dan 1 minuut geleden",
"Loading addresses...": "Adressen laden...",
"Loading auto-replies...": "Automatische antwoorden laden...",
"Loading auto-reply...": "Automatisch antwoord laden...",
"Loading calendar invite...": "Agenda-uitnodiging laden...",
"Loading integrations...": "Integraties laden...",
"Loading labels...": "Labels laden...",
@@ -311,22 +411,32 @@
"Maildomains management": "Maildomeinen beheer",
"Manage {{entity}} accesses": "{{entity}} toegang beheren",
"Manage accesses": "Beheer toegang",
"Mandatory 2FA": "Verplichte 2FA",
"Mandatory 2FA disabled for {{mailbox}}.": "Verplichte 2FA uitgeschakeld voor {{mailbox}}.",
"Mandatory 2FA enabled for {{mailbox}}.": "Verplichte 2FA ingeschakeld voor {{mailbox}}.",
"Mark all as read": "Alles markeren als gelezen",
"Mark all as unread": "Alles markeren als ongelezen",
"Mark as read": "Markeren als gelezen",
"Mark as read from here": "Markeer als gelezen vanaf hier",
"Mark as unread": "Markeer als gelezen",
"Mark as unread from here": "Markeer als ongelezen vanaf hier",
"Mentioned": "Vermeld",
"Message content": "Bericht inhoud",
"Message from {referer_domain}": "Bericht van {referer_domain}",
"Message sent successfully": "Bericht succesvol verzonden",
"Message templates for {{mailbox}}": "Berichtsjablonen voor {{mailbox}}",
"Messaging": "Berichten",
"Missing": "Ontbreekt",
"Modified": "Gewijzigd",
"Modify": "Wijzig",
"Monday": "Maandag",
"Monthly": "Maandelijks",
"More": "Meer",
"More options": "Meer opties",
"More options (none available for this mailbox)": "Meer opties (geen beschikbaar voor deze mailbox)",
"Move {{count}} threads_one": "{{count}} thread verplaatsen",
"Move {{count}} threads_other": "{{count}} threads verplaatsen",
"My auto-replies": "Mijn automatische antwoorden",
"My message templates": "Bericht sjablonen",
"My signatures": "Mijn handtekeningen",
"Name": "Naam",
@@ -334,22 +444,39 @@
"Name is required.": "Naam is verplicht.",
"Name must be a valid domain name.": "Naam moet een geldige domeinnaam zijn.",
"New address": "Nieuw adres",
"New auto-reply": "Nieuw automatisch antwoord",
"New domain": "Nieuw domein",
"New integration": "Nieuwe integratie",
"New message": "Nieuw bericht",
"New signature": "Nieuwe handtekening",
"New template": "Nieuw sjabloon",
"No accesses": "Geen toegangen",
"No action available for this mailbox": "Geen actie beschikbaar voor deze mailbox",
"No addresses found": "Er zijn geen adressen gevonden",
"No attachments": "Geen bijlagen",
"No auto-replies found": "Geen automatische antwoorden gevonden",
"No DNS records found": "Geen DNS-records gevonden",
"No event found in calendar invite": "Geen afspraak gevonden in agenda uitnodiging",
"No integration found": "Geen integratie gevonden",
"No mailbox": "Geen mailbox",
"No message could be archived.": "Geen enkel bericht kon worden gearchiveerd.",
"No message could be deleted.": "Geen enkel bericht kon worden verwijderd.",
"No message could be reported as spam.": "Geen enkel bericht kon worden gerapporteerd als spam.",
"No message could be starred.": "Geen enkel bericht kon worden gemarkeerd met een ster.",
"No results": "Geen resultaten",
"No signature": "Geen handtekening",
"No signatures found": "Geen handtekeningen gevonden",
"No subject": "Geen onderwerp",
"No summary available.": "Geen samenvatting beschikbaar.",
"No template found": "Geen sjabloon gevonden",
"No thread could be archived.": "Geen enkele thread kon worden gearchiveerd.",
"No thread could be deleted.": "Geen enkele thread kon worden verwijderd.",
"No thread could be reported as spam.": "Geen enkele thread kon worden gerapporteerd als spam.",
"No thread could be starred.": "Geen enkele thread kon worden gemarkeerd met een ster.",
"No threads": "Geen threads",
"No threads match the active filters": "Geen threads voldoen aan de actieve filters",
"On going": "Lopend",
"Only available for personal mailboxes in identity-synced domains.": "Alleen beschikbaar voor persoonlijke mailboxen in identiteit-gesynchroniseerde domeinen.",
"Open {{driveAppName}} preview": "Open {{driveAppName}} voorbeeld",
"Open filters": "Open filters",
"Open the menu": "Menu openen",
@@ -369,10 +496,12 @@
"Read": "Lees",
"Read state": "Lees status",
"Recurring": "Terugkerend",
"Recurring weekly": "Wekelijks terugkerend",
"Redirection": "Omleiding",
"Refresh": "Vernieuw",
"Refresh summary": "Samenvatting vernieuwen",
"Remove": "Verwijderen",
"Remove access?": "Toegang verwijderen?",
"Remove report": "Rapport verwijderen",
"Remove spam report": "Spam rapport verwijderen",
"Remove tag": "Verwijder tag",
@@ -380,16 +509,25 @@
"Reply all": "Allen beantwoorden",
"Report as spam": "Als spam melden",
"Reset": "Reset",
"Reset 2FA": "2FA opnieuw instellen",
"Reset 2FA for {{mailbox}}": "2FA opnieuw instellen voor {{mailbox}}",
"Reset password": "Reset wachtwoord",
"Reset password of {{mailbox}}": "Reset wachtwoord van {{mailbox}}",
"Retry": "Opnieuw proberen",
"Saturday": "Zaterdag",
"Save": "Opslaan",
"Save changes": "Wijzigingen opslaan",
"Save into your {{driveAppName}}'s workspace": "Sla op in uw {{driveAppName}}'s workspace",
"Saving...": "Opslaan...",
"Schedule": "Schema",
"Scheduled": "Gepland",
"Search": "Zoek",
"Search a domain": "Zoek een domein",
"Search a label": "Label zoeken",
"Search a mailbox": "Zoek een mailbox",
"Search a tag": "Label zoeken",
"Search by domain name…": "Zoeken op domeinnaam…",
"Search by name or address…": "Zoeken op naam of adres…",
"Search in messages...": "Zoeken in berichten...",
"See members of this thread ({{count}} members)_one": "Leden van deze thread bekijken ({{count}} leden)",
"See members of this thread ({{count}} members)_other": "Leden van deze thread bekijken ({{count}} leden)",
@@ -431,6 +569,18 @@
"Spam": "Spam",
"Spam report removed from {{count}} threads._one": "Spam rapport verwijderd uit de thread.",
"Spam report removed from {{count}} threads._other": "Spam rapport verwijderd uit {{count}} threads.",
"Split thread": "Thread splitsen",
"Split thread from here": "Thread vanaf hier splitsen",
"Star": "Markeren met ster",
"Star this thread": "Deze thread markeren met een ster",
"Starred": "Met ster",
"Start date": "Startdatum",
"Start date is required": "Startdatum is verplicht",
"Start date must be before end date": "Startdatum moet voor einddatum liggen",
"Start day": "Startdag",
"Start day is required": "Startdag is verplicht",
"Start time": "Starttijd",
"Start time is required": "Starttijd is verplicht",
"Start typing...": "Begin met typen...",
"Subject": "Onderwerp",
"Subject template": "Onderwerp sjabloon",
@@ -440,6 +590,7 @@
"Summarize": "Vat samen",
"Summary": "Samenvatting",
"Summary refreshed!": "Samenvatting vernieuwd!",
"Sunday": "Zondag",
"Synchronize mailboxes with an identity provider": "Synchroniseer mailboxen met een identiteitsprovider",
"Tags": "Tags",
"Target": "Target",
@@ -462,15 +613,15 @@
"The redirect mailbox <strong>{{mailboxAddress}}</strong> has been created successfully.": "De redirect mailbox <1>{{mailboxAddress}}</1> is succesvol gemaakt.",
"The shared mailbox <strong>{{mailboxAddress}}</strong> has been created successfully.": "De gedeelde mailbox <1>{{mailboxAddress}}</1> is succesvol gemaakt.",
"The upload failed. Please try again.": "Upload mislukt, probeer het opnieuw.",
"These DNS records must be configured on the domain <strong>{{domain}}</strong> for the mail system to work properly. If you don't know how to update them, please contact your technical service provider or system administrator.": "Deze DNS-records moeten op het domein <strong>{{domain}}</strong> worden geconfigureerd om het mailsysteem naar behoren te laten werken. Als u niet weet hoe u deze kunt updaten, neem dan contact op met uw technische serviceprovider of systeembeheerder.",
"These DNS records must be configured on the domain <strong>{{domain}}</strong> for the mail system to work properly. Changes may take up to 24 hours to propagate. If you don't know how to update them, please contact your technical service provider or system administrator.": "Deze DNS-records moeten op het domein <strong>{{domain}}</strong> worden geconfigureerd om het mailsysteem naar behoren te laten werken. Het kan tot 24 uur duren voordat wijzigingen zijn doorgevoerd. Als u niet weet hoe u deze kunt updaten, neem dan contact op met uw technische serviceprovider of systeembeheerder.",
"These tags will be automatically applied to every incoming message from the widget.": "Deze tags worden automatisch toegepast op elk inkomend bericht vanuit de widget.",
"This action cannot be undone and the user will need the new password to access its mailbox.": "Deze actie kan niet ongedaan worden gemaakt en de gebruiker heeft het nieuwe wachtwoord nodig om toegang te krijgen tot de mailbox.",
"This contact's identity could not be verified. Proceed with caution.": "De identiteit van deze contactpersoon kon niet worden geverifieerd. Wees voorzichtig.",
"This message failed sender authentication and is likely a forgery. Do not trust it.": "Dit bericht heeft de afzenderauthenticatie niet doorstaan en is waarschijnlijk vervalst. Vertrouw het niet.",
"This description will be used by the AI to automatically assign this label to your messages.": "Deze beschrijving wordt gebruikt door de AI om dit label automatisch aan je berichten toe te wijzen.",
"This email prefix is not allowed for personal mailboxes. Please choose a different prefix.": "Dit emailvoorvoegsel is niet toegestaan voor persoonlijke mailboxen. Kies een andere voorvoegsel.",
"This event has been cancelled": "Deze afspraak is geannuleerd",
"This is the only admin of this mailbox, you cannot therefore modify its access.": "Dit is de enige admin van deze mailbox, u kunt daarom de toegang niet wijzigen.",
"This message failed sender authentication and is likely a forgery. Do not trust it.": "Dit bericht heeft de afzenderauthenticatie niet doorstaan en is waarschijnlijk vervalst. Vertrouw het niet.",
"This message has {{count}} attachments_one": "Deze e-mail heeft een bijlage",
"This message has {{count}} attachments_other": "Dit bericht heeft {{count}} bijlagen",
"This message has a draft": "Dit bericht heeft een concept",
@@ -483,9 +634,13 @@
"This signature is forced": "Deze handtekening is geforceerd",
"This thread has been reported as spam.": "Deze discussie is gerapporteerd als spam.",
"This thread has been reported as spam. For your security, downloading attachments has been disabled.": "Deze discussie is gemeld als spam. Voor je veiligheid is het downloaden van bijlagen uitgeschakeld.",
"This will move this message and all following messages to a new thread. Continue?": "Hiermee worden dit bericht en alle volgende berichten naar een nieuwe thread verplaatst. Doorgaan?",
"Those message templates are linked to the mailbox \"{{mailbox}}\". In case of a shared mailbox, all other mailbox users will be able to use them.": "Deze berichtsjablonen zijn gekoppeld aan de mailbox \"{{mailbox}}\". In het geval van een gedeeld postvak, kunnen alle andere mailbox gebruikers deze gebruiken.",
"Those signatures are linked to the mailbox \"{{mailbox}}\". In case of a shared mailbox, all other mailbox users will be able to use them.": "Die handtekeningen zijn gekoppeld aan de mailbox \"{{mailbox}}\". In het geval van een gedeeld postvak, kunnen alle andere mailbox gebruikers deze gebruiken.",
"Thread access removed": "Kanaal toegang verwijderd",
"Thread has been split successfully.": "Thread is succesvol gesplitst.",
"Thursday": "Donderdag",
"Timezone": "Tijdzone",
"To": "Aan",
"To be able to import emails from an IMAP server, you may need to allow IMAP access on your account.": "Om e-mails van een IMAP-server te kunnen importeren, moet je mogelijk IMAP-toegang op je account toestaan.",
"To:": "Aan:",
@@ -493,6 +648,7 @@
"Today": "Vandaag",
"Trash": "Prullenbak",
"Try again": "Opnieuw proberen",
"Tuesday": "Dinsdag",
"Tutorials and training": "Tutorials en training",
"Type": "Type",
"Unable to copy credentials.": "Kan de inloggegevens niet kopiëren.",
@@ -504,7 +660,10 @@
"Unknown": "Onbekend",
"Unknown user": "Onbekende gebruiker",
"Unread": "Ongelezen",
"Unread mention": "Ongelezen vermelding",
"Unsaved changes": "Niet-opgeslagen wijzigingen",
"Unstar": "Ster verwijderen",
"Unstar this thread": "Ster van deze thread verwijderen",
"until {{date}}": "tot {{date}}",
"Update": "Bijwerken",
"Update a Label": "Update een Label",
@@ -520,21 +679,27 @@
"View full documentation": "Bekijk de volledige documentatie",
"Visit the Help center": "Bezoek het helpcentrum",
"Website Widget": "Websitewidget",
"Wednesday": "Woensdag",
"Weekly": "Wekelijks",
"While the auto-reply is disabled, it will not be sent.": "Zolang het automatische antwoord is uitgeschakeld, wordt het niet verzonden.",
"While the signature is disabled, it will not be available to the users.": "Terwijl handtekening is uitgeschakeld, is niet beschikbaar voor de gebruikers.",
"Widget": "Widget",
"Yearly": "Jaarlijks",
"Yesterday": "Gisteren",
"You": "Jij",
"You and all users with access to the mailbox \"{{mailboxName}}\" will no longer see this thread.": "U en alle gebruikers met toegang tot de mailbox \"{{mailboxName}}\" zullen deze thread niet meer zien.",
"You are the last editor of this thread, you cannot therefore modify your access.": "U bent de laatste redacteur van dit kanaal, daarom kunt u uw toegang niet aanpassen.",
"You can close this window and continue using the app.": "Je kunt dit venster sluiten en de app blijven gebruiken.",
"You can now inform the person that their mailbox is ready to be used and communicate the instructions for authentication.": "U kunt de persoon nu informeren dat hun mailbox klaar is om te worden gebruikt en de instructies voor authenticatie communiceren.",
"You can safely retry the import — messages already imported will not be duplicated.": "U kunt de import veilig opnieuw proberen — al geïmporteerde berichten worden niet gedupliceerd.",
"You cannot delete the last editor of this thread": "U kunt de laatste bewerker van dit kanaal niet verwijderen",
"You cannot modify it.": "Je kunt het niet wijzigen.",
"You have {{count}} recipients, which exceeds the maximum of {{max}} recipients per message. The message cannot be sent until you reduce the number of recipients._one": "U heeft {{count}} ontvanger, wat hoger is dan het maximum van {{max}} ontvangers per bericht. Het bericht kan niet worden verzonden totdat u het aantal ontvangers vermindert.",
"You have {{count}} recipients, which exceeds the maximum of {{max}} recipients per message. The message cannot be sent until you reduce the number of recipients._other": "U heeft {{count}} ontvangers, die het maximum van {{max}} ontvangers per bericht overschrijden. Het bericht kan niet worden verzonden totdat u het aantal ontvangers vermindert.",
"You have aborted the upload.": "Je hebt het uploaden afgebroken.",
"You have unsaved changes. Are you sure you want to close?": "Je hebt niet-opgeslagen wijzigingen. Weet je zeker dat je wil annuleren?",
"You left the thread": "U hebt de thread verlaten",
"You may not have sufficient permissions for all selected threads.": "U hebt mogelijk niet voldoende rechten voor alle geselecteerde threads.",
"You must confirm this statement.": "U moet deze verklaring bevestigen.",
"Your email...": "Jouw email...",
"Your messages have been imported successfully!": "Je berichten zijn succesvol geïmporteerd!",
@@ -29,6 +29,9 @@ import type {
MailboxAdmin,
MailboxAdminCreate,
MailboxAdminCreatePayloadRequest,
MailboxAdminMandatoryTotpPayloadRequest,
MailboxAdminMandatoryTotpResponse,
MailboxAdminResetTotpResponse,
MaildomainsListParams,
MaildomainsMailboxesListParams,
MaildomainsMessageTemplatesListParams,
@@ -52,9 +55,7 @@ import type { ErrorType } from "../../fetch-api";
type SecondParameter<T extends (...args: never) => unknown> = Parameters<T>[1];
/**
* ViewSet for listing MailDomains the user administers.
Provides a top-level entry for mail domain administration.
Endpoint: /maildomains/<maildomain_pk>/
* List mail domains, optionally filtered by name with the `q` parameter.
*/
export type maildomainsListResponse200 = {
data: PaginatedMailDomainAdminList;
@@ -625,13 +626,7 @@ export const useMaildomainsCheckDnsCreate = <
return useMutation(mutationOptions, queryClient);
};
/**
* ViewSet for managing Mailboxes within a specific MailDomain.
Nested under /maildomains/{maildomain_pk}/mailboxes/
Permissions are checked by IsMailDomainAdmin for the maildomain_pk.
This viewset serves a different purpose than the one in mailbox.py (/api/v1.0/mailboxes/).
That other one is for listing the mailboxes a user has access to in regular app use.
This one is for managing mailboxes within a specific maildomain in the admin interface.
* List mailboxes, optionally filtered by local part / contact name.
*/
export type maildomainsMailboxesListResponse200 = {
data: PaginatedMailboxAdminList;
@@ -1408,6 +1403,141 @@ export const useMaildomainsMailboxesDestroy = <
return useMutation(mutationOptions, queryClient);
};
/**
* Toggle the Keycloak realm role indicated by KEYCLOAK_TOTP_ROLE_ID on the user backing this mailbox.
*/
export type maildomainsMailboxesSetMandatoryTotpResponse200 = {
data: MailboxAdminMandatoryTotpResponse;
status: 200;
};
export type maildomainsMailboxesSetMandatoryTotpResponseSuccess =
maildomainsMailboxesSetMandatoryTotpResponse200 & {
headers: Headers;
};
export type maildomainsMailboxesSetMandatoryTotpResponse =
maildomainsMailboxesSetMandatoryTotpResponseSuccess;
export const getMaildomainsMailboxesSetMandatoryTotpUrl = (
maildomainPk: string,
id: string,
) => {
return `/api/v1.0/maildomains/${maildomainPk}/mailboxes/${id}/mandatory-totp/`;
};
export const maildomainsMailboxesSetMandatoryTotp = async (
maildomainPk: string,
id: string,
mailboxAdminMandatoryTotpPayloadRequest: MailboxAdminMandatoryTotpPayloadRequest,
options?: RequestInit,
): Promise<maildomainsMailboxesSetMandatoryTotpResponse> => {
return fetchAPI<maildomainsMailboxesSetMandatoryTotpResponse>(
getMaildomainsMailboxesSetMandatoryTotpUrl(maildomainPk, id),
{
...options,
method: "POST",
headers: { "Content-Type": "application/json", ...options?.headers },
body: JSON.stringify(mailboxAdminMandatoryTotpPayloadRequest),
},
);
};
export const getMaildomainsMailboxesSetMandatoryTotpMutationOptions = <
TError = ErrorType<unknown>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof maildomainsMailboxesSetMandatoryTotp>>,
TError,
{
maildomainPk: string;
id: string;
data: MailboxAdminMandatoryTotpPayloadRequest;
},
TContext
>;
request?: SecondParameter<typeof fetchAPI>;
}): UseMutationOptions<
Awaited<ReturnType<typeof maildomainsMailboxesSetMandatoryTotp>>,
TError,
{
maildomainPk: string;
id: string;
data: MailboxAdminMandatoryTotpPayloadRequest;
},
TContext
> => {
const mutationKey = ["maildomainsMailboxesSetMandatoryTotp"];
const { mutation: mutationOptions, request: requestOptions } = options
? options.mutation &&
"mutationKey" in options.mutation &&
options.mutation.mutationKey
? options
: { ...options, mutation: { ...options.mutation, mutationKey } }
: { mutation: { mutationKey }, request: undefined };
const mutationFn: MutationFunction<
Awaited<ReturnType<typeof maildomainsMailboxesSetMandatoryTotp>>,
{
maildomainPk: string;
id: string;
data: MailboxAdminMandatoryTotpPayloadRequest;
}
> = (props) => {
const { maildomainPk, id, data } = props ?? {};
return maildomainsMailboxesSetMandatoryTotp(
maildomainPk,
id,
data,
requestOptions,
);
};
return { mutationFn, ...mutationOptions };
};
export type MaildomainsMailboxesSetMandatoryTotpMutationResult = NonNullable<
Awaited<ReturnType<typeof maildomainsMailboxesSetMandatoryTotp>>
>;
export type MaildomainsMailboxesSetMandatoryTotpMutationBody =
MailboxAdminMandatoryTotpPayloadRequest;
export type MaildomainsMailboxesSetMandatoryTotpMutationError =
ErrorType<unknown>;
export const useMaildomainsMailboxesSetMandatoryTotp = <
TError = ErrorType<unknown>,
TContext = unknown,
>(
options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof maildomainsMailboxesSetMandatoryTotp>>,
TError,
{
maildomainPk: string;
id: string;
data: MailboxAdminMandatoryTotpPayloadRequest;
},
TContext
>;
request?: SecondParameter<typeof fetchAPI>;
},
queryClient?: QueryClient,
): UseMutationResult<
Awaited<ReturnType<typeof maildomainsMailboxesSetMandatoryTotp>>,
TError,
{
maildomainPk: string;
id: string;
data: MailboxAdminMandatoryTotpPayloadRequest;
},
TContext
> => {
const mutationOptions =
getMaildomainsMailboxesSetMandatoryTotpMutationOptions(options);
return useMutation(mutationOptions, queryClient);
};
/**
* Reset the Keycloak password for a specific mailbox.
*/
@@ -1547,6 +1677,111 @@ export const useMaildomainsMailboxesResetPassword = <
return useMutation(mutationOptions, queryClient);
};
/**
* Remove existing OTP credentials and require the user to re-enroll in TOTP on next login.
*/
export type maildomainsMailboxesResetTotpResponse200 = {
data: MailboxAdminResetTotpResponse;
status: 200;
};
export type maildomainsMailboxesResetTotpResponseSuccess =
maildomainsMailboxesResetTotpResponse200 & {
headers: Headers;
};
export type maildomainsMailboxesResetTotpResponse =
maildomainsMailboxesResetTotpResponseSuccess;
export const getMaildomainsMailboxesResetTotpUrl = (
maildomainPk: string,
id: string,
) => {
return `/api/v1.0/maildomains/${maildomainPk}/mailboxes/${id}/reset-totp/`;
};
export const maildomainsMailboxesResetTotp = async (
maildomainPk: string,
id: string,
options?: RequestInit,
): Promise<maildomainsMailboxesResetTotpResponse> => {
return fetchAPI<maildomainsMailboxesResetTotpResponse>(
getMaildomainsMailboxesResetTotpUrl(maildomainPk, id),
{
...options,
method: "PATCH",
},
);
};
export const getMaildomainsMailboxesResetTotpMutationOptions = <
TError = ErrorType<unknown>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof maildomainsMailboxesResetTotp>>,
TError,
{ maildomainPk: string; id: string },
TContext
>;
request?: SecondParameter<typeof fetchAPI>;
}): UseMutationOptions<
Awaited<ReturnType<typeof maildomainsMailboxesResetTotp>>,
TError,
{ maildomainPk: string; id: string },
TContext
> => {
const mutationKey = ["maildomainsMailboxesResetTotp"];
const { mutation: mutationOptions, request: requestOptions } = options
? options.mutation &&
"mutationKey" in options.mutation &&
options.mutation.mutationKey
? options
: { ...options, mutation: { ...options.mutation, mutationKey } }
: { mutation: { mutationKey }, request: undefined };
const mutationFn: MutationFunction<
Awaited<ReturnType<typeof maildomainsMailboxesResetTotp>>,
{ maildomainPk: string; id: string }
> = (props) => {
const { maildomainPk, id } = props ?? {};
return maildomainsMailboxesResetTotp(maildomainPk, id, requestOptions);
};
return { mutationFn, ...mutationOptions };
};
export type MaildomainsMailboxesResetTotpMutationResult = NonNullable<
Awaited<ReturnType<typeof maildomainsMailboxesResetTotp>>
>;
export type MaildomainsMailboxesResetTotpMutationError = ErrorType<unknown>;
export const useMaildomainsMailboxesResetTotp = <
TError = ErrorType<unknown>,
TContext = unknown,
>(
options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof maildomainsMailboxesResetTotp>>,
TError,
{ maildomainPk: string; id: string },
TContext
>;
request?: SecondParameter<typeof fetchAPI>;
},
queryClient?: QueryClient,
): UseMutationResult<
Awaited<ReturnType<typeof maildomainsMailboxesResetTotp>>,
TError,
{ maildomainPk: string; id: string },
TContext
> => {
const mutationOptions =
getMaildomainsMailboxesResetTotpMutationOptions(options);
return useMutation(mutationOptions, queryClient);
};
/**
* List message templates for a maildomain.
*/
@@ -36,6 +36,7 @@ export type ConfigRetrieve200 = {
readonly FEATURE_MAILDOMAIN_CREATE: boolean;
readonly FEATURE_MAILDOMAIN_MANAGE_ACCESSES: boolean;
readonly FEATURE_THREAD_SPLIT: boolean;
readonly FEATURE_MAILDOMAIN_MANAGE_TOTP: boolean;
/** Maximum age in seconds for a message to be eligible for manual retry of failed deliveries */
readonly MESSAGES_MANUAL_RETRY_MAX_AGE: number;
/** Whether silent OIDC login is enabled */
@@ -67,6 +67,9 @@ export * from "./mailbox_admin_create";
export * from "./mailbox_admin_create_metadata_request";
export * from "./mailbox_admin_create_metadata_type_enum";
export * from "./mailbox_admin_create_payload_request";
export * from "./mailbox_admin_mandatory_totp_payload_request";
export * from "./mailbox_admin_mandatory_totp_response";
export * from "./mailbox_admin_reset_totp_response";
export * from "./mailbox_admin_update_metadata_request";
export * from "./mailbox_light";
export * from "./mailbox_role_choices";
@@ -19,6 +19,7 @@ export interface MailDomainAdmin {
/** date and time at which a record was last updated */
readonly updated_at: string;
readonly expected_dns_records: string;
readonly mailbox_count: string;
/** Sync mailboxes to an identity provider. */
readonly identity_sync: boolean;
/** Instance permissions and capabilities */
@@ -34,4 +34,14 @@ export interface MailboxAdmin {
readonly updated_at: string;
readonly can_reset_password: boolean;
readonly contact: Contact;
/**
* Most recent ``accessed_at`` across all mailbox accesses.
* @nullable
*/
readonly last_accessed_at: string | null;
/**
* Whether the Keycloak user backing this mailbox carries the KEYCLOAK_TOTP_ROLE_ID realm role. ``null`` when the feature is disabled or the role id isn't configured.
* @nullable
*/
readonly has_mandatory_totp: boolean | null;
}
@@ -31,6 +31,16 @@ export interface MailboxAdminCreate {
readonly updated_at: string;
readonly can_reset_password: boolean;
readonly contact: Contact;
/**
* Most recent ``accessed_at`` across all mailbox accesses.
* @nullable
*/
readonly last_accessed_at: string | null;
/**
* Whether the Keycloak user backing this mailbox carries the KEYCLOAK_TOTP_ROLE_ID realm role. ``null`` when the feature is disabled or the role id isn't configured.
* @nullable
*/
readonly has_mandatory_totp: boolean | null;
/**
* Fake method just to make the OpenAPI schema valid.
* @nullable
@@ -0,0 +1,11 @@
/**
* Generated by orval 🍺
* Do not edit manually.
* messages API
* This is the messages API schema.
* OpenAPI spec version: 1.0.0 (v1.0)
*/
export interface MailboxAdminMandatoryTotpPayloadRequest {
enabled: boolean;
}
@@ -0,0 +1,11 @@
/**
* Generated by orval 🍺
* Do not edit manually.
* messages API
* This is the messages API schema.
* OpenAPI spec version: 1.0.0 (v1.0)
*/
export interface MailboxAdminMandatoryTotpResponse {
enabled: boolean;
}
@@ -0,0 +1,11 @@
/**
* Generated by orval 🍺
* Do not edit manually.
* messages API
* This is the messages API schema.
* OpenAPI spec version: 1.0.0 (v1.0)
*/
export interface MailboxAdminResetTotpResponse {
removed_credentials: number;
}
@@ -11,4 +11,8 @@ export type MaildomainsListParams = {
* A page number within the paginated result set.
*/
page?: number;
/**
* Filter domains whose name contains this value (case-insensitive).
*/
q?: string;
};
@@ -11,4 +11,8 @@ export type MaildomainsMailboxesListParams = {
* A page number within the paginated result set.
*/
page?: number;
/**
* Filter mailboxes whose local part or contact name contains this value (case-insensitive).
*/
q?: string;
};
@@ -0,0 +1,53 @@
import { Icon, IconType } from "@gouvfr-lasuite/ui-kit";
import { Input } from "@gouvfr-lasuite/cunningham-react";
import { useEffect, useState } from "react";
import { useDebounceCallback } from "@/hooks/use-debounce-callback";
type AdminSearchInputProps = {
/** Visually hidden — used as the input's accessible name. */
label: string;
/** Visible placeholder shown when the input is empty. */
placeholder: string;
onChange: (value: string) => void;
initialValue?: string;
};
const DEBOUNCE_MS = 200;
/**
* Search input used at the top of admin lists. Maintains its own immediate
* input value and reports changes upward through a debounced callback.
*/
export const AdminSearchInput = ({
label,
placeholder,
onChange,
initialValue = "",
}: AdminSearchInputProps) => {
const [value, setValue] = useState<string>(initialValue);
const debounced = useDebounceCallback(onChange, DEBOUNCE_MS);
// Re-sync when the parent resets the query externally (e.g. switching
// resources). Cancel any pending debounced call so a still-buffered
// keystroke from the previous resource doesn't fire onChange with
// stale input after the reset.
useEffect(() => {
debounced.cancel();
setValue(initialValue);
}, [initialValue, debounced]);
return (
<Input
icon={<Icon name="search" type={IconType.OUTLINED} />}
type="search"
label={label}
placeholder={placeholder}
value={value}
onChange={(e) => {
setValue(e.target.value);
debounced(e.target.value);
}}
fullWidth
/>
);
};
@@ -7,7 +7,7 @@ import { AdminMailDomainProvider, useAdminMailDomain } from "@/features/provider
import useAbility, { Abilities } from "@/hooks/use-ability";
import ErrorPage from "next/error";
import { Toaster } from "@/features/ui/components/toaster";
import { Icon, IconSize, IconType } from "@gouvfr-lasuite/ui-kit";
import { Badge, Icon, IconSize, IconType } from "@gouvfr-lasuite/ui-kit";
import { useTheme } from "@/features/providers/theme";
import { LayoutProvider } from "@/features/layouts/components/layout-context";
@@ -73,7 +73,12 @@ function AdminLayoutContent({
// Build tabs if we're in a domain
const tabs = selectedMailDomain ? [
{ id: "addresses", label: t("Addresses"), href: `/domain/${selectedMailDomain.id}`, icon: "inbox" },
{
id: "addresses",
label: <div>{t("Addresses")} <Badge type="neutral">{selectedMailDomain.mailbox_count}</Badge></div>,
href: `/domain/${selectedMailDomain.id}`,
icon: "inbox"
},
{ id: "dns", label: t("DNS"), href: `/domain/${selectedMailDomain.id}/dns`, icon: "dns" },
{ id: "signatures", label: t("Signatures"), href: `/domain/${selectedMailDomain.id}/signatures`, icon: "drive_file_rename_outline" },
] : [];
@@ -1,19 +1,23 @@
import { MailboxAdmin, MailDomainAdmin, useMaildomainsMailboxesDestroy, useMaildomainsMailboxesList } from "@/features/api/gen";
import { MailboxAdmin, MailDomainAdmin, useMaildomainsMailboxesDestroy, useMaildomainsMailboxesList, useMaildomainsMailboxesResetTotp, useMaildomainsMailboxesSetMandatoryTotp } from "@/features/api/gen";
import { ModalMailboxManageAccesses } from "@/features/layouts/components/admin/modal-mailbox-manage-accesses";
import { Banner } from "@/features/ui/components/banner";
import useAbility, { Abilities } from "@/hooks/use-ability";
import { IconType, DropdownMenu, Icon, IconSize, Spinner } from "@gouvfr-lasuite/ui-kit";
import { Button, DataGrid, Tooltip, useModals, usePagination } from "@gouvfr-lasuite/cunningham-react";
import { Button, DataGrid, Switch, Tooltip, useModals, usePagination } from "@gouvfr-lasuite/cunningham-react";
import { keepPreviousData } from "@tanstack/react-query";
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import ModalMailboxResetPassword from "../modal-mailbox-reset-password";
import { addToast, ToasterItem } from "@/features/ui/components/toaster";
import { ModalCreateOrUpdateMailbox } from "../modal-create-update-mailbox";
import MailboxHelper from "@/features/utils/mailbox-helper";
import { FEATURE_KEYS, useFeatureFlag } from "@/hooks/use-feature";
import { EmptyCell } from "@/features/ui/components/empty-cell";
type AdminUserDataGridProps = {
domain: MailDomainAdmin;
pagination: ReturnType<typeof usePagination>;
searchQuery?: string;
}
enum MailboxEditAction {
@@ -22,14 +26,27 @@ enum MailboxEditAction {
MANAGE_ACCESS = 'manageAccess',
}
export const AdminMailboxDataGrid = ({ domain, pagination }: AdminUserDataGridProps) => {
const { t } = useTranslation();
const { data: mailboxesData, isLoading, error, refetch: refetchMailboxes } = useMaildomainsMailboxesList(domain.id, { page: pagination.page });
export const AdminMailboxDataGrid = ({ domain, pagination, searchQuery }: AdminUserDataGridProps) => {
const { t, i18n } = useTranslation();
const trimmedQuery = (searchQuery ?? "").trim();
const { data: mailboxesData, isLoading, error, refetch: refetchMailboxes } = useMaildomainsMailboxesList(domain.id, {
page: pagination.page,
...(trimmedQuery ? { q: trimmedQuery } : {}),
}, {
query: { placeholderData: keepPreviousData },
});
const mailboxes = mailboxesData?.data.results || [];
const [editedMailbox, setEditedMailbox] = useState<MailboxAdmin | null>(null);
const [editAction, setEditAction] = useState<MailboxEditAction | null>(null);
// Tracks which mailbox rows are mid-toggle so we only disable those switches
// (a single global `isPending` would lock every row when any toggle is in flight,
// and a scalar would lose the first id when a second toggle starts).
const [pendingTotpMailboxIds, setPendingTotpMailboxIds] = useState<Set<string>>(new Set());
const canManageMailboxes = useAbility(Abilities.CAN_MANAGE_MAILDOMAIN_MAILBOXES, domain);
const deleteMailboxMutation = useMaildomainsMailboxesDestroy();
const setMandatoryTotpMutation = useMaildomainsMailboxesSetMandatoryTotp();
const resetTotpMutation = useMaildomainsMailboxesResetTotp();
const isMandatoryTotpEnabled = useFeatureFlag(FEATURE_KEYS.MAILDOMAIN_MANAGE_TOTP);
const modals = useModals();
const handleCloseEditUserModal = (refetch: boolean = false) => {
@@ -55,6 +72,56 @@ export const AdminMailboxDataGrid = ({ domain, pagination }: AdminUserDataGridPr
setEditedMailbox(mailbox);
}
const handleToggleMandatoryTotp = (mailbox: MailboxAdmin, enabled: boolean) => {
setPendingTotpMailboxIds((prev) => new Set(prev).add(mailbox.id));
setMandatoryTotpMutation.mutate(
{ maildomainPk: domain.id, id: mailbox.id, data: { enabled } },
{
onSuccess: () => {
refetchMailboxes();
addToast(
<ToasterItem>
<Icon name="security" size={IconSize.SMALL} />
<span>
{enabled
? t('Mandatory 2FA enabled for {{mailbox}}.', { mailbox: MailboxHelper.toString(mailbox) })
: t('Mandatory 2FA disabled for {{mailbox}}.', { mailbox: MailboxHelper.toString(mailbox) })}
</span>
</ToasterItem>
);
},
onSettled: () => setPendingTotpMailboxIds((prev) => {
const next = new Set(prev);
next.delete(mailbox.id);
return next;
}),
}
);
}
const handleResetTotp = async (mailbox: MailboxAdmin) => {
const email = MailboxHelper.toString(mailbox);
const decision = await modals.confirmationModal({
title: <span className="c__modal__text--centered">{t('Reset 2FA for {{mailbox}}', { mailbox: email })}</span>,
children: t('Existing 2FA credentials will be removed. The user will be asked to re-enroll on next login.'),
});
if (decision !== 'yes') return;
resetTotpMutation.mutate(
{ maildomainPk: domain.id, id: mailbox.id },
{
onSuccess: () => {
addToast(
<ToasterItem>
<Icon name="security" size={IconSize.SMALL} />
<span>{t('2FA has been reset for {{mailbox}}.', { mailbox: email })}</span>
</ToasterItem>
);
},
}
);
}
const handleDelete = async (mailbox: MailboxAdmin) => {
const email = MailboxHelper.toString(mailbox);
const decision = await modals.deleteConfirmationModal({
@@ -109,6 +176,33 @@ export const AdminMailboxDataGrid = ({ domain, pagination }: AdminUserDataGridPr
headerName: t("Email address"),
renderCell: ({ row }: { row: MailboxAdmin }) => <strong>{MailboxHelper.toString(row)}</strong>,
},
{
id: "last_accessed_at",
headerName: t("Last access"),
size: 160,
renderCell: ({ row }: { row: MailboxAdmin }) =>
row.last_accessed_at
? new Date(row.last_accessed_at).toLocaleDateString(i18n.resolvedLanguage)
: <EmptyCell />,
},
...(isMandatoryTotpEnabled ? [{
id: "mandatory_totp",
headerName: t("Mandatory 2FA"),
size: 160,
renderCell: ({ row }: { row: MailboxAdmin }) => {
if (row.has_mandatory_totp === null || row.has_mandatory_totp === undefined) {
return <EmptyCell tooltip={t('Only available for personal mailboxes in identity-synced domains.')} />;
}
return (
<Switch
checked={Boolean(row.has_mandatory_totp)}
disabled={!canManageMailboxes || pendingTotpMailboxIds.has(row.id)}
onChange={(event) => handleToggleMandatoryTotp(row, event.target.checked)}
aria-label={t('Mandatory 2FA')}
/>
);
},
}] : []),
{
id: "accesses",
headerName: t("Accesses"),
@@ -146,6 +240,9 @@ export const AdminMailboxDataGrid = ({ domain, pagination }: AdminUserDataGridPr
renderCell: ({ row }: { row: MailboxAdmin }) => <ActionsRow
onManageAccess={() => handleManageAccess(row)}
onResetPassword={row.can_reset_password ? () => handleResetPassword(row) : undefined}
onResetTotp={isMandatoryTotpEnabled && row.has_mandatory_totp !== null && row.has_mandatory_totp !== undefined
? () => handleResetTotp(row)
: undefined}
onDelete={() => handleDelete(row)}
onUpdate={() => handleUpdate(row)}
/>,
@@ -153,10 +250,12 @@ export const AdminMailboxDataGrid = ({ domain, pagination }: AdminUserDataGridPr
];
useEffect(() => {
if (!pagination.pagesCount && mailboxesData?.data.count) {
pagination.setPagesCount(Math.ceil(mailboxesData.data.count / pagination.pageSize));
if (mailboxesData?.data.count !== undefined) {
pagination.setPagesCount(
Math.max(1, Math.ceil(mailboxesData.data.count / pagination.pageSize))
);
}
}, [mailboxesData?.data.count, pagination.pageSize]);
}, [mailboxesData?.data.count, pagination.pageSize, pagination.setPagesCount]);
useEffect(() => {
if (editedMailbox) {
@@ -225,14 +324,31 @@ export const AdminMailboxDataGrid = ({ domain, pagination }: AdminUserDataGridPr
type ActionsRowProps = {
onManageAccess: () => void;
onResetPassword?: () => void;
onResetTotp?: () => void;
onDelete: () => void;
onUpdate: () => void;
};
const ActionsRow = ({ onManageAccess, onResetPassword, onDelete, onUpdate }: ActionsRowProps) => {
const ActionsRow = ({ onManageAccess, onResetPassword, onResetTotp, onDelete, onUpdate }: ActionsRowProps) => {
const [isMoreActionsOpen, setMoreActionsOpen] = useState<boolean>(false);
const { t } = useTranslation();
// Build options in display order, then put a separator before the last item
// (Delete) so the destructive action is visually grouped on its own.
const secondaryActions = [
{ icon: <Icon name="group" size={IconSize.SMALL} />, label: t('Manage accesses'), callback: onManageAccess },
...(onResetPassword ? [{ icon: <Icon name="lock" size={IconSize.SMALL} />, label: t('Reset password'), callback: onResetPassword }] : []),
...(onResetTotp ? [{ icon: <Icon name="security" size={IconSize.SMALL} />, label: t('Reset 2FA'), callback: onResetTotp }] : []),
];
const destructive = { icon: <Icon name="delete" size={IconSize.SMALL} />, label: t('Delete'), callback: onDelete };
const options = [
...secondaryActions.map((opt, i) => ({
...opt,
showSeparator: i === secondaryActions.length - 1,
})),
destructive,
];
return (
<div className="flex-row" style={{ gap: "var(--c--globals--spacings--2xs)" }}>
<Button
@@ -246,26 +362,7 @@ const ActionsRow = ({ onManageAccess, onResetPassword, onDelete, onUpdate }: Act
<DropdownMenu
isOpen={isMoreActionsOpen}
onOpenChange={setMoreActionsOpen}
options={[
{
icon: <Icon name="group" size={IconSize.SMALL} />,
label: t('Manage accesses'),
callback: onManageAccess,
showSeparator: onResetPassword ? false : true,
},
...(onResetPassword ? [{
icon: <Icon name="lock" size={IconSize.SMALL} />,
label: t('Reset password'),
callback: onResetPassword,
showSeparator: true,
},
] : []),
{
label: t('Delete'),
icon: <Icon name="delete" size={IconSize.SMALL} />,
callback: onDelete,
}
]}
options={options}
>
<Tooltip content={t('More options')} placement="left">
<Button
@@ -4,12 +4,15 @@ import { Icon, IconType, Spinner } from "@gouvfr-lasuite/ui-kit";
import { useAdminMailDomain } from "@/features/providers/admin-maildomain";
import { AdminMailboxDataGrid } from "./mailbox-data-grid";
import { Banner } from "@/features/ui/components/banner";
import { AdminSearchInput } from "@/features/forms/components/admin-search-input";
type AdminDomainPageContentProps = {
pagination: ReturnType<typeof usePagination>;
searchQuery: string;
onSearchChange: (query: string) => void;
}
export const AdminDomainPageContent = ({ pagination }: AdminDomainPageContentProps) => {
export const AdminDomainPageContent = ({ pagination, searchQuery, onSearchChange }: AdminDomainPageContentProps) => {
const { t } = useTranslation();
const { selectedMailDomain, isLoading } = useAdminMailDomain();
@@ -29,5 +32,21 @@ export const AdminDomainPageContent = ({ pagination }: AdminDomainPageContentPro
);
}
return <AdminMailboxDataGrid domain={selectedMailDomain} pagination={pagination} />;
return (
<>
<div className="admin-page__search">
<AdminSearchInput
label={t("Search a mailbox")}
placeholder={t("Search by name or address…")}
initialValue={searchQuery}
onChange={onSearchChange}
/>
</div>
<AdminMailboxDataGrid
domain={selectedMailDomain}
pagination={pagination}
searchQuery={searchQuery}
/>
</>
);
}
@@ -3,7 +3,8 @@ import { MailDomainAdmin } from "../api/gen/models/mail_domain_admin";
import { useMaildomainsList, useMaildomainsRetrieve } from "../api/gen";
import { useRouter } from "next/router";
import { usePagination } from "@gouvfr-lasuite/cunningham-react";
import { DEFAULT_PAGE_SIZE } from "../config/constants";
import { keepPreviousData } from "@tanstack/react-query";
import { useSearchablePagination } from "@/hooks/use-searchable-pagination";
type AdminMailDomainContextType = {
selectedMailDomain: MailDomainAdmin | null;
@@ -11,6 +12,8 @@ type AdminMailDomainContextType = {
isLoading: boolean;
error: unknown | null;
pagination: ReturnType<typeof usePagination>;
searchQuery: string;
setSearchQuery: (query: string) => void;
}
const AdminMailDomainContext = createContext<AdminMailDomainContextType | undefined>(undefined)
@@ -21,21 +24,32 @@ const AdminMailDomainContext = createContext<AdminMailDomainContextType | undefi
*/
export const AdminMailDomainProvider = ({ children }: PropsWithChildren) => {
const router = useRouter();
const pagination = usePagination({ pageSize: DEFAULT_PAGE_SIZE });
const { data: maildomainsData, isLoading: isLoadingList, error: listError } = useMaildomainsList({ page: pagination.page });
const { pagination, searchQuery, setSearchQuery } = useSearchablePagination();
const trimmedQuery = searchQuery.trim();
const { data: maildomainsData, isLoading: isLoadingList, error: listError } = useMaildomainsList({
page: pagination.page,
...(trimmedQuery ? { q: trimmedQuery } : {}),
}, {
query: { placeholderData: keepPreviousData },
});
const { data: selectedMaildomainData, isLoading: isLoadingItem, error: itemError } = useMaildomainsRetrieve(
router.query.maildomainId as string, { query: { enabled: !!router.query.maildomainId } });
const context = useMemo(() => ({
selectedMailDomain: selectedMaildomainData?.data || null,
mailDomains: maildomainsData?.data.results || [],
isLoading: isLoadingList || isLoadingItem,
error: listError || itemError,
pagination
}), [selectedMaildomainData, maildomainsData, isLoadingList, isLoadingItem, listError, itemError, pagination]);
pagination,
searchQuery,
setSearchQuery,
}), [selectedMaildomainData, maildomainsData, isLoadingList, isLoadingItem, listError, itemError, pagination, searchQuery, setSearchQuery]);
useEffect(() => {
if (maildomainsData?.data.count) {
pagination.setPagesCount(Math.ceil(maildomainsData.data.count / pagination.pageSize));
if (maildomainsData?.data.count !== undefined) {
pagination.setPagesCount(
Math.max(1, Math.ceil(maildomainsData.data.count / pagination.pageSize))
);
}
}, [maildomainsData?.data.count, pagination.pageSize, pagination.setPagesCount]);
@@ -29,6 +29,7 @@ const DEFAULT_CONFIG: AppConfig = {
IMAGE_PROXY_ENABLED: false,
FEATURE_MAILDOMAIN_CREATE: true,
FEATURE_MAILDOMAIN_MANAGE_ACCESSES: true,
FEATURE_MAILDOMAIN_MANAGE_TOTP: false,
FEATURE_THREAD_SPLIT: true,
DRIVE: DEFAULT_DRIVE_CONFIG,
MESSAGES_MANUAL_RETRY_MAX_AGE: 0,
@@ -0,0 +1,24 @@
import { Tooltip } from "@gouvfr-lasuite/cunningham-react";
import { ReactNode } from "react";
type EmptyCellProps = {
tooltip?: ReactNode;
};
/**
* Em-dash placeholder for a DataGrid cell with no value, optionally wrapped
* in a Tooltip explaining the reason.
*/
export const EmptyCell = ({ tooltip }: EmptyCellProps) => {
const dash = (
<span style={{ color: "var(--c--contextuals--content--semantic--neutral--tertiary)" }}>
</span>
);
if (!tooltip) return dash;
return (
<Tooltip content={tooltip} placement="top">
{dash}
</Tooltip>
);
};
+45 -17
View File
@@ -1,23 +1,51 @@
import { useCallback, useEffect, useRef } from 'react';
import { useEffect, useMemo, useRef } from 'react';
/**
* useDebounceCallback hook
* Ensure the callback is called only after the delay has passed
* Debounced callback. Returns the debounced function with a `cancel`
* method attached so callers can drop any pending invocation useful
* when the input that fed the callback has been reset externally and
* the queued call would carry stale state.
*/
export function useDebounceCallback<Fn extends (...args: Parameters<Fn>) => void>(callback: Fn, delay: number): (...args: Parameters<Fn>) => void {
export type DebouncedCallback<P extends readonly unknown[]> =
((...args: P) => void) & { cancel: () => void };
export function useDebounceCallback<P extends readonly unknown[]>(
callback: (...args: P) => void,
delay: number,
): DebouncedCallback<P> {
const timeoutRef = useRef<NodeJS.Timeout | null>(null);
const debouncedCallback = useCallback((...args: Parameters<Fn>) => {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
}
timeoutRef.current = setTimeout(() => callback(...args), delay);
}, [callback, delay]);
// Latest-callback ref so the memoized debounced function always
// invokes the current callback without losing its own identity
// when the parent re-renders with a fresh arrow function.
const callbackRef = useRef(callback);
callbackRef.current = callback;
useEffect(() => () => {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
}
}, []);
// Clean up any pending timer on unmount so a queued callback can't
// fire after the host component is gone.
useEffect(
() => () => {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
}
},
[],
);
return debouncedCallback;
}
return useMemo<DebouncedCallback<P>>(() => {
const cancel = () => {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
timeoutRef.current = null;
}
};
const fn = ((...args: P) => {
cancel();
timeoutRef.current = setTimeout(
() => callbackRef.current(...args),
delay,
);
}) as DebouncedCallback<P>;
fn.cancel = cancel;
return fn;
}, [delay]);
}
+3
View File
@@ -7,6 +7,7 @@ export enum FEATURE_KEYS {
MAILBOX_ADMIN_CHANNELS = 'mailbox_admin_channels',
MAILDOMAIN_CREATE = 'maildomain_create',
MAILDOMAIN_MANAGE_ACCESSES = 'maildomain_manage_accesses',
MAILDOMAIN_MANAGE_TOTP = 'maildomain_manage_totp',
THREAD_SPLIT = 'thread_split',
}
@@ -33,6 +34,8 @@ export const useFeatureFlag = (featureKey: FEATURE_KEYS) => {
return config.FEATURE_MAILDOMAIN_CREATE === true;
case FEATURE_KEYS.MAILDOMAIN_MANAGE_ACCESSES:
return config.FEATURE_MAILDOMAIN_MANAGE_ACCESSES === true;
case FEATURE_KEYS.MAILDOMAIN_MANAGE_TOTP:
return config.FEATURE_MAILDOMAIN_MANAGE_TOTP === true;
case FEATURE_KEYS.THREAD_SPLIT:
return config.FEATURE_THREAD_SPLIT === true;
default:
@@ -0,0 +1,47 @@
import { usePagination } from "@gouvfr-lasuite/cunningham-react";
import { useEffect, useRef, useState } from "react";
import { DEFAULT_PAGE_SIZE } from "@/features/config/constants";
import usePrevious from "./use-previous";
type UseSearchablePaginationOptions = {
pageSize?: number;
/**
* When this value changes, both the search query and pagination reset.
* Pages-router pages don't unmount on dynamic-param change, so pass the
* route param (e.g. `maildomainId`) to keep state isolated per resource.
*/
resetKey?: unknown;
};
/**
* Cunningham `usePagination` paired with a search-query state.
*
* Changing the search query resets the pagination back to page 1 and clears
* `pagesCount` so the next list response reseeds it without this the user
* could land on an out-of-range page after typing a query.
*/
export const useSearchablePagination = (
options: UseSearchablePaginationOptions = {},
) => {
const { pageSize = DEFAULT_PAGE_SIZE, resetKey } = options;
const pagination = usePagination({ pageSize });
const [searchQuery, setSearchQueryState] = useState<string>("");
const previousResetKey = usePrevious(resetKey);
const setSearchQuery = (query: string) => {
setSearchQueryState(query);
pagination.setPage(1);
pagination.setPagesCount(undefined);
};
useEffect(() => {
if (previousResetKey === resetKey) return;
if (resetKey === undefined) return;
setSearchQueryState("");
pagination.setPage(1);
pagination.setPagesCount(undefined);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [resetKey]);
return { pagination, searchQuery, setSearchQuery };
};
@@ -1,15 +1,21 @@
import { DEFAULT_PAGE_SIZE } from "@/features/config/constants";
import { useRouter } from "next/router";
import { AdminLayout } from "@/features/layouts/components/admin/admin-layout";
import { CreateMailboxAction } from "@/features/layouts/components/admin/mailboxes-view/create-mailbox-action";
import { AdminDomainPageContent } from "@/features/layouts/components/admin/mailboxes-view/page-content";
import { usePagination } from "@gouvfr-lasuite/cunningham-react";
import { useQueryClient } from "@tanstack/react-query";
import { useSearchablePagination } from "@/hooks/use-searchable-pagination";
/**
* Admin page which list all mailboxes for a given domain and allow to manage them.
*/
export default function AdminDomainMailboxesPage() {
const pagination = usePagination({ pageSize: DEFAULT_PAGE_SIZE });
const router = useRouter();
// Next.js Pages Router keeps this component mounted across `/domain/[id]`
// navigations — pass the param as a reset key so each domain starts with
// an empty search and page=1.
const { pagination, searchQuery, setSearchQuery } = useSearchablePagination({
resetKey: router.query.maildomainId,
});
const queryClient = useQueryClient();
const handleCreateMailbox = async () => {
@@ -33,7 +39,11 @@ export default function AdminDomainMailboxesPage() {
currentTab="addresses"
actions={<CreateMailboxAction onCreate={handleCreateMailbox} />}
>
<AdminDomainPageContent pagination={pagination} />
<AdminDomainPageContent
pagination={pagination}
searchQuery={searchQuery}
onSearchChange={setSearchQuery}
/>
</AdminLayout>
);
}
@@ -83,6 +83,15 @@
z-index: 100;
margin-bottom: var(--c--globals--spacings--sm);
}
&__search {
margin-bottom: var(--c--globals--spacings--base);
}
.admin-data-grid__resource-count {
font-size: var(--c--globals--font--sizes--sm);
color: var(--c--contextuals--content--semantic--neutral--tertiary);
}
}
@media (max-width: 768px) {
+23 -3
View File
@@ -13,6 +13,7 @@ import { CreateDomainAction } from "@/features/layouts/components/admin/domains-
import { useQueryClient } from "@tanstack/react-query";
import { addToast, ToasterItem } from "@/features/ui/components/toaster";
import { ModalMaildomainManageAccesses } from "@/features/layouts/components/admin/modal-maildomain-manage-accesses";
import { AdminSearchInput } from "@/features/forms/components/admin-search-input";
type AdminDataGridProps = {
pagination: ReturnType<typeof usePagination>;
@@ -56,6 +57,12 @@ function AdminDataGrid({ domains, pagination }: AdminDataGridProps) {
headerName: t("Updated at"),
renderCell: ({ row }: { row: MailDomainAdmin }) => new Date(row.updated_at).toLocaleDateString(i18n.resolvedLanguage),
},
{
id: "mailbox_count",
size: 160,
headerName: t("Mailbox count"),
renderCell: ({ row }: { row: MailDomainAdmin }) => row.mailbox_count,
},
...(canManageMaildomainAccesses ? [{
id: "actions",
size: 200,
@@ -96,12 +103,17 @@ function AdminDataGrid({ domains, pagination }: AdminDataGridProps) {
const AdminPageContent = () => {
const router = useRouter();
const { t } = useTranslation();
const { mailDomains, isLoading, error, pagination } = useAdminMailDomain();
const { mailDomains, isLoading, error, pagination, searchQuery, setSearchQuery } = useAdminMailDomain();
const canCreateMaildomain = useAbility(Abilities.CAN_CREATE_MAILDOMAINS);
const hasManageAbility = useAbility(Abilities.CAN_MANAGE_SOME_MAILDOMAIN_ACCESSES);
const isManageAccessesEnabled = useFeatureFlag(FEATURE_KEYS.MAILDOMAIN_MANAGE_ACCESSES);
const canManageMaildomainAccesses = hasManageAbility && isManageAccessesEnabled;
const shouldRedirect = !canCreateMaildomain && !canManageMaildomainAccesses && !isLoading && mailDomains.length === 1;
// Treat a whitespace-only query as "no search" — matches the API,
// which strips whitespace before applying ``?q=``.
const normalizedSearchQuery = (searchQuery || '').trim();
// Only auto-redirect when the user has a single domain in total — i.e.
// not when the apparent single domain is the result of an active search.
const shouldRedirect = !canCreateMaildomain && !canManageMaildomainAccesses && !isLoading && !normalizedSearchQuery && mailDomains.length === 1;
/**
* Auto-navigate to first domain if there's only one and the
@@ -113,7 +125,7 @@ const AdminPageContent = () => {
}
}, [router, shouldRedirect]);
if (isLoading || shouldRedirect) {
if (shouldRedirect || (isLoading && !normalizedSearchQuery)) {
return (
<div className="admin-page__loading">
<Spinner />
@@ -134,6 +146,14 @@ const AdminPageContent = () => {
<div className="admin-page__bar">
<h1>{t("Maildomains management")}</h1>
</div>
<div className="admin-page__search">
<AdminSearchInput
label={t("Search a domain")}
placeholder={t("Search by domain name…")}
initialValue={searchQuery}
onChange={setSearchQuery}
/>
</div>
<AdminDataGrid domains={mailDomains} pagination={pagination} />
</>
)
+3
View File
@@ -18,6 +18,9 @@ FROM quay.io/keycloak/keycloak:26.6.1 AS builder
WORKDIR /opt/keycloak
COPY --chown=keycloak:keycloak --chmod=644 themes/dsfr-2.2.1.jar /opt/keycloak/providers/dsfr.jar
COPY --from=scripts --chown=keycloak:keycloak --chmod=644 /custom-scripts.jar /opt/keycloak/providers/custom-scripts.jar
# Committed JAR. Rebuild via `make build-keycloak` if you edit the Java
# source under bulk-role-membership/.
COPY --chown=keycloak:keycloak --chmod=644 bulk-role-membership/bulk-role-membership.jar /opt/keycloak/providers/bulk-role-membership.jar
ARG KC_METRICS_ENABLED=true
ARG KC_HEALTH_ENABLED=true
@@ -15,6 +15,9 @@ rm keycloak.tgz
# Copy themes
cp -r themes/* keycloak/providers/
# Copy custom provider JARs (committed pre-built; rebuild via `make build-keycloak`)
cp bulk-role-membership/bulk-role-membership.jar keycloak/providers/
# Package scripts
# if variable SCRIPT_GROUP_ATTRIBUTE_WHITELIST if defined, replace it in the .js file
if [ -n "$SCRIPT_GROUP_ATTRIBUTE_WHITELIST" ]; then
@@ -23,4 +26,4 @@ fi
cd scripts && zip -r ../keycloak/providers/custom-scripts.jar META-INF *.js && cd ..
echo "-----> Building Keycloak"
PATH=$HOME/.scalingo/with_jstack/bin:$PATH ./keycloak/bin/kc.sh build
PATH=$HOME/.jdk/bin:$PATH ./keycloak/bin/kc.sh build
@@ -0,0 +1 @@
target/
+111
View File
@@ -0,0 +1,111 @@
# bulk-role-membership
Keycloak provider exposing a single admin REST endpoint that answers
"for these N usernames, which have realm role R?" in one indexed DB
query.
Avoids the two shapes Keycloak's stock admin API forces on you:
- `/users/{id}/role-mappings/realm` called N times — O(N) round trips.
- `/roles/{name}/users` fetched in full and intersected client-side —
O(role-size) wire payload, plus the cache layer needed to amortize
that cost across page renders.
## Endpoint
`POST /realms/{realm}/bulk-role-membership/check`
Headers: `Authorization: Bearer <admin token>` — same token used for any
other `/admin/realms/{realm}/*` call. The endpoint enforces the same
`users:query` realm-management permission as the upstream user-listing
endpoints (`AdminPermissions.evaluator(...).users().requireQuery()`).
Request body:
```json
{
"role_id": "<uuid of the realm role>",
"usernames": ["alice@example.com", "bob@example.com", ...]
}
```
Response 200:
```json
{ "members": ["alice@example.com", ...] }
```
`members` is the subset of the input `usernames` that have a direct
mapping to `role_id`. Order is not preserved. Username comparison is
case-insensitive (Keycloak's stored canonical form is lowercase).
Composite-role and group-inherited memberships are **not** expanded —
same semantics as Keycloak's own `GET /roles/{name}/users`.
Errors:
- `400` — missing `role_id` or `usernames`.
- `401` — no bearer token / bad token.
- `403` — token lacks `users:query` on this realm.
- `404``role_id` not found in this realm.
## Build
The pre-built JAR is committed at
`src/keycloak/bulk-role-membership/bulk-role-membership.jar`, so
`docker compose up` works on a fresh checkout without any Java
tooling. The JAR has no shaded dependencies — every pom dependency is
`provided`, i.e. expected to already be on Keycloak's classpath. The
file contains only this project's three classes plus the SPI service
descriptor (~8 KB).
When you edit the Java source, rebuild via:
```sh
make build-keycloak
```
This runs Maven inside a `maven:3.9-eclipse-temurin-21` container,
emits the JAR at `target/bulk-role-membership.jar`, and copies it to
the committed path. Commit the new JAR alongside your code change.
`compose.yaml` mounts the committed JAR into
`/opt/keycloak/providers/`; `docker compose restart keycloak` picks
up a rebuilt JAR. For production, `src/keycloak/Dockerfile` `COPY`s
the same file into the image before `kc.sh build` runs.
## Direct query rationale
Internal Keycloak SPIs (`RoleProvider`, `UserProvider`) have no bulk
membership method — `user.hasRole(role)` is a per-user lookup. The
endpoint therefore queries the JPA-backed role-mapping and user-entity
tables directly:
```sql
SELECT ue.USERNAME
FROM USER_ROLE_MAPPING urm
JOIN USER_ENTITY ue ON ue.ID = urm.USER_ID
WHERE urm.ROLE_ID = :rid AND LOWER(ue.USERNAME) IN (:unames)
```
The table names and columns (`USER_ROLE_MAPPING` composite PK on
`ROLE_ID, USER_ID`; `USER_ENTITY` PK on `ID` with `USERNAME` indexed)
are unchanged since Keycloak 1.0 and remain in 26.x. Lookup is
O(log N) per probe via the PK / unique indexes.
Caveats:
- Bypasses Keycloak's user cache. For our use case (admin listing
reads, not auth-hot path) that's the desired property.
- Couples to the JPA storage schema. If you ever move users to a
federation backend (LDAP, custom UserStorageProvider), this query
returns nothing for federated users — the table only holds users
whose primary store is the JPA `UserEntity`.
- Composite roles are not expanded. Make the TOTP-style flag a
flat realm role.
## Test
`make test-keycloak` builds the JARs, brings the dev Keycloak service
up via compose, and runs every script in `src/keycloak/tests/test_*.py`
inside the backend-dev container. The bulk-role-membership test creates
a temporary realm role and three users, assigns the role to two of
them, hits the endpoint, asserts the response, then deletes everything
it created. Drop additional `test_*.py` files in `src/keycloak/tests/`
to add coverage for other custom providers.
+91
View File
@@ -0,0 +1,91 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>messages.keycloak</groupId>
<artifactId>bulk-role-membership</artifactId>
<version>1.0.0</version>
<packaging>jar</packaging>
<name>Bulk Role Membership Provider</name>
<description>
Custom Keycloak admin REST endpoint that answers
"for these N usernames, which have realm role R?" in a single
indexed DB query, avoiding the need to either fetch the full
role membership or call /users/{id}/role-mappings N times.
</description>
<properties>
<maven.compiler.source>21</maven.compiler.source>
<maven.compiler.target>21</maven.compiler.target>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<keycloak.version>26.6.1</keycloak.version>
</properties>
<dependencies>
<dependency>
<groupId>org.keycloak</groupId>
<artifactId>keycloak-core</artifactId>
<version>${keycloak.version}</version>
<scope>provided</scope>
</dependency>
<dependency>
<groupId>org.keycloak</groupId>
<artifactId>keycloak-server-spi</artifactId>
<version>${keycloak.version}</version>
<scope>provided</scope>
</dependency>
<dependency>
<groupId>org.keycloak</groupId>
<artifactId>keycloak-server-spi-private</artifactId>
<version>${keycloak.version}</version>
<scope>provided</scope>
</dependency>
<dependency>
<groupId>org.keycloak</groupId>
<artifactId>keycloak-services</artifactId>
<version>${keycloak.version}</version>
<scope>provided</scope>
</dependency>
<dependency>
<groupId>org.keycloak</groupId>
<artifactId>keycloak-model-jpa</artifactId>
<version>${keycloak.version}</version>
<scope>provided</scope>
</dependency>
<dependency>
<groupId>jakarta.ws.rs</groupId>
<artifactId>jakarta.ws.rs-api</artifactId>
<version>3.1.0</version>
<scope>provided</scope>
</dependency>
<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-annotations</artifactId>
<version>2.17.2</version>
<scope>provided</scope>
</dependency>
<dependency>
<groupId>jakarta.persistence</groupId>
<artifactId>jakarta.persistence-api</artifactId>
<version>3.1.0</version>
<scope>provided</scope>
</dependency>
</dependencies>
<build>
<finalName>bulk-role-membership</finalName>
<plugins>
<plugin>
<artifactId>maven-compiler-plugin</artifactId>
<version>3.13.0</version>
</plugin>
<plugin>
<artifactId>maven-jar-plugin</artifactId>
<version>3.4.2</version>
</plugin>
</plugins>
</build>
</project>
@@ -0,0 +1,133 @@
package messages.keycloak.bulkrolemembership;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Objects;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import com.fasterxml.jackson.annotation.JsonProperty;
import jakarta.persistence.EntityManager;
import jakarta.ws.rs.Consumes;
import jakarta.ws.rs.NotAuthorizedException;
import jakarta.ws.rs.POST;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.core.Response;
import org.keycloak.connections.jpa.JpaConnectionProvider;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.RealmModel;
import org.keycloak.models.RoleModel;
import org.keycloak.services.ErrorResponse;
import org.keycloak.services.managers.AppAuthManager;
import org.keycloak.services.managers.AuthenticationManager;
import org.keycloak.services.resources.admin.AdminAuth;
import org.keycloak.services.resources.admin.fgap.AdminPermissionEvaluator;
import org.keycloak.services.resources.admin.fgap.AdminPermissions;
public class BulkRoleMembershipResource {
// Hard cap on input size. PostgreSQL caps a single prepared statement at
// ~32k parameters; well before that, an unbounded IN-list signals a
// caller bug we'd rather surface as 400 than as a 500 deep inside JDBC.
// Our admin pagination ships ~100 in practice; 1000 is generous.
private static final int MAX_USERNAMES = 1000;
private final KeycloakSession session;
public BulkRoleMembershipResource(KeycloakSession session) {
this.session = session;
}
@POST
@Path("check")
@Consumes(MediaType.APPLICATION_JSON)
@Produces(MediaType.APPLICATION_JSON)
public Response check(BulkCheckRequest req) {
if (req == null || req.roleId == null || req.usernames == null) {
throw ErrorResponse.error(
"role_id and usernames are required", Response.Status.BAD_REQUEST);
}
if (req.usernames.size() > MAX_USERNAMES) {
throw ErrorResponse.error(
"usernames exceeds the " + MAX_USERNAMES + " cap",
Response.Status.BAD_REQUEST);
}
RealmModel realm = session.getContext().getRealm();
requireUserQueryPermission(realm);
RoleModel role = realm.getRoleById(req.roleId);
if (role == null || !realm.getId().equals(role.getContainerId())) {
throw ErrorResponse.error("realm role not found", Response.Status.NOT_FOUND);
}
// Keycloak stores usernames in lowercase canonical form (since 21+);
// lowercase on both sides defends against any case-mismatch at the
// call site without depending on a specific Keycloak normalization.
// ``filter(nonNull)`` keeps a stray null from blowing up the stream.
// ``Locale.ROOT`` keeps the casing rule locale-independent (avoids the
// Turkish-locale ``I ı`` surprise on non-ASCII usernames).
List<String> lowered = req.usernames.stream()
.filter(Objects::nonNull)
.map(s -> s.toLowerCase(Locale.ROOT))
.toList();
if (lowered.isEmpty()) {
return Response.ok(Map.of("members", List.of())).build();
}
// Single indexed query joining role-mapping user-entity. Both tables
// and their (ROLE_ID, USER_ID) / (ID, USERNAME) columns are stable
// since Keycloak 1.0. Result is the subset of input usernames whose
// user has a direct mapping to role_id composite-role and
// group-inherited memberships are NOT expanded (matches the
// semantics of the upstream /roles/{name}/users endpoint).
//
// The ``REALM_ID`` filter is defense in depth: the role-scope check
// above already implies same-realm membership (Keycloak invariants),
// but joining on REALM_ID explicitly removes any room for a foreign
// username collision to surface a user from another realm. Matches
// the post-KEYCLOAK-4559 upstream convention for raw JPA queries.
EntityManager em = session.getProvider(JpaConnectionProvider.class).getEntityManager();
@SuppressWarnings("unchecked")
List<String> matched = em.createNativeQuery(
"SELECT ue.USERNAME FROM USER_ROLE_MAPPING urm "
+ "JOIN USER_ENTITY ue ON ue.ID = urm.USER_ID "
+ "WHERE urm.ROLE_ID = :rid "
+ "AND ue.REALM_ID = :realmId "
+ "AND LOWER(ue.USERNAME) IN (:unames)")
.setParameter("rid", req.roleId)
.setParameter("realmId", realm.getId())
.setParameter("unames", lowered)
.getResultList();
return Response.ok(Map.of("members", matched)).build();
}
private void requireUserQueryPermission(RealmModel realm) {
AppAuthManager.BearerTokenAuthenticator authenticator =
new AppAuthManager.BearerTokenAuthenticator(session);
AuthenticationManager.AuthResult result = authenticator.authenticate();
if (result == null) {
throw new NotAuthorizedException("Bearer token required");
}
AdminAuth admin = new AdminAuth(
realm, result.getToken(), result.getUser(), result.getClient());
AdminPermissionEvaluator eval = AdminPermissions.evaluator(session, realm, admin);
eval.users().requireQuery();
}
@JsonIgnoreProperties(ignoreUnknown = true)
public static class BulkCheckRequest {
@JsonProperty("role_id")
public String roleId;
@JsonProperty("usernames")
public List<String> usernames;
}
}
@@ -0,0 +1,21 @@
package messages.keycloak.bulkrolemembership;
import org.keycloak.models.KeycloakSession;
import org.keycloak.services.resource.RealmResourceProvider;
public class BulkRoleMembershipResourceProvider implements RealmResourceProvider {
private final KeycloakSession session;
public BulkRoleMembershipResourceProvider(KeycloakSession session) {
this.session = session;
}
@Override
public Object getResource() {
return new BulkRoleMembershipResource(session);
}
@Override
public void close() {}
}
@@ -0,0 +1,31 @@
package messages.keycloak.bulkrolemembership;
import org.keycloak.Config;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.KeycloakSessionFactory;
import org.keycloak.services.resource.RealmResourceProvider;
import org.keycloak.services.resource.RealmResourceProviderFactory;
public class BulkRoleMembershipResourceProviderFactory implements RealmResourceProviderFactory {
public static final String PROVIDER_ID = "bulk-role-membership";
@Override
public RealmResourceProvider create(KeycloakSession session) {
return new BulkRoleMembershipResourceProvider(session);
}
@Override
public void init(Config.Scope config) {}
@Override
public void postInit(KeycloakSessionFactory factory) {}
@Override
public void close() {}
@Override
public String getId() {
return PROVIDER_ID;
}
}
@@ -0,0 +1 @@
messages.keycloak.bulkrolemembership.BulkRoleMembershipResourceProviderFactory
@@ -0,0 +1,400 @@
#!/usr/bin/env python3
"""End-to-end test for the bulk-role-membership Keycloak provider.
Runs against a Keycloak instance already brought up by `docker compose
up keycloak`. Exercises happy paths, input validation, role-scope
guards, authentication, authorization, SQL-injection safety, and HTTP
semantics. Creates and deletes its own roles and users so the realm
state is unchanged when the script finishes.
Usage (preferred): `make test-keycloak` from the repo root.
Direct: `python test_bulk_role_membership.py` against
`KEYCLOAK_URL` (default `http://localhost:8902`).
"""
import json
import os
import sys
import urllib3
import uuid
import requests
from keycloak import KeycloakAdmin, KeycloakOpenID
# We use verify=False for the dev keycloak which is plain http; silence
# urllib3's per-call warning so the test output stays readable.
urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
KEYCLOAK_URL = os.environ.get("KEYCLOAK_URL", "http://localhost:8902")
TARGET_REALM = os.environ.get("KEYCLOAK_REALM", "messages")
CLIENT_ID = os.environ.get("KEYCLOAK_CLIENT_ID", "rest-api")
CLIENT_SECRET = os.environ.get(
"KEYCLOAK_CLIENT_SECRET", "ServiceAccountClientSecretForDev"
)
MASTER_ADMIN_USER = os.environ.get("KC_BOOTSTRAP_ADMIN_USERNAME", "admin")
MASTER_ADMIN_PASS = os.environ.get("KC_BOOTSTRAP_ADMIN_PASSWORD", "admin")
ENDPOINT_PATH = f"/realms/{TARGET_REALM}/bulk-role-membership/check"
ENDPOINT_URL = f"{KEYCLOAK_URL}{ENDPOINT_PATH}"
def _post_with_token(token, body, *, raw_data=None, content_type="application/json"):
"""POST to the endpoint with an explicit bearer token (or None)."""
headers = {"Content-Type": content_type}
if token is not None:
headers["Authorization"] = f"Bearer {token}"
return requests.post(
ENDPOINT_URL,
data=raw_data if raw_data is not None else json.dumps(body),
headers=headers,
verify=False,
timeout=10,
)
def _password_token(realm, client_id, username, password, *, client_secret=None):
"""Fetch an access token via the OIDC password grant."""
data = {
"grant_type": "password",
"client_id": client_id,
"username": username,
"password": password,
}
if client_secret is not None:
data["client_secret"] = client_secret
response = requests.post(
f"{KEYCLOAK_URL}/realms/{realm}/protocol/openid-connect/token",
data=data,
verify=False,
timeout=10,
)
response.raise_for_status()
return response.json()["access_token"]
def _ok(label):
print(f"OK: {label}")
def main() -> int:
suffix = uuid.uuid4().hex[:8]
role_name = f"test-bulk-role-{suffix}"
empty_role_name = f"test-bulk-empty-{suffix}"
lowpriv_username = f"test-bulk-lowpriv-{suffix}@example.test"
lowpriv_password = "Lowpr1v!" + suffix
usernames = [f"test-bulk-user-{i}-{suffix}@example.test" for i in range(3)]
# ──────────────────────── auth setup ────────────────────────
openid = KeycloakOpenID(
server_url=KEYCLOAK_URL,
realm_name=TARGET_REALM,
client_id=CLIENT_ID,
client_secret_key=CLIENT_SECRET,
)
admin_token_payload = openid.token(grant_type="client_credentials")
admin_token = admin_token_payload["access_token"]
admin = KeycloakAdmin(
server_url=KEYCLOAK_URL,
realm_name=TARGET_REALM,
token=admin_token_payload,
verify=False,
)
created_role_id = None
created_empty_role_id = None
created_user_ids: list[str] = []
created_lowpriv_id = None
try:
# ──────────────────────── fixture ────────────────────────
admin.create_realm_role({"name": role_name})
created_role_id = admin.get_realm_role(role_name)["id"]
print(f"Created role {role_name} (id={created_role_id})")
admin.create_realm_role({"name": empty_role_name})
created_empty_role_id = admin.get_realm_role(empty_role_name)["id"]
for username in usernames:
uid = admin.create_user(
{"username": username, "email": username, "enabled": True}
)
created_user_ids.append(uid)
role_repr = admin.get_realm_role(role_name)
admin.assign_realm_roles(user_id=created_user_ids[0], roles=[role_repr])
admin.assign_realm_roles(user_id=created_user_ids[2], roles=[role_repr])
created_lowpriv_id = admin.create_user(
{
"username": lowpriv_username,
"email": lowpriv_username,
"enabled": True,
"emailVerified": True,
"credentials": [
{
"type": "password",
"value": lowpriv_password,
"temporary": False,
}
],
}
)
lowpriv_token = _password_token(
TARGET_REALM,
CLIENT_ID,
lowpriv_username,
lowpriv_password,
client_secret=CLIENT_SECRET,
)
master_admin_token = _password_token(
"master", "admin-cli", MASTER_ADMIN_USER, MASTER_ADMIN_PASS
)
print("Fixture ready: roles, users, low-priv token, master token")
def post(body, *, raw_data=None, content_type="application/json"):
return _post_with_token(
admin_token, body, raw_data=raw_data, content_type=content_type
)
# ─────────────────────── happy paths ────────────────────────
r = post({"role_id": created_role_id, "usernames": usernames})
assert r.status_code == 200, r.text
assert set(r.json()["members"]) == {usernames[0], usernames[2]}
_ok("happy path: 2 of 3 usernames match")
r = post({"role_id": created_role_id, "usernames": [usernames[1]]})
assert r.status_code == 200 and r.json()["members"] == []
_ok("subset with no match returns empty list")
r = post({"role_id": created_role_id, "usernames": [usernames[0].upper()]})
assert r.status_code == 200 and r.json()["members"] == [usernames[0]]
_ok("case-insensitive lookup, canonical username returned")
r = post({"role_id": created_role_id, "usernames": []})
assert r.status_code == 200 and r.json()["members"] == []
_ok("empty usernames list returns empty list")
# Duplicates in input should not multiply rows in the response.
r = post(
{"role_id": created_role_id, "usernames": [usernames[0], usernames[0]]}
)
assert r.status_code == 200 and r.json()["members"] == [usernames[0]]
_ok("duplicate input deduped to one row")
r = post({"role_id": created_empty_role_id, "usernames": usernames})
assert r.status_code == 200 and r.json()["members"] == []
_ok("role with no members returns empty list")
# Unknown fields in the body must be ignored (forward-compatibility).
r = post(
{
"role_id": created_role_id,
"usernames": usernames,
"future_field": "ignore me",
}
)
assert r.status_code == 200 and set(r.json()["members"]) == {
usernames[0],
usernames[2],
}
_ok("unknown body fields are ignored")
# ───────────────────── input validation ─────────────────────
r = post({"role_id": created_role_id})
assert r.status_code == 400
# Admin-REST error shape: structured body with "errorMessage" key.
assert "errorMessage" in r.json()
_ok("missing usernames field → 400 with errorMessage body")
r = post({"usernames": usernames})
assert r.status_code == 400
_ok("missing role_id field → 400")
r = post({})
assert r.status_code == 400
_ok("empty JSON object → 400")
r = post({"role_id": None, "usernames": usernames})
assert r.status_code == 400
_ok("explicit null role_id → 400")
r = post({"role_id": created_role_id, "usernames": None})
assert r.status_code == 400
_ok("explicit null usernames → 400")
r = post(None, raw_data="")
assert r.status_code == 400
_ok("empty body → 400")
r = post(None, raw_data="this is not json")
assert r.status_code == 400
_ok("non-JSON body → 400")
r = post({"role_id": created_role_id, "usernames": "alice"})
assert r.status_code == 400
_ok("usernames as string (not list) → 400")
# Null elements inside the list should be filtered out, not NPE.
r = post(
{
"role_id": created_role_id,
"usernames": [None, usernames[0], None, usernames[2]],
}
)
assert r.status_code == 200 and set(r.json()["members"]) == {
usernames[0],
usernames[2],
}
_ok("null elements inside usernames are filtered, not NPE")
# ───────────────────── size limit ─────────────────────
oversized = [f"x{i}@example.test" for i in range(1001)]
r = post({"role_id": created_role_id, "usernames": oversized})
assert r.status_code == 400 and "1000" in r.text
_ok("usernames length 1001 → 400 with cap hint")
# 1000 exactly should be accepted (we just won't get any matches).
ok_sized = [f"x{i}@example.test" for i in range(1000)]
r = post({"role_id": created_role_id, "usernames": ok_sized})
assert r.status_code == 200 and r.json()["members"] == []
_ok("usernames length 1000 accepted at the cap")
# ───────────────────── role validation ─────────────────────
r = post(
{
"role_id": "00000000-0000-0000-0000-000000000000",
"usernames": usernames,
}
)
assert r.status_code == 404
_ok("nonexistent role_id (zero UUID) → 404")
r = post({"role_id": "not-a-uuid", "usernames": usernames})
assert r.status_code == 404
_ok("non-UUID role_id → 404, not 500")
r = post({"role_id": "", "usernames": usernames})
assert r.status_code == 404
_ok("empty-string role_id → 404")
# Cross-realm safety: ask for a master-realm role id while hitting
# the messages-realm endpoint. The defensive containerId check
# rejects this even if Keycloak's per-realm getRoleById ever leaked.
master_admin_client = KeycloakAdmin(
server_url=KEYCLOAK_URL,
username=MASTER_ADMIN_USER,
password=MASTER_ADMIN_PASS,
realm_name="master",
user_realm_name="master",
verify=False,
)
master_admin_role_id = master_admin_client.get_realm_role("admin")["id"]
r = post({"role_id": master_admin_role_id, "usernames": usernames})
assert r.status_code == 404
_ok("cross-realm role_id (master 'admin' role) → 404")
# ────────────────────── authentication ──────────────────────
r = _post_with_token(
None, {"role_id": created_role_id, "usernames": usernames}
)
assert r.status_code == 401
_ok("no Authorization header → 401")
r = _post_with_token(
"not.a.real.token",
{"role_id": created_role_id, "usernames": usernames},
)
assert r.status_code == 401
_ok("garbage bearer token → 401")
# An access token issued by /realms/master should not authenticate
# against /realms/messages/...; signature/issuer mismatch → 401.
r = _post_with_token(
master_admin_token,
{"role_id": created_role_id, "usernames": usernames},
)
assert r.status_code == 401
_ok("master-realm token on messages endpoint → 401")
# ─────────────────────── authorization ──────────────────────
# A regular user with a valid token but no realm-management roles
# must be rejected with 403, not allowed through.
r = _post_with_token(
lowpriv_token,
{"role_id": created_role_id, "usernames": usernames},
)
assert r.status_code == 403, (
f"low-priv user: expected 403, got {r.status_code}: {r.text}"
)
_ok("authenticated low-privilege user → 403")
# ─────────────────────── SQL injection ──────────────────────
# role_id is bound by name; no string concatenation. A SQL-meta
# value just fails the role lookup → 404.
r = post({"role_id": "' OR 1=1 --", "usernames": usernames})
assert r.status_code == 404
_ok("SQL-meta role_id → 404, no injection")
# usernames are bound by name too. Even a username containing
# statement terminators and a DROP cannot escape the parameter.
evil = "alice'; DROP TABLE USER_ENTITY; --@example.test"
r = post({"role_id": created_role_id, "usernames": [evil]})
assert r.status_code == 200 and r.json()["members"] == []
_ok("SQL-meta username → 200, no injection")
# Verify the tables are still alive by re-running a known-good call.
r = post({"role_id": created_role_id, "usernames": [usernames[0]]})
assert r.status_code == 200 and r.json()["members"] == [usernames[0]]
_ok("tables intact after injection attempts")
# ──────────────────────── HTTP semantics ────────────────────
r = requests.get(
ENDPOINT_URL,
headers={"Authorization": f"Bearer {admin_token}"},
verify=False,
timeout=10,
)
assert r.status_code == 405, f"GET should be 405, got {r.status_code}"
_ok("GET on POST-only endpoint → 405")
r = post(
{"role_id": created_role_id, "usernames": usernames},
content_type="text/plain",
)
# Jackson + JAX-RS reject the wrong media type with 415.
assert r.status_code in (400, 415), (
f"wrong content-type: expected 400/415, got {r.status_code}"
)
_ok("wrong Content-Type rejected (415/400)")
# Response always carries a "members" key, even when empty.
r = post({"role_id": created_empty_role_id, "usernames": usernames})
assert "members" in r.json()
assert r.headers.get("content-type", "").startswith("application/json")
_ok("response shape: members key present, JSON content-type")
print("\nAll assertions passed.")
return 0
finally:
for uid in created_user_ids + (
[created_lowpriv_id] if created_lowpriv_id else []
):
try:
admin.delete_user(uid)
except Exception as exc: # pylint: disable=broad-exception-caught
print(f"cleanup: failed to delete user {uid}: {exc}", file=sys.stderr)
for role in (role_name, empty_role_name):
try:
admin.delete_realm_role(role)
except Exception as exc: # pylint: disable=broad-exception-caught
print(
f"cleanup: failed to delete role {role}: {exc}",
file=sys.stderr,
)
if __name__ == "__main__":
sys.exit(main())