mirror of
https://github.com/suitenumerique/messages.git
synced 2026-08-17 21:25:41 +02:00
✨(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:
@@ -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
|
||||
|
||||
Executable
+67
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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 [])
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}"
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
@@ -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")
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
+11
@@ -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" },
|
||||
] : [];
|
||||
|
||||
+126
-29
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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]);
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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} />
|
||||
</>
|
||||
)
|
||||
|
||||
@@ -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/
|
||||
@@ -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.
|
||||
Binary file not shown.
@@ -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>
|
||||
+133
@@ -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;
|
||||
}
|
||||
}
|
||||
+21
@@ -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() {}
|
||||
}
|
||||
+31
@@ -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;
|
||||
}
|
||||
}
|
||||
+1
@@ -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())
|
||||
Reference in New Issue
Block a user