# PROJECT: Access Control UX — Session Expired & Access Denied **Status:** Complete **Priority:** Medium-High **Created:** 2026-02 **Updated:** 2026-03-11 **Related:** `src/routes/+layout.svelte`, `src/lib/ae_api/`, `src/lib/stores/ae_stores.ts` --- ## 1. Objective Clean up inconsistent access-denied and session-expired UX across the app: 1. **Session Expired banner** — When the API returns 401/403, show a non-blocking dismissible banner in the root layout rather than silently failing. The `flag_expired` placeholder in the root layout is already wired for this but nothing sets it. 2. **Standardize Access Denied display** — Replace the one-off browser `alert()` in event settings with a proper in-page gate. Create a small reusable component for inline denial cards. 3. **Maintain intentional special cases** — IDAA Novi UUID gate and the Root Site Access Key gate are correct and must not be touched. --- ## 2. Current State Inventory ### Pattern A — Root Layout: Site Access Key Gate ✅ Working, keep as-is **File:** `src/routes/+layout.svelte` lines 130–135, 299–305 **Type:** Full-screen blocker **Logic:** If `site_access_key` is set and `allow_access` key doesn't match + user is not `trusted_access`, `flag_denied = true`. **Current UI:** Minimal full-screen `

Access Denied

` + Reload button. **Verdict:** Works correctly. The minimal styling is intentional (it's a hard site gate). Keep but no code change needed unless you want to polish the styling later. --- ### Pattern B — Root Layout: Session Expired Banner 🔴 Declared, never set **File:** `src/routes/+layout.svelte` line 63 **Variable:** `let flag_expired: boolean = $state(false)` — **never set anywhere.** **Intent:** This should show a non-blocking dismissible banner whenever the API returns 401/403, signaling a stale JWT/session. **Gap:** API helpers detect 401/403 and log diagnostic info but never fire any store event. **Fix:** See Implementation Plan step 1. --- ### Pattern C — Core Layout: Manager Access Gate ✅ Already correct **File:** `src/routes/core/+layout.svelte` lines 13–20, 62–75 **Logic:** - `onMount`: 500ms delay then `goto('/')` if still not `manager_access` (handles slow hydration) - `{:else}` block: Shows a styled "Access Restricted" card with Lock icon, description, "Return Home" button while waiting/denied **Verdict:** This pattern is correct and consistent. The `{:else}` visual gate prevents flashing. The delayed redirect is a graceful fallback. **No changes needed.** --- ### Pattern D — IDAA Layout: Novi UUID Gate ✅ Intentionally custom, keep as-is **File:** `src/routes/idaa/(idaa)/+layout.svelte` **Logic:** Async POST to `https://www.idaa.org/api` to verify Novi UUID. `novi_verifying` flag prevents Access Denied flash during network round-trip. **Verdict:** Intentionally custom to IDAA's member verification flow. **Do not standardize or touch this.** --- ### Pattern E — Event Settings: browser `alert()` 🟡 Needs fix **File:** `src/routes/events/[event_id]/settings/+page.svelte` lines 44–47 **Current code:** ```ts if (!$ae_loc.administrator_access) { if (browser) { alert('Access Denied: Administrative privileges and Edit Mode required.'); goto(`/events/${event_id}`); } } ``` **Problems:** 1. `alert()` is a blocking browser dialog — ugly, inconsistent with app UX 2. Runs in module-level `if` block (not `onMount`) — can fire before hydration is fully complete 3. No visual component shown; just redirects **Fix:** Remove `alert()`, move check into `onMount` with a small delay (like `/core` pattern), or add an inline gate using a reusable component. --- ### Pattern F — Badge Review: Inline "Access Denied" Card 🟡 Acceptable, minor polish **File:** `src/routes/events/[event_id]/(badges)/badges/[badge_id]/review/+page.svelte` lines 315–330 **Context:** Passcode check failure — attendee entered wrong passcode **Current UI:** ```html

Access Denied

{passcode_error}

``` **Verdict:** Contextually appropriate and functional. The "Try Again" button is good UX. This is a prime candidate to be replaced with a reusable component once one exists, but it is not broken. --- ### Pattern G — API Helpers: 401/403 Detection Without UI Feedback 🔴 Gap **Files:** `src/lib/ae_api/api_get_object.ts`, `api_post_object.ts`, `api_patch_object.ts` (all ~line 237) **Current behavior:** Logs auth diagnostics to console, returns `false` or `null`. No store event fired. **Gap:** When a JWT expires mid-session, the user sees requests silently fail (data doesn't load/save) with no explanation. They may think the app broke. **Fix:** On 401/403, set `ae_auth_error` store → root layout watches it and sets `flag_expired = true`. --- ### Pattern H — Presenter Auth (`auth__person`) — Existing system, no UX issues to fix now **Store:** `$events_loc.auth__person` — stores authenticated presenter identity **URL params:** `?person_id=...&person_pass=...&presentation_id=...&presenter_id=...` **Anonymous toggle:** Per-event config allows presenters to upload files without signing in **Verdict:** Auth system is working. The gating UI in the presenter pages is contextually managed. Not in scope for this cleanup. Revisit when building out the Leads feature or a future auth refactor. --- ## 3. Issues Ranked by Priority | # | Severity | Issue | File | Fix | |---|---|---|---|---| | 1 | 🔴 High | API 401/403 silently fails — users have no feedback | `api_*.ts` | Wire to `ae_auth_error` store | | 2 | 🔴 High | `flag_expired` never set — session expired banner never shows | `+layout.svelte` | Watch `ae_auth_error`, render banner | | 3 | 🟡 Medium | `alert()` in event settings — ugly, blocking, not idiomatic | `settings/+page.svelte` | Replace with `onMount` gate + reusable component | | 4 | 🟢 Low | Badge review inline card — not reusing a component | `badges/.../review/+page.svelte` | Replace when `element_access_denied.svelte` is ready | --- ## 4. Design Decisions ### 4a. Session Expired Banner Design - **Non-blocking top bar** — similar to the existing `is_offline` and `api_unreachable` banners in the root layout - **Dismissible** — user clicks X to clear; or auto-hides after signing back in - **Message:** "Your session has expired. Please reload or sign in again." with a Reload button - **Trigger:** Any 401 or 403 from any of the three API helpers ### 4b. `ae_auth_error` Store Simple writable in `ae_stores.ts`: ```ts export const ae_auth_error = writable<{ type: 'expired' | 'denied' | null, ts: number | null }>({ type: null, ts: null }); ``` This is intentionally minimal — just enough to signal the root layout. ### 4c. Reusable `element_access_denied.svelte` A small card component for inline access denial within a page: ``` Props: - title?: string (default: "Access Denied") - message?: string (default: "You do not have permission to view this content.") - show_reload?: boolean - show_return_home?: boolean - action_label?: string (optional extra button) - on_action?: () => void ``` Location: `src/lib/elements/element_access_denied.svelte` ### 4d. Event Settings Fix The settings page check should mirror the `/core` pattern: - Move to `onMount` with 500ms grace delay - No `alert()` — if not authorized, the redirect fires silently after the delay - Add inline gate (`{:else}` block with "Access Restricted" message) if the user somehow lands here ### 4e. What We Are NOT Changing - Root Layout site access key gate — working correctly - `/core` layout — already correct - IDAA Novi UUID gate — intentionally custom - Presenter auth system (`auth__person`) — not in scope --- ## 5. Implementation Plan ### Step 1: Add `ae_auth_error` store ✅ DONE (2026-03-11) **File:** `src/lib/stores/ae_stores.ts` Add after the existing store declarations: ```ts // Auth error signal — set by API helpers on 401/403 to trigger root layout session-expired banner export const ae_auth_error = writable<{ type: 'expired' | null, ts: number | null }>({ type: null, ts: null }); ``` --- ### Step 2: Wire API helpers to `ae_auth_error` ✅ DONE (2026-03-11) **Files:** `src/lib/ae_api/api_get_object.ts`, `api_post_object.ts`, `api_patch_object.ts` (same pattern in all three) In the existing `if (response.status === 401 || response.status === 403)` block, add one line after the existing `console.warn(...)`: ```ts import { ae_auth_error } from '$lib/stores/ae_stores'; // ... ae_auth_error.set({ type: 'expired', ts: Date.now() }); ``` **Note:** Only import `ae_auth_error` — no other store changes. Do NOT import `ae_auth_error` at module level if the API helpers are used SSR-side. Use a dynamic import or guard with `browser` check if needed. --- ### Step 3: Wire `flag_expired` in root layout ✅ DONE (2026-03-11) **File:** `src/routes/+layout.svelte` Add an `$effect` that watches `$ae_auth_error` and sets `flag_expired`: ```ts $effect(() => { if ($ae_auth_error?.type === 'expired' && $ae_auth_error?.ts) { untrack(() => { flag_expired = true; }); } }); ``` Add the dismissible banner to the template (after/near the existing `is_offline` banner, in the `{#if browser && $ae_loc?.allow_access}` block): ```html {#if flag_expired}

Your session has expired. Please reload or sign in again.

{/if} ``` --- ### Step 4: Create `element_access_denied.svelte` ✅ DONE (2026-03-11) **File:** `src/lib/elements/element_access_denied.svelte` Reusable card for inline access denial. Props per design decision 4c. --- ### Step 5: Fix Event Settings `alert()` ✅ DONE (2026-03-11) **File:** `src/routes/events/[event_id]/settings/+page.svelte` Replace the module-level `if (!$ae_loc.administrator_access)` + `alert()` block with: 1. Move check into `onMount` with the same 500ms grace-delay pattern as `/core` 2. Add `{:else}` gate in the template using `element_access_denied.svelte` 3. Remove the `browser` guard (not needed inside `onMount`) --- ### Step 6 (Optional / Low Priority): Swap badge review inline card ✅ DONE (2026-03-11) **File:** `src/routes/events/[event_id]/(badges)/badges/[badge_id]/review/+page.svelte` Replace inline access denied card with `element_access_denied.svelte` once the component exists. Keep "Try Again" action via `on_action` prop. --- ## 6. Files to Modify Summary | File | Change | |---|---| | `src/lib/stores/ae_stores.ts` | Add `ae_auth_error` writable store | | `src/lib/ae_api/api_get_object.ts` | Set `ae_auth_error` on 401/403 | | `src/lib/ae_api/api_post_object.ts` | Set `ae_auth_error` on 401/403 | | `src/lib/ae_api/api_patch_object.ts` | Set `ae_auth_error` on 401/403 | | `src/routes/+layout.svelte` | Watch `ae_auth_error`, render session-expired banner | | `src/routes/events/[event_id]/settings/+page.svelte` | Remove `alert()`, fix auth gate pattern | | `src/lib/elements/element_access_denied.svelte` | **NEW** — reusable inline denial card | | `src/routes/events/[event_id]/(badges)/badges/[badge_id]/review/+page.svelte` | Swap inline card with component (low priority) | --- ## 7. Testing Notes - **Session expired banner:** Force a 401 by testing with an expired JWT or by calling an API with wrong credentials. Banner should appear. Dismiss should clear it. Reload should reload browser. - **Event settings gate:** Navigate to `/events/{id}/settings` without `administrator_access`. Should redirect without any `alert()` dialog. - **Badge review:** Enter a bad passcode — Access Denied card should appear with "Try Again". - **IDAA, /core, root site key gate:** Verify no regressions. --- ## 8. Risks & Notes - The API helpers (`api_get_object.ts` etc.) run in a module context that is imported across many components. The `ae_auth_error` store must only be imported/used inside a `browser` guard or dynamically if the helpers are ever called SSR-side. Check `src/lib/ae_core/ae_core__site.ts` (which uses these helpers during SSR hydration) — **do not set the store from SSR context.** Add `if (browser)` before the `ae_auth_error.set(...)` call. - The root layout sync effect runs `untrack()` to prevent circular store updates. The new `$effect` for `ae_auth_error` must also use `untrack()` to be safe. - `flag_expired` should NOT permanently gate the UI — it should only show a top banner. The user may have been mid-editing and should not lose their work. The current `flag_denied` full-screen block is for site key access only.