Merge pull request #42 from mrteye/feat-ui-decomposition-phase1-13025745089234711919
Refactor Phase 1: Sessions & Layout Extraction
This commit is contained in:
commit
eb15c9003f
@ -1,4 +1,7 @@
|
|||||||
import { COMMON_CSS } from "./CommonStyles.ts";
|
import { COMMON_CSS } from "./CommonStyles.ts";
|
||||||
|
import { Navbar } from "./layout/Navbar.tsx";
|
||||||
|
import { MobileNav } from "./layout/MobileNav.tsx";
|
||||||
|
import { UserMenu } from "./layout/UserMenu.tsx";
|
||||||
|
|
||||||
export const AuthenticatedLayout = ({
|
export const AuthenticatedLayout = ({
|
||||||
children,
|
children,
|
||||||
@ -265,80 +268,9 @@ export const AuthenticatedLayout = ({
|
|||||||
<body>
|
<body>
|
||||||
<div class="app-shell">
|
<div class="app-shell">
|
||||||
{/* Top Header */}
|
{/* Top Header */}
|
||||||
<header class="top-bar">
|
<Navbar navItems={navItems} currentPath={currentPath}>
|
||||||
<div style="display: flex; align-items: center;">
|
<UserMenu isAdmin={isAdmin} />
|
||||||
<a href="/dashboard" class="brand">
|
</Navbar>
|
||||||
<span class="brand-badge">AY</span>
|
|
||||||
<span>Auth-Yes</span>
|
|
||||||
</a>
|
|
||||||
|
|
||||||
{/* Desktop Nav Links */}
|
|
||||||
<nav class="desktop-nav">
|
|
||||||
{navItems.map((item) => {
|
|
||||||
const isActive = item.href === "/dashboard"
|
|
||||||
? currentPath === "/dashboard"
|
|
||||||
: currentPath.startsWith(item.href) &&
|
|
||||||
item.href !== "/dashboard";
|
|
||||||
|
|
||||||
return (
|
|
||||||
<a
|
|
||||||
href={item.href}
|
|
||||||
class={isActive ? "active" : ""}
|
|
||||||
>
|
|
||||||
<span dangerouslySetInnerHTML={{ __html: item.icon }} />
|
|
||||||
<span>{item.label}</span>
|
|
||||||
</a>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</nav>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="header-actions">
|
|
||||||
{isAdmin && (
|
|
||||||
<a
|
|
||||||
href="/admin/users"
|
|
||||||
class="btn-primary"
|
|
||||||
style="padding: 0.4rem 0.85rem; font-size: 0.85rem; min-height: 36px; height: 36px; box-sizing: border-box; text-decoration: none; display: inline-flex; align-items: center; justify-content: center; gap: 0.35rem;"
|
|
||||||
>
|
|
||||||
<svg
|
|
||||||
width="15"
|
|
||||||
height="15"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
stroke-width="2"
|
|
||||||
stroke-linecap="round"
|
|
||||||
stroke-linejoin="round"
|
|
||||||
>
|
|
||||||
<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z">
|
|
||||||
</path>
|
|
||||||
</svg>
|
|
||||||
<span>Admin Console</span>
|
|
||||||
</a>
|
|
||||||
)}
|
|
||||||
<a
|
|
||||||
href="/logout"
|
|
||||||
class="logout-link"
|
|
||||||
title="Sign out of your session"
|
|
||||||
>
|
|
||||||
<svg
|
|
||||||
width="16"
|
|
||||||
height="16"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
stroke-width="2"
|
|
||||||
stroke-linecap="round"
|
|
||||||
stroke-linejoin="round"
|
|
||||||
>
|
|
||||||
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"></path>
|
|
||||||
<polyline points="16 17 21 12 16 7"></polyline>
|
|
||||||
<line x1="21" x2="9" y1="12" y2="12"></line>
|
|
||||||
</svg>
|
|
||||||
<span>Logout</span>
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
{/* Main Body */}
|
{/* Main Body */}
|
||||||
<main class="main-content">
|
<main class="main-content">
|
||||||
@ -346,24 +278,7 @@ export const AuthenticatedLayout = ({
|
|||||||
</main>
|
</main>
|
||||||
|
|
||||||
{/* Mobile Bottom Navigation Bar */}
|
{/* Mobile Bottom Navigation Bar */}
|
||||||
<nav class="mobile-bottom-nav">
|
<MobileNav navItems={navItems} currentPath={currentPath} />
|
||||||
{navItems.map((item) => {
|
|
||||||
const isActive = item.href === "/dashboard"
|
|
||||||
? currentPath === "/dashboard"
|
|
||||||
: currentPath.startsWith(item.href) &&
|
|
||||||
item.href !== "/dashboard";
|
|
||||||
|
|
||||||
return (
|
|
||||||
<a
|
|
||||||
href={item.href}
|
|
||||||
class={isActive ? "active" : ""}
|
|
||||||
>
|
|
||||||
<span dangerouslySetInnerHTML={{ __html: item.icon }} />
|
|
||||||
<span>{item.label}</span>
|
|
||||||
</a>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</nav>
|
|
||||||
</div>
|
</div>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@ -3,6 +3,9 @@ import { EventCockpitDeck } from "./sessions/EventCockpitDeck.tsx";
|
|||||||
import { DirectPassDrawer } from "./sessions/DirectPassDrawer.tsx";
|
import { DirectPassDrawer } from "./sessions/DirectPassDrawer.tsx";
|
||||||
import { WorkshopDrawer } from "./sessions/WorkshopDrawer.tsx";
|
import { WorkshopDrawer } from "./sessions/WorkshopDrawer.tsx";
|
||||||
import { ScopeModal } from "./sessions/ScopeModal.tsx";
|
import { ScopeModal } from "./sessions/ScopeModal.tsx";
|
||||||
|
import { SessionTable } from "./sessions/SessionTable.tsx";
|
||||||
|
import { SessionDeck } from "./sessions/SessionDeck.tsx";
|
||||||
|
import { SessionsScript } from "./sessions/SessionsScript.tsx";
|
||||||
|
|
||||||
export const SessionsPage = ({
|
export const SessionsPage = ({
|
||||||
sessions,
|
sessions,
|
||||||
@ -103,251 +106,18 @@ export const SessionsPage = ({
|
|||||||
<ScopeModal apps={apps} />
|
<ScopeModal apps={apps} />
|
||||||
|
|
||||||
{/* Desktop Table View (≥ 768px) */}
|
{/* Desktop Table View (≥ 768px) */}
|
||||||
<div class="card desktop-only" style="display: none;">
|
<SessionTable
|
||||||
<div class="table-container">
|
sessions={sessions}
|
||||||
<table>
|
currentSessionId={currentSessionId}
|
||||||
<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) */}
|
{/* Mobile Adaptive Cards View (< 768px) */}
|
||||||
<div
|
<SessionDeck
|
||||||
class="mobile-only"
|
sessions={sessions}
|
||||||
style="display: flex; flex-direction: column; gap: 1rem;"
|
currentSessionId={currentSessionId}
|
||||||
>
|
|
||||||
{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>
|
<style>
|
||||||
{`
|
{`
|
||||||
@ -398,356 +168,7 @@ export const SessionsPage = ({
|
|||||||
`}
|
`}
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
<script
|
<SessionsScript />
|
||||||
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>
|
</AuthenticatedLayout>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
24
ui/components/layout/MobileNav.tsx
Normal file
24
ui/components/layout/MobileNav.tsx
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
export const MobileNav = (
|
||||||
|
{ navItems, currentPath }: { navItems: any[]; currentPath: string },
|
||||||
|
) => {
|
||||||
|
return (
|
||||||
|
<nav class="mobile-bottom-nav">
|
||||||
|
{navItems.map((item: any) => {
|
||||||
|
const isActive = item.href === "/dashboard"
|
||||||
|
? currentPath === "/dashboard"
|
||||||
|
: currentPath.startsWith(item.href) &&
|
||||||
|
item.href !== "/dashboard";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<a
|
||||||
|
href={item.href}
|
||||||
|
class={isActive ? "active" : ""}
|
||||||
|
>
|
||||||
|
<span dangerouslySetInnerHTML={{ __html: item.icon }} />
|
||||||
|
<span>{item.label}</span>
|
||||||
|
</a>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</nav>
|
||||||
|
);
|
||||||
|
};
|
||||||
40
ui/components/layout/Navbar.tsx
Normal file
40
ui/components/layout/Navbar.tsx
Normal file
@ -0,0 +1,40 @@
|
|||||||
|
export const Navbar = (
|
||||||
|
{ navItems, currentPath, children }: {
|
||||||
|
navItems: any[];
|
||||||
|
currentPath: string;
|
||||||
|
children?: any;
|
||||||
|
},
|
||||||
|
) => {
|
||||||
|
return (
|
||||||
|
<header class="top-bar">
|
||||||
|
<div style="display: flex; align-items: center;">
|
||||||
|
<a href="/dashboard" class="brand">
|
||||||
|
<span class="brand-badge">AY</span>
|
||||||
|
<span>Auth-Yes</span>
|
||||||
|
</a>
|
||||||
|
|
||||||
|
{/* Desktop Nav Links */}
|
||||||
|
<nav class="desktop-nav">
|
||||||
|
{navItems.map((item: any) => {
|
||||||
|
const isActive = item.href === "/dashboard"
|
||||||
|
? currentPath === "/dashboard"
|
||||||
|
: currentPath.startsWith(item.href) &&
|
||||||
|
item.href !== "/dashboard";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<a
|
||||||
|
href={item.href}
|
||||||
|
class={isActive ? "active" : ""}
|
||||||
|
>
|
||||||
|
<span dangerouslySetInnerHTML={{ __html: item.icon }} />
|
||||||
|
<span>{item.label}</span>
|
||||||
|
</a>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{children}
|
||||||
|
</header>
|
||||||
|
);
|
||||||
|
};
|
||||||
49
ui/components/layout/UserMenu.tsx
Normal file
49
ui/components/layout/UserMenu.tsx
Normal file
@ -0,0 +1,49 @@
|
|||||||
|
export const UserMenu = ({ isAdmin = false }: { isAdmin?: boolean }) => {
|
||||||
|
return (
|
||||||
|
<div class="header-actions">
|
||||||
|
{isAdmin && (
|
||||||
|
<a
|
||||||
|
href="/admin/users"
|
||||||
|
class="btn-primary"
|
||||||
|
style="padding: 0.4rem 0.85rem; font-size: 0.85rem; min-height: 36px; height: 36px; box-sizing: border-box; text-decoration: none; display: inline-flex; align-items: center; justify-content: center; gap: 0.35rem;"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
width="15"
|
||||||
|
height="15"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
>
|
||||||
|
<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z">
|
||||||
|
</path>
|
||||||
|
</svg>
|
||||||
|
<span>Admin Console</span>
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
<a
|
||||||
|
href="/logout"
|
||||||
|
class="logout-link"
|
||||||
|
title="Sign out of your session"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
width="16"
|
||||||
|
height="16"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
>
|
||||||
|
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"></path>
|
||||||
|
<polyline points="16 17 21 12 16 7"></polyline>
|
||||||
|
<line x1="21" x2="9" y1="12" y2="12"></line>
|
||||||
|
</svg>
|
||||||
|
<span>Logout</span>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
114
ui/components/sessions/SessionDeck.tsx
Normal file
114
ui/components/sessions/SessionDeck.tsx
Normal file
@ -0,0 +1,114 @@
|
|||||||
|
export const SessionDeck = (
|
||||||
|
{ sessions, currentSessionId}: {
|
||||||
|
sessions: any[];
|
||||||
|
currentSessionId: string;
|
||||||
|
},
|
||||||
|
) => {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
class="mobile-only"
|
||||||
|
style="display: flex; flex-direction: column; gap: 1rem;"
|
||||||
|
>
|
||||||
|
{sessions.length === 0
|
||||||
|
? (
|
||||||
|
<div class="card" style="text-align: center; padding: 2rem;">
|
||||||
|
<p style="color: var(--text-muted); margin: 0;">
|
||||||
|
No active sessions found.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
: (
|
||||||
|
sessions.map((session) => {
|
||||||
|
const isCurrent = session.id === currentSessionId;
|
||||||
|
const isAgent = !!session.is_agent;
|
||||||
|
const scopes = Array.isArray(session.custom_scopes)
|
||||||
|
? session.custom_scopes
|
||||||
|
: [];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div class="card" key={session.id} style="margin-bottom: 0;">
|
||||||
|
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 0.75rem;">
|
||||||
|
<div style="display: flex; align-items: center; gap: 0.65rem;">
|
||||||
|
<div style="display: flex; align-items: center; justify-content: center; width: 38px; height: 38px; background: var(--surface-muted); border-radius: var(--radius-md); font-size: 1.2rem;">
|
||||||
|
{isAgent ? "🔑" : isCurrent ? "📱" : "💻"}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div style="font-weight: 700; font-size: 0.95rem; color: var(--text-primary);">
|
||||||
|
{session.label ||
|
||||||
|
(isCurrent ? "This Device" : "Remote Device")}
|
||||||
|
</div>
|
||||||
|
<div style="font-size: 0.75rem; color: var(--text-muted); font-family: monospace;">
|
||||||
|
{session.id.substring(0, 14)}...
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{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>
|
||||||
|
);
|
||||||
|
};
|
||||||
145
ui/components/sessions/SessionTable.tsx
Normal file
145
ui/components/sessions/SessionTable.tsx
Normal file
@ -0,0 +1,145 @@
|
|||||||
|
export const SessionTable = (
|
||||||
|
{ sessions, currentSessionId}: {
|
||||||
|
sessions: any[];
|
||||||
|
currentSessionId: string;
|
||||||
|
},
|
||||||
|
) => {
|
||||||
|
return (
|
||||||
|
<div class="card desktop-only" style="display: none;">
|
||||||
|
<div class="table-container">
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Type & Label</th>
|
||||||
|
<th>Permissions</th>
|
||||||
|
<th>Activity</th>
|
||||||
|
<th>Expires</th>
|
||||||
|
<th>Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{sessions.length === 0
|
||||||
|
? (
|
||||||
|
<tr>
|
||||||
|
<td
|
||||||
|
colSpan={5}
|
||||||
|
style="text-align: center; padding: 2rem; color: var(--text-muted);"
|
||||||
|
>
|
||||||
|
No active sessions found.
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)
|
||||||
|
: (
|
||||||
|
sessions.map((session) => {
|
||||||
|
const isCurrent = session.id === currentSessionId;
|
||||||
|
const isAgent = !!session.is_agent;
|
||||||
|
const scopes = Array.isArray(session.custom_scopes)
|
||||||
|
? session.custom_scopes
|
||||||
|
: [];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<tr key={session.id}>
|
||||||
|
<td>
|
||||||
|
<div style="display: flex; align-items: center; gap: 0.5rem;">
|
||||||
|
<span style="font-size: 1.1rem;">
|
||||||
|
{isAgent ? "🔑" : isCurrent ? "📱" : "💻"}
|
||||||
|
</span>
|
||||||
|
<div>
|
||||||
|
<strong style="color: var(--text-primary);">
|
||||||
|
{session.label ||
|
||||||
|
(isCurrent ? "This Device" : "Remote Device")}
|
||||||
|
</strong>
|
||||||
|
<div style="font-size: 0.75rem; color: var(--text-muted); font-family: monospace;">
|
||||||
|
{session.id.substring(0, 14)}...
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{isAgent
|
||||||
|
? (
|
||||||
|
<span class="badge badge-info">
|
||||||
|
{scopes.length > 0
|
||||||
|
? `${scopes.length} Scopes`
|
||||||
|
: "Delegated"}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
: (
|
||||||
|
<span class="badge badge-success">
|
||||||
|
Interactive
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td style="color: var(--text-secondary); font-size: 0.85rem;">
|
||||||
|
{session.last_activity_action
|
||||||
|
? (
|
||||||
|
<div>
|
||||||
|
<span style="color: var(--primary); font-family: monospace; font-size: 0.8rem;">
|
||||||
|
{session.last_activity_action}
|
||||||
|
</span>
|
||||||
|
<div style="font-size: 0.75rem; color: var(--text-muted);">
|
||||||
|
{new Date(
|
||||||
|
session.last_activity_at ||
|
||||||
|
session.created_at,
|
||||||
|
).toLocaleTimeString()}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
: (
|
||||||
|
<span>
|
||||||
|
{new Date(session.created_at)
|
||||||
|
.toLocaleDateString()}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td style="color: var(--text-secondary); font-size: 0.85rem;">
|
||||||
|
{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>
|
||||||
|
);
|
||||||
|
};
|
||||||
354
ui/components/sessions/SessionsScript.tsx
Normal file
354
ui/components/sessions/SessionsScript.tsx
Normal file
@ -0,0 +1,354 @@
|
|||||||
|
export const SessionsScript = () => {
|
||||||
|
return (
|
||||||
|
<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>
|
||||||
|
);
|
||||||
|
};
|
||||||
Loading…
x
Reference in New Issue
Block a user