14 Commits

Author SHA1 Message Date
Scott Idem
71e79f032d docs(idaa): mark Access Denied root cause investigation as resolved
All 10 fixes applied and verified as of 2026-05-19. Collapsed the three
open issues into the completed checklist with commit references.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 16:06:45 -04:00
Scott Idem
53fd5e7de4 fix(idaa): increase spinner timeout to 35s, guard sessionStorage with try-catch
VERIFY_TIMEOUT_MS 8s → 35s: worst-case auto-retry cycle is 27s (12s abort +
3s wait + 12s abort). At 8s the "Reset & Retry" banner fired while the second
retry was still in flight; members who clicked it cleared their stores and
reloaded mid-attempt, landing on Access Denied. At 35s the escape hatch only
appears if verification is genuinely stuck (slow Novi server or missing api_key).

sessionStorage try-catch: iOS Safari Private Browsing and certain iframe sandbox
configs throw on sessionStorage access. Wrap setItem (onMount) and getItem
(reload_with_uuid) in try-catch so the component degrades gracefully to
location.reload() rather than crashing silently.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 16:04:28 -04:00
Scott Idem
14e84884cd refactor(idaa): rename reload_to_origin → reload_with_uuid (clearer intent)
'origin' is a reserved web term (protocol+domain); the function restores the
initial Novi-provided iframe URL (which includes the ?uuid= param), not the
site root. Renamed to reload_with_uuid to make that clear.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 15:53:18 -04:00
Scott Idem
e921ca973f fix(idaa): add Novi fetch timeout and auto-retry on network errors
- Wrap Novi API fetch in AbortController with 12s hard timeout — prevents
  verify_in_flight from getting stuck if Novi's server hangs with no response
- Auto-retry once (after 3s) on network errors (TypeError: Failed to fetch)
  and timeouts (AbortError) — these are almost always transient cellular/WiFi
  blips and previously hard-failed with no second chance
- Rate-limit retries (429) already had a 10s wait; network retry is separate
- Update status message to "Connection issue — retrying..." during network retry
- Update error panel hint to suggest closing/reopening the Novi page as last resort
- Update Access Denied hint with same guidance

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 15:29:14 -04:00
Scott Idem
2855e091f7 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>
2026-05-19 15:22:20 -04:00
Scott Idem
f37c64c68b perf(app): remove unused Google Fonts requests, add pre-JS loading spinner
- app.html: comment out 3 Google Fonts <link> tags (Noto Sans, Noto Serif,
  Roboto Mono) — no theme or component applies these families; all themes use
  system-ui. Saves 3 blocking network requests on every page load.
- app.html: add subtle CSS-only #ae_loader spinner (1.75rem ring, pointer-events:none)
  that shows during JS download + root load function, before Svelte mounts.
- +layout.svelte: add onMount to remove #ae_loader as soon as Svelte bootstraps;
  the existing is_hydrating frosted-glass overlay takes over from there.
- app.css: comment out orphaned Quicksand @font-face — font-family was never
  applied to any element so browser never fetched it anyway.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 15:02:19 -04:00
Scott Idem
615af58a11 docs(idaa): update CLIENT doc for error handling and contact search status
- Access Gate: document new verify_error_type states (rate_limited/api_error),
  retry/reset UI buttons added in the previous session
- Search Architecture: correct 'contacts not searchable' — default_qry_str already
  includes contact data; two bugs fixed 2026-05-19 (stale STORED GENERATED columns,
  frontend secondary filter dropping API-matched results). IDB fast-path gap remains.
- TODO__Agents.md: update contact search task to reflect API path now working;
  narrow remaining work to IDB fast-path only

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 15:02:09 -04:00
Scott Idem
76e21b08ff fix(idaa): remove over-filtering of API text search results in recovery meetings
The secondary client-side filter was re-checking qry_str against name, description,
location_text, and default_qry_str on the API response. This silently dropped meetings
that the API correctly matched via default_qry_str (a backend-generated combined index
containing contact name/email) — because that field is not always present in the
response body.

The API's LIKE search on default_qry_str is already exact. The secondary filter should
only correct structured dimensions (type, physical/virtual OR-logic) where the backend
uses AND logic that the frontend must compensate for. Text search is left to the API.

Root cause confirmed: STORED GENERATED columns in the event table needed a rebuild;
frontend filtering was masking results that the API returned correctly.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 12:49:52 -04:00
Scott Idem
4ada5c4a8f Lowered the timeout from 8 to 5 seconds 2026-05-19 11:33:24 -04:00
Scott Idem
8850db89c6 fix(layouts): guard appshell header/footer data stores behind account_id
element_data_store fires its load trigger as soon as api_ready is true,
with no check for account_id. In the IDAA iframe flow, the outer layout
mounts before Novi UUID verification completes, so the footer fetch fires
with no x-account-id header and gets a 403.

Wrap the IDAA outer layout footer in {#if $ae_loc.account_id} so it only
loads once the member's identity is established. Apply the same guard to
the events layout header and footer for consistency.

Journals was already safe (data stores are inside the trusted_access gate).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 11:17:24 -04:00
Scott Idem
ccacdc3f4b Add comments and less debug 2026-05-19 10:55:32 -04:00
Scott Idem
128944c7ab 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>
2026-05-19 10:46:28 -04:00
Scott Idem
c0386f27bc fix(idaa): fix name A→Z / Z→A sort not applying in API revalidation path
The RE-SORT block after API revalidation only checked for 'name' (a legacy
sort mode removed when sort_modes was introduced). 'name_asc' and 'name_desc'
fell through to the else branch and were silently re-sorted chronologically
by tmp_sort_1, overriding the user's selection. Updated to match the fast-path
IDB sort logic which already handled all three modes correctly.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 09:49:40 -04:00
Scott Idem
ee506832e7 fix(idaa): make novi_btn buttons visible in Bootstrap iframe context
Bootstrap v3 (.btn) sets border:1px solid transparent which overrides
Skeleton/Tailwind preset-outlined border classes when loaded last in the
Novi iframe. Replacing the dead border-color comments with a box-shadow
ring — Bootstrap does not reset box-shadow on .btn so it survives without
!important. Adds a darker ring + faint bg tint on hover.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-18 23:41:05 -04:00
9 changed files with 440 additions and 125 deletions

View File

@@ -286,7 +286,12 @@ This ensures that OSIT staff with `super` or `manager` roles retain full access
### Access Gate (`(idaa)/+layout.svelte`)
The inner layout blocks ALL rendering if the user is not authorized:
- `novi_verifying = true` → "Verifying identity..." spinner
- `novi_verifying = true` → "Verifying identity..." spinner (message updates during retry)
- `verify_error_type === 'rate_limited'` → yellow "Identity Verification Unavailable" panel with:
- **Try Again** — calls `handle_verify_retry()` (respects retry_count, waits 10 s before re-calling Novi)
- **Clear Cache & Reload** — clears IDB + localStorage + sessionStorage, then reloads
- **Full Reset** — same clear but also navigates to `/` with `invalidateAll`
- `verify_error_type === 'api_error'` → same yellow panel (API returned non-2xx, not a rate limit)
- Verification failed or no UUID → "Access Denied" error page
- Access check runs before any child routes render
@@ -392,18 +397,29 @@ Search uses the standard Aether SWR pattern (IDB cache returned immediately, the
### Search Architecture — What Is and Isn't Searched
The fulltext search runs against the `default_qry_str` field (backend-computed, contains:
`id_random`, type, name, description, timezone, recurring pattern/text, location text).
The fulltext search runs against the `default_qry_str` field (backend-computed STORED GENERATED
column, contains: `id_random`, type, name, description, timezone, recurring pattern/text,
location text, **contact name and email**).
**Contact names and emails are NOT currently searchable.** The `contact_li_json` field is a
JSON longtext — MariaDB cannot efficiently substring-search it directly. The backend already
has a `contact_li_json_ext` (STORED GENERATED, indexed) column to work around this, but it
has not yet been added to the searchable fields whitelist in the API.
**Contact names and emails ARE searchable via the API path.** `default_qry_str` includes
contact data, so the API `lk_qry` LIKE search on that field covers contacts automatically.
**Pending fix (tracked in TODO__Agents.md, 2026-04-08):**
- Backend: add `contact_li_json_ext` to the event object searchable fields whitelist
- Frontend: add `contact_li_json_ext` as an OR condition in `search__event()`, and update
the local IDB fast-path filter to parse `contact_li_json` for instant cache results
**IDB fast-path gap:** The local cache (Dexie) fast-path returns all cached meetings without
text filtering — users see the unfiltered list immediately, then the API result (with contacts
filtered) replaces it after the background refresh completes. The IDB path does not parse
`contact_li_json` for instant local text matching.
**Known history (2026-05-19):** Contact search appeared broken due to two issues now resolved:
1. The backend STORED GENERATED columns (`default_qry_str`, `contact_li_json_ext`) had stale
values; forced a rebuild via fake updates on each event record.
2. The recovery meetings page secondary filter was re-running text matching against response
fields — silently dropping results that matched only via `default_qry_str` (e.g. by contact
name, since that field may not appear in the response body). Fix: removed text re-filtering
from the secondary filter (type / physical / virtual OR-logic only).
**Remaining enhancement (tracked in TODO__Agents.md):**
- Add `contact_li_json_ext` to the IDB fast-path filter in `search__event()` and the recovery
meetings page so contact matches appear instantly from cache, not only after API refresh.
### My Meetings (Favorites)
@@ -821,4 +837,4 @@ ae_loc.idaa_loc = { novi_uuid: 'test-uuid-value', ... };
---
**Document Status:** ✅ Current
**Last Verified:** 2026-05-18Recovery Meetings: updated default limit to 100 and added 75 to limit stepper; added My Meetings / favorites (data_store-backed, star button on list + detail page), guided empty state for filtered zero-results, corrected filter/sort descriptions; updated `idaa_loc` and `idaa_sess` store schemas to match actual fields
**Last Verified:** 2026-05-19Access Gate: documented new `verify_error_type` error-handling states and retry/reset UI; Search Architecture: corrected contact-search status (now works via `default_qry_str` in API path — two root causes fixed 2026-05-18/19); noted IDB fast-path gap as remaining enhancement

View File

@@ -77,6 +77,40 @@ guessing defaults.
## 🚧 Upcoming High Priority
### ~~[IDAA] Random "Access Denied" — Root Cause Review & Fixes~~ ✅ Resolved (2026-05-19)
All known root causes fixed across 10+ commits to `src/routes/idaa/(idaa)/+layout.svelte`.
Deploying as of 2026-05-19. Monitor for further member reports.
#### All fixes applied
- [x] Access Denied on iframe reload (sessionStorage URL preservation) — `2855e091f`
- [x] TTL cache bypassed when `$ae_loc` auth flags reset — `2855e091f`
- [x] "Verification Unavailable" screen distinct from "Access Denied" — `2855e091f`
- [x] "Try Again" without page reload (`retry_count` pattern) — `2855e091f`
- [x] Novi TTL extended to 45 minutes (from 5) — `2855e091f` + manual edit
- [x] 12 s AbortController hard timeout on Novi fetch — `e921ca973`
- [x] Network/AbortError gets 3 s grace + one retry — `e921ca973`
- [x] Clear Cache & Reload added to Access Denied state (iframe mode) — `2855e091f`
- [x] `VERIFY_TIMEOUT_MS` 8 s → 35 s (was firing mid-retry, causing premature Reset clicks) — `53fd5e7de`
- [x] `sessionStorage` try-catch (iOS Safari Private Browsing throws on access) — `53fd5e7de`
- [x] Appshell stores guarded behind `account_id``8850db89c`
- [x] Recovery meetings over-filtering bug (API `default_qry_str`) — `76e21b08f`
- [x] A→Z sort in recovery meetings API revalidation path — `c0386f27b`
- [x] `events.event` IDB content version bump (stale cache) — previous commit
#### Root layout SWR verified safe:
The root `+layout.ts` builds `ae_loc_init` as a plain site-config object (no `authenticated_access`,
`trusted_access`, or `access_type` fields). The root layout sync effect
`$ae_loc = { ...current_loc, ...ae_acct.loc }` therefore cannot overwrite Novi-set auth flags.
Confirmed safe — this is NOT a cause of Access Denied.
#### Remaining architectural note:
The long-term fix for the coarse `$ae_loc` reactivity (Svelte 4 store) causing Effect 2 to
re-run on unrelated writes is tracked under **[Stores] Svelte 4 → Svelte 5 State Migration**
below. The TTL + `verify_in_flight` guards are the current mitigation.
---
### [Stores] Svelte 4 → Svelte 5 State Migration (prerequisite for Phase 2c)
The app uses `svelte-persisted-store` (Svelte 4 store contract) for all core persisted state
(`ae_loc`, `idaa_loc`, `ae_api`, `ae_sess`, etc.). In Svelte 5 `$effect`, reading **any field**
@@ -217,20 +251,20 @@ suddenly jumps to 0 errors, verify it's not because a bad `.d.ts` replaced a pac
- `$effect` auth guards in IDAA page components are reactivity guards (prevent spurious SWR calls on coarse `$ae_loc` writes), NOT auth-bypass guards. SvelteKit layout hierarchy already prevents child components from mounting when `(idaa)/+layout.svelte` blocks rendering.
- Doc: SvelteKit layout hierarchy security model captured in `GUIDE__SvelteKit2_Svelte5_DexieJS.md` and `BOOTSTRAP__AI_Agent_Quickstart.md` (Mistake #7).
- [ ] **[IDAA] Make `contact_li_json_ext` searchable — Recovery Meeting contact search (2026-04-08)**
Members cannot search for meetings by contact name or email. `contact_li_json` data is not
included in `default_qry_str` and MariaDB cannot substring-search a JSON longtext directly.
The `event` table already has `contact_li_json_ext` (STORED GENERATED, indexed) to work around this.
- [ ] **[IDAA] IDB fast-path contact search — Recovery Meetings (2026-04-08, updated 2026-05-19)**
**API path is now working**`default_qry_str` already includes contact name/email.
Two bugs were fixed 2026-05-19: (1) STORED GENERATED columns had stale values; forced
rebuild via fake updates. (2) Frontend secondary filter was re-checking text against
response fields, silently dropping API results that matched only via `default_qry_str`.
**Backend (blocked on this first):** Add `contact_li_json_ext` to the searchable fields
whitelist for the `event` object type — likely a one-line change in `ae_obj_types_def.py`
or the event object definition. Message sent to backend agent 2026-04-08.
**Frontend (after backend ships):**
- `src/lib/ae_events/ae_events__event.ts``search__event()`: add `contact_li_json_ext`
as an OR condition alongside `default_qry_str` when `qry_str` is present.
- `src/routes/idaa/(idaa)/recovery_meetings/+page.svelte` fast-path IDB filter: parse
`contact_li_json` and include contact names/emails in the local text match check.
**Remaining gap — IDB fast-path only:** The local cache fast-path returns all cached meetings
without text filtering; users see the unfiltered list first, then the API-filtered result
replaces it. To make contact matches appear instantly from cache:
- `src/lib/ae_events/ae_events__event.ts` → fast-path filter in `search__event()`: parse
`contact_li_json` and include contact names/emails in the local text match.
- `src/routes/idaa/(idaa)/recovery_meetings/+page.svelte` fast-path display: same filter.
Backend enhancement (`contact_li_json_ext` whitelist) is not required for this — the IDB
records already store `contact_li_json` raw JSON which can be parsed client-side.
- [ ] **[IDAA] Optimize Recovery Meetings SQL VIEW and indexes.**
The current search query can be taxing on the server. With ~150 active meetings, the view

View File

@@ -233,12 +233,14 @@ html.trusted_access #appShell {
font-display: swap;
} */
/* modern theme */
@font-face {
/* modern theme — @font-face commented out 2026-05-19: Quicksand is declared but no
CSS rule applies font-family:'Quicksand' to any element, so the browser never
fetches this file. Re-enable if a theme or component starts using it. */
/* @font-face {
font-family: 'Quicksand';
src: url('/fonts/Quicksand.ttf');
font-display: swap;
}
} */
/* :root [data-theme='modern'] { */
/* --theme-rounded-base: 20px;
@@ -916,8 +918,14 @@ img.qr_code:focus {
/* BEGIN: Overrides and fixes specific to Novi and IDAA */
.iframe .novi_btn {
border-radius: 60px;
/* border-color: hsla(0, 0%, 50%, .5); */
/* border-color: hsla(0, 0%, 0%, .15); */
/* Bootstrap v3 (.btn) sets border:1px solid transparent and wins over
Skeleton/Tailwind preset-outlined classes when loaded last. Use box-shadow
instead — Bootstrap does not set box-shadow on .btn so it cannot strip it. */
box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.35);
}
.iframe .novi_btn:hover {
box-shadow: 0 0 0 2px rgba(0, 0, 0, 0.5);
background-color: rgba(0, 0, 0, 0.04);
}
.iframe .novi_m0 {

View File

@@ -53,6 +53,8 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<!-- Google Fonts: commented out 2026-05-19 — no theme or component applies these families;
all themes use system-ui/sans-serif. Re-enable if a theme is added that references them.
<link rel="preconnect" href="https://fonts.gstatic.com" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link
@@ -64,14 +66,43 @@
<link
href="https://fonts.googleapis.com/css2?family=Roboto+Mono:ital,wght@0,100;0,200;0,300;0,400;0,500;0,600;0,700;1,100;1,200;1,300;1,400;1,500;1,600;1,700&display=swap"
rel="stylesheet" />
-->
<!-- <link href="app.css" rel="stylesheet"> -->
<!-- Pre-JS loading indicator. Removed by root +layout.svelte onMount once Svelte
bootstraps and the existing is_hydrating overlay takes over. Pointer-events:none
so it never blocks interaction if something goes wrong with the remove call. -->
<style>
#ae_loader {
position: fixed;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
z-index: 9999;
pointer-events: none;
}
#ae_loader::after {
content: '';
width: 1.75rem;
height: 1.75rem;
border-radius: 50%;
border: 2px solid rgba(120, 120, 120, 0.15);
border-top-color: rgba(120, 120, 120, 0.45);
animation: ae_loader_spin 0.75s linear infinite;
}
@keyframes ae_loader_spin {
to { transform: rotate(360deg); }
}
</style>
%sveltekit.head%
</head>
<!-- h-full w-full overflow-auto -->
<!-- overflow-x-scroll -->
<body data-sveltekit-preload-data="hover" class="h-full w-full">
<div id="ae_loader" aria-hidden="true"></div>
<div style="display: contents" class="">%sveltekit.body%</div>
</body>
</html>

View File

@@ -4,7 +4,7 @@
let log_lvl: number = 0;
// *** Import Svelte specific
import { untrack } from 'svelte';
import { onMount, untrack } from 'svelte';
import { goto, invalidateAll } from '$app/navigation';
import '../app.css';
@@ -53,6 +53,12 @@ interface Props {
let { data, children }: Props = $props();
// Remove the pre-JS loader from app.html as soon as Svelte mounts.
// The existing is_hydrating overlay takes over from here.
onMount(() => {
document.getElementById('ae_loader')?.remove();
});
// STABLE DERIVATION: Using prop data directly to avoid store loops
let ae_acct = $derived(data[data.account_id]);

View File

@@ -251,7 +251,7 @@ function clear_sess() {
class="mx-1 inline-block text-gray-500 dark:text-gray-400" />
<abbr title="Aether - Events Module" class="text-gray-500 dark:text-gray-400 font-semibold"> Æ Events </abbr>
</span>
{#if !$ae_sess?.disable_sys_header}
{#if !$ae_sess?.disable_sys_header && $ae_loc.account_id}
<Element_data_store
ds_code="hub__site__appshell_header"
ds_type="html" />
@@ -364,7 +364,7 @@ function clear_sess() {
</button>
</div>
{#if !$ae_sess?.disable_sys_footer}
{#if !$ae_sess?.disable_sys_footer && $ae_loc.account_id}
<footer
class:hidden={yTop > 300}
class:opacity-80={yTop < 250}

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
@@ -63,6 +63,50 @@ 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);
// 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.
//
@@ -83,18 +127,14 @@ if (browser) {
// 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.
const VERIFY_TIMEOUT_MS = 8000;
//
// 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;
// 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(() => {
@@ -108,7 +148,7 @@ $effect(() => {
}
});
const VERIFIED_TTL_MS_DEFAULT = 5 * 60 * 1000; // 5 minutes
const VERIFIED_TTL_MS_DEFAULT = 45 * 60 * 1000; // 45 minutes
// Effect 1: Set URL origin and params
$effect(() => {
@@ -140,6 +180,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) {
@@ -202,14 +243,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;
@@ -244,7 +288,14 @@ $effect(() => {
* Verifies a Novi UUID against the Novi API and sets permissions accordingly.
* "All or nothing" — if no API key is configured or the call fails, access is denied.
* Called from within untrack(), so store writes here will not trigger reactive loops.
* On a 429 rate-limit response, waits 10 seconds and retries once before failing.
*
* Retry policy:
* 429 (rate limited) → wait 10s, retry once.
* Network error / timeout → wait 3s, retry once.
* Anything else (4xx, 5xx, empty 200) → fail immediately.
*
* The Novi fetch is wrapped in an AbortController with a 12s hard timeout.
* Without it, a hung Novi server leaves verify_in_flight=true indefinitely.
*/
async function verify_novi_uuid(
uuid: string,
@@ -268,21 +319,36 @@ 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 {
const headers = new Headers();
headers.append('Authorization', `Basic ${api_key}`);
const response = await fetch(`${api_root_url}/customers/${uuid}`, {
method: 'GET',
headers
});
// Hard timeout: abort if Novi doesn't respond within 12 seconds.
// Prevents verify_in_flight from getting stuck on a hung server.
const abort_ctrl = new AbortController();
const abort_timeout_id = setTimeout(() => abort_ctrl.abort(), 12_000);
let response: Response;
try {
response = await fetch(`${api_root_url}/customers/${uuid}`, {
method: 'GET',
headers,
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 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 +359,12 @@ 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);
}
// NOTE: We are currently trusting that the pages in the Novi CMS are handling the actual current active member checks. It (Novi CMS page config) denies access to the page as a whole if they are not a member. 2026-05-19
// Build display name: prefer "First L." format, fall back to full Name field.
const first_name = result?.FirstName ?? null;
@@ -319,6 +391,9 @@ async function verify_novi_uuid(
);
}
// Other result fields for future use:
// Role ("Regular User"), MembershipExpires, MemberStatus ("current"), CreatedDate, LastUpdatedDate, Groups ("GroupUniqueID", "GroupName", "InheritingMember", "JoinDate")
$idaa_loc.novi_uuid = uuid;
$idaa_loc.novi_email = verified_email;
$idaa_loc.novi_full_name = verified_name;
@@ -353,12 +428,31 @@ async function verify_novi_uuid(
$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 (cellular drop,
// hotel WiFi hiccup, momentary Novi server lag).
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, api_key, api_root_url, 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; 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 +469,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 +494,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.
@@ -410,13 +515,85 @@ async function verify_novi_uuid(
db_archives.archive.clear().catch(() => {});
db_archives.content.clear().catch(() => {});
db_events.event.clear().catch(() => {});
location.reload();
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}
<p class="text-sm">
We were unable to connect to the membership directory. This is usually a temporary network 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}
@@ -442,58 +619,78 @@ async function verify_novi_uuid(
{/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_with_uuid() 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 these 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", 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}

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 ──────────────
//
@@ -84,7 +87,7 @@ let has_active_filters = $derived(
let show_cache_reset_btn = $state(false);
let cache_reset_timer: any = null;
// Starts/cancels the 8-second countdown based on no_results_no_filters.
// Starts/cancels the 7-second countdown based on no_results_no_filters.
// $effect only re-runs when the derived VALUE changes (true↔false), not on every
// store write — so the timer counts down cleanly without spurious resets.
$effect(() => {
@@ -97,7 +100,7 @@ $effect(() => {
show_cache_reset_btn = false;
cache_reset_timer = setTimeout(() => {
show_cache_reset_btn = true;
}, 8000);
}, 5000);
} else {
show_cache_reset_btn = false;
}
@@ -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) {
@@ -297,8 +301,16 @@ async function handle_search_refresh(qry_key: string) {
if (current_search_id === last_search_id) {
let api_results = results || [];
// SECONDARY FILTER: Ensure API results respect exact UI filters (handles backend broadness)
api_results = api_results.filter((ev: any) => {
// SECONDARY FILTER: Re-apply structured filters the API may handle loosely.
// WHY type/physical/virtual: the backend uses AND logic for body filters, so
// physical+virtual together would incorrectly exclude either-only meetings —
// we pass only one at a time and handle OR logic here.
// WHY NOT qry_str: the API already applied exact LIKE search on default_qry_str
// (a backend-generated combined index that includes contact name/email). Re-running
// text filtering client-side against the response fields silently drops meetings
// that matched only via default_qry_str (e.g., by contact name) because that
// field may not be present in the response body or may not duplicate the match.
api_results = api_results.filter((ev) => {
if (qry_type && ev.type !== qry_type) return false;
if (qry_physical || qry_virtual) {
let match = false;
@@ -306,27 +318,22 @@ async function handle_search_refresh(qry_key: string) {
if (qry_virtual && ev.virtual == true) match = true;
if (!match) return false;
}
if (qry_str && qry_str.length >= 3) {
const name = (ev.name ?? '').toLowerCase();
const desc = (ev.description ?? '').toLowerCase();
const loc = (ev.location_text ?? '').toLowerCase();
const dqs = (ev.default_qry_str ?? '').toLowerCase();
if (
!name.includes(qry_str) &&
!desc.includes(qry_str) &&
!loc.includes(qry_str) &&
!dqs.includes(qry_str)
)
return false;
}
return true;
});
// RE-SORT: Ensure perfect chronological order even if API puts NULLs last (Refactored 2026-02-16)
if ($idaa_loc.recovery_meetings.qry__order_by === 'name') {
// RE-SORT: Match the fast-path IDB sort so stale/NULL API ordering is corrected.
// WHY: The RE-SORT previously only checked 'name' (a legacy mode that no longer
// exists in sort_modes). 'name_asc' and 'name_desc' fell through to the else
// branch and were silently re-sorted chronologically, ignoring the user's selection.
const sort_order = $idaa_loc.recovery_meetings.qry__order_by;
if (sort_order === 'name_asc' || sort_order === 'name') {
api_results.sort((a, b) =>
(a.name ?? '').localeCompare(b.name ?? '')
);
} else if (sort_order === 'name_desc') {
api_results.sort((a, b) =>
(b.name ?? '').localeCompare(a.name ?? '')
);
} else {
api_results.sort((a, b) =>
(b.tmp_sort_1 ?? '').localeCompare(a.tmp_sort_1 ?? '')
@@ -380,7 +387,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.
@@ -398,6 +408,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 = [];
}
@@ -466,7 +477,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"
@@ -509,7 +526,7 @@ if (browser) {
class="btn btn-sm preset-tonal-warning m-auto"
onclick={handle_cache_reset}>
<span class="fas fa-sync-alt m-1"></span>
Refresh Meeting Cache
Refresh Meeting List Cache
</button>
</div>
{/if}

View File

@@ -377,14 +377,20 @@ $effect(() => {
</section>
<!-- The footer for the IDAA section of the site -->
<section
class="module_footer footer_content lg:text-md xl:text-md flex min-h-7 px-1 py-0.5 text-sm text-slate-400 transition hover:text-slate-800 sm:text-sm 2xl:text-lg"
class:ae_debug={$ae_loc?.debug}>
<Element_data_store
ds_code="hub__site__appshell_footer"
ds_type="html"
class_li="grow flex flex-row justify-between" />
</section>
<!-- WHY the account_id guard: the outer IDAA layout renders before the inner (idaa) layout
completes Novi UUID verification. Without it, element_data_store fires immediately with
no account_id in the headers → 403. The footer is IDAA-specific content so it should
only load once the member's identity is established. -->
{#if $ae_loc.account_id}
<section
class="module_footer footer_content lg:text-md xl:text-md flex min-h-7 px-1 py-0.5 text-sm text-slate-400 transition hover:text-slate-800 sm:text-sm 2xl:text-lg"
class:ae_debug={$ae_loc?.debug}>
<Element_data_store
ds_code="hub__site__appshell_footer"
ds_type="html"
class_li="grow flex flex-row justify-between" />
</section>
{/if}
</div>
<!-- </AppShell> -->