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

290 lines
13 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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:**
```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 315330
**Context:** Passcode check failure — attendee entered wrong passcode
**Current UI:**
```html
<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`:
```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}
<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.