Files
OSIT-AE-App-Svelte/src/routes/idaa/(idaa)/+layout.svelte
Scott Idem 3ea362c166 fix(idaa): restore site_cfg guard to prevent API call on non-IDAA domains
The server-side migration removed the old novi_idaa_api_key check, which was
also acting as an implicit 'is IDAA configured here?' guard. Without it, any
domain that resolves (including ghost/domain-not-found with account_id='ghost')
would fire the Aether endpoint and get an error response, showing 'Verification
Unavailable' over the root layout's 'Domain Not Found' message.

Restore the site_cfg.novi_idaa_api_key presence check as the first guard:
- key absent → site_cfg_json still loading OR this is not an IDAA site → skip
- account_id='ghost' → domain lookup failed → added explicit ghost guard too

The key itself is unused for auth (server holds it); we only test its presence.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 18:57:26 -04:00

687 lines
34 KiB
Svelte

<script lang="ts">
import { onMount, 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';
import { db_posts } from '$lib/ae_posts/db_posts';
import { db_archives } from '$lib/ae_archives/db_archives';
import { db_events } from '$lib/ae_events/db_events';
import { check_and_clear_idb_table } from '$lib/stores/store_versions';
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;
// Tracks the type of Novi API failure for the UI — 'rate_limited' or 'api_error'.
// A transient API error is NOT the same as a real membership denial; this lets us
// show a distinct "Verification Unavailable" state instead of "Access Denied".
let verify_error_type: 'rate_limited' | 'api_error' | 'network_error' | null = $state(null);
// Shown inside the spinner — updated during rate-limit retry waits.
let verifying_status_msg: string = $state('Verifying identity...');
// Incremented by handle_verify_retry() to re-run Effect 2 without a full page reload.
let retry_count: number = $state(0);
// In-iframe reload helper.
// After internal SvelteKit navigation the UUID is stripped from the URL — a bare
// location.reload() would reload without it, so verification can't run and the user
// sees "Access Denied" again. We save the initial load URL (which contains the UUID)
// to sessionStorage on first mount and use it for all reload buttons.
// sessionStorage is per-tab and cross-origin-isolated, so each Novi iframe instance
// gets its own slot; it clears naturally when Novi closes/reopens the iframe.
const IDAA_IFRAME_RELOAD_URL_KEY = 'idaa_iframe_reload_url';
onMount(() => {
const uuid_in_url = new URLSearchParams(window.location.search).get('uuid');
if (uuid_in_url) {
// Guard: iOS Safari Private Browsing and some iframe sandbox configs throw on
// sessionStorage access. Graceful fallback: skip the save; reload_with_uuid()
// will fall back to location.reload() (loses UUID-preservation but doesn't crash).
try {
if (!sessionStorage.getItem(IDAA_IFRAME_RELOAD_URL_KEY)) {
sessionStorage.setItem(IDAA_IFRAME_RELOAD_URL_KEY, window.location.href);
}
} catch {
console.warn('IDAA Layout: sessionStorage unavailable — reload buttons will use location.reload() fallback.');
}
}
});
function reload_with_uuid() {
try {
const initial_url = sessionStorage.getItem(IDAA_IFRAME_RELOAD_URL_KEY);
if (initial_url && initial_url !== location.href) {
location.href = initial_url;
return;
}
} catch { /* sessionStorage unavailable — fall through to location.reload() */ }
location.reload();
}
// Clear stale db_events.event IDB data on IDAA session start.
//
// WHY: Stale cached event records were the root cause of the "no meetings found" bug
// on the IDAA Recovery Meetings page — a ~1-year unresolved issue (fixed 2026-05-16).
// After a deploy that changed properties_to_save, old IDB records persisted with missing
// or wrong fields. The search fast path returned 0 results (failed account_id filter),
// the API call errored silently, and the error state showed the same message as a real
// empty result — users had no indication anything was wrong.
//
// This runs once per IDAA session. On a version match (normal case) it costs one
// localStorage read. On a mismatch it clears the table; the SWR search re-fetches.
// To force a clear after a deploy: bump IDB_CONTENT_VERSIONS.events.event in store_versions.ts.
if (browser) {
check_and_clear_idb_table(db_events.event, 'events', 'event').catch(() => {});
}
// 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.
//
// WHY 35s: worst-case auto-retry cycle is 27s (12s first timeout + 3s wait + 12s retry).
// If the auto-retries succeed or fail within that window, the spinner is already gone
// (content shown or error panel shown) before this fires. The escape hatch only appears
// for the rare case where the Novi API is slow-but-not-timing-out, or site_cfg_json
// never loads. Previously 8s — fired mid-flight and caused premature Reset & Retry clicks.
const VERIFY_TIMEOUT_MS = 35_000;
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;
}
});
// WHY 12 hours: members open IDAA in a hotel/conference context and should not need to
// re-verify mid-day. After our client-side Novi call is replaced by server-side (FastAPI),
// this TTL can be tuned independently. Until then, 12h covers a full workday.
const VERIFIED_TTL_MS_DEFAULT = 12 * 60 * 60 * 1000; // 12 hours
// 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 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;
void retry_count; // Reactive dep — re-run Effect 2 when user clicks "Try Again".
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.
// BUT: only if $ae_loc also reflects active auth. If $ae_loc was reset
// externally (e.g., sign-out) while $idaa_loc retained novi_verified (within TTL),
// the state is inconsistent — fall through to Case 1 and purge.
if ($ae_loc.trusted_access || $ae_loc.authenticated_access) {
novi_verifying = false;
return;
}
// Inconsistent state — $idaa_loc says verified but $ae_loc has no auth.
// Fall through to Case 1 below to purge stale IDB data.
console.warn('IDAA Layout: has_cached_session but no auth in $ae_loc — purging (inconsistent state).');
}
// Case 3: no UUID, no cached Novi session, but user has trusted/manager access.
// They are legitimately authenticated via a non-Novi path — do NOT purge.
if ($ae_loc.trusted_access) {
novi_verifying = false;
return;
}
// Case 1: no UUID, no cached session, no elevated access — anonymous user.
// Purge any IDAA data that may have been cached from a previous session.
$idaa_loc.novi_verified = false;
novi_verifying = false;
console.log('IDAA Layout: No UUID / no session — purging IDAA IDB tables (posts, archives, events).');
db_posts.post.clear().catch(() => {});
db_posts.comment.clear().catch(() => {});
db_archives.archive.clear().catch(() => {});
db_archives.content.clear().catch(() => {});
db_events.event.clear().catch(() => {});
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 AND $ae_loc still has permissions.
// Without the permission check: if $ae_loc resets (e.g. browser restart while
// $idaa_loc TTL is still valid), verification is skipped and the user hits Access Denied
// because $ae_loc.authenticated_access is false. Re-running verify fixes both.
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 &&
($ae_loc.trusted_access || $ae_loc.authenticated_access)
) {
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);
});
});
/**
* Verifies a Novi UUID via the Aether server-side proxy endpoint.
* The backend calls Novi server-to-server, eliminating browser IP exposure (hotel WiFi,
* VPN blocks, Cloudflare IP reputation) that caused false "Access Denied" for some members.
*
* Aether endpoint: GET /v3/action/idaa/novi_member/{uuid}
* Response codes:
* 200 → verified — data.full_name and data.email populated
* 404 → not a member (or Novi returned empty 200, handled server-side) → deny
* 429 → Novi rate limited → wait 10s, retry once
* 503 → Novi unreachable / Novi 5xx → show api_error UI
* Network error / AbortError → wait 3s, retry once
*/
async function verify_novi_uuid(uuid: string, is_retry: boolean = false) {
// WHY: Check presence of novi_idaa_api_key to detect whether this site has IDAA
// configured. The key itself is no longer used for auth (the Aether server holds it),
// but its absence means either (a) this is not the IDAA site / dev domain, or (b)
// site_cfg_json hasn't finished loading yet. In both cases: skip and wait for the
// next Effect 2 trigger. This also guards the 'ghost' account_id state (domain-not-found
// fallback gives site_cfg_json = {}) — prevents a spurious API call with a bad account_id.
const site_cfg = $ae_loc.site_cfg_json || {};
if (!site_cfg.novi_idaa_api_key) {
console.warn('IDAA Layout: Novi not configured for this site (or site_cfg_json still loading) — skipping verification.');
verify_in_flight = false;
return;
}
const account_id = $ae_loc.account_id;
const api_key = $ae_api.api_secret_key;
const api_url = $ae_api.base_url;
if (!account_id || account_id === 'ghost' || !api_key || !api_url) {
// Aether config not yet available. Do not clear $idaa_loc: that would destroy a
// valid cached session and cause a re-auth loop on the next Effect 2 run.
console.warn('IDAA Layout: Aether API config not yet available — skipping verification (will retry when config loads).');
verify_in_flight = false;
return;
}
verifying_status_msg = 'Verifying identity...';
verify_error_type = null;
console.log(`IDAA Layout: Starting Novi UUID verification (server-side) for ${uuid}...`);
try {
// Hard timeout: abort if the Aether endpoint doesn't respond within 12 seconds.
const abort_ctrl = new AbortController();
const abort_timeout_id = setTimeout(() => abort_ctrl.abort(), 12_000);
let response: Response;
try {
response = await fetch(`${api_url}/v3/action/idaa/novi_member/${uuid}`, {
method: 'GET',
headers: {
'x-aether-api-key': api_key,
'x-account-id': account_id
},
signal: abort_ctrl.signal
});
} finally {
clearTimeout(abort_timeout_id);
}
if (response.status === 429) {
if (is_retry) {
verify_error_type = 'rate_limited';
throw new Error(`Novi verification rate limited for UUID ${uuid} (retry also failed)`);
}
console.warn(`IDAA Layout: Rate limited (429) for ${uuid}. Retrying in 10s...`);
verifying_status_msg = 'High traffic — retrying in 10 seconds...';
await new Promise<void>((resolve) => setTimeout(resolve, 10_000));
await verify_novi_uuid(uuid, true);
return;
}
if (!response.ok) {
// 404 = not a member (Novi 404, or Novi empty-200 anti-pattern handled server-side).
// 503 = Novi unreachable / Novi 5xx — show api_error UI so user can retry.
throw new Error(`Novi verification returned ${response.status} for UUID ${uuid}`);
}
const result = await response.json();
if (log_lvl > 1) {
console.log(`IDAA Layout: Novi verification response for ${uuid}:`, result);
}
const verified_name: string | null = result?.data?.full_name ?? null;
const verified_email: string | null = result?.data?.email ?? null;
if (!result?.data?.verified || (!verified_name && !verified_email)) {
throw new Error(`Novi verification returned 200 but no identity data for UUID ${uuid}`);
}
$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 (server-side). 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) {
// Network errors (TypeError: Failed to fetch) and AbortError (our 12s timeout) get one
// automatic retry after a short wait — they are almost always transient.
const is_network_or_timeout =
error instanceof TypeError ||
(error instanceof Error && error.name === 'AbortError');
if (is_network_or_timeout && !is_retry) {
const reason = error instanceof Error && error.name === 'AbortError'
? 'timed out (no response in 12s)'
: 'network error';
console.warn(`IDAA Layout: Novi verification ${reason} for ${uuid}. Retrying in 3s...`);
verifying_status_msg = 'Connection issue — retrying...';
await new Promise<void>((resolve) => setTimeout(resolve, 3_000));
await verify_novi_uuid(uuid, true);
return;
}
// 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.
// 'rate_limited' is set before throw for 429.
// 'network_error' for persistent network/timeout failures after retry.
// 'api_error' for all other failures (404 non-member, 503 Novi down, etc.).
if (!verify_error_type) {
verify_error_type = is_network_or_timeout ? 'network_error' : 'api_error';
}
$idaa_loc.novi_uuid = null;
$idaa_loc.novi_email = null;
$idaa_loc.novi_full_name = null;
$idaa_loc.novi_verified = false;
// Purge private IDAA data from IDB — do not leave sensitive data cached after auth failure.
console.log('IDAA Layout: Novi auth failed — purging IDAA IDB tables (posts, archives, events).');
db_posts.post.clear().catch(() => {});
db_posts.comment.clear().catch(() => {});
db_archives.archive.clear().catch(() => {});
db_archives.content.clear().catch(() => {});
db_events.event.clear().catch(() => {});
} finally {
verify_in_flight = false;
novi_verifying = false;
}
}
/**
* Clears the verification failure latch and forces Effect 2 to re-run without a full page reload.
* Called by the "Try Again" button in the verification-error UI state.
*/
function handle_verify_retry() {
verify_error_type = null;
verify_failed_for_uuid = null;
verifying_timed_out = false;
novi_verifying = true;
retry_count++;
}
</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_status_msg}
</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');
console.log('IDAA Layout: Reset & Retry — purging IDAA IDB tables (posts, archives, events).');
db_posts.post.clear().catch(() => {});
db_posts.comment.clear().catch(() => {});
db_archives.archive.clear().catch(() => {});
db_archives.content.clear().catch(() => {});
db_events.event.clear().catch(() => {});
reload_with_uuid();
}}>
<span class="fas fa-redo m-1"></span>
Reset &amp; Retry
</button>
{/if}
</div>
{:else if verify_error_type}
<!-- Verification error — distinct from real access denial.
A transient Novi API failure (network, 5xx, rate limit) should NOT look identical
to a real "your UUID is not a member" denial. Provides Try Again without a reload,
plus aggressive Clear Cache and Full Reset paths for persistent issues. -->
<div class="container m-8 flex w-full flex-col items-center justify-center gap-3 p-8 text-center">
<h1 class="font-bold">
<span class="text-yellow-500 dark:text-yellow-400">
<span class="fas fa-exclamation-circle mr-1"></span>
Identity Verification Unavailable
</span>
</h1>
{#if verify_error_type === 'rate_limited'}
<p class="text-sm">
The membership directory is temporarily busy (rate limited). Please wait a moment and try again.
</p>
{:else if verify_error_type === 'network_error'}
<p class="text-sm">
We were unable to reach the membership directory after two attempts. This is usually caused by a network filter blocking the connection — it is not a problem with your membership.
</p>
<ul class="mt-1 list-disc pl-5 text-left text-sm">
<li>Turn off your VPN if one is running</li>
<li>Switch from hotel or conference WiFi to your phone's cellular data</li>
<li>Try a different network (mobile data, home WiFi)</li>
<li>If on a corporate or hospital network, try from a personal device</li>
</ul>
{:else}
<p class="text-sm">
We were unable to connect to the membership directory. This is usually a temporary issue — it is not a problem with your membership or access.
</p>
{/if}
<p class="text-xs italic text-gray-500">
Try "Try Again" first. If the problem persists, use "Clear Cache &amp; Reload".
If nothing works, close this page and reopen IDAA from the menu on idaa.org.
</p>
<div class="flex flex-row flex-wrap items-center justify-center gap-2">
<!-- Try Again: unlatches verify_failed_for_uuid and re-runs Effect 2 — no reload needed -->
<button
type="button"
onclick={handle_verify_retry}
class="btn btn-sm preset-tonal-primary border-primary-500 border">
<span class="fas fa-redo m-1"></span>
Try Again
</button>
<!-- Clear Cache & Reload: wipes IDAA-specific localStorage + IDB, then reloads -->
<button
type="button"
onclick={() => {
localStorage.removeItem('ae_idaa_loc');
console.log('IDAA Layout: Clear Cache & Reload — purging IDAA IDB tables.');
db_posts.post.clear().catch(() => {});
db_posts.comment.clear().catch(() => {});
db_archives.archive.clear().catch(() => {});
db_archives.content.clear().catch(() => {});
db_events.event.clear().catch(() => {});
reload_with_uuid();
}}
class="btn btn-sm preset-tonal-surface preset-outlined-warning-100-900 hover:preset-filled-warning-200-800 transition-all">
<span class="fas fa-sync-alt m-1"></span>
Clear Cache &amp; Reload
</button>
<!-- Full Reset: enumerates and deletes all IDB + clears all storage + reloads -->
<button
type="button"
onclick={async () => {
if (!confirm('FULL RESET: Delete all caches and local storage, then reload?')) return;
const db_list = await indexedDB.databases();
for (const db of db_list) {
if (db.name) indexedDB.deleteDatabase(db.name);
}
localStorage.clear();
sessionStorage.clear();
// sessionStorage was just cleared, so reload_with_uuid() falls back to
// location.reload() — that's correct since this is a full wipe.
location.reload();
}}
class="btn btn-sm preset-tonal-surface preset-outlined-error-100-900 hover:preset-filled-error-200-800 transition-all">
<span class="fas fa-trash m-1"></span>
Full Reset
</button>
</div>
</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}
<p class="text-center text-xs text-gray-500">
Novi: {$idaa_loc.novi_uuid} · {$idaa_loc.novi_full_name ?? 'name not set'} · {$idaa_loc.novi_email ?? 'email not set'}
</p>
{/if}
{:else}
<!-- Access Denied — shown only when verification is not in flight (novi_verifying=false),
no API error (verify_error_type=null), and $ae_loc has no auth. Most common causes:
(1) No UUID in URL and no cached session — genuine denial.
(2) Timing race on first load — UUID arrives but $ae_loc not yet populated.
(3) $ae_loc reset while $idaa_loc TTL cache was still valid (fixed via TTL+perms check).
In iframe context the UUID is only on the initial Novi-provided URL, not on
subsequent SvelteKit client-side navigations — reload_with_uuid() restores it. -->
<div
class="container m-8 flex w-full flex-col items-center justify-center gap-3 p-8 text-center">
<h1 class="font-bold">
<span class="text-red-500">
<span class="fas fa-exclamation-triangle"></span>
Access Denied
<span class="fas fa-exclamation-triangle"></span>
</span>
</h1>
<p class="text-sm">You do not have access to this IDAA page.</p>
{#if $ae_loc.iframe}
<p class="text-xs italic text-gray-500">
If you just opened this page, try reloading. If the problem persists, try
"Clear Cache &amp; Reload", or close this page and reopen IDAA from the menu on idaa.org.
</p>
<div class="flex flex-row flex-wrap items-center justify-center gap-2">
<!-- WHY: In iframe mode the Novi UUID is passed via URL param on first load.
If verification was a timing race the user lands here. reload_with_uuid()
restores the original URL (with UUID) — plain location.reload() would
reload the current SvelteKit path which may not have the UUID. -->
<button
type="button"
class="btn btn-sm preset-tonal-primary border-primary-500 border"
onclick={reload_with_uuid}>
<span class="fas fa-redo m-1"></span>
Reload / Retry
</button>
<button
type="button"
onclick={() => {
localStorage.removeItem('ae_idaa_loc');
db_posts.post.clear().catch(() => {});
db_posts.comment.clear().catch(() => {});
db_archives.archive.clear().catch(() => {});
db_archives.content.clear().catch(() => {});
db_events.event.clear().catch(() => {});
reload_with_uuid();
}}
class="btn btn-sm preset-tonal-surface preset-outlined-warning-100-900 hover:preset-filled-warning-200-800 transition-all">
<span class="fas fa-sync-alt m-1"></span>
Clear Cache &amp; Reload
</button>
</div>
{/if}
{#if $ae_loc.edit_mode}
<div class="mt-2 rounded border border-gray-300 bg-gray-50 p-2 text-left text-xs text-gray-500 dark:border-gray-700 dark:bg-gray-900">
<p class="font-mono">Debug (edit mode only)</p>
{#if $idaa_loc.novi_uuid}
<p>UUID: {$idaa_loc.novi_uuid}</p>
<p>Name: {$idaa_loc.novi_full_name ?? 'not set'}</p>
<p>Email: {$idaa_loc.novi_email ?? 'not set'}</p>
{:else}
<p>No Novi UUID in store</p>
{/if}
<p>iframe: {$ae_loc.iframe}</p>
<p>authenticated_access: {$ae_loc.authenticated_access}</p>
<p>trusted_access: {$ae_loc.trusted_access}</p>
</div>
{/if}
</div>
{/if}