Archived to documentation/history/: - PROJECT__AE_Firefly_Theme_Repair_SUMMARY.md (complete) - PROJECT__AE_Pres_Mgmt_Session_view_refactor_2026-02.md (resolved 2026-02-26) - PROJECT__AE_Access_Control_UX.md (all steps done 2026-03-11) - PROJECT__AE_combined_front_back_Docker.md (complete 2026-03-10) Pre-archive housekeeping: - CLAUDE.md: removed 3 resolved active issues; replaced stale session bug doc link with Badges Task 4.0 doc - AE__Architecture.md: corrected stale "TipTap marked for removal" note — both editors are active - PROJECT__AE_Firefly_Theme_Repair_SUMMARY.md: added archival note re element_modal_v1 retirement Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
13 KiB
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:
- 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_expiredplaceholder in the root layout is already wired for this but nothing sets it. - 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. - 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 <h1>Access Denied</h1> + 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 thengoto('/')if still notmanager_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:
if (!$ae_loc.administrator_access) {
if (browser) {
alert('Access Denied: Administrative privileges and Edit Mode required.');
goto(`/events/${event_id}`);
}
}
Problems:
alert()is a blocking browser dialog — ugly, inconsistent with app UX- Runs in module-level
ifblock (notonMount) — can fire before hydration is fully complete - 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:
<div class="card p-6 space-y-4 max-w-sm">
<div class="flex items-center gap-2 text-error-500">
<h3 class="text-lg font-semibold">Access Denied</h3>
</div>
<p class="text-sm text-gray-700">{passcode_error}</p>
<button ... >Try Again</button>
</div>
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_offlineandapi_unreachablebanners 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:
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
onMountwith 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
/corelayout — 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:
// 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(...):
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:
$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):
{#if flag_expired}
<div class="fixed top-0 left-0 right-0 z-50 bg-warning-500 text-white px-4 py-2 flex items-center justify-between">
<p class="text-sm font-semibold">Your session has expired. Please reload or sign in again.</p>
<div class="flex gap-2">
<button class="btn btn-sm preset-filled-surface" onclick={() => window.location.reload()}>Reload</button>
<button class="btn btn-sm" onclick={() => { flag_expired = false; ae_auth_error.set({ type: null, ts: null }); }}>Dismiss</button>
</div>
</div>
{/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:
- Move check into
onMountwith the same 500ms grace-delay pattern as/core - Add
{:else}gate in the template usingelement_access_denied.svelte - Remove the
browserguard (not needed insideonMount)
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}/settingswithoutadministrator_access. Should redirect without anyalert()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.tsetc.) run in a module context that is imported across many components. Theae_auth_errorstore must only be imported/used inside abrowserguard or dynamically if the helpers are ever called SSR-side. Checksrc/lib/ae_core/ae_core__site.ts(which uses these helpers during SSR hydration) — do not set the store from SSR context. Addif (browser)before theae_auth_error.set(...)call. - The root layout sync effect runs
untrack()to prevent circular store updates. The new$effectforae_auth_errormust also useuntrack()to be safe. flag_expiredshould 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 currentflag_deniedfull-screen block is for site key access only.