mirror of
https://github.com/suitenumerique/meet.git
synced 2026-07-28 04:39:16 +00:00
Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c149c8ce9c | |||
| 198442b137 |
@@ -13,7 +13,7 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v6
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
- name: Download Crowdin files
|
- name: Download Crowdin files
|
||||||
uses: crowdin/github-action@v2
|
uses: crowdin/github-action@v2
|
||||||
|
|||||||
@@ -23,13 +23,7 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
-
|
-
|
||||||
name: Checkout repository
|
name: Checkout repository
|
||||||
uses: actions/checkout@v6
|
uses: actions/checkout@v4
|
||||||
-
|
|
||||||
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
|
||||||
@@ -43,19 +37,18 @@ 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 }}
|
||||||
@@ -66,13 +59,7 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
-
|
-
|
||||||
name: Checkout repository
|
name: Checkout repository
|
||||||
uses: actions/checkout@v6
|
uses: actions/checkout@v4
|
||||||
-
|
|
||||||
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
|
||||||
@@ -86,12 +73,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
|
||||||
@@ -99,7 +86,6 @@ 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 }}
|
||||||
@@ -110,13 +96,7 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
-
|
-
|
||||||
name: Checkout repository
|
name: Checkout repository
|
||||||
uses: actions/checkout@v6
|
uses: actions/checkout@v4
|
||||||
-
|
|
||||||
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
|
||||||
@@ -130,12 +110,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
|
||||||
@@ -143,7 +123,6 @@ 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 }}
|
||||||
@@ -154,13 +133,7 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
-
|
-
|
||||||
name: Checkout repository
|
name: Checkout repository
|
||||||
uses: actions/checkout@v6
|
uses: actions/checkout@v4
|
||||||
-
|
|
||||||
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
|
||||||
@@ -174,14 +147,6 @@ 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
|
|
||||||
# uses: numerique-gouv/action-trivy-cache@main
|
|
||||||
# continue-on-error: true
|
|
||||||
# with:
|
|
||||||
# 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-context: './src/summary'
|
|
||||||
-
|
-
|
||||||
name: Build and push
|
name: Build and push
|
||||||
uses: docker/build-push-action@v6
|
uses: docker/build-push-action@v6
|
||||||
@@ -189,7 +154,6 @@ 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 }}
|
||||||
@@ -200,13 +164,7 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
-
|
-
|
||||||
name: Checkout repository
|
name: Checkout repository
|
||||||
uses: actions/checkout@v6
|
uses: actions/checkout@v4
|
||||||
-
|
|
||||||
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
|
||||||
@@ -220,14 +178,6 @@ 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
|
|
||||||
# uses: numerique-gouv/action-trivy-cache@main
|
|
||||||
# continue-on-error: true
|
|
||||||
# with:
|
|
||||||
# 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-context: './src/agents'
|
|
||||||
-
|
-
|
||||||
name: Build and push
|
name: Build and push
|
||||||
uses: docker/build-push-action@v6
|
uses: docker/build-push-action@v6
|
||||||
@@ -235,7 +185,6 @@ 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 }}
|
||||||
|
|||||||
+20
-43
@@ -7,18 +7,14 @@ 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@v6
|
uses: actions/checkout@v4
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
- name: show
|
- name: show
|
||||||
@@ -43,11 +39,9 @@ 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@v6
|
uses: actions/checkout@v4
|
||||||
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
|
||||||
@@ -55,11 +49,9 @@ 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@v6
|
uses: actions/checkout@v4
|
||||||
- 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)
|
||||||
@@ -70,22 +62,20 @@ 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@v6
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
- name: Install Node.js
|
- name: Install Node.js
|
||||||
uses: actions/setup-node@v6
|
uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: "18"
|
node-version: "18"
|
||||||
|
|
||||||
- name: Restore the mail templates
|
- name: Restore the mail templates
|
||||||
uses: actions/cache@v5
|
uses: actions/cache@v4
|
||||||
id: mail-templates
|
id: mail-templates
|
||||||
with:
|
with:
|
||||||
path: "src/backend/core/templates/mail"
|
path: "src/backend/core/templates/mail"
|
||||||
@@ -105,23 +95,21 @@ 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@v5
|
uses: actions/cache@v4
|
||||||
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@v6
|
uses: actions/checkout@v4
|
||||||
- name: Install Python
|
- name: Install Python
|
||||||
uses: actions/setup-python@v6
|
uses: actions/setup-python@v5
|
||||||
with:
|
with:
|
||||||
python-version: "3.13"
|
python-version: "3.13"
|
||||||
cache: "pip"
|
cache: "pip"
|
||||||
@@ -136,16 +124,14 @@ 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@v6
|
uses: actions/checkout@v4
|
||||||
- name: Install Python
|
- name: Install Python
|
||||||
uses: actions/setup-python@v6
|
uses: actions/setup-python@v5
|
||||||
with:
|
with:
|
||||||
python-version: "3.13"
|
python-version: "3.13"
|
||||||
cache: "pip"
|
cache: "pip"
|
||||||
@@ -158,16 +144,14 @@ 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@v6
|
uses: actions/checkout@v4
|
||||||
- name: Install Python
|
- name: Install Python
|
||||||
uses: actions/setup-python@v6
|
uses: actions/setup-python@v5
|
||||||
with:
|
with:
|
||||||
python-version: "3.13"
|
python-version: "3.13"
|
||||||
cache: "pip"
|
cache: "pip"
|
||||||
@@ -181,8 +165,7 @@ 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
|
||||||
@@ -233,7 +216,7 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v6
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
- name: Create writable /data
|
- name: Create writable /data
|
||||||
run: |
|
run: |
|
||||||
@@ -241,7 +224,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@v5
|
uses: actions/cache@v4
|
||||||
id: mail-templates
|
id: mail-templates
|
||||||
with:
|
with:
|
||||||
path: "src/backend/core/templates/mail"
|
path: "src/backend/core/templates/mail"
|
||||||
@@ -275,7 +258,7 @@ jobs:
|
|||||||
mc mb meet/meet-media-storage"
|
mc mb meet/meet-media-storage"
|
||||||
|
|
||||||
- name: Install Python
|
- name: Install Python
|
||||||
uses: actions/setup-python@v6
|
uses: actions/setup-python@v5
|
||||||
with:
|
with:
|
||||||
python-version: "3.13"
|
python-version: "3.13"
|
||||||
cache: "pip"
|
cache: "pip"
|
||||||
@@ -296,11 +279,9 @@ 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@v6
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: cd src/frontend/ && npm ci
|
run: cd src/frontend/ && npm ci
|
||||||
@@ -313,14 +294,12 @@ 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@v6
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: npm ci
|
run: npm ci
|
||||||
@@ -333,15 +312,13 @@ 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@v6
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
- 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@v6
|
uses: actions/checkout@v4
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
|
|
||||||
|
|||||||
@@ -1,29 +0,0 @@
|
|||||||
# /!\
|
|
||||||
# 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 }}
|
|
||||||
+1
-158
@@ -8,170 +8,13 @@ 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
|
|
||||||
|
|
||||||
- 🔒️(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
|
|
||||||
|
|
||||||
### Added
|
|
||||||
|
|
||||||
- ✨(frontend) expose Windows app web link #976
|
|
||||||
- ✨(frontend) support additional shortcuts to broaden accessibility
|
|
||||||
|
|
||||||
### Changed
|
|
||||||
|
|
||||||
- ✨(frontend) add clickable settings general link in idle modal #974
|
|
||||||
- ♻️(backend) refactor external API token-related items #1006
|
|
||||||
|
|
||||||
## [1.6.0] - 2026-02-10
|
|
||||||
|
|
||||||
### Added
|
|
||||||
|
|
||||||
- ✨(backend) monitor throttling rate failure through sentry #964
|
|
||||||
- 🚀(paas) add PaaS deployment scripts, tested on Scalingo #957
|
|
||||||
|
|
||||||
### Changed
|
|
||||||
|
|
||||||
- ♿️(frontend) improve spinner reduced‑motion fallback #931
|
|
||||||
- ♿️(frontend) fix form labels and autocomplete wiring #932
|
|
||||||
- 🥅(summary) catch file-related exceptions when handling recording #944
|
|
||||||
- 📝(frontend) update legal terms #956
|
|
||||||
- ⚡️(backend) enhance django admin's loading performance #954
|
|
||||||
- 🌐(frontend) add missing DE translation for accessibility settings
|
|
||||||
|
|
||||||
### Fixed
|
|
||||||
|
|
||||||
- 🔐(backend) enforce object-level permission checks on room endpoint #959
|
|
||||||
- 🔒️(backend) add application validation when consuming external JWT #963
|
|
||||||
|
|
||||||
## [1.5.0] - 2026-01-28
|
|
||||||
|
|
||||||
### Changed
|
|
||||||
|
|
||||||
- ♿️(frontend) adjust visual-only tooltip a11y labels #910
|
|
||||||
- ♿️(frontend) sr pin/unpin announcements with dedicated messages #898
|
|
||||||
- ♿(frontend) adjust sr announcements for idle disconnect timer #908
|
|
||||||
- ♿️(frontend) add global screen reader announcer#922
|
|
||||||
|
|
||||||
### Fixed
|
|
||||||
|
|
||||||
- 🔒️(frontend) fix an XSS vulnerability on the recording page #911
|
|
||||||
|
|
||||||
## [1.4.0] - 2026-01-25
|
|
||||||
|
|
||||||
### Added
|
|
||||||
|
|
||||||
- ✨(frontend) add configurable redirect for unauthenticated users #904
|
|
||||||
|
|
||||||
### Changed
|
|
||||||
|
|
||||||
- ♿️(frontend) add accessible back button in side panel #881
|
|
||||||
- ♿️(frontend) improve participants toggle a11y label #880
|
|
||||||
- ♿️(frontend) make carousel image decorative #871
|
|
||||||
- ♿️(frontend) reactions are now vocalized and configurable #849
|
|
||||||
- ♿️(frontend) improve background effect announcements #879
|
|
||||||
|
|
||||||
### Fixed
|
|
||||||
|
|
||||||
- 🔒(backend) prevent automatic upgrade setuptools
|
|
||||||
- ♿(frontend) improve contrast for selected options #863
|
|
||||||
- ♿️(frontend) announce copy state in invite dialog #877
|
|
||||||
- 📝(frontend) align close dialog label in rooms locale #878
|
|
||||||
- 🩹(backend) use case-insensitive email matching in the external api #887
|
|
||||||
- 🐛(frontend) ensure transcript segments are sorted by their timestamp #899
|
|
||||||
- 🐛(frontend) scope scrollbar gutter override to video rooms #882
|
|
||||||
|
|
||||||
## [1.3.0] - 2026-01-13
|
|
||||||
|
|
||||||
### Added
|
|
||||||
|
|
||||||
- ✨(summary) add dutch and german languages
|
|
||||||
- 🔧(agents) make Silero VAD optional
|
|
||||||
- 🚸(frontend) explain to a user they were ejected
|
|
||||||
|
|
||||||
### Changed
|
|
||||||
|
|
||||||
- 📈(frontend) track new recording's modes
|
|
||||||
- ♿️(frontend) improve accessibility of the background and effects menu
|
|
||||||
- ♿️(frontend) improve SR and focus for transcript and recording #810
|
|
||||||
- 💄(frontend) adjust spacing in the recording side panels
|
|
||||||
- 🚸(frontend) remove the default comma delimiter in humanized durations
|
|
||||||
|
|
||||||
### Fixed
|
|
||||||
|
|
||||||
- 🐛(frontend) remove unexpected F2 tooltip when clicking video screen
|
|
||||||
- 🩹(frontend) icon font loading to avoid text/icon flickering
|
|
||||||
|
|
||||||
## [1.2.0] - 2026-01-05
|
|
||||||
|
|
||||||
### Added
|
|
||||||
|
|
||||||
- ✨(agent) support Kyutai client for subtitle
|
- ✨(agent) support Kyutai client for subtitle
|
||||||
|
- ✨(frontend) remove the beta badge
|
||||||
- ✨(all) support starting transcription and recording simultaneously
|
- ✨(all) support starting transcription and recording simultaneously
|
||||||
- ✨(backend) persist options on a recording
|
- ✨(backend) persist options on a recording
|
||||||
- ✨(all) support choosing the transcription language
|
- ✨(all) support choosing the transcription language
|
||||||
- ✨(summary) add a download link to the audio/video file
|
- ✨(summary) add a download link to the audio/video file
|
||||||
- ✨(frontend) allow unprivileged users to request a recording
|
- ✨(frontend) allow unprivileged users to request a recording
|
||||||
|
|
||||||
### Changed
|
|
||||||
|
|
||||||
- 🚸(frontend) remove the beta badge
|
|
||||||
- ♻️(summary) extract file handling in a robust service
|
- ♻️(summary) extract file handling in a robust service
|
||||||
- ♻️(all) manage recording state on the backend side
|
- ♻️(all) manage recording state on the backend side
|
||||||
|
|
||||||
|
|||||||
+1
-4
@@ -4,7 +4,7 @@
|
|||||||
FROM python:3.13.5-alpine3.21 AS base
|
FROM python:3.13.5-alpine3.21 AS base
|
||||||
|
|
||||||
# Upgrade pip to its latest release to speed up dependencies installation
|
# Upgrade pip to its latest release to speed up dependencies installation
|
||||||
RUN python -m pip install --upgrade pip
|
RUN python -m pip install --upgrade pip setuptools
|
||||||
|
|
||||||
# Upgrade system packages to install security updates
|
# Upgrade system packages to install security updates
|
||||||
RUN apk update && \
|
RUN apk update && \
|
||||||
@@ -127,9 +127,6 @@ 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,10 +23,9 @@
|
|||||||
# ==============================================================================
|
# ==============================================================================
|
||||||
# VARIABLES
|
# VARIABLES
|
||||||
|
|
||||||
ESC := $(shell printf '\033')
|
BOLD := \033[1m
|
||||||
BOLD := $(ESC)[1m
|
RESET := \033[0m
|
||||||
RESET := $(ESC)[0m
|
GREEN := \033[1;32m
|
||||||
GREEN := $(ESC)[1;32m
|
|
||||||
|
|
||||||
|
|
||||||
# -- Database
|
# -- Database
|
||||||
@@ -86,8 +85,7 @@ 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
|
||||||
|
|||||||
@@ -1,2 +0,0 @@
|
|||||||
web: bin/buildpack_start.sh
|
|
||||||
postdeploy: python manage.py migrate
|
|
||||||
@@ -50,9 +50,6 @@ La Suite Meet is fully self-hostable and released under the MIT License, ensurin
|
|||||||
|
|
||||||
We’re continuously adding new features to enhance your experience, with the latest updates coming soon!
|
We’re continuously adding new features to enhance your experience, with the latest updates coming soon!
|
||||||
|
|
||||||
### 🚀 Major roll out to all French public servants
|
|
||||||
|
|
||||||
On the 25th of January 2026, David Amiel, France’s Minister for Civil Service and State Reform, announced the full deployment of Visio—the French government’s dedicated Meet platform—to all public servants. ([Source in French](https://www.latribune.fr/article/la-tribune-dimanche/politique/73157688099661/david-amiel-ministre-delegue-de-la-fonction-publique-nous-allons-sortir-de-la-dependance-aux-outils-americains))
|
|
||||||
|
|
||||||
## Table of Contents
|
## Table of Contents
|
||||||
|
|
||||||
@@ -89,7 +86,7 @@ We hope to see many more, here is an incomplete list of public La Suite Meet ins
|
|||||||
| [visio.numerique.gouv.fr](https://visio.numerique.gouv.fr/) | DINUM | French public agents working for the central administration and the extended public sphere. ProConnect is required to login in or sign up|
|
| [visio.numerique.gouv.fr](https://visio.numerique.gouv.fr/) | DINUM | French public agents working for the central administration and the extended public sphere. ProConnect is required to login in or sign up|
|
||||||
| [visio.suite.anct.gouv.fr](https://visio.suite.anct.gouv.fr/) | ANCT | French public agents working for the territorial administration and the extended public sphere. ProConnect is required to login in or sign up|
|
| [visio.suite.anct.gouv.fr](https://visio.suite.anct.gouv.fr/) | ANCT | French public agents working for the territorial administration and the extended public sphere. ProConnect is required to login in or sign up|
|
||||||
| [visio.lasuite.coop](https://visio.lasuite.coop/) | lasuite.coop | Free and open demo to all. Content and accounts are reset after one month |
|
| [visio.lasuite.coop](https://visio.lasuite.coop/) | lasuite.coop | Free and open demo to all. Content and accounts are reset after one month |
|
||||||
| [mosacloud.cloud](https://mosa.cloud/) | mosa.cloud | Demo instance of mosa.cloud, a dutch company providing services around La Suite apps. |
|
| [meet.demo.mosacloud.eu](https://meet.demo.mosacloud.eu/) | mosa.cloud | Demo instance of mosa.cloud, a dutch company providing services around La Suite apps. |
|
||||||
|
|
||||||
|
|
||||||
## Contributing
|
## Contributing
|
||||||
|
|||||||
@@ -18,7 +18,6 @@ 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=[
|
||||||
@@ -34,7 +33,6 @@ 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',
|
||||||
@@ -59,7 +57,6 @@ 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',
|
||||||
@@ -72,7 +69,6 @@ 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',
|
||||||
|
|||||||
@@ -1,9 +0,0 @@
|
|||||||
#!/bin/bash
|
|
||||||
|
|
||||||
set -o errexit # always exit on error
|
|
||||||
set -o pipefail # don't ignore exit codes when piping output
|
|
||||||
|
|
||||||
echo "-----> Running post-compile script"
|
|
||||||
|
|
||||||
# Cleanup
|
|
||||||
rm -rf docker docs env.d gitlint
|
|
||||||
@@ -1,50 +0,0 @@
|
|||||||
#!/bin/bash
|
|
||||||
|
|
||||||
set -o errexit # always exit on error
|
|
||||||
set -o pipefail # don't ignore exit codes when piping output
|
|
||||||
|
|
||||||
echo "-----> Running post-frontend script"
|
|
||||||
|
|
||||||
# Move the frontend build to the nginx root and clean up
|
|
||||||
mkdir -p build/
|
|
||||||
mv src/frontend/dist build/frontend-out
|
|
||||||
|
|
||||||
ASSETS_DIR=build/frontend-out/assets
|
|
||||||
if [ -n "$CUSTOM_LOGO_URL" ]; then
|
|
||||||
# Ensure https
|
|
||||||
[[ ! "$CUSTOM_LOGO_URL" =~ ^https:// ]] && echo "[custom-logo] ERROR: URL must use HTTPS" >&2 && exit 1
|
|
||||||
|
|
||||||
# Prevent SSRF
|
|
||||||
HOSTNAME=$(echo "$CUSTOM_LOGO_URL" | sed -E 's|^https://([^/:]+).*|\1|')
|
|
||||||
[[ "$HOSTNAME" =~ ^(localhost|127\.|10\.|172\.(1[6-9]|2[0-9]|3[01])\.|192\.168\.|0\.0\.0\.0|\[::1\]) ]] && echo "[custom-logo] ERROR: SSRF blocked: $HOSTNAME" >&2 && exit 1
|
|
||||||
|
|
||||||
LOGO_FILE="${ASSETS_DIR}/logo.svg"
|
|
||||||
TMP_FILE=$(mktemp "${LOGO_FILE}.XXXXXX.tmp")
|
|
||||||
|
|
||||||
# Actual download
|
|
||||||
echo "[custom-logo] INFO: Downloading custom logo from: $CUSTOM_LOGO_URL"
|
|
||||||
curl -fsSL --tlsv1.2 -o "$TMP_FILE" "$CUSTOM_LOGO_URL"
|
|
||||||
|
|
||||||
# Validate filesize
|
|
||||||
FILESIZE=$(stat -c%s "$TMP_FILE" 2>/dev/null || stat -f%z "$TMP_FILE")
|
|
||||||
[[ "$FILESIZE" -eq 0 ]] && echo "[custom-logo] ERROR: empty file" >&2 && exit 1
|
|
||||||
[[ "$FILESIZE" -gt 5242880 ]] && echo "[custom-logo] ERROR: file too large (${FILESIZE}B > 5MB)" >&2 && exit 1
|
|
||||||
|
|
||||||
# Validate file type
|
|
||||||
IS_SVG=false
|
|
||||||
|
|
||||||
HEADER=$(head -c 100 "$TMP_FILE" | tr -d '\0' | tr '[:upper:]' '[:lower:]')
|
|
||||||
[[ "$HEADER" =~ ^.*"<svg".*$ ]] && IS_SVG=true
|
|
||||||
[[ "$HEADER" =~ ^.*"<?xml".*"<svg".*$ ]] && IS_SVG=true
|
|
||||||
|
|
||||||
[[ "$IS_SVG" == false ]] && echo "[custom-logo] ERROR: not a valid SVG file" >&2 && exit 1
|
|
||||||
|
|
||||||
mv -f "$TMP_FILE" "$LOGO_FILE"
|
|
||||||
echo "[custom-logo] INFO: Custom logo downloaded successfuly"
|
|
||||||
fi
|
|
||||||
|
|
||||||
mv src/backend/* ./
|
|
||||||
mv deploy/paas/* ./
|
|
||||||
|
|
||||||
echo "3.13" > .python-version
|
|
||||||
echo "." > requirements.txt
|
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
#!/bin/bash
|
|
||||||
|
|
||||||
# Start the Django backend server
|
|
||||||
gunicorn -b 0.0.0.0:8000 meet.wsgi:application --log-file - &
|
|
||||||
|
|
||||||
# Start the Nginx server
|
|
||||||
bin/run &
|
|
||||||
|
|
||||||
# if the current shell is killed, also terminate all its children
|
|
||||||
trap "pkill SIGTERM -P $$" SIGTERM
|
|
||||||
|
|
||||||
# wait for a single child to finish,
|
|
||||||
wait -n
|
|
||||||
# then kill all the other tasks
|
|
||||||
pkill -P $$
|
|
||||||
@@ -1,52 +0,0 @@
|
|||||||
# ERB templated nginx configuration
|
|
||||||
# see https://doc.scalingo.com/platform/deployment/buildpacks/nginx
|
|
||||||
|
|
||||||
upstream backend_server {
|
|
||||||
server localhost:8000 fail_timeout=0;
|
|
||||||
}
|
|
||||||
|
|
||||||
server {
|
|
||||||
listen <%= ENV["PORT"] %>;
|
|
||||||
server_name _;
|
|
||||||
server_tokens off;
|
|
||||||
|
|
||||||
root /app/build/frontend-out;
|
|
||||||
|
|
||||||
# Django rest framework
|
|
||||||
location ^~ /api/ {
|
|
||||||
proxy_set_header X-Forwarded-Proto https;
|
|
||||||
proxy_set_header Host $http_host;
|
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
|
||||||
|
|
||||||
proxy_redirect off;
|
|
||||||
proxy_pass http://backend_server;
|
|
||||||
}
|
|
||||||
|
|
||||||
# Django admin
|
|
||||||
location ^~ /admin/ {
|
|
||||||
proxy_set_header X-Forwarded-Proto https;
|
|
||||||
proxy_set_header Host $http_host;
|
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
|
||||||
|
|
||||||
proxy_redirect off;
|
|
||||||
proxy_pass http://backend_server;
|
|
||||||
}
|
|
||||||
|
|
||||||
# Serve static files with caching
|
|
||||||
location ~* ^/assets/.*\.(css|js|json|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
|
|
||||||
expires 30d;
|
|
||||||
add_header Cache-Control "public, max-age=2592000";
|
|
||||||
}
|
|
||||||
|
|
||||||
# Serve static files
|
|
||||||
location / {
|
|
||||||
try_files $uri $uri/ /index.html;
|
|
||||||
# Add no-cache headers
|
|
||||||
add_header Cache-Control "no-cache, no-store, must-revalidate";
|
|
||||||
add_header Pragma "no-cache"; # HTTP 1.0 header for backward compatibility
|
|
||||||
add_header Expires 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
# Optionally, handle 404 errors by redirecting to index.html
|
|
||||||
error_page 404 =200 /index.html;
|
|
||||||
}
|
|
||||||
@@ -1,9 +1,9 @@
|
|||||||
upstream meet_backend {
|
upstream meet_backend {
|
||||||
server ${BACKEND_INTERNAL_HOST}:8000 fail_timeout=0;
|
server ${BACKEND_HOST}:8000 fail_timeout=0;
|
||||||
}
|
}
|
||||||
|
|
||||||
upstream meet_frontend {
|
upstream meet_frontend {
|
||||||
server ${FRONTEND_INTERNAL_HOST}:8080 fail_timeout=0;
|
server ${FRONTEND_HOST}:8080 fail_timeout=0;
|
||||||
}
|
}
|
||||||
|
|
||||||
server {
|
server {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
FROM livekit/livekit-server:v1.9.4
|
FROM livekit/livekit-server:v1.9.0
|
||||||
|
|
||||||
# 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/
|
||||||
|
|||||||
@@ -3,8 +3,3 @@ redis:
|
|||||||
address: redis:6379
|
address: redis:6379
|
||||||
keys:
|
keys:
|
||||||
devkey: secret
|
devkey: secret
|
||||||
|
|
||||||
webhook:
|
|
||||||
api_key: devkey
|
|
||||||
urls:
|
|
||||||
- http://app-dev:8000/api/v1.0/rooms/webhooks-livekit/
|
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ services:
|
|||||||
- env.d/postgresql
|
- env.d/postgresql
|
||||||
- env.d/common
|
- env.d/common
|
||||||
volumes:
|
volumes:
|
||||||
- ./data/databases/backend:/var/lib/postgresql/data
|
- ./data/databases/backend:/var/lib/postgresql/data/pgdata
|
||||||
|
|
||||||
redis:
|
redis:
|
||||||
image: redis:5
|
image: redis:5
|
||||||
@@ -20,8 +20,8 @@ services:
|
|||||||
user: ${DOCKER_USER:-1000}
|
user: ${DOCKER_USER:-1000}
|
||||||
restart: always
|
restart: always
|
||||||
env_file:
|
env_file:
|
||||||
- .env
|
|
||||||
- env.d/common
|
- env.d/common
|
||||||
|
- env.d/backend
|
||||||
- env.d/postgresql
|
- env.d/postgresql
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD", "python", "manage.py", "check"]
|
test: ["CMD", "python", "manage.py", "check"]
|
||||||
@@ -45,7 +45,6 @@ services:
|
|||||||
- /docker-entrypoint.sh
|
- /docker-entrypoint.sh
|
||||||
command: ["nginx", "-g", "daemon off;"]
|
command: ["nginx", "-g", "daemon off;"]
|
||||||
env_file:
|
env_file:
|
||||||
- .env
|
|
||||||
- env.d/common
|
- env.d/common
|
||||||
# Uncomment and set your values if using our nginx proxy example
|
# Uncomment and set your values if using our nginx proxy example
|
||||||
# environment:
|
# environment:
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
### Step 1: Prepare your working environment:
|
### Step 1: Prepare your working environment:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
mkdir -p keycloak/env.d && cd keycloak
|
mkdir keycloak/env.d && cd keycloak
|
||||||
curl -o compose.yaml https://raw.githubusercontent.com/suitenumerique/meet/refs/heads/main/docs/examples/compose/keycloak/compose.yaml
|
curl -o compose.yaml https://raw.githubusercontent.com/suitenumerique/meet/refs/heads/main/docs/examples/compose/keycloak/compose.yaml
|
||||||
curl -o env.d/kc_postgresql https://raw.githubusercontent.com/suitenumerique/meet/refs/heads/main/env.d/production.dist/kc_postgresql
|
curl -o env.d/kc_postgresql https://raw.githubusercontent.com/suitenumerique/meet/refs/heads/main/env.d/production.dist/kc_postgresql
|
||||||
curl -o env.d/keycloak https://raw.githubusercontent.com/suitenumerique/meet/refs/heads/main/env.d/production.dist/keycloak
|
curl -o env.d/keycloak https://raw.githubusercontent.com/suitenumerique/meet/refs/heads/main/env.d/production.dist/keycloak
|
||||||
|
|||||||
@@ -9,10 +9,6 @@ La Suite Meet maintainers use only the Kubernetes deployment method in productio
|
|||||||
We understand that not everyone has a Kubernetes cluster available, please follow the instructions provided [here](/docs/installation/compose.md) to set up a docker compose instance.
|
We understand that not everyone has a Kubernetes cluster available, please follow the instructions provided [here](/docs/installation/compose.md) to set up a docker compose instance.
|
||||||
We also provide [Docker images](https://hub.docker.com/u/lasuite?page=1&search=meet) that can be deployed using Compose.
|
We also provide [Docker images](https://hub.docker.com/u/lasuite?page=1&search=meet) that can be deployed using Compose.
|
||||||
|
|
||||||
## Scalingo
|
|
||||||
|
|
||||||
La Suite Meet can be deployed on Scalingo PaaS using the Suite Numérique buildpack. See the [Scalingo deployment guide](./scalingo.md) for detailed instructions.
|
|
||||||
|
|
||||||
## Other ways to install La Suite Meet
|
## Other ways to install La Suite Meet
|
||||||
Community members have contributed alternative ways to install La Suite Meet 🙏. While maintainers may not provide direct support, we help keep these instructions up to date, and you can reach out to contributors or the community for assistance.
|
Community members have contributed alternative ways to install La Suite Meet 🙏. While maintainers may not provide direct support, we help keep these instructions up to date, and you can reach out to contributors or the community for assistance.
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# Installation with docker compose
|
# Installation with docker compose
|
||||||
|
|
||||||
We provide a sample configuration for running Meet using Docker Compose. Please note that this configuration is experimental, and the official way to deploy Meet in production is to use [k8s](../installation/kubernetes.md).
|
We provide a sample configuration for running Meet using Docker Compose. Please note that this configuration is experimental, and the official way to deploy Meet in production is to use [k8s](../installation/k8s.md)
|
||||||
|
|
||||||
## Requirements
|
## Requirements
|
||||||
|
|
||||||
@@ -47,7 +47,7 @@ curl -o default.conf.template https://raw.githubusercontent.com/suitenumerique/m
|
|||||||
|
|
||||||
## Step 2: Configuration
|
## Step 2: Configuration
|
||||||
|
|
||||||
Meet configuration is achieved through environment variables. We provide a [detailed description of all variables](../../src/helm/meet/README.md).
|
Meet configuration is achieved through environment variables. We provide a [detailed description of all variables](../env.md).
|
||||||
|
|
||||||
In this example, we assume the following services:
|
In this example, we assume the following services:
|
||||||
|
|
||||||
@@ -129,7 +129,7 @@ The following ports will need to be opened:
|
|||||||
- 7881/tcp - WebRTC ICE over TCP
|
- 7881/tcp - WebRTC ICE over TCP
|
||||||
- 7882/udp - for WebRTC multiplexing over UDP
|
- 7882/udp - for WebRTC multiplexing over UDP
|
||||||
|
|
||||||
If you are using ufw, enter the following:
|
If you are using ufw, enter the follwoing:
|
||||||
```
|
```
|
||||||
ufw allow 80/tcp
|
ufw allow 80/tcp
|
||||||
ufw allow 443/tcp
|
ufw allow 443/tcp
|
||||||
@@ -177,15 +177,6 @@ You will need to uncomment the environment and network sections in compose file
|
|||||||
# external: true
|
# external: true
|
||||||
```
|
```
|
||||||
|
|
||||||
#### Caddy Reverse Proxy
|
|
||||||
Expose the Frontend port to the host
|
|
||||||
```yaml
|
|
||||||
frontend:
|
|
||||||
…
|
|
||||||
ports:
|
|
||||||
- "8086:8086"
|
|
||||||
```
|
|
||||||
|
|
||||||
## Step 5: Start Meet
|
## Step 5: Start Meet
|
||||||
|
|
||||||
You are ready to start your Meet application !
|
You are ready to start your Meet application !
|
||||||
@@ -207,7 +198,7 @@ Replace `<admin email>` with the email of your admin user and generate a secure
|
|||||||
|
|
||||||
Your Meet instance is now available on the domain you defined, https://meet.yourdomain.tld.
|
Your Meet instance is now available on the domain you defined, https://meet.yourdomain.tld.
|
||||||
|
|
||||||
The admin interface is available on https://meet.yourdomain.tld/admin with the admin user you just created.
|
THe admin interface is available on https://meet.yourdomain.tld/admin with the admin user you just created.
|
||||||
|
|
||||||
## How to upgrade your Meet application
|
## How to upgrade your Meet application
|
||||||
|
|
||||||
|
|||||||
@@ -1,185 +0,0 @@
|
|||||||
# Deployment on Scalingo
|
|
||||||
|
|
||||||
This guide explains how to deploy La Suite Meet on [Scalingo](https://scalingo.com/) using the [Suite Numérique buildpack](https://github.com/suitenumerique/buildpack).
|
|
||||||
|
|
||||||
## Overview
|
|
||||||
|
|
||||||
Scalingo is a Platform-as-a-Service (PaaS) that simplifies application deployment. This setup uses a custom buildpack to handle both the frontend (Vite) and backend (Django) builds, serving them through Nginx.
|
|
||||||
|
|
||||||
## Prerequisites
|
|
||||||
|
|
||||||
- A Scalingo account
|
|
||||||
- Scalingo CLI installed (optional but recommended)
|
|
||||||
- A PostgreSQL database addon
|
|
||||||
- A Redis addon (for caching and sessions)
|
|
||||||
|
|
||||||
## Step 1: Create Your App
|
|
||||||
|
|
||||||
Create a new app on Scalingo using `scalingo` cli or using the [Scalingo dashboard](https://dashboard.scalingo.com/).
|
|
||||||
|
|
||||||
## Step 2: Provision Addons
|
|
||||||
|
|
||||||
Add the required PostgreSQL and Redis services.
|
|
||||||
|
|
||||||
This will set the following environment variables automatically:
|
|
||||||
- `SCALINGO_POSTGRESQL_URL` - Database connection string
|
|
||||||
- `SCALINGO_REDIS_URL` - Redis connection string
|
|
||||||
|
|
||||||
## Step 3: Configure Environment Variables
|
|
||||||
|
|
||||||
Set the following environment variables in your Scalingo app:
|
|
||||||
|
|
||||||
### Buildpack Configuration
|
|
||||||
|
|
||||||
```bash
|
|
||||||
scalingo env-set BUILDPACK_URL="https://github.com/suitenumerique/buildpack#main"
|
|
||||||
scalingo env-set LASUITE_APP_NAME="meet"
|
|
||||||
scalingo env-set LASUITE_BACKEND_DIR="."
|
|
||||||
scalingo env-set LASUITE_FRONTEND_DIR="src/frontend/"
|
|
||||||
scalingo env-set LASUITE_NGINX_DIR="."
|
|
||||||
scalingo env-set LASUITE_SCRIPT_POSTCOMPILE="bin/buildpack_postcompile.sh"
|
|
||||||
scalingo env-set LASUITE_SCRIPT_POSTFRONTEND="bin/buildpack_postfrontend.sh"
|
|
||||||
```
|
|
||||||
|
|
||||||
### Database and Cache
|
|
||||||
|
|
||||||
```bash
|
|
||||||
scalingo env-set DATABASE_URL="\$SCALINGO_POSTGRESQL_URL"
|
|
||||||
scalingo env-set REDIS_URL="\$SCALINGO_REDIS_URL"
|
|
||||||
```
|
|
||||||
|
|
||||||
### Django Settings
|
|
||||||
|
|
||||||
```bash
|
|
||||||
scalingo env-set DJANGO_SETTINGS_MODULE="meet.settings"
|
|
||||||
scalingo env-set DJANGO_CONFIGURATION="Production"
|
|
||||||
scalingo env-set DJANGO_SECRET_KEY="<generate-a-secure-secret-key>"
|
|
||||||
scalingo env-set DJANGO_ALLOWED_HOSTS="my-meet-app.osc-fr1.scalingo.io"
|
|
||||||
```
|
|
||||||
|
|
||||||
### OIDC Authentication
|
|
||||||
|
|
||||||
Configure your OIDC provider (e.g., Keycloak, Authentik):
|
|
||||||
|
|
||||||
```bash
|
|
||||||
scalingo env-set OIDC_OP_BASE_URL="https://auth.yourdomain.com/realms/meet"
|
|
||||||
scalingo env-set OIDC_RP_CLIENT_ID="meet-client-id"
|
|
||||||
scalingo env-set OIDC_RP_CLIENT_SECRET="<your-client-secret>"
|
|
||||||
scalingo env-set OIDC_RP_SIGN_ALGO="RS256"
|
|
||||||
```
|
|
||||||
|
|
||||||
### LiveKit Configuration
|
|
||||||
|
|
||||||
Meet requires a LiveKit server for video conferencing:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
scalingo env-set LIVEKIT_API_URL="wss://livekit.yourdomain.com"
|
|
||||||
scalingo env-set LIVEKIT_API_KEY="<your-livekit-api-key>"
|
|
||||||
scalingo env-set LIVEKIT_API_SECRET="<your-livekit-api-secret>"
|
|
||||||
```
|
|
||||||
|
|
||||||
### Email Configuration (Optional)
|
|
||||||
|
|
||||||
For email notifications see https://doc.scalingo.com/platform/app/sending-emails:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
scalingo env-set DJANGO_EMAIL_HOST="smtp.example.org"
|
|
||||||
scalingo env-set DJANGO_EMAIL_PORT="587"
|
|
||||||
scalingo env-set DJANGO_EMAIL_HOST_USER="<smtp-user>"
|
|
||||||
scalingo env-set DJANGO_EMAIL_HOST_PASSWORD="<smtp-password>"
|
|
||||||
scalingo env-set DJANGO_EMAIL_USE_TLS="True"
|
|
||||||
scalingo env-set DJANGO_EMAIL_FROM="meet@yourdomain.com"
|
|
||||||
```
|
|
||||||
|
|
||||||
## Step 4: Deploy
|
|
||||||
|
|
||||||
Deploy your application:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git push scalingo main
|
|
||||||
```
|
|
||||||
|
|
||||||
The Procfile will automatically:
|
|
||||||
1. Build the frontend (Vite)
|
|
||||||
2. Build the backend (Django)
|
|
||||||
3. Run the post-compile script (cleanup)
|
|
||||||
4. Run the post-frontend script (move assets and prepare for deployment)
|
|
||||||
5. Start Nginx and Gunicorn
|
|
||||||
6. Run django migrations
|
|
||||||
|
|
||||||
## Step 5: Create superuser
|
|
||||||
|
|
||||||
After the first deployment, create an admin user:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
scalingo run python manage.py createsuperuser
|
|
||||||
```
|
|
||||||
|
|
||||||
## Custom Domain (Optional)
|
|
||||||
|
|
||||||
To use a custom domain:
|
|
||||||
|
|
||||||
1. Add the domain in Scalingo dashboard
|
|
||||||
2. Update `DJANGO_ALLOWED_HOSTS` with your custom domain
|
|
||||||
3. Configure your DNS to point to Scalingo
|
|
||||||
|
|
||||||
```bash
|
|
||||||
scalingo domains-add meet.yourdomain.com
|
|
||||||
scalingo env-set DJANGO_ALLOWED_HOSTS="meet.yourdomain.com,my-meet-app.osc-fr1.scalingo.io"
|
|
||||||
```
|
|
||||||
|
|
||||||
## Custom Logo (Optional)
|
|
||||||
|
|
||||||
To use a custom logo, set the `CUSTOM_LOGO_URL` environment variable with an HTTPS URL pointing to an SVG item (max 5MB):
|
|
||||||
|
|
||||||
```bash
|
|
||||||
scalingo env-set CUSTOM_LOGO_URL="https://cdn.yourdomain.com/logo.svg"
|
|
||||||
```
|
|
||||||
|
|
||||||
## Troubleshooting
|
|
||||||
|
|
||||||
### Check Logs
|
|
||||||
|
|
||||||
```bash
|
|
||||||
scalingo logs --tail
|
|
||||||
```
|
|
||||||
|
|
||||||
### Common Issues
|
|
||||||
|
|
||||||
1. **Build fails**: Check that all required environment variables are set
|
|
||||||
2. **Database connection error**: Verify `DATABASE_URL` is correctly set to `$SCALINGO_POSTGRESQL_URL`
|
|
||||||
3. **Static files not served**: Ensure the buildpack post-frontend script ran successfully
|
|
||||||
4. **OIDC errors**: Verify your OIDC provider configuration and callback URLs
|
|
||||||
|
|
||||||
### Useful Commands
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Open a console
|
|
||||||
scalingo run bash
|
|
||||||
|
|
||||||
# Restart the app
|
|
||||||
scalingo restart
|
|
||||||
|
|
||||||
# Scale containers
|
|
||||||
scalingo scale web:2
|
|
||||||
|
|
||||||
# One-off command
|
|
||||||
scalingo run python manage.py shell
|
|
||||||
```
|
|
||||||
|
|
||||||
## Architecture
|
|
||||||
|
|
||||||
On Scalingo, the application runs as follows:
|
|
||||||
|
|
||||||
1. **Build Phase**: The buildpack compiles both frontend and backend
|
|
||||||
2. **Runtime**:
|
|
||||||
- Nginx serves static files and proxies to the backend
|
|
||||||
- Gunicorn runs the Django WSGI application
|
|
||||||
- Both processes are managed by the `bin/buildpack_start.sh` script
|
|
||||||
|
|
||||||
## Additional Resources
|
|
||||||
|
|
||||||
- [Scalingo Documentation](https://doc.scalingo.com/)
|
|
||||||
- [Suite Numérique Buildpack](https://github.com/suitenumerique/buildpack)
|
|
||||||
- [Meet Environment Variables](../../src/helm/meet/README.md)
|
|
||||||
- [Django Configurations Documentation](https://django-configurations.readthedocs.io/)
|
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
APP_NAME="meet-app-summary-dev"
|
APP_NAME="meet-app-summary-dev"
|
||||||
APP_API_TOKEN="password"
|
APP_API_TOKEN="password"
|
||||||
|
|
||||||
AWS_STORAGE_BUCKET_NAME="http://meet-media-storage"
|
AWS_STORAGE_BUCKET_NAME="meet-media-storage"
|
||||||
AWS_S3_ENDPOINT_URL="minio:9000"
|
AWS_S3_ENDPOINT_URL="minio:9000"
|
||||||
AWS_S3_SECURE_ACCESS=false
|
AWS_S3_SECURE_ACCESS=false
|
||||||
|
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ DJANGO_EMAIL_FROM=<your email address>
|
|||||||
#DJANGO_EMAIL_USE_SSL=true # A flag to enable or disable SSL for email sending.
|
#DJANGO_EMAIL_USE_SSL=true # A flag to enable or disable SSL for email sending.
|
||||||
|
|
||||||
DJANGO_EMAIL_BRAND_NAME="La Suite Numérique"
|
DJANGO_EMAIL_BRAND_NAME="La Suite Numérique"
|
||||||
DJANGO_EMAIL_LOGO_IMG="https://${MEET_HOST}/assets/logo-suite-numerique.png"
|
DJANGO_EMAIL_LOGO_IMG="https://${meet_HOST}/assets/logo-suite-numerique.png"
|
||||||
|
|
||||||
# Backend url
|
# Backend url
|
||||||
MEET_BASE_URL="https://${MEET_HOST}"
|
MEET_BASE_URL="https://${MEET_HOST}"
|
||||||
|
|||||||
@@ -2,7 +2,6 @@
|
|||||||
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,21 +3,6 @@
|
|||||||
"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",
|
||||||
@@ -30,12 +15,6 @@
|
|||||||
"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,8 +4,6 @@ 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
|
||||||
@@ -21,9 +19,6 @@ 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}
|
||||||
|
|
||||||
|
|||||||
@@ -31,7 +31,6 @@ logger = logging.getLogger("transcriber")
|
|||||||
|
|
||||||
TRANSCRIBER_AGENT_NAME = os.getenv("TRANSCRIBER_AGENT_NAME", "multi-user-transcriber")
|
TRANSCRIBER_AGENT_NAME = os.getenv("TRANSCRIBER_AGENT_NAME", "multi-user-transcriber")
|
||||||
STT_PROVIDER = os.getenv("STT_PROVIDER", "deepgram")
|
STT_PROVIDER = os.getenv("STT_PROVIDER", "deepgram")
|
||||||
ENABLE_SILERO_VAD = os.getenv("ENABLE_SILERO_VAD", "true").lower() == "true"
|
|
||||||
|
|
||||||
|
|
||||||
def create_stt_provider():
|
def create_stt_provider():
|
||||||
@@ -123,8 +122,9 @@ class MultiUserTranscriber:
|
|||||||
if participant.identity in self._sessions:
|
if participant.identity in self._sessions:
|
||||||
return self._sessions[participant.identity]
|
return self._sessions[participant.identity]
|
||||||
|
|
||||||
vad = self.ctx.proc.userdata.get("vad", None)
|
session = AgentSession(
|
||||||
session = AgentSession(vad=vad)
|
vad=self.ctx.proc.userdata["vad"],
|
||||||
|
)
|
||||||
room_io = RoomIO(
|
room_io = RoomIO(
|
||||||
agent_session=session,
|
agent_session=session,
|
||||||
room=self.ctx.room,
|
room=self.ctx.room,
|
||||||
@@ -193,7 +193,6 @@ async def handle_transcriber_job_request(job_req: JobRequest) -> None:
|
|||||||
|
|
||||||
def prewarm(proc: JobProcess):
|
def prewarm(proc: JobProcess):
|
||||||
"""Preload voice activity detection model."""
|
"""Preload voice activity detection model."""
|
||||||
if ENABLE_SILERO_VAD:
|
|
||||||
proc.userdata["vad"] = silero.VAD.load()
|
proc.userdata["vad"] = silero.VAD.load()
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,15 +1,14 @@
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "agents"
|
name = "agents"
|
||||||
version = "1.9.0"
|
version = "1.1.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]
|
||||||
|
|||||||
@@ -115,10 +115,6 @@ class RoomAdmin(admin.ModelAdmin):
|
|||||||
list_filter = ["access_level", "created_at"]
|
list_filter = ["access_level", "created_at"]
|
||||||
readonly_fields = ["id", "created_at", "updated_at"]
|
readonly_fields = ["id", "created_at", "updated_at"]
|
||||||
|
|
||||||
def get_queryset(self, request):
|
|
||||||
"""Optimize queries by prefetching related access and user data to avoid N+1 queries."""
|
|
||||||
return super().get_queryset(request).prefetch_related("accesses__user")
|
|
||||||
|
|
||||||
def get_owner(self, obj):
|
def get_owner(self, obj):
|
||||||
"""Return the owner of the room for display in the admin list."""
|
"""Return the owner of the room for display in the admin list."""
|
||||||
|
|
||||||
@@ -142,7 +138,6 @@ class RecordingAccessInline(admin.TabularInline):
|
|||||||
|
|
||||||
model = models.RecordingAccess
|
model = models.RecordingAccess
|
||||||
extra = 0
|
extra = 0
|
||||||
autocomplete_fields = ["user"]
|
|
||||||
|
|
||||||
|
|
||||||
@admin.action(description=_("Resend notification to external service"))
|
@admin.action(description=_("Resend notification to external service"))
|
||||||
@@ -212,18 +207,8 @@ class RecordingAdmin(admin.ModelAdmin):
|
|||||||
"created_at",
|
"created_at",
|
||||||
"worker_id",
|
"worker_id",
|
||||||
)
|
)
|
||||||
list_filter = ["created_at"]
|
list_filter = ["status", "room", "created_at"]
|
||||||
list_select_related = ("room",)
|
readonly_fields = ["id", "created_at", "updated_at"]
|
||||||
readonly_fields = (
|
|
||||||
"id",
|
|
||||||
"created_at",
|
|
||||||
"options",
|
|
||||||
"mode",
|
|
||||||
"room",
|
|
||||||
"status",
|
|
||||||
"updated_at",
|
|
||||||
"worker_id",
|
|
||||||
)
|
|
||||||
actions = [resend_notification]
|
actions = [resend_notification]
|
||||||
|
|
||||||
def get_queryset(self, request):
|
def get_queryset(self, request):
|
||||||
|
|||||||
@@ -2,8 +2,6 @@
|
|||||||
|
|
||||||
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 = {
|
||||||
@@ -47,27 +45,11 @@ 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
|
||||||
|
|
||||||
if not request.user.is_authenticated:
|
return 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,20 +1,15 @@
|
|||||||
"""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 django_pydantic_field.rest_framework import SchemaField
|
from livekit.api import ParticipantPermission
|
||||||
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):
|
||||||
@@ -28,25 +23,6 @@ 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
|
||||||
@@ -225,27 +201,6 @@ 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."""
|
||||||
|
|
||||||
@@ -258,11 +213,10 @@ class StartRecordingSerializer(BaseValidationOnlySerializer):
|
|||||||
"screen_recording or transcript.",
|
"screen_recording or transcript.",
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
options = SchemaField(
|
options = serializers.JSONField(
|
||||||
schema=RecordingOptions | None,
|
|
||||||
required=False,
|
required=False,
|
||||||
allow_null=True,
|
allow_null=True,
|
||||||
help_text="Recording options",
|
default=dict,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -307,28 +261,6 @@ 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."""
|
||||||
|
|
||||||
@@ -340,11 +272,10 @@ class UpdateParticipantSerializer(BaseParticipantsManagementSerializer):
|
|||||||
allow_null=True,
|
allow_null=True,
|
||||||
help_text="Participant attributes as JSON object",
|
help_text="Participant attributes as JSON object",
|
||||||
)
|
)
|
||||||
permission = SchemaField(
|
permission = serializers.DictField(
|
||||||
schema=ParticipantPermission | None,
|
|
||||||
required=False,
|
required=False,
|
||||||
allow_null=True,
|
allow_null=True,
|
||||||
help_text="Participant permissions",
|
help_text="Participant permission as JSON object",
|
||||||
)
|
)
|
||||||
name = serializers.CharField(
|
name = serializers.CharField(
|
||||||
max_length=255,
|
max_length=255,
|
||||||
@@ -354,33 +285,6 @@ 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"]
|
||||||
@@ -396,4 +300,12 @@ 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
|
||||||
|
|||||||
@@ -1,26 +0,0 @@
|
|||||||
"""Throttling modules for the API."""
|
|
||||||
|
|
||||||
from lasuite.drf.throttling import MonitoredThrottleMixin
|
|
||||||
from rest_framework.throttling import AnonRateThrottle
|
|
||||||
from sentry_sdk import capture_message
|
|
||||||
|
|
||||||
|
|
||||||
def sentry_monitoring_throttle_failure(message):
|
|
||||||
"""Log when a failure occurs to detect rate limiting issues."""
|
|
||||||
capture_message(message, "warning")
|
|
||||||
|
|
||||||
|
|
||||||
class MonitoredAnonRateThrottle(MonitoredThrottleMixin, AnonRateThrottle):
|
|
||||||
"""Throttle for the monitored scoped rate throttle."""
|
|
||||||
|
|
||||||
|
|
||||||
class RequestEntryAnonRateThrottle(MonitoredAnonRateThrottle):
|
|
||||||
"""Throttle Anonymous user requesting room entry"""
|
|
||||||
|
|
||||||
scope = "request_entry"
|
|
||||||
|
|
||||||
|
|
||||||
class CreationCallbackAnonRateThrottle(MonitoredAnonRateThrottle):
|
|
||||||
"""Throttle Anonymous user requesting room generation callback"""
|
|
||||||
|
|
||||||
scope = "creation_callback"
|
|
||||||
@@ -10,7 +10,7 @@ from django.http import Http404
|
|||||||
from django.shortcuts import get_object_or_404
|
from django.shortcuts import get_object_or_404
|
||||||
from django.utils.text import slugify
|
from django.utils.text import slugify
|
||||||
|
|
||||||
from rest_framework import decorators, mixins, pagination, viewsets
|
from rest_framework import decorators, mixins, pagination, throttling, viewsets
|
||||||
from rest_framework import (
|
from rest_framework import (
|
||||||
exceptions as drf_exceptions,
|
exceptions as drf_exceptions,
|
||||||
)
|
)
|
||||||
@@ -58,7 +58,7 @@ from core.services.room_creation import RoomCreation
|
|||||||
from core.services.subtitle import SubtitleException, SubtitleService
|
from core.services.subtitle import SubtitleException, SubtitleService
|
||||||
|
|
||||||
from ..authentication.livekit import LiveKitTokenAuthentication
|
from ..authentication.livekit import LiveKitTokenAuthentication
|
||||||
from . import permissions, serializers, throttling
|
from . import permissions, serializers
|
||||||
from .feature_flag import FeatureFlag
|
from .feature_flag import FeatureFlag
|
||||||
|
|
||||||
# pylint: disable=too-many-ancestors
|
# pylint: disable=too-many-ancestors
|
||||||
@@ -187,10 +187,22 @@ class UserViewSet(
|
|||||||
"""
|
"""
|
||||||
context = {"request": request}
|
context = {"request": request}
|
||||||
return drf_response.Response(
|
return drf_response.Response(
|
||||||
serializers.UserMeSerializer(request.user, context=context).data
|
self.serializer_class(request.user, context=context).data
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class RequestEntryAnonRateThrottle(throttling.AnonRateThrottle):
|
||||||
|
"""Throttle Anonymous user requesting room entry"""
|
||||||
|
|
||||||
|
scope = "request_entry"
|
||||||
|
|
||||||
|
|
||||||
|
class CreationCallbackAnonRateThrottle(throttling.AnonRateThrottle):
|
||||||
|
"""Throttle Anonymous user requesting room generation callback"""
|
||||||
|
|
||||||
|
scope = "creation_callback"
|
||||||
|
|
||||||
|
|
||||||
class RoomViewSet(
|
class RoomViewSet(
|
||||||
mixins.CreateModelMixin,
|
mixins.CreateModelMixin,
|
||||||
mixins.DestroyModelMixin,
|
mixins.DestroyModelMixin,
|
||||||
@@ -296,14 +308,12 @@ class RoomViewSet(
|
|||||||
)
|
)
|
||||||
|
|
||||||
mode = serializer.validated_data["mode"]
|
mode = serializer.validated_data["mode"]
|
||||||
options = serializer.validated_data.get("options")
|
options = serializer.validated_data["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,
|
room=room, mode=mode, options=options
|
||||||
mode=mode,
|
|
||||||
options=options.model_dump(exclude_none=True) if options else {},
|
|
||||||
)
|
)
|
||||||
|
|
||||||
models.RecordingAccess.objects.create(
|
models.RecordingAccess.objects.create(
|
||||||
@@ -369,7 +379,7 @@ class RoomViewSet(
|
|||||||
methods=["post"],
|
methods=["post"],
|
||||||
url_path="request-entry",
|
url_path="request-entry",
|
||||||
permission_classes=[],
|
permission_classes=[],
|
||||||
throttle_classes=[throttling.RequestEntryAnonRateThrottle],
|
throttle_classes=[RequestEntryAnonRateThrottle],
|
||||||
)
|
)
|
||||||
def request_entry(self, request, pk=None): # pylint: disable=unused-argument
|
def request_entry(self, request, pk=None): # pylint: disable=unused-argument
|
||||||
"""Request entry to a room"""
|
"""Request entry to a room"""
|
||||||
@@ -479,7 +489,7 @@ class RoomViewSet(
|
|||||||
methods=["post"],
|
methods=["post"],
|
||||||
url_path="creation-callback",
|
url_path="creation-callback",
|
||||||
permission_classes=[],
|
permission_classes=[],
|
||||||
throttle_classes=[throttling.CreationCallbackAnonRateThrottle],
|
throttle_classes=[CreationCallbackAnonRateThrottle],
|
||||||
)
|
)
|
||||||
def creation_callback(self, request):
|
def creation_callback(self, request):
|
||||||
"""Retrieve cached room data via an unauthenticated request with a unique ID.
|
"""Retrieve cached room data via an unauthenticated request with a unique ID.
|
||||||
@@ -609,15 +619,13 @@ 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=permission.model_dump() if permission else None,
|
permission=serializer.validated_data.get("permission"),
|
||||||
name=serializer.validated_data.get("name"),
|
name=serializer.validated_data.get("name"),
|
||||||
)
|
)
|
||||||
except ParticipantsManagementException:
|
except ParticipantsManagementException:
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
"""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
|
||||||
@@ -11,7 +10,6 @@ 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,
|
||||||
@@ -19,8 +17,6 @@ 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.
|
||||||
@@ -63,21 +59,6 @@ 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.
|
||||||
|
|||||||
@@ -1,29 +0,0 @@
|
|||||||
"""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
|
|
||||||
)
|
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
"""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.
|
|
||||||
"""
|
|
||||||
@@ -1,120 +0,0 @@
|
|||||||
"""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
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
"""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}
|
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
"""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,52 +1,26 @@
|
|||||||
"""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.services import jwt_token
|
|
||||||
|
|
||||||
User = get_user_model()
|
User = get_user_model()
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
class BaseJWTAuthentication(authentication.BaseAuthentication):
|
class ApplicationJWTAuthentication(authentication.BaseAuthentication):
|
||||||
"""Base JWT authentication class."""
|
"""JWT authentication for application-delegated API access.
|
||||||
|
|
||||||
def __init__(
|
Validates JWT tokens issued to applications that are acting on behalf
|
||||||
self, secret_key, algorithm, issuer, audience, expiration_seconds, token_type
|
of users. Tokens must include user_id, client_id, and delegation flag.
|
||||||
):
|
|
||||||
"""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.
|
||||||
|
|
||||||
@@ -72,78 +46,6 @@ class BaseJWTAuthentication(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.
|
||||||
|
|
||||||
@@ -158,61 +60,60 @@ class BaseJWTAuthentication(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
|
||||||
payload = self.decode_jwt(token)
|
try:
|
||||||
|
payload = pyJwt.decode(
|
||||||
if payload is None:
|
token,
|
||||||
return None
|
settings.APPLICATION_JWT_SECRET_KEY,
|
||||||
|
algorithms=[settings.APPLICATION_JWT_ALG],
|
||||||
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,
|
issuer=settings.APPLICATION_JWT_ISSUER,
|
||||||
audience=settings.APPLICATION_JWT_AUDIENCE,
|
audience=settings.APPLICATION_JWT_AUDIENCE,
|
||||||
expiration_seconds=settings.APPLICATION_JWT_EXPIRATION_SECONDS,
|
|
||||||
token_type=settings.APPLICATION_JWT_TOKEN_TYPE,
|
|
||||||
)
|
)
|
||||||
|
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
|
||||||
|
|
||||||
def validate_payload(self, payload):
|
user_id = payload.get("user_id")
|
||||||
"""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.")
|
||||||
|
|
||||||
try:
|
|
||||||
application = Application.objects.get(client_id=client_id)
|
|
||||||
except Application.DoesNotExist as e:
|
|
||||||
logger.warning("Application not found: %s", client_id)
|
|
||||||
raise exceptions.AuthenticationFailed("Application not found.") from e
|
|
||||||
|
|
||||||
if not application.active:
|
|
||||||
logger.warning(
|
|
||||||
"Inactive application attempted authentication: %s", client_id
|
|
||||||
)
|
|
||||||
raise exceptions.AuthenticationFailed("Application is disabled.")
|
|
||||||
|
|
||||||
if not is_delegated:
|
if not is_delegated:
|
||||||
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."""
|
||||||
|
|||||||
@@ -33,11 +33,12 @@ class BaseScopePermission(permissions.BasePermission):
|
|||||||
Raises:
|
Raises:
|
||||||
PermissionDenied: If required scope is missing from token
|
PermissionDenied: If required scope is missing from token
|
||||||
"""
|
"""
|
||||||
# Get the current action (e.g., 'list', 'create'), if None let DRF handle it
|
# Get the current action (e.g., 'list', 'create')
|
||||||
action = getattr(view, "action", None)
|
action = getattr(view, "action", None)
|
||||||
if not action:
|
if not action:
|
||||||
# DRF routers return a 405 for unsupported methods
|
raise exceptions.PermissionDenied(
|
||||||
return True
|
"Insufficient permissions. Unknown action."
|
||||||
|
)
|
||||||
|
|
||||||
required_scope = self.scope_map.get(action)
|
required_scope = self.scope_map.get(action)
|
||||||
if not required_scope:
|
if not required_scope:
|
||||||
@@ -56,12 +57,9 @@ class BaseScopePermission(permissions.BasePermission):
|
|||||||
if isinstance(token_scopes, str):
|
if isinstance(token_scopes, str):
|
||||||
token_scopes = token_scopes.split()
|
token_scopes = token_scopes.split()
|
||||||
|
|
||||||
# Ensure scopes is a deduplicated list (preserving order) and lowercase all scopes
|
|
||||||
token_scopes = list(dict.fromkeys(scope.lower() for scope in token_scopes))
|
|
||||||
|
|
||||||
if settings.OIDC_RS_SCOPES_PREFIX:
|
if settings.OIDC_RS_SCOPES_PREFIX:
|
||||||
token_scopes = [
|
token_scopes = [
|
||||||
scope.removeprefix(f"{settings.OIDC_RS_SCOPES_PREFIX}:")
|
scope.replace(f"{settings.OIDC_RS_SCOPES_PREFIX}:", "")
|
||||||
for scope in token_scopes
|
for scope in token_scopes
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -84,23 +82,3 @@ class HasRequiredRoomScope(BaseScopePermission):
|
|||||||
"partial_update": models.ApplicationScope.ROOMS_UPDATE,
|
"partial_update": models.ApplicationScope.ROOMS_UPDATE,
|
||||||
"destroy": models.ApplicationScope.ROOMS_DELETE,
|
"destroy": models.ApplicationScope.ROOMS_DELETE,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
class RoomPermissions(permissions.BasePermission):
|
|
||||||
"""Permissions applying to the room API endpoint."""
|
|
||||||
|
|
||||||
def has_permission(self, request, view):
|
|
||||||
"""Allow access only to authenticated users."""
|
|
||||||
return request.user.is_authenticated
|
|
||||||
|
|
||||||
def has_object_permission(self, request, view, obj):
|
|
||||||
"""Enforce role-based access: read=any role, delete=owner, write=admin or owner."""
|
|
||||||
user = request.user
|
|
||||||
|
|
||||||
if request.method in permissions.SAFE_METHODS:
|
|
||||||
return obj.has_any_role(user)
|
|
||||||
|
|
||||||
if request.method == "DELETE":
|
|
||||||
return obj.is_owner(user)
|
|
||||||
|
|
||||||
return obj.is_administrator_or_owner(user)
|
|
||||||
|
|||||||
@@ -1,12 +1,14 @@
|
|||||||
"""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
|
||||||
from django.contrib.auth.hashers import check_password
|
from django.contrib.auth.hashers import check_password
|
||||||
from django.core.exceptions import SuspiciousOperation, ValidationError
|
from django.core.exceptions import 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 (
|
||||||
@@ -20,14 +22,13 @@ 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
|
||||||
|
|
||||||
logger = getLogger(__name__)
|
logger = getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
class ApplicationViewSet(viewsets.ViewSet):
|
class ApplicationViewSet(viewsets.GenericViewSet):
|
||||||
"""API endpoints for application authentication and token generation."""
|
"""API endpoints for application authentication and token generation."""
|
||||||
|
|
||||||
@decorators.action(
|
@decorators.action(
|
||||||
@@ -92,7 +93,7 @@ class ApplicationViewSet(viewsets.ViewSet):
|
|||||||
)
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
user = models.User.objects.get(email__iexact=email)
|
user = models.User.objects.get(email=email)
|
||||||
except models.User.DoesNotExist as e:
|
except models.User.DoesNotExist as e:
|
||||||
if (
|
if (
|
||||||
settings.APPLICATION_ALLOW_USER_CREATION
|
settings.APPLICATION_ALLOW_USER_CREATION
|
||||||
@@ -122,33 +123,34 @@ class ApplicationViewSet(viewsets.ViewSet):
|
|||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
raise drf_exceptions.NotFound("User not found.") from e
|
raise drf_exceptions.NotFound("User not found.") from e
|
||||||
except models.User.MultipleObjectsReturned as e:
|
|
||||||
raise SuspiciousOperation(
|
|
||||||
"Multiple user accounts share a common email."
|
|
||||||
) from e
|
|
||||||
|
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
scope = " ".join(application.scopes or [])
|
scope = " ".join(application.scopes or [])
|
||||||
|
|
||||||
token_service = JwtTokenService(
|
payload = {
|
||||||
secret_key=settings.APPLICATION_JWT_SECRET_KEY,
|
"iss": settings.APPLICATION_JWT_ISSUER,
|
||||||
algorithm=settings.APPLICATION_JWT_ALG,
|
"aud": settings.APPLICATION_JWT_AUDIENCE,
|
||||||
issuer=settings.APPLICATION_JWT_ISSUER,
|
"iat": now,
|
||||||
audience=settings.APPLICATION_JWT_AUDIENCE,
|
"exp": now + timedelta(seconds=settings.APPLICATION_JWT_EXPIRATION_SECONDS),
|
||||||
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,
|
"client_id": client_id,
|
||||||
|
"scope": scope,
|
||||||
|
"user_id": str(user.id),
|
||||||
"delegated": True,
|
"delegated": True,
|
||||||
},
|
}
|
||||||
|
|
||||||
|
token = jwt.encode(
|
||||||
|
payload,
|
||||||
|
settings.APPLICATION_JWT_SECRET_KEY,
|
||||||
|
algorithm=settings.APPLICATION_JWT_ALG,
|
||||||
)
|
)
|
||||||
|
|
||||||
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,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -176,9 +178,7 @@ class RoomViewSet(
|
|||||||
ResourceServerAuthentication,
|
ResourceServerAuthentication,
|
||||||
]
|
]
|
||||||
permission_classes = [
|
permission_classes = [
|
||||||
api.permissions.IsAuthenticated
|
api.permissions.IsAuthenticated & permissions.HasRequiredRoomScope
|
||||||
& permissions.HasRequiredRoomScope
|
|
||||||
& permissions.RoomPermissions
|
|
||||||
]
|
]
|
||||||
queryset = models.Room.objects.all()
|
queryset = models.Room.objects.all()
|
||||||
serializer_class = serializers.RoomSerializer
|
serializer_class = serializers.RoomSerializer
|
||||||
|
|||||||
@@ -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=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')),
|
('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')),
|
||||||
('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', through_fields=('resource', 'user'), to=settings.AUTH_USER_MODEL),
|
field=models.ManyToManyField(related_name='resources', through='core.ResourceAccess', 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=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'),
|
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'),
|
||||||
),
|
),
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -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=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'),
|
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'),
|
||||||
),
|
),
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -292,10 +292,6 @@ class Resource(BaseModel):
|
|||||||
role = RoleChoices.MEMBER
|
role = RoleChoices.MEMBER
|
||||||
return role
|
return role
|
||||||
|
|
||||||
def has_any_role(self, user):
|
|
||||||
"""Check if a user has any role on the resource."""
|
|
||||||
return self.get_role(user) is not None
|
|
||||||
|
|
||||||
def is_administrator_or_owner(self, user):
|
def is_administrator_or_owner(self, user):
|
||||||
"""
|
"""
|
||||||
Check if a user is administrator or owner of the resource."""
|
Check if a user is administrator or owner of the resource."""
|
||||||
|
|||||||
@@ -167,7 +167,6 @@ 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 = {
|
||||||
|
|||||||
@@ -1,153 +0,0 @@
|
|||||||
"""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
|
|
||||||
@@ -102,7 +102,6 @@ def test_notify_user_by_email_success(mocked_current_site, settings):
|
|||||||
settings.EMAIL_SUPPORT_EMAIL = "support@acme.com"
|
settings.EMAIL_SUPPORT_EMAIL = "support@acme.com"
|
||||||
settings.EMAIL_LOGO_IMG = "https://acme.com/logo"
|
settings.EMAIL_LOGO_IMG = "https://acme.com/logo"
|
||||||
settings.SCREEN_RECORDING_BASE_URL = "https://acme.com/recordings"
|
settings.SCREEN_RECORDING_BASE_URL = "https://acme.com/recordings"
|
||||||
settings.RECORDING_DOWNLOAD_BASE_URL = None
|
|
||||||
settings.EMAIL_FROM = "notifications@acme.com"
|
settings.EMAIL_FROM = "notifications@acme.com"
|
||||||
|
|
||||||
recording = factories.RecordingFactory(room__name="Conference Room A")
|
recording = factories.RecordingFactory(room__name="Conference Room A")
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ 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
|
||||||
@@ -133,7 +132,11 @@ 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",
|
||||||
}
|
}
|
||||||
@@ -148,151 +151,6 @@ 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()
|
||||||
@@ -368,17 +226,7 @@ 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 response.json() == {
|
assert "Invalid permission" in str(response.data)
|
||||||
"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,308 +199,3 @@ 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,7 +125,6 @@ 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,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,504 +0,0 @@
|
|||||||
"""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
|
|
||||||
@@ -2,21 +2,21 @@
|
|||||||
Tests for external API /room endpoint
|
Tests for external API /room endpoint
|
||||||
"""
|
"""
|
||||||
|
|
||||||
# pylint: disable=W0621,C0302
|
# pylint: disable=W0621
|
||||||
|
|
||||||
import uuid
|
|
||||||
from datetime import datetime, timedelta, timezone
|
from datetime import datetime, timedelta, timezone
|
||||||
from unittest import mock
|
|
||||||
|
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
|
|
||||||
import jwt
|
import jwt
|
||||||
import pytest
|
import pytest
|
||||||
import responses
|
import responses
|
||||||
from lasuite.oidc_resource_server.authentication import ResourceServerAuthentication
|
|
||||||
from rest_framework.test import APIClient
|
from rest_framework.test import APIClient
|
||||||
|
|
||||||
from core.factories import ApplicationFactory, RoomFactory, UserFactory
|
from core.factories import (
|
||||||
|
RoomFactory,
|
||||||
|
UserFactory,
|
||||||
|
)
|
||||||
from core.models import ApplicationScope, RoleChoices, Room, RoomAccessLevel, User
|
from core.models import ApplicationScope, RoleChoices, Room, RoomAccessLevel, User
|
||||||
|
|
||||||
pytestmark = pytest.mark.django_db
|
pytestmark = pytest.mark.django_db
|
||||||
@@ -27,14 +27,12 @@ def generate_test_token(user, scopes):
|
|||||||
now = datetime.now(timezone.utc)
|
now = datetime.now(timezone.utc)
|
||||||
scope_string = " ".join(scopes)
|
scope_string = " ".join(scopes)
|
||||||
|
|
||||||
application = ApplicationFactory()
|
|
||||||
|
|
||||||
payload = {
|
payload = {
|
||||||
"iss": settings.APPLICATION_JWT_ISSUER,
|
"iss": settings.APPLICATION_JWT_ISSUER,
|
||||||
"aud": settings.APPLICATION_JWT_AUDIENCE,
|
"aud": settings.APPLICATION_JWT_AUDIENCE,
|
||||||
"iat": now,
|
"iat": now,
|
||||||
"exp": now + timedelta(seconds=settings.APPLICATION_JWT_EXPIRATION_SECONDS),
|
"exp": now + timedelta(seconds=settings.APPLICATION_JWT_EXPIRATION_SECONDS),
|
||||||
"client_id": str(application.client_id),
|
"client_id": "test-client-id",
|
||||||
"scope": scope_string,
|
"scope": scope_string,
|
||||||
"user_id": str(user.id),
|
"user_id": str(user.id),
|
||||||
"delegated": True,
|
"delegated": True,
|
||||||
@@ -55,25 +53,11 @@ def test_api_rooms_list_requires_authentication():
|
|||||||
assert response.status_code == 401
|
assert response.status_code == 401
|
||||||
|
|
||||||
|
|
||||||
def test_api_rooms_list_inactive_user():
|
def test_api_rooms_list_with_valid_token(settings):
|
||||||
"""List should return 401 if user is inactive."""
|
|
||||||
|
|
||||||
user1 = UserFactory(is_active=False)
|
|
||||||
RoomFactory(users=[(user1, RoleChoices.OWNER)])
|
|
||||||
|
|
||||||
token = generate_test_token(user1, [ApplicationScope.ROOMS_LIST])
|
|
||||||
|
|
||||||
client = APIClient()
|
|
||||||
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
|
|
||||||
response = client.get("/external-api/v1.0/rooms/")
|
|
||||||
|
|
||||||
assert response.status_code == 401
|
|
||||||
assert "user account is disabled" in str(response.data).lower()
|
|
||||||
|
|
||||||
|
|
||||||
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)])
|
||||||
|
|
||||||
@@ -89,25 +73,9 @@ def test_api_rooms_list_with_valid_token():
|
|||||||
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():
|
|
||||||
"""Listing rooms with a valid token returns an empty list when there are no rooms."""
|
|
||||||
|
|
||||||
user = UserFactory()
|
|
||||||
|
|
||||||
# Generate valid token
|
|
||||||
token = generate_test_token(user, [ApplicationScope.ROOMS_LIST])
|
|
||||||
|
|
||||||
client = APIClient()
|
|
||||||
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
|
|
||||||
response = client.get("/external-api/v1.0/rooms/")
|
|
||||||
|
|
||||||
assert response.status_code == 200
|
|
||||||
assert response.data["count"] == 0
|
|
||||||
assert response.data["results"] == []
|
|
||||||
|
|
||||||
|
|
||||||
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()
|
||||||
@@ -124,8 +92,8 @@ def test_api_rooms_list_with_expired_token(settings):
|
|||||||
|
|
||||||
|
|
||||||
@responses.activate
|
@responses.activate
|
||||||
def test_api_rooms_list_with_invalid_rs_token(settings):
|
def test_api_rooms_list_with_invalid_token(settings):
|
||||||
"""Listing rooms with invalid resource server token should return 400."""
|
"""Listing rooms with invalid token should return 400."""
|
||||||
|
|
||||||
settings.OIDC_OP_INTROSPECTION_ENDPOINT = "https://oidc.example.com/introspect"
|
settings.OIDC_OP_INTROSPECTION_ENDPOINT = "https://oidc.example.com/introspect"
|
||||||
settings.OIDC_OP_URL = "https://oidc.example.com"
|
settings.OIDC_OP_URL = "https://oidc.example.com"
|
||||||
@@ -148,8 +116,9 @@ 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():
|
def test_api_rooms_list_missing_scope(settings):
|
||||||
"""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()
|
||||||
|
|
||||||
@@ -161,30 +130,12 @@ def test_api_rooms_list_missing_scope():
|
|||||||
response = client.get("/external-api/v1.0/rooms/")
|
response = client.get("/external-api/v1.0/rooms/")
|
||||||
|
|
||||||
assert response.status_code == 403
|
assert response.status_code == 403
|
||||||
assert (
|
assert "Insufficient permissions. Required scope: rooms:list" in str(response.data)
|
||||||
"insufficient permissions. required scope: rooms:list"
|
|
||||||
in str(response.data).lower()
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def test_api_rooms_list_no_scope():
|
def test_api_rooms_list_filters_by_user(settings):
|
||||||
"""Listing rooms without any scope should return 403."""
|
|
||||||
|
|
||||||
user = UserFactory()
|
|
||||||
|
|
||||||
# Token without scope
|
|
||||||
token = generate_test_token(user, [])
|
|
||||||
|
|
||||||
client = APIClient()
|
|
||||||
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
|
|
||||||
response = client.get("/external-api/v1.0/rooms/")
|
|
||||||
|
|
||||||
assert response.status_code == 403
|
|
||||||
assert "insufficient permissions." in str(response.data).lower()
|
|
||||||
|
|
||||||
|
|
||||||
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()
|
||||||
@@ -193,9 +144,7 @@ def test_api_rooms_list_filters_by_user():
|
|||||||
room2 = RoomFactory(users=[(user2, RoleChoices.OWNER)])
|
room2 = RoomFactory(users=[(user2, RoleChoices.OWNER)])
|
||||||
room3 = RoomFactory(users=[(user1, RoleChoices.MEMBER)])
|
room3 = RoomFactory(users=[(user1, RoleChoices.MEMBER)])
|
||||||
|
|
||||||
token = generate_test_token(
|
token = generate_test_token(user1, [ApplicationScope.ROOMS_LIST])
|
||||||
user1, [ApplicationScope.ROOMS_LIST, ApplicationScope.ROOMS_CREATE]
|
|
||||||
)
|
|
||||||
|
|
||||||
client = APIClient()
|
client = APIClient()
|
||||||
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
|
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
|
||||||
@@ -209,82 +158,9 @@ def test_api_rooms_list_filters_by_user():
|
|||||||
assert str(room2.id) not in returned_ids
|
assert str(room2.id) not in returned_ids
|
||||||
|
|
||||||
|
|
||||||
def test_api_rooms_retrieve_requires_authentication():
|
def test_api_rooms_retrieve_requires_scope(settings):
|
||||||
"""Retrieving rooms without authentication should return 401."""
|
|
||||||
|
|
||||||
user1 = UserFactory()
|
|
||||||
room1 = RoomFactory(users=[(user1, RoleChoices.OWNER)])
|
|
||||||
|
|
||||||
client = APIClient()
|
|
||||||
response = client.get(f"/external-api/v1.0/rooms/{room1.id}/")
|
|
||||||
|
|
||||||
assert response.status_code == 401
|
|
||||||
|
|
||||||
|
|
||||||
def test_api_rooms_retrieve_inactive_user():
|
|
||||||
"""Retrieve should return 401 if user is inactive."""
|
|
||||||
|
|
||||||
user1 = UserFactory(is_active=False)
|
|
||||||
room1 = RoomFactory(users=[(user1, RoleChoices.OWNER)])
|
|
||||||
|
|
||||||
token = generate_test_token(user1, [ApplicationScope.ROOMS_LIST])
|
|
||||||
|
|
||||||
client = APIClient()
|
|
||||||
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
|
|
||||||
response = client.get(f"/external-api/v1.0/rooms/{room1.id}/")
|
|
||||||
|
|
||||||
assert response.status_code == 401
|
|
||||||
assert "user account is disabled" in str(response.data).lower()
|
|
||||||
|
|
||||||
|
|
||||||
def test_api_rooms_retrieve_with_expired_token(settings):
|
|
||||||
"""Retrieving rooms with expired token should return 401."""
|
|
||||||
settings.APPLICATION_JWT_EXPIRATION_SECONDS = 0
|
|
||||||
|
|
||||||
user = UserFactory()
|
|
||||||
room = RoomFactory(users=[(user, RoleChoices.OWNER)])
|
|
||||||
|
|
||||||
# Generate expired token
|
|
||||||
token = generate_test_token(user, [ApplicationScope.ROOMS_CREATE])
|
|
||||||
|
|
||||||
client = APIClient()
|
|
||||||
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
|
|
||||||
response = client.get(f"/external-api/v1.0/rooms/{room.id}/")
|
|
||||||
|
|
||||||
assert response.status_code == 401
|
|
||||||
assert "expired" in str(response.data).lower()
|
|
||||||
|
|
||||||
|
|
||||||
@responses.activate
|
|
||||||
def test_api_rooms_retrieve_with_invalid_rs_token(settings):
|
|
||||||
"""Retrieving rooms with invalid resource server token should return 400."""
|
|
||||||
|
|
||||||
settings.OIDC_OP_INTROSPECTION_ENDPOINT = "https://oidc.example.com/introspect"
|
|
||||||
settings.OIDC_OP_URL = "https://oidc.example.com"
|
|
||||||
|
|
||||||
responses.add(
|
|
||||||
responses.POST,
|
|
||||||
"https://oidc.example.com/introspect",
|
|
||||||
json={
|
|
||||||
"iss": "https://oidc.example.com",
|
|
||||||
"active": False,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
user = UserFactory()
|
|
||||||
room = RoomFactory(users=[(user, RoleChoices.OWNER)])
|
|
||||||
|
|
||||||
client = APIClient()
|
|
||||||
client.credentials(HTTP_AUTHORIZATION="Bearer invalid-token-123")
|
|
||||||
response = client.get(f"/external-api/v1.0/rooms/{room.id}/")
|
|
||||||
|
|
||||||
# Return 400 instead of 401 because ResourceServerAuthentication raises
|
|
||||||
# SuspiciousOperation when the introspected user is not active
|
|
||||||
assert response.status_code == 400
|
|
||||||
|
|
||||||
|
|
||||||
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)])
|
||||||
@@ -302,25 +178,9 @@ def test_api_rooms_retrieve_requires_scope():
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def test_api_rooms_retrieve_no_scope():
|
|
||||||
"""Retrieving rooms without any scope should return 403."""
|
|
||||||
|
|
||||||
user = UserFactory()
|
|
||||||
|
|
||||||
# Token without scope
|
|
||||||
token = generate_test_token(user, [])
|
|
||||||
room = RoomFactory(users=[(user, RoleChoices.OWNER)])
|
|
||||||
|
|
||||||
client = APIClient()
|
|
||||||
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
|
|
||||||
response = client.get(f"/external-api/v1.0/rooms/{room.id}/")
|
|
||||||
|
|
||||||
assert response.status_code == 403
|
|
||||||
assert "insufficient permissions." in str(response.data).lower()
|
|
||||||
|
|
||||||
|
|
||||||
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"
|
||||||
@@ -352,128 +212,9 @@ def test_api_rooms_retrieve_success(settings):
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def test_api_rooms_retrieve_success_by_user():
|
def test_api_rooms_create_requires_scope(settings):
|
||||||
"""Retrieve should only return rooms accessible to the authenticated user."""
|
|
||||||
|
|
||||||
user1 = UserFactory()
|
|
||||||
user2 = UserFactory()
|
|
||||||
|
|
||||||
room1 = RoomFactory(users=[(user1, RoleChoices.OWNER)])
|
|
||||||
room2 = RoomFactory(users=[(user2, RoleChoices.OWNER)])
|
|
||||||
room3 = RoomFactory(users=[(user1, RoleChoices.MEMBER)])
|
|
||||||
room4 = RoomFactory(users=[(user1, RoleChoices.ADMIN)])
|
|
||||||
|
|
||||||
token = generate_test_token(
|
|
||||||
user1, [ApplicationScope.ROOMS_RETRIEVE, ApplicationScope.ROOMS_LIST]
|
|
||||||
)
|
|
||||||
|
|
||||||
client = APIClient()
|
|
||||||
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
|
|
||||||
response = client.get(f"/external-api/v1.0/rooms/{room2.id}/")
|
|
||||||
|
|
||||||
assert response.status_code == 403
|
|
||||||
|
|
||||||
client = APIClient()
|
|
||||||
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
|
|
||||||
response = client.get(f"/external-api/v1.0/rooms/{room1.id}/")
|
|
||||||
|
|
||||||
assert response.status_code == 200
|
|
||||||
|
|
||||||
client = APIClient()
|
|
||||||
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
|
|
||||||
response = client.get(f"/external-api/v1.0/rooms/{room3.id}/")
|
|
||||||
|
|
||||||
assert response.status_code == 200
|
|
||||||
|
|
||||||
client = APIClient()
|
|
||||||
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
|
|
||||||
response = client.get(f"/external-api/v1.0/rooms/{room4.id}/")
|
|
||||||
|
|
||||||
assert response.status_code == 200
|
|
||||||
|
|
||||||
|
|
||||||
def test_api_rooms_retrieve_not_found():
|
|
||||||
"""Retrieving a non-existing room with correct scope should return a 404."""
|
|
||||||
|
|
||||||
user = UserFactory()
|
|
||||||
token = generate_test_token(user, [ApplicationScope.ROOMS_RETRIEVE])
|
|
||||||
|
|
||||||
client = APIClient()
|
|
||||||
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
|
|
||||||
response = client.get(f"/external-api/v1.0/rooms/{uuid.uuid4()}/")
|
|
||||||
|
|
||||||
assert response.status_code == 404
|
|
||||||
assert "no room matches the given query." in str(response.data).lower()
|
|
||||||
|
|
||||||
|
|
||||||
def test_api_rooms_create_requires_authentication():
|
|
||||||
"""Creating rooms without authentication should return 401."""
|
|
||||||
|
|
||||||
client = APIClient()
|
|
||||||
response = client.post("/external-api/v1.0/rooms/")
|
|
||||||
|
|
||||||
assert response.status_code == 401
|
|
||||||
|
|
||||||
|
|
||||||
def test_api_rooms_create_with_expired_token(settings):
|
|
||||||
"""Creating rooms with expired token should return 401."""
|
|
||||||
settings.APPLICATION_JWT_EXPIRATION_SECONDS = 0
|
|
||||||
|
|
||||||
user = UserFactory()
|
|
||||||
|
|
||||||
# Generate expired token
|
|
||||||
token = generate_test_token(user, [ApplicationScope.ROOMS_CREATE])
|
|
||||||
|
|
||||||
client = APIClient()
|
|
||||||
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
|
|
||||||
response = client.post("/external-api/v1.0/rooms/")
|
|
||||||
|
|
||||||
assert response.status_code == 401
|
|
||||||
assert "expired" in str(response.data).lower()
|
|
||||||
|
|
||||||
|
|
||||||
@responses.activate
|
|
||||||
def test_api_rooms_create_with_invalid_rs_token(settings):
|
|
||||||
"""Creating rooms with invalid resource server token should return 400."""
|
|
||||||
|
|
||||||
settings.OIDC_OP_INTROSPECTION_ENDPOINT = "https://oidc.example.com/introspect"
|
|
||||||
settings.OIDC_OP_URL = "https://oidc.example.com"
|
|
||||||
|
|
||||||
responses.add(
|
|
||||||
responses.POST,
|
|
||||||
"https://oidc.example.com/introspect",
|
|
||||||
json={
|
|
||||||
"iss": "https://oidc.example.com",
|
|
||||||
"active": False,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
client = APIClient()
|
|
||||||
client.credentials(HTTP_AUTHORIZATION="Bearer invalid-token-123")
|
|
||||||
response = client.post("/external-api/v1.0/rooms/")
|
|
||||||
|
|
||||||
# Return 400 instead of 401 because ResourceServerAuthentication raises
|
|
||||||
# SuspiciousOperation when the introspected user is not active
|
|
||||||
assert response.status_code == 400
|
|
||||||
|
|
||||||
|
|
||||||
def test_api_rooms_create_inactive_user():
|
|
||||||
"""Create should return 401 if user is inactive."""
|
|
||||||
|
|
||||||
user1 = UserFactory(is_active=False)
|
|
||||||
|
|
||||||
token = generate_test_token(user1, [ApplicationScope.ROOMS_CREATE])
|
|
||||||
|
|
||||||
client = APIClient()
|
|
||||||
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
|
|
||||||
response = client.post("/external-api/v1.0/rooms/")
|
|
||||||
|
|
||||||
assert response.status_code == 401
|
|
||||||
assert "user account is disabled" in str(response.data).lower()
|
|
||||||
|
|
||||||
|
|
||||||
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
|
||||||
@@ -484,36 +225,18 @@ def test_api_rooms_create_requires_scope():
|
|||||||
response = client.post("/external-api/v1.0/rooms/", {}, format="json")
|
response = client.post("/external-api/v1.0/rooms/", {}, format="json")
|
||||||
|
|
||||||
assert response.status_code == 403
|
assert response.status_code == 403
|
||||||
assert (
|
assert "Insufficient permissions. Required scope: rooms:create" in str(
|
||||||
"insufficient permissions. required scope: rooms:create"
|
response.data
|
||||||
in str(response.data).lower()
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def test_api_rooms_create_no_scope():
|
def test_api_rooms_create_success(settings):
|
||||||
"""Creating rooms without any scope should return 403."""
|
|
||||||
|
|
||||||
user = UserFactory()
|
|
||||||
|
|
||||||
# Token without scope
|
|
||||||
token = generate_test_token(user, [])
|
|
||||||
|
|
||||||
client = APIClient()
|
|
||||||
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
|
|
||||||
response = client.post("/external-api/v1.0/rooms/")
|
|
||||||
|
|
||||||
assert response.status_code == 403
|
|
||||||
assert "insufficient permissions." in str(response.data).lower()
|
|
||||||
|
|
||||||
|
|
||||||
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()
|
||||||
|
|
||||||
token = generate_test_token(
|
token = generate_test_token(user, [ApplicationScope.ROOMS_CREATE])
|
||||||
user, [ApplicationScope.ROOMS_CREATE, ApplicationScope.ROOMS_LIST]
|
|
||||||
)
|
|
||||||
|
|
||||||
client = APIClient()
|
client = APIClient()
|
||||||
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
|
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
|
||||||
@@ -522,8 +245,6 @@ def test_api_rooms_create_success():
|
|||||||
assert response.status_code == 201
|
assert response.status_code == 201
|
||||||
assert "id" in response.data
|
assert "id" in response.data
|
||||||
assert "slug" in response.data
|
assert "slug" in response.data
|
||||||
assert "name" in response.data
|
|
||||||
assert response.data["name"] == response.data["slug"]
|
|
||||||
|
|
||||||
# Verify room was created with user as owner
|
# Verify room was created with user as owner
|
||||||
room = Room.objects.get(id=response.data["id"])
|
room = Room.objects.get(id=response.data["id"])
|
||||||
@@ -531,72 +252,9 @@ def test_api_rooms_create_success():
|
|||||||
assert room.access_level == "trusted"
|
assert room.access_level == "trusted"
|
||||||
|
|
||||||
|
|
||||||
def test_api_rooms_create_readonly_enforcement():
|
|
||||||
"""Creating a room succeeds and any provided read-only fields are ignored."""
|
|
||||||
|
|
||||||
user = UserFactory()
|
|
||||||
|
|
||||||
token = generate_test_token(user, [ApplicationScope.ROOMS_CREATE])
|
|
||||||
|
|
||||||
client = APIClient()
|
|
||||||
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
|
|
||||||
response = client.post(
|
|
||||||
"/external-api/v1.0/rooms/",
|
|
||||||
{
|
|
||||||
"id": "fake-id",
|
|
||||||
"slug": "fake-slug",
|
|
||||||
"name": "fake-name",
|
|
||||||
"access_level": "public",
|
|
||||||
},
|
|
||||||
format="json",
|
|
||||||
)
|
|
||||||
|
|
||||||
assert response.status_code == 201
|
|
||||||
assert "slug" in response.data
|
|
||||||
assert response.data["id"] != "fake-id"
|
|
||||||
assert "name" in response.data
|
|
||||||
assert response.data["slug"] != "fake-slug"
|
|
||||||
assert "id" in response.data
|
|
||||||
assert response.data["name"] != "fake-name"
|
|
||||||
|
|
||||||
# Verify room was created with user as owner
|
|
||||||
room = Room.objects.get(id=response.data["id"])
|
|
||||||
assert room.get_role(user) == RoleChoices.OWNER
|
|
||||||
assert room.access_level == "trusted"
|
|
||||||
|
|
||||||
|
|
||||||
def test_api_rooms_unknown_actions():
|
|
||||||
"""Updating or deleting a room are not supported yet."""
|
|
||||||
|
|
||||||
user = UserFactory()
|
|
||||||
room = RoomFactory(users=[(user, RoleChoices.OWNER)])
|
|
||||||
|
|
||||||
token = generate_test_token(
|
|
||||||
user,
|
|
||||||
[
|
|
||||||
ApplicationScope.ROOMS_RETRIEVE,
|
|
||||||
ApplicationScope.ROOMS_DELETE,
|
|
||||||
ApplicationScope.ROOMS_UPDATE,
|
|
||||||
],
|
|
||||||
)
|
|
||||||
|
|
||||||
client = APIClient()
|
|
||||||
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
|
|
||||||
response = client.delete(f"/external-api/v1.0/rooms/{room.id}/")
|
|
||||||
|
|
||||||
assert response.status_code == 405
|
|
||||||
assert 'method "delete" not allowed.' in str(response.data).lower()
|
|
||||||
|
|
||||||
client = APIClient()
|
|
||||||
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
|
|
||||||
response = client.patch(f"/external-api/v1.0/rooms/{room.id}/")
|
|
||||||
|
|
||||||
assert response.status_code == 405
|
|
||||||
assert 'method "patch" not allowed.' in str(response.data).lower()
|
|
||||||
|
|
||||||
|
|
||||||
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()
|
||||||
@@ -615,6 +273,7 @@ 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()
|
||||||
@@ -631,41 +290,10 @@ def test_api_rooms_response_no_telephony(settings):
|
|||||||
assert response.data["id"] == str(room.id)
|
assert response.data["id"] == str(room.id)
|
||||||
|
|
||||||
|
|
||||||
def test_api_rooms_token_scope_case_insensitive(settings):
|
|
||||||
"""Token's scope should be case-insensitive."""
|
|
||||||
user = UserFactory()
|
|
||||||
application = ApplicationFactory()
|
|
||||||
|
|
||||||
# Generate token with mixed-case scope "Rooms:List" to verify that scope
|
|
||||||
# validation is case-insensitive (should match "rooms:list")
|
|
||||||
now = datetime.now(timezone.utc)
|
|
||||||
payload = {
|
|
||||||
"iss": settings.APPLICATION_JWT_ISSUER,
|
|
||||||
"aud": settings.APPLICATION_JWT_AUDIENCE,
|
|
||||||
"iat": now,
|
|
||||||
"exp": now + timedelta(hours=1),
|
|
||||||
"client_id": str(application.client_id),
|
|
||||||
"scope": "Rooms:List", # Mixed case - should be accepted as "rooms:list"
|
|
||||||
"user_id": str(user.id),
|
|
||||||
"delegated": True,
|
|
||||||
}
|
|
||||||
token = jwt.encode(
|
|
||||||
payload,
|
|
||||||
settings.APPLICATION_JWT_SECRET_KEY,
|
|
||||||
algorithm=settings.APPLICATION_JWT_ALG,
|
|
||||||
)
|
|
||||||
|
|
||||||
client = APIClient()
|
|
||||||
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
|
|
||||||
response = client.get("/external-api/v1.0/rooms/")
|
|
||||||
|
|
||||||
assert response.status_code == 200
|
|
||||||
|
|
||||||
|
|
||||||
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()
|
|
||||||
|
|
||||||
# Generate token without delegated flag
|
# Generate token without delegated flag
|
||||||
now = datetime.now(timezone.utc)
|
now = datetime.now(timezone.utc)
|
||||||
@@ -674,7 +302,7 @@ def test_api_rooms_token_without_delegated_flag(settings):
|
|||||||
"aud": settings.APPLICATION_JWT_AUDIENCE,
|
"aud": settings.APPLICATION_JWT_AUDIENCE,
|
||||||
"iat": now,
|
"iat": now,
|
||||||
"exp": now + timedelta(hours=1),
|
"exp": now + timedelta(hours=1),
|
||||||
"client_id": str(application.client_id),
|
"client_id": "test-client",
|
||||||
"scope": "rooms:list",
|
"scope": "rooms:list",
|
||||||
"user_id": str(user.id),
|
"user_id": str(user.id),
|
||||||
"delegated": False, # Not delegated
|
"delegated": False, # Not delegated
|
||||||
@@ -690,75 +318,12 @@ def test_api_rooms_token_without_delegated_flag(settings):
|
|||||||
response = client.get("/external-api/v1.0/rooms/")
|
response = client.get("/external-api/v1.0/rooms/")
|
||||||
|
|
||||||
assert response.status_code == 401
|
assert response.status_code == 401
|
||||||
assert "invalid token type." in str(response.data).lower()
|
assert "Invalid token type." in str(response.data)
|
||||||
|
|
||||||
|
|
||||||
@mock.patch.object(ResourceServerAuthentication, "authenticate", return_value=None)
|
|
||||||
def test_api_rooms_token_invalid_signature(mock_rs_authenticate, settings):
|
|
||||||
"""Token signed with an invalid key should defer to the next authentication."""
|
|
||||||
user = UserFactory()
|
|
||||||
application = ApplicationFactory()
|
|
||||||
|
|
||||||
# Generate token without delegated flag
|
|
||||||
now = datetime.now(timezone.utc)
|
|
||||||
payload = {
|
|
||||||
"iss": settings.APPLICATION_JWT_ISSUER,
|
|
||||||
"aud": settings.APPLICATION_JWT_AUDIENCE,
|
|
||||||
"iat": now,
|
|
||||||
"exp": now + timedelta(hours=1),
|
|
||||||
"client_id": str(application.client_id),
|
|
||||||
"scope": "rooms:list",
|
|
||||||
"user_id": str(user.id),
|
|
||||||
"delegated": True,
|
|
||||||
}
|
|
||||||
token = jwt.encode(
|
|
||||||
payload,
|
|
||||||
"invalid-private-key",
|
|
||||||
algorithm=settings.APPLICATION_JWT_ALG,
|
|
||||||
)
|
|
||||||
|
|
||||||
client = APIClient()
|
|
||||||
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
|
|
||||||
response = client.get("/external-api/v1.0/rooms/")
|
|
||||||
|
|
||||||
mock_rs_authenticate.assert_called()
|
|
||||||
assert response.status_code == 401
|
|
||||||
|
|
||||||
|
|
||||||
@mock.patch.object(ResourceServerAuthentication, "authenticate", return_value=None)
|
|
||||||
def test_api_rooms_token_invalid_alg(mock_rs_authenticate, settings):
|
|
||||||
"""Token signed with an invalid alg should defer to the next authentication."""
|
|
||||||
settings.APPLICATION_JWT_ALG = "RS256"
|
|
||||||
user = UserFactory()
|
|
||||||
|
|
||||||
# Generate token without delegated flag
|
|
||||||
now = datetime.now(timezone.utc)
|
|
||||||
payload = {
|
|
||||||
"iss": settings.APPLICATION_JWT_ISSUER,
|
|
||||||
"aud": settings.APPLICATION_JWT_AUDIENCE,
|
|
||||||
"iat": now,
|
|
||||||
"exp": now + timedelta(hours=1),
|
|
||||||
"client_id": "test-client",
|
|
||||||
"scope": "rooms:list",
|
|
||||||
"user_id": str(user.id),
|
|
||||||
"delegated": True,
|
|
||||||
}
|
|
||||||
token = jwt.encode(
|
|
||||||
payload,
|
|
||||||
settings.APPLICATION_JWT_SECRET_KEY,
|
|
||||||
algorithm="HS256", # different value
|
|
||||||
)
|
|
||||||
|
|
||||||
client = APIClient()
|
|
||||||
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
|
|
||||||
response = client.get("/external-api/v1.0/rooms/")
|
|
||||||
|
|
||||||
mock_rs_authenticate.assert_called()
|
|
||||||
assert response.status_code == 401
|
|
||||||
|
|
||||||
|
|
||||||
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)
|
||||||
@@ -783,152 +348,7 @@ def test_api_rooms_token_missing_client_id(settings):
|
|||||||
response = client.get("/external-api/v1.0/rooms/")
|
response = client.get("/external-api/v1.0/rooms/")
|
||||||
|
|
||||||
assert response.status_code == 401
|
assert response.status_code == 401
|
||||||
assert "invalid token claims." in str(response.data).lower()
|
assert "Invalid token claims." in str(response.data)
|
||||||
|
|
||||||
|
|
||||||
def test_api_rooms_token_missing_user_id(settings):
|
|
||||||
"""Token without user_id should be rejected."""
|
|
||||||
application = ApplicationFactory()
|
|
||||||
|
|
||||||
now = datetime.now(timezone.utc)
|
|
||||||
payload = {
|
|
||||||
"iss": settings.APPLICATION_JWT_ISSUER,
|
|
||||||
"aud": settings.APPLICATION_JWT_AUDIENCE,
|
|
||||||
"iat": now,
|
|
||||||
"exp": now + timedelta(hours=1),
|
|
||||||
"client_id": str(application.client_id),
|
|
||||||
"scope": "rooms:list",
|
|
||||||
"delegated": True,
|
|
||||||
# Missing user_id
|
|
||||||
}
|
|
||||||
token = jwt.encode(
|
|
||||||
payload,
|
|
||||||
settings.APPLICATION_JWT_SECRET_KEY,
|
|
||||||
algorithm=settings.APPLICATION_JWT_ALG,
|
|
||||||
)
|
|
||||||
|
|
||||||
client = APIClient()
|
|
||||||
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
|
|
||||||
response = client.get("/external-api/v1.0/rooms/")
|
|
||||||
|
|
||||||
assert response.status_code == 401
|
|
||||||
assert "invalid token claims." in str(response.data).lower()
|
|
||||||
|
|
||||||
|
|
||||||
def test_api_rooms_token_invalid_audience(settings):
|
|
||||||
"""Token with an invalid audience should be rejected."""
|
|
||||||
user = UserFactory()
|
|
||||||
application = ApplicationFactory()
|
|
||||||
|
|
||||||
now = datetime.now(timezone.utc)
|
|
||||||
payload = {
|
|
||||||
"iss": settings.APPLICATION_JWT_ISSUER,
|
|
||||||
"aud": "invalid-audience",
|
|
||||||
"iat": now,
|
|
||||||
"exp": now + timedelta(hours=1),
|
|
||||||
"client_id": str(application.client_id),
|
|
||||||
"user_id": str(user.id),
|
|
||||||
"scope": "rooms:list",
|
|
||||||
"delegated": True,
|
|
||||||
}
|
|
||||||
token = jwt.encode(
|
|
||||||
payload,
|
|
||||||
settings.APPLICATION_JWT_SECRET_KEY,
|
|
||||||
algorithm=settings.APPLICATION_JWT_ALG,
|
|
||||||
)
|
|
||||||
|
|
||||||
client = APIClient()
|
|
||||||
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
|
|
||||||
response = client.get("/external-api/v1.0/rooms/")
|
|
||||||
|
|
||||||
assert response.status_code == 401
|
|
||||||
assert "invalid token." in str(response.data).lower()
|
|
||||||
|
|
||||||
|
|
||||||
def test_api_rooms_token_unknown_user(settings):
|
|
||||||
"""Token for unknown user should be rejected."""
|
|
||||||
application = ApplicationFactory()
|
|
||||||
|
|
||||||
now = datetime.now(timezone.utc)
|
|
||||||
payload = {
|
|
||||||
"iss": settings.APPLICATION_JWT_ISSUER,
|
|
||||||
"aud": settings.APPLICATION_JWT_AUDIENCE,
|
|
||||||
"iat": now,
|
|
||||||
"exp": now + timedelta(hours=1),
|
|
||||||
"client_id": str(application.client_id),
|
|
||||||
"user_id": str(uuid.uuid4()),
|
|
||||||
"scope": "rooms:list",
|
|
||||||
"delegated": True,
|
|
||||||
}
|
|
||||||
token = jwt.encode(
|
|
||||||
payload,
|
|
||||||
settings.APPLICATION_JWT_SECRET_KEY,
|
|
||||||
algorithm=settings.APPLICATION_JWT_ALG,
|
|
||||||
)
|
|
||||||
|
|
||||||
client = APIClient()
|
|
||||||
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
|
|
||||||
response = client.get("/external-api/v1.0/rooms/")
|
|
||||||
|
|
||||||
assert response.status_code == 401
|
|
||||||
assert "user not found." in str(response.data).lower()
|
|
||||||
|
|
||||||
|
|
||||||
def test_api_rooms_token_unknown_application(settings):
|
|
||||||
"""Token for unknown application should be rejected."""
|
|
||||||
|
|
||||||
now = datetime.now(timezone.utc)
|
|
||||||
payload = {
|
|
||||||
"iss": settings.APPLICATION_JWT_ISSUER,
|
|
||||||
"aud": settings.APPLICATION_JWT_AUDIENCE,
|
|
||||||
"iat": now,
|
|
||||||
"exp": now + timedelta(hours=1),
|
|
||||||
"client_id": "unknown-client-id",
|
|
||||||
"user_id": str(uuid.uuid4()),
|
|
||||||
"scope": "rooms:list",
|
|
||||||
"delegated": True,
|
|
||||||
}
|
|
||||||
token = jwt.encode(
|
|
||||||
payload,
|
|
||||||
settings.APPLICATION_JWT_SECRET_KEY,
|
|
||||||
algorithm=settings.APPLICATION_JWT_ALG,
|
|
||||||
)
|
|
||||||
|
|
||||||
client = APIClient()
|
|
||||||
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
|
|
||||||
response = client.get("/external-api/v1.0/rooms/")
|
|
||||||
|
|
||||||
assert response.status_code == 401
|
|
||||||
assert "application not found." in str(response.data).lower()
|
|
||||||
|
|
||||||
|
|
||||||
def test_api_rooms_token_inactive_application(settings):
|
|
||||||
"""Token for inactive application should be rejected."""
|
|
||||||
application = ApplicationFactory(active=False)
|
|
||||||
|
|
||||||
now = datetime.now(timezone.utc)
|
|
||||||
payload = {
|
|
||||||
"iss": settings.APPLICATION_JWT_ISSUER,
|
|
||||||
"aud": settings.APPLICATION_JWT_AUDIENCE,
|
|
||||||
"iat": now,
|
|
||||||
"exp": now + timedelta(hours=1),
|
|
||||||
"client_id": str(application.client_id),
|
|
||||||
"user_id": str(uuid.uuid4()),
|
|
||||||
"scope": "rooms:list",
|
|
||||||
"delegated": True,
|
|
||||||
}
|
|
||||||
token = jwt.encode(
|
|
||||||
payload,
|
|
||||||
settings.APPLICATION_JWT_SECRET_KEY,
|
|
||||||
algorithm=settings.APPLICATION_JWT_ALG,
|
|
||||||
)
|
|
||||||
|
|
||||||
client = APIClient()
|
|
||||||
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
|
|
||||||
response = client.get("/external-api/v1.0/rooms/")
|
|
||||||
|
|
||||||
assert response.status_code == 401
|
|
||||||
assert "application is disabled." in str(response.data).lower()
|
|
||||||
|
|
||||||
|
|
||||||
@responses.activate
|
@responses.activate
|
||||||
@@ -1087,7 +507,7 @@ def test_resource_server_authentication_successful(settings):
|
|||||||
"aud": "some_client_id", # settings.OIDC_RS_CLIENT_ID
|
"aud": "some_client_id", # settings.OIDC_RS_CLIENT_ID
|
||||||
"sub": "very-specific-sub",
|
"sub": "very-specific-sub",
|
||||||
"client_id": "some_service_provider",
|
"client_id": "some_service_provider",
|
||||||
"scope": "openid lasuite_meet lasuite_meet:rooms:list lasuite_meet:rooms:retrieve",
|
"scope": "openid lasuite_meet lasuite_meet:rooms:list",
|
||||||
"active": True,
|
"active": True,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -21,7 +21,8 @@ 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."""
|
||||||
UserFactory(email="User.Family@example.com")
|
settings.APPLICATION_JWT_SECRET_KEY = "devKey"
|
||||||
|
user = UserFactory(email="user@example.com")
|
||||||
application = ApplicationFactory(
|
application = ApplicationFactory(
|
||||||
active=True,
|
active=True,
|
||||||
scopes=[ApplicationScope.ROOMS_LIST, ApplicationScope.ROOMS_CREATE],
|
scopes=[ApplicationScope.ROOMS_LIST, ApplicationScope.ROOMS_CREATE],
|
||||||
@@ -39,7 +40,7 @@ def test_api_applications_generate_token_success(settings):
|
|||||||
"client_id": application.client_id,
|
"client_id": application.client_id,
|
||||||
"client_secret": plain_secret,
|
"client_secret": plain_secret,
|
||||||
"grant_type": "client_credentials",
|
"grant_type": "client_credentials",
|
||||||
"scope": "user.family@example.com",
|
"scope": user.email,
|
||||||
},
|
},
|
||||||
format="json",
|
format="json",
|
||||||
)
|
)
|
||||||
@@ -172,8 +173,9 @@ 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():
|
def test_api_applications_generate_token_domain_authorized(settings):
|
||||||
"""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,
|
||||||
@@ -228,6 +230,7 @@ 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(
|
||||||
@@ -277,6 +280,7 @@ 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
|
||||||
@@ -333,6 +337,7 @@ 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
|
||||||
|
|||||||
@@ -18,7 +18,6 @@ from socket import gethostbyname, gethostname
|
|||||||
|
|
||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
|
|
||||||
import dj_database_url
|
|
||||||
import sentry_sdk
|
import sentry_sdk
|
||||||
from configurations import Configuration, values
|
from configurations import Configuration, values
|
||||||
from lasuite.configuration.values import SecretFileValue
|
from lasuite.configuration.values import SecretFileValue
|
||||||
@@ -93,11 +92,7 @@ class Base(Configuration):
|
|||||||
|
|
||||||
# Database
|
# Database
|
||||||
DATABASES = {
|
DATABASES = {
|
||||||
"default": dj_database_url.config()
|
"default": {
|
||||||
if values.DatabaseURLValue(
|
|
||||||
None, environ_name="DATABASE_URL", environ_prefix=None
|
|
||||||
)
|
|
||||||
else {
|
|
||||||
"ENGINE": values.Value(
|
"ENGINE": values.Value(
|
||||||
"django.db.backends.postgresql_psycopg2",
|
"django.db.backends.postgresql_psycopg2",
|
||||||
environ_name="DB_ENGINE",
|
environ_name="DB_ENGINE",
|
||||||
@@ -297,9 +292,6 @@ class Base(Configuration):
|
|||||||
),
|
),
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
MONITORED_THROTTLE_FAILURE_CALLBACK = (
|
|
||||||
"core.api.throttling.sentry_monitoring_throttle_failure"
|
|
||||||
)
|
|
||||||
|
|
||||||
SPECTACULAR_SETTINGS = {
|
SPECTACULAR_SETTINGS = {
|
||||||
"TITLE": "Meet API",
|
"TITLE": "Meet API",
|
||||||
@@ -344,9 +336,6 @@ class Base(Configuration):
|
|||||||
"feedback": values.DictValue(
|
"feedback": values.DictValue(
|
||||||
{}, environ_name="FRONTEND_FEEDBACK", environ_prefix=None
|
{}, environ_name="FRONTEND_FEEDBACK", environ_prefix=None
|
||||||
),
|
),
|
||||||
"external_home_url": values.Value(
|
|
||||||
None, environ_name="FRONTEND_EXTERNAL_HOME_URL", environ_prefix=None
|
|
||||||
),
|
|
||||||
"use_french_gov_footer": values.BooleanValue(
|
"use_french_gov_footer": values.BooleanValue(
|
||||||
False, environ_name="FRONTEND_USE_FRENCH_GOV_FOOTER", environ_prefix=None
|
False, environ_name="FRONTEND_USE_FRENCH_GOV_FOOTER", environ_prefix=None
|
||||||
),
|
),
|
||||||
@@ -596,12 +585,6 @@ 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(
|
||||||
@@ -673,7 +656,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=int,
|
converter=lambda x: int(x), # pylint: disable=unnecessary-lambda
|
||||||
)
|
)
|
||||||
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(
|
||||||
@@ -706,23 +689,6 @@ 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
|
||||||
@@ -940,9 +906,6 @@ 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
-29
@@ -7,7 +7,7 @@ build-backend = "setuptools.build_meta"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "meet"
|
name = "meet"
|
||||||
version = "1.9.0"
|
version = "1.1.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,49 +17,46 @@ 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.13",
|
"Programming Language :: Python :: 3.10",
|
||||||
]
|
]
|
||||||
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.13"
|
requires-python = ">=3.10"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"boto3==1.42.49",
|
"boto3==1.40.69",
|
||||||
"Brotli==1.2.0",
|
"Brotli==1.2.0",
|
||||||
"brevo-python==1.2.0",
|
"brevo-python==1.2.0",
|
||||||
"celery[redis]==5.6.2",
|
"celery[redis]==5.5.3",
|
||||||
"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.2.0",
|
"django-countries==8.0.0",
|
||||||
"django-lasuite[all]==0.0.24",
|
"django-lasuite[all]==0.0.19",
|
||||||
"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.9",
|
||||||
"django==5.2.11",
|
|
||||||
"djangorestframework==3.16.1",
|
"djangorestframework==3.16.1",
|
||||||
"drf_spectacular==0.29.0",
|
"drf_spectacular==0.29.0",
|
||||||
"dockerflow==2026.1.26",
|
"dockerflow==2024.4.2",
|
||||||
"easy_thumbnails==2.10.1",
|
"easy_thumbnails==2.10.1",
|
||||||
"factory_boy==3.3.3",
|
"factory_boy==3.3.3",
|
||||||
"gunicorn==25.1.0",
|
"gunicorn==23.0.0",
|
||||||
"jsonschema==4.26.0",
|
"jsonschema==4.25.1",
|
||||||
"markdown==3.10.2",
|
"markdown==3.10",
|
||||||
"nested-multipart-parser==1.6.0",
|
"nested-multipart-parser==1.6.0",
|
||||||
"psycopg[binary]==3.3.2",
|
"psycopg[binary]==3.2.12",
|
||||||
"pydantic==2.12.4",
|
"PyJWT==2.10.1",
|
||||||
"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.53.0",
|
"sentry-sdk==2.43.0",
|
||||||
"whitenoise==6.11.0",
|
"whitenoise==6.11.0",
|
||||||
"mozilla-django-oidc==5.0.2",
|
"mozilla-django-oidc==4.0.1",
|
||||||
"livekit-api==1.1.0",
|
"livekit-api==1.0.7",
|
||||||
"aiohttp==3.13.3",
|
"aiohttp==3.13.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
[project.urls]
|
[project.urls]
|
||||||
@@ -71,21 +68,21 @@ dependencies = [
|
|||||||
[project.optional-dependencies]
|
[project.optional-dependencies]
|
||||||
dev = [
|
dev = [
|
||||||
"django-extensions==4.1",
|
"django-extensions==4.1",
|
||||||
"drf-spectacular-sidecar==2026.1.1",
|
"drf-spectacular-sidecar==2025.10.1",
|
||||||
"freezegun==1.5.5",
|
"freezegun==1.5.5",
|
||||||
"ipdb==0.13.13",
|
"ipdb==0.13.13",
|
||||||
"ipython==9.10.0",
|
"ipython==9.7.0",
|
||||||
"pyfakefs==6.1.1",
|
"pyfakefs==5.10.2",
|
||||||
"pylint-django==2.7.0",
|
"pylint-django==2.6.1",
|
||||||
"pylint<4.0.0",
|
"pylint<4.0.0",
|
||||||
"pytest-cov==7.0.0",
|
"pytest-cov==7.0.0",
|
||||||
"pytest-django==4.12.0",
|
"pytest-django==4.11.1",
|
||||||
"pytest==9.0.2",
|
"pytest==9.0.0",
|
||||||
"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.15.1",
|
"ruff==0.14.4",
|
||||||
"types-requests==2.32.4.20260107",
|
"types-requests==2.32.4.20250913",
|
||||||
]
|
]
|
||||||
|
|
||||||
[tool.setuptools]
|
[tool.setuptools]
|
||||||
|
|||||||
@@ -43,8 +43,7 @@ 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,12 +5,6 @@ server {
|
|||||||
|
|
||||||
root /usr/share/nginx/html;
|
root /usr/share/nginx/html;
|
||||||
|
|
||||||
location = /.well-known/windows-app-web-link {
|
|
||||||
default_type application/json;
|
|
||||||
alias /usr/share/nginx/html/.well-known/windows-app-web-link;
|
|
||||||
add_header Content-Disposition "attachment; filename=windows-app-web-link";
|
|
||||||
}
|
|
||||||
|
|
||||||
# Serve static files with caching
|
# Serve static files with caching
|
||||||
location ~* ^/assets/.*\.(css|js|json|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
|
location ~* ^/assets/.*\.(css|js|json|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
|
||||||
expires 30d;
|
expires 30d;
|
||||||
|
|||||||
@@ -6,22 +6,6 @@
|
|||||||
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png">
|
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png">
|
||||||
<link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png">
|
<link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png">
|
||||||
<link rel="manifest" href="/site.webmanifest">
|
<link rel="manifest" href="/site.webmanifest">
|
||||||
<!-- Font URLs are resolved and replaced by Vite during the build process. Font loading failures will not break the application. -->
|
|
||||||
<link
|
|
||||||
rel="preload"
|
|
||||||
as="font"
|
|
||||||
crossorigin="anonymous"
|
|
||||||
href="/node_modules/@fontsource/material-icons-outlined/files/material-icons-outlined-latin-400-normal.woff2"
|
|
||||||
type="font/woff2"
|
|
||||||
/>
|
|
||||||
<!-- Font URLs are resolved and replaced by Vite during the build process. Font loading failures will not break the application. -->
|
|
||||||
<link
|
|
||||||
rel="preload"
|
|
||||||
as="font"
|
|
||||||
crossorigin="anonymous"
|
|
||||||
href="/node_modules/@fontsource-variable/material-symbols-outlined/files/material-symbols-outlined-latin-wght-normal.woff2"
|
|
||||||
type="font/woff2"
|
|
||||||
/>
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>%VITE_APP_TITLE%</title>
|
<title>%VITE_APP_TITLE%</title>
|
||||||
</head>
|
</head>
|
||||||
|
|||||||
Generated
+1822
-3796
File diff suppressed because it is too large
Load Diff
+25
-25
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "meet",
|
"name": "meet",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "1.9.0",
|
"version": "1.1.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "panda codegen && vite",
|
"dev": "panda codegen && vite",
|
||||||
@@ -13,56 +13,56 @@
|
|||||||
"check": "prettier --check ./src"
|
"check": "prettier --check ./src"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@fontsource-variable/material-symbols-outlined": "5.2.34",
|
"@fontsource-variable/material-symbols-outlined": "5.2.30",
|
||||||
"@fontsource/material-icons-outlined": "5.2.6",
|
"@fontsource/material-icons-outlined": "5.2.6",
|
||||||
"@livekit/components-react": "2.9.19",
|
"@livekit/components-react": "2.9.13",
|
||||||
"@livekit/components-styles": "1.2.0",
|
"@livekit/components-styles": "1.1.6",
|
||||||
"@livekit/track-processors": "0.7.0",
|
"@livekit/track-processors": "0.6.1",
|
||||||
"@pandacss/preset-panda": "1.8.2",
|
"@pandacss/preset-panda": "0.54.0",
|
||||||
"@react-aria/toast": "3.0.10",
|
"@react-aria/toast": "3.0.5",
|
||||||
"@react-types/overlays": "3.9.3",
|
"@react-types/overlays": "3.9.0",
|
||||||
"@remixicon/react": "4.6.0",
|
"@remixicon/react": "4.6.0",
|
||||||
"@tanstack/react-query": "5.90.21",
|
"@tanstack/react-query": "5.81.5",
|
||||||
"@timephy/rnnoise-wasm": "1.0.0",
|
"@timephy/rnnoise-wasm": "1.0.0",
|
||||||
"crisp-sdk-web": "1.0.27",
|
"crisp-sdk-web": "1.0.25",
|
||||||
"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.0",
|
||||||
"i18next": "25.8.8",
|
"i18next": "25.3.1",
|
||||||
"i18next-browser-languagedetector": "8.2.1",
|
"i18next-browser-languagedetector": "8.2.0",
|
||||||
"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.17.1",
|
"livekit-client": "2.15.7",
|
||||||
"posthog-js": "1.342.1",
|
"posthog-js": "1.256.2",
|
||||||
"react": "18.3.1",
|
"react": "18.3.1",
|
||||||
"react-aria-components": "1.14.0",
|
"react-aria-components": "1.10.1",
|
||||||
"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",
|
||||||
"valtio": "2.3.0",
|
"valtio": "2.1.5",
|
||||||
"wouter": "3.9.0"
|
"wouter": "3.7.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@pandacss/dev": "1.8.2",
|
"@pandacss/dev": "0.54.0",
|
||||||
"@tanstack/eslint-plugin-query": "5.91.4",
|
"@tanstack/eslint-plugin-query": "5.81.2",
|
||||||
"@tanstack/react-query-devtools": "5.91.3",
|
"@tanstack/react-query-devtools": "5.81.5",
|
||||||
"@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": "5.1.4",
|
"@vitejs/plugin-react": "4.6.0",
|
||||||
"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",
|
||||||
"eslint-plugin-react-hooks": "5.2.0",
|
"eslint-plugin-react-hooks": "5.2.0",
|
||||||
"eslint-plugin-react-refresh": "0.4.20",
|
"eslint-plugin-react-refresh": "0.4.20",
|
||||||
"postcss": "8.5.6",
|
"postcss": "8.5.6",
|
||||||
"prettier": "3.8.1",
|
"prettier": "3.6.2",
|
||||||
"typescript": "5.8.3",
|
"typescript": "5.8.3",
|
||||||
"vite": "7.3.1",
|
"vite": "7.0.8",
|
||||||
"vite-tsconfig-paths": "6.1.1"
|
"vite-tsconfig-paths": "5.1.4"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +0,0 @@
|
|||||||
[
|
|
||||||
{
|
|
||||||
"packageFamilyName" : "Visio_g3z6ba6vek6vg",
|
|
||||||
"paths" : [ "*" ]
|
|
||||||
}
|
|
||||||
]
|
|
||||||
@@ -17,7 +17,6 @@ export interface ApiConfig {
|
|||||||
feedback: {
|
feedback: {
|
||||||
url: string
|
url: string
|
||||||
}
|
}
|
||||||
external_home_url?: string
|
|
||||||
silence_livekit_debug_logs?: boolean
|
silence_livekit_debug_logs?: boolean
|
||||||
is_silent_login_enabled?: boolean
|
is_silent_login_enabled?: boolean
|
||||||
custom_css_url?: string
|
custom_css_url?: string
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -6,7 +6,6 @@ export const BlurOnStrong = () => {
|
|||||||
viewBox="0 0 24 24"
|
viewBox="0 0 24 24"
|
||||||
fill="none"
|
fill="none"
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
aria-hidden="true"
|
|
||||||
>
|
>
|
||||||
<path
|
<path
|
||||||
fillRule="evenodd"
|
fillRule="evenodd"
|
||||||
|
|||||||
@@ -7,5 +7,4 @@ export type ApiUser = {
|
|||||||
last_name: string
|
last_name: string
|
||||||
language: BackendLanguage
|
language: BackendLanguage
|
||||||
timezone: string
|
timezone: string
|
||||||
can_create?: boolean
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ 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: {
|
||||||
@@ -145,21 +144,6 @@ 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[] = [
|
||||||
{
|
{
|
||||||
@@ -179,45 +163,11 @@ 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',
|
||||||
@@ -230,10 +180,10 @@ export const IntroSlider = () => {
|
|||||||
<Button
|
<Button
|
||||||
variant="secondaryText"
|
variant="secondaryText"
|
||||||
square
|
square
|
||||||
className={carouselNavButton}
|
aria-label={t('previous.label')}
|
||||||
aria-label={previousAriaLabel}
|
tooltip={t('previous.tooltip')}
|
||||||
aria-disabled={slideIndex === 0}
|
onPress={() => setSlideIndex(slideIndex - 1)}
|
||||||
onPress={goPrev}
|
isDisabled={slideIndex == 0}
|
||||||
>
|
>
|
||||||
<RiArrowLeftSLine />
|
<RiArrowLeftSLine />
|
||||||
</Button>
|
</Button>
|
||||||
@@ -241,12 +191,8 @@ export const IntroSlider = () => {
|
|||||||
</ButtonContainer>
|
</ButtonContainer>
|
||||||
<SlideContainer>
|
<SlideContainer>
|
||||||
{SLIDES.map((slide, index) => (
|
{SLIDES.map((slide, index) => (
|
||||||
<Slide
|
<Slide visible={index == slideIndex} key={index}>
|
||||||
aria-hidden={index !== slideIndex}
|
<Image src={slide.src} alt={t(`${slide.key}.imgAlt`)} />
|
||||||
visible={index === slideIndex}
|
|
||||||
key={index}
|
|
||||||
>
|
|
||||||
<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>
|
||||||
<Body>{t(`${slide.key}.body`)}</Body>
|
<Body>{t(`${slide.key}.body`)}</Body>
|
||||||
@@ -259,10 +205,10 @@ export const IntroSlider = () => {
|
|||||||
<Button
|
<Button
|
||||||
variant="secondaryText"
|
variant="secondaryText"
|
||||||
square
|
square
|
||||||
className={carouselNavButton}
|
aria-label={t('next.label')}
|
||||||
aria-label={nextAriaLabel}
|
tooltip={t('next.tooltip')}
|
||||||
aria-disabled={slideIndex === NUMBER_SLIDES - 1}
|
onPress={() => setSlideIndex(slideIndex + 1)}
|
||||||
onPress={goNext}
|
isDisabled={slideIndex == NUMBER_SLIDES - 1}
|
||||||
>
|
>
|
||||||
<RiArrowRightSLine />
|
<RiArrowRightSLine />
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -5,18 +5,28 @@ import { isRoomValid } from '@/features/rooms'
|
|||||||
|
|
||||||
export const JoinMeetingDialog = () => {
|
export const JoinMeetingDialog = () => {
|
||||||
const { t } = useTranslation('home')
|
const { t } = useTranslation('home')
|
||||||
|
return (
|
||||||
const handleSubmit = (data: { roomId?: FormDataEntryValue }) => {
|
<Dialog title={t('joinMeeting')}>
|
||||||
const roomId = (data.roomId as string)
|
<Form
|
||||||
|
onSubmit={(data) => {
|
||||||
|
navigateTo(
|
||||||
|
'room',
|
||||||
|
(data.roomId as string)
|
||||||
.trim()
|
.trim()
|
||||||
.replace(`${window.location.origin}/`, '')
|
.replace(`${window.location.origin}/`, '')
|
||||||
navigateTo('room', roomId)
|
)
|
||||||
}
|
}}
|
||||||
|
submitLabel={t('joinInputSubmit')}
|
||||||
const validateRoomId = (value: string) => {
|
>
|
||||||
const trimmed = value.trim()
|
<Field
|
||||||
if (!trimmed) return null
|
type="text"
|
||||||
return !isRoomValid(trimmed) ? (
|
name="roomId"
|
||||||
|
label={t('joinInputLabel')}
|
||||||
|
description={t('joinInputExample', {
|
||||||
|
example: window.origin + '/azer-tyu-qsdf',
|
||||||
|
})}
|
||||||
|
validate={(value) => {
|
||||||
|
return !isRoomValid(value.trim()) ? (
|
||||||
<>
|
<>
|
||||||
<p>{t('joinInputError')}</p>
|
<p>{t('joinInputError')}</p>
|
||||||
<Ul>
|
<Ul>
|
||||||
@@ -25,22 +35,7 @@ export const JoinMeetingDialog = () => {
|
|||||||
</Ul>
|
</Ul>
|
||||||
</>
|
</>
|
||||||
) : null
|
) : null
|
||||||
}
|
}}
|
||||||
|
|
||||||
return (
|
|
||||||
<Dialog title={t('joinMeeting')}>
|
|
||||||
<Form onSubmit={handleSubmit} submitLabel={t('joinInputSubmit')}>
|
|
||||||
{/* eslint-disable jsx-a11y/no-autofocus -- Focus on input when modal opens, required for accessibility */}
|
|
||||||
<Field
|
|
||||||
type="text"
|
|
||||||
autoFocus
|
|
||||||
isRequired
|
|
||||||
name="roomId"
|
|
||||||
label={t('joinInputLabel')}
|
|
||||||
description={t('joinInputExample', {
|
|
||||||
example: window.origin + '/azer-tyu-qsdf',
|
|
||||||
})}
|
|
||||||
validate={validateRoomId}
|
|
||||||
/>
|
/>
|
||||||
</Form>
|
</Form>
|
||||||
<H lvl={2}>{t('joinMeetingTipHeading')}</H>
|
<H lvl={2}>{t('joinMeetingTipHeading')}</H>
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import { RiAddLine, RiLink } from '@remixicon/react'
|
|||||||
import { LaterMeetingDialog } from '@/features/home/components/LaterMeetingDialog'
|
import { LaterMeetingDialog } from '@/features/home/components/LaterMeetingDialog'
|
||||||
import { IntroSlider } from '@/features/home/components/IntroSlider'
|
import { IntroSlider } from '@/features/home/components/IntroSlider'
|
||||||
import { MoreLink } from '@/features/home/components/MoreLink'
|
import { MoreLink } from '@/features/home/components/MoreLink'
|
||||||
import { ReactNode, useEffect, useState } from 'react'
|
import { ReactNode, useState } from 'react'
|
||||||
|
|
||||||
import { css } from '@/styled-system/css'
|
import { css } from '@/styled-system/css'
|
||||||
import { menuRecipe } from '@/primitives/menuRecipe.ts'
|
import { menuRecipe } from '@/primitives/menuRecipe.ts'
|
||||||
@@ -19,7 +19,6 @@ import { usePersistentUserChoices } from '@/features/rooms/livekit/hooks/usePers
|
|||||||
import { useConfig } from '@/api/useConfig'
|
import { useConfig } from '@/api/useConfig'
|
||||||
import { LoginButton } from '@/components/LoginButton'
|
import { LoginButton } from '@/components/LoginButton'
|
||||||
import { ApiRoom } from '@/features/rooms/api/ApiRoom'
|
import { ApiRoom } from '@/features/rooms/api/ApiRoom'
|
||||||
import { LoadingScreen } from '@/components/LoadingScreen'
|
|
||||||
|
|
||||||
const Columns = ({ children }: { children?: ReactNode }) => {
|
const Columns = ({ children }: { children?: ReactNode }) => {
|
||||||
return (
|
return (
|
||||||
@@ -148,8 +147,7 @@ const IntroText = styled('div', {
|
|||||||
|
|
||||||
export const Home = () => {
|
export const Home = () => {
|
||||||
const { t } = useTranslation('home')
|
const { t } = useTranslation('home')
|
||||||
const { isLoggedIn, user } = useUser()
|
const { isLoggedIn } = useUser()
|
||||||
const canCreate = user?.can_create === true
|
|
||||||
|
|
||||||
const {
|
const {
|
||||||
userChoices: { username },
|
userChoices: { username },
|
||||||
@@ -157,34 +155,9 @@ export const Home = () => {
|
|||||||
|
|
||||||
const { mutateAsync: createRoom } = useCreateRoom()
|
const { mutateAsync: createRoom } = useCreateRoom()
|
||||||
const [laterRoom, setLaterRoom] = useState<null | ApiRoom>(null)
|
const [laterRoom, setLaterRoom] = useState<null | ApiRoom>(null)
|
||||||
const [redirectFailed, setRedirectFailed] = useState(false)
|
|
||||||
|
|
||||||
const { data } = useConfig()
|
const { data } = useConfig()
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const checkSiteAndRedirect = async () => {
|
|
||||||
if (!data?.external_home_url) return
|
|
||||||
if (isLoggedIn === false) {
|
|
||||||
try {
|
|
||||||
await fetch(data.external_home_url, {
|
|
||||||
method: 'HEAD', // Use HEAD to avoid downloading the full page
|
|
||||||
mode: 'no-cors', // Needed for cross-origin requests
|
|
||||||
})
|
|
||||||
window.location.replace(data.external_home_url)
|
|
||||||
} catch (error) {
|
|
||||||
setRedirectFailed(true)
|
|
||||||
console.error('Site is not reachable:', error)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
checkSiteAndRedirect()
|
|
||||||
}, [isLoggedIn, data])
|
|
||||||
|
|
||||||
if (data?.external_home_url && isLoggedIn == false && !redirectFailed) {
|
|
||||||
return <LoadingScreen header={false} footer={false} delay={0} />
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<UserAware>
|
<UserAware>
|
||||||
<Screen>
|
<Screen>
|
||||||
@@ -201,7 +174,6 @@ export const Home = () => {
|
|||||||
})}
|
})}
|
||||||
>
|
>
|
||||||
{isLoggedIn ? (
|
{isLoggedIn ? (
|
||||||
canCreate ? (
|
|
||||||
<Menu>
|
<Menu>
|
||||||
<Button variant="primary" data-attr="create-meeting">
|
<Button variant="primary" data-attr="create-meeting">
|
||||||
{t('createMeeting')}
|
{t('createMeeting')}
|
||||||
@@ -241,16 +213,6 @@ export const Home = () => {
|
|||||||
</MenuItem>
|
</MenuItem>
|
||||||
</RACMenu>
|
</RACMenu>
|
||||||
</Menu>
|
</Menu>
|
||||||
) : (
|
|
||||||
<p
|
|
||||||
className={css({
|
|
||||||
color: 'greyscale.700',
|
|
||||||
fontSize: '0.95rem',
|
|
||||||
})}
|
|
||||||
>
|
|
||||||
{t('noAccess')}
|
|
||||||
</p>
|
|
||||||
)
|
|
||||||
) : (
|
) : (
|
||||||
<LoginButton proConnectHint={false} />
|
<LoginButton proConnectHint={false} />
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -25,27 +25,15 @@ export const TermsOfServiceRoute = () => {
|
|||||||
{/* Article 2 */}
|
{/* Article 2 */}
|
||||||
<H lvl={2}>{t('articles.article2.title')}</H>
|
<H lvl={2}>{t('articles.article2.title')}</H>
|
||||||
<P>{t('articles.article2.content')}</P>
|
<P>{t('articles.article2.content')}</P>
|
||||||
{ensureArray(
|
<P>{t('articles.article2.purposes')}</P>
|
||||||
t('articles.article2.paragraphs', {
|
|
||||||
returnObjects: true,
|
|
||||||
})
|
|
||||||
).map((paragraph, index) => (
|
|
||||||
<P key={index}>{paragraph}</P>
|
|
||||||
))}
|
|
||||||
|
|
||||||
{/* Article 3 */}
|
{/* Article 3 */}
|
||||||
<H lvl={2}>{t('articles.article3.title')}</H>
|
<H lvl={2}>{t('articles.article3.title')}</H>
|
||||||
{ensureArray(
|
<P>{t('articles.article3.definition')}</P>
|
||||||
t('articles.article3.paragraphs', {
|
|
||||||
returnObjects: true,
|
|
||||||
})
|
|
||||||
).map((paragraph, index) => (
|
|
||||||
<P key={index}>{paragraph}</P>
|
|
||||||
))}
|
|
||||||
|
|
||||||
{/* Article 4 */}
|
{/* Article 4 */}
|
||||||
<H lvl={2}>{t('articles.article4.title')}</H>
|
<H lvl={2}>{t('articles.article4.title')}</H>
|
||||||
<P>{t('articles.article4.definition')}</P>
|
<P>{t('articles.article4.content')}</P>
|
||||||
|
|
||||||
{/* Article 5 */}
|
{/* Article 5 */}
|
||||||
<H lvl={2} margin={false}>
|
<H lvl={2} margin={false}>
|
||||||
@@ -74,7 +62,6 @@ export const TermsOfServiceRoute = () => {
|
|||||||
__html: t('articles.article5.sections.section1.paragraph3'),
|
__html: t('articles.article5.sections.section1.paragraph3'),
|
||||||
}}
|
}}
|
||||||
></P>
|
></P>
|
||||||
<P>{t('articles.article5.sections.section1.paragraph4')}</P>
|
|
||||||
|
|
||||||
{/* Section 5.2 */}
|
{/* Section 5.2 */}
|
||||||
<H lvl={3} bold>
|
<H lvl={3} bold>
|
||||||
@@ -124,12 +111,15 @@ export const TermsOfServiceRoute = () => {
|
|||||||
))}
|
))}
|
||||||
|
|
||||||
{/* Article 7 */}
|
{/* Article 7 */}
|
||||||
<H lvl={2}>{t('articles.article7.title')}</H>
|
<H lvl={2} margin={false}>
|
||||||
|
{t('articles.article7.title')}
|
||||||
|
</H>
|
||||||
|
|
||||||
{/* Section 7.1 */}
|
{/* Section 7.1 */}
|
||||||
<H lvl={3} bold>
|
<H lvl={3} bold>
|
||||||
{t('articles.article7.sections.section1.title')}
|
{t('articles.article7.sections.section1.title')}
|
||||||
</H>
|
</H>
|
||||||
|
<P>{t('articles.article7.sections.section1.content')}</P>
|
||||||
{ensureArray(
|
{ensureArray(
|
||||||
t('articles.article7.sections.section1.paragraphs', {
|
t('articles.article7.sections.section1.paragraphs', {
|
||||||
returnObjects: true,
|
returnObjects: true,
|
||||||
@@ -142,51 +132,16 @@ export const TermsOfServiceRoute = () => {
|
|||||||
<H lvl={3} bold>
|
<H lvl={3} bold>
|
||||||
{t('articles.article7.sections.section2.title')}
|
{t('articles.article7.sections.section2.title')}
|
||||||
</H>
|
</H>
|
||||||
{ensureArray(
|
|
||||||
t('articles.article7.sections.section2.paragraphs', {
|
|
||||||
returnObjects: true,
|
|
||||||
})
|
|
||||||
).map((paragraph, index) => (
|
|
||||||
<P key={index}>{paragraph}</P>
|
|
||||||
))}
|
|
||||||
|
|
||||||
{/* Section 7.3 */}
|
|
||||||
<H lvl={3} bold>
|
|
||||||
{t('articles.article7.sections.section3.title')}
|
|
||||||
</H>
|
|
||||||
{ensureArray(
|
|
||||||
t('articles.article7.sections.section3.paragraphs', {
|
|
||||||
returnObjects: true,
|
|
||||||
})
|
|
||||||
).map((paragraph, index) => (
|
|
||||||
<P key={index}>{paragraph}</P>
|
|
||||||
))}
|
|
||||||
|
|
||||||
{/* Section 7.4 */}
|
|
||||||
<H lvl={3} bold>
|
|
||||||
{t('articles.article7.sections.section4.title')}
|
|
||||||
</H>
|
|
||||||
{ensureArray(
|
|
||||||
t('articles.article7.sections.section4.paragraphs', {
|
|
||||||
returnObjects: true,
|
|
||||||
})
|
|
||||||
).map((paragraph, index) => (
|
|
||||||
<P key={index}>{paragraph}</P>
|
|
||||||
))}
|
|
||||||
|
|
||||||
{/* Section 7.5 */}
|
|
||||||
<H lvl={3} bold>
|
|
||||||
{t('articles.article7.sections.section5.title')}
|
|
||||||
</H>
|
|
||||||
<P>
|
<P>
|
||||||
{t('articles.article7.sections.section5.content')
|
{t('articles.article7.sections.section2.content')
|
||||||
.split('https://github.com/suitenumerique/meet')[0]
|
.split('https://github.com/suitenumerique/meet')[0]
|
||||||
.replace('https://github.com/suitenumerique/meet', '')}{' '}
|
.replace('https://github.com/suitenumerique/meet', '')}{' '}
|
||||||
<A href="https://github.com/suitenumerique/meet" color="primary">
|
<A href="https://github.com/suitenumerique/meet" color="primary">
|
||||||
https://github.com/suitenumerique/meet
|
https://github.com/suitenumerique/meet
|
||||||
</A>
|
</A>
|
||||||
|
{'. '}
|
||||||
{
|
{
|
||||||
t('articles.article7.sections.section5.content').split(
|
t('articles.article7.sections.section2.content').split(
|
||||||
'https://github.com/suitenumerique/meet'
|
'https://github.com/suitenumerique/meet'
|
||||||
)[1]
|
)[1]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { css } from '@/styled-system/css'
|
import { css, cx } from '@/styled-system/css'
|
||||||
import { HStack } from '@/styled-system/jsx'
|
import { HStack } from '@/styled-system/jsx'
|
||||||
import { Spinner } from '@/primitives/Spinner'
|
import { Spinner } from '@/primitives/Spinner'
|
||||||
import { Button, Icon, Text } from '@/primitives'
|
import { Button, Text } from '@/primitives'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import { RecordingStatuses } from '../hooks/useRecordingStatuses'
|
import { RecordingStatuses } from '../hooks/useRecordingStatuses'
|
||||||
import { ReactNode, useEffect, useRef, useState } from 'react'
|
import { ReactNode, useEffect, useRef, useState } from 'react'
|
||||||
@@ -42,17 +42,6 @@ export const ControlsButton = ({
|
|||||||
}: ControlsButtonProps) => {
|
}: ControlsButtonProps) => {
|
||||||
const { t } = useTranslation('rooms', { keyPrefix: i18nKeyPrefix })
|
const { t } = useTranslation('rooms', { keyPrefix: i18nKeyPrefix })
|
||||||
|
|
||||||
// Focus management: focus the primary action button when this side panel opens.
|
|
||||||
const primaryActionRef = useRef<HTMLButtonElement | null>(null)
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
requestAnimationFrame(() => {
|
|
||||||
if (primaryActionRef.current) {
|
|
||||||
primaryActionRef.current.focus({ preventScroll: true })
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
const room = useRoomContext()
|
const room = useRoomContext()
|
||||||
const isRoomConnected = room.state == ConnectionState.Connected
|
const isRoomConnected = room.state == ConnectionState.Connected
|
||||||
|
|
||||||
@@ -108,7 +97,6 @@ export const ControlsButton = ({
|
|||||||
fullWidth
|
fullWidth
|
||||||
onPress={handle}
|
onPress={handle}
|
||||||
isDisabled={isDisabled}
|
isDisabled={isDisabled}
|
||||||
ref={primaryActionRef}
|
|
||||||
>
|
>
|
||||||
{t('button.stop')}
|
{t('button.stop')}
|
||||||
</Button>
|
</Button>
|
||||||
@@ -141,23 +129,31 @@ export const ControlsButton = ({
|
|||||||
})}
|
})}
|
||||||
onPress={() => openSidePanel()}
|
onPress={() => openSidePanel()}
|
||||||
>
|
>
|
||||||
<Icon
|
<span
|
||||||
className={css({
|
className={cx(
|
||||||
|
'material-icons',
|
||||||
|
css({
|
||||||
color: 'primary.500',
|
color: 'primary.500',
|
||||||
marginRight: '1rem',
|
marginRight: '1rem',
|
||||||
})}
|
})
|
||||||
name="info"
|
)}
|
||||||
/>
|
>
|
||||||
|
info
|
||||||
|
</span>
|
||||||
<Text variant={'smNote'}>
|
<Text variant={'smNote'}>
|
||||||
{parseLineBreaks(t('button.anotherModeStarted'))}
|
{parseLineBreaks(t('button.anotherModeStarted'))}
|
||||||
</Text>
|
</Text>
|
||||||
<Icon
|
<span
|
||||||
className={css({
|
className={cx(
|
||||||
|
'material-icons',
|
||||||
|
css({
|
||||||
color: 'primary.500',
|
color: 'primary.500',
|
||||||
marginLeft: 'auto',
|
marginLeft: 'auto',
|
||||||
})}
|
})
|
||||||
name="chevron_right"
|
)}
|
||||||
/>
|
>
|
||||||
|
chevron_right
|
||||||
|
</span>
|
||||||
</RACButton>
|
</RACButton>
|
||||||
)}
|
)}
|
||||||
<Button
|
<Button
|
||||||
@@ -166,7 +162,6 @@ export const ControlsButton = ({
|
|||||||
onPress={handle}
|
onPress={handle}
|
||||||
isDisabled={isDisabled}
|
isDisabled={isDisabled}
|
||||||
size="compact"
|
size="compact"
|
||||||
ref={primaryActionRef}
|
|
||||||
>
|
>
|
||||||
{t('button.start')}
|
{t('button.start')}
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { H, Text, Icon } from '@/primitives'
|
import { H, Text } from '@/primitives'
|
||||||
import { css } from '@/styled-system/css'
|
import { css } from '@/styled-system/css'
|
||||||
import { LoginButton } from '@/components/LoginButton'
|
import { LoginButton } from '@/components/LoginButton'
|
||||||
import { HStack } from '@/styled-system/jsx'
|
import { HStack } from '@/styled-system/jsx'
|
||||||
@@ -24,7 +24,9 @@ export const LoginPrompt = ({ heading, body }: LoginPromptProps) => {
|
|||||||
})}
|
})}
|
||||||
>
|
>
|
||||||
<HStack justify="start" alignItems="center" marginBottom="0.5rem">
|
<HStack justify="start" alignItems="center" marginBottom="0.5rem">
|
||||||
<Icon type="symbols" name="login" />
|
<span className="material-symbols" aria-hidden={true}>
|
||||||
|
login
|
||||||
|
</span>
|
||||||
<H lvl={3} margin={false} padding={false}>
|
<H lvl={3} margin={false} padding={false}>
|
||||||
{heading}
|
{heading}
|
||||||
</H>
|
</H>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { css } from '@/styled-system/css'
|
import { css } from '@/styled-system/css'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import { useMemo, useRef, useEffect } from 'react'
|
import { useMemo } from 'react'
|
||||||
import { Text } from '@/primitives'
|
import { Text } from '@/primitives'
|
||||||
import {
|
import {
|
||||||
RecordingMode,
|
RecordingMode,
|
||||||
@@ -13,7 +13,6 @@ import { useSidePanel } from '@/features/rooms/livekit/hooks/useSidePanel'
|
|||||||
import { useRoomMetadata } from '../hooks/useRoomMetadata'
|
import { useRoomMetadata } from '../hooks/useRoomMetadata'
|
||||||
import { RecordingStatusIcon } from './RecordingStatusIcon'
|
import { RecordingStatusIcon } from './RecordingStatusIcon'
|
||||||
import { useIsRecording } from '@livekit/components-react'
|
import { useIsRecording } from '@livekit/components-react'
|
||||||
import { useScreenReaderAnnounce } from '@/hooks/useScreenReaderAnnounce'
|
|
||||||
|
|
||||||
export const RecordingStateToast = () => {
|
export const RecordingStateToast = () => {
|
||||||
const { t } = useTranslation('rooms', {
|
const { t } = useTranslation('rooms', {
|
||||||
@@ -22,9 +21,6 @@ export const RecordingStateToast = () => {
|
|||||||
|
|
||||||
const { openTranscript, openScreenRecording } = useSidePanel()
|
const { openTranscript, openScreenRecording } = useSidePanel()
|
||||||
|
|
||||||
const lastKeyRef = useRef('')
|
|
||||||
const announce = useScreenReaderAnnounce()
|
|
||||||
|
|
||||||
const hasTranscriptAccess = useHasRecordingAccess(
|
const hasTranscriptAccess = useHasRecordingAccess(
|
||||||
RecordingMode.Transcript,
|
RecordingMode.Transcript,
|
||||||
FeatureFlags.Transcript
|
FeatureFlags.Transcript
|
||||||
@@ -71,16 +67,6 @@ export const RecordingStateToast = () => {
|
|||||||
return `${metadata.recording_mode}.${status}`
|
return `${metadata.recording_mode}.${status}`
|
||||||
}, [metadata, isStarted, isStarting, isRecording])
|
}, [metadata, isStarted, isStarting, isRecording])
|
||||||
|
|
||||||
// Update screen reader message only when the key actually changes
|
|
||||||
// This prevents duplicate announcements caused by re-renders
|
|
||||||
useEffect(() => {
|
|
||||||
if (key && key !== lastKeyRef.current) {
|
|
||||||
lastKeyRef.current = key
|
|
||||||
const message = t(key)
|
|
||||||
announce(message)
|
|
||||||
}
|
|
||||||
}, [announce, key, t])
|
|
||||||
|
|
||||||
if (!key) return null
|
if (!key) return null
|
||||||
|
|
||||||
const hasScreenRecordingAccessAndActive =
|
const hasScreenRecordingAccessAndActive =
|
||||||
@@ -88,8 +74,6 @@ export const RecordingStateToast = () => {
|
|||||||
const hasTranscriptAccessAndActive = isTranscriptActive && hasTranscriptAccess
|
const hasTranscriptAccessAndActive = isTranscriptActive && hasTranscriptAccess
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
|
||||||
{/* Visual banner (without aria-live to avoid duplicate announcements) */}
|
|
||||||
<div
|
<div
|
||||||
className={css({
|
className={css({
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
@@ -111,8 +95,7 @@ export const RecordingStateToast = () => {
|
|||||||
isTranscriptActive={isTranscriptActive}
|
isTranscriptActive={isTranscriptActive}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{!hasScreenRecordingAccessAndActive &&
|
{!hasScreenRecordingAccessAndActive && !hasTranscriptAccessAndActive && (
|
||||||
!hasTranscriptAccessAndActive && (
|
|
||||||
<Text
|
<Text
|
||||||
variant={'sm'}
|
variant={'sm'}
|
||||||
className={css({
|
className={css({
|
||||||
@@ -147,6 +130,5 @@ export const RecordingStateToast = () => {
|
|||||||
</RACButton>
|
</RACButton>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import { Spinner } from '@/primitives/Spinner'
|
import { Spinner } from '@/primitives/Spinner'
|
||||||
import { Icon } from '@/primitives'
|
|
||||||
|
|
||||||
interface RecordingStatusIconProps {
|
interface RecordingStatusIconProps {
|
||||||
isStarted: boolean
|
isStarted: boolean
|
||||||
@@ -15,8 +14,8 @@ export const RecordingStatusIcon = ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (isTranscriptActive) {
|
if (isTranscriptActive) {
|
||||||
return <Icon type="symbols" name="speech_to_text" />
|
return <span className="material-symbols">speech_to_text</span>
|
||||||
}
|
}
|
||||||
|
|
||||||
return <Icon type="symbols" name="screen_record" />
|
return <span className="material-symbols">screen_record</span>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Button, Icon, H, Text } from '@/primitives'
|
import { Button, H, Text } from '@/primitives'
|
||||||
import { css } from '@/styled-system/css'
|
import { css } from '@/styled-system/css'
|
||||||
import { HStack } from '@/styled-system/jsx'
|
import { HStack } from '@/styled-system/jsx'
|
||||||
import { useEffect, useRef, useState } from 'react'
|
import { useEffect, useRef, useState } from 'react'
|
||||||
@@ -59,7 +59,7 @@ export const RequestRecording = ({
|
|||||||
})}
|
})}
|
||||||
>
|
>
|
||||||
<HStack justify="start" alignItems="center" marginBottom="0.5rem">
|
<HStack justify="start" alignItems="center" marginBottom="0.5rem">
|
||||||
<Icon type="symbols" name="person_raised_hand" />
|
<span className="material-symbols">person_raised_hand</span>
|
||||||
<H lvl={3} margin={false} padding={false}>
|
<H lvl={3} margin={false} padding={false}>
|
||||||
{heading}
|
{heading}
|
||||||
</H>
|
</H>
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { css } from '@/styled-system/css'
|
import { css } from '@/styled-system/css'
|
||||||
import { ReactNode } from 'react'
|
import { ReactNode } from 'react'
|
||||||
import { Icon } from '@/primitives'
|
|
||||||
|
|
||||||
type RowPosition = 'first' | 'middle' | 'last' | 'single'
|
type RowPosition = 'first' | 'middle' | 'last' | 'single'
|
||||||
|
|
||||||
@@ -46,7 +45,7 @@ export const RowWrapper = ({
|
|||||||
})}
|
})}
|
||||||
>
|
>
|
||||||
{/* fixme - doesn't handle properly material-symbols */}
|
{/* fixme - doesn't handle properly material-symbols */}
|
||||||
<Icon name={iconName} />
|
<span className="material-icons">{iconName}</span>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
className={css({
|
className={css({
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { useRoomId } from '@/features/rooms/livekit/hooks/useRoomId'
|
|||||||
import { useRoomContext } from '@livekit/components-react'
|
import { useRoomContext } from '@livekit/components-react'
|
||||||
import {
|
import {
|
||||||
RecordingMode,
|
RecordingMode,
|
||||||
|
useHasFeatureWithoutAdminRights,
|
||||||
useHumanizeRecordingMaxDuration,
|
useHumanizeRecordingMaxDuration,
|
||||||
useRecordingStatuses,
|
useRecordingStatuses,
|
||||||
} from '@/features/recording'
|
} from '@/features/recording'
|
||||||
@@ -18,6 +19,7 @@ import {
|
|||||||
} from '@/features/notifications'
|
} from '@/features/notifications'
|
||||||
import posthog from 'posthog-js'
|
import posthog from 'posthog-js'
|
||||||
import { useConfig } from '@/api/useConfig'
|
import { useConfig } from '@/api/useConfig'
|
||||||
|
import { FeatureFlags } from '@/features/analytics/enums'
|
||||||
import { NoAccessView } from './NoAccessView'
|
import { NoAccessView } from './NoAccessView'
|
||||||
import { ControlsButton } from './ControlsButton'
|
import { ControlsButton } from './ControlsButton'
|
||||||
import { RowWrapper } from './RowWrapper'
|
import { RowWrapper } from './RowWrapper'
|
||||||
@@ -26,7 +28,6 @@ import { Checkbox } from '@/primitives/Checkbox'
|
|||||||
import { useTranscriptionLanguage } from '@/features/settings'
|
import { useTranscriptionLanguage } from '@/features/settings'
|
||||||
import { useMutateRecording } from '../hooks/useMutateRecording'
|
import { useMutateRecording } from '../hooks/useMutateRecording'
|
||||||
import { useSidePanel } from '@/features/rooms/livekit/hooks/useSidePanel'
|
import { useSidePanel } from '@/features/rooms/livekit/hooks/useSidePanel'
|
||||||
import { useIsAdminOrOwner } from '@/features/rooms/livekit/hooks/useIsAdminOrOwner.ts'
|
|
||||||
|
|
||||||
export const ScreenRecordingSidePanel = () => {
|
export const ScreenRecordingSidePanel = () => {
|
||||||
const { data } = useConfig()
|
const { data } = useConfig()
|
||||||
@@ -37,7 +38,10 @@ export const ScreenRecordingSidePanel = () => {
|
|||||||
|
|
||||||
const [includeTranscript, setIncludeTranscript] = useState(false)
|
const [includeTranscript, setIncludeTranscript] = useState(false)
|
||||||
|
|
||||||
const isAdminOrOwner = useIsAdminOrOwner()
|
const hasFeatureWithoutAdminRights = useHasFeatureWithoutAdminRights(
|
||||||
|
RecordingMode.ScreenRecording,
|
||||||
|
FeatureFlags.ScreenRecording
|
||||||
|
)
|
||||||
|
|
||||||
const { notifyParticipants } = useNotifyParticipants()
|
const { notifyParticipants } = useNotifyParticipants()
|
||||||
const { selectedLanguageKey, isLanguageSetToAuto } =
|
const { selectedLanguageKey, isLanguageSetToAuto } =
|
||||||
@@ -94,17 +98,14 @@ export const ScreenRecordingSidePanel = () => {
|
|||||||
await notifyParticipants({
|
await notifyParticipants({
|
||||||
type: NotificationType.ScreenRecordingStarted,
|
type: NotificationType.ScreenRecordingStarted,
|
||||||
})
|
})
|
||||||
posthog.capture('screen-recording-started', {
|
posthog.capture('screen-recording-started', {})
|
||||||
includeTranscript: includeTranscript,
|
|
||||||
language: selectedLanguageKey,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to handle recording:', error)
|
console.error('Failed to handle recording:', error)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!isAdminOrOwner) {
|
if (hasFeatureWithoutAdminRights) {
|
||||||
return (
|
return (
|
||||||
<NoAccessView
|
<NoAccessView
|
||||||
i18nKeyPrefix={keyPrefix}
|
i18nKeyPrefix={keyPrefix}
|
||||||
@@ -145,7 +146,7 @@ export const ScreenRecordingSidePanel = () => {
|
|||||||
},
|
},
|
||||||
})}
|
})}
|
||||||
/>
|
/>
|
||||||
<VStack gap={0} marginBottom={15}>
|
<VStack gap={0} marginBottom={30}>
|
||||||
<H lvl={1} margin={'sm'} fullWidth>
|
<H lvl={1} margin={'sm'} fullWidth>
|
||||||
{t('heading')}
|
{t('heading')}
|
||||||
</H>
|
</H>
|
||||||
@@ -162,7 +163,7 @@ export const ScreenRecordingSidePanel = () => {
|
|||||||
)}
|
)}
|
||||||
</Text>
|
</Text>
|
||||||
</VStack>
|
</VStack>
|
||||||
<VStack gap={0} marginBottom={25}>
|
<VStack gap={0} marginBottom={40}>
|
||||||
<RowWrapper iconName="cloud_download" position="first">
|
<RowWrapper iconName="cloud_download" position="first">
|
||||||
<Text variant="sm">{t('details.destination')}</Text>
|
<Text variant="sm">{t('details.destination')}</Text>
|
||||||
</RowWrapper>
|
</RowWrapper>
|
||||||
|
|||||||
@@ -117,10 +117,7 @@ export const TranscriptSidePanel = () => {
|
|||||||
await notifyParticipants({
|
await notifyParticipants({
|
||||||
type: NotificationType.TranscriptionStarted,
|
type: NotificationType.TranscriptionStarted,
|
||||||
})
|
})
|
||||||
posthog.capture('transcript-started', {
|
posthog.capture('transcript-started', {})
|
||||||
includeScreenRecording: includeScreenRecording,
|
|
||||||
language: selectedLanguageKey,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to handle transcript:', error)
|
console.error('Failed to handle transcript:', error)
|
||||||
@@ -181,7 +178,7 @@ export const TranscriptSidePanel = () => {
|
|||||||
},
|
},
|
||||||
})}
|
})}
|
||||||
/>
|
/>
|
||||||
<VStack gap={0} marginBottom={15}>
|
<VStack gap={0} marginBottom={30}>
|
||||||
<H lvl={1} margin={'sm'}>
|
<H lvl={1} margin={'sm'}>
|
||||||
{t('heading')}
|
{t('heading')}
|
||||||
</H>
|
</H>
|
||||||
@@ -198,7 +195,7 @@ export const TranscriptSidePanel = () => {
|
|||||||
)}
|
)}
|
||||||
</Text>
|
</Text>
|
||||||
</VStack>
|
</VStack>
|
||||||
<VStack gap={0} marginBottom={25}>
|
<VStack gap={0} marginBottom={40}>
|
||||||
<RowWrapper iconName="article" position="first">
|
<RowWrapper iconName="article" position="first">
|
||||||
<Text variant="sm">
|
<Text variant="sm">
|
||||||
{data?.transcription_destination ? (
|
{data?.transcription_destination ? (
|
||||||
|
|||||||
@@ -11,7 +11,6 @@ export const useHumanizeRecordingMaxDuration = () => {
|
|||||||
|
|
||||||
return humanizeDuration(data?.recording?.max_duration, {
|
return humanizeDuration(data?.recording?.max_duration, {
|
||||||
language: i18n.language,
|
language: i18n.language,
|
||||||
delimiter: ' ',
|
|
||||||
})
|
})
|
||||||
}, [data])
|
}, [data])
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -104,12 +104,14 @@ export const RecordingDownload = () => {
|
|||||||
{t('success.title')}
|
{t('success.title')}
|
||||||
</H>
|
</H>
|
||||||
<Text centered margin="md" wrap={'balance'}>
|
<Text centered margin="md" wrap={'balance'}>
|
||||||
<span>
|
<span
|
||||||
{t('success.body', {
|
dangerouslySetInnerHTML={{
|
||||||
|
__html: t('success.body', {
|
||||||
room: data.room.name,
|
room: data.room.name,
|
||||||
created_at: formatDate(data.created_at, 'YYYY-MM-DD HH:mm'),
|
created_at: formatDate(data.created_at, 'YYYY-MM-DD HH:mm'),
|
||||||
})}
|
}),
|
||||||
</span>
|
}}
|
||||||
|
/>
|
||||||
<span>
|
<span>
|
||||||
{configData?.recording?.expiration_days && (
|
{configData?.recording?.expiration_days && (
|
||||||
<>
|
<>
|
||||||
|
|||||||
@@ -25,12 +25,12 @@ import { VideoConference } from '../livekit/prefabs/VideoConference'
|
|||||||
import { css } from '@/styled-system/css'
|
import { css } from '@/styled-system/css'
|
||||||
import { BackgroundProcessorFactory } from '../livekit/components/blur'
|
import { BackgroundProcessorFactory } from '../livekit/components/blur'
|
||||||
import { LocalUserChoices } from '@/stores/userChoices'
|
import { LocalUserChoices } from '@/stores/userChoices'
|
||||||
|
import { navigateTo } from '@/navigation/navigateTo'
|
||||||
import { MediaDeviceErrorAlert } from './MediaDeviceErrorAlert'
|
import { MediaDeviceErrorAlert } from './MediaDeviceErrorAlert'
|
||||||
import { usePostHog } from 'posthog-js/react'
|
import { usePostHog } from 'posthog-js/react'
|
||||||
import { useConfig } from '@/api/useConfig'
|
import { useConfig } from '@/api/useConfig'
|
||||||
import { isFireFox } from '@/utils/livekit'
|
import { isFireFox } from '@/utils/livekit'
|
||||||
import { useIsMobile } from '@/utils/useIsMobile'
|
import { useIsMobile } from '@/utils/useIsMobile'
|
||||||
import { navigateTo } from '@/navigation/navigateTo'
|
|
||||||
|
|
||||||
export const Conference = ({
|
export const Conference = ({
|
||||||
roomId,
|
roomId,
|
||||||
@@ -228,20 +228,10 @@ export const Conference = ({
|
|||||||
posthog.captureException(e)
|
posthog.captureException(e)
|
||||||
}}
|
}}
|
||||||
onDisconnected={(e) => {
|
onDisconnected={(e) => {
|
||||||
switch (e) {
|
if (e == DisconnectReason.CLIENT_INITIATED) {
|
||||||
case DisconnectReason.CLIENT_INITIATED:
|
navigateTo('feedback', { duplicateIdentity: false })
|
||||||
navigateTo('feedback')
|
} else if (e == DisconnectReason.DUPLICATE_IDENTITY) {
|
||||||
return
|
navigateTo('feedback', { duplicateIdentity: true })
|
||||||
case DisconnectReason.DUPLICATE_IDENTITY:
|
|
||||||
case DisconnectReason.PARTICIPANT_REMOVED:
|
|
||||||
navigateTo(
|
|
||||||
'feedback',
|
|
||||||
{},
|
|
||||||
{
|
|
||||||
state: { reason: e },
|
|
||||||
}
|
|
||||||
)
|
|
||||||
return
|
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
onMediaDeviceFailure={(e, kind) => {
|
onMediaDeviceFailure={(e, kind) => {
|
||||||
|
|||||||
@@ -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: 'visible' }}
|
style={{ maxWidth: '100%', overflow: 'hidden' }}
|
||||||
>
|
>
|
||||||
<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: 'visible',
|
overflow: 'hidden',
|
||||||
})}
|
})}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
@@ -112,8 +112,8 @@ export const InviteDialog = (props: Omit<DialogProps, 'title'>) => {
|
|||||||
square
|
square
|
||||||
size={'sm'}
|
size={'sm'}
|
||||||
onPress={copyRoomUrlToClipboard}
|
onPress={copyRoomUrlToClipboard}
|
||||||
aria-label={isRoomUrlCopied ? t('copied') : t('copyUrl')}
|
aria-label={t('copyUrl')}
|
||||||
tooltip={isRoomUrlCopied ? t('copied') : t('copyUrl')}
|
tooltip={t('copyUrl')}
|
||||||
>
|
>
|
||||||
{isRoomUrlCopied ? (
|
{isRoomUrlCopied ? (
|
||||||
<RiCheckLine aria-hidden="true" />
|
<RiCheckLine aria-hidden="true" />
|
||||||
@@ -138,12 +138,11 @@ export const InviteDialog = (props: Omit<DialogProps, 'title'>) => {
|
|||||||
{formatPinCode(roomData?.pin_code)}
|
{formatPinCode(roomData?.pin_code)}
|
||||||
</Text>
|
</Text>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
variant={isCopied ? 'success' : 'secondaryText'}
|
variant={isCopied ? 'success' : 'secondaryText'}
|
||||||
size="sm"
|
size="sm"
|
||||||
fullWidth
|
fullWidth
|
||||||
aria-label={isCopied ? t('copied') : t('copy')}
|
aria-label={t('copy')}
|
||||||
style={{
|
style={{
|
||||||
justifyContent: 'start',
|
justifyContent: 'start',
|
||||||
}}
|
}}
|
||||||
@@ -174,7 +173,7 @@ export const InviteDialog = (props: Omit<DialogProps, 'title'>) => {
|
|||||||
<Button
|
<Button
|
||||||
variant={isCopied ? 'success' : 'tertiary'}
|
variant={isCopied ? 'success' : 'tertiary'}
|
||||||
fullWidth
|
fullWidth
|
||||||
aria-label={isCopied ? t('copied') : t('copy')}
|
aria-label={t('copy')}
|
||||||
onPress={copyRoomToClipboard}
|
onPress={copyRoomToClipboard}
|
||||||
data-attr="share-dialog-copy"
|
data-attr="share-dialog-copy"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -446,14 +446,16 @@ export const Join = ({
|
|||||||
type="text"
|
type="text"
|
||||||
onChange={saveUsername}
|
onChange={saveUsername}
|
||||||
label={t('usernameLabel')}
|
label={t('usernameLabel')}
|
||||||
id="input-name"
|
aria-label={t('usernameLabel')}
|
||||||
defaultValue={username}
|
defaultValue={username}
|
||||||
validate={(value) => !value && t('errors.usernameEmpty')}
|
validate={(value) => !value && t('errors.usernameEmpty')}
|
||||||
wrapperProps={{
|
wrapperProps={{
|
||||||
noMargin: true,
|
noMargin: true,
|
||||||
fullWidth: true,
|
fullWidth: true,
|
||||||
}}
|
}}
|
||||||
autoComplete="name"
|
labelProps={{
|
||||||
|
center: true,
|
||||||
|
}}
|
||||||
maxLength={50}
|
maxLength={50}
|
||||||
/>
|
/>
|
||||||
</VStack>
|
</VStack>
|
||||||
@@ -753,7 +755,7 @@ export const Join = ({
|
|||||||
try {
|
try {
|
||||||
saveVideoInputDeviceId(id)
|
saveVideoInputDeviceId(id)
|
||||||
if (videoTrack) {
|
if (videoTrack) {
|
||||||
await videoTrack.setDeviceId({ exact: id })
|
await 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 type { PostHog } from 'posthog-js'
|
import { 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,17 +74,13 @@ export const useWaitingParticipants = () => {
|
|||||||
): Promise<void> => {
|
): Promise<void> => {
|
||||||
try {
|
try {
|
||||||
setListEnabled(false)
|
setListEnabled(false)
|
||||||
|
for (const participant of waitingParticipants) {
|
||||||
await Promise.all(
|
await enterRoom({
|
||||||
waitingParticipants.map((participant) =>
|
|
||||||
enterRoom({
|
|
||||||
roomId: roomId,
|
roomId: roomId,
|
||||||
allowEntry,
|
allowEntry,
|
||||||
participantId: participant.id,
|
participantId: participant.id,
|
||||||
})
|
})
|
||||||
)
|
}
|
||||||
)
|
|
||||||
|
|
||||||
await refetchWaiting()
|
await refetchWaiting()
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(e)
|
console.error(e)
|
||||||
|
|||||||
@@ -1,29 +1,21 @@
|
|||||||
import { A, Button, Dialog, H, P, ScreenReaderAnnouncer } from '@/primitives'
|
import { Button, Dialog, H, P } from '@/primitives'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import { css } from '@/styled-system/css'
|
import { css } from '@/styled-system/css'
|
||||||
import { useSnapshot } from 'valtio'
|
import { useSnapshot } from 'valtio'
|
||||||
import { connectionObserverStore } from '@/stores/connectionObserver'
|
import { connectionObserverStore } from '@/stores/connectionObserver'
|
||||||
import { HStack } from '@/styled-system/jsx'
|
import { HStack } from '@/styled-system/jsx'
|
||||||
import { useEffect, useRef, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import { navigateTo } from '@/navigation/navigateTo'
|
import { navigateTo } from '@/navigation/navigateTo'
|
||||||
import humanizeDuration from 'humanize-duration'
|
import humanizeDuration from 'humanize-duration'
|
||||||
import i18n from 'i18next'
|
import i18n from 'i18next'
|
||||||
import { useScreenReaderAnnounce } from '@/hooks/useScreenReaderAnnounce'
|
|
||||||
import { useSettingsDialog } from '@/features/settings/hook/useSettingsDialog'
|
|
||||||
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 = new Set([90, 60, 30])
|
|
||||||
const FINAL_COUNTDOWN_SECONDS = 10
|
|
||||||
|
|
||||||
export const IsIdleDisconnectModal = () => {
|
export const IsIdleDisconnectModal = () => {
|
||||||
const connectionObserverSnap = useSnapshot(connectionObserverStore)
|
const connectionObserverSnap = useSnapshot(connectionObserverStore)
|
||||||
const [timeRemaining, setTimeRemaining] = useState(IDLE_DISCONNECT_TIMEOUT_MS)
|
const [timeRemaining, setTimeRemaining] = useState(IDLE_DISCONNECT_TIMEOUT_MS)
|
||||||
const lastAnnouncementRef = useRef<number | null>(null)
|
|
||||||
const { openSettingsDialog } = useSettingsDialog()
|
|
||||||
|
|
||||||
const { t } = useTranslation('rooms', { keyPrefix: 'isIdleDisconnectModal' })
|
const { t } = useTranslation('rooms', { keyPrefix: 'isIdleDisconnectModal' })
|
||||||
const announce = useScreenReaderAnnounce()
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (connectionObserverSnap.isIdleDisconnectModalOpen) {
|
if (connectionObserverSnap.isIdleDisconnectModalOpen) {
|
||||||
@@ -43,42 +35,10 @@ export const IsIdleDisconnectModal = () => {
|
|||||||
}
|
}
|
||||||
}, [connectionObserverSnap.isIdleDisconnectModalOpen])
|
}, [connectionObserverSnap.isIdleDisconnectModalOpen])
|
||||||
|
|
||||||
useEffect(() => {
|
const minutes = Math.floor(timeRemaining / 1000 / 60)
|
||||||
if (!connectionObserverSnap.isIdleDisconnectModalOpen) {
|
const seconds = (timeRemaining / 1000) % 60
|
||||||
lastAnnouncementRef.current = null
|
|
||||||
}
|
|
||||||
}, [connectionObserverSnap.isIdleDisconnectModalOpen])
|
|
||||||
|
|
||||||
const remainingSeconds = Math.floor(timeRemaining / 1000)
|
|
||||||
const minutes = Math.floor(remainingSeconds / 60)
|
|
||||||
const seconds = remainingSeconds % 60
|
|
||||||
const formattedTime = `${minutes}:${seconds.toString().padStart(2, '0')}`
|
const formattedTime = `${minutes}:${seconds.toString().padStart(2, '0')}`
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!connectionObserverSnap.isIdleDisconnectModalOpen) return
|
|
||||||
|
|
||||||
const shouldAnnounce =
|
|
||||||
COUNTDOWN_ANNOUNCEMENT_SECONDS.has(remainingSeconds) ||
|
|
||||||
remainingSeconds <= FINAL_COUNTDOWN_SECONDS
|
|
||||||
|
|
||||||
if (shouldAnnounce && remainingSeconds !== lastAnnouncementRef.current) {
|
|
||||||
lastAnnouncementRef.current = remainingSeconds
|
|
||||||
const message = t('countdownAnnouncement', {
|
|
||||||
duration: humanizeDuration(remainingSeconds * 1000, {
|
|
||||||
language: i18n.language,
|
|
||||||
round: false,
|
|
||||||
largest: 2,
|
|
||||||
}),
|
|
||||||
})
|
|
||||||
announce(message, 'assertive', 'idle')
|
|
||||||
}
|
|
||||||
}, [
|
|
||||||
announce,
|
|
||||||
connectionObserverSnap.isIdleDisconnectModalOpen,
|
|
||||||
remainingSeconds,
|
|
||||||
t,
|
|
||||||
])
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog
|
<Dialog
|
||||||
isOpen={connectionObserverSnap.isIdleDisconnectModalOpen}
|
isOpen={connectionObserverSnap.isIdleDisconnectModalOpen}
|
||||||
@@ -92,7 +52,6 @@ export const IsIdleDisconnectModal = () => {
|
|||||||
{({ close }) => {
|
{({ close }) => {
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<ScreenReaderAnnouncer channel="idle" />
|
|
||||||
<div
|
<div
|
||||||
className={css({
|
className={css({
|
||||||
height: '50px',
|
height: '50px',
|
||||||
@@ -106,7 +65,6 @@ export const IsIdleDisconnectModal = () => {
|
|||||||
color: 'blue.800',
|
color: 'blue.800',
|
||||||
margin: 'auto',
|
margin: 'auto',
|
||||||
})}
|
})}
|
||||||
aria-hidden="true"
|
|
||||||
>
|
>
|
||||||
{formattedTime}
|
{formattedTime}
|
||||||
</div>
|
</div>
|
||||||
@@ -120,19 +78,7 @@ export const IsIdleDisconnectModal = () => {
|
|||||||
}),
|
}),
|
||||||
})}
|
})}
|
||||||
</P>
|
</P>
|
||||||
<P>
|
<P>{t('settings')}</P>
|
||||||
{t('settingsPrefix')}{' '}
|
|
||||||
<A
|
|
||||||
color="primary"
|
|
||||||
onPress={() => {
|
|
||||||
connectionObserverStore.isIdleDisconnectModalOpen = false
|
|
||||||
openSettingsDialog(SettingsDialogExtendedKey.GENERAL)
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{t('settingsLink')}
|
|
||||||
</A>
|
|
||||||
{t('settingsSuffix')}
|
|
||||||
</P>
|
|
||||||
<HStack marginTop="2rem">
|
<HStack marginTop="2rem">
|
||||||
<Button
|
<Button
|
||||||
onPress={() => {
|
onPress={() => {
|
||||||
|
|||||||
@@ -1,28 +1,5 @@
|
|||||||
import React, { ReactNode } from 'react'
|
import React, { ReactNode } from 'react'
|
||||||
import { styled } from '@/styled-system/jsx'
|
import { css } from '@/styled-system/css'
|
||||||
|
|
||||||
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
|
||||||
@@ -35,5 +12,21 @@ export interface KeyboardShortcutHintProps {
|
|||||||
export const KeyboardShortcutHint: React.FC<KeyboardShortcutHintProps> = ({
|
export const KeyboardShortcutHint: React.FC<KeyboardShortcutHintProps> = ({
|
||||||
children,
|
children,
|
||||||
}) => {
|
}) => {
|
||||||
return <Hint>{children}</Hint>
|
return (
|
||||||
|
<div
|
||||||
|
className={css({
|
||||||
|
position: 'absolute',
|
||||||
|
top: '0.75rem',
|
||||||
|
right: '0.75rem',
|
||||||
|
backgroundColor: 'rgba(0,0,0,0.5)',
|
||||||
|
color: 'white',
|
||||||
|
borderRadius: 'calc(var(--lk-border-radius) / 2)',
|
||||||
|
paddingInline: '0.5rem',
|
||||||
|
paddingBlock: '0.1rem',
|
||||||
|
fontSize: '0.875rem',
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,21 +1,8 @@
|
|||||||
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,
|
||||||
@@ -30,14 +17,26 @@ export const ParticipantName = ({
|
|||||||
|
|
||||||
if (isScreenShare) {
|
if (isScreenShare) {
|
||||||
return (
|
return (
|
||||||
<Text variant="sm" style={participantNameScreenShareStyles}>
|
<Text
|
||||||
|
variant="sm"
|
||||||
|
style={{
|
||||||
|
paddingBottom: '0.1rem',
|
||||||
|
marginLeft: '0.4rem',
|
||||||
|
}}
|
||||||
|
>
|
||||||
{t('screenShare', { name: displayedName })}
|
{t('screenShare', { name: displayedName })}
|
||||||
</Text>
|
</Text>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Text variant="sm" style={participantNameStyles} aria-hidden="true">
|
<Text
|
||||||
|
variant="sm"
|
||||||
|
style={{
|
||||||
|
paddingBottom: '0.1rem',
|
||||||
|
}}
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
{displayedName}
|
{displayedName}
|
||||||
</Text>
|
</Text>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -117,9 +117,7 @@ export const ParticipantTile: (
|
|||||||
'aria-label': t('containerLabel', { name: participantName }),
|
'aria-label': t('containerLabel', { name: participantName }),
|
||||||
onFocus: (event: React.FocusEvent<HTMLDivElement>) => {
|
onFocus: (event: React.FocusEvent<HTMLDivElement>) => {
|
||||||
elementProps.onFocus?.(event)
|
elementProps.onFocus?.(event)
|
||||||
const target = event.target as HTMLElement | null
|
setHasKeyboardFocus(true)
|
||||||
const isFocusVisible = !!target?.matches?.(':focus-visible')
|
|
||||||
setHasKeyboardFocus(isFocusVisible)
|
|
||||||
},
|
},
|
||||||
onBlur: (event: React.FocusEvent<HTMLDivElement>) => {
|
onBlur: (event: React.FocusEvent<HTMLDivElement>) => {
|
||||||
elementProps.onBlur?.(event)
|
elementProps.onBlur?.(event)
|
||||||
@@ -183,7 +181,7 @@ export const ParticipantTile: (
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{isHandRaised && !isScreenShare && (
|
{isHandRaised && !isScreenShare && (
|
||||||
<span>
|
<>
|
||||||
<span>{positionInQueue}</span>
|
<span>{positionInQueue}</span>
|
||||||
<RiHand
|
<RiHand
|
||||||
color="black"
|
color="black"
|
||||||
@@ -197,7 +195,7 @@ export const ParticipantTile: (
|
|||||||
animationIterationCount: '2',
|
animationIterationCount: '2',
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</span>
|
</>
|
||||||
)}
|
)}
|
||||||
{isScreenShare && (
|
{isScreenShare && (
|
||||||
<ScreenShareIcon
|
<ScreenShareIcon
|
||||||
@@ -210,13 +208,11 @@ export const ParticipantTile: (
|
|||||||
{isEncrypted && !isScreenShare && (
|
{isEncrypted && !isScreenShare && (
|
||||||
<LockLockedIcon style={{ marginRight: '0.25rem' }} />
|
<LockLockedIcon style={{ marginRight: '0.25rem' }} />
|
||||||
)}
|
)}
|
||||||
<div className="lk-participant-name-wrapper">
|
|
||||||
<ParticipantName
|
<ParticipantName
|
||||||
isScreenShare={isScreenShare}
|
isScreenShare={isScreenShare}
|
||||||
participant={trackReference.participant}
|
participant={trackReference.participant}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
</HStack>
|
</HStack>
|
||||||
<ConnectionQualityIndicator className="lk-participant-metadata-item" />
|
<ConnectionQualityIndicator className="lk-participant-metadata-item" />
|
||||||
</div>
|
</div>
|
||||||
@@ -231,7 +227,9 @@ export const ParticipantTile: (
|
|||||||
)}
|
)}
|
||||||
</ParticipantContextIfNeeded>
|
</ParticipantContextIfNeeded>
|
||||||
</TrackRefContextIfNeeded>
|
</TrackRefContextIfNeeded>
|
||||||
|
{hasKeyboardFocus && (
|
||||||
<KeyboardShortcutHint>{t('toolbarHint')}</KeyboardShortcutHint>
|
<KeyboardShortcutHint>{t('toolbarHint')}</KeyboardShortcutHint>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -5,10 +5,6 @@ import { css } from '@/styled-system/css'
|
|||||||
import { Participant } from 'livekit-client'
|
import { Participant } from 'livekit-client'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import { Reaction } from '@/features/rooms/livekit/components/controls/ReactionsToggle'
|
import { Reaction } from '@/features/rooms/livekit/components/controls/ReactionsToggle'
|
||||||
import { getEmojiLabel } from '@/features/rooms/livekit/utils/reactionUtils'
|
|
||||||
import { accessibilityStore } from '@/stores/accessibility'
|
|
||||||
import { useSnapshot } from 'valtio'
|
|
||||||
import { useScreenReaderAnnounce } from '@/hooks/useScreenReaderAnnounce'
|
|
||||||
|
|
||||||
export const ANIMATION_DURATION = 3000
|
export const ANIMATION_DURATION = 3000
|
||||||
export const ANIMATION_DISTANCE = 300
|
export const ANIMATION_DISTANCE = 300
|
||||||
@@ -144,41 +140,11 @@ export function ReactionPortal({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ReactionPortals = ({ reactions }: { reactions: Reaction[] }) => {
|
export const ReactionPortals = ({ reactions }: { reactions: Reaction[] }) =>
|
||||||
const { t } = useTranslation('rooms', { keyPrefix: 'controls.reactions' })
|
reactions.map((instance) => (
|
||||||
const { announceReactions } = useSnapshot(accessibilityStore)
|
|
||||||
const [lastAnnouncedId, setLastAnnouncedId] = useState<number | null>(null)
|
|
||||||
const announce = useScreenReaderAnnounce()
|
|
||||||
|
|
||||||
const latestReaction =
|
|
||||||
reactions.length > 0 ? reactions[reactions.length - 1] : undefined
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!announceReactions) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (!latestReaction) return
|
|
||||||
const isNewReaction = latestReaction.id !== lastAnnouncedId
|
|
||||||
if (!isNewReaction) return
|
|
||||||
|
|
||||||
const emojiLabel = getEmojiLabel(latestReaction.emoji, t)
|
|
||||||
const participantName = latestReaction.participant?.isLocal
|
|
||||||
? t('you')
|
|
||||||
: latestReaction.participant?.name?.trim() ||
|
|
||||||
t('someone', { defaultValue: 'Someone' })
|
|
||||||
announce(t('announce', { name: participantName, emoji: emojiLabel }))
|
|
||||||
setLastAnnouncedId(latestReaction.id)
|
|
||||||
}, [announce, latestReaction, lastAnnouncedId, announceReactions, t])
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
{reactions.map((instance) => (
|
|
||||||
<ReactionPortal
|
<ReactionPortal
|
||||||
key={instance.id}
|
key={instance.id}
|
||||||
emoji={instance.emoji}
|
emoji={instance.emoji}
|
||||||
participant={instance.participant}
|
participant={instance.participant}
|
||||||
/>
|
/>
|
||||||
))}
|
))
|
||||||
</>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -13,32 +13,27 @@ import { Effects } from './effects/Effects'
|
|||||||
import { Admin } from './Admin'
|
import { Admin } from './Admin'
|
||||||
import { Tools } from './Tools'
|
import { Tools } from './Tools'
|
||||||
import { Info } from './Info'
|
import { Info } from './Info'
|
||||||
import { HStack } from '@/styled-system/jsx'
|
|
||||||
|
|
||||||
type StyledSidePanelProps = {
|
type StyledSidePanelProps = {
|
||||||
title: string
|
title: string
|
||||||
ariaLabel: string
|
|
||||||
children: ReactNode
|
children: ReactNode
|
||||||
onClose: () => void
|
onClose: () => void
|
||||||
isClosed: boolean
|
isClosed: boolean
|
||||||
closeButtonTooltip: string
|
closeButtonTooltip: string
|
||||||
isSubmenu: boolean
|
isSubmenu: boolean
|
||||||
onBack: () => void
|
onBack: () => void
|
||||||
backButtonLabel: string
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const StyledSidePanel = ({
|
const StyledSidePanel = ({
|
||||||
title,
|
title,
|
||||||
ariaLabel,
|
|
||||||
children,
|
children,
|
||||||
onClose,
|
onClose,
|
||||||
isClosed,
|
isClosed,
|
||||||
closeButtonTooltip,
|
closeButtonTooltip,
|
||||||
isSubmenu = false,
|
isSubmenu = false,
|
||||||
onBack,
|
onBack,
|
||||||
backButtonLabel,
|
|
||||||
}: StyledSidePanelProps) => (
|
}: StyledSidePanelProps) => (
|
||||||
<aside
|
<div
|
||||||
className={css({
|
className={css({
|
||||||
borderWidth: '1px',
|
borderWidth: '1px',
|
||||||
borderStyle: 'solid',
|
borderStyle: 'solid',
|
||||||
@@ -63,37 +58,32 @@ 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">
|
|
||||||
{isSubmenu && (
|
|
||||||
<Button
|
|
||||||
variant="secondaryText"
|
|
||||||
size="sm"
|
|
||||||
square
|
|
||||||
className={css({ marginRight: '0.5rem', marginLeft: '1rem' })}
|
|
||||||
aria-label={backButtonLabel}
|
|
||||||
onPress={onBack}
|
|
||||||
>
|
|
||||||
<RiArrowLeftLine size={20} aria-hidden="true" />
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
<Heading
|
<Heading
|
||||||
slot="title"
|
slot="title"
|
||||||
level={1}
|
level={1}
|
||||||
className={text({ variant: 'h2' })}
|
className={text({ variant: 'h2' })}
|
||||||
style={{
|
style={{
|
||||||
paddingLeft: isSubmenu ? 0 : '1.5rem',
|
paddingLeft: '1.5rem',
|
||||||
paddingTop: '1rem',
|
paddingTop: '1rem',
|
||||||
display: isClosed ? 'none' : 'flex',
|
display: isClosed ? 'none' : 'flex',
|
||||||
justifyContent: 'start',
|
justifyContent: 'start',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
{isSubmenu && (
|
||||||
|
<Button
|
||||||
|
variant="secondaryText"
|
||||||
|
size={'sm'}
|
||||||
|
square
|
||||||
|
className={css({ marginRight: '0.5rem' })}
|
||||||
|
onPress={onBack}
|
||||||
|
>
|
||||||
|
<RiArrowLeftLine size={20} />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
{title}
|
{title}
|
||||||
</Heading>
|
</Heading>
|
||||||
</HStack>
|
|
||||||
<Div
|
<Div
|
||||||
position="absolute"
|
position="absolute"
|
||||||
top="5"
|
top="5"
|
||||||
@@ -114,7 +104,7 @@ const StyledSidePanel = ({
|
|||||||
</Button>
|
</Button>
|
||||||
</Div>
|
</Div>
|
||||||
{children}
|
{children}
|
||||||
</aside>
|
</div>
|
||||||
)
|
)
|
||||||
|
|
||||||
type PanelProps = {
|
type PanelProps = {
|
||||||
@@ -135,6 +125,7 @@ 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,
|
||||||
@@ -153,7 +144,6 @@ export const SidePanel = () => {
|
|||||||
return (
|
return (
|
||||||
<StyledSidePanel
|
<StyledSidePanel
|
||||||
title={t(`heading.${activeSubPanelId || activePanelId}`)}
|
title={t(`heading.${activeSubPanelId || activePanelId}`)}
|
||||||
ariaLabel={t('ariaLabel')}
|
|
||||||
onClose={() => {
|
onClose={() => {
|
||||||
layoutStore.activePanelId = null
|
layoutStore.activePanelId = null
|
||||||
layoutStore.activeSubPanelId = null
|
layoutStore.activeSubPanelId = null
|
||||||
@@ -163,7 +153,6 @@ export const SidePanel = () => {
|
|||||||
})}
|
})}
|
||||||
isClosed={!isSidePanelOpen}
|
isClosed={!isSidePanelOpen}
|
||||||
isSubmenu={isSubPanelOpen}
|
isSubmenu={isSubPanelOpen}
|
||||||
backButtonLabel={t('backToTools')}
|
|
||||||
onBack={() => (layoutStore.activeSubPanelId = null)}
|
onBack={() => (layoutStore.activeSubPanelId = null)}
|
||||||
>
|
>
|
||||||
<Panel isOpen={isParticipantsOpen}>
|
<Panel isOpen={isParticipantsOpen}>
|
||||||
@@ -175,7 +164,7 @@ export const SidePanel = () => {
|
|||||||
<Panel isOpen={isChatOpen} keepAlive={true}>
|
<Panel isOpen={isChatOpen} keepAlive={true}>
|
||||||
<Chat />
|
<Chat />
|
||||||
</Panel>
|
</Panel>
|
||||||
<Panel isOpen={isToolsOpen} keepAlive={true}>
|
<Panel isOpen={isToolsOpen}>
|
||||||
<Tools />
|
<Tools />
|
||||||
</Panel>
|
</Panel>
|
||||||
<Panel isOpen={isAdminOpen}>
|
<Panel isOpen={isAdminOpen}>
|
||||||
|
|||||||
@@ -1,10 +1,9 @@
|
|||||||
import { A, Div, Icon, Text } from '@/primitives'
|
import { A, Div, Text } from '@/primitives'
|
||||||
import { css } from '@/styled-system/css'
|
import { css } from '@/styled-system/css'
|
||||||
import { Button as RACButton } from 'react-aria-components'
|
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,
|
||||||
@@ -87,7 +86,7 @@ const ToolButton = ({
|
|||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
})}
|
})}
|
||||||
>
|
>
|
||||||
<Icon type="symbols" name="chevron_forward" />
|
<span className="material-symbols">chevron_forward</span>
|
||||||
</div>
|
</div>
|
||||||
</RACButton>
|
</RACButton>
|
||||||
)
|
)
|
||||||
@@ -95,26 +94,10 @@ const ToolButton = ({
|
|||||||
|
|
||||||
export const Tools = () => {
|
export const Tools = () => {
|
||||||
const { data } = useConfig()
|
const { data } = useConfig()
|
||||||
const { openTranscript, openScreenRecording, activeSubPanelId, isToolsOpen } =
|
const { openTranscript, openScreenRecording, activeSubPanelId } =
|
||||||
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
|
||||||
)
|
)
|
||||||
@@ -163,7 +146,7 @@ export const Tools = () => {
|
|||||||
</Text>
|
</Text>
|
||||||
{isTranscriptEnabled && (
|
{isTranscriptEnabled && (
|
||||||
<ToolButton
|
<ToolButton
|
||||||
icon={<Icon type="symbols" name="speech_to_text" />}
|
icon={<span className="material-symbols">speech_to_text</span>}
|
||||||
title={t('tools.transcript.title')}
|
title={t('tools.transcript.title')}
|
||||||
description={t('tools.transcript.body')}
|
description={t('tools.transcript.body')}
|
||||||
onPress={() => openTranscript()}
|
onPress={() => openTranscript()}
|
||||||
@@ -171,7 +154,7 @@ export const Tools = () => {
|
|||||||
)}
|
)}
|
||||||
{isScreenRecordingEnabled && (
|
{isScreenRecordingEnabled && (
|
||||||
<ToolButton
|
<ToolButton
|
||||||
icon={<Icon type="symbols" name="mode_standby" />}
|
icon={<span className="material-symbols">mode_standby</span>}
|
||||||
title={t('tools.screenRecording.title')}
|
title={t('tools.screenRecording.title')}
|
||||||
description={t('tools.screenRecording.body')}
|
description={t('tools.screenRecording.body')}
|
||||||
onPress={() => openScreenRecording()}
|
onPress={() => openScreenRecording()}
|
||||||
|
|||||||
+3
-1
@@ -11,7 +11,9 @@ import {
|
|||||||
ProcessorType,
|
ProcessorType,
|
||||||
} from '.'
|
} from '.'
|
||||||
|
|
||||||
export class UnifiedBackgroundTrackProcessor implements BackgroundProcessorInterface {
|
export class UnifiedBackgroundTrackProcessor
|
||||||
|
implements BackgroundProcessorInterface
|
||||||
|
{
|
||||||
processor: ProcessorWrapper<BackgroundOptions>
|
processor: ProcessorWrapper<BackgroundOptions>
|
||||||
opts: BackgroundOptions
|
opts: BackgroundOptions
|
||||||
processorType: ProcessorType
|
processorType: ProcessorType
|
||||||
|
|||||||
@@ -13,7 +13,8 @@ export interface ProcessorSerialized {
|
|||||||
options: BackgroundOptions
|
options: BackgroundOptions
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface BackgroundProcessorInterface extends TrackProcessor<Track.Kind> {
|
export interface BackgroundProcessorInterface
|
||||||
|
extends TrackProcessor<Track.Kind> {
|
||||||
update(opts: BackgroundOptions): Promise<void>
|
update(opts: BackgroundOptions): Promise<void>
|
||||||
options: BackgroundOptions
|
options: BackgroundOptions
|
||||||
clone(): BackgroundProcessorInterface
|
clone(): BackgroundProcessorInterface
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ import { ToggleButton } from '@/primitives'
|
|||||||
import { chatStore } from '@/stores/chat'
|
import { chatStore } from '@/stores/chat'
|
||||||
import { useSidePanel } from '../../hooks/useSidePanel'
|
import { useSidePanel } from '../../hooks/useSidePanel'
|
||||||
import { ToggleButtonProps } from '@/primitives/ToggleButton'
|
import { ToggleButtonProps } from '@/primitives/ToggleButton'
|
||||||
import { useRegisterKeyboardShortcut } from '@/features/shortcuts/useRegisterKeyboardShortcut'
|
|
||||||
|
|
||||||
export const ChatToggle = ({
|
export const ChatToggle = ({
|
||||||
onPress,
|
onPress,
|
||||||
@@ -19,11 +18,6 @@ export const ChatToggle = ({
|
|||||||
const { isChatOpen, toggleChat } = useSidePanel()
|
const { isChatOpen, toggleChat } = useSidePanel()
|
||||||
const tooltipLabel = isChatOpen ? 'open' : 'closed'
|
const tooltipLabel = isChatOpen ? 'open' : 'closed'
|
||||||
|
|
||||||
useRegisterKeyboardShortcut({
|
|
||||||
id: 'toggle-chat',
|
|
||||||
handler: toggleChat,
|
|
||||||
})
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={css({
|
className={css({
|
||||||
|
|||||||
+4
-24
@@ -1,6 +1,5 @@
|
|||||||
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'
|
||||||
@@ -19,7 +18,6 @@ import { useCannotUseDevice } from '../../../hooks/useCannotUseDevice'
|
|||||||
import { useDeviceIcons } from '../../../hooks/useDeviceIcons'
|
import { useDeviceIcons } from '../../../hooks/useDeviceIcons'
|
||||||
import { useDeviceShortcut } from '../../../hooks/useDeviceShortcut'
|
import { useDeviceShortcut } from '../../../hooks/useDeviceShortcut'
|
||||||
import { ToggleSource, CaptureOptionsBySource } from '@livekit/components-core'
|
import { ToggleSource, CaptureOptionsBySource } from '@livekit/components-core'
|
||||||
import { getShortcutDescriptorById } from '@/features/shortcuts/catalog'
|
|
||||||
|
|
||||||
type ToggleDeviceStyleProps = {
|
type ToggleDeviceStyleProps = {
|
||||||
variant?: NonNullable<ButtonRecipeProps>['variant']
|
variant?: NonNullable<ButtonRecipeProps>['variant']
|
||||||
@@ -88,30 +86,14 @@ 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,
|
shortcut: deviceShortcut,
|
||||||
handler: async () => {
|
handler: async () => await toggle(),
|
||||||
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,
|
||||||
})
|
})
|
||||||
|
|
||||||
const pushToTalkShortcut = getShortcutDescriptorById('push-to-talk')
|
|
||||||
useLongPress({
|
useLongPress({
|
||||||
keyCode: kind === 'audioinput' ? pushToTalkShortcut?.code : undefined,
|
keyCode: kind === 'audioinput' ? 'KeyV' : undefined,
|
||||||
onKeyDown,
|
onKeyDown,
|
||||||
onKeyUp,
|
onKeyUp,
|
||||||
isDisabled: cannotUseDevice,
|
isDisabled: cannotUseDevice,
|
||||||
@@ -121,9 +103,7 @@ export const ToggleDevice = <T extends ToggleSource>({
|
|||||||
const label = t(enabled ? 'disable' : 'enable', {
|
const label = t(enabled ? 'disable' : 'enable', {
|
||||||
keyPrefix: `selectDevice.${kind}`,
|
keyPrefix: `selectDevice.${kind}`,
|
||||||
})
|
})
|
||||||
return deviceShortcut?.shortcut
|
return deviceShortcut ? appendShortcutLabel(label, deviceShortcut) : label
|
||||||
? appendShortcutLabel(label, deviceShortcut.shortcut)
|
|
||||||
: label
|
|
||||||
}, [enabled, kind, deviceShortcut, t])
|
}, [enabled, kind, deviceShortcut, t])
|
||||||
|
|
||||||
const Icon =
|
const Icon =
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user