feat(sessions): implement agent session delegation with scoped permissions and instant handoff

This commit is contained in:
Tyler Gillispie 2026-08-24 23:46:44 -07:00
parent 80cab8454e
commit 5f88733fc6
8 changed files with 1169 additions and 100 deletions

View File

@ -7,6 +7,9 @@ export interface AuthenticatedUser {
userId: string; userId: string;
sessionId: string; sessionId: string;
username: string; username: string;
label?: string;
isAgent?: boolean;
customScopes?: string[];
} }
export interface AppRecord { export interface AppRecord {
@ -39,14 +42,13 @@ export function getCookieDomain(customRpId?: string): string | undefined {
} }
/** /**
* Helper to get authenticated user from session cookie. * Helper to get authenticated user from session cookie or Authorization header.
* Checks Valkey cache first, with automatic PostgreSQL sessions table fallback. * Checks Valkey cache first, with automatic PostgreSQL sessions table fallback.
* Iterates through all session_id cookies to prevent Android Chrome cookie shadowing. * Iterates through all session_id cookies to prevent Android Chrome cookie shadowing.
*/ */
/** /**
* Extracts all session_id tokens from the Cookie header. * Extracts all session_id tokens from the Cookie and Authorization headers.
* Necessary because Chromium Android can send both a host-only and a wildcard cookie simultaneously.
*/ */
export function extractAllSessionIds(c: Context): string[] { export function extractAllSessionIds(c: Context): string[] {
const candidates: string[] = []; const candidates: string[] = [];
@ -89,14 +91,16 @@ export async function getAuthenticatedUser(
const sessionData = JSON.parse(sessionDataStr); const sessionData = JSON.parse(sessionDataStr);
if (sessionData && sessionData.uuid) { if (sessionData && sessionData.uuid) {
if (i > 0) { if (i > 0) {
// A stale ghost cookie was ahead of this valid one.
// Attempt to purge the host-only cookie to heal the browser jar.
deleteCookie(c, "session_id", { path: "/" }); deleteCookie(c, "session_id", { path: "/" });
} }
return { return {
userId: sessionData.uuid, userId: sessionData.uuid,
sessionId: candidateId, sessionId: candidateId,
username: sessionData.username || "", username: sessionData.username || "",
label: sessionData.label,
isAgent: sessionData.isAgent,
customScopes: sessionData.customScopes,
}; };
} }
} }
@ -108,7 +112,7 @@ export async function getAuthenticatedUser(
try { try {
const nowIso = new Date().toISOString(); const nowIso = new Date().toISOString();
const session = await sqlWrapper.sql` const session = await sqlWrapper.sql`
SELECT s.user_id, s.expires_at, u.username SELECT s.user_id, s.expires_at, s.label, s.is_agent, s.custom_scopes, u.username
FROM sessions s FROM sessions s
JOIN users u ON s.user_id = u.id JOIN users u ON s.user_id = u.id
WHERE s.id = ${candidateId} AND s.expires_at > ${nowIso} WHERE s.id = ${candidateId} AND s.expires_at > ${nowIso}
@ -127,14 +131,28 @@ export async function getAuthenticatedUser(
await valkey.setex( await valkey.setex(
candidateId, candidateId,
ttlSeconds, ttlSeconds,
JSON.stringify({ uuid: session.user_id, username }), JSON.stringify({
uuid: session.user_id,
username,
label: session.label,
isAgent: session.is_agent,
customScopes: session.custom_scopes,
}),
); );
} catch (_e) {} } catch (_e) {}
if (i > 0) { if (i > 0) {
deleteCookie(c, "session_id", { path: "/" }); deleteCookie(c, "session_id", { path: "/" });
} }
return { userId: session.user_id, sessionId: candidateId, username };
return {
userId: session.user_id,
sessionId: candidateId,
username,
label: session.label,
isAgent: session.is_agent,
customScopes: session.custom_scopes,
};
} }
} catch (_err) { } catch (_err) {
// Continue to next candidate // Continue to next candidate

View File

@ -240,11 +240,26 @@ export async function initDb(): Promise<void> {
CREATE TABLE IF NOT EXISTS sessions ( CREATE TABLE IF NOT EXISTS sessions (
id TEXT PRIMARY KEY, id TEXT PRIMARY KEY,
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
label TEXT,
is_agent BOOLEAN DEFAULT FALSE,
custom_scopes TEXT[],
last_activity_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
last_activity_action TEXT,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
expires_at TIMESTAMP WITH TIME ZONE NOT NULL expires_at TIMESTAMP WITH TIME ZONE NOT NULL
); );
`; `;
try {
await sql`ALTER TABLE sessions ADD COLUMN IF NOT EXISTS label TEXT`;
await sql`ALTER TABLE sessions ADD COLUMN IF NOT EXISTS is_agent BOOLEAN DEFAULT FALSE`;
await sql`ALTER TABLE sessions ADD COLUMN IF NOT EXISTS custom_scopes TEXT[]`;
await sql`ALTER TABLE sessions ADD COLUMN IF NOT EXISTS last_activity_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()`;
await sql`ALTER TABLE sessions ADD COLUMN IF NOT EXISTS last_activity_action TEXT`;
} catch {
// Ignore migration column exists
}
await sql` await sql`
CREATE TABLE IF NOT EXISTS hwk_keys ( CREATE TABLE IF NOT EXISTS hwk_keys (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(), id UUID PRIMARY KEY DEFAULT gen_random_uuid(),

View File

@ -554,15 +554,14 @@ Deno.test("App Launchpad Zero-Knowledge Query Logic", async (t) => {
); );
await t.step("GET /dashboard renders for admin", async () => { await t.step("GET /dashboard renders for admin", async () => {
const mockGet = (key: string) => { const valkeyStub = stub(valkey, "get", (key: any) => {
if (key === "valid_session") { if (String(key) === "valid_session") {
return Promise.resolve( return Promise.resolve(
JSON.stringify({ uuid: "admin-uuid", username: "admin" }), JSON.stringify({ uuid: "admin-uuid", username: "admin" }),
); );
} }
return Promise.resolve(null); return Promise.resolve(null);
}; });
valkey.get = mockGet as any;
const originalSql = sqlWrapper.sql; const originalSql = sqlWrapper.sql;
sqlWrapper.sql = (strings: any, ..._values: any[]) => { sqlWrapper.sql = (strings: any, ..._values: any[]) => {
@ -605,19 +604,19 @@ Deno.test("App Launchpad Zero-Knowledge Query Logic", async (t) => {
assert(text.includes("Admin Console")); // Layout link assert(text.includes("Admin Console")); // Layout link
} finally { } finally {
sqlWrapper.sql = originalSql; sqlWrapper.sql = originalSql;
valkeyStub.restore();
} }
}); });
await t.step("GET /dashboard renders for regular user", async () => { await t.step("GET /dashboard renders for regular user", async () => {
const mockGet = (key: string) => { const valkeyStub = stub(valkey, "get", (key: any) => {
if (key === "valid_session") { if (String(key) === "valid_session") {
return Promise.resolve( return Promise.resolve(
JSON.stringify({ uuid: "user-uuid", username: "user" }), JSON.stringify({ uuid: "user-uuid", username: "user" }),
); );
} }
return Promise.resolve(null); return Promise.resolve(null);
}; });
valkey.get = mockGet as any;
const originalSql = sqlWrapper.sql; const originalSql = sqlWrapper.sql;
sqlWrapper.sql = (strings: any, ..._values: any[]) => { sqlWrapper.sql = (strings: any, ..._values: any[]) => {
@ -656,6 +655,7 @@ Deno.test("App Launchpad Zero-Knowledge Query Logic", async (t) => {
assert(!text.includes("Admin Console")); // Layout link should be missing assert(!text.includes("Admin Console")); // Layout link should be missing
} finally { } finally {
sqlWrapper.sql = originalSql; sqlWrapper.sql = originalSql;
valkeyStub.restore();
} }
}); });
}); });
@ -664,15 +664,14 @@ Deno.test("Cookie Shadowing & Multi-Cookie Iteration", async (t) => {
await t.step( await t.step(
"GET /dashboard authenticates when fresh session is shadowed by stale host-only cookie", "GET /dashboard authenticates when fresh session is shadowed by stale host-only cookie",
async () => { async () => {
const mockGet = (key: string) => { const valkeyStub = stub(valkey, "get", (key: any) => {
if (key === "fresh_wildcard_session") { if (String(key) === "fresh_wildcard_session") {
return Promise.resolve( return Promise.resolve(
JSON.stringify({ uuid: "user-uuid", username: "tylerg" }), JSON.stringify({ uuid: "user-uuid", username: "tylerg" }),
); );
} }
return Promise.resolve(null); return Promise.resolve(null);
}; });
valkey.get = mockGet as any;
const originalSql = sqlWrapper.sql; const originalSql = sqlWrapper.sql;
sqlWrapper.sql = () => Promise.resolve([]); sqlWrapper.sql = () => Promise.resolve([]);
@ -699,6 +698,140 @@ Deno.test("Cookie Shadowing & Multi-Cookie Iteration", async (t) => {
); );
} finally { } finally {
sqlWrapper.sql = originalSql; sqlWrapper.sql = originalSql;
valkeyStub.restore();
}
},
);
});
Deno.test("Agent Session Delegation & Scoped Permissions", async (t) => {
await t.step(
"POST /api/sessions/delegate - Mints child session with scopes",
async () => {
const valkeyStub = stub(valkey, "get", (key: any) => {
if (String(key) === "admin-session") {
return Promise.resolve(
JSON.stringify({ uuid: "admin-uuid", username: "tylerg" }),
);
}
return Promise.resolve(null);
});
const valkeySetStub = stub(valkey, "set", () => Promise.resolve("OK"));
const originalSql = sqlWrapper.sql;
sqlWrapper.sql = ((_query: any, ..._args: any[]) => {
return Promise.resolve([{ id: "mock-id" }]);
}) as any;
try {
const res = await app.request("/api/sessions/delegate", {
method: "POST",
headers: {
Authorization: "Bearer admin-session",
"Content-Type": "application/json",
},
body: JSON.stringify({
label: "Antigravity Assistant",
lifespanHours: 12,
mode: "read_only",
}),
});
assertEquals(res.status, 200);
const json = await res.json();
assert(json.success === true);
assert(json.sessionId.startsWith("ay_sess_"));
assertEquals(json.label, "Antigravity Assistant");
assert(json.scopes.includes("read:audit"));
} finally {
sqlWrapper.sql = originalSql;
valkeyStub.restore();
valkeySetStub.restore();
}
},
);
await t.step(
"PUT /api/sessions/:id/scopes - Updates custom scopes",
async () => {
const valkeyStub = stub(valkey, "get", (key: any) => {
if (String(key) === "admin-session") {
return Promise.resolve(
JSON.stringify({ uuid: "admin-uuid", username: "tylerg" }),
);
}
return Promise.resolve(null);
});
const originalSql = sqlWrapper.sql;
sqlWrapper.sql = ((_query: any, ..._args: any[]) => {
return Promise.resolve([{ id: "ay_sess_123", is_agent: true }]);
}) as any;
try {
const res = await app.request("/api/sessions/ay_sess_123/scopes", {
method: "PUT",
headers: {
Authorization: "Bearer admin-session",
"Content-Type": "application/json",
},
body: JSON.stringify({
customScopes: ["read:audit", "app:ed-droid"],
}),
});
assertEquals(res.status, 200);
const json = await res.json();
assert(json.success === true);
assertEquals(json.scopes, ["read:audit", "app:ed-droid"]);
} finally {
sqlWrapper.sql = originalSql;
valkeyStub.restore();
}
},
);
await t.step(
"POST /api/sessions/:id/extend - Extends session TTL",
async () => {
const valkeyStub = stub(valkey, "get", (key: any) => {
if (String(key) === "admin-session") {
return Promise.resolve(
JSON.stringify({ uuid: "admin-uuid", username: "tylerg" }),
);
}
return Promise.resolve(null);
});
const valkeyExpireStub = stub(valkey, "expire", () => Promise.resolve(1));
const originalSql = sqlWrapper.sql;
sqlWrapper.sql = ((_query: any, ..._args: any[]) => {
return Promise.resolve([{
id: "ay_sess_123",
expires_at: new Date().toISOString(),
}]);
}) as any;
try {
const res = await app.request("/api/sessions/ay_sess_123/extend", {
method: "POST",
headers: {
Authorization: "Bearer admin-session",
"Content-Type": "application/json",
},
body: JSON.stringify({
extendHours: 2,
}),
});
assertEquals(res.status, 200);
const json = await res.json();
assert(json.success === true);
assert(json.newExpiresAt);
} finally {
sqlWrapper.sql = originalSql;
valkeyStub.restore();
valkeyExpireStub.restore();
} }
}, },
); );

View File

@ -2019,7 +2019,7 @@ app.get("/api/sessions", async (c) => {
if (!auth) return c.json({ error: "Unauthorized" }, 401); if (!auth) return c.json({ error: "Unauthorized" }, 401);
const sessions = await sqlWrapper.sql` const sessions = await sqlWrapper.sql`
SELECT id, created_at, expires_at SELECT id, label, is_agent, custom_scopes, last_activity_at, last_activity_action, created_at, expires_at
FROM sessions FROM sessions
WHERE user_id = ${auth.userId} AND expires_at > NOW() WHERE user_id = ${auth.userId} AND expires_at > NOW()
ORDER BY created_at DESC ORDER BY created_at DESC
@ -2028,6 +2028,178 @@ app.get("/api/sessions", async (c) => {
return c.json({ sessions, currentSessionId: auth.sessionId }); return c.json({ sessions, currentSessionId: auth.sessionId });
}); });
// Delegate a child agent session with custom lifespan and scopes
app.post("/api/sessions/delegate", async (c) => {
const auth = await getAuthenticatedUser(c);
if (!auth) return c.json({ error: "Unauthorized" }, 401);
const {
label,
lifespanHours = 1,
mode = "read_only",
customScopes = [],
} = await c.req.json();
const cleanLabel = (label && typeof label === "string" && label.trim())
? label.trim()
: "AI Agent Session";
const hours = Math.min(Math.max(Number(lifespanHours) || 1, 1), 720); // Max 30 days
const expiresAt = new Date(Date.now() + hours * 3600 * 1000);
let effectiveScopes: string[] = [];
if (mode === "read_only") {
effectiveScopes = [
"read:audit",
"read:users",
"read:apps",
"read:roles",
"read:sessions",
];
} else if (mode === "operator") {
effectiveScopes = ["operator", "read:audit", "read:users", "read:apps"];
} else if (mode === "admin") {
effectiveScopes = ["*"];
} else if (mode === "custom" && Array.isArray(customScopes)) {
effectiveScopes = customScopes.map((s: string) => String(s).trim()).filter(
Boolean,
);
}
const rawBytes = new Uint8Array(32);
crypto.getRandomValues(rawBytes);
const tokenHex = Array.from(rawBytes).map((b) =>
b.toString(16).padStart(2, "0")
).join("");
const sessionId = `ay_sess_${tokenHex}`;
await sqlWrapper.sql`
INSERT INTO sessions (id, user_id, label, is_agent, custom_scopes, expires_at, created_at)
VALUES (${sessionId}, ${auth.userId}, ${cleanLabel}, true, ${effectiveScopes}, ${expiresAt.toISOString()}, NOW())
`;
try {
const sessionData = {
uuid: auth.userId,
username: auth.username,
label: cleanLabel,
isAgent: true,
customScopes: effectiveScopes,
};
await valkey.set(
sessionId,
JSON.stringify(sessionData),
"EX",
Math.floor(hours * 3600),
);
} catch (err) {
console.error("[Valkey] Failed to cache delegated session:", err);
}
auditWrapper.auditLog(auth.userId, "session_delegated", sessionId, {
label: cleanLabel,
lifespan_hours: hours,
mode,
scopes: effectiveScopes,
}, getClientIp(c));
return c.json({
success: true,
sessionId,
token: sessionId,
label: cleanLabel,
expiresAt: expiresAt.toISOString(),
scopes: effectiveScopes,
});
});
// Update permissions on an active session
app.put("/api/sessions/:id/scopes", async (c) => {
const auth = await getAuthenticatedUser(c);
if (!auth) return c.json({ error: "Unauthorized" }, 401);
const targetSessionId = c.req.param("id");
const { customScopes = [] } = await c.req.json();
const session = await sqlWrapper.sql`
SELECT id, is_agent FROM sessions WHERE id = ${targetSessionId} AND user_id = ${auth.userId}
`.then((res: any) => res[0]);
if (!session) {
return c.json({ error: "Session not found or access denied" }, 404);
}
const effectiveScopes = Array.isArray(customScopes)
? customScopes.map((s: string) => String(s).trim()).filter(Boolean)
: [];
await sqlWrapper.sql`
UPDATE sessions SET custom_scopes = ${effectiveScopes} WHERE id = ${targetSessionId}
`;
try {
const existingCached = await valkey.get(targetSessionId);
if (existingCached) {
const parsed = JSON.parse(existingCached);
parsed.customScopes = effectiveScopes;
await valkey.set(targetSessionId, JSON.stringify(parsed));
}
} catch (_e) {}
auditWrapper.auditLog(
auth.userId,
"session_scopes_updated",
targetSessionId,
{
scopes: effectiveScopes,
},
getClientIp(c),
);
return c.json({ success: true, scopes: effectiveScopes });
});
// Extend session TTL
app.post("/api/sessions/:id/extend", async (c) => {
const auth = await getAuthenticatedUser(c);
if (!auth) return c.json({ error: "Unauthorized" }, 401);
const targetSessionId = c.req.param("id");
const { extendHours = 1 } = await c.req.json();
const additionalHours = Math.max(Number(extendHours) || 1, 1);
const session = await sqlWrapper.sql`
SELECT id, expires_at FROM sessions WHERE id = ${targetSessionId} AND user_id = ${auth.userId}
`.then((res: any) => res[0]);
if (!session) {
return c.json({ error: "Session not found or access denied" }, 404);
}
const currentExpiry = new Date(session.expires_at).getTime();
const newExpiry = new Date(
Math.max(Date.now(), currentExpiry) + additionalHours * 3600 * 1000,
);
await sqlWrapper.sql`
UPDATE sessions SET expires_at = ${newExpiry.toISOString()} WHERE id = ${targetSessionId}
`;
try {
const ttlSeconds = Math.max(
1,
Math.floor((newExpiry.getTime() - Date.now()) / 1000),
);
await valkey.expire(targetSessionId, ttlSeconds);
} catch (_e) {}
auditWrapper.auditLog(auth.userId, "session_extended", targetSessionId, {
extended_by_hours: additionalHours,
new_expires_at: newExpiry.toISOString(),
}, getClientIp(c));
return c.json({ success: true, newExpiresAt: newExpiry.toISOString() });
});
// Revoke a specific session // Revoke a specific session
app.delete("/api/sessions/:id", async (c) => { app.delete("/api/sessions/:id", async (c) => {
const auth = await getAuthenticatedUser(c); const auth = await getAuthenticatedUser(c);

View File

@ -7,7 +7,7 @@ export const valkey = VALKEY_URL
? new Redis(VALKEY_URL, { ? new Redis(VALKEY_URL, {
enableOfflineQueue: false, enableOfflineQueue: false,
}) })
: {} as Redis; // Mock for tests : new Redis({ lazyConnect: true, enableOfflineQueue: false });
export async function pingValkey(): Promise<void> { export async function pingValkey(): Promise<void> {
if (!VALKEY_URL) return; // Skip in test if (!VALKEY_URL) return; // Skip in test

View File

@ -0,0 +1,55 @@
# TASK METADATA
- **Target Files:**
- `server/db.ts`
- `server/main.ts`
- `server/auth-session.ts`
- `ui/components/SessionsPage.tsx`
- `ui/mod.ts`
- `server/main.test.ts`
- **Core Objective:** Implement frictionless Child Session Delegation with Agent
Labeling, Scoped Permissions (read-only, operator, custom app grants), custom
TTLs, and live observability cards.
- **Dependencies:** `server/auth-session.ts`, `server/db.ts`,
`ui/components/SessionsPage.tsx`
- **Additional Important Notes:** Zero-dependency SDK compatibility; instant
Valkey RESP3 push invalidation; progressive disclosure for advanced scope
customization.
---
### 1. Architectural Considerations & Risks
- **Security & Session Isolation:**
- Child sessions must have isolated session IDs so revoking an agent session
does not invalidate the user's primary interactive browser session.
- When custom scopes are defined, `getAuthenticatedUser(c)` or
`AuthMiddleware` verifies that the requested action/app matches the
session's permitted scope list.
- **Progressive Disclosure UX:**
- Default simple presets: Lifespan (`1h`, `12h`, `7d`) and Access Mode
(`Read-Only`, `Operator`, `Full Admin`).
- Optional expandable drawer for granular app-level permissions.
- **Observability:**
- Track `last_activity_at` and `last_activity_action` for real-time visibility
in the session cards.
---
### 2. Proposed Implementation
1. **Database Schema & Migrations (`server/db.ts`):**
- Add `label`, `is_agent`, `custom_scopes`, `last_activity_at`,
`last_activity_action` to `sessions` table.
2. **Backend API Endpoints (`server/main.ts`):**
- `POST /api/sessions/delegate`: Mints a child session with custom label,
lifespan, and scopes.
- `PUT /api/sessions/:id/scopes`: Updates permissions on an active session.
- `POST /api/sessions/:id/extend`: Extends session TTL.
3. **UI Implementation (`ui/components/SessionsPage.tsx`):**
- Add **"Delegate Agent Session"** top action and expandable modal.
- Hand-off card with 1-tap copy for token, CLI export, and cURL header.
- Distinct 🤖 Agent session cards with live countdown, `[Extend +1h]`,
`[Edit Scopes]`, and `[Revoke]`.
4. **Automated Tests (`server/main.test.ts`):**
- Verify delegation, custom scope restrictions, and extension.

View File

@ -3,26 +3,425 @@ import { AuthenticatedLayout } from "./AuthenticatedLayout.tsx";
export const SessionsPage = ({ export const SessionsPage = ({
sessions, sessions,
currentSessionId, currentSessionId,
apps = [],
isAdmin = false, isAdmin = false,
}: { }: {
sessions: any[]; sessions: any[];
currentSessionId: string; currentSessionId: string;
apps?: any[];
isAdmin?: boolean; isAdmin?: boolean;
}) => { }) => {
return ( return (
<AuthenticatedLayout <AuthenticatedLayout
title="Sessions" title="Sessions & Delegations"
currentPath="/dashboard/sessions" currentPath="/dashboard/sessions"
isAdmin={isAdmin} isAdmin={isAdmin}
> >
<div style="margin-bottom: 2rem;"> <div
<h1 style="font-size: 1.75rem; font-weight: 700; margin: 0 0 0.5rem 0; color: var(--text-primary);"> id="status-banner"
Active Sessions style="display: none; margin-bottom: 1rem; padding: 0.75rem 1rem; border-radius: var(--radius-md); font-size: 0.9rem;"
</h1> />
<p style="color: var(--text-secondary); margin: 0; font-size: 0.95rem;">
Manage and revoke authenticated device sessions connected to your <div style="display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: 1.5rem; flex-wrap: wrap; gap: 1rem;">
account. <div>
</p> <h1 style="font-size: 1.75rem; font-weight: 700; margin: 0 0 0.5rem 0; color: var(--text-primary);">
Active Sessions & Delegations
</h1>
<p style="color: var(--text-secondary); margin: 0; font-size: 0.95rem;">
Manage browser logins and spawn isolated sessions for AI agents and
CLI tools.
</p>
</div>
<button
type="button"
id="openDelegateBtn"
class="btn-primary"
style="min-height: 42px; box-shadow: var(--shadow-sm); display: inline-flex; align-items: center; gap: 0.4rem;"
onclick="openDelegateDrawer()"
>
<svg
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2.5"
>
<path d="M12 5v14M5 12h14"></path>
</svg>
<span>Delegate Agent Session</span>
</button>
</div>
{/* Delegate Agent Session Drawer */}
<div
id="delegateDrawer"
class="card"
style="display: none; border-left: 4px solid var(--primary); margin-bottom: 1.5rem;"
>
<div style="display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: 0.5rem;">
<div>
<h3 style="margin: 0 0 0.25rem 0; color: var(--text-primary);">
Spawn Delegated Agent Session
</h3>
<p style="color: var(--text-secondary); font-size: 0.9rem; margin: 0;">
Mint an isolated child session for an AI assistant, daemon, or CLI
without exposing your primary browser credentials.
</p>
</div>
<button
type="button"
onclick="closeDelegateDrawer()"
style="background: none; border: none; font-size: 1.3rem; color: var(--text-muted); cursor: pointer;"
>
&times;
</button>
</div>
<form
id="delegateForm"
onsubmit="handleDelegateSession(event)"
style="margin-top: 1rem;"
>
{/* Label Input */}
<div style="margin-bottom: 1.25rem;">
<label style="display: block; font-weight: 600; margin-bottom: 0.35rem; font-size: 0.875rem; color: var(--text-secondary);">
Agent Label / Purpose *
</label>
<input
type="text"
id="delegateLabel"
placeholder="e.g. Antigravity Coding Assistant, Jules Auto-Refactor, Sync Script"
required
style="width: 100%;"
/>
</div>
{/* Lifespan Presets */}
<div style="margin-bottom: 1.25rem;">
<label style="display: block; font-weight: 600; margin-bottom: 0.45rem; font-size: 0.875rem; color: var(--text-secondary);">
Lifespan & Auto-Expiration
</label>
<div style="display: flex; gap: 0.5rem; flex-wrap: wrap;">
<button
type="button"
class="pill-btn lifespan-pill active"
data-hours="1"
onclick="selectLifespan(this, 1)"
>
1 Hour (Quick Task)
</button>
<button
type="button"
class="pill-btn lifespan-pill"
data-hours="12"
onclick="selectLifespan(this, 12)"
>
🛠 12 Hours (Work Session)
</button>
<button
type="button"
class="pill-btn lifespan-pill"
data-hours="24"
onclick="selectLifespan(this, 24)"
>
📅 24 Hours
</button>
<button
type="button"
class="pill-btn lifespan-pill"
data-hours="168"
onclick="selectLifespan(this, 168)"
>
🗓 7 Days
</button>
</div>
<input type="hidden" id="delegateHours" value="1" />
</div>
{/* Access Mode Presets */}
<div style="margin-bottom: 1.25rem;">
<label style="display: block; font-weight: 600; margin-bottom: 0.45rem; font-size: 0.875rem; color: var(--text-secondary);">
Access Mode & Permissions
</label>
<div style="display: flex; gap: 0.5rem; flex-wrap: wrap;">
<button
type="button"
class="pill-btn mode-pill active"
data-mode="read_only"
onclick="selectMode(this, 'read_only')"
>
👁 Read-Only (Audit & Inspection)
</button>
<button
type="button"
class="pill-btn mode-pill"
data-mode="operator"
onclick="selectMode(this, 'operator')"
>
Operator (App Actions)
</button>
{isAdmin && (
<button
type="button"
class="pill-btn mode-pill"
data-mode="admin"
onclick="selectMode(this, 'admin')"
>
👑 Full Admin
</button>
)}
<button
type="button"
class="pill-btn mode-pill"
data-mode="custom"
onclick="selectMode(this, 'custom')"
>
🛠 Custom Scopes
</button>
</div>
<input type="hidden" id="delegateMode" value="read_only" />
</div>
{/* Progressive Disclosure: Specific App & Capability Scopes */}
<details
id="customScopesSection"
style="margin-bottom: 1.25rem; border: 1px solid var(--border-subtle); border-radius: var(--radius-md); padding: 0.75rem 1rem; background: var(--surface-muted);"
>
<summary style="cursor: pointer; font-weight: 600; font-size: 0.875rem; color: var(--text-primary);">
Customize Specific App Grants & Capabilities (Optional)
</summary>
<div style="margin-top: 0.75rem; display: flex; flex-direction: column; gap: 0.5rem;">
<p style="margin: 0 0 0.5rem 0; font-size: 0.8rem; color: var(--text-secondary);">
Select which subsidiary microservices and IAM capabilities this
agent token is permitted to access:
</p>
<div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 0.5rem;">
<label style="display: flex; align-items: center; gap: 0.5rem; font-size: 0.85rem; color: var(--text-primary); cursor: pointer;">
<input
type="checkbox"
class="scope-checkbox"
value="read:audit"
checked
/>
Read Audit Ledger
</label>
<label style="display: flex; align-items: center; gap: 0.5rem; font-size: 0.85rem; color: var(--text-primary); cursor: pointer;">
<input
type="checkbox"
class="scope-checkbox"
value="read:users"
checked
/>
Read Users List
</label>
<label style="display: flex; align-items: center; gap: 0.5rem; font-size: 0.85rem; color: var(--text-primary); cursor: pointer;">
<input
type="checkbox"
class="scope-checkbox"
value="read:apps"
checked
/>
Read Apps Registry
</label>
{apps.map((app) => (
<label
key={app.id}
style="display: flex; align-items: center; gap: 0.5rem; font-size: 0.85rem; color: var(--text-primary); cursor: pointer;"
>
<input
type="checkbox"
class="scope-checkbox"
value={`app:${app.name}`}
/>
{app.name} ({app.domain || "Internal"})
</label>
))}
</div>
</div>
</details>
<div style="display: flex; gap: 0.75rem;">
<button
type="submit"
id="submitDelegateBtn"
class="btn-primary"
style="min-height: 42px;"
>
Mint & Delegate Session
</button>
<button
type="button"
class="btn-outline"
onclick="closeDelegateDrawer()"
style="min-height: 42px;"
>
Cancel
</button>
</div>
</form>
{/* Hand-Off Card (Revealed upon generation) */}
<div
id="handoffModal"
style="display: none; margin-top: 1.5rem; padding: 1.25rem; background: var(--surface-card); border: 1px solid var(--primary); border-radius: var(--radius-md); box-shadow: var(--shadow-md);"
>
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 0.75rem;">
<div style="display: flex; align-items: center; gap: 0.5rem;">
<span style="font-size: 1.2rem;">🤖</span>
<strong style="color: var(--text-primary); font-size: 1rem;">
Agent Session Delegated Successfully!
</strong>
</div>
<span class="badge badge-success">Active Now</span>
</div>
<p style="font-size: 0.85rem; color: var(--text-secondary); margin: 0 0 1rem 0;">
Copy this token or ready-to-run CLI string. You can revoke or extend
this token anytime from the active session cards below.
</p>
<div style="display: flex; flex-direction: column; gap: 0.75rem; margin-bottom: 1rem;">
{/* 1-Tap Copy CLI */}
<div>
<label style="display: block; font-size: 0.75rem; font-weight: 700; text-transform: uppercase; color: var(--text-muted); margin-bottom: 0.25rem;">
Terminal Environment Export (CLI)
</label>
<div style="display: flex; gap: 0.5rem;">
<code
id="handoffCliText"
style="flex: 1; padding: 0.5rem 0.75rem; background: var(--surface-muted); border: 1px solid var(--border-subtle); border-radius: var(--radius-sm); font-family: monospace; font-size: 0.85rem; overflow-x: auto; white-space: nowrap; color: var(--primary);"
>
</code>
<button
type="button"
class="btn-primary"
style="min-height: 36px; padding: 0 0.85rem; font-size: 0.8rem;"
onclick="copyHandoff('cli')"
>
Copy CLI
</button>
</div>
</div>
{/* 1-Tap Copy cURL Header */}
<div>
<label style="display: block; font-size: 0.75rem; font-weight: 700; text-transform: uppercase; color: var(--text-muted); margin-bottom: 0.25rem;">
HTTP Authorization Header
</label>
<div style="display: flex; gap: 0.5rem;">
<code
id="handoffCurlText"
style="flex: 1; padding: 0.5rem 0.75rem; background: var(--surface-muted); border: 1px solid var(--border-subtle); border-radius: var(--radius-sm); font-family: monospace; font-size: 0.85rem; overflow-x: auto; white-space: nowrap; color: var(--text-primary);"
>
</code>
<button
type="button"
class="btn-outline"
style="min-height: 36px; padding: 0 0.85rem; font-size: 0.8rem;"
onclick="copyHandoff('curl')"
>
Copy Header
</button>
</div>
</div>
</div>
<button
type="button"
class="btn-outline"
style="width: 100%; min-height: 38px; justify-content: center; font-size: 0.85rem;"
onclick="closeHandoffModal()"
>
Done (Session is Active)
</button>
</div>
</div>
{/* Edit Scopes Modal */}
<div
id="editScopesModal"
style="display: none; position: fixed; top: 0; left: 0; width: 100vw; height: 100vh; background: rgba(0,0,0,0.6); z-index: 9999; justify-content: center; align-items: center;"
>
<div style="background: var(--surface-card); border: 1px solid var(--border-subtle); border-radius: var(--radius-md); width: 90%; max-width: 500px; padding: 1.5rem; box-shadow: var(--shadow-lg);">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 1rem;">
<h3 style="margin: 0; font-size: 1.1rem; color: var(--text-primary);">
Update Scopes for:{" "}
<span id="editScopesLabel" style="color: var(--primary);"></span>
</h3>
<button
type="button"
onclick="closeEditScopesModal()"
style="background: none; border: none; font-size: 1.2rem; cursor: pointer; color: var(--text-muted);"
>
&times;
</button>
</div>
<input type="hidden" id="editScopesSessionId" value="" />
<div style="display: flex; flex-direction: column; gap: 0.5rem; margin-bottom: 1.25rem;">
<label style="display: flex; align-items: center; gap: 0.5rem; font-size: 0.85rem; color: var(--text-primary); cursor: pointer;">
<input
type="checkbox"
id="scope_read_audit"
class="edit-scope-chk"
value="read:audit"
/>
Read Audit Ledger
</label>
<label style="display: flex; align-items: center; gap: 0.5rem; font-size: 0.85rem; color: var(--text-primary); cursor: pointer;">
<input
type="checkbox"
id="scope_read_users"
class="edit-scope-chk"
value="read:users"
/>
Read Users List
</label>
<label style="display: flex; align-items: center; gap: 0.5rem; font-size: 0.85rem; color: var(--text-primary); cursor: pointer;">
<input
type="checkbox"
id="scope_read_apps"
class="edit-scope-chk"
value="read:apps"
/>
Read Apps Registry
</label>
{apps.map((app) => (
<label
key={app.id}
style="display: flex; align-items: center; gap: 0.5rem; font-size: 0.85rem; color: var(--text-primary); cursor: pointer;"
>
<input
type="checkbox"
class="edit-scope-chk"
value={`app:${app.name}`}
/>
Access {app.name}
</label>
))}
</div>
<div style="display: flex; justify-content: flex-end; gap: 0.5rem;">
<button
type="button"
class="btn-outline"
onclick="closeEditScopesModal()"
>
Cancel
</button>
<button
type="button"
class="btn-primary"
onclick="saveUpdatedScopes()"
>
Save Scopes
</button>
</div>
</div>
</div> </div>
{/* Desktop Table View (≥ 768px) */} {/* Desktop Table View (≥ 768px) */}
@ -31,10 +430,11 @@ export const SessionsPage = ({
<table> <table>
<thead> <thead>
<tr> <tr>
<th>Status</th> <th>Type & Label</th>
<th>Created At</th> <th>Permissions</th>
<th>Expires At</th> <th>Activity</th>
<th>Action</th> <th>Expires</th>
<th>Actions</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@ -42,7 +442,7 @@ export const SessionsPage = ({
? ( ? (
<tr> <tr>
<td <td
colSpan={4} colSpan={5}
style="text-align: center; padding: 2rem; color: var(--text-muted);" style="text-align: center; padding: 2rem; color: var(--text-muted);"
> >
No active sessions found. No active sessions found.
@ -52,38 +452,108 @@ export const SessionsPage = ({
: ( : (
sessions.map((session) => { sessions.map((session) => {
const isCurrent = session.id === currentSessionId; const isCurrent = session.id === currentSessionId;
const isAgent = !!session.is_agent;
const scopes = Array.isArray(session.custom_scopes)
? session.custom_scopes
: [];
return ( return (
<tr key={session.id}> <tr key={session.id}>
<td> <td>
{isCurrent <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-success"> <span class="badge badge-info">
Current Session {scopes.length > 0
? `${scopes.length} Scopes`
: "Full Access"}
</span> </span>
) )
: ( : (
<span class="badge badge-secondary"> <span class="badge badge-success">
Remote Session Interactive
</span> </span>
)} )}
</td> </td>
<td style="color: var(--text-secondary);"> <td style="color: var(--text-secondary); font-size: 0.85rem;">
{new Date(session.created_at).toLocaleString()} {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>
<td style="color: var(--text-secondary);"> <td style="color: var(--text-secondary); font-size: 0.85rem;">
{new Date(session.expires_at).toLocaleString()} {new Date(session.expires_at).toLocaleString()}
</td> </td>
<td> <td>
{!isCurrent && ( <div style="display: flex; gap: 0.35rem; align-items: center;">
<button {isAgent && (
type="button" <>
class="btn-danger revoke-btn" <button
data-session-id={session.id} type="button"
style="padding: 0.35rem 0.75rem; font-size: 0.85rem; min-height: 32px;" class="btn-outline"
> style="padding: 0.25rem 0.5rem; font-size: 0.75rem; min-height: 30px;"
Revoke onclick={`extendSession('${session.id}', 1)`}
</button> 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 || "Agent"
}', ${
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> </td>
</tr> </tr>
); );
@ -110,69 +580,91 @@ export const SessionsPage = ({
: ( : (
sessions.map((session) => { sessions.map((session) => {
const isCurrent = session.id === currentSessionId; const isCurrent = session.id === currentSessionId;
const isAgent = !!session.is_agent;
const scopes = Array.isArray(session.custom_scopes)
? session.custom_scopes
: [];
return ( return (
<div class="card" key={session.id} style="margin-bottom: 0;"> <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; justify-content: space-between; align-items: center; margin-bottom: 0.75rem;">
<div style="display: flex; align-items: center; gap: 0.5rem;"> <div style="display: flex; align-items: center; gap: 0.65rem;">
<div style="display: flex; align-items: center; justify-content: center; width: 36px; height: 36px; background: var(--surface-muted); border-radius: var(--radius-md); color: var(--text-secondary);"> <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;">
<svg {isAgent ? "🤖" : isCurrent ? "📱" : "💻"}
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<rect
width="14"
height="20"
x="5"
y="2"
rx="2"
ry="2"
>
</rect>
<line x1="12" x2="12.01" y1="18" y2="18"></line>
</svg>
</div> </div>
<div> <div>
<div style="font-weight: 600; font-size: 0.95rem; color: var(--text-primary);"> <div style="font-weight: 700; font-size: 0.95rem; color: var(--text-primary);">
{isCurrent ? "This Device" : "Remote Device"} {session.label ||
(isCurrent ? "This Device" : "Remote Device")}
</div> </div>
<div style="font-size: 0.75rem; color: var(--text-muted); font-family: monospace;"> <div style="font-size: 0.75rem; color: var(--text-muted); font-family: monospace;">
{session.id.substring(0, 13)}... {session.id.substring(0, 14)}...
</div> </div>
</div> </div>
</div> </div>
{isCurrent {isAgent
? <span class="badge badge-info">Agent Token</span>
: isCurrent
? <span class="badge badge-success">Active Now</span> ? <span class="badge badge-success">Active Now</span>
: <span class="badge badge-secondary">Active</span>} : <span class="badge badge-secondary">Active</span>}
</div> </div>
<div style="font-size: 0.8rem; color: var(--text-secondary); margin-bottom: 1rem; line-height: 1.6;"> <div style="font-size: 0.8rem; color: var(--text-secondary); margin-bottom: 1rem; line-height: 1.6;">
<div> {isAgent && (
<strong>Signed in:</strong>{" "} <div style="margin-bottom: 0.25rem;">
{new Date(session.created_at).toLocaleString()} <strong>Scopes:</strong>{" "}
</div> {scopes.length > 0 ? scopes.join(", ") : "Full Access"}
</div>
)}
<div> <div>
<strong>Expires:</strong>{" "} <strong>Expires:</strong>{" "}
{new Date(session.expires_at).toLocaleString()} {new Date(session.expires_at).toLocaleString()}
</div> </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>
{!isCurrent && ( <div style="display: flex; gap: 0.5rem;">
<button {isAgent && (
type="button" <>
class="btn-danger revoke-btn" <button
data-session-id={session.id} type="button"
style="width: 100%; min-height: 44px;" class="btn-outline"
> style="flex: 1; justify-content: center; min-height: 38px; font-size: 0.8rem;"
Revoke Device Session onclick={`extendSession('${session.id}', 1)`}
</button> >
)} +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 || "Agent"
}', ${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>
); );
}) })
@ -189,15 +681,188 @@ export const SessionsPage = ({
.desktop-only { display: none !important; } .desktop-only { display: none !important; }
.mobile-only { display: flex !important; } .mobile-only { display: flex !important; }
} }
.pill-btn {
background: var(--surface-card);
border: 1px solid var(--border-subtle);
border-radius: var(--radius-full);
padding: 0.4rem 0.85rem;
font-size: 0.85rem;
font-weight: 600;
color: var(--text-secondary);
cursor: pointer;
transition: all 0.15s ease;
}
.pill-btn:hover {
border-color: var(--primary);
color: var(--primary);
}
.pill-btn.active {
background: var(--primary-light);
border-color: var(--primary);
color: var(--primary);
}
`} `}
</style> </style>
<script <script
dangerouslySetInnerHTML={{ dangerouslySetInnerHTML={{
__html: ` __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 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;
const mode = document.getElementById('delegateMode').value;
const customScopes = [];
if (mode === 'custom') {
document.querySelectorAll('.scope-checkbox:checked').forEach(cb => {
customScopes.push(cb.value);
});
}
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('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';
}
}
function copyHandoff(type) {
let text = '';
if (type === 'cli') text = document.getElementById('handoffCliText').textContent;
if (type === 'curl') text = document.getElementById('handoffCurlText').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);
}
}
// Revoke handler
document.querySelectorAll('.revoke-btn').forEach(btn => { document.querySelectorAll('.revoke-btn').forEach(btn => {
btn.addEventListener('click', async (e) => { btn.addEventListener('click', async (e) => {
if (!confirm('Are you sure you want to revoke this session?')) return; if (!confirm('Revoke this session immediately?')) return;
const sessionId = e.currentTarget.getAttribute('data-session-id'); const sessionId = e.currentTarget.getAttribute('data-session-id');
const originalText = e.currentTarget.textContent; const originalText = e.currentTarget.textContent;
@ -213,12 +878,12 @@ export const SessionsPage = ({
window.location.reload(); window.location.reload();
} else { } else {
const data = await res.json(); const data = await res.json();
alert(data.error || 'Failed to revoke session'); showNotice(data.error || 'Failed to revoke session', true);
e.currentTarget.textContent = originalText; e.currentTarget.textContent = originalText;
e.currentTarget.disabled = false; e.currentTarget.disabled = false;
} }
} catch (err) { } catch (err) {
alert('An error occurred'); showNotice('Network error revoking session', true);
e.currentTarget.textContent = originalText; e.currentTarget.textContent = originalText;
e.currentTarget.disabled = false; e.currentTarget.disabled = false;
} }

View File

@ -182,15 +182,26 @@ uiApp.get("/dashboard/sessions", async (c) => {
const isAdmin = await isGlobalAdmin(auth.userId); const isAdmin = await isGlobalAdmin(auth.userId);
const apps = await sql`
SELECT id, name, domain, spiffe_id
FROM apps
ORDER BY name ASC
`;
const sessions = await sql` const sessions = await sql`
SELECT id, created_at, expires_at SELECT id, label, is_agent, custom_scopes, last_activity_at, last_activity_action, created_at, expires_at
FROM sessions FROM sessions
WHERE user_id = ${auth.userId} AND expires_at > NOW() WHERE user_id = ${auth.userId} AND expires_at > NOW()
ORDER BY created_at DESC ORDER BY created_at DESC
`; `;
return c.html( return c.html(
SessionsPage({ sessions, currentSessionId: auth.sessionId, isAdmin }), SessionsPage({
sessions,
currentSessionId: auth.sessionId,
apps: apps as any,
isAdmin,
}),
); );
}); });