feat(meeting-intelligence): add per-meeting access control

Restrict MI data so only meeting attendees can access their meetings.
Each meeting gets a unique access_token generated at creation time.
Attendees discover their token via conference_id (room name) lookup.

Backend:
- New auth.py with Bearer token validation and multi-token extraction
- Token generation in create_meeting(), backfill on startup
- All endpoints gated: list_meetings filters by X-MI-Tokens header,
  per-meeting endpoints require Authorization: Bearer <token>
- New GET /meetings/token?conference_id=<room> discovery endpoint

Frontend:
- tokenStorage.ts manages tokens in localStorage keyed by room name
- middleware.ts auto-fetches token on CONFERENCE_JOINED
- All API calls in actions.ts now include auth headers

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Jeff Emmett 2026-04-09 10:18:13 -04:00
parent 4efcdfbf36
commit 2fe77055a9
14 changed files with 441 additions and 34 deletions

View File

@ -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=<room>.
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")

View File

@ -3,6 +3,7 @@ Database operations for the Meeting Intelligence API.
""" """
import json import json
import secrets
import uuid import uuid
from datetime import datetime from datetime import datetime
from typing import Optional, List, Dict, Any from typing import Optional, List, Dict, Any
@ -103,16 +104,18 @@ class Database:
) -> str: ) -> str:
"""Create a new meeting record.""" """Create a new meeting record."""
meeting_id = str(uuid.uuid4()) meeting_id = str(uuid.uuid4())
access_token = secrets.token_urlsafe(32)
async with self.pool.acquire() as conn: async with self.pool.acquire() as conn:
await conn.execute(""" await conn.execute("""
INSERT INTO meetings ( INSERT INTO meetings (
id, conference_id, conference_name, title, 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, """, 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 return meeting_id
@ -148,6 +151,91 @@ class Database:
WHERE id = ${i}::uuid WHERE id = ${i}::uuid
""", *values) """, *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 ==================== # ==================== Transcripts ====================
async def get_transcript( async def get_transcript(

View File

@ -45,6 +45,12 @@ async def lifespan(app: FastAPI):
# Make database available to routes # Make database available to routes
app.state.db = state.db 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") log.info("Meeting Intelligence API started successfully")
yield yield

View File

@ -14,6 +14,8 @@ from fastapi import APIRouter, HTTPException, Request, Response
from fastapi.responses import StreamingResponse from fastapi.responses import StreamingResponse
from pydantic import BaseModel from pydantic import BaseModel
from ..auth import validate_meeting_access
import structlog import structlog
log = structlog.get_logger() log = structlog.get_logger()
@ -35,7 +37,9 @@ async def export_meeting(
include_transcript: bool = True, include_transcript: bool = True,
include_summary: 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 db = request.app.state.db
# Get meeting data # Get meeting data

View File

@ -7,6 +7,8 @@ from typing import Optional, List
from fastapi import APIRouter, HTTPException, Request, Query from fastapi import APIRouter, HTTPException, Request, Query
from pydantic import BaseModel from pydantic import BaseModel
from ..auth import get_bearer_token, get_multi_tokens, validate_meeting_access
import structlog import structlog
log = structlog.get_logger() log = structlog.get_logger()
@ -36,6 +38,39 @@ class MeetingListResponse(BaseModel):
offset: int 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) @router.get("", response_model=MeetingListResponse)
async def list_meetings( async def list_meetings(
request: Request, request: Request,
@ -43,10 +78,20 @@ async def list_meetings(
offset: int = Query(default=0, ge=0), offset: int = Query(default=0, ge=0),
status: Optional[str] = Query(default=None) status: Optional[str] = Query(default=None)
): ):
"""List all meetings with pagination.""" """List meetings the caller has access to.
db = request.app.state.db
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( return MeetingListResponse(
meetings=[ meetings=[
@ -63,7 +108,7 @@ async def list_meetings(
) )
for m in meetings for m in meetings
], ],
total=len(meetings), # TODO: Add total count query total=len(meetings),
limit=limit, limit=limit,
offset=offset offset=offset
) )
@ -71,9 +116,10 @@ async def list_meetings(
@router.get("/{meeting_id}", response_model=MeetingResponse) @router.get("/{meeting_id}", response_model=MeetingResponse)
async def get_meeting(request: Request, meeting_id: str): async def get_meeting(request: Request, meeting_id: str):
"""Get meeting details.""" """Get meeting details. Requires Bearer token."""
db = request.app.state.db await validate_meeting_access(request, meeting_id)
db = request.app.state.db
meeting = await db.get_meeting(meeting_id) meeting = await db.get_meeting(meeting_id)
if not meeting: if not meeting:
@ -97,16 +143,15 @@ async def get_meeting(request: Request, meeting_id: str):
@router.delete("/{meeting_id}") @router.delete("/{meeting_id}")
async def delete_meeting(request: Request, meeting_id: str): async def delete_meeting(request: Request, meeting_id: str):
"""Delete a meeting and all associated data.""" """Delete a meeting and all associated data. Requires Bearer token."""
db = request.app.state.db await validate_meeting_access(request, meeting_id)
db = request.app.state.db
meeting = await db.get_meeting(meeting_id) meeting = await db.get_meeting(meeting_id)
if not meeting: if not meeting:
raise HTTPException(status_code=404, detail="Meeting not found") 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") await db.update_meeting(meeting_id, status="deleted")
return {"status": "deleted", "meeting_id": meeting_id} return {"status": "deleted", "meeting_id": meeting_id}

View File

@ -8,6 +8,7 @@ from fastapi import APIRouter, HTTPException, Request, Query
from pydantic import BaseModel from pydantic import BaseModel
from sentence_transformers import SentenceTransformer from sentence_transformers import SentenceTransformer
from ..auth import get_multi_tokens
from ..config import settings from ..config import settings
import structlog import structlog
@ -58,6 +59,8 @@ class SearchRequest(BaseModel):
async def search_transcripts(request: Request, body: SearchRequest): async def search_transcripts(request: Request, body: SearchRequest):
"""Search across meeting transcripts. """Search across meeting transcripts.
Requires X-MI-Tokens header. Results are filtered to authorized meetings only.
Search types: Search types:
- text: Full-text search using PostgreSQL ts_vector - text: Full-text search using PostgreSQL ts_vector
- semantic: Semantic search using vector embeddings - semantic: Semantic search using vector embeddings
@ -65,6 +68,13 @@ async def search_transcripts(request: Request, body: SearchRequest):
""" """
db = request.app.state.db 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: if not body.query or len(body.query.strip()) < 2:
raise HTTPException( raise HTTPException(
status_code=400, status_code=400,
@ -124,6 +134,12 @@ async def search_transcripts(request: Request, body: SearchRequest):
detail=f"Semantic search failed: {str(e)}" 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 # Deduplicate and sort by score
seen = set() seen = set()
unique_results = [] unique_results = []

View File

@ -10,6 +10,7 @@ import httpx
from fastapi import APIRouter, HTTPException, Request, BackgroundTasks from fastapi import APIRouter, HTTPException, Request, BackgroundTasks
from pydantic import BaseModel from pydantic import BaseModel
from ..auth import validate_meeting_access
from ..config import settings from ..config import settings
import structlog import structlog
@ -116,7 +117,9 @@ def _summary_to_response(meeting_id: str, summary: dict) -> SummaryResponse:
@router.get("/{meeting_id}/summary", response_model=SummaryResponse) @router.get("/{meeting_id}/summary", response_model=SummaryResponse)
async def get_summary(request: Request, meeting_id: str): 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 db = request.app.state.db
# Verify meeting exists # Verify meeting exists
@ -142,7 +145,9 @@ async def generate_summary(
body: GenerateSummaryRequest, body: GenerateSummaryRequest,
background_tasks: BackgroundTasks 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 db = request.app.state.db
# Verify meeting exists # Verify meeting exists

View File

@ -7,6 +7,8 @@ from typing import Optional, List
from fastapi import APIRouter, HTTPException, Request, Query from fastapi import APIRouter, HTTPException, Request, Query
from pydantic import BaseModel from pydantic import BaseModel
from ..auth import validate_meeting_access
import structlog import structlog
log = structlog.get_logger() log = structlog.get_logger()
@ -53,7 +55,9 @@ async def get_transcript(
meeting_id: str, meeting_id: str,
speaker: Optional[str] = Query(default=None, description="Filter by speaker ID") 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 db = request.app.state.db
# Verify meeting exists # Verify meeting exists
@ -96,7 +100,9 @@ async def get_transcript(
@router.get("/{meeting_id}/speakers", response_model=SpeakersResponse) @router.get("/{meeting_id}/speakers", response_model=SpeakersResponse)
async def get_speakers(request: Request, meeting_id: str): 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 db = request.app.state.db
# Verify meeting exists # Verify meeting exists
@ -123,7 +129,9 @@ async def get_speakers(request: Request, meeting_id: str):
@router.get("/{meeting_id}/transcript/text") @router.get("/{meeting_id}/transcript/text")
async def get_transcript_text(request: Request, meeting_id: str): 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 db = request.app.state.db
# Verify meeting exists # Verify meeting exists

View File

@ -21,6 +21,7 @@ CREATE TABLE meetings (
status VARCHAR(50) DEFAULT 'recording', status VARCHAR(50) DEFAULT 'recording',
-- Status: 'recording', 'extracting_audio', 'transcribing', 'diarizing', 'summarizing', 'ready', 'failed' -- Status: 'recording', 'extracting_audio', 'transcribing', 'diarizing', 'summarizing', 'ready', 'failed'
error_message TEXT, error_message TEXT,
access_token VARCHAR(64) UNIQUE,
metadata JSONB DEFAULT '{}', metadata JSONB DEFAULT '{}',
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
updated_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_status ON meetings(status);
CREATE INDEX idx_meetings_started_at ON meetings(started_at DESC); 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_created_at ON meetings(created_at DESC);
CREATE INDEX idx_meetings_access_token ON meetings(access_token);
-- ============================================================ -- ============================================================
-- Meeting Participants -- Meeting Participants

View File

@ -27,5 +27,6 @@ import '../face-landmarks/middleware';
import '../gifs/middleware'; import '../gifs/middleware';
import '../whiteboard/middleware.web'; import '../whiteboard/middleware.web';
import '../file-sharing/middleware.web'; import '../file-sharing/middleware.web';
import '../meeting-intelligence/middleware';
import './middlewares.any'; import './middlewares.any';

View File

@ -2,7 +2,7 @@
* Action creators for Meeting Intelligence feature. * Action creators for Meeting Intelligence feature.
*/ */
import { IStore } from '../app/types'; import { IReduxState, IStore } from '../app/types';
import { import {
CLEAR_SEARCH, CLEAR_SEARCH,
@ -34,6 +34,7 @@ import {
UPDATE_MEETING_STATUS UPDATE_MEETING_STATUS
} from './actionTypes'; } from './actionTypes';
import { API_BASE_URL } from './constants'; import { API_BASE_URL } from './constants';
import { getAllTokens, getTokenForConference, storeToken } from './tokenStorage';
import { import {
IMeeting, IMeeting,
IMeetingSummary, IMeetingSummary,
@ -42,6 +43,43 @@ import {
ITranscriptSegment ITranscriptSegment
} from './types'; } 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<string, string>} Headers object with Authorization if token found.
*/
function _getBearerHeaders(getState: () => IReduxState, meetingId?: string): Record<string, string> {
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. * 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. * Fetch the list of meetings.
* *
@ -104,7 +173,14 @@ export function fetchMeetings() {
dispatch({ type: FETCH_MEETINGS_REQUEST }); dispatch({ type: FETCH_MEETINGS_REQUEST });
try { try {
const response = await fetch(`${API_BASE_URL}/meetings`); const tokens = getAllTokens();
const headers: Record<string, string> = {};
if (tokens.length > 0) {
headers['X-MI-Tokens'] = tokens.join(',');
}
const response = await fetch(`${API_BASE_URL}/meetings`, { headers });
if (!response.ok) { if (!response.ok) {
throw new Error(`HTTP error: ${response.status}`); throw new Error(`HTTP error: ${response.status}`);
@ -132,11 +208,13 @@ export function fetchMeetings() {
* @returns {Function} Async thunk action. * @returns {Function} Async thunk action.
*/ */
export function fetchTranscript(meetingId: string) { export function fetchTranscript(meetingId: string) {
return async (dispatch: IStore['dispatch']) => { return async (dispatch: IStore['dispatch'], getState: () => IReduxState) => {
dispatch({ type: FETCH_TRANSCRIPT_REQUEST }); dispatch({ type: FETCH_TRANSCRIPT_REQUEST });
try { 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.ok) {
if (response.status === 404) { if (response.status === 404) {
@ -172,11 +250,13 @@ export function fetchTranscript(meetingId: string) {
* @returns {Function} Async thunk action. * @returns {Function} Async thunk action.
*/ */
export function fetchSummary(meetingId: string) { export function fetchSummary(meetingId: string) {
return async (dispatch: IStore['dispatch']) => { return async (dispatch: IStore['dispatch'], getState: () => IReduxState) => {
dispatch({ type: FETCH_SUMMARY_REQUEST }); dispatch({ type: FETCH_SUMMARY_REQUEST });
try { 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.ok) {
if (response.status === 404) { if (response.status === 404) {
@ -212,12 +292,13 @@ export function fetchSummary(meetingId: string) {
* @returns {Function} Async thunk action. * @returns {Function} Async thunk action.
*/ */
export function generateSummary(meetingId: string) { export function generateSummary(meetingId: string) {
return async (dispatch: IStore['dispatch']) => { return async (dispatch: IStore['dispatch'], getState: () => IReduxState) => {
dispatch({ type: GENERATE_SUMMARY_REQUEST }); dispatch({ type: GENERATE_SUMMARY_REQUEST });
try { try {
const response = await fetch(`${API_BASE_URL}/meetings/${meetingId}/summary`, { const response = await fetch(`${API_BASE_URL}/meetings/${meetingId}/summary`, {
method: 'POST' method: 'POST',
headers: _getBearerHeaders(getState, meetingId)
}); });
if (!response.ok) { if (!response.ok) {
@ -246,9 +327,11 @@ export function generateSummary(meetingId: string) {
* @returns {Function} Async thunk action. * @returns {Function} Async thunk action.
*/ */
export function fetchSpeakerStats(meetingId: string) { export function fetchSpeakerStats(meetingId: string) {
return async (dispatch: IStore['dispatch']) => { return async (dispatch: IStore['dispatch'], getState: () => IReduxState) => {
try { 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) { if (!response.ok) {
return; return;
@ -285,9 +368,18 @@ export function searchTranscripts(query: string) {
dispatch({ type: SEARCH_REQUEST }); dispatch({ type: SEARCH_REQUEST });
try { try {
const tokens = getAllTokens();
const headers: Record<string, string> = {
'Content-Type': 'application/json'
};
if (tokens.length > 0) {
headers['X-MI-Tokens'] = tokens.join(',');
}
const response = await fetch(`${API_BASE_URL}/search`, { const response = await fetch(`${API_BASE_URL}/search`, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers,
body: JSON.stringify({ query, limit: 50 }) body: JSON.stringify({ query, limit: 50 })
}); });
@ -340,12 +432,13 @@ export function setExportFormat(format: 'pdf' | 'markdown' | 'json') {
* @returns {Function} Async thunk action. * @returns {Function} Async thunk action.
*/ */
export function exportMeeting(meetingId: string, format: string) { export function exportMeeting(meetingId: string, format: string) {
return async (dispatch: IStore['dispatch']) => { return async (dispatch: IStore['dispatch'], getState: () => IReduxState) => {
dispatch({ type: EXPORT_REQUEST }); dispatch({ type: EXPORT_REQUEST });
try { try {
const response = await fetch( 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) { if (!response.ok) {

View File

@ -51,8 +51,8 @@ const RecordingsList: React.FC = () => {
width = '48'> width = '48'>
<path d = 'M17 10.5V7c0-.55-.45-1-1-1H4c-.55 0-1 .45-1 1v10c0 .55.45 1 1 1h12c.55 0 1-.45 1-1v-3.5l4 4v-11l-4 4z' /> <path d = 'M17 10.5V7c0-.55-.45-1-1-1H4c-.55 0-1 .45-1 1v10c0 .55.45 1 1 1h12c.55 0 1-.45 1-1v-3.5l4 4v-11l-4 4z' />
</svg> </svg>
<h3>No recordings yet</h3> <h3>No recordings available</h3>
<p>Recordings will appear here after you record a meeting.</p> <p>Recordings will appear here after your meetings are processed.</p>
</div> </div>
); );
} }

View File

@ -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;
});

View File

@ -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<string, string>} The stored tokens.
*/
export function getStoredTokens(): Record<string, string> {
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];
}