Merge pull request #52 from mrteye/feat-event-attendee-controls-13863970798268504771
feat: implement live attendee management drawer and session pause Implemented Phase 4 of the Event & Session Overhaul: - **Database:** Added `is_paused` flag to `sessions` and `event_passes` tables safely via soft-fail migrations. - **API (Sessions):** Added `POST /api/sessions/:id/pause` endpoint; updated `session_resolver` to serialize `is_paused` into Valkey caches and the edge middleware (`auth_forward.ts`) to return 403 when paused. - **API (Events):** Added endpoints to fetch `attendees` (resolving via `guest_<slug>_<seat>` deterministic lookup), `rotate-pin`, and `expand` seats. Secured all event modification endpoints to enforce `created_by` or global admin scope. - **UI & Scripts:** Built `EventAttendeesDrawer` to visualize live connections, injected the `<EventAttendeesDrawer />` container in `SessionsPage`, added quick controls to the `EventCockpitDeck`, and backed the DOM manipulation seamlessly with vanilla JS in `SessionsScript.tsx`.
This commit is contained in:
commit
a53e69d9fb
@ -219,11 +219,18 @@ export async function initDb(): Promise<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
`;
|
||||
|
||||
|
||||
@ -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
|
||||
// ---------------------------------------------------------
|
||||
|
||||
@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@ -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 = ({
|
||||
<WorkshopDrawer apps={apps} />
|
||||
</div>
|
||||
|
||||
<EventAttendeesDrawer />
|
||||
|
||||
<ScopeModal apps={apps} />
|
||||
|
||||
{/* Desktop Table View (≥ 768px) */}
|
||||
|
||||
49
ui/components/sessions/EventAttendeesDrawer.tsx
Normal file
49
ui/components/sessions/EventAttendeesDrawer.tsx
Normal file
@ -0,0 +1,49 @@
|
||||
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"
|
||||
>
|
||||
×
|
||||
</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>
|
||||
);
|
||||
};
|
||||
@ -55,17 +55,28 @@ export const EventCockpitDeck = ({ eventPasses }: { eventPasses: any[] }) => {
|
||||
<span style="font-size: 0.8rem; font-weight: 600; width: 60px; color: var(--text-muted);">
|
||||
PIN:
|
||||
</span>
|
||||
<code style="flex: 1; padding: 0.35rem 0.5rem; background: var(--surface-muted); border-radius: var(--radius-sm); font-family: monospace; font-size: 0.9rem; text-align: center; letter-spacing: 2px;">
|
||||
<code
|
||||
style="flex: 1; padding: 0.35rem 0.5rem; background: var(--surface-muted); border-radius: var(--radius-sm); font-family: monospace; font-size: 0.9rem; text-align: center; letter-spacing: 2px;"
|
||||
id={`pin-${event.id}`}
|
||||
>
|
||||
{event.pin_code}
|
||||
</code>
|
||||
<button
|
||||
type="button"
|
||||
class="btn-outline"
|
||||
style="padding: 0.25rem 0.5rem; font-size: 0.75rem; min-height: 28px;"
|
||||
onclick={`copyText('${event.pin_code}')`}
|
||||
onclick={`copyText(document.getElementById('pin-${event.id}').textContent.trim())`}
|
||||
>
|
||||
Copy
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="btn-outline"
|
||||
style="padding: 0.25rem 0.5rem; font-size: 0.75rem; min-height: 28px;"
|
||||
onclick={`rotatePin('${event.id}')`}
|
||||
>
|
||||
🔄 Rotate
|
||||
</button>
|
||||
</div>
|
||||
<div style="display: flex; align-items: center; gap: 0.5rem;">
|
||||
<span style="font-size: 0.8rem; font-weight: 600; width: 60px; color: var(--text-muted);">
|
||||
@ -83,9 +94,50 @@ export const EventCockpitDeck = ({ eventPasses }: { eventPasses: any[] }) => {
|
||||
Copy
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<details style="font-size: 0.8rem;">
|
||||
<summary style="cursor: pointer; color: var(--primary); font-weight: 600;">
|
||||
Show CLI 1-liner handoff
|
||||
</summary>
|
||||
<div style="margin-top: 0.5rem; display: flex; align-items: center; gap: 0.5rem;">
|
||||
<code style="flex: 1; padding: 0.35rem 0.5rem; background: var(--surface-muted); border-radius: var(--radius-sm); font-family: monospace; font-size: 0.75rem; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;">
|
||||
curl -sSL {Deno.env.get("RP_ID")
|
||||
? `https://${Deno.env.get("RP_ID")}`
|
||||
: ""}/join/{event.slug}?format=env | source /dev/stdin
|
||||
</code>
|
||||
<button
|
||||
type="button"
|
||||
class="btn-outline"
|
||||
style="padding: 0.25rem 0.5rem; font-size: 0.75rem; min-height: 28px;"
|
||||
onclick={`copyText("curl -sSL " + window.location.origin + "/join/${event.slug}?format=env | source /dev/stdin")`}
|
||||
>
|
||||
Copy
|
||||
</button>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
|
||||
{/* Cockpit Actions */}
|
||||
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 0.5rem; margin-bottom: 0.5rem;">
|
||||
<button
|
||||
type="button"
|
||||
class="btn-outline"
|
||||
style="justify-content: center; min-height: 38px; font-size: 0.85rem;"
|
||||
onclick={`openAttendeesDrawer('${event.id}', '${
|
||||
event.name.replace(/'/g, "\\'")
|
||||
}')`}
|
||||
>
|
||||
👥 Manage Attendees ({event.seats_claimed})
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="btn-outline"
|
||||
style="justify-content: center; min-height: 38px; font-size: 0.85rem;"
|
||||
onclick={`expandSeats('${event.id}', 5)`}
|
||||
>
|
||||
+5 Seats
|
||||
</button>
|
||||
</div>
|
||||
<div style="display: flex; gap: 0.5rem;">
|
||||
<button
|
||||
type="button"
|
||||
@ -99,6 +151,7 @@ export const EventCockpitDeck = ({ eventPasses }: { eventPasses: any[] }) => {
|
||||
type="button"
|
||||
class="btn-danger end-event-btn"
|
||||
data-event-id={event.id}
|
||||
aria-label="End Event and Revoke All"
|
||||
style="flex: 1; justify-content: center; min-height: 38px; font-size: 0.85rem;"
|
||||
>
|
||||
🔴 End & Revoke All
|
||||
|
||||
@ -274,6 +274,143 @@ export const SessionsScript = () => {
|
||||
showNotice('Copied to clipboard!', false);
|
||||
}
|
||||
|
||||
async function rotatePin(eventId) {
|
||||
try {
|
||||
const res = await fetch('/api/events/' + eventId + '/rotate-pin', {
|
||||
method: 'POST'
|
||||
});
|
||||
const data = await res.json();
|
||||
if (res.ok && data.success) {
|
||||
const pinElem = document.getElementById('pin-' + eventId);
|
||||
if (pinElem) {
|
||||
pinElem.textContent = data.pinCode;
|
||||
}
|
||||
showNotice('Event PIN rotated successfully', false);
|
||||
} else {
|
||||
showNotice(data.error || 'Failed to rotate PIN', true);
|
||||
}
|
||||
} catch (err) {
|
||||
showNotice('Network error rotating PIN', true);
|
||||
}
|
||||
}
|
||||
|
||||
async function expandSeats(eventId, count) {
|
||||
try {
|
||||
const res = await fetch('/api/events/' + eventId + '/expand', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ addSeats: count }),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (res.ok && data.success) {
|
||||
showNotice('Expanded capacity by ' + count + ' seats', false);
|
||||
setTimeout(() => window.location.reload(), 600);
|
||||
} else {
|
||||
showNotice(data.error || 'Failed to expand seats', true);
|
||||
}
|
||||
} catch (err) {
|
||||
showNotice('Network error expanding seats', true);
|
||||
}
|
||||
}
|
||||
|
||||
function closeAttendeesDrawer() {
|
||||
document.getElementById('attendeesDrawer').style.display = 'none';
|
||||
}
|
||||
|
||||
async function openAttendeesDrawer(eventId, eventName) {
|
||||
document.getElementById('attendeesDrawerEventName').textContent = eventName;
|
||||
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' });
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/events/' + eventId + '/attendees');
|
||||
const data = await res.json();
|
||||
|
||||
if (res.ok && data.success) {
|
||||
const attendees = data.attendees;
|
||||
if (attendees.length === 0) {
|
||||
document.getElementById('attendeesDrawerContent').innerHTML = '<div style="padding: 1rem; text-align: center; color: var(--text-muted);">No attendees currently active.</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
let html = '<div style="display: flex; flex-direction: column; gap: 0.5rem;">';
|
||||
for (const att of attendees) {
|
||||
const isPaused = att.is_paused === true;
|
||||
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>
|
||||
<div style="font-weight: 600; font-size: 0.9rem; color: var(--text-primary); \${isPaused ? 'text-decoration: line-through;' : ''}">\${att.username}</div>
|
||||
<div style="font-size: 0.75rem; color: var(--text-secondary);">Expires: \${new Date(att.expires_at).toLocaleString()}</div>
|
||||
</div>
|
||||
<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}')">
|
||||
\${isPaused ? '▶️ Unpause' : '⏸️ Pause'}
|
||||
</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">
|
||||
Revoke
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
\`;
|
||||
}
|
||||
html += '</div>';
|
||||
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 = '<div style="padding: 1rem; color: var(--danger-text);">Failed to load attendees: ' + (data.error || 'Unknown error') + '</div>';
|
||||
}
|
||||
} catch (err) {
|
||||
document.getElementById('attendeesDrawerContent').innerHTML = '<div style="padding: 1rem; color: var(--danger-text);">Network error loading attendees.</div>';
|
||||
}
|
||||
}
|
||||
|
||||
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) => {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user