mirror of
https://github.com/suitenumerique/meet.git
synced 2026-10-07 14:00:56 +00:00
Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b86ac35dc6 | |||
| f182474ed9 |
@@ -88,8 +88,6 @@ jobs:
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Create writable /data
|
||||
run: |
|
||||
@@ -161,8 +159,6 @@ jobs:
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Install ffmpeg
|
||||
run: |
|
||||
@@ -190,8 +186,6 @@ jobs:
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Install dependencies
|
||||
run: cd src/frontend/ && npm ci --ignore-scripts
|
||||
@@ -202,27 +196,6 @@ jobs:
|
||||
- name: Check format
|
||||
run: cd src/frontend/ && npm run check
|
||||
|
||||
test-front:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Install Node.js
|
||||
uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6.5.0
|
||||
with:
|
||||
node-version: "24"
|
||||
|
||||
- name: Install dependencies
|
||||
run: cd src/frontend/ && npm ci --ignore-scripts
|
||||
|
||||
- name: Run tests
|
||||
run: cd src/frontend/ && npm test
|
||||
|
||||
lint-sdk:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
@@ -233,8 +206,6 @@ jobs:
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci --ignore-scripts
|
||||
@@ -256,8 +227,6 @@ jobs:
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci --ignore-scripts
|
||||
|
||||
+1
-3
@@ -16,8 +16,7 @@ and this project adheres to
|
||||
- 🔧(summary) add setting to control Sentry traces sampling rate
|
||||
- ✨(frontend) let signed-out visitors start a meeting
|
||||
- ✨(backend) expose `allow_unregistered_rooms` in the frontend configuration
|
||||
- ✅(frontend) add vitest so the frontend can carry unit tests
|
||||
- ♿️(frontend) make participant pagination readable and keyboard reachable #1775
|
||||
- ✨(backend) authenticate external API calls with Menshen exchanged tokens
|
||||
|
||||
### Changed
|
||||
|
||||
@@ -167,7 +166,6 @@ and this project adheres to
|
||||
### Added
|
||||
|
||||
- ✨(any) let any authenticated user manage the lobby on trusted rooms
|
||||
|
||||
### Changed
|
||||
|
||||
- 📱(frontend) collapse mobile control bar items on narrow viewports
|
||||
|
||||
@@ -169,7 +169,7 @@ run-summary: ## start only the summary application and all needed services
|
||||
@$(COMPOSE) up --force-recreate -d celery-summary-summarize
|
||||
.PHONY: run-summary
|
||||
|
||||
run-agents: ## start the LiveKit agents (opt-in, see docs/developping_locally.md)
|
||||
run-agents: ## start the multi-user-transcriber agent
|
||||
@$(MAKE) run-agent-multi-user-transcriber
|
||||
@$(MAKE) run-agent-metadata-collector
|
||||
.PHONY: run-agents
|
||||
@@ -186,6 +186,7 @@ run:
|
||||
run: ## start the wsgi (production) and development server
|
||||
@$(MAKE) run-backend
|
||||
@$(MAKE) run-summary
|
||||
@$(MAKE) run-agents
|
||||
@$(COMPOSE) up --force-recreate -d frontend
|
||||
.PHONY: run
|
||||
|
||||
@@ -259,8 +260,7 @@ lint-pylint: ## lint back-end python sources with pylint only on changed files f
|
||||
test: ## run project tests; pass extra pytest args via ARGS, e.g. `make test ARGS="-vv"`
|
||||
@args="$(ARGS) $(filter-out $@,$(MAKECMDGOALS))" && \
|
||||
$(MAKE) test-back-parallel ARGS="$${args}" && \
|
||||
$(MAKE) test-summary ARGS="$${args}" && \
|
||||
$(MAKE) test-frontend
|
||||
$(MAKE) test-summary ARGS="$${args}"
|
||||
.PHONY: test
|
||||
|
||||
test-back: ## run back-end tests (pass extra pytest args via ARGS)
|
||||
@@ -278,10 +278,6 @@ test-summary: ## run summary tests (pass extra pytest args via ARGS)
|
||||
bin/pytest-summary $${args}
|
||||
.PHONY: test-summary
|
||||
|
||||
test-frontend: ## run the frontend unit tests
|
||||
cd $(PATH_FRONT) && npm test
|
||||
.PHONY: test-frontend
|
||||
|
||||
makemigrations: ## run django makemigrations for the Meet project.
|
||||
@echo "$(BOLD)Running makemigrations$(RESET)"
|
||||
@$(COMPOSE) up -d postgresql
|
||||
|
||||
+25
-76
@@ -16,54 +16,43 @@ the following command inside your docker container:
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Marketing / Brevo integration now uses `django-lasuite`
|
||||
### Purging inactive rooms
|
||||
|
||||
The in-house marketing service (`core.services.marketing`) has been removed and
|
||||
replaced by the shared implementation from `django-lasuite`
|
||||
(`lasuite.marketing`). This fixes a bug where updating a user's contact on
|
||||
Brevo overwrote their list memberships, removing lists set by other
|
||||
La Suite products. Existing lists are now preserved and merged.
|
||||
Rooms now keep track of the last time they were started (`last_started_at`), fed by LiveKit's `room_started` webhook. A new `purge_inactive_rooms` management command permanently deletes the rooms that have not been started for `ROOM_INACTIVITY_DELETION_DAYS` days. See [the room purge documentation](docs/features/room-purge.md).
|
||||
|
||||
**Celery worker required.** Newsletter signup on login
|
||||
(`SIGNUP_NEW_USER_TO_MARKETING_EMAIL=True`) is now dispatched as an
|
||||
asynchronous Celery task (`lasuite.marketing.tasks.create_or_update_contact`)
|
||||
instead of a synchronous call with a 1s timeout. Make sure a Celery worker is
|
||||
running alongside the backend, otherwise contacts will never be pushed to Brevo.
|
||||
- The feature is **disabled by default**: nothing is deleted unless you set `ROOM_INACTIVITY_DELETION_DAYS`.
|
||||
- The migration marks every existing room as started at the time of the upgrade, so no existing room can be purged before a full inactivity period has elapsed after upgrading.
|
||||
- Rooms holding a saved recording their users may still access are kept: any saved recording, or, when `RECORDING_EXPIRATION_DAYS` is set, a saved recording created within that window.
|
||||
- Inactivity is measured from LiveKit's `room_started` webhook: if it is not delivered to your backend, rooms in daily use look inactive and get purged.
|
||||
- When a room is purged, all it's configuration and access rights are also deleted. Its slug becomes available again and can be reused when a meeting is created from that same URL.
|
||||
|
||||
**Configuration changes.** The following environment variables / settings are
|
||||
**removed** and no longer read:
|
||||
* With `ALLOW_UNREGISTERED_ROOMS=false`, only an authenticated user can navigate to a previously existing link after the room has been purged. Doing so recreates the room in the database with a fresh configuration, with that user associated with it and granted admin rights.
|
||||
* With `ALLOW_UNREGISTERED_ROOMS=true`, any user can reopen the purged room by navigating to the same URL. In that case, the room is created dynamically and no corresponding room entry is persisted in the database.
|
||||
|
||||
- `MARKETING_SERVICE_CLASS`
|
||||
- `BREVO_API_KEY`
|
||||
- `BREVO_API_CONTACT_LIST_IDS`
|
||||
- `BREVO_API_CONTACT_ATTRIBUTES` (previous default: `{"VISIO_USER": True}`)
|
||||
- `BREVO_API_TIMEOUT`
|
||||
### Local development: MinIO replaced by Garage
|
||||
|
||||
They are replaced by a single `LASUITE_MARKETING` setting, configured through:
|
||||
The development stacks now use [Garage](https://garagehq.deuxfleurs.fr/) instead of MinIO as S3 storage. Garage keeps its own format in `data/media/meta` and `data/media/data` and cannot read what MinIO left there, so local recordings and files will be lost.
|
||||
|
||||
| Variable | Default | Description |
|
||||
| ------------------------------ | ------------------------------------------------ | -------------------------------------------- |
|
||||
| `LASUITE_MARKETING_BACKEND` | `lasuite.marketing.backends.dummy.DummyBackend` | Backend class path |
|
||||
| `LASUITE_MARKETING_PARAMETERS` | `{}` | Keyword arguments passed to the backend |
|
||||
To migrate a local environment:
|
||||
|
||||
⚠️ The default backend is now a **dummy** (no-op). If you previously used
|
||||
Brevo, you must explicitly configure it, otherwise signups are silently dropped:
|
||||
1. Stop the stack and remove its containers, including the former `minio` one: `docker compose down --remove-orphans`
|
||||
2. Optionally reclaim the space used by MinIO: `rm -rf data/media && make data/media`
|
||||
3. In your `env.d/development/*` files, replace `minio:9000` by `garage:9000`, the `meet` / `password` credentials by `meet-access-key` / `meet-secret-access-key`, and add `AWS_S3_REGION_NAME=local` (or delete these files and run `make create-env-files`)
|
||||
4. Run `make create-env-files` to generate `env.d/development/garage`, which holds a random RPC secret for Garage.
|
||||
5. Rebuild the images, since the summary and agent images now install boto3 instead of minio
|
||||
|
||||
LASUITE_MARKETING_BACKEND=lasuite.marketing.backends.brevo.BrevoBackend
|
||||
LASUITE_MARKETING_PARAMETERS={"api_key": "<your-brevo-api-key>", "api_contact_list_ids": [1, 2], "api_contact_attributes": {"VISIO_USER": True}}
|
||||
### Summary service and metadata collector: boto3 replaces the minio client
|
||||
|
||||
Migration mapping:
|
||||
The summary service and the metadata collector agent now talk to S3 through boto3 instead of the minio client, with the same settings.
|
||||
Requests are now signed for `AWS_S3_REGION_NAME` as-is. When it is not set, the region is no longer looked up from the bucket: boto3 falls back to `AWS_DEFAULT_REGION`, then to `us-east-1`. If you left `AWS_S3_REGION_NAME` unset, set it to your provider's region before upgrading, or providers that check the signing region will reject the transcripts, summaries and meeting metadata uploads, as well as their signed URLs.
|
||||
|
||||
- `BREVO_API_KEY` → `api_key`
|
||||
- `BREVO_API_CONTACT_LIST_IDS` → `api_contact_list_ids`
|
||||
- `BREVO_API_CONTACT_ATTRIBUTES` → `api_contact_attributes` (re-add
|
||||
`{"VISIO_USER": True}` if you relied on the old default)
|
||||
- `BREVO_API_TIMEOUT` → no equivalent (the request runs in a background task)
|
||||
Also:
|
||||
- Signed URLs to transcripts and summaries are now always path-style (`<endpoint>/<bucket>/<key>`), whereas the minio client used virtual-hosted-style URLs
|
||||
- The metadata collector now accepts `AWS_S3_ENDPOINT_URL` with or without a scheme, like the summary service: the scheme always follows `AWS_S3_SECURE_ACCESS`.
|
||||
|
||||
Note: `BREVO_API_KEY` used to support being read from a secret file; the API key
|
||||
now lives inside `LASUITE_MARKETING_PARAMETERS`, so adapt how you inject that
|
||||
secret (e.g. build the whole variable from your secret store).
|
||||
### Helm chart: media services default to Garage
|
||||
|
||||
The `meet` chart now defaults `serviceMedia.host` and `serviceMediaFiles.host` to `garage.meet.svc.cluster.local`, and the `upstream-vhost` annotation of `ingressMedia` and `ingressMediaFiles` to `garage.meet.svc.cluster.local:9000`. If you relied on the former `minio.meet.svc.cluster.local` defaults, set these values explicitly to your S3 service before upgrading, or recordings and files stop being served under `/media`.
|
||||
### Recording encoding settings replaced by a resolution/profile model
|
||||
|
||||
The `RECORDING_ENCODING_*` settings introduced in v1.16.0 exposed raw encoder
|
||||
@@ -186,46 +175,6 @@ Before enabling it:
|
||||
See [docs/features/recording.md](docs/features/recording.md#tuning-recording-encoding)
|
||||
for the full setting reference, the shipped profile table and the tuning caveats.
|
||||
|
||||
## v1.33.0
|
||||
|
||||
### Purging inactive rooms
|
||||
|
||||
Rooms now keep track of the last time they were started (`last_started_at`), fed by LiveKit's `room_started` webhook. A new `purge_inactive_rooms` management command permanently deletes the rooms that have not been started for `ROOM_INACTIVITY_DELETION_DAYS` days. See [the room purge documentation](docs/features/room-purge.md).
|
||||
|
||||
- The feature is **disabled by default**: nothing is deleted unless you set `ROOM_INACTIVITY_DELETION_DAYS`.
|
||||
- The migration marks every existing room as started at the time of the upgrade, so no existing room can be purged before a full inactivity period has elapsed after upgrading.
|
||||
- Rooms holding a saved recording their users may still access are kept: any saved recording, or, when `RECORDING_EXPIRATION_DAYS` is set, a saved recording created within that window.
|
||||
- Inactivity is measured from LiveKit's `room_started` webhook: if it is not delivered to your backend, rooms in daily use look inactive and get purged.
|
||||
- When a room is purged, all it's configuration and access rights are also deleted. Its slug becomes available again and can be reused when a meeting is created from that same URL.
|
||||
|
||||
* With `ALLOW_UNREGISTERED_ROOMS=false`, only an authenticated user can navigate to a previously existing link after the room has been purged. Doing so recreates the room in the database with a fresh configuration, with that user associated with it and granted admin rights.
|
||||
* With `ALLOW_UNREGISTERED_ROOMS=true`, any user can reopen the purged room by navigating to the same URL. In that case, the room is created dynamically and no corresponding room entry is persisted in the database.
|
||||
|
||||
### Local development: MinIO replaced by Garage
|
||||
|
||||
The development stacks now use [Garage](https://garagehq.deuxfleurs.fr/) instead of MinIO as S3 storage. Garage keeps its own format in `data/media/meta` and `data/media/data` and cannot read what MinIO left there, so local recordings and files will be lost.
|
||||
|
||||
To migrate a local environment:
|
||||
|
||||
1. Stop the stack and remove its containers, including the former `minio` one: `docker compose down --remove-orphans`
|
||||
2. Optionally reclaim the space used by MinIO: `rm -rf data/media && make data/media`
|
||||
3. In your `env.d/development/*` files, replace `minio:9000` by `garage:9000`, the `meet` / `password` credentials by `meet-access-key` / `meet-secret-access-key`, and add `AWS_S3_REGION_NAME=local` (or delete these files and run `make create-env-files`)
|
||||
4. Run `make create-env-files` to generate `env.d/development/garage`, which holds a random RPC secret for Garage.
|
||||
5. Rebuild the images, since the summary and agent images now install boto3 instead of minio
|
||||
|
||||
### Summary service and metadata collector: boto3 replaces the minio client
|
||||
|
||||
The summary service and the metadata collector agent now talk to S3 through boto3 instead of the minio client, with the same settings.
|
||||
Requests are now signed for `AWS_S3_REGION_NAME` as-is. When it is not set, the region is no longer looked up from the bucket: boto3 falls back to `AWS_DEFAULT_REGION`. If you left `AWS_S3_REGION_NAME` unset, set it to your provider's region before upgrading, or providers that check the signing region will reject the transcripts, summaries and meeting metadata uploads, as well as their signed URLs.
|
||||
|
||||
Also:
|
||||
- Signed URLs to transcripts and summaries are now always path-style (`<endpoint>/<bucket>/<key>`), whereas the minio client used virtual-hosted-style URLs
|
||||
- The metadata collector now accepts `AWS_S3_ENDPOINT_URL` with or without a scheme, like the summary service: the scheme always follows `AWS_S3_SECURE_ACCESS`.
|
||||
|
||||
### Helm chart: media services default to Garage
|
||||
|
||||
The `meet` chart now defaults `serviceMedia.host` and `serviceMediaFiles.host` to `garage.meet.svc.cluster.local`, and the `upstream-vhost` annotation of `ingressMedia` and `ingressMediaFiles` to `garage.meet.svc.cluster.local:9000`. If you relied on the former `minio.meet.svc.cluster.local` defaults, set these values explicitly to your S3 service before upgrading, or recordings and files stop being served under `/media`.
|
||||
|
||||
## v1.30.0
|
||||
|
||||
### Removing S3 storage-event webhooks for recordings
|
||||
|
||||
+8
-9
@@ -104,16 +104,15 @@ k8s_yaml(secret_yaml_generic(
|
||||
|
||||
k8s_yaml(local('cd ../src/helm && helmfile -n meet -e ${DEV_ENV:-dev-keycloak} template .'))
|
||||
|
||||
k8s_resource('dev-backend-garage-cors', resource_deps=['dev-backend-garage'])
|
||||
k8s_resource('dev-backend-keycloak', resource_deps=['dev-backend-keycloak-pg'])
|
||||
k8s_resource('meet-backend', resource_deps=['dev-backend-postgres', 'dev-backend-garage-cors', 'dev-backend-redis', 'dev-backend-keycloak', 'livekit-livekit-server'])
|
||||
k8s_resource('meet-celery-backend', resource_deps=['dev-backend-redis'])
|
||||
k8s_resource('meet-celery-summarize', resource_deps=['dev-backend-redis'])
|
||||
k8s_resource('meet-celery-summary-backend', resource_deps=['dev-backend-redis'])
|
||||
k8s_resource('meet-celery-transcribe-default', resource_deps=['dev-backend-redis'])
|
||||
k8s_resource('livekit-livekit-server', resource_deps=['dev-backend-redis'])
|
||||
k8s_resource('garage-cors', resource_deps=['garage'])
|
||||
k8s_resource('meet-backend', resource_deps=['postgresql', 'garage-cors', 'redis', 'livekit-livekit-server'])
|
||||
k8s_resource('meet-celery-backend', resource_deps=['redis'])
|
||||
k8s_resource('meet-celery-summarize', resource_deps=['redis'])
|
||||
k8s_resource('meet-celery-summary-backend', resource_deps=['redis'])
|
||||
k8s_resource('meet-celery-transcribe-default', resource_deps=['redis'])
|
||||
k8s_resource('livekit-livekit-server', resource_deps=['redis'])
|
||||
k8s_resource('livekit-livekit-server-test-connection', resource_deps=['livekit-livekit-server'])
|
||||
k8s_resource('livekit-egress', resource_deps=['livekit-livekit-server'])
|
||||
k8s_resource('keycloak', resource_deps=['kc-postgresql'])
|
||||
# Trigger once on launch
|
||||
k8s_resource(
|
||||
'meet-backend-createsuperuser',
|
||||
|
||||
+8
-5
@@ -9,8 +9,6 @@ services:
|
||||
|
||||
redis:
|
||||
image: redis:5
|
||||
ports:
|
||||
- "6379:6379"
|
||||
|
||||
mailcatcher:
|
||||
image: sj26/mailcatcher:latest
|
||||
@@ -273,6 +271,11 @@ services:
|
||||
- ./src/agents:/app
|
||||
- /app/.venv
|
||||
|
||||
redis-summary:
|
||||
image: redis:5
|
||||
ports:
|
||||
- "6379:6379"
|
||||
|
||||
app-summary-dev:
|
||||
build:
|
||||
context: src/summary
|
||||
@@ -287,7 +290,7 @@ services:
|
||||
volumes:
|
||||
- ./src/summary:/app
|
||||
depends_on:
|
||||
- redis
|
||||
- redis-summary
|
||||
|
||||
celery-summary-transcribe:
|
||||
container_name: celery-summary-transcribe
|
||||
@@ -301,7 +304,7 @@ services:
|
||||
volumes:
|
||||
- ./src/summary:/app
|
||||
depends_on:
|
||||
- redis
|
||||
- redis-summary
|
||||
- app-summary-dev
|
||||
- garage
|
||||
develop:
|
||||
@@ -321,7 +324,7 @@ services:
|
||||
volumes:
|
||||
- ./src/summary:/app
|
||||
depends_on:
|
||||
- redis
|
||||
- redis-summary
|
||||
- app-summary-dev
|
||||
- garage
|
||||
develop:
|
||||
|
||||
@@ -107,37 +107,6 @@ $ npm i
|
||||
$ npm run dev
|
||||
```
|
||||
|
||||
### LiveKit agents (optional)
|
||||
|
||||
The LiveKit agents are not started by `make run`. Each one runs its own
|
||||
container and stays connected to LiveKit, which costs CPU and memory you
|
||||
don't need unless you work on the features they power. Start them only
|
||||
when you need them.
|
||||
|
||||
| Agent | Feature | Make command | Setting in `env.d/development/common` |
|
||||
|---|---|---|---|
|
||||
| `metadata-collector-dev` | Recording metadata (used to identify speakers in transcripts) | `make run-agent-metadata-collector` | `METADATA_COLLECTOR_ENABLED=True` |
|
||||
| `multi-user-transcriber-dev` | Live subtitles | `make run-agent-multi-user-transcriber` | `ROOM_SUBTITLE_ENABLED=True` |
|
||||
|
||||
To start both at once:
|
||||
```shellscript
|
||||
$ make run-agents
|
||||
```
|
||||
|
||||
Then set the matching settings to `True` and restart the backend so it
|
||||
picks them up:
|
||||
```shellscript
|
||||
$ make run-backend
|
||||
```
|
||||
|
||||
The multi-user transcriber also needs a speech-to-text provider. Configure
|
||||
`STT_PROVIDER` and its credentials in
|
||||
`env.d/development/multi_user_transcriber`.
|
||||
|
||||
Keep the settings and the agents in sync: if a setting is `True` while its
|
||||
agent is stopped, the backend still dispatches jobs to it and the feature
|
||||
fails silently.
|
||||
|
||||
---
|
||||
|
||||
## Adding Content
|
||||
|
||||
@@ -104,11 +104,10 @@ ROOM_TELEPHONY_ENABLED=True
|
||||
# ROOMKIT_ENABLED = True
|
||||
# ROOMKIT_SERVER_TO_SERVER_API_TOKEN = ThisIsAnExampleKeyForDevPurposeOnly
|
||||
|
||||
# LiveKit agents (opt-in, start them with `make run-agents`)
|
||||
# Metadata (requires the metadata-collector agent)
|
||||
METADATA_COLLECTOR_ENABLED=False
|
||||
# Metadata
|
||||
METADATA_COLLECTOR_ENABLED=True
|
||||
|
||||
# Subtitle (requires the multi-user-transcriber agent)
|
||||
# Subtitle
|
||||
ROOM_SUBTITLE_ENABLED=False
|
||||
|
||||
FRONTEND_USE_FRENCH_GOV_FOOTER=False
|
||||
|
||||
@@ -9,10 +9,6 @@ AWS_S3_ACCESS_KEY_ID="meet-access-key"
|
||||
AWS_S3_SECRET_ACCESS_KEY="meet-secret-access-key"
|
||||
AWS_S3_REGION_NAME="local"
|
||||
|
||||
CELERY_BROKER_URL="redis://redis:6379/2"
|
||||
CELERY_RESULT_BACKEND="redis://redis:6379/2"
|
||||
TASK_TRACKER_REDIS_URL="redis://redis:6379/2"
|
||||
|
||||
WHISPERX_BASE_URL="https://configure-your-url.com"
|
||||
WHISPERX_ASR_MODEL="large-v2"
|
||||
WHISPERX_API_KEY="your-secret-key"
|
||||
|
||||
@@ -4,21 +4,51 @@
|
||||
# ruff: noqa: PLR0913
|
||||
|
||||
import logging
|
||||
from dataclasses import asdict
|
||||
|
||||
from django.conf import settings
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.core.exceptions import SuspiciousOperation
|
||||
|
||||
import requests
|
||||
from lasuite.oidc_resource_server.backend import ResourceServerBackend as LaSuiteBackend
|
||||
from menshen_client import Configuration, IntrospectionRequest, TokenExchangeClient
|
||||
from menshen_client.exceptions import ResponseParsingError
|
||||
from rest_framework import authentication, exceptions
|
||||
|
||||
from core.models import Application
|
||||
from core.models import Application, ApplicationScope
|
||||
from core.services import jwt_token
|
||||
|
||||
User = get_user_model()
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def get_bearer_token(request):
|
||||
"""Extract the bearer token from the Authorization header.
|
||||
|
||||
Returns:
|
||||
Token string, or None if the request carries no bearer token
|
||||
|
||||
Raises:
|
||||
AuthenticationFailed: If the Authorization header is malformed
|
||||
"""
|
||||
|
||||
auth_header = authentication.get_authorization_header(request).split()
|
||||
|
||||
if not auth_header or auth_header[0].lower() != b"bearer":
|
||||
return None
|
||||
|
||||
if len(auth_header) != 2:
|
||||
logger.warning("Invalid token header format")
|
||||
raise exceptions.AuthenticationFailed("Invalid token header.")
|
||||
|
||||
try:
|
||||
return auth_header[1].decode("utf-8")
|
||||
except UnicodeError as e:
|
||||
logger.warning("Token decode error: %s", e)
|
||||
raise exceptions.AuthenticationFailed("Invalid token encoding.") from e
|
||||
|
||||
|
||||
class BaseJWTAuthentication(authentication.BaseAuthentication):
|
||||
"""Base JWT authentication class."""
|
||||
|
||||
@@ -71,22 +101,12 @@ class BaseJWTAuthentication(authentication.BaseAuthentication):
|
||||
if not self.is_enabled:
|
||||
return None
|
||||
|
||||
auth_header = authentication.get_authorization_header(request).split()
|
||||
token = get_bearer_token(request)
|
||||
|
||||
if not auth_header or auth_header[0].lower() != b"bearer":
|
||||
if token is None:
|
||||
# Defer to next authentication backend
|
||||
return None
|
||||
|
||||
if len(auth_header) != 2:
|
||||
logger.warning("Invalid token header format")
|
||||
raise exceptions.AuthenticationFailed("Invalid token header.")
|
||||
|
||||
try:
|
||||
token = auth_header[1].decode("utf-8")
|
||||
except UnicodeError as e:
|
||||
logger.warning("Token decode error: %s", e)
|
||||
raise exceptions.AuthenticationFailed("Invalid token encoding.") from e
|
||||
|
||||
return self.authenticate_credentials(token)
|
||||
|
||||
def decode_jwt(self, token):
|
||||
@@ -252,6 +272,139 @@ class AddonsJWTAuthentication(BaseJWTAuthentication):
|
||||
)
|
||||
|
||||
|
||||
# Menshen grants scopes following La Suite's "service:resource:action" convention.
|
||||
# Map them to the scopes expected by the external API permissions.
|
||||
MENSHEN_SCOPES_MAPPING = {
|
||||
"meet:room:create": ApplicationScope.ROOMS_CREATE,
|
||||
}
|
||||
|
||||
|
||||
class MenshenAuthentication(authentication.BaseAuthentication):
|
||||
"""Authentication for tokens exchanged through Menshen.
|
||||
|
||||
Menshen is La Suite's OAuth 2.0 token exchange server (RFC 8693): another service
|
||||
exchanges its user's access token for a token targeting Meet, then calls the external
|
||||
API on behalf of that user. Tokens are validated by introspection (RFC 7662). Menshen
|
||||
only reports a token as active when Meet is among its audiences.
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
"""Initialize the Menshen client from Django settings."""
|
||||
|
||||
super().__init__()
|
||||
|
||||
self._client = None
|
||||
|
||||
if not settings.MENSHEN_ENABLED:
|
||||
return
|
||||
|
||||
self._client = TokenExchangeClient(
|
||||
config=Configuration(
|
||||
client_id=settings.MENSHEN_CLIENT_ID,
|
||||
client_secret=settings.MENSHEN_CLIENT_SECRET,
|
||||
server_root_url=settings.MENSHEN_SERVER_URL,
|
||||
)
|
||||
)
|
||||
|
||||
def authenticate(self, request):
|
||||
"""Introspect the bearer token with Menshen.
|
||||
|
||||
Returns:
|
||||
Tuple of (user, payload) if the token is active, None otherwise
|
||||
"""
|
||||
|
||||
if self._client is None:
|
||||
return None
|
||||
|
||||
token = get_bearer_token(request)
|
||||
|
||||
if token is None:
|
||||
return None
|
||||
|
||||
payload = self.introspect(token)
|
||||
|
||||
if not payload.get("active"):
|
||||
# Not a Menshen token, or an expired or revoked one: defer to next
|
||||
# authentication backend
|
||||
return None
|
||||
|
||||
user = self.get_user(payload)
|
||||
|
||||
scopes = payload.get("scope") or ""
|
||||
payload["scope"] = [
|
||||
MENSHEN_SCOPES_MAPPING[scope]
|
||||
for scope in scopes.split()
|
||||
if scope in MENSHEN_SCOPES_MAPPING
|
||||
]
|
||||
|
||||
return (user, payload)
|
||||
|
||||
def introspect(self, token):
|
||||
"""Submit the token to Menshen's introspection endpoint.
|
||||
|
||||
Errors are reported as an inactive token, so a Menshen outage doesn't
|
||||
prevent the next authentication backends from running.
|
||||
|
||||
Args:
|
||||
token: Bearer token string
|
||||
|
||||
Returns:
|
||||
Introspection response dict
|
||||
"""
|
||||
|
||||
try:
|
||||
response = self._client.introspect(IntrospectionRequest(token=token))
|
||||
except (requests.RequestException, ResponseParsingError) as e:
|
||||
logger.warning("Menshen introspection failed: %s", e)
|
||||
return {"active": False}
|
||||
|
||||
# Permission classes expect a dict payload in request.auth
|
||||
return asdict(response)
|
||||
|
||||
def get_user(self, payload):
|
||||
"""Retrieve or create the user from the introspection response.
|
||||
|
||||
Menshen forwards the `sub` and `email` of the subject token, as introspected
|
||||
with the OIDC provider.
|
||||
|
||||
Args:
|
||||
payload: Introspection response dict
|
||||
|
||||
Returns:
|
||||
User instance
|
||||
|
||||
Raises:
|
||||
AuthenticationFailed: If user not found or inactive
|
||||
"""
|
||||
|
||||
sub = payload.get("sub")
|
||||
|
||||
if not sub:
|
||||
logger.warning("Missing 'sub' in Menshen introspection response")
|
||||
raise exceptions.AuthenticationFailed("Invalid token claims.")
|
||||
|
||||
try:
|
||||
user = User.objects.get(sub=sub)
|
||||
except User.DoesNotExist as e:
|
||||
if not settings.OIDC_CREATE_USER:
|
||||
logger.warning("User not found: %s", sub)
|
||||
raise exceptions.AuthenticationFailed("User not found.") from e
|
||||
|
||||
user = User(sub=sub, email=payload.get("email"))
|
||||
user.set_unusable_password()
|
||||
user.save()
|
||||
|
||||
if not user.is_active:
|
||||
logger.warning("Inactive user attempted authentication: %s", user.pk)
|
||||
raise exceptions.AuthenticationFailed("User account is disabled.")
|
||||
|
||||
return user
|
||||
|
||||
def authenticate_header(self, request):
|
||||
"""Return authentication scheme for WWW-Authenticate header."""
|
||||
return "Bearer"
|
||||
|
||||
|
||||
class ResourceServerBackend(LaSuiteBackend):
|
||||
"""OIDC Resource Server backend for user creation and retrieval."""
|
||||
|
||||
|
||||
@@ -166,6 +166,7 @@ class RoomViewSet(
|
||||
authentication_classes = [
|
||||
authentication.ApplicationJWTAuthentication,
|
||||
authentication.AddonsJWTAuthentication,
|
||||
authentication.MenshenAuthentication,
|
||||
ResourceServerAuthentication,
|
||||
]
|
||||
permission_classes = [
|
||||
|
||||
@@ -0,0 +1,209 @@
|
||||
"""Tests for the external API MenshenAuthentication."""
|
||||
|
||||
from unittest import mock
|
||||
|
||||
import pytest
|
||||
import responses
|
||||
from lasuite.oidc_resource_server.authentication import ResourceServerAuthentication
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
from core.factories import UserFactory
|
||||
from core.models import RoleChoices, Room, User
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
SERVER_URL = "https://menshen.example.com"
|
||||
INTROSPECTION_ENDPOINT = f"{SERVER_URL}/auth/token/introspect/"
|
||||
TOKEN = "menshen-exchanged-token"
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def menshen_settings(settings):
|
||||
"""Enable Menshen authentication."""
|
||||
settings.MENSHEN_ENABLED = True
|
||||
settings.MENSHEN_SERVER_URL = SERVER_URL
|
||||
settings.MENSHEN_CLIENT_ID = "meet"
|
||||
settings.MENSHEN_CLIENT_SECRET = "meet-secret"
|
||||
|
||||
|
||||
def _introspection(sub, scope="meet:room:create", **overrides):
|
||||
return {
|
||||
"active": True,
|
||||
"sub": sub,
|
||||
"email": "user@example.com",
|
||||
"scope": scope,
|
||||
"aud": "meet",
|
||||
"client_id": "menshen",
|
||||
**overrides,
|
||||
}
|
||||
|
||||
|
||||
def _create_room():
|
||||
client = APIClient()
|
||||
client.credentials(HTTP_AUTHORIZATION=f"Bearer {TOKEN}")
|
||||
return client.post("/external-api/v1.0/rooms/", {}, format="json")
|
||||
|
||||
|
||||
@responses.activate
|
||||
def test_menshen_active_token():
|
||||
"""An active token with a mapped scope should create a room owned by the user."""
|
||||
|
||||
user = UserFactory()
|
||||
|
||||
# Catch and mock external HTTP requests
|
||||
responses.add(
|
||||
responses.POST,
|
||||
INTROSPECTION_ENDPOINT,
|
||||
json=_introspection(user.sub),
|
||||
match=[responses.matchers.urlencoded_params_matcher({"token": TOKEN})],
|
||||
)
|
||||
|
||||
response = _create_room()
|
||||
|
||||
assert response.status_code == 201
|
||||
room = Room.objects.get(id=response.data["id"])
|
||||
assert room.get_role(user) == RoleChoices.OWNER
|
||||
assert responses.calls[0].request.headers["Authorization"].startswith("Basic ")
|
||||
|
||||
|
||||
@responses.activate
|
||||
@mock.patch.object(ResourceServerAuthentication, "authenticate", return_value=None)
|
||||
def test_menshen_disabled(mock_rs_authenticate, settings):
|
||||
"""Menshen should not be called when disabled."""
|
||||
|
||||
settings.MENSHEN_ENABLED = False
|
||||
|
||||
response = _create_room()
|
||||
|
||||
assert response.status_code == 401
|
||||
assert len(responses.calls) == 0
|
||||
mock_rs_authenticate.assert_called_once()
|
||||
|
||||
|
||||
@responses.activate
|
||||
@mock.patch.object(ResourceServerAuthentication, "authenticate", return_value=None)
|
||||
def test_menshen_inactive_token_defers(mock_rs_authenticate):
|
||||
"""An inactive token should defer to the next authentication backend."""
|
||||
|
||||
user = UserFactory()
|
||||
|
||||
responses.add(
|
||||
responses.POST,
|
||||
INTROSPECTION_ENDPOINT,
|
||||
json=_introspection(user.sub, scope="meet:room:create", active=False),
|
||||
)
|
||||
|
||||
response = _create_room()
|
||||
|
||||
assert response.status_code == 401
|
||||
mock_rs_authenticate.assert_called_once()
|
||||
|
||||
|
||||
@responses.activate
|
||||
@mock.patch.object(ResourceServerAuthentication, "authenticate", return_value=None)
|
||||
def test_menshen_error_defers(mock_rs_authenticate):
|
||||
"""A Menshen error should defer to the next authentication backend."""
|
||||
|
||||
responses.add(responses.POST, INTROSPECTION_ENDPOINT, status=500)
|
||||
|
||||
response = _create_room()
|
||||
|
||||
assert response.status_code == 401
|
||||
mock_rs_authenticate.assert_called_once()
|
||||
|
||||
|
||||
@responses.activate
|
||||
@mock.patch.object(ResourceServerAuthentication, "authenticate", return_value=None)
|
||||
def test_menshen_unparsable_response_defers(mock_rs_authenticate):
|
||||
"""A response the client can't parse should defer to the next backend."""
|
||||
|
||||
responses.add(
|
||||
responses.POST,
|
||||
INTROSPECTION_ENDPOINT,
|
||||
json={"active": True, "unexpected": "field"},
|
||||
)
|
||||
|
||||
response = _create_room()
|
||||
|
||||
assert response.status_code == 401
|
||||
mock_rs_authenticate.assert_called_once()
|
||||
|
||||
|
||||
@responses.activate
|
||||
def test_menshen_unmapped_scope():
|
||||
"""Scopes granted for other services or other Meet actions should not grant access."""
|
||||
|
||||
user = UserFactory()
|
||||
|
||||
responses.add(
|
||||
responses.POST,
|
||||
INTROSPECTION_ENDPOINT,
|
||||
json=_introspection(user.sub, scope="drive:item:create meet:room:list"),
|
||||
)
|
||||
|
||||
response = _create_room()
|
||||
|
||||
assert response.status_code == 403
|
||||
assert "insufficient permissions." in str(response.data).lower()
|
||||
assert not Room.objects.exists()
|
||||
|
||||
|
||||
@responses.activate
|
||||
def test_menshen_missing_sub():
|
||||
"""An active token without sub should be rejected."""
|
||||
|
||||
responses.add(responses.POST, INTROSPECTION_ENDPOINT, json=_introspection(None))
|
||||
|
||||
response = _create_room()
|
||||
|
||||
assert response.status_code == 401
|
||||
assert "invalid token claims." in str(response.data).lower()
|
||||
|
||||
|
||||
@responses.activate
|
||||
def test_menshen_inactive_user():
|
||||
"""An active token for an inactive user should be rejected."""
|
||||
|
||||
user = UserFactory(is_active=False)
|
||||
|
||||
responses.add(responses.POST, INTROSPECTION_ENDPOINT, json=_introspection(user.sub))
|
||||
|
||||
response = _create_room()
|
||||
|
||||
assert response.status_code == 401
|
||||
assert "user account is disabled." in str(response.data).lower()
|
||||
|
||||
|
||||
@responses.activate
|
||||
def test_menshen_unknown_user_created(settings):
|
||||
"""An unknown sub should create a user when OIDC_CREATE_USER is set."""
|
||||
|
||||
settings.OIDC_CREATE_USER = True
|
||||
|
||||
responses.add(
|
||||
responses.POST, INTROSPECTION_ENDPOINT, json=_introspection("new-sub")
|
||||
)
|
||||
|
||||
response = _create_room()
|
||||
|
||||
assert response.status_code == 201
|
||||
user = User.objects.get(sub="new-sub")
|
||||
assert user.email == "user@example.com"
|
||||
assert user.is_active is True
|
||||
|
||||
|
||||
@responses.activate
|
||||
def test_menshen_unknown_user_not_created(settings):
|
||||
"""An unknown sub should be rejected when OIDC_CREATE_USER is unset."""
|
||||
|
||||
settings.OIDC_CREATE_USER = False
|
||||
|
||||
responses.add(
|
||||
responses.POST, INTROSPECTION_ENDPOINT, json=_introspection("new-sub")
|
||||
)
|
||||
|
||||
response = _create_room()
|
||||
|
||||
assert response.status_code == 401
|
||||
assert "user not found." in str(response.data).lower()
|
||||
assert not User.objects.filter(sub="new-sub").exists()
|
||||
@@ -702,6 +702,20 @@ class Base(Configuration):
|
||||
default=None, environ_name="OIDC_RS_SCOPES_PREFIX", environ_prefix=None
|
||||
)
|
||||
|
||||
# Menshen, La Suite's OAuth 2.0 token exchange server
|
||||
MENSHEN_ENABLED = values.BooleanValue(
|
||||
False, environ_name="MENSHEN_ENABLED", environ_prefix=None
|
||||
)
|
||||
MENSHEN_SERVER_URL = values.Value(
|
||||
None, environ_name="MENSHEN_SERVER_URL", environ_prefix=None
|
||||
)
|
||||
MENSHEN_CLIENT_ID = values.Value(
|
||||
None, environ_name="MENSHEN_CLIENT_ID", environ_prefix=None
|
||||
)
|
||||
MENSHEN_CLIENT_SECRET = SecretFileValue(
|
||||
None, environ_name="MENSHEN_CLIENT_SECRET", environ_prefix=None
|
||||
)
|
||||
|
||||
# Video conference configuration
|
||||
LIVEKIT_CONFIGURATION = {
|
||||
"api_key": SecretFileValue(environ_name="LIVEKIT_API_KEY", environ_prefix=None),
|
||||
|
||||
@@ -49,6 +49,7 @@ dependencies = [
|
||||
"gunicorn==26.2.0",
|
||||
"jsonschema==4.26.0",
|
||||
"markdown==3.10.3",
|
||||
"menshen-client==0.1.0",
|
||||
"nested-multipart-parser==1.6.0",
|
||||
"posthog==7.44.0",
|
||||
"psycopg[binary]==3.3.4",
|
||||
|
||||
Generated
+14
@@ -1327,6 +1327,7 @@ dependencies = [
|
||||
{ name = "jsonschema" },
|
||||
{ name = "livekit-api" },
|
||||
{ name = "markdown" },
|
||||
{ name = "menshen-client" },
|
||||
{ name = "mozilla-django-oidc" },
|
||||
{ name = "nested-multipart-parser" },
|
||||
{ name = "phonenumbers" },
|
||||
@@ -1392,6 +1393,7 @@ requires-dist = [
|
||||
{ name = "jsonschema", specifier = "==4.26.0" },
|
||||
{ name = "livekit-api", specifier = "==1.2.0" },
|
||||
{ name = "markdown", specifier = "==3.10.3" },
|
||||
{ name = "menshen-client", specifier = "==0.1.0" },
|
||||
{ name = "mozilla-django-oidc", specifier = "==5.0.2" },
|
||||
{ name = "nested-multipart-parser", specifier = "==1.6.0" },
|
||||
{ name = "phonenumbers", specifier = "==9.0.37" },
|
||||
@@ -1428,6 +1430,18 @@ dev = [
|
||||
{ name = "types-requests", specifier = "==2.33.0.20260712" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "menshen-client"
|
||||
version = "0.1.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "requests" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/53/d5/c2be400c190efe26b70e5c60dd9d9ab279ece3b51de457abc184a238c727/menshen_client-0.1.0.tar.gz", hash = "sha256:21fe48788e860c7f2e103787ce0827981e9059146cf7bdb9ac1a8778acb9ff77", size = 6219, upload-time = "2026-09-21T21:04:01.677Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/d6/bf/11d15da4852b8344eb9f30861bd15a8f1d931ff4232d9b7de18fce09fd3c/menshen_client-0.1.0-py3-none-any.whl", hash = "sha256:d0dfb351812bb93620a4796e0f0ce1a40e8ec859dc8de2a1f50ae7f306d4d29a", size = 7040, upload-time = "2026-09-21T21:04:00.433Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "mozilla-django-oidc"
|
||||
version = "5.0.2"
|
||||
|
||||
Generated
+4
-278
@@ -64,8 +64,7 @@
|
||||
"typescript-eslint": "8.60.1",
|
||||
"vite": "8.0.14",
|
||||
"vite-plugin-static-copy": "4.1.1",
|
||||
"vite-plugin-svgr": "5.2.0",
|
||||
"vitest": "5.0.2"
|
||||
"vite-plugin-svgr": "5.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@adobe/react-spectrum": {
|
||||
@@ -943,9 +942,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@jridgewell/sourcemap-codec": {
|
||||
"version": "1.6.0",
|
||||
"resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz",
|
||||
"integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==",
|
||||
"version": "1.5.5",
|
||||
"resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
|
||||
"integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
@@ -2507,24 +2506,6 @@
|
||||
"tslib": "^2.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/chai": {
|
||||
"version": "5.2.3",
|
||||
"resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz",
|
||||
"integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/deep-eql": "*",
|
||||
"assertion-error": "^2.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/deep-eql": {
|
||||
"version": "4.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz",
|
||||
"integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/dom-mediacapture-record": {
|
||||
"version": "1.0.22",
|
||||
"resolved": "https://registry.npmjs.org/@types/dom-mediacapture-record/-/dom-mediacapture-record-1.0.22.tgz",
|
||||
@@ -2805,64 +2786,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/mocker": {
|
||||
"version": "5.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-5.0.2.tgz",
|
||||
"integrity": "sha512-Z5FS00Q1SJHkB35xATsmWGdQ5WA1/0MV3CDjqyv7GavHv1OfOj145MNfHOlHk7QLes21dKFDHr8EO2zvL+9WGA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@jridgewell/trace-mapping": "0.3.31",
|
||||
"@vitest/spy": "5.0.2",
|
||||
"estree-walker": "^3.0.3",
|
||||
"magic-string": "^1.2.3"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/vitest"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"msw": "^2.4.9",
|
||||
"vite": "^6.0.0 || ^7.0.0 || ^8.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"msw": {
|
||||
"optional": true
|
||||
},
|
||||
"vite": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/mocker/node_modules/estree-walker": {
|
||||
"version": "3.0.3",
|
||||
"resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz",
|
||||
"integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/estree": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/mocker/node_modules/magic-string": {
|
||||
"version": "1.4.2",
|
||||
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-1.4.2.tgz",
|
||||
"integrity": "sha512-vG+rjFRj1PqdIBozIxAGMjPlOhaVe+GXpbttY/iSK7rGcJRMlwNJO7dcUwmUqkymsFLJiNGI06t4D7Fr7yRC9g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@jridgewell/sourcemap-codec": "^1.6.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/spy": {
|
||||
"version": "5.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-5.0.2.tgz",
|
||||
"integrity": "sha512-Ijc7T1nT9efNb5LxvjaBrEqw3f/QwUv5EE0nKqZxgqsaV/FxAAZ8baGylA8X/Z2oS4Lp+K74Jr6dTJsDKxJDeg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/vitest"
|
||||
}
|
||||
},
|
||||
"node_modules/@vue/compiler-core": {
|
||||
"version": "3.5.25",
|
||||
"resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.25.tgz",
|
||||
@@ -3228,16 +3151,6 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/assertion-error": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz",
|
||||
"integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/ast-types-flow": {
|
||||
"version": "0.0.8",
|
||||
"resolved": "https://registry.npmjs.org/ast-types-flow/-/ast-types-flow-0.0.8.tgz",
|
||||
@@ -4217,16 +4130,6 @@
|
||||
],
|
||||
"license": "CC-BY-4.0"
|
||||
},
|
||||
"node_modules/chai": {
|
||||
"version": "6.2.2",
|
||||
"resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz",
|
||||
"integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/chalk": {
|
||||
"version": "4.1.2",
|
||||
"resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
|
||||
@@ -5127,13 +5030,6 @@
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/es-module-lexer": {
|
||||
"version": "2.3.2",
|
||||
"resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.2.tgz",
|
||||
"integrity": "sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/es-object-atoms": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz",
|
||||
@@ -5636,16 +5532,6 @@
|
||||
"node": ">=18.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/expect-type": {
|
||||
"version": "1.4.0",
|
||||
"resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz",
|
||||
"integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">=12.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/express": {
|
||||
"version": "5.2.1",
|
||||
"resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz",
|
||||
@@ -8761,20 +8647,6 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/obug": {
|
||||
"version": "2.2.1",
|
||||
"resolved": "https://registry.npmjs.org/obug/-/obug-2.2.1.tgz",
|
||||
"integrity": "sha512-XrsrhT5sybtKI6wakr2SPOlGZWWYbUXZ7a0jT8/QOeAPau+1X/bSegNe5YR75oJmEZQbKningirmGOEJCIk61Q==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
"https://github.com/sponsors/sxzz",
|
||||
"https://opencollective.com/debug"
|
||||
],
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=12.20.0"
|
||||
}
|
||||
},
|
||||
"node_modules/on-finished": {
|
||||
"version": "2.4.1",
|
||||
"resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz",
|
||||
@@ -10348,13 +10220,6 @@
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/std-env": {
|
||||
"version": "4.3.0",
|
||||
"resolved": "https://registry.npmjs.org/std-env/-/std-env-4.3.0.tgz",
|
||||
"integrity": "sha512-OtU/EgQ1kIm5KwqQpBC6ZEMXrZRui11w8zgfTWp8cdO9B8OaPsbA8bTHO2P+HNo1VlUTGMVBwPhydu6poeXiag==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/stop-iteration-iterator": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/stop-iteration-iterator/-/stop-iteration-iterator-1.1.0.tgz",
|
||||
@@ -10661,26 +10526,6 @@
|
||||
"xtend": "~4.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/tinybench": {
|
||||
"version": "6.2.0",
|
||||
"resolved": "https://registry.npmjs.org/tinybench/-/tinybench-6.2.0.tgz",
|
||||
"integrity": "sha512-78U2TlB2CnVenajOFzf3BKSm0J6oz5L0NV7g32LCPccvYc0lbWvys4d3uUUCS2B1N8PAf2+aekR8i1KbC3HO7Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/tinyexec": {
|
||||
"version": "1.3.1",
|
||||
"resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.3.1.tgz",
|
||||
"integrity": "sha512-GCvB3aoys96IuDFBMcTB46JOR6mdMtAToqwiW8JlWhsoh1mhHi/xn9ss/Dg7N555GiJyEt2qzoG/NHCwM6h1EA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/tinyglobby": {
|
||||
"version": "0.2.17",
|
||||
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz",
|
||||
@@ -11765,112 +11610,6 @@
|
||||
"url": "https://github.com/sponsors/jonschlinkert"
|
||||
}
|
||||
},
|
||||
"node_modules/vitest": {
|
||||
"version": "5.0.2",
|
||||
"resolved": "https://registry.npmjs.org/vitest/-/vitest-5.0.2.tgz",
|
||||
"integrity": "sha512-7MQrx9pDv5aHiUcovIb/70Ys3tgtkUVgCtledvKdCmEO+/1Dicq5ZqoSxOW034m03oqC+oHOKui2dM6qtMLoJg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/chai": "^5.2.2",
|
||||
"@vitest/mocker": "5.0.2",
|
||||
"chai": "^6.2.2",
|
||||
"es-module-lexer": "^2.3.2",
|
||||
"expect-type": "^1.4.0",
|
||||
"magic-string": "^1.2.3",
|
||||
"obug": "^2.1.4",
|
||||
"picomatch": "^4.0.7",
|
||||
"std-env": "^4.2.0",
|
||||
"tinybench": "^6.1.4",
|
||||
"tinyexec": "^1.3.0",
|
||||
"tinyglobby": "^0.2.17",
|
||||
"why-is-node-running": "^3.2.1"
|
||||
},
|
||||
"bin": {
|
||||
"vitest": "vitest.mjs"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^22.12.0 || ^24.0.0 || >=26.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/vitest"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@edge-runtime/vm": "*",
|
||||
"@opentelemetry/api": "^1.9.0",
|
||||
"@types/node": "^22.0.0 || >=24.0.0",
|
||||
"@vitest/browser-playwright": "5.0.2",
|
||||
"@vitest/browser-preview": "5.0.2",
|
||||
"@vitest/browser-webdriverio": "^5.0.0-beta.5 || >=5.0.0",
|
||||
"@vitest/coverage-istanbul": "5.0.2",
|
||||
"@vitest/coverage-v8": "5.0.2",
|
||||
"@vitest/ui": "5.0.2",
|
||||
"happy-dom": "*",
|
||||
"jsdom": "*",
|
||||
"vite": "^6.4.0 || ^7.0.0 || ^8.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@edge-runtime/vm": {
|
||||
"optional": true
|
||||
},
|
||||
"@opentelemetry/api": {
|
||||
"optional": true
|
||||
},
|
||||
"@types/node": {
|
||||
"optional": true
|
||||
},
|
||||
"@vitest/browser-playwright": {
|
||||
"optional": true
|
||||
},
|
||||
"@vitest/browser-preview": {
|
||||
"optional": true
|
||||
},
|
||||
"@vitest/browser-webdriverio": {
|
||||
"optional": true
|
||||
},
|
||||
"@vitest/coverage-istanbul": {
|
||||
"optional": true
|
||||
},
|
||||
"@vitest/coverage-v8": {
|
||||
"optional": true
|
||||
},
|
||||
"@vitest/ui": {
|
||||
"optional": true
|
||||
},
|
||||
"happy-dom": {
|
||||
"optional": true
|
||||
},
|
||||
"jsdom": {
|
||||
"optional": true
|
||||
},
|
||||
"vite": {
|
||||
"optional": false
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/vitest/node_modules/magic-string": {
|
||||
"version": "1.4.2",
|
||||
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-1.4.2.tgz",
|
||||
"integrity": "sha512-vG+rjFRj1PqdIBozIxAGMjPlOhaVe+GXpbttY/iSK7rGcJRMlwNJO7dcUwmUqkymsFLJiNGI06t4D7Fr7yRC9g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@jridgewell/sourcemap-codec": "^1.6.0"
|
||||
}
|
||||
},
|
||||
"node_modules/vitest/node_modules/picomatch": {
|
||||
"version": "4.0.7",
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz",
|
||||
"integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/jonschlinkert"
|
||||
}
|
||||
},
|
||||
"node_modules/walk-sync": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/walk-sync/-/walk-sync-2.2.0.tgz",
|
||||
@@ -12055,19 +11794,6 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/why-is-node-running": {
|
||||
"version": "3.2.2",
|
||||
"resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-3.2.2.tgz",
|
||||
"integrity": "sha512-NKUzAelcoCXhXL4dJzKIwXeR8iEVqsA0Lq6Vnd0UXvgaKbzVo4ZTHROF2Jidrv+SgxOQ03fMinnNhzZATxOD3A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"why-is-node-running": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.11"
|
||||
}
|
||||
},
|
||||
"node_modules/word-wrap": {
|
||||
"version": "1.2.5",
|
||||
"resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz",
|
||||
|
||||
@@ -7,7 +7,6 @@
|
||||
"dev": "panda codegen && vite",
|
||||
"build": "panda codegen && tsc -b && vite build",
|
||||
"build:debug": "VITE_ANALYZE=true npm run build -- --debug",
|
||||
"test": "panda codegen && vitest run",
|
||||
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
|
||||
"lint:fix": "eslint . --fix",
|
||||
"preview": "vite preview",
|
||||
@@ -72,7 +71,6 @@
|
||||
"typescript-eslint": "8.60.1",
|
||||
"vite": "8.0.14",
|
||||
"vite-plugin-static-copy": "4.1.1",
|
||||
"vite-plugin-svgr": "5.2.0",
|
||||
"vitest": "5.0.2"
|
||||
"vite-plugin-svgr": "5.2.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
useSwipe,
|
||||
} from '@livekit/components-react'
|
||||
import { mergeProps } from '@/utils/mergeProps'
|
||||
import { PaginationIndicator } from './PaginationIndicator'
|
||||
import { useGridLayout } from '../hooks/useGridLayout'
|
||||
import { PaginationControl } from './PaginationControl'
|
||||
import { useSpeakerPromotionTrigger } from '../hooks/useSpeakerPromotionTrigger'
|
||||
@@ -61,7 +62,13 @@ export function GridLayout({ tracks, ...props }: GridLayoutProps) {
|
||||
>
|
||||
<TrackLoop tracks={pagination.tracks}>{props.children}</TrackLoop>
|
||||
{tracks.length > layout.maxTiles && (
|
||||
<PaginationControl {...pagination} focusShortcut />
|
||||
<>
|
||||
<PaginationIndicator
|
||||
totalPageCount={pagination.totalPageCount}
|
||||
currentPage={pagination.currentPage}
|
||||
/>
|
||||
<PaginationControl pagesContainer={gridEl} {...pagination} />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -1,147 +1,99 @@
|
||||
import * as React from 'react'
|
||||
import { createInteractingObservable } from '@livekit/components-core'
|
||||
import { RiArrowLeftSLine, RiArrowRightSLine } from '@remixicon/react'
|
||||
import { Button } from '@/primitives'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { css, cva, type RecipeVariantProps } from '@/styled-system/css'
|
||||
import { useCallback, useRef } from 'react'
|
||||
import { useRegisterKeyboardShortcut } from '@/features/shortcuts/useRegisterKeyboardShortcut'
|
||||
import { css } from '@/styled-system/css'
|
||||
|
||||
const paginationToolbar = cva({
|
||||
base: {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '0.125rem',
|
||||
backgroundColor: 'primaryDark.50',
|
||||
borderRadius: '2rem',
|
||||
border: '1px solid',
|
||||
borderColor: 'primaryDark.200',
|
||||
padding: '0.375rem',
|
||||
},
|
||||
variants: {
|
||||
placement: {
|
||||
overlay: {
|
||||
position: 'absolute',
|
||||
bottom: '1rem',
|
||||
left: '50%',
|
||||
zIndex: 2,
|
||||
transform: 'translateX(-50%)',
|
||||
boxShadow: '0 2px 10px rgba(0, 0, 0, 0.45)',
|
||||
},
|
||||
inline: {
|
||||
alignSelf: 'center',
|
||||
flexShrink: 0,
|
||||
marginTop: '1rem',
|
||||
},
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
placement: 'overlay',
|
||||
},
|
||||
})
|
||||
|
||||
export type PaginationControlProps = RecipeVariantProps<
|
||||
typeof paginationToolbar
|
||||
> & {
|
||||
export interface PaginationControlProps {
|
||||
totalPageCount: number
|
||||
nextPage: () => void
|
||||
prevPage: () => void
|
||||
currentPage: number
|
||||
// The shortcut listens on the main window only, so a single instance may own it.
|
||||
focusShortcut?: boolean
|
||||
pagesContainer?: React.RefObject<HTMLElement>
|
||||
}
|
||||
|
||||
const arrowButtonClass = css({
|
||||
_disabled: {
|
||||
cursor: 'default',
|
||||
backgroundColor: 'transparent !important',
|
||||
'& svg': {
|
||||
opacity: 0.35,
|
||||
},
|
||||
_focusVisible: {
|
||||
outline: '2px solid',
|
||||
outlineColor: 'focusRing',
|
||||
outlineOffset: '2px',
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
export function PaginationControl({
|
||||
totalPageCount,
|
||||
nextPage,
|
||||
prevPage,
|
||||
currentPage,
|
||||
placement,
|
||||
focusShortcut = false,
|
||||
pagesContainer: connectedElement,
|
||||
}: PaginationControlProps) {
|
||||
const { t } = useTranslation('rooms', { keyPrefix: 'pagination' })
|
||||
const prevButtonRef = useRef<HTMLButtonElement>(null)
|
||||
const nextButtonRef = useRef<HTMLButtonElement>(null)
|
||||
const isSinglePage = totalPageCount <= 1
|
||||
const isFirstPage = currentPage <= 1
|
||||
const isLastPage = currentPage >= totalPageCount
|
||||
const [interactive, setInteractive] = useState(false)
|
||||
|
||||
const focusPagination = useCallback(() => {
|
||||
const target = isLastPage ? prevButtonRef.current : nextButtonRef.current
|
||||
target?.focus()
|
||||
}, [isLastPage])
|
||||
useEffect(() => {
|
||||
let subscription:
|
||||
| ReturnType<ReturnType<typeof createInteractingObservable>['subscribe']>
|
||||
| undefined
|
||||
if (connectedElement) {
|
||||
subscription = createInteractingObservable(
|
||||
connectedElement.current,
|
||||
2000
|
||||
).subscribe(setInteractive)
|
||||
}
|
||||
return () => {
|
||||
if (subscription) {
|
||||
subscription.unsubscribe()
|
||||
}
|
||||
}
|
||||
}, [connectedElement])
|
||||
|
||||
useRegisterKeyboardShortcut({
|
||||
id: focusShortcut ? 'focus-pagination' : undefined,
|
||||
handler: focusPagination,
|
||||
isDisabled: isSinglePage,
|
||||
unregisterOnUnmount: true,
|
||||
})
|
||||
|
||||
if (isSinglePage) return null
|
||||
|
||||
const pageCount = t('count', { currentPage, totalPageCount })
|
||||
if (totalPageCount <= 1) return null
|
||||
|
||||
return (
|
||||
<div
|
||||
role="group"
|
||||
aria-label={`${t('label')}, ${pageCount}`}
|
||||
className={paginationToolbar({ placement })}
|
||||
<nav
|
||||
aria-label={t('label')}
|
||||
className={css({
|
||||
position: 'absolute',
|
||||
bottom: '1rem',
|
||||
left: '50%',
|
||||
transform: 'translateX(-50%)',
|
||||
alignItems: 'stretch',
|
||||
backgroundColor: 'var(--lk-control-bg)',
|
||||
borderRadius: 'var(--lk-border-radius)',
|
||||
transition: 'opacity ease-in-out .15s',
|
||||
display: 'none',
|
||||
border: '1px solid',
|
||||
borderColor: 'primaryDark.100',
|
||||
overflow: 'hidden',
|
||||
})}
|
||||
style={{
|
||||
display: interactive ? 'flex' : 'none',
|
||||
}}
|
||||
data-lk-user-interaction={interactive}
|
||||
>
|
||||
<Button
|
||||
ref={prevButtonRef}
|
||||
aria-disabled={isFirstPage}
|
||||
onPress={isFirstPage ? undefined : prevPage}
|
||||
size="sm"
|
||||
square
|
||||
variant="primaryTextDark"
|
||||
className={arrowButtonClass}
|
||||
isDisabled={currentPage == 1}
|
||||
onPress={prevPage}
|
||||
size="xs"
|
||||
variant="quaternaryText"
|
||||
aria-label={t('previous')}
|
||||
tooltip={t('previous')}
|
||||
>
|
||||
<RiArrowLeftSLine size={20} />
|
||||
<RiArrowLeftSLine />
|
||||
</Button>
|
||||
<span
|
||||
role="status"
|
||||
className={css({
|
||||
color: 'white',
|
||||
fontSize: '0.8125rem',
|
||||
fontWeight: 500,
|
||||
minWidth: '3.5rem',
|
||||
textAlign: 'center',
|
||||
userSelect: 'none',
|
||||
padding: '0 0.35rem',
|
||||
whiteSpace: 'nowrap',
|
||||
padding: '0.25rem 0.5rem',
|
||||
})}
|
||||
>
|
||||
{pageCount}
|
||||
{t('count', {
|
||||
currentPage,
|
||||
totalPageCount,
|
||||
})}
|
||||
</span>
|
||||
<Button
|
||||
ref={nextButtonRef}
|
||||
aria-disabled={isLastPage}
|
||||
onPress={isLastPage ? undefined : nextPage}
|
||||
size="sm"
|
||||
square
|
||||
variant="primaryTextDark"
|
||||
className={arrowButtonClass}
|
||||
isDisabled={currentPage == totalPageCount}
|
||||
onPress={nextPage}
|
||||
size="xs"
|
||||
variant="quaternaryText"
|
||||
aria-label={t('next')}
|
||||
tooltip={t('next')}
|
||||
>
|
||||
<RiArrowRightSLine size={20} />
|
||||
<RiArrowRightSLine />
|
||||
</Button>
|
||||
</div>
|
||||
</nav>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import * as React from 'react'
|
||||
|
||||
export interface PaginationIndicatorProps {
|
||||
totalPageCount: number
|
||||
currentPage: number
|
||||
}
|
||||
|
||||
export const PaginationIndicator: (
|
||||
props: PaginationIndicatorProps & React.RefAttributes<HTMLDivElement>
|
||||
) => React.ReactNode = /* @__PURE__ */ React.forwardRef<
|
||||
HTMLDivElement,
|
||||
PaginationIndicatorProps
|
||||
>(function PaginationIndicator(
|
||||
{ totalPageCount, currentPage }: PaginationIndicatorProps,
|
||||
ref
|
||||
) {
|
||||
const bubbles = new Array(totalPageCount).fill('').map((_, index) => {
|
||||
if (index + 1 === currentPage) {
|
||||
return <span data-lk-active key={index} />
|
||||
} else {
|
||||
return <span key={index} />
|
||||
}
|
||||
})
|
||||
|
||||
return (
|
||||
<div ref={ref} className="lk-pagination-indicator" aria-hidden="true">
|
||||
{bubbles}
|
||||
</div>
|
||||
)
|
||||
})
|
||||
@@ -0,0 +1,96 @@
|
||||
import { RiArrowLeftSLine, RiArrowRightSLine } from '@remixicon/react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { styled } from '@/styled-system/jsx'
|
||||
|
||||
interface PipPaginationProps {
|
||||
totalPageCount: number
|
||||
currentPage: number
|
||||
nextPage: () => void
|
||||
prevPage: () => void
|
||||
}
|
||||
|
||||
export const PipPagination = ({
|
||||
totalPageCount,
|
||||
currentPage,
|
||||
nextPage,
|
||||
prevPage,
|
||||
}: PipPaginationProps) => {
|
||||
const { t } = useTranslation('rooms', { keyPrefix: 'pagination' })
|
||||
|
||||
if (totalPageCount <= 1) return null
|
||||
|
||||
return (
|
||||
<Nav aria-label={t('label')}>
|
||||
<ArrowButton
|
||||
type="button"
|
||||
onClick={prevPage}
|
||||
disabled={currentPage === 1}
|
||||
aria-label={t('previous')}
|
||||
>
|
||||
<RiArrowLeftSLine size={18} />
|
||||
</ArrowButton>
|
||||
<Counter role="status">
|
||||
{t('count', { currentPage, totalPageCount })}
|
||||
</Counter>
|
||||
<ArrowButton
|
||||
type="button"
|
||||
onClick={nextPage}
|
||||
disabled={currentPage === totalPageCount}
|
||||
aria-label={t('next')}
|
||||
>
|
||||
<RiArrowRightSLine size={18} />
|
||||
</ArrowButton>
|
||||
</Nav>
|
||||
)
|
||||
}
|
||||
|
||||
const Nav = styled('nav', {
|
||||
base: {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: '0.25rem',
|
||||
marginTop: '1rem',
|
||||
flexShrink: 0,
|
||||
},
|
||||
})
|
||||
|
||||
const ArrowButton = styled('button', {
|
||||
base: {
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
width: '1.75rem',
|
||||
height: '1.75rem',
|
||||
borderRadius: '4px',
|
||||
border: 'none',
|
||||
cursor: 'pointer',
|
||||
color: 'white',
|
||||
backgroundColor: 'primaryDark.100',
|
||||
transition: 'opacity 0.15s, background-color 0.15s',
|
||||
'&:hover:not(:disabled)': {
|
||||
backgroundColor: 'primaryDark.75',
|
||||
},
|
||||
'&:focus-visible': {
|
||||
outline: '2px solid',
|
||||
outlineColor: 'white',
|
||||
outlineOffset: '2px',
|
||||
},
|
||||
'&:disabled': {
|
||||
opacity: 0.3,
|
||||
cursor: 'default',
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
const Counter = styled('span', {
|
||||
base: {
|
||||
fontSize: '0.75rem',
|
||||
color: 'white',
|
||||
opacity: 0.8,
|
||||
whiteSpace: 'nowrap',
|
||||
padding: '0 0.25rem',
|
||||
minWidth: '3rem',
|
||||
textAlign: 'center',
|
||||
},
|
||||
})
|
||||
@@ -4,7 +4,7 @@ import { RoomEvent, Track } from 'livekit-client'
|
||||
import { styled } from '@/styled-system/jsx'
|
||||
import { PipFocusLayout } from './PipFocusLayout'
|
||||
import { PipGridLayout } from './PipGridLayout'
|
||||
import { PaginationControl } from '@/features/layout/components/PaginationControl'
|
||||
import { PipPagination } from './PipPagination'
|
||||
import { PipScreenShareLayout } from './PipScreenShareLayout'
|
||||
import { StageFrame } from './StageFrame'
|
||||
import { MAX_PIP_TILES } from '../../utils/pipGrid'
|
||||
@@ -120,7 +120,12 @@ const PaginatedStage = ({
|
||||
}) => (
|
||||
<StageWrapper>
|
||||
<StageFrame>{children}</StageFrame>
|
||||
<PaginationControl {...pagination} placement="inline" />
|
||||
<PipPagination
|
||||
totalPageCount={pagination.totalPageCount}
|
||||
currentPage={pagination.currentPage}
|
||||
nextPage={pagination.nextPage}
|
||||
prevPage={pagination.prevPage}
|
||||
/>
|
||||
</StageWrapper>
|
||||
)
|
||||
|
||||
|
||||
@@ -31,20 +31,35 @@ export const useSidePanel = () => {
|
||||
const isSidePanelOpen = !!activePanelId
|
||||
const isSubPanelOpen = !!activeSubPanelId
|
||||
|
||||
// Reads the live store, not the render snapshot: shortcut handlers outlive
|
||||
// toggles that unmount (e.g. inside a closed overflow menu).
|
||||
const togglePanel = (panelId: PanelId) => {
|
||||
layoutStore.activePanelId =
|
||||
layoutStore.activePanelId === panelId ? null : panelId
|
||||
if (layoutStore.activeSubPanelId) layoutStore.activeSubPanelId = null
|
||||
const toggleAdmin = () => {
|
||||
layoutStore.activePanelId = isAdminOpen ? null : PanelId.ADMIN
|
||||
if (layoutSnap.activeSubPanelId) layoutStore.activeSubPanelId = null
|
||||
}
|
||||
|
||||
const toggleAdmin = () => togglePanel(PanelId.ADMIN)
|
||||
const toggleParticipants = () => togglePanel(PanelId.PARTICIPANTS)
|
||||
const toggleChat = () => togglePanel(PanelId.CHAT)
|
||||
const toggleEffects = () => togglePanel(PanelId.EFFECTS)
|
||||
const toggleTools = () => togglePanel(PanelId.TOOLS)
|
||||
const toggleInfo = () => togglePanel(PanelId.INFO)
|
||||
const toggleParticipants = () => {
|
||||
layoutStore.activePanelId = isParticipantsOpen ? null : PanelId.PARTICIPANTS
|
||||
if (layoutSnap.activeSubPanelId) layoutStore.activeSubPanelId = null
|
||||
}
|
||||
|
||||
const toggleChat = () => {
|
||||
layoutStore.activePanelId = isChatOpen ? null : PanelId.CHAT
|
||||
if (layoutSnap.activeSubPanelId) layoutStore.activeSubPanelId = null
|
||||
}
|
||||
|
||||
const toggleEffects = () => {
|
||||
layoutStore.activePanelId = isEffectsOpen ? null : PanelId.EFFECTS
|
||||
if (layoutSnap.activeSubPanelId) layoutStore.activeSubPanelId = null
|
||||
}
|
||||
|
||||
const toggleTools = () => {
|
||||
layoutStore.activePanelId = isToolsOpen ? null : PanelId.TOOLS
|
||||
if (layoutSnap.activeSubPanelId) layoutStore.activeSubPanelId = null
|
||||
}
|
||||
|
||||
const toggleInfo = () => {
|
||||
layoutStore.activePanelId = isInfoOpen ? null : PanelId.INFO
|
||||
if (layoutSnap.activeSubPanelId) layoutStore.activeSubPanelId = null
|
||||
}
|
||||
|
||||
const openTranscript = () => {
|
||||
layoutStore.activeSubPanelId = SubPanelId.TRANSCRIPT
|
||||
|
||||
@@ -1,14 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { normalizeRoomId } from './isRoomValid'
|
||||
|
||||
describe('normalizeRoomId', () => {
|
||||
it('lowercases and re-inserts the hyphens of a ten-letter id', () => {
|
||||
expect(normalizeRoomId('ABCDEFGHIJ')).toBe('abc-defg-hij')
|
||||
expect(normalizeRoomId('abc-defghij')).toBe('abc-defg-hij')
|
||||
})
|
||||
|
||||
it('returns any other input unchanged', () => {
|
||||
expect(normalizeRoomId('abc-def')).toBe('abc-def')
|
||||
expect(normalizeRoomId('Not-A-Room')).toBe('Not-A-Room')
|
||||
})
|
||||
})
|
||||
@@ -7,7 +7,6 @@ export type ShortcutCategory = 'navigation' | 'media' | 'interaction'
|
||||
export type ShortcutId =
|
||||
| 'open-shortcuts'
|
||||
| 'focus-toolbar'
|
||||
| 'focus-pagination'
|
||||
| 'toggle-microphone'
|
||||
| 'toggle-camera'
|
||||
| 'push-to-talk'
|
||||
@@ -45,11 +44,6 @@ export const shortcutCatalog: ShortcutDescriptor[] = [
|
||||
category: 'navigation',
|
||||
shortcut: { key: 'F2' },
|
||||
},
|
||||
{
|
||||
id: 'focus-pagination',
|
||||
category: 'navigation',
|
||||
shortcut: { key: 'G', ctrlKey: true, shiftKey: true },
|
||||
},
|
||||
{
|
||||
id: 'toggle-microphone',
|
||||
category: 'media',
|
||||
|
||||
@@ -7,16 +7,12 @@ export type useRegisterKeyboardShortcutProps = {
|
||||
id?: ShortcutId
|
||||
handler: () => Promise<void | boolean | undefined> | void
|
||||
isDisabled?: boolean
|
||||
// Opt-in: controls rendered inside menus unmount when the menu closes, and
|
||||
// their shortcuts must keep working.
|
||||
unregisterOnUnmount?: boolean
|
||||
}
|
||||
|
||||
export const useRegisterKeyboardShortcut = ({
|
||||
id,
|
||||
handler,
|
||||
isDisabled = false,
|
||||
unregisterOnUnmount = false,
|
||||
}: useRegisterKeyboardShortcutProps) => {
|
||||
useEffect(() => {
|
||||
if (!id) return
|
||||
@@ -25,14 +21,8 @@ export const useRegisterKeyboardShortcut = ({
|
||||
const formattedKey = formatShortcutKey(descriptor.shortcut)
|
||||
if (isDisabled) {
|
||||
keyboardShortcutsStore.shortcuts.delete(formattedKey)
|
||||
return
|
||||
} else {
|
||||
keyboardShortcutsStore.shortcuts.set(formattedKey, handler)
|
||||
}
|
||||
keyboardShortcutsStore.shortcuts.set(formattedKey, handler)
|
||||
if (!unregisterOnUnmount) return
|
||||
return () => {
|
||||
if (keyboardShortcutsStore.shortcuts.get(formattedKey) === handler) {
|
||||
keyboardShortcutsStore.shortcuts.delete(formattedKey)
|
||||
}
|
||||
}
|
||||
}, [handler, id, isDisabled, unregisterOnUnmount])
|
||||
}, [handler, id, isDisabled])
|
||||
}
|
||||
|
||||
@@ -784,7 +784,6 @@
|
||||
"actions": {
|
||||
"open-shortcuts": "Tastenkürzel-Hilfe öffnen",
|
||||
"focus-toolbar": "Fokus auf die untere Symbolleiste",
|
||||
"focus-pagination": "Fokus auf die Teilnehmerseiten",
|
||||
"toggle-microphone": "Mikrofon umschalten",
|
||||
"toggle-camera": "Kamera umschalten",
|
||||
"push-to-talk": "Push-to-talk",
|
||||
|
||||
@@ -784,7 +784,6 @@
|
||||
"actions": {
|
||||
"open-shortcuts": "Open shortcuts help",
|
||||
"focus-toolbar": "Focus bottom toolbar",
|
||||
"focus-pagination": "Focus participant pagination",
|
||||
"toggle-microphone": "Toggle microphone",
|
||||
"toggle-camera": "Toggle camera",
|
||||
"push-to-talk": "Push-to-talk (hold to unmute)",
|
||||
|
||||
@@ -783,7 +783,6 @@
|
||||
"actions": {
|
||||
"open-shortcuts": "Abrir la ayuda de atajos",
|
||||
"focus-toolbar": "Poner el foco en la barra de herramientas inferior",
|
||||
"focus-pagination": "Poner el foco en la paginación de participantes",
|
||||
"toggle-microphone": "Activar o desactivar el micrófono",
|
||||
"toggle-camera": "Activar o desactivar la cámara",
|
||||
"push-to-talk": "Pulsar para hablar (mantener para reactivar)",
|
||||
|
||||
@@ -784,7 +784,6 @@
|
||||
"actions": {
|
||||
"open-shortcuts": "Ouvrir l’aide des raccourcis",
|
||||
"focus-toolbar": "Mettre le focus sur la barre d’outils du bas",
|
||||
"focus-pagination": "Mettre le focus sur la pagination des participants",
|
||||
"toggle-microphone": "Activer ou désactiver le micro",
|
||||
"toggle-camera": "Activer ou désactiver la caméra",
|
||||
"push-to-talk": "Appuyer pour parler (maintenir pour réactiver)",
|
||||
|
||||
@@ -784,7 +784,6 @@
|
||||
"actions": {
|
||||
"open-shortcuts": "Sneltoetsenhulp openen",
|
||||
"focus-toolbar": "Focus op de onderste werkbalk",
|
||||
"focus-pagination": "Focus op de paginering van deelnemers",
|
||||
"toggle-microphone": "Microfoon aan/uit",
|
||||
"toggle-camera": "Camera aan/uit",
|
||||
"push-to-talk": "Push-to-talk (ingedrukt houden om te activeren)",
|
||||
|
||||
@@ -10,5 +10,5 @@
|
||||
"noEmit": true,
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": ["vite.config.ts", "vitest.config.ts"]
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
|
||||
@@ -1,8 +0,0 @@
|
||||
import { defineConfig } from 'vitest/config'
|
||||
|
||||
// Kept apart from vite.config.ts so the tests load none of the build plugins.
|
||||
export default defineConfig({
|
||||
resolve: {
|
||||
tsconfigPaths: true,
|
||||
},
|
||||
})
|
||||
@@ -24,7 +24,7 @@ _summaryEnvVars: &summaryEnvVars
|
||||
APP_NAME: summary-microservice
|
||||
APP_API_TOKEN: password
|
||||
AWS_STORAGE_BUCKET_NAME: meet-media-storage
|
||||
AWS_S3_ENDPOINT_URL: http://dev-backend-garage.meet.svc.cluster.local:9000/
|
||||
AWS_S3_ENDPOINT_URL: http://garage.meet.svc.cluster.local:9000/
|
||||
AWS_S3_ACCESS_KEY_ID: meet-access-key
|
||||
AWS_S3_SECRET_ACCESS_KEY: meet-secret-access-key
|
||||
AWS_S3_SECURE_ACCESS: False
|
||||
@@ -69,9 +69,9 @@ _summaryEnvVars: &summaryEnvVars
|
||||
LLM_MODEL: Qwen/Qwen3-Coder-30B-A3B-Instruct
|
||||
WEBHOOK_API_TOKEN: password
|
||||
WEBHOOK_URL: https://www.mock-impress.com/webhook/
|
||||
CELERY_BROKER_URL: redis://user:pass@dev-backend-redis:6379/1
|
||||
CELERY_RESULT_BACKEND: redis://user:pass@dev-backend-redis:6379/1
|
||||
TASK_TRACKER_REDIS_URL: redis://user:pass@dev-backend-redis:6379/1
|
||||
CELERY_BROKER_URL: redis://default:pass@redis-master:6379/1
|
||||
CELERY_RESULT_BACKEND: redis://default:pass@redis-master:6379/1
|
||||
TASK_TRACKER_REDIS_URL: redis://default:pass@redis-master:6379/1
|
||||
IS_RESOLVE_SPEAKER_IDENTITIES_ENABLED: true
|
||||
RESOLVE_SPEAKER_IDENTITIES_DEFAULT_OVERLAP: 0.5
|
||||
RESOLVE_SPEAKER_ENABLE_SPLIT_ON_WORDS: true
|
||||
@@ -127,21 +127,12 @@ backend:
|
||||
LOGIN_REDIRECT_URL_FAILURE: https://meet.127.0.0.1.nip.io
|
||||
LOGOUT_REDIRECT_URL: https://meet.127.0.0.1.nip.io
|
||||
# Databases
|
||||
DB_HOST: dev-backend-postgres
|
||||
DB_NAME:
|
||||
secretKeyRef:
|
||||
name: dev-backend-postgres
|
||||
key: database
|
||||
DB_USER:
|
||||
secretKeyRef:
|
||||
name: dev-backend-postgres
|
||||
key: username
|
||||
DB_PASSWORD:
|
||||
secretKeyRef:
|
||||
name: dev-backend-postgres
|
||||
key: password
|
||||
DB_HOST: postgres
|
||||
DB_NAME: meet
|
||||
DB_USER: dinum
|
||||
DB_PASSWORD: pass
|
||||
DB_PORT: 5432
|
||||
REDIS_URL: redis://user:pass@dev-backend-redis:6379/1
|
||||
REDIS_URL: redis://default:pass@redis-master:6379/1
|
||||
# Static files
|
||||
STORAGES_STATICFILES_BACKEND: django.contrib.staticfiles.storage.StaticFilesStorage
|
||||
# Permissions
|
||||
@@ -172,7 +163,7 @@ backend:
|
||||
FRONTEND_TRANSCRIPTION_DESTINATION: "https://docs.numerique.gouv.fr"
|
||||
FRONTEND_IS_SILENT_LOGIN_ENABLED: False
|
||||
# S3 Storage
|
||||
AWS_S3_ENDPOINT_URL: http://dev-backend-garage.meet.svc.cluster.local:9000
|
||||
AWS_S3_ENDPOINT_URL: http://garage.meet.svc.cluster.local:9000
|
||||
AWS_S3_ACCESS_KEY_ID: meet-access-key
|
||||
AWS_S3_SECRET_ACCESS_KEY: meet-secret-access-key
|
||||
AWS_STORAGE_BUCKET_NAME: meet-media-storage
|
||||
@@ -195,7 +186,7 @@ backend:
|
||||
MEDIA_BASE_URL: https://meet.127.0.0.1.nip.io
|
||||
FILE_UPLOAD_ENABLED: True
|
||||
CELERY_ENABLED: True
|
||||
CELERY_BROKER_URL: redis://user:pass@dev-backend-redis:6379/1
|
||||
CELERY_BROKER_URL: redis://default:pass@redis-master:6379/1
|
||||
# Recording & Transcription
|
||||
RECORDING_ENABLE: True
|
||||
SUMMARY_SERVICE_ENDPOINT: http://meet-summary:80/api/v2/async-jobs/transcribe/
|
||||
@@ -275,11 +266,11 @@ ingressMedia:
|
||||
annotations:
|
||||
nginx.ingress.kubernetes.io/auth-url: https://meet.127.0.0.1.nip.io/api/v1.0/recordings/media-auth/
|
||||
nginx.ingress.kubernetes.io/auth-response-headers: "Authorization, X-Amz-Date, X-Amz-Content-SHA256"
|
||||
nginx.ingress.kubernetes.io/upstream-vhost: dev-backend-garage.meet.svc.cluster.local:9000
|
||||
nginx.ingress.kubernetes.io/upstream-vhost: garage.meet.svc.cluster.local:9000
|
||||
nginx.ingress.kubernetes.io/rewrite-target: /meet-media-storage/$1
|
||||
|
||||
serviceMedia:
|
||||
host: dev-backend-garage.meet.svc.cluster.local
|
||||
host: garage.meet.svc.cluster.local
|
||||
port: 9000
|
||||
|
||||
# ---- Extra ingress/service for background file uploads ------------
|
||||
@@ -291,11 +282,11 @@ ingressMediaFiles:
|
||||
annotations:
|
||||
nginx.ingress.kubernetes.io/auth-url: https://meet.127.0.0.1.nip.io/api/v1.0/files/media-auth/
|
||||
nginx.ingress.kubernetes.io/auth-response-headers: "Authorization, X-Amz-Date, X-Amz-Content-SHA256"
|
||||
nginx.ingress.kubernetes.io/upstream-vhost: dev-backend-garage.meet.svc.cluster.local:9000
|
||||
nginx.ingress.kubernetes.io/upstream-vhost: garage.meet.svc.cluster.local:9000
|
||||
nginx.ingress.kubernetes.io/rewrite-target: /meet-media-storage/files/$1
|
||||
|
||||
serviceMediaFiles:
|
||||
host: dev-backend-garage.meet.svc.cluster.local
|
||||
host: garage.meet.svc.cluster.local
|
||||
port: 9000
|
||||
|
||||
# ---- STT Orchestration Microservice Components --------------------
|
||||
@@ -373,7 +364,7 @@ agentMetadata:
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
ENABLE_SILERO_VAD: "false"
|
||||
AWS_S3_ENDPOINT_URL: dev-backend-garage.meet.svc.cluster.local:9000
|
||||
AWS_S3_ENDPOINT_URL: garage.meet.svc.cluster.local:9000
|
||||
AWS_S3_ACCESS_KEY_ID: meet-access-key
|
||||
AWS_S3_SECRET_ACCESS_KEY: meet-secret-access-key
|
||||
AWS_S3_SECURE_ACCESS: False
|
||||
|
||||
@@ -13,15 +13,14 @@ egress:
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
redis:
|
||||
address: dev-backend-redis:6379
|
||||
username: user
|
||||
address: redis-master:6379
|
||||
password: pass
|
||||
s3:
|
||||
access_key: meet-access-key
|
||||
secret: meet-secret-access-key
|
||||
region: local
|
||||
bucket: meet-media-storage
|
||||
endpoint: http://dev-backend-garage:9000
|
||||
endpoint: http://garage:9000
|
||||
force_path_style: true
|
||||
|
||||
loadBalancer:
|
||||
|
||||
@@ -14,8 +14,7 @@ livekit:
|
||||
port_range_end: 60000
|
||||
tcp_port: 7881
|
||||
redis:
|
||||
address: dev-backend-redis:6379
|
||||
username: user
|
||||
address: redis-master:6379
|
||||
password: pass
|
||||
keys:
|
||||
turn:
|
||||
|
||||
@@ -16,15 +16,14 @@ egress:
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
redis:
|
||||
address: dev-backend-redis:6379
|
||||
username: user
|
||||
address: redis-master:6379
|
||||
password: pass
|
||||
s3:
|
||||
access_key: meet-access-key
|
||||
secret: meet-secret-access-key
|
||||
region: local
|
||||
bucket: meet-media-storage
|
||||
endpoint: http://dev-backend-garage:9000
|
||||
endpoint: http://garage:9000
|
||||
force_path_style: true
|
||||
|
||||
loadBalancer:
|
||||
|
||||
@@ -14,8 +14,7 @@ livekit:
|
||||
port_range_end: 60000
|
||||
tcp_port: 7881
|
||||
redis:
|
||||
address: dev-backend-redis:6379
|
||||
username: user
|
||||
address: redis-master:6379
|
||||
password: pass
|
||||
keys:
|
||||
turn:
|
||||
|
||||
@@ -0,0 +1,172 @@
|
||||
---
|
||||
apiVersion: networking.k8s.io/v1
|
||||
kind: Ingress
|
||||
metadata:
|
||||
name: garage
|
||||
annotations:
|
||||
nginx.ingress.kubernetes.io/proxy-body-size: 10m
|
||||
spec:
|
||||
rules:
|
||||
- host: "garage.127.0.0.1.nip.io"
|
||||
http:
|
||||
paths:
|
||||
- path: /
|
||||
pathType: Prefix
|
||||
backend:
|
||||
service:
|
||||
name: garage
|
||||
port:
|
||||
number: 9000
|
||||
tls:
|
||||
- hosts:
|
||||
- garage.127.0.0.1.nip.io
|
||||
secretName: meet-tls
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: garage
|
||||
namespace: {{ .Release.Namespace | quote }}
|
||||
spec:
|
||||
ports:
|
||||
- name: client
|
||||
port: 9000
|
||||
protocol: TCP
|
||||
targetPort: 9000
|
||||
selector:
|
||||
app.kubernetes.io/instance: extra
|
||||
app.kubernetes.io/name: garage
|
||||
type: ClusterIP
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: ConfigMap
|
||||
metadata:
|
||||
name: garage-config
|
||||
namespace: {{ .Release.Namespace | quote }}
|
||||
data:
|
||||
garage.toml: |
|
||||
metadata_dir = "/var/lib/garage/meta"
|
||||
data_dir = "/var/lib/garage/data"
|
||||
db_engine = "lmdb"
|
||||
|
||||
replication_factor = 1
|
||||
|
||||
rpc_bind_addr = "127.0.0.1:3901"
|
||||
rpc_public_addr = "127.0.0.1:3901"
|
||||
|
||||
[s3_api]
|
||||
api_bind_addr = "[::]:9000"
|
||||
# Clients must sign their requests for this region (AWS_S3_REGION_NAME)
|
||||
s3_region = "local"
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Secret
|
||||
metadata:
|
||||
name: garage-dev
|
||||
namespace: {{ .Release.Namespace | quote }}
|
||||
type: Opaque
|
||||
data:
|
||||
GARAGE_RPC_SECRET: {{ printf "%s/garage-rpc-secret" .Release.Namespace | sha256sum | b64enc }}
|
||||
---
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: garage
|
||||
namespace: {{ .Release.Namespace | quote }}
|
||||
labels:
|
||||
app.kubernetes.io/instance: extra
|
||||
app.kubernetes.io/name: garage
|
||||
spec:
|
||||
selector:
|
||||
matchLabels:
|
||||
app.kubernetes.io/instance: extra
|
||||
app.kubernetes.io/name: garage
|
||||
replicas: 1
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app.kubernetes.io/instance: extra
|
||||
app.kubernetes.io/name: garage
|
||||
spec:
|
||||
containers:
|
||||
- name: garage
|
||||
command:
|
||||
- /garage
|
||||
- server
|
||||
- --single-node
|
||||
- --default-bucket
|
||||
env:
|
||||
- name: GARAGE_RPC_SECRET
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: garage-dev
|
||||
key: GARAGE_RPC_SECRET
|
||||
- name: GARAGE_DEFAULT_ACCESS_KEY
|
||||
value: meet-access-key
|
||||
- name: GARAGE_DEFAULT_SECRET_KEY
|
||||
value: meet-secret-access-key
|
||||
- name: GARAGE_DEFAULT_BUCKET
|
||||
value: meet-media-storage
|
||||
image: "dxflrs/garage:v2.4.1"
|
||||
imagePullPolicy: IfNotPresent
|
||||
ports:
|
||||
- containerPort: 9000
|
||||
name: client
|
||||
readinessProbe:
|
||||
exec:
|
||||
command:
|
||||
- /garage
|
||||
- health
|
||||
volumeMounts:
|
||||
- mountPath: /etc/garage.toml
|
||||
name: config
|
||||
subPath: garage.toml
|
||||
- mountPath: /var/lib/garage
|
||||
name: data
|
||||
volumes:
|
||||
- name: config
|
||||
configMap:
|
||||
name: garage-config
|
||||
- name: data
|
||||
emptyDir:
|
||||
---
|
||||
# Garage denies cross-origin requests by default: allow the frontend to upload
|
||||
# files straight to the bucket
|
||||
apiVersion: batch/v1
|
||||
kind: Job
|
||||
metadata:
|
||||
name: garage-cors
|
||||
spec:
|
||||
template:
|
||||
spec:
|
||||
containers:
|
||||
- name: aws-cli
|
||||
image: amazon/aws-cli:2.37.1
|
||||
env:
|
||||
- name: AWS_ACCESS_KEY_ID
|
||||
value: meet-access-key
|
||||
- name: AWS_SECRET_ACCESS_KEY
|
||||
value: meet-secret-access-key
|
||||
- name: AWS_DEFAULT_REGION
|
||||
value: local
|
||||
- name: AWS_ENDPOINT_URL
|
||||
value: http://garage:9000
|
||||
- name: BUCKET
|
||||
value: meet-media-storage
|
||||
command:
|
||||
- /bin/sh
|
||||
- -c
|
||||
- |
|
||||
deadline=$(($(date +%s) + 300))
|
||||
until aws s3api head-bucket --bucket="$BUCKET" --cli-connect-timeout=5; do
|
||||
if [ "$(date +%s)" -ge "$deadline" ]; then
|
||||
echo "Bucket $BUCKET still unavailable on $AWS_ENDPOINT_URL" >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "Waiting for bucket $BUCKET on $AWS_ENDPOINT_URL"
|
||||
sleep 5
|
||||
done
|
||||
exec aws s3api put-bucket-cors --bucket="$BUCKET" \
|
||||
--cors-configuration='{"CORSRules": [{"AllowedOrigins": ["https://meet.127.0.0.1.nip.io"], "AllowedMethods": ["GET", "HEAD", "PUT"], "AllowedHeaders": ["*"], "ExposeHeaders": ["ETag"]}]}'
|
||||
restartPolicy: Never
|
||||
backoffLimit: 3
|
||||
@@ -0,0 +1,61 @@
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: kc-postgres
|
||||
namespace: {{ .Release.Namespace | quote }}
|
||||
spec:
|
||||
ports:
|
||||
- name: tcp-postgresql
|
||||
port: 5432
|
||||
protocol: TCP
|
||||
targetPort: tcp-postgresql
|
||||
selector:
|
||||
app.kubernetes.io/instance: extra
|
||||
app.kubernetes.io/name: kc-postgresql
|
||||
type: ClusterIP
|
||||
---
|
||||
apiVersion: apps/v1
|
||||
kind: StatefulSet
|
||||
metadata:
|
||||
name: kc-postgresql
|
||||
namespace: {{ .Release.Namespace | quote }}
|
||||
spec:
|
||||
selector:
|
||||
matchLabels:
|
||||
app.kubernetes.io/instance: extra
|
||||
app.kubernetes.io/name: kc-postgresql
|
||||
serviceName: "kc-postgres"
|
||||
replicas: 1
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app.kubernetes.io/instance: extra
|
||||
app.kubernetes.io/name: kc-postgresql
|
||||
spec:
|
||||
terminationGracePeriodSeconds: 10
|
||||
containers:
|
||||
- name: pg
|
||||
image: postgres:16-alpine
|
||||
ports:
|
||||
- containerPort: 5432
|
||||
name: tcp-postgresql
|
||||
env:
|
||||
- name: POSTGRES_PASSWORD
|
||||
value: pass
|
||||
- name: POSTGRES_USER
|
||||
value: dinum
|
||||
- name: POSTGRES_DB
|
||||
value: keycloak
|
||||
volumeMounts:
|
||||
- name: data
|
||||
mountPath: /var/lib/postgresql
|
||||
volumeClaimTemplates:
|
||||
- metadata:
|
||||
name: data
|
||||
spec:
|
||||
accessModes: [ "ReadWriteOnce" ]
|
||||
resources:
|
||||
requests:
|
||||
storage: 1Gi
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
---
|
||||
apiVersion: networking.k8s.io/v1
|
||||
kind: Ingress
|
||||
metadata:
|
||||
name: keycloak
|
||||
spec:
|
||||
rules:
|
||||
- host: "keycloak.127.0.0.1.nip.io"
|
||||
http:
|
||||
paths:
|
||||
- path: /
|
||||
pathType: Prefix
|
||||
backend:
|
||||
service:
|
||||
name: keycloak
|
||||
port:
|
||||
number: 8080
|
||||
tls:
|
||||
- hosts:
|
||||
- keycloak.127.0.0.1.nip.io
|
||||
secretName: meet-tls
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: keycloak
|
||||
namespace: {{ .Release.Namespace | quote }}
|
||||
spec:
|
||||
ports:
|
||||
- name: tcp-keycloak
|
||||
port: 8080
|
||||
protocol: TCP
|
||||
targetPort: tcp-keycloak
|
||||
selector:
|
||||
app.kubernetes.io/instance: extra
|
||||
app.kubernetes.io/name: keycloak
|
||||
type: ClusterIP
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: ConfigMap
|
||||
metadata:
|
||||
name: realm
|
||||
data:
|
||||
meet.json: |
|
||||
{{ .Values.realm | indent 4 }}
|
||||
---
|
||||
apiVersion: apps/v1
|
||||
kind: StatefulSet
|
||||
metadata:
|
||||
name: keycloak
|
||||
namespace: {{ .Release.Namespace | quote }}
|
||||
spec:
|
||||
selector:
|
||||
matchLabels:
|
||||
app.kubernetes.io/instance: extra
|
||||
app.kubernetes.io/name: keycloak
|
||||
serviceName: "keycloak"
|
||||
replicas: 1
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app.kubernetes.io/instance: extra
|
||||
app.kubernetes.io/name: keycloak
|
||||
spec:
|
||||
terminationGracePeriodSeconds: 10
|
||||
containers:
|
||||
- name: keycloak
|
||||
image: quay.io/keycloak/keycloak:20.0.1
|
||||
args:
|
||||
- start-dev
|
||||
- --features=preview
|
||||
- --import-realm
|
||||
- --proxy=edge
|
||||
- --hostname=keycloak.127.0.0.1.nip.io
|
||||
- --hostname-strict=false
|
||||
- --hostname-strict-https=false
|
||||
ports:
|
||||
- containerPort: 8080
|
||||
name: tcp-keycloak
|
||||
env:
|
||||
- name: KEYCLOAK_ADMIN
|
||||
value: admin
|
||||
- name: KEYCLOAK_ADMIN_PASSWORD
|
||||
value: admin
|
||||
- name: PROXY_ADDRESS_FORWARDING
|
||||
value: 'true'
|
||||
- name: KC_DB_URL_HOST
|
||||
value: kc_postgresql
|
||||
- name: KC_DB_URL_DATABASE
|
||||
value: keycloak
|
||||
- name: KC_DB_PASSWORD
|
||||
value: pass
|
||||
- name: KC_DB_USERNAME
|
||||
value: dinum
|
||||
- name: KC_DB_SCHEMA
|
||||
value: public
|
||||
volumeMounts:
|
||||
- name: realm
|
||||
mountPath: "/opt/keycloak/data/import"
|
||||
readOnly: true
|
||||
volumes:
|
||||
- name: realm
|
||||
configMap:
|
||||
name: realm
|
||||
@@ -0,0 +1,70 @@
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: postgres
|
||||
namespace: {{ .Release.Namespace | quote }}
|
||||
spec:
|
||||
ports:
|
||||
- name: tcp-postgresql
|
||||
port: 5432
|
||||
protocol: TCP
|
||||
targetPort: tcp-postgresql
|
||||
selector:
|
||||
app.kubernetes.io/instance: extra
|
||||
app.kubernetes.io/name: postgresql
|
||||
type: ClusterIP
|
||||
---
|
||||
apiVersion: apps/v1
|
||||
kind: StatefulSet
|
||||
metadata:
|
||||
name: postgresql
|
||||
namespace: {{ .Release.Namespace | quote }}
|
||||
spec:
|
||||
selector:
|
||||
matchLabels:
|
||||
app.kubernetes.io/instance: extra
|
||||
app.kubernetes.io/name: postgresql
|
||||
serviceName: "postgres"
|
||||
replicas: 1
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app.kubernetes.io/instance: extra
|
||||
app.kubernetes.io/name: postgresql
|
||||
spec:
|
||||
terminationGracePeriodSeconds: 10
|
||||
containers:
|
||||
- name: pg
|
||||
image: postgres:16-alpine
|
||||
readinessProbe:
|
||||
exec:
|
||||
command: [ "pg_isready", "-U", "dinum", "-d", "meet", "-h", "127.0.0.1" ]
|
||||
initialDelaySeconds: 5
|
||||
periodSeconds: 5
|
||||
livenessProbe:
|
||||
exec:
|
||||
command: [ "pg_isready", "-U", "dinum", "-d", "meet", "-h", "127.0.0.1" ]
|
||||
initialDelaySeconds: 15
|
||||
periodSeconds: 10
|
||||
ports:
|
||||
- containerPort: 5432
|
||||
name: tcp-postgresql
|
||||
env:
|
||||
- name: POSTGRES_PASSWORD
|
||||
value: pass
|
||||
- name: POSTGRES_USER
|
||||
value: dinum
|
||||
- name: POSTGRES_DB
|
||||
value: meet
|
||||
volumeMounts:
|
||||
- name: data
|
||||
mountPath: /var/lib/postgresql/data
|
||||
volumeClaimTemplates:
|
||||
- metadata:
|
||||
name: data
|
||||
spec:
|
||||
accessModes: [ "ReadWriteOnce" ]
|
||||
resources:
|
||||
requests:
|
||||
storage: 1Gi
|
||||
@@ -0,0 +1,65 @@
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: redis-master
|
||||
namespace: {{ .Release.Namespace | quote }}
|
||||
spec:
|
||||
ports:
|
||||
- name: tcp-redis
|
||||
port: 6379
|
||||
protocol: TCP
|
||||
targetPort: tcp-redis
|
||||
selector:
|
||||
app.kubernetes.io/instance: extra
|
||||
app.kubernetes.io/name: redis
|
||||
type: ClusterIP
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: ConfigMap
|
||||
metadata:
|
||||
name: redis
|
||||
data:
|
||||
redis.conf: |
|
||||
bind 0.0.0.0
|
||||
port 6379
|
||||
user default on >pass ~* &* +@all
|
||||
---
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: redis
|
||||
namespace: {{ .Release.Namespace | quote }}
|
||||
labels:
|
||||
app.kubernetes.io/instance: extra
|
||||
app.kubernetes.io/name: redis
|
||||
spec:
|
||||
selector:
|
||||
matchLabels:
|
||||
app.kubernetes.io/instance: extra
|
||||
app.kubernetes.io/name: redis
|
||||
replicas: 1
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app.kubernetes.io/instance: extra
|
||||
app.kubernetes.io/name: redis
|
||||
spec:
|
||||
containers:
|
||||
- name: redis
|
||||
args:
|
||||
- redis-server
|
||||
- /usr/local/etc/redis/redis.conf
|
||||
image: "redis:8.2-alpine"
|
||||
imagePullPolicy: IfNotPresent
|
||||
ports:
|
||||
- containerPort: 6379
|
||||
name: tcp-redis
|
||||
volumeMounts:
|
||||
- name: redis
|
||||
mountPath: "/usr/local/etc/redis"
|
||||
readOnly: true
|
||||
volumes:
|
||||
- name: redis
|
||||
configMap:
|
||||
name: redis
|
||||
@@ -13,9 +13,6 @@ repositories:
|
||||
- name: livekit
|
||||
url: https://helm.livekit.io
|
||||
|
||||
- name: dev-backends
|
||||
url: https://suitenumerique.github.io/helm-dev-backend
|
||||
|
||||
releases:
|
||||
- name: extra
|
||||
installed: {{ regexMatch "^dev.*" .Environment.Name }}
|
||||
@@ -30,59 +27,8 @@ releases:
|
||||
- enablePermanentRedirect: {{ .Values | get "enablePermanentRedirect" "False"}}
|
||||
- oldDomain: {{ .Values | get "oldDomain" "demo.com" }}
|
||||
- newDomain: {{ .Values | get "newDomain" "demo.com" }}
|
||||
|
||||
- name: dev-backend
|
||||
installed: {{ regexMatch "^dev.*" .Environment.Name }}
|
||||
namespace: {{ .Namespace }}
|
||||
chart: dev-backends/dev-backend
|
||||
version: 0.0.14
|
||||
values:
|
||||
- postgres:
|
||||
enabled: true
|
||||
username: dinum
|
||||
password: pass
|
||||
database: meet
|
||||
size: 1Gi
|
||||
- redis:
|
||||
enabled: true
|
||||
username: user
|
||||
password: pass
|
||||
- garage:
|
||||
enabled: true
|
||||
accessKey: meet-access-key
|
||||
secretKey: meet-secret-access-key
|
||||
bucket: meet-media-storage
|
||||
region: local
|
||||
persistence: false
|
||||
ingress:
|
||||
enabled: true
|
||||
hostname: garage.127.0.0.1.nip.io
|
||||
annotations:
|
||||
nginx.ingress.kubernetes.io/proxy-body-size: 10m
|
||||
tls:
|
||||
enabled: true
|
||||
secretName: meet-tls
|
||||
cors:
|
||||
allowedOrigins:
|
||||
- https://meet.127.0.0.1.nip.io
|
||||
- keycloak:
|
||||
enabled: true
|
||||
hostname: keycloak.127.0.0.1.nip.io
|
||||
username: admin
|
||||
password: admin
|
||||
tls:
|
||||
enabled: true
|
||||
secretName: meet-tls
|
||||
db:
|
||||
username: dinum
|
||||
password: pass
|
||||
database: keycloak
|
||||
size: 1Gi
|
||||
realm:
|
||||
name: meet
|
||||
username: meet
|
||||
password: meet
|
||||
email: meet@example.com
|
||||
- realm: |
|
||||
{{ readFile "../../docker/auth/realm.json" | replace "http://localhost:3200" "https://meet.127.0.0.1.nip.io" | indent 8 }}
|
||||
|
||||
|
||||
- name: meet
|
||||
|
||||
Reference in New Issue
Block a user