All files / src/utils/components LockdownGuard.jsx

100% Statements 11/11
81.81% Branches 9/11
100% Functions 5/5
100% Lines 9/9

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125                  5x                           5x                                                   5x 24x                 24x 24x   24x                                                         4x                                         8x                        
import React from "react";
import { Button, Chip, Dialog, DialogActions, DialogContent, DialogTitle, Stack, Tooltip, Typography } from "@mui/material";
import WarningAmberIcon from "@mui/icons-material/WarningAmber";
import useAssessmentLockdown from "@/utils/hooks/useAssessmentLockdown";
 
/** Human-readable label per violation `type` — see useAssessmentLockdown.js for the
 * full set the client reports, and the backend's KNOWN_LOCKDOWN_VIOLATION_TYPES this
 * must stay in sync with. "unknown" is the backend's own catch-all bucket, not a
 * client-emitted type — kept here defensively in case it ever comes back that way. */
const VIOLATION_TYPE_LABELS = {
    fullscreen_exit: "Fullscreen exits",
    tab_hidden: "Tab switches",
    window_blur: "Window focus lost",
    clipboard_blocked: "Copy/paste attempts",
    blocked_shortcut: "Blocked shortcuts",
    unknown: "Other",
};
 
/** Build-time kill switch for the whole feature — see docs/implementation/browser-lockdown.md.
 * Defaults to off (lockdown stays on); set VITE_DISABLE_BROWSER_LOCKDOWN=true in an env file
 * to turn the feature off everywhere the guard is mounted (e.g. while debugging the
 * take-assignment page without fighting fullscreen/copy-paste blocking). Distinct from the
 * per-instance `enabled` prop, which callers don't currently use. */
const BROWSER_LOCKDOWN_FEATURE_ENABLED = import.meta.env.VITE_DISABLE_BROWSER_LOCKDOWN !== "true";
 
/**
 * Client-side exam-integrity deterrents for a quiz/assessment-taking page:
 * fullscreen enforcement, tab-switch/blur detection, copy-paste/
 * dev-tools-shortcut blocking, and a beforeunload warning. Right-click is not
 * blocked. See docs/implementation/browser-lockdown.md — this is "soft" enforcement
 * only, not a true lockdown browser.
 *
 * Shared by the student's real take-assignment page and the teacher's
 * "View As Student" preview, so both show identical deterrents.
 *
 * @param {boolean} [enabled=true] - ANDed with VITE_DISABLE_BROWSER_LOCKDOWN (see
 * BROWSER_LOCKDOWN_FEATURE_ENABLED above) — `enabled={false}` here or the env var set to
 * "true" either one disables the guard.
 * @param {() => void} [onCancel] - Shows a "Cancel" action on the fullscreen
 * prompt when provided, letting the caller route the student away instead of
 * forcing them into fullscreen (e.g. reusing the page's own "Confirm Back" flow).
 * @param {string} [assignmentId] - Real attempt id; enables backend violation
 * reporting and the auto-submit threshold (see useAssessmentLockdown.js). Omit
 * for a preview with no real attempt behind it, e.g. "View As Student".
 * @param {(payload: {violationCount: number, maxViolations: number}) => void} [onThresholdExceeded]
 * - Fires once when the backend reports the violation threshold exceeded; the
 * caller is responsible for actually submitting the assignment and locking the
 * student out (this component has no access to the answers payload).
 */
const LockdownGuard = ({ enabled = true, onCancel, assignmentId, onThresholdExceeded }) => {
    const { needsFullscreenPrompt, requestFullscreen, violationBreakdown } = useAssessmentLockdown({
        enabled: enabled && BROWSER_LOCKDOWN_FEATURE_ENABLED,
        assignmentId,
        onThresholdExceeded,
    });
 
    // Backend-confirmed only (see useAssessmentLockdown.js) — empty, and thus
    // invisible, for the teacher's "View As Student" preview, which never reports
    // violations at all since it has no real assignmentId.
    const violationEntries = Object.entries(violationBreakdown).filter(([, count]) => count > 0);
    const totalViolations = violationEntries.reduce((sum, [, count]) => sum + count, 0);
 
    return (
        <>
            <Dialog open={needsFullscreenPrompt} disableEscapeKeyDown maxWidth="sm" fullWidth>
                <DialogTitle>Fullscreen Required</DialogTitle>
                <DialogContent dividers>
                    <Typography>
                        This assessment must be taken in fullscreen mode. Click below to continue — leaving
                        fullscreen, switching tabs, and copy/paste are disabled while the assessment is in progress.
                    </Typography>
                </DialogContent>
                <DialogActions sx={{ display: "flex", justifyContent: "flex-end", gap: 1 }}>
                    {onCancel && (
                        <Button variant="outlined" color="inherit" onClick={onCancel}>
                            Cancel
                        </Button>
                    )}
                    <Button variant="contained" color="primary" sx={{ width: "220px" }} onClick={requestFullscreen}>
                        Begin in Fullscreen
                    </Button>
                </DialogActions>
            </Dialog>
 
            {totalViolations > 0 && (
                <Tooltip
                    arrow
                    placement="left"
                    title={
                        <Stack spacing={0.5} sx={{ py: 0.5, minWidth: 160 }}>
                            {violationEntries.map(([type, count]) => (
                                <Typography
                                    key={type}
                                    variant="caption"
                                    sx={{ display: "flex", justifyContent: "space-between", gap: 2 }}
                                >
                                    <span>{VIOLATION_TYPE_LABELS[type] || type}</span>
                                    <strong>{count}</strong>
                                </Typography>
                            ))}
                        </Stack>
                    }
                >
                    <Chip
                        icon={<WarningAmberIcon fontSize="small" />}
                        color="warning"
                        variant="filled"
                        label={`${totalViolations} violation${totalViolations === 1 ? "" : "s"}`}
                        sx={{
                            position: "fixed",
                            bottom: 16,
                            right: 16,
                            zIndex: (theme) => theme.zIndex.snackbar,
                            cursor: "default",
                            boxShadow: 3,
                        }}
                    />
                </Tooltip>
            )}
        </>
    );
};
 
export default LockdownGuard;