1079 lines
37 KiB
JavaScript
1079 lines
37 KiB
JavaScript
let _lastMintedToken = "";
|
|
|
|
function showNotice(msg, isError) {
|
|
const banner = document.getElementById("status-banner");
|
|
if (!banner) return;
|
|
banner.textContent = msg;
|
|
banner.style.display = "block";
|
|
banner.style.position = "fixed";
|
|
banner.style.top = "1.25rem";
|
|
banner.style.right = "1.25rem";
|
|
banner.style.zIndex = "99999";
|
|
banner.style.minWidth = "280px";
|
|
banner.style.maxWidth = "480px";
|
|
banner.style.padding = "0.85rem 1.25rem";
|
|
banner.style.borderRadius = "var(--radius-md)";
|
|
banner.style.boxShadow = "0 10px 25px -5px rgba(0, 0, 0, 0.35)";
|
|
banner.style.fontSize = "0.9rem";
|
|
banner.style.fontWeight = "600";
|
|
banner.style.background = isError ? "var(--danger-bg)" : "var(--success-bg)";
|
|
banner.style.color = isError ? "var(--danger-text)" : "var(--success-text)";
|
|
banner.style.border = isError
|
|
? "1px solid var(--danger-border)"
|
|
: "1px solid var(--success-border)";
|
|
if (banner._timeout) clearTimeout(banner._timeout);
|
|
banner._timeout = setTimeout(() => {
|
|
banner.style.display = "none";
|
|
}, 4000);
|
|
}
|
|
globalThis.showNotice = showNotice;
|
|
|
|
function setTabLock(locked) {
|
|
const btnDirect = document.getElementById("tabBtnDirectPass");
|
|
const btnWorkshop = document.getElementById("tabBtnWorkshopPass");
|
|
if (btnDirect) {
|
|
btnDirect.disabled = locked;
|
|
btnDirect.style.pointerEvents = locked ? "none" : "auto";
|
|
btnDirect.style.opacity = locked ? "0.5" : "1";
|
|
}
|
|
if (btnWorkshop) {
|
|
btnWorkshop.disabled = locked;
|
|
btnWorkshop.style.pointerEvents = locked ? "none" : "auto";
|
|
btnWorkshop.style.opacity = locked ? "0.5" : "1";
|
|
}
|
|
}
|
|
|
|
function resetDirectPassForm() {
|
|
const form = document.getElementById("delegateForm");
|
|
if (form) form.reset();
|
|
const createEl = document.getElementById("singleSessionCreateState");
|
|
if (createEl) createEl.style.display = "block";
|
|
const handoffEl = document.getElementById("handoffModal");
|
|
if (handoffEl) handoffEl.style.display = "none";
|
|
setTabLock(false);
|
|
}
|
|
globalThis.resetDirectPassForm = resetDirectPassForm;
|
|
|
|
function resetWorkshopForm() {
|
|
const form = document.getElementById("eventForm");
|
|
if (form) form.reset();
|
|
const createEl = document.getElementById("eventCreateState");
|
|
if (createEl) createEl.style.display = "block";
|
|
const handoffEl = document.getElementById("eventHandoffState");
|
|
if (handoffEl) handoffEl.style.display = "none";
|
|
setTabLock(false);
|
|
}
|
|
globalThis.resetWorkshopForm = resetWorkshopForm;
|
|
|
|
function openDelegateDrawer() {
|
|
const drawer = document.getElementById("delegateDrawer");
|
|
if (!drawer) return;
|
|
drawer.style.display = "block";
|
|
drawer.scrollIntoView({ behavior: "smooth" });
|
|
|
|
resetDirectPassForm();
|
|
resetWorkshopForm();
|
|
switchDelegateTab("tabDirectPass");
|
|
}
|
|
globalThis.openDelegateDrawer = openDelegateDrawer;
|
|
|
|
function closeDelegateDrawer() {
|
|
const drawer = document.getElementById("delegateDrawer");
|
|
if (drawer) drawer.style.display = "none";
|
|
resetDirectPassForm();
|
|
resetWorkshopForm();
|
|
}
|
|
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")
|
|
);
|
|
btn.classList.add("active");
|
|
const input = document.getElementById("delegateHours");
|
|
if (input) input.value = hours;
|
|
}
|
|
globalThis.selectLifespan = selectLifespan;
|
|
|
|
function selectMode(btn, mode) {
|
|
document.querySelectorAll(".mode-pill").forEach((b) =>
|
|
b.classList.remove("active")
|
|
);
|
|
btn.classList.add("active");
|
|
const input = document.getElementById("delegateMode");
|
|
if (input) input.value = mode;
|
|
|
|
const accordion = document.getElementById("customScopesSection");
|
|
if (accordion && mode === "custom") {
|
|
accordion.open = true;
|
|
}
|
|
}
|
|
globalThis.selectMode = selectMode;
|
|
|
|
function handleTargetAppChange(appName) {
|
|
if (appName) {
|
|
const scopeVal = "app:" + appName;
|
|
document.querySelectorAll(".scope-checkbox").forEach((cb) => {
|
|
if (cb.value === scopeVal) cb.checked = true;
|
|
});
|
|
}
|
|
}
|
|
globalThis.handleTargetAppChange = handleTargetAppChange;
|
|
|
|
async function handleDelegateSession(e) {
|
|
e.preventDefault();
|
|
const labelInput = document.getElementById("delegateLabel");
|
|
const label = labelInput ? labelInput.value.trim() : "Delegated Session";
|
|
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";
|
|
const targetAppInput = document.getElementById("delegateTargetApp");
|
|
const targetApp = targetAppInput ? targetAppInput.value : "";
|
|
|
|
const customScopes = [];
|
|
if (targetApp) {
|
|
customScopes.push("app:" + targetApp);
|
|
}
|
|
if (mode === "custom" || targetApp) {
|
|
document.querySelectorAll(".scope-checkbox:checked").forEach((cb) => {
|
|
if (!customScopes.includes(cb.value)) {
|
|
customScopes.push(cb.value);
|
|
}
|
|
});
|
|
if (targetApp && mode !== "custom" && mode !== "admin") {
|
|
mode = "custom";
|
|
}
|
|
}
|
|
|
|
const btn = document.getElementById("submitDelegateBtn");
|
|
if (btn) {
|
|
btn.disabled = true;
|
|
btn.textContent = "Creating Session...";
|
|
}
|
|
|
|
try {
|
|
const res = await fetch("/api/sessions/delegate", {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ label, lifespanHours, mode, customScopes }),
|
|
});
|
|
const data = await res.json();
|
|
if (res.ok) {
|
|
_lastMintedToken = data.token;
|
|
const origin = globalThis.location ? globalThis.location.origin : "";
|
|
const magicElem = document.getElementById("handoffMagicLinkText");
|
|
if (magicElem) {
|
|
magicElem.textContent = origin + "/pass?token=" + data.token;
|
|
}
|
|
const cliElem = document.getElementById("handoffCliText");
|
|
if (cliElem) {
|
|
cliElem.textContent = 'export AUTH_YES_TOKEN="' + data.token + '"';
|
|
}
|
|
const curlElem = document.getElementById("handoffCurlText");
|
|
if (curlElem) {
|
|
curlElem.textContent = '-H "Authorization: Bearer ' + data.token + '"';
|
|
}
|
|
|
|
const createEl = document.getElementById("singleSessionCreateState");
|
|
if (createEl) createEl.style.display = "none";
|
|
const handoffModal = document.getElementById("handoffModal");
|
|
if (handoffModal) handoffModal.style.display = "block";
|
|
|
|
setTabLock(true);
|
|
showNotice("Delegated session created for: " + data.label, false);
|
|
} else {
|
|
showNotice(data.error || "Failed to delegate session", true);
|
|
}
|
|
} catch (_err) {
|
|
showNotice("Network error delegating session", true);
|
|
} finally {
|
|
if (btn) {
|
|
btn.disabled = false;
|
|
btn.textContent = "Create Session";
|
|
}
|
|
}
|
|
}
|
|
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 = "Creating Event...";
|
|
}
|
|
|
|
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";
|
|
|
|
setTabLock(true);
|
|
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 = "🎟️ Create Event";
|
|
}
|
|
}
|
|
}
|
|
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") {
|
|
const el = document.getElementById("handoffCliText");
|
|
if (el) text = el.textContent;
|
|
}
|
|
if (type === "curl") {
|
|
const el = document.getElementById("handoffCurlText");
|
|
if (el) text = el.textContent;
|
|
}
|
|
if (type === "magic") {
|
|
const el = document.getElementById("handoffMagicLinkText");
|
|
if (el) text = el.textContent;
|
|
}
|
|
if (navigator.clipboard) {
|
|
navigator.clipboard.writeText(text);
|
|
showNotice("Copied to clipboard: " + text, false);
|
|
}
|
|
}
|
|
globalThis.copyHandoff = copyHandoff;
|
|
|
|
function closeHandoffModal() {
|
|
resetDirectPassForm();
|
|
const drawer = document.getElementById("delegateDrawer");
|
|
if (drawer) drawer.style.display = "none";
|
|
if (globalThis.location) globalThis.location.reload();
|
|
}
|
|
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 {
|
|
scopes = typeof scopesJson === "string"
|
|
? JSON.parse(scopesJson)
|
|
: scopesJson;
|
|
if (typeof scopes === "string") scopes = JSON.parse(scopes);
|
|
} catch (_e) {
|
|
scopes = [];
|
|
}
|
|
const idInput = document.getElementById("editScopesSessionId");
|
|
if (idInput) idInput.value = sessionId;
|
|
const labelEl = document.getElementById("editScopesLabel");
|
|
if (labelEl) labelEl.textContent = label;
|
|
|
|
document.querySelectorAll(".edit-scope-chk").forEach((cb) => {
|
|
cb.checked = Array.isArray(scopes) && scopes.includes(cb.value);
|
|
});
|
|
|
|
const modal = document.getElementById("editScopesModal");
|
|
if (modal) modal.style.display = "flex";
|
|
}
|
|
globalThis.openEditScopesModal = openEditScopesModal;
|
|
|
|
function closeEditScopesModal() {
|
|
const modal = document.getElementById("editScopesModal");
|
|
if (modal) modal.style.display = "none";
|
|
}
|
|
globalThis.closeEditScopesModal = closeEditScopesModal;
|
|
|
|
async function saveUpdatedScopes() {
|
|
const idInput = document.getElementById("editScopesSessionId");
|
|
const sessionId = idInput ? idInput.value : "";
|
|
const customScopes = [];
|
|
document.querySelectorAll(".edit-scope-chk:checked").forEach((cb) => {
|
|
customScopes.push(cb.value);
|
|
});
|
|
|
|
try {
|
|
const res = await fetch("/api/sessions/" + sessionId + "/scopes", {
|
|
method: "PUT",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ customScopes }),
|
|
});
|
|
if (res.ok) {
|
|
showNotice("Session scopes updated!", false);
|
|
setTimeout(() => {
|
|
if (globalThis.location) globalThis.location.reload();
|
|
}, 500);
|
|
} else {
|
|
const data = await res.json();
|
|
showNotice(data.error || "Failed to update scopes", true);
|
|
}
|
|
} catch (_err) {
|
|
showNotice("Network error updating scopes", true);
|
|
}
|
|
}
|
|
globalThis.saveUpdatedScopes = saveUpdatedScopes;
|
|
|
|
function copyText(text) {
|
|
if (navigator.clipboard) {
|
|
navigator.clipboard.writeText(text);
|
|
showNotice("Copied to clipboard!", false);
|
|
}
|
|
}
|
|
globalThis.copyText = copyText;
|
|
|
|
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("PIN rotated successfully", false);
|
|
} else {
|
|
showNotice(data.error || "Failed to rotate PIN", true);
|
|
}
|
|
} catch (_err) {
|
|
showNotice("Network error rotating PIN", 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);
|
|
|
|
const drawer = document.getElementById("attendeesDrawer");
|
|
if (drawer && drawer.classList.contains("open")) {
|
|
const countdownElem = document.getElementById(
|
|
"guestDrawerCountdown",
|
|
);
|
|
if (countdownElem && data.event && data.event.expires_at) {
|
|
countdownElem.setAttribute(
|
|
"data-expires-at",
|
|
data.event.expires_at,
|
|
);
|
|
const expDate = new Date(data.event.expires_at);
|
|
const expiresAtElem = document.getElementById(
|
|
"guestDrawerExpiresAt",
|
|
);
|
|
if (expiresAtElem) {
|
|
expiresAtElem.textContent = expDate.toLocaleTimeString([], {
|
|
hour: "2-digit",
|
|
minute: "2-digit",
|
|
});
|
|
}
|
|
updateAllCountdowns();
|
|
}
|
|
}
|
|
|
|
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 Event";
|
|
}
|
|
} catch (_err) {
|
|
showNotice("Network error ending event", true);
|
|
e.currentTarget.disabled = false;
|
|
e.currentTarget.textContent = "End Event";
|
|
}
|
|
});
|
|
});
|
|
|
|
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();
|
|
}
|
|
});
|