feat: send email in share transcript and add email sending in room (#924)

* fix: add source language for file pipeline

* feat: send email in share transcript and add email sending in room

* fix: hide audio and video streaming for unauthenticated users

* fix: security order
This commit is contained in:
Juan Diego García
2026-03-24 17:17:52 -05:00
committed by GitHub
parent 74b9b97453
commit e2ba502697
28 changed files with 861 additions and 174 deletions

View File

@@ -0,0 +1,28 @@
"""add email_transcript_to to room
Revision ID: b4c7e8f9a012
Revises: a2b3c4d5e6f7
Create Date: 2026-03-24 00:00:00.000000
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
revision: str = "b4c7e8f9a012"
down_revision: Union[str, None] = "a2b3c4d5e6f7"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.add_column(
"room",
sa.Column("email_transcript_to", sa.String(), nullable=True),
)
def downgrade() -> None:
op.drop_column("room", "email_transcript_to")

View File

@@ -13,6 +13,7 @@ from reflector.events import subscribers_shutdown, subscribers_startup
from reflector.logger import logger from reflector.logger import logger
from reflector.metrics import metrics_init from reflector.metrics import metrics_init
from reflector.settings import settings from reflector.settings import settings
from reflector.views.config import router as config_router
from reflector.views.daily import router as daily_router from reflector.views.daily import router as daily_router
from reflector.views.meetings import router as meetings_router from reflector.views.meetings import router as meetings_router
from reflector.views.rooms import router as rooms_router from reflector.views.rooms import router as rooms_router
@@ -107,6 +108,7 @@ app.include_router(transcripts_process_router, prefix="/v1")
app.include_router(user_router, prefix="/v1") app.include_router(user_router, prefix="/v1")
app.include_router(user_api_keys_router, prefix="/v1") app.include_router(user_api_keys_router, prefix="/v1")
app.include_router(user_ws_router, prefix="/v1") app.include_router(user_ws_router, prefix="/v1")
app.include_router(config_router, prefix="/v1")
app.include_router(zulip_router, prefix="/v1") app.include_router(zulip_router, prefix="/v1")
app.include_router(whereby_router, prefix="/v1") app.include_router(whereby_router, prefix="/v1")
app.include_router(daily_router, prefix="/v1/daily") app.include_router(daily_router, prefix="/v1/daily")

View File

@@ -63,6 +63,7 @@ rooms = sqlalchemy.Table(
nullable=False, nullable=False,
server_default=sqlalchemy.sql.false(), server_default=sqlalchemy.sql.false(),
), ),
sqlalchemy.Column("email_transcript_to", sqlalchemy.String, nullable=True),
sqlalchemy.Index("idx_room_is_shared", "is_shared"), sqlalchemy.Index("idx_room_is_shared", "is_shared"),
sqlalchemy.Index("idx_room_ics_enabled", "ics_enabled"), sqlalchemy.Index("idx_room_ics_enabled", "ics_enabled"),
) )
@@ -92,6 +93,7 @@ class Room(BaseModel):
ics_last_etag: str | None = None ics_last_etag: str | None = None
platform: Platform = Field(default_factory=lambda: settings.DEFAULT_VIDEO_PLATFORM) platform: Platform = Field(default_factory=lambda: settings.DEFAULT_VIDEO_PLATFORM)
skip_consent: bool = False skip_consent: bool = False
email_transcript_to: str | None = None
class RoomController: class RoomController:
@@ -147,6 +149,7 @@ class RoomController:
ics_enabled: bool = False, ics_enabled: bool = False,
platform: Platform = settings.DEFAULT_VIDEO_PLATFORM, platform: Platform = settings.DEFAULT_VIDEO_PLATFORM,
skip_consent: bool = False, skip_consent: bool = False,
email_transcript_to: str | None = None,
): ):
""" """
Add a new room Add a new room
@@ -172,6 +175,7 @@ class RoomController:
"ics_enabled": ics_enabled, "ics_enabled": ics_enabled,
"platform": platform, "platform": platform,
"skip_consent": skip_consent, "skip_consent": skip_consent,
"email_transcript_to": email_transcript_to,
} }
room = Room(**room_data) room = Room(**room_data)

View File

@@ -1501,13 +1501,30 @@ async def send_email(input: PipelineInput, ctx: Context) -> EmailResult:
if recording and recording.meeting_id: if recording and recording.meeting_id:
meeting = await meetings_controller.get_by_id(recording.meeting_id) meeting = await meetings_controller.get_by_id(recording.meeting_id)
if not meeting or not meeting.email_recipients: recipients = (
list(meeting.email_recipients)
if meeting and meeting.email_recipients
else []
)
# Also check room-level email
from reflector.db.rooms import rooms_controller # noqa: PLC0415
if transcript.room_id:
room = await rooms_controller.get_by_id(transcript.room_id)
if room and room.email_transcript_to:
if room.email_transcript_to not in recipients:
recipients.append(room.email_transcript_to)
if not recipients:
ctx.log("send_email skipped (no email recipients)") ctx.log("send_email skipped (no email recipients)")
return EmailResult(skipped=True) return EmailResult(skipped=True)
await transcripts_controller.update(transcript, {"share_mode": "public"}) # For room-level emails, do NOT change share_mode (only set public if meeting had recipients)
if meeting and meeting.email_recipients:
await transcripts_controller.update(transcript, {"share_mode": "public"})
count = await send_transcript_email(meeting.email_recipients, transcript) count = await send_transcript_email(recipients, transcript)
ctx.log(f"send_email complete: sent {count} emails") ctx.log(f"send_email complete: sent {count} emails")
return EmailResult(emails_sent=count) return EmailResult(emails_sent=count)

View File

@@ -896,14 +896,30 @@ async def send_email(input: FilePipelineInput, ctx: Context) -> EmailResult:
if recording and recording.meeting_id: if recording and recording.meeting_id:
meeting = await meetings_controller.get_by_id(recording.meeting_id) meeting = await meetings_controller.get_by_id(recording.meeting_id)
if not meeting or not meeting.email_recipients: recipients = (
list(meeting.email_recipients)
if meeting and meeting.email_recipients
else []
)
# Also check room-level email
from reflector.db.rooms import rooms_controller # noqa: PLC0415
if transcript.room_id:
room = await rooms_controller.get_by_id(transcript.room_id)
if room and room.email_transcript_to:
if room.email_transcript_to not in recipients:
recipients.append(room.email_transcript_to)
if not recipients:
ctx.log("send_email skipped (no email recipients)") ctx.log("send_email skipped (no email recipients)")
return EmailResult(skipped=True) return EmailResult(skipped=True)
# Set transcript to public so the link works for anyone # For room-level emails, do NOT change share_mode (only set public if meeting had recipients)
await transcripts_controller.update(transcript, {"share_mode": "public"}) if meeting and meeting.email_recipients:
await transcripts_controller.update(transcript, {"share_mode": "public"})
count = await send_transcript_email(meeting.email_recipients, transcript) count = await send_transcript_email(recipients, transcript)
ctx.log(f"send_email complete: sent {count} emails") ctx.log(f"send_email complete: sent {count} emails")
return EmailResult(emails_sent=count) return EmailResult(emails_sent=count)

View File

@@ -397,13 +397,30 @@ async def send_email(input: LivePostPipelineInput, ctx: Context) -> EmailResult:
if recording and recording.meeting_id: if recording and recording.meeting_id:
meeting = await meetings_controller.get_by_id(recording.meeting_id) meeting = await meetings_controller.get_by_id(recording.meeting_id)
if not meeting or not meeting.email_recipients: recipients = (
list(meeting.email_recipients)
if meeting and meeting.email_recipients
else []
)
# Also check room-level email
from reflector.db.rooms import rooms_controller # noqa: PLC0415
if transcript.room_id:
room = await rooms_controller.get_by_id(transcript.room_id)
if room and room.email_transcript_to:
if room.email_transcript_to not in recipients:
recipients.append(room.email_transcript_to)
if not recipients:
ctx.log("send_email skipped (no email recipients)") ctx.log("send_email skipped (no email recipients)")
return EmailResult(skipped=True) return EmailResult(skipped=True)
await transcripts_controller.update(transcript, {"share_mode": "public"}) # For room-level emails, do NOT change share_mode (only set public if meeting had recipients)
if meeting and meeting.email_recipients:
await transcripts_controller.update(transcript, {"share_mode": "public"})
count = await send_transcript_email(meeting.email_recipients, transcript) count = await send_transcript_email(recipients, transcript)
ctx.log(f"send_email complete: sent {count} emails") ctx.log(f"send_email complete: sent {count} emails")
return EmailResult(emails_sent=count) return EmailResult(emails_sent=count)

View File

@@ -116,9 +116,12 @@ class Storage:
expires_in: int = 3600, expires_in: int = 3600,
*, *,
bucket: str | None = None, bucket: str | None = None,
extra_params: dict | None = None,
) -> str: ) -> str:
"""Generate presigned URL. bucket: override instance default if provided.""" """Generate presigned URL. bucket: override instance default if provided."""
return await self._get_file_url(filename, operation, expires_in, bucket=bucket) return await self._get_file_url(
filename, operation, expires_in, bucket=bucket, extra_params=extra_params
)
async def _get_file_url( async def _get_file_url(
self, self,
@@ -127,6 +130,7 @@ class Storage:
expires_in: int = 3600, expires_in: int = 3600,
*, *,
bucket: str | None = None, bucket: str | None = None,
extra_params: dict | None = None,
) -> str: ) -> str:
raise NotImplementedError raise NotImplementedError

View File

@@ -170,16 +170,23 @@ class AwsStorage(Storage):
expires_in: int = 3600, expires_in: int = 3600,
*, *,
bucket: str | None = None, bucket: str | None = None,
extra_params: dict | None = None,
) -> str: ) -> str:
actual_bucket = bucket or self._bucket_name actual_bucket = bucket or self._bucket_name
folder = self.aws_folder folder = self.aws_folder
s3filename = f"{folder}/{filename}" if folder else filename s3filename = f"{folder}/{filename}" if folder else filename
params = {}
if extra_params:
params.update(extra_params)
# Always set Bucket/Key after extra_params to prevent overrides
params["Bucket"] = actual_bucket
params["Key"] = s3filename
async with self.session.client( async with self.session.client(
"s3", config=self.boto_config, endpoint_url=self._endpoint_url "s3", config=self.boto_config, endpoint_url=self._endpoint_url
) as client: ) as client:
presigned_url = await client.generate_presigned_url( presigned_url = await client.generate_presigned_url(
operation, operation,
Params={"Bucket": actual_bucket, "Key": s3filename}, Params=params,
ExpiresIn=expires_in, ExpiresIn=expires_in,
) )

View File

@@ -0,0 +1,20 @@
from fastapi import APIRouter
from pydantic import BaseModel
from reflector.email import is_email_configured
from reflector.settings import settings
router = APIRouter()
class ConfigResponse(BaseModel):
zulip_enabled: bool
email_enabled: bool
@router.get("/config", response_model=ConfigResponse)
async def get_config():
return ConfigResponse(
zulip_enabled=bool(settings.ZULIP_REALM),
email_enabled=is_email_configured(),
)

View File

@@ -44,6 +44,7 @@ class Room(BaseModel):
ics_last_etag: Optional[str] = None ics_last_etag: Optional[str] = None
platform: Platform platform: Platform
skip_consent: bool = False skip_consent: bool = False
email_transcript_to: str | None = None
class RoomDetails(Room): class RoomDetails(Room):
@@ -93,6 +94,7 @@ class CreateRoom(BaseModel):
ics_enabled: bool = False ics_enabled: bool = False
platform: Platform platform: Platform
skip_consent: bool = False skip_consent: bool = False
email_transcript_to: str | None = None
class UpdateRoom(BaseModel): class UpdateRoom(BaseModel):
@@ -112,6 +114,7 @@ class UpdateRoom(BaseModel):
ics_enabled: Optional[bool] = None ics_enabled: Optional[bool] = None
platform: Optional[Platform] = None platform: Optional[Platform] = None
skip_consent: Optional[bool] = None skip_consent: Optional[bool] = None
email_transcript_to: Optional[str] = None
class CreateRoomMeeting(BaseModel): class CreateRoomMeeting(BaseModel):
@@ -253,6 +256,7 @@ async def rooms_create(
ics_enabled=room.ics_enabled, ics_enabled=room.ics_enabled,
platform=room.platform, platform=room.platform,
skip_consent=room.skip_consent, skip_consent=room.skip_consent,
email_transcript_to=room.email_transcript_to,
) )

View File

@@ -40,6 +40,7 @@ from reflector.db.transcripts import (
transcripts_controller, transcripts_controller,
) )
from reflector.db.users import user_controller from reflector.db.users import user_controller
from reflector.email import is_email_configured, send_transcript_email
from reflector.processors.types import Transcript as ProcessorTranscript from reflector.processors.types import Transcript as ProcessorTranscript
from reflector.processors.types import Word from reflector.processors.types import Word
from reflector.schemas.transcript_formats import TranscriptFormat, TranscriptSegment from reflector.schemas.transcript_formats import TranscriptFormat, TranscriptSegment
@@ -718,3 +719,31 @@ async def transcript_post_to_zulip(
await transcripts_controller.update( await transcripts_controller.update(
transcript, {"zulip_message_id": response["id"]} transcript, {"zulip_message_id": response["id"]}
) )
class SendEmailRequest(BaseModel):
email: str
class SendEmailResponse(BaseModel):
sent: int
@router.post("/transcripts/{transcript_id}/email", response_model=SendEmailResponse)
async def transcript_send_email(
transcript_id: str,
request: SendEmailRequest,
user: Annotated[auth.UserInfo, Depends(auth.current_user)],
):
if not is_email_configured():
raise HTTPException(status_code=400, detail="Email not configured")
user_id = user["sub"]
transcript = await transcripts_controller.get_by_id_for_http(
transcript_id, user_id=user_id
)
if not transcript:
raise HTTPException(status_code=404, detail="Transcript not found")
if not transcripts_controller.user_can_mutate(transcript, user_id):
raise HTTPException(status_code=403, detail="Not authorized")
sent = await send_transcript_email([request.email], transcript)
return SendEmailResponse(sent=sent)

View File

@@ -53,9 +53,22 @@ async def transcript_get_audio_mp3(
else: else:
user_id = token_user["sub"] user_id = token_user["sub"]
transcript = await transcripts_controller.get_by_id_for_http( if not user_id and not token:
transcript_id, user_id=user_id # No authentication provided at all. Only anonymous transcripts
) # (user_id=None) are accessible without auth, to preserve
# pipeline access via _generate_local_audio_link().
transcript = await transcripts_controller.get_by_id(transcript_id)
if not transcript or transcript.deleted_at is not None:
raise HTTPException(status_code=404, detail="Transcript not found")
if transcript.user_id is not None:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Authentication required",
)
else:
transcript = await transcripts_controller.get_by_id_for_http(
transcript_id, user_id=user_id
)
if transcript.audio_location == "storage": if transcript.audio_location == "storage":
# proxy S3 file, to prevent issue with CORS # proxy S3 file, to prevent issue with CORS
@@ -94,16 +107,16 @@ async def transcript_get_audio_mp3(
request, request,
transcript.audio_mp3_filename, transcript.audio_mp3_filename,
content_type="audio/mpeg", content_type="audio/mpeg",
content_disposition=f"attachment; filename={filename}", content_disposition=f"inline; filename={filename}",
) )
@router.get("/transcripts/{transcript_id}/audio/waveform") @router.get("/transcripts/{transcript_id}/audio/waveform")
async def transcript_get_audio_waveform( async def transcript_get_audio_waveform(
transcript_id: str, transcript_id: str,
user: Annotated[Optional[auth.UserInfo], Depends(auth.current_user_optional)], user: Annotated[auth.UserInfo, Depends(auth.current_user)],
) -> AudioWaveform: ) -> AudioWaveform:
user_id = user["sub"] if user else None user_id = user["sub"]
transcript = await transcripts_controller.get_by_id_for_http( transcript = await transcripts_controller.get_by_id_for_http(
transcript_id, user_id=user_id transcript_id, user_id=user_id
) )

View File

@@ -2,16 +2,14 @@
Transcript cloud video endpoint — returns a presigned URL for streaming playback. Transcript cloud video endpoint — returns a presigned URL for streaming playback.
""" """
from typing import Annotated, Optional from typing import Annotated
import jwt from fastapi import APIRouter, Depends, HTTPException
from fastapi import APIRouter, Depends, HTTPException, status
from pydantic import BaseModel from pydantic import BaseModel
import reflector.auth as auth import reflector.auth as auth
from reflector.db.meetings import meetings_controller from reflector.db.meetings import meetings_controller
from reflector.db.transcripts import transcripts_controller from reflector.db.transcripts import transcripts_controller
from reflector.settings import settings
from reflector.storage import get_source_storage from reflector.storage import get_source_storage
router = APIRouter() router = APIRouter()
@@ -30,26 +28,9 @@ class VideoUrlResponse(BaseModel):
) )
async def transcript_get_video_url( async def transcript_get_video_url(
transcript_id: str, transcript_id: str,
user: Annotated[Optional[auth.UserInfo], Depends(auth.current_user_optional)], user: Annotated[auth.UserInfo, Depends(auth.current_user)],
token: str | None = None,
): ):
user_id = user["sub"] if user else None user_id = user["sub"]
if not user_id and token:
try:
token_user = await auth.verify_raw_token(token)
except Exception:
token_user = None
if not token_user:
try:
payload = jwt.decode(token, settings.SECRET_KEY, algorithms=["HS256"])
user_id = payload.get("sub")
except jwt.PyJWTError:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid or expired token",
)
else:
user_id = token_user["sub"]
transcript = await transcripts_controller.get_by_id_for_http( transcript = await transcripts_controller.get_by_id_for_http(
transcript_id, user_id=user_id transcript_id, user_id=user_id
@@ -66,7 +47,11 @@ async def transcript_get_video_url(
url = await source_storage.get_file_url( url = await source_storage.get_file_url(
meeting.daily_composed_video_s3_key, meeting.daily_composed_video_s3_key,
operation="get_object", operation="get_object",
expires_in=3600, expires_in=900,
extra_params={
"ResponseContentDisposition": "inline",
"ResponseContentType": "video/mp4",
},
) )
return VideoUrlResponse( return VideoUrlResponse(

View File

@@ -137,6 +137,7 @@ async def mock_storage():
operation: str = "get_object", operation: str = "get_object",
expires_in: int = 3600, expires_in: int = 3600,
bucket=None, bucket=None,
extra_params=None,
): ):
return f"http://test-storage/{path}" return f"http://test-storage/{path}"

View File

@@ -373,9 +373,9 @@ async def test_audio_mp3_requires_token_for_owned_transcript(
tr.audio_mp3_filename.parent.mkdir(parents=True, exist_ok=True) tr.audio_mp3_filename.parent.mkdir(parents=True, exist_ok=True)
shutil.copy(audio_path, tr.audio_mp3_filename) shutil.copy(audio_path, tr.audio_mp3_filename)
# Anonymous GET without token should be 403 or 404 depending on access; we call mp3 # Anonymous GET without token should be 401 (auth required)
resp = await client.get(f"/transcripts/{t.id}/audio/mp3") resp = await client.get(f"/transcripts/{t.id}/audio/mp3")
assert resp.status_code == 403 assert resp.status_code == 401
# With token should succeed # With token should succeed
token = create_access_token( token = create_access_token(
@@ -898,7 +898,7 @@ async def test_anonymous_transcript_in_list_when_public_mode(client, monkeypatch
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_anonymous_transcript_audio_accessible(client, monkeypatch, tmpdir): async def test_anonymous_transcript_audio_accessible(client, monkeypatch, tmpdir):
"""Anonymous transcript audio (mp3) is accessible without authentication """Anonymous transcript audio (mp3) is accessible without authentication
because user_id=None bypasses share_mode checks.""" because user_id=None bypasses the auth requirement (pipeline access)."""
monkeypatch.setattr(settings, "PUBLIC_MODE", True) monkeypatch.setattr(settings, "PUBLIC_MODE", True)
monkeypatch.setattr(settings, "DATA_DIR", Path(tmpdir).as_posix()) monkeypatch.setattr(settings, "DATA_DIR", Path(tmpdir).as_posix())
@@ -920,7 +920,7 @@ async def test_anonymous_transcript_audio_accessible(client, monkeypatch, tmpdir
resp = await client.get(f"/transcripts/{t.id}/audio/mp3") resp = await client.get(f"/transcripts/{t.id}/audio/mp3")
assert ( assert (
resp.status_code == 200 resp.status_code == 200
), f"Anonymous transcript audio should be accessible: {resp.text}" ), f"Anonymous transcript audio should be accessible for pipeline: {resp.text}"
@pytest.mark.asyncio @pytest.mark.asyncio

View File

@@ -40,7 +40,7 @@ async def fake_transcript(tmpdir, client, monkeypatch):
], ],
) )
async def test_transcript_audio_download( async def test_transcript_audio_download(
fake_transcript, url_suffix, content_type, client authenticated_client, fake_transcript, url_suffix, content_type, client
): ):
response = await client.get(f"/transcripts/{fake_transcript.id}/audio{url_suffix}") response = await client.get(f"/transcripts/{fake_transcript.id}/audio{url_suffix}")
assert response.status_code == 200 assert response.status_code == 200
@@ -61,7 +61,7 @@ async def test_transcript_audio_download(
], ],
) )
async def test_transcript_audio_download_head( async def test_transcript_audio_download_head(
fake_transcript, url_suffix, content_type, client authenticated_client, fake_transcript, url_suffix, content_type, client
): ):
response = await client.head(f"/transcripts/{fake_transcript.id}/audio{url_suffix}") response = await client.head(f"/transcripts/{fake_transcript.id}/audio{url_suffix}")
assert response.status_code == 200 assert response.status_code == 200
@@ -82,7 +82,7 @@ async def test_transcript_audio_download_head(
], ],
) )
async def test_transcript_audio_download_range( async def test_transcript_audio_download_range(
fake_transcript, url_suffix, content_type, client authenticated_client, fake_transcript, url_suffix, content_type, client
): ):
response = await client.get( response = await client.get(
f"/transcripts/{fake_transcript.id}/audio{url_suffix}", f"/transcripts/{fake_transcript.id}/audio{url_suffix}",
@@ -102,7 +102,7 @@ async def test_transcript_audio_download_range(
], ],
) )
async def test_transcript_audio_download_range_with_seek( async def test_transcript_audio_download_range_with_seek(
fake_transcript, url_suffix, content_type, client authenticated_client, fake_transcript, url_suffix, content_type, client
): ):
response = await client.get( response = await client.get(
f"/transcripts/{fake_transcript.id}/audio{url_suffix}", f"/transcripts/{fake_transcript.id}/audio{url_suffix}",

View File

@@ -98,10 +98,10 @@ async def private_transcript(tmpdir):
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_audio_mp3_private_no_auth_returns_403(private_transcript, client): async def test_audio_mp3_private_no_auth_returns_401(private_transcript, client):
"""Without auth, accessing a private transcript's audio returns 403.""" """Without auth, accessing a private transcript's audio returns 401."""
response = await client.get(f"/transcripts/{private_transcript.id}/audio/mp3") response = await client.get(f"/transcripts/{private_transcript.id}/audio/mp3")
assert response.status_code == 403 assert response.status_code == 401
@pytest.mark.asyncio @pytest.mark.asyncio
@@ -125,8 +125,8 @@ async def test_audio_mp3_with_bearer_header(private_transcript, client):
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_audio_mp3_public_transcript_no_auth_ok(tmpdir, client): async def test_audio_mp3_public_transcript_no_auth_returns_401(tmpdir, client):
"""Public transcripts are accessible without any auth.""" """Public transcripts require authentication for audio access."""
from reflector.db.transcripts import SourceKind, transcripts_controller from reflector.db.transcripts import SourceKind, transcripts_controller
from reflector.settings import settings from reflector.settings import settings
@@ -146,8 +146,7 @@ async def test_audio_mp3_public_transcript_no_auth_ok(tmpdir, client):
shutil.copy(mp3_source, audio_filename) shutil.copy(mp3_source, audio_filename)
response = await client.get(f"/transcripts/{transcript.id}/audio/mp3") response = await client.get(f"/transcripts/{transcript.id}/audio/mp3")
assert response.status_code == 200 assert response.status_code == 401
assert response.headers["content-type"] == "audio/mpeg"
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -299,11 +298,9 @@ async def test_local_audio_link_token_works_with_authentik_backend(
"""_generate_local_audio_link creates an HS256 token via create_access_token. """_generate_local_audio_link creates an HS256 token via create_access_token.
When the Authentik (RS256) auth backend is active, verify_raw_token uses When the Authentik (RS256) auth backend is active, verify_raw_token uses
JWTAuth which expects RS256 + public key. The HS256 token created by JWTAuth which expects RS256 + public key. The HS256 token fails RS256
_generate_local_audio_link will fail verification, returning 401. verification, but the audio endpoint's HS256 fallback (jwt.decode with
SECRET_KEY) correctly handles it, so the request succeeds with 200.
This test documents the bug: the internal audio URL generated for the
diarization pipeline is unusable under the JWT auth backend.
""" """
from urllib.parse import parse_qs, urlparse from urllib.parse import parse_qs, urlparse
@@ -322,6 +319,55 @@ async def test_local_audio_link_token_works_with_authentik_backend(
f"/transcripts/{private_transcript.id}/audio/mp3?token={token}" f"/transcripts/{private_transcript.id}/audio/mp3?token={token}"
) )
# BUG: this should be 200 (the token was created by our own server), # The HS256 fallback in the audio endpoint handles this correctly.
# but the Authentik backend rejects it because it's HS256, not RS256.
assert response.status_code == 200 assert response.status_code == 200
# ---------------------------------------------------------------------------
# Waveform endpoint auth tests
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_waveform_requires_authentication(client):
"""Waveform endpoint returns 401 for unauthenticated requests."""
response = await client.get("/transcripts/any-id/audio/waveform")
assert response.status_code == 401
@pytest.mark.asyncio
async def test_audio_mp3_authenticated_user_accesses_anonymous_transcript(
tmpdir, client
):
"""Authenticated user can access audio for an anonymous (user_id=None) transcript."""
from reflector.app import app
from reflector.auth import current_user, current_user_optional
from reflector.db.transcripts import SourceKind, transcripts_controller
from reflector.settings import settings
settings.DATA_DIR = Path(tmpdir)
transcript = await transcripts_controller.add(
"Anonymous audio test",
source_kind=SourceKind.FILE,
user_id=None,
share_mode="private",
)
await transcripts_controller.update(transcript, {"status": "ended"})
audio_filename = transcript.audio_mp3_filename
mp3_source = Path(__file__).parent / "records" / "test_mathieu_hello.mp3"
audio_filename.parent.mkdir(parents=True, exist_ok=True)
shutil.copy(mp3_source, audio_filename)
_user = lambda: {"sub": "some-authenticated-user", "email": "user@example.com"}
app.dependency_overrides[current_user] = _user
app.dependency_overrides[current_user_optional] = _user
try:
response = await client.get(f"/transcripts/{transcript.id}/audio/mp3")
finally:
del app.dependency_overrides[current_user]
del app.dependency_overrides[current_user_optional]
assert response.status_code == 200
assert response.headers["content-type"] == "audio/mpeg"

View File

@@ -103,3 +103,162 @@ async def test_transcript_get_includes_video_fields(authenticated_client, client
data = response.json() data = response.json()
assert data["has_cloud_video"] is False assert data["has_cloud_video"] is False
assert data["cloud_video_duration"] is None assert data["cloud_video_duration"] is None
@pytest.mark.asyncio
async def test_video_url_requires_authentication(client):
"""Test that video URL endpoint returns 401 for unauthenticated requests."""
response = await client.get("/transcripts/any-id/video/url")
assert response.status_code == 401
@pytest.mark.asyncio
async def test_video_url_presigned_params(authenticated_client, client):
"""Test that presigned URL is generated with short expiry and inline disposition."""
from reflector.db import get_database
from reflector.db.meetings import meetings
meeting_id = "test-meeting-presigned-params"
await get_database().execute(
meetings.insert().values(
id=meeting_id,
room_name="Presigned Params Meeting",
room_url="https://example.com",
host_room_url="https://example.com/host",
start_date=datetime.now(timezone.utc),
end_date=datetime.now(timezone.utc) + timedelta(hours=1),
room_id=None,
daily_composed_video_s3_key="recordings/video.mp4",
daily_composed_video_duration=60,
)
)
transcript = await transcripts_controller.add(
name="presigned-params",
source_kind=SourceKind.ROOM,
meeting_id=meeting_id,
user_id="randomuserid",
)
with patch("reflector.views.transcripts_video.get_source_storage") as mock_storage:
mock_instance = AsyncMock()
mock_instance.get_file_url = AsyncMock(
return_value="https://s3.example.com/presigned-url"
)
mock_storage.return_value = mock_instance
await client.get(f"/transcripts/{transcript.id}/video/url")
mock_instance.get_file_url.assert_called_once_with(
"recordings/video.mp4",
operation="get_object",
expires_in=900,
extra_params={
"ResponseContentDisposition": "inline",
"ResponseContentType": "video/mp4",
},
)
async def _create_meeting_with_video(meeting_id):
"""Helper to create a meeting with cloud video."""
from reflector.db import get_database
from reflector.db.meetings import meetings
await get_database().execute(
meetings.insert().values(
id=meeting_id,
room_name="Video Meeting",
room_url="https://example.com",
host_room_url="https://example.com/host",
start_date=datetime.now(timezone.utc),
end_date=datetime.now(timezone.utc) + timedelta(hours=1),
room_id=None,
daily_composed_video_s3_key="recordings/video.mp4",
daily_composed_video_duration=60,
)
)
@pytest.mark.asyncio
async def test_video_url_private_transcript_denies_non_owner(
authenticated_client, client
):
"""Authenticated non-owner cannot access video for a private transcript."""
meeting_id = "test-meeting-private-deny"
await _create_meeting_with_video(meeting_id)
transcript = await transcripts_controller.add(
name="private-video",
source_kind=SourceKind.ROOM,
meeting_id=meeting_id,
user_id="other-owner",
share_mode="private",
)
with patch("reflector.views.transcripts_video.get_source_storage") as mock_storage:
mock_instance = AsyncMock()
mock_instance.get_file_url = AsyncMock(
return_value="https://s3.example.com/url"
)
mock_storage.return_value = mock_instance
response = await client.get(f"/transcripts/{transcript.id}/video/url")
assert response.status_code == 403
@pytest.mark.asyncio
async def test_video_url_public_transcript_allows_authenticated_non_owner(
authenticated_client, client
):
"""Authenticated non-owner can access video for a public transcript."""
meeting_id = "test-meeting-public-allow"
await _create_meeting_with_video(meeting_id)
transcript = await transcripts_controller.add(
name="public-video",
source_kind=SourceKind.ROOM,
meeting_id=meeting_id,
user_id="other-owner",
share_mode="public",
)
with patch("reflector.views.transcripts_video.get_source_storage") as mock_storage:
mock_instance = AsyncMock()
mock_instance.get_file_url = AsyncMock(
return_value="https://s3.example.com/url"
)
mock_storage.return_value = mock_instance
response = await client.get(f"/transcripts/{transcript.id}/video/url")
assert response.status_code == 200
@pytest.mark.asyncio
async def test_video_url_semi_private_allows_authenticated_non_owner(
authenticated_client, client
):
"""Authenticated non-owner can access video for a semi-private transcript."""
meeting_id = "test-meeting-semi-private-allow"
await _create_meeting_with_video(meeting_id)
transcript = await transcripts_controller.add(
name="semi-private-video",
source_kind=SourceKind.ROOM,
meeting_id=meeting_id,
user_id="other-owner",
share_mode="semi-private",
)
with patch("reflector.views.transcripts_video.get_source_storage") as mock_storage:
mock_instance = AsyncMock()
mock_instance.get_file_url = AsyncMock(
return_value="https://s3.example.com/url"
)
mock_storage.return_value = mock_instance
response = await client.get(f"/transcripts/{transcript.id}/video/url")
assert response.status_code == 200

View File

@@ -31,6 +31,7 @@ import {
useZulipTopics, useZulipTopics,
useRoomGet, useRoomGet,
useRoomTestWebhook, useRoomTestWebhook,
useConfig,
} from "../../lib/apiHooks"; } from "../../lib/apiHooks";
import { RoomList } from "./_components/RoomList"; import { RoomList } from "./_components/RoomList";
import { PaginationPage } from "../browse/_components/Pagination"; import { PaginationPage } from "../browse/_components/Pagination";
@@ -92,6 +93,7 @@ const roomInitialState = {
icsFetchInterval: 5, icsFetchInterval: 5,
platform: "whereby", platform: "whereby",
skipConsent: false, skipConsent: false,
emailTranscriptTo: "",
}; };
export default function RoomsList() { export default function RoomsList() {
@@ -133,11 +135,15 @@ export default function RoomsList() {
null, null,
); );
const [showWebhookSecret, setShowWebhookSecret] = useState(false); const [showWebhookSecret, setShowWebhookSecret] = useState(false);
const [emailTranscriptEnabled, setEmailTranscriptEnabled] = useState(false);
const createRoomMutation = useRoomCreate(); const createRoomMutation = useRoomCreate();
const updateRoomMutation = useRoomUpdate(); const updateRoomMutation = useRoomUpdate();
const deleteRoomMutation = useRoomDelete(); const deleteRoomMutation = useRoomDelete();
const { data: streams = [] } = useZulipStreams(); const { data: config } = useConfig();
const zulipEnabled = config?.zulip_enabled ?? false;
const emailEnabled = config?.email_enabled ?? false;
const { data: streams = [] } = useZulipStreams(zulipEnabled);
const { data: topics = [] } = useZulipTopics(selectedStreamId); const { data: topics = [] } = useZulipTopics(selectedStreamId);
const { const {
@@ -177,6 +183,7 @@ export default function RoomsList() {
icsFetchInterval: detailedEditedRoom.ics_fetch_interval || 5, icsFetchInterval: detailedEditedRoom.ics_fetch_interval || 5,
platform: detailedEditedRoom.platform, platform: detailedEditedRoom.platform,
skipConsent: detailedEditedRoom.skip_consent || false, skipConsent: detailedEditedRoom.skip_consent || false,
emailTranscriptTo: detailedEditedRoom.email_transcript_to || "",
} }
: null, : null,
[detailedEditedRoom], [detailedEditedRoom],
@@ -329,6 +336,7 @@ export default function RoomsList() {
ics_fetch_interval: room.icsFetchInterval, ics_fetch_interval: room.icsFetchInterval,
platform, platform,
skip_consent: room.skipConsent, skip_consent: room.skipConsent,
email_transcript_to: room.emailTranscriptTo || null,
}; };
if (isEditing) { if (isEditing) {
@@ -369,6 +377,7 @@ export default function RoomsList() {
// Reset states // Reset states
setShowWebhookSecret(false); setShowWebhookSecret(false);
setWebhookTestResult(null); setWebhookTestResult(null);
setEmailTranscriptEnabled(!!roomData.email_transcript_to);
setRoomInput({ setRoomInput({
name: roomData.name, name: roomData.name,
@@ -392,6 +401,7 @@ export default function RoomsList() {
icsFetchInterval: roomData.ics_fetch_interval || 5, icsFetchInterval: roomData.ics_fetch_interval || 5,
platform: roomData.platform, platform: roomData.platform,
skipConsent: roomData.skip_consent || false, skipConsent: roomData.skip_consent || false,
emailTranscriptTo: roomData.email_transcript_to || "",
}); });
setEditRoomId(roomId); setEditRoomId(roomId);
setIsEditing(true); setIsEditing(true);
@@ -469,6 +479,7 @@ export default function RoomsList() {
setNameError(""); setNameError("");
setShowWebhookSecret(false); setShowWebhookSecret(false);
setWebhookTestResult(null); setWebhookTestResult(null);
setEmailTranscriptEnabled(false);
onOpen(); onOpen();
}} }}
> >
@@ -504,7 +515,9 @@ export default function RoomsList() {
<Tabs.List> <Tabs.List>
<Tabs.Trigger value="general">General</Tabs.Trigger> <Tabs.Trigger value="general">General</Tabs.Trigger>
<Tabs.Trigger value="calendar">Calendar</Tabs.Trigger> <Tabs.Trigger value="calendar">Calendar</Tabs.Trigger>
<Tabs.Trigger value="share">Share</Tabs.Trigger> {(zulipEnabled || emailEnabled) && (
<Tabs.Trigger value="share">Share</Tabs.Trigger>
)}
<Tabs.Trigger value="webhook">WebHook</Tabs.Trigger> <Tabs.Trigger value="webhook">WebHook</Tabs.Trigger>
</Tabs.List> </Tabs.List>
@@ -831,96 +844,144 @@ export default function RoomsList() {
</Tabs.Content> </Tabs.Content>
<Tabs.Content value="share" pt={6}> <Tabs.Content value="share" pt={6}>
<Field.Root> {emailEnabled && (
<Checkbox.Root <>
name="zulipAutoPost" <Field.Root>
checked={room.zulipAutoPost} <Checkbox.Root
onCheckedChange={(e) => { checked={emailTranscriptEnabled}
const syntheticEvent = { onCheckedChange={(e) => {
target: { setEmailTranscriptEnabled(!!e.checked);
name: "zulipAutoPost", if (!e.checked) {
type: "checkbox", setRoomInput({
checked: e.checked, ...room,
}, emailTranscriptTo: "",
}; });
handleRoomChange(syntheticEvent); }
}} }}
> >
<Checkbox.HiddenInput /> <Checkbox.HiddenInput />
<Checkbox.Control> <Checkbox.Control>
<Checkbox.Indicator /> <Checkbox.Indicator />
</Checkbox.Control> </Checkbox.Control>
<Checkbox.Label> <Checkbox.Label>
Automatically post transcription to Zulip Email me transcript when processed
</Checkbox.Label> </Checkbox.Label>
</Checkbox.Root> </Checkbox.Root>
</Field.Root> </Field.Root>
<Field.Root mt={4}> {emailTranscriptEnabled && (
<Field.Label>Zulip stream</Field.Label> <Field.Root mt={2}>
<Select.Root <Input
value={room.zulipStream ? [room.zulipStream] : []} name="emailTranscriptTo"
onValueChange={(e) => type="email"
setRoomInput({ placeholder="your@email.com"
...room, value={room.emailTranscriptTo}
zulipStream: e.value[0], onChange={handleRoomChange}
zulipTopic: "", />
}) <Field.HelperText>
} Transcript will be emailed to this address after
collection={streamCollection} processing
disabled={!room.zulipAutoPost} </Field.HelperText>
> </Field.Root>
<Select.HiddenSelect /> )}
<Select.Control> </>
<Select.Trigger> )}
<Select.ValueText placeholder="Select stream" /> {zulipEnabled && (
</Select.Trigger> <>
<Select.IndicatorGroup> <Field.Root mt={emailEnabled ? 4 : 0}>
<Select.Indicator /> <Checkbox.Root
</Select.IndicatorGroup> name="zulipAutoPost"
</Select.Control> checked={room.zulipAutoPost}
<Select.Positioner> onCheckedChange={(e) => {
<Select.Content> const syntheticEvent = {
{streamOptions.map((option) => ( target: {
<Select.Item key={option.value} item={option}> name: "zulipAutoPost",
{option.label} type: "checkbox",
<Select.ItemIndicator /> checked: e.checked,
</Select.Item> },
))} };
</Select.Content> handleRoomChange(syntheticEvent);
</Select.Positioner> }}
</Select.Root> >
</Field.Root> <Checkbox.HiddenInput />
<Field.Root mt={4}> <Checkbox.Control>
<Field.Label>Zulip topic</Field.Label> <Checkbox.Indicator />
<Select.Root </Checkbox.Control>
value={room.zulipTopic ? [room.zulipTopic] : []} <Checkbox.Label>
onValueChange={(e) => Automatically post transcription to Zulip
setRoomInput({ ...room, zulipTopic: e.value[0] }) </Checkbox.Label>
} </Checkbox.Root>
collection={topicCollection} </Field.Root>
disabled={!room.zulipAutoPost} <Field.Root mt={4}>
> <Field.Label>Zulip stream</Field.Label>
<Select.HiddenSelect /> <Select.Root
<Select.Control> value={room.zulipStream ? [room.zulipStream] : []}
<Select.Trigger> onValueChange={(e) =>
<Select.ValueText placeholder="Select topic" /> setRoomInput({
</Select.Trigger> ...room,
<Select.IndicatorGroup> zulipStream: e.value[0],
<Select.Indicator /> zulipTopic: "",
</Select.IndicatorGroup> })
</Select.Control> }
<Select.Positioner> collection={streamCollection}
<Select.Content> disabled={!room.zulipAutoPost}
{topicOptions.map((option) => ( >
<Select.Item key={option.value} item={option}> <Select.HiddenSelect />
{option.label} <Select.Control>
<Select.ItemIndicator /> <Select.Trigger>
</Select.Item> <Select.ValueText placeholder="Select stream" />
))} </Select.Trigger>
</Select.Content> <Select.IndicatorGroup>
</Select.Positioner> <Select.Indicator />
</Select.Root> </Select.IndicatorGroup>
</Field.Root> </Select.Control>
<Select.Positioner>
<Select.Content>
{streamOptions.map((option) => (
<Select.Item key={option.value} item={option}>
{option.label}
<Select.ItemIndicator />
</Select.Item>
))}
</Select.Content>
</Select.Positioner>
</Select.Root>
</Field.Root>
<Field.Root mt={4}>
<Field.Label>Zulip topic</Field.Label>
<Select.Root
value={room.zulipTopic ? [room.zulipTopic] : []}
onValueChange={(e) =>
setRoomInput({
...room,
zulipTopic: e.value[0],
})
}
collection={topicCollection}
disabled={!room.zulipAutoPost}
>
<Select.HiddenSelect />
<Select.Control>
<Select.Trigger>
<Select.ValueText placeholder="Select topic" />
</Select.Trigger>
<Select.IndicatorGroup>
<Select.Indicator />
</Select.IndicatorGroup>
</Select.Control>
<Select.Positioner>
<Select.Content>
{topicOptions.map((option) => (
<Select.Item key={option.value} item={option}>
{option.label}
<Select.ItemIndicator />
</Select.Item>
))}
</Select.Content>
</Select.Positioner>
</Select.Root>
</Field.Root>
</>
)}
</Tabs.Content> </Tabs.Content>
<Tabs.Content value="webhook" pt={6}> <Tabs.Content value="webhook" pt={6}>

View File

@@ -24,6 +24,8 @@ import {
} from "@chakra-ui/react"; } from "@chakra-ui/react";
import { useTranscriptGet } from "../../../lib/apiHooks"; import { useTranscriptGet } from "../../../lib/apiHooks";
import { TranscriptStatus } from "../../../lib/transcript"; import { TranscriptStatus } from "../../../lib/transcript";
import { useAuth } from "../../../lib/AuthProvider";
import { featureEnabled } from "../../../lib/features";
type TranscriptDetails = { type TranscriptDetails = {
params: Promise<{ params: Promise<{
@@ -57,7 +59,10 @@ export default function TranscriptDetails(details: TranscriptDetails) {
const [finalSummaryElement, setFinalSummaryElement] = const [finalSummaryElement, setFinalSummaryElement] =
useState<HTMLDivElement | null>(null); useState<HTMLDivElement | null>(null);
const hasCloudVideo = !!transcript.data?.has_cloud_video; const auth = useAuth();
const isAuthenticated =
auth.status === "authenticated" || !featureEnabled("requireLogin");
const hasCloudVideo = !!transcript.data?.has_cloud_video && isAuthenticated;
const [videoExpanded, setVideoExpanded] = useState(false); const [videoExpanded, setVideoExpanded] = useState(false);
const [videoNewBadge, setVideoNewBadge] = useState(() => { const [videoNewBadge, setVideoNewBadge] = useState(() => {
if (typeof window === "undefined") return true; if (typeof window === "undefined") return true;
@@ -145,7 +150,7 @@ export default function TranscriptDetails(details: TranscriptDetails) {
mt={4} mt={4}
mb={4} mb={4}
> >
{!mp3.audioDeleted && ( {isAuthenticated && !mp3.audioDeleted && (
<> <>
{waveform.waveform && mp3.media && topics.topics ? ( {waveform.waveform && mp3.media && topics.topics ? (
<Player <Player

View File

@@ -21,6 +21,10 @@ import { useAuth } from "../../../lib/AuthProvider";
import { featureEnabled } from "../../../lib/features"; import { featureEnabled } from "../../../lib/features";
import { SearchableLanguageSelect } from "../../../components/SearchableLanguageSelect"; import { SearchableLanguageSelect } from "../../../components/SearchableLanguageSelect";
const sourceLanguages = supportedLanguages.filter(
(l) => l.value && l.value !== "NOTRANSLATION",
);
const TranscriptCreate = () => { const TranscriptCreate = () => {
const router = useRouter(); const router = useRouter();
const auth = useAuth(); const auth = useAuth();
@@ -33,8 +37,13 @@ const TranscriptCreate = () => {
const nameChange = (event: React.ChangeEvent<HTMLInputElement>) => { const nameChange = (event: React.ChangeEvent<HTMLInputElement>) => {
setName(event.target.value); setName(event.target.value);
}; };
const [sourceLanguage, setSourceLanguage] = useState<string>("");
const [targetLanguage, setTargetLanguage] = useState<string>("NOTRANSLATION"); const [targetLanguage, setTargetLanguage] = useState<string>("NOTRANSLATION");
const onSourceLanguageChange = (newval) => {
(!newval || typeof newval === "string") &&
setSourceLanguage(newval || "en");
};
const onLanguageChange = (newval) => { const onLanguageChange = (newval) => {
(!newval || typeof newval === "string") && setTargetLanguage(newval); (!newval || typeof newval === "string") && setTargetLanguage(newval);
}; };
@@ -55,7 +64,7 @@ const TranscriptCreate = () => {
const targetLang = getTargetLanguage(); const targetLang = getTargetLanguage();
createTranscript.create({ createTranscript.create({
name, name,
source_language: "en", source_language: sourceLanguage || "en",
target_language: targetLang || "en", target_language: targetLang || "en",
source_kind: "live", source_kind: "live",
}); });
@@ -67,7 +76,7 @@ const TranscriptCreate = () => {
const targetLang = getTargetLanguage(); const targetLang = getTargetLanguage();
createTranscript.create({ createTranscript.create({
name, name,
source_language: "en", source_language: sourceLanguage || "en",
target_language: targetLang || "en", target_language: targetLang || "en",
source_kind: "file", source_kind: "file",
}); });
@@ -160,6 +169,15 @@ const TranscriptCreate = () => {
placeholder="Optional" placeholder="Optional"
/> />
</Box> </Box>
<Box mb={4}>
<Text mb={1}>Audio language</Text>
<SearchableLanguageSelect
options={sourceLanguages}
value={sourceLanguage}
onChange={onSourceLanguageChange}
placeholder="Select language"
/>
</Box>
<Box mb={4}> <Box mb={4}>
<Text mb={1}>Do you want to enable live translation?</Text> <Text mb={1}>Do you want to enable live translation?</Text>
<SearchableLanguageSelect <SearchableLanguageSelect

View File

@@ -18,10 +18,11 @@ import {
createListCollection, createListCollection,
} from "@chakra-ui/react"; } from "@chakra-ui/react";
import { LuShare2 } from "react-icons/lu"; import { LuShare2 } from "react-icons/lu";
import { useTranscriptUpdate } from "../../lib/apiHooks"; import { useTranscriptUpdate, useConfig } from "../../lib/apiHooks";
import ShareLink from "./shareLink"; import ShareLink from "./shareLink";
import ShareCopy from "./shareCopy"; import ShareCopy from "./shareCopy";
import ShareZulip from "./shareZulip"; import ShareZulip from "./shareZulip";
import ShareEmail from "./shareEmail";
import { useAuth } from "../../lib/AuthProvider"; import { useAuth } from "../../lib/AuthProvider";
import { featureEnabled } from "../../lib/features"; import { featureEnabled } from "../../lib/features";
@@ -55,6 +56,9 @@ export default function ShareAndPrivacy(props: ShareAndPrivacyProps) {
const [shareLoading, setShareLoading] = useState(false); const [shareLoading, setShareLoading] = useState(false);
const requireLogin = featureEnabled("requireLogin"); const requireLogin = featureEnabled("requireLogin");
const updateTranscriptMutation = useTranscriptUpdate(); const updateTranscriptMutation = useTranscriptUpdate();
const { data: config } = useConfig();
const zulipEnabled = config?.zulip_enabled ?? false;
const emailEnabled = config?.email_enabled ?? false;
const updateShareMode = async (selectedValue: string) => { const updateShareMode = async (selectedValue: string) => {
const selectedOption = shareOptionsData.find( const selectedOption = shareOptionsData.find(
@@ -169,14 +173,20 @@ export default function ShareAndPrivacy(props: ShareAndPrivacyProps) {
<Text fontSize="sm" mb="2" fontWeight={"bold"}> <Text fontSize="sm" mb="2" fontWeight={"bold"}>
Share options Share options
</Text> </Text>
<Flex gap={2} mb={2}> <Flex gap={2} mb={2} flexWrap="wrap">
{requireLogin && ( {requireLogin && zulipEnabled && (
<ShareZulip <ShareZulip
transcript={props.transcript} transcript={props.transcript}
topics={props.topics} topics={props.topics}
disabled={toShareMode(shareMode.value) === "private"} disabled={toShareMode(shareMode.value) === "private"}
/> />
)} )}
{emailEnabled && (
<ShareEmail
transcript={props.transcript}
disabled={toShareMode(shareMode.value) === "private"}
/>
)}
<ShareCopy <ShareCopy
finalSummaryElement={props.finalSummaryElement} finalSummaryElement={props.finalSummaryElement}
transcript={props.transcript} transcript={props.transcript}

View File

@@ -0,0 +1,110 @@
import { useState } from "react";
import type { components } from "../../reflector-api";
type GetTranscriptWithParticipants =
components["schemas"]["GetTranscriptWithParticipants"];
import {
Button,
Dialog,
CloseButton,
Input,
Box,
Text,
} from "@chakra-ui/react";
import { LuMail } from "react-icons/lu";
import { useTranscriptSendEmail } from "../../lib/apiHooks";
type ShareEmailProps = {
transcript: GetTranscriptWithParticipants;
disabled: boolean;
};
export default function ShareEmail(props: ShareEmailProps) {
const [showModal, setShowModal] = useState(false);
const [email, setEmail] = useState("");
const [sent, setSent] = useState(false);
const sendEmailMutation = useTranscriptSendEmail();
const handleSend = async () => {
if (!email) return;
try {
await sendEmailMutation.mutateAsync({
params: {
path: { transcript_id: props.transcript.id },
},
body: { email },
});
setSent(true);
setTimeout(() => {
setSent(false);
setShowModal(false);
setEmail("");
}, 2000);
} catch (error) {
console.error("Error sending email:", error);
}
};
return (
<>
<Button disabled={props.disabled} onClick={() => setShowModal(true)}>
<LuMail /> Send Email
</Button>
<Dialog.Root
open={showModal}
onOpenChange={(e) => {
setShowModal(e.open);
if (!e.open) {
setSent(false);
setEmail("");
}
}}
size="md"
>
<Dialog.Backdrop />
<Dialog.Positioner>
<Dialog.Content>
<Dialog.Header>
<Dialog.Title>Send Transcript via Email</Dialog.Title>
<Dialog.CloseTrigger asChild>
<CloseButton />
</Dialog.CloseTrigger>
</Dialog.Header>
<Dialog.Body>
{sent ? (
<Text color="green.500">Email sent successfully!</Text>
) : (
<Box>
<Text mb={2}>
Enter the email address to send this transcript to:
</Text>
<Input
type="email"
placeholder="recipient@example.com"
value={email}
onChange={(e) => setEmail(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && handleSend()}
/>
</Box>
)}
</Dialog.Body>
<Dialog.Footer>
<Button variant="ghost" onClick={() => setShowModal(false)}>
Close
</Button>
{!sent && (
<Button
disabled={!email || sendEmailMutation.isPending}
onClick={handleSend}
>
{sendEmailMutation.isPending ? "Sending..." : "Send"}
</Button>
)}
</Dialog.Footer>
</Dialog.Content>
</Dialog.Positioner>
</Dialog.Root>
</>
);
}

View File

@@ -56,7 +56,7 @@ const useMp3 = (transcriptId: string, waiting?: boolean): Mp3Response => {
}, [navigator.serviceWorker, !serviceWorker, accessTokenInfo]); }, [navigator.serviceWorker, !serviceWorker, accessTokenInfo]);
useEffect(() => { useEffect(() => {
if (!transcriptId || later || !transcript) return; if (!transcriptId || later || !transcript || !accessTokenInfo) return;
let stopped = false; let stopped = false;
let audioElement: HTMLAudioElement | null = null; let audioElement: HTMLAudioElement | null = null;
@@ -113,7 +113,7 @@ const useMp3 = (transcriptId: string, waiting?: boolean): Mp3Response => {
if (handleError) audioElement.removeEventListener("error", handleError); if (handleError) audioElement.removeEventListener("error", handleError);
} }
}; };
}, [transcriptId, transcript, later]); }, [transcriptId, transcript, later, accessTokenInfo]);
const getNow = () => { const getNow = () => {
setLater(false); setLater(false);

View File

@@ -39,17 +39,16 @@ export default function VideoPlayer({
setLoading(true); setLoading(true);
setError(null); setError(null);
try { try {
const params = new URLSearchParams(); const url = `${API_URL}/v1/transcripts/${transcriptId}/video/url`;
if (accessToken) {
params.set("token", accessToken);
}
const url = `${API_URL}/v1/transcripts/${transcriptId}/video/url?${params}`;
const headers: Record<string, string> = {}; const headers: Record<string, string> = {};
if (accessToken) { if (accessToken) {
headers["Authorization"] = `Bearer ${accessToken}`; headers["Authorization"] = `Bearer ${accessToken}`;
} }
const resp = await fetch(url, { headers }); const resp = await fetch(url, { headers });
if (!resp.ok) { if (!resp.ok) {
if (resp.status === 401) {
throw new Error("Sign in to view the video recording");
}
throw new Error("Failed to load video"); throw new Error("Failed to load video");
} }
const data = await resp.json(); const data = await resp.json();
@@ -90,7 +89,7 @@ export default function VideoPlayer({
w="fit-content" w="fit-content"
maxW="100%" maxW="100%"
> >
<Text fontSize="sm">Failed to load video recording</Text> <Text fontSize="sm">{error || "Failed to load video recording"}</Text>
</Box> </Box>
); );
} }
@@ -132,10 +131,14 @@ export default function VideoPlayer({
</Flex> </Flex>
</Flex> </Flex>
{/* Video element with visible controls */} {/* Video element with visible controls */}
{/* eslint-disable-next-line jsx-a11y/media-has-caption */}
<video <video
src={videoUrl} src={videoUrl}
controls controls
autoPlay autoPlay
controlsList="nodownload"
disablePictureInPicture
onContextMenu={(e) => e.preventDefault()}
style={{ style={{
display: "block", display: "block",
width: "100%", width: "100%",

View File

@@ -67,7 +67,7 @@ export function SearchableLanguageSelect({
const collection = useMemo(() => createListCollection({ items }), [items]); const collection = useMemo(() => createListCollection({ items }), [items]);
const selectedValues = value && value !== "NOTRANSLATION" ? [value] : []; const selectedValues = value ? [value] : [];
return ( return (
<Combobox.Root <Combobox.Root

View File

@@ -228,7 +228,11 @@ export function useRoomDelete() {
}); });
} }
export function useZulipStreams() { export function useConfig() {
return $api.useQuery("get", "/v1/config", {});
}
export function useZulipStreams(enabled: boolean = true) {
const { isAuthenticated } = useAuthReady(); const { isAuthenticated } = useAuthReady();
return $api.useQuery( return $api.useQuery(
@@ -236,7 +240,7 @@ export function useZulipStreams() {
"/v1/zulip/streams", "/v1/zulip/streams",
{}, {},
{ {
enabled: isAuthenticated, enabled: enabled && isAuthenticated,
}, },
); );
} }
@@ -291,6 +295,16 @@ export function useTranscriptPostToZulip() {
}); });
} }
export function useTranscriptSendEmail() {
const { setError } = useError();
return $api.useMutation("post", "/v1/transcripts/{transcript_id}/email", {
onError: (error) => {
setError(error as Error, "There was an error sending the email");
},
});
}
export function useTranscriptUploadAudio() { export function useTranscriptUploadAudio() {
const { setError } = useError(); const { setError } = useError();
const queryClient = useQueryClient(); const queryClient = useQueryClient();

View File

@@ -456,6 +456,23 @@ export interface paths {
patch?: never; patch?: never;
trace?: never; trace?: never;
}; };
"/v1/transcripts/{transcript_id}/email": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
/** Transcript Send Email */
post: operations["v1_transcript_send_email"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/v1/transcripts/{transcript_id}/audio/mp3": { "/v1/transcripts/{transcript_id}/audio/mp3": {
parameters: { parameters: {
query?: never; query?: never;
@@ -739,6 +756,23 @@ export interface paths {
patch?: never; patch?: never;
trace?: never; trace?: never;
}; };
"/v1/config": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/** Get Config */
get: operations["v1_get_config"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/v1/zulip/streams": { "/v1/zulip/streams": {
parameters: { parameters: {
query?: never; query?: never;
@@ -942,6 +976,13 @@ export interface components {
*/ */
updated_at: string; updated_at: string;
}; };
/** ConfigResponse */
ConfigResponse: {
/** Zulip Enabled */
zulip_enabled: boolean;
/** Email Enabled */
email_enabled: boolean;
};
/** CreateApiKeyRequest */ /** CreateApiKeyRequest */
CreateApiKeyRequest: { CreateApiKeyRequest: {
/** Name */ /** Name */
@@ -1025,6 +1066,8 @@ export interface components {
* @default false * @default false
*/ */
skip_consent: boolean; skip_consent: boolean;
/** Email Transcript To */
email_transcript_to?: string | null;
}; };
/** CreateRoomMeeting */ /** CreateRoomMeeting */
CreateRoomMeeting: { CreateRoomMeeting: {
@@ -1844,6 +1887,8 @@ export interface components {
* @default false * @default false
*/ */
skip_consent: boolean; skip_consent: boolean;
/** Email Transcript To */
email_transcript_to?: string | null;
}; };
/** RoomDetails */ /** RoomDetails */
RoomDetails: { RoomDetails: {
@@ -1900,6 +1945,8 @@ export interface components {
* @default false * @default false
*/ */
skip_consent: boolean; skip_consent: boolean;
/** Email Transcript To */
email_transcript_to?: string | null;
/** Webhook Url */ /** Webhook Url */
webhook_url: string | null; webhook_url: string | null;
/** Webhook Secret */ /** Webhook Secret */
@@ -1984,6 +2031,16 @@ export interface components {
/** Change Seq */ /** Change Seq */
change_seq?: number | null; change_seq?: number | null;
}; };
/** SendEmailRequest */
SendEmailRequest: {
/** Email */
email: string;
};
/** SendEmailResponse */
SendEmailResponse: {
/** Sent */
sent: number;
};
/** /**
* SourceKind * SourceKind
* @enum {string} * @enum {string}
@@ -2264,6 +2321,8 @@ export interface components {
platform?: ("whereby" | "daily") | null; platform?: ("whereby" | "daily") | null;
/** Skip Consent */ /** Skip Consent */
skip_consent?: boolean | null; skip_consent?: boolean | null;
/** Email Transcript To */
email_transcript_to?: string | null;
}; };
/** UpdateTranscript */ /** UpdateTranscript */
UpdateTranscript: { UpdateTranscript: {
@@ -3497,6 +3556,41 @@ export interface operations {
}; };
}; };
}; };
v1_transcript_send_email: {
parameters: {
query?: never;
header?: never;
path: {
transcript_id: string;
};
cookie?: never;
};
requestBody: {
content: {
"application/json": components["schemas"]["SendEmailRequest"];
};
};
responses: {
/** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["SendEmailResponse"];
};
};
/** @description Validation Error */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["HTTPValidationError"];
};
};
};
};
v1_transcript_get_audio_mp3: { v1_transcript_get_audio_mp3: {
parameters: { parameters: {
query?: { query?: {
@@ -4167,6 +4261,26 @@ export interface operations {
}; };
}; };
}; };
v1_get_config: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ConfigResponse"];
};
};
};
};
v1_zulip_get_streams: { v1_zulip_get_streams: {
parameters: { parameters: {
query?: never; query?: never;