fix(ui): restore compact/grid toggle, 2-tab delegation, sticky header, and event cockpit guest actions
This commit is contained in:
parent
b2a2bdab07
commit
cd2dd194ec
@ -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 = '<span class="status-badge-red" title="' + fullISO +
|
||||
'" style="background:rgba(239,68,68,0.15);color:#dc2626;padding:2px 6px;border-radius:4px;font-size:0.75rem;font-weight:600;">Expired</span>';
|
||||
} 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 = '<span style="' + badgeStyle + '" title="' + fullISO + '">' +
|
||||
timeRemainingStr + "</span>";
|
||||
}
|
||||
|
||||
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 = '<span style="' + badgeStyle + '">' + badgeText +
|
||||
"</span>";
|
||||
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 =
|
||||
'<div style="display: flex; justify-content: center; align-items: center; height: 100%; color: var(--text-muted); font-size: 0.9rem;">Loading attendees...</div>';
|
||||
}
|
||||
|
||||
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 =
|
||||
'<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;
|
||||
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 += `
|
||||
<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="display: flex; align-items: center; gap: 0.5rem; margin-bottom: 0.25rem;">
|
||||
<div style="font-weight: 600; font-size: 0.95rem; color: var(--text-primary); ${
|
||||
isPaused ? "text-decoration: line-through;" : ""
|
||||
}">Seat #${seatNumber}</div>
|
||||
<div style="font-size: 0.75rem; padding: 2px 6px; border-radius: 4px; background: ${
|
||||
isPaused ? "var(--warning-light)" : "var(--success-light)"
|
||||
}; color: ${isPaused ? "var(--warning)" : "var(--success)"};">
|
||||
${isPaused ? "⏸️ Paused" : "🟢 Active"}
|
||||
</div>
|
||||
</div>
|
||||
<div style="font-size: 0.75rem; color: var(--text-secondary);" title="${att.username}">
|
||||
${joinText}
|
||||
</div>
|
||||
<div style="font-size: 0.75rem; color: var(--text-secondary); margin-top: 0.15rem;">
|
||||
Last Action: ${lastAction} · ${timeAgo} · ${clientIcon}
|
||||
</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 ? "▶️ Resume" : "⏸️ Pause"}
|
||||
</button>
|
||||
<button type="button" class="btn-danger revoke-attendee-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>";
|
||||
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 =
|
||||
'<div style="padding: 1rem; color: var(--danger-text);">Failed to load attendees: ' +
|
||||
(data.error || "HTTP " + res.status) + "</div>";
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
const contentElem = document.getElementById("attendeesDrawerContent");
|
||||
if (contentElem) {
|
||||
contentElem.innerHTML =
|
||||
'<div style="padding: 1rem; color: var(--danger-text);">Error loading attendees: ' +
|
||||
(err && err.message ? err.message : "Network failure") + "</div>";
|
||||
}
|
||||
}
|
||||
}
|
||||
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();
|
||||
}
|
||||
});
|
||||
|
||||
@ -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");
|
||||
});
|
||||
|
||||
249
src/features/events/events_actions_routes.ts
Normal file
249
src/features/events/events_actions_routes.ts
Normal file
@ -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 });
|
||||
});
|
||||
@ -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 };
|
||||
}
|
||||
|
||||
@ -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(<EventCockpitDeckFragment eventPasses={events} />);
|
||||
});
|
||||
|
||||
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(<EventCockpitDeckFragment eventPasses={events} />);
|
||||
});
|
||||
|
||||
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(<EventCockpitDeckFragment eventPasses={events} />);
|
||||
});
|
||||
// ---------------------------------------------------------
|
||||
// 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(<EventJoinPageFragment />);
|
||||
});
|
||||
|
||||
@ -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 */}
|
||||
<div style="margin-bottom: 1.5rem; display: flex; justify-content: space-between; align-items: center; flex-wrap: wrap; gap: 1rem; border-bottom: 1px solid var(--border-subtle); padding-bottom: 1rem;">
|
||||
{/* Top Action Bar with Sticky Positioning */}
|
||||
<div style="position: sticky; top: 0; z-index: 40; background: var(--surface-bg, #0f172a); padding: 0.75rem 0; margin-bottom: 1.5rem; display: flex; justify-content: space-between; align-items: center; flex-wrap: wrap; gap: 1rem; border-bottom: 1px solid var(--border-subtle);">
|
||||
<div>
|
||||
<h1 style="font-size: 1.75rem; font-weight: 700; margin: 0 0 0.25rem 0; color: var(--text-primary);">
|
||||
Sessions & Events
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user