fix(ui): complete all items in tasks/checklist.md (sticky docking, floating toasts, rotate PIN, tab locks, expired events, reset actions)

This commit is contained in:
Tyler Gillispie 2026-08-27 22:24:10 -07:00
parent ed9bdb8852
commit b38f122df4
7 changed files with 501 additions and 267 deletions

View File

@ -5,30 +5,74 @@ function showNotice(msg, isError) {
if (!banner) return; if (!banner) return;
banner.textContent = msg; banner.textContent = msg;
banner.style.display = "block"; banner.style.display = "block";
banner.style.position = "fixed";
banner.style.top = "1.25rem";
banner.style.right = "1.25rem";
banner.style.zIndex = "99999";
banner.style.minWidth = "280px";
banner.style.maxWidth = "480px";
banner.style.padding = "0.85rem 1.25rem";
banner.style.borderRadius = "var(--radius-md)";
banner.style.boxShadow = "0 10px 25px -5px rgba(0, 0, 0, 0.35)";
banner.style.fontSize = "0.9rem";
banner.style.fontWeight = "600";
banner.style.background = isError ? "var(--danger-bg)" : "var(--success-bg)"; banner.style.background = isError ? "var(--danger-bg)" : "var(--success-bg)";
banner.style.color = isError ? "var(--danger-text)" : "var(--success-text)"; banner.style.color = isError ? "var(--danger-text)" : "var(--success-text)";
banner.style.border = isError banner.style.border = isError
? "1px solid var(--danger-border)" ? "1px solid var(--danger-border)"
: "1px solid var(--success-border)"; : "1px solid var(--success-border)";
setTimeout(() => { if (banner._timeout) clearTimeout(banner._timeout);
banner._timeout = setTimeout(() => {
banner.style.display = "none"; banner.style.display = "none";
}, 5000); }, 4000);
} }
globalThis.showNotice = showNotice; globalThis.showNotice = showNotice;
function setTabLock(locked) {
const btnDirect = document.getElementById("tabBtnDirectPass");
const btnWorkshop = document.getElementById("tabBtnWorkshopPass");
if (btnDirect) {
btnDirect.disabled = locked;
btnDirect.style.pointerEvents = locked ? "none" : "auto";
btnDirect.style.opacity = locked ? "0.5" : "1";
}
if (btnWorkshop) {
btnWorkshop.disabled = locked;
btnWorkshop.style.pointerEvents = locked ? "none" : "auto";
btnWorkshop.style.opacity = locked ? "0.5" : "1";
}
}
function resetDirectPassForm() {
const form = document.getElementById("delegateForm");
if (form) form.reset();
const createEl = document.getElementById("singleSessionCreateState");
if (createEl) createEl.style.display = "block";
const handoffEl = document.getElementById("handoffModal");
if (handoffEl) handoffEl.style.display = "none";
setTabLock(false);
}
globalThis.resetDirectPassForm = resetDirectPassForm;
function resetWorkshopForm() {
const form = document.getElementById("eventForm");
if (form) form.reset();
const createEl = document.getElementById("eventCreateState");
if (createEl) createEl.style.display = "block";
const handoffEl = document.getElementById("eventHandoffState");
if (handoffEl) handoffEl.style.display = "none";
setTabLock(false);
}
globalThis.resetWorkshopForm = resetWorkshopForm;
function openDelegateDrawer() { function openDelegateDrawer() {
const drawer = document.getElementById("delegateDrawer"); const drawer = document.getElementById("delegateDrawer");
if (!drawer) return; if (!drawer) return;
drawer.style.display = "block"; drawer.style.display = "block";
drawer.scrollIntoView({ behavior: "smooth" }); drawer.scrollIntoView({ behavior: "smooth" });
const eventCreateState = document.getElementById("eventCreateState"); resetDirectPassForm();
if (eventCreateState) eventCreateState.style.display = "block"; resetWorkshopForm();
const eventHandoffState = document.getElementById("eventHandoffState");
if (eventHandoffState) eventHandoffState.style.display = "none";
const eventForm = document.getElementById("eventForm");
if (eventForm) eventForm.reset();
switchDelegateTab("tabDirectPass"); switchDelegateTab("tabDirectPass");
} }
globalThis.openDelegateDrawer = openDelegateDrawer; globalThis.openDelegateDrawer = openDelegateDrawer;
@ -36,6 +80,8 @@ globalThis.openDelegateDrawer = openDelegateDrawer;
function closeDelegateDrawer() { function closeDelegateDrawer() {
const drawer = document.getElementById("delegateDrawer"); const drawer = document.getElementById("delegateDrawer");
if (drawer) drawer.style.display = "none"; if (drawer) drawer.style.display = "none";
resetDirectPassForm();
resetWorkshopForm();
} }
globalThis.closeDelegateDrawer = closeDelegateDrawer; globalThis.closeDelegateDrawer = closeDelegateDrawer;
@ -132,7 +178,7 @@ async function handleDelegateSession(e) {
const hoursInput = document.getElementById("delegateHours"); const hoursInput = document.getElementById("delegateHours");
const lifespanHours = parseInt(hoursInput ? hoursInput.value : "1", 10) || 1; const lifespanHours = parseInt(hoursInput ? hoursInput.value : "1", 10) || 1;
const modeInput = document.getElementById("delegateMode"); const modeInput = document.getElementById("delegateMode");
let mode = modeInput ? modeInput.value : "read_only"; let mode = modeInput ? modeInput.value : "read";
const targetAppInput = document.getElementById("delegateTargetApp"); const targetAppInput = document.getElementById("delegateTargetApp");
const targetApp = targetAppInput ? targetAppInput.value : ""; const targetApp = targetAppInput ? targetAppInput.value : "";
@ -179,8 +225,13 @@ async function handleDelegateSession(e) {
if (curlElem) { if (curlElem) {
curlElem.textContent = '-H "Authorization: Bearer ' + data.token + '"'; curlElem.textContent = '-H "Authorization: Bearer ' + data.token + '"';
} }
const createEl = document.getElementById("singleSessionCreateState");
if (createEl) createEl.style.display = "none";
const handoffModal = document.getElementById("handoffModal"); const handoffModal = document.getElementById("handoffModal");
if (handoffModal) handoffModal.style.display = "block"; if (handoffModal) handoffModal.style.display = "block";
setTabLock(true);
showNotice("Delegated session created for: " + data.label, false); showNotice("Delegated session created for: " + data.label, false);
} else { } else {
showNotice(data.error || "Failed to delegate session", true); showNotice(data.error || "Failed to delegate session", true);
@ -255,6 +306,7 @@ async function handleCreateEvent(e) {
const handoffState = document.getElementById("eventHandoffState"); const handoffState = document.getElementById("eventHandoffState");
if (handoffState) handoffState.style.display = "block"; if (handoffState) handoffState.style.display = "block";
setTabLock(true);
showNotice("Workshop pass created: " + ev.name, false); showNotice("Workshop pass created: " + ev.name, false);
} else { } else {
showNotice(data.error || "Failed to create event pass", true); showNotice(data.error || "Failed to create event pass", true);
@ -319,8 +371,9 @@ function copyHandoff(type) {
globalThis.copyHandoff = copyHandoff; globalThis.copyHandoff = copyHandoff;
function closeHandoffModal() { function closeHandoffModal() {
const modal = document.getElementById("handoffModal"); resetDirectPassForm();
if (modal) modal.style.display = "none"; const drawer = document.getElementById("delegateDrawer");
if (drawer) drawer.style.display = "none";
if (globalThis.location) globalThis.location.reload(); if (globalThis.location) globalThis.location.reload();
} }
globalThis.closeHandoffModal = closeHandoffModal; globalThis.closeHandoffModal = closeHandoffModal;
@ -466,12 +519,12 @@ async function rotatePin(eventId) {
} }
} }
showNotice("Event credentials rotated successfully", false); showNotice("PIN rotated successfully", false);
} else { } else {
showNotice(data.error || "Failed to rotate credentials", true); showNotice(data.error || "Failed to rotate PIN", true);
} }
} catch (_err) { } catch (_err) {
showNotice("Network error rotating credentials", true); showNotice("Network error rotating PIN", true);
} }
} }
globalThis.rotatePin = rotatePin; globalThis.rotatePin = rotatePin;
@ -885,6 +938,31 @@ function bindEventCockpitActions() {
const data = await res.json().catch(() => ({})); const data = await res.json().catch(() => ({}));
if (res.ok && data.success) { if (res.ok && data.success) {
showNotice("Event extended by 1 hour!", false); showNotice("Event extended by 1 hour!", false);
const drawer = document.getElementById("attendeesDrawer");
if (drawer && drawer.classList.contains("open")) {
const countdownElem = document.getElementById(
"guestDrawerCountdown",
);
if (countdownElem && data.event && data.event.expires_at) {
countdownElem.setAttribute(
"data-expires-at",
data.event.expires_at,
);
const expDate = new Date(data.event.expires_at);
const expiresAtElem = document.getElementById(
"guestDrawerExpiresAt",
);
if (expiresAtElem) {
expiresAtElem.textContent = expDate.toLocaleTimeString([], {
hour: "2-digit",
minute: "2-digit",
});
}
updateAllCountdowns();
}
}
setTimeout(() => { setTimeout(() => {
if (globalThis.location) globalThis.location.reload(); if (globalThis.location) globalThis.location.reload();
}, 600); }, 600);
@ -927,12 +1005,12 @@ function bindEventCockpitActions() {
const data = await res.json().catch(() => ({})); const data = await res.json().catch(() => ({}));
showNotice(data.error || "Failed to end event", true); showNotice(data.error || "Failed to end event", true);
e.currentTarget.disabled = false; e.currentTarget.disabled = false;
e.currentTarget.textContent = "🔴 End & Revoke All"; e.currentTarget.textContent = "End Event";
} }
} catch (_err) { } catch (_err) {
showNotice("Network error ending event", true); showNotice("Network error ending event", true);
e.currentTarget.disabled = false; e.currentTarget.disabled = false;
e.currentTarget.textContent = "🔴 End & Revoke All"; e.currentTarget.textContent = "End Event";
} }
}); });
}); });

View File

@ -5,6 +5,14 @@ export const EventCockpitDeckFragment = (
) => { ) => {
if (!eventPasses || eventPasses.length === 0) return null; if (!eventPasses || eventPasses.length === 0) return null;
const now = Date.now();
const activePasses = eventPasses.filter((e) =>
!e.expires_at || new Date(e.expires_at).getTime() > now
);
const expiredPasses = eventPasses.filter((e) =>
e.expires_at && new Date(e.expires_at).getTime() <= now
);
return ( return (
<div style="margin-bottom: 2rem;"> <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;"> <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;">
@ -36,7 +44,7 @@ export const EventCockpitDeckFragment = (
</div> </div>
<div id="eventDeckContainer" class="grid-view"> <div id="eventDeckContainer" class="grid-view">
{eventPasses.map((event) => { {activePasses.map((event) => {
const expDate = new Date(event.expires_at); const expDate = new Date(event.expires_at);
const timeString = expDate.toLocaleTimeString([], { const timeString = expDate.toLocaleTimeString([], {
hour: "2-digit", hour: "2-digit",
@ -192,7 +200,6 @@ export const EventCockpitDeckFragment = (
class="btn-outline" class="btn-outline"
aria-label={`Manage attendees for ${event.name}`} aria-label={`Manage attendees for ${event.name}`}
style="justify-content: center; min-height: 38px; font-size: 0.85rem;" style="justify-content: center; min-height: 38px; font-size: 0.85rem;"
data-on-click={`@get("/api/events/${event.id}/attendees")`}
onclick={`openAttendeesDrawer('${event.id}', '${ onclick={`openAttendeesDrawer('${event.id}', '${
event.name.replace(/'/g, "\\'") event.name.replace(/'/g, "\\'")
}')`} }')`}
@ -204,7 +211,6 @@ export const EventCockpitDeckFragment = (
class="btn-outline" class="btn-outline"
aria-label={`Add 5 seats to ${event.name}`} aria-label={`Add 5 seats to ${event.name}`}
style="justify-content: center; min-height: 38px; font-size: 0.85rem;" 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)`} onclick={`expandSeats('${event.id}', 5)`}
> >
+5 Seats +5 Seats
@ -214,17 +220,15 @@ export const EventCockpitDeckFragment = (
<button <button
type="button" type="button"
class="btn-outline" class="btn-outline"
aria-label={`Rotate Credentials for ${event.name}`} aria-label={`Rotate PIN for ${event.name}`}
style="justify-content: center; min-height: 38px; font-size: 0.82rem; padding: 0 0.25rem;" 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}')`} onclick={`rotatePin('${event.id}')`}
> >
🔄 Rotate Credentials 🔄 Rotate PIN
</button> </button>
<button <button
type="button" type="button"
class="btn-outline extend-event-btn" class="btn-outline extend-event-btn"
data-on-click={`@post("/api/events/${event.id}/extend")`}
data-event-id={event.id} data-event-id={event.id}
aria-label={`Extend lifespan by 1 hour for ${event.name}`} 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;" style="justify-content: center; min-height: 38px; font-size: 0.82rem; padding: 0 0.25rem;"
@ -234,7 +238,6 @@ export const EventCockpitDeckFragment = (
<button <button
type="button" type="button"
class="btn-danger end-event-btn" class="btn-danger end-event-btn"
data-on-click={`@post("/api/events/${event.id}/end")`}
data-event-id={event.id} data-event-id={event.id}
aria-label={`End event ${event.name} and revoke all attendees`} 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;" style="justify-content: center; min-height: 38px; font-size: 0.82rem; padding: 0 0.25rem;"
@ -279,7 +282,6 @@ export const EventCockpitDeckFragment = (
type="button" type="button"
class="btn-outline" class="btn-outline"
style="padding: 0.25rem 0.5rem; font-size: 0.8rem; min-height: 28px;" 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}', '${ onclick={`openAttendeesDrawer('${event.id}', '${
event.name.replace(/'/g, "\\'") event.name.replace(/'/g, "\\'")
}')`} }')`}
@ -289,7 +291,6 @@ export const EventCockpitDeckFragment = (
<button <button
type="button" type="button"
class="btn-outline extend-event-btn" class="btn-outline extend-event-btn"
data-on-click={`@post("/api/events/${event.id}/extend")`}
data-event-id={event.id} data-event-id={event.id}
style="padding: 0.25rem 0.5rem; font-size: 0.8rem; min-height: 28px;" style="padding: 0.25rem 0.5rem; font-size: 0.8rem; min-height: 28px;"
> >
@ -301,6 +302,57 @@ export const EventCockpitDeckFragment = (
); );
})} })}
</div> </div>
{/* Collapsible Expired Events Section */}
{expiredPasses.length > 0 && (
<details style="margin-top: 1.5rem; border: 1px solid var(--border-subtle); border-radius: var(--radius-md); padding: 0.75rem 1rem; background: var(--surface-muted);">
<summary style="font-weight: 600; font-size: 0.95rem; color: var(--text-secondary); cursor: pointer;">
📁 Expired Events ({expiredPasses.length})
</summary>
<div style="margin-top: 1rem; display: flex; flex-direction: column; gap: 0.75rem;">
{expiredPasses.map((event) => (
<div
key={event.id}
class="card"
style="padding: 0.75rem 1rem; margin: 0; display: flex; justify-content: space-between; align-items: center; opacity: 0.85; border-left: 3px solid var(--text-muted); flex-wrap: wrap; gap: 0.75rem;"
>
<div>
<div style="display: flex; align-items: center; gap: 0.5rem; margin-bottom: 0.25rem;">
<strong style="color: var(--text-primary); font-size: 0.95rem;">
{event.name}
</strong>
<span class="badge badge-secondary">Expired</span>
</div>
<div style="font-size: 0.8rem; color: var(--text-secondary);">
Expired {new Date(event.expires_at).toLocaleDateString()} ·
{" "}
{event.seats_claimed} claimed seats
</div>
</div>
<div style="display: flex; gap: 0.5rem; align-items: center;">
<button
type="button"
class="btn-outline extend-event-btn"
data-event-id={event.id}
style="padding: 0.3rem 0.65rem; font-size: 0.8rem; min-height: 32px;"
>
🔄 +1h Reopen
</button>
<button
type="button"
class="btn-danger end-event-btn"
data-event-id={event.id}
style="padding: 0.3rem 0.65rem; font-size: 0.8rem; min-height: 32px;"
>
End Event
</button>
</div>
</div>
))}
</div>
</details>
)}
<style>{EVENT_COCKPIT_CSS}</style> <style>{EVENT_COCKPIT_CSS}</style>
</div> </div>
); );

View File

@ -1,4 +1,8 @@
export const WorkshopPassDrawerFragment = ({ apps }: { apps: any[] }) => { export const WorkshopPassDrawerFragment = ({
apps = [],
}: {
apps?: any[];
}) => {
return ( return (
<div id="tabWorkshopPass" style="display: none;"> <div id="tabWorkshopPass" style="display: none;">
<div id="eventCreateState" style="display: block;"> <div id="eventCreateState" style="display: block;">
@ -146,10 +150,10 @@ export const WorkshopPassDrawerFragment = ({ apps }: { apps: any[] }) => {
<input type="hidden" id="eventLifespanHours" value="3" /> <input type="hidden" id="eventLifespanHours" value="3" />
</div> </div>
{/* Custom Codes Accordion */} {/* Custom Codes Accordion (Optional) */}
<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);"> <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;"> <summary style="font-size: 0.875rem; font-weight: 600; color: var(--text-primary); cursor: pointer;">
Custom Vanity Slug & Custom PIN Code (Optional) Custom Vanity Slug & PIN Code (Optional)
</summary> </summary>
<div style="margin-top: 0.75rem; display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 1rem;"> <div style="margin-top: 0.75rem; display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 1rem;">
<div> <div>
@ -201,7 +205,7 @@ export const WorkshopPassDrawerFragment = ({ apps }: { apps: any[] }) => {
{/* Event Launch Success State */} {/* Event Launch Success State */}
<div <div
id="eventHandoffState" 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);" style="display: none; 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; 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;"> <div style="display: flex; align-items: center; gap: 0.5rem; flex: 1; min-width: 0;">
@ -301,14 +305,24 @@ export const WorkshopPassDrawerFragment = ({ apps }: { apps: any[] }) => {
</div> </div>
</div> </div>
<button <div style="display: flex; gap: 0.75rem;">
type="button" <button
class="btn-outline" type="button"
style="width: 100%; min-height: 38px; justify-content: center; font-size: 0.85rem;" class="btn-primary"
onclick="closeEventHandoffModal()" style="flex: 1; min-height: 38px; justify-content: center; font-size: 0.85rem;"
> onclick="resetWorkshopForm()"
OK >
</button> Create Another
</button>
<button
type="button"
class="btn-outline"
style="flex: 1; min-height: 38px; justify-content: center; font-size: 0.85rem;"
onclick="closeEventHandoffModal()"
>
OK
</button>
</div>
</div> </div>
</div> </div>
); );

View File

@ -7,204 +7,226 @@ export const DirectPassDrawerFragment = ({
}) => { }) => {
return ( return (
<div id="tabDirectPass"> <div id="tabDirectPass">
<p style="color: var(--text-secondary); font-size: 0.9rem; margin: 0 0 1rem 0;"> {/* Creation State */}
Mint an isolated, scoped child session for 1-click magic links, CLI <div id="singleSessionCreateState" style="display: block;">
tools, team members, or AI assistants without exposing your primary <p style="color: var(--text-secondary); font-size: 0.9rem; margin: 0 0 1rem 0;">
passkeys. Mint an isolated, scoped child session for 1-click magic links, CLI
</p> tools, team members, or AI assistants without exposing your primary
passkeys.
</p>
<form <form
id="delegateForm" id="delegateForm"
onsubmit="handleDelegateSession(event)" onsubmit="handleDelegateSession(event)"
style="margin-top: 1rem;" 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);"> {/* Label Input */}
Select Allowed Custom Scopes: <div style="margin-bottom: 1.25rem;">
</label> <label style="display: block; font-weight: 600; margin-bottom: 0.35rem; font-size: 0.875rem; color: var(--text-secondary);">
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 0.5rem;"> Session Label / Purpose *
<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>
<label style="display: flex; align-items: center; gap: 0.4rem; font-size: 0.8rem; cursor: pointer;"> <input
<input type="text"
type="checkbox" id="delegateLabel"
class="scope-checkbox" placeholder="e.g. Friend Demo Pass, Antigravity Assistant, CI/CD Runner, Terminal CLI"
value="write:events" required
/> style="width: 100%;"
<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>
<div style="display: flex; gap: 0.75rem;"> {/* Direct 1-Click Destination */}
<button <div style="margin-bottom: 1.25rem;">
type="submit" <label style="display: block; font-weight: 600; margin-bottom: 0.35rem; font-size: 0.875rem; color: var(--text-secondary);">
id="submitDelegateBtn" 1-Click Destination App (Optional)
class="btn-primary" </label>
style="min-height: 42px;" <select
> id="delegateTargetApp"
Create Session onchange="handleTargetAppChange(this.value)"
</button> 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;"
<button >
type="button" <option value="">Auth-Yes Central Dashboard (Default)</option>
class="btn-outline" {apps.map((app) => (
onclick="closeDelegateDrawer()" <option value={app.name} key={app.id}>
style="min-height: 42px;" {app.name} ({app.domain || "Internal App"})
> </option>
Cancel ))}
</button> </select>
</div> <p style="font-size: 0.75rem; color: var(--text-muted); margin: 0.35rem 0 0 0;">
</form> Selecting an app scopes the pass and automatically redirects the
recipient to that app upon 1-click redemption.
</p>
</div>
{/* Hand-Off Card */} {/* 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>
{/* Progressive Disclosure for Custom App Scopes */}
<details
id="customScopesSection"
style="margin-bottom: 1.25rem; border: 1px solid var(--border-subtle); border-radius: var(--radius-md); padding: 0.75rem 1rem; background: var(--surface-muted);"
>
<summary style="cursor: pointer; font-weight: 600; font-size: 0.875rem; color: var(--text-primary);">
Customize App Permissions & Scopes (Optional)
</summary>
<div style="margin-top: 0.75rem; display: flex; flex-direction: column; gap: 0.5rem;">
<p style="margin: 0 0 0.5rem 0; font-size: 0.8rem; color: var(--text-secondary);">
Select which subsidiary microservices and IAM capabilities this
delegated token is permitted to access:
</p>
<div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 0.5rem;">
<label style="display: flex; align-items: center; gap: 0.5rem; font-size: 0.85rem; color: var(--text-primary); cursor: pointer;">
<input
type="checkbox"
class="scope-checkbox"
value="read:audit"
checked
/>
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"
class="scope-checkbox"
value="read:users"
checked
/>
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"
class="scope-checkbox"
value="read:apps"
checked
/>
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="scope-checkbox"
value={`app:${app.name}`}
/>
{app.name} ({app.domain || "Internal"})
</label>
))}
{isAdmin && (
<label style="display: flex; align-items: center; gap: 0.5rem; font-size: 0.85rem; color: var(--danger-text); cursor: pointer;">
<input
type="checkbox"
class="scope-checkbox"
value="admin"
/>
admin (Full Control)
</label>
)}
</div>
</div>
</details>
<div style="display: flex; gap: 0.75rem;">
<button
type="submit"
id="submitDelegateBtn"
class="btn-primary"
style="min-height: 42px;"
>
Create Session
</button>
<button
type="button"
class="btn-outline"
onclick="closeDelegateDrawer()"
style="min-height: 42px;"
>
Cancel
</button>
</div>
</form>
</div>
{/* Hand-Off Card (Revealed upon generation) */}
<div <div
id="handoffModal" 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);" style="display: none; 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; justify-content: space-between; align-items: center; margin-bottom: 0.75rem;">
<div style="display: flex; align-items: center; gap: 0.5rem;"> <div style="display: flex; align-items: center; gap: 0.5rem;">
@ -222,7 +244,7 @@ export const DirectPassDrawerFragment = ({
below. below.
</p> </p>
<div style="display: flex; flex-direction: column; gap: 0.75rem; margin-bottom: 1rem;"> <div style="display: flex; flex-direction: column; gap: 0.75rem; margin-bottom: 1.25rem;">
{/* 1-Click Magic Link */} {/* 1-Click Magic Link */}
<div> <div>
<label style="display: block; font-size: 0.75rem; font-weight: 700; text-transform: uppercase; color: var(--text-muted); margin-bottom: 0.25rem;"> <label style="display: block; font-size: 0.75rem; font-weight: 700; text-transform: uppercase; color: var(--text-muted); margin-bottom: 0.25rem;">
@ -236,7 +258,7 @@ export const DirectPassDrawerFragment = ({
</code> </code>
<button <button
type="button" type="button"
class="btn-primary" class="btn-outline"
style="min-height: 36px; padding: 0 0.85rem; font-size: 0.8rem;" style="min-height: 36px; padding: 0 0.85rem; font-size: 0.8rem;"
data-ignore data-ignore
onclick="copyHandoff('magic')" onclick="copyHandoff('magic')"
@ -293,14 +315,24 @@ export const DirectPassDrawerFragment = ({
</div> </div>
</div> </div>
<button <div style="display: flex; gap: 0.75rem;">
type="button" <button
class="btn-outline" type="button"
style="width: 100%; min-height: 38px; justify-content: center; font-size: 0.85rem;" class="btn-primary"
onclick="closeHandoffModal()" style="flex: 1; min-height: 38px; justify-content: center; font-size: 0.85rem;"
> onclick="resetDirectPassForm()"
Done (Session is Active) >
</button> Create Another
</button>
<button
type="button"
class="btn-outline"
style="flex: 1; min-height: 38px; justify-content: center; font-size: 0.85rem;"
onclick="closeHandoffModal()"
>
OK
</button>
</div>
</div> </div>
</div> </div>
); );

View File

@ -6,6 +6,10 @@ import {
SessionDeckFragment, SessionDeckFragment,
SessionTableFragment, SessionTableFragment,
} from "./fragments.tsx"; } from "./fragments.tsx";
import {
EventCockpitDeckFragment,
WorkshopPassDrawerFragment,
} from "../events/fragments.tsx";
Deno.test("[Sessions] GET /dashboard unauthenticated redirects to /login", async () => { Deno.test("[Sessions] GET /dashboard unauthenticated redirects to /login", async () => {
const req = new Request("http://localhost/dashboard"); const req = new Request("http://localhost/dashboard");
@ -80,10 +84,64 @@ Deno.test("[Sessions] DirectPassDrawerFragment renders options and form", () =>
]; ];
const html = <DirectPassDrawerFragment apps={mockApps} isAdmin />; const html = <DirectPassDrawerFragment apps={mockApps} isAdmin />;
assertStringIncludes(String(html), "Create Session"); assertStringIncludes(String(html), "Create Session");
assertStringIncludes(String(html), "singleSessionCreateState");
assertStringIncludes(String(html), "handoffModal");
assertStringIncludes(String(html), "Create Another");
assertStringIncludes(String(html), "OK");
assertStringIncludes(
String(html),
"Customize App Permissions &amp; Scopes (Optional)",
);
assertStringIncludes(String(html), "ed-droid"); assertStringIncludes(String(html), "ed-droid");
assertStringIncludes(String(html), "1-Click Destination App"); assertStringIncludes(String(html), "1-Click Destination App");
}); });
Deno.test("[Sessions] WorkshopPassDrawerFragment renders event options and form", () => {
const mockApps = [
{ id: "app-1", name: "ed-droid", domain: "ed.atyg.org" },
];
const html = <WorkshopPassDrawerFragment apps={mockApps} />;
assertStringIncludes(String(html), "Create Event");
assertStringIncludes(String(html), "eventCreateState");
assertStringIncludes(String(html), "eventHandoffState");
assertStringIncludes(String(html), "Create Another");
assertStringIncludes(String(html), "OK");
assertStringIncludes(
String(html),
"Custom Vanity Slug &amp; PIN Code (Optional)",
);
});
Deno.test("[Sessions] EventCockpitDeckFragment handles active and expired events", () => {
const activePass = {
id: "ev-1",
name: "Active Hackathon",
pin_code: "123-456",
slug: "active-hack",
seats_claimed: 5,
max_seats: 50,
expires_at: new Date(Date.now() + 3600000).toISOString(),
};
const expiredPass = {
id: "ev-2",
name: "Old Workshop",
pin_code: "789-012",
slug: "old-workshop",
seats_claimed: 10,
max_seats: 25,
expires_at: new Date(Date.now() - 3600000).toISOString(),
};
const html = (
<EventCockpitDeckFragment eventPasses={[activePass, expiredPass]} />
);
assertStringIncludes(String(html), "Active Hackathon");
assertStringIncludes(String(html), "Rotate PIN");
assertStringIncludes(String(html), "Expired Events (1)");
assertStringIncludes(String(html), "Old Workshop");
assertStringIncludes(String(html), "+1h Reopen");
});
Deno.test("[Sessions] ScopeModalFragment renders selectable permission matrix", () => { Deno.test("[Sessions] ScopeModalFragment renders selectable permission matrix", () => {
const html = <ScopeModalFragment apps={[]} />; const html = <ScopeModalFragment apps={[]} />;
assertStringIncludes(String(html), "Update Scopes for:"); assertStringIncludes(String(html), "Update Scopes for:");

View File

@ -34,11 +34,11 @@ export const SessionsPageFragment = ({
id="status-banner" id="status-banner"
role="status" role="status"
aria-live="polite" aria-live="polite"
style="display: none; margin-bottom: 1rem; padding: 0.75rem 1rem; border-radius: var(--radius-md); font-size: 0.9rem;" style="display: none; position: fixed; top: 1.25rem; right: 1.25rem; z-index: 99999; min-width: 280px; max-width: 480px; padding: 0.85rem 1.25rem; border-radius: var(--radius-md); box-shadow: 0 10px 25px -5px rgba(0,0,0,0.35); font-size: 0.9rem; font-weight: 600;"
/> />
{/* Top Action Bar with Sticky Positioning */} {/* Top Action Bar with Sticky Docking directly beneath main navbar (top: 57px) */}
<div style="position: sticky; top: 0; z-index: 40; background: var(--surface-bg, #0f172a); padding: 0.75rem 0; margin-bottom: 1.5rem; display: flex; justify-content: space-between; align-items: center; flex-wrap: wrap; gap: 1rem; border-bottom: 1px solid var(--border-subtle);"> <div style="position: sticky; top: 57px; z-index: 30; background: var(--surface-canvas); padding: 0.85rem 0; margin-bottom: 1.5rem; display: flex; justify-content: space-between; align-items: center; flex-wrap: wrap; gap: 1rem; border-bottom: 1px solid var(--border-subtle);">
<div> <div>
<h1 style="font-size: 1.75rem; font-weight: 700; margin: 0 0 0.25rem 0; color: var(--text-primary);"> <h1 style="font-size: 1.75rem; font-weight: 700; margin: 0 0 0.25rem 0; color: var(--text-primary);">
Sessions & Events Sessions & Events

View File

@ -2,77 +2,77 @@
### Sticky Navigation Header ### Sticky Navigation Header
- [ ] `sticky-sub-header-docking`: Dock the sticky `Sessions & Events` - [x] `sticky-sub-header-docking`: Dock the sticky `Sessions & Events`
sub-header directly beneath the main navigation bar so it scrolls smoothly sub-header directly beneath the main navigation bar so it scrolls smoothly
without overlapping or covering top navigation elements. without overlapping or covering top navigation elements.
- [ ] `sticky-sub-header-opaque-bg`: Ensure the sticky sub-header has an opaque - [x] `sticky-sub-header-opaque-bg`: Ensure the sticky sub-header has an opaque
background so underlying cards do not bleed through on scroll. background so underlying cards do not bleed through on scroll.
### Single Session Creation (DirectPassDrawer) ### Single Session Creation (DirectPassDrawer)
- [ ] `single-session-form-hiding`: Hide the single session creation form upon - [x] `single-session-form-hiding`: Hide the single session creation form upon
minting so only the completed handoff card is displayed. minting so only the completed handoff card is displayed.
- [ ] `single-session-cancel-button-removal`: Remove the Cancel button from the - [x] `single-session-cancel-button-removal`: Remove the Cancel button from the
view once a single session has been created. view once a single session has been created.
- [ ] `single-session-copy-button-styling`: Style the 1-Click Link copy button - [x] `single-session-copy-button-styling`: Style the 1-Click Link copy button
using the standard outline style matching other copy actions. using the standard outline style matching other copy actions.
- [ ] `single-session-submit-button-label`: Label the single session drawer - [x] `single-session-submit-button-label`: Label the single session drawer
submit button as "Create Session". submit button as "Create Session".
- [ ] `single-session-handoff-ok-button`: Label the single session handoff - [x] `single-session-handoff-ok-button`: Label the single session handoff
completion button as "OK". completion button as "OK".
### Multi-Claim Event Creation (WorkshopDrawer) ### Multi-Claim Event Creation (WorkshopDrawer)
- [ ] `event-submit-button-label`: Label the multi-claim event drawer submit - [x] `event-submit-button-label`: Label the multi-claim event drawer submit
button as "Create Event". button as "Create Event".
- [ ] `event-handoff-ok-button`: Label the multi-claim event handoff completion - [x] `event-handoff-ok-button`: Label the multi-claim event handoff completion
button as "OK". button as "OK".
### Dropdown Accordion Headers ### Dropdown Accordion Headers
- [ ] `single-session-accordion-arrow-fix`: Eliminate the broken double arrow - [x] `single-session-accordion-arrow-fix`: Eliminate the broken double arrow
(`> >`) from the single session optional settings dropdown header. (`> >`) from the single session optional settings dropdown header.
- [ ] `single-session-accordion-label`: Label the single session optional - [x] `single-session-accordion-label`: Label the single session optional
settings dropdown header as "Customize App Permissions & Scopes settings dropdown header as "Customize App Permissions & Scopes
(Optional)". (Optional)".
- [ ] `workshop-accordion-arrow-fix`: Eliminate the broken double arrow (`> >`) - [x] `workshop-accordion-arrow-fix`: Eliminate the broken double arrow (`> >`)
from the workshop pass optional settings dropdown header. from the workshop pass optional settings dropdown header.
- [ ] `workshop-accordion-label`: Label the workshop pass optional settings - [x] `workshop-accordion-label`: Label the workshop pass optional settings
dropdown header as "Custom Vanity Slug & PIN Code (Optional)". dropdown header as "Custom Vanity Slug & PIN Code (Optional)".
### Rapid Creation & Tab State ### Rapid Creation & Tab State
- [ ] `single-session-create-another-button`: Add a "Create Another" button on - [x] `single-session-create-another-button`: Add a "Create Another" button on
the single session handoff card that clears the form and starts a new the single session handoff card that clears the form and starts a new
session in one click. session in one click.
- [ ] `event-create-another-button`: Add a "Create Another" button on the event - [x] `event-create-another-button`: Add a "Create Another" button on the event
handoff card that clears the form and starts a new event in one click. handoff card that clears the form and starts a new event in one click.
- [ ] `handoff-tab-lock-state`: Disable switching between Single Session and - [x] `handoff-tab-lock-state`: Disable switching between Single Session and
Multi-Claim Event tabs while viewing a completed handoff card. Multi-Claim Event tabs while viewing a completed handoff card.
- [ ] `handoff-tab-unlock-on-reset`: Re-enable tab switching when the user - [x] `handoff-tab-unlock-on-reset`: Re-enable tab switching when the user
clicks "Create Another" or closes the drawer. clicks "Create Another" or closes the drawer.
### Expired Events Management ### Expired Events Management
- [ ] `expired-events-query-split`: Separate event passes into active events and - [x] `expired-events-query-split`: Separate event passes into active events and
expired events based on their expiration timestamp and status. expired events based on their expiration timestamp and status.
- [ ] `expired-events-collapsible-accordion`: Organize expired events into a - [x] `expired-events-collapsible-accordion`: Organize expired events into a
dedicated collapsible `<details>` section titled "📁 Expired Events (N)" dedicated collapsible `<details>` section titled "📁 Expired Events (N)"
below active events. below active events.
- [ ] `expired-events-card-styling`: Display expired event passes with distinct - [x] `expired-events-card-styling`: Display expired event passes with distinct
muted styling and expired status badges. muted styling and expired status badges.
- [ ] `expired-events-1click-reopen`: Provide a one-click "+1h Reopen" button on - [x] `expired-events-1click-reopen`: Provide a one-click "+1h Reopen" button on
naturally expired events to add time and reactivate them. naturally expired events to add time and reactivate them.
### Guest Drawer Telemetry & Sync ### Guest Drawer Telemetry & Sync
- [ ] `guest-drawer-telemetry-backend`: Fetch the last activity action, last - [x] `guest-drawer-telemetry-backend`: Fetch the last activity action, last
activity timestamp, and agent flag for each attendee. activity timestamp, and agent flag for each attendee.
- [ ] `guest-drawer-last-action-display`: Display the attendee's last recorded - [x] `guest-drawer-last-action-display`: Display the attendee's last recorded
action and relative time on their guest card. action and relative time on their guest card.
- [ ] `guest-drawer-client-badge`: Display a client icon (such as Web or CLI) on - [x] `guest-drawer-client-badge`: Display a client icon (such as Web or CLI) on
each attendee card based on their recorded action. each attendee card based on their recorded action.
- [ ] `guest-drawer-timer-sync-on-extend`: Dynamically update the countdown - [x] `guest-drawer-timer-sync-on-extend`: Dynamically update the countdown
timer and expiration time in the open guest drawer when an event is timer and expiration time in the open guest drawer when an event is
extended. extended.