diff --git a/public/sessions-scripts.js b/public/sessions-scripts.js
index f1771c3..383f502 100644
--- a/public/sessions-scripts.js
+++ b/public/sessions-scripts.js
@@ -18,29 +18,78 @@ globalThis.showNotice = showNotice;
function openDelegateDrawer() {
const drawer = document.getElementById("delegateDrawer");
- if (drawer) {
- drawer.style.display = "block";
- drawer.offsetHeight;
- drawer.classList.add("open");
- const panel = drawer.querySelector(".drawer-panel");
- if (panel) panel.classList.add("open");
- }
+ if (!drawer) return;
+ drawer.style.display = "block";
+ drawer.scrollIntoView({ behavior: "smooth" });
+
+ const eventCreateState = document.getElementById("eventCreateState");
+ if (eventCreateState) eventCreateState.style.display = "block";
+ const eventHandoffState = document.getElementById("eventHandoffState");
+ if (eventHandoffState) eventHandoffState.style.display = "none";
+ const eventForm = document.getElementById("eventForm");
+ if (eventForm) eventForm.reset();
+
+ switchDelegateTab("tabDirectPass");
}
globalThis.openDelegateDrawer = openDelegateDrawer;
function closeDelegateDrawer() {
const drawer = document.getElementById("delegateDrawer");
- if (drawer) {
- drawer.classList.remove("open");
- const panel = drawer.querySelector(".drawer-panel");
- if (panel) panel.classList.remove("open");
- setTimeout(() => {
- drawer.style.display = "none";
- }, 250);
- }
+ if (drawer) drawer.style.display = "none";
}
globalThis.closeDelegateDrawer = closeDelegateDrawer;
+function switchDelegateTab(tabId) {
+ const directTab = document.getElementById("tabDirectPass");
+ const workshopTab = document.getElementById("tabWorkshopPass");
+ if (directTab) directTab.style.display = "none";
+ if (workshopTab) workshopTab.style.display = "none";
+
+ const btnDirect = document.getElementById("tabBtnDirectPass");
+ const btnWorkshop = document.getElementById("tabBtnWorkshopPass");
+
+ if (btnDirect) {
+ btnDirect.classList.remove("active");
+ btnDirect.setAttribute("aria-selected", "false");
+ }
+ if (btnWorkshop) {
+ btnWorkshop.classList.remove("active");
+ btnWorkshop.setAttribute("aria-selected", "false");
+ }
+
+ const activeTab = document.getElementById(tabId);
+ if (activeTab) activeTab.style.display = "block";
+
+ if (tabId === "tabDirectPass" && btnDirect) {
+ btnDirect.classList.add("active");
+ btnDirect.setAttribute("aria-selected", "true");
+ } else if (btnWorkshop) {
+ btnWorkshop.classList.add("active");
+ btnWorkshop.setAttribute("aria-selected", "true");
+ }
+}
+globalThis.switchDelegateTab = switchDelegateTab;
+
+function selectSeats(btn, count) {
+ document.querySelectorAll(".seat-pill").forEach((b) =>
+ b.classList.remove("active")
+ );
+ btn.classList.add("active");
+ const input = document.getElementById("eventMaxSeats");
+ if (input) input.value = count;
+}
+globalThis.selectSeats = selectSeats;
+
+function selectEventLifespan(btn, hours) {
+ document.querySelectorAll(".event-lifespan-pill").forEach((b) =>
+ b.classList.remove("active")
+ );
+ btn.classList.add("active");
+ const input = document.getElementById("eventLifespanHours");
+ if (input) input.value = hours;
+}
+globalThis.selectEventLifespan = selectEventLifespan;
+
function selectLifespan(btn, hours) {
document.querySelectorAll(".lifespan-pill").forEach((b) =>
b.classList.remove("active")
@@ -59,9 +108,9 @@ function selectMode(btn, mode) {
const input = document.getElementById("delegateMode");
if (input) input.value = mode;
- const matrix = document.getElementById("customScopeMatrix");
- if (matrix) {
- matrix.style.display = mode === "custom" ? "block" : "none";
+ const accordion = document.getElementById("customScopesSection");
+ if (accordion && mode === "custom") {
+ accordion.open = true;
}
}
globalThis.selectMode = selectMode;
@@ -83,7 +132,7 @@ async function handleDelegateSession(e) {
const hoursInput = document.getElementById("delegateHours");
const lifespanHours = parseInt(hoursInput ? hoursInput.value : "1", 10) || 1;
const modeInput = document.getElementById("delegateMode");
- let mode = modeInput ? modeInput.value : "read";
+ let mode = modeInput ? modeInput.value : "read_only";
const targetAppInput = document.getElementById("delegateTargetApp");
const targetApp = targetAppInput ? targetAppInput.value : "";
@@ -132,7 +181,7 @@ async function handleDelegateSession(e) {
}
const handoffModal = document.getElementById("handoffModal");
if (handoffModal) handoffModal.style.display = "block";
- showNotice("Delegated session created: " + data.label, false);
+ showNotice("Delegated session created for: " + data.label, false);
} else {
showNotice(data.error || "Failed to delegate session", true);
}
@@ -147,6 +196,107 @@ async function handleDelegateSession(e) {
}
globalThis.handleDelegateSession = handleDelegateSession;
+async function handleCreateEvent(e) {
+ e.preventDefault();
+ const nameInput = document.getElementById("eventName");
+ const name = nameInput ? nameInput.value.trim() : "";
+ const appIdInput = document.getElementById("eventAppId");
+ const appId = appIdInput ? appIdInput.value || null : null;
+ const roleInput = document.getElementById("eventRole");
+ const role = roleInput ? roleInput.value || "viewer" : "viewer";
+ const maxSeatsInput = document.getElementById("eventMaxSeats");
+ const maxSeats = parseInt(maxSeatsInput ? maxSeatsInput.value : "0", 10) || 0;
+ const lifespanHoursInput = document.getElementById("eventLifespanHours");
+ const lifespanHours =
+ parseInt(lifespanHoursInput ? lifespanHoursInput.value : "3", 10) || 3;
+ const slugInput = document.getElementById("eventSlug");
+ const slug = slugInput ? slugInput.value.trim() || undefined : undefined;
+ const pinInput = document.getElementById("eventPinCode");
+ const pinCode = pinInput ? pinInput.value.trim() || undefined : undefined;
+
+ const btn = document.getElementById("submitEventBtn");
+ if (btn) {
+ btn.disabled = true;
+ btn.textContent = "Launching...";
+ }
+
+ try {
+ const res = await fetch("/api/events", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({
+ name,
+ appId,
+ role,
+ maxSeats,
+ lifespanHours,
+ slug,
+ pinCode,
+ }),
+ });
+ const data = await res.json();
+ if (res.ok && data.success) {
+ const ev = data.event;
+ const origin = globalThis.location ? globalThis.location.origin : "";
+ const titleElem = document.getElementById("createdEventTitle");
+ if (titleElem) titleElem.textContent = ev.name + " Live!";
+ const pinElem = document.getElementById("eventHandoffPin");
+ if (pinElem) pinElem.textContent = ev.pin_code;
+ const linkElem = document.getElementById("eventHandoffLink");
+ if (linkElem) linkElem.textContent = origin + "/e/" + ev.slug;
+ const cliElem = document.getElementById("eventHandoffCli");
+ if (cliElem) {
+ cliElem.textContent = "curl -sSL " + origin + "/join/" + ev.slug +
+ "?format=env | source /dev/stdin";
+ }
+
+ const createState = document.getElementById("eventCreateState");
+ if (createState) createState.style.display = "none";
+ const handoffState = document.getElementById("eventHandoffState");
+ if (handoffState) handoffState.style.display = "block";
+
+ showNotice("Workshop pass created: " + ev.name, false);
+ } else {
+ showNotice(data.error || "Failed to create event pass", true);
+ }
+ } catch (_err) {
+ showNotice("Network error creating event pass", true);
+ } finally {
+ if (btn) {
+ btn.disabled = false;
+ btn.textContent = "ποΈ Launch Workshop Pass";
+ }
+ }
+}
+globalThis.handleCreateEvent = handleCreateEvent;
+
+function copyEventHandoff(type) {
+ let text = "";
+ if (type === "pin") {
+ const el = document.getElementById("eventHandoffPin");
+ if (el) text = el.textContent;
+ }
+ if (type === "link") {
+ const el = document.getElementById("eventHandoffLink");
+ if (el) text = el.textContent;
+ }
+ if (type === "cli") {
+ const el = document.getElementById("eventHandoffCli");
+ if (el) text = el.textContent;
+ }
+ if (navigator.clipboard) {
+ navigator.clipboard.writeText(text);
+ showNotice("Copied to clipboard: " + text, false);
+ }
+}
+globalThis.copyEventHandoff = copyEventHandoff;
+
+function closeEventHandoffModal() {
+ closeDelegateDrawer();
+ if (globalThis.location) globalThis.location.reload();
+}
+globalThis.closeEventHandoffModal = closeEventHandoffModal;
+
function copyHandoff(type) {
let text = "";
if (type === "cli") {
@@ -163,7 +313,7 @@ function copyHandoff(type) {
}
if (navigator.clipboard) {
navigator.clipboard.writeText(text);
- showNotice("Copied to clipboard!", false);
+ showNotice("Copied to clipboard: " + text, false);
}
}
globalThis.copyHandoff = copyHandoff;
@@ -175,6 +325,28 @@ function closeHandoffModal() {
}
globalThis.closeHandoffModal = closeHandoffModal;
+async function extendSession(sessionId, hours) {
+ try {
+ const res = await fetch("/api/sessions/" + sessionId + "/extend", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ extendHours: hours }),
+ });
+ if (res.ok) {
+ showNotice("Session extended by " + hours + " hour(s)!", false);
+ setTimeout(() => {
+ if (globalThis.location) globalThis.location.reload();
+ }, 600);
+ } else {
+ const data = await res.json();
+ showNotice(data.error || "Failed to extend session", true);
+ }
+ } catch (_err) {
+ showNotice("Network error extending session", true);
+ }
+}
+globalThis.extendSession = extendSession;
+
function openEditScopesModal(sessionId, label, scopesJson) {
let scopes = [];
try {
@@ -242,10 +414,587 @@ function copyText(text) {
}
globalThis.copyText = copyText;
-// Global escape listener for modals and drawers
+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;
+ const compactPinElem = document.getElementById(
+ "compact-pin-" + eventId,
+ );
+ if (compactPinElem) compactPinElem.textContent = data.pinCode;
+
+ if (data.slug) {
+ const origin = globalThis.location ? globalThis.location.origin : "";
+ const linkUrl = origin + "/e/" + data.slug;
+ const cliCmd = "curl -sSL " + origin + "/join/" + data.slug +
+ "?format=env | source /dev/stdin";
+
+ const linkCodeElem = document.getElementById("link-code-" + eventId);
+ if (linkCodeElem) linkCodeElem.textContent = "/e/" + data.slug;
+
+ const linkBtnElem = document.getElementById("link-btn-" + eventId);
+ if (linkBtnElem) linkBtnElem.onclick = () => copyText(linkUrl);
+
+ const cliCodeElem = document.getElementById("cli-code-" + eventId);
+ if (cliCodeElem) cliCodeElem.textContent = cliCmd;
+
+ const cliBtnElem = document.getElementById("cli-btn-" + eventId);
+ if (cliBtnElem) {
+ cliBtnElem.onclick = (e) => {
+ if (e && e.stopPropagation) e.stopPropagation();
+ copyText(cliCmd);
+ };
+ }
+
+ const compactLinkElem = document.getElementById(
+ "compact-link-" + eventId,
+ );
+ if (compactLinkElem) {
+ compactLinkElem.onclick = () => copyText(linkUrl);
+ }
+
+ const compactCliElem = document.getElementById(
+ "compact-cli-" + eventId,
+ );
+ if (compactCliElem) {
+ compactCliElem.onclick = () => copyText(cliCmd);
+ }
+ }
+
+ showNotice("Event credentials rotated successfully", false);
+ } else {
+ showNotice(data.error || "Failed to rotate credentials", true);
+ }
+ } catch (_err) {
+ showNotice("Network error rotating credentials", true);
+ }
+}
+globalThis.rotatePin = rotatePin;
+
+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(() => {
+ if (globalThis.location) globalThis.location.reload();
+ }, 600);
+ } else {
+ showNotice(data.error || "Failed to expand seats", true);
+ }
+ } catch (_err) {
+ showNotice("Network error expanding seats", true);
+ }
+}
+globalThis.expandSeats = expandSeats;
+
+function closeAttendeesDrawer() {
+ const drawer = document.getElementById("attendeesDrawer");
+ if (drawer) {
+ drawer.classList.remove("open");
+ const panel = drawer.querySelector(".drawer-panel");
+ if (panel) panel.classList.remove("open");
+ setTimeout(() => {
+ drawer.style.display = "none";
+ }, 250);
+ }
+}
+globalThis.closeAttendeesDrawer = closeAttendeesDrawer;
+
+function formatNaturalExpiry(expiresAt) {
+ const expDate = new Date(expiresAt);
+ const now = new Date();
+ const diffMs = expDate.getTime() - now.getTime();
+
+ const timeStr = expDate.toLocaleTimeString([], {
+ hour: "numeric",
+ minute: "2-digit",
+ });
+ const fullISO = expDate.toISOString();
+
+ let dateStr = "";
+ const isToday = expDate.getDate() === now.getDate() &&
+ expDate.getMonth() === now.getMonth() &&
+ expDate.getFullYear() === now.getFullYear();
+
+ const tomorrow = new Date(now);
+ tomorrow.setDate(tomorrow.getDate() + 1);
+ const isTomorrow = expDate.getDate() === tomorrow.getDate() &&
+ expDate.getMonth() === tomorrow.getMonth() &&
+ expDate.getFullYear() === tomorrow.getFullYear();
+
+ if (isToday) {
+ dateStr = "Today";
+ } else if (isTomorrow) {
+ dateStr = "Tomorrow";
+ } else if (expDate.getFullYear() === now.getFullYear()) {
+ dateStr = expDate.toLocaleDateString([], {
+ month: "short",
+ day: "numeric",
+ });
+ } else {
+ dateStr = expDate.toLocaleDateString([], {
+ month: "short",
+ day: "numeric",
+ year: "numeric",
+ });
+ }
+
+ let badge = "";
+ if (diffMs <= 0) {
+ badge = 'Expired';
+ } else {
+ const totalMins = Math.floor(diffMs / 60000);
+ const hours = totalMins / 60;
+ const days = hours / 24;
+ const months = days / 30;
+ const years = days / 365;
+
+ let timeRemainingStr = "";
+ let badgeStyle =
+ "background:rgba(34,197,94,0.15);color:#16a34a;padding:2px 6px;border-radius:4px;font-size:0.75rem;font-weight:600;";
+
+ if (hours < 1) {
+ timeRemainingStr = totalMins + "m left";
+ badgeStyle =
+ "background:rgba(245,158,11,0.15);color:#d97706;padding:2px 6px;border-radius:4px;font-size:0.75rem;font-weight:600;";
+ } else if (hours < 24) {
+ const h = Math.floor(hours);
+ const m = totalMins % 60;
+ timeRemainingStr = h + "h " + m + "m left";
+ badgeStyle =
+ "background:rgba(245,158,11,0.15);color:#d97706;padding:2px 6px;border-radius:4px;font-size:0.75rem;font-weight:600;";
+ } else if (days <= 60) {
+ timeRemainingStr = Math.floor(days) + "d left";
+ } else if (months <= 12) {
+ timeRemainingStr = months.toFixed(1) + " mos left";
+ } else {
+ timeRemainingStr = years.toFixed(1) + " yrs left";
+ }
+
+ badge = '' +
+ timeRemainingStr + "";
+ }
+
+ return dateStr + " · " + timeStr + " · " + badge;
+}
+globalThis.formatNaturalExpiry = formatNaturalExpiry;
+
+function formatNaturalJoinTime(createdAt, isActive) {
+ const createdDate = new Date(createdAt);
+ const now = new Date();
+
+ const diffMs = now.getTime() - createdDate.getTime();
+ const diffMins = Math.floor(diffMs / 60000);
+
+ const timeStr = createdDate.toLocaleTimeString([], {
+ hour: "numeric",
+ minute: "2-digit",
+ });
+ const isToday = createdDate.getDate() === now.getDate() &&
+ expDateMatches(createdDate, now);
+ const dateStr = isToday ? "Today" : createdDate.toLocaleDateString([], {
+ month: "short",
+ day: "numeric",
+ });
+
+ let durationStr = "";
+ if (diffMins < 60) {
+ durationStr = diffMins + "m";
+ } else {
+ const h = Math.floor(diffMins / 60);
+ const m = diffMins % 60;
+ durationStr = h + "h " + m + "m";
+ }
+
+ const badgeStyle = isActive
+ ? "background:rgba(34,197,94,0.15);color:#16a34a;padding:2px 6px;border-radius:4px;font-size:0.75rem;font-weight:600;"
+ : "background:rgba(234,179,8,0.15);color:#ca8a04;padding:2px 6px;border-radius:4px;font-size:0.75rem;font-weight:600;";
+ const badgeText = isActive ? "Active " + durationStr : "Paused";
+
+ const activeBadge = '' + badgeText +
+ "";
+ return "Joined " + dateStr + " · " + timeStr + " · " +
+ activeBadge;
+}
+function expDateMatches(a, b) {
+ return a.getMonth() === b.getMonth() && a.getFullYear() === b.getFullYear();
+}
+globalThis.formatNaturalJoinTime = formatNaturalJoinTime;
+
+function updateAllCountdowns() {
+ const pills = document.querySelectorAll(
+ ".countdown-pill, #guestDrawerCountdown",
+ );
+
+ pills.forEach((pill) => {
+ const expiresAtStr = pill.getAttribute("data-expires-at");
+ if (!expiresAtStr) return;
+
+ const now = new Date();
+ const expDate = new Date(expiresAtStr);
+ const diffMs = expDate.getTime() - now.getTime();
+ if (diffMs <= 0) {
+ pill.textContent = "β³ Expired";
+ } else {
+ const totalMins = Math.floor(diffMs / 60000);
+ const hours = Math.floor(totalMins / 60);
+ const mins = totalMins % 60;
+ pill.textContent = "β³ " + hours + "h " + mins + "m left";
+ }
+ });
+}
+globalThis.updateAllCountdowns = updateAllCountdowns;
+
+async function openAttendeesDrawer(eventId, eventName) {
+ const titleEl = document.getElementById("guestDrawerTitle");
+ if (titleEl) titleEl.textContent = eventName + " Guests";
+ const contentEl = document.getElementById("attendeesDrawerContent");
+ if (contentEl) {
+ contentEl.innerHTML =
+ '
Loading attendees...
';
+ }
+
+ const drawer = document.getElementById("attendeesDrawer");
+ if (drawer) {
+ drawer.style.display = "block";
+ drawer.offsetHeight;
+ drawer.classList.add("open");
+ const panel = drawer.querySelector(".drawer-panel");
+ if (panel) panel.classList.add("open");
+ }
+
+ try {
+ const res = await fetch("/api/events/" + eventId + "/attendees");
+ const data = await res.json();
+
+ if (res.ok && data.success) {
+ const attendees = data.attendees || [];
+
+ const claimedElem = document.getElementById("guestDrawerClaimed");
+ if (claimedElem) claimedElem.textContent = attendees.length;
+
+ if (data.event) {
+ const maxElem = document.getElementById("guestDrawerMax");
+ if (maxElem) maxElem.textContent = data.event.max_seats;
+
+ const expiresAtElem = document.getElementById("guestDrawerExpiresAt");
+ if (expiresAtElem && data.event.expires_at) {
+ const expDate = new Date(data.event.expires_at);
+ expiresAtElem.textContent = expDate.toLocaleTimeString([], {
+ hour: "2-digit",
+ minute: "2-digit",
+ });
+ }
+
+ const countdownElem = document.getElementById("guestDrawerCountdown");
+ if (countdownElem && data.event.expires_at) {
+ countdownElem.setAttribute("data-expires-at", data.event.expires_at);
+ }
+
+ updateAllCountdowns();
+ }
+
+ const contentElem = document.getElementById("attendeesDrawerContent");
+ if (attendees.length === 0) {
+ if (contentElem) {
+ contentElem.innerHTML =
+ 'No attendees currently active.
';
+ }
+ return;
+ }
+
+ let html =
+ '';
+ for (const att of attendees) {
+ const isPaused = att.is_paused === true;
+ const usernameParts = (att.username || "").split("_");
+ const seatNumber = usernameParts.length > 2
+ ? usernameParts[usernameParts.length - 1]
+ : "?";
+
+ const joinText = formatNaturalJoinTime(att.created_at, !isPaused);
+ const lastAction = att.last_activity_action || "ForwardAuth Ingress";
+ let timeAgo = "just now";
+ if (att.last_activity_at) {
+ const lastActDate = new Date(att.last_activity_at);
+ const diffMinsAct = Math.floor(
+ (Date.now() - lastActDate.getTime()) / 60000,
+ );
+ timeAgo = diffMinsAct < 1 ? "just now" : diffMinsAct + "m ago";
+ }
+ const clientIcon = lastAction.includes("CLI") ? "π CLI" : "π» Web";
+
+ html += `
+
+
+
+
Seat #${seatNumber}
+
+ ${isPaused ? "βΈοΈ Paused" : "π’ Active"}
+
+
+
+ ${joinText}
+
+
+ Last Action: ${lastAction} · ${timeAgo} · ${clientIcon}
+
+
+
+
+
+
+
+ `;
+ }
+ html += "
";
+ contentElem.innerHTML = html;
+
+ contentElem.querySelectorAll(".revoke-attendee-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);
+ } 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 {
+ const contentElem = document.getElementById("attendeesDrawerContent");
+ if (contentElem) {
+ contentElem.innerHTML =
+ 'Failed to load attendees: ' +
+ (data.error || "HTTP " + res.status) + "
";
+ }
+ }
+ } catch (err) {
+ const contentElem = document.getElementById("attendeesDrawerContent");
+ if (contentElem) {
+ contentElem.innerHTML =
+ 'Error loading attendees: ' +
+ (err && err.message ? err.message : "Network failure") + "
";
+ }
+ }
+}
+globalThis.openAttendeesDrawer = openAttendeesDrawer;
+
+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);
+ } else {
+ showNotice(data.error || "Failed to toggle pause state", true);
+ }
+ } catch (_err) {
+ showNotice("Network error toggling pause state", true);
+ }
+}
+globalThis.toggleSessionPause = toggleSessionPause;
+
+function setEventViewMode(mode) {
+ const container = document.getElementById("eventDeckContainer");
+ const gridBtn = document.getElementById("viewModeGrid");
+ const compactBtn = document.getElementById("viewModeCompact");
+
+ if (container && gridBtn && compactBtn) {
+ if (mode === "compact") {
+ container.classList.remove("grid-view");
+ container.classList.add("compact-view");
+ gridBtn.classList.remove("active");
+ compactBtn.classList.add("active");
+ try {
+ localStorage.setItem("auth_yes_event_view_mode", "compact");
+ } catch (_e) {}
+ } else {
+ container.classList.remove("compact-view");
+ container.classList.add("grid-view");
+ compactBtn.classList.remove("active");
+ gridBtn.classList.add("active");
+ try {
+ localStorage.setItem("auth_yes_event_view_mode", "grid");
+ } catch (_e) {}
+ }
+ }
+}
+globalThis.setEventViewMode = setEventViewMode;
+
+function bindEventCockpitActions() {
+ document.querySelectorAll(".extend-event-btn").forEach((btn) => {
+ btn.addEventListener("click", async (e) => {
+ const eventId = e.currentTarget.getAttribute("data-event-id");
+ e.currentTarget.disabled = true;
+ try {
+ const res = await fetch("/api/events/" + eventId + "/extend", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ extendHours: 1 }),
+ });
+ const data = await res.json().catch(() => ({}));
+ if (res.ok && data.success) {
+ showNotice("Event extended by 1 hour!", false);
+ setTimeout(() => {
+ if (globalThis.location) globalThis.location.reload();
+ }, 600);
+ } else {
+ showNotice(
+ data.error || "Failed to extend event (HTTP " + res.status + ")",
+ true,
+ );
+ e.currentTarget.disabled = false;
+ }
+ } catch (_err) {
+ showNotice("Network error extending event", true);
+ e.currentTarget.disabled = false;
+ }
+ });
+ });
+
+ document.querySelectorAll(".end-event-btn").forEach((btn) => {
+ btn.addEventListener("click", async (e) => {
+ if (
+ !confirm(
+ "Are you sure you want to end this workshop? This will immediately revoke ALL active guest sessions and they will lose access.",
+ )
+ ) return;
+
+ const eventId = e.currentTarget.getAttribute("data-event-id");
+ e.currentTarget.disabled = true;
+ e.currentTarget.textContent = "Revoking...";
+
+ try {
+ const res = await fetch("/api/events/" + eventId + "/end", {
+ method: "POST",
+ });
+ if (res.ok) {
+ showNotice("Workshop ended and all sessions revoked.", false);
+ setTimeout(() => {
+ if (globalThis.location) globalThis.location.reload();
+ }, 600);
+ } else {
+ const data = await res.json().catch(() => ({}));
+ showNotice(data.error || "Failed to end event", true);
+ e.currentTarget.disabled = false;
+ e.currentTarget.textContent = "π΄ End & Revoke All";
+ }
+ } catch (_err) {
+ showNotice("Network error ending event", true);
+ e.currentTarget.disabled = false;
+ e.currentTarget.textContent = "π΄ End & Revoke All";
+ }
+ });
+ });
+
+ document.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 res = await fetch("/api/sessions/" + sessionId, {
+ method: "DELETE",
+ });
+ if (res.ok) {
+ showNotice("Session revoked successfully.", false);
+ setTimeout(() => {
+ if (globalThis.location) globalThis.location.reload();
+ }, 500);
+ } else {
+ const data = await res.json().catch(() => ({}));
+ showNotice(data.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;
+ }
+ });
+ });
+}
+
+// Initial binding
+if (document.readyState === "loading") {
+ document.addEventListener("DOMContentLoaded", () => {
+ bindEventCockpitActions();
+ updateAllCountdowns();
+ setInterval(updateAllCountdowns, 60000);
+ try {
+ const savedMode = localStorage.getItem("auth_yes_event_view_mode");
+ if (savedMode) setEventViewMode(savedMode);
+ } catch (_e) {}
+ });
+} else {
+ bindEventCockpitActions();
+ updateAllCountdowns();
+ setInterval(updateAllCountdowns, 60000);
+ try {
+ const savedMode = localStorage.getItem("auth_yes_event_view_mode");
+ if (savedMode) setEventViewMode(savedMode);
+ } catch (_e) {}
+}
+
document.addEventListener("keydown", (e) => {
if (e.key === "Escape") {
closeDelegateDrawer();
closeEditScopesModal();
+ closeAttendeesDrawer();
}
});
diff --git a/src/features/events/events.test.ts b/src/features/events/events.test.ts
index 7e35ceb..307d1a5 100644
--- a/src/features/events/events.test.ts
+++ b/src/features/events/events.test.ts
@@ -24,3 +24,47 @@ Deno.test("[Events] POST /api/join without code returns HTML error fragment", as
assertStringIncludes(html, 'id="status-banner"');
assertStringIncludes(html, "Event code or PIN is required");
});
+
+Deno.test("[Events] API actions require authentication", async () => {
+ const endpoints = [
+ { method: "POST", path: "/api/events", body: { name: "Test" } },
+ {
+ method: "POST",
+ path: "/api/events/ev-123/extend",
+ body: { extendHours: 1 },
+ },
+ { method: "POST", path: "/api/events/ev-123/rotate-pin", body: {} },
+ {
+ method: "POST",
+ path: "/api/events/ev-123/expand",
+ body: { addSeats: 5 },
+ },
+ { method: "GET", path: "/api/events/ev-123/attendees", body: null },
+ { method: "POST", path: "/api/events/ev-123/end", body: {} },
+ ];
+
+ for (const ep of endpoints) {
+ const req = new Request(`http://localhost${ep.path}`, {
+ method: ep.method,
+ headers: { "Content-Type": "application/json" },
+ body: ep.body ? JSON.stringify(ep.body) : null,
+ });
+ const res = await eventsRoutes.fetch(req);
+ assertEquals(res.status, 401, `Endpoint ${ep.path} should require auth`);
+ }
+});
+
+Deno.test("[Events] public/sessions-scripts.js contains all required UI functions", async () => {
+ const scriptContent = await Deno.readTextFile("./public/sessions-scripts.js");
+ assertStringIncludes(scriptContent, "function setEventViewMode");
+ assertStringIncludes(scriptContent, "function switchDelegateTab");
+ assertStringIncludes(scriptContent, "function handleCreateEvent");
+ assertStringIncludes(scriptContent, "function openAttendeesDrawer");
+ assertStringIncludes(scriptContent, "function rotatePin");
+ assertStringIncludes(scriptContent, "function expandSeats");
+ assertStringIncludes(scriptContent, "function extendSession");
+ assertStringIncludes(scriptContent, "function updateAllCountdowns");
+ assertStringIncludes(scriptContent, ".extend-event-btn");
+ assertStringIncludes(scriptContent, ".end-event-btn");
+ assertStringIncludes(scriptContent, ".revoke-btn");
+});
diff --git a/src/features/events/events_actions_routes.ts b/src/features/events/events_actions_routes.ts
new file mode 100644
index 0000000..cd99b12
--- /dev/null
+++ b/src/features/events/events_actions_routes.ts
@@ -0,0 +1,249 @@
+import { Hono } from "jsr:@hono/hono@4";
+import {
+ getAuthenticatedUser,
+ hasScope,
+ isGlobalAdmin,
+} from "../../core/session.ts";
+import { getClientIp } from "../../core/middleware.ts";
+import { auditWrapper } from "../../core/audit.ts";
+import * as Queries from "./queries.ts";
+
+export const eventsActionsRoutes = new Hono();
+
+// ---------------------------------------------------------
+// Create Multi-Claim Workshop Event Pass
+// ---------------------------------------------------------
+eventsActionsRoutes.post("/api/events", 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);
+ }
+
+ try {
+ const body = await c.req.json().catch(() => ({}));
+ const name = (body.name || "").trim();
+ if (!name) return c.json({ error: "Event name is required" }, 400);
+
+ const appId = body.appId || null;
+ const role = body.role || "viewer";
+ const maxSeats = Math.max(0, parseInt(body.maxSeats, 10) || 0);
+ const lifespanHours = Math.max(1, parseInt(body.lifespanHours, 10) || 3);
+
+ // Generate PIN (e.g. 123-456)
+ const rawPin = body.pinCode && /^\d{3}-?\d{3}$/.test(body.pinCode.trim())
+ ? body.pinCode.trim()
+ : Math.floor(100000 + Math.random() * 900000).toString();
+ const pinCode = rawPin.includes("-")
+ ? rawPin
+ : `${rawPin.slice(0, 3)}-${rawPin.slice(3)}`;
+
+ // Generate vanity slug
+ let baseSlug = body.slug
+ ? body.slug.trim().toLowerCase().replace(/[^a-z0-9-_]/g, "")
+ : name.toLowerCase().replace(/[^a-z0-9]/g, "-").replace(/-+/g, "-");
+ if (!baseSlug) baseSlug = "workshop";
+ const slugSuffix = Math.random().toString(36).substring(2, 6);
+ const slug = `${baseSlug}-${slugSuffix}`;
+
+ const expiresAt = new Date(Date.now() + lifespanHours * 3600 * 1000);
+
+ const event = await Queries.createEventPass(
+ name,
+ slug,
+ pinCode,
+ appId,
+ role,
+ maxSeats,
+ lifespanHours,
+ user.userId,
+ expiresAt,
+ );
+
+ auditWrapper.auditLog(
+ user.userId,
+ "event_pass_created",
+ event ? event.id : null,
+ { name, slug, maxSeats, lifespanHours },
+ getClientIp(c),
+ );
+
+ return c.json({ success: true, event });
+ } catch (err: any) {
+ console.error("[Events] Failed to create event pass:", err);
+ return c.json({ error: err.message || "Failed to create event pass" }, 500);
+ }
+});
+
+// ---------------------------------------------------------
+// Rotate Event Credentials (PIN & Slug)
+// ---------------------------------------------------------
+eventsActionsRoutes.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");
+ const isAdmin = await isGlobalAdmin(user.userId);
+ const newPin = await Queries.rotateEventPin(eventId, user.userId, isAdmin);
+
+ if (!newPin) {
+ return c.json(
+ { error: "Event not found, inactive, or unauthorized" },
+ 404,
+ );
+ }
+
+ auditWrapper.auditLog(
+ user.userId,
+ "event_ingress_rotated",
+ eventId,
+ {},
+ getClientIp(c),
+ );
+
+ return c.json({ success: true, pinCode: newPin });
+});
+
+// ---------------------------------------------------------
+// Expand Workshop Max Seats
+// ---------------------------------------------------------
+eventsActionsRoutes.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(() => ({ addSeats: 5 }));
+ const addSeats = Math.max(Number(body.addSeats) || 5, 1);
+ const isAdmin = await isGlobalAdmin(user.userId);
+
+ const maxSeats = await Queries.expandEventSeats(
+ eventId,
+ addSeats,
+ user.userId,
+ isAdmin,
+ );
+
+ if (maxSeats === null) {
+ return c.json(
+ { error: "Event not found, inactive, or unauthorized" },
+ 404,
+ );
+ }
+
+ auditWrapper.auditLog(user.userId, "event_seats_expanded", eventId, {
+ added: addSeats,
+ newMax: maxSeats,
+ }, getClientIp(c));
+
+ return c.json({ success: true, maxSeats });
+});
+
+// ---------------------------------------------------------
+// Extend Event Lifespan
+// ---------------------------------------------------------
+eventsActionsRoutes.post("/api/events/:id/extend", 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(() => ({ extendHours: 1 }));
+ const extendHours = Math.max(Number(body.extendHours) || 1, 1);
+ const isAdmin = await isGlobalAdmin(user.userId);
+
+ const result = await Queries.extendEventLifespan(
+ eventId,
+ extendHours,
+ user.userId,
+ isAdmin,
+ );
+
+ if (!result) {
+ return c.json(
+ { error: "Event not found, inactive, or unauthorized" },
+ 404,
+ );
+ }
+
+ auditWrapper.auditLog(user.userId, "event_extended", eventId, {
+ extended_by_hours: extendHours,
+ new_expires_at: result.expires_at,
+ }, getClientIp(c));
+
+ return c.json({ success: true, newExpiresAt: result.expires_at });
+});
+
+// ---------------------------------------------------------
+// Fetch Event Attendees for Slide-Over Drawer
+// ---------------------------------------------------------
+eventsActionsRoutes.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");
+ const isAdmin = await isGlobalAdmin(user.userId);
+ const data = await Queries.getEventWithAttendees(
+ eventId,
+ user.userId,
+ isAdmin,
+ );
+
+ if (!data) {
+ return c.json({ error: "Event not found or unauthorized" }, 404);
+ }
+
+ return c.json({
+ success: true,
+ attendees: data.attendees,
+ event: {
+ max_seats: data.event.max_seats,
+ expires_at: data.event.expires_at,
+ },
+ });
+});
+
+// ---------------------------------------------------------
+// End Workshop Event & Revoke All Attendee Sessions
+// ---------------------------------------------------------
+eventsActionsRoutes.post("/api/events/:id/end", 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 isAdmin = await isGlobalAdmin(user.userId);
+ const slug = await Queries.endEvent(eventId, user.userId, isAdmin);
+
+ if (!slug) {
+ return c.json({ error: "Event not found or unauthorized" }, 404);
+ }
+
+ auditWrapper.auditLog(
+ user.userId,
+ "event_ended",
+ eventId,
+ {},
+ getClientIp(c),
+ );
+
+ return c.json({ success: true });
+});
diff --git a/src/features/events/queries.ts b/src/features/events/queries.ts
index 9d9b093..09f757f 100644
--- a/src/features/events/queries.ts
+++ b/src/features/events/queries.ts
@@ -190,3 +190,54 @@ export async function getUserEventPasses(userId: string) {
`;
return result;
}
+
+export async function extendEventLifespan(
+ eventId: string,
+ extendHours: number,
+ userId: string,
+ isGlobalAdmin: boolean,
+) {
+ const result = await sqlWrapper.sql`
+ UPDATE event_passes
+ SET expires_at = GREATEST(expires_at, NOW()) + INTERVAL '1 hour' * ${extendHours}
+ WHERE id = ${eventId}
+ AND (created_by = ${userId} OR ${isGlobalAdmin})
+ AND is_active = TRUE
+ RETURNING id, expires_at
+ `;
+ if (!result || result.length === 0) return null;
+
+ await sqlWrapper.sql`
+ UPDATE sessions
+ SET expires_at = expires_at + INTERVAL '1 hour' * ${extendHours}
+ WHERE user_id IN (
+ SELECT id FROM users WHERE event_pass_id = ${eventId}
+ )
+ `;
+
+ return result[0];
+}
+
+export async function getEventWithAttendees(
+ eventId: string,
+ userId: string,
+ isGlobalAdmin: boolean,
+) {
+ const event = await sqlWrapper.sql`
+ SELECT id, slug, max_seats, expires_at FROM event_passes
+ WHERE id = ${eventId}
+ AND (created_by = ${userId} OR ${isGlobalAdmin})
+ `.then((res: any) => res[0]);
+
+ if (!event) return null;
+
+ const attendees = await sqlWrapper.sql`
+ SELECT s.id, s.label, s.is_paused, s.created_at, s.expires_at, s.last_activity_at, s.last_activity_action, u.username, u.display_name
+ FROM sessions s
+ JOIN users u ON s.user_id = u.id
+ WHERE u.event_pass_id = ${eventId}
+ ORDER BY s.created_at DESC
+ `;
+
+ return { event, attendees };
+}
diff --git a/src/features/events/routes.tsx b/src/features/events/routes.tsx
index 03f0b33..6e5819b 100644
--- a/src/features/events/routes.tsx
+++ b/src/features/events/routes.tsx
@@ -3,7 +3,6 @@ import {
getAuthenticatedUser,
getCookieDomain,
hasScope,
- isGlobalAdmin,
} from "../../core/session.ts";
import { deleteCookie, setCookie } from "jsr:@hono/hono@4/cookie";
import { encodeHex } from "jsr:@std/encoding@1/hex";
@@ -13,114 +12,17 @@ import { auditWrapper } from "../../core/audit.ts";
import { streamDatastar } from "../../core/sse_adapter.ts";
import { renderErrorToastFragment } from "../../core/error_fragments.tsx";
import * as Queries from "./queries.ts";
-import {
- EventCockpitDeckFragment,
- EventJoinPageFragment,
-} from "./fragments.tsx";
+import { EventJoinPageFragment } from "./fragments.tsx";
+import { eventsActionsRoutes } from "./events_actions_routes.ts";
export const eventsRoutes = new Hono();
-// We will implement routes here
-eventsRoutes.post("/api/events/:id/rotate-pin", async (c) => {
- const user = await getAuthenticatedUser(c);
- if (!user) return c.html(renderErrorToastFragment("Unauthorized"), 401);
+// Mount Actions sub-router
+eventsRoutes.route("/", eventsActionsRoutes);
- if (!hasScope(user, "write:events")) {
- return c.html(
- renderErrorToastFragment("Forbidden: Insufficient scopes"),
- 403,
- );
- }
-
- const eventId = c.req.param("id");
- const newPin = await Queries.rotateEventPin(
- eventId,
- user.userId,
- await isGlobalAdmin(user.userId),
- );
-
- if (!newPin) {
- return c.html(
- renderErrorToastFragment("Event not found, inactive, or unauthorized"),
- 404,
- );
- }
-
- // Refresh the cockpit deck fragment
- const events: any[] = []; // TODO: fetch updated events list
- return c.html();
-});
-
-eventsRoutes.post("/api/events/:id/expand", async (c) => {
- const user = await getAuthenticatedUser(c);
- if (!user) return c.html(renderErrorToastFragment("Unauthorized"), 401);
-
- if (!hasScope(user, "write:events")) {
- return c.html(
- renderErrorToastFragment("Forbidden: Insufficient scopes"),
- 403,
- );
- }
-
- const eventId = c.req.param("id");
- let body = { addSeats: 5 };
- if (c.req.header("content-type")?.includes("application/json")) {
- body = await c.req.json().catch(() => ({ addSeats: 5 }));
- }
-
- const addSeats = Math.max(Number(body.addSeats) || 5, 1);
- const maxSeats = await Queries.expandEventSeats(
- eventId,
- addSeats,
- user.userId,
- await isGlobalAdmin(user.userId),
- );
-
- if (maxSeats === null) {
- return c.html(
- renderErrorToastFragment("Event not found, inactive, or unauthorized"),
- 404,
- );
- }
-
- // Refresh cockpit deck
- const events: any[] = []; // TODO: fetch updated events list
- return c.html();
-});
-
-eventsRoutes.post("/api/events/:id/end", async (c) => {
- const user = await getAuthenticatedUser(c);
- if (!user) return c.html(renderErrorToastFragment("Unauthorized"), 401);
-
- if (!hasScope(user, "write:events")) {
- return c.html(
- renderErrorToastFragment("Forbidden: Insufficient scopes"),
- 403,
- );
- }
-
- const eventId = c.req.param("id");
- const slug = await Queries.endEvent(
- eventId,
- user.userId,
- await isGlobalAdmin(user.userId),
- );
-
- if (!slug) {
- return c.html(
- renderErrorToastFragment("Event not found or unauthorized"),
- 404,
- );
- }
-
- // Revoke all sessions for this event
- const _guestPattern = `guest_${slug}_`;
- // Iterate sessions and delete via valkey, db...
-
- // Refresh cockpit deck
- const events: any[] = []; // TODO: fetch updated events list
- return c.html();
-});
+// ---------------------------------------------------------
+// Join Event via Code or PIN
+// ---------------------------------------------------------
eventsRoutes.post("/api/join", async (c) => {
let code = "";
if (
@@ -244,11 +146,13 @@ eventsRoutes.post("/api/join", async (c) => {
return c.html(renderErrorToastFragment("Failed to join event"), 500);
}
});
+
+// ---------------------------------------------------------
+// Live Event Seats SSE Stream
+// ---------------------------------------------------------
eventsRoutes.get("/api/events/:id/stream", async (c) => {
const user = await getAuthenticatedUser(c);
- if (!user) {
- return c.text("Unauthorized", 401);
- }
+ if (!user) return c.text("Unauthorized", 401);
if (!hasScope(user, "read:events")) {
return c.text("Forbidden", 403);
@@ -259,7 +163,6 @@ eventsRoutes.get("/api/events/:id/stream", async (c) => {
return streamDatastar(c, async (stream) => {
let subscriber: any;
try {
- // initial state
const event = await Queries.getEventById(eventId);
if (event) {
await stream.write({
@@ -269,7 +172,6 @@ eventsRoutes.get("/api/events/:id/stream", async (c) => {
});
}
- // setup valkey subscriber for live seats broadcast
subscriber = valkey.duplicate();
await subscriber.subscribe(`event:seats:${eventId}`);
@@ -288,7 +190,7 @@ eventsRoutes.get("/api/events/:id/stream", async (c) => {
});
while (!stream.aborted) {
- await stream.sleep(15000); // keep alive
+ await stream.sleep(15000);
}
} catch (err) {
console.error("[Events SSE] Stream error:", err);
@@ -309,6 +211,10 @@ eventsRoutes.get("/api/events/:id/stream", async (c) => {
}
});
});
+
+// ---------------------------------------------------------
+// UI Join Page
+// ---------------------------------------------------------
eventsRoutes.get("/join", (c) => {
return c.html();
});
diff --git a/src/features/sessions/sessions_fragments.tsx b/src/features/sessions/sessions_fragments.tsx
index e31296e..77f8b59 100644
--- a/src/features/sessions/sessions_fragments.tsx
+++ b/src/features/sessions/sessions_fragments.tsx
@@ -37,8 +37,8 @@ export const SessionsPageFragment = ({
style="display: none; margin-bottom: 1rem; padding: 0.75rem 1rem; border-radius: var(--radius-md); font-size: 0.9rem;"
/>
- {/* Top Action Bar */}
-
+ {/* Top Action Bar with Sticky Positioning */}
+