subhajit_work_22_07 #11

Merged
subhajit.pal merged 6 commits from subhajit_work_22_07 into main 2026-07-30 12:01:56 +00:00
7 changed files with 469 additions and 235 deletions
Showing only changes of commit 3d7d3042ed - Show all commits

View File

@ -306,6 +306,18 @@ public function updateOnboardingToggle(Request $request)
return redirect()->route('admin.dashboard')->with('success', $msg);
}
/**
* Toggle candidate system screenshot capture globally.
*/
public function updateScreenshotToggle(Request $request)
{
$enabled = $request->boolean('enable_candidate_screenshots') ? '1' : '0';
\App\Models\Setting::set('enable_candidate_screenshots', $enabled);
$msg = $enabled === '1' ? 'Candidate System Screenshot Capture ENABLED globally.' : 'Candidate System Screenshot Capture DISABLED globally by Admin.';
return redirect()->route('admin.dashboard')->with('success', $msg);
}
/**
* Toggle the admin privilege of a user.
*/

View File

@ -148,96 +148,96 @@ public function executeCode(Request $request)
$request->validate([
'language' => 'required|string',
'code' => 'required|string',
'interview_id' => 'nullable|string',
]);
$langMap = [
'python' => ['language' => 'python', 'version' => '*'],
'c' => ['language' => 'c', 'version' => '*'],
'cpp' => ['language' => 'c++', 'version' => '*'],
'java' => ['language' => 'java', 'version' => '*'],
'php' => ['language' => 'php', 'version' => '*'],
'laravel' => ['language' => 'php', 'version' => '*'],
'javascript' => ['language' => 'javascript', 'version' => '*'],
];
$langKey = strtolower($request->language);
$targetLang = $langMap[$langKey] ?? ['language' => 'python', 'version' => '*'];
$langKey = strtolower(trim($request->language));
$code = $request->code;
$output = '';
// 1. Instant Local CLI Process Execution (0.02s response for Node.js, Python, PHP)
try {
$response = Http::withHeaders([
'User-Agent' => 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
'Accept' => 'application/json',
])->timeout(10)->post('https://emkc.org/api/v2/piston/execute', [
'language' => $targetLang['language'],
'version' => $targetLang['version'],
'files' => [
[
'name' => 'solution',
'content' => $request->code,
]
],
]);
if ($response->successful()) {
$data = $response->json();
$output = $data['run']['output'] ?? ($data['compile']['output'] ?? 'Code executed successfully. No stdout output.');
return response()->json(['success' => true, 'output' => $output]);
}
// Retry with explicit fallback version if wildcard returned non-200
$fallbackVersions = [
'python' => '3.10.0',
'c' => '10.2.0',
'c++' => '10.2.0',
'java' => '15.0.2',
'php' => '8.2.3',
'javascript' => '18.15.0',
];
$retryResponse = Http::withHeaders([
'User-Agent' => 'SingleLogin-ProctoredIDE/1.0',
'Accept' => 'application/json',
])->timeout(10)->post('https://emkc.org/api/v2/piston/execute', [
'language' => $targetLang['language'],
'version' => $fallbackVersions[$targetLang['language']] ?? '3.10.0',
'files' => [
[
'name' => 'solution',
'content' => $request->code,
]
],
]);
if ($retryResponse->successful()) {
$data = $retryResponse->json();
$output = $data['run']['output'] ?? ($data['compile']['output'] ?? 'Code executed successfully.');
return response()->json(['success' => true, 'output' => $output]);
}
// Fallback for local PHP execution if PHP/Laravel language requested
if (in_array($langKey, ['php', 'laravel'])) {
if (in_array($langKey, ['javascript', 'js'])) {
$process = new \Symfony\Component\Process\Process(['node', '-e', $code]);
$process->setTimeout(4);
$process->run();
$out = $process->getOutput() ?: $process->getErrorOutput();
$output = trim($out) ?: 'JavaScript executed successfully (no stdout).';
} elseif ($langKey === 'python') {
$process = new \Symfony\Component\Process\Process(['python', '-c', $code]);
$process->setTimeout(4);
$process->run();
$out = $process->getOutput() ?: $process->getErrorOutput();
$output = trim($out) ?: 'Python executed successfully (no stdout).';
} elseif (in_array($langKey, ['php', 'laravel'])) {
ob_start();
try {
$cleanCode = preg_replace('/^\s*<\?php\s*/i', '', $request->code);
$cleanCode = preg_replace('/^\s*<\?php\s*/i', '', $code);
eval($cleanCode);
$localOutput = ob_get_clean();
return response()->json(['success' => true, 'output' => $localOutput ?: '[Local PHP Execution Succeeded]']);
$out = ob_get_clean();
$output = trim($out) ?: 'PHP code executed successfully.';
} catch (\Throwable $e) {
ob_end_clean();
return response()->json(['success' => true, 'output' => 'PHP Evaluation Error: ' . $e->getMessage()]);
$output = 'PHP Evaluation Error: ' . $e->getMessage();
}
}
return response()->json([
'success' => true,
'output' => "=== Code Output ===\n" . "Code submitted successfully for evaluation.\n(Remote execution engine status: " . $response->status() . ")\nSolution saved to proctored record."
]);
} catch (\Exception $e) {
return response()->json([
'success' => true,
'output' => "=== Code Output ===\nCode session active.\nSolution saved to proctored record."
]);
// Local execution exception; fall through to Judge0 API
}
// 2. Remote Compiler API (Judge0 CE) for C, C++, Java or non-local runtimes
if (empty($output)) {
$judge0Map = [
'python' => 71,
'c' => 50,
'cpp' => 54,
'c++' => 54,
'java' => 62,
'php' => 68,
'laravel' => 68,
'javascript' => 63,
'js' => 63,
];
if (isset($judge0Map[$langKey])) {
try {
$response = Http::timeout(5)->post('https://ce.judge0.com/submissions?wait=true', [
'source_code' => $code,
'language_id' => $judge0Map[$langKey],
]);
if ($response->successful()) {
$data = $response->json();
$stdout = $data['stdout'] ?? '';
$stderr = $data['stderr'] ?? '';
$compile = $data['compile_output'] ?? '';
$msg = $data['message'] ?? '';
$outputParts = array_filter([$stdout, $stderr, $compile, $msg]);
$output = trim(implode("\n", $outputParts));
}
} catch (\Exception $e) {}
}
}
if (empty($output)) {
$output = "=== Code Execution Status ===\nSolution executed.\nLanguage: " . strtoupper($langKey);
}
// Auto-persist candidate code + execution output directly to interview model
$interviewId = $request->input('interview_id');
if ($interviewId) {
$interview = Interview::where('id', $interviewId)->orWhere('submission_unique_id', $interviewId)->first();
if ($interview) {
$interview->update([
'submitted_code' => $code,
'submitted_language' => $langKey,
'code_output' => $output,
]);
}
}
return response()->json(['success' => true, 'output' => $output]);
}
/**
@ -340,10 +340,14 @@ public function candidateSendMessage(Request $request, $id)
public function syncCandidateCode(Request $request, $id)
{
$interview = Interview::findOrFail($id);
$interview->update([
$updateData = [
'submitted_code' => $request->input('code'),
'submitted_language' => $request->input('language', $interview->language),
]);
];
if ($request->has('output')) {
$updateData['code_output'] = $request->input('output');
}
$interview->update($updateData);
return response()->json(['success' => true]);
}
@ -570,13 +574,18 @@ public function getPollData($id)
$starterName = $interview->callStarter ? $interview->callStarter->name : null;
$globalScreenshotsEnabled = \App\Models\Setting::get('enable_candidate_screenshots', '1') === '1';
$interviewScreenshotsEnabled = (bool) ($interview->enable_tab_switch_screenshot ?? true);
$effectiveScreenshotEnabled = $globalScreenshotsEnabled && $interviewScreenshotsEnabled;
return response()->json([
'status' => $interview->status,
'call_status' => $interview->call_status ?? 'idle',
'call_started_by' => $interview->call_started_by,
'starter_name' => $starterName,
'call_started_at' => $interview->call_started_at ? $interview->call_started_at->toIso8601String() : null,
'enable_tab_switch_screenshot' => (bool) ($interview->enable_tab_switch_screenshot ?? true),
'enable_tab_switch_screenshot' => $effectiveScreenshotEnabled,
'global_screenshot_enabled' => $globalScreenshotsEnabled,
'tab_switch_screenshots' => $interview->tab_switch_screenshots ?? [],
'submitted_code' => $interview->submitted_code,
'submitted_language' => $interview->submitted_language,
@ -633,7 +642,7 @@ public function updateCallStatus(Request $request, $id)
}
/**
* Upload & Save Candidate Tab-Switch Screenshot under public/uploads/candidate_screenshots/{unique_id}/.
* Upload & Save Candidate System Screen Screenshot under public/uploads/candidate_screenshots/{unique_id}/.
*/
public function uploadTabScreenshot(Request $request, $id)
{
@ -641,15 +650,18 @@ public function uploadTabScreenshot(Request $request, $id)
->orWhere('submission_unique_id', $id)
->firstOrFail();
if (isset($interview->enable_tab_switch_screenshot) && !$interview->enable_tab_switch_screenshot) {
$globalEnabled = \App\Models\Setting::get('enable_candidate_screenshots', '1') === '1';
$interviewEnabled = !isset($interview->enable_tab_switch_screenshot) || $interview->enable_tab_switch_screenshot;
if (!$globalEnabled || !$interviewEnabled) {
return response()->json([
'success' => false,
'message' => 'Tab-switch screenshot capture is disabled by admin setting.'
'message' => 'Candidate system screenshot capture is disabled by admin setting.'
]);
}
$url = null;
$filename = 'screenshot_tab_switch_' . time() . '_' . rand(100, 999) . '.jpg';
$reason = $request->input('reason', 'tab_switch'); // 'call_start' or 'tab_switch'
$filename = 'screenshot_' . $reason . '_' . time() . '_' . rand(100, 999) . '.jpg';
$subDir = 'uploads/candidate_screenshots/' . $interview->submission_unique_id;
$destinationPath = public_path($subDir);
@ -657,6 +669,7 @@ public function uploadTabScreenshot(Request $request, $id)
mkdir($destinationPath, 0777, true);
}
$url = null;
if ($request->hasFile('screenshot')) {
$file = $request->file('screenshot');
$file->move($destinationPath, $filename);
@ -685,7 +698,7 @@ public function uploadTabScreenshot(Request $request, $id)
'filename' => $filename,
'ai_verdict' => $aiVerdict,
'timestamp' => now()->toIso8601String(),
'reason' => 'tab_switch',
'reason' => $reason,
];
$screenshots = $interview->tab_switch_screenshots ?? [];
@ -693,10 +706,13 @@ public function uploadTabScreenshot(Request $request, $id)
$aiDetails = $aiVerdict['summary'] . ' (' . round(($aiVerdict['confidence'] ?? 0.85) * 100) . '% Confidence - ' . ($aiVerdict['engine'] ?? 'AI Engine') . ')';
$logType = ($reason === 'call_start') ? 'call_start_screenshot' : (($aiVerdict['is_cheating'] && str_contains(strtolower($aiVerdict['ai_tool_detected'] ?? ''), 'parakeet')) ? 'external_ai_detected' : 'tab_switch');
$logMsg = ($reason === 'call_start') ? '📸 CALL STARTED SYSTEM SNAPSHOT: System screen snapshot captured automatically when call started.' : ('🤖 AI INSPECTOR VERDICT: ' . $aiDetails);
$logs = $interview->proctor_logs ?? [];
$logs[] = [
'type' => ($aiVerdict['is_cheating'] && str_contains(strtolower($aiVerdict['ai_tool_detected'] ?? ''), 'parakeet')) ? 'external_ai_detected' : 'tab_switch',
'details' => '🤖 AI INSPECTOR VERDICT: ' . $aiDetails,
'type' => $logType,
'details' => $logMsg,
'ai_verdict' => $aiVerdict,
'screenshot_url' => $relativePath,
'timestamp' => now()->toIso8601String(),

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

View File

@ -910,6 +910,77 @@
</div>
@endif
<!-- Global Admin Settings & Proctoring Control Panel -->
<section class="admin-card" style="margin-bottom: 35px; background: linear-gradient(135deg, rgba(15, 23, 42, 0.95) 0%, rgba(30, 41, 59, 0.95) 100%); border: 1.5px solid rgba(99, 102, 241, 0.35); box-shadow: 0 12px 36px rgba(0, 0, 0, 0.5); border-radius: 16px;">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 16px; border-bottom: 1px solid rgba(255, 255, 255, 0.1); padding-bottom: 12px;">
<div>
<h3 style="font-family: 'Outfit', sans-serif; font-size: 1.3rem; font-weight: 800; color: #ffffff; margin: 0; display: flex; align-items: center; gap: 8px;">
⚙️ Global Admin Proctoring & System Settings
</h3>
<div style="font-size: 0.75rem; color: var(--text-muted); margin-top: 4px;">Master control panel for candidate assessment rules, system screen capturing, and module toggles</div>
</div>
<span class="badge" style="background: rgba(99, 102, 241, 0.2); color: #818cf8; border: 1px solid rgba(99, 102, 241, 0.4); font-weight: 700; padding: 6px 12px; font-size: 0.75rem;">
🛡️ ADMIN CONTROL PANEL
</span>
</div>
<div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(320px, 1fr)); gap: 16px; margin-top: 16px;">
<!-- 1. Candidate System Screenshot Capture Master Toggle -->
<div style="padding: 16px; background: rgba(16, 185, 129, 0.08); border: 1.5px solid rgba(16, 185, 129, 0.3); border-radius: 12px;">
<form action="{{ route('admin.settings.screenshot-toggle') }}" method="POST">
@csrf
<div style="margin-bottom: 12px;">
<div style="font-size: 0.95rem; color: #ffffff; font-weight: 700; display: flex; align-items: center; gap: 6px;">
📸 Candidate System Screenshot Capture
</div>
<div style="font-size: 0.73rem; color: var(--text-muted); margin-top: 4px; line-height: 1.4;">
Captures candidate system desktop screen automatically on call start & tab switches. Admin can stop taking screenshots anytime across all candidates.
</div>
</div>
<div style="display: flex; justify-content: space-between; align-items: center; padding-top: 10px; border-top: 1px solid rgba(255, 255, 255, 0.08);">
<span style="font-size: 0.75rem; font-weight: 700; color: {{ \App\Models\Setting::get('enable_candidate_screenshots', '1') === '1' ? '#34d399' : '#fca5a5' }};">
{{ \App\Models\Setting::get('enable_candidate_screenshots', '1') === '1' ? '🟢 ENABLED (Active)' : '🔴 STOPPED / DISABLED' }}
</span>
<label style="display: inline-flex; align-items: center; gap: 6px; cursor: pointer;">
<input type="checkbox" name="enable_candidate_screenshots" value="1" onchange="this.form.submit()" {{ \App\Models\Setting::get('enable_candidate_screenshots', '1') === '1' ? 'checked' : '' }} style="accent-color: #10b981; width: 20px; height: 20px; cursor: pointer;">
<span style="font-size: 0.8rem; font-weight: 800; color: white; background: rgba(255,255,255,0.12); padding: 5px 12px; border-radius: 6px;">
{{ \App\Models\Setting::get('enable_candidate_screenshots', '1') === '1' ? 'Stop Screenshot Capture' : 'Enable Screenshot Capture' }}
</span>
</label>
</div>
</form>
</div>
<!-- 2. Onboarding & Offboarding Module Master Toggle -->
<div style="padding: 16px; background: rgba(99, 102, 241, 0.08); border: 1.5px solid rgba(99, 102, 241, 0.3); border-radius: 12px;">
<form action="{{ route('admin.settings.onboarding-toggle') }}" method="POST">
@csrf
<div style="margin-bottom: 12px;">
<div style="font-size: 0.95rem; color: #ffffff; font-weight: 700; display: flex; align-items: center; gap: 6px;">
🚀 Candidate Provisioning & Onboarding Module
</div>
<div style="font-size: 0.73rem; color: var(--text-muted); margin-top: 4px; line-height: 1.4;">
Master toggle to enable/disable automated candidate provisioning, onboarding checklists, and 1-click revocation.
</div>
</div>
<div style="display: flex; justify-content: space-between; align-items: center; padding-top: 10px; border-top: 1px solid rgba(255, 255, 255, 0.08);">
<span style="font-size: 0.75rem; font-weight: 700; color: {{ \App\Models\Setting::get('onboarding_enabled', '1') === '1' ? '#34d399' : '#fca5a5' }};">
{{ \App\Models\Setting::get('onboarding_enabled', '1') === '1' ? '🟢 ENABLED' : '🔴 DISABLED' }}
</span>
<label style="display: inline-flex; align-items: center; gap: 6px; cursor: pointer;">
<input type="checkbox" name="onboarding_enabled" value="1" onchange="this.form.submit()" {{ \App\Models\Setting::get('onboarding_enabled', '1') === '1' ? 'checked' : '' }} style="accent-color: #10b981; width: 20px; height: 20px; cursor: pointer;">
<span style="font-size: 0.8rem; font-weight: 800; color: white; background: rgba(255,255,255,0.12); padding: 5px 12px; border-radius: 6px;">
{{ \App\Models\Setting::get('onboarding_enabled', '1') === '1' ? 'Disable Onboarding' : 'Enable Onboarding' }}
</span>
</label>
</div>
</form>
</div>
</div>
</section>
<!-- Users Table Card -->
<section class="users-card" style="margin-bottom: 50px;">
<div class="card-header" style="margin-bottom: 20px;">
@ -1174,7 +1245,7 @@
</form>
<!-- Onboarding & Offboarding Module Master Toggle -->
<form action="{{ route('admin.settings.onboarding-toggle') }}" method="POST" style="margin-bottom: 20px; padding: 12px 14px; background: rgba(99, 102, 241, 0.08); border: 1px solid rgba(99, 102, 241, 0.25); border-radius: 12px;">
<form action="{{ route('admin.settings.onboarding-toggle') }}" method="POST" style="margin-bottom: 12px; padding: 12px 14px; background: rgba(99, 102, 241, 0.08); border: 1px solid rgba(99, 102, 241, 0.25); border-radius: 12px;">
@csrf
<div style="display: flex; justify-content: space-between; align-items: center;">
<div>
@ -1190,6 +1261,23 @@
</div>
</form>
<!-- Candidate System Screenshot Master Toggle (Admin Control) -->
<form action="{{ route('admin.settings.screenshot-toggle') }}" method="POST" style="margin-bottom: 20px; padding: 12px 14px; background: rgba(16, 185, 129, 0.08); border: 1px solid rgba(16, 185, 129, 0.25); border-radius: 12px;">
@csrf
<div style="display: flex; justify-content: space-between; align-items: center;">
<div>
<div style="font-size: 0.8rem; color: #ffffff; font-weight: 600;">📸 Candidate System Screenshot Capture</div>
<div style="font-size: 0.7rem; color: var(--text-muted);">Master Admin control to stop/start candidate system screen capturing (call start & tab switch)</div>
</div>
<label style="display: inline-flex; align-items: center; gap: 6px; cursor: pointer;">
<input type="checkbox" name="enable_candidate_screenshots" value="1" onchange="this.form.submit()" {{ \App\Models\Setting::get('enable_candidate_screenshots', '1') === '1' ? 'checked' : '' }} style="accent-color: #10b981; width: 18px; height: 18px;">
<span style="font-size: 0.75rem; font-weight: 700; color: {{ \App\Models\Setting::get('enable_candidate_screenshots', '1') === '1' ? '#34d399' : '#fca5a5' }};">
{{ \App\Models\Setting::get('enable_candidate_screenshots', '1') === '1' ? 'ENABLED' : 'STOPPED / DISABLED' }}
</span>
</label>
</div>
</form>
<!-- Registered Roles List -->
<div class="table-responsive" style="max-height: 380px; overflow-y: auto;">
<table class="users-table" style="font-size:0.8rem;">

View File

@ -487,7 +487,7 @@
}
});
// Execute Code live via Piston API
// Execute Code live via Piston/Judge0 Engine
function runCode() {
document.getElementById('run-status').innerText = 'Running...';
document.getElementById('terminal-output').innerText = 'Executing solution on code engine...';
@ -501,16 +501,23 @@ function runCode() {
'Content-Type': 'application/json',
'X-CSRF-TOKEN': '{{ csrf_token() }}'
},
body: JSON.stringify({ language: lang, code: code })
body: JSON.stringify({ language: lang, code: code, interview_id: '{{ $interview->id }}' })
})
.then(r => r.json())
.then(data => {
document.getElementById('run-status').innerText = 'Ready';
if (data.success) {
document.getElementById('terminal-output').innerText = data.output;
} else {
document.getElementById('terminal-output').innerText = 'Error: ' + data.output;
}
const outStr = data.success ? data.output : ('Error: ' + data.output);
document.getElementById('terminal-output').innerText = outStr;
// Sync live code & execution output to backend immediately
fetch(`{{ route("interview.candidate.sync-code", $interview->id) }}`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-TOKEN': '{{ csrf_token() }}'
},
body: JSON.stringify({ code: code, language: lang, output: outStr })
});
})
.catch(err => {
document.getElementById('run-status').innerText = 'Error';
@ -561,14 +568,19 @@ function getScreenShareVideoElement() {
}
// REAL CANDIDATE SYSTEM / COMPUTER SCREENSHOT CAPTURE ENGINE
function captureAndUploadTabScreenshot() {
function captureAndUploadTabScreenshot(reason = 'tab_switch') {
if (!isTabSwitchScreenshotEnabled) return;
try {
const tempCanvas = document.createElement('canvas');
let drewScreenStream = false;
// PRIORITY 1: Capture Candidate's ACTUAL Computer System Desktop Screen (Captures switched tab, external desktop apps like Parakeet AI, etc.)
const isCallStart = (reason === 'call_start');
const bannerLabel = isCallStart
? '📸 CALL STARTED • CANDIDATE SYSTEM SCREEN SNAPSHOT • ' + new Date().toLocaleString()
: '🚨 CANDIDATE SYSTEM COMPUTER DESKTOP SNAPSHOT • ' + new Date().toLocaleString();
// PRIORITY 1: Capture Candidate's ACTUAL Computer System Desktop Screen (Screen share stream)
if (typeof screenShareStream !== 'undefined' && screenShareStream && screenShareStream.getVideoTracks().length > 0) {
const screenTrack = screenShareStream.getVideoTracks()[0];
if (screenTrack.readyState === 'live') {
@ -579,24 +591,31 @@ function captureAndUploadTabScreenshot() {
vidElem.play().catch(e => {});
}
const settings = screenTrack.getSettings ? screenTrack.getSettings() : {};
const w = settings.width || vidElem.videoWidth || 1280;
const h = settings.height || vidElem.videoHeight || 720;
let rawW = vidElem.videoWidth || 1280;
let rawH = vidElem.videoHeight || 720;
if (w > 0 && h > 0) {
tempCanvas.width = w;
tempCanvas.height = h;
if (rawW > 0 && rawH > 0) {
const maxW = 1280;
let targetW = rawW;
let targetH = rawH;
if (targetW > maxW) {
targetH = Math.round(targetH * (maxW / targetW));
targetW = maxW;
}
tempCanvas.width = targetW;
tempCanvas.height = targetH;
const tempCtx = tempCanvas.getContext('2d');
tempCtx.drawImage(vidElem, 0, 0, w, h);
tempCtx.drawImage(vidElem, 0, 0, targetW, targetH);
// Add red violation banner
// Add system screenshot banner
tempCtx.fillStyle = 'rgba(15, 23, 42, 0.90)';
tempCtx.fillRect(0, tempCanvas.height - 42, tempCanvas.width, 42);
tempCtx.fillStyle = '#ef4444';
tempCtx.fillStyle = isCallStart ? '#10b981' : '#ef4444';
tempCtx.font = 'bold 14px sans-serif';
tempCtx.fillText('🚨 CANDIDATE SYSTEM COMPUTER DESKTOP SNAPSHOT (TAB / APP SWITCH DETECTED) • ' + new Date().toLocaleString(), 16, tempCanvas.height - 15);
tempCtx.fillText(bannerLabel, 16, tempCanvas.height - 15);
uploadTabScreenshotBlob(tempCanvas.toDataURL('image/jpeg', 0.85));
uploadTabScreenshotBlob(tempCanvas.toDataURL('image/jpeg', 0.75), reason);
drewScreenStream = true;
}
} catch(e) {
@ -607,32 +626,31 @@ function captureAndUploadTabScreenshot() {
if (drewScreenStream) return;
// PRIORITY 2: Full Rendered DOM Workspace Capture via html2canvas (Captures exact active tab DOM, code editor, layout)
// PRIORITY 2: Full Rendered DOM Workspace Capture via html2canvas
if (typeof html2canvas !== 'undefined') {
html2canvas(document.body, { logging: false, useCORS: true, allowTaint: true }).then(canvas => {
html2canvas(document.body, { logging: false, useCORS: true, allowTaint: true, scale: 0.85 }).then(canvas => {
const ctx = canvas.getContext('2d');
ctx.fillStyle = 'rgba(15, 23, 42, 0.90)';
ctx.fillRect(0, canvas.height - 42, canvas.width, 42);
ctx.fillStyle = '#ef4444';
ctx.fillStyle = isCallStart ? '#10b981' : '#ef4444';
ctx.font = 'bold 14px sans-serif';
ctx.fillText('🚨 CANDIDATE WORKSPACE TAB SNAPSHOT • ' + new Date().toLocaleString(), 16, canvas.height - 15);
uploadTabScreenshotBlob(canvas.toDataURL('image/jpeg', 0.85));
ctx.fillText(bannerLabel, 16, canvas.height - 15);
uploadTabScreenshotBlob(canvas.toDataURL('image/jpeg', 0.75), reason);
}).catch(err => {
fallbackCompositeSnapshot();
fallbackCompositeSnapshot(reason);
});
} else {
fallbackCompositeSnapshot();
fallbackCompositeSnapshot(reason);
}
} catch(e) {
console.log('Tab switch screenshot capture exception:', e);
console.log('Candidate system screenshot capture exception:', e);
}
}
function fallbackCompositeSnapshot() {
function fallbackCompositeSnapshot(reason = 'tab_switch') {
const tempCanvas = document.createElement('canvas');
const video = document.getElementById('webcam');
const w = (video && video.videoWidth > 0) ? video.videoWidth : 1280;
const h = (video && video.videoHeight > 0) ? video.videoHeight : 720;
const w = 1280;
const h = 720;
tempCanvas.width = w;
tempCanvas.height = h;
const tempCtx = tempCanvas.getContext('2d');
@ -643,51 +661,47 @@ function fallbackCompositeSnapshot() {
tempCtx.fillRect(0, 0, w, 50);
tempCtx.fillStyle = '#38bdf8';
tempCtx.font = 'bold 16px sans-serif';
tempCtx.fillText('💻 Candidate Assessment Workspace • ID: ' + (typeof interviewId !== 'undefined' ? interviewId : ''), 20, 32);
tempCtx.fillText('💻 Candidate System Workspace • ID: ' + (typeof interviewId !== 'undefined' ? interviewId : ''), 20, 32);
const codeDisplay = document.querySelector('.CodeMirror') || document.getElementById('code-editor');
tempCtx.fillStyle = '#020617';
tempCtx.fillRect(20, 70, w - 240, h - 130);
tempCtx.fillRect(20, 70, w - 40, h - 130);
tempCtx.fillStyle = '#94a3b8';
tempCtx.font = '13px monospace';
const codeText = codeDisplay ? (codeDisplay.innerText || codeDisplay.value || '') : '';
const codeText = codeDisplay ? (codeDisplay.innerText || codeDisplay.value || '') : (typeof editor !== 'undefined' && editor ? editor.getValue() : '');
const lines = codeText.split('\n').slice(0, 30);
lines.forEach((line, i) => {
tempCtx.fillText(line.substring(0, 100), 35, 95 + (i * 18));
tempCtx.fillText(line.substring(0, 120), 35, 95 + (i * 18));
});
if (video && video.readyState >= 2) {
try {
tempCtx.drawImage(video, w - 210, 70, 190, 140);
tempCtx.strokeStyle = '#6366f1';
tempCtx.lineWidth = 2;
tempCtx.strokeRect(w - 210, 70, 190, 140);
} catch(e) {}
}
// Candidate webcam feed is explicitly omitted to ensure screenshot is candidate system screen ONLY
const isCallStart = (reason === 'call_start');
tempCtx.fillStyle = 'rgba(15, 23, 42, 0.90)';
tempCtx.fillRect(0, tempCanvas.height - 42, tempCanvas.width, 42);
tempCtx.fillStyle = '#ef4444';
tempCtx.fillStyle = isCallStart ? '#10b981' : '#ef4444';
tempCtx.font = 'bold 14px sans-serif';
tempCtx.fillText('🚨 CANDIDATE TAB SWITCH DETECTED • SYSTEM SNAPSHOT • ' + new Date().toLocaleString(), 16, tempCanvas.height - 15);
const text = isCallStart
? '📸 CALL STARTED • CANDIDATE SYSTEM SNAPSHOT • ' + new Date().toLocaleString()
: '🚨 CANDIDATE SYSTEM SNAPSHOT • ' + new Date().toLocaleString();
tempCtx.fillText(text, 16, tempCanvas.height - 15);
uploadTabScreenshotBlob(tempCanvas.toDataURL('image/jpeg', 0.85));
uploadTabScreenshotBlob(tempCanvas.toDataURL('image/jpeg', 0.85), reason);
}
function uploadTabScreenshotBlob(imageData) {
function uploadTabScreenshotBlob(imageData, reason = 'tab_switch') {
fetch(`{{ route('interview.upload-tab-screenshot', $interview->id) }}`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-TOKEN': '{{ csrf_token() }}'
},
body: JSON.stringify({ image: imageData })
body: JSON.stringify({ image: imageData, reason: reason })
})
.then(r => r.json())
.then(data => {
console.log('Computer screen tab-switch screenshot saved:', data);
console.log('Candidate system screenshot saved:', data);
})
.catch(err => console.log('Tab screenshot fetch error:', err));
.catch(err => console.log('System screenshot fetch error:', err));
}
// 1. Tab Switch Detection (Captures Candidate's SYSTEM DESKTOP MONITOR screenshot)
@ -1046,6 +1060,7 @@ class CandRingtoneEngine {
function triggerCandidateRing(offer = null, callStartedAt = null) {
if (isCandidateCallConnected) return;
if (latestCallStatus === 'ended' || latestCallStatus === 'idle') return;
if (candidateDeclinedOrLeftCall) {
showCandidateJoinOption();
return;
@ -1187,6 +1202,14 @@ function showCandidateJoinOption() {
await handleInterviewerOffer(pendingOffer);
pendingOffer = null;
}
// Automatically capture candidate system screenshot on call start (if screenshot feature enabled)
if (!hasCapturedCallStartScreenshot && isTabSwitchScreenshotEnabled) {
hasCapturedCallStartScreenshot = true;
setTimeout(() => {
captureAndUploadTabScreenshot('call_start');
}, 1200);
}
}
// 4. WebRTC Real-Time 2-Way Interview Call Setup (Dual Signaling: BroadcastChannel + PeerJS)
@ -1197,6 +1220,8 @@ function showCandidateJoinOption() {
let isMicEnabled = true;
let isCamEnabled = true;
let activeCallInstance = null;
let hasCapturedCallStartScreenshot = false;
let lastRungCallStartedAt = null;
const globalCallChannel = new BroadcastChannel('global_call_alerts');
globalCallChannel.onmessage = (event) => {
@ -1472,6 +1497,20 @@ function candidateLogoutAction() {
function candidateLeaveCall(isEndedByHost = false) {
isCandidateCallConnected = false;
candidateDeclinedOrLeftCall = true;
candRingtone.stop();
if (candRingTimer) {
clearTimeout(candRingTimer);
candRingTimer = null;
}
const modal = document.getElementById('candidate-incoming-modal');
if (modal) {
modal.style.opacity = '0';
modal.style.pointerEvents = 'none';
}
if (activeCallInstance) {
try { activeCallInstance.close(); } catch(e) {}
activeCallInstance = null;
@ -1802,7 +1841,7 @@ function saveNotepadContent() {
},
body: JSON.stringify({ notes: notes })
});
}, 800);
}, 300);
}
// HTML5 Canvas Drawing Logic
@ -1850,6 +1889,7 @@ function draw(e) {
ctx.strokeStyle = document.getElementById('draw-color').value;
ctx.lineTo(e.offsetX, e.offsetY);
ctx.stroke();
saveCanvasDrawing();
}
function stopDraw() {
@ -1880,7 +1920,7 @@ function saveCanvasDrawing() {
},
body: JSON.stringify({ drawing: drawingData })
});
}, 1000);
}, 300);
}
setInterval(() => {
@ -1897,16 +1937,25 @@ function saveCanvasDrawing() {
if (data.call_status === 'active') {
latestCallStatus = 'active';
if (!isCandidateCallConnected) {
if (!hasCapturedCallStartScreenshot && isTabSwitchScreenshotEnabled) {
hasCapturedCallStartScreenshot = true;
setTimeout(() => {
captureAndUploadTabScreenshot('call_start');
}, 1200);
}
if (!isCandidateCallConnected && !candidateDeclinedOrLeftCall) {
const elapsed = data.call_started_at ? (Date.now() - new Date(data.call_started_at).getTime()) : 999999;
if (elapsed <= 10000) {
if (elapsed <= 12000 && lastRungCallStartedAt !== data.call_started_at) {
lastRungCallStartedAt = data.call_started_at;
triggerCandidateRing(null, data.call_started_at);
} else {
} else if (elapsed > 12000) {
showCandidateJoinOption();
}
}
} else if (data.call_status === 'ended' || data.call_status === 'idle') {
latestCallStatus = data.call_status;
hasCapturedCallStartScreenshot = false;
lastRungCallStartedAt = null;
candRingtone.stop();
if (candRingTimer) clearTimeout(candRingTimer);
isCandidateCallConnected = false;

View File

@ -215,6 +215,32 @@
{{ session('success') }}
</div>
@endif
<!-- Admin Proctoring & System Screenshot Control Banner -->
<div style="margin-bottom: 20px; padding: 14px 20px; background: linear-gradient(135deg, rgba(15, 23, 42, 0.9) 0%, rgba(30, 41, 59, 0.9) 100%); border: 1.5px solid rgba(16, 185, 129, 0.35); border-radius: 14px; display: flex; justify-content: space-between; align-items: center; box-shadow: 0 4px 20px rgba(0, 0, 0, 0.4);">
<div style="display: flex; align-items: center; gap: 12px;">
<div style="font-size: 1.5rem;">📸</div>
<div>
<div style="font-size: 0.9rem; font-weight: 700; color: white;">Global Candidate System Screenshot Control</div>
<div style="font-size: 0.75rem; color: var(--text-muted);">
Captures candidate desktop screen on call start & tab switches.
Admin can stop/start screen capturing globally anytime.
</div>
</div>
</div>
<div style="display: flex; align-items: center; gap: 12px;">
<span id="global-screenshot-status-text" style="font-size: 0.8rem; font-weight: 700; color: {{ \App\Models\Setting::get('enable_candidate_screenshots', '1') === '1' ? '#34d399' : '#fca5a5' }};">
{{ \App\Models\Setting::get('enable_candidate_screenshots', '1') === '1' ? '🟢 ENABLED (Screen Capturing Active)' : '🔴 STOPPED / DISABLED' }}
</span>
@if(Auth::user() && Auth::user()->isAdmin())
<form action="{{ route('admin.settings.screenshot-toggle') }}" method="POST" style="margin: 0;">
@csrf
<button type="submit" class="btn-nav" style="padding: 6px 14px; font-size: 0.75rem; font-weight: 700; background: {{ \App\Models\Setting::get('enable_candidate_screenshots', '1') === '1' ? 'rgba(239, 68, 68, 0.2)' : 'rgba(16, 185, 129, 0.2)' }}; color: {{ \App\Models\Setting::get('enable_candidate_screenshots', '1') === '1' ? '#fca5a5' : '#34d399' }}; border: 1px solid {{ \App\Models\Setting::get('enable_candidate_screenshots', '1') === '1' ? '#ef4444' : '#10b981' }}; border-radius: 8px; cursor: pointer;">
{{ \App\Models\Setting::get('enable_candidate_screenshots', '1') === '1' ? '🛑 Stop Screenshot Capture' : '🟢 Start Screenshot Capture' }}
</button>
</form>
@endif
</div>
</div>
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 24px;">
<div>
@ -1160,7 +1186,7 @@ function openReviewModal(id, name, uid, autoJoinCall = false) {
pollReviewData();
clearInterval(sosPollInterval);
sosPollInterval = setInterval(pollReviewData, 2000);
sosPollInterval = setInterval(pollReviewData, 1200);
if (autoJoinCall) {
setTimeout(() => {
@ -1781,38 +1807,112 @@ function startInterviewerCall(isRejoin = false) {
});
}
function leaveInterviewerCall() {
function leaveInterviewerCall(isExplicitEnd = true) {
if (currentInterviewId && isExplicitEnd) {
const endingId = currentInterviewId;
endedCallIds.add(endingId);
dismissedCallIds.add(endingId);
if (typeof globalCallChannel !== 'undefined' && globalCallChannel) {
try {
globalCallChannel.postMessage({
type: 'call_ended',
interview_id: endingId
});
} catch(e) {}
}
if (typeof callSigChannel !== 'undefined' && callSigChannel) {
try {
callSigChannel.postMessage({ type: 'end_call_all', interview_id: endingId });
} catch(e) {}
}
if (typeof interviewerSigChannel !== 'undefined' && interviewerSigChannel) {
try {
interviewerSigChannel.postMessage({ type: 'end_call_all', interview_id: endingId });
} catch(e) {}
}
fetch(`{{ route('interview.call-status', ':id') }}`.replace(':id', endingId), {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-TOKEN': '{{ csrf_token() }}'
},
body: JSON.stringify({ call_status: 'ended' })
}).catch(e => {});
}
if (interviewerLocalStream) {
interviewerLocalStream.getTracks().forEach(t => t.stop());
try {
interviewerLocalStream.getTracks().forEach(t => t.stop());
} catch(e) {}
interviewerLocalStream = null;
}
if (activeCall) {
activeCall.close();
try { activeCall.close(); } catch(e) {}
activeCall = null;
}
document.getElementById('cand-video-placeholder').style.display = 'flex';
document.getElementById('self-video-placeholder').style.display = 'flex';
const candPlace = document.getElementById('cand-video-placeholder');
const screenPlace = document.getElementById('screen-video-placeholder');
const selfPlace = document.getElementById('self-video-placeholder');
if (candPlace) candPlace.style.display = 'flex';
if (screenPlace) screenPlace.style.display = 'flex';
if (selfPlace) selfPlace.style.display = 'flex';
document.getElementById('btn-start-call').innerText = '📞 Join Call';
document.getElementById('btn-start-call').style.display = 'inline-block';
document.getElementById('btn-start-call').style.background = '#6366f1';
document.getElementById('btn-leave-call').style.display = 'none';
document.getElementById('btn-end-all-call').style.display = 'none';
const candVid = document.getElementById('interviewer-cand-video');
if (candVid) candVid.srcObject = null;
document.getElementById('btn-toggle-interviewer-mic').style.display = 'none';
document.getElementById('btn-toggle-interviewer-cam').style.display = 'none';
const selfVid = document.getElementById('interviewer-self-video');
if (selfVid) selfVid.srcObject = null;
document.getElementById('interviewer-call-status').innerText = 'LEFT CALL';
document.getElementById('interviewer-call-status').style.background = 'rgba(234,179,8,0.2)';
document.getElementById('interviewer-call-status').style.color = '#fde047';
const startBtn = document.getElementById('btn-start-call');
if (startBtn) {
startBtn.innerText = '📞 Call Candidate';
startBtn.style.display = 'inline-block';
startBtn.style.background = 'linear-gradient(135deg, #10b981 0%, #059669 100%)';
startBtn.onclick = startInterviewerCall;
}
const leaveBtn = document.getElementById('btn-leave-call');
if (leaveBtn) leaveBtn.style.display = 'none';
const endAllBtn = document.getElementById('btn-end-all-call');
if (endAllBtn) endAllBtn.style.display = 'none';
const micBtn = document.getElementById('btn-toggle-interviewer-mic');
if (micBtn) micBtn.style.display = 'none';
const camBtn = document.getElementById('btn-toggle-interviewer-cam');
if (camBtn) camBtn.style.display = 'none';
const statusBadge = document.getElementById('interviewer-call-status');
if (statusBadge) {
statusBadge.innerText = 'CALL ENDED';
statusBadge.style.background = 'rgba(239,68,68,0.2)';
statusBadge.style.color = '#fca5a5';
}
}
function endCallForAll() {
const executeEndCall = () => {
if (typeof callRingtone !== 'undefined' && callRingtone) {
try { callRingtone.stop(); } catch(e) {}
}
const modal = document.getElementById('incoming-call-modal');
if (modal) modal.classList.remove('active');
activeIncomingCall = null;
iAmTheCaller = false;
leaveInterviewerCall(true);
};
if (typeof Swal !== 'undefined') {
Swal.fire({
title: '🛑 End Call for All Participants?',
text: 'This will terminate the 2-way call session for the candidate and all panelists.',
text: 'This will terminate the 2-way call session for candidate and all panelists.',
icon: 'warning',
showCancelButton: true,
confirmButtonColor: '#ef4444',
@ -1820,36 +1920,13 @@ function endCallForAll() {
confirmButtonText: 'Yes, End Call for All'
}).then((result) => {
if (result.isConfirmed) {
if (currentInterviewId) {
const endingId = currentInterviewId;
endedCallIds.add(endingId);
dismissedCallIds.add(endingId);
callRingtone.stop();
document.getElementById('incoming-call-modal').classList.remove('active');
activeIncomingCall = null;
globalCallChannel.postMessage({
type: 'call_ended',
interview_id: endingId
});
fetch(`{{ route('interview.call-status', ':id') }}`.replace(':id', endingId), {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-TOKEN': '{{ csrf_token() }}'
},
body: JSON.stringify({ call_status: 'ended' })
});
}
iAmTheCaller = false;
leaveInterviewerCall();
document.getElementById('interviewer-call-status').innerText = 'CALL ENDED FOR ALL';
document.getElementById('interviewer-call-status').style.background = 'rgba(239,68,68,0.2)';
document.getElementById('interviewer-call-status').style.color = '#fca5a5';
executeEndCall();
}
});
} else {
if (confirm('End Call for All Participants?')) {
executeEndCall();
}
}
}
@ -1867,42 +1944,53 @@ function startCallRecording() {
let candidateStream = (candVid && candVid.srcObject) ? candVid.srcObject : null;
let streamToRecord = new MediaStream();
// Add Candidate Video and Audio tracks
// Add live Candidate Video and Audio tracks
if (candidateStream) {
candidateStream.getTracks().forEach(track => streamToRecord.addTrack(track));
candidateStream.getTracks().forEach(track => {
if (track.readyState === 'live') {
try { streamToRecord.addTrack(track); } catch(e) {}
}
});
}
// Add Interviewer Local Microphone Audio track for 2-way call recording
if (interviewerLocalStream) {
interviewerLocalStream.getAudioTracks().forEach(track => {
// Only add if not already in streamToRecord
if (!streamToRecord.getAudioTracks().includes(track)) {
streamToRecord.addTrack(track);
if (track.readyState === 'live' && !streamToRecord.getAudioTracks().includes(track)) {
try { streamToRecord.addTrack(track); } catch(e) {}
}
});
if (!candidateStream) {
interviewerLocalStream.getVideoTracks().forEach(track => streamToRecord.addTrack(track));
if (streamToRecord.getVideoTracks().length === 0) {
interviewerLocalStream.getVideoTracks().forEach(track => {
if (track.readyState === 'live') {
try { streamToRecord.addTrack(track); } catch(e) {}
}
});
}
}
if (streamToRecord.getTracks().length === 0) {
alert('No active video/audio stream available to record. Please click "Start / Join Call" first.');
const activeTracks = streamToRecord.getTracks().filter(t => t.readyState === 'live');
if (activeTracks.length === 0) {
alert('No active video/audio stream available to record. Please start or join the call first.');
return;
}
adminRecordedChunks = [];
try {
let mime = 'video/webm';
if (MediaRecorder.isTypeSupported('video/mp4')) {
mime = 'video/mp4';
} else if (MediaRecorder.isTypeSupported('video/webm;codecs=vp9')) {
mime = 'video/webm;codecs=vp9';
} else if (MediaRecorder.isTypeSupported('video/webm')) {
mime = 'video/webm';
let options = {};
if (typeof MediaRecorder.isTypeSupported === 'function') {
if (MediaRecorder.isTypeSupported('video/webm;codecs=vp8,opus')) {
options = { mimeType: 'video/webm;codecs=vp8,opus' };
} else if (MediaRecorder.isTypeSupported('video/webm')) {
options = { mimeType: 'video/webm' };
}
}
const options = { mimeType: mime };
adminMediaRecorder = new MediaRecorder(streamToRecord, options);
try {
adminMediaRecorder = new MediaRecorder(streamToRecord, options);
} catch(errOpt) {
adminMediaRecorder = new MediaRecorder(streamToRecord);
}
adminMediaRecorder.ondataavailable = function(e) {
if (e.data && e.data.size > 0) {
@ -1912,7 +2000,7 @@ function startCallRecording() {
adminMediaRecorder.onstop = function() {
if (adminRecordedChunks.length === 0) return;
const recMime = adminMediaRecorder.mimeType || mime;
const recMime = adminMediaRecorder.mimeType || 'video/webm';
const isMp4 = recMime.includes('mp4');
const ext = isMp4 ? 'mp4' : 'webm';
const blobType = isMp4 ? 'video/mp4' : 'video/webm';
@ -1938,6 +2026,7 @@ function startCallRecording() {
});
}
} catch(e) {
console.error('MediaRecorder error:', e);
alert('Recording failed to initialize: ' + e.message);
}
}
@ -2026,35 +2115,14 @@ function toggleInterviewerCam() {
}
function endInterviewerCall() {
if (interviewerLocalStream) {
interviewerLocalStream.getTracks().forEach(t => t.stop());
interviewerLocalStream = null;
}
if (activeCall) {
activeCall.close();
activeCall = null;
}
document.getElementById('cand-video-placeholder').style.display = 'flex';
document.getElementById('screen-video-placeholder').style.display = 'flex';
document.getElementById('self-video-placeholder').style.display = 'flex';
document.getElementById('btn-start-call').innerText = '📞 Start 2-Way Call';
document.getElementById('btn-start-call').style.background = '#10b981';
document.getElementById('btn-start-call').onclick = startInterviewerCall;
document.getElementById('btn-toggle-interviewer-mic').style.display = 'none';
document.getElementById('btn-toggle-interviewer-cam').style.display = 'none';
document.getElementById('interviewer-call-status').innerText = 'DISCONNECTED';
document.getElementById('interviewer-call-status').style.background = 'rgba(99,102,241,0.2)';
document.getElementById('interviewer-call-status').style.color = '#818cf8';
leaveInterviewerCall(true);
}
function closeReviewModal() {
clearInterval(sosPollInterval);
endInterviewerCall();
document.getElementById('review-modal').classList.remove('active');
leaveInterviewerCall(true);
const modal = document.getElementById('review-modal');
if (modal) modal.classList.remove('active');
}
function sendSilentWarning() {

View File

@ -97,6 +97,7 @@
Route::delete('/roles/{id}', [AdminController::class, 'destroyRole'])->name('admin.roles.destroy');
Route::post('/settings/default-role', [AdminController::class, 'updateDefaultRole'])->name('admin.settings.default-role');
Route::post('/settings/onboarding-toggle', [AdminController::class, 'updateOnboardingToggle'])->name('admin.settings.onboarding-toggle');
Route::post('/settings/screenshot-toggle', [AdminController::class, 'updateScreenshotToggle'])->name('admin.settings.screenshot-toggle');
// User Roles & Overrides
Route::post('/users/{id}/roles', [AdminController::class, 'updateUserRoles'])->name('admin.users.roles.update');