diff --git a/deploy/meeting-intelligence/api/app/auth.py b/deploy/meeting-intelligence/api/app/auth.py new file mode 100644 index 0000000..734e44a --- /dev/null +++ b/deploy/meeting-intelligence/api/app/auth.py @@ -0,0 +1,50 @@ +""" +Authentication helpers for per-meeting access control. + +Each meeting has a unique access_token. Knowing the conference_id (room name) +lets a client discover the token via GET /meetings/token?conference_id=. +All other endpoints require a valid token to return data. +""" + +from typing import List, Optional + +from fastapi import HTTPException, Request + +import structlog + +log = structlog.get_logger() + + +def get_bearer_token(request: Request) -> Optional[str]: + """Extract Bearer token from Authorization header.""" + auth = request.headers.get("Authorization", "") + if auth.startswith("Bearer "): + return auth[7:] + return None + + +def get_multi_tokens(request: Request) -> List[str]: + """Extract comma-separated tokens from X-MI-Tokens header.""" + raw = request.headers.get("X-MI-Tokens", "") + if not raw: + return [] + return [t.strip() for t in raw.split(",") if t.strip()] + + +async def validate_meeting_access( + request: Request, + meeting_id: str, +) -> None: + """Verify the Bearer token matches the meeting's access_token. + + Raises 403 if the token is missing or doesn't match. + """ + token = get_bearer_token(request) + if not token: + raise HTTPException(status_code=403, detail="Missing access token") + + db = request.app.state.db + stored_token = await db.get_meeting_access_token(meeting_id) + + if not stored_token or token != stored_token: + raise HTTPException(status_code=403, detail="Invalid access token") diff --git a/deploy/meeting-intelligence/api/app/database.py b/deploy/meeting-intelligence/api/app/database.py index a7b786b..5a577eb 100644 --- a/deploy/meeting-intelligence/api/app/database.py +++ b/deploy/meeting-intelligence/api/app/database.py @@ -3,6 +3,7 @@ Database operations for the Meeting Intelligence API. """ import json +import secrets import uuid from datetime import datetime from typing import Optional, List, Dict, Any @@ -103,16 +104,18 @@ class Database: ) -> str: """Create a new meeting record.""" meeting_id = str(uuid.uuid4()) + access_token = secrets.token_urlsafe(32) async with self.pool.acquire() as conn: await conn.execute(""" INSERT INTO meetings ( id, conference_id, conference_name, title, - recording_path, started_at, status, metadata + recording_path, started_at, status, access_token, metadata ) - VALUES ($1, $2, $3, $4, $5, $6, 'recording', $7::jsonb) + VALUES ($1, $2, $3, $4, $5, $6, 'recording', $7, $8::jsonb) """, meeting_id, conference_id, conference_name, title, - recording_path, started_at or datetime.utcnow(), json.dumps(metadata or {})) + recording_path, started_at or datetime.utcnow(), + access_token, json.dumps(metadata or {})) return meeting_id @@ -148,6 +151,91 @@ class Database: WHERE id = ${i}::uuid """, *values) + async def list_meetings_by_tokens( + self, + tokens: List[str], + limit: int = 50, + offset: int = 0, + status: Optional[str] = None + ) -> List[Dict[str, Any]]: + """List meetings filtered by access tokens.""" + if not tokens: + return [] + + async with self.pool.acquire() as conn: + if status: + rows = await conn.fetch(""" + SELECT id, conference_id, conference_name, title, + started_at, ended_at, duration_seconds, + status, created_at + FROM meetings + WHERE access_token = ANY($1) AND status = $2 + ORDER BY created_at DESC + LIMIT $3 OFFSET $4 + """, tokens, status, limit, offset) + else: + rows = await conn.fetch(""" + SELECT id, conference_id, conference_name, title, + started_at, ended_at, duration_seconds, + status, created_at + FROM meetings + WHERE access_token = ANY($1) + ORDER BY created_at DESC + LIMIT $2 OFFSET $3 + """, tokens, limit, offset) + + return [dict(row) for row in rows] + + async def get_meeting_tokens_by_conference( + self, + conference_id: str + ) -> List[Dict[str, Any]]: + """Get access tokens for all meetings with a given conference_id.""" + async with self.pool.acquire() as conn: + rows = await conn.fetch(""" + SELECT id, conference_id, access_token + FROM meetings + WHERE conference_id = $1 AND access_token IS NOT NULL + ORDER BY created_at DESC + """, conference_id) + + return [dict(row) for row in rows] + + async def get_meeting_access_token( + self, + meeting_id: str + ) -> Optional[str]: + """Get the access token for a specific meeting.""" + async with self.pool.acquire() as conn: + row = await conn.fetchrow(""" + SELECT access_token FROM meetings WHERE id = $1::uuid + """, meeting_id) + + if row: + return row["access_token"] + return None + + async def backfill_tokens(self): + """Generate access tokens for meetings that don't have one.""" + async with self.pool.acquire() as conn: + rows = await conn.fetch(""" + SELECT id FROM meetings WHERE access_token IS NULL + """) + + if not rows: + return 0 + + count = 0 + for row in rows: + token = secrets.token_urlsafe(32) + await conn.execute(""" + UPDATE meetings SET access_token = $1 WHERE id = $2 + """, token, row["id"]) + count += 1 + + log.info("Backfilled access tokens", count=count) + return count + # ==================== Transcripts ==================== async def get_transcript( diff --git a/deploy/meeting-intelligence/api/app/main.py b/deploy/meeting-intelligence/api/app/main.py index 8473f63..1803c22 100644 --- a/deploy/meeting-intelligence/api/app/main.py +++ b/deploy/meeting-intelligence/api/app/main.py @@ -45,6 +45,12 @@ async def lifespan(app: FastAPI): # Make database available to routes app.state.db = state.db + # Backfill access tokens for any meetings created before auth was added + try: + await state.db.backfill_tokens() + except Exception as e: + log.warning("Token backfill failed (non-fatal)", error=str(e)) + log.info("Meeting Intelligence API started successfully") yield diff --git a/deploy/meeting-intelligence/api/app/routes/export.py b/deploy/meeting-intelligence/api/app/routes/export.py index f88863e..3849424 100644 --- a/deploy/meeting-intelligence/api/app/routes/export.py +++ b/deploy/meeting-intelligence/api/app/routes/export.py @@ -14,6 +14,8 @@ from fastapi import APIRouter, HTTPException, Request, Response from fastapi.responses import StreamingResponse from pydantic import BaseModel +from ..auth import validate_meeting_access + import structlog log = structlog.get_logger() @@ -35,7 +37,9 @@ async def export_meeting( include_transcript: bool = True, include_summary: bool = True ): - """Export meeting data in various formats.""" + """Export meeting data in various formats. Requires Bearer token.""" + await validate_meeting_access(request, meeting_id) + db = request.app.state.db # Get meeting data diff --git a/deploy/meeting-intelligence/api/app/routes/meetings.py b/deploy/meeting-intelligence/api/app/routes/meetings.py index ba0327b..f026118 100644 --- a/deploy/meeting-intelligence/api/app/routes/meetings.py +++ b/deploy/meeting-intelligence/api/app/routes/meetings.py @@ -7,6 +7,8 @@ from typing import Optional, List from fastapi import APIRouter, HTTPException, Request, Query from pydantic import BaseModel +from ..auth import get_bearer_token, get_multi_tokens, validate_meeting_access + import structlog log = structlog.get_logger() @@ -36,6 +38,39 @@ class MeetingListResponse(BaseModel): offset: int +class MeetingTokenResponse(BaseModel): + conference_id: str + meeting_id: str + access_token: str + + +@router.get("/token", response_model=List[MeetingTokenResponse]) +async def get_meeting_token( + request: Request, + conference_id: str = Query(..., description="Room name to look up tokens for") +): + """Get access tokens for meetings by conference_id (room name). + + This is the only unauthenticated endpoint (besides /health). + Knowing the room name is proof of attendance. + """ + db = request.app.state.db + + meetings = await db.get_meeting_tokens_by_conference(conference_id) + + if not meetings: + raise HTTPException(status_code=404, detail="No meetings found for this conference") + + return [ + MeetingTokenResponse( + conference_id=m["conference_id"], + meeting_id=str(m["id"]), + access_token=m["access_token"] + ) + for m in meetings + ] + + @router.get("", response_model=MeetingListResponse) async def list_meetings( request: Request, @@ -43,10 +78,20 @@ async def list_meetings( offset: int = Query(default=0, ge=0), status: Optional[str] = Query(default=None) ): - """List all meetings with pagination.""" - db = request.app.state.db + """List meetings the caller has access to. - meetings = await db.list_meetings(limit=limit, offset=offset, status=status) + Requires X-MI-Tokens header with comma-separated access tokens. + Returns only meetings matching the provided tokens. + """ + db = request.app.state.db + tokens = get_multi_tokens(request) + + if not tokens: + return MeetingListResponse(meetings=[], total=0, limit=limit, offset=offset) + + meetings = await db.list_meetings_by_tokens( + tokens=tokens, limit=limit, offset=offset, status=status + ) return MeetingListResponse( meetings=[ @@ -63,7 +108,7 @@ async def list_meetings( ) for m in meetings ], - total=len(meetings), # TODO: Add total count query + total=len(meetings), limit=limit, offset=offset ) @@ -71,9 +116,10 @@ async def list_meetings( @router.get("/{meeting_id}", response_model=MeetingResponse) async def get_meeting(request: Request, meeting_id: str): - """Get meeting details.""" - db = request.app.state.db + """Get meeting details. Requires Bearer token.""" + await validate_meeting_access(request, meeting_id) + db = request.app.state.db meeting = await db.get_meeting(meeting_id) if not meeting: @@ -97,16 +143,15 @@ async def get_meeting(request: Request, meeting_id: str): @router.delete("/{meeting_id}") async def delete_meeting(request: Request, meeting_id: str): - """Delete a meeting and all associated data.""" - db = request.app.state.db + """Delete a meeting and all associated data. Requires Bearer token.""" + await validate_meeting_access(request, meeting_id) + db = request.app.state.db meeting = await db.get_meeting(meeting_id) if not meeting: raise HTTPException(status_code=404, detail="Meeting not found") - # TODO: Implement cascade delete - # For now, just mark as deleted await db.update_meeting(meeting_id, status="deleted") return {"status": "deleted", "meeting_id": meeting_id} diff --git a/deploy/meeting-intelligence/api/app/routes/search.py b/deploy/meeting-intelligence/api/app/routes/search.py index 6f209de..4859947 100644 --- a/deploy/meeting-intelligence/api/app/routes/search.py +++ b/deploy/meeting-intelligence/api/app/routes/search.py @@ -8,6 +8,7 @@ from fastapi import APIRouter, HTTPException, Request, Query from pydantic import BaseModel from sentence_transformers import SentenceTransformer +from ..auth import get_multi_tokens from ..config import settings import structlog @@ -58,6 +59,8 @@ class SearchRequest(BaseModel): async def search_transcripts(request: Request, body: SearchRequest): """Search across meeting transcripts. + Requires X-MI-Tokens header. Results are filtered to authorized meetings only. + Search types: - text: Full-text search using PostgreSQL ts_vector - semantic: Semantic search using vector embeddings @@ -65,6 +68,13 @@ async def search_transcripts(request: Request, body: SearchRequest): """ db = request.app.state.db + # Get authorized meeting IDs from tokens + tokens = get_multi_tokens(request) + authorized_meetings = set() + if tokens: + meetings = await db.list_meetings_by_tokens(tokens=tokens, limit=1000) + authorized_meetings = {str(m["id"]) for m in meetings} + if not body.query or len(body.query.strip()) < 2: raise HTTPException( status_code=400, @@ -124,6 +134,12 @@ async def search_transcripts(request: Request, body: SearchRequest): detail=f"Semantic search failed: {str(e)}" ) + # Filter to authorized meetings only + if authorized_meetings: + results = [r for r in results if r.meeting_id in authorized_meetings] + else: + results = [] + # Deduplicate and sort by score seen = set() unique_results = [] diff --git a/deploy/meeting-intelligence/api/app/routes/summaries.py b/deploy/meeting-intelligence/api/app/routes/summaries.py index 717d3db..d071ba7 100644 --- a/deploy/meeting-intelligence/api/app/routes/summaries.py +++ b/deploy/meeting-intelligence/api/app/routes/summaries.py @@ -10,6 +10,7 @@ import httpx from fastapi import APIRouter, HTTPException, Request, BackgroundTasks from pydantic import BaseModel +from ..auth import validate_meeting_access from ..config import settings import structlog @@ -116,7 +117,9 @@ def _summary_to_response(meeting_id: str, summary: dict) -> SummaryResponse: @router.get("/{meeting_id}/summary", response_model=SummaryResponse) async def get_summary(request: Request, meeting_id: str): - """Get AI-generated summary for a meeting.""" + """Get AI-generated summary for a meeting. Requires Bearer token.""" + await validate_meeting_access(request, meeting_id) + db = request.app.state.db # Verify meeting exists @@ -142,7 +145,9 @@ async def generate_summary( body: GenerateSummaryRequest, background_tasks: BackgroundTasks ): - """Generate AI summary for a meeting.""" + """Generate AI summary for a meeting. Requires Bearer token.""" + await validate_meeting_access(request, meeting_id) + db = request.app.state.db # Verify meeting exists diff --git a/deploy/meeting-intelligence/api/app/routes/transcripts.py b/deploy/meeting-intelligence/api/app/routes/transcripts.py index a47bf89..8d2b51e 100644 --- a/deploy/meeting-intelligence/api/app/routes/transcripts.py +++ b/deploy/meeting-intelligence/api/app/routes/transcripts.py @@ -7,6 +7,8 @@ from typing import Optional, List from fastapi import APIRouter, HTTPException, Request, Query from pydantic import BaseModel +from ..auth import validate_meeting_access + import structlog log = structlog.get_logger() @@ -53,7 +55,9 @@ async def get_transcript( meeting_id: str, speaker: Optional[str] = Query(default=None, description="Filter by speaker ID") ): - """Get full transcript for a meeting.""" + """Get full transcript for a meeting. Requires Bearer token.""" + await validate_meeting_access(request, meeting_id) + db = request.app.state.db # Verify meeting exists @@ -96,7 +100,9 @@ async def get_transcript( @router.get("/{meeting_id}/speakers", response_model=SpeakersResponse) async def get_speakers(request: Request, meeting_id: str): - """Get speaker statistics for a meeting.""" + """Get speaker statistics for a meeting. Requires Bearer token.""" + await validate_meeting_access(request, meeting_id) + db = request.app.state.db # Verify meeting exists @@ -123,7 +129,9 @@ async def get_speakers(request: Request, meeting_id: str): @router.get("/{meeting_id}/transcript/text") async def get_transcript_text(request: Request, meeting_id: str): - """Get transcript as plain text.""" + """Get transcript as plain text. Requires Bearer token.""" + await validate_meeting_access(request, meeting_id) + db = request.app.state.db # Verify meeting exists diff --git a/deploy/meeting-intelligence/postgres/init.sql b/deploy/meeting-intelligence/postgres/init.sql index 24556ca..6408b3e 100644 --- a/deploy/meeting-intelligence/postgres/init.sql +++ b/deploy/meeting-intelligence/postgres/init.sql @@ -21,6 +21,7 @@ CREATE TABLE meetings ( status VARCHAR(50) DEFAULT 'recording', -- Status: 'recording', 'extracting_audio', 'transcribing', 'diarizing', 'summarizing', 'ready', 'failed' error_message TEXT, + access_token VARCHAR(64) UNIQUE, metadata JSONB DEFAULT '{}', created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() @@ -30,6 +31,7 @@ CREATE INDEX idx_meetings_conference_id ON meetings(conference_id); CREATE INDEX idx_meetings_status ON meetings(status); CREATE INDEX idx_meetings_started_at ON meetings(started_at DESC); CREATE INDEX idx_meetings_created_at ON meetings(created_at DESC); +CREATE INDEX idx_meetings_access_token ON meetings(access_token); -- ============================================================ -- Meeting Participants diff --git a/react/features/app/middlewares.web.ts b/react/features/app/middlewares.web.ts index 1156a4b..3ea27cb 100644 --- a/react/features/app/middlewares.web.ts +++ b/react/features/app/middlewares.web.ts @@ -27,5 +27,6 @@ import '../face-landmarks/middleware'; import '../gifs/middleware'; import '../whiteboard/middleware.web'; import '../file-sharing/middleware.web'; +import '../meeting-intelligence/middleware'; import './middlewares.any'; diff --git a/react/features/meeting-intelligence/actions.ts b/react/features/meeting-intelligence/actions.ts index c281055..21a6c20 100644 --- a/react/features/meeting-intelligence/actions.ts +++ b/react/features/meeting-intelligence/actions.ts @@ -2,7 +2,7 @@ * Action creators for Meeting Intelligence feature. */ -import { IStore } from '../app/types'; +import { IReduxState, IStore } from '../app/types'; import { CLEAR_SEARCH, @@ -34,6 +34,7 @@ import { UPDATE_MEETING_STATUS } from './actionTypes'; import { API_BASE_URL } from './constants'; +import { getAllTokens, getTokenForConference, storeToken } from './tokenStorage'; import { IMeeting, IMeetingSummary, @@ -42,6 +43,43 @@ import { ITranscriptSegment } from './types'; +/** + * Get a Bearer auth header for a meeting, looked up by conference_id from state. + * + * @param {Function} getState - Redux getState function. + * @param {string} meetingId - Optional meeting ID to look up conference_id. + * @returns {Record} Headers object with Authorization if token found. + */ +function _getBearerHeaders(getState: () => IReduxState, meetingId?: string): Record { + const state = getState(); + const miState = state['features/meeting-intelligence']; + let conferenceId: string | undefined; + + // Try to find conference_id from selected meeting or provided meetingId + if (miState?.selectedMeeting) { + conferenceId = miState.selectedMeeting.conference_id; + } else if (meetingId && miState?.meetings) { + const meeting = miState.meetings.find((m: IMeeting) => m.id === meetingId); + + conferenceId = meeting?.conference_id; + } + + // Fall back to current conference room name + if (!conferenceId) { + conferenceId = state['features/base/conference']?.room; + } + + if (conferenceId) { + const token = getTokenForConference(conferenceId); + + if (token) { + return { Authorization: `Bearer ${token}` }; + } + } + + return {}; +} + /** * Toggle the meeting intelligence dashboard. * @@ -94,6 +132,37 @@ export function clearSelectedMeeting() { }; } +/** + * Fetch access tokens for a conference_id and store them locally. + * + * @param {string} conferenceId - The room name. + * @returns {Function} Async thunk action. + */ +export function fetchMeetingToken(conferenceId: string) { + return async (_dispatch: IStore['dispatch']) => { + try { + const response = await fetch( + `${API_BASE_URL}/meetings/token?conference_id=${encodeURIComponent(conferenceId)}` + ); + + if (!response.ok) { + return; + } + + const data = await response.json(); + + // Store each token keyed by conference_id + if (Array.isArray(data)) { + for (const entry of data) { + storeToken(entry.conference_id, entry.access_token); + } + } + } catch { + // Token fetch failures are non-fatal + } + }; +} + /** * Fetch the list of meetings. * @@ -104,7 +173,14 @@ export function fetchMeetings() { dispatch({ type: FETCH_MEETINGS_REQUEST }); try { - const response = await fetch(`${API_BASE_URL}/meetings`); + const tokens = getAllTokens(); + const headers: Record = {}; + + if (tokens.length > 0) { + headers['X-MI-Tokens'] = tokens.join(','); + } + + const response = await fetch(`${API_BASE_URL}/meetings`, { headers }); if (!response.ok) { throw new Error(`HTTP error: ${response.status}`); @@ -132,11 +208,13 @@ export function fetchMeetings() { * @returns {Function} Async thunk action. */ export function fetchTranscript(meetingId: string) { - return async (dispatch: IStore['dispatch']) => { + return async (dispatch: IStore['dispatch'], getState: () => IReduxState) => { dispatch({ type: FETCH_TRANSCRIPT_REQUEST }); try { - const response = await fetch(`${API_BASE_URL}/meetings/${meetingId}/transcript`); + const response = await fetch(`${API_BASE_URL}/meetings/${meetingId}/transcript`, { + headers: _getBearerHeaders(getState, meetingId) + }); if (!response.ok) { if (response.status === 404) { @@ -172,11 +250,13 @@ export function fetchTranscript(meetingId: string) { * @returns {Function} Async thunk action. */ export function fetchSummary(meetingId: string) { - return async (dispatch: IStore['dispatch']) => { + return async (dispatch: IStore['dispatch'], getState: () => IReduxState) => { dispatch({ type: FETCH_SUMMARY_REQUEST }); try { - const response = await fetch(`${API_BASE_URL}/meetings/${meetingId}/summary`); + const response = await fetch(`${API_BASE_URL}/meetings/${meetingId}/summary`, { + headers: _getBearerHeaders(getState, meetingId) + }); if (!response.ok) { if (response.status === 404) { @@ -212,12 +292,13 @@ export function fetchSummary(meetingId: string) { * @returns {Function} Async thunk action. */ export function generateSummary(meetingId: string) { - return async (dispatch: IStore['dispatch']) => { + return async (dispatch: IStore['dispatch'], getState: () => IReduxState) => { dispatch({ type: GENERATE_SUMMARY_REQUEST }); try { const response = await fetch(`${API_BASE_URL}/meetings/${meetingId}/summary`, { - method: 'POST' + method: 'POST', + headers: _getBearerHeaders(getState, meetingId) }); if (!response.ok) { @@ -246,9 +327,11 @@ export function generateSummary(meetingId: string) { * @returns {Function} Async thunk action. */ export function fetchSpeakerStats(meetingId: string) { - return async (dispatch: IStore['dispatch']) => { + return async (dispatch: IStore['dispatch'], getState: () => IReduxState) => { try { - const response = await fetch(`${API_BASE_URL}/meetings/${meetingId}/speakers`); + const response = await fetch(`${API_BASE_URL}/meetings/${meetingId}/speakers`, { + headers: _getBearerHeaders(getState, meetingId) + }); if (!response.ok) { return; @@ -285,9 +368,18 @@ export function searchTranscripts(query: string) { dispatch({ type: SEARCH_REQUEST }); try { + const tokens = getAllTokens(); + const headers: Record = { + 'Content-Type': 'application/json' + }; + + if (tokens.length > 0) { + headers['X-MI-Tokens'] = tokens.join(','); + } + const response = await fetch(`${API_BASE_URL}/search`, { method: 'POST', - headers: { 'Content-Type': 'application/json' }, + headers, body: JSON.stringify({ query, limit: 50 }) }); @@ -340,12 +432,13 @@ export function setExportFormat(format: 'pdf' | 'markdown' | 'json') { * @returns {Function} Async thunk action. */ export function exportMeeting(meetingId: string, format: string) { - return async (dispatch: IStore['dispatch']) => { + return async (dispatch: IStore['dispatch'], getState: () => IReduxState) => { dispatch({ type: EXPORT_REQUEST }); try { const response = await fetch( - `${API_BASE_URL}/meetings/${meetingId}/export?format=${format}` + `${API_BASE_URL}/meetings/${meetingId}/export?format=${format}`, + { headers: _getBearerHeaders(getState, meetingId) } ); if (!response.ok) { diff --git a/react/features/meeting-intelligence/components/web/RecordingsList.tsx b/react/features/meeting-intelligence/components/web/RecordingsList.tsx index 10d653f..51de4b5 100644 --- a/react/features/meeting-intelligence/components/web/RecordingsList.tsx +++ b/react/features/meeting-intelligence/components/web/RecordingsList.tsx @@ -51,8 +51,8 @@ const RecordingsList: React.FC = () => { width = '48'> -

No recordings yet

-

Recordings will appear here after you record a meeting.

+

No recordings available

+

Recordings will appear here after your meetings are processed.

); } diff --git a/react/features/meeting-intelligence/middleware.ts b/react/features/meeting-intelligence/middleware.ts new file mode 100644 index 0000000..58752c5 --- /dev/null +++ b/react/features/meeting-intelligence/middleware.ts @@ -0,0 +1,25 @@ +/** + * Middleware for Meeting Intelligence feature. + * + * Listens for CONFERENCE_JOINED to automatically fetch the access token + * for the current room, so it's ready when the user opens the MI dashboard. + */ + +import { CONFERENCE_JOINED } from '../base/conference/actionTypes'; +import MiddlewareRegistry from '../base/redux/MiddlewareRegistry'; + +import { fetchMeetingToken } from './actions'; + +MiddlewareRegistry.register(({ dispatch, getState }) => (next: Function) => (action: any) => { + const result = next(action); + + if (action.type === CONFERENCE_JOINED) { + const room = getState()['features/base/conference']?.room; + + if (room) { + dispatch(fetchMeetingToken(room) as any); + } + } + + return result; +}); diff --git a/react/features/meeting-intelligence/tokenStorage.ts b/react/features/meeting-intelligence/tokenStorage.ts new file mode 100644 index 0000000..00adb37 --- /dev/null +++ b/react/features/meeting-intelligence/tokenStorage.ts @@ -0,0 +1,64 @@ +/** + * LocalStorage-based token storage for Meeting Intelligence access control. + * + * Tokens are keyed by conference_id (room name) so attendees can access + * meeting data across sessions without user accounts. + */ + +const STORAGE_KEY = 'mi-tokens'; + +/** + * Get all stored tokens as a map of conferenceId -> token. + * + * @returns {Record} The stored tokens. + */ +export function getStoredTokens(): Record { + try { + const raw = localStorage.getItem(STORAGE_KEY); + + if (!raw) { + return {}; + } + + return JSON.parse(raw); + } catch { + return {}; + } +} + +/** + * Store a token for a conference. + * + * @param {string} conferenceId - The conference/room name. + * @param {string} token - The access token. + * @returns {void} + */ +export function storeToken(conferenceId: string, token: string): void { + try { + const tokens = getStoredTokens(); + + tokens[conferenceId] = token; + localStorage.setItem(STORAGE_KEY, JSON.stringify(tokens)); + } catch { + // localStorage may be unavailable + } +} + +/** + * Get a flat array of all stored token values. + * + * @returns {string[]} Array of token strings. + */ +export function getAllTokens(): string[] { + return Object.values(getStoredTokens()); +} + +/** + * Get the token for a specific conference. + * + * @param {string} conferenceId - The conference/room name. + * @returns {string|undefined} The token or undefined. + */ +export function getTokenForConference(conferenceId: string): string | undefined { + return getStoredTokens()[conferenceId]; +}