- Removed the dual top buttons from SessionsPage.tsx. - Removed the `#eventDrawer` and wrapped its fields, buttons, and modal under `<form id="eventForm">` inside `#tabWorkshopPass` of `#delegateDrawer`. - Wrapped the 1:1 Direct pass form fields, buttons, and modal under `<form id="delegateForm">` inside `#tabDirectPass` of `#delegateDrawer`. - Added a vanilla Javascript tab switching logic for UI interaction without client-side frameworks. - Removed deprecated `openEventDrawer` and `closeEventDrawer`. - Replaced the single main button to trigger `openDelegateDrawer`. Co-authored-by: mrteye <1945243+mrteye@users.noreply.github.com>
1549 lines
66 KiB
TypeScript
1549 lines
66 KiB
TypeScript
import { AuthenticatedLayout } from "./AuthenticatedLayout.tsx";
|
||
|
||
export const SessionsPage = ({
|
||
sessions,
|
||
currentSessionId,
|
||
apps = [],
|
||
isAdmin = false,
|
||
eventPasses = [],
|
||
}: {
|
||
sessions: any[];
|
||
currentSessionId: string;
|
||
apps?: any[];
|
||
isAdmin?: boolean;
|
||
eventPasses?: any[];
|
||
}) => {
|
||
return (
|
||
<AuthenticatedLayout
|
||
title="Sessions & Delegations"
|
||
currentPath="/dashboard/sessions"
|
||
isAdmin={isAdmin}
|
||
>
|
||
<div
|
||
id="status-banner"
|
||
style="display: none; margin-bottom: 1rem; padding: 0.75rem 1rem; border-radius: var(--radius-md); font-size: 0.9rem;"
|
||
/>
|
||
|
||
{eventPasses && eventPasses.length > 0 && (
|
||
<div style="margin-bottom: 2rem;">
|
||
<h2 style="font-size: 1.25rem; font-weight: 700; margin: 0 0 1rem 0; color: var(--text-primary); border-bottom: 1px solid var(--border-subtle); padding-bottom: 0.5rem;">
|
||
Event Cockpit Deck (Live Workshops)
|
||
</h2>
|
||
<div style="display: grid; grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); gap: 1rem;">
|
||
{eventPasses.map((event) => (
|
||
<div
|
||
key={event.id}
|
||
class="card"
|
||
style="border-left: 4px solid var(--primary); margin: 0;"
|
||
>
|
||
<div style="display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: 0.75rem;">
|
||
<div>
|
||
<h3 style="margin: 0 0 0.25rem 0; font-size: 1.1rem; color: var(--text-primary);">
|
||
{event.name}
|
||
</h3>
|
||
<div style="font-size: 0.8rem; color: var(--text-secondary);">
|
||
Expires: {new Date(event.expires_at).toLocaleString()}
|
||
</div>
|
||
</div>
|
||
<span class="badge badge-success">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 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: 60px; 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;">
|
||
{event.pin_code}
|
||
</code>
|
||
<button
|
||
type="button"
|
||
class="btn-outline"
|
||
style="padding: 0.25rem 0.5rem; font-size: 0.75rem; min-height: 28px;"
|
||
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: 60px; color: var(--text-muted);">
|
||
Link:
|
||
</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.75rem; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;">
|
||
/e/{event.slug}
|
||
</code>
|
||
<button
|
||
type="button"
|
||
class="btn-outline"
|
||
style="padding: 0.25rem 0.5rem; font-size: 0.75rem; min-height: 28px;"
|
||
onclick={`copyText(window.location.origin + '/e/${event.slug}')`}
|
||
>
|
||
Copy
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Cockpit Actions */}
|
||
<div style="display: flex; gap: 0.5rem;">
|
||
<button
|
||
type="button"
|
||
class="btn-outline extend-event-btn"
|
||
data-event-id={event.id}
|
||
style="flex: 1; justify-content: center; min-height: 38px; font-size: 0.85rem;"
|
||
>
|
||
+1h Extend
|
||
</button>
|
||
<button
|
||
type="button"
|
||
class="btn-danger end-event-btn"
|
||
data-event-id={event.id}
|
||
style="flex: 1; justify-content: center; min-height: 38px; font-size: 0.85rem;"
|
||
>
|
||
🔴 End & Revoke All
|
||
</button>
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
<div style="display: flex; justify-content: space-between; align-items: flex-start; 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 & Passes
|
||
</h1>
|
||
<p style="color: var(--text-secondary); margin: 0; font-size: 0.95rem;">
|
||
Manage logins, mint 1:1 ephemeral links & CLI tokens, or launch
|
||
multi-claim workshop event passes.
|
||
</p>
|
||
</div>
|
||
|
||
<div style="display: flex; gap: 0.75rem; flex-wrap: wrap;">
|
||
<button
|
||
type="button"
|
||
class="btn-primary"
|
||
style="min-height: 42px; box-shadow: var(--shadow-sm); display: inline-flex; align-items: center; gap: 0.4rem;"
|
||
onclick="openDelegateDrawer()"
|
||
>
|
||
<span style="font-size: 1.1rem;">🔑</span>
|
||
<span>+ Delegate Session</span>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Delegate Session Drawer */}
|
||
<div
|
||
id="delegateDrawer"
|
||
class="card"
|
||
style="display: none; border-left: 4px solid var(--primary); margin-bottom: 1.5rem;"
|
||
>
|
||
<div style="display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: 0.5rem;">
|
||
<div style="width: 100%;">
|
||
<div style="display: flex; justify-content: space-between; align-items: center;">
|
||
<h3 style="margin: 0 0 0.25rem 0; color: var(--text-primary);">
|
||
Create New Pass / Session
|
||
</h3>
|
||
<button
|
||
type="button"
|
||
onclick="closeDelegateDrawer()"
|
||
style="background: none; border: none; font-size: 1.3rem; color: var(--text-muted); cursor: pointer;"
|
||
>
|
||
×
|
||
</button>
|
||
</div>
|
||
|
||
<div style="display: flex; background: var(--surface-muted); padding: 4px; border-radius: var(--radius-sm); border: 1px solid var(--border-subtle); gap: 4px; margin-top: 1rem; margin-bottom: 1rem;">
|
||
<button
|
||
type="button"
|
||
id="tabBtnDirectPass"
|
||
class="delegate-tab-btn active"
|
||
onclick="switchDelegateTab('tabDirectPass')"
|
||
>
|
||
🔑 1:1 Direct Pass
|
||
</button>
|
||
<button
|
||
type="button"
|
||
id="tabBtnWorkshopPass"
|
||
class="delegate-tab-btn"
|
||
onclick="switchDelegateTab('tabWorkshopPass')"
|
||
>
|
||
🎟️ Multi-Claim Workshop Pass
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<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 (App Selection) */}
|
||
<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}>
|
||
{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_only"
|
||
onclick="selectMode(this, 'read_only')"
|
||
>
|
||
👁️ Read-Only (Audit & Inspection)
|
||
</button>
|
||
<button
|
||
type="button"
|
||
class="pill-btn mode-pill"
|
||
data-mode="operator"
|
||
onclick="selectMode(this, 'operator')"
|
||
>
|
||
⚡ Operator (App Actions)
|
||
</button>
|
||
{isAdmin && (
|
||
<button
|
||
type="button"
|
||
class="pill-btn mode-pill"
|
||
data-mode="admin"
|
||
onclick="selectMode(this, 'admin')"
|
||
>
|
||
👑 Full Admin
|
||
</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_only" />
|
||
</div>
|
||
|
||
{/* Progressive Disclosure: Specific App & Capability 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 Specific App Grants & Capabilities (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>
|
||
))}
|
||
</div>
|
||
</div>
|
||
</details>
|
||
|
||
<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 (Revealed upon generation) */}
|
||
<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;"
|
||
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;"
|
||
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;"
|
||
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>
|
||
|
||
<div id="tabWorkshopPass" style="display: none;">
|
||
<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}>
|
||
{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 (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);">
|
||
<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"
|
||
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>
|
||
|
||
{/* Event Launch Success Modal / Card */}
|
||
<div
|
||
id="eventHandoffModal"
|
||
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;">
|
||
<span style="font-size: 1.3rem;">🎟️</span>
|
||
<strong
|
||
id="createdEventTitle"
|
||
style="color: var(--text-primary); font-size: 1.1rem;"
|
||
>
|
||
Workshop Pass Live!
|
||
</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;">
|
||
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-primary"
|
||
style="min-height: 36px; padding: 0 0.85rem; font-size: 0.8rem;"
|
||
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"
|
||
style="min-height: 36px; padding: 0 0.85rem; font-size: 0.8rem;"
|
||
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"
|
||
style="min-height: 36px; padding: 0 0.85rem; font-size: 0.8rem;"
|
||
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()"
|
||
>
|
||
Done & Refresh Cockpit
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Edit Scopes Modal */}
|
||
<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>
|
||
|
||
{/* Desktop Table View (≥ 768px) */}
|
||
<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;">
|
||
{new Date(session.expires_at).toLocaleString()}
|
||
</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;"
|
||
onclick={`extendSession('${session.id}', 1)`}
|
||
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-session-id={session.id}
|
||
style="padding: 0.25rem 0.65rem; font-size: 0.8rem; min-height: 30px;"
|
||
>
|
||
Revoke
|
||
</button>
|
||
)}
|
||
</div>
|
||
</td>
|
||
</tr>
|
||
);
|
||
})
|
||
)}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Mobile Adaptive Cards View (< 768px) */}
|
||
<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>
|
||
|
||
{isAgent
|
||
? <span class="badge badge-info">Delegated</span>
|
||
: isCurrent
|
||
? <span class="badge badge-success">Active Now</span>
|
||
: <span class="badge badge-secondary">Active</span>}
|
||
</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>
|
||
<strong>Expires:</strong>{" "}
|
||
{new Date(session.expires_at).toLocaleString()}
|
||
</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>
|
||
|
||
<div style="display: flex; gap: 0.5rem;">
|
||
{isAgent && (
|
||
<>
|
||
<button
|
||
type="button"
|
||
class="btn-outline"
|
||
style="flex: 1; justify-content: center; min-height: 38px; font-size: 0.8rem;"
|
||
onclick={`extendSession('${session.id}', 1)`}
|
||
>
|
||
+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>
|
||
</>
|
||
)}
|
||
{!isCurrent && (
|
||
<button
|
||
type="button"
|
||
class="btn-danger revoke-btn"
|
||
data-session-id={session.id}
|
||
style="flex: 1; justify-content: center; min-height: 38px; font-size: 0.85rem;"
|
||
>
|
||
Revoke
|
||
</button>
|
||
)}
|
||
</div>
|
||
</div>
|
||
);
|
||
})
|
||
)}
|
||
</div>
|
||
|
||
<style>
|
||
{`
|
||
@media (min-width: 768px) {
|
||
.desktop-only { display: block !important; }
|
||
.mobile-only { display: none !important; }
|
||
}
|
||
@media (max-width: 767px) {
|
||
.desktop-only { display: none !important; }
|
||
.mobile-only { display: flex !important; }
|
||
}
|
||
.pill-btn {
|
||
background: var(--surface-card);
|
||
border: 1px solid var(--border-subtle);
|
||
border-radius: var(--radius-full);
|
||
padding: 0.4rem 0.85rem;
|
||
font-size: 0.85rem;
|
||
font-weight: 600;
|
||
color: var(--text-secondary);
|
||
cursor: pointer;
|
||
transition: all 0.15s ease;
|
||
}
|
||
.pill-btn:hover {
|
||
border-color: var(--primary);
|
||
color: var(--primary);
|
||
}
|
||
.pill-btn.active {
|
||
background: var(--primary-light);
|
||
border-color: var(--primary);
|
||
color: var(--primary);
|
||
}
|
||
.delegate-tab-btn {
|
||
flex: 1;
|
||
padding: 0.5rem 0.75rem;
|
||
border-radius: var(--radius-sm);
|
||
border: none;
|
||
font-weight: 600;
|
||
cursor: pointer;
|
||
transition: all 0.15s;
|
||
background: transparent;
|
||
color: var(--text-secondary);
|
||
}
|
||
.delegate-tab-btn.active {
|
||
background: var(--surface-card);
|
||
color: var(--primary);
|
||
box-shadow: var(--shadow-xs);
|
||
}
|
||
`}
|
||
</style>
|
||
|
||
<script
|
||
dangerouslySetInnerHTML={{
|
||
__html: `
|
||
let lastMintedToken = "";
|
||
|
||
function showNotice(msg, isError) {
|
||
const banner = document.getElementById('status-banner');
|
||
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);
|
||
}
|
||
|
||
function openDelegateDrawer() {
|
||
document.getElementById('delegateDrawer').style.display = 'block';
|
||
document.getElementById('delegateDrawer').scrollIntoView({ behavior: 'smooth' });
|
||
}
|
||
|
||
function closeDelegateDrawer() {
|
||
document.getElementById('delegateDrawer').style.display = 'none';
|
||
}
|
||
|
||
function switchDelegateTab(tabId) {
|
||
document.getElementById('tabDirectPass').style.display = 'none';
|
||
document.getElementById('tabWorkshopPass').style.display = 'none';
|
||
document.getElementById('tabBtnDirectPass').classList.remove('active');
|
||
document.getElementById('tabBtnWorkshopPass').classList.remove('active');
|
||
|
||
document.getElementById(tabId).style.display = 'block';
|
||
if (tabId === 'tabDirectPass') {
|
||
document.getElementById('tabBtnDirectPass').classList.add('active');
|
||
} else {
|
||
document.getElementById('tabBtnWorkshopPass').classList.add('active');
|
||
}
|
||
}
|
||
|
||
function selectSeats(btn, count) {
|
||
document.querySelectorAll('.seat-pill').forEach(b => b.classList.remove('active'));
|
||
btn.classList.add('active');
|
||
document.getElementById('eventMaxSeats').value = count;
|
||
}
|
||
|
||
function selectEventLifespan(btn, hours) {
|
||
document.querySelectorAll('.event-lifespan-pill').forEach(b => b.classList.remove('active'));
|
||
btn.classList.add('active');
|
||
document.getElementById('eventLifespanHours').value = hours;
|
||
}
|
||
|
||
function handleTargetAppChange(appName) {
|
||
if (appName) {
|
||
const scopeVal = 'app:' + appName;
|
||
document.querySelectorAll('.scope-checkbox').forEach(cb => {
|
||
if (cb.value === scopeVal) cb.checked = true;
|
||
});
|
||
}
|
||
}
|
||
|
||
function selectLifespan(btn, hours) {
|
||
document.querySelectorAll('.lifespan-pill').forEach(b => b.classList.remove('active'));
|
||
btn.classList.add('active');
|
||
document.getElementById('delegateHours').value = hours;
|
||
}
|
||
|
||
function selectMode(btn, mode) {
|
||
document.querySelectorAll('.mode-pill').forEach(b => b.classList.remove('active'));
|
||
btn.classList.add('active');
|
||
document.getElementById('delegateMode').value = mode;
|
||
|
||
const accordion = document.getElementById('customScopesSection');
|
||
if (mode === 'custom') {
|
||
accordion.open = true;
|
||
}
|
||
}
|
||
|
||
async function handleDelegateSession(e) {
|
||
e.preventDefault();
|
||
const label = document.getElementById('delegateLabel').value.trim();
|
||
const lifespanHours = parseInt(document.getElementById('delegateHours').value) || 1;
|
||
let mode = document.getElementById('delegateMode').value;
|
||
const targetApp = document.getElementById('delegateTargetApp') ? document.getElementById('delegateTargetApp').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');
|
||
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;
|
||
document.getElementById('handoffMagicLinkText').textContent = window.location.origin + '/pass?token=' + data.token;
|
||
document.getElementById('handoffCliText').textContent = 'export AUTH_YES_TOKEN="' + data.token + '"';
|
||
document.getElementById('handoffCurlText').textContent = '-H "Authorization: Bearer ' + data.token + '"';
|
||
document.getElementById('handoffModal').style.display = 'block';
|
||
showNotice('Delegated session created for: ' + data.label, false);
|
||
} else {
|
||
showNotice(data.error || 'Failed to delegate session', true);
|
||
}
|
||
} catch (err) {
|
||
showNotice('Network error delegating session', true);
|
||
} finally {
|
||
btn.disabled = false;
|
||
btn.textContent = 'Mint & Delegate Session';
|
||
}
|
||
}
|
||
|
||
async function handleCreateEvent(e) {
|
||
e.preventDefault();
|
||
const name = document.getElementById('eventName').value.trim();
|
||
const appId = document.getElementById('eventAppId').value || null;
|
||
const role = document.getElementById('eventRole').value || 'viewer';
|
||
const maxSeats = parseInt(document.getElementById('eventMaxSeats').value) || 0;
|
||
const lifespanHours = parseInt(document.getElementById('eventLifespanHours').value) || 3;
|
||
const slug = document.getElementById('eventSlug').value.trim() || undefined;
|
||
const pinCode = document.getElementById('eventPinCode').value.trim() || undefined;
|
||
|
||
const btn = document.getElementById('submitEventBtn');
|
||
btn.disabled = true;
|
||
btn.textContent = 'Launching...';
|
||
|
||
try {
|
||
const res = await fetch('/api/events', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ name, appId, role, maxSeats, lifespanHours, slug, pinCode }),
|
||
});
|
||
const data = await res.json();
|
||
if (res.ok && data.success) {
|
||
const ev = data.event;
|
||
document.getElementById('createdEventTitle').textContent = '🎟️ ' + ev.name + ' Live!';
|
||
document.getElementById('eventHandoffPin').textContent = ev.pin_code;
|
||
document.getElementById('eventHandoffLink').textContent = window.location.origin + '/e/' + ev.slug;
|
||
document.getElementById('eventHandoffCli').textContent = 'curl -sSL ' + window.location.origin + '/join/' + ev.slug + '?format=env | source /dev/stdin';
|
||
document.getElementById('eventHandoffModal').style.display = 'block';
|
||
showNotice('Workshop pass created: ' + ev.name, false);
|
||
} else {
|
||
showNotice(data.error || 'Failed to create event pass', true);
|
||
}
|
||
} catch (err) {
|
||
showNotice('Network error creating event pass', true);
|
||
} finally {
|
||
btn.disabled = false;
|
||
btn.textContent = '🎟️ Launch Workshop Pass';
|
||
}
|
||
}
|
||
|
||
function copyEventHandoff(type) {
|
||
let text = '';
|
||
if (type === 'pin') text = document.getElementById('eventHandoffPin').textContent;
|
||
if (type === 'link') text = document.getElementById('eventHandoffLink').textContent;
|
||
if (type === 'cli') text = document.getElementById('eventHandoffCli').textContent;
|
||
navigator.clipboard.writeText(text);
|
||
showNotice('Copied to clipboard: ' + text, false);
|
||
}
|
||
|
||
function closeEventHandoffModal() {
|
||
document.getElementById('eventHandoffModal').style.display = 'none';
|
||
window.location.reload();
|
||
}
|
||
|
||
function copyHandoff(type) {
|
||
let text = '';
|
||
if (type === 'link') text = document.getElementById('handoffLinkText').textContent;
|
||
if (type === 'cli') text = document.getElementById('handoffCliText').textContent;
|
||
if (type === 'curl') text = document.getElementById('handoffCurlText').textContent;
|
||
if (type === 'magic') text = document.getElementById('handoffMagicLinkText').textContent;
|
||
navigator.clipboard.writeText(text);
|
||
showNotice('Copied to clipboard: ' + text, false);
|
||
}
|
||
|
||
function closeHandoffModal() {
|
||
document.getElementById('handoffModal').style.display = 'none';
|
||
window.location.reload();
|
||
}
|
||
|
||
async function extendSession(sessionId, hours) {
|
||
try {
|
||
const res = await fetch('/api/sessions/' + sessionId + '/extend', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ extendHours: hours }),
|
||
});
|
||
if (res.ok) {
|
||
showNotice('Session extended by ' + hours + ' hour(s)!', false);
|
||
setTimeout(() => window.location.reload(), 600);
|
||
} else {
|
||
const data = await res.json();
|
||
showNotice(data.error || 'Failed to extend session', true);
|
||
}
|
||
} catch (err) {
|
||
showNotice('Network error extending session', true);
|
||
}
|
||
}
|
||
|
||
function openEditScopesModal(sessionId, label, scopesJson) {
|
||
const scopes = JSON.parse(scopesJson || '[]');
|
||
document.getElementById('editScopesSessionId').value = sessionId;
|
||
document.getElementById('editScopesLabel').textContent = label;
|
||
|
||
document.querySelectorAll('.edit-scope-chk').forEach(cb => {
|
||
cb.checked = scopes.includes(cb.value);
|
||
});
|
||
|
||
document.getElementById('editScopesModal').style.display = 'flex';
|
||
}
|
||
|
||
function closeEditScopesModal() {
|
||
document.getElementById('editScopesModal').style.display = 'none';
|
||
}
|
||
|
||
async function saveUpdatedScopes() {
|
||
const sessionId = document.getElementById('editScopesSessionId').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(() => window.location.reload(), 600);
|
||
} else {
|
||
const data = await res.json();
|
||
showNotice(data.error || 'Failed to update scopes', true);
|
||
}
|
||
} catch (err) {
|
||
showNotice('Network error updating scopes', true);
|
||
}
|
||
}
|
||
|
||
function copyText(text) {
|
||
navigator.clipboard.writeText(text);
|
||
showNotice('Copied to clipboard!', false);
|
||
}
|
||
|
||
// Event Cockpit Actions
|
||
document.querySelectorAll('.extend-event-btn').forEach(btn => {
|
||
btn.addEventListener('click', async (e) => {
|
||
const eventId = e.currentTarget.getAttribute('data-event-id');
|
||
e.currentTarget.disabled = true;
|
||
try {
|
||
const res = await fetch('/api/events/' + eventId + '/extend', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ extendHours: 1 })
|
||
});
|
||
if (res.ok) {
|
||
showNotice('Event extended by 1 hour!', false);
|
||
setTimeout(() => window.location.reload(), 600);
|
||
} else {
|
||
const data = await res.json();
|
||
showNotice(data.error || 'Failed to extend event', true);
|
||
e.currentTarget.disabled = false;
|
||
}
|
||
} catch (err) {
|
||
showNotice('Network error extending event', true);
|
||
e.currentTarget.disabled = false;
|
||
}
|
||
});
|
||
});
|
||
|
||
document.querySelectorAll('.end-event-btn').forEach(btn => {
|
||
btn.addEventListener('click', async (e) => {
|
||
if (!confirm('Are you sure you want to end this workshop? This will immediately revoke ALL active guest sessions and they will lose access.')) return;
|
||
|
||
const eventId = e.currentTarget.getAttribute('data-event-id');
|
||
e.currentTarget.disabled = true;
|
||
e.currentTarget.textContent = 'Revoking...';
|
||
|
||
try {
|
||
const res = await fetch('/api/events/' + eventId + '/end', {
|
||
method: 'POST',
|
||
});
|
||
if (res.ok) {
|
||
showNotice('Workshop ended and all sessions revoked.', false);
|
||
setTimeout(() => window.location.reload(), 600);
|
||
} else {
|
||
const data = await res.json();
|
||
showNotice(data.error || 'Failed to end event', true);
|
||
e.currentTarget.disabled = false;
|
||
e.currentTarget.textContent = '🔴 End & Revoke All';
|
||
}
|
||
} catch (err) {
|
||
showNotice('Network error ending event', true);
|
||
e.currentTarget.disabled = false;
|
||
e.currentTarget.textContent = '🔴 End & Revoke All';
|
||
}
|
||
});
|
||
});
|
||
|
||
// Revoke handler
|
||
document.querySelectorAll('.revoke-btn').forEach(btn => {
|
||
btn.addEventListener('click', async (e) => {
|
||
if (!confirm('Revoke this session immediately?')) return;
|
||
|
||
const sessionId = e.currentTarget.getAttribute('data-session-id');
|
||
const originalText = e.currentTarget.textContent;
|
||
e.currentTarget.textContent = 'Revoking...';
|
||
e.currentTarget.disabled = true;
|
||
|
||
try {
|
||
const res = await fetch('/api/sessions/' + sessionId, {
|
||
method: 'DELETE'
|
||
});
|
||
|
||
if (res.ok) {
|
||
window.location.reload();
|
||
} else {
|
||
const data = await res.json();
|
||
showNotice(data.error || 'Failed to revoke session', true);
|
||
e.currentTarget.textContent = originalText;
|
||
e.currentTarget.disabled = false;
|
||
}
|
||
} catch (err) {
|
||
showNotice('Network error revoking session', true);
|
||
e.currentTarget.textContent = originalText;
|
||
e.currentTarget.disabled = false;
|
||
}
|
||
});
|
||
});
|
||
`,
|
||
}}
|
||
>
|
||
</script>
|
||
</AuthenticatedLayout>
|
||
);
|
||
};
|