Compare commits
21 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 68ce56c1c8 | |||
| c289f79d8e | |||
| b79fa14919 | |||
| c7c0df5b6d | |||
| cb00347be6 | |||
| bcb004ab4b | |||
| 422f838899 | |||
| 462c6c50e5 | |||
| 63565b38c3 | |||
| 10d759bdbb | |||
| 51f1f0ebbf | |||
| 9e27d0f345 | |||
| 978d931bd7 | |||
| 0c811222d4 | |||
| 94171dcb82 | |||
| 56c1cd98fa | |||
| f2e6edb90d | |||
| bc76c44fe9 | |||
| e519f00342 | |||
| 2246bb7782 | |||
| e210f26f9c |
@@ -16,7 +16,7 @@
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://livekit.io/">LiveKit</a> - <a href="https://go.crisp.chat/chat/embed/?website_id=58ea6697-8eba-4492-bc59-ad6562585041">Chat with us</a> - <a href="https://github.com/orgs/suitenumerique/projects/3/views/2">Roadmap</a> - <a href="https://github.com/suitenumerique/meet/blob/main/CHANGELOG.md">Changelog</a> - <a href="https://github.com/suitenumerique/meet/issues/new?assignees=&labels=bug&template=Bug_report.md">Bug reports</a>
|
||||
<a href="https://livekit.io/">LiveKit</a> - <a href="https://matrix.to/#/#meet-official:matrix.org">Chat with us</a> - <a href="https://github.com/orgs/suitenumerique/projects/3/views/2">Roadmap</a> - <a href="https://github.com/suitenumerique/meet/blob/main/CHANGELOG.md">Changelog</a> - <a href="https://github.com/suitenumerique/meet/issues/new?assignees=&labels=bug&template=Bug_report.md">Bug reports</a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
|
||||
@@ -287,6 +287,7 @@ These are the environmental options available on meet backend.
|
||||
| OIDC_OP_AUTHORIZATION_ENDPOINT | oidc endpoint for authorization | |
|
||||
| OIDC_OP_TOKEN_ENDPOINT | oidc endpoint for token | |
|
||||
| OIDC_OP_USER_ENDPOINT | oidc endpoint for user | |
|
||||
| OIDC_OP_USER_ENDPOINT_FORMAT | oidc endpoint format (AUTO, JWT, JSON) | AUTO |
|
||||
| OIDC_OP_LOGOUT_ENDPOINT | oidc endpoint for logout | |
|
||||
| OIDC_AUTH_REQUEST_EXTRA_PARAMS | extra parameters for oidc request | |
|
||||
| OIDC_RP_SCOPES | oidc scopes | openid email |
|
||||
@@ -301,6 +302,7 @@ These are the environmental options available on meet backend.
|
||||
| OIDC_REDIRECT_FIELD_NAME | direct field for oidc | returnTo |
|
||||
| OIDC_USERINFO_FULLNAME_FIELDS | full name claim from OIDC token | ["given_name", "usual_name"] |
|
||||
| OIDC_USERINFO_SHORTNAME_FIELD | shortname claim from OIDC token | given_name |
|
||||
| OIDC_USERINFO_ESSENTIAL_CLAIMS | required claims from OIDC token | [] |
|
||||
| LIVEKIT_API_KEY | livekit api key | |
|
||||
| LIVEKIT_API_SECRET | livekit api secret | |
|
||||
| LIVEKIT_API_URL | livekit api url | |
|
||||
|
||||
@@ -120,7 +120,7 @@ class RoomSerializer(serializers.ModelSerializer):
|
||||
role = instance.get_role(request.user)
|
||||
is_admin = models.RoleChoices.check_administrator_role(role)
|
||||
|
||||
if role is not None:
|
||||
if is_admin:
|
||||
access_serializer = NestedResourceAccessSerializer(
|
||||
instance.accesses.select_related("resource", "user").all(),
|
||||
context=self.context,
|
||||
|
||||
@@ -531,40 +531,12 @@ class RoomViewSet(
|
||||
)
|
||||
|
||||
|
||||
class ResourceAccessListModelMixin:
|
||||
"""List mixin for resource access API."""
|
||||
|
||||
def get_permissions(self):
|
||||
"""User only needs to be authenticated to list rooms access"""
|
||||
if self.action == "list":
|
||||
permission_classes = [permissions.IsAuthenticated]
|
||||
else:
|
||||
return super().get_permissions()
|
||||
|
||||
return [permission() for permission in permission_classes]
|
||||
|
||||
def get_queryset(self):
|
||||
"""Return the queryset according to the action."""
|
||||
queryset = super().get_queryset()
|
||||
if self.action == "list":
|
||||
user = self.request.user
|
||||
queryset = queryset.filter(
|
||||
Q(resource__accesses__user=user),
|
||||
resource__accesses__role__in=[
|
||||
models.RoleChoices.ADMIN,
|
||||
models.RoleChoices.OWNER,
|
||||
],
|
||||
).distinct()
|
||||
return queryset
|
||||
|
||||
|
||||
class ResourceAccessViewSet(
|
||||
ResourceAccessListModelMixin,
|
||||
mixins.CreateModelMixin,
|
||||
mixins.DestroyModelMixin,
|
||||
mixins.ListModelMixin,
|
||||
mixins.RetrieveModelMixin,
|
||||
mixins.UpdateModelMixin,
|
||||
mixins.ListModelMixin,
|
||||
viewsets.GenericViewSet,
|
||||
):
|
||||
"""
|
||||
@@ -575,6 +547,25 @@ class ResourceAccessViewSet(
|
||||
queryset = models.ResourceAccess.objects.all()
|
||||
serializer_class = serializers.ResourceAccessSerializer
|
||||
|
||||
def get_queryset(self):
|
||||
"""Return the queryset according to the action."""
|
||||
|
||||
queryset = super().get_queryset()
|
||||
|
||||
# Restrict access to resources the user either has explicit
|
||||
# permissions for or administrative privileges over.
|
||||
if self.action == "list":
|
||||
user = self.request.user
|
||||
queryset = queryset.filter(
|
||||
Q(resource__accesses__user=user),
|
||||
resource__accesses__role__in=[
|
||||
models.RoleChoices.ADMIN,
|
||||
models.RoleChoices.OWNER,
|
||||
],
|
||||
).distinct()
|
||||
|
||||
return queryset
|
||||
|
||||
|
||||
class RecordingViewSet(
|
||||
mixins.DestroyModelMixin,
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
"""Authentication Backends for the Meet core app."""
|
||||
|
||||
import contextlib
|
||||
|
||||
from django.conf import settings
|
||||
from django.core.exceptions import ImproperlyConfigured, SuspiciousOperation
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
import requests
|
||||
from mozilla_django_oidc.auth import (
|
||||
OIDCAuthenticationBackend as MozillaOIDCAuthenticationBackend,
|
||||
from lasuite.oidc_login.backends import (
|
||||
OIDCAuthenticationBackend as LaSuiteOIDCAuthenticationBackend,
|
||||
)
|
||||
|
||||
from core.models import User
|
||||
@@ -17,93 +18,46 @@ from core.services.marketing import (
|
||||
)
|
||||
|
||||
|
||||
class OIDCAuthenticationBackend(MozillaOIDCAuthenticationBackend):
|
||||
class OIDCAuthenticationBackend(LaSuiteOIDCAuthenticationBackend):
|
||||
"""Custom OpenID Connect (OIDC) Authentication Backend.
|
||||
|
||||
This class overrides the default OIDC Authentication Backend to accommodate differences
|
||||
in the User and Identity models, and handles signed and/or encrypted UserInfo response.
|
||||
"""
|
||||
|
||||
def get_userinfo(self, access_token, id_token, payload):
|
||||
"""Return user details dictionary.
|
||||
def get_extra_claims(self, user_info):
|
||||
"""
|
||||
Return extra claims from user_info.
|
||||
|
||||
Parameters:
|
||||
- access_token (str): The access token.
|
||||
- id_token (str): The id token (unused).
|
||||
- payload (dict): The token payload (unused).
|
||||
|
||||
Note: The id_token and payload parameters are unused in this implementation,
|
||||
but were kept to preserve base method signature.
|
||||
|
||||
Note: It handles signed and/or encrypted UserInfo Response. It is required by
|
||||
Agent Connect, which follows the OIDC standard. It forces us to override the
|
||||
base method, which deal with 'application/json' response.
|
||||
Args:
|
||||
user_info (dict): The user information dictionary.
|
||||
|
||||
Returns:
|
||||
- dict: User details dictionary obtained from the OpenID Connect user endpoint.
|
||||
dict: A dictionary of extra claims.
|
||||
|
||||
"""
|
||||
|
||||
user_response = requests.get(
|
||||
self.OIDC_OP_USER_ENDPOINT,
|
||||
headers={"Authorization": f"Bearer {access_token}"},
|
||||
verify=self.get_settings("OIDC_VERIFY_SSL", True),
|
||||
timeout=self.get_settings("OIDC_TIMEOUT", None),
|
||||
proxies=self.get_settings("OIDC_PROXY", None),
|
||||
)
|
||||
user_response.raise_for_status()
|
||||
userinfo = self.verify_token(user_response.text)
|
||||
return userinfo
|
||||
|
||||
def get_or_create_user(self, access_token, id_token, payload):
|
||||
"""Return a User based on userinfo. Get or create a new user if no user matches the Sub.
|
||||
|
||||
Parameters:
|
||||
- access_token (str): The access token.
|
||||
- id_token (str): The ID token.
|
||||
- payload (dict): The user payload.
|
||||
|
||||
Returns:
|
||||
- User: An existing or newly created User instance.
|
||||
|
||||
Raises:
|
||||
- Exception: Raised when user creation is not allowed and no existing user is found.
|
||||
"""
|
||||
|
||||
user_info = self.get_userinfo(access_token, id_token, payload)
|
||||
sub = user_info.get("sub")
|
||||
|
||||
if not sub:
|
||||
raise SuspiciousOperation(
|
||||
_("User info contained no recognizable user identification")
|
||||
)
|
||||
|
||||
email = user_info.get("email")
|
||||
user = self.get_existing_user(sub, email)
|
||||
|
||||
claims = {
|
||||
"email": email,
|
||||
return {
|
||||
# Get user's full name from OIDC fields defined in settings
|
||||
"full_name": self.compute_full_name(user_info),
|
||||
"short_name": user_info.get(settings.OIDC_USERINFO_SHORTNAME_FIELD),
|
||||
}
|
||||
if not user and self.get_settings("OIDC_CREATE_USER", True):
|
||||
user = User.objects.create(
|
||||
sub=sub,
|
||||
password="!", # noqa: S106
|
||||
**claims,
|
||||
)
|
||||
|
||||
if settings.SIGNUP_NEW_USER_TO_MARKETING_EMAIL:
|
||||
self.signup_to_marketing_email(email)
|
||||
def post_get_or_create_user(self, user, claims, is_new_user):
|
||||
"""
|
||||
Post-processing after user creation or retrieval.
|
||||
|
||||
elif not user:
|
||||
return None
|
||||
Args:
|
||||
user (User): The user instance.
|
||||
claims (dict): The claims dictionary.
|
||||
is_new_user (bool): Indicates if the user was newly created.
|
||||
|
||||
if not user.is_active:
|
||||
raise SuspiciousOperation(_("User account is disabled"))
|
||||
Returns:
|
||||
- None
|
||||
|
||||
self.update_user_if_needed(user, claims)
|
||||
|
||||
return user
|
||||
"""
|
||||
email = claims["email"]
|
||||
if is_new_user and email and settings.SIGNUP_NEW_USER_TO_MARKETING_EMAIL:
|
||||
self.signup_to_marketing_email(email)
|
||||
|
||||
@staticmethod
|
||||
def signup_to_marketing_email(email):
|
||||
@@ -116,7 +70,9 @@ class OIDCAuthenticationBackend(MozillaOIDCAuthenticationBackend):
|
||||
|
||||
Note: For a more robust solution, consider using Async task processing (Celery/Django-Q)
|
||||
"""
|
||||
try:
|
||||
with contextlib.suppress(
|
||||
ContactCreationError, ImproperlyConfigured, ImportError
|
||||
):
|
||||
marketing_service = get_marketing_service()
|
||||
contact_data = ContactData(
|
||||
email=email, attributes={"VISIO_SOURCE": ["SIGNIN"]}
|
||||
@@ -124,8 +80,6 @@ class OIDCAuthenticationBackend(MozillaOIDCAuthenticationBackend):
|
||||
marketing_service.create_contact(
|
||||
contact_data, timeout=settings.BREVO_API_TIMEOUT
|
||||
)
|
||||
except (ContactCreationError, ImproperlyConfigured, ImportError):
|
||||
pass
|
||||
|
||||
def get_existing_user(self, sub, email):
|
||||
"""Fetch existing user by sub or email."""
|
||||
@@ -142,32 +96,3 @@ class OIDCAuthenticationBackend(MozillaOIDCAuthenticationBackend):
|
||||
_("Multiple user accounts share a common email.")
|
||||
) from e
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def compute_full_name(user_info):
|
||||
"""Compute user's full name based on OIDC fields in settings."""
|
||||
full_name = " ".join(
|
||||
filter(
|
||||
None,
|
||||
(
|
||||
user_info.get(field)
|
||||
for field in settings.OIDC_USERINFO_FULLNAME_FIELDS
|
||||
),
|
||||
)
|
||||
)
|
||||
return full_name or None
|
||||
|
||||
@staticmethod
|
||||
def update_user_if_needed(user, claims):
|
||||
"""Update user claims if they have changed."""
|
||||
user_fields = vars(user.__class__) # Get available model fields
|
||||
updated_claims = {
|
||||
key: value
|
||||
for key, value in claims.items()
|
||||
if value and key in user_fields and value != getattr(user, key)
|
||||
}
|
||||
|
||||
if not updated_claims:
|
||||
return
|
||||
|
||||
User.objects.filter(sub=user.sub).update(**updated_claims)
|
||||
|
||||
@@ -1,18 +0,0 @@
|
||||
"""Authentication URLs for the People core app."""
|
||||
|
||||
from django.urls import path
|
||||
|
||||
from mozilla_django_oidc.urls import urlpatterns as mozzila_oidc_urls
|
||||
|
||||
from .views import OIDCLogoutCallbackView, OIDCLogoutView
|
||||
|
||||
urlpatterns = [
|
||||
# Override the default 'logout/' path from Mozilla Django OIDC with our custom view.
|
||||
path("logout/", OIDCLogoutView.as_view(), name="oidc_logout_custom"),
|
||||
path(
|
||||
"logout-callback/",
|
||||
OIDCLogoutCallbackView.as_view(),
|
||||
name="oidc_logout_callback",
|
||||
),
|
||||
*mozzila_oidc_urls,
|
||||
]
|
||||
@@ -1,181 +0,0 @@
|
||||
"""Authentication Views for the People core app."""
|
||||
|
||||
import copy
|
||||
from urllib.parse import urlencode
|
||||
|
||||
from django.contrib import auth
|
||||
from django.core.exceptions import SuspiciousOperation
|
||||
from django.http import HttpResponseRedirect
|
||||
from django.urls import reverse
|
||||
from django.utils import crypto
|
||||
|
||||
from mozilla_django_oidc.utils import (
|
||||
absolutify,
|
||||
)
|
||||
from mozilla_django_oidc.views import (
|
||||
OIDCAuthenticationCallbackView as MozillaOIDCAuthenticationCallbackView,
|
||||
)
|
||||
from mozilla_django_oidc.views import (
|
||||
OIDCAuthenticationRequestView as MozillaOIDCAuthenticationRequestView,
|
||||
)
|
||||
from mozilla_django_oidc.views import (
|
||||
OIDCLogoutView as MozillaOIDCOIDCLogoutView,
|
||||
)
|
||||
|
||||
|
||||
class OIDCLogoutView(MozillaOIDCOIDCLogoutView):
|
||||
"""Custom logout view for handling OpenID Connect (OIDC) logout flow.
|
||||
|
||||
Adds support for handling logout callbacks from the identity provider (OP)
|
||||
by initiating the logout flow if the user has an active session.
|
||||
|
||||
The Django session is retained during the logout process to persist the 'state' OIDC parameter.
|
||||
This parameter is crucial for maintaining the integrity of the logout flow between this call
|
||||
and the subsequent callback.
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
def persist_state(request, state):
|
||||
"""Persist the given 'state' parameter in the session's 'oidc_states' dictionary
|
||||
|
||||
This method is used to store the OIDC state parameter in the session, according to the
|
||||
structure expected by Mozilla Django OIDC's 'add_state_and_verifier_and_nonce_to_session'
|
||||
utility function.
|
||||
"""
|
||||
|
||||
if "oidc_states" not in request.session or not isinstance(
|
||||
request.session["oidc_states"], dict
|
||||
):
|
||||
request.session["oidc_states"] = {}
|
||||
|
||||
request.session["oidc_states"][state] = {}
|
||||
request.session.save()
|
||||
|
||||
def construct_oidc_logout_url(self, request):
|
||||
"""Create the redirect URL for interfacing with the OIDC provider.
|
||||
|
||||
Retrieves the necessary parameters from the session and constructs the URL
|
||||
required to initiate logout with the OpenID Connect provider.
|
||||
|
||||
If no ID token is found in the session, the logout flow will not be initiated,
|
||||
and the method will return the default redirect URL.
|
||||
|
||||
The 'state' parameter is generated randomly and persisted in the session to ensure
|
||||
its integrity during the subsequent callback.
|
||||
"""
|
||||
|
||||
oidc_logout_endpoint = self.get_settings("OIDC_OP_LOGOUT_ENDPOINT")
|
||||
|
||||
if not oidc_logout_endpoint:
|
||||
return self.redirect_url
|
||||
|
||||
reverse_url = reverse("oidc_logout_callback")
|
||||
id_token = request.session.get("oidc_id_token", None)
|
||||
|
||||
if not id_token:
|
||||
return self.redirect_url
|
||||
|
||||
query = {
|
||||
"id_token_hint": id_token,
|
||||
"state": crypto.get_random_string(self.get_settings("OIDC_STATE_SIZE", 32)),
|
||||
"post_logout_redirect_uri": absolutify(request, reverse_url),
|
||||
}
|
||||
|
||||
self.persist_state(request, query["state"])
|
||||
|
||||
return f"{oidc_logout_endpoint}?{urlencode(query)}"
|
||||
|
||||
def post(self, request):
|
||||
"""Handle user logout.
|
||||
|
||||
If the user is not authenticated, redirects to the default logout URL.
|
||||
Otherwise, constructs the OIDC logout URL and redirects the user to start
|
||||
the logout process.
|
||||
|
||||
If the user is redirected to the default logout URL, ensure her Django session
|
||||
is terminated.
|
||||
"""
|
||||
|
||||
logout_url = self.redirect_url
|
||||
|
||||
if request.user.is_authenticated:
|
||||
logout_url = self.construct_oidc_logout_url(request)
|
||||
|
||||
# If the user is not redirected to the OIDC provider, ensure logout
|
||||
if logout_url == self.redirect_url:
|
||||
auth.logout(request)
|
||||
|
||||
return HttpResponseRedirect(logout_url)
|
||||
|
||||
|
||||
class OIDCLogoutCallbackView(MozillaOIDCOIDCLogoutView):
|
||||
"""Custom view for handling the logout callback from the OpenID Connect (OIDC) provider.
|
||||
|
||||
Handles the callback after logout from the identity provider (OP).
|
||||
Verifies the state parameter and performs necessary logout actions.
|
||||
|
||||
The Django session is maintained during the logout process to ensure the integrity
|
||||
of the logout flow initiated in the previous step.
|
||||
"""
|
||||
|
||||
http_method_names = ["get"]
|
||||
|
||||
def get(self, request):
|
||||
"""Handle the logout callback.
|
||||
|
||||
If the user is not authenticated, redirects to the default logout URL.
|
||||
Otherwise, verifies the state parameter and performs necessary logout actions.
|
||||
"""
|
||||
|
||||
if not request.user.is_authenticated:
|
||||
return HttpResponseRedirect(self.redirect_url)
|
||||
|
||||
state = request.GET.get("state")
|
||||
|
||||
if state not in request.session.get("oidc_states", {}):
|
||||
msg = "OIDC callback state not found in session `oidc_states`!"
|
||||
raise SuspiciousOperation(msg)
|
||||
|
||||
del request.session["oidc_states"][state]
|
||||
request.session.save()
|
||||
|
||||
auth.logout(request)
|
||||
|
||||
return HttpResponseRedirect(self.redirect_url)
|
||||
|
||||
|
||||
class OIDCAuthenticationCallbackView(MozillaOIDCAuthenticationCallbackView):
|
||||
"""Custom callback view for handling the silent login flow."""
|
||||
|
||||
@property
|
||||
def failure_url(self):
|
||||
"""Override the failure URL property to handle silent login flow
|
||||
|
||||
A silent login failure (e.g., no active user session) should not be
|
||||
considered as an authentication failure.
|
||||
"""
|
||||
if self.request.session.get("silent", None):
|
||||
del self.request.session["silent"]
|
||||
self.request.session.save()
|
||||
return self.success_url
|
||||
return super().failure_url
|
||||
|
||||
|
||||
class OIDCAuthenticationRequestView(MozillaOIDCAuthenticationRequestView):
|
||||
"""Custom authentication view for handling the silent login flow."""
|
||||
|
||||
def get_extra_params(self, request):
|
||||
"""Handle 'prompt' extra parameter for the silent login flow
|
||||
|
||||
This extra parameter is necessary to distinguish between a standard
|
||||
authentication flow and the silent login flow.
|
||||
"""
|
||||
extra_params = self.get_settings("OIDC_AUTH_REQUEST_EXTRA_PARAMS", None)
|
||||
if extra_params is None:
|
||||
extra_params = {}
|
||||
if request.GET.get("silent") == "true":
|
||||
extra_params = copy.deepcopy(extra_params)
|
||||
extra_params.update({"prompt": "none"})
|
||||
request.session["silent"] = True
|
||||
request.session.save()
|
||||
return extra_params
|
||||
@@ -41,6 +41,10 @@ class BaseEgressService:
|
||||
raise WorkerConnectionError(
|
||||
f"LiveKit client connection error, {e.message}."
|
||||
) from e
|
||||
except Exception as e:
|
||||
raise WorkerConnectionError(
|
||||
f"Unexpected error during LiveKit client connection: {str(e)}"
|
||||
) from e
|
||||
|
||||
finally:
|
||||
await lkapi.aclose()
|
||||
|
||||
@@ -58,7 +58,7 @@ def test_authentication_getter_new_user_no_email(monkeypatch):
|
||||
|
||||
assert user.sub == "123"
|
||||
assert user.email is None
|
||||
assert user.password == "!"
|
||||
assert user.has_usable_password() is False
|
||||
assert models.User.objects.count() == 1
|
||||
|
||||
|
||||
@@ -84,7 +84,7 @@ def test_authentication_getter_new_user_with_email(monkeypatch):
|
||||
assert user.email == email
|
||||
assert user.full_name is None
|
||||
assert user.short_name is None
|
||||
assert user.password == "!"
|
||||
assert user.has_usable_password() is False
|
||||
assert models.User.objects.count() == 1
|
||||
|
||||
|
||||
@@ -110,7 +110,7 @@ def test_authentication_getter_new_user_with_names(monkeypatch, email):
|
||||
assert user.email == email
|
||||
assert user.full_name == "John Doe"
|
||||
assert user.short_name == "John"
|
||||
assert user.password == "!"
|
||||
assert user.has_usable_password() is False
|
||||
assert models.User.objects.count() == 1
|
||||
|
||||
|
||||
@@ -129,7 +129,7 @@ def test_models_oidc_user_getter_invalid_token(django_assert_num_queries, monkey
|
||||
django_assert_num_queries(0),
|
||||
pytest.raises(
|
||||
SuspiciousOperation,
|
||||
match="User info contained no recognizable user identification",
|
||||
match="Claims verification failed",
|
||||
),
|
||||
):
|
||||
klass.get_or_create_user(access_token="test-token", id_token=None, payload=None)
|
||||
|
||||
@@ -1,10 +0,0 @@
|
||||
"""Unit tests for the Authentication URLs."""
|
||||
|
||||
from core.authentication.urls import urlpatterns
|
||||
|
||||
|
||||
def test_urls_override_default_mozilla_django_oidc():
|
||||
"""Custom URL patterns should override default ones from Mozilla Django OIDC."""
|
||||
|
||||
url_names = [u.name for u in urlpatterns]
|
||||
assert url_names.index("oidc_logout_custom") < url_names.index("oidc_logout")
|
||||
@@ -1,359 +0,0 @@
|
||||
"""Unit tests for the Authentication Views."""
|
||||
|
||||
from unittest import mock
|
||||
from urllib.parse import parse_qs, urlparse
|
||||
|
||||
from django.contrib.auth.models import AnonymousUser
|
||||
from django.contrib.sessions.middleware import SessionMiddleware
|
||||
from django.core.exceptions import SuspiciousOperation
|
||||
from django.test import RequestFactory
|
||||
from django.test.utils import override_settings
|
||||
from django.urls import reverse
|
||||
from django.utils import crypto
|
||||
|
||||
import pytest
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
from core import factories
|
||||
from core.authentication.views import (
|
||||
MozillaOIDCAuthenticationCallbackView,
|
||||
OIDCAuthenticationCallbackView,
|
||||
OIDCAuthenticationRequestView,
|
||||
OIDCLogoutCallbackView,
|
||||
OIDCLogoutView,
|
||||
)
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
|
||||
@override_settings(LOGOUT_REDIRECT_URL="/example-logout")
|
||||
def test_view_logout_anonymous():
|
||||
"""Anonymous users calling the logout url,
|
||||
should be redirected to the specified LOGOUT_REDIRECT_URL."""
|
||||
|
||||
url = reverse("oidc_logout_custom")
|
||||
response = APIClient().get(url)
|
||||
|
||||
assert response.status_code == 302
|
||||
assert response.url == "/example-logout"
|
||||
|
||||
|
||||
@mock.patch.object(
|
||||
OIDCLogoutView, "construct_oidc_logout_url", return_value="/example-logout"
|
||||
)
|
||||
def test_view_logout(mocked_oidc_logout_url):
|
||||
"""Authenticated users should be redirected to OIDC provider for logout."""
|
||||
|
||||
user = factories.UserFactory()
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
url = reverse("oidc_logout_custom")
|
||||
response = client.get(url)
|
||||
|
||||
mocked_oidc_logout_url.assert_called_once()
|
||||
|
||||
assert response.status_code == 302
|
||||
assert response.url == "/example-logout"
|
||||
|
||||
|
||||
@override_settings(LOGOUT_REDIRECT_URL="/default-redirect-logout")
|
||||
@mock.patch.object(
|
||||
OIDCLogoutView, "construct_oidc_logout_url", return_value="/default-redirect-logout"
|
||||
)
|
||||
def test_view_logout_no_oidc_provider(mocked_oidc_logout_url):
|
||||
"""Authenticated users should be logged out when no OIDC provider is available."""
|
||||
|
||||
user = factories.UserFactory()
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
url = reverse("oidc_logout_custom")
|
||||
|
||||
with mock.patch("mozilla_django_oidc.views.auth.logout") as mock_logout:
|
||||
response = client.get(url)
|
||||
mocked_oidc_logout_url.assert_called_once()
|
||||
mock_logout.assert_called_once()
|
||||
|
||||
assert response.status_code == 302
|
||||
assert response.url == "/default-redirect-logout"
|
||||
|
||||
|
||||
@override_settings(LOGOUT_REDIRECT_URL="/example-logout")
|
||||
def test_view_logout_callback_anonymous():
|
||||
"""Anonymous users calling the logout callback url,
|
||||
should be redirected to the specified LOGOUT_REDIRECT_URL."""
|
||||
|
||||
url = reverse("oidc_logout_callback")
|
||||
response = APIClient().get(url)
|
||||
|
||||
assert response.status_code == 302
|
||||
assert response.url == "/example-logout"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"initial_oidc_states",
|
||||
[{}, {"other_state": "foo"}],
|
||||
)
|
||||
def test_view_logout_persist_state(initial_oidc_states):
|
||||
"""State value should be persisted in session's data."""
|
||||
|
||||
user = factories.UserFactory()
|
||||
|
||||
request = RequestFactory().request()
|
||||
request.user = user
|
||||
|
||||
middleware = SessionMiddleware(get_response=lambda x: x)
|
||||
middleware.process_request(request)
|
||||
|
||||
if initial_oidc_states:
|
||||
request.session["oidc_states"] = initial_oidc_states
|
||||
request.session.save()
|
||||
|
||||
mocked_state = "mock_state"
|
||||
|
||||
OIDCLogoutView().persist_state(request, mocked_state)
|
||||
|
||||
assert "oidc_states" in request.session
|
||||
assert request.session["oidc_states"] == {
|
||||
"mock_state": {},
|
||||
**initial_oidc_states,
|
||||
}
|
||||
|
||||
|
||||
@override_settings(OIDC_OP_LOGOUT_ENDPOINT="/example-logout")
|
||||
@mock.patch.object(OIDCLogoutView, "persist_state")
|
||||
@mock.patch.object(crypto, "get_random_string", return_value="mocked_state")
|
||||
def test_view_logout_construct_oidc_logout_url(
|
||||
mocked_get_random_string, mocked_persist_state
|
||||
):
|
||||
"""Should construct the logout URL to initiate the logout flow with the OIDC provider."""
|
||||
|
||||
user = factories.UserFactory()
|
||||
|
||||
request = RequestFactory().request()
|
||||
request.user = user
|
||||
|
||||
middleware = SessionMiddleware(get_response=lambda x: x)
|
||||
middleware.process_request(request)
|
||||
|
||||
request.session["oidc_id_token"] = "mocked_oidc_id_token"
|
||||
request.session.save()
|
||||
|
||||
redirect_url = OIDCLogoutView().construct_oidc_logout_url(request)
|
||||
|
||||
mocked_persist_state.assert_called_once()
|
||||
mocked_get_random_string.assert_called_once()
|
||||
|
||||
params = parse_qs(urlparse(redirect_url).query)
|
||||
|
||||
assert params["id_token_hint"][0] == "mocked_oidc_id_token"
|
||||
assert params["state"][0] == "mocked_state"
|
||||
|
||||
url = reverse("oidc_logout_callback")
|
||||
assert url in params["post_logout_redirect_uri"][0]
|
||||
|
||||
|
||||
@override_settings(LOGOUT_REDIRECT_URL="/")
|
||||
def test_view_logout_construct_oidc_logout_url_none_id_token():
|
||||
"""If no ID token is available in the session,
|
||||
the user should be redirected to the final URL."""
|
||||
|
||||
user = factories.UserFactory()
|
||||
|
||||
request = RequestFactory().request()
|
||||
request.user = user
|
||||
|
||||
middleware = SessionMiddleware(get_response=lambda x: x)
|
||||
middleware.process_request(request)
|
||||
|
||||
redirect_url = OIDCLogoutView().construct_oidc_logout_url(request)
|
||||
|
||||
assert redirect_url == "/"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"initial_state",
|
||||
[None, {"other_state": "foo"}],
|
||||
)
|
||||
def test_view_logout_callback_wrong_state(initial_state):
|
||||
"""Should raise an error if OIDC state doesn't match session data."""
|
||||
|
||||
user = factories.UserFactory()
|
||||
|
||||
request = RequestFactory().request()
|
||||
request.user = user
|
||||
|
||||
middleware = SessionMiddleware(get_response=lambda x: x)
|
||||
middleware.process_request(request)
|
||||
|
||||
if initial_state:
|
||||
request.session["oidc_states"] = initial_state
|
||||
request.session.save()
|
||||
|
||||
callback_view = OIDCLogoutCallbackView.as_view()
|
||||
|
||||
with pytest.raises(SuspiciousOperation) as excinfo:
|
||||
callback_view(request)
|
||||
|
||||
assert (
|
||||
str(excinfo.value) == "OIDC callback state not found in session `oidc_states`!"
|
||||
)
|
||||
|
||||
|
||||
@override_settings(LOGOUT_REDIRECT_URL="/example-logout")
|
||||
def test_view_logout_callback():
|
||||
"""If state matches, callback should clear OIDC state and redirects."""
|
||||
|
||||
user = factories.UserFactory()
|
||||
|
||||
request = RequestFactory().get("/logout-callback/", data={"state": "mocked_state"})
|
||||
request.user = user
|
||||
|
||||
middleware = SessionMiddleware(get_response=lambda x: x)
|
||||
middleware.process_request(request)
|
||||
|
||||
mocked_state = "mocked_state"
|
||||
|
||||
request.session["oidc_states"] = {mocked_state: {}}
|
||||
request.session.save()
|
||||
|
||||
callback_view = OIDCLogoutCallbackView.as_view()
|
||||
|
||||
with mock.patch("mozilla_django_oidc.views.auth.logout") as mock_logout:
|
||||
|
||||
def clear_user(request):
|
||||
# Assert state is cleared prior to logout
|
||||
assert request.session["oidc_states"] == {}
|
||||
request.user = AnonymousUser()
|
||||
|
||||
mock_logout.side_effect = clear_user
|
||||
response = callback_view(request)
|
||||
mock_logout.assert_called_once()
|
||||
|
||||
assert response.status_code == 302
|
||||
assert response.url == "/example-logout"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("mocked_extra_params_setting", [{"foo": 123}, {}, None])
|
||||
def test_view_authentication_default(settings, mocked_extra_params_setting):
|
||||
"""By default, authentication request should not trigger silent login."""
|
||||
|
||||
settings.OIDC_AUTH_REQUEST_EXTRA_PARAMS = mocked_extra_params_setting
|
||||
|
||||
user = factories.UserFactory()
|
||||
|
||||
request = RequestFactory().request()
|
||||
request.user = user
|
||||
request.GET = {}
|
||||
|
||||
view = OIDCAuthenticationRequestView()
|
||||
extra_params = view.get_extra_params(request)
|
||||
|
||||
assert extra_params == (mocked_extra_params_setting or {})
|
||||
|
||||
|
||||
@pytest.mark.parametrize("mocked_extra_params_setting", [{"foo": 123}, {}, None])
|
||||
def test_view_authentication_silent_false(settings, mocked_extra_params_setting):
|
||||
"""Ensure setting 'silent' parameter to a random value doesn't trigger the silent login flow."""
|
||||
|
||||
settings.OIDC_AUTH_REQUEST_EXTRA_PARAMS = mocked_extra_params_setting
|
||||
|
||||
user = factories.UserFactory()
|
||||
|
||||
request = RequestFactory().request()
|
||||
request.user = user
|
||||
request.GET = {"silent": "foo"}
|
||||
|
||||
middleware = SessionMiddleware(get_response=lambda x: x)
|
||||
middleware.process_request(request)
|
||||
|
||||
view = OIDCAuthenticationRequestView()
|
||||
extra_params = view.get_extra_params(request)
|
||||
|
||||
assert extra_params == (mocked_extra_params_setting or {})
|
||||
assert not request.session.get("silent")
|
||||
|
||||
|
||||
@pytest.mark.parametrize("mocked_extra_params_setting", [{"foo": 123}, {}, None])
|
||||
def test_view_authentication_silent_true(settings, mocked_extra_params_setting):
|
||||
"""If 'silent' parameter is set to True, the silent login should be triggered."""
|
||||
settings.OIDC_AUTH_REQUEST_EXTRA_PARAMS = mocked_extra_params_setting
|
||||
|
||||
user = factories.UserFactory()
|
||||
|
||||
request = RequestFactory().request()
|
||||
request.user = user
|
||||
request.GET = {"silent": "true"}
|
||||
|
||||
middleware = SessionMiddleware(get_response=lambda x: x)
|
||||
middleware.process_request(request)
|
||||
|
||||
view = OIDCAuthenticationRequestView()
|
||||
extra_params = view.get_extra_params(request)
|
||||
expected_params = {"prompt": "none"}
|
||||
|
||||
assert (
|
||||
extra_params == {**mocked_extra_params_setting, **expected_params}
|
||||
if mocked_extra_params_setting
|
||||
else expected_params
|
||||
)
|
||||
assert request.session.get("silent") is True
|
||||
|
||||
|
||||
@mock.patch.object(
|
||||
MozillaOIDCAuthenticationCallbackView,
|
||||
"failure_url",
|
||||
new_callable=mock.PropertyMock,
|
||||
return_value="foo",
|
||||
)
|
||||
def test_view_callback_failure_url(mocked_failure_url):
|
||||
"""Test default behavior of the 'failure_url' property"""
|
||||
|
||||
user = factories.UserFactory()
|
||||
|
||||
request = RequestFactory().request()
|
||||
request.user = user
|
||||
|
||||
middleware = SessionMiddleware(get_response=lambda x: x)
|
||||
middleware.process_request(request)
|
||||
|
||||
view = OIDCAuthenticationCallbackView()
|
||||
view.request = request
|
||||
|
||||
returned_url = view.failure_url
|
||||
|
||||
mocked_failure_url.assert_called_once()
|
||||
assert returned_url == "foo"
|
||||
|
||||
|
||||
@mock.patch.object(
|
||||
OIDCAuthenticationCallbackView,
|
||||
"success_url",
|
||||
new_callable=mock.PropertyMock,
|
||||
return_value="foo",
|
||||
)
|
||||
def test_view_callback_failure_url_silent_login(mocked_success_url):
|
||||
"""If a silent login was initiated and failed, it should not be treated as a failure."""
|
||||
|
||||
user = factories.UserFactory()
|
||||
|
||||
request = RequestFactory().request()
|
||||
request.user = user
|
||||
|
||||
middleware = SessionMiddleware(get_response=lambda x: x)
|
||||
middleware.process_request(request)
|
||||
|
||||
request.session["silent"] = True
|
||||
request.session.save()
|
||||
|
||||
view = OIDCAuthenticationCallbackView()
|
||||
view.request = request
|
||||
|
||||
returned_url = view.failure_url
|
||||
|
||||
mocked_success_url.assert_called_once()
|
||||
assert returned_url == "foo"
|
||||
assert not request.session.get("silent")
|
||||
@@ -338,22 +338,20 @@ def test_api_rooms_retrieve_authenticated():
|
||||
)
|
||||
def test_api_rooms_retrieve_members(mock_token, django_assert_num_queries, settings):
|
||||
"""
|
||||
Users who are members of a room should be allowed to see related users.
|
||||
Users who are members of a room should not be allowed to see related users.
|
||||
"""
|
||||
settings.TIME_ZONE = "UTC"
|
||||
user = UserFactory()
|
||||
other_user = UserFactory()
|
||||
|
||||
room = RoomFactory()
|
||||
user_access = UserResourceAccessFactory(resource=room, user=user, role="member")
|
||||
other_user_access = UserResourceAccessFactory(
|
||||
resource=room, user=other_user, role="member"
|
||||
)
|
||||
UserResourceAccessFactory(resource=room, user=user, role="member")
|
||||
UserResourceAccessFactory(resource=room, user=other_user, role="member")
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
with django_assert_num_queries(4):
|
||||
with django_assert_num_queries(3):
|
||||
response = client.get(
|
||||
f"/api/v1.0/rooms/{room.id!s}/",
|
||||
)
|
||||
@@ -361,37 +359,7 @@ def test_api_rooms_retrieve_members(mock_token, django_assert_num_queries, setti
|
||||
assert response.status_code == 200
|
||||
content_dict = response.json()
|
||||
|
||||
assert sorted(content_dict.pop("accesses"), key=lambda x: x["id"]) == sorted(
|
||||
[
|
||||
{
|
||||
"id": str(user_access.id),
|
||||
"user": {
|
||||
"id": str(user_access.user.id),
|
||||
"email": user_access.user.email,
|
||||
"full_name": user_access.user.full_name,
|
||||
"short_name": user_access.user.short_name,
|
||||
"timezone": "UTC",
|
||||
"language": user_access.user.language,
|
||||
},
|
||||
"resource": str(room.id),
|
||||
"role": user_access.role,
|
||||
},
|
||||
{
|
||||
"id": str(other_user_access.id),
|
||||
"user": {
|
||||
"id": str(other_user_access.user.id),
|
||||
"email": other_user_access.user.email,
|
||||
"full_name": other_user_access.user.full_name,
|
||||
"short_name": other_user_access.user.short_name,
|
||||
"timezone": "UTC",
|
||||
"language": other_user_access.user.language,
|
||||
},
|
||||
"resource": str(room.id),
|
||||
"role": other_user_access.role,
|
||||
},
|
||||
],
|
||||
key=lambda x: x["id"],
|
||||
)
|
||||
assert "accesses" not in content_dict
|
||||
|
||||
expected_name = str(room.id)
|
||||
assert content_dict == {
|
||||
|
||||
@@ -3,10 +3,10 @@
|
||||
from django.conf import settings
|
||||
from django.urls import include, path
|
||||
|
||||
from lasuite.oidc_login.urls import urlpatterns as oidc_urls
|
||||
from rest_framework.routers import DefaultRouter
|
||||
|
||||
from core.api import get_frontend_configuration, viewsets
|
||||
from core.authentication.urls import urlpatterns as oidc_urls
|
||||
|
||||
# - Main endpoints
|
||||
router = DefaultRouter()
|
||||
|
||||
@@ -263,6 +263,9 @@ class Base(Configuration):
|
||||
"rest_framework.parsers.JSONParser",
|
||||
"nested_multipart_parser.drf.DrfNestedParser",
|
||||
],
|
||||
"DEFAULT_RENDERER_CLASSES": [
|
||||
"rest_framework.renderers.JSONRenderer",
|
||||
],
|
||||
"EXCEPTION_HANDLER": "core.api.exception_handler",
|
||||
"DEFAULT_PAGINATION_CLASS": "rest_framework.pagination.PageNumberPagination",
|
||||
"PAGE_SIZE": 20,
|
||||
@@ -312,6 +315,9 @@ class Base(Configuration):
|
||||
"is_silent_login_enabled": values.BooleanValue(
|
||||
True, environ_name="FRONTEND_IS_SILENT_LOGING_ENABLED", environ_prefix=None
|
||||
),
|
||||
"feedback": values.DictValue(
|
||||
{}, environ_name="FRONTEND_FEEDBACK", environ_prefix=None
|
||||
),
|
||||
}
|
||||
|
||||
# Mail
|
||||
@@ -356,8 +362,8 @@ class Base(Configuration):
|
||||
SESSION_COOKIE_AGE = 60 * 60 * 12
|
||||
|
||||
# OIDC - Authorization Code Flow
|
||||
OIDC_AUTHENTICATE_CLASS = "core.authentication.views.OIDCAuthenticationRequestView"
|
||||
OIDC_CALLBACK_CLASS = "core.authentication.views.OIDCAuthenticationCallbackView"
|
||||
OIDC_AUTHENTICATE_CLASS = "lasuite.oidc_login.views.OIDCAuthenticationRequestView"
|
||||
OIDC_CALLBACK_CLASS = "lasuite.oidc_login.views.OIDCAuthenticationCallbackView"
|
||||
OIDC_CREATE_USER = values.BooleanValue(
|
||||
default=True, environ_name="OIDC_CREATE_USER", environ_prefix=None
|
||||
)
|
||||
@@ -391,6 +397,9 @@ class Base(Configuration):
|
||||
OIDC_OP_USER_ENDPOINT = values.Value(
|
||||
None, environ_name="OIDC_OP_USER_ENDPOINT", environ_prefix=None
|
||||
)
|
||||
OIDC_OP_USER_ENDPOINT_FORMAT = values.Value(
|
||||
"AUTO", environ_name="OIDC_OP_USER_ENDPOINT_FORMAT", environ_prefix=None
|
||||
)
|
||||
OIDC_OP_LOGOUT_ENDPOINT = values.Value(
|
||||
None, environ_name="OIDC_OP_LOGOUT_ENDPOINT", environ_prefix=None
|
||||
)
|
||||
@@ -437,6 +446,11 @@ class Base(Configuration):
|
||||
environ_name="OIDC_USERINFO_SHORTNAME_FIELD",
|
||||
environ_prefix=None,
|
||||
)
|
||||
OIDC_USERINFO_ESSENTIAL_CLAIMS = values.ListValue(
|
||||
default=[],
|
||||
environ_name="OIDC_USERINFO_ESSENTIAL_CLAIMS",
|
||||
environ_prefix=None,
|
||||
)
|
||||
|
||||
# Video conference configuration
|
||||
LIVEKIT_CONFIGURATION = {
|
||||
|
||||
@@ -7,7 +7,7 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "meet"
|
||||
version = "0.1.19"
|
||||
version = "0.1.21"
|
||||
authors = [{ "name" = "DINUM", "email" = "dev@mail.numerique.gouv.fr" }]
|
||||
classifiers = [
|
||||
"Development Status :: 5 - Production/Stable",
|
||||
@@ -32,6 +32,7 @@ dependencies = [
|
||||
"django-configurations==2.5.1",
|
||||
"django-cors-headers==4.7.0",
|
||||
"django-countries==7.6.1",
|
||||
"django-lasuite==0.0.7",
|
||||
"django-parler==2.3",
|
||||
"redis==5.2.1",
|
||||
"django-redis==5.4.0",
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "meet",
|
||||
"version": "0.1.19",
|
||||
"version": "0.1.21",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "meet",
|
||||
"version": "0.1.19",
|
||||
"version": "0.1.21",
|
||||
"dependencies": {
|
||||
"@livekit/components-react": "2.8.1",
|
||||
"@livekit/components-styles": "1.1.4",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "meet",
|
||||
"private": true,
|
||||
"version": "0.1.19",
|
||||
"version": "0.1.21",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "panda codegen && vite",
|
||||
|
||||
@@ -13,14 +13,17 @@ import { routes } from './routes'
|
||||
import './i18n/init'
|
||||
import { queryClient } from '@/api/queryClient'
|
||||
import { AppInitialization } from '@/components/AppInitialization'
|
||||
import { useIsSdkContext } from '@/features/sdk/hooks/useIsSdkContext'
|
||||
|
||||
function App() {
|
||||
const { i18n } = useTranslation()
|
||||
useLang(i18n.language)
|
||||
|
||||
const isSDKContext = useIsSdkContext()
|
||||
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<AppInitialization />
|
||||
{!isSDKContext && <AppInitialization />}
|
||||
<Suspense fallback={null}>
|
||||
<I18nProvider locale={i18n.language}>
|
||||
<Layout>
|
||||
|
||||
@@ -11,6 +11,9 @@ export interface ApiConfig {
|
||||
support?: {
|
||||
id: string
|
||||
}
|
||||
feedback: {
|
||||
url: string
|
||||
}
|
||||
silence_livekit_debug_logs?: boolean
|
||||
is_silent_login_enabled?: boolean
|
||||
recording?: {
|
||||
|
||||
@@ -2,15 +2,9 @@ import { silenceLiveKitLogs } from '@/utils/livekit'
|
||||
import { useConfig } from '@/api/useConfig'
|
||||
import { useAnalytics } from '@/features/analytics/hooks/useAnalytics'
|
||||
import { useSupport } from '@/features/support/hooks/useSupport'
|
||||
import { useLocation } from 'wouter'
|
||||
import { useSyncUserPreferencesWithBackend } from '@/features/auth'
|
||||
|
||||
const SDK_BASE_ROUTE = '/sdk'
|
||||
|
||||
export const AppInitialization = () => {
|
||||
const { data } = useConfig()
|
||||
const [location] = useLocation()
|
||||
useSyncUserPreferencesWithBackend()
|
||||
|
||||
const {
|
||||
analytics = {},
|
||||
@@ -18,10 +12,8 @@ export const AppInitialization = () => {
|
||||
silence_livekit_debug_logs = false,
|
||||
} = data || {}
|
||||
|
||||
const isSDKContext = location.includes(SDK_BASE_ROUTE)
|
||||
|
||||
useAnalytics({ ...analytics, isDisabled: isSDKContext })
|
||||
useSupport({ ...support, isDisabled: isSDKContext })
|
||||
useAnalytics(analytics)
|
||||
useSupport(support)
|
||||
|
||||
silenceLiveKitLogs(silence_livekit_debug_logs)
|
||||
|
||||
|
||||
@@ -2,10 +2,14 @@ import { css } from '@/styled-system/css'
|
||||
import { RiErrorWarningLine, RiExternalLinkLine } from '@remixicon/react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Text, A } from '@/primitives'
|
||||
import { GRIST_FEEDBACKS_FORM } from '@/utils/constants'
|
||||
import { useConfig } from '@/api/useConfig'
|
||||
|
||||
export const FeedbackBanner = () => {
|
||||
const { t } = useTranslation()
|
||||
const { data } = useConfig()
|
||||
|
||||
if (!data?.feedback?.url) return
|
||||
|
||||
return (
|
||||
<div
|
||||
className={css({
|
||||
@@ -35,7 +39,7 @@ export const FeedbackBanner = () => {
|
||||
gap: 0.25,
|
||||
})}
|
||||
>
|
||||
<A href={GRIST_FEEDBACKS_FORM} target="_blank" size="sm">
|
||||
<A href={data?.feedback?.url} target="_blank" size="sm">
|
||||
{t('feedback.cta')}
|
||||
</A>
|
||||
<RiExternalLinkLine size={16} aria-hidden="true" />
|
||||
|
||||
@@ -26,7 +26,7 @@ export const fetchUser = (
|
||||
// make sure to not resolve the promise while trying to silent login
|
||||
// so that consumers of fetchUser don't think the work already ended
|
||||
if (opts.attemptSilent && canAttemptSilentLogin()) {
|
||||
attemptSilentLogin(300)
|
||||
attemptSilentLogin(30)
|
||||
} else {
|
||||
resolve(false)
|
||||
}
|
||||
|
||||
@@ -27,5 +27,6 @@ export function useStartRecording(
|
||||
return useMutation<ApiRoom, ApiError, StartRecordingParams>({
|
||||
mutationFn: startRecording,
|
||||
onSuccess: options?.onSuccess,
|
||||
onError: options?.onError,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -19,5 +19,6 @@ export function useStopRecording(
|
||||
return useMutation<ApiRoom, ApiError, StopRecordingParams>({
|
||||
mutationFn: stopRecording,
|
||||
onSuccess: options?.onSuccess,
|
||||
onError: options?.onError,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { A, Button, Div, H, Text } from '@/primitives'
|
||||
import { A, Button, Dialog, Div, H, P, Text } from '@/primitives'
|
||||
|
||||
import fourthSlide from '@/assets/intro-slider/4_record.png'
|
||||
import { css } from '@/styled-system/css'
|
||||
@@ -6,19 +6,19 @@ import { useRoomId } from '@/features/rooms/livekit/hooks/useRoomId'
|
||||
import { useRoomContext } from '@livekit/components-react'
|
||||
import {
|
||||
RecordingMode,
|
||||
useIsRecordingTransitioning,
|
||||
useStartRecording,
|
||||
useStopRecording,
|
||||
} from '@/features/recording'
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { RoomEvent } from 'livekit-client'
|
||||
import { ConnectionState, RoomEvent } from 'livekit-client'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { RecordingStatus, recordingStore } from '@/stores/recording'
|
||||
import { CRISP_HELP_ARTICLE_RECORDING } from '@/utils/constants'
|
||||
import { useIsRecordingTransitioning } from '@/features/recording'
|
||||
|
||||
import {
|
||||
useNotifyParticipants,
|
||||
NotificationType,
|
||||
useNotifyParticipants,
|
||||
} from '@/features/notifications'
|
||||
import posthog from 'posthog-js'
|
||||
import { useSnapshot } from 'valtio/index'
|
||||
@@ -29,12 +29,20 @@ export const ScreenRecordingSidePanel = () => {
|
||||
const recordingSnap = useSnapshot(recordingStore)
|
||||
const { t } = useTranslation('rooms', { keyPrefix: 'screenRecording' })
|
||||
|
||||
const [isErrorDialogOpen, setIsErrorDialogOpen] = useState('')
|
||||
|
||||
const { notifyParticipants } = useNotifyParticipants()
|
||||
|
||||
const roomId = useRoomId()
|
||||
|
||||
const { mutateAsync: startRecordingRoom } = useStartRecording()
|
||||
const { mutateAsync: stopRecordingRoom } = useStopRecording()
|
||||
const { mutateAsync: startRecordingRoom, isPending: isPendingToStart } =
|
||||
useStartRecording({
|
||||
onError: () => setIsErrorDialogOpen('start'),
|
||||
})
|
||||
const { mutateAsync: stopRecordingRoom, isPending: isPendingToStop } =
|
||||
useStopRecording({
|
||||
onError: () => setIsErrorDialogOpen('stop'),
|
||||
})
|
||||
|
||||
const statuses = useMemo(() => {
|
||||
return {
|
||||
@@ -50,6 +58,7 @@ export const ScreenRecordingSidePanel = () => {
|
||||
}, [recordingSnap])
|
||||
|
||||
const room = useRoomContext()
|
||||
const isRoomConnected = room.state == ConnectionState.Connected
|
||||
const isRecordingTransitioning = useIsRecordingTransitioning()
|
||||
|
||||
useEffect(() => {
|
||||
@@ -94,8 +103,11 @@ export const ScreenRecordingSidePanel = () => {
|
||||
|
||||
const isDisabled = useMemo(
|
||||
() =>
|
||||
isLoading || isRecordingTransitioning || statuses.isAnotherModeStarted,
|
||||
[isLoading, isRecordingTransitioning, statuses]
|
||||
isLoading ||
|
||||
isRecordingTransitioning ||
|
||||
statuses.isAnotherModeStarted ||
|
||||
!isRoomConnected,
|
||||
[isLoading, isRecordingTransitioning, statuses, isRoomConnected]
|
||||
)
|
||||
|
||||
return (
|
||||
@@ -145,7 +157,7 @@ export const ScreenRecordingSidePanel = () => {
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{statuses.isStopping ? (
|
||||
{statuses.isStopping || isPendingToStop ? (
|
||||
<>
|
||||
<H lvl={3} margin={false}>
|
||||
{t('stopping.heading')}
|
||||
@@ -193,7 +205,7 @@ export const ScreenRecordingSidePanel = () => {
|
||||
size="sm"
|
||||
variant="tertiary"
|
||||
>
|
||||
{statuses.isStarting ? (
|
||||
{statuses.isStarting || isPendingToStart ? (
|
||||
<>
|
||||
<Spinner size={20} />
|
||||
{t('start.loading')}
|
||||
@@ -206,6 +218,20 @@ export const ScreenRecordingSidePanel = () => {
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
<Dialog
|
||||
isOpen={!!isErrorDialogOpen}
|
||||
role="alertdialog"
|
||||
aria-label={t('alert.title')}
|
||||
>
|
||||
<P>{t(`alert.body.${isErrorDialogOpen}`)}</P>
|
||||
<Button
|
||||
variant="text"
|
||||
size="sm"
|
||||
onPress={() => setIsErrorDialogOpen('')}
|
||||
>
|
||||
{t('alert.button')}
|
||||
</Button>
|
||||
</Dialog>
|
||||
</Div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { A, Button, Div, H, LinkButton, Text } from '@/primitives'
|
||||
import { A, Button, Dialog, Div, H, LinkButton, P, Text } from '@/primitives'
|
||||
|
||||
import thirdSlide from '@/assets/intro-slider/3_resume.png'
|
||||
import { css } from '@/styled-system/css'
|
||||
@@ -12,7 +12,7 @@ import {
|
||||
useStopRecording,
|
||||
} from '../index'
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { RoomEvent } from 'livekit-client'
|
||||
import { ConnectionState, RoomEvent } from 'livekit-client'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { RecordingStatus, recordingStore } from '@/stores/recording'
|
||||
import {
|
||||
@@ -32,6 +32,8 @@ export const TranscriptSidePanel = () => {
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const { t } = useTranslation('rooms', { keyPrefix: 'transcript' })
|
||||
|
||||
const [isErrorDialogOpen, setIsErrorDialogOpen] = useState('')
|
||||
|
||||
const recordingSnap = useSnapshot(recordingStore)
|
||||
|
||||
const { notifyParticipants } = useNotifyParticipants()
|
||||
@@ -42,8 +44,15 @@ export const TranscriptSidePanel = () => {
|
||||
)
|
||||
const roomId = useRoomId()
|
||||
|
||||
const { mutateAsync: startRecordingRoom } = useStartRecording()
|
||||
const { mutateAsync: stopRecordingRoom } = useStopRecording()
|
||||
const { mutateAsync: startRecordingRoom, isPending: isPendingToStart } =
|
||||
useStartRecording({
|
||||
onError: () => setIsErrorDialogOpen('start'),
|
||||
})
|
||||
|
||||
const { mutateAsync: stopRecordingRoom, isPending: isPendingToStop } =
|
||||
useStopRecording({
|
||||
onError: () => setIsErrorDialogOpen('stop'),
|
||||
})
|
||||
|
||||
const statuses = useMemo(() => {
|
||||
return {
|
||||
@@ -58,6 +67,7 @@ export const TranscriptSidePanel = () => {
|
||||
const isRecordingTransitioning = useIsRecordingTransitioning()
|
||||
|
||||
const room = useRoomContext()
|
||||
const isRoomConnected = room.state == ConnectionState.Connected
|
||||
|
||||
useEffect(() => {
|
||||
const handleRecordingStatusChanged = () => {
|
||||
@@ -98,8 +108,11 @@ export const TranscriptSidePanel = () => {
|
||||
|
||||
const isDisabled = useMemo(
|
||||
() =>
|
||||
isLoading || isRecordingTransitioning || statuses.isAnotherModeStarted,
|
||||
[isLoading, isRecordingTransitioning, statuses]
|
||||
isLoading ||
|
||||
isRecordingTransitioning ||
|
||||
statuses.isAnotherModeStarted ||
|
||||
!isRoomConnected,
|
||||
[isLoading, isRecordingTransitioning, statuses, isRoomConnected]
|
||||
)
|
||||
|
||||
return (
|
||||
@@ -177,7 +190,7 @@ export const TranscriptSidePanel = () => {
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{statuses.isStopping ? (
|
||||
{statuses.isStopping || isPendingToStop ? (
|
||||
<>
|
||||
<H lvl={3} margin={false}>
|
||||
{t('stopping.heading')}
|
||||
@@ -225,7 +238,7 @@ export const TranscriptSidePanel = () => {
|
||||
size="sm"
|
||||
variant="tertiary"
|
||||
>
|
||||
{statuses.isStarting ? (
|
||||
{statuses.isStarting || isPendingToStart ? (
|
||||
<>
|
||||
<Spinner size={20} />
|
||||
{t('start.loading')}
|
||||
@@ -240,6 +253,20 @@ export const TranscriptSidePanel = () => {
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
<Dialog
|
||||
isOpen={!!isErrorDialogOpen}
|
||||
role="alertdialog"
|
||||
aria-label={t('alert.title')}
|
||||
>
|
||||
<P>{t(`alert.body.${isErrorDialogOpen}`)}</P>
|
||||
<Button
|
||||
variant="text"
|
||||
size="sm"
|
||||
onPress={() => setIsErrorDialogOpen('')}
|
||||
>
|
||||
{t('alert.button')}
|
||||
</Button>
|
||||
</Dialog>
|
||||
</Div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -353,7 +353,6 @@ export const Join = ({
|
||||
type="text"
|
||||
onChange={setUsername}
|
||||
label={t('usernameLabel')}
|
||||
aria-label={t('usernameLabel')}
|
||||
defaultValue={initialUserChoices?.username}
|
||||
validate={(value) => !value && t('errors.usernameEmpty')}
|
||||
wrapperProps={{
|
||||
|
||||
@@ -6,6 +6,7 @@ import { styled, VStack } from '@/styled-system/jsx'
|
||||
import { usePostHog } from 'posthog-js/react'
|
||||
import { PostHog } from 'posthog-js'
|
||||
import { Button as RACButton } from 'react-aria-components'
|
||||
import { useIsAnalyticsEnabled } from '@/features/analytics/hooks/useIsAnalyticsEnabled'
|
||||
|
||||
const Card = styled('div', {
|
||||
base: {
|
||||
@@ -299,6 +300,7 @@ const AuthenticationMessage = ({
|
||||
}
|
||||
|
||||
export const Rating = () => {
|
||||
const isAnalyticsEnabled = useIsAnalyticsEnabled()
|
||||
const posthog = usePostHog()
|
||||
|
||||
const isUserAnonymous = useMemo(() => {
|
||||
@@ -307,6 +309,8 @@ export const Rating = () => {
|
||||
|
||||
const [step, setStep] = useState(0)
|
||||
|
||||
if (!isAnalyticsEnabled) return
|
||||
|
||||
if (step == 0) {
|
||||
return <RateQuality posthog={posthog} onNext={() => setStep(step + 1)} />
|
||||
}
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import { Text } from '@/primitives'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useParticipantInfo } from '@livekit/components-react'
|
||||
import { Participant } from 'livekit-client'
|
||||
|
||||
export const ParticipantName = ({
|
||||
participant,
|
||||
isScreenShare = false,
|
||||
}: {
|
||||
participant: Participant
|
||||
isScreenShare: boolean
|
||||
}) => {
|
||||
const { t } = useTranslation('rooms', { keyPrefix: 'participantTile' })
|
||||
|
||||
const { identity, name } = useParticipantInfo({ participant })
|
||||
const displayedName = name != '' ? name : identity
|
||||
|
||||
if (isScreenShare) {
|
||||
return (
|
||||
<Text
|
||||
variant="sm"
|
||||
style={{
|
||||
paddingBottom: '0.1rem',
|
||||
marginLeft: '0.4rem',
|
||||
}}
|
||||
>
|
||||
{t('screenShare', { name: displayedName })}
|
||||
</Text>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Text
|
||||
variant="sm"
|
||||
style={{
|
||||
paddingBottom: '0.1rem',
|
||||
}}
|
||||
>
|
||||
{displayedName}
|
||||
</Text>
|
||||
)
|
||||
}
|
||||
@@ -2,7 +2,6 @@ import {
|
||||
AudioTrack,
|
||||
ConnectionQualityIndicator,
|
||||
LockLockedIcon,
|
||||
ParticipantName,
|
||||
ParticipantTileProps,
|
||||
ScreenShareIcon,
|
||||
useEnsureTrackRef,
|
||||
@@ -29,6 +28,7 @@ import { MutedMicIndicator } from './MutedMicIndicator'
|
||||
import { ParticipantPlaceholder } from './ParticipantPlaceholder'
|
||||
import { ParticipantTileFocus } from './ParticipantTileFocus'
|
||||
import { FullScreenShareWarning } from './FullScreenShareWarning'
|
||||
import { ParticipantName } from './ParticipantName'
|
||||
|
||||
export function TrackRefContextIfNeeded(
|
||||
props: React.PropsWithChildren<{
|
||||
@@ -97,6 +97,8 @@ export const ParticipantTile: (
|
||||
participant: trackReference.participant,
|
||||
})
|
||||
|
||||
const isScreenShare = trackReference.source != Track.Source.Camera
|
||||
|
||||
return (
|
||||
<div ref={ref} style={{ position: 'relative' }} {...elementProps}>
|
||||
<TrackRefContextIfNeeded trackRef={trackReference}>
|
||||
@@ -129,45 +131,50 @@ export const ParticipantTile: (
|
||||
{!disableMetadata && (
|
||||
<div className="lk-participant-metadata">
|
||||
<HStack gap={0.25}>
|
||||
<MutedMicIndicator
|
||||
participant={trackReference.participant}
|
||||
/>
|
||||
{!isScreenShare && (
|
||||
<MutedMicIndicator
|
||||
participant={trackReference.participant}
|
||||
/>
|
||||
)}
|
||||
<div
|
||||
className="lk-participant-metadata-item"
|
||||
style={{
|
||||
minHeight: '24px',
|
||||
backgroundColor: isHandRaised ? 'white' : undefined,
|
||||
color: isHandRaised ? 'black' : undefined,
|
||||
padding: '0.1rem 0.25rem',
|
||||
backgroundColor:
|
||||
isHandRaised && !isScreenShare ? 'white' : undefined,
|
||||
color:
|
||||
isHandRaised && !isScreenShare ? 'black' : undefined,
|
||||
transition: 'background 200ms ease, color 400ms ease',
|
||||
}}
|
||||
>
|
||||
{trackReference.source === Track.Source.Camera ? (
|
||||
<>
|
||||
{isHandRaised && (
|
||||
<RiHand
|
||||
color="black"
|
||||
size={16}
|
||||
style={{
|
||||
marginInlineEnd: '.25rem', // fixme - match TrackMutedIndicator styling
|
||||
animationDuration: '300ms',
|
||||
animationName: 'wave_hand',
|
||||
animationIterationCount: '2',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{isEncrypted && (
|
||||
<LockLockedIcon
|
||||
style={{ marginRight: '0.25rem' }}
|
||||
/>
|
||||
)}
|
||||
<ParticipantName />
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<ScreenShareIcon style={{ marginRight: '0.25rem' }} />
|
||||
<ParticipantName>'s screen</ParticipantName>
|
||||
</>
|
||||
{isHandRaised && !isScreenShare && (
|
||||
<RiHand
|
||||
color="black"
|
||||
size={16}
|
||||
style={{
|
||||
marginRight: '0.4rem',
|
||||
minWidth: '16px',
|
||||
animationDuration: '300ms',
|
||||
animationName: 'wave_hand',
|
||||
animationIterationCount: '2',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{isScreenShare && (
|
||||
<ScreenShareIcon
|
||||
style={{
|
||||
maxWidth: '20px',
|
||||
width: '100%',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{isEncrypted && !isScreenShare && (
|
||||
<LockLockedIcon style={{ marginRight: '0.25rem' }} />
|
||||
)}
|
||||
<ParticipantName
|
||||
isScreenShare={isScreenShare}
|
||||
participant={trackReference.participant}
|
||||
/>
|
||||
</div>
|
||||
</HStack>
|
||||
<ConnectionQualityIndicator className="lk-participant-metadata-item" />
|
||||
|
||||
@@ -110,9 +110,10 @@ const StyledSidePanel = ({
|
||||
type PanelProps = {
|
||||
isOpen: boolean
|
||||
children: React.ReactNode
|
||||
keepAlive?: boolean
|
||||
}
|
||||
|
||||
const Panel = ({ isOpen, children }: PanelProps) => (
|
||||
const Panel = ({ isOpen, keepAlive = false, children }: PanelProps) => (
|
||||
<div
|
||||
style={{
|
||||
display: isOpen ? 'inherit' : 'none',
|
||||
@@ -121,7 +122,7 @@ const Panel = ({ isOpen, children }: PanelProps) => (
|
||||
flexGrow: 1,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
{keepAlive || isOpen ? children : null}
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -160,7 +161,7 @@ export const SidePanel = () => {
|
||||
<Panel isOpen={isEffectsOpen}>
|
||||
<Effects />
|
||||
</Panel>
|
||||
<Panel isOpen={isChatOpen}>
|
||||
<Panel isOpen={isChatOpen} keepAlive={true}>
|
||||
<Chat />
|
||||
</Panel>
|
||||
<Panel isOpen={isToolsOpen}>
|
||||
|
||||
+5
-2
@@ -2,14 +2,17 @@ import { RiMegaphoneLine } from '@remixicon/react'
|
||||
import { MenuItem } from 'react-aria-components'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { menuRecipe } from '@/primitives/menuRecipe'
|
||||
import { GRIST_FEEDBACKS_FORM } from '@/utils/constants'
|
||||
import { useConfig } from '@/api/useConfig'
|
||||
|
||||
export const FeedbackMenuItem = () => {
|
||||
const { t } = useTranslation('rooms', { keyPrefix: 'options.items' })
|
||||
const { data } = useConfig()
|
||||
|
||||
if (!data?.feedback?.url) return
|
||||
|
||||
return (
|
||||
<MenuItem
|
||||
href={GRIST_FEEDBACKS_FORM}
|
||||
href={data?.feedback?.url}
|
||||
target="_blank"
|
||||
className={menuRecipe({ icon: true, variant: 'dark' }).item}
|
||||
>
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
} from '@/features/rooms/livekit/components/ReactionPortal'
|
||||
import { Toolbar as RACToolbar } from 'react-aria-components'
|
||||
import { Participant } from 'livekit-client'
|
||||
import useRateLimiter from '@/hooks/useRateLimiter'
|
||||
|
||||
// eslint-disable-next-line react-refresh/only-export-components
|
||||
export const EMOJIS = ['👍', '👎', '👏', '❤️', '😂', '😮', '🎉']
|
||||
@@ -56,6 +57,12 @@ export const ReactionsToggle = () => {
|
||||
}, ANIMATION_DURATION)
|
||||
}
|
||||
|
||||
const debouncedSendReaction = useRateLimiter({
|
||||
callback: sendReaction,
|
||||
maxCalls: 10,
|
||||
windowMs: 1000,
|
||||
})
|
||||
|
||||
// Custom animation implementation for the emoji toolbar
|
||||
// Could not use a menu and its animation, because a menu would make the toolbar inaccessible by keyboard
|
||||
// animation isn't perfect
|
||||
@@ -127,7 +134,7 @@ export const ReactionsToggle = () => {
|
||||
{EMOJIS.map((emoji, index) => (
|
||||
<Button
|
||||
key={index}
|
||||
onPress={() => sendReaction(emoji)}
|
||||
onPress={() => debouncedSendReaction(emoji)}
|
||||
aria-label={t('send', { emoji })}
|
||||
variant="primaryTextDark"
|
||||
size="sm"
|
||||
|
||||
@@ -14,7 +14,6 @@ import {
|
||||
RiMore2Line,
|
||||
RiSettings3Line,
|
||||
} from '@remixicon/react'
|
||||
import { GRIST_FEEDBACKS_FORM } from '@/utils/constants'
|
||||
import { ScreenShareToggle } from '../../components/controls/ScreenShareToggle'
|
||||
import { ChatToggle } from '../../components/controls/ChatToggle'
|
||||
import { ParticipantsToggle } from '../../components/controls/Participants/ParticipantsToggle'
|
||||
@@ -24,6 +23,7 @@ import { useSettingsDialog } from '../../components/controls/SettingsDialogConte
|
||||
import { ResponsiveMenu } from './ResponsiveMenu'
|
||||
import { ToolsToggle } from '../../components/controls/ToolsToggle'
|
||||
import { CameraSwitchButton } from '../../components/controls/CameraSwitchButton'
|
||||
import { useConfig } from '@/api/useConfig'
|
||||
|
||||
export function MobileControlBar({
|
||||
onDeviceError,
|
||||
@@ -38,6 +38,8 @@ export function MobileControlBar({
|
||||
const { toggleEffects } = useSidePanel()
|
||||
const { setDialogOpen } = useSettingsDialog()
|
||||
|
||||
const { data } = useConfig()
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
@@ -150,17 +152,19 @@ export function MobileControlBar({
|
||||
>
|
||||
<RiAccountBoxLine size={20} />
|
||||
</Button>
|
||||
<LinkButton
|
||||
href={GRIST_FEEDBACKS_FORM}
|
||||
variant="primaryTextDark"
|
||||
tooltip={t('options.items.feedback')}
|
||||
aria-label={t('options.items.feedback')}
|
||||
description={true}
|
||||
target="_blank"
|
||||
onPress={() => setIsMenuOpened(false)}
|
||||
>
|
||||
<RiMegaphoneLine size={20} />
|
||||
</LinkButton>
|
||||
{data?.feedback?.url && (
|
||||
<LinkButton
|
||||
href={data?.feedback?.url}
|
||||
variant="primaryTextDark"
|
||||
tooltip={t('options.items.feedback')}
|
||||
aria-label={t('options.items.feedback')}
|
||||
description={true}
|
||||
target="_blank"
|
||||
onPress={() => setIsMenuOpened(false)}
|
||||
>
|
||||
<RiMegaphoneLine size={20} />
|
||||
</LinkButton>
|
||||
)}
|
||||
<Button
|
||||
onPress={() => {
|
||||
setDialogOpen(true)
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
import { useLocation } from 'wouter'
|
||||
|
||||
const SDK_BASE_ROUTE = '/sdk'
|
||||
|
||||
export const useIsSdkContext = () => {
|
||||
const [location] = useLocation()
|
||||
return location.includes(SDK_BASE_ROUTE)
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import { useCallback, useRef } from 'react'
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
export type RateLimiterProps<T extends (...args: any[]) => any> = {
|
||||
callback: T
|
||||
maxCalls: number
|
||||
windowMs: number
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
export default function useRateLimiter<T extends (...args: any[]) => any>({
|
||||
callback,
|
||||
maxCalls = 5,
|
||||
windowMs = 1000,
|
||||
}: RateLimiterProps<T>) {
|
||||
const callsCountRef = useRef(0)
|
||||
const resetTimeoutRef = useRef<NodeJS.Timeout | undefined>(undefined)
|
||||
|
||||
const rateLimitedFn = useCallback(
|
||||
(...args: Parameters<T>) => {
|
||||
if (callsCountRef.current < maxCalls) {
|
||||
callsCountRef.current += 1
|
||||
if (callsCountRef.current === 1) {
|
||||
resetTimeoutRef.current = setTimeout(() => {
|
||||
callsCountRef.current = 0
|
||||
resetTimeoutRef.current = undefined
|
||||
}, windowMs)
|
||||
}
|
||||
return callback(...args)
|
||||
} else {
|
||||
return null
|
||||
}
|
||||
},
|
||||
[callback, maxCalls, windowMs]
|
||||
)
|
||||
return rateLimitedFn
|
||||
}
|
||||
@@ -237,6 +237,14 @@
|
||||
"heading": "",
|
||||
"body": "",
|
||||
"button": ""
|
||||
},
|
||||
"alert": {
|
||||
"title": "",
|
||||
"body": {
|
||||
"stop": "",
|
||||
"start": ""
|
||||
},
|
||||
"button": ""
|
||||
}
|
||||
},
|
||||
"screenRecording": {
|
||||
@@ -254,6 +262,14 @@
|
||||
"heading": "",
|
||||
"body": "",
|
||||
"button": ""
|
||||
},
|
||||
"alert": {
|
||||
"title": "",
|
||||
"body": {
|
||||
"stop": "",
|
||||
"start": ""
|
||||
},
|
||||
"button": ""
|
||||
}
|
||||
},
|
||||
"admin": {
|
||||
@@ -363,5 +379,8 @@
|
||||
"message": "",
|
||||
"stop": "",
|
||||
"ignore": ""
|
||||
},
|
||||
"participantTile": {
|
||||
"screenShare": ""
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
"heading": "An error occurred while loading the page"
|
||||
},
|
||||
"feedback": {
|
||||
"context": "Visio is still in early development — your input matters!",
|
||||
"context": "Product under development — your input matters!",
|
||||
"cta": "Share your feedback"
|
||||
},
|
||||
"forbidden": {
|
||||
|
||||
@@ -237,6 +237,14 @@
|
||||
"heading": "Become a beta tester",
|
||||
"body": "Record your meeting for later. You will receive a summary by email once the meeting is finished.",
|
||||
"button": "Sign up"
|
||||
},
|
||||
"alert": {
|
||||
"title": "Transcription Failed",
|
||||
"body": {
|
||||
"stop": "We were unable to stop the transcription. Please try again in a moment.",
|
||||
"start": "We were unable to start the transcription. Please try again in a moment."
|
||||
},
|
||||
"button": "OK"
|
||||
}
|
||||
},
|
||||
"screenRecording": {
|
||||
@@ -255,6 +263,14 @@
|
||||
"heading": "Recording in progress…",
|
||||
"body": "You will receive the result by email once the recording is complete.",
|
||||
"button": "Stop recording"
|
||||
},
|
||||
"alert": {
|
||||
"title": "Recording Failed",
|
||||
"body": {
|
||||
"stop": "We were unable to stop the recording. Please try again in a moment.",
|
||||
"start": "We were unable to start the recording. Please try again in a moment."
|
||||
},
|
||||
"button": "OK"
|
||||
}
|
||||
},
|
||||
"admin": {
|
||||
@@ -364,5 +380,8 @@
|
||||
"message": "To avoid infinite loop display, do not share your entire screen. Instead, share a tab or another window.",
|
||||
"stop": "Stop presenting",
|
||||
"ignore": "Ignore"
|
||||
},
|
||||
"participantTile": {
|
||||
"screenShare": "{{name}}'s screen"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
"heading": "Une erreur est survenue lors du chargement de la page"
|
||||
},
|
||||
"feedback": {
|
||||
"context": "Visio est en pleine construction — votre avis compte !",
|
||||
"context": "Produit en cours de développement — votre avis compte !",
|
||||
"cta": "Partagez votre avis"
|
||||
},
|
||||
"forbidden": {
|
||||
|
||||
@@ -237,6 +237,14 @@
|
||||
"heading": "Devenez beta testeur",
|
||||
"body": "Enregistrer votre réunion pour plus tard. Vous recevrez un compte-rendu par email une fois la réunion terminée.",
|
||||
"button": "Inscrivez-vous"
|
||||
},
|
||||
"alert": {
|
||||
"title": "Échec de transcription",
|
||||
"body": {
|
||||
"stop": "Nous n'avons pas pu stopper la transcription. Veuillez réessayer dans quelques instants.",
|
||||
"start": "Nous n'avons pas pu démarrer la transcription. Veuillez réessayer dans quelques instants."
|
||||
},
|
||||
"button": "OK"
|
||||
}
|
||||
},
|
||||
"screenRecording": {
|
||||
@@ -255,6 +263,14 @@
|
||||
"heading": "Enregistrement en cours …",
|
||||
"body": "Vous recevrez le resultat par email une fois l'enregistrement terminé.",
|
||||
"button": "Arrêter l'enregistrement"
|
||||
},
|
||||
"alert": {
|
||||
"title": "Échec de l'enregistrement",
|
||||
"body": {
|
||||
"stop": "Nous n'avons pas pu stopper l'enregistrement. Veuillez réessayer dans quelques instants.",
|
||||
"start": "Nous n'avons pas pu démarrer l'enregistrement. Veuillez réessayer dans quelques instants."
|
||||
},
|
||||
"button": "OK"
|
||||
}
|
||||
},
|
||||
"admin": {
|
||||
@@ -364,5 +380,8 @@
|
||||
"message": "Pour éviter l'affichage en boucle infinie, ne partagez pas l'intégralité de votre écran. Partagez plutôt un onglet ou une autre fenêtre.",
|
||||
"stop": "Arrêter la présentation",
|
||||
"ignore": "Ignorer"
|
||||
},
|
||||
"participantTile": {
|
||||
"screenShare": "Écran de {{name}}"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
"heading": "Er is een fout opgetreden bij het laden van de pagina"
|
||||
},
|
||||
"feedback": {
|
||||
"context": "Visio is nog in vroege ontwikkeling - uw input is belangrijk!",
|
||||
"context": "Product in ontwikkeling - uw input is belangrijk!",
|
||||
"cta": "Deel uw feedback"
|
||||
},
|
||||
"forbidden": {
|
||||
|
||||
@@ -237,6 +237,14 @@
|
||||
"heading": "Word betatester",
|
||||
"body": "Neem uw vergadering op voor later. U ontvangt een samenvatting per e-mail zodra de vergadering is afgelopen.",
|
||||
"button": "Aanmelden"
|
||||
},
|
||||
"alert": {
|
||||
"title": "Transcriptie mislukt",
|
||||
"body": {
|
||||
"stop": "We konden de transcriptie niet stoppen. Probeer het over enkele ogenblikken opnieuw.",
|
||||
"start": "We konden de transcriptie niet starten. Probeer het over enkele ogenblikken opnieuw."
|
||||
},
|
||||
"button": "Opnieuw proberen"
|
||||
}
|
||||
},
|
||||
"screenRecording": {
|
||||
@@ -255,6 +263,14 @@
|
||||
"heading": "Opname bezig …",
|
||||
"body": "Je ontvangt het resultaat per e-mail zodra de opname is voltooid.",
|
||||
"button": "Opname stoppen"
|
||||
},
|
||||
"alert": {
|
||||
"title": "Opname mislukt",
|
||||
"body": {
|
||||
"stop": "We konden de opname niet stoppen. Probeer het over enkele ogenblikken opnieuw.",
|
||||
"start": "We konden de opname niet starten. Probeer het over enkele ogenblikken opnieuw."
|
||||
},
|
||||
"button": "Opnieuw proberen"
|
||||
}
|
||||
},
|
||||
"admin": {
|
||||
@@ -364,5 +380,8 @@
|
||||
"message": "Om niet oneindige uw scherm in zichzelf te delen, kunt u beter niet het hele scherm delen. Deel in plaats daarvan een tab of een ander venster.",
|
||||
"stop": "Stop met presenteren",
|
||||
"ignore": "Negeren"
|
||||
},
|
||||
"participantTile": {
|
||||
"screenShare": "{{name}} scherm"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,3 @@
|
||||
export const GRIST_FEEDBACKS_FORM =
|
||||
'https://grist.numerique.gouv.fr/o/docs/cbMv4G7pLY3Z/USER-RESEARCH-or-LA-SUITE/f/26' as const
|
||||
|
||||
export const BETA_USERS_FORM_URL =
|
||||
'https://grist.numerique.gouv.fr/o/docs/forms/3fFfvJoTBEQ6ZiMi8zsQwX/17' as const
|
||||
|
||||
|
||||
@@ -55,6 +55,7 @@ backend:
|
||||
ALLOW_UNREGISTERED_ROOMS: False
|
||||
FRONTEND_SILENCE_LIVEKIT_DEBUG: False
|
||||
FRONTEND_SUPPORT: "{'id': '58ea6697-8eba-4492-bc59-ad6562585041'}"
|
||||
FRONTEND_FEEDBACK: "{'url': 'https://grist.numerique.gouv.fr/o/docs/cbMv4G7pLY3Z/USER-RESEARCH-or-LA-SUITE/f/26'}"
|
||||
AWS_S3_ENDPOINT_URL: http://minio.meet.svc.cluster.local:9000
|
||||
AWS_S3_ACCESS_KEY_ID: meet
|
||||
AWS_S3_SECRET_ACCESS_KEY: password
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "mail_mjml",
|
||||
"version": "0.1.19",
|
||||
"version": "0.1.21",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "mail_mjml",
|
||||
"version": "0.1.19",
|
||||
"version": "0.1.21",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@html-to/text-cli": "0.5.4",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "mail_mjml",
|
||||
"version": "0.1.19",
|
||||
"version": "0.1.21",
|
||||
"description": "An util to generate html and text django's templates from mjml templates",
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
},
|
||||
"version": "0.1.19",
|
||||
"version": "0.1.21",
|
||||
"type": "module",
|
||||
"main": "./dist/visio-sdk.umd.cjs",
|
||||
"module": "./dist/visio-sdk.js",
|
||||
|
||||
Generated
+3
-3
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "sdk",
|
||||
"version": "0.1.19",
|
||||
"version": "0.1.21",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "sdk",
|
||||
"version": "0.1.19",
|
||||
"version": "0.1.21",
|
||||
"license": "ISC",
|
||||
"workspaces": [
|
||||
"./library",
|
||||
@@ -620,7 +620,7 @@
|
||||
},
|
||||
"library": {
|
||||
"name": "@gouvfr-lasuite/visio-sdk",
|
||||
"version": "0.0.1",
|
||||
"version": "0.1.21",
|
||||
"dependencies": {
|
||||
"react": "18.3.1",
|
||||
"react-dom": "18.3.1"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "sdk",
|
||||
"version": "0.1.19",
|
||||
"version": "0.1.21",
|
||||
"author": "",
|
||||
"license": "ISC",
|
||||
"description": "",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
|
||||
[project]
|
||||
name = "summary"
|
||||
version = "0.1.19"
|
||||
version = "0.1.21"
|
||||
dependencies = [
|
||||
"fastapi[standard]>=0.105.0",
|
||||
"uvicorn>=0.24.0",
|
||||
|
||||
Reference in New Issue
Block a user