mirror of
https://github.com/suitenumerique/meet.git
synced 2026-07-27 12:19:10 +00:00
Compare commits
81 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e7dc54d6c5 | |||
| 9a07fba991 | |||
| ca3b1f0297 | |||
| 01390b12fb | |||
| df1eca7c34 | |||
| b529e9c848 | |||
| 23a2d3bcac | |||
| 55749a9565 | |||
| 8115a39538 | |||
| 2416ca1127 | |||
| 1971f594cf | |||
| fb0ee9e8f6 | |||
| b261f2ee5b | |||
| aa54075e6b | |||
| 271b598cee | |||
| fc232759fb | |||
| a992aa8898 | |||
| 1b8b91a44d | |||
| 63c6f5a8a1 | |||
| 1970a4d6b1 | |||
| d406f31bd8 | |||
| 4a011024dd | |||
| 59b23ad1b9 | |||
| 93be2881d2 | |||
| daa125edf3 | |||
| c93e770704 | |||
| 1f57adc4da | |||
| 952e6970f0 | |||
| af0746eac1 | |||
| 62492d1411 | |||
| 34e21b77b5 | |||
| 9edb9c2f57 | |||
| ee1abbed04 | |||
| ed52123733 | |||
| a7739efb70 | |||
| 72ef3c8bb0 | |||
| e9ef2bc4ae | |||
| 8587574fcd | |||
| c3617fc005 | |||
| 8f81318ecf | |||
| afd2f9a299 | |||
| fff5740b14 | |||
| 7545f94bbd | |||
| edb90327e5 | |||
| fdff5092b0 | |||
| 234116163a | |||
| 2a8027deb0 | |||
| d167490c09 | |||
| e7ea700c3d | |||
| edf19c8f1e | |||
| e6feed2086 | |||
| dbd9ac6eea | |||
| 86b03a3d47 | |||
| 2c2b4edc84 | |||
| 4be2d16483 | |||
| ccd0cb4641 | |||
| 561ea346db | |||
| 13bd195b22 | |||
| a35a4ffbec | |||
| 668523aa8b | |||
| d15fb0a19b | |||
| d6502f0739 | |||
| 504b851731 | |||
| cb6a81715a | |||
| 47c133cc64 | |||
| c0d490f549 | |||
| 41ad15e20b | |||
| 0707ac2cd4 | |||
| 5ce63d937d | |||
| 50791c945d | |||
| 1fb37f2f8e | |||
| eab64b7197 | |||
| 786cd3e4c7 | |||
| c8f96b1f22 | |||
| df5f0dbf9f | |||
| dc67d7c190 | |||
| 82208c220a | |||
| 2e081e5e1e | |||
| 57b8a15642 | |||
| b03bfe94a4 | |||
| a2774eb888 |
@@ -21,7 +21,7 @@ jobs:
|
||||
repositories: "meet,secrets"
|
||||
-
|
||||
name: Checkout repository
|
||||
uses: actions/checkout@v2
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
submodules: recursive
|
||||
token: ${{ steps.app-token.outputs.token }}
|
||||
|
||||
@@ -28,7 +28,7 @@ jobs:
|
||||
repositories: "meet,secrets"
|
||||
-
|
||||
name: Checkout repository
|
||||
uses: actions/checkout@v2
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
submodules: recursive
|
||||
token: ${{ steps.app-token.outputs.token }}
|
||||
@@ -72,7 +72,7 @@ jobs:
|
||||
repositories: "meet,secrets"
|
||||
-
|
||||
name: Checkout repository
|
||||
uses: actions/checkout@v2
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
submodules: recursive
|
||||
token: ${{ steps.app-token.outputs.token }}
|
||||
@@ -122,7 +122,7 @@ jobs:
|
||||
repositories: "meet,secrets"
|
||||
-
|
||||
name: Checkout repository
|
||||
uses: actions/checkout@v2
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
submodules: recursive
|
||||
token: ${{ steps.app-token.outputs.token }}
|
||||
|
||||
@@ -14,7 +14,7 @@ jobs:
|
||||
if: github.event_name == 'pull_request' # Makes sense only for pull requests
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v2
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: show
|
||||
@@ -77,9 +77,9 @@ jobs:
|
||||
working-directory: src/backend
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v2
|
||||
uses: actions/checkout@v4
|
||||
- name: Install Python
|
||||
uses: actions/setup-python@v3
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.10"
|
||||
- name: Install development dependencies
|
||||
@@ -89,7 +89,7 @@ jobs:
|
||||
- name: Lint code with ruff
|
||||
run: ~/.local/bin/ruff check .
|
||||
- name: Lint code with pylint
|
||||
run: ~/.local/bin/pylint .
|
||||
run: ~/.local/bin/pylint meet demo core
|
||||
|
||||
test-back:
|
||||
runs-on: ubuntu-latest
|
||||
@@ -122,9 +122,6 @@ jobs:
|
||||
DB_PASSWORD: pass
|
||||
DB_PORT: 5432
|
||||
STORAGES_STATICFILES_BACKEND: django.contrib.staticfiles.storage.StaticFilesStorage
|
||||
AWS_S3_ENDPOINT_URL: http://localhost:9000
|
||||
AWS_S3_ACCESS_KEY_ID: impress
|
||||
AWS_S3_SECRET_ACCESS_KEY: password
|
||||
LIVEKIT_API_SECRET: secret
|
||||
LIVEKIT_API_KEY: devkey
|
||||
|
||||
@@ -145,7 +142,7 @@ jobs:
|
||||
key: mail-templates-${{ hashFiles('src/mail/mjml') }}
|
||||
|
||||
- name: Install Python
|
||||
uses: actions/setup-python@v3
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.10"
|
||||
|
||||
@@ -176,7 +173,7 @@ jobs:
|
||||
repositories: "infrastructure,secrets"
|
||||
-
|
||||
name: Checkout repository
|
||||
uses: actions/checkout@v2
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
submodules: recursive
|
||||
token: ${{ steps.app-token.outputs.token }}
|
||||
@@ -193,7 +190,7 @@ jobs:
|
||||
sudo apt-get install -y gettext
|
||||
|
||||
- name: Install Python
|
||||
uses: actions/setup-python@v3
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.10"
|
||||
|
||||
|
||||
@@ -50,9 +50,6 @@ node_modules
|
||||
# Mails
|
||||
src/backend/core/templates/mail/
|
||||
|
||||
# Typescript client
|
||||
src/frontend/tsclient
|
||||
|
||||
# Swagger
|
||||
**/swagger.json
|
||||
|
||||
|
||||
@@ -62,12 +62,17 @@ words=wip
|
||||
# For example, use the following regex if you only want to allow email addresses from foo.com
|
||||
# regex=[^@]+@foo.com
|
||||
|
||||
[ignore-by-title]
|
||||
[ignore-by-title:bots]
|
||||
# Allow empty body & wrong title pattern only when bots (pyup/greenkeeper)
|
||||
# upgrade dependencies
|
||||
regex=^(⬆️.*|Update (.*) from (.*) to (.*)|(chore|fix)\(package\): update .*)$
|
||||
ignore=B6,UC1
|
||||
|
||||
[ignore-by-title:releases]
|
||||
# Allow empty body for release commits
|
||||
regex=^🔖.*$
|
||||
ignore=B6
|
||||
|
||||
# [ignore-by-body]
|
||||
# Ignore certain rules for commits of which the body has a line that matches a regex
|
||||
# E.g. Match bodies that have a line that that contain "release"
|
||||
|
||||
@@ -49,7 +49,6 @@ WAIT_DB = @$(COMPOSE_RUN) dockerize -wait tcp://$(DB_HOST):$(DB_PORT
|
||||
# -- Backend
|
||||
MANAGE = $(COMPOSE_RUN_APP) python manage.py
|
||||
MAIL_YARN = $(COMPOSE_RUN) -w /app/src/mail node yarn # FIXME : use npm
|
||||
TSCLIENT_YARN = $(COMPOSE_RUN) -w /app/src/tsclient node yarn # FIXME : use npm
|
||||
|
||||
# -- Frontend
|
||||
PATH_FRONT = ./src/frontend
|
||||
@@ -142,7 +141,7 @@ lint-ruff-check: ## lint back-end python sources with ruff
|
||||
|
||||
lint-pylint: ## lint back-end python sources with pylint only on changed files from main
|
||||
@echo 'lint:pylint started…'
|
||||
bin/pylint --diff-only=origin/main
|
||||
@$(COMPOSE_RUN_APP) pylint meet demo core
|
||||
.PHONY: lint-pylint
|
||||
|
||||
test: ## run project tests
|
||||
@@ -275,17 +274,6 @@ mails-install: ## install the mail generator
|
||||
@$(MAIL_YARN) install
|
||||
.PHONY: mails-install
|
||||
|
||||
# -- TS client generator
|
||||
|
||||
# FIXME : adapt this command
|
||||
tsclient-install: ## Install the Typescript API client generator
|
||||
@$(TSCLIENT_YARN) install
|
||||
.PHONY: tsclient-install
|
||||
|
||||
# FIXME : adapt this command
|
||||
tsclient: tsclient-install ## Generate a Typescript API client
|
||||
@$(TSCLIENT_YARN) generate:api:client:local ../frontend/tsclient
|
||||
.PHONY: tsclient-install
|
||||
|
||||
# -- Misc
|
||||
clean: ## restore repository state as it was freshly cloned
|
||||
|
||||
@@ -94,6 +94,56 @@ You first need to create a superuser account:
|
||||
$ make superuser
|
||||
```
|
||||
|
||||
### Run application on local Kubernetes
|
||||
|
||||
The application is deployed across staging, preprod, and production environments using Kubernetes (K8s).
|
||||
Reproducing environment conditions locally is crucial for developing new features or debugging issues.
|
||||
|
||||
This is facilitated by [Tilt](https://tilt.dev/) ("Kubernetes for Prod, Tilt for Dev"). Tilt enables smart rebuilds and live updates for services running locally in Kubernetes. We defined our services in a Tiltfile located at `bin/Tiltfile`.
|
||||
|
||||
|
||||
#### Getting Started
|
||||
|
||||
Make sure you have installed:
|
||||
- kubectl
|
||||
- helm
|
||||
- helmfile
|
||||
- tilt
|
||||
|
||||
To build and start the Kubernetes cluster using Kind:
|
||||
```shell
|
||||
$ make build-k8s-cluster
|
||||
```
|
||||
|
||||
Once the Kubernetes cluster is ready, start the application stack locally:
|
||||
```shell
|
||||
$ make start-tilt
|
||||
```
|
||||
These commands set up and run your application environment using Tilt for local Kubernetes development.
|
||||
|
||||
You can monitor Tilt's at `http://localhost:10350/`. After Tilt actions finish, you can access the app at `https://meet.127.0.0.1.nip.io/`.
|
||||
|
||||
#### Debugging frontend
|
||||
|
||||
Tilt deploys the `meet-dev` for the frontend by default, to benefit from Vite.js hot reloading while developing.
|
||||
To troubleshoot production issues, please modify the Tiltfile, switch frontend's target to `frontend-production`:
|
||||
|
||||
```yaml
|
||||
...
|
||||
|
||||
docker_build(
|
||||
'localhost:5001/meet-frontend:latest',
|
||||
context='..',
|
||||
dockerfile='../src/frontend/Dockerfile',
|
||||
only=['./src/frontend', './docker', './.dockerignore'],
|
||||
target='frontend-production', # Update this line when needed
|
||||
live_update=[
|
||||
sync('../src/frontend', '/home/frontend'),
|
||||
]
|
||||
)
|
||||
...
|
||||
```
|
||||
|
||||
## Contributing
|
||||
|
||||
This project is intended to be community-driven, so please, do not hesitate to
|
||||
|
||||
-38
@@ -1,38 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
# shellcheck source=bin/_config.sh
|
||||
source "$(dirname "${BASH_SOURCE[0]}")/_config.sh"
|
||||
|
||||
declare diff_from
|
||||
declare -a paths
|
||||
declare -a args
|
||||
|
||||
# Parse options
|
||||
for arg in "$@"
|
||||
do
|
||||
case $arg in
|
||||
--diff-only=*)
|
||||
diff_from="${arg#*=}"
|
||||
shift
|
||||
;;
|
||||
-*)
|
||||
args+=("$arg")
|
||||
shift
|
||||
;;
|
||||
*)
|
||||
paths+=("$arg")
|
||||
shift
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
if [[ -n "${diff_from}" ]]; then
|
||||
# Run pylint only on modified files located in src/backend
|
||||
# (excluding deleted files and migration files)
|
||||
# shellcheck disable=SC2207
|
||||
paths=($(git diff "${diff_from}" --name-only --diff-filter=d -- src/backend ':!**/migrations/*.py' | grep -E '^src/backend/.*\.py$'))
|
||||
fi
|
||||
|
||||
# Fix docker vs local path when project sources are mounted as a volume
|
||||
read -ra paths <<< "$(echo "${paths[@]}" | sed "s|src/backend/||g")"
|
||||
_dc_run app-dev pylint "${paths[@]}" "${args[@]}"
|
||||
+50
-32
@@ -1,39 +1,57 @@
|
||||
# Releasing new version
|
||||
# Releasing a new version
|
||||
|
||||
Whenever we are cooking a new release (e.g. `4.18.1`) we should follow a standard procedure described below:
|
||||
|
||||
1. Create a new branch named: `release/4.18.1`.
|
||||
2. Bump the release number in the appropriate file, i.e. for backend in the `pyproject.toml` and `package.json` for node-based projects (tips: find and replace all occurrences of the previous version number to ensure consistency across files).
|
||||
3. Update the project's `Changelog` following the [keepachangelog](https://keepachangelog.com/en/0.3.0/) recommendations.
|
||||
4. Update Docker image tag in Helm values files located at `src/helm/env.d` for both `preprod` and `production` environments:
|
||||
```yaml
|
||||
image:
|
||||
repository: lasuite/meet-backend
|
||||
pullPolicy: Always
|
||||
tag: "v4.18.1" # Replace with the latest Docker image tag.
|
||||
```
|
||||
5. Commit your changes with a structured message:
|
||||
- Add a title including the version of the release and respecting the above described format using the 🔖 release emoji.
|
||||
- Paste in the body all changes from the changelog concerned by this release, removing only the Markdown tags and making sure that lines are shorter than 74 characters.
|
||||
1. Create a new branch named: `release/4.18.1`.
|
||||
2. Bump the release number for backend project, frontend projects, and Helm files:
|
||||
|
||||
```
|
||||
- for backend, update the version number by hand in `pyproject.toml`,
|
||||
- for each frontend projects (`src/frontend`, `src/mail`), run `npm version 4.18.1` in their directory. This will update both their `package.json` and `package-lock.json` for you,
|
||||
- for Helm, update Docker image tag in files located at `src/helm/env.d` for both `preprod` and `production` environments:
|
||||
|
||||
```yaml
|
||||
image:
|
||||
repository: lasuite/meet-backend
|
||||
pullPolicy: Always
|
||||
tag: "v4.18.1" # Replace with your new version number, without forgetting the "v" prefix
|
||||
```
|
||||
|
||||
The new images don't exist _yet_: they will be created automatically later in the process.
|
||||
|
||||
3. ~~Update the project's `Changelog` following the [keepachangelog](https://keepachangelog.com/en/0.3.0/) recommendations~~ _we don't keep a changelog yet for now as the project is still in its infancy. Soon™!_
|
||||
4. Commit your changes with the following format: the 🔖 release emoji, the type of release (patch/minor/patch) and the release version:
|
||||
|
||||
```text
|
||||
🔖(minor) bump release to 4.18.0
|
||||
|
||||
Added:
|
||||
|
||||
- Implement base CLI commands (list, extract, fetch & push) for supported backends
|
||||
- Support for ElasticSearch database backend
|
||||
|
||||
Changed:
|
||||
|
||||
- Replace LDP storage backend by FS storage backend`
|
||||
```
|
||||
|
||||
6. Open a pull request.
|
||||
7. Wait for an approval from your peers.
|
||||
8. Merge your pull request.
|
||||
9. Checkout and pull changes from the `main` branch to ensure you have the latest updates.
|
||||
10. Tag & push your commit: `git tag v4.18.1 && git push origin --tags`
|
||||
11. Manually release your version on GitHub.
|
||||
12. Ensure that the CI Docker job has successfully pushed the newly built Docker images to Docker Hub with the appropriate tags.
|
||||
13. To deploy using ArgoCD, you need to update the `production` or `pre-production` tags. ArgoCD will automatically detect and deploy the new tag.
|
||||
5. Open a pull request, wait for an approval from your peers and merge it.
|
||||
6. Checkout and pull changes from the `main` branch to ensure you have the latest updates.
|
||||
7. Tag and push your commit:
|
||||
|
||||
```bash
|
||||
git tag v4.18.1 && git push origin --tags
|
||||
```
|
||||
|
||||
Doing this triggers the CI and tells it to build the new Docker image versions that you targeted earlier in the Helm files.
|
||||
|
||||
8. Ensure the new [backend](https://hub.docker.com/r/lasuite/meet-frontend/tags) and [frontend](https://hub.docker.com/r/lasuite/meet-frontend/tags) image tags are on Docker Hub.
|
||||
9. The release is now done!
|
||||
|
||||
# Deploying
|
||||
|
||||
> [!TIP]
|
||||
> The `staging` platform is deployed automatically with every update of the `main` branch.
|
||||
|
||||
Making a new release doesn't publish it automatically in production.
|
||||
|
||||
Deployment is done by ArgoCD. ArgoCD checks for the `production` tag and automatically deploys the production platform with the targeted commit.
|
||||
|
||||
To publish, we mark the commit we want with the `production` tag. ArgoCD is then notified that the tag has changed. It then deploys the Docker image tags specified in the Helm files of the targeted commit.
|
||||
|
||||
To publish the release you just made:
|
||||
|
||||
```bash
|
||||
git tag --force production v4.18.1
|
||||
git push --force origin production
|
||||
```
|
||||
|
||||
@@ -1,25 +0,0 @@
|
||||
# Api client TypeScript
|
||||
|
||||
The backend application can automatically create a TypeScript client to be used in frontend
|
||||
applications. It is used in the Meet front application itself.
|
||||
|
||||
This client is made with [openapi-typescript-codegen](https://github.com/ferdikoomen/openapi-typescript-codegen)
|
||||
and Meet's backend OpenAPI schema (available [here](http://localhost:8071/v1.0/swagger/) if you have the backend running).
|
||||
|
||||
## Requirements
|
||||
|
||||
We'll need the online OpenAPI schema generated by swagger. Therefore you will first need to
|
||||
install the backend application.
|
||||
|
||||
## Install openApiClientJs
|
||||
|
||||
```sh
|
||||
$ cd src/tsclient
|
||||
$ yarn install
|
||||
```
|
||||
|
||||
## Generate the client
|
||||
|
||||
```sh
|
||||
yarn generate:api:client:local <output_path_for_generated_client>
|
||||
```
|
||||
@@ -18,9 +18,6 @@ MEET_BASE_URL="http://localhost:8072"
|
||||
|
||||
# Media
|
||||
STORAGES_STATICFILES_BACKEND=django.contrib.staticfiles.storage.StaticFilesStorage
|
||||
AWS_S3_ENDPOINT_URL=http://minio:9000
|
||||
AWS_S3_ACCESS_KEY_ID=meet
|
||||
AWS_S3_SECRET_ACCESS_KEY=password
|
||||
|
||||
# OIDC
|
||||
OIDC_OP_JWKS_ENDPOINT=http://nginx:8083/realms/meet/protocol/openid-connect/certs
|
||||
@@ -44,3 +41,4 @@ OIDC_AUTH_REQUEST_EXTRA_PARAMS={"acr_values": "eidas1"}
|
||||
LIVEKIT_API_SECRET=secret
|
||||
LIVEKIT_API_KEY=devkey
|
||||
LIVEKIT_API_URL=http://localhost:7880
|
||||
ALLOW_UNREGISTERED_ROOMS=False
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
"""Admin classes and registrations for core app."""
|
||||
|
||||
from django.contrib import admin
|
||||
from django.contrib.auth import admin as auth_admin
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
"""
|
||||
Meet analytics class.
|
||||
"""
|
||||
|
||||
import uuid
|
||||
|
||||
from django.conf import settings
|
||||
|
||||
from june import analytics as jAnalytics
|
||||
|
||||
|
||||
class Analytics:
|
||||
"""Analytics integration
|
||||
|
||||
This class wraps the June analytics code to avoid coupling our code directly
|
||||
with this third-party library. By doing so, we create a generic interface
|
||||
for analytics that can be easily modified or replaced in the future.
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
key = getattr(settings, "ANALYTICS_KEY", None)
|
||||
|
||||
if key is not None:
|
||||
jAnalytics.write_key = key
|
||||
|
||||
self._enabled = key is not None
|
||||
|
||||
def _is_anonymous_user(self, user):
|
||||
"""Check if the user is anonymous."""
|
||||
return user is None or user.is_anonymous
|
||||
|
||||
def identify(self, user, **kwargs):
|
||||
"""Identify a user"""
|
||||
|
||||
if self._is_anonymous_user(user) or not self._enabled:
|
||||
return
|
||||
|
||||
traits = kwargs.pop("traits", {})
|
||||
traits.update({"email": user.email_anonymized})
|
||||
|
||||
jAnalytics.identify(user_id=user.sub, traits=traits, **kwargs)
|
||||
|
||||
def track(self, user, **kwargs):
|
||||
"""Track an event"""
|
||||
|
||||
if not self._enabled:
|
||||
return
|
||||
|
||||
event_data = {}
|
||||
if self._is_anonymous_user(user):
|
||||
event_data["anonymous_id"] = str(uuid.uuid4())
|
||||
else:
|
||||
event_data["user_id"] = user.sub
|
||||
|
||||
jAnalytics.track(**event_data, **kwargs)
|
||||
|
||||
|
||||
analytics = Analytics()
|
||||
@@ -1,4 +1,5 @@
|
||||
"""Meet core API endpoints"""
|
||||
|
||||
from django.conf import settings
|
||||
from django.core.exceptions import ValidationError
|
||||
|
||||
@@ -22,6 +23,8 @@ def exception_handler(exc, context):
|
||||
detail = exc.message
|
||||
elif hasattr(exc, "messages"):
|
||||
detail = exc.messages
|
||||
else:
|
||||
detail = ""
|
||||
|
||||
exc = drf_exceptions.ValidationError(detail=detail)
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
"""Permission handlers for the Meet core app."""
|
||||
|
||||
from rest_framework import permissions
|
||||
|
||||
from ..models import RoleChoices
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
"""Client serializers for the Meet core app."""
|
||||
|
||||
from django.conf import settings
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
"""API endpoints"""
|
||||
|
||||
import uuid
|
||||
|
||||
from django.conf import settings
|
||||
@@ -19,6 +20,7 @@ from rest_framework import (
|
||||
|
||||
from core import models, utils
|
||||
|
||||
from ..analytics import analytics
|
||||
from . import permissions, serializers
|
||||
|
||||
# pylint: disable=too-many-ancestors
|
||||
@@ -184,6 +186,13 @@ class RoomViewSet(
|
||||
"""
|
||||
try:
|
||||
instance = self.get_object()
|
||||
|
||||
analytics.track(
|
||||
user=self.request.user,
|
||||
event="Get Room",
|
||||
properties={"slug": instance.slug},
|
||||
)
|
||||
|
||||
except Http404:
|
||||
if not settings.ALLOW_UNREGISTERED_ROOMS:
|
||||
raise
|
||||
@@ -232,6 +241,14 @@ class RoomViewSet(
|
||||
role=models.RoleChoices.OWNER,
|
||||
)
|
||||
|
||||
analytics.track(
|
||||
user=self.request.user,
|
||||
event="Create Room",
|
||||
properties={
|
||||
"slug": room.slug,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
class ResourceAccessListModelMixin:
|
||||
"""List mixin for resource access API."""
|
||||
|
||||
@@ -10,6 +10,8 @@ from mozilla_django_oidc.auth import (
|
||||
|
||||
from core.models import User
|
||||
|
||||
from ..analytics import analytics
|
||||
|
||||
|
||||
class OIDCAuthenticationBackend(MozillaOIDCAuthenticationBackend):
|
||||
"""Custom OpenID Connect (OIDC) Authentication Backend.
|
||||
@@ -79,6 +81,7 @@ class OIDCAuthenticationBackend(MozillaOIDCAuthenticationBackend):
|
||||
else:
|
||||
user = None
|
||||
|
||||
analytics.identify(user=user)
|
||||
return user
|
||||
|
||||
def create_user(self, claims):
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"""Authentication Views for the People core app."""
|
||||
|
||||
import copy
|
||||
from urllib.parse import urlencode
|
||||
|
||||
from django.contrib import auth
|
||||
@@ -11,10 +12,18 @@ from django.utils import crypto
|
||||
from mozilla_django_oidc.utils import (
|
||||
absolutify,
|
||||
)
|
||||
from mozilla_django_oidc.views import (
|
||||
OIDCAuthenticationCallbackView as MozillaOIDCAuthenticationCallbackView,
|
||||
)
|
||||
from mozilla_django_oidc.views import (
|
||||
OIDCAuthenticationRequestView as MozillaOIDCAuthenticationRequestView,
|
||||
)
|
||||
from mozilla_django_oidc.views import (
|
||||
OIDCLogoutView as MozillaOIDCOIDCLogoutView,
|
||||
)
|
||||
|
||||
from ..analytics import analytics
|
||||
|
||||
|
||||
class OIDCLogoutView(MozillaOIDCOIDCLogoutView):
|
||||
"""Custom logout view for handling OpenID Connect (OIDC) logout flow.
|
||||
@@ -91,6 +100,10 @@ class OIDCLogoutView(MozillaOIDCOIDCLogoutView):
|
||||
|
||||
logout_url = self.redirect_url
|
||||
|
||||
analytics.track(
|
||||
user=request.user,
|
||||
event="Signed Out",
|
||||
)
|
||||
if request.user.is_authenticated:
|
||||
logout_url = self.construct_oidc_logout_url(request)
|
||||
|
||||
@@ -135,3 +148,40 @@ class OIDCLogoutCallbackView(MozillaOIDCOIDCLogoutView):
|
||||
auth.logout(request)
|
||||
|
||||
return HttpResponseRedirect(self.redirect_url)
|
||||
|
||||
|
||||
class OIDCAuthenticationCallbackView(MozillaOIDCAuthenticationCallbackView):
|
||||
"""Custom callback view for handling the silent loging flow."""
|
||||
|
||||
@property
|
||||
def failure_url(self):
|
||||
"""Override the failure URL property to handle silent login flow
|
||||
|
||||
A silent login failure (e.g., no active user session) should not be
|
||||
considered as an authentication failure.
|
||||
"""
|
||||
if self.request.session.get("silent", None):
|
||||
del self.request.session["silent"]
|
||||
self.request.session.save()
|
||||
return self.success_url
|
||||
return super().failure_url
|
||||
|
||||
|
||||
class OIDCAuthenticationRequestView(MozillaOIDCAuthenticationRequestView):
|
||||
"""Custom authentication view for handling the silent loging flow."""
|
||||
|
||||
def get_extra_params(self, request):
|
||||
"""Handle 'prompt' extra parameter for the silent login flow
|
||||
|
||||
This extra parameter is necessary to distinguish between a standard
|
||||
authentication flow and the silent login flow.
|
||||
"""
|
||||
extra_params = self.get_settings("OIDC_AUTH_REQUEST_EXTRA_PARAMS", None)
|
||||
if extra_params is None:
|
||||
extra_params = {}
|
||||
if request.GET.get("silent") == "true":
|
||||
extra_params = copy.deepcopy(extra_params)
|
||||
extra_params.update({"prompt": "none"})
|
||||
request.session["silent"] = True
|
||||
request.session.save()
|
||||
return extra_params
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"""
|
||||
Core application enums declaration
|
||||
"""
|
||||
|
||||
from django.conf import global_settings, settings
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
"""
|
||||
Core application factories
|
||||
"""
|
||||
|
||||
from django.conf import settings
|
||||
from django.contrib.auth.hashers import make_password
|
||||
from django.utils.text import slugify
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"""
|
||||
Declare and configure the models for the Meet core application
|
||||
"""
|
||||
|
||||
import uuid
|
||||
from logging import getLogger
|
||||
|
||||
@@ -162,6 +163,13 @@ class User(AbstractBaseUser, BaseModel, auth_models.PermissionsMixin):
|
||||
"""
|
||||
return []
|
||||
|
||||
@property
|
||||
def email_anonymized(self):
|
||||
"""Anonymize the email address by replacing the local part with asterisks."""
|
||||
if not self.email:
|
||||
return ""
|
||||
return f"***@{self.email.split('@')[1]}"
|
||||
|
||||
|
||||
class Resource(BaseModel):
|
||||
"""Model to define access control"""
|
||||
|
||||
@@ -92,9 +92,12 @@ def test_models_oidc_user_getter_invalid_token(django_assert_num_queries, monkey
|
||||
|
||||
monkeypatch.setattr(OIDCAuthenticationBackend, "get_userinfo", get_userinfo_mocked)
|
||||
|
||||
with django_assert_num_queries(0), pytest.raises(
|
||||
SuspiciousOperation,
|
||||
match="User info contained no recognizable user identification",
|
||||
with (
|
||||
django_assert_num_queries(0),
|
||||
pytest.raises(
|
||||
SuspiciousOperation,
|
||||
match="User info contained no recognizable user identification",
|
||||
),
|
||||
):
|
||||
klass.get_or_create_user(access_token="test-token", id_token=None, payload=None)
|
||||
|
||||
|
||||
@@ -15,7 +15,13 @@ import pytest
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
from core import factories
|
||||
from core.authentication.views import OIDCLogoutCallbackView, OIDCLogoutView
|
||||
from core.authentication.views import (
|
||||
MozillaOIDCAuthenticationCallbackView,
|
||||
OIDCAuthenticationCallbackView,
|
||||
OIDCAuthenticationRequestView,
|
||||
OIDCLogoutCallbackView,
|
||||
OIDCLogoutView,
|
||||
)
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
@@ -229,3 +235,125 @@ def test_view_logout_callback():
|
||||
|
||||
assert response.status_code == 302
|
||||
assert response.url == "/example-logout"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("mocked_extra_params_setting", [{"foo": 123}, {}, None])
|
||||
def test_view_authentication_default(settings, mocked_extra_params_setting):
|
||||
"""By default, authentication request should not trigger silent login."""
|
||||
|
||||
settings.OIDC_AUTH_REQUEST_EXTRA_PARAMS = mocked_extra_params_setting
|
||||
|
||||
user = factories.UserFactory()
|
||||
|
||||
request = RequestFactory().request()
|
||||
request.user = user
|
||||
request.GET = {}
|
||||
|
||||
view = OIDCAuthenticationRequestView()
|
||||
extra_params = view.get_extra_params(request)
|
||||
|
||||
assert extra_params == (mocked_extra_params_setting or {})
|
||||
|
||||
|
||||
@pytest.mark.parametrize("mocked_extra_params_setting", [{"foo": 123}, {}, None])
|
||||
def test_view_authentication_silent_false(settings, mocked_extra_params_setting):
|
||||
"""Ensure setting 'silent' parameter to a random value doesn't trigger the silent login flow."""
|
||||
|
||||
settings.OIDC_AUTH_REQUEST_EXTRA_PARAMS = mocked_extra_params_setting
|
||||
|
||||
user = factories.UserFactory()
|
||||
|
||||
request = RequestFactory().request()
|
||||
request.user = user
|
||||
request.GET = {"silent": "foo"}
|
||||
|
||||
middleware = SessionMiddleware(get_response=lambda x: x)
|
||||
middleware.process_request(request)
|
||||
|
||||
view = OIDCAuthenticationRequestView()
|
||||
extra_params = view.get_extra_params(request)
|
||||
|
||||
assert extra_params == (mocked_extra_params_setting or {})
|
||||
assert not request.session.get("silent")
|
||||
|
||||
|
||||
@pytest.mark.parametrize("mocked_extra_params_setting", [{"foo": 123}, {}, None])
|
||||
def test_view_authentication_silent_true(settings, mocked_extra_params_setting):
|
||||
"""If 'silent' parameter is set to True, the silent login should be triggered."""
|
||||
settings.OIDC_AUTH_REQUEST_EXTRA_PARAMS = mocked_extra_params_setting
|
||||
|
||||
user = factories.UserFactory()
|
||||
|
||||
request = RequestFactory().request()
|
||||
request.user = user
|
||||
request.GET = {"silent": "true"}
|
||||
|
||||
middleware = SessionMiddleware(get_response=lambda x: x)
|
||||
middleware.process_request(request)
|
||||
|
||||
view = OIDCAuthenticationRequestView()
|
||||
extra_params = view.get_extra_params(request)
|
||||
expected_params = {"prompt": "none"}
|
||||
|
||||
assert (
|
||||
extra_params == {**mocked_extra_params_setting, **expected_params}
|
||||
if mocked_extra_params_setting
|
||||
else expected_params
|
||||
)
|
||||
assert request.session.get("silent") is True
|
||||
|
||||
|
||||
@mock.patch.object(
|
||||
MozillaOIDCAuthenticationCallbackView,
|
||||
"failure_url",
|
||||
new_callable=mock.PropertyMock,
|
||||
return_value="foo",
|
||||
)
|
||||
def test_view_callback_failure_url(mocked_failure_url):
|
||||
"""Test default behavior of the 'failure_url' property"""
|
||||
|
||||
user = factories.UserFactory()
|
||||
|
||||
request = RequestFactory().request()
|
||||
request.user = user
|
||||
|
||||
middleware = SessionMiddleware(get_response=lambda x: x)
|
||||
middleware.process_request(request)
|
||||
|
||||
view = OIDCAuthenticationCallbackView()
|
||||
view.request = request
|
||||
|
||||
returned_url = view.failure_url
|
||||
|
||||
mocked_failure_url.assert_called_once()
|
||||
assert returned_url == "foo"
|
||||
|
||||
|
||||
@mock.patch.object(
|
||||
OIDCAuthenticationCallbackView,
|
||||
"success_url",
|
||||
new_callable=mock.PropertyMock,
|
||||
return_value="foo",
|
||||
)
|
||||
def test_view_callback_failure_url_silent_login(mocked_success_url):
|
||||
"""If a silent login was initiated and failed, it should not be treated as a failure."""
|
||||
|
||||
user = factories.UserFactory()
|
||||
|
||||
request = RequestFactory().request()
|
||||
request.user = user
|
||||
|
||||
middleware = SessionMiddleware(get_response=lambda x: x)
|
||||
middleware.process_request(request)
|
||||
|
||||
request.session["silent"] = True
|
||||
request.session.save()
|
||||
|
||||
view = OIDCAuthenticationCallbackView()
|
||||
view.request = request
|
||||
|
||||
returned_url = view.failure_url
|
||||
|
||||
mocked_success_url.assert_called_once()
|
||||
assert returned_url == "foo"
|
||||
assert not request.session.get("silent")
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
"""Fixtures for tests in the Meet core application"""
|
||||
|
||||
from unittest import mock
|
||||
|
||||
import pytest
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"""
|
||||
Test rooms API endpoints in the Meet core app: create.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"""
|
||||
Test rooms API endpoints in the Meet core app: delete.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"""
|
||||
Test rooms API endpoints in the Meet core app: list.
|
||||
"""
|
||||
|
||||
from unittest import mock
|
||||
|
||||
import pytest
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"""
|
||||
Test rooms API endpoints in the Meet core app: retrieve.
|
||||
"""
|
||||
|
||||
import random
|
||||
from unittest import mock
|
||||
|
||||
@@ -157,7 +158,7 @@ def test_api_rooms_retrieve_anonymous_unregistered_not_allowed():
|
||||
response = client.get("/api/v1.0/rooms/unregistered-room/")
|
||||
|
||||
assert response.status_code == 404
|
||||
assert response.json() == {"detail": "Not found."}
|
||||
assert response.json() == {"detail": "No Room matches the given query."}
|
||||
|
||||
|
||||
@mock.patch("core.utils.generate_token", return_value="foo")
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"""
|
||||
Test rooms API endpoints in the Meet core app: update.
|
||||
"""
|
||||
|
||||
import random
|
||||
|
||||
import pytest
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"""
|
||||
Test suite for generated openapi schema.
|
||||
"""
|
||||
|
||||
import json
|
||||
from io import StringIO
|
||||
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
"""
|
||||
Test for the Analytics class.
|
||||
"""
|
||||
|
||||
# pylint: disable=W0212
|
||||
|
||||
from unittest.mock import patch
|
||||
|
||||
from django.contrib.auth.models import AnonymousUser
|
||||
from django.test.utils import override_settings
|
||||
|
||||
import pytest
|
||||
|
||||
from core.analytics import Analytics
|
||||
from core.factories import UserFactory
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
|
||||
@pytest.fixture(name="mock_june_analytics")
|
||||
def _mock_june_analytics():
|
||||
with patch("core.analytics.jAnalytics") as mock:
|
||||
yield mock
|
||||
|
||||
|
||||
@override_settings(ANALYTICS_KEY="test_key")
|
||||
def test_analytics_init_enabled(mock_june_analytics):
|
||||
"""Should enable analytics and set the write key correctly when ANALYTICS_KEY is set."""
|
||||
analytics = Analytics()
|
||||
assert analytics._enabled is True
|
||||
assert mock_june_analytics.write_key == "test_key"
|
||||
|
||||
|
||||
@override_settings(ANALYTICS_KEY=None)
|
||||
def test_analytics_init_disabled():
|
||||
"""Should disable analytics when ANALYTICS_KEY is not set."""
|
||||
analytics = Analytics()
|
||||
assert analytics._enabled is False
|
||||
|
||||
|
||||
@override_settings(ANALYTICS_KEY="test_key")
|
||||
def test_analytics_identify_user(mock_june_analytics):
|
||||
"""Should identify a user with the correct traits when analytics is enabled."""
|
||||
user = UserFactory(sub="12345", email="user@example.com")
|
||||
analytics = Analytics()
|
||||
analytics.identify(user)
|
||||
mock_june_analytics.identify.assert_called_once_with(
|
||||
user_id="12345", traits={"email": "***@example.com"}
|
||||
)
|
||||
|
||||
|
||||
@override_settings(ANALYTICS_KEY="test_key")
|
||||
def test_analytics_identify_user_with_traits(mock_june_analytics):
|
||||
"""Should identify a user with additional traits when analytics is enabled."""
|
||||
user = UserFactory(sub="12345", email="user@example.com")
|
||||
analytics = Analytics()
|
||||
analytics.identify(user, traits={"email": "user@example.com", "foo": "foo"})
|
||||
mock_june_analytics.identify.assert_called_once_with(
|
||||
user_id="12345", traits={"email": "***@example.com", "foo": "foo"}
|
||||
)
|
||||
|
||||
|
||||
@override_settings(ANALYTICS_KEY=None)
|
||||
def test_analytics_identify_not_enabled(mock_june_analytics):
|
||||
"""Should not call identify when analytics is not enabled."""
|
||||
user = UserFactory(sub="12345", email="user@example.com")
|
||||
analytics = Analytics()
|
||||
analytics.identify(user)
|
||||
mock_june_analytics.identify.assert_not_called()
|
||||
|
||||
|
||||
@override_settings(ANALYTICS_KEY="test_key")
|
||||
def test_analytics_identify_no_user(mock_june_analytics):
|
||||
"""Should not call identify when the user is None."""
|
||||
analytics = Analytics()
|
||||
analytics.identify(None)
|
||||
mock_june_analytics.identify.assert_not_called()
|
||||
|
||||
|
||||
@override_settings(ANALYTICS_KEY="test_key")
|
||||
def test_analytics_identify_anonymous_user(mock_june_analytics):
|
||||
"""Should not call identify when the user is anonymous."""
|
||||
user = AnonymousUser()
|
||||
analytics = Analytics()
|
||||
analytics.identify(user)
|
||||
mock_june_analytics.identify.assert_not_called()
|
||||
|
||||
|
||||
@override_settings(ANALYTICS_KEY="test_key")
|
||||
def test_analytics_track_event(mock_june_analytics):
|
||||
"""Should track an event with the correct user and event details when analytics is enabled."""
|
||||
user = UserFactory(sub="12345")
|
||||
analytics = Analytics()
|
||||
analytics.track(user, event="test_event", foo="foo")
|
||||
mock_june_analytics.track.assert_called_once_with(
|
||||
user_id="12345", event="test_event", foo="foo"
|
||||
)
|
||||
|
||||
|
||||
@override_settings(ANALYTICS_KEY=None)
|
||||
def test_analytics_track_event_not_enabled(mock_june_analytics):
|
||||
"""Should not call track when analytics is not enabled."""
|
||||
user = UserFactory(sub="12345")
|
||||
analytics = Analytics()
|
||||
analytics.track(user, event="test_event", foo="foo")
|
||||
|
||||
mock_june_analytics.track.assert_not_called()
|
||||
|
||||
|
||||
@override_settings(ANALYTICS_KEY="test_key")
|
||||
@patch("uuid.uuid4", return_value="test_uuid4")
|
||||
def test_analytics_track_event_no_user(mock_uuid4, mock_june_analytics):
|
||||
"""Should track an event with a random anonymous user ID when the user is None."""
|
||||
analytics = Analytics()
|
||||
analytics.track(None, event="test_event", foo="foo")
|
||||
mock_june_analytics.track.assert_called_once_with(
|
||||
anonymous_id="test_uuid4", event="test_event", foo="foo"
|
||||
)
|
||||
mock_uuid4.assert_called_once()
|
||||
|
||||
|
||||
@override_settings(ANALYTICS_KEY="test_key")
|
||||
@patch("uuid.uuid4", return_value="test_uuid4")
|
||||
def test_analytics_track_event_anonymous_user(mock_uuid4, mock_june_analytics):
|
||||
"""Should track an event with a random anonymous user ID when the user is anonymous."""
|
||||
user = AnonymousUser()
|
||||
analytics = Analytics()
|
||||
analytics.track(user, event="test_event", foo="foo")
|
||||
mock_june_analytics.track.assert_called_once_with(
|
||||
anonymous_id="test_uuid4", event="test_event", foo="foo"
|
||||
)
|
||||
mock_uuid4.assert_called_once()
|
||||
@@ -1,6 +1,7 @@
|
||||
"""
|
||||
Test resource accesses API endpoints in the Meet core app.
|
||||
"""
|
||||
|
||||
import random
|
||||
from unittest import mock
|
||||
from uuid import uuid4
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"""
|
||||
Test users API endpoints in the Meet core app.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"""
|
||||
Unit tests for the ResourceAccess model with user
|
||||
"""
|
||||
|
||||
from django.core.exceptions import ValidationError
|
||||
|
||||
import pytest
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"""
|
||||
Unit tests for the Room model
|
||||
"""
|
||||
|
||||
from django.contrib.auth.models import AnonymousUser
|
||||
from django.core.exceptions import ValidationError
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"""
|
||||
Unit tests for the User model
|
||||
"""
|
||||
|
||||
from unittest import mock
|
||||
|
||||
from django.core.exceptions import ValidationError
|
||||
@@ -43,3 +44,12 @@ def test_models_users_send_mail_main_missing():
|
||||
user.email_user("my subject", "my message")
|
||||
|
||||
assert str(excinfo.value) == "User has no email address."
|
||||
|
||||
|
||||
def test_models_users_email_anonymized():
|
||||
"""The user's email should be anonymized if it exists."""
|
||||
user = factories.UserFactory(email="john.doe@world.com")
|
||||
assert user.email_anonymized == "***@world.com"
|
||||
|
||||
user = factories.UserFactory(email=None)
|
||||
assert user.email_anonymized == ""
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
"""URL configuration for the core app."""
|
||||
|
||||
from django.conf import settings
|
||||
from django.urls import include, path
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"""
|
||||
Utils functions used in the core app
|
||||
"""
|
||||
|
||||
from typing import Optional
|
||||
from uuid import uuid4
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
"""Management user to create a superuser."""
|
||||
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.core.management.base import BaseCommand
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
"""
|
||||
meet's sandbox management script.
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
"""Meet celery configuration file."""
|
||||
|
||||
import os
|
||||
|
||||
from celery import Celery
|
||||
|
||||
@@ -9,6 +9,7 @@ https://docs.djangoproject.com/en/3.1/topics/settings/
|
||||
For the full list of settings and their values, see
|
||||
https://docs.djangoproject.com/en/3.1/ref/settings/
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
|
||||
@@ -284,6 +285,8 @@ class Base(Configuration):
|
||||
SESSION_COOKIE_AGE = 60 * 60 * 12
|
||||
|
||||
# OIDC - Authorization Code Flow
|
||||
OIDC_AUTHENTICATE_CLASS = "core.authentication.views.OIDCAuthenticationRequestView"
|
||||
OIDC_CALLBACK_CLASS = "core.authentication.views.OIDCAuthenticationCallbackView"
|
||||
OIDC_CREATE_USER = values.BooleanValue(
|
||||
default=True,
|
||||
environ_name="OIDC_CREATE_USER",
|
||||
@@ -344,6 +347,9 @@ class Base(Configuration):
|
||||
ALLOW_LOGOUT_GET_METHOD = values.BooleanValue(
|
||||
default=True, environ_name="ALLOW_LOGOUT_GET_METHOD", environ_prefix=None
|
||||
)
|
||||
OIDC_REDIRECT_FIELD_NAME = values.Value(
|
||||
"returnTo", environ_name="OIDC_REDIRECT_FIELD_NAME", environ_prefix=None
|
||||
)
|
||||
|
||||
# Video conference configuration
|
||||
LIVEKIT_CONFIGURATION = {
|
||||
@@ -359,6 +365,9 @@ class Base(Configuration):
|
||||
ALLOW_UNREGISTERED_ROOMS = values.BooleanValue(
|
||||
True, environ_name="ALLOW_UNREGISTERED_ROOMS", environ_prefix=None
|
||||
)
|
||||
ANALYTICS_KEY = values.Value(
|
||||
None, environ_name="ANALYTICS_KEY", environ_prefix=None
|
||||
)
|
||||
|
||||
# pylint: disable=invalid-name
|
||||
@property
|
||||
@@ -479,6 +488,8 @@ class Test(Base):
|
||||
|
||||
CELERY_TASK_ALWAYS_EAGER = values.BooleanValue(True)
|
||||
|
||||
ANALYTICS_KEY = None
|
||||
|
||||
def __init__(self):
|
||||
# pylint: disable=invalid-name
|
||||
self.INSTALLED_APPS += ["drf_spectacular_sidecar"]
|
||||
|
||||
+40
-39
@@ -7,7 +7,7 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "meet"
|
||||
version = "0.1.1"
|
||||
version = "0.1.3"
|
||||
authors = [{ "name" = "DINUM", "email" = "dev@mail.numerique.gouv.fr" }]
|
||||
classifiers = [
|
||||
"Development Status :: 5 - Production/Stable",
|
||||
@@ -19,44 +19,45 @@ classifiers = [
|
||||
"Programming Language :: Python :: 3",
|
||||
"Programming Language :: Python :: 3.10",
|
||||
]
|
||||
description = "An application to print markdown to pdf from a set of managed templates."
|
||||
description = "A simple video and phone conferencing tool, powered by LiveKit"
|
||||
keywords = ["Django", "Contacts", "Templates", "RBAC"]
|
||||
license = { file = "LICENSE" }
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
dependencies = [
|
||||
"boto3==1.33.6",
|
||||
"boto3==1.34.153",
|
||||
"Brotli==1.1.0",
|
||||
"celery[redis]==5.3.6",
|
||||
"django-configurations==2.5",
|
||||
"django-cors-headers==4.3.1",
|
||||
"django-countries==7.5.1",
|
||||
"celery[redis]==5.4.0",
|
||||
"django-configurations==2.5.1",
|
||||
"django-cors-headers==4.4.0",
|
||||
"django-countries==7.6.1",
|
||||
"django-parler==2.3",
|
||||
"redis==5.0.3",
|
||||
"redis==5.0.8",
|
||||
"django-redis==5.4.0",
|
||||
"django-storages[s3]==1.14.2",
|
||||
"django-storages[s3]==1.14.4",
|
||||
"django-timezone-field>=5.1",
|
||||
"django==5.0.3",
|
||||
"djangorestframework==3.14.0",
|
||||
"drf_spectacular==0.26.5",
|
||||
"dockerflow==2022.8.0",
|
||||
"easy_thumbnails==2.8.5",
|
||||
"django==5.0.7",
|
||||
"djangorestframework==3.15.2",
|
||||
"drf_spectacular==0.27.2",
|
||||
"dockerflow==2024.4.2",
|
||||
"easy_thumbnails==2.9",
|
||||
"factory_boy==3.3.0",
|
||||
"freezegun==1.5.0",
|
||||
"freezegun==1.5.1",
|
||||
"gunicorn==22.0.0",
|
||||
"jsonschema==4.20.0",
|
||||
"markdown==3.5.1",
|
||||
"jsonschema==4.23.0",
|
||||
"june-analytics-python==2.3.0",
|
||||
"markdown==3.6",
|
||||
"nested-multipart-parser==1.5.0",
|
||||
"psycopg[binary]==3.1.14",
|
||||
"PyJWT==2.8.0",
|
||||
"python-frontmatter==1.0.1",
|
||||
"requests==2.31.0",
|
||||
"sentry-sdk==1.38.0",
|
||||
"psycopg[binary]==3.2.1",
|
||||
"PyJWT==2.9.0",
|
||||
"python-frontmatter==1.1.0",
|
||||
"requests==2.32.3",
|
||||
"sentry-sdk==2.12.0",
|
||||
"url-normalize==1.4.3",
|
||||
"WeasyPrint>=60.2",
|
||||
"whitenoise==6.6.0",
|
||||
"mozilla-django-oidc==4.0.0",
|
||||
"livekit-api==0.5.1",
|
||||
"whitenoise==6.7.0",
|
||||
"mozilla-django-oidc==4.0.1",
|
||||
"livekit-api==0.6.0",
|
||||
]
|
||||
|
||||
[project.urls]
|
||||
@@ -68,20 +69,20 @@ dependencies = [
|
||||
[project.optional-dependencies]
|
||||
dev = [
|
||||
"django-extensions==3.2.3",
|
||||
"drf-spectacular-sidecar==2023.12.1",
|
||||
"drf-spectacular-sidecar==2024.7.1",
|
||||
"ipdb==0.13.13",
|
||||
"ipython==8.18.1",
|
||||
"pyfakefs==5.3.2",
|
||||
"ipython==8.26.0",
|
||||
"pyfakefs==5.6.0",
|
||||
"pylint-django==2.5.5",
|
||||
"pylint==3.0.3",
|
||||
"pytest-cov==4.1.0",
|
||||
"pytest-django==4.7.0",
|
||||
"pytest==7.4.3",
|
||||
"pytest-icdiff==0.8",
|
||||
"pytest-xdist==3.5.0",
|
||||
"responses==0.24.1",
|
||||
"ruff==0.1.6",
|
||||
"types-requests==2.31.0.10",
|
||||
"pylint==3.2.6",
|
||||
"pytest-cov==5.0.0",
|
||||
"pytest-django==4.8.0",
|
||||
"pytest==8.3.2",
|
||||
"pytest-icdiff==0.9",
|
||||
"pytest-xdist==3.6.1",
|
||||
"responses==0.25.3",
|
||||
"ruff==0.5.6",
|
||||
"types-requests==2.32.0.20240712",
|
||||
]
|
||||
|
||||
[tool.setuptools]
|
||||
@@ -100,7 +101,6 @@ exclude = [
|
||||
"__pycache__",
|
||||
"*/migrations/*",
|
||||
]
|
||||
ignore= ["DJ001", "PLR2004"]
|
||||
line-length = 88
|
||||
|
||||
|
||||
@@ -121,12 +121,13 @@ select = [
|
||||
"SLF", # flake8-self
|
||||
"T20", # flake8-print
|
||||
]
|
||||
ignore= ["DJ001", "PLR2004"]
|
||||
|
||||
[tool.ruff.lint.isort]
|
||||
section-order = ["future","standard-library","django","third-party","meet","first-party","local-folder"]
|
||||
sections = { meet=["core"], django=["django"] }
|
||||
|
||||
[tool.ruff.per-file-ignores]
|
||||
[tool.ruff.lint.per-file-ignores]
|
||||
"**/tests/*" = ["S", "SLF"]
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
|
||||
@@ -1,7 +0,0 @@
|
||||
#!/usr/bin/env python
|
||||
"""Setup file for the meet module. All configuration stands in the setup.cfg file."""
|
||||
# coding: utf-8
|
||||
|
||||
from setuptools import setup
|
||||
|
||||
setup()
|
||||
Generated
+61
-5
@@ -1,16 +1,17 @@
|
||||
{
|
||||
"name": "meet",
|
||||
"version": "0.1.1",
|
||||
"version": "0.1.3",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "meet",
|
||||
"version": "0.1.1",
|
||||
"version": "0.1.3",
|
||||
"dependencies": {
|
||||
"@livekit/components-react": "2.3.3",
|
||||
"@livekit/components-styles": "1.0.12",
|
||||
"@pandacss/preset-panda": "0.41.0",
|
||||
"@remixicon/react": "4.2.0",
|
||||
"@tanstack/react-query": "5.49.2",
|
||||
"hoofd": "1.7.1",
|
||||
"i18next": "23.12.1",
|
||||
@@ -22,6 +23,7 @@
|
||||
"react-aria-components": "1.2.1",
|
||||
"react-dom": "18.2.0",
|
||||
"react-i18next": "14.1.3",
|
||||
"valtio": "1.13.2",
|
||||
"wouter": "3.3.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -3177,6 +3179,14 @@
|
||||
"react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@remixicon/react": {
|
||||
"version": "4.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@remixicon/react/-/react-4.2.0.tgz",
|
||||
"integrity": "sha512-eGhKpZ88OU0qkcY9pJu6khBmItDV82nU130E6C68yc+FbljueHlUYy/4CrJsmf860RIDMay2Rpzl27OSJ81miw==",
|
||||
"peerDependencies": {
|
||||
"react": ">=18.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@rollup/rollup-android-arm-eabi": {
|
||||
"version": "4.18.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.18.0.tgz",
|
||||
@@ -3634,13 +3644,13 @@
|
||||
"version": "15.7.12",
|
||||
"resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.12.tgz",
|
||||
"integrity": "sha512-5zvhXYtRNRluoE/jAp4GVsSduVUzNWKkOZrCDBWYtE7biZywwdC2AcEzg+cSMLFRfVgeAFqpfNabiPjxFddV1Q==",
|
||||
"dev": true
|
||||
"devOptional": true
|
||||
},
|
||||
"node_modules/@types/react": {
|
||||
"version": "18.3.3",
|
||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.3.tgz",
|
||||
"integrity": "sha512-hti/R0pS0q1/xx+TsI73XIqk26eBsISZ2R0wUijXIngRK9R/e7Xw/cXVxQK7R5JjW+SV4zGcn5hXjudkN/pLIw==",
|
||||
"dev": true,
|
||||
"devOptional": true,
|
||||
"dependencies": {
|
||||
"@types/prop-types": "*",
|
||||
"csstype": "^3.0.2"
|
||||
@@ -5125,7 +5135,7 @@
|
||||
"version": "3.1.3",
|
||||
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz",
|
||||
"integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==",
|
||||
"dev": true
|
||||
"devOptional": true
|
||||
},
|
||||
"node_modules/damerau-levenshtein": {
|
||||
"version": "1.0.8",
|
||||
@@ -5273,6 +5283,14 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/derive-valtio": {
|
||||
"version": "0.1.0",
|
||||
"resolved": "https://registry.npmjs.org/derive-valtio/-/derive-valtio-0.1.0.tgz",
|
||||
"integrity": "sha512-OCg2UsLbXK7GmmpzMXhYkdO64vhJ1ROUUGaTFyHjVwEdMEcTTRj7W1TxLbSBxdY8QLBPCcp66MTyaSy0RpO17A==",
|
||||
"peerDependencies": {
|
||||
"valtio": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/detect-libc": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-1.0.3.tgz",
|
||||
@@ -8928,6 +8946,11 @@
|
||||
"node": "10.* || >= 12.*"
|
||||
}
|
||||
},
|
||||
"node_modules/proxy-compare": {
|
||||
"version": "2.6.0",
|
||||
"resolved": "https://registry.npmjs.org/proxy-compare/-/proxy-compare-2.6.0.tgz",
|
||||
"integrity": "sha512-8xuCeM3l8yqdmbPoYeLbrAXCBWu19XEYc5/F28f5qOaoAIMyfmBUkl5axiK+x9olUvRlcekvnm98AP9RDngOIw=="
|
||||
},
|
||||
"node_modules/punycode": {
|
||||
"version": "2.3.1",
|
||||
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
|
||||
@@ -10092,6 +10115,39 @@
|
||||
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
|
||||
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="
|
||||
},
|
||||
"node_modules/valtio": {
|
||||
"version": "1.13.2",
|
||||
"resolved": "https://registry.npmjs.org/valtio/-/valtio-1.13.2.tgz",
|
||||
"integrity": "sha512-Qik0o+DSy741TmkqmRfjq+0xpZBXi/Y6+fXZLn0xNF1z/waFMbE3rkivv5Zcf9RrMUp6zswf2J7sbh2KBlba5A==",
|
||||
"dependencies": {
|
||||
"derive-valtio": "0.1.0",
|
||||
"proxy-compare": "2.6.0",
|
||||
"use-sync-external-store": "1.2.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12.20.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": ">=16.8",
|
||||
"react": ">=16.8"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
},
|
||||
"react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/valtio/node_modules/use-sync-external-store": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.2.0.tgz",
|
||||
"integrity": "sha512-eEgnFxGQ1Ife9bzYs6VLi8/4X6CObHMw9Qr9tPY43iKwsPw8xE8+EFsf/2cFZ5S3esXgpWgtSCtLNS41F+sKPA==",
|
||||
"peerDependencies": {
|
||||
"react": "^16.8.0 || ^17.0.0 || ^18.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/value-or-function": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/value-or-function/-/value-or-function-4.0.0.tgz",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "meet",
|
||||
"private": true,
|
||||
"version": "0.1.1",
|
||||
"version": "0.1.3",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "panda codegen && vite",
|
||||
@@ -14,6 +14,7 @@
|
||||
"@livekit/components-react": "2.3.3",
|
||||
"@livekit/components-styles": "1.0.12",
|
||||
"@pandacss/preset-panda": "0.41.0",
|
||||
"@remixicon/react": "4.2.0",
|
||||
"@tanstack/react-query": "5.49.2",
|
||||
"hoofd": "1.7.1",
|
||||
"i18next": "23.12.1",
|
||||
@@ -25,6 +26,7 @@
|
||||
"react-aria-components": "1.2.1",
|
||||
"react-dom": "18.2.0",
|
||||
"react-i18next": "14.1.3",
|
||||
"valtio": "1.13.2",
|
||||
"wouter": "3.3.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -35,18 +35,6 @@ const config: Config = {
|
||||
exclude: [],
|
||||
jsxFramework: 'react',
|
||||
outdir: 'src/styled-system',
|
||||
conditions: {
|
||||
extend: {
|
||||
// React Aria builds upon data attributes instead of css pseudo-classes, in case we style a React Aria component
|
||||
// we dont want to trigger pseudo class related styles
|
||||
'ra-hover': '&:is([data-hovered])',
|
||||
'ra-focus': '&:is([data-focused])',
|
||||
'ra-focusVisible': '&:is([data-focus-visible])',
|
||||
'ra-disabled': '&:is([data-disabled])',
|
||||
pressed: '&:is([data-pressed])',
|
||||
'ra-pressed': '&:is([data-pressed])',
|
||||
},
|
||||
},
|
||||
theme: {
|
||||
...pandaPreset.theme,
|
||||
// media queries are defined in em so that zooming with text-only mode triggers breakpoints
|
||||
@@ -58,6 +46,19 @@ const config: Config = {
|
||||
xl: '80em', // 1280px
|
||||
'2xl': '96em', // 1536px
|
||||
},
|
||||
keyframes: {
|
||||
slide: {
|
||||
from: {
|
||||
transform: 'var(--origin)',
|
||||
opacity: 0,
|
||||
},
|
||||
to: {
|
||||
transform: 'translateY(0)',
|
||||
opacity: 1,
|
||||
},
|
||||
},
|
||||
fade: { from: { opacity: 0 }, to: { opacity: 1 } },
|
||||
},
|
||||
tokens: defineTokens({
|
||||
/* we take a few things from the panda preset but for now we clear out some stuff.
|
||||
* This way we'll only add the things we need step by step and prevent using lots of differents things.
|
||||
@@ -165,7 +166,7 @@ const config: Config = {
|
||||
colors: {
|
||||
default: {
|
||||
text: { value: '{colors.gray.900}' },
|
||||
bg: { value: '{colors.slate.50}' },
|
||||
bg: { value: 'white' },
|
||||
subtle: { value: '{colors.gray.100}' },
|
||||
'subtle-text': { value: '{colors.gray.600}' },
|
||||
},
|
||||
@@ -179,7 +180,8 @@ const config: Config = {
|
||||
hover: { value: '{colors.gray.200}' },
|
||||
active: { value: '{colors.gray.300}' },
|
||||
text: { value: '{colors.default.text}' },
|
||||
border: { value: '{colors.gray.300}' },
|
||||
border: { value: '{colors.gray.500}' },
|
||||
subtle: { value: '{colors.gray.400}' },
|
||||
},
|
||||
primary: {
|
||||
DEFAULT: { value: '{colors.blue.700}' },
|
||||
@@ -199,12 +201,13 @@ const config: Config = {
|
||||
'subtle-text': { value: '{colors.red.700}' },
|
||||
},
|
||||
success: {
|
||||
DEFAULT: { value: '{colors.emerald.700}' },
|
||||
hover: { value: '{colors.emerald.800}' },
|
||||
active: { value: '{colors.emerald.900}' },
|
||||
DEFAULT: { value: '{colors.green.700}' },
|
||||
hover: { value: '{colors.green.800}' },
|
||||
active: { value: '{colors.green.900}' },
|
||||
text: { value: '{colors.white}' },
|
||||
subtle: { value: '{colors.emerald.100}' },
|
||||
'subtle-text': { value: '{colors.emerald.700}' },
|
||||
subtle: { value: '{colors.green.100}' },
|
||||
'subtle-text': { value: '{colors.green.800}' },
|
||||
...pandaPreset.theme.tokens.colors.green,
|
||||
},
|
||||
warning: {
|
||||
DEFAULT: { value: '{colors.amber.700}' },
|
||||
@@ -214,6 +217,7 @@ const config: Config = {
|
||||
subtle: { value: '{colors.amber.100}' },
|
||||
'subtle-text': { value: '{colors.amber.700}' },
|
||||
},
|
||||
focusRing: { value: 'rgb(74, 121, 199)' },
|
||||
},
|
||||
shadows: {
|
||||
box: { value: '{shadows.sm}' },
|
||||
@@ -229,12 +233,20 @@ const config: Config = {
|
||||
DEFAULT: { value: '{spacing.1}' },
|
||||
lg: { value: '{spacing.2}' },
|
||||
},
|
||||
paragraph: { value: '{spacing.1}' },
|
||||
paragraph: { value: '{spacing.0.5}' },
|
||||
heading: { value: '{spacing.1}' },
|
||||
gutter: { value: '{spacing.1}' },
|
||||
textfield: { value: '{spacing.1}' },
|
||||
},
|
||||
}),
|
||||
textStyles: defineTextStyles({
|
||||
display: {
|
||||
value: {
|
||||
fontSize: '3rem',
|
||||
lineHeight: '2rem',
|
||||
fontWeight: 700,
|
||||
},
|
||||
},
|
||||
h1: {
|
||||
value: {
|
||||
fontSize: '1.5rem',
|
||||
@@ -253,7 +265,6 @@ const config: Config = {
|
||||
value: {
|
||||
fontSize: '1.125rem',
|
||||
lineHeight: '1.75rem',
|
||||
fontWeight: 700,
|
||||
},
|
||||
},
|
||||
body: {
|
||||
@@ -262,7 +273,7 @@ const config: Config = {
|
||||
lineHeight: '1.5',
|
||||
},
|
||||
},
|
||||
small: {
|
||||
sm: {
|
||||
value: {
|
||||
fontSize: '0.875rem',
|
||||
lineHeight: '1.25rem',
|
||||
|
||||
+20
-13
@@ -2,29 +2,36 @@ import '@livekit/components-styles'
|
||||
import '@/styles/index.css'
|
||||
import { Suspense } from 'react'
|
||||
import { ReactQueryDevtools } from '@tanstack/react-query-devtools'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { QueryClientProvider } from '@tanstack/react-query'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useLang } from 'hoofd'
|
||||
import { Route, Switch } from 'wouter'
|
||||
import { Screen } from './layout/Screen'
|
||||
import { HomeRoute } from '@/features/home'
|
||||
import { RoomRoute, roomIdRegex } from '@/features/rooms'
|
||||
import { NotFound } from './routes/NotFound'
|
||||
import { Switch, Route } from 'wouter'
|
||||
import { Layout } from './layout/Layout'
|
||||
import { NotFoundScreen } from './components/NotFoundScreen'
|
||||
import { routes } from './routes'
|
||||
import './i18n/init'
|
||||
import { silenceLiveKitLogs } from "@/utils/livekit.ts";
|
||||
import { queryClient } from "@/api/queryClient";
|
||||
|
||||
const queryClient = new QueryClient()
|
||||
|
||||
function App() {
|
||||
const { i18n } = useTranslation()
|
||||
useLang(i18n.language)
|
||||
|
||||
const isProduction = import.meta.env.PROD
|
||||
silenceLiveKitLogs(isProduction)
|
||||
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<Suspense fallback={<Screen />}>
|
||||
<Switch>
|
||||
<Route path="/" component={HomeRoute} />
|
||||
<Route path={roomIdRegex} component={RoomRoute} />
|
||||
<Route component={NotFound} />
|
||||
</Switch>
|
||||
<Suspense fallback={null}>
|
||||
<Layout>
|
||||
<Switch>
|
||||
{Object.entries(routes).map(([, route], i) => (
|
||||
<Route key={i} path={route.path} component={route.Component} />
|
||||
))}
|
||||
<Route component={NotFoundScreen} />
|
||||
</Switch>
|
||||
</Layout>
|
||||
<ReactQueryDevtools initialIsOpen={false} />
|
||||
</Suspense>
|
||||
</QueryClientProvider>
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
import { QueryClient } from "@tanstack/react-query";
|
||||
|
||||
export const queryClient = new QueryClient()
|
||||
@@ -0,0 +1,14 @@
|
||||
import { Link } from '@/primitives'
|
||||
import { AProps } from '@/primitives/A'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
export const BackToHome = ({ size }: { size?: AProps['size'] }) => {
|
||||
const { t } = useTranslation()
|
||||
return (
|
||||
<p>
|
||||
<Link to="/" size={size}>
|
||||
{t('backToHome')}
|
||||
</Link>
|
||||
</p>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { useState, useEffect, type ReactNode } from 'react'
|
||||
|
||||
export const DelayedRender = ({
|
||||
children,
|
||||
delay = 500,
|
||||
}: {
|
||||
delay?: number
|
||||
children: ReactNode
|
||||
}) => {
|
||||
const [show, setShow] = useState(false)
|
||||
useEffect(() => {
|
||||
if (delay === 0) {
|
||||
setShow(true)
|
||||
return
|
||||
}
|
||||
const timeout = setTimeout(() => setShow(true), delay)
|
||||
return () => clearTimeout(timeout)
|
||||
}, [delay])
|
||||
if (delay !== 0 && !show) {
|
||||
return null
|
||||
}
|
||||
|
||||
return children
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { CenteredContent } from '@/layout/CenteredContent'
|
||||
import { Screen } from '@/layout/Screen'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Center } from '@/styled-system/jsx'
|
||||
import { Text } from '@/primitives'
|
||||
|
||||
export const ErrorScreen = ({ title, body }: {
|
||||
title?: string
|
||||
body?: string
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
return (
|
||||
<Screen layout="centered">
|
||||
<CenteredContent title={title || t('error.heading')} withBackButton>
|
||||
{!!body && (
|
||||
<Center>
|
||||
<Text as="p" variant="h3" centered>
|
||||
{body}
|
||||
</Text>
|
||||
</Center>
|
||||
)}
|
||||
</CenteredContent>
|
||||
</Screen>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { Button } from '@/primitives'
|
||||
import { css } from '@/styled-system/css'
|
||||
import { RiExternalLinkLine } from '@remixicon/react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
export const Feedback = () => {
|
||||
const { t } = useTranslation()
|
||||
return (
|
||||
<Button
|
||||
href="https://grist.incubateur.net/o/docs/forms/1YrfNP1QSSy8p2gCxMFnSf/4"
|
||||
variant="success"
|
||||
target="_blank"
|
||||
>
|
||||
<span className={css({ marginRight: 0.5 })} aria-hidden="true">
|
||||
💡
|
||||
</span>
|
||||
{t('feedbackAlert')}
|
||||
<RiExternalLinkLine
|
||||
size={16}
|
||||
className={css({ marginLeft: 0.5 })}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { Screen, type ScreenProps } from '@/layout/Screen'
|
||||
import { DelayedRender } from './DelayedRender'
|
||||
import { CenteredContent } from '@/layout/CenteredContent'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Center } from '@/styled-system/jsx'
|
||||
|
||||
export const LoadingScreen = ({
|
||||
delay = 500,
|
||||
header = undefined,
|
||||
layout = 'centered',
|
||||
}: {
|
||||
delay?: number
|
||||
} & Omit<ScreenProps, 'children'>) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
return (
|
||||
<DelayedRender delay={delay}>
|
||||
<Screen layout={layout} header={header}>
|
||||
<CenteredContent>
|
||||
<Center>
|
||||
<p>{t('loading')}</p>
|
||||
</Center>
|
||||
</CenteredContent>
|
||||
</Screen>
|
||||
</DelayedRender>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { CenteredContent } from '@/layout/CenteredContent'
|
||||
import { Screen } from '@/layout/Screen'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
export const NotFoundScreen = () => {
|
||||
const { t } = useTranslation()
|
||||
return (
|
||||
<Screen layout="centered">
|
||||
<CenteredContent title={t('notFound.heading')} withBackButton />
|
||||
</Screen>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { ErrorScreen } from '@/components/ErrorScreen'
|
||||
import { LoadingScreen } from '@/components/LoadingScreen'
|
||||
|
||||
/**
|
||||
* Render an error or loading Screen while a given `status` is not a success,
|
||||
* otherwise directly render children.
|
||||
*
|
||||
* `status` matches react query statuses.
|
||||
*
|
||||
* Children usually contain a Screen at some point in the render tree.
|
||||
*/
|
||||
export const QueryAware = ({
|
||||
status,
|
||||
children,
|
||||
}: {
|
||||
status: 'error' | 'idle' | 'pending' | 'success'
|
||||
children: React.ReactNode
|
||||
}) => {
|
||||
if (status === 'error') {
|
||||
return <ErrorScreen />
|
||||
}
|
||||
|
||||
if (status === 'pending') {
|
||||
return <LoadingScreen header={undefined} />
|
||||
}
|
||||
|
||||
return children
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { ApiError } from '@/api/ApiError'
|
||||
import { fetchApi } from '@/api/fetchApi'
|
||||
import { type ApiUser } from './ApiUser'
|
||||
import { attemptSilentLogin, canAttemptSilentLogin } from '../utils/silentLogin'
|
||||
|
||||
/**
|
||||
* fetch the logged-in user from the api.
|
||||
@@ -16,7 +17,13 @@ export const fetchUser = (): Promise<ApiUser | false> => {
|
||||
.catch((error) => {
|
||||
// we assume that a 401 means the user is not logged in
|
||||
if (error instanceof ApiError && error.statusCode === 401) {
|
||||
resolve(false)
|
||||
// make sure to not resolve the promise while trying to silent login
|
||||
// so that consumers of fetchUser don't think the work already ended
|
||||
if (canAttemptSilentLogin()) {
|
||||
attemptSilentLogin(3600)
|
||||
} else {
|
||||
resolve(false)
|
||||
}
|
||||
} else {
|
||||
reject(error)
|
||||
}
|
||||
|
||||
@@ -1,17 +1,27 @@
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { keys } from '@/api/queryKeys'
|
||||
import { fetchUser } from './fetchUser'
|
||||
import { type ApiUser } from './ApiUser'
|
||||
|
||||
/**
|
||||
* returns info about currently logged in user
|
||||
*
|
||||
* `isLoggedIn` is undefined while query is loading and true/false when it's done
|
||||
*/
|
||||
export const useUser = () => {
|
||||
const query = useQuery({
|
||||
queryKey: [keys.user],
|
||||
queryFn: fetchUser,
|
||||
staleTime: 1000 * 60 * 60, // 1 hour
|
||||
})
|
||||
|
||||
const isLoggedIn =
|
||||
query.status === 'success' ? query.data !== false : undefined
|
||||
const isLoggedOut = isLoggedIn === false
|
||||
|
||||
return {
|
||||
...query,
|
||||
// if fetchUser returns false, it means the user is not logged in: expose that
|
||||
user: query.data === false ? undefined : query.data,
|
||||
isLoggedIn: query.data !== undefined && query.data !== false,
|
||||
user: isLoggedOut ? undefined : (query.data as ApiUser | undefined),
|
||||
isLoggedIn,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
import { useUser } from '@/features/auth'
|
||||
import { LoadingScreen } from '@/components/LoadingScreen'
|
||||
|
||||
/**
|
||||
* Renders a loading Screen while user info has not been fetched yet,
|
||||
* otherwise directly render children.
|
||||
*
|
||||
* Children usually contain a Screen at some point in the render tree.
|
||||
*
|
||||
* This is helpful to prevent flash of logged-out content for a few milliseconds when user is actually logged in
|
||||
*/
|
||||
export const UserAware = ({ children }: { children: React.ReactNode }) => {
|
||||
const { isLoggedIn } = useUser()
|
||||
|
||||
return isLoggedIn !== undefined ? (
|
||||
children
|
||||
) : (
|
||||
<LoadingScreen header={false} delay={1000} />
|
||||
)
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
export { useUser } from './api/useUser'
|
||||
export { authUrl } from './utils/authUrl'
|
||||
export { logoutUrl } from './utils/logoutUrl'
|
||||
export { UserAware } from './components/UserAware'
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import { apiUrl } from '@/api/apiUrl'
|
||||
|
||||
export const authUrl = () => {
|
||||
return apiUrl('/authenticate')
|
||||
export const authUrl = ({
|
||||
silent = false,
|
||||
returnTo = window.location.href,
|
||||
} = {}) => {
|
||||
return apiUrl(`/authenticate?silent=${encodeURIComponent(silent)}&returnTo=${encodeURIComponent(returnTo)}`)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import { authUrl } from "@/features/auth";
|
||||
|
||||
const SILENT_LOGIN_RETRY_KEY = 'silent-login-retry'
|
||||
|
||||
const isRetryAllowed = () => {
|
||||
const lastRetryDate = localStorage.getItem(SILENT_LOGIN_RETRY_KEY);
|
||||
if (!lastRetryDate) {
|
||||
return true;
|
||||
}
|
||||
const now = new Date();
|
||||
return now.getTime() > Number(lastRetryDate)
|
||||
}
|
||||
|
||||
const setNextRetryTime = (retryIntervalInSeconds: number) => {
|
||||
const now = new Date()
|
||||
const nextRetryTime = now.getTime() + (retryIntervalInSeconds * 1000);
|
||||
localStorage.setItem(SILENT_LOGIN_RETRY_KEY, String(nextRetryTime));
|
||||
}
|
||||
|
||||
const initiateSilentLogin = () => {
|
||||
window.location.href = authUrl({ silent: true })
|
||||
}
|
||||
|
||||
export const canAttemptSilentLogin = () => {
|
||||
return isRetryAllowed()
|
||||
}
|
||||
|
||||
export const attemptSilentLogin = (retryIntervalInSeconds: number) => {
|
||||
if (!isRetryAllowed()) {
|
||||
return
|
||||
}
|
||||
setNextRetryTime(retryIntervalInSeconds)
|
||||
initiateSilentLogin()
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Field, Ul, H, P, Form, Dialog } from '@/primitives'
|
||||
import { navigateTo } from '@/navigation/navigateTo'
|
||||
import { isRoomValid } from '@/features/rooms'
|
||||
|
||||
export const JoinMeetingDialog = () => {
|
||||
const { t } = useTranslation('home')
|
||||
return (
|
||||
<Dialog title={t('joinMeeting')}>
|
||||
<Form
|
||||
onSubmit={(data) => {
|
||||
navigateTo(
|
||||
'room',
|
||||
(data.roomId as string)
|
||||
.trim()
|
||||
.replace(`${window.location.origin}/`, '')
|
||||
)
|
||||
}}
|
||||
submitLabel={t('joinInputSubmit')}
|
||||
>
|
||||
<Field
|
||||
type="text"
|
||||
name="roomId"
|
||||
label={t('joinInputLabel')}
|
||||
description={t('joinInputExample', {
|
||||
example: 'https://meet.numerique.gouv.fr/azer-tyu-qsdf',
|
||||
})}
|
||||
validate={(value) => {
|
||||
return !isRoomValid(value.trim()) ? (
|
||||
<>
|
||||
<p>{t('joinInputError')}</p>
|
||||
<Ul>
|
||||
<li>{window.location.origin}/uio-azer-jkl</li>
|
||||
<li>uio-azer-jkl</li>
|
||||
</Ul>
|
||||
</>
|
||||
) : null
|
||||
}}
|
||||
/>
|
||||
</Form>
|
||||
<H lvl={2}>{t('joinMeetingTipHeading')}</H>
|
||||
<P last>{t('joinMeetingTipContent')}</P>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -1,2 +1 @@
|
||||
export { navigateToHome } from './navigation/navigateToHome'
|
||||
export { Home as HomeRoute } from './routes/Home'
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
import { navigate } from 'wouter/use-browser-location'
|
||||
|
||||
export const navigateToHome = () => {
|
||||
navigate(`/`)
|
||||
}
|
||||
@@ -1,37 +1,67 @@
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { A, Button, Italic, P, Div, H, Box } from '@/primitives'
|
||||
import { authUrl, useUser } from '@/features/auth'
|
||||
import { navigateToNewRoom } from '@/features/rooms'
|
||||
import { DialogTrigger } from 'react-aria-components'
|
||||
import { Button, Text } from '@/primitives'
|
||||
import { HStack } from '@/styled-system/jsx'
|
||||
import { navigateTo } from '@/navigation/navigateTo'
|
||||
import { Screen } from '@/layout/Screen'
|
||||
import { Centered } from '@/layout/Centered'
|
||||
import { generateRoomId } from '@/features/rooms'
|
||||
import { authUrl, useUser, UserAware } from '@/features/auth'
|
||||
import { JoinMeetingDialog } from '../components/JoinMeetingDialog'
|
||||
import { useCreateRoom } from '@/features/rooms'
|
||||
|
||||
export const Home = () => {
|
||||
const { t } = useTranslation('home')
|
||||
const { isLoggedIn } = useUser()
|
||||
|
||||
const { mutateAsync: createRoom } = useCreateRoom({
|
||||
onSuccess: (data) => {
|
||||
navigateTo('room', data.slug, {
|
||||
state: { create: true, initialRoomData: data },
|
||||
})
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<Screen>
|
||||
<Box type="screen">
|
||||
<H lvl={1}>{t('heading')}</H>
|
||||
<P>{t('intro')}</P>
|
||||
<Div marginBottom="gutter">
|
||||
<Box variant="subtle" size="sm">
|
||||
{isLoggedIn ? (
|
||||
<Button variant="primary" onPress={() => navigateToNewRoom()}>
|
||||
{t('createMeeting')}
|
||||
<UserAware>
|
||||
<Screen>
|
||||
<Centered width="fit-content">
|
||||
<Text as="h1" variant="display">
|
||||
{t('heading')}
|
||||
</Text>
|
||||
<Text as="p" variant="h3">
|
||||
{t('intro')}
|
||||
</Text>
|
||||
{!isLoggedIn && (
|
||||
<Text margin="sm" variant="note">
|
||||
{t('loginToCreateMeeting')}
|
||||
</Text>
|
||||
)}
|
||||
<HStack gap="gutter">
|
||||
<Button
|
||||
variant="primary"
|
||||
onPress={
|
||||
isLoggedIn
|
||||
? async () => {
|
||||
const slug = generateRoomId()
|
||||
await createRoom({slug})
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
href={isLoggedIn ? undefined : authUrl()}
|
||||
>
|
||||
{isLoggedIn ? t('createMeeting') : t('login', { ns: 'global' })}
|
||||
</Button>
|
||||
|
||||
<DialogTrigger>
|
||||
<Button variant="primary" outline>
|
||||
{t('joinMeeting')}
|
||||
</Button>
|
||||
) : (
|
||||
<p>
|
||||
<A href={authUrl()}>{t('login')}</A>
|
||||
</p>
|
||||
)}
|
||||
</Box>
|
||||
</Div>
|
||||
<P>
|
||||
<Italic>{t('or')}</Italic>
|
||||
</P>
|
||||
<Box variant="subtle" size="sm">
|
||||
<p>{t('copyMeetingUrl')}</p>
|
||||
</Box>
|
||||
</Box>
|
||||
</Screen>
|
||||
<JoinMeetingDialog />
|
||||
</DialogTrigger>
|
||||
</HStack>
|
||||
</Centered>
|
||||
</Screen>
|
||||
</UserAware>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -8,4 +8,7 @@ export type ApiRoom = {
|
||||
room: string
|
||||
token: string
|
||||
}
|
||||
configuration?: {
|
||||
[key: string]: string | number | boolean;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import { useMutation, UseMutationOptions } from "@tanstack/react-query";
|
||||
import { fetchApi } from '@/api/fetchApi';
|
||||
import { ApiError } from "@/api/ApiError";
|
||||
import { ApiRoom } from "./ApiRoom";
|
||||
|
||||
export interface CreateRoomParams {
|
||||
slug: string;
|
||||
}
|
||||
|
||||
const createRoom = ({slug}: CreateRoomParams): Promise<ApiRoom> => {
|
||||
return fetchApi(`rooms/`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
name: slug,
|
||||
}),
|
||||
})
|
||||
};
|
||||
|
||||
|
||||
export function useCreateRoom(options?: UseMutationOptions<ApiRoom, ApiError, CreateRoomParams>) {
|
||||
return useMutation<ApiRoom, ApiError, CreateRoomParams>({
|
||||
mutationFn: createRoom,
|
||||
onSuccess: options?.onSuccess,
|
||||
});
|
||||
}
|
||||
@@ -1,48 +1,124 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import {
|
||||
formatChatMessageLinks,
|
||||
LiveKitRoom,
|
||||
VideoConference,
|
||||
type LocalUserChoices,
|
||||
} from '@livekit/components-react'
|
||||
import { Room, RoomOptions } from 'livekit-client'
|
||||
import { keys } from '@/api/queryKeys'
|
||||
import { QueryAware } from '@/layout/QueryAware'
|
||||
import { navigateToHome } from '@/features/home'
|
||||
import { queryClient } from '@/api/queryClient'
|
||||
import { navigateTo } from '@/navigation/navigateTo'
|
||||
import { Screen } from '@/layout/Screen'
|
||||
import { QueryAware } from '@/components/QueryAware'
|
||||
import { ErrorScreen } from '@/components/ErrorScreen'
|
||||
import { fetchRoom } from '../api/fetchRoom'
|
||||
import { ApiRoom } from '../api/ApiRoom'
|
||||
import { useCreateRoom } from '../api/createRoom'
|
||||
import { InviteDialog } from './InviteDialog'
|
||||
|
||||
export const Conference = ({
|
||||
roomId,
|
||||
userConfig,
|
||||
initialRoomData,
|
||||
mode = 'join',
|
||||
}: {
|
||||
roomId: string
|
||||
userConfig: LocalUserChoices
|
||||
mode?: 'join' | 'create'
|
||||
initialRoomData?: ApiRoom
|
||||
}) => {
|
||||
const { status, data } = useQuery({
|
||||
queryKey: [keys.room, roomId, userConfig.username],
|
||||
const fetchKey = [keys.room, roomId, userConfig.username]
|
||||
|
||||
const { mutateAsync: createRoom, status: createStatus, isError: isCreateError} = useCreateRoom({
|
||||
onSuccess: (data) => {
|
||||
queryClient.setQueryData(fetchKey, data)
|
||||
},
|
||||
});
|
||||
|
||||
const { status: fetchStatus, isError: isFetchError, data } = useQuery({
|
||||
queryKey: fetchKey,
|
||||
enabled: !initialRoomData,
|
||||
initialData: initialRoomData,
|
||||
queryFn: () =>
|
||||
fetchRoom({
|
||||
roomId: roomId as string,
|
||||
username: userConfig.username,
|
||||
}).catch((error) => {
|
||||
if (error.statusCode == '404') {
|
||||
createRoom({slug: roomId})
|
||||
}
|
||||
}),
|
||||
retry: false,
|
||||
})
|
||||
|
||||
const roomOptions = useMemo((): RoomOptions => {
|
||||
return {
|
||||
videoCaptureDefaults: {
|
||||
deviceId: userConfig.videoDeviceId ?? undefined,
|
||||
},
|
||||
audioCaptureDefaults: {
|
||||
deviceId: userConfig.audioDeviceId ?? undefined,
|
||||
},
|
||||
}
|
||||
// do not rely on the userConfig object directly as its reference may change on every render
|
||||
}, [userConfig.videoDeviceId, userConfig.audioDeviceId])
|
||||
|
||||
const room = useMemo(() => new Room(roomOptions), [roomOptions])
|
||||
|
||||
const [showInviteDialog, setShowInviteDialog] = useState(mode === 'create')
|
||||
|
||||
/**
|
||||
* checks for actual click on the leave button instead of
|
||||
* relying on LiveKitRoom onDisconnected because onDisconnected
|
||||
* triggers even on page reload, it's not a user "onLeave" event really.
|
||||
* Here we want to react to the user actually deciding to leave.
|
||||
*/
|
||||
useEffect(() => {
|
||||
const checkOnLeaveClick = (event: MouseEvent) => {
|
||||
const target = event.target as HTMLElement
|
||||
if (target.classList.contains('lk-disconnect-button')) {
|
||||
navigateTo('feedback')
|
||||
}
|
||||
}
|
||||
document.body.addEventListener('click', checkOnLeaveClick)
|
||||
return () => {
|
||||
document.body.removeEventListener('click', checkOnLeaveClick)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const { t } = useTranslation('rooms')
|
||||
if (isCreateError) {
|
||||
// this error screen should be replaced by a proper waiting room for anonymous user.
|
||||
return <ErrorScreen title={t('error.createRoom.heading')} body={t('error.createRoom.body')} />
|
||||
}
|
||||
|
||||
return (
|
||||
<QueryAware status={status}>
|
||||
<LiveKitRoom
|
||||
serverUrl={data?.livekit?.url}
|
||||
token={data?.livekit?.token}
|
||||
connect={true}
|
||||
audio={{
|
||||
deviceId: userConfig.audioDeviceId,
|
||||
}}
|
||||
video={{
|
||||
deviceId: userConfig.videoDeviceId,
|
||||
}}
|
||||
onDisconnected={() => {
|
||||
navigateToHome()
|
||||
}}
|
||||
>
|
||||
<VideoConference />
|
||||
</LiveKitRoom>
|
||||
<QueryAware status={isFetchError ? createStatus : fetchStatus}>
|
||||
<Screen>
|
||||
<LiveKitRoom
|
||||
room={room}
|
||||
serverUrl={data?.livekit?.url}
|
||||
token={data?.livekit?.token}
|
||||
connect={true}
|
||||
audio={userConfig.audioEnabled}
|
||||
video={userConfig.videoEnabled}
|
||||
>
|
||||
<VideoConference
|
||||
chatMessageFormatter={formatChatMessageLinks}
|
||||
/>
|
||||
{showInviteDialog && (
|
||||
<InviteDialog
|
||||
isOpen={showInviteDialog}
|
||||
onOpenChange={setShowInviteDialog}
|
||||
roomId={roomId}
|
||||
onClose={() => setShowInviteDialog(false)}
|
||||
/>
|
||||
)}
|
||||
</LiveKitRoom>
|
||||
</Screen>
|
||||
</QueryAware>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { getRouteUrl } from '@/navigation/getRouteUrl'
|
||||
import { Div, Button, Dialog, Input, type DialogProps } from '@/primitives'
|
||||
import { HStack } from '@/styled-system/jsx'
|
||||
|
||||
export const InviteDialog = ({
|
||||
roomId,
|
||||
...dialogProps
|
||||
}: { roomId: string } & Omit<DialogProps, 'title'>) => {
|
||||
const { t } = useTranslation('rooms')
|
||||
const roomUrl = getRouteUrl('room', roomId)
|
||||
|
||||
const copyLabel = t('shareDialog.copy')
|
||||
const copiedLabel = t('shareDialog.copied')
|
||||
const [copyLinkLabel, setCopyLinkLabel] = useState(copyLabel)
|
||||
useEffect(() => {
|
||||
if (copyLinkLabel == copiedLabel) {
|
||||
const timeout = setTimeout(() => {
|
||||
setCopyLinkLabel(copyLabel)
|
||||
}, 5000)
|
||||
return () => {
|
||||
clearTimeout(timeout)
|
||||
}
|
||||
}
|
||||
}, [copyLinkLabel, copyLabel, copiedLabel])
|
||||
|
||||
return (
|
||||
<Dialog {...dialogProps} title={t('shareDialog.heading')}>
|
||||
<HStack alignItems="stretch" gap="gutter">
|
||||
<Div flex="1">
|
||||
<Input
|
||||
type="text"
|
||||
aria-label={t('shareDialog.inputLabel')}
|
||||
value={roomUrl}
|
||||
readOnly
|
||||
onClick={(e) => {
|
||||
e.currentTarget.select()
|
||||
}}
|
||||
/>
|
||||
</Div>
|
||||
<Div minWidth="8rem">
|
||||
<Button
|
||||
variant="primary"
|
||||
size="sm"
|
||||
fullWidth
|
||||
onPress={() => {
|
||||
navigator.clipboard.writeText(roomUrl)
|
||||
setCopyLinkLabel(copiedLabel)
|
||||
}}
|
||||
>
|
||||
{copyLinkLabel}
|
||||
</Button>
|
||||
</Div>
|
||||
</HStack>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Box } from '@/layout/Box'
|
||||
import { PreJoin, type LocalUserChoices } from '@livekit/components-react'
|
||||
import { Screen } from '@/layout/Screen'
|
||||
import { CenteredContent } from '@/layout/CenteredContent'
|
||||
|
||||
export const Join = ({
|
||||
onSubmit,
|
||||
@@ -10,8 +11,17 @@ export const Join = ({
|
||||
const { t } = useTranslation('rooms')
|
||||
|
||||
return (
|
||||
<Box title={t('join.heading')} withBackButton>
|
||||
<PreJoin persistUserChoices onSubmit={onSubmit} />
|
||||
</Box>
|
||||
<Screen layout="centered">
|
||||
<CenteredContent title={t('join.heading')}>
|
||||
<PreJoin
|
||||
persistUserChoices
|
||||
onSubmit={onSubmit}
|
||||
micLabel={t('join.micLabel')}
|
||||
camLabel={t('join.camlabel')}
|
||||
joinLabel={t('join.joinLabel')}
|
||||
userLabel={t('join.userLabel')}
|
||||
/>
|
||||
</CenteredContent>
|
||||
</Screen>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
export { navigateToNewRoom } from './navigation/navigateToNewRoom'
|
||||
export { Room as RoomRoute } from './routes/Room'
|
||||
export { roomIdRegex } from './utils/generateRoomId'
|
||||
export { FeedbackRoute } from './routes/Feedback'
|
||||
export { roomIdPattern, isRoomValid } from './utils/isRoomValid'
|
||||
export { generateRoomId } from './utils/generateRoomId'
|
||||
export { useCreateRoom } from './api/createRoom'
|
||||
|
||||
@@ -1,6 +0,0 @@
|
||||
import { navigate } from 'wouter/use-browser-location'
|
||||
import { generateRoomId } from '../utils/generateRoomId'
|
||||
|
||||
export const navigateToNewRoom = () => {
|
||||
navigate(`/${generateRoomId()}`)
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Text } from '@/primitives'
|
||||
import { Screen } from '@/layout/Screen'
|
||||
import { CenteredContent } from '@/layout/CenteredContent'
|
||||
|
||||
export const FeedbackRoute = () => {
|
||||
const { t } = useTranslation('rooms')
|
||||
return (
|
||||
<Screen layout="centered">
|
||||
<CenteredContent title={t('feedback.heading')} withBackButton>
|
||||
<Text as="p" variant="h3" centered>{t('feedback.body')}</Text>
|
||||
</CenteredContent>
|
||||
</Screen>
|
||||
)
|
||||
}
|
||||
@@ -1,24 +1,47 @@
|
||||
import { type LocalUserChoices } from '@livekit/components-react'
|
||||
import { useState } from 'react'
|
||||
import {
|
||||
usePersistentUserChoices,
|
||||
type LocalUserChoices,
|
||||
} from '@livekit/components-react'
|
||||
import { useParams } from 'wouter'
|
||||
import { ErrorScreen } from '@/components/ErrorScreen'
|
||||
import { useUser, UserAware } from '@/features/auth'
|
||||
import { Conference } from '../components/Conference'
|
||||
import { Join } from '../components/Join'
|
||||
import { Screen } from '@/layout/Screen'
|
||||
import { ErrorScreen } from '@/layout/ErrorScreen'
|
||||
|
||||
export const Room = () => {
|
||||
const { isLoggedIn } = useUser()
|
||||
const { userChoices: existingUserChoices } = usePersistentUserChoices()
|
||||
const [userConfig, setUserConfig] = useState<LocalUserChoices | null>(null)
|
||||
|
||||
const { roomId } = useParams()
|
||||
const initialRoomData = history.state?.initialRoomData
|
||||
const mode = isLoggedIn && history.state?.create ? 'create' : 'join'
|
||||
const skipJoinScreen = isLoggedIn && mode === 'create'
|
||||
|
||||
if (!roomId) {
|
||||
return <ErrorScreen />
|
||||
}
|
||||
return (
|
||||
<Screen>
|
||||
{userConfig ? (
|
||||
<Conference roomId={roomId} userConfig={userConfig} />
|
||||
) : (
|
||||
|
||||
if (!userConfig && !skipJoinScreen) {
|
||||
return (
|
||||
<UserAware>
|
||||
<Join onSubmit={setUserConfig} />
|
||||
)}
|
||||
</Screen>
|
||||
</UserAware>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<UserAware>
|
||||
<Conference
|
||||
initialRoomData={initialRoomData}
|
||||
roomId={roomId}
|
||||
mode={mode}
|
||||
userConfig={{
|
||||
...existingUserChoices,
|
||||
...userConfig,
|
||||
}}
|
||||
/>
|
||||
</UserAware>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -12,5 +12,3 @@ const generateSegment = (length: number): string =>
|
||||
// Generates a unique room identifier following the Google Meet format
|
||||
export const generateRoomId = () =>
|
||||
[generateSegment(3), generateSegment(4), generateSegment(3)].join('-')
|
||||
|
||||
export const roomIdRegex = /^[/](?<roomId>[a-z]{3}-[a-z]{4}-[a-z]{3})$/
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
export const roomIdPattern = '[a-z]{3}-[a-z]{4}-[a-z]{3}'
|
||||
|
||||
export const isRoomValid = (roomIdOrUrl: string) =>
|
||||
new RegExp(`^${roomIdPattern}$`).test(roomIdOrUrl) ||
|
||||
new RegExp(`^${window.location.origin}/${roomIdPattern}$`).test(roomIdOrUrl)
|
||||
@@ -0,0 +1,22 @@
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { DialogTrigger } from 'react-aria-components'
|
||||
import { RiSettings3Line } from '@remixicon/react'
|
||||
import { Button } from '@/primitives'
|
||||
import { SettingsDialog } from './SettingsDialog'
|
||||
|
||||
export const SettingsButton = () => {
|
||||
const { t } = useTranslation('settings')
|
||||
return (
|
||||
<DialogTrigger>
|
||||
<Button
|
||||
square
|
||||
invisible
|
||||
aria-label={t('settingsButtonLabel')}
|
||||
tooltip={t('settingsButtonLabel')}
|
||||
>
|
||||
<RiSettings3Line />
|
||||
</Button>
|
||||
<SettingsDialog />
|
||||
</DialogTrigger>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useLanguageLabels } from '@/i18n/useLanguageLabels'
|
||||
import { Dialog, Field, H } from '@/primitives'
|
||||
|
||||
export const SettingsDialog = () => {
|
||||
const { t, i18n } = useTranslation('settings')
|
||||
const { languagesList, currentLanguage } = useLanguageLabels()
|
||||
return (
|
||||
<Dialog title={t('dialog.heading')}>
|
||||
<H lvl={2}>{t('language.heading')}</H>
|
||||
<Field
|
||||
type="select"
|
||||
label={t('language.label')}
|
||||
items={languagesList}
|
||||
defaultSelectedKey={currentLanguage.key}
|
||||
onSelectionChange={(lang) => {
|
||||
i18n.changeLanguage(lang as string)
|
||||
}}
|
||||
/>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export { SettingsButton } from './components/SettingsButton'
|
||||
export { SettingsDialog } from './components/SettingsDialog'
|
||||
@@ -9,7 +9,7 @@ export const LanguageSelector = () => {
|
||||
<Popover aria-label={t('languageSelector.popoverLabel')}>
|
||||
<Button
|
||||
aria-label={t('languageSelector.buttonLabel', {
|
||||
currentLanguage,
|
||||
currentLanguage: currentLanguage.label,
|
||||
})}
|
||||
size="sm"
|
||||
variant="primary"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
const langageLabels: Record<string, string> = {
|
||||
const languageLabels: Record<string, string> = {
|
||||
en: 'English',
|
||||
fr: 'Français',
|
||||
de: 'Deutsch',
|
||||
@@ -13,8 +13,15 @@ export const useLanguageLabels = () => {
|
||||
(lang) => lang !== 'cimode'
|
||||
)
|
||||
const languagesList = supportedLanguages.map((lang) => ({
|
||||
key: lang,
|
||||
value: lang,
|
||||
label: langageLabels[lang],
|
||||
label: languageLabels[lang],
|
||||
}))
|
||||
return { languagesList, currentLanguage: langageLabels[i18n.language] }
|
||||
return {
|
||||
languagesList,
|
||||
currentLanguage: {
|
||||
key: i18n.language,
|
||||
label: languageLabels[i18n.language],
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,30 +0,0 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Box as BoxDiv, H, Link } from '@/primitives'
|
||||
|
||||
export type BoxProps = {
|
||||
children?: ReactNode
|
||||
title?: ReactNode
|
||||
withBackButton?: boolean
|
||||
}
|
||||
|
||||
export const Box = ({
|
||||
children,
|
||||
title = '',
|
||||
withBackButton = false,
|
||||
}: BoxProps) => {
|
||||
const { t } = useTranslation()
|
||||
return (
|
||||
<BoxDiv type="screen">
|
||||
{!!title && <H lvl={1}>{title}</H>}
|
||||
{children}
|
||||
{!!withBackButton && (
|
||||
<p>
|
||||
<Link to="/" size="small">
|
||||
{t('backToHome')}
|
||||
</Link>
|
||||
</p>
|
||||
)}
|
||||
</BoxDiv>
|
||||
)
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
import { Screen } from './Screen'
|
||||
import { Box, type BoxProps } from './Box'
|
||||
|
||||
export const BoxScreen = (props: BoxProps) => {
|
||||
return (
|
||||
<Screen>
|
||||
<Box {...props} />
|
||||
</Screen>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import { Div, VerticallyOffCenter } from '@/primitives'
|
||||
import type { SystemStyleObject } from '../styled-system/types'
|
||||
|
||||
export const Centered = ({
|
||||
width = '38rem',
|
||||
children,
|
||||
}: {
|
||||
width?: SystemStyleObject['width']
|
||||
children?: ReactNode
|
||||
}) => {
|
||||
return (
|
||||
<VerticallyOffCenter>
|
||||
<Div margin="auto" width={width} maxWidth="100%">
|
||||
{children}
|
||||
</Div>
|
||||
</VerticallyOffCenter>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { BackToHome } from '@/components/BackToHome'
|
||||
import { H } from '@/primitives'
|
||||
import { Center } from '@/styled-system/jsx'
|
||||
|
||||
export const CenteredContent = ({
|
||||
title,
|
||||
children,
|
||||
withBackButton,
|
||||
}: {
|
||||
title?: string
|
||||
children?: React.ReactNode
|
||||
withBackButton?: boolean
|
||||
}) => {
|
||||
return (
|
||||
<>
|
||||
{!!title && (
|
||||
<Center>
|
||||
<H lvl={1}>{title}</H>
|
||||
</Center>
|
||||
)}
|
||||
{children}
|
||||
{!!withBackButton && (
|
||||
<Center marginTop={2}>
|
||||
<BackToHome size="sm" />
|
||||
</Center>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
import { BoxScreen } from './BoxScreen'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
export const ErrorScreen = () => {
|
||||
const { t } = useTranslation()
|
||||
return <BoxScreen title={t('error.heading')} withBackButton />
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
import { BoxScreen } from './BoxScreen'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
export const ForbiddenScreen = () => {
|
||||
const { t } = useTranslation()
|
||||
return <BoxScreen title={t('forbidden.heading')} withBackButton />
|
||||
}
|
||||
@@ -1,49 +1,92 @@
|
||||
import { Link } from 'wouter'
|
||||
import { css } from '@/styled-system/css'
|
||||
import { flex } from '@/styled-system/patterns'
|
||||
import { Stack } from '@/styled-system/jsx'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { LanguageSelector } from '@/i18n/LanguageSelector'
|
||||
import { A, Badge, Text } from '@/primitives'
|
||||
import { A, Button, Popover, PopoverList, Text } from '@/primitives'
|
||||
import { SettingsButton } from '@/features/settings'
|
||||
import { authUrl, logoutUrl, useUser } from '@/features/auth'
|
||||
import { useMatchesRoute } from '@/navigation/useMatchesRoute'
|
||||
import { Feedback } from '@/components/Feedback'
|
||||
|
||||
export const Header = () => {
|
||||
const { t } = useTranslation()
|
||||
const isHome = useMatchesRoute('home')
|
||||
const isRoom = useMatchesRoute('room')
|
||||
const { user, isLoggedIn } = useUser()
|
||||
|
||||
return (
|
||||
<div
|
||||
className={css({
|
||||
backgroundColor: 'primary.text',
|
||||
color: 'primary',
|
||||
borderBottomColor: 'box.border',
|
||||
borderBottomWidth: 1,
|
||||
borderBottomStyle: 'solid',
|
||||
padding: 1,
|
||||
paddingY: 1,
|
||||
paddingX: 1,
|
||||
flexShrink: 0,
|
||||
boxShadow: 'box',
|
||||
})}
|
||||
>
|
||||
<Stack direction="row" justify="space-between" align="center">
|
||||
<div
|
||||
className={css({
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
rowGap: 1,
|
||||
md: {
|
||||
rowGap: 0,
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
},
|
||||
})}
|
||||
>
|
||||
<header>
|
||||
<Text bold variant="h1" margin={false}>
|
||||
<Link to="/">{t('app')}</Link>
|
||||
</Text>
|
||||
<Stack gap={2.25} direction="row" align="center">
|
||||
<Text bold variant="h1" margin={false}>
|
||||
<Link
|
||||
onClick={(event) => {
|
||||
if (
|
||||
isRoom &&
|
||||
!window.confirm(t('leaveRoomPrompt', { ns: 'rooms' }))
|
||||
) {
|
||||
event.preventDefault()
|
||||
}
|
||||
}}
|
||||
to="/"
|
||||
>
|
||||
{t('app')}
|
||||
</Link>
|
||||
</Text>
|
||||
<Feedback />
|
||||
</Stack>
|
||||
</header>
|
||||
<nav>
|
||||
<Stack gap={1} direction="row" align="center">
|
||||
<LanguageSelector />
|
||||
{isLoggedIn === false && <A href={authUrl()}>{t('login')}</A>}
|
||||
{!!user && (
|
||||
<p className={flex({ gap: 1, align: 'center' })}>
|
||||
<Badge>{user.email}</Badge>
|
||||
<A href={logoutUrl()} size="small">
|
||||
{t('logout')}
|
||||
</A>
|
||||
</p>
|
||||
{isLoggedIn === false && !isHome && (
|
||||
<A href={authUrl()}>{t('login')}</A>
|
||||
)}
|
||||
{!!user && (
|
||||
<Popover aria-label={t('logout')}>
|
||||
<Button
|
||||
size="sm"
|
||||
invisible
|
||||
tooltip={t('loggedInUserTooltip')}
|
||||
tooltipType="delayed"
|
||||
>
|
||||
{user.email}
|
||||
</Button>
|
||||
<PopoverList
|
||||
items={[{ key: 'logout', value: 'logout', label: t('logout') }]}
|
||||
onAction={(value) => {
|
||||
if (value === 'logout') {
|
||||
window.location.href = logoutUrl()
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</Popover>
|
||||
)}
|
||||
<SettingsButton />
|
||||
</Stack>
|
||||
</nav>
|
||||
</Stack>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import { type ReactNode } from 'react'
|
||||
import { css } from '@/styled-system/css'
|
||||
import { Header } from './Header'
|
||||
import { layoutStore } from '@/stores/layout'
|
||||
import { useSnapshot } from 'valtio'
|
||||
|
||||
export type Layout = 'fullpage' | 'centered'
|
||||
|
||||
/**
|
||||
* Layout component for the app.
|
||||
*
|
||||
* This component is meant to be used as a wrapper around the whole app.
|
||||
* In a specific page, use the `Screen` component and change its props to change global page layout.
|
||||
*/
|
||||
export const Layout = ({ children }: { children: ReactNode }) => {
|
||||
const layoutSnap = useSnapshot(layoutStore)
|
||||
const showHeader = layoutSnap.showHeader
|
||||
|
||||
return (
|
||||
<div
|
||||
className={css({
|
||||
height: '100%',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
backgroundColor: 'white',
|
||||
color: 'default.text',
|
||||
})}
|
||||
>
|
||||
{showHeader && <Header />}
|
||||
<main
|
||||
className={css({
|
||||
flexGrow: 1,
|
||||
overflow: 'auto',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
})}
|
||||
>
|
||||
{children}
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { BoxScreen } from './BoxScreen'
|
||||
import { Screen } from './Screen'
|
||||
|
||||
export const LoadingScreen = ({ asBox = false }: { asBox?: boolean }) => {
|
||||
const { t } = useTranslation()
|
||||
// show the loading screen only after a little while to prevent flash of texts
|
||||
const [show, setShow] = useState(false)
|
||||
useEffect(() => {
|
||||
const timeout = setTimeout(() => setShow(true), 500)
|
||||
return () => clearTimeout(timeout)
|
||||
}, [])
|
||||
if (!show) {
|
||||
return null
|
||||
}
|
||||
const Container = asBox ? BoxScreen : Screen
|
||||
return (
|
||||
<Container>
|
||||
<p>{t('loading')}</p>
|
||||
</Container>
|
||||
)
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { BoxScreen } from './BoxScreen'
|
||||
|
||||
export const NotFoundScreen = () => {
|
||||
const { t } = useTranslation()
|
||||
return <BoxScreen title={t('notFound.heading')} withBackButton />
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
import { ErrorScreen } from './ErrorScreen'
|
||||
import { LoadingScreen } from './LoadingScreen'
|
||||
|
||||
export const QueryAware = ({
|
||||
status,
|
||||
children,
|
||||
}: {
|
||||
status: 'error' | 'pending' | 'success'
|
||||
children: React.ReactNode
|
||||
}) => {
|
||||
if (status === 'error') {
|
||||
return <ErrorScreen />
|
||||
}
|
||||
|
||||
if (status === 'pending') {
|
||||
return <LoadingScreen />
|
||||
}
|
||||
|
||||
return children
|
||||
}
|
||||
@@ -1,27 +1,30 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import { css } from '@/styled-system/css'
|
||||
import { Header } from './Header'
|
||||
import { layoutStore } from '@/stores/layout'
|
||||
import { Layout } from './Layout'
|
||||
import { useEffect } from 'react'
|
||||
import { Centered } from './Centered'
|
||||
|
||||
export const Screen = ({ children }: { children?: ReactNode }) => {
|
||||
return (
|
||||
<div
|
||||
className={css({
|
||||
height: '100%',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
backgroundColor: 'default.bg',
|
||||
color: 'default.text',
|
||||
})}
|
||||
>
|
||||
<Header />
|
||||
<main
|
||||
className={css({
|
||||
flexGrow: 1,
|
||||
overflow: 'auto',
|
||||
})}
|
||||
>
|
||||
{children}
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
export type ScreenProps = {
|
||||
/**
|
||||
* 'fullpage' by default.
|
||||
*/
|
||||
layout?: Layout
|
||||
/**
|
||||
* Show header or not.
|
||||
* True by default. Pass undefined to render the screen without modifying current header visibility
|
||||
*/
|
||||
header?: boolean
|
||||
children: React.ReactNode
|
||||
}
|
||||
|
||||
export const Screen = ({
|
||||
layout = 'fullpage',
|
||||
header = true,
|
||||
children,
|
||||
}: ScreenProps) => {
|
||||
useEffect(() => {
|
||||
if (header !== undefined) {
|
||||
layoutStore.showHeader = header
|
||||
}
|
||||
}, [header])
|
||||
return layout === 'centered' ? <Centered>{children}</Centered> : children
|
||||
}
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
{
|
||||
"app": "Meet",
|
||||
"backToHome": "",
|
||||
"cancel": "",
|
||||
"closeDialog": "",
|
||||
"error": {
|
||||
"heading": ""
|
||||
},
|
||||
"feedbackAlert": "",
|
||||
"forbidden": {
|
||||
"heading": ""
|
||||
},
|
||||
@@ -12,6 +15,7 @@
|
||||
"popoverLabel": ""
|
||||
},
|
||||
"loading": "",
|
||||
"loggedInUserTooltip": "",
|
||||
"login": "Anmelden",
|
||||
"logout": "",
|
||||
"notFound": {
|
||||
|
||||
@@ -1,8 +1,13 @@
|
||||
{
|
||||
"copyMeetingUrl": "",
|
||||
"createMeeting": "",
|
||||
"heading": "",
|
||||
"intro": "",
|
||||
"login": "",
|
||||
"or": ""
|
||||
"joinInputError": "",
|
||||
"joinInputExample": "",
|
||||
"joinInputLabel": "",
|
||||
"joinInputSubmit": "",
|
||||
"joinMeeting": "",
|
||||
"joinMeetingTipContent": "",
|
||||
"joinMeetingTipHeading": "",
|
||||
"loginToCreateMeeting": ""
|
||||
}
|
||||
|
||||
@@ -1,5 +1,26 @@
|
||||
{
|
||||
"join": {
|
||||
"feedback": {
|
||||
"body": "",
|
||||
"heading": ""
|
||||
},
|
||||
"join": {
|
||||
"camlabel": "",
|
||||
"heading": "",
|
||||
"joinLabel": "",
|
||||
"micLabel": "",
|
||||
"userLabel": ""
|
||||
},
|
||||
"leaveRoomPrompt": "",
|
||||
"shareDialog": {
|
||||
"copied": "",
|
||||
"copy": "",
|
||||
"heading": "",
|
||||
"inputLabel": ""
|
||||
},
|
||||
"error": {
|
||||
"createRoom": {
|
||||
"heading": "",
|
||||
"body": ""
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"dialog": {
|
||||
"heading": ""
|
||||
},
|
||||
"language": {
|
||||
"heading": "",
|
||||
"label": ""
|
||||
},
|
||||
"settingsButtonLabel": ""
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user