feat(web): build unified auth sessions page

Render /profile/sessions from the new GET /api/v1/auth/sessions API.
The page shows the current browser session and active CLI sessions in one
list, with revoke buttons gated by the backend-supplied revocable field.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Bryan Helmkamp 2026-05-10 14:30:00 -04:00
parent 5da2f3d455
commit d2623662e0
No known key found for this signature in database
13 changed files with 571 additions and 1 deletions

View file

@ -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<AuthSessionsResponse>(
queryKeys.auth.sessions(),
() => apiData(() => authApi.listAuthSessions()),
);
}
export function useSystemInfo() {
return useSWR<SystemInfoResponse>(
queryKeys.system.info(),

View file

@ -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: {

View file

@ -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<T>(
call: () => Promise<{ data: T }>,
): Promise<T> {
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<TestRenderer.ReactTestRenderer["toJSON"]>,
): 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(<ProfileSessions />);
});
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"]);
});
});

View file

@ -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 <div />;
const { data, error } = useAuthSessions();
const { mutate } = useSWRConfig();
const [revokingId, setRevokingId] = useState<string | null>(null);
const [revokeError, setRevokeError] = useState<string | null>(null);
if (error) {
return (
<div className="space-y-6">
<Panel title="Sessions">
<div className="px-4 py-6 text-sm text-fg-2">
Couldn&apos;t load sessions. Please try again.
</div>
</Panel>
</div>
);
}
if (!data) {
return (
<div className="space-y-6">
<PanelSkeleton />
</div>
);
}
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 (
<div className="space-y-6">
<Panel title="Sessions">
{sessions.length === 0 ? (
<div className="px-4 py-6 text-sm text-fg-muted">No sessions.</div>
) : (
sessions.map((session) => (
<SessionRow
key={session.id}
session={session}
onRevoke={revoke}
pending={revokingId === session.id}
disabled={revokingId !== null}
/>
))
)}
</Panel>
{revokeError ? (
<div
role="alert"
className="text-sm text-rose-400"
data-testid="revoke-error"
>
{revokeError}
</div>
) : null}
</div>
);
}
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 (
<div className="grid grid-cols-[minmax(0,1fr)_auto] items-start gap-4 px-4 py-3.5">
<div className="min-w-0 space-y-1">
<div className="flex flex-wrap items-center gap-2">
<span className="text-sm text-fg">{session.label}</span>
<Badge>{session.kind}</Badge>
{session.current ? <Badge>current</Badge> : null}
</div>
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-fg-muted">
<span>
<Muted>Provider</Muted> <Mono>{session.provider}</Mono>
</span>
<span>
<Muted>Login</Muted> <Mono>{session.login}</Mono>
</span>
</div>
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-fg-muted">
<span>
<Muted>Last active</Muted>{" "}
<span title={formatAbsoluteTs(session.lastSeenAt)}>
{formatRelativeTime(session.lastSeenAt)}
</span>
</span>
<span>
<Muted>Expires</Muted>{" "}
<span title={formatAbsoluteTs(session.expiresAt)}>
{formatAbsoluteTs(session.expiresAt)}
</span>
</span>
</div>
{session.userAgent ? (
<div className="truncate text-xs text-fg-muted" title={session.userAgent}>
<Muted>User agent</Muted>{" "}
<span className="font-mono text-fg-3">{session.userAgent}</span>
</div>
) : null}
</div>
<div className="flex shrink-0 items-center">
{session.revocable ? (
<button
type="button"
onClick={() => onRevoke(session.id)}
disabled={disabled}
aria-label={`Revoke ${session.label}`}
className="rounded-md border border-line bg-overlay px-2.5 py-1 text-xs text-fg-2 transition-colors hover:bg-overlay-strong hover:text-fg disabled:cursor-not-allowed disabled:opacity-50"
>
{pending ? "Revoking…" : "Revoke"}
</button>
) : null}
</div>
</div>
);
}

View file

@ -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

View file

@ -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<RequestArgs> => {
// 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<RequestArgs> => {
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<void>> {
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<AuthSessionsResponse>> {
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<void> {
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<AuthMeResponse> {
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<AuthSessionsResponse> {
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

View file

@ -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];

View file

@ -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<AuthSession>;
}

View file

@ -21,3 +21,6 @@ export interface DeleteRunSandbox {
'provider': SandboxProvider;
'id': string;
}

View file

@ -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';

View file

@ -32,3 +32,6 @@ export interface RunSandboxSettings {
'docker': DockerSettings | null;
'daytona': DaytonaSettings | null;
}

View file

@ -47,3 +47,6 @@ export interface RunSandbox {
'clone_branch'?: string | null;
'resources'?: SandboxResources | null;
}

View file

@ -25,3 +25,6 @@ export const SandboxProvider = {
} as const;
export type SandboxProvider = typeof SandboxProvider[keyof typeof SandboxProvider];