mirror of
https://github.com/suitenumerique/meet.git
synced 2026-07-27 12:19:10 +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
|
||||||
@@ -55,7 +49,6 @@ jobs:
|
|||||||
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
|
||||||
@@ -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
|
||||||
@@ -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
-128
@@ -8,140 +8,13 @@ and this project adheres to
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
### Added
|
|
||||||
|
|
||||||
- 👷(docker) add arm64 platform support for image builds
|
|
||||||
|
|
||||||
### 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
|
|
||||||
- ♿️(a11y) fix focus ring on tab container components
|
|
||||||
|
|
||||||
|
|
||||||
## [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}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
+1
-5
@@ -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,9 +69,8 @@ clean_old_images('localhost:5001/meet-summary')
|
|||||||
docker_build(
|
docker_build(
|
||||||
'localhost:5001/meet-agents:latest',
|
'localhost:5001/meet-agents:latest',
|
||||||
context='../src/agents',
|
context='../src/agents',
|
||||||
build_args={'DOCKER_USER': '1001:127'},
|
|
||||||
dockerfile='../src/agents/Dockerfile',
|
dockerfile='../src/agents/Dockerfile',
|
||||||
only=['.'],
|
only=['.'],
|
||||||
target = 'production',
|
target = 'production',
|
||||||
live_update=[
|
live_update=[
|
||||||
sync('../src/agents', '/app'),
|
sync('../src/agents', '/app'),
|
||||||
|
|||||||
@@ -1,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;
|
|
||||||
}
|
|
||||||
@@ -38,20 +38,6 @@ COPY ./docker/dinum-frontend/assets/ \
|
|||||||
COPY ./docker/dinum-frontend/fonts/ \
|
COPY ./docker/dinum-frontend/fonts/ \
|
||||||
./dist/assets/fonts/
|
./dist/assets/fonts/
|
||||||
|
|
||||||
# ---- Outlook add-in builder image ----
|
|
||||||
FROM node:20-alpine AS outlook-addin-builder
|
|
||||||
|
|
||||||
WORKDIR /home/outlook-addin
|
|
||||||
|
|
||||||
COPY ./src/addins/outlook-addin/package.json ./package.json
|
|
||||||
COPY ./src/addins/outlook-addin/package-lock.json ./package-lock.json
|
|
||||||
|
|
||||||
RUN npm ci
|
|
||||||
|
|
||||||
COPY ./src/addins/outlook-addin/ .
|
|
||||||
|
|
||||||
RUN npx webpack --mode production
|
|
||||||
|
|
||||||
# ---- Front-end image ----
|
# ---- Front-end image ----
|
||||||
FROM nginxinc/nginx-unprivileged:alpine3.21 AS frontend-production
|
FROM nginxinc/nginx-unprivileged:alpine3.21 AS frontend-production
|
||||||
|
|
||||||
@@ -73,10 +59,6 @@ COPY --from=meet-builder \
|
|||||||
/home/frontend/dist \
|
/home/frontend/dist \
|
||||||
/usr/share/nginx/html
|
/usr/share/nginx/html
|
||||||
|
|
||||||
COPY --from=outlook-addin-builder \
|
|
||||||
/home/outlook-addin/dist \
|
|
||||||
/usr/share/nginx/html/outlook-addin
|
|
||||||
|
|
||||||
COPY ./src/frontend/default.conf /etc/nginx/conf.d
|
COPY ./src/frontend/default.conf /etc/nginx/conf.d
|
||||||
COPY ./docker/files/usr/local/bin/entrypoint /usr/local/bin/entrypoint
|
COPY ./docker/files/usr/local/bin/entrypoint /usr/local/bin/entrypoint
|
||||||
|
|
||||||
|
|||||||
@@ -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}"
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -1,8 +0,0 @@
|
|||||||
{
|
|
||||||
"plugins": [
|
|
||||||
"office-addins"
|
|
||||||
],
|
|
||||||
"extends": [
|
|
||||||
"plugin:office-addins/recommended"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
Binary file not shown.
|
Before Width: | Height: | Size: 4.6 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 1.6 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 2.3 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 2.1 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 4.7 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 12 KiB |
@@ -1,12 +0,0 @@
|
|||||||
{
|
|
||||||
"presets": [
|
|
||||||
[
|
|
||||||
"@babel/preset-env",
|
|
||||||
{
|
|
||||||
"targets": {
|
|
||||||
"esmodules": false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
],
|
|
||||||
]
|
|
||||||
}
|
|
||||||
@@ -1,173 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
|
||||||
<OfficeApp xmlns="http://schemas.microsoft.com/office/appforoffice/1.1" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:bt="http://schemas.microsoft.com/office/officeappbasictypes/1.0" xmlns:mailappor="http://schemas.microsoft.com/office/mailappversionoverrides/1.0" xsi:type="MailApp">
|
|
||||||
<Id>a025f0f6-757a-4790-97f3-99c66c4a5795</Id>
|
|
||||||
<Version>0.0.1.0</Version>
|
|
||||||
<ProviderName>Visio</ProviderName>
|
|
||||||
<DefaultLocale>en-US</DefaultLocale>
|
|
||||||
<DisplayName DefaultValue="Visio"/>
|
|
||||||
<Description DefaultValue="Ajoutez facilement un lien de réunion Visio à vos emails et événements Outlook."/>
|
|
||||||
<IconUrl DefaultValue="https://localhost:3000/assets/icon-64.png"/>
|
|
||||||
<HighResolutionIconUrl DefaultValue="https://localhost:3000/assets/icon-128.png"/>
|
|
||||||
<SupportUrl DefaultValue="https://www.contoso.com/help"/>
|
|
||||||
<AppDomains>
|
|
||||||
<AppDomain>https://localhost:3000</AppDomain>
|
|
||||||
<AppDomain>https://meet.127.0.0.1.nip.io</AppDomain>
|
|
||||||
</AppDomains>
|
|
||||||
<Hosts>
|
|
||||||
<Host Name="Mailbox"/>
|
|
||||||
</Hosts>
|
|
||||||
<Requirements>
|
|
||||||
<Sets>
|
|
||||||
<Set Name="Mailbox" MinVersion="1.1"/>
|
|
||||||
</Sets>
|
|
||||||
</Requirements>
|
|
||||||
<FormSettings>
|
|
||||||
<Form xsi:type="ItemRead">
|
|
||||||
<DesktopSettings>
|
|
||||||
<SourceLocation DefaultValue="https://localhost:3000/taskpane.html"/>
|
|
||||||
<RequestedHeight>250</RequestedHeight>
|
|
||||||
</DesktopSettings>
|
|
||||||
</Form>
|
|
||||||
<Form xsi:type="ItemEdit">
|
|
||||||
<DesktopSettings>
|
|
||||||
<SourceLocation DefaultValue="https://localhost:3000/taskpane.html"/>
|
|
||||||
</DesktopSettings>
|
|
||||||
</Form>
|
|
||||||
</FormSettings>
|
|
||||||
<Permissions>ReadWriteItem</Permissions>
|
|
||||||
<Rule xsi:type="RuleCollection" Mode="Or">
|
|
||||||
<Rule xsi:type="ItemIs" ItemType="Message" FormType="Read"/>
|
|
||||||
<Rule xsi:type="ItemIs" ItemType="Message" FormType="Edit"/>
|
|
||||||
<Rule xsi:type="ItemIs" ItemType="Appointment" FormType="Edit"/>
|
|
||||||
</Rule>
|
|
||||||
<DisableEntityHighlighting>false</DisableEntityHighlighting>
|
|
||||||
<VersionOverrides xmlns="http://schemas.microsoft.com/office/mailappversionoverrides" xsi:type="VersionOverridesV1_0">
|
|
||||||
<Requirements>
|
|
||||||
<bt:Sets DefaultMinVersion="1.3">
|
|
||||||
<bt:Set Name="Mailbox"/>
|
|
||||||
</bt:Sets>
|
|
||||||
</Requirements>
|
|
||||||
<Hosts>
|
|
||||||
<Host xsi:type="MailHost">
|
|
||||||
<DesktopFormFactor>
|
|
||||||
<FunctionFile resid="Commands.Url"/>
|
|
||||||
|
|
||||||
<!-- ─── Mail: Read ─────────────────────────────────────────── -->
|
|
||||||
<ExtensionPoint xsi:type="MessageReadCommandSurface">
|
|
||||||
<OfficeTab id="TabDefault">
|
|
||||||
<Group id="msgReadGroup">
|
|
||||||
<Label resid="GroupLabel"/>
|
|
||||||
<Control xsi:type="Button" id="msgReadOpenPaneButton">
|
|
||||||
<Label resid="TaskpaneButton.Label"/>
|
|
||||||
<Supertip>
|
|
||||||
<Title resid="TaskpaneButton.Label"/>
|
|
||||||
<Description resid="TaskpaneButton.Tooltip"/>
|
|
||||||
</Supertip>
|
|
||||||
<Icon>
|
|
||||||
<bt:Image size="16" resid="Icon.16x16"/>
|
|
||||||
<bt:Image size="32" resid="Icon.32x32"/>
|
|
||||||
<bt:Image size="80" resid="Icon.80x80"/>
|
|
||||||
</Icon>
|
|
||||||
<Action xsi:type="ShowTaskpane">
|
|
||||||
<SourceLocation resid="Taskpane.Url"/>
|
|
||||||
</Action>
|
|
||||||
</Control>
|
|
||||||
</Group>
|
|
||||||
</OfficeTab>
|
|
||||||
</ExtensionPoint>
|
|
||||||
|
|
||||||
<!-- ─── Mail: Compose ─────────────────────────────────────── -->
|
|
||||||
<ExtensionPoint xsi:type="MessageComposeCommandSurface">
|
|
||||||
<OfficeTab id="TabDefault">
|
|
||||||
<Group id="msgComposeGroup">
|
|
||||||
<Label resid="GroupLabel"/>
|
|
||||||
<Control xsi:type="Button" id="msgComposeOpenPaneButton">
|
|
||||||
<Label resid="TaskpaneButton.Label"/>
|
|
||||||
<Supertip>
|
|
||||||
<Title resid="TaskpaneButton.Label"/>
|
|
||||||
<Description resid="TaskpaneButton.Tooltip"/>
|
|
||||||
</Supertip>
|
|
||||||
<Icon>
|
|
||||||
<bt:Image size="16" resid="Icon.16x16"/>
|
|
||||||
<bt:Image size="32" resid="Icon.32x32"/>
|
|
||||||
<bt:Image size="80" resid="Icon.80x80"/>
|
|
||||||
</Icon>
|
|
||||||
<Action xsi:type="ShowTaskpane">
|
|
||||||
<SourceLocation resid="Taskpane.Url"/>
|
|
||||||
</Action>
|
|
||||||
</Control>
|
|
||||||
</Group>
|
|
||||||
</OfficeTab>
|
|
||||||
</ExtensionPoint>
|
|
||||||
|
|
||||||
<!-- ─── Calendar: Compose (New/Edit appointment) ──────────── -->
|
|
||||||
<ExtensionPoint xsi:type="AppointmentOrganizerCommandSurface">
|
|
||||||
<OfficeTab id="TabDefault">
|
|
||||||
<Group id="apptComposeGroup">
|
|
||||||
<Label resid="GroupLabel"/>
|
|
||||||
|
|
||||||
<!-- Button 1: Generate meeting link (function call) -->
|
|
||||||
<Control xsi:type="Button" id="apptGenerateLinkButton">
|
|
||||||
<Label resid="GenerateLink.Label"/>
|
|
||||||
<Supertip>
|
|
||||||
<Title resid="GenerateLink.Label"/>
|
|
||||||
<Description resid="GenerateLink.Tooltip"/>
|
|
||||||
</Supertip>
|
|
||||||
<Icon>
|
|
||||||
<bt:Image size="16" resid="Icon.16x16"/>
|
|
||||||
<bt:Image size="32" resid="Icon.32x32"/>
|
|
||||||
<bt:Image size="80" resid="Icon.80x80"/>
|
|
||||||
</Icon>
|
|
||||||
<Action xsi:type="ExecuteFunction">
|
|
||||||
<FunctionName>generateMeetingLinkFromCalendar</FunctionName>
|
|
||||||
</Action>
|
|
||||||
</Control>
|
|
||||||
|
|
||||||
<!-- Button 2: Open settings taskpane -->
|
|
||||||
<Control xsi:type="Button" id="apptOpenSettingsButton">
|
|
||||||
<Label resid="OpenSettings.Label"/>
|
|
||||||
<Supertip>
|
|
||||||
<Title resid="OpenSettings.Label"/>
|
|
||||||
<Description resid="OpenSettings.Tooltip"/>
|
|
||||||
</Supertip>
|
|
||||||
<Icon>
|
|
||||||
<bt:Image size="16" resid="Icon.16x16"/>
|
|
||||||
<bt:Image size="32" resid="Icon.32x32"/>
|
|
||||||
<bt:Image size="80" resid="Icon.80x80"/>
|
|
||||||
</Icon>
|
|
||||||
<Action xsi:type="ShowTaskpane">
|
|
||||||
<SourceLocation resid="Taskpane.Url"/>
|
|
||||||
</Action>
|
|
||||||
</Control>
|
|
||||||
|
|
||||||
</Group>
|
|
||||||
</OfficeTab>
|
|
||||||
</ExtensionPoint>
|
|
||||||
|
|
||||||
</DesktopFormFactor>
|
|
||||||
</Host>
|
|
||||||
</Hosts>
|
|
||||||
<Resources>
|
|
||||||
<bt:Images>
|
|
||||||
<bt:Image id="Icon.16x16" DefaultValue="https://localhost:3000/assets/icon-16.png"/>
|
|
||||||
<bt:Image id="Icon.32x32" DefaultValue="https://localhost:3000/assets/icon-32.png"/>
|
|
||||||
<bt:Image id="Icon.80x80" DefaultValue="https://localhost:3000/assets/icon-80.png"/>
|
|
||||||
</bt:Images>
|
|
||||||
<bt:Urls>
|
|
||||||
<bt:Url id="Commands.Url" DefaultValue="https://localhost:3000/commands.html"/>
|
|
||||||
<bt:Url id="Taskpane.Url" DefaultValue="https://localhost:3000/taskpane.html"/>
|
|
||||||
</bt:Urls>
|
|
||||||
<bt:ShortStrings>
|
|
||||||
<bt:String id="GroupLabel" DefaultValue="Visio"/>
|
|
||||||
<bt:String id="TaskpaneButton.Label" DefaultValue="Ouvrir le panneau"/>
|
|
||||||
<bt:String id="GenerateLink.Label" DefaultValue="Générer un lien de réunion"/>
|
|
||||||
<bt:String id="OpenSettings.Label" DefaultValue="Paramètres"/>
|
|
||||||
</bt:ShortStrings>
|
|
||||||
<bt:LongStrings>
|
|
||||||
<bt:String id="TaskpaneButton.Tooltip" DefaultValue="Ouvre le panneau de connexion Visio."/>
|
|
||||||
<bt:String id="GenerateLink.Tooltip" DefaultValue="Génère un lien de réunion Visio et l'insère dans l'événement."/>
|
|
||||||
<bt:String id="OpenSettings.Tooltip" DefaultValue="Ouvre les paramètres de connexion Visio."/>
|
|
||||||
</bt:LongStrings>
|
|
||||||
</Resources>
|
|
||||||
</VersionOverrides>
|
|
||||||
</OfficeApp>
|
|
||||||
-16211
File diff suppressed because it is too large
Load Diff
@@ -1,63 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "office-addin-taskpane-js",
|
|
||||||
"version": "0.0.1",
|
|
||||||
"repository": {
|
|
||||||
"type": "git",
|
|
||||||
"url": "https://github.com/OfficeDev/Office-Addin-TaskPane-JS.git"
|
|
||||||
},
|
|
||||||
"license": "MIT",
|
|
||||||
"config": {
|
|
||||||
"app_to_debug": "outlook",
|
|
||||||
"app_type_to_debug": "desktop",
|
|
||||||
"dev_server_port": 3000
|
|
||||||
},
|
|
||||||
"scripts": {
|
|
||||||
"build": "webpack --mode production",
|
|
||||||
"build:dev": "webpack --mode development",
|
|
||||||
"dev-server": "webpack serve --mode development",
|
|
||||||
"lint": "office-addin-lint check",
|
|
||||||
"lint:fix": "office-addin-lint fix",
|
|
||||||
"prettier": "office-addin-lint prettier",
|
|
||||||
"signin": "office-addin-dev-settings m365-account login",
|
|
||||||
"signout": "office-addin-dev-settings m365-account logout",
|
|
||||||
"start": "office-addin-debugging start manifest.xml",
|
|
||||||
"stop": "office-addin-debugging stop manifest.xml",
|
|
||||||
"validate": "office-addin-manifest validate manifest.xml",
|
|
||||||
"watch": "webpack --mode development --watch"
|
|
||||||
},
|
|
||||||
"dependencies": {
|
|
||||||
"core-js": "^3.36.0",
|
|
||||||
"regenerator-runtime": "^0.14.1"
|
|
||||||
},
|
|
||||||
"devDependencies": {
|
|
||||||
"@babel/core": "^7.24.0",
|
|
||||||
"@babel/preset-env": "^7.25.4",
|
|
||||||
"@types/office-js": "^1.0.377",
|
|
||||||
"@types/office-runtime": "^1.0.35",
|
|
||||||
"acorn": "^8.11.3",
|
|
||||||
"babel-loader": "^9.1.3",
|
|
||||||
"copy-webpack-plugin": "^12.0.2",
|
|
||||||
"eslint-plugin-office-addins": "^4.0.3",
|
|
||||||
"file-loader": "^6.2.0",
|
|
||||||
"html-loader": "^5.0.0",
|
|
||||||
"html-webpack-inject-attributes-plugin": "^1.0.6",
|
|
||||||
"html-webpack-plugin": "^5.6.0",
|
|
||||||
"office-addin-cli": "^2.0.3",
|
|
||||||
"office-addin-debugging": "^6.0.3",
|
|
||||||
"office-addin-dev-certs": "^2.0.3",
|
|
||||||
"office-addin-lint": "^3.0.3",
|
|
||||||
"office-addin-manifest": "^2.0.3",
|
|
||||||
"office-addin-prettier-config": "^2.0.1",
|
|
||||||
"os-browserify": "^0.3.0",
|
|
||||||
"process": "^0.11.10",
|
|
||||||
"source-map-loader": "^5.0.0",
|
|
||||||
"webpack": "^5.95.0",
|
|
||||||
"webpack-cli": "^5.1.4",
|
|
||||||
"webpack-dev-server": "5.1.0"
|
|
||||||
},
|
|
||||||
"prettier": "office-addin-prettier-config",
|
|
||||||
"browserslist": [
|
|
||||||
"last 2 versions",
|
|
||||||
"ie 11"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html>
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8" />
|
|
||||||
<meta http-equiv="X-UA-Compatible" content="IE=Edge" />
|
|
||||||
<script nonce="NONCE_PLACEHOLDER" src="https://appsforoffice.microsoft.com/lib/1/hosted/office.js"></script>
|
|
||||||
</head>
|
|
||||||
<body></body>
|
|
||||||
</html>
|
|
||||||
@@ -1,80 +0,0 @@
|
|||||||
/* global Office */
|
|
||||||
const { loadSession, buildMeetingMessage, BASE_URL } = require("../common");
|
|
||||||
|
|
||||||
Office.onReady(() => {});
|
|
||||||
|
|
||||||
function generateMeetingLinkFromCalendar(event) {
|
|
||||||
const session = loadSession();
|
|
||||||
|
|
||||||
if (!session?.access_token) {
|
|
||||||
Office.context.mailbox.item.notificationMessages.replaceAsync("meetNotif", {
|
|
||||||
type: Office.MailboxEnums.ItemNotificationMessageType.ErrorMessage,
|
|
||||||
message: "Vous n'êtes pas connecté. Ouvrez les paramètres pour vous connecter.",
|
|
||||||
});
|
|
||||||
event.completed();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
fetch(`${BASE_URL}/external-api/v1.0/rooms/`, {
|
|
||||||
method: "POST",
|
|
||||||
headers: {
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
"Authorization": "Bearer " + session.access_token,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
.then((res) => res.json())
|
|
||||||
.then((data) => {
|
|
||||||
console.log("Room created:", data);
|
|
||||||
|
|
||||||
const { url, message } = buildMeetingMessage(data);
|
|
||||||
const item = Office.context.mailbox.item;
|
|
||||||
|
|
||||||
item.body.getAsync(Office.CoercionType.Html, (getResult) => {
|
|
||||||
if (getResult.status !== Office.AsyncResultStatus.Succeeded) {
|
|
||||||
item.notificationMessages.replaceAsync("meetNotif", {
|
|
||||||
type: Office.MailboxEnums.ItemNotificationMessageType.ErrorMessage,
|
|
||||||
message: `Erreur de lecture: ${getResult.error.message}`,
|
|
||||||
});
|
|
||||||
event.completed();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
item.body.setAsync(getResult.value + message, { coercionType: Office.CoercionType.Html }, (setResult) => {
|
|
||||||
if (setResult.status !== Office.AsyncResultStatus.Succeeded) {
|
|
||||||
item.notificationMessages.replaceAsync("meetNotif", {
|
|
||||||
type: Office.MailboxEnums.ItemNotificationMessageType.ErrorMessage,
|
|
||||||
message: `Erreur d'insertion: ${setResult.error.message}`,
|
|
||||||
});
|
|
||||||
event.completed();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
item.location.setAsync(url, (locationResult) => {
|
|
||||||
if (locationResult.status === Office.AsyncResultStatus.Succeeded) {
|
|
||||||
item.notificationMessages.replaceAsync("meetNotif", {
|
|
||||||
type: Office.MailboxEnums.ItemNotificationMessageType.InformationalMessage,
|
|
||||||
message: "Lien de réunion inséré !",
|
|
||||||
icon: "Icon.80x80",
|
|
||||||
persistent: false,
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
item.notificationMessages.replaceAsync("meetNotif", {
|
|
||||||
type: Office.MailboxEnums.ItemNotificationMessageType.ErrorMessage,
|
|
||||||
message: `Erreur de localisation: ${locationResult.error.message}`,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
event.completed();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
})
|
|
||||||
.catch((err) => {
|
|
||||||
Office.context.mailbox.item.notificationMessages.replaceAsync("meetNotif", {
|
|
||||||
type: Office.MailboxEnums.ItemNotificationMessageType.ErrorMessage,
|
|
||||||
message: `Erreur: ${err.message}`,
|
|
||||||
});
|
|
||||||
event.completed();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
Office.actions.associate("generateMeetingLinkFromCalendar", generateMeetingLinkFromCalendar);
|
|
||||||
@@ -1,99 +0,0 @@
|
|||||||
/* global Office */
|
|
||||||
|
|
||||||
const BASE_URL = "https://meet.127.0.0.1.nip.io"; // todo - use env variable
|
|
||||||
|
|
||||||
// ─── Session Storage ──────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
function saveSession(data) {
|
|
||||||
const expiresAt = data.expires_in
|
|
||||||
? new Date(Date.now() + data.expires_in * 1000).toISOString()
|
|
||||||
: null;
|
|
||||||
|
|
||||||
const payload = JSON.stringify({
|
|
||||||
...data,
|
|
||||||
expiresAt,
|
|
||||||
savedAt: new Date().toISOString(),
|
|
||||||
});
|
|
||||||
|
|
||||||
localStorage.setItem("meetSession", payload);
|
|
||||||
|
|
||||||
const rs = Office.context.roamingSettings;
|
|
||||||
rs.set("meetSession", payload);
|
|
||||||
rs.saveAsync((result) => {
|
|
||||||
if (result.status !== Office.AsyncResultStatus.Succeeded) {
|
|
||||||
console.error("RoamingSettings save failed:", result.error.message);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function loadSession() {
|
|
||||||
let session = null;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const stored = Office.context.roamingSettings.get("meetSession");
|
|
||||||
if (stored) session = JSON.parse(stored);
|
|
||||||
} catch (e) {
|
|
||||||
console.warn("RoamingSettings read failed:", e);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!session) {
|
|
||||||
try {
|
|
||||||
const stored = localStorage.getItem("meetSession");
|
|
||||||
if (stored) session = JSON.parse(stored);
|
|
||||||
} catch (e) {
|
|
||||||
console.warn("localStorage read failed:", e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!session) return null;
|
|
||||||
|
|
||||||
if (session.expiresAt && new Date() > new Date(session.expiresAt)) {
|
|
||||||
console.warn("Token expired, clearing session.");
|
|
||||||
clearSession();
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return session;
|
|
||||||
}
|
|
||||||
|
|
||||||
function clearSession() {
|
|
||||||
localStorage.removeItem("meetSession");
|
|
||||||
try {
|
|
||||||
const rs = Office.context.roamingSettings;
|
|
||||||
rs.remove("meetSession");
|
|
||||||
rs.saveAsync(() => console.log("RoamingSettings cleared."));
|
|
||||||
} catch (e) {
|
|
||||||
console.warn("Could not clear RoamingSettings:", e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Meeting Message Builder ───────────────────────────────────────────────
|
|
||||||
|
|
||||||
function buildMeetingMessage(data) {
|
|
||||||
const url = data.url;
|
|
||||||
const phone = data.telephony?.phone_number;
|
|
||||||
const pin = data.telephony?.pin_code;
|
|
||||||
|
|
||||||
const formattedPin = pin
|
|
||||||
? pin.replace(/(\d{3})(\d{3})(\d{4})/, "$1 $2 $3") + "#"
|
|
||||||
: "";
|
|
||||||
|
|
||||||
const formattedPhone = phone
|
|
||||||
? phone.replace(/^\+33(\d)(\d{2})(\d{2})(\d{2})(\d{2})$/, "+33 $1 $2 $3 $4 $5")
|
|
||||||
: phone;
|
|
||||||
|
|
||||||
const message = `<pre style="font-family:inherit; font-size:inherit; border:none; background:none; margin:16px 0;">
|
|
||||||
────────────────────────────────────────
|
|
||||||
Rejoindre la réunion LaSuite Meet
|
|
||||||
|
|
||||||
<a href="${url}">${url}</a>
|
|
||||||
|
|
||||||
Ou appelez (audio uniquement)
|
|
||||||
(FR) ${formattedPhone}
|
|
||||||
Code : ${formattedPin}
|
|
||||||
────────────────────────────────────────</pre>`;
|
|
||||||
|
|
||||||
return { url, message };
|
|
||||||
}
|
|
||||||
|
|
||||||
module.exports = { BASE_URL, saveSession, loadSession, clearSession, buildMeetingMessage };
|
|
||||||
File diff suppressed because one or more lines are too long
@@ -1,58 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html>
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8" />
|
|
||||||
<meta http-equiv="X-UA-Compatible" content="IE=Edge" />
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
||||||
<title>Visio</title>
|
|
||||||
<link rel="stylesheet" href="taskpane.css" />
|
|
||||||
<script nonce="NONCE_PLACEHOLDER" src="https://appsforoffice.microsoft.com/lib/1/hosted/office.js"></script>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<div id="sideload-msg">Veuillez charger le complément.</div>
|
|
||||||
|
|
||||||
<div id="app-body">
|
|
||||||
|
|
||||||
<!-- Loading -->
|
|
||||||
<div id="view-loading">
|
|
||||||
<p class="intro-text">Chargement...</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Unauthenticated -->
|
|
||||||
<div id="view-unauth" style="display:none;">
|
|
||||||
<p class="intro-text">
|
|
||||||
<span>Ajoutez facilement un lien de réunion Visio à vos événements Outlook.</span>
|
|
||||||
<a href="https://meet.numerique.gouv.fr" target="_blank" class="learn-more">En savoir plus</a>
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<hr class="divider" />
|
|
||||||
|
|
||||||
<button class="proconnect-button" id="btn-connect">
|
|
||||||
<span class="proconnect-sr-only">S'identifier avec ProConnect</span>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<p>
|
|
||||||
<a
|
|
||||||
href="https://www.proconnect.gouv.fr/"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
title="Qu’est-ce que ProConnect ? - nouvelle fenêtre"
|
|
||||||
>
|
|
||||||
Qu’est-ce que ProConnect ?
|
|
||||||
</a>
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Authenticated -->
|
|
||||||
<div id="view-auth" style="display:none;">
|
|
||||||
<div id="btn-container">
|
|
||||||
<button id="btn-generate">Ajouter une réunion Visio</button>
|
|
||||||
<button id="btn-disconnect">Se déconnecter</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<p id="status"></p>
|
|
||||||
|
|
||||||
</div>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
@@ -1,223 +0,0 @@
|
|||||||
|
|
||||||
const { BASE_URL, loadSession, saveSession, clearSession, buildMeetingMessage } = require("../common");
|
|
||||||
|
|
||||||
// ─── Views ────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
function showView(name) {
|
|
||||||
document.getElementById("view-loading").style.display = "none";
|
|
||||||
document.getElementById("view-unauth").style.display = "none";
|
|
||||||
document.getElementById("view-auth").style.display = "none";
|
|
||||||
document.getElementById(`view-${name}`).style.display = "block";
|
|
||||||
}
|
|
||||||
|
|
||||||
function setStatus(msg) {
|
|
||||||
document.getElementById("status").textContent = msg;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Polling ──────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
function startPolling(session_id, { onSuccess, onTimeout, onError }) {
|
|
||||||
let pollCount = 0;
|
|
||||||
const pollInterval = setInterval(() => {
|
|
||||||
// ─── Timeout after 3 minutes ──────────────────────────────
|
|
||||||
if (pollCount++ > 180) {
|
|
||||||
clearInterval(pollInterval);
|
|
||||||
onTimeout?.();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
fetch(`${BASE_URL}/api/v1.0/addons/sessions/wip/`, {
|
|
||||||
method: "POST",
|
|
||||||
credentials: "include",
|
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
body: JSON.stringify({ session_id }),
|
|
||||||
})
|
|
||||||
.then((res) => res.json())
|
|
||||||
.then((sessionData) => {
|
|
||||||
console.log("Polling:", sessionData);
|
|
||||||
if (sessionData.state === "authenticated" && sessionData.access_token) {
|
|
||||||
clearInterval(pollInterval);
|
|
||||||
onSuccess?.(sessionData);
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.catch((err) => {
|
|
||||||
clearInterval(pollInterval);
|
|
||||||
onError?.(err);
|
|
||||||
});
|
|
||||||
}, 1000);
|
|
||||||
|
|
||||||
return pollInterval;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Transit Dialog ───────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
function openTransitDialog(transit_token, { onCancel, onError }) {
|
|
||||||
const meetUrl = `${BASE_URL}/addons/transit/?transit_token=${transit_token}`;
|
|
||||||
|
|
||||||
Office.context.ui.displayDialogAsync(
|
|
||||||
meetUrl,
|
|
||||||
{ height: 60, width: 50, displayInIframe: false },
|
|
||||||
(asyncResult) => {
|
|
||||||
if (asyncResult.status === Office.AsyncResultStatus.Failed) {
|
|
||||||
onError?.(asyncResult.error);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const dialog = asyncResult.value;
|
|
||||||
|
|
||||||
dialog.addEventHandler(Office.EventType.DialogMessageReceived, () => {
|
|
||||||
onCancel?.();
|
|
||||||
dialog.close();
|
|
||||||
});
|
|
||||||
|
|
||||||
dialog.addEventHandler(Office.EventType.DialogEventReceived, (arg) => {
|
|
||||||
if (arg.error === 12006) {
|
|
||||||
setStatus("Dialog fermé. En attente d'authentification...");
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
return dialog;
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Auth Flow ────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
function connect() {
|
|
||||||
setStatus("Démarrage de la session...");
|
|
||||||
|
|
||||||
fetch(`${BASE_URL}/api/v1.0/addons/sessions/`, {
|
|
||||||
method: "POST",
|
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
})
|
|
||||||
.then((res) => res.json())
|
|
||||||
.then((data) => {
|
|
||||||
const session_id = data.session_id;
|
|
||||||
const transit_token = data.transit_token;
|
|
||||||
setStatus("En attente d'authentification...");
|
|
||||||
|
|
||||||
const pollInterval = startPolling(session_id, {
|
|
||||||
onSuccess: (sessionData) => {
|
|
||||||
saveSession(sessionData);
|
|
||||||
setStatus("Connecté !");
|
|
||||||
showView("auth");
|
|
||||||
},
|
|
||||||
onTimeout: () => {
|
|
||||||
setStatus("Délai d'authentification dépassé. Veuillez réessayer.");
|
|
||||||
showView("unauth");
|
|
||||||
},
|
|
||||||
onError: (err) => {
|
|
||||||
setStatus(`Erreur de polling: ${err.message}`);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
openTransitDialog(transit_token, {
|
|
||||||
onCancel: () => clearInterval(pollInterval),
|
|
||||||
onError: (err) => {
|
|
||||||
clearInterval(pollInterval);
|
|
||||||
setStatus(`Erreur dialog: ${err.message}`);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
})
|
|
||||||
.catch((err) => {
|
|
||||||
setStatus(`Erreur de connexion: ${err.message}`);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function disconnect() {
|
|
||||||
clearSession();
|
|
||||||
setStatus("Déconnecté.");
|
|
||||||
showView("unauth");
|
|
||||||
}
|
|
||||||
|
|
||||||
function generateMeetingLink() {
|
|
||||||
const session = loadSession();
|
|
||||||
if (!session?.access_token) {
|
|
||||||
setStatus("Session introuvable. Veuillez vous reconnecter.");
|
|
||||||
showView("unauth");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const btn = document.getElementById("btn-generate");
|
|
||||||
btn.disabled = true;
|
|
||||||
btn.textContent = "Génération...";
|
|
||||||
|
|
||||||
fetch(`${BASE_URL}/external-api/v1.0/rooms/`, {
|
|
||||||
method: "POST",
|
|
||||||
headers: {
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
"Authorization": "Bearer " + session.access_token,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
.then((res) => res.json())
|
|
||||||
.then((data) => {
|
|
||||||
console.log("Room created:", data);
|
|
||||||
|
|
||||||
const { url, message } = buildMeetingMessage(data);
|
|
||||||
const item = Office.context.mailbox.item;
|
|
||||||
|
|
||||||
item.body.getAsync(Office.CoercionType.Html, (getResult) => {
|
|
||||||
if (getResult.status !== Office.AsyncResultStatus.Succeeded) {
|
|
||||||
setStatus(`Erreur de lecture: ${getResult.error.message}`);
|
|
||||||
btn.disabled = false;
|
|
||||||
btn.textContent = "Ajouter une réunion Visio";
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
item.body.setAsync(
|
|
||||||
getResult.value + message,
|
|
||||||
{ coercionType: Office.CoercionType.Html },
|
|
||||||
(setResult) => {
|
|
||||||
if (setResult.status !== Office.AsyncResultStatus.Succeeded) {
|
|
||||||
setStatus(`Erreur d'insertion: ${setResult.error.message}`);
|
|
||||||
btn.disabled = false;
|
|
||||||
btn.textContent = "Ajouter une réunion Visio";
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── If calendar event, also set location ──────────────
|
|
||||||
if (item.itemType === Office.MailboxEnums.ItemType.Appointment) {
|
|
||||||
item.location.setAsync(url, (locationResult) => {
|
|
||||||
btn.disabled = false;
|
|
||||||
btn.textContent = "Ajouter une réunion Visio";
|
|
||||||
if (locationResult.status === Office.AsyncResultStatus.Succeeded) {
|
|
||||||
setStatus("Lien de réunion inséré !");
|
|
||||||
} else {
|
|
||||||
setStatus(`Erreur de localisation: ${locationResult.error.message}`);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
btn.disabled = false;
|
|
||||||
btn.textContent = "Ajouter une réunion Visio";
|
|
||||||
setStatus("Lien de réunion inséré !");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
);
|
|
||||||
});
|
|
||||||
})
|
|
||||||
.catch((err) => {
|
|
||||||
btn.disabled = false;
|
|
||||||
btn.textContent = "Ajouter une réunion Visio";
|
|
||||||
setStatus(`Erreur: ${err.message}`);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Init ─────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
Office.onReady((info) => {
|
|
||||||
if (info.host === Office.HostType.Outlook) {
|
|
||||||
document.getElementById("sideload-msg").style.display = "none";
|
|
||||||
document.getElementById("app-body").style.display = "flex";
|
|
||||||
|
|
||||||
document.getElementById("btn-connect").onclick = connect;
|
|
||||||
document.getElementById("btn-disconnect").onclick = disconnect;
|
|
||||||
document.getElementById("btn-generate").onclick = generateMeetingLink;
|
|
||||||
|
|
||||||
const session = loadSession();
|
|
||||||
if (session?.state === "authenticated" && session?.access_token) {
|
|
||||||
setStatus("Connecté.");
|
|
||||||
showView("auth");
|
|
||||||
} else {
|
|
||||||
showView("unauth");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
@@ -1,110 +0,0 @@
|
|||||||
/* eslint-disable no-undef */
|
|
||||||
|
|
||||||
const devCerts = require("office-addin-dev-certs");
|
|
||||||
const CopyWebpackPlugin = require("copy-webpack-plugin");
|
|
||||||
const HtmlWebpackPlugin = require("html-webpack-plugin");
|
|
||||||
const htmlWebpackInjectAttributesPlugin = require("html-webpack-inject-attributes-plugin");
|
|
||||||
|
|
||||||
const urlDev = "https://localhost:3000/";
|
|
||||||
const urlProd = "https://meet.127.0.0.1.nip.io/outlook-addin/";
|
|
||||||
|
|
||||||
async function getHttpsOptions() {
|
|
||||||
const httpsOptions = await devCerts.getHttpsServerOptions();
|
|
||||||
return { ca: httpsOptions.ca, key: httpsOptions.key, cert: httpsOptions.cert };
|
|
||||||
}
|
|
||||||
|
|
||||||
module.exports = async (env, options) => {
|
|
||||||
const dev = options.mode === "development";
|
|
||||||
const config = {
|
|
||||||
devtool: "source-map",
|
|
||||||
entry: {
|
|
||||||
polyfill: ["core-js/stable", "regenerator-runtime/runtime"],
|
|
||||||
taskpane: ["./src/taskpane/taskpane.js", "./src/taskpane/taskpane.html"],
|
|
||||||
commands: "./src/commands/commands.js",
|
|
||||||
},
|
|
||||||
output: {
|
|
||||||
clean: true,
|
|
||||||
},
|
|
||||||
resolve: {
|
|
||||||
extensions: [".html", ".js"],
|
|
||||||
},
|
|
||||||
module: {
|
|
||||||
rules: [
|
|
||||||
{
|
|
||||||
test: /\.js$/,
|
|
||||||
exclude: /node_modules/,
|
|
||||||
use: {
|
|
||||||
loader: "babel-loader",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
test: /\.html$/,
|
|
||||||
exclude: /node_modules/,
|
|
||||||
use: "html-loader",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
test: /\.(png|jpg|jpeg|gif|ico)$/,
|
|
||||||
type: "asset/resource",
|
|
||||||
generator: {
|
|
||||||
filename: "assets/[name][ext][query]",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
plugins: [
|
|
||||||
new HtmlWebpackPlugin({
|
|
||||||
filename: "taskpane.html",
|
|
||||||
template: "./src/taskpane/taskpane.html",
|
|
||||||
chunks: ["polyfill", "taskpane"],
|
|
||||||
scriptLoading: "defer",
|
|
||||||
attributes: {
|
|
||||||
nonce: "NONCE_PLACEHOLDER",
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
new CopyWebpackPlugin({
|
|
||||||
patterns: [
|
|
||||||
{
|
|
||||||
from: "assets/*",
|
|
||||||
to: "assets/[name][ext][query]",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
from: "manifest*.xml",
|
|
||||||
to: "[name]" + "[ext]",
|
|
||||||
transform(content) {
|
|
||||||
if (dev) {
|
|
||||||
return content;
|
|
||||||
} else {
|
|
||||||
return content.toString().replace(new RegExp(urlDev, "g"), urlProd);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
}),
|
|
||||||
new HtmlWebpackPlugin({
|
|
||||||
filename: "commands.html",
|
|
||||||
template: "./src/commands/commands.html",
|
|
||||||
chunks: ["polyfill", "commands"],
|
|
||||||
scriptLoading: "defer",
|
|
||||||
attributes: {
|
|
||||||
nonce: "NONCE_PLACEHOLDER",
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
new htmlWebpackInjectAttributesPlugin(),
|
|
||||||
],
|
|
||||||
devServer: {
|
|
||||||
headers: {
|
|
||||||
"Access-Control-Allow-Origin": "*",
|
|
||||||
},
|
|
||||||
server: {
|
|
||||||
type: "https",
|
|
||||||
options:
|
|
||||||
env.WEBPACK_BUILD || options.https !== undefined
|
|
||||||
? options.https
|
|
||||||
: await getHttpsOptions(),
|
|
||||||
},
|
|
||||||
port: process.env.npm_package_config_dev_server_port || 3000,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
return config;
|
|
||||||
};
|
|
||||||
@@ -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,8 +193,7 @@ 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()
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
@@ -1,15 +1,14 @@
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "agents"
|
name = "agents"
|
||||||
version = "1.8.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]
|
||||||
|
|||||||
@@ -1 +0,0 @@
|
|||||||
"""Meet core add-ons module."""
|
|
||||||
@@ -1,192 +0,0 @@
|
|||||||
"""Authentication session management for add-ons using temporary cache-based sessions."""
|
|
||||||
|
|
||||||
import secrets
|
|
||||||
from datetime import datetime, timedelta, timezone
|
|
||||||
from enum import Enum
|
|
||||||
from logging import getLogger
|
|
||||||
|
|
||||||
from django.conf import settings
|
|
||||||
from django.core.cache import cache
|
|
||||||
from django.core.exceptions import SuspiciousOperation
|
|
||||||
|
|
||||||
from core.models import User
|
|
||||||
from core.services.jwt_token import JwtTokenService
|
|
||||||
|
|
||||||
logger = getLogger(__name__)
|
|
||||||
|
|
||||||
|
|
||||||
class SessionOwnershipError(Exception):
|
|
||||||
"""Raised when the claimed session_id does not match the result_token binding."""
|
|
||||||
|
|
||||||
class SessionState(str, Enum):
|
|
||||||
"""Add-on authentication session states."""
|
|
||||||
|
|
||||||
PENDING = "pending"
|
|
||||||
AUTHENTICATED = "authenticated"
|
|
||||||
|
|
||||||
|
|
||||||
class TokenExchangeService:
|
|
||||||
"""Manage temporary authentication sessions for add-on JWT token exchange."""
|
|
||||||
|
|
||||||
def __init__(self):
|
|
||||||
"""Initialize the service with the configured token service."""
|
|
||||||
|
|
||||||
self._token_service = JwtTokenService(
|
|
||||||
secret_key=settings.ADDONS_JWT_SECRET_KEY,
|
|
||||||
algorithm=settings.ADDONS_JWT_ALG,
|
|
||||||
issuer=settings.ADDONS_JWT_ISSUER,
|
|
||||||
audience=settings.ADDONS_JWT_AUDIENCE, # todo - precise
|
|
||||||
expiration_seconds=settings.ADDONS_JWT_EXPIRATION_SECONDS,
|
|
||||||
token_type=settings.ADDONS_JWT_TOKEN_TYPE,
|
|
||||||
)
|
|
||||||
|
|
||||||
def _session_cache_key(self, session_id: str) -> str:
|
|
||||||
"""Generate cache key for a session ID."""
|
|
||||||
return f"{settings.ADDONS_SESSION_KEY_PREFIX}_{session_id}"
|
|
||||||
|
|
||||||
def _token_cache_key(self, result_token: str) -> str:
|
|
||||||
"""Wip."""
|
|
||||||
return f"{settings.ADDONS_SESSION_TOKEN_PREFIX}_{result_token}"
|
|
||||||
|
|
||||||
def init_session(self) -> tuple[str, str, str]:
|
|
||||||
"""Create a new pending authentication session and return its ID."""
|
|
||||||
|
|
||||||
session_id = secrets.token_urlsafe(settings.ADDONS_SESSION_ID_LENGTH)
|
|
||||||
result_token = secrets.token_urlsafe(32) # separate, never in any UR
|
|
||||||
|
|
||||||
expires_at = datetime.now(timezone.utc) + timedelta(
|
|
||||||
seconds=settings.ADDONS_SESSION_TIMEOUT
|
|
||||||
)
|
|
||||||
|
|
||||||
session_data = {
|
|
||||||
"state": SessionState.PENDING,
|
|
||||||
"expires_at": expires_at.isoformat(),
|
|
||||||
}
|
|
||||||
|
|
||||||
# Store the session itself
|
|
||||||
cache.set(
|
|
||||||
self._session_cache_key(session_id),
|
|
||||||
session_data,
|
|
||||||
timeout=settings.ADDONS_SESSION_TIMEOUT,
|
|
||||||
)
|
|
||||||
|
|
||||||
# Store the token → session_id binding (same TTL)
|
|
||||||
cache.set(
|
|
||||||
self._token_cache_key(result_token),
|
|
||||||
session_id,
|
|
||||||
timeout=settings.ADDONS_SESSION_TIMEOUT,
|
|
||||||
)
|
|
||||||
|
|
||||||
# Transit token → session_id, very short TTL, one-time use
|
|
||||||
transit_token = secrets.token_urlsafe(32)
|
|
||||||
cache.set(
|
|
||||||
f"addon_transit_{transit_token}",
|
|
||||||
session_id,
|
|
||||||
timeout=120
|
|
||||||
)
|
|
||||||
|
|
||||||
print('$$ init transit_token')
|
|
||||||
print(transit_token)
|
|
||||||
|
|
||||||
return session_id, result_token, transit_token
|
|
||||||
|
|
||||||
# todo - wip
|
|
||||||
def get_session(self, session_id: str) -> dict:
|
|
||||||
"""Retrieve session data and clear it if authenticated."""
|
|
||||||
|
|
||||||
return self._get_and_maybe_clear(session_id)
|
|
||||||
|
|
||||||
def get_session_by_token(self, result_token: str, claimed_session_id: str) -> dict:
|
|
||||||
"""Resolve result_token → session_id → session data.
|
|
||||||
|
|
||||||
Verifies that the claimed_session_id matches the token binding,
|
|
||||||
proving the caller initiated this session (ownership check).
|
|
||||||
Clears the session once authenticated (one-time read).
|
|
||||||
"""
|
|
||||||
session_id = cache.get(self._token_cache_key(result_token))
|
|
||||||
if not session_id:
|
|
||||||
return {}
|
|
||||||
|
|
||||||
print("$$$ session_id")
|
|
||||||
print(session_id)
|
|
||||||
|
|
||||||
print("$$$ claimed_session_id")
|
|
||||||
print(claimed_session_id)
|
|
||||||
|
|
||||||
if not secrets.compare_digest(session_id, claimed_session_id):
|
|
||||||
raise SessionOwnershipError("Session ID does not match token binding.")
|
|
||||||
|
|
||||||
return self._get_and_maybe_clear(session_id)
|
|
||||||
|
|
||||||
def _get_and_maybe_clear(self, session_id: str) -> dict:
|
|
||||||
"""Wip."""
|
|
||||||
|
|
||||||
cache_key = self._session_cache_key(session_id)
|
|
||||||
data = cache.get(cache_key)
|
|
||||||
|
|
||||||
if not data:
|
|
||||||
return {}
|
|
||||||
|
|
||||||
if data.get("state") == SessionState.AUTHENTICATED:
|
|
||||||
# One-time read: clear both the session and the token binding
|
|
||||||
self.clear_session(session_id)
|
|
||||||
|
|
||||||
# Return copy without internal fields
|
|
||||||
internal_fields = {"expires_at"}
|
|
||||||
return {k: v for k, v in data.items() if k not in internal_fields}
|
|
||||||
|
|
||||||
def clear_session(self, session_id: str, result_token: str | None = None) -> None:
|
|
||||||
"""Wip."""
|
|
||||||
cache.delete(self._session_cache_key(session_id))
|
|
||||||
if result_token:
|
|
||||||
cache.delete(self._token_cache_key(result_token))
|
|
||||||
|
|
||||||
def set_access_token(self, user: User, session_id: str):
|
|
||||||
"""Generate and store access token for an authenticated user session."""
|
|
||||||
|
|
||||||
cache_key = self._session_cache_key(session_id)
|
|
||||||
existing_data = cache.get(cache_key)
|
|
||||||
|
|
||||||
if not existing_data:
|
|
||||||
raise SuspiciousOperation("Session not found.")
|
|
||||||
|
|
||||||
expires_at = existing_data.get("expires_at", None)
|
|
||||||
|
|
||||||
if not expires_at:
|
|
||||||
self.clear_session(session_id)
|
|
||||||
raise SuspiciousOperation("Invalid session data.")
|
|
||||||
|
|
||||||
remaining_seconds = int(
|
|
||||||
(
|
|
||||||
datetime.fromisoformat(expires_at) - datetime.now(timezone.utc)
|
|
||||||
).total_seconds()
|
|
||||||
)
|
|
||||||
|
|
||||||
if remaining_seconds <= 0:
|
|
||||||
self.clear_session(session_id)
|
|
||||||
raise SuspiciousOperation("Session expired.")
|
|
||||||
|
|
||||||
if existing_data.get("state") != SessionState.PENDING:
|
|
||||||
self.clear_session(session_id)
|
|
||||||
raise SuspiciousOperation("Access token already set.")
|
|
||||||
|
|
||||||
response = self._token_service.generate_jwt(user, settings.ADDONS_SCOPES)
|
|
||||||
new_data = {
|
|
||||||
**existing_data,
|
|
||||||
**response,
|
|
||||||
"state": SessionState.AUTHENTICATED,
|
|
||||||
}
|
|
||||||
|
|
||||||
cache.set(cache_key, new_data, timeout=remaining_seconds)
|
|
||||||
|
|
||||||
def token_to_session(self, result_token):
|
|
||||||
"""wip."""
|
|
||||||
return None
|
|
||||||
|
|
||||||
def consume_transit_token(self, transit_token: str) -> str | None:
|
|
||||||
"""Resolve and immediately delete the transit token (one-time use)."""
|
|
||||||
key = f"addon_transit_{transit_token}"
|
|
||||||
session_id = cache.get(key)
|
|
||||||
if session_id:
|
|
||||||
cache.delete(key) # consumed — cannot be replayed
|
|
||||||
return session_id
|
|
||||||
@@ -1,62 +0,0 @@
|
|||||||
"""Add-ons views."""
|
|
||||||
|
|
||||||
from django.conf import settings
|
|
||||||
from django.core.exceptions import SuspiciousOperation
|
|
||||||
from django.shortcuts import redirect, render
|
|
||||||
from django.utils.translation import gettext_lazy as _
|
|
||||||
from django.views.decorators.http import require_http_methods
|
|
||||||
|
|
||||||
from core.addons.service import SessionState, TokenExchangeService
|
|
||||||
|
|
||||||
|
|
||||||
def render_error(request, message, status=400):
|
|
||||||
"""Render simple error page."""
|
|
||||||
return render(request, "addons/error.html", {"message": message}, status=status)
|
|
||||||
|
|
||||||
|
|
||||||
@require_http_methods(["GET"])
|
|
||||||
def transit_page(request):
|
|
||||||
"""Initialize authentication flow for add-on session."""
|
|
||||||
|
|
||||||
transit_token = request.GET.get("transit_token")
|
|
||||||
|
|
||||||
if not transit_token:
|
|
||||||
return render_error(request, _("Transit token is required."), status=400)
|
|
||||||
|
|
||||||
session_id = TokenExchangeService().consume_transit_token(transit_token)
|
|
||||||
|
|
||||||
if not session_id:
|
|
||||||
return render_error(request, _("Invalid or expired transit token."), status=404)
|
|
||||||
|
|
||||||
# Validate the session is still pending
|
|
||||||
data = TokenExchangeService().get_session(session_id)
|
|
||||||
if not data:
|
|
||||||
return render_error(request, _("Session not found or expired."), status=404)
|
|
||||||
|
|
||||||
if data.get("state") != SessionState.PENDING:
|
|
||||||
return render_error(request, _("Invalid session state."), status=400)
|
|
||||||
|
|
||||||
request.session[settings.ADDONS_SESSION_KEY_AUTH] = session_id
|
|
||||||
|
|
||||||
return_to = f"{settings.APPLICATION_BASE_URL}/addons/redirect"
|
|
||||||
return redirect(f"/api/{settings.API_VERSION}/authenticate/?returnTo={return_to}")
|
|
||||||
|
|
||||||
|
|
||||||
@require_http_methods(["GET"])
|
|
||||||
def redirect_page(request):
|
|
||||||
"""Complete authentication and close the popup window."""
|
|
||||||
|
|
||||||
if not request.user.is_authenticated:
|
|
||||||
return render_error(request, _("Authentication required."), status=401)
|
|
||||||
|
|
||||||
session_id = request.session.pop(settings.ADDONS_SESSION_KEY_AUTH, None)
|
|
||||||
|
|
||||||
if not session_id:
|
|
||||||
return render_error(request, _("No active session found."), status=404)
|
|
||||||
|
|
||||||
try:
|
|
||||||
TokenExchangeService().set_access_token(request.user, session_id)
|
|
||||||
except SuspiciousOperation:
|
|
||||||
return render_error(request, _("Invalid or expired session."), status=400)
|
|
||||||
|
|
||||||
return render(request, "addons/redirect_success.html")
|
|
||||||
@@ -1,113 +0,0 @@
|
|||||||
"""Add-ons API endpoints"""
|
|
||||||
|
|
||||||
from logging import getLogger
|
|
||||||
|
|
||||||
from django.conf import settings
|
|
||||||
from django.core.exceptions import SuspiciousOperation
|
|
||||||
|
|
||||||
from rest_framework import (
|
|
||||||
response as drf_response,
|
|
||||||
)
|
|
||||||
from rest_framework import decorators
|
|
||||||
from rest_framework import status as drf_status
|
|
||||||
from rest_framework import viewsets
|
|
||||||
|
|
||||||
from core.addons.service import TokenExchangeService, SessionOwnershipError
|
|
||||||
|
|
||||||
logger = getLogger(__name__)
|
|
||||||
|
|
||||||
|
|
||||||
class AuthSessionViewSet(viewsets.ViewSet):
|
|
||||||
"""ViewSet for managing add-on authentication sessions via token exchange."""
|
|
||||||
|
|
||||||
authentication_classes = []
|
|
||||||
permission_classes = []
|
|
||||||
throttle_classes = []
|
|
||||||
|
|
||||||
def create(self, request):
|
|
||||||
"""Create a pending session.
|
|
||||||
|
|
||||||
Returns session_id in the body (client forwards it to the 3rd-party view).
|
|
||||||
Sets result_token as an HttpOnly cookie (the only poll credential).
|
|
||||||
"""
|
|
||||||
session_id, result_token, transit_token = TokenExchangeService().init_session()
|
|
||||||
response = drf_response.Response(
|
|
||||||
{"session_id": session_id, "transit_token": transit_token}, status=drf_status.HTTP_201_CREATED
|
|
||||||
)
|
|
||||||
response.set_cookie(
|
|
||||||
key=settings.ADDONS_RESULT_TOKEN_COOKIE_NAME,
|
|
||||||
value=result_token,
|
|
||||||
max_age=6000,
|
|
||||||
httponly=True,
|
|
||||||
secure=True,
|
|
||||||
samesite="None",
|
|
||||||
)
|
|
||||||
return response
|
|
||||||
|
|
||||||
@decorators.action(
|
|
||||||
detail=False,
|
|
||||||
methods=["post"],
|
|
||||||
url_name="wip",
|
|
||||||
url_path="wip",
|
|
||||||
permission_classes=[],
|
|
||||||
authentication_classes=[],
|
|
||||||
)
|
|
||||||
def long_poll(self, request):
|
|
||||||
"""Long-poll endpoint — only the cookie is accepted, never a session_id.
|
|
||||||
|
|
||||||
pk is intentionally ignored; the session is resolved from the cookie.
|
|
||||||
"""
|
|
||||||
|
|
||||||
result_token = request.COOKIES.get(settings.ADDONS_RESULT_TOKEN_COOKIE_NAME)
|
|
||||||
session_id = request.data.get("session_id")
|
|
||||||
|
|
||||||
if not result_token:
|
|
||||||
return drf_response.Response(
|
|
||||||
{"detail": "Missing result token."},
|
|
||||||
status=drf_status.HTTP_401_UNAUTHORIZED,
|
|
||||||
)
|
|
||||||
|
|
||||||
if not session_id:
|
|
||||||
return drf_response.Response(
|
|
||||||
{"detail": "Missing result session id."},
|
|
||||||
status=drf_status.HTTP_401_UNAUTHORIZED,
|
|
||||||
)
|
|
||||||
|
|
||||||
try:
|
|
||||||
data = TokenExchangeService().get_session_by_token(
|
|
||||||
result_token=result_token,
|
|
||||||
claimed_session_id=session_id,
|
|
||||||
)
|
|
||||||
except SessionOwnershipError as e:
|
|
||||||
raise SuspiciousOperation(str(e)) from e
|
|
||||||
|
|
||||||
if not data:
|
|
||||||
return drf_response.Response(
|
|
||||||
{"detail": "Session not found or expired."},
|
|
||||||
status=drf_status.HTTP_404_NOT_FOUND,
|
|
||||||
)
|
|
||||||
|
|
||||||
if data.get("state") == "pending":
|
|
||||||
return drf_response.Response(
|
|
||||||
{"state": "pending"},
|
|
||||||
status=drf_status.HTTP_202_ACCEPTED,
|
|
||||||
)
|
|
||||||
|
|
||||||
return drf_response.Response(data, status=drf_status.HTTP_200_OK)
|
|
||||||
|
|
||||||
def destroy(self, request, pk=None):
|
|
||||||
"""Explicit session teardown, resolves via cookie, not pk."""
|
|
||||||
|
|
||||||
result_token = request.COOKIES.get(settings.ADDONS_RESULT_TOKEN_COOKIE_NAME)
|
|
||||||
if not result_token:
|
|
||||||
return drf_response.Response(status=drf_status.HTTP_204_NO_CONTENT)
|
|
||||||
|
|
||||||
# We need the session_id to clear both keys — resolve it first
|
|
||||||
session_id = TokenExchangeService().token_to_session(result_token)
|
|
||||||
if session_id:
|
|
||||||
TokenExchangeService().clear_session(session_id, result_token)
|
|
||||||
|
|
||||||
response = drf_response.Response(status=drf_status.HTTP_204_NO_CONTENT)
|
|
||||||
response.delete_cookie(settings.ADDONS_RESULT_TOKEN_COOKIE_NAME)
|
|
||||||
|
|
||||||
return response
|
|
||||||
@@ -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):
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -191,6 +191,18 @@ class UserViewSet(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
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,
|
||||||
@@ -367,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"""
|
||||||
@@ -477,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.
|
||||||
|
|||||||
@@ -1,51 +1,25 @@
|
|||||||
"""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,79 +60,59 @@ 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,
|
||||||
|
settings.APPLICATION_JWT_SECRET_KEY,
|
||||||
|
algorithms=[settings.APPLICATION_JWT_ALG],
|
||||||
|
issuer=settings.APPLICATION_JWT_ISSUER,
|
||||||
|
audience=settings.APPLICATION_JWT_AUDIENCE,
|
||||||
|
)
|
||||||
|
except pyJwt.ExpiredSignatureError as e:
|
||||||
|
logger.warning("Token expired")
|
||||||
|
raise exceptions.AuthenticationFailed("Token expired.") from e
|
||||||
|
except pyJwt.InvalidIssuerError as e:
|
||||||
|
logger.warning("Invalid JWT issuer: %s", e)
|
||||||
|
raise exceptions.AuthenticationFailed("Invalid token.") from e
|
||||||
|
except pyJwt.InvalidAudienceError as e:
|
||||||
|
logger.warning("Invalid JWT audience: %s", e)
|
||||||
|
raise exceptions.AuthenticationFailed("Invalid token.") from e
|
||||||
|
except pyJwt.InvalidTokenError:
|
||||||
|
# Invalid JWT token - defer to next authentication backend
|
||||||
return None
|
return None
|
||||||
|
|
||||||
self.validate_payload(payload)
|
user_id = payload.get("user_id")
|
||||||
user = self.get_user(payload)
|
|
||||||
|
|
||||||
return (user, payload)
|
|
||||||
|
|
||||||
|
|
||||||
class ApplicationJWTAuthentication(BaseJWTAuthentication):
|
|
||||||
"""JWT authentication for application-delegated API access.
|
|
||||||
|
|
||||||
Validates JWT tokens issued to applications that are acting on behalf
|
|
||||||
of users. Tokens must include user_id, client_id, and delegation flag.
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(self):
|
|
||||||
"""Initialize authentication backend with application JWT settings from Django settings."""
|
|
||||||
super().__init__(
|
|
||||||
secret_key=settings.APPLICATION_JWT_SECRET_KEY,
|
|
||||||
algorithm=settings.APPLICATION_JWT_ALG,
|
|
||||||
issuer=settings.APPLICATION_JWT_ISSUER,
|
|
||||||
audience=settings.APPLICATION_JWT_AUDIENCE,
|
|
||||||
expiration_seconds=settings.APPLICATION_JWT_EXPIRATION_SECONDS,
|
|
||||||
token_type=settings.APPLICATION_JWT_TOKEN_TYPE,
|
|
||||||
)
|
|
||||||
|
|
||||||
def validate_payload(self, payload):
|
|
||||||
"""Validate application-specific claims."""
|
|
||||||
client_id = payload.get("client_id")
|
client_id = payload.get("client_id")
|
||||||
is_delegated = payload.get("delegated", False)
|
is_delegated = payload.get("delegated", False)
|
||||||
|
|
||||||
|
if not user_id:
|
||||||
|
logger.warning("Missing 'user_id' in JWT payload")
|
||||||
|
raise exceptions.AuthenticationFailed("Invalid token claims.")
|
||||||
|
|
||||||
if not client_id:
|
if not client_id:
|
||||||
logger.warning("Missing 'client_id' in JWT payload")
|
logger.warning("Missing 'client_id' in JWT payload")
|
||||||
raise exceptions.AuthenticationFailed("Invalid token claims.")
|
raise exceptions.AuthenticationFailed("Invalid token claims.")
|
||||||
|
|
||||||
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
|
||||||
|
|
||||||
class AddonsJWTAuthentication(BaseJWTAuthentication):
|
if not user.is_active:
|
||||||
"""JWT authentication for addons API access.
|
logger.warning("Inactive user attempted authentication: %s", user_id)
|
||||||
|
raise exceptions.AuthenticationFailed("User account is disabled.")
|
||||||
|
|
||||||
Validates JWT tokens issued by addons for authenticating users.
|
return (user, payload)
|
||||||
Tokens must include user_id to identify the authenticated user.
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(self):
|
def authenticate_header(self, request):
|
||||||
"""Initialize authentication backend with application JWT settings from Django settings."""
|
"""Return authentication scheme for WWW-Authenticate header."""
|
||||||
super().__init__(
|
return "Bearer"
|
||||||
secret_key=settings.ADDONS_JWT_SECRET_KEY,
|
|
||||||
algorithm=settings.ADDONS_JWT_ALG,
|
|
||||||
issuer=settings.ADDONS_JWT_ISSUER,
|
|
||||||
audience=settings.ADDONS_JWT_AUDIENCE,
|
|
||||||
expiration_seconds=settings.ADDONS_JWT_EXPIRATION_SECONDS,
|
|
||||||
token_type=settings.ADDONS_JWT_TOKEN_TYPE,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class ResourceServerBackend(LaSuiteBackend):
|
class ResourceServerBackend(LaSuiteBackend):
|
||||||
|
|||||||
@@ -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,
|
"client_id": client_id,
|
||||||
token_type=settings.APPLICATION_JWT_TOKEN_TYPE,
|
"scope": scope,
|
||||||
)
|
"user_id": str(user.id),
|
||||||
|
"delegated": True,
|
||||||
|
}
|
||||||
|
|
||||||
data = token_service.generate_jwt(
|
token = jwt.encode(
|
||||||
user,
|
payload,
|
||||||
scope,
|
settings.APPLICATION_JWT_SECRET_KEY,
|
||||||
{
|
algorithm=settings.APPLICATION_JWT_ALG,
|
||||||
"client_id": client_id,
|
|
||||||
"delegated": True,
|
|
||||||
},
|
|
||||||
)
|
)
|
||||||
|
|
||||||
return drf_response.Response(
|
return drf_response.Response(
|
||||||
data,
|
{
|
||||||
|
"access_token": token,
|
||||||
|
"token_type": settings.APPLICATION_JWT_TOKEN_TYPE,
|
||||||
|
"expires_in": settings.APPLICATION_JWT_EXPIRATION_SECONDS,
|
||||||
|
"scope": scope,
|
||||||
|
},
|
||||||
status=drf_status.HTTP_200_OK,
|
status=drf_status.HTTP_200_OK,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -173,13 +175,10 @@ class RoomViewSet(
|
|||||||
|
|
||||||
authentication_classes = [
|
authentication_classes = [
|
||||||
authentication.ApplicationJWTAuthentication,
|
authentication.ApplicationJWTAuthentication,
|
||||||
authentication.AddonsJWTAuthentication,
|
|
||||||
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
|
||||||
|
|||||||
@@ -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."""
|
||||||
|
|||||||
@@ -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
|
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
{% load i18n %}
|
|
||||||
{% get_current_language as LANGUAGE %}
|
|
||||||
<!DOCTYPE html>
|
|
||||||
<html lang="{{ LANGUAGE }}">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8">
|
|
||||||
<title>{% trans "Error" %}</title>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<div class="container">
|
|
||||||
<h1>{{ title|default:_("Error") }}</h1>
|
|
||||||
<p>{{ message|default:_("Something went wrong.") }}</p>
|
|
||||||
<button onclick="window.close()">{% trans "Close" %}</button>
|
|
||||||
</div>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
|
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
{% load i18n %}
|
|
||||||
{% get_current_language as LANGUAGE %}
|
|
||||||
<!DOCTYPE html>
|
|
||||||
<html lang="{{ LANGUAGE }}">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8">
|
|
||||||
<title>{% trans "Authentication Success" %}</title>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<script>
|
|
||||||
window.close();
|
|
||||||
</script>
|
|
||||||
<p>{% trans "Session stored successfully. This window will close automatically." %}</p>
|
|
||||||
<p>{% trans "If it doesn't close" %}, <a href="javascript:window.close()">{% trans "click here" %}</a>.</p>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
|
|
||||||
@@ -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")
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -6,8 +6,6 @@ from django.urls import include, path
|
|||||||
from lasuite.oidc_login.urls import urlpatterns as oidc_urls
|
from lasuite.oidc_login.urls import urlpatterns as oidc_urls
|
||||||
from rest_framework.routers import DefaultRouter
|
from rest_framework.routers import DefaultRouter
|
||||||
|
|
||||||
from core.addons import views as addons_views
|
|
||||||
from core.addons import viewsets as addons_viewsets
|
|
||||||
from core.api import get_frontend_configuration, viewsets
|
from core.api import get_frontend_configuration, viewsets
|
||||||
from core.external_api import viewsets as external_viewsets
|
from core.external_api import viewsets as external_viewsets
|
||||||
|
|
||||||
@@ -28,24 +26,12 @@ external_router.register(
|
|||||||
basename="external_application",
|
basename="external_application",
|
||||||
)
|
)
|
||||||
|
|
||||||
# - Addons API
|
|
||||||
addons_router = DefaultRouter()
|
|
||||||
addons_router.register(
|
|
||||||
"addons/sessions",
|
|
||||||
addons_viewsets.AuthSessionViewSet,
|
|
||||||
basename="addons_auth_sessions",
|
|
||||||
)
|
|
||||||
|
|
||||||
external_router.register(
|
external_router.register(
|
||||||
"rooms",
|
"rooms",
|
||||||
external_viewsets.RoomViewSet,
|
external_viewsets.RoomViewSet,
|
||||||
basename="external_room",
|
basename="external_room",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
addons_urls = addons_router.urls if settings.ADDONS_ENABLED else []
|
|
||||||
|
|
||||||
|
|
||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
path(
|
path(
|
||||||
f"api/{settings.API_VERSION}/",
|
f"api/{settings.API_VERSION}/",
|
||||||
@@ -53,26 +39,12 @@ urlpatterns = [
|
|||||||
[
|
[
|
||||||
*router.urls,
|
*router.urls,
|
||||||
*oidc_urls,
|
*oidc_urls,
|
||||||
*addons_urls, # should be in external api
|
|
||||||
path("config/", get_frontend_configuration, name="config"),
|
path("config/", get_frontend_configuration, name="config"),
|
||||||
]
|
]
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
]
|
]
|
||||||
|
|
||||||
if settings.ADDONS_ENABLED:
|
|
||||||
urlpatterns.append(
|
|
||||||
path(
|
|
||||||
"addons/",
|
|
||||||
include(
|
|
||||||
[
|
|
||||||
path("transit/", addons_views.transit_page, name="transit_page"),
|
|
||||||
path("redirect/", addons_views.redirect_page, name="redirect_page"),
|
|
||||||
]
|
|
||||||
),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
if settings.EXTERNAL_API_ENABLED:
|
if settings.EXTERNAL_API_ENABLED:
|
||||||
urlpatterns.append(
|
urlpatterns.append(
|
||||||
path(
|
path(
|
||||||
|
|||||||
Binary file not shown.
@@ -8,7 +8,7 @@ msgid ""
|
|||||||
msgstr ""
|
msgstr ""
|
||||||
"Project-Id-Version: PACKAGE VERSION\n"
|
"Project-Id-Version: PACKAGE VERSION\n"
|
||||||
"Report-Msgid-Bugs-To: \n"
|
"Report-Msgid-Bugs-To: \n"
|
||||||
"POT-Creation-Date: 2026-01-26 15:40+0000\n"
|
"POT-Creation-Date: 2025-12-29 15:15+0000\n"
|
||||||
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
|
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
|
||||||
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
|
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
|
||||||
"Language-Team: LANGUAGE <LL@li.org>\n"
|
"Language-Team: LANGUAGE <LL@li.org>\n"
|
||||||
@@ -17,30 +17,6 @@ msgstr ""
|
|||||||
"Content-Type: text/plain; charset=UTF-8\n"
|
"Content-Type: text/plain; charset=UTF-8\n"
|
||||||
"Content-Transfer-Encoding: 8bit\n"
|
"Content-Transfer-Encoding: 8bit\n"
|
||||||
|
|
||||||
#: core/addons/views.py:24
|
|
||||||
msgid "Session ID is required."
|
|
||||||
msgstr "Sitzungs-ID ist erforderlich."
|
|
||||||
|
|
||||||
#: core/addons/views.py:29
|
|
||||||
msgid "Session not found or expired."
|
|
||||||
msgstr "Sitzung nicht gefunden oder abgelaufen."
|
|
||||||
|
|
||||||
#: core/addons/views.py:32
|
|
||||||
msgid "Invalid session state."
|
|
||||||
msgstr "Ungültiger Sitzungsstatus."
|
|
||||||
|
|
||||||
#: core/addons/views.py:45
|
|
||||||
msgid "Authentication required."
|
|
||||||
msgstr "Authentifizierung erforderlich."
|
|
||||||
|
|
||||||
#: core/addons/views.py:50
|
|
||||||
msgid "No active session found."
|
|
||||||
msgstr "Keine aktive Sitzung gefunden."
|
|
||||||
|
|
||||||
#: core/addons/views.py:55
|
|
||||||
msgid "Invalid or expired session."
|
|
||||||
msgstr "Ungültige oder abgelaufene Sitzung."
|
|
||||||
|
|
||||||
#: core/admin.py:29
|
#: core/admin.py:29
|
||||||
msgid "Personal info"
|
msgid "Personal info"
|
||||||
msgstr "Persönliche Informationen"
|
msgstr "Persönliche Informationen"
|
||||||
@@ -432,7 +408,7 @@ msgstr "Anwendungsdomain"
|
|||||||
msgid "Application domains"
|
msgid "Application domains"
|
||||||
msgstr "Anwendungsdomains"
|
msgstr "Anwendungsdomains"
|
||||||
|
|
||||||
#: core/recording/event/notification.py:116
|
#: core/recording/event/notification.py:94
|
||||||
msgid "Your recording is ready"
|
msgid "Your recording is ready"
|
||||||
msgstr "Ihre Aufzeichnung ist bereit"
|
msgstr "Ihre Aufzeichnung ist bereit"
|
||||||
|
|
||||||
@@ -441,30 +417,6 @@ msgstr "Ihre Aufzeichnung ist bereit"
|
|||||||
msgid "Video call in progress: {sender.email} is waiting for you to connect"
|
msgid "Video call in progress: {sender.email} is waiting for you to connect"
|
||||||
msgstr "Videoanruf läuft: {sender.email} wartet auf Ihre Teilnahme"
|
msgstr "Videoanruf läuft: {sender.email} wartet auf Ihre Teilnahme"
|
||||||
|
|
||||||
#: core/templates/addons/error.html:7 core/templates/addons/error.html:11
|
|
||||||
msgid "Error"
|
|
||||||
msgstr "Fehler"
|
|
||||||
|
|
||||||
#: core/templates/addons/error.html:12
|
|
||||||
msgid "Something went wrong."
|
|
||||||
msgstr "Etwas ist schiefgelaufen."
|
|
||||||
|
|
||||||
#: core/templates/addons/error.html:13
|
|
||||||
msgid "Close"
|
|
||||||
msgstr "Schließen"
|
|
||||||
|
|
||||||
#: core/templates/addons/redirect_success.html:7
|
|
||||||
msgid "Authentication Success"
|
|
||||||
msgstr "Authentifizierung erfolgreich"
|
|
||||||
|
|
||||||
#: core/templates/addons/redirect_success.html:13
|
|
||||||
msgid "Session stored successfully. This window will close automatically."
|
|
||||||
msgstr "Sitzung erfolgreich gespeichert. Dieses Fenster wird automatisch geschlossen."
|
|
||||||
|
|
||||||
#: core/templates/addons/redirect_success.html:14
|
|
||||||
msgid "If it doesn't close"
|
|
||||||
msgstr "Falls es sich nicht schließt"
|
|
||||||
|
|
||||||
#: core/templates/mail/html/invitation.html:159
|
#: core/templates/mail/html/invitation.html:159
|
||||||
#: core/templates/mail/html/screen_recording.html:159
|
#: core/templates/mail/html/screen_recording.html:159
|
||||||
#: core/templates/mail/text/invitation.txt:3
|
#: core/templates/mail/text/invitation.txt:3
|
||||||
|
|||||||
Binary file not shown.
@@ -8,7 +8,7 @@ msgid ""
|
|||||||
msgstr ""
|
msgstr ""
|
||||||
"Project-Id-Version: PACKAGE VERSION\n"
|
"Project-Id-Version: PACKAGE VERSION\n"
|
||||||
"Report-Msgid-Bugs-To: \n"
|
"Report-Msgid-Bugs-To: \n"
|
||||||
"POT-Creation-Date: 2026-01-26 15:40+0000\n"
|
"POT-Creation-Date: 2025-12-29 15:15+0000\n"
|
||||||
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
|
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
|
||||||
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
|
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
|
||||||
"Language-Team: LANGUAGE <LL@li.org>\n"
|
"Language-Team: LANGUAGE <LL@li.org>\n"
|
||||||
@@ -17,30 +17,6 @@ msgstr ""
|
|||||||
"Content-Type: text/plain; charset=UTF-8\n"
|
"Content-Type: text/plain; charset=UTF-8\n"
|
||||||
"Content-Transfer-Encoding: 8bit\n"
|
"Content-Transfer-Encoding: 8bit\n"
|
||||||
|
|
||||||
#: core/addons/views.py:24
|
|
||||||
msgid "Session ID is required."
|
|
||||||
msgstr "Session ID is required."
|
|
||||||
|
|
||||||
#: core/addons/views.py:29
|
|
||||||
msgid "Session not found or expired."
|
|
||||||
msgstr "Session not found or expired."
|
|
||||||
|
|
||||||
#: core/addons/views.py:32
|
|
||||||
msgid "Invalid session state."
|
|
||||||
msgstr "Invalid session state."
|
|
||||||
|
|
||||||
#: core/addons/views.py:45
|
|
||||||
msgid "Authentication required."
|
|
||||||
msgstr "Authentication required."
|
|
||||||
|
|
||||||
#: core/addons/views.py:50
|
|
||||||
msgid "No active session found."
|
|
||||||
msgstr "No active session found."
|
|
||||||
|
|
||||||
#: core/addons/views.py:55
|
|
||||||
msgid "Invalid or expired session."
|
|
||||||
msgstr "Invalid or expired session."
|
|
||||||
|
|
||||||
#: core/admin.py:29
|
#: core/admin.py:29
|
||||||
msgid "Personal info"
|
msgid "Personal info"
|
||||||
msgstr "Personal info"
|
msgstr "Personal info"
|
||||||
@@ -429,7 +405,7 @@ msgstr "Application domain"
|
|||||||
msgid "Application domains"
|
msgid "Application domains"
|
||||||
msgstr "Application domains"
|
msgstr "Application domains"
|
||||||
|
|
||||||
#: core/recording/event/notification.py:116
|
#: core/recording/event/notification.py:94
|
||||||
msgid "Your recording is ready"
|
msgid "Your recording is ready"
|
||||||
msgstr "Your recording is ready"
|
msgstr "Your recording is ready"
|
||||||
|
|
||||||
@@ -438,30 +414,6 @@ msgstr "Your recording is ready"
|
|||||||
msgid "Video call in progress: {sender.email} is waiting for you to connect"
|
msgid "Video call in progress: {sender.email} is waiting for you to connect"
|
||||||
msgstr "Video call in progress: {sender.email} is waiting for you to connect"
|
msgstr "Video call in progress: {sender.email} is waiting for you to connect"
|
||||||
|
|
||||||
#: core/templates/addons/error.html:7 core/templates/addons/error.html:11
|
|
||||||
msgid "Error"
|
|
||||||
msgstr "Error"
|
|
||||||
|
|
||||||
#: core/templates/addons/error.html:12
|
|
||||||
msgid "Something went wrong."
|
|
||||||
msgstr "Something went wrong."
|
|
||||||
|
|
||||||
#: core/templates/addons/error.html:13
|
|
||||||
msgid "Close"
|
|
||||||
msgstr "Close"
|
|
||||||
|
|
||||||
#: core/templates/addons/redirect_success.html:7
|
|
||||||
msgid "Authentication Success"
|
|
||||||
msgstr "Authentication Success"
|
|
||||||
|
|
||||||
#: core/templates/addons/redirect_success.html:13
|
|
||||||
msgid "Session stored successfully. This window will close automatically."
|
|
||||||
msgstr "Session stored successfully. This window will close automatically."
|
|
||||||
|
|
||||||
#: core/templates/addons/redirect_success.html:14
|
|
||||||
msgid "If it doesn't close"
|
|
||||||
msgstr "If it doesn't close"
|
|
||||||
|
|
||||||
#: core/templates/mail/html/invitation.html:159
|
#: core/templates/mail/html/invitation.html:159
|
||||||
#: core/templates/mail/html/screen_recording.html:159
|
#: core/templates/mail/html/screen_recording.html:159
|
||||||
#: core/templates/mail/text/invitation.txt:3
|
#: core/templates/mail/text/invitation.txt:3
|
||||||
|
|||||||
Binary file not shown.
@@ -8,7 +8,7 @@ msgid ""
|
|||||||
msgstr ""
|
msgstr ""
|
||||||
"Project-Id-Version: PACKAGE VERSION\n"
|
"Project-Id-Version: PACKAGE VERSION\n"
|
||||||
"Report-Msgid-Bugs-To: \n"
|
"Report-Msgid-Bugs-To: \n"
|
||||||
"POT-Creation-Date: 2026-01-26 15:40+0000\n"
|
"POT-Creation-Date: 2025-12-29 15:15+0000\n"
|
||||||
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
|
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
|
||||||
"Last-Translator: antoine.lebaud@mail.numerique.gouv.fr\n"
|
"Last-Translator: antoine.lebaud@mail.numerique.gouv.fr\n"
|
||||||
"Language-Team: LANGUAGE <LL@li.org>\n"
|
"Language-Team: LANGUAGE <LL@li.org>\n"
|
||||||
@@ -17,30 +17,6 @@ msgstr ""
|
|||||||
"Content-Type: text/plain; charset=UTF-8\n"
|
"Content-Type: text/plain; charset=UTF-8\n"
|
||||||
"Content-Transfer-Encoding: 8bit\n"
|
"Content-Transfer-Encoding: 8bit\n"
|
||||||
|
|
||||||
#: core/addons/views.py:24
|
|
||||||
msgid "Session ID is required."
|
|
||||||
msgstr "L'identifiant de session est requis."
|
|
||||||
|
|
||||||
#: core/addons/views.py:29
|
|
||||||
msgid "Session not found or expired."
|
|
||||||
msgstr "Session introuvable ou expirée."
|
|
||||||
|
|
||||||
#: core/addons/views.py:32
|
|
||||||
msgid "Invalid session state."
|
|
||||||
msgstr "État de session invalide."
|
|
||||||
|
|
||||||
#: core/addons/views.py:45
|
|
||||||
msgid "Authentication required."
|
|
||||||
msgstr "Authentification requise."
|
|
||||||
|
|
||||||
#: core/addons/views.py:50
|
|
||||||
msgid "No active session found."
|
|
||||||
msgstr "Aucune session active trouvée."
|
|
||||||
|
|
||||||
#: core/addons/views.py:55
|
|
||||||
msgid "Invalid or expired session."
|
|
||||||
msgstr "Session invalide ou expirée."
|
|
||||||
|
|
||||||
#: core/admin.py:29
|
#: core/admin.py:29
|
||||||
msgid "Personal info"
|
msgid "Personal info"
|
||||||
msgstr "Informations personnelles"
|
msgstr "Informations personnelles"
|
||||||
@@ -433,7 +409,7 @@ msgstr "Domaine d’application"
|
|||||||
msgid "Application domains"
|
msgid "Application domains"
|
||||||
msgstr "Domaines d’application"
|
msgstr "Domaines d’application"
|
||||||
|
|
||||||
#: core/recording/event/notification.py:116
|
#: core/recording/event/notification.py:94
|
||||||
msgid "Your recording is ready"
|
msgid "Your recording is ready"
|
||||||
msgstr "Votre enregistrement est prêt"
|
msgstr "Votre enregistrement est prêt"
|
||||||
|
|
||||||
@@ -442,30 +418,6 @@ msgstr "Votre enregistrement est prêt"
|
|||||||
msgid "Video call in progress: {sender.email} is waiting for you to connect"
|
msgid "Video call in progress: {sender.email} is waiting for you to connect"
|
||||||
msgstr "Appel vidéo en cours : {sender.email} attend que vous vous connectiez"
|
msgstr "Appel vidéo en cours : {sender.email} attend que vous vous connectiez"
|
||||||
|
|
||||||
#: core/templates/addons/error.html:7 core/templates/addons/error.html:11
|
|
||||||
msgid "Error"
|
|
||||||
msgstr "Erreur"
|
|
||||||
|
|
||||||
#: core/templates/addons/error.html:12
|
|
||||||
msgid "Something went wrong."
|
|
||||||
msgstr "Une erreur s'est produite."
|
|
||||||
|
|
||||||
#: core/templates/addons/error.html:13
|
|
||||||
msgid "Close"
|
|
||||||
msgstr "Fermer"
|
|
||||||
|
|
||||||
#: core/templates/addons/redirect_success.html:7
|
|
||||||
msgid "Authentication Success"
|
|
||||||
msgstr "Authentification réussie"
|
|
||||||
|
|
||||||
#: core/templates/addons/redirect_success.html:13
|
|
||||||
msgid "Session stored successfully. This window will close automatically."
|
|
||||||
msgstr "Session enregistrée avec succès. Cette fenêtre se fermera automatiquement."
|
|
||||||
|
|
||||||
#: core/templates/addons/redirect_success.html:14
|
|
||||||
msgid "If it doesn't close"
|
|
||||||
msgstr "Si elle ne se ferme pas"
|
|
||||||
|
|
||||||
#: core/templates/mail/html/invitation.html:159
|
#: core/templates/mail/html/invitation.html:159
|
||||||
#: core/templates/mail/html/screen_recording.html:159
|
#: core/templates/mail/html/screen_recording.html:159
|
||||||
#: core/templates/mail/text/invitation.txt:3
|
#: core/templates/mail/text/invitation.txt:3
|
||||||
|
|||||||
Binary file not shown.
@@ -8,7 +8,7 @@ msgid ""
|
|||||||
msgstr ""
|
msgstr ""
|
||||||
"Project-Id-Version: PACKAGE VERSION\n"
|
"Project-Id-Version: PACKAGE VERSION\n"
|
||||||
"Report-Msgid-Bugs-To: \n"
|
"Report-Msgid-Bugs-To: \n"
|
||||||
"POT-Creation-Date: 2026-01-26 15:40+0000\n"
|
"POT-Creation-Date: 2025-12-29 15:15+0000\n"
|
||||||
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
|
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
|
||||||
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
|
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
|
||||||
"Language-Team: LANGUAGE <LL@li.org>\n"
|
"Language-Team: LANGUAGE <LL@li.org>\n"
|
||||||
@@ -17,30 +17,6 @@ msgstr ""
|
|||||||
"Content-Type: text/plain; charset=UTF-8\n"
|
"Content-Type: text/plain; charset=UTF-8\n"
|
||||||
"Content-Transfer-Encoding: 8bit\n"
|
"Content-Transfer-Encoding: 8bit\n"
|
||||||
|
|
||||||
#: core/addons/views.py:24
|
|
||||||
msgid "Session ID is required."
|
|
||||||
msgstr "Sessie-ID is vereist."
|
|
||||||
|
|
||||||
#: core/addons/views.py:29
|
|
||||||
msgid "Session not found or expired."
|
|
||||||
msgstr "Sessie niet gevonden of verlopen."
|
|
||||||
|
|
||||||
#: core/addons/views.py:32
|
|
||||||
msgid "Invalid session state."
|
|
||||||
msgstr "Ongeldige sessiestatus."
|
|
||||||
|
|
||||||
#: core/addons/views.py:45
|
|
||||||
msgid "Authentication required."
|
|
||||||
msgstr "Authenticatie vereist."
|
|
||||||
|
|
||||||
#: core/addons/views.py:50
|
|
||||||
msgid "No active session found."
|
|
||||||
msgstr "Geen actieve sessie gevonden."
|
|
||||||
|
|
||||||
#: core/addons/views.py:55
|
|
||||||
msgid "Invalid or expired session."
|
|
||||||
msgstr "Ongeldige of verlopen sessie."
|
|
||||||
|
|
||||||
#: core/admin.py:29
|
#: core/admin.py:29
|
||||||
msgid "Personal info"
|
msgid "Personal info"
|
||||||
msgstr "Persoonlijke informatie"
|
msgstr "Persoonlijke informatie"
|
||||||
@@ -428,7 +404,7 @@ msgstr "Applicatiedomein"
|
|||||||
msgid "Application domains"
|
msgid "Application domains"
|
||||||
msgstr "Applicatiedomeinen"
|
msgstr "Applicatiedomeinen"
|
||||||
|
|
||||||
#: core/recording/event/notification.py:116
|
#: core/recording/event/notification.py:94
|
||||||
msgid "Your recording is ready"
|
msgid "Your recording is ready"
|
||||||
msgstr "Je opname is klaar"
|
msgstr "Je opname is klaar"
|
||||||
|
|
||||||
@@ -437,30 +413,6 @@ msgstr "Je opname is klaar"
|
|||||||
msgid "Video call in progress: {sender.email} is waiting for you to connect"
|
msgid "Video call in progress: {sender.email} is waiting for you to connect"
|
||||||
msgstr "Video-oproep bezig: {sender.email} wacht op je verbinding"
|
msgstr "Video-oproep bezig: {sender.email} wacht op je verbinding"
|
||||||
|
|
||||||
#: core/templates/addons/error.html:7 core/templates/addons/error.html:11
|
|
||||||
msgid "Error"
|
|
||||||
msgstr "Fout"
|
|
||||||
|
|
||||||
#: core/templates/addons/error.html:12
|
|
||||||
msgid "Something went wrong."
|
|
||||||
msgstr "Er is iets misgegaan."
|
|
||||||
|
|
||||||
#: core/templates/addons/error.html:13
|
|
||||||
msgid "Close"
|
|
||||||
msgstr "Sluiten"
|
|
||||||
|
|
||||||
#: core/templates/addons/redirect_success.html:7
|
|
||||||
msgid "Authentication Success"
|
|
||||||
msgstr "Authenticatie geslaagd"
|
|
||||||
|
|
||||||
#: core/templates/addons/redirect_success.html:13
|
|
||||||
msgid "Session stored successfully. This window will close automatically."
|
|
||||||
msgstr "Sessie succesvol opgeslagen. Dit venster wordt automatisch gesloten."
|
|
||||||
|
|
||||||
#: core/templates/addons/redirect_success.html:14
|
|
||||||
msgid "If it doesn't close"
|
|
||||||
msgstr "Als het niet sluit"
|
|
||||||
|
|
||||||
#: core/templates/mail/html/invitation.html:159
|
#: core/templates/mail/html/invitation.html:159
|
||||||
#: core/templates/mail/html/screen_recording.html:159
|
#: core/templates/mail/html/screen_recording.html:159
|
||||||
#: core/templates/mail/text/invitation.txt:3
|
#: core/templates/mail/text/invitation.txt:3
|
||||||
|
|||||||
@@ -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
|
||||||
),
|
),
|
||||||
@@ -667,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(
|
||||||
@@ -797,93 +786,6 @@ class Base(Configuration):
|
|||||||
environ_prefix=None,
|
environ_prefix=None,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Addons
|
|
||||||
ADDONS_ENABLED = values.BooleanValue(
|
|
||||||
False,
|
|
||||||
environ_name="ADDONS_ENABLED",
|
|
||||||
environ_prefix=None,
|
|
||||||
)
|
|
||||||
ADDONS_SESSION_ID_LENGTH = values.PositiveIntegerValue(
|
|
||||||
32,
|
|
||||||
environ_name="ADDONS_SESSION_ID_LENGTH",
|
|
||||||
environ_prefix=None,
|
|
||||||
)
|
|
||||||
# Cache key for the session itself (session_id → session data)
|
|
||||||
ADDONS_SESSION_KEY_PREFIX = values.Value(
|
|
||||||
"addons_session_id",
|
|
||||||
environ_name="ADDONS_SESSION_KEY_PREFIX",
|
|
||||||
environ_prefix=None,
|
|
||||||
)
|
|
||||||
# Cache key for the token → session binding (result_token → session_id)
|
|
||||||
ADDONS_SESSION_TOKEN_PREFIX = values.Value(
|
|
||||||
"addons_token_id",
|
|
||||||
environ_name="ADDONS_SESSION_TOKEN_PREFIX",
|
|
||||||
environ_prefix=None,
|
|
||||||
)
|
|
||||||
# Used as the Django session key in transit page
|
|
||||||
ADDONS_SESSION_KEY_AUTH = values.Value(
|
|
||||||
"addons_session_id",
|
|
||||||
environ_name="ADDONS_SESSION_KEY_AUTH",
|
|
||||||
environ_prefix=None,
|
|
||||||
)
|
|
||||||
ADDONS_SESSION_TIMEOUT = values.PositiveIntegerValue(
|
|
||||||
600, environ_name="ADDONS_SESSION_TIMEOUT", environ_prefix=None
|
|
||||||
)
|
|
||||||
ADDONS_RESULT_TOKEN_COOKIE_NAME = values.Value(
|
|
||||||
"wip",
|
|
||||||
environ_name="ADDONS_RESULT_TOKEN_COOKIE_NAME",
|
|
||||||
environ_prefix=None,
|
|
||||||
)
|
|
||||||
ADDONS_RESULT_TOKEN_COOKIE_SECURE = values.BooleanValue(
|
|
||||||
True,
|
|
||||||
environ_name="ADDONS_RESULT_TOKEN_COOKIE_SECURE",
|
|
||||||
environ_prefix=None,
|
|
||||||
)
|
|
||||||
ADDONS_RESULT_TOKEN_COOKIE_HTTP_ONLY = values.BooleanValue(
|
|
||||||
True,
|
|
||||||
environ_name="ADDONS_RESULT_TOKEN_COOKIE_HTTP_ONLY",
|
|
||||||
environ_prefix=None,
|
|
||||||
)
|
|
||||||
ADDONS_RESULT_TOKEN_COOKIE_SAMESITE = values.Value(
|
|
||||||
"strict",
|
|
||||||
environ_name="ADDONS_RESULT_TOKEN_COOKIE_SAMESITE",
|
|
||||||
environ_prefix=None,
|
|
||||||
)
|
|
||||||
|
|
||||||
ADDONS_JWT_SECRET_KEY = SecretFileValue(
|
|
||||||
None, environ_name="ADDONS_JWT_SECRET_KEY", environ_prefix=None
|
|
||||||
)
|
|
||||||
ADDONS_JWT_ALG = values.Value(
|
|
||||||
"HS256",
|
|
||||||
environ_name="ADDONS_JWT_ALG",
|
|
||||||
environ_prefix=None,
|
|
||||||
)
|
|
||||||
ADDONS_SCOPES = values.Value(
|
|
||||||
"rooms:create rooms:list",
|
|
||||||
environ_name="ADDONS_SCOPES",
|
|
||||||
environ_prefix=None,
|
|
||||||
)
|
|
||||||
ADDONS_JWT_ISSUER = values.Value(
|
|
||||||
"lasuite-meet",
|
|
||||||
environ_name="ADDONS_JWT_ISSUER",
|
|
||||||
environ_prefix=None,
|
|
||||||
)
|
|
||||||
ADDONS_JWT_AUDIENCE = values.Value(
|
|
||||||
None,
|
|
||||||
environ_name="ADDONS_JWT_AUDIENCE",
|
|
||||||
environ_prefix=None,
|
|
||||||
)
|
|
||||||
ADDONS_JWT_EXPIRATION_SECONDS = values.PositiveIntegerValue(
|
|
||||||
3600,
|
|
||||||
environ_name="ADDONS_JWT_EXPIRATION_SECONDS",
|
|
||||||
environ_prefix=None,
|
|
||||||
)
|
|
||||||
ADDONS_JWT_TOKEN_TYPE = values.Value(
|
|
||||||
"Bearer",
|
|
||||||
environ_name="ADDONS_JWT_TOKEN_TYPE",
|
|
||||||
environ_prefix=None,
|
|
||||||
)
|
|
||||||
|
|
||||||
# pylint: disable=invalid-name
|
# pylint: disable=invalid-name
|
||||||
@property
|
@property
|
||||||
def ENVIRONMENT(self):
|
def ENVIRONMENT(self):
|
||||||
@@ -1004,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):
|
||||||
|
|||||||
+24
-25
@@ -7,7 +7,7 @@ build-backend = "setuptools.build_meta"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "meet"
|
name = "meet"
|
||||||
version = "1.8.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",
|
||||||
@@ -25,39 +25,38 @@ license = { file = "LICENSE" }
|
|||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
requires-python = ">=3.10"
|
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==5.2.11",
|
"django==5.2.9",
|
||||||
"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",
|
||||||
"PyJWT==2.11.0",
|
"PyJWT==2.10.1",
|
||||||
"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]
|
||||||
@@ -69,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,47 +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";
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
# Manifest — fetched, never iframed
|
|
||||||
location = /outlook-addin/manifest.xml {
|
|
||||||
alias /usr/share/nginx/html/outlook-addin/manifest.xml;
|
|
||||||
|
|
||||||
add_header Access-Control-Allow-Origin "*";
|
|
||||||
add_header Cache-Control "no-cache, no-store, must-revalidate";
|
|
||||||
add_header X-Frame-Options "DENY";
|
|
||||||
add_header Content-Security-Policy "frame-ancestors 'none'";
|
|
||||||
}
|
|
||||||
|
|
||||||
location ~ ^/outlook-addin(/.*)?$ {
|
|
||||||
alias /usr/share/nginx/html/outlook-addin$1;
|
|
||||||
add_header Access-Control-Allow-Origin "*";
|
|
||||||
add_header Cache-Control "no-cache, no-store, must-revalidate";
|
|
||||||
|
|
||||||
set $ms_domains "https://*.live.com https://*.office.com https://*.microsoft.com https://*.office365.com https://*.sharepoint.com";
|
|
||||||
|
|
||||||
set $nonce $request_id;
|
|
||||||
|
|
||||||
set $csp "upgrade-insecure-requests; ";
|
|
||||||
set $csp "${csp}frame-ancestors ${ms_domains}; ";
|
|
||||||
set $csp "${csp}script-src 'nonce-${nonce}' 'strict-dynamic' ${ms_domains} 'self'; ";
|
|
||||||
set $csp "${csp}connect-src 'self' 'strict-dynamic' ${ms_domains}; ";
|
|
||||||
set $csp "${csp}frame-src 'none'; ";
|
|
||||||
set $csp "${csp}object-src 'none'; ";
|
|
||||||
set $csp "${csp}base-uri 'none'; ";
|
|
||||||
|
|
||||||
add_header Content-Security-Policy $csp;
|
|
||||||
|
|
||||||
sub_filter 'NONCE_PLACEHOLDER' $nonce;
|
|
||||||
sub_filter_once off;
|
|
||||||
sub_filter_types text/html;
|
|
||||||
}
|
|
||||||
|
|
||||||
# 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
+957
-2918
File diff suppressed because it is too large
Load Diff
+24
-24
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "meet",
|
"name": "meet",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "1.8.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.10.1",
|
"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"
|
||||||
|
|||||||
@@ -192,7 +192,7 @@ export const IntroSlider = () => {
|
|||||||
<SlideContainer>
|
<SlideContainer>
|
||||||
{SLIDES.map((slide, index) => (
|
{SLIDES.map((slide, index) => (
|
||||||
<Slide visible={index == slideIndex} key={index}>
|
<Slide visible={index == slideIndex} key={index}>
|
||||||
<Image src={slide.src} alt="" role="presentation" />
|
<Image src={slide.src} alt={t(`${slide.key}.imgAlt`)} />
|
||||||
<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>
|
||||||
|
|||||||
@@ -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 (
|
||||||
@@ -156,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>
|
||||||
|
|||||||
@@ -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(
|
||||||
color: 'primary.500',
|
'material-icons',
|
||||||
marginRight: '1rem',
|
css({
|
||||||
})}
|
color: 'primary.500',
|
||||||
name="info"
|
marginRight: '1rem',
|
||||||
/>
|
})
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
info
|
||||||
|
</span>
|
||||||
<Text variant={'smNote'}>
|
<Text variant={'smNote'}>
|
||||||
{parseLineBreaks(t('button.anotherModeStarted'))}
|
{parseLineBreaks(t('button.anotherModeStarted'))}
|
||||||
</Text>
|
</Text>
|
||||||
<Icon
|
<span
|
||||||
className={css({
|
className={cx(
|
||||||
color: 'primary.500',
|
'material-icons',
|
||||||
marginLeft: 'auto',
|
css({
|
||||||
})}
|
color: 'primary.500',
|
||||||
name="chevron_right"
|
marginLeft: 'auto',
|
||||||
/>
|
})
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
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,65 +74,61 @@ export const RecordingStateToast = () => {
|
|||||||
const hasTranscriptAccessAndActive = isTranscriptActive && hasTranscriptAccess
|
const hasTranscriptAccessAndActive = isTranscriptActive && hasTranscriptAccess
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<div
|
||||||
{/* Visual banner (without aria-live to avoid duplicate announcements) */}
|
className={css({
|
||||||
<div
|
display: 'flex',
|
||||||
className={css({
|
position: 'fixed',
|
||||||
display: 'flex',
|
top: '10px',
|
||||||
position: 'fixed',
|
left: '10px',
|
||||||
top: '10px',
|
paddingY: '0.25rem',
|
||||||
left: '10px',
|
paddingX: '0.75rem 0.75rem',
|
||||||
paddingY: '0.25rem',
|
backgroundColor: 'danger.700',
|
||||||
paddingX: '0.75rem 0.75rem',
|
borderColor: 'white',
|
||||||
backgroundColor: 'danger.700',
|
border: '1px solid',
|
||||||
borderColor: 'white',
|
color: 'white',
|
||||||
border: '1px solid',
|
borderRadius: '4px',
|
||||||
color: 'white',
|
gap: '0.5rem',
|
||||||
borderRadius: '4px',
|
})}
|
||||||
gap: '0.5rem',
|
>
|
||||||
})}
|
<RecordingStatusIcon
|
||||||
>
|
isStarted={isStarted}
|
||||||
<RecordingStatusIcon
|
isTranscriptActive={isTranscriptActive}
|
||||||
isStarted={isStarted}
|
/>
|
||||||
isTranscriptActive={isTranscriptActive}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{!hasScreenRecordingAccessAndActive &&
|
{!hasScreenRecordingAccessAndActive && !hasTranscriptAccessAndActive && (
|
||||||
!hasTranscriptAccessAndActive && (
|
<Text
|
||||||
<Text
|
variant={'sm'}
|
||||||
variant={'sm'}
|
className={css({
|
||||||
className={css({
|
fontWeight: '500 !important',
|
||||||
fontWeight: '500 !important',
|
})}
|
||||||
})}
|
>
|
||||||
>
|
{t(key)}
|
||||||
{t(key)}
|
</Text>
|
||||||
</Text>
|
)}
|
||||||
)}
|
{hasScreenRecordingAccessAndActive && (
|
||||||
{hasScreenRecordingAccessAndActive && (
|
<RACButton
|
||||||
<RACButton
|
onPress={openScreenRecording}
|
||||||
onPress={openScreenRecording}
|
className={css({
|
||||||
className={css({
|
textStyle: 'sm !important',
|
||||||
textStyle: 'sm !important',
|
fontWeight: '500 !important',
|
||||||
fontWeight: '500 !important',
|
cursor: 'pointer',
|
||||||
cursor: 'pointer',
|
})}
|
||||||
})}
|
>
|
||||||
>
|
{t(key)}
|
||||||
{t(key)}
|
</RACButton>
|
||||||
</RACButton>
|
)}
|
||||||
)}
|
{hasTranscriptAccessAndActive && (
|
||||||
{hasTranscriptAccessAndActive && (
|
<RACButton
|
||||||
<RACButton
|
onPress={openTranscript}
|
||||||
onPress={openTranscript}
|
className={css({
|
||||||
className={css({
|
textStyle: 'sm !important',
|
||||||
textStyle: 'sm !important',
|
fontWeight: '500 !important',
|
||||||
fontWeight: '500 !important',
|
cursor: 'pointer',
|
||||||
cursor: 'pointer',
|
})}
|
||||||
})}
|
>
|
||||||
>
|
{t(key)}
|
||||||
{t(key)}
|
</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={{
|
||||||
room: data.room.name,
|
__html: t('success.body', {
|
||||||
created_at: formatDate(data.created_at, 'YYYY-MM-DD HH:mm'),
|
room: data.room.name,
|
||||||
})}
|
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) => {
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 = [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.includes(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={() => {
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user