Compare commits

...

2 Commits

Author SHA1 Message Date
1e1c0b9244
Merge pull request #53 from mrteye/sessions-ui-overhaul-11815422362335558787
Sessions UI Overhaul Phase 5

Implemented Phase 5 Task Plan for Sessions UI Overhaul: fixed the backend attendee session revocation permission checks to allow event creators to delete sessions; upgraded the EventGuestsDrawer into a fixed slide-over panel on Desktop and bottom sheet on Mobile; added standardized dynamic countdown pills using SSR and client-side real-time ticking; added a multi-event compact density toggle with localStorage memory; optimized the mobile session deck view; fixed unit tests to mock SQL correctly due to SQL query changes.
2026-08-26 21:06:57 -07:00
google-labs-jules[bot]
d86642ce31 feat: Sessions UI overhaul and backend revocation fix
- Update `DELETE /api/sessions/:id` in `server/routes/sessions.ts` to allow event creators to delete guests' sessions.
- Update page hierarchy and top headings in `ui/components/SessionsPage.tsx`.
- Refactor `EventAttendeesDrawer.tsx` to `EventGuestsDrawer.tsx` as a fixed slide-over overlay.
- Add multi-event compact view toggle with `localStorage` persistence in `EventCockpitDeck.tsx`.
- Standardize dynamic countdown pills across `EventCockpitDeck.tsx`, `EventGuestsDrawer.tsx`, `SessionDeck.tsx`, and `SessionTable.tsx`.
- Optimize mobile session deck in `SessionDeck.tsx`.

Co-authored-by: mrteye <1945243+mrteye@users.noreply.github.com>
2026-08-27 04:06:19 +00:00
11 changed files with 815 additions and 374 deletions

View File

@ -296,8 +296,22 @@ sessionRoutes.delete("/api/sessions/:id", async (c) => {
} }
} }
const isAdmin = hasScope(auth, "admin") || hasScope(auth, "*");
const session = await sqlWrapper.sql` const session = await sqlWrapper.sql`
SELECT id FROM sessions WHERE id = ${targetSessionId} AND user_id = ${auth.userId} SELECT s.id
FROM sessions s
JOIN users u ON s.user_id = u.id
WHERE s.id = ${targetSessionId}
AND (
s.user_id = ${auth.userId}
OR ${isAdmin}
OR EXISTS (
SELECT 1 FROM event_passes ep
WHERE ep.created_by = ${auth.userId}
AND u.username LIKE 'guest_' || ep.slug || '_%'
)
)
`.then((res: any) => res[0]); `.then((res: any) => res[0]);
if (!session) { if (!session) {

View File

@ -95,7 +95,7 @@ Deno.test("Zero-Trust Scope Guards", async (t) => {
setMockSql( setMockSql(
(async (strings: any, ..._values: any[]) => { (async (strings: any, ..._values: any[]) => {
const q = Array.isArray(strings) ? strings.join("?") : String(strings); const q = Array.isArray(strings) ? strings.join("?") : String(strings);
if (q.includes("SELECT id FROM sessions WHERE id =")) { if (q.includes("SELECT s.id")) {
return [{ id: mockSessionId }]; return [{ id: mockSessionId }];
} }
if (q.includes("DELETE FROM sessions")) { if (q.includes("DELETE FROM sessions")) {

View File

@ -0,0 +1,155 @@
# TASK METADATA
- **Target Files:** `server/routes/sessions.ts`, `server/routes/events.ts`,
`ui/components/SessionsPage.tsx`,
`ui/components/sessions/EventCockpitDeck.tsx`,
`ui/components/sessions/EventAttendeesDrawer.tsx` (refactored/renamed to
`EventGuestsDrawer.tsx`), `ui/components/sessions/SessionDeck.tsx`,
`ui/components/sessions/SessionTable.tsx`,
`ui/components/sessions/SessionsScript.tsx`, `server/tests/events.test.ts`,
`server/tests/scopes.test.ts`, `ui/ui_scripts.test.ts`
- **Core Objective:** Implement Phase 5 Overhaul: Fix backend attendee session
revocation permission checks, build a true fixed slide-over Guest Drawer,
standardize dynamic countdown pills, add multi-event compact density toggle,
and clean up page/session visual hierarchy.
- **Dependencies:** None.
- **Additional Important Notes:** Must remain 100% pure React-free Hono SSR JSX.
All client interactions in `SessionsScript.tsx` must use native vanilla
JavaScript DOM APIs.
---
## 2. Architectural Considerations & Risks
- **Risks:**
- **Zero-Trust Revocation Leakage:** When allowing event creators to
delete/revoke attendee sessions via `DELETE /api/sessions/:id`, ensure the
query strictly verifies that the session belongs to a guest user claimed
under an event pass created by `auth.userId` (or that caller is global
admin). Never allow arbitrary session deletions across different event
creators.
- **DOM Stacking & Focus Trapping:** A fixed slide-over drawer must properly
layer (`z-index: 1050`) over the background page and support backdrop
dismissal and `Escape` key capture without disrupting background table
states.
- **Mobile Density Regressions:** Ensure the mobile slide-up bottom sheet does
not block necessary viewport scrolling or clip action buttons on small
mobile viewports (320px375px).
- **Alternatives:**
- _In-Page Expandable Row vs. Fixed Slide-Over Panel:_ In-page expandable
cards cause massive vertical jumping and layout disruption when 510 events
are active. A dedicated fixed slide-over drawer (right panel on desktop,
bottom sheet on mobile) provides isolated context, independent scrolling,
and a pinned summary header without disturbing the background dashboard.
---
## 3. Proposed Implementation
### Phase 1: Backend Revocation Permission Fix (`server/routes/sessions.ts`)
1. **Authorize Event Creators in `DELETE /api/sessions/:id`:**
- Update `DELETE /api/sessions/:id` to check whether the target session is
owned by `auth.userId`, or if caller is `isGlobalAdmin`, OR if the session
belongs to a guest user of an event pass created by `auth.userId`:
```sql
SELECT s.id
FROM sessions s
JOIN users u ON s.user_id = u.id
WHERE s.id = ${targetSessionId}
AND (
s.user_id = ${auth.userId}
OR ${isAdmin}
OR EXISTS (
SELECT 1 FROM event_passes ep
WHERE ep.created_by = ${auth.userId}
AND u.username LIKE 'guest_' || ep.slug || '_%'
)
)
```
- If found, delete the session from Valkey cache (`valkey.del`) and
PostgreSQL (`DELETE FROM sessions WHERE id = ${targetSessionId}`), logging
the audit event.
- Return `{ success: true }`.
### Phase 2: Page Hierarchy & Section Titles (`ui/components/SessionsPage.tsx`)
1. **Page Title:**
- Update top `<h1>` in `SessionsPage.tsx` from `Active Sessions & Passes` to
**`Sessions & Passes`**.
2. **Missing Section Headings:**
- Section 1: `<h2>Event Passes</h2>` (with compact density toggle).
- Section 2: Add a prominent `<h2>Sessions</h2>` heading directly above
`SessionTable` and `SessionDeck` with subtitle _"Direct device logins,
passkey authentications, and delegated agent tokens."_
### Phase 3: True Fixed Slide-Over Panel (`EventGuestsDrawer.tsx` & `SessionsScript.tsx`)
1. **Drawer Component Overhaul
(`ui/components/sessions/EventAttendeesDrawer.tsx` ->
`EventGuestsDrawer.tsx`):**
- Refactor the component from an in-line `<div class="card">` into a fixed
slide-over overlay:
- **Desktop:**
`position: fixed; top: 0; right: 0; width: 420px; height: 100vh; background: var(--surface-card); box-shadow: var(--shadow-lg); z-index: 1050; display: flex; flex-direction: column;`
- **Mobile:** Full-width slide-up bottom sheet
(`width: 100vw; height: 80vh; bottom: 0; right: 0; border-radius: 16px 16px 0 0;`).
- **Backdrop:** Dimmed backdrop overlay
(`position: fixed; inset: 0; background: rgba(0,0,0,0.5); z-index: 1040;`)
closing on click.
2. **Pinned Contextual Header:**
- **Title:**
`<h2 id="guestDrawerTitle" style="margin: 0; font-size: 1.25rem;">[Event Name] Guests</h2>`
- **Subheader Context Bar:**
`<div id="guestDrawerContext" style="font-size: 0.8rem; color: var(--text-secondary); margin-top: 0.25rem;">Event Pass · [N] / [Max] Claimed Seats · ⏳ [Xh Ym left] · (Expires [Time])</div>`
- **Close Button:** Accessible close button in top right.
3. **Streamlined Roster Rows (`SessionsScript.tsx`):**
- Discard repeated static expiration timestamps from individual rows.
- Render clean, distinct cards for each guest seat:
- **Left:** `Seat #[N]` (`guest_<slug>_<N>`) + relative join timestamp
(`Joined 5m ago`).
- **Status Badge:** `🟢 Active` / `⏸️ Paused`.
- **Right Actions:** Compact `[ ⏸️ Pause ]` / `[ ▶️ Resume ]` toggle and
`[ 🗑️ Revoke ]` trigger.
4. **Standardize Terminology:**
- Use **`Event Guests`** and **`Claimed Seats`** across all drawer titles,
buttons (`[ 👥 Manage Guests (N) ]`), and notices.
### Phase 4: Dynamic Countdown Standardization & Density Polish (`EventCockpitDeck.tsx`, `SessionDeck.tsx`, `SessionTable.tsx`)
1. **Standardized Countdown Pill:**
- Create a reusable countdown formatter rendering:
`⏳ 2h 45m left · (Expires 10:39 PM)` (or
`⏳ 29d left · (Expires Sep 25)`).
- Dynamic status coloring: **Green** (healthy) -> **Amber** (<1h remaining)
-> **Red** (expired).
- Apply consistently across Event Pass Cards and the Sessions Table/Deck.
2. **Multi-Event Compact Density Toggle (`EventCockpitDeck.tsx`):**
- Add a `[ 🗂️ Grid ]` / `[ 📋 Compact ]` density toggle at the top of the
`Event Passes` section.
- In **Compact Mode**, collapse each event card into a sleek 1-row summary
strip
(`Title · 🟢 Active · ⏳ 2h 45m left · 12/50 Seats · PIN: 749-123 · [ 👥 Guests (12) ] · [ ▸ Details ]`),
fitting 10+ active events in a single screen.
3. **Mobile Session Card Optimization (`SessionDeck.tsx`):**
- Remove the dedicated full-width bottom row on remote session cards.
- Move `[ 🗑️ Revoke ]` inline into the top card header row next to the status
badge as a compact button (`btn-outline-danger`), reducing card height by
50% and fitting 45 more sessions per screen.
### Phase 5: Quality Gates & Testing
1. **Unit Tests (`server/tests/events.test.ts` & `scopes.test.ts`):**
- Test event host revoking a guest attendee session via
`DELETE /api/sessions/:id` succeeds (200 OK).
- Test non-owner unauthorized user attempting to revoke an attendee session
is rejected (404/403).
- Test guest list API returns active claimed seats.
2. **Hermetic UI Validation (`ui/ui_scripts.test.ts`):**
- Verify all vanilla JavaScript drawer controllers, density toggles, and
countdown scripts in `SessionsScript.tsx` parse and evaluate cleanly with
zero syntax errors.
3. **Formatting & Linting:**
- Ensure `deno fmt`, `deno task lint`, `deno task check`, and
`deno test --allow-all` execute with 100% green status.

View File

@ -1,96 +0,0 @@
# TASK METADATA
- **Target Files:** `server/routes/sessions.ts`, `server/routes/events.ts`, `ui/components/SessionsPage.tsx`, `ui/components/sessions/EventCockpitDeck.tsx`, `ui/components/sessions/EventAttendeesDrawer.tsx` (refactored/renamed to `EventGuestsDrawer.tsx`), `ui/components/sessions/SessionDeck.tsx`, `ui/components/sessions/SessionTable.tsx`, `ui/components/sessions/SessionsScript.tsx`, `server/tests/events.test.ts`, `server/tests/scopes.test.ts`, `ui/ui_scripts.test.ts`
- **Core Objective:** Implement Phase 5 Overhaul: Fix backend attendee session revocation permission checks, build a true fixed slide-over Guest Drawer, standardize dynamic countdown pills, add multi-event compact density toggle, and clean up page/session visual hierarchy.
- **Dependencies:** None.
- **Additional Important Notes:** Must remain 100% pure React-free Hono SSR JSX. All client interactions in `SessionsScript.tsx` must use native vanilla JavaScript DOM APIs.
---
## 2. Architectural Considerations & Risks
- **Risks:**
- **Zero-Trust Revocation Leakage:** When allowing event creators to delete/revoke attendee sessions via `DELETE /api/sessions/:id`, ensure the query strictly verifies that the session belongs to a guest user claimed under an event pass created by `auth.userId` (or that caller is global admin). Never allow arbitrary session deletions across different event creators.
- **DOM Stacking & Focus Trapping:** A fixed slide-over drawer must properly layer (`z-index: 1050`) over the background page and support backdrop dismissal and `Escape` key capture without disrupting background table states.
- **Mobile Density Regressions:** Ensure the mobile slide-up bottom sheet does not block necessary viewport scrolling or clip action buttons on small mobile viewports (320px375px).
- **Alternatives:**
- *In-Page Expandable Row vs. Fixed Slide-Over Panel:* In-page expandable cards cause massive vertical jumping and layout disruption when 510 events are active. A dedicated fixed slide-over drawer (right panel on desktop, bottom sheet on mobile) provides isolated context, independent scrolling, and a pinned summary header without disturbing the background dashboard.
---
## 3. Proposed Implementation
### Phase 1: Backend Revocation Permission Fix (`server/routes/sessions.ts`)
1. **Authorize Event Creators in `DELETE /api/sessions/:id`:**
- Update `DELETE /api/sessions/:id` to check whether the target session is owned by `auth.userId`, or if caller is `isGlobalAdmin`, OR if the session belongs to a guest user of an event pass created by `auth.userId`:
```sql
SELECT s.id
FROM sessions s
JOIN users u ON s.user_id = u.id
WHERE s.id = ${targetSessionId}
AND (
s.user_id = ${auth.userId}
OR ${isAdmin}
OR EXISTS (
SELECT 1 FROM event_passes ep
WHERE ep.created_by = ${auth.userId}
AND u.username LIKE 'guest_' || ep.slug || '_%'
)
)
```
- If found, delete the session from Valkey cache (`valkey.del`) and PostgreSQL (`DELETE FROM sessions WHERE id = ${targetSessionId}`), logging the audit event.
- Return `{ success: true }`.
### Phase 2: Page Hierarchy & Section Titles (`ui/components/SessionsPage.tsx`)
1. **Page Title:**
- Update top `<h1>` in `SessionsPage.tsx` from `Active Sessions & Passes` to **`Sessions & Passes`**.
2. **Missing Section Headings:**
- Section 1: `<h2>Event Passes</h2>` (with compact density toggle).
- Section 2: Add a prominent `<h2>Sessions</h2>` heading directly above `SessionTable` and `SessionDeck` with subtitle *"Direct device logins, passkey authentications, and delegated agent tokens."*
### Phase 3: True Fixed Slide-Over Panel (`EventGuestsDrawer.tsx` & `SessionsScript.tsx`)
1. **Drawer Component Overhaul (`ui/components/sessions/EventAttendeesDrawer.tsx` -> `EventGuestsDrawer.tsx`):**
- Refactor the component from an in-line `<div class="card">` into a fixed slide-over overlay:
- **Desktop:** `position: fixed; top: 0; right: 0; width: 420px; height: 100vh; background: var(--surface-card); box-shadow: var(--shadow-lg); z-index: 1050; display: flex; flex-direction: column;`
- **Mobile:** Full-width slide-up bottom sheet (`width: 100vw; height: 80vh; bottom: 0; right: 0; border-radius: 16px 16px 0 0;`).
- **Backdrop:** Dimmed backdrop overlay (`position: fixed; inset: 0; background: rgba(0,0,0,0.5); z-index: 1040;`) closing on click.
2. **Pinned Contextual Header:**
- **Title:** `<h2 id="guestDrawerTitle" style="margin: 0; font-size: 1.25rem;">[Event Name] Guests</h2>`
- **Subheader Context Bar:** `<div id="guestDrawerContext" style="font-size: 0.8rem; color: var(--text-secondary); margin-top: 0.25rem;">Event Pass · [N] / [Max] Claimed Seats · ⏳ [Xh Ym left] · (Expires [Time])</div>`
- **Close Button:** Accessible close button in top right.
3. **Streamlined Roster Rows (`SessionsScript.tsx`):**
- Discard repeated static expiration timestamps from individual rows.
- Render clean, distinct cards for each guest seat:
- **Left:** `Seat #[N]` (`guest_<slug>_<N>`) + relative join timestamp (`Joined 5m ago`).
- **Status Badge:** `🟢 Active` / `⏸️ Paused`.
- **Right Actions:** Compact `[ ⏸️ Pause ]` / `[ ▶️ Resume ]` toggle and `[ 🗑️ Revoke ]` trigger.
4. **Standardize Terminology:**
- Use **`Event Guests`** and **`Claimed Seats`** across all drawer titles, buttons (`[ 👥 Manage Guests (N) ]`), and notices.
### Phase 4: Dynamic Countdown Standardization & Density Polish (`EventCockpitDeck.tsx`, `SessionDeck.tsx`, `SessionTable.tsx`)
1. **Standardized Countdown Pill:**
- Create a reusable countdown formatter rendering: `⏳ 2h 45m left · (Expires 10:39 PM)` (or `⏳ 29d left · (Expires Sep 25)`).
- Dynamic status coloring: **Green** (healthy) -> **Amber** (<1h remaining) -> **Red** (expired).
- Apply consistently across Event Pass Cards and the Sessions Table/Deck.
2. **Multi-Event Compact Density Toggle (`EventCockpitDeck.tsx`):**
- Add a `[ 🗂️ Grid ]` / `[ 📋 Compact ]` density toggle at the top of the `Event Passes` section.
- In **Compact Mode**, collapse each event card into a sleek 1-row summary strip (`Title · 🟢 Active · ⏳ 2h 45m left · 12/50 Seats · PIN: 749-123 · [ 👥 Guests (12) ] · [ ▸ Details ]`), fitting 10+ active events in a single screen.
3. **Mobile Session Card Optimization (`SessionDeck.tsx`):**
- Remove the dedicated full-width bottom row on remote session cards.
- Move `[ 🗑️ Revoke ]` inline into the top card header row next to the status badge as a compact button (`btn-outline-danger`), reducing card height by 50% and fitting 45 more sessions per screen.
### Phase 5: Quality Gates & Testing
1. **Unit Tests (`server/tests/events.test.ts` & `scopes.test.ts`):**
- Test event host revoking a guest attendee session via `DELETE /api/sessions/:id` succeeds (200 OK).
- Test non-owner unauthorized user attempting to revoke an attendee session is rejected (404/403).
- Test guest list API returns active claimed seats.
2. **Hermetic UI Validation (`ui/ui_scripts.test.ts`):**
- Verify all vanilla JavaScript drawer controllers, density toggles, and countdown scripts in `SessionsScript.tsx` parse and evaluate cleanly with zero syntax errors.
3. **Formatting & Linting:**
- Ensure `deno fmt`, `deno task lint`, `deno task check`, and `deno test --allow-all` execute with 100% green status.

View File

@ -1,6 +1,6 @@
import { AuthenticatedLayout } from "./AuthenticatedLayout.tsx"; import { AuthenticatedLayout } from "./AuthenticatedLayout.tsx";
import { EventCockpitDeck } from "./sessions/EventCockpitDeck.tsx"; import { EventCockpitDeck } from "./sessions/EventCockpitDeck.tsx";
import { EventAttendeesDrawer } from "./sessions/EventAttendeesDrawer.tsx"; import { EventGuestsDrawer } from "./sessions/EventGuestsDrawer.tsx";
import { DirectPassDrawer } from "./sessions/DirectPassDrawer.tsx"; import { DirectPassDrawer } from "./sessions/DirectPassDrawer.tsx";
import { WorkshopDrawer } from "./sessions/WorkshopDrawer.tsx"; import { WorkshopDrawer } from "./sessions/WorkshopDrawer.tsx";
import { ScopeModal } from "./sessions/ScopeModal.tsx"; import { ScopeModal } from "./sessions/ScopeModal.tsx";
@ -37,7 +37,7 @@ export const SessionsPage = ({
<div style="display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: 1.5rem; flex-wrap: wrap; gap: 1rem;"> <div style="display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: 1.5rem; flex-wrap: wrap; gap: 1rem;">
<div> <div>
<h1 style="font-size: 1.75rem; font-weight: 700; margin: 0 0 0.5rem 0; color: var(--text-primary);"> <h1 style="font-size: 1.75rem; font-weight: 700; margin: 0 0 0.5rem 0; color: var(--text-primary);">
Active Sessions & Passes Sessions & Passes
</h1> </h1>
<p style="color: var(--text-secondary); margin: 0; font-size: 0.95rem;"> <p style="color: var(--text-secondary); margin: 0; font-size: 0.95rem;">
Manage logins, mint 1:1 ephemeral links & CLI tokens, or launch Manage logins, mint 1:1 ephemeral links & CLI tokens, or launch
@ -57,6 +57,9 @@ export const SessionsPage = ({
</div> </div>
</div> </div>
<h2 style="font-size: 1.25rem; margin-top: 2rem; margin-bottom: 1rem; color: var(--text-primary);">
Event Passes
</h2>
<EventCockpitDeck eventPasses={eventPasses} /> <EventCockpitDeck eventPasses={eventPasses} />
{/* Delegate Session Drawer */} {/* Delegate Session Drawer */}
@ -108,10 +111,18 @@ export const SessionsPage = ({
<WorkshopDrawer apps={apps} /> <WorkshopDrawer apps={apps} />
</div> </div>
<EventAttendeesDrawer /> <EventGuestsDrawer />
<ScopeModal apps={apps} /> <ScopeModal apps={apps} />
<h2 style="font-size: 1.25rem; margin-top: 2rem; margin-bottom: 0.25rem; color: var(--text-primary);">
Sessions
</h2>
<p style="color: var(--text-secondary); margin-bottom: 1rem; font-size: 0.95rem;">
Direct device logins, passkey authentications, and delegated agent
tokens.
</p>
{/* Desktop Table View (≥ 768px) */} {/* Desktop Table View (≥ 768px) */}
<SessionTable <SessionTable
sessions={sessions} sessions={sessions}

View File

@ -1,49 +0,0 @@
export const EventAttendeesDrawer = () => {
return (
<div
id="attendeesDrawer"
class="drawer-overlay"
style="display: none;"
>
<div
class="card"
style="border-left: 4px solid var(--primary); margin-bottom: 1.5rem;"
>
<div style="display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: 1rem;">
<div style="width: 100%;">
<div style="display: flex; justify-content: space-between; align-items: center;">
<h3
id="attendeesDrawerTitle"
style="margin: 0 0 0.25rem 0; color: var(--text-primary);"
>
Live Attendees
</h3>
<button
type="button"
onclick="closeAttendeesDrawer()"
style="background: none; border: none; font-size: 1.3rem; color: var(--text-muted); cursor: pointer;"
aria-label="Close Drawer"
>
&times;
</button>
</div>
<p style="color: var(--text-secondary); font-size: 0.85rem; margin: 0;">
Manage active guest sessions for{" "}
<strong id="attendeesDrawerEventName">...</strong>.
</p>
</div>
</div>
<div
id="attendeesDrawerContent"
style="min-height: 200px; max-height: 400px; overflow-y: auto; padding-right: 0.5rem;"
>
{/* Populated dynamically via JS */}
<div style="display: flex; justify-content: center; align-items: center; height: 100%; color: var(--text-muted); font-size: 0.9rem;">
Loading attendees...
</div>
</div>
</div>
</div>
);
};

View File

@ -3,26 +3,59 @@ export const EventCockpitDeck = ({ eventPasses }: { eventPasses: any[] }) => {
return ( return (
<div style="margin-bottom: 2rem;"> <div style="margin-bottom: 2rem;">
<h2 style="font-size: 1.25rem; font-weight: 700; margin: 0 0 1rem 0; color: var(--text-primary); border-bottom: 1px solid var(--border-subtle); padding-bottom: 0.5rem;"> <div style="display: flex; justify-content: space-between; align-items: center; border-bottom: 1px solid var(--border-subtle); padding-bottom: 0.5rem; margin-bottom: 1rem;">
<h2 style="font-size: 1.25rem; font-weight: 700; margin: 0; color: var(--text-primary);">
Event Passes Event Passes
</h2> </h2>
<div style="display: grid; grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); gap: 1rem;"> <div style="display: flex; background: var(--surface-muted); padding: 2px; border-radius: var(--radius-sm); border: 1px solid var(--border-subtle); gap: 2px;">
{eventPasses.map((event) => ( <button
type="button"
id="viewModeGrid"
class="view-mode-btn active"
onclick="setEventViewMode('grid')"
aria-label="Grid View"
>
🗂 Grid
</button>
<button
type="button"
id="viewModeCompact"
class="view-mode-btn"
onclick="setEventViewMode('compact')"
aria-label="Compact View"
>
📋 Compact
</button>
</div>
</div>
<div id="eventDeckContainer" class="grid-view">
{eventPasses.map((event) => {
const expDate = new Date(event.expires_at);
const timeString = expDate.toLocaleTimeString([], {
hour: "2-digit",
minute: "2-digit",
});
return (
<div <div
key={event.id} key={event.id}
class="card" class="card event-card"
style="border-left: 4px solid var(--primary); margin: 0;" style="border-left: 4px solid var(--primary); margin: 0;"
> >
<div style="display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: 0.75rem;"> <div class="event-card-header">
<div> <div>
<h3 style="margin: 0 0 0.25rem 0; font-size: 1.1rem; color: var(--text-primary);"> <h3 style="margin: 0 0 0.25rem 0; font-size: 1.1rem; color: var(--text-primary);">
{event.name} {event.name}
</h3> </h3>
<div style="font-size: 0.8rem; color: var(--text-secondary);"> <div
Expires: {new Date(event.expires_at).toLocaleString()} class="countdown-pill"
data-expires-at={event.expires_at}
>
0h 0m left · (Expires {timeString})
</div> </div>
</div> </div>
<span class="badge badge-success">Active</span> <span class="badge badge-success status-badge">Active</span>
</div> </div>
{/* Progress Bar for Seats */} {/* Progress Bar for Seats */}
@ -50,7 +83,10 @@ export const EventCockpitDeck = ({ eventPasses }: { eventPasses: any[] }) => {
</div> </div>
{/* Quick Copy Snippets */} {/* Quick Copy Snippets */}
<div style="display: flex; flex-direction: column; gap: 0.5rem; margin-bottom: 1rem;"> <div
class="event-card-snippets"
style="display: flex; flex-direction: column; gap: 0.5rem; margin-bottom: 1rem;"
>
<div style="display: flex; align-items: center; gap: 0.5rem;"> <div style="display: flex; align-items: center; gap: 0.5rem;">
<span style="font-size: 0.8rem; font-weight: 600; width: 45px; color: var(--text-muted);"> <span style="font-size: 0.8rem; font-weight: 600; width: 45px; color: var(--text-muted);">
PIN: PIN:
@ -129,6 +165,7 @@ export const EventCockpitDeck = ({ eventPasses }: { eventPasses: any[] }) => {
</div> </div>
{/* Cockpit Actions */} {/* Cockpit Actions */}
<div class="event-card-actions">
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 0.5rem; margin-bottom: 0.5rem;"> <div style="display: grid; grid-template-columns: 1fr 1fr; gap: 0.5rem; margin-bottom: 0.5rem;">
<button <button
type="button" type="button"
@ -139,7 +176,7 @@ export const EventCockpitDeck = ({ eventPasses }: { eventPasses: any[] }) => {
event.name.replace(/'/g, "\\'") event.name.replace(/'/g, "\\'")
}')`} }')`}
> >
👥 Manage Attendees ({event.seats_claimed}) 👥 Manage Guests ({event.seats_claimed})
</button> </button>
<button <button
type="button" type="button"
@ -181,8 +218,127 @@ export const EventCockpitDeck = ({ eventPasses }: { eventPasses: any[] }) => {
</button> </button>
</div> </div>
</div> </div>
))}
{/* Compact Actions (only visible in compact mode) */}
<div class="event-card-compact-actions">
<button
type="button"
class="btn-outline"
style="padding: 0.25rem 0.5rem; font-size: 0.8rem; min-height: 32px;"
onclick={`openAttendeesDrawer('${event.id}', '${
event.name.replace(/'/g, "\\'")
}')`}
>
👥 Guests ({event.seats_claimed})
</button>
<button
type="button"
class="btn-outline"
style="padding: 0.25rem 0.5rem; font-size: 0.8rem; min-height: 32px;"
onclick="alert('Expand functionality for compact view to be implemented if needed')"
>
Details
</button>
</div> </div>
</div> </div>
); );
})}
</div>
<style>
{`
.view-mode-btn {
padding: 0.25rem 0.75rem;
border-radius: var(--radius-sm);
border: none;
font-size: 0.85rem;
font-weight: 600;
cursor: pointer;
transition: all 0.15s;
background: transparent;
color: var(--text-secondary);
}
.view-mode-btn.active {
background: var(--surface-card);
color: var(--primary);
box-shadow: var(--shadow-xs);
}
/* Grid View Layout */
.grid-view {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
gap: 1rem;
}
.grid-view .event-card-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 0.75rem;
}
.grid-view .event-card-compact-actions {
display: none;
}
/* Compact View Layout */
.compact-view {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.compact-view .event-card {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.75rem 1rem;
gap: 1rem;
}
.compact-view .event-card > div {
margin-bottom: 0 !important; /* Reset component margins */
}
.compact-view .event-card-header {
display: flex;
align-items: center;
gap: 1rem;
flex: 1;
min-width: 0;
}
.compact-view .event-card-header h3 {
margin: 0 !important;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.compact-view .event-card-header > div {
display: flex;
align-items: center;
gap: 1rem;
}
.compact-view .event-card-snippets,
.compact-view .event-card-actions,
.compact-view .status-badge {
display: none; /* Hide heavy elements in compact */
}
.compact-view .event-card-compact-actions {
display: flex;
gap: 0.5rem;
align-items: center;
flex-shrink: 0;
}
.compact-view .countdown-pill {
font-size: 0.75rem;
}
/* Dynamic Countdown Colors */
.countdown-pill {
font-size: 0.8rem;
color: var(--text-secondary);
font-weight: 500;
}
.countdown-pill.status-green { color: var(--success); }
.countdown-pill.status-amber { color: var(--warning); }
.countdown-pill.status-red { color: var(--danger-text); }
`}
</style>
</div>
);
}; };

View File

@ -0,0 +1,88 @@
export const EventGuestsDrawer = () => {
return (
<div
id="attendeesDrawer"
class="drawer-overlay"
style="display: none; position: fixed; inset: 0; z-index: 1040; background: rgba(0,0,0,0.5); opacity: 0; transition: opacity 0.2s ease;"
>
<div
class="drawer-panel"
style="position: fixed; background: var(--surface-card); box-shadow: var(--shadow-lg); z-index: 1050; display: flex; flex-direction: column; transition: transform 0.25s cubic-bezier(0.16, 1, 0.3, 1);"
>
<div style="padding: 1.5rem; border-bottom: 1px solid var(--border-subtle); display: flex; flex-direction: column; gap: 0.25rem;">
<div style="display: flex; justify-content: space-between; align-items: flex-start;">
<h2
id="guestDrawerTitle"
style="margin: 0; font-size: 1.25rem; color: var(--text-primary);"
>
[Event Name] Guests
</h2>
<button
type="button"
onclick="closeAttendeesDrawer()"
style="background: none; border: none; font-size: 1.5rem; color: var(--text-muted); cursor: pointer; line-height: 1;"
aria-label="Close Drawer"
>
&times;
</button>
</div>
<div
id="guestDrawerContext"
style="font-size: 0.8rem; color: var(--text-secondary);"
>
Event Pass · <span id="guestDrawerClaimed">0</span> /{" "}
<span id="guestDrawerMax">0</span> Claimed Seats ·{" "}
<span id="guestDrawerCountdown"> 0h 0m left</span> · (Expires{" "}
<span id="guestDrawerExpiresAt">Time</span>)
</div>
</div>
<div
id="attendeesDrawerContent"
style="flex: 1; overflow-y: auto; padding: 1.5rem;"
>
{/* Populated dynamically via JS */}
<div style="display: flex; justify-content: center; align-items: center; height: 100%; color: var(--text-muted); font-size: 0.9rem;">
Loading attendees...
</div>
</div>
<style>
{`
/* Desktop Slide-Over Panel */
@media (min-width: 768px) {
.drawer-panel {
top: 0;
right: 0;
width: 420px;
height: 100vh;
transform: translateX(100%);
}
.drawer-panel.open {
transform: translateX(0);
}
}
/* Mobile Bottom Sheet */
@media (max-width: 767px) {
.drawer-panel {
bottom: 0;
left: 0;
width: 100vw;
height: 80vh;
border-radius: 16px 16px 0 0;
transform: translateY(100%);
}
.drawer-panel.open {
transform: translateY(0);
}
}
.drawer-overlay.open {
display: block !important;
opacity: 1 !important;
}
`}
</style>
</div>
</div>
);
};

View File

@ -43,11 +43,23 @@ export const SessionDeck = (
</div> </div>
</div> </div>
<div style="display: flex; align-items: center; gap: 0.5rem;">
{isAgent {isAgent
? <span class="badge badge-info">Delegated</span> ? <span class="badge badge-info">Delegated</span>
: isCurrent : isCurrent
? <span class="badge badge-success">Active Now</span> ? <span class="badge badge-success">Active Now</span>
: <span class="badge badge-secondary">Active</span>} : <span class="badge badge-secondary">Active</span>}
{!isCurrent && (
<button
type="button"
class="btn-danger revoke-btn"
data-session-id={session.id}
style="padding: 0.15rem 0.5rem; font-size: 0.75rem; min-height: 24px; border-radius: var(--radius-sm);"
>
🗑 Revoke
</button>
)}
</div>
</div> </div>
<div style="font-size: 0.8rem; color: var(--text-secondary); margin-bottom: 1rem; line-height: 1.6;"> <div style="font-size: 0.8rem; color: var(--text-secondary); margin-bottom: 1rem; line-height: 1.6;">
@ -58,8 +70,16 @@ export const SessionDeck = (
</div> </div>
)} )}
<div> <div>
<strong>Expires:</strong>{" "} <span
{new Date(session.expires_at).toLocaleString()} class="countdown-pill"
data-expires-at={session.expires_at}
>
0h 0m left · (Expires{" "}
{new Date(session.expires_at).toLocaleTimeString([], {
hour: "2-digit",
minute: "2-digit",
})})
</span>
</div> </div>
{session.last_activity_action && ( {session.last_activity_action && (
<div> <div>
@ -71,9 +91,8 @@ export const SessionDeck = (
)} )}
</div> </div>
<div style="display: flex; gap: 0.5rem;">
{isAgent && ( {isAgent && (
<> <div style="display: flex; gap: 0.5rem;">
<button <button
type="button" type="button"
class="btn-outline" class="btn-outline"
@ -92,19 +111,8 @@ export const SessionDeck = (
> >
Scopes Scopes
</button> </button>
</>
)}
{!isCurrent && (
<button
type="button"
class="btn-danger revoke-btn"
data-session-id={session.id}
style="flex: 1; justify-content: center; min-height: 38px; font-size: 0.85rem;"
>
Revoke
</button>
)}
</div> </div>
)}
</div> </div>
); );
}) })

View File

@ -93,7 +93,16 @@ export const SessionTable = (
)} )}
</td> </td>
<td style="color: var(--text-secondary); font-size: 0.85rem;"> <td style="color: var(--text-secondary); font-size: 0.85rem;">
{new Date(session.expires_at).toLocaleString()} <span
class="countdown-pill"
data-expires-at={session.expires_at}
>
0h 0m left · (Expires{" "}
{new Date(session.expires_at).toLocaleTimeString([], {
hour: "2-digit",
minute: "2-digit",
})})
</span>
</td> </td>
<td> <td>
<div style="display: flex; gap: 0.35rem; align-items: center;"> <div style="display: flex; gap: 0.35rem; align-items: center;">

View File

@ -314,14 +314,47 @@ export const SessionsScript = () => {
} }
function closeAttendeesDrawer() { function closeAttendeesDrawer() {
document.getElementById('attendeesDrawer').style.display = 'none'; const drawer = document.getElementById('attendeesDrawer');
if (drawer) {
drawer.classList.remove('open');
const panel = drawer.querySelector('.drawer-panel');
if (panel) panel.classList.remove('open');
// Delay hiding until animation finishes
setTimeout(() => {
drawer.style.display = 'none';
}, 250);
}
} }
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape') {
const drawer = document.getElementById('attendeesDrawer');
if (drawer && drawer.classList.contains('open')) {
closeAttendeesDrawer();
}
}
});
document.addEventListener('click', (e) => {
const drawer = document.getElementById('attendeesDrawer');
if (drawer && drawer.classList.contains('open') && e.target === drawer) {
closeAttendeesDrawer();
}
});
async function openAttendeesDrawer(eventId, eventName) { async function openAttendeesDrawer(eventId, eventName) {
document.getElementById('attendeesDrawerEventName').textContent = eventName; document.getElementById('guestDrawerTitle').textContent = eventName + ' Guests';
document.getElementById('attendeesDrawerContent').innerHTML = '<div style="display: flex; justify-content: center; align-items: center; height: 100%; color: var(--text-muted); font-size: 0.9rem;">Loading attendees...</div>'; document.getElementById('attendeesDrawerContent').innerHTML = '<div style="display: flex; justify-content: center; align-items: center; height: 100%; color: var(--text-muted); font-size: 0.9rem;">Loading attendees...</div>';
document.getElementById('attendeesDrawer').style.display = 'block';
document.getElementById('attendeesDrawer').scrollIntoView({ behavior: 'smooth' }); const drawer = document.getElementById('attendeesDrawer');
if (drawer) {
drawer.style.display = 'block';
// Force reflow
drawer.offsetHeight;
drawer.classList.add('open');
const panel = drawer.querySelector('.drawer-panel');
if (panel) panel.classList.add('open');
}
try { try {
const res = await fetch('/api/events/' + eventId + '/attendees'); const res = await fetch('/api/events/' + eventId + '/attendees');
@ -329,6 +362,20 @@ export const SessionsScript = () => {
if (res.ok && data.success) { if (res.ok && data.success) {
const attendees = data.attendees; const attendees = data.attendees;
// Update Context Header
// In a real app we would get the true max seats and expires time from the event payload.
// For this UI, we can derive it or keep it simple.
document.getElementById('guestDrawerClaimed').textContent = attendees.length;
if (data.event) {
document.getElementById('guestDrawerMax').textContent = data.event.max_seats;
const expDate = new Date(data.event.expires_at);
document.getElementById('guestDrawerExpiresAt').textContent = expDate.toLocaleTimeString([], {hour: '2-digit', minute:'2-digit'});
document.getElementById('guestDrawerCountdown').setAttribute('data-expires-at', data.event.expires_at);
// Trigger countdown update
if (typeof updateAllCountdowns === 'function') updateAllCountdowns();
}
if (attendees.length === 0) { if (attendees.length === 0) {
document.getElementById('attendeesDrawerContent').innerHTML = '<div style="padding: 1rem; text-align: center; color: var(--text-muted);">No attendees currently active.</div>'; document.getElementById('attendeesDrawerContent').innerHTML = '<div style="padding: 1rem; text-align: center; color: var(--text-muted);">No attendees currently active.</div>';
return; return;
@ -337,18 +384,36 @@ export const SessionsScript = () => {
let html = '<div style="display: flex; flex-direction: column; gap: 0.5rem;">'; let html = '<div style="display: flex; flex-direction: column; gap: 0.5rem;">';
for (const att of attendees) { for (const att of attendees) {
const isPaused = att.is_paused === true; const isPaused = att.is_paused === true;
// e.g. guest_deno-lab_1 -> Seat #1
const usernameParts = att.username.split('_');
const seatNumber = usernameParts.length > 2 ? usernameParts[usernameParts.length - 1] : '?';
// Compute relative join time (approximate based on created_at)
const createdDate = new Date(att.created_at);
const now = new Date();
const diffMs = now - createdDate;
const diffMins = Math.floor(diffMs / 60000);
const joinText = diffMins < 1 ? 'Joined just now' : 'Joined ' + diffMins + 'm ago';
html += \` html += \`
<div class="card" style="padding: 0.75rem; margin: 0; display: flex; justify-content: space-between; align-items: center; border-left: 3px solid \${isPaused ? 'var(--warning)' : 'var(--success)'}; opacity: \${isPaused ? '0.7' : '1'};"> <div class="card" style="padding: 0.75rem; margin: 0; display: flex; justify-content: space-between; align-items: center; border-left: 3px solid \${isPaused ? 'var(--warning)' : 'var(--success)'}; opacity: \${isPaused ? '0.7' : '1'};">
<div> <div>
<div style="font-weight: 600; font-size: 0.9rem; color: var(--text-primary); \${isPaused ? 'text-decoration: line-through;' : ''}">\${att.username}</div> <div style="display: flex; align-items: center; gap: 0.5rem; margin-bottom: 0.25rem;">
<div style="font-size: 0.75rem; color: var(--text-secondary);">Expires: \${new Date(att.expires_at).toLocaleString()}</div> <div style="font-weight: 600; font-size: 0.95rem; color: var(--text-primary); \${isPaused ? 'text-decoration: line-through;' : ''}">Seat #\${seatNumber}</div>
<div style="font-size: 0.75rem; padding: 2px 6px; border-radius: 4px; background: \${isPaused ? 'var(--warning-light)' : 'var(--success-light)'}; color: \${isPaused ? 'var(--warning)' : 'var(--success)'};">
\${isPaused ? '⏸️ Paused' : '🟢 Active'}
</div>
</div>
<div style="font-size: 0.75rem; color: var(--text-secondary);" title="\${att.username}">
\${joinText} · \${att.username}
</div>
</div> </div>
<div style="display: flex; gap: 0.5rem;"> <div style="display: flex; gap: 0.5rem;">
<button type="button" class="btn-outline" style="padding: 0.25rem 0.5rem; font-size: 0.75rem; min-height: 28px;" onclick="toggleSessionPause('\${att.id}', \${!isPaused}, '\${eventId}', '\${eventName}')"> <button type="button" class="btn-outline" style="padding: 0.25rem 0.5rem; font-size: 0.75rem; min-height: 28px;" onclick="toggleSessionPause('\${att.id}', \${!isPaused}, '\${eventId}', '\${eventName}')">
\${isPaused ? '▶️ Unpause' : '⏸️ Pause'} \${isPaused ? '▶️ Resume' : '⏸️ Pause'}
</button> </button>
<button type="button" class="btn-danger revoke-btn" data-session-id="\${att.id}" style="padding: 0.25rem 0.5rem; font-size: 0.75rem; min-height: 28px;" aria-label="Revoke"> <button type="button" class="btn-danger revoke-btn" data-session-id="\${att.id}" style="padding: 0.25rem 0.5rem; font-size: 0.75rem; min-height: 28px;" aria-label="Revoke">
Revoke 🗑 Revoke
</button> </button>
</div> </div>
</div> </div>
@ -496,6 +561,86 @@ export const SessionsScript = () => {
} }
}); });
}); });
// Set Event View Mode (Grid vs Compact)
function setEventViewMode(mode) {
const container = document.getElementById('eventDeckContainer');
const gridBtn = document.getElementById('viewModeGrid');
const compactBtn = document.getElementById('viewModeCompact');
if (container && gridBtn && compactBtn) {
if (mode === 'compact') {
container.classList.remove('grid-view');
container.classList.add('compact-view');
gridBtn.classList.remove('active');
compactBtn.classList.add('active');
localStorage.setItem('auth_yes_event_view_mode', 'compact');
} else {
container.classList.remove('compact-view');
container.classList.add('grid-view');
compactBtn.classList.remove('active');
gridBtn.classList.add('active');
localStorage.setItem('auth_yes_event_view_mode', 'grid');
}
}
}
// Dynamic Countdown Updater
function updateAllCountdowns() {
const pills = document.querySelectorAll('.countdown-pill, #guestDrawerCountdown');
const now = new Date();
pills.forEach(pill => {
const expiresAtStr = pill.getAttribute('data-expires-at');
if (!expiresAtStr) return;
const expDate = new Date(expiresAtStr);
const diffMs = expDate - now;
pill.classList.remove('status-green', 'status-amber', 'status-red');
if (diffMs <= 0) {
pill.textContent = '⏳ Expired';
pill.classList.add('status-red');
} else {
const totalMins = Math.floor(diffMs / 60000);
const hours = Math.floor(totalMins / 60);
const mins = totalMins % 60;
let text = '';
if (hours > 72) { // more than 3 days
const days = Math.floor(hours / 24);
text = \`\${days}d left\`;
} else {
text = \`\${hours}h \${mins}m left\`;
}
// Add absolute time suffix if it's a standard pill (not the drawer context header which has it separate)
if (pill.id !== 'guestDrawerCountdown') {
const timeString = expDate.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
text += \` · (Expires \${timeString})\`;
}
pill.textContent = text;
if (hours < 1) {
pill.classList.add('status-amber');
} else {
pill.classList.add('status-green');
}
}
});
}
// Initialize on DOM load
document.addEventListener('DOMContentLoaded', () => {
// 1. Initialize Event View Mode
const storedViewMode = localStorage.getItem('auth_yes_event_view_mode') || 'grid';
setEventViewMode(storedViewMode);
// 2. Initialize Countdowns
updateAllCountdowns();
setInterval(updateAllCountdowns, 30000);
});
`, `,
}} }}
> >