diff --git a/documentation/PROJECT__AE_Access_Control_UX.md b/documentation/PROJECT__AE_Access_Control_UX.md
new file mode 100644
index 00000000..6eca7d2e
--- /dev/null
+++ b/documentation/PROJECT__AE_Access_Control_UX.md
@@ -0,0 +1,288 @@
+# PROJECT: Access Control UX — Session Expired & Access Denied
+
+**Status:** Planning
+**Priority:** Medium-High
+**Created:** 2026-02
+**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
+
+**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`
+
+**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
+
+**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`
+
+**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()`
+
+**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
+
+**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.
diff --git a/src/lib/ae_api/api_get_object.ts b/src/lib/ae_api/api_get_object.ts
index e8f96e52..684f014c 100644
--- a/src/lib/ae_api/api_get_object.ts
+++ b/src/lib/ae_api/api_get_object.ts
@@ -1,3 +1,5 @@
+import { browser } from '$app/environment';
+import { ae_auth_error } from '$lib/stores/ae_stores';
import type { key_val } from '$lib/stores/ae_stores';
export let temp_get_blob_percent_completed = 0;
@@ -95,8 +97,8 @@ export const get_object = async function get_object({
// Handle "Bootstrap Paradox" for unauthenticated requests
const bypass_val = merged_headers['x-no-account-id'] || merged_headers['x_no_account_id'];
- const is_valid_bypass = bypass_val === 'bypass' ||
- bypass_val === 'Nothing to See Here' ||
+ const is_valid_bypass = bypass_val === 'bypass' ||
+ bypass_val === 'Nothing to See Here' ||
params['key'] ||
bypass_val === 'direct-download';
@@ -105,7 +107,7 @@ export const get_object = async function get_object({
delete merged_headers['x-account-id'];
delete merged_headers['x_account_id'];
} else {
- // If it's a placeholder (like "No_Account_ID_Here"), just remove the bypass header
+ // If it's a placeholder (like "No_Account_ID_Here"), just remove the bypass header
// but DO NOT strip the valid Account ID.
delete merged_headers['x-no-account-id'];
delete merged_headers['x_no_account_id'];
@@ -116,7 +118,7 @@ export const get_object = async function get_object({
const prop_cleaned = prop.replaceAll('_', '-');
let value = merged_headers[prop];
if (value === null || value === undefined) continue;
-
+
if (typeof value !== 'string') {
value = JSON.stringify(value);
}
@@ -124,10 +126,10 @@ export const get_object = async function get_object({
}
// Auto-inject Authorization header if JWT is present but header is missing
- let jwt = headers_cleaned['jwt'] ||
- headers_cleaned['JWT'] ||
- api_cfg['jwt'] ||
- api_cfg['headers']?.['jwt'] ||
+ let jwt = headers_cleaned['jwt'] ||
+ headers_cleaned['JWT'] ||
+ api_cfg['jwt'] ||
+ api_cfg['headers']?.['jwt'] ||
api_cfg['headers']?.['JWT'];
// Final Fallback: Direct check of primary ae_loc key
@@ -142,7 +144,7 @@ export const get_object = async function get_object({
// Silently fail on storage read
}
}
-
+
if (jwt && !headers_cleaned['Authorization'] && !headers_cleaned['authorization']) {
headers_cleaned['Authorization'] = `Bearer ${jwt}`;
}
@@ -200,9 +202,9 @@ export const get_object = async function get_object({
if (response instanceof Error || (response && (response.name === 'TypeError' || response.name === 'AbortError'))) {
// If it was an explicit abort, definitely stop
if (response.name === 'AbortError') return false;
-
+
if (log_lvl > 1) console.log('API GET Object: Detected NetworkError or TypeError. Failing fast.');
- return false;
+ return false;
}
if (!response) {
@@ -237,7 +239,7 @@ export const get_object = async function get_object({
// FAIL FAST (Section 2D): Do not retry on Auth or Client errors (400, 401, 403, 422)
if (response.status === 400 || response.status === 401 || response.status === 403 || response.status === 422) {
if (log_lvl) console.error(`API Client Failure (${response.status}). Failing fast.`);
-
+
if (response.status === 401 || response.status === 403) {
console.warn(`AUTH DIAGNOSTICS: Headers sent for ${endpoint}:`, {
has_auth: !!headers_cleaned['Authorization'],
@@ -245,8 +247,10 @@ export const get_object = async function get_object({
has_account_id: !!headers_cleaned['x-account-id'],
jwt_preview: jwt ? `${jwt.slice(0, 8)}...` : 'MISSING'
});
+ // Signal the root layout to show the session-expired banner.
+ if (browser) ae_auth_error.set({ type: 'expired', ts: Date.now() });
}
-
+
// Structured Error Handling (V3): Attempt to get rich error metadata
let error_json: any = null;
try {
@@ -256,9 +260,9 @@ export const get_object = async function get_object({
}
if (log_lvl) console.log('The response was not ok. Structured Error Check:', error_json);
-
+
if (error_json?.meta?.details) {
- return error_json;
+ return error_json;
}
// Fallback for standard FastAPI "detail" errors
diff --git a/src/lib/ae_api/api_patch_object.ts b/src/lib/ae_api/api_patch_object.ts
index c3b5ed07..85d391ff 100644
--- a/src/lib/ae_api/api_patch_object.ts
+++ b/src/lib/ae_api/api_patch_object.ts
@@ -1,3 +1,5 @@
+import { browser } from '$app/environment';
+import { ae_auth_error } from '$lib/stores/ae_stores';
import type { key_val } from '$lib/stores/ae_stores';
/**
@@ -74,8 +76,8 @@ export const patch_object = async function patch_object({
// Handle "Bootstrap Paradox" for unauthenticated requests
const bypass_val = merged_headers['x-no-account-id'] || merged_headers['x_no_account_id'];
- const is_valid_bypass = bypass_val === 'bypass' ||
- bypass_val === 'Nothing to See Here' ||
+ const is_valid_bypass = bypass_val === 'bypass' ||
+ bypass_val === 'Nothing to See Here' ||
params['key'] ||
bypass_val === 'direct-download';
@@ -84,7 +86,7 @@ export const patch_object = async function patch_object({
delete merged_headers['x-account-id'];
delete merged_headers['x_account_id'];
} else {
- // If it's a placeholder (like "No_Account_ID_Here"), just remove the bypass header
+ // If it's a placeholder (like "No_Account_ID_Here"), just remove the bypass header
// but DO NOT strip the valid Account ID.
delete merged_headers['x-no-account-id'];
delete merged_headers['x_no_account_id'];
@@ -102,10 +104,10 @@ export const patch_object = async function patch_object({
}
// Auto-inject Authorization header if JWT is present but header is missing
- let jwt = headers_cleaned['jwt'] ||
- headers_cleaned['JWT'] ||
- api_cfg['jwt'] ||
- api_cfg['headers']?.['jwt'] ||
+ let jwt = headers_cleaned['jwt'] ||
+ headers_cleaned['JWT'] ||
+ api_cfg['jwt'] ||
+ api_cfg['headers']?.['jwt'] ||
api_cfg['headers']?.['JWT'];
// Final Fallback: Direct check of primary ae_loc key
@@ -185,7 +187,7 @@ export const patch_object = async function patch_object({
// FAIL FAST (Section 2D): Do not retry on Auth or Client errors (400, 401, 403, 422)
if (response.status === 400 || response.status === 401 || response.status === 403 || response.status === 422) {
if (log_lvl) console.error(`API Client Failure (${response.status}). Failing fast.`);
-
+
if (response.status === 401 || response.status === 403) {
console.warn(`AUTH DIAGNOSTICS (PATCH): Headers sent for ${endpoint}:`, {
has_auth: !!headers_cleaned['Authorization'],
@@ -193,6 +195,8 @@ export const patch_object = async function patch_object({
has_account_id: !!headers_cleaned['x-account-id'],
jwt_preview: jwt ? `${jwt.slice(0, 8)}...` : 'MISSING'
});
+ // Signal the root layout to show the session-expired banner.
+ if (browser) ae_auth_error.set({ type: 'expired', ts: Date.now() });
}
// Structured Error Handling (V3): Attempt to get rich error metadata
@@ -204,9 +208,9 @@ export const patch_object = async function patch_object({
}
if (log_lvl) console.log('The response was not ok. Structured Error Check:', error_json);
-
+
if (error_json?.meta?.details) {
- return error_json;
+ return error_json;
}
// Fallback for standard FastAPI "detail" errors
diff --git a/src/lib/ae_api/api_post_object.ts b/src/lib/ae_api/api_post_object.ts
index 1a829c7b..9786f364 100644
--- a/src/lib/ae_api/api_post_object.ts
+++ b/src/lib/ae_api/api_post_object.ts
@@ -1,3 +1,5 @@
+import { browser } from '$app/environment';
+import { ae_auth_error } from '$lib/stores/ae_stores';
import type { key_val } from '$lib/stores/ae_stores';
export const temp_post_blob_percent_completed = 0;
@@ -94,8 +96,8 @@ export const post_object = async function post_object({
// Handle "Bootstrap Paradox" for unauthenticated requests
const bypass_val = merged_headers['x-no-account-id'] || merged_headers['x_no_account_id'];
- const is_valid_bypass = bypass_val === 'bypass' ||
- bypass_val === 'Nothing to See Here' ||
+ const is_valid_bypass = bypass_val === 'bypass' ||
+ bypass_val === 'Nothing to See Here' ||
params['key'] ||
bypass_val === 'direct-download';
@@ -104,7 +106,7 @@ export const post_object = async function post_object({
delete merged_headers['x-account-id'];
delete merged_headers['x_account_id'];
} else {
- // If it's a placeholder (like "No_Account_ID_Here"), just remove the bypass header
+ // If it's a placeholder (like "No_Account_ID_Here"), just remove the bypass header
// but DO NOT strip the valid Account ID.
delete merged_headers['x-no-account-id'];
delete merged_headers['x_no_account_id'];
@@ -122,10 +124,10 @@ export const post_object = async function post_object({
}
// Auto-inject Authorization header if JWT is present but header is missing
- let jwt = headers_cleaned['jwt'] ||
- headers_cleaned['JWT'] ||
- api_cfg['jwt'] ||
- api_cfg['headers']?.['jwt'] ||
+ let jwt = headers_cleaned['jwt'] ||
+ headers_cleaned['JWT'] ||
+ api_cfg['jwt'] ||
+ api_cfg['headers']?.['jwt'] ||
api_cfg['headers']?.['JWT'];
// Final Fallback: Direct check of primary ae_loc key
@@ -207,7 +209,7 @@ export const post_object = async function post_object({
if (response instanceof Error || (response && (response.name === 'TypeError' || response.name === 'AbortError'))) {
if (response.name === 'AbortError') return false;
if (log_lvl > 1) console.log('API POST Object: Detected NetworkError or TypeError. Failing fast.');
- return false;
+ return false;
}
if (!response) {
@@ -231,7 +233,7 @@ export const post_object = async function post_object({
// FAIL FAST (Section 2D): Do not retry on Auth or Client errors (400, 401, 403, 422)
if (response.status === 400 || response.status === 401 || response.status === 403 || response.status === 422) {
if (log_lvl) console.error(`API Client Failure (${response.status}). Failing fast.`);
-
+
if (response.status === 401 || response.status === 403) {
console.warn(`AUTH DIAGNOSTICS (POST): Headers sent for ${endpoint}:`, {
has_auth: !!headers_cleaned['Authorization'],
@@ -239,8 +241,10 @@ export const post_object = async function post_object({
has_account_id: !!headers_cleaned['x-account-id'],
jwt_preview: jwt ? `${jwt.slice(0, 8)}...` : 'MISSING'
});
+ // Signal the root layout to show the session-expired banner.
+ if (browser) ae_auth_error.set({ type: 'expired', ts: Date.now() });
}
-
+
// Structured Error Handling (V3): Attempt to get rich error metadata
let error_json: any = null;
try {
@@ -250,9 +254,9 @@ export const post_object = async function post_object({
}
if (log_lvl) console.log('The response was not ok. Structured Error Check:', error_json);
-
+
if (error_json?.meta?.details) {
- return error_json;
+ return error_json;
}
// Fallback for standard FastAPI "detail" errors
diff --git a/src/lib/stores/ae_stores.ts b/src/lib/stores/ae_stores.ts
index 8f100460..a95305e3 100644
--- a/src/lib/stores/ae_stores.ts
+++ b/src/lib/stores/ae_stores.ts
@@ -530,6 +530,10 @@ export const slct = writable(slct_obj_template);
export const slct_trigger: any = writable(null);
// console.log(`AE Stores - Selected Trigger:`, slct_trigger);
+// Auth error signal — set by API helpers on 401/403 to trigger the session-expired banner in the root layout.
+// Only set from browser context (never SSR). 'expired' covers both 401 and 403 responses.
+export const ae_auth_error = writable<{ type: 'expired' | null; ts: number | null }>({ type: null, ts: null });
+
/* *** BEGIN *** Create time variable */
// Updated 2020
export const time = readable(new Date(), function start(set) {
diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte
index 7912d0c3..12bcbe7a 100644
--- a/src/routes/+layout.svelte
+++ b/src/routes/+layout.svelte
@@ -37,7 +37,7 @@
// *** Import Aether specific variables and functions
// import Analytics from '$lib/app_components/analytics.svelte';
- import { ae_loc, ae_sess, ae_api, slct, slct_trigger } from '$lib/stores/ae_stores';
+ import { ae_loc, ae_sess, ae_api, slct, slct_trigger, ae_auth_error } from '$lib/stores/ae_stores';
// import { events_loc, events_slct } from '$lib/stores/ae_events_stores';
// import MyClipboard from '$lib/app_components/e_app_clipboard.svelte';
@@ -272,6 +272,17 @@
return () => window.removeEventListener('message', handler);
});
+
+ // 5. SESSION EXPIRED EFFECT — watches ae_auth_error and raises the banner when the API signals 401/403.
+ // Guards on $ae_loc.jwt so that unauthenticated first-loads (no stored JWT) never trigger it —
+ // 401s are expected on first visit and should not look like a session expiry.
+ $effect(() => {
+ if ($ae_auth_error?.type === 'expired') {
+ untrack(() => {
+ if ($ae_loc?.jwt) flag_expired = true;
+ });
+ }
+ });
@@ -285,6 +296,16 @@
{/if}
+{#if browser && flag_expired}
+
+
Your session has expired. Please reload or sign in again.