Compare commits

..

No commits in common. "a53e69d9fbdc5e4f84240ed579e47413f789ae78" and "f6b5f4704c93eae1bd084344fe98948b1bd173d4" have entirely different histories.

10 changed files with 5 additions and 449 deletions

View File

@ -219,18 +219,11 @@ export async function initDb(): Promise<void> {
lifespan_hours INT DEFAULT 3, lifespan_hours INT DEFAULT 3,
created_by UUID REFERENCES users(id) ON DELETE SET NULL, created_by UUID REFERENCES users(id) ON DELETE SET NULL,
is_active BOOLEAN DEFAULT TRUE, is_active BOOLEAN DEFAULT TRUE,
is_paused BOOLEAN DEFAULT FALSE,
expires_at TIMESTAMP WITH TIME ZONE, expires_at TIMESTAMP WITH TIME ZONE,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() 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` await sql`
CREATE TABLE IF NOT EXISTS audit_sths ( CREATE TABLE IF NOT EXISTS audit_sths (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(), id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
@ -270,7 +263,6 @@ export async function initDb(): Promise<void> {
custom_scopes TEXT[], custom_scopes TEXT[],
last_activity_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), last_activity_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
last_activity_action TEXT, last_activity_action TEXT,
is_paused BOOLEAN DEFAULT FALSE,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
expires_at TIMESTAMP WITH TIME ZONE NOT NULL expires_at TIMESTAMP WITH TIME ZONE NOT NULL
); );
@ -282,7 +274,6 @@ 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 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_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 last_activity_action TEXT`;
await sql`ALTER TABLE sessions ADD COLUMN IF NOT EXISTS is_paused BOOLEAN DEFAULT FALSE`;
} catch { } catch {
// Ignore migration column exists // Ignore migration column exists
} }

View File

@ -111,10 +111,6 @@ forwardAuthRoutes.get("/api/forward-auth", async (c) => {
return c.text("Unauthorized", 401); return c.text("Unauthorized", 401);
} }
if (auth.isPaused) {
return c.text("Forbidden: Session Paused by Host", 403);
}
const user = await sqlWrapper.sql` const user = await sqlWrapper.sql`
SELECT id, username, account_status SELECT id, username, account_status
FROM users FROM users

View File

@ -7,7 +7,6 @@ import {
getAuthenticatedUser, getAuthenticatedUser,
getCookieDomain, getCookieDomain,
hasScope, hasScope,
isGlobalAdmin,
} from "../auth-session.ts"; } from "../auth-session.ts";
import { getClientIp } from "../middleware.ts"; import { getClientIp } from "../middleware.ts";
import { auditWrapper } from "../audit.ts"; import { auditWrapper } from "../audit.ts";
@ -21,130 +20,6 @@ export const eventRoutes = new Hono();
// Multi-Claim Event Passes & Short Code Join Portal // 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) => { eventRoutes.post("/api/events/:id/end", async (c) => {
const user = await getAuthenticatedUser(c); const user = await getAuthenticatedUser(c);
if (!user) return c.json({ error: "Unauthorized" }, 401); if (!user) return c.json({ error: "Unauthorized" }, 401);
@ -159,9 +34,7 @@ eventRoutes.post("/api/events/:id/end", async (c) => {
const eventResult = await sqlWrapper.sql` const eventResult = await sqlWrapper.sql`
UPDATE event_passes UPDATE event_passes
SET is_active = FALSE SET is_active = FALSE
WHERE id = ${eventId} AND (created_by = ${user.userId} OR ${await isGlobalAdmin( WHERE id = ${eventId} AND created_by = ${user.userId}
user.userId,
)})
RETURNING slug RETURNING slug
`; `;
@ -207,9 +80,7 @@ eventRoutes.post("/api/events/:id/extend", async (c) => {
const eventResult = await sqlWrapper.sql` const eventResult = await sqlWrapper.sql`
UPDATE event_passes UPDATE event_passes
SET expires_at = expires_at + interval '${extendHours} hours' SET expires_at = expires_at + interval '${extendHours} hours'
WHERE id = ${eventId} AND (created_by = ${user.userId} OR ${await isGlobalAdmin( WHERE id = ${eventId} AND created_by = ${user.userId} AND is_active = TRUE
user.userId,
)}) AND is_active = TRUE
RETURNING slug, expires_at RETURNING slug, expires_at
`; `;

View File

@ -175,62 +175,6 @@ sessionRoutes.put("/api/sessions/:id/scopes", async (c) => {
return c.json({ success: true, scopes: effectiveScopes }); 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 // Extend session TTL
// --------------------------------------------------------- // ---------------------------------------------------------

View File

@ -9,7 +9,6 @@ export interface AuthenticatedUser {
username: string; username: string;
label?: string; label?: string;
isAgent?: boolean; isAgent?: boolean;
isPaused?: boolean;
customScopes?: string[]; customScopes?: string[];
} }
@ -75,7 +74,6 @@ export async function getAuthenticatedUser(
username: sessionData.username || "", username: sessionData.username || "",
label: sessionData.label, label: sessionData.label,
isAgent: sessionData.isAgent, isAgent: sessionData.isAgent,
isPaused: sessionData.is_paused,
customScopes: sessionData.customScopes, customScopes: sessionData.customScopes,
}; };
} }
@ -88,7 +86,7 @@ export async function getAuthenticatedUser(
try { try {
const nowIso = new Date().toISOString(); const nowIso = new Date().toISOString();
const session = await sqlWrapper.sql` const session = await sqlWrapper.sql`
SELECT s.user_id, s.expires_at, s.label, s.is_agent, s.is_paused, s.custom_scopes, u.username SELECT s.user_id, s.expires_at, s.label, s.is_agent, s.custom_scopes, u.username
FROM sessions s FROM sessions s
JOIN users u ON s.user_id = u.id JOIN users u ON s.user_id = u.id
WHERE s.id = ${candidateId} AND s.expires_at > ${nowIso} WHERE s.id = ${candidateId} AND s.expires_at > ${nowIso}
@ -112,7 +110,6 @@ export async function getAuthenticatedUser(
username, username,
label: session.label, label: session.label,
isAgent: session.is_agent, isAgent: session.is_agent,
is_paused: session.is_paused,
customScopes: session.custom_scopes, customScopes: session.custom_scopes,
}), }),
); );
@ -128,7 +125,6 @@ export async function getAuthenticatedUser(
username, username,
label: session.label, label: session.label,
isAgent: session.is_agent, isAgent: session.is_agent,
isPaused: session.is_paused,
customScopes: session.custom_scopes, customScopes: session.custom_scopes,
}; };
} }

View File

@ -1,6 +1,5 @@
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 { 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";
@ -109,8 +108,6 @@ export const SessionsPage = ({
<WorkshopDrawer apps={apps} /> <WorkshopDrawer apps={apps} />
</div> </div>
<EventAttendeesDrawer />
<ScopeModal apps={apps} /> <ScopeModal apps={apps} />
{/* Desktop Table View (≥ 768px) */} {/* Desktop Table View (≥ 768px) */}

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

@ -55,28 +55,17 @@ export const EventCockpitDeck = ({ eventPasses }: { eventPasses: any[] }) => {
<span style="font-size: 0.8rem; font-weight: 600; width: 60px; color: var(--text-muted);"> <span style="font-size: 0.8rem; font-weight: 600; width: 60px; color: var(--text-muted);">
PIN: PIN:
</span> </span>
<code <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;">
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} {event.pin_code}
</code> </code>
<button <button
type="button" type="button"
class="btn-outline" class="btn-outline"
style="padding: 0.25rem 0.5rem; font-size: 0.75rem; min-height: 28px;" style="padding: 0.25rem 0.5rem; font-size: 0.75rem; min-height: 28px;"
onclick={`copyText(document.getElementById('pin-${event.id}').textContent.trim())`} onclick={`copyText('${event.pin_code}')`}
> >
Copy Copy
</button> </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>
<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: 60px; color: var(--text-muted);"> <span style="font-size: 0.8rem; font-weight: 600; width: 60px; color: var(--text-muted);">
@ -94,50 +83,9 @@ export const EventCockpitDeck = ({ eventPasses }: { eventPasses: any[] }) => {
Copy Copy
</button> </button>
</div> </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> </div>
{/* Cockpit Actions */} {/* 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;"> <div style="display: flex; gap: 0.5rem;">
<button <button
type="button" type="button"
@ -151,7 +99,6 @@ export const EventCockpitDeck = ({ eventPasses }: { eventPasses: any[] }) => {
type="button" type="button"
class="btn-danger end-event-btn" class="btn-danger end-event-btn"
data-event-id={event.id} 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;" style="flex: 1; justify-content: center; min-height: 38px; font-size: 0.85rem;"
> >
🔴 End & Revoke All 🔴 End & Revoke All

View File

@ -274,143 +274,6 @@ export const SessionsScript = () => {
showNotice('Copied to clipboard!', false); 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 // Event Cockpit Actions
document.querySelectorAll('.extend-event-btn').forEach(btn => { document.querySelectorAll('.extend-event-btn').forEach(btn => {
btn.addEventListener('click', async (e) => { btn.addEventListener('click', async (e) => {