Files
OSIT-AE-App-Svelte/documentation/PROJECT__AE_Access_Control_UX.md
Scott Idem c73b5a09e4 feat: add element_access_denied.svelte; use in badge review page
- New reusable element_access_denied.svelte with title, message, action props
- Badge review page: swap inline 'Access Denied' card with the component
- Project doc: all 6 steps complete, status → Complete
2026-03-11 17:06:11 -04:00

13 KiB
Raw Blame History

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 130135, 299305 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 1320, 6275 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 4447 Current code:

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 315330 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_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:

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:

// 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 variant-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:

  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.