Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 6d6589e487 |
@@ -117,57 +117,10 @@ jobs:
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
|
||||
build-and-push-summary:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
-
|
||||
uses: actions/create-github-app-token@v1
|
||||
id: app-token
|
||||
with:
|
||||
app-id: ${{ secrets.APP_ID }}
|
||||
private-key: ${{ secrets.PRIVATE_KEY }}
|
||||
owner: ${{ github.repository_owner }}
|
||||
repositories: "meet,secrets"
|
||||
-
|
||||
name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
submodules: recursive
|
||||
token: ${{ steps.app-token.outputs.token }}
|
||||
-
|
||||
name: Load sops secrets
|
||||
uses: rouja/actions-sops@main
|
||||
with:
|
||||
secret-file: secrets/numerique-gouv/meet/secrets.enc.env
|
||||
age-key: ${{ secrets.SOPS_PRIVATE }}
|
||||
-
|
||||
name: Docker meta
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: lasuite/meet-summary
|
||||
-
|
||||
name: Login to DockerHub
|
||||
if: github.event_name != 'pull_request'
|
||||
run: echo "$DOCKER_HUB_PASSWORD" | docker login -u "$DOCKER_HUB_USER" --password-stdin
|
||||
-
|
||||
name: Build and push
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: ./src/summary
|
||||
file: ./src/summary/Dockerfile
|
||||
target: production
|
||||
build-args: DOCKER_USER=${{ env.DOCKER_USER }}:-1000
|
||||
push: ${{ github.event_name != 'pull_request' }}
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
|
||||
|
||||
notify-argocd:
|
||||
needs:
|
||||
- build-and-push-frontend
|
||||
- build-and-push-backend
|
||||
- build-and-push-summary
|
||||
runs-on: ubuntu-latest
|
||||
if: |
|
||||
github.event_name != 'pull_request'
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
name: Helmfile lint
|
||||
run-name: Helmfile lint
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches:
|
||||
- 'main'
|
||||
|
||||
jobs:
|
||||
helmfile-lint:
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: ghcr.io/helmfile/helmfile:latest
|
||||
steps:
|
||||
-
|
||||
uses: numerique-gouv/action-helmfile-lint@main
|
||||
with:
|
||||
app-id: ${{ secrets.APP_ID }}
|
||||
age-key: ${{ secrets.SOPS_PRIVATE }}
|
||||
private-key: ${{ secrets.PRIVATE_KEY }}
|
||||
helmfile-src: "src/helm"
|
||||
repositories: "meet,secrets"
|
||||
@@ -1,35 +0,0 @@
|
||||
name: Release Chart
|
||||
run-name: Release Chart
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- 'main'
|
||||
paths:
|
||||
- ./src/helm/meet/**
|
||||
|
||||
jobs:
|
||||
release:
|
||||
permissions:
|
||||
contents: write
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Cleanup
|
||||
run: rm -rf ./src/helm/extra
|
||||
|
||||
- name: Install Helm
|
||||
uses: azure/setup-helm@v4
|
||||
env:
|
||||
GITHUB_TOKEN: "${{ secrets.GITHUB_TOKEN }}"
|
||||
|
||||
- name: Publish Helm charts
|
||||
uses: numerique-gouv/helm-gh-pages@add-overwrite-option
|
||||
with:
|
||||
charts_dir: ./src/helm
|
||||
linting: on
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
@@ -301,14 +301,8 @@ build-k8s-cluster: ## build the kubernetes cluster using kind
|
||||
./bin/start-kind.sh
|
||||
.PHONY: build-k8s-cluster
|
||||
|
||||
install-external-secrets: ## install the kubernetes secrets from Vaultwarden
|
||||
./bin/install-external-secrets.sh
|
||||
.PHONY: build-k8s-cluster
|
||||
|
||||
start-tilt: ## start the kubernetes cluster using kind
|
||||
kubectl config set-context --current --namespace=meet
|
||||
tilt up -f ./bin/Tiltfile
|
||||
.PHONY: build-k8s-cluster
|
||||
|
||||
start-tilt-keycloak: ## start the kubernetes cluster using kind, without Pro Connect for authentication, use keycloak
|
||||
DEV_ENV=dev-keycloak tilt up -f ./bin/Tiltfile
|
||||
.PHONY: build-k8s-cluster
|
||||
|
||||
@@ -118,8 +118,6 @@ $ make build-k8s-cluster
|
||||
Once the Kubernetes cluster is ready, start the application stack locally:
|
||||
```shell
|
||||
$ make start-tilt
|
||||
or
|
||||
$ make start-tilt-keycloak # start stack without Pro Connect, use keycloak
|
||||
```
|
||||
These commands set up and run your application environment using Tilt for local Kubernetes development.
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
load('ext://uibutton', 'cmd_button', 'bool_input', 'location')
|
||||
load('ext://namespace', 'namespace_create', 'namespace_inject')
|
||||
namespace_create('meet')
|
||||
|
||||
docker_build(
|
||||
'localhost:5001/meet-backend:latest',
|
||||
context='..',
|
||||
@@ -27,18 +28,7 @@ docker_build(
|
||||
]
|
||||
)
|
||||
|
||||
docker_build(
|
||||
'localhost:5001/meet-summary:latest',
|
||||
context='../src/summary',
|
||||
dockerfile='../src/summary/Dockerfile',
|
||||
only=['.', '../../docker', '../../.dockerignore'],
|
||||
target = 'production',
|
||||
live_update=[
|
||||
sync('../src/summary', '/home/summary'),
|
||||
]
|
||||
)
|
||||
|
||||
k8s_yaml(local('cd ../src/helm && helmfile -n meet -e ${DEV_ENV:-dev} template .'))
|
||||
k8s_yaml(local('cd ../src/helm && helmfile -n meet -e dev template .'))
|
||||
|
||||
migration = '''
|
||||
set -eu
|
||||
|
||||
@@ -1,90 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
set -o errexit
|
||||
|
||||
CURRENT_DIR=$(pwd)
|
||||
NAMESPACE=${1:-meet}
|
||||
SECRET_NAME=${2:-bitwarden-cli-meet}
|
||||
TEMP_SECRET_FILE=$(mktemp)
|
||||
|
||||
|
||||
cleanup() {
|
||||
rm -f "${TEMP_SECRET_FILE}"
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
|
||||
# Check if kubectl is available
|
||||
check_prerequisites() {
|
||||
if ! command -v kubectl &> /dev/null; then
|
||||
echo "Error: kubectl is not installed or not in PATH"
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
# Check if secret already exists
|
||||
check_secret_exists() {
|
||||
kubectl -n "${NAMESPACE}" get secrets "${SECRET_NAME}" &> /dev/null
|
||||
}
|
||||
|
||||
|
||||
# Collect user input securely
|
||||
get_user_input() {
|
||||
echo "Please provide the following information:"
|
||||
read -p "Enter your Vaultwarden email login: " LOGIN
|
||||
read -s -p "Enter your Vaultwarden password: " PASSWORD
|
||||
echo
|
||||
read -p "Enter your Vaultwarden server url: " URL
|
||||
}
|
||||
|
||||
# Create and apply the secret
|
||||
create_secret() {
|
||||
cat > "${TEMP_SECRET_FILE}" << EOF
|
||||
apiVersion: v1
|
||||
kind: Secret
|
||||
metadata:
|
||||
name: ${SECRET_NAME}
|
||||
namespace: ${NAMESPACE}
|
||||
type: Opaque
|
||||
stringData:
|
||||
BW_HOST: ${URL}
|
||||
BW_PASSWORD: ${PASSWORD}
|
||||
BW_USERNAME: ${LOGIN}
|
||||
EOF
|
||||
|
||||
kubectl -n "${NAMESPACE}" apply -f "${TEMP_SECRET_FILE}"
|
||||
}
|
||||
|
||||
# Install external-secrets using Helm
|
||||
install_external_secrets() {
|
||||
if ! kubectl get ns external-secrets &>/dev/null; then
|
||||
echo "Installing external-secrets…"
|
||||
helm repo add external-secrets https://charts.external-secrets.io
|
||||
helm upgrade --install external-secrets \
|
||||
external-secrets/external-secrets \
|
||||
-n external-secrets \
|
||||
--create-namespace \
|
||||
--set installCRDs=true
|
||||
else
|
||||
echo "External secrets already deployed"
|
||||
fi
|
||||
}
|
||||
|
||||
main() {
|
||||
check_prerequisites
|
||||
|
||||
if check_secret_exists; then
|
||||
echo "Secret '${SECRET_NAME}' already present in namespace '${NAMESPACE}'"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo -e ${TEMP_SECRET_FILE}
|
||||
|
||||
get_user_input
|
||||
echo -e "\nCreating Vaultwarden secret…"
|
||||
create_secret
|
||||
install_external_secrets
|
||||
|
||||
echo "Secret installation completed successfully"
|
||||
}
|
||||
|
||||
main "$@"
|
||||
@@ -1,4 +1,4 @@
|
||||
#!/usr/bin/env bash
|
||||
#!/bin/bash
|
||||
|
||||
mkdir -p "$(dirname -- "${BASH_SOURCE[0]}")/../.git/hooks/"
|
||||
PRE_COMMIT_FILE="$(dirname -- "${BASH_SOURCE[0]}")/../.git/hooks/pre-commit"
|
||||
|
||||
@@ -1,3 +1,138 @@
|
||||
#!/usr/bin/env bash
|
||||
#!/bin/sh
|
||||
set -o errexit
|
||||
|
||||
curl https://raw.githubusercontent.com/numerique-gouv/tools/refs/heads/main/kind/create_cluster.sh | bash -s -- meet
|
||||
CURRENT_DIR=$(pwd)
|
||||
|
||||
echo "0. Create ca"
|
||||
# 0. Create ca
|
||||
mkcert -install
|
||||
cd /tmp
|
||||
mkcert "127.0.0.1.nip.io" "*.127.0.0.1.nip.io"
|
||||
cd $CURRENT_DIR
|
||||
|
||||
echo "1. Create registry container unless it already exists"
|
||||
# 1. Create registry container unless it already exists
|
||||
reg_name='kind-registry'
|
||||
reg_port='5001'
|
||||
if [ "$(docker inspect -f '{{.State.Running}}' "${reg_name}" 2>/dev/null || true)" != 'true' ]; then
|
||||
docker run \
|
||||
-d --restart=always -p "127.0.0.1:${reg_port}:5000" --network bridge --name "${reg_name}" \
|
||||
registry:2
|
||||
fi
|
||||
|
||||
echo "2. Create kind cluster with containerd registry config dir enabled"
|
||||
# 2. Create kind cluster with containerd registry config dir enabled
|
||||
# TODO: kind will eventually enable this by default and this patch will
|
||||
# be unnecessary.
|
||||
#
|
||||
# See:
|
||||
# https://github.com/kubernetes-sigs/kind/issues/2875
|
||||
# https://github.com/containerd/containerd/blob/main/docs/cri/config.md#registry-configuration
|
||||
# See: https://github.com/containerd/containerd/blob/main/docs/hosts.md
|
||||
cat <<EOF | kind create cluster --config=-
|
||||
kind: Cluster
|
||||
apiVersion: kind.x-k8s.io/v1alpha4
|
||||
containerdConfigPatches:
|
||||
- |-
|
||||
[plugins."io.containerd.grpc.v1.cri".registry]
|
||||
config_path = "/etc/containerd/certs.d"
|
||||
nodes:
|
||||
- role: control-plane
|
||||
image: kindest/node:v1.27.3
|
||||
kubeadmConfigPatches:
|
||||
- |
|
||||
kind: InitConfiguration
|
||||
nodeRegistration:
|
||||
kubeletExtraArgs:
|
||||
node-labels: "ingress-ready=true"
|
||||
extraPortMappings:
|
||||
- containerPort: 80
|
||||
hostPort: 80
|
||||
protocol: TCP
|
||||
- containerPort: 443
|
||||
hostPort: 443
|
||||
protocol: TCP
|
||||
- role: worker
|
||||
image: kindest/node:v1.27.3
|
||||
- role: worker
|
||||
image: kindest/node:v1.27.3
|
||||
EOF
|
||||
|
||||
echo "3. Add the registry config to the nodes"
|
||||
# 3. Add the registry config to the nodes
|
||||
#
|
||||
# This is necessary because localhost resolves to loopback addresses that are
|
||||
# network-namespace local.
|
||||
# In other words: localhost in the container is not localhost on the host.
|
||||
#
|
||||
# We want a consistent name that works from both ends, so we tell containerd to
|
||||
# alias localhost:${reg_port} to the registry container when pulling images
|
||||
REGISTRY_DIR="/etc/containerd/certs.d/localhost:${reg_port}"
|
||||
for node in $(kind get nodes); do
|
||||
docker exec "${node}" mkdir -p "${REGISTRY_DIR}"
|
||||
cat <<EOF | docker exec -i "${node}" cp /dev/stdin "${REGISTRY_DIR}/hosts.toml"
|
||||
[host."http://${reg_name}:5000"]
|
||||
EOF
|
||||
done
|
||||
|
||||
echo "4. Connect the registry to the cluster network if not already connected"
|
||||
# 4. Connect the registry to the cluster network if not already connected
|
||||
# This allows kind to bootstrap the network but ensures they're on the same network
|
||||
if [ "$(docker inspect -f='{{json .NetworkSettings.Networks.kind}}' "${reg_name}")" = 'null' ]; then
|
||||
docker network connect "kind" "${reg_name}"
|
||||
fi
|
||||
|
||||
echo "5. Document the local registry"
|
||||
# 5. Document the local registry
|
||||
# https://github.com/kubernetes/enhancements/tree/master/keps/sig-cluster-lifecycle/generic/1755-communicating-a-local-registry
|
||||
cat <<EOF | kubectl apply -f -
|
||||
apiVersion: v1
|
||||
kind: ConfigMap
|
||||
metadata:
|
||||
name: local-registry-hosting
|
||||
namespace: kube-public
|
||||
data:
|
||||
localRegistryHosting.v1: |
|
||||
host: "localhost:${reg_port}"
|
||||
help: "https://kind.sigs.k8s.io/docs/user/local-registry/"
|
||||
EOF
|
||||
|
||||
cat <<EOF | kubectl apply -f -
|
||||
apiVersion: v1
|
||||
kind: ConfigMap
|
||||
metadata:
|
||||
name: coredns
|
||||
namespace: kube-system
|
||||
data:
|
||||
Corefile: |
|
||||
.:53 {
|
||||
errors
|
||||
health {
|
||||
lameduck 5s
|
||||
}
|
||||
ready
|
||||
kubernetes cluster.local in-addr.arpa ip6.arpa {
|
||||
pods insecure
|
||||
fallthrough in-addr.arpa ip6.arpa
|
||||
ttl 30
|
||||
}
|
||||
prometheus :9153
|
||||
forward . /etc/resolv.conf {
|
||||
max_concurrent 1000
|
||||
}
|
||||
rewrite stop {
|
||||
name regex (.*).127.0.0.1.nip.io ingress-nginx-controller.ingress-nginx.svc.cluster.local answer auto
|
||||
}
|
||||
cache 30
|
||||
loop
|
||||
reload
|
||||
loadbalance
|
||||
}
|
||||
EOF
|
||||
|
||||
kubectl -n kube-system rollout restart deployments/coredns
|
||||
|
||||
echo "6. Install ingress-nginx"
|
||||
kubectl apply -f https://raw.githubusercontent.com/kubernetes/ingress-nginx/main/deploy/static/provider/kind/deploy.yaml
|
||||
kubectl -n ingress-nginx create secret tls mkcert --key /tmp/127.0.0.1.nip.io+1-key.pem --cert /tmp/127.0.0.1.nip.io+1.pem
|
||||
kubectl -n ingress-nginx patch deployments.apps ingress-nginx-controller --type 'json' -p '[{"op": "add", "path": "/spec/template/spec/containers/0/args/-", "value":"--default-ssl-certificate=ingress-nginx/mkcert"}]'
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
#!/usr/bin/env bash
|
||||
#!/bin/bash
|
||||
|
||||
git submodule update --init --recursive
|
||||
git submodule foreach 'git fetch origin; git checkout $(git rev-parse --abbrev-ref HEAD); git reset --hard origin/$(git rev-parse --abbrev-ref HEAD); git submodule update --recursive; git clean -dfx'
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
#!/usr/bin/env bash
|
||||
#!/bin/bash
|
||||
|
||||
find . -name "*.enc.*" -exec sops updatekeys -y {} \;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
#!/usr/bin/env bash
|
||||
#!/bin/bash
|
||||
|
||||
set -e
|
||||
|
||||
|
||||
@@ -1,40 +0,0 @@
|
||||
replicaCount: 1
|
||||
terminationGracePeriodSeconds: 18000
|
||||
|
||||
livekit:
|
||||
keys:
|
||||
devkey: secret
|
||||
log_level: debug
|
||||
rtc:
|
||||
use_external_ip: false
|
||||
port_range_start: 50000
|
||||
port_range_end: 60000
|
||||
tcp_port: 7881
|
||||
redis:
|
||||
address: redis-master:6379
|
||||
password: pass
|
||||
turn:
|
||||
enabled: true
|
||||
udp_port: 443
|
||||
domain: livekit.127.0.0.1.nip.io
|
||||
loadBalancerAnnotations: {}
|
||||
|
||||
|
||||
loadBalancer:
|
||||
type: nginx
|
||||
annotations:
|
||||
nginx.ingress.kubernetes.io/rewrite-target: /
|
||||
nginx.ingress.kubernetes.io/ssl-redirect: "true"
|
||||
tls:
|
||||
- hosts:
|
||||
- livekit.127.0.0.1.nip.io
|
||||
secretName: livekit-dinum-cert
|
||||
|
||||
autoscaling:
|
||||
enabled: false
|
||||
minReplicas: 1
|
||||
maxReplicas: 5
|
||||
targetCPUUtilizationPercentage: 60
|
||||
|
||||
nodeSelector: {}
|
||||
resources: {}
|
||||
@@ -1,120 +0,0 @@
|
||||
image:
|
||||
repository: lasuite/meet-backend
|
||||
pullPolicy: Always
|
||||
tag: "latest"
|
||||
|
||||
backend:
|
||||
replicas: 1
|
||||
envVars:
|
||||
DJANGO_CSRF_TRUSTED_ORIGINS: https://meet.127.0.0.1.nip.io,http://meet.127.0.0.1.nip.io
|
||||
DJANGO_CONFIGURATION: Production
|
||||
DJANGO_ALLOWED_HOSTS: meet.127.0.0.1.nip.io
|
||||
DJANGO_SECRET_KEY: ThisCouldBeAReallyGoodOrPerhapsABadKeyToUseSometimes
|
||||
DJANGO_SETTINGS_MODULE: meet.settings
|
||||
DJANGO_SILENCED_SYSTEM_CHECKS: security.W004, security.W008
|
||||
DJANGO_SUPERUSER_PASSWORD: admin
|
||||
DJANGO_EMAIL_HOST: "mailcatcher"
|
||||
DJANGO_EMAIL_PORT: 1025
|
||||
DJANGO_EMAIL_USE_SSL: False
|
||||
OIDC_OP_JWKS_ENDPOINT: https://keycloak.127.0.0.1.nip.io/realms/meet/protocol/openid-connect/certs
|
||||
OIDC_OP_AUTHORIZATION_ENDPOINT: https://keycloak.127.0.0.1.nip.io/realms/meet/protocol/openid-connect/auth
|
||||
OIDC_OP_TOKEN_ENDPOINT: https://keycloak.127.0.0.1.nip.io/realms/meet/protocol/openid-connect/token
|
||||
OIDC_OP_USER_ENDPOINT: https://keycloak.127.0.0.1.nip.io/realms/meet/protocol/openid-connect/userinfo
|
||||
OIDC_OP_LOGOUT_ENDPOINT: https://keycloak.127.0.0.1.nip.io/realms/meet/protocol/openid-connect/session/end
|
||||
OIDC_RP_CLIENT_ID: meet
|
||||
OIDC_RP_CLIENT_SECRET: ThisIsAnExampleKeyForDevPurposeOnly
|
||||
OIDC_RP_SIGN_ALGO: RS256
|
||||
OIDC_RP_SCOPES: "openid email"
|
||||
OIDC_REDIRECT_ALLOWED_HOSTS: https://meet.127.0.0.1.nip.io
|
||||
OIDC_AUTH_REQUEST_EXTRA_PARAMS: "{'acr_values': 'eidas1'}"
|
||||
LOGIN_REDIRECT_URL: https://meet.127.0.0.1.nip.io
|
||||
LOGIN_REDIRECT_URL_FAILURE: https://meet.127.0.0.1.nip.io
|
||||
LOGOUT_REDIRECT_URL: https://meet.127.0.0.1.nip.io
|
||||
DB_HOST: postgresql
|
||||
DB_NAME: meet
|
||||
DB_USER: dinum
|
||||
DB_PASSWORD: pass
|
||||
DB_PORT: 5432
|
||||
POSTGRES_DB: meet
|
||||
POSTGRES_USER: dinum
|
||||
POSTGRES_PASSWORD: pass
|
||||
REDIS_URL: redis://default:pass@redis-master:6379/1
|
||||
STORAGES_STATICFILES_BACKEND: django.contrib.staticfiles.storage.StaticFilesStorage
|
||||
LIVEKIT_API_SECRET: secret
|
||||
LIVEKIT_API_KEY: devkey
|
||||
LIVEKIT_API_URL: https://livekit.127.0.0.1.nip.io/
|
||||
ALLOW_UNREGISTERED_ROOMS: False
|
||||
FRONTEND_SILENCE_LIVEKIT_DEBUG: False
|
||||
|
||||
|
||||
migrate:
|
||||
command:
|
||||
- "/bin/sh"
|
||||
- "-c"
|
||||
- |
|
||||
python manage.py migrate --no-input &&
|
||||
python manage.py create_demo --force
|
||||
restartPolicy: Never
|
||||
|
||||
command:
|
||||
- "gunicorn"
|
||||
- "-c"
|
||||
- "/usr/local/etc/gunicorn/meet.py"
|
||||
- "meet.wsgi:application"
|
||||
- "--reload"
|
||||
|
||||
createsuperuser:
|
||||
command:
|
||||
- "/bin/sh"
|
||||
- "-c"
|
||||
- |
|
||||
python manage.py createsuperuser --email admin@example.com --password admin
|
||||
restartPolicy: Never
|
||||
|
||||
# Exra volume to manage our local custom CA and avoid to set ssl_verify: false
|
||||
extraVolumeMounts:
|
||||
- name: certs
|
||||
mountPath: /usr/local/lib/python3.12/site-packages/certifi/cacert.pem
|
||||
subPath: cacert.pem
|
||||
|
||||
# Exra volume to manage our local custom CA and avoid to set ssl_verify: false
|
||||
extraVolumes:
|
||||
- name: certs
|
||||
configMap:
|
||||
name: certifi
|
||||
items:
|
||||
- key: cacert.pem
|
||||
path: cacert.pem
|
||||
|
||||
frontend:
|
||||
envVars:
|
||||
VITE_PORT: 8080
|
||||
VITE_HOST: 0.0.0.0
|
||||
VITE_API_BASE_URL: https://meet.127.0.0.1.nip.io/
|
||||
|
||||
replicas: 1
|
||||
|
||||
image:
|
||||
repository: lasuite/meet-frontend
|
||||
pullPolicy: Always
|
||||
tag: "latest"
|
||||
|
||||
ingress:
|
||||
enabled: true
|
||||
host: meet.127.0.0.1.nip.io
|
||||
|
||||
ingressAdmin:
|
||||
enabled: true
|
||||
host: meet.127.0.0.1.nip.io
|
||||
|
||||
posthog:
|
||||
ingress:
|
||||
enabled: false
|
||||
ingressAssets:
|
||||
enabled: false
|
||||
|
||||
summary:
|
||||
replicas: 0
|
||||
|
||||
celery:
|
||||
replicas: 0
|
||||
@@ -1,7 +0,0 @@
|
||||
auth:
|
||||
username: dinum
|
||||
password: pass
|
||||
database: meet
|
||||
tls:
|
||||
enabled: true
|
||||
autoGenerated: true
|
||||
@@ -1,3 +0,0 @@
|
||||
auth:
|
||||
password: pass
|
||||
architecture: standalone
|
||||
@@ -1,233 +0,0 @@
|
||||
# Installation on a k8s cluster
|
||||
|
||||
This document is a step-by-step guide that describes how to install Visio on a k8s cluster without AI features.
|
||||
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- k8s cluster with an nginx-ingress controller
|
||||
- an OIDC provider (if you don't have one, we will provide an example)
|
||||
- a LiveKit server (if you don't have one, we will provide an example)
|
||||
- a PostgreSQL server (if you don't have one, we will provide an example)
|
||||
- a Memcached server (if you don't have one, we will provide an example)
|
||||
|
||||
### Test cluster
|
||||
|
||||
If you do not have a test cluster, you can install everything on a local kind cluster. In this case, the simplest way is to use our script **bin/start-kind.sh**.
|
||||
|
||||
To be able to use the script, you will need to install:
|
||||
|
||||
- Docker (https://docs.docker.com/desktop/)
|
||||
- Kind (https://kind.sigs.k8s.io/docs/user/quick-start/#installation)
|
||||
- Mkcert (https://github.com/FiloSottile/mkcert#installation)
|
||||
- Helm (https://helm.sh/docs/intro/quickstart/#install-helm)
|
||||
|
||||
```
|
||||
$ ./bin/start-kind.sh
|
||||
0. Create ca
|
||||
The local CA is already installed in the system trust store! 👍
|
||||
The local CA is already installed in the Firefox and/or Chrome/Chromium trust store! 👍
|
||||
|
||||
|
||||
Created a new certificate valid for the following names 📜
|
||||
- "127.0.0.1.nip.io"
|
||||
- "*.127.0.0.1.nip.io"
|
||||
|
||||
Reminder: X.509 wildcards only go one level deep, so this won't match a.b.127.0.0.1.nip.io ℹ️
|
||||
|
||||
The certificate is at "./127.0.0.1.nip.io+1.pem" and the key at "./127.0.0.1.nip.io+1-key.pem" ✅
|
||||
|
||||
It will expire on 23 March 2027 🗓
|
||||
|
||||
1. Create registry container unless it already exists
|
||||
2. Create kind cluster with containerd registry config dir enabled
|
||||
Creating cluster "visio" ...
|
||||
✓ Ensuring node image (kindest/node:v1.27.3) 🖼
|
||||
✓ Preparing nodes 📦
|
||||
✓ Writing configuration 📜
|
||||
✓ Starting control-plane 🕹️
|
||||
✓ Installing CNI 🔌
|
||||
✓ Installing StorageClass 💾
|
||||
Set kubectl context to "kind-visio"
|
||||
You can now use your cluster with:
|
||||
|
||||
kubectl cluster-info --context kind-visio
|
||||
|
||||
Thanks for using kind! 😊
|
||||
3. Add the registry config to the nodes
|
||||
4. Connect the registry to the cluster network if not already connected
|
||||
5. Document the local registry
|
||||
configmap/local-registry-hosting created
|
||||
Warning: resource configmaps/coredns is missing the kubectl.kubernetes.io/last-applied-configuration annotation which is required by kubectl apply. kubectl apply should only be used on resources created declaratively by either kubectl create --save-config or kubectl apply. The missing annotation will be patched automatically.
|
||||
configmap/coredns configured
|
||||
deployment.apps/coredns restarted
|
||||
6. Install ingress-nginx
|
||||
namespace/ingress-nginx created
|
||||
serviceaccount/ingress-nginx created
|
||||
serviceaccount/ingress-nginx-admission created
|
||||
role.rbac.authorization.k8s.io/ingress-nginx created
|
||||
role.rbac.authorization.k8s.io/ingress-nginx-admission created
|
||||
clusterrole.rbac.authorization.k8s.io/ingress-nginx created
|
||||
clusterrole.rbac.authorization.k8s.io/ingress-nginx-admission created
|
||||
rolebinding.rbac.authorization.k8s.io/ingress-nginx created
|
||||
rolebinding.rbac.authorization.k8s.io/ingress-nginx-admission created
|
||||
clusterrolebinding.rbac.authorization.k8s.io/ingress-nginx created
|
||||
clusterrolebinding.rbac.authorization.k8s.io/ingress-nginx-admission created
|
||||
configmap/ingress-nginx-controller created
|
||||
service/ingress-nginx-controller created
|
||||
service/ingress-nginx-controller-admission created
|
||||
deployment.apps/ingress-nginx-controller created
|
||||
job.batch/ingress-nginx-admission-create created
|
||||
job.batch/ingress-nginx-admission-patch created
|
||||
ingressclass.networking.k8s.io/nginx created
|
||||
validatingwebhookconfiguration.admissionregistration.k8s.io/ingress-nginx-admission created
|
||||
secret/mkcert created
|
||||
deployment.apps/ingress-nginx-controller patched
|
||||
7. Setup namespace
|
||||
namespace/meet created
|
||||
Context "kind-visio" modified.
|
||||
secret/mkcert created
|
||||
$ kind get clusters
|
||||
visio
|
||||
$ kubectl -n ingress-nginx get po
|
||||
NAME READY STATUS RESTARTS AGE
|
||||
ingress-nginx-admission-create-jgnc9 0/1 Completed 0 2m44s
|
||||
ingress-nginx-admission-patch-wrt47 0/1 Completed 0 2m44s
|
||||
ingress-nginx-controller-57c548c4cd-9xwt6 1/1 Running 0 2m44s
|
||||
```
|
||||
When your k8s cluster is ready, you can start the deployment. This cluster is special because it uses the *.127.0.0.1.nip.io domain and mkcert certificates to have full HTTPS support and easy domain name management.
|
||||
|
||||
Please remember that *.127.0.0.1.nip.io will always resolve to 127.0.0.1, except in the k8s cluster where we configure CoreDNS to answer with the ingress-nginx service IP.
|
||||
|
||||
## Preparation
|
||||
|
||||
### What will you use to authenticate your users ?
|
||||
|
||||
Visio uses OIDC, so if you already have an OIDC provider, obtain the necessary information to use it. In the next step, we will see how to configure Django (and thus Visio) to use it. If you do not have a provider, we will show you how to deploy a local Keycloak instance (this is not a production deployment, just a demo).
|
||||
|
||||
If you haven't run the script **bin/start-kind.sh**, you'll need to manually create the namespace by running the following command:
|
||||
|
||||
```
|
||||
$ kubectl create namespace meet
|
||||
```
|
||||
|
||||
If you have already run the script, you can skip this step and proceed to the next instruction.
|
||||
|
||||
```
|
||||
$ kubectl config set-context --current --namespace=meet
|
||||
$ helm install keycloak oci://registry-1.docker.io/bitnamicharts/keycloak -f examples/keycloak.values.yaml
|
||||
$ #wait until
|
||||
$ kubectl get po
|
||||
NAME READY STATUS RESTARTS AGE
|
||||
keycloak-0 1/1 Running 0 6m48s
|
||||
keycloak-postgresql-0 1/1 Running 0 6m48s
|
||||
```
|
||||
|
||||
From here the important information you will need are :
|
||||
|
||||
```
|
||||
OIDC_OP_JWKS_ENDPOINT: https://keycloak.127.0.0.1.nip.io/realms/meet/protocol/openid-connect/certs
|
||||
OIDC_OP_AUTHORIZATION_ENDPOINT: https://keycloak.127.0.0.1.nip.io/realms/meet/protocol/openid-connect/auth
|
||||
OIDC_OP_TOKEN_ENDPOINT: https://keycloak.127.0.0.1.nip.io/realms/meet/protocol/openid-connect/token
|
||||
OIDC_OP_USER_ENDPOINT: https://keycloak.127.0.0.1.nip.io/realms/meet/protocol/openid-connect/userinfo
|
||||
OIDC_OP_LOGOUT_ENDPOINT: https://keycloak.127.0.0.1.nip.io/realms/meet/protocol/openid-connect/session/end
|
||||
OIDC_RP_CLIENT_ID: meet
|
||||
OIDC_RP_CLIENT_SECRET: ThisIsAnExampleKeyForDevPurposeOnly
|
||||
OIDC_RP_SIGN_ALGO: RS256
|
||||
OIDC_RP_SCOPES: "openid email"
|
||||
```
|
||||
|
||||
You can find these values in **examples/keycloak.values.yaml**
|
||||
|
||||
### Find livekit server connexion values
|
||||
|
||||
Visio use livekit for streaming part so if you have a livekit provider, obtain the necessary information to use it. If you do not have a provider, you can install a livekit testing environment as follows:
|
||||
|
||||
Livekit need a redis (and meet too) so we will start by deploying a redis :
|
||||
|
||||
```
|
||||
$ helm install redis oci://registry-1.docker.io/bitnamicharts/redis -f examples/redis.values.yaml
|
||||
$ kubectl get po
|
||||
NAME READY STATUS RESTARTS AGE
|
||||
keycloak-0 1/1 Running 0 26m
|
||||
keycloak-postgresql-0 1/1 Running 0 26m
|
||||
redis-master-0 1/1 Running 0 35s
|
||||
```
|
||||
When the redis is ready we can deploy livekit-server.
|
||||
|
||||
```
|
||||
$ helm repo add livekit https://helm.livekit.io
|
||||
$ helm repo update
|
||||
$ helm install livekit livekit/livekit-server -f examples/livekit.values.yaml
|
||||
$ kubectl get po
|
||||
NAME READY STATUS RESTARTS AGE
|
||||
keycloak-0 1/1 Running 0 30m
|
||||
keycloak-postgresql-0 1/1 Running 0 30m
|
||||
livekit-livekit-server-5c5fb87f7f-ct6x5 1/1 Running 0 7s
|
||||
redis-master-0 1/1 Running 0 4m30s
|
||||
$ curl https://livekit.127.0.0.1.nip.io
|
||||
OK
|
||||
```
|
||||
|
||||
From here important information you will need are :
|
||||
|
||||
```
|
||||
LIVEKIT_API_SECRET: secret
|
||||
LIVEKIT_API_KEY: devkey
|
||||
LIVEKIT_API_URL: https://livekit.127.0.0.1.nip.io/
|
||||
REDIS_URL: redis://default:pass@redis-master:6379/1
|
||||
CELERY_BROKER_URL: redis://default:pass@redis-master:6379/1
|
||||
CELERY_RESULT_BACKEND: redis://default:pass@redis-master:6379/1
|
||||
```
|
||||
|
||||
### Find postgresql connexion values
|
||||
|
||||
Visio uses a postgresql db as backend so if you have a provider, obtain the necessary information to use it. If you do not have, you can install a postgresql testing environment as follows:
|
||||
|
||||
```
|
||||
$ helm install postgresql oci://registry-1.docker.io/bitnamicharts/postgresql -f examples/postgresql.values.yaml
|
||||
$ kubectl get po
|
||||
NAME READY STATUS RESTARTS AGE
|
||||
keycloak-0 1/1 Running 0 45m
|
||||
keycloak-postgresql-0 1/1 Running 0 45m
|
||||
livekit-livekit-server-5c5fb87f7f-ct6x5 1/1 Running 0 15m
|
||||
postgresql-0 1/1 Running 0 50s
|
||||
redis-master-0 1/1 Running 0 19
|
||||
```
|
||||
From here important information you will need are :
|
||||
|
||||
```
|
||||
DB_HOST: postgres-postgresql
|
||||
DB_NAME: meet
|
||||
DB_USER: dinum
|
||||
DB_PASSWORD: pass
|
||||
DB_PORT: 5432
|
||||
POSTGRES_DB: meet
|
||||
POSTGRES_USER: dinum
|
||||
POSTGRES_PASSWORD: pass
|
||||
```
|
||||
|
||||
## Deployment
|
||||
|
||||
Now you are ready to deploy Visio without AI. AI required more dependencies (Openai-compliant API, LiveKit Egress, Cold storage and a docs deployment to push resumes). To deploy meet you need to provide all previous information to the helm chart.
|
||||
|
||||
```
|
||||
$ helm repo add meet https://numerique-gouv.github.io/meet/
|
||||
$ helm repo update
|
||||
$ helm install meet meet/meet -f examples/meet.values.yaml
|
||||
```
|
||||
|
||||
## Test your deployment
|
||||
|
||||
In order to test your deployment you have to log in to your instance. If you use exclusively our examples you can do:
|
||||
|
||||
```
|
||||
$ kubectl get ingress
|
||||
NAME CLASS HOSTS ADDRESS PORTS AGE
|
||||
keycloak <none> keycloak.127.0.0.1.nip.io localhost 80 58m
|
||||
livekit-livekit-server <none> livekit.127.0.0.1.nip.io localhost 80, 443 106m
|
||||
meet <none> meet.127.0.0.1.nip.io localhost 80, 443 52m
|
||||
meet-admin <none> meet.127.0.0.1.nip.io localhost 80, 443 52m
|
||||
```
|
||||
|
||||
You can use Visio on https://meet.127.0.0.1.nip.io. The provisioning user in keycloak is meet/meet.
|
||||
@@ -22,19 +22,7 @@ class UserAdmin(auth_admin.UserAdmin):
|
||||
)
|
||||
},
|
||||
),
|
||||
(
|
||||
_("Personal info"),
|
||||
{
|
||||
"fields": (
|
||||
"sub",
|
||||
"email",
|
||||
"full_name",
|
||||
"short_name",
|
||||
"language",
|
||||
"timezone",
|
||||
)
|
||||
},
|
||||
),
|
||||
(_("Personal info"), {"fields": ("sub", "email", "language", "timezone")}),
|
||||
(
|
||||
_("Permissions"),
|
||||
{
|
||||
@@ -64,8 +52,6 @@ class UserAdmin(auth_admin.UserAdmin):
|
||||
"sub",
|
||||
"admin_email",
|
||||
"email",
|
||||
"full_name",
|
||||
"short_name",
|
||||
"is_active",
|
||||
"is_staff",
|
||||
"is_superuser",
|
||||
@@ -74,24 +60,9 @@ class UserAdmin(auth_admin.UserAdmin):
|
||||
"updated_at",
|
||||
)
|
||||
list_filter = ("is_staff", "is_superuser", "is_device", "is_active")
|
||||
ordering = (
|
||||
"is_active",
|
||||
"-is_superuser",
|
||||
"-is_staff",
|
||||
"-is_device",
|
||||
"-updated_at",
|
||||
"full_name",
|
||||
)
|
||||
readonly_fields = (
|
||||
"id",
|
||||
"sub",
|
||||
"email",
|
||||
"full_name",
|
||||
"short_name",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
)
|
||||
search_fields = ("id", "sub", "admin_email", "email", "full_name")
|
||||
ordering = ("is_active", "-is_superuser", "-is_staff", "-is_device", "-updated_at")
|
||||
readonly_fields = ("id", "sub", "email", "created_at", "updated_at")
|
||||
search_fields = ("id", "sub", "admin_email", "email")
|
||||
|
||||
|
||||
class ResourceAccessInline(admin.TabularInline):
|
||||
|
||||
@@ -14,8 +14,8 @@ class UserSerializer(serializers.ModelSerializer):
|
||||
|
||||
class Meta:
|
||||
model = models.User
|
||||
fields = ["id", "email", "full_name", "short_name"]
|
||||
read_only_fields = ["id", "email", "full_name", "short_name"]
|
||||
fields = ["id", "email"]
|
||||
read_only_fields = ["id", "email"]
|
||||
|
||||
|
||||
class ResourceAccessSerializerMixin:
|
||||
|
||||
@@ -32,7 +32,6 @@ from core.recording.event.exceptions import (
|
||||
InvalidFileTypeError,
|
||||
ParsingEventDataError,
|
||||
)
|
||||
from core.recording.event.notification import notification_service
|
||||
from core.recording.event.parsers import get_parser
|
||||
from core.recording.worker.exceptions import (
|
||||
RecordingStartError,
|
||||
@@ -449,17 +448,7 @@ class RecordingViewSet(
|
||||
" in an error state or has already been saved."
|
||||
)
|
||||
|
||||
# Attempt to notify external services about the recording
|
||||
# This is a non-blocking operation - failures are logged but don't interrupt the flow
|
||||
notification_succeeded = notification_service.notify_external_services(
|
||||
recording
|
||||
)
|
||||
|
||||
recording.status = (
|
||||
models.RecordingStatusChoices.NOTIFICATION_SUCCEEDED
|
||||
if notification_succeeded
|
||||
else models.RecordingStatusChoices.SAVED
|
||||
)
|
||||
recording.status = models.RecordingStatusChoices.SAVED
|
||||
recording.save()
|
||||
|
||||
return drf_response.Response(
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"""Authentication Backends for the Meet core app."""
|
||||
|
||||
from django.conf import settings
|
||||
from django.core.exceptions import ImproperlyConfigured, SuspiciousOperation
|
||||
from django.core.exceptions import SuspiciousOperation
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
import requests
|
||||
@@ -10,11 +10,6 @@ from mozilla_django_oidc.auth import (
|
||||
)
|
||||
|
||||
from core.models import User
|
||||
from core.services.marketing_service import (
|
||||
ContactCreationError,
|
||||
ContactData,
|
||||
get_marketing_service,
|
||||
)
|
||||
|
||||
|
||||
class OIDCAuthenticationBackend(MozillaOIDCAuthenticationBackend):
|
||||
@@ -80,51 +75,20 @@ class OIDCAuthenticationBackend(MozillaOIDCAuthenticationBackend):
|
||||
email = user_info.get("email")
|
||||
user = self.get_existing_user(sub, email)
|
||||
|
||||
claims = {
|
||||
"email": email,
|
||||
"full_name": self.compute_full_name(user_info),
|
||||
"short_name": user_info.get(settings.OIDC_USERINFO_SHORTNAME_FIELD),
|
||||
}
|
||||
if not user and self.get_settings("OIDC_CREATE_USER", True):
|
||||
user = User.objects.create(
|
||||
sub=sub,
|
||||
email=email,
|
||||
password="!", # noqa: S106
|
||||
**claims,
|
||||
)
|
||||
|
||||
if settings.SIGNUP_NEW_USER_TO_MARKETING_EMAIL:
|
||||
self.signup_to_marketing_email(email)
|
||||
|
||||
elif not user:
|
||||
return None
|
||||
|
||||
if not user.is_active:
|
||||
raise SuspiciousOperation(_("User account is disabled"))
|
||||
|
||||
self.update_user_if_needed(user, claims)
|
||||
|
||||
return user
|
||||
|
||||
@staticmethod
|
||||
def signup_to_marketing_email(email):
|
||||
"""Pragmatic approach to newsletter signup during authentication flow.
|
||||
|
||||
Details:
|
||||
1. Uses a very short timeout (1s) to prevent blocking the auth process
|
||||
2. Silently fails if the marketing service is down/slow to prioritize user experience
|
||||
3. Trade-off: May miss some signups but ensures auth flow remains fast
|
||||
|
||||
Note: For a more robust solution, consider using Async task processing (Celery/Django-Q)
|
||||
"""
|
||||
try:
|
||||
marketing_service = get_marketing_service()
|
||||
contact_data = ContactData(
|
||||
email=email, attributes={"VISIO_SOURCE": ["SIGNIN"]}
|
||||
)
|
||||
marketing_service.create_contact(contact_data, timeout=1)
|
||||
except (ContactCreationError, ImproperlyConfigured, ImportError):
|
||||
pass
|
||||
|
||||
def get_existing_user(self, sub, email):
|
||||
"""Fetch existing user by sub or email."""
|
||||
try:
|
||||
@@ -140,32 +104,3 @@ class OIDCAuthenticationBackend(MozillaOIDCAuthenticationBackend):
|
||||
_("Multiple user accounts share a common email.")
|
||||
) from e
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def compute_full_name(user_info):
|
||||
"""Compute user's full name based on OIDC fields in settings."""
|
||||
full_name = " ".join(
|
||||
filter(
|
||||
None,
|
||||
(
|
||||
user_info.get(field)
|
||||
for field in settings.OIDC_USERINFO_FULLNAME_FIELDS
|
||||
),
|
||||
)
|
||||
)
|
||||
return full_name or None
|
||||
|
||||
@staticmethod
|
||||
def update_user_if_needed(user, claims):
|
||||
"""Update user claims if they have changed."""
|
||||
user_fields = vars(user.__class__) # Get available model fields
|
||||
updated_claims = {
|
||||
key: value
|
||||
for key, value in claims.items()
|
||||
if value and key in user_fields and value != getattr(user, key)
|
||||
}
|
||||
|
||||
if not updated_claims:
|
||||
return
|
||||
|
||||
User.objects.filter(sub=user.sub).update(**updated_claims)
|
||||
|
||||
@@ -145,7 +145,7 @@ class OIDCLogoutCallbackView(MozillaOIDCOIDCLogoutView):
|
||||
|
||||
|
||||
class OIDCAuthenticationCallbackView(MozillaOIDCAuthenticationCallbackView):
|
||||
"""Custom callback view for handling the silent login flow."""
|
||||
"""Custom callback view for handling the silent loging flow."""
|
||||
|
||||
@property
|
||||
def failure_url(self):
|
||||
@@ -162,7 +162,7 @@ class OIDCAuthenticationCallbackView(MozillaOIDCAuthenticationCallbackView):
|
||||
|
||||
|
||||
class OIDCAuthenticationRequestView(MozillaOIDCAuthenticationRequestView):
|
||||
"""Custom authentication view for handling the silent login flow."""
|
||||
"""Custom authentication view for handling the silent loging flow."""
|
||||
|
||||
def get_extra_params(self, request):
|
||||
"""Handle 'prompt' extra parameter for the silent login flow
|
||||
|
||||
@@ -23,8 +23,6 @@ class UserFactory(factory.django.DjangoModelFactory):
|
||||
|
||||
sub = factory.Sequence(lambda n: f"user{n!s}")
|
||||
email = factory.Faker("email")
|
||||
full_name = factory.Faker("name")
|
||||
short_name = factory.Faker("first_name")
|
||||
language = factory.fuzzy.FuzzyChoice([lang[0] for lang in settings.LANGUAGES])
|
||||
password = make_password("password")
|
||||
|
||||
|
||||
@@ -1,23 +0,0 @@
|
||||
# Generated by Django 5.1.1 on 2024-11-13 09:11
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('core', '0007_recording_mode'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='user',
|
||||
name='full_name',
|
||||
field=models.CharField(blank=True, max_length=100, null=True, verbose_name='full name'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='user',
|
||||
name='short_name',
|
||||
field=models.CharField(blank=True, max_length=100, null=True, verbose_name='short name'),
|
||||
)
|
||||
]
|
||||
@@ -1,18 +0,0 @@
|
||||
# Generated by Django 5.1.3 on 2024-12-02 13:23
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('core', '0008_user_full_name_user_short_name'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='recording',
|
||||
name='status',
|
||||
field=models.CharField(choices=[('initiated', 'Initiated'), ('active', 'Active'), ('stopped', 'Stopped'), ('saved', 'Saved'), ('aborted', 'Aborted'), ('failed_to_start', 'Failed to Start'), ('failed_to_stop', 'Failed to Stop'), ('notification_succeeded', 'Notification succeeded')], default='initiated', max_length=50),
|
||||
),
|
||||
]
|
||||
@@ -1,21 +0,0 @@
|
||||
# Generated by Django 5.1.4 on 2025-01-13 12:25
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('core', '0009_alter_recording_status'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterModelOptions(
|
||||
name='resourceaccess',
|
||||
options={'ordering': ('-created_at',), 'verbose_name': 'Resource access', 'verbose_name_plural': 'Resource accesses'},
|
||||
),
|
||||
migrations.AlterModelOptions(
|
||||
name='user',
|
||||
options={'ordering': ('-created_at',), 'verbose_name': 'user', 'verbose_name_plural': 'users'},
|
||||
),
|
||||
]
|
||||
@@ -49,7 +49,6 @@ class RecordingStatusChoices(models.TextChoices):
|
||||
ABORTED = "aborted", _("Aborted")
|
||||
FAILED_TO_START = "failed_to_start", _("Failed to Start")
|
||||
FAILED_TO_STOP = "failed_to_stop", _("Failed to Stop")
|
||||
NOTIFICATION_SUCCEEDED = "notification_succeeded", _("Notification succeeded")
|
||||
|
||||
@classmethod
|
||||
def is_final(cls, status):
|
||||
@@ -142,14 +141,11 @@ class User(AbstractBaseUser, BaseModel, auth_models.PermissionsMixin):
|
||||
email = models.EmailField(_("identity email address"), blank=True, null=True)
|
||||
|
||||
# Unlike the "email" field which stores the email coming from the OIDC token, this field
|
||||
# stores the email used by staff users to log in to the admin site
|
||||
# stores the email used by staff users to login to the admin site
|
||||
admin_email = models.EmailField(
|
||||
_("admin email address"), unique=True, blank=True, null=True
|
||||
)
|
||||
full_name = models.CharField(_("full name"), max_length=100, null=True, blank=True)
|
||||
short_name = models.CharField(
|
||||
_("short name"), max_length=100, null=True, blank=True
|
||||
)
|
||||
|
||||
language = models.CharField(
|
||||
max_length=10,
|
||||
choices=lazy(lambda: settings.LANGUAGES, tuple)(),
|
||||
@@ -189,7 +185,6 @@ class User(AbstractBaseUser, BaseModel, auth_models.PermissionsMixin):
|
||||
|
||||
class Meta:
|
||||
db_table = "meet_user"
|
||||
ordering = ("-created_at",)
|
||||
verbose_name = _("user")
|
||||
verbose_name_plural = _("users")
|
||||
|
||||
@@ -209,6 +204,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]}"
|
||||
|
||||
|
||||
def get_resource_roles(resource: models.Model, user: User) -> List[str]:
|
||||
"""
|
||||
@@ -305,7 +307,6 @@ class ResourceAccess(BaseModel):
|
||||
|
||||
class Meta:
|
||||
db_table = "meet_resource_access"
|
||||
ordering = ("-created_at",)
|
||||
verbose_name = _("Resource access")
|
||||
verbose_name_plural = _("Resource accesses")
|
||||
constraints = [
|
||||
@@ -465,23 +466,7 @@ class BaseAccess(BaseModel):
|
||||
|
||||
|
||||
class Recording(BaseModel):
|
||||
"""Model for recordings that take place in a room.
|
||||
|
||||
Recording Status Flow:
|
||||
1. INITIATED: Initial state when recording is requested
|
||||
2. ACTIVE: Recording is currently in progress
|
||||
3. STOPPED: Recording has been stopped by user/system
|
||||
4. SAVED: Recording has been successfully processed and stored
|
||||
4. NOTIFICATION_SUCCEEDED: External service has been notified of this recording
|
||||
|
||||
Error States:
|
||||
- FAILED_TO_START: Worker failed to initialize recording
|
||||
- FAILED_TO_STOP: Worker failed during stop operation
|
||||
- ABORTED: Recording was terminated before completion
|
||||
|
||||
Warning: Worker failures may lead to database inconsistency between the actual
|
||||
recording state and its status in the database.
|
||||
"""
|
||||
"""Model for recordings that take place in a room"""
|
||||
|
||||
room = models.ForeignKey(
|
||||
Room,
|
||||
@@ -490,7 +475,7 @@ class Recording(BaseModel):
|
||||
verbose_name=_("Room"),
|
||||
)
|
||||
status = models.CharField(
|
||||
max_length=50,
|
||||
max_length=20,
|
||||
choices=RecordingStatusChoices.choices,
|
||||
default=RecordingStatusChoices.INITIATED,
|
||||
)
|
||||
@@ -562,7 +547,22 @@ class Recording(BaseModel):
|
||||
|
||||
|
||||
class RecordingAccess(BaseAccess):
|
||||
"""Relation model to give access to a recording for a user or a team with a role."""
|
||||
"""Relation model to give access to a recording for a user or a team with a role.
|
||||
|
||||
Recording Status Flow:
|
||||
1. INITIATED: Initial state when recording is requested
|
||||
2. ACTIVE: Recording is currently in progress
|
||||
3. STOPPED: Recording has been stopped by user/system
|
||||
4. SAVED: Recording has been successfully processed and stored
|
||||
|
||||
Error States:
|
||||
- FAILED_TO_START: Worker failed to initialize recording
|
||||
- FAILED_TO_STOP: Worker failed during stop operation
|
||||
- ABORTED: Recording was terminated before completion
|
||||
|
||||
Warning: Worker failures may lead to database inconsistency between the actual
|
||||
recording state and its status in the database.
|
||||
"""
|
||||
|
||||
recording = models.ForeignKey(
|
||||
Recording,
|
||||
|
||||
@@ -47,6 +47,8 @@ class StorageEventAuthentication(BaseAuthentication):
|
||||
|
||||
def authenticate(self, request):
|
||||
"""Validate the Bearer token from the Authorization header."""
|
||||
if not settings.RECORDING_ENABLE_STORAGE_EVENT_AUTH:
|
||||
return MachineUser(), None
|
||||
|
||||
if not settings.RECORDING_ENABLE_STORAGE_EVENT_AUTH:
|
||||
return MachineUser(), None
|
||||
|
||||
@@ -1,93 +0,0 @@
|
||||
"""Service to notify external services when a new recording is ready."""
|
||||
|
||||
import logging
|
||||
|
||||
from django.conf import settings
|
||||
|
||||
import requests
|
||||
|
||||
from core import models
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class NotificationService:
|
||||
"""Service for processing recordings and notifying external services."""
|
||||
|
||||
def notify_external_services(self, recording):
|
||||
"""Process a recording based on its mode."""
|
||||
|
||||
if recording.mode == models.RecordingModeChoices.TRANSCRIPT:
|
||||
return self._notify_summary_service(recording)
|
||||
|
||||
if recording.mode == models.RecordingModeChoices.SCREEN_RECORDING:
|
||||
logger.warning(
|
||||
"Screen recording mode not implemented for recording %s", recording.id
|
||||
)
|
||||
return False
|
||||
|
||||
logger.error(
|
||||
"Unknown recording mode %s for recording %s",
|
||||
recording.mode,
|
||||
recording.id,
|
||||
)
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
def _notify_summary_service(recording):
|
||||
"""Notify summary service about a new recording."""
|
||||
|
||||
if (
|
||||
not settings.SUMMARY_SERVICE_ENDPOINT
|
||||
or not settings.SUMMARY_SERVICE_API_TOKEN
|
||||
):
|
||||
logger.error("Summary service not configured")
|
||||
return False
|
||||
|
||||
owner_access = (
|
||||
models.RecordingAccess.objects.select_related("user")
|
||||
.filter(
|
||||
role=models.RoleChoices.OWNER,
|
||||
recording_id=recording.id,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
|
||||
if not owner_access:
|
||||
logger.error("No owner found for recording %s", recording.id)
|
||||
return False
|
||||
|
||||
key = f"{settings.RECORDING_OUTPUT_FOLDER}/{recording.id}.ogg"
|
||||
|
||||
payload = {
|
||||
"filename": key,
|
||||
"email": owner_access.user.email,
|
||||
"sub": owner_access.user.sub,
|
||||
}
|
||||
|
||||
headers = {
|
||||
"Content-Type": "application/json",
|
||||
"Authorization": f"Bearer {settings.SUMMARY_SERVICE_API_TOKEN}",
|
||||
}
|
||||
|
||||
try:
|
||||
response = requests.post(
|
||||
settings.SUMMARY_SERVICE_ENDPOINT,
|
||||
json=payload,
|
||||
headers=headers,
|
||||
timeout=30,
|
||||
)
|
||||
response.raise_for_status()
|
||||
except requests.HTTPError as exc:
|
||||
logger.exception(
|
||||
"Summary service HTTP error for recording %s. URL: %s. Exception: %s",
|
||||
recording.id,
|
||||
settings.SUMMARY_SERVICE_ENDPOINT,
|
||||
exc,
|
||||
)
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
|
||||
notification_service = NotificationService()
|
||||
@@ -1,134 +0,0 @@
|
||||
"""Marketing service in charge of pushing data for marketing automation."""
|
||||
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
from functools import lru_cache
|
||||
from typing import Dict, List, Optional, Protocol
|
||||
|
||||
from django.conf import settings
|
||||
from django.core.exceptions import ImproperlyConfigured
|
||||
from django.utils.module_loading import import_string
|
||||
|
||||
import brevo_python
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ContactCreationError(Exception):
|
||||
"""Raised when the contact creation fails."""
|
||||
|
||||
|
||||
@dataclass
|
||||
class ContactData:
|
||||
"""Contact data for marketing service integration."""
|
||||
|
||||
email: str
|
||||
attributes: Optional[Dict[str, str]] = None
|
||||
list_ids: Optional[List[int]] = None
|
||||
update_enabled: bool = True
|
||||
|
||||
|
||||
class MarketingServiceProtocol(Protocol):
|
||||
"""Interface for marketing automation service integrations."""
|
||||
|
||||
def create_contact(
|
||||
self, contact_data: ContactData, timeout: Optional[int] = None
|
||||
) -> dict:
|
||||
"""Create or update a contact.
|
||||
|
||||
Args:
|
||||
contact_data: Contact information and attributes
|
||||
timeout: API request timeout in seconds
|
||||
|
||||
Returns:
|
||||
dict: Service response
|
||||
|
||||
Raises:
|
||||
ContactCreationError: If contact creation fails
|
||||
"""
|
||||
|
||||
|
||||
class BrevoMarketingService:
|
||||
"""Brevo marketing automation integration.
|
||||
|
||||
Handles:
|
||||
- Contact management and segmentation
|
||||
- Marketing campaigns and automation
|
||||
- Email communications
|
||||
|
||||
Configuration via Django settings:
|
||||
- BREVO_API_KEY: API authentication
|
||||
- BREVO_API_CONTACT_LIST_IDS: Default contact lists
|
||||
- BREVO_API_CONTACT_ATTRIBUTES: Default contact attributes
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
"""Initialize Brevo (ex-sendinblue) marketing service."""
|
||||
|
||||
if not settings.BREVO_API_KEY:
|
||||
raise ImproperlyConfigured("Brevo API key is required")
|
||||
|
||||
configuration = brevo_python.Configuration()
|
||||
configuration.api_key["api-key"] = settings.BREVO_API_KEY
|
||||
|
||||
self._api_client = brevo_python.ApiClient(configuration)
|
||||
|
||||
def create_contact(self, contact_data: ContactData, timeout=None) -> dict:
|
||||
"""Create or update a Brevo contact.
|
||||
|
||||
Args:
|
||||
contact_data: Contact information and attributes
|
||||
timeout: API request timeout in seconds
|
||||
|
||||
Returns:
|
||||
dict: Brevo API response
|
||||
|
||||
Raises:
|
||||
ContactCreationError: If contact creation fails
|
||||
ImproperlyConfigured: If required settings are missing
|
||||
|
||||
Note:
|
||||
Contact attributes must be pre-configured in Brevo.
|
||||
Changes to attributes can impact existing workflows.
|
||||
"""
|
||||
|
||||
if not settings.BREVO_API_CONTACT_LIST_IDS:
|
||||
raise ImproperlyConfigured(
|
||||
"Default Brevo List IDs must be configured in settings."
|
||||
)
|
||||
|
||||
contact_api = brevo_python.ContactsApi(self._api_client)
|
||||
|
||||
attributes = {
|
||||
**settings.BREVO_API_CONTACT_ATTRIBUTES,
|
||||
**(contact_data.attributes or {}),
|
||||
}
|
||||
|
||||
list_ids = (contact_data.list_ids or []) + settings.BREVO_API_CONTACT_LIST_IDS
|
||||
|
||||
contact = brevo_python.CreateContact(
|
||||
email=contact_data.email,
|
||||
attributes=attributes,
|
||||
list_ids=list_ids,
|
||||
update_enabled=contact_data.update_enabled,
|
||||
)
|
||||
|
||||
api_configurations = {}
|
||||
|
||||
if timeout is not None:
|
||||
api_configurations["_request_timeout"] = timeout
|
||||
|
||||
try:
|
||||
response = contact_api.create_contact(contact, **api_configurations)
|
||||
except brevo_python.rest.ApiException as err:
|
||||
logger.exception("Failed to create contact in Brevo")
|
||||
raise ContactCreationError("Failed to create contact in Brevo") from err
|
||||
|
||||
return response
|
||||
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
def get_marketing_service() -> MarketingServiceProtocol:
|
||||
"""Return cached instance of configured marketing service."""
|
||||
marketing_service_cls = import_string(settings.MARKETING_SERVICE_CLASS)
|
||||
return marketing_service_cls()
|
||||
@@ -1,15 +1,12 @@
|
||||
"""Unit tests for the Authentication Backends."""
|
||||
|
||||
from unittest import mock
|
||||
|
||||
from django.core.exceptions import ImproperlyConfigured, SuspiciousOperation
|
||||
from django.core.exceptions import SuspiciousOperation
|
||||
|
||||
import pytest
|
||||
|
||||
from core import models
|
||||
from core.authentication.backends import OIDCAuthenticationBackend
|
||||
from core.factories import UserFactory
|
||||
from core.services import marketing_service
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
@@ -65,14 +62,15 @@ def test_authentication_getter_new_user_no_email(monkeypatch):
|
||||
def test_authentication_getter_new_user_with_email(monkeypatch):
|
||||
"""
|
||||
If no user matches, a user should be created.
|
||||
User's info contains an email, created user's email should be filled.
|
||||
User's email and name should be set on the identity.
|
||||
The "email" field on the User model should not be set as it is reserved for staff users.
|
||||
"""
|
||||
klass = OIDCAuthenticationBackend()
|
||||
|
||||
email = "meet@example.com"
|
||||
|
||||
def get_userinfo_mocked(*args):
|
||||
return {"sub": "123", "email": email}
|
||||
return {"sub": "123", "email": email, "first_name": "John", "last_name": "Doe"}
|
||||
|
||||
monkeypatch.setattr(OIDCAuthenticationBackend, "get_userinfo", get_userinfo_mocked)
|
||||
|
||||
@@ -82,34 +80,6 @@ def test_authentication_getter_new_user_with_email(monkeypatch):
|
||||
|
||||
assert user.sub == "123"
|
||||
assert user.email == email
|
||||
assert user.full_name is None
|
||||
assert user.short_name is None
|
||||
assert user.password == "!"
|
||||
assert models.User.objects.count() == 1
|
||||
|
||||
|
||||
@pytest.mark.parametrize("email", [None, "johndoe@foo.com"])
|
||||
def test_authentication_getter_new_user_with_names(monkeypatch, email):
|
||||
"""
|
||||
If no user matches, a user should be created.
|
||||
User's info contains name-related field, created user's full and short names should be filled,
|
||||
whether the email is filled
|
||||
"""
|
||||
klass = OIDCAuthenticationBackend()
|
||||
|
||||
def get_userinfo_mocked(*args):
|
||||
return {"sub": "123", "given_name": "John", "usual_name": "Doe", "email": email}
|
||||
|
||||
monkeypatch.setattr(OIDCAuthenticationBackend, "get_userinfo", get_userinfo_mocked)
|
||||
|
||||
user = klass.get_or_create_user(
|
||||
access_token="test-token", id_token=None, payload=None
|
||||
)
|
||||
|
||||
assert user.sub == "123"
|
||||
assert user.email == email
|
||||
assert user.full_name == "John Doe"
|
||||
assert user.short_name == "John"
|
||||
assert user.password == "!"
|
||||
assert models.User.objects.count() == 1
|
||||
|
||||
@@ -315,239 +285,3 @@ def test_authentication_getter_existing_user_email_tricky(email, monkeypatch, se
|
||||
)
|
||||
|
||||
assert user != db_user
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"given_name, usual_name, email",
|
||||
[
|
||||
("Jack", "Doe", "john.doe@example.com"),
|
||||
("John", "Duy", "john.doe@example.com"),
|
||||
("John", "Doe", "jack.duy@example.com"),
|
||||
("Jack", "Duy", "jack.duy@example.com"),
|
||||
],
|
||||
)
|
||||
def test_authentication_getter_existing_user_change_fields(
|
||||
given_name, usual_name, email, django_assert_num_queries, monkeypatch
|
||||
):
|
||||
"""It should update the email or name fields on the user when they change."""
|
||||
|
||||
klass = OIDCAuthenticationBackend()
|
||||
user = UserFactory(
|
||||
full_name="John Doe", short_name="John", email="john.doe@example.com"
|
||||
)
|
||||
|
||||
def get_userinfo_mocked(*args):
|
||||
return {
|
||||
"sub": user.sub,
|
||||
"email": email,
|
||||
"given_name": given_name,
|
||||
"usual_name": usual_name,
|
||||
}
|
||||
|
||||
monkeypatch.setattr(OIDCAuthenticationBackend, "get_userinfo", get_userinfo_mocked)
|
||||
|
||||
# One and only one additional update query when a field has changed
|
||||
with django_assert_num_queries(2):
|
||||
authenticated_user = klass.get_or_create_user(
|
||||
access_token="test-token", id_token=None, payload=None
|
||||
)
|
||||
|
||||
assert user == authenticated_user
|
||||
user.refresh_from_db()
|
||||
assert user.email == email
|
||||
assert user.full_name == f"{given_name:s} {usual_name:s}"
|
||||
assert user.short_name == given_name
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"user_info, expected_name",
|
||||
[
|
||||
({"given_name": "John", "family_name": "Doe"}, "John Doe"),
|
||||
(
|
||||
{"given_name": "John", "middle_name": "M", "family_name": "Doe"},
|
||||
"John M Doe",
|
||||
),
|
||||
({"family_name": "Doe"}, "Doe"),
|
||||
({"given_name": "", "family_name": ""}, None),
|
||||
({}, None),
|
||||
],
|
||||
)
|
||||
def test_compute_full_name(user_info, expected_name, settings):
|
||||
"""Test full name computation from OIDC user info fields."""
|
||||
settings.OIDC_USERINFO_FULLNAME_FIELDS = [
|
||||
"given_name",
|
||||
"middle_name",
|
||||
"family_name",
|
||||
]
|
||||
klass = OIDCAuthenticationBackend()
|
||||
assert klass.compute_full_name(user_info) == expected_name
|
||||
|
||||
|
||||
def test_compute_full_name_no_fields(settings):
|
||||
"""Test full name computation with empty field configuration."""
|
||||
settings.OIDC_USERINFO_FULLNAME_FIELDS = []
|
||||
klass = OIDCAuthenticationBackend()
|
||||
assert klass.compute_full_name({"given_name": "John"}) is None
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"claims",
|
||||
[
|
||||
{"email": "john.doe@example.com"}, # Same data - no change needed
|
||||
{"email": ""}, # Empty strings should not override
|
||||
{"non_related_field": "foo"}, # Unrelated fields should be ignored
|
||||
{}, # Empty claims should not affect user
|
||||
{"email": None}, # None values should be ignored
|
||||
],
|
||||
)
|
||||
def test_update_user_when_no_update_needed(django_assert_num_queries, claims):
|
||||
"""Test that user attributes remain unchanged when claims don't require updates."""
|
||||
|
||||
user = UserFactory(
|
||||
full_name="John Doe", short_name="John", email="john.doe@example.com"
|
||||
)
|
||||
|
||||
klass = OIDCAuthenticationBackend()
|
||||
|
||||
with django_assert_num_queries(0):
|
||||
klass.update_user_if_needed(user, claims)
|
||||
|
||||
user.refresh_from_db()
|
||||
|
||||
assert user.email == "john.doe@example.com"
|
||||
|
||||
|
||||
@mock.patch.object(OIDCAuthenticationBackend, "signup_to_marketing_email")
|
||||
def test_marketing_signup_new_user_enabled(mock_signup, monkeypatch, settings):
|
||||
"""Test marketing signup for new user with settings enabled."""
|
||||
settings.SIGNUP_NEW_USER_TO_MARKETING_EMAIL = True
|
||||
|
||||
klass = OIDCAuthenticationBackend()
|
||||
email = "test@example.com"
|
||||
|
||||
def get_userinfo_mocked(*args):
|
||||
return {"sub": "123", "email": email}
|
||||
|
||||
monkeypatch.setattr(OIDCAuthenticationBackend, "get_userinfo", get_userinfo_mocked)
|
||||
|
||||
user = klass.get_or_create_user("test-token", None, None)
|
||||
|
||||
assert user.email == email
|
||||
mock_signup.assert_called_once_with(email)
|
||||
|
||||
|
||||
@mock.patch.object(OIDCAuthenticationBackend, "signup_to_marketing_email")
|
||||
def test_marketing_signup_new_user_disabled(mock_signup, monkeypatch, settings):
|
||||
"""Test no marketing signup for new user with settings disabled."""
|
||||
settings.SIGNUP_NEW_USER_TO_MARKETING_EMAIL = False
|
||||
|
||||
klass = OIDCAuthenticationBackend()
|
||||
email = "test@example.com"
|
||||
|
||||
def get_userinfo_mocked(*args):
|
||||
return {"sub": "123", "email": email}
|
||||
|
||||
monkeypatch.setattr(OIDCAuthenticationBackend, "get_userinfo", get_userinfo_mocked)
|
||||
|
||||
user = klass.get_or_create_user("test-token", None, None)
|
||||
|
||||
assert user.email == email
|
||||
mock_signup.assert_not_called()
|
||||
|
||||
|
||||
@mock.patch.object(OIDCAuthenticationBackend, "signup_to_marketing_email")
|
||||
def test_marketing_signup_new_user_default_disabled(mock_signup, monkeypatch):
|
||||
"""Test no marketing signup for new user with settings by default disabled."""
|
||||
|
||||
klass = OIDCAuthenticationBackend()
|
||||
email = "test@example.com"
|
||||
|
||||
def get_userinfo_mocked(*args):
|
||||
return {"sub": "123", "email": email}
|
||||
|
||||
monkeypatch.setattr(OIDCAuthenticationBackend, "get_userinfo", get_userinfo_mocked)
|
||||
|
||||
user = klass.get_or_create_user("test-token", None, None)
|
||||
|
||||
assert user.email == email
|
||||
mock_signup.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"is_signup_enabled",
|
||||
[True, False],
|
||||
)
|
||||
@mock.patch.object(OIDCAuthenticationBackend, "signup_to_marketing_email")
|
||||
def test_marketing_signup_existing_user(
|
||||
mock_signup, monkeypatch, settings, is_signup_enabled
|
||||
):
|
||||
"""Test no marketing signup for existing user regardless of settings."""
|
||||
|
||||
settings.SIGNUP_NEW_USER_TO_MARKETING_EMAIL = is_signup_enabled
|
||||
|
||||
klass = OIDCAuthenticationBackend()
|
||||
db_user = UserFactory(email="test@example.com")
|
||||
|
||||
def get_userinfo_mocked(*args):
|
||||
return {"sub": db_user.sub, "email": db_user.email}
|
||||
|
||||
monkeypatch.setattr(OIDCAuthenticationBackend, "get_userinfo", get_userinfo_mocked)
|
||||
|
||||
user = klass.get_or_create_user("test-token", None, None)
|
||||
assert user == db_user
|
||||
mock_signup.assert_not_called()
|
||||
|
||||
|
||||
@mock.patch("core.authentication.backends.get_marketing_service")
|
||||
def test_signup_to_marketing_email_success(mock_marketing):
|
||||
"""Test successful marketing signup."""
|
||||
|
||||
email = "test@example.com"
|
||||
|
||||
# Call the method
|
||||
OIDCAuthenticationBackend.signup_to_marketing_email(email)
|
||||
|
||||
# Verify service interaction
|
||||
mock_service = mock_marketing.return_value
|
||||
mock_service.create_contact.assert_called_once()
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"error",
|
||||
[
|
||||
ImportError,
|
||||
ImproperlyConfigured,
|
||||
],
|
||||
)
|
||||
@mock.patch("core.authentication.backends.get_marketing_service")
|
||||
def test_marketing_signup_handles_service_initialization_errors(
|
||||
mock_marketing, error, settings
|
||||
):
|
||||
"""Tests errors that occur when trying to get/initialize the marketing service."""
|
||||
settings.SIGNUP_NEW_USER_TO_MARKETING_EMAIL = True
|
||||
|
||||
mock_marketing.side_effect = error
|
||||
|
||||
# Should not raise any exception
|
||||
OIDCAuthenticationBackend.signup_to_marketing_email("test@example.com")
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"error",
|
||||
[
|
||||
marketing_service.ContactCreationError,
|
||||
ImproperlyConfigured,
|
||||
ImportError,
|
||||
],
|
||||
)
|
||||
@mock.patch("core.authentication.backends.get_marketing_service")
|
||||
def test_marketing_signup_handles_contact_creation_errors(
|
||||
mock_marketing, error, settings
|
||||
):
|
||||
"""Tests errors that occur during the contact creation process."""
|
||||
|
||||
settings.SIGNUP_NEW_USER_TO_MARKETING_EMAIL = True
|
||||
mock_marketing.return_value.create_contact.side_effect = error
|
||||
|
||||
# Should not raise any exception
|
||||
OIDCAuthenticationBackend.signup_to_marketing_email("test@example.com")
|
||||
|
||||
@@ -301,8 +301,6 @@ def test_api_rooms_retrieve_members(mock_token, django_assert_num_queries):
|
||||
"user": {
|
||||
"id": str(user_access.user.id),
|
||||
"email": user_access.user.email,
|
||||
"full_name": user_access.user.full_name,
|
||||
"short_name": user_access.user.short_name,
|
||||
},
|
||||
"resource": str(room.id),
|
||||
"role": user_access.role,
|
||||
@@ -312,8 +310,6 @@ def test_api_rooms_retrieve_members(mock_token, django_assert_num_queries):
|
||||
"user": {
|
||||
"id": str(other_user_access.user.id),
|
||||
"email": other_user_access.user.email,
|
||||
"full_name": other_user_access.user.full_name,
|
||||
"short_name": other_user_access.user.short_name,
|
||||
},
|
||||
"resource": str(room.id),
|
||||
"role": other_user_access.role,
|
||||
@@ -378,8 +374,6 @@ def test_api_rooms_retrieve_administrators(mock_token, django_assert_num_queries
|
||||
"user": {
|
||||
"id": str(other_user_access.user.id),
|
||||
"email": other_user_access.user.email,
|
||||
"full_name": other_user_access.user.full_name,
|
||||
"short_name": other_user_access.user.short_name,
|
||||
},
|
||||
"resource": str(room.id),
|
||||
"role": other_user_access.role,
|
||||
@@ -389,8 +383,6 @@ def test_api_rooms_retrieve_administrators(mock_token, django_assert_num_queries
|
||||
"user": {
|
||||
"id": str(user_access.user.id),
|
||||
"email": user_access.user.email,
|
||||
"full_name": user_access.user.full_name,
|
||||
"short_name": user_access.user.short_name,
|
||||
},
|
||||
"resource": str(room.id),
|
||||
"role": user_access.role,
|
||||
|
||||
@@ -1,187 +0,0 @@
|
||||
"""
|
||||
Test marketing services.
|
||||
"""
|
||||
|
||||
# pylint: disable=W0621,W0613
|
||||
|
||||
from unittest import mock
|
||||
|
||||
from django.conf import settings
|
||||
from django.core.exceptions import ImproperlyConfigured
|
||||
|
||||
import brevo_python
|
||||
import pytest
|
||||
|
||||
from core.services.marketing_service import (
|
||||
BrevoMarketingService,
|
||||
ContactCreationError,
|
||||
ContactData,
|
||||
get_marketing_service,
|
||||
)
|
||||
|
||||
|
||||
def test_init_missing_api_key(settings):
|
||||
"""Test initialization with missing API key."""
|
||||
settings.BREVO_API_KEY = None
|
||||
with pytest.raises(ImproperlyConfigured, match="Brevo API key is required"):
|
||||
BrevoMarketingService()
|
||||
|
||||
|
||||
def test_create_contact_missing_list_ids(settings):
|
||||
"""Test contact creation with missing list IDs."""
|
||||
|
||||
settings.BREVO_API_KEY = "test-api-key"
|
||||
settings.BREVO_API_CONTACT_LIST_IDS = None
|
||||
settings.BREVO_API_CONTACT_ATTRIBUTES = {"source": "test"}
|
||||
|
||||
valid_contact_data = ContactData(
|
||||
email="test@example.com",
|
||||
attributes={"first_name": "Test"},
|
||||
list_ids=[1, 2],
|
||||
update_enabled=True,
|
||||
)
|
||||
|
||||
brevo_service = BrevoMarketingService()
|
||||
|
||||
with pytest.raises(
|
||||
ImproperlyConfigured, match="Default Brevo List IDs must be configured"
|
||||
):
|
||||
brevo_service.create_contact(valid_contact_data)
|
||||
|
||||
|
||||
@mock.patch("brevo_python.ContactsApi")
|
||||
def test_create_contact_success(mock_contact_api):
|
||||
"""Test successful contact creation."""
|
||||
|
||||
mock_api = mock_contact_api.return_value
|
||||
|
||||
settings.BREVO_API_KEY = "test-api-key"
|
||||
settings.BREVO_API_CONTACT_LIST_IDS = [1, 2, 3, 4]
|
||||
settings.BREVO_API_CONTACT_ATTRIBUTES = {"source": "test"}
|
||||
|
||||
valid_contact_data = ContactData(
|
||||
email="test@example.com",
|
||||
attributes={"first_name": "Test"},
|
||||
list_ids=[1, 2],
|
||||
update_enabled=True,
|
||||
)
|
||||
|
||||
brevo_service = BrevoMarketingService()
|
||||
|
||||
mock_api.create_contact.return_value = {"id": "test-id"}
|
||||
response = brevo_service.create_contact(valid_contact_data)
|
||||
|
||||
assert response == {"id": "test-id"}
|
||||
|
||||
mock_api.create_contact.assert_called_once()
|
||||
contact_arg = mock_api.create_contact.call_args[0][0]
|
||||
assert contact_arg.email == "test@example.com"
|
||||
assert contact_arg.attributes == {
|
||||
**settings.BREVO_API_CONTACT_ATTRIBUTES,
|
||||
**valid_contact_data.attributes,
|
||||
}
|
||||
assert set(contact_arg.list_ids) == {1, 2, 3, 4}
|
||||
assert contact_arg.update_enabled is True
|
||||
|
||||
|
||||
@mock.patch("brevo_python.ContactsApi")
|
||||
def test_create_contact_with_timeout(mock_contact_api):
|
||||
"""Test contact creation with timeout."""
|
||||
|
||||
mock_api = mock_contact_api.return_value
|
||||
|
||||
settings.BREVO_API_KEY = "test-api-key"
|
||||
settings.BREVO_API_CONTACT_LIST_IDS = [1, 2, 3, 4]
|
||||
settings.BREVO_API_CONTACT_ATTRIBUTES = {"source": "test"}
|
||||
|
||||
valid_contact_data = ContactData(
|
||||
email="test@example.com",
|
||||
attributes={"first_name": "Test"},
|
||||
list_ids=[1, 2],
|
||||
update_enabled=True,
|
||||
)
|
||||
|
||||
brevo_service = BrevoMarketingService()
|
||||
brevo_service.create_contact(valid_contact_data, timeout=30)
|
||||
|
||||
mock_api.create_contact.assert_called_once()
|
||||
assert mock_api.create_contact.call_args[1]["_request_timeout"] == 30
|
||||
|
||||
|
||||
@mock.patch("brevo_python.ContactsApi")
|
||||
def test_create_contact_api_error(mock_contact_api):
|
||||
"""Test contact creation API error handling."""
|
||||
|
||||
mock_api = mock_contact_api.return_value
|
||||
|
||||
settings.BREVO_API_KEY = "test-api-key"
|
||||
settings.BREVO_API_CONTACT_LIST_IDS = [1, 2, 3, 4]
|
||||
settings.BREVO_API_CONTACT_ATTRIBUTES = {"source": "test"}
|
||||
|
||||
valid_contact_data = ContactData(
|
||||
email="test@example.com",
|
||||
attributes={"first_name": "Test"},
|
||||
list_ids=[1, 2],
|
||||
update_enabled=True,
|
||||
)
|
||||
|
||||
brevo_service = BrevoMarketingService()
|
||||
|
||||
mock_api.create_contact.side_effect = brevo_python.rest.ApiException()
|
||||
|
||||
with pytest.raises(ContactCreationError, match="Failed to create contact in Brevo"):
|
||||
brevo_service.create_contact(valid_contact_data)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def clear_marketing_cache():
|
||||
"""Clear marketing service cache between tests."""
|
||||
get_marketing_service.cache_clear()
|
||||
yield
|
||||
get_marketing_service.cache_clear()
|
||||
|
||||
|
||||
def test_get_marketing_service_caching(clear_marketing_cache):
|
||||
"""Test marketing service caching behavior."""
|
||||
settings.BREVO_API_KEY = "test-api-key"
|
||||
settings.MARKETING_SERVICE_CLASS = (
|
||||
"core.services.marketing_service.BrevoMarketingService"
|
||||
)
|
||||
|
||||
service1 = get_marketing_service()
|
||||
service2 = get_marketing_service()
|
||||
|
||||
assert service1 is service2
|
||||
assert isinstance(service1, BrevoMarketingService)
|
||||
|
||||
|
||||
def test_get_marketing_service_invalid_class(clear_marketing_cache):
|
||||
"""Test handling of invalid service class."""
|
||||
settings.MARKETING_SERVICE_CLASS = "invalid.service.path"
|
||||
|
||||
with pytest.raises(ImportError):
|
||||
get_marketing_service()
|
||||
|
||||
|
||||
@mock.patch("core.services.marketing_service.import_string")
|
||||
def test_service_instantiation_called_once(mock_import_string, clear_marketing_cache):
|
||||
"""Test service class is instantiated only once."""
|
||||
|
||||
settings.BREVO_API_KEY = "test-api-key"
|
||||
settings.MARKETING_SERVICE_CLASS = (
|
||||
"core.services.marketing_service.BrevoMarketingService"
|
||||
)
|
||||
get_marketing_service.cache_clear()
|
||||
|
||||
mock_service_cls = mock.Mock()
|
||||
mock_service_instance = mock.Mock()
|
||||
mock_service_cls.return_value = mock_service_instance
|
||||
mock_import_string.return_value = mock_service_cls
|
||||
|
||||
service1 = get_marketing_service()
|
||||
service2 = get_marketing_service()
|
||||
|
||||
mock_import_string.assert_called_once_with(settings.MARKETING_SERVICE_CLASS)
|
||||
mock_service_cls.assert_called_once()
|
||||
assert service1 is service2
|
||||
assert service1 is mock_service_instance
|
||||
@@ -66,7 +66,7 @@ def test_api_users_list_query_email():
|
||||
|
||||
assert response.status_code == 200
|
||||
user_ids = [user["id"] for user in response.json()["results"]]
|
||||
assert user_ids == [str(frank.id), str(nicole.id)]
|
||||
assert user_ids == [str(nicole.id), str(frank.id)]
|
||||
|
||||
|
||||
def test_api_users_retrieve_me_anonymous():
|
||||
@@ -96,8 +96,6 @@ def test_api_users_retrieve_me_authenticated():
|
||||
assert response.json() == {
|
||||
"id": str(user.id),
|
||||
"email": user.email,
|
||||
"full_name": user.full_name,
|
||||
"short_name": user.short_name,
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -44,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 == ""
|
||||
|
||||
@@ -324,10 +324,8 @@ class Base(Configuration):
|
||||
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", environ_prefix=None
|
||||
)
|
||||
OIDC_VERIFY_SSL = values.BooleanValue(
|
||||
default=True, environ_name="OIDC_VERIFY_SSL", environ_prefix=None
|
||||
default=True,
|
||||
environ_name="OIDC_CREATE_USER",
|
||||
)
|
||||
OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION = values.BooleanValue(
|
||||
default=False,
|
||||
@@ -392,16 +390,6 @@ class Base(Configuration):
|
||||
OIDC_REDIRECT_FIELD_NAME = values.Value(
|
||||
"returnTo", environ_name="OIDC_REDIRECT_FIELD_NAME", environ_prefix=None
|
||||
)
|
||||
OIDC_USERINFO_FULLNAME_FIELDS = values.ListValue(
|
||||
default=["given_name", "usual_name"],
|
||||
environ_name="OIDC_USERINFO_FULLNAME_FIELDS",
|
||||
environ_prefix=None,
|
||||
)
|
||||
OIDC_USERINFO_SHORTNAME_FIELD = values.Value(
|
||||
default="given_name",
|
||||
environ_name="OIDC_USERINFO_SHORTNAME_FIELD",
|
||||
environ_prefix=None,
|
||||
)
|
||||
|
||||
# Video conference configuration
|
||||
LIVEKIT_CONFIGURATION = {
|
||||
@@ -448,36 +436,8 @@ class Base(Configuration):
|
||||
False, environ_name="RECORDING_STORAGE_EVENT_ENABLE", environ_prefix=None
|
||||
)
|
||||
RECORDING_STORAGE_EVENT_TOKEN = values.Value(
|
||||
None, environ_name="RECORDING_STORAGE_EVENT_TOKEN", environ_prefix=None
|
||||
None, environ_name="RECORDING_STORAGE_HOOK_TOKEN", environ_prefix=None
|
||||
)
|
||||
SUMMARY_SERVICE_ENDPOINT = values.Value(
|
||||
None, environ_name="SUMMARY_SERVICE_ENDPOINT", environ_prefix=None
|
||||
)
|
||||
SUMMARY_SERVICE_API_TOKEN = values.Value(
|
||||
None, environ_name="SUMMARY_SERVICE_API_TOKEN", environ_prefix=None
|
||||
)
|
||||
|
||||
# Marketing and communication settings
|
||||
SIGNUP_NEW_USER_TO_MARKETING_EMAIL = values.BooleanValue(
|
||||
False, # When enabled, new users are automatically added to mailing list.
|
||||
environ_name="SIGNUP_NEW_USER_TO_MARKETING_EMAIL",
|
||||
environ_prefix=None,
|
||||
)
|
||||
MARKETING_SERVICE_CLASS = values.Value(
|
||||
"core.services.marketing_service.BrevoMarketingService",
|
||||
environ_name="MARKETING_SERVICE_CLASS",
|
||||
environ_prefix=None,
|
||||
)
|
||||
BREVO_API_KEY = values.Value(
|
||||
None, environ_name="BREVO_API_KEY", environ_prefix=None
|
||||
)
|
||||
BREVO_API_CONTACT_LIST_IDS = values.ListValue(
|
||||
[],
|
||||
environ_name="BREVO_API_CONTACT_LIST_IDS",
|
||||
environ_prefix=None,
|
||||
converter=lambda x: int(x), # pylint: disable=unnecessary-lambda
|
||||
)
|
||||
BREVO_API_CONTACT_ATTRIBUTES = values.DictValue({"VISIO_USER": True})
|
||||
|
||||
# pylint: disable=invalid-name
|
||||
@property
|
||||
@@ -525,10 +485,8 @@ class Base(Configuration):
|
||||
release=get_release(),
|
||||
integrations=[DjangoIntegration()],
|
||||
)
|
||||
|
||||
# Add the application name to the Sentry scope
|
||||
scope = sentry_sdk.get_global_scope()
|
||||
scope.set_tag("application", "backend")
|
||||
with sentry_sdk.configure_scope() as scope:
|
||||
scope.set_extra("application", "backend")
|
||||
|
||||
|
||||
class Build(Base):
|
||||
|
||||
@@ -7,7 +7,7 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "meet"
|
||||
version = "0.1.12"
|
||||
version = "0.1.7"
|
||||
authors = [{ "name" = "DINUM", "email" = "dev@mail.numerique.gouv.fr" }]
|
||||
classifiers = [
|
||||
"Development Status :: 5 - Production/Stable",
|
||||
@@ -25,21 +25,20 @@ license = { file = "LICENSE" }
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
dependencies = [
|
||||
"boto3==1.35.97",
|
||||
"boto3==1.35.19",
|
||||
"Brotli==1.1.0",
|
||||
"brevo-python==1.1.2",
|
||||
"celery[redis]==5.4.0",
|
||||
"django-configurations==2.5.1",
|
||||
"django-cors-headers==4.6.0",
|
||||
"django-cors-headers==4.4.0",
|
||||
"django-countries==7.6.1",
|
||||
"django-parler==2.3",
|
||||
"redis==5.2.1",
|
||||
"redis==5.0.8",
|
||||
"django-redis==5.4.0",
|
||||
"django-storages[s3]==1.14.4",
|
||||
"django-timezone-field>=5.1",
|
||||
"django==5.1.5",
|
||||
"django==5.1.1",
|
||||
"djangorestframework==3.15.2",
|
||||
"drf_spectacular==0.28.0",
|
||||
"drf_spectacular==0.27.2",
|
||||
"dockerflow==2024.4.2",
|
||||
"easy_thumbnails==2.10",
|
||||
"factory_boy==3.3.1",
|
||||
@@ -48,17 +47,17 @@ dependencies = [
|
||||
"june-analytics-python==2.3.0",
|
||||
"markdown==3.7",
|
||||
"nested-multipart-parser==1.5.0",
|
||||
"psycopg[binary]==3.2.3",
|
||||
"PyJWT==2.10.1",
|
||||
"psycopg[binary]==3.2.2",
|
||||
"PyJWT==2.9.0",
|
||||
"python-frontmatter==1.1.0",
|
||||
"requests==2.32.3",
|
||||
"sentry-sdk==2.19.2",
|
||||
"sentry-sdk==2.14.0",
|
||||
"url-normalize==1.4.3",
|
||||
"WeasyPrint>=60.2",
|
||||
"whitenoise==6.8.2",
|
||||
"whitenoise==6.7.0",
|
||||
"mozilla-django-oidc==4.0.1",
|
||||
"livekit-api==0.8.1",
|
||||
"aiohttp==3.11.11",
|
||||
"livekit-api==0.7.0",
|
||||
"aiohttp==3.10.10",
|
||||
]
|
||||
|
||||
[project.urls]
|
||||
@@ -70,21 +69,21 @@ dependencies = [
|
||||
[project.optional-dependencies]
|
||||
dev = [
|
||||
"django-extensions==3.2.3",
|
||||
"drf-spectacular-sidecar==2024.12.1",
|
||||
"drf-spectacular-sidecar==2024.7.1",
|
||||
"freezegun==1.5.1",
|
||||
"ipdb==0.13.13",
|
||||
"ipython==8.31.0",
|
||||
"pyfakefs==5.7.3",
|
||||
"pylint-django==2.6.1",
|
||||
"pylint==3.3.3",
|
||||
"pytest-cov==6.0.0",
|
||||
"ipython==8.27.0",
|
||||
"pyfakefs==5.6.0",
|
||||
"pylint-django==2.5.5",
|
||||
"pylint==3.2.7",
|
||||
"pytest-cov==5.0.0",
|
||||
"pytest-django==4.9.0",
|
||||
"pytest==8.3.4",
|
||||
"pytest==8.3.3",
|
||||
"pytest-icdiff==0.9",
|
||||
"pytest-xdist==3.6.1",
|
||||
"responses==0.25.5",
|
||||
"ruff==0.9.1",
|
||||
"types-requests==2.32.0.20241016",
|
||||
"responses==0.25.3",
|
||||
"ruff==0.6.5",
|
||||
"types-requests==2.32.0.20240914",
|
||||
]
|
||||
|
||||
[tool.setuptools]
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
<html lang="en" data-lk-theme="visio-light">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/assets/icon.svg" />
|
||||
<link rel="icon" type="image/svg+xml" href="/play-icon.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Visio</title>
|
||||
</head>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "meet",
|
||||
"private": true,
|
||||
"version": "0.1.12",
|
||||
"version": "0.1.7",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "panda codegen && vite",
|
||||
@@ -13,48 +13,48 @@
|
||||
"check": "prettier --check ./src"
|
||||
},
|
||||
"dependencies": {
|
||||
"@livekit/components-react": "2.6.9",
|
||||
"@livekit/components-styles": "1.1.4",
|
||||
"@livekit/components-react": "2.6.5",
|
||||
"@livekit/components-styles": "1.1.3",
|
||||
"@livekit/track-processors": "0.3.2",
|
||||
"@pandacss/preset-panda": "0.48.0",
|
||||
"@react-aria/toast": "3.0.0-beta.18",
|
||||
"@remixicon/react": "4.5.0",
|
||||
"@tanstack/react-query": "5.61.3",
|
||||
"@pandacss/preset-panda": "0.46.1",
|
||||
"@react-aria/toast": "3.0.0-beta.16",
|
||||
"@remixicon/react": "4.2.0",
|
||||
"@tanstack/react-query": "5.59.4",
|
||||
"crisp-sdk-web": "1.0.25",
|
||||
"hoofd": "1.7.1",
|
||||
"i18next": "24.0.2",
|
||||
"i18next": "23.15.2",
|
||||
"i18next-browser-languagedetector": "8.0.0",
|
||||
"i18next-parser": "9.0.2",
|
||||
"i18next-resources-to-backend": "1.2.1",
|
||||
"livekit-client": "2.6.3",
|
||||
"posthog-js": "1.188.0",
|
||||
"livekit-client": "2.5.7",
|
||||
"posthog-js": "1.167.0",
|
||||
"react": "18.3.1",
|
||||
"react-aria-components": "1.5.0",
|
||||
"react-aria-components": "1.4.0",
|
||||
"react-dom": "18.3.1",
|
||||
"react-i18next": "15.1.1",
|
||||
"react-i18next": "15.0.2",
|
||||
"use-sound": "4.0.3",
|
||||
"valtio": "2.1.2",
|
||||
"valtio": "2.0.0",
|
||||
"wouter": "3.3.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@pandacss/dev": "0.48.0",
|
||||
"@tanstack/eslint-plugin-query": "5.61.3",
|
||||
"@tanstack/react-query-devtools": "5.61.3",
|
||||
"@types/node": "22.9.3",
|
||||
"@types/react": "18.3.12",
|
||||
"@types/react-dom": "18.3.1",
|
||||
"@typescript-eslint/eslint-plugin": "8.15.0",
|
||||
"@typescript-eslint/parser": "8.15.0",
|
||||
"@vitejs/plugin-react": "4.3.4",
|
||||
"@pandacss/dev": "0.46.1",
|
||||
"@tanstack/eslint-plugin-query": "5.59.2",
|
||||
"@tanstack/react-query-devtools": "5.59.4",
|
||||
"@types/node": "20.16.11",
|
||||
"@types/react": "18.3.11",
|
||||
"@types/react-dom": "18.3.0",
|
||||
"@typescript-eslint/eslint-plugin": "8.8.1",
|
||||
"@typescript-eslint/parser": "8.8.1",
|
||||
"@vitejs/plugin-react": "4.3.2",
|
||||
"eslint": "8.57.0",
|
||||
"eslint-config-prettier": "9.1.0",
|
||||
"eslint-plugin-jsx-a11y": "6.10.2",
|
||||
"eslint-plugin-react-hooks": "5.0.0",
|
||||
"eslint-plugin-react-refresh": "0.4.14",
|
||||
"postcss": "8.4.49",
|
||||
"eslint-plugin-jsx-a11y": "6.10.0",
|
||||
"eslint-plugin-react-hooks": "4.6.2",
|
||||
"eslint-plugin-react-refresh": "0.4.12",
|
||||
"postcss": "8.4.47",
|
||||
"prettier": "3.3.3",
|
||||
"typescript": "5.7.2",
|
||||
"vite": "6.0.7",
|
||||
"vite-tsconfig-paths": "5.1.4"
|
||||
"typescript": "5.6.3",
|
||||
"vite": "5.4.8",
|
||||
"vite-tsconfig-paths": "5.0.1"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -35,46 +35,11 @@ const config: Config = {
|
||||
exclude: [],
|
||||
jsxFramework: 'react',
|
||||
outdir: 'src/styled-system',
|
||||
globalFontface: {
|
||||
Marianne: [
|
||||
{
|
||||
src: 'url(/fonts/Marianne-Regular-subset.woff2) format("woff2")',
|
||||
fontWeight: 400,
|
||||
fontStyle: 'normal',
|
||||
fontDisplay: 'swap',
|
||||
},
|
||||
{
|
||||
path: 'url(/fonts/Marianne-Regular_Italic-subset.woff2) format("woff2")',
|
||||
fontWeight: 400,
|
||||
fontStyle: 'italic',
|
||||
fontDisplay: 'swap',
|
||||
},
|
||||
{
|
||||
path: 'url(/fonts/Marianne-Medium-subset.woff2) format("woff2")',
|
||||
fontWeight: 500,
|
||||
fontStyle: 'normal',
|
||||
fontDisplay: 'swap',
|
||||
},
|
||||
{
|
||||
path: 'url(/fonts/Marianne-Bold-subset.woff2) format("woff2")',
|
||||
fontWeight: 700,
|
||||
fontStyle: 'normal',
|
||||
fontDisplay: 'swap',
|
||||
},
|
||||
{
|
||||
path: 'url(/fonts/Marianne-ExtraBold-subset.woff2) format("woff2")',
|
||||
fontWeight: 800,
|
||||
fontStyle: 'normal',
|
||||
fontDisplay: 'swap',
|
||||
},
|
||||
],
|
||||
},
|
||||
theme: {
|
||||
...pandaPreset.theme,
|
||||
// media queries are defined in em so that zooming with text-only mode triggers breakpoints
|
||||
breakpoints: {
|
||||
xs: '22.6em', // 360px (we assume less than that are old/entry level mobile phones)
|
||||
xsm: '31.25em', // 500px,
|
||||
sm: '40em', // 640px
|
||||
md: '48em', // 768px
|
||||
lg: '64em', // 1024px
|
||||
@@ -116,10 +81,10 @@ const config: Config = {
|
||||
'80%': { transform: 'rotate(20deg)' },
|
||||
'100%': { transform: 'rotate(0)' },
|
||||
},
|
||||
pulse_background: {
|
||||
'0%': { opacity: '1' },
|
||||
'50%': { opacity: '0.65' },
|
||||
'100%': { opacity: '1' },
|
||||
pulse_mic: {
|
||||
'0%': { color: 'primary', opacity: '1' },
|
||||
'50%': { color: 'primary', opacity: '0.8' },
|
||||
'100%': { color: 'primary', opacity: '1' },
|
||||
},
|
||||
},
|
||||
tokens: defineTokens({
|
||||
@@ -127,66 +92,6 @@ const config: Config = {
|
||||
* This way we'll only add the things we need step by step and prevent using lots of differents things.
|
||||
*/
|
||||
...pandaPreset.theme.tokens,
|
||||
colors: defineTokens.colors({
|
||||
...pandaPreset.theme.tokens.colors,
|
||||
primaryDark: {
|
||||
50: { value: '#161622' },
|
||||
100: { value: '#2D2D46' },
|
||||
200: { value: '#43436A' },
|
||||
300: { value: '#5A5A8F' },
|
||||
400: { value: '#7070B3' },
|
||||
500: { value: '#8787D7' },
|
||||
600: { value: '#9D9DDF' },
|
||||
700: { value: '#B3B3E7' },
|
||||
800: { value: '#C9C9EE' },
|
||||
900: { value: '#DFDFF6' },
|
||||
950: { value: '#F5F5FE' },
|
||||
action: { value: '#C1C1FB' },
|
||||
},
|
||||
primary: {
|
||||
50: { value: '#F5F5FE' },
|
||||
100: { value: '#ECECFE' },
|
||||
200: { value: '#E3E3FB' },
|
||||
300: { value: '#CACAFB' },
|
||||
400: { value: '#8585F6' },
|
||||
500: { value: '#6A6AF4' },
|
||||
600: { value: '#313178' },
|
||||
700: { value: '#272747' },
|
||||
800: { value: '#000091' },
|
||||
900: { value: '#21213F' },
|
||||
950: { value: '#1B1B35' },
|
||||
action: { value: '#1212FF' },
|
||||
},
|
||||
greyscale: {
|
||||
'000': { value: '#FFFFFF' },
|
||||
50: { value: '#F6F6F6' },
|
||||
100: { value: '#EEEEEE' },
|
||||
200: { value: '#E5E5E5' },
|
||||
250: { value: '#DDDDDD' },
|
||||
300: { value: '#CECECE' },
|
||||
400: { value: '#929292' },
|
||||
500: { value: '#7C7C7C' },
|
||||
600: { value: '#666666' },
|
||||
700: { value: '#3A3A3A' },
|
||||
750: { value: '#353535' },
|
||||
800: { value: '#2A2A2A' },
|
||||
900: { value: '#242424' },
|
||||
950: { value: '#1E1E1E' },
|
||||
1000: { value: '#161616' },
|
||||
},
|
||||
error: {
|
||||
100: { value: '#261212' },
|
||||
200: { value: '#6C302E' },
|
||||
300: { value: '#983533' },
|
||||
400: { value: '#CA3632' },
|
||||
500: { value: '#EF413D' },
|
||||
600: { value: '#EE6A66' },
|
||||
700: { value: '#F28D8A' },
|
||||
800: { value: '#F6AFAD' },
|
||||
900: { value: '#FAD2D1' },
|
||||
950: { value: '#FFF4F4' },
|
||||
},
|
||||
}),
|
||||
animations: {},
|
||||
blurs: {},
|
||||
/* just directly use values as tokens. This allows us to follow a specific design scale,
|
||||
@@ -288,7 +193,7 @@ const config: Config = {
|
||||
semanticTokens: defineSemanticTokens({
|
||||
colors: {
|
||||
default: {
|
||||
text: { value: '{colors.greyscale.1000}' },
|
||||
text: { value: '{colors.gray.900}' },
|
||||
bg: { value: 'white' },
|
||||
subtle: { value: '{colors.gray.100}' },
|
||||
'subtle-text': { value: '{colors.gray.600}' },
|
||||
|
||||
|
Before Width: | Height: | Size: 13 KiB |
@@ -1,5 +0,0 @@
|
||||
<svg width="160" height="160" viewBox="0 0 160 160" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M5.61135 59.6951C5 62.832 5 66.6949 5 73.1656V88.7307C5 96.7588 5 100.773 6.16756 104.366C7.20062 107.546 8.89041 110.472 11.1274 112.957C13.6555 115.765 17.1318 117.772 24.0842 121.786L37.564 129.568C43.7169 133.121 47.1472 135.101 50.4165 136.054V88.0288C50.4165 85.6281 49.1052 83.4192 46.9976 82.2696L5.61135 59.6951Z" fill="#C9191E"/>
|
||||
<path d="M118.313 66.7489V91.7898C118.313 95.2521 119.818 98.5435 122.436 100.809L140.459 116.406C141.513 117.298 142.608 118.028 143.744 118.596C144.92 119.123 146.056 119.387 147.151 119.387C149.503 119.387 151.389 118.616 152.809 117.075C154.269 115.493 154.999 113.445 154.999 110.93V47.6577C154.999 45.1431 154.269 43.1151 152.809 41.5738C151.389 39.992 149.503 39.2011 147.151 39.2011C146.056 39.2011 144.92 39.4647 143.744 39.992C142.608 40.5193 141.513 41.2494 140.459 42.1822L122.45 57.7172C119.823 59.983 118.313 63.28 118.313 66.7489Z" fill="#000091"/>
|
||||
<path d="M11.6345 50.7522C11.1333 50.4788 10.6078 50.2937 10.0757 50.1915C10.4114 49.7628 10.7622 49.3452 11.1276 48.9394C13.6558 46.1315 17.132 44.1245 24.0845 40.1105L37.5643 32.3279C44.5167 28.3139 47.993 26.3069 51.6887 25.5213C54.9587 24.8262 58.3383 24.8262 61.6083 25.5213C65.304 26.3069 68.7803 28.3139 75.7327 32.3279L89.0535 40.0187C89.1066 40.0492 89.1597 40.0798 89.2129 40.1105C96.1653 44.1245 99.6416 46.1316 102.17 48.9394C104.407 51.4238 106.096 54.3506 107.13 57.5301C108.297 61.1235 108.297 65.1375 108.297 73.1656V88.7307C108.297 96.7588 108.297 100.773 107.13 104.366C106.096 107.546 104.407 110.473 102.17 112.957C99.6416 115.765 96.1655 117.772 89.2133 121.785C89.1537 121.82 89.0936 121.855 89.0341 121.889L75.7327 129.568C68.7803 133.582 65.304 135.589 61.6083 136.375C61.4564 136.407 61.3043 136.438 61.1519 136.467L61.1519 88.0288C61.1519 81.6997 57.6948 75.8761 52.1386 72.8455L11.6345 50.7522Z" fill="#000091"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 1.9 KiB |
@@ -1,3 +0,0 @@
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M6.66667 4.00098V5.33431H3.33333V12.6676H10.6667V9.33431H12V13.3343C12 13.7025 11.7015 14.001 11.3333 14.001H2.66667C2.29848 14.001 2 13.7025 2 13.3343V4.66764C2 4.29945 2.29848 4.00098 2.66667 4.00098H6.66667ZM14 2.00098V7.33431H12.6667V4.27631L7.47133 9.47231L6.52867 8.52964L11.7233 3.33431H8.66667V2.00098H14Z" fill="#666666"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 484 B |
|
Before Width: | Height: | Size: 21 KiB |
@@ -30,7 +30,7 @@ function App() {
|
||||
<Route component={NotFoundScreen} />
|
||||
</Switch>
|
||||
</Layout>
|
||||
<ReactQueryDevtools initialIsOpen={false} buttonPosition="top-left" />
|
||||
<ReactQueryDevtools initialIsOpen={false} />
|
||||
</I18nProvider>
|
||||
</Suspense>
|
||||
</QueryClientProvider>
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
import { fetchApi } from './fetchApi'
|
||||
import { keys } from './queryKeys'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { RecordingMode } from '@/features/rooms/api/startRecording'
|
||||
|
||||
// todo - refactor it in a proper place
|
||||
export enum RecordingMode {
|
||||
Transcript = 'transcript',
|
||||
ScreenRecording = 'screen_recording',
|
||||
}
|
||||
|
||||
export interface ApiConfig {
|
||||
analytics?: {
|
||||
|
||||
|
Before Width: | Height: | Size: 651 KiB |
|
Before Width: | Height: | Size: 702 KiB |
|
Before Width: | Height: | Size: 599 KiB |
@@ -1,6 +0,0 @@
|
||||
<svg width="448" height="172" viewBox="0 0 448 172" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M5.61135 65.6951C5 68.832 5 72.6949 5 79.1656V94.7307C5 102.759 5 106.773 6.16756 110.366C7.20062 113.546 8.89041 116.472 11.1274 118.957C13.6555 121.765 17.1318 123.772 24.0842 127.786L37.564 135.568C43.7169 139.121 47.1472 141.101 50.4165 142.054V94.0288C50.4165 91.6281 49.1052 89.4192 46.9976 88.2696L5.61135 65.6951Z" fill="#C9191E"/>
|
||||
<path d="M118.313 72.7489V97.7898C118.313 101.252 119.818 104.544 122.436 106.809L140.459 122.406C141.513 123.298 142.608 124.028 143.744 124.596C144.92 125.123 146.056 125.387 147.151 125.387C149.503 125.387 151.389 124.616 152.809 123.075C154.269 121.493 154.999 119.445 154.999 116.93V53.6577C154.999 51.1431 154.269 49.1151 152.809 47.5738C151.389 45.992 149.503 45.2011 147.151 45.2011C146.056 45.2011 144.92 45.4647 143.744 45.992C142.608 46.5193 141.513 47.2494 140.459 48.1822L122.45 63.7172C119.823 65.983 118.313 69.28 118.313 72.7489Z" fill="#000091"/>
|
||||
<path d="M11.6345 56.7522C11.1333 56.4788 10.6078 56.2937 10.0757 56.1915C10.4114 55.7628 10.7622 55.3452 11.1276 54.9394C13.6558 52.1315 17.132 50.1245 24.0845 46.1105L37.5643 38.3279C44.5167 34.3139 47.993 32.3069 51.6887 31.5213C54.9587 30.8262 58.3383 30.8262 61.6083 31.5213C65.304 32.3069 68.7803 34.3139 75.7327 38.3279L89.0535 46.0187C89.1066 46.0492 89.1597 46.0798 89.2129 46.1105C96.1653 50.1245 99.6416 52.1316 102.17 54.9394C104.407 57.4238 106.096 60.3506 107.13 63.5301C108.297 67.1235 108.297 71.1375 108.297 79.1656V94.7307C108.297 102.759 108.297 106.773 107.13 110.366C106.096 113.546 104.407 116.473 102.17 118.957C99.6416 121.765 96.1655 123.772 89.2133 127.785C89.1537 127.82 89.0936 127.855 89.0341 127.889L75.7327 135.568C68.7803 139.582 65.304 141.589 61.6083 142.375C61.4564 142.407 61.3043 142.438 61.1519 142.467L61.1519 94.0288C61.1519 87.6997 57.6948 81.8761 52.1386 78.8455L11.6345 56.7522Z" fill="#000091"/>
|
||||
<path d="M193.72 45.7333H211.035L234.771 108.456L258.507 45.7333H275.821L245.435 126H224.107L193.72 45.7333ZM289.019 58.1173C283.859 58.1173 279.501 53.76 279.501 48.6C279.501 43.44 283.859 39.0827 289.019 39.0827C294.179 39.0827 298.421 43.44 298.421 48.6C298.421 53.76 294.179 58.1173 289.019 58.1173ZM281.68 126V68.208H296.243V126H281.68ZM303.29 117.629L312.922 108.915C316.477 113.387 320.72 116.597 326.224 116.597C330.925 116.597 333.333 113.845 333.333 110.405C333.333 100.315 305.698 104.099 305.698 83.8027C305.698 73.5973 314.298 65.9147 326.338 65.9147C335.168 65.9147 343.194 70.1573 347.322 75.776L337.69 84.2613C334.709 80.592 330.925 77.6107 326.453 77.6107C321.866 77.6107 319.688 80.1333 319.688 83.1147C319.688 92.976 347.322 89.536 347.322 109.488C347.093 121.643 337.346 128.293 326.453 128.293C316.133 128.293 308.794 124.165 303.29 117.629ZM363.494 58.1173C358.334 58.1173 353.977 53.76 353.977 48.6C353.977 43.44 358.334 39.0827 363.494 39.0827C368.654 39.0827 372.897 43.44 372.897 48.6C372.897 53.76 368.654 58.1173 363.494 58.1173ZM356.155 126V68.208H370.718V126H356.155ZM411.115 65.9147C429.92 65.9147 442.763 79.7893 442.763 97.104C442.763 114.419 429.92 128.293 411.115 128.293C392.309 128.293 379.467 114.419 379.467 97.104C379.467 79.7893 392.309 65.9147 411.115 65.9147ZM411.344 114.533C420.632 114.533 427.627 107.08 427.627 97.104C427.627 87.0133 420.632 79.6747 411.344 79.6747C401.712 79.6747 394.603 87.0133 394.603 97.104C394.603 107.195 401.712 114.533 411.344 114.533Z" fill="#000091"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 3.4 KiB |
@@ -0,0 +1,25 @@
|
||||
import { css } from '@/styled-system/css'
|
||||
import { RiExternalLinkLine } from '@remixicon/react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { LinkButton } from '@/primitives'
|
||||
|
||||
export const Feedback = () => {
|
||||
const { t } = useTranslation()
|
||||
return (
|
||||
<LinkButton
|
||||
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"
|
||||
/>
|
||||
</LinkButton>
|
||||
)
|
||||
}
|
||||
@@ -1,44 +0,0 @@
|
||||
import { css } from '@/styled-system/css'
|
||||
import { RiErrorWarningLine, RiExternalLinkLine } from '@remixicon/react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Text, A } from '@/primitives'
|
||||
import { GRIST_FORM } from '@/utils/constants'
|
||||
|
||||
export const FeedbackBanner = () => {
|
||||
const { t } = useTranslation()
|
||||
return (
|
||||
<div
|
||||
className={css({
|
||||
width: '100%',
|
||||
backgroundColor: '#E8EDFF',
|
||||
color: '#0063CB',
|
||||
display: { base: 'none', sm: 'flex' },
|
||||
justifyContent: 'center',
|
||||
padding: '0.5rem 0',
|
||||
})}
|
||||
>
|
||||
<div
|
||||
className={css({
|
||||
display: 'inline-flex',
|
||||
gap: '0.5rem',
|
||||
alignItems: 'center',
|
||||
})}
|
||||
>
|
||||
<RiErrorWarningLine size={20} />
|
||||
<Text as="p">{t('feedback.context')}</Text>
|
||||
<div
|
||||
className={css({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 0.25,
|
||||
})}
|
||||
>
|
||||
<A href={GRIST_FORM} target="_blank">
|
||||
{t('feedback.cta')}
|
||||
</A>
|
||||
<RiExternalLinkLine size={16} aria-hidden="true" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -7,7 +7,6 @@ import { Center } from '@/styled-system/jsx'
|
||||
export const LoadingScreen = ({
|
||||
delay = 500,
|
||||
header = undefined,
|
||||
footer = undefined,
|
||||
layout = 'centered',
|
||||
}: {
|
||||
delay?: number
|
||||
@@ -16,7 +15,7 @@ export const LoadingScreen = ({
|
||||
|
||||
return (
|
||||
<DelayedRender delay={delay}>
|
||||
<Screen layout={layout} header={header} footer={footer}>
|
||||
<Screen layout={layout} header={header}>
|
||||
<CenteredContent>
|
||||
<Center>
|
||||
<p>{t('loading')}</p>
|
||||
|
||||
@@ -21,7 +21,7 @@ export const QueryAware = ({
|
||||
}
|
||||
|
||||
if (status === 'pending') {
|
||||
return <LoadingScreen header={undefined} footer={undefined} />
|
||||
return <LoadingScreen header={undefined} />
|
||||
}
|
||||
|
||||
return children
|
||||
|
||||
@@ -27,7 +27,7 @@ export const SoundTester = () => {
|
||||
return (
|
||||
<>
|
||||
<Button
|
||||
variant="secondaryText"
|
||||
invisible
|
||||
onPress={() => {
|
||||
audioRef?.current?.play()
|
||||
setIsPlaying(true)
|
||||
|
||||
@@ -1,6 +0,0 @@
|
||||
import { useConfig } from '@/api/useConfig.ts'
|
||||
|
||||
export const useIsAnalyticsEnabled = () => {
|
||||
const { data } = useConfig()
|
||||
return !!data?.analytics?.id
|
||||
}
|
||||
@@ -1,6 +1,4 @@
|
||||
export type ApiUser = {
|
||||
id: string
|
||||
email: string
|
||||
full_name: string
|
||||
last_name: string
|
||||
}
|
||||
|
||||
@@ -15,6 +15,6 @@ export const UserAware = ({ children }: { children: React.ReactNode }) => {
|
||||
return isLoggedIn !== undefined ? (
|
||||
children
|
||||
) : (
|
||||
<LoadingScreen header={false} footer={false} delay={1000} />
|
||||
<LoadingScreen header={false} delay={1000} />
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,248 +0,0 @@
|
||||
import firstSlide from '@/assets/intro-slider/1_solo.png'
|
||||
import secondSlide from '@/assets/intro-slider/2_multiple.png'
|
||||
import thirdSlide from '@/assets/intro-slider/3_resume.png'
|
||||
|
||||
import { styled } from '@/styled-system/jsx'
|
||||
import { css } from '@/styled-system/css'
|
||||
import { Button, LinkButton } from '@/primitives'
|
||||
import { RiArrowLeftSLine, RiArrowRightSLine } from '@remixicon/react'
|
||||
import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
// todo - extract in a proper env variable
|
||||
const BETA_USERS_FORM_URL =
|
||||
'https://grist.numerique.gouv.fr/o/docs/forms/3fFfvJoTBEQ6ZiMi8zsQwX/17'
|
||||
|
||||
const Heading = styled('h2', {
|
||||
base: {
|
||||
width: 'fit-content',
|
||||
marginBottom: 0,
|
||||
fontSize: '1.5rem',
|
||||
marginTop: '0.75rem',
|
||||
lineHeight: '2rem',
|
||||
maxWidth: '23rem',
|
||||
textAlign: 'center',
|
||||
textWrap: 'pretty',
|
||||
},
|
||||
})
|
||||
|
||||
const Body = styled('p', {
|
||||
base: {
|
||||
maxWidth: '23rem',
|
||||
textAlign: 'center',
|
||||
textWrap: 'pretty',
|
||||
lineHeight: '1.2rem',
|
||||
fontSize: '1rem',
|
||||
},
|
||||
})
|
||||
|
||||
const Image = styled('img', {
|
||||
base: {
|
||||
maxHeight: '362px',
|
||||
height: '100%',
|
||||
width: 'fit-content',
|
||||
},
|
||||
})
|
||||
|
||||
const Dot = styled('div', {
|
||||
base: {
|
||||
borderRadius: '50%',
|
||||
display: 'inline-block',
|
||||
height: '.375rem',
|
||||
margin: '0 .25rem',
|
||||
width: '.375rem',
|
||||
},
|
||||
variants: {
|
||||
selected: {
|
||||
true: {
|
||||
backgroundColor: '#000091',
|
||||
},
|
||||
false: {
|
||||
backgroundColor: '#CACAFB',
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
const Container = styled('div', {
|
||||
base: {
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
width: '100%',
|
||||
justifyContent: 'space-between',
|
||||
textAlign: 'center',
|
||||
},
|
||||
})
|
||||
|
||||
const ButtonContainer = styled('div', {
|
||||
base: {
|
||||
display: { base: 'none', xsm: 'block' },
|
||||
},
|
||||
})
|
||||
|
||||
const ButtonVerticalCenter = styled('div', {
|
||||
base: {
|
||||
marginTop: '13.3125rem',
|
||||
transform: 'translateY(-50%)',
|
||||
},
|
||||
})
|
||||
|
||||
const SlideContainer = styled('div', {
|
||||
base: {
|
||||
alignItems: 'stretch',
|
||||
display: 'flex',
|
||||
position: 'relative',
|
||||
},
|
||||
})
|
||||
|
||||
const Slide = styled('div', {
|
||||
base: {
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
gap: '0.5rem',
|
||||
justifyContent: 'start',
|
||||
minHeight: { base: 'none', xsm: '550px' },
|
||||
minWidth: { base: 'none', xsm: '200px' },
|
||||
width: { base: '100%', xsm: '22.625rem' },
|
||||
},
|
||||
variants: {
|
||||
visible: {
|
||||
true: {
|
||||
visibility: 'visible',
|
||||
position: 'static',
|
||||
},
|
||||
false: {
|
||||
visibility: 'hidden',
|
||||
position: 'absolute',
|
||||
},
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
visible: false,
|
||||
},
|
||||
})
|
||||
|
||||
const TextAnimation = styled('div', {
|
||||
base: {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
flexDirection: 'column',
|
||||
gap: '0.5rem',
|
||||
},
|
||||
variants: {
|
||||
visible: {
|
||||
true: {
|
||||
opacity: 1,
|
||||
transform: 'none',
|
||||
transition: 'opacity ease-in .3s, transform ease-in .3s',
|
||||
},
|
||||
false: {
|
||||
opacity: 0,
|
||||
transform: 'translateX(-30%)',
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
type Slide = {
|
||||
key: string
|
||||
img: string
|
||||
isAvailableInBeta?: boolean
|
||||
}
|
||||
|
||||
// todo - optimize how images are imported
|
||||
const SLIDES: Slide[] = [
|
||||
{
|
||||
key: 'slide1',
|
||||
img: firstSlide,
|
||||
},
|
||||
{
|
||||
key: 'slide2',
|
||||
img: secondSlide,
|
||||
},
|
||||
{
|
||||
key: 'slide3',
|
||||
img: thirdSlide,
|
||||
isAvailableInBeta: true,
|
||||
},
|
||||
]
|
||||
|
||||
export const IntroSlider = () => {
|
||||
const [slideIndex, setSlideIndex] = useState(0)
|
||||
const { t } = useTranslation('home', { keyPrefix: 'introSlider' })
|
||||
const NUMBER_SLIDES = SLIDES.length
|
||||
|
||||
return (
|
||||
<Container>
|
||||
<div
|
||||
className={css({
|
||||
display: 'flex',
|
||||
flexGrow: 1,
|
||||
justifyContent: 'center',
|
||||
})}
|
||||
>
|
||||
<ButtonContainer>
|
||||
<ButtonVerticalCenter>
|
||||
<Button
|
||||
variant="secondaryText"
|
||||
square
|
||||
aria-label={t('previous.label')}
|
||||
tooltip={t('previous.tooltip')}
|
||||
onPress={() => setSlideIndex(slideIndex - 1)}
|
||||
isDisabled={slideIndex == 0}
|
||||
>
|
||||
<RiArrowLeftSLine />
|
||||
</Button>
|
||||
</ButtonVerticalCenter>
|
||||
</ButtonContainer>
|
||||
<SlideContainer>
|
||||
{SLIDES.map((slide, index) => (
|
||||
<Slide visible={index == slideIndex} key={index}>
|
||||
<Image src={slide.img} alt={t(`${slide.key}.imgAlt`)} />
|
||||
<TextAnimation visible={index == slideIndex}>
|
||||
<Heading>{t(`${slide.key}.title`)}</Heading>
|
||||
<Body>{t(`${slide.key}.body`)}</Body>
|
||||
{slide.isAvailableInBeta && (
|
||||
<LinkButton
|
||||
href={BETA_USERS_FORM_URL}
|
||||
tooltip={t('beta.tooltip')}
|
||||
variant={'primary'}
|
||||
size={'sm'}
|
||||
style={{ marginTop: '1rem', width: 'fit-content' }}
|
||||
>
|
||||
{t('beta.text')}
|
||||
</LinkButton>
|
||||
)}
|
||||
</TextAnimation>
|
||||
</Slide>
|
||||
))}
|
||||
</SlideContainer>
|
||||
<ButtonContainer>
|
||||
<ButtonVerticalCenter>
|
||||
<Button
|
||||
variant="secondaryText"
|
||||
square
|
||||
aria-label={t('next.label')}
|
||||
tooltip={t('next.tooltip')}
|
||||
onPress={() => setSlideIndex(slideIndex + 1)}
|
||||
isDisabled={slideIndex == NUMBER_SLIDES - 1}
|
||||
>
|
||||
<RiArrowRightSLine />
|
||||
</Button>
|
||||
</ButtonVerticalCenter>
|
||||
</ButtonContainer>
|
||||
</div>
|
||||
<div
|
||||
className={css({
|
||||
marginTop: '0.5rem',
|
||||
display: { base: 'none', xsm: 'block' },
|
||||
})}
|
||||
>
|
||||
{SLIDES.map((_, index) => (
|
||||
<Dot key={index} selected={index == slideIndex} />
|
||||
))}
|
||||
</div>
|
||||
</Container>
|
||||
)
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
import { A, Text } from '@/primitives'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
const MANIFEST_LINK =
|
||||
'https://docs.numerique.gouv.fr/docs/1ef86abf-f7e0-46ce-b6c7-8be8b8af4c3d/'
|
||||
|
||||
export const MoreLink = () => {
|
||||
const { t } = useTranslation('home')
|
||||
|
||||
return (
|
||||
<Text as={'p'} variant={'sm'} style={{ padding: '1rem 0' }}>
|
||||
<A
|
||||
href={MANIFEST_LINK}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
aria-label={t('moreLinkLabel')}
|
||||
>
|
||||
{t('moreLink')}
|
||||
</A>{' '}
|
||||
{t('moreAbout')}
|
||||
</Text>
|
||||
)
|
||||
}
|
||||
@@ -1,149 +1,20 @@
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { DialogTrigger, MenuItem, Menu as RACMenu } from 'react-aria-components'
|
||||
import { Button, Menu } from '@/primitives'
|
||||
import { styled } from '@/styled-system/jsx'
|
||||
import { Button, Menu, 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 { useUser, UserAware } from '@/features/auth'
|
||||
import { JoinMeetingDialog } from '../components/JoinMeetingDialog'
|
||||
import { ProConnectButton } from '@/components/ProConnectButton'
|
||||
import { useCreateRoom } from '@/features/rooms'
|
||||
import { usePersistentUserChoices } from '@livekit/components-react'
|
||||
import { menuItemRecipe } from '@/primitives/menuItemRecipe'
|
||||
import { RiAddLine, RiLink } from '@remixicon/react'
|
||||
import { LaterMeetingDialog } from '@/features/home/components/LaterMeetingDialog'
|
||||
import { IntroSlider } from '@/features/home/components/IntroSlider'
|
||||
import { MoreLink } from '@/features/home/components/MoreLink'
|
||||
import { ReactNode, useState } from 'react'
|
||||
|
||||
import { css } from '@/styled-system/css'
|
||||
import { menuRecipe } from '@/primitives/menuRecipe.ts'
|
||||
|
||||
const Columns = ({ children }: { children?: ReactNode }) => {
|
||||
return (
|
||||
<div
|
||||
className={css({
|
||||
alignItems: 'center',
|
||||
margin: 'auto',
|
||||
display: 'inline-flex',
|
||||
flexDirection: 'column',
|
||||
height: '100%',
|
||||
minHeight: '100%',
|
||||
justifyContent: 'normal',
|
||||
padding: '0 1rem',
|
||||
width: 'calc(100% - 2rem)',
|
||||
'@media(prefers-reduced-motion: reduce)': {
|
||||
opacity: 1,
|
||||
},
|
||||
'@media(prefers-reduced-motion: no-preference)': {
|
||||
opacity: 0,
|
||||
animation: '.5s ease-in fade 0s forwards',
|
||||
},
|
||||
lg: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'center',
|
||||
width: '100%',
|
||||
padding: 0,
|
||||
},
|
||||
})}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const LeftColumn = ({ children }: { children?: ReactNode }) => {
|
||||
return (
|
||||
<div
|
||||
className={css({
|
||||
alignItems: 'center',
|
||||
textAlign: 'center',
|
||||
display: 'inline-flex',
|
||||
flexDirection: 'column',
|
||||
flexBasis: 'auto',
|
||||
flexShrink: 0,
|
||||
maxWidth: '38rem',
|
||||
width: '100%',
|
||||
padding: '1rem 3%',
|
||||
marginTop: 'auto',
|
||||
lg: {
|
||||
margin: 0,
|
||||
textAlign: 'left',
|
||||
alignItems: 'flex-start',
|
||||
flexShrink: '1',
|
||||
flexBasis: '36rem',
|
||||
maxWidth: '36rem',
|
||||
padding: '1em 3em',
|
||||
},
|
||||
})}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const RightColumn = ({ children }: { children?: ReactNode }) => {
|
||||
return (
|
||||
<div
|
||||
className={css({
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
overflow: 'hidden',
|
||||
padding: '1rem 3%',
|
||||
marginBottom: 'auto',
|
||||
flexBasis: 'auto',
|
||||
flexShrink: 0,
|
||||
maxWidth: '39rem',
|
||||
lg: {
|
||||
margin: 0,
|
||||
flexBasis: '45%',
|
||||
padding: '1em 3em',
|
||||
},
|
||||
})}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const Separator = styled('div', {
|
||||
base: {
|
||||
borderBottom: '1px solid',
|
||||
borderColor: '#747775',
|
||||
marginTop: '2.5rem',
|
||||
maxWidth: '30rem',
|
||||
width: '100%',
|
||||
},
|
||||
})
|
||||
|
||||
const Heading = styled('h1', {
|
||||
base: {
|
||||
fontWeight: '500',
|
||||
fontStyle: 'normal',
|
||||
fontStretch: 'normal',
|
||||
fontOpticalSizing: 'auto',
|
||||
marginBottom: 0,
|
||||
paddingBottom: '0.75rem',
|
||||
fontSize: '2.3rem',
|
||||
lineHeight: '2.5rem',
|
||||
letterSpacing: '0',
|
||||
xsm: {
|
||||
fontSize: '3rem',
|
||||
lineHeight: '3.2rem',
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
const IntroText = styled('div', {
|
||||
base: {
|
||||
marginBottom: '3rem',
|
||||
fontSize: '1.5rem',
|
||||
lineHeight: '1.8rem',
|
||||
textWrap: 'pretty',
|
||||
maxWidth: '32rem',
|
||||
},
|
||||
})
|
||||
import { useState } from 'react'
|
||||
|
||||
export const Home = () => {
|
||||
const { t } = useTranslation('home')
|
||||
@@ -159,93 +30,72 @@ export const Home = () => {
|
||||
return (
|
||||
<UserAware>
|
||||
<Screen>
|
||||
<Columns>
|
||||
<LeftColumn>
|
||||
<Heading>{t('heading')}</Heading>
|
||||
<IntroText>{t('intro')}</IntroText>
|
||||
<div
|
||||
className={css({
|
||||
display: 'flex',
|
||||
gap: 0.5,
|
||||
flexDirection: { base: 'column', xsm: 'row' },
|
||||
alignItems: { base: 'center', xsm: 'items-start' },
|
||||
})}
|
||||
>
|
||||
{isLoggedIn ? (
|
||||
<Menu>
|
||||
<Button variant="primary" data-attr="create-meeting">
|
||||
{t('createMeeting')}
|
||||
</Button>
|
||||
<RACMenu>
|
||||
<MenuItem
|
||||
className={
|
||||
menuRecipe({ icon: true, variant: 'light' }).item
|
||||
}
|
||||
onAction={async () => {
|
||||
const slug = generateRoomId()
|
||||
createRoom({ slug, username }).then((data) =>
|
||||
navigateTo('room', data.slug, {
|
||||
state: { create: true, initialRoomData: data },
|
||||
})
|
||||
)
|
||||
}}
|
||||
data-attr="create-option-instant"
|
||||
>
|
||||
<RiAddLine size={18} />
|
||||
{t('createMenu.instantOption')}
|
||||
</MenuItem>
|
||||
<MenuItem
|
||||
className={
|
||||
menuRecipe({ icon: true, variant: 'light' }).item
|
||||
}
|
||||
onAction={() => {
|
||||
const slug = generateRoomId()
|
||||
createRoom({ slug, username }).then((data) =>
|
||||
setLaterRoomId(data.slug)
|
||||
)
|
||||
}}
|
||||
data-attr="create-option-later"
|
||||
>
|
||||
<RiLink size={18} />
|
||||
{t('createMenu.laterOption')}
|
||||
</MenuItem>
|
||||
</RACMenu>
|
||||
</Menu>
|
||||
) : (
|
||||
<ProConnectButton hint={false} />
|
||||
)}
|
||||
<DialogTrigger>
|
||||
<Button
|
||||
variant="secondary"
|
||||
style={{
|
||||
height: !isLoggedIn ? '56px' : undefined, // Temporary, Align with ProConnect Button fixed height
|
||||
}}
|
||||
>
|
||||
{t('joinMeeting')}
|
||||
<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" alignItems="start">
|
||||
{isLoggedIn ? (
|
||||
<Menu>
|
||||
<Button variant="primary" data-attr="create-meeting">
|
||||
{t('createMeeting')}
|
||||
</Button>
|
||||
<JoinMeetingDialog />
|
||||
</DialogTrigger>
|
||||
</div>
|
||||
<Separator />
|
||||
<div
|
||||
className={css({
|
||||
display: { base: 'none', lg: 'inline' },
|
||||
})}
|
||||
>
|
||||
<MoreLink />
|
||||
</div>
|
||||
</LeftColumn>
|
||||
<RightColumn>
|
||||
<IntroSlider />
|
||||
<div
|
||||
className={css({
|
||||
display: { base: 'inline', lg: 'none' },
|
||||
})}
|
||||
>
|
||||
<MoreLink />
|
||||
</div>
|
||||
</RightColumn>
|
||||
</Columns>
|
||||
<RACMenu>
|
||||
<MenuItem
|
||||
className={menuItemRecipe({ icon: true })}
|
||||
onAction={async () => {
|
||||
const slug = generateRoomId()
|
||||
createRoom({ slug, username }).then((data) =>
|
||||
navigateTo('room', data.slug, {
|
||||
state: { create: true, initialRoomData: data },
|
||||
})
|
||||
)
|
||||
}}
|
||||
data-attr="create-option-instant"
|
||||
>
|
||||
<RiAddLine size={18} />
|
||||
{t('createMenu.instantOption')}
|
||||
</MenuItem>
|
||||
<MenuItem
|
||||
className={menuItemRecipe({ icon: true })}
|
||||
onAction={() => {
|
||||
const slug = generateRoomId()
|
||||
createRoom({ slug, username }).then((data) =>
|
||||
setLaterRoomId(data.slug)
|
||||
)
|
||||
}}
|
||||
data-attr="create-option-later"
|
||||
>
|
||||
<RiLink size={18} />
|
||||
{t('createMenu.laterOption')}
|
||||
</MenuItem>
|
||||
</RACMenu>
|
||||
</Menu>
|
||||
) : (
|
||||
<ProConnectButton />
|
||||
)}
|
||||
<DialogTrigger>
|
||||
<Button
|
||||
variant="primary"
|
||||
outline
|
||||
style={{
|
||||
height: !isLoggedIn ? '56px' : undefined, // Temporary, Align with ProConnect Button fixed height
|
||||
}}
|
||||
>
|
||||
{t('joinMeeting')}
|
||||
</Button>
|
||||
<JoinMeetingDialog />
|
||||
</DialogTrigger>
|
||||
</HStack>
|
||||
</Centered>
|
||||
<LaterMeetingDialog
|
||||
roomId={laterRoomId || ''}
|
||||
onOpenChange={() => setLaterRoomId(null)}
|
||||
|
||||
@@ -16,11 +16,11 @@ export const MainNotificationToast = () => {
|
||||
if (isMobileBrowser()) {
|
||||
return
|
||||
}
|
||||
triggerNotificationSound(NotificationType.ParticipantJoined)
|
||||
triggerNotificationSound(NotificationType.Joined)
|
||||
toastQueue.add(
|
||||
{
|
||||
participant,
|
||||
type: NotificationType.ParticipantJoined,
|
||||
type: NotificationType.Joined,
|
||||
},
|
||||
{
|
||||
timeout: 5000,
|
||||
@@ -67,7 +67,7 @@ export const MainNotificationToast = () => {
|
||||
const existingToast = toastQueue.visibleToasts.find(
|
||||
(toast) =>
|
||||
toast.content.participant === participant &&
|
||||
toast.content.type === NotificationType.HandRaised
|
||||
toast.content.type === NotificationType.Raised
|
||||
)
|
||||
|
||||
if (existingToast && prevMetadata.raised && !metadata.raised) {
|
||||
@@ -76,11 +76,11 @@ export const MainNotificationToast = () => {
|
||||
}
|
||||
|
||||
if (!existingToast && !prevMetadata.raised && metadata.raised) {
|
||||
triggerNotificationSound(NotificationType.HandRaised)
|
||||
triggerNotificationSound(NotificationType.Raised)
|
||||
toastQueue.add(
|
||||
{
|
||||
participant,
|
||||
type: NotificationType.HandRaised,
|
||||
type: NotificationType.Raised,
|
||||
},
|
||||
{ timeout: 5000 }
|
||||
)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
export enum NotificationType {
|
||||
ParticipantJoined = 'participantJoined',
|
||||
HandRaised = 'handRaised',
|
||||
// todo - implement message received notification
|
||||
Joined = 'joined',
|
||||
Default = 'default',
|
||||
Raised = 'raised',
|
||||
Lowered = 'lowered',
|
||||
}
|
||||
|
||||
@@ -17,10 +17,10 @@ export function ToastRegion({ state, ...props }: ToastRegionProps) {
|
||||
return (
|
||||
<div {...regionProps} ref={ref} className="toast-region">
|
||||
{state.visibleToasts.map((toast) => {
|
||||
if (toast.content?.type === NotificationType.ParticipantJoined) {
|
||||
if (toast.content?.type === NotificationType.Joined) {
|
||||
return <ToastJoined key={toast.key} toast={toast} state={state} />
|
||||
}
|
||||
if (toast.content?.type === NotificationType.HandRaised) {
|
||||
if (toast.content?.type === NotificationType.Raised) {
|
||||
return <ToastRaised key={toast.key} toast={toast} state={state} />
|
||||
}
|
||||
return <Toast key={toast.key} toast={toast} state={state} />
|
||||
|
||||
@@ -1,24 +1,18 @@
|
||||
import useSound from 'use-sound'
|
||||
import { useSnapshot } from 'valtio'
|
||||
import { notificationsStore } from '@/stores/notifications'
|
||||
import { NotificationType } from '@/features/notifications/NotificationType'
|
||||
|
||||
// fixme - handle dynamic audio output changes
|
||||
export const useNotificationSound = () => {
|
||||
const notificationsSnap = useSnapshot(notificationsStore)
|
||||
const [play] = useSound('./sounds/notifications.mp3', {
|
||||
sprite: {
|
||||
participantJoined: [0, 1150],
|
||||
handRaised: [1400, 180],
|
||||
messageReceived: [1580, 300],
|
||||
joined: [0, 1150],
|
||||
raised: [1400, 180],
|
||||
message: [1580, 300],
|
||||
waiting: [2039, 710],
|
||||
success: [2740, 1304],
|
||||
},
|
||||
volume: notificationsSnap.soundNotificationVolume,
|
||||
})
|
||||
const triggerNotificationSound = (type: NotificationType) => {
|
||||
const isSoundEnabled = notificationsSnap.soundNotifications.get(type)
|
||||
if (isSoundEnabled) play({ id: type })
|
||||
const triggerNotificationSound = (type: string) => {
|
||||
play({ id: type })
|
||||
}
|
||||
return { triggerNotificationSound }
|
||||
}
|
||||
|
||||
@@ -3,7 +3,6 @@ export type ApiRoom = {
|
||||
name: string
|
||||
slug: string
|
||||
is_public: boolean
|
||||
is_administrable: boolean
|
||||
livekit?: {
|
||||
url: string
|
||||
room: string
|
||||
|
||||
@@ -1,35 +0,0 @@
|
||||
import { useMutation, UseMutationOptions } from '@tanstack/react-query'
|
||||
import { fetchApi } from '@/api/fetchApi'
|
||||
import { ApiError } from '@/api/ApiError'
|
||||
import { ApiRoom } from './ApiRoom'
|
||||
|
||||
export enum RecordingMode {
|
||||
Transcript = 'transcript',
|
||||
ScreenRecording = 'screen_recording',
|
||||
}
|
||||
|
||||
export interface StartRecordingParams {
|
||||
id: string
|
||||
mode?: RecordingMode
|
||||
}
|
||||
|
||||
const startRecording = ({
|
||||
id,
|
||||
mode = RecordingMode.Transcript,
|
||||
}: StartRecordingParams): Promise<ApiRoom> => {
|
||||
return fetchApi(`rooms/${id}/start-recording/`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
mode: mode,
|
||||
}),
|
||||
})
|
||||
}
|
||||
|
||||
export function useStartRecording(
|
||||
options?: UseMutationOptions<ApiRoom, ApiError, StartRecordingParams>
|
||||
) {
|
||||
return useMutation<ApiRoom, ApiError, StartRecordingParams>({
|
||||
mutationFn: startRecording,
|
||||
onSuccess: options?.onSuccess,
|
||||
})
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
import { useMutation, UseMutationOptions } from '@tanstack/react-query'
|
||||
import { fetchApi } from '@/api/fetchApi'
|
||||
import { ApiError } from '@/api/ApiError'
|
||||
import { ApiRoom } from './ApiRoom'
|
||||
|
||||
export interface StopRecordingParams {
|
||||
id: string
|
||||
}
|
||||
|
||||
const stopRecording = ({ id }: StopRecordingParams): Promise<ApiRoom> => {
|
||||
return fetchApi(`rooms/${id}/stop-recording/`, {
|
||||
method: 'POST',
|
||||
})
|
||||
}
|
||||
|
||||
export function useStopRecording(
|
||||
options?: UseMutationOptions<ApiRoom, ApiError, StopRecordingParams>
|
||||
) {
|
||||
return useMutation<ApiRoom, ApiError, StopRecordingParams>({
|
||||
mutationFn: stopRecording,
|
||||
onSuccess: options?.onSuccess,
|
||||
})
|
||||
}
|
||||
@@ -15,7 +15,6 @@ import { InviteDialog } from './InviteDialog'
|
||||
|
||||
import { VideoConference } from '../livekit/prefabs/VideoConference'
|
||||
import posthog from 'posthog-js'
|
||||
import { css } from '@/styled-system/css'
|
||||
|
||||
export const Conference = ({
|
||||
roomId,
|
||||
@@ -65,11 +64,6 @@ export const Conference = ({
|
||||
|
||||
const roomOptions = useMemo((): RoomOptions => {
|
||||
return {
|
||||
adaptiveStream: true,
|
||||
dynacast: true,
|
||||
publishDefaults: {
|
||||
videoCodec: 'vp9',
|
||||
},
|
||||
videoCaptureDefaults: {
|
||||
deviceId: userConfig.videoDeviceId ?? undefined,
|
||||
},
|
||||
@@ -99,12 +93,12 @@ export const Conference = ({
|
||||
// These settings help ensure successful connections in poor network conditions
|
||||
const connectOptions = {
|
||||
maxRetries: 5, // Default: 1. Only for unreachable server scenarios
|
||||
peerConnectionTimeout: 60000, // Default: 15s. Extended for slow TURN/TLS negotiation
|
||||
peerConnectionTimeout: 60, // Default: 15s. Extended for slow TURN/TLS negotiation
|
||||
}
|
||||
|
||||
return (
|
||||
<QueryAware status={isFetchError ? createStatus : fetchStatus}>
|
||||
<Screen header={false} footer={false}>
|
||||
<Screen header={false}>
|
||||
<LiveKitRoom
|
||||
room={room}
|
||||
serverUrl={data?.livekit?.url}
|
||||
@@ -113,9 +107,6 @@ export const Conference = ({
|
||||
audio={userConfig.audioEnabled}
|
||||
video={userConfig.videoEnabled}
|
||||
connectOptions={connectOptions}
|
||||
className={css({
|
||||
backgroundColor: 'primaryDark.50 !important',
|
||||
})}
|
||||
>
|
||||
<VideoConference />
|
||||
{showInviteDialog && (
|
||||
|
||||
@@ -49,6 +49,8 @@ export const InviteDialog = ({
|
||||
}
|
||||
}, [isCopied])
|
||||
|
||||
const [isHovered, setIsHovered] = useState(false)
|
||||
|
||||
return (
|
||||
<StyledRACDialog {...dialogProps}>
|
||||
{({ close }) => (
|
||||
@@ -64,7 +66,6 @@ export const InviteDialog = ({
|
||||
<Div position="absolute" top="5" right="5">
|
||||
<Button
|
||||
invisible
|
||||
variant="tertiaryText"
|
||||
size="xs"
|
||||
onPress={() => {
|
||||
dialogProps.onClose?.()
|
||||
@@ -77,24 +78,45 @@ export const InviteDialog = ({
|
||||
</Div>
|
||||
<P>{t('shareDialog.description')}</P>
|
||||
<Button
|
||||
variant={isCopied ? 'success' : 'tertiary'}
|
||||
variant={isCopied ? 'success' : 'primary'}
|
||||
size="sm"
|
||||
fullWidth
|
||||
aria-label={t('shareDialog.copy')}
|
||||
style={{
|
||||
justifyContent: 'start',
|
||||
}}
|
||||
onPress={() => {
|
||||
navigator.clipboard.writeText(roomUrl)
|
||||
setIsCopied(true)
|
||||
}}
|
||||
onHoverChange={setIsHovered}
|
||||
data-attr="share-dialog-copy"
|
||||
>
|
||||
{isCopied ? (
|
||||
<>
|
||||
<RiCheckLine size={24} style={{ marginRight: '8px' }} />
|
||||
<RiCheckLine size={18} style={{ marginRight: '8px' }} />
|
||||
{t('shareDialog.copied')}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<RiFileCopyLine size={24} style={{ marginRight: '8px' }} />
|
||||
{t('shareDialog.copyButton')}
|
||||
<RiFileCopyLine
|
||||
size={18}
|
||||
style={{ marginRight: '8px', minWidth: '18px' }}
|
||||
/>
|
||||
{isHovered ? (
|
||||
t('shareDialog.copy')
|
||||
) : (
|
||||
<div
|
||||
style={{
|
||||
textOverflow: 'ellipsis',
|
||||
overflow: 'hidden',
|
||||
userSelect: 'none',
|
||||
textWrap: 'nowrap',
|
||||
}}
|
||||
>
|
||||
{roomUrl.replace(/^https?:\/\//, '')}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
|
||||
@@ -2,7 +2,6 @@ import { useTranslation } from 'react-i18next'
|
||||
import { PreJoin, type LocalUserChoices } from '@livekit/components-react'
|
||||
import { Screen } from '@/layout/Screen'
|
||||
import { CenteredContent } from '@/layout/CenteredContent'
|
||||
import { useUser } from '@/features/auth'
|
||||
|
||||
export const Join = ({
|
||||
onSubmit,
|
||||
@@ -10,10 +9,9 @@ export const Join = ({
|
||||
onSubmit: (choices: LocalUserChoices) => void
|
||||
}) => {
|
||||
const { t } = useTranslation('rooms')
|
||||
const { user } = useUser()
|
||||
|
||||
return (
|
||||
<Screen layout="centered" footer={false}>
|
||||
<Screen layout="centered">
|
||||
<CenteredContent title={t('join.heading')}>
|
||||
<PreJoin
|
||||
persistUserChoices
|
||||
@@ -22,7 +20,6 @@ export const Join = ({
|
||||
camLabel={t('join.videoinput.label')}
|
||||
joinLabel={t('join.joinLabel')}
|
||||
userLabel={t('join.usernameLabel')}
|
||||
defaults={{ username: user?.full_name }}
|
||||
/>
|
||||
</CenteredContent>
|
||||
</Screen>
|
||||
|
||||
@@ -1,255 +0,0 @@
|
||||
import { Button, H, Text, TextArea } from '@/primitives'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { cva } from '@/styled-system/css'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { styled, VStack } from '@/styled-system/jsx'
|
||||
import { usePostHog } from 'posthog-js/react'
|
||||
import { PostHog } from 'posthog-js'
|
||||
|
||||
const Card = styled('div', {
|
||||
base: {
|
||||
border: '1px solid',
|
||||
borderColor: 'gray.300',
|
||||
padding: '1rem',
|
||||
marginTop: '1.5rem',
|
||||
borderRadius: '0.25rem',
|
||||
boxShadow: '',
|
||||
minWidth: '380px',
|
||||
minHeight: '196px',
|
||||
},
|
||||
})
|
||||
|
||||
const Bar = styled('div', {
|
||||
base: {
|
||||
display: 'flex',
|
||||
border: '2px solid',
|
||||
borderColor: 'gray.300',
|
||||
borderRadius: '8px',
|
||||
overflowY: 'hidden',
|
||||
scrollbar: 'hidden',
|
||||
},
|
||||
})
|
||||
|
||||
const ratingButtonRecipe = cva({
|
||||
base: {
|
||||
backgroundColor: 'white',
|
||||
color: 'initial',
|
||||
border: 'none',
|
||||
borderRadius: 0,
|
||||
padding: '0.5rem 0.85rem',
|
||||
flexGrow: '1',
|
||||
cursor: 'pointer',
|
||||
},
|
||||
variants: {
|
||||
selected: {
|
||||
true: {
|
||||
backgroundColor: 'primary.800',
|
||||
color: 'white',
|
||||
},
|
||||
false: {
|
||||
'&[data-hovered]': {
|
||||
backgroundColor: 'gray.100',
|
||||
},
|
||||
},
|
||||
},
|
||||
borderLeft: {
|
||||
true: {
|
||||
borderLeft: '1px solid',
|
||||
borderColor: 'gray.300',
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
const labelRecipe = cva({
|
||||
base: {
|
||||
color: 'gray.600',
|
||||
paddingTop: '0.25rem',
|
||||
},
|
||||
})
|
||||
|
||||
const OpenFeedback = ({
|
||||
posthog,
|
||||
onNext,
|
||||
}: {
|
||||
posthog: PostHog
|
||||
onNext: () => void
|
||||
}) => {
|
||||
const { t } = useTranslation('rooms', { keyPrefix: 'openFeedback' })
|
||||
const [feedback, setFeedback] = useState('')
|
||||
|
||||
const onContinue = () => {
|
||||
setFeedback('')
|
||||
onNext()
|
||||
}
|
||||
|
||||
const onSubmit = () => {
|
||||
try {
|
||||
posthog.capture('survey sent', {
|
||||
$survey_id: '01933c5a-5a1d-0000-ada8-e39f5918c2d4',
|
||||
$survey_response: feedback,
|
||||
})
|
||||
} catch (e) {
|
||||
console.warn(e)
|
||||
} finally {
|
||||
onContinue()
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<H lvl={3}>{t('question')}</H>
|
||||
<TextArea
|
||||
id="feedbackInput"
|
||||
name="feedback"
|
||||
placeholder={t('placeholder')}
|
||||
required
|
||||
value={feedback}
|
||||
onChange={(e) => setFeedback(e.target.value)}
|
||||
style={{
|
||||
minHeight: '150px',
|
||||
marginBottom: '1rem',
|
||||
}}
|
||||
/>
|
||||
<VStack gap="0.5">
|
||||
<Button
|
||||
variant="primary"
|
||||
size="sm"
|
||||
fullWidth
|
||||
isDisabled={!feedback}
|
||||
onPress={onSubmit}
|
||||
>
|
||||
{t('submit')}
|
||||
</Button>
|
||||
<Button
|
||||
invisible
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
fullWidth
|
||||
onPress={onNext}
|
||||
>
|
||||
{t('skip')}
|
||||
</Button>
|
||||
</VStack>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
const RateQuality = ({
|
||||
posthog,
|
||||
onNext,
|
||||
maxRating = 7,
|
||||
}: {
|
||||
posthog: PostHog
|
||||
onNext: () => void
|
||||
maxRating?: number
|
||||
}) => {
|
||||
const { t } = useTranslation('rooms', { keyPrefix: 'rating' })
|
||||
const [selectedRating, setSelectedRating] = useState<number | null>(null)
|
||||
|
||||
const handleRatingClick = (rating: number) => {
|
||||
setSelectedRating((prevRating) => (prevRating === rating ? null : rating))
|
||||
}
|
||||
|
||||
const onSubmit = () => {
|
||||
try {
|
||||
posthog.capture('survey sent', {
|
||||
$survey_id: '01933c22-d005-0000-b623-20b752171e2e',
|
||||
$survey_response: `${selectedRating}`,
|
||||
})
|
||||
} catch (e) {
|
||||
console.warn(e)
|
||||
} finally {
|
||||
setSelectedRating(null)
|
||||
onNext()
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<H lvl={3}>{t('question')}</H>
|
||||
<Bar>
|
||||
{[...Array(maxRating)].map((_, index) => (
|
||||
<Button
|
||||
key={index}
|
||||
onPress={() => handleRatingClick(index + 1)}
|
||||
className={ratingButtonRecipe({
|
||||
selected: selectedRating === index + 1,
|
||||
borderLeft: index != 0,
|
||||
})}
|
||||
>
|
||||
{index + 1}
|
||||
</Button>
|
||||
))}
|
||||
</Bar>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
marginBottom: '1rem',
|
||||
}}
|
||||
>
|
||||
<Text variant="sm" className={labelRecipe()}>
|
||||
{t('levels.min')}
|
||||
</Text>
|
||||
<Text variant="sm" className={labelRecipe()}>
|
||||
{t('levels.max')}
|
||||
</Text>
|
||||
</div>
|
||||
<Button
|
||||
variant="primary"
|
||||
size="sm"
|
||||
fullWidth
|
||||
isDisabled={!selectedRating}
|
||||
onPress={onSubmit}
|
||||
>
|
||||
{t('submit')}
|
||||
</Button>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
const ConfirmationMessage = ({ onNext }: { onNext: () => void }) => {
|
||||
const { t } = useTranslation('rooms', { keyPrefix: 'confirmationMessage' })
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => {
|
||||
onNext()
|
||||
}, 10000)
|
||||
return () => clearTimeout(timer)
|
||||
}, [onNext])
|
||||
return (
|
||||
<Card
|
||||
style={{
|
||||
maxWidth: '380px',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
>
|
||||
<VStack gap={0}>
|
||||
<H lvl={3}>{t('heading')}</H>
|
||||
<Text as="p" variant="paragraph" centered>
|
||||
{t('body')}
|
||||
</Text>
|
||||
</VStack>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
export const Rating = () => {
|
||||
const posthog = usePostHog()
|
||||
|
||||
const [step, setStep] = useState(0)
|
||||
|
||||
if (step == 0) {
|
||||
return <RateQuality posthog={posthog} onNext={() => setStep(step + 1)} />
|
||||
}
|
||||
|
||||
if (step == 1) {
|
||||
return <OpenFeedback posthog={posthog} onNext={() => setStep(step + 1)} />
|
||||
}
|
||||
|
||||
if (step == 2) {
|
||||
return <ConfirmationMessage onNext={() => setStep(0)} />
|
||||
}
|
||||
}
|
||||
@@ -5,9 +5,11 @@ import { Text, P, ToggleButton, Div, H } from '@/primitives'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { HStack, styled, VStack } from '@/styled-system/jsx'
|
||||
import {
|
||||
BackgroundBlurFactory,
|
||||
BackgroundBlurProcessorInterface,
|
||||
} from './blur/index'
|
||||
BackgroundBlur,
|
||||
BackgroundOptions,
|
||||
ProcessorWrapper,
|
||||
BackgroundTransformer,
|
||||
} from '@livekit/track-processors'
|
||||
|
||||
const Information = styled('div', {
|
||||
base: {
|
||||
@@ -25,8 +27,6 @@ enum BlurRadius {
|
||||
NORMAL = 10,
|
||||
}
|
||||
|
||||
const isSupported = BackgroundBlurFactory.isSupported()
|
||||
|
||||
export const Effects = () => {
|
||||
const { t } = useTranslation('rooms', { keyPrefix: 'effects' })
|
||||
const { isCameraEnabled, cameraTrack, localParticipant } =
|
||||
@@ -37,12 +37,15 @@ export const Effects = () => {
|
||||
const localCameraTrack = cameraTrack?.track as LocalVideoTrack
|
||||
|
||||
const getProcessor = () => {
|
||||
return localCameraTrack?.getProcessor() as BackgroundBlurProcessorInterface
|
||||
return localCameraTrack?.getProcessor() as ProcessorWrapper<BackgroundOptions>
|
||||
}
|
||||
|
||||
const getBlurRadius = (): BlurRadius => {
|
||||
const processor = getProcessor()
|
||||
return processor?.options.blurRadius || BlurRadius.NONE
|
||||
return (
|
||||
(processor?.transformer as BackgroundTransformer)?.options?.blurRadius ||
|
||||
BlurRadius.NONE
|
||||
)
|
||||
}
|
||||
|
||||
const toggleBlur = async (blurRadius: number) => {
|
||||
@@ -58,11 +61,9 @@ export const Effects = () => {
|
||||
if (blurRadius == currentBlurRadius && processor) {
|
||||
await localCameraTrack.stopProcessor()
|
||||
} else if (!processor) {
|
||||
await localCameraTrack.setProcessor(
|
||||
BackgroundBlurFactory.getProcessor({ blurRadius })!
|
||||
)
|
||||
await localCameraTrack.setProcessor(BackgroundBlur(blurRadius))
|
||||
} else {
|
||||
processor?.update({ blurRadius })
|
||||
await processor?.updateTransformerOptions({ blurRadius })
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error applying blur:', error)
|
||||
@@ -146,10 +147,11 @@ export const Effects = () => {
|
||||
>
|
||||
{t('heading')}
|
||||
</H>
|
||||
{isSupported ? (
|
||||
{ProcessorWrapper.isSupported ? (
|
||||
<HStack>
|
||||
<ToggleButton
|
||||
size={'sm'}
|
||||
legacyStyle
|
||||
aria-label={tooltipLabel(BlurRadius.LIGHT)}
|
||||
tooltip={tooltipLabel(BlurRadius.LIGHT)}
|
||||
isDisabled={processorPending}
|
||||
@@ -160,6 +162,7 @@ export const Effects = () => {
|
||||
</ToggleButton>
|
||||
<ToggleButton
|
||||
size={'sm'}
|
||||
legacyStyle
|
||||
aria-label={tooltipLabel(BlurRadius.NORMAL)}
|
||||
tooltip={tooltipLabel(BlurRadius.NORMAL)}
|
||||
isDisabled={processorPending}
|
||||
|
||||
@@ -10,7 +10,7 @@ const StyledParticipantPlaceHolder = styled('div', {
|
||||
base: {
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
backgroundColor: 'primaryDark.100',
|
||||
backgroundColor: '#3d4043', // fixme - copied from gmeet
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
|
||||
@@ -1,43 +0,0 @@
|
||||
import { css } from '@/styled-system/css'
|
||||
import { RiRecordCircleLine } from '@remixicon/react'
|
||||
import { Text } from '@/primitives'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useRoomContext } from '@livekit/components-react'
|
||||
|
||||
export const RecordingStateToast = () => {
|
||||
const { t } = useTranslation('rooms', { keyPrefix: 'recording' })
|
||||
|
||||
const room = useRoomContext()
|
||||
|
||||
if (!room?.isRecording) return
|
||||
|
||||
return (
|
||||
<div
|
||||
className={css({
|
||||
display: 'flex',
|
||||
position: 'fixed',
|
||||
top: '10px',
|
||||
left: '10px',
|
||||
paddingY: '0.25rem',
|
||||
paddingX: '0.25rem 0.35rem',
|
||||
backgroundColor: 'primaryDark.200',
|
||||
borderColor: 'primaryDark.400',
|
||||
border: '1px solid',
|
||||
color: 'white',
|
||||
borderRadius: '4px',
|
||||
gap: '0.5rem',
|
||||
})}
|
||||
>
|
||||
<RiRecordCircleLine
|
||||
size={20}
|
||||
className={css({
|
||||
color: 'white',
|
||||
backgroundColor: 'danger.700',
|
||||
padding: '3px',
|
||||
borderRadius: '3px',
|
||||
})}
|
||||
/>
|
||||
<Text variant={'sm'}>{t('label')}</Text>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -10,7 +10,6 @@ import { useSidePanel } from '../hooks/useSidePanel'
|
||||
import { ReactNode } from 'react'
|
||||
import { Effects } from './Effects'
|
||||
import { Chat } from '../prefabs/Chat'
|
||||
import { Transcript } from './Transcript'
|
||||
|
||||
type StyledSidePanelProps = {
|
||||
title: string
|
||||
@@ -69,7 +68,6 @@ const StyledSidePanel = ({
|
||||
>
|
||||
<Button
|
||||
invisible
|
||||
variant="tertiaryText"
|
||||
size="xs"
|
||||
onPress={onClose}
|
||||
aria-label={closeButtonTooltip}
|
||||
@@ -107,7 +105,6 @@ export const SidePanel = () => {
|
||||
isEffectsOpen,
|
||||
isChatOpen,
|
||||
isSidePanelOpen,
|
||||
isTranscriptOpen,
|
||||
} = useSidePanel()
|
||||
const { t } = useTranslation('rooms', { keyPrefix: 'sidePanel' })
|
||||
|
||||
@@ -129,9 +126,6 @@ export const SidePanel = () => {
|
||||
<Panel isOpen={isChatOpen}>
|
||||
<Chat />
|
||||
</Panel>
|
||||
<Panel isOpen={isTranscriptOpen}>
|
||||
<Transcript />
|
||||
</Panel>
|
||||
</StyledSidePanel>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,100 +0,0 @@
|
||||
import { Button, Div, H, Text } from '@/primitives'
|
||||
|
||||
import thirdSlide from '@/assets/intro-slider/3_resume.png'
|
||||
import { css } from '@/styled-system/css'
|
||||
|
||||
import { useHasTranscriptAccess } from '../hooks/useHasTranscriptAccess'
|
||||
import { RiRecordCircleLine, RiStopCircleLine } from '@remixicon/react'
|
||||
import { useRoomId } from '@/features/rooms/livekit/hooks/useRoomId'
|
||||
import { useRoomContext } from '@livekit/components-react'
|
||||
import {
|
||||
RecordingMode,
|
||||
useStartRecording,
|
||||
} from '@/features/rooms/api/startRecording'
|
||||
import { useStopRecording } from '@/features/rooms/api/stopRecording'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { RoomEvent } from 'livekit-client'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
export const Transcript = () => {
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
|
||||
const { t } = useTranslation('rooms', { keyPrefix: 'transcript' })
|
||||
|
||||
const hasTranscriptAccess = useHasTranscriptAccess()
|
||||
|
||||
const roomId = useRoomId()
|
||||
|
||||
const { mutateAsync: startRecordingRoom } = useStartRecording()
|
||||
const { mutateAsync: stopRecordingRoom } = useStopRecording()
|
||||
|
||||
const room = useRoomContext()
|
||||
|
||||
useEffect(() => {
|
||||
const handleRecordingStatusChanged = () => {
|
||||
setIsLoading(false)
|
||||
}
|
||||
room.on(RoomEvent.RecordingStatusChanged, handleRecordingStatusChanged)
|
||||
return () => {
|
||||
room.off(RoomEvent.RecordingStatusChanged, handleRecordingStatusChanged)
|
||||
}
|
||||
}, [room])
|
||||
|
||||
const handleTranscript = async () => {
|
||||
if (!roomId) {
|
||||
console.warn('No room ID found')
|
||||
return
|
||||
}
|
||||
try {
|
||||
setIsLoading(true)
|
||||
if (room.isRecording) {
|
||||
await stopRecordingRoom({ id: roomId })
|
||||
} else {
|
||||
await startRecordingRoom({ id: roomId, mode: RecordingMode.Transcript })
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to handle transcript:', error)
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (!hasTranscriptAccess) return
|
||||
|
||||
return (
|
||||
<Div
|
||||
display="flex"
|
||||
overflowY="scroll"
|
||||
padding="0 1.5rem"
|
||||
flexGrow={1}
|
||||
flexDirection="column"
|
||||
alignItems="center"
|
||||
>
|
||||
<img src={thirdSlide} alt={'wip'} />
|
||||
{room.isRecording ? (
|
||||
<>
|
||||
<H lvl={2}>{t('stop.heading')}</H>
|
||||
<Text variant="sm" centered wrap="balance">
|
||||
{t('stop.body')}
|
||||
</Text>
|
||||
<div className={css({ height: '2rem' })} />
|
||||
<Button isDisabled={isLoading} onPress={() => handleTranscript()}>
|
||||
<RiStopCircleLine style={{ marginRight: '0.5rem' }} />{' '}
|
||||
{t('stop.button')}
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<H lvl={2}>{t('start.heading')}</H>
|
||||
<Text variant="sm" centered wrap="balance">
|
||||
{t('start.body')}
|
||||
</Text>
|
||||
<div className={css({ height: '2rem' })} />
|
||||
<Button isDisabled={isLoading} onPress={() => handleTranscript()}>
|
||||
<RiRecordCircleLine style={{ marginRight: '0.5rem' }} />{' '}
|
||||
{t('start.button')}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</Div>
|
||||
)
|
||||
}
|
||||
@@ -1,281 +0,0 @@
|
||||
import { ProcessorOptions, Track } from 'livekit-client'
|
||||
import posthog from 'posthog-js'
|
||||
import {
|
||||
FilesetResolver,
|
||||
ImageSegmenter,
|
||||
ImageSegmenterResult,
|
||||
} from '@mediapipe/tasks-vision'
|
||||
import {
|
||||
CLEAR_TIMEOUT,
|
||||
SET_TIMEOUT,
|
||||
TIMEOUT_TICK,
|
||||
timerWorkerScript,
|
||||
} from './TimerWorker'
|
||||
import { BackgroundBlurProcessorInterface, BackgroundOptions } from '.'
|
||||
|
||||
const PROCESSING_WIDTH = 256
|
||||
const PROCESSING_HEIGHT = 144
|
||||
|
||||
const SEGMENTATION_MASK_CANVAS_ID = 'background-blur-local-segmentation'
|
||||
const BLUR_CANVAS_ID = 'background-blur-local'
|
||||
|
||||
const DEFAULT_BLUR = '10'
|
||||
|
||||
/**
|
||||
* This implementation of video blurring is made to be run on CPU for browser that are
|
||||
* not compatible with track-processor-js.
|
||||
*
|
||||
* It also make possible to run blurring on browser that does not implement MediaStreamTrackGenerator and
|
||||
* MediaStreamTrackProcessor.
|
||||
*/
|
||||
export class BackgroundBlurCustomProcessor
|
||||
implements BackgroundBlurProcessorInterface
|
||||
{
|
||||
options: BackgroundOptions
|
||||
name: string
|
||||
processedTrack?: MediaStreamTrack | undefined
|
||||
|
||||
source?: MediaStreamTrack
|
||||
sourceSettings?: MediaTrackSettings
|
||||
videoElement?: HTMLVideoElement
|
||||
videoElementLoaded?: boolean
|
||||
|
||||
// Canvas containg the video processing result, of which we extract as stream.
|
||||
outputCanvas?: HTMLCanvasElement
|
||||
outputCanvasCtx?: CanvasRenderingContext2D
|
||||
|
||||
imageSegmenter?: ImageSegmenter
|
||||
imageSegmenterResult?: ImageSegmenterResult
|
||||
|
||||
// Canvas used for resizing video source and projecting mask.
|
||||
segmentationMaskCanvas?: HTMLCanvasElement
|
||||
segmentationMaskCanvasCtx?: CanvasRenderingContext2D
|
||||
|
||||
// Mask containg the inference result.
|
||||
segmentationMask?: ImageData
|
||||
|
||||
// The resized image of the video source.
|
||||
sourceImageData?: ImageData
|
||||
|
||||
timerWorker?: Worker
|
||||
|
||||
constructor(opts: BackgroundOptions) {
|
||||
this.name = 'blur'
|
||||
this.options = opts
|
||||
}
|
||||
|
||||
static get isSupported() {
|
||||
return navigator.userAgent.toLowerCase().includes('firefox')
|
||||
}
|
||||
|
||||
async init(opts: ProcessorOptions<Track.Kind>) {
|
||||
if (!opts.element) {
|
||||
throw new Error('Element is required for processing')
|
||||
}
|
||||
|
||||
this.source = opts.track as MediaStreamTrack
|
||||
this.sourceSettings = this.source!.getSettings()
|
||||
this.videoElement = opts.element as HTMLVideoElement
|
||||
|
||||
this._createMainCanvas()
|
||||
this._createMaskCanvas()
|
||||
|
||||
const stream = this.outputCanvas!.captureStream()
|
||||
const tracks = stream.getVideoTracks()
|
||||
if (tracks.length == 0) {
|
||||
throw new Error('No tracks found for processing')
|
||||
}
|
||||
this.processedTrack = tracks[0]
|
||||
|
||||
this.segmentationMask = new ImageData(PROCESSING_WIDTH, PROCESSING_HEIGHT)
|
||||
await this.initSegmenter()
|
||||
this._initWorker()
|
||||
|
||||
posthog.capture('firefox-blurring-init')
|
||||
}
|
||||
|
||||
update(opts: BackgroundOptions): void {
|
||||
this.options = opts
|
||||
}
|
||||
|
||||
_initWorker() {
|
||||
this.timerWorker = new Worker(timerWorkerScript, {
|
||||
name: 'Blurring',
|
||||
})
|
||||
this.timerWorker.onmessage = (data) => this.onTimerMessage(data)
|
||||
// When hiding camera then showing it again, the onloadeddata callback is not fired again.
|
||||
if (this.videoElementLoaded) {
|
||||
this.timerWorker!.postMessage({
|
||||
id: SET_TIMEOUT,
|
||||
timeMs: 1000 / 30,
|
||||
})
|
||||
} else {
|
||||
this.videoElement!.onloadeddata = () => {
|
||||
this.videoElementLoaded = true
|
||||
this.timerWorker!.postMessage({
|
||||
id: SET_TIMEOUT,
|
||||
timeMs: 1000 / 30,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onTimerMessage(response: { data: { id: number } }) {
|
||||
if (response.data.id === TIMEOUT_TICK) {
|
||||
this.process()
|
||||
}
|
||||
}
|
||||
|
||||
async initSegmenter() {
|
||||
const vision = await FilesetResolver.forVisionTasks(
|
||||
'https://cdn.jsdelivr.net/npm/@mediapipe/tasks-vision/wasm'
|
||||
)
|
||||
this.imageSegmenter = await ImageSegmenter.createFromOptions(vision, {
|
||||
baseOptions: {
|
||||
modelAssetPath:
|
||||
'https://storage.googleapis.com/mediapipe-models/image_segmenter/selfie_segmenter_landscape/float16/latest/selfie_segmenter_landscape.tflite',
|
||||
delegate: 'CPU', // Use CPU for Firefox.
|
||||
},
|
||||
runningMode: 'VIDEO',
|
||||
outputCategoryMask: true,
|
||||
outputConfidenceMasks: false,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Resize the source video to the processing resolution.
|
||||
*/
|
||||
async sizeSource() {
|
||||
this.segmentationMaskCanvasCtx?.drawImage(
|
||||
this.videoElement!,
|
||||
0,
|
||||
0,
|
||||
this.videoElement!.videoWidth,
|
||||
this.videoElement!.videoHeight,
|
||||
0,
|
||||
0,
|
||||
PROCESSING_WIDTH,
|
||||
PROCESSING_HEIGHT
|
||||
)
|
||||
|
||||
this.sourceImageData = this.segmentationMaskCanvasCtx?.getImageData(
|
||||
0,
|
||||
0,
|
||||
PROCESSING_WIDTH,
|
||||
PROCESSING_WIDTH
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Run the segmentation.
|
||||
*/
|
||||
async segment() {
|
||||
const startTimeMs = performance.now()
|
||||
return new Promise<void>((resolve) => {
|
||||
this.imageSegmenter!.segmentForVideo(
|
||||
this.sourceImageData!,
|
||||
startTimeMs,
|
||||
(result: ImageSegmenterResult) => {
|
||||
this.imageSegmenterResult = result
|
||||
resolve()
|
||||
}
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* TODO: future improvement with WebGL.
|
||||
*/
|
||||
async blur() {
|
||||
const mask = this.imageSegmenterResult!.categoryMask!.getAsUint8Array()
|
||||
for (let i = 0; i < mask.length; ++i) {
|
||||
this.segmentationMask!.data[i * 4 + 3] = 255 - mask[i]
|
||||
}
|
||||
|
||||
this.segmentationMaskCanvasCtx!.putImageData(this.segmentationMask!, 0, 0)
|
||||
|
||||
this.outputCanvasCtx!.globalCompositeOperation = 'copy'
|
||||
this.outputCanvasCtx!.filter = 'blur(8px)'
|
||||
|
||||
this.outputCanvasCtx!.drawImage(
|
||||
this.segmentationMaskCanvas!,
|
||||
0,
|
||||
0,
|
||||
PROCESSING_WIDTH,
|
||||
PROCESSING_HEIGHT,
|
||||
0,
|
||||
0,
|
||||
this.videoElement!.videoWidth,
|
||||
this.videoElement!.videoHeight
|
||||
)
|
||||
|
||||
this.outputCanvasCtx!.globalCompositeOperation = 'source-in'
|
||||
this.outputCanvasCtx!.filter = 'none'
|
||||
this.outputCanvasCtx!.drawImage(this.videoElement!, 0, 0)
|
||||
|
||||
this.outputCanvasCtx!.globalCompositeOperation = 'destination-over'
|
||||
this.outputCanvasCtx!.filter = `blur(${this.options.blurRadius ?? DEFAULT_BLUR}px)`
|
||||
this.outputCanvasCtx!.drawImage(this.videoElement!, 0, 0)
|
||||
}
|
||||
|
||||
async process() {
|
||||
await this.sizeSource()
|
||||
await this.segment()
|
||||
await this.blur()
|
||||
this.timerWorker!.postMessage({
|
||||
id: SET_TIMEOUT,
|
||||
timeMs: 1000 / 30,
|
||||
})
|
||||
}
|
||||
|
||||
_createMainCanvas() {
|
||||
this.outputCanvas = document.querySelector(
|
||||
'canvas#background-blur-local'
|
||||
) as HTMLCanvasElement
|
||||
if (!this.outputCanvas) {
|
||||
this.outputCanvas = this._createCanvas(
|
||||
BLUR_CANVAS_ID,
|
||||
this.sourceSettings!.width!,
|
||||
this.sourceSettings!.height!
|
||||
)
|
||||
}
|
||||
this.outputCanvasCtx = this.outputCanvas.getContext('2d')!
|
||||
}
|
||||
|
||||
_createMaskCanvas() {
|
||||
this.segmentationMaskCanvas = document.querySelector(
|
||||
`#${SEGMENTATION_MASK_CANVAS_ID}`
|
||||
) as HTMLCanvasElement
|
||||
if (!this.segmentationMaskCanvas) {
|
||||
this.segmentationMaskCanvas = this._createCanvas(
|
||||
SEGMENTATION_MASK_CANVAS_ID,
|
||||
PROCESSING_WIDTH,
|
||||
PROCESSING_HEIGHT
|
||||
)
|
||||
}
|
||||
this.segmentationMaskCanvasCtx =
|
||||
this.segmentationMaskCanvas.getContext('2d')!
|
||||
}
|
||||
|
||||
_createCanvas(id: string, width: number, height: number) {
|
||||
const element = document.createElement('canvas')
|
||||
element.setAttribute('id', id)
|
||||
element.setAttribute('width', '' + width)
|
||||
element.setAttribute('height', '' + height)
|
||||
return element
|
||||
}
|
||||
|
||||
async restart(opts: ProcessorOptions<Track.Kind>) {
|
||||
await this.destroy()
|
||||
return this.init(opts)
|
||||
}
|
||||
|
||||
async destroy() {
|
||||
this.timerWorker?.postMessage({
|
||||
id: CLEAR_TIMEOUT,
|
||||
})
|
||||
|
||||
this.timerWorker?.terminate()
|
||||
this.imageSegmenter?.close()
|
||||
}
|
||||
}
|
||||
@@ -1,48 +0,0 @@
|
||||
import {
|
||||
BackgroundBlur,
|
||||
BackgroundTransformer,
|
||||
ProcessorWrapper,
|
||||
} from '@livekit/track-processors'
|
||||
import { ProcessorOptions, Track } from 'livekit-client'
|
||||
import { BackgroundBlurProcessorInterface, BackgroundOptions } from '.'
|
||||
|
||||
/**
|
||||
* This is simply a wrapper around track-processor-js Processor
|
||||
* in order to be compatible with a common interface BackgroundBlurProcessorInterface
|
||||
* used accross the project.
|
||||
*/
|
||||
export class BackgroundBlurTrackProcessorJsWrapper
|
||||
implements BackgroundBlurProcessorInterface
|
||||
{
|
||||
name: string = 'Blur'
|
||||
|
||||
processor: ProcessorWrapper<BackgroundOptions>
|
||||
|
||||
constructor(opts: BackgroundOptions) {
|
||||
this.processor = BackgroundBlur(opts.blurRadius)
|
||||
}
|
||||
|
||||
async init(opts: ProcessorOptions<Track.Kind>) {
|
||||
return this.processor.init(opts)
|
||||
}
|
||||
|
||||
async restart(opts: ProcessorOptions<Track.Kind>) {
|
||||
return this.processor.restart(opts)
|
||||
}
|
||||
|
||||
async destroy() {
|
||||
return this.processor.destroy()
|
||||
}
|
||||
|
||||
update(opts: BackgroundOptions): void {
|
||||
this.processor.updateTransformerOptions(opts)
|
||||
}
|
||||
|
||||
get processedTrack() {
|
||||
return this.processor.processedTrack
|
||||
}
|
||||
|
||||
get options() {
|
||||
return (this.processor.transformer as BackgroundTransformer).options
|
||||
}
|
||||
}
|
||||