diff --git a/apps/fabro-web/app/lib/queries.ts b/apps/fabro-web/app/lib/queries.ts index cd1efca69..ae279fe04 100644 --- a/apps/fabro-web/app/lib/queries.ts +++ b/apps/fabro-web/app/lib/queries.ts @@ -3,6 +3,7 @@ import type { ApiQuestion, AuthConfigResponse, AuthMeResponse, + AuthSessionsResponse, CommandLogResponse, EventEnvelope, PaginatedBoardRunList, @@ -75,6 +76,13 @@ export function useAuthMe() { ); } +export function useAuthSessions() { + return useSWR( + queryKeys.auth.sessions(), + () => apiData(() => authApi.listAuthSessions()), + ); +} + export function useSystemInfo() { return useSWR( queryKeys.system.info(), diff --git a/apps/fabro-web/app/lib/query-keys.ts b/apps/fabro-web/app/lib/query-keys.ts index 8a2dd1969..ac86a9101 100644 --- a/apps/fabro-web/app/lib/query-keys.ts +++ b/apps/fabro-web/app/lib/query-keys.ts @@ -27,6 +27,7 @@ export const queryKeys = { auth: { config: () => ["auth", "config"] as const, me: () => ["auth", "me"] as const, + sessions: () => ["auth", "sessions"] as const, loginDevToken: () => ["auth", "login-dev-token"] as const, }, demo: { diff --git a/apps/fabro-web/app/routes/profile-sessions.test.tsx b/apps/fabro-web/app/routes/profile-sessions.test.tsx new file mode 100644 index 000000000..06b49df06 --- /dev/null +++ b/apps/fabro-web/app/routes/profile-sessions.test.tsx @@ -0,0 +1,182 @@ +import { afterEach, describe, expect, mock, test } from "bun:test"; +import TestRenderer, { act } from "react-test-renderer"; + +import type { + AuthSession, + AuthSessionsResponse, +} from "@qltysh/fabro-api-client"; + +let currentResponse: AuthSessionsResponse | undefined; + +const deleteAuthSessionMock = mock((_id: string) => Promise.resolve({ data: undefined })); +const mutateMock = mock((..._args: unknown[]) => Promise.resolve(undefined)); + +mock.module("../lib/queries", () => ({ + useAuthSessions: () => ({ data: currentResponse, error: undefined }), +})); + +mock.module("../lib/api-client", () => ({ + apiData: async function apiData( + call: () => Promise<{ data: T }>, + ): Promise { + const response = await call(); + return response.data; + }, + authApi: { + deleteAuthSession: (id: string) => deleteAuthSessionMock(id), + }, + ApiError: class ApiError extends Error { + readonly status: number; + readonly requestId: string | null; + readonly body: unknown; + + constructor({ + status, + message, + requestId, + body, + }: { + status: number; + message: string; + requestId: string | null; + body: unknown; + }) { + super(message); + this.name = "ApiError"; + this.status = status; + this.requestId = requestId; + this.body = body; + } + }, +})); + +mock.module("swr", () => ({ + useSWRConfig: () => ({ mutate: mutateMock }), +})); + +const { default: ProfileSessions } = await import("./profile-sessions"); + +const browserSession: AuthSession = { + id: "browser:current", + kind: "browser", + current: true, + provider: "github", + login: "alice", + label: "This browser", + createdAt: "2026-05-10T10:00:00Z", + lastSeenAt: "2026-05-10T12:00:00Z", + expiresAt: "2026-05-17T10:00:00Z", + revocable: false, +}; + +const cliSession: AuthSession = { + id: "cli:abcd-1234", + kind: "cli", + current: false, + provider: "github", + login: "alice", + label: "Fabro CLI", + userAgent: "fabro/0.1.0 Darwin", + createdAt: "2026-05-09T08:00:00Z", + lastSeenAt: "2026-05-10T11:30:00Z", + expiresAt: "2026-06-09T08:00:00Z", + revocable: true, +}; + +function textFromNode( + node: ReturnType, +): string { + if (!node) return ""; + if (typeof node === "string") return node; + if (Array.isArray(node)) return node.map(textFromNode).join(" "); + return (node.children ?? []).map(textFromNode).join(" "); +} + +function render(): TestRenderer.ReactTestRenderer { + (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + let renderer!: TestRenderer.ReactTestRenderer; + act(() => { + renderer = TestRenderer.create(); + }); + return renderer; +} + +const mountedRenderers: TestRenderer.ReactTestRenderer[] = []; + +function renderAndTrack(): TestRenderer.ReactTestRenderer { + const renderer = render(); + mountedRenderers.push(renderer); + return renderer; +} + +afterEach(() => { + for (const renderer of mountedRenderers.splice(0)) { + act(() => renderer.unmount()); + } + currentResponse = undefined; + deleteAuthSessionMock.mockClear(); + mutateMock.mockClear(); + delete (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT; +}); + +describe("ProfileSessions", () => { + test("renders a profile-style skeleton while loading", () => { + currentResponse = undefined; + const renderer = renderAndTrack(); + const text = textFromNode(renderer.toJSON()); + + // Skeleton has no real content, just placeholder bars; verify session + // labels haven't rendered yet. + expect(text).not.toContain("This browser"); + expect(text).not.toContain("Fabro CLI"); + }); + + test("renders browser and CLI sessions from a unified response", () => { + currentResponse = { sessions: [cliSession, browserSession] }; + const renderer = renderAndTrack(); + const text = textFromNode(renderer.toJSON()); + + expect(text).toContain("This browser"); + expect(text).toContain("Fabro CLI"); + expect(text).toContain("browser"); + expect(text).toContain("cli"); + expect(text).toContain("alice"); + expect(text).toContain("fabro/0.1.0 Darwin"); + }); + + test("does not show a revoke button for non-revocable browser sessions", () => { + currentResponse = { sessions: [browserSession] }; + const renderer = renderAndTrack(); + + const buttons = renderer.root.findAllByType("button"); + expect(buttons).toHaveLength(0); + }); + + test("shows a revoke button for revocable CLI sessions", () => { + currentResponse = { sessions: [cliSession] }; + const renderer = renderAndTrack(); + + const buttons = renderer.root.findAllByType("button"); + expect(buttons).toHaveLength(1); + expect(buttons[0].props["aria-label"]).toBe("Revoke Fabro CLI"); + }); + + test("clicking revoke calls the delete endpoint and refreshes the sessions query", async () => { + currentResponse = { sessions: [cliSession] }; + const renderer = renderAndTrack(); + + const button = renderer.root.findByType("button"); + + await act(async () => { + await button.props.onClick(); + }); + + expect(deleteAuthSessionMock).toHaveBeenCalledTimes(1); + expect(deleteAuthSessionMock.mock.calls[0]?.[0]).toBe("cli:abcd-1234"); + + expect(mutateMock).toHaveBeenCalledTimes(1); + const mutateKey = mutateMock.mock.calls[0]?.[0] as readonly unknown[]; + expect(Array.isArray(mutateKey)).toBe(true); + expect(mutateKey).toEqual(["auth", "sessions"]); + }); +}); diff --git a/apps/fabro-web/app/routes/profile-sessions.tsx b/apps/fabro-web/app/routes/profile-sessions.tsx index 9227b5c5b..244e7636e 100644 --- a/apps/fabro-web/app/routes/profile-sessions.tsx +++ b/apps/fabro-web/app/routes/profile-sessions.tsx @@ -1,3 +1,162 @@ +import { useState } from "react"; +import { useSWRConfig } from "swr"; +import type { AuthSession } from "@qltysh/fabro-api-client"; + +import { ApiError, apiData, authApi } from "../lib/api-client"; +import { useAuthSessions } from "../lib/queries"; +import { queryKeys } from "../lib/query-keys"; +import { + Badge, + Mono, + Muted, + Panel, + PanelSkeleton, +} from "../components/settings-panel"; +import { formatAbsoluteTs, formatRelativeTime } from "../lib/format"; + export default function ProfileSessions() { - return
; + const { data, error } = useAuthSessions(); + const { mutate } = useSWRConfig(); + const [revokingId, setRevokingId] = useState(null); + const [revokeError, setRevokeError] = useState(null); + + if (error) { + return ( +
+ +
+ Couldn't load sessions. Please try again. +
+
+
+ ); + } + + if (!data) { + return ( +
+ +
+ ); + } + + const sessions = sortSessions(data.sessions); + + async function revoke(id: string) { + setRevokeError(null); + setRevokingId(id); + try { + await apiData(() => authApi.deleteAuthSession(id)); + await mutate(queryKeys.auth.sessions()); + } catch (e) { + const message = + e instanceof ApiError && e.message + ? e.message + : "Couldn't revoke this session. Please try again."; + setRevokeError(message); + } finally { + setRevokingId(null); + } + } + + return ( +
+ + {sessions.length === 0 ? ( +
No sessions.
+ ) : ( + sessions.map((session) => ( + + )) + )} +
+ {revokeError ? ( +
+ {revokeError} +
+ ) : null} +
+ ); +} + +function sortSessions(sessions: AuthSession[]): AuthSession[] { + return [...sessions].sort((a, b) => { + if (a.current !== b.current) return a.current ? -1 : 1; + return Date.parse(b.lastSeenAt) - Date.parse(a.lastSeenAt); + }); +} + +function SessionRow({ + session, + onRevoke, + pending, + disabled, +}: { + session: AuthSession; + onRevoke: (id: string) => void; + pending: boolean; + disabled: boolean; +}) { + return ( +
+
+
+ {session.label} + {session.kind} + {session.current ? current : null} +
+
+ + Provider {session.provider} + + + Login {session.login} + +
+
+ + Last active{" "} + + {formatRelativeTime(session.lastSeenAt)} + + + + Expires{" "} + + {formatAbsoluteTs(session.expiresAt)} + + +
+ {session.userAgent ? ( +
+ User agent{" "} + {session.userAgent} +
+ ) : null} +
+
+ {session.revocable ? ( + + ) : null} +
+
+ ); } diff --git a/lib/packages/fabro-api-client/src/.openapi-generator/FILES b/lib/packages/fabro-api-client/src/.openapi-generator/FILES index 829008132..049f049f7 100644 --- a/lib/packages/fabro-api-client/src/.openapi-generator/FILES +++ b/lib/packages/fabro-api-client/src/.openapi-generator/FILES @@ -36,6 +36,8 @@ models/auth-config-response.ts models/auth-me-response.ts models/auth-method.ts models/auth-session-user.ts +models/auth-session.ts +models/auth-sessions-response.ts models/billed-token-counts.ts models/billing-by-model.ts models/billing-model-ref.ts diff --git a/lib/packages/fabro-api-client/src/api/auth-api.ts b/lib/packages/fabro-api-client/src/api/auth-api.ts index e38f9fb4a..afc5bd1e0 100644 --- a/lib/packages/fabro-api-client/src/api/auth-api.ts +++ b/lib/packages/fabro-api-client/src/api/auth-api.ts @@ -26,6 +26,8 @@ import type { AuthConfigResponse } from '../models'; // @ts-ignore import type { AuthMeResponse } from '../models'; // @ts-ignore +import type { AuthSessionsResponse } from '../models'; +// @ts-ignore import type { DemoToggleRequest } from '../models'; // @ts-ignore import type { DemoToggleResponse } from '../models'; @@ -40,6 +42,46 @@ import type { ErrorResponse } from '../models'; */ export const AuthApiAxiosParamCreator = function (configuration?: Configuration) { return { + /** + * Revokes an active CLI session chain. Browser sessions are not revocable in this API version. + * @summary Revoke an authenticated session + * @param {string} id + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + deleteAuthSession: async (id: string, options: RawAxiosRequestConfig = {}): Promise => { + // verify required parameter 'id' is not null or undefined + assertParamExists('deleteAuthSession', 'id', id) + const localVarPath = `/api/v1/auth/sessions/{id}` + .replace(`{${"id"}}`, encodeURIComponent(String(id))); + // use dummy base URL string because the URL constructor only accepts absolute URLs. + const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL); + let baseOptions; + if (configuration) { + baseOptions = configuration.baseOptions; + } + + const localVarRequestOptions = { method: 'DELETE', ...baseOptions, ...options}; + const localVarHeaderParameter = {} as any; + const localVarQueryParameter = {} as any; + + // authentication SessionCookie required + + // authentication BearerAuth required + // http bearer authentication required + await setBearerAuthToObject(localVarHeaderParameter, configuration) + + localVarHeaderParameter['Accept'] = 'application/json'; + + setSearchParams(localVarUrlObj, localVarQueryParameter); + let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {}; + localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers}; + + return { + url: toPathString(localVarUrlObj), + options: localVarRequestOptions, + }; + }, /** * Returns the browser login methods enabled for this server. * @summary Retrieve auth configuration @@ -106,6 +148,42 @@ export const AuthApiAxiosParamCreator = function (configuration?: Configuration) options: localVarRequestOptions, }; }, + /** + * Returns the current browser session and active CLI session chains for the authenticated user. + * @summary List authenticated sessions + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + listAuthSessions: async (options: RawAxiosRequestConfig = {}): Promise => { + const localVarPath = `/api/v1/auth/sessions`; + // use dummy base URL string because the URL constructor only accepts absolute URLs. + const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL); + let baseOptions; + if (configuration) { + baseOptions = configuration.baseOptions; + } + + const localVarRequestOptions = { method: 'GET', ...baseOptions, ...options}; + const localVarHeaderParameter = {} as any; + const localVarQueryParameter = {} as any; + + // authentication SessionCookie required + + // authentication BearerAuth required + // http bearer authentication required + await setBearerAuthToObject(localVarHeaderParameter, configuration) + + localVarHeaderParameter['Accept'] = 'application/json'; + + setSearchParams(localVarUrlObj, localVarQueryParameter); + let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {}; + localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers}; + + return { + url: toPathString(localVarUrlObj), + options: localVarRequestOptions, + }; + }, /** * Creates a browser session from an enabled development token. * @summary Login with development token @@ -191,6 +269,19 @@ export const AuthApiAxiosParamCreator = function (configuration?: Configuration) export const AuthApiFp = function(configuration?: Configuration) { const localVarAxiosParamCreator = AuthApiAxiosParamCreator(configuration) return { + /** + * Revokes an active CLI session chain. Browser sessions are not revocable in this API version. + * @summary Revoke an authenticated session + * @param {string} id + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + async deleteAuthSession(id: string, options?: RawAxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> { + const localVarAxiosArgs = await localVarAxiosParamCreator.deleteAuthSession(id, options); + const localVarOperationServerIndex = configuration?.serverIndex ?? 0; + const localVarOperationServerBasePath = operationServerMap['AuthApi.deleteAuthSession']?.[localVarOperationServerIndex]?.url; + return (axios, basePath) => createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration)(axios, localVarOperationServerBasePath || basePath); + }, /** * Returns the browser login methods enabled for this server. * @summary Retrieve auth configuration @@ -215,6 +306,18 @@ export const AuthApiFp = function(configuration?: Configuration) { const localVarOperationServerBasePath = operationServerMap['AuthApi.getAuthMe']?.[localVarOperationServerIndex]?.url; return (axios, basePath) => createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration)(axios, localVarOperationServerBasePath || basePath); }, + /** + * Returns the current browser session and active CLI session chains for the authenticated user. + * @summary List authenticated sessions + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + async listAuthSessions(options?: RawAxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> { + const localVarAxiosArgs = await localVarAxiosParamCreator.listAuthSessions(options); + const localVarOperationServerIndex = configuration?.serverIndex ?? 0; + const localVarOperationServerBasePath = operationServerMap['AuthApi.listAuthSessions']?.[localVarOperationServerIndex]?.url; + return (axios, basePath) => createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration)(axios, localVarOperationServerBasePath || basePath); + }, /** * Creates a browser session from an enabled development token. * @summary Login with development token @@ -250,6 +353,16 @@ export const AuthApiFp = function(configuration?: Configuration) { export const AuthApiFactory = function (configuration?: Configuration, basePath?: string, axios?: AxiosInstance) { const localVarFp = AuthApiFp(configuration) return { + /** + * Revokes an active CLI session chain. Browser sessions are not revocable in this API version. + * @summary Revoke an authenticated session + * @param {string} id + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + deleteAuthSession(id: string, options?: RawAxiosRequestConfig): AxiosPromise { + return localVarFp.deleteAuthSession(id, options).then((request) => request(axios, basePath)); + }, /** * Returns the browser login methods enabled for this server. * @summary Retrieve auth configuration @@ -268,6 +381,15 @@ export const AuthApiFactory = function (configuration?: Configuration, basePath? getAuthMe(options?: RawAxiosRequestConfig): AxiosPromise { return localVarFp.getAuthMe(options).then((request) => request(axios, basePath)); }, + /** + * Returns the current browser session and active CLI session chains for the authenticated user. + * @summary List authenticated sessions + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + listAuthSessions(options?: RawAxiosRequestConfig): AxiosPromise { + return localVarFp.listAuthSessions(options).then((request) => request(axios, basePath)); + }, /** * Creates a browser session from an enabled development token. * @summary Login with development token @@ -295,6 +417,17 @@ export const AuthApiFactory = function (configuration?: Configuration, basePath? * AuthApi - object-oriented interface */ export class AuthApi extends BaseAPI { + /** + * Revokes an active CLI session chain. Browser sessions are not revocable in this API version. + * @summary Revoke an authenticated session + * @param {string} id + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + public deleteAuthSession(id: string, options?: RawAxiosRequestConfig) { + return AuthApiFp(this.configuration).deleteAuthSession(id, options).then((request) => request(this.axios, this.basePath)); + } + /** * Returns the browser login methods enabled for this server. * @summary Retrieve auth configuration @@ -315,6 +448,16 @@ export class AuthApi extends BaseAPI { return AuthApiFp(this.configuration).getAuthMe(options).then((request) => request(this.axios, this.basePath)); } + /** + * Returns the current browser session and active CLI session chains for the authenticated user. + * @summary List authenticated sessions + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + public listAuthSessions(options?: RawAxiosRequestConfig) { + return AuthApiFp(this.configuration).listAuthSessions(options).then((request) => request(this.axios, this.basePath)); + } + /** * Creates a browser session from an enabled development token. * @summary Login with development token diff --git a/lib/packages/fabro-api-client/src/models/auth-session.ts b/lib/packages/fabro-api-client/src/models/auth-session.ts new file mode 100644 index 000000000..05d9c815d --- /dev/null +++ b/lib/packages/fabro-api-client/src/models/auth-session.ts @@ -0,0 +1,38 @@ +/* tslint:disable */ +/* eslint-disable */ +/** + * Fabro Run API + * HTTP API for managing Fabro workflow run executions. + * + * The version of the OpenAPI document: 0.1.0 + * + * + * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). + * https://openapi-generator.tech + * Do not edit the class manually. + */ + + + +export interface AuthSession { + 'id': string; + 'kind': AuthSessionKindEnum; + 'current': boolean; + 'provider': string; + 'login': string; + 'label': string; + 'userAgent'?: string; + 'createdAt': string; + 'lastSeenAt': string; + 'expiresAt': string; + 'revocable': boolean; +} + +export const AuthSessionKindEnum = { + BROWSER: 'browser', + CLI: 'cli' +} as const; + +export type AuthSessionKindEnum = typeof AuthSessionKindEnum[keyof typeof AuthSessionKindEnum]; + + diff --git a/lib/packages/fabro-api-client/src/models/auth-sessions-response.ts b/lib/packages/fabro-api-client/src/models/auth-sessions-response.ts new file mode 100644 index 000000000..fbf898951 --- /dev/null +++ b/lib/packages/fabro-api-client/src/models/auth-sessions-response.ts @@ -0,0 +1,23 @@ +/* tslint:disable */ +/* eslint-disable */ +/** + * Fabro Run API + * HTTP API for managing Fabro workflow run executions. + * + * The version of the OpenAPI document: 0.1.0 + * + * + * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). + * https://openapi-generator.tech + * Do not edit the class manually. + */ + + +// May contain unused imports in some cases +// @ts-ignore +import type { AuthSession } from './auth-session'; + +export interface AuthSessionsResponse { + 'sessions': Array; +} + diff --git a/lib/packages/fabro-api-client/src/models/delete-run-sandbox.ts b/lib/packages/fabro-api-client/src/models/delete-run-sandbox.ts index 4ca8e8658..5c224237d 100644 --- a/lib/packages/fabro-api-client/src/models/delete-run-sandbox.ts +++ b/lib/packages/fabro-api-client/src/models/delete-run-sandbox.ts @@ -21,3 +21,6 @@ export interface DeleteRunSandbox { 'provider': SandboxProvider; 'id': string; } + + + diff --git a/lib/packages/fabro-api-client/src/models/index.ts b/lib/packages/fabro-api-client/src/models/index.ts index 74a294b6d..2e7a9081f 100644 --- a/lib/packages/fabro-api-client/src/models/index.ts +++ b/lib/packages/fabro-api-client/src/models/index.ts @@ -13,7 +13,9 @@ export * from './artifacts-settings'; export * from './auth-config-response'; export * from './auth-me-response'; export * from './auth-method'; +export * from './auth-session'; export * from './auth-session-user'; +export * from './auth-sessions-response'; export * from './billed-token-counts'; export * from './billing-by-model'; export * from './billing-model-ref'; diff --git a/lib/packages/fabro-api-client/src/models/run-sandbox-settings.ts b/lib/packages/fabro-api-client/src/models/run-sandbox-settings.ts index 450627891..bfe260bac 100644 --- a/lib/packages/fabro-api-client/src/models/run-sandbox-settings.ts +++ b/lib/packages/fabro-api-client/src/models/run-sandbox-settings.ts @@ -32,3 +32,6 @@ export interface RunSandboxSettings { 'docker': DockerSettings | null; 'daytona': DaytonaSettings | null; } + + + diff --git a/lib/packages/fabro-api-client/src/models/run-sandbox.ts b/lib/packages/fabro-api-client/src/models/run-sandbox.ts index 5e7df6cd3..0da84460d 100644 --- a/lib/packages/fabro-api-client/src/models/run-sandbox.ts +++ b/lib/packages/fabro-api-client/src/models/run-sandbox.ts @@ -47,3 +47,6 @@ export interface RunSandbox { 'clone_branch'?: string | null; 'resources'?: SandboxResources | null; } + + + diff --git a/lib/packages/fabro-api-client/src/models/sandbox-provider.ts b/lib/packages/fabro-api-client/src/models/sandbox-provider.ts index baa1661eb..370b44c1c 100644 --- a/lib/packages/fabro-api-client/src/models/sandbox-provider.ts +++ b/lib/packages/fabro-api-client/src/models/sandbox-provider.ts @@ -25,3 +25,6 @@ export const SandboxProvider = { } as const; export type SandboxProvider = typeof SandboxProvider[keyof typeof SandboxProvider]; + + +