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;
|
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
|
||||||
|
|||||||
15
server/db.ts
15
server/db.ts
@ -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(),
|
||||||
|
|||||||
@ -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();
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|||||||
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);
|
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);
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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,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;"
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</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>
|
</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;
|
||||||
}
|
}
|
||||||
|
|||||||
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 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,
|
||||||
|
}),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user