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>
687 lines
34 KiB
Svelte
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 & 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 & 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 & 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 & 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 & 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}
|