fix(idaa): fix Access Denied on reload in iframe and extend Novi TTL to 25 min

- Add reload_to_origin(): saves initial iframe URL (with ?uuid=) to sessionStorage
  on mount; all reload buttons use it instead of bare location.reload() so the UUID
  is preserved after internal SvelteKit navigation strips it from the URL
- Fix TTL short-circuit to also check $ae_loc permissions — without this, a store
  reset (browser restart, stale localStorage) while the TTL was still valid would
  skip re-verification and fall straight to Access Denied
- Extend Novi verification TTL from 5 to 25 minutes
- Add Clear Cache & Reload option to the Access Denied state (iframe mode)
- Move Novi UUID debug info on Access Denied page to edit_mode only; UUID line
  at bottom of auth'd pages stays always visible for troubleshooting
- Remove expired temporary tech-notice variables (template block was already commented out)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Scott Idem
2026-05-19 15:22:20 -04:00
parent f37c64c68b
commit 2855e091f7

View File

@@ -1,5 +1,5 @@
<script lang="ts"> <script lang="ts">
import { untrack } from 'svelte'; import { onMount, untrack } from 'svelte';
let log_lvl: number = 0; let log_lvl: number = 0;
// *** Import Svelte specific // *** Import Svelte specific
@@ -72,6 +72,31 @@ let verifying_status_msg: string = $state('Verifying identity...');
// Incremented by handle_verify_retry() to re-run Effect 2 without a full page reload. // Incremented by handle_verify_retry() to re-run Effect 2 without a full page reload.
let retry_count: number = $state(0); 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 && !sessionStorage.getItem(IDAA_IFRAME_RELOAD_URL_KEY)) {
sessionStorage.setItem(IDAA_IFRAME_RELOAD_URL_KEY, window.location.href);
}
});
function reload_to_origin() {
const origin_url = sessionStorage.getItem(IDAA_IFRAME_RELOAD_URL_KEY);
if (origin_url && origin_url !== location.href) {
location.href = origin_url;
} else {
location.reload();
}
}
// Clear stale db_events.event IDB data on IDAA session start. // 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 // WHY: Stale cached event records were the root cause of the "no meetings found" bug
@@ -93,16 +118,6 @@ if (browser) {
// or the Novi API call hangs — the user would otherwise be stuck with no escape. // or the Novi API call hangs — the user would otherwise be stuck with no escape.
const VERIFY_TIMEOUT_MS = 8000; 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); let verifying_timed_out: boolean = $state(false);
$effect(() => { $effect(() => {
@@ -116,7 +131,7 @@ $effect(() => {
} }
}); });
const VERIFIED_TTL_MS_DEFAULT = 5 * 60 * 1000; // 5 minutes const VERIFIED_TTL_MS_DEFAULT = 45 * 60 * 1000; // 25 minutes
// Effect 1: Set URL origin and params // Effect 1: Set URL origin and params
$effect(() => { $effect(() => {
@@ -211,14 +226,17 @@ $effect(() => {
return; return;
} }
// TTL cache: skip if this UUID was recently verified. // TTL cache: skip if this UUID was recently verified AND $ae_loc still has permissions.
// Prevents duplicate API calls when site_cfg_json updates multiple times (SWR pattern). // 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(); const now = Date.now();
if ( if (
$idaa_loc.novi_verified && $idaa_loc.novi_verified &&
$idaa_loc.novi_uuid === current_uuid && $idaa_loc.novi_uuid === current_uuid &&
$idaa_loc.novi_verified_ts && $idaa_loc.novi_verified_ts &&
now - $idaa_loc.novi_verified_ts < ttl_ms 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}`); if (log_lvl) console.log(`IDAA Layout: cached verification valid for ${current_uuid}`);
novi_verifying = false; novi_verifying = false;
@@ -445,7 +463,7 @@ function handle_verify_retry() {
db_archives.archive.clear().catch(() => {}); db_archives.archive.clear().catch(() => {});
db_archives.content.clear().catch(() => {}); db_archives.content.clear().catch(() => {});
db_events.event.clear().catch(() => {}); db_events.event.clear().catch(() => {});
location.reload(); reload_to_origin();
}}> }}>
<span class="fas fa-redo m-1"></span> <span class="fas fa-redo m-1"></span>
Reset &amp; Retry Reset &amp; Retry
@@ -496,7 +514,7 @@ function handle_verify_retry() {
db_archives.archive.clear().catch(() => {}); db_archives.archive.clear().catch(() => {});
db_archives.content.clear().catch(() => {}); db_archives.content.clear().catch(() => {});
db_events.event.clear().catch(() => {}); db_events.event.clear().catch(() => {});
location.reload(); reload_to_origin();
}} }}
class="btn btn-sm preset-tonal-surface preset-outlined-warning-100-900 hover:preset-filled-warning-200-800 transition-all"> 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> <span class="fas fa-sync-alt m-1"></span>
@@ -513,6 +531,8 @@ function handle_verify_retry() {
} }
localStorage.clear(); localStorage.clear();
sessionStorage.clear(); sessionStorage.clear();
// sessionStorage was just cleared, so reload_to_origin() falls back to
// location.reload() — that's correct since this is a full wipe.
location.reload(); location.reload();
}} }}
class="btn btn-sm preset-tonal-surface preset-outlined-error-100-900 hover:preset-filled-error-200-800 transition-all"> class="btn btn-sm preset-tonal-surface preset-outlined-error-100-900 hover:preset-filled-error-200-800 transition-all">
@@ -546,58 +566,77 @@ function handle_verify_retry() {
{/if} --> {/if} -->
{@render children?.()} {@render children?.()}
{#if $idaa_loc.novi_uuid} {#if $idaa_loc.novi_uuid}
<span class="text-sm text-gray-500"> <p class="text-center text-xs text-gray-500">
Novi: <span class="fas fa-user m-1"></span> Novi: {$idaa_loc.novi_uuid} · {$idaa_loc.novi_full_name ?? 'name not set'} · {$idaa_loc.novi_email ?? 'email not set'}
{$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 in URL param!
</p> </p>
{/if} {/if}
{:else} {: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_to_origin() restores it. -->
<div <div
class="container m-8 flex w-full flex-col items-center justify-center gap-1 p-8 font-bold"> class="container m-8 flex w-full flex-col items-center justify-center gap-3 p-8 text-center">
<h1> <h1 class="font-bold">
<span class="text-red-500"> <span class="text-red-500">
<span class="fas fa-exclamation-triangle"></span> <span class="fas fa-exclamation-triangle"></span>
Access Denied Access Denied
<span class="fas fa-exclamation-triangle"></span> <span class="fas fa-exclamation-triangle"></span>
</span> </span>
</h1> </h1>
<p>You do not have access to this IDAA page.</p> <p class="text-sm">You do not have access to this 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} {#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".
</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. <!-- 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 If verification was a timing race the user lands here. reload_to_origin()
lands on Access Denied. Reloading the iframe re-triggers verification. --> restores the original URL (with UUID) — plain location.reload() would
reload the current SvelteKit path which may not have the UUID. -->
<button <button
type="button" type="button"
class="btn btn-sm preset-tonal-primary border-primary-500 mt-4 border" class="btn btn-sm preset-tonal-primary border-primary-500 border"
onclick={() => location.reload()}> onclick={reload_to_origin}>
<span class="fas fa-redo m-1"></span> <span class="fas fa-redo m-1"></span>
Reload / Retry Reload / Retry
</button> </button>
<p class="mt-1 text-xs text-gray-500"> <button
If your session just started, try reloading. type="button"
</p> 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_to_origin();
}}
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} {/if}
</div> </div>
{/if} {/if}