Compare commits

...

20 Commits

Author SHA1 Message Date
lebaudantoine c6cb735a09 wip poc a email notification 2025-04-10 15:59:38 +02:00
lebaudantoine 461ab70918 fixup! wip generate secure signed link to download file from the storage 2025-04-10 15:47:52 +02:00
lebaudantoine bc6bf73e71 wip introduce property is_saved 2025-04-10 15:44:37 +02:00
lebaudantoine 566b085ede fixup! wip introduce screen recording 2025-04-10 15:43:59 +02:00
lebaudantoine 84d51bc86c wip add La Suite logo 2025-04-10 14:47:25 +02:00
lebaudantoine 66eab072ee wip extract key computation in a proper property method 2025-04-10 14:46:47 +02:00
lebaudantoine f390ad46d5 wip page to download a recording 2025-04-09 23:02:40 +02:00
lebaudantoine 6bdf1d173b wip generate secure signed link to download file from the storage 2025-04-09 20:30:45 +02:00
lebaudantoine 02cc74e06f wip update serializer and viewset for recordings 2025-04-09 20:30:18 +02:00
lebaudantoine 784d97efd6 wip introduce utils to generate s3 authorization headers 2025-04-09 20:29:46 +02:00
lebaudantoine 1f44edcdf3 wip create a new ingress for media file 2025-04-09 20:28:54 +02:00
lebaudantoine 92e87fcd32 wip enable one of the recording not both 2025-04-09 11:32:45 +02:00
lebaudantoine 5138f66262 wip introduce screen recording 2025-04-07 22:06:28 +02:00
lebaudantoine 9a21404d23 wip introduce hook useHasScreenRecordingAccess 2025-04-07 22:06:28 +02:00
lebaudantoine 85784419a3 wip make generic and reusable the recording state toast 2025-04-07 22:06:28 +02:00
lebaudantoine 3431df05af wip generalize recording toast key name 2025-04-07 22:06:28 +02:00
lebaudantoine 4e9dd87a7a wip support new sub panel id 'recording' 2025-04-07 22:06:28 +02:00
lebaudantoine e4c27aa840 wip handle recording notification 2025-04-07 22:06:28 +02:00
lebaudantoine 280cd01df5 wip clean dead code 2025-04-07 11:03:16 +02:00
lebaudantoine 1a552282a5 wip refactor transcript keys 2025-04-07 10:53:00 +02:00
46 changed files with 1089 additions and 98 deletions
+1 -1
View File
@@ -156,7 +156,7 @@ class RecordingSerializer(serializers.ModelSerializer):
class Meta:
model = models.Recording
fields = ["id", "room", "created_at", "updated_at", "status"]
fields = ["id", "room", "created_at", "updated_at", "status", "mode"]
read_only_fields = fields
+85 -2
View File
@@ -2,6 +2,8 @@
import uuid
from logging import getLogger
from urllib.parse import urlparse
from django.conf import settings
from django.db.models import Q
@@ -20,7 +22,7 @@ from rest_framework import (
status as drf_status,
)
from core import models, utils
from core import models, utils, enums
from core.recording.event.authentication import StorageEventAuthentication
from core.recording.event.exceptions import (
InvalidBucketError,
@@ -544,6 +546,7 @@ class ResourceAccessViewSet(
class RecordingViewSet(
mixins.DestroyModelMixin,
mixins.ListModelMixin,
mixins.RetrieveModelMixin,
viewsets.GenericViewSet,
):
"""
@@ -591,7 +594,7 @@ class RecordingViewSet(
)
try:
recording = models.Recording.objects.get(id=recording_id)
recording = models.Recording.objects.select_related('room').get(id=recording_id)
except models.Recording.DoesNotExist as e:
raise drf_exceptions.NotFound("No recording found for this event.") from e
@@ -617,3 +620,83 @@ class RecordingViewSet(
return drf_response.Response(
{"message": "Event processed."},
)
def _auth_get_original_url(self, request):
"""
Extracts and parses the original URL from the "HTTP_X_ORIGINAL_URL" header.
Raises PermissionDenied if the header is missing.
The original url is passed by nginx in the "HTTP_X_ORIGINAL_URL" header.
See corresponding ingress configuration in Helm chart and read about the
nginx.ingress.kubernetes.io/auth-url annotation to understand how the Nginx ingress
is configured to do this.
Based on the original url and the logged-in user, we must decide if we authorize Nginx
to let this request go through (by returning a 200 code) or if we block it (by returning
a 403 error). Note that we return 403 errors without any further details for security
reasons.
"""
# Extract the original URL from the request header
original_url = request.META.get("HTTP_X_ORIGINAL_URL")
if not original_url:
logger.debug("Missing HTTP_X_ORIGINAL_URL header in subrequest")
raise drf_exceptions.PermissionDenied()
logger.debug("Original url: '%s'", original_url)
return urlparse(original_url)
def _auth_get_url_params(self, pattern, fragment):
"""
Extracts URL parameters from the given fragment using the specified regex pattern.
Raises PermissionDenied if parameters cannot be extracted.
"""
match = pattern.search(fragment)
try:
return match.groupdict()
except (ValueError, AttributeError) as exc:
logger.debug("Failed to extract parameters from subrequest URL: %s", exc)
raise drf_exceptions.PermissionDenied() from exc
@decorators.action(detail=False, methods=["get"], url_path="media-auth")
def media_auth(self, request, *args, **kwargs):
"""
This view is used by an Nginx subrequest to control access to a recording's
media file.
When we let the request go through, we compute authorization headers that will be added to
the request going through thanks to the nginx.ingress.kubernetes.io/auth-response-headers
annotation. The request will then be proxied to the object storage backend who will
respond with the file after checking the signature included in headers.
"""
parsed_url = self._auth_get_original_url(request)
url_params = self._auth_get_url_params(
enums.RECORDING_STORAGE_URL_PATTERN, parsed_url.path
)
user = request.user
recording_id = url_params['recording']
try:
recording = models.Recording.objects.get(id=recording_id)
except models.Recording.DoesNotExist as e:
raise drf_exceptions.NotFound("No recording found for this event.") from e
abilities = recording.get_abilities(user)
if not abilities["retrieve"]:
logger.debug("User '%s' lacks permission for attachment", user)
raise drf_exceptions.PermissionDenied()
if not recording.is_saved:
logger.debug("Recording '%s' has not been saved", recording)
raise drf_exceptions.PermissionDenied()
# Generate S3 authorization headers using the extracted URL parameters
request = utils.generate_s3_authorization_headers(recording.key)
return drf_response.Response("authorized", headers=request.headers, status=200)
+12
View File
@@ -1,10 +1,22 @@
"""
Core application enums declaration
"""
import re
from django.conf import global_settings, settings
from django.utils.translation import gettext_lazy as _
UUID_REGEX = (
r"[a-fA-F0-9]{8}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{12}"
)
FILE_EXT_REGEX = r"\.[a-zA-Z0-9]{1,10}"
RECORDING_FOLDER = 'recordings'
RECORDING_STORAGE_URL_PATTERN = re.compile(
f"/media/recordings/(?P<recording>{UUID_REGEX:s})(?P<extension>{FILE_EXT_REGEX:s})"
)
# Django sets `LANGUAGES` by default with all supported languages. We can use it for
# the choice of languages which should not be limited to the few languages active in
# the app.
+12
View File
@@ -576,6 +576,18 @@ class Recording(BaseModel):
RecordingStatusChoices.STOPPED,
}
@property
def is_saved(self) -> bool:
"""Wip"""
return self.status in {
RecordingStatusChoices.SAVED,
}
@property
def key(self):
"""Wip."""
extension = "ogg" if self.mode == RecordingModeChoices.TRANSCRIPT else "mp4"
return f"{settings.RECORDING_OUTPUT_FOLDER}/{self.id}.{extension}"
class RecordingAccess(BaseAccess):
"""Relation model to give access to a recording for a user or a team with a role."""
@@ -1,8 +1,14 @@
"""Service to notify external services when a new recording is ready."""
import logging
import smtplib
from django.conf import settings
from django.template.loader import render_to_string
from django.utils.translation import get_language, override
from django.utils.translation import gettext_lazy as _
from django.contrib.sites.models import Site
from django.core.mail import send_mail
import requests
@@ -21,10 +27,7 @@ class NotificationService:
return self._notify_summary_service(recording)
if recording.mode == models.RecordingModeChoices.SCREEN_RECORDING:
logger.warning(
"Screen recording mode not implemented for recording %s", recording.id
)
return False
return self._notify_user_by_email(recording)
logger.error(
"Unknown recording mode %s for recording %s",
@@ -33,6 +36,65 @@ class NotificationService:
)
return False
@staticmethod
def _notify_user_by_email(recording) -> bool:
"""Wip."""
# subject
# emails
domain = Site.objects.get_current().domain
owner_accesses = (
models.RecordingAccess.objects.select_related("user")
.filter(
role=models.RoleChoices.OWNER,
recording_id=recording.id,
)
.all()
)
if not owner_accesses:
logger.error("No owner found for recording %s", recording.id)
return False
language = get_language()
context = (
{
"brandname": settings.EMAIL_BRAND_NAME,
"support_email": settings.EMAIL_SUPPORT_EMAIL,
"logo_img": settings.EMAIL_LOGO_IMG,
"domain": domain,
"room_name": recording.room.name,
"recording_date": recording.created_at.strftime("%A %d %B %Y"),
"recording_time": recording.created_at.strftime("%H:%M"),
"link": f"{domain}/recordings/{recording.id}",
}
)
emails = [access.user.email for access in owner_accesses]
with override(language):
msg_html = render_to_string("mail/html/screen_recording.html", context)
msg_plain = render_to_string("mail/text/screen_recording.txt", context)
subject = str(_("Your recording is ready")) # Force translation
try:
send_mail(
subject.capitalize(),
msg_plain,
settings.EMAIL_FROM,
emails,
html_message=msg_html,
fail_silently=False,
)
except smtplib.SMTPException as exception:
logger.error("notification to %s was not sent: %s", emails, exception)
return False
return True
@staticmethod
def _notify_summary_service(recording):
"""Notify summary service about a new recording."""
@@ -57,10 +119,8 @@ class NotificationService:
logger.error("No owner found for recording %s", recording.id)
return False
key = f"{settings.RECORDING_OUTPUT_FOLDER}/{recording.id}.ogg"
payload = {
"filename": key,
"filename": recording.key,
"email": owner_access.user.email,
"sub": owner_access.user.sub,
}
+35
View File
@@ -14,6 +14,10 @@ from django.conf import settings
from livekit.api import AccessToken, VideoGrants
from django.core.files.storage import default_storage
import botocore
def generate_color(identity: str) -> str:
"""Generates a consistent HSL color based on a given identity string.
@@ -110,3 +114,34 @@ def generate_livekit_config(
room=room_id, user=user, username=username, color=color
),
}
def generate_s3_authorization_headers(key):
"""
Generate authorization headers for an s3 object.
These headers can be used as an alternative to signed urls with many benefits:
- the urls of our files never expire and can be stored in our recording' metadata
- we don't leak authorized urls that could be shared (file access can only be done
with cookies)
- access control is truly realtime
- the object storage service does not need to be exposed on internet
"""
url = default_storage.unsigned_connection.meta.client.generate_presigned_url(
"get_object",
ExpiresIn=0,
Params={"Bucket": default_storage.bucket_name, "Key": key},
)
request = botocore.awsrequest.AWSRequest(method="get", url=url)
s3_client = default_storage.connection.meta.client
# pylint: disable=protected-access
credentials = s3_client._request_signer._credentials # noqa: SLF001
frozen_credentials = credentials.get_frozen_credentials()
region = s3_client.meta.region_name
auth = botocore.auth.S3SigV4Auth(frozen_credentials, "s3", region)
auth.add_auth(request)
return request
+3
View File
@@ -318,6 +318,9 @@ class Base(Configuration):
EMAIL_USE_TLS = values.BooleanValue(False)
EMAIL_USE_SSL = values.BooleanValue(False)
EMAIL_FROM = values.Value("from@example.com")
EMAIL_BRAND_NAME = values.Value(None)
EMAIL_SUPPORT_EMAIL = values.Value(None)
EMAIL_LOGO_IMG = values.Value(None)
AUTH_USER_MODEL = "core.User"
Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 704 KiB

@@ -90,6 +90,8 @@ export const MainNotificationToast = () => {
break
case NotificationType.TranscriptionStarted:
case NotificationType.TranscriptionStopped:
case NotificationType.ScreenRecordingStarted:
case NotificationType.ScreenRecordingStopped:
toastQueue.add(
{
participant,
@@ -8,4 +8,6 @@ export enum NotificationType {
ParticipantWaiting = 'participantWaiting',
TranscriptionStarted = 'transcriptionStarted',
TranscriptionStopped = 'transcriptionStopped',
ScreenRecordingStarted = 'screenRecordingStarted',
ScreenRecordingStopped = 'screenRecordingStopped',
}
@@ -48,7 +48,7 @@ export function Toast({ state, ...props }: ToastProps) {
return (
<StyledToastContainer {...toastProps} ref={ref}>
<StyledToast>
<div {...contentProps}>{props.toast.content?.message} machine a</div>
<div {...contentProps}>{props.toast.content?.message}</div>
<Button square size="sm" invisible {...closeButtonProps}>
<RiCloseLine color="white" />
</Button>
@@ -1,19 +1,34 @@
import { useToast } from '@react-aria/toast'
import { useRef } from 'react'
import { useMemo, useRef } from 'react'
import { StyledToastContainer, ToastProps } from './Toast'
import { HStack } from '@/styled-system/jsx'
import { useTranslation } from 'react-i18next'
import { NotificationType } from '../NotificationType'
export function ToastTranscript({ state, ...props }: ToastProps) {
export function ToastAnyRecording({ state, ...props }: ToastProps) {
const { t } = useTranslation('notifications')
const ref = useRef(null)
const { toastProps, contentProps } = useToast(props, state, ref)
const participant = props.toast.content.participant
const type = props.toast.content.type
const key = `recording${type == NotificationType.TranscriptionStarted ? 'Started' : 'Stopped'}`
const key = useMemo(() => {
switch (type) {
case NotificationType.TranscriptionStarted:
return 'transcript.started'
case NotificationType.TranscriptionStopped:
return 'transcript.stopped'
case NotificationType.ScreenRecordingStarted:
return 'screenRecording.started'
case NotificationType.ScreenRecordingStopped:
return 'screenRecording.stopped'
default:
return
}
}, [type])
if (!key) return
return (
<StyledToastContainer {...toastProps} ref={ref}>
@@ -9,7 +9,7 @@ import { ToastRaised } from './ToastRaised'
import { ToastMuted } from './ToastMuted'
import { ToastMessageReceived } from './ToastMessageReceived'
import { ToastLowerHand } from './ToastLowerHand'
import { ToastTranscript } from './ToastTranscript'
import { ToastAnyRecording } from './ToastAnyRecording'
interface ToastRegionProps extends AriaToastRegionProps {
state: ToastState<ToastData>
@@ -39,7 +39,9 @@ const renderToast = (
case NotificationType.TranscriptionStarted:
case NotificationType.TranscriptionStopped:
return <ToastTranscript key={toast.key} toast={toast} state={state} />
case NotificationType.ScreenRecordingStarted:
case NotificationType.ScreenRecordingStopped:
return <ToastAnyRecording key={toast.key} toast={toast} state={state} />
default:
return <Toast key={toast.key} toast={toast} state={state} />
@@ -0,0 +1,19 @@
import { fetchApi } from '@/api/fetchApi'
import { ApiRoom } from '@/features/rooms/api/ApiRoom'
import { RecordingMode } from '@/features/rooms/api/startRecording'
export type RecordingApi = {
id: string
room: Pick<ApiRoom, 'id' | 'name' | 'slug' | 'access_level'>
created_at: string
mode: RecordingMode
}
export const fetchRecording = ({
recordingId,
}: {
roomId: string
username?: string
}) => {
return fetchApi<RecordingApi>(`/recordings/${recordingId}/`)
}
@@ -0,0 +1 @@
export { Recording as RecordingRoute } from './routes/Recording'
@@ -0,0 +1,107 @@
import { Center, VStack } from '@/styled-system/jsx'
import { Bold, Button, Field, H, Input, LinkButton, Text } from '@/primitives'
import { Screen } from '@/layout/Screen.tsx'
import { useParams } from 'wouter'
import { fetchRecording } from '../api/fetchRecording'
import { useQuery } from '@tanstack/react-query'
import { Spinner } from '@/primitives/Spinner'
import { UserAware, useUser } from '@/features/auth'
import { CenteredContent } from '@/layout/CenteredContent.tsx'
import { useState } from 'react'
import fourthSlide from '@/assets/intro-slider/4_record.png'
import { css } from '@/styled-system/css'
import { RecordingMode } from '@/features/rooms/api/startRecording.ts'
function formatDate(date, format = 'YYYY-MM-DD') {
// Accept Date object or create one from string
const dateObj = date instanceof Date ? date : new Date(date)
// Check if the date is valid
if (isNaN(dateObj.getTime())) {
return 'Invalid Date'
}
const year = dateObj.getFullYear()
const month = dateObj.getMonth() + 1 // getMonth() returns 0-11
const day = dateObj.getDate()
const hours = dateObj.getHours()
const minutes = dateObj.getMinutes()
const seconds = dateObj.getSeconds()
// Pad numbers with leading zeros
const pad = (num) => String(num).padStart(2, '0')
// Replace format tokens with actual values
return format
.replace('YYYY', year)
.replace('MM', pad(month))
.replace('DD', pad(day))
.replace('HH', pad(hours))
.replace('mm', pad(minutes))
.replace('ss', pad(seconds))
}
export const Recording = () => {
const { recordingId } = useParams()
const { isLoggedIn } = useUser()
const { data, isLoading, isError } = useQuery({
queryKey: ['recording', recordingId],
queryFn: () => fetchRecording({ recordingId }),
retry: false,
})
if (isLoading) {
return (
<Screen layout="centered" footer={false}>
<CenteredContent>
<Spinner />
</CenteredContent>
</Screen>
)
}
if (isError || !isLoggedIn) {
return (
<Screen layout="centered" footer={false}>
<CenteredContent title={'lien invalide'} withBackButton>
<Text centered>
Vous ne pouvez pas accéder à ce lien d'enregistrement
</Text>
</CenteredContent>
</Screen>
)
}
return (
<UserAware>
<Screen layout="centered" footer={false}>
<Center>
<VStack>
<img
src={fourthSlide}
alt={''}
className={css({
maxHeight: '309px',
})}
/>
<H lvl={1} centered>
Votre enregistrement est prêt !
</H>
<Text centered margin="md">
Enregistrement de la réunion <b>{data.room.name}</b> du{' '}
{formatDate(data.created_at, 'YYYY-MM-DD HH:mm')}. <br /> Cet
enregistrement est disponible <u>pendant 7 jours</u>.
</Text>
<LinkButton
href={`https://meet.127.0.0.1.nip.io/media/recordings/${data.id}.${data.mode == RecordingMode.ScreenRecording ? 'mp4' : 'ogg'}`}
download={`${data.room.name}-${formatDate(data.created_at)}`}
>
Télécharger
</LinkButton>
</VStack>
</Center>
</Screen>
</UserAware>
)
}
@@ -8,20 +8,22 @@ import { Text } from '@/primitives'
import { RemoteParticipant, RoomEvent } from 'livekit-client'
import { decodeNotificationDataReceived } from '@/features/notifications/utils'
import { NotificationType } from '@/features/notifications/NotificationType'
import { TranscriptionStatus, transcriptionStore } from '@/stores/transcription'
import { RecordingStatus, recordingStore } from '@/stores/recording'
export const TranscriptStateToast = () => {
const { t } = useTranslation('rooms', { keyPrefix: 'recording.transcript' })
export const RecordingStateToast = () => {
const { t } = useTranslation('rooms', {
keyPrefix: 'recordingToast',
})
const room = useRoomContext()
const transcriptionSnap = useSnapshot(transcriptionStore)
const recordingSnap = useSnapshot(recordingStore)
useEffect(() => {
if (room.isRecording) {
transcriptionStore.status = TranscriptionStatus.STARTED
if (room.isRecording && recordingSnap.status == RecordingStatus.STOPPED) {
recordingStore.status = RecordingStatus.ANY_STARTED
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
}, [room.isRecording])
useEffect(() => {
const handleDataReceived = (
@@ -34,10 +36,16 @@ export const TranscriptStateToast = () => {
switch (notification.type) {
case NotificationType.TranscriptionStarted:
transcriptionStore.status = TranscriptionStatus.STARTING
recordingStore.status = RecordingStatus.TRANSCRIPT_STARTING
break
case NotificationType.TranscriptionStopped:
transcriptionStore.status = TranscriptionStatus.STOPPING
recordingStore.status = RecordingStatus.TRANSCRIPT_STOPPING
break
case NotificationType.ScreenRecordingStarted:
recordingStore.status = RecordingStatus.SCREEN_RECORDING_STARTING
break
case NotificationType.ScreenRecordingStopped:
recordingStore.status = RecordingStatus.SCREEN_RECORDING_STOPPING
break
default:
return
@@ -45,9 +53,17 @@ export const TranscriptStateToast = () => {
}
const handleRecordingStatusChanged = (status: boolean) => {
transcriptionStore.status = status
? TranscriptionStatus.STARTED
: TranscriptionStatus.STOPPED
if (!status) {
recordingStore.status = RecordingStatus.STOPPED
} else if (recordingSnap.status == RecordingStatus.TRANSCRIPT_STARTING) {
recordingStore.status = RecordingStatus.TRANSCRIPT_STARTED
} else if (
recordingSnap.status == RecordingStatus.SCREEN_RECORDING_STARTING
) {
recordingStore.status = RecordingStatus.SCREEN_RECORDING_STARTED
} else {
recordingStore.status = RecordingStatus.ANY_STARTED
}
}
room.on(RoomEvent.DataReceived, handleDataReceived)
@@ -57,20 +73,30 @@ export const TranscriptStateToast = () => {
room.off(RoomEvent.DataReceived, handleDataReceived)
room.off(RoomEvent.RecordingStatusChanged, handleRecordingStatusChanged)
}
}, [room])
}, [room, recordingSnap])
const key = useMemo(() => {
switch (transcriptionSnap.status) {
case TranscriptionStatus.STOPPING:
return 'stopping'
case TranscriptionStatus.STARTING:
return 'starting'
switch (recordingSnap.status) {
case RecordingStatus.TRANSCRIPT_STARTED:
return 'transcript.started'
case RecordingStatus.TRANSCRIPT_STOPPING:
return 'transcript.stopping'
case RecordingStatus.TRANSCRIPT_STARTING:
return 'transcript.starting'
case RecordingStatus.SCREEN_RECORDING_STARTED:
return 'screenRecording.started'
case RecordingStatus.SCREEN_RECORDING_STOPPING:
return 'screenRecording.stopping'
case RecordingStatus.SCREEN_RECORDING_STARTING:
return 'screenRecording.starting'
case RecordingStatus.ANY_STARTED:
return 'any.started'
default:
return 'started'
return
}
}, [transcriptionSnap])
}, [recordingSnap])
if (transcriptionSnap.status == TranscriptionStatus.STOPPED) return
if (!key) return
return (
<div
@@ -0,0 +1,166 @@
import { A, Button, Div, Text } from '@/primitives'
import fourthSlide from '@/assets/intro-slider/4_record.png'
import { css } from '@/styled-system/css'
import { useRoomId } from '@/features/rooms/livekit/hooks/useRoomId'
import { useRoomContext } from '@livekit/components-react'
import {
RecordingMode,
useStartRecording,
} from '@/features/rooms/api/startRecording'
import { useStopRecording } from '@/features/rooms/api/stopRecording'
import { useEffect, useMemo, useState } from 'react'
import { RoomEvent } from 'livekit-client'
import { useTranslation } from 'react-i18next'
import { NotificationPayload } from '@/features/notifications/NotificationPayload'
import { NotificationType } from '@/features/notifications/NotificationType'
import { RecordingStatus, recordingStore } from '@/stores/recording'
import { CRISP_HELP_ARTICLE_RECORDING } from '@/utils/constants'
import { useIsRecordingTransitioning } from '../hooks/useIsRecordingTransitioning'
import {
useIsScreenRecordingStarted,
useIsTranscriptStarted,
} from '../hooks/useIsRecordingStarted'
export const ScreenRecording = () => {
const [isLoading, setIsLoading] = useState(false)
const { t } = useTranslation('rooms', { keyPrefix: 'screenRecording' })
const roomId = useRoomId()
const { mutateAsync: startRecordingRoom } = useStartRecording()
const { mutateAsync: stopRecordingRoom } = useStopRecording()
const isScreenRecordingStarted = useIsScreenRecordingStarted()
const isTranscriptStarted = useIsTranscriptStarted()
const room = useRoomContext()
const isRecordingTransitioning = useIsRecordingTransitioning()
useEffect(() => {
const handleRecordingStatusChanged = () => {
setIsLoading(false)
}
room.on(RoomEvent.RecordingStatusChanged, handleRecordingStatusChanged)
return () => {
room.off(RoomEvent.RecordingStatusChanged, handleRecordingStatusChanged)
}
}, [room])
const notifyParticipant = async (status: NotificationType) => {
const encoder = new TextEncoder()
const payload: NotificationPayload = {
type: status,
}
const data = encoder.encode(JSON.stringify(payload))
await room.localParticipant.publishData(data, {
reliable: true,
})
}
const handleScreenRecording = async () => {
if (!roomId) {
console.warn('No room ID found')
return
}
try {
setIsLoading(true)
if (room.isRecording) {
await stopRecordingRoom({ id: roomId })
await notifyParticipant(NotificationType.ScreenRecordingStopped)
recordingStore.status = RecordingStatus.SCREEN_RECORDING_STOPPING
} else {
await startRecordingRoom({
id: roomId,
mode: RecordingMode.ScreenRecording,
})
await notifyParticipant(NotificationType.ScreenRecordingStarted)
recordingStore.status = RecordingStatus.SCREEN_RECORDING_STARTING
}
} catch (error) {
console.error('Failed to handle transcript:', error)
setIsLoading(false)
}
}
const isDisabled = useMemo(
() => isLoading || isRecordingTransitioning || isTranscriptStarted,
[isLoading, isRecordingTransitioning, isTranscriptStarted]
)
return (
<Div
display="flex"
overflowY="scroll"
padding="0 1.5rem"
flexGrow={1}
flexDirection="column"
alignItems="center"
>
<img
src={fourthSlide}
alt={''}
className={css({
minHeight: '309px',
marginBottom: '1rem',
})}
/>
{isScreenRecordingStarted ? (
<>
<Text>{t('stop.heading')}</Text>
<Text
variant="note"
wrap={'pretty'}
centered
className={css({
textStyle: 'sm',
marginBottom: '2.5rem',
marginTop: '0.25rem',
})}
>
{t('stop.body')}
</Text>
<Button
isDisabled={isDisabled}
onPress={() => handleScreenRecording()}
data-attr="stop-transcript"
size="sm"
variant="tertiary"
>
{t('stop.button')}
</Button>
</>
) : (
<>
<Text>{t('start.heading')}</Text>
<Text
variant="note"
wrap={'pretty'}
centered
className={css({
textStyle: 'sm',
maxWidth: '90%',
marginBottom: '2.5rem',
marginTop: '0.25rem',
})}
>
{t('start.body')} <br />{' '}
<A href={CRISP_HELP_ARTICLE_RECORDING} target="_blank">
{t('start.linkMore')}
</A>
</Text>
<Button
isDisabled={isDisabled}
onPress={() => handleScreenRecording()}
data-attr="start-transcript"
size="sm"
variant="tertiary"
>
{t('start.button')}
</Button>
</>
)}
</Div>
)
}
@@ -5,9 +5,11 @@ import { useTranslation } from 'react-i18next'
import { CRISP_HELP_ARTICLE_MORE_TOOLS } from '@/utils/constants'
import { ReactNode } from 'react'
import { Transcript } from './Transcript'
import { RiFileTextFill } from '@remixicon/react'
import { useIsTranscriptEnabled } from '../hooks/useIsTranscriptEnabled'
import { useSidePanel } from '../hooks/useSidePanel'
import { RiFileTextFill, RiLiveFill } from '@remixicon/react'
import { SubPanelId, useSidePanel } from '../hooks/useSidePanel'
import { ScreenRecording } from '@/features/rooms/livekit/components/ScreenRecording'
import { useIsRecordingEnabled } from '../hooks/useIsRecordingEnabled'
import { RecordingMode } from '@/features/rooms/api/startRecording'
export interface ToolsButtonProps {
icon: ReactNode
@@ -68,12 +70,22 @@ const ToolButton = ({
}
export const Tools = () => {
const { openTranscript, isTranscriptOpen } = useSidePanel()
const { t } = useTranslation('rooms', { keyPrefix: 'moreTools' })
const isTranscriptEnabled = useIsTranscriptEnabled()
const { openTranscript, openScreenRecording, activeSubPanelId } =
useSidePanel()
if (isTranscriptOpen && isTranscriptEnabled) {
return <Transcript />
const { t } = useTranslation('rooms', { keyPrefix: 'moreTools' })
const isTranscriptEnabled = useIsRecordingEnabled(RecordingMode.Transcript)
// FIXME - use settings returned by backend
const isRecordingEnabled = true
switch (activeSubPanelId) {
case SubPanelId.TRANSCRIPT:
return <Transcript />
case SubPanelId.SCREEN_RECORDING:
return <ScreenRecording />
default:
break
}
return (
@@ -108,6 +120,14 @@ export const Tools = () => {
onPress={() => openTranscript()}
/>
)}
{isRecordingEnabled && (
<ToolButton
icon={<RiLiveFill size={24} color="white" />}
title={t('tools.screenRecording.title')}
description={t('tools.screenRecording.body')}
onPress={() => openScreenRecording()}
/>
)}
</Div>
)
}
@@ -14,16 +14,17 @@ import { RoomEvent } from 'livekit-client'
import { useTranslation } from 'react-i18next'
import { NotificationPayload } from '@/features/notifications/NotificationPayload'
import { NotificationType } from '@/features/notifications/NotificationType'
import { useSnapshot } from 'valtio/index'
import {
TranscriptionStatus,
transcriptionStore,
} from '@/stores/transcription.ts'
import { RecordingStatus, recordingStore } from '@/stores/recording'
import { useHasTranscriptAccess } from '../hooks/useHasTranscriptAccess'
import {
BETA_USERS_FORM_URL,
CRISP_HELP_ARTICLE_TRANSCRIPT,
} from '@/utils/constants'
import { useIsRecordingTransitioning } from '../hooks/useIsRecordingTransitioning'
import {
useIsScreenRecordingStarted,
useIsTranscriptStarted,
} from '../hooks/useIsRecordingStarted'
export const Transcript = () => {
const [isLoading, setIsLoading] = useState(false)
@@ -35,7 +36,9 @@ export const Transcript = () => {
const { mutateAsync: startRecordingRoom } = useStartRecording()
const { mutateAsync: stopRecordingRoom } = useStopRecording()
const transcriptionSnap = useSnapshot(transcriptionStore)
const isScreenRecordingStarted = useIsScreenRecordingStarted()
const isTranscriptStarted = useIsTranscriptStarted()
const isRecordingTransitioning = useIsRecordingTransitioning()
const room = useRoomContext()
@@ -70,11 +73,11 @@ export const Transcript = () => {
if (room.isRecording) {
await stopRecordingRoom({ id: roomId })
await notifyParticipant(NotificationType.TranscriptionStopped)
transcriptionStore.status = TranscriptionStatus.STOPPING
recordingStore.status = RecordingStatus.TRANSCRIPT_STOPPING
} else {
await startRecordingRoom({ id: roomId, mode: RecordingMode.Transcript })
await notifyParticipant(NotificationType.TranscriptionStarted)
transcriptionStore.status = TranscriptionStatus.STARTING
recordingStore.status = RecordingStatus.TRANSCRIPT_STARTING
}
} catch (error) {
console.error('Failed to handle transcript:', error)
@@ -83,11 +86,8 @@ export const Transcript = () => {
}
const isDisabled = useMemo(
() =>
isLoading ||
transcriptionSnap.status === TranscriptionStatus.STARTING ||
transcriptionSnap.status === TranscriptionStatus.STOPPING,
[isLoading, transcriptionSnap]
() => isLoading || isRecordingTransitioning || isScreenRecordingStarted,
[isLoading, isRecordingTransitioning, isScreenRecordingStarted]
)
return (
@@ -136,7 +136,7 @@ export const Transcript = () => {
</>
) : (
<>
{room.isRecording ? (
{isTranscriptStarted ? (
<>
<Text>{t('stop.heading')}</Text>
<Text
@@ -0,0 +1,20 @@
import { useFeatureFlagEnabled } from 'posthog-js/react'
import { useIsAnalyticsEnabled } from '@/features/analytics/hooks/useIsAnalyticsEnabled'
import { RecordingMode } from '@/features/rooms/api/startRecording'
import { useIsRecordingEnabled } from './useIsRecordingEnabled'
import { useIsAdminOrOwner } from './useIsAdminOrOwner'
export const useHasScreenRecordingAccess = () => {
const featureEnabled = useFeatureFlagEnabled('screen-recording')
const isAnalyticsEnabled = useIsAnalyticsEnabled()
const isScreenRecordingEnabled = useIsRecordingEnabled(
RecordingMode.ScreenRecording
)
const isAdminOrOwner = useIsAdminOrOwner()
return (
(featureEnabled || !isAnalyticsEnabled) &&
isAdminOrOwner &&
isScreenRecordingEnabled
)
}
@@ -1,12 +1,13 @@
import { useFeatureFlagEnabled } from 'posthog-js/react'
import { useIsAnalyticsEnabled } from '@/features/analytics/hooks/useIsAnalyticsEnabled'
import { useIsTranscriptEnabled } from './useIsTranscriptEnabled'
import { RecordingMode } from '@/features/rooms/api/startRecording'
import { useIsAdminOrOwner } from './useIsAdminOrOwner'
import { useIsRecordingEnabled } from './useIsRecordingEnabled'
export const useHasTranscriptAccess = () => {
const featureEnabled = useFeatureFlagEnabled('transcription-summary')
const isAnalyticsEnabled = useIsAnalyticsEnabled()
const isTranscriptEnabled = useIsTranscriptEnabled()
const isTranscriptEnabled = useIsRecordingEnabled(RecordingMode.Transcript)
const isAdminOrOwner = useIsAdminOrOwner()
return (
@@ -1,11 +1,11 @@
import { RecordingMode } from '@/features/rooms/api/startRecording'
import { useConfig } from '@/api/useConfig'
export const useIsTranscriptEnabled = () => {
export const useIsRecordingEnabled = (mode: RecordingMode) => {
const { data } = useConfig()
return (
data?.recording?.is_enabled &&
data?.recording?.available_modes?.includes(RecordingMode.Transcript)
data?.recording?.available_modes?.includes(mode)
)
}
@@ -0,0 +1,12 @@
import { useSnapshot } from 'valtio'
import { RecordingStatus, recordingStore } from '@/stores/recording'
export const useIsScreenRecordingStarted = () => {
const recordingSnap = useSnapshot(recordingStore)
return recordingSnap.status == RecordingStatus.SCREEN_RECORDING_STARTED
}
export const useIsTranscriptStarted = () => {
const recordingSnap = useSnapshot(recordingStore)
return recordingSnap.status == RecordingStatus.TRANSCRIPT_STARTED
}
@@ -0,0 +1,15 @@
import { useSnapshot } from 'valtio'
import { RecordingStatus, recordingStore } from '@/stores/recording'
export const useIsRecordingTransitioning = () => {
const recordingSnap = useSnapshot(recordingStore)
const transitionalStates = [
RecordingStatus.TRANSCRIPT_STARTING,
RecordingStatus.TRANSCRIPT_STOPPING,
RecordingStatus.SCREEN_RECORDING_STARTING,
RecordingStatus.SCREEN_RECORDING_STOPPING,
]
return transitionalStates.includes(recordingSnap.status)
}
@@ -11,6 +11,7 @@ export enum PanelId {
export enum SubPanelId {
TRANSCRIPT = 'transcript',
SCREEN_RECORDING = 'screenRecording',
}
export const useSidePanel = () => {
@@ -24,6 +25,7 @@ export const useSidePanel = () => {
const isToolsOpen = activePanelId == PanelId.TOOLS
const isAdminOpen = activePanelId == PanelId.ADMIN
const isTranscriptOpen = activeSubPanelId == SubPanelId.TRANSCRIPT
const isScreenRecordingOpen = activeSubPanelId == SubPanelId.SCREEN_RECORDING
const isSidePanelOpen = !!activePanelId
const isSubPanelOpen = !!activeSubPanelId
@@ -57,6 +59,11 @@ export const useSidePanel = () => {
layoutStore.activePanelId = PanelId.TOOLS
}
const openScreenRecording = () => {
layoutStore.activeSubPanelId = SubPanelId.SCREEN_RECORDING
layoutStore.activePanelId = PanelId.TOOLS
}
return {
activePanelId,
activeSubPanelId,
@@ -66,6 +73,7 @@ export const useSidePanel = () => {
toggleTools,
toggleAdmin,
openTranscript,
openScreenRecording,
isSubPanelOpen,
isChatOpen,
isParticipantsOpen,
@@ -74,5 +82,6 @@ export const useSidePanel = () => {
isToolsOpen,
isAdminOpen,
isTranscriptOpen,
isScreenRecordingOpen,
}
}
@@ -28,7 +28,7 @@ import { FocusLayout } from '../components/FocusLayout'
import { ParticipantTile } from '../components/ParticipantTile'
import { SidePanel } from '../components/SidePanel'
import { useSidePanel } from '../hooks/useSidePanel'
import { TranscriptStateToast } from '../components/TranscriptStateToast'
import { RecordingStateToast } from '../components/RecordingStateToast'
import { ScreenShareErrorModal } from '../components/ScreenShareErrorModal'
const LayoutWrapper = styled(
@@ -231,7 +231,7 @@ export function VideoConference({ ...props }: VideoConferenceProps) {
)}
<RoomAudioRenderer />
<ConnectionStateToast />
<TranscriptStateToast />
<RecordingStateToast />
</div>
)
}
@@ -22,6 +22,12 @@
"open": "",
"accept": ""
},
"recordingStarted": "",
"recordingStopped": ""
"transcript": {
"started": "",
"stopped": ""
},
"screenRecording": {
"started": "",
"stopped": ""
}
}
+9 -1
View File
@@ -292,11 +292,19 @@
}
}
},
"recording": {
"recordingToast": {
"transcript": {
"started": "",
"starting": "",
"stopping": ""
},
"screenRecording": {
"started": "",
"starting": "",
"stopping": ""
},
"any": {
"started": ""
}
},
"participantTileFocus": {
@@ -22,6 +22,12 @@
"open": "Open",
"accept": "Accept"
},
"recordingStarted": "{{name}} started the meeting transcription.",
"recordingStopped": "{{name}} stopped the meeting transcription."
"transcript": {
"started": "{{name}} started the meeting transcription.",
"stopped": "{{name}} stopped the meeting transcription."
},
"screenRecording": {
"started": "{{name}} started the meeting recording.",
"stopped": "{{name}} stopped the meeting recording."
}
}
+30 -3
View File
@@ -168,6 +168,7 @@
"effects": "Effects",
"chat": "Messages in the chat",
"transcript": "Transcription",
"screenRecording": "Recording",
"admin": "Admin settings",
"tools": "More tools"
},
@@ -175,7 +176,8 @@
"participants": "participants",
"effects": "effects",
"chat": "messages",
"transcript": "Transcription",
"transcript": "transcription",
"screenRecording": "recording",
"admin": "admin settings",
"tools": "more tools"
},
@@ -190,7 +192,11 @@
"tools": {
"transcript": {
"title": "Transcription",
"body": "Transcribe your meeting for later"
"body": "Keep a written record of your meeting."
},
"screenRecording": {
"title": "Recording",
"body": "Record your meeting to watch it again whenever you like."
}
}
},
@@ -212,6 +218,19 @@
"button": "Sign up"
}
},
"screenRecording": {
"start": {
"heading": "Record this call",
"body": "Record this call to watch it later and receive the video recording by email.",
"button": "Start recording",
"linkMore": "Learn more"
},
"stop": {
"heading": "Recording in progress…",
"body": "You will receive the result by email once the recording is complete.",
"button": "Stop recording"
}
},
"admin": {
"description": "These organizer settings allow you to maintain control of your meeting. Only organizers can access these controls.",
"access": {
@@ -291,11 +310,19 @@
}
}
},
"recording": {
"recordingToast": {
"transcript": {
"started": "Transcribing",
"starting": "Transcription starting",
"stopping": "Transcription stopping"
},
"screenRecording": {
"started": "Recording in progress",
"starting": "Starting recording",
"stopping": "Stopping recording"
},
"any": {
"started": "Recording in progress"
}
},
"participantTileFocus": {
@@ -22,6 +22,12 @@
"open": "Afficher",
"accept": "Accepter"
},
"recordingStarted": "{{name}} a démarré la transcription de la réunion.",
"recordingStopped": "{{name}} a arrêté la transcription de la réunion."
"transcript": {
"started": "{{name}} a démarré la transcription de la réunion.",
"stopped": "{{name}} a arrêté la transcription de la réunion."
},
"screenRecording": {
"started": "{{name}} a démarré l'enregistrement de la réunion.",
"stopped": "{{name}} a arrêté l'enregistrement de la réunion."
}
}
+29 -2
View File
@@ -168,6 +168,7 @@
"effects": "Effets",
"chat": "Messages dans l'appel",
"transcript": "Transcription",
"screenRecording": "Enregistrement",
"admin": "Commandes de l'organisateur",
"tools": "Plus d'outils"
},
@@ -176,6 +177,7 @@
"effects": "les effets",
"chat": "les messages",
"transcript": "transcription",
"screenRecording": "enregistrement",
"admin": "commandes de l'organisateur",
"tools": "plus d'outils"
},
@@ -190,7 +192,11 @@
"tools": {
"transcript": {
"title": "Transcription",
"body": "Transcrivez votre réunion pour plus tard"
"body": "Conservez une trace écrite de votre réunion."
},
"screenRecording": {
"title": "Enregistrement",
"body": "Enregistrez votre réunion pour la revoir quand vous le souhaitez."
}
}
},
@@ -212,6 +218,19 @@
"button": "Inscrivez-vous"
}
},
"screenRecording": {
"start": {
"heading": "Enregistrer cet appel",
"body": "Enregistrez cet appel pour plus tard et recevez l'enregistrement vidéo par mail.",
"button": "Démarrer l'enregistrement",
"linkMore": "En savoir plus"
},
"stop": {
"heading": "Enregistrement en cours …",
"body": "Vous recevrez le resultat par email une fois l'enregistrement terminé.",
"button": "Arrêter l'enregistrement"
}
},
"admin": {
"description": "Ces paramètres organisateur vous permettent de garder le contrôle de votre réunion. Seuls les organisateurs peuvent accéder à ces commandes.",
"access": {
@@ -291,11 +310,19 @@
}
}
},
"recording": {
"recordingToast": {
"transcript": {
"started": "Transcription en cours",
"starting": "Démarrage de la transcription",
"stopping": "Arrêt de la transcription"
},
"screenRecording": {
"started": "Enregistrement en cours",
"starting": "Démarrage de l'enregistrement",
"stopping": "Arrêt de l'enregistrement"
},
"any": {
"started": "Enregistrement en cours"
}
},
"participantTileFocus": {
@@ -22,6 +22,12 @@
"open": "Openen",
"accept": "Accepteren"
},
"recordingStarted": "{{name}} is de transcriptie van de vergadering gestart.",
"recordingStopped": "{{name}} heeft de transcriptie van de vergadering gestopt."
"transcript": {
"started": "{{name}} is de transcriptie van de vergadering gestart.",
"stopped": "{{name}} heeft de transcriptie van de vergadering gestopt."
},
"screenRecording": {
"started": "{{name}} is begonnen met het opnemen van de vergadering.",
"stopped": "{{name}} is gestopt met het opnemen van de vergadering."
}
}
+30 -3
View File
@@ -168,6 +168,7 @@
"effects": "Effecten",
"chat": "Berichten in de chat",
"transcript": "Transcriptie",
"screenRecording": "Schermopname",
"admin": "Beheerdersbediening",
"tools": "Meer tools"
},
@@ -175,7 +176,8 @@
"participants": "deelnemers",
"effects": "effecten",
"chat": "berichten",
"transcript": "transcriptie",
"screenRecording": "transcriptie",
"transcript": "schermopname",
"admin": "beheerdersbediening",
"tools": "meer tools"
},
@@ -190,7 +192,11 @@
"tools": {
"transcript": {
"title": "Transcriptie",
"body": "Transcribeer je vergadering voor later"
"body": "Bewaar een schriftelijk verslag van je vergadering."
},
"screenRecording": {
"title": "Opname",
"body": "Neem je vergadering op om die later opnieuw te bekijken."
}
}
},
@@ -212,6 +218,19 @@
"button": "Aanmelden"
}
},
"screenRecording": {
"start": {
"heading": "Dit gesprek opnemen",
"body": "Neem dit gesprek op om het later terug te kijken. Je ontvangt de video-opname per e-mail.",
"button": "Opname starten",
"linkMore": "Meer informatie"
},
"stop": {
"heading": "Opname bezig …",
"body": "Je ontvangt het resultaat per e-mail zodra de opname is voltooid.",
"button": "Opname stoppen"
}
},
"admin": {
"description": "Deze organisatorinstellingen geven u controle over uw vergadering. Alleen organisatoren hebben toegang tot deze bedieningselementen.",
"access": {
@@ -291,11 +310,19 @@
}
}
},
"recording": {
"recordingToast": {
"transcript": {
"started": "Transcriptie bezig",
"starting": "Transcriptie begint",
"stopping": "Transcriptie stopt"
},
"screenRecording": {
"started": "Opname bezig",
"starting": "Opname starten",
"stopping": "Opname stoppen"
},
"any": {
"started": "Opname bezig"
}
},
"participantTileFocus": {
+9
View File
@@ -5,6 +5,7 @@ import { AccessibilityRoute } from '@/features/legalsTerms/Accessibility'
import { TermsOfServiceRoute } from '@/features/legalsTerms/TermsOfService'
import { CreatePopup } from '@/features/sdk/routes/CreatePopup'
import { CreateMeetingButton } from '@/features/sdk/routes/CreateMeetingButton'
import { DownloadRoute, RecordingRoute } from '@/features/recording'
export const routes: Record<
| 'home'
@@ -34,6 +35,14 @@ export const routes: Record<
to: (roomId: string) => `/${roomId.trim()}`,
Component: RoomRoute,
},
recording: {
name: 'recording',
path: new RegExp(
`^[/]recording[/](?<recordingId>[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})$`
),
to: (recordingId: string) => `/recording/${recordingId.trim()}`,
Component: RecordingRoute,
},
feedback: {
name: 'feedback',
path: '/feedback',
+20
View File
@@ -0,0 +1,20 @@
import { proxy } from 'valtio'
export enum RecordingStatus {
TRANSCRIPT_STARTING,
TRANSCRIPT_STARTED,
TRANSCRIPT_STOPPING,
STOPPED,
SCREEN_RECORDING_STARTING,
SCREEN_RECORDING_STARTED,
SCREEN_RECORDING_STOPPING,
ANY_STARTED,
}
type State = {
status: RecordingStatus
}
export const recordingStore = proxy<State>({
status: RecordingStatus.STOPPED,
})
-16
View File
@@ -1,16 +0,0 @@
import { proxy } from 'valtio'
export enum TranscriptionStatus {
STARTING,
STARTED,
STOPPING,
STOPPED,
}
type State = {
status: TranscriptionStatus
}
export const transcriptionStore = proxy<State>({
status: TranscriptionStatus.STOPPED,
})
+3
View File
@@ -9,3 +9,6 @@ export const CRISP_HELP_ARTICLE_MORE_TOOLS =
export const CRISP_HELP_ARTICLE_TRANSCRIPT =
'https://lasuite.crisp.help/fr/article/visio-transcript-1sjq43x' as const
export const CRISP_HELP_ARTICLE_RECORDING =
'https://lasuite.crisp.help/fr/article/visio-enregistrement-wgc8o0' as const
@@ -16,6 +16,9 @@ backend:
DJANGO_EMAIL_HOST: "mailcatcher"
DJANGO_EMAIL_PORT: 1025
DJANGO_EMAIL_USE_SSL: False
DJANGO_EMAIL_BRAND_NAME: "La Suite Numérique"
DJANGO_EMAIL_SUPPORT_EMAIL: "test@yopmail.com"
DJANGO_EMAIL_LOGO_IMG: https://meet.127.0.0.1.nip.io/assets/logo-suite-numerique.png
OIDC_OP_JWKS_ENDPOINT: https://keycloak.127.0.0.1.nip.io/realms/meet/protocol/openid-connect/certs
OIDC_OP_AUTHORIZATION_ENDPOINT: https://keycloak.127.0.0.1.nip.io/realms/meet/protocol/openid-connect/auth
OIDC_OP_TOKEN_ENDPOINT: https://keycloak.127.0.0.1.nip.io/realms/meet/protocol/openid-connect/token
@@ -195,3 +198,17 @@ celery:
- "worker"
- "--pool=solo"
- "--loglevel=info"
ingressMedia:
enabled: true
host: meet.127.0.0.1.nip.io
annotations:
nginx.ingress.kubernetes.io/auth-url: https://meet.127.0.0.1.nip.io/api/v1.0/recordings/media-auth/
nginx.ingress.kubernetes.io/auth-response-headers: "Authorization, X-Amz-Date, X-Amz-Content-SHA256"
nginx.ingress.kubernetes.io/upstream-vhost: minio.meet.svc.cluster.local:9000
nginx.ingress.kubernetes.io/rewrite-target: /meet-media-storage/$1
serviceMedia:
host: minio.meet.svc.cluster.local
port: 9000
@@ -32,6 +32,8 @@ backend:
DJANGO_EMAIL_HOST: "mailcatcher"
DJANGO_EMAIL_PORT: 1025
DJANGO_EMAIL_USE_SSL: False
DJANGO_EMAIL_BRAND_NAME: "La Suite Numérique"
DJANGO_EMAIL_SUPPORT_EMAIL: "test@yopmail.com"
OIDC_OP_JWKS_ENDPOINT: https://fca.integ01.dev-agentconnect.fr/api/v2/jwks
OIDC_OP_AUTHORIZATION_ENDPOINT: https://fca.integ01.dev-agentconnect.fr/api/v2/authorize
OIDC_OP_TOKEN_ENDPOINT: https://fca.integ01.dev-agentconnect.fr/api/v2/token
@@ -223,3 +225,17 @@ celery:
- "worker"
- "--pool=solo"
- "--loglevel=info"
ingressMedia:
enabled: true
host: meet.127.0.0.1.nip.io
annotations:
nginx.ingress.kubernetes.io/auth-url: https://meet.127.0.0.1.nip.io/api/v1.0/recordings/media-auth/
nginx.ingress.kubernetes.io/auth-response-headers: "Authorization, X-Amz-Date, X-Amz-Content-SHA256"
nginx.ingress.kubernetes.io/upstream-vhost: minio.meet.svc.cluster.local:9000
nginx.ingress.kubernetes.io/rewrite-target: /meet-media-storage/$1
serviceMedia:
host: minio.meet.svc.cluster.local
port: 9000
@@ -0,0 +1,83 @@
{{- if .Values.ingressMedia.enabled -}}
{{- $fullName := include "meet.fullname" . -}}
{{- if and .Values.ingressMedia.className (not (semverCompare ">=1.18-0" .Capabilities.KubeVersion.GitVersion)) }}
{{- if not (hasKey .Values.ingressMedia.annotations "kubernetes.io/ingress.class") }}
{{- $_ := set .Values.ingressMedia.annotations "kubernetes.io/ingress.class" .Values.ingressMedia.className}}
{{- end }}
{{- end }}
{{- if semverCompare ">=1.19-0" .Capabilities.KubeVersion.GitVersion -}}
apiVersion: networking.k8s.io/v1
{{- else if semverCompare ">=1.14-0" .Capabilities.KubeVersion.GitVersion -}}
apiVersion: networking.k8s.io/v1beta1
{{- else -}}
apiVersion: extensions/v1beta1
{{- end }}
kind: Ingress
metadata:
name: {{ $fullName }}-media
namespace: {{ .Release.Namespace | quote }}
labels:
{{- include "meet.labels" . | nindent 4 }}
{{- with .Values.ingressMedia.annotations }}
annotations:
{{- toYaml . | nindent 4 }}
{{- end }}
spec:
{{- if and .Values.ingressMedia.className (semverCompare ">=1.18-0" .Capabilities.KubeVersion.GitVersion) }}
ingressClassName: {{ .Values.ingressMedia.className }}
{{- end }}
{{- if .Values.ingressMedia.tls.enabled }}
tls:
{{- if .Values.ingressMedia.host }}
- secretName: {{ .Values.ingressMedia.tls.secretName | default (printf "%s-tls" $fullName) | quote }}
hosts:
- {{ .Values.ingressMedia.host | quote }}
{{- end }}
{{- range .Values.ingressMedia.tls.additional }}
- hosts:
{{- range .hosts }}
- {{ . | quote }}
{{- end }}
secretName: {{ .secretName }}
{{- end }}
{{- end }}
rules:
{{- if .Values.ingressMedia.host }}
- host: {{ .Values.ingressMedia.host | quote }}
http:
paths:
- path: {{ .Values.ingressMedia.path | quote }}
{{- if semverCompare ">=1.18-0" $.Capabilities.KubeVersion.GitVersion }}
pathType: ImplementationSpecific
{{- end }}
backend:
{{- if semverCompare ">=1.19-0" $.Capabilities.KubeVersion.GitVersion }}
service:
name: {{ $fullName }}-media
port:
number: {{ .Values.serviceMedia.port }}
{{- else }}
serviceName: {{ $fullName }}-media
servicePort: {{ .Values.serviceMedia.port }}
{{- end }}
{{- end }}
{{- range .Values.ingressMedia.hosts }}
- host: {{ . | quote }}
http:
paths:
- path: {{ $.Values.ingressMedia.path | quote }}
{{- if semverCompare ">=1.18-0" $.Capabilities.KubeVersion.GitVersion }}
pathType: ImplementationSpecific
{{- end }}
backend:
{{- if semverCompare ">=1.19-0" $.Capabilities.KubeVersion.GitVersion }}
service:
name: {{ $fullName }}-media
port:
number: {{ .Values.serviceMedia.port }}
{{- else }}
serviceName: {{ $fullName }}-media
servicePort: {{ .Values.serviceMedia.port }}
{{- end }}
{{- end }}
{{- end }}
+14
View File
@@ -0,0 +1,14 @@
{{- $fullName := include "meet.fullname" . -}}
{{- $component := "media" -}}
apiVersion: v1
kind: Service
metadata:
name: {{ $fullName }}-media
namespace: {{ .Release.Namespace | quote }}
labels:
{{- include "meet.common.labels" (list . $component) | nindent 4 }}
annotations:
{{- toYaml $.Values.serviceMedia.annotations | nindent 4 }}
spec:
type: ExternalName
externalName: {{ $.Values.serviceMedia.host }}
+43
View File
@@ -70,6 +70,47 @@ ingressAdmin:
additional: []
## @param ingressMedia.enabled whether to enable the Ingress or not
## @param ingressMedia.className IngressClass to use for the Ingress
## @param ingressMedia.host Host for the Ingress
## @param ingressMedia.path Path to use for the Ingress
ingressMedia:
enabled: false
className: null
host: meet.example.com
path: /media/(.*)
## @param ingressMedia.hosts Additional host to configure for the Ingress
hosts: [ ]
# - chart-example.local
## @param ingressMedia.tls.enabled Weather to enable TLS for the Ingress
## @param ingressMedia.tls.secretName Secret name for TLS config
## @skip ingressMedia.tls.additional
## @extra ingressMedia.tls.additional[].secretName Secret name for additional TLS config
## @extra ingressMedia.tls.additional[].hosts[] Hosts for additional TLS config
tls:
enabled: true
secretName: null
additional: []
## @param ingressMedia.annotations.nginx.ingress.kubernetes.io/auth-url
## @param ingressMedia.annotations.nginx.ingress.kubernetes.io/auth-response-headers
## @param ingressMedia.annotations.nginx.ingress.kubernetes.io/upstream-vhost
annotations:
nginx.ingress.kubernetes.io/auth-url: https://meet.example.com/api/v1.0/recordings/media-auth/
nginx.ingress.kubernetes.io/auth-response-headers: "Authorization, X-Amz-Date, X-Amz-Content-SHA256"
nginx.ingress.kubernetes.io/upstream-vhost: minio.meet.svc.cluster.local:9000
nginx.ingress.kubernetes.io/configuration-snippet: |
add_header Content-Security-Policy "default-src 'none'" always;
## @param serviceMedia.host
## @param serviceMedia.port
## @param serviceMedia.annotations
serviceMedia:
host: minio.meet.svc.cluster.local
port: 9000
annotations: {}
## @section backend
backend:
@@ -546,3 +587,5 @@ celery:
## @param celery.pdb.enabled Enable pdb on celery
pdb:
enabled: false
+67
View File
@@ -0,0 +1,67 @@
<mjml>
<mj-include path="./partial/header.mjml" />
<mj-body mj-class="bg--blue-100">
<mj-wrapper css-class="wrapper" padding="5px 25px 0px 25px">
<mj-section css-class="wrapper-logo">
<mj-column>
<mj-image
align="center"
src="{{logo_img}}"
width="320px"
alt="{%trans 'Logo email' %}"
/>
</mj-column>
</mj-section>
<mj-section mj-class="bg--white-100" padding="0px 20px 60px 20px">
<mj-column>
<mj-text align="center">
<h1>{% trans "Your recording is ready!"%}</h1>
</mj-text>
<!-- Main Message -->
<mj-text>
{% blocktrans %}
Your recording of "{{room_name}}" on {{recording_date}} at {{recording_time}} is now ready to view.
{% endblocktrans %}
</mj-text>
<mj-text>
<p>{% trans "To keep this recording permanently:" %}</p>
<ol>
<li>{% trans "Click the \"Open\" button below" %}</li>
<li>{% trans "Use the \"Download\" button in the interface" %}</li>
<li>{% trans "Save the file to your preferred location" %}</li>
</ol>
</mj-text>
<mj-button
href="{{link}}"
background-color="#000091"
color="white"
padding-bottom="30px"
>
{% trans "Open"%}
</mj-button>
<mj-text>
{% blocktrans %}
If you have any questions or need assistance, please contact our support team at {{support_email}}.
{% endblocktrans %}
</mj-text>
<mj-divider
border-width="1px"
border-style="solid"
border-color="#DDDDDD"
width="30%"
align="center"
/>
<!-- Signature -->
<mj-text>
<p>
{% blocktrans %}
Thank you for using {{brandname}}.
{% endblocktrans %}
</p>
</mj-text>
</mj-column>
</mj-section>
</mj-wrapper>
</mj-body>
</mjml>