feat(idaa): improve error handling for Novi verification failures and network errors

Distinguish transient API failures (rate limits, server errors, network drops) from
real membership denials, so members see actionable messages instead of 'Access Denied.'

Layout: new verify_error_type state ('rate_limited' | 'api_error') surfaces a
yellow 'Identity Verification Unavailable' banner with three recovery options --
Try Again (no reload, clears latch), Clear Cache and Reload, and Full Reset.
Spinner now shows live status messages (e.g. 'High traffic - retrying in 10 seconds...').

Recovery meetings page: qry_error_detail distinguishes network drops (TypeError /
ERR_NETWORK_CHANGED) from server errors, showing specific guidance in the error UI.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Scott Idem
2026-05-19 10:46:28 -04:00
parent c0386f27bc
commit 128944c7ab
2 changed files with 117 additions and 4 deletions

View File

@@ -63,6 +63,14 @@ let verify_in_flight = false;
// 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' | 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);
// Clear stale db_events.event IDB data on IDAA session start.
//
@@ -140,6 +148,7 @@ $effect(() => {
// 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) {
@@ -268,6 +277,8 @@ async function verify_novi_uuid(
verify_in_flight = false;
return;
}
verifying_status_msg = 'Verifying identity...';
verify_error_type = null;
console.log(`IDAA Layout: Starting Novi UUID verification for ${uuid}...`);
try {
@@ -280,9 +291,11 @@ async function verify_novi_uuid(
if (response.status === 429) {
if (is_retry) {
verify_error_type = 'rate_limited';
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...`);
verifying_status_msg = 'High traffic — retrying in 10 seconds...';
await new Promise<void>((resolve) => setTimeout(resolve, 10_000));
await verify_novi_uuid(uuid, api_key, api_root_url, true);
return;
@@ -293,6 +306,10 @@ async function verify_novi_uuid(
}
const result = await response.json();
log_lvl = 2;
if (log_lvl > 1) {
console.log(`IDAA Layout: Novi API response for ${uuid}:`, result);
}
// Build display name: prefer "First L." format, fall back to full Name field.
const first_name = result?.FirstName ?? null;
@@ -359,6 +376,8 @@ async function verify_novi_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; everything else is an unexpected API error.
if (!verify_error_type) verify_error_type = 'api_error';
$idaa_loc.novi_uuid = null;
$idaa_loc.novi_email = null;
$idaa_loc.novi_full_name = null;
@@ -375,6 +394,17 @@ async function verify_novi_uuid(
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}
@@ -389,7 +419,7 @@ async function verify_novi_uuid(
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...
{verifying_status_msg}
</p>
{#if verifying_timed_out}
<!-- Escape hatch: shown after VERIFY_TIMEOUT_MS if still spinning.
@@ -417,6 +447,75 @@ async function verify_novi_uuid(
</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}
<p class="text-sm">
We were unable to connect to the membership directory. This is likely a temporary issue — it may not be a problem with your membership or access.
</p>
{/if}
<p class="text-xs italic text-gray-500">
If this keeps happening, try "Clear Cache &amp; Reload" or contact technical support.
</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(() => {});
location.reload();
}}
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();
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}
@@ -463,7 +562,7 @@ async function verify_novi_uuid(
<span class="fas fa-exclamation-triangle"></span>
</span>
</h1>
<p>You do not have access to these IDAA page.</p>
<p>You do not have access to this IDAA page.</p>
{#if $ae_loc.iframe}
In iframe mode

View File

@@ -47,6 +47,9 @@ let last_executed_key = '';
// with a visible retry button — NOT the same "No meetings found" message a real empty
// result would show. (Conflating the two was a key reason the bug went unsolved so long.)
let auto_retry_count = 0;
// Tracks the category of the last search error for a more informative user message.
// ERR_NETWORK_CHANGED and other fetch failures surface as TypeError in JS.
let qry_error_detail: 'network' | 'server' | null = $state(null);
// ── Escape-hatch: cache-reset button after suspicious zero-result delay ──────────────
//
@@ -188,6 +191,7 @@ async function handle_search_refresh(qry_key: string) {
);
$idaa_sess.recovery_meetings.qry__status = 'loading';
qry_error_detail = null;
// If 'Remote First' is toggled (Admin only), we clear results immediately to show fresh state.
if (remote_first) {
@@ -388,7 +392,10 @@ async function handle_search_refresh(qry_key: string) {
}
} catch (error) {
if (current_search_id === last_search_id) {
console.error('Revalidation failed:', error);
// TypeError = network-level failure (ERR_NETWORK_CHANGED, offline, DNS failure).
// These are transient by nature; show a different message than a server error.
const is_network_err = error instanceof TypeError;
console.error('Revalidation failed:', is_network_err ? `Network error: ${error}` : error);
if (auto_retry_count < 1) {
// First failure — schedule one silent retry before surfacing the error.
@@ -406,6 +413,7 @@ async function handle_search_refresh(qry_key: string) {
// is not the same as a genuinely empty result. Conflating the two was why
// the "no meetings found" bug went undiagnosed for ~1 year — staff and users
// saw what looked like an empty list and assumed no data, not a failure.
qry_error_detail = is_network_err ? 'network' : 'server';
$idaa_sess.recovery_meetings.qry__status = 'error';
event_id_li = [];
}
@@ -474,7 +482,13 @@ if (browser) {
{:else if $idaa_sess.recovery_meetings.qry__status === 'error'}
<div
class="ae_highlight ae_padding_md ae_row ae_flex_justify_center flex-col gap-2 text-center">
<p>Unable to load meetings. Please try again.</p>
<p>
{#if qry_error_detail === 'network'}
Network connection interrupted — please check your connection and try again.
{:else}
Unable to load meetings — server error. Please try again.
{/if}
</p>
<button
type="button"
class="btn btn-sm preset-tonal-primary m-auto"