mirror of
https://github.com/suitenumerique/meet.git
synced 2026-07-27 12:19:10 +00:00
Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 8812e12849 | |||
| 80c9620368 | |||
| 8bdca048f4 |
@@ -8,17 +8,6 @@ and this project adheres to
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Added
|
||||
|
||||
- ✨(encryption) opt-in end-to-end encryption for meetings, with the
|
||||
passphrase carried in the URL hash and an explicit "Create an
|
||||
encrypted meeting" entry in the home create menu (gated by a
|
||||
per-user Security preference) #1337
|
||||
- ✨(encryption) authenticated participant emails surfaced in the
|
||||
participants list of encrypted rooms; anonymous participants get a
|
||||
red badge with tooltip in the list, waiting room, and the floating
|
||||
join-request notification
|
||||
|
||||
### Changed
|
||||
|
||||
- ⬆️(dependencies) update python dependencies
|
||||
|
||||
@@ -125,10 +125,16 @@ run-summary: ## start only the summary application and all needed services
|
||||
@$(COMPOSE) up --force-recreate -d celery-summary-summarize
|
||||
.PHONY: run-summary
|
||||
|
||||
run-sip: ## start the SIP gateway and the browser-based softphone (Janus + nginx)
|
||||
@$(COMPOSE) up --force-recreate -d sip
|
||||
@$(COMPOSE) up --force-recreate -d sip-web-janus sip-web
|
||||
.PHONY: run-sip
|
||||
|
||||
run:
|
||||
run: ## start the wsgi (production) and development server
|
||||
@$(MAKE) run-backend
|
||||
@$(MAKE) run-summary
|
||||
@$(MAKE) run-sip
|
||||
@$(COMPOSE) up --force-recreate -d frontend
|
||||
.PHONY: run
|
||||
|
||||
|
||||
@@ -52,19 +52,30 @@ La Suite Meet supports end-to-end encryption (E2EE) for meetings, so the media s
|
||||
|
||||
#### How it works
|
||||
|
||||
- Each encrypted meeting carries a 48-character hex passphrase appended to the URL hash (`#…`) — 192 bits of entropy. The server never sees it; sharing the meeting link shares the key.
|
||||
- Each encrypted meeting carries a 48-character random passphrase appended to the URL hash (`#…`). The server never sees it; sharing the meeting link shares the key.
|
||||
- Frames are encrypted in the browser via LiveKit's Worker + `crypto.subtle` (AES-GCM); only the media payload is encrypted, codec headers stay clear so the SFU can still packetize RTP.
|
||||
- The runtime "is this call encrypted?" decision keys off the URL hash, not the database flag. The DB column (`Room.encryption_mode`) is only used as a sanity reference: if the URL hash and the server's claim disagree, the joining client surfaces an explicit mismatch screen instead of silently joining in clear or in a private encrypted bubble.
|
||||
|
||||
> **Threat model.** "Server doesn't see plaintext" — not "users are safe from a malicious server." A compromised server could still serve modified JavaScript to a participant, who would then leak their passphrase. The E2EE story protects the media path against a passive or compromised SFU, not against a fully compromised origin.
|
||||
|
||||
#### Encryption mode is set at creation, immutable after
|
||||
|
||||
`Room.encryption_mode` is a string enum (`none` / `basic`) chosen when the room is created and never mutated afterwards — changing it would change the link's semantics, since the passphrase lives in the URL hash. There is no mid-call "pause encryption" mechanism: while a meeting is encrypted, **recording and transcription endpoints reject requests with a 400** (`Recording is unavailable in encrypted rooms.` / `Subtitles are unavailable in encrypted rooms.`), the More-tools panel renders those items disabled with an explanatory banner, and the SIP gateway never gets a dispatch rule for encrypted rooms (so dial-in numbers and PINs aren't allocated). Encrypted rooms are also force-locked to `restricted` access level (lobby admission), since basic E2EE only meaningfully protects against passive eavesdropping if the host vets joiners before they receive the in-URL key.
|
||||
- The runtime "is this call encrypted?" decision keys off the URL hash, not the database flag — a compromised server cannot fabricate a passphrase that all participants happen to share.
|
||||
- The DB flag (`Room.is_encrypted`) is a hint used at room creation time only (so the Create button knows to generate a hash) and to detect link/server inconsistencies.
|
||||
|
||||
#### Opt-in by user
|
||||
|
||||
End-to-end encryption is a per-user preference. In **Settings**, under the **Security** section, signed-in users can flip the **End-to-end encryption** toggle — once enabled, a third "Create an encrypted meeting" entry appears in the home-page create-menu (with its own confirmation modal that lists the disabled features and a "Treat this link like a password" connection-details dialog before the meeting starts). Joining is unaffected by the toggle: any participant clicking a meeting link that carries a valid hash joins encrypted, regardless of their own setting. Authenticated joiners of encrypted rooms cannot edit their displayed name — the server enforces the OIDC name on the JWT.
|
||||
End-to-end encryption is a per-user preference. In **Settings → Security**, signed-in users can enable "End-to-end encryption" — from then on every meeting they create is encrypted by default. Joining is unaffected: if a meeting URL has a passphrase, the joining client uses it.
|
||||
|
||||
#### Pause / resume for recording and transcription
|
||||
|
||||
While encryption is on, the SFU cannot record or transcribe (it has nothing to read). When an admin (or, if no admin is present, the longest-present participant — provided a pause has already been observed in the session) starts a recording or transcription:
|
||||
|
||||
1. A confirmation dialog warns that encryption will be paused.
|
||||
2. On confirm, an `ENCRYPTION_PAUSED` message is broadcast over a LiveKit reliable data channel. While the sender hasn't yet flipped its own state, that message is itself encrypted — which is the trust anchor: only callers holding the passphrase can produce frames everyone can decrypt.
|
||||
3. Each receiver disables E2EE locally and republishes its tracks unencrypted.
|
||||
4. Late joiners send an `ENCRYPTION_STATUS_PROBE` so the leader can re-emit the announcement to them.
|
||||
5. When **both** recording and transcription stop, the participant who paused broadcasts `ENCRYPTION_RESUMED` and everyone re-enables E2EE with the same URL passphrase.
|
||||
|
||||
The pause state is intentionally session-only and never persisted — `Room.is_encrypted` does not flip.
|
||||
|
||||
#### Phone / SIP participants
|
||||
|
||||
Phone and other external devices can't decrypt our frames. When one joins an encrypted room, the backend webhook detects them, broadcasts a system notice (admins see a snackbar with an "Open settings" CTA), and removes the external participant. The admin can then disable encryption from the Security settings and the user can dial in again.
|
||||
|
||||
#### Configuration
|
||||
|
||||
@@ -72,7 +83,7 @@ End-to-end encryption is a per-user preference. In **Settings**, under the **Sec
|
||||
ENCRYPTION_ENABLED=true
|
||||
```
|
||||
|
||||
Setting `ENCRYPTION_ENABLED=false` rejects encrypted-room creation at the API level. Existing encrypted rooms stay encrypted (the mode is immutable), but no new ones can be created.
|
||||
Setting `ENCRYPTION_ENABLED=false` disables the user preference toggle entirely; existing encrypted rooms stay encrypted but no new ones can be created.
|
||||
|
||||
La Suite Meet is fully self-hostable and released under the MIT License, ensuring complete control and flexibility. It's simple to [get started](https://visio.numerique.gouv.fr/) or [request a demo](mailto:visio@numerique.gouv.fr).
|
||||
|
||||
@@ -117,7 +128,7 @@ We hope to see many more, here is an incomplete list of public La Suite Meet ins
|
||||
| [visio.numerique.gouv.fr](https://visio.numerique.gouv.fr/) | DINUM | French public agents working for the central administration and the extended public sphere. ProConnect is required to login in or sign up |
|
||||
| [visio.suite.anct.gouv.fr](https://visio.suite.anct.gouv.fr/) | ANCT | French public agents working for the territorial administration and the extended public sphere. ProConnect is required to login in or sign up |
|
||||
| [visio.lasuite.coop](https://visio.lasuite.coop/) | lasuite.coop | Free and open demo to all. Content and accounts are reset after one month |
|
||||
| [mosa.cloud](https://mosa.cloud/) | mosa.cloud | Demo instance of mosa.cloud, a dutch company providing services around La Suite apps. |
|
||||
| [mosacloud.cloud](https://mosa.cloud/) | mosa.cloud | Demo instance of mosa.cloud, a dutch company providing services around La Suite apps. |
|
||||
|
||||
## Contributing
|
||||
|
||||
|
||||
+55
-2
@@ -168,8 +168,7 @@ services:
|
||||
- "3000:8080"
|
||||
|
||||
dockerize:
|
||||
image: jwilder/dockerize
|
||||
platform: linux/x86_64
|
||||
image: powerman/dockerize:0.19.0
|
||||
|
||||
crowdin:
|
||||
image: crowdin/cli:4.0.0
|
||||
@@ -246,6 +245,60 @@ services:
|
||||
depends_on:
|
||||
- redis
|
||||
|
||||
# SIP gateway. Built from the sibling livekit-sip checkout so any dev
|
||||
# cloning both repos gets a working `docker compose up`. TODO: replace
|
||||
# the build: block with `image: livekit/sip` once upstream ships an
|
||||
# image with the team's video bridging.
|
||||
sip:
|
||||
build:
|
||||
context: ../livekit-sip
|
||||
dockerfile: build/sip/Dockerfile
|
||||
ports:
|
||||
- "5060:5060/udp"
|
||||
- "5060:5060/tcp"
|
||||
- "10000-10020:10000-10020/udp"
|
||||
environment:
|
||||
SIP_CONFIG_BODY: |
|
||||
api_key: 'devkey'
|
||||
api_secret: 'secret'
|
||||
ws_url: 'ws://livekit:7880'
|
||||
redis:
|
||||
address: 'redis:6379'
|
||||
sip_port: 5060
|
||||
rtp_port: 10000-10020
|
||||
use_external_ip: false
|
||||
logging:
|
||||
level: debug
|
||||
depends_on:
|
||||
- livekit
|
||||
- redis
|
||||
|
||||
# Janus WebRTC gateway with the SIP plugin. Bridges a browser SIP demo
|
||||
# tab to the SIP gateway above. ICE-TCP enabled (janus.jcfg) so media
|
||||
# works under Lima port-forwarding without vmnet.
|
||||
sip-web-janus:
|
||||
build: ./docker/janus
|
||||
ports:
|
||||
- "10100-10120:10100-10120/tcp"
|
||||
volumes:
|
||||
- ./docker/janus/conf/janus.jcfg:/usr/local/etc/janus/janus.jcfg:ro
|
||||
- ./docker/janus/conf/janus.transport.http.jcfg:/usr/local/etc/janus/janus.transport.http.jcfg:ro
|
||||
- ./docker/janus/conf/janus.plugin.sip.jcfg:/usr/local/etc/janus/janus.plugin.sip.jcfg:ro
|
||||
depends_on:
|
||||
- sip
|
||||
|
||||
# Serves docker/janus/web/{index.html,janus.js} and proxies /janus to
|
||||
# the Janus container so the demo is single-origin.
|
||||
sip-web:
|
||||
image: nginx:alpine
|
||||
ports:
|
||||
- "8088:80"
|
||||
volumes:
|
||||
- ./docker/janus/web:/usr/share/nginx/html:ro
|
||||
- ./docker/janus/conf/nginx.conf:/etc/nginx/conf.d/default.conf:ro
|
||||
depends_on:
|
||||
- sip-web-janus
|
||||
|
||||
redis-summary:
|
||||
image: redis
|
||||
ports:
|
||||
|
||||
@@ -845,6 +845,23 @@
|
||||
"offline_access",
|
||||
"microprofile-jwt"
|
||||
]
|
||||
},
|
||||
{
|
||||
"clientId": "encryption",
|
||||
"name": "Encryption Service",
|
||||
"enabled": true,
|
||||
"publicClient": true,
|
||||
"standardFlowEnabled": true,
|
||||
"directAccessGrantsEnabled": false,
|
||||
"redirectUris": [
|
||||
"http://encryption.localhost:7200/auth/callback"
|
||||
],
|
||||
"webOrigins": [
|
||||
"http://encryption.localhost:7200",
|
||||
"http://data.encryption.localhost:7200"
|
||||
],
|
||||
"protocol": "openid-connect",
|
||||
"fullScopeAllowed": true
|
||||
}
|
||||
],
|
||||
"clientScopes": [
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
# Build Janus from source so ICE-TCP support is present (the canyan image
|
||||
# was compiled against libnice 0.1.16, which lacks the runtime ICE-TCP
|
||||
# capability Janus 1.1.x feature-detects at startup). Debian 13 ships
|
||||
# libnice 0.1.21+ which has ICE-TCP. Also: builds natively on arm64, so
|
||||
# no QEMU emulation overhead.
|
||||
FROM debian:13-slim AS builder
|
||||
|
||||
ARG JANUS_VERSION=v1.1.4
|
||||
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
build-essential ca-certificates git pkg-config \
|
||||
autoconf automake libtool gengetopt \
|
||||
libssl-dev libsrtp2-dev libglib2.0-dev libopus-dev libogg-dev \
|
||||
libcurl4-openssl-dev libconfig-dev libnice-dev \
|
||||
libmicrohttpd-dev libjansson-dev libsofia-sip-ua-dev \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
WORKDIR /src
|
||||
RUN git clone --depth 1 --branch ${JANUS_VERSION} \
|
||||
https://github.com/meetecho/janus-gateway.git janus
|
||||
|
||||
WORKDIR /src/janus
|
||||
RUN ./autogen.sh && \
|
||||
./configure \
|
||||
--prefix=/opt/janus \
|
||||
--disable-rabbitmq --disable-mqtt --disable-nanomsg --disable-unix-sockets \
|
||||
--disable-data-channels \
|
||||
--disable-all-plugins \
|
||||
--enable-plugin-sip \
|
||||
--disable-all-handlers \
|
||||
--disable-all-transports --enable-rest && \
|
||||
make -j$(nproc) && \
|
||||
make install && \
|
||||
make configs
|
||||
|
||||
FROM debian:13-slim
|
||||
|
||||
# Debian 13 (trixie) renamed several libs in the time_t-64 transition:
|
||||
# libglib2.0-0t64, libcurl4t64. libconfig9 → libconfig11.
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
ca-certificates libssl3 libsrtp2-1 libglib2.0-0t64 libopus0 libogg0 \
|
||||
libcurl4t64 libconfig11 libnice10 libmicrohttpd12 libjansson4 \
|
||||
libsofia-sip-ua0 \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY --from=builder /opt/janus /opt/janus
|
||||
|
||||
# Make paths match canyan's so the rest of the compose mounts (configs, demos)
|
||||
# Just-Work without changes. The configs we mount in docker/janus/conf/ assume
|
||||
# /usr/local/etc/janus and /usr/local/lib/janus.
|
||||
RUN ln -s /opt/janus/bin/janus /usr/local/bin/janus && \
|
||||
ln -s /opt/janus/etc/janus /usr/local/etc/janus && \
|
||||
ln -s /opt/janus/lib/janus /usr/local/lib/janus && \
|
||||
ln -s /opt/janus/share/janus /usr/local/share/janus
|
||||
|
||||
EXPOSE 8088 10100-10120/tcp 10100-10120/udp
|
||||
|
||||
CMD ["/usr/local/bin/janus"]
|
||||
@@ -0,0 +1,42 @@
|
||||
// Minimal Janus config for browser-↔-SIP demo. Trimmed from the upstream
|
||||
// sample to the bits we actually need.
|
||||
general: {
|
||||
configs_folder = "/usr/local/etc/janus"
|
||||
plugins_folder = "/usr/local/lib/janus/plugins"
|
||||
transports_folder = "/usr/local/lib/janus/transports"
|
||||
events_folder = "/usr/local/lib/janus/events"
|
||||
log_to_stdout = true
|
||||
debug_level = 4
|
||||
server_name = "meet-sip-demo"
|
||||
}
|
||||
|
||||
media: {
|
||||
// Janus ↔ Browser media (WebRTC over TCP — Lima only forwards TCP).
|
||||
rtp_port_range = "10100-10120"
|
||||
ipv6 = false
|
||||
}
|
||||
|
||||
nat: {
|
||||
// The whole reason this works without vmnet — Janus offers TCP ICE
|
||||
// candidates the Mac browser can actually connect to.
|
||||
ice_tcp = true
|
||||
// libnice quirk: ICE-TCP needs ICE Lite mode on the server side or
|
||||
// the connectivity check state machine deadlocks. Janus warns about
|
||||
// this at startup if Lite is off while TCP is on.
|
||||
ice_lite = true
|
||||
// Janus advertises this IP in ICE candidates. From the Mac browser's
|
||||
// perspective, the Janus daemon lives at 127.0.0.1:<port> (Lima
|
||||
// forwards Mac:127.0.0.1:<port> → VM → docker port-map → container).
|
||||
nat_1_1_mapping = "127.0.0.1"
|
||||
keep_private_host = true
|
||||
}
|
||||
|
||||
plugins: {
|
||||
// Only keep the SIP plugin. Everything else trimmed for boot time.
|
||||
disable = "libjanus_audiobridge.so,libjanus_videoroom.so,libjanus_streaming.so,libjanus_textroom.so,libjanus_recordplay.so,libjanus_voicemail.so,libjanus_echotest.so,libjanus_videocall.so,libjanus_nosip.so,libjanus_duktape.so,libjanus_lua.so"
|
||||
}
|
||||
|
||||
transports: {
|
||||
// Only HTTP. WS is overkill for the demo and adds another port.
|
||||
disable = "libjanus_websockets.so,libjanus_mqtt.so,libjanus_nanomsg.so,libjanus_rabbitmq.so,libjanus_pfunix.so"
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
// SIP plugin defaults. RTP range for the SIP leg (Janus ↔ livekit/sip)
|
||||
// is kept separate from WebRTC range — both legs are inside the docker
|
||||
// bridge network, so any UDP range works.
|
||||
general: {
|
||||
local_ip = "0.0.0.0"
|
||||
rtp_port_range = "20000-20100"
|
||||
events = true
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
// HTTP transport: bind 0.0.0.0:8088 inside the container, served to the
|
||||
// browser through the nginx sidecar that also hosts the demo HTML.
|
||||
general: {
|
||||
json = "indented"
|
||||
base_path = "/janus"
|
||||
http = true
|
||||
port = 8088
|
||||
https = false
|
||||
mhd_connection_limit = 1020
|
||||
}
|
||||
|
||||
admin: {
|
||||
admin_base_path = "/admin"
|
||||
admin_http = false
|
||||
admin_https = false
|
||||
}
|
||||
|
||||
certificates: {
|
||||
}
|
||||
|
||||
cors: {
|
||||
// Same-origin via nginx proxy; demo page and API share a host.
|
||||
# allow_origin = "*"
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
server {
|
||||
listen 80;
|
||||
server_name _;
|
||||
|
||||
# Docker's embedded DNS (127.0.0.11). Without this, nginx resolves
|
||||
# upstreams once at startup and breaks the moment a peer container is
|
||||
# recreated with a new bridge IP. The `valid=10s` plus a variable in
|
||||
# proxy_pass forces per-request re-resolution.
|
||||
resolver 127.0.0.11 valid=10s ipv6=off;
|
||||
|
||||
# Serve the Janus demo HTML/JS extracted from the canyan image.
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
# Janus HTTP API — same origin as the demo pages so the default
|
||||
# settings.js URL ("http://<host>:8088/janus") just works. Regex so
|
||||
# /janus and /janus/<session>/... proxy, but /janus.js still hits
|
||||
# the static file root.
|
||||
location ~ ^/janus(/|$) {
|
||||
# Variable upstream + the resolver above = re-DNS on each request.
|
||||
# Plain `proxy_pass http://sip-web-janus:8088` would cache the IP
|
||||
# for the lifetime of the worker process.
|
||||
set $janus_upstream sip-web-janus;
|
||||
proxy_pass http://$janus_upstream:8088;
|
||||
proxy_http_version 1.1;
|
||||
proxy_buffering off;
|
||||
proxy_read_timeout 120s;
|
||||
}
|
||||
|
||||
location / {
|
||||
try_files $uri $uri/ =404;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,405 @@
|
||||
<!DOCTYPE html>
|
||||
<!--
|
||||
Minimal SIP-via-Janus demo. Replaces the upstream siptest.html bundle with
|
||||
a single self-contained page. Everything is auto-driven: page load creates
|
||||
the Janus session, attaches to the SIP plugin, registers as guest, places
|
||||
the call. The user just needs to type the room PIN on the dialpad.
|
||||
|
||||
Co-located only with janus.js (vendor lib, ~113KB) — no settings.js, no
|
||||
navbar/footer, no shared CSS, no logos. nginx serves these two files.
|
||||
-->
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>SIP Demo</title>
|
||||
<style>
|
||||
* { box-sizing: border-box; }
|
||||
html, body { margin: 0; padding: 0; height: 100%; background: #1b1b1f; color: #e8e8e8;
|
||||
font: 14px/1.4 -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; }
|
||||
#app { display: flex; flex-direction: column; height: 100vh; max-width: 720px; margin: 0 auto; padding: 16px; gap: 12px; }
|
||||
h1 { margin: 0; font-size: 18px; font-weight: 600; }
|
||||
#status { padding: 8px 12px; border-radius: 6px; background: #2c2c33; font-family: ui-monospace, monospace; font-size: 13px; }
|
||||
#video-wrap { position: relative; flex: 1; background: #000; border-radius: 8px; overflow: hidden; min-height: 240px; }
|
||||
#remoteVideo { width: 100%; height: 100%; object-fit: contain; }
|
||||
#noVideo { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; color: #555; font-size: 14px; }
|
||||
.row { display: flex; gap: 8px; flex-wrap: wrap; }
|
||||
button { font: inherit; padding: 8px 14px; border: 0; border-radius: 6px; background: #4a90e2; color: #fff; cursor: pointer; }
|
||||
button:hover:not(:disabled) { background: #357ec0; }
|
||||
button:disabled { opacity: 0.4; cursor: not-allowed; }
|
||||
button.danger { background: #dc3545; }
|
||||
button.danger:hover:not(:disabled) { background: #b62733; }
|
||||
button.success { background: #28a745; }
|
||||
button.success:hover:not(:disabled) { background: #1f8035; }
|
||||
.dialpad { display: grid; grid-template-columns: repeat(4, 1fr); gap: 6px; }
|
||||
.dialpad button { padding: 14px 0; font-size: 16px; background: #3a3a44; }
|
||||
.dialpad button:hover:not(:disabled) { background: #4a4a55; }
|
||||
#pin-row input { flex: 1; padding: 8px 12px; border: 1px solid #444; border-radius: 6px; background: #2c2c33; color: #fff; font: inherit; font-family: ui-monospace, monospace; letter-spacing: 0.2em; }
|
||||
#log { font-family: ui-monospace, monospace; font-size: 11px; max-height: 120px; overflow-y: auto; background: #15151a; padding: 8px; border-radius: 6px; color: #888; }
|
||||
.log-line { white-space: pre-wrap; word-break: break-word; }
|
||||
.log-err { color: #ff7373; }
|
||||
.disabled { opacity: 0.35; pointer-events: none; transition: opacity 0.2s; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app">
|
||||
<h1>SIP demo (browser → Janus → livekit-sip → room)</h1>
|
||||
<div id="status">Loading…</div>
|
||||
|
||||
<div id="video-wrap">
|
||||
<video id="remoteVideo" autoplay playsinline muted></video>
|
||||
<audio id="remoteAudio" autoplay></audio>
|
||||
<div id="noVideo">No remote video yet</div>
|
||||
</div>
|
||||
|
||||
<div id="pin-row" class="row" style="display:none">
|
||||
<input type="text" id="pin" placeholder="Enter PIN then press # on the pad" inputmode="numeric" pattern="[0-9]*">
|
||||
<button id="sendPin" class="success">Send PIN</button>
|
||||
</div>
|
||||
|
||||
<div id="dialpad" class="dialpad" style="display:none">
|
||||
<button data-d="1">1</button><button data-d="2">2</button><button data-d="3">3</button><button data-d="A">A</button>
|
||||
<button data-d="4">4</button><button data-d="5">5</button><button data-d="6">6</button><button data-d="B">B</button>
|
||||
<button data-d="7">7</button><button data-d="8">8</button><button data-d="9">9</button><button data-d="C">C</button>
|
||||
<button data-d="*">*</button><button data-d="0">0</button><button data-d="#">#</button><button data-d="D">D</button>
|
||||
</div>
|
||||
|
||||
<div id="controls" class="row">
|
||||
<button id="muteBtn" class="danger">🔇 Mic muted</button>
|
||||
<button id="cameraBtn" class="success">📷 Camera on</button>
|
||||
<button id="speakerBtn" class="success">🔊 Speaker on</button>
|
||||
<button id="hangupBtn" class="danger" disabled>Hangup</button>
|
||||
<button id="rejoinBtn" style="display:none">Re-join</button>
|
||||
</div>
|
||||
|
||||
<details><summary style="cursor:pointer; opacity:0.7; font-size:12px">debug log</summary>
|
||||
<div id="log"></div>
|
||||
</details>
|
||||
</div>
|
||||
|
||||
<!-- webrtc-adapter is required by janus.js (cross-browser WebRTC API
|
||||
normalizer). The upstream siptest pulls it from CDN; we do the same.
|
||||
Without it Janus.init() silently fails. -->
|
||||
<script src="https://cdn.jsdelivr.net/npm/webrtc-adapter@9.0.1/out/adapter.min.js"></script>
|
||||
<script src="janus.js"></script>
|
||||
<script>
|
||||
// ============================================================================
|
||||
// Configuration (hard-coded; this is a dev demo)
|
||||
// ============================================================================
|
||||
const JANUS_SERVER = location.origin + "/janus"; // nginx proxies to janus
|
||||
const SIP_PROXY = "sip:sip:5060"; // docker service name
|
||||
const SIP_IDENTITY = "sip:demo@example.invalid"; // no registrar, guest
|
||||
const SIP_PEER = "sip:phone@sip:5060"; // user-part irrelevant
|
||||
// (Direct+PIN rule)
|
||||
const DISPLAY_NAME = "test-user";
|
||||
const ICE_SERVERS = []; // ICE-TCP via janus.jcfg
|
||||
|
||||
// ============================================================================
|
||||
// UI helpers
|
||||
// ============================================================================
|
||||
const $ = id => document.getElementById(id);
|
||||
function setStatus(text) { $("status").textContent = text; log(text); }
|
||||
function log(msg, isErr = false) {
|
||||
const line = document.createElement("div");
|
||||
line.className = "log-line" + (isErr ? " log-err" : "");
|
||||
line.textContent = "[" + new Date().toLocaleTimeString() + "] " + msg;
|
||||
$("log").appendChild(line);
|
||||
$("log").scrollTop = $("log").scrollHeight;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Janus state
|
||||
// ============================================================================
|
||||
let janus = null;
|
||||
let sipcall = null;
|
||||
let inCall = false;
|
||||
|
||||
// ============================================================================
|
||||
// Init: create Janus → session → SIP handle → register → call
|
||||
// ============================================================================
|
||||
Janus.init({
|
||||
debug: false,
|
||||
callback: () => {
|
||||
if (!Janus.isWebrtcSupported()) {
|
||||
setStatus("WebRTC not supported in this browser.");
|
||||
return;
|
||||
}
|
||||
createSession();
|
||||
}
|
||||
});
|
||||
|
||||
function createSession() {
|
||||
setStatus("Connecting to Janus…");
|
||||
janus = new Janus({
|
||||
server: JANUS_SERVER,
|
||||
iceServers: ICE_SERVERS,
|
||||
success: () => {
|
||||
setStatus("Connected. Attaching SIP plugin…");
|
||||
attachSip();
|
||||
},
|
||||
error: (err) => setStatus("Janus error: " + err),
|
||||
destroyed: () => setStatus("Janus session destroyed."),
|
||||
});
|
||||
}
|
||||
|
||||
function attachSip() {
|
||||
janus.attach({
|
||||
plugin: "janus.plugin.sip",
|
||||
opaqueId: "sip-demo-" + Janus.randomString(8),
|
||||
success: (handle) => {
|
||||
sipcall = handle;
|
||||
window.sipcall = handle; // for ad-hoc console debugging
|
||||
registerAsGuest();
|
||||
},
|
||||
error: (err) => setStatus("Plugin attach error: " + err),
|
||||
onmessage: handleMessage,
|
||||
onlocaltrack: (track, on) => {
|
||||
// We don't render local preview — Meet tab is already showing it.
|
||||
},
|
||||
onremotetrack: (track, mid, on, meta) => {
|
||||
if (!on) return;
|
||||
const stream = new MediaStream([track]);
|
||||
if (track.kind === "audio") {
|
||||
$("remoteAudio").srcObject = stream;
|
||||
} else if (track.kind === "video") {
|
||||
$("remoteVideo").srcObject = stream;
|
||||
$("noVideo").style.display = "none";
|
||||
}
|
||||
},
|
||||
oncleanup: () => { setStatus("Call cleaned up."); endCallUi(); },
|
||||
});
|
||||
}
|
||||
|
||||
function registerAsGuest() {
|
||||
setStatus("Registering as guest…");
|
||||
sipcall.send({
|
||||
message: {
|
||||
request: "register",
|
||||
type: "guest",
|
||||
proxy: SIP_PROXY,
|
||||
username: SIP_IDENTITY,
|
||||
display_name: DISPLAY_NAME,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function placeCall() {
|
||||
setStatus("Calling " + SIP_PEER + "…");
|
||||
const tracks = [
|
||||
{ type: "audio", capture: true, recv: true },
|
||||
{ type: "video", capture: true, recv: true },
|
||||
];
|
||||
sipcall.createOffer({
|
||||
tracks: tracks,
|
||||
success: (jsep) => {
|
||||
sipcall.send({
|
||||
message: { request: "call", uri: SIP_PEER, autoaccept_reinvites: false },
|
||||
jsep: jsep,
|
||||
});
|
||||
},
|
||||
error: (err) => setStatus("createOffer failed: " + err),
|
||||
});
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Plugin event router
|
||||
// ============================================================================
|
||||
function handleMessage(msg, jsep) {
|
||||
const result = msg && msg.result;
|
||||
const event = result && result.event;
|
||||
if (event) log("event: " + event);
|
||||
|
||||
if (event === "registered") {
|
||||
setStatus("Registered. Placing call…");
|
||||
// Small delay to settle the registration
|
||||
setTimeout(placeCall, 100);
|
||||
}
|
||||
else if (event === "calling") {
|
||||
setStatus("Calling…");
|
||||
}
|
||||
else if (event === "accepted") {
|
||||
setStatus("Call accepted. You should hear the IVR — enter PIN on the dialpad.");
|
||||
startCallUi();
|
||||
if (jsep) sipcall.handleRemoteJsep({ jsep: jsep });
|
||||
}
|
||||
else if (event === "hangup") {
|
||||
setStatus("Call ended: " + (result.reason || result.code || ""));
|
||||
endCallUi();
|
||||
}
|
||||
else if (event === "missed_call") {
|
||||
setStatus("Missed call.");
|
||||
endCallUi();
|
||||
}
|
||||
else if (event === "registration_failed") {
|
||||
setStatus("Registration failed: " + result.code + " " + result.reason, true);
|
||||
}
|
||||
else if (msg && msg.error) {
|
||||
setStatus("Plugin error: " + msg.error, true);
|
||||
}
|
||||
|
||||
if (jsep && event !== "accepted") {
|
||||
sipcall.handleRemoteJsep({ jsep: jsep });
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// In-call UI: mute, hangup, dialpad, PIN entry
|
||||
// ============================================================================
|
||||
function startCallUi() {
|
||||
inCall = true;
|
||||
$("pin-row").style.display = "flex";
|
||||
$("pin-row").classList.remove("disabled");
|
||||
$("dialpad").style.display = "grid";
|
||||
$("dialpad").classList.remove("disabled");
|
||||
$("hangupBtn").disabled = false;
|
||||
$("rejoinBtn").style.display = "none";
|
||||
// Auto-mute mic so two tabs on the same Mac don't feedback-howl.
|
||||
if (sipcall.isAudioMuted && !sipcall.isAudioMuted()) {
|
||||
sipcall.muteAudio();
|
||||
}
|
||||
updateMuteButton();
|
||||
updateCameraButton();
|
||||
updateSpeakerButton();
|
||||
}
|
||||
|
||||
function endCallUi() {
|
||||
inCall = false;
|
||||
$("pin-row").style.display = "none";
|
||||
$("pin-row").classList.remove("disabled");
|
||||
$("dialpad").style.display = "none";
|
||||
$("dialpad").classList.remove("disabled");
|
||||
$("hangupBtn").disabled = true;
|
||||
$("rejoinBtn").style.display = "inline-block";
|
||||
$("remoteVideo").srcObject = null;
|
||||
$("remoteAudio").srcObject = null;
|
||||
$("noVideo").style.display = "flex";
|
||||
}
|
||||
|
||||
function updateMuteButton() {
|
||||
const b = $("muteBtn");
|
||||
if (!sipcall || !sipcall.isAudioMuted) return;
|
||||
if (sipcall.isAudioMuted()) {
|
||||
b.textContent = "🔇 Mic muted";
|
||||
b.className = "danger";
|
||||
} else {
|
||||
b.textContent = "🎤 Mic on";
|
||||
b.className = "success";
|
||||
}
|
||||
}
|
||||
|
||||
function updateCameraButton() {
|
||||
const b = $("cameraBtn");
|
||||
if (!sipcall || !sipcall.isVideoMuted) return;
|
||||
if (sipcall.isVideoMuted()) {
|
||||
b.textContent = "📷 Camera off";
|
||||
b.className = "danger";
|
||||
} else {
|
||||
b.textContent = "📷 Camera on";
|
||||
b.className = "success";
|
||||
}
|
||||
}
|
||||
|
||||
function updateSpeakerButton() {
|
||||
const b = $("speakerBtn");
|
||||
const audio = $("remoteAudio");
|
||||
if (audio.muted) {
|
||||
b.textContent = "🔇 Speaker off";
|
||||
b.className = "danger";
|
||||
} else {
|
||||
b.textContent = "🔊 Speaker on";
|
||||
b.className = "success";
|
||||
}
|
||||
}
|
||||
|
||||
$("muteBtn").addEventListener("click", () => {
|
||||
if (!sipcall) return;
|
||||
if (sipcall.isAudioMuted()) sipcall.unmuteAudio();
|
||||
else sipcall.muteAudio();
|
||||
setTimeout(updateMuteButton, 50);
|
||||
});
|
||||
|
||||
$("cameraBtn").addEventListener("click", () => {
|
||||
if (!sipcall || !sipcall.muteVideo) return;
|
||||
if (sipcall.isVideoMuted()) sipcall.unmuteVideo();
|
||||
else sipcall.muteVideo();
|
||||
setTimeout(updateCameraButton, 50);
|
||||
});
|
||||
|
||||
$("speakerBtn").addEventListener("click", () => {
|
||||
const audio = $("remoteAudio");
|
||||
audio.muted = !audio.muted;
|
||||
updateSpeakerButton();
|
||||
});
|
||||
|
||||
$("hangupBtn").addEventListener("click", () => {
|
||||
if (!sipcall) return;
|
||||
sipcall.send({ message: { request: "hangup" } });
|
||||
sipcall.hangup();
|
||||
});
|
||||
|
||||
$("rejoinBtn").addEventListener("click", () => location.reload());
|
||||
|
||||
// ============================================================================
|
||||
// DTMF — try the in-band RTP-event path first (RFC 4733 via the handle's
|
||||
// dtmf() method), which is what livekit-sip's IVR actually listens for.
|
||||
// Fall back to SIP INFO if RTP DTMF isn't available (older Janus, no
|
||||
// telephone-event in SDP, etc.).
|
||||
// ============================================================================
|
||||
function sendDtmf(digit) {
|
||||
if (!sipcall || !inCall) return;
|
||||
log("DTMF → " + digit);
|
||||
let sent = false;
|
||||
try {
|
||||
if (typeof sipcall.dtmf === "function") {
|
||||
sipcall.dtmf({
|
||||
dtmf: { tones: String(digit), duration: 200, gap: 70 },
|
||||
success: () => {},
|
||||
error: (err) => log("dtmf() error: " + err, true),
|
||||
});
|
||||
sent = true;
|
||||
}
|
||||
} catch (e) {
|
||||
log("dtmf() threw: " + e, true);
|
||||
}
|
||||
if (!sent) {
|
||||
// Fallback: SIP INFO method
|
||||
sipcall.send({
|
||||
message: { request: "dtmf_info", digit: String(digit) },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
document.querySelectorAll(".dialpad button").forEach(b => {
|
||||
b.addEventListener("click", () => sendDtmf(b.dataset.d));
|
||||
});
|
||||
|
||||
$("sendPin").addEventListener("click", () => {
|
||||
const pin = $("pin").value.replace(/[^\d#*]/g, "");
|
||||
if (!pin) return;
|
||||
const tones = (pin.endsWith("#") ? pin : pin + "#");
|
||||
log("DTMF batch → " + tones);
|
||||
// Send all tones in ONE call. Per-digit looping with setTimeout races
|
||||
// the WebRTC DTMF sender's internal queue and drops digits (we lost
|
||||
// the trailing "0" before "#" doing it digit-by-digit). The browser's
|
||||
// RTCDTMFSender paces them itself given duration/gap.
|
||||
try {
|
||||
sipcall.dtmf({
|
||||
dtmf: { tones: tones, duration: 400, gap: 100 },
|
||||
success: () => log("DTMF batch sent"),
|
||||
error: (err) => log("dtmf() error: " + err, true),
|
||||
});
|
||||
} catch (e) {
|
||||
log("dtmf() threw, falling back to SIP INFO per digit: " + e, true);
|
||||
for (const ch of tones) {
|
||||
sipcall.send({ message: { request: "dtmf_info", digit: String(ch) } });
|
||||
}
|
||||
}
|
||||
$("pin").value = "";
|
||||
// PIN + dialpad are no longer needed once you've submitted. Gateway
|
||||
// either bridges you in (correct PIN) or hangs up with wrong-pin
|
||||
// (the hangup event re-resets everything via endCallUi).
|
||||
$("pin-row").classList.add("disabled");
|
||||
$("dialpad").classList.add("disabled");
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
File diff suppressed because it is too large
Load Diff
@@ -32,27 +32,15 @@ class UserSerializer(serializers.ModelSerializer):
|
||||
model = models.User
|
||||
fields = [
|
||||
"id",
|
||||
"sub",
|
||||
"email",
|
||||
"full_name",
|
||||
"short_name",
|
||||
"timezone",
|
||||
"language",
|
||||
"default_encryption_mode",
|
||||
"default_encryption",
|
||||
]
|
||||
read_only_fields = ["id", "email", "full_name", "short_name"]
|
||||
|
||||
def validate_default_encryption_mode(self, value):
|
||||
"""Reject a non-none default when the server has encryption disabled.
|
||||
|
||||
Keeps the user preference DB in sync with the deployment's posture:
|
||||
if an operator flips ENCRYPTION_ENABLED off, no client should be able
|
||||
to keep persisting `basic` as their default behind their back.
|
||||
"""
|
||||
if value != models.EncryptionMode.NONE and not settings.ENCRYPTION_ENABLED:
|
||||
raise serializers.ValidationError(
|
||||
_("End-to-end encryption is disabled on this server.")
|
||||
)
|
||||
return value
|
||||
read_only_fields = ["id", "sub", "email", "full_name", "short_name"]
|
||||
|
||||
|
||||
class UserLightSerializer(serializers.ModelSerializer):
|
||||
@@ -157,52 +145,37 @@ class RoomSerializer(serializers.ModelSerializer):
|
||||
"configuration",
|
||||
"access_level",
|
||||
"pin_code",
|
||||
"encryption_mode",
|
||||
"is_encrypted",
|
||||
"encryption_paused",
|
||||
]
|
||||
read_only_fields = ["id", "slug", "pin_code"]
|
||||
|
||||
def validate_encryption_mode(self, value):
|
||||
"""Encryption mode is part of the link's semantics (the passphrase
|
||||
lives in the URL hash for `basic` rooms) so it cannot be changed once
|
||||
the room exists."""
|
||||
def validate_is_encrypted(self, value):
|
||||
"""is_encrypted is set at creation and is part of the link's identity
|
||||
(copy-link always carries the hash for an encrypted room). Mid-call
|
||||
toggling is done via encryption_paused, not by mutating this flag."""
|
||||
instance = self.instance
|
||||
if instance and instance.encryption_mode != value:
|
||||
if instance and instance.is_encrypted != value:
|
||||
raise serializers.ValidationError(
|
||||
"Encryption mode cannot be changed after room creation."
|
||||
"Encryption mode cannot be changed after room creation. "
|
||||
"Use encryption_paused to temporarily suspend encryption."
|
||||
)
|
||||
return value
|
||||
|
||||
def validate_access_level(self, value):
|
||||
"""Encrypted rooms must stay restricted — the lobby is the only way
|
||||
to enforce per-participant admission, and basic encryption relies on
|
||||
the host vetting each joiner before they receive the in-URL key."""
|
||||
instance = self.instance
|
||||
if (
|
||||
instance
|
||||
and instance.encryption_mode != models.EncryptionMode.NONE
|
||||
and value != models.RoomAccessLevel.RESTRICTED
|
||||
):
|
||||
raise serializers.ValidationError(
|
||||
"Encrypted rooms require restricted access level."
|
||||
)
|
||||
return value
|
||||
|
||||
def validate(self, attrs):
|
||||
"""Force encrypted rooms to RESTRICTED at creation time.
|
||||
|
||||
Doing this here (rather than in validate_access_level) lets the
|
||||
client omit `access_level` entirely when creating an encrypted room
|
||||
— we silently override whatever the default would have been.
|
||||
"""
|
||||
encryption_mode = attrs.get(
|
||||
"encryption_mode",
|
||||
self.instance.encryption_mode
|
||||
if self.instance
|
||||
else models.EncryptionMode.NONE,
|
||||
def validate_encryption_paused(self, value):
|
||||
"""encryption_paused only makes sense on encrypted rooms. Allow either
|
||||
direction (True ↔ False) post-creation: admins suspend E2EE so a
|
||||
SIP/device caller can join, then resume once they hang up."""
|
||||
is_encrypted = (
|
||||
self.instance.is_encrypted
|
||||
if self.instance is not None
|
||||
else self.initial_data.get("is_encrypted", False)
|
||||
)
|
||||
if encryption_mode != models.EncryptionMode.NONE and not self.instance:
|
||||
attrs["access_level"] = models.RoomAccessLevel.RESTRICTED
|
||||
return super().validate(attrs)
|
||||
if value and not is_encrypted:
|
||||
raise serializers.ValidationError(
|
||||
"Cannot pause encryption on a non-encrypted room."
|
||||
)
|
||||
return value
|
||||
|
||||
def to_representation(self, instance):
|
||||
"""
|
||||
@@ -246,20 +219,12 @@ class RoomSerializer(serializers.ModelSerializer):
|
||||
room_id = f"{instance.id!s}"
|
||||
username = request.query_params.get("username", None)
|
||||
|
||||
# In encrypted rooms, authenticated users cannot pick an
|
||||
# arbitrary display name — it must come from the OIDC profile.
|
||||
# We enforce this server-side so a tampered client cannot
|
||||
# override what other participants see.
|
||||
if instance.is_encrypted and request.user.is_authenticated:
|
||||
username = request.user.full_name or request.user.email
|
||||
|
||||
output["livekit"] = utils.generate_livekit_config(
|
||||
room_id=room_id,
|
||||
user=request.user,
|
||||
username=username,
|
||||
configuration=configuration,
|
||||
is_admin_or_owner=is_admin_or_owner,
|
||||
encryption_mode=instance.encryption_mode,
|
||||
)
|
||||
else:
|
||||
del output["pin_code"]
|
||||
|
||||
@@ -281,16 +281,12 @@ class RoomViewSet(
|
||||
|
||||
def perform_create(self, serializer):
|
||||
"""Set the current user as owner of the newly created room."""
|
||||
encryption_mode = serializer.validated_data.get(
|
||||
"encryption_mode", models.EncryptionMode.NONE
|
||||
)
|
||||
is_encrypted = serializer.validated_data.get("is_encrypted", False)
|
||||
|
||||
if (
|
||||
encryption_mode != models.EncryptionMode.NONE
|
||||
and not settings.ENCRYPTION_ENABLED
|
||||
):
|
||||
# Block encrypted room creation if encryption is not enabled on this instance
|
||||
if is_encrypted and not settings.ENCRYPTION_ENABLED:
|
||||
raise drf_exceptions.ValidationError(
|
||||
{"encryption_mode": "Encryption is not enabled on this server."}
|
||||
{"is_encrypted": "Encryption is not enabled on this server."}
|
||||
)
|
||||
|
||||
room = serializer.save()
|
||||
@@ -316,9 +312,17 @@ class RoomViewSet(
|
||||
"""Start recording a room."""
|
||||
|
||||
serializer = serializers.StartRecordingSerializer(data=request.data)
|
||||
|
||||
if not serializer.is_valid():
|
||||
print(
|
||||
"[start-recording] body=",
|
||||
request.data,
|
||||
"errors=",
|
||||
serializer.errors,
|
||||
flush=True,
|
||||
)
|
||||
return drf_response.Response(
|
||||
{"detail": "Invalid request."},
|
||||
{"detail": "Invalid request.", "errors": serializer.errors},
|
||||
status=drf_status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
@@ -326,11 +330,6 @@ class RoomViewSet(
|
||||
options = serializer.validated_data.get("options")
|
||||
room = self.get_object()
|
||||
|
||||
if room.is_encrypted:
|
||||
raise drf_exceptions.ValidationError(
|
||||
{"detail": "Recording is unavailable in encrypted rooms."}
|
||||
)
|
||||
|
||||
# May raise exception if an active or initiated recording already exist for the room
|
||||
recording = models.Recording.objects.create(
|
||||
room=room,
|
||||
@@ -586,11 +585,6 @@ class RoomViewSet(
|
||||
|
||||
room = self.get_object()
|
||||
|
||||
if room.is_encrypted:
|
||||
raise drf_exceptions.ValidationError(
|
||||
{"detail": "Subtitles are unavailable in encrypted rooms."}
|
||||
)
|
||||
|
||||
try:
|
||||
SubtitleService().start_subtitle(room)
|
||||
except SubtitleException:
|
||||
|
||||
@@ -1,46 +0,0 @@
|
||||
"""Add Room.encryption_mode and User.default_encryption_mode (enum-based).
|
||||
|
||||
We store the mode as an enum (CharField with choices) rather than a boolean
|
||||
so a future "advanced" mode (per-user vault keys, etc.) can be added without
|
||||
a schema migration.
|
||||
"""
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("core", "0018_rename_active_application_is_active"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="room",
|
||||
name="encryption_mode",
|
||||
field=models.CharField(
|
||||
choices=[
|
||||
("none", "No encryption"),
|
||||
("basic", "Passphrase-in-URL encryption"),
|
||||
],
|
||||
default="none",
|
||||
help_text="End-to-end encryption mode for this room.",
|
||||
max_length=20,
|
||||
verbose_name="Encryption mode",
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="user",
|
||||
name="default_encryption_mode",
|
||||
field=models.CharField(
|
||||
choices=[
|
||||
("none", "No encryption"),
|
||||
("basic", "Passphrase-in-URL encryption"),
|
||||
],
|
||||
default="none",
|
||||
help_text="Encryption mode pre-selected when this user creates a new meeting.",
|
||||
max_length=20,
|
||||
verbose_name="Default encryption mode",
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,36 @@
|
||||
"""Add Room.is_encrypted and User.default_encryption.
|
||||
|
||||
`is_encrypted` is a boolean for now because passphrase-in-URL is the only
|
||||
supported mode. A future migration may turn it into a CharField/enum if a
|
||||
local-keys (vault) mode is reintroduced.
|
||||
"""
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("core", "0018_rename_active_application_is_active"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="room",
|
||||
name="is_encrypted",
|
||||
field=models.BooleanField(
|
||||
default=False,
|
||||
help_text="Whether end-to-end encryption is enabled for this room.",
|
||||
verbose_name="Encryption enabled",
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="user",
|
||||
name="default_encryption",
|
||||
field=models.BooleanField(
|
||||
default=False,
|
||||
help_text="Whether new meetings created by this user are end-to-end encrypted by default.",
|
||||
verbose_name="Default to end-to-end encryption",
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 5.2.12 on 2026-05-12 08:33
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('core', '0019_room_is_encrypted_user_default_encryption'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='room',
|
||||
name='encryption_paused',
|
||||
field=models.BooleanField(default=False, help_text='Temporarily suspend E2EE so external devices can join.', verbose_name='Encryption paused'),
|
||||
),
|
||||
]
|
||||
+53
-80
@@ -98,17 +98,6 @@ class RoomAccessLevel(models.TextChoices):
|
||||
RESTRICTED = "restricted", _("Restricted Access")
|
||||
|
||||
|
||||
class EncryptionMode(models.TextChoices):
|
||||
"""Encryption mode for a room.
|
||||
|
||||
Kept as an enum (not a boolean) so future modes — e.g. a vault-managed
|
||||
per-user key flow — can be added without another schema migration.
|
||||
"""
|
||||
|
||||
NONE = "none", _("No encryption")
|
||||
BASIC = "basic", _("Passphrase-in-URL encryption")
|
||||
|
||||
|
||||
class BaseModel(models.Model):
|
||||
"""
|
||||
Serves as an abstract base model for other models, ensuring that records are validated
|
||||
@@ -211,13 +200,12 @@ class User(AbstractBaseUser, BaseModel, auth_models.PermissionsMixin):
|
||||
"Unselect this instead of deleting accounts."
|
||||
),
|
||||
)
|
||||
default_encryption_mode = models.CharField(
|
||||
_("Default encryption mode"),
|
||||
max_length=20,
|
||||
choices=EncryptionMode.choices,
|
||||
default=EncryptionMode.NONE,
|
||||
default_encryption = models.BooleanField(
|
||||
_("Default to end-to-end encryption"),
|
||||
default=False,
|
||||
help_text=_(
|
||||
"Encryption mode pre-selected when this user creates a new meeting."
|
||||
"Whether new meetings created by this user are "
|
||||
"end-to-end encrypted by default."
|
||||
),
|
||||
)
|
||||
|
||||
@@ -408,14 +396,24 @@ class Room(Resource):
|
||||
choices=RoomAccessLevel.choices,
|
||||
default=settings.RESOURCE_DEFAULT_ACCESS_LEVEL,
|
||||
)
|
||||
# Set at creation, immutable after (the URL hash carries the passphrase,
|
||||
# so changing the mode would break every previously-shared link).
|
||||
encryption_mode = models.CharField(
|
||||
max_length=20,
|
||||
choices=EncryptionMode.choices,
|
||||
default=EncryptionMode.NONE,
|
||||
verbose_name=_("Encryption mode"),
|
||||
help_text=_("End-to-end encryption mode for this room."),
|
||||
# Boolean for now: today the only encryption mode is the passphrase-in-URL
|
||||
# one. If a follow-up adds a stronger "local-keys" mode (private keys held
|
||||
# in a vault iframe), this can grow into a CharField with choices like
|
||||
# `none / passphrase / local_keys`. Set at creation; never mutated after —
|
||||
# changing it would change the link's semantics (copy-link / hash carry).
|
||||
is_encrypted = models.BooleanField(
|
||||
default=False,
|
||||
verbose_name=_("Encryption enabled"),
|
||||
help_text=_("Whether end-to-end encryption is enabled for this room."),
|
||||
)
|
||||
# Mid-call admin override: when True on an `is_encrypted` room, the active
|
||||
# encryption is suspended so a phone/SIP/device caller can join in plaintext.
|
||||
# The link still carries the hash; toggling back to False resumes E2EE.
|
||||
# Always False on non-encrypted rooms (enforced by the serializer).
|
||||
encryption_paused = models.BooleanField(
|
||||
default=False,
|
||||
verbose_name=_("Encryption paused"),
|
||||
help_text=_("Temporarily suspend E2EE so external devices can join."),
|
||||
)
|
||||
configuration = models.JSONField(
|
||||
blank=True,
|
||||
@@ -442,63 +440,43 @@ class Room(Resource):
|
||||
return capfirst(self.name)
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
"""Generate a unique n-digit pin code for new rooms.
|
||||
|
||||
Skip PIN allocation for encrypted rooms — the SIP gateway will
|
||||
always reject calls to them (no way to derive the key), and the
|
||||
PIN namespace is finite (10**length): no point burning slots that
|
||||
can never be dialed.
|
||||
|
||||
Also run `clean()` so the encryption invariants are enforced on
|
||||
every save path (ORM, admin, shell), not only via the DRF
|
||||
serializer.
|
||||
"""
|
||||
self.clean()
|
||||
if (
|
||||
settings.ROOM_TELEPHONY_ENABLED
|
||||
and not self.pk
|
||||
and not self.pin_code
|
||||
and self.encryption_mode == EncryptionMode.NONE
|
||||
):
|
||||
if settings.ROOM_TELEPHONY_ENABLED and not self.pk and not self.pin_code:
|
||||
self.pin_code = self.generate_unique_pin_code(
|
||||
length=settings.ROOM_TELEPHONY_PIN_LENGTH
|
||||
)
|
||||
|
||||
previous = None
|
||||
if self.pk:
|
||||
try:
|
||||
previous = Room.objects.only(
|
||||
"is_encrypted", "encryption_paused"
|
||||
).get(pk=self.pk)
|
||||
except Room.DoesNotExist:
|
||||
previous = None
|
||||
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
def clean(self):
|
||||
"""Enforce encryption-mode invariants outside DRF.
|
||||
# Sync encryption state to LiveKit room metadata so the SIP gateway can
|
||||
# decide between bridge mode and placeholder mode. Best-effort: if no
|
||||
# LiveKit room exists yet, the call is a no-op.
|
||||
encryption_changed = previous is None or (
|
||||
previous.is_encrypted != self.is_encrypted
|
||||
or previous.encryption_paused != self.encryption_paused
|
||||
)
|
||||
if encryption_changed:
|
||||
try:
|
||||
from core import utils as core_utils # local import: avoid cycle
|
||||
|
||||
Two rules:
|
||||
- `encryption_mode` is set at creation and never mutated afterwards
|
||||
(the URL-hash passphrase encodes assumptions about it).
|
||||
- An encrypted room must be at the RESTRICTED access level so the
|
||||
host vets joiners before they ever see the in-URL key.
|
||||
"""
|
||||
super().clean()
|
||||
if self.pk is not None:
|
||||
previous = Room.objects.filter(pk=self.pk).only("encryption_mode").first()
|
||||
if (
|
||||
previous is not None
|
||||
and previous.encryption_mode != self.encryption_mode
|
||||
):
|
||||
raise ValidationError(
|
||||
{
|
||||
"encryption_mode": _(
|
||||
"Encryption mode cannot be changed after room creation."
|
||||
)
|
||||
}
|
||||
core_utils.update_room_metadata(
|
||||
room_name=str(self.pk),
|
||||
metadata={
|
||||
"is_encrypted": bool(self.is_encrypted),
|
||||
"encryption_paused": bool(self.encryption_paused),
|
||||
},
|
||||
)
|
||||
if (
|
||||
self.encryption_mode != EncryptionMode.NONE
|
||||
and self.access_level != RoomAccessLevel.RESTRICTED
|
||||
):
|
||||
raise ValidationError(
|
||||
{
|
||||
"access_level": _(
|
||||
"Encrypted rooms must use the 'restricted' access level."
|
||||
)
|
||||
}
|
||||
)
|
||||
except Exception: # pylint: disable=broad-except
|
||||
# Metadata sync is advisory — never break Room.save() over it.
|
||||
pass
|
||||
|
||||
def clean_fields(self, exclude=None):
|
||||
"""
|
||||
@@ -522,11 +500,6 @@ class Room(Resource):
|
||||
"""Check if a room is public"""
|
||||
return self.access_level == RoomAccessLevel.PUBLIC
|
||||
|
||||
@property
|
||||
def is_encrypted(self):
|
||||
"""Convenience: any non-none encryption mode counts as encrypted."""
|
||||
return self.encryption_mode != EncryptionMode.NONE
|
||||
|
||||
@staticmethod
|
||||
def generate_unique_pin_code(length):
|
||||
"""Generate a unique n-digit PIN code"""
|
||||
|
||||
@@ -18,6 +18,10 @@ from core.recording.services.recording_events import (
|
||||
)
|
||||
|
||||
from .lobby import LobbyService
|
||||
from .participants_management import (
|
||||
ParticipantsManagement,
|
||||
ParticipantsManagementException,
|
||||
)
|
||||
from .telephony import TelephonyException, TelephonyService
|
||||
|
||||
logger = getLogger(__name__)
|
||||
@@ -185,6 +189,72 @@ class LiveKitEventsService:
|
||||
f"Failed to process limit reached event for recording {recording}"
|
||||
) from e
|
||||
|
||||
def _handle_participant_joined(self, data):
|
||||
"""Handle 'participant_joined' event.
|
||||
|
||||
When a SIP/phone participant joins an end-to-end encrypted room they
|
||||
cannot decrypt anything. We:
|
||||
1. Send an in-band notification so admins see a snackbar with an
|
||||
"Open settings" CTA.
|
||||
2. Leave the participant connected — the gateway is responsible for
|
||||
holding them on a placeholder prompt loop ("this meeting is
|
||||
encrypted, ask the host to disable it") until either the admin
|
||||
turns encryption off (we update LiveKit room metadata, gateway
|
||||
reacts) or the user hangs up.
|
||||
|
||||
See README "Phone / SIP participants" for the full flow.
|
||||
"""
|
||||
|
||||
participant = getattr(data, "participant", None)
|
||||
if participant is None:
|
||||
return
|
||||
|
||||
# LiveKit ParticipantInfo.Kind: 0 = STANDARD, 1 = INGRESS, 2 = EGRESS,
|
||||
# 3 = SIP, 4 = AGENT. We treat both SIP and INGRESS as "external
|
||||
# device that can't run our E2EE code".
|
||||
kind = getattr(participant, "kind", 0)
|
||||
is_external_device = kind in (1, 3)
|
||||
if not is_external_device:
|
||||
return
|
||||
|
||||
try:
|
||||
room_id = uuid.UUID(data.room.name)
|
||||
except ValueError:
|
||||
return
|
||||
|
||||
try:
|
||||
room = models.Room.objects.get(id=room_id)
|
||||
except models.Room.DoesNotExist:
|
||||
return
|
||||
|
||||
# Live encryption: is_encrypted set AND not currently paused.
|
||||
# If the admin has already paused encryption, the gateway will bridge
|
||||
# the call normally — no need to surface a blocked notification.
|
||||
if not room.is_encrypted or room.encryption_paused:
|
||||
return
|
||||
|
||||
# 1. Broadcast a system notice — frontends decode this on the
|
||||
# "encryption-state" or notifications channel and show a snackbar.
|
||||
try:
|
||||
utils.notify_participants(
|
||||
room_name=str(room_id),
|
||||
notification_data={
|
||||
"type": "external_device_blocked",
|
||||
"participant_identity": participant.identity,
|
||||
"participant_name": participant.name or participant.identity,
|
||||
},
|
||||
)
|
||||
except utils.NotificationError:
|
||||
logger.exception(
|
||||
"Failed to notify room about blocked external device"
|
||||
)
|
||||
|
||||
# No eject. The gateway reads LiveKit room metadata
|
||||
# (`{is_encrypted, encryption_paused}` — see `Room.save()`) and stays
|
||||
# in placeholder-prompt mode for the SIP leg as long as encryption is
|
||||
# live. When the admin sets encryption_paused=true, the gateway
|
||||
# transitions to a normal bridge without dropping the call.
|
||||
|
||||
def _handle_room_started(self, data):
|
||||
"""Handle 'room_started' event."""
|
||||
|
||||
@@ -202,16 +272,23 @@ class LiveKitEventsService:
|
||||
except models.Room.DoesNotExist as err:
|
||||
raise ActionFailedError(f"Room with ID {room_id} does not exist") from err
|
||||
|
||||
# Note: `encryption_mode` is stamped into the LK room's metadata at
|
||||
# creation time via the access token's RoomConfiguration (see
|
||||
# `utils.generate_token`), so we don't need to patch it here.
|
||||
# Now that the LiveKit room object exists, push the encryption flags
|
||||
# into its metadata so the SIP gateway can read them as soon as a SIP
|
||||
# caller joins. Room.save() also tries this on every change, but at
|
||||
# *creation* time the LiveKit room didn't exist yet — this is the
|
||||
# first reliable opportunity.
|
||||
try:
|
||||
utils.update_room_metadata(
|
||||
str(room_id),
|
||||
{
|
||||
"is_encrypted": bool(room.is_encrypted),
|
||||
"encryption_paused": bool(room.encryption_paused),
|
||||
},
|
||||
)
|
||||
except utils.MetadataUpdateException as e:
|
||||
logger.exception("Failed to seed encryption metadata: %s", e)
|
||||
|
||||
# Phone dial-in is incompatible with end-to-end encryption — a SIP
|
||||
# caller has no way to derive the room key, and the SIP gateway will
|
||||
# play "encryption_not_supported" and hang up on them anyway. Skip
|
||||
# the dispatch rule for encrypted rooms so no metadata mentions a
|
||||
# PIN that won't be reachable.
|
||||
if settings.ROOM_TELEPHONY_ENABLED and not room.is_encrypted:
|
||||
if settings.ROOM_TELEPHONY_ENABLED:
|
||||
try:
|
||||
self.telephony_service.create_dispatch_rule(room)
|
||||
except TelephonyException as e:
|
||||
|
||||
@@ -145,19 +145,6 @@ class LobbyService:
|
||||
5. If denied, do nothing.
|
||||
"""
|
||||
|
||||
# In encrypted rooms, authenticated users cannot pick an arbitrary
|
||||
# display name — server enforces the OIDC name so a tampered client
|
||||
# can't impersonate someone else with their account. If both
|
||||
# full_name and email are absent (degenerate OIDC payload), fall
|
||||
# back to a server-controlled technical name rather than trusting
|
||||
# whatever the client posted.
|
||||
if room.is_encrypted and request.user.is_authenticated:
|
||||
username = (
|
||||
request.user.full_name
|
||||
or request.user.email
|
||||
or f"noname-{request.user.id}"
|
||||
)
|
||||
|
||||
participant_id = self._get_or_create_participant_id(request)
|
||||
participant = self._get_participant(room.id, participant_id)
|
||||
|
||||
@@ -183,7 +170,6 @@ class LobbyService:
|
||||
configuration=room.configuration,
|
||||
is_admin_or_owner=False,
|
||||
participant_id=participant_id,
|
||||
encryption_mode=room.encryption_mode,
|
||||
)
|
||||
return participant, livekit_config
|
||||
|
||||
@@ -209,7 +195,6 @@ class LobbyService:
|
||||
configuration=room.configuration,
|
||||
is_admin_or_owner=False,
|
||||
participant_id=participant_id,
|
||||
encryption_mode=room.encryption_mode,
|
||||
)
|
||||
|
||||
return participant, livekit_config
|
||||
|
||||
@@ -59,7 +59,6 @@ def test_request_entry_anonymous(settings):
|
||||
"username": "test_user",
|
||||
"status": "waiting",
|
||||
"color": "mocked-color",
|
||||
"is_authenticated": False,
|
||||
"livekit": None,
|
||||
}
|
||||
|
||||
@@ -109,7 +108,6 @@ def test_request_entry_authenticated_user(settings):
|
||||
"username": "test_user",
|
||||
"status": "waiting",
|
||||
"color": "mocked-color",
|
||||
"is_authenticated": True,
|
||||
"livekit": None,
|
||||
}
|
||||
|
||||
@@ -182,7 +180,6 @@ def test_request_entry_with_existing_participants(settings):
|
||||
"username": "test_user",
|
||||
"status": "waiting",
|
||||
"color": "mocked-color",
|
||||
"is_authenticated": False,
|
||||
"livekit": None,
|
||||
}
|
||||
|
||||
@@ -235,7 +232,6 @@ def test_request_entry_public_room(settings):
|
||||
"username": "test_user",
|
||||
"status": "accepted",
|
||||
"color": "mocked-color",
|
||||
"is_authenticated": False,
|
||||
"livekit": {"token": "test-token"},
|
||||
}
|
||||
|
||||
@@ -288,7 +284,6 @@ def test_request_entry_authenticated_user_public_room(settings):
|
||||
"username": "test_user",
|
||||
"status": "accepted",
|
||||
"color": "mocked-color",
|
||||
"is_authenticated": True,
|
||||
"livekit": {"token": "test-token"},
|
||||
}
|
||||
|
||||
@@ -343,7 +338,6 @@ def test_request_entry_waiting_participant_public_room(settings):
|
||||
"username": "user1",
|
||||
"status": "accepted",
|
||||
"color": "#123456",
|
||||
"is_authenticated": False,
|
||||
"livekit": {"token": "test-token"},
|
||||
}
|
||||
|
||||
@@ -607,14 +601,12 @@ def test_list_waiting_participants_success(settings):
|
||||
"username": "user1",
|
||||
"status": "waiting",
|
||||
"color": "#123456",
|
||||
"is_authenticated": False,
|
||||
},
|
||||
{
|
||||
"id": "f4ca3ab8a6c04ad88097b8da33f60f10",
|
||||
"username": "user2",
|
||||
"status": "waiting",
|
||||
"color": "#654321",
|
||||
"is_authenticated": False,
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
@@ -33,7 +33,6 @@ def test_api_rooms_retrieve_anonymous_private_pk():
|
||||
"is_administrable": False,
|
||||
"name": room.name,
|
||||
"slug": room.slug,
|
||||
"encryption_mode": room.encryption_mode,
|
||||
}
|
||||
|
||||
|
||||
@@ -53,7 +52,6 @@ def test_api_rooms_retrieve_anonymous_trusted_pk():
|
||||
"is_administrable": False,
|
||||
"name": room.name,
|
||||
"slug": room.slug,
|
||||
"encryption_mode": room.encryption_mode,
|
||||
}
|
||||
|
||||
|
||||
@@ -72,7 +70,6 @@ def test_api_rooms_retrieve_anonymous_private_pk_no_dashes():
|
||||
"is_administrable": False,
|
||||
"name": room.name,
|
||||
"slug": room.slug,
|
||||
"encryption_mode": room.encryption_mode,
|
||||
}
|
||||
|
||||
|
||||
@@ -89,7 +86,6 @@ def test_api_rooms_retrieve_anonymous_private_slug():
|
||||
"is_administrable": False,
|
||||
"name": room.name,
|
||||
"slug": room.slug,
|
||||
"encryption_mode": room.encryption_mode,
|
||||
}
|
||||
|
||||
|
||||
@@ -106,7 +102,6 @@ def test_api_rooms_retrieve_anonymous_private_slug_not_normalized():
|
||||
"is_administrable": False,
|
||||
"name": room.name,
|
||||
"slug": room.slug,
|
||||
"encryption_mode": room.encryption_mode,
|
||||
}
|
||||
|
||||
|
||||
@@ -216,7 +211,6 @@ def test_api_rooms_retrieve_anonymous_public(mock_token):
|
||||
"name": room.name,
|
||||
"pin_code": room.pin_code,
|
||||
"slug": room.slug,
|
||||
"encryption_mode": room.encryption_mode,
|
||||
}
|
||||
|
||||
mock_token.assert_called_once()
|
||||
@@ -263,7 +257,6 @@ def test_api_rooms_retrieve_authenticated_public(mock_token):
|
||||
"name": room.name,
|
||||
"pin_code": room.pin_code,
|
||||
"slug": room.slug,
|
||||
"encryption_mode": room.encryption_mode,
|
||||
}
|
||||
|
||||
mock_token.assert_called_once_with(
|
||||
@@ -274,7 +267,6 @@ def test_api_rooms_retrieve_authenticated_public(mock_token):
|
||||
sources=["mock-source"],
|
||||
is_admin_or_owner=False,
|
||||
participant_id=None,
|
||||
encryption_mode="none",
|
||||
)
|
||||
|
||||
|
||||
@@ -316,7 +308,6 @@ def test_api_rooms_retrieve_authenticated_trusted(mock_token):
|
||||
"name": room.name,
|
||||
"pin_code": room.pin_code,
|
||||
"slug": room.slug,
|
||||
"encryption_mode": room.encryption_mode,
|
||||
}
|
||||
|
||||
mock_token.assert_called_once_with(
|
||||
@@ -327,7 +318,6 @@ def test_api_rooms_retrieve_authenticated_trusted(mock_token):
|
||||
sources=None,
|
||||
is_admin_or_owner=False,
|
||||
participant_id=None,
|
||||
encryption_mode="none",
|
||||
)
|
||||
|
||||
|
||||
@@ -353,7 +343,6 @@ def test_api_rooms_retrieve_authenticated():
|
||||
"is_administrable": False,
|
||||
"name": room.name,
|
||||
"slug": room.slug,
|
||||
"encryption_mode": room.encryption_mode,
|
||||
}
|
||||
|
||||
|
||||
@@ -405,7 +394,6 @@ def test_api_rooms_retrieve_members(mock_token, django_assert_num_queries, setti
|
||||
"name": room.name,
|
||||
"pin_code": room.pin_code,
|
||||
"slug": room.slug,
|
||||
"encryption_mode": room.encryption_mode,
|
||||
}
|
||||
|
||||
mock_token.assert_called_once_with(
|
||||
@@ -416,7 +404,6 @@ def test_api_rooms_retrieve_members(mock_token, django_assert_num_queries, setti
|
||||
sources=["mock-source"],
|
||||
is_admin_or_owner=False,
|
||||
participant_id=None,
|
||||
encryption_mode="none",
|
||||
)
|
||||
|
||||
|
||||
@@ -466,7 +453,6 @@ def test_api_rooms_retrieve_administrators(
|
||||
"short_name": other_user_access.user.short_name,
|
||||
"timezone": "UTC",
|
||||
"language": other_user_access.user.language,
|
||||
"default_encryption_mode": "none",
|
||||
},
|
||||
"resource": str(room.id),
|
||||
"role": other_user_access.role,
|
||||
@@ -480,7 +466,6 @@ def test_api_rooms_retrieve_administrators(
|
||||
"short_name": user_access.user.short_name,
|
||||
"timezone": "UTC",
|
||||
"language": user_access.user.language,
|
||||
"default_encryption_mode": "none",
|
||||
},
|
||||
"resource": str(room.id),
|
||||
"role": user_access.role,
|
||||
@@ -502,7 +487,6 @@ def test_api_rooms_retrieve_administrators(
|
||||
"name": room.name,
|
||||
"pin_code": room.pin_code,
|
||||
"slug": room.slug,
|
||||
"encryption_mode": room.encryption_mode,
|
||||
}
|
||||
|
||||
mock_token.assert_called_once_with(
|
||||
@@ -513,5 +497,4 @@ def test_api_rooms_retrieve_administrators(
|
||||
sources=None,
|
||||
is_admin_or_owner=True,
|
||||
participant_id=None,
|
||||
encryption_mode="none",
|
||||
)
|
||||
|
||||
@@ -268,7 +268,6 @@ def test_request_entry_public_room(
|
||||
configuration=room.configuration,
|
||||
is_admin_or_owner=False,
|
||||
participant_id="test-participant-id",
|
||||
encryption_mode="none",
|
||||
)
|
||||
|
||||
lobby_service._get_participant.assert_called_once_with(room.id, participant_id)
|
||||
@@ -308,7 +307,6 @@ def test_request_entry_trusted_room(
|
||||
configuration=room.configuration,
|
||||
is_admin_or_owner=False,
|
||||
participant_id="test-participant-id",
|
||||
encryption_mode="none",
|
||||
)
|
||||
|
||||
lobby_service._get_participant.assert_called_once_with(room.id, participant_id)
|
||||
@@ -339,12 +337,7 @@ def test_request_entry_new_participant(
|
||||
|
||||
assert participant == participant_data
|
||||
assert livekit_config is None
|
||||
mock_enter.assert_called_once_with(
|
||||
room.id,
|
||||
participant_id,
|
||||
username,
|
||||
is_authenticated=request.user.is_authenticated,
|
||||
)
|
||||
mock_enter.assert_called_once_with(room.id, participant_id, username)
|
||||
lobby_service._get_participant.assert_called_once_with(room.id, participant_id)
|
||||
|
||||
|
||||
@@ -409,7 +402,6 @@ def test_request_entry_accepted_participant(
|
||||
configuration=room.configuration,
|
||||
is_admin_or_owner=False,
|
||||
participant_id="test-participant-id",
|
||||
encryption_mode="none",
|
||||
)
|
||||
lobby_service._get_participant.assert_called_once_with(room.id, participant_id)
|
||||
|
||||
@@ -785,7 +777,6 @@ def test_update_participant_status_success(mock_cache, lobby_service, participan
|
||||
"username": "test-username",
|
||||
"id": participant_id,
|
||||
"color": "#123456",
|
||||
"is_authenticated": False,
|
||||
}
|
||||
mock_cache.set.assert_called_once_with(
|
||||
"mocked_cache_key", expected_data, timeout=60
|
||||
|
||||
@@ -125,7 +125,6 @@ def test_api_users_retrieve_me_authenticated(settings):
|
||||
"short_name": user.short_name,
|
||||
"language": user.language,
|
||||
"timezone": "UTC",
|
||||
"default_encryption_mode": "none",
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -32,7 +32,6 @@ from livekit.api import ( # pylint: disable=E0611
|
||||
UpdateRoomMetadataRequest,
|
||||
VideoGrants,
|
||||
)
|
||||
from livekit.protocol.room import RoomConfiguration # pylint: disable=E0611
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -67,7 +66,6 @@ def generate_token(
|
||||
sources: Optional[List[str]] = None,
|
||||
is_admin_or_owner: bool = False,
|
||||
participant_id: Optional[str] = None,
|
||||
encryption_mode: str = "none",
|
||||
) -> str:
|
||||
"""Generate a LiveKit access token for a user in a specific room.
|
||||
|
||||
@@ -88,26 +86,17 @@ def generate_token(
|
||||
str: The LiveKit JWT access token.
|
||||
"""
|
||||
|
||||
# Local import: core.models loads core.utils mid-import (see models.py:28),
|
||||
# so importing EncryptionMode at module top would deadlock the bootstrap.
|
||||
from core.models import ( # noqa: PLC0415 pylint: disable=import-outside-toplevel
|
||||
EncryptionMode,
|
||||
)
|
||||
|
||||
if is_admin_or_owner or sources is None:
|
||||
if is_admin_or_owner:
|
||||
sources = settings.LIVEKIT_DEFAULT_SOURCES
|
||||
|
||||
# In encrypted rooms, no one can change their name/attributes after the
|
||||
# admin accepted them — otherwise a participant authenticated under one
|
||||
# identity could rewrite their JWT-presented name to spoof someone else
|
||||
# mid-meeting. In plain rooms, free naming is fine.
|
||||
can_update_own_metadata = encryption_mode == EncryptionMode.NONE
|
||||
if sources is None:
|
||||
sources = settings.LIVEKIT_DEFAULT_SOURCES
|
||||
|
||||
video_grants = VideoGrants(
|
||||
room=room,
|
||||
room_join=True,
|
||||
room_admin=is_admin_or_owner,
|
||||
can_update_own_metadata=can_update_own_metadata,
|
||||
can_update_own_metadata=True,
|
||||
can_publish=bool(sources),
|
||||
can_publish_sources=sources,
|
||||
can_subscribe=True,
|
||||
@@ -129,21 +118,6 @@ def generate_token(
|
||||
"is_authenticated": "true" if not user.is_anonymous else "false",
|
||||
}
|
||||
|
||||
# Emit the email only for authenticated participants of *encrypted*
|
||||
# rooms. LK signaling broadcasts attributes to every peer in the room,
|
||||
# so making this conditional on the encryption gate is what prevents
|
||||
# an anonymous joiner of a public/trusted room from harvesting all
|
||||
# authenticated users' emails. Frontend hiding (the
|
||||
# `isLoggedIn`-gated render in ParticipantListItem) is only
|
||||
# defense-in-depth — anyone with devtools can read attributes
|
||||
# otherwise.
|
||||
if (
|
||||
not user.is_anonymous
|
||||
and encryption_mode != EncryptionMode.NONE
|
||||
and getattr(user, "email", None)
|
||||
):
|
||||
attributes["email"] = user.email
|
||||
|
||||
token = (
|
||||
AccessToken(
|
||||
api_key=settings.LIVEKIT_CONFIGURATION["api_key"],
|
||||
@@ -155,21 +129,6 @@ def generate_token(
|
||||
.with_attributes(attributes)
|
||||
)
|
||||
|
||||
# Encode the encryption mode into the room's metadata at LK-creation
|
||||
# time (via the access token's room_config). LiveKit creates the room
|
||||
# lazily when the first participant joins; the embedded config tells
|
||||
# it to stamp `{"encryption_mode": "<mode>"}` into the metadata at
|
||||
# that moment — no extra round-trip, no race window where a SIP
|
||||
# caller could read empty metadata before the `room_started` webhook
|
||||
# has time to push it.
|
||||
if encryption_mode != EncryptionMode.NONE:
|
||||
token = token.with_room_config(
|
||||
RoomConfiguration(
|
||||
name=room,
|
||||
metadata=json.dumps({"encryption_mode": encryption_mode}),
|
||||
)
|
||||
)
|
||||
|
||||
return token.to_jwt()
|
||||
|
||||
|
||||
@@ -181,7 +140,6 @@ def generate_livekit_config(
|
||||
color: Optional[str] = None,
|
||||
configuration: Optional[dict] = None,
|
||||
participant_id: Optional[str] = None,
|
||||
encryption_mode: str = "none",
|
||||
) -> dict:
|
||||
"""Generate LiveKit configuration for room access.
|
||||
|
||||
@@ -214,7 +172,6 @@ def generate_livekit_config(
|
||||
sources=sources,
|
||||
is_admin_or_owner=is_admin_or_owner,
|
||||
participant_id=participant_id,
|
||||
encryption_mode=encryption_mode,
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
@@ -561,12 +561,12 @@ class Base(Configuration):
|
||||
"returnTo", environ_name="OIDC_REDIRECT_FIELD_NAME", environ_prefix=None
|
||||
)
|
||||
OIDC_USERINFO_FULLNAME_FIELDS = values.ListValue(
|
||||
default=["given_name", "usual_name"],
|
||||
default=["first_name", "last_name"],
|
||||
environ_name="OIDC_USERINFO_FULLNAME_FIELDS",
|
||||
environ_prefix=None,
|
||||
)
|
||||
OIDC_USERINFO_SHORTNAME_FIELD = values.Value(
|
||||
default="given_name",
|
||||
default="first_name",
|
||||
environ_name="OIDC_USERINFO_SHORTNAME_FIELD",
|
||||
environ_prefix=None,
|
||||
)
|
||||
|
||||
@@ -13,7 +13,6 @@ export interface ApiConfig {
|
||||
help_article_transcript: string
|
||||
help_article_recording: string
|
||||
help_article_more_tools: string
|
||||
help_article_encryption?: string
|
||||
}
|
||||
feedback: {
|
||||
url: string
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { BackendLanguage } from '@/utils/languages'
|
||||
import { ApiEncryptionMode } from '@/features/rooms/api/ApiRoom'
|
||||
|
||||
export type ApiUser = {
|
||||
id: string
|
||||
@@ -9,5 +8,5 @@ export type ApiUser = {
|
||||
last_name: string
|
||||
language: BackendLanguage
|
||||
timezone: string
|
||||
default_encryption_mode: ApiEncryptionMode
|
||||
default_encryption: boolean
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ import { type ApiUser } from './ApiUser'
|
||||
import { fetchApi } from '@/api/fetchApi'
|
||||
|
||||
export type ApiUserPreferences = Partial<
|
||||
Pick<ApiUser, 'timezone' | 'language' | 'default_encryption_mode'>
|
||||
Pick<ApiUser, 'timezone' | 'language' | 'default_encryption'>
|
||||
> & { id: string }
|
||||
|
||||
export const updateUserPreferences = async ({
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
/**
|
||||
* Tile overlay shown when LiveKit raises an EncryptionError for a remote
|
||||
* participant (a passphrase/key mismatch — "you and they don't share the
|
||||
* same encryption key"). Renders the participant's avatar placeholder
|
||||
* over the broken video, plus a black banner at the bottom of the tile
|
||||
* explaining the issue.
|
||||
* Small banner shown on a participant tile when LiveKit raises an
|
||||
* EncryptionError for that participant — typically a passphrase/key
|
||||
* mismatch ("you and they don't share the same encryption key"). The
|
||||
* avatar stays visible behind the banner.
|
||||
*
|
||||
* Cleared automatically once frames decrypt again
|
||||
* (ParticipantEncryptionStatusChanged with encrypted=true).
|
||||
@@ -13,11 +12,8 @@ import { useTranslation } from 'react-i18next'
|
||||
import { Participant, RoomEvent } from 'livekit-client'
|
||||
import { useRoomContext } from '@livekit/components-react'
|
||||
import { RiLockFill } from '@remixicon/react'
|
||||
import { css } from '@/styled-system/css'
|
||||
|
||||
import { useRoomData } from '@/features/rooms/livekit/hooks/useRoomData'
|
||||
import { ParticipantPlaceholder } from '@/features/rooms/livekit/components/ParticipantPlaceholder'
|
||||
import { ApiEncryptionMode } from '@/features/rooms/api/ApiRoom'
|
||||
|
||||
interface Props {
|
||||
participant: Participant
|
||||
@@ -29,12 +25,11 @@ export function DecryptionFailedTileOverlay({ participant }: Props) {
|
||||
})
|
||||
const room = useRoomContext()
|
||||
const roomData = useRoomData()
|
||||
const isEncrypted = roomData?.encryption_mode === ApiEncryptionMode.BASIC
|
||||
|
||||
const [failed, setFailed] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (!isEncrypted) return
|
||||
if (!roomData?.is_encrypted) return
|
||||
if (participant.isLocal) return
|
||||
|
||||
const identity = participant.identity
|
||||
@@ -52,62 +47,54 @@ export function DecryptionFailedTileOverlay({ participant }: Props) {
|
||||
room.off(RoomEvent.EncryptionError, onError)
|
||||
room.off(RoomEvent.ParticipantEncryptionStatusChanged, onStatus)
|
||||
}
|
||||
}, [room, isEncrypted, participant])
|
||||
}, [room, roomData?.is_encrypted, participant])
|
||||
|
||||
if (!failed) return null
|
||||
|
||||
return (
|
||||
<output
|
||||
aria-label={t('title')}
|
||||
className={css({
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
zIndex: 3,
|
||||
bottom: '2.5rem',
|
||||
left: '50%',
|
||||
transform: 'translateX(-50%)',
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.75)',
|
||||
borderRadius: '0.5rem',
|
||||
padding: '0.6rem 1rem',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
gap: '0.3rem',
|
||||
maxWidth: '85%',
|
||||
zIndex: 4,
|
||||
pointerEvents: 'none',
|
||||
})}
|
||||
}}
|
||||
role="status"
|
||||
>
|
||||
<ParticipantPlaceholder participant={participant} />
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
bottom: '2.5rem',
|
||||
left: '50%',
|
||||
transform: 'translateX(-50%)',
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.75)',
|
||||
borderRadius: '0.5rem',
|
||||
padding: '0.6rem 1rem',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
gap: '0.3rem',
|
||||
maxWidth: '85%',
|
||||
gap: '0.4rem',
|
||||
color: '#f87171',
|
||||
fontSize: '0.85rem',
|
||||
fontWeight: 600,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '0.4rem',
|
||||
color: '#f87171',
|
||||
fontSize: '0.85rem',
|
||||
fontWeight: 600,
|
||||
}}
|
||||
>
|
||||
<RiLockFill size={14} />
|
||||
<span>{t('title')}</span>
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
color: '#d1d5db',
|
||||
fontSize: '0.75rem',
|
||||
textAlign: 'center',
|
||||
lineHeight: 1.4,
|
||||
maxWidth: '22rem',
|
||||
}}
|
||||
>
|
||||
{t('body')}
|
||||
</div>
|
||||
<RiLockFill size={14} />
|
||||
<span>{t('title')}</span>
|
||||
</div>
|
||||
</output>
|
||||
<div
|
||||
style={{
|
||||
color: '#d1d5db',
|
||||
fontSize: '0.75rem',
|
||||
textAlign: 'center',
|
||||
lineHeight: 1.4,
|
||||
maxWidth: '22rem',
|
||||
}}
|
||||
>
|
||||
{t('body')}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
/**
|
||||
* When the participant who paused encryption sees that *both* recording and
|
||||
* transcription have stopped, automatically broadcast `ENCRYPTION_RESUMED`.
|
||||
*
|
||||
* Other participants don't run this watcher: only the pauser can resume
|
||||
* (they're the one with `pausedByMe=true`). If they leave the room, the next
|
||||
* leader/admin can manually resume from the Settings panel — or in v1 the
|
||||
* room simply stays paused for the rest of the session.
|
||||
*/
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { useIsRecording } from '@livekit/components-react'
|
||||
import { RecordingMode, useRecordingStatuses } from '@/features/recording'
|
||||
import { EncryptionPhase } from './encryptionStatusTypes'
|
||||
import { useEncryptionStatus } from './useEncryptionStatus'
|
||||
|
||||
export function EncryptionAutoResumeWatcher() {
|
||||
const { phase, pausedByMe, resumeEncryption } = useEncryptionStatus()
|
||||
const isLiveKitRecording = useIsRecording()
|
||||
const transcriptStatuses = useRecordingStatuses(RecordingMode.Transcript)
|
||||
const screenRecStatuses = useRecordingStatuses(RecordingMode.ScreenRecording)
|
||||
|
||||
// Edge guard: avoid resuming on the initial render before anything has
|
||||
// actually started. We only resume after we've observed an active state.
|
||||
const wasActiveRef = useRef(false)
|
||||
const isAnyActive =
|
||||
isLiveKitRecording ||
|
||||
transcriptStatuses.isActive ||
|
||||
screenRecStatuses.isActive
|
||||
|
||||
useEffect(() => {
|
||||
if (isAnyActive) {
|
||||
wasActiveRef.current = true
|
||||
}
|
||||
}, [isAnyActive])
|
||||
|
||||
useEffect(() => {
|
||||
if (phase !== EncryptionPhase.PAUSED) return
|
||||
if (!pausedByMe) return
|
||||
if (!wasActiveRef.current) return
|
||||
if (isAnyActive) return
|
||||
|
||||
void resumeEncryption()
|
||||
}, [phase, pausedByMe, isAnyActive, resumeEncryption])
|
||||
|
||||
return null
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* Shown when the URL hash and the room's encryption_mode disagree.
|
||||
* Shown when the URL hash and the room's `is_encrypted` flag disagree.
|
||||
*
|
||||
* - missingPassphrase: room is encrypted on the server, but the URL has no
|
||||
* (or an invalid) passphrase. The user opened the wrong link.
|
||||
@@ -16,6 +16,11 @@ import { Button, Text } from '@/primitives'
|
||||
import { Screen } from '@/layout/Screen'
|
||||
import { CenteredContent } from '@/layout/CenteredContent'
|
||||
import { navigateTo } from '@/navigation/navigateTo'
|
||||
import {
|
||||
generateRoomId,
|
||||
useCreateRoom,
|
||||
} from '@/features/rooms'
|
||||
import { generatePassphrase } from './passphrase'
|
||||
|
||||
interface Props {
|
||||
reason: 'missingPassphrase' | 'unexpectedPassphrase'
|
||||
@@ -23,15 +28,35 @@ interface Props {
|
||||
|
||||
export function EncryptionMismatchScreen({ reason }: Props) {
|
||||
const { t } = useTranslation('rooms', { keyPrefix: 'encryption.mismatch' })
|
||||
const { mutateAsync: createRoom } = useCreateRoom()
|
||||
|
||||
const handleCreateFresh = async () => {
|
||||
const slug = generateRoomId()
|
||||
const hash = generatePassphrase()
|
||||
const room = await createRoom({ slug, isEncrypted: true })
|
||||
navigateTo('room', room.slug, {
|
||||
state: { create: true, initialRoomData: room },
|
||||
})
|
||||
window.history.replaceState(
|
||||
window.history.state,
|
||||
'',
|
||||
`${window.location.pathname}#${hash}`
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Screen layout="centered">
|
||||
<CenteredContent>
|
||||
<CenteredContent withBackButton>
|
||||
<Center>
|
||||
<div
|
||||
className={css({
|
||||
maxWidth: '420px',
|
||||
padding: '2rem',
|
||||
borderRadius: '1rem',
|
||||
backgroundColor: 'white',
|
||||
border: '1px solid',
|
||||
borderColor: 'greyscale.200',
|
||||
boxShadow: '0 4px 16px rgba(0,0,0,0.06)',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
@@ -68,8 +93,8 @@ export function EncryptionMismatchScreen({ reason }: Props) {
|
||||
>
|
||||
{t(`${reason}.body`)}
|
||||
</Text>
|
||||
<Button variant="primary" onPress={() => navigateTo('home')}>
|
||||
{t('backHome')}
|
||||
<Button variant="primary" onPress={handleCreateFresh}>
|
||||
{t('createFresh')}
|
||||
</Button>
|
||||
</div>
|
||||
</Center>
|
||||
|
||||
@@ -0,0 +1,384 @@
|
||||
/**
|
||||
* In-call encryption state machine and pause protocol.
|
||||
*
|
||||
* Three phases:
|
||||
* - UNENCRYPTED — the room is not end-to-end encrypted.
|
||||
* - ENCRYPTED — E2EE is active; frames are encrypted with the URL passphrase.
|
||||
* - PAUSED — encryption is temporarily paused for this session, typically
|
||||
* so the SFU can record / transcribe.
|
||||
*
|
||||
* The "paused" state is intentionally ephemeral: it is never persisted in the
|
||||
* database. The truth source for "this call is encrypted" is the presence of
|
||||
* the passphrase in the URL hash, not a server-side flag — a hacked server
|
||||
* cannot fabricate a passphrase that all participants happen to share.
|
||||
*
|
||||
* Pause is broadcast over a LiveKit reliable data channel. While the sender
|
||||
* has not yet flipped its own state, the message itself travels encrypted,
|
||||
* which is the trust anchor: only callers who hold the passphrase can produce
|
||||
* frames everyone can decrypt.
|
||||
*
|
||||
* Pause is reversible: when both recording and transcription have stopped,
|
||||
* the participant who initiated the pause broadcasts ENCRYPTION_RESUMED and
|
||||
* everyone re-enables E2EE with the same URL passphrase.
|
||||
*
|
||||
* Initiation: admins can always pause/resume. If no admin is in the room and
|
||||
* a pause has already been observed in this session (everSeenPause), the
|
||||
* leader (oldest non-SIP participant) may also pause/resume — this covers
|
||||
* the "the admin left mid-call" edge case without granting unsolicited
|
||||
* pause power to non-admins.
|
||||
*/
|
||||
import {
|
||||
ReactNode,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react'
|
||||
import { useRoomContext } from '@livekit/components-react'
|
||||
import {
|
||||
DataPacket_Kind,
|
||||
Participant,
|
||||
ParticipantKind,
|
||||
RemoteParticipant,
|
||||
RoomEvent,
|
||||
} from 'livekit-client'
|
||||
import { EncryptionStatusContext } from './encryptionStatusContextValue'
|
||||
import { EncryptionPhase, PauseReason } from './encryptionStatusTypes'
|
||||
|
||||
const ENCRYPTION_TOPIC = 'encryption-state'
|
||||
const PROBE_RESPONSE_GRACE_MS = 2500
|
||||
|
||||
const textEncoder = new TextEncoder()
|
||||
const textDecoder = new TextDecoder()
|
||||
|
||||
interface ProtocolMessage {
|
||||
type:
|
||||
| 'ENCRYPTION_PAUSED'
|
||||
| 'ENCRYPTION_RESUMED'
|
||||
| 'ENCRYPTION_STATUS_PROBE'
|
||||
reason?: PauseReason
|
||||
/** Sender's `joinedAt` timestamp; used in leader election. */
|
||||
senderJoinedAt?: number
|
||||
/** Whether the sender is a room admin/owner. */
|
||||
senderIsAdmin?: boolean
|
||||
}
|
||||
|
||||
function encodeMessage(msg: ProtocolMessage): Uint8Array {
|
||||
return textEncoder.encode(JSON.stringify(msg))
|
||||
}
|
||||
|
||||
function decodeMessage(payload: Uint8Array): ProtocolMessage | null {
|
||||
try {
|
||||
return JSON.parse(textDecoder.decode(payload)) as ProtocolMessage
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function isParticipantAdmin(participant: Participant | undefined): boolean {
|
||||
return participant?.attributes?.room_admin === 'true'
|
||||
}
|
||||
|
||||
function isParticipantPhoneOrSip(p: Participant): boolean {
|
||||
return p.kind === ParticipantKind.SIP
|
||||
}
|
||||
|
||||
interface EncryptionStatusProviderProps {
|
||||
children: ReactNode
|
||||
/** Whether this room is end-to-end encrypted. */
|
||||
isEncrypted: boolean
|
||||
/** Called when the local client should toggle E2EE on/off. */
|
||||
onPhaseChange?: (phase: EncryptionPhase) => void
|
||||
/**
|
||||
* Optional hook to mirror local pause/resume to the room's
|
||||
* server-side encryption_paused field. When provided, recording and
|
||||
* transcription pauses also propagate to the SIP gateway (so phone
|
||||
* callers transition out of placeholder mode automatically).
|
||||
*/
|
||||
setServerEncryptionPaused?: (paused: boolean) => Promise<void> | void
|
||||
}
|
||||
|
||||
export function EncryptionStatusProvider({
|
||||
children,
|
||||
isEncrypted,
|
||||
onPhaseChange,
|
||||
setServerEncryptionPaused,
|
||||
}: EncryptionStatusProviderProps) {
|
||||
const room = useRoomContext()
|
||||
const initialPhase = isEncrypted
|
||||
? EncryptionPhase.ENCRYPTED
|
||||
: EncryptionPhase.UNENCRYPTED
|
||||
const [phase, setPhase] = useState<EncryptionPhase>(initialPhase)
|
||||
const [pauseReason, setPauseReason] = useState<PauseReason | undefined>()
|
||||
const [pausedByMe, setPausedByMe] = useState(false)
|
||||
const everSeenPauseRef = useRef(false)
|
||||
const phaseRef = useRef(phase)
|
||||
phaseRef.current = phase
|
||||
|
||||
// When the encrypted flag changes (e.g. on initial room data load), align
|
||||
// the local phase. The pause path keeps phase=PAUSED across updates.
|
||||
useEffect(() => {
|
||||
if (!isEncrypted && phaseRef.current !== EncryptionPhase.UNENCRYPTED) {
|
||||
setPhase(EncryptionPhase.UNENCRYPTED)
|
||||
setPauseReason(undefined)
|
||||
setPausedByMe(false)
|
||||
} else if (
|
||||
isEncrypted &&
|
||||
phaseRef.current === EncryptionPhase.UNENCRYPTED
|
||||
) {
|
||||
setPhase(EncryptionPhase.ENCRYPTED)
|
||||
}
|
||||
}, [isEncrypted])
|
||||
|
||||
// Push phase transitions to LiveKit (E2EE on/off + republish).
|
||||
useEffect(() => {
|
||||
onPhaseChange?.(phase)
|
||||
}, [phase, onPhaseChange])
|
||||
|
||||
const sendProtocolMessage = useCallback(
|
||||
async (msg: ProtocolMessage, destination?: string[]) => {
|
||||
try {
|
||||
await room.localParticipant.publishData(encodeMessage(msg), {
|
||||
reliable: true,
|
||||
topic: ENCRYPTION_TOPIC,
|
||||
destinationIdentities: destination,
|
||||
})
|
||||
} catch (err) {
|
||||
console.error('[encryption] failed to publish protocol message', err)
|
||||
}
|
||||
},
|
||||
[room]
|
||||
)
|
||||
|
||||
/**
|
||||
* Determine whether we (locally) consider `sender` legitimate to issue
|
||||
* pause/resume messages.
|
||||
*
|
||||
* Always true for admins. For non-admins, true only if there is no admin
|
||||
* currently in the room AND the sender is the oldest non-SIP participant
|
||||
* we know of (deterministic across peers via joinedAt+identity).
|
||||
*/
|
||||
const isLegitimatePauseSender = useCallback(
|
||||
(sender: RemoteParticipant | undefined): boolean => {
|
||||
if (!sender) return false
|
||||
if (isParticipantAdmin(sender)) return true
|
||||
|
||||
const everyone: Participant[] = [
|
||||
room.localParticipant,
|
||||
...Array.from(room.remoteParticipants.values()),
|
||||
]
|
||||
const adminPresent = everyone.some(isParticipantAdmin)
|
||||
if (adminPresent) return false
|
||||
|
||||
const eligible = everyone.filter((p) => !isParticipantPhoneOrSip(p))
|
||||
const sorted = eligible.sort((a, b) => {
|
||||
const aJ = a.joinedAt?.getTime() ?? Number.MAX_SAFE_INTEGER
|
||||
const bJ = b.joinedAt?.getTime() ?? Number.MAX_SAFE_INTEGER
|
||||
if (aJ !== bJ) return aJ - bJ
|
||||
return a.identity.localeCompare(b.identity)
|
||||
})
|
||||
const leader = sorted[0]
|
||||
return !!leader && leader.identity === sender.identity
|
||||
},
|
||||
[room]
|
||||
)
|
||||
|
||||
/** Same logic but applied to the local participant (am I allowed to act?). */
|
||||
const localCanInitiate = useCallback((): boolean => {
|
||||
if (isParticipantAdmin(room.localParticipant)) return true
|
||||
if (!everSeenPauseRef.current) return false
|
||||
|
||||
const everyone: Participant[] = [
|
||||
room.localParticipant,
|
||||
...Array.from(room.remoteParticipants.values()),
|
||||
]
|
||||
if (everyone.some(isParticipantAdmin)) return false
|
||||
|
||||
const eligible = everyone.filter((p) => !isParticipantPhoneOrSip(p))
|
||||
const sorted = eligible.sort((a, b) => {
|
||||
const aJ = a.joinedAt?.getTime() ?? Number.MAX_SAFE_INTEGER
|
||||
const bJ = b.joinedAt?.getTime() ?? Number.MAX_SAFE_INTEGER
|
||||
if (aJ !== bJ) return aJ - bJ
|
||||
return a.identity.localeCompare(b.identity)
|
||||
})
|
||||
return sorted[0]?.identity === room.localParticipant.identity
|
||||
}, [room])
|
||||
|
||||
const handlePauseAnnouncement = useCallback(
|
||||
(msg: ProtocolMessage, sender?: RemoteParticipant) => {
|
||||
if (!isLegitimatePauseSender(sender)) return
|
||||
everSeenPauseRef.current = true
|
||||
if (phaseRef.current !== EncryptionPhase.ENCRYPTED) return
|
||||
|
||||
setPhase(EncryptionPhase.PAUSED)
|
||||
setPauseReason(msg.reason)
|
||||
setPausedByMe(false)
|
||||
},
|
||||
[isLegitimatePauseSender]
|
||||
)
|
||||
|
||||
const handleResumeAnnouncement = useCallback(
|
||||
(sender?: RemoteParticipant) => {
|
||||
if (!isLegitimatePauseSender(sender)) return
|
||||
if (phaseRef.current !== EncryptionPhase.PAUSED) return
|
||||
|
||||
setPhase(EncryptionPhase.ENCRYPTED)
|
||||
setPauseReason(undefined)
|
||||
setPausedByMe(false)
|
||||
},
|
||||
[isLegitimatePauseSender]
|
||||
)
|
||||
|
||||
const handleProbe = useCallback(
|
||||
(sender: RemoteParticipant) => {
|
||||
if (phaseRef.current !== EncryptionPhase.PAUSED) return
|
||||
// We respond if we ourselves are a legitimate sender for this room.
|
||||
if (!localCanInitiate()) return
|
||||
|
||||
void sendProtocolMessage(
|
||||
{
|
||||
type: 'ENCRYPTION_PAUSED',
|
||||
reason: pauseReason,
|
||||
senderIsAdmin: isParticipantAdmin(room.localParticipant),
|
||||
senderJoinedAt:
|
||||
room.localParticipant.joinedAt?.getTime() ?? Date.now(),
|
||||
},
|
||||
[sender.identity]
|
||||
)
|
||||
},
|
||||
[room, pauseReason, sendProtocolMessage, localCanInitiate]
|
||||
)
|
||||
|
||||
// Subscribe to encryption-channel data messages.
|
||||
useEffect(() => {
|
||||
if (!isEncrypted) return
|
||||
|
||||
const handler = (
|
||||
payload: Uint8Array,
|
||||
participant?: RemoteParticipant,
|
||||
_kind?: DataPacket_Kind,
|
||||
topic?: string
|
||||
) => {
|
||||
if (topic !== ENCRYPTION_TOPIC) return
|
||||
const msg = decodeMessage(payload)
|
||||
if (!msg) return
|
||||
if (msg.type === 'ENCRYPTION_PAUSED') {
|
||||
handlePauseAnnouncement(msg, participant)
|
||||
} else if (msg.type === 'ENCRYPTION_RESUMED') {
|
||||
handleResumeAnnouncement(participant)
|
||||
} else if (msg.type === 'ENCRYPTION_STATUS_PROBE' && participant) {
|
||||
handleProbe(participant)
|
||||
}
|
||||
}
|
||||
|
||||
room.on(RoomEvent.DataReceived, handler)
|
||||
return () => {
|
||||
room.off(RoomEvent.DataReceived, handler)
|
||||
}
|
||||
}, [
|
||||
room,
|
||||
isEncrypted,
|
||||
handlePauseAnnouncement,
|
||||
handleResumeAnnouncement,
|
||||
handleProbe,
|
||||
])
|
||||
|
||||
// On join, ask the room whether encryption is currently paused.
|
||||
useEffect(() => {
|
||||
if (!isEncrypted) return
|
||||
if (phase !== EncryptionPhase.ENCRYPTED) return
|
||||
|
||||
let cancelled = false
|
||||
const timer = setTimeout(() => {
|
||||
if (cancelled) return
|
||||
void sendProtocolMessage({ type: 'ENCRYPTION_STATUS_PROBE' })
|
||||
}, 0)
|
||||
const cleanup = setTimeout(() => {
|
||||
// Nothing to do — if no answer arrived, we stay encrypted.
|
||||
}, PROBE_RESPONSE_GRACE_MS)
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
clearTimeout(timer)
|
||||
clearTimeout(cleanup)
|
||||
}
|
||||
// we intentionally only run this when joining the encrypted state
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [isEncrypted])
|
||||
|
||||
const pauseEncryption = useCallback(
|
||||
async (reason: PauseReason) => {
|
||||
if (phaseRef.current !== EncryptionPhase.ENCRYPTED) return false
|
||||
if (!localCanInitiate()) return false
|
||||
|
||||
everSeenPauseRef.current = true
|
||||
setPhase(EncryptionPhase.PAUSED)
|
||||
setPauseReason(reason)
|
||||
setPausedByMe(true)
|
||||
|
||||
// Mirror to the server's encryption_paused field if a setter was
|
||||
// wired through. This is what makes the SIP gateway transition out
|
||||
// of placeholder mode for recording/transcription too, not just
|
||||
// for an explicit admin pause from the Admin panel.
|
||||
if (setServerEncryptionPaused) {
|
||||
try {
|
||||
await setServerEncryptionPaused(true)
|
||||
} catch (err) {
|
||||
console.error('[encryption] server-side pause failed', err)
|
||||
}
|
||||
}
|
||||
|
||||
await sendProtocolMessage({
|
||||
type: 'ENCRYPTION_PAUSED',
|
||||
reason,
|
||||
senderIsAdmin: isParticipantAdmin(room.localParticipant),
|
||||
senderJoinedAt:
|
||||
room.localParticipant.joinedAt?.getTime() ?? Date.now(),
|
||||
})
|
||||
return true
|
||||
},
|
||||
[room, sendProtocolMessage, localCanInitiate, setServerEncryptionPaused]
|
||||
)
|
||||
|
||||
const resumeEncryption = useCallback(async () => {
|
||||
if (phaseRef.current !== EncryptionPhase.PAUSED) return false
|
||||
if (!localCanInitiate()) return false
|
||||
|
||||
setPhase(EncryptionPhase.ENCRYPTED)
|
||||
setPauseReason(undefined)
|
||||
setPausedByMe(false)
|
||||
|
||||
if (setServerEncryptionPaused) {
|
||||
try {
|
||||
await setServerEncryptionPaused(false)
|
||||
} catch (err) {
|
||||
console.error('[encryption] server-side resume failed', err)
|
||||
}
|
||||
}
|
||||
|
||||
await sendProtocolMessage({
|
||||
type: 'ENCRYPTION_RESUMED',
|
||||
senderIsAdmin: isParticipantAdmin(room.localParticipant),
|
||||
senderJoinedAt: room.localParticipant.joinedAt?.getTime() ?? Date.now(),
|
||||
})
|
||||
return true
|
||||
}, [room, sendProtocolMessage, localCanInitiate, setServerEncryptionPaused])
|
||||
|
||||
const value = useMemo(
|
||||
() => ({
|
||||
phase,
|
||||
pauseReason,
|
||||
pausedByMe,
|
||||
pauseEncryption,
|
||||
resumeEncryption,
|
||||
}),
|
||||
[phase, pauseReason, pausedByMe, pauseEncryption, resumeEncryption]
|
||||
)
|
||||
|
||||
return (
|
||||
<EncryptionStatusContext.Provider value={value}>
|
||||
{children}
|
||||
</EncryptionStatusContext.Provider>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,212 @@
|
||||
/**
|
||||
* Transient bottom snackbars announcing encryption state changes:
|
||||
* - "Encryption paused while transcription is on"
|
||||
* - "Encryption was turned off for this meeting"
|
||||
* - "A participant can't decrypt this meeting" (admin only, with CTA)
|
||||
*/
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { css } from '@/styled-system/css'
|
||||
import { HStack, VStack } from '@/styled-system/jsx'
|
||||
import { Button, Text } from '@/primitives'
|
||||
import { ParticipantKind, RemoteParticipant } from 'livekit-client'
|
||||
import { useRemoteParticipants } from '@livekit/components-react'
|
||||
import { useIsAdminOrOwner } from '@/features/rooms/livekit/hooks/useIsAdminOrOwner'
|
||||
import { useSidePanel } from '@/features/rooms/livekit/hooks/useSidePanel'
|
||||
import { EncryptionPhase, PauseReason } from './encryptionStatusTypes'
|
||||
import { useEncryptionStatus } from './useEncryptionStatus'
|
||||
|
||||
const DISPLAY_DURATION_MS = 8000
|
||||
|
||||
const SnackbarShell = ({ children }: { children: React.ReactNode }) => (
|
||||
<div
|
||||
className={css({
|
||||
position: 'fixed',
|
||||
bottom: '5rem',
|
||||
right: '1rem',
|
||||
zIndex: 1500,
|
||||
maxWidth: '24rem',
|
||||
padding: '0.85rem 1rem',
|
||||
backgroundColor: '#1e3a5f',
|
||||
borderRadius: '0.5rem',
|
||||
boxShadow: '0 10px 30px rgba(0,0,0,0.25)',
|
||||
})}
|
||||
role="status"
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
|
||||
function useTransient<T>(value: T, displayMs: number) {
|
||||
const [shown, setShown] = useState<T | null>(null)
|
||||
const timer = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!value) return
|
||||
setShown(value)
|
||||
if (timer.current) clearTimeout(timer.current)
|
||||
timer.current = setTimeout(() => setShown(null), displayMs)
|
||||
return () => {
|
||||
if (timer.current) clearTimeout(timer.current)
|
||||
}
|
||||
}, [value, displayMs])
|
||||
|
||||
return [shown, () => setShown(null)] as const
|
||||
}
|
||||
|
||||
export function EncryptionStatusSnackbars() {
|
||||
const { t } = useTranslation('rooms', { keyPrefix: 'encryption.snackbar' })
|
||||
const { phase, pauseReason, pausedByMe } = useEncryptionStatus()
|
||||
const remoteParticipants = useRemoteParticipants()
|
||||
const isAdmin = useIsAdminOrOwner()
|
||||
const { toggleAdmin, isAdminOpen } = useSidePanel()
|
||||
|
||||
const [pauseSignal, setPauseSignal] = useState<{
|
||||
reason?: PauseReason
|
||||
pausedByMe: boolean
|
||||
} | null>(null)
|
||||
|
||||
const previousPhase = useRef(phase)
|
||||
useEffect(() => {
|
||||
if (
|
||||
previousPhase.current !== EncryptionPhase.PAUSED &&
|
||||
phase === EncryptionPhase.PAUSED
|
||||
) {
|
||||
setPauseSignal({ reason: pauseReason, pausedByMe })
|
||||
}
|
||||
previousPhase.current = phase
|
||||
}, [phase, pauseReason, pausedByMe])
|
||||
|
||||
const [pauseToast, dismissPauseToast] = useTransient(
|
||||
pauseSignal,
|
||||
DISPLAY_DURATION_MS
|
||||
)
|
||||
|
||||
// The SIP-blocked snackbar persists as long as a SIP participant is
|
||||
// present in the encrypted room — admin needs to either pause encryption
|
||||
// (Settings → Security → This meeting) or wait for the SIP user to hang
|
||||
// up. Manual dismiss hides it until a different SIP participant arrives.
|
||||
//
|
||||
// useRemoteParticipants re-renders on participant join/leave/state — that
|
||||
// gives us a reactive participant list without manual event listeners,
|
||||
// which is more reliable than the prior subscribe-on-mount approach.
|
||||
const [sipDismissedIdentity, setSipDismissedIdentity] = useState<string | null>(null)
|
||||
|
||||
const isSip = (p: RemoteParticipant) =>
|
||||
p.kind === ParticipantKind.SIP || p.identity.startsWith('sip_')
|
||||
|
||||
const sipParticipant =
|
||||
isAdmin && phase === EncryptionPhase.ENCRYPTED
|
||||
? remoteParticipants.find(isSip) ?? null
|
||||
: null
|
||||
|
||||
const sipLabel = sipParticipant
|
||||
? sipParticipant.name || sipParticipant.identity
|
||||
: null
|
||||
|
||||
const showSipSnack =
|
||||
sipParticipant !== null &&
|
||||
sipDismissedIdentity !== sipParticipant.identity
|
||||
|
||||
return (
|
||||
<>
|
||||
{pauseToast && (
|
||||
<SnackbarShell>
|
||||
<HStack
|
||||
gap="1rem"
|
||||
justify="space-between"
|
||||
alignItems="center"
|
||||
className={css({ width: '100%' })}
|
||||
>
|
||||
<VStack gap="0.15rem" alignItems="start">
|
||||
<Text
|
||||
variant="sm"
|
||||
margin={false}
|
||||
className={css({ color: 'white', fontWeight: 600 })}
|
||||
>
|
||||
{pauseToast.pausedByMe
|
||||
? t('pausedByMeTitle')
|
||||
: t('pausedTitle')}
|
||||
</Text>
|
||||
<Text
|
||||
variant="note"
|
||||
margin={false}
|
||||
className={css({
|
||||
color: 'rgba(255,255,255,0.85)',
|
||||
fontSize: '0.8rem',
|
||||
})}
|
||||
>
|
||||
{pauseToast.reason === 'transcript'
|
||||
? t('reasonTranscript')
|
||||
: pauseToast.reason === 'recording'
|
||||
? t('reasonRecording')
|
||||
: pauseToast.reason === 'sip_participant'
|
||||
? t('reasonSip')
|
||||
: t('reasonManual')}
|
||||
</Text>
|
||||
</VStack>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="text"
|
||||
onPress={dismissPauseToast}
|
||||
className={css({ color: 'white !important' })}
|
||||
>
|
||||
{t('dismiss')}
|
||||
</Button>
|
||||
</HStack>
|
||||
</SnackbarShell>
|
||||
)}
|
||||
{showSipSnack && phase === EncryptionPhase.ENCRYPTED && (
|
||||
<SnackbarShell>
|
||||
<HStack
|
||||
gap="1rem"
|
||||
justify="space-between"
|
||||
alignItems="center"
|
||||
className={css({ width: '100%' })}
|
||||
>
|
||||
<VStack gap="0.15rem" alignItems="start">
|
||||
<Text
|
||||
variant="sm"
|
||||
margin={false}
|
||||
className={css({ color: 'white', fontWeight: 600 })}
|
||||
>
|
||||
{t('sipTitle')}
|
||||
</Text>
|
||||
<Text
|
||||
variant="note"
|
||||
margin={false}
|
||||
className={css({
|
||||
color: 'rgba(255,255,255,0.85)',
|
||||
fontSize: '0.8rem',
|
||||
})}
|
||||
>
|
||||
{t('sipBody', { name: sipLabel })}
|
||||
</Text>
|
||||
</VStack>
|
||||
{!isAdminOpen && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="text"
|
||||
className={css({ color: 'white !important' })}
|
||||
onPress={toggleAdmin}
|
||||
>
|
||||
{t('openAdmin')}
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
size="sm"
|
||||
variant="text"
|
||||
className={css({ color: 'white !important' })}
|
||||
onPress={() =>
|
||||
sipParticipant &&
|
||||
setSipDismissedIdentity(sipParticipant.identity)
|
||||
}
|
||||
>
|
||||
{t('dismiss')}
|
||||
</Button>
|
||||
</HStack>
|
||||
</SnackbarShell>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -1,37 +0,0 @@
|
||||
/**
|
||||
* Small pill used to surface a feature name with an icon, e.g. in the
|
||||
* encrypted-room create dialog, the "Meeting information" panel and the
|
||||
* floating share dialog — the three places that list disabled features.
|
||||
*/
|
||||
import { css } from '@/styled-system/css'
|
||||
import { ReactNode } from 'react'
|
||||
|
||||
interface Props {
|
||||
icon: ReactNode
|
||||
label: string
|
||||
size?: 'sm' | 'md'
|
||||
}
|
||||
|
||||
export const FeaturePill = ({ icon, label, size = 'md' }: Props) => {
|
||||
const fontSize = size === 'sm' ? '0.8rem' : '0.85rem'
|
||||
const padding = size === 'sm' ? '0.3rem 0.6rem' : '0.4rem 0.7rem'
|
||||
return (
|
||||
<span
|
||||
className={css({
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: '0.4rem',
|
||||
borderRadius: '0.5rem',
|
||||
border: '1px solid',
|
||||
borderColor: 'greyscale.250',
|
||||
color: 'greyscale.700',
|
||||
backgroundColor: 'white',
|
||||
whiteSpace: 'nowrap',
|
||||
})}
|
||||
style={{ fontSize, padding }}
|
||||
>
|
||||
{icon}
|
||||
{label}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
/**
|
||||
* Tiny per-participant identity confidence pill, shown in encrypted meetings.
|
||||
*
|
||||
* - "ProConnect" → server-verified identity (the participant signed in).
|
||||
* - "Anonymous" → self-declared name; treat with caution.
|
||||
*
|
||||
* Sourced from the `is_authenticated` JWT attribute set in
|
||||
* `core/utils.py::generate_token` (or the equivalent flag on a lobby
|
||||
* participant). No fingerprints, no email — just a one-glance signal.
|
||||
*/
|
||||
import { css } from '@/styled-system/css'
|
||||
import { RiShieldCheckFill, RiUserLine } from '@remixicon/react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import type { Participant } from 'livekit-client'
|
||||
|
||||
interface BadgeProps {
|
||||
size?: 'sm' | 'md'
|
||||
}
|
||||
|
||||
interface FromParticipantProps extends BadgeProps {
|
||||
participant: Participant
|
||||
isAuthenticated?: never
|
||||
}
|
||||
|
||||
interface FromFlagProps extends BadgeProps {
|
||||
isAuthenticated: boolean
|
||||
participant?: never
|
||||
}
|
||||
|
||||
export function IdentityBadge(props: FromParticipantProps | FromFlagProps) {
|
||||
const { t } = useTranslation('rooms', { keyPrefix: 'identity' })
|
||||
const isAuthenticated =
|
||||
props.participant !== undefined
|
||||
? props.participant.attributes?.is_authenticated === 'true'
|
||||
: props.isAuthenticated
|
||||
const px = props.size === 'md' ? 14 : 12
|
||||
|
||||
const label = isAuthenticated ? t('proconnect') : t('anonymous')
|
||||
const color = isAuthenticated ? '#1e40af' : '#b45309'
|
||||
const bg = isAuthenticated ? 'rgba(30,64,175,0.10)' : 'rgba(180,83,9,0.10)'
|
||||
|
||||
return (
|
||||
<span
|
||||
title={label}
|
||||
aria-label={label}
|
||||
className={css({
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: '0.2rem',
|
||||
padding: '0 0.3rem',
|
||||
borderRadius: '0.25rem',
|
||||
fontSize: '0.65rem',
|
||||
fontWeight: 600,
|
||||
letterSpacing: '0.02em',
|
||||
whiteSpace: 'nowrap',
|
||||
})}
|
||||
style={{ backgroundColor: bg, color }}
|
||||
>
|
||||
{isAuthenticated ? (
|
||||
<RiShieldCheckFill size={px} color={color} />
|
||||
) : (
|
||||
<RiUserLine size={px} color={color} />
|
||||
)}
|
||||
{label}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
/**
|
||||
* Modal asking the admin to confirm that they accept pausing encryption
|
||||
* to start recording or transcription.
|
||||
*/
|
||||
import { Button, Dialog, Text } from '@/primitives'
|
||||
import { HStack, VStack } from '@/styled-system/jsx'
|
||||
import { css } from '@/styled-system/css'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
interface Props {
|
||||
isOpen: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
reason: 'recording' | 'transcript'
|
||||
onConfirm: () => void | Promise<void>
|
||||
}
|
||||
|
||||
export function PauseEncryptionConfirmDialog({
|
||||
isOpen,
|
||||
onOpenChange,
|
||||
reason,
|
||||
onConfirm,
|
||||
}: Props) {
|
||||
const { t } = useTranslation('rooms', {
|
||||
keyPrefix: 'encryption.pauseConfirm',
|
||||
})
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
isOpen={isOpen}
|
||||
onOpenChange={onOpenChange}
|
||||
role="dialog"
|
||||
type="flex"
|
||||
title={t(`title.${reason}`)}
|
||||
>
|
||||
<VStack
|
||||
alignItems="start"
|
||||
gap="0.75rem"
|
||||
className={css({ maxWidth: '24rem' })}
|
||||
>
|
||||
<Text variant="sm">{t('description')}</Text>
|
||||
<Text
|
||||
variant="note"
|
||||
className={css({
|
||||
fontSize: '0.8rem',
|
||||
color: 'greyscale.500',
|
||||
})}
|
||||
>
|
||||
{t('learnMore')}
|
||||
</Text>
|
||||
<HStack gap="0.5rem" justify="end" className={css({ width: '100%' })}>
|
||||
<Button variant="secondary" onPress={() => onOpenChange(false)}>
|
||||
{t('cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
onPress={async () => {
|
||||
await onConfirm()
|
||||
onOpenChange(false)
|
||||
}}
|
||||
>
|
||||
{t(`confirm.${reason}`)}
|
||||
</Button>
|
||||
</HStack>
|
||||
</VStack>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -2,7 +2,7 @@
|
||||
* Top-left status banner shown during a meeting.
|
||||
*
|
||||
* Renders a horizontal stack of pills, one per active state:
|
||||
* - "End-to-end encrypted"
|
||||
* - "End-to-end encrypted" / "Encryption paused"
|
||||
* - "Recording in progress"
|
||||
* - "Transcription in progress"
|
||||
*
|
||||
@@ -13,14 +13,17 @@ import { css } from '@/styled-system/css'
|
||||
import { HStack } from '@/styled-system/jsx'
|
||||
import {
|
||||
RiFileTextFill,
|
||||
RiLockFill,
|
||||
RiLockUnlockFill,
|
||||
RiRecordCircleFill,
|
||||
RiShieldCheckLine,
|
||||
} from '@remixicon/react'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useRoomData } from '@/features/rooms/livekit/hooks/useRoomData'
|
||||
import { RecordingMode, useRecordingStatuses } from '@/features/recording'
|
||||
import { ApiEncryptionMode } from '@/features/rooms/api/ApiRoom'
|
||||
import {
|
||||
RecordingMode,
|
||||
useRecordingStatuses,
|
||||
} from '@/features/recording'
|
||||
|
||||
const COLLAPSE_DELAY_MS = 4000
|
||||
|
||||
@@ -40,9 +43,10 @@ function StatusPill({ icon, label, background, pulse }: PillProps) {
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<output
|
||||
<div
|
||||
onMouseEnter={() => setCollapsed(false)}
|
||||
onMouseLeave={() => setCollapsed(true)}
|
||||
role="status"
|
||||
aria-label={label}
|
||||
className={css({
|
||||
display: 'inline-flex',
|
||||
@@ -78,7 +82,7 @@ function StatusPill({ icon, label, background, pulse }: PillProps) {
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
</output>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -95,9 +99,13 @@ export function RoomStatusBanner() {
|
||||
const isRecording = screenRec.isStarted
|
||||
const isTranscribing = transcript.isStarted
|
||||
|
||||
const isEncrypted = roomData?.encryption_mode === ApiEncryptionMode.BASIC
|
||||
// The encryption pill reflects the room's nature plus its current paused
|
||||
// state — never disappears just because encryption is temporarily off
|
||||
// mid-call.
|
||||
const encryptionCapable = !!roomData?.is_encrypted
|
||||
const encryptionPaused = !!roomData?.encryption_paused
|
||||
|
||||
if (!isEncrypted && !isRecording && !isTranscribing) {
|
||||
if (!encryptionCapable && !isRecording && !isTranscribing) {
|
||||
return null
|
||||
}
|
||||
|
||||
@@ -111,14 +119,22 @@ export function RoomStatusBanner() {
|
||||
zIndex: 10,
|
||||
})}
|
||||
>
|
||||
{isEncrypted && (
|
||||
{encryptionCapable && !encryptionPaused && (
|
||||
<StatusPill
|
||||
key="encrypted"
|
||||
icon={<RiShieldCheckLine size={14} color="white" />}
|
||||
icon={<RiLockFill size={13} color="white" />}
|
||||
label={t('encrypted')}
|
||||
background="#1e3a5f"
|
||||
/>
|
||||
)}
|
||||
{encryptionCapable && encryptionPaused && (
|
||||
<StatusPill
|
||||
key="paused"
|
||||
icon={<RiLockUnlockFill size={13} color="white" />}
|
||||
label={t('paused')}
|
||||
background="#b45309"
|
||||
/>
|
||||
)}
|
||||
{isTranscribing && (
|
||||
<StatusPill
|
||||
key="transcript"
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
/**
|
||||
* Small banner shown on top of a SIP/phone participant's tile when the room
|
||||
* is live-encrypted (encrypted AND not paused). The avatar (LiveKit's
|
||||
* ParticipantPlaceholder) stays visible behind it; this is just a callout
|
||||
* near the bottom of the tile so everyone in the room knows why the caller
|
||||
* isn't producing audio/video. Admins get a CTA to pause encryption.
|
||||
*
|
||||
* Detection is kind-then-identity: ParticipantKind.SIP is the canonical
|
||||
* signal but we also accept identities prefixed `sip_` so the UI keeps
|
||||
* working if a gateway revision forgets to set the kind enum.
|
||||
*/
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Participant, ParticipantKind } from 'livekit-client'
|
||||
import { RiLockFill } from '@remixicon/react'
|
||||
|
||||
import { useRoomData } from '@/features/rooms/livekit/hooks/useRoomData'
|
||||
import { useIsAdminOrOwner } from '@/features/rooms/livekit/hooks/useIsAdminOrOwner'
|
||||
|
||||
function isSipParticipant(p: Participant): boolean {
|
||||
if (p.kind === ParticipantKind.SIP) return true
|
||||
return p.identity.startsWith('sip_')
|
||||
}
|
||||
|
||||
interface Props {
|
||||
participant: Participant
|
||||
}
|
||||
|
||||
export function SipBlockedTileOverlay({ participant }: Props) {
|
||||
const { t } = useTranslation('rooms', { keyPrefix: 'encryption.sipBlocked' })
|
||||
const room = useRoomData()
|
||||
const isAdmin = useIsAdminOrOwner()
|
||||
|
||||
const liveEncryption =
|
||||
!!room && !!room.is_encrypted && !room.encryption_paused
|
||||
if (!liveEncryption) return null
|
||||
if (!isSipParticipant(participant)) return null
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
bottom: '2.5rem',
|
||||
left: '50%',
|
||||
transform: 'translateX(-50%)',
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.75)',
|
||||
borderRadius: '0.5rem',
|
||||
padding: '0.6rem 1rem',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
gap: '0.4rem',
|
||||
maxWidth: '85%',
|
||||
zIndex: 4,
|
||||
pointerEvents: 'auto',
|
||||
}}
|
||||
role="status"
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '0.4rem',
|
||||
color: '#fbbf24',
|
||||
fontSize: '0.85rem',
|
||||
fontWeight: 600,
|
||||
}}
|
||||
>
|
||||
<RiLockFill size={14} />
|
||||
<span>{t('title')}</span>
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
color: '#d1d5db',
|
||||
fontSize: '0.75rem',
|
||||
textAlign: 'center',
|
||||
lineHeight: 1.4,
|
||||
maxWidth: '22rem',
|
||||
}}
|
||||
>
|
||||
{isAdmin ? t('bodyAdmin') : t('bodyParticipant')}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { createContext } from 'react'
|
||||
import {
|
||||
EncryptionPhase,
|
||||
EncryptionStatusContextValue,
|
||||
} from './encryptionStatusTypes'
|
||||
|
||||
const noopContext: EncryptionStatusContextValue = {
|
||||
phase: EncryptionPhase.UNENCRYPTED,
|
||||
pausedByMe: false,
|
||||
pauseEncryption: async () => false,
|
||||
resumeEncryption: async () => false,
|
||||
}
|
||||
|
||||
export const EncryptionStatusContext =
|
||||
createContext<EncryptionStatusContextValue>(noopContext)
|
||||
@@ -0,0 +1,32 @@
|
||||
export enum EncryptionPhase {
|
||||
UNENCRYPTED = 'unencrypted',
|
||||
ENCRYPTED = 'encrypted',
|
||||
PAUSED = 'paused',
|
||||
}
|
||||
|
||||
export type PauseReason =
|
||||
| 'recording'
|
||||
| 'transcript'
|
||||
| 'manual'
|
||||
| 'sip_participant'
|
||||
|
||||
export interface EncryptionStatus {
|
||||
phase: EncryptionPhase
|
||||
pauseReason?: PauseReason
|
||||
/** True when the local participant initiated the current pause. */
|
||||
pausedByMe: boolean
|
||||
}
|
||||
|
||||
export interface EncryptionStatusContextValue extends EncryptionStatus {
|
||||
/**
|
||||
* Pause encryption for this session and notify the rest of the room.
|
||||
* Returns true on success.
|
||||
*/
|
||||
pauseEncryption: (reason: PauseReason) => Promise<boolean>
|
||||
/**
|
||||
* Resume encryption after a pause. Returns true on success. Only the
|
||||
* participant who initiated the pause (or anyone meeting the legitimacy
|
||||
* rules) can resume.
|
||||
*/
|
||||
resumeEncryption: () => Promise<boolean>
|
||||
}
|
||||
@@ -4,7 +4,15 @@ export {
|
||||
getPassphraseFromHash,
|
||||
PASSPHRASE_LENGTH,
|
||||
} from './passphrase'
|
||||
export { EncryptionStatusProvider } from './EncryptionStatusContext'
|
||||
export { useEncryptionStatus } from './useEncryptionStatus'
|
||||
export { EncryptionPhase } from './encryptionStatusTypes'
|
||||
export type { EncryptionStatus, PauseReason } from './encryptionStatusTypes'
|
||||
export { RoomStatusBanner } from './RoomStatusBanner'
|
||||
export { FeaturePill } from './FeaturePill'
|
||||
export { EncryptionStatusSnackbars } from './EncryptionStatusSnackbars'
|
||||
export { PauseEncryptionConfirmDialog } from './PauseEncryptionConfirmDialog'
|
||||
export { IdentityBadge } from './IdentityBadge'
|
||||
export { EncryptionMismatchScreen } from './EncryptionMismatchScreen'
|
||||
export { EncryptionAutoResumeWatcher } from './EncryptionAutoResumeWatcher'
|
||||
export { SipBlockedTileOverlay } from './SipBlockedTileOverlay'
|
||||
export { DecryptionFailedTileOverlay } from './DecryptionFailedTileOverlay'
|
||||
|
||||
@@ -4,27 +4,28 @@
|
||||
* The passphrase is appended to a room URL as the hash fragment
|
||||
* (e.g. `https://meet.example.com/abc-defg-hij#<passphrase>`). The
|
||||
* server never sees it; participants share it by sharing the link.
|
||||
*
|
||||
* Encoding is plain hex so that the validator's regex matches exactly
|
||||
* what the generator produces: 48 lowercase hex characters = 192 bits
|
||||
* of entropy, no overlap with looser "looks like a passphrase" inputs.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Number of random bytes used to seed a passphrase.
|
||||
* Each byte is rendered as 2 base36 characters, so the resulting
|
||||
* passphrase is 48 characters long.
|
||||
*/
|
||||
const PASSPHRASE_BYTES = 24
|
||||
|
||||
/** Length, in characters, of a generated passphrase. */
|
||||
export const PASSPHRASE_LENGTH = PASSPHRASE_BYTES * 2
|
||||
|
||||
/** Generate a random passphrase suitable for an encrypted room. */
|
||||
/** Generate a random passphrase suitable for room E2E encryption. */
|
||||
export function generatePassphrase(): string {
|
||||
return Array.from(crypto.getRandomValues(new Uint8Array(PASSPHRASE_BYTES)))
|
||||
.map((b) => b.toString(16).padStart(2, '0'))
|
||||
.map((b) => b.toString(36).padStart(2, '0'))
|
||||
.join('')
|
||||
}
|
||||
|
||||
/** Whether a string is exactly a generator-shaped passphrase. */
|
||||
/** Whether a string looks like a valid passphrase. */
|
||||
export function isValidPassphrase(value: string): boolean {
|
||||
return value.length === PASSPHRASE_LENGTH && /^[0-9a-f]+$/.test(value)
|
||||
return value.length === PASSPHRASE_LENGTH && /^[a-z0-9]+$/.test(value)
|
||||
}
|
||||
|
||||
/** Read the current URL hash (without the leading `#`). */
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
import { useContext } from 'react'
|
||||
import { EncryptionStatusContext } from './encryptionStatusContextValue'
|
||||
|
||||
export const useEncryptionStatus = () => useContext(EncryptionStatusContext)
|
||||
@@ -1,147 +0,0 @@
|
||||
import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Button, Dialog } from '@/primitives'
|
||||
import { Checkbox } from '@/primitives/Checkbox'
|
||||
import { css } from '@/styled-system/css'
|
||||
import { HStack } from '@/styled-system/jsx'
|
||||
import { getRouteUrl } from '@/navigation/getRouteUrl'
|
||||
import { RiAlertFill, RiCheckLine, RiFileCopyLine } from '@remixicon/react'
|
||||
import { ApiRoom } from '@/features/rooms/api/ApiRoom'
|
||||
|
||||
interface Props {
|
||||
room: ApiRoom | null
|
||||
hash: string
|
||||
onOpenChange: (open: boolean) => void
|
||||
onStart: () => void
|
||||
}
|
||||
|
||||
export const ConnectionDetailsDialog = ({
|
||||
room,
|
||||
hash,
|
||||
onOpenChange,
|
||||
onStart,
|
||||
}: Props) => {
|
||||
const { t } = useTranslation('home', { keyPrefix: 'connectionDetailsDialog' })
|
||||
const [acknowledged, setAcknowledged] = useState(false)
|
||||
const [copied, setCopied] = useState(false)
|
||||
|
||||
if (!room) return null
|
||||
|
||||
const url = `${getRouteUrl('room', room.slug)}#${hash}`
|
||||
const displayUrl = url.replace(/^https?:\/\//, '')
|
||||
|
||||
const copy = async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(url)
|
||||
setCopied(true)
|
||||
window.setTimeout(() => setCopied(false), 2000)
|
||||
} catch (err) {
|
||||
console.error('copy failed', err)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
isOpen={!!room}
|
||||
onOpenChange={onOpenChange}
|
||||
title={t('title')}
|
||||
role="dialog"
|
||||
>
|
||||
<p
|
||||
className={css({
|
||||
fontSize: '0.9rem',
|
||||
color: 'greyscale.700',
|
||||
marginBottom: '1rem',
|
||||
})}
|
||||
>
|
||||
{t('description')}
|
||||
</p>
|
||||
<div
|
||||
className={css({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '0.5rem',
|
||||
padding: '0.6rem 0.9rem',
|
||||
borderRadius: '0.5rem',
|
||||
border: '1px solid',
|
||||
borderColor: 'greyscale.250',
|
||||
backgroundColor: 'white',
|
||||
marginBottom: '1rem',
|
||||
})}
|
||||
>
|
||||
<span
|
||||
className={css({
|
||||
flexGrow: 1,
|
||||
fontFamily: 'monospace',
|
||||
fontSize: '0.8rem',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
})}
|
||||
>
|
||||
{displayUrl}
|
||||
</span>
|
||||
<Button
|
||||
variant={copied ? 'success' : 'tertiaryText'}
|
||||
square
|
||||
size="sm"
|
||||
onPress={copy}
|
||||
aria-label={t('copy')}
|
||||
tooltip={t('copy')}
|
||||
>
|
||||
{copied ? <RiCheckLine size={16} /> : <RiFileCopyLine size={16} />}
|
||||
</Button>
|
||||
</div>
|
||||
<div
|
||||
className={css({
|
||||
display: 'flex',
|
||||
gap: '0.5rem',
|
||||
padding: '0.75rem 0.9rem',
|
||||
borderRadius: '0.5rem',
|
||||
backgroundColor: '#fff7ed',
|
||||
border: '1px solid #fed7aa',
|
||||
marginBottom: '1rem',
|
||||
alignItems: 'flex-start',
|
||||
})}
|
||||
>
|
||||
<RiAlertFill
|
||||
size={18}
|
||||
color="#b45309"
|
||||
className={css({ flexShrink: 0 })}
|
||||
/>
|
||||
<div className={css({ flex: 1 })}>
|
||||
<p
|
||||
className={css({
|
||||
fontSize: '0.85rem',
|
||||
color: '#7c2d12',
|
||||
lineHeight: 1.4,
|
||||
marginBottom: '0.5rem',
|
||||
})}
|
||||
>
|
||||
{t('warning')}
|
||||
</p>
|
||||
<Checkbox
|
||||
isSelected={acknowledged}
|
||||
onChange={setAcknowledged}
|
||||
className={css({
|
||||
fontSize: '0.9rem',
|
||||
color: '#7c2d12',
|
||||
})}
|
||||
>
|
||||
{t('iUnderstand')}
|
||||
</Checkbox>
|
||||
</div>
|
||||
</div>
|
||||
<HStack gap="0.5rem" justify="flex-end">
|
||||
<Button
|
||||
variant="primary"
|
||||
isDisabled={!acknowledged}
|
||||
onPress={onStart}
|
||||
data-attr="encrypted-start"
|
||||
>
|
||||
{t('startMeeting')}
|
||||
</Button>
|
||||
</HStack>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -1,92 +0,0 @@
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Button, Dialog } from '@/primitives'
|
||||
import { HStack } from '@/styled-system/jsx'
|
||||
import { css } from '@/styled-system/css'
|
||||
import {
|
||||
RiPhoneLine,
|
||||
RiComputerLine,
|
||||
RiFileTextLine,
|
||||
RiRecordCircleLine,
|
||||
} from '@remixicon/react'
|
||||
import { FeaturePill } from '@/features/encryption'
|
||||
|
||||
interface Props {
|
||||
isOpen: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
onConfirm: () => void
|
||||
}
|
||||
|
||||
export const CreateEncryptedMeetingDialog = ({
|
||||
isOpen,
|
||||
onOpenChange,
|
||||
onConfirm,
|
||||
}: Props) => {
|
||||
const { t } = useTranslation('home', {
|
||||
keyPrefix: 'createEncryptedMeetingDialog',
|
||||
})
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
isOpen={isOpen}
|
||||
onOpenChange={onOpenChange}
|
||||
title={t('title')}
|
||||
role="dialog"
|
||||
>
|
||||
<p
|
||||
className={css({
|
||||
fontSize: '0.9rem',
|
||||
color: 'greyscale.700',
|
||||
marginBottom: '0.75rem',
|
||||
})}
|
||||
>
|
||||
{t('description')}
|
||||
</p>
|
||||
<div
|
||||
className={css({
|
||||
display: 'flex',
|
||||
flexWrap: 'wrap',
|
||||
gap: '0.5rem',
|
||||
marginBottom: '1rem',
|
||||
})}
|
||||
>
|
||||
<FeaturePill
|
||||
icon={<RiPhoneLine size={14} />}
|
||||
label={t('features.dialIn')}
|
||||
/>
|
||||
<FeaturePill
|
||||
icon={<RiComputerLine size={14} />}
|
||||
label={t('features.meetingRoom')}
|
||||
/>
|
||||
<FeaturePill
|
||||
icon={<RiFileTextLine size={14} />}
|
||||
label={t('features.transcription')}
|
||||
/>
|
||||
<FeaturePill
|
||||
icon={<RiRecordCircleLine size={14} />}
|
||||
label={t('features.recording')}
|
||||
/>
|
||||
</div>
|
||||
<p
|
||||
className={css({
|
||||
fontSize: '0.85rem',
|
||||
color: 'greyscale.700',
|
||||
marginBottom: '1.25rem',
|
||||
})}
|
||||
>
|
||||
{t('warning')}
|
||||
</p>
|
||||
<HStack gap="0.5rem" justify="flex-end">
|
||||
<Button variant="tertiary" onPress={() => onOpenChange(false)}>
|
||||
{t('cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
onPress={onConfirm}
|
||||
data-attr="create-encrypted-confirm"
|
||||
>
|
||||
{t('confirm')}
|
||||
</Button>
|
||||
</HStack>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -6,8 +6,6 @@ import { navigateTo } from '@/navigation/navigateTo'
|
||||
import { isRoomValid } from '@/features/rooms'
|
||||
import { normalizeRoomId } from '@/features/rooms/utils/isRoomValid'
|
||||
import { fetchRoom } from '@/features/rooms/api/fetchRoom'
|
||||
import { isValidPassphrase } from '@/features/encryption'
|
||||
import { ApiEncryptionMode } from '@/features/rooms/api/ApiRoom'
|
||||
|
||||
export const JoinMeetingDialog = () => {
|
||||
const { t } = useTranslation('home')
|
||||
@@ -33,14 +31,15 @@ export const JoinMeetingDialog = () => {
|
||||
const parsed = parseInput(input)
|
||||
|
||||
if (parsed.hash) {
|
||||
navigateTo('room', parsed.roomId, { hash: parsed.hash })
|
||||
navigateTo('room', parsed.roomId)
|
||||
window.location.hash = parsed.hash
|
||||
return
|
||||
}
|
||||
|
||||
setIsLoading(true)
|
||||
try {
|
||||
const room = await fetchRoom({ roomId: parsed.roomId })
|
||||
if (room.encryption_mode === ApiEncryptionMode.BASIC) {
|
||||
if (room.is_encrypted) {
|
||||
setRoomId(parsed.roomId)
|
||||
setStep('passphrase')
|
||||
return
|
||||
@@ -54,42 +53,32 @@ export const JoinMeetingDialog = () => {
|
||||
}
|
||||
}
|
||||
|
||||
const handlePassphraseSubmit = (data: {
|
||||
passphrase?: FormDataEntryValue
|
||||
}) => {
|
||||
const handlePassphraseSubmit = (data: { passphrase?: FormDataEntryValue }) => {
|
||||
const passphrase = (data.passphrase as string).trim()
|
||||
navigateTo('room', roomId, { hash: passphrase })
|
||||
navigateTo('room', roomId)
|
||||
window.location.hash = passphrase
|
||||
}
|
||||
|
||||
const validateRoomId = (value: string) => {
|
||||
const trimmed = value.trim()
|
||||
if (!trimmed) return null
|
||||
const { roomId: id, hash } = parseInput(trimmed)
|
||||
if (!isRoomValid(id))
|
||||
return (
|
||||
<>
|
||||
<p>{t('joinInputError')}</p>
|
||||
<Ul>
|
||||
<li>{window.location.origin}/uio-azer-jkl</li>
|
||||
<li>uio-azer-jkl</li>
|
||||
<li>uioazerjkl</li>
|
||||
</Ul>
|
||||
</>
|
||||
)
|
||||
// If a hash is pasted in, refuse malformed passphrases now (instead of
|
||||
// letting Conference render the mismatch screen after navigation).
|
||||
if (hash && !isValidPassphrase(hash))
|
||||
return <p>{t('joinPassphraseInvalidFormat')}</p>
|
||||
return null
|
||||
const { roomId: id } = parseInput(trimmed)
|
||||
return !isRoomValid(id) ? (
|
||||
<>
|
||||
<p>{t('joinInputError')}</p>
|
||||
<Ul>
|
||||
<li>{window.location.origin}/uio-azer-jkl</li>
|
||||
<li>uio-azer-jkl</li>
|
||||
<li>uioazerjkl</li>
|
||||
</Ul>
|
||||
</>
|
||||
) : null
|
||||
}
|
||||
|
||||
if (step === 'passphrase') {
|
||||
return (
|
||||
<Dialog title={t('joinMeeting')}>
|
||||
<Form
|
||||
onSubmit={handlePassphraseSubmit}
|
||||
submitLabel={t('joinPassphraseSubmit')}
|
||||
>
|
||||
<Form onSubmit={handlePassphraseSubmit} submitLabel={t('joinPassphraseSubmit')}>
|
||||
<P
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: t('joinPassphraseDescription', {
|
||||
@@ -130,12 +119,9 @@ export const JoinMeetingDialog = () => {
|
||||
isRequired
|
||||
name="passphrase"
|
||||
label={t('joinPassphraseLabel')}
|
||||
validate={(value: string) => {
|
||||
const v = (value || '').trim()
|
||||
if (!v) return t('joinPassphraseError')
|
||||
if (!isValidPassphrase(v)) return t('joinPassphraseInvalidFormat')
|
||||
return null
|
||||
}}
|
||||
validate={(value: string) =>
|
||||
!value ? t('joinPassphraseError') : null
|
||||
}
|
||||
/>
|
||||
|
||||
<P
|
||||
@@ -154,10 +140,7 @@ export const JoinMeetingDialog = () => {
|
||||
|
||||
return (
|
||||
<Dialog title={t('joinMeeting')}>
|
||||
<Form
|
||||
onSubmit={handleRoomSubmit}
|
||||
submitLabel={isLoading ? '...' : t('joinInputSubmit')}
|
||||
>
|
||||
<Form onSubmit={handleRoomSubmit} submitLabel={isLoading ? '...' : t('joinInputSubmit')}>
|
||||
{/* eslint-disable jsx-a11y/no-autofocus -- Focus on input when modal opens, required for accessibility */}
|
||||
<Field
|
||||
type="text"
|
||||
|
||||
@@ -13,11 +13,12 @@ import { useCopyRoomToClipboard } from '@/features/rooms/livekit/hooks/useCopyRo
|
||||
// fixme - duplication with the InviteDialog
|
||||
export const LaterMeetingDialog = ({
|
||||
room,
|
||||
hash,
|
||||
...dialogProps
|
||||
}: { room: null | ApiRoom } & Omit<DialogProps, 'title'>) => {
|
||||
}: { room: null | ApiRoom; hash?: string } & Omit<DialogProps, 'title'>) => {
|
||||
const { t } = useTranslation('home', { keyPrefix: 'laterMeetingDialog' })
|
||||
|
||||
const roomUrl = room ? getRouteUrl('room', room.slug) : null
|
||||
const roomUrl = room ? `${getRouteUrl('room', room.slug)}${hash ? `#${hash}` : ''}` : null
|
||||
const telephony = useTelephony()
|
||||
|
||||
const [isHovered, setIsHovered] = useState(false)
|
||||
@@ -31,7 +32,7 @@ export const LaterMeetingDialog = ({
|
||||
copyRoomToClipboard,
|
||||
isRoomUrlCopied,
|
||||
copyRoomUrlToClipboard,
|
||||
} = useCopyRoomToClipboard(room || undefined)
|
||||
} = useCopyRoomToClipboard(room || undefined, hash)
|
||||
|
||||
return (
|
||||
<Dialog isOpen={!!room} {...dialogProps} title={t('heading')}>
|
||||
|
||||
@@ -1,10 +1,5 @@
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import {
|
||||
DialogTrigger,
|
||||
MenuItem,
|
||||
Menu as RACMenu,
|
||||
Separator as RACSeparator,
|
||||
} from 'react-aria-components'
|
||||
import { DialogTrigger, MenuItem, Menu as RACMenu } from 'react-aria-components'
|
||||
import { Button, Menu } from '@/primitives'
|
||||
import { styled } from '@/styled-system/jsx'
|
||||
import { navigateTo } from '@/navigation/navigateTo'
|
||||
@@ -12,12 +7,9 @@ import { Screen } from '@/layout/Screen'
|
||||
import { generateRoomId, useCreateRoom } from '@/features/rooms'
|
||||
import { useUser, UserAware } from '@/features/auth'
|
||||
import { JoinMeetingDialog } from '../components/JoinMeetingDialog'
|
||||
import { RiAddLine, RiLink, RiShieldCrossLine } from '@remixicon/react'
|
||||
import { RiAddLine, RiLink } from '@remixicon/react'
|
||||
import { LaterMeetingDialog } from '@/features/home/components/LaterMeetingDialog'
|
||||
import { CreateEncryptedMeetingDialog } from '@/features/home/components/CreateEncryptedMeetingDialog'
|
||||
import { ConnectionDetailsDialog } from '@/features/home/components/ConnectionDetailsDialog'
|
||||
import { generatePassphrase } from '@/features/encryption'
|
||||
import { ApiEncryptionMode } from '@/features/rooms/api/ApiRoom'
|
||||
import { IntroSlider } from '@/features/home/components/IntroSlider'
|
||||
import { MoreLink } from '@/features/home/components/MoreLink'
|
||||
import { ReactNode, useEffect, useState } from 'react'
|
||||
@@ -164,33 +156,18 @@ export const Home = () => {
|
||||
} = usePersistentUserChoices()
|
||||
|
||||
const { mutateAsync: createRoom } = useCreateRoom()
|
||||
const [laterRoom, setLaterRoom] = useState<null | { room: ApiRoom }>(null)
|
||||
const [encryptedRoom, setEncryptedRoom] = useState<null | {
|
||||
room: ApiRoom
|
||||
hash: string
|
||||
}>(null)
|
||||
const [showEncryptedConfirm, setShowEncryptedConfirm] = useState(false)
|
||||
const [laterRoom, setLaterRoom] = useState<null | { room: ApiRoom; hash?: string }>(null)
|
||||
const [redirectFailed, setRedirectFailed] = useState(false)
|
||||
|
||||
const { data } = useConfig()
|
||||
// The encrypted dropdown entry is offered only when:
|
||||
// - the server has encryption enabled (instance config), AND
|
||||
// - the user opted into the feature in their preferences.
|
||||
// "Instant meeting" and "Later date" stay plain regardless — encryption
|
||||
// is always an explicit, opt-in flow with its own confirmation modal.
|
||||
const encryptionAvailable =
|
||||
!!data?.encryption?.enabled &&
|
||||
user?.default_encryption_mode === ApiEncryptionMode.BASIC
|
||||
const encryptionAvailable = !!data?.encryption?.enabled
|
||||
const defaultEncryption = encryptionAvailable && !!user?.default_encryption
|
||||
|
||||
const buildRoomBundle = async (
|
||||
encryptionMode: ApiEncryptionMode = ApiEncryptionMode.NONE
|
||||
) => {
|
||||
const buildRoomBundle = async () => {
|
||||
const slug = generateRoomId()
|
||||
const hash =
|
||||
encryptionMode === ApiEncryptionMode.BASIC
|
||||
? generatePassphrase()
|
||||
: undefined
|
||||
const room = await createRoom({ slug, username, encryptionMode })
|
||||
const isEncrypted = defaultEncryption
|
||||
const hash = isEncrypted ? generatePassphrase() : undefined
|
||||
const room = await createRoom({ slug, username, isEncrypted })
|
||||
return { room, hash }
|
||||
}
|
||||
|
||||
@@ -244,10 +221,17 @@ export const Home = () => {
|
||||
menuRecipe({ icon: true, variant: 'light' }).item
|
||||
}
|
||||
onAction={async () => {
|
||||
const { room } = await buildRoomBundle()
|
||||
const { room, hash } = await buildRoomBundle()
|
||||
navigateTo('room', room.slug, {
|
||||
state: { create: true, initialRoomData: room },
|
||||
})
|
||||
if (hash) {
|
||||
window.history.replaceState(
|
||||
window.history.state,
|
||||
'',
|
||||
`${window.location.pathname}#${hash}`
|
||||
)
|
||||
}
|
||||
}}
|
||||
data-attr="create-option-instant"
|
||||
>
|
||||
@@ -259,36 +243,14 @@ export const Home = () => {
|
||||
menuRecipe({ icon: true, variant: 'light' }).item
|
||||
}
|
||||
onAction={async () => {
|
||||
const { room } = await buildRoomBundle()
|
||||
setLaterRoom({ room })
|
||||
const { room, hash } = await buildRoomBundle()
|
||||
setLaterRoom({ room, hash })
|
||||
}}
|
||||
data-attr="create-option-later"
|
||||
>
|
||||
<RiLink size={18} />
|
||||
{t('createMenu.laterOption')}
|
||||
</MenuItem>
|
||||
{encryptionAvailable && (
|
||||
<>
|
||||
<RACSeparator
|
||||
className={css({
|
||||
border: 'none',
|
||||
height: '1px',
|
||||
background: 'greyscale.250',
|
||||
margin: '0.35rem 0',
|
||||
})}
|
||||
/>
|
||||
<MenuItem
|
||||
className={
|
||||
menuRecipe({ icon: true, variant: 'light' }).item
|
||||
}
|
||||
onAction={() => setShowEncryptedConfirm(true)}
|
||||
data-attr="create-option-encrypted"
|
||||
>
|
||||
<RiShieldCrossLine size={18} />
|
||||
{t('createMenu.encryptedOption')}
|
||||
</MenuItem>
|
||||
</>
|
||||
)}
|
||||
</RACMenu>
|
||||
</Menu>
|
||||
) : (
|
||||
@@ -318,35 +280,9 @@ export const Home = () => {
|
||||
</Columns>
|
||||
<LaterMeetingDialog
|
||||
room={laterRoom?.room ?? null}
|
||||
hash={laterRoom?.hash}
|
||||
onOpenChange={() => setLaterRoom(null)}
|
||||
/>
|
||||
<CreateEncryptedMeetingDialog
|
||||
isOpen={showEncryptedConfirm}
|
||||
onOpenChange={setShowEncryptedConfirm}
|
||||
onConfirm={async () => {
|
||||
setShowEncryptedConfirm(false)
|
||||
const { room, hash } = await buildRoomBundle(
|
||||
ApiEncryptionMode.BASIC
|
||||
)
|
||||
if (hash) setEncryptedRoom({ room, hash })
|
||||
}}
|
||||
/>
|
||||
<ConnectionDetailsDialog
|
||||
room={encryptedRoom?.room ?? null}
|
||||
hash={encryptedRoom?.hash ?? ''}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) setEncryptedRoom(null)
|
||||
}}
|
||||
onStart={() => {
|
||||
if (!encryptedRoom) return
|
||||
const { room, hash } = encryptedRoom
|
||||
setEncryptedRoom(null)
|
||||
navigateTo('room', room.slug, {
|
||||
state: { create: true, initialRoomData: room },
|
||||
hash,
|
||||
})
|
||||
}}
|
||||
/>
|
||||
</Screen>
|
||||
</UserAware>
|
||||
)
|
||||
|
||||
+2
-21
@@ -3,8 +3,7 @@ import { HStack, VStack } from '@/styled-system/jsx'
|
||||
import { Avatar } from '@/components/Avatar'
|
||||
import { Button, Text } from '@/primitives'
|
||||
import { css } from '@/styled-system/css'
|
||||
import { RiErrorWarningLine, RiInfinityLine } from '@remixicon/react'
|
||||
import { VisualOnlyTooltip } from '@/primitives/VisualOnlyTooltip'
|
||||
import { RiInfinityLine } from '@remixicon/react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { usePrevious } from '@/hooks/usePrevious'
|
||||
@@ -22,8 +21,6 @@ export const WaitingParticipantNotification = () => {
|
||||
const { t } = useTranslation('notifications', {
|
||||
keyPrefix: 'waitingParticipants',
|
||||
})
|
||||
const { t: tRooms } = useTranslation('rooms', { keyPrefix: 'identity' })
|
||||
const anonymousLabel = tRooms('anonymous.tooltip')
|
||||
|
||||
const timerRef = useRef<NodeJS.Timeout | null>(null)
|
||||
const { isParticipantsOpen, toggleParticipants } = useSidePanel()
|
||||
@@ -99,7 +96,7 @@ export const WaitingParticipantNotification = () => {
|
||||
>
|
||||
{t('one')}
|
||||
</Text>
|
||||
<HStack gap="0.5rem" alignItems="center">
|
||||
<HStack gap="0.5rem">
|
||||
<Avatar
|
||||
name={waitingParticipants[0].username}
|
||||
bgColor={waitingParticipants[0].color}
|
||||
@@ -118,22 +115,6 @@ export const WaitingParticipantNotification = () => {
|
||||
>
|
||||
{waitingParticipants[0].username}
|
||||
</Text>
|
||||
{!waitingParticipants[0].is_authenticated && (
|
||||
<VisualOnlyTooltip
|
||||
tooltip={anonymousLabel}
|
||||
ariaLabel={anonymousLabel}
|
||||
>
|
||||
<span
|
||||
className={css({
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
cursor: 'help',
|
||||
})}
|
||||
>
|
||||
<RiErrorWarningLine size={16} color="#f87171" />
|
||||
</span>
|
||||
</VisualOnlyTooltip>
|
||||
)}
|
||||
</HStack>
|
||||
<HStack gap="0.25rem" marginLeft="auto">
|
||||
<Button
|
||||
|
||||
@@ -10,11 +10,6 @@ export enum ApiAccessLevel {
|
||||
RESTRICTED = 'restricted',
|
||||
}
|
||||
|
||||
export enum ApiEncryptionMode {
|
||||
NONE = 'none',
|
||||
BASIC = 'basic',
|
||||
}
|
||||
|
||||
export type ApiRoom = {
|
||||
id: string
|
||||
name: string
|
||||
@@ -22,7 +17,8 @@ export type ApiRoom = {
|
||||
pin_code: string
|
||||
is_administrable: boolean
|
||||
access_level: ApiAccessLevel
|
||||
encryption_mode: ApiEncryptionMode
|
||||
is_encrypted: boolean
|
||||
encryption_paused: boolean
|
||||
livekit?: ApiLiveKit
|
||||
configuration?: {
|
||||
[key: string]: string | number | boolean | string[]
|
||||
|
||||
@@ -1,30 +1,28 @@
|
||||
import { useMutation, UseMutationOptions } from '@tanstack/react-query'
|
||||
import { fetchApi } from '@/api/fetchApi'
|
||||
import { ApiError } from '@/api/ApiError'
|
||||
import { ApiEncryptionMode, ApiRoom } from './ApiRoom'
|
||||
import { ApiRoom } from './ApiRoom'
|
||||
|
||||
export interface CreateRoomParams {
|
||||
slug: string
|
||||
callbackId?: string
|
||||
username?: string
|
||||
encryptionMode?: ApiEncryptionMode
|
||||
isEncrypted?: boolean
|
||||
}
|
||||
|
||||
const createRoom = ({
|
||||
slug,
|
||||
callbackId,
|
||||
username = '',
|
||||
encryptionMode = ApiEncryptionMode.NONE,
|
||||
isEncrypted = false,
|
||||
}: CreateRoomParams): Promise<ApiRoom> => {
|
||||
const queryParams = username
|
||||
? `?username=${encodeURIComponent(username)}`
|
||||
: ''
|
||||
const queryParams = username ? `?username=${encodeURIComponent(username)}` : ''
|
||||
return fetchApi(`rooms/${queryParams}`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
name: slug,
|
||||
callback_id: callbackId,
|
||||
encryption_mode: encryptionMode,
|
||||
is_encrypted: isEncrypted,
|
||||
}),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -5,7 +5,9 @@ import { ApiError } from '@/api/ApiError'
|
||||
|
||||
export type PatchRoomParams = {
|
||||
roomId: string
|
||||
room: Partial<Pick<ApiRoom, 'configuration' | 'access_level'>>
|
||||
room: Partial<
|
||||
Pick<ApiRoom, 'configuration' | 'access_level' | 'encryption_paused'>
|
||||
>
|
||||
}
|
||||
|
||||
export const patchRoom = ({ roomId, room }: PatchRoomParams) => {
|
||||
|
||||
@@ -10,13 +10,17 @@ import {
|
||||
ExternalE2EEKeyProvider,
|
||||
MediaDeviceFailure,
|
||||
Room,
|
||||
RoomEvent,
|
||||
RoomOptions,
|
||||
VideoPresets,
|
||||
} from 'livekit-client'
|
||||
import {
|
||||
generatePassphrase,
|
||||
getPassphraseFromHash,
|
||||
isValidPassphrase,
|
||||
EncryptionStatusProvider,
|
||||
EncryptionMismatchScreen,
|
||||
EncryptionPhase,
|
||||
} from '@/features/encryption'
|
||||
import { keys } from '@/api/queryKeys'
|
||||
import { queryClient } from '@/api/queryClient'
|
||||
@@ -24,8 +28,9 @@ import { Screen } from '@/layout/Screen'
|
||||
import { QueryAware } from '@/components/QueryAware'
|
||||
import { ErrorScreen } from '@/components/ErrorScreen'
|
||||
import { fetchRoom } from '../api/fetchRoom'
|
||||
import { ApiEncryptionMode, ApiRoom } from '../api/ApiRoom'
|
||||
import { ApiRoom } from '../api/ApiRoom'
|
||||
import { useCreateRoom } from '../api/createRoom'
|
||||
import { usePatchRoom } from '../api/patchRoom'
|
||||
import { InviteDialog } from './InviteDialog'
|
||||
import { VideoConference } from '../livekit/prefabs/VideoConference'
|
||||
import { css } from '@/styled-system/css'
|
||||
@@ -92,60 +97,66 @@ export const Conference = ({
|
||||
retry: false,
|
||||
})
|
||||
|
||||
// The URL hash is the *source of truth* for whether to encrypt: the
|
||||
// server is never given the passphrase, so a compromised server can't
|
||||
// fabricate or suppress encryption — it can only claim a status, and we
|
||||
// use that claim only as a sanity reference for the mismatch screen.
|
||||
// Trust the URL hash for the runtime "is encrypted" decision: it's the
|
||||
// only signal a hacked server can't fabricate. The DB flag tells us
|
||||
// whether the room creator *meant* this room to be encrypted — it's used
|
||||
// to detect mismatches (see below) but never to enable encryption alone.
|
||||
// encryption_paused is an admin override on an encrypted room: when set,
|
||||
// E2EE is suspended for this call so external devices can join, but the
|
||||
// link still carries the hash and the room can be resumed at any time.
|
||||
//
|
||||
// `hasValidHash` is synchronous (reads window.location.hash), so it's
|
||||
// either true or false on every render — never "we don't know yet".
|
||||
// Two derived flags:
|
||||
// encryptionCapable — this room has an encryption key; the Room object
|
||||
// is constructed with the e2ee worker + key provider regardless of
|
||||
// whether E2EE is currently active. Stable across mid-call pauses, so
|
||||
// the Room instance never gets recreated mid-call.
|
||||
// liveEncryption — encryption is currently active (capable AND not
|
||||
// paused). Drives room.setE2EEEnabled() and the encryption phase UI.
|
||||
const hashPassphrase = getPassphraseFromHash()
|
||||
const dbSaysEncrypted = !!data?.is_encrypted
|
||||
const isPaused = !!data?.encryption_paused
|
||||
const hasValidHash = isValidPassphrase(hashPassphrase)
|
||||
const dbSaysEncrypted = data?.encryption_mode === ApiEncryptionMode.BASIC
|
||||
|
||||
type EncryptionMismatch =
|
||||
const encryptionMismatch:
|
||||
| 'missingPassphrase'
|
||||
| 'unexpectedPassphrase'
|
||||
| null
|
||||
| null =
|
||||
data === undefined
|
||||
? null
|
||||
: dbSaysEncrypted && !isPaused && !hasValidHash
|
||||
? 'missingPassphrase'
|
||||
: !dbSaysEncrypted && hashPassphrase.length > 0
|
||||
? 'unexpectedPassphrase'
|
||||
: null
|
||||
|
||||
let encryptionMismatch: EncryptionMismatch = null
|
||||
if (data !== undefined) {
|
||||
if (dbSaysEncrypted && !hasValidHash) {
|
||||
encryptionMismatch = 'missingPassphrase'
|
||||
} else if (!dbSaysEncrypted && hashPassphrase.length > 0) {
|
||||
encryptionMismatch = 'unexpectedPassphrase'
|
||||
}
|
||||
}
|
||||
|
||||
// We treat the room as encrypted purely because we have a valid hash.
|
||||
// No server condition. If the hash is valid we MUST run E2EE; if not,
|
||||
// there's nothing to encrypt with.
|
||||
const isEncrypted = hasValidHash
|
||||
const encryptionCapable = dbSaysEncrypted && hasValidHash
|
||||
const liveEncryption = encryptionCapable && !isPaused
|
||||
// Kept as `isEncrypted` so legacy reads below stay readable. Refers to
|
||||
// the live state — flip-on-pause/flip-on-resume is wired below.
|
||||
const isEncrypted = liveEncryption
|
||||
|
||||
const keyProviderRef = useRef<ExternalE2EEKeyProvider | null>(null)
|
||||
const workerRef = useRef<Worker | null>(null)
|
||||
// `roomWithE2EE` is the actual `Room` instance for which we've already
|
||||
// run `setKey + setE2EEEnabled(true)`. Comparing it by reference with the
|
||||
// currently-memoised `room` lets us derive the "setup complete" status
|
||||
// synchronously during render — no separate boolean, no useEffect-driven
|
||||
// reset, no race window between a new Room appearing and a flag flipping.
|
||||
//
|
||||
// A device-pref change rebuilds `roomOptions` → a new `Room` instance is
|
||||
// memoised; on that same render `roomWithE2EE !== room` so the gate
|
||||
// below stays closed until the setup effect has stamped the new Room.
|
||||
const [roomWithE2EE, setRoomWithE2EE] = useState<Room | null>(null)
|
||||
const [encryptionSetupError, setEncryptionSetupError] =
|
||||
useState<Error | null>(null)
|
||||
// Setup is complete once the key provider is wired up; it does NOT need
|
||||
// to re-run when encryption_paused flips. We toggle the active state of
|
||||
// E2EE separately via room.setE2EEEnabled.
|
||||
const [encryptionSetupComplete, setEncryptionSetupComplete] = useState(
|
||||
!encryptionCapable
|
||||
)
|
||||
|
||||
const getKeyProvider = () => {
|
||||
if (!keyProviderRef.current && isEncrypted) {
|
||||
if (!keyProviderRef.current && encryptionCapable) {
|
||||
keyProviderRef.current = new ExternalE2EEKeyProvider()
|
||||
}
|
||||
return keyProviderRef.current
|
||||
}
|
||||
|
||||
const getWorker = () => {
|
||||
if (!workerRef.current && isEncrypted && typeof window !== 'undefined') {
|
||||
if (
|
||||
!workerRef.current &&
|
||||
encryptionCapable &&
|
||||
typeof window !== 'undefined'
|
||||
) {
|
||||
workerRef.current = new Worker(
|
||||
new URL('livekit-client/e2ee-worker', import.meta.url)
|
||||
)
|
||||
@@ -158,8 +169,13 @@ export const Conference = ({
|
||||
adaptiveStream: true,
|
||||
dynacast: true,
|
||||
publishDefaults: {
|
||||
videoCodec: isEncrypted ? undefined : 'vp9',
|
||||
red: !isEncrypted,
|
||||
// VP8 whenever the room is encryption-capable: encryption_paused
|
||||
// can flip mid-call to let a SIP/phone caller bridge, and the
|
||||
// gateway's room→SIP GStreamer path only handles VP8 today. Using
|
||||
// VP8 unconditionally for encryption-capable rooms keeps the Room
|
||||
// instance stable across pause/resume.
|
||||
videoCodec: encryptionCapable ? 'vp8' : 'vp8',
|
||||
red: !encryptionCapable,
|
||||
},
|
||||
videoCaptureDefaults: {
|
||||
deviceId: userConfig.videoDeviceId ?? undefined,
|
||||
@@ -175,7 +191,11 @@ export const Conference = ({
|
||||
},
|
||||
}
|
||||
|
||||
if (isEncrypted) {
|
||||
// Always wire up the E2EE worker + key provider for an encryption-
|
||||
// capable room. We toggle whether encryption is *active* with
|
||||
// room.setE2EEEnabled below; the worker stays around so resume is a
|
||||
// single API call rather than a Room reconstruction.
|
||||
if (encryptionCapable) {
|
||||
const worker = getWorker()
|
||||
const keyProvider = getKeyProvider()
|
||||
if (keyProvider && worker) {
|
||||
@@ -185,9 +205,10 @@ export const Conference = ({
|
||||
|
||||
return baseOptions
|
||||
// do not rely on the userConfig object directly as its reference may change on every render
|
||||
// getKeyProvider/getWorker are stable refs, intentionally not in deps
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [
|
||||
isEncrypted,
|
||||
encryptionCapable,
|
||||
userConfig.videoDeviceId,
|
||||
userConfig.videoPublishResolution,
|
||||
userConfig.audioDeviceId,
|
||||
@@ -196,15 +217,6 @@ export const Conference = ({
|
||||
|
||||
const room = useMemo(() => new Room(roomOptions), [roomOptions])
|
||||
|
||||
const encryptionSetupComplete = !isEncrypted || roomWithE2EE === room
|
||||
// Never let LiveKitRoom connect in an indeterminate state:
|
||||
// 1. `data` must have arrived from the server so we know whether to
|
||||
// show the mismatch screen.
|
||||
// 2. If the URL has a valid hash, the *current* Room must have already
|
||||
// been armed with setKey + setE2EEEnabled — otherwise the camera
|
||||
// goes out in clear.
|
||||
const canConnectMediaWise = data !== undefined && encryptionSetupComplete
|
||||
|
||||
/*
|
||||
* Ensure stable WebSocket connection URL. This is critical for legacy browser compatibility
|
||||
* (Firefox <124, Chrome <125, Edge <125) where HTTPS URLs in WebSocket() constructor
|
||||
@@ -219,69 +231,149 @@ export const Conference = ({
|
||||
return livekit_url
|
||||
}, [apiConfig?.livekit])
|
||||
|
||||
const isAdmin = mode === 'create' || data?.is_administrable === true
|
||||
const adminPassphraseRef = useRef<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!isEncrypted || roomWithE2EE === room) return
|
||||
if (!encryptionCapable || encryptionSetupComplete) return
|
||||
|
||||
const keyProvider = getKeyProvider()
|
||||
if (!keyProvider) return
|
||||
|
||||
// `isEncrypted === hasValidHash`, so by the time we get here the URL
|
||||
// already carries a valid passphrase. Hash generation happens upstream
|
||||
// (in `Home.tsx` for new encrypted meetings); we just read it here.
|
||||
const passphrase = getPassphraseFromHash()
|
||||
if (!passphrase) return
|
||||
let passphrase: string | null = null
|
||||
|
||||
if (isAdmin) {
|
||||
if (!adminPassphraseRef.current) {
|
||||
const existingHash = getPassphraseFromHash()
|
||||
if (existingHash) {
|
||||
adminPassphraseRef.current = existingHash
|
||||
} else {
|
||||
adminPassphraseRef.current = generatePassphrase()
|
||||
window.history.replaceState(
|
||||
window.history.state,
|
||||
'',
|
||||
`${window.location.pathname}${window.location.search}#${adminPassphraseRef.current}`
|
||||
)
|
||||
}
|
||||
}
|
||||
passphrase = adminPassphraseRef.current
|
||||
} else {
|
||||
passphrase = getPassphraseFromHash() || null
|
||||
}
|
||||
|
||||
if (!passphrase) {
|
||||
console.error('[Encryption] No passphrase available')
|
||||
return
|
||||
}
|
||||
|
||||
// Must only stamp the room as "armed" after the chain has actually
|
||||
// succeeded. If `setE2EEEnabled` rejects we surface the failure to
|
||||
// the user via `encryptionSetupError` and stay disconnected.
|
||||
let cancelled = false
|
||||
keyProvider
|
||||
.setKey(passphrase)
|
||||
.then(() => room.setE2EEEnabled(true))
|
||||
.then(() => {
|
||||
if (!cancelled) setRoomWithE2EE(room)
|
||||
.then(async () => {
|
||||
// Enable E2EE BEFORE connecting — sets encryptionType=GCM so tracks
|
||||
// are published with encryption metadata from the start. If the
|
||||
// room is currently paused, we set up the worker but leave E2EE
|
||||
// disabled (resume flips it on without rebuilding the Room).
|
||||
try {
|
||||
await room.setE2EEEnabled(liveEncryption)
|
||||
} catch (err) {
|
||||
console.error('[Encryption] E2EE enable failed:', err)
|
||||
}
|
||||
|
||||
setEncryptionSetupComplete(true)
|
||||
})
|
||||
.catch((err) => {
|
||||
if (cancelled) return
|
||||
console.error('[Encryption] setup failed:', err)
|
||||
setEncryptionSetupError(
|
||||
err instanceof Error ? err : new Error(String(err))
|
||||
)
|
||||
console.error('[Encryption] Key setup failed:', err)
|
||||
})
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
// getKeyProvider is a stable ref; not part of deps
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [room, isEncrypted, roomWithE2EE])
|
||||
}, [room, encryptionCapable, encryptionSetupComplete, isAdmin])
|
||||
|
||||
// Mid-call admin toggle: when the server flips encryption_paused, every
|
||||
// browser client sees the change as `liveEncryption` flipping. A single
|
||||
// setE2EEEnabled call is enough — livekit-client internally calls
|
||||
// republishAllTracks so the SFU forwards tracks in the new mode (plain
|
||||
// during pause so SIP can bridge; E2EE on resume).
|
||||
//
|
||||
// republishAllTracks occasionally drops a track silently when its
|
||||
// renegotiation hits a transport-state race. We snapshot the local
|
||||
// mic/camera enabled state before the toggle and re-assert them
|
||||
// afterwards so the user doesn't lose their camera or microphone over a
|
||||
// pause/resume cycle.
|
||||
const prevLiveRef = useRef<boolean | null>(null)
|
||||
useEffect(() => {
|
||||
if (!data) return
|
||||
let currentHash = getPassphraseFromHash()
|
||||
const onHashChange = () => {
|
||||
const next = getPassphraseFromHash()
|
||||
if (next === currentHash) return
|
||||
currentHash = next
|
||||
if (!encryptionCapable || !encryptionSetupComplete) return
|
||||
if (prevLiveRef.current === null) {
|
||||
prevLiveRef.current = liveEncryption
|
||||
return // initial setup already covered this value
|
||||
}
|
||||
if (prevLiveRef.current === liveEncryption) return
|
||||
prevLiveRef.current = liveEncryption
|
||||
void (async () => {
|
||||
const lp = room.localParticipant
|
||||
const camWasOn = lp.isCameraEnabled
|
||||
const micWasOn = lp.isMicrophoneEnabled
|
||||
try {
|
||||
await room.setE2EEEnabled(liveEncryption)
|
||||
} catch (err) {
|
||||
console.error('[Encryption] mid-call E2EE toggle failed', err)
|
||||
return
|
||||
}
|
||||
// Re-assert track state — republishAllTracks may have silently
|
||||
// dropped one of them mid-renegotiation.
|
||||
try {
|
||||
if (camWasOn && !lp.isCameraEnabled) {
|
||||
await lp.setCameraEnabled(true)
|
||||
}
|
||||
if (micWasOn && !lp.isMicrophoneEnabled) {
|
||||
await lp.setMicrophoneEnabled(true)
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[Encryption] track re-assert failed', err)
|
||||
}
|
||||
})()
|
||||
}, [room, encryptionCapable, encryptionSetupComplete, liveEncryption])
|
||||
|
||||
// Listen for server-driven metadata updates (admin pausing/resuming from
|
||||
// a different client) and refresh the room query so liveEncryption above
|
||||
// reflects the new state.
|
||||
useEffect(() => {
|
||||
if (!room) return
|
||||
const onMetadataChanged = () => {
|
||||
queryClient.invalidateQueries({ queryKey: fetchKey })
|
||||
}
|
||||
room.on(RoomEvent.RoomMetadataChanged, onMetadataChanged)
|
||||
return () => {
|
||||
room.off(RoomEvent.RoomMetadataChanged, onMetadataChanged)
|
||||
}
|
||||
// fetchKey is derived from roomId; both stable
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [room])
|
||||
|
||||
// If the user changes the hash mid-session (e.g. corrects a typo), reload
|
||||
// so the new passphrase is picked up by the encryption setup.
|
||||
useEffect(() => {
|
||||
if (!encryptionCapable) return
|
||||
const handleHashChange = () => {
|
||||
window.location.reload()
|
||||
}
|
||||
window.addEventListener('hashchange', onHashChange)
|
||||
return () => window.removeEventListener('hashchange', onHashChange)
|
||||
}, [data])
|
||||
window.addEventListener('hashchange', handleHashChange)
|
||||
return () => window.removeEventListener('hashchange', handleHashChange)
|
||||
}, [encryptionCapable])
|
||||
|
||||
useEffect(() => {
|
||||
/**
|
||||
* Warm up connection to LiveKit server before joining room.
|
||||
* Use the normalized `serverUrl` (the same value the LiveKitRoom
|
||||
* will connect to after `force_wss_protocol`) so the warm-up matches
|
||||
* the actual connection target.
|
||||
* Warm up connection to LiveKit server before joining room
|
||||
*/
|
||||
const prepareConnection = async () => {
|
||||
if (!apiConfig || !serverUrl || isConnectionWarmedUp) return
|
||||
await room.prepareConnection(serverUrl)
|
||||
if (!apiConfig || isConnectionWarmedUp) return
|
||||
await room.prepareConnection(apiConfig.livekit.url)
|
||||
|
||||
if (isFireFox() && apiConfig.livekit.enable_firefox_proxy_workaround) {
|
||||
try {
|
||||
const wssUrl =
|
||||
serverUrl.replace('https://', 'wss://').replace(/\/$/, '') + '/rtc'
|
||||
apiConfig.livekit.url
|
||||
.replace('https://', 'wss://')
|
||||
.replace(/\/$/, '') + '/rtc'
|
||||
|
||||
/**
|
||||
* FIREFOX + PROXY WORKAROUND — see livekit-examples/meet/issues/466
|
||||
@@ -296,7 +388,27 @@ export const Conference = ({
|
||||
setIsConnectionWarmedUp(true)
|
||||
}
|
||||
prepareConnection()
|
||||
}, [room, apiConfig, serverUrl, isConnectionWarmedUp])
|
||||
}, [room, apiConfig, isConnectionWarmedUp])
|
||||
|
||||
const { mutateAsync: patchRoom } = usePatchRoom()
|
||||
const setServerEncryptionPaused = async (paused: boolean) => {
|
||||
await patchRoom({ roomId, room: { encryption_paused: paused } })
|
||||
}
|
||||
|
||||
const handlePhaseChange = (phase: EncryptionPhase) => {
|
||||
if (!encryptionCapable) return
|
||||
if (phase === EncryptionPhase.PAUSED) {
|
||||
void room.setE2EEEnabled(false).catch((err) => {
|
||||
console.error('[Encryption] E2EE pause failed', err)
|
||||
})
|
||||
} else if (phase === EncryptionPhase.ENCRYPTED) {
|
||||
// Resume path: re-enable E2EE with the same URL passphrase that's
|
||||
// already loaded into the keyProvider.
|
||||
void room.setE2EEEnabled(true).catch((err) => {
|
||||
console.error('[Encryption] E2EE resume failed', err)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const [showInviteDialog, setShowInviteDialog] = useState(mode === 'create')
|
||||
const [mediaDeviceError, setMediaDeviceError] = useState<{
|
||||
@@ -323,15 +435,6 @@ export const Conference = ({
|
||||
return <EncryptionMismatchScreen reason={encryptionMismatch} />
|
||||
}
|
||||
|
||||
if (encryptionSetupError) {
|
||||
return (
|
||||
<ErrorScreen
|
||||
title={t('error.encryptionSetup.heading')}
|
||||
body={t('error.encryptionSetup.body')}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
// Some clients (like DINUM) operate in bandwidth-constrained environments
|
||||
const connectOptions = {
|
||||
maxRetries: 5,
|
||||
@@ -345,7 +448,7 @@ export const Conference = ({
|
||||
room={room}
|
||||
serverUrl={serverUrl}
|
||||
token={data?.livekit?.token}
|
||||
connect={isConnectionWarmedUp && canConnectMediaWise}
|
||||
connect={isConnectionWarmedUp && encryptionSetupComplete}
|
||||
audio={userConfig.audioEnabled}
|
||||
video={
|
||||
userConfig.videoEnabled && {
|
||||
@@ -384,7 +487,13 @@ export const Conference = ({
|
||||
}
|
||||
}}
|
||||
>
|
||||
<VideoConference />
|
||||
<EncryptionStatusProvider
|
||||
isEncrypted={isEncrypted}
|
||||
onPhaseChange={handlePhaseChange}
|
||||
setServerEncryptionPaused={setServerEncryptionPaused}
|
||||
>
|
||||
<VideoConference />
|
||||
</EncryptionStatusProvider>
|
||||
{showInviteDialog && !isMobile && (
|
||||
<InviteDialog
|
||||
isOpen={showInviteDialog}
|
||||
|
||||
@@ -5,19 +5,15 @@ import { HStack, styled, VStack } from '@/styled-system/jsx'
|
||||
import { Heading, Dialog } from 'react-aria-components'
|
||||
import { Text, text } from '@/primitives/Text'
|
||||
import {
|
||||
RiAlertFill,
|
||||
RiCheckLine,
|
||||
RiCloseLine,
|
||||
RiComputerLine,
|
||||
RiFileCopyLine,
|
||||
RiPhoneLine,
|
||||
RiSpam2Fill,
|
||||
} from '@remixicon/react'
|
||||
import { useMemo } from 'react'
|
||||
import { css } from '@/styled-system/css'
|
||||
import { useRoomData } from '@/features/rooms/livekit/hooks/useRoomData'
|
||||
import { FeaturePill } from '@/features/encryption'
|
||||
import { ApiAccessLevel, ApiEncryptionMode } from '@/features/rooms/api/ApiRoom'
|
||||
import { ApiAccessLevel } from '@/features/rooms/api/ApiRoom'
|
||||
import { useTelephony } from '@/features/rooms/livekit/hooks/useTelephony'
|
||||
import { formatPinCode } from '@/features/rooms/utils/telephony'
|
||||
import { useCopyRoomToClipboard } from '@/features/rooms/livekit/hooks/useCopyRoomToClipboard'
|
||||
@@ -45,16 +41,8 @@ const StyledRACDialog = styled(Dialog, {
|
||||
|
||||
export const InviteDialog = (props: Omit<DialogProps, 'title'>) => {
|
||||
const { t } = useTranslation('rooms', { keyPrefix: 'shareDialog' })
|
||||
const { t: tHome } = useTranslation('home', {
|
||||
keyPrefix: 'connectionDetailsDialog',
|
||||
})
|
||||
const { t: tFeatures } = useTranslation('home', {
|
||||
keyPrefix: 'createEncryptedMeetingDialog',
|
||||
})
|
||||
|
||||
const roomData = useRoomData()
|
||||
const isEncrypted = roomData?.encryption_mode === ApiEncryptionMode.BASIC
|
||||
const isAdminOrOwner = !!roomData?.is_administrable
|
||||
const baseRoomUrl = getRouteUrl('room', roomData?.slug)
|
||||
// Include the hash (passphrase) for basic encrypted rooms so the full link is visible
|
||||
const roomUrl = window.location.hash
|
||||
@@ -63,12 +51,9 @@ export const InviteDialog = (props: Omit<DialogProps, 'title'>) => {
|
||||
|
||||
const telephony = useTelephony()
|
||||
|
||||
// Encrypted rooms never get a working PIN (backend skips both pin_code
|
||||
// and dispatch_rule allocation), so the phone block must stay hidden
|
||||
// even if a stale pin_code somehow slipped through.
|
||||
const isTelephonyReadyForUse = useMemo(() => {
|
||||
return telephony?.enabled && roomData?.pin_code && !isEncrypted
|
||||
}, [telephony?.enabled, roomData?.pin_code, isEncrypted])
|
||||
return telephony?.enabled && roomData?.pin_code
|
||||
}, [telephony?.enabled, roomData?.pin_code])
|
||||
|
||||
const {
|
||||
isCopied,
|
||||
@@ -87,7 +72,7 @@ export const InviteDialog = (props: Omit<DialogProps, 'title'>) => {
|
||||
style={{ maxWidth: '100%', overflow: 'visible' }}
|
||||
>
|
||||
<Heading slot="title" level={2} className={text({ variant: 'h2' })}>
|
||||
{isEncrypted ? t('encryptedHeading') : t('heading')}
|
||||
{t('heading')}
|
||||
</Heading>
|
||||
<Div position="absolute" top="5" right="5">
|
||||
<Button
|
||||
@@ -103,235 +88,113 @@ export const InviteDialog = (props: Omit<DialogProps, 'title'>) => {
|
||||
<RiCloseLine />
|
||||
</Button>
|
||||
</Div>
|
||||
{isEncrypted && !isAdminOrOwner ? (
|
||||
<P>{t('encryptedGuestBody')}</P>
|
||||
) : (
|
||||
<P>{t('description')}</P>
|
||||
)}
|
||||
{(() => {
|
||||
if (isEncrypted && !isAdminOrOwner) return null
|
||||
if (isEncrypted) {
|
||||
return (
|
||||
<div
|
||||
className={css({
|
||||
width: '100%',
|
||||
marginTop: '0.5rem',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '0.75rem',
|
||||
})}
|
||||
>
|
||||
<div
|
||||
role="alert"
|
||||
className={css({
|
||||
display: 'flex',
|
||||
gap: '0.5rem',
|
||||
alignItems: 'center',
|
||||
padding: '0.6rem 0.85rem',
|
||||
borderRadius: '0.5rem',
|
||||
backgroundColor: '#fff7ed',
|
||||
border: '1px solid #fed7aa',
|
||||
color: '#7c2d12',
|
||||
})}
|
||||
>
|
||||
<RiAlertFill
|
||||
size={18}
|
||||
color="#b45309"
|
||||
className={css({ flexShrink: 0 })}
|
||||
/>
|
||||
<Text
|
||||
variant="sm"
|
||||
margin={false}
|
||||
className={css({
|
||||
color: '#7c2d12',
|
||||
fontSize: '0.85rem',
|
||||
lineHeight: 1.4,
|
||||
})}
|
||||
>
|
||||
{tHome('warning')}
|
||||
</Text>
|
||||
</div>
|
||||
<div
|
||||
className={css({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '0.5rem',
|
||||
padding: '0.5rem 0.75rem',
|
||||
borderRadius: '0.5rem',
|
||||
border: '1px solid',
|
||||
borderColor: 'greyscale.250',
|
||||
})}
|
||||
>
|
||||
<span
|
||||
className={css({
|
||||
flex: 1,
|
||||
fontFamily: 'monospace',
|
||||
fontSize: '0.8rem',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
})}
|
||||
>
|
||||
{roomUrl?.replace(/^https?:\/\//, '')}
|
||||
</span>
|
||||
<Button
|
||||
variant={isRoomUrlCopied ? 'success' : 'tertiaryText'}
|
||||
square
|
||||
size="sm"
|
||||
onPress={copyRoomUrlToClipboard}
|
||||
aria-label={isRoomUrlCopied ? t('copied') : t('copyUrl')}
|
||||
tooltip={isRoomUrlCopied ? t('copied') : t('copyUrl')}
|
||||
>
|
||||
{isRoomUrlCopied ? (
|
||||
<RiCheckLine size={16} />
|
||||
) : (
|
||||
<RiFileCopyLine size={16} />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
<Text
|
||||
margin={false}
|
||||
className={css({
|
||||
fontSize: '12px',
|
||||
fontWeight: 400,
|
||||
color: 'greyscale.500',
|
||||
})}
|
||||
>
|
||||
{t('encryptedDisabledHeading')}
|
||||
</Text>
|
||||
<div
|
||||
className={css({
|
||||
display: 'flex',
|
||||
flexWrap: 'wrap',
|
||||
gap: '0.4rem',
|
||||
})}
|
||||
>
|
||||
<FeaturePill
|
||||
size="sm"
|
||||
icon={<RiPhoneLine size={13} />}
|
||||
label={tFeatures('features.dialIn')}
|
||||
/>
|
||||
<FeaturePill
|
||||
size="sm"
|
||||
icon={<RiComputerLine size={13} />}
|
||||
label={tFeatures('features.meetingRoom')}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
if (isTelephonyReadyForUse) {
|
||||
return (
|
||||
<div
|
||||
className={css({
|
||||
width: '100%',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
marginTop: '0.5rem',
|
||||
gap: '1rem',
|
||||
overflow: 'visible',
|
||||
})}
|
||||
>
|
||||
<div
|
||||
className={css({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
})}
|
||||
>
|
||||
<Text as="p" wrap="pretty">
|
||||
{roomUrl?.replace(/^https?:\/\//, '')}
|
||||
</Text>
|
||||
{isTelephonyReadyForUse && roomUrl && (
|
||||
<Button
|
||||
variant={isRoomUrlCopied ? 'success' : 'tertiaryText'}
|
||||
square
|
||||
size={'sm'}
|
||||
onPress={copyRoomUrlToClipboard}
|
||||
aria-label={
|
||||
isRoomUrlCopied ? t('copied') : t('copyUrl')
|
||||
}
|
||||
tooltip={isRoomUrlCopied ? t('copied') : t('copyUrl')}
|
||||
>
|
||||
{isRoomUrlCopied ? (
|
||||
<RiCheckLine aria-hidden="true" />
|
||||
) : (
|
||||
<RiFileCopyLine aria-hidden="true" />
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<div
|
||||
className={css({
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
})}
|
||||
>
|
||||
<Text as="p" wrap="pretty">
|
||||
<Bold>{t('phone.call')}</Bold> ({telephony?.country}){' '}
|
||||
{telephony?.internationalPhoneNumber}
|
||||
</Text>
|
||||
<Text as="p" wrap="pretty">
|
||||
<Bold>{t('phone.pinCode')}</Bold>{' '}
|
||||
{formatPinCode(roomData?.pin_code)}
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
<P>{t('description')}</P>
|
||||
{isTelephonyReadyForUse ? (
|
||||
<div
|
||||
className={css({
|
||||
width: '100%',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
marginTop: '0.5rem',
|
||||
gap: '1rem',
|
||||
overflow: 'visible',
|
||||
})}
|
||||
>
|
||||
<div
|
||||
className={css({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
})}
|
||||
>
|
||||
<Text as="p" wrap="pretty">
|
||||
{roomUrl?.replace(/^https?:\/\//, '')}
|
||||
</Text>
|
||||
{isTelephonyReadyForUse && roomUrl && (
|
||||
<Button
|
||||
variant={isCopied ? 'success' : 'secondaryText'}
|
||||
size="sm"
|
||||
fullWidth
|
||||
aria-label={isCopied ? t('copied') : t('copy')}
|
||||
style={{
|
||||
justifyContent: 'start',
|
||||
}}
|
||||
onPress={copyRoomToClipboard}
|
||||
data-attr="share-dialog-copy"
|
||||
variant={isRoomUrlCopied ? 'success' : 'tertiaryText'}
|
||||
square
|
||||
size={'sm'}
|
||||
onPress={copyRoomUrlToClipboard}
|
||||
aria-label={isRoomUrlCopied ? t('copied') : t('copyUrl')}
|
||||
tooltip={isRoomUrlCopied ? t('copied') : t('copyUrl')}
|
||||
>
|
||||
{isCopied ? (
|
||||
<>
|
||||
<RiCheckLine
|
||||
size={18}
|
||||
style={{ marginRight: '8px' }}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{t('copied')}
|
||||
</>
|
||||
{isRoomUrlCopied ? (
|
||||
<RiCheckLine aria-hidden="true" />
|
||||
) : (
|
||||
<>
|
||||
<RiFileCopyLine
|
||||
style={{ marginRight: '6px', minWidth: '18px' }}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{t('copy')}
|
||||
</>
|
||||
<RiFileCopyLine aria-hidden="true" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return (
|
||||
)}
|
||||
</div>
|
||||
<div
|
||||
className={css({
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
})}
|
||||
>
|
||||
<Text as="p" wrap="pretty">
|
||||
<Bold>{t('phone.call')}</Bold> ({telephony?.country}){' '}
|
||||
{telephony?.internationalPhoneNumber}
|
||||
</Text>
|
||||
<Text as="p" wrap="pretty">
|
||||
<Bold>{t('phone.pinCode')}</Bold>{' '}
|
||||
{formatPinCode(roomData?.pin_code)}
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant={isCopied ? 'success' : 'tertiary'}
|
||||
variant={isCopied ? 'success' : 'secondaryText'}
|
||||
size="sm"
|
||||
fullWidth
|
||||
aria-label={isCopied ? t('copied') : t('copy')}
|
||||
style={{
|
||||
justifyContent: 'start',
|
||||
}}
|
||||
onPress={copyRoomToClipboard}
|
||||
data-attr="share-dialog-copy"
|
||||
>
|
||||
{isCopied ? (
|
||||
<>
|
||||
<RiCheckLine size={24} style={{ marginRight: '8px' }} />
|
||||
<RiCheckLine
|
||||
size={18}
|
||||
style={{ marginRight: '8px' }}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{t('copied')}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<RiFileCopyLine size={24} style={{ marginRight: '8px' }} />
|
||||
{t('copyUrl')}
|
||||
<RiFileCopyLine
|
||||
style={{ marginRight: '6px', minWidth: '18px' }}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{t('copy')}
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
)
|
||||
})()}
|
||||
</div>
|
||||
) : (
|
||||
<Button
|
||||
variant={isCopied ? 'success' : 'tertiary'}
|
||||
fullWidth
|
||||
aria-label={isCopied ? t('copied') : t('copy')}
|
||||
onPress={copyRoomToClipboard}
|
||||
data-attr="share-dialog-copy"
|
||||
>
|
||||
{isCopied ? (
|
||||
<>
|
||||
<RiCheckLine size={24} style={{ marginRight: '8px' }} />
|
||||
{t('copied')}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<RiFileCopyLine size={24} style={{ marginRight: '8px' }} />
|
||||
{t('copyUrl')}
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
{roomData?.access_level === ApiAccessLevel.PUBLIC && (
|
||||
<HStack>
|
||||
<div
|
||||
|
||||
@@ -32,15 +32,14 @@ import { useQuery } from '@tanstack/react-query'
|
||||
import { queryClient } from '@/api/queryClient'
|
||||
import { ApiLobbyStatus, ApiRequestEntry } from '../api/requestEntry'
|
||||
import { Spinner } from '@/primitives/Spinner'
|
||||
import { ApiAccessLevel, ApiEncryptionMode } from '../api/ApiRoom'
|
||||
import { ApiAccessLevel } from '../api/ApiRoom'
|
||||
import {
|
||||
isValidPassphrase,
|
||||
getPassphraseFromHash,
|
||||
EncryptionMismatchScreen,
|
||||
} from '@/features/encryption'
|
||||
import { useLoginHint } from '@/hooks/useLoginHint'
|
||||
import { RiInformationLine, RiLockLine } from '@remixicon/react'
|
||||
import { useUser } from '@/features/auth'
|
||||
import { RiInformationLine } from '@remixicon/react'
|
||||
import { openPermissionsDialog } from '@/stores/permissions'
|
||||
import { useResolveInitiallyDefaultDeviceId } from '../livekit/hooks/useResolveInitiallyDefaultDeviceId'
|
||||
import { isSafari } from '@/utils/livekit'
|
||||
@@ -118,30 +117,20 @@ export const Join = ({
|
||||
retry: false,
|
||||
})
|
||||
|
||||
const isEncryptedRoom = roomInfo?.encryption_mode === ApiEncryptionMode.BASIC
|
||||
const { user, isLoggedIn } = useUser()
|
||||
// Authenticated joiners of an encrypted room can't pick an arbitrary
|
||||
// display name — it must match the OIDC profile, and the backend
|
||||
// re-enforces this when minting the JWT.
|
||||
const isNameLocked = isEncryptedRoom && !!isLoggedIn
|
||||
const lockedName = user?.full_name || user?.email || ''
|
||||
|
||||
// Keep the passphrase in state and refresh on `hashchange` so the
|
||||
// mismatch screen recovers immediately when the user pastes the
|
||||
// correct hash into the address bar.
|
||||
const [passphrase, setPassphrase] = useState(getPassphraseFromHash)
|
||||
useEffect(() => {
|
||||
const onHashChange = () => setPassphrase(getPassphraseFromHash())
|
||||
window.addEventListener('hashchange', onHashChange)
|
||||
return () => window.removeEventListener('hashchange', onHashChange)
|
||||
}, [])
|
||||
const hasValidPassphrase = isEncryptedRoom
|
||||
const isEncryptedRoom = !!roomInfo?.is_encrypted
|
||||
const isPaused = !!roomInfo?.encryption_paused
|
||||
// Live encryption is what gates the "needs a passphrase" UX. A paused
|
||||
// encrypted room accepts hashless joiners (they join in plaintext) and
|
||||
// re-enforces the passphrase requirement once the admin resumes.
|
||||
const liveEncryption = isEncryptedRoom && !isPaused
|
||||
const passphrase = getPassphraseFromHash()
|
||||
const hasValidPassphrase = liveEncryption
|
||||
? isValidPassphrase(passphrase)
|
||||
: true
|
||||
// If the URL has a passphrase but the room itself is not encrypted, the
|
||||
// link looks tampered with — refuse to join and offer a fresh room.
|
||||
const unexpectedPassphrase =
|
||||
!!roomInfo && !isEncryptedRoom && passphrase.length > 0
|
||||
!!roomInfo && !roomInfo.is_encrypted && passphrase.length > 0
|
||||
|
||||
const {
|
||||
userChoices: {
|
||||
@@ -455,7 +444,7 @@ export const Join = ({
|
||||
if (unexpectedPassphrase) {
|
||||
return <EncryptionMismatchScreen reason="unexpectedPassphrase" />
|
||||
}
|
||||
if (isEncryptedRoom && !hasValidPassphrase) {
|
||||
if (liveEncryption && !hasValidPassphrase) {
|
||||
return <EncryptionMismatchScreen reason="missingPassphrase" />
|
||||
}
|
||||
return (
|
||||
@@ -470,74 +459,34 @@ export const Join = ({
|
||||
<H lvl={1} margin="sm" centered>
|
||||
{t('heading')}
|
||||
</H>
|
||||
{isNameLocked ? (
|
||||
<Field
|
||||
type="text"
|
||||
onChange={saveUsername}
|
||||
label={t('usernameLabel')}
|
||||
id="input-name"
|
||||
defaultValue={username}
|
||||
validate={(value) => !value && t('errors.usernameEmpty')}
|
||||
wrapperProps={{
|
||||
noMargin: true,
|
||||
fullWidth: true,
|
||||
}}
|
||||
autoComplete="name"
|
||||
maxLength={50}
|
||||
/>
|
||||
{liveEncryption && (
|
||||
<div
|
||||
className={css({
|
||||
width: '100%',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '0.25rem',
|
||||
alignItems: 'center',
|
||||
gap: '0.4rem',
|
||||
paddingTop: '0.5rem',
|
||||
})}
|
||||
>
|
||||
<Text variant="note" className={css({ fontSize: '0.8rem' })}>
|
||||
{t('usernameLabel')}
|
||||
<RiInformationLine size={14} color="#1e3a5f" />
|
||||
<Text variant="note" className={css({ fontSize: '0.75rem' })}>
|
||||
{t('encryptedHint')}
|
||||
</Text>
|
||||
<div
|
||||
className={css({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '0.5rem',
|
||||
padding: '0.5rem 0.75rem',
|
||||
backgroundColor: 'greyscale.100',
|
||||
borderRadius: '0.375rem',
|
||||
border: '1px solid',
|
||||
borderColor: 'greyscale.200',
|
||||
})}
|
||||
>
|
||||
<RiLockLine size={14} color="#6b7280" />
|
||||
<Text variant="sm" margin={false}>
|
||||
{lockedName}
|
||||
</Text>
|
||||
</div>
|
||||
<div
|
||||
className={css({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '0.4rem',
|
||||
})}
|
||||
>
|
||||
<RiInformationLine
|
||||
size={18}
|
||||
color="#6b7280"
|
||||
className={css({ flexShrink: 0 })}
|
||||
/>
|
||||
<Text
|
||||
variant="note"
|
||||
margin={false}
|
||||
className={css({
|
||||
fontSize: '0.8rem',
|
||||
color: 'greyscale.500',
|
||||
})}
|
||||
>
|
||||
{t('encryptedNameLocked')}
|
||||
</Text>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<Field
|
||||
type="text"
|
||||
onChange={saveUsername}
|
||||
label={t('usernameLabel')}
|
||||
id="input-name"
|
||||
defaultValue={username}
|
||||
validate={(value) => !value && t('errors.usernameEmpty')}
|
||||
wrapperProps={{
|
||||
noMargin: true,
|
||||
fullWidth: true,
|
||||
}}
|
||||
autoComplete="name"
|
||||
maxLength={50}
|
||||
/>
|
||||
)}
|
||||
</VStack>
|
||||
</Form>
|
||||
|
||||
@@ -56,7 +56,7 @@ export const useLobby = ({
|
||||
enabled: status === ApiLobbyStatus.WAITING,
|
||||
})
|
||||
|
||||
const startWaiting = useCallback(() => {
|
||||
const startWaiting = useCallback(async () => {
|
||||
setStatus(ApiLobbyStatus.WAITING)
|
||||
startWaitingTimeout()
|
||||
}, [startWaitingTimeout])
|
||||
|
||||
@@ -1,15 +1,17 @@
|
||||
import { Div, Field, H, Text } from '@/primitives'
|
||||
import { css } from '@/styled-system/css'
|
||||
import { Separator as RACSeparator } from 'react-aria-components'
|
||||
import { RiAlertFill } from '@remixicon/react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useRecordingStatuses } from '@/features/recording'
|
||||
import { RecordingMode } from '@/features/recording'
|
||||
import { usePatchRoom } from '@/features/rooms/api/patchRoom'
|
||||
import { ApiAccessLevel, ApiEncryptionMode } from '@/features/rooms/api/ApiRoom'
|
||||
import { fetchRoom } from '@/features/rooms/api/fetchRoom'
|
||||
import { ApiAccessLevel } from '@/features/rooms/api/ApiRoom'
|
||||
import { queryClient } from '@/api/queryClient'
|
||||
import { keys } from '@/api/queryKeys'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { useParams } from 'wouter'
|
||||
import { usePublishSourcesManager } from '@/features/rooms/livekit/hooks/usePublishSourcesManager'
|
||||
import { useRoomData } from '@/features/rooms/livekit/hooks/useRoomData'
|
||||
|
||||
export const Admin = () => {
|
||||
const { t } = useTranslation('rooms', { keyPrefix: 'admin' })
|
||||
@@ -22,7 +24,12 @@ export const Admin = () => {
|
||||
|
||||
const { mutateAsync: patchRoom } = usePatchRoom()
|
||||
|
||||
const readOnlyData = useRoomData()
|
||||
const { data: readOnlyData } = useQuery({
|
||||
queryKey: [keys.room, roomId],
|
||||
queryFn: () => fetchRoom({ roomId }),
|
||||
retry: false,
|
||||
enabled: false,
|
||||
})
|
||||
|
||||
const {
|
||||
toggleMicrophone,
|
||||
@@ -33,6 +40,25 @@ export const Admin = () => {
|
||||
isScreenShareEnabled,
|
||||
} = usePublishSourcesManager()
|
||||
|
||||
// Reasons we block the admin from re-enabling encryption mid-call:
|
||||
// - a recording is in progress (the recording server needs plaintext)
|
||||
// - a transcript is being captured (same reason)
|
||||
// SIP participants aren't a blocker — re-enabling encryption while
|
||||
// they're present moves them back into placeholder mode, and the admin
|
||||
// will see the snackbar.
|
||||
//
|
||||
// We use `isStarted` from useRecordingStatuses (metadata-driven) rather
|
||||
// than LK's `useIsRecording`. The metadata transitions through Saving
|
||||
// immediately when the user clicks stop, so the alert clears right
|
||||
// away rather than after the 1–2s LK round-trip.
|
||||
const screenRec = useRecordingStatuses(RecordingMode.ScreenRecording)
|
||||
const transcript = useRecordingStatuses(RecordingMode.Transcript)
|
||||
const resumeBlockedReason = screenRec.isStarted
|
||||
? t('encryption.blocked.recording')
|
||||
: transcript.isStarted
|
||||
? t('encryption.blocked.transcript')
|
||||
: null
|
||||
|
||||
return (
|
||||
<Div
|
||||
display="flex"
|
||||
@@ -127,6 +153,122 @@ export const Admin = () => {
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{readOnlyData?.is_encrypted && (
|
||||
<div
|
||||
className={css({
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
marginTop: '1rem',
|
||||
width: '100%',
|
||||
})}
|
||||
>
|
||||
<RACSeparator
|
||||
className={css({
|
||||
border: 'none',
|
||||
height: '1px',
|
||||
width: '100%',
|
||||
background: 'greyscale.250',
|
||||
})}
|
||||
/>
|
||||
<H
|
||||
lvl={2}
|
||||
className={css({
|
||||
fontWeight: 500,
|
||||
})}
|
||||
margin="sm"
|
||||
>
|
||||
{t('encryption.title')}
|
||||
</H>
|
||||
<Text
|
||||
variant="note"
|
||||
wrap="balance"
|
||||
className={css({
|
||||
textStyle: 'sm',
|
||||
})}
|
||||
margin={'md'}
|
||||
>
|
||||
{t('encryption.description')}
|
||||
</Text>
|
||||
{(() => {
|
||||
const resumeBlocked =
|
||||
!!readOnlyData?.encryption_paused && resumeBlockedReason != null
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className={css({
|
||||
opacity: resumeBlocked ? 0.45 : 1,
|
||||
pointerEvents: resumeBlocked ? 'none' : undefined,
|
||||
transition: 'opacity 200ms ease',
|
||||
})}
|
||||
aria-disabled={resumeBlocked || undefined}
|
||||
>
|
||||
<Field
|
||||
type="switch"
|
||||
label={t('encryption.toggle.label')}
|
||||
description={
|
||||
readOnlyData?.encryption_paused
|
||||
? t('encryption.toggle.descriptionPaused')
|
||||
: t('encryption.toggle.descriptionLive')
|
||||
}
|
||||
isSelected={!!readOnlyData?.encryption_paused}
|
||||
isDisabled={resumeBlocked}
|
||||
onChange={(paused) =>
|
||||
patchRoom({
|
||||
roomId,
|
||||
room: { encryption_paused: paused },
|
||||
})
|
||||
.then((room) => {
|
||||
queryClient.setQueryData([keys.room, roomId], room)
|
||||
})
|
||||
.catch((e) => console.error(e))
|
||||
}
|
||||
wrapperProps={{
|
||||
noMargin: true,
|
||||
fullWidth: true,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{resumeBlocked && (
|
||||
<div
|
||||
role="alert"
|
||||
className={css({
|
||||
display: 'flex',
|
||||
gap: '0.5rem',
|
||||
marginTop: '0.75rem',
|
||||
padding: '0.7rem 0.85rem',
|
||||
borderRadius: '0.5rem',
|
||||
backgroundColor: '#fff7ed',
|
||||
border: '1px solid #fed7aa',
|
||||
color: '#7c2d12',
|
||||
})}
|
||||
>
|
||||
<span
|
||||
aria-hidden
|
||||
className={css({
|
||||
fontSize: '1rem',
|
||||
lineHeight: '1.2',
|
||||
})}
|
||||
>
|
||||
⚠
|
||||
</span>
|
||||
<Text
|
||||
variant="sm"
|
||||
margin={false}
|
||||
className={css({
|
||||
color: '#7c2d12',
|
||||
fontSize: '0.85rem',
|
||||
lineHeight: 1.4,
|
||||
})}
|
||||
>
|
||||
{resumeBlockedReason}
|
||||
</Text>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
})()}
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
className={css({
|
||||
display: 'flex',
|
||||
@@ -161,99 +303,45 @@ export const Admin = () => {
|
||||
>
|
||||
{t('access.description')}
|
||||
</Text>
|
||||
{(() => {
|
||||
const isEncrypted =
|
||||
readOnlyData?.encryption_mode === ApiEncryptionMode.BASIC
|
||||
return (
|
||||
<>
|
||||
{isEncrypted && (
|
||||
<div
|
||||
role="alert"
|
||||
className={css({
|
||||
display: 'flex',
|
||||
gap: '0.5rem',
|
||||
alignItems: 'center',
|
||||
padding: '0.6rem 0.85rem',
|
||||
marginBottom: '0.75rem',
|
||||
borderRadius: '0.5rem',
|
||||
backgroundColor: '#fff7ed',
|
||||
border: '1px solid #fed7aa',
|
||||
color: '#7c2d12',
|
||||
})}
|
||||
>
|
||||
<RiAlertFill
|
||||
size={18}
|
||||
color="#b45309"
|
||||
className={css({ flexShrink: 0 })}
|
||||
/>
|
||||
<Text
|
||||
margin={false}
|
||||
className={css({
|
||||
color: '#7c2d12',
|
||||
fontSize: '0.85rem',
|
||||
lineHeight: 1.4,
|
||||
})}
|
||||
>
|
||||
{t('access.encryptedLocked')}
|
||||
</Text>
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
className={css({
|
||||
opacity: isEncrypted ? 0.7 : 1,
|
||||
pointerEvents: isEncrypted ? 'none' : undefined,
|
||||
transition: 'opacity 200ms ease',
|
||||
})}
|
||||
aria-disabled={isEncrypted || undefined}
|
||||
>
|
||||
<Field
|
||||
type="radioGroup"
|
||||
label={t('access.type')}
|
||||
aria-label={t('access.type')}
|
||||
labelProps={{
|
||||
className: css({
|
||||
fontSize: '1rem',
|
||||
paddingBottom: '1rem',
|
||||
}),
|
||||
}}
|
||||
isDisabled={isEncrypted}
|
||||
value={
|
||||
isEncrypted
|
||||
? ApiAccessLevel.RESTRICTED
|
||||
: readOnlyData?.access_level
|
||||
}
|
||||
onChange={(value) =>
|
||||
patchRoom({
|
||||
roomId,
|
||||
room: { access_level: value as ApiAccessLevel },
|
||||
})
|
||||
.then((room) => {
|
||||
queryClient.setQueryData([keys.room, roomId], room)
|
||||
})
|
||||
.catch((e) => console.error(e))
|
||||
}
|
||||
items={[
|
||||
{
|
||||
value: ApiAccessLevel.PUBLIC,
|
||||
label: t('access.levels.public.label'),
|
||||
description: t('access.levels.public.description'),
|
||||
},
|
||||
{
|
||||
value: ApiAccessLevel.TRUSTED,
|
||||
label: t('access.levels.trusted.label'),
|
||||
description: t('access.levels.trusted.description'),
|
||||
},
|
||||
{
|
||||
value: ApiAccessLevel.RESTRICTED,
|
||||
label: t('access.levels.restricted.label'),
|
||||
description: t('access.levels.restricted.description'),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
})()}
|
||||
<Field
|
||||
type="radioGroup"
|
||||
label={t('access.type')}
|
||||
aria-label={t('access.type')}
|
||||
labelProps={{
|
||||
className: css({
|
||||
fontSize: '1rem',
|
||||
paddingBottom: '1rem',
|
||||
}),
|
||||
}}
|
||||
value={readOnlyData?.access_level}
|
||||
onChange={(value) =>
|
||||
patchRoom({
|
||||
roomId,
|
||||
room: { access_level: value as ApiAccessLevel },
|
||||
})
|
||||
.then((room) => {
|
||||
queryClient.setQueryData([keys.room, roomId], room)
|
||||
})
|
||||
.catch((e) => console.error(e))
|
||||
}
|
||||
items={[
|
||||
{
|
||||
value: ApiAccessLevel.PUBLIC,
|
||||
label: t('access.levels.public.label'),
|
||||
description: t('access.levels.public.description'),
|
||||
},
|
||||
{
|
||||
value: ApiAccessLevel.TRUSTED,
|
||||
label: t('access.levels.trusted.label'),
|
||||
description: t('access.levels.trusted.description'),
|
||||
},
|
||||
{
|
||||
value: ApiAccessLevel.RESTRICTED,
|
||||
label: t('access.levels.restricted.label'),
|
||||
description: t('access.levels.restricted.description'),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
</Div>
|
||||
)
|
||||
|
||||
@@ -2,27 +2,16 @@ import { useTranslation } from 'react-i18next'
|
||||
import { useMemo } from 'react'
|
||||
import { VStack } from '@/styled-system/jsx'
|
||||
import { css } from '@/styled-system/css'
|
||||
import {
|
||||
RiAlertFill,
|
||||
RiCheckLine,
|
||||
RiComputerLine,
|
||||
RiFileCopyLine,
|
||||
RiPhoneLine,
|
||||
} from '@remixicon/react'
|
||||
import { RiCheckLine, RiFileCopyLine } from '@remixicon/react'
|
||||
import { Bold, Button, Div, Text } from '@/primitives'
|
||||
import { getRouteUrl } from '@/navigation/getRouteUrl'
|
||||
import { useRoomData } from '../hooks/useRoomData'
|
||||
import { formatPinCode } from '../../utils/telephony'
|
||||
import { useTelephony } from '../hooks/useTelephony'
|
||||
import { useCopyRoomToClipboard } from '../hooks/useCopyRoomToClipboard'
|
||||
import { FeaturePill } from '@/features/encryption'
|
||||
import { ApiEncryptionMode } from '../../api/ApiRoom'
|
||||
|
||||
export const Info = () => {
|
||||
const { t } = useTranslation('rooms', { keyPrefix: 'info' })
|
||||
const { t: tHome } = useTranslation('home', {
|
||||
keyPrefix: 'connectionDetailsDialog',
|
||||
})
|
||||
|
||||
const data = useRoomData()
|
||||
const baseRoomUrl = getRouteUrl('room', data?.slug)
|
||||
@@ -31,157 +20,13 @@ export const Info = () => {
|
||||
: baseRoomUrl
|
||||
|
||||
const telephony = useTelephony()
|
||||
const isEncrypted = data?.encryption_mode === ApiEncryptionMode.BASIC
|
||||
const isAdminOrOwner = !!data?.is_administrable
|
||||
|
||||
const isTelephonyReadyForUse = useMemo(() => {
|
||||
return telephony?.enabled && data?.pin_code && !isEncrypted
|
||||
}, [telephony?.enabled, data?.pin_code, isEncrypted])
|
||||
return telephony?.enabled && data?.pin_code
|
||||
}, [telephony?.enabled, data?.pin_code])
|
||||
|
||||
const { isCopied, copyRoomToClipboard } = useCopyRoomToClipboard(data)
|
||||
|
||||
if (isEncrypted && !isAdminOrOwner) {
|
||||
return (
|
||||
<Div
|
||||
display="flex"
|
||||
overflowY="scroll"
|
||||
padding="0 1.5rem"
|
||||
flexGrow={1}
|
||||
flexDirection="column"
|
||||
alignItems="start"
|
||||
>
|
||||
<Text as="p" variant="note" wrap="pretty">
|
||||
{t('encrypted.guestBody')}
|
||||
</Text>
|
||||
</Div>
|
||||
)
|
||||
}
|
||||
|
||||
if (isEncrypted) {
|
||||
return (
|
||||
<Div
|
||||
display="flex"
|
||||
overflowY="scroll"
|
||||
padding="0 1.5rem"
|
||||
flexGrow={1}
|
||||
flexDirection="column"
|
||||
alignItems="start"
|
||||
>
|
||||
<VStack
|
||||
alignItems="stretch"
|
||||
gap="0.75rem"
|
||||
className={css({ width: '100%' })}
|
||||
>
|
||||
<div
|
||||
role="alert"
|
||||
className={css({
|
||||
display: 'flex',
|
||||
gap: '0.5rem',
|
||||
alignItems: 'center',
|
||||
padding: '0.6rem 0.85rem',
|
||||
borderRadius: '0.5rem',
|
||||
backgroundColor: '#fff7ed',
|
||||
border: '1px solid #fed7aa',
|
||||
color: '#7c2d12',
|
||||
})}
|
||||
>
|
||||
<RiAlertFill
|
||||
size={18}
|
||||
color="#b45309"
|
||||
className={css({ flexShrink: 0 })}
|
||||
/>
|
||||
<Text
|
||||
variant="sm"
|
||||
margin={false}
|
||||
className={css({
|
||||
color: '#7c2d12',
|
||||
fontSize: '0.85rem',
|
||||
lineHeight: 1.4,
|
||||
})}
|
||||
>
|
||||
{tHome('warning')}
|
||||
</Text>
|
||||
</div>
|
||||
<Text as="p" variant="note" margin={false}>
|
||||
<Bold>{t('encrypted.linkLabel')}</Bold>
|
||||
</Text>
|
||||
<div
|
||||
className={css({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '0.5rem',
|
||||
padding: '0.5rem 0.75rem',
|
||||
borderRadius: '0.5rem',
|
||||
border: '1px solid',
|
||||
borderColor: 'greyscale.250',
|
||||
})}
|
||||
>
|
||||
<span
|
||||
className={css({
|
||||
flex: 1,
|
||||
fontFamily: 'monospace',
|
||||
fontSize: '0.8rem',
|
||||
wordBreak: 'break-all',
|
||||
})}
|
||||
>
|
||||
{roomUrl.replace(/^https?:\/\//, '')}
|
||||
</span>
|
||||
<Button
|
||||
square
|
||||
size="sm"
|
||||
variant={isCopied ? 'success' : 'tertiaryText'}
|
||||
onPress={copyRoomToClipboard}
|
||||
aria-label={
|
||||
isCopied
|
||||
? t('roomInformation.button.copied')
|
||||
: t('roomInformation.button.copy')
|
||||
}
|
||||
tooltip={
|
||||
isCopied
|
||||
? t('roomInformation.button.copied')
|
||||
: t('roomInformation.button.copy')
|
||||
}
|
||||
data-attr="copy-info-sidepannel"
|
||||
>
|
||||
{isCopied ? (
|
||||
<RiCheckLine size={16} />
|
||||
) : (
|
||||
<RiFileCopyLine size={16} />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
<Text
|
||||
as="p"
|
||||
margin={false}
|
||||
className={css({
|
||||
fontSize: '12px',
|
||||
fontWeight: 400,
|
||||
color: 'greyscale.500',
|
||||
})}
|
||||
>
|
||||
{t('encrypted.disabledHeading')}
|
||||
</Text>
|
||||
<div
|
||||
className={css({
|
||||
display: 'flex',
|
||||
flexWrap: 'wrap',
|
||||
gap: '0.5rem',
|
||||
})}
|
||||
>
|
||||
<FeaturePill
|
||||
icon={<RiPhoneLine size={14} />}
|
||||
label={t('encrypted.features.dialIn')}
|
||||
/>
|
||||
<FeaturePill
|
||||
icon={<RiComputerLine size={14} />}
|
||||
label={t('encrypted.features.meetingRoom')}
|
||||
/>
|
||||
</div>
|
||||
</VStack>
|
||||
</Div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Div
|
||||
display="flex"
|
||||
|
||||
@@ -22,7 +22,12 @@ import { Track } from 'livekit-client'
|
||||
import { RiHand } from '@remixicon/react'
|
||||
import { useRaisedHand, useRaisedHandPosition } from '../hooks/useRaisedHand'
|
||||
import { HStack } from '@/styled-system/jsx'
|
||||
import { DecryptionFailedTileOverlay } from '@/features/encryption'
|
||||
import {
|
||||
IdentityBadge,
|
||||
SipBlockedTileOverlay,
|
||||
DecryptionFailedTileOverlay,
|
||||
} from '@/features/encryption'
|
||||
import { useRoomData } from '../hooks/useRoomData'
|
||||
import { MutedMicIndicator } from './MutedMicIndicator'
|
||||
import { ParticipantPlaceholder } from './ParticipantPlaceholder'
|
||||
import { ParticipantTileFocus } from './ParticipantTileFocus'
|
||||
@@ -106,6 +111,13 @@ export const ParticipantTile: (
|
||||
|
||||
const isScreenShare = trackReference.source != Track.Source.Camera
|
||||
const [hasKeyboardFocus, setHasKeyboardFocus] = React.useState(false)
|
||||
// Show the badge in any encryption-capable room — including while the
|
||||
// room is paused. The badge reflects the *room's nature* (encrypted vs
|
||||
// unencrypted-by-design), which doesn't change when an admin temporarily
|
||||
// pauses encryption for SIP/recording/transcription.
|
||||
const tileRoomData = useRoomData()
|
||||
const showIdentityBadge =
|
||||
!isScreenShare && !!tileRoomData?.is_encrypted
|
||||
|
||||
const participantName = getParticipantName(trackReference.participant)
|
||||
const { t } = useTranslation('rooms', { keyPrefix: 'participantTileFocus' })
|
||||
@@ -218,6 +230,12 @@ export const ParticipantTile: (
|
||||
participant={trackReference.participant}
|
||||
/>
|
||||
</div>
|
||||
{showIdentityBadge && (
|
||||
<IdentityBadge
|
||||
participant={trackReference.participant}
|
||||
size="sm"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</HStack>
|
||||
<ConnectionQualityIndicator className="lk-participant-metadata-item" />
|
||||
@@ -232,9 +250,14 @@ export const ParticipantTile: (
|
||||
/>
|
||||
)}
|
||||
{!isScreenShare && (
|
||||
<DecryptionFailedTileOverlay
|
||||
participant={trackReference.participant}
|
||||
/>
|
||||
<>
|
||||
<SipBlockedTileOverlay
|
||||
participant={trackReference.participant}
|
||||
/>
|
||||
<DecryptionFailedTileOverlay
|
||||
participant={trackReference.participant}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</ParticipantContextIfNeeded>
|
||||
</TrackRefContextIfNeeded>
|
||||
|
||||
@@ -2,8 +2,7 @@ import { A, Div, Icon, Text } from '@/primitives'
|
||||
import { css } from '@/styled-system/css'
|
||||
import { Button as RACButton } from 'react-aria-components'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { ReactNode } from 'react'
|
||||
import { RiAlertFill } from '@remixicon/react'
|
||||
import { ReactNode, useState } from 'react'
|
||||
import { SubPanelId, useSidePanel } from '../hooks/useSidePanel'
|
||||
import { useRestoreFocus } from '@/hooks/useRestoreFocus'
|
||||
import {
|
||||
@@ -13,8 +12,12 @@ import {
|
||||
ScreenRecordingSidePanel,
|
||||
} from '@/features/recording'
|
||||
import { useConfig } from '@/api/useConfig'
|
||||
import { useRoomData } from '@/features/rooms/livekit/hooks/useRoomData'
|
||||
import { ApiEncryptionMode } from '@/features/rooms/api/ApiRoom'
|
||||
import {
|
||||
EncryptionPhase,
|
||||
PauseEncryptionConfirmDialog,
|
||||
useEncryptionStatus,
|
||||
} from '@/features/encryption'
|
||||
|
||||
|
||||
export interface ToolsButtonProps {
|
||||
icon: ReactNode
|
||||
@@ -108,8 +111,10 @@ export const Tools = () => {
|
||||
const { openTranscript, openScreenRecording, activeSubPanelId, isToolsOpen } =
|
||||
useSidePanel()
|
||||
const { t } = useTranslation('rooms', { keyPrefix: 'moreTools' })
|
||||
const roomData = useRoomData()
|
||||
const isEncrypted = roomData?.encryption_mode === ApiEncryptionMode.BASIC
|
||||
const { phase, pauseEncryption } = useEncryptionStatus()
|
||||
const [confirmReason, setConfirmReason] = useState<
|
||||
'recording' | 'transcript' | null
|
||||
>(null)
|
||||
|
||||
// Restore focus to the element that opened the Tools panel
|
||||
useRestoreFocus(isToolsOpen, {
|
||||
@@ -140,6 +145,15 @@ export const Tools = () => {
|
||||
break
|
||||
}
|
||||
|
||||
const handlePress = (reason: 'recording' | 'transcript') => {
|
||||
if (phase === EncryptionPhase.ENCRYPTED) {
|
||||
setConfirmReason(reason)
|
||||
return
|
||||
}
|
||||
if (reason === 'recording') openScreenRecording()
|
||||
else openTranscript()
|
||||
}
|
||||
|
||||
return (
|
||||
<Div
|
||||
display="flex"
|
||||
@@ -174,42 +188,12 @@ export const Tools = () => {
|
||||
</A>
|
||||
)}
|
||||
</Text>
|
||||
{isEncrypted && (
|
||||
<div
|
||||
className={css({
|
||||
display: 'flex',
|
||||
gap: '0.5rem',
|
||||
alignItems: 'center',
|
||||
padding: '0.6rem 0.85rem',
|
||||
margin: '0 0.75rem 0.75rem',
|
||||
borderRadius: '0.5rem',
|
||||
backgroundColor: '#fff7ed',
|
||||
border: '1px solid #fed7aa',
|
||||
color: '#7c2d12',
|
||||
})}
|
||||
role="alert"
|
||||
>
|
||||
<RiAlertFill size={18} color="#b45309" />
|
||||
<Text
|
||||
variant="sm"
|
||||
margin={false}
|
||||
className={css({
|
||||
color: '#7c2d12',
|
||||
fontSize: '0.85rem',
|
||||
lineHeight: 1.4,
|
||||
})}
|
||||
>
|
||||
{t('encryptedBlock')}
|
||||
</Text>
|
||||
</div>
|
||||
)}
|
||||
{isTranscriptEnabled && (
|
||||
<ToolButton
|
||||
icon={<Icon type="symbols" name="speech_to_text" />}
|
||||
title={t('tools.transcript.title')}
|
||||
description={t('tools.transcript.body')}
|
||||
onPress={openTranscript}
|
||||
isDisabled={isEncrypted}
|
||||
onPress={() => handlePress('transcript')}
|
||||
/>
|
||||
)}
|
||||
{isScreenRecordingEnabled && (
|
||||
@@ -217,10 +201,22 @@ export const Tools = () => {
|
||||
icon={<Icon type="symbols" name="mode_standby" />}
|
||||
title={t('tools.screenRecording.title')}
|
||||
description={t('tools.screenRecording.body')}
|
||||
onPress={openScreenRecording}
|
||||
isDisabled={isEncrypted}
|
||||
onPress={() => handlePress('recording')}
|
||||
/>
|
||||
)}
|
||||
<PauseEncryptionConfirmDialog
|
||||
isOpen={confirmReason !== null}
|
||||
onOpenChange={(open) => !open && setConfirmReason(null)}
|
||||
reason={confirmReason ?? 'recording'}
|
||||
onConfirm={async () => {
|
||||
if (!confirmReason) return
|
||||
const ok = await pauseEncryption(confirmReason)
|
||||
if (ok) {
|
||||
if (confirmReason === 'recording') openScreenRecording()
|
||||
else openTranscript()
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</Div>
|
||||
)
|
||||
}
|
||||
|
||||
+35
-16
@@ -1,19 +1,23 @@
|
||||
import { RiRecordCircleLine } from '@remixicon/react'
|
||||
import { MenuItem } from 'react-aria-components'
|
||||
import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { menuRecipe } from '@/primitives/menuRecipe'
|
||||
import { useSidePanel } from '@/features/rooms/livekit/hooks/useSidePanel'
|
||||
import { RecordingMode, useHasRecordingAccess } from '@/features/recording'
|
||||
import { FeatureFlags } from '@/features/analytics/enums'
|
||||
import { useRoomData } from '@/features/rooms/livekit/hooks/useRoomData'
|
||||
import { ApiEncryptionMode } from '@/features/rooms/api/ApiRoom'
|
||||
import {
|
||||
EncryptionPhase,
|
||||
PauseEncryptionConfirmDialog,
|
||||
useEncryptionStatus,
|
||||
} from '@/features/encryption'
|
||||
|
||||
export const ScreenRecordingMenuItem = () => {
|
||||
const { t } = useTranslation('rooms', { keyPrefix: 'options.items' })
|
||||
const { isScreenRecordingOpen, openScreenRecording, toggleTools } =
|
||||
useSidePanel()
|
||||
const roomData = useRoomData()
|
||||
const isEncrypted = roomData?.encryption_mode === ApiEncryptionMode.BASIC
|
||||
const { phase, pauseEncryption } = useEncryptionStatus()
|
||||
const [confirmOpen, setConfirmOpen] = useState(false)
|
||||
|
||||
const hasScreenRecordingAccess = useHasRecordingAccess(
|
||||
RecordingMode.ScreenRecording,
|
||||
@@ -22,18 +26,33 @@ export const ScreenRecordingMenuItem = () => {
|
||||
|
||||
if (!hasScreenRecordingAccess) return null
|
||||
|
||||
const handlePress = () => {
|
||||
if (phase === EncryptionPhase.ENCRYPTED) {
|
||||
setConfirmOpen(true)
|
||||
return
|
||||
}
|
||||
if (!isScreenRecordingOpen) openScreenRecording()
|
||||
else toggleTools()
|
||||
}
|
||||
|
||||
return (
|
||||
<MenuItem
|
||||
className={menuRecipe({ icon: true, variant: 'dark' }).item}
|
||||
isDisabled={isEncrypted}
|
||||
onAction={() => {
|
||||
if (isEncrypted) return
|
||||
if (!isScreenRecordingOpen) openScreenRecording()
|
||||
else toggleTools()
|
||||
}}
|
||||
>
|
||||
<RiRecordCircleLine size={20} />
|
||||
{t('screenRecording')}
|
||||
</MenuItem>
|
||||
<>
|
||||
<MenuItem
|
||||
className={menuRecipe({ icon: true, variant: 'dark' }).item}
|
||||
onAction={handlePress}
|
||||
>
|
||||
<RiRecordCircleLine size={20} />
|
||||
{t('screenRecording')}
|
||||
</MenuItem>
|
||||
<PauseEncryptionConfirmDialog
|
||||
isOpen={confirmOpen}
|
||||
onOpenChange={setConfirmOpen}
|
||||
reason="recording"
|
||||
onConfirm={async () => {
|
||||
const ok = await pauseEncryption('recording')
|
||||
if (ok) openScreenRecording()
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
+35
-16
@@ -1,18 +1,22 @@
|
||||
import { RiFileTextLine } from '@remixicon/react'
|
||||
import { MenuItem } from 'react-aria-components'
|
||||
import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { menuRecipe } from '@/primitives/menuRecipe'
|
||||
import { useSidePanel } from '@/features/rooms/livekit/hooks/useSidePanel'
|
||||
import { RecordingMode, useHasRecordingAccess } from '@/features/recording'
|
||||
import { FeatureFlags } from '@/features/analytics/enums'
|
||||
import { useRoomData } from '@/features/rooms/livekit/hooks/useRoomData'
|
||||
import { ApiEncryptionMode } from '@/features/rooms/api/ApiRoom'
|
||||
import {
|
||||
EncryptionPhase,
|
||||
PauseEncryptionConfirmDialog,
|
||||
useEncryptionStatus,
|
||||
} from '@/features/encryption'
|
||||
|
||||
export const TranscriptMenuItem = () => {
|
||||
const { t } = useTranslation('rooms', { keyPrefix: 'options.items' })
|
||||
const { isTranscriptOpen, openTranscript, toggleTools } = useSidePanel()
|
||||
const roomData = useRoomData()
|
||||
const isEncrypted = roomData?.encryption_mode === ApiEncryptionMode.BASIC
|
||||
const { phase, pauseEncryption } = useEncryptionStatus()
|
||||
const [confirmOpen, setConfirmOpen] = useState(false)
|
||||
|
||||
const hasTranscriptAccess = useHasRecordingAccess(
|
||||
RecordingMode.Transcript,
|
||||
@@ -21,18 +25,33 @@ export const TranscriptMenuItem = () => {
|
||||
|
||||
if (!hasTranscriptAccess) return null
|
||||
|
||||
const handlePress = () => {
|
||||
if (phase === EncryptionPhase.ENCRYPTED) {
|
||||
setConfirmOpen(true)
|
||||
return
|
||||
}
|
||||
if (!isTranscriptOpen) openTranscript()
|
||||
else toggleTools()
|
||||
}
|
||||
|
||||
return (
|
||||
<MenuItem
|
||||
className={menuRecipe({ icon: true, variant: 'dark' }).item}
|
||||
isDisabled={isEncrypted}
|
||||
onAction={() => {
|
||||
if (isEncrypted) return
|
||||
if (!isTranscriptOpen) openTranscript()
|
||||
else toggleTools()
|
||||
}}
|
||||
>
|
||||
<RiFileTextLine size={20} />
|
||||
{t('transcript')}
|
||||
</MenuItem>
|
||||
<>
|
||||
<MenuItem
|
||||
className={menuRecipe({ icon: true, variant: 'dark' }).item}
|
||||
onAction={handlePress}
|
||||
>
|
||||
<RiFileTextLine size={20} />
|
||||
{t('transcript')}
|
||||
</MenuItem>
|
||||
<PauseEncryptionConfirmDialog
|
||||
isOpen={confirmOpen}
|
||||
onOpenChange={setConfirmOpen}
|
||||
reason="transcript"
|
||||
onConfirm={async () => {
|
||||
const ok = await pauseEncryption('transcript')
|
||||
if (ok) openTranscript()
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
+19
-57
@@ -13,7 +13,7 @@ import {
|
||||
useTrackMutedIndicator,
|
||||
} from '@livekit/components-react'
|
||||
import Source = Track.Source
|
||||
import { RiErrorWarningLine, RiMicFill, RiMicOffFill } from '@remixicon/react'
|
||||
import { RiMicFill, RiMicOffFill } from '@remixicon/react'
|
||||
import { Button } from '@/primitives'
|
||||
import { useState } from 'react'
|
||||
import { MuteAlertDialog } from '../../MuteAlertDialog'
|
||||
@@ -21,8 +21,7 @@ import { useMuteParticipant } from '@/features/rooms/api/muteParticipant'
|
||||
import { useCanMute } from '@/features/rooms/livekit/hooks/useCanMute'
|
||||
import { ParticipantMenuButton } from '../../ParticipantMenu/ParticipantMenuButton'
|
||||
import { PinBadge } from './PinBadge'
|
||||
import { VisualOnlyTooltip } from '@/primitives/VisualOnlyTooltip'
|
||||
import { useUser } from '@/features/auth'
|
||||
import { IdentityBadge, useEncryptionStatus, EncryptionPhase } from '@/features/encryption'
|
||||
|
||||
type MicIndicatorProps = {
|
||||
participant: Participant
|
||||
@@ -99,16 +98,9 @@ export const ParticipantListItem = ({
|
||||
participant,
|
||||
}: ParticipantListItemProps) => {
|
||||
const { t } = useTranslation('rooms')
|
||||
const { isLoggedIn } = useUser()
|
||||
const { phase } = useEncryptionStatus()
|
||||
const showIdentityBadge = phase !== EncryptionPhase.UNENCRYPTED
|
||||
const name = participant.name || participant.identity
|
||||
const isParticipantAuthenticated =
|
||||
participant.attributes?.is_authenticated === 'true'
|
||||
const anonymousLabel = t('identity.anonymous.tooltip')
|
||||
// Email is only displayed to authenticated viewers (defense-in-depth on
|
||||
// top of the JWT-level guarantee that it's only emitted for authenticated
|
||||
// participants). The LK signaling channel broadcasts attributes to every
|
||||
// peer, so the UI is what protects anonymous viewers from seeing it.
|
||||
const email = isLoggedIn ? participant.attributes?.email : undefined
|
||||
return (
|
||||
<HStack
|
||||
role="listitem"
|
||||
@@ -129,54 +121,24 @@ export const ParticipantListItem = ({
|
||||
<PinBadge participant={participant} />
|
||||
</div>
|
||||
<VStack gap={0} alignItems="start">
|
||||
<HStack gap="0.2rem" alignItems="center">
|
||||
<Text
|
||||
variant="sm"
|
||||
margin={false}
|
||||
className={css({
|
||||
userSelect: 'none',
|
||||
whiteSpace: 'nowrap',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
maxWidth: '150px',
|
||||
lineHeight: 1.2,
|
||||
})}
|
||||
>
|
||||
{name}
|
||||
{isLocal(participant) && ` (${t('participants.you')})`}
|
||||
</Text>
|
||||
{!isParticipantAuthenticated && (
|
||||
<VisualOnlyTooltip
|
||||
tooltip={anonymousLabel}
|
||||
ariaLabel={anonymousLabel}
|
||||
>
|
||||
<span
|
||||
className={css({
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
cursor: 'help',
|
||||
})}
|
||||
>
|
||||
<RiErrorWarningLine size={14} color="#dc2626" />
|
||||
</span>
|
||||
</VisualOnlyTooltip>
|
||||
)}
|
||||
</HStack>
|
||||
<Text
|
||||
variant="sm"
|
||||
className={css({
|
||||
userSelect: 'none',
|
||||
whiteSpace: 'nowrap',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
maxWidth: '150px',
|
||||
})}
|
||||
>
|
||||
{name}
|
||||
{isLocal(participant) && ` (${t('participants.you')})`}
|
||||
</Text>
|
||||
{getParticipantIsRoomAdmin(participant) && (
|
||||
<Text variant="xsNote">{t('participants.host')}</Text>
|
||||
)}
|
||||
{email && (
|
||||
<Text
|
||||
variant="xsNote"
|
||||
className={css({
|
||||
whiteSpace: 'nowrap',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
maxWidth: '180px',
|
||||
})}
|
||||
>
|
||||
{email}
|
||||
</Text>
|
||||
{showIdentityBadge && (
|
||||
<IdentityBadge participant={participant} size="sm" />
|
||||
)}
|
||||
</VStack>
|
||||
</HStack>
|
||||
|
||||
+25
-41
@@ -4,8 +4,9 @@ import { css } from '@/styled-system/css'
|
||||
import { Avatar } from '@/components/Avatar'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { WaitingParticipant } from '@/features/rooms/api/listWaitingParticipants'
|
||||
import { RiCloseLine, RiErrorWarningLine } from '@remixicon/react'
|
||||
import { VisualOnlyTooltip } from '@/primitives/VisualOnlyTooltip'
|
||||
import { RiCloseLine } from '@remixicon/react'
|
||||
import { IdentityBadge } from '@/features/encryption'
|
||||
import { useRoomData } from '@/features/rooms/livekit/hooks/useRoomData'
|
||||
|
||||
export const WaitingParticipantListItem = ({
|
||||
participant,
|
||||
@@ -15,7 +16,8 @@ export const WaitingParticipantListItem = ({
|
||||
onAction: (participant: WaitingParticipant, allowEntry: boolean) => void
|
||||
}) => {
|
||||
const { t } = useTranslation('rooms')
|
||||
const anonymousLabel = t('identity.anonymous.tooltip')
|
||||
const roomData = useRoomData()
|
||||
const showIdentityBadge = !!roomData?.is_encrypted
|
||||
|
||||
return (
|
||||
<HStack
|
||||
@@ -36,46 +38,28 @@ export const WaitingParticipantListItem = ({
|
||||
})}
|
||||
>
|
||||
<Avatar name={participant.username} bgColor={participant.color} />
|
||||
<VStack
|
||||
gap={0}
|
||||
alignItems="start"
|
||||
className={css({ flex: 1, minWidth: 0 })}
|
||||
>
|
||||
<HStack gap="0.2rem" alignItems="center">
|
||||
<Text
|
||||
variant="sm"
|
||||
margin={false}
|
||||
className={css({
|
||||
userSelect: 'none',
|
||||
whiteSpace: 'nowrap',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
padding: '0.1rem 0.25rem',
|
||||
lineHeight: 1.2,
|
||||
})}
|
||||
>
|
||||
{participant.username}
|
||||
</Text>
|
||||
{!participant.is_authenticated && (
|
||||
<VisualOnlyTooltip
|
||||
tooltip={anonymousLabel}
|
||||
ariaLabel={anonymousLabel}
|
||||
>
|
||||
<span
|
||||
className={css({
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
cursor: 'help',
|
||||
})}
|
||||
>
|
||||
<RiErrorWarningLine size={14} color="#dc2626" />
|
||||
</span>
|
||||
</VisualOnlyTooltip>
|
||||
)}
|
||||
</HStack>
|
||||
<VStack gap={0} alignItems="start" className={css({ flex: 1, minWidth: 0 })}>
|
||||
<Text
|
||||
variant="sm"
|
||||
className={css({
|
||||
userSelect: 'none',
|
||||
whiteSpace: 'nowrap',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
padding: '0.1rem 0.25rem',
|
||||
})}
|
||||
>
|
||||
{participant.username}
|
||||
</Text>
|
||||
{showIdentityBadge && (
|
||||
<IdentityBadge isAuthenticated={participant.is_authenticated} />
|
||||
)}
|
||||
</VStack>
|
||||
</HStack>
|
||||
<HStack gap="0.25rem" className={css({ flexShrink: '0' })}>
|
||||
<HStack
|
||||
gap="0.25rem"
|
||||
className={css({ flexShrink: '0' })}
|
||||
>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="tertiary"
|
||||
|
||||
@@ -2,15 +2,12 @@ import { useTelephony } from './useTelephony'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { formatPinCode } from '@/features/rooms/utils/telephony'
|
||||
import { ApiRoom, ApiEncryptionMode } from '@/features/rooms/api/ApiRoom'
|
||||
import { ApiRoom } from '@/features/rooms/api/ApiRoom'
|
||||
import { getRouteUrl } from '@/navigation/getRouteUrl'
|
||||
|
||||
const COPY_SUCCESS_TIMEOUT = 3000
|
||||
|
||||
export const useCopyRoomToClipboard = (
|
||||
room: ApiRoom | undefined,
|
||||
hashOverride?: string
|
||||
) => {
|
||||
export const useCopyRoomToClipboard = (room: ApiRoom | undefined, hashOverride?: string) => {
|
||||
const telephony = useTelephony()
|
||||
const { t } = useTranslation('global', { keyPrefix: 'clipboardContent' })
|
||||
|
||||
@@ -42,16 +39,9 @@ export const useCopyRoomToClipboard = (
|
||||
return hash ? `${base}${hash}` : base
|
||||
}, [room?.slug, hashOverride])
|
||||
|
||||
// Encrypted rooms never get a dispatch rule on the SIP gateway side
|
||||
// (the backend skips it because no PIN-driven join can ever decrypt),
|
||||
// so make sure we don't paste a non-functional phone+PIN snippet either.
|
||||
const hasTelephonyInfo = useMemo(() => {
|
||||
return (
|
||||
telephony.enabled &&
|
||||
room?.pin_code &&
|
||||
room?.encryption_mode !== ApiEncryptionMode.BASIC
|
||||
)
|
||||
}, [telephony.enabled, room?.pin_code, room?.encryption_mode])
|
||||
return telephony.enabled && room?.pin_code
|
||||
}, [telephony.enabled, room?.pin_code])
|
||||
|
||||
const content = useMemo(() => {
|
||||
if (!roomUrl || !room) return ''
|
||||
|
||||
@@ -42,7 +42,11 @@ import { Subtitles } from '@/features/subtitle/component/Subtitles'
|
||||
import { CarouselLayout } from '../components/layout/CarouselLayout'
|
||||
import { GridLayout } from '../components/layout/GridLayout'
|
||||
import { IsIdleDisconnectModal } from '../components/IsIdleDisconnectModal'
|
||||
import { RoomStatusBanner } from '@/features/encryption'
|
||||
import {
|
||||
RoomStatusBanner,
|
||||
EncryptionStatusSnackbars,
|
||||
EncryptionAutoResumeWatcher,
|
||||
} from '@/features/encryption'
|
||||
import { getParticipantName } from '@/features/rooms/utils/getParticipantName'
|
||||
import { useScreenReaderAnnounce } from '@/hooks/useScreenReaderAnnounce'
|
||||
|
||||
@@ -278,6 +282,8 @@ export function VideoConference({ ...props }: VideoConferenceProps) {
|
||||
/>
|
||||
<IsIdleDisconnectModal />
|
||||
<RoomStatusBanner />
|
||||
<EncryptionStatusSnackbars />
|
||||
<EncryptionAutoResumeWatcher />
|
||||
<div
|
||||
// todo - extract these magic values into constant
|
||||
style={{
|
||||
|
||||
@@ -15,6 +15,7 @@ import { PopupManager } from '../utils/PopupManager'
|
||||
import { CallbackCreationRoomData } from '../utils/types'
|
||||
import { useSearchParams } from 'wouter'
|
||||
|
||||
|
||||
const popupManager = new PopupManager()
|
||||
|
||||
export const CreateMeetingButton = () => {
|
||||
@@ -77,17 +78,10 @@ export const CreateMeetingButton = () => {
|
||||
|
||||
// Communicate iframe height to parent for proper sizing
|
||||
useEffect(() => {
|
||||
const targetOrigin = (() => {
|
||||
try {
|
||||
return document.referrer ? new URL(document.referrer).origin : '/'
|
||||
} catch {
|
||||
return '/'
|
||||
}
|
||||
})()
|
||||
const observer = new ResizeObserver(() => {
|
||||
window.parent.postMessage(
|
||||
{ type: 'RESIZE', data: { height: document.body.scrollHeight } },
|
||||
targetOrigin
|
||||
'*'
|
||||
)
|
||||
})
|
||||
observer.observe(document.body)
|
||||
|
||||
@@ -8,18 +8,20 @@ import { Button, Text } from '@/primitives'
|
||||
import { VStack } from '@/styled-system/jsx'
|
||||
import { CallbackIdHandler } from '../utils/CallbackIdHandler'
|
||||
import { PopupWindow } from '../utils/PopupWindow'
|
||||
import { ApiEncryptionMode } from '@/features/rooms/api/ApiRoom'
|
||||
import { generatePassphrase } from '@/features/encryption'
|
||||
import { useConfig } from '@/api/useConfig'
|
||||
import { RiVideoOnLine } from '@remixicon/react'
|
||||
|
||||
const callbackIdHandler = new CallbackIdHandler()
|
||||
const popupWindow = new PopupWindow()
|
||||
|
||||
export const CreatePopup = () => {
|
||||
const { isLoggedIn } = useUser({
|
||||
const { isLoggedIn, user } = useUser({
|
||||
fetchUserOptions: { attemptSilent: false },
|
||||
})
|
||||
const { mutateAsync: createRoom } = useCreateRoom()
|
||||
const { t } = useTranslation('sdk', { keyPrefix: 'createPopup' })
|
||||
const { data: config } = useConfig()
|
||||
|
||||
const callbackId = useMemo(() => callbackIdHandler.getOrCreate(), [])
|
||||
const [isCreating, setIsCreating] = useState(false)
|
||||
@@ -35,12 +37,16 @@ export const CreatePopup = () => {
|
||||
|
||||
try {
|
||||
const slug = generateRoomId()
|
||||
const isEncrypted =
|
||||
!!config?.encryption?.enabled && !!user?.default_encryption
|
||||
const hash = isEncrypted ? generatePassphrase() : undefined
|
||||
|
||||
const roomData = await createRoom({
|
||||
slug,
|
||||
encryptionMode: ApiEncryptionMode.NONE,
|
||||
isEncrypted,
|
||||
})
|
||||
|
||||
popupWindow.sendRoomData({ slug: roomData.slug }, () => {
|
||||
popupWindow.sendRoomData({ slug: roomData.slug, hash }, () => {
|
||||
callbackIdHandler.clear()
|
||||
popupWindow.close()
|
||||
})
|
||||
@@ -50,6 +56,8 @@ export const CreatePopup = () => {
|
||||
}
|
||||
}
|
||||
|
||||
// Auto-create as soon as we know the user; the SDK popup is intentionally
|
||||
// a single-action surface — no per-meeting picker.
|
||||
useEffect(() => {
|
||||
if (isLoggedIn && !isCreating && callbackId) {
|
||||
void handleCreate()
|
||||
@@ -88,11 +96,7 @@ export const CreatePopup = () => {
|
||||
<Text
|
||||
variant="sm"
|
||||
bold
|
||||
className={css({
|
||||
textAlign: 'center',
|
||||
fontSize: '1.1rem',
|
||||
marginBottom: '0.5rem',
|
||||
})}
|
||||
className={css({ textAlign: 'center', fontSize: '1.1rem', marginBottom: '0.5rem' })}
|
||||
>
|
||||
{t('title')}
|
||||
</Text>
|
||||
|
||||
@@ -60,9 +60,7 @@ export class PopupManager {
|
||||
if (!data?.room) return
|
||||
onRoomData(data.room)
|
||||
const baseUrl = getRouteUrl('room', data.room.slug)
|
||||
const roomUrl = data.room.hash
|
||||
? `${baseUrl}#${data.room.hash}`
|
||||
: baseUrl
|
||||
const roomUrl = data.room.hash ? `${baseUrl}#${data.room.hash}` : baseUrl
|
||||
this.sendRoomData({
|
||||
room: {
|
||||
url: roomUrl,
|
||||
|
||||
@@ -1,25 +1,33 @@
|
||||
/**
|
||||
* "Encrypt new meetings by default" toggle used both in the in-meeting
|
||||
* Security tab and the home-page SettingsDialog. Flipping it directly
|
||||
* patches the user preference — the per-meeting CreateEncryptedMeeting
|
||||
* dialog already surfaces the disabled-features warning when needed.
|
||||
* Reusable "create encrypted meetings by default" toggle + confirmation
|
||||
* dialog. Used both in the in-meeting Security tab and the home-page
|
||||
* SettingsDialog so the option is reachable from outside a call.
|
||||
*/
|
||||
import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useMutation } from '@tanstack/react-query'
|
||||
import { A, Field, Text } from '@/primitives'
|
||||
import {
|
||||
RiFileTextLine,
|
||||
RiPhoneLine,
|
||||
RiRecordCircleLine,
|
||||
RiVideoOnLine,
|
||||
} from '@remixicon/react'
|
||||
import { Button, Dialog, Field, Text } from '@/primitives'
|
||||
import { HStack, VStack } from '@/styled-system/jsx'
|
||||
import { css } from '@/styled-system/css'
|
||||
import { useUser } from '@/features/auth'
|
||||
import { updateUserPreferences } from '@/features/auth/api/updateUserPreferences'
|
||||
import { queryClient } from '@/api/queryClient'
|
||||
import { keys } from '@/api/queryKeys'
|
||||
import { useConfig } from '@/api/useConfig'
|
||||
import { LoginButton } from '@/components/LoginButton'
|
||||
import { ApiEncryptionMode } from '@/features/rooms/api/ApiRoom'
|
||||
|
||||
export const EncryptionDefaultField = () => {
|
||||
const { t } = useTranslation('settings', { keyPrefix: 'security' })
|
||||
const { user, isLoggedIn } = useUser()
|
||||
const { data: config } = useConfig()
|
||||
const isFeatureEnabled = !!config?.encryption?.enabled
|
||||
const [confirmTarget, setConfirmTarget] = useState<boolean | null>(null)
|
||||
|
||||
const { mutateAsync, isPending } = useMutation({
|
||||
mutationFn: updateUserPreferences,
|
||||
@@ -28,23 +36,23 @@ export const EncryptionDefaultField = () => {
|
||||
},
|
||||
})
|
||||
|
||||
const isOn = user?.default_encryption_mode === ApiEncryptionMode.BASIC
|
||||
const isOn = !!user?.default_encryption
|
||||
|
||||
const handleToggle = async (next: boolean) => {
|
||||
const requestToggle = (next: boolean) => {
|
||||
if (!user) return
|
||||
try {
|
||||
await mutateAsync({
|
||||
user: {
|
||||
id: user.id,
|
||||
default_encryption_mode: next
|
||||
? ApiEncryptionMode.BASIC
|
||||
: ApiEncryptionMode.NONE,
|
||||
},
|
||||
})
|
||||
} catch {
|
||||
// react-query stores the error on the mutation; the UI shows
|
||||
// isPending while in flight and reverts the toggle on failure.
|
||||
if (next) {
|
||||
setConfirmTarget(true)
|
||||
return
|
||||
}
|
||||
void mutateAsync({ user: { id: user.id, default_encryption: false } })
|
||||
}
|
||||
|
||||
const confirm = async () => {
|
||||
if (!user || confirmTarget === null) return
|
||||
await mutateAsync({
|
||||
user: { id: user.id, default_encryption: confirmTarget },
|
||||
})
|
||||
setConfirmTarget(null)
|
||||
}
|
||||
|
||||
if (!isLoggedIn) {
|
||||
@@ -67,29 +75,85 @@ export const EncryptionDefaultField = () => {
|
||||
}
|
||||
|
||||
return (
|
||||
<Field
|
||||
type="switch"
|
||||
label={t('encryption.label')}
|
||||
description={
|
||||
<>
|
||||
{t('encryption.description')}{' '}
|
||||
{config?.support?.help_article_encryption && (
|
||||
<A
|
||||
href={config.support.help_article_encryption}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
externalIcon
|
||||
color="note"
|
||||
<>
|
||||
<Field
|
||||
type="switch"
|
||||
label={t('toggle.label')}
|
||||
description={t('toggle.description')}
|
||||
isSelected={isOn}
|
||||
isDisabled={isPending}
|
||||
onChange={requestToggle}
|
||||
wrapperProps={{ noMargin: true, fullWidth: true }}
|
||||
/>
|
||||
<Dialog
|
||||
isOpen={confirmTarget !== null}
|
||||
onOpenChange={(open) => !open && setConfirmTarget(null)}
|
||||
role="dialog"
|
||||
type="flex"
|
||||
title={t('confirmModal.title')}
|
||||
>
|
||||
<VStack
|
||||
alignItems="start"
|
||||
gap="0.75rem"
|
||||
className={css({ maxWidth: '24rem' })}
|
||||
>
|
||||
<Text variant="sm">{t('confirmModal.description')}</Text>
|
||||
<VStack gap="0.5rem" alignItems="start">
|
||||
<ConfirmRow
|
||||
icon={<RiPhoneLine size={16} />}
|
||||
label={t('confirmModal.items.phone')}
|
||||
/>
|
||||
<ConfirmRow
|
||||
icon={<RiVideoOnLine size={16} />}
|
||||
label={t('confirmModal.items.devices')}
|
||||
/>
|
||||
<ConfirmRow
|
||||
icon={<RiFileTextLine size={16} />}
|
||||
label={t('confirmModal.items.transcription')}
|
||||
/>
|
||||
<ConfirmRow
|
||||
icon={<RiRecordCircleLine size={16} />}
|
||||
label={t('confirmModal.items.recording')}
|
||||
/>
|
||||
</VStack>
|
||||
<Text
|
||||
variant="note"
|
||||
className={css({ fontSize: '0.8rem', color: 'greyscale.500' })}
|
||||
>
|
||||
{t('confirmModal.footnote')}
|
||||
</Text>
|
||||
<HStack gap="0.5rem" justify="end" className={css({ width: '100%' })}>
|
||||
<Button
|
||||
variant="secondary"
|
||||
onPress={() => setConfirmTarget(null)}
|
||||
>
|
||||
{t('encryption.learnMore')}
|
||||
</A>
|
||||
)}
|
||||
</>
|
||||
}
|
||||
isSelected={isOn}
|
||||
isDisabled={isPending}
|
||||
onChange={handleToggle}
|
||||
wrapperProps={{ noMargin: true, fullWidth: true }}
|
||||
/>
|
||||
{t('confirmModal.cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
isDisabled={isPending}
|
||||
onPress={confirm}
|
||||
>
|
||||
{t('confirmModal.confirm')}
|
||||
</Button>
|
||||
</HStack>
|
||||
</VStack>
|
||||
</Dialog>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
const ConfirmRow = ({
|
||||
icon,
|
||||
label,
|
||||
}: {
|
||||
icon: React.ReactNode
|
||||
label: string
|
||||
}) => (
|
||||
<HStack gap="0.5rem" alignItems="center">
|
||||
<span className={css({ color: 'greyscale.600' })}>{icon}</span>
|
||||
<Text variant="sm" margin={false}>
|
||||
{label}
|
||||
</Text>
|
||||
</HStack>
|
||||
)
|
||||
|
||||
@@ -1,15 +1,6 @@
|
||||
import { Trans, useTranslation } from 'react-i18next'
|
||||
import { useLanguageLabels } from '@/i18n/useLanguageLabels'
|
||||
import {
|
||||
A,
|
||||
Badge,
|
||||
Dialog,
|
||||
type DialogProps,
|
||||
Field,
|
||||
H,
|
||||
P,
|
||||
Text,
|
||||
} from '@/primitives'
|
||||
import { A, Badge, Dialog, type DialogProps, Field, H, P } from '@/primitives'
|
||||
import { useUser } from '@/features/auth'
|
||||
import { LoginButton } from '@/components/LoginButton'
|
||||
import { EncryptionDefaultField } from './EncryptionDefaultField'
|
||||
@@ -56,12 +47,7 @@ export const SettingsDialog = (props: SettingsDialogProps) => {
|
||||
i18n.changeLanguage(lang as string)
|
||||
}}
|
||||
/>
|
||||
<H lvl={2} margin={false}>
|
||||
{t('security.heading')}
|
||||
</H>
|
||||
<Text variant="note" margin="md">
|
||||
{t('security.subtitle')}
|
||||
</Text>
|
||||
<H lvl={2}>{t('security.heading')}</H>
|
||||
<EncryptionDefaultField />
|
||||
</Dialog>
|
||||
)
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
/**
|
||||
* Security settings tab — user-level encryption preferences only. The
|
||||
* per-meeting pause control lives in the Admin panel (room moderation),
|
||||
* since it's a moderation action, not a user preference.
|
||||
*/
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { H, Text } from '@/primitives'
|
||||
import { H } from '@/primitives'
|
||||
import { TabPanel, TabPanelProps } from '@/primitives/Tabs'
|
||||
import { EncryptionDefaultField } from '../EncryptionDefaultField'
|
||||
|
||||
@@ -9,12 +14,7 @@ export const SecurityTab = ({ id }: SecurityTabProps) => {
|
||||
const { t } = useTranslation('settings', { keyPrefix: 'security' })
|
||||
return (
|
||||
<TabPanel padding="md" flex id={id}>
|
||||
<H lvl={2} margin={false}>
|
||||
{t('heading')}
|
||||
</H>
|
||||
<Text variant="note" margin="md">
|
||||
{t('subtitle')}
|
||||
</Text>
|
||||
<H lvl={2}>{t('heading')}</H>
|
||||
<EncryptionDefaultField />
|
||||
</TabPanel>
|
||||
)
|
||||
|
||||
@@ -22,29 +22,7 @@
|
||||
"moreAbout": "about {{appTitle}}",
|
||||
"createMenu": {
|
||||
"laterOption": "Create a meeting for a later date",
|
||||
"instantOption": "Start an instant meeting",
|
||||
"encryptedOption": "Create an encrypted meeting"
|
||||
},
|
||||
"createEncryptedMeetingDialog": {
|
||||
"title": "Create an encrypted meeting",
|
||||
"description": "Encryption disables these features:",
|
||||
"features": {
|
||||
"dialIn": "Phone dial-in",
|
||||
"meetingRoom": "Meeting room devices",
|
||||
"transcription": "Transcription",
|
||||
"recording": "Recording"
|
||||
},
|
||||
"warning": "Encryption may slow down your meeting and can't be turned off later.",
|
||||
"cancel": "Cancel",
|
||||
"confirm": "Create encrypted meeting"
|
||||
},
|
||||
"connectionDetailsDialog": {
|
||||
"title": "Your connection details",
|
||||
"description": "Share this link with your guests. They'll wait in the lobby until you let them in.",
|
||||
"warning": "Treat this link like a password. Anyone with it can join the lobby, and decrypt the meeting once you let them in.",
|
||||
"iUnderstand": "I understand",
|
||||
"startMeeting": "Start meeting",
|
||||
"copy": "Copy meeting link"
|
||||
"instantOption": "Start an instant meeting"
|
||||
},
|
||||
"laterMeetingDialog": {
|
||||
"heading": "Your connection details",
|
||||
@@ -87,6 +65,5 @@
|
||||
},
|
||||
"carouselLabel": "Introduction slideshow",
|
||||
"slidePosition": "Slide {{current}} of {{total}}"
|
||||
},
|
||||
"joinPassphraseInvalidFormat": "The passphrase looks malformed. It should be 48 hexadecimal characters from the meeting link."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -54,6 +54,7 @@
|
||||
"toggleOn": "Click to turn on",
|
||||
"usernameHint": "Shown to other participants",
|
||||
"usernameLabel": "Your name",
|
||||
"encryptedHint": "This meeting is end-to-end encrypted. Make sure you opened the same meeting link as the host.",
|
||||
"errors": {
|
||||
"usernameEmpty": "Your name cannot be empty"
|
||||
},
|
||||
@@ -84,8 +85,7 @@
|
||||
"invalidKey": {
|
||||
"title": "Invalid meeting link",
|
||||
"body": "This encrypted meeting requires a valid encryption key in the URL. Please ask the meeting organizer for the correct link."
|
||||
},
|
||||
"encryptedNameLocked": "Authenticated identity — you can't change your name in an encrypted meeting."
|
||||
}
|
||||
},
|
||||
"leaveRoomPrompt": "This will make you leave the meeting.",
|
||||
"shareDialog": {
|
||||
@@ -99,10 +99,7 @@
|
||||
"phone": {
|
||||
"call": "Call:",
|
||||
"pinCode": "Code:"
|
||||
},
|
||||
"encryptedHeading": "Meeting information",
|
||||
"encryptedGuestBody": "This meeting is encrypted. The meeting link is only visible on the device where the meeting was created.",
|
||||
"encryptedDisabledHeading": "The following features are disabled:"
|
||||
}
|
||||
},
|
||||
"pagination": {
|
||||
"count": "{{currentPage}} of {{totalPageCount}}",
|
||||
@@ -161,10 +158,6 @@
|
||||
"helpLinkLabel": "Presentation issue",
|
||||
"closeButton": "Dismiss",
|
||||
"newTab": "New window"
|
||||
},
|
||||
"encryptionSetup": {
|
||||
"heading": "Encryption setup failed",
|
||||
"body": "Your browser couldn't set up the encryption layer for this meeting. Reload the page; if it keeps failing, try another browser."
|
||||
}
|
||||
},
|
||||
"isIdleDisconnectModal": {
|
||||
@@ -372,31 +365,20 @@
|
||||
"title": "Record",
|
||||
"body": "Save meetings as video."
|
||||
}
|
||||
},
|
||||
"encryptedBlock": "Tools are unavailable in encrypted mode."
|
||||
}
|
||||
},
|
||||
"info": {
|
||||
"roomInformation": {
|
||||
"title": "Connection information",
|
||||
"title": "Connection Information",
|
||||
"button": {
|
||||
"ariaLabel": "Copy the information from your meeting",
|
||||
"copy": "Copy information",
|
||||
"copied": "Information copied to clipboard",
|
||||
"ariaLabel": "Copy meeting information"
|
||||
"copied": "Information copied"
|
||||
},
|
||||
"phone": {
|
||||
"call": "Call:",
|
||||
"pinCode": "Code:"
|
||||
}
|
||||
},
|
||||
"encrypted": {
|
||||
"heading": "Meeting information",
|
||||
"guestBody": "This meeting is encrypted. The meeting link is only visible on the device where the meeting was created.",
|
||||
"linkLabel": "Connection information",
|
||||
"disabledHeading": "The following features are disabled:",
|
||||
"features": {
|
||||
"dialIn": "Phone dial-in",
|
||||
"meetingRoom": "Meeting room devices"
|
||||
}
|
||||
}
|
||||
},
|
||||
"transcript": {
|
||||
@@ -517,8 +499,7 @@
|
||||
"label": "Restricted",
|
||||
"description": "People who have not been invited to the meeting must request to join."
|
||||
}
|
||||
},
|
||||
"encryptedLocked": "Encrypted meetings are always restricted — guests wait in the lobby until you let them in."
|
||||
}
|
||||
},
|
||||
"moderation": {
|
||||
"title": "Meeting Moderation",
|
||||
@@ -535,6 +516,19 @@
|
||||
"label": "Share their screen",
|
||||
"description": "Disabling this option will prevent participants from sharing their screen, and any ongoing screen sharing will be stopped immediately."
|
||||
}
|
||||
},
|
||||
"encryption": {
|
||||
"title": "Encryption",
|
||||
"description": "Temporarily pause end-to-end encryption so a phone or other external device can join. The meeting link still carries the encryption key, so you can resume at any time.",
|
||||
"toggle": {
|
||||
"label": "Pause encryption",
|
||||
"descriptionLive": "Encryption is active for this meeting.",
|
||||
"descriptionPaused": "Encryption is paused — external devices can join in plain audio and video."
|
||||
},
|
||||
"blocked": {
|
||||
"recording": "Encryption can't resume while a recording is in progress. Stop the recording first.",
|
||||
"transcript": "Encryption can't resume while transcription is running. Stop the transcript first."
|
||||
}
|
||||
}
|
||||
},
|
||||
"rating": {
|
||||
@@ -697,12 +691,12 @@
|
||||
"screenShare": "{{name}}'s screen"
|
||||
},
|
||||
"identity": {
|
||||
"anonymous": {
|
||||
"tooltip": "This user is not authenticated"
|
||||
}
|
||||
"proconnect": "Connected",
|
||||
"anonymous": "Anonymous"
|
||||
},
|
||||
"roomStatus": {
|
||||
"encrypted": "End-to-end encrypted",
|
||||
"paused": "Encryption paused",
|
||||
"recording": "Recording in progress",
|
||||
"transcribing": "Transcription in progress"
|
||||
},
|
||||
@@ -716,7 +710,38 @@
|
||||
"title": "This meeting is not encrypted",
|
||||
"body": "The link contains an encryption key but the meeting is not configured for end-to-end encryption. The link may have been altered. Create a fresh encrypted meeting to keep your conversation safe."
|
||||
},
|
||||
"backHome": "Back to home"
|
||||
"createFresh": "Create a new encrypted meeting"
|
||||
},
|
||||
"pauseConfirm": {
|
||||
"title": {
|
||||
"recording": "Turn on recording?",
|
||||
"transcript": "Turn on transcription?"
|
||||
},
|
||||
"description": "Encryption will pause while this feature is on. The server temporarily needs access to the media content to provide it.",
|
||||
"learnMore": "Encryption resumes automatically once you stop the feature.",
|
||||
"cancel": "Cancel",
|
||||
"confirm": {
|
||||
"recording": "Turn on",
|
||||
"transcript": "Turn on"
|
||||
}
|
||||
},
|
||||
"snackbar": {
|
||||
"pausedTitle": "Encryption paused",
|
||||
"pausedByMeTitle": "You paused encryption",
|
||||
"reasonTranscript": "Encryption is paused while transcription is on. It will resume when transcription stops.",
|
||||
"reasonRecording": "Encryption is paused while the meeting is being recorded. It will resume when recording stops.",
|
||||
"reasonManual": "An admin turned off encryption for this meeting.",
|
||||
"reasonSip": "Encryption was paused so a phone participant can join.",
|
||||
"sipTitle": "A participant can't decrypt this meeting",
|
||||
"sipBody": "{{name}} joined by phone or another device that cannot decrypt this meeting. Pause encryption from the Admin panel to let them in.",
|
||||
"openAdmin": "Open admin",
|
||||
"dismiss": "OK"
|
||||
},
|
||||
"sipBlocked": {
|
||||
"title": "Can't decrypt this caller",
|
||||
"bodyAdmin": "This participant joined by phone or another device that doesn't support end-to-end encryption. Pause encryption from the Admin panel to bridge their audio and video.",
|
||||
"bodyParticipant": "This participant joined by phone or another device that doesn't support end-to-end encryption. Ask the host to pause encryption to let them in.",
|
||||
"openAdmin": "Open admin"
|
||||
},
|
||||
"decryptionFailed": {
|
||||
"title": "Decryption failed",
|
||||
|
||||
@@ -9,13 +9,33 @@
|
||||
},
|
||||
"security": {
|
||||
"heading": "Security",
|
||||
"subtitle": "Add security options.",
|
||||
"signInRequired": "Sign in to set encryption preferences.",
|
||||
"featureDisabled": "End-to-end encryption is not available on this server.",
|
||||
"encryption": {
|
||||
"label": "End-to-end encryption",
|
||||
"description": "You can create encrypted meetings on demand. Only participants can access the content, not even our servers.",
|
||||
"learnMore": "Learn more"
|
||||
"defaultHeading": "New meetings",
|
||||
"toggle": {
|
||||
"label": "Encrypt new meetings by default",
|
||||
"description": "When on, every new meeting you create starts end-to-end encrypted. You can still pause encryption per-meeting from this same Security panel."
|
||||
},
|
||||
"confirmModal": {
|
||||
"title": "Turn on encryption?",
|
||||
"description": "While encryption is on, these features are unavailable. Encryption can be turned off for individual meetings when needed.",
|
||||
"items": {
|
||||
"phone": "Phone dial-in",
|
||||
"devices": "Meeting room devices",
|
||||
"transcription": "Transcription",
|
||||
"recording": "Recording"
|
||||
},
|
||||
"footnote": "You can change this preference at any time.",
|
||||
"cancel": "Cancel",
|
||||
"confirm": "Turn on"
|
||||
},
|
||||
"thisMeeting": {
|
||||
"heading": "This meeting",
|
||||
"toggle": {
|
||||
"label": "Pause encryption",
|
||||
"descriptionLive": "Encryption is active. Pause it to let a phone or other external device join — the link still carries the encryption key, so you can resume at any time.",
|
||||
"descriptionPaused": "Encryption is paused. External devices can join in plain audio and video. Toggle off to resume protection."
|
||||
}
|
||||
}
|
||||
},
|
||||
"preferences": {
|
||||
|
||||
@@ -22,29 +22,7 @@
|
||||
"moreAbout": "sur {{appTitle}}",
|
||||
"createMenu": {
|
||||
"laterOption": "Créer une réunion pour une date ultérieure",
|
||||
"instantOption": "Démarrer une réunion instantanée",
|
||||
"encryptedOption": "Créer une réunion chiffrée"
|
||||
},
|
||||
"createEncryptedMeetingDialog": {
|
||||
"title": "Créer une réunion chiffrée",
|
||||
"description": "Le chiffrement désactive ces fonctionnalités :",
|
||||
"features": {
|
||||
"dialIn": "Appel téléphonique",
|
||||
"meetingRoom": "Appareils de salle de réunion",
|
||||
"transcription": "Transcription",
|
||||
"recording": "Enregistrement"
|
||||
},
|
||||
"warning": "Le chiffrement peut ralentir votre réunion et ne pourra pas être désactivé par la suite.",
|
||||
"cancel": "Annuler",
|
||||
"confirm": "Créer la réunion chiffrée"
|
||||
},
|
||||
"connectionDetailsDialog": {
|
||||
"title": "Vos informations de connexion",
|
||||
"description": "Partagez ce lien avec vos invités. Ils patienteront dans le salon d'attente jusqu'à ce que vous les autorisiez.",
|
||||
"warning": "Traitez ce lien comme un mot de passe. Toute personne qui le possède peut rejoindre le salon d'attente et déchiffrer la réunion dès que vous l'aurez laissée entrer.",
|
||||
"iUnderstand": "J'ai compris",
|
||||
"startMeeting": "Démarrer la réunion",
|
||||
"copy": "Copier le lien de la réunion"
|
||||
"instantOption": "Démarrer une réunion instantanée"
|
||||
},
|
||||
"laterMeetingDialog": {
|
||||
"heading": "Vos informations de connexion",
|
||||
@@ -87,6 +65,5 @@
|
||||
"title": "Transformez vos réunions avec l'IA",
|
||||
"body": "Obtenez des transcriptions précises et actionnables, pour booster votre productivité. Fonctionnalité en beta, essayez-la maintenant !"
|
||||
}
|
||||
},
|
||||
"joinPassphraseInvalidFormat": "La phrase secrète semble incorrecte. Elle doit être composée de 48 caractères hexadécimaux issus du lien de la réunion."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -54,6 +54,7 @@
|
||||
"toggleOn": "Cliquez pour activer",
|
||||
"usernameHint": "Affiché aux autres participants",
|
||||
"usernameLabel": "Votre nom",
|
||||
"encryptedHint": "Cette réunion est chiffrée de bout en bout. Vérifiez que vous avez ouvert le même lien que l'organisateur.",
|
||||
"errors": {
|
||||
"usernameEmpty": "Votre nom ne peut pas être vide"
|
||||
},
|
||||
@@ -84,8 +85,7 @@
|
||||
"invalidKey": {
|
||||
"title": "Lien de réunion invalide",
|
||||
"body": "Cette réunion chiffrée nécessite une clé de chiffrement valide dans l'URL. Veuillez demander le lien correct à l'organisateur de la réunion."
|
||||
},
|
||||
"encryptedNameLocked": "Identité authentifiée — vous ne pouvez pas modifier votre nom dans une réunion chiffrée."
|
||||
}
|
||||
},
|
||||
"leaveRoomPrompt": "Revenir à l'accueil vous fera quitter la réunion.",
|
||||
"shareDialog": {
|
||||
@@ -99,10 +99,7 @@
|
||||
"phone": {
|
||||
"call": "Appelez le :",
|
||||
"pinCode": "Code :"
|
||||
},
|
||||
"encryptedHeading": "Informations de la réunion",
|
||||
"encryptedGuestBody": "Cette réunion est chiffrée. Le lien de la réunion n'est visible que sur l'appareil sur lequel elle a été créée.",
|
||||
"encryptedDisabledHeading": "Les fonctionnalités suivantes sont désactivées :"
|
||||
}
|
||||
},
|
||||
"pagination": {
|
||||
"count": "{{currentPage}} sur {{totalPageCount}}",
|
||||
@@ -161,10 +158,6 @@
|
||||
"helpLinkLabel": "Problème de présentation",
|
||||
"closeButton": "Ignorer",
|
||||
"newTab": "Nouvelle fenêtre"
|
||||
},
|
||||
"encryptionSetup": {
|
||||
"heading": "Échec de l'activation du chiffrement",
|
||||
"body": "Votre navigateur n'a pas pu configurer la couche de chiffrement pour cette réunion. Rechargez la page ; si l'erreur persiste, essayez un autre navigateur."
|
||||
}
|
||||
},
|
||||
"isIdleDisconnectModal": {
|
||||
@@ -372,31 +365,20 @@
|
||||
"title": "Enregistrer",
|
||||
"body": "Enregistrer la réunion en vidéo."
|
||||
}
|
||||
},
|
||||
"encryptedBlock": "Les outils ne sont pas disponibles en mode chiffré."
|
||||
}
|
||||
},
|
||||
"info": {
|
||||
"roomInformation": {
|
||||
"title": "Informations de connexion",
|
||||
"title": "Informations de connexions",
|
||||
"button": {
|
||||
"ariaLabel": "Copier les informations de votre réunion",
|
||||
"copy": "Copier les informations",
|
||||
"copied": "Informations copiées",
|
||||
"ariaLabel": "Copier les informations de la réunion"
|
||||
"copied": "Informations copiées"
|
||||
},
|
||||
"phone": {
|
||||
"call": "Appel :",
|
||||
"call": "Appelez le :",
|
||||
"pinCode": "Code :"
|
||||
}
|
||||
},
|
||||
"encrypted": {
|
||||
"heading": "Informations de la réunion",
|
||||
"guestBody": "Cette réunion est chiffrée. Le lien de la réunion n'est visible que sur l'appareil sur lequel elle a été créée.",
|
||||
"linkLabel": "Informations de connexion",
|
||||
"disabledHeading": "Les fonctionnalités suivantes sont désactivées :",
|
||||
"features": {
|
||||
"dialIn": "Appel téléphonique",
|
||||
"meetingRoom": "Appareils de salle de réunion"
|
||||
}
|
||||
}
|
||||
},
|
||||
"transcript": {
|
||||
@@ -517,8 +499,7 @@
|
||||
"label": "Restreindre",
|
||||
"description": "Les personnes qui n'ont pas été invitées à la réunion doivent demander à la rejoindre."
|
||||
}
|
||||
},
|
||||
"encryptedLocked": "Les réunions chiffrées sont toujours restreintes — les invités patientent dans le salon d'attente jusqu'à ce que vous les autorisiez."
|
||||
}
|
||||
},
|
||||
"moderation": {
|
||||
"title": "Modération de la réunion",
|
||||
@@ -535,6 +516,19 @@
|
||||
"label": "Partager leur écran",
|
||||
"description": "En désactivant cette option, les participants ne pourront plus partager leur écran et tout partage en cours sera immédiatement interrompu."
|
||||
}
|
||||
},
|
||||
"encryption": {
|
||||
"title": "Chiffrement",
|
||||
"description": "Mettre temporairement le chiffrement de bout en bout en pause pour qu'un téléphone ou un autre appareil externe puisse rejoindre. Le lien de la réunion porte toujours la clé, vous pouvez réactiver à tout moment.",
|
||||
"toggle": {
|
||||
"label": "Mettre le chiffrement en pause",
|
||||
"descriptionLive": "Le chiffrement est actif pour cette réunion.",
|
||||
"descriptionPaused": "Le chiffrement est en pause — les appareils externes peuvent rejoindre en audio et vidéo non chiffrés."
|
||||
},
|
||||
"blocked": {
|
||||
"recording": "Le chiffrement ne peut pas être réactivé tant qu'un enregistrement est en cours. Arrêtez l'enregistrement d'abord.",
|
||||
"transcript": "Le chiffrement ne peut pas être réactivé tant qu'une transcription est en cours. Arrêtez la transcription d'abord."
|
||||
}
|
||||
}
|
||||
},
|
||||
"rating": {
|
||||
@@ -697,30 +691,61 @@
|
||||
"screenShare": "Écran de {{name}}"
|
||||
},
|
||||
"identity": {
|
||||
"anonymous": {
|
||||
"tooltip": "Cet utilisateur n'est pas authentifié"
|
||||
}
|
||||
"proconnect": "Connecté",
|
||||
"anonymous": "Anonyme"
|
||||
},
|
||||
"roomStatus": {
|
||||
"encrypted": "Chiffrée de bout en bout",
|
||||
"encrypted": "Chiffré de bout en bout",
|
||||
"paused": "Chiffrement en pause",
|
||||
"recording": "Enregistrement en cours",
|
||||
"transcribing": "Transcription en cours"
|
||||
},
|
||||
"encryption": {
|
||||
"mismatch": {
|
||||
"missingPassphrase": {
|
||||
"title": "Cette réunion nécessite une phrase secrète",
|
||||
"body": "Le lien que vous utilisez ne contient pas la clé de chiffrement. Demandez au créateur de partager le lien complet, ou créez une nouvelle réunion chiffrée."
|
||||
"title": "Cette réunion nécessite une phrase secrète de chiffrement",
|
||||
"body": "Le lien que vous avez utilisé ne contient pas la clé de chiffrement. Demandez à l'organisateur de partager le lien complet, ou créez une nouvelle réunion chiffrée."
|
||||
},
|
||||
"unexpectedPassphrase": {
|
||||
"title": "Cette réunion n'est pas chiffrée",
|
||||
"body": "Le lien contient une clé de chiffrement mais la réunion n'est pas configurée pour le chiffrement bout-en-bout. Le lien a peut-être été altéré. Créez une nouvelle réunion chiffrée pour préserver la confidentialité."
|
||||
"body": "Le lien contient une clé de chiffrement mais la réunion n'est pas configurée pour le chiffrement de bout en bout. Le lien a peut-être été altéré. Créez une nouvelle réunion chiffrée pour garder vos échanges sécurisés."
|
||||
},
|
||||
"backHome": "Retour à l'accueil"
|
||||
"createFresh": "Créer une nouvelle réunion chiffrée"
|
||||
},
|
||||
"pauseConfirm": {
|
||||
"title": {
|
||||
"recording": "Activer l'enregistrement ?",
|
||||
"transcript": "Activer la transcription ?"
|
||||
},
|
||||
"description": "Le chiffrement sera mis en pause pendant cette opération. Le serveur a besoin d'un accès temporaire au contenu pour la fournir.",
|
||||
"learnMore": "Le chiffrement reprendra automatiquement à l'arrêt de la fonctionnalité.",
|
||||
"cancel": "Annuler",
|
||||
"confirm": {
|
||||
"recording": "Activer",
|
||||
"transcript": "Activer"
|
||||
}
|
||||
},
|
||||
"snackbar": {
|
||||
"pausedTitle": "Chiffrement en pause",
|
||||
"pausedByMeTitle": "Vous avez mis le chiffrement en pause",
|
||||
"reasonTranscript": "Le chiffrement est en pause pendant la transcription. Il reprendra à l'arrêt.",
|
||||
"reasonRecording": "Le chiffrement est en pause pendant l'enregistrement. Il reprendra à l'arrêt.",
|
||||
"reasonManual": "Un administrateur a désactivé le chiffrement pour cette réunion.",
|
||||
"reasonSip": "Le chiffrement a été mis en pause pour permettre à un participant téléphonique de rejoindre.",
|
||||
"sipTitle": "Un participant ne peut pas déchiffrer cette réunion",
|
||||
"sipBody": "{{name}} a rejoint par téléphone ou un appareil incompatible avec le chiffrement. Mettez le chiffrement en pause depuis le panneau Admin pour le laisser entrer.",
|
||||
"openAdmin": "Ouvrir l'admin",
|
||||
"dismiss": "OK"
|
||||
},
|
||||
"sipBlocked": {
|
||||
"title": "Impossible de déchiffrer ce participant",
|
||||
"bodyAdmin": "Ce participant a rejoint depuis un téléphone ou un appareil qui ne prend pas en charge le chiffrement de bout en bout. Mettez le chiffrement en pause depuis le panneau Admin pour relayer son audio et sa vidéo.",
|
||||
"bodyParticipant": "Ce participant a rejoint depuis un téléphone ou un appareil qui ne prend pas en charge le chiffrement de bout en bout. Demandez à l'hôte de mettre le chiffrement en pause pour lui permettre d'entrer.",
|
||||
"openAdmin": "Ouvrir l'admin"
|
||||
},
|
||||
"decryptionFailed": {
|
||||
"title": "Échec du déchiffrement",
|
||||
"body": "Vérifiez que vous et cette personne utilisez bien le même lien de réunion. Si seule cette personne est concernée, le problème vient probablement de chez elle."
|
||||
"body": "Vérifiez que vous et cette personne utilisez le même lien de réunion. Si vous êtes le seul à ne pas la voir, le problème vient probablement de son côté."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,13 +9,33 @@
|
||||
},
|
||||
"security": {
|
||||
"heading": "Sécurité",
|
||||
"subtitle": "Ajoutez des options de sécurité.",
|
||||
"signInRequired": "Connectez-vous pour configurer vos préférences de chiffrement.",
|
||||
"featureDisabled": "Le chiffrement de bout en bout n'est pas disponible sur ce serveur.",
|
||||
"encryption": {
|
||||
"label": "Chiffrement de bout en bout",
|
||||
"description": "Vous pouvez créer des réunions chiffrées à la demande. Seuls les participants ont accès au contenu, pas même nos serveurs.",
|
||||
"learnMore": "En savoir plus"
|
||||
"defaultHeading": "Nouvelles réunions",
|
||||
"toggle": {
|
||||
"label": "Chiffrer les nouvelles réunions par défaut",
|
||||
"description": "Lorsque cette option est activée, chaque nouvelle réunion que vous créez démarre chiffrée de bout en bout. Vous pouvez toujours mettre le chiffrement en pause pour une réunion donnée depuis ce même panneau Sécurité."
|
||||
},
|
||||
"confirmModal": {
|
||||
"title": "Activer le chiffrement ?",
|
||||
"description": "Pendant que le chiffrement est actif, ces fonctionnalités sont indisponibles. Vous pourrez désactiver le chiffrement pour des réunions individuelles si besoin.",
|
||||
"items": {
|
||||
"phone": "Appel téléphonique entrant",
|
||||
"devices": "Salles de réunion connectées",
|
||||
"transcription": "Transcription",
|
||||
"recording": "Enregistrement"
|
||||
},
|
||||
"footnote": "Vous pouvez modifier cette préférence à tout moment.",
|
||||
"cancel": "Annuler",
|
||||
"confirm": "Activer"
|
||||
},
|
||||
"thisMeeting": {
|
||||
"heading": "Cette réunion",
|
||||
"toggle": {
|
||||
"label": "Mettre le chiffrement en pause",
|
||||
"descriptionLive": "Le chiffrement est actif. Mettez-le en pause pour laisser un téléphone ou un autre appareil externe rejoindre — le lien porte toujours la clé de chiffrement, vous pourrez le réactiver à tout moment.",
|
||||
"descriptionPaused": "Le chiffrement est en pause. Les appareils externes peuvent rejoindre en audio et vidéo non chiffrés. Désactivez pour réactiver la protection."
|
||||
}
|
||||
}
|
||||
},
|
||||
"preferences": {
|
||||
|
||||
@@ -6,7 +6,7 @@ export const navigateTo = <S = unknown>(
|
||||
routeName: RouteName,
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
params?: any,
|
||||
options?: { replace?: boolean; state?: S; hash?: string }
|
||||
options?: { replace?: boolean; state?: S }
|
||||
) => {
|
||||
const route = getRouteByName(routeName)
|
||||
const to = route.to
|
||||
@@ -17,10 +17,5 @@ export const navigateTo = <S = unknown>(
|
||||
if (!to) {
|
||||
throw new Error(`Can't find path to navigate to for ${routeName}`)
|
||||
}
|
||||
// Including the hash in the URL passed to `navigate` lets us avoid a
|
||||
// brittle pushState + replaceState dance at the call site: the URL the
|
||||
// app first renders already carries the fragment.
|
||||
const { hash, ...navigateOptions } = options ?? {}
|
||||
const target = hash ? `${to}#${hash}` : to
|
||||
return navigate(target, navigateOptions)
|
||||
return navigate(to, options)
|
||||
}
|
||||
|
||||
@@ -135,9 +135,8 @@ export const Checkbox = ({
|
||||
<StyledCheckbox {...props}>
|
||||
{(renderProps) => {
|
||||
if (renderProps.isInvalid && !!props.validate) {
|
||||
const next = props.validate(renderProps.isSelected)
|
||||
if (next !== error) setError(next)
|
||||
} else if (error !== null) {
|
||||
setError(props.validate(renderProps.isSelected))
|
||||
} else {
|
||||
setError(null)
|
||||
}
|
||||
return (
|
||||
|
||||
@@ -58,12 +58,7 @@ const StyledLabel = styled(Label, {
|
||||
|
||||
type OmittedRACProps = 'type' | 'label' | 'items' | 'description' | 'validate'
|
||||
type Items<T = ReactNode> = {
|
||||
items: Array<{
|
||||
value: string
|
||||
description?: string
|
||||
label: T
|
||||
isDisabled?: boolean
|
||||
}>
|
||||
items: Array<{ value: string; description?: string; label: T; isDisabled?: boolean }>
|
||||
}
|
||||
type PartialTextFieldProps = Omit<TextFieldProps, OmittedRACProps>
|
||||
type PartialCheckboxProps = Omit<CheckboxProps, OmittedRACProps>
|
||||
@@ -115,7 +110,7 @@ type FieldProps<T extends object> = (
|
||||
} & PartialSwitchProps)
|
||||
) & {
|
||||
label: string
|
||||
description?: ReactNode
|
||||
description?: string
|
||||
wrapperProps?: React.ComponentProps<typeof FieldWrapper>
|
||||
labelProps?: React.ComponentProps<typeof StyledLabel>
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user