diff --git a/apps/backend/.env.example b/apps/backend/.env.example index a3f4e95..7553c26 100644 --- a/apps/backend/.env.example +++ b/apps/backend/.env.example @@ -12,4 +12,9 @@ DB_USER=tandemcode DB_PASSWORD= CORS_ORIGINS=http://localhost:5173 + +# Clerk instance that signs session tokens. Find it in the Clerk dashboard +# under API Keys as the Frontend API URL. Not a secret. +CLERK_ISSUER=https://your-app.clerk.accounts.dev + RUN_MIGRATIONS_ON_STARTUP=true diff --git a/apps/backend/app/core/auth.py b/apps/backend/app/core/auth.py new file mode 100644 index 0000000..f263969 --- /dev/null +++ b/apps/backend/app/core/auth.py @@ -0,0 +1,55 @@ +"""Verification of Clerk session tokens. + +Clerk signs session tokens with RS256 and publishes the matching public keys at +its issuer's JWKS endpoint. Tokens are therefore verified here, in process, and +nothing on the request path calls out to Clerk once the key set is cached. +""" + +from __future__ import annotations + +import jwt +from anyio import to_thread +from jwt import PyJWKClient + +from app.core.config import CLERK_AUTHORIZED_PARTIES, CLERK_ISSUER + +# Clerk issues short-lived tokens and its frontend SDK refreshes them, so this +# only has to absorb clock drift between us and Clerk. +_LEEWAY_SECONDS = 10 + +# Caches the key set internally, so this is one fetch every few minutes rather +# than one per request. +_jwks = PyJWKClient(f"{CLERK_ISSUER}/.well-known/jwks.json") + + +class TokenError(Exception): + """A session token was absent, malformed, expired, or not issued to us.""" + + +async def clerk_user_id(token: str) -> str: + """Return the Clerk user id that `token` proves, or raise TokenError.""" + try: + # On a cache miss this fetches over the network with a blocking client. + signing_key = await to_thread.run_sync(_jwks.get_signing_key_from_jwt, token) + claims = jwt.decode( + token, + signing_key.key, + algorithms=["RS256"], + issuer=CLERK_ISSUER, + leeway=_LEEWAY_SECONDS, + # Clerk session tokens carry no `aud`. The `azp` check below is the + # equivalent guard, so requiring an audience here would reject + # every real token. + options={"verify_aud": False, "require": ["exp", "iat", "sub"]}, + ) + except jwt.PyJWTError as exc: + raise TokenError(str(exc)) from exc + + # `azp` is the origin Clerk minted the token for. Without this, a token + # issued to any other application on the same Clerk instance would be + # accepted here. + azp = claims.get("azp") + if azp not in CLERK_AUTHORIZED_PARTIES: + raise TokenError(f"Token was issued to {azp!r}, which is not an allowed origin") + + return claims["sub"] diff --git a/apps/backend/app/core/config.py b/apps/backend/app/core/config.py index dfb8f99..aa0b86b 100644 --- a/apps/backend/app/core/config.py +++ b/apps/backend/app/core/config.py @@ -50,3 +50,16 @@ def _required(name: str) -> str: "true", "yes", } + +# The Clerk instance that issues session tokens, e.g. +# https://your-app-42.clerk.accounts.dev. Required: a backend that cannot check +# tokens has no business starting. +CLERK_ISSUER = _required("CLERK_ISSUER").rstrip("/") + +# Clerk stamps the requesting origin into each token's `azp` claim. Our own +# origins are the right default, since the browser app is the only client. +CLERK_AUTHORIZED_PARTIES = [ + party.strip() + for party in os.getenv("CLERK_AUTHORIZED_PARTIES", ",".join(CORS_ORIGINS)).split(",") + if party.strip() +] diff --git a/apps/backend/app/dao/room_members.py b/apps/backend/app/dao/room_members.py index be81526..4d1ecb2 100644 --- a/apps/backend/app/dao/room_members.py +++ b/apps/backend/app/dao/room_members.py @@ -36,9 +36,11 @@ async def remove_member(self, room_id: str, user_id: str) -> None: async def list_members(self, room_id: str) -> list[dict]: query = """ - SELECT rm.user_id, u.name, u.email, rm.role, rm.joined_at + SELECT rm.user_id, u.name, u.email, rm.joined_at, + CASE WHEN r.created_by = rm.user_id THEN 'owner' ELSE 'participant' END AS role FROM room_members rm JOIN users u ON u.id = rm.user_id + JOIN rooms r ON r.id = rm.room_id WHERE rm.room_id = $1 ORDER BY rm.joined_at ASC """ diff --git a/apps/backend/app/dao/rooms.py b/apps/backend/app/dao/rooms.py index 5ad9e86..67c7c3e 100644 --- a/apps/backend/app/dao/rooms.py +++ b/apps/backend/app/dao/rooms.py @@ -63,6 +63,11 @@ async def list_active_by_creator(self, user_id: str) -> list[dict]: rows = await conn.fetch(query, user_id) return [_map_room(row) for row in rows] + async def deactivate(self, room_id: str) -> None: + query = "UPDATE rooms SET is_active = FALSE WHERE id = $1" + async with self.pool.acquire() as conn: + await conn.execute(query, room_id) + async def exists(self, room_id: str) -> bool: query = "SELECT EXISTS(SELECT 1 FROM rooms WHERE id = $1)" async with self.pool.acquire() as conn: diff --git a/apps/backend/app/dependencies.py b/apps/backend/app/dependencies.py index 111688b..02cc073 100644 --- a/apps/backend/app/dependencies.py +++ b/apps/backend/app/dependencies.py @@ -1,8 +1,10 @@ from __future__ import annotations import asyncpg -from fastapi import Request +from fastapi import Depends, HTTPException, Request, status +from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer +from app.core.auth import TokenError, clerk_user_id from app.dao.problems import ProblemDAO from app.dao.room_members import RoomMemberDAO from app.dao.rooms import RoomDAO @@ -14,6 +16,31 @@ from app.services.users import UserService +# auto_error=False so a missing header lands on our own 401 below rather than +# FastAPI's bare 403. +_bearer = HTTPBearer(auto_error=False) + + +async def current_user_id( + credentials: HTTPAuthorizationCredentials | None = Depends(_bearer), +) -> str: + """The Clerk user id behind this request.""" + if credentials is None: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Missing bearer token", + headers={"WWW-Authenticate": "Bearer"}, + ) + try: + return await clerk_user_id(credentials.credentials) + except TokenError as exc: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail=str(exc), + headers={"WWW-Authenticate": "Bearer"}, + ) from exc + + def get_pool(request: Request) -> asyncpg.Pool: return request.app.state.db_pool diff --git a/apps/backend/app/main.py b/apps/backend/app/main.py index 62b5986..dc09028 100644 --- a/apps/backend/app/main.py +++ b/apps/backend/app/main.py @@ -14,6 +14,7 @@ from app.routes.rooms import router as rooms_router from app.routes.submissions import router as submissions_router from app.routes.users import router as users_router +from app.websocket.auth import authenticate from app.websocket.room_chat import RoomChatManager from app.websocket.yjs import YjsRelayManager @@ -56,13 +57,17 @@ async def healthcheck() -> dict[str, str]: @app.websocket("/ws/room/{room_id}") async def room_websocket(websocket: WebSocket, room_id: str) -> None: + user_id = await authenticate(websocket, room_id) + if user_id is None: + return + manager: RoomChatManager = websocket.app.state.room_chat_manager room_member_dao = RoomMemberDAO(websocket.app.state.db_pool) - user_id = websocket.query_params.get("userId") - - await manager.join(websocket, room_id, user_id, room_member_dao) + # join() is inside the try so that an accept() which fails still unwinds + # through leave() and takes the presence row with it. try: + await manager.join(websocket, room_id, user_id, room_member_dao) while True: message = await websocket.receive_text() await manager.broadcast(room_id, message) @@ -74,6 +79,9 @@ async def room_websocket(websocket: WebSocket, room_id: str) -> None: @app.websocket("/ws/yjs/{room_id}") async def yjs_websocket(websocket: WebSocket, room_id: str) -> None: + if await authenticate(websocket, room_id) is None: + return + manager: YjsRelayManager = websocket.app.state.yjs_relay_manager await manager.connect(websocket, room_id) diff --git a/apps/backend/app/routes/problems.py b/apps/backend/app/routes/problems.py index 319cab0..d2b1c9b 100644 --- a/apps/backend/app/routes/problems.py +++ b/apps/backend/app/routes/problems.py @@ -4,11 +4,16 @@ from fastapi import APIRouter, Depends, Query -from app.dependencies import get_problem_service +from app.dependencies import current_user_id, get_problem_service from app.schemas.problems import CreateProblemRequest, ProblemResponse from app.services.problems import ProblemService -router = APIRouter(prefix="/api/problems", tags=["problems"]) +# Declared on the router so a route added later cannot quietly skip it. +router = APIRouter( + prefix="/api/problems", + tags=["problems"], + dependencies=[Depends(current_user_id)], +) @router.get("", response_model=list[ProblemResponse]) diff --git a/apps/backend/app/routes/rooms.py b/apps/backend/app/routes/rooms.py index dbc88cf..098b7f1 100644 --- a/apps/backend/app/routes/rooms.py +++ b/apps/backend/app/routes/rooms.py @@ -2,24 +2,31 @@ from fastapi import APIRouter, Depends -from app.dependencies import get_room_service +from app.dependencies import current_user_id, get_room_service from app.schemas.rooms import ( CreateRoomRequest, + LeaveRoomResponse, RoomResponse, SetProblemRequest, UserInRoomResponse, ) from app.services.rooms import RoomService -router = APIRouter(prefix="/api/rooms", tags=["rooms"]) +# Declared on the router so a route added later cannot quietly skip it. +router = APIRouter( + prefix="/api/rooms", + tags=["rooms"], + dependencies=[Depends(current_user_id)], +) @router.post("", response_model=RoomResponse) async def create_room( payload: CreateRoomRequest, + user_id: str = Depends(current_user_id), service: RoomService = Depends(get_room_service), ) -> RoomResponse: - room = await service.create_room(payload.name, payload.description, payload.createdBy) + room = await service.create_room(payload.name, payload.description, user_id) return RoomResponse.model_validate(room) @@ -43,26 +50,38 @@ async def list_rooms_by_creator( @router.get("/{room_id}/members", response_model=list[UserInRoomResponse]) async def list_room_members( room_id: str, + caller_id: str = Depends(current_user_id), service: RoomService = Depends(get_room_service), ) -> list[UserInRoomResponse]: - members = await service.list_room_members(room_id) + members = await service.list_room_members(room_id, caller_id) return [UserInRoomResponse.model_validate(member) for member in members] +@router.post("/{room_id}/leave", response_model=LeaveRoomResponse) +async def leave_room( + room_id: str, + caller_id: str = Depends(current_user_id), + service: RoomService = Depends(get_room_service), +) -> LeaveRoomResponse: + return LeaveRoomResponse.model_validate(await service.leave_room(room_id, caller_id)) + + @router.patch("/{room_id}/problem", response_model=RoomResponse) async def set_current_problem( room_id: str, payload: SetProblemRequest, + caller_id: str = Depends(current_user_id), service: RoomService = Depends(get_room_service), ) -> RoomResponse: - room = await service.set_current_problem(room_id, payload.problemId) + room = await service.set_current_problem(room_id, payload.problemId, caller_id) return RoomResponse.model_validate(room) @router.get("/{room_id}", response_model=RoomResponse) async def get_room( room_id: str, + caller_id: str = Depends(current_user_id), service: RoomService = Depends(get_room_service), ) -> RoomResponse: - room = await service.get_room(room_id) + room = await service.get_room(room_id, caller_id) return RoomResponse.model_validate(room) diff --git a/apps/backend/app/routes/submissions.py b/apps/backend/app/routes/submissions.py index ef25a9b..f351a34 100644 --- a/apps/backend/app/routes/submissions.py +++ b/apps/backend/app/routes/submissions.py @@ -4,21 +4,26 @@ from fastapi import APIRouter, Depends, Query -from app.dependencies import get_submission_service +from app.dependencies import current_user_id, get_submission_service from app.schemas.submissions import SubmissionResponse, SubmitRequest from app.services.submissions import SubmissionService -router = APIRouter(prefix="/api/submissions", tags=["submissions"]) +router = APIRouter( + prefix="/api/submissions", + tags=["submissions"], + dependencies=[Depends(current_user_id)], +) @router.post("", response_model=SubmissionResponse) async def submit_code( payload: SubmitRequest, + user_id: str = Depends(current_user_id), service: SubmissionService = Depends(get_submission_service), ) -> SubmissionResponse: submission = await service.submit( room_id=payload.roomId, - user_id=payload.userId, + user_id=user_id, problem_id=payload.problemId, language=payload.language, code=payload.code, @@ -30,16 +35,18 @@ async def submit_code( async def list_submissions( room_id: str, userId: str | None = Query(default=None), + caller_id: str = Depends(current_user_id), service: SubmissionService = Depends(get_submission_service), ) -> list[SubmissionResponse]: - submissions = await service.list_submissions(room_id, userId) + submissions = await service.list_submissions(room_id, caller_id, userId) return [SubmissionResponse.model_validate(submission) for submission in submissions] @router.get("/{submission_id}", response_model=SubmissionResponse) async def get_submission( submission_id: UUID, + caller_id: str = Depends(current_user_id), service: SubmissionService = Depends(get_submission_service), ) -> SubmissionResponse: - submission = await service.get_submission(submission_id) + submission = await service.get_submission(submission_id, caller_id) return SubmissionResponse.model_validate(submission) diff --git a/apps/backend/app/routes/users.py b/apps/backend/app/routes/users.py index a7d285b..887b286 100644 --- a/apps/backend/app/routes/users.py +++ b/apps/backend/app/routes/users.py @@ -2,19 +2,27 @@ from fastapi import APIRouter, Depends, status -from app.dependencies import get_user_service +from app.dependencies import current_user_id, get_user_service from app.schemas.users import CreateUserRequest, UserResponse from app.services.users import UserService -router = APIRouter(prefix="/api/users", tags=["users"]) +# Declared on the router so a route added later cannot quietly skip it. +router = APIRouter( + prefix="/api/users", + tags=["users"], + dependencies=[Depends(current_user_id)], +) @router.post("", response_model=UserResponse, status_code=status.HTTP_200_OK) async def create_user( payload: CreateUserRequest, + user_id: str = Depends(current_user_id), service: UserService = Depends(get_user_service), ) -> UserResponse: - user = await service.create_user(payload.id, payload.email, payload.name) + # The id is the token's, never the body's. Email and name stay client-supplied + # because Clerk's default session token does not carry them. + user = await service.create_user(user_id, payload.email, payload.name) return UserResponse.model_validate(user) diff --git a/apps/backend/app/schemas/rooms.py b/apps/backend/app/schemas/rooms.py index 672a353..e3f3156 100644 --- a/apps/backend/app/schemas/rooms.py +++ b/apps/backend/app/schemas/rooms.py @@ -9,13 +9,16 @@ class CreateRoomRequest(BaseModel): name: str description: str | None = None - createdBy: str class SetProblemRequest(BaseModel): problemId: UUID +class LeaveRoomResponse(BaseModel): + roomClosed: bool + + class RoomResponse(BaseModel): id: str name: str diff --git a/apps/backend/app/schemas/submissions.py b/apps/backend/app/schemas/submissions.py index 212a496..3770602 100644 --- a/apps/backend/app/schemas/submissions.py +++ b/apps/backend/app/schemas/submissions.py @@ -8,7 +8,6 @@ class SubmitRequest(BaseModel): roomId: str - userId: str problemId: UUID language: str code: str diff --git a/apps/backend/app/schemas/users.py b/apps/backend/app/schemas/users.py index 96efa33..6ae6063 100644 --- a/apps/backend/app/schemas/users.py +++ b/apps/backend/app/schemas/users.py @@ -6,7 +6,6 @@ class CreateUserRequest(BaseModel): - id: str email: str name: str diff --git a/apps/backend/app/services/rooms.py b/apps/backend/app/services/rooms.py index eeb10da..65c24b3 100644 --- a/apps/backend/app/services/rooms.py +++ b/apps/backend/app/services/rooms.py @@ -10,6 +10,48 @@ from app.dao.users import UserDAO +async def ensure_room_access(room_dao: RoomDAO, room_id: str, user_id: str) -> dict: + """Return the room if `user_id` may act inside it, otherwise raise. + + Every room-scoped read, write and websocket goes through here, so the rule + lives in exactly one place. + + That rule is wide on purpose: any signed-in user may enter any active room. + It is what the product already does - the room list is public and rooms are + entered by id - and `room_members` records who is connected right now, not + who is permitted. Narrowing this to invitations needs a real membership + table first, which is the open question on issue #15. `user_id` is part of + the signature so that callers must hold an authenticated caller to ask. + """ + room = await room_dao.get_by_id(room_id) + if not room: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"Room not found: {room_id}", + ) + if not room["isActive"]: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail=f"Room is closed: {room_id}", + ) + return room + + +async def ensure_room_owner(room_dao: RoomDAO, room_id: str, user_id: str) -> dict: + """Return the room if `user_id` created it, otherwise raise. + + Ownership is `rooms.created_by` and nothing else. The `role` column on + room_members is not consulted, so the two cannot disagree. + """ + room = await ensure_room_access(room_dao, room_id, user_id) + if room["createdBy"] != user_id: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Only the room owner can do that", + ) + return room + + class RoomService: def __init__( self, @@ -24,8 +66,9 @@ def __init__( self.user_dao = user_dao async def create_room(self, name: str, description: str | None, created_by: str) -> dict: - # Checked up front so an unknown creator is a 404 rather than a foreign - # key violation surfacing as a 500. + # The caller is authenticated but may not have been synced into our + # users table yet, and the room's foreign key needs that row. Checked up + # front so it is a 404 rather than a foreign key violation as a 500. if not await self.user_dao.exists(created_by): raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, @@ -35,14 +78,8 @@ async def create_room(self, name: str, description: str | None, created_by: str) room_id = str(uuid4()) return await self.room_dao.create(room_id, name, description, created_by) - async def get_room(self, room_id: str) -> dict: - room = await self.room_dao.get_by_id(room_id) - if not room: - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, - detail=f"Room not found with id: {room_id}", - ) - return room + async def get_room(self, room_id: str, caller_id: str) -> dict: + return await ensure_room_access(self.room_dao, room_id, caller_id) async def list_active_rooms(self) -> list[dict]: return await self.room_dao.list_active() @@ -50,19 +87,14 @@ async def list_active_rooms(self) -> list[dict]: async def list_rooms_by_creator(self, user_id: str) -> list[dict]: return await self.room_dao.list_active_by_creator(user_id) - async def list_room_members(self, room_id: str) -> list[dict]: + async def list_room_members(self, room_id: str, caller_id: str) -> list[dict]: + await ensure_room_access(self.room_dao, room_id, caller_id) return await self.room_member_dao.list_members(room_id) - async def set_current_problem(self, room_id: str, problem_id) -> dict: - room = await self.room_dao.get_by_id(room_id) - if not room: - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, - detail=f"Room not found: {room_id}", - ) + async def set_current_problem(self, room_id: str, problem_id, caller_id: str) -> dict: + await ensure_room_owner(self.room_dao, room_id, caller_id) - problem_exists = await self.problem_dao.exists(problem_id) - if not problem_exists: + if not await self.problem_dao.exists(problem_id): raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail=f"Problem not found: {problem_id}", @@ -75,3 +107,22 @@ async def set_current_problem(self, room_id: str, problem_id) -> dict: detail=f"Room not found: {room_id}", ) return updated_room + + async def leave_room(self, room_id: str, user_id: str) -> dict: + """Drop the caller's presence, and close the room if that empties it. + + Closing is `is_active = false`, not a DELETE. Submissions and events + reference the room and are the raw material for the session history we + want to show people later, so the row has to survive. + + Only an explicit leave can close a room. Disconnecting does not, or a + refresh or a flaky network would destroy a room out from under someone. + """ + await ensure_room_access(self.room_dao, room_id, user_id) + await self.room_member_dao.remove_member(room_id, user_id) + + if await self.room_member_dao.list_members(room_id): + return {"roomClosed": False} + + await self.room_dao.deactivate(room_id) + return {"roomClosed": True} diff --git a/apps/backend/app/services/submissions.py b/apps/backend/app/services/submissions.py index 82269f4..4e8593c 100644 --- a/apps/backend/app/services/submissions.py +++ b/apps/backend/app/services/submissions.py @@ -6,6 +6,7 @@ from app.dao.rooms import RoomDAO from app.dao.submissions import SubmissionDAO from app.dao.users import UserDAO +from app.services.rooms import ensure_room_access class SubmissionService: @@ -29,12 +30,7 @@ async def submit( language: str, code: str, ) -> dict: - room_exists = await self.room_dao.exists(room_id) - if not room_exists: - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, - detail=f"Room not found: {room_id}", - ) + await ensure_room_access(self.room_dao, room_id, user_id) problem_exists = await self.problem_dao.exists(problem_id) if not problem_exists: @@ -52,16 +48,23 @@ async def submit( return await self.submission_dao.create(room_id, user_id, problem_id, language, code) - async def get_submission(self, submission_id) -> dict: + async def get_submission(self, submission_id, caller_id: str) -> dict: submission = await self.submission_dao.get_by_id(submission_id) if not submission: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail=f"Submission not found: {submission_id}", ) + await ensure_room_access(self.room_dao, submission["roomId"], caller_id) return submission - async def list_submissions(self, room_id: str, user_id: str | None) -> list[dict]: + async def list_submissions( + self, + room_id: str, + caller_id: str, + user_id: str | None = None, + ) -> list[dict]: + await ensure_room_access(self.room_dao, room_id, caller_id) if user_id: return await self.submission_dao.list_by_room_and_user(room_id, user_id) return await self.submission_dao.list_by_room(room_id) diff --git a/apps/backend/app/websocket/auth.py b/apps/backend/app/websocket/auth.py new file mode 100644 index 0000000..f5ff39b --- /dev/null +++ b/apps/backend/app/websocket/auth.py @@ -0,0 +1,48 @@ +"""Handshake-time authentication for the websocket routes. + +A browser cannot set an Authorization header on a websocket, so the session +token arrives as a query parameter instead. It is checked before accept(), so a +caller who fails these checks never gets a socket at all - the handshake itself +fails and no frame is ever exchanged. +""" + +from __future__ import annotations + +import logging + +from fastapi import HTTPException, WebSocket + +from app.core.auth import TokenError, clerk_user_id +from app.dao.rooms import RoomDAO +from app.services.rooms import ensure_room_access + +logger = logging.getLogger(__name__) + +_POLICY_VIOLATION = 1008 + + +async def authenticate(websocket: WebSocket, room_id: str) -> str | None: + """The caller's Clerk user id, or None once the handshake has been refused. + + Callers must return immediately on None; the socket is already closed. + """ + token = websocket.query_params.get("token") + if not token: + await websocket.close(code=_POLICY_VIOLATION, reason="Missing token") + return None + + try: + user_id = await clerk_user_id(token) + except TokenError as exc: + logger.info("Rejected websocket on room %s: %s", room_id, exc) + await websocket.close(code=_POLICY_VIOLATION, reason="Invalid token") + return None + + try: + await ensure_room_access(RoomDAO(websocket.app.state.db_pool), room_id, user_id) + except HTTPException as exc: + logger.info("Refused %s access to room %s: %s", user_id, room_id, exc.detail) + await websocket.close(code=_POLICY_VIOLATION, reason=exc.detail) + return None + + return user_id diff --git a/apps/backend/app/websocket/room_chat.py b/apps/backend/app/websocket/room_chat.py index db9e0de..7183b6f 100644 --- a/apps/backend/app/websocket/room_chat.py +++ b/apps/backend/app/websocket/room_chat.py @@ -1,5 +1,6 @@ from __future__ import annotations +import json import logging from collections import defaultdict @@ -20,27 +21,32 @@ class RoomChatManager: """ def __init__(self) -> None: - self.room_sessions: dict[str, dict[WebSocket, str | None]] = defaultdict(dict) + self.room_sessions: dict[str, dict[WebSocket, str]] = defaultdict(dict) async def join( self, websocket: WebSocket, room_id: str, - user_id: str | None, + user_id: str, room_member_dao: RoomMemberDAO, ) -> None: - # Presence is written before the handshake completes, so a GET /members - # issued the moment the socket opens already sees this user. - if user_id: - try: - await room_member_dao.add_member(room_id, user_id) - except Exception: - logger.exception( - "Failed to add room member user_id=%s room_id=%s", user_id, room_id - ) + # Both writes land before the handshake completes: a GET /members issued + # the moment the socket opens already sees this user, and an accept() + # that fails still unwinds through leave() rather than stranding the + # presence row. Chat is worth having even if the presence write fails, + # hence the log-and-continue. + try: + await room_member_dao.add_member(room_id, user_id) + except Exception: + logger.exception( + "Failed to add room member user_id=%s room_id=%s", user_id, room_id + ) - await websocket.accept() self.room_sessions[room_id][websocket] = user_id + await websocket.accept() + + # After accept so the joiner is in the roster it receives. + await self._broadcast_presence(room_id, room_member_dao) async def leave( self, @@ -65,6 +71,27 @@ async def leave( "Failed to remove room member user_id=%s room_id=%s", user_id, room_id ) + await self._broadcast_presence(room_id, room_member_dao) + + async def _broadcast_presence( + self, + room_id: str, + room_member_dao: RoomMemberDAO, + ) -> None: + """Push the whole roster rather than a delta, so a client that missed an + event still converges on the next one.""" + try: + members = await room_member_dao.list_members(room_id) + except Exception: + logger.exception("Failed to read members for room %s", room_id) + return + + # default=str renders joined_at, which is a datetime. + await self.broadcast( + room_id, + json.dumps({"type": "presence", "members": members}, default=str), + ) + async def broadcast(self, room_id: str, message: str) -> None: for session in list(self.room_sessions.get(room_id, {})): try: diff --git a/apps/backend/docker-compose.yml b/apps/backend/docker-compose.yml index 3a8c59c..2ad796b 100644 --- a/apps/backend/docker-compose.yml +++ b/apps/backend/docker-compose.yml @@ -30,6 +30,7 @@ services: DB_USER: ${DB_USER:?missing DB_USER} DB_PASSWORD: ${DB_PASSWORD:?missing DB_PASSWORD} CORS_ORIGINS: ${CORS_ORIGINS:-http://localhost:5173} + CLERK_ISSUER: ${CLERK_ISSUER:?missing CLERK_ISSUER} RUN_MIGRATIONS_ON_STARTUP: ${RUN_MIGRATIONS_ON_STARTUP:-true} ports: - "8080:8080" diff --git a/apps/backend/requirements.txt b/apps/backend/requirements.txt index d248171..3e246cc 100644 --- a/apps/backend/requirements.txt +++ b/apps/backend/requirements.txt @@ -1,4 +1,6 @@ +anyio>=4.0.0,<5.0.0 asyncpg>=0.29.0,<1.0.0 fastapi>=0.115.0,<1.0.0 +pyjwt[crypto]>=2.9.0,<3.0.0 python-dotenv>=1.0.0,<2.0.0 uvicorn[standard]>=0.30.0,<1.0.0 diff --git a/apps/web/src/components/CollaborativeEditor.tsx b/apps/web/src/components/CollaborativeEditor.tsx index 359a5da..58dfe12 100644 --- a/apps/web/src/components/CollaborativeEditor.tsx +++ b/apps/web/src/components/CollaborativeEditor.tsx @@ -1,5 +1,7 @@ import { useEffect, useRef } from "react"; -import Editor, { OnMount } from "@monaco-editor/react"; +import { useAuth } from "@clerk/clerk-react"; +import Editor from "@monaco-editor/react"; +import type { OnMount } from "@monaco-editor/react"; import * as Y from "yjs"; import { WebsocketProvider } from "y-websocket"; import { MonacoBinding } from "y-monaco"; @@ -18,7 +20,18 @@ const MONACO_LANGUAGE: Record = { const WS_URL = "ws://localhost:8080/ws/yjs"; +// Clerk session tokens last about a minute. y-websocket re-reads provider.params +// every time it dials, so refreshing well inside that window is what lets a +// dropped connection come back instead of failing the handshake forever. +const TOKEN_REFRESH_MS = 30_000; + const CollaborativeEditor = ({ roomId, language, onCodeChange }: Props) => { + const { getToken } = useAuth(); + // Held in a ref so that a fresh getToken identity from Clerk cannot re-run the + // effect below and tear down the shared document mid-session. + const getTokenRef = useRef(getToken); + getTokenRef.current = getToken; + const editorRef = useRef(null); const monacoRef = useRef(null); const ydocRef = useRef(null); @@ -48,25 +61,49 @@ const CollaborativeEditor = ({ roomId, language, onCodeChange }: Props) => { }; // Initialize the Yjs doc and WebSocket provider once per roomId. - // y-websocket connects to our Spring Boot relay at /ws/yjs/{roomId}, - // which forwards binary Yjs messages to all other sessions in the room. + // y-websocket connects to our relay at /ws/yjs/{roomId}, which forwards + // binary Yjs messages to all other sessions in the room. The relay rejects + // the handshake without a valid session token, which is why the connection + // cannot be opened until getToken resolves. useEffect(() => { const ydoc = new Y.Doc(); - const provider = new WebsocketProvider(WS_URL, roomId, ydoc); ydocRef.current = ydoc; - providerRef.current = provider; - // StrictMode re-run: editor is already mounted, recreate binding now. - if (editorRef.current) { - createBinding(ydoc, provider, editorRef.current); - } + let cancelled = false; + let refresh: ReturnType | undefined; + + const connect = async () => { + const token = await getTokenRef.current(); + if (cancelled || !token) return; + + const provider = new WebsocketProvider(WS_URL, roomId, ydoc, { + params: { token }, + }); + providerRef.current = provider; + + refresh = setInterval(async () => { + const next = await getTokenRef.current(); + if (next) { + provider.params.token = next; + } + }, TOKEN_REFRESH_MS); + + // StrictMode re-run: editor is already mounted, recreate binding now. + if (editorRef.current) { + createBinding(ydoc, provider, editorRef.current); + } + }; + + connect(); return () => { + cancelled = true; + clearInterval(refresh); bindingRef.current?.destroy(); bindingRef.current = null; - provider.destroy(); - ydoc.destroy(); + providerRef.current?.destroy(); providerRef.current = null; + ydoc.destroy(); ydocRef.current = null; }; }, [roomId]); diff --git a/apps/web/src/components/RoomChatComponent.tsx b/apps/web/src/components/RoomChatComponent.tsx index 438ead9..cfee152 100644 --- a/apps/web/src/components/RoomChatComponent.tsx +++ b/apps/web/src/components/RoomChatComponent.tsx @@ -1,23 +1,12 @@ import React, { useState, useRef, useEffect } from "react"; -import useWebSocket from "../hooks/UseWebSocket"; - -interface ChatMessage { - id: string; - text: string; - username: string; - timestamp: Date; - isOwn: boolean; -} +import { useRoomSocket } from "../hooks/roomSocketContext"; interface RoomChatComponentProps { roomId?: string; } const RoomChatComponent: React.FC = ({ roomId }) => { - // Use our WebSocket hook for real-time messaging - const { isConnected, messages, sendMessage, connectionState } = useWebSocket( - roomId || "" - ); + const { isConnected, messages, sendMessage } = useRoomSocket(); const [newMessage, setNewMessage] = useState(""); const messagesEndRef = useRef(null); diff --git a/apps/web/src/components/RoomMembersPanel.tsx b/apps/web/src/components/RoomMembersPanel.tsx index 46b99e5..b334925 100644 --- a/apps/web/src/components/RoomMembersPanel.tsx +++ b/apps/web/src/components/RoomMembersPanel.tsx @@ -1,80 +1,12 @@ -import { useState, useEffect } from "react"; -import { roomApi } from "../lib/api"; +import { useRoomSocket } from "../hooks/roomSocketContext"; import { useUser } from "../hooks/useUser"; -import useWebSocket from "../hooks/UseWebSocket"; -interface RoomMember { - userId: string; - name: string; - email: string; - role: string; - joinedAt: string; -} - -const RoomMembersPanel = ({ roomId }: { roomId: string }) => { - const [members, setMembers] = useState([]); - const [loading, setLoading] = useState(true); - const { clerkUser, isSignedIn } = useUser(); - const { connectionState } = useWebSocket(roomId); // Listen to WebSocket state changes - - const fetchMembers = async () => { - try { - setLoading(true); - const apiMembers = await roomApi.getMembersInRoom(roomId); - - // Check if current user is in the list, if not add them - const currentUserId = clerkUser?.id; - const hasCurrentUser = apiMembers.some( - (member: RoomMember) => member.userId === currentUserId - ); - - if (currentUserId && isSignedIn && !hasCurrentUser) { - // Add current user to the list if they're not there yet - const currentUserMember: RoomMember = { - userId: currentUserId, - name: clerkUser.fullName || clerkUser.firstName || "You", - email: clerkUser.primaryEmailAddress?.emailAddress || "", - role: "participant", - joinedAt: new Date().toISOString(), - }; - setMembers([currentUserMember, ...apiMembers]); - } else { - setMembers(apiMembers); - } - } catch (error) { - console.error("Error fetching room members:", error); - } finally { - setLoading(false); - } - }; - - // Fetch members when component mounts or when WebSocket connection changes - useEffect(() => { - if (roomId && isSignedIn) { - fetchMembers(); - } - }, [roomId, isSignedIn]); - - // Refresh members when WebSocket connection state changes (someone joins/leaves) - useEffect(() => { - if (connectionState === "connected" && roomId && isSignedIn) { - // Small delay to ensure backend has processed the connection - const timer = setTimeout(fetchMembers, 1000); - return () => clearTimeout(timer); - } - }, [connectionState]); - - if (loading) { - return ( -
-

- Room members -

-
Loading members...
-
- ); - } +const RoomMembersPanel = () => { + const { members, connectionState } = useRoomSocket(); + const { clerkUser } = useUser(); + // Membership is presence: the server pushes the roster whenever anyone joins + // or leaves, so everyone listed is connected right now. return (

@@ -82,7 +14,9 @@ const RoomMembersPanel = ({ roomId }: { roomId: string }) => {

{members.length === 0 ? ( -
No members in this room
+
+ {connectionState === "connected" ? "No members in this room" : "Connecting..."} +
) : (
{members.map((member) => { @@ -91,7 +25,6 @@ const RoomMembersPanel = ({ roomId }: { roomId: string }) => { return (
- {/* Profile picture with Clerk image or initials */} {profileImage ? ( {
)} - {/* User info */}
{isCurrentUser ? `${member.name} (You)` : member.name || "Unknown user"}
-
{member.role}
+
+ {member.role} +
- {/* Online indicator */}
{ + const [connectionState, setConnectionState] = + useState("disconnected"); + const [messages, setMessages] = useState([]); + const [members, setMembers] = useState([]); + + const { clerkUser } = useUser(); + const { getToken } = useAuth(); + const getTokenRef = useRef(getToken); + getTokenRef.current = getToken; + + const userId = clerkUser?.id; + const wsRef = useRef(null); + + const sendMessage = (text: string) => { + const ws = wsRef.current; + if (!ws || ws.readyState !== WebSocket.OPEN || !clerkUser) return; + + ws.send( + JSON.stringify({ + text, + userId: clerkUser.id, + username: clerkUser.fullName || clerkUser.firstName || "Unknown User", + timestamp: new Date().toISOString(), + }) + ); + }; + + useEffect(() => { + if (!roomId || !userId) return; + + setConnectionState("connecting"); + setMembers([]); + + let ws: WebSocket | null = null; + let cancelled = false; + + // The browser cannot send an Authorization header on a websocket, so the + // session token goes in the query string. Fetching it makes this async, and + // the effect can be torn down while we wait. + const connect = async () => { + const token = await getTokenRef.current(); + if (cancelled || !token) return; + + ws = new WebSocket( + `ws://localhost:8080/ws/room/${roomId}?token=${encodeURIComponent( + token + )}` + ); + wsRef.current = ws; + + ws.onopen = () => setConnectionState("connected"); + + ws.onmessage = (event) => { + let payload; + try { + payload = JSON.parse(event.data); + } catch { + return; + } + + if (payload.type === "presence") { + setMembers(payload.members); + return; + } + + setMessages((prev) => [ + ...prev, + { + id: `${Date.now()}-${prev.length}`, + text: payload.text, + username: payload.userId === userId ? "You" : payload.username, + timestamp: new Date(payload.timestamp), + isOwn: payload.userId === userId, + }, + ]); + }; + + ws.onclose = () => { + setConnectionState("disconnected"); + setMembers([]); + }; + + ws.onerror = (error) => { + console.error("Room socket error", error); + setConnectionState("disconnected"); + }; + }; + + connect(); + + return () => { + cancelled = true; + ws?.close(); + wsRef.current = null; + }; + }, [roomId, userId]); + + return ( + + {children} + + ); +}; diff --git a/apps/web/src/hooks/UseWebSocket.ts b/apps/web/src/hooks/UseWebSocket.ts deleted file mode 100644 index a8a672a..0000000 --- a/apps/web/src/hooks/UseWebSocket.ts +++ /dev/null @@ -1,132 +0,0 @@ -// custom hook -import { useState, useEffect, useRef } from "react"; -import { useUser } from "../hooks/useUser"; - -interface ChatMessage { - id: string; - text: string; - username: string; - timestamp: Date; - isOwn: boolean; -} - -const useWebSocket = (roomId: string) => { - // connection management - // message sending/recieving - // connection state tracking - const [isConnected, setIsConnected] = useState(false); - const [messages, setMessages] = useState([]); - const [connectionState, setConnectionState] = useState< - "connecting" | "connected" | "disconnected" - >("disconnected"); - - const { clerkUser } = useUser(); - const userId = clerkUser?.id; - - const wsRef = useRef(null); - - const sendMessage = (text: string) => { - if ( - wsRef.current && - wsRef.current.readyState === WebSocket.OPEN && - clerkUser - ) { - // Send structured message with user info - const messageData = { - text: text, - userId: clerkUser.id, - username: clerkUser.fullName || clerkUser.firstName || "Unknown User", - timestamp: new Date().toISOString(), - }; - - wsRef.current.send(JSON.stringify(messageData)); - } else { - console.log( - "websocket not connected or user not loaded, cannot send message" - ); - } - }; - - useEffect(() => { - if (!roomId || !userId) return; // Wait for both roomId and userId - - // set connection state - setConnectionState("connecting"); - setIsConnected(false); - - // create websocket connection - const ws = new WebSocket( - `ws://localhost:8080/ws/room/${roomId}?userId=${userId}` - ); - wsRef.current = ws; - - // when the connection opens: - ws.onopen = () => { - console.log("Connected to room: ", roomId); - setIsConnected(true); - setConnectionState("connected"); - }; - - // when we receive a message: - ws.onmessage = (event) => { - console.log("received message", event.data); - - try { - // Try to parse as JSON (new format) - const messageData = JSON.parse(event.data); - - const newMessage: ChatMessage = { - id: Date.now().toString(), - text: messageData.text, - username: - messageData.userId === clerkUser?.id ? "You" : messageData.username, - timestamp: new Date(messageData.timestamp), - isOwn: messageData.userId === clerkUser?.id, - }; - - setMessages((prev) => [...prev, newMessage]); - } catch (error) { - // Fallback for old format (plain text) - for backward compatibility - console.log("Received plain text message:", event.data); - - const newMessage: ChatMessage = { - id: Date.now().toString(), - text: event.data, - username: "Other user", - timestamp: new Date(), - isOwn: false, - }; - - setMessages((prev) => [...prev, newMessage]); - } - }; - - // when the connection closes: - ws.onclose = () => { - console.log("Disconnectef from room: ", roomId); - setIsConnected(false); - setConnectionState("disconnected"); - }; - - // when theres some error lol - ws.onerror = (error) => { - console.error("WebSocket error", error); - setConnectionState("disconnected"); - setIsConnected(false); - }; - - // cleanup function - runs when the component unmounts - return () => { - ws.close(); - }; - }, [roomId, userId]); // reconnect when roomId or userId changes - - return { - isConnected, - messages, - sendMessage, - connectionState, - }; -}; - -export default useWebSocket; diff --git a/apps/web/src/hooks/roomSocketContext.ts b/apps/web/src/hooks/roomSocketContext.ts new file mode 100644 index 0000000..c12e015 --- /dev/null +++ b/apps/web/src/hooks/roomSocketContext.ts @@ -0,0 +1,37 @@ +import { createContext, useContext } from "react"; + +export interface ChatMessage { + id: string; + text: string; + username: string; + timestamp: Date; + isOwn: boolean; +} + +export interface RoomMember { + userId: string; + name: string; + email: string; + role: string; + joinedAt: string; +} + +export type ConnectionState = "connecting" | "connected" | "disconnected"; + +interface RoomSocket { + isConnected: boolean; + connectionState: ConnectionState; + messages: ChatMessage[]; + members: RoomMember[]; + sendMessage: (text: string) => void; +} + +export const RoomSocketContext = createContext(null); + +export const useRoomSocket = (): RoomSocket => { + const socket = useContext(RoomSocketContext); + if (!socket) { + throw new Error("useRoomSocket must be rendered inside a RoomSocketProvider"); + } + return socket; +}; diff --git a/apps/web/src/hooks/useUser.ts b/apps/web/src/hooks/useUser.ts index 6b980cd..e2dd2d1 100644 --- a/apps/web/src/hooks/useUser.ts +++ b/apps/web/src/hooks/useUser.ts @@ -40,7 +40,6 @@ export function useUser() { // Create new user in backend const userData = { - id: clerkUser.id, email: clerkUser.primaryEmailAddress?.emailAddress || "", name: clerkUser.fullName || clerkUser.firstName || "Unknown User", }; diff --git a/apps/web/src/lib/api.ts b/apps/web/src/lib/api.ts index 62c753e..681d9cc 100644 --- a/apps/web/src/lib/api.ts +++ b/apps/web/src/lib/api.ts @@ -1,4 +1,5 @@ import axios from "axios"; +import { getSessionToken } from "./auth"; const API_BASE_URL = "http://localhost:8080/api"; @@ -9,9 +10,19 @@ export const api = axios.create({ }, }); +// Every /api route requires a Clerk session token. Attaching it here means no +// call site has to remember to. +api.interceptors.request.use(async (config) => { + const token = await getSessionToken(); + if (token) { + config.headers.Authorization = `Bearer ${token}`; + } + return config; +}); + export const userApi = { // create user (called when Clerk user signs up) - createUser: async (userData: { id: string; email: string; name: string }) => { + createUser: async (userData: { email: string; name: string }) => { const response = await api.post("/users", userData); return response.data; }, @@ -41,11 +52,7 @@ export const roomApi = { }, // creatw a new room: - createRoom: async (roomData: { - name: string; - description: string; - createdBy: string; - }) => { + createRoom: async (roomData: { name: string; description: string }) => { const response = await api.post("/rooms", roomData); return response.data; }, @@ -61,6 +68,12 @@ export const roomApi = { return response.data; }, + // Closes the room if it leaves nobody behind. + leaveRoom: async (roomId: string) => { + const response = await api.post(`/rooms/${roomId}/leave`); + return response.data as { roomClosed: boolean }; + }, + setRoomProblem: async (roomId: string, problemId: string) => { const response = await api.patch(`/rooms/${roomId}/problem`, { problemId }); return response.data; @@ -88,7 +101,6 @@ export const problemApi = { export const submissionApi = { submit: async (data: { roomId: string; - userId: string; problemId: string; language: string; code: string; diff --git a/apps/web/src/lib/auth.ts b/apps/web/src/lib/auth.ts new file mode 100644 index 0000000..2912d7b --- /dev/null +++ b/apps/web/src/lib/auth.ts @@ -0,0 +1,24 @@ +import { useAuth } from "@clerk/clerk-react"; +import { useEffect } from "react"; + +// Clerk only hands out session tokens through a React hook, but the axios +// instance in lib/api.ts is module scope and has no hooks available. The bridge +// below publishes the getter once so non-React callers can reach it. +let tokenGetter: (() => Promise) | null = null; + +export const getSessionToken = async (): Promise => + tokenGetter ? tokenGetter() : null; + +/** Renders nothing. Must be inside ClerkProvider. */ +export const ClerkAuthBridge = () => { + const { getToken } = useAuth(); + + useEffect(() => { + tokenGetter = getToken; + return () => { + tokenGetter = null; + }; + }, [getToken]); + + return null; +}; diff --git a/apps/web/src/main.tsx b/apps/web/src/main.tsx index f5ca5ba..add7537 100644 --- a/apps/web/src/main.tsx +++ b/apps/web/src/main.tsx @@ -13,6 +13,7 @@ import JoinRoom from "./routes/rooms/JoinRoom.tsx"; import RoomView from "./routes/rooms/RoomView.tsx"; import CreateRoom from "./routes/rooms/CreateRoom.tsx"; import Problems from "./routes/Problems.tsx"; +import { ClerkAuthBridge } from "./lib/auth.ts"; // Import your Publishable Key const PUBLISHABLE_KEY = import.meta.env.VITE_CLERK_PUBLISHABLE_KEY; @@ -38,6 +39,7 @@ const router = createBrowserRouter([ createRoot(document.getElementById("root")!).render( + diff --git a/apps/web/src/routes/rooms/CreateRoom.tsx b/apps/web/src/routes/rooms/CreateRoom.tsx index 9ca201f..5fbe91e 100644 --- a/apps/web/src/routes/rooms/CreateRoom.tsx +++ b/apps/web/src/routes/rooms/CreateRoom.tsx @@ -35,7 +35,6 @@ const CreateRoom = () => { const roomData = { name: formData.name.trim(), description: formData.description.trim(), - createdBy: user.id, }; const newRoom = await roomApi.createRoom(roomData); diff --git a/apps/web/src/routes/rooms/RoomView.tsx b/apps/web/src/routes/rooms/RoomView.tsx index 77641d6..5ce07d5 100644 --- a/apps/web/src/routes/rooms/RoomView.tsx +++ b/apps/web/src/routes/rooms/RoomView.tsx @@ -1,5 +1,5 @@ import { useState, useEffect } from "react"; -import { useParams, Link } from "react-router-dom"; +import { useParams, Link, useNavigate } from "react-router-dom"; import { useUser } from "@clerk/clerk-react"; import Header from "../../components/Header"; import Footer from "../../components/Footer"; @@ -7,7 +7,8 @@ import RoomChatComponent from "../../components/RoomChatComponent"; import RoomMembersPanel from "../../components/RoomMembersPanel"; import CollaborativeEditor from "../../components/CollaborativeEditor"; import { roomApi, problemApi, submissionApi } from "../../lib/api"; -import useWebSocket from "../../hooks/UseWebSocket"; +import { RoomSocketProvider } from "../../hooks/RoomSocketProvider"; +import { useRoomSocket } from "../../hooks/roomSocketContext"; type Problem = { id: string; @@ -39,7 +40,51 @@ const STATUS_COLORS: Record = { error: "text-red-600 bg-red-50", }; -const RoomView = () => { +const ConnectionStatus = () => { + const { isConnected } = useRoomSocket(); + return ( +
+
+ + {isConnected ? "Connected" : "Connecting..."} + +
+ ); +}; + +const LeaveRoomButton = ({ roomId }: { roomId: string }) => { + const navigate = useNavigate(); + const [leaving, setLeaving] = useState(false); + + const handleLeave = async () => { + setLeaving(true); + try { + await roomApi.leaveRoom(roomId); + } catch (err) { + console.error("Failed to leave room:", err); + } finally { + // Navigating unmounts the provider, which closes the socket and lets the + // server tell everyone still here that we have gone. + navigate("/rooms"); + } + }; + + return ( + + ); +}; + +const RoomViewContent = () => { const { roomId } = useParams(); const { user } = useUser(); @@ -54,8 +99,6 @@ const RoomView = () => { const [availableProblems, setAvailableProblems] = useState([]); const [loadingProblems, setLoadingProblems] = useState(false); - const { connectionState } = useWebSocket(roomId || ""); - const isConnected = connectionState === "connected"; const isRoomCreator = roomData?.createdBy === user?.id; // Fetch room data @@ -123,7 +166,6 @@ const RoomView = () => { setIsSubmitting(true); const submission = await submissionApi.submit({ roomId, - userId: user.id, problemId: currentProblem.id, language, code, @@ -181,16 +223,7 @@ const RoomView = () => { | {roomData?.name} -
-
- - {isConnected ? "Connected" : "Connecting..."} - -
+
@@ -217,9 +250,7 @@ const RoomView = () => { > Copy room ID - +
@@ -334,7 +365,7 @@ const RoomView = () => { {/* Right Column */}
- +
@@ -397,4 +428,14 @@ const RoomView = () => { ); }; +const RoomView = () => { + const { roomId } = useParams(); + + return ( + + + + ); +}; + export default RoomView;