feat(badges): print/review pages, 4-button list, Lucide icons, permissions doc

Badge search results list (ae_comp__badge_obj_li):
- 4 action buttons per row: Print, Review (nav link), Copy Link (clipboard), Email Link
- Visibility rules: unprinted-only for non-edit mode; all non-hidden for trusted+edit
- Plain name display (User/EyeOff icon) — name is no longer a print link
- Obscured email for non-trusted users
- Debug row (ID, CR, UP, PC, FP, LP) in edit mode
- All icons converted to Lucide (Font Awesome removed)

Badge print page (/print):
- 3 header action buttons: Print Now, Review (nav), Email Link
- Removed old [badge_id]/+page.svelte placeholder (moved to trash)
- Added is_trusted, is_edit_mode, print state derived vars
- "Already printed Nx — last [timestamp]" warning inline with name
- Removed unused imports (browser, onMount, events_slct)

Badge review page (/review):
- 3 header action buttons: Print (nav), Copy Link (clipboard), Email Link
- Added events_loc for email placeholder + title event name
- Added is_edit_mode, print_count, is_printed, copy_status
- FA icons replaced with Lucide (ShieldCheck, UserCheck, User)
- Title now includes event name (was missing)

Infrastructure:
- print/+page.ts and review/+page.ts added (non-blocking badge loaders)
- ae_comp__badge_review_form.svelte stub created (fields pending)
- Fixed: components no longer write to $ae_loc.edit_mode (critical bug)

Docs:
- NEW: AE__Permissions_and_Security.md — full permissions hierarchy reference
- NEW: PROJECT__AE_Events_Badges_Review_Print.md — agent task brief for review form + print font controls
- UPDATED: MODULE__AE_Events_Badges.md rev 5 — field permissions spec, header buttons, still-needed list by priority

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Scott Idem
2026-02-27 15:12:22 -05:00
parent ee500a9ad5
commit c4e85b1fe3
15 changed files with 2046 additions and 412 deletions

View File

@@ -0,0 +1,174 @@
# Aether — Permissions and Security
**Last updated:** 2026-02-27
**Source of truth:** `src/lib/ae_utils/ae_utils__perm_checks.ts`, `src/lib/stores/ae_stores.ts`
---
## Access Level Hierarchy
Highest to lowest. Each level **inherits all access from every level below it**.
| Level | `access_type` string | Typical Use |
| --- | --- | --- |
| Super | `super` | OSIT internal — full system access |
| Manager | `manager` | Account managers |
| Administrator | `administrator` | Event/account admins |
| Trusted | `trusted` | **Onsite staff** — site passcode or AE login |
| Public | `public` | Site-wide passcode granted |
| Authenticated | `authenticated` | Identity verified (e.g. IDAA Novi UUID) |
| Anonymous | `anonymous` | Default — not signed in |
> **Note on Public vs Authenticated:** `public` is a *site-wide* unlock (anyone with the passcode). `authenticated` verifies a *specific identity*. In the hierarchy, public outranks authenticated because it implies broader site access.
---
## `$ae_loc` Store — Permission Flags
`$ae_loc` is a `persisted()` store (backed by localStorage). Key fields:
```typescript
$ae_loc.access_type // string: current access type ('anonymous', 'trusted', etc.)
// Cumulative boolean flags (true = "you have AT LEAST this level")
$ae_loc.anonymous_access // always true
$ae_loc.authenticated_access // true from authenticated and above
$ae_loc.public_access // true from public and above
$ae_loc.trusted_access // true from trusted and above ← most-used gate
$ae_loc.administrator_access // true from administrator and above
$ae_loc.manager_access // true from manager and above
$ae_loc.super_access // true only at super
// Exclusive check flags (true = "you are EXACTLY this level")
$ae_loc.trusted_check // true only if access_type === 'trusted'
$ae_loc.administrator_check // etc.
// (rarely needed — prefer the _access flags)
// Behavior flags
$ae_loc.edit_mode // boolean — user preference, see below
$ae_loc.adv_mode // boolean — advanced mode toggle
```
### Additional intermediate levels (in permission checks, not in hierarchy order)
`support`, `assistant`, `verified`, `provisional` — appear in `_access` flags but are not part of the canonical `access_level_order`. Treat as internal/intermediate.
---
## Edit Mode — Critical Rules
`$ae_loc.edit_mode` is a **user preference**, not a permission level.
**Rules that must never be broken:**
1. **Components must never write to `$ae_loc.edit_mode`** — only the system menu toggle and sign-out/permission-drop handlers may change it.
2. Edit mode is only available to `trusted` and above in 95% of modules (the toggle is hidden from lower-access users).
3. Edit mode persists across navigation — it is NOT reset by page loads or component mounts.
4. Sign-out and permission drops to below `authenticated` should reset `edit_mode` to `false`.
> **Background:** A bug was fixed (2026-02-27) where `ae_comp__badge_obj_view.svelte` was writing `$ae_loc.edit_mode = false` in a data-loading `$effect`, silently overriding the user's preference on every navigation to the badge print page.
---
## Authentication Methods
| Method | Grants | Used For |
| --- | --- | --- |
| Site passcode (`site_access_code_kv`) | `trusted`, `public`, or `authenticated` | Onsite staff and event attendees |
| AE Username + Password | `trusted` and above | Staff with AE accounts |
| Novi UUID | `authenticated` | IDAA members (Novi membership system) |
Passcodes are stored per-level in `$ae_loc.site_access_code_kv`:
```typescript
site_access_code_kv: {
administrator: null, // highest passcode tier
trusted: null, // onsite staff passcode
public: 'public1980', // example
authenticated: 'auth1980'
}
```
---
## Utility Functions
### `process_permission_checks(access_type: string)`
Returns a full permission object (`_check` and `_access` flags) for a given access type string. Used when access type changes to update `$ae_loc`.
```typescript
import { process_permission_checks } from '$lib/ae_utils/ae_utils__perm_checks';
const checks = process_permission_checks('trusted');
// checks.trusted_access === true
// checks.administrator_access === false
```
### `compare_access_levels(level_a, level_b)`
Returns `1` if `level_a` is higher, `-1` if lower, `0` if equal. Useful for threshold comparisons.
---
## Privacy and Security Rules
### IDAA — International Doctors in Alcoholics Anonymous
- **ALL IDAA content is private. Always. No exceptions.**
- BB (Bulletin Board / Posts), Archives, Recovery Meetings — all require authentication.
- IDAA users authenticate via Novi UUID at `authenticated` level or higher.
- A prior agent accidentally exposed IDAA BB data publicly — treat any IDAA exposure as Sev-1.
### Journals
- Private personal data. Always authenticated. Passcode/encryption features exist.
- Never expose journal content publicly.
### `PUBLIC_AE_API_SECRET_KEY`
- Ongoing Sev-1 audit. Do not introduce new usages.
- Prefer per-request API key headers (`x-aether-api-key` + `x-account-id`).
### Email Display
Non-trusted users must never see a full email address. Obscure using:
```typescript
// joh***@example.com
function obscure_email(email: string): string {
const at = email.indexOf('@');
if (at < 0) return email;
return `${email.slice(0, Math.min(3, at))}***${email.slice(at)}`;
}
```
This pattern lives in `ae_comp__badge_obj_li.svelte` — move to `ae_utils` if needed elsewhere.
---
## Module-Specific Permission Patterns
### Events — Badges
| Scenario | Visibility | Print Action | Review Actions |
| --- | --- | --- | --- |
| Anonymous / below trusted | Unprinted only | None (name display only) | Email Review Link button (→ email API) |
| Trusted, not Edit Mode | Unprinted only | Clickable (first print) | Email Review Link button |
| Trusted, Edit Mode | All non-hidden | Clickable incl. reprint; shows `Nx` count | Email Review Link + direct Review Link (clipboard) |
- Print count badge: shown as `Nx` (e.g. `2×`) next to the printer icon when `print_count >= 1`
- Edit mode for badges: limited to `trusted_access` users (toggle hidden from lower levels)
- `person_passcode` field (for attendee-gated review URL): **not yet in DB** as of 2026-02-27
### IDAA
- Auth gate test must be the **first test** in any test file — privacy enforcement is a hard requirement.
- Default required permission: `trusted_access` or higher for module access.
---
## Common Template Patterns
```svelte
<!-- Gate on trusted access -->
{#if $ae_loc.trusted_access}
<!-- Gate on edit mode (always check trusted too — edit mode alone is insufficient) -->
{#if $ae_loc.trusted_access && $ae_loc.edit_mode}
<!-- Gate on administrator -->
{#if $ae_loc.administrator_access}
<!-- Show full vs obscured email -->
{$ae_loc.trusted_access ? email : obscure_email(email)}
```
> Never gate purely on `$ae_loc.edit_mode` without also checking a permission level. Edit mode is a UI preference, not a permission grant.

View File

@@ -3,7 +3,7 @@
**Module Path:** `src/routes/events/[event_id]/(badges)/badges/`
**API Module:** `src/lib/ae_events/ae_events__event_badge.ts`
**Database:** `db_events.badge` (Dexie IndexedDB table)
**Last Updated:** 2026-02-26 (rev 3)
**Last Updated:** 2026-02-27 (rev 5)
---
@@ -64,17 +64,23 @@ display_name = badge.full_name_override || badge.full_name || "-- no name --"
// ✅ Display still shows "Bob Smith"
```
### Override Fields (7 total)
### Override Fields
| Regular Field | Override Field | Purpose | Editable By |
|---|---|---|---|
| `pronouns` | `pronouns_override` | Preferred pronouns | Staff, Attendee |
| `professional_title` | `professional_title_override` | Job title display | Staff, Attendee |
| `full_name` | `full_name_override` | Preferred name, pronouns | Staff, Attendee |
| `full_name` | `full_name_override` | Preferred name display | Staff, Attendee |
| `affiliations` | `affiliations_override` | Organization display | Staff, Attendee |
| `phone` | `phone_override` | Phone number | Staff, Attendee |
| `email` | `email_override` | Contact email override | Staff only |
| `location` | `location_override` | City/State/Country display | Staff, Attendee |
| `badge_type` | `badge_type_override` | Badge category label (varies per Event/Template) | Staff only |
| `badge_type_code` | `badge_type_code_override` | Badge access level code (varies per Event/Template) | Staff only |
| `badge_type` | `badge_type_override` | Badge category label text | Staff only |
| `badge_type_code` | `badge_type_code_override` | Badge access level code | Staff only |
| `registration_type` | `registration_type_override` | Registration category label text | Staff only |
| `registration_type_code` | `registration_type_code_override` | Registration category code | Staff only |
> **Note:** `phone`, `phone_override`, `pronouns_override`, `registration_type`, `registration_type_code`, `registration_type_override`, `registration_type_code_override` may need to be confirmed against the DB schema via `ae_describe event_badge` and added to `properties_to_save` in `ae_events__event_badge.ts` if not already present.
### Sync Safety Rules
@@ -149,18 +155,39 @@ def sync_badge_from_external(external_badge_data, existing_badge):
6. **Manager** — All administrator + event configuration
7. **Super** — All manager + cross-event operations
### Current Implementation (v3)
### Current Implementation (v3) — 2026-02-27
**Quick Edit Feature** ([badge_id]/+page.svelte → ae_comp__badge_obj_view.svelte)
#### Badge Search Results Visibility
**Edit Mode Trigger:**
- URL hash `#review` enables edit mode
- Sets `$ae_loc.edit_mode = true`
- Shows Save/Cancel buttons
| Access Level | Sees |
| --- | --- |
| Below Trusted (incl. anonymous) | Only badges where `print_count < 1` and not hidden |
| Trusted, not Edit Mode | Only badges where `print_count < 1` and not hidden |
| Trusted + Edit Mode | All badges where `hide === false` (including already-printed) |
**Currently Editable Fields:**
#### Print Button Behavior (per result row)
| Access Level | Print Action |
| --- | --- |
| Below Trusted | No print action — name shown with User icon, non-interactive |
| Trusted, `print_count < 1` | Clickable link → `/print` page, Printer icon |
| Trusted, `print_count >= 1`, not Edit Mode | Disabled (already printed safety lock), shows `Nx` count |
| Trusted, `print_count >= 1`, Edit Mode | Clickable reprint — shows `Nx` count badge next to icon |
Print count displayed as `[Printer][2×] Name` when `print_count >= 1`.
#### Review Area Buttons (per result row, up to 3 buttons total)
| Button | Visible To | Behavior |
| --- | --- | --- |
| Email Review Link | All users | Placeholder `alert()` — will trigger email API |
| Review Link (clipboard) | Trusted + Edit Mode only | Copies `/review` URL to clipboard; shows `Copied!` feedback |
| *(direct Review link)* | *(future)* | *(not yet implemented as separate nav button)* |
#### Badge Edit Form (`ae_comp__badge_obj_view.svelte`)
**Currently editable fields (local `edit_mode_active`, not global `edit_mode`):**
```typescript
// Lines 90-96 in ae_comp__badge_obj_view.svelte
editable_full_name_override: string | null
editable_professional_title_override: string | null
editable_affiliations_override: string | null // textarea
@@ -170,37 +197,109 @@ editable_email: string | null
editable_badge_type_code: string | null
```
**UI Components:**
- Input fields shown when `edit_mode_active === true`
- Save button calls `handle_save_changes()` (Line 365)
- Cancel button calls `handle_cancel_changes()` (Line 450)
- Only changed fields sent to API (`update_ae_obj__event_badge`)
- Save button → `handle_save_changes()` — only changed fields sent to API
- Cancel button → `handle_cancel_changes()` — reverts to IDB values
- **IMPORTANT:** This component must NEVER write to `$ae_loc.edit_mode` — it uses its own local `edit_mode_active` flag only. (Bug fixed 2026-02-27)
### Future Planned Enhancement
#### Badge Review Form (`ae_comp__badge_review_form.svelte`)
**Event-Level Configuration:** `event.mod_badges_json.edit_permissions`
Form-based review (NOT a badge render). Used by the `/review` page.
- `can_edit_fields: string[]` prop controls which fields are editable per user level
- `['*']` = administrator (all fields)
- `is_staff: boolean` prop shows/hides the source-data panel
- Fields show "(overridden)" label when an override value differs from the base field
#### Badge Review Page — Header Buttons (implemented 2026-02-27)
| Button | Visible To | Behavior |
| --- | --- | --- |
| Back → Search (ArrowLeft) | Staff (`has_staff_access`) only | `<a href="/events/{id}/badges">` |
| Print (Printer icon) | Trusted+, not printed OR Trusted+Edit if printed | `<a href="/print">`, shows `Nx` count if reprinting |
| Copy Link (clipboard) | Trusted + Edit Mode only | Copies review URL to clipboard; `Copied!` feedback for 2s |
| Email Link (Mail icon) | All if not printed; Trusted+Edit if printed | Placeholder `alert()` — email API pending |
#### Badge Print Page — Header Buttons (implemented 2026-02-27)
| Button | Visible To | Behavior |
| --- | --- | --- |
| Back → Search (ArrowLeft) | Always (when badge loaded) | `<a href="/events/{id}/badges">` |
| Print Now (Printer icon) | Trusted+, not printed OR Trusted+Edit if printed | Calls `window.print()` directly (convenience duplicate); print count tracked by component button |
| Review (Eye icon) | Trusted + Edit Mode only | `<a href="/review">` nav link |
| Email Link (Mail icon) | All if not printed; Trusted+Edit if printed | Placeholder `alert()` — email API pending |
#### Badge Review Page — Display Sections (planned, not yet built)
In addition to the editable form, the review page will display:
1. **Print status** — print count + first/last print timestamps (read-only)
2. **QR Code** — the attendee's badge QR code for scanning at the badge kiosk (for automatic badge search + print flow). QR generation code may be recoverable from the legacy AE Badge version.
3. **Options** (`other_1_code` through `other_8_code`) — shown as `[✓] Option X` if the field has a value; hidden if empty
4. **Tickets** (`ticket_1_code` through `ticket_8_code`) — shown as `[✓] Ticket X` if the field has a value; hidden if empty
#### Default Field Permissions (hardcoded for now — Axonius first show, mid-April 2026)
These are hardcoded in `review/+page.svelte` pending connection to `mod_badges_json.edit_permissions`.
**Attendee (passcode-authenticated / anonymous with link):**
```typescript
[
'pronouns_override',
'full_name_override',
'professional_title_override',
'affiliations_override',
'phone_override',
'location_override',
'allow_tracking', // Exhibitor Leads opt-in
'agree_to_tc', // Terms & Conditions placeholder
]
```
**Trusted Staff and above:**
```typescript
[
'pronouns_override',
'full_name_override',
'professional_title_override',
'affiliations_override',
'email_override',
'phone_override',
'location_override',
'badge_type_code_override', // + badge_type_override (text label)
'registration_type_code_override', // + registration_type_override (text label)
'option_1' ... 'option_8', // i.e. other_1_code ... other_8_code
'ticket_1_code' ... 'ticket_8_code',
'allow_tracking',
'agree_to_tc',
'hide',
'priority',
'notes',
]
```
**Administrator**`can_edit_fields = ['*']` (all fields)
**Badge type options (hardcoded for now):** `member`, `non-member`, `guest`, `exhibitor`, `staff`, `test`
(In future: read from Event Badge Template's configured list)
**Registration type options:** Same list as badge type for now — identical select options.
#### Future: Per-Event Configuration
`event.mod_badges_json.edit_permissions` — placeholder settings UI exists in
`ae_comp__event_settings_badges_form.svelte`. Review page uses hardcoded defaults for now.
The settings form and review page are not yet connected.
```json
{
"authenticated": {
"can_edit": ["full_name_override", "professional_title_override", "affiliations_override", "location_override"],
"requires_approval": false
"can_edit": ["pronouns_override", "full_name_override", "professional_title_override", "affiliations_override", "phone_override", "location_override", "allow_tracking", "agree_to_tc"]
},
"trusted": {
"can_edit": ["full_name_override", "professional_title_override", "affiliations_override", "location_override", "email_override"],
"can_view_all": true,
"requires_approval": false
},
"administrator": {
"can_edit": "*",
"can_bulk_edit": true,
"can_delete": true
"can_edit": ["*attendee_fields", "email_override", "badge_type_code_override", "registration_type_code_override", "option_x", "ticket_x_code", "allow_tracking", "agree_to_tc", "hide", "priority", "notes"]
}
}
```
**Status:** Placeholder UI exists, JSON config not yet implemented.
---
## Search & Filter Capabilities
@@ -377,15 +476,14 @@ async function handle_print_badge() {
Button has `data-testid="badge-print-btn"` and shows loading/done/error states with icon feedback.
### Print Workflow
1. **Pre-Print:** Check `print_count` to warn if already printed
2. **Print:** `window.print()` — standard browser print dialog. Works well in Chrome, Chromium, and Firefox. Chrome is the recommended browser for onsite badge printing (most stable in production).
3. **Post-Print:** Handled by `handle_print_badge()` — count + timestamps updated
4. **Audit:** Print history available for staff review
1. **Pre-Print:** Badge print page (`/print`) shows "Already printed N times" warning in screen-only header if `print_count >= 1`
2. **Record:** `handle_print_badge()` updates `print_count`, `print_last_datetime`, and `print_first_datetime` (first print only) via API before printing
3. **Print:** `window.print()` — standard browser print dialog, wired and working (2026-02-27)
4. **Redirect:** After 1 second, `goto(/events/{id}/badges)` returns to search
5. **Audit:** `print_first_datetime` and `print_last_datetime` visible in Edit Mode debug row
**Browser vs Electron:** Badge printing does NOT require the Electron native app. The standard browser print dialog works well across Chrome, Chromium, and Firefox. The Electron native app is specialized for the **Events Pres Mgmt Launcher only** and should not be assumed available for badge stations.
**Current Status:** Button records print event and updates IDB. The `window.print()` call still needs to be wired to the button — see Future Enhancements.
---
## Database Schema
@@ -483,18 +581,42 @@ delete_ae_obj_id__event_badge({ event_badge_id, event_id, method })
### Route Structure
```
/events/[event_id]/(badges)/badges/
├── +layout.svelte # Layout wrapper (minimal)
├── +page.svelte # Badge list + search
├── ae_comp__badge_search.svelte # Search form + filters
├── ae_comp__badge_obj_li.svelte # Badge list display
├── +layout.svelte # Layout wrapper (minimal)
├── +page.svelte # Badge list + search
├── ae_comp__badge_search.svelte # Search form + filters
├── ae_comp__badge_obj_li.svelte # Badge list display (results)
├── ae_comp__badge_create_form.svelte # (Not actively used)
├── ae_comp__badge_upload_form.svelte # Bulk CSV upload
└── [badge_id]/
├── +page.svelte # Badge detail container
├── +page.ts # Badge loader (non-blocking)
── ae_comp__badge_obj_view.svelte # Badge display + edit
├── ae_comp__badge_obj_view.svelte # Badge rendering + staff edit + print button
├── ae_comp__badge_review_form.svelte # Form-based field review/edit (attendee + staff)
── print/
│ ├── +page.ts # Non-blocking badge loader (inc_template: true)
│ └── +page.svelte # Print-focused page — screen header + badge render
└── review/
├── +page.ts # Non-blocking badge loader (inc_template: false)
└── +page.svelte # Passcode-gated review page
```
> **Note:** The old `[badge_id]/+page.svelte` placeholder was removed (2026-02-27). The name link in the search results list now goes directly to `/print`.
#### Badge Print Page (`/print`)
- Screen-only header (`print:hidden`): "Back to Search" link + "Already printed N times" warning
- Badge rendered via `ae_comp__badge_obj_view` with `is_review_mode={false}`
- Print button inside `ae_comp__badge_obj_view` handles count update → `window.print()` → redirect to search
- Page `<title>` includes badge name + event name
#### Badge Review Page (`/review`)
- Passcode-gated for attendees — URL `?passcode=...` matched against `badge.person_passcode`
- **Note:** `person_passcode` field is not yet in the DB (as of 2026-02-27). Review page accessible to staff via `trusted_access` without a passcode.
- Access hierarchy (checked in order):
1. Administrator → full access (`can_edit_fields = ['*']`)
2. Trusted Staff → staff field set
3. Attendee with valid passcode → attendee field set
4. No access → passcode entry form shown
- Uses `ae_comp__badge_review_form.svelte` (NOT badge render)
- "Back to Search" link shown for staff only
### Key Components
**Badge List Page** (`+page.svelte`)
@@ -577,19 +699,45 @@ None — all current badge tests passing as of 2026-02-26 (f5e98b8c).
## Known Issues & Future Enhancements
### Known Issues
1. **Test Infrastructure:** Mock API routes not connecting to page requests
2. **Session Cold-Start:** Potential race condition on first load (same as pres mgmt module)
3. **Type Definitions:** Some TypeScript errors on external package types (pre-existing)
1. **Session Cold-Start:** Potential race condition on first load (same as pres mgmt module)
2. **Type Definitions:** Some pre-existing TypeScript errors on external package types (not introduced by badge work)
3. **`person_passcode` not in DB:** Attendee-gated review URL (`?passcode=...`) cannot function until this field is added to the `event_badge` schema. The review page falls back to passcode entry form for non-staff.
4. **Print page CSS:** Badge print rendering and `@page` print styles not yet fine-tuned — expected to need work
5. **`mod_badges_json.edit_permissions` not connected:** Settings UI exists but review page uses hardcoded field defaults
### Future Enhancements
1. **Access-Based Edit Permissions:** Implement JSON config for field-level access control
2. **Print Functionality:** Wire up `window.print()` to the print button. Standard browser print API — works well in Chrome/Chromium/Firefox for badge label printing. Electron is NOT needed.
3. **Batch Operations:** Bulk update, bulk print, bulk export
4. **Audit Log:** Track who edited which fields and when
5. **Photo Badges:** Support badge photo upload and display
6. **Custom Badge Layouts:** Dynamic template selection per badge type
7. **Real-Time Sync:** WebSocket updates for multi-device badge printing stations
8. **Approval Workflow:** Require manager approval for certain field changes
### Implemented (2026-02-27)
-`window.print()` wired to print button (records count first, then prints, then redirects)
- ✅ Dedicated `/print` page — replaces old `[badge_id]/+page.svelte` placeholder
- ✅ Dedicated `/review` page — passcode-gated, access-tiered
-`ae_comp__badge_review_form.svelte` — stub created, full form fields pending
- ✅ Badge search results visibility rules (unprinted-only for non-edit, all for trusted+edit)
- ✅ Badge list: 4 action buttons per row (Print, Review nav, Copy Link, Email Link) — all Lucide icons
- ✅ Print page: 3 action buttons in header (Print Now, Review nav, Email Link) — all Lucide icons
- ✅ Review page: 3 action buttons in header (Print nav, Copy Link, Email Link) — all Lucide icons
- ✅ Print button: not shown when already printed (unless Edit Mode)
- ✅ Print count shown as `Nx` badge next to printer icon
- ✅ Email obscuring for non-trusted users
- ✅ Email Review Link button (placeholder alert — email API pending)
- ✅ Direct Review Link clipboard copy (trusted + Edit Mode only)
- ✅ Fixed: components no longer write to `$ae_loc.edit_mode`
- ✅ Settings UI for `edit_permissions` per event (`ae_comp__event_settings_badges_form.svelte`)
- ✅ All badge module icons converted to Lucide (Font Awesome removed from badge routes)
### Still Needed — HIGH PRIORITY (first show: April 2026)
1. **Badge Review Form — actual fields:** `ae_comp__badge_review_form.svelte` is a stub. Needs full field rendering, edit inputs, save/cancel, and the display-only sections (QR code, print status, option/ticket checkmarks). See `PROJECT__AE_Events_Badges_Review_Print.md` for full spec.
2. **Badge Print Page — font size controls:** Screen-only controls to adjust font size for name, professional title, affiliations, and location sections before printing. See project brief.
### Still Needed — MEDIUM PRIORITY
1. **Email API for review links:** `send_review_email()` is a placeholder `alert()`. Needs actual email send endpoint.
2. **`person_passcode` DB field:** Add to `event_badge` schema to enable attendee-gated review URLs.
3. **Connect `edit_permissions` config:** Read `mod_badges_json.edit_permissions` in review page instead of hardcoded defaults.
4. **Print page CSS / `@page` styles:** Badge rendering, sizing, and print-specific stylesheet.
### Still Needed — FUTURE / LOW PRIORITY
1. **Batch Operations:** Bulk update, bulk print, bulk export
2. **Audit Log:** Track who edited which fields and when
3. **Photo Badges:** Support badge photo upload and display
4. **Real-Time Sync:** WebSocket updates for multi-device badge printing stations
---
@@ -646,6 +794,6 @@ db_events.badge.toArray().then(console.log)
---
**Document Status:** ✅ Complete
**Last Verified:** 2026-02-26 (rev 3all badge tests passing)
**Verified Against:** Code commit f5e98b8c (all data integrity tests passing)
**Document Status:** 🔄 In Progress
**Last Verified:** 2026-02-27 (rev 5field permissions spec added, header buttons implemented, review form fields pending)
**Verified Against:** Code as of 2026-02-27 (branch ae_app_3x_llm)

View File

@@ -0,0 +1,339 @@
# PROJECT: AE Events Badges — Review Form & Print Font Controls
**Created:** 2026-02-27
**Branch:** `ae_app_3x_llm`
**Priority:** HIGH — first live event is Axonius, NYC, mid-April 2026
**Owner:** Scott Idem / One Sky IT
---
## Context
The Events Badges module is mostly complete for navigation and search. Two key pieces of
functional UI remain unbuilt and are needed before the first show:
1. **Badge Review Form**`ae_comp__badge_review_form.svelte` is currently a stub. It
needs actual field rendering, edit inputs gated by access level, save/cancel API calls,
and display-only sections (QR code, print status, option/ticket checkmarks).
2. **Badge Print Font Controls** — The print page header needs screen-only controls
(hidden during `window.print()`) to bump font sizes for the name, professional title,
affiliations, and location sections before printing. These only affect the `ae_comp__badge_obj_view.svelte` render — not the page layout/template structural dimensions.
Read `documentation/MODULE__AE_Events_Badges.md` for full module context before starting.
---
## MANDATORY: Before You Start
1. Run `ae_describe event_badge` (MCP tool) to confirm which fields actually exist in the
DB. Several fields in the spec below may need to be added to `properties_to_save` in
`src/lib/ae_events/ae_events__event_badge.ts` if they are not already saved to IDB.
2. Fields to specifically confirm exist in `event_badge` schema:
- `pronouns`, `pronouns_override`
- `phone`, `phone_override`
- `allow_tracking`
- `agree_to_tc`
- `other_1_code` through `other_8_code` (the "option" fields)
- `ticket_1_code` through `ticket_8_code`
- `registration_type`, `registration_type_code`
- `registration_type_override`, `registration_type_code_override`
3. Run `npx svelte-check` before committing. Baseline is **77 errors** (all pre-existing,
none in the badge module files). Do not introduce new errors.
4. Do NOT write to `$ae_loc.edit_mode` from any badge component. This was a critical
bug (fixed 2026-02-27). See `documentation/AE__Permissions_and_Security.md`.
---
## TASK 1: Badge Review Form (HIGH PRIORITY)
### File to build
`src/routes/events/[event_id]/(badges)/badges/[badge_id]/ae_comp__badge_review_form.svelte`
This component is already imported and used by `review/+page.svelte`. Props it receives:
```typescript
interface Props {
event_id: string;
event_badge_id: string;
lq__event_badge_obj: any; // Svelte 5 store from liveQuery
can_edit_fields: string[]; // Which fields this user can edit
is_staff: boolean; // True if trusted_access or higher
log_lvl?: number;
}
```
`can_edit_fields` values:
- `['*']` — administrator (all fields)
- Array of field names — specific editable fields
- `[]` — read-only (shouldn't normally reach this component, but handle it)
### Helper
Use a helper derived inside the component:
```typescript
function can_edit(field: string): boolean {
return can_edit_fields.includes('*') || can_edit_fields.includes(field);
}
```
### Save / Cancel Pattern
Follow the Journals module pattern (`src/lib/ae_journals/`). Key points:
- Use `import { events_func } from '$lib/ae_events_functions'`
- Call `events_func.update_ae_obj__event_badge({ event_badge_id, event_id, data_kv })`
- Only send changed fields in `data_kv` (compare against `$lq__event_badge_obj` values)
- Show save/cancel buttons only when something has changed (`has_changes` derived)
- Show a success/error state briefly after save (1-2 seconds, then reset)
- Cancel resets local state back to `$lq__event_badge_obj` values
- Use `data-testid="badge-review-save-btn"` and `data-testid="badge-review-cancel-btn"`
### Save API Call
```typescript
await events_func.update_ae_obj__event_badge({
api_cfg: $ae_api, // from ae_loc store or passed as prop — check how ae_comp__badge_obj_view.svelte does it
event_badge_id: event_badge_id,
event_id: event_id,
data_kv: { /* only changed fields */ }
});
```
Check `ae_comp__badge_obj_view.svelte` for the existing save pattern — it already works
and can be used as reference.
---
### Section 1: Display-Only Status Bar (all access levels)
Always show at top of form. Read-only. No edit controls.
```
Print Status: [Not yet printed] OR [Printed 3× — first: Jan 5 2026, last: Jan 5 2026]
```
Use `$lq__event_badge_obj.print_count`, `print_first_datetime`, `print_last_datetime`.
Format datetimes with `ae_util.iso_datetime_formatter(dt, 'datetime_iso_12_no_seconds')`.
Import `ae_util` from `$lib/ae_utils/ae_utils`.
---
### Section 2: QR Code (all access levels)
Display the attendee's badge QR code. This is the same QR code shown on the printed badge
itself — scanning it at the badge station triggers automatic badge search and print.
**Check the legacy AE Badge version for existing QR generation code.** Look in:
- `src/lib/ae_events/` for any QR-related utilities
- `ae_comp__badge_obj_view.svelte` — the badge render component almost certainly generates
a QR code already for the printed badge. Reuse that logic/component if possible.
The QR code value should encode the badge ID or a URL that resolves to the badge.
---
### Section 3: Editable Fields
Render each field as: read-only display when `!can_edit(field)`, or an `<input>` /
`<select>` / `<textarea>` when `can_edit(field)`.
Show `(overridden)` label next to override fields when the override value differs from
the base field value.
#### Attendee-Editable Fields (shown to all access levels with link)
| Field | Input Type | Notes |
|---|---|---|
| `pronouns_override` | text input | Fallback display: `pronouns` |
| `full_name_override` | text input | Fallback display: `full_name` |
| `professional_title_override` | text input | Fallback display: `professional_title` |
| `affiliations_override` | textarea | Fallback display: `affiliations` |
| `phone_override` | text input (tel) | Fallback display: `phone` |
| `location_override` | text input | Fallback display: `location` |
| `allow_tracking` | checkbox | Label: "Allow exhibitor lead scanning" |
| `agree_to_tc` | checkbox | Label: "I agree to the Terms and Conditions" + placeholder T&C text block |
#### Staff-Only Additional Fields (shown when `is_staff === true`)
| Field | Input Type | Notes |
|---|---|---|
| `email_override` | email input | Fallback display: `email` |
| `badge_type_code_override` | select | Options: member, non-member, guest, exhibitor, staff, test; also updates `badge_type_override` text |
| `registration_type_code_override` | select | Same options as badge_type for now; also updates `registration_type_override` |
| `hide` | checkbox | Label: "Hidden from search results" |
| `priority` | number input | |
| `notes` | textarea | |
#### Staff-Only: Options & Tickets (read-edit, shown when `is_staff === true`)
**Other/Options** (`other_1_code` through `other_8_code`):
- If field has a value: show as editable text input with label "Option X"
- If field is empty/null: show faintly as "Option X (empty)" — staff can still set it
- These represent event-specific add-ons or membership indicators
**Tickets** (`ticket_1_code` through `ticket_8_code`):
- Same pattern as options above, label "Ticket X"
#### Attendee-Only: Options & Tickets (display only)
When `!is_staff` and the field has a value: show `[✓] Option X` or `[✓] Ticket X`.
When the field is empty: hide entirely (attendees don't see empty slots).
---
### Section 4: Terms & Conditions Block (all, only when `agree_to_tc` in can_edit_fields)
Placeholder text for now:
```
By checking this box, I confirm that the information on my badge is correct to the best
of my knowledge. I agree that this badge may be used for identification purposes during
the event and that my attendance may be recorded by exhibitors using the lead scanning
feature if I permit it.
```
Show this before the `agree_to_tc` checkbox. If `agree_to_tc` is not in `can_edit_fields`,
hide the entire block.
---
### Field State Pattern (Svelte 5 runes)
```typescript
// Initialize local editable state from badge object
let local_full_name_override = $state($lq__event_badge_obj?.full_name_override ?? '');
let local_pronouns_override = $state($lq__event_badge_obj?.pronouns_override ?? '');
// ... etc for each editable field
// Detect changes
let has_changes = $derived(
local_full_name_override !== ($lq__event_badge_obj?.full_name_override ?? '')
|| local_pronouns_override !== ($lq__event_badge_obj?.pronouns_override ?? '')
// ... etc
);
// Build changed-fields-only payload
function build_save_payload(): Record<string, any> {
const payload: Record<string, any> = {};
if (local_full_name_override !== ($lq__event_badge_obj?.full_name_override ?? ''))
payload.full_name_override = local_full_name_override || null; // empty string → null
// ... etc
return payload;
}
```
**Important:** Empty string inputs should save as `null` (clears the override, falls back
to base field). Use `value || null` in the payload.
---
## TASK 2: Badge Print Font Size Controls (MEDIUM PRIORITY)
### Where to add
`src/routes/events/[event_id]/(badges)/badges/[badge_id]/print/+page.svelte`
Add a screen-only (`print:hidden`) control panel between the header and the badge render.
This panel lets staff adjust font sizes for the four text-heavy sections before clicking Print.
### Controls needed
```
Font Size Controls (screen only, hidden during print):
[Name] [] [14px] [+]
[Title] [] [12px] [+]
[Affiliations] [] [11px] [+]
[Location] [] [10px] [+]
```
- Start with sensible defaults (match what `ae_comp__badge_obj_view.svelte` currently uses)
- Min/max per field (e.g., 8px24px for name, 7px18px for others)
- Pass the sizes as props into `ae_comp__badge_obj_view`
### Props to add to `ae_comp__badge_obj_view.svelte`
`ae_comp__badge_obj_view.svelte` currently has internal font size logic. It needs to
accept optional override props:
```typescript
// New optional props:
font_size_name?: number; // px
font_size_title?: number; // px
font_size_affiliations?: number; // px
font_size_location?: number; // px
```
When these props are provided, use them instead of the internally computed sizes.
When not provided, fall back to existing auto-sizing behavior.
**IMPORTANT:** Do NOT touch structural dimensions (overall badge width/height, header/footer
sizes, template layout). Only the text content font sizes.
---
## Key Files
| File | Role |
|---|---|
| `[badge_id]/ae_comp__badge_review_form.svelte` | **BUILD THIS** — review form stub |
| `[badge_id]/ae_comp__badge_obj_view.svelte` | Badge render + print button; add font size props |
| `[badge_id]/print/+page.svelte` | Print page; add font size control panel |
| `[badge_id]/review/+page.svelte` | Review page; already wired, passes `can_edit_fields` |
| `src/lib/ae_events/ae_events__event_badge.ts` | API functions: `update_ae_obj__event_badge` |
| `src/lib/ae_events/db_events.ts` | Dexie schema — `properties_to_save` for badge |
| `src/lib/ae_utils/ae_utils.ts` | `ae_util.iso_datetime_formatter()` |
| `documentation/MODULE__AE_Events_Badges.md` | Full module reference |
| `documentation/AE__Permissions_and_Security.md` | Permission flags, edit_mode rules |
| `documentation/GUIDE__AE_API_V3_for_Frontend.md` | V3 API reference |
## Access Level Reference
```typescript
// From $ae_loc store (persisted localStorage)
$ae_loc.trusted_access // true = trusted and above (onsite staff)
$ae_loc.administrator_access // true = administrator and above
$ae_loc.edit_mode // boolean — user preference toggle (NEVER write to this from components)
```
`is_staff` prop on the review form = `administrator_access || trusted_access`.
---
## Patterns to Follow
- **Canonical module reference:** `src/lib/ae_journals/` — most complete, most advanced
- **Svelte 5 runes:** `$state`, `$derived`, `$derived.by()`, `$effect` — no legacy `$:` syntax
- **Icons:** Lucide Svelte only — `import { Save, X, Check, ... } from 'lucide-svelte'`
- **No Font Awesome** (`fas fa-*`) anywhere in the badge module
- **Styling:** Tailwind CSS v4 + Skeleton UI utility classes (`btn`, `preset-tonal-*`, `input`, `card`)
- **Commits:** Atomic — one component per commit; run `npx svelte-check` before every commit
---
## What NOT to Do
- Do NOT touch `@page` CSS or badge template structural dimensions — print layout is out of scope
- Do NOT write to `$ae_loc.edit_mode` from any component
- Do NOT connect `mod_badges_json.edit_permissions` yet — hardcoded field lists are intentional for now
- Do NOT implement the email API — `send_review_email()` placeholder stays as `alert()`
- Do NOT add `person_passcode` DB field — out of scope for this sprint
---
## Testing
Run existing badge tests after any changes:
```bash
npm run test:unit
npx playwright test tests/events/badges/
```
Baseline: all badge tests passing as of 2026-02-26 (`f5e98b8c`).
Add `data-testid` attributes to key interactive elements:
- `badge-review-save-btn`
- `badge-review-cancel-btn`
- `badge-review-full-name-input`
- `badge-review-agree-to-tc-checkbox`

View File

@@ -1,3 +1,6 @@
# Aether (AE) Event Badges Module (v3)
# Aether (AE) Events - Badges Module (v3)
This directory contains the files for the new Event Badges module (v3). Detailed documentation to follow.
This directory contains the files for the new Event Badges module (v3).
Detailed documentation can be found here:
@documentation/MODULE__AE_Events_Badges.md

View File

@@ -1,199 +0,0 @@
<script lang="ts">
import { untrack } from 'svelte';
interface Props {
/** @type {import('./$types').PageData} */
data: any;
log_lvl?: number;
}
let { data, log_lvl = 0 }: Props = $props();
// *** Import Svelte specific
// import { goto } from '$app/navigation';
// *** Import other supporting libraries
import { browser } from '$app/environment';
import { liveQuery } from 'dexie';
// *** Import Aether specific variables and functions
// import type { key_val } from '$lib/ae_stores';
// import { ae_util } from '$lib/ae_utils/ae_utils';
// import { core_func } from '$lib/ae_core_functions';
import { ae_loc } from '$lib/stores/ae_stores';
import { db_events } from '$lib/ae_events/db_events';
// import { ae_snip, ae_loc, ae_sess, ae_api, ae_trig, slct, slct_trigger } from '$lib/ae_stores';
import {
events_loc,
events_sess,
events_slct,
events_trigger
} from '$lib/stores/ae_events_stores';
// import { events_func } from '$lib/ae_events_functions';
import Comp_badge_obj_view from './ae_comp__badge_obj_view.svelte';
import { page } from '$app/state';
import { LoaderCircle } from 'lucide-svelte';
// *** Variables
// Use page.params for robust reactivity in Svelte 5
let event_badge_id = $derived(page.params.badge_id);
// Track if we are waiting for initial IDB result
let is_loading_idb = $state(true);
// let url_test_val = $derived(data.url.searchParams.get('test_val'));
// $effect(() => {
// console.log(`URL test_val = ${url_test_val}`);
// });
let lq__event_badge_obj = $derived(
liveQuery(async () => {
if (!event_badge_id) return null;
if (log_lvl) {
console.log(
`*** LiveQuery: lq__event_badge_obj *** event_badge_id=${event_badge_id}`
);
}
let results = await db_events.badge.get(event_badge_id);
if (log_lvl) {
console.log(
`*** LiveQuery: lq__event_badge_obj *** results=`,
results
);
}
return results;
})
);
// SIDE EFFECT: Update loading state when the observable value changes
$effect(() => {
if ($lq__event_badge_obj !== undefined) {
untrack(() => is_loading_idb = false);
}
});
let lq__event_badge_template_obj = $derived(
liveQuery(async () => {
let results = await db_events.badge_template.get(
$lq__event_badge_obj?.event_badge_template_id ?? ''
); // null or undefined does not reset things like '' does
if (log_lvl) {
console.log(
`*** LiveQuery: lq__event_badge_template_obj *** event_badge_template_id=${
$lq__event_badge_obj?.event_badge_template_id ?? ''
}`,
results
);
}
// Check if results are different than the current session version stored under $events_slct
// if ($events_slct.event_badge_obj && results) {
// if (JSON.stringify($events_slct.event_badge_obj) !== JSON.stringify(results)) {
// $events_slct.event_badge_obj = { ...results };
// }
// }
return results;
})
);
let is_review_mode: boolean = $state(false);
// *** Functions and Logic
import { onMount } from 'svelte';
let lq__event_obj: any = $state(undefined);
onMount(() => {
const observable = liveQuery(() =>
db_events.event.get($events_slct?.event_id ?? '')
);
const subscription = observable.subscribe((value) => {
lq__event_obj = value;
});
if (browser && window.location.hash === '#review') {
is_review_mode = true;
$ae_loc.edit_mode = true;
} else {
is_review_mode = false;
$ae_loc.edit_mode = false;
}
return () => {
subscription.unsubscribe();
};
});
</script>
<svelte:head>
<title>
&AElig;: Badge -
{$lq__event_badge_obj?.given_name ?? '-- not set --'}
{$lq__event_badge_obj?.family_name
? $lq__event_badge_obj?.family_name.charAt(0) + '.'
: ''}
- Badges v3 -
{$events_loc?.title}
</title>
</svelte:head>
<!-- badge ID +page: Where is here??? -->
<!-- event {data.params.event_id} / badge {data.params.badge_id} -->
{#if $lq__event_badge_obj}
<header
class="
w-full
flex flex-row gap-1 items-center justify-between
border-b border-gray-300
mb-2
pb-2
"
>
<h2 class="text-2xl font-bold">
Badge:
{#if $lq__event_badge_obj.full_name}
{$lq__event_badge_obj.full_name}
{:else if $lq__event_badge_obj.given_name}
{$lq__event_badge_obj.given_name}
{:else}
-- no name --
{/if}
</h2>
<a
href={`/events/${$lq__event_badge_obj.event_id}/badges`}
class="text-sm italic text-blue-600 hover:underline"
>
<span class="fas fa-search"></span>
Back to Search
</a>
</header>
{#if $lq__event_badge_obj && $lq__event_badge_obj.event_id && event_badge_id}
<Comp_badge_obj_view
event_id={$lq__event_badge_obj.event_id as string}
event_badge_id={event_badge_id as string}
{lq__event_badge_obj}
{is_review_mode}
{lq__event_badge_template_obj}
/>
{/if}
{:else if is_loading_idb || !event_badge_id}
<div class="flex flex-col items-center justify-center p-20 gap-4 opacity-50">
<LoaderCircle size="3em" class="animate-spin" />
<p class="text-xl font-bold text-center">Loading Badge Details...</p>
</div>
{:else}
<div class="card p-8 variant-soft-error border border-error-500/30 text-center space-y-4">
<h2 class="h2 font-black text-error-500">Badge Not Found</h2>
<p class="opacity-70">No record found locally for ID: <span class="font-mono">{event_badge_id}</span></p>
<button class="btn variant-filled-primary" onclick={() => window.location.reload()}>
<span class="fas fa-sync mr-2"></span> Force Refresh
</button>
</div>
{/if}

View File

@@ -125,12 +125,12 @@
editable_badge_type_code =
$lq__event_badge_obj.badge_type_code ?? null;
// Only set the local edit state — never touch $ae_loc.edit_mode here.
// $ae_loc.edit_mode is a user preference; this component must not override it.
if (is_review_mode) {
edit_mode_active = true;
$ae_loc.edit_mode = true;
} else {
edit_mode_active = false; // Ensure it starts off if not in review mode
$ae_loc.edit_mode = false;
edit_mode_active = false;
}
}
});
@@ -420,7 +420,6 @@
update_complete = true;
if (!is_review_mode) {
edit_mode_active = false;
$ae_loc.edit_mode = false;
}
return;
}
@@ -437,7 +436,6 @@
update_complete = true;
if (!is_review_mode) {
edit_mode_active = false;
$ae_loc.edit_mode = false;
}
// Optionally, refresh the $lq__event_badge_obj if needed, though Dexie might handle it
} catch (error) {
@@ -469,7 +467,6 @@
}
if (!is_review_mode) {
edit_mode_active = false;
$ae_loc.edit_mode = false;
}
update_status = 'idle';
update_complete = true;
@@ -509,6 +506,9 @@
print_status = 'done';
console.log(`Badge printed. Count: ${data_to_update.print_count}`);
// Trigger browser print dialog (records the print count first so it's always logged)
if (browser) window.print();
// Brief success flash, then return to badge search
setTimeout(() => {
goto(`/events/${event_id}/badges`);
@@ -658,7 +658,6 @@ onkeypress={() => {
"
onclick={() => {
edit_mode_active = true;
$ae_loc.edit_mode = true;
}}
title="Edit Badge Information"
data-testid="badge-edit-btn"

View File

@@ -0,0 +1,382 @@
<script lang="ts">
/**
* Badge Review Form
*
* A form-based view of badge fields for attendees and staff to review/edit.
* Does NOT render the badge layout — that is handled by ae_comp__badge_obj_view.svelte.
*
* Access:
* - Attendees (passcode-validated): can edit fields listed in can_edit_fields
* - Trusted staff: can_edit_fields + additional staff-only fields
* - Administrators: all override fields
*
* Field display is driven by the `can_edit_fields` prop, which comes from
* event.mod_badges_json.edit_permissions (see ae_comp__event_settings_badges_form.svelte).
*/
interface Props {
event_id: string;
event_badge_id: string;
lq__event_badge_obj?: any;
can_edit_fields?: string[]; // Whitelist of field names attendee/staff may edit
is_staff?: boolean; // True = staff view (shows read-only source fields too)
log_lvl?: number;
}
let {
event_id,
event_badge_id,
lq__event_badge_obj,
can_edit_fields = ['full_name_override', 'professional_title_override', 'affiliations_override', 'location_override'],
is_staff = false,
log_lvl = 0
}: Props = $props();
import type { key_val } from '$lib/stores/ae_stores';
import { ae_api } from '$lib/stores/ae_stores';
import { events_func } from '$lib/ae_events_functions';
// *** Editable field state (only fields in can_edit_fields are active)
let editable_full_name_override: string = $state('');
let editable_professional_title_override: string = $state('');
let editable_affiliations_override: string = $state('');
let editable_location_override: string = $state('');
let editable_email: string = $state('');
let editable_badge_type_code: string = $state('');
let save_status: 'idle' | 'loading' | 'done' | 'error' = $state('idle');
// Initialize from badge object
$effect(() => {
if ($lq__event_badge_obj) {
editable_full_name_override =
$lq__event_badge_obj.full_name_override ?? $lq__event_badge_obj.full_name ?? '';
editable_professional_title_override =
$lq__event_badge_obj.professional_title_override ?? $lq__event_badge_obj.professional_title ?? '';
editable_affiliations_override =
$lq__event_badge_obj.affiliations_override ?? $lq__event_badge_obj.affiliations ?? '';
editable_location_override =
$lq__event_badge_obj.location_override ?? $lq__event_badge_obj.location ?? '';
editable_email =
$lq__event_badge_obj.email ?? '';
editable_badge_type_code =
$lq__event_badge_obj.badge_type_code ?? '';
}
});
// Derived: is this field editable for the current user?
function can_edit(field: string): boolean {
return can_edit_fields.includes('*') || can_edit_fields.includes(field);
}
function has_override(base_field: string, override_field: string): boolean {
return !!(
$lq__event_badge_obj?.[override_field] &&
$lq__event_badge_obj[override_field] !== $lq__event_badge_obj[base_field]
);
}
async function handle_save() {
if (!$lq__event_badge_obj?.event_badge_id) return;
save_status = 'loading';
const data_to_update: key_val = {};
// Only include fields in can_edit_fields that have changed
if (can_edit('full_name_override')) {
const original = $lq__event_badge_obj.full_name_override ?? $lq__event_badge_obj.full_name ?? '';
if (editable_full_name_override !== original) {
data_to_update.full_name_override = editable_full_name_override || null;
}
}
if (can_edit('professional_title_override')) {
const original = $lq__event_badge_obj.professional_title_override ?? $lq__event_badge_obj.professional_title ?? '';
if (editable_professional_title_override !== original) {
data_to_update.professional_title_override = editable_professional_title_override || null;
}
}
if (can_edit('affiliations_override')) {
const original = $lq__event_badge_obj.affiliations_override ?? $lq__event_badge_obj.affiliations ?? '';
if (editable_affiliations_override !== original) {
data_to_update.affiliations_override = editable_affiliations_override || null;
}
}
if (can_edit('location_override')) {
const original = $lq__event_badge_obj.location_override ?? $lq__event_badge_obj.location ?? '';
if (editable_location_override !== original) {
data_to_update.location_override = editable_location_override || null;
}
}
if (can_edit('email')) {
if (editable_email !== ($lq__event_badge_obj.email ?? '')) {
data_to_update.email = editable_email || null;
}
}
if (can_edit('badge_type_code')) {
if (editable_badge_type_code !== ($lq__event_badge_obj.badge_type_code ?? '')) {
data_to_update.badge_type_code = editable_badge_type_code || null;
}
}
if (Object.keys(data_to_update).length === 0) {
save_status = 'done';
return;
}
try {
await events_func.update_ae_obj__event_badge({
api_cfg: $ae_api,
event_id,
event_badge_id: $lq__event_badge_obj.event_badge_id,
data_kv: data_to_update,
log_lvl
});
save_status = 'done';
} catch (error) {
console.error('Error saving badge review changes:', error);
save_status = 'error';
}
}
function handle_cancel() {
if ($lq__event_badge_obj) {
editable_full_name_override =
$lq__event_badge_obj.full_name_override ?? $lq__event_badge_obj.full_name ?? '';
editable_professional_title_override =
$lq__event_badge_obj.professional_title_override ?? $lq__event_badge_obj.professional_title ?? '';
editable_affiliations_override =
$lq__event_badge_obj.affiliations_override ?? $lq__event_badge_obj.affiliations ?? '';
editable_location_override =
$lq__event_badge_obj.location_override ?? $lq__event_badge_obj.location ?? '';
editable_email = $lq__event_badge_obj.email ?? '';
editable_badge_type_code = $lq__event_badge_obj.badge_type_code ?? '';
}
save_status = 'idle';
}
</script>
{#if $lq__event_badge_obj}
<div class="space-y-6 max-w-2xl">
<!-- Source data header (staff only) -->
{#if is_staff}
<div class="card p-3 variant-soft-surface text-sm space-y-1">
<p class="font-semibold text-xs uppercase tracking-wide text-gray-500">Source Data (from registration system — read only)</p>
<div class="grid grid-cols-2 gap-x-4 gap-y-0.5 text-xs">
<span class="text-gray-500">Given Name</span>
<span>{$lq__event_badge_obj.given_name ?? '—'}</span>
<span class="text-gray-500">Family Name</span>
<span>{$lq__event_badge_obj.family_name ?? '—'}</span>
<span class="text-gray-500">Full Name</span>
<span>{$lq__event_badge_obj.full_name ?? '—'}</span>
<span class="text-gray-500">Professional Title</span>
<span>{$lq__event_badge_obj.professional_title ?? '—'}</span>
<span class="text-gray-500">Affiliations</span>
<span>{$lq__event_badge_obj.affiliations ?? '—'}</span>
<span class="text-gray-500">Location</span>
<span>{$lq__event_badge_obj.location ?? '—'}</span>
<span class="text-gray-500">Badge Type</span>
<span>{$lq__event_badge_obj.badge_type_code ?? '—'}</span>
</div>
</div>
{/if}
<!-- Editable fields -->
<div class="space-y-4">
<p class="text-sm text-gray-500 italic">
Fields below are what will appear on your printed badge.
{#if can_edit_fields.length > 0}
You may edit the highlighted fields.
{/if}
</p>
<!-- Full Name -->
{#if can_edit('full_name_override') || is_staff}
<div class="space-y-1">
<label for="badge-review-full-name" class="block text-sm font-medium">
Full Name (on badge)
{#if has_override('full_name', 'full_name_override')}
<span class="text-xs text-amber-600 ml-1">(overridden)</span>
{/if}
</label>
{#if can_edit('full_name_override')}
<input
id="badge-review-full-name"
type="text"
class="input w-full"
bind:value={editable_full_name_override}
placeholder={$lq__event_badge_obj.full_name ?? 'Full name'}
data-testid="badge-review-full-name-input"
/>
{:else}
<p class="input w-full bg-gray-50 opacity-60 cursor-not-allowed">
{$lq__event_badge_obj.full_name_override ?? $lq__event_badge_obj.full_name ?? '—'}
</p>
{/if}
</div>
{/if}
<!-- Professional Title -->
{#if can_edit('professional_title_override') || is_staff}
<div class="space-y-1">
<label for="badge-review-pro-title" class="block text-sm font-medium">
Professional Title (on badge)
{#if has_override('professional_title', 'professional_title_override')}
<span class="text-xs text-amber-600 ml-1">(overridden)</span>
{/if}
</label>
{#if can_edit('professional_title_override')}
<input
id="badge-review-pro-title"
type="text"
class="input w-full"
bind:value={editable_professional_title_override}
placeholder={$lq__event_badge_obj.professional_title ?? 'Professional title'}
data-testid="badge-review-pro-title-input"
/>
{:else}
<p class="input w-full bg-gray-50 opacity-60 cursor-not-allowed">
{$lq__event_badge_obj.professional_title_override ?? $lq__event_badge_obj.professional_title ?? '—'}
</p>
{/if}
</div>
{/if}
<!-- Affiliations -->
{#if can_edit('affiliations_override') || is_staff}
<div class="space-y-1">
<label for="badge-review-affiliations" class="block text-sm font-medium">
Affiliations / Organization (on badge)
{#if has_override('affiliations', 'affiliations_override')}
<span class="text-xs text-amber-600 ml-1">(overridden)</span>
{/if}
</label>
{#if can_edit('affiliations_override')}
<textarea
id="badge-review-affiliations"
class="textarea w-full"
rows="2"
bind:value={editable_affiliations_override}
placeholder={$lq__event_badge_obj.affiliations ?? 'Affiliations / organization'}
data-testid="badge-review-affiliations-input"
></textarea>
{:else}
<p class="input w-full bg-gray-50 opacity-60 cursor-not-allowed">
{$lq__event_badge_obj.affiliations_override ?? $lq__event_badge_obj.affiliations ?? '—'}
</p>
{/if}
</div>
{/if}
<!-- Location -->
{#if can_edit('location_override') || is_staff}
<div class="space-y-1">
<label for="badge-review-location" class="block text-sm font-medium">
Location (on badge)
{#if has_override('location', 'location_override')}
<span class="text-xs text-amber-600 ml-1">(overridden)</span>
{/if}
</label>
{#if can_edit('location_override')}
<input
id="badge-review-location"
type="text"
class="input w-full"
bind:value={editable_location_override}
placeholder={$lq__event_badge_obj.location ?? 'City, State/Province, Country'}
data-testid="badge-review-location-input"
/>
{:else}
<p class="input w-full bg-gray-50 opacity-60 cursor-not-allowed">
{$lq__event_badge_obj.location_override ?? $lq__event_badge_obj.location ?? '—'}
</p>
{/if}
</div>
{/if}
<!-- Email (staff only by default) -->
{#if can_edit('email')}
<div class="space-y-1">
<label for="badge-review-email" class="block text-sm font-medium">
Email
</label>
<input
id="badge-review-email"
type="email"
class="input w-full"
bind:value={editable_email}
placeholder="Email address"
data-testid="badge-review-email-input"
/>
</div>
{/if}
<!-- Badge Type Code (staff only by default) -->
{#if can_edit('badge_type_code')}
<div class="space-y-1">
<label for="badge-review-badge-type" class="block text-sm font-medium">
Badge Type Code
</label>
<input
id="badge-review-badge-type"
type="text"
class="input w-full"
bind:value={editable_badge_type_code}
placeholder="e.g. current_member, guest, staff"
data-testid="badge-review-badge-type-input"
/>
</div>
{/if}
</div>
<!-- Save / Cancel -->
{#if can_edit_fields.length > 0}
<div class="flex flex-row gap-2 items-center">
<button
type="button"
class="btn preset-filled-primary"
class:preset-tonal-primary={save_status === 'loading'}
class:preset-filled-success-500={save_status === 'done'}
class:preset-filled-error-500={save_status === 'error'}
disabled={save_status === 'loading'}
onclick={handle_save}
data-testid="badge-review-save-btn"
>
{#if save_status === 'loading'}
<span class="fas fa-spinner fa-spin mr-2"></span> Saving…
{:else if save_status === 'done'}
<span class="fas fa-check mr-2"></span> Saved
{:else if save_status === 'error'}
<span class="fas fa-exclamation-triangle mr-2"></span> Error — try again
{:else}
<span class="fas fa-save mr-2"></span> Save Changes
{/if}
</button>
{#if save_status !== 'done'}
<button
type="button"
class="btn preset-tonal-surface"
onclick={handle_cancel}
data-testid="badge-review-cancel-btn"
>
Cancel
</button>
{/if}
</div>
{/if}
<!-- Print info (read-only) -->
{#if is_staff && $lq__event_badge_obj.print_count !== undefined}
<div class="text-xs text-gray-500 space-y-0.5 border-t border-gray-200 pt-3 mt-2">
<p>Print count: <strong>{$lq__event_badge_obj.print_count ?? 0}</strong></p>
{#if $lq__event_badge_obj.print_first_datetime}
<p>First printed: {new Date($lq__event_badge_obj.print_first_datetime).toLocaleString()}</p>
{/if}
{#if $lq__event_badge_obj.print_last_datetime}
<p>Last printed: {new Date($lq__event_badge_obj.print_last_datetime).toLocaleString()}</p>
{/if}
</div>
{/if}
</div>
{/if}

View File

@@ -1,7 +1,199 @@
<script lang="ts">
// Page for printing badges
interface Props {
/** @type {import('./$types').PageData} */
data: any;
log_lvl?: number;
}
let { data, log_lvl = 0 }: Props = $props();
import { untrack } from 'svelte';
import { liveQuery } from 'dexie';
import { ae_loc } from '$lib/stores/ae_stores';
import { db_events } from '$lib/ae_events/db_events';
import { events_loc } from '$lib/stores/ae_events_stores';
import { page } from '$app/state';
import { ArrowLeft, Eye, LoaderCircle, Mail, Printer } from 'lucide-svelte';
import Comp_badge_obj_view from '../ae_comp__badge_obj_view.svelte';
let event_badge_id = $derived(page.params.badge_id);
let event_id = $derived(page.params.event_id);
let is_loading_idb = $state(true);
let lq__event_badge_obj = $derived(
liveQuery(async () => {
if (!event_badge_id) return null;
return await db_events.badge.get(event_badge_id);
})
);
$effect(() => {
if ($lq__event_badge_obj !== undefined) {
untrack(() => (is_loading_idb = false));
}
});
let lq__event_badge_template_obj = $derived(
liveQuery(async () => {
return await db_events.badge_template.get(
$lq__event_badge_obj?.event_badge_template_id ?? ''
);
})
);
// Access level shortcuts
let is_trusted = $derived($ae_loc.trusted_access === true);
let is_edit_mode = $derived($ae_loc.edit_mode === true);
// Print state derived from badge
let print_count = $derived($lq__event_badge_obj?.print_count ?? 0);
let is_printed = $derived(print_count >= 1);
function build_review_url(): string {
// TODO: append ?passcode=... when person_passcode is added to the event_badge schema
return `/events/${$lq__event_badge_obj?.event_id}/badges/${$lq__event_badge_obj?.event_badge_id}/review`;
}
function obscure_email(email: string | null | undefined): string {
if (!email) return '';
const at = email.indexOf('@');
if (at < 0) return email;
return `${email.slice(0, Math.min(3, at))}***${email.slice(at)}`;
}
// TODO: replace alert with actual email API call when available
function send_review_email() {
const badge = $lq__event_badge_obj;
const name =
badge?.full_name_override
?? badge?.full_name
?? `${badge?.given_name ?? ''} ${badge?.family_name ?? ''}`.trim();
const email = is_trusted
? (badge?.email ?? '(no email on file)')
: obscure_email(badge?.email);
const event_name = $events_loc?.title ?? 'this event';
alert(`PLACEHOLDER: An email will be sent to ${name} at ${email}. Use that link to review your ${event_name} badge.`);
}
// Print mode: display only — ae_comp__badge_obj_view handles print count + window.print()
// The "Print Now" header button below only triggers window.print() for convenience;
// print count is incremented by the print button inside ae_comp__badge_obj_view.
</script>
<h1 class="h1">Print Badges</h1>
<svelte:head>
<title>
&AElig;: Print Badge —
{$lq__event_badge_obj?.full_name_override ?? $lq__event_badge_obj?.full_name ?? '—'}
{$events_loc?.title ? ` — ${$events_loc.title}` : ''}
</title>
</svelte:head>
<p>This page will be used for printing badges.</p>
{#if $lq__event_badge_obj && $lq__event_badge_obj.event_id && event_badge_id}
<!-- Print chrome: hidden when browser prints (print:hidden) -->
<header class="print:hidden w-full flex flex-row flex-wrap gap-2 items-center justify-between border-b border-gray-300 mb-3 pb-2">
<!-- Left: Back to Search + name + already-printed warning -->
<div class="flex flex-row gap-2 items-center min-w-0">
<a
href={`/events/${$lq__event_badge_obj.event_id}/badges`}
class="btn btn-sm preset-tonal-surface flex items-center gap-1 shrink-0"
title="Back to badge search"
>
<ArrowLeft size="1em" />
<span class="hidden sm:inline">Search</span>
</a>
<div class="flex flex-col min-w-0">
<h2 class="text-base font-bold truncate">
{$lq__event_badge_obj.full_name_override ?? $lq__event_badge_obj.full_name ?? '— no name —'}
</h2>
{#if is_printed}
<p class="text-xs text-amber-600 font-medium">
Printed {print_count}×
{#if $lq__event_badge_obj.print_last_datetime}
— last {new Date($lq__event_badge_obj.print_last_datetime).toLocaleString()}
{/if}
</p>
{/if}
</div>
</div>
<!-- Right: Action buttons -->
<div class="flex flex-row gap-1 items-center shrink-0">
<!-- 1. Print Now: Trusted+, not printed OR Edit Mode (reprint) -->
{#if is_trusted && (!is_printed || is_edit_mode)}
<button
type="button"
class="btn btn-sm preset-tonal-primary flex items-center gap-1"
onclick={() => window.print()}
title={is_printed ? `Reprint badge (printed ${print_count}×)` : 'Print badge now'}
>
<Printer size="1em" />
{#if is_printed}
<span class="font-bold text-xs">{print_count}×</span>
{/if}
<span class="hidden sm:inline">Print Now</span>
</button>
{/if}
<!-- 2. Direct Review link: Trusted + Edit Mode -->
{#if is_trusted && is_edit_mode}
<a
href={build_review_url()}
class="btn btn-sm preset-tonal-secondary flex items-center gap-1"
title="Open badge review page"
>
<Eye size="1em" />
<span class="hidden sm:inline">Review</span>
</a>
{/if}
<!-- 3. Email Review Link: all if not printed; Trusted+Edit if printed
TODO: replace alert with actual email API call -->
{#if !is_printed || (is_trusted && is_edit_mode)}
<button
type="button"
class="btn btn-sm preset-tonal-surface flex items-center gap-1"
onclick={() => send_review_email()}
title="Email a review link to this attendee"
>
<Mail size="1em" />
<span class="hidden sm:inline">Email Link</span>
</button>
{/if}
</div>
</header>
<!-- Badge render — ae_comp__badge_obj_view handles print count + window.print() -->
<Comp_badge_obj_view
event_id={$lq__event_badge_obj.event_id as string}
event_badge_id={event_badge_id as string}
{lq__event_badge_obj}
{lq__event_badge_template_obj}
is_review_mode={false}
/>
{:else if is_loading_idb || !event_badge_id}
<div class="flex flex-col items-center justify-center p-20 gap-4 opacity-50">
<LoaderCircle size="3em" class="animate-spin" />
<p class="text-xl font-bold text-center">Loading Badge...</p>
</div>
{:else}
<div class="card p-8 text-center space-y-4">
<h2 class="text-2xl font-bold text-error-500">Badge Not Found</h2>
<p class="opacity-70">No record found for ID: <span class="font-mono">{event_badge_id}</span></p>
<div class="flex gap-2 justify-center">
<button class="btn preset-filled-primary" onclick={() => window.location.reload()}>
Retry
</button>
<a href={`/events/${event_id}/badges`} class="btn preset-tonal-surface">
Back to Search
</a>
</div>
</div>
{/if}

View File

@@ -0,0 +1,31 @@
/** @type {import('./$types').PageLoad} */
console.log(`Events - Badges [badge_id]/print +page.ts start`);
import { browser } from '$app/environment';
import { events_func } from '$lib/ae_events_functions';
// Loads badge + template for the print page (non-blocking background refresh)
export async function load({ params, parent }) {
const log_lvl: number = 0;
const parent_data = await parent();
const account_id = parent_data.account_id;
const ae_acct = parent_data[account_id];
if (browser) {
const event_id = params.event_id;
const event_badge_id = params.badge_id;
if (event_badge_id) {
events_func.load_ae_obj_id__event_badge({
api_cfg: ae_acct.api,
event_badge_id: event_badge_id,
event_id: event_id,
inc_template: true,
log_lvl: log_lvl
});
}
}
return { params };
}

View File

@@ -1,7 +1,358 @@
<script lang="ts">
// Page for reviewing badges
/**
* Badge Review Page
*
* Access modes (checked in order):
* 1. Administrator ($ae_loc.administrator_access) — full edit access, no passcode needed
* 2. Trusted staff ($ae_loc.trusted_access) — trusted-level fields, no passcode needed
* 3. Attendee (URL ?passcode=... matches badge.person_passcode) — attendee-level fields
* 4. Passcode entry form — if none of the above, prompt for passcode
*
* Editable fields per level come from event.mod_badges_json.edit_permissions.
* Defaults apply if not configured in event settings.
*/
interface Props {
/** @type {import('./$types').PageData} */
data: any;
log_lvl?: number;
}
let { data, log_lvl = 0 }: Props = $props();
import { untrack } from 'svelte';
import { liveQuery } from 'dexie';
import { ae_loc } from '$lib/stores/ae_stores';
import { db_events } from '$lib/ae_events/db_events';
import { events_loc } from '$lib/stores/ae_events_stores';
import { page } from '$app/state';
import {
ArrowLeft,
Check,
Link,
LoaderCircle,
Mail,
Printer,
ShieldCheck,
User,
UserCheck
} from 'lucide-svelte';
import Comp_badge_review_form from '../ae_comp__badge_review_form.svelte';
let event_badge_id = $derived(page.params.badge_id);
let event_id = $derived(page.params.event_id);
let is_loading_idb = $state(true);
// *** LiveQuery: badge object
let lq__event_badge_obj = $derived(
liveQuery(async () => {
if (!event_badge_id) return null;
return await db_events.badge.get(event_badge_id);
})
);
$effect(() => {
if ($lq__event_badge_obj !== undefined) {
untrack(() => (is_loading_idb = false));
}
});
// TODO: Load event.mod_badges_json.edit_permissions for per-event field config.
// Hardcoded defaults for now — revisit after basic flow is working.
const default_authenticated_fields = [
'full_name_override',
'professional_title_override',
'affiliations_override',
'location_override'
];
const default_trusted_fields = [
'full_name_override',
'professional_title_override',
'affiliations_override',
'location_override',
'email',
'badge_type_code'
];
// *** Passcode logic
let url_passcode = $derived(page.url?.searchParams?.get('passcode') ?? '');
let entered_passcode = $state('');
let passcode_checked = $state(false);
let passcode_valid = $state(false);
let passcode_error = $state('');
// Auto-validate URL passcode once badge is loaded
$effect(() => {
if (url_passcode && $lq__event_badge_obj && !passcode_checked) {
untrack(() => {
check_passcode(url_passcode);
});
}
});
function check_passcode(code: string) {
passcode_checked = true;
const badge_passcode = $lq__event_badge_obj?.person_passcode;
if (!badge_passcode) {
// No passcode set on badge — deny access to prevent unintentional open access
passcode_valid = false;
passcode_error = 'This badge does not have a review link enabled. Please contact event staff.';
} else if (code && code === badge_passcode) {
passcode_valid = true;
passcode_error = '';
} else {
passcode_valid = false;
passcode_error = 'Incorrect passcode. Please check your email link or contact event staff.';
}
}
// *** Access level shortcuts
let is_administrator = $derived($ae_loc?.administrator_access === true);
let is_trusted = $derived($ae_loc?.trusted_access === true);
let is_edit_mode = $derived($ae_loc?.edit_mode === true);
let has_staff_access = $derived(is_administrator || is_trusted);
let has_attendee_access = $derived(passcode_valid);
let has_any_access = $derived(has_staff_access || has_attendee_access);
// *** Print state derived from badge
let print_count = $derived($lq__event_badge_obj?.print_count ?? 0);
let is_printed = $derived(print_count >= 1);
// *** Copy review link state
let copy_status: 'idle' | 'copied' = $state('idle');
function build_review_url(): string {
// TODO: append ?passcode=... when person_passcode is added to the event_badge schema
return `/events/${$lq__event_badge_obj?.event_id}/badges/${$lq__event_badge_obj?.event_badge_id}/review`;
}
async function copy_review_link() {
const full_url = window.location.origin + build_review_url();
try {
await navigator.clipboard.writeText(full_url);
copy_status = 'copied';
setTimeout(() => {
copy_status = 'idle';
}, 2000);
} catch {
console.error('Clipboard write failed');
}
}
function obscure_email(email: string | null | undefined): string {
if (!email) return '';
const at = email.indexOf('@');
if (at < 0) return email;
return `${email.slice(0, Math.min(3, at))}***${email.slice(at)}`;
}
// TODO: replace alert with actual email API call when available
function send_review_email() {
const badge = $lq__event_badge_obj;
const name =
badge?.full_name_override
?? badge?.full_name
?? `${badge?.given_name ?? ''} ${badge?.family_name ?? ''}`.trim();
const email = is_trusted
? (badge?.email ?? '(no email on file)')
: obscure_email(badge?.email);
const event_name = $events_loc?.title ?? 'this event';
alert(`PLACEHOLDER: An email will be sent to ${name} at ${email}. Use that link to review your ${event_name} badge.`);
}
// *** Resolve editable field list based on access level
// Uses $derived.by() to return the array directly (not a function).
// TODO: Read from event.mod_badges_json.edit_permissions for per-event config.
let can_edit_fields: string[] = $derived.by(() => {
if (is_administrator) return ['*'];
if (is_trusted) return default_trusted_fields;
if (has_attendee_access) return default_authenticated_fields;
return [];
});
</script>
<h1 class="h1">Review Badges</h1>
<svelte:head>
<title>
&AElig;: Review Badge —
{$lq__event_badge_obj?.full_name_override ?? $lq__event_badge_obj?.full_name ?? '—'}
{$events_loc?.title ? ` — ${$events_loc.title}` : ''}
</title>
</svelte:head>
<p>This page will be used for reviewing badges.</p>
{#if $lq__event_badge_obj}
<!-- Page header -->
<header class="w-full flex flex-row flex-wrap gap-2 items-center justify-between border-b border-gray-300 mb-4 pb-2">
<!-- Left: Back to Search (staff only) + title + name -->
<div class="flex flex-row gap-2 items-center min-w-0">
{#if has_staff_access}
<a
href={`/events/${$lq__event_badge_obj.event_id}/badges`}
class="btn btn-sm preset-tonal-surface flex items-center gap-1 shrink-0"
title="Back to badge search"
>
<ArrowLeft size="1em" />
<span class="hidden sm:inline">Search</span>
</a>
{/if}
<div class="flex flex-col min-w-0">
<h2 class="text-base font-bold">Review Badge</h2>
<p class="text-sm text-gray-500 truncate">
{$lq__event_badge_obj.full_name_override ?? $lq__event_badge_obj.full_name ?? '— no name —'}
</p>
</div>
</div>
<!-- Right: Action buttons -->
<div class="flex flex-row gap-1 items-center shrink-0">
<!-- 1. Print Badge: Trusted+, not printed OR Edit Mode (reprint) -->
{#if is_trusted && (!is_printed || is_edit_mode)}
<a
href={`/events/${$lq__event_badge_obj.event_id}/badges/${$lq__event_badge_obj.event_badge_id}/print`}
class="btn btn-sm preset-tonal-primary flex items-center gap-1"
title={is_printed ? `Reprint badge (printed ${print_count}×)` : 'Print badge'}
>
<Printer size="1em" />
{#if is_printed}
<span class="font-bold text-xs">{print_count}×</span>
{/if}
<span class="hidden sm:inline">Print</span>
</a>
{/if}
<!-- 2. Copy review link to clipboard: Trusted + Edit Mode -->
{#if is_trusted && is_edit_mode}
<button
type="button"
class="btn btn-sm flex items-center gap-1"
class:preset-tonal-secondary={copy_status !== 'copied'}
class:preset-filled-success-500={copy_status === 'copied'}
onclick={() => copy_review_link()}
title="Copy review link to clipboard"
>
{#if copy_status === 'copied'}
<Check size="1em" />
<span class="hidden sm:inline">Copied!</span>
{:else}
<Link size="1em" />
<span class="hidden sm:inline">Copy Link</span>
{/if}
</button>
{/if}
<!-- 3. Email Review Link: all if not printed; Trusted+Edit if printed
TODO: replace alert with actual email API call -->
{#if !is_printed || (is_trusted && is_edit_mode)}
<button
type="button"
class="btn btn-sm preset-tonal-surface flex items-center gap-1"
onclick={() => send_review_email()}
title="Email a review link to this attendee"
>
<Mail size="1em" />
<span class="hidden sm:inline">Email Link</span>
</button>
{/if}
</div>
</header>
{#if has_any_access}
<div class="space-y-4">
<!-- Access level indicator -->
{#if is_administrator}
<p class="text-xs text-gray-400 flex items-center gap-1">
<ShieldCheck size="1em" class="text-purple-500" />
Administrator access — all fields editable
</p>
{:else if is_trusted}
<p class="text-xs text-gray-400 flex items-center gap-1">
<UserCheck size="1em" class="text-blue-500" />
Staff access — extended fields editable
</p>
{:else if has_attendee_access}
<p class="text-xs text-gray-400 flex items-center gap-1">
<User size="1em" class="text-green-500" />
Reviewing your badge information
</p>
{/if}
<Comp_badge_review_form
event_id={event_id as string}
event_badge_id={event_badge_id as string}
{lq__event_badge_obj}
{can_edit_fields}
is_staff={has_staff_access}
{log_lvl}
/>
</div>
{:else if !passcode_checked && !url_passcode}
<!-- Passcode entry (attendee navigates directly, no URL passcode) -->
<div class="card p-6 space-y-4 max-w-sm">
<h3 class="text-lg font-semibold">Enter Your Passcode</h3>
<p class="text-sm text-gray-500">
Enter the passcode from your badge review email to view and update your badge information.
</p>
<div class="space-y-2">
<label for="passcode-entry" class="block text-sm font-medium">Passcode</label>
<input
id="passcode-entry"
type="text"
class="input w-full font-mono tracking-widest"
bind:value={entered_passcode}
placeholder="Enter passcode"
onkeydown={(e) => { if (e.key === 'Enter') check_passcode(entered_passcode); }}
data-testid="badge-review-passcode-input"
/>
</div>
{#if passcode_error}
<p class="text-sm text-error-500">{passcode_error}</p>
{/if}
<button
type="button"
class="btn preset-filled-primary w-full"
onclick={() => check_passcode(entered_passcode)}
data-testid="badge-review-passcode-submit"
>
Access My Badge
</button>
</div>
{:else if passcode_checked && !passcode_valid}
<!-- Invalid passcode -->
<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
type="button"
class="btn preset-tonal-surface w-full"
onclick={() => { passcode_checked = false; passcode_error = ''; entered_passcode = ''; }}
>
Try Again
</button>
</div>
{/if}
{:else if is_loading_idb || !event_badge_id}
<div class="flex flex-col items-center justify-center p-20 gap-4 opacity-50">
<LoaderCircle size="3em" class="animate-spin" />
<p class="text-xl font-bold text-center">Loading Badge Information…</p>
</div>
{:else}
<div class="card p-8 text-center space-y-4">
<h2 class="text-2xl font-bold text-error-500">Badge Not Found</h2>
<p class="opacity-70">No record found for ID: <span class="font-mono">{event_badge_id}</span></p>
<button class="btn preset-filled-primary" onclick={() => window.location.reload()}>
Retry
</button>
</div>
{/if}

View File

@@ -0,0 +1,31 @@
/** @type {import('./$types').PageLoad} */
console.log(`Events - Badges [badge_id]/review +page.ts start`);
import { browser } from '$app/environment';
import { events_func } from '$lib/ae_events_functions';
// Loads badge + template for the review page (non-blocking background refresh)
export async function load({ params, parent }) {
const log_lvl: number = 0;
const parent_data = await parent();
const account_id = parent_data.account_id;
const ae_acct = parent_data[account_id];
if (browser) {
const event_id = params.event_id;
const event_badge_id = params.badge_id;
if (event_badge_id) {
events_func.load_ae_obj_id__event_badge({
api_cfg: ae_acct.api,
event_badge_id: event_badge_id,
event_id: event_id,
inc_template: false, // Review form doesn't need template
log_lvl: log_lvl
});
}
}
return { params };
}

View File

@@ -19,40 +19,93 @@
hide_badge_type = false
}: Props = $props();
// import { type Badge as BadgeType } from '$lib/ae_events/db_events';
import { ae_loc } from '$lib/stores/ae_stores';
import { events_loc } from '$lib/stores/ae_events_stores';
import { ae_util } from '$lib/ae_utils/ae_utils';
import {
LoaderCircle,
Badge,
Check,
Eye,
EyeOff,
Mail,
MapPin,
Tags,
FileSearch
FileSearch,
Link,
Printer,
User
} from 'lucide-svelte';
// Derived list of visible items (Standardized Pattern 2026-01-27)
// Track per-badge copy state for the "Review Link" clipboard button
let copy_status: Record<string, 'idle' | 'copied'> = $state({});
// Access level shortcuts
let is_trusted = $derived($ae_loc.trusted_access === true);
let is_edit_mode = $derived($ae_loc.edit_mode === true);
/**
* Obscures an email address for display to non-trusted users.
* e.g. john.doe@example.com → joh***@example.com
*/
function obscure_email(email: string | null | undefined): string {
if (!email) return '';
const at = email.indexOf('@');
if (at < 0) return email;
const visible = email.slice(0, Math.min(3, at));
return `${visible}***${email.slice(at)}`;
}
function build_review_url(event_badge_obj: any): string {
// TODO: append ?passcode=... when person_passcode is added to the event_badge schema
return `/events/${event_badge_obj.event_id}/badges/${event_badge_obj.event_badge_id}/review`;
}
async function copy_review_link(event_badge_obj: any) {
const url = build_review_url(event_badge_obj);
const full_url = window.location.origin + url;
try {
await navigator.clipboard.writeText(full_url);
copy_status[event_badge_obj.event_badge_id] = 'copied';
setTimeout(() => {
copy_status[event_badge_obj.event_badge_id] = 'idle';
}, 2000);
} catch {
console.error('Clipboard write failed');
}
}
// TODO: replace alert with actual email API call when available
function send_review_email(event_badge_obj: any) {
const name =
event_badge_obj?.full_name_override
?? event_badge_obj?.full_name
?? `${event_badge_obj?.given_name ?? ''} ${event_badge_obj?.family_name ?? ''}`.trim();
const email = is_trusted
? (event_badge_obj?.email ?? '(no email on file)')
: obscure_email(event_badge_obj?.email);
const event_name = $events_loc?.title ?? 'this event';
alert(`PLACEHOLDER: An email will be sent to ${name} at ${email}. Use that link to review your ${event_name} badge.`);
}
let visible_badge_obj_li = $derived(
(() => {
const list = $lq__event_badge_obj_li;
if (list === undefined || list === null) return null;
if (!Array.isArray(list)) return [];
const filtered = list.filter((item: any) => {
if (!item) return false;
// ADMIN/TRUSTED: See everything
if ($ae_loc.trusted_access) return true;
// PUBLIC: Filter hidden
return !item.hide;
if (is_trusted && is_edit_mode) {
// Edit Mode: show all non-hidden, including already-printed
return !item.hide;
}
// Everyone else (non-trusted or trusted not in edit mode):
// Only show badges that have not been printed yet
return (item.print_count ?? 0) < 1 && !item.hide;
});
if (log_lvl)
console.log(
`visible_badge_obj_li: Input=${list.length}, Output=${filtered.length}`
);
console.log(`visible_badge_obj_li: Input=${list.length}, Output=${filtered.length}`);
return filtered;
})()
);
@@ -67,21 +120,22 @@
<p>Loading badges...</p>
</div>
{:else if visible_badge_obj_li.length > 0}
<header
class="w-full flex flex-row gap-2 items-center justify-start mb-2 px-2"
>
<header class="w-full flex flex-row gap-2 items-center justify-start mb-2 px-2">
<h2 class="text-sm text-gray-500 font-normal">Results:</h2>
<span
class="badge preset-tonal-success font-bold text-lg px-3 py-1"
>
{visible_badge_obj_li.length}<span
class="text-gray-400 dark:text-gray-600">&times;</span
>
<span class="badge preset-tonal-success font-bold text-lg px-3 py-1">
{visible_badge_obj_li.length}<span class="text-gray-400 dark:text-gray-600">&times;</span>
</span>
</header>
<ul class="w-full space-y-1">
{#each visible_badge_obj_li as event_badge_obj (event_badge_obj.event_badge_id)}
{@const print_count = event_badge_obj.print_count ?? 0}
{@const is_printed = print_count >= 1}
{@const display_name =
event_badge_obj?.full_name_override
?? event_badge_obj?.full_name
?? `${event_badge_obj?.given_name ?? ''} ${event_badge_obj?.family_name ?? ''}`.trim()}
<li
class="
border border-surface-200 dark:border-surface-700 rounded-lg p-2
@@ -90,149 +144,157 @@
hover:border-primary-500 transition-colors
"
>
<div
class="flex flex-row flex-wrap gap-2 items-center justify-between w-full"
>
<div
class="flex flex-row flex-wrap gap-2 items-center justify-start grow"
>
<a
href={`/events/${event_badge_obj?.event_id}/badges/${event_badge_obj?.event_badge_id}`}
class="flex flex-row gap-2 items-center justify-start min-w-fit font-bold text-lg hover:text-primary-500"
>
<div class="flex flex-row flex-wrap gap-2 items-center justify-between w-full">
<!-- Left cluster: name (display only) + info chips -->
<div class="flex flex-row flex-wrap gap-x-3 gap-y-1 items-center grow min-w-0">
<!-- Name: always plain display — print action is a separate button -->
<span class="flex flex-row gap-1.5 items-center font-bold text-lg shrink-0">
{#if event_badge_obj?.hide}
<EyeOff
size="1.2em"
class="text-gray-400"
/>
<EyeOff size="1.1em" class="text-gray-400" />
{:else}
<Badge size="1.2em" />
<User size="1.1em" class="text-surface-400" />
{/if}
<span>{display_name}</span>
</span>
<span>
{#if event_badge_obj?.full_name_override}
{event_badge_obj?.full_name_override}
{:else if event_badge_obj?.full_name}
{event_badge_obj?.full_name}
{:else}
{event_badge_obj?.given_name}
{event_badge_obj?.family_name}
{/if}
</span>
{#if event_badge_obj?.print_count >= 1}
<span
class="badge preset-filled-success-500 flex items-center gap-1 text-xs py-0 px-1"
>
<Check size="1em" />
{event_badge_obj.print_count}
</span>
{/if}
</a>
{#if show_sensitive_fields}
<span
class="text-xs text-surface-400 flex items-center gap-1"
>
<!-- Email chip — obscured for non-trusted -->
{#if show_sensitive_fields && event_badge_obj?.email}
<span class="text-xs text-surface-400 flex items-center gap-1">
<Mail size="1em" />
{#if $ae_loc.trusted_access}
{event_badge_obj?.email}
{:else}
{event_badge_obj?.email?.replace(
/^(.{3}).*@/,
'$1...@'
) ?? ''}
{/if}
{is_trusted
? event_badge_obj.email
: obscure_email(event_badge_obj.email)}
</span>
{/if}
{#if !hide_affiliations && event_badge_obj.affiliations}
<span
class="text-xs text-surface-400 flex items-center gap-1"
>
<!-- Affiliations chip (prefers override) -->
{#if !hide_affiliations && (event_badge_obj?.affiliations_override ?? event_badge_obj?.affiliations)}
<span class="text-xs text-surface-400 flex items-center gap-1">
<MapPin size="1em" />
{event_badge_obj.affiliations}
{event_badge_obj.affiliations_override ?? event_badge_obj.affiliations}
</span>
{/if}
{#if !hide_badge_type && event_badge_obj.badge_type}
<span
class="text-xs italic text-primary-500 bg-primary-500/10 px-2 rounded-token flex items-center gap-1"
>
<!-- Badge type chip -->
{#if !hide_badge_type && event_badge_obj?.badge_type}
<span class="text-xs italic text-primary-500 bg-primary-500/10 px-2 rounded-token flex items-center gap-1">
<Tags size="1em" />
{event_badge_obj.badge_type}
</span>
{/if}
</div>
{#if $ae_loc.trusted_access}
<a
href={`/events/${event_badge_obj?.event_id}/badges/${event_badge_obj?.event_badge_id}#review`}
class="btn btn-sm preset-tonal-primary"
>
Review
</a>
{/if}
<!-- Right: up to 4 action buttons -->
<div class="flex flex-row gap-1 items-center shrink-0">
<!-- 1. Print Badge: Trusted+, not yet printed OR in Edit Mode (reprint) -->
{#if is_trusted && (!is_printed || is_edit_mode)}
<a
href={`/events/${event_badge_obj?.event_id}/badges/${event_badge_obj?.event_badge_id}/print`}
class="btn btn-sm preset-tonal-primary flex items-center gap-1"
title={is_printed ? `Reprint badge (printed ${print_count}×)` : 'Print badge'}
>
<Printer size="1em" />
{#if is_printed}
<span class="font-bold text-xs">{print_count}×</span>
{/if}
<span class="hidden sm:inline">Print</span>
</a>
{/if}
<!-- 2. Direct Review link: Trusted + Edit Mode (navigates to /review) -->
{#if is_trusted && is_edit_mode}
<a
href={build_review_url(event_badge_obj)}
class="btn btn-sm preset-tonal-secondary flex items-center gap-1"
title="Open badge review page"
>
<Eye size="1em" />
<span class="hidden sm:inline">Review</span>
</a>
{/if}
<!-- 3. Copy review link to clipboard: Trusted + Edit Mode -->
{#if is_trusted && is_edit_mode}
<button
type="button"
class="btn btn-sm flex items-center gap-1"
class:preset-tonal-secondary={copy_status[event_badge_obj.event_badge_id] !== 'copied'}
class:preset-filled-success-500={copy_status[event_badge_obj.event_badge_id] === 'copied'}
onclick={() => copy_review_link(event_badge_obj)}
title="Copy review link to clipboard"
>
{#if copy_status[event_badge_obj.event_badge_id] === 'copied'}
<Check size="1em" />
<span class="hidden sm:inline">Copied!</span>
{:else}
<Link size="1em" />
<span class="hidden sm:inline">Copy Link</span>
{/if}
</button>
{/if}
<!-- 4. Email Review Link: all if not printed; Trusted + Edit Mode if printed
TODO: replace alert with actual email API call -->
{#if !is_printed || (is_trusted && is_edit_mode)}
<button
type="button"
class="btn btn-sm preset-tonal-surface flex items-center gap-1"
onclick={() => send_review_email(event_badge_obj)}
title="Email a review link to this attendee"
>
<Mail size="1em" />
<span class="hidden sm:inline">Email Link</span>
</button>
{/if}
</div>
</div>
{#if $ae_loc.edit_mode}
<!-- Debug/metadata row — Edit Mode staff only -->
{#if is_edit_mode && is_trusted}
<div
class="flex flex-row flex-wrap gap-x-4 gap-y-1 items-center justify-start w-full mt-1 p-1.5 bg-surface-200/50 dark:bg-surface-800/50 rounded text-[10px] font-mono border border-surface-300 dark:border-surface-700 opacity-80"
>
<span class="flex items-center gap-1"
><span class="font-bold opacity-50">ID:</span>
{event_badge_obj?.event_badge_id}</span
>
<span class="flex items-center gap-1"
><span class="font-bold opacity-50">CR:</span>
{ae_util.iso_datetime_formatter(
event_badge_obj.created_on,
'datetime_iso_12_no_seconds'
)}</span
>
<span class="flex items-center gap-1"
><span class="font-bold opacity-50">UP:</span>
{ae_util.iso_datetime_formatter(
event_badge_obj.updated_on,
'datetime_iso_12_no_seconds'
)}</span
>
{#if event_badge_obj.print_first_datetime}
<span class="flex items-center gap-1"
><span class="font-bold opacity-50"
>FP:</span
>
{ae_util.iso_datetime_formatter(
event_badge_obj.print_first_datetime,
'datetime_iso_12_no_seconds'
)}</span
>
{/if}
{#if event_badge_obj.print_last_datetime}
<span class="flex items-center gap-1"
><span class="font-bold opacity-50"
>LP:</span
>
{ae_util.iso_datetime_formatter(
event_badge_obj.print_last_datetime,
'datetime_iso_12_no_seconds'
)}</span
>
{/if}
<span class="flex items-center gap-1">
<span class="font-bold opacity-50">ID:</span>
{event_badge_obj?.event_badge_id}
</span>
<span class="flex items-center gap-1">
<span class="font-bold opacity-50">CR:</span>
{ae_util.iso_datetime_formatter(event_badge_obj.created_on, 'datetime_iso_12_no_seconds')}
</span>
<span class="flex items-center gap-1">
<span class="font-bold opacity-50">UP:</span>
{ae_util.iso_datetime_formatter(event_badge_obj.updated_on, 'datetime_iso_12_no_seconds')}
</span>
<span class="flex items-center gap-1">
<span class="font-bold opacity-50">PC:</span>
{print_count}
</span>
<span class="flex items-center gap-1">
<span class="font-bold opacity-50">FP:</span>
{event_badge_obj.print_first_datetime
? ae_util.iso_datetime_formatter(event_badge_obj.print_first_datetime, 'datetime_iso_12_no_seconds')
: '—'}
</span>
<span class="flex items-center gap-1">
<span class="font-bold opacity-50">LP:</span>
{event_badge_obj.print_last_datetime
? ae_util.iso_datetime_formatter(event_badge_obj.print_last_datetime, 'datetime_iso_12_no_seconds')
: '—'}
</span>
</div>
{/if}
</li>
{/each}
</ul>
{:else}
<div
class="flex flex-col items-center justify-center p-20 opacity-50 text-center"
>
<div class="flex flex-col items-center justify-center p-20 opacity-50 text-center">
<FileSearch size="3em" class="mb-2 opacity-20 mx-auto" />
<p>
No badges found matching your criteria. Try adjusting your
filters.
</p>
<p>No badges found matching your criteria. Try adjusting your filters.</p>
</div>
{/if}
</section>

View File

@@ -92,12 +92,12 @@
{/each}
</div>
{#if $ae_loc.administrator_access}
{#if $ae_loc.administrator_access && $ae_loc.edit_mode}
<section class="card p-6 variant-soft-warning border-l-4 border-warning-500 mt-12 bg-surface-100 dark:bg-surface-800 shadow-lg">
<div class="flex items-center gap-4">
<span class="fas fa-tools text-3xl"></span>
<div>
<h3 class="text-xl font-bold">Administrative Access</h3>
<h3 class="text-xl font-bold">Event Admin Settings</h3>
<p>You have elevated privileges. Use the menu above to access advanced settings and reports.</p>
</div>
<a href="/events/{$events_slct.event_id}/settings" class="btn ae_btn_warning ml-auto">

View File

@@ -38,7 +38,7 @@
let show_upload_badge_modal: boolean = $state(false);
// Guard: Only allow administrators in edit mode
if (!$ae_loc.administrator_access || !$ae_loc.edit_mode) {
if (!$ae_loc.administrator_access) {
if (browser) {
alert(
'Access Denied: Administrative privileges and Edit Mode required.'

View File

@@ -8,6 +8,73 @@
let { mod_badges_json = $bindable({}), onsave }: Props = $props();
/**
* edit_permissions — controls which fields each access level may edit in the badge review form.
* Stored as mod_badges_json.edit_permissions.
*
* Structure:
* authenticated.can_edit — fields attendees (passcode-validated) may edit
* trusted.can_edit — fields trusted staff may edit
* administrator.can_edit — '*' (all) or a specific field list
*
* Default attendee fields: full_name_override, professional_title_override,
* affiliations_override, location_override
* Default trusted fields: above + email, badge_type_code
*/
const all_attendee_fields = [
{ key: 'full_name_override', label: 'Full Name (override)' },
{ key: 'professional_title_override', label: 'Professional Title (override)' },
{ key: 'affiliations_override', label: 'Affiliations (override)' },
{ key: 'location_override', label: 'Location (override)' }
];
const all_staff_fields = [
...all_attendee_fields,
{ key: 'email', label: 'Email' },
{ key: 'badge_type_code', label: 'Badge Type Code' }
];
// Ensure edit_permissions sub-object exists
function ensure_permissions() {
if (!mod_badges_json) return;
if (!mod_badges_json.edit_permissions) {
mod_badges_json.edit_permissions = {};
}
if (!mod_badges_json.edit_permissions.authenticated) {
mod_badges_json.edit_permissions.authenticated = {
can_edit: ['full_name_override', 'professional_title_override', 'affiliations_override', 'location_override']
};
}
if (!mod_badges_json.edit_permissions.trusted) {
mod_badges_json.edit_permissions.trusted = {
can_edit: ['full_name_override', 'professional_title_override', 'affiliations_override', 'location_override', 'email', 'badge_type_code']
};
}
if (!mod_badges_json.edit_permissions.administrator) {
mod_badges_json.edit_permissions.administrator = { can_edit: '*' };
}
}
function is_field_enabled(level: 'authenticated' | 'trusted', field_key: string): boolean {
const cfg = mod_badges_json?.edit_permissions?.[level]?.can_edit;
if (!cfg) return false;
if (cfg === '*') return true;
return Array.isArray(cfg) && cfg.includes(field_key);
}
function toggle_field(level: 'authenticated' | 'trusted', field_key: string) {
ensure_permissions();
if (!mod_badges_json?.edit_permissions?.[level]) return;
let fields: string[] = mod_badges_json.edit_permissions[level].can_edit;
if (!Array.isArray(fields)) fields = [];
if (fields.includes(field_key)) {
mod_badges_json.edit_permissions[level].can_edit = fields.filter((f: string) => f !== field_key);
} else {
mod_badges_json.edit_permissions[level].can_edit = [...fields, field_key];
}
}
function save() {
if (onsave && mod_badges_json) onsave(mod_badges_json);
}
@@ -100,7 +167,61 @@
</label>
</div>
</div>
{/if}
<details class="space-y-3">
<summary class="cursor-pointer font-medium text-sm">
Badge Review — Editable Field Permissions
</summary>
<div class="space-y-4 pt-2 pl-2">
<p class="text-xs text-gray-500">
Controls which fields each access level may edit on the Badge Review page.
Staff (Trusted) defaults include all attendee fields plus Email and Badge Type Code.
Administrators can always edit everything.
</p>
<!-- Attendee (passcode-validated) -->
<div class="space-y-1">
<p class="text-sm font-medium">Attendees (passcode link)</p>
<div class="grid grid-cols-2 gap-x-4 gap-y-1">
{#each all_attendee_fields as field}
<label class="label flex items-center gap-2 text-sm">
<input
type="checkbox"
class="checkbox"
checked={is_field_enabled('authenticated', field.key)}
onchange={() => toggle_field('authenticated', field.key)}
/>
<span>{field.label}</span>
</label>
{/each}
</div>
</div>
<!-- Staff (Trusted) -->
<div class="space-y-1">
<p class="text-sm font-medium">Staff (Trusted access)</p>
<div class="grid grid-cols-2 gap-x-4 gap-y-1">
{#each all_staff_fields as field}
<label class="label flex items-center gap-2 text-sm">
<input
type="checkbox"
class="checkbox"
checked={is_field_enabled('trusted', field.key)}
onchange={() => toggle_field('trusted', field.key)}
/>
<span>{field.label}</span>
</label>
{/each}
</div>
</div>
<!-- Administrator note -->
<p class="text-xs text-gray-400 italic">
Administrators always have access to all fields (not configurable).
</p>
</div>
</details>
{/if} <!-- end {#if mod_badges_json} -->
<button type="button" class="btn preset-tonal-primary" onclick={save}
>Save</button