Compare commits
No commits in common. "a53e69d9fbdc5e4f84240ed579e47413f789ae78" and "f6b5f4704c93eae1bd084344fe98948b1bd173d4" have entirely different histories.
a53e69d9fb
...
f6b5f4704c
@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
|||||||
@ -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
|
||||||
// ---------------------------------------------------------
|
// ---------------------------------------------------------
|
||||||
|
|||||||
@ -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,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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) */}
|
||||||
|
|||||||
@ -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"
|
|
||||||
>
|
|
||||||
×
|
|
||||||
</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,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
|
||||||
|
|||||||
@ -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) => {
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user