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">
import { untrack } from 'svelte';
import { onMount, untrack } from 'svelte';
let log_lvl: number = 0;
// *** 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.
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.
//
// 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.
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(() => {
@@ -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(() => {
@@ -211,14 +226,17 @@ $effect(() => {
return;
}
// TTL cache: skip if this UUID was recently verified.
// Prevents duplicate API calls when site_cfg_json updates multiple times (SWR pattern).
// 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
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;
@@ -445,7 +463,7 @@ function handle_verify_retry() {
db_archives.archive.clear().catch(() => {});
db_archives.content.clear().catch(() => {});
db_events.event.clear().catch(() => {});
location.reload();
reload_to_origin();
}}>
<span class="fas fa-redo m-1"></span>
Reset &amp; Retry
@@ -496,7 +514,7 @@ function handle_verify_retry() {
db_archives.archive.clear().catch(() => {});
db_archives.content.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">
<span class="fas fa-sync-alt m-1"></span>
@@ -513,6 +531,8 @@ function handle_verify_retry() {
}
localStorage.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();
}}
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} -->
{@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 in URL param!
<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_to_origin() restores it. -->
<div
class="container m-8 flex w-full flex-col items-center justify-center gap-1 p-8 font-bold">
<h1>
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>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}
<!-- 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 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.
If verification was a timing race the user lands here. reload_to_origin()
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_to_origin}>
<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_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}
</div>
{/if}