446 lines
19 KiB
Svelte
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 & 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}
|