auth-yes/scratch/jules_session_13025.patch

1525 lines
64 KiB
Diff

diff --git a/ui/components/AuthenticatedLayout.tsx b/ui/components/AuthenticatedLayout.tsx
index ca464ea..baa3592 100644
--- a/ui/components/AuthenticatedLayout.tsx
+++ b/ui/components/AuthenticatedLayout.tsx
@@ -1,4 +1,7 @@
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 = ({
children,
@@ -265,80 +268,9 @@ export const AuthenticatedLayout = ({
<body>
<div class="app-shell">
{/* Top Header */}
- <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) => {
- 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>
+ <Navbar navItems={navItems} currentPath={currentPath}>
+ <UserMenu isAdmin={isAdmin} />
+ </Navbar>
{/* Main Body */}
<main class="main-content">
@@ -346,24 +278,7 @@ export const AuthenticatedLayout = ({
</main>
{/* Mobile Bottom Navigation Bar */}
- <nav class="mobile-bottom-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>
+ <MobileNav navItems={navItems} currentPath={currentPath} />
</div>
</body>
</html>
diff --git a/ui/components/SessionsPage.tsx b/ui/components/SessionsPage.tsx
index 64a2cf3..4be727b 100644
--- a/ui/components/SessionsPage.tsx
+++ b/ui/components/SessionsPage.tsx
@@ -3,6 +3,9 @@ import { EventCockpitDeck } from "./sessions/EventCockpitDeck.tsx";
import { DirectPassDrawer } from "./sessions/DirectPassDrawer.tsx";
import { WorkshopDrawer } from "./sessions/WorkshopDrawer.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 = ({
sessions,
@@ -96,258 +99,25 @@ export const SessionsPage = ({
</div>
</div>
- <DirectPassDrawer apps={apps} isAdmin={isAdmin} />
+ <DirectPassDrawer />
<WorkshopDrawer apps={apps} />
</div>
<ScopeModal apps={apps} />
{/* 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
- : [];
+ <SessionTable
+ sessions={sessions}
+ currentSessionId={currentSessionId}
- 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>
+ <SessionDeck
+ sessions={sessions}
+ currentSessionId={currentSessionId}
- <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>
{`
@@ -398,356 +168,7 @@ export const SessionsPage = ({
`}
</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>
+ <SessionsScript />
</AuthenticatedLayout>
);
};
diff --git a/ui/components/layout/MobileNav.tsx b/ui/components/layout/MobileNav.tsx
new file mode 100644
index 0000000..5fd0c78
--- /dev/null
+++ b/ui/components/layout/MobileNav.tsx
@@ -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>
+ );
+};
diff --git a/ui/components/layout/Navbar.tsx b/ui/components/layout/Navbar.tsx
new file mode 100644
index 0000000..d708b2a
--- /dev/null
+++ b/ui/components/layout/Navbar.tsx
@@ -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>
+ );
+};
diff --git a/ui/components/layout/UserMenu.tsx b/ui/components/layout/UserMenu.tsx
new file mode 100644
index 0000000..a15d6be
--- /dev/null
+++ b/ui/components/layout/UserMenu.tsx
@@ -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>
+ );
+};
diff --git a/ui/components/sessions/SessionDeck.tsx b/ui/components/sessions/SessionDeck.tsx
new file mode 100644
index 0000000..5552ec4
--- /dev/null
+++ b/ui/components/sessions/SessionDeck.tsx
@@ -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>
+ );
+};
diff --git a/ui/components/sessions/SessionTable.tsx b/ui/components/sessions/SessionTable.tsx
new file mode 100644
index 0000000..c1829c5
--- /dev/null
+++ b/ui/components/sessions/SessionTable.tsx
@@ -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>
+ );
+};
diff --git a/ui/components/sessions/SessionsScript.tsx b/ui/components/sessions/SessionsScript.tsx
new file mode 100644
index 0000000..ae1a8ee
--- /dev/null
+++ b/ui/components/sessions/SessionsScript.tsx
@@ -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>
+ );
+};