Files
OSIT-AE-App-Svelte/src/routes/idaa/(idaa)/+layout.svelte
2026-04-02 18:19:13 -04:00

446 lines
19 KiB
Svelte

<script lang="ts">
import { untrack } from 'svelte';
let log_lvl: number = 0;
// *** Import Svelte specific
import { browser } from '$app/environment';
import { page } from '$app/stores';
// *** Import Aether specific variables and functions
import { ae_util } from '$lib/ae_utils/ae_utils';
import {
ae_snip,
ae_loc,
ae_sess,
ae_api,
ae_trig,
slct,
slct_trigger
} from '$lib/stores/ae_stores';
import { idaa_loc, idaa_sess, idaa_slct } from '$lib/stores/ae_idaa_stores';
interface Props {
/** @type {import('./$types').LayoutData} */
data: any;
children?: import('svelte').Snippet;
}
let { data, children }: Props = $props();
// Reactive UUID — derived from $page.url so it updates on every SvelteKit client-side
// navigation, not just full page reloads.
// WHY: The original impl read window.location.search once (const), assuming UUID changes
// always cause a full iframe reload (Novi impersonation behavior). But manual URL edits
// in the same SvelteKit session keep the layout mounted and leave url_uuid stale — the
// TTL cache then hits for the OLD valid UUID, granting access under the wrong identity.
// $page.url.searchParams is always current, so any UUID change is caught and re-verified.
const url_uuid = $derived(browser ? ($page.url.searchParams.get('uuid') ?? null) : null);
// True while the Novi API call is in flight.
// Pre-initialized to true when a UUID is present to prevent an "Access Denied" flash
// before the effect has a chance to run on first render.
// WHY direct window.location.search here (not url_uuid derived): $state initializers are
// not reactive — we want a one-time snapshot of "is there a UUID on load?". Using the
// $derived here would only capture its initial value anyway, and Svelte warns about it.
let novi_verifying: boolean = $state(
browser ? !!new URLSearchParams(window.location.search).get('uuid') : false
);
// Concurrency guard — separate from novi_verifying (the UI spinner).
// Do NOT use novi_verifying as a concurrency guard: it is pre-initialized to true,
// which would cause the guard to fire immediately and skip verification entirely.
let verify_in_flight = false;
// Failure latch — stores the UUID that definitively failed (Novi 200+empty, 4xx, network error).
// WHY (loop prevention): writes to $idaa_loc in the catch block trigger svelte-persisted-store
// storage events → $ae_loc re-notifies subscribers → Effect 2 re-runs. Without the latch,
// the same failing UUID would be re-verified infinitely.
// WHY (UUID-keyed, not boolean): when the URL UUID changes (SvelteKit navigation), the new
// UUID must be verified fresh. A plain boolean would wrongly block verification of the new UUID.
// Storing the failed UUID means only that exact UUID is skipped; any other UUID is a clean slate.
let verify_failed_for_uuid: string | null = null;
// 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.
const VERIFY_TIMEOUT_MS = 8000;
// One-time technical notice banner — persisted in localStorage so it only shows once.
// TEMPORARY (2026-04-01): Remove this block after a few days.
const TECH_NOTICE_KEY = 'idaa_tech_notice_2026_04_01_dismissed';
let show_tech_notice: boolean = $state(
browser ? localStorage.getItem(TECH_NOTICE_KEY) !== 'true' : false
);
function dismiss_tech_notice() {
show_tech_notice = false;
try { localStorage.setItem(TECH_NOTICE_KEY, 'true'); } catch { /* storage unavailable */ }
}
let verifying_timed_out: boolean = $state(false);
$effect(() => {
if (novi_verifying) {
const id = setTimeout(() => {
verifying_timed_out = true;
}, VERIFY_TIMEOUT_MS);
return () => clearTimeout(id);
} else {
verifying_timed_out = false;
}
});
const VERIFIED_TTL_MS_DEFAULT = 5 * 60 * 1000; // 5 minutes
// Effect 1: Set URL origin and params
$effect(() => {
untrack(() => {
$ae_loc.url_origin = data.url.origin;
$ae_loc.params = data.params;
});
if (log_lvl > 1) {
console.log(`+layout.svelte data:`, data);
}
});
// Effect 2: Novi UUID verification
// Reactive dependencies (read outside untrack):
// - $ae_loc.site_cfg_json: API key arrives async via SWR; re-run when config loads.
// - url_uuid ($derived from $page.url): re-run when URL UUID changes (SvelteKit navigation).
// Everything else is inside untrack() to prevent reactive loops from store writes.
$effect(() => {
if (!browser) return;
const site_cfg = $ae_loc.site_cfg_json || {};
const api_key: string | null = site_cfg.novi_idaa_api_key ?? null;
const api_root: string = site_cfg.novi_api_root_url ?? 'https://www.idaa.org/api';
const admin_li: string[] = site_cfg.novi_admin_li ?? [];
const trusted_li: string[] = site_cfg.novi_trusted_li ?? [];
const ttl_ms: number = site_cfg.novi_verified_ttl_ms ?? VERIFIED_TTL_MS_DEFAULT;
// Read url_uuid here (outside untrack) to create a reactive dependency.
// Effect 2 re-runs whenever the UUID in the URL changes.
const current_uuid = url_uuid;
untrack(() => {
if (!current_uuid) {
// No UUID in URL. Two possible cases:
//
// 1. Non-Novi path (user/pass or shared passcode sign-in) — clear and deny.
//
// 2. Internal SvelteKit navigation within the iframe (e.g. clicking "Meeting Details"
// from the list page). The UUID was on the initial iframe load URL but is NOT
// carried forward on internal <a href> links — they only contain the path/event_id.
// In this case the user has a valid TTL-cached Novi session in $idaa_loc and we
// must NOT clear it, or every internal navigation will show "Access Denied".
//
// Distinguish the two by checking if there is an active verified session.
const now = Date.now();
const has_cached_session =
$idaa_loc.novi_verified &&
$idaa_loc.novi_uuid &&
$idaa_loc.novi_verified_ts &&
now - $idaa_loc.novi_verified_ts < ttl_ms;
if (has_cached_session) {
// Case 2: internal navigation — keep the verified session, nothing to do.
novi_verifying = false;
return;
}
// Case 1: no UUID, no cached session — non-Novi path, deny normally.
$idaa_loc.novi_verified = false;
novi_verifying = false;
return;
}
if (verify_in_flight) return;
if (verify_failed_for_uuid === current_uuid) {
// This exact UUID already definitively failed — do not retry.
// A different UUID is a clean slate; see verify_failed_for_uuid declaration.
if (log_lvl) console.log(`IDAA Layout: skipping re-verification — already failed for ${current_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();
if (
$idaa_loc.novi_verified &&
$idaa_loc.novi_uuid === current_uuid &&
$idaa_loc.novi_verified_ts &&
now - $idaa_loc.novi_verified_ts < ttl_ms
) {
if (log_lvl) console.log(`IDAA Layout: cached verification valid for ${current_uuid}`);
novi_verifying = false;
return;
}
// Load admin/trusted lists before calling verify.
// Only override when site_cfg provides them — don't wipe hardcoded defaults with [].
if (admin_li?.length) $idaa_loc.novi_admin_li = admin_li;
if (trusted_li?.length) $idaa_loc.novi_trusted_li = trusted_li;
verify_in_flight = true;
// WHY: Only show the "Verifying identity..." spinner on initial load when the user
// has NO existing valid session. If the TTL expired and we're re-verifying in the
// background (user is already authenticated), run silently — do NOT set
// novi_verifying=true. Setting it would hide children and destroy any running
// components (e.g. Jitsi mid-call). The user can keep working; access is only
// revoked in the catch block if the re-verification actively fails.
const has_valid_session =
$ae_loc.trusted_access ||
($ae_loc.authenticated_access &&
$idaa_loc.novi_verified &&
$idaa_loc.novi_uuid === current_uuid);
if (!has_valid_session) {
novi_verifying = true;
}
verify_novi_uuid(current_uuid, api_key, api_root);
});
});
/**
* Verifies a Novi UUID against the Novi API and sets permissions accordingly.
* "All or nothing" — if no API key is configured or the call fails, access is denied.
* Called from within untrack(), so store writes here will not trigger reactive loops.
* On a 429 rate-limit response, waits 10 seconds and retries once before failing.
*/
async function verify_novi_uuid(
uuid: string,
api_key: string | null,
api_root_url: string,
is_retry: boolean = false
) {
if (!api_key) {
// WHY: Do NOT clear $idaa_loc state here, and do NOT log "Starting verification".
// The api_key can be transiently null when $ae_loc.site_cfg_json hasn't finished
// loading (SWR: root layout fires $ae_loc = new_loc before cfg is in the store).
// Clearing novi_verified/novi_uuid here would destroy a valid cached session, making
// the TTL check fail on the very next Effect 2 run — causing a re-auth loop.
// If the api_key is genuinely not configured, $idaa_loc starts with
// novi_verified=false and novi_uuid=null (defaults), so access is denied naturally.
// novi_verifying is left at its current value intentionally:
// - If has_valid_session was false above, novi_verifying=true (spinner stays visible
// until api_key loads and Effect 2 re-runs with a real key).
// - If has_valid_session was true, novi_verifying=false (children keep rendering).
console.warn('IDAA Layout: Novi API key not yet available — skipping verification (will retry when site_cfg_json loads).');
verify_in_flight = false;
return;
}
console.log(`IDAA Layout: Starting Novi UUID verification for ${uuid}...`);
try {
const headers = new Headers();
headers.append('Authorization', `Basic ${api_key}`);
const response = await fetch(`${api_root_url}/customers/${uuid}`, {
method: 'GET',
headers
});
if (response.status === 429) {
if (is_retry) {
throw new Error(`Novi API rate limited for UUID ${uuid} (retry also failed)`);
}
console.warn(`IDAA Layout: Novi API rate limited (429) for ${uuid}. Retrying in 10s...`);
await new Promise<void>((resolve) => setTimeout(resolve, 10_000));
await verify_novi_uuid(uuid, api_key, api_root_url, true);
return;
}
if (!response.ok) {
throw new Error(`Novi API returned ${response.status} for UUID ${uuid}`);
}
const result = await response.json();
// Build display name: prefer "First L." format, fall back to full Name field.
const first_name = result?.FirstName ?? null;
const last_initial = result?.LastName
? `${result.LastName.charAt(0).toUpperCase()}.`
: '';
const verified_name =
first_name && last_initial
? `${first_name} ${last_initial}`
: (result?.Name ?? null);
// Normalize email — Novi occasionally includes spaces where + should be.
const verified_email = result?.Email
? 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;
$idaa_loc.novi_verified = true;
$idaa_loc.novi_verified_ts = Date.now();
console.log(
`IDAA Layout: Novi UUID verified. Name: ${verified_name}, Email: ${verified_email}`
);
// Determine permission level based on verified UUID.
// UUID confirmed real → at minimum authenticated. Check lists for higher levels.
let target_novi_level = 'authenticated';
if ($idaa_loc.novi_admin_li?.includes(uuid)) {
target_novi_level = 'administrator';
} else if ($idaa_loc.novi_trusted_li?.includes(uuid)) {
target_novi_level = 'trusted';
}
// PERMISSION UPGRADE STRATEGY: only apply if higher than current level.
// This prevents a global 'manager' from being downgraded by the IDAA layout.
const current_level = $ae_loc.access_type || 'anonymous';
if (ae_util.compare_access_levels(target_novi_level, current_level) === 1) {
console.log(
`IDAA Layout: Upgrading access from ${current_level} to ${target_novi_level} (Novi verified)`
);
const perms = ae_util.process_permission_checks(target_novi_level);
$ae_loc = { ...$ae_loc, ...perms };
}
// Reset BB query filters to safe defaults in case they were left in a non-default state.
$idaa_loc.bb.qry__hidden = 'not_hidden';
$idaa_loc.bb.qry__enabled = 'enabled';
} catch (error) {
// Verification failed — all-or-nothing means deny access.
console.error(
`IDAA Layout: Novi UUID verification failed for ${uuid}:`,
error
);
verify_failed_for_uuid = uuid; // Latch — stop retry loop for this UUID. See declaration comment.
$idaa_loc.novi_uuid = null;
$idaa_loc.novi_email = null;
$idaa_loc.novi_full_name = null;
$idaa_loc.novi_verified = false;
} finally {
verify_in_flight = false;
novi_verifying = false;
}
}
</script>
{#if !browser}
<!-- SSR / pre-hydration placeholder -->
<p class="text-center text-sm text-gray-500">
<span class="fas fa-spinner fa-spin"></span>
Loading...
</p>
{:else if novi_verifying}
<!-- Async Novi API call is in flight — show spinner to prevent Access Denied flash -->
<div
class="container m-8 flex w-full flex-col items-center justify-center gap-2 p-8">
<p class="text-center text-sm text-gray-500">
<span class="fas fa-spinner fa-spin"></span>
Verifying identity...
</p>
{#if verifying_timed_out}
<!-- Escape hatch: shown after VERIFY_TIMEOUT_MS if still spinning.
Most likely cause: stale localStorage missing novi_idaa_api_key.
Clearing both stores and reloading forces a fresh site config fetch. -->
<p class="mt-2 text-center text-xs text-gray-400">
This is taking longer than expected.
</p>
<button
type="button"
class="btn btn-sm preset-tonal-primary border-primary-500 mt-1 border"
onclick={() => {
localStorage.removeItem('ae_loc');
localStorage.removeItem('ae_idaa_loc');
location.reload();
}}>
<span class="fas fa-redo m-1"></span>
Reset &amp; Retry
</button>
{/if}
</div>
{:else if $ae_loc.trusted_access || ($ae_loc.authenticated_access && $idaa_loc.novi_uuid && $idaa_loc.novi_verified)}
<!-- TEMPORARY (2026-04-01): One-time notice about last night's technical issues. Remove after a few days. -->
<!-- {#if show_tech_notice && 1 == 3}
<div class="m-2 flex items-start gap-3 rounded-lg border border-yellow-400 bg-yellow-50 p-3 text-yellow-900 dark:border-yellow-600 dark:bg-yellow-950 dark:text-yellow-100">
<span class="fas fa-exclamation-triangle mt-0.5 shrink-0 text-yellow-500"></span>
<p class="flex-1 text-sm">
Opt 1:
<strong>Notice:</strong> We experienced technical difficulties last night (March 31) and this morning (April 1). Things are back to normal. We apologize for any inconvenience.
<br>
<br>
Opt 2:
<strong>Notice:</strong> We have experienced some technical difficulties recently. Things are back to normal. We apologize for any inconvenience.
</p>
<button
type="button"
class="shrink-0 rounded px-2 py-1 text-xs font-semibold hover:bg-yellow-200 dark:hover:bg-yellow-800 btn"
onclick={dismiss_tech_notice}>
OK
</button>
</div>
{/if} -->
{@render children?.()}
{#if $idaa_loc.novi_uuid}
<span class="text-sm text-gray-500">
Novi: <span class="fas fa-user m-1"></span>
{$idaa_loc.novi_uuid}
{$idaa_loc.novi_full_name ?? 'name not set'}
{$idaa_loc.novi_email ?? 'email not set'}
</span>
{:else}
<p class="text-center text-sm text-gray-500">
IDAA Novi UUID not found!
</p>
{/if}
{:else}
<div
class="container m-8 flex w-full flex-col items-center justify-center gap-1 p-8 font-bold">
<h1>
<span class="text-red-500">
<span class="fas fa-exclamation-triangle"></span>
Access Denied
<span class="fas fa-exclamation-triangle"></span>
</span>
</h1>
<p>You do not have access to these IDAA page.</p>
{#if $ae_loc.iframe}
In iframe mode
{/if}
{#if $idaa_loc.novi_uuid}
<span class="text-sm text-gray-500">
Novi: <span class="fas fa-user m-1"></span>
{$idaa_loc.novi_uuid}
{$idaa_loc.novi_full_name ?? 'name not set'}
{$idaa_loc.novi_email ?? 'email not set'}
</span>
{:else}
<p>IDAA Novi UUID not found!</p>
{/if}
{#if $ae_loc.iframe}
<!-- WHY: In iframe mode the Novi UUID is passed via URL param on first load.
If verification hasn't completed yet (timing race on Novi API), the user
lands on Access Denied. Reloading the iframe re-triggers verification. -->
<button
type="button"
class="btn btn-sm preset-tonal-primary border-primary-500 mt-4 border"
onclick={() => location.reload()}>
<span class="fas fa-redo m-1"></span>
Reload / Retry
</button>
<p class="mt-1 text-xs text-gray-500">
If your session just started, try reloading.
</p>
{/if}
</div>
{/if}