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 */} +

Sessions & Events