mirror of
https://github.com/suitenumerique/meet.git
synced 2026-07-27 12:19:10 +00:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ca98cf5fac |
@@ -2,6 +2,8 @@
|
|||||||
|
|
||||||
from rest_framework import permissions
|
from rest_framework import permissions
|
||||||
|
|
||||||
|
from core.entitlements import EntitlementsUnavailableError, get_user_entitlements
|
||||||
|
|
||||||
from ..models import RoleChoices
|
from ..models import RoleChoices
|
||||||
|
|
||||||
ACTION_FOR_METHOD_TO_PERMISSION = {
|
ACTION_FOR_METHOD_TO_PERMISSION = {
|
||||||
@@ -45,11 +47,27 @@ class RoomPermissions(permissions.BasePermission):
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
def has_permission(self, request, view):
|
def has_permission(self, request, view):
|
||||||
"""Only allow authenticated users for unsafe methods."""
|
"""Only allow authenticated users for unsafe methods.
|
||||||
|
|
||||||
|
Room creation additionally requires the can_create entitlement.
|
||||||
|
Fail-closed: denies creation when the entitlements service is unavailable.
|
||||||
|
"""
|
||||||
if request.method in permissions.SAFE_METHODS:
|
if request.method in permissions.SAFE_METHODS:
|
||||||
return True
|
return True
|
||||||
|
|
||||||
return request.user.is_authenticated
|
if not request.user.is_authenticated:
|
||||||
|
return False
|
||||||
|
|
||||||
|
if view.action == "create":
|
||||||
|
try:
|
||||||
|
entitlements = get_user_entitlements(
|
||||||
|
request.user.sub, request.user.email
|
||||||
|
)
|
||||||
|
return entitlements.get("can_create", False)
|
||||||
|
except EntitlementsUnavailableError:
|
||||||
|
return False
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
def has_object_permission(self, request, view, obj):
|
def has_object_permission(self, request, view, obj):
|
||||||
"""Object permissions are only given to administrators of the room."""
|
"""Object permissions are only given to administrators of the room."""
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ from rest_framework.exceptions import PermissionDenied
|
|||||||
from timezone_field.rest_framework import TimeZoneSerializerField
|
from timezone_field.rest_framework import TimeZoneSerializerField
|
||||||
|
|
||||||
from core import models, utils
|
from core import models, utils
|
||||||
|
from core.entitlements import EntitlementsUnavailableError, get_user_entitlements
|
||||||
|
|
||||||
|
|
||||||
class UserSerializer(serializers.ModelSerializer):
|
class UserSerializer(serializers.ModelSerializer):
|
||||||
@@ -27,6 +28,25 @@ class UserSerializer(serializers.ModelSerializer):
|
|||||||
read_only_fields = ["id", "email", "full_name", "short_name"]
|
read_only_fields = ["id", "email", "full_name", "short_name"]
|
||||||
|
|
||||||
|
|
||||||
|
class UserMeSerializer(UserSerializer):
|
||||||
|
"""Serialize users for me endpoint."""
|
||||||
|
|
||||||
|
can_create = serializers.SerializerMethodField(read_only=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = models.User
|
||||||
|
fields = [*UserSerializer.Meta.fields, "can_create"]
|
||||||
|
read_only_fields = [*UserSerializer.Meta.read_only_fields, "can_create"]
|
||||||
|
|
||||||
|
def get_can_create(self, user) -> bool:
|
||||||
|
"""Check entitlements for the current user."""
|
||||||
|
try:
|
||||||
|
entitlements = get_user_entitlements(user.sub, user.email)
|
||||||
|
return entitlements.get("can_create", False)
|
||||||
|
except EntitlementsUnavailableError:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
class ResourceAccessSerializerMixin:
|
class ResourceAccessSerializerMixin:
|
||||||
"""
|
"""
|
||||||
A serializer mixin to share controlling that the logged-in user submitting a room access object
|
A serializer mixin to share controlling that the logged-in user submitting a room access object
|
||||||
|
|||||||
@@ -187,7 +187,7 @@ class UserViewSet(
|
|||||||
"""
|
"""
|
||||||
context = {"request": request}
|
context = {"request": request}
|
||||||
return drf_response.Response(
|
return drf_response.Response(
|
||||||
self.serializer_class(request.user, context=context).data
|
serializers.UserMeSerializer(request.user, context=context).data
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
"""Authentication Backends for the Meet core app."""
|
"""Authentication Backends for the Meet core app."""
|
||||||
|
|
||||||
import contextlib
|
import contextlib
|
||||||
|
import logging
|
||||||
|
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.core.exceptions import ImproperlyConfigured, SuspiciousOperation
|
from django.core.exceptions import ImproperlyConfigured, SuspiciousOperation
|
||||||
@@ -10,6 +11,7 @@ from lasuite.oidc_login.backends import (
|
|||||||
OIDCAuthenticationBackend as LaSuiteOIDCAuthenticationBackend,
|
OIDCAuthenticationBackend as LaSuiteOIDCAuthenticationBackend,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
from core.entitlements import EntitlementsUnavailableError, get_user_entitlements
|
||||||
from core.models import User
|
from core.models import User
|
||||||
from core.services.marketing import (
|
from core.services.marketing import (
|
||||||
ContactCreationError,
|
ContactCreationError,
|
||||||
@@ -17,6 +19,8 @@ from core.services.marketing import (
|
|||||||
get_marketing_service,
|
get_marketing_service,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
class OIDCAuthenticationBackend(LaSuiteOIDCAuthenticationBackend):
|
class OIDCAuthenticationBackend(LaSuiteOIDCAuthenticationBackend):
|
||||||
"""Custom OpenID Connect (OIDC) Authentication Backend.
|
"""Custom OpenID Connect (OIDC) Authentication Backend.
|
||||||
@@ -59,6 +63,21 @@ class OIDCAuthenticationBackend(LaSuiteOIDCAuthenticationBackend):
|
|||||||
if is_new_user and email and settings.SIGNUP_NEW_USER_TO_MARKETING_EMAIL:
|
if is_new_user and email and settings.SIGNUP_NEW_USER_TO_MARKETING_EMAIL:
|
||||||
self.signup_to_marketing_email(email)
|
self.signup_to_marketing_email(email)
|
||||||
|
|
||||||
|
# Warm the entitlements cache on login (force_refresh)
|
||||||
|
try:
|
||||||
|
get_user_entitlements(
|
||||||
|
user_sub=user.sub,
|
||||||
|
user_email=user.email,
|
||||||
|
user_info=claims,
|
||||||
|
force_refresh=True,
|
||||||
|
)
|
||||||
|
except EntitlementsUnavailableError:
|
||||||
|
email_domain = user.email.split("@")[-1] if "@" in user.email else "?"
|
||||||
|
logger.warning(
|
||||||
|
"Entitlements unavailable for user@%s during login",
|
||||||
|
email_domain,
|
||||||
|
)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def signup_to_marketing_email(email):
|
def signup_to_marketing_email(email):
|
||||||
"""Pragmatic approach to newsletter signup during authentication flow.
|
"""Pragmatic approach to newsletter signup during authentication flow.
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
"""Entitlements service layer."""
|
||||||
|
|
||||||
|
from core.entitlements.factory import get_entitlements_backend
|
||||||
|
|
||||||
|
|
||||||
|
class EntitlementsUnavailableError(Exception):
|
||||||
|
"""Raised when the entitlements backend cannot be reached or returns an error."""
|
||||||
|
|
||||||
|
|
||||||
|
def get_user_entitlements(user_sub, user_email, user_info=None, force_refresh=False):
|
||||||
|
"""Get user entitlements, delegating to the configured backend.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
user_sub: The user's OIDC subject identifier.
|
||||||
|
user_email: The user's email address.
|
||||||
|
user_info: The full OIDC user_info dict (forwarded to backend).
|
||||||
|
force_refresh: If True, bypass backend cache and fetch fresh data.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
dict: {"can_create": bool}
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
EntitlementsUnavailableError: If the backend cannot be reached
|
||||||
|
and no cache exists.
|
||||||
|
"""
|
||||||
|
backend = get_entitlements_backend()
|
||||||
|
return backend.get_user_entitlements(
|
||||||
|
user_sub, user_email, user_info=user_info, force_refresh=force_refresh
|
||||||
|
)
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
"""Abstract base class for entitlements backends."""
|
||||||
|
|
||||||
|
from abc import ABC, abstractmethod
|
||||||
|
|
||||||
|
|
||||||
|
class EntitlementsBackend(ABC):
|
||||||
|
"""Abstract base class that defines the interface for entitlements backends."""
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def get_user_entitlements(
|
||||||
|
self, user_sub, user_email, user_info=None, force_refresh=False
|
||||||
|
):
|
||||||
|
"""Fetch user entitlements.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
user_sub: The user's OIDC subject identifier.
|
||||||
|
user_email: The user's email address.
|
||||||
|
user_info: The full OIDC user_info dict (backends may
|
||||||
|
extract claims from it).
|
||||||
|
force_refresh: If True, bypass any cache and fetch fresh data.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
dict: {"can_create": bool}
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
EntitlementsUnavailableError: If the backend cannot be reached.
|
||||||
|
"""
|
||||||
@@ -0,0 +1,120 @@
|
|||||||
|
"""DeployCenter (Espace Operateur) entitlements backend."""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from django.conf import settings
|
||||||
|
from django.core.cache import cache
|
||||||
|
|
||||||
|
import requests
|
||||||
|
|
||||||
|
from core.entitlements import EntitlementsUnavailableError
|
||||||
|
from core.entitlements.backends.base import EntitlementsBackend
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class DeployCenterEntitlementsBackend(EntitlementsBackend):
|
||||||
|
"""Backend that fetches entitlements from the DeployCenter API.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
base_url: Full URL of the entitlements endpoint
|
||||||
|
(e.g. "https://dc.example.com/api/v1.0/entitlements/").
|
||||||
|
service_id: The service identifier in DeployCenter.
|
||||||
|
api_key: API key for X-Service-Auth header.
|
||||||
|
timeout: HTTP request timeout in seconds.
|
||||||
|
oidc_claims: List of OIDC claim names to extract from user_info
|
||||||
|
and forward as query params (e.g. ["siret"]).
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__( # pylint: disable=too-many-arguments
|
||||||
|
self,
|
||||||
|
base_url,
|
||||||
|
service_id,
|
||||||
|
api_key,
|
||||||
|
*,
|
||||||
|
timeout=10,
|
||||||
|
oidc_claims=None,
|
||||||
|
):
|
||||||
|
self.base_url = base_url
|
||||||
|
self.service_id = service_id
|
||||||
|
self.api_key = api_key
|
||||||
|
self.timeout = timeout
|
||||||
|
self.oidc_claims = oidc_claims or []
|
||||||
|
|
||||||
|
def _cache_key(self, user_sub):
|
||||||
|
return f"entitlements:user:{user_sub}"
|
||||||
|
|
||||||
|
def _make_request(self, user_email, user_info=None):
|
||||||
|
"""Make a request to the DeployCenter entitlements API.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
dict | None: The response data, or None on failure.
|
||||||
|
"""
|
||||||
|
params = {
|
||||||
|
"service_id": self.service_id,
|
||||||
|
"account_type": "user",
|
||||||
|
"account_email": user_email,
|
||||||
|
}
|
||||||
|
|
||||||
|
# Forward configured OIDC claims as query params
|
||||||
|
if user_info:
|
||||||
|
for claim in self.oidc_claims:
|
||||||
|
if claim in user_info:
|
||||||
|
params[claim] = user_info[claim]
|
||||||
|
|
||||||
|
headers = {
|
||||||
|
"X-Service-Auth": f"Bearer {self.api_key}",
|
||||||
|
}
|
||||||
|
|
||||||
|
try:
|
||||||
|
response = requests.get(
|
||||||
|
self.base_url,
|
||||||
|
params=params,
|
||||||
|
headers=headers,
|
||||||
|
timeout=self.timeout,
|
||||||
|
)
|
||||||
|
response.raise_for_status()
|
||||||
|
return response.json()
|
||||||
|
except (requests.RequestException, ValueError):
|
||||||
|
email_domain = user_email.split("@")[-1] if "@" in user_email else "?"
|
||||||
|
logger.warning(
|
||||||
|
"DeployCenter entitlements request failed for user@%s",
|
||||||
|
email_domain,
|
||||||
|
exc_info=True,
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
|
||||||
|
def get_user_entitlements(
|
||||||
|
self, user_sub, user_email, user_info=None, force_refresh=False
|
||||||
|
):
|
||||||
|
"""Fetch user entitlements from DeployCenter with caching.
|
||||||
|
|
||||||
|
On cache miss or force_refresh: fetches from the API.
|
||||||
|
On API failure: falls back to stale cache if available,
|
||||||
|
otherwise raises EntitlementsUnavailableError.
|
||||||
|
"""
|
||||||
|
cache_key = self._cache_key(user_sub)
|
||||||
|
|
||||||
|
if not force_refresh:
|
||||||
|
cached = cache.get(cache_key)
|
||||||
|
if cached is not None:
|
||||||
|
return cached
|
||||||
|
|
||||||
|
data = self._make_request(user_email, user_info=user_info)
|
||||||
|
|
||||||
|
if data is None:
|
||||||
|
# API failed — try stale cache as fallback
|
||||||
|
cached = cache.get(cache_key)
|
||||||
|
if cached is not None:
|
||||||
|
return cached
|
||||||
|
raise EntitlementsUnavailableError(
|
||||||
|
"Failed to fetch user entitlements from DeployCenter"
|
||||||
|
)
|
||||||
|
|
||||||
|
entitlements = data.get("entitlements", {})
|
||||||
|
result = {
|
||||||
|
"can_create": entitlements.get("can_create", False),
|
||||||
|
}
|
||||||
|
|
||||||
|
cache.set(cache_key, result, settings.ENTITLEMENTS_CACHE_TIMEOUT)
|
||||||
|
return result
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
"""Local entitlements backend for development and testing."""
|
||||||
|
|
||||||
|
from core.entitlements.backends.base import EntitlementsBackend
|
||||||
|
|
||||||
|
|
||||||
|
class LocalEntitlementsBackend(EntitlementsBackend):
|
||||||
|
"""Local backend that always grants access."""
|
||||||
|
|
||||||
|
def get_user_entitlements(
|
||||||
|
self, user_sub, user_email, user_info=None, force_refresh=False
|
||||||
|
):
|
||||||
|
return {"can_create": True}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
"""Factory for creating entitlements backend instances."""
|
||||||
|
|
||||||
|
import functools
|
||||||
|
|
||||||
|
from django.conf import settings
|
||||||
|
from django.utils.module_loading import import_string
|
||||||
|
|
||||||
|
|
||||||
|
@functools.cache
|
||||||
|
def get_entitlements_backend():
|
||||||
|
"""Return a singleton instance of the configured entitlements backend."""
|
||||||
|
backend_class = import_string(settings.ENTITLEMENTS_BACKEND)
|
||||||
|
return backend_class(**settings.ENTITLEMENTS_BACKEND_PARAMETERS)
|
||||||
@@ -125,6 +125,7 @@ def test_api_users_retrieve_me_authenticated(settings):
|
|||||||
"short_name": user.short_name,
|
"short_name": user.short_name,
|
||||||
"language": user.language,
|
"language": user.language,
|
||||||
"timezone": "UTC",
|
"timezone": "UTC",
|
||||||
|
"can_create": True,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,504 @@
|
|||||||
|
"""Tests for the entitlements module."""
|
||||||
|
|
||||||
|
# pylint: disable=redefined-outer-name
|
||||||
|
|
||||||
|
from unittest import mock
|
||||||
|
|
||||||
|
from django.test import override_settings
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
import requests
|
||||||
|
import responses
|
||||||
|
from rest_framework.status import HTTP_201_CREATED, HTTP_403_FORBIDDEN
|
||||||
|
from rest_framework.test import APIClient
|
||||||
|
|
||||||
|
from django.core.cache import cache as django_cache
|
||||||
|
|
||||||
|
from core import factories
|
||||||
|
from core.api.serializers import UserMeSerializer
|
||||||
|
from core.authentication.backends import OIDCAuthenticationBackend
|
||||||
|
from core.entitlements import EntitlementsUnavailableError, get_user_entitlements
|
||||||
|
from core.entitlements.backends.deploycenter import DeployCenterEntitlementsBackend
|
||||||
|
from core.entitlements.backends.local import LocalEntitlementsBackend
|
||||||
|
from core.entitlements.factory import get_entitlements_backend
|
||||||
|
|
||||||
|
pytestmark = pytest.mark.django_db
|
||||||
|
|
||||||
|
DC_URL = "https://deploy.example.com/api/v1.0/entitlements/"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def _clear_cache():
|
||||||
|
"""Clear Django cache between tests to prevent entitlements cache bleed."""
|
||||||
|
django_cache.clear()
|
||||||
|
|
||||||
|
|
||||||
|
# -- LocalEntitlementsBackend --
|
||||||
|
|
||||||
|
|
||||||
|
def test_local_backend_always_grants_access():
|
||||||
|
"""The local backend should always return can_create=True."""
|
||||||
|
backend = LocalEntitlementsBackend()
|
||||||
|
result = backend.get_user_entitlements("sub-123", "user@example.com")
|
||||||
|
assert result == {"can_create": True}
|
||||||
|
|
||||||
|
|
||||||
|
def test_local_backend_ignores_parameters():
|
||||||
|
"""The local backend should work regardless of parameters passed."""
|
||||||
|
backend = LocalEntitlementsBackend()
|
||||||
|
result = backend.get_user_entitlements(
|
||||||
|
"sub-123",
|
||||||
|
"user@example.com",
|
||||||
|
user_info={"some": "claim"},
|
||||||
|
force_refresh=True,
|
||||||
|
)
|
||||||
|
assert result == {"can_create": True}
|
||||||
|
|
||||||
|
|
||||||
|
# -- Factory --
|
||||||
|
|
||||||
|
|
||||||
|
@override_settings(
|
||||||
|
ENTITLEMENTS_BACKEND="core.entitlements.backends.local.LocalEntitlementsBackend",
|
||||||
|
ENTITLEMENTS_BACKEND_PARAMETERS={},
|
||||||
|
)
|
||||||
|
def test_factory_returns_local_backend():
|
||||||
|
"""The factory should instantiate the configured backend."""
|
||||||
|
get_entitlements_backend.cache_clear()
|
||||||
|
backend = get_entitlements_backend()
|
||||||
|
assert isinstance(backend, LocalEntitlementsBackend)
|
||||||
|
get_entitlements_backend.cache_clear()
|
||||||
|
|
||||||
|
|
||||||
|
@override_settings(
|
||||||
|
ENTITLEMENTS_BACKEND="core.entitlements.backends.local.LocalEntitlementsBackend",
|
||||||
|
ENTITLEMENTS_BACKEND_PARAMETERS={},
|
||||||
|
)
|
||||||
|
def test_factory_singleton():
|
||||||
|
"""The factory should return the same instance on repeated calls."""
|
||||||
|
get_entitlements_backend.cache_clear()
|
||||||
|
backend1 = get_entitlements_backend()
|
||||||
|
backend2 = get_entitlements_backend()
|
||||||
|
assert backend1 is backend2
|
||||||
|
get_entitlements_backend.cache_clear()
|
||||||
|
|
||||||
|
|
||||||
|
# -- get_user_entitlements public API --
|
||||||
|
|
||||||
|
|
||||||
|
@override_settings(
|
||||||
|
ENTITLEMENTS_BACKEND="core.entitlements.backends.local.LocalEntitlementsBackend",
|
||||||
|
ENTITLEMENTS_BACKEND_PARAMETERS={},
|
||||||
|
)
|
||||||
|
def test_get_user_entitlements_with_local_backend():
|
||||||
|
"""The public API should delegate to the configured backend."""
|
||||||
|
get_entitlements_backend.cache_clear()
|
||||||
|
result = get_user_entitlements("sub-123", "user@example.com")
|
||||||
|
assert result["can_create"] is True
|
||||||
|
get_entitlements_backend.cache_clear()
|
||||||
|
|
||||||
|
|
||||||
|
# -- DeployCenterEntitlementsBackend --
|
||||||
|
|
||||||
|
|
||||||
|
@responses.activate
|
||||||
|
def test_deploycenter_backend_grants_access():
|
||||||
|
"""DeployCenter backend should return can_create from API response."""
|
||||||
|
responses.add(
|
||||||
|
responses.GET,
|
||||||
|
DC_URL,
|
||||||
|
json={"entitlements": {"can_create": True}},
|
||||||
|
status=200,
|
||||||
|
)
|
||||||
|
|
||||||
|
backend = DeployCenterEntitlementsBackend(
|
||||||
|
base_url=DC_URL,
|
||||||
|
service_id="meet",
|
||||||
|
api_key="test-key",
|
||||||
|
)
|
||||||
|
result = backend.get_user_entitlements("sub-123", "user@example.com")
|
||||||
|
assert result == {"can_create": True}
|
||||||
|
|
||||||
|
# Verify request was made with correct params and header
|
||||||
|
assert len(responses.calls) == 1
|
||||||
|
request = responses.calls[0].request
|
||||||
|
assert "service_id=meet" in request.url
|
||||||
|
assert "account_email=user%40example.com" in request.url
|
||||||
|
assert request.headers["X-Service-Auth"] == "Bearer test-key"
|
||||||
|
|
||||||
|
|
||||||
|
@responses.activate
|
||||||
|
def test_deploycenter_backend_denies_access():
|
||||||
|
"""DeployCenter backend should return can_create=False when API says so."""
|
||||||
|
responses.add(
|
||||||
|
responses.GET,
|
||||||
|
DC_URL,
|
||||||
|
json={"entitlements": {"can_create": False}},
|
||||||
|
status=200,
|
||||||
|
)
|
||||||
|
|
||||||
|
backend = DeployCenterEntitlementsBackend(
|
||||||
|
base_url=DC_URL,
|
||||||
|
service_id="meet",
|
||||||
|
api_key="test-key",
|
||||||
|
)
|
||||||
|
result = backend.get_user_entitlements("sub-123", "user@example.com")
|
||||||
|
assert result == {"can_create": False}
|
||||||
|
|
||||||
|
|
||||||
|
@responses.activate
|
||||||
|
@override_settings(ENTITLEMENTS_CACHE_TIMEOUT=300)
|
||||||
|
def test_deploycenter_backend_uses_cache():
|
||||||
|
"""DeployCenter should use cached results when not force_refresh."""
|
||||||
|
responses.add(
|
||||||
|
responses.GET,
|
||||||
|
DC_URL,
|
||||||
|
json={"entitlements": {"can_create": True}},
|
||||||
|
status=200,
|
||||||
|
)
|
||||||
|
|
||||||
|
backend = DeployCenterEntitlementsBackend(
|
||||||
|
base_url=DC_URL,
|
||||||
|
service_id="meet",
|
||||||
|
api_key="test-key",
|
||||||
|
)
|
||||||
|
|
||||||
|
# First call hits the API
|
||||||
|
result1 = backend.get_user_entitlements("sub-123", "user@example.com")
|
||||||
|
assert result1 == {"can_create": True}
|
||||||
|
assert len(responses.calls) == 1
|
||||||
|
|
||||||
|
# Second call should use cache
|
||||||
|
result2 = backend.get_user_entitlements("sub-123", "user@example.com")
|
||||||
|
assert result2 == {"can_create": True}
|
||||||
|
assert len(responses.calls) == 1 # No additional API call
|
||||||
|
|
||||||
|
|
||||||
|
@responses.activate
|
||||||
|
@override_settings(ENTITLEMENTS_CACHE_TIMEOUT=300)
|
||||||
|
def test_deploycenter_backend_force_refresh_bypasses_cache():
|
||||||
|
"""force_refresh=True should bypass cache and hit the API."""
|
||||||
|
responses.add(
|
||||||
|
responses.GET,
|
||||||
|
DC_URL,
|
||||||
|
json={"entitlements": {"can_create": True}},
|
||||||
|
status=200,
|
||||||
|
)
|
||||||
|
responses.add(
|
||||||
|
responses.GET,
|
||||||
|
DC_URL,
|
||||||
|
json={"entitlements": {"can_create": False}},
|
||||||
|
status=200,
|
||||||
|
)
|
||||||
|
|
||||||
|
backend = DeployCenterEntitlementsBackend(
|
||||||
|
base_url=DC_URL,
|
||||||
|
service_id="meet",
|
||||||
|
api_key="test-key",
|
||||||
|
)
|
||||||
|
|
||||||
|
result1 = backend.get_user_entitlements("sub-123", "user@example.com")
|
||||||
|
assert result1["can_create"] is True
|
||||||
|
|
||||||
|
result2 = backend.get_user_entitlements(
|
||||||
|
"sub-123", "user@example.com", force_refresh=True
|
||||||
|
)
|
||||||
|
assert result2["can_create"] is False
|
||||||
|
assert len(responses.calls) == 2
|
||||||
|
|
||||||
|
|
||||||
|
@responses.activate
|
||||||
|
@override_settings(ENTITLEMENTS_CACHE_TIMEOUT=300)
|
||||||
|
def test_deploycenter_backend_fallback_to_stale_cache():
|
||||||
|
"""When API fails, should return stale cached value if available."""
|
||||||
|
responses.add(
|
||||||
|
responses.GET,
|
||||||
|
DC_URL,
|
||||||
|
json={"entitlements": {"can_create": True}},
|
||||||
|
status=200,
|
||||||
|
)
|
||||||
|
|
||||||
|
backend = DeployCenterEntitlementsBackend(
|
||||||
|
base_url=DC_URL,
|
||||||
|
service_id="meet",
|
||||||
|
api_key="test-key",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Populate cache
|
||||||
|
backend.get_user_entitlements("sub-123", "user@example.com")
|
||||||
|
|
||||||
|
# Now API fails
|
||||||
|
responses.replace(
|
||||||
|
responses.GET,
|
||||||
|
DC_URL,
|
||||||
|
body=requests.ConnectionError("Connection error"),
|
||||||
|
)
|
||||||
|
|
||||||
|
# force_refresh to hit API, but should fall back to cache
|
||||||
|
result = backend.get_user_entitlements(
|
||||||
|
"sub-123", "user@example.com", force_refresh=True
|
||||||
|
)
|
||||||
|
assert result == {"can_create": True}
|
||||||
|
|
||||||
|
|
||||||
|
@responses.activate
|
||||||
|
def test_deploycenter_backend_raises_when_no_cache():
|
||||||
|
"""When API fails and no cache exists, should raise."""
|
||||||
|
responses.add(
|
||||||
|
responses.GET,
|
||||||
|
DC_URL,
|
||||||
|
body=requests.ConnectionError("Connection error"),
|
||||||
|
)
|
||||||
|
|
||||||
|
backend = DeployCenterEntitlementsBackend(
|
||||||
|
base_url=DC_URL,
|
||||||
|
service_id="meet",
|
||||||
|
api_key="test-key",
|
||||||
|
)
|
||||||
|
|
||||||
|
with pytest.raises(EntitlementsUnavailableError):
|
||||||
|
backend.get_user_entitlements("sub-123", "user@example.com")
|
||||||
|
|
||||||
|
|
||||||
|
@responses.activate
|
||||||
|
def test_deploycenter_backend_sends_oidc_claims():
|
||||||
|
"""DeployCenter should forward configured OIDC claims."""
|
||||||
|
responses.add(
|
||||||
|
responses.GET,
|
||||||
|
DC_URL,
|
||||||
|
json={"entitlements": {"can_create": True}},
|
||||||
|
status=200,
|
||||||
|
)
|
||||||
|
|
||||||
|
backend = DeployCenterEntitlementsBackend(
|
||||||
|
base_url=DC_URL,
|
||||||
|
service_id="meet",
|
||||||
|
api_key="test-key",
|
||||||
|
oidc_claims=["organization"],
|
||||||
|
)
|
||||||
|
|
||||||
|
backend.get_user_entitlements(
|
||||||
|
"sub-123",
|
||||||
|
"user@example.com",
|
||||||
|
user_info={"organization": "org-42", "other": "ignored"},
|
||||||
|
)
|
||||||
|
|
||||||
|
request = responses.calls[0].request
|
||||||
|
assert "organization=org-42" in request.url
|
||||||
|
assert "other" not in request.url
|
||||||
|
|
||||||
|
|
||||||
|
# -- Auth backend integration --
|
||||||
|
|
||||||
|
|
||||||
|
def test_auth_backend_warms_cache_on_login():
|
||||||
|
"""post_get_or_create_user should call get_user_entitlements with force_refresh."""
|
||||||
|
user = factories.UserFactory()
|
||||||
|
backend = OIDCAuthenticationBackend()
|
||||||
|
|
||||||
|
with mock.patch(
|
||||||
|
"core.authentication.backends.get_user_entitlements",
|
||||||
|
return_value={"can_create": True},
|
||||||
|
) as mock_ent:
|
||||||
|
backend.post_get_or_create_user(
|
||||||
|
user, {"email": user.email, "sub": "x"}, is_new_user=False
|
||||||
|
)
|
||||||
|
mock_ent.assert_called_once_with(
|
||||||
|
user_sub=user.sub,
|
||||||
|
user_email=user.email,
|
||||||
|
user_info={"email": user.email, "sub": "x"},
|
||||||
|
force_refresh=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_auth_backend_login_succeeds_when_access_denied():
|
||||||
|
"""Login should succeed even when can_create is False (gated in frontend)."""
|
||||||
|
user = factories.UserFactory()
|
||||||
|
backend = OIDCAuthenticationBackend()
|
||||||
|
|
||||||
|
with mock.patch(
|
||||||
|
"core.authentication.backends.get_user_entitlements",
|
||||||
|
return_value={"can_create": False},
|
||||||
|
):
|
||||||
|
# Should not raise — user logs in, frontend gates access
|
||||||
|
backend.post_get_or_create_user(
|
||||||
|
user, {"email": user.email}, is_new_user=False
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_auth_backend_login_succeeds_when_entitlements_unavailable():
|
||||||
|
"""Login should succeed when entitlements service is unavailable."""
|
||||||
|
user = factories.UserFactory()
|
||||||
|
backend = OIDCAuthenticationBackend()
|
||||||
|
|
||||||
|
with mock.patch(
|
||||||
|
"core.authentication.backends.get_user_entitlements",
|
||||||
|
side_effect=EntitlementsUnavailableError("unavailable"),
|
||||||
|
):
|
||||||
|
# Should not raise
|
||||||
|
backend.post_get_or_create_user(
|
||||||
|
user, {"email": user.email}, is_new_user=False
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# -- UserMeSerializer (can_create field) --
|
||||||
|
|
||||||
|
|
||||||
|
def test_user_me_serializer_includes_can_create_true():
|
||||||
|
"""UserMeSerializer should include can_create=True when entitled."""
|
||||||
|
user = factories.UserFactory()
|
||||||
|
with mock.patch(
|
||||||
|
"core.api.serializers.get_user_entitlements",
|
||||||
|
return_value={"can_create": True},
|
||||||
|
):
|
||||||
|
data = UserMeSerializer(user).data
|
||||||
|
assert data["can_create"] is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_user_me_serializer_includes_can_create_false():
|
||||||
|
"""UserMeSerializer should include can_create=False when not entitled."""
|
||||||
|
user = factories.UserFactory()
|
||||||
|
with mock.patch(
|
||||||
|
"core.api.serializers.get_user_entitlements",
|
||||||
|
return_value={"can_create": False},
|
||||||
|
):
|
||||||
|
data = UserMeSerializer(user).data
|
||||||
|
assert data["can_create"] is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_user_me_serializer_can_create_fail_closed():
|
||||||
|
"""UserMeSerializer should return can_create=False when entitlements unavailable."""
|
||||||
|
user = factories.UserFactory()
|
||||||
|
with mock.patch(
|
||||||
|
"core.api.serializers.get_user_entitlements",
|
||||||
|
side_effect=EntitlementsUnavailableError("unavailable"),
|
||||||
|
):
|
||||||
|
data = UserMeSerializer(user).data
|
||||||
|
assert data["can_create"] is False
|
||||||
|
|
||||||
|
|
||||||
|
# -- /users/me/ endpoint integration --
|
||||||
|
|
||||||
|
|
||||||
|
def test_api_users_me_includes_can_create():
|
||||||
|
"""GET /users/me/ should include can_create in the response."""
|
||||||
|
user = factories.UserFactory()
|
||||||
|
client = APIClient()
|
||||||
|
client.force_login(user)
|
||||||
|
|
||||||
|
response = client.get("/api/v1.0/users/me/")
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert "can_create" in response.json()
|
||||||
|
assert response.json()["can_create"] is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_api_users_me_can_create_false():
|
||||||
|
"""GET /users/me/ should return can_create=False when not entitled."""
|
||||||
|
user = factories.UserFactory()
|
||||||
|
client = APIClient()
|
||||||
|
client.force_login(user)
|
||||||
|
|
||||||
|
with mock.patch(
|
||||||
|
"core.api.serializers.get_user_entitlements",
|
||||||
|
return_value={"can_create": False},
|
||||||
|
):
|
||||||
|
response = client.get("/api/v1.0/users/me/")
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.json()["can_create"] is False
|
||||||
|
|
||||||
|
|
||||||
|
# -- Room creation entitlements enforcement --
|
||||||
|
|
||||||
|
|
||||||
|
def test_room_creation_blocked_when_not_entitled():
|
||||||
|
"""Room creation should return 403 when user has can_create=False."""
|
||||||
|
user = factories.UserFactory()
|
||||||
|
client = APIClient()
|
||||||
|
client.force_login(user)
|
||||||
|
|
||||||
|
with mock.patch(
|
||||||
|
"core.api.permissions.get_user_entitlements",
|
||||||
|
return_value={"can_create": False},
|
||||||
|
):
|
||||||
|
response = client.post(
|
||||||
|
"/api/v1.0/rooms/",
|
||||||
|
data={"name": "test-room"},
|
||||||
|
format="json",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == HTTP_403_FORBIDDEN
|
||||||
|
|
||||||
|
|
||||||
|
def test_room_creation_blocked_when_entitlements_unavailable():
|
||||||
|
"""Room creation should return 403 when entitlements service
|
||||||
|
is unavailable (fail-closed)."""
|
||||||
|
user = factories.UserFactory()
|
||||||
|
client = APIClient()
|
||||||
|
client.force_login(user)
|
||||||
|
|
||||||
|
with mock.patch(
|
||||||
|
"core.api.permissions.get_user_entitlements",
|
||||||
|
side_effect=EntitlementsUnavailableError("unavailable"),
|
||||||
|
):
|
||||||
|
response = client.post(
|
||||||
|
"/api/v1.0/rooms/",
|
||||||
|
data={"name": "test-room"},
|
||||||
|
format="json",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == HTTP_403_FORBIDDEN
|
||||||
|
|
||||||
|
|
||||||
|
def test_room_creation_allowed_when_entitled():
|
||||||
|
"""Room creation should succeed when user has can_create=True."""
|
||||||
|
user = factories.UserFactory()
|
||||||
|
client = APIClient()
|
||||||
|
client.force_login(user)
|
||||||
|
|
||||||
|
with mock.patch(
|
||||||
|
"core.api.permissions.get_user_entitlements",
|
||||||
|
return_value={"can_create": True},
|
||||||
|
):
|
||||||
|
response = client.post(
|
||||||
|
"/api/v1.0/rooms/",
|
||||||
|
data={"name": "test-room"},
|
||||||
|
format="json",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == HTTP_201_CREATED
|
||||||
|
|
||||||
|
|
||||||
|
# -- Non-create room actions are NOT gated by entitlements --
|
||||||
|
|
||||||
|
|
||||||
|
def test_room_retrieve_allowed_when_not_entitled():
|
||||||
|
"""Room retrieval should work even when user has can_create=False."""
|
||||||
|
user = factories.UserFactory()
|
||||||
|
room = factories.RoomFactory()
|
||||||
|
client = APIClient()
|
||||||
|
client.force_login(user)
|
||||||
|
|
||||||
|
with mock.patch(
|
||||||
|
"core.api.permissions.get_user_entitlements",
|
||||||
|
return_value={"can_create": False},
|
||||||
|
):
|
||||||
|
response = client.get(f"/api/v1.0/rooms/{room.id}/")
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
|
||||||
|
|
||||||
|
def test_room_list_allowed_when_not_entitled():
|
||||||
|
"""Room listing should work even when user has can_create=False."""
|
||||||
|
user = factories.UserFactory()
|
||||||
|
client = APIClient()
|
||||||
|
client.force_login(user)
|
||||||
|
|
||||||
|
with mock.patch(
|
||||||
|
"core.api.permissions.get_user_entitlements",
|
||||||
|
return_value={"can_create": False},
|
||||||
|
):
|
||||||
|
response = client.get("/api/v1.0/rooms/")
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
@@ -706,6 +706,23 @@ class Base(Configuration):
|
|||||||
environ_prefix=None,
|
environ_prefix=None,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Entitlements
|
||||||
|
ENTITLEMENTS_BACKEND = values.Value(
|
||||||
|
"core.entitlements.backends.local.LocalEntitlementsBackend",
|
||||||
|
environ_name="ENTITLEMENTS_BACKEND",
|
||||||
|
environ_prefix=None,
|
||||||
|
)
|
||||||
|
ENTITLEMENTS_BACKEND_PARAMETERS = values.DictValue(
|
||||||
|
{},
|
||||||
|
environ_name="ENTITLEMENTS_BACKEND_PARAMETERS",
|
||||||
|
environ_prefix=None,
|
||||||
|
)
|
||||||
|
ENTITLEMENTS_CACHE_TIMEOUT = values.PositiveIntegerValue(
|
||||||
|
300, # 5 minutes
|
||||||
|
environ_name="ENTITLEMENTS_CACHE_TIMEOUT",
|
||||||
|
environ_prefix=None,
|
||||||
|
)
|
||||||
|
|
||||||
# Calendar integrations
|
# Calendar integrations
|
||||||
ROOM_CREATION_CALLBACK_CACHE_TIMEOUT = values.PositiveIntegerValue(
|
ROOM_CREATION_CALLBACK_CACHE_TIMEOUT = values.PositiveIntegerValue(
|
||||||
600, # 10 minutes
|
600, # 10 minutes
|
||||||
|
|||||||
@@ -7,4 +7,5 @@ export type ApiUser = {
|
|||||||
last_name: string
|
last_name: string
|
||||||
language: BackendLanguage
|
language: BackendLanguage
|
||||||
timezone: string
|
timezone: string
|
||||||
|
can_create?: boolean
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -148,7 +148,8 @@ const IntroText = styled('div', {
|
|||||||
|
|
||||||
export const Home = () => {
|
export const Home = () => {
|
||||||
const { t } = useTranslation('home')
|
const { t } = useTranslation('home')
|
||||||
const { isLoggedIn } = useUser()
|
const { isLoggedIn, user } = useUser()
|
||||||
|
const canCreate = user?.can_create === true
|
||||||
|
|
||||||
const {
|
const {
|
||||||
userChoices: { username },
|
userChoices: { username },
|
||||||
@@ -200,45 +201,56 @@ export const Home = () => {
|
|||||||
})}
|
})}
|
||||||
>
|
>
|
||||||
{isLoggedIn ? (
|
{isLoggedIn ? (
|
||||||
<Menu>
|
canCreate ? (
|
||||||
<Button variant="primary" data-attr="create-meeting">
|
<Menu>
|
||||||
{t('createMeeting')}
|
<Button variant="primary" data-attr="create-meeting">
|
||||||
</Button>
|
{t('createMeeting')}
|
||||||
<RACMenu>
|
</Button>
|
||||||
<MenuItem
|
<RACMenu>
|
||||||
className={
|
<MenuItem
|
||||||
menuRecipe({ icon: true, variant: 'light' }).item
|
className={
|
||||||
}
|
menuRecipe({ icon: true, variant: 'light' }).item
|
||||||
onAction={async () => {
|
}
|
||||||
const slug = generateRoomId()
|
onAction={async () => {
|
||||||
createRoom({ slug, username }).then((data) =>
|
const slug = generateRoomId()
|
||||||
navigateTo('room', data.slug, {
|
createRoom({ slug, username }).then((data) =>
|
||||||
state: { create: true, initialRoomData: data },
|
navigateTo('room', data.slug, {
|
||||||
})
|
state: { create: true, initialRoomData: data },
|
||||||
)
|
})
|
||||||
}}
|
)
|
||||||
data-attr="create-option-instant"
|
}}
|
||||||
>
|
data-attr="create-option-instant"
|
||||||
<RiAddLine size={18} />
|
>
|
||||||
{t('createMenu.instantOption')}
|
<RiAddLine size={18} />
|
||||||
</MenuItem>
|
{t('createMenu.instantOption')}
|
||||||
<MenuItem
|
</MenuItem>
|
||||||
className={
|
<MenuItem
|
||||||
menuRecipe({ icon: true, variant: 'light' }).item
|
className={
|
||||||
}
|
menuRecipe({ icon: true, variant: 'light' }).item
|
||||||
onAction={() => {
|
}
|
||||||
const slug = generateRoomId()
|
onAction={() => {
|
||||||
createRoom({ slug, username }).then((data) =>
|
const slug = generateRoomId()
|
||||||
setLaterRoom(data)
|
createRoom({ slug, username }).then((data) =>
|
||||||
)
|
setLaterRoom(data)
|
||||||
}}
|
)
|
||||||
data-attr="create-option-later"
|
}}
|
||||||
>
|
data-attr="create-option-later"
|
||||||
<RiLink size={18} />
|
>
|
||||||
{t('createMenu.laterOption')}
|
<RiLink size={18} />
|
||||||
</MenuItem>
|
{t('createMenu.laterOption')}
|
||||||
</RACMenu>
|
</MenuItem>
|
||||||
</Menu>
|
</RACMenu>
|
||||||
|
</Menu>
|
||||||
|
) : (
|
||||||
|
<p
|
||||||
|
className={css({
|
||||||
|
color: 'greyscale.700',
|
||||||
|
fontSize: '0.95rem',
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
{t('noAccess')}
|
||||||
|
</p>
|
||||||
|
)
|
||||||
) : (
|
) : (
|
||||||
<LoginButton proConnectHint={false} />
|
<LoginButton proConnectHint={false} />
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -10,7 +10,10 @@ const callbackIdHandler = new CallbackIdHandler()
|
|||||||
const popupWindow = new PopupWindow()
|
const popupWindow = new PopupWindow()
|
||||||
|
|
||||||
export const CreatePopup = () => {
|
export const CreatePopup = () => {
|
||||||
const { isLoggedIn } = useUser({ fetchUserOptions: { attemptSilent: false } })
|
const { isLoggedIn, user } = useUser({
|
||||||
|
fetchUserOptions: { attemptSilent: false },
|
||||||
|
})
|
||||||
|
const canCreate = user?.can_create === true
|
||||||
const { mutateAsync: createRoom } = useCreateRoom()
|
const { mutateAsync: createRoom } = useCreateRoom()
|
||||||
|
|
||||||
const callbackId = useMemo(() => callbackIdHandler.getOrCreate(), [])
|
const callbackId = useMemo(() => callbackIdHandler.getOrCreate(), [])
|
||||||
@@ -55,10 +58,10 @@ export const CreatePopup = () => {
|
|||||||
console.error('Failed to create meeting room:', error)
|
console.error('Failed to create meeting room:', error)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (isLoggedIn && callbackId) {
|
if (isLoggedIn && canCreate && callbackId) {
|
||||||
createMeetingRoom()
|
createMeetingRoom()
|
||||||
}
|
}
|
||||||
}, [isLoggedIn, callbackId, createRoom])
|
}, [isLoggedIn, canCreate, callbackId, createRoom])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
|
|||||||
@@ -10,6 +10,7 @@
|
|||||||
"joinMeetingTipContent": "Sie können einem Meeting beitreten, indem Sie den vollständigen Link in die Adressleiste Ihres Browsers einfügen.",
|
"joinMeetingTipContent": "Sie können einem Meeting beitreten, indem Sie den vollständigen Link in die Adressleiste Ihres Browsers einfügen.",
|
||||||
"joinMeetingTipHeading": "Wussten Sie schon?",
|
"joinMeetingTipHeading": "Wussten Sie schon?",
|
||||||
"loginToCreateMeeting": "Melden Sie sich an, um ein Meeting zu erstellen",
|
"loginToCreateMeeting": "Melden Sie sich an, um ein Meeting zu erstellen",
|
||||||
|
"noAccess": "Sie haben keinen Zugang zur Erstellung von Meetings. Bitte kontaktieren Sie Ihren Administrator.",
|
||||||
"moreLinkLabel": "Mehr erfahren – neues Tab",
|
"moreLinkLabel": "Mehr erfahren – neues Tab",
|
||||||
"moreLink": "Mehr erfahren",
|
"moreLink": "Mehr erfahren",
|
||||||
"moreAbout": "über {{appTitle}}",
|
"moreAbout": "über {{appTitle}}",
|
||||||
|
|||||||
@@ -10,6 +10,7 @@
|
|||||||
"joinMeetingTipContent": "You can join a meeting by pasting its full link in the browser's address bar.",
|
"joinMeetingTipContent": "You can join a meeting by pasting its full link in the browser's address bar.",
|
||||||
"joinMeetingTipHeading": "Did you know?",
|
"joinMeetingTipHeading": "Did you know?",
|
||||||
"loginToCreateMeeting": "Login to create a meeting",
|
"loginToCreateMeeting": "Login to create a meeting",
|
||||||
|
"noAccess": "You do not have access to create meetings. Please contact your administrator.",
|
||||||
"moreLinkLabel": "Learn more - new tab",
|
"moreLinkLabel": "Learn more - new tab",
|
||||||
"moreLink": "Learn more",
|
"moreLink": "Learn more",
|
||||||
"moreAbout": "about {{appTitle}}",
|
"moreAbout": "about {{appTitle}}",
|
||||||
|
|||||||
@@ -10,6 +10,7 @@
|
|||||||
"joinMeetingTipContent": "Vous pouvez rejoindre une réunion en copiant directement son lien complet dans la barre d'adresse du navigateur.",
|
"joinMeetingTipContent": "Vous pouvez rejoindre une réunion en copiant directement son lien complet dans la barre d'adresse du navigateur.",
|
||||||
"joinMeetingTipHeading": "Astuce",
|
"joinMeetingTipHeading": "Astuce",
|
||||||
"loginToCreateMeeting": "Connectez-vous pour créer une réunion",
|
"loginToCreateMeeting": "Connectez-vous pour créer une réunion",
|
||||||
|
"noAccess": "Vous n'avez pas accès à la création de réunions. Veuillez contacter votre administrateur.",
|
||||||
"moreLinkLabel": "En savoir plus - nouvelle fenêtre",
|
"moreLinkLabel": "En savoir plus - nouvelle fenêtre",
|
||||||
"moreLink": "En savoir plus",
|
"moreLink": "En savoir plus",
|
||||||
"moreAbout": "sur {{appTitle}}",
|
"moreAbout": "sur {{appTitle}}",
|
||||||
|
|||||||
@@ -10,6 +10,7 @@
|
|||||||
"joinMeetingTipContent": "U kunt deelnemen aan een vergadering door de volledige link in de adresbalk van de browser te plakken.",
|
"joinMeetingTipContent": "U kunt deelnemen aan een vergadering door de volledige link in de adresbalk van de browser te plakken.",
|
||||||
"joinMeetingTipHeading": "Wist u dat?",
|
"joinMeetingTipHeading": "Wist u dat?",
|
||||||
"loginToCreateMeeting": "Log in om een vergadering te maken",
|
"loginToCreateMeeting": "Log in om een vergadering te maken",
|
||||||
|
"noAccess": "U heeft geen toegang om vergaderingen te maken. Neem contact op met uw beheerder.",
|
||||||
"moreLinkLabel": "Meer informatie - nieuw tabblad",
|
"moreLinkLabel": "Meer informatie - nieuw tabblad",
|
||||||
"moreLink": "Meer informatie",
|
"moreLink": "Meer informatie",
|
||||||
"moreAbout": "over {{appTitle}}",
|
"moreAbout": "over {{appTitle}}",
|
||||||
|
|||||||
Reference in New Issue
Block a user