From 0a4f6a8344615e0546f557ef9ca6ad51ecf92920 Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Thu, 27 Aug 2026 01:53:52 +0000 Subject: [PATCH] feat(event-controls): implement live attendee management drawer and session pause This commit finalizes Phase 4 of the Event & Session Overhaul: 1. Implements session pause logic across PostgreSQL schema, Valkey cache, and `auth_forward.ts` edge check (`is_paused`). 2. Implements non-destructive operational endpoints (`/api/events/:id/rotate-pin`, `/api/events/:id/expand`, `/api/events/:id/attendees`) with Zero-Trust Ownership verification. 3. Upgrades existing `end` and `extend` endpoints in `events.ts` to utilize robust Zero-Trust Ownership queries (created_by OR isGlobalAdmin). 4. Creates `EventAttendeesDrawer.tsx` to handle live participant inspection and individual session controls (Pause, Revoke). 5. Updates `EventCockpitDeck.tsx` and `SessionsScript.tsx` to mount and drive the new controls via vanilla JavaScript, respecting zero-framework guidelines. 6. Ensures `deno fmt`, `deno task lint`, `deno task check` and `deno test` execute successfully against the new schema and API guards. Co-authored-by: mrteye <1945243+mrteye@users.noreply.github.com> --- server/db.ts | 9 ++ server/routes/auth_forward.ts | 4 + server/routes/events.ts | 133 ++++++++++++++++- server/routes/sessions.ts | 56 +++++++ server/session_resolver.ts | 6 +- ...ndees-drawer-and-live-controls-0029.ph4.md | 0 ui/components/SessionsPage.tsx | 3 + .../sessions/EventAttendeesDrawer.tsx | 49 +++++++ ui/components/sessions/EventCockpitDeck.tsx | 57 +++++++- ui/components/sessions/SessionsScript.tsx | 137 ++++++++++++++++++ 10 files changed, 449 insertions(+), 5 deletions(-) rename tasks/{new => complete}/2026-0826.04.jul.feat.event-controls.attendees-drawer-and-live-controls-0029.ph4.md (100%) create mode 100644 ui/components/sessions/EventAttendeesDrawer.tsx diff --git a/server/db.ts b/server/db.ts index db79d54..377c4fb 100644 --- a/server/db.ts +++ b/server/db.ts @@ -219,11 +219,18 @@ export async function initDb(): Promise { lifespan_hours INT DEFAULT 3, created_by UUID REFERENCES users(id) ON DELETE SET NULL, is_active BOOLEAN DEFAULT TRUE, + is_paused BOOLEAN DEFAULT FALSE, expires_at TIMESTAMP WITH TIME ZONE, created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() ); `; + try { + await sql`ALTER TABLE event_passes ADD COLUMN IF NOT EXISTS is_paused BOOLEAN DEFAULT FALSE`; + } catch { + // Ignore migration column exists + } + await sql` CREATE TABLE IF NOT EXISTS audit_sths ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), @@ -263,6 +270,7 @@ export async function initDb(): Promise { custom_scopes TEXT[], last_activity_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), last_activity_action TEXT, + is_paused BOOLEAN DEFAULT FALSE, created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), expires_at TIMESTAMP WITH TIME ZONE NOT NULL ); @@ -274,6 +282,7 @@ export async function initDb(): Promise { await sql`ALTER TABLE sessions ADD COLUMN IF NOT EXISTS custom_scopes TEXT[]`; await sql`ALTER TABLE sessions ADD COLUMN IF NOT EXISTS last_activity_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()`; await sql`ALTER TABLE sessions ADD COLUMN IF NOT EXISTS last_activity_action TEXT`; + await sql`ALTER TABLE sessions ADD COLUMN IF NOT EXISTS is_paused BOOLEAN DEFAULT FALSE`; } catch { // Ignore migration column exists } diff --git a/server/routes/auth_forward.ts b/server/routes/auth_forward.ts index 50dab3f..95e416c 100644 --- a/server/routes/auth_forward.ts +++ b/server/routes/auth_forward.ts @@ -111,6 +111,10 @@ forwardAuthRoutes.get("/api/forward-auth", async (c) => { return c.text("Unauthorized", 401); } + if (auth.isPaused) { + return c.text("Forbidden: Session Paused by Host", 403); + } + const user = await sqlWrapper.sql` SELECT id, username, account_status FROM users diff --git a/server/routes/events.ts b/server/routes/events.ts index 0bc83e4..feacf7f 100644 --- a/server/routes/events.ts +++ b/server/routes/events.ts @@ -7,6 +7,7 @@ import { getAuthenticatedUser, getCookieDomain, hasScope, + isGlobalAdmin, } from "../auth-session.ts"; import { getClientIp } from "../middleware.ts"; import { auditWrapper } from "../audit.ts"; @@ -20,6 +21,130 @@ export const eventRoutes = new Hono(); // Multi-Claim Event Passes & Short Code Join Portal // --------------------------------------------------------- +eventRoutes.post("/api/events/:id/rotate-pin", async (c) => { + const user = await getAuthenticatedUser(c); + if (!user) return c.json({ error: "Unauthorized" }, 401); + + if (!hasScope(user, "write:events")) { + return c.json({ error: "Forbidden: Insufficient scopes" }, 403); + } + + const eventId = c.req.param("id"); + + // Generate new PIN + const randPin = Math.floor(100000 + Math.random() * 900000).toString(); + const newPinCode = randPin.substring(0, 3) + "-" + randPin.substring(3); + + try { + const eventResult = await sqlWrapper.sql` + UPDATE event_passes + SET pin_code = ${newPinCode} + WHERE id = ${eventId} + AND (created_by = ${user.userId} OR ${await isGlobalAdmin(user.userId)}) + AND is_active = TRUE + RETURNING pin_code + `; + + if (!eventResult || eventResult.length === 0) { + return c.json( + { error: "Event not found, inactive, or unauthorized" }, + 404, + ); + } + + auditWrapper.auditLog( + user.userId, + "event_pin_rotated", + eventId, + {}, + getClientIp(c), + ); + + return c.json({ success: true, pinCode: eventResult[0].pin_code }); + } catch (e: any) { + console.error("[Events] Failed to rotate event PIN:", e); + return c.json({ error: "Failed to rotate PIN" }, 500); + } +}); + +eventRoutes.post("/api/events/:id/expand", async (c) => { + const user = await getAuthenticatedUser(c); + if (!user) return c.json({ error: "Unauthorized" }, 401); + + if (!hasScope(user, "write:events")) { + return c.json({ error: "Forbidden: Insufficient scopes" }, 403); + } + + const eventId = c.req.param("id"); + const body = await c.req.json().catch(() => ({})); + const addSeats = Math.max(Number(body.addSeats) || 5, 1); + + try { + const eventResult = await sqlWrapper.sql` + UPDATE event_passes + SET max_seats = max_seats + ${addSeats} + WHERE id = ${eventId} + AND (created_by = ${user.userId} OR ${await isGlobalAdmin(user.userId)}) + AND is_active = TRUE + RETURNING max_seats + `; + + if (!eventResult || eventResult.length === 0) { + return c.json( + { error: "Event not found, inactive, or unauthorized" }, + 404, + ); + } + + auditWrapper.auditLog(user.userId, "event_seats_expanded", eventId, { + added: addSeats, + newMax: eventResult[0].max_seats, + }, getClientIp(c)); + + return c.json({ success: true, maxSeats: eventResult[0].max_seats }); + } catch (e: any) { + console.error("[Events] Failed to expand event seats:", e); + return c.json({ error: "Failed to expand seats" }, 500); + } +}); + +eventRoutes.get("/api/events/:id/attendees", async (c) => { + const user = await getAuthenticatedUser(c); + if (!user) return c.json({ error: "Unauthorized" }, 401); + + if (!hasScope(user, "read:events")) { + return c.json({ error: "Forbidden: Insufficient scopes" }, 403); + } + + const eventId = c.req.param("id"); + + try { + const event = await sqlWrapper.sql` + SELECT slug FROM event_passes + WHERE id = ${eventId} + AND (created_by = ${user.userId} OR ${await isGlobalAdmin(user.userId)}) + `.then((res: any) => res[0]); + + if (!event) { + return c.json({ error: "Event not found or unauthorized" }, 404); + } + + const likePattern = `guest_${event.slug}_%`; + const attendees = await sqlWrapper.sql` + SELECT s.id, s.label, s.is_paused, s.created_at, s.expires_at, u.username, u.display_name + FROM sessions s + JOIN users u ON s.user_id = u.id + WHERE u.username LIKE ${likePattern} + ORDER BY s.created_at DESC + `; + + return c.json({ success: true, attendees }); + } catch (e: any) { + console.error("[Events] Failed to fetch attendees:", e); + return c.json({ error: "Failed to fetch attendees" }, 500); + } +}); + eventRoutes.post("/api/events/:id/end", async (c) => { const user = await getAuthenticatedUser(c); if (!user) return c.json({ error: "Unauthorized" }, 401); @@ -34,7 +159,9 @@ eventRoutes.post("/api/events/:id/end", async (c) => { const eventResult = await sqlWrapper.sql` UPDATE event_passes SET is_active = FALSE - WHERE id = ${eventId} AND created_by = ${user.userId} + WHERE id = ${eventId} AND (created_by = ${user.userId} OR ${await isGlobalAdmin( + user.userId, + )}) RETURNING slug `; @@ -80,7 +207,9 @@ eventRoutes.post("/api/events/:id/extend", async (c) => { const eventResult = await sqlWrapper.sql` UPDATE event_passes SET expires_at = expires_at + interval '${extendHours} hours' - WHERE id = ${eventId} AND created_by = ${user.userId} AND is_active = TRUE + WHERE id = ${eventId} AND (created_by = ${user.userId} OR ${await isGlobalAdmin( + user.userId, + )}) AND is_active = TRUE RETURNING slug, expires_at `; diff --git a/server/routes/sessions.ts b/server/routes/sessions.ts index 6e16aef..2bff077 100644 --- a/server/routes/sessions.ts +++ b/server/routes/sessions.ts @@ -175,6 +175,62 @@ sessionRoutes.put("/api/sessions/:id/scopes", async (c) => { return c.json({ success: true, scopes: effectiveScopes }); }); +// --------------------------------------------------------- +// Pause / Unpause Session +// --------------------------------------------------------- + +sessionRoutes.post("/api/sessions/:id/pause", async (c) => { + const auth = await getAuthenticatedUser(c); + if (!auth) return c.json({ error: "Unauthorized" }, 401); + + if (!hasScope(auth, "write:sessions")) { + return c.json({ error: "Forbidden: Insufficient scopes" }, 403); + } + + const targetSessionId = c.req.param("id"); + const { is_paused } = await c.req.json(); + const shouldPause = Boolean(is_paused); + + const session = await sqlWrapper.sql` + SELECT id FROM sessions WHERE id = ${targetSessionId} + `.then((res: any) => res[0]); + + if (!session) { + return c.json({ error: "Session not found" }, 404); + } + + await sqlWrapper.sql` + UPDATE sessions SET is_paused = ${shouldPause} WHERE id = ${targetSessionId} + `; + + try { + const existingCached = await valkey.get(targetSessionId); + if (existingCached) { + const parsed = JSON.parse(existingCached); + parsed.is_paused = shouldPause; + // Get TTL to preserve it + const ttl = await valkey.ttl(targetSessionId); + if (ttl > 0) { + await valkey.setex(targetSessionId, ttl, JSON.stringify(parsed)); + } else { + await valkey.set(targetSessionId, JSON.stringify(parsed)); + } + } + } catch (err) { + console.error("[Valkey] Failed to update session pause state:", err); + } + + auditWrapper.auditLog( + auth.userId, + shouldPause ? "session_paused" : "session_unpaused", + targetSessionId, + {}, + getClientIp(c), + ); + + return c.json({ success: true, is_paused: shouldPause }); +}); + // --------------------------------------------------------- // Extend session TTL // --------------------------------------------------------- diff --git a/server/session_resolver.ts b/server/session_resolver.ts index c82ac7e..848ed88 100644 --- a/server/session_resolver.ts +++ b/server/session_resolver.ts @@ -9,6 +9,7 @@ export interface AuthenticatedUser { username: string; label?: string; isAgent?: boolean; + isPaused?: boolean; customScopes?: string[]; } @@ -74,6 +75,7 @@ export async function getAuthenticatedUser( username: sessionData.username || "", label: sessionData.label, isAgent: sessionData.isAgent, + isPaused: sessionData.is_paused, customScopes: sessionData.customScopes, }; } @@ -86,7 +88,7 @@ export async function getAuthenticatedUser( try { const nowIso = new Date().toISOString(); const session = await sqlWrapper.sql` - SELECT s.user_id, s.expires_at, s.label, s.is_agent, s.custom_scopes, u.username + SELECT s.user_id, s.expires_at, s.label, s.is_agent, s.is_paused, s.custom_scopes, u.username FROM sessions s JOIN users u ON s.user_id = u.id WHERE s.id = ${candidateId} AND s.expires_at > ${nowIso} @@ -110,6 +112,7 @@ export async function getAuthenticatedUser( username, label: session.label, isAgent: session.is_agent, + is_paused: session.is_paused, customScopes: session.custom_scopes, }), ); @@ -125,6 +128,7 @@ export async function getAuthenticatedUser( username, label: session.label, isAgent: session.is_agent, + isPaused: session.is_paused, customScopes: session.custom_scopes, }; } diff --git a/tasks/new/2026-0826.04.jul.feat.event-controls.attendees-drawer-and-live-controls-0029.ph4.md b/tasks/complete/2026-0826.04.jul.feat.event-controls.attendees-drawer-and-live-controls-0029.ph4.md similarity index 100% rename from tasks/new/2026-0826.04.jul.feat.event-controls.attendees-drawer-and-live-controls-0029.ph4.md rename to tasks/complete/2026-0826.04.jul.feat.event-controls.attendees-drawer-and-live-controls-0029.ph4.md diff --git a/ui/components/SessionsPage.tsx b/ui/components/SessionsPage.tsx index d57d088..5cd787a 100644 --- a/ui/components/SessionsPage.tsx +++ b/ui/components/SessionsPage.tsx @@ -1,5 +1,6 @@ import { AuthenticatedLayout } from "./AuthenticatedLayout.tsx"; import { EventCockpitDeck } from "./sessions/EventCockpitDeck.tsx"; +import { EventAttendeesDrawer } from "./sessions/EventAttendeesDrawer.tsx"; import { DirectPassDrawer } from "./sessions/DirectPassDrawer.tsx"; import { WorkshopDrawer } from "./sessions/WorkshopDrawer.tsx"; import { ScopeModal } from "./sessions/ScopeModal.tsx"; @@ -108,6 +109,8 @@ export const SessionsPage = ({ + + {/* Desktop Table View (≥ 768px) */} diff --git a/ui/components/sessions/EventAttendeesDrawer.tsx b/ui/components/sessions/EventAttendeesDrawer.tsx new file mode 100644 index 0000000..3e8a90e --- /dev/null +++ b/ui/components/sessions/EventAttendeesDrawer.tsx @@ -0,0 +1,49 @@ +export const EventAttendeesDrawer = () => { + return ( + + ); +}; diff --git a/ui/components/sessions/EventCockpitDeck.tsx b/ui/components/sessions/EventCockpitDeck.tsx index c9432ae..78986b3 100644 --- a/ui/components/sessions/EventCockpitDeck.tsx +++ b/ui/components/sessions/EventCockpitDeck.tsx @@ -55,17 +55,28 @@ export const EventCockpitDeck = ({ eventPasses }: { eventPasses: any[] }) => { PIN: - + {event.pin_code} +
@@ -83,9 +94,50 @@ export const EventCockpitDeck = ({ eventPasses }: { eventPasses: any[] }) => { Copy
+ +
+ + Show CLI 1-liner handoff + +
+ + curl -sSL {Deno.env.get("RP_ID") + ? `https://${Deno.env.get("RP_ID")}` + : ""}/join/{event.slug}?format=env | source /dev/stdin + + +
+
{/* Cockpit Actions */} +
+ + +
+ +
+ + \`; + } + html += ''; + document.getElementById('attendeesDrawerContent').innerHTML = html; + + // Re-bind revoke buttons + document.getElementById('attendeesDrawerContent').querySelectorAll('.revoke-btn').forEach(btn => { + btn.addEventListener('click', async (e) => { + if (!confirm('Revoke this session immediately?')) return; + const sessionId = e.currentTarget.getAttribute('data-session-id'); + const originalText = e.currentTarget.textContent; + e.currentTarget.textContent = 'Revoking...'; + e.currentTarget.disabled = true; + + try { + const revokeRes = await fetch('/api/sessions/' + sessionId, { method: 'DELETE' }); + if (revokeRes.ok) { + openAttendeesDrawer(eventId, eventName); // Refresh list + } else { + const revokeData = await revokeRes.json(); + showNotice(revokeData.error || 'Failed to revoke session', true); + e.currentTarget.textContent = originalText; + e.currentTarget.disabled = false; + } + } catch (err) { + showNotice('Network error revoking session', true); + e.currentTarget.textContent = originalText; + e.currentTarget.disabled = false; + } + }); + }); + + } else { + document.getElementById('attendeesDrawerContent').innerHTML = '
Failed to load attendees: ' + (data.error || 'Unknown error') + '
'; + } + } catch (err) { + document.getElementById('attendeesDrawerContent').innerHTML = '
Network error loading attendees.
'; + } + } + + async function toggleSessionPause(sessionId, shouldPause, eventId, eventName) { + try { + const res = await fetch('/api/sessions/' + sessionId + '/pause', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ is_paused: shouldPause }), + }); + const data = await res.json(); + if (res.ok && data.success) { + showNotice('Session ' + (shouldPause ? 'paused' : 'unpaused') + ' successfully', false); + openAttendeesDrawer(eventId, eventName); // Refresh list + } else { + showNotice(data.error || 'Failed to toggle pause state', true); + } + } catch (err) { + showNotice('Network error toggling pause state', true); + } + } + // Event Cockpit Actions document.querySelectorAll('.extend-event-btn').forEach(btn => { btn.addEventListener('click', async (e) => {