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:
parent
4efcdfbf36
commit
2fe77055a9
|
|
@ -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")
|
||||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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 = []
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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<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.
|
||||
*
|
||||
|
|
@ -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<string, string> = {};
|
||||
|
||||
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<string, string> = {
|
||||
'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) {
|
||||
|
|
|
|||
|
|
@ -51,8 +51,8 @@ const RecordingsList: React.FC = () => {
|
|||
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' />
|
||||
</svg>
|
||||
<h3>No recordings yet</h3>
|
||||
<p>Recordings will appear here after you record a meeting.</p>
|
||||
<h3>No recordings available</h3>
|
||||
<p>Recordings will appear here after your meetings are processed.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
});
|
||||
|
|
@ -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];
|
||||
}
|
||||
Loading…
Reference in New Issue