diff --git a/src/routes/idaa/(idaa)/+layout.svelte b/src/routes/idaa/(idaa)/+layout.svelte index b80f3bcc..daca62fc 100644 --- a/src/routes/idaa/(idaa)/+layout.svelte +++ b/src/routes/idaa/(idaa)/+layout.svelte @@ -43,6 +43,14 @@ let novi_verifying: boolean = $state(!!url_uuid); // which would cause the guard to fire immediately and skip verification entirely. let verify_in_flight = false; +// Failure latch — set to true when verification definitively fails (e.g. Novi returns +// 200 with empty data for a non-existent UUID, or a 4xx response). +// WHY: Without this, writes to $idaa_loc in the catch block trigger svelte-persisted-store +// storage events, which cause $ae_loc to re-notify its subscribers, re-running Effect 2 in +// an infinite loop. The UUID is fixed for this page load (read once from window.location.search), +// so retrying will always produce the same result. User must reload to retry. +let verify_failed = false; + // Show a manual reset button if the spinner is still visible after this many ms. // Handles the case where site_cfg_json loads without novi_idaa_api_key (stale cache) // or the Novi API call hangs — the user would otherwise be stuck with no escape. @@ -99,6 +107,14 @@ $effect(() => { if (verify_in_flight) return; + if (verify_failed) { + // Verification definitively failed for this UUID — do not retry. + // See comment on verify_failed declaration for the full explanation. + if (log_lvl) console.log(`IDAA Layout: skipping re-verification — already failed for ${url_uuid}`); + novi_verifying = false; + return; + } + // TTL cache: skip if this UUID was recently verified. // Prevents duplicate API calls when site_cfg_json updates multiple times (SWR pattern). const now = Date.now(); @@ -206,6 +222,16 @@ async function verify_novi_uuid( ? result.Email.replace(/\s+/g, '+') : null; + // WHY: Novi may return HTTP 200 with empty/null fields for UUIDs that don't + // exist or have been deleted (common API anti-pattern — empty 200 vs 404). + // Without this check, any UUID would pass verification as long as Novi doesn't + // return a 4xx status. Require at least one identity field to treat as a real member. + if (!verified_email && !first_name && !(result?.Name)) { + throw new Error( + `Novi API returned 200 but no member data for UUID ${uuid} — treating as unverified` + ); + } + $idaa_loc.novi_uuid = uuid; $idaa_loc.novi_email = verified_email; $idaa_loc.novi_full_name = verified_name; @@ -245,6 +271,7 @@ async function verify_novi_uuid( `IDAA Layout: Novi UUID verification failed for ${uuid}:`, error ); + verify_failed = true; // Latch — stop the Effect 2 retry loop. See declaration comment. $idaa_loc.novi_uuid = null; $idaa_loc.novi_email = null; $idaa_loc.novi_full_name = null; @@ -290,7 +317,7 @@ async function verify_novi_uuid( {/if} -{:else if $ae_loc.trusted_access || ($ae_loc.authenticated_access && $idaa_loc.novi_uuid)} +{:else if $ae_loc.trusted_access || ($ae_loc.authenticated_access && $idaa_loc.novi_uuid && $idaa_loc.novi_verified)} {@render children?.()} {#if $idaa_loc.novi_uuid} diff --git a/src/routes/idaa/(idaa)/video_conferences/+page.svelte b/src/routes/idaa/(idaa)/video_conferences/+page.svelte index 4750a7f1..15edfdc4 100644 --- a/src/routes/idaa/(idaa)/video_conferences/+page.svelte +++ b/src/routes/idaa/(idaa)/video_conferences/+page.svelte @@ -53,6 +53,17 @@ let meeting_duration: string = $state('00:00:00'); let duration_timer_id: any = $state(null); // reporting_timer_id is now removed +// Breakout modal — lets users open the meeting outside the Novi iframe +let show_breakout_modal: boolean = $state(false); +let breakout_link_copied: boolean = $state(false); + +function copy_meeting_link() { + navigator.clipboard.writeText($page.url.href).then(() => { + breakout_link_copied = true; + setTimeout(() => (breakout_link_copied = false), 2000); + }); +} + /** * Creates a new activity log entry for a discrete event (e.g., raise hand). */ @@ -567,6 +578,21 @@ onMount(async () => { await fetch_novi_data(); + // Defense-in-depth: the parent layout should have blocked unverified users, + // but guard here as a second layer. For non-trusted users, the UUID in the URL + // must match the UUID that was verified by the layout. Prevents joining Jitsi + // with a fake UUID even if the layout guard is somehow bypassed. + if (!$ae_loc.trusted_access && (!$idaa_loc.novi_verified || $idaa_loc.novi_uuid !== user_id)) { + const container = document.getElementById(jitsi_container_id); + if (container) + container.innerHTML = + '

Access denied: Novi identity not verified for this meeting.

'; + console.error( + `Jitsi: Aborting — UUID not verified or mismatch. verified=${$idaa_loc.novi_verified}, stored=${$idaa_loc.novi_uuid}, url=${user_id}` + ); + return; + } + if (!domain) { console.error('Jitsi: domain not set after fetch_novi_data — cannot load Jitsi script.'); return; @@ -1182,6 +1208,81 @@ async function init_jitsi() { {/if} + +{#if $ae_loc.iframe} +
+ +
+{/if} + +{#if show_breakout_modal} +
(show_breakout_modal = false)} + onkeydown={(e) => e.key === 'Escape' && (show_breakout_modal = false)} + role="button" + tabindex="-1" + aria-label="Close meeting link dialog"> + +
+{/if} +