feat(sessions): implement agent session delegation with scoped permissions and instant handoff
This commit is contained in:
parent
80cab8454e
commit
5f88733fc6
@ -7,6 +7,9 @@ export interface AuthenticatedUser {
|
||||
userId: string;
|
||||
sessionId: string;
|
||||
username: string;
|
||||
label?: string;
|
||||
isAgent?: boolean;
|
||||
customScopes?: string[];
|
||||
}
|
||||
|
||||
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.
|
||||
* Iterates through all session_id cookies to prevent Android Chrome cookie shadowing.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Extracts all session_id tokens from the Cookie header.
|
||||
* Necessary because Chromium Android can send both a host-only and a wildcard cookie simultaneously.
|
||||
* Extracts all session_id tokens from the Cookie and Authorization headers.
|
||||
*/
|
||||
export function extractAllSessionIds(c: Context): string[] {
|
||||
const candidates: string[] = [];
|
||||
@ -89,14 +91,16 @@ export async function getAuthenticatedUser(
|
||||
const sessionData = JSON.parse(sessionDataStr);
|
||||
if (sessionData && sessionData.uuid) {
|
||||
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: "/" });
|
||||
}
|
||||
|
||||
return {
|
||||
userId: sessionData.uuid,
|
||||
sessionId: candidateId,
|
||||
username: sessionData.username || "",
|
||||
label: sessionData.label,
|
||||
isAgent: sessionData.isAgent,
|
||||
customScopes: sessionData.customScopes,
|
||||
};
|
||||
}
|
||||
}
|
||||
@ -108,7 +112,7 @@ export async function getAuthenticatedUser(
|
||||
try {
|
||||
const nowIso = new Date().toISOString();
|
||||
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
|
||||
JOIN users u ON s.user_id = u.id
|
||||
WHERE s.id = ${candidateId} AND s.expires_at > ${nowIso}
|
||||
@ -127,14 +131,28 @@ export async function getAuthenticatedUser(
|
||||
await valkey.setex(
|
||||
candidateId,
|
||||
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) {}
|
||||
|
||||
if (i > 0) {
|
||||
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) {
|
||||
// Continue to next candidate
|
||||
|
||||
15
server/db.ts
15
server/db.ts
@ -240,11 +240,26 @@ export async function initDb(): Promise<void> {
|
||||
CREATE TABLE IF NOT EXISTS sessions (
|
||||
id TEXT PRIMARY KEY,
|
||||
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(),
|
||||
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`
|
||||
CREATE TABLE IF NOT EXISTS hwk_keys (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
|
||||
@ -554,15 +554,14 @@ Deno.test("App Launchpad Zero-Knowledge Query Logic", async (t) => {
|
||||
);
|
||||
|
||||
await t.step("GET /dashboard renders for admin", async () => {
|
||||
const mockGet = (key: string) => {
|
||||
if (key === "valid_session") {
|
||||
const valkeyStub = stub(valkey, "get", (key: any) => {
|
||||
if (String(key) === "valid_session") {
|
||||
return Promise.resolve(
|
||||
JSON.stringify({ uuid: "admin-uuid", username: "admin" }),
|
||||
);
|
||||
}
|
||||
return Promise.resolve(null);
|
||||
};
|
||||
valkey.get = mockGet as any;
|
||||
});
|
||||
|
||||
const originalSql = sqlWrapper.sql;
|
||||
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
|
||||
} finally {
|
||||
sqlWrapper.sql = originalSql;
|
||||
valkeyStub.restore();
|
||||
}
|
||||
});
|
||||
|
||||
await t.step("GET /dashboard renders for regular user", async () => {
|
||||
const mockGet = (key: string) => {
|
||||
if (key === "valid_session") {
|
||||
const valkeyStub = stub(valkey, "get", (key: any) => {
|
||||
if (String(key) === "valid_session") {
|
||||
return Promise.resolve(
|
||||
JSON.stringify({ uuid: "user-uuid", username: "user" }),
|
||||
);
|
||||
}
|
||||
return Promise.resolve(null);
|
||||
};
|
||||
valkey.get = mockGet as any;
|
||||
});
|
||||
|
||||
const originalSql = sqlWrapper.sql;
|
||||
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
|
||||
} finally {
|
||||
sqlWrapper.sql = originalSql;
|
||||
valkeyStub.restore();
|
||||
}
|
||||
});
|
||||
});
|
||||
@ -664,15 +664,14 @@ Deno.test("Cookie Shadowing & Multi-Cookie Iteration", async (t) => {
|
||||
await t.step(
|
||||
"GET /dashboard authenticates when fresh session is shadowed by stale host-only cookie",
|
||||
async () => {
|
||||
const mockGet = (key: string) => {
|
||||
if (key === "fresh_wildcard_session") {
|
||||
const valkeyStub = stub(valkey, "get", (key: any) => {
|
||||
if (String(key) === "fresh_wildcard_session") {
|
||||
return Promise.resolve(
|
||||
JSON.stringify({ uuid: "user-uuid", username: "tylerg" }),
|
||||
);
|
||||
}
|
||||
return Promise.resolve(null);
|
||||
};
|
||||
valkey.get = mockGet as any;
|
||||
});
|
||||
|
||||
const originalSql = sqlWrapper.sql;
|
||||
sqlWrapper.sql = () => Promise.resolve([]);
|
||||
@ -699,6 +698,140 @@ Deno.test("Cookie Shadowing & Multi-Cookie Iteration", async (t) => {
|
||||
);
|
||||
} finally {
|
||||
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();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
174
server/main.ts
174
server/main.ts
@ -2019,7 +2019,7 @@ app.get("/api/sessions", async (c) => {
|
||||
if (!auth) return c.json({ error: "Unauthorized" }, 401);
|
||||
|
||||
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
|
||||
WHERE user_id = ${auth.userId} AND expires_at > NOW()
|
||||
ORDER BY created_at DESC
|
||||
@ -2028,6 +2028,178 @@ app.get("/api/sessions", async (c) => {
|
||||
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
|
||||
app.delete("/api/sessions/:id", async (c) => {
|
||||
const auth = await getAuthenticatedUser(c);
|
||||
|
||||
@ -7,7 +7,7 @@ export const valkey = VALKEY_URL
|
||||
? new Redis(VALKEY_URL, {
|
||||
enableOfflineQueue: false,
|
||||
})
|
||||
: {} as Redis; // Mock for tests
|
||||
: new Redis({ lazyConnect: true, enableOfflineQueue: false });
|
||||
|
||||
export async function pingValkey(): Promise<void> {
|
||||
if (!VALKEY_URL) return; // Skip in test
|
||||
|
||||
@ -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.
|
||||
@ -3,38 +3,438 @@ import { AuthenticatedLayout } from "./AuthenticatedLayout.tsx";
|
||||
export const SessionsPage = ({
|
||||
sessions,
|
||||
currentSessionId,
|
||||
apps = [],
|
||||
isAdmin = false,
|
||||
}: {
|
||||
sessions: any[];
|
||||
currentSessionId: string;
|
||||
apps?: any[];
|
||||
isAdmin?: boolean;
|
||||
}) => {
|
||||
return (
|
||||
<AuthenticatedLayout
|
||||
title="Sessions"
|
||||
title="Sessions & Delegations"
|
||||
currentPath="/dashboard/sessions"
|
||||
isAdmin={isAdmin}
|
||||
>
|
||||
<div style="margin-bottom: 2rem;">
|
||||
<div
|
||||
id="status-banner"
|
||||
style="display: none; margin-bottom: 1rem; padding: 0.75rem 1rem; border-radius: var(--radius-md); font-size: 0.9rem;"
|
||||
/>
|
||||
|
||||
<div style="display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: 1.5rem; flex-wrap: wrap; gap: 1rem;">
|
||||
<div>
|
||||
<h1 style="font-size: 1.75rem; font-weight: 700; margin: 0 0 0.5rem 0; color: var(--text-primary);">
|
||||
Active Sessions
|
||||
Active Sessions & Delegations
|
||||
</h1>
|
||||
<p style="color: var(--text-secondary); margin: 0; font-size: 0.95rem;">
|
||||
Manage and revoke authenticated device sessions connected to your
|
||||
account.
|
||||
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;"
|
||||
>
|
||||
×
|
||||
</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);"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<input type="hidden" id="editScopesSessionId" value="" />
|
||||
|
||||
<div style="display: flex; flex-direction: column; gap: 0.5rem; margin-bottom: 1.25rem;">
|
||||
<label style="display: flex; align-items: center; gap: 0.5rem; font-size: 0.85rem; color: var(--text-primary); cursor: pointer;">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="scope_read_audit"
|
||||
class="edit-scope-chk"
|
||||
value="read:audit"
|
||||
/>
|
||||
Read Audit Ledger
|
||||
</label>
|
||||
<label style="display: flex; align-items: center; gap: 0.5rem; font-size: 0.85rem; color: var(--text-primary); cursor: pointer;">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="scope_read_users"
|
||||
class="edit-scope-chk"
|
||||
value="read:users"
|
||||
/>
|
||||
Read Users List
|
||||
</label>
|
||||
<label style="display: flex; align-items: center; gap: 0.5rem; font-size: 0.85rem; color: var(--text-primary); cursor: pointer;">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="scope_read_apps"
|
||||
class="edit-scope-chk"
|
||||
value="read:apps"
|
||||
/>
|
||||
Read Apps Registry
|
||||
</label>
|
||||
{apps.map((app) => (
|
||||
<label
|
||||
key={app.id}
|
||||
style="display: flex; align-items: center; gap: 0.5rem; font-size: 0.85rem; color: var(--text-primary); cursor: pointer;"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
class="edit-scope-chk"
|
||||
value={`app:${app.name}`}
|
||||
/>
|
||||
Access {app.name}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div style="display: flex; justify-content: flex-end; gap: 0.5rem;">
|
||||
<button
|
||||
type="button"
|
||||
class="btn-outline"
|
||||
onclick="closeEditScopesModal()"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="btn-primary"
|
||||
onclick="saveUpdatedScopes()"
|
||||
>
|
||||
Save Scopes
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Desktop Table View (≥ 768px) */}
|
||||
<div class="card desktop-only" style="display: none;">
|
||||
<div class="table-container">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Status</th>
|
||||
<th>Created At</th>
|
||||
<th>Expires At</th>
|
||||
<th>Action</th>
|
||||
<th>Type & Label</th>
|
||||
<th>Permissions</th>
|
||||
<th>Activity</th>
|
||||
<th>Expires</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@ -42,7 +442,7 @@ export const SessionsPage = ({
|
||||
? (
|
||||
<tr>
|
||||
<td
|
||||
colSpan={4}
|
||||
colSpan={5}
|
||||
style="text-align: center; padding: 2rem; color: var(--text-muted);"
|
||||
>
|
||||
No active sessions found.
|
||||
@ -52,38 +452,108 @@ export const SessionsPage = ({
|
||||
: (
|
||||
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>
|
||||
{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">
|
||||
Current Session
|
||||
<span class="badge badge-info">
|
||||
{scopes.length > 0
|
||||
? `${scopes.length} Scopes`
|
||||
: "Full Access"}
|
||||
</span>
|
||||
)
|
||||
: (
|
||||
<span class="badge badge-secondary">
|
||||
Remote Session
|
||||
<span class="badge badge-success">
|
||||
Interactive
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td style="color: var(--text-secondary);">
|
||||
{new Date(session.created_at).toLocaleString()}
|
||||
<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);">
|
||||
<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 || "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.35rem 0.75rem; font-size: 0.85rem; min-height: 32px;"
|
||||
style="padding: 0.25rem 0.65rem; font-size: 0.8rem; min-height: 30px;"
|
||||
>
|
||||
Revoke
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
@ -110,70 +580,92 @@ export const SessionsPage = ({
|
||||
: (
|
||||
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.5rem;">
|
||||
<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);">
|
||||
<svg
|
||||
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 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: 600; font-size: 0.95rem; color: var(--text-primary);">
|
||||
{isCurrent ? "This Device" : "Remote Device"}
|
||||
<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, 13)}...
|
||||
{session.id.substring(0, 14)}...
|
||||
</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-secondary">Active</span>}
|
||||
</div>
|
||||
|
||||
<div style="font-size: 0.8rem; color: var(--text-secondary); margin-bottom: 1rem; line-height: 1.6;">
|
||||
<div>
|
||||
<strong>Signed in:</strong>{" "}
|
||||
{new Date(session.created_at).toLocaleString()}
|
||||
{isAgent && (
|
||||
<div style="margin-bottom: 0.25rem;">
|
||||
<strong>Scopes:</strong>{" "}
|
||||
{scopes.length > 0 ? scopes.join(", ") : "Full Access"}
|
||||
</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 || "Agent"
|
||||
}', ${JSON.stringify(JSON.stringify(scopes))})`}
|
||||
>
|
||||
Scopes
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
{!isCurrent && (
|
||||
<button
|
||||
type="button"
|
||||
class="btn-danger revoke-btn"
|
||||
data-session-id={session.id}
|
||||
style="width: 100%; min-height: 44px;"
|
||||
style="flex: 1; justify-content: center; min-height: 38px; font-size: 0.85rem;"
|
||||
>
|
||||
Revoke Device Session
|
||||
Revoke
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
@ -189,15 +681,188 @@ export const SessionsPage = ({
|
||||
.desktop-only { display: none !important; }
|
||||
.mobile-only { display: flex !important; }
|
||||
}
|
||||
.pill-btn {
|
||||
background: var(--surface-card);
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: var(--radius-full);
|
||||
padding: 0.4rem 0.85rem;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
.pill-btn:hover {
|
||||
border-color: var(--primary);
|
||||
color: var(--primary);
|
||||
}
|
||||
.pill-btn.active {
|
||||
background: var(--primary-light);
|
||||
border-color: var(--primary);
|
||||
color: var(--primary);
|
||||
}
|
||||
`}
|
||||
</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 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 => {
|
||||
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 originalText = e.currentTarget.textContent;
|
||||
@ -213,12 +878,12 @@ export const SessionsPage = ({
|
||||
window.location.reload();
|
||||
} else {
|
||||
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.disabled = false;
|
||||
}
|
||||
} catch (err) {
|
||||
alert('An error occurred');
|
||||
showNotice('Network error revoking session', true);
|
||||
e.currentTarget.textContent = originalText;
|
||||
e.currentTarget.disabled = false;
|
||||
}
|
||||
|
||||
15
ui/mod.ts
15
ui/mod.ts
@ -182,15 +182,26 @@ uiApp.get("/dashboard/sessions", async (c) => {
|
||||
|
||||
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`
|
||||
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
|
||||
WHERE user_id = ${auth.userId} AND expires_at > NOW()
|
||||
ORDER BY created_at DESC
|
||||
`;
|
||||
|
||||
return c.html(
|
||||
SessionsPage({ sessions, currentSessionId: auth.sessionId, isAdmin }),
|
||||
SessionsPage({
|
||||
sessions,
|
||||
currentSessionId: auth.sessionId,
|
||||
apps: apps as any,
|
||||
isAdmin,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user