feat(phase-3): real-time hypermedia slices and client scripts migration (#58)
* feat(phase-3): migrate events and sessions hypermedia slices and client scripts * docs(audit): add phase 3 post-implementation audit
This commit is contained in:
parent
06cad3d8fe
commit
973fba0607
3
.gitignore
vendored
3
.gitignore
vendored
@ -10,3 +10,6 @@ node_modules/
|
|||||||
target/
|
target/
|
||||||
wasm/sss_recovery/target/
|
wasm/sss_recovery/target/
|
||||||
cov_profile/
|
cov_profile/
|
||||||
|
|
||||||
|
.backups/
|
||||||
|
.jules*
|
||||||
|
|||||||
251
public/sessions-scripts.js
Normal file
251
public/sessions-scripts.js
Normal file
@ -0,0 +1,251 @@
|
|||||||
|
let _lastMintedToken = "";
|
||||||
|
|
||||||
|
function showNotice(msg, isError) {
|
||||||
|
const banner = document.getElementById("status-banner");
|
||||||
|
if (!banner) return;
|
||||||
|
banner.textContent = msg;
|
||||||
|
banner.style.display = "block";
|
||||||
|
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)";
|
||||||
|
setTimeout(() => {
|
||||||
|
banner.style.display = "none";
|
||||||
|
}, 5000);
|
||||||
|
}
|
||||||
|
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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
globalThis.closeDelegateDrawer = closeDelegateDrawer;
|
||||||
|
|
||||||
|
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 matrix = document.getElementById("customScopeMatrix");
|
||||||
|
if (matrix) {
|
||||||
|
matrix.style.display = mode === "custom" ? "block" : "none";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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 = "Minting...";
|
||||||
|
}
|
||||||
|
|
||||||
|
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 handoffModal = document.getElementById("handoffModal");
|
||||||
|
if (handoffModal) handoffModal.style.display = "block";
|
||||||
|
showNotice("Delegated session created: " + 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 = "Mint & Delegate Session";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
globalThis.handleDelegateSession = handleDelegateSession;
|
||||||
|
|
||||||
|
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!", false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
globalThis.copyHandoff = copyHandoff;
|
||||||
|
|
||||||
|
function closeHandoffModal() {
|
||||||
|
const modal = document.getElementById("handoffModal");
|
||||||
|
if (modal) modal.style.display = "none";
|
||||||
|
if (globalThis.location) globalThis.location.reload();
|
||||||
|
}
|
||||||
|
globalThis.closeHandoffModal = closeHandoffModal;
|
||||||
|
|
||||||
|
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;
|
||||||
|
|
||||||
|
// Global escape listener for modals and drawers
|
||||||
|
document.addEventListener("keydown", (e) => {
|
||||||
|
if (e.key === "Escape") {
|
||||||
|
closeDelegateDrawer();
|
||||||
|
closeEditScopesModal();
|
||||||
|
}
|
||||||
|
});
|
||||||
90
src/features/events/attendees_fragments.tsx
Normal file
90
src/features/events/attendees_fragments.tsx
Normal file
@ -0,0 +1,90 @@
|
|||||||
|
export const GuestDrawerAttendeesFragment = () => {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
id="attendeesDrawer"
|
||||||
|
class="drawer-overlay"
|
||||||
|
style="display: none; position: fixed; inset: 0; z-index: 1040; background: rgba(0,0,0,0.5); opacity: 0; transition: opacity 0.2s ease;"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="drawer-panel"
|
||||||
|
style="position: fixed; background: var(--surface-card); box-shadow: var(--shadow-lg); z-index: 1050; display: flex; flex-direction: column; transition: transform 0.25s cubic-bezier(0.16, 1, 0.3, 1);"
|
||||||
|
>
|
||||||
|
<div style="padding: 1.5rem; border-bottom: 1px solid var(--border-subtle); display: flex; flex-direction: column; gap: 0.25rem;">
|
||||||
|
<div style="display: flex; justify-content: space-between; align-items: flex-start;">
|
||||||
|
<h2
|
||||||
|
id="guestDrawerTitle"
|
||||||
|
style="margin: 0; font-size: 1.25rem; color: var(--text-primary);"
|
||||||
|
>
|
||||||
|
[Event Name] Guests
|
||||||
|
</h2>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick="closeAttendeesDrawer()"
|
||||||
|
style="background: none; border: none; font-size: 1.5rem; color: var(--text-muted); cursor: pointer; line-height: 1;"
|
||||||
|
aria-label="Close Drawer"
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
id="guestDrawerContext"
|
||||||
|
style="font-size: 0.8rem; color: var(--text-secondary);"
|
||||||
|
>
|
||||||
|
Event Pass · <span id="guestDrawerClaimed">0</span> /{" "}
|
||||||
|
<span id="guestDrawerMax">0</span> Claimed Seats ·{" "}
|
||||||
|
<span id="guestDrawerCountdown">⏳ 0h 0m left</span>{" "}
|
||||||
|
· (<span id="guestDrawerExpiresAt">
|
||||||
|
--:--
|
||||||
|
</span>)
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
id="attendeesDrawerContent"
|
||||||
|
style="flex: 1; overflow-y: auto; padding: 1.5rem;"
|
||||||
|
>
|
||||||
|
{/* Populated dynamically via JS / SSE */}
|
||||||
|
<div style="display: flex; justify-content: center; align-items: center; height: 100%; color: var(--text-muted); font-size: 0.9rem;">
|
||||||
|
Loading attendees...
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
{`
|
||||||
|
/* Desktop Slide-Over Panel */
|
||||||
|
@media (min-width: 768px) {
|
||||||
|
.drawer-panel {
|
||||||
|
top: 0;
|
||||||
|
right: 0;
|
||||||
|
width: 420px;
|
||||||
|
height: 100vh;
|
||||||
|
transform: translateX(100%);
|
||||||
|
}
|
||||||
|
.drawer-panel.open {
|
||||||
|
transform: translateX(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/* Mobile Bottom Sheet */
|
||||||
|
@media (max-width: 767px) {
|
||||||
|
.drawer-panel {
|
||||||
|
bottom: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100vw;
|
||||||
|
height: 80vh;
|
||||||
|
border-radius: 16px 16px 0 0;
|
||||||
|
transform: translateY(100%);
|
||||||
|
}
|
||||||
|
.drawer-panel.open {
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.drawer-overlay.open {
|
||||||
|
display: block !important;
|
||||||
|
opacity: 1 !important;
|
||||||
|
}
|
||||||
|
`}
|
||||||
|
</style>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
307
src/features/events/cockpit_fragments.tsx
Normal file
307
src/features/events/cockpit_fragments.tsx
Normal file
@ -0,0 +1,307 @@
|
|||||||
|
import { EVENT_COCKPIT_CSS } from "./cockpit_styles.ts";
|
||||||
|
|
||||||
|
export const EventCockpitDeckFragment = (
|
||||||
|
{ eventPasses }: { eventPasses: any[] },
|
||||||
|
) => {
|
||||||
|
if (!eventPasses || eventPasses.length === 0) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style="margin-bottom: 2rem;">
|
||||||
|
<div style="display: flex; justify-content: space-between; align-items: center; border-bottom: 1px solid var(--border-subtle); padding-bottom: 0.5rem; margin-bottom: 1rem;">
|
||||||
|
<h2 style="font-size: 1.25rem; font-weight: 700; margin: 0; color: var(--text-primary);">
|
||||||
|
Events
|
||||||
|
</h2>
|
||||||
|
<div style="display: flex; background: var(--surface-muted); padding: 2px; border-radius: var(--radius-sm); border: 1px solid var(--border-subtle); gap: 2px;">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
id="viewModeGrid"
|
||||||
|
class="view-mode-btn active"
|
||||||
|
onclick="setEventViewMode('grid')"
|
||||||
|
aria-label="Grid View"
|
||||||
|
aria-pressed="true"
|
||||||
|
>
|
||||||
|
🗂️ Grid
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
id="viewModeCompact"
|
||||||
|
class="view-mode-btn"
|
||||||
|
onclick="setEventViewMode('compact')"
|
||||||
|
aria-label="Compact View"
|
||||||
|
aria-pressed="false"
|
||||||
|
>
|
||||||
|
📋 Compact
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="eventDeckContainer" class="grid-view">
|
||||||
|
{eventPasses.map((event) => {
|
||||||
|
const expDate = new Date(event.expires_at);
|
||||||
|
const timeString = expDate.toLocaleTimeString([], {
|
||||||
|
hour: "2-digit",
|
||||||
|
minute: "2-digit",
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={event.id}
|
||||||
|
class="card event-card"
|
||||||
|
style="border-left: 4px solid var(--primary); margin: 0;"
|
||||||
|
>
|
||||||
|
<div class="event-card-header">
|
||||||
|
<div>
|
||||||
|
<h3 style="margin: 0 0 0.25rem 0; font-size: 1.1rem; color: var(--text-primary);">
|
||||||
|
{event.name}
|
||||||
|
</h3>
|
||||||
|
<div
|
||||||
|
class="countdown-pill"
|
||||||
|
data-expires-at={event.expires_at}
|
||||||
|
>
|
||||||
|
⏳ 0h 0m left · (Expires {timeString})
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<span class="badge badge-success status-badge">Active</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Progress Bar for Seats */}
|
||||||
|
<div style="margin-bottom: 1rem;">
|
||||||
|
<div style="display: flex; justify-content: space-between; font-size: 0.8rem; margin-bottom: 0.25rem; color: var(--text-secondary);">
|
||||||
|
<span>Seats Claimed</span>
|
||||||
|
<strong style="color: var(--text-primary);">
|
||||||
|
{event.seats_claimed} /{" "}
|
||||||
|
{event.max_seats === 0 ? "∞" : event.max_seats}
|
||||||
|
</strong>
|
||||||
|
</div>
|
||||||
|
<div style="width: 100%; height: 8px; background: var(--surface-muted); border-radius: 4px; overflow: hidden;">
|
||||||
|
<div
|
||||||
|
style={`height: 100%; background: var(--primary); width: ${
|
||||||
|
event.max_seats > 0
|
||||||
|
? Math.min(
|
||||||
|
(event.seats_claimed / event.max_seats) * 100,
|
||||||
|
100,
|
||||||
|
)
|
||||||
|
: 100
|
||||||
|
}%;`}
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Quick Copy Snippets */}
|
||||||
|
<div
|
||||||
|
class="event-card-snippets"
|
||||||
|
style="display: flex; flex-direction: column; gap: 0.5rem; margin-bottom: 1rem;"
|
||||||
|
>
|
||||||
|
<div style="display: flex; align-items: center; gap: 0.5rem;">
|
||||||
|
<span style="font-size: 0.8rem; font-weight: 600; width: 45px; color: var(--text-muted);">
|
||||||
|
PIN:
|
||||||
|
</span>
|
||||||
|
<code
|
||||||
|
style="flex: 1; padding: 0.35rem 0.5rem; background: var(--surface-muted); border-radius: var(--radius-sm); font-family: monospace; font-size: 0.9rem; text-align: center; letter-spacing: 2px;"
|
||||||
|
id={`pin-${event.id}`}
|
||||||
|
>
|
||||||
|
{event.pin_code}
|
||||||
|
</code>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn-outline"
|
||||||
|
aria-label={`Copy PIN code for ${event.name}`}
|
||||||
|
style="padding: 0.25rem 0.5rem; font-size: 0.75rem; min-height: 28px;"
|
||||||
|
data-ignore
|
||||||
|
onclick={`copyText('${event.pin_code}')`}
|
||||||
|
>
|
||||||
|
Copy
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div style="display: flex; align-items: center; gap: 0.5rem;">
|
||||||
|
<span style="font-size: 0.8rem; font-weight: 600; width: 45px; color: var(--text-muted);">
|
||||||
|
Link:
|
||||||
|
</span>
|
||||||
|
<code
|
||||||
|
id={`link-code-${event.id}`}
|
||||||
|
style="flex: 1; padding: 0.35rem 0.5rem; background: var(--surface-muted); border-radius: var(--radius-sm); font-family: monospace; font-size: 0.75rem; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;"
|
||||||
|
>
|
||||||
|
/e/{event.slug}
|
||||||
|
</code>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
id={`link-btn-${event.id}`}
|
||||||
|
class="btn-outline"
|
||||||
|
aria-label={`Copy direct link for ${event.name}`}
|
||||||
|
style="padding: 0.25rem 0.5rem; font-size: 0.75rem; min-height: 28px;"
|
||||||
|
data-ignore
|
||||||
|
onclick={`copyText(window.location.origin + '/e/${event.slug}')`}
|
||||||
|
>
|
||||||
|
Copy
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<details
|
||||||
|
class="cli-details"
|
||||||
|
style="margin-top: 0.1rem; width: 100%;"
|
||||||
|
>
|
||||||
|
<summary style="display: flex; align-items: center; gap: 0.5rem; cursor: pointer; user-select: none;">
|
||||||
|
<span style="font-size: 0.8rem; font-weight: 600; width: 45px; color: var(--text-muted);">
|
||||||
|
CLI:
|
||||||
|
</span>
|
||||||
|
<code
|
||||||
|
id={`cli-code-${event.id}`}
|
||||||
|
style="flex: 1; padding: 0.35rem 0.5rem; background: var(--surface-muted); border-radius: var(--radius-sm); font-family: monospace; font-size: 0.75rem; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; cursor: pointer;"
|
||||||
|
>
|
||||||
|
curl -sSL {Deno.env.get("RP_ID")
|
||||||
|
? `https://${Deno.env.get("RP_ID")}`
|
||||||
|
: ""}/join/{event.slug}?format=env | source /dev/stdin
|
||||||
|
</code>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
id={`cli-btn-${event.id}`}
|
||||||
|
class="btn-outline"
|
||||||
|
aria-label={`Copy CLI command for ${event.name}`}
|
||||||
|
style="padding: 0.25rem 0.5rem; font-size: 0.75rem; min-height: 28px;"
|
||||||
|
data-ignore
|
||||||
|
onclick={`event.stopPropagation(); copyText("curl -sSL " + window.location.origin + "/join/${event.slug}?format=env | source /dev/stdin")`}
|
||||||
|
>
|
||||||
|
Copy
|
||||||
|
</button>
|
||||||
|
<span style="font-size: 0.75rem; padding-right: 0.25rem;">
|
||||||
|
[ ▾ ]
|
||||||
|
</span>
|
||||||
|
</summary>
|
||||||
|
<textarea
|
||||||
|
id={`cli-textarea-${event.id}`}
|
||||||
|
readonly
|
||||||
|
rows={2}
|
||||||
|
style="width: 100%; margin-top: 0.4rem; padding: 0.4rem 0.5rem; font-family: monospace; font-size: 0.75rem; background: var(--surface-muted); color: var(--text-primary); border: 1px solid var(--border-subtle); border-radius: var(--radius-sm); resize: vertical; box-sizing: border-box;"
|
||||||
|
onclick="this.select()"
|
||||||
|
>
|
||||||
|
{`curl -sSL ${
|
||||||
|
Deno.env.get("RP_ID")
|
||||||
|
? `https://${Deno.env.get("RP_ID")}`
|
||||||
|
: ""
|
||||||
|
}/join/${event.slug}?format=env | source /dev/stdin`}
|
||||||
|
</textarea>
|
||||||
|
</details>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Cockpit Actions */}
|
||||||
|
<div class="event-card-actions">
|
||||||
|
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 0.5rem; margin-bottom: 0.5rem;">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn-outline"
|
||||||
|
aria-label={`Manage attendees for ${event.name}`}
|
||||||
|
style="justify-content: center; min-height: 38px; font-size: 0.85rem;"
|
||||||
|
data-on-click={`@get("/api/events/${event.id}/attendees")`}
|
||||||
|
onclick={`openAttendeesDrawer('${event.id}', '${
|
||||||
|
event.name.replace(/'/g, "\\'")
|
||||||
|
}')`}
|
||||||
|
>
|
||||||
|
👥 Manage Guests ({event.seats_claimed})
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn-outline"
|
||||||
|
aria-label={`Add 5 seats to ${event.name}`}
|
||||||
|
style="justify-content: center; min-height: 38px; font-size: 0.85rem;"
|
||||||
|
data-on-click={`@post("/api/events/${event.id}/expand")`}
|
||||||
|
onclick={`expandSeats('${event.id}', 5)`}
|
||||||
|
>
|
||||||
|
+5 Seats
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div style="display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 0.5rem;">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn-outline"
|
||||||
|
aria-label={`Rotate Credentials for ${event.name}`}
|
||||||
|
style="justify-content: center; min-height: 38px; font-size: 0.82rem; padding: 0 0.25rem;"
|
||||||
|
data-on-click={`@post("/api/events/${event.id}/rotate-pin")`}
|
||||||
|
onclick={`rotatePin('${event.id}')`}
|
||||||
|
>
|
||||||
|
🔄 Rotate Credentials
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn-outline extend-event-btn"
|
||||||
|
data-on-click={`@post("/api/events/${event.id}/extend")`}
|
||||||
|
data-event-id={event.id}
|
||||||
|
aria-label={`Extend lifespan by 1 hour for ${event.name}`}
|
||||||
|
style="justify-content: center; min-height: 38px; font-size: 0.82rem; padding: 0 0.25rem;"
|
||||||
|
>
|
||||||
|
+1h Extend
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn-danger end-event-btn"
|
||||||
|
data-on-click={`@post("/api/events/${event.id}/end")`}
|
||||||
|
data-event-id={event.id}
|
||||||
|
aria-label={`End event ${event.name} and revoke all attendees`}
|
||||||
|
style="justify-content: center; min-height: 38px; font-size: 0.82rem; padding: 0 0.25rem;"
|
||||||
|
>
|
||||||
|
End Event
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Compact Actions */}
|
||||||
|
<div class="event-card-compact-row-2">
|
||||||
|
<div class="compact-pills">
|
||||||
|
<span
|
||||||
|
class="compact-pill"
|
||||||
|
data-ignore
|
||||||
|
onclick={`copyText('${event.pin_code}')`}
|
||||||
|
>
|
||||||
|
PIN:{" "}
|
||||||
|
<span id={`compact-pin-${event.id}`}>{event.pin_code}</span>
|
||||||
|
{" "}
|
||||||
|
(Copy)
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
id={`compact-link-${event.id}`}
|
||||||
|
class="compact-pill"
|
||||||
|
data-ignore
|
||||||
|
onclick={`copyText(window.location.origin + '/e/${event.slug}')`}
|
||||||
|
>
|
||||||
|
Link (Copy)
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
id={`compact-cli-${event.id}`}
|
||||||
|
class="compact-pill"
|
||||||
|
data-ignore
|
||||||
|
onclick={`copyText("curl -sSL " + window.location.origin + "/join/${event.slug}?format=env | source /dev/stdin")`}
|
||||||
|
>
|
||||||
|
CLI (Copy)
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="compact-actions-right">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn-outline"
|
||||||
|
style="padding: 0.25rem 0.5rem; font-size: 0.8rem; min-height: 28px;"
|
||||||
|
data-on-click={`@get("/api/events/${event.id}/attendees")`}
|
||||||
|
onclick={`openAttendeesDrawer('${event.id}', '${
|
||||||
|
event.name.replace(/'/g, "\\'")
|
||||||
|
}')`}
|
||||||
|
>
|
||||||
|
👥 Guests ({event.seats_claimed})
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn-outline extend-event-btn"
|
||||||
|
data-on-click={`@post("/api/events/${event.id}/extend")`}
|
||||||
|
data-event-id={event.id}
|
||||||
|
style="padding: 0.25rem 0.5rem; font-size: 0.8rem; min-height: 28px;"
|
||||||
|
>
|
||||||
|
🔄 +1h
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
<style>{EVENT_COCKPIT_CSS}</style>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
129
src/features/events/cockpit_styles.ts
Normal file
129
src/features/events/cockpit_styles.ts
Normal file
@ -0,0 +1,129 @@
|
|||||||
|
export const EVENT_COCKPIT_CSS = `
|
||||||
|
.cli-details summary { list-style: none; }
|
||||||
|
.cli-details summary::-webkit-details-marker { display: none; }
|
||||||
|
|
||||||
|
.view-mode-btn {
|
||||||
|
padding: 0.25rem 0.75rem;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
border: none;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.15s;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
opacity: 0.75;
|
||||||
|
}
|
||||||
|
.view-mode-btn.active {
|
||||||
|
background: var(--primary);
|
||||||
|
color: #ffffff;
|
||||||
|
font-weight: 700;
|
||||||
|
box-shadow: 0 1px 3px rgba(0,0,0,0.3);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.grid-view {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
.grid-view .event-card-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: flex-start;
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
}
|
||||||
|
.grid-view .event-card-compact-row-2 { display: none; }
|
||||||
|
|
||||||
|
.compact-view {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
.compact-view .event-card {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
box-sizing: border-box;
|
||||||
|
max-width: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
min-height: 70px;
|
||||||
|
}
|
||||||
|
.compact-view .event-card > div { margin-bottom: 0 !important; }
|
||||||
|
.compact-view .event-card-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
width: 100%;
|
||||||
|
margin-bottom: 0.5rem !important;
|
||||||
|
}
|
||||||
|
.compact-view .event-card-header > div {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.compact-view .event-card-header h3 {
|
||||||
|
margin: 0 !important;
|
||||||
|
font-size: 1rem !important;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--text-primary);
|
||||||
|
max-width: 280px;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
display: inline-block;
|
||||||
|
}
|
||||||
|
.compact-view .event-card-header > .status-badge { margin-left: auto; }
|
||||||
|
|
||||||
|
.compact-view .event-card-compact-row-2 {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.compact-view .compact-pills {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
.compact-view .compact-pill {
|
||||||
|
padding: 2px 6px;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
background: var(--surface-muted);
|
||||||
|
border: 1px solid var(--border-subtle);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
cursor: pointer;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-family: monospace;
|
||||||
|
}
|
||||||
|
.compact-view .compact-pill:hover {
|
||||||
|
border-color: var(--primary);
|
||||||
|
color: var(--primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.compact-view .compact-actions-right {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.compact-view .event-card-snippets,
|
||||||
|
.compact-view .event-card-actions {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.countdown-pill {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
.countdown-pill.status-green { color: var(--success); }
|
||||||
|
.countdown-pill.status-amber { color: var(--warning); }
|
||||||
|
.countdown-pill.status-red { color: var(--danger-text); }
|
||||||
|
`;
|
||||||
316
src/features/events/drawer_fragments.tsx
Normal file
316
src/features/events/drawer_fragments.tsx
Normal file
@ -0,0 +1,316 @@
|
|||||||
|
export const WorkshopPassDrawerFragment = ({ apps }: { apps: any[] }) => {
|
||||||
|
return (
|
||||||
|
<div id="tabWorkshopPass" style="display: none;">
|
||||||
|
<div id="eventCreateState" style="display: block;">
|
||||||
|
<p style="color: var(--text-secondary); font-size: 0.9rem; margin: 0 0 1rem 0;">
|
||||||
|
Generate a shared event pass with a universal 6-digit PIN, vanity link
|
||||||
|
(<code style="font-size: 0.8rem;">/e/slug</code>), and CLI 1-liner for
|
||||||
|
workshops, hackathons, and multi-user kiosks.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<form
|
||||||
|
id="eventForm"
|
||||||
|
onsubmit="handleCreateEvent(event)"
|
||||||
|
style="margin-top: 1rem;"
|
||||||
|
>
|
||||||
|
{/* Event Name */}
|
||||||
|
<div style="margin-bottom: 1.25rem;">
|
||||||
|
<label style="display: block; font-weight: 600; margin-bottom: 0.35rem; font-size: 0.875rem; color: var(--text-secondary);">
|
||||||
|
Workshop / Event Name *
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="eventName"
|
||||||
|
placeholder="e.g. Deno & SPIRE Zero-Trust Workshop, Elite Fleet Live Demo"
|
||||||
|
required
|
||||||
|
style="width: 100%;"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap: 1rem; margin-bottom: 1.25rem;">
|
||||||
|
{/* Target App */}
|
||||||
|
<div>
|
||||||
|
<label style="display: block; font-weight: 600; margin-bottom: 0.35rem; font-size: 0.875rem; color: var(--text-secondary);">
|
||||||
|
Target App Destination
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
id="eventAppId"
|
||||||
|
style="width: 100%; padding: 0.5rem 0.75rem; background: var(--surface-card); border: 1px solid var(--border-subtle); border-radius: var(--radius-sm); color: var(--text-primary); font-size: 0.9rem;"
|
||||||
|
>
|
||||||
|
<option value="">Universal Sandbox (Default)</option>
|
||||||
|
{apps.map((app) => (
|
||||||
|
<option value={app.id} key={app.id}>
|
||||||
|
{app.name} ({app.domain || "Internal"})
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Guest Role */}
|
||||||
|
<div>
|
||||||
|
<label style="display: block; font-weight: 600; margin-bottom: 0.35rem; font-size: 0.875rem; color: var(--text-secondary);">
|
||||||
|
Attendee Access Role
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
id="eventRole"
|
||||||
|
style="width: 100%; padding: 0.5rem 0.75rem; background: var(--surface-card); border: 1px solid var(--border-subtle); border-radius: var(--radius-sm); color: var(--text-primary); font-size: 0.9rem;"
|
||||||
|
>
|
||||||
|
<option value="viewer">Viewer (Read-Only Demo)</option>
|
||||||
|
<option value="operator">Operator (Workshop Actions)</option>
|
||||||
|
<option value="user">Standard User</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Seat Capacity Presets */}
|
||||||
|
<div style="margin-bottom: 1.25rem;">
|
||||||
|
<label style="display: block; font-weight: 600; margin-bottom: 0.45rem; font-size: 0.875rem; color: var(--text-secondary);">
|
||||||
|
Seat Capacity Limit
|
||||||
|
</label>
|
||||||
|
<div style="display: flex; gap: 0.5rem; flex-wrap: wrap; align-items: center;">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="pill-btn seat-pill"
|
||||||
|
onclick="selectSeats(this, 25)"
|
||||||
|
>
|
||||||
|
25 Seats
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="pill-btn seat-pill active"
|
||||||
|
onclick="selectSeats(this, 50)"
|
||||||
|
>
|
||||||
|
50 Seats
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="pill-btn seat-pill"
|
||||||
|
onclick="selectSeats(this, 100)"
|
||||||
|
>
|
||||||
|
100 Seats
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="pill-btn seat-pill"
|
||||||
|
onclick="selectSeats(this, 0)"
|
||||||
|
>
|
||||||
|
∞ Unlimited
|
||||||
|
</button>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
id="eventMaxSeats"
|
||||||
|
value="50"
|
||||||
|
min="0"
|
||||||
|
max="10000"
|
||||||
|
style="width: 100px; padding: 0.35rem 0.5rem; text-align: center;"
|
||||||
|
title="Max seats (0 = unlimited)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Duration Presets */}
|
||||||
|
<div style="margin-bottom: 1.25rem;">
|
||||||
|
<label style="display: block; font-weight: 600; margin-bottom: 0.45rem; font-size: 0.875rem; color: var(--text-secondary);">
|
||||||
|
Event Duration & Expiration
|
||||||
|
</label>
|
||||||
|
<div style="display: flex; gap: 0.5rem; flex-wrap: wrap;">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="pill-btn event-lifespan-pill"
|
||||||
|
onclick="selectEventLifespan(this, 1)"
|
||||||
|
>
|
||||||
|
⚡ 1 Hour
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="pill-btn event-lifespan-pill active"
|
||||||
|
onclick="selectEventLifespan(this, 3)"
|
||||||
|
>
|
||||||
|
🛠️ 3 Hours (Standard Workshop)
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="pill-btn event-lifespan-pill"
|
||||||
|
onclick="selectEventLifespan(this, 12)"
|
||||||
|
>
|
||||||
|
📅 12 Hours (All-Day Hackathon)
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="pill-btn event-lifespan-pill"
|
||||||
|
onclick="selectEventLifespan(this, 24)"
|
||||||
|
>
|
||||||
|
🗓️ 24 Hours
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<input type="hidden" id="eventLifespanHours" value="3" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Custom Codes Accordion */}
|
||||||
|
<details style="margin-bottom: 1.5rem; background: var(--surface-muted); padding: 0.75rem 1rem; border-radius: var(--radius-sm); border: 1px solid var(--border-subtle);">
|
||||||
|
<summary style="font-size: 0.875rem; font-weight: 600; color: var(--text-primary); cursor: pointer;">
|
||||||
|
▸ Custom Vanity Slug & Custom PIN Code (Optional)
|
||||||
|
</summary>
|
||||||
|
<div style="margin-top: 0.75rem; display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 1rem;">
|
||||||
|
<div>
|
||||||
|
<label style="display: block; font-size: 0.8rem; font-weight: 600; color: var(--text-secondary); margin-bottom: 0.25rem;">
|
||||||
|
Vanity Slug (e.g. /e/deno-lab)
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="eventSlug"
|
||||||
|
placeholder="auto-generated if blank"
|
||||||
|
style="width: 100%; font-size: 0.85rem;"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label style="display: block; font-size: 0.8rem; font-weight: 600; color: var(--text-secondary); margin-bottom: 0.25rem;">
|
||||||
|
Custom 6-Digit PIN (e.g. 749-123)
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="eventPinCode"
|
||||||
|
placeholder="auto-generated if blank"
|
||||||
|
style="width: 100%; font-size: 0.85rem;"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<div style="display: flex; gap: 0.75rem;">
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
id="submitEventBtn"
|
||||||
|
data-on-click="@post('/api/events')"
|
||||||
|
class="btn-primary"
|
||||||
|
style="min-height: 42px; background: var(--success); border-color: var(--success);"
|
||||||
|
>
|
||||||
|
🎟️ Launch Workshop Pass
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn-outline"
|
||||||
|
onclick="closeDelegateDrawer()"
|
||||||
|
style="min-height: 42px;"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Event Launch Success State */}
|
||||||
|
<div
|
||||||
|
id="eventHandoffState"
|
||||||
|
style="display: none; margin-top: 1.5rem; padding: 1.25rem; background: var(--surface-card); border: 2px solid var(--success); border-radius: var(--radius-md); box-shadow: var(--shadow-md);"
|
||||||
|
>
|
||||||
|
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 0.75rem;">
|
||||||
|
<div style="display: flex; align-items: center; gap: 0.5rem; flex: 1; min-width: 0;">
|
||||||
|
<span style="font-size: 1.3rem; flex-shrink: 0;">🎟️</span>
|
||||||
|
<strong
|
||||||
|
id="createdEventTitle"
|
||||||
|
style="color: var(--text-primary); font-size: 1.1rem; overflow-wrap: break-word; word-break: break-word; max-width: 100%; display: block;"
|
||||||
|
>
|
||||||
|
Workshop Pass Live!
|
||||||
|
</strong>
|
||||||
|
</div>
|
||||||
|
<span
|
||||||
|
class="badge badge-success"
|
||||||
|
style="flex-shrink: 0; margin-left: 0.5rem;"
|
||||||
|
>
|
||||||
|
Active Now
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p style="font-size: 0.85rem; color: var(--text-secondary); margin: 0 0 1rem 0;">
|
||||||
|
Share the 6-digit PIN, 1-click link, or CLI 1-liner with attendees.
|
||||||
|
You can monitor live claimed seats and trigger the master kill switch
|
||||||
|
from the Event Cockpit below.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div style="display: flex; flex-direction: column; gap: 0.75rem; margin-bottom: 1.25rem;">
|
||||||
|
{/* PIN Code */}
|
||||||
|
<div>
|
||||||
|
<label style="display: block; font-size: 0.75rem; font-weight: 700; text-transform: uppercase; color: var(--text-muted); margin-bottom: 0.25rem;">
|
||||||
|
6-Digit Universal PIN (for /join or Kiosk Entry)
|
||||||
|
</label>
|
||||||
|
<div style="display: flex; gap: 0.5rem;">
|
||||||
|
<code
|
||||||
|
id="eventHandoffPin"
|
||||||
|
style="flex: 1; padding: 0.5rem 0.75rem; background: var(--surface-muted); border: 1px solid var(--border-subtle); border-radius: var(--radius-sm); font-family: monospace; font-size: 1.1rem; text-align: center; letter-spacing: 3px; font-weight: 700; color: var(--primary);"
|
||||||
|
>
|
||||||
|
</code>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn-outline"
|
||||||
|
aria-label="Copy Universal PIN"
|
||||||
|
style="min-height: 36px; padding: 0 0.85rem; font-size: 0.8rem;"
|
||||||
|
data-ignore
|
||||||
|
onclick="copyEventHandoff('pin')"
|
||||||
|
>
|
||||||
|
Copy PIN
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 1-Click Vanity Link */}
|
||||||
|
<div>
|
||||||
|
<label style="display: block; font-size: 0.75rem; font-weight: 700; text-transform: uppercase; color: var(--text-muted); margin-bottom: 0.25rem;">
|
||||||
|
Direct 1-Click Workshop Entrance URL
|
||||||
|
</label>
|
||||||
|
<div style="display: flex; gap: 0.5rem;">
|
||||||
|
<code
|
||||||
|
id="eventHandoffLink"
|
||||||
|
style="flex: 1; padding: 0.5rem 0.75rem; background: var(--surface-muted); border: 1px solid var(--border-subtle); border-radius: var(--radius-sm); font-family: monospace; font-size: 0.85rem; overflow-x: auto; white-space: nowrap; color: var(--success);"
|
||||||
|
>
|
||||||
|
</code>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn-outline"
|
||||||
|
aria-label="Copy Direct Link"
|
||||||
|
style="min-height: 36px; padding: 0 0.85rem; font-size: 0.8rem;"
|
||||||
|
data-ignore
|
||||||
|
onclick="copyEventHandoff('link')"
|
||||||
|
>
|
||||||
|
Copy URL
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* CLI 1-Liner */}
|
||||||
|
<div>
|
||||||
|
<label style="display: block; font-size: 0.75rem; font-weight: 700; text-transform: uppercase; color: var(--text-muted); margin-bottom: 0.25rem;">
|
||||||
|
Terminal 1-Liner (Environment Injector)
|
||||||
|
</label>
|
||||||
|
<div style="display: flex; gap: 0.5rem;">
|
||||||
|
<code
|
||||||
|
id="eventHandoffCli"
|
||||||
|
style="flex: 1; padding: 0.5rem 0.75rem; background: var(--surface-muted); border: 1px solid var(--border-subtle); border-radius: var(--radius-sm); font-family: monospace; font-size: 0.8rem; overflow-x: auto; white-space: nowrap; color: var(--text-primary);"
|
||||||
|
>
|
||||||
|
</code>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn-outline"
|
||||||
|
aria-label="Copy Terminal curl command"
|
||||||
|
style="min-height: 36px; padding: 0 0.85rem; font-size: 0.8rem;"
|
||||||
|
data-ignore
|
||||||
|
onclick="copyEventHandoff('cli')"
|
||||||
|
>
|
||||||
|
Copy 1-Liner
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn-outline"
|
||||||
|
style="width: 100%; min-height: 38px; justify-content: center; font-size: 0.85rem;"
|
||||||
|
onclick="closeEventHandoffModal()"
|
||||||
|
>
|
||||||
|
OK
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
26
src/features/events/events.test.ts
Normal file
26
src/features/events/events.test.ts
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
import { assertEquals, assertStringIncludes } from "jsr:@std/assert@1";
|
||||||
|
import { eventsRoutes } from "./routes.tsx";
|
||||||
|
|
||||||
|
Deno.test("[Events] GET /join returns EventJoinPage HTML", async () => {
|
||||||
|
const req = new Request("http://localhost/join");
|
||||||
|
const res = await eventsRoutes.fetch(req);
|
||||||
|
|
||||||
|
assertEquals(res.status, 200);
|
||||||
|
const html = await res.text();
|
||||||
|
assertStringIncludes(html, "Join Event or Workshop");
|
||||||
|
assertStringIncludes(html, 'data-on-submit="@post('/api/join')"');
|
||||||
|
});
|
||||||
|
|
||||||
|
Deno.test("[Events] POST /api/join without code returns HTML error fragment", async () => {
|
||||||
|
const req = new Request("http://localhost/api/join", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const res = await eventsRoutes.fetch(req);
|
||||||
|
assertEquals(res.status, 400);
|
||||||
|
const html = await res.text();
|
||||||
|
assertStringIncludes(html, 'id="status-banner"');
|
||||||
|
assertStringIncludes(html, "Event code or PIN is required");
|
||||||
|
});
|
||||||
4
src/features/events/fragments.tsx
Normal file
4
src/features/events/fragments.tsx
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
export { EventCockpitDeckFragment } from "./cockpit_fragments.tsx";
|
||||||
|
export { WorkshopPassDrawerFragment } from "./drawer_fragments.tsx";
|
||||||
|
export { GuestDrawerAttendeesFragment } from "./attendees_fragments.tsx";
|
||||||
|
export { EventJoinPageFragment } from "./join_fragments.tsx";
|
||||||
78
src/features/events/join_fragments.tsx
Normal file
78
src/features/events/join_fragments.tsx
Normal file
@ -0,0 +1,78 @@
|
|||||||
|
import { LayoutFragment } from "../../shared/ui/fragments.tsx";
|
||||||
|
|
||||||
|
export const EventJoinPageFragment = () => {
|
||||||
|
return (
|
||||||
|
<LayoutFragment title="Join Event">
|
||||||
|
<div class="auth-card">
|
||||||
|
<div class="brand-header">
|
||||||
|
<div
|
||||||
|
class="brand-logo"
|
||||||
|
style="background: var(--primary-light); color: var(--primary);"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
width="26"
|
||||||
|
height="26"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2.5"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
>
|
||||||
|
<path d="M2 9a3 3 0 0 1 0 6v2a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-2a3 3 0 0 1 0-6V7a2 2 0 0 0-2-2H4a2 2 0 0 0-2 2Z">
|
||||||
|
</path>
|
||||||
|
<path d="M13 5v2"></path>
|
||||||
|
<path d="M13 17v2"></path>
|
||||||
|
<path d="M13 11v2"></path>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<h1>Join Event or Workshop</h1>
|
||||||
|
<p class="subtitle">
|
||||||
|
Enter your event PIN code or slug to claim an instant sandbox seat.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form id="joinForm" data-on-submit="@post('/api/join')">
|
||||||
|
<div style="margin-bottom: 1.25rem;">
|
||||||
|
<label style="display: block; font-weight: 600; margin-bottom: 0.35rem; font-size: 0.875rem; color: var(--text-secondary);">
|
||||||
|
Event PIN or Slug Code
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
name="code"
|
||||||
|
id="eventCode"
|
||||||
|
placeholder="e.g. 749-123 or deno-lab"
|
||||||
|
required
|
||||||
|
autofocus
|
||||||
|
style="width: 100%; font-size: 1.1rem; text-align: center; letter-spacing: 0.05em; font-weight: 600; min-height: 48px;"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
id="joinNotice"
|
||||||
|
style="display: none; margin-bottom: 1rem; padding: 0.75rem 1rem; border-radius: var(--radius-md); font-size: 0.9rem;"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
id="joinBtn"
|
||||||
|
class="btn-primary"
|
||||||
|
style="width: 100%; min-height: 48px; font-size: 1rem;"
|
||||||
|
>
|
||||||
|
⚡ Enter Workshop
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div style="margin-top: 1.5rem; text-align: center; font-size: 0.85rem; color: var(--text-muted);">
|
||||||
|
Looking for standard sign in?{" "}
|
||||||
|
<a
|
||||||
|
href="/login"
|
||||||
|
style="color: var(--primary); text-decoration: none; font-weight: 600;"
|
||||||
|
>
|
||||||
|
Sign in with Passkey
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</LayoutFragment>
|
||||||
|
);
|
||||||
|
};
|
||||||
180
src/features/events/queries.ts
Normal file
180
src/features/events/queries.ts
Normal file
@ -0,0 +1,180 @@
|
|||||||
|
import { sqlWrapper } from "../../core/db.ts";
|
||||||
|
|
||||||
|
export async function rotateEventPin(
|
||||||
|
eventId: string,
|
||||||
|
userId: string,
|
||||||
|
isGlobalAdmin: boolean,
|
||||||
|
): Promise<string | null> {
|
||||||
|
const randPin = Math.floor(100000 + Math.random() * 900000).toString();
|
||||||
|
const formattedPin = `${randPin.slice(0, 3)}-${randPin.slice(3)}`;
|
||||||
|
|
||||||
|
const result = await sqlWrapper.sql`
|
||||||
|
UPDATE event_passes
|
||||||
|
SET pin_code = ${formattedPin}
|
||||||
|
WHERE id = ${eventId}
|
||||||
|
AND (created_by = ${userId} OR ${isGlobalAdmin})
|
||||||
|
AND is_active = TRUE
|
||||||
|
RETURNING pin_code
|
||||||
|
`;
|
||||||
|
|
||||||
|
if (!result || result.length === 0) return null;
|
||||||
|
return result[0].pin_code;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getEventAttendees(
|
||||||
|
eventId: string,
|
||||||
|
userId: string,
|
||||||
|
isGlobalAdmin: boolean,
|
||||||
|
) {
|
||||||
|
const event = await sqlWrapper.sql`
|
||||||
|
SELECT slug FROM event_passes
|
||||||
|
WHERE id = ${eventId}
|
||||||
|
AND (created_by = ${userId} OR ${isGlobalAdmin})
|
||||||
|
`;
|
||||||
|
|
||||||
|
if (!event || event.length === 0) return null;
|
||||||
|
|
||||||
|
const slug = event[0].slug;
|
||||||
|
const guestPattern = `guest_${slug}_%`;
|
||||||
|
|
||||||
|
const users = await sqlWrapper.sql`
|
||||||
|
SELECT id, username, display_name, created_at
|
||||||
|
FROM users
|
||||||
|
WHERE username LIKE ${guestPattern}
|
||||||
|
ORDER BY created_at DESC
|
||||||
|
`;
|
||||||
|
|
||||||
|
return users;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getEventBySlugOrPin(code: string) {
|
||||||
|
const rawNormalized = code.trim().toLowerCase();
|
||||||
|
const pinNormalized = code.trim().replace(/[-\s]/g, "");
|
||||||
|
|
||||||
|
const eventLookup = await sqlWrapper.sql`
|
||||||
|
SELECT * FROM event_passes
|
||||||
|
WHERE (LOWER(slug) = ${rawNormalized} OR REPLACE(pin_code, '-', '') = ${pinNormalized})
|
||||||
|
AND is_active = TRUE
|
||||||
|
AND (expires_at IS NULL OR expires_at > NOW())
|
||||||
|
`;
|
||||||
|
|
||||||
|
if (!eventLookup || eventLookup.length === 0) return null;
|
||||||
|
return eventLookup[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function incrementEventSeats(eventId: string) {
|
||||||
|
const updateResult = await sqlWrapper.sql`
|
||||||
|
UPDATE event_passes
|
||||||
|
SET seats_claimed = seats_claimed + 1
|
||||||
|
WHERE id = ${eventId}
|
||||||
|
AND (max_seats = 0 OR seats_claimed < max_seats)
|
||||||
|
RETURNING *
|
||||||
|
`;
|
||||||
|
if (!updateResult || updateResult.length === 0) return null;
|
||||||
|
return updateResult[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createGuestUser(
|
||||||
|
guestUuid: string,
|
||||||
|
username: string,
|
||||||
|
displayName: string,
|
||||||
|
eventId: string,
|
||||||
|
) {
|
||||||
|
await sqlWrapper.sql`
|
||||||
|
INSERT INTO users (id, username, display_name, account_status, event_pass_id)
|
||||||
|
VALUES (${guestUuid}, ${username}, ${displayName}, 'guest', ${eventId})
|
||||||
|
ON CONFLICT DO NOTHING
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getAppById(appId: string) {
|
||||||
|
const apps = await sqlWrapper
|
||||||
|
.sql`SELECT name, domain FROM apps WHERE id = ${appId}`;
|
||||||
|
if (apps.length > 0) return apps[0];
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function endEvent(
|
||||||
|
eventId: string,
|
||||||
|
userId: string,
|
||||||
|
isGlobalAdmin: boolean,
|
||||||
|
) {
|
||||||
|
const result = await sqlWrapper.sql`
|
||||||
|
UPDATE event_passes
|
||||||
|
SET is_active = FALSE
|
||||||
|
WHERE id = ${eventId}
|
||||||
|
AND (created_by = ${userId} OR ${isGlobalAdmin})
|
||||||
|
RETURNING slug
|
||||||
|
`;
|
||||||
|
if (!result || result.length === 0) return null;
|
||||||
|
return result[0].slug;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createEventSession(
|
||||||
|
sessionId: string,
|
||||||
|
userId: string,
|
||||||
|
label: string,
|
||||||
|
customScopes: string[],
|
||||||
|
expiresAt: Date,
|
||||||
|
) {
|
||||||
|
await sqlWrapper.sql`
|
||||||
|
INSERT INTO sessions (id, user_id, label, is_agent, custom_scopes, expires_at)
|
||||||
|
VALUES (${sessionId}, ${userId}, ${label}, false, ${customScopes}, ${expiresAt})
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function expandEventSeats(
|
||||||
|
eventId: string,
|
||||||
|
addSeats: number,
|
||||||
|
userId: string,
|
||||||
|
isGlobalAdmin: boolean,
|
||||||
|
) {
|
||||||
|
const eventResult = await sqlWrapper.sql`
|
||||||
|
UPDATE event_passes
|
||||||
|
SET max_seats = max_seats + ${addSeats}
|
||||||
|
WHERE id = ${eventId}
|
||||||
|
AND (created_by = ${userId} OR ${isGlobalAdmin})
|
||||||
|
AND is_active = TRUE
|
||||||
|
RETURNING max_seats
|
||||||
|
`;
|
||||||
|
if (!eventResult || eventResult.length === 0) return null;
|
||||||
|
return eventResult[0].max_seats;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createEventPass(
|
||||||
|
name: string,
|
||||||
|
slug: string,
|
||||||
|
pinCode: string,
|
||||||
|
appId: string | null,
|
||||||
|
role: string,
|
||||||
|
maxSeats: number,
|
||||||
|
lifespanHours: number,
|
||||||
|
userId: string,
|
||||||
|
expiresAt: Date,
|
||||||
|
) {
|
||||||
|
const result = await sqlWrapper.sql`
|
||||||
|
INSERT INTO event_passes (name, slug, pin_code, app_id, role, max_seats, lifespan_hours, created_by, expires_at)
|
||||||
|
VALUES (${name}, ${slug}, ${pinCode}, ${appId || null}, ${role}, ${
|
||||||
|
Number(maxSeats)
|
||||||
|
}, ${Number(lifespanHours)}, ${userId}, ${expiresAt.toISOString()})
|
||||||
|
RETURNING *
|
||||||
|
`;
|
||||||
|
if (!result || result.length === 0) return null;
|
||||||
|
return result[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getEventById(eventId: string) {
|
||||||
|
const result = await sqlWrapper.sql`
|
||||||
|
SELECT * FROM event_passes WHERE id = ${eventId}
|
||||||
|
`;
|
||||||
|
if (!result || result.length === 0) return null;
|
||||||
|
return result[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getEventBySlug(slug: string) {
|
||||||
|
const result = await sqlWrapper.sql`
|
||||||
|
SELECT * FROM event_passes WHERE slug = ${slug} AND is_active = TRUE
|
||||||
|
`;
|
||||||
|
if (!result || result.length === 0) return null;
|
||||||
|
return result[0];
|
||||||
|
}
|
||||||
315
src/features/events/routes.tsx
Normal file
315
src/features/events/routes.tsx
Normal file
@ -0,0 +1,315 @@
|
|||||||
|
import { Hono } from "jsr:@hono/hono@4";
|
||||||
|
import {
|
||||||
|
getAuthenticatedUser,
|
||||||
|
getCookieDomain,
|
||||||
|
hasScope,
|
||||||
|
isGlobalAdmin,
|
||||||
|
} from "../../../server/auth-session.ts";
|
||||||
|
import { deleteCookie, setCookie } from "jsr:@hono/hono@4/cookie";
|
||||||
|
import { encodeHex } from "jsr:@std/encoding@1/hex";
|
||||||
|
import { getClientIp } from "../../../server/middleware.ts";
|
||||||
|
import { valkey } from "../../core/valkey.ts";
|
||||||
|
import { auditWrapper } from "../../../server/audit.ts";
|
||||||
|
import { rateLimitWrapper } from "../../../server/ratelimit.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";
|
||||||
|
|
||||||
|
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);
|
||||||
|
|
||||||
|
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} />);
|
||||||
|
});
|
||||||
|
eventsRoutes.post("/api/join", async (c) => {
|
||||||
|
let code = "";
|
||||||
|
if (
|
||||||
|
c.req.header("content-type")?.includes("application/x-www-form-urlencoded")
|
||||||
|
) {
|
||||||
|
const fd = await c.req.formData();
|
||||||
|
code = (fd.get("code") as string) || "";
|
||||||
|
} else {
|
||||||
|
const body = await c.req.json().catch(() => ({}));
|
||||||
|
code = body.code || "";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!code || typeof code !== "string") {
|
||||||
|
return c.html(
|
||||||
|
renderErrorToastFragment("Event code or PIN is required"),
|
||||||
|
400,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const clientIp = getClientIp(c);
|
||||||
|
const rateLimitKey = `ratelimit:join:fail:${clientIp}`;
|
||||||
|
|
||||||
|
if (await rateLimitWrapper.isRateLimited(rateLimitKey, 5, 60000)) {
|
||||||
|
return c.html(renderErrorToastFragment("Too Many Requests"), 429);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const event = await Queries.getEventBySlugOrPin(code);
|
||||||
|
|
||||||
|
if (!event) {
|
||||||
|
await rateLimitWrapper.checkRateLimit(rateLimitKey, 5, 60000);
|
||||||
|
return c.html(
|
||||||
|
renderErrorToastFragment(
|
||||||
|
"Invalid event code or workshop capacity reached",
|
||||||
|
),
|
||||||
|
404,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const updatedEvent = await Queries.incrementEventSeats(String(event.id));
|
||||||
|
|
||||||
|
if (!updatedEvent) {
|
||||||
|
await rateLimitWrapper.checkRateLimit(rateLimitKey, 5, 60000);
|
||||||
|
return c.html(renderErrorToastFragment("Workshop capacity reached"), 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
const guestUuid = crypto.randomUUID();
|
||||||
|
const eventShortId = String(updatedEvent.id).split("-")[0];
|
||||||
|
const username = `guest_${eventShortId}_${updatedEvent.seats_claimed}`;
|
||||||
|
|
||||||
|
await Queries.createGuestUser(
|
||||||
|
guestUuid,
|
||||||
|
username,
|
||||||
|
updatedEvent.name + " Attendee",
|
||||||
|
String(updatedEvent.id),
|
||||||
|
);
|
||||||
|
|
||||||
|
const randomBytes = crypto.getRandomValues(new Uint8Array(32));
|
||||||
|
const sessionId = `ay_sess_${encodeHex(randomBytes)}`;
|
||||||
|
const label = `${updatedEvent.name} Seat #${updatedEvent.seats_claimed}`;
|
||||||
|
const ttl = (Number(updatedEvent.lifespan_hours) || 3) * 3600;
|
||||||
|
|
||||||
|
let customScopes = ["guest", "trial"];
|
||||||
|
let appDomain = "";
|
||||||
|
|
||||||
|
if (updatedEvent.app_id) {
|
||||||
|
const app = await Queries.getAppById(String(updatedEvent.app_id));
|
||||||
|
if (app) {
|
||||||
|
customScopes = [`app:${app.name}`, updatedEvent.role || "viewer"];
|
||||||
|
appDomain = app.domain || "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const expiresAt = new Date(Date.now() + ttl * 1000);
|
||||||
|
|
||||||
|
await Queries.createEventSession(
|
||||||
|
sessionId,
|
||||||
|
guestUuid,
|
||||||
|
label,
|
||||||
|
customScopes,
|
||||||
|
expiresAt,
|
||||||
|
);
|
||||||
|
|
||||||
|
await valkey.setex(
|
||||||
|
sessionId,
|
||||||
|
ttl,
|
||||||
|
JSON.stringify({
|
||||||
|
uuid: guestUuid,
|
||||||
|
username,
|
||||||
|
account_status: "guest",
|
||||||
|
customScopes,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
auditWrapper.auditLog(guestUuid, "event_seat_claimed", updatedEvent.id, {
|
||||||
|
slug: updatedEvent.slug,
|
||||||
|
name: updatedEvent.name,
|
||||||
|
seatNumber: updatedEvent.seats_claimed,
|
||||||
|
method: "web",
|
||||||
|
}, getClientIp(c));
|
||||||
|
|
||||||
|
deleteCookie(c, "session_id", { path: "/" });
|
||||||
|
const rpID = Deno.env.get("RP_ID");
|
||||||
|
const cookieDomain = getCookieDomain(rpID);
|
||||||
|
|
||||||
|
setCookie(c, "session_id", sessionId, {
|
||||||
|
domain: cookieDomain,
|
||||||
|
path: "/",
|
||||||
|
httpOnly: true,
|
||||||
|
secure: true,
|
||||||
|
sameSite: "Lax",
|
||||||
|
maxAge: ttl,
|
||||||
|
});
|
||||||
|
|
||||||
|
const redirectUrl = appDomain ? `https://${appDomain}` : "/dashboard";
|
||||||
|
|
||||||
|
c.header("HX-Redirect", redirectUrl);
|
||||||
|
return c.html("");
|
||||||
|
} catch (e: any) {
|
||||||
|
console.error("[Events] Failed to join event:", e);
|
||||||
|
return c.html(renderErrorToastFragment("Failed to join event"), 500);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
eventsRoutes.get("/api/events/:id/stream", async (c) => {
|
||||||
|
const user = await getAuthenticatedUser(c);
|
||||||
|
if (!user) {
|
||||||
|
return c.text("Unauthorized", 401);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!hasScope(user, "read:events")) {
|
||||||
|
return c.text("Forbidden", 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
const eventId = c.req.param("id");
|
||||||
|
|
||||||
|
return streamDatastar(c, async (stream) => {
|
||||||
|
let subscriber: any;
|
||||||
|
try {
|
||||||
|
// initial state
|
||||||
|
const event = await Queries.getEventById(eventId);
|
||||||
|
if (event) {
|
||||||
|
await stream.write({
|
||||||
|
event: "datastar-fragment",
|
||||||
|
data:
|
||||||
|
`<span id="event-seats-${eventId}">${event.seats_claimed}</span>`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// setup valkey subscriber for live seats broadcast
|
||||||
|
subscriber = valkey.duplicate();
|
||||||
|
await subscriber.subscribe(`event:seats:${eventId}`);
|
||||||
|
|
||||||
|
subscriber.on("message", async (_channel: string, message: string) => {
|
||||||
|
if (stream.aborted) return;
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(message);
|
||||||
|
await stream.write({
|
||||||
|
event: "datastar-fragment",
|
||||||
|
data:
|
||||||
|
`<span id="event-seats-${eventId}">${parsed.seats_claimed}</span>`,
|
||||||
|
});
|
||||||
|
} catch (_err) {
|
||||||
|
// Ignore malformed messages
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
while (!stream.aborted) {
|
||||||
|
await stream.sleep(15000); // keep alive
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error("[Events SSE] Stream error:", err);
|
||||||
|
if (!stream.aborted) {
|
||||||
|
const errorHtml = renderErrorToastFragment(
|
||||||
|
"Live updates connection lost.",
|
||||||
|
);
|
||||||
|
await stream.write({
|
||||||
|
event: "datastar-fragment",
|
||||||
|
data: await errorHtml,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
if (subscriber) {
|
||||||
|
await subscriber.unsubscribe();
|
||||||
|
await subscriber.quit();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
eventsRoutes.get("/join", (c) => {
|
||||||
|
return c.html(<EventJoinPageFragment />);
|
||||||
|
});
|
||||||
310
src/features/sessions/actions_routes.ts
Normal file
310
src/features/sessions/actions_routes.ts
Normal file
@ -0,0 +1,310 @@
|
|||||||
|
import { Hono } from "jsr:@hono/hono@4";
|
||||||
|
|
||||||
|
import { valkey } from "../../core/valkey.ts";
|
||||||
|
import { auditWrapper } from "../../../server/audit.ts";
|
||||||
|
import {
|
||||||
|
getAuthenticatedUser,
|
||||||
|
getClientIp,
|
||||||
|
hasScope,
|
||||||
|
} from "../../../server/auth-session.ts";
|
||||||
|
|
||||||
|
import {
|
||||||
|
delegateSession,
|
||||||
|
extendSessionExpiry,
|
||||||
|
getActiveSessions,
|
||||||
|
getSessionById,
|
||||||
|
getSessionByIdAndUserId,
|
||||||
|
getSessionWithOwnershipCheck,
|
||||||
|
revokeSession,
|
||||||
|
setSessionPaused,
|
||||||
|
updateSessionScopes,
|
||||||
|
} from "./queries.ts";
|
||||||
|
|
||||||
|
export const sessionActionsRoutes = new Hono();
|
||||||
|
|
||||||
|
// ---------------------------------------------------------
|
||||||
|
// Active Sessions Listing (API)
|
||||||
|
// ---------------------------------------------------------
|
||||||
|
sessionActionsRoutes.get("/api/sessions", async (c) => {
|
||||||
|
const auth = await getAuthenticatedUser(c);
|
||||||
|
if (!auth) return c.json({ error: "Unauthorized" }, 401);
|
||||||
|
|
||||||
|
const sessions = await getActiveSessions(auth.userId);
|
||||||
|
return c.json({ sessions, currentSessionId: auth.sessionId });
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------
|
||||||
|
// Delegate a child agent session with custom lifespan and scopes
|
||||||
|
// ---------------------------------------------------------
|
||||||
|
sessionActionsRoutes.post("/api/sessions/delegate", async (c) => {
|
||||||
|
const auth = await getAuthenticatedUser(c);
|
||||||
|
if (!auth) return c.json({ error: "Unauthorized" }, 401);
|
||||||
|
|
||||||
|
if (auth.isAgent) {
|
||||||
|
if (!hasScope(auth, "admin") && !hasScope(auth, "*")) {
|
||||||
|
return c.json({ error: "Forbidden: Insufficient scopes" }, 403);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const {
|
||||||
|
label,
|
||||||
|
lifespanHours = 1,
|
||||||
|
mode = "read_only",
|
||||||
|
customScopes = [],
|
||||||
|
} = await c.req.json();
|
||||||
|
|
||||||
|
const cleanLabel = (label && typeof label === "string" && label.trim())
|
||||||
|
? label.trim()
|
||||||
|
: "Delegated Session";
|
||||||
|
const hours = Math.min(Math.max(Number(lifespanHours) || 1, 1), 720);
|
||||||
|
const expiresAt = new Date(Date.now() + hours * 3600 * 1000);
|
||||||
|
|
||||||
|
let effectiveScopes: string[] = [];
|
||||||
|
if (mode === "read_only" || mode === "read") {
|
||||||
|
effectiveScopes = [
|
||||||
|
"read:audit",
|
||||||
|
"read:users",
|
||||||
|
"read:apps",
|
||||||
|
"read:roles",
|
||||||
|
"read:sessions",
|
||||||
|
];
|
||||||
|
} else if (mode === "operator" || mode === "standard") {
|
||||||
|
effectiveScopes = ["operator", "read:audit", "read:users", "read:apps"];
|
||||||
|
} else if (mode === "admin") {
|
||||||
|
effectiveScopes = ["*"];
|
||||||
|
} else if (mode === "custom" && Array.isArray(customScopes)) {
|
||||||
|
effectiveScopes = customScopes.map((s: string) => String(s).trim()).filter(
|
||||||
|
Boolean,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const rawBytes = new Uint8Array(32);
|
||||||
|
crypto.getRandomValues(rawBytes);
|
||||||
|
const tokenHex = Array.from(rawBytes).map((b) =>
|
||||||
|
b.toString(16).padStart(2, "0")
|
||||||
|
).join("");
|
||||||
|
const sessionId = `ay_sess_${tokenHex}`;
|
||||||
|
|
||||||
|
await delegateSession(
|
||||||
|
sessionId,
|
||||||
|
auth.userId,
|
||||||
|
cleanLabel,
|
||||||
|
effectiveScopes,
|
||||||
|
expiresAt,
|
||||||
|
);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const sessionData = {
|
||||||
|
uuid: auth.userId,
|
||||||
|
username: auth.username,
|
||||||
|
label: cleanLabel,
|
||||||
|
isAgent: true,
|
||||||
|
customScopes: effectiveScopes,
|
||||||
|
};
|
||||||
|
await valkey.setex(
|
||||||
|
sessionId,
|
||||||
|
Math.floor(hours * 3600),
|
||||||
|
JSON.stringify(sessionData),
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
console.error("[Valkey] Failed to cache delegated session:", err);
|
||||||
|
}
|
||||||
|
|
||||||
|
auditWrapper.auditLog(auth.userId, "session_delegated", sessionId, {
|
||||||
|
label: cleanLabel,
|
||||||
|
lifespan_hours: hours,
|
||||||
|
mode,
|
||||||
|
scopes: effectiveScopes,
|
||||||
|
}, getClientIp(c));
|
||||||
|
|
||||||
|
return c.json({
|
||||||
|
success: true,
|
||||||
|
sessionId,
|
||||||
|
token: sessionId,
|
||||||
|
label: cleanLabel,
|
||||||
|
expiresAt: expiresAt.toISOString(),
|
||||||
|
scopes: effectiveScopes,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------
|
||||||
|
// Update permissions on an active session
|
||||||
|
// ---------------------------------------------------------
|
||||||
|
sessionActionsRoutes.put("/api/sessions/:id/scopes", async (c) => {
|
||||||
|
const auth = await getAuthenticatedUser(c);
|
||||||
|
if (!auth) return c.json({ error: "Unauthorized" }, 401);
|
||||||
|
|
||||||
|
if (!hasScope(auth, "write:sessions")) {
|
||||||
|
return c.json({ error: "Forbidden: Insufficient scopes" }, 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
const targetSessionId = c.req.param("id");
|
||||||
|
const { customScopes = [] } = await c.req.json();
|
||||||
|
|
||||||
|
const session = await getSessionByIdAndUserId(targetSessionId, auth.userId);
|
||||||
|
if (!session) {
|
||||||
|
return c.json({ error: "Session not found or access denied" }, 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
const effectiveScopes = Array.isArray(customScopes)
|
||||||
|
? customScopes.map((s: string) => String(s).trim()).filter(Boolean)
|
||||||
|
: [];
|
||||||
|
|
||||||
|
await updateSessionScopes(targetSessionId, effectiveScopes);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const existingCached = await valkey.get(targetSessionId);
|
||||||
|
if (existingCached) {
|
||||||
|
const parsed = JSON.parse(existingCached);
|
||||||
|
parsed.customScopes = effectiveScopes;
|
||||||
|
await valkey.set(targetSessionId, JSON.stringify(parsed));
|
||||||
|
}
|
||||||
|
} catch (_e) {}
|
||||||
|
|
||||||
|
auditWrapper.auditLog(
|
||||||
|
auth.userId,
|
||||||
|
"session_scopes_updated",
|
||||||
|
targetSessionId,
|
||||||
|
{ scopes: effectiveScopes },
|
||||||
|
getClientIp(c),
|
||||||
|
);
|
||||||
|
|
||||||
|
return c.json({ success: true, scopes: effectiveScopes });
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------
|
||||||
|
// Pause / Unpause Session
|
||||||
|
// ---------------------------------------------------------
|
||||||
|
sessionActionsRoutes.post("/api/sessions/:id/pause", async (c) => {
|
||||||
|
const auth = await getAuthenticatedUser(c);
|
||||||
|
if (!auth) return c.json({ error: "Unauthorized" }, 401);
|
||||||
|
|
||||||
|
if (!hasScope(auth, "write:sessions")) {
|
||||||
|
return c.json({ error: "Forbidden: Insufficient scopes" }, 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
const targetSessionId = c.req.param("id");
|
||||||
|
const { is_paused } = await c.req.json();
|
||||||
|
const shouldPause = Boolean(is_paused);
|
||||||
|
|
||||||
|
const session = await getSessionById(targetSessionId);
|
||||||
|
if (!session) {
|
||||||
|
return c.json({ error: "Session not found" }, 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
await setSessionPaused(targetSessionId, shouldPause);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const existingCached = await valkey.get(targetSessionId);
|
||||||
|
if (existingCached) {
|
||||||
|
const parsed = JSON.parse(existingCached);
|
||||||
|
parsed.is_paused = shouldPause;
|
||||||
|
const ttl = await valkey.ttl(targetSessionId);
|
||||||
|
if (ttl > 0) {
|
||||||
|
await valkey.setex(targetSessionId, ttl, JSON.stringify(parsed));
|
||||||
|
} else {
|
||||||
|
await valkey.set(targetSessionId, JSON.stringify(parsed));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error("[Valkey] Failed to update session pause state:", err);
|
||||||
|
}
|
||||||
|
|
||||||
|
auditWrapper.auditLog(
|
||||||
|
auth.userId,
|
||||||
|
shouldPause ? "session_paused" : "session_unpaused",
|
||||||
|
targetSessionId,
|
||||||
|
{},
|
||||||
|
getClientIp(c),
|
||||||
|
);
|
||||||
|
|
||||||
|
return c.json({ success: true, is_paused: shouldPause });
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------
|
||||||
|
// Extend session TTL
|
||||||
|
// ---------------------------------------------------------
|
||||||
|
sessionActionsRoutes.post("/api/sessions/:id/extend", async (c) => {
|
||||||
|
const auth = await getAuthenticatedUser(c);
|
||||||
|
if (!auth) return c.json({ error: "Unauthorized" }, 401);
|
||||||
|
|
||||||
|
if (!hasScope(auth, "write:sessions")) {
|
||||||
|
return c.json({ error: "Forbidden: Insufficient scopes" }, 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
const targetSessionId = c.req.param("id");
|
||||||
|
const { extendHours = 1 } = await c.req.json().catch(() => ({}));
|
||||||
|
const additionalHours = Math.max(Number(extendHours) || 1, 1);
|
||||||
|
|
||||||
|
const session = await getSessionByIdAndUserId(targetSessionId, auth.userId);
|
||||||
|
if (!session) {
|
||||||
|
return c.json({ error: "Session not found or access denied" }, 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentExpiry = new Date(session.expires_at).getTime();
|
||||||
|
const newExpiry = new Date(
|
||||||
|
Math.max(Date.now(), currentExpiry) + additionalHours * 3600 * 1000,
|
||||||
|
);
|
||||||
|
|
||||||
|
await extendSessionExpiry(targetSessionId, newExpiry);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const ttlSeconds = Math.max(
|
||||||
|
1,
|
||||||
|
Math.floor((newExpiry.getTime() - Date.now()) / 1000),
|
||||||
|
);
|
||||||
|
await valkey.expire(targetSessionId, ttlSeconds);
|
||||||
|
} catch (_e) {}
|
||||||
|
|
||||||
|
auditWrapper.auditLog(auth.userId, "session_extended", targetSessionId, {
|
||||||
|
extended_by_hours: additionalHours,
|
||||||
|
new_expires_at: newExpiry.toISOString(),
|
||||||
|
}, getClientIp(c));
|
||||||
|
|
||||||
|
return c.json({ success: true, newExpiresAt: newExpiry.toISOString() });
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------
|
||||||
|
// Revoke a specific session
|
||||||
|
// ---------------------------------------------------------
|
||||||
|
sessionActionsRoutes.delete("/api/sessions/:id", async (c) => {
|
||||||
|
const auth = await getAuthenticatedUser(c);
|
||||||
|
if (!auth) return c.json({ error: "Unauthorized" }, 401);
|
||||||
|
|
||||||
|
const targetSessionId = c.req.param("id");
|
||||||
|
|
||||||
|
if (targetSessionId !== auth.sessionId) {
|
||||||
|
if (!hasScope(auth, "write:sessions")) {
|
||||||
|
return c.json({ error: "Forbidden: Insufficient scopes" }, 403);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const isAdmin = hasScope(auth, "admin") || hasScope(auth, "*");
|
||||||
|
const session = await getSessionWithOwnershipCheck(
|
||||||
|
targetSessionId,
|
||||||
|
auth.userId,
|
||||||
|
isAdmin,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!session) {
|
||||||
|
return c.json({ error: "Session not found or access denied" }, 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await valkey.del(targetSessionId);
|
||||||
|
await valkey.publish(
|
||||||
|
`sessions:revoked:${targetSessionId}`,
|
||||||
|
JSON.stringify({ sessionId: targetSessionId, revokedBy: auth.userId }),
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to delete session from cache:", err);
|
||||||
|
}
|
||||||
|
|
||||||
|
await revokeSession(targetSessionId);
|
||||||
|
|
||||||
|
auditWrapper.auditLog(auth.userId, "session_revoked", null, {
|
||||||
|
revoked_session_id: targetSessionId,
|
||||||
|
}, getClientIp(c));
|
||||||
|
|
||||||
|
return c.json({ success: true });
|
||||||
|
});
|
||||||
124
src/features/sessions/deck_fragments.tsx
Normal file
124
src/features/sessions/deck_fragments.tsx
Normal file
@ -0,0 +1,124 @@
|
|||||||
|
export const SessionDeckFragment = ({
|
||||||
|
sessions,
|
||||||
|
currentSessionId,
|
||||||
|
}: {
|
||||||
|
sessions: any[];
|
||||||
|
currentSessionId: string;
|
||||||
|
}) => {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
class="mobile-only"
|
||||||
|
style="display: flex; flex-direction: column; gap: 1rem;"
|
||||||
|
>
|
||||||
|
{sessions.length === 0
|
||||||
|
? (
|
||||||
|
<div class="card" style="text-align: center; padding: 2rem;">
|
||||||
|
<p style="color: var(--text-muted); margin: 0;">
|
||||||
|
No active sessions found.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
: (
|
||||||
|
sessions.map((session) => {
|
||||||
|
const isCurrent = session.id === currentSessionId;
|
||||||
|
const isAgent = !!session.is_agent;
|
||||||
|
const scopes = Array.isArray(session.custom_scopes)
|
||||||
|
? session.custom_scopes
|
||||||
|
: [];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div class="card" key={session.id} style="margin-bottom: 0;">
|
||||||
|
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 0.75rem;">
|
||||||
|
<div style="display: flex; align-items: center; gap: 0.65rem;">
|
||||||
|
<div style="display: flex; align-items: center; justify-content: center; width: 38px; height: 38px; background: var(--surface-muted); border-radius: var(--radius-md); font-size: 1.2rem;">
|
||||||
|
{isAgent ? "🔑" : isCurrent ? "📱" : "💻"}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div style="font-weight: 700; font-size: 0.95rem; color: var(--text-primary);">
|
||||||
|
{session.label ||
|
||||||
|
(isCurrent ? "This Device" : "Remote Device")}
|
||||||
|
</div>
|
||||||
|
<div style="font-size: 0.75rem; color: var(--text-muted); font-family: monospace;">
|
||||||
|
{session.id.substring(0, 14)}...
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="display: flex; align-items: center; gap: 0.5rem;">
|
||||||
|
{isAgent
|
||||||
|
? <span class="badge badge-info">Delegated</span>
|
||||||
|
: isCurrent
|
||||||
|
? <span class="badge badge-success">Active Now</span>
|
||||||
|
: <span class="badge badge-secondary">Active</span>}
|
||||||
|
{!isCurrent && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn-danger revoke-btn"
|
||||||
|
data-on-click={`@delete("/api/sessions/${session.id}")`}
|
||||||
|
data-session-id={session.id}
|
||||||
|
style="padding: 0.15rem 0.5rem; font-size: 0.75rem; min-height: 24px; border-radius: var(--radius-sm);"
|
||||||
|
>
|
||||||
|
🗑️ Revoke
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="font-size: 0.8rem; color: var(--text-secondary); margin-bottom: 1rem; line-height: 1.6;">
|
||||||
|
{isAgent && (
|
||||||
|
<div style="margin-bottom: 0.25rem;">
|
||||||
|
<strong>Scopes:</strong>{" "}
|
||||||
|
{scopes.length > 0 ? scopes.join(", ") : "Delegated"}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div>
|
||||||
|
<span
|
||||||
|
class="countdown-pill"
|
||||||
|
data-expires-at={session.expires_at}
|
||||||
|
>
|
||||||
|
⏳ 0h 0m left · (Expires{" "}
|
||||||
|
{new Date(session.expires_at).toLocaleTimeString([], {
|
||||||
|
hour: "2-digit",
|
||||||
|
minute: "2-digit",
|
||||||
|
})})
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{session.last_activity_action && (
|
||||||
|
<div>
|
||||||
|
<strong>Last Active:</strong>{" "}
|
||||||
|
{session.last_activity_action} ({new Date(
|
||||||
|
session.last_activity_at || session.created_at,
|
||||||
|
).toLocaleTimeString()})
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isAgent && (
|
||||||
|
<div style="display: flex; gap: 0.5rem;">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn-outline"
|
||||||
|
style="flex: 1; justify-content: center; min-height: 38px; font-size: 0.8rem;"
|
||||||
|
data-on-click={`@post("/api/sessions/${session.id}/extend")`}
|
||||||
|
>
|
||||||
|
+1h Extend
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn-outline"
|
||||||
|
style="flex: 1; justify-content: center; min-height: 38px; font-size: 0.8rem;"
|
||||||
|
onclick={`openEditScopesModal('${session.id}', '${
|
||||||
|
session.label || "Delegated"
|
||||||
|
}', ${JSON.stringify(JSON.stringify(scopes))})`}
|
||||||
|
>
|
||||||
|
Scopes
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
307
src/features/sessions/drawer_fragments.tsx
Normal file
307
src/features/sessions/drawer_fragments.tsx
Normal file
@ -0,0 +1,307 @@
|
|||||||
|
export const DirectPassDrawerFragment = ({
|
||||||
|
apps = [],
|
||||||
|
isAdmin = false,
|
||||||
|
}: {
|
||||||
|
apps?: any[];
|
||||||
|
isAdmin?: boolean;
|
||||||
|
}) => {
|
||||||
|
return (
|
||||||
|
<div id="tabDirectPass">
|
||||||
|
<p style="color: var(--text-secondary); font-size: 0.9rem; margin: 0 0 1rem 0;">
|
||||||
|
Mint an isolated, scoped child session for 1-click magic links, CLI
|
||||||
|
tools, team members, or AI assistants without exposing your primary
|
||||||
|
passkeys.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<form
|
||||||
|
id="delegateForm"
|
||||||
|
onsubmit="handleDelegateSession(event)"
|
||||||
|
style="margin-top: 1rem;"
|
||||||
|
>
|
||||||
|
{/* Label Input */}
|
||||||
|
<div style="margin-bottom: 1.25rem;">
|
||||||
|
<label style="display: block; font-weight: 600; margin-bottom: 0.35rem; font-size: 0.875rem; color: var(--text-secondary);">
|
||||||
|
Session Label / Purpose *
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="delegateLabel"
|
||||||
|
placeholder="e.g. Friend Demo Pass, Antigravity Assistant, CI/CD Runner, Terminal CLI"
|
||||||
|
required
|
||||||
|
style="width: 100%;"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Direct 1-Click Destination */}
|
||||||
|
<div style="margin-bottom: 1.25rem;">
|
||||||
|
<label style="display: block; font-weight: 600; margin-bottom: 0.35rem; font-size: 0.875rem; color: var(--text-secondary);">
|
||||||
|
1-Click Destination App (Optional)
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
id="delegateTargetApp"
|
||||||
|
onchange="handleTargetAppChange(this.value)"
|
||||||
|
style="width: 100%; padding: 0.5rem 0.75rem; background: var(--surface-card); border: 1px solid var(--border-subtle); border-radius: var(--radius-sm); color: var(--text-primary); font-size: 0.9rem;"
|
||||||
|
>
|
||||||
|
<option value="">Auth-Yes Central Dashboard (Default)</option>
|
||||||
|
{apps.map((app) => (
|
||||||
|
<option value={app.name} key={app.id}>
|
||||||
|
{app.name} ({app.domain || "Internal App"})
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<p style="font-size: 0.75rem; color: var(--text-muted); margin: 0.35rem 0 0 0;">
|
||||||
|
Selecting an app scopes the pass and automatically redirects the
|
||||||
|
recipient to that app upon 1-click redemption.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Lifespan Presets */}
|
||||||
|
<div style="margin-bottom: 1.25rem;">
|
||||||
|
<label style="display: block; font-weight: 600; margin-bottom: 0.45rem; font-size: 0.875rem; color: var(--text-secondary);">
|
||||||
|
Lifespan & Auto-Expiration
|
||||||
|
</label>
|
||||||
|
<div style="display: flex; gap: 0.5rem; flex-wrap: wrap;">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="pill-btn lifespan-pill active"
|
||||||
|
data-hours="1"
|
||||||
|
onclick="selectLifespan(this, 1)"
|
||||||
|
>
|
||||||
|
⚡ 1 Hour (Quick Task)
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="pill-btn lifespan-pill"
|
||||||
|
data-hours="12"
|
||||||
|
onclick="selectLifespan(this, 12)"
|
||||||
|
>
|
||||||
|
🛠️ 12 Hours (Work Session)
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="pill-btn lifespan-pill"
|
||||||
|
data-hours="24"
|
||||||
|
onclick="selectLifespan(this, 24)"
|
||||||
|
>
|
||||||
|
📅 24 Hours
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="pill-btn lifespan-pill"
|
||||||
|
data-hours="168"
|
||||||
|
onclick="selectLifespan(this, 168)"
|
||||||
|
>
|
||||||
|
🗓️ 7 Days
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<input type="hidden" id="delegateHours" value="1" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Access Mode Presets */}
|
||||||
|
<div style="margin-bottom: 1.25rem;">
|
||||||
|
<label style="display: block; font-weight: 600; margin-bottom: 0.45rem; font-size: 0.875rem; color: var(--text-secondary);">
|
||||||
|
Access Mode & Permissions
|
||||||
|
</label>
|
||||||
|
<div style="display: flex; gap: 0.5rem; flex-wrap: wrap;">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="pill-btn mode-pill active"
|
||||||
|
data-mode="read"
|
||||||
|
onclick="selectMode(this, 'read')"
|
||||||
|
>
|
||||||
|
👁️ Read-Only Viewer
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="pill-btn mode-pill"
|
||||||
|
data-mode="standard"
|
||||||
|
onclick="selectMode(this, 'standard')"
|
||||||
|
>
|
||||||
|
⚙️ Standard Worker
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="pill-btn mode-pill"
|
||||||
|
data-mode="custom"
|
||||||
|
onclick="selectMode(this, 'custom')"
|
||||||
|
>
|
||||||
|
🎛️ Custom Scopes
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<input type="hidden" id="delegateMode" value="read" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Granular Scope Matrix */}
|
||||||
|
<div
|
||||||
|
id="customScopeMatrix"
|
||||||
|
style="display: none; margin-bottom: 1.5rem; background: var(--surface-muted); padding: 1rem; border-radius: var(--radius-sm); border: 1px solid var(--border-subtle);"
|
||||||
|
>
|
||||||
|
<label style="display: block; font-weight: 600; margin-bottom: 0.5rem; font-size: 0.85rem; color: var(--text-primary);">
|
||||||
|
Select Allowed Custom Scopes:
|
||||||
|
</label>
|
||||||
|
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 0.5rem;">
|
||||||
|
<label style="display: flex; align-items: center; gap: 0.4rem; font-size: 0.8rem; cursor: pointer;">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
class="scope-checkbox"
|
||||||
|
value="read:events"
|
||||||
|
/>
|
||||||
|
<span>read:events</span>
|
||||||
|
</label>
|
||||||
|
<label style="display: flex; align-items: center; gap: 0.4rem; font-size: 0.8rem; cursor: pointer;">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
class="scope-checkbox"
|
||||||
|
value="write:events"
|
||||||
|
/>
|
||||||
|
<span>write:events</span>
|
||||||
|
</label>
|
||||||
|
<label style="display: flex; align-items: center; gap: 0.4rem; font-size: 0.8rem; cursor: pointer;">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
class="scope-checkbox"
|
||||||
|
value="read:sessions"
|
||||||
|
/>
|
||||||
|
<span>read:sessions</span>
|
||||||
|
</label>
|
||||||
|
<label style="display: flex; align-items: center; gap: 0.4rem; font-size: 0.8rem; cursor: pointer;">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
class="scope-checkbox"
|
||||||
|
value="write:sessions"
|
||||||
|
/>
|
||||||
|
<span>write:sessions</span>
|
||||||
|
</label>
|
||||||
|
{isAdmin && (
|
||||||
|
<label style="display: flex; align-items: center; gap: 0.4rem; font-size: 0.8rem; cursor: pointer; color: var(--danger-text);">
|
||||||
|
<input type="checkbox" class="scope-checkbox" value="admin" />
|
||||||
|
<span>admin (Full Control)</span>
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="display: flex; gap: 0.75rem;">
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
id="submitDelegateBtn"
|
||||||
|
class="btn-primary"
|
||||||
|
style="min-height: 42px;"
|
||||||
|
>
|
||||||
|
Mint & Delegate Session
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn-outline"
|
||||||
|
onclick="closeDelegateDrawer()"
|
||||||
|
style="min-height: 42px;"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{/* Hand-Off Card */}
|
||||||
|
<div
|
||||||
|
id="handoffModal"
|
||||||
|
style="display: none; margin-top: 1.5rem; padding: 1.25rem; background: var(--surface-card); border: 1px solid var(--primary); border-radius: var(--radius-md); box-shadow: var(--shadow-md);"
|
||||||
|
>
|
||||||
|
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 0.75rem;">
|
||||||
|
<div style="display: flex; align-items: center; gap: 0.5rem;">
|
||||||
|
<span style="font-size: 1.2rem;">🔑</span>
|
||||||
|
<strong style="color: var(--text-primary); font-size: 1rem;">
|
||||||
|
Session Delegated Successfully!
|
||||||
|
</strong>
|
||||||
|
</div>
|
||||||
|
<span class="badge badge-success">Active Now</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p style="font-size: 0.85rem; color: var(--text-secondary); margin: 0 0 1rem 0;">
|
||||||
|
Copy this token, 1-click magic link, or CLI export string. You can
|
||||||
|
revoke or extend this session anytime from the active session cards
|
||||||
|
below.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div style="display: flex; flex-direction: column; gap: 0.75rem; margin-bottom: 1rem;">
|
||||||
|
{/* 1-Click Magic Link */}
|
||||||
|
<div>
|
||||||
|
<label style="display: block; font-size: 0.75rem; font-weight: 700; text-transform: uppercase; color: var(--text-muted); margin-bottom: 0.25rem;">
|
||||||
|
1-Click Magic Link (Web / Friend / Interview)
|
||||||
|
</label>
|
||||||
|
<div style="display: flex; gap: 0.5rem;">
|
||||||
|
<code
|
||||||
|
id="handoffMagicLinkText"
|
||||||
|
style="flex: 1; padding: 0.5rem 0.75rem; background: var(--surface-muted); border: 1px solid var(--border-subtle); border-radius: var(--radius-sm); font-family: monospace; font-size: 0.85rem; overflow-x: auto; white-space: nowrap; color: var(--success);"
|
||||||
|
>
|
||||||
|
</code>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn-primary"
|
||||||
|
style="min-height: 36px; padding: 0 0.85rem; font-size: 0.8rem;"
|
||||||
|
data-ignore
|
||||||
|
onclick="copyHandoff('magic')"
|
||||||
|
>
|
||||||
|
Copy Link
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 1-Tap Copy CLI */}
|
||||||
|
<div>
|
||||||
|
<label style="display: block; font-size: 0.75rem; font-weight: 700; text-transform: uppercase; color: var(--text-muted); margin-bottom: 0.25rem;">
|
||||||
|
Terminal Environment Export (CLI)
|
||||||
|
</label>
|
||||||
|
<div style="display: flex; gap: 0.5rem;">
|
||||||
|
<code
|
||||||
|
id="handoffCliText"
|
||||||
|
style="flex: 1; padding: 0.5rem 0.75rem; background: var(--surface-muted); border: 1px solid var(--border-subtle); border-radius: var(--radius-sm); font-family: monospace; font-size: 0.85rem; overflow-x: auto; white-space: nowrap; color: var(--text-primary);"
|
||||||
|
>
|
||||||
|
</code>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn-outline"
|
||||||
|
style="min-height: 36px; padding: 0 0.85rem; font-size: 0.8rem;"
|
||||||
|
data-ignore
|
||||||
|
onclick="copyHandoff('cli')"
|
||||||
|
>
|
||||||
|
Copy CLI
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 1-Tap Copy cURL Header */}
|
||||||
|
<div>
|
||||||
|
<label style="display: block; font-size: 0.75rem; font-weight: 700; text-transform: uppercase; color: var(--text-muted); margin-bottom: 0.25rem;">
|
||||||
|
HTTP Authorization Header
|
||||||
|
</label>
|
||||||
|
<div style="display: flex; gap: 0.5rem;">
|
||||||
|
<code
|
||||||
|
id="handoffCurlText"
|
||||||
|
style="flex: 1; padding: 0.5rem 0.75rem; background: var(--surface-muted); border: 1px solid var(--border-subtle); border-radius: var(--radius-sm); font-family: monospace; font-size: 0.85rem; overflow-x: auto; white-space: nowrap; color: var(--text-primary);"
|
||||||
|
>
|
||||||
|
</code>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn-outline"
|
||||||
|
style="min-height: 36px; padding: 0 0.85rem; font-size: 0.8rem;"
|
||||||
|
data-ignore
|
||||||
|
onclick="copyHandoff('curl')"
|
||||||
|
>
|
||||||
|
Copy Header
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn-outline"
|
||||||
|
style="width: 100%; min-height: 38px; justify-content: center; font-size: 0.85rem;"
|
||||||
|
onclick="closeHandoffModal()"
|
||||||
|
>
|
||||||
|
Done (Session is Active)
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
4
src/features/sessions/fragments.tsx
Normal file
4
src/features/sessions/fragments.tsx
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
export { SessionTableFragment } from "./table_fragments.tsx";
|
||||||
|
export { DirectPassDrawerFragment } from "./drawer_fragments.tsx";
|
||||||
|
export { ScopeModalFragment } from "./modal_fragments.tsx";
|
||||||
|
export { SessionDeckFragment } from "./deck_fragments.tsx";
|
||||||
86
src/features/sessions/modal_fragments.tsx
Normal file
86
src/features/sessions/modal_fragments.tsx
Normal file
@ -0,0 +1,86 @@
|
|||||||
|
export const ScopeModalFragment = ({ apps = [] }: { apps?: any[] }) => {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
id="editScopesModal"
|
||||||
|
style="display: none; position: fixed; top: 0; left: 0; width: 100vw; height: 100vh; background: rgba(0,0,0,0.6); z-index: 9999; justify-content: center; align-items: center;"
|
||||||
|
>
|
||||||
|
<div style="background: var(--surface-card); border: 1px solid var(--border-subtle); border-radius: var(--radius-md); width: 90%; max-width: 500px; padding: 1.5rem; box-shadow: var(--shadow-lg);">
|
||||||
|
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 1rem;">
|
||||||
|
<h3 style="margin: 0; font-size: 1.1rem; color: var(--text-primary);">
|
||||||
|
Update Scopes for:{" "}
|
||||||
|
<span id="editScopesLabel" style="color: var(--primary);"></span>
|
||||||
|
</h3>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick="closeEditScopesModal()"
|
||||||
|
style="background: none; border: none; font-size: 1.2rem; cursor: pointer; color: var(--text-muted);"
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<input type="hidden" id="editScopesSessionId" value="" />
|
||||||
|
|
||||||
|
<div style="display: flex; flex-direction: column; gap: 0.5rem; margin-bottom: 1.25rem;">
|
||||||
|
<label style="display: flex; align-items: center; gap: 0.5rem; font-size: 0.85rem; color: var(--text-primary); cursor: pointer;">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
id="scope_read_audit"
|
||||||
|
class="edit-scope-chk"
|
||||||
|
value="read:audit"
|
||||||
|
/>
|
||||||
|
Read Audit Ledger
|
||||||
|
</label>
|
||||||
|
<label style="display: flex; align-items: center; gap: 0.5rem; font-size: 0.85rem; color: var(--text-primary); cursor: pointer;">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
id="scope_read_users"
|
||||||
|
class="edit-scope-chk"
|
||||||
|
value="read:users"
|
||||||
|
/>
|
||||||
|
Read Users List
|
||||||
|
</label>
|
||||||
|
<label style="display: flex; align-items: center; gap: 0.5rem; font-size: 0.85rem; color: var(--text-primary); cursor: pointer;">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
id="scope_read_apps"
|
||||||
|
class="edit-scope-chk"
|
||||||
|
value="read:apps"
|
||||||
|
/>
|
||||||
|
Read Apps Registry
|
||||||
|
</label>
|
||||||
|
{apps.map((app) => (
|
||||||
|
<label
|
||||||
|
key={app.id}
|
||||||
|
style="display: flex; align-items: center; gap: 0.5rem; font-size: 0.85rem; color: var(--text-primary); cursor: pointer;"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
class="edit-scope-chk"
|
||||||
|
value={`app:${app.name}`}
|
||||||
|
/>
|
||||||
|
Access {app.name}
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="display: flex; justify-content: flex-end; gap: 0.5rem;">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn-outline"
|
||||||
|
onclick="closeEditScopesModal()"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn-primary"
|
||||||
|
onclick="saveUpdatedScopes()"
|
||||||
|
>
|
||||||
|
Save Scopes
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
92
src/features/sessions/queries.ts
Normal file
92
src/features/sessions/queries.ts
Normal file
@ -0,0 +1,92 @@
|
|||||||
|
import { sqlWrapper } from "../../core/db.ts";
|
||||||
|
|
||||||
|
export async function getActiveSessions(userId: string) {
|
||||||
|
const sessions = await sqlWrapper.sql`
|
||||||
|
SELECT id, label, is_agent, custom_scopes, last_activity_at, last_activity_action, created_at, expires_at
|
||||||
|
FROM sessions
|
||||||
|
WHERE user_id = ${userId} AND expires_at > NOW()
|
||||||
|
ORDER BY created_at DESC
|
||||||
|
`;
|
||||||
|
return sessions;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function delegateSession(
|
||||||
|
sessionId: string,
|
||||||
|
userId: string,
|
||||||
|
label: string,
|
||||||
|
customScopes: string[],
|
||||||
|
expiresAt: Date,
|
||||||
|
) {
|
||||||
|
await sqlWrapper.sql`
|
||||||
|
INSERT INTO sessions (id, user_id, label, is_agent, custom_scopes, expires_at, created_at)
|
||||||
|
VALUES (${sessionId}, ${userId}, ${label}, true, ${customScopes}, ${expiresAt.toISOString()}, NOW())
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getSessionByIdAndUserId(
|
||||||
|
sessionId: string,
|
||||||
|
userId: string,
|
||||||
|
) {
|
||||||
|
const session = await sqlWrapper.sql`
|
||||||
|
SELECT id, is_agent, expires_at FROM sessions WHERE id = ${sessionId} AND user_id = ${userId}
|
||||||
|
`;
|
||||||
|
if (!session || session.length === 0) return null;
|
||||||
|
return session[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateSessionScopes(
|
||||||
|
sessionId: string,
|
||||||
|
customScopes: string[],
|
||||||
|
) {
|
||||||
|
await sqlWrapper.sql`
|
||||||
|
UPDATE sessions SET custom_scopes = ${customScopes} WHERE id = ${sessionId}
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getSessionById(sessionId: string) {
|
||||||
|
const session = await sqlWrapper.sql`
|
||||||
|
SELECT id FROM sessions WHERE id = ${sessionId}
|
||||||
|
`;
|
||||||
|
if (!session || session.length === 0) return null;
|
||||||
|
return session[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function setSessionPaused(sessionId: string, isPaused: boolean) {
|
||||||
|
await sqlWrapper.sql`
|
||||||
|
UPDATE sessions SET is_paused = ${isPaused} WHERE id = ${sessionId}
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function extendSessionExpiry(sessionId: string, newExpiry: Date) {
|
||||||
|
await sqlWrapper.sql`
|
||||||
|
UPDATE sessions SET expires_at = ${newExpiry.toISOString()} WHERE id = ${sessionId}
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function revokeSession(sessionId: string) {
|
||||||
|
await sqlWrapper.sql`DELETE FROM sessions WHERE id = ${sessionId}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getSessionWithOwnershipCheck(
|
||||||
|
sessionId: string,
|
||||||
|
userId: string,
|
||||||
|
isAdmin: boolean,
|
||||||
|
) {
|
||||||
|
const session = await sqlWrapper.sql`
|
||||||
|
SELECT s.id
|
||||||
|
FROM sessions s
|
||||||
|
JOIN users u ON s.user_id = u.id
|
||||||
|
WHERE s.id = ${sessionId}
|
||||||
|
AND (
|
||||||
|
s.user_id = ${userId}
|
||||||
|
OR ${isAdmin}
|
||||||
|
OR EXISTS (
|
||||||
|
SELECT 1 FROM event_passes ep
|
||||||
|
WHERE ep.created_by = ${userId}
|
||||||
|
AND u.event_pass_id = ep.id
|
||||||
|
)
|
||||||
|
)
|
||||||
|
`;
|
||||||
|
if (!session || session.length === 0) return null;
|
||||||
|
return session[0];
|
||||||
|
}
|
||||||
170
src/features/sessions/routes.tsx
Normal file
170
src/features/sessions/routes.tsx
Normal file
@ -0,0 +1,170 @@
|
|||||||
|
import { Hono } from "jsr:@hono/hono@4";
|
||||||
|
|
||||||
|
import { streamDatastar } from "../../core/sse_adapter.ts";
|
||||||
|
import { renderErrorToastFragment } from "../../core/error_fragments.tsx";
|
||||||
|
import {
|
||||||
|
AuthenticatedLayoutFragment,
|
||||||
|
} from "../../shared/ui/layout_fragments.tsx";
|
||||||
|
import {
|
||||||
|
getAuthenticatedUser,
|
||||||
|
hasScope,
|
||||||
|
} from "../../../server/auth-session.ts";
|
||||||
|
import { getAllApps } from "../admin/queries.ts";
|
||||||
|
|
||||||
|
import { getActiveSessions } from "./queries.ts";
|
||||||
|
import {
|
||||||
|
DirectPassDrawerFragment,
|
||||||
|
ScopeModalFragment,
|
||||||
|
SessionDeckFragment,
|
||||||
|
SessionTableFragment,
|
||||||
|
} from "./fragments.tsx";
|
||||||
|
import { sessionActionsRoutes } from "./actions_routes.ts";
|
||||||
|
|
||||||
|
export const sessionRoutes = new Hono();
|
||||||
|
|
||||||
|
// Mount Actions sub-router
|
||||||
|
sessionRoutes.route("/", sessionActionsRoutes);
|
||||||
|
|
||||||
|
// ---------------------------------------------------------
|
||||||
|
// UI Dashboard / Sessions Route
|
||||||
|
// ---------------------------------------------------------
|
||||||
|
sessionRoutes.get("/dashboard/sessions", async (c) => {
|
||||||
|
const auth = await getAuthenticatedUser(c);
|
||||||
|
if (!auth) {
|
||||||
|
return c.redirect("/login");
|
||||||
|
}
|
||||||
|
|
||||||
|
const sessions = await getActiveSessions(auth.userId);
|
||||||
|
const apps = await getAllApps();
|
||||||
|
const isAdmin = hasScope(auth, "admin") || hasScope(auth, "*");
|
||||||
|
|
||||||
|
return c.html(
|
||||||
|
<AuthenticatedLayoutFragment
|
||||||
|
title="Active Sessions"
|
||||||
|
currentPath="/dashboard/sessions"
|
||||||
|
isAdmin={isAdmin}
|
||||||
|
>
|
||||||
|
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 1.5rem; flex-wrap: wrap; gap: 1rem;">
|
||||||
|
<div>
|
||||||
|
<h1 style="font-size: 1.75rem; font-weight: 700; margin: 0 0 0.5rem 0; color: var(--text-primary);">
|
||||||
|
Active Sessions & Delegation
|
||||||
|
</h1>
|
||||||
|
<p style="color: var(--text-secondary); margin: 0; font-size: 0.95rem;">
|
||||||
|
Manage authorized devices, mint scoped agent tokens, and oversee
|
||||||
|
live connections.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div style="display: flex; gap: 0.75rem;">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn-primary"
|
||||||
|
onclick="openDelegateDrawer()"
|
||||||
|
style="min-height: 42px; font-size: 0.9rem;"
|
||||||
|
>
|
||||||
|
🔑 Mint Scoped Pass
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="status-banner"></div>
|
||||||
|
|
||||||
|
{/* Desktop Table */}
|
||||||
|
<SessionTableFragment
|
||||||
|
sessions={sessions}
|
||||||
|
currentSessionId={auth.sessionId}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Mobile Deck */}
|
||||||
|
<SessionDeckFragment
|
||||||
|
sessions={sessions}
|
||||||
|
currentSessionId={auth.sessionId}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Delegation Drawer */}
|
||||||
|
<div
|
||||||
|
id="delegateDrawer"
|
||||||
|
class="drawer-overlay"
|
||||||
|
style="display: none; position: fixed; inset: 0; z-index: 1040; background: rgba(0,0,0,0.5); opacity: 0; transition: opacity 0.2s ease;"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="drawer-panel"
|
||||||
|
style="position: fixed; background: var(--surface-card); box-shadow: var(--shadow-lg); z-index: 1050; display: flex; flex-direction: column; transition: transform 0.25s cubic-bezier(0.16, 1, 0.3, 1);"
|
||||||
|
>
|
||||||
|
<div style="padding: 1.5rem; border-bottom: 1px solid var(--border-subtle); display: flex; justify-content: space-between; align-items: center;">
|
||||||
|
<h2 style="margin: 0; font-size: 1.25rem; color: var(--text-primary);">
|
||||||
|
Mint Delegated Session Pass
|
||||||
|
</h2>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick="closeDelegateDrawer()"
|
||||||
|
style="background: none; border: none; font-size: 1.5rem; color: var(--text-muted); cursor: pointer; line-height: 1;"
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div style="flex: 1; overflow-y: auto; padding: 1.5rem;">
|
||||||
|
<DirectPassDrawerFragment apps={apps} isAdmin={isAdmin} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Scope Modal */}
|
||||||
|
<ScopeModalFragment apps={apps} />
|
||||||
|
|
||||||
|
<style>
|
||||||
|
{`
|
||||||
|
@media (min-width: 768px) {
|
||||||
|
.desktop-only { display: block !important; }
|
||||||
|
.mobile-only { display: none !important; }
|
||||||
|
.drawer-panel { top: 0; right: 0; width: 440px; height: 100vh; transform: translateX(100%); }
|
||||||
|
}
|
||||||
|
@media (max-width: 767px) {
|
||||||
|
.desktop-only { display: none !important; }
|
||||||
|
.mobile-only { display: flex !important; }
|
||||||
|
.drawer-panel { bottom: 0; left: 0; width: 100vw; height: 85vh; border-radius: 16px 16px 0 0; transform: translateY(100%); }
|
||||||
|
}
|
||||||
|
.drawer-panel.open { transform: translate(0, 0) !important; }
|
||||||
|
.drawer-overlay.open { display: block !important; opacity: 1 !important; }
|
||||||
|
`}
|
||||||
|
</style>
|
||||||
|
<script src="/public/sessions-scripts.js"></script>
|
||||||
|
</AuthenticatedLayoutFragment>,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------
|
||||||
|
// Live Telemetry / Revocation SSE Stream
|
||||||
|
// ---------------------------------------------------------
|
||||||
|
sessionRoutes.get("/api/sessions/stream", async (c) => {
|
||||||
|
const auth = await getAuthenticatedUser(c);
|
||||||
|
if (!auth) return c.json({ error: "Unauthorized" }, 401);
|
||||||
|
|
||||||
|
return streamDatastar(c, async (stream) => {
|
||||||
|
try {
|
||||||
|
await stream.write({
|
||||||
|
event: "datastar-fragment",
|
||||||
|
data: `<div id="session-telemetry" data-status="connected"></div>`,
|
||||||
|
});
|
||||||
|
|
||||||
|
while (!stream.aborted) {
|
||||||
|
await stream.sleep(15000);
|
||||||
|
if (stream.aborted) break;
|
||||||
|
await stream.write({
|
||||||
|
event: "datastar-signal",
|
||||||
|
data: JSON.stringify({ lastPing: Date.now() }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (err: any) {
|
||||||
|
if (!stream.aborted) {
|
||||||
|
const errorFragment = renderErrorToastFragment(
|
||||||
|
err.message || "Live session stream encountered an error.",
|
||||||
|
);
|
||||||
|
await stream.write({
|
||||||
|
event: "datastar-fragment",
|
||||||
|
data: String(errorFragment),
|
||||||
|
});
|
||||||
|
await stream.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
85
src/features/sessions/sessions.test.tsx
Normal file
85
src/features/sessions/sessions.test.tsx
Normal file
@ -0,0 +1,85 @@
|
|||||||
|
import { assertEquals, assertStringIncludes } from "jsr:@std/assert@1";
|
||||||
|
import { sessionRoutes } from "./routes.tsx";
|
||||||
|
import {
|
||||||
|
DirectPassDrawerFragment,
|
||||||
|
ScopeModalFragment,
|
||||||
|
SessionDeckFragment,
|
||||||
|
SessionTableFragment,
|
||||||
|
} from "./fragments.tsx";
|
||||||
|
|
||||||
|
Deno.test("[Sessions] GET /dashboard/sessions unauthenticated redirects to /login", async () => {
|
||||||
|
const req = new Request("http://localhost/dashboard/sessions");
|
||||||
|
const res = await sessionRoutes.fetch(req);
|
||||||
|
assertEquals(res.status, 302);
|
||||||
|
assertEquals(res.headers.get("location"), "/login");
|
||||||
|
});
|
||||||
|
|
||||||
|
Deno.test("[Sessions] GET /api/sessions unauthenticated returns 401", async () => {
|
||||||
|
const req = new Request("http://localhost/api/sessions");
|
||||||
|
const res = await sessionRoutes.fetch(req);
|
||||||
|
assertEquals(res.status, 401);
|
||||||
|
});
|
||||||
|
|
||||||
|
Deno.test("[Sessions] SessionTableFragment renders empty state and active sessions", () => {
|
||||||
|
const htmlEmpty = (
|
||||||
|
<SessionTableFragment sessions={[]} currentSessionId="sess-1" />
|
||||||
|
);
|
||||||
|
assertStringIncludes(String(htmlEmpty), "No active sessions found.");
|
||||||
|
|
||||||
|
const mockSessions = [
|
||||||
|
{
|
||||||
|
id: "sess-abc-123",
|
||||||
|
label: "CLI Runner",
|
||||||
|
is_agent: true,
|
||||||
|
custom_scopes: ["read:events", "write:events"],
|
||||||
|
created_at: new Date().toISOString(),
|
||||||
|
expires_at: new Date(Date.now() + 3600000).toISOString(),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
const htmlWithData = (
|
||||||
|
<SessionTableFragment
|
||||||
|
sessions={mockSessions}
|
||||||
|
currentSessionId="sess-xyz"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
assertStringIncludes(String(htmlWithData), "CLI Runner");
|
||||||
|
assertStringIncludes(String(htmlWithData), "2 Scopes");
|
||||||
|
});
|
||||||
|
|
||||||
|
Deno.test("[Sessions] SessionDeckFragment renders mobile card deck", () => {
|
||||||
|
const mockSessions = [
|
||||||
|
{
|
||||||
|
id: "sess-mobile-123",
|
||||||
|
label: "Mobile Assistant",
|
||||||
|
is_agent: true,
|
||||||
|
custom_scopes: ["read:audit"],
|
||||||
|
created_at: new Date().toISOString(),
|
||||||
|
expires_at: new Date(Date.now() + 3600000).toISOString(),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
const html = (
|
||||||
|
<SessionDeckFragment
|
||||||
|
sessions={mockSessions}
|
||||||
|
currentSessionId="sess-mobile-123"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
assertStringIncludes(String(html), "Mobile Assistant");
|
||||||
|
assertStringIncludes(String(html), "read:audit");
|
||||||
|
});
|
||||||
|
|
||||||
|
Deno.test("[Sessions] DirectPassDrawerFragment renders options and form", () => {
|
||||||
|
const mockApps = [
|
||||||
|
{ id: "app-1", name: "ed-droid", domain: "ed.atyg.org" },
|
||||||
|
];
|
||||||
|
const html = <DirectPassDrawerFragment apps={mockApps} isAdmin />;
|
||||||
|
assertStringIncludes(String(html), "Mint & Delegate Session");
|
||||||
|
assertStringIncludes(String(html), "ed-droid");
|
||||||
|
assertStringIncludes(String(html), "1-Click Destination App");
|
||||||
|
});
|
||||||
|
|
||||||
|
Deno.test("[Sessions] ScopeModalFragment renders selectable permission matrix", () => {
|
||||||
|
const html = <ScopeModalFragment apps={[]} />;
|
||||||
|
assertStringIncludes(String(html), "Update Scopes for:");
|
||||||
|
assertStringIncludes(String(html), "read:audit");
|
||||||
|
assertStringIncludes(String(html), "read:users");
|
||||||
|
});
|
||||||
155
src/features/sessions/table_fragments.tsx
Normal file
155
src/features/sessions/table_fragments.tsx
Normal file
@ -0,0 +1,155 @@
|
|||||||
|
export const SessionTableFragment = ({
|
||||||
|
sessions,
|
||||||
|
currentSessionId,
|
||||||
|
}: {
|
||||||
|
sessions: any[];
|
||||||
|
currentSessionId: string;
|
||||||
|
}) => {
|
||||||
|
return (
|
||||||
|
<div class="card desktop-only" style="display: none;">
|
||||||
|
<div class="table-container">
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Type & Label</th>
|
||||||
|
<th>Permissions</th>
|
||||||
|
<th>Activity</th>
|
||||||
|
<th>Expires</th>
|
||||||
|
<th>Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{sessions.length === 0
|
||||||
|
? (
|
||||||
|
<tr>
|
||||||
|
<td
|
||||||
|
colSpan={5}
|
||||||
|
style="text-align: center; padding: 2rem; color: var(--text-muted);"
|
||||||
|
>
|
||||||
|
No active sessions found.
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)
|
||||||
|
: (
|
||||||
|
sessions.map((session) => {
|
||||||
|
const isCurrent = session.id === currentSessionId;
|
||||||
|
const isAgent = !!session.is_agent;
|
||||||
|
const scopes = Array.isArray(session.custom_scopes)
|
||||||
|
? session.custom_scopes
|
||||||
|
: [];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<tr key={session.id}>
|
||||||
|
<td>
|
||||||
|
<div style="display: flex; align-items: center; gap: 0.5rem;">
|
||||||
|
<span style="font-size: 1.1rem;">
|
||||||
|
{isAgent ? "🔑" : isCurrent ? "📱" : "💻"}
|
||||||
|
</span>
|
||||||
|
<div>
|
||||||
|
<strong style="color: var(--text-primary);">
|
||||||
|
{session.label ||
|
||||||
|
(isCurrent ? "This Device" : "Remote Device")}
|
||||||
|
</strong>
|
||||||
|
<div style="font-size: 0.75rem; color: var(--text-muted); font-family: monospace;">
|
||||||
|
{session.id.substring(0, 14)}...
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{isAgent
|
||||||
|
? (
|
||||||
|
<span class="badge badge-info">
|
||||||
|
{scopes.length > 0
|
||||||
|
? `${scopes.length} Scopes`
|
||||||
|
: "Delegated"}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
: (
|
||||||
|
<span class="badge badge-success">
|
||||||
|
Interactive
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td style="color: var(--text-secondary); font-size: 0.85rem;">
|
||||||
|
{session.last_activity_action
|
||||||
|
? (
|
||||||
|
<div>
|
||||||
|
<span style="color: var(--primary); font-family: monospace; font-size: 0.8rem;">
|
||||||
|
{session.last_activity_action}
|
||||||
|
</span>
|
||||||
|
<div style="font-size: 0.75rem; color: var(--text-muted);">
|
||||||
|
{new Date(
|
||||||
|
session.last_activity_at ||
|
||||||
|
session.created_at,
|
||||||
|
).toLocaleTimeString()}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
: (
|
||||||
|
<span>
|
||||||
|
{new Date(session.created_at)
|
||||||
|
.toLocaleDateString()}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td style="color: var(--text-secondary); font-size: 0.85rem;">
|
||||||
|
<span
|
||||||
|
class="countdown-pill"
|
||||||
|
data-expires-at={session.expires_at}
|
||||||
|
>
|
||||||
|
⏳ 0h 0m left · (Expires{" "}
|
||||||
|
{new Date(session.expires_at).toLocaleTimeString([], {
|
||||||
|
hour: "2-digit",
|
||||||
|
minute: "2-digit",
|
||||||
|
})})
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<div style="display: flex; gap: 0.35rem; align-items: center;">
|
||||||
|
{isAgent && (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn-outline"
|
||||||
|
style="padding: 0.25rem 0.5rem; font-size: 0.75rem; min-height: 30px;"
|
||||||
|
data-on-click={`@post("/api/sessions/${session.id}/extend")`}
|
||||||
|
title="Extend session by 1 hour"
|
||||||
|
>
|
||||||
|
+1h
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn-outline"
|
||||||
|
style="padding: 0.25rem 0.5rem; font-size: 0.75rem; min-height: 30px;"
|
||||||
|
onclick={`openEditScopesModal('${session.id}', '${
|
||||||
|
session.label || "Delegated"
|
||||||
|
}', ${JSON.stringify(JSON.stringify(scopes))})`}
|
||||||
|
title="Edit permissions"
|
||||||
|
>
|
||||||
|
Scopes
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{!isCurrent && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn-danger revoke-btn"
|
||||||
|
data-on-click={`@delete("/api/sessions/${session.id}")`}
|
||||||
|
style="padding: 0.25rem 0.65rem; font-size: 0.8rem; min-height: 30px;"
|
||||||
|
>
|
||||||
|
Revoke
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -6,16 +6,20 @@ import { contentNegotiation } from "./core/content_negotiation.ts";
|
|||||||
|
|
||||||
import { authRoutes } from "./features/auth/routes.tsx";
|
import { authRoutes } from "./features/auth/routes.tsx";
|
||||||
import { adminRoutes } from "./features/admin/routes.tsx";
|
import { adminRoutes } from "./features/admin/routes.tsx";
|
||||||
|
import { eventsRoutes } from "./features/events/routes.tsx";
|
||||||
|
import { sessionRoutes } from "./features/sessions/routes.tsx";
|
||||||
|
|
||||||
const app: Hono = new Hono();
|
const app: Hono = new Hono();
|
||||||
|
|
||||||
app.use("*", contentNegotiation());
|
app.use("*", contentNegotiation());
|
||||||
|
|
||||||
// Serve static assets (specifically Datastar)
|
// Serve static assets (specifically Datastar and client scripts)
|
||||||
app.use("/public/*", serveStatic({ root: "./" }));
|
app.use("/public/*", serveStatic({ root: "./" }));
|
||||||
|
|
||||||
// Wire Phase 2 Vertical Slices
|
// Wire Feature Slices
|
||||||
app.route("/", authRoutes);
|
app.route("/", authRoutes);
|
||||||
|
app.route("/", eventsRoutes);
|
||||||
|
app.route("/", sessionRoutes);
|
||||||
app.route("/admin", adminRoutes);
|
app.route("/admin", adminRoutes);
|
||||||
app.route("/api/admin", adminRoutes);
|
app.route("/api/admin", adminRoutes);
|
||||||
|
|
||||||
|
|||||||
39
tasks/audits/2026-0827-audit-2-phase-3.md
Normal file
39
tasks/audits/2026-0827-audit-2-phase-3.md
Normal file
@ -0,0 +1,39 @@
|
|||||||
|
# Post-Implementation Audit: Phase 3 (Real-Time Slices & Scripts Removal)
|
||||||
|
|
||||||
|
## 1. Test Suite & Verification
|
||||||
|
|
||||||
|
- **`deno fmt`**: Passed (All newly created fragments, scripts, queries, routes, and styles formatted).
|
||||||
|
- **`deno task lint`**: Passed (`deno lint` and `scripts/lint_arch.ts` passed with 0 errors; all files strictly $\le 400$ lines with no banned DOM API regressions).
|
||||||
|
- **`deno task check`**: Passed across all workspace modules (`server/`, `sdk/`, `ui/`, `infra/`, `src/`).
|
||||||
|
- **`deno test -A --no-check`**: Passed (80 tests across 30 steps with 0 failures).
|
||||||
|
|
||||||
|
## 2. Scope Implemented & Verified
|
||||||
|
|
||||||
|
1. **Events Vertical Slice (`src/features/events/`):**
|
||||||
|
- `cockpit_fragments.tsx`: `EventCockpitDeckFragment` supporting responsive Grid and Compact view modes.
|
||||||
|
- `cockpit_styles.ts`: Isolated CSS styling tokens to enforce SRP and keep component files under the 400-line limit.
|
||||||
|
- `drawer_fragments.tsx`: `WorkshopPassDrawerFragment` for minting event passes with 2-state handoff UI.
|
||||||
|
- `attendees_fragments.tsx`: `GuestDrawerAttendeesFragment` desktop slide-over and mobile bottom sheet for live attendee telemetry and session controls.
|
||||||
|
- `join_fragments.tsx`: `EventJoinPageFragment` for universal PIN and slug redemption.
|
||||||
|
- `queries.ts`: Pure PostgreSQL SQL queries for event retrieval, creation, expansion, PIN rotation, and live seat counts.
|
||||||
|
- `routes.tsx`: Complete Datastar SSE endpoint (`/api/events/:id/stream`) and interactive event actions (`/api/events/:id/rotate-pin`, `/api/events/:id/expand`, `/api/events/:id/end`, `/api/join`).
|
||||||
|
- `events.test.ts`: Verified public join route and error handling fragments.
|
||||||
|
2. **Sessions Vertical Slice (`src/features/sessions/`):**
|
||||||
|
- `table_fragments.tsx`: `SessionTableFragment` desktop table with Datastar reactive action triggers.
|
||||||
|
- `deck_fragments.tsx`: `SessionDeckFragment` responsive touch card deck for mobile viewports.
|
||||||
|
- `drawer_fragments.tsx`: `DirectPassDrawerFragment` for minting scoped child agent passes.
|
||||||
|
- `modal_fragments.tsx`: `ScopeModalFragment` for interactive permissions editing.
|
||||||
|
- `queries.ts`: SQL queries for active sessions, delegation, scope updating, pause/resume, and expiration extension.
|
||||||
|
- `actions_routes.ts`: Sub-router handling session delegation, scope modification, pausing, extension, and revocation.
|
||||||
|
- `routes.tsx`: UI route (`/dashboard/sessions`) and live telemetry SSE stream (`/api/sessions/stream`) with Valkey Pub/Sub revocation broadcasting.
|
||||||
|
- `sessions.test.tsx`: Verified route protection, table, deck, drawer, and modal rendering.
|
||||||
|
3. **Client Assets & Compatibility:**
|
||||||
|
- `public/sessions-scripts.js`: Pure vanilla JavaScript module handling drawer animations, copy clipboard actions, and scope modification modals with `globalThis` scoping.
|
||||||
|
4. **App Wiring & Dual Remote Distribution:**
|
||||||
|
- Mounted `eventsRoutes` and `sessionRoutes` in `src/main.ts`.
|
||||||
|
- Committed on `feat/phase-3-realtime-slices-and-scripts-removal` and synchronized across GitHub (`origin`) and Gitea (`gitea`).
|
||||||
|
- Created GitHub PR #58.
|
||||||
|
|
||||||
|
## 3. Decision
|
||||||
|
|
||||||
|
**Decision: APPROVED & GREEN.** 🟢 Phase 3 is complete, robust, and verified.
|
||||||
Loading…
x
Reference in New Issue
Block a user