ui(badges): layout & fit-text tweaks; improve template form controls; remove badge modals from event settings; add documentation for passcode security

This commit is contained in:
Scott Idem
2026-04-10 11:44:22 -04:00
parent c9e2284758
commit e542c55500
5 changed files with 499 additions and 155 deletions

View File

@@ -0,0 +1,382 @@
# PROJECT: Site Passcode Security — API-Verified Auth
**Last updated:** 2026-04-10
**Status:** Backend work in progress — frontend pending backend completion
**Priority:** High — passcodes for trusted/administrator access currently in localStorage plaintext
---
## Problem Statement
When a user loads the Aether frontend, the site bootstrap response includes `access_code_kv_json` — a JSON object containing all passcodes for all access levels (administrator, trusted, public, authenticated). The frontend stores this verbatim in `$ae_loc.site_access_code_kv`, which is persisted in localStorage.
**Result:** Anyone with DevTools → Application → Local Storage can see every passcode for every access level on any Aether site. For public/authenticated this is low risk, but for trusted and administrator this is a real exposure — these passcodes can grant control over event data, badge printing, edit mode, etc.
The passcode check (`handle_check_access_type_passcode` in `e_app_access_type.svelte`) is entirely local — it reads the cached values and compares directly. No API call is made. The backend already has a `/authenticate_passcode` endpoint that verifies server-side, but it needs the fixes described below before the frontend can rely on it.
### Source of Truth
`site.access_code_kv_json` is the single source of truth for all passcodes. The `v_site_domain` DB view joins this field from the site table — there is no separate copy. Both the bootstrap response and `/authenticate_passcode` read from the same data.
---
## Threat Model
| Threat | Current | After Fix |
|---|---|---|
| Attacker inspects localStorage | Sees all passcodes in plaintext | Sees a JWT (opaque, no passcode) |
| Attacker uses stolen trusted passcode | Trivial if they have localStorage access | Still possible if they enter the passcode — unavoidable |
| Attacker replays an old passcode after it changes | Works forever (cached value never refreshes) | Fails — API verifies against current DB value |
| Attacker tampers with `access_type` in localStorage | Grants apparent permission but API calls still fail | Same — `access_type` is still persisted separately |
| Passcode reuse across sessions | Works indefinitely | JWT TTL enforces session expiry per role |
| Offline / API-unavailable entry | Works (local cache) | **Blocked** — requires API to verify |
### The fundamental constraint
Passcode-based access is inherently weaker than username/password login with a hashed credential. The system's security model layers passcode access below user login, and API calls themselves are still gated by `x-aether-api-key` + `x-account-id`. The passcode primarily controls **what the frontend shows** and some API-level permission gates for trusted routes.
---
## Proposed Solution: API-Verified Passcode + JWT Session
### Core idea
1. **Never send passcodes to the client.** The frontend stops reading/storing `access_code_kv_json` from the bootstrap response.
2. **Passcode entry triggers an API call** to `/authenticate_passcode`. API verifies server-side against the DB.
3. **On success, the API returns a JWT** — the JWT contains the role, account context, and expiry.
4. **Store the JWT in `$ae_loc.jwt`** (already a field, already wired into `$ae_api`).
5. **On page reload**, check the JWT's `eat` (expires-at) claim locally (base64 decode, no signature verification needed client-side). If expired, drop to anonymous. If valid, `access_type` is already persisted in `$ae_loc`.
### Session restore on reload
- `access_type` still persists in localStorage (no change here)
- The JWT is the **proof** that the access was legitimately granted and is still valid
- On page load: decode JWT payload (base64 the middle segment), check `eat` vs `Date.now()/1000`
- If JWT expired → reset `access_type` to anonymous, clear JWT
- If JWT valid → no action needed, `access_type` is already correct
This gives session expiry without a network call on every page load.
---
## TTL Per Role — Decided
| Access Level | JWT TTL | Notes |
|---|---|---|
| `super` | 8 hours | Highest privilege |
| `manager` | 24 hours | |
| `administrator` | 48 hours | |
| `trusted` | 48 hours | Onsite staff — covers multi-day events |
| `public` | 24 hours | |
| `authenticated` | 12 hours | |
| `anonymous` | N/A | No passcode |
---
## Caching Decision
**No passcode caching.** Every passcode entry makes one API call. The JWT handles session persistence — no passcode ever touches localStorage. Performance impact is only at the moment of entry (~50150ms), which is acceptable for a once-per-session action.
---
## Backend Changes Required
**File:** `aether_api_fastapi/app/routers/api.py`
The `/authenticate_passcode` endpoint exists and is structurally correct but has four issues that must be fixed before the frontend migrates to using it.
### Fix 1: Passcode matching must use explicit priority order
**Current (wrong):**
```python
for role, code in access_codes.items(): # dict insertion order — not guaranteed
if str(code) == str(passcode):
matched_role = role
break
```
**Required:**
```python
ROLE_PRIORITY = ['super', 'manager', 'administrator', 'trusted', 'public', 'authenticated']
matched_role = None
for role in ROLE_PRIORITY:
code = access_codes.get(role)
if code and str(code) == str(passcode):
matched_role = role
break
```
This ensures that if a config mistake causes two roles to share a passcode, the higher-privilege role always wins. It also makes the intent explicit and independent of JSON storage order.
### Fix 2: JWT payload must include all six role flags
**Current (incomplete):**
```python
payload = {
'account_id': account_id_random,
'administrator': (matched_role == 'administrator'),
'manager': (matched_role == 'manager'),
'super': (matched_role == 'super'),
# trusted / public / authenticated missing
...
}
```
**Required:**
```python
payload = {
'account_id': account_id_random,
'super': (matched_role == 'super'),
'manager': (matched_role == 'manager'),
'administrator': (matched_role == 'administrator'),
'trusted': (matched_role == 'trusted'),
'public': (matched_role == 'public'),
'authenticated': (matched_role == 'authenticated'),
'json_str': json.dumps({
'auth_type': 'passcode', # distinguishes from user login JWTs
'site_id': site_id,
'role': matched_role # canonical role string — frontend uses this
})
}
```
The `auth_type: 'passcode'` marker is critical — it allows the frontend and any future backend consumers to distinguish a passcode JWT from a user login JWT.
### Fix 3: Per-role TTL
**Current:**
```python
token = sign_jwt(
secret_key=settings.JWT_KEY,
ttl=3600 * 24, # hardcoded 24h for all roles
**payload
)
```
**Required:**
```python
ROLE_TTL = {
'super': 8 * 3600, # 8 hours
'manager': 24 * 3600, # 24 hours
'administrator': 48 * 3600, # 48 hours
'trusted': 48 * 3600, # 48 hours
'public': 24 * 3600, # 24 hours
'authenticated': 12 * 3600, # 12 hours
}
token = sign_jwt(
secret_key=settings.JWT_KEY,
ttl=ROLE_TTL[matched_role],
**payload
)
```
### Fix 4: Add minimum length validation to `passcode` field
**Current:**
```python
passcode: str = Field(..., description="The passcode to verify")
```
**Required:**
```python
passcode: str = Field(..., min_length=5, description="The passcode to verify")
```
This matches the frontend's 5-character trigger and prevents empty/trivial submissions.
### Complete corrected endpoint (for reference)
```python
ROLE_PRIORITY = ['super', 'manager', 'administrator', 'trusted', 'public', 'authenticated']
ROLE_TTL = {
'super': 8 * 3600,
'manager': 24 * 3600,
'administrator': 48 * 3600,
'trusted': 48 * 3600,
'public': 24 * 3600,
'authenticated': 12 * 3600,
}
class PasscodeAuthRequest(BaseModel):
"""Request model for site-based passcode authentication."""
site_id: str = Field(..., description="Random string ID of the site")
passcode: str = Field(..., min_length=5, description="The passcode to verify")
@router.post('/authenticate_passcode', response_model=Resp_Body_Base)
async def authenticate_passcode(
auth_req: PasscodeAuthRequest,
response: Response = Response,
):
"""
Passcode-to-JWT Endpoint.
Verifies a passcode against site.access_code_kv_json (single source of truth —
v_site_domain joins from the same site record).
Returns a signed JWT with the site's account context, full role flags, and
a per-role TTL. The jwt.json_str.auth_type='passcode' field distinguishes
this token from a user login JWT.
"""
site_id = auth_req.site_id
passcode = auth_req.passcode
# 1. Look up the site record
search_data = {'id_random': site_id}
if record := sql_select(table_name='site', data=search_data):
# 2. Parse access codes
access_codes_raw = record.get('access_code_kv_json')
access_codes = {}
if access_codes_raw:
try:
access_codes = json.loads(access_codes_raw) if isinstance(access_codes_raw, str) else access_codes_raw
except Exception as e:
log.error(f"Failed to parse access_code_kv_json for site {site_id}: {e}")
# 3. Verify passcode in explicit priority order (highest privilege wins)
matched_role = None
for role in ROLE_PRIORITY:
code = access_codes.get(role)
if code and str(code) == str(passcode):
matched_role = role
break
if matched_role:
log.info(f"Auth Success: Verified '{matched_role}' passcode for site {site_id}")
# 4. Resolve account context
account_id_random = record.get('account_id_random')
if not account_id_random:
if account_id_int := record.get('account_id'):
account_id_random = get_id_random(record_id=account_id_int, table_name='account')
# 5. Mint JWT with complete role flags and per-role TTL
payload = {
'account_id': account_id_random,
'super': (matched_role == 'super'),
'manager': (matched_role == 'manager'),
'administrator': (matched_role == 'administrator'),
'trusted': (matched_role == 'trusted'),
'public': (matched_role == 'public'),
'authenticated': (matched_role == 'authenticated'),
'json_str': json.dumps({
'auth_type': 'passcode',
'site_id': site_id,
'role': matched_role
})
}
token = sign_jwt(
secret_key=settings.JWT_KEY,
ttl=ROLE_TTL[matched_role],
**payload
)
return mk_resp(
data={'jwt': token, 'account_id': account_id_random, 'role': matched_role},
response=response
)
else:
log.warning(f"Auth Failed: Invalid passcode for site {site_id}")
return mk_resp(data=False, status_code=401, response=response, status_message="Invalid passcode.")
else:
log.warning(f"Auth Failed: Site {site_id} not found.")
return mk_resp(data=False, status_code=404, response=response, status_message="Site not found.")
```
### Backend Phase 2 (follow-up — not blocking frontend)
**Remove `access_code_kv_json` from the `Site_Domain_Base` response model** (`site_domain_models.py`). This ensures passcodes are never sent to the client even if future code reads from the bootstrap. Requires confirming no other endpoint consumers rely on `access_code_kv_json` being in the base response before making this change.
---
## Frontend Changes Required
**These depend on the backend fixes above being deployed first.**
### 1a. `src/lib/app_components/e_app_access_type.svelte`
Replace `handle_check_access_type_passcode` entirely. The new version:
- Is `async`
- Adds `auth_pending: boolean = $state(false)` and `auth_error: string | null = $state(null)`
- Uses a direct `fetch` call (NOT `post_object` — avoids triggering the session-expired banner on a 401)
- On success: sets `$ae_loc.access_type = data.role`, stores `$ae_loc.jwt = data.jwt`, triggers `process_permission_check` as before
- On 401: shows inline error, clears `entered_passcode`, resets `checked_passcode = null` to allow retry
- On network error: shows inline connection error
- Clears `auth_error` when `entered_passcode` changes
API call shape:
```http
POST /authenticate_passcode
Content-Type: application/json
x-aether-api-key: <from $ae_api.headers['x-aether-api-key']>
Body: { site_id: $ae_loc.site_id, passcode: entered_passcode }
```
Add to template (near the passcode input):
```svelte
{#if auth_pending}
<Loader size="1em" class="animate-spin text-gray-400" />
{/if}
{#if auth_error}
<span class="text-error-500 text-xs">{auth_error}</span>
{/if}
```
### 1b. `src/routes/+layout.ts`
**Stop caching passcodes from bootstrap** — remove line ~394:
```ts
// ae_loc_init['site_access_code_kv'] = json_data.access_code_kv_json || {};
```
**Add passcode JWT expiry check** — after the block around line 84 where `ae_loc_json.jwt` is read, add:
```ts
// Enforce passcode JWT TTL on page load.
// Decodes the JWT payload (base64, no secret needed) and resets access to anonymous if expired.
// User login JWTs (auth_type !== 'passcode') are left untouched.
if (ae_loc_json?.jwt) {
try {
const parts = ae_loc_json.jwt.split('.');
if (parts.length === 3) {
const jwt_payload = JSON.parse(atob(parts[1]));
const json_str = typeof jwt_payload.json_str === 'string'
? JSON.parse(jwt_payload.json_str)
: jwt_payload.json_str;
if (json_str?.auth_type === 'passcode' && jwt_payload.eat < Date.now() / 1000) {
// Passcode JWT has expired — revoke access
ae_loc_json.jwt = null;
ae_loc_json.access_type = 'anonymous';
}
}
} catch {
// Malformed JWT — leave untouched, let existing handling deal with it
}
}
```
### 1c. `src/lib/stores/ae_stores__auth_loc_defaults.ts` (cleanup)
Remove `site_access_code_kv` from the `AuthLocState` interface and the `auth_loc_defaults` object. The field is unused after 1a. Confirm no other component reads from it first (current grep: only `e_app_access_type.svelte` uses it — confirmed).
---
## Migration Notes
- Users with existing localStorage will still have `site_access_code_kv` cached — this is harmless after the frontend stops reading it. No forced cache clear needed.
- Existing persisted `access_type` is unaffected — users keep their current session level until their JWT expires or they manually clear storage.
- The `$ae_loc.jwt` field is already used by the user login flow. The `auth_type: 'passcode'` marker in `json_str` ensures the expiry logic only targets passcode sessions, not user login sessions.
---
## Files Affected
| File | Repo | Change |
| --- | --- | --- |
| `app/routers/api.py` | `aether_api_fastapi` | **Backend — do first.** Priority ordering, full JWT payload, per-role TTL, min_length on passcode |
| `app/models/site_domain_models.py` | `aether_api_fastapi` | Phase 2: remove `access_code_kv_json` from public model |
| `src/lib/app_components/e_app_access_type.svelte` | `aether_app_sveltekit` | Replace local check with async API call; loading/error UI |
| `src/routes/+layout.ts` | `aether_app_sveltekit` | Stop caching passcodes; add JWT expiry check |
| `src/lib/stores/ae_stores__auth_loc_defaults.ts` | `aether_app_sveltekit` | Cleanup: remove `site_access_code_kv` |
| `documentation/AE__Permissions_and_Security.md` | `aether_app_sveltekit` | Update passcode auth section to reflect new flow |

View File

@@ -19,7 +19,7 @@
* Props:
* min — minimum font size in px (default: 16)
* max — maximum font size in px (default: 80)
* manual_size — when set, disables auto-scaling and applies this font size directly
* manual_size — when set, caps the binary search at this size (overflow-safe preferred max)
* disabled — when true, neither auto-scaling nor manual_size is applied
* height — explicit CSS height for the wrapper div (e.g. "1.5in", "3rem")
* width — explicit CSS width for the wrapper div (rarely needed; usually inherits)
@@ -37,7 +37,7 @@
* {attendee_name}
* </Element_fit_text>
*
* Example — manual override (disables auto-scale, applies fixed size):
* Example — manual cap (prefers this size, scales down if it doesn't fit):
* <Element_fit_text min={20} max={80} manual_size={font_size_name}>
* {attendee_name}
* </Element_fit_text>
@@ -49,7 +49,12 @@ import type { Snippet } from 'svelte';
interface Props {
min?: number;
max?: number;
/** When set, disables auto-scaling and applies this size directly via inline style. */
/**
* When set, caps the binary search at this size (overflow-safe preferred max).
* If the text fits at manual_size → renders at exactly manual_size.
* If manual_size is too large for the container → scales down to what fits.
* Reset to null to restore full auto-scaling.
*/
manual_size?: number | null;
/** When true, neither auto-scaling nor manual_size is applied. */
disabled?: boolean;
@@ -78,16 +83,32 @@ let {
children
}: Props = $props();
// Pass null to the action when auto-scaling should be suppressed
// Always run the action unless explicitly disabled.
// When manual_size is set, use it as the max for the binary search — the action
// still runs so the text can never overflow the container. This means manual_size
// is "no larger than X, but still container-safe":
// manual_size fits → renders at exactly manual_size (same as before)
// manual_size too big → action scales down to what fits (prevents clipping)
// manual_size=null → auto-scales up to max as normal
// Using min=1 in manual mode: the user may intentionally want sizes below the
// auto-scale minimum (e.g. a very long name that only reads well at 22px).
// The controls' own FONT_SIZE_MIN prevents comically small values.
//
// WHY NOT inline style: if we set font-size via style attr AND the action sets it
// via node.style.fontSize, the last writer wins — causing unpredictable results.
// Letting the action own font-size entirely avoids this race.
let action_params = $derived(
disabled || manual_size != null ? null : { min, max }
disabled
? null
: {
min: manual_size != null ? 1 : min,
max: manual_size != null ? manual_size : max
}
);
// Compose the final inline style.
// Priority: manual_size → height/width → extra_style (caller's additional styles)
// Compose the final inline style — font-size is NOT here; the action owns it.
let computed_style = $derived(() => {
const parts: string[] = [];
if (manual_size != null) parts.push(`font-size: ${manual_size}px`);
if (height) parts.push(`height: ${height}`);
if (width) parts.push(`width: ${width}`);
if (extra_style) parts.push(extra_style);

View File

@@ -342,10 +342,10 @@ let fit_heights = $derived.by(() => {
grp_name_title: '1.6in',
grp_name_title_flex: 'around',
name: '1.4in',
title: '0.4in',
grp_aff_loc: '.4in',
title: '0.9in',
grp_aff_loc: '1.0in',
grp_aff_loc_flex: 'end',
affiliations: '0.4in',
affiliations: '1.0in',
location: '0.0in'
};
@@ -602,19 +602,19 @@ const code_to_icon: {
<!-- *** badge_front section start *** -->
<section
class="badge_front badge_type__{effective_badge_type_code.toLowerCase()}
group relative m-0
flex max-h-[6.0in]
min-h-[6.0in]
min-w-3.5
w-[4in]
max-w-fit
flex-col
items-end justify-end gap-0
overflow-visible
p-0
text-center hover:outline-2 hover:outline-red-500/75
hover:outline-dashed
"
group relative m-0
flex max-h-[6.0in]
min-h-[6.0in]
min-w-3.5
w-[4in]
max-w-fit
flex-col
items-end justify-end gap-0
overflow-visible
p-0
text-center hover:outline-2 hover:outline-red-500/75
hover:outline-dashed
"
style="{bg_image_path
? `background-image: url('${bg_image_path}'); background-size: cover; background-position: top center; background-repeat: no-repeat;`
: ''}{demo_bg_style ? ` ${demo_bg_style}` : ''}">
@@ -669,15 +669,17 @@ const code_to_icon: {
<div
class="badge_body
m-0
flex grow
flex-col
items-center
justify-end overflow-clip
p-0 px-8 pb-1
text-black
gap-0
"
m-0 grow
min-w-full w-full max-w-fit
flex
flex-col
items-center
justify-evenly overflow-clip
mt-54
p-0 px-8 pb-1
text-black
gap-0
"
style="{body_text_color_style}">
<!--
person_name container: explicit height from fit_heights so Element_fit_text
@@ -686,12 +688,14 @@ const code_to_icon: {
-->
<div
class="person_name
m-0 flex
flex-col
gap-2
overflow-hidden p-0
hover:outline-2 hover:outline-gray-500/75 hover:outline-dashed
"
grow
w-full
m-0 flex
flex-col items-start justify-center
gap-2
overflow-hidden p-0
hover:outline-2 hover:outline-gray-500/75 hover:outline-dashed
"
style="height: {fit_heights.grp_name_title}; justify-content: {flex_justify(
fit_heights.grp_name_title_flex
)}">
@@ -703,16 +707,23 @@ const code_to_icon: {
max=80 (fills badge width for short names like "Bob")
-->
<Element_fit_text
min={36}
min={34}
max={80}
manual_size={font_size_name ?? null}
height={fit_heights.name}
class="full_name_override_all leading-none hover:bg-pink-100/50">
class="full_name_override_all
grow
leading-none hover:bg-pink-100/50
"
>
<!-- class:name_pad_short
class:name_pad_mid
class:name_pad_long -->
<div
class="full_name_override"
class:name_pad_short
class:name_pad_mid
class:name_pad_long
style="text-align: {align_name};">
{#if display_name}
@@ -733,7 +744,12 @@ const code_to_icon: {
max={38}
manual_size={font_size_title ?? null}
height={fit_heights.title}
class="professional_title leading-none hover:bg-pink-100/50">
class="professional_title
grow
leading-none
hover:bg-pink-100/50
"
>
<div style="text-align: {align_title};">{@html display_title}</div>
</Element_fit_text>
{/if}
@@ -1285,16 +1301,16 @@ const code_to_icon: {
* correctly regardless of badge template or font size.
*/
.name_pad_short {
padding-left: 18%;
/* padding-left: 18%; */
padding-right: 18%;
}
.name_pad_mid {
padding-left: 8%;
padding-right: 8%;
/* padding-left: 8%; */
padding-right: 10%;
}
.name_pad_long {
padding-left: 2%;
padding-right: 2%;
/* padding-left: 2%; */
/* padding-right: 0%; */
}
/*

View File

@@ -1,6 +1,6 @@
<script lang="ts">
import { untrack } from 'svelte';
import { Loader2 } from '@lucide/svelte';
import { ChevronDown, ChevronUp, Loader2 } from '@lucide/svelte';
import type { key_val } from '$lib/stores/ae_stores';
import { events_func } from '$lib/ae_events/ae_events_functions';
import { ae_api } from '$lib/stores/ae_stores';
@@ -296,12 +296,13 @@ function toggle_cfg_controls_auth_editable(key: string) {
<form onsubmit={prevent_default(handle_submit)} class="space-y-4 p-4">
<h3 class="h3">{template_id ? 'Edit' : 'Create New'} Badge Template</h3>
<section class="border-t pt-3">
<button type="button" class="text-sm text-surface-500" onclick={() => (sections_open.general = !sections_open.general)}>
General {sections_open.general ? '▲' : '▼'}
<section class="border-surface-200-800 rounded-xl border">
<button type="button" class="flex w-full items-center justify-between px-4 py-3 text-left font-semibold" onclick={() => (sections_open.general = !sections_open.general)}>
<span>General</span>
{#if sections_open.general}<ChevronUp size="1em" />{:else}<ChevronDown size="1em" />{/if}
</button>
{#if sections_open.general}
<div class="space-y-3 pt-2">
<div class="border-surface-200-800 space-y-3 border-t px-4 py-3">
<label class="label">
<span>Template Name</span>
<input type="text" bind:value={name} class="input" required />
@@ -319,12 +320,13 @@ function toggle_cfg_controls_auth_editable(key: string) {
{/if}
</section>
<section class="border-t pt-3">
<button type="button" class="text-sm text-surface-500" onclick={() => (sections_open.branding = !sections_open.branding)}>
Header & Branding {sections_open.branding ? '▲' : '▼'}
<section class="border-surface-200-800 rounded-xl border">
<button type="button" class="flex w-full items-center justify-between px-4 py-3 text-left font-semibold" onclick={() => (sections_open.branding = !sections_open.branding)}>
<span>Header &amp; Branding</span>
{#if sections_open.branding}<ChevronUp size="1em" />{:else}<ChevronDown size="1em" />{/if}
</button>
{#if sections_open.branding}
<div class="space-y-3 pt-2">
<div class="border-surface-200-800 space-y-3 border-t px-4 py-3">
<label class="label">
<span>Header Path (URL) — top banner image (used when no background image)</span>
<input type="text" bind:value={header_path} class="input" />
@@ -349,12 +351,13 @@ function toggle_cfg_controls_auth_editable(key: string) {
{/if}
</section>
<section class="border-t pt-3">
<button type="button" class="text-sm text-surface-500" onclick={() => (sections_open.footer = !sections_open.footer)}>
Footer {sections_open.footer ? '▲' : '▼'}
<section class="border-surface-200-800 rounded-xl border">
<button type="button" class="flex w-full items-center justify-between px-4 py-3 text-left font-semibold" onclick={() => (sections_open.footer = !sections_open.footer)}>
<span>Footer</span>
{#if sections_open.footer}<ChevronUp size="1em" />{:else}<ChevronDown size="1em" />{/if}
</button>
{#if sections_open.footer}
<div class="space-y-3 pt-2">
<div class="border-surface-200-800 space-y-3 border-t px-4 py-3">
<label class="label">
<span>Footer Text (HTML allowed)</span>
<textarea bind:value={footer_text} class="textarea" rows="2"></textarea>
@@ -363,12 +366,13 @@ function toggle_cfg_controls_auth_editable(key: string) {
{/if}
</section>
<section class="border-t pt-3">
<button type="button" class="text-sm text-surface-500" onclick={() => (sections_open.qr = !sections_open.qr)}>
QR & Wireless {sections_open.qr ? '▲' : '▼'}
<section class="border-surface-200-800 rounded-xl border">
<button type="button" class="flex w-full items-center justify-between px-4 py-3 text-left font-semibold" onclick={() => (sections_open.qr = !sections_open.qr)}>
<span>QR &amp; Wireless</span>
{#if sections_open.qr}<ChevronUp size="1em" />{:else}<ChevronDown size="1em" />{/if}
</button>
{#if sections_open.qr}
<div class="space-y-3 pt-2">
<div class="border-surface-200-800 space-y-3 border-t px-4 py-3">
<label class="label flex items-center gap-2">
<input type="checkbox" bind:checked={cfg_show_qr_front} class="checkbox" />
<span>Show QR Code on Front</span>
@@ -389,12 +393,13 @@ function toggle_cfg_controls_auth_editable(key: string) {
{/if}
</section>
<section class="border-t pt-3">
<button type="button" class="text-sm text-surface-500" onclick={() => (sections_open.tickets = !sections_open.tickets)}>
Tickets {sections_open.tickets ? '▲' : '▼'}
<section class="border-surface-200-800 rounded-xl border">
<button type="button" class="flex w-full items-center justify-between px-4 py-3 text-left font-semibold" onclick={() => (sections_open.tickets = !sections_open.tickets)}>
<span>Tickets</span>
{#if sections_open.tickets}<ChevronUp size="1em" />{:else}<ChevronDown size="1em" />{/if}
</button>
{#if sections_open.tickets}
<div class="space-y-3 pt-2">
<div class="border-surface-200-800 space-y-3 border-t px-4 py-3">
<label class="label">
<span>Ticket 1 Text (HTML allowed)</span>
<textarea bind:value={ticket_1_text} class="textarea" rows="2"></textarea>
@@ -411,12 +416,13 @@ function toggle_cfg_controls_auth_editable(key: string) {
{/if}
</section>
<section class="border-t pt-3">
<button type="button" class="text-sm text-surface-500" onclick={() => (advanced_open = !advanced_open)}>
Advanced (cfg_json) {advanced_open ? '▲' : '▼'}
<section class="border-surface-200-800 rounded-xl border">
<button type="button" class="flex w-full items-center justify-between px-4 py-3 text-left font-semibold" onclick={() => (advanced_open = !advanced_open)}>
<span>Advanced (cfg_json)</span>
{#if advanced_open}<ChevronUp size="1em" />{:else}<ChevronDown size="1em" />{/if}
</button>
{#if advanced_open}
<div class="space-y-4 pt-2">
<div class="border-surface-200-800 space-y-4 border-t px-4 py-3">
<!-- Visibility -->
<div>
@@ -590,7 +596,7 @@ function toggle_cfg_controls_auth_editable(key: string) {
disabled={submit_status === 'loading'}>Cancel</button>
<button
type="submit"
class="btn preset-filled-primary"
class="btn preset-filled-primary-500"
disabled={submit_status === 'loading'}>
{#if submit_status === 'loading'}
<Loader2 size="1em" class="animate-spin" aria-hidden="true" />

View File

@@ -1,14 +1,7 @@
<script lang="ts">
import { page } from '$app/state';
import { goto } from '$app/navigation';
import {
Lock,
Printer,
Plus,
Upload,
FileText,
BarChart2
} from '@lucide/svelte';
import { Lock, Printer } from '@lucide/svelte';
import { liveQuery } from 'dexie';
import { db_events, type Event } from '$lib/ae_events/db_events';
import { onMount } from 'svelte';
@@ -19,9 +12,6 @@ import AE_Comp_Editor_CodeMirror from '$lib/elements/element_editor_codemirror.s
import Ae_comp_event_settings_form from './ae_comp__event_settings_form.svelte';
import Ae_comp_event_settings_basic_form from './ae_comp__event_settings_basic_form.svelte';
import Ae_comp_event_settings_abstracts_form from './ae_comp__event_settings_abstracts_form.svelte';
import { Modal } from 'flowbite-svelte';
import Comp_badge_create_form from '../(badges)/badges/ae_comp__badge_create_form.svelte';
import Comp_badge_upload_form from '../(badges)/badges/ae_comp__badge_upload_form.svelte';
let event_id = page.params.event_id as string;
let event_obj: Event | undefined | null = $state(null);
@@ -38,9 +28,6 @@ let tmp_abstracts_json_str = $state('');
let tmp_exhibits_json_str = $state('');
let tmp_meetings_json_str = $state('');
let show_create_badge_modal: boolean = $state(false);
let show_upload_badge_modal: boolean = $state(false);
onMount(() => {
// Guard: administrator access required. 500ms grace delay matches the /core
// layout pattern — allows the persisted store to hydrate before redirecting.
@@ -130,33 +117,6 @@ async function handle_save(field_name: string, data: any) {
<summary class="summary text-error-500 font-bold"
>Admin Tools</summary>
<div class="space-y-4 p-4">
{#if (badges_loc.current.enable_add_badge_btn ?? true) || (badges_loc.current.enable_upload_badge_li_btn ?? true)}
<div class="card rounded-md border p-4 text-center">
<h4 class="h4">Badge Operations</h4>
<div class="mt-2 flex flex-wrap justify-center gap-2">
{#if badges_loc.current.enable_add_badge_btn ?? true}
<button
type="button"
class="btn btn-primary"
onclick={() =>
(show_create_badge_modal = true)}>
<Plus size="1em" aria-hidden="true" /> Add New Badge
</button>
{/if}
{#if badges_loc.current.enable_upload_badge_li_btn ?? true}
<button
type="button"
class="btn btn-primary ml-2"
onclick={() =>
(show_upload_badge_modal = true)}>
<Upload size="1em" aria-hidden="true" /> Upload Badge
List
</button>
{/if}
</div>
</div>
{/if}
{#if badges_loc.current.enable_mass_print ?? true}
<div class="card rounded-md border p-4 text-center">
<h4 class="h4">Mass Print Options</h4>
@@ -182,20 +142,6 @@ async function handle_save(field_name: string, data: any) {
</div>
{/if}
<div class="mt-4 flex flex-wrap justify-center gap-4">
<a
href={`/events/${event_id}/templates`}
class="btn btn-tertiary">
<FileText size="1em" aria-hidden="true" /> Manage Badge
Templates
</a>
<a
href={`/events/${event_id}/badges/stats`}
class="btn btn-tertiary">
<BarChart2 size="1em" aria-hidden="true" /> Badge Printing
Stats
</a>
</div>
</div>
</details>
@@ -444,33 +390,6 @@ async function handle_save(field_name: string, data: any) {
<p>Loading event data...</p>
{/if}
{#if show_create_badge_modal}
<Modal bind:open={show_create_badge_modal}>
<div class="card p-4">
<h3 class="h3">Create New Badge</h3>
<Comp_badge_create_form
{event_id}
onsuccess={() => {
show_create_badge_modal = false;
}}
oncancel={() => (show_create_badge_modal = false)} />
</div>
</Modal>
{/if}
{#if show_upload_badge_modal}
<Modal bind:open={show_upload_badge_modal}>
<div class="card p-4">
<h3 class="h3">Upload Badges (CSV)</h3>
<Comp_badge_upload_form
{event_id}
onsuccess={() => {
show_upload_badge_modal = false;
}}
oncancel={() => (show_upload_badge_modal = false)} />
</div>
</Modal>
{/if}
{:else}
<!-- Non-administrator landed here — show a brief message while the onMount redirect fires -->
<section