Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ca98cf5fac | |||
| e56c0f997e | |||
| 61afd94e3a | |||
| 3d7aec2b4a | |||
| 9c009839f0 | |||
| ec63ddcd47 | |||
| fcde8757e6 |
+7
-1
@@ -8,11 +8,18 @@ and this project adheres to
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Fixed
|
||||
|
||||
- 🩹(frontend) remove incorrect reference to ProConnect on the prejoin #1080
|
||||
- ✨(frontend) add Ctrl+Shift+/ to open shortcuts settings #1050
|
||||
- ♿(frontend) announce selected state to screen readers #1081
|
||||
|
||||
### Changed
|
||||
|
||||
- 🔒️(backend) enhance API input validation to strengthen security #1053
|
||||
- 🦺(backend) strengthen API validation for recording options #1063
|
||||
- ⚡️(frontend) optimize few performance caveats #1073
|
||||
- 🔒️(helm) introduce a dedicated Kubernetes Ingress for webhook-livekit #1066
|
||||
|
||||
### Fixed
|
||||
|
||||
@@ -42,7 +49,6 @@ and this project adheres to
|
||||
- ♿️(frontend) improve accessibility of the IntroSlider carousel #1026
|
||||
- ♿️(frontend) add skip link component for keyboard navigation #1019
|
||||
- ♿️(frontend) announce mic/camera state to SR on shortcut toggle #1052
|
||||
- ✨(frontend) add Ctrl+Shift+/ to open shortcuts settings #1050
|
||||
|
||||
### Fixed
|
||||
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
from rest_framework import permissions
|
||||
|
||||
from core.entitlements import EntitlementsUnavailableError, get_user_entitlements
|
||||
|
||||
from ..models import RoleChoices
|
||||
|
||||
ACTION_FOR_METHOD_TO_PERMISSION = {
|
||||
@@ -45,11 +47,27 @@ class RoomPermissions(permissions.BasePermission):
|
||||
"""
|
||||
|
||||
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:
|
||||
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):
|
||||
"""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 core import models, utils
|
||||
from core.entitlements import EntitlementsUnavailableError, get_user_entitlements
|
||||
|
||||
|
||||
class UserSerializer(serializers.ModelSerializer):
|
||||
@@ -27,6 +28,25 @@ class UserSerializer(serializers.ModelSerializer):
|
||||
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:
|
||||
"""
|
||||
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}
|
||||
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."""
|
||||
|
||||
import contextlib
|
||||
import logging
|
||||
|
||||
from django.conf import settings
|
||||
from django.core.exceptions import ImproperlyConfigured, SuspiciousOperation
|
||||
@@ -10,6 +11,7 @@ from lasuite.oidc_login.backends import (
|
||||
OIDCAuthenticationBackend as LaSuiteOIDCAuthenticationBackend,
|
||||
)
|
||||
|
||||
from core.entitlements import EntitlementsUnavailableError, get_user_entitlements
|
||||
from core.models import User
|
||||
from core.services.marketing import (
|
||||
ContactCreationError,
|
||||
@@ -17,6 +19,8 @@ from core.services.marketing import (
|
||||
get_marketing_service,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class OIDCAuthenticationBackend(LaSuiteOIDCAuthenticationBackend):
|
||||
"""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:
|
||||
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
|
||||
def signup_to_marketing_email(email):
|
||||
"""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,
|
||||
"language": user.language,
|
||||
"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,
|
||||
)
|
||||
|
||||
# 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
|
||||
ROOM_CREATION_CALLBACK_CACHE_TIMEOUT = values.PositiveIntegerValue(
|
||||
600, # 10 minutes
|
||||
|
||||
Generated
+51
-55
@@ -979,10 +979,11 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@eslint/eslintrc/node_modules/minimatch": {
|
||||
"version": "3.1.2",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
|
||||
"integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==",
|
||||
"version": "3.1.5",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz",
|
||||
"integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"brace-expansion": "^1.1.7"
|
||||
},
|
||||
@@ -1143,10 +1144,11 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@humanwhocodes/config-array/node_modules/minimatch": {
|
||||
"version": "3.1.2",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
|
||||
"integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==",
|
||||
"version": "3.1.5",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz",
|
||||
"integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"brace-expansion": "^1.1.7"
|
||||
},
|
||||
@@ -4493,33 +4495,10 @@
|
||||
"tinyglobby": "^0.2.14"
|
||||
}
|
||||
},
|
||||
"node_modules/@ts-morph/common/node_modules/balanced-match": {
|
||||
"version": "4.0.4",
|
||||
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz",
|
||||
"integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": "18 || 20 || >=22"
|
||||
}
|
||||
},
|
||||
"node_modules/@ts-morph/common/node_modules/brace-expansion": {
|
||||
"version": "5.0.3",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.3.tgz",
|
||||
"integrity": "sha512-fy6KJm2RawA5RcHkLa1z/ScpBeA762UF9KmZQxwIbDtRJrgLzM10depAiEQ+CXYcoiqW1/m96OAAoke2nE9EeA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"balanced-match": "^4.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": "18 || 20 || >=22"
|
||||
}
|
||||
},
|
||||
"node_modules/@ts-morph/common/node_modules/minimatch": {
|
||||
"version": "10.2.2",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.2.tgz",
|
||||
"integrity": "sha512-+G4CpNBxa5MprY+04MbgOw1v7So6n5JY166pFi9KfYwT78fxScCeSNQSNzp6dpPSW2rONOps6Ocam1wFhCgoVw==",
|
||||
"version": "10.2.4",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.4.tgz",
|
||||
"integrity": "sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg==",
|
||||
"dev": true,
|
||||
"license": "BlueOak-1.0.0",
|
||||
"dependencies": {
|
||||
@@ -5414,13 +5393,26 @@
|
||||
"integrity": "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww=="
|
||||
},
|
||||
"node_modules/brace-expansion": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz",
|
||||
"integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==",
|
||||
"version": "5.0.3",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.3.tgz",
|
||||
"integrity": "sha512-fy6KJm2RawA5RcHkLa1z/ScpBeA762UF9KmZQxwIbDtRJrgLzM10depAiEQ+CXYcoiqW1/m96OAAoke2nE9EeA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"balanced-match": "^1.0.0"
|
||||
"balanced-match": "^4.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": "18 || 20 || >=22"
|
||||
}
|
||||
},
|
||||
"node_modules/brace-expansion/node_modules/balanced-match": {
|
||||
"version": "4.0.4",
|
||||
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz",
|
||||
"integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": "18 || 20 || >=22"
|
||||
}
|
||||
},
|
||||
"node_modules/braces": {
|
||||
@@ -6608,9 +6600,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/eslint-plugin-jsx-a11y/node_modules/minimatch": {
|
||||
"version": "3.1.2",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
|
||||
"integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==",
|
||||
"version": "3.1.5",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz",
|
||||
"integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
@@ -6734,10 +6726,11 @@
|
||||
}
|
||||
},
|
||||
"node_modules/eslint/node_modules/minimatch": {
|
||||
"version": "3.1.2",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
|
||||
"integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==",
|
||||
"version": "3.1.5",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz",
|
||||
"integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"brace-expansion": "^1.1.7"
|
||||
},
|
||||
@@ -7391,9 +7384,10 @@
|
||||
}
|
||||
},
|
||||
"node_modules/glob/node_modules/minimatch": {
|
||||
"version": "3.1.2",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
|
||||
"integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==",
|
||||
"version": "3.1.5",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz",
|
||||
"integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"brace-expansion": "^1.1.7"
|
||||
},
|
||||
@@ -8789,9 +8783,10 @@
|
||||
}
|
||||
},
|
||||
"node_modules/matcher-collection/node_modules/minimatch": {
|
||||
"version": "3.1.2",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
|
||||
"integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==",
|
||||
"version": "3.1.5",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz",
|
||||
"integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"brace-expansion": "^1.1.7"
|
||||
},
|
||||
@@ -8891,13 +8886,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/minimatch": {
|
||||
"version": "9.0.5",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz",
|
||||
"integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==",
|
||||
"version": "9.0.8",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.8.tgz",
|
||||
"integrity": "sha512-reYkDYtj/b19TeqbNZCV4q9t+Yxylf/rYBsLb42SXJatTv4/ylq5lEiAmhA/IToxO7NI2UzNMghHoHuaqDkAjw==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"brace-expansion": "^2.0.1"
|
||||
"brace-expansion": "^5.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=16 || 14 >=14.17"
|
||||
@@ -11879,9 +11874,10 @@
|
||||
}
|
||||
},
|
||||
"node_modules/walk-sync/node_modules/minimatch": {
|
||||
"version": "3.1.2",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
|
||||
"integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==",
|
||||
"version": "3.1.5",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz",
|
||||
"integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"brace-expansion": "^1.1.7"
|
||||
},
|
||||
|
||||
@@ -7,4 +7,5 @@ export type ApiUser = {
|
||||
last_name: string
|
||||
language: BackendLanguage
|
||||
timezone: string
|
||||
can_create?: boolean
|
||||
}
|
||||
|
||||
@@ -148,7 +148,8 @@ const IntroText = styled('div', {
|
||||
|
||||
export const Home = () => {
|
||||
const { t } = useTranslation('home')
|
||||
const { isLoggedIn } = useUser()
|
||||
const { isLoggedIn, user } = useUser()
|
||||
const canCreate = user?.can_create === true
|
||||
|
||||
const {
|
||||
userChoices: { username },
|
||||
@@ -200,45 +201,56 @@ export const Home = () => {
|
||||
})}
|
||||
>
|
||||
{isLoggedIn ? (
|
||||
<Menu>
|
||||
<Button variant="primary" data-attr="create-meeting">
|
||||
{t('createMeeting')}
|
||||
</Button>
|
||||
<RACMenu>
|
||||
<MenuItem
|
||||
className={
|
||||
menuRecipe({ icon: true, variant: 'light' }).item
|
||||
}
|
||||
onAction={async () => {
|
||||
const slug = generateRoomId()
|
||||
createRoom({ slug, username }).then((data) =>
|
||||
navigateTo('room', data.slug, {
|
||||
state: { create: true, initialRoomData: data },
|
||||
})
|
||||
)
|
||||
}}
|
||||
data-attr="create-option-instant"
|
||||
>
|
||||
<RiAddLine size={18} />
|
||||
{t('createMenu.instantOption')}
|
||||
</MenuItem>
|
||||
<MenuItem
|
||||
className={
|
||||
menuRecipe({ icon: true, variant: 'light' }).item
|
||||
}
|
||||
onAction={() => {
|
||||
const slug = generateRoomId()
|
||||
createRoom({ slug, username }).then((data) =>
|
||||
setLaterRoom(data)
|
||||
)
|
||||
}}
|
||||
data-attr="create-option-later"
|
||||
>
|
||||
<RiLink size={18} />
|
||||
{t('createMenu.laterOption')}
|
||||
</MenuItem>
|
||||
</RACMenu>
|
||||
</Menu>
|
||||
canCreate ? (
|
||||
<Menu>
|
||||
<Button variant="primary" data-attr="create-meeting">
|
||||
{t('createMeeting')}
|
||||
</Button>
|
||||
<RACMenu>
|
||||
<MenuItem
|
||||
className={
|
||||
menuRecipe({ icon: true, variant: 'light' }).item
|
||||
}
|
||||
onAction={async () => {
|
||||
const slug = generateRoomId()
|
||||
createRoom({ slug, username }).then((data) =>
|
||||
navigateTo('room', data.slug, {
|
||||
state: { create: true, initialRoomData: data },
|
||||
})
|
||||
)
|
||||
}}
|
||||
data-attr="create-option-instant"
|
||||
>
|
||||
<RiAddLine size={18} />
|
||||
{t('createMenu.instantOption')}
|
||||
</MenuItem>
|
||||
<MenuItem
|
||||
className={
|
||||
menuRecipe({ icon: true, variant: 'light' }).item
|
||||
}
|
||||
onAction={() => {
|
||||
const slug = generateRoomId()
|
||||
createRoom({ slug, username }).then((data) =>
|
||||
setLaterRoom(data)
|
||||
)
|
||||
}}
|
||||
data-attr="create-option-later"
|
||||
>
|
||||
<RiLink size={18} />
|
||||
{t('createMenu.laterOption')}
|
||||
</MenuItem>
|
||||
</RACMenu>
|
||||
</Menu>
|
||||
) : (
|
||||
<p
|
||||
className={css({
|
||||
color: 'greyscale.700',
|
||||
fontSize: '0.95rem',
|
||||
})}
|
||||
>
|
||||
{t('noAccess')}
|
||||
</p>
|
||||
)
|
||||
) : (
|
||||
<LoginButton proConnectHint={false} />
|
||||
)}
|
||||
|
||||
@@ -1,5 +1,28 @@
|
||||
import React, { ReactNode } from 'react'
|
||||
import { css } from '@/styled-system/css'
|
||||
import { styled } from '@/styled-system/jsx'
|
||||
|
||||
const Hint = styled('div', {
|
||||
base: {
|
||||
position: 'absolute',
|
||||
top: '0.75rem',
|
||||
right: '0.75rem',
|
||||
backgroundColor: 'rgba(0,0,0,0.5)',
|
||||
color: 'white',
|
||||
borderRadius: 'calc(var(--lk-border-radius) / 2)',
|
||||
paddingInline: '0.5rem',
|
||||
paddingBlock: '0.1rem',
|
||||
fontSize: '0.875rem',
|
||||
opacity: 0,
|
||||
visibility: 'hidden',
|
||||
pointerEvents: 'none',
|
||||
transition: 'opacity 150ms ease',
|
||||
'.lk-grid-layout > *:first-child:focus-within &': {
|
||||
opacity: 1,
|
||||
visibility: 'visible',
|
||||
pointerEvents: 'auto',
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
export interface KeyboardShortcutHintProps {
|
||||
children: ReactNode
|
||||
@@ -12,21 +35,5 @@ export interface KeyboardShortcutHintProps {
|
||||
export const KeyboardShortcutHint: React.FC<KeyboardShortcutHintProps> = ({
|
||||
children,
|
||||
}) => {
|
||||
return (
|
||||
<div
|
||||
className={css({
|
||||
position: 'absolute',
|
||||
top: '0.75rem',
|
||||
right: '0.75rem',
|
||||
backgroundColor: 'rgba(0,0,0,0.5)',
|
||||
color: 'white',
|
||||
borderRadius: 'calc(var(--lk-border-radius) / 2)',
|
||||
paddingInline: '0.5rem',
|
||||
paddingBlock: '0.1rem',
|
||||
fontSize: '0.875rem',
|
||||
})}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
return <Hint>{children}</Hint>
|
||||
}
|
||||
|
||||
@@ -231,9 +231,7 @@ export const ParticipantTile: (
|
||||
)}
|
||||
</ParticipantContextIfNeeded>
|
||||
</TrackRefContextIfNeeded>
|
||||
<div className="shortcut-hint-wrapper">
|
||||
<KeyboardShortcutHint>{t('toolbarHint')}</KeyboardShortcutHint>
|
||||
</div>
|
||||
<KeyboardShortcutHint>{t('toolbarHint')}</KeyboardShortcutHint>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
|
||||
@@ -10,7 +10,10 @@ const callbackIdHandler = new CallbackIdHandler()
|
||||
const popupWindow = new PopupWindow()
|
||||
|
||||
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 callbackId = useMemo(() => callbackIdHandler.getOrCreate(), [])
|
||||
@@ -55,10 +58,10 @@ export const CreatePopup = () => {
|
||||
console.error('Failed to create meeting room:', error)
|
||||
}
|
||||
}
|
||||
if (isLoggedIn && callbackId) {
|
||||
if (isLoggedIn && canCreate && callbackId) {
|
||||
createMeetingRoom()
|
||||
}
|
||||
}, [isLoggedIn, callbackId, createRoom])
|
||||
}, [isLoggedIn, canCreate, callbackId, createRoom])
|
||||
|
||||
return (
|
||||
<div
|
||||
|
||||
@@ -25,6 +25,7 @@
|
||||
"heading": "Überprüfen Sie Ihren Meeting-Code",
|
||||
"body": "Stellen Sie sicher, dass Sie den richtigen Meeting-Code in der URL eingegeben haben. Beispiel:"
|
||||
},
|
||||
"selected": "ausgewählt",
|
||||
"submit": "OK",
|
||||
"footer": {
|
||||
"links": {
|
||||
@@ -45,7 +46,7 @@
|
||||
"license": "Etalab 2.0 Lizenz"
|
||||
},
|
||||
"loginHint": {
|
||||
"title": "Melden Sie sich mit Ihrem ProConnect-Konto an",
|
||||
"title": "Melden Sie sich mit Ihrem Konto an",
|
||||
"body": "Statt zu warten, melden Sie sich mit Ihrem ProConnect-Konto an.",
|
||||
"button": {
|
||||
"ariaLabel": "Hinweis schließen",
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
"joinMeetingTipContent": "Sie können einem Meeting beitreten, indem Sie den vollständigen Link in die Adressleiste Ihres Browsers einfügen.",
|
||||
"joinMeetingTipHeading": "Wussten Sie schon?",
|
||||
"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",
|
||||
"moreLink": "Mehr erfahren",
|
||||
"moreAbout": "über {{appTitle}}",
|
||||
|
||||
@@ -25,6 +25,7 @@
|
||||
"heading": "Verify your meeting code",
|
||||
"body": "Check that you have entered the correct meeting code in the URL. Example:"
|
||||
},
|
||||
"selected": "selected",
|
||||
"submit": "OK",
|
||||
"footer": {
|
||||
"links": {
|
||||
@@ -45,7 +46,7 @@
|
||||
"license": "etalab 2.0 license"
|
||||
},
|
||||
"loginHint": {
|
||||
"title": "Log in with your ProConnect account",
|
||||
"title": "Log in with your account",
|
||||
"body": "Instead of waiting, log in with your ProConnect account.",
|
||||
"button": {
|
||||
"ariaLabel": "Close the suggestion",
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
"joinMeetingTipContent": "You can join a meeting by pasting its full link in the browser's address bar.",
|
||||
"joinMeetingTipHeading": "Did you know?",
|
||||
"loginToCreateMeeting": "Login to create a meeting",
|
||||
"noAccess": "You do not have access to create meetings. Please contact your administrator.",
|
||||
"moreLinkLabel": "Learn more - new tab",
|
||||
"moreLink": "Learn more",
|
||||
"moreAbout": "about {{appTitle}}",
|
||||
|
||||
@@ -25,6 +25,7 @@
|
||||
"heading": "Vérifier votre code de réunion",
|
||||
"body": "Vérifiez que vous avez saisi le code de réunion correct dans l'URL. Exemple :"
|
||||
},
|
||||
"selected": "sélectionné",
|
||||
"submit": "OK",
|
||||
"footer": {
|
||||
"links": {
|
||||
@@ -45,7 +46,7 @@
|
||||
"license": "licence etalab 2.0"
|
||||
},
|
||||
"loginHint": {
|
||||
"title": "Connectez-vous avec votre compte ProConnect",
|
||||
"title": "Connectez-vous avec votre compte",
|
||||
"body": "Au lieu de patienter, connectez-vous avec votre compte ProConnect.",
|
||||
"button": {
|
||||
"ariaLabel": "Fermer la suggestion",
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
"joinMeetingTipContent": "Vous pouvez rejoindre une réunion en copiant directement son lien complet dans la barre d'adresse du navigateur.",
|
||||
"joinMeetingTipHeading": "Astuce",
|
||||
"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",
|
||||
"moreLink": "En savoir plus",
|
||||
"moreAbout": "sur {{appTitle}}",
|
||||
|
||||
@@ -24,6 +24,7 @@
|
||||
"notFound": {
|
||||
"heading": "Pagina niet gevonden"
|
||||
},
|
||||
"selected": "geselecteerd",
|
||||
"submit": "OK",
|
||||
"footer": {
|
||||
"links": {
|
||||
@@ -44,7 +45,7 @@
|
||||
"license": "etalab 2.0 licentie"
|
||||
},
|
||||
"loginHint": {
|
||||
"title": "Log in met je ProConnect-account",
|
||||
"title": "Log in met je account",
|
||||
"body": "In plaats van te wachten, log in met je ProConnect-account.",
|
||||
"button": {
|
||||
"ariaLabel": "Sluit de suggestie",
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
"joinMeetingTipContent": "U kunt deelnemen aan een vergadering door de volledige link in de adresbalk van de browser te plakken.",
|
||||
"joinMeetingTipHeading": "Wist u dat?",
|
||||
"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",
|
||||
"moreLink": "Meer informatie",
|
||||
"moreAbout": "over {{appTitle}}",
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { ReactNode } from 'react'
|
||||
import { Menu, MenuProps, MenuItem } from 'react-aria-components'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { VisuallyHidden } from '@/styled-system/jsx'
|
||||
import { menuRecipe } from '@/primitives/menuRecipe.ts'
|
||||
import type { RecipeVariantProps } from '@/styled-system/types'
|
||||
|
||||
@@ -19,6 +21,7 @@ export const MenuList = <T extends string | number = string>({
|
||||
} & MenuProps<unknown> &
|
||||
RecipeVariantProps<typeof menuRecipe>) => {
|
||||
const [variantProps] = menuRecipe.splitVariantProps(menuProps)
|
||||
const { t } = useTranslation('global')
|
||||
const classes = menuRecipe({
|
||||
extraPadding: true,
|
||||
variant: variant,
|
||||
@@ -39,11 +42,19 @@ export const MenuList = <T extends string | number = string>({
|
||||
className={classes.item}
|
||||
key={value}
|
||||
id={value as string}
|
||||
textValue={typeof label === 'string' ? label : undefined}
|
||||
onAction={() => {
|
||||
onAction(value as T)
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
{({ isSelected }) => (
|
||||
<>
|
||||
{label}
|
||||
{isSelected && (
|
||||
<VisuallyHidden>, {t('selected')}</VisuallyHidden>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</MenuItem>
|
||||
)
|
||||
})}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { type ReactNode } from 'react'
|
||||
import { styled } from '@/styled-system/jsx'
|
||||
import { styled, VisuallyHidden } from '@/styled-system/jsx'
|
||||
import { RemixiconComponentType, RiArrowDropDownLine } from '@remixicon/react'
|
||||
import {
|
||||
Button,
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
SelectProps as RACSelectProps,
|
||||
SelectValue,
|
||||
} from 'react-aria-components'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Box } from './Box'
|
||||
import { StyledPopover } from './Popover'
|
||||
import { menuRecipe } from '@/primitives/menuRecipe.ts'
|
||||
@@ -110,6 +111,7 @@ export const Select = <T extends string | number>({
|
||||
...props
|
||||
}: SelectProps<T>) => {
|
||||
const IconComponent = iconComponent
|
||||
const { t } = useTranslation('global')
|
||||
return (
|
||||
<RACSelect {...props}>
|
||||
{label}
|
||||
@@ -138,8 +140,18 @@ export const Select = <T extends string | number>({
|
||||
}
|
||||
id={item.value}
|
||||
key={item.value}
|
||||
textValue={
|
||||
typeof item.label === 'string' ? item.label : undefined
|
||||
}
|
||||
>
|
||||
{item.label}
|
||||
{({ isSelected }) => (
|
||||
<>
|
||||
{item.label}
|
||||
{isSelected && (
|
||||
<VisuallyHidden>, {t('selected')}</VisuallyHidden>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</ListBoxItem>
|
||||
))}
|
||||
</ListBox>
|
||||
|
||||
@@ -154,7 +154,7 @@
|
||||
|
||||
/* Participant name ellipsis: truncate when overflowing */
|
||||
.lk-participant-metadata {
|
||||
width: 100%;
|
||||
gap: 1rem;
|
||||
}
|
||||
.lk-participant-metadata > *:first-child {
|
||||
min-width: 0;
|
||||
@@ -172,16 +172,3 @@
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
/* Shortcut hint: visible only on first grid tile when focused (CSS-based) */
|
||||
.shortcut-hint-wrapper {
|
||||
opacity: 0;
|
||||
visibility: hidden;
|
||||
pointer-events: none;
|
||||
transition: opacity 150ms ease;
|
||||
}
|
||||
.lk-grid-layout > *:first-child:focus-within .shortcut-hint-wrapper {
|
||||
opacity: 1;
|
||||
visibility: visible;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
@@ -128,6 +128,10 @@ ingressAdmin:
|
||||
enabled: true
|
||||
host: meet.127.0.0.1.nip.io
|
||||
|
||||
ingressWebhook:
|
||||
enabled: true
|
||||
host: meet.127.0.0.1.nip.io
|
||||
|
||||
posthog:
|
||||
ingress:
|
||||
enabled: false
|
||||
|
||||
@@ -141,6 +141,10 @@ ingressAdmin:
|
||||
enabled: true
|
||||
host: meet.127.0.0.1.nip.io
|
||||
|
||||
ingressWebhook:
|
||||
enabled: true
|
||||
host: meet.127.0.0.1.nip.io
|
||||
|
||||
posthog:
|
||||
ingress:
|
||||
enabled: false
|
||||
|
||||
@@ -156,6 +156,10 @@ ingressAdmin:
|
||||
enabled: true
|
||||
host: meet.127.0.0.1.nip.io
|
||||
|
||||
ingressWebhook:
|
||||
enabled: true
|
||||
host: meet.127.0.0.1.nip.io
|
||||
|
||||
posthog:
|
||||
ingress:
|
||||
enabled: false
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
apiVersion: v2
|
||||
type: application
|
||||
name: meet
|
||||
version: 0.0.15
|
||||
version: 0.0.16
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
{{- if .Values.ingressWebhook.enabled -}}
|
||||
{{- $fullName := include "meet.fullname" . -}}
|
||||
{{- if and .Values.ingressWebhook.className (not (semverCompare ">=1.18-0" .Capabilities.KubeVersion.GitVersion)) }}
|
||||
{{- if not (hasKey .Values.ingressWebhook.annotations "kubernetes.io/ingress.class") }}
|
||||
{{- $_ := set .Values.ingressWebhook.annotations "kubernetes.io/ingress.class" .Values.ingressWebhook.className}}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
{{- if semverCompare ">=1.19-0" .Capabilities.KubeVersion.GitVersion -}}
|
||||
apiVersion: networking.k8s.io/v1
|
||||
{{- else if semverCompare ">=1.14-0" .Capabilities.KubeVersion.GitVersion -}}
|
||||
apiVersion: networking.k8s.io/v1beta1
|
||||
{{- else -}}
|
||||
apiVersion: extensions/v1beta1
|
||||
{{- end }}
|
||||
kind: Ingress
|
||||
metadata:
|
||||
name: {{ $fullName }}-webhook
|
||||
namespace: {{ .Release.Namespace | quote }}
|
||||
labels:
|
||||
{{- include "meet.labels" . | nindent 4 }}
|
||||
{{- with .Values.ingressWebhook.annotations }}
|
||||
annotations:
|
||||
{{- toYaml . | nindent 4 }}
|
||||
{{- end }}
|
||||
spec:
|
||||
{{- if and .Values.ingressWebhook.className (semverCompare ">=1.18-0" .Capabilities.KubeVersion.GitVersion) }}
|
||||
ingressClassName: {{ .Values.ingressWebhook.className }}
|
||||
{{- end }}
|
||||
{{- if .Values.ingressWebhook.tls.enabled }}
|
||||
tls:
|
||||
{{- if .Values.ingressWebhook.host }}
|
||||
- secretName: {{ .Values.ingressWebhook.tls.secretName | default (printf "%s-tls" $fullName) | quote }}
|
||||
hosts:
|
||||
- {{ .Values.ingressWebhook.host | quote }}
|
||||
{{- end }}
|
||||
{{- range .Values.ingressWebhook.tls.additional }}
|
||||
- hosts:
|
||||
{{- range .hosts }}
|
||||
- {{ . | quote }}
|
||||
{{- end }}
|
||||
secretName: {{ .secretName }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
rules:
|
||||
{{- if .Values.ingressWebhook.host }}
|
||||
- host: {{ .Values.ingressWebhook.host | quote }}
|
||||
http:
|
||||
paths:
|
||||
- path: {{ .Values.ingressWebhook.path }}
|
||||
{{- if semverCompare ">=1.18-0" $.Capabilities.KubeVersion.GitVersion }}
|
||||
pathType: Exact
|
||||
{{- end }}
|
||||
backend:
|
||||
{{- if semverCompare ">=1.19-0" $.Capabilities.KubeVersion.GitVersion }}
|
||||
service:
|
||||
name: {{ include "meet.backend.fullname" . }}
|
||||
port:
|
||||
number: {{ .Values.backend.service.port }}
|
||||
{{- else }}
|
||||
serviceName: {{ include "meet.backend.fullname" . }}
|
||||
servicePort: {{ .Values.backend.service.port }}
|
||||
{{- end }}
|
||||
{{- with .Values.ingressWebhook.customBackends }}
|
||||
{{- toYaml . | nindent 10 }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
{{- range .Values.ingressWebhook.hosts }}
|
||||
- host: {{ . | quote }}
|
||||
http:
|
||||
paths:
|
||||
- path: {{ .Values.ingressWebhook.path }}
|
||||
{{- if semverCompare ">=1.18-0" $.Capabilities.KubeVersion.GitVersion }}
|
||||
pathType: Exact
|
||||
{{- end }}
|
||||
backend:
|
||||
{{- if semverCompare ">=1.19-0" $.Capabilities.KubeVersion.GitVersion }}
|
||||
service:
|
||||
name: {{ include "meet.backend.fullname" $ }}
|
||||
port:
|
||||
number: {{ $.Values.backend.service.port }}
|
||||
{{- else }}
|
||||
serviceName: {{ include "meet.backend.fullname" $ }}
|
||||
servicePort: {{ $.Values.backend.service.port }}
|
||||
{{- end }}
|
||||
{{- with $.Values.ingressWebhook.customBackends }}
|
||||
{{- toYaml . | nindent 10 }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
|
||||
@@ -50,6 +50,31 @@ ingress:
|
||||
## @param ingress.customBackends Add custom backends to ingress
|
||||
customBackends: []
|
||||
|
||||
## @param ingressWebhook.enabled whether to enable the Ingress or not
|
||||
## @param ingressWebhook.className IngressClass to use for the Ingress
|
||||
## @param ingressWebhook.host Host for the Ingress
|
||||
## @param ingressWebhook.path Path to use for the Ingress
|
||||
ingressWebhook:
|
||||
enabled: false
|
||||
className: null
|
||||
host: meet.example.com
|
||||
path: /api/v1.0/rooms/webhooks-livekit/
|
||||
## @param ingressWebhook.hosts Additional host to configure for the Ingress
|
||||
hosts: []
|
||||
# - chart-example.local
|
||||
## @param ingressWebhook.tls.enabled Weather to enable TLS for the Ingress
|
||||
## @param ingressWebhook.tls.secretName Secret name for TLS config
|
||||
## @skip ingressWebhook.tls.additional
|
||||
## @extra ingressWebhook.tls.additional[].secretName Secret name for additional TLS config
|
||||
## @extra ingressWebhook.tls.additional[].hosts[] Hosts for additional TLS config
|
||||
tls:
|
||||
secretName: null
|
||||
enabled: true
|
||||
additional: []
|
||||
|
||||
## @param ingressWebhook.customBackends Add custom backends to ingress
|
||||
customBackends: []
|
||||
|
||||
|
||||
## @param ingressAdmin.enabled whether to enable the Ingress or not
|
||||
## @param ingressAdmin.className IngressClass to use for the Ingress
|
||||
|
||||
Reference in New Issue
Block a user