diff --git a/src/components/ui/MeetingModal.tsx b/src/components/ui/MeetingModal.tsx index e790bc3..32caab2 100644 --- a/src/components/ui/MeetingModal.tsx +++ b/src/components/ui/MeetingModal.tsx @@ -28,8 +28,12 @@ function MeetingModal({ const handleStart = async () => { if (isJoinMeeting) { - const meetingId = meetingUrl.split("/").pop(); - if (meetingId) joinMeeting(meetingId); + // Passed through even when empty — a URL ending in "/" yields "", and + // the guard that used to sit here turned that into a button that + // silently did nothing. joinMeeting reports it instead. + const meetingId = meetingUrl.split("/").pop() ?? ""; + + joinMeeting(meetingId); setMeetingUrl(""); onClose(); return; diff --git a/src/components/ui/MeetingRoom.tsx b/src/components/ui/MeetingRoom.tsx index 6d9a4ab..6a5ff60 100644 --- a/src/components/ui/MeetingRoom.tsx +++ b/src/components/ui/MeetingRoom.tsx @@ -58,6 +58,7 @@ import { useProctoring } from "@/hooks/useProctoring"; import { useFullscreenGuard } from "@/hooks/useFullscreenGuard"; import FullscreenGuardOverlay from "./FullscreenGuardOverlay"; import { resolveEnforcement } from "@/lib/proctoring/enforcement"; +import { getHostControlsAvailability } from "@/lib/hostControls"; import IntegrityReport from "@/components/interviews/IntegrityReport"; import { cn, getInterviewEndTimeMs } from "@/lib/utils"; import { getDisplayErrorMessage, logError } from "@/lib/errors"; @@ -203,6 +204,26 @@ function MeetingRoom({ isRecruiter || interview.interviewerIds.includes(currentUser.clerkId) || interview.interviewerIds.includes(localParticipant?.userId ?? ""))); + /** + * The people removal can target: everyone but the local participant, and only + * those Stream has given a userId. + */ + const removableParticipants = useMemo( + () => participants.filter((p) => !p.isLocalParticipant && p.userId), + [participants], + ); + + /** + * One answer to "what may this host do", shared by the menu and the handlers. + * They used to decide separately and disagree — see src/lib/hostControls.ts. + */ + const hostControls = getHostControlsAvailability({ + isHost, + canMuteUsers, + canBlockUsers, + removableParticipantCount: removableParticipants.length, + }); + /** * Integrity monitoring runs for the candidate and nobody else. * @@ -443,21 +464,43 @@ function MeetingRoom({ } /* ── host actions ── */ + + /** + * Best-effort audit trail, matching how participant join/leave is logged + * above: a failed write must never turn a host action that succeeded into an + * error toast, and an ad-hoc call with no interviews row simply has nothing + * to write against. + */ + const logHostAction = (event: { + type: string; + detail: string; + metadata?: string; + }) => { + if (!interview) return; + + void logSessionEvent({ + interviewId: interview._id, + streamCallId: interview.streamCallId, + ...event, + }).catch(() => undefined); + }; + const handleMuteAll = async () => { - if (!call || !interview) return; + // Only `call` is required. An instant meeting has no interviews row, and + // refusing to mute because there is nowhere to file the audit entry is what + // made this button inert — see src/lib/hostControls.ts. + if (!call) return; setHostActionLoading("mute"); try { await call.muteAllUsers("audio"); - await logSessionEvent({ - interviewId: interview._id, - streamCallId: interview.streamCallId, + toast.success("Muted all participants."); + logHostAction({ type: "host.muted_all", detail: "Muted all participants", }); - toast.success("Muted all participants."); } catch (error) { logError("MeetingRoom.handleMuteAll", error, { - interviewId: interview._id, + interviewId: interview?._id, }); toast.error(getDisplayErrorMessage(error, "Unable to mute everyone.")); } finally { @@ -466,21 +509,19 @@ function MeetingRoom({ }; const handleRemoveParticipant = async (userId: string) => { - if (!call || !interview) return; + if (!call) return; setHostActionLoading("remove"); try { await call.blockUser(userId); - await logSessionEvent({ - interviewId: interview._id, - streamCallId: interview.streamCallId, + toast.success("Participant removed from the session."); + logHostAction({ type: "host.removed_participant", detail: userId, metadata: JSON.stringify({ participantId: userId }), }); - toast.success("Participant removed from the session."); } catch (error) { logError("MeetingRoom.handleRemoveParticipant", error, { - interviewId: interview._id, + interviewId: interview?._id, participantId: userId, }); toast.error( @@ -712,7 +753,7 @@ function MeetingRoom({ {/* Host controls. Requires both the app-level role and the Stream capability that authorises the request — the role alone rendered a menu whose every action Stream rejected. */} - {isHost && (canMuteUsers || canBlockUsers) && ( + {hostControls.anyAvailable && (