mirror of
https://github.com/suitenumerique/meet.git
synced 2026-07-28 12:49:34 +00:00
Compare commits
78 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ca98cf5fac | |||
| e56c0f997e | |||
| 61afd94e3a | |||
| 3d7aec2b4a | |||
| 9c009839f0 | |||
| ec63ddcd47 | |||
| fcde8757e6 | |||
| 9610e606eb | |||
| 8362ac0e24 | |||
| f1ddd7fa2f | |||
| 487340efca | |||
| 7ebc928dd3 | |||
| 85de214ca7 | |||
| e3e34dbf31 | |||
| 555afe4abd | |||
| 78ddb121e3 | |||
| ca9c7fc152 | |||
| 6e3845d0c1 | |||
| 41b171da68 | |||
| 4ad897e756 | |||
| 42647d6d25 | |||
| 14526808ab | |||
| 25167495cc | |||
| 720eb6a93e | |||
| bfbf253033 | |||
| 692e0e359e | |||
| 1d23cb889a | |||
| b2ad423886 | |||
| 2c7b4bea04 | |||
| 1eda18ea6e | |||
| 8d5488c333 | |||
| 5c0e6b6479 | |||
| 077cf59082 | |||
| 4881fa20f5 | |||
| 116db1e697 | |||
| 4b76e9571f | |||
| e8739d7e70 | |||
| 602bcf3185 | |||
| f5e0ddf692 | |||
| cd0cec78ba | |||
| e647787170 | |||
| d76b4c9b9f | |||
| 09c7edecb8 | |||
| f625df6508 | |||
| ac87980a27 | |||
| 7cab46dc29 | |||
| 259b739160 | |||
| 6f77559633 | |||
| 2cdf19de77 | |||
| fcf08a6dbd | |||
| 7bf623f654 | |||
| 1c1d1938d9 | |||
| ddb81765f3 | |||
| 8ca52737cd | |||
| 87b9ca2314 | |||
| 8a6419da44 | |||
| 127d4e1d5a | |||
| 99cbc1f784 | |||
| 246312c51c | |||
| 1b09683938 | |||
| db3d3d61ef | |||
| c1a797c2c1 | |||
| 4d6a7573c4 | |||
| 0b73fd8f06 | |||
| e362765b21 | |||
| be79fdac80 | |||
| 75a15a0004 | |||
| 3087dfe486 | |||
| 9916ab7d7e | |||
| bd2ad3bb99 | |||
| f02fbc85a3 | |||
| 4fd4e074e0 | |||
| ec3d4f7462 | |||
| 4507325331 | |||
| dac4a72838 | |||
| 5048005fc1 | |||
| 002c7c0e42 | |||
| e18b732776 |
@@ -13,7 +13,7 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v6
|
||||||
|
|
||||||
- name: Download Crowdin files
|
- name: Download Crowdin files
|
||||||
uses: crowdin/github-action@v2
|
uses: crowdin/github-action@v2
|
||||||
|
|||||||
@@ -23,7 +23,13 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
-
|
-
|
||||||
name: Checkout repository
|
name: Checkout repository
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v6
|
||||||
|
-
|
||||||
|
name: Set up QEMU
|
||||||
|
uses: docker/setup-qemu-action@v3
|
||||||
|
-
|
||||||
|
name: Set up Docker Buildx
|
||||||
|
uses: docker/setup-buildx-action@v3
|
||||||
-
|
-
|
||||||
name: Docker meta
|
name: Docker meta
|
||||||
id: meta
|
id: meta
|
||||||
@@ -37,18 +43,19 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
username: ${{ secrets.DOCKER_HUB_USER }}
|
username: ${{ secrets.DOCKER_HUB_USER }}
|
||||||
password: ${{ secrets.DOCKER_HUB_PASSWORD }}
|
password: ${{ secrets.DOCKER_HUB_PASSWORD }}
|
||||||
-
|
# -
|
||||||
name: Run trivy scan
|
# name: Run trivy scan
|
||||||
uses: numerique-gouv/action-trivy-cache@main
|
# uses: numerique-gouv/action-trivy-cache@main
|
||||||
with:
|
# with:
|
||||||
docker-build-args: '--target backend-production -f Dockerfile'
|
# docker-build-args: '--target backend-production -f Dockerfile'
|
||||||
docker-image-name: '${{ env.DOCKER_CONTAINER_REGISTRY_HOSTNAME }}/${{ env.DOCKER_CONTAINER_REGISTRY_NAMESPACE }}/meet-backend:${{ github.sha }}'
|
# docker-image-name: '${{ env.DOCKER_CONTAINER_REGISTRY_HOSTNAME }}/${{ env.DOCKER_CONTAINER_REGISTRY_NAMESPACE }}/meet-backend:${{ github.sha }}'
|
||||||
-
|
-
|
||||||
name: Build and push
|
name: Build and push
|
||||||
uses: docker/build-push-action@v6
|
uses: docker/build-push-action@v6
|
||||||
with:
|
with:
|
||||||
context: .
|
context: .
|
||||||
target: backend-production
|
target: backend-production
|
||||||
|
platforms: linux/amd64,linux/arm64
|
||||||
build-args: DOCKER_USER=${{ env.DOCKER_USER }}:-1000
|
build-args: DOCKER_USER=${{ env.DOCKER_USER }}:-1000
|
||||||
push: ${{ github.event_name != 'pull_request' }}
|
push: ${{ github.event_name != 'pull_request' }}
|
||||||
tags: ${{ steps.meta.outputs.tags }}
|
tags: ${{ steps.meta.outputs.tags }}
|
||||||
@@ -59,7 +66,13 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
-
|
-
|
||||||
name: Checkout repository
|
name: Checkout repository
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v6
|
||||||
|
-
|
||||||
|
name: Set up QEMU
|
||||||
|
uses: docker/setup-qemu-action@v3
|
||||||
|
-
|
||||||
|
name: Set up Docker Buildx
|
||||||
|
uses: docker/setup-buildx-action@v3
|
||||||
-
|
-
|
||||||
name: Docker meta
|
name: Docker meta
|
||||||
id: meta
|
id: meta
|
||||||
@@ -73,12 +86,12 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
username: ${{ secrets.DOCKER_HUB_USER }}
|
username: ${{ secrets.DOCKER_HUB_USER }}
|
||||||
password: ${{ secrets.DOCKER_HUB_PASSWORD }}
|
password: ${{ secrets.DOCKER_HUB_PASSWORD }}
|
||||||
-
|
# -
|
||||||
name: Run trivy scan
|
# name: Run trivy scan
|
||||||
uses: numerique-gouv/action-trivy-cache@main
|
# uses: numerique-gouv/action-trivy-cache@main
|
||||||
with:
|
# with:
|
||||||
docker-build-args: '-f src/frontend/Dockerfile --target frontend-production'
|
# docker-build-args: '-f src/frontend/Dockerfile --target frontend-production'
|
||||||
docker-image-name: '${{ env.DOCKER_CONTAINER_REGISTRY_HOSTNAME }}/${{ env.DOCKER_CONTAINER_REGISTRY_NAMESPACE }}/meet-frontend:${{ github.sha }}'
|
# docker-image-name: '${{ env.DOCKER_CONTAINER_REGISTRY_HOSTNAME }}/${{ env.DOCKER_CONTAINER_REGISTRY_NAMESPACE }}/meet-frontend:${{ github.sha }}'
|
||||||
-
|
-
|
||||||
name: Build and push
|
name: Build and push
|
||||||
uses: docker/build-push-action@v6
|
uses: docker/build-push-action@v6
|
||||||
@@ -86,6 +99,7 @@ jobs:
|
|||||||
context: .
|
context: .
|
||||||
file: ./src/frontend/Dockerfile
|
file: ./src/frontend/Dockerfile
|
||||||
target: frontend-production
|
target: frontend-production
|
||||||
|
platforms: linux/amd64,linux/arm64
|
||||||
build-args: DOCKER_USER=${{ env.DOCKER_USER }}:-1000
|
build-args: DOCKER_USER=${{ env.DOCKER_USER }}:-1000
|
||||||
push: ${{ github.event_name != 'pull_request' }}
|
push: ${{ github.event_name != 'pull_request' }}
|
||||||
tags: ${{ steps.meta.outputs.tags }}
|
tags: ${{ steps.meta.outputs.tags }}
|
||||||
@@ -96,7 +110,13 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
-
|
-
|
||||||
name: Checkout repository
|
name: Checkout repository
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v6
|
||||||
|
-
|
||||||
|
name: Set up QEMU
|
||||||
|
uses: docker/setup-qemu-action@v3
|
||||||
|
-
|
||||||
|
name: Set up Docker Buildx
|
||||||
|
uses: docker/setup-buildx-action@v3
|
||||||
-
|
-
|
||||||
name: Docker meta
|
name: Docker meta
|
||||||
id: meta
|
id: meta
|
||||||
@@ -110,12 +130,12 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
username: ${{ secrets.DOCKER_HUB_USER }}
|
username: ${{ secrets.DOCKER_HUB_USER }}
|
||||||
password: ${{ secrets.DOCKER_HUB_PASSWORD }}
|
password: ${{ secrets.DOCKER_HUB_PASSWORD }}
|
||||||
-
|
# -
|
||||||
name: Run trivy scan
|
# name: Run trivy scan
|
||||||
uses: numerique-gouv/action-trivy-cache@main
|
# uses: numerique-gouv/action-trivy-cache@main
|
||||||
with:
|
# with:
|
||||||
docker-build-args: '-f docker/dinum-frontend/Dockerfile --target frontend-production'
|
# docker-build-args: '-f docker/dinum-frontend/Dockerfile --target frontend-production'
|
||||||
docker-image-name: '${{ env.DOCKER_CONTAINER_REGISTRY_HOSTNAME }}/${{ env.DOCKER_CONTAINER_REGISTRY_NAMESPACE }}/meet-frontend-dinum:${{ github.sha }}'
|
# docker-image-name: '${{ env.DOCKER_CONTAINER_REGISTRY_HOSTNAME }}/${{ env.DOCKER_CONTAINER_REGISTRY_NAMESPACE }}/meet-frontend-dinum:${{ github.sha }}'
|
||||||
-
|
-
|
||||||
name: Build and push
|
name: Build and push
|
||||||
uses: docker/build-push-action@v6
|
uses: docker/build-push-action@v6
|
||||||
@@ -123,6 +143,7 @@ jobs:
|
|||||||
context: .
|
context: .
|
||||||
file: ./docker/dinum-frontend/Dockerfile
|
file: ./docker/dinum-frontend/Dockerfile
|
||||||
target: frontend-production
|
target: frontend-production
|
||||||
|
platforms: linux/amd64,linux/arm64
|
||||||
build-args: DOCKER_USER=${{ env.DOCKER_USER }}:-1000
|
build-args: DOCKER_USER=${{ env.DOCKER_USER }}:-1000
|
||||||
push: ${{ github.event_name != 'pull_request' }}
|
push: ${{ github.event_name != 'pull_request' }}
|
||||||
tags: ${{ steps.meta.outputs.tags }}
|
tags: ${{ steps.meta.outputs.tags }}
|
||||||
@@ -133,7 +154,13 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
-
|
-
|
||||||
name: Checkout repository
|
name: Checkout repository
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v6
|
||||||
|
-
|
||||||
|
name: Set up QEMU
|
||||||
|
uses: docker/setup-qemu-action@v3
|
||||||
|
-
|
||||||
|
name: Set up Docker Buildx
|
||||||
|
uses: docker/setup-buildx-action@v3
|
||||||
-
|
-
|
||||||
name: Docker meta
|
name: Docker meta
|
||||||
id: meta
|
id: meta
|
||||||
@@ -147,13 +174,13 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
username: ${{ secrets.DOCKER_HUB_USER }}
|
username: ${{ secrets.DOCKER_HUB_USER }}
|
||||||
password: ${{ secrets.DOCKER_HUB_PASSWORD }}
|
password: ${{ secrets.DOCKER_HUB_PASSWORD }}
|
||||||
-
|
# -
|
||||||
name: Run trivy scan
|
# name: Run trivy scan
|
||||||
uses: numerique-gouv/action-trivy-cache@main
|
# uses: numerique-gouv/action-trivy-cache@main
|
||||||
continue-on-error: true
|
# continue-on-error: true
|
||||||
with:
|
# with:
|
||||||
docker-build-args: '-f src/summary/Dockerfile --target production'
|
# docker-build-args: '-f src/summary/Dockerfile --target production'
|
||||||
docker-image-name: '${{ env.DOCKER_CONTAINER_REGISTRY_HOSTNAME }}/${{ env.DOCKER_CONTAINER_REGISTRY_NAMESPACE }}/meet-summary:${{ github.sha }}'
|
# docker-image-name: '${{ env.DOCKER_CONTAINER_REGISTRY_HOSTNAME }}/${{ env.DOCKER_CONTAINER_REGISTRY_NAMESPACE }}/meet-summary:${{ github.sha }}'
|
||||||
docker-context: './src/summary'
|
docker-context: './src/summary'
|
||||||
-
|
-
|
||||||
name: Build and push
|
name: Build and push
|
||||||
@@ -162,6 +189,7 @@ jobs:
|
|||||||
context: ./src/summary
|
context: ./src/summary
|
||||||
file: ./src/summary/Dockerfile
|
file: ./src/summary/Dockerfile
|
||||||
target: production
|
target: production
|
||||||
|
platforms: linux/amd64,linux/arm64
|
||||||
build-args: DOCKER_USER=${{ env.DOCKER_USER }}:-1000
|
build-args: DOCKER_USER=${{ env.DOCKER_USER }}:-1000
|
||||||
push: ${{ github.event_name != 'pull_request' }}
|
push: ${{ github.event_name != 'pull_request' }}
|
||||||
tags: ${{ steps.meta.outputs.tags }}
|
tags: ${{ steps.meta.outputs.tags }}
|
||||||
@@ -172,7 +200,13 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
-
|
-
|
||||||
name: Checkout repository
|
name: Checkout repository
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v6
|
||||||
|
-
|
||||||
|
name: Set up QEMU
|
||||||
|
uses: docker/setup-qemu-action@v3
|
||||||
|
-
|
||||||
|
name: Set up Docker Buildx
|
||||||
|
uses: docker/setup-buildx-action@v3
|
||||||
-
|
-
|
||||||
name: Docker meta
|
name: Docker meta
|
||||||
id: meta
|
id: meta
|
||||||
@@ -186,14 +220,14 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
username: ${{ secrets.DOCKER_HUB_USER }}
|
username: ${{ secrets.DOCKER_HUB_USER }}
|
||||||
password: ${{ secrets.DOCKER_HUB_PASSWORD }}
|
password: ${{ secrets.DOCKER_HUB_PASSWORD }}
|
||||||
-
|
# -
|
||||||
name: Run trivy scan
|
# name: Run trivy scan
|
||||||
uses: numerique-gouv/action-trivy-cache@main
|
# uses: numerique-gouv/action-trivy-cache@main
|
||||||
continue-on-error: true
|
# continue-on-error: true
|
||||||
with:
|
# with:
|
||||||
docker-build-args: '-f src/agents/Dockerfile --target production'
|
# docker-build-args: '-f src/agents/Dockerfile --target production'
|
||||||
docker-image-name: '${{ env.DOCKER_CONTAINER_REGISTRY_HOSTNAME }}/${{ env.DOCKER_CONTAINER_REGISTRY_NAMESPACE }}/meet-agents:${{ github.sha }}'
|
# docker-image-name: '${{ env.DOCKER_CONTAINER_REGISTRY_HOSTNAME }}/${{ env.DOCKER_CONTAINER_REGISTRY_NAMESPACE }}/meet-agents:${{ github.sha }}'
|
||||||
docker-context: './src/agents'
|
# docker-context: './src/agents'
|
||||||
-
|
-
|
||||||
name: Build and push
|
name: Build and push
|
||||||
uses: docker/build-push-action@v6
|
uses: docker/build-push-action@v6
|
||||||
@@ -201,6 +235,7 @@ jobs:
|
|||||||
context: ./src/agents
|
context: ./src/agents
|
||||||
file: ./src/agents/Dockerfile
|
file: ./src/agents/Dockerfile
|
||||||
target: production
|
target: production
|
||||||
|
platforms: linux/amd64,linux/arm64
|
||||||
build-args: DOCKER_USER=${{ env.DOCKER_USER }}:-1000
|
build-args: DOCKER_USER=${{ env.DOCKER_USER }}:-1000
|
||||||
push: ${{ github.event_name != 'pull_request' }}
|
push: ${{ github.event_name != 'pull_request' }}
|
||||||
tags: ${{ steps.meta.outputs.tags }}
|
tags: ${{ steps.meta.outputs.tags }}
|
||||||
|
|||||||
+43
-20
@@ -7,14 +7,18 @@ on:
|
|||||||
pull_request:
|
pull_request:
|
||||||
branches:
|
branches:
|
||||||
- "*"
|
- "*"
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
lint-git:
|
lint-git:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
if: github.event_name == 'pull_request' # Makes sense only for pull requests
|
if: github.event_name == 'pull_request' # Makes sense only for pull requests
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v6
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
- name: show
|
- name: show
|
||||||
@@ -39,9 +43,11 @@ jobs:
|
|||||||
if: |
|
if: |
|
||||||
contains(github.event.pull_request.labels.*.name, 'noChangeLog') == false &&
|
contains(github.event.pull_request.labels.*.name, 'noChangeLog') == false &&
|
||||||
github.event_name == 'pull_request'
|
github.event_name == 'pull_request'
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v6
|
||||||
with:
|
with:
|
||||||
fetch-depth: 50
|
fetch-depth: 50
|
||||||
- name: Check that the CHANGELOG has been modified in the current branch
|
- name: Check that the CHANGELOG has been modified in the current branch
|
||||||
@@ -49,9 +55,11 @@ jobs:
|
|||||||
|
|
||||||
lint-changelog:
|
lint-changelog:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v6
|
||||||
- name: Check CHANGELOG max line length
|
- name: Check CHANGELOG max line length
|
||||||
run: |
|
run: |
|
||||||
max_line_length=$(cat CHANGELOG.md | grep -Ev "^\[.*\]: https://github.com" | wc -L)
|
max_line_length=$(cat CHANGELOG.md | grep -Ev "^\[.*\]: https://github.com" | wc -L)
|
||||||
@@ -62,20 +70,22 @@ jobs:
|
|||||||
|
|
||||||
build-mails:
|
build-mails:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
defaults:
|
defaults:
|
||||||
run:
|
run:
|
||||||
working-directory: src/mail
|
working-directory: src/mail
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v6
|
||||||
|
|
||||||
- name: Install Node.js
|
- name: Install Node.js
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v6
|
||||||
with:
|
with:
|
||||||
node-version: "18"
|
node-version: "18"
|
||||||
|
|
||||||
- name: Restore the mail templates
|
- name: Restore the mail templates
|
||||||
uses: actions/cache@v4
|
uses: actions/cache@v5
|
||||||
id: mail-templates
|
id: mail-templates
|
||||||
with:
|
with:
|
||||||
path: "src/backend/core/templates/mail"
|
path: "src/backend/core/templates/mail"
|
||||||
@@ -95,21 +105,23 @@ jobs:
|
|||||||
|
|
||||||
- name: Cache mail templates
|
- name: Cache mail templates
|
||||||
if: steps.mail-templates.outputs.cache-hit != 'true'
|
if: steps.mail-templates.outputs.cache-hit != 'true'
|
||||||
uses: actions/cache@v4
|
uses: actions/cache@v5
|
||||||
with:
|
with:
|
||||||
path: "src/backend/core/templates/mail"
|
path: "src/backend/core/templates/mail"
|
||||||
key: mail-templates-${{ hashFiles('src/mail/mjml') }}
|
key: mail-templates-${{ hashFiles('src/mail/mjml') }}
|
||||||
|
|
||||||
lint-back:
|
lint-back:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
defaults:
|
defaults:
|
||||||
run:
|
run:
|
||||||
working-directory: src/backend
|
working-directory: src/backend
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v6
|
||||||
- name: Install Python
|
- name: Install Python
|
||||||
uses: actions/setup-python@v5
|
uses: actions/setup-python@v6
|
||||||
with:
|
with:
|
||||||
python-version: "3.13"
|
python-version: "3.13"
|
||||||
cache: "pip"
|
cache: "pip"
|
||||||
@@ -124,14 +136,16 @@ jobs:
|
|||||||
|
|
||||||
lint-agents:
|
lint-agents:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
defaults:
|
defaults:
|
||||||
run:
|
run:
|
||||||
working-directory: src/agents
|
working-directory: src/agents
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v6
|
||||||
- name: Install Python
|
- name: Install Python
|
||||||
uses: actions/setup-python@v5
|
uses: actions/setup-python@v6
|
||||||
with:
|
with:
|
||||||
python-version: "3.13"
|
python-version: "3.13"
|
||||||
cache: "pip"
|
cache: "pip"
|
||||||
@@ -144,14 +158,16 @@ jobs:
|
|||||||
|
|
||||||
lint-summary:
|
lint-summary:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
defaults:
|
defaults:
|
||||||
run:
|
run:
|
||||||
working-directory: src/summary
|
working-directory: src/summary
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v6
|
||||||
- name: Install Python
|
- name: Install Python
|
||||||
uses: actions/setup-python@v5
|
uses: actions/setup-python@v6
|
||||||
with:
|
with:
|
||||||
python-version: "3.13"
|
python-version: "3.13"
|
||||||
cache: "pip"
|
cache: "pip"
|
||||||
@@ -165,7 +181,8 @@ jobs:
|
|||||||
test-back:
|
test-back:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
needs: build-mails
|
needs: build-mails
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
defaults:
|
defaults:
|
||||||
run:
|
run:
|
||||||
working-directory: src/backend
|
working-directory: src/backend
|
||||||
@@ -216,7 +233,7 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v6
|
||||||
|
|
||||||
- name: Create writable /data
|
- name: Create writable /data
|
||||||
run: |
|
run: |
|
||||||
@@ -224,7 +241,7 @@ jobs:
|
|||||||
sudo mkdir -p /data/static
|
sudo mkdir -p /data/static
|
||||||
|
|
||||||
- name: Restore the mail templates
|
- name: Restore the mail templates
|
||||||
uses: actions/cache@v4
|
uses: actions/cache@v5
|
||||||
id: mail-templates
|
id: mail-templates
|
||||||
with:
|
with:
|
||||||
path: "src/backend/core/templates/mail"
|
path: "src/backend/core/templates/mail"
|
||||||
@@ -258,7 +275,7 @@ jobs:
|
|||||||
mc mb meet/meet-media-storage"
|
mc mb meet/meet-media-storage"
|
||||||
|
|
||||||
- name: Install Python
|
- name: Install Python
|
||||||
uses: actions/setup-python@v5
|
uses: actions/setup-python@v6
|
||||||
with:
|
with:
|
||||||
python-version: "3.13"
|
python-version: "3.13"
|
||||||
cache: "pip"
|
cache: "pip"
|
||||||
@@ -279,9 +296,11 @@ jobs:
|
|||||||
|
|
||||||
lint-front:
|
lint-front:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v6
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: cd src/frontend/ && npm ci
|
run: cd src/frontend/ && npm ci
|
||||||
@@ -294,12 +313,14 @@ jobs:
|
|||||||
|
|
||||||
lint-sdk:
|
lint-sdk:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
defaults:
|
defaults:
|
||||||
run:
|
run:
|
||||||
working-directory: src/sdk/library
|
working-directory: src/sdk/library
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v6
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: npm ci
|
run: npm ci
|
||||||
@@ -312,13 +333,15 @@ jobs:
|
|||||||
|
|
||||||
build-sdk:
|
build-sdk:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
needs: lint-sdk
|
needs: lint-sdk
|
||||||
defaults:
|
defaults:
|
||||||
run:
|
run:
|
||||||
working-directory: src/sdk/library
|
working-directory: src/sdk/library
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v6
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: npm ci
|
run: npm ci
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v6
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
# /!\
|
||||||
|
# Security Note: This action is not hardened against prompt injection attacks and should only be used
|
||||||
|
# to review trusted PRs. Configure your repository with "Require approval for all external contributors"
|
||||||
|
# to ensure workflows only run after a maintainer has reviewed the PR.
|
||||||
|
name: Security Review
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
pull-requests: write # Needed for leaving PR comments
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
on:
|
||||||
|
pull_request:
|
||||||
|
branches:
|
||||||
|
- 'main'
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
security:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v6
|
||||||
|
with:
|
||||||
|
ref: ${{ github.event.pull_request.head.sha || github.sha }}
|
||||||
|
fetch-depth: 2
|
||||||
|
|
||||||
|
- uses: anthropics/claude-code-security-review@0c6a49f1fa56a1d472575da86a94dbc1edb78eda
|
||||||
|
with:
|
||||||
|
comment-pr: true
|
||||||
|
exclude-directories: docs,gitlint,LICENSES,bin
|
||||||
|
claude-api-key: ${{ secrets.CLAUDE_API_KEY }}
|
||||||
+57
-1
@@ -8,9 +8,64 @@ and this project adheres to
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- 🩹(frontend) remove incorrect reference to ProConnect on the prejoin #1080
|
||||||
|
- ✨(frontend) add Ctrl+Shift+/ to open shortcuts settings #1050
|
||||||
|
- ♿(frontend) announce selected state to screen readers #1081
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
|
|
||||||
- ♻️(frontend) Use React Aria FocusScope for side panel accessibility
|
- 🔒️(backend) enhance API input validation to strengthen security #1053
|
||||||
|
- 🦺(backend) strengthen API validation for recording options #1063
|
||||||
|
- ⚡️(frontend) optimize few performance caveats #1073
|
||||||
|
- 🔒️(helm) introduce a dedicated Kubernetes Ingress for webhook-livekit #1066
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- 🐛(migrations) use settings in migrations #1058
|
||||||
|
- 💄(frontend) truncate pinned participant name with ellipsis on overflow #1056
|
||||||
|
- ♿(frontend) prevent focus ring clipping on invite dialog #1078
|
||||||
|
|
||||||
|
## [1.9.0] - 2026-03-02
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- 👷(docker) add arm64 platform support for image builds
|
||||||
|
- ✨(summary) add localization support for transcription context text
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- ♻️(frontend) replace custom reactions toolbar with react aria popover #985
|
||||||
|
- 🔒️(frontend) uninstall curl from the frontend production image #987
|
||||||
|
- 💄(frontend) add focus ring to reaction emoji buttons
|
||||||
|
- ✨(frontend) introduce a shortcut settings tab #975
|
||||||
|
- 🚚(frontend) rename "wellknown" directory to "well-known" #1009
|
||||||
|
- 🌐(frontend) localize SR modifier labels #1010
|
||||||
|
- ⬆️(backend) update python dependencies #1011
|
||||||
|
- ♿️(frontend) fix focus ring on tab container components #1012
|
||||||
|
- ♿️(frontend) upgrade join meeting modal accessibility #1027
|
||||||
|
- ⬆️(python) bump minimal required python version to 3.13 #1033
|
||||||
|
- ♿️(frontend) improve accessibility of the IntroSlider carousel #1026
|
||||||
|
- ♿️(frontend) add skip link component for keyboard navigation #1019
|
||||||
|
- ♿️(frontend) announce mic/camera state to SR on shortcut toggle #1052
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- 🩹(frontend) fix German language preference update #1021
|
||||||
|
|
||||||
|
## [1.8.0] - 2026-02-20
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- 🔒️(agents) uninstall pip from the agents image
|
||||||
|
- 🔒️(summary) switch to Alpine base image
|
||||||
|
- 🔒️(backend) uninstall pip in the production image
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- 🔒️(agents) upgrade OpenSSL to address CVE-2025-15467
|
||||||
|
- 📌(agents) pin protobuf to 6.33.5 to fix CVE-2026-0994
|
||||||
|
|
||||||
## [1.7.0] - 2026-02-19
|
## [1.7.0] - 2026-02-19
|
||||||
|
|
||||||
@@ -22,6 +77,7 @@ and this project adheres to
|
|||||||
### Changed
|
### Changed
|
||||||
|
|
||||||
- ✨(frontend) add clickable settings general link in idle modal #974
|
- ✨(frontend) add clickable settings general link in idle modal #974
|
||||||
|
- ♻️(backend) refactor external API token-related items #1006
|
||||||
|
|
||||||
## [1.6.0] - 2026-02-10
|
## [1.6.0] - 2026-02-10
|
||||||
|
|
||||||
|
|||||||
@@ -127,6 +127,9 @@ ARG MEET_STATIC_ROOT=/data/static
|
|||||||
RUN mkdir -p /usr/local/etc/gunicorn
|
RUN mkdir -p /usr/local/etc/gunicorn
|
||||||
COPY docker/files/usr/local/etc/gunicorn/meet.py /usr/local/etc/gunicorn/meet.py
|
COPY docker/files/usr/local/etc/gunicorn/meet.py /usr/local/etc/gunicorn/meet.py
|
||||||
|
|
||||||
|
# Remove pip to reduce attack surface in production
|
||||||
|
RUN pip uninstall -y pip
|
||||||
|
|
||||||
# Un-privileged user running the application
|
# Un-privileged user running the application
|
||||||
ARG DOCKER_USER
|
ARG DOCKER_USER
|
||||||
USER ${DOCKER_USER}
|
USER ${DOCKER_USER}
|
||||||
|
|||||||
@@ -23,9 +23,10 @@
|
|||||||
# ==============================================================================
|
# ==============================================================================
|
||||||
# VARIABLES
|
# VARIABLES
|
||||||
|
|
||||||
BOLD := \033[1m
|
ESC := $(shell printf '\033')
|
||||||
RESET := \033[0m
|
BOLD := $(ESC)[1m
|
||||||
GREEN := \033[1;32m
|
RESET := $(ESC)[0m
|
||||||
|
GREEN := $(ESC)[1;32m
|
||||||
|
|
||||||
|
|
||||||
# -- Database
|
# -- Database
|
||||||
@@ -85,7 +86,8 @@ bootstrap: \
|
|||||||
demo \
|
demo \
|
||||||
back-i18n-compile \
|
back-i18n-compile \
|
||||||
mails-install \
|
mails-install \
|
||||||
mails-build
|
mails-build \
|
||||||
|
run
|
||||||
.PHONY: bootstrap
|
.PHONY: bootstrap
|
||||||
|
|
||||||
# -- Docker/compose
|
# -- Docker/compose
|
||||||
|
|||||||
+5
-1
@@ -18,6 +18,7 @@ docker_build(
|
|||||||
'localhost:5001/meet-backend:latest',
|
'localhost:5001/meet-backend:latest',
|
||||||
context='..',
|
context='..',
|
||||||
dockerfile='../Dockerfile',
|
dockerfile='../Dockerfile',
|
||||||
|
build_args={'DOCKER_USER': '1001:127'},
|
||||||
only=['./src/backend', './src/mail', './docker'],
|
only=['./src/backend', './src/mail', './docker'],
|
||||||
target = 'backend-production',
|
target = 'backend-production',
|
||||||
live_update=[
|
live_update=[
|
||||||
@@ -33,6 +34,7 @@ clean_old_images('localhost:5001/meet-backend')
|
|||||||
docker_build(
|
docker_build(
|
||||||
'localhost:5001/meet-frontend-dinum:latest',
|
'localhost:5001/meet-frontend-dinum:latest',
|
||||||
context='..',
|
context='..',
|
||||||
|
build_args={'DOCKER_USER': '1001:127'},
|
||||||
dockerfile='../docker/dinum-frontend/Dockerfile',
|
dockerfile='../docker/dinum-frontend/Dockerfile',
|
||||||
only=['./src/frontend', './docker', './.dockerignore'],
|
only=['./src/frontend', './docker', './.dockerignore'],
|
||||||
target = 'frontend-production',
|
target = 'frontend-production',
|
||||||
@@ -57,6 +59,7 @@ clean_old_images('localhost:5001/meet-frontend-generic')
|
|||||||
docker_build(
|
docker_build(
|
||||||
'localhost:5001/meet-summary:latest',
|
'localhost:5001/meet-summary:latest',
|
||||||
context='../src/summary',
|
context='../src/summary',
|
||||||
|
build_args={'DOCKER_USER': '1001:127'},
|
||||||
dockerfile='../src/summary/Dockerfile',
|
dockerfile='../src/summary/Dockerfile',
|
||||||
only=['.'],
|
only=['.'],
|
||||||
target = 'production',
|
target = 'production',
|
||||||
@@ -69,8 +72,9 @@ clean_old_images('localhost:5001/meet-summary')
|
|||||||
docker_build(
|
docker_build(
|
||||||
'localhost:5001/meet-agents:latest',
|
'localhost:5001/meet-agents:latest',
|
||||||
context='../src/agents',
|
context='../src/agents',
|
||||||
|
build_args={'DOCKER_USER': '1001:127'},
|
||||||
dockerfile='../src/agents/Dockerfile',
|
dockerfile='../src/agents/Dockerfile',
|
||||||
only=['.'],
|
only=['.'],
|
||||||
target = 'production',
|
target = 'production',
|
||||||
live_update=[
|
live_update=[
|
||||||
sync('../src/agents', '/app'),
|
sync('../src/agents', '/app'),
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
FROM livekit/livekit-server:v1.9.0
|
FROM livekit/livekit-server:v1.9.4
|
||||||
|
|
||||||
# We inject the nip.io certificate manually because the livekit chart doesn't support volume mounting
|
# We inject the nip.io certificate manually because the livekit chart doesn't support volume mounting
|
||||||
COPY rootCA.pem /etc/ssl/certs/
|
COPY rootCA.pem /etc/ssl/certs/
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ Set the following environment variables in your Scalingo app:
|
|||||||
### Buildpack Configuration
|
### Buildpack Configuration
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
scalingo env-set BUILDBACK_URL="https://github.com/suitenumerique/buildpack#main"
|
scalingo env-set BUILDPACK_URL="https://github.com/suitenumerique/buildpack#main"
|
||||||
scalingo env-set LASUITE_APP_NAME="meet"
|
scalingo env-set LASUITE_APP_NAME="meet"
|
||||||
scalingo env-set LASUITE_BACKEND_DIR="."
|
scalingo env-set LASUITE_BACKEND_DIR="."
|
||||||
scalingo env-set LASUITE_FRONTEND_DIR="src/frontend/"
|
scalingo env-set LASUITE_FRONTEND_DIR="src/frontend/"
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
Gitlint extra rule to validate that the message title is of the form
|
Gitlint extra rule to validate that the message title is of the form
|
||||||
"<gitmoji>(<scope>) <subject>"
|
"<gitmoji>(<scope>) <subject>"
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import unicode_literals
|
from __future__ import unicode_literals
|
||||||
|
|
||||||
import re
|
import re
|
||||||
|
|||||||
@@ -3,6 +3,21 @@
|
|||||||
"dependencyDashboard": true,
|
"dependencyDashboard": true,
|
||||||
"labels": ["dependencies", "noChangeLog"],
|
"labels": ["dependencies", "noChangeLog"],
|
||||||
"packageRules": [
|
"packageRules": [
|
||||||
|
{
|
||||||
|
"groupName": "js dependencies",
|
||||||
|
"matchManagers": ["npm"],
|
||||||
|
"schedule": ["on the first day of the month"],
|
||||||
|
"matchPackagePatterns": ["*"],
|
||||||
|
"minimumReleaseAge": "7 days",
|
||||||
|
"internalChecksFilter": "strict"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"groupName": "python dependencies",
|
||||||
|
"matchManagers": ["setup-cfg", "pep621"],
|
||||||
|
"schedule": ["on the first day of the month"],
|
||||||
|
"matchPackagePatterns": ["*"],
|
||||||
|
"minimumReleaseAge": "7 days"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"enabled": false,
|
"enabled": false,
|
||||||
"groupName": "ignored python dependencies",
|
"groupName": "ignored python dependencies",
|
||||||
@@ -15,6 +30,12 @@
|
|||||||
"matchPackageNames": ["pylint"],
|
"matchPackageNames": ["pylint"],
|
||||||
"allowedVersions": "<4.0.0"
|
"allowedVersions": "<4.0.0"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"groupName": "allowed django versions",
|
||||||
|
"matchManagers": ["pep621"],
|
||||||
|
"matchPackageNames": ["django"],
|
||||||
|
"allowedVersions": "<6.0.0"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"enabled": false,
|
"enabled": false,
|
||||||
"groupName": "ignored js dependencies",
|
"groupName": "ignored js dependencies",
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ FROM python:3.13-slim AS base
|
|||||||
RUN apt-get update && apt-get install -y \
|
RUN apt-get update && apt-get install -y \
|
||||||
libglib2.0-0 \
|
libglib2.0-0 \
|
||||||
libgobject-2.0-0 \
|
libgobject-2.0-0 \
|
||||||
|
"openssl=3.5.4-1~deb13u2" \
|
||||||
|
"libssl3t64=3.5.4-1~deb13u2" \
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
FROM base AS builder
|
FROM base AS builder
|
||||||
@@ -19,6 +21,9 @@ FROM base AS production
|
|||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Remove pip to reduce attack surface in production
|
||||||
|
RUN pip uninstall -y pip
|
||||||
|
|
||||||
ARG DOCKER_USER
|
ARG DOCKER_USER
|
||||||
USER ${DOCKER_USER}
|
USER ${DOCKER_USER}
|
||||||
|
|
||||||
|
|||||||
@@ -1,14 +1,15 @@
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "agents"
|
name = "agents"
|
||||||
version = "1.7.0"
|
version = "1.9.0"
|
||||||
requires-python = ">=3.12"
|
requires-python = ">=3.12"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"livekit-agents==1.3.10",
|
"livekit-agents==1.3.10",
|
||||||
"livekit-plugins-deepgram==1.3.10",
|
"livekit-plugins-deepgram==1.3.10",
|
||||||
"livekit-plugins-silero==1.3.10",
|
"livekit-plugins-silero==1.3.10",
|
||||||
"livekit-plugins-kyutai-lasuite==0.0.6",
|
"livekit-plugins-kyutai-lasuite==0.0.6",
|
||||||
"python-dotenv==1.2.1"
|
"python-dotenv==1.2.1",
|
||||||
|
"protobuf==6.33.5"
|
||||||
]
|
]
|
||||||
|
|
||||||
[project.optional-dependencies]
|
[project.optional-dependencies]
|
||||||
|
|||||||
@@ -2,6 +2,8 @@
|
|||||||
|
|
||||||
from rest_framework import permissions
|
from rest_framework import permissions
|
||||||
|
|
||||||
|
from core.entitlements import EntitlementsUnavailableError, get_user_entitlements
|
||||||
|
|
||||||
from ..models import RoleChoices
|
from ..models import RoleChoices
|
||||||
|
|
||||||
ACTION_FOR_METHOD_TO_PERMISSION = {
|
ACTION_FOR_METHOD_TO_PERMISSION = {
|
||||||
@@ -45,11 +47,27 @@ class RoomPermissions(permissions.BasePermission):
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
def has_permission(self, request, view):
|
def has_permission(self, request, view):
|
||||||
"""Only allow authenticated users for unsafe methods."""
|
"""Only allow authenticated users for unsafe methods.
|
||||||
|
|
||||||
|
Room creation additionally requires the can_create entitlement.
|
||||||
|
Fail-closed: denies creation when the entitlements service is unavailable.
|
||||||
|
"""
|
||||||
if request.method in permissions.SAFE_METHODS:
|
if request.method in permissions.SAFE_METHODS:
|
||||||
return True
|
return True
|
||||||
|
|
||||||
return request.user.is_authenticated
|
if not request.user.is_authenticated:
|
||||||
|
return False
|
||||||
|
|
||||||
|
if view.action == "create":
|
||||||
|
try:
|
||||||
|
entitlements = get_user_entitlements(
|
||||||
|
request.user.sub, request.user.email
|
||||||
|
)
|
||||||
|
return entitlements.get("can_create", False)
|
||||||
|
except EntitlementsUnavailableError:
|
||||||
|
return False
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
def has_object_permission(self, request, view, obj):
|
def has_object_permission(self, request, view, obj):
|
||||||
"""Object permissions are only given to administrators of the room."""
|
"""Object permissions are only given to administrators of the room."""
|
||||||
|
|||||||
@@ -1,15 +1,20 @@
|
|||||||
"""Client serializers for the Meet core app."""
|
"""Client serializers for the Meet core app."""
|
||||||
|
|
||||||
# pylint: disable=abstract-method,no-name-in-module
|
# pylint: disable=abstract-method,no-name-in-module
|
||||||
|
from typing import Literal
|
||||||
|
|
||||||
|
from django.conf import settings
|
||||||
|
from django.core.exceptions import SuspiciousOperation
|
||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
|
|
||||||
from livekit.api import ParticipantPermission
|
from django_pydantic_field.rest_framework import SchemaField
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
from rest_framework import serializers
|
from rest_framework import serializers
|
||||||
from rest_framework.exceptions import PermissionDenied
|
from rest_framework.exceptions import PermissionDenied
|
||||||
from timezone_field.rest_framework import TimeZoneSerializerField
|
from timezone_field.rest_framework import TimeZoneSerializerField
|
||||||
|
|
||||||
from core import models, utils
|
from core import models, utils
|
||||||
|
from core.entitlements import EntitlementsUnavailableError, get_user_entitlements
|
||||||
|
|
||||||
|
|
||||||
class UserSerializer(serializers.ModelSerializer):
|
class UserSerializer(serializers.ModelSerializer):
|
||||||
@@ -23,6 +28,25 @@ class UserSerializer(serializers.ModelSerializer):
|
|||||||
read_only_fields = ["id", "email", "full_name", "short_name"]
|
read_only_fields = ["id", "email", "full_name", "short_name"]
|
||||||
|
|
||||||
|
|
||||||
|
class UserMeSerializer(UserSerializer):
|
||||||
|
"""Serialize users for me endpoint."""
|
||||||
|
|
||||||
|
can_create = serializers.SerializerMethodField(read_only=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = models.User
|
||||||
|
fields = [*UserSerializer.Meta.fields, "can_create"]
|
||||||
|
read_only_fields = [*UserSerializer.Meta.read_only_fields, "can_create"]
|
||||||
|
|
||||||
|
def get_can_create(self, user) -> bool:
|
||||||
|
"""Check entitlements for the current user."""
|
||||||
|
try:
|
||||||
|
entitlements = get_user_entitlements(user.sub, user.email)
|
||||||
|
return entitlements.get("can_create", False)
|
||||||
|
except EntitlementsUnavailableError:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
class ResourceAccessSerializerMixin:
|
class ResourceAccessSerializerMixin:
|
||||||
"""
|
"""
|
||||||
A serializer mixin to share controlling that the logged-in user submitting a room access object
|
A serializer mixin to share controlling that the logged-in user submitting a room access object
|
||||||
@@ -201,6 +225,27 @@ class BaseValidationOnlySerializer(serializers.Serializer):
|
|||||||
raise NotImplementedError(f"{self.__class__.__name__} is validation-only")
|
raise NotImplementedError(f"{self.__class__.__name__} is validation-only")
|
||||||
|
|
||||||
|
|
||||||
|
class RecordingOptions(BaseModel):
|
||||||
|
"""Configuration options for recording.
|
||||||
|
|
||||||
|
Attributes:
|
||||||
|
language: ISO 639-1 language code compatible with whisperX.
|
||||||
|
When `None`, the transcription engine will attempt to
|
||||||
|
auto-detect the spoken language.
|
||||||
|
transcribe: Whether to transcribe the recorded audio.
|
||||||
|
When `None`, falls back to the application default.
|
||||||
|
original_mode: The original recording mode before any override.
|
||||||
|
Must be one of the valid RecordingModeChoices values when provided.
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
|
language: str | None = None
|
||||||
|
transcribe: bool | None = None
|
||||||
|
original_mode: Literal["screen_recording", "transcript"] | None = None
|
||||||
|
|
||||||
|
model_config = {"extra": "forbid"}
|
||||||
|
|
||||||
|
|
||||||
class StartRecordingSerializer(BaseValidationOnlySerializer):
|
class StartRecordingSerializer(BaseValidationOnlySerializer):
|
||||||
"""Validate start recording requests."""
|
"""Validate start recording requests."""
|
||||||
|
|
||||||
@@ -213,10 +258,11 @@ class StartRecordingSerializer(BaseValidationOnlySerializer):
|
|||||||
"screen_recording or transcript.",
|
"screen_recording or transcript.",
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
options = serializers.JSONField(
|
options = SchemaField(
|
||||||
|
schema=RecordingOptions | None,
|
||||||
required=False,
|
required=False,
|
||||||
allow_null=True,
|
allow_null=True,
|
||||||
default=dict,
|
help_text="Recording options",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -261,6 +307,28 @@ class MuteParticipantSerializer(BaseParticipantsManagementSerializer):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class ParticipantPermission(BaseModel):
|
||||||
|
"""Mirror the LiveKit ParticipantPermission protobuf.
|
||||||
|
|
||||||
|
Control what a participant is allowed to publish, subscribe, and do within a room.
|
||||||
|
Unknown fields are rejected.
|
||||||
|
"""
|
||||||
|
|
||||||
|
can_subscribe: bool | None = None
|
||||||
|
can_publish: bool | None = None
|
||||||
|
can_publish_data: bool | None = None
|
||||||
|
can_publish_sources: list[int] = Field(
|
||||||
|
default_factory=list
|
||||||
|
) # TrackSource enum values
|
||||||
|
hidden: bool | None = None
|
||||||
|
recorder: bool | None = None
|
||||||
|
can_update_metadata: bool | None = None
|
||||||
|
agent: bool | None = None
|
||||||
|
can_subscribe_metrics: bool | None = None
|
||||||
|
|
||||||
|
model_config = {"extra": "forbid"}
|
||||||
|
|
||||||
|
|
||||||
class UpdateParticipantSerializer(BaseParticipantsManagementSerializer):
|
class UpdateParticipantSerializer(BaseParticipantsManagementSerializer):
|
||||||
"""Validate participant update data."""
|
"""Validate participant update data."""
|
||||||
|
|
||||||
@@ -272,10 +340,11 @@ class UpdateParticipantSerializer(BaseParticipantsManagementSerializer):
|
|||||||
allow_null=True,
|
allow_null=True,
|
||||||
help_text="Participant attributes as JSON object",
|
help_text="Participant attributes as JSON object",
|
||||||
)
|
)
|
||||||
permission = serializers.DictField(
|
permission = SchemaField(
|
||||||
|
schema=ParticipantPermission | None,
|
||||||
required=False,
|
required=False,
|
||||||
allow_null=True,
|
allow_null=True,
|
||||||
help_text="Participant permission as JSON object",
|
help_text="Participant permissions",
|
||||||
)
|
)
|
||||||
name = serializers.CharField(
|
name = serializers.CharField(
|
||||||
max_length=255,
|
max_length=255,
|
||||||
@@ -285,6 +354,33 @@ class UpdateParticipantSerializer(BaseParticipantsManagementSerializer):
|
|||||||
help_text="Display name for the participant",
|
help_text="Display name for the participant",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def validate_permission(self, permission):
|
||||||
|
"""Validate that the given permission does not include forbidden or unimplemented fields."""
|
||||||
|
|
||||||
|
if permission is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
suspicious_fields = [
|
||||||
|
field
|
||||||
|
for field in settings.PARTICIPANT_FORBIDDEN_PERMISSION_FIELDS
|
||||||
|
if getattr(permission, field) is not None
|
||||||
|
]
|
||||||
|
if suspicious_fields:
|
||||||
|
raise SuspiciousOperation(
|
||||||
|
f"Setting the following participant permissions is not allowed: "
|
||||||
|
f"{', '.join(suspicious_fields)}."
|
||||||
|
)
|
||||||
|
if permission.can_subscribe_metrics is not None:
|
||||||
|
raise serializers.ValidationError(
|
||||||
|
{
|
||||||
|
"permission": {
|
||||||
|
"can_subscribe_metrics": "This permission is not implemented."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
return permission
|
||||||
|
|
||||||
def validate(self, attrs):
|
def validate(self, attrs):
|
||||||
"""Ensure at least one update field is provided."""
|
"""Ensure at least one update field is provided."""
|
||||||
update_fields = ["metadata", "attributes", "permission", "name"]
|
update_fields = ["metadata", "attributes", "permission", "name"]
|
||||||
@@ -300,12 +396,4 @@ class UpdateParticipantSerializer(BaseParticipantsManagementSerializer):
|
|||||||
f"{', '.join(update_fields)}."
|
f"{', '.join(update_fields)}."
|
||||||
)
|
)
|
||||||
|
|
||||||
if "permission" in attrs:
|
|
||||||
try:
|
|
||||||
ParticipantPermission(**attrs["permission"])
|
|
||||||
except ValueError as e:
|
|
||||||
raise serializers.ValidationError(
|
|
||||||
{"permission": f"Invalid permission: {str(e)}"}
|
|
||||||
) from e
|
|
||||||
|
|
||||||
return attrs
|
return attrs
|
||||||
|
|||||||
@@ -187,7 +187,7 @@ class UserViewSet(
|
|||||||
"""
|
"""
|
||||||
context = {"request": request}
|
context = {"request": request}
|
||||||
return drf_response.Response(
|
return drf_response.Response(
|
||||||
self.serializer_class(request.user, context=context).data
|
serializers.UserMeSerializer(request.user, context=context).data
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -296,12 +296,14 @@ class RoomViewSet(
|
|||||||
)
|
)
|
||||||
|
|
||||||
mode = serializer.validated_data["mode"]
|
mode = serializer.validated_data["mode"]
|
||||||
options = serializer.validated_data["options"]
|
options = serializer.validated_data.get("options")
|
||||||
room = self.get_object()
|
room = self.get_object()
|
||||||
|
|
||||||
# May raise exception if an active or initiated recording already exist for the room
|
# May raise exception if an active or initiated recording already exist for the room
|
||||||
recording = models.Recording.objects.create(
|
recording = models.Recording.objects.create(
|
||||||
room=room, mode=mode, options=options
|
room=room,
|
||||||
|
mode=mode,
|
||||||
|
options=options.model_dump(exclude_none=True) if options else {},
|
||||||
)
|
)
|
||||||
|
|
||||||
models.RecordingAccess.objects.create(
|
models.RecordingAccess.objects.create(
|
||||||
@@ -607,13 +609,15 @@ class RoomViewSet(
|
|||||||
serializer = serializers.UpdateParticipantSerializer(data=request.data)
|
serializer = serializers.UpdateParticipantSerializer(data=request.data)
|
||||||
serializer.is_valid(raise_exception=True)
|
serializer.is_valid(raise_exception=True)
|
||||||
|
|
||||||
|
permission = serializer.validated_data.get("permission")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
ParticipantsManagement().update(
|
ParticipantsManagement().update(
|
||||||
room_name=str(room.pk),
|
room_name=str(room.pk),
|
||||||
identity=str(serializer.validated_data["participant_identity"]),
|
identity=str(serializer.validated_data["participant_identity"]),
|
||||||
metadata=serializer.validated_data.get("metadata"),
|
metadata=serializer.validated_data.get("metadata"),
|
||||||
attributes=serializer.validated_data.get("attributes"),
|
attributes=serializer.validated_data.get("attributes"),
|
||||||
permission=serializer.validated_data.get("permission"),
|
permission=permission.model_dump() if permission else None,
|
||||||
name=serializer.validated_data.get("name"),
|
name=serializer.validated_data.get("name"),
|
||||||
)
|
)
|
||||||
except ParticipantsManagementException:
|
except ParticipantsManagementException:
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
"""Authentication Backends for the Meet core app."""
|
"""Authentication Backends for the Meet core app."""
|
||||||
|
|
||||||
import contextlib
|
import contextlib
|
||||||
|
import logging
|
||||||
|
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.core.exceptions import ImproperlyConfigured, SuspiciousOperation
|
from django.core.exceptions import ImproperlyConfigured, SuspiciousOperation
|
||||||
@@ -10,6 +11,7 @@ from lasuite.oidc_login.backends import (
|
|||||||
OIDCAuthenticationBackend as LaSuiteOIDCAuthenticationBackend,
|
OIDCAuthenticationBackend as LaSuiteOIDCAuthenticationBackend,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
from core.entitlements import EntitlementsUnavailableError, get_user_entitlements
|
||||||
from core.models import User
|
from core.models import User
|
||||||
from core.services.marketing import (
|
from core.services.marketing import (
|
||||||
ContactCreationError,
|
ContactCreationError,
|
||||||
@@ -17,6 +19,8 @@ from core.services.marketing import (
|
|||||||
get_marketing_service,
|
get_marketing_service,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
class OIDCAuthenticationBackend(LaSuiteOIDCAuthenticationBackend):
|
class OIDCAuthenticationBackend(LaSuiteOIDCAuthenticationBackend):
|
||||||
"""Custom OpenID Connect (OIDC) Authentication Backend.
|
"""Custom OpenID Connect (OIDC) Authentication Backend.
|
||||||
@@ -59,6 +63,21 @@ class OIDCAuthenticationBackend(LaSuiteOIDCAuthenticationBackend):
|
|||||||
if is_new_user and email and settings.SIGNUP_NEW_USER_TO_MARKETING_EMAIL:
|
if is_new_user and email and settings.SIGNUP_NEW_USER_TO_MARKETING_EMAIL:
|
||||||
self.signup_to_marketing_email(email)
|
self.signup_to_marketing_email(email)
|
||||||
|
|
||||||
|
# Warm the entitlements cache on login (force_refresh)
|
||||||
|
try:
|
||||||
|
get_user_entitlements(
|
||||||
|
user_sub=user.sub,
|
||||||
|
user_email=user.email,
|
||||||
|
user_info=claims,
|
||||||
|
force_refresh=True,
|
||||||
|
)
|
||||||
|
except EntitlementsUnavailableError:
|
||||||
|
email_domain = user.email.split("@")[-1] if "@" in user.email else "?"
|
||||||
|
logger.warning(
|
||||||
|
"Entitlements unavailable for user@%s during login",
|
||||||
|
email_domain,
|
||||||
|
)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def signup_to_marketing_email(email):
|
def signup_to_marketing_email(email):
|
||||||
"""Pragmatic approach to newsletter signup during authentication flow.
|
"""Pragmatic approach to newsletter signup during authentication flow.
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
"""Entitlements service layer."""
|
||||||
|
|
||||||
|
from core.entitlements.factory import get_entitlements_backend
|
||||||
|
|
||||||
|
|
||||||
|
class EntitlementsUnavailableError(Exception):
|
||||||
|
"""Raised when the entitlements backend cannot be reached or returns an error."""
|
||||||
|
|
||||||
|
|
||||||
|
def get_user_entitlements(user_sub, user_email, user_info=None, force_refresh=False):
|
||||||
|
"""Get user entitlements, delegating to the configured backend.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
user_sub: The user's OIDC subject identifier.
|
||||||
|
user_email: The user's email address.
|
||||||
|
user_info: The full OIDC user_info dict (forwarded to backend).
|
||||||
|
force_refresh: If True, bypass backend cache and fetch fresh data.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
dict: {"can_create": bool}
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
EntitlementsUnavailableError: If the backend cannot be reached
|
||||||
|
and no cache exists.
|
||||||
|
"""
|
||||||
|
backend = get_entitlements_backend()
|
||||||
|
return backend.get_user_entitlements(
|
||||||
|
user_sub, user_email, user_info=user_info, force_refresh=force_refresh
|
||||||
|
)
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
"""Abstract base class for entitlements backends."""
|
||||||
|
|
||||||
|
from abc import ABC, abstractmethod
|
||||||
|
|
||||||
|
|
||||||
|
class EntitlementsBackend(ABC):
|
||||||
|
"""Abstract base class that defines the interface for entitlements backends."""
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def get_user_entitlements(
|
||||||
|
self, user_sub, user_email, user_info=None, force_refresh=False
|
||||||
|
):
|
||||||
|
"""Fetch user entitlements.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
user_sub: The user's OIDC subject identifier.
|
||||||
|
user_email: The user's email address.
|
||||||
|
user_info: The full OIDC user_info dict (backends may
|
||||||
|
extract claims from it).
|
||||||
|
force_refresh: If True, bypass any cache and fetch fresh data.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
dict: {"can_create": bool}
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
EntitlementsUnavailableError: If the backend cannot be reached.
|
||||||
|
"""
|
||||||
@@ -0,0 +1,120 @@
|
|||||||
|
"""DeployCenter (Espace Operateur) entitlements backend."""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from django.conf import settings
|
||||||
|
from django.core.cache import cache
|
||||||
|
|
||||||
|
import requests
|
||||||
|
|
||||||
|
from core.entitlements import EntitlementsUnavailableError
|
||||||
|
from core.entitlements.backends.base import EntitlementsBackend
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class DeployCenterEntitlementsBackend(EntitlementsBackend):
|
||||||
|
"""Backend that fetches entitlements from the DeployCenter API.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
base_url: Full URL of the entitlements endpoint
|
||||||
|
(e.g. "https://dc.example.com/api/v1.0/entitlements/").
|
||||||
|
service_id: The service identifier in DeployCenter.
|
||||||
|
api_key: API key for X-Service-Auth header.
|
||||||
|
timeout: HTTP request timeout in seconds.
|
||||||
|
oidc_claims: List of OIDC claim names to extract from user_info
|
||||||
|
and forward as query params (e.g. ["siret"]).
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__( # pylint: disable=too-many-arguments
|
||||||
|
self,
|
||||||
|
base_url,
|
||||||
|
service_id,
|
||||||
|
api_key,
|
||||||
|
*,
|
||||||
|
timeout=10,
|
||||||
|
oidc_claims=None,
|
||||||
|
):
|
||||||
|
self.base_url = base_url
|
||||||
|
self.service_id = service_id
|
||||||
|
self.api_key = api_key
|
||||||
|
self.timeout = timeout
|
||||||
|
self.oidc_claims = oidc_claims or []
|
||||||
|
|
||||||
|
def _cache_key(self, user_sub):
|
||||||
|
return f"entitlements:user:{user_sub}"
|
||||||
|
|
||||||
|
def _make_request(self, user_email, user_info=None):
|
||||||
|
"""Make a request to the DeployCenter entitlements API.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
dict | None: The response data, or None on failure.
|
||||||
|
"""
|
||||||
|
params = {
|
||||||
|
"service_id": self.service_id,
|
||||||
|
"account_type": "user",
|
||||||
|
"account_email": user_email,
|
||||||
|
}
|
||||||
|
|
||||||
|
# Forward configured OIDC claims as query params
|
||||||
|
if user_info:
|
||||||
|
for claim in self.oidc_claims:
|
||||||
|
if claim in user_info:
|
||||||
|
params[claim] = user_info[claim]
|
||||||
|
|
||||||
|
headers = {
|
||||||
|
"X-Service-Auth": f"Bearer {self.api_key}",
|
||||||
|
}
|
||||||
|
|
||||||
|
try:
|
||||||
|
response = requests.get(
|
||||||
|
self.base_url,
|
||||||
|
params=params,
|
||||||
|
headers=headers,
|
||||||
|
timeout=self.timeout,
|
||||||
|
)
|
||||||
|
response.raise_for_status()
|
||||||
|
return response.json()
|
||||||
|
except (requests.RequestException, ValueError):
|
||||||
|
email_domain = user_email.split("@")[-1] if "@" in user_email else "?"
|
||||||
|
logger.warning(
|
||||||
|
"DeployCenter entitlements request failed for user@%s",
|
||||||
|
email_domain,
|
||||||
|
exc_info=True,
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
|
||||||
|
def get_user_entitlements(
|
||||||
|
self, user_sub, user_email, user_info=None, force_refresh=False
|
||||||
|
):
|
||||||
|
"""Fetch user entitlements from DeployCenter with caching.
|
||||||
|
|
||||||
|
On cache miss or force_refresh: fetches from the API.
|
||||||
|
On API failure: falls back to stale cache if available,
|
||||||
|
otherwise raises EntitlementsUnavailableError.
|
||||||
|
"""
|
||||||
|
cache_key = self._cache_key(user_sub)
|
||||||
|
|
||||||
|
if not force_refresh:
|
||||||
|
cached = cache.get(cache_key)
|
||||||
|
if cached is not None:
|
||||||
|
return cached
|
||||||
|
|
||||||
|
data = self._make_request(user_email, user_info=user_info)
|
||||||
|
|
||||||
|
if data is None:
|
||||||
|
# API failed — try stale cache as fallback
|
||||||
|
cached = cache.get(cache_key)
|
||||||
|
if cached is not None:
|
||||||
|
return cached
|
||||||
|
raise EntitlementsUnavailableError(
|
||||||
|
"Failed to fetch user entitlements from DeployCenter"
|
||||||
|
)
|
||||||
|
|
||||||
|
entitlements = data.get("entitlements", {})
|
||||||
|
result = {
|
||||||
|
"can_create": entitlements.get("can_create", False),
|
||||||
|
}
|
||||||
|
|
||||||
|
cache.set(cache_key, result, settings.ENTITLEMENTS_CACHE_TIMEOUT)
|
||||||
|
return result
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
"""Local entitlements backend for development and testing."""
|
||||||
|
|
||||||
|
from core.entitlements.backends.base import EntitlementsBackend
|
||||||
|
|
||||||
|
|
||||||
|
class LocalEntitlementsBackend(EntitlementsBackend):
|
||||||
|
"""Local backend that always grants access."""
|
||||||
|
|
||||||
|
def get_user_entitlements(
|
||||||
|
self, user_sub, user_email, user_info=None, force_refresh=False
|
||||||
|
):
|
||||||
|
return {"can_create": True}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
"""Factory for creating entitlements backend instances."""
|
||||||
|
|
||||||
|
import functools
|
||||||
|
|
||||||
|
from django.conf import settings
|
||||||
|
from django.utils.module_loading import import_string
|
||||||
|
|
||||||
|
|
||||||
|
@functools.cache
|
||||||
|
def get_entitlements_backend():
|
||||||
|
"""Return a singleton instance of the configured entitlements backend."""
|
||||||
|
backend_class = import_string(settings.ENTITLEMENTS_BACKEND)
|
||||||
|
return backend_class(**settings.ENTITLEMENTS_BACKEND_PARAMETERS)
|
||||||
@@ -1,27 +1,51 @@
|
|||||||
"""Authentication Backends for external application to the Meet core app."""
|
"""Authentication Backends for external application to the Meet core app."""
|
||||||
|
|
||||||
|
# pylint: disable=R0913,R0917
|
||||||
|
# ruff: noqa: PLR0913
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.contrib.auth import get_user_model
|
from django.contrib.auth import get_user_model
|
||||||
from django.core.exceptions import SuspiciousOperation
|
from django.core.exceptions import SuspiciousOperation
|
||||||
|
|
||||||
import jwt as pyJwt
|
|
||||||
from lasuite.oidc_resource_server.backend import ResourceServerBackend as LaSuiteBackend
|
from lasuite.oidc_resource_server.backend import ResourceServerBackend as LaSuiteBackend
|
||||||
from rest_framework import authentication, exceptions
|
from rest_framework import authentication, exceptions
|
||||||
|
|
||||||
from core.models import Application
|
from core.models import Application
|
||||||
|
from core.services import jwt_token
|
||||||
|
|
||||||
User = get_user_model()
|
User = get_user_model()
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
class ApplicationJWTAuthentication(authentication.BaseAuthentication):
|
class BaseJWTAuthentication(authentication.BaseAuthentication):
|
||||||
"""JWT authentication for application-delegated API access.
|
"""Base JWT authentication class."""
|
||||||
|
|
||||||
Validates JWT tokens issued to applications that are acting on behalf
|
def __init__(
|
||||||
of users. Tokens must include user_id, client_id, and delegation flag.
|
self, secret_key, algorithm, issuer, audience, expiration_seconds, token_type
|
||||||
"""
|
):
|
||||||
|
"""Initialize the JWT authentication backend with the given token service configuration.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
secret_key: Secret key for JWT encoding/decoding
|
||||||
|
algorithm: JWT algorithm (e.g. HS256)
|
||||||
|
issuer: Expected token issuer identifier
|
||||||
|
audience: Expected token audience identifier
|
||||||
|
expiration_seconds: Token expiration time in seconds
|
||||||
|
token_type: Token type (e.g. Bearer)
|
||||||
|
"""
|
||||||
|
|
||||||
|
super().__init__()
|
||||||
|
|
||||||
|
self._token_service = jwt_token.JwtTokenService(
|
||||||
|
secret_key=secret_key,
|
||||||
|
algorithm=algorithm,
|
||||||
|
issuer=issuer,
|
||||||
|
audience=audience,
|
||||||
|
expiration_seconds=expiration_seconds,
|
||||||
|
token_type=token_type,
|
||||||
|
)
|
||||||
|
|
||||||
def authenticate(self, request):
|
def authenticate(self, request):
|
||||||
"""Extract and validate JWT from Authorization header.
|
"""Extract and validate JWT from Authorization header.
|
||||||
@@ -48,6 +72,78 @@ class ApplicationJWTAuthentication(authentication.BaseAuthentication):
|
|||||||
|
|
||||||
return self.authenticate_credentials(token)
|
return self.authenticate_credentials(token)
|
||||||
|
|
||||||
|
def decode_jwt(self, token):
|
||||||
|
"""Decode and validate JWT token.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
token: JWT token string
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Decoded payload dict, or None if token is invalid
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
AuthenticationFailed: If token is expired or has invalid issuer/audience
|
||||||
|
"""
|
||||||
|
|
||||||
|
try:
|
||||||
|
payload = self._token_service.decode_jwt(token)
|
||||||
|
return payload
|
||||||
|
except jwt_token.TokenExpiredError as e:
|
||||||
|
logger.warning("Token expired")
|
||||||
|
raise exceptions.AuthenticationFailed("Token expired.") from e
|
||||||
|
except jwt_token.TokenInvalidError as e:
|
||||||
|
logger.warning("Invalid JWT issuer or audience: %s", e)
|
||||||
|
raise exceptions.AuthenticationFailed("Invalid token.") from e
|
||||||
|
except jwt_token.TokenDecodeError:
|
||||||
|
# Invalid JWT token - defer to next authentication backend
|
||||||
|
return None
|
||||||
|
|
||||||
|
def validate_payload(self, payload):
|
||||||
|
"""Validate JWT payload claims.
|
||||||
|
|
||||||
|
Override in subclasses to add custom validation.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
payload: Decoded JWT payload
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
AuthenticationFailed: If required claims are missing or invalid
|
||||||
|
"""
|
||||||
|
|
||||||
|
def get_user(self, payload):
|
||||||
|
"""Retrieve and validate user from payload.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
payload: Decoded JWT payload
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
User instance
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
AuthenticationFailed: If user not found or inactive
|
||||||
|
"""
|
||||||
|
user_id = payload.get("user_id")
|
||||||
|
|
||||||
|
if not user_id:
|
||||||
|
logger.warning("Missing 'user_id' in JWT payload")
|
||||||
|
raise exceptions.AuthenticationFailed("Invalid token claims.")
|
||||||
|
|
||||||
|
try:
|
||||||
|
user = User.objects.get(id=user_id)
|
||||||
|
except User.DoesNotExist as e:
|
||||||
|
logger.warning("User not found: %s", user_id)
|
||||||
|
raise exceptions.AuthenticationFailed("User not found.") from e
|
||||||
|
|
||||||
|
if not user.is_active:
|
||||||
|
logger.warning("Inactive user attempted authentication: %s", user_id)
|
||||||
|
raise exceptions.AuthenticationFailed("User account is disabled.")
|
||||||
|
|
||||||
|
return user
|
||||||
|
|
||||||
|
def authenticate_header(self, request):
|
||||||
|
"""Return authentication scheme for WWW-Authenticate header."""
|
||||||
|
return "Bearer"
|
||||||
|
|
||||||
def authenticate_credentials(self, token):
|
def authenticate_credentials(self, token):
|
||||||
"""Validate JWT token and return authenticated user.
|
"""Validate JWT token and return authenticated user.
|
||||||
|
|
||||||
@@ -62,36 +158,41 @@ class ApplicationJWTAuthentication(authentication.BaseAuthentication):
|
|||||||
Raises:
|
Raises:
|
||||||
AuthenticationFailed: If token is expired, or user not found
|
AuthenticationFailed: If token is expired, or user not found
|
||||||
"""
|
"""
|
||||||
# Decode and validate JWT
|
|
||||||
try:
|
payload = self.decode_jwt(token)
|
||||||
payload = pyJwt.decode(
|
|
||||||
token,
|
if payload is None:
|
||||||
settings.APPLICATION_JWT_SECRET_KEY,
|
|
||||||
algorithms=[settings.APPLICATION_JWT_ALG],
|
|
||||||
issuer=settings.APPLICATION_JWT_ISSUER,
|
|
||||||
audience=settings.APPLICATION_JWT_AUDIENCE,
|
|
||||||
)
|
|
||||||
except pyJwt.ExpiredSignatureError as e:
|
|
||||||
logger.warning("Token expired")
|
|
||||||
raise exceptions.AuthenticationFailed("Token expired.") from e
|
|
||||||
except pyJwt.InvalidIssuerError as e:
|
|
||||||
logger.warning("Invalid JWT issuer: %s", e)
|
|
||||||
raise exceptions.AuthenticationFailed("Invalid token.") from e
|
|
||||||
except pyJwt.InvalidAudienceError as e:
|
|
||||||
logger.warning("Invalid JWT audience: %s", e)
|
|
||||||
raise exceptions.AuthenticationFailed("Invalid token.") from e
|
|
||||||
except pyJwt.InvalidTokenError:
|
|
||||||
# Invalid JWT token - defer to next authentication backend
|
|
||||||
return None
|
return None
|
||||||
|
|
||||||
user_id = payload.get("user_id")
|
self.validate_payload(payload)
|
||||||
|
user = self.get_user(payload)
|
||||||
|
|
||||||
|
return (user, payload)
|
||||||
|
|
||||||
|
|
||||||
|
class ApplicationJWTAuthentication(BaseJWTAuthentication):
|
||||||
|
"""JWT authentication for application-delegated API access.
|
||||||
|
|
||||||
|
Validates JWT tokens issued to applications that are acting on behalf
|
||||||
|
of users. Tokens must include user_id, client_id, and delegation flag.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
"""Initialize authentication backend with application JWT settings from Django settings."""
|
||||||
|
super().__init__(
|
||||||
|
secret_key=settings.APPLICATION_JWT_SECRET_KEY,
|
||||||
|
algorithm=settings.APPLICATION_JWT_ALG,
|
||||||
|
issuer=settings.APPLICATION_JWT_ISSUER,
|
||||||
|
audience=settings.APPLICATION_JWT_AUDIENCE,
|
||||||
|
expiration_seconds=settings.APPLICATION_JWT_EXPIRATION_SECONDS,
|
||||||
|
token_type=settings.APPLICATION_JWT_TOKEN_TYPE,
|
||||||
|
)
|
||||||
|
|
||||||
|
def validate_payload(self, payload):
|
||||||
|
"""Validate application-specific claims."""
|
||||||
client_id = payload.get("client_id")
|
client_id = payload.get("client_id")
|
||||||
is_delegated = payload.get("delegated", False)
|
is_delegated = payload.get("delegated", False)
|
||||||
|
|
||||||
if not user_id:
|
|
||||||
logger.warning("Missing 'user_id' in JWT payload")
|
|
||||||
raise exceptions.AuthenticationFailed("Invalid token claims.")
|
|
||||||
|
|
||||||
if not client_id:
|
if not client_id:
|
||||||
logger.warning("Missing 'client_id' in JWT payload")
|
logger.warning("Missing 'client_id' in JWT payload")
|
||||||
raise exceptions.AuthenticationFailed("Invalid token claims.")
|
raise exceptions.AuthenticationFailed("Invalid token claims.")
|
||||||
@@ -112,22 +213,6 @@ class ApplicationJWTAuthentication(authentication.BaseAuthentication):
|
|||||||
logger.warning("Token is not marked as delegated")
|
logger.warning("Token is not marked as delegated")
|
||||||
raise exceptions.AuthenticationFailed("Invalid token type.")
|
raise exceptions.AuthenticationFailed("Invalid token type.")
|
||||||
|
|
||||||
try:
|
|
||||||
user = User.objects.get(id=user_id)
|
|
||||||
except User.DoesNotExist as e:
|
|
||||||
logger.warning("User not found: %s", user_id)
|
|
||||||
raise exceptions.AuthenticationFailed("User not found.") from e
|
|
||||||
|
|
||||||
if not user.is_active:
|
|
||||||
logger.warning("Inactive user attempted authentication: %s", user_id)
|
|
||||||
raise exceptions.AuthenticationFailed("User account is disabled.")
|
|
||||||
|
|
||||||
return (user, payload)
|
|
||||||
|
|
||||||
def authenticate_header(self, request):
|
|
||||||
"""Return authentication scheme for WWW-Authenticate header."""
|
|
||||||
return "Bearer"
|
|
||||||
|
|
||||||
|
|
||||||
class ResourceServerBackend(LaSuiteBackend):
|
class ResourceServerBackend(LaSuiteBackend):
|
||||||
"""OIDC Resource Server backend for user creation and retrieval."""
|
"""OIDC Resource Server backend for user creation and retrieval."""
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
"""External API endpoints"""
|
"""External API endpoints"""
|
||||||
|
|
||||||
from datetime import datetime, timedelta, timezone
|
|
||||||
from logging import getLogger
|
from logging import getLogger
|
||||||
|
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
@@ -8,7 +7,6 @@ from django.contrib.auth.hashers import check_password
|
|||||||
from django.core.exceptions import SuspiciousOperation, ValidationError
|
from django.core.exceptions import SuspiciousOperation, ValidationError
|
||||||
from django.core.validators import validate_email
|
from django.core.validators import validate_email
|
||||||
|
|
||||||
import jwt
|
|
||||||
from lasuite.oidc_resource_server.authentication import ResourceServerAuthentication
|
from lasuite.oidc_resource_server.authentication import ResourceServerAuthentication
|
||||||
from rest_framework import decorators, mixins, viewsets
|
from rest_framework import decorators, mixins, viewsets
|
||||||
from rest_framework import (
|
from rest_framework import (
|
||||||
@@ -22,6 +20,7 @@ from rest_framework import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
from core import api, models
|
from core import api, models
|
||||||
|
from core.services.jwt_token import JwtTokenService
|
||||||
|
|
||||||
from . import authentication, permissions, serializers
|
from . import authentication, permissions, serializers
|
||||||
|
|
||||||
@@ -128,33 +127,28 @@ class ApplicationViewSet(viewsets.ViewSet):
|
|||||||
"Multiple user accounts share a common email."
|
"Multiple user accounts share a common email."
|
||||||
) from e
|
) from e
|
||||||
|
|
||||||
now = datetime.now(timezone.utc)
|
|
||||||
scope = " ".join(application.scopes or [])
|
scope = " ".join(application.scopes or [])
|
||||||
|
|
||||||
payload = {
|
token_service = JwtTokenService(
|
||||||
"iss": settings.APPLICATION_JWT_ISSUER,
|
secret_key=settings.APPLICATION_JWT_SECRET_KEY,
|
||||||
"aud": settings.APPLICATION_JWT_AUDIENCE,
|
|
||||||
"iat": now,
|
|
||||||
"exp": now + timedelta(seconds=settings.APPLICATION_JWT_EXPIRATION_SECONDS),
|
|
||||||
"client_id": client_id,
|
|
||||||
"scope": scope,
|
|
||||||
"user_id": str(user.id),
|
|
||||||
"delegated": True,
|
|
||||||
}
|
|
||||||
|
|
||||||
token = jwt.encode(
|
|
||||||
payload,
|
|
||||||
settings.APPLICATION_JWT_SECRET_KEY,
|
|
||||||
algorithm=settings.APPLICATION_JWT_ALG,
|
algorithm=settings.APPLICATION_JWT_ALG,
|
||||||
|
issuer=settings.APPLICATION_JWT_ISSUER,
|
||||||
|
audience=settings.APPLICATION_JWT_AUDIENCE,
|
||||||
|
expiration_seconds=settings.APPLICATION_JWT_EXPIRATION_SECONDS,
|
||||||
|
token_type=settings.APPLICATION_JWT_TOKEN_TYPE,
|
||||||
|
)
|
||||||
|
|
||||||
|
data = token_service.generate_jwt(
|
||||||
|
user,
|
||||||
|
scope,
|
||||||
|
{
|
||||||
|
"client_id": client_id,
|
||||||
|
"delegated": True,
|
||||||
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
return drf_response.Response(
|
return drf_response.Response(
|
||||||
{
|
data,
|
||||||
"access_token": token,
|
|
||||||
"token_type": settings.APPLICATION_JWT_TOKEN_TYPE,
|
|
||||||
"expires_in": settings.APPLICATION_JWT_EXPIRATION_SECONDS,
|
|
||||||
"scope": scope,
|
|
||||||
},
|
|
||||||
status=drf_status.HTTP_200_OK,
|
status=drf_status.HTTP_200_OK,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ class Migration(migrations.Migration):
|
|||||||
('sub', models.CharField(blank=True, help_text='Optional for pending users; required upon account activation. 255 characters or fewer. Letters, numbers, and @/./+/-/_ characters only.', max_length=255, null=True, unique=True, validators=[django.core.validators.RegexValidator(message='Enter a valid sub. This value may contain only letters, numbers, and @/./+/-/_ characters.', regex='^[\\w.@+-]+\\Z')], verbose_name='sub')),
|
('sub', models.CharField(blank=True, help_text='Optional for pending users; required upon account activation. 255 characters or fewer. Letters, numbers, and @/./+/-/_ characters only.', max_length=255, null=True, unique=True, validators=[django.core.validators.RegexValidator(message='Enter a valid sub. This value may contain only letters, numbers, and @/./+/-/_ characters.', regex='^[\\w.@+-]+\\Z')], verbose_name='sub')),
|
||||||
('email', models.EmailField(blank=True, max_length=254, null=True, verbose_name='identity email address')),
|
('email', models.EmailField(blank=True, max_length=254, null=True, verbose_name='identity email address')),
|
||||||
('admin_email', models.EmailField(blank=True, max_length=254, null=True, unique=True, verbose_name='admin email address')),
|
('admin_email', models.EmailField(blank=True, max_length=254, null=True, unique=True, verbose_name='admin email address')),
|
||||||
('language', models.CharField(choices="(('en-us', 'English'), ('fr-fr', 'French'))", default='en-us', help_text='The language in which the user wants to see the interface.', max_length=10, verbose_name='language')),
|
('language', models.CharField(choices=settings.LANGUAGES, default=settings.LANGUAGE_CODE, help_text='The language in which the user wants to see the interface.', max_length=10, verbose_name='language')),
|
||||||
('timezone', timezone_field.fields.TimeZoneField(choices_display='WITH_GMT_OFFSET', default='UTC', help_text='The timezone in which the user wants to see times.', use_pytz=False)),
|
('timezone', timezone_field.fields.TimeZoneField(choices_display='WITH_GMT_OFFSET', default='UTC', help_text='The timezone in which the user wants to see times.', use_pytz=False)),
|
||||||
('is_device', models.BooleanField(default=False, help_text='Whether the user is a device or a real user.', verbose_name='device')),
|
('is_device', models.BooleanField(default=False, help_text='Whether the user is a device or a real user.', verbose_name='device')),
|
||||||
('is_staff', models.BooleanField(default=False, help_text='Whether the user can log into this admin site.', verbose_name='staff status')),
|
('is_staff', models.BooleanField(default=False, help_text='Whether the user can log into this admin site.', verbose_name='staff status')),
|
||||||
@@ -96,7 +96,7 @@ class Migration(migrations.Migration):
|
|||||||
migrations.AddField(
|
migrations.AddField(
|
||||||
model_name='resource',
|
model_name='resource',
|
||||||
name='users',
|
name='users',
|
||||||
field=models.ManyToManyField(related_name='resources', through='core.ResourceAccess', to=settings.AUTH_USER_MODEL),
|
field=models.ManyToManyField(related_name='resources', through='core.ResourceAccess', through_fields=('resource', 'user'), to=settings.AUTH_USER_MODEL),
|
||||||
),
|
),
|
||||||
migrations.AddConstraint(
|
migrations.AddConstraint(
|
||||||
model_name='resourceaccess',
|
model_name='resourceaccess',
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
# Generated by Django 5.0.7 on 2024-08-07 14:39
|
# Generated by Django 5.0.7 on 2024-08-07 14:39
|
||||||
|
from django.conf import settings
|
||||||
from django.db import migrations, models
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
@@ -13,6 +13,6 @@ class Migration(migrations.Migration):
|
|||||||
migrations.AlterField(
|
migrations.AlterField(
|
||||||
model_name='user',
|
model_name='user',
|
||||||
name='language',
|
name='language',
|
||||||
field=models.CharField(choices="(('en-us', 'English'), ('fr-fr', 'French'))", default='en-us', help_text='The language in which the user wants to see the interface.', max_length=10, verbose_name='language'),
|
field=models.CharField(choices=settings.LANGUAGES, default=settings.LANGUAGE_CODE, help_text='The language in which the user wants to see the interface.', max_length=10, verbose_name='language'),
|
||||||
),
|
),
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
# Generated by Django 5.1.8 on 2025-04-22 14:52
|
# Generated by Django 5.1.8 on 2025-04-22 14:52
|
||||||
|
from django.conf import settings
|
||||||
from django.db import migrations, models
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
@@ -13,6 +13,6 @@ class Migration(migrations.Migration):
|
|||||||
migrations.AlterField(
|
migrations.AlterField(
|
||||||
model_name='user',
|
model_name='user',
|
||||||
name='language',
|
name='language',
|
||||||
field=models.CharField(choices="(('en-us', 'English'), ('fr-fr', 'French'), ('nl-nl', 'Dutch'))", default='en-us', help_text='The language in which the user wants to see the interface.', max_length=10, verbose_name='language'),
|
field=models.CharField(choices=settings.LANGUAGES, default=settings.LANGUAGE_CODE, help_text='The language in which the user wants to see the interface.', max_length=10, verbose_name='language'),
|
||||||
),
|
),
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -167,6 +167,7 @@ class NotificationService:
|
|||||||
owner_access.user.timezone
|
owner_access.user.timezone
|
||||||
).strftime("%H:%M"),
|
).strftime("%H:%M"),
|
||||||
"download_link": f"{get_recording_download_base_url()}/{recording.id}",
|
"download_link": f"{get_recording_download_base_url()}/{recording.id}",
|
||||||
|
"context_language": owner_access.user.language,
|
||||||
}
|
}
|
||||||
|
|
||||||
headers = {
|
headers = {
|
||||||
|
|||||||
@@ -0,0 +1,153 @@
|
|||||||
|
"""JWT token service."""
|
||||||
|
|
||||||
|
# pylint: disable=R0913,R0917
|
||||||
|
# ruff: noqa: PLR0913
|
||||||
|
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from django.core.exceptions import ImproperlyConfigured
|
||||||
|
|
||||||
|
import jwt
|
||||||
|
|
||||||
|
|
||||||
|
class JWTError(Exception):
|
||||||
|
"""Base exception for all JWT token errors."""
|
||||||
|
|
||||||
|
|
||||||
|
class TokenExpiredError(JWTError):
|
||||||
|
"""Raised when the JWT token has expired."""
|
||||||
|
|
||||||
|
|
||||||
|
class TokenInvalidError(JWTError):
|
||||||
|
"""Raised when the JWT token has an invalid issuer or audience."""
|
||||||
|
|
||||||
|
|
||||||
|
class TokenDecodeError(JWTError):
|
||||||
|
"""Raised for any other unrecoverable JWT decode failure."""
|
||||||
|
|
||||||
|
|
||||||
|
class JwtTokenService:
|
||||||
|
"""Generic JWT token service with configurable settings."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
secret_key: str,
|
||||||
|
algorithm: str,
|
||||||
|
issuer: str,
|
||||||
|
audience: str,
|
||||||
|
expiration_seconds: int,
|
||||||
|
token_type: str,
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Initialize the token service with custom settings.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
secret_key: Secret key for JWT encoding/decoding
|
||||||
|
algorithm: JWT algorithm
|
||||||
|
issuer: Token issuer identifier
|
||||||
|
audience: Token audience identifier
|
||||||
|
expiration_seconds: Token expiration time in seconds
|
||||||
|
token_type: Token type
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ImproperlyConfigured: If secret_key is None or empty
|
||||||
|
"""
|
||||||
|
if not secret_key:
|
||||||
|
raise ImproperlyConfigured("Secret key is required.")
|
||||||
|
if not algorithm:
|
||||||
|
raise ImproperlyConfigured("Algorithm is required.")
|
||||||
|
if not token_type:
|
||||||
|
raise ImproperlyConfigured("Token's type is required.")
|
||||||
|
if expiration_seconds is None:
|
||||||
|
raise ImproperlyConfigured("Expiration's seconds is required.")
|
||||||
|
|
||||||
|
self._key = secret_key
|
||||||
|
self._algorithm = algorithm
|
||||||
|
self._issuer = issuer
|
||||||
|
self._audience = audience
|
||||||
|
self._expiration_seconds = expiration_seconds
|
||||||
|
self._token_type = token_type
|
||||||
|
|
||||||
|
def generate_jwt(
|
||||||
|
self, user, scope: str, extra_payload: Optional[dict] = None
|
||||||
|
) -> dict:
|
||||||
|
"""
|
||||||
|
Generate an access token for the given user.
|
||||||
|
|
||||||
|
Note: any extra_payload variables named iat, exp, or user_id will
|
||||||
|
be overwritten by this service
|
||||||
|
|
||||||
|
Args:
|
||||||
|
user: User instance for whom to generate the token
|
||||||
|
scope: Space-separated scope string
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dictionary containing access_token, token_type, expires_in, and scope optionally
|
||||||
|
"""
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
|
||||||
|
payload = extra_payload.copy() if extra_payload else {}
|
||||||
|
|
||||||
|
payload.update(
|
||||||
|
{
|
||||||
|
"iat": now,
|
||||||
|
"exp": now + timedelta(seconds=self._expiration_seconds),
|
||||||
|
"user_id": str(user.id),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
if self._issuer:
|
||||||
|
payload["iss"] = self._issuer
|
||||||
|
if self._audience:
|
||||||
|
payload["aud"] = self._audience
|
||||||
|
if scope:
|
||||||
|
payload["scope"] = scope
|
||||||
|
|
||||||
|
token = jwt.encode(
|
||||||
|
payload,
|
||||||
|
self._key,
|
||||||
|
algorithm=self._algorithm,
|
||||||
|
)
|
||||||
|
|
||||||
|
response = {
|
||||||
|
"access_token": token,
|
||||||
|
"token_type": self._token_type,
|
||||||
|
"expires_in": self._expiration_seconds,
|
||||||
|
}
|
||||||
|
|
||||||
|
if scope:
|
||||||
|
response["scope"] = scope
|
||||||
|
|
||||||
|
return response
|
||||||
|
|
||||||
|
def decode_jwt(self, token):
|
||||||
|
"""Decode and validate JWT token.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
token: JWT token string
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Decoded payload dict.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
TokenExpiredError: If the token has expired.
|
||||||
|
TokenInvalidError: If the token has an invalid issuer or audience.
|
||||||
|
TokenDecodeError: If the token is malformed or cannot be decoded.
|
||||||
|
"""
|
||||||
|
|
||||||
|
try:
|
||||||
|
payload = jwt.decode(
|
||||||
|
token,
|
||||||
|
self._key,
|
||||||
|
algorithms=[self._algorithm],
|
||||||
|
issuer=self._issuer,
|
||||||
|
audience=self._audience,
|
||||||
|
)
|
||||||
|
return payload
|
||||||
|
except jwt.ExpiredSignatureError as e:
|
||||||
|
raise TokenExpiredError("Token expired.") from e
|
||||||
|
except (jwt.InvalidIssuerError, jwt.InvalidAudienceError) as e:
|
||||||
|
raise TokenInvalidError("Invalid token.") from e
|
||||||
|
except jwt.InvalidTokenError as e:
|
||||||
|
raise TokenDecodeError("Token decode error.") from e
|
||||||
@@ -8,6 +8,7 @@ import random
|
|||||||
from unittest import mock
|
from unittest import mock
|
||||||
from uuid import uuid4
|
from uuid import uuid4
|
||||||
|
|
||||||
|
from django.core.exceptions import SuspiciousOperation
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
@@ -132,11 +133,7 @@ def test_update_participant_success(mock_livekit_client):
|
|||||||
1,
|
1,
|
||||||
2,
|
2,
|
||||||
], # [TrackSource.CAMERA, TrackSource.MICROPHONE]
|
], # [TrackSource.CAMERA, TrackSource.MICROPHONE]
|
||||||
"hidden": False,
|
|
||||||
"recorder": False,
|
|
||||||
"can_update_metadata": True,
|
"can_update_metadata": True,
|
||||||
"agent": False,
|
|
||||||
"can_subscribe_metrics": False,
|
|
||||||
},
|
},
|
||||||
"name": "John Doe",
|
"name": "John Doe",
|
||||||
}
|
}
|
||||||
@@ -151,6 +148,151 @@ def test_update_participant_success(mock_livekit_client):
|
|||||||
mock_livekit_client.aclose.assert_called_once()
|
mock_livekit_client.aclose.assert_called_once()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"permission_payload",
|
||||||
|
[
|
||||||
|
{}, # empty dict is valid
|
||||||
|
{"can_subscribe": True},
|
||||||
|
{"can_publish": True},
|
||||||
|
{"can_publish_data": True},
|
||||||
|
{"can_publish_sources": [1, 2]},
|
||||||
|
{"can_update_metadata": True},
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_update_participant_permission_fields_are_optional(
|
||||||
|
mock_livekit_client, permission_payload
|
||||||
|
):
|
||||||
|
"""Test that each required permission field can be passed individually."""
|
||||||
|
client = APIClient()
|
||||||
|
room = RoomFactory()
|
||||||
|
user = UserFactory()
|
||||||
|
UserResourceAccessFactory(
|
||||||
|
resource=room, user=user, role=random.choice(["administrator", "owner"])
|
||||||
|
)
|
||||||
|
client.force_authenticate(user=user)
|
||||||
|
|
||||||
|
payload = {
|
||||||
|
"participant_identity": str(uuid4()),
|
||||||
|
"permission": permission_payload,
|
||||||
|
}
|
||||||
|
|
||||||
|
url = reverse("rooms-update-participant", kwargs={"pk": room.id})
|
||||||
|
response = client.post(url, payload, format="json")
|
||||||
|
|
||||||
|
assert response.status_code == status.HTTP_200_OK
|
||||||
|
assert response.data == {"status": "success"}
|
||||||
|
|
||||||
|
mock_livekit_client.room.update_participant.assert_called_once()
|
||||||
|
mock_livekit_client.aclose.assert_called_once()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"value,permission_key",
|
||||||
|
[
|
||||||
|
(False, "hidden"),
|
||||||
|
(True, "hidden"),
|
||||||
|
(False, "recorder"),
|
||||||
|
(True, "recorder"),
|
||||||
|
(False, "agent"),
|
||||||
|
(True, "agent"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
@mock.patch("core.api.serializers.SuspiciousOperation", side_effect=SuspiciousOperation)
|
||||||
|
def test_update_participant_suspicious_permission(
|
||||||
|
mock_suspicious, value, permission_key
|
||||||
|
):
|
||||||
|
"""Test update participant raises 400 when a restricted permission is set."""
|
||||||
|
client = APIClient()
|
||||||
|
room = RoomFactory()
|
||||||
|
user = UserFactory()
|
||||||
|
UserResourceAccessFactory(
|
||||||
|
resource=room, user=user, role=random.choice(["administrator", "owner"])
|
||||||
|
)
|
||||||
|
client.force_authenticate(user=user)
|
||||||
|
|
||||||
|
payload = {
|
||||||
|
"participant_identity": str(uuid4()),
|
||||||
|
"permission": {
|
||||||
|
"can_subscribe": True,
|
||||||
|
"can_publish": True,
|
||||||
|
"can_publish_data": True,
|
||||||
|
"can_update_metadata": False,
|
||||||
|
permission_key: value,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
url = reverse("rooms-update-participant", kwargs={"pk": room.id})
|
||||||
|
response = client.post(url, payload, format="json")
|
||||||
|
|
||||||
|
assert response.status_code == status.HTTP_400_BAD_REQUEST
|
||||||
|
mock_suspicious.assert_called_once_with(
|
||||||
|
f"Setting the following participant permissions is not allowed: {permission_key}."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@mock.patch("core.api.serializers.SuspiciousOperation", side_effect=SuspiciousOperation)
|
||||||
|
def test_update_participant_suspicious_permission_multiple(mock_suspicious):
|
||||||
|
"""Test update participant raises 400 when multiple suspicious permissions are set."""
|
||||||
|
client = APIClient()
|
||||||
|
room = RoomFactory()
|
||||||
|
user = UserFactory()
|
||||||
|
UserResourceAccessFactory(
|
||||||
|
resource=room, user=user, role=random.choice(["administrator", "owner"])
|
||||||
|
)
|
||||||
|
client.force_authenticate(user=user)
|
||||||
|
|
||||||
|
payload = {
|
||||||
|
"participant_identity": str(uuid4()),
|
||||||
|
"permission": {
|
||||||
|
"can_subscribe": True,
|
||||||
|
"can_publish": True,
|
||||||
|
"can_publish_data": True,
|
||||||
|
"hidden": True,
|
||||||
|
"recorder": False,
|
||||||
|
"can_update_metadata": False,
|
||||||
|
"agent": True,
|
||||||
|
"can_subscribe_metrics": False,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
url = reverse("rooms-update-participant", kwargs={"pk": room.id})
|
||||||
|
response = client.post(url, payload, format="json")
|
||||||
|
|
||||||
|
assert response.status_code == status.HTTP_400_BAD_REQUEST
|
||||||
|
mock_suspicious.assert_called_once_with(
|
||||||
|
"Setting the following participant permissions is not allowed: hidden, recorder, agent."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("value", (False, True))
|
||||||
|
def test_update_participant_unimplemented_can_subscribe_metrics(value):
|
||||||
|
"""Test update participant raises 400 when can_subscribe_metrics is set."""
|
||||||
|
client = APIClient()
|
||||||
|
room = RoomFactory()
|
||||||
|
user = UserFactory()
|
||||||
|
UserResourceAccessFactory(
|
||||||
|
resource=room, user=user, role=random.choice(["administrator", "owner"])
|
||||||
|
)
|
||||||
|
client.force_authenticate(user=user)
|
||||||
|
|
||||||
|
payload = {
|
||||||
|
"participant_identity": str(uuid4()),
|
||||||
|
"permission": {
|
||||||
|
"can_subscribe": True,
|
||||||
|
"can_publish": True,
|
||||||
|
"can_publish_data": True,
|
||||||
|
"can_update_metadata": False,
|
||||||
|
"can_subscribe_metrics": value,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
url = reverse("rooms-update-participant", kwargs={"pk": room.id})
|
||||||
|
response = client.post(url, payload, format="json")
|
||||||
|
|
||||||
|
assert response.status_code == status.HTTP_400_BAD_REQUEST
|
||||||
|
assert "can_subscribe_metrics" in str(response.data)
|
||||||
|
|
||||||
|
|
||||||
def test_update_participant_forbidden_without_access():
|
def test_update_participant_forbidden_without_access():
|
||||||
"""Test update participant returns 403 when user lacks room privileges."""
|
"""Test update participant returns 403 when user lacks room privileges."""
|
||||||
client = APIClient()
|
client = APIClient()
|
||||||
@@ -226,7 +368,17 @@ def test_update_participant_invalid_permission():
|
|||||||
response = client.post(url, payload, format="json")
|
response = client.post(url, payload, format="json")
|
||||||
|
|
||||||
assert response.status_code == status.HTTP_400_BAD_REQUEST
|
assert response.status_code == status.HTTP_400_BAD_REQUEST
|
||||||
assert "Invalid permission" in str(response.data)
|
assert response.json() == {
|
||||||
|
"permission": [
|
||||||
|
{
|
||||||
|
"type": "extra_forbidden",
|
||||||
|
"loc": ["invalid-attributes"],
|
||||||
|
"msg": "Extra inputs are not permitted",
|
||||||
|
"input": "True",
|
||||||
|
"url": "https://errors.pydantic.dev/2.12/v/extra_forbidden",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def test_update_participant_wrong_metadata_attributes():
|
def test_update_participant_wrong_metadata_attributes():
|
||||||
|
|||||||
@@ -199,3 +199,308 @@ def test_start_recording_success(
|
|||||||
access = recording.accesses.first()
|
access = recording.accesses.first()
|
||||||
assert access.user == user
|
assert access.user == user
|
||||||
assert access.role == "owner"
|
assert access.role == "owner"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("value", ["fr", "en", "nl", "de"])
|
||||||
|
def test_start_recording_options_language_valid(
|
||||||
|
settings, mock_worker_service_factory, mock_worker_manager, value
|
||||||
|
):
|
||||||
|
"""Should accept a valid ISO 639-1 language code."""
|
||||||
|
settings.RECORDING_ENABLE = True
|
||||||
|
room = RoomFactory()
|
||||||
|
user = UserFactory()
|
||||||
|
room.accesses.create(user=user, role="owner")
|
||||||
|
client = APIClient()
|
||||||
|
client.force_login(user)
|
||||||
|
|
||||||
|
response = client.post(
|
||||||
|
f"/api/v1.0/rooms/{room.id}/start-recording/",
|
||||||
|
{"mode": "screen_recording", "options": {"language": value}},
|
||||||
|
format="json",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 201
|
||||||
|
|
||||||
|
recording = Recording.objects.get(room=room)
|
||||||
|
assert recording.options == {"language": value}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("value", ["invalid-value", "francais", "123"])
|
||||||
|
def test_start_recording_options_language_not_validated(
|
||||||
|
settings, mock_worker_service_factory, mock_worker_manager, value
|
||||||
|
):
|
||||||
|
"""Invalid language codes are currently accepted — no format validation yet.
|
||||||
|
|
||||||
|
TODO: tighten this once language validation is introduced.
|
||||||
|
"""
|
||||||
|
settings.RECORDING_ENABLE = True
|
||||||
|
room = RoomFactory()
|
||||||
|
user = UserFactory()
|
||||||
|
room.accesses.create(user=user, role="owner")
|
||||||
|
client = APIClient()
|
||||||
|
client.force_login(user)
|
||||||
|
|
||||||
|
response = client.post(
|
||||||
|
f"/api/v1.0/rooms/{room.id}/start-recording/",
|
||||||
|
{"mode": "screen_recording", "options": {"language": value}},
|
||||||
|
format="json",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 201
|
||||||
|
|
||||||
|
|
||||||
|
def test_start_recording_options_language_null(
|
||||||
|
settings, mock_worker_service_factory, mock_worker_manager
|
||||||
|
):
|
||||||
|
"""Should accept null language (triggers auto-detection)."""
|
||||||
|
settings.RECORDING_ENABLE = True
|
||||||
|
room = RoomFactory()
|
||||||
|
user = UserFactory()
|
||||||
|
room.accesses.create(user=user, role="owner")
|
||||||
|
client = APIClient()
|
||||||
|
client.force_login(user)
|
||||||
|
|
||||||
|
response = client.post(
|
||||||
|
f"/api/v1.0/rooms/{room.id}/start-recording/",
|
||||||
|
{"mode": "screen_recording", "options": {"language": None}},
|
||||||
|
format="json",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 201
|
||||||
|
recording = Recording.objects.get(room=room)
|
||||||
|
assert recording.options == {}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("value", [True, 1, "y", "on", "true", "yes", "t"])
|
||||||
|
def test_start_recording_options_transcribe_valid_true(
|
||||||
|
settings, mock_worker_service_factory, mock_worker_manager, value
|
||||||
|
):
|
||||||
|
"""Should accept transcribe with any valid pydantic true values."""
|
||||||
|
settings.RECORDING_ENABLE = True
|
||||||
|
room = RoomFactory()
|
||||||
|
user = UserFactory()
|
||||||
|
room.accesses.create(user=user, role="owner")
|
||||||
|
client = APIClient()
|
||||||
|
client.force_login(user)
|
||||||
|
|
||||||
|
response = client.post(
|
||||||
|
f"/api/v1.0/rooms/{room.id}/start-recording/",
|
||||||
|
{"mode": "screen_recording", "options": {"transcribe": value}},
|
||||||
|
format="json",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 201
|
||||||
|
recording = Recording.objects.get(room=room)
|
||||||
|
assert recording.options == {"transcribe": True}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("value", [False, 0, "n", "off", "false", "no", "f"])
|
||||||
|
def test_start_recording_options_transcribe_valid_false(
|
||||||
|
settings, mock_worker_service_factory, mock_worker_manager, value
|
||||||
|
):
|
||||||
|
"""Should accept transcribe with any valid pydantic false values."""
|
||||||
|
settings.RECORDING_ENABLE = True
|
||||||
|
room = RoomFactory()
|
||||||
|
user = UserFactory()
|
||||||
|
room.accesses.create(user=user, role="owner")
|
||||||
|
client = APIClient()
|
||||||
|
client.force_login(user)
|
||||||
|
|
||||||
|
response = client.post(
|
||||||
|
f"/api/v1.0/rooms/{room.id}/start-recording/",
|
||||||
|
{"mode": "screen_recording", "options": {"transcribe": value}},
|
||||||
|
format="json",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 201
|
||||||
|
recording = Recording.objects.get(room=room)
|
||||||
|
assert recording.options == {"transcribe": False}
|
||||||
|
|
||||||
|
|
||||||
|
def test_start_recording_options_transcribe_null(
|
||||||
|
settings, mock_worker_service_factory, mock_worker_manager
|
||||||
|
):
|
||||||
|
"""Should accept transcribe=null (falls back to application default)."""
|
||||||
|
settings.RECORDING_ENABLE = True
|
||||||
|
room = RoomFactory()
|
||||||
|
user = UserFactory()
|
||||||
|
room.accesses.create(user=user, role="owner")
|
||||||
|
client = APIClient()
|
||||||
|
client.force_login(user)
|
||||||
|
|
||||||
|
response = client.post(
|
||||||
|
f"/api/v1.0/rooms/{room.id}/start-recording/",
|
||||||
|
{"mode": "screen_recording", "options": {"transcribe": None}},
|
||||||
|
format="json",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 201
|
||||||
|
recording = Recording.objects.get(room=room)
|
||||||
|
assert recording.options == {}
|
||||||
|
|
||||||
|
|
||||||
|
def test_start_recording_options_null(
|
||||||
|
settings, mock_worker_service_factory, mock_worker_manager
|
||||||
|
):
|
||||||
|
"""Should accept options=null."""
|
||||||
|
settings.RECORDING_ENABLE = True
|
||||||
|
room = RoomFactory()
|
||||||
|
user = UserFactory()
|
||||||
|
room.accesses.create(user=user, role="owner")
|
||||||
|
client = APIClient()
|
||||||
|
client.force_login(user)
|
||||||
|
|
||||||
|
response = client.post(
|
||||||
|
f"/api/v1.0/rooms/{room.id}/start-recording/",
|
||||||
|
{"mode": "screen_recording", "options": None},
|
||||||
|
format="json",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 201
|
||||||
|
recording = Recording.objects.get(room=room)
|
||||||
|
assert recording.options == {}
|
||||||
|
|
||||||
|
|
||||||
|
def test_start_recording_options_omitted(
|
||||||
|
settings, mock_worker_service_factory, mock_worker_manager
|
||||||
|
):
|
||||||
|
"""Should accept a request with no options field at all."""
|
||||||
|
settings.RECORDING_ENABLE = True
|
||||||
|
room = RoomFactory()
|
||||||
|
user = UserFactory()
|
||||||
|
room.accesses.create(user=user, role="owner")
|
||||||
|
client = APIClient()
|
||||||
|
client.force_login(user)
|
||||||
|
|
||||||
|
response = client.post(
|
||||||
|
f"/api/v1.0/rooms/{room.id}/start-recording/",
|
||||||
|
{"mode": "screen_recording"},
|
||||||
|
format="json",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 201
|
||||||
|
recording = Recording.objects.get(room=room)
|
||||||
|
assert recording.options == {}
|
||||||
|
|
||||||
|
|
||||||
|
def test_start_recording_options_unknown_field_rejected(settings):
|
||||||
|
"""Should reject unknown fields in options (extra='forbid')."""
|
||||||
|
settings.RECORDING_ENABLE = True
|
||||||
|
room = RoomFactory()
|
||||||
|
user = UserFactory()
|
||||||
|
room.accesses.create(user=user, role="owner")
|
||||||
|
client = APIClient()
|
||||||
|
client.force_login(user)
|
||||||
|
|
||||||
|
response = client.post(
|
||||||
|
f"/api/v1.0/rooms/{room.id}/start-recording/",
|
||||||
|
{"mode": "screen_recording", "options": {"unknown_field": "value"}},
|
||||||
|
format="json",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 400
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("value", ["foo", 12])
|
||||||
|
def test_start_recording_options_invalid_transcribe_type(settings, value):
|
||||||
|
"""Should reject non-boolean transcribe values."""
|
||||||
|
settings.RECORDING_ENABLE = True
|
||||||
|
room = RoomFactory()
|
||||||
|
user = UserFactory()
|
||||||
|
room.accesses.create(user=user, role="owner")
|
||||||
|
client = APIClient()
|
||||||
|
client.force_login(user)
|
||||||
|
|
||||||
|
response = client.post(
|
||||||
|
f"/api/v1.0/rooms/{room.id}/start-recording/",
|
||||||
|
{"mode": "screen_recording", "options": {"transcribe": value}},
|
||||||
|
format="json",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 400
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("value", ["screen_recording", "transcript"])
|
||||||
|
def test_start_recording_options_original_mode_valid(
|
||||||
|
settings, mock_worker_service_factory, mock_worker_manager, value
|
||||||
|
):
|
||||||
|
"""Should accept valid recording mode choices for original_mode."""
|
||||||
|
settings.RECORDING_ENABLE = True
|
||||||
|
room = RoomFactory()
|
||||||
|
user = UserFactory()
|
||||||
|
room.accesses.create(user=user, role="owner")
|
||||||
|
client = APIClient()
|
||||||
|
client.force_login(user)
|
||||||
|
|
||||||
|
response = client.post(
|
||||||
|
f"/api/v1.0/rooms/{room.id}/start-recording/",
|
||||||
|
{"mode": "screen_recording", "options": {"original_mode": value}},
|
||||||
|
format="json",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 201
|
||||||
|
recording = Recording.objects.get(room=room)
|
||||||
|
assert recording.options == {"original_mode": value}
|
||||||
|
|
||||||
|
|
||||||
|
def test_start_recording_options_original_mode_null(
|
||||||
|
settings, mock_worker_service_factory, mock_worker_manager
|
||||||
|
):
|
||||||
|
"""Should accept original_mode=null."""
|
||||||
|
settings.RECORDING_ENABLE = True
|
||||||
|
room = RoomFactory()
|
||||||
|
user = UserFactory()
|
||||||
|
room.accesses.create(user=user, role="owner")
|
||||||
|
client = APIClient()
|
||||||
|
client.force_login(user)
|
||||||
|
|
||||||
|
response = client.post(
|
||||||
|
f"/api/v1.0/rooms/{room.id}/start-recording/",
|
||||||
|
{"mode": "screen_recording", "options": {"original_mode": None}},
|
||||||
|
format="json",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 201
|
||||||
|
recording = Recording.objects.get(room=room)
|
||||||
|
assert recording.options == {}
|
||||||
|
|
||||||
|
|
||||||
|
def test_start_recording_options_original_mode_omitted(
|
||||||
|
settings, mock_worker_service_factory, mock_worker_manager
|
||||||
|
):
|
||||||
|
"""Should accept a request with original_mode omitted."""
|
||||||
|
settings.RECORDING_ENABLE = True
|
||||||
|
room = RoomFactory()
|
||||||
|
user = UserFactory()
|
||||||
|
room.accesses.create(user=user, role="owner")
|
||||||
|
client = APIClient()
|
||||||
|
client.force_login(user)
|
||||||
|
|
||||||
|
response = client.post(
|
||||||
|
f"/api/v1.0/rooms/{room.id}/start-recording/",
|
||||||
|
{"mode": "screen_recording", "options": {}},
|
||||||
|
format="json",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 201
|
||||||
|
recording = Recording.objects.get(room=room)
|
||||||
|
assert recording.options == {}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("value", ["invalid_mode", "foo", 123, "SCREEN_RECORDING"])
|
||||||
|
def test_start_recording_options_original_mode_invalid(settings, value):
|
||||||
|
"""Should reject invalid recording mode values for original_mode."""
|
||||||
|
settings.RECORDING_ENABLE = True
|
||||||
|
room = RoomFactory()
|
||||||
|
user = UserFactory()
|
||||||
|
room.accesses.create(user=user, role="owner")
|
||||||
|
client = APIClient()
|
||||||
|
client.force_login(user)
|
||||||
|
|
||||||
|
response = client.post(
|
||||||
|
f"/api/v1.0/rooms/{room.id}/start-recording/",
|
||||||
|
{"mode": "screen_recording", "options": {"original_mode": value}},
|
||||||
|
format="json",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 400
|
||||||
|
|||||||
@@ -125,6 +125,7 @@ def test_api_users_retrieve_me_authenticated(settings):
|
|||||||
"short_name": user.short_name,
|
"short_name": user.short_name,
|
||||||
"language": user.language,
|
"language": user.language,
|
||||||
"timezone": "UTC",
|
"timezone": "UTC",
|
||||||
|
"can_create": True,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,504 @@
|
|||||||
|
"""Tests for the entitlements module."""
|
||||||
|
|
||||||
|
# pylint: disable=redefined-outer-name
|
||||||
|
|
||||||
|
from unittest import mock
|
||||||
|
|
||||||
|
from django.test import override_settings
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
import requests
|
||||||
|
import responses
|
||||||
|
from rest_framework.status import HTTP_201_CREATED, HTTP_403_FORBIDDEN
|
||||||
|
from rest_framework.test import APIClient
|
||||||
|
|
||||||
|
from django.core.cache import cache as django_cache
|
||||||
|
|
||||||
|
from core import factories
|
||||||
|
from core.api.serializers import UserMeSerializer
|
||||||
|
from core.authentication.backends import OIDCAuthenticationBackend
|
||||||
|
from core.entitlements import EntitlementsUnavailableError, get_user_entitlements
|
||||||
|
from core.entitlements.backends.deploycenter import DeployCenterEntitlementsBackend
|
||||||
|
from core.entitlements.backends.local import LocalEntitlementsBackend
|
||||||
|
from core.entitlements.factory import get_entitlements_backend
|
||||||
|
|
||||||
|
pytestmark = pytest.mark.django_db
|
||||||
|
|
||||||
|
DC_URL = "https://deploy.example.com/api/v1.0/entitlements/"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def _clear_cache():
|
||||||
|
"""Clear Django cache between tests to prevent entitlements cache bleed."""
|
||||||
|
django_cache.clear()
|
||||||
|
|
||||||
|
|
||||||
|
# -- LocalEntitlementsBackend --
|
||||||
|
|
||||||
|
|
||||||
|
def test_local_backend_always_grants_access():
|
||||||
|
"""The local backend should always return can_create=True."""
|
||||||
|
backend = LocalEntitlementsBackend()
|
||||||
|
result = backend.get_user_entitlements("sub-123", "user@example.com")
|
||||||
|
assert result == {"can_create": True}
|
||||||
|
|
||||||
|
|
||||||
|
def test_local_backend_ignores_parameters():
|
||||||
|
"""The local backend should work regardless of parameters passed."""
|
||||||
|
backend = LocalEntitlementsBackend()
|
||||||
|
result = backend.get_user_entitlements(
|
||||||
|
"sub-123",
|
||||||
|
"user@example.com",
|
||||||
|
user_info={"some": "claim"},
|
||||||
|
force_refresh=True,
|
||||||
|
)
|
||||||
|
assert result == {"can_create": True}
|
||||||
|
|
||||||
|
|
||||||
|
# -- Factory --
|
||||||
|
|
||||||
|
|
||||||
|
@override_settings(
|
||||||
|
ENTITLEMENTS_BACKEND="core.entitlements.backends.local.LocalEntitlementsBackend",
|
||||||
|
ENTITLEMENTS_BACKEND_PARAMETERS={},
|
||||||
|
)
|
||||||
|
def test_factory_returns_local_backend():
|
||||||
|
"""The factory should instantiate the configured backend."""
|
||||||
|
get_entitlements_backend.cache_clear()
|
||||||
|
backend = get_entitlements_backend()
|
||||||
|
assert isinstance(backend, LocalEntitlementsBackend)
|
||||||
|
get_entitlements_backend.cache_clear()
|
||||||
|
|
||||||
|
|
||||||
|
@override_settings(
|
||||||
|
ENTITLEMENTS_BACKEND="core.entitlements.backends.local.LocalEntitlementsBackend",
|
||||||
|
ENTITLEMENTS_BACKEND_PARAMETERS={},
|
||||||
|
)
|
||||||
|
def test_factory_singleton():
|
||||||
|
"""The factory should return the same instance on repeated calls."""
|
||||||
|
get_entitlements_backend.cache_clear()
|
||||||
|
backend1 = get_entitlements_backend()
|
||||||
|
backend2 = get_entitlements_backend()
|
||||||
|
assert backend1 is backend2
|
||||||
|
get_entitlements_backend.cache_clear()
|
||||||
|
|
||||||
|
|
||||||
|
# -- get_user_entitlements public API --
|
||||||
|
|
||||||
|
|
||||||
|
@override_settings(
|
||||||
|
ENTITLEMENTS_BACKEND="core.entitlements.backends.local.LocalEntitlementsBackend",
|
||||||
|
ENTITLEMENTS_BACKEND_PARAMETERS={},
|
||||||
|
)
|
||||||
|
def test_get_user_entitlements_with_local_backend():
|
||||||
|
"""The public API should delegate to the configured backend."""
|
||||||
|
get_entitlements_backend.cache_clear()
|
||||||
|
result = get_user_entitlements("sub-123", "user@example.com")
|
||||||
|
assert result["can_create"] is True
|
||||||
|
get_entitlements_backend.cache_clear()
|
||||||
|
|
||||||
|
|
||||||
|
# -- DeployCenterEntitlementsBackend --
|
||||||
|
|
||||||
|
|
||||||
|
@responses.activate
|
||||||
|
def test_deploycenter_backend_grants_access():
|
||||||
|
"""DeployCenter backend should return can_create from API response."""
|
||||||
|
responses.add(
|
||||||
|
responses.GET,
|
||||||
|
DC_URL,
|
||||||
|
json={"entitlements": {"can_create": True}},
|
||||||
|
status=200,
|
||||||
|
)
|
||||||
|
|
||||||
|
backend = DeployCenterEntitlementsBackend(
|
||||||
|
base_url=DC_URL,
|
||||||
|
service_id="meet",
|
||||||
|
api_key="test-key",
|
||||||
|
)
|
||||||
|
result = backend.get_user_entitlements("sub-123", "user@example.com")
|
||||||
|
assert result == {"can_create": True}
|
||||||
|
|
||||||
|
# Verify request was made with correct params and header
|
||||||
|
assert len(responses.calls) == 1
|
||||||
|
request = responses.calls[0].request
|
||||||
|
assert "service_id=meet" in request.url
|
||||||
|
assert "account_email=user%40example.com" in request.url
|
||||||
|
assert request.headers["X-Service-Auth"] == "Bearer test-key"
|
||||||
|
|
||||||
|
|
||||||
|
@responses.activate
|
||||||
|
def test_deploycenter_backend_denies_access():
|
||||||
|
"""DeployCenter backend should return can_create=False when API says so."""
|
||||||
|
responses.add(
|
||||||
|
responses.GET,
|
||||||
|
DC_URL,
|
||||||
|
json={"entitlements": {"can_create": False}},
|
||||||
|
status=200,
|
||||||
|
)
|
||||||
|
|
||||||
|
backend = DeployCenterEntitlementsBackend(
|
||||||
|
base_url=DC_URL,
|
||||||
|
service_id="meet",
|
||||||
|
api_key="test-key",
|
||||||
|
)
|
||||||
|
result = backend.get_user_entitlements("sub-123", "user@example.com")
|
||||||
|
assert result == {"can_create": False}
|
||||||
|
|
||||||
|
|
||||||
|
@responses.activate
|
||||||
|
@override_settings(ENTITLEMENTS_CACHE_TIMEOUT=300)
|
||||||
|
def test_deploycenter_backend_uses_cache():
|
||||||
|
"""DeployCenter should use cached results when not force_refresh."""
|
||||||
|
responses.add(
|
||||||
|
responses.GET,
|
||||||
|
DC_URL,
|
||||||
|
json={"entitlements": {"can_create": True}},
|
||||||
|
status=200,
|
||||||
|
)
|
||||||
|
|
||||||
|
backend = DeployCenterEntitlementsBackend(
|
||||||
|
base_url=DC_URL,
|
||||||
|
service_id="meet",
|
||||||
|
api_key="test-key",
|
||||||
|
)
|
||||||
|
|
||||||
|
# First call hits the API
|
||||||
|
result1 = backend.get_user_entitlements("sub-123", "user@example.com")
|
||||||
|
assert result1 == {"can_create": True}
|
||||||
|
assert len(responses.calls) == 1
|
||||||
|
|
||||||
|
# Second call should use cache
|
||||||
|
result2 = backend.get_user_entitlements("sub-123", "user@example.com")
|
||||||
|
assert result2 == {"can_create": True}
|
||||||
|
assert len(responses.calls) == 1 # No additional API call
|
||||||
|
|
||||||
|
|
||||||
|
@responses.activate
|
||||||
|
@override_settings(ENTITLEMENTS_CACHE_TIMEOUT=300)
|
||||||
|
def test_deploycenter_backend_force_refresh_bypasses_cache():
|
||||||
|
"""force_refresh=True should bypass cache and hit the API."""
|
||||||
|
responses.add(
|
||||||
|
responses.GET,
|
||||||
|
DC_URL,
|
||||||
|
json={"entitlements": {"can_create": True}},
|
||||||
|
status=200,
|
||||||
|
)
|
||||||
|
responses.add(
|
||||||
|
responses.GET,
|
||||||
|
DC_URL,
|
||||||
|
json={"entitlements": {"can_create": False}},
|
||||||
|
status=200,
|
||||||
|
)
|
||||||
|
|
||||||
|
backend = DeployCenterEntitlementsBackend(
|
||||||
|
base_url=DC_URL,
|
||||||
|
service_id="meet",
|
||||||
|
api_key="test-key",
|
||||||
|
)
|
||||||
|
|
||||||
|
result1 = backend.get_user_entitlements("sub-123", "user@example.com")
|
||||||
|
assert result1["can_create"] is True
|
||||||
|
|
||||||
|
result2 = backend.get_user_entitlements(
|
||||||
|
"sub-123", "user@example.com", force_refresh=True
|
||||||
|
)
|
||||||
|
assert result2["can_create"] is False
|
||||||
|
assert len(responses.calls) == 2
|
||||||
|
|
||||||
|
|
||||||
|
@responses.activate
|
||||||
|
@override_settings(ENTITLEMENTS_CACHE_TIMEOUT=300)
|
||||||
|
def test_deploycenter_backend_fallback_to_stale_cache():
|
||||||
|
"""When API fails, should return stale cached value if available."""
|
||||||
|
responses.add(
|
||||||
|
responses.GET,
|
||||||
|
DC_URL,
|
||||||
|
json={"entitlements": {"can_create": True}},
|
||||||
|
status=200,
|
||||||
|
)
|
||||||
|
|
||||||
|
backend = DeployCenterEntitlementsBackend(
|
||||||
|
base_url=DC_URL,
|
||||||
|
service_id="meet",
|
||||||
|
api_key="test-key",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Populate cache
|
||||||
|
backend.get_user_entitlements("sub-123", "user@example.com")
|
||||||
|
|
||||||
|
# Now API fails
|
||||||
|
responses.replace(
|
||||||
|
responses.GET,
|
||||||
|
DC_URL,
|
||||||
|
body=requests.ConnectionError("Connection error"),
|
||||||
|
)
|
||||||
|
|
||||||
|
# force_refresh to hit API, but should fall back to cache
|
||||||
|
result = backend.get_user_entitlements(
|
||||||
|
"sub-123", "user@example.com", force_refresh=True
|
||||||
|
)
|
||||||
|
assert result == {"can_create": True}
|
||||||
|
|
||||||
|
|
||||||
|
@responses.activate
|
||||||
|
def test_deploycenter_backend_raises_when_no_cache():
|
||||||
|
"""When API fails and no cache exists, should raise."""
|
||||||
|
responses.add(
|
||||||
|
responses.GET,
|
||||||
|
DC_URL,
|
||||||
|
body=requests.ConnectionError("Connection error"),
|
||||||
|
)
|
||||||
|
|
||||||
|
backend = DeployCenterEntitlementsBackend(
|
||||||
|
base_url=DC_URL,
|
||||||
|
service_id="meet",
|
||||||
|
api_key="test-key",
|
||||||
|
)
|
||||||
|
|
||||||
|
with pytest.raises(EntitlementsUnavailableError):
|
||||||
|
backend.get_user_entitlements("sub-123", "user@example.com")
|
||||||
|
|
||||||
|
|
||||||
|
@responses.activate
|
||||||
|
def test_deploycenter_backend_sends_oidc_claims():
|
||||||
|
"""DeployCenter should forward configured OIDC claims."""
|
||||||
|
responses.add(
|
||||||
|
responses.GET,
|
||||||
|
DC_URL,
|
||||||
|
json={"entitlements": {"can_create": True}},
|
||||||
|
status=200,
|
||||||
|
)
|
||||||
|
|
||||||
|
backend = DeployCenterEntitlementsBackend(
|
||||||
|
base_url=DC_URL,
|
||||||
|
service_id="meet",
|
||||||
|
api_key="test-key",
|
||||||
|
oidc_claims=["organization"],
|
||||||
|
)
|
||||||
|
|
||||||
|
backend.get_user_entitlements(
|
||||||
|
"sub-123",
|
||||||
|
"user@example.com",
|
||||||
|
user_info={"organization": "org-42", "other": "ignored"},
|
||||||
|
)
|
||||||
|
|
||||||
|
request = responses.calls[0].request
|
||||||
|
assert "organization=org-42" in request.url
|
||||||
|
assert "other" not in request.url
|
||||||
|
|
||||||
|
|
||||||
|
# -- Auth backend integration --
|
||||||
|
|
||||||
|
|
||||||
|
def test_auth_backend_warms_cache_on_login():
|
||||||
|
"""post_get_or_create_user should call get_user_entitlements with force_refresh."""
|
||||||
|
user = factories.UserFactory()
|
||||||
|
backend = OIDCAuthenticationBackend()
|
||||||
|
|
||||||
|
with mock.patch(
|
||||||
|
"core.authentication.backends.get_user_entitlements",
|
||||||
|
return_value={"can_create": True},
|
||||||
|
) as mock_ent:
|
||||||
|
backend.post_get_or_create_user(
|
||||||
|
user, {"email": user.email, "sub": "x"}, is_new_user=False
|
||||||
|
)
|
||||||
|
mock_ent.assert_called_once_with(
|
||||||
|
user_sub=user.sub,
|
||||||
|
user_email=user.email,
|
||||||
|
user_info={"email": user.email, "sub": "x"},
|
||||||
|
force_refresh=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_auth_backend_login_succeeds_when_access_denied():
|
||||||
|
"""Login should succeed even when can_create is False (gated in frontend)."""
|
||||||
|
user = factories.UserFactory()
|
||||||
|
backend = OIDCAuthenticationBackend()
|
||||||
|
|
||||||
|
with mock.patch(
|
||||||
|
"core.authentication.backends.get_user_entitlements",
|
||||||
|
return_value={"can_create": False},
|
||||||
|
):
|
||||||
|
# Should not raise — user logs in, frontend gates access
|
||||||
|
backend.post_get_or_create_user(
|
||||||
|
user, {"email": user.email}, is_new_user=False
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_auth_backend_login_succeeds_when_entitlements_unavailable():
|
||||||
|
"""Login should succeed when entitlements service is unavailable."""
|
||||||
|
user = factories.UserFactory()
|
||||||
|
backend = OIDCAuthenticationBackend()
|
||||||
|
|
||||||
|
with mock.patch(
|
||||||
|
"core.authentication.backends.get_user_entitlements",
|
||||||
|
side_effect=EntitlementsUnavailableError("unavailable"),
|
||||||
|
):
|
||||||
|
# Should not raise
|
||||||
|
backend.post_get_or_create_user(
|
||||||
|
user, {"email": user.email}, is_new_user=False
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# -- UserMeSerializer (can_create field) --
|
||||||
|
|
||||||
|
|
||||||
|
def test_user_me_serializer_includes_can_create_true():
|
||||||
|
"""UserMeSerializer should include can_create=True when entitled."""
|
||||||
|
user = factories.UserFactory()
|
||||||
|
with mock.patch(
|
||||||
|
"core.api.serializers.get_user_entitlements",
|
||||||
|
return_value={"can_create": True},
|
||||||
|
):
|
||||||
|
data = UserMeSerializer(user).data
|
||||||
|
assert data["can_create"] is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_user_me_serializer_includes_can_create_false():
|
||||||
|
"""UserMeSerializer should include can_create=False when not entitled."""
|
||||||
|
user = factories.UserFactory()
|
||||||
|
with mock.patch(
|
||||||
|
"core.api.serializers.get_user_entitlements",
|
||||||
|
return_value={"can_create": False},
|
||||||
|
):
|
||||||
|
data = UserMeSerializer(user).data
|
||||||
|
assert data["can_create"] is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_user_me_serializer_can_create_fail_closed():
|
||||||
|
"""UserMeSerializer should return can_create=False when entitlements unavailable."""
|
||||||
|
user = factories.UserFactory()
|
||||||
|
with mock.patch(
|
||||||
|
"core.api.serializers.get_user_entitlements",
|
||||||
|
side_effect=EntitlementsUnavailableError("unavailable"),
|
||||||
|
):
|
||||||
|
data = UserMeSerializer(user).data
|
||||||
|
assert data["can_create"] is False
|
||||||
|
|
||||||
|
|
||||||
|
# -- /users/me/ endpoint integration --
|
||||||
|
|
||||||
|
|
||||||
|
def test_api_users_me_includes_can_create():
|
||||||
|
"""GET /users/me/ should include can_create in the response."""
|
||||||
|
user = factories.UserFactory()
|
||||||
|
client = APIClient()
|
||||||
|
client.force_login(user)
|
||||||
|
|
||||||
|
response = client.get("/api/v1.0/users/me/")
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert "can_create" in response.json()
|
||||||
|
assert response.json()["can_create"] is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_api_users_me_can_create_false():
|
||||||
|
"""GET /users/me/ should return can_create=False when not entitled."""
|
||||||
|
user = factories.UserFactory()
|
||||||
|
client = APIClient()
|
||||||
|
client.force_login(user)
|
||||||
|
|
||||||
|
with mock.patch(
|
||||||
|
"core.api.serializers.get_user_entitlements",
|
||||||
|
return_value={"can_create": False},
|
||||||
|
):
|
||||||
|
response = client.get("/api/v1.0/users/me/")
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.json()["can_create"] is False
|
||||||
|
|
||||||
|
|
||||||
|
# -- Room creation entitlements enforcement --
|
||||||
|
|
||||||
|
|
||||||
|
def test_room_creation_blocked_when_not_entitled():
|
||||||
|
"""Room creation should return 403 when user has can_create=False."""
|
||||||
|
user = factories.UserFactory()
|
||||||
|
client = APIClient()
|
||||||
|
client.force_login(user)
|
||||||
|
|
||||||
|
with mock.patch(
|
||||||
|
"core.api.permissions.get_user_entitlements",
|
||||||
|
return_value={"can_create": False},
|
||||||
|
):
|
||||||
|
response = client.post(
|
||||||
|
"/api/v1.0/rooms/",
|
||||||
|
data={"name": "test-room"},
|
||||||
|
format="json",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == HTTP_403_FORBIDDEN
|
||||||
|
|
||||||
|
|
||||||
|
def test_room_creation_blocked_when_entitlements_unavailable():
|
||||||
|
"""Room creation should return 403 when entitlements service
|
||||||
|
is unavailable (fail-closed)."""
|
||||||
|
user = factories.UserFactory()
|
||||||
|
client = APIClient()
|
||||||
|
client.force_login(user)
|
||||||
|
|
||||||
|
with mock.patch(
|
||||||
|
"core.api.permissions.get_user_entitlements",
|
||||||
|
side_effect=EntitlementsUnavailableError("unavailable"),
|
||||||
|
):
|
||||||
|
response = client.post(
|
||||||
|
"/api/v1.0/rooms/",
|
||||||
|
data={"name": "test-room"},
|
||||||
|
format="json",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == HTTP_403_FORBIDDEN
|
||||||
|
|
||||||
|
|
||||||
|
def test_room_creation_allowed_when_entitled():
|
||||||
|
"""Room creation should succeed when user has can_create=True."""
|
||||||
|
user = factories.UserFactory()
|
||||||
|
client = APIClient()
|
||||||
|
client.force_login(user)
|
||||||
|
|
||||||
|
with mock.patch(
|
||||||
|
"core.api.permissions.get_user_entitlements",
|
||||||
|
return_value={"can_create": True},
|
||||||
|
):
|
||||||
|
response = client.post(
|
||||||
|
"/api/v1.0/rooms/",
|
||||||
|
data={"name": "test-room"},
|
||||||
|
format="json",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == HTTP_201_CREATED
|
||||||
|
|
||||||
|
|
||||||
|
# -- Non-create room actions are NOT gated by entitlements --
|
||||||
|
|
||||||
|
|
||||||
|
def test_room_retrieve_allowed_when_not_entitled():
|
||||||
|
"""Room retrieval should work even when user has can_create=False."""
|
||||||
|
user = factories.UserFactory()
|
||||||
|
room = factories.RoomFactory()
|
||||||
|
client = APIClient()
|
||||||
|
client.force_login(user)
|
||||||
|
|
||||||
|
with mock.patch(
|
||||||
|
"core.api.permissions.get_user_entitlements",
|
||||||
|
return_value={"can_create": False},
|
||||||
|
):
|
||||||
|
response = client.get(f"/api/v1.0/rooms/{room.id}/")
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
|
||||||
|
|
||||||
|
def test_room_list_allowed_when_not_entitled():
|
||||||
|
"""Room listing should work even when user has can_create=False."""
|
||||||
|
user = factories.UserFactory()
|
||||||
|
client = APIClient()
|
||||||
|
client.force_login(user)
|
||||||
|
|
||||||
|
with mock.patch(
|
||||||
|
"core.api.permissions.get_user_entitlements",
|
||||||
|
return_value={"can_create": False},
|
||||||
|
):
|
||||||
|
response = client.get("/api/v1.0/rooms/")
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
@@ -55,9 +55,8 @@ def test_api_rooms_list_requires_authentication():
|
|||||||
assert response.status_code == 401
|
assert response.status_code == 401
|
||||||
|
|
||||||
|
|
||||||
def test_api_rooms_list_inactive_user(settings):
|
def test_api_rooms_list_inactive_user():
|
||||||
"""List should return 401 if user is inactive."""
|
"""List should return 401 if user is inactive."""
|
||||||
settings.APPLICATION_JWT_SECRET_KEY = "devKey"
|
|
||||||
|
|
||||||
user1 = UserFactory(is_active=False)
|
user1 = UserFactory(is_active=False)
|
||||||
RoomFactory(users=[(user1, RoleChoices.OWNER)])
|
RoomFactory(users=[(user1, RoleChoices.OWNER)])
|
||||||
@@ -72,11 +71,9 @@ def test_api_rooms_list_inactive_user(settings):
|
|||||||
assert "user account is disabled" in str(response.data).lower()
|
assert "user account is disabled" in str(response.data).lower()
|
||||||
|
|
||||||
|
|
||||||
def test_api_rooms_list_with_valid_token(settings):
|
def test_api_rooms_list_with_valid_token():
|
||||||
"""Listing rooms with valid token should succeed."""
|
"""Listing rooms with valid token should succeed."""
|
||||||
|
|
||||||
settings.APPLICATION_JWT_SECRET_KEY = "devKey"
|
|
||||||
|
|
||||||
user = UserFactory()
|
user = UserFactory()
|
||||||
room = RoomFactory(users=[(user, RoleChoices.OWNER)])
|
room = RoomFactory(users=[(user, RoleChoices.OWNER)])
|
||||||
|
|
||||||
@@ -92,9 +89,8 @@ def test_api_rooms_list_with_valid_token(settings):
|
|||||||
assert response.data["results"][0]["id"] == str(room.id)
|
assert response.data["results"][0]["id"] == str(room.id)
|
||||||
|
|
||||||
|
|
||||||
def test_api_rooms_list_with_no_rooms(settings):
|
def test_api_rooms_list_with_no_rooms():
|
||||||
"""Listing rooms with a valid token returns an empty list when there are no rooms."""
|
"""Listing rooms with a valid token returns an empty list when there are no rooms."""
|
||||||
settings.APPLICATION_JWT_SECRET_KEY = "devKey"
|
|
||||||
|
|
||||||
user = UserFactory()
|
user = UserFactory()
|
||||||
|
|
||||||
@@ -112,7 +108,6 @@ def test_api_rooms_list_with_no_rooms(settings):
|
|||||||
|
|
||||||
def test_api_rooms_list_with_expired_token(settings):
|
def test_api_rooms_list_with_expired_token(settings):
|
||||||
"""Listing rooms with expired token should return 401."""
|
"""Listing rooms with expired token should return 401."""
|
||||||
settings.APPLICATION_JWT_SECRET_KEY = "devKey"
|
|
||||||
settings.APPLICATION_JWT_EXPIRATION_SECONDS = 0
|
settings.APPLICATION_JWT_EXPIRATION_SECONDS = 0
|
||||||
|
|
||||||
user = UserFactory()
|
user = UserFactory()
|
||||||
@@ -153,9 +148,8 @@ def test_api_rooms_list_with_invalid_rs_token(settings):
|
|||||||
assert response.status_code == 400
|
assert response.status_code == 400
|
||||||
|
|
||||||
|
|
||||||
def test_api_rooms_list_missing_scope(settings):
|
def test_api_rooms_list_missing_scope():
|
||||||
"""Listing rooms without required scope should return 403."""
|
"""Listing rooms without required scope should return 403."""
|
||||||
settings.APPLICATION_JWT_SECRET_KEY = "devKey"
|
|
||||||
|
|
||||||
user = UserFactory()
|
user = UserFactory()
|
||||||
|
|
||||||
@@ -173,9 +167,8 @@ def test_api_rooms_list_missing_scope(settings):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def test_api_rooms_list_no_scope(settings):
|
def test_api_rooms_list_no_scope():
|
||||||
"""Listing rooms without any scope should return 403."""
|
"""Listing rooms without any scope should return 403."""
|
||||||
settings.APPLICATION_JWT_SECRET_KEY = "devKey"
|
|
||||||
|
|
||||||
user = UserFactory()
|
user = UserFactory()
|
||||||
|
|
||||||
@@ -190,9 +183,8 @@ def test_api_rooms_list_no_scope(settings):
|
|||||||
assert "insufficient permissions." in str(response.data).lower()
|
assert "insufficient permissions." in str(response.data).lower()
|
||||||
|
|
||||||
|
|
||||||
def test_api_rooms_list_filters_by_user(settings):
|
def test_api_rooms_list_filters_by_user():
|
||||||
"""List should only return rooms accessible to the authenticated user."""
|
"""List should only return rooms accessible to the authenticated user."""
|
||||||
settings.APPLICATION_JWT_SECRET_KEY = "devKey"
|
|
||||||
|
|
||||||
user1 = UserFactory()
|
user1 = UserFactory()
|
||||||
user2 = UserFactory()
|
user2 = UserFactory()
|
||||||
@@ -217,11 +209,9 @@ def test_api_rooms_list_filters_by_user(settings):
|
|||||||
assert str(room2.id) not in returned_ids
|
assert str(room2.id) not in returned_ids
|
||||||
|
|
||||||
|
|
||||||
def test_api_rooms_retrieve_requires_authentication(settings):
|
def test_api_rooms_retrieve_requires_authentication():
|
||||||
"""Retrieving rooms without authentication should return 401."""
|
"""Retrieving rooms without authentication should return 401."""
|
||||||
|
|
||||||
settings.APPLICATION_JWT_SECRET_KEY = "devKey"
|
|
||||||
|
|
||||||
user1 = UserFactory()
|
user1 = UserFactory()
|
||||||
room1 = RoomFactory(users=[(user1, RoleChoices.OWNER)])
|
room1 = RoomFactory(users=[(user1, RoleChoices.OWNER)])
|
||||||
|
|
||||||
@@ -231,9 +221,8 @@ def test_api_rooms_retrieve_requires_authentication(settings):
|
|||||||
assert response.status_code == 401
|
assert response.status_code == 401
|
||||||
|
|
||||||
|
|
||||||
def test_api_rooms_retrieve_inactive_user(settings):
|
def test_api_rooms_retrieve_inactive_user():
|
||||||
"""Retrieve should return 401 if user is inactive."""
|
"""Retrieve should return 401 if user is inactive."""
|
||||||
settings.APPLICATION_JWT_SECRET_KEY = "devKey"
|
|
||||||
|
|
||||||
user1 = UserFactory(is_active=False)
|
user1 = UserFactory(is_active=False)
|
||||||
room1 = RoomFactory(users=[(user1, RoleChoices.OWNER)])
|
room1 = RoomFactory(users=[(user1, RoleChoices.OWNER)])
|
||||||
@@ -250,7 +239,6 @@ def test_api_rooms_retrieve_inactive_user(settings):
|
|||||||
|
|
||||||
def test_api_rooms_retrieve_with_expired_token(settings):
|
def test_api_rooms_retrieve_with_expired_token(settings):
|
||||||
"""Retrieving rooms with expired token should return 401."""
|
"""Retrieving rooms with expired token should return 401."""
|
||||||
settings.APPLICATION_JWT_SECRET_KEY = "devKey"
|
|
||||||
settings.APPLICATION_JWT_EXPIRATION_SECONDS = 0
|
settings.APPLICATION_JWT_EXPIRATION_SECONDS = 0
|
||||||
|
|
||||||
user = UserFactory()
|
user = UserFactory()
|
||||||
@@ -295,9 +283,8 @@ def test_api_rooms_retrieve_with_invalid_rs_token(settings):
|
|||||||
assert response.status_code == 400
|
assert response.status_code == 400
|
||||||
|
|
||||||
|
|
||||||
def test_api_rooms_retrieve_requires_scope(settings):
|
def test_api_rooms_retrieve_requires_scope():
|
||||||
"""Retrieving a room requires ROOMS_RETRIEVE scope."""
|
"""Retrieving a room requires ROOMS_RETRIEVE scope."""
|
||||||
settings.APPLICATION_JWT_SECRET_KEY = "devKey"
|
|
||||||
|
|
||||||
user = UserFactory()
|
user = UserFactory()
|
||||||
room = RoomFactory(users=[(user, RoleChoices.OWNER)])
|
room = RoomFactory(users=[(user, RoleChoices.OWNER)])
|
||||||
@@ -315,9 +302,8 @@ def test_api_rooms_retrieve_requires_scope(settings):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def test_api_rooms_retrieve_no_scope(settings):
|
def test_api_rooms_retrieve_no_scope():
|
||||||
"""Retrieving rooms without any scope should return 403."""
|
"""Retrieving rooms without any scope should return 403."""
|
||||||
settings.APPLICATION_JWT_SECRET_KEY = "devKey"
|
|
||||||
|
|
||||||
user = UserFactory()
|
user = UserFactory()
|
||||||
|
|
||||||
@@ -335,7 +321,6 @@ def test_api_rooms_retrieve_no_scope(settings):
|
|||||||
|
|
||||||
def test_api_rooms_retrieve_success(settings):
|
def test_api_rooms_retrieve_success(settings):
|
||||||
"""Retrieving a room with correct scope should succeed."""
|
"""Retrieving a room with correct scope should succeed."""
|
||||||
settings.APPLICATION_JWT_SECRET_KEY = "devKey"
|
|
||||||
settings.APPLICATION_BASE_URL = "http://your-application.com"
|
settings.APPLICATION_BASE_URL = "http://your-application.com"
|
||||||
settings.ROOM_TELEPHONY_ENABLED = True
|
settings.ROOM_TELEPHONY_ENABLED = True
|
||||||
settings.ROOM_TELEPHONY_PHONE_NUMBER = "+1-555-0100"
|
settings.ROOM_TELEPHONY_PHONE_NUMBER = "+1-555-0100"
|
||||||
@@ -367,9 +352,8 @@ def test_api_rooms_retrieve_success(settings):
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def test_api_rooms_retrieve_success_by_user(settings):
|
def test_api_rooms_retrieve_success_by_user():
|
||||||
"""Retrieve should only return rooms accessible to the authenticated user."""
|
"""Retrieve should only return rooms accessible to the authenticated user."""
|
||||||
settings.APPLICATION_JWT_SECRET_KEY = "devKey"
|
|
||||||
|
|
||||||
user1 = UserFactory()
|
user1 = UserFactory()
|
||||||
user2 = UserFactory()
|
user2 = UserFactory()
|
||||||
@@ -408,9 +392,8 @@ def test_api_rooms_retrieve_success_by_user(settings):
|
|||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
|
|
||||||
|
|
||||||
def test_api_rooms_retrieve_not_found(settings):
|
def test_api_rooms_retrieve_not_found():
|
||||||
"""Retrieving a non-existing room with correct scope should return a 404."""
|
"""Retrieving a non-existing room with correct scope should return a 404."""
|
||||||
settings.APPLICATION_JWT_SECRET_KEY = "devKey"
|
|
||||||
|
|
||||||
user = UserFactory()
|
user = UserFactory()
|
||||||
token = generate_test_token(user, [ApplicationScope.ROOMS_RETRIEVE])
|
token = generate_test_token(user, [ApplicationScope.ROOMS_RETRIEVE])
|
||||||
@@ -423,11 +406,9 @@ def test_api_rooms_retrieve_not_found(settings):
|
|||||||
assert "no room matches the given query." in str(response.data).lower()
|
assert "no room matches the given query." in str(response.data).lower()
|
||||||
|
|
||||||
|
|
||||||
def test_api_rooms_create_requires_authentication(settings):
|
def test_api_rooms_create_requires_authentication():
|
||||||
"""Creating rooms without authentication should return 401."""
|
"""Creating rooms without authentication should return 401."""
|
||||||
|
|
||||||
settings.APPLICATION_JWT_SECRET_KEY = "devKey"
|
|
||||||
|
|
||||||
client = APIClient()
|
client = APIClient()
|
||||||
response = client.post("/external-api/v1.0/rooms/")
|
response = client.post("/external-api/v1.0/rooms/")
|
||||||
|
|
||||||
@@ -436,7 +417,6 @@ def test_api_rooms_create_requires_authentication(settings):
|
|||||||
|
|
||||||
def test_api_rooms_create_with_expired_token(settings):
|
def test_api_rooms_create_with_expired_token(settings):
|
||||||
"""Creating rooms with expired token should return 401."""
|
"""Creating rooms with expired token should return 401."""
|
||||||
settings.APPLICATION_JWT_SECRET_KEY = "devKey"
|
|
||||||
settings.APPLICATION_JWT_EXPIRATION_SECONDS = 0
|
settings.APPLICATION_JWT_EXPIRATION_SECONDS = 0
|
||||||
|
|
||||||
user = UserFactory()
|
user = UserFactory()
|
||||||
@@ -477,9 +457,8 @@ def test_api_rooms_create_with_invalid_rs_token(settings):
|
|||||||
assert response.status_code == 400
|
assert response.status_code == 400
|
||||||
|
|
||||||
|
|
||||||
def test_api_rooms_create_inactive_user(settings):
|
def test_api_rooms_create_inactive_user():
|
||||||
"""Create should return 401 if user is inactive."""
|
"""Create should return 401 if user is inactive."""
|
||||||
settings.APPLICATION_JWT_SECRET_KEY = "devKey"
|
|
||||||
|
|
||||||
user1 = UserFactory(is_active=False)
|
user1 = UserFactory(is_active=False)
|
||||||
|
|
||||||
@@ -493,9 +472,8 @@ def test_api_rooms_create_inactive_user(settings):
|
|||||||
assert "user account is disabled" in str(response.data).lower()
|
assert "user account is disabled" in str(response.data).lower()
|
||||||
|
|
||||||
|
|
||||||
def test_api_rooms_create_requires_scope(settings):
|
def test_api_rooms_create_requires_scope():
|
||||||
"""Creating a room requires ROOMS_CREATE scope."""
|
"""Creating a room requires ROOMS_CREATE scope."""
|
||||||
settings.APPLICATION_JWT_SECRET_KEY = "devKey"
|
|
||||||
user = UserFactory()
|
user = UserFactory()
|
||||||
|
|
||||||
# Token without ROOMS_CREATE scope
|
# Token without ROOMS_CREATE scope
|
||||||
@@ -512,9 +490,8 @@ def test_api_rooms_create_requires_scope(settings):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def test_api_rooms_create_no_scope(settings):
|
def test_api_rooms_create_no_scope():
|
||||||
"""Creating rooms without any scope should return 403."""
|
"""Creating rooms without any scope should return 403."""
|
||||||
settings.APPLICATION_JWT_SECRET_KEY = "devKey"
|
|
||||||
|
|
||||||
user = UserFactory()
|
user = UserFactory()
|
||||||
|
|
||||||
@@ -529,9 +506,8 @@ def test_api_rooms_create_no_scope(settings):
|
|||||||
assert "insufficient permissions." in str(response.data).lower()
|
assert "insufficient permissions." in str(response.data).lower()
|
||||||
|
|
||||||
|
|
||||||
def test_api_rooms_create_success(settings):
|
def test_api_rooms_create_success():
|
||||||
"""Creating a room with correct scope should succeed."""
|
"""Creating a room with correct scope should succeed."""
|
||||||
settings.APPLICATION_JWT_SECRET_KEY = "devKey"
|
|
||||||
|
|
||||||
user = UserFactory()
|
user = UserFactory()
|
||||||
|
|
||||||
@@ -555,9 +531,8 @@ def test_api_rooms_create_success(settings):
|
|||||||
assert room.access_level == "trusted"
|
assert room.access_level == "trusted"
|
||||||
|
|
||||||
|
|
||||||
def test_api_rooms_create_readonly_enforcement(settings):
|
def test_api_rooms_create_readonly_enforcement():
|
||||||
"""Creating a room succeeds and any provided read-only fields are ignored."""
|
"""Creating a room succeeds and any provided read-only fields are ignored."""
|
||||||
settings.APPLICATION_JWT_SECRET_KEY = "devKey"
|
|
||||||
|
|
||||||
user = UserFactory()
|
user = UserFactory()
|
||||||
|
|
||||||
@@ -590,9 +565,8 @@ def test_api_rooms_create_readonly_enforcement(settings):
|
|||||||
assert room.access_level == "trusted"
|
assert room.access_level == "trusted"
|
||||||
|
|
||||||
|
|
||||||
def test_api_rooms_unknown_actions(settings):
|
def test_api_rooms_unknown_actions():
|
||||||
"""Updating or deleting a room are not supported yet."""
|
"""Updating or deleting a room are not supported yet."""
|
||||||
settings.APPLICATION_JWT_SECRET_KEY = "devKey"
|
|
||||||
|
|
||||||
user = UserFactory()
|
user = UserFactory()
|
||||||
room = RoomFactory(users=[(user, RoleChoices.OWNER)])
|
room = RoomFactory(users=[(user, RoleChoices.OWNER)])
|
||||||
@@ -623,7 +597,6 @@ def test_api_rooms_unknown_actions(settings):
|
|||||||
|
|
||||||
def test_api_rooms_response_no_url(settings):
|
def test_api_rooms_response_no_url(settings):
|
||||||
"""Response should not include url field when APPLICATION_BASE_URL is None."""
|
"""Response should not include url field when APPLICATION_BASE_URL is None."""
|
||||||
settings.APPLICATION_JWT_SECRET_KEY = "devKey"
|
|
||||||
settings.APPLICATION_BASE_URL = None
|
settings.APPLICATION_BASE_URL = None
|
||||||
|
|
||||||
user = UserFactory()
|
user = UserFactory()
|
||||||
@@ -642,7 +615,6 @@ def test_api_rooms_response_no_url(settings):
|
|||||||
|
|
||||||
def test_api_rooms_response_no_telephony(settings):
|
def test_api_rooms_response_no_telephony(settings):
|
||||||
"""Response should not include telephony field when ROOM_TELEPHONY_ENABLED is False."""
|
"""Response should not include telephony field when ROOM_TELEPHONY_ENABLED is False."""
|
||||||
settings.APPLICATION_JWT_SECRET_KEY = "devKey"
|
|
||||||
settings.ROOM_TELEPHONY_ENABLED = False
|
settings.ROOM_TELEPHONY_ENABLED = False
|
||||||
|
|
||||||
user = UserFactory()
|
user = UserFactory()
|
||||||
@@ -661,7 +633,6 @@ def test_api_rooms_response_no_telephony(settings):
|
|||||||
|
|
||||||
def test_api_rooms_token_scope_case_insensitive(settings):
|
def test_api_rooms_token_scope_case_insensitive(settings):
|
||||||
"""Token's scope should be case-insensitive."""
|
"""Token's scope should be case-insensitive."""
|
||||||
settings.APPLICATION_JWT_SECRET_KEY = "devKey"
|
|
||||||
user = UserFactory()
|
user = UserFactory()
|
||||||
application = ApplicationFactory()
|
application = ApplicationFactory()
|
||||||
|
|
||||||
@@ -693,7 +664,6 @@ def test_api_rooms_token_scope_case_insensitive(settings):
|
|||||||
|
|
||||||
def test_api_rooms_token_without_delegated_flag(settings):
|
def test_api_rooms_token_without_delegated_flag(settings):
|
||||||
"""Token without delegated flag should be rejected."""
|
"""Token without delegated flag should be rejected."""
|
||||||
settings.APPLICATION_JWT_SECRET_KEY = "devKey"
|
|
||||||
user = UserFactory()
|
user = UserFactory()
|
||||||
application = ApplicationFactory()
|
application = ApplicationFactory()
|
||||||
|
|
||||||
@@ -726,7 +696,6 @@ def test_api_rooms_token_without_delegated_flag(settings):
|
|||||||
@mock.patch.object(ResourceServerAuthentication, "authenticate", return_value=None)
|
@mock.patch.object(ResourceServerAuthentication, "authenticate", return_value=None)
|
||||||
def test_api_rooms_token_invalid_signature(mock_rs_authenticate, settings):
|
def test_api_rooms_token_invalid_signature(mock_rs_authenticate, settings):
|
||||||
"""Token signed with an invalid key should defer to the next authentication."""
|
"""Token signed with an invalid key should defer to the next authentication."""
|
||||||
settings.APPLICATION_JWT_SECRET_KEY = "devKey"
|
|
||||||
user = UserFactory()
|
user = UserFactory()
|
||||||
application = ApplicationFactory()
|
application = ApplicationFactory()
|
||||||
|
|
||||||
@@ -759,7 +728,6 @@ def test_api_rooms_token_invalid_signature(mock_rs_authenticate, settings):
|
|||||||
@mock.patch.object(ResourceServerAuthentication, "authenticate", return_value=None)
|
@mock.patch.object(ResourceServerAuthentication, "authenticate", return_value=None)
|
||||||
def test_api_rooms_token_invalid_alg(mock_rs_authenticate, settings):
|
def test_api_rooms_token_invalid_alg(mock_rs_authenticate, settings):
|
||||||
"""Token signed with an invalid alg should defer to the next authentication."""
|
"""Token signed with an invalid alg should defer to the next authentication."""
|
||||||
settings.APPLICATION_JWT_SECRET_KEY = "devKey"
|
|
||||||
settings.APPLICATION_JWT_ALG = "RS256"
|
settings.APPLICATION_JWT_ALG = "RS256"
|
||||||
user = UserFactory()
|
user = UserFactory()
|
||||||
|
|
||||||
@@ -791,7 +759,6 @@ def test_api_rooms_token_invalid_alg(mock_rs_authenticate, settings):
|
|||||||
|
|
||||||
def test_api_rooms_token_missing_client_id(settings):
|
def test_api_rooms_token_missing_client_id(settings):
|
||||||
"""Token without client_id should be rejected."""
|
"""Token without client_id should be rejected."""
|
||||||
settings.APPLICATION_JWT_SECRET_KEY = "devKey"
|
|
||||||
user = UserFactory()
|
user = UserFactory()
|
||||||
|
|
||||||
now = datetime.now(timezone.utc)
|
now = datetime.now(timezone.utc)
|
||||||
@@ -821,7 +788,6 @@ def test_api_rooms_token_missing_client_id(settings):
|
|||||||
|
|
||||||
def test_api_rooms_token_missing_user_id(settings):
|
def test_api_rooms_token_missing_user_id(settings):
|
||||||
"""Token without user_id should be rejected."""
|
"""Token without user_id should be rejected."""
|
||||||
settings.APPLICATION_JWT_SECRET_KEY = "devKey"
|
|
||||||
application = ApplicationFactory()
|
application = ApplicationFactory()
|
||||||
|
|
||||||
now = datetime.now(timezone.utc)
|
now = datetime.now(timezone.utc)
|
||||||
@@ -851,7 +817,6 @@ def test_api_rooms_token_missing_user_id(settings):
|
|||||||
|
|
||||||
def test_api_rooms_token_invalid_audience(settings):
|
def test_api_rooms_token_invalid_audience(settings):
|
||||||
"""Token with an invalid audience should be rejected."""
|
"""Token with an invalid audience should be rejected."""
|
||||||
settings.APPLICATION_JWT_SECRET_KEY = "devKey"
|
|
||||||
user = UserFactory()
|
user = UserFactory()
|
||||||
application = ApplicationFactory()
|
application = ApplicationFactory()
|
||||||
|
|
||||||
@@ -882,7 +847,6 @@ def test_api_rooms_token_invalid_audience(settings):
|
|||||||
|
|
||||||
def test_api_rooms_token_unknown_user(settings):
|
def test_api_rooms_token_unknown_user(settings):
|
||||||
"""Token for unknown user should be rejected."""
|
"""Token for unknown user should be rejected."""
|
||||||
settings.APPLICATION_JWT_SECRET_KEY = "devKey"
|
|
||||||
application = ApplicationFactory()
|
application = ApplicationFactory()
|
||||||
|
|
||||||
now = datetime.now(timezone.utc)
|
now = datetime.now(timezone.utc)
|
||||||
@@ -912,7 +876,6 @@ def test_api_rooms_token_unknown_user(settings):
|
|||||||
|
|
||||||
def test_api_rooms_token_unknown_application(settings):
|
def test_api_rooms_token_unknown_application(settings):
|
||||||
"""Token for unknown application should be rejected."""
|
"""Token for unknown application should be rejected."""
|
||||||
settings.APPLICATION_JWT_SECRET_KEY = "devKey"
|
|
||||||
|
|
||||||
now = datetime.now(timezone.utc)
|
now = datetime.now(timezone.utc)
|
||||||
payload = {
|
payload = {
|
||||||
@@ -941,7 +904,6 @@ def test_api_rooms_token_unknown_application(settings):
|
|||||||
|
|
||||||
def test_api_rooms_token_inactive_application(settings):
|
def test_api_rooms_token_inactive_application(settings):
|
||||||
"""Token for inactive application should be rejected."""
|
"""Token for inactive application should be rejected."""
|
||||||
settings.APPLICATION_JWT_SECRET_KEY = "devKey"
|
|
||||||
application = ApplicationFactory(active=False)
|
application = ApplicationFactory(active=False)
|
||||||
|
|
||||||
now = datetime.now(timezone.utc)
|
now = datetime.now(timezone.utc)
|
||||||
|
|||||||
@@ -21,7 +21,6 @@ pytestmark = pytest.mark.django_db
|
|||||||
|
|
||||||
def test_api_applications_generate_token_success(settings):
|
def test_api_applications_generate_token_success(settings):
|
||||||
"""Valid credentials should return a JWT token."""
|
"""Valid credentials should return a JWT token."""
|
||||||
settings.APPLICATION_JWT_SECRET_KEY = "devKey"
|
|
||||||
UserFactory(email="User.Family@example.com")
|
UserFactory(email="User.Family@example.com")
|
||||||
application = ApplicationFactory(
|
application = ApplicationFactory(
|
||||||
active=True,
|
active=True,
|
||||||
@@ -173,9 +172,8 @@ def test_api_applications_generate_token_domain_not_authorized():
|
|||||||
assert "not authorized for this email domain" in str(response.data)
|
assert "not authorized for this email domain" in str(response.data)
|
||||||
|
|
||||||
|
|
||||||
def test_api_applications_generate_token_domain_authorized(settings):
|
def test_api_applications_generate_token_domain_authorized():
|
||||||
"""Application with domain authorization should succeed."""
|
"""Application with domain authorization should succeed."""
|
||||||
settings.APPLICATION_JWT_SECRET_KEY = "devKey"
|
|
||||||
user = UserFactory(email="user@allowed.com")
|
user = UserFactory(email="user@allowed.com")
|
||||||
application = ApplicationFactory(
|
application = ApplicationFactory(
|
||||||
active=True,
|
active=True,
|
||||||
@@ -230,7 +228,6 @@ def test_api_applications_generate_token_user_not_found():
|
|||||||
@freeze_time("2023-01-15 12:00:00")
|
@freeze_time("2023-01-15 12:00:00")
|
||||||
def test_api_applications_token_payload_structure(settings):
|
def test_api_applications_token_payload_structure(settings):
|
||||||
"""Generated token should have correct payload structure."""
|
"""Generated token should have correct payload structure."""
|
||||||
settings.APPLICATION_JWT_SECRET_KEY = "devKey"
|
|
||||||
user = UserFactory(email="user@example.com")
|
user = UserFactory(email="user@example.com")
|
||||||
|
|
||||||
application = ApplicationFactory(
|
application = ApplicationFactory(
|
||||||
@@ -280,7 +277,6 @@ def test_api_applications_token_payload_structure(settings):
|
|||||||
def test_api_applications_token_new_user(settings):
|
def test_api_applications_token_new_user(settings):
|
||||||
"""Should create a new pending user when creation is allowed and user doesn't exist."""
|
"""Should create a new pending user when creation is allowed and user doesn't exist."""
|
||||||
|
|
||||||
settings.APPLICATION_JWT_SECRET_KEY = "devKey"
|
|
||||||
settings.APPLICATION_ALLOW_USER_CREATION = True
|
settings.APPLICATION_ALLOW_USER_CREATION = True
|
||||||
settings.OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION = True
|
settings.OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION = True
|
||||||
settings.OIDC_USER_SUB_FIELD_IMMUTABLE = False
|
settings.OIDC_USER_SUB_FIELD_IMMUTABLE = False
|
||||||
@@ -337,7 +333,6 @@ def test_api_applications_token_new_user(settings):
|
|||||||
def test_api_applications_token_existing_user(settings):
|
def test_api_applications_token_existing_user(settings):
|
||||||
"""Application should not create a new user when user exist."""
|
"""Application should not create a new user when user exist."""
|
||||||
|
|
||||||
settings.APPLICATION_JWT_SECRET_KEY = "devKey"
|
|
||||||
user = UserFactory(email="user@example.com")
|
user = UserFactory(email="user@example.com")
|
||||||
|
|
||||||
settings.APPLICATION_ALLOW_USER_CREATION = True
|
settings.APPLICATION_ALLOW_USER_CREATION = True
|
||||||
|
|||||||
@@ -596,6 +596,12 @@ class Base(Configuration):
|
|||||||
ALLOW_UNREGISTERED_ROOMS = values.BooleanValue(
|
ALLOW_UNREGISTERED_ROOMS = values.BooleanValue(
|
||||||
True, environ_name="ALLOW_UNREGISTERED_ROOMS", environ_prefix=None
|
True, environ_name="ALLOW_UNREGISTERED_ROOMS", environ_prefix=None
|
||||||
)
|
)
|
||||||
|
# if provided, treat as suspicious (possible privilege escalation attempt).
|
||||||
|
PARTICIPANT_FORBIDDEN_PERMISSION_FIELDS = values.ListValue(
|
||||||
|
["hidden", "recorder", "agent"],
|
||||||
|
environ_name="PARTICIPANT_FORBIDDEN_PERMISSION_FIELDS",
|
||||||
|
environ_prefix=None,
|
||||||
|
)
|
||||||
|
|
||||||
# Recording settings
|
# Recording settings
|
||||||
RECORDING_ENABLE = values.BooleanValue(
|
RECORDING_ENABLE = values.BooleanValue(
|
||||||
@@ -667,7 +673,7 @@ class Base(Configuration):
|
|||||||
[],
|
[],
|
||||||
environ_name="BREVO_API_CONTACT_LIST_IDS",
|
environ_name="BREVO_API_CONTACT_LIST_IDS",
|
||||||
environ_prefix=None,
|
environ_prefix=None,
|
||||||
converter=lambda x: int(x), # pylint: disable=unnecessary-lambda
|
converter=int,
|
||||||
)
|
)
|
||||||
BREVO_API_CONTACT_ATTRIBUTES = values.DictValue({"VISIO_USER": True})
|
BREVO_API_CONTACT_ATTRIBUTES = values.DictValue({"VISIO_USER": True})
|
||||||
BREVO_API_TIMEOUT = values.PositiveIntegerValue(
|
BREVO_API_TIMEOUT = values.PositiveIntegerValue(
|
||||||
@@ -700,6 +706,23 @@ class Base(Configuration):
|
|||||||
environ_prefix=None,
|
environ_prefix=None,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Entitlements
|
||||||
|
ENTITLEMENTS_BACKEND = values.Value(
|
||||||
|
"core.entitlements.backends.local.LocalEntitlementsBackend",
|
||||||
|
environ_name="ENTITLEMENTS_BACKEND",
|
||||||
|
environ_prefix=None,
|
||||||
|
)
|
||||||
|
ENTITLEMENTS_BACKEND_PARAMETERS = values.DictValue(
|
||||||
|
{},
|
||||||
|
environ_name="ENTITLEMENTS_BACKEND_PARAMETERS",
|
||||||
|
environ_prefix=None,
|
||||||
|
)
|
||||||
|
ENTITLEMENTS_CACHE_TIMEOUT = values.PositiveIntegerValue(
|
||||||
|
300, # 5 minutes
|
||||||
|
environ_name="ENTITLEMENTS_CACHE_TIMEOUT",
|
||||||
|
environ_prefix=None,
|
||||||
|
)
|
||||||
|
|
||||||
# Calendar integrations
|
# Calendar integrations
|
||||||
ROOM_CREATION_CALLBACK_CACHE_TIMEOUT = values.PositiveIntegerValue(
|
ROOM_CREATION_CALLBACK_CACHE_TIMEOUT = values.PositiveIntegerValue(
|
||||||
600, # 10 minutes
|
600, # 10 minutes
|
||||||
@@ -917,6 +940,9 @@ class Test(Base):
|
|||||||
USE_SWAGGER = True
|
USE_SWAGGER = True
|
||||||
EXTERNAL_API_ENABLED = True
|
EXTERNAL_API_ENABLED = True
|
||||||
|
|
||||||
|
APPLICATION_JWT_SECRET_KEY = "devKey" # noqa:S105
|
||||||
|
APPLICATION_JWT_AUDIENCE = "Test inc."
|
||||||
|
|
||||||
CELERY_TASK_ALWAYS_EAGER = values.BooleanValue(True)
|
CELERY_TASK_ALWAYS_EAGER = values.BooleanValue(True)
|
||||||
|
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
|
|||||||
+26
-24
@@ -7,7 +7,7 @@ build-backend = "setuptools.build_meta"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "meet"
|
name = "meet"
|
||||||
version = "1.7.0"
|
version = "1.9.0"
|
||||||
authors = [{ "name" = "DINUM", "email" = "dev@mail.numerique.gouv.fr" }]
|
authors = [{ "name" = "DINUM", "email" = "dev@mail.numerique.gouv.fr" }]
|
||||||
classifiers = [
|
classifiers = [
|
||||||
"Development Status :: 5 - Production/Stable",
|
"Development Status :: 5 - Production/Stable",
|
||||||
@@ -17,46 +17,48 @@ classifiers = [
|
|||||||
"License :: OSI Approved :: MIT License",
|
"License :: OSI Approved :: MIT License",
|
||||||
"Natural Language :: English",
|
"Natural Language :: English",
|
||||||
"Programming Language :: Python :: 3",
|
"Programming Language :: Python :: 3",
|
||||||
"Programming Language :: Python :: 3.10",
|
"Programming Language :: Python :: 3.13",
|
||||||
]
|
]
|
||||||
description = "A simple video and phone conferencing tool, powered by LiveKit"
|
description = "A simple video and phone conferencing tool, powered by LiveKit"
|
||||||
keywords = ["Django", "Contacts", "Templates", "RBAC"]
|
keywords = ["Django", "Contacts", "Templates", "RBAC"]
|
||||||
license = { file = "LICENSE" }
|
license = { file = "LICENSE" }
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
requires-python = ">=3.10"
|
requires-python = ">=3.13"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"boto3==1.40.69",
|
"boto3==1.42.49",
|
||||||
"Brotli==1.2.0",
|
"Brotli==1.2.0",
|
||||||
"brevo-python==1.2.0",
|
"brevo-python==1.2.0",
|
||||||
"celery[redis]==5.5.3",
|
"celery[redis]==5.6.2",
|
||||||
"dj-database-url==3.1.0",
|
"dj-database-url==3.1.0",
|
||||||
"django-configurations==2.5.1",
|
"django-configurations==2.5.1",
|
||||||
"django-cors-headers==4.9.0",
|
"django-cors-headers==4.9.0",
|
||||||
"django-countries==8.0.0",
|
"django-countries==8.2.0",
|
||||||
"django-lasuite[all]==0.0.19",
|
"django-lasuite[all]==0.0.24",
|
||||||
"django-parler==2.3",
|
"django-parler==2.3",
|
||||||
"redis==5.2.1",
|
"redis==5.2.1",
|
||||||
"django-redis==6.0.0",
|
"django-redis==6.0.0",
|
||||||
"django-storages[s3]==1.14.6",
|
"django-storages[s3]==1.14.6",
|
||||||
"django-timezone-field>=5.1",
|
"django-timezone-field>=5.1",
|
||||||
|
"django-pydantic-field==0.5.4",
|
||||||
"django==5.2.11",
|
"django==5.2.11",
|
||||||
"djangorestframework==3.16.1",
|
"djangorestframework==3.16.1",
|
||||||
"drf_spectacular==0.29.0",
|
"drf_spectacular==0.29.0",
|
||||||
"dockerflow==2024.4.2",
|
"dockerflow==2026.1.26",
|
||||||
"easy_thumbnails==2.10.1",
|
"easy_thumbnails==2.10.1",
|
||||||
"factory_boy==3.3.3",
|
"factory_boy==3.3.3",
|
||||||
"gunicorn==23.0.0",
|
"gunicorn==25.1.0",
|
||||||
"jsonschema==4.25.1",
|
"jsonschema==4.26.0",
|
||||||
"markdown==3.10",
|
"markdown==3.10.2",
|
||||||
"nested-multipart-parser==1.6.0",
|
"nested-multipart-parser==1.6.0",
|
||||||
"psycopg[binary]==3.2.12",
|
"psycopg[binary]==3.3.2",
|
||||||
"PyJWT==2.10.1",
|
"pydantic==2.12.4",
|
||||||
|
"PyJWT==2.11.0",
|
||||||
"python-frontmatter==1.1.0",
|
"python-frontmatter==1.1.0",
|
||||||
"requests==2.32.5",
|
"requests==2.32.5",
|
||||||
"sentry-sdk==2.43.0",
|
"sentry-sdk==2.53.0",
|
||||||
"whitenoise==6.11.0",
|
"whitenoise==6.11.0",
|
||||||
"mozilla-django-oidc==4.0.1",
|
"mozilla-django-oidc==5.0.2",
|
||||||
"livekit-api==1.0.7",
|
"livekit-api==1.1.0",
|
||||||
"aiohttp==3.13.3",
|
"aiohttp==3.13.3",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -69,21 +71,21 @@ dependencies = [
|
|||||||
[project.optional-dependencies]
|
[project.optional-dependencies]
|
||||||
dev = [
|
dev = [
|
||||||
"django-extensions==4.1",
|
"django-extensions==4.1",
|
||||||
"drf-spectacular-sidecar==2025.10.1",
|
"drf-spectacular-sidecar==2026.1.1",
|
||||||
"freezegun==1.5.5",
|
"freezegun==1.5.5",
|
||||||
"ipdb==0.13.13",
|
"ipdb==0.13.13",
|
||||||
"ipython==9.7.0",
|
"ipython==9.10.0",
|
||||||
"pyfakefs==5.10.2",
|
"pyfakefs==6.1.1",
|
||||||
"pylint-django==2.6.1",
|
"pylint-django==2.7.0",
|
||||||
"pylint<4.0.0",
|
"pylint<4.0.0",
|
||||||
"pytest-cov==7.0.0",
|
"pytest-cov==7.0.0",
|
||||||
"pytest-django==4.11.1",
|
"pytest-django==4.12.0",
|
||||||
"pytest==9.0.0",
|
"pytest==9.0.2",
|
||||||
"pytest-icdiff==0.9",
|
"pytest-icdiff==0.9",
|
||||||
"pytest-xdist==3.8.0",
|
"pytest-xdist==3.8.0",
|
||||||
"responses==0.25.8",
|
"responses==0.25.8",
|
||||||
"ruff==0.14.4",
|
"ruff==0.15.1",
|
||||||
"types-requests==2.32.4.20250913",
|
"types-requests==2.32.4.20260107",
|
||||||
]
|
]
|
||||||
|
|
||||||
[tool.setuptools]
|
[tool.setuptools]
|
||||||
|
|||||||
@@ -43,7 +43,8 @@ RUN apk update && apk upgrade libssl3 \
|
|||||||
libxml2>=2.12.7-r2 \
|
libxml2>=2.12.7-r2 \
|
||||||
libxslt>=1.1.39-r2 \
|
libxslt>=1.1.39-r2 \
|
||||||
libexpat>=2.7.2-r0 \
|
libexpat>=2.7.2-r0 \
|
||||||
libpng>=1.6.53-r0
|
libpng>=1.6.53-r0 \
|
||||||
|
&& apk del curl
|
||||||
|
|
||||||
USER nginx
|
USER nginx
|
||||||
|
|
||||||
|
|||||||
@@ -5,9 +5,9 @@ server {
|
|||||||
|
|
||||||
root /usr/share/nginx/html;
|
root /usr/share/nginx/html;
|
||||||
|
|
||||||
location = /.wellknown/windows-app-web-link {
|
location = /.well-known/windows-app-web-link {
|
||||||
default_type application/json;
|
default_type application/json;
|
||||||
alias /usr/share/nginx/html/.wellknown/windows-app-web-link;
|
alias /usr/share/nginx/html/.well-known/windows-app-web-link;
|
||||||
add_header Content-Disposition "attachment; filename=windows-app-web-link";
|
add_header Content-Disposition "attachment; filename=windows-app-web-link";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Generated
+3298
-1685
File diff suppressed because it is too large
Load Diff
+16
-16
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "meet",
|
"name": "meet",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "1.7.0",
|
"version": "1.9.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "panda codegen && vite",
|
"dev": "panda codegen && vite",
|
||||||
@@ -15,28 +15,28 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@fontsource-variable/material-symbols-outlined": "5.2.34",
|
"@fontsource-variable/material-symbols-outlined": "5.2.34",
|
||||||
"@fontsource/material-icons-outlined": "5.2.6",
|
"@fontsource/material-icons-outlined": "5.2.6",
|
||||||
"@livekit/components-react": "2.9.13",
|
"@livekit/components-react": "2.9.19",
|
||||||
"@livekit/components-styles": "1.1.6",
|
"@livekit/components-styles": "1.2.0",
|
||||||
"@livekit/track-processors": "0.6.1",
|
"@livekit/track-processors": "0.7.0",
|
||||||
"@pandacss/preset-panda": "0.54.0",
|
"@pandacss/preset-panda": "1.8.2",
|
||||||
"@react-aria/toast": "3.0.10",
|
"@react-aria/toast": "3.0.10",
|
||||||
"@react-types/overlays": "3.9.3",
|
"@react-types/overlays": "3.9.3",
|
||||||
"@remixicon/react": "4.6.0",
|
"@remixicon/react": "4.6.0",
|
||||||
"@tanstack/react-query": "5.81.5",
|
"@tanstack/react-query": "5.90.21",
|
||||||
"@timephy/rnnoise-wasm": "1.0.0",
|
"@timephy/rnnoise-wasm": "1.0.0",
|
||||||
"crisp-sdk-web": "1.0.27",
|
"crisp-sdk-web": "1.0.27",
|
||||||
"derive-valtio": "0.2.0",
|
"derive-valtio": "0.2.0",
|
||||||
"hoofd": "1.7.3",
|
"hoofd": "1.7.3",
|
||||||
"humanize-duration": "3.33.2",
|
"humanize-duration": "3.33.2",
|
||||||
"i18next": "25.8.4",
|
"i18next": "25.8.8",
|
||||||
"i18next-browser-languagedetector": "8.2.0",
|
"i18next-browser-languagedetector": "8.2.1",
|
||||||
"i18next-parser": "9.3.0",
|
"i18next-parser": "9.3.0",
|
||||||
"i18next-resources-to-backend": "1.2.1",
|
"i18next-resources-to-backend": "1.2.1",
|
||||||
"libphonenumber-js": "1.12.10",
|
"libphonenumber-js": "1.12.10",
|
||||||
"livekit-client": "2.15.7",
|
"livekit-client": "2.17.1",
|
||||||
"posthog-js": "1.342.1",
|
"posthog-js": "1.342.1",
|
||||||
"react": "18.3.1",
|
"react": "18.3.1",
|
||||||
"react-aria-components": "1.10.1",
|
"react-aria-components": "1.14.0",
|
||||||
"react-dom": "18.3.1",
|
"react-dom": "18.3.1",
|
||||||
"react-i18next": "15.1.1",
|
"react-i18next": "15.1.1",
|
||||||
"use-sound": "5.0.0",
|
"use-sound": "5.0.0",
|
||||||
@@ -44,16 +44,16 @@
|
|||||||
"wouter": "3.9.0"
|
"wouter": "3.9.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@pandacss/dev": "0.54.0",
|
"@pandacss/dev": "1.8.2",
|
||||||
"@tanstack/eslint-plugin-query": "5.81.2",
|
"@tanstack/eslint-plugin-query": "5.91.4",
|
||||||
"@tanstack/react-query-devtools": "5.81.5",
|
"@tanstack/react-query-devtools": "5.91.3",
|
||||||
"@types/humanize-duration": "3.27.4",
|
"@types/humanize-duration": "3.27.4",
|
||||||
"@types/node": "22.16.0",
|
"@types/node": "22.16.0",
|
||||||
"@types/react": "18.3.12",
|
"@types/react": "18.3.12",
|
||||||
"@types/react-dom": "18.3.1",
|
"@types/react-dom": "18.3.1",
|
||||||
"@typescript-eslint/eslint-plugin": "8.35.1",
|
"@typescript-eslint/eslint-plugin": "8.35.1",
|
||||||
"@typescript-eslint/parser": "8.35.1",
|
"@typescript-eslint/parser": "8.35.1",
|
||||||
"@vitejs/plugin-react": "4.6.0",
|
"@vitejs/plugin-react": "5.1.4",
|
||||||
"eslint": "8.57.0",
|
"eslint": "8.57.0",
|
||||||
"eslint-config-prettier": "10.1.5",
|
"eslint-config-prettier": "10.1.5",
|
||||||
"eslint-plugin-jsx-a11y": "6.10.2",
|
"eslint-plugin-jsx-a11y": "6.10.2",
|
||||||
@@ -62,7 +62,7 @@
|
|||||||
"postcss": "8.5.6",
|
"postcss": "8.5.6",
|
||||||
"prettier": "3.8.1",
|
"prettier": "3.8.1",
|
||||||
"typescript": "5.8.3",
|
"typescript": "5.8.3",
|
||||||
"vite": "7.0.8",
|
"vite": "7.3.1",
|
||||||
"vite-tsconfig-paths": "5.1.4"
|
"vite-tsconfig-paths": "6.1.1"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,4 +7,5 @@ export type ApiUser = {
|
|||||||
last_name: string
|
last_name: string
|
||||||
language: BackendLanguage
|
language: BackendLanguage
|
||||||
timezone: string
|
timezone: string
|
||||||
|
can_create?: boolean
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { Button } from '@/primitives'
|
|||||||
import { RiArrowLeftSLine, RiArrowRightSLine } from '@remixicon/react'
|
import { RiArrowLeftSLine, RiArrowRightSLine } from '@remixicon/react'
|
||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import { useScreenReaderAnnounce } from '@/hooks/useScreenReaderAnnounce'
|
||||||
|
|
||||||
const Heading = styled('h2', {
|
const Heading = styled('h2', {
|
||||||
base: {
|
base: {
|
||||||
@@ -144,6 +145,21 @@ type Slide = {
|
|||||||
isAvailableInBeta?: boolean
|
isAvailableInBeta?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const carouselNavButton = css({
|
||||||
|
_focusVisible: {
|
||||||
|
outline: '2px solid var(--colors-focus-ring) !important',
|
||||||
|
outlineOffset: '1px',
|
||||||
|
},
|
||||||
|
_disabled: {
|
||||||
|
color: 'greyscale.400',
|
||||||
|
cursor: 'default',
|
||||||
|
pointerEvents: 'none',
|
||||||
|
_pressed: {
|
||||||
|
backgroundColor: 'transparent',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
// todo - optimize how images are imported
|
// todo - optimize how images are imported
|
||||||
const SLIDES: Slide[] = [
|
const SLIDES: Slide[] = [
|
||||||
{
|
{
|
||||||
@@ -163,11 +179,45 @@ const SLIDES: Slide[] = [
|
|||||||
export const IntroSlider = () => {
|
export const IntroSlider = () => {
|
||||||
const [slideIndex, setSlideIndex] = useState(0)
|
const [slideIndex, setSlideIndex] = useState(0)
|
||||||
const { t } = useTranslation('home', { keyPrefix: 'introSlider' })
|
const { t } = useTranslation('home', { keyPrefix: 'introSlider' })
|
||||||
|
const announce = useScreenReaderAnnounce()
|
||||||
|
|
||||||
const NUMBER_SLIDES = SLIDES.length
|
const NUMBER_SLIDES = SLIDES.length
|
||||||
|
|
||||||
|
const goPrev = () => {
|
||||||
|
if (slideIndex === 0) return
|
||||||
|
const newIndex = slideIndex - 1
|
||||||
|
setSlideIndex(newIndex)
|
||||||
|
announce(
|
||||||
|
t('slidePosition', { current: newIndex + 1, total: NUMBER_SLIDES }),
|
||||||
|
'polite',
|
||||||
|
'global'
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const goNext = () => {
|
||||||
|
if (slideIndex === NUMBER_SLIDES - 1) return
|
||||||
|
const newIndex = slideIndex + 1
|
||||||
|
setSlideIndex(newIndex)
|
||||||
|
announce(
|
||||||
|
t('slidePosition', { current: newIndex + 1, total: NUMBER_SLIDES }),
|
||||||
|
'polite',
|
||||||
|
'global'
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const ariaLabelParams = {
|
||||||
|
current: slideIndex + 1,
|
||||||
|
total: NUMBER_SLIDES,
|
||||||
|
}
|
||||||
|
const previousAriaLabel = t('previous.labelWithPosition', ariaLabelParams)
|
||||||
|
const nextAriaLabel = t('next.labelWithPosition', ariaLabelParams)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Container>
|
<Container
|
||||||
|
role="region"
|
||||||
|
aria-roledescription="carousel"
|
||||||
|
aria-label={t('carouselLabel')}
|
||||||
|
>
|
||||||
<div
|
<div
|
||||||
className={css({
|
className={css({
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
@@ -180,10 +230,10 @@ export const IntroSlider = () => {
|
|||||||
<Button
|
<Button
|
||||||
variant="secondaryText"
|
variant="secondaryText"
|
||||||
square
|
square
|
||||||
aria-label={t('previous.label')}
|
className={carouselNavButton}
|
||||||
tooltip={t('previous.tooltip')}
|
aria-label={previousAriaLabel}
|
||||||
onPress={() => setSlideIndex(slideIndex - 1)}
|
aria-disabled={slideIndex === 0}
|
||||||
isDisabled={slideIndex == 0}
|
onPress={goPrev}
|
||||||
>
|
>
|
||||||
<RiArrowLeftSLine />
|
<RiArrowLeftSLine />
|
||||||
</Button>
|
</Button>
|
||||||
@@ -191,7 +241,11 @@ export const IntroSlider = () => {
|
|||||||
</ButtonContainer>
|
</ButtonContainer>
|
||||||
<SlideContainer>
|
<SlideContainer>
|
||||||
{SLIDES.map((slide, index) => (
|
{SLIDES.map((slide, index) => (
|
||||||
<Slide visible={index == slideIndex} key={index}>
|
<Slide
|
||||||
|
aria-hidden={index !== slideIndex}
|
||||||
|
visible={index === slideIndex}
|
||||||
|
key={index}
|
||||||
|
>
|
||||||
<Image src={slide.src} alt="" role="presentation" />
|
<Image src={slide.src} alt="" role="presentation" />
|
||||||
<TextAnimation visible={index == slideIndex}>
|
<TextAnimation visible={index == slideIndex}>
|
||||||
<Heading>{t(`${slide.key}.title`)}</Heading>
|
<Heading>{t(`${slide.key}.title`)}</Heading>
|
||||||
@@ -205,10 +259,10 @@ export const IntroSlider = () => {
|
|||||||
<Button
|
<Button
|
||||||
variant="secondaryText"
|
variant="secondaryText"
|
||||||
square
|
square
|
||||||
aria-label={t('next.label')}
|
className={carouselNavButton}
|
||||||
tooltip={t('next.tooltip')}
|
aria-label={nextAriaLabel}
|
||||||
onPress={() => setSlideIndex(slideIndex + 1)}
|
aria-disabled={slideIndex === NUMBER_SLIDES - 1}
|
||||||
isDisabled={slideIndex == NUMBER_SLIDES - 1}
|
onPress={goNext}
|
||||||
>
|
>
|
||||||
<RiArrowRightSLine />
|
<RiArrowRightSLine />
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -5,37 +5,42 @@ import { isRoomValid } from '@/features/rooms'
|
|||||||
|
|
||||||
export const JoinMeetingDialog = () => {
|
export const JoinMeetingDialog = () => {
|
||||||
const { t } = useTranslation('home')
|
const { t } = useTranslation('home')
|
||||||
|
|
||||||
|
const handleSubmit = (data: { roomId?: FormDataEntryValue }) => {
|
||||||
|
const roomId = (data.roomId as string)
|
||||||
|
.trim()
|
||||||
|
.replace(`${window.location.origin}/`, '')
|
||||||
|
navigateTo('room', roomId)
|
||||||
|
}
|
||||||
|
|
||||||
|
const validateRoomId = (value: string) => {
|
||||||
|
const trimmed = value.trim()
|
||||||
|
if (!trimmed) return null
|
||||||
|
return !isRoomValid(trimmed) ? (
|
||||||
|
<>
|
||||||
|
<p>{t('joinInputError')}</p>
|
||||||
|
<Ul>
|
||||||
|
<li>{window.location.origin}/uio-azer-jkl</li>
|
||||||
|
<li>uio-azer-jkl</li>
|
||||||
|
</Ul>
|
||||||
|
</>
|
||||||
|
) : null
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog title={t('joinMeeting')}>
|
<Dialog title={t('joinMeeting')}>
|
||||||
<Form
|
<Form onSubmit={handleSubmit} submitLabel={t('joinInputSubmit')}>
|
||||||
onSubmit={(data) => {
|
{/* eslint-disable jsx-a11y/no-autofocus -- Focus on input when modal opens, required for accessibility */}
|
||||||
navigateTo(
|
|
||||||
'room',
|
|
||||||
(data.roomId as string)
|
|
||||||
.trim()
|
|
||||||
.replace(`${window.location.origin}/`, '')
|
|
||||||
)
|
|
||||||
}}
|
|
||||||
submitLabel={t('joinInputSubmit')}
|
|
||||||
>
|
|
||||||
<Field
|
<Field
|
||||||
type="text"
|
type="text"
|
||||||
|
autoFocus
|
||||||
|
isRequired
|
||||||
name="roomId"
|
name="roomId"
|
||||||
label={t('joinInputLabel')}
|
label={t('joinInputLabel')}
|
||||||
description={t('joinInputExample', {
|
description={t('joinInputExample', {
|
||||||
example: window.origin + '/azer-tyu-qsdf',
|
example: window.origin + '/azer-tyu-qsdf',
|
||||||
})}
|
})}
|
||||||
validate={(value) => {
|
validate={validateRoomId}
|
||||||
return !isRoomValid(value.trim()) ? (
|
|
||||||
<>
|
|
||||||
<p>{t('joinInputError')}</p>
|
|
||||||
<Ul>
|
|
||||||
<li>{window.location.origin}/uio-azer-jkl</li>
|
|
||||||
<li>uio-azer-jkl</li>
|
|
||||||
</Ul>
|
|
||||||
</>
|
|
||||||
) : null
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
</Form>
|
</Form>
|
||||||
<H lvl={2}>{t('joinMeetingTipHeading')}</H>
|
<H lvl={2}>{t('joinMeetingTipHeading')}</H>
|
||||||
|
|||||||
@@ -148,7 +148,8 @@ const IntroText = styled('div', {
|
|||||||
|
|
||||||
export const Home = () => {
|
export const Home = () => {
|
||||||
const { t } = useTranslation('home')
|
const { t } = useTranslation('home')
|
||||||
const { isLoggedIn } = useUser()
|
const { isLoggedIn, user } = useUser()
|
||||||
|
const canCreate = user?.can_create === true
|
||||||
|
|
||||||
const {
|
const {
|
||||||
userChoices: { username },
|
userChoices: { username },
|
||||||
@@ -200,45 +201,56 @@ export const Home = () => {
|
|||||||
})}
|
})}
|
||||||
>
|
>
|
||||||
{isLoggedIn ? (
|
{isLoggedIn ? (
|
||||||
<Menu>
|
canCreate ? (
|
||||||
<Button variant="primary" data-attr="create-meeting">
|
<Menu>
|
||||||
{t('createMeeting')}
|
<Button variant="primary" data-attr="create-meeting">
|
||||||
</Button>
|
{t('createMeeting')}
|
||||||
<RACMenu>
|
</Button>
|
||||||
<MenuItem
|
<RACMenu>
|
||||||
className={
|
<MenuItem
|
||||||
menuRecipe({ icon: true, variant: 'light' }).item
|
className={
|
||||||
}
|
menuRecipe({ icon: true, variant: 'light' }).item
|
||||||
onAction={async () => {
|
}
|
||||||
const slug = generateRoomId()
|
onAction={async () => {
|
||||||
createRoom({ slug, username }).then((data) =>
|
const slug = generateRoomId()
|
||||||
navigateTo('room', data.slug, {
|
createRoom({ slug, username }).then((data) =>
|
||||||
state: { create: true, initialRoomData: data },
|
navigateTo('room', data.slug, {
|
||||||
})
|
state: { create: true, initialRoomData: data },
|
||||||
)
|
})
|
||||||
}}
|
)
|
||||||
data-attr="create-option-instant"
|
}}
|
||||||
>
|
data-attr="create-option-instant"
|
||||||
<RiAddLine size={18} />
|
>
|
||||||
{t('createMenu.instantOption')}
|
<RiAddLine size={18} />
|
||||||
</MenuItem>
|
{t('createMenu.instantOption')}
|
||||||
<MenuItem
|
</MenuItem>
|
||||||
className={
|
<MenuItem
|
||||||
menuRecipe({ icon: true, variant: 'light' }).item
|
className={
|
||||||
}
|
menuRecipe({ icon: true, variant: 'light' }).item
|
||||||
onAction={() => {
|
}
|
||||||
const slug = generateRoomId()
|
onAction={() => {
|
||||||
createRoom({ slug, username }).then((data) =>
|
const slug = generateRoomId()
|
||||||
setLaterRoom(data)
|
createRoom({ slug, username }).then((data) =>
|
||||||
)
|
setLaterRoom(data)
|
||||||
}}
|
)
|
||||||
data-attr="create-option-later"
|
}}
|
||||||
>
|
data-attr="create-option-later"
|
||||||
<RiLink size={18} />
|
>
|
||||||
{t('createMenu.laterOption')}
|
<RiLink size={18} />
|
||||||
</MenuItem>
|
{t('createMenu.laterOption')}
|
||||||
</RACMenu>
|
</MenuItem>
|
||||||
</Menu>
|
</RACMenu>
|
||||||
|
</Menu>
|
||||||
|
) : (
|
||||||
|
<p
|
||||||
|
className={css({
|
||||||
|
color: 'greyscale.700',
|
||||||
|
fontSize: '0.95rem',
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
{t('noAccess')}
|
||||||
|
</p>
|
||||||
|
)
|
||||||
) : (
|
) : (
|
||||||
<LoginButton proConnectHint={false} />
|
<LoginButton proConnectHint={false} />
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -65,7 +65,7 @@ export const InviteDialog = (props: Omit<DialogProps, 'title'>) => {
|
|||||||
alignItems="left"
|
alignItems="left"
|
||||||
justify="start"
|
justify="start"
|
||||||
gap={0}
|
gap={0}
|
||||||
style={{ maxWidth: '100%', overflow: 'hidden' }}
|
style={{ maxWidth: '100%', overflow: 'visible' }}
|
||||||
>
|
>
|
||||||
<Heading slot="title" level={2} className={text({ variant: 'h2' })}>
|
<Heading slot="title" level={2} className={text({ variant: 'h2' })}>
|
||||||
{t('heading')}
|
{t('heading')}
|
||||||
@@ -93,7 +93,7 @@ export const InviteDialog = (props: Omit<DialogProps, 'title'>) => {
|
|||||||
flexDirection: 'column',
|
flexDirection: 'column',
|
||||||
marginTop: '0.5rem',
|
marginTop: '0.5rem',
|
||||||
gap: '1rem',
|
gap: '1rem',
|
||||||
overflow: 'hidden',
|
overflow: 'visible',
|
||||||
})}
|
})}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
|
|||||||
@@ -753,7 +753,7 @@ export const Join = ({
|
|||||||
try {
|
try {
|
||||||
saveVideoInputDeviceId(id)
|
saveVideoInputDeviceId(id)
|
||||||
if (videoTrack) {
|
if (videoTrack) {
|
||||||
await await videoTrack.setDeviceId({ exact: id })
|
await videoTrack.setDeviceId({ exact: id })
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to switch camera device', err)
|
console.error('Failed to switch camera device', err)
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { cva } from '@/styled-system/css'
|
|||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import { styled, VStack } from '@/styled-system/jsx'
|
import { styled, VStack } from '@/styled-system/jsx'
|
||||||
import { usePostHog } from 'posthog-js/react'
|
import { usePostHog } from 'posthog-js/react'
|
||||||
import { PostHog } from 'posthog-js'
|
import type { PostHog } from 'posthog-js'
|
||||||
import { Button as RACButton } from 'react-aria-components'
|
import { Button as RACButton } from 'react-aria-components'
|
||||||
import { useIsAnalyticsEnabled } from '@/features/analytics/hooks/useIsAnalyticsEnabled'
|
import { useIsAnalyticsEnabled } from '@/features/analytics/hooks/useIsAnalyticsEnabled'
|
||||||
|
|
||||||
|
|||||||
@@ -74,13 +74,17 @@ export const useWaitingParticipants = () => {
|
|||||||
): Promise<void> => {
|
): Promise<void> => {
|
||||||
try {
|
try {
|
||||||
setListEnabled(false)
|
setListEnabled(false)
|
||||||
for (const participant of waitingParticipants) {
|
|
||||||
await enterRoom({
|
await Promise.all(
|
||||||
roomId: roomId,
|
waitingParticipants.map((participant) =>
|
||||||
allowEntry,
|
enterRoom({
|
||||||
participantId: participant.id,
|
roomId: roomId,
|
||||||
})
|
allowEntry,
|
||||||
}
|
participantId: participant.id,
|
||||||
|
})
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
await refetchWaiting()
|
await refetchWaiting()
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(e)
|
console.error(e)
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import { useSettingsDialog } from '@/features/settings/hook/useSettingsDialog'
|
|||||||
import { SettingsDialogExtendedKey } from '@/features/settings/type'
|
import { SettingsDialogExtendedKey } from '@/features/settings/type'
|
||||||
|
|
||||||
const IDLE_DISCONNECT_TIMEOUT_MS = 120000 // 2 minutes
|
const IDLE_DISCONNECT_TIMEOUT_MS = 120000 // 2 minutes
|
||||||
const COUNTDOWN_ANNOUNCEMENT_SECONDS = [90, 60, 30]
|
const COUNTDOWN_ANNOUNCEMENT_SECONDS = new Set([90, 60, 30])
|
||||||
const FINAL_COUNTDOWN_SECONDS = 10
|
const FINAL_COUNTDOWN_SECONDS = 10
|
||||||
|
|
||||||
export const IsIdleDisconnectModal = () => {
|
export const IsIdleDisconnectModal = () => {
|
||||||
@@ -58,7 +58,7 @@ export const IsIdleDisconnectModal = () => {
|
|||||||
if (!connectionObserverSnap.isIdleDisconnectModalOpen) return
|
if (!connectionObserverSnap.isIdleDisconnectModalOpen) return
|
||||||
|
|
||||||
const shouldAnnounce =
|
const shouldAnnounce =
|
||||||
COUNTDOWN_ANNOUNCEMENT_SECONDS.includes(remainingSeconds) ||
|
COUNTDOWN_ANNOUNCEMENT_SECONDS.has(remainingSeconds) ||
|
||||||
remainingSeconds <= FINAL_COUNTDOWN_SECONDS
|
remainingSeconds <= FINAL_COUNTDOWN_SECONDS
|
||||||
|
|
||||||
if (shouldAnnounce && remainingSeconds !== lastAnnouncementRef.current) {
|
if (shouldAnnounce && remainingSeconds !== lastAnnouncementRef.current) {
|
||||||
|
|||||||
@@ -1,5 +1,28 @@
|
|||||||
import React, { ReactNode } from 'react'
|
import React, { ReactNode } from 'react'
|
||||||
import { css } from '@/styled-system/css'
|
import { styled } from '@/styled-system/jsx'
|
||||||
|
|
||||||
|
const Hint = styled('div', {
|
||||||
|
base: {
|
||||||
|
position: 'absolute',
|
||||||
|
top: '0.75rem',
|
||||||
|
right: '0.75rem',
|
||||||
|
backgroundColor: 'rgba(0,0,0,0.5)',
|
||||||
|
color: 'white',
|
||||||
|
borderRadius: 'calc(var(--lk-border-radius) / 2)',
|
||||||
|
paddingInline: '0.5rem',
|
||||||
|
paddingBlock: '0.1rem',
|
||||||
|
fontSize: '0.875rem',
|
||||||
|
opacity: 0,
|
||||||
|
visibility: 'hidden',
|
||||||
|
pointerEvents: 'none',
|
||||||
|
transition: 'opacity 150ms ease',
|
||||||
|
'.lk-grid-layout > *:first-child:focus-within &': {
|
||||||
|
opacity: 1,
|
||||||
|
visibility: 'visible',
|
||||||
|
pointerEvents: 'auto',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
export interface KeyboardShortcutHintProps {
|
export interface KeyboardShortcutHintProps {
|
||||||
children: ReactNode
|
children: ReactNode
|
||||||
@@ -12,21 +35,5 @@ export interface KeyboardShortcutHintProps {
|
|||||||
export const KeyboardShortcutHint: React.FC<KeyboardShortcutHintProps> = ({
|
export const KeyboardShortcutHint: React.FC<KeyboardShortcutHintProps> = ({
|
||||||
children,
|
children,
|
||||||
}) => {
|
}) => {
|
||||||
return (
|
return <Hint>{children}</Hint>
|
||||||
<div
|
|
||||||
className={css({
|
|
||||||
position: 'absolute',
|
|
||||||
top: '0.75rem',
|
|
||||||
right: '0.75rem',
|
|
||||||
backgroundColor: 'rgba(0,0,0,0.5)',
|
|
||||||
color: 'white',
|
|
||||||
borderRadius: 'calc(var(--lk-border-radius) / 2)',
|
|
||||||
paddingInline: '0.5rem',
|
|
||||||
paddingBlock: '0.1rem',
|
|
||||||
fontSize: '0.875rem',
|
|
||||||
})}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,21 @@
|
|||||||
|
import type { CSSProperties } from 'react'
|
||||||
import { Text } from '@/primitives'
|
import { Text } from '@/primitives'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import { useParticipantInfo } from '@livekit/components-react'
|
import { useParticipantInfo } from '@livekit/components-react'
|
||||||
import { Participant } from 'livekit-client'
|
import { Participant } from 'livekit-client'
|
||||||
|
|
||||||
|
const participantNameStyles: CSSProperties = {
|
||||||
|
paddingBottom: '0.1rem',
|
||||||
|
overflow: 'hidden',
|
||||||
|
textOverflow: 'ellipsis',
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
}
|
||||||
|
|
||||||
|
const participantNameScreenShareStyles: CSSProperties = {
|
||||||
|
...participantNameStyles,
|
||||||
|
marginLeft: '0.4rem',
|
||||||
|
}
|
||||||
|
|
||||||
export const ParticipantName = ({
|
export const ParticipantName = ({
|
||||||
participant,
|
participant,
|
||||||
isScreenShare = false,
|
isScreenShare = false,
|
||||||
@@ -17,26 +30,14 @@ export const ParticipantName = ({
|
|||||||
|
|
||||||
if (isScreenShare) {
|
if (isScreenShare) {
|
||||||
return (
|
return (
|
||||||
<Text
|
<Text variant="sm" style={participantNameScreenShareStyles}>
|
||||||
variant="sm"
|
|
||||||
style={{
|
|
||||||
paddingBottom: '0.1rem',
|
|
||||||
marginLeft: '0.4rem',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{t('screenShare', { name: displayedName })}
|
{t('screenShare', { name: displayedName })}
|
||||||
</Text>
|
</Text>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Text
|
<Text variant="sm" style={participantNameStyles} aria-hidden="true">
|
||||||
variant="sm"
|
|
||||||
style={{
|
|
||||||
paddingBottom: '0.1rem',
|
|
||||||
}}
|
|
||||||
aria-hidden="true"
|
|
||||||
>
|
|
||||||
{displayedName}
|
{displayedName}
|
||||||
</Text>
|
</Text>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -183,7 +183,7 @@ export const ParticipantTile: (
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{isHandRaised && !isScreenShare && (
|
{isHandRaised && !isScreenShare && (
|
||||||
<>
|
<span>
|
||||||
<span>{positionInQueue}</span>
|
<span>{positionInQueue}</span>
|
||||||
<RiHand
|
<RiHand
|
||||||
color="black"
|
color="black"
|
||||||
@@ -197,7 +197,7 @@ export const ParticipantTile: (
|
|||||||
animationIterationCount: '2',
|
animationIterationCount: '2',
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</>
|
</span>
|
||||||
)}
|
)}
|
||||||
{isScreenShare && (
|
{isScreenShare && (
|
||||||
<ScreenShareIcon
|
<ScreenShareIcon
|
||||||
@@ -210,10 +210,12 @@ export const ParticipantTile: (
|
|||||||
{isEncrypted && !isScreenShare && (
|
{isEncrypted && !isScreenShare && (
|
||||||
<LockLockedIcon style={{ marginRight: '0.25rem' }} />
|
<LockLockedIcon style={{ marginRight: '0.25rem' }} />
|
||||||
)}
|
)}
|
||||||
<ParticipantName
|
<div className="lk-participant-name-wrapper">
|
||||||
isScreenShare={isScreenShare}
|
<ParticipantName
|
||||||
participant={trackReference.participant}
|
isScreenShare={isScreenShare}
|
||||||
/>
|
participant={trackReference.participant}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</HStack>
|
</HStack>
|
||||||
<ConnectionQualityIndicator className="lk-participant-metadata-item" />
|
<ConnectionQualityIndicator className="lk-participant-metadata-item" />
|
||||||
@@ -229,9 +231,7 @@ export const ParticipantTile: (
|
|||||||
)}
|
)}
|
||||||
</ParticipantContextIfNeeded>
|
</ParticipantContextIfNeeded>
|
||||||
</TrackRefContextIfNeeded>
|
</TrackRefContextIfNeeded>
|
||||||
{hasKeyboardFocus && (
|
<KeyboardShortcutHint>{t('toolbarHint')}</KeyboardShortcutHint>
|
||||||
<KeyboardShortcutHint>{t('toolbarHint')}</KeyboardShortcutHint>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import { RiArrowLeftLine, RiCloseLine } from '@remixicon/react'
|
|||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import { ParticipantsList } from './controls/Participants/ParticipantsList'
|
import { ParticipantsList } from './controls/Participants/ParticipantsList'
|
||||||
import { useSidePanel } from '../hooks/useSidePanel'
|
import { useSidePanel } from '../hooks/useSidePanel'
|
||||||
import { ReactNode, useEffect, useRef } from 'react'
|
import { ReactNode } from 'react'
|
||||||
import { Chat } from '../prefabs/Chat'
|
import { Chat } from '../prefabs/Chat'
|
||||||
import { Effects } from './effects/Effects'
|
import { Effects } from './effects/Effects'
|
||||||
import { Admin } from './Admin'
|
import { Admin } from './Admin'
|
||||||
@@ -15,11 +15,9 @@ import { Tools } from './Tools'
|
|||||||
import { Info } from './Info'
|
import { Info } from './Info'
|
||||||
import { HStack } from '@/styled-system/jsx'
|
import { HStack } from '@/styled-system/jsx'
|
||||||
|
|
||||||
const SIDE_PANEL_HEADING_ID = 'side-panel-heading'
|
|
||||||
const SIDE_PANEL_CLOSE_ID = 'side-panel-close'
|
|
||||||
|
|
||||||
type StyledSidePanelProps = {
|
type StyledSidePanelProps = {
|
||||||
title: string
|
title: string
|
||||||
|
ariaLabel: string
|
||||||
children: ReactNode
|
children: ReactNode
|
||||||
onClose: () => void
|
onClose: () => void
|
||||||
isClosed: boolean
|
isClosed: boolean
|
||||||
@@ -31,6 +29,7 @@ type StyledSidePanelProps = {
|
|||||||
|
|
||||||
const StyledSidePanel = ({
|
const StyledSidePanel = ({
|
||||||
title,
|
title,
|
||||||
|
ariaLabel,
|
||||||
children,
|
children,
|
||||||
onClose,
|
onClose,
|
||||||
isClosed,
|
isClosed,
|
||||||
@@ -39,14 +38,7 @@ const StyledSidePanel = ({
|
|||||||
onBack,
|
onBack,
|
||||||
backButtonLabel,
|
backButtonLabel,
|
||||||
}: StyledSidePanelProps) => (
|
}: StyledSidePanelProps) => (
|
||||||
// eslint-disable-next-line jsx-a11y/no-noninteractive-element-interactions -- role="dialog" makes this interactive
|
|
||||||
<aside
|
<aside
|
||||||
role="dialog"
|
|
||||||
aria-labelledby={!isClosed ? SIDE_PANEL_HEADING_ID : undefined}
|
|
||||||
aria-hidden={isClosed || undefined}
|
|
||||||
onKeyDown={(e) => {
|
|
||||||
if (e.key === 'Escape') onClose()
|
|
||||||
}}
|
|
||||||
className={css({
|
className={css({
|
||||||
borderWidth: '1px',
|
borderWidth: '1px',
|
||||||
borderStyle: 'solid',
|
borderStyle: 'solid',
|
||||||
@@ -71,57 +63,57 @@ const StyledSidePanel = ({
|
|||||||
style={{
|
style={{
|
||||||
transform: isClosed ? 'translateX(calc(360px + 1.5rem))' : 'none',
|
transform: isClosed ? 'translateX(calc(360px + 1.5rem))' : 'none',
|
||||||
}}
|
}}
|
||||||
|
aria-hidden={isClosed}
|
||||||
|
aria-label={ariaLabel}
|
||||||
>
|
>
|
||||||
<HStack alignItems="center">
|
<HStack alignItems="center">
|
||||||
{isSubmenu && (
|
{isSubmenu && (
|
||||||
<Button
|
<Button
|
||||||
variant="secondaryText"
|
variant="secondaryText"
|
||||||
size="sm"
|
size="sm"
|
||||||
square
|
square
|
||||||
className={css({ marginRight: '0.5rem', marginLeft: '1rem' })}
|
className={css({ marginRight: '0.5rem', marginLeft: '1rem' })}
|
||||||
aria-label={backButtonLabel}
|
aria-label={backButtonLabel}
|
||||||
onPress={onBack}
|
onPress={onBack}
|
||||||
>
|
|
||||||
<RiArrowLeftLine size={20} aria-hidden="true" />
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
<Heading
|
|
||||||
id={SIDE_PANEL_HEADING_ID}
|
|
||||||
slot="title"
|
|
||||||
level={1}
|
|
||||||
className={text({ variant: 'h2' })}
|
|
||||||
style={{
|
|
||||||
paddingLeft: isSubmenu ? 0 : '1.5rem',
|
|
||||||
paddingTop: '1rem',
|
|
||||||
display: isClosed ? 'none' : 'flex',
|
|
||||||
justifyContent: 'start',
|
|
||||||
alignItems: 'center',
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
{title}
|
<RiArrowLeftLine size={20} aria-hidden="true" />
|
||||||
</Heading>
|
</Button>
|
||||||
</HStack>
|
)}
|
||||||
<Div
|
<Heading
|
||||||
position="absolute"
|
slot="title"
|
||||||
top="5"
|
level={1}
|
||||||
right="5"
|
className={text({ variant: 'h2' })}
|
||||||
style={{
|
style={{
|
||||||
display: isClosed ? 'none' : undefined,
|
paddingLeft: isSubmenu ? 0 : '1.5rem',
|
||||||
|
paddingTop: '1rem',
|
||||||
|
display: isClosed ? 'none' : 'flex',
|
||||||
|
justifyContent: 'start',
|
||||||
|
alignItems: 'center',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Button
|
{title}
|
||||||
id={SIDE_PANEL_CLOSE_ID}
|
</Heading>
|
||||||
invisible
|
</HStack>
|
||||||
variant="tertiaryText"
|
<Div
|
||||||
size="xs"
|
position="absolute"
|
||||||
onPress={onClose}
|
top="5"
|
||||||
aria-label={closeButtonTooltip}
|
right="5"
|
||||||
tooltip={closeButtonTooltip}
|
style={{
|
||||||
>
|
display: isClosed ? 'none' : undefined,
|
||||||
<RiCloseLine />
|
}}
|
||||||
</Button>
|
>
|
||||||
</Div>
|
<Button
|
||||||
{children}
|
invisible
|
||||||
|
variant="tertiaryText"
|
||||||
|
size="xs"
|
||||||
|
onPress={onClose}
|
||||||
|
aria-label={closeButtonTooltip}
|
||||||
|
tooltip={closeButtonTooltip}
|
||||||
|
>
|
||||||
|
<RiCloseLine />
|
||||||
|
</Button>
|
||||||
|
</Div>
|
||||||
|
{children}
|
||||||
</aside>
|
</aside>
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -143,7 +135,6 @@ const Panel = ({ isOpen, keepAlive = false, children }: PanelProps) => (
|
|||||||
{keepAlive || isOpen ? children : null}
|
{keepAlive || isOpen ? children : null}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|
||||||
export const SidePanel = () => {
|
export const SidePanel = () => {
|
||||||
const {
|
const {
|
||||||
activePanelId,
|
activePanelId,
|
||||||
@@ -159,51 +150,14 @@ export const SidePanel = () => {
|
|||||||
} = useSidePanel()
|
} = useSidePanel()
|
||||||
const { t } = useTranslation('rooms', { keyPrefix: 'sidePanel' })
|
const { t } = useTranslation('rooms', { keyPrefix: 'sidePanel' })
|
||||||
|
|
||||||
const triggerRef = useRef<HTMLElement | null>(null)
|
|
||||||
|
|
||||||
// The aside stays mounted (CSS slide + keepAlive), so we manually handle
|
|
||||||
// auto-focus on open and restore focus on close (via handleClose).
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!isSidePanelOpen) return
|
|
||||||
const active = document.activeElement as HTMLElement
|
|
||||||
// Menu items render as DIVs that unmount when the menu closes — resolve to the menu trigger
|
|
||||||
triggerRef.current =
|
|
||||||
active?.tagName === 'DIV'
|
|
||||||
? (document.querySelector<HTMLElement>('#room-options-trigger') ??
|
|
||||||
active)
|
|
||||||
: active
|
|
||||||
requestAnimationFrame(() => {
|
|
||||||
const closeBtn = document.getElementById(SIDE_PANEL_CLOSE_ID)
|
|
||||||
// Skip if a child panel already moved focus inside (e.g. Chat input)
|
|
||||||
if (closeBtn?.closest('aside')?.contains(document.activeElement)) return
|
|
||||||
closeBtn?.focus({ preventScroll: true })
|
|
||||||
})
|
|
||||||
}, [isSidePanelOpen])
|
|
||||||
|
|
||||||
const handleClose = () => {
|
|
||||||
const trigger = triggerRef.current
|
|
||||||
triggerRef.current = null
|
|
||||||
layoutStore.activePanelId = null
|
|
||||||
layoutStore.activeSubPanelId = null
|
|
||||||
// Double RAF: first lets React re-render, second lets FocusScope release containment
|
|
||||||
requestAnimationFrame(() => {
|
|
||||||
requestAnimationFrame(() => {
|
|
||||||
if (trigger?.isConnected) {
|
|
||||||
trigger.focus({ preventScroll: true })
|
|
||||||
} else {
|
|
||||||
document
|
|
||||||
.querySelector<HTMLElement>('#room-options-trigger')
|
|
||||||
?.focus({ preventScroll: true })
|
|
||||||
}
|
|
||||||
})
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<StyledSidePanel
|
<StyledSidePanel
|
||||||
title={t(`heading.${activeSubPanelId || activePanelId}`)}
|
title={t(`heading.${activeSubPanelId || activePanelId}`)}
|
||||||
onClose={handleClose}
|
ariaLabel={t('ariaLabel')}
|
||||||
|
onClose={() => {
|
||||||
|
layoutStore.activePanelId = null
|
||||||
|
layoutStore.activeSubPanelId = null
|
||||||
|
}}
|
||||||
closeButtonTooltip={t('closeButton', {
|
closeButtonTooltip={t('closeButton', {
|
||||||
content: t(`content.${activeSubPanelId || activePanelId}`),
|
content: t(`content.${activeSubPanelId || activePanelId}`),
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { Button as RACButton } from 'react-aria-components'
|
|||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import { ReactNode } from 'react'
|
import { ReactNode } from 'react'
|
||||||
import { SubPanelId, useSidePanel } from '../hooks/useSidePanel'
|
import { SubPanelId, useSidePanel } from '../hooks/useSidePanel'
|
||||||
|
import { useRestoreFocus } from '@/hooks/useRestoreFocus'
|
||||||
import {
|
import {
|
||||||
useIsRecordingModeEnabled,
|
useIsRecordingModeEnabled,
|
||||||
RecordingMode,
|
RecordingMode,
|
||||||
@@ -94,10 +95,26 @@ const ToolButton = ({
|
|||||||
|
|
||||||
export const Tools = () => {
|
export const Tools = () => {
|
||||||
const { data } = useConfig()
|
const { data } = useConfig()
|
||||||
const { openTranscript, openScreenRecording, activeSubPanelId } =
|
const { openTranscript, openScreenRecording, activeSubPanelId, isToolsOpen } =
|
||||||
useSidePanel()
|
useSidePanel()
|
||||||
const { t } = useTranslation('rooms', { keyPrefix: 'moreTools' })
|
const { t } = useTranslation('rooms', { keyPrefix: 'moreTools' })
|
||||||
|
|
||||||
|
// Restore focus to the element that opened the Tools panel
|
||||||
|
// following the same pattern as Chat.
|
||||||
|
useRestoreFocus(isToolsOpen, {
|
||||||
|
// If the active element is a MenuItem (DIV) that will be unmounted when the menu closes,
|
||||||
|
// find the "more options" button ("Plus d'options") that opened the menu
|
||||||
|
resolveTrigger: (activeEl) => {
|
||||||
|
if (activeEl?.tagName === 'DIV') {
|
||||||
|
return document.querySelector<HTMLElement>('#room-options-trigger')
|
||||||
|
}
|
||||||
|
// For direct button clicks (e.g. "Plus d'outils"), use the active element as is
|
||||||
|
return activeEl
|
||||||
|
},
|
||||||
|
restoreFocusRaf: true,
|
||||||
|
preventScroll: true,
|
||||||
|
})
|
||||||
|
|
||||||
const isTranscriptEnabled = useIsRecordingModeEnabled(
|
const isTranscriptEnabled = useIsRecordingModeEnabled(
|
||||||
RecordingMode.Transcript
|
RecordingMode.Transcript
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -80,12 +80,10 @@ export const ChatInput = ({
|
|||||||
<TextArea
|
<TextArea
|
||||||
ref={inputRef}
|
ref={inputRef}
|
||||||
onKeyDown={(e) => {
|
onKeyDown={(e) => {
|
||||||
if (e.key !== 'Escape') e.stopPropagation()
|
e.stopPropagation()
|
||||||
submitOnEnter(e)
|
submitOnEnter(e)
|
||||||
}}
|
}}
|
||||||
onKeyUp={(e) => {
|
onKeyUp={(e) => e.stopPropagation()}
|
||||||
if (e.key !== 'Escape') e.stopPropagation()
|
|
||||||
}}
|
|
||||||
placeholder={t('textArea.placeholder')}
|
placeholder={t('textArea.placeholder')}
|
||||||
value={text}
|
value={text}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
|
|||||||
+16
-1
@@ -1,5 +1,6 @@
|
|||||||
import { ToggleButton } from '@/primitives'
|
import { ToggleButton } from '@/primitives'
|
||||||
import { useRegisterKeyboardShortcut } from '@/features/shortcuts/useRegisterKeyboardShortcut'
|
import { useRegisterKeyboardShortcut } from '@/features/shortcuts/useRegisterKeyboardShortcut'
|
||||||
|
import { useScreenReaderAnnounce } from '@/hooks/useScreenReaderAnnounce'
|
||||||
import { useMemo, useState } from 'react'
|
import { useMemo, useState } from 'react'
|
||||||
import { appendShortcutLabel } from '@/features/shortcuts/utils'
|
import { appendShortcutLabel } from '@/features/shortcuts/utils'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
@@ -87,10 +88,24 @@ export const ToggleDevice = <T extends ToggleSource>({
|
|||||||
const deviceIcons = useDeviceIcons(kind)
|
const deviceIcons = useDeviceIcons(kind)
|
||||||
const cannotUseDevice = useCannotUseDevice(kind)
|
const cannotUseDevice = useCannotUseDevice(kind)
|
||||||
const deviceShortcut = useDeviceShortcut(kind)
|
const deviceShortcut = useDeviceShortcut(kind)
|
||||||
|
const announce = useScreenReaderAnnounce()
|
||||||
|
|
||||||
useRegisterKeyboardShortcut({
|
useRegisterKeyboardShortcut({
|
||||||
id: deviceShortcut?.id,
|
id: deviceShortcut?.id,
|
||||||
handler: async () => await toggle(),
|
handler: async () => {
|
||||||
|
const nextState = !enabled
|
||||||
|
try {
|
||||||
|
const didChange = await toggle(nextState)
|
||||||
|
if (didChange === false) return
|
||||||
|
|
||||||
|
const message = t(nextState ? 'turnedOn' : 'turnedOff', {
|
||||||
|
keyPrefix: `selectDevice.${kind}`,
|
||||||
|
})
|
||||||
|
announce(message, 'assertive')
|
||||||
|
} catch {
|
||||||
|
// no announce
|
||||||
|
}
|
||||||
|
},
|
||||||
isDisabled: cannotUseDevice,
|
isDisabled: cannotUseDevice,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import { RiEmotionLine } from '@remixicon/react'
|
import { RiEmotionLine } from '@remixicon/react'
|
||||||
import { useState, useRef, useEffect } from 'react'
|
import { useState, useRef } from 'react'
|
||||||
import { css } from '@/styled-system/css'
|
import { css } from '@/styled-system/css'
|
||||||
import { useRoomContext } from '@livekit/components-react'
|
import { useRoomContext } from '@livekit/components-react'
|
||||||
import { ToggleButton, Button } from '@/primitives'
|
import { ToggleButton, Button } from '@/primitives'
|
||||||
@@ -12,7 +12,12 @@ import {
|
|||||||
} from '@/features/rooms/livekit/components/ReactionPortal'
|
} from '@/features/rooms/livekit/components/ReactionPortal'
|
||||||
import { getEmojiLabel } from '@/features/rooms/livekit/utils/reactionUtils'
|
import { getEmojiLabel } from '@/features/rooms/livekit/utils/reactionUtils'
|
||||||
import { useRegisterKeyboardShortcut } from '@/features/shortcuts/useRegisterKeyboardShortcut'
|
import { useRegisterKeyboardShortcut } from '@/features/shortcuts/useRegisterKeyboardShortcut'
|
||||||
import { Toolbar as RACToolbar } from 'react-aria-components'
|
import {
|
||||||
|
Popover as RACPopover,
|
||||||
|
Dialog,
|
||||||
|
DialogTrigger,
|
||||||
|
} from 'react-aria-components'
|
||||||
|
import { FocusScope } from '@react-aria/focus'
|
||||||
import { Participant } from 'livekit-client'
|
import { Participant } from 'livekit-client'
|
||||||
import useRateLimiter from '@/hooks/useRateLimiter'
|
import useRateLimiter from '@/hooks/useRateLimiter'
|
||||||
|
|
||||||
@@ -40,11 +45,11 @@ export const ReactionsToggle = () => {
|
|||||||
const instanceIdRef = useRef(0)
|
const instanceIdRef = useRef(0)
|
||||||
const room = useRoomContext()
|
const room = useRoomContext()
|
||||||
|
|
||||||
const [isVisible, setIsVisible] = useState(false)
|
const [isOpen, setIsOpen] = useState(false)
|
||||||
|
|
||||||
useRegisterKeyboardShortcut({
|
useRegisterKeyboardShortcut({
|
||||||
id: 'reaction',
|
id: 'reaction',
|
||||||
handler: () => setIsVisible((prev) => !prev),
|
handler: () => setIsOpen((prev) => !prev),
|
||||||
})
|
})
|
||||||
|
|
||||||
const sendReaction = async (emoji: string) => {
|
const sendReaction = async (emoji: string) => {
|
||||||
@@ -79,100 +84,76 @@ export const ReactionsToggle = () => {
|
|||||||
windowMs: 1000,
|
windowMs: 1000,
|
||||||
})
|
})
|
||||||
|
|
||||||
// Custom animation implementation for the emoji toolbar
|
|
||||||
// Could not use a menu and its animation, because a menu would make the toolbar inaccessible by keyboard
|
|
||||||
// animation isn't perfect
|
|
||||||
const [isRendered, setIsRendered] = useState(isVisible)
|
|
||||||
const [opacity, setOpacity] = useState(isVisible ? 1 : 0)
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (isVisible) {
|
|
||||||
// Show: first render, then animate in
|
|
||||||
setIsRendered(true)
|
|
||||||
// Need to delay setting opacity to ensure CSS transition works
|
|
||||||
// (using requestAnimationFrame to ensure DOM has updated)
|
|
||||||
requestAnimationFrame(() => {
|
|
||||||
requestAnimationFrame(() => {
|
|
||||||
setOpacity(1)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
} else if (isRendered) {
|
|
||||||
// Hide: first animate out, then unrender
|
|
||||||
setOpacity(0)
|
|
||||||
|
|
||||||
// Wait for animation to complete before removing from DOM
|
|
||||||
const timer = setTimeout(() => {
|
|
||||||
setIsRendered(false)
|
|
||||||
}, 200) // Match this to your animation duration
|
|
||||||
return () => clearTimeout(timer)
|
|
||||||
}
|
|
||||||
}, [isVisible, isRendered])
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div
|
<div className={css({ position: 'relative' })}>
|
||||||
className={css({
|
<DialogTrigger isOpen={isOpen} onOpenChange={setIsOpen}>
|
||||||
position: 'relative',
|
<ToggleButton
|
||||||
})}
|
square
|
||||||
>
|
variant="primaryDark"
|
||||||
<ToggleButton
|
aria-label={t('button')}
|
||||||
square
|
tooltip={t('button')}
|
||||||
variant="primaryDark"
|
isSelected={isOpen}
|
||||||
aria-label={t('button')}
|
onChange={setIsOpen}
|
||||||
tooltip={t('button')}
|
>
|
||||||
onPress={() => setIsVisible(!isVisible)}
|
<RiEmotionLine />
|
||||||
>
|
</ToggleButton>
|
||||||
<RiEmotionLine />
|
<RACPopover
|
||||||
</ToggleButton>
|
placement="top"
|
||||||
{isRendered && (
|
offset={8}
|
||||||
<div
|
isNonModal
|
||||||
|
shouldCloseOnInteractOutside={() => false}
|
||||||
className={css({
|
className={css({
|
||||||
position: 'absolute',
|
|
||||||
top: -63,
|
|
||||||
left: -162,
|
|
||||||
borderRadius: '8px',
|
borderRadius: '8px',
|
||||||
padding: '0.35rem',
|
padding: '0.35rem',
|
||||||
backgroundColor: 'primaryDark.50',
|
backgroundColor: 'primaryDark.50',
|
||||||
opacity: opacity,
|
'&[data-entering]': {
|
||||||
transition: 'opacity 0.2s ease',
|
animation: 'fade 200ms ease',
|
||||||
|
},
|
||||||
|
'&[data-exiting]': {
|
||||||
|
animation: 'fade 200ms ease-in reverse',
|
||||||
|
},
|
||||||
})}
|
})}
|
||||||
onTransitionEnd={() => {
|
|
||||||
if (!isVisible) {
|
|
||||||
setIsRendered(false)
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<RACToolbar
|
<Dialog className={css({ outline: 'none' })}>
|
||||||
className={css({
|
{/* eslint-disable-next-line jsx-a11y/no-autofocus -- FocusScope autoFocus is programmatic focus for overlays, not the HTML autofocus attribute */}
|
||||||
display: 'flex',
|
<FocusScope contain autoFocus restoreFocus>
|
||||||
gap: '0.5rem',
|
<div
|
||||||
})}
|
role="toolbar"
|
||||||
>
|
aria-orientation="horizontal"
|
||||||
{Object.values(Emoji).map((emoji, index) => (
|
aria-label={t('button')}
|
||||||
<Button
|
className={css({
|
||||||
key={index}
|
display: 'flex',
|
||||||
onPress={() => debouncedSendReaction(emoji)}
|
gap: '0.5rem',
|
||||||
aria-label={t('send', { emoji: getEmojiLabel(emoji, t) })}
|
})}
|
||||||
variant="primaryTextDark"
|
|
||||||
size="sm"
|
|
||||||
square
|
|
||||||
data-attr={`send-reaction-${emoji}`}
|
|
||||||
>
|
>
|
||||||
<img
|
{Object.values(Emoji).map((emoji, index) => (
|
||||||
src={`/assets/reactions/${emoji}.png`}
|
<Button
|
||||||
alt=""
|
key={index}
|
||||||
className={css({
|
onPress={() => debouncedSendReaction(emoji)}
|
||||||
minHeight: '28px',
|
aria-label={t('send', { emoji: getEmojiLabel(emoji, t) })}
|
||||||
minWidth: '28px',
|
variant="primaryTextDark"
|
||||||
pointerEvents: 'none',
|
size="sm"
|
||||||
userSelect: 'none',
|
square
|
||||||
})}
|
data-attr={`send-reaction-${emoji}`}
|
||||||
/>
|
>
|
||||||
</Button>
|
<img
|
||||||
))}
|
src={`/assets/reactions/${emoji}.png`}
|
||||||
</RACToolbar>
|
alt=""
|
||||||
</div>
|
className={css({
|
||||||
)}
|
width: '28px',
|
||||||
|
height: '28px',
|
||||||
|
pointerEvents: 'none',
|
||||||
|
userSelect: 'none',
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</FocusScope>
|
||||||
|
</Dialog>
|
||||||
|
</RACPopover>
|
||||||
|
</DialogTrigger>
|
||||||
</div>
|
</div>
|
||||||
<ReactionPortals reactions={reactions} />
|
<ReactionPortals reactions={reactions} />
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { useTranslation } from 'react-i18next'
|
|||||||
import { useSidePanel } from '../../hooks/useSidePanel'
|
import { useSidePanel } from '../../hooks/useSidePanel'
|
||||||
import { css } from '@/styled-system/css'
|
import { css } from '@/styled-system/css'
|
||||||
import { ToggleButtonProps } from '@/primitives/ToggleButton'
|
import { ToggleButtonProps } from '@/primitives/ToggleButton'
|
||||||
|
import { useRegisterKeyboardShortcut } from '@/features/shortcuts/useRegisterKeyboardShortcut'
|
||||||
|
|
||||||
export const ToolsToggle = ({
|
export const ToolsToggle = ({
|
||||||
variant = 'primaryTextDark',
|
variant = 'primaryTextDark',
|
||||||
@@ -15,6 +16,11 @@ export const ToolsToggle = ({
|
|||||||
const { isToolsOpen, toggleTools } = useSidePanel()
|
const { isToolsOpen, toggleTools } = useSidePanel()
|
||||||
const tooltipLabel = isToolsOpen ? 'open' : 'closed'
|
const tooltipLabel = isToolsOpen ? 'open' : 'closed'
|
||||||
|
|
||||||
|
useRegisterKeyboardShortcut({
|
||||||
|
id: 'recording',
|
||||||
|
handler: toggleTools,
|
||||||
|
})
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={css({
|
className={css({
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import { ChatEntry } from '../components/chat/Entry'
|
|||||||
import { useSidePanel } from '../hooks/useSidePanel'
|
import { useSidePanel } from '../hooks/useSidePanel'
|
||||||
import { LocalParticipant, RemoteParticipant, RoomEvent } from 'livekit-client'
|
import { LocalParticipant, RemoteParticipant, RoomEvent } from 'livekit-client'
|
||||||
import { css } from '@/styled-system/css'
|
import { css } from '@/styled-system/css'
|
||||||
|
import { useRestoreFocus } from '@/hooks/useRestoreFocus'
|
||||||
|
|
||||||
export interface ChatProps
|
export interface ChatProps
|
||||||
extends React.HTMLAttributes<HTMLDivElement>, ChatOptions {}
|
extends React.HTMLAttributes<HTMLDivElement>, ChatOptions {}
|
||||||
@@ -35,12 +36,18 @@ export function Chat({ ...props }: ChatProps) {
|
|||||||
const { isChatOpen } = useSidePanel()
|
const { isChatOpen } = useSidePanel()
|
||||||
const chatSnap = useSnapshot(chatStore)
|
const chatSnap = useSnapshot(chatStore)
|
||||||
|
|
||||||
React.useEffect(() => {
|
// Keep track of the element that opened the chat so we can restore focus
|
||||||
if (!isChatOpen) return
|
// when the chat panel is closed.
|
||||||
requestAnimationFrame(() => {
|
useRestoreFocus(isChatOpen, {
|
||||||
inputRef.current?.focus({ preventScroll: true })
|
// Avoid layout "jump" during the side panel slide-in animation.
|
||||||
})
|
// Focusing can trigger scroll into view; preventScroll keeps the animation smooth.
|
||||||
}, [isChatOpen])
|
onOpened: () => {
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
inputRef.current?.focus({ preventScroll: true })
|
||||||
|
})
|
||||||
|
},
|
||||||
|
preventScroll: true,
|
||||||
|
})
|
||||||
|
|
||||||
// Use useParticipants hook to trigger a re-render when the participant list changes.
|
// Use useParticipants hook to trigger a re-render when the participant list changes.
|
||||||
const participants = useParticipants()
|
const participants = useParticipants()
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import { StartMediaButton } from '../../components/controls/StartMediaButton'
|
|||||||
import { MoreOptions } from './MoreOptions'
|
import { MoreOptions } from './MoreOptions'
|
||||||
import { useRef } from 'react'
|
import { useRef } from 'react'
|
||||||
import { useRegisterKeyboardShortcut } from '@/features/shortcuts/useRegisterKeyboardShortcut'
|
import { useRegisterKeyboardShortcut } from '@/features/shortcuts/useRegisterKeyboardShortcut'
|
||||||
|
import { useFullScreen } from '../../hooks/useFullScreen'
|
||||||
import { VideoDeviceControl } from '../../components/controls/Device/VideoDeviceControl'
|
import { VideoDeviceControl } from '../../components/controls/Device/VideoDeviceControl'
|
||||||
import { AudioDevicesControl } from '../../components/controls/Device/AudioDevicesControl'
|
import { AudioDevicesControl } from '../../components/controls/Device/AudioDevicesControl'
|
||||||
|
|
||||||
@@ -21,6 +22,8 @@ export function DesktopControlBar({
|
|||||||
const browserSupportsScreenSharing = supportsScreenSharing()
|
const browserSupportsScreenSharing = supportsScreenSharing()
|
||||||
const desktopControlBarEl = useRef<HTMLDivElement>(null)
|
const desktopControlBarEl = useRef<HTMLDivElement>(null)
|
||||||
|
|
||||||
|
const { toggleFullScreen, isFullscreenAvailable } = useFullScreen({})
|
||||||
|
|
||||||
useRegisterKeyboardShortcut({
|
useRegisterKeyboardShortcut({
|
||||||
id: 'focus-toolbar',
|
id: 'focus-toolbar',
|
||||||
handler: () => {
|
handler: () => {
|
||||||
@@ -32,6 +35,13 @@ export function DesktopControlBar({
|
|||||||
firstButton?.focus()
|
firstButton?.focus()
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
useRegisterKeyboardShortcut({
|
||||||
|
id: 'fullscreen',
|
||||||
|
handler: toggleFullScreen,
|
||||||
|
isDisabled: !isFullscreenAvailable,
|
||||||
|
})
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
ref={desktopControlBarEl}
|
ref={desktopControlBarEl}
|
||||||
|
|||||||
@@ -31,6 +31,9 @@ import { RecordingProvider } from '@/features/recording'
|
|||||||
import { ScreenShareErrorModal } from '../components/ScreenShareErrorModal'
|
import { ScreenShareErrorModal } from '../components/ScreenShareErrorModal'
|
||||||
import { useConnectionObserver } from '../hooks/useConnectionObserver'
|
import { useConnectionObserver } from '../hooks/useConnectionObserver'
|
||||||
import { useNoiseReduction } from '../hooks/useNoiseReduction'
|
import { useNoiseReduction } from '../hooks/useNoiseReduction'
|
||||||
|
import { useRegisterKeyboardShortcut } from '@/features/shortcuts/useRegisterKeyboardShortcut'
|
||||||
|
import { useSettingsDialog } from '@/features/settings'
|
||||||
|
import { SettingsDialogExtendedKey } from '@/features/settings/type'
|
||||||
import { useVideoResolutionSubscription } from '../hooks/useVideoResolutionSubscription'
|
import { useVideoResolutionSubscription } from '../hooks/useVideoResolutionSubscription'
|
||||||
import { SettingsDialogProvider } from '@/features/settings/components/SettingsDialogProvider'
|
import { SettingsDialogProvider } from '@/features/settings/components/SettingsDialogProvider'
|
||||||
import { useSubtitles } from '@/features/subtitle/hooks/useSubtitles'
|
import { useSubtitles } from '@/features/subtitle/hooks/useSubtitles'
|
||||||
@@ -97,6 +100,7 @@ export function VideoConference({ ...props }: VideoConferenceProps) {
|
|||||||
const { t: tRooms } = useTranslation('rooms')
|
const { t: tRooms } = useTranslation('rooms')
|
||||||
const room = useRoomContext()
|
const room = useRoomContext()
|
||||||
const announce = useScreenReaderAnnounce()
|
const announce = useScreenReaderAnnounce()
|
||||||
|
const { toggleSettingsDialog } = useSettingsDialog()
|
||||||
|
|
||||||
const getAnnouncementName = useCallback(
|
const getAnnouncementName = useCallback(
|
||||||
(participant?: Participant | null) => {
|
(participant?: Participant | null) => {
|
||||||
@@ -111,6 +115,13 @@ export function VideoConference({ ...props }: VideoConferenceProps) {
|
|||||||
useConnectionObserver()
|
useConnectionObserver()
|
||||||
useVideoResolutionSubscription()
|
useVideoResolutionSubscription()
|
||||||
|
|
||||||
|
useRegisterKeyboardShortcut({
|
||||||
|
id: 'open-shortcuts',
|
||||||
|
handler: useCallback(() => {
|
||||||
|
toggleSettingsDialog(SettingsDialogExtendedKey.SHORTCUTS)
|
||||||
|
}, [toggleSettingsDialog]),
|
||||||
|
})
|
||||||
|
|
||||||
const tracks = useTracks(
|
const tracks = useTracks(
|
||||||
[
|
[
|
||||||
{ source: Track.Source.Camera, withPlaceholder: true },
|
{ source: Track.Source.Camera, withPlaceholder: true },
|
||||||
|
|||||||
@@ -4,8 +4,10 @@ export const roomIdPattern = '[a-z]{3}-[a-z]{4}-[a-z]{3}'
|
|||||||
export const flexibleRoomIdPattern =
|
export const flexibleRoomIdPattern =
|
||||||
'(?:[a-zA-Z0-9]{3}-?[a-zA-Z0-9]{4}-?[a-zA-Z0-9]{3})'
|
'(?:[a-zA-Z0-9]{3}-?[a-zA-Z0-9]{4}-?[a-zA-Z0-9]{3})'
|
||||||
|
|
||||||
|
const roomRegex = new RegExp(`^${roomIdPattern}$`)
|
||||||
|
|
||||||
export const isRoomValid = (roomIdOrUrl: string) =>
|
export const isRoomValid = (roomIdOrUrl: string) =>
|
||||||
new RegExp(`^${roomIdPattern}$`).test(roomIdOrUrl) ||
|
roomRegex.test(roomIdOrUrl) ||
|
||||||
new RegExp(`^${window.location.origin}/${roomIdPattern}$`).test(roomIdOrUrl)
|
new RegExp(`^${window.location.origin}/${roomIdPattern}$`).test(roomIdOrUrl)
|
||||||
|
|
||||||
export const normalizeRoomId = (roomId: string) => {
|
export const normalizeRoomId = (roomId: string) => {
|
||||||
|
|||||||
@@ -10,7 +10,10 @@ const callbackIdHandler = new CallbackIdHandler()
|
|||||||
const popupWindow = new PopupWindow()
|
const popupWindow = new PopupWindow()
|
||||||
|
|
||||||
export const CreatePopup = () => {
|
export const CreatePopup = () => {
|
||||||
const { isLoggedIn } = useUser({ fetchUserOptions: { attemptSilent: false } })
|
const { isLoggedIn, user } = useUser({
|
||||||
|
fetchUserOptions: { attemptSilent: false },
|
||||||
|
})
|
||||||
|
const canCreate = user?.can_create === true
|
||||||
const { mutateAsync: createRoom } = useCreateRoom()
|
const { mutateAsync: createRoom } = useCreateRoom()
|
||||||
|
|
||||||
const callbackId = useMemo(() => callbackIdHandler.getOrCreate(), [])
|
const callbackId = useMemo(() => callbackIdHandler.getOrCreate(), [])
|
||||||
@@ -55,10 +58,10 @@ export const CreatePopup = () => {
|
|||||||
console.error('Failed to create meeting room:', error)
|
console.error('Failed to create meeting room:', error)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (isLoggedIn && callbackId) {
|
if (isLoggedIn && canCreate && callbackId) {
|
||||||
createMeetingRoom()
|
createMeetingRoom()
|
||||||
}
|
}
|
||||||
}, [isLoggedIn, callbackId, createRoom])
|
}, [isLoggedIn, canCreate, callbackId, createRoom])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
RiSpeakerLine,
|
RiSpeakerLine,
|
||||||
RiVideoOnLine,
|
RiVideoOnLine,
|
||||||
RiEyeLine,
|
RiEyeLine,
|
||||||
|
RiKeyboardBoxLine,
|
||||||
} from '@remixicon/react'
|
} from '@remixicon/react'
|
||||||
import { AccountTab } from './tabs/AccountTab'
|
import { AccountTab } from './tabs/AccountTab'
|
||||||
import { NotificationsTab } from './tabs/NotificationsTab'
|
import { NotificationsTab } from './tabs/NotificationsTab'
|
||||||
@@ -19,11 +20,12 @@ import { GeneralTab } from './tabs/GeneralTab'
|
|||||||
import { AudioTab } from './tabs/AudioTab'
|
import { AudioTab } from './tabs/AudioTab'
|
||||||
import { VideoTab } from './tabs/VideoTab'
|
import { VideoTab } from './tabs/VideoTab'
|
||||||
import { TranscriptionTab } from './tabs/TranscriptionTab'
|
import { TranscriptionTab } from './tabs/TranscriptionTab'
|
||||||
|
import { ShortcutTab } from './tabs/ShortcutTab'
|
||||||
import { useRef } from 'react'
|
import { useRef } from 'react'
|
||||||
import { useMediaQuery } from '@/features/rooms/livekit/hooks/useMediaQuery'
|
import { useMediaQuery } from '@/features/rooms/livekit/hooks/useMediaQuery'
|
||||||
import { SettingsDialogExtendedKey } from '@/features/settings/type'
|
import { SettingsDialogExtendedKey } from '@/features/settings/type'
|
||||||
import { useIsAdminOrOwner } from '@/features/rooms/livekit/hooks/useIsAdminOrOwner'
|
import { useIsAdminOrOwner } from '@/features/rooms/livekit/hooks/useIsAdminOrOwner'
|
||||||
import AccessibilityTab from './tabs/AccessibilityTab'
|
import { AccessibilityTab } from './tabs/AccessibilityTab'
|
||||||
|
|
||||||
const tabsStyle = css({
|
const tabsStyle = css({
|
||||||
maxHeight: '40.625rem', // fixme size copied from meet settings modal
|
maxHeight: '40.625rem', // fixme size copied from meet settings modal
|
||||||
@@ -39,6 +41,7 @@ const tabListContainerStyle = css({
|
|||||||
flexDirection: 'column',
|
flexDirection: 'column',
|
||||||
borderRight: '1px solid lightGray', // fixme poor color management
|
borderRight: '1px solid lightGray', // fixme poor color management
|
||||||
paddingY: '1rem',
|
paddingY: '1rem',
|
||||||
|
paddingLeft: '0.2rem',
|
||||||
paddingRight: '1.5rem',
|
paddingRight: '1.5rem',
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -107,6 +110,10 @@ export const SettingsDialogExtended = (props: SettingsDialogExtended) => {
|
|||||||
{isWideScreen &&
|
{isWideScreen &&
|
||||||
t(`tabs.${SettingsDialogExtendedKey.NOTIFICATIONS}`)}
|
t(`tabs.${SettingsDialogExtendedKey.NOTIFICATIONS}`)}
|
||||||
</Tab>
|
</Tab>
|
||||||
|
<Tab icon highlight id={SettingsDialogExtendedKey.SHORTCUTS}>
|
||||||
|
<RiKeyboardBoxLine />
|
||||||
|
{isWideScreen && t(`tabs.${SettingsDialogExtendedKey.SHORTCUTS}`)}
|
||||||
|
</Tab>
|
||||||
{isAdminOrOwner && (
|
{isAdminOrOwner && (
|
||||||
<Tab icon highlight id={SettingsDialogExtendedKey.TRANSCRIPTION}>
|
<Tab icon highlight id={SettingsDialogExtendedKey.TRANSCRIPTION}>
|
||||||
<Icon type="symbols" name="speech_to_text" />
|
<Icon type="symbols" name="speech_to_text" />
|
||||||
@@ -130,6 +137,7 @@ export const SettingsDialogExtended = (props: SettingsDialogExtended) => {
|
|||||||
<VideoTab id={SettingsDialogExtendedKey.VIDEO} />
|
<VideoTab id={SettingsDialogExtendedKey.VIDEO} />
|
||||||
<GeneralTab id={SettingsDialogExtendedKey.GENERAL} />
|
<GeneralTab id={SettingsDialogExtendedKey.GENERAL} />
|
||||||
<NotificationsTab id={SettingsDialogExtendedKey.NOTIFICATIONS} />
|
<NotificationsTab id={SettingsDialogExtendedKey.NOTIFICATIONS} />
|
||||||
|
<ShortcutTab id={SettingsDialogExtendedKey.SHORTCUTS} />
|
||||||
{/* Transcription tab won't be accessible if the tab is not active in the tab list */}
|
{/* Transcription tab won't be accessible if the tab is not active in the tab list */}
|
||||||
<TranscriptionTab id={SettingsDialogExtendedKey.TRANSCRIPTION} />
|
<TranscriptionTab id={SettingsDialogExtendedKey.TRANSCRIPTION} />
|
||||||
<AccessibilityTab id={SettingsDialogExtendedKey.ACCESSIBILITY} />
|
<AccessibilityTab id={SettingsDialogExtendedKey.ACCESSIBILITY} />
|
||||||
|
|||||||
@@ -36,5 +36,3 @@ export const AccessibilityTab = ({ id }: AccessibilityTabProps) => {
|
|||||||
</TabPanel>
|
</TabPanel>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export default AccessibilityTab
|
|
||||||
|
|||||||
@@ -0,0 +1,55 @@
|
|||||||
|
import { shortcutCatalog } from '@/features/shortcuts/catalog'
|
||||||
|
import { ShortcutRow } from '@/features/shortcuts/components/ShortcutRow'
|
||||||
|
import { css } from '@/styled-system/css'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import { TabPanel, type TabPanelProps } from '@/primitives/Tabs'
|
||||||
|
|
||||||
|
const tableStyle = css({
|
||||||
|
width: '100%',
|
||||||
|
borderCollapse: 'collapse',
|
||||||
|
overflowY: 'auto',
|
||||||
|
'& caption': {
|
||||||
|
fontWeight: 'bold',
|
||||||
|
marginBottom: '0.75rem',
|
||||||
|
textAlign: 'left',
|
||||||
|
},
|
||||||
|
'& th, & td': {
|
||||||
|
padding: '0.65rem 0',
|
||||||
|
textAlign: 'left',
|
||||||
|
fontWeight: 'normal',
|
||||||
|
},
|
||||||
|
'& tbody tr': {
|
||||||
|
borderBottom: '1px solid rgba(255,255,255,0.08)',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
export const ShortcutTab = ({ id }: Pick<TabPanelProps, 'id'>) => {
|
||||||
|
const { t } = useTranslation(['settings', 'rooms'])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TabPanel
|
||||||
|
id={id}
|
||||||
|
padding="md"
|
||||||
|
flex
|
||||||
|
className={css({
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<table className={tableStyle}>
|
||||||
|
<caption>{t('shortcuts.listLabel')}</caption>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th scope="col">{t('shortcuts.columnAction')}</th>
|
||||||
|
<th scope="col">{t('shortcuts.columnShortcut')}</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{shortcutCatalog.map((item) => (
|
||||||
|
<ShortcutRow key={item?.id} descriptor={item} />
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</TabPanel>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -4,7 +4,7 @@ import { TabPanel, TabPanelProps } from '@/primitives/Tabs'
|
|||||||
import { useMediaDeviceSelect, useRoomContext } from '@livekit/components-react'
|
import { useMediaDeviceSelect, useRoomContext } from '@livekit/components-react'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import { usePersistentUserChoices } from '@/features/rooms/livekit/hooks/usePersistentUserChoices'
|
import { usePersistentUserChoices } from '@/features/rooms/livekit/hooks/usePersistentUserChoices'
|
||||||
import { useCallback, useEffect, useState } from 'react'
|
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||||
import { css } from '@/styled-system/css'
|
import { css } from '@/styled-system/css'
|
||||||
import {
|
import {
|
||||||
createLocalVideoTrack,
|
createLocalVideoTrack,
|
||||||
@@ -22,6 +22,8 @@ export type VideoTabProps = Pick<DialogProps, 'onOpenChange'> &
|
|||||||
|
|
||||||
type DeviceItems = Array<{ value: string; label: string }>
|
type DeviceItems = Array<{ value: string; label: string }>
|
||||||
|
|
||||||
|
const EMPTY_PROPS = {}
|
||||||
|
|
||||||
export const VideoTab = ({ id }: VideoTabProps) => {
|
export const VideoTab = ({ id }: VideoTabProps) => {
|
||||||
const { t } = useTranslation('settings', { keyPrefix: 'video' })
|
const { t } = useTranslation('settings', { keyPrefix: 'video' })
|
||||||
const { localParticipant, remoteParticipants } = useRoomContext()
|
const { localParticipant, remoteParticipants } = useRoomContext()
|
||||||
@@ -59,7 +61,7 @@ export const VideoTab = ({ id }: VideoTabProps) => {
|
|||||||
const isCamEnabled = devicesIn?.length > 0
|
const isCamEnabled = devicesIn?.length > 0
|
||||||
|
|
||||||
const disabledProps = isCamEnabled
|
const disabledProps = isCamEnabled
|
||||||
? {}
|
? EMPTY_PROPS
|
||||||
: {
|
: {
|
||||||
placeholder: t('permissionsRequired'),
|
placeholder: t('permissionsRequired'),
|
||||||
isDisabled: true,
|
isDisabled: true,
|
||||||
@@ -117,6 +119,40 @@ export const VideoTab = ({ id }: VideoTabProps) => {
|
|||||||
}
|
}
|
||||||
}, [videoDeviceId, videoElement])
|
}, [videoDeviceId, videoElement])
|
||||||
|
|
||||||
|
const resolutionItems = useMemo(() => {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
value: 'h720',
|
||||||
|
label: `${t('resolution.publish.items.high')} (720p)`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: 'h360',
|
||||||
|
label: `${t('resolution.publish.items.medium')} (360p)`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: 'h180',
|
||||||
|
label: `${t('resolution.publish.items.low')} (180p)`,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}, [t])
|
||||||
|
|
||||||
|
const videoQualityItems = useMemo(() => {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
value: VideoQuality.HIGH.toString(),
|
||||||
|
label: t('resolution.subscribe.items.high'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: VideoQuality.MEDIUM.toString(),
|
||||||
|
label: t('resolution.subscribe.items.medium'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: VideoQuality.LOW.toString(),
|
||||||
|
label: t('resolution.subscribe.items.low'),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}, [t])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TabPanel padding={'md'} flex id={id}>
|
<TabPanel padding={'md'} flex id={id}>
|
||||||
<RowWrapper heading={t('camera.heading')}>
|
<RowWrapper heading={t('camera.heading')}>
|
||||||
@@ -178,20 +214,7 @@ export const VideoTab = ({ id }: VideoTabProps) => {
|
|||||||
<Field
|
<Field
|
||||||
type="select"
|
type="select"
|
||||||
label={t('resolution.publish.label')}
|
label={t('resolution.publish.label')}
|
||||||
items={[
|
items={resolutionItems}
|
||||||
{
|
|
||||||
value: 'h720',
|
|
||||||
label: `${t('resolution.publish.items.high')} (720p)`,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
value: 'h360',
|
|
||||||
label: `${t('resolution.publish.items.medium')} (360p)`,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
value: 'h180',
|
|
||||||
label: `${t('resolution.publish.items.low')} (180p)`,
|
|
||||||
},
|
|
||||||
]}
|
|
||||||
selectedKey={videoPublishResolution}
|
selectedKey={videoPublishResolution}
|
||||||
onSelectionChange={async (key) => {
|
onSelectionChange={async (key) => {
|
||||||
await handleVideoResolutionChange(key as VideoResolution)
|
await handleVideoResolutionChange(key as VideoResolution)
|
||||||
@@ -206,20 +229,7 @@ export const VideoTab = ({ id }: VideoTabProps) => {
|
|||||||
<Field
|
<Field
|
||||||
type="select"
|
type="select"
|
||||||
label={t('resolution.subscribe.label')}
|
label={t('resolution.subscribe.label')}
|
||||||
items={[
|
items={videoQualityItems}
|
||||||
{
|
|
||||||
value: VideoQuality.HIGH.toString(),
|
|
||||||
label: t('resolution.subscribe.items.high'),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
value: VideoQuality.MEDIUM.toString(),
|
|
||||||
label: t('resolution.subscribe.items.medium'),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
value: VideoQuality.LOW.toString(),
|
|
||||||
label: t('resolution.subscribe.items.low'),
|
|
||||||
},
|
|
||||||
]}
|
|
||||||
selectedKey={videoSubscribeQuality?.toString()}
|
selectedKey={videoSubscribeQuality?.toString()}
|
||||||
onSelectionChange={(key) => {
|
onSelectionChange={(key) => {
|
||||||
if (key == undefined) return
|
if (key == undefined) return
|
||||||
|
|||||||
@@ -14,7 +14,25 @@ export const useSettingsDialog = () => {
|
|||||||
settingsStore.areSettingsOpen = true
|
settingsStore.areSettingsOpen = true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const closeSettingsDialog = () => {
|
||||||
|
settingsStore.areSettingsOpen = false
|
||||||
|
}
|
||||||
|
|
||||||
|
const toggleSettingsDialog = (
|
||||||
|
defaultSelectedTab?: SettingsDialogExtendedKey
|
||||||
|
) => {
|
||||||
|
if (areSettingsOpen) {
|
||||||
|
closeSettingsDialog()
|
||||||
|
} else {
|
||||||
|
if (defaultSelectedTab)
|
||||||
|
settingsStore.defaultSelectedTab = defaultSelectedTab
|
||||||
|
settingsStore.areSettingsOpen = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
openSettingsDialog,
|
openSettingsDialog,
|
||||||
|
closeSettingsDialog,
|
||||||
|
toggleSettingsDialog,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,5 +5,6 @@ export enum SettingsDialogExtendedKey {
|
|||||||
GENERAL = 'general',
|
GENERAL = 'general',
|
||||||
NOTIFICATIONS = 'notifications',
|
NOTIFICATIONS = 'notifications',
|
||||||
TRANSCRIPTION = 'transcription',
|
TRANSCRIPTION = 'transcription',
|
||||||
|
SHORTCUTS = 'shortcuts',
|
||||||
ACCESSIBILITY = 'accessibility',
|
ACCESSIBILITY = 'accessibility',
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { Shortcut } from './types'
|
|||||||
export type ShortcutCategory = 'navigation' | 'media' | 'interaction'
|
export type ShortcutCategory = 'navigation' | 'media' | 'interaction'
|
||||||
|
|
||||||
export type ShortcutId =
|
export type ShortcutId =
|
||||||
|
| 'open-shortcuts'
|
||||||
| 'focus-toolbar'
|
| 'focus-toolbar'
|
||||||
| 'toggle-microphone'
|
| 'toggle-microphone'
|
||||||
| 'toggle-camera'
|
| 'toggle-camera'
|
||||||
@@ -29,6 +30,11 @@ export type ShortcutDescriptor = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const shortcutCatalog: ShortcutDescriptor[] = [
|
export const shortcutCatalog: ShortcutDescriptor[] = [
|
||||||
|
{
|
||||||
|
id: 'open-shortcuts',
|
||||||
|
category: 'navigation',
|
||||||
|
shortcut: { key: '/', ctrlKey: true, shiftKey: true },
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: 'focus-toolbar',
|
id: 'focus-toolbar',
|
||||||
category: 'navigation',
|
category: 'navigation',
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import React from 'react'
|
||||||
|
import { css, cx } from '@/styled-system/css'
|
||||||
|
|
||||||
|
type ShortcutBadgeProps = {
|
||||||
|
visualLabel: string
|
||||||
|
srLabel?: string
|
||||||
|
className?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const badgeStyle = css({
|
||||||
|
fontFamily: 'monospace',
|
||||||
|
backgroundColor: 'rgba(255,255,255,0.12)',
|
||||||
|
paddingInline: '0.4rem',
|
||||||
|
paddingBlock: '0.2rem',
|
||||||
|
borderRadius: '6px',
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
minWidth: '5.5rem',
|
||||||
|
textAlign: 'center',
|
||||||
|
})
|
||||||
|
|
||||||
|
export const ShortcutBadge: React.FC<ShortcutBadgeProps> = ({
|
||||||
|
visualLabel,
|
||||||
|
srLabel,
|
||||||
|
className,
|
||||||
|
}) => {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<kbd className={cx(badgeStyle, className)} aria-hidden="true">
|
||||||
|
{visualLabel}
|
||||||
|
</kbd>
|
||||||
|
{srLabel && <span className="sr-only">{srLabel}</span>}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import React from 'react'
|
||||||
|
import { css } from '@/styled-system/css'
|
||||||
|
import { text } from '@/primitives/Text'
|
||||||
|
import { ShortcutDescriptor } from '../catalog'
|
||||||
|
import { ShortcutBadge } from './ShortcutBadge'
|
||||||
|
import { useShortcutFormatting } from '../hooks/useShortcutFormatting'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
|
||||||
|
type ShortcutRowProps = {
|
||||||
|
descriptor: ShortcutDescriptor
|
||||||
|
}
|
||||||
|
|
||||||
|
const shortcutCellStyle = css({
|
||||||
|
textAlign: 'right',
|
||||||
|
})
|
||||||
|
|
||||||
|
export const ShortcutRow: React.FC<ShortcutRowProps> = ({ descriptor }) => {
|
||||||
|
const { t } = useTranslation('rooms', { keyPrefix: 'shortcutsPanel' })
|
||||||
|
const { formatVisual, formatForSR } = useShortcutFormatting()
|
||||||
|
|
||||||
|
const visualShortcut = formatVisual(
|
||||||
|
descriptor.shortcut,
|
||||||
|
descriptor.code,
|
||||||
|
descriptor.kind
|
||||||
|
)
|
||||||
|
const srShortcut = formatForSR(
|
||||||
|
descriptor.shortcut,
|
||||||
|
descriptor.code,
|
||||||
|
descriptor.kind
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<tr>
|
||||||
|
<th scope="row" className={text({ variant: 'body' })}>
|
||||||
|
{t(`actions.${descriptor.id}`)}
|
||||||
|
</th>
|
||||||
|
<td className={shortcutCellStyle}>
|
||||||
|
<ShortcutBadge visualLabel={visualShortcut} srLabel={srShortcut} />
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
import { Shortcut } from './types'
|
||||||
|
import { isMacintosh } from '@/utils/livekit'
|
||||||
|
|
||||||
|
// Visible label for a shortcut (uses ⌘/Ctrl prefix when needed).
|
||||||
|
export const formatShortcutLabel = (shortcut?: Shortcut) => {
|
||||||
|
if (!shortcut) return '—'
|
||||||
|
const key = shortcut.key?.toUpperCase()
|
||||||
|
if (!key) return '—'
|
||||||
|
const parts: string[] = []
|
||||||
|
if (shortcut.ctrlKey) parts.push(isMacintosh() ? '⌘' : 'Ctrl')
|
||||||
|
if (shortcut.altKey) parts.push(isMacintosh() ? '⌥' : 'Alt')
|
||||||
|
if (shortcut.shiftKey) parts.push('Shift')
|
||||||
|
parts.push(key)
|
||||||
|
return parts.join('+')
|
||||||
|
}
|
||||||
|
|
||||||
|
// SR-friendly label for a shortcut (reads "Control plus D").
|
||||||
|
export const formatShortcutLabelForSR = (
|
||||||
|
shortcut: Shortcut | undefined,
|
||||||
|
{
|
||||||
|
controlLabel,
|
||||||
|
commandLabel,
|
||||||
|
altLabel,
|
||||||
|
optionLabel,
|
||||||
|
shiftLabel,
|
||||||
|
plusLabel,
|
||||||
|
noShortcutLabel,
|
||||||
|
}: {
|
||||||
|
controlLabel: string
|
||||||
|
commandLabel: string
|
||||||
|
altLabel: string
|
||||||
|
optionLabel: string
|
||||||
|
shiftLabel: string
|
||||||
|
plusLabel: string
|
||||||
|
noShortcutLabel: string
|
||||||
|
}
|
||||||
|
) => {
|
||||||
|
if (!shortcut) return noShortcutLabel
|
||||||
|
const key = shortcut.key?.toUpperCase()
|
||||||
|
if (!key) return noShortcutLabel
|
||||||
|
const ctrlWord = isMacintosh() ? commandLabel : controlLabel
|
||||||
|
const altWord = isMacintosh() ? optionLabel : altLabel
|
||||||
|
const parts: string[] = []
|
||||||
|
if (shortcut.ctrlKey) parts.push(ctrlWord)
|
||||||
|
if (shortcut.altKey) parts.push(altWord)
|
||||||
|
if (shortcut.shiftKey) parts.push(shiftLabel)
|
||||||
|
parts.push(key)
|
||||||
|
return parts.join(` ${plusLabel} `)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract displayable key name from KeyboardEvent.code (ex: KeyV -> V).
|
||||||
|
export const getKeyLabelFromCode = (code?: string) => {
|
||||||
|
if (!code) return ''
|
||||||
|
if (code.startsWith('Key') && code.length === 4) return code.slice(3)
|
||||||
|
if (code.startsWith('Digit') && code.length === 6) return code.slice(5)
|
||||||
|
if (code === 'Space') return '␣'
|
||||||
|
if (code.startsWith('Arrow')) return code.slice(5) // Up, Down, Left, Right
|
||||||
|
return code
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import { useCallback } from 'react'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import { Shortcut } from '../types'
|
||||||
|
import {
|
||||||
|
formatShortcutLabel,
|
||||||
|
formatShortcutLabelForSR,
|
||||||
|
getKeyLabelFromCode,
|
||||||
|
} from '../formatLabels'
|
||||||
|
|
||||||
|
export const useShortcutFormatting = () => {
|
||||||
|
const { t } = useTranslation('rooms')
|
||||||
|
|
||||||
|
const formatVisual = useCallback(
|
||||||
|
(shortcut?: Shortcut, code?: string, kind?: string) => {
|
||||||
|
if (code && kind === 'longPress') {
|
||||||
|
const label = getKeyLabelFromCode(code)
|
||||||
|
return t('shortcutsPanel.visual.hold', { key: label || '?' })
|
||||||
|
}
|
||||||
|
return formatShortcutLabel(shortcut)
|
||||||
|
},
|
||||||
|
[t]
|
||||||
|
)
|
||||||
|
|
||||||
|
const formatForSR = useCallback(
|
||||||
|
(shortcut?: Shortcut, code?: string, kind?: string) => {
|
||||||
|
if (code && kind === 'longPress') {
|
||||||
|
const label = getKeyLabelFromCode(code)
|
||||||
|
return t('shortcutsPanel.sr.hold', { key: label || '?' })
|
||||||
|
}
|
||||||
|
return formatShortcutLabelForSR(shortcut, {
|
||||||
|
controlLabel: t('shortcutsPanel.sr.control'),
|
||||||
|
commandLabel: t('shortcutsPanel.sr.command'),
|
||||||
|
altLabel: t('shortcutsPanel.sr.alt'),
|
||||||
|
optionLabel: t('shortcutsPanel.sr.option'),
|
||||||
|
shiftLabel: t('shortcutsPanel.sr.shift'),
|
||||||
|
plusLabel: t('shortcutsPanel.sr.plus'),
|
||||||
|
noShortcutLabel: t('shortcutsPanel.sr.noShortcut'),
|
||||||
|
})
|
||||||
|
},
|
||||||
|
[t]
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
formatVisual,
|
||||||
|
formatForSR,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -19,7 +19,10 @@ export const useKeyboardShortcuts = () => {
|
|||||||
shiftKey,
|
shiftKey,
|
||||||
altKey,
|
altKey,
|
||||||
})
|
})
|
||||||
const shortcut = shortcutsSnap.shortcuts.get(shortcutKey)
|
let shortcut = shortcutsSnap.shortcuts.get(shortcutKey)
|
||||||
|
if (!shortcut && shortcutKey === 'ctrl+shift+?') {
|
||||||
|
shortcut = shortcutsSnap.shortcuts.get('ctrl+shift+/')
|
||||||
|
}
|
||||||
if (!shortcut) return
|
if (!shortcut) return
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
await shortcut()
|
await shortcut()
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { layoutStore } from '@/stores/layout'
|
|||||||
import { useSnapshot } from 'valtio'
|
import { useSnapshot } from 'valtio'
|
||||||
import { Footer } from '@/layout/Footer'
|
import { Footer } from '@/layout/Footer'
|
||||||
import { ScreenReaderAnnouncer } from '@/primitives'
|
import { ScreenReaderAnnouncer } from '@/primitives'
|
||||||
|
import { SkipLink, MAIN_CONTENT_ID } from './SkipLink'
|
||||||
|
|
||||||
export type Layout = 'fullpage' | 'centered'
|
export type Layout = 'fullpage' | 'centered'
|
||||||
|
|
||||||
@@ -21,6 +22,7 @@ export const Layout = ({ children }: { children: ReactNode }) => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
{showHeader && <SkipLink />}
|
||||||
<div
|
<div
|
||||||
className={css({
|
className={css({
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
@@ -35,6 +37,7 @@ export const Layout = ({ children }: { children: ReactNode }) => {
|
|||||||
>
|
>
|
||||||
{showHeader && <Header />}
|
{showHeader && <Header />}
|
||||||
<main
|
<main
|
||||||
|
id={MAIN_CONTENT_ID}
|
||||||
className={css({
|
className={css({
|
||||||
flexGrow: 1,
|
flexGrow: 1,
|
||||||
overflow: 'auto',
|
overflow: 'auto',
|
||||||
|
|||||||
@@ -0,0 +1,69 @@
|
|||||||
|
import { type MouseEvent } from 'react'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import { styled } from '@/styled-system/jsx'
|
||||||
|
|
||||||
|
export const MAIN_CONTENT_ID = 'main-content'
|
||||||
|
|
||||||
|
// Visually hidden until focus (not sr-only). Must become visible on focus for keyboard users.
|
||||||
|
const StyledSkipLink = styled('a', {
|
||||||
|
base: {
|
||||||
|
position: 'absolute',
|
||||||
|
width: '1px',
|
||||||
|
height: '1px',
|
||||||
|
margin: '-1px',
|
||||||
|
padding: 0,
|
||||||
|
overflow: 'hidden',
|
||||||
|
clip: 'rect(0, 0, 0, 0)',
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
border: 0,
|
||||||
|
textDecoration: 'none',
|
||||||
|
_focusVisible: {
|
||||||
|
position: 'fixed',
|
||||||
|
top: '0.5rem',
|
||||||
|
left: '50%',
|
||||||
|
transform: 'translateX(-50%)',
|
||||||
|
width: 'auto',
|
||||||
|
height: 'auto',
|
||||||
|
margin: 0,
|
||||||
|
padding: '0.625rem 1rem',
|
||||||
|
overflow: 'visible',
|
||||||
|
clip: 'auto',
|
||||||
|
whiteSpace: 'normal',
|
||||||
|
zIndex: 9999,
|
||||||
|
backgroundColor: 'white',
|
||||||
|
color: 'primary.800',
|
||||||
|
fontWeight: 500,
|
||||||
|
fontSize: '0.875rem',
|
||||||
|
border: '1px solid',
|
||||||
|
borderColor: 'primary.800',
|
||||||
|
borderRadius: 4,
|
||||||
|
outline: '2px solid',
|
||||||
|
outlineColor: 'focusRing',
|
||||||
|
outlineOffset: 2,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
export const SkipLink = () => {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
|
||||||
|
const handleClick = (e: MouseEvent<HTMLAnchorElement>) => {
|
||||||
|
e.preventDefault()
|
||||||
|
const main = document.getElementById(MAIN_CONTENT_ID)
|
||||||
|
if (!main) return
|
||||||
|
|
||||||
|
const heading = main.querySelector('h1, h2, h3') as HTMLElement | null
|
||||||
|
const target = heading ?? main
|
||||||
|
|
||||||
|
if (!target.hasAttribute('tabindex')) {
|
||||||
|
target.setAttribute('tabindex', '-1')
|
||||||
|
}
|
||||||
|
target.focus()
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<StyledSkipLink href={`#${MAIN_CONTENT_ID}`} onClick={handleClick}>
|
||||||
|
{t('skipLink')}
|
||||||
|
</StyledSkipLink>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -25,6 +25,7 @@
|
|||||||
"heading": "Überprüfen Sie Ihren Meeting-Code",
|
"heading": "Überprüfen Sie Ihren Meeting-Code",
|
||||||
"body": "Stellen Sie sicher, dass Sie den richtigen Meeting-Code in der URL eingegeben haben. Beispiel:"
|
"body": "Stellen Sie sicher, dass Sie den richtigen Meeting-Code in der URL eingegeben haben. Beispiel:"
|
||||||
},
|
},
|
||||||
|
"selected": "ausgewählt",
|
||||||
"submit": "OK",
|
"submit": "OK",
|
||||||
"footer": {
|
"footer": {
|
||||||
"links": {
|
"links": {
|
||||||
@@ -45,13 +46,14 @@
|
|||||||
"license": "Etalab 2.0 Lizenz"
|
"license": "Etalab 2.0 Lizenz"
|
||||||
},
|
},
|
||||||
"loginHint": {
|
"loginHint": {
|
||||||
"title": "Melden Sie sich mit Ihrem ProConnect-Konto an",
|
"title": "Melden Sie sich mit Ihrem Konto an",
|
||||||
"body": "Statt zu warten, melden Sie sich mit Ihrem ProConnect-Konto an.",
|
"body": "Statt zu warten, melden Sie sich mit Ihrem ProConnect-Konto an.",
|
||||||
"button": {
|
"button": {
|
||||||
"ariaLabel": "Hinweis schließen",
|
"ariaLabel": "Hinweis schließen",
|
||||||
"label": "OK"
|
"label": "OK"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"skipLink": "Zum Hauptinhalt springen",
|
||||||
"clipboardContent": {
|
"clipboardContent": {
|
||||||
"url": "Um an der Videokonferenz teilzunehmen, klicken Sie auf diesen Link: {{roomUrl}}",
|
"url": "Um an der Videokonferenz teilzunehmen, klicken Sie auf diesen Link: {{roomUrl}}",
|
||||||
"numberAndPin": "Um telefonisch teilzunehmen, wählen Sie {{phoneNumber}} und geben Sie diesen Code ein: {{pinCode}}"
|
"numberAndPin": "Um telefonisch teilzunehmen, wählen Sie {{phoneNumber}} und geben Sie diesen Code ein: {{pinCode}}"
|
||||||
|
|||||||
@@ -10,6 +10,7 @@
|
|||||||
"joinMeetingTipContent": "Sie können einem Meeting beitreten, indem Sie den vollständigen Link in die Adressleiste Ihres Browsers einfügen.",
|
"joinMeetingTipContent": "Sie können einem Meeting beitreten, indem Sie den vollständigen Link in die Adressleiste Ihres Browsers einfügen.",
|
||||||
"joinMeetingTipHeading": "Wussten Sie schon?",
|
"joinMeetingTipHeading": "Wussten Sie schon?",
|
||||||
"loginToCreateMeeting": "Melden Sie sich an, um ein Meeting zu erstellen",
|
"loginToCreateMeeting": "Melden Sie sich an, um ein Meeting zu erstellen",
|
||||||
|
"noAccess": "Sie haben keinen Zugang zur Erstellung von Meetings. Bitte kontaktieren Sie Ihren Administrator.",
|
||||||
"moreLinkLabel": "Mehr erfahren – neues Tab",
|
"moreLinkLabel": "Mehr erfahren – neues Tab",
|
||||||
"moreLink": "Mehr erfahren",
|
"moreLink": "Mehr erfahren",
|
||||||
"moreAbout": "über {{appTitle}}",
|
"moreAbout": "über {{appTitle}}",
|
||||||
@@ -31,12 +32,14 @@
|
|||||||
},
|
},
|
||||||
"introSlider": {
|
"introSlider": {
|
||||||
"previous": {
|
"previous": {
|
||||||
"label": "Zurück",
|
"label": "Vorherige Folie",
|
||||||
"tooltip": "Zurück"
|
"labelWithPosition": "Vorherige Folie ({{current}} von {{total}})",
|
||||||
|
"tooltip": "Vorherige Folie"
|
||||||
},
|
},
|
||||||
"next": {
|
"next": {
|
||||||
"label": "Weiter",
|
"label": "Nächste Folie",
|
||||||
"tooltip": "Weiter"
|
"labelWithPosition": "Nächste Folie ({{current}} von {{total}})",
|
||||||
|
"tooltip": "Nächste Folie"
|
||||||
},
|
},
|
||||||
"beta": {
|
"beta": {
|
||||||
"text": "An der Beta teilnehmen",
|
"text": "An der Beta teilnehmen",
|
||||||
@@ -53,6 +56,8 @@
|
|||||||
"slide3": {
|
"slide3": {
|
||||||
"title": "Verwandeln Sie Ihre Meetings mit KI",
|
"title": "Verwandeln Sie Ihre Meetings mit KI",
|
||||||
"body": "Erhalten Sie präzise und verwertbare Transkripte zur Steigerung Ihrer Produktivität. Funktion in der Beta – jetzt testen!"
|
"body": "Erhalten Sie präzise und verwertbare Transkripte zur Steigerung Ihrer Produktivität. Funktion in der Beta – jetzt testen!"
|
||||||
}
|
},
|
||||||
|
"carouselLabel": "Einführungs-Diashow",
|
||||||
|
"slidePosition": "Folie {{current}} von {{total}}"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,6 +22,8 @@
|
|||||||
"permissionsNeeded": "Kamera auswählen - genehmigung erforderlich",
|
"permissionsNeeded": "Kamera auswählen - genehmigung erforderlich",
|
||||||
"disable": "Kamera deaktivieren",
|
"disable": "Kamera deaktivieren",
|
||||||
"enable": "Kamera aktivieren",
|
"enable": "Kamera aktivieren",
|
||||||
|
"turnedOff": "Kamera deaktiviert",
|
||||||
|
"turnedOn": "Kamera aktiviert",
|
||||||
"label": "Kamera",
|
"label": "Kamera",
|
||||||
"placeholder": "Kamera aktivieren, um die Vorschau zu sehen"
|
"placeholder": "Kamera aktivieren, um die Vorschau zu sehen"
|
||||||
},
|
},
|
||||||
@@ -30,6 +32,8 @@
|
|||||||
"permissionsNeeded": "Mikrofon auswählen - genehmigung erforderlich",
|
"permissionsNeeded": "Mikrofon auswählen - genehmigung erforderlich",
|
||||||
"disable": "Mikrofon deaktivieren",
|
"disable": "Mikrofon deaktivieren",
|
||||||
"enable": "Mikrofon aktivieren",
|
"enable": "Mikrofon aktivieren",
|
||||||
|
"turnedOff": "Mikrofon deaktiviert",
|
||||||
|
"turnedOn": "Mikrofon aktiviert",
|
||||||
"label": "Mikrofon"
|
"label": "Mikrofon"
|
||||||
},
|
},
|
||||||
"audiooutput": {
|
"audiooutput": {
|
||||||
@@ -586,7 +590,7 @@
|
|||||||
},
|
},
|
||||||
"participantTileFocus": {
|
"participantTileFocus": {
|
||||||
"containerLabel": "Optionen für {{name}}",
|
"containerLabel": "Optionen für {{name}}",
|
||||||
"toolbarHint": "F2: zur Symbolleiste unten.",
|
"toolbarHint": "Ctrl+Shift+/: Direkt auf die Tastenkürzel zugreifen.",
|
||||||
"pin": {
|
"pin": {
|
||||||
"enable": "Anheften",
|
"enable": "Anheften",
|
||||||
"disable": "Lösen"
|
"disable": "Lösen"
|
||||||
@@ -595,6 +599,41 @@
|
|||||||
"muteParticipant": "{{name}} stummschalten",
|
"muteParticipant": "{{name}} stummschalten",
|
||||||
"fullScreen": "Vollbild"
|
"fullScreen": "Vollbild"
|
||||||
},
|
},
|
||||||
|
"shortcutsPanel": {
|
||||||
|
"title": "Tastenkombinationen",
|
||||||
|
"categories": {
|
||||||
|
"navigation": "Navigation",
|
||||||
|
"media": "Medien",
|
||||||
|
"interaction": "Interaktion"
|
||||||
|
},
|
||||||
|
"actions": {
|
||||||
|
"open-shortcuts": "Tastenkürzel-Hilfe öffnen",
|
||||||
|
"focus-toolbar": "Fokus auf die untere Symbolleiste",
|
||||||
|
"toggle-microphone": "Mikrofon umschalten",
|
||||||
|
"toggle-camera": "Kamera umschalten",
|
||||||
|
"push-to-talk": "Push-to-talk (gedrückt halten zum Einschalten)",
|
||||||
|
"reaction": "Reaktionspanel",
|
||||||
|
"fullscreen": "Vollbild umschalten",
|
||||||
|
"recording": "Aufnahmepanel umschalten",
|
||||||
|
"raise-hand": "Hand heben oder senken",
|
||||||
|
"toggle-chat": "Chat anzeigen/ausblenden",
|
||||||
|
"toggle-participants": "Teilnehmer anzeigen/ausblenden",
|
||||||
|
"open-shortcuts-settings": "Tastenkürzel-Einstellungen öffnen"
|
||||||
|
},
|
||||||
|
"sr": {
|
||||||
|
"control": "Steuerung",
|
||||||
|
"command": "Befehl",
|
||||||
|
"alt": "Alt",
|
||||||
|
"option": "Option",
|
||||||
|
"shift": "Umschalt",
|
||||||
|
"plus": "plus",
|
||||||
|
"hold": "Halte {{key}} gedrückt",
|
||||||
|
"noShortcut": "Kein Tastenkürzel"
|
||||||
|
},
|
||||||
|
"visual": {
|
||||||
|
"hold": "Halte {{key}} gedrückt"
|
||||||
|
}
|
||||||
|
},
|
||||||
"fullScreenWarning": {
|
"fullScreenWarning": {
|
||||||
"message": "Um eine Endlosschleife zu vermeiden, teile nicht deinen gesamten Bildschirm. Teile stattdessen einen Tab oder ein anderes Fenster.",
|
"message": "Um eine Endlosschleife zu vermeiden, teile nicht deinen gesamten Bildschirm. Teile stattdessen einen Tab oder ein anderes Fenster.",
|
||||||
"stop": "Präsentation beenden",
|
"stop": "Präsentation beenden",
|
||||||
|
|||||||
@@ -100,6 +100,11 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"shortcuts": {
|
||||||
|
"listLabel": "Tastenkürzel",
|
||||||
|
"columnAction": "Aktion",
|
||||||
|
"columnShortcut": "Tastenkürzel"
|
||||||
|
},
|
||||||
"dialog": {
|
"dialog": {
|
||||||
"heading": "Einstellungen"
|
"heading": "Einstellungen"
|
||||||
},
|
},
|
||||||
@@ -120,6 +125,7 @@
|
|||||||
"general": "Allgemein",
|
"general": "Allgemein",
|
||||||
"notifications": "Benachrichtigungen",
|
"notifications": "Benachrichtigungen",
|
||||||
"accessibility": "Barrierefreiheit",
|
"accessibility": "Barrierefreiheit",
|
||||||
"transcription": "Transkription"
|
"transcription": "Transkription",
|
||||||
|
"shortcuts": "Tastenkürzel"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,6 +25,7 @@
|
|||||||
"heading": "Verify your meeting code",
|
"heading": "Verify your meeting code",
|
||||||
"body": "Check that you have entered the correct meeting code in the URL. Example:"
|
"body": "Check that you have entered the correct meeting code in the URL. Example:"
|
||||||
},
|
},
|
||||||
|
"selected": "selected",
|
||||||
"submit": "OK",
|
"submit": "OK",
|
||||||
"footer": {
|
"footer": {
|
||||||
"links": {
|
"links": {
|
||||||
@@ -45,13 +46,14 @@
|
|||||||
"license": "etalab 2.0 license"
|
"license": "etalab 2.0 license"
|
||||||
},
|
},
|
||||||
"loginHint": {
|
"loginHint": {
|
||||||
"title": "Log in with your ProConnect account",
|
"title": "Log in with your account",
|
||||||
"body": "Instead of waiting, log in with your ProConnect account.",
|
"body": "Instead of waiting, log in with your ProConnect account.",
|
||||||
"button": {
|
"button": {
|
||||||
"ariaLabel": "Close the suggestion",
|
"ariaLabel": "Close the suggestion",
|
||||||
"label": "OK"
|
"label": "OK"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"skipLink": "Skip to main content",
|
||||||
"clipboardContent": {
|
"clipboardContent": {
|
||||||
"url": "To join the video conference, click on this link: {{roomUrl}}",
|
"url": "To join the video conference, click on this link: {{roomUrl}}",
|
||||||
"numberAndPin": "To join by phone, dial {{phoneNumber}} and enter this code: {{pinCode}}"
|
"numberAndPin": "To join by phone, dial {{phoneNumber}} and enter this code: {{pinCode}}"
|
||||||
|
|||||||
@@ -10,6 +10,7 @@
|
|||||||
"joinMeetingTipContent": "You can join a meeting by pasting its full link in the browser's address bar.",
|
"joinMeetingTipContent": "You can join a meeting by pasting its full link in the browser's address bar.",
|
||||||
"joinMeetingTipHeading": "Did you know?",
|
"joinMeetingTipHeading": "Did you know?",
|
||||||
"loginToCreateMeeting": "Login to create a meeting",
|
"loginToCreateMeeting": "Login to create a meeting",
|
||||||
|
"noAccess": "You do not have access to create meetings. Please contact your administrator.",
|
||||||
"moreLinkLabel": "Learn more - new tab",
|
"moreLinkLabel": "Learn more - new tab",
|
||||||
"moreLink": "Learn more",
|
"moreLink": "Learn more",
|
||||||
"moreAbout": "about {{appTitle}}",
|
"moreAbout": "about {{appTitle}}",
|
||||||
@@ -31,12 +32,14 @@
|
|||||||
},
|
},
|
||||||
"introSlider": {
|
"introSlider": {
|
||||||
"previous": {
|
"previous": {
|
||||||
"label": "previous",
|
"label": "Previous slide",
|
||||||
"tooltip": "previous"
|
"labelWithPosition": "Previous slide ({{current}} of {{total}})",
|
||||||
|
"tooltip": "Previous slide"
|
||||||
},
|
},
|
||||||
"next": {
|
"next": {
|
||||||
"label": "next",
|
"label": "Next slide",
|
||||||
"tooltip": "next"
|
"labelWithPosition": "Next slide ({{current}} of {{total}})",
|
||||||
|
"tooltip": "Next slide"
|
||||||
},
|
},
|
||||||
"beta": {
|
"beta": {
|
||||||
"text": "Join the beta",
|
"text": "Join the beta",
|
||||||
@@ -53,6 +56,8 @@
|
|||||||
"slide3": {
|
"slide3": {
|
||||||
"title": "Transform your meetings with AI",
|
"title": "Transform your meetings with AI",
|
||||||
"body": "Get accurate and actionable transcripts to boost your productivity. Feature in beta—try it now!"
|
"body": "Get accurate and actionable transcripts to boost your productivity. Feature in beta—try it now!"
|
||||||
}
|
},
|
||||||
|
"carouselLabel": "Introduction slideshow",
|
||||||
|
"slidePosition": "Slide {{current}} of {{total}}"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,6 +22,8 @@
|
|||||||
"permissionsNeeded": "Select camera - permission needed",
|
"permissionsNeeded": "Select camera - permission needed",
|
||||||
"disable": "Disable camera",
|
"disable": "Disable camera",
|
||||||
"enable": "Enable camera",
|
"enable": "Enable camera",
|
||||||
|
"turnedOff": "Camera turned off",
|
||||||
|
"turnedOn": "Camera turned on",
|
||||||
"label": "Camera",
|
"label": "Camera",
|
||||||
"placeholder": "Enable camera to see the preview"
|
"placeholder": "Enable camera to see the preview"
|
||||||
},
|
},
|
||||||
@@ -30,6 +32,8 @@
|
|||||||
"permissionsNeeded": "Select microphone - permission needed",
|
"permissionsNeeded": "Select microphone - permission needed",
|
||||||
"disable": "Disable microphone",
|
"disable": "Disable microphone",
|
||||||
"enable": "Enable microphone",
|
"enable": "Enable microphone",
|
||||||
|
"turnedOff": "Microphone turned off",
|
||||||
|
"turnedOn": "Microphone turned on",
|
||||||
"label": "Microphone"
|
"label": "Microphone"
|
||||||
},
|
},
|
||||||
"audiooutput": {
|
"audiooutput": {
|
||||||
@@ -586,7 +590,7 @@
|
|||||||
},
|
},
|
||||||
"participantTileFocus": {
|
"participantTileFocus": {
|
||||||
"containerLabel": "Options for {{name}}",
|
"containerLabel": "Options for {{name}}",
|
||||||
"toolbarHint": "F2: go to the bottom toolbar.",
|
"toolbarHint": "Ctrl+Shift+/: access shortcuts directly.",
|
||||||
"pin": {
|
"pin": {
|
||||||
"enable": "Pin",
|
"enable": "Pin",
|
||||||
"disable": "Unpin"
|
"disable": "Unpin"
|
||||||
@@ -595,6 +599,41 @@
|
|||||||
"muteParticipant": "Mute {{name}}",
|
"muteParticipant": "Mute {{name}}",
|
||||||
"fullScreen": "Full screen"
|
"fullScreen": "Full screen"
|
||||||
},
|
},
|
||||||
|
"shortcutsPanel": {
|
||||||
|
"title": "Keyboard shortcuts",
|
||||||
|
"categories": {
|
||||||
|
"navigation": "Navigation",
|
||||||
|
"media": "Media",
|
||||||
|
"interaction": "Interaction"
|
||||||
|
},
|
||||||
|
"actions": {
|
||||||
|
"open-shortcuts": "Open shortcuts help",
|
||||||
|
"focus-toolbar": "Focus bottom toolbar",
|
||||||
|
"toggle-microphone": "Toggle microphone",
|
||||||
|
"toggle-camera": "Toggle camera",
|
||||||
|
"push-to-talk": "Push-to-talk (hold to unmute)",
|
||||||
|
"reaction": "Emoji reaction panel",
|
||||||
|
"fullscreen": "Toggle fullscreen",
|
||||||
|
"recording": "Toggle recording panel",
|
||||||
|
"raise-hand": "Raise or lower hand",
|
||||||
|
"toggle-chat": "Toggle chat",
|
||||||
|
"toggle-participants": "Toggle participants",
|
||||||
|
"open-shortcuts-settings": "Open shortcuts settings"
|
||||||
|
},
|
||||||
|
"sr": {
|
||||||
|
"control": "Control",
|
||||||
|
"command": "Command",
|
||||||
|
"alt": "Alt",
|
||||||
|
"option": "Option",
|
||||||
|
"shift": "Shift",
|
||||||
|
"plus": "plus",
|
||||||
|
"hold": "Hold {{key}}",
|
||||||
|
"noShortcut": "No shortcut"
|
||||||
|
},
|
||||||
|
"visual": {
|
||||||
|
"hold": "Hold {{key}}"
|
||||||
|
}
|
||||||
|
},
|
||||||
"fullScreenWarning": {
|
"fullScreenWarning": {
|
||||||
"message": "To avoid infinite loop display, do not share your entire screen. Instead, share a tab or another window.",
|
"message": "To avoid infinite loop display, do not share your entire screen. Instead, share a tab or another window.",
|
||||||
"stop": "Stop presenting",
|
"stop": "Stop presenting",
|
||||||
|
|||||||
@@ -100,6 +100,11 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"shortcuts": {
|
||||||
|
"listLabel": "Keyboard shortcuts",
|
||||||
|
"columnAction": "Action",
|
||||||
|
"columnShortcut": "Shortcut"
|
||||||
|
},
|
||||||
"dialog": {
|
"dialog": {
|
||||||
"heading": "Settings"
|
"heading": "Settings"
|
||||||
},
|
},
|
||||||
@@ -120,6 +125,7 @@
|
|||||||
"general": "General",
|
"general": "General",
|
||||||
"notifications": "Notifications",
|
"notifications": "Notifications",
|
||||||
"accessibility": "Accessibility",
|
"accessibility": "Accessibility",
|
||||||
"transcription": "Transcription"
|
"transcription": "Transcription",
|
||||||
|
"shortcuts": "Shortcuts"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,6 +25,7 @@
|
|||||||
"heading": "Vérifier votre code de réunion",
|
"heading": "Vérifier votre code de réunion",
|
||||||
"body": "Vérifiez que vous avez saisi le code de réunion correct dans l'URL. Exemple :"
|
"body": "Vérifiez que vous avez saisi le code de réunion correct dans l'URL. Exemple :"
|
||||||
},
|
},
|
||||||
|
"selected": "sélectionné",
|
||||||
"submit": "OK",
|
"submit": "OK",
|
||||||
"footer": {
|
"footer": {
|
||||||
"links": {
|
"links": {
|
||||||
@@ -45,13 +46,14 @@
|
|||||||
"license": "licence etalab 2.0"
|
"license": "licence etalab 2.0"
|
||||||
},
|
},
|
||||||
"loginHint": {
|
"loginHint": {
|
||||||
"title": "Connectez-vous avec votre compte ProConnect",
|
"title": "Connectez-vous avec votre compte",
|
||||||
"body": "Au lieu de patienter, connectez-vous avec votre compte ProConnect.",
|
"body": "Au lieu de patienter, connectez-vous avec votre compte ProConnect.",
|
||||||
"button": {
|
"button": {
|
||||||
"ariaLabel": "Fermer la suggestion",
|
"ariaLabel": "Fermer la suggestion",
|
||||||
"label": "OK"
|
"label": "OK"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"skipLink": "Aller au contenu principal",
|
||||||
"clipboardContent": {
|
"clipboardContent": {
|
||||||
"url": "Pour participer à la visioconférence, cliquez sur ce lien : {{roomUrl}}",
|
"url": "Pour participer à la visioconférence, cliquez sur ce lien : {{roomUrl}}",
|
||||||
"numberAndPin": "Pour participer par téléphone, composez le {{phoneNumber}} et saisissez ce code : {{pinCode}}"
|
"numberAndPin": "Pour participer par téléphone, composez le {{phoneNumber}} et saisissez ce code : {{pinCode}}"
|
||||||
|
|||||||
@@ -10,6 +10,7 @@
|
|||||||
"joinMeetingTipContent": "Vous pouvez rejoindre une réunion en copiant directement son lien complet dans la barre d'adresse du navigateur.",
|
"joinMeetingTipContent": "Vous pouvez rejoindre une réunion en copiant directement son lien complet dans la barre d'adresse du navigateur.",
|
||||||
"joinMeetingTipHeading": "Astuce",
|
"joinMeetingTipHeading": "Astuce",
|
||||||
"loginToCreateMeeting": "Connectez-vous pour créer une réunion",
|
"loginToCreateMeeting": "Connectez-vous pour créer une réunion",
|
||||||
|
"noAccess": "Vous n'avez pas accès à la création de réunions. Veuillez contacter votre administrateur.",
|
||||||
"moreLinkLabel": "En savoir plus - nouvelle fenêtre",
|
"moreLinkLabel": "En savoir plus - nouvelle fenêtre",
|
||||||
"moreLink": "En savoir plus",
|
"moreLink": "En savoir plus",
|
||||||
"moreAbout": "sur {{appTitle}}",
|
"moreAbout": "sur {{appTitle}}",
|
||||||
@@ -30,14 +31,18 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"introSlider": {
|
"introSlider": {
|
||||||
|
"carouselLabel": "Diaporama de présentation",
|
||||||
"previous": {
|
"previous": {
|
||||||
"label": "précédent",
|
"label": "Diapositive précédente",
|
||||||
"tooltip": "précédent"
|
"labelWithPosition": "Diapositive précédente ({{current}} sur {{total}})",
|
||||||
|
"tooltip": "Diapositive précédente"
|
||||||
},
|
},
|
||||||
"next": {
|
"next": {
|
||||||
"label": "suivant",
|
"label": "Diapositive suivante",
|
||||||
"tooltip": "suivant"
|
"labelWithPosition": "Diapositive suivante ({{current}} sur {{total}})",
|
||||||
|
"tooltip": "Diapositive suivante"
|
||||||
},
|
},
|
||||||
|
"slidePosition": "Diapositive {{current}} sur {{total}}",
|
||||||
"beta": {
|
"beta": {
|
||||||
"text": "Essayer la beta",
|
"text": "Essayer la beta",
|
||||||
"tooltip": "Accéder au formulaire"
|
"tooltip": "Accéder au formulaire"
|
||||||
|
|||||||
@@ -22,6 +22,8 @@
|
|||||||
"permissionsNeeded": "Choisir la webcam - autorisations nécessaires",
|
"permissionsNeeded": "Choisir la webcam - autorisations nécessaires",
|
||||||
"disable": "Désactiver la webcam",
|
"disable": "Désactiver la webcam",
|
||||||
"enable": "Activer la webcam",
|
"enable": "Activer la webcam",
|
||||||
|
"turnedOff": "Webcam désactivée",
|
||||||
|
"turnedOn": "Webcam activée",
|
||||||
"label": "Webcam",
|
"label": "Webcam",
|
||||||
"placeholder": "Activez la webcam pour prévisualiser l'affichage"
|
"placeholder": "Activez la webcam pour prévisualiser l'affichage"
|
||||||
},
|
},
|
||||||
@@ -30,6 +32,8 @@
|
|||||||
"permissionsNeeded": "Choisir le micro - autorisations nécessaires",
|
"permissionsNeeded": "Choisir le micro - autorisations nécessaires",
|
||||||
"disable": "Désactiver le micro",
|
"disable": "Désactiver le micro",
|
||||||
"enable": "Activer le micro",
|
"enable": "Activer le micro",
|
||||||
|
"turnedOff": "Micro désactivé",
|
||||||
|
"turnedOn": "Micro activé",
|
||||||
"label": "Microphone"
|
"label": "Microphone"
|
||||||
},
|
},
|
||||||
"audiooutput": {
|
"audiooutput": {
|
||||||
@@ -586,7 +590,7 @@
|
|||||||
},
|
},
|
||||||
"participantTileFocus": {
|
"participantTileFocus": {
|
||||||
"containerLabel": "Options pour {{name}}",
|
"containerLabel": "Options pour {{name}}",
|
||||||
"toolbarHint": "F2 : raccourci barre d'outils en bas.",
|
"toolbarHint": "Ctrl+Shift+/ : accéder directement aux raccourcis.",
|
||||||
"pin": {
|
"pin": {
|
||||||
"enable": "Épingler",
|
"enable": "Épingler",
|
||||||
"disable": "Annuler l'épinglage"
|
"disable": "Annuler l'épinglage"
|
||||||
@@ -595,6 +599,41 @@
|
|||||||
"muteParticipant": "Couper le micro de {{name}}",
|
"muteParticipant": "Couper le micro de {{name}}",
|
||||||
"fullScreen": "Plein écran"
|
"fullScreen": "Plein écran"
|
||||||
},
|
},
|
||||||
|
"shortcutsPanel": {
|
||||||
|
"title": "Raccourcis clavier",
|
||||||
|
"categories": {
|
||||||
|
"navigation": "Navigation",
|
||||||
|
"media": "Média",
|
||||||
|
"interaction": "Interaction"
|
||||||
|
},
|
||||||
|
"actions": {
|
||||||
|
"open-shortcuts": "Ouvrir l’aide des raccourcis",
|
||||||
|
"focus-toolbar": "Mettre le focus sur la barre d’outils du bas",
|
||||||
|
"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)",
|
||||||
|
"reaction": "Panneau des réactions",
|
||||||
|
"fullscreen": "Basculer en plein écran",
|
||||||
|
"recording": "Basculer le panneau d’enregistrement",
|
||||||
|
"raise-hand": "Lever ou baisser la main",
|
||||||
|
"toggle-chat": "Afficher/Masquer le chat",
|
||||||
|
"toggle-participants": "Afficher/Masquer les participants",
|
||||||
|
"open-shortcuts-settings": "Ouvrir les réglages des raccourcis"
|
||||||
|
},
|
||||||
|
"sr": {
|
||||||
|
"control": "Contrôle",
|
||||||
|
"command": "Commande",
|
||||||
|
"alt": "Alt",
|
||||||
|
"option": "Option",
|
||||||
|
"shift": "Majuscule",
|
||||||
|
"plus": "plus",
|
||||||
|
"hold": "Maintenir {{key}}",
|
||||||
|
"noShortcut": "Aucun raccourci"
|
||||||
|
},
|
||||||
|
"visual": {
|
||||||
|
"hold": "Maintenir {{key}}"
|
||||||
|
}
|
||||||
|
},
|
||||||
"fullScreenWarning": {
|
"fullScreenWarning": {
|
||||||
"message": "Pour éviter l'affichage en boucle infinie, ne partagez pas l'intégralité de votre écran. Partagez plutôt un onglet ou une autre fenêtre.",
|
"message": "Pour éviter l'affichage en boucle infinie, ne partagez pas l'intégralité de votre écran. Partagez plutôt un onglet ou une autre fenêtre.",
|
||||||
"stop": "Arrêter la présentation",
|
"stop": "Arrêter la présentation",
|
||||||
|
|||||||
@@ -100,6 +100,11 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"shortcuts": {
|
||||||
|
"listLabel": "Liste des raccourcis clavier",
|
||||||
|
"columnAction": "Action",
|
||||||
|
"columnShortcut": "Raccourci"
|
||||||
|
},
|
||||||
"dialog": {
|
"dialog": {
|
||||||
"heading": "Paramètres"
|
"heading": "Paramètres"
|
||||||
},
|
},
|
||||||
@@ -120,6 +125,7 @@
|
|||||||
"general": "Général",
|
"general": "Général",
|
||||||
"notifications": "Notifications",
|
"notifications": "Notifications",
|
||||||
"accessibility": "Accessibilité",
|
"accessibility": "Accessibilité",
|
||||||
"transcription": "Transcription"
|
"transcription": "Transcription",
|
||||||
|
"shortcuts": "Raccourcis"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,6 +24,7 @@
|
|||||||
"notFound": {
|
"notFound": {
|
||||||
"heading": "Pagina niet gevonden"
|
"heading": "Pagina niet gevonden"
|
||||||
},
|
},
|
||||||
|
"selected": "geselecteerd",
|
||||||
"submit": "OK",
|
"submit": "OK",
|
||||||
"footer": {
|
"footer": {
|
||||||
"links": {
|
"links": {
|
||||||
@@ -44,13 +45,14 @@
|
|||||||
"license": "etalab 2.0 licentie"
|
"license": "etalab 2.0 licentie"
|
||||||
},
|
},
|
||||||
"loginHint": {
|
"loginHint": {
|
||||||
"title": "Log in met je ProConnect-account",
|
"title": "Log in met je account",
|
||||||
"body": "In plaats van te wachten, log in met je ProConnect-account.",
|
"body": "In plaats van te wachten, log in met je ProConnect-account.",
|
||||||
"button": {
|
"button": {
|
||||||
"ariaLabel": "Sluit de suggestie",
|
"ariaLabel": "Sluit de suggestie",
|
||||||
"label": "OK"
|
"label": "OK"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"skipLink": "Naar de hoofdinhoud gaan",
|
||||||
"clipboardContent": {
|
"clipboardContent": {
|
||||||
"url": "Klik op deze link om deel te nemen aan de videoconferentie: {{roomUrl}}",
|
"url": "Klik op deze link om deel te nemen aan de videoconferentie: {{roomUrl}}",
|
||||||
"numberAndPin": "Bel {{phoneNumber}} en voer deze code in om telefonisch deel te nemen: {{pinCode}}"
|
"numberAndPin": "Bel {{phoneNumber}} en voer deze code in om telefonisch deel te nemen: {{pinCode}}"
|
||||||
|
|||||||
@@ -10,6 +10,7 @@
|
|||||||
"joinMeetingTipContent": "U kunt deelnemen aan een vergadering door de volledige link in de adresbalk van de browser te plakken.",
|
"joinMeetingTipContent": "U kunt deelnemen aan een vergadering door de volledige link in de adresbalk van de browser te plakken.",
|
||||||
"joinMeetingTipHeading": "Wist u dat?",
|
"joinMeetingTipHeading": "Wist u dat?",
|
||||||
"loginToCreateMeeting": "Log in om een vergadering te maken",
|
"loginToCreateMeeting": "Log in om een vergadering te maken",
|
||||||
|
"noAccess": "U heeft geen toegang om vergaderingen te maken. Neem contact op met uw beheerder.",
|
||||||
"moreLinkLabel": "Meer informatie - nieuw tabblad",
|
"moreLinkLabel": "Meer informatie - nieuw tabblad",
|
||||||
"moreLink": "Meer informatie",
|
"moreLink": "Meer informatie",
|
||||||
"moreAbout": "over {{appTitle}}",
|
"moreAbout": "over {{appTitle}}",
|
||||||
@@ -31,12 +32,14 @@
|
|||||||
},
|
},
|
||||||
"introSlider": {
|
"introSlider": {
|
||||||
"previous": {
|
"previous": {
|
||||||
"label": "vorige",
|
"label": "Vorige dia",
|
||||||
"tooltip": "vorige"
|
"labelWithPosition": "Vorige dia ({{current}} van {{total}})",
|
||||||
|
"tooltip": "Vorige dia"
|
||||||
},
|
},
|
||||||
"next": {
|
"next": {
|
||||||
"label": "volgende",
|
"label": "Volgende dia",
|
||||||
"tooltip": "volgende"
|
"labelWithPosition": "Volgende dia ({{current}} van {{total}})",
|
||||||
|
"tooltip": "Volgende dia"
|
||||||
},
|
},
|
||||||
"beta": {
|
"beta": {
|
||||||
"text": "Word lid van de bèta",
|
"text": "Word lid van de bèta",
|
||||||
@@ -53,6 +56,8 @@
|
|||||||
"slide3": {
|
"slide3": {
|
||||||
"title": "Transformeer uw vergaderingen met AI",
|
"title": "Transformeer uw vergaderingen met AI",
|
||||||
"body": "Krijg nauwkeurige en bruikbare transcripties om uw productiviteit te stimuleren. Deze mogelijkheid is in bèta, probeer het nu!"
|
"body": "Krijg nauwkeurige en bruikbare transcripties om uw productiviteit te stimuleren. Deze mogelijkheid is in bèta, probeer het nu!"
|
||||||
}
|
},
|
||||||
|
"carouselLabel": "Introductie-diavoorstelling",
|
||||||
|
"slidePosition": "Dia {{current}} van {{total}}"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,6 +22,8 @@
|
|||||||
"permissionsNeeded": "Selecteer camera - Toestemming vereist",
|
"permissionsNeeded": "Selecteer camera - Toestemming vereist",
|
||||||
"disable": "Camera uitschakelen",
|
"disable": "Camera uitschakelen",
|
||||||
"enable": "Camera inschakelen",
|
"enable": "Camera inschakelen",
|
||||||
|
"turnedOff": "Camera uitgeschakeld",
|
||||||
|
"turnedOn": "Camera ingeschakeld",
|
||||||
"label": "Camera",
|
"label": "Camera",
|
||||||
"placeholder": "Schakel de camera in om de preview te zien"
|
"placeholder": "Schakel de camera in om de preview te zien"
|
||||||
},
|
},
|
||||||
@@ -30,6 +32,8 @@
|
|||||||
"permissionsNeeded": "Selecteer microfoon - Toestemming vereist",
|
"permissionsNeeded": "Selecteer microfoon - Toestemming vereist",
|
||||||
"disable": "Microfoon dempen",
|
"disable": "Microfoon dempen",
|
||||||
"enable": "Microfoon dempen opheffen",
|
"enable": "Microfoon dempen opheffen",
|
||||||
|
"turnedOff": "Microfoon uitgeschakeld",
|
||||||
|
"turnedOn": "Microfoon ingeschakeld",
|
||||||
"label": "Microfoon"
|
"label": "Microfoon"
|
||||||
},
|
},
|
||||||
"audiooutput": {
|
"audiooutput": {
|
||||||
@@ -586,7 +590,7 @@
|
|||||||
},
|
},
|
||||||
"participantTileFocus": {
|
"participantTileFocus": {
|
||||||
"containerLabel": "Opties voor {{name}}",
|
"containerLabel": "Opties voor {{name}}",
|
||||||
"toolbarHint": "F2: naar de werkbalk onderaan.",
|
"toolbarHint": "Ctrl+Shift+/: direct toegang tot de sneltoetsen.",
|
||||||
"pin": {
|
"pin": {
|
||||||
"enable": "Pinnen",
|
"enable": "Pinnen",
|
||||||
"disable": "Losmaken"
|
"disable": "Losmaken"
|
||||||
@@ -595,6 +599,41 @@
|
|||||||
"muteParticipant": "Demp {{name}}",
|
"muteParticipant": "Demp {{name}}",
|
||||||
"fullScreen": "Volledig scherm"
|
"fullScreen": "Volledig scherm"
|
||||||
},
|
},
|
||||||
|
"shortcutsPanel": {
|
||||||
|
"title": "Sneltoetsen",
|
||||||
|
"categories": {
|
||||||
|
"navigation": "Navigatie",
|
||||||
|
"media": "Media",
|
||||||
|
"interaction": "Interactie"
|
||||||
|
},
|
||||||
|
"actions": {
|
||||||
|
"open-shortcuts": "Sneltoetsenhulp openen",
|
||||||
|
"focus-toolbar": "Focus op de onderste werkbalk",
|
||||||
|
"toggle-microphone": "Microfoon aan/uit",
|
||||||
|
"toggle-camera": "Camera aan/uit",
|
||||||
|
"push-to-talk": "Push-to-talk (ingedrukt houden om te activeren)",
|
||||||
|
"reaction": "Reactiepaneel",
|
||||||
|
"fullscreen": "Volledig scherm wisselen",
|
||||||
|
"recording": "Opnamepaneel wisselen",
|
||||||
|
"raise-hand": "Hand opsteken of laten zakken",
|
||||||
|
"toggle-chat": "Chat tonen/verbergen",
|
||||||
|
"toggle-participants": "Deelnemers tonen/verbergen",
|
||||||
|
"open-shortcuts-settings": "Sneltoets-instellingen openen"
|
||||||
|
},
|
||||||
|
"sr": {
|
||||||
|
"control": "Control",
|
||||||
|
"command": "Command",
|
||||||
|
"alt": "Alt",
|
||||||
|
"option": "Option",
|
||||||
|
"shift": "Shift",
|
||||||
|
"plus": "plus",
|
||||||
|
"hold": "Houd {{key}} ingedrukt",
|
||||||
|
"noShortcut": "Geen sneltoets"
|
||||||
|
},
|
||||||
|
"visual": {
|
||||||
|
"hold": "Houd {{key}} ingedrukt"
|
||||||
|
}
|
||||||
|
},
|
||||||
"fullScreenWarning": {
|
"fullScreenWarning": {
|
||||||
"message": "Om niet oneindige uw scherm in zichzelf te delen, kunt u beter niet het hele scherm delen. Deel in plaats daarvan een tab of een ander venster.",
|
"message": "Om niet oneindige uw scherm in zichzelf te delen, kunt u beter niet het hele scherm delen. Deel in plaats daarvan een tab of een ander venster.",
|
||||||
"stop": "Stop met presenteren",
|
"stop": "Stop met presenteren",
|
||||||
|
|||||||
@@ -100,6 +100,11 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"shortcuts": {
|
||||||
|
"listLabel": "Sneltoetsen",
|
||||||
|
"columnAction": "Actie",
|
||||||
|
"columnShortcut": "Sneltoets"
|
||||||
|
},
|
||||||
"dialog": {
|
"dialog": {
|
||||||
"heading": "Instellingen"
|
"heading": "Instellingen"
|
||||||
},
|
},
|
||||||
@@ -114,6 +119,7 @@
|
|||||||
"video": "Video",
|
"video": "Video",
|
||||||
"general": "Algemeen",
|
"general": "Algemeen",
|
||||||
"notifications": "Meldingen",
|
"notifications": "Meldingen",
|
||||||
"transcription": "Transcriptie"
|
"transcription": "Transcriptie",
|
||||||
|
"shortcuts": "Sneltoetsen"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import { ReactNode } from 'react'
|
import { ReactNode } from 'react'
|
||||||
import { Menu, MenuProps, MenuItem } from 'react-aria-components'
|
import { Menu, MenuProps, MenuItem } from 'react-aria-components'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import { VisuallyHidden } from '@/styled-system/jsx'
|
||||||
import { menuRecipe } from '@/primitives/menuRecipe.ts'
|
import { menuRecipe } from '@/primitives/menuRecipe.ts'
|
||||||
import type { RecipeVariantProps } from '@/styled-system/types'
|
import type { RecipeVariantProps } from '@/styled-system/types'
|
||||||
|
|
||||||
@@ -19,6 +21,7 @@ export const MenuList = <T extends string | number = string>({
|
|||||||
} & MenuProps<unknown> &
|
} & MenuProps<unknown> &
|
||||||
RecipeVariantProps<typeof menuRecipe>) => {
|
RecipeVariantProps<typeof menuRecipe>) => {
|
||||||
const [variantProps] = menuRecipe.splitVariantProps(menuProps)
|
const [variantProps] = menuRecipe.splitVariantProps(menuProps)
|
||||||
|
const { t } = useTranslation('global')
|
||||||
const classes = menuRecipe({
|
const classes = menuRecipe({
|
||||||
extraPadding: true,
|
extraPadding: true,
|
||||||
variant: variant,
|
variant: variant,
|
||||||
@@ -39,11 +42,19 @@ export const MenuList = <T extends string | number = string>({
|
|||||||
className={classes.item}
|
className={classes.item}
|
||||||
key={value}
|
key={value}
|
||||||
id={value as string}
|
id={value as string}
|
||||||
|
textValue={typeof label === 'string' ? label : undefined}
|
||||||
onAction={() => {
|
onAction={() => {
|
||||||
onAction(value as T)
|
onAction(value as T)
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{label}
|
{({ isSelected }) => (
|
||||||
|
<>
|
||||||
|
{label}
|
||||||
|
{isSelected && (
|
||||||
|
<VisuallyHidden>, {t('selected')}</VisuallyHidden>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</MenuItem>
|
</MenuItem>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { type ReactNode } from 'react'
|
import { type ReactNode } from 'react'
|
||||||
import { styled } from '@/styled-system/jsx'
|
import { styled, VisuallyHidden } from '@/styled-system/jsx'
|
||||||
import { RemixiconComponentType, RiArrowDropDownLine } from '@remixicon/react'
|
import { RemixiconComponentType, RiArrowDropDownLine } from '@remixicon/react'
|
||||||
import {
|
import {
|
||||||
Button,
|
Button,
|
||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
SelectProps as RACSelectProps,
|
SelectProps as RACSelectProps,
|
||||||
SelectValue,
|
SelectValue,
|
||||||
} from 'react-aria-components'
|
} from 'react-aria-components'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
import { Box } from './Box'
|
import { Box } from './Box'
|
||||||
import { StyledPopover } from './Popover'
|
import { StyledPopover } from './Popover'
|
||||||
import { menuRecipe } from '@/primitives/menuRecipe.ts'
|
import { menuRecipe } from '@/primitives/menuRecipe.ts'
|
||||||
@@ -110,6 +111,7 @@ export const Select = <T extends string | number>({
|
|||||||
...props
|
...props
|
||||||
}: SelectProps<T>) => {
|
}: SelectProps<T>) => {
|
||||||
const IconComponent = iconComponent
|
const IconComponent = iconComponent
|
||||||
|
const { t } = useTranslation('global')
|
||||||
return (
|
return (
|
||||||
<RACSelect {...props}>
|
<RACSelect {...props}>
|
||||||
{label}
|
{label}
|
||||||
@@ -138,8 +140,18 @@ export const Select = <T extends string | number>({
|
|||||||
}
|
}
|
||||||
id={item.value}
|
id={item.value}
|
||||||
key={item.value}
|
key={item.value}
|
||||||
|
textValue={
|
||||||
|
typeof item.label === 'string' ? item.label : undefined
|
||||||
|
}
|
||||||
>
|
>
|
||||||
{item.label}
|
{({ isSelected }) => (
|
||||||
|
<>
|
||||||
|
{item.label}
|
||||||
|
{isSelected && (
|
||||||
|
<VisuallyHidden>, {t('selected')}</VisuallyHidden>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</ListBoxItem>
|
</ListBoxItem>
|
||||||
))}
|
))}
|
||||||
</ListBox>
|
</ListBox>
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user