Compare commits
3 Commits
a084f16866
...
2c0f2a4a41
| Author | SHA1 | Date | |
|---|---|---|---|
| 2c0f2a4a41 | |||
| 686cebe577 | |||
| 57d4c0ff51 |
@ -69,3 +69,7 @@ MICROSOFT_CLIENT_ID=
|
|||||||
MICROSOFT_CLIENT_SECRET=
|
MICROSOFT_CLIENT_SECRET=
|
||||||
MICROSOFT_REDIRECT_URI="${APP_URL}/auth/microsoft/callback"
|
MICROSOFT_REDIRECT_URI="${APP_URL}/auth/microsoft/callback"
|
||||||
MICROSOFT_TENANT_ID=common
|
MICROSOFT_TENANT_ID=common
|
||||||
|
|
||||||
|
# Metered TURN Server Credentials
|
||||||
|
METERED_URL=
|
||||||
|
METERED_KEY=
|
||||||
|
|||||||
71
app/Http/Controllers/IceServerController.php
Normal file
71
app/Http/Controllers/IceServerController.php
Normal file
@ -0,0 +1,71 @@
|
|||||||
|
<?php
|
||||||
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
use Illuminate\Support\Facades\Auth;
|
||||||
|
use Illuminate\Support\Facades\Http;
|
||||||
|
use Illuminate\Support\Facades\Log;
|
||||||
|
|
||||||
|
class IceServerController extends Controller
|
||||||
|
{
|
||||||
|
public function __invoke(): JsonResponse
|
||||||
|
{
|
||||||
|
if (!Auth::check() && !session()->has('candidate_interview_id')) {
|
||||||
|
return response()->json(['error' => 'Unauthenticated access.'], 401);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$baseUrl = config(
|
||||||
|
"services.metered.url",
|
||||||
|
env('METERD_URL', 'https://single-login-system.metered.live/api/v1/turn/credentials')
|
||||||
|
);
|
||||||
|
$apiKey = config(
|
||||||
|
"services.metered.key",
|
||||||
|
env('METERED_KEY', 'ffc9146ac5c2f75d83d7c679d8553a21c8fa')
|
||||||
|
);
|
||||||
|
|
||||||
|
$url = $baseUrl;
|
||||||
|
if ($apiKey && !str_contains($url, "apiKey=")) {
|
||||||
|
$separator = str_contains($url, "?") ? "&" : "?";
|
||||||
|
$url .= $separator . "apiKey=" . urlencode($apiKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
$response = Http::timeout(5)->get($url);
|
||||||
|
|
||||||
|
if ($response->successful()) {
|
||||||
|
return response()->json($response->json());
|
||||||
|
}
|
||||||
|
|
||||||
|
Log::error(
|
||||||
|
"Metered API error response",
|
||||||
|
[
|
||||||
|
"status" => $response->status(),
|
||||||
|
"body" => $response->body(),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
return response()->json(
|
||||||
|
[
|
||||||
|
"error" => "Failed to fetch ICE servers",
|
||||||
|
"status" => $response->status(),
|
||||||
|
],
|
||||||
|
$response->status() >= 400 && $response->status() < 600
|
||||||
|
? $response->status()
|
||||||
|
: 500,
|
||||||
|
);
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
Log::error(
|
||||||
|
"ICE servers fetch exception: " . $e->getMessage(),
|
||||||
|
);
|
||||||
|
|
||||||
|
return response()->json(
|
||||||
|
[
|
||||||
|
"error" => "An error occurred while fetching ICE servers.",
|
||||||
|
"message" => $e->getMessage(),
|
||||||
|
],
|
||||||
|
500,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@ -42,4 +42,9 @@
|
|||||||
'tenant' => env('MICROSOFT_TENANT_ID', env('MICROSOFT_GRAPH_TENANT_ID', 'common')),
|
'tenant' => env('MICROSOFT_TENANT_ID', env('MICROSOFT_GRAPH_TENANT_ID', 'common')),
|
||||||
],
|
],
|
||||||
|
|
||||||
|
'metered' => [
|
||||||
|
'url' => env('METERED_URL'),
|
||||||
|
'key' => env('METERED_KEY'),
|
||||||
|
],
|
||||||
|
|
||||||
];
|
];
|
||||||
|
|||||||
@ -1 +1,10 @@
|
|||||||
//
|
import { getMeteredIceServers, initMeteredIceServers, globalIceServers } from './metered.js';
|
||||||
|
|
||||||
|
window.globalIceServers = globalIceServers;
|
||||||
|
window.getMeteredIceServers = getMeteredIceServers;
|
||||||
|
window.initMeteredIceServers = initMeteredIceServers;
|
||||||
|
|
||||||
|
// Pre-fetch TURN credentials immediately on app boot
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
window.getMeteredIceServers();
|
||||||
|
}
|
||||||
|
|||||||
54
resources/js/metered.js
Normal file
54
resources/js/metered.js
Normal file
@ -0,0 +1,54 @@
|
|||||||
|
/**
|
||||||
|
* Metered TURN Server Integration
|
||||||
|
*/
|
||||||
|
|
||||||
|
let globalIceServers = [
|
||||||
|
{ urls: 'stun:stun.l.google.com:19302' }
|
||||||
|
];
|
||||||
|
let meteredIceFetchPromise = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch ICE servers from /ice-servers endpoint and merge with default STUN server.
|
||||||
|
* Handles network errors gracefully with fallback.
|
||||||
|
*
|
||||||
|
* @returns {Promise<Array>} Resolves with the array of ICE server objects.
|
||||||
|
*/
|
||||||
|
export function getMeteredIceServers() {
|
||||||
|
if (meteredIceFetchPromise) return meteredIceFetchPromise;
|
||||||
|
|
||||||
|
meteredIceFetchPromise = fetch('/ice-servers', {
|
||||||
|
headers: {
|
||||||
|
'Accept': 'application/json',
|
||||||
|
'X-Requested-With': 'XMLHttpRequest'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.then(response => {
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Failed to fetch ICE servers: HTTP status ${response.status}`);
|
||||||
|
}
|
||||||
|
return response.json();
|
||||||
|
})
|
||||||
|
.then(meteredIce => {
|
||||||
|
if (Array.isArray(meteredIce) && meteredIce.length > 0) {
|
||||||
|
globalIceServers = [
|
||||||
|
{ urls: 'stun:stun.l.google.com:19302' },
|
||||||
|
...meteredIce.slice(0, 3)
|
||||||
|
];
|
||||||
|
}
|
||||||
|
window.globalIceServers = globalIceServers;
|
||||||
|
return globalIceServers;
|
||||||
|
})
|
||||||
|
.catch(e => {
|
||||||
|
console.warn('Metered TURN fetch warning:', e);
|
||||||
|
window.globalIceServers = globalIceServers;
|
||||||
|
return globalIceServers;
|
||||||
|
});
|
||||||
|
|
||||||
|
return meteredIceFetchPromise;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function initMeteredIceServers() {
|
||||||
|
return getMeteredIceServers();
|
||||||
|
}
|
||||||
|
|
||||||
|
export { globalIceServers };
|
||||||
@ -10,13 +10,16 @@
|
|||||||
<link href="https://fonts.googleapis.com/css2?family=Fira+Code:wght@400;500;600&family=Instrument+Sans:wght@400;500;600;700&family=Outfit:wght@500;600;700&display=swap" rel="stylesheet">
|
<link href="https://fonts.googleapis.com/css2?family=Fira+Code:wght@400;500;600&family=Instrument+Sans:wght@400;500;600;700&family=Outfit:wght@500;600;700&display=swap" rel="stylesheet">
|
||||||
<script src="https://code.jquery.com/jquery-3.7.1.min.js"></script>
|
<script src="https://code.jquery.com/jquery-3.7.1.min.js"></script>
|
||||||
|
|
||||||
<!-- Monaco Code Editor Loader & PeerJS Realtime WebRTC Call & SweetAlert2 -->
|
<!-- PeerJS, Metered SDK, SweetAlert2 & html2canvas -->
|
||||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/monaco-editor/0.45.0/min/vs/loader.min.js"></script>
|
|
||||||
<script src="https://unpkg.com/peerjs@1.5.2/dist/peerjs.min.js"></script>
|
<script src="https://unpkg.com/peerjs@1.5.2/dist/peerjs.min.js"></script>
|
||||||
<script src="//cdn.metered.ca/sdk/video/1.4.6/sdk.min.js"></script>
|
<script src="//cdn.metered.ca/sdk/video/1.4.6/sdk.min.js"></script>
|
||||||
<script src="https://cdn.jsdelivr.net/npm/sweetalert2@11"></script>
|
<script src="https://cdn.jsdelivr.net/npm/sweetalert2@11"></script>
|
||||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/html2canvas/1.4.1/html2canvas.min.js"></script>
|
<script src="https://cdnjs.cloudflare.com/ajax/libs/html2canvas/1.4.1/html2canvas.min.js"></script>
|
||||||
|
|
||||||
|
<!-- Monaco Code Editor Loader (loaded after UMD libraries to prevent define.amd conflicts) -->
|
||||||
|
<script src="https://cdnjs.cloudflare.com/ajax/libs/monaco-editor/0.45.0/min/vs/loader.min.js"></script>
|
||||||
|
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
:root {
|
:root {
|
||||||
--bg-color: #080c14;
|
--bg-color: #080c14;
|
||||||
@ -413,8 +416,10 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
|
var callSigChannel = null;
|
||||||
let editor;
|
let editor;
|
||||||
let interviewId = {{ $interview->id }};
|
let interviewId = {{ $interview->id }};
|
||||||
|
|
||||||
let mediaRecorder;
|
let mediaRecorder;
|
||||||
let recordedChunks = [];
|
let recordedChunks = [];
|
||||||
let previousFrameData = null;
|
let previousFrameData = null;
|
||||||
@ -560,10 +565,27 @@ function submitSolution() {
|
|||||||
|
|
||||||
let isTabSwitchScreenshotEnabled = true;
|
let isTabSwitchScreenshotEnabled = true;
|
||||||
let lastTabSwitchTime = 0;
|
let lastTabSwitchTime = 0;
|
||||||
|
let pendingNativePrompts = 0; // counter for native OS prompts
|
||||||
|
let suppressFocusViolationsUntil = 0; // trailing buffer after prompt resolves
|
||||||
|
|
||||||
|
function beginNativePrompt() {
|
||||||
|
pendingNativePrompts++;
|
||||||
|
}
|
||||||
|
|
||||||
|
function endNativePrompt() {
|
||||||
|
pendingNativePrompts = Math.max(0, pendingNativePrompts - 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
function armTrailingBuffer(ms) {
|
||||||
|
suppressFocusViolationsUntil = Math.max(suppressFocusViolationsUntil, Date.now() + ms);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isFocusSuppressed() {
|
||||||
|
return pendingNativePrompts > 0 || Date.now() < suppressFocusViolationsUntil;
|
||||||
|
}
|
||||||
let latestCallStatus = '{{ $interview->call_status ?? "idle" }}';
|
let latestCallStatus = '{{ $interview->call_status ?? "idle" }}';
|
||||||
let screenShareVideoElem = null;
|
let screenShareVideoElem = null;
|
||||||
|
|
||||||
// Auto-initialize persistent offscreen video element for screen share capture
|
|
||||||
// Auto-initialize persistent offscreen video element for screen share capture
|
// Auto-initialize persistent offscreen video element for screen share capture
|
||||||
function getScreenShareVideoElement() {
|
function getScreenShareVideoElement() {
|
||||||
if (!screenShareVideoElem) {
|
if (!screenShareVideoElem) {
|
||||||
@ -724,6 +746,7 @@ function uploadTabScreenshotBlob(imageData, reason = 'tab_switch') {
|
|||||||
|
|
||||||
// 1. Tab Switch Detection (Captures Candidate's SYSTEM DESKTOP MONITOR screenshot)
|
// 1. Tab Switch Detection (Captures Candidate's SYSTEM DESKTOP MONITOR screenshot)
|
||||||
document.addEventListener('visibilitychange', function () {
|
document.addEventListener('visibilitychange', function () {
|
||||||
|
if (isFocusSuppressed()) return;
|
||||||
if (document.hidden) {
|
if (document.hidden) {
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
if (now - lastTabSwitchTime > 1000) {
|
if (now - lastTabSwitchTime > 1000) {
|
||||||
@ -736,6 +759,7 @@ function uploadTabScreenshotBlob(imageData, reason = 'tab_switch') {
|
|||||||
|
|
||||||
// 2. Window Focus Loss / External AI Overlay Application Switch Detection
|
// 2. Window Focus Loss / External AI Overlay Application Switch Detection
|
||||||
window.addEventListener('blur', function () {
|
window.addEventListener('blur', function () {
|
||||||
|
if (isFocusSuppressed()) return;
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
if (now - lastTabSwitchTime > 1000) {
|
if (now - lastTabSwitchTime > 1000) {
|
||||||
lastTabSwitchTime = now;
|
lastTabSwitchTime = now;
|
||||||
@ -746,6 +770,7 @@ function uploadTabScreenshotBlob(imageData, reason = 'tab_switch') {
|
|||||||
|
|
||||||
// Mouse Viewport Departure (Candidate moving cursor outside window to interact with Parakeet AI or secondary app overlay)
|
// Mouse Viewport Departure (Candidate moving cursor outside window to interact with Parakeet AI or secondary app overlay)
|
||||||
document.addEventListener('mouseleave', function () {
|
document.addEventListener('mouseleave', function () {
|
||||||
|
if (isFocusSuppressed()) return;
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
if (now - lastTabSwitchTime > 2500) {
|
if (now - lastTabSwitchTime > 2500) {
|
||||||
lastTabSwitchTime = now;
|
lastTabSwitchTime = now;
|
||||||
@ -757,7 +782,7 @@ function uploadTabScreenshotBlob(imageData, reason = 'tab_switch') {
|
|||||||
// 3. Code Copy-Paste & Ingestion Detection
|
// 3. Code Copy-Paste & Ingestion Detection
|
||||||
document.addEventListener('paste', function (e) {
|
document.addEventListener('paste', function (e) {
|
||||||
logViolation('paste_event', 'Candidate pasted content into editor window (possible AI answer paste)');
|
logViolation('paste_event', 'Candidate pasted content into editor window (possible AI answer paste)');
|
||||||
});
|
}, true);
|
||||||
|
|
||||||
// Question Selection / Copying Detection (Candidate copying questions to feed Parakeet AI)
|
// Question Selection / Copying Detection (Candidate copying questions to feed Parakeet AI)
|
||||||
document.addEventListener('copy', function (e) {
|
document.addEventListener('copy', function (e) {
|
||||||
@ -1290,36 +1315,28 @@ function safePlayMediaStream(videoElem, stream, isSelf = false) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Metered TURN Server Integration (Pre-fetched for seamless WebRTC P2P + TURN relay)
|
// Metered TURN Server Integration (Managed via window.getMeteredIceServers in app.js / metered.js)
|
||||||
const METERED_CREDENTIALS_URL = "https://single-login-system.metered.live/api/v1/turn/credentials?apiKey=ffc9146ac5c2f75d83d7c679d8553a21c8fa";
|
let globalIceServers = window.globalIceServers || [
|
||||||
let globalIceServers = [
|
|
||||||
{ urls: 'stun:stun.l.google.com:19302' }
|
{ urls: 'stun:stun.l.google.com:19302' }
|
||||||
];
|
];
|
||||||
let meteredIceFetchPromise = null;
|
|
||||||
|
|
||||||
function initMeteredIceServers() {
|
function initMeteredIceServers() {
|
||||||
if (meteredIceFetchPromise) return meteredIceFetchPromise;
|
if (typeof window.getMeteredIceServers === 'function') {
|
||||||
meteredIceFetchPromise = fetch(METERED_CREDENTIALS_URL)
|
return window.getMeteredIceServers().then(servers => {
|
||||||
.then(r => r.json())
|
if (Array.isArray(servers) && servers.length > 0) {
|
||||||
.then(meteredIce => {
|
globalIceServers = servers;
|
||||||
if (Array.isArray(meteredIce) && meteredIce.length > 0) {
|
|
||||||
globalIceServers = [
|
|
||||||
{ urls: 'stun:stun.l.google.com:19302' },
|
|
||||||
...meteredIce.slice(0, 3)
|
|
||||||
];
|
|
||||||
}
|
}
|
||||||
return globalIceServers;
|
return globalIceServers;
|
||||||
})
|
|
||||||
.catch(e => {
|
|
||||||
console.warn('Metered TURN fetch warning:', e);
|
|
||||||
return globalIceServers;
|
|
||||||
});
|
});
|
||||||
return meteredIceFetchPromise;
|
}
|
||||||
|
return Promise.resolve(globalIceServers);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Initiate TURN credentials pre-fetch immediately
|
// Initiate TURN credentials pre-fetch immediately
|
||||||
initMeteredIceServers();
|
initMeteredIceServers();
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
let localMediaStream = null;
|
let localMediaStream = null;
|
||||||
let peerInstance = null;
|
let peerInstance = null;
|
||||||
let candidatePeerConnection = null;
|
let candidatePeerConnection = null;
|
||||||
@ -1363,7 +1380,8 @@ function initMeteredIceServers() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const callSigChannel = new BroadcastChannel('webrtc_call_' + interviewId);
|
callSigChannel = new BroadcastChannel('webrtc_call_' + interviewId);
|
||||||
|
|
||||||
|
|
||||||
callSigChannel.onmessage = async (event) => {
|
callSigChannel.onmessage = async (event) => {
|
||||||
const msg = event.data;
|
const msg = event.data;
|
||||||
@ -1553,6 +1571,8 @@ function startCandidateCameraStream() {
|
|||||||
autoGainControl: true
|
autoGainControl: true
|
||||||
};
|
};
|
||||||
|
|
||||||
|
beginNativePrompt();
|
||||||
|
|
||||||
return navigator.mediaDevices.getUserMedia({
|
return navigator.mediaDevices.getUserMedia({
|
||||||
video: {
|
video: {
|
||||||
width: { ideal: 1280 },
|
width: { ideal: 1280 },
|
||||||
@ -1566,6 +1586,7 @@ function startCandidateCameraStream() {
|
|||||||
.catch(() => navigator.mediaDevices.getUserMedia({ video: false, audio: true }))
|
.catch(() => navigator.mediaDevices.getUserMedia({ video: false, audio: true }))
|
||||||
.catch(() => navigator.mediaDevices.getUserMedia({ video: true, audio: false }))
|
.catch(() => navigator.mediaDevices.getUserMedia({ video: true, audio: false }))
|
||||||
.then(stream => {
|
.then(stream => {
|
||||||
|
endNativePrompt();
|
||||||
localMediaStream = stream;
|
localMediaStream = stream;
|
||||||
const vid = document.getElementById('webcam');
|
const vid = document.getElementById('webcam');
|
||||||
if (vid) {
|
if (vid) {
|
||||||
@ -1592,6 +1613,7 @@ function startCandidateCameraStream() {
|
|||||||
return stream;
|
return stream;
|
||||||
})
|
})
|
||||||
.catch(err => {
|
.catch(err => {
|
||||||
|
endNativePrompt();
|
||||||
console.log('Candidate media access error:', err);
|
console.log('Candidate media access error:', err);
|
||||||
if (avatarPlaceholder) avatarPlaceholder.style.display = 'flex';
|
if (avatarPlaceholder) avatarPlaceholder.style.display = 'flex';
|
||||||
initCandidatePeer();
|
initCandidatePeer();
|
||||||
@ -1603,20 +1625,11 @@ function startCandidateCameraStream() {
|
|||||||
|
|
||||||
// Prompt candidate to activate Entire Screen Proctoring Shield upon joining
|
// Prompt candidate to activate Entire Screen Proctoring Shield upon joining
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
if (!screenShareStream && typeof Swal !== 'undefined') {
|
if (!screenShareStream) {
|
||||||
Swal.fire({
|
promptScreenShareRequired();
|
||||||
icon: 'info',
|
|
||||||
title: '🛡️ Parakeet AI & Overlay Detection Shield',
|
|
||||||
text: 'To ensure assessment integrity and detect external AI overlay windows (such as Parakeet AI), Entire Screen sharing is required.',
|
|
||||||
confirmButtonText: '🖥️ Enable Entire Screen Shield Now',
|
|
||||||
allowOutsideClick: false
|
|
||||||
}).then((res) => {
|
|
||||||
if (res.isConfirmed) {
|
|
||||||
startScreenShare();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}, 2000);
|
}, 1500);
|
||||||
|
|
||||||
|
|
||||||
function candidateLogoutAction() {
|
function candidateLogoutAction() {
|
||||||
if (typeof Swal !== 'undefined') {
|
if (typeof Swal !== 'undefined') {
|
||||||
@ -1741,6 +1754,17 @@ function toggleCam() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function requestFullscreenAsPromise(elem) {
|
||||||
|
if (elem.requestFullscreen) {
|
||||||
|
return elem.requestFullscreen();
|
||||||
|
}
|
||||||
|
if (elem.webkitRequestFullscreen) {
|
||||||
|
elem.webkitRequestFullscreen();
|
||||||
|
return Promise.resolve();
|
||||||
|
}
|
||||||
|
return Promise.reject(new Error('Fullscreen not supported'));
|
||||||
|
}
|
||||||
|
|
||||||
function requestProctoringFullscreen() {
|
function requestProctoringFullscreen() {
|
||||||
try {
|
try {
|
||||||
const elem = document.documentElement;
|
const elem = document.documentElement;
|
||||||
@ -1753,6 +1777,7 @@ function requestProctoringFullscreen() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
document.addEventListener('fullscreenchange', function() {
|
document.addEventListener('fullscreenchange', function() {
|
||||||
|
if (isFocusSuppressed()) return;
|
||||||
if (!document.fullscreenElement) {
|
if (!document.fullscreenElement) {
|
||||||
logViolation('tab_switch', 'Candidate exited full-screen proctoring mode (possible overlay AI window interaction)');
|
logViolation('tab_switch', 'Candidate exited full-screen proctoring mode (possible overlay AI window interaction)');
|
||||||
captureAndUploadTabScreenshot();
|
captureAndUploadTabScreenshot();
|
||||||
@ -1786,12 +1811,60 @@ function initLiveOverlayDetector() {
|
|||||||
}, 2000);
|
}, 2000);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function promptScreenShareRequired(reasonMessage) {
|
||||||
|
if (screenShareStream && screenShareStream.active) return;
|
||||||
|
|
||||||
|
const messageHtml = reasonMessage ? `
|
||||||
|
<div class="text-center">
|
||||||
|
<p style="color: #ef4444; font-weight: 600; margin-bottom: 12px;">
|
||||||
|
${reasonMessage}
|
||||||
|
</p>
|
||||||
|
<p style="margin-bottom: 12px; color:1D2A36;">
|
||||||
|
Screen sharing is <strong>mandatory</strong>.
|
||||||
|
</p>
|
||||||
|
<p style="font-size: 0.85rem; color: #94a3b8;">
|
||||||
|
When prompted by your browser, select <strong>Entire Screen</strong> and click <strong>Share</strong>.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
` : `
|
||||||
|
<div class="text-center">
|
||||||
|
<p style="margin-bottom: 12px; color: #1D2A36;">
|
||||||
|
Before you begin, please share your <strong>entire screen</strong>.
|
||||||
|
</p>
|
||||||
|
<p style="font-size: 0.85rem; color: #94a3b8;">
|
||||||
|
When prompted by your browser, select <strong>Entire Screen</strong> and click <strong>Share</strong>.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
if (typeof Swal !== 'undefined') {
|
||||||
|
Swal.fire({
|
||||||
|
icon: 'warning',
|
||||||
|
title: 'Screen Sharing Required',
|
||||||
|
html: messageHtml,
|
||||||
|
confirmButtonText: 'Share Entire Screen Now',
|
||||||
|
confirmButtonColor: '#6366f1',
|
||||||
|
allowOutsideClick: false,
|
||||||
|
allowEscapeKey: false
|
||||||
|
}).then((res) => {
|
||||||
|
if (res.isConfirmed) {
|
||||||
|
startScreenShare();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
alert((reasonMessage ? (reasonMessage + ' ') : '') + 'Screen sharing is required. Please select Entire Screen when prompted.');
|
||||||
|
startScreenShare();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function startScreenShare() {
|
function startScreenShare() {
|
||||||
if (!navigator.mediaDevices.getDisplayMedia) {
|
if (!navigator.mediaDevices.getDisplayMedia) {
|
||||||
alert('Screen sharing is not supported on your browser.');
|
alert('Screen sharing is not supported on your browser.');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
beginNativePrompt();
|
||||||
|
|
||||||
navigator.mediaDevices.getDisplayMedia({
|
navigator.mediaDevices.getDisplayMedia({
|
||||||
video: {
|
video: {
|
||||||
displaySurface: 'monitor'
|
displaySurface: 'monitor'
|
||||||
@ -1799,25 +1872,29 @@ function startScreenShare() {
|
|||||||
audio: false
|
audio: false
|
||||||
})
|
})
|
||||||
.then(stream => {
|
.then(stream => {
|
||||||
|
endNativePrompt();
|
||||||
const track = stream.getVideoTracks()[0];
|
const track = stream.getVideoTracks()[0];
|
||||||
const settings = track.getSettings ? track.getSettings() : {};
|
const settings = track.getSettings ? track.getSettings() : {};
|
||||||
|
|
||||||
// Enforce ENTIRE SCREEN share for Parakeet AI overlay detection
|
// Enforce ENTIRE SCREEN share
|
||||||
if (settings.displaySurface && settings.displaySurface !== 'monitor') {
|
if (settings.displaySurface && settings.displaySurface !== 'monitor') {
|
||||||
if (typeof Swal !== 'undefined') {
|
if (typeof Swal !== 'undefined') {
|
||||||
Swal.fire({
|
Swal.fire({
|
||||||
icon: 'warning',
|
icon: 'warning',
|
||||||
title: '⚠️ Entire Screen Share Required',
|
title: 'Entire Screen Share Required',
|
||||||
text: 'You selected a single tab or window. To detect overlay AI assistants (such as Parakeet AI) and ensure proctoring integrity, you MUST select "Entire Screen".',
|
text: 'You selected a single tab or window. You MUST select "Entire Screen".',
|
||||||
confirmButtonText: '🖥️ Reshare Entire Screen'
|
confirmButtonText: 'Reshare Entire Screen',
|
||||||
|
confirmButtonColor: '#6366f1',
|
||||||
|
allowOutsideClick: false,
|
||||||
|
allowEscapeKey: false
|
||||||
}).then(() => {
|
}).then(() => {
|
||||||
track.stop();
|
track.stop();
|
||||||
setTimeout(startScreenShare, 500);
|
setTimeout(startScreenShare, 300);
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
alert('Entire Screen share is required for proctoring overlay detection. Please select Entire Screen.');
|
alert('Entire Screen share is required. Please select Entire Screen.');
|
||||||
track.stop();
|
track.stop();
|
||||||
setTimeout(startScreenShare, 500);
|
setTimeout(startScreenShare, 300);
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@ -1829,11 +1906,17 @@ function startScreenShare() {
|
|||||||
|
|
||||||
const btn = document.getElementById('share-screen-btn');
|
const btn = document.getElementById('share-screen-btn');
|
||||||
if (btn) {
|
if (btn) {
|
||||||
btn.innerText = '🟢 Entire Screen Sharing Active (Parakeet AI Shield ON)';
|
btn.innerText = '🟢 Entire Screen Sharing Active';
|
||||||
btn.style.background = '#10b981';
|
btn.style.background = '#10b981';
|
||||||
}
|
}
|
||||||
|
|
||||||
requestProctoringFullscreen();
|
beginNativePrompt();
|
||||||
|
requestFullscreenAsPromise(document.documentElement)
|
||||||
|
.catch(() => {})
|
||||||
|
.finally(() => {
|
||||||
|
endNativePrompt();
|
||||||
|
armTrailingBuffer(1000);
|
||||||
|
});
|
||||||
|
|
||||||
// Connect screen stream to interviewer screen peer
|
// Connect screen stream to interviewer screen peer
|
||||||
const screenPeer = new Peer('cand_screen_' + interviewId + '_' + Date.now());
|
const screenPeer = new Peer('cand_screen_' + interviewId + '_' + Date.now());
|
||||||
@ -1842,20 +1925,27 @@ function startScreenShare() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
track.onended = () => {
|
track.onended = () => {
|
||||||
|
screenShareStream = null;
|
||||||
if (btn) {
|
if (btn) {
|
||||||
btn.innerText = '🖥️ Share Screen with Interviewer';
|
btn.innerText = '🖥️ Share Screen with Interviewer';
|
||||||
btn.style.background = 'linear-gradient(135deg, #6366f1 0%, #0078d4 100%)';
|
btn.style.background = 'linear-gradient(135deg, #6366f1 0%, #0078d4 100%)';
|
||||||
}
|
}
|
||||||
logViolation('tab_switch', 'Candidate stopped screen sharing (proctoring screen share disabled)');
|
logViolation('tab_switch', 'Candidate stopped screen sharing (proctoring screen share disabled)');
|
||||||
|
promptScreenShareRequired('Screen sharing was stopped. You must share your Entire Screen to continue.');
|
||||||
};
|
};
|
||||||
|
|
||||||
initLiveOverlayDetector();
|
initLiveOverlayDetector();
|
||||||
})
|
})
|
||||||
.catch(err => {
|
.catch(err => {
|
||||||
console.log('Screen sharing cancelled:', err);
|
endNativePrompt();
|
||||||
|
console.log('Screen sharing cancelled/denied:', err);
|
||||||
|
screenShareStream = null;
|
||||||
|
logViolation('tab_switch', 'Candidate denied or cancelled screen sharing permission prompt');
|
||||||
|
promptScreenShareRequired('Screen sharing permission was denied or cancelled.');
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
function analyzeCameraFrame() {
|
function analyzeCameraFrame() {
|
||||||
const video = document.getElementById('webcam');
|
const video = document.getElementById('webcam');
|
||||||
const canvas = document.getElementById('proctor-canvas');
|
const canvas = document.getElementById('proctor-canvas');
|
||||||
|
|||||||
@ -235,7 +235,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(320px, 1fr)); gap: 16px; margin-top: 16px;">
|
<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 -->
|
<!-- 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;">
|
<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">
|
<form action="{{ route('admin.settings.screenshot-toggle') }}" method="POST">
|
||||||
@ -792,7 +792,7 @@
|
|||||||
<!-- INCOMING CALL RING MODAL (REAL CALLING APP UI FOR PANELISTS) -->
|
<!-- INCOMING CALL RING MODAL (REAL CALLING APP UI FOR PANELISTS) -->
|
||||||
<div id="incoming-call-modal" class="admin-modal" style="z-index: 10000; background: rgba(5, 8, 17, 0.85); backdrop-filter: blur(16px);">
|
<div id="incoming-call-modal" class="admin-modal" style="z-index: 10000; background: rgba(5, 8, 17, 0.85); backdrop-filter: blur(16px);">
|
||||||
<div class="admin-modal-content" style="max-width: 460px; background: linear-gradient(145deg, #0f172a 0%, #1e1b4b 100%); border: 2px solid #6366f1; border-radius: 24px; padding: 32px; box-shadow: 0 0 50px rgba(99, 102, 241, 0.6); text-align: center;">
|
<div class="admin-modal-content" style="max-width: 460px; background: linear-gradient(145deg, #0f172a 0%, #1e1b4b 100%); border: 2px solid #6366f1; border-radius: 24px; padding: 32px; box-shadow: 0 0 50px rgba(99, 102, 241, 0.6); text-align: center;">
|
||||||
|
|
||||||
<div style="position: relative; width: 90px; height: 90px; margin: 0 auto 20px auto; display: flex; align-items: center; justify-content: center;">
|
<div style="position: relative; width: 90px; height: 90px; margin: 0 auto 20px auto; display: flex; align-items: center; justify-content: center;">
|
||||||
<div style="position: absolute; inset: -15px; border-radius: 50%; border: 2px solid rgba(16, 185, 129, 0.6); animation: ringPulse 1.4s infinite ease-out;"></div>
|
<div style="position: absolute; inset: -15px; border-radius: 50%; border: 2px solid rgba(16, 185, 129, 0.6); animation: ringPulse 1.4s infinite ease-out;"></div>
|
||||||
<div style="position: absolute; inset: -30px; border-radius: 50%; border: 2px solid rgba(99, 102, 241, 0.4); animation: ringPulse 1.4s infinite ease-out 0.4s;"></div>
|
<div style="position: absolute; inset: -30px; border-radius: 50%; border: 2px solid rgba(99, 102, 241, 0.4); animation: ringPulse 1.4s infinite ease-out 0.4s;"></div>
|
||||||
@ -804,11 +804,11 @@
|
|||||||
<div style="font-size: 0.75rem; text-transform: uppercase; letter-spacing: 1.5px; color: #34d399; font-weight: 800; margin-bottom: 6px;">
|
<div style="font-size: 0.75rem; text-transform: uppercase; letter-spacing: 1.5px; color: #34d399; font-weight: 800; margin-bottom: 6px;">
|
||||||
⚡ INCOMING INTERVIEW CALL
|
⚡ INCOMING INTERVIEW CALL
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<h3 id="incoming-cand-name" style="font-family: 'Outfit', sans-serif; font-size: 1.45rem; font-weight: 800; color: white; margin-bottom: 4px;">
|
<h3 id="incoming-cand-name" style="font-family: 'Outfit', sans-serif; font-size: 1.45rem; font-weight: 800; color: white; margin-bottom: 4px;">
|
||||||
Candidate Name
|
Candidate Name
|
||||||
</h3>
|
</h3>
|
||||||
|
|
||||||
<div id="incoming-starter-info" style="font-size: 0.82rem; color: #a5b4fc; margin-bottom: 16px;">
|
<div id="incoming-starter-info" style="font-size: 0.82rem; color: #a5b4fc; margin-bottom: 16px;">
|
||||||
Initiated by Panelist
|
Initiated by Panelist
|
||||||
</div>
|
</div>
|
||||||
@ -844,6 +844,7 @@
|
|||||||
</style>
|
</style>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
|
var interviewerSigChannel = null;
|
||||||
let currentInterviewId = null;
|
let currentInterviewId = null;
|
||||||
let interviewerPeer = null;
|
let interviewerPeer = null;
|
||||||
let screenReceiverPeer = null;
|
let screenReceiverPeer = null;
|
||||||
@ -1018,7 +1019,7 @@ class CallRingtoneEngine {
|
|||||||
this.init();
|
this.init();
|
||||||
if (this.isPlaying) return;
|
if (this.isPlaying) return;
|
||||||
this.isPlaying = true;
|
this.isPlaying = true;
|
||||||
|
|
||||||
this.playTone();
|
this.playTone();
|
||||||
if (this.interval) clearInterval(this.interval);
|
if (this.interval) clearInterval(this.interval);
|
||||||
this.interval = setInterval(() => {
|
this.interval = setInterval(() => {
|
||||||
@ -1686,36 +1687,28 @@ function blockCandidateAction() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Metered TURN Server Integration (Pre-fetched for seamless WebRTC P2P + TURN relay)
|
// Metered TURN Server Integration (Managed via window.getMeteredIceServers in app.js / metered.js)
|
||||||
const METERED_CREDENTIALS_URL = "https://single-login-system.metered.live/api/v1/turn/credentials?apiKey=ffc9146ac5c2f75d83d7c679d8553a21c8fa";
|
let globalIceServers = window.globalIceServers || [
|
||||||
let globalIceServers = [
|
|
||||||
{ urls: 'stun:stun.l.google.com:19302' }
|
{ urls: 'stun:stun.l.google.com:19302' }
|
||||||
];
|
];
|
||||||
let meteredIceFetchPromise = null;
|
|
||||||
|
|
||||||
function initMeteredIceServers() {
|
function initMeteredIceServers() {
|
||||||
if (meteredIceFetchPromise) return meteredIceFetchPromise;
|
if (typeof window.getMeteredIceServers === 'function') {
|
||||||
meteredIceFetchPromise = fetch(METERED_CREDENTIALS_URL)
|
return window.getMeteredIceServers().then(servers => {
|
||||||
.then(r => r.json())
|
if (Array.isArray(servers) && servers.length > 0) {
|
||||||
.then(meteredIce => {
|
globalIceServers = servers;
|
||||||
if (Array.isArray(meteredIce) && meteredIce.length > 0) {
|
|
||||||
globalIceServers = [
|
|
||||||
{ urls: 'stun:stun.l.google.com:19302' },
|
|
||||||
...meteredIce.slice(0, 3)
|
|
||||||
];
|
|
||||||
}
|
}
|
||||||
return globalIceServers;
|
return globalIceServers;
|
||||||
})
|
|
||||||
.catch(e => {
|
|
||||||
console.warn('Metered TURN fetch warning:', e);
|
|
||||||
return globalIceServers;
|
|
||||||
});
|
});
|
||||||
return meteredIceFetchPromise;
|
}
|
||||||
|
return Promise.resolve(globalIceServers);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Initiate TURN credentials pre-fetch immediately
|
// Initiate TURN credentials pre-fetch immediately
|
||||||
initMeteredIceServers();
|
initMeteredIceServers();
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
function safePlayMediaStream(videoElem, stream, isSelf = false) {
|
function safePlayMediaStream(videoElem, stream, isSelf = false) {
|
||||||
if (!videoElem || !stream) return;
|
if (!videoElem || !stream) return;
|
||||||
if (isSelf) {
|
if (isSelf) {
|
||||||
@ -1814,7 +1807,8 @@ function zoomCandVideo(delta) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let interviewerPeerConnection = null;
|
let interviewerPeerConnection = null;
|
||||||
let interviewerSigChannel = null;
|
interviewerSigChannel = null;
|
||||||
|
|
||||||
|
|
||||||
function initInterviewerSigChannel() {
|
function initInterviewerSigChannel() {
|
||||||
if (!currentInterviewId) return;
|
if (!currentInterviewId) return;
|
||||||
@ -2155,6 +2149,7 @@ function endCallForAll() {
|
|||||||
// --- SILENT CALL RECORDING ENGINE FOR INTERVIEWERS & ADMINS ---
|
// --- SILENT CALL RECORDING ENGINE FOR INTERVIEWERS & ADMINS ---
|
||||||
let adminMediaRecorder = null;
|
let adminMediaRecorder = null;
|
||||||
let adminRecordedChunks = [];
|
let adminRecordedChunks = [];
|
||||||
|
let recAudioContext = null;
|
||||||
|
|
||||||
function startCallRecording() {
|
function startCallRecording() {
|
||||||
if (!currentInterviewId) {
|
if (!currentInterviewId) {
|
||||||
@ -2166,28 +2161,59 @@ function startCallRecording() {
|
|||||||
let candidateStream = (candVid && candVid.srcObject) ? candVid.srcObject : null;
|
let candidateStream = (candVid && candVid.srcObject) ? candVid.srcObject : null;
|
||||||
let streamToRecord = new MediaStream();
|
let streamToRecord = new MediaStream();
|
||||||
|
|
||||||
// Add live Candidate Video and Audio tracks
|
// 1. Add Video Track (Candidate video or Interviewer fallback)
|
||||||
if (candidateStream) {
|
if (candidateStream) {
|
||||||
candidateStream.getTracks().forEach(track => {
|
candidateStream.getVideoTracks().forEach(track => {
|
||||||
if (track.readyState === 'live') {
|
if (track.readyState === 'live') {
|
||||||
try { streamToRecord.addTrack(track); } catch(e) {}
|
try { streamToRecord.addTrack(track); } catch(e) {}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add Interviewer Local Microphone Audio track for 2-way call recording
|
if (streamToRecord.getVideoTracks().length === 0 && interviewerLocalStream) {
|
||||||
if (interviewerLocalStream) {
|
interviewerLocalStream.getVideoTracks().forEach(track => {
|
||||||
interviewerLocalStream.getAudioTracks().forEach(track => {
|
if (track.readyState === 'live') {
|
||||||
if (track.readyState === 'live' && !streamToRecord.getAudioTracks().includes(track)) {
|
|
||||||
try { streamToRecord.addTrack(track); } catch(e) {}
|
try { streamToRecord.addTrack(track); } catch(e) {}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
if (streamToRecord.getVideoTracks().length === 0) {
|
}
|
||||||
interviewerLocalStream.getVideoTracks().forEach(track => {
|
|
||||||
if (track.readyState === 'live') {
|
// 2. Mix Multiple Audio Tracks into a SINGLE Audio Track via Web Audio API
|
||||||
try { streamToRecord.addTrack(track); } catch(e) {}
|
// (MediaRecorder standard throws DOMException if streamToRecord has >1 audio track)
|
||||||
}
|
const candAudioTracks = candidateStream ? candidateStream.getAudioTracks().filter(t => t.readyState === 'live') : [];
|
||||||
});
|
const adminAudioTracks = interviewerLocalStream ? interviewerLocalStream.getAudioTracks().filter(t => t.readyState === 'live') : [];
|
||||||
|
|
||||||
|
if (candAudioTracks.length > 0 || adminAudioTracks.length > 0) {
|
||||||
|
try {
|
||||||
|
const AudioContextClass = window.AudioContext || window.webkitAudioContext;
|
||||||
|
if (recAudioContext) {
|
||||||
|
try { recAudioContext.close(); } catch(e) {}
|
||||||
|
}
|
||||||
|
recAudioContext = new AudioContextClass();
|
||||||
|
const audioDestination = recAudioContext.createMediaStreamDestination();
|
||||||
|
|
||||||
|
if (candAudioTracks.length > 0) {
|
||||||
|
const candAudioStream = new MediaStream(candAudioTracks);
|
||||||
|
const candSource = recAudioContext.createMediaStreamSource(candAudioStream);
|
||||||
|
candSource.connect(audioDestination);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (adminAudioTracks.length > 0) {
|
||||||
|
const adminAudioStream = new MediaStream(adminAudioTracks);
|
||||||
|
const adminSource = recAudioContext.createMediaStreamSource(adminAudioStream);
|
||||||
|
adminSource.connect(audioDestination);
|
||||||
|
}
|
||||||
|
|
||||||
|
const mixedAudioTrack = audioDestination.stream.getAudioTracks()[0];
|
||||||
|
if (mixedAudioTrack) {
|
||||||
|
streamToRecord.addTrack(mixedAudioTrack);
|
||||||
|
}
|
||||||
|
} catch(e) {
|
||||||
|
console.warn('Web Audio API mixing fallback:', e);
|
||||||
|
const fallbackAudio = candAudioTracks[0] || adminAudioTracks[0];
|
||||||
|
if (fallbackAudio) {
|
||||||
|
try { streamToRecord.addTrack(fallbackAudio); } catch(err) {}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -2221,6 +2247,10 @@ function startCallRecording() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
adminMediaRecorder.onstop = function() {
|
adminMediaRecorder.onstop = function() {
|
||||||
|
if (recAudioContext) {
|
||||||
|
try { recAudioContext.close(); } catch(e) {}
|
||||||
|
recAudioContext = null;
|
||||||
|
}
|
||||||
if (adminRecordedChunks.length === 0) return;
|
if (adminRecordedChunks.length === 0) return;
|
||||||
const recMime = adminMediaRecorder.mimeType || 'video/webm';
|
const recMime = adminMediaRecorder.mimeType || 'video/webm';
|
||||||
const isMp4 = recMime.includes('mp4');
|
const isMp4 = recMime.includes('mp4');
|
||||||
|
|||||||
@ -5,6 +5,7 @@
|
|||||||
use App\Http\Controllers\InterviewController;
|
use App\Http\Controllers\InterviewController;
|
||||||
use App\Http\Controllers\LoginController;
|
use App\Http\Controllers\LoginController;
|
||||||
use App\Http\Controllers\OnboardingController;
|
use App\Http\Controllers\OnboardingController;
|
||||||
|
use App\Http\Controllers\IceServerController;
|
||||||
use Illuminate\Support\Facades\Route;
|
use Illuminate\Support\Facades\Route;
|
||||||
|
|
||||||
// Authentication & Portal landing
|
// Authentication & Portal landing
|
||||||
@ -39,9 +40,11 @@
|
|||||||
Route::post('/candidate/send-message/{id}', [InterviewController::class, 'candidateSendMessage'])->name('interview.candidate.send-message');
|
Route::post('/candidate/send-message/{id}', [InterviewController::class, 'candidateSendMessage'])->name('interview.candidate.send-message');
|
||||||
Route::get('/candidate/poll/{id}', [InterviewController::class, 'getPollData'])->name('interview.candidate.poll');
|
Route::get('/candidate/poll/{id}', [InterviewController::class, 'getPollData'])->name('interview.candidate.poll');
|
||||||
|
|
||||||
// Public Storage & Media Stream Delivery (Recordings & Video Streaming)
|
// Public Storage & Media Stream Delivery & ICE Servers
|
||||||
Route::get('/media-stream/{path}', [InterviewController::class, 'serveStorageFile'])->where('path', '.*')->name('media.stream');
|
Route::get('/media-stream/{path}', [InterviewController::class, 'serveStorageFile'])->where('path', '.*')->name('media.stream');
|
||||||
Route::get('/storage/{path}', [InterviewController::class, 'serveStorageFile'])->where('path', '.*')->name('storage.file');
|
Route::get('/storage/{path}', [InterviewController::class, 'serveStorageFile'])->where('path', '.*')->name('storage.file');
|
||||||
|
Route::get('/ice-servers', IceServerController::class)->name('ice-servers');
|
||||||
|
|
||||||
|
|
||||||
// User Dashboard Portal (requires authenticated user)
|
// User Dashboard Portal (requires authenticated user)
|
||||||
Route::middleware(['auth', 'role:user', 'verify-ms-session'])->group(function () {
|
Route::middleware(['auth', 'role:user', 'verify-ms-session'])->group(function () {
|
||||||
@ -53,7 +56,7 @@
|
|||||||
Route::get('/sso/personal-launch/{id}', [DashboardController::class, 'launchPersonalSSO'])->name('sso.personal-launch');
|
Route::get('/sso/personal-launch/{id}', [DashboardController::class, 'launchPersonalSSO'])->name('sso.personal-launch');
|
||||||
Route::get('/sso/callback', [DashboardController::class, 'handleSSOCallback'])->name('sso.callback');
|
Route::get('/sso/callback', [DashboardController::class, 'handleSSOCallback'])->name('sso.callback');
|
||||||
Route::get('/sso/launch-app', [DashboardController::class, 'launchApp'])->name('sso.launch-app');
|
Route::get('/sso/launch-app', [DashboardController::class, 'launchApp'])->name('sso.launch-app');
|
||||||
|
|
||||||
// Personal custom apps management
|
// Personal custom apps management
|
||||||
Route::post('/dashboard/personal-apps', [DashboardController::class, 'storePersonalApp'])->name('dashboard.personal-apps.store');
|
Route::post('/dashboard/personal-apps', [DashboardController::class, 'storePersonalApp'])->name('dashboard.personal-apps.store');
|
||||||
Route::post('/dashboard/personal-apps/{id}/update', [DashboardController::class, 'updatePersonalApp'])->name('dashboard.personal-apps.update');
|
Route::post('/dashboard/personal-apps/{id}/update', [DashboardController::class, 'updatePersonalApp'])->name('dashboard.personal-apps.update');
|
||||||
@ -81,6 +84,7 @@
|
|||||||
Route::post('/interviews/{id}/toggle-tab-screenshot', [InterviewController::class, 'toggleTabScreenshot'])->name('interview.toggle-tab-screenshot');
|
Route::post('/interviews/{id}/toggle-tab-screenshot', [InterviewController::class, 'toggleTabScreenshot'])->name('interview.toggle-tab-screenshot');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
// Admin Control Panel (requires admin role)
|
// Admin Control Panel (requires admin role)
|
||||||
Route::middleware(['auth', 'role:admin'])->prefix('controlpannel')->group(function () {
|
Route::middleware(['auth', 'role:admin'])->prefix('controlpannel')->group(function () {
|
||||||
Route::get('/', [AdminController::class, 'index'])->middleware('verify-ms-session');
|
Route::get('/', [AdminController::class, 'index'])->middleware('verify-ms-session');
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user