feat(frontend): TanStack Query hooks + useSession/useLogin/useLogout

Adds api/hooks.ts (query keys, list/detail queries, useApiMutation,
useInvalidate) and auth/session.ts (useSession swallows 401 into
authenticated=false; useLogin/useLogout invalidate the me key).
tsconfig now maps @/* -> src/* so tsc -b matches Vite's resolver.
This commit is contained in:
Claude
2026-08-28 16:09:01 +00:00
parent e1632f1272
commit 2cf3c8759f
4 changed files with 141 additions and 0 deletions

72
frontend/src/api/hooks.ts Normal file
View File

@@ -0,0 +1,72 @@
import { useMutation, useQuery, useQueryClient, type UseMutationOptions } from "@tanstack/react-query";
import { apiFetch, UnauthorizedError } from "./client";
import type {
ConnectionDto, ConnectionProviderDto, ExecutionDetail, ExecutionSummary,
MeResponse, NodeTypeDto, WorkflowDto,
} from "./types";
export const qk = {
me: ["session", "me"] as const,
nodeTypes: ["node-types"] as const,
providers: ["connection-providers"] as const,
connections: ["connections"] as const,
workflows: ["workflows"] as const,
workflow: (id: string) => ["workflows", id] as const,
executions: (workflowId: string) => ["workflows", workflowId, "executions"] as const,
execution: (eid: string) => ["executions", eid] as const,
};
export function useNodeTypes() {
return useQuery({ queryKey: qk.nodeTypes, queryFn: () => apiFetch<NodeTypeDto[]>("/api/node-types") });
}
export function useProviders() {
return useQuery({ queryKey: qk.providers, queryFn: () => apiFetch<ConnectionProviderDto[]>("/api/connection-providers") });
}
export function useConnections() {
return useQuery({ queryKey: qk.connections, queryFn: () => apiFetch<ConnectionDto[]>("/api/connections") });
}
export function useWorkflows() {
return useQuery({ queryKey: qk.workflows, queryFn: () => apiFetch<WorkflowDto[]>("/api/workflows") });
}
export function useWorkflow(id: string | undefined) {
return useQuery({
queryKey: id ? qk.workflow(id) : ["workflow-disabled"],
queryFn: () => apiFetch<WorkflowDto>(`/api/workflows/${id}`),
enabled: !!id,
});
}
export function useExecutions(workflowId: string | undefined, limit = 100) {
return useQuery({
queryKey: workflowId ? qk.executions(workflowId) : ["executions-disabled"],
queryFn: () => apiFetch<ExecutionSummary[]>(`/api/workflows/${workflowId}/executions?limit=${limit}`),
enabled: !!workflowId,
});
}
export function useExecution(eid: string | undefined) {
return useQuery({
queryKey: eid ? qk.execution(eid) : ["execution-disabled"],
queryFn: () => apiFetch<ExecutionDetail>(`/api/executions/${eid}`),
enabled: !!eid,
});
}
// Wrapper so consumers can invalidate a list after a mutation without threading
// the client through every component.
export function useInvalidate() {
const qc = useQueryClient();
return (key: readonly unknown[]) => qc.invalidateQueries({ queryKey: key });
}
export function useApiMutation<TIn, TOut>(
fn: (input: TIn) => Promise<TOut>,
opts?: UseMutationOptions<TOut, unknown, TIn>,
) {
return useMutation({
mutationFn: fn,
...opts,
});
}
// Re-export for consumers.
export { UnauthorizedError };
export type { MeResponse };

View File

@@ -0,0 +1,31 @@
import { describe, it, expect } from "vitest";
import { renderHook, waitFor } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import type { PropsWithChildren } from "react";
import { mswServer, http, HttpResponse } from "@/testing/msw";
import { useSession } from "./session";
function wrap() {
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
return ({ children }: PropsWithChildren) => (
<QueryClientProvider client={qc}>{children}</QueryClientProvider>
);
}
describe("useSession", () => {
it("returns authenticated=false when GET /api/session/me is 401", async () => {
const { result } = renderHook(() => useSession(), { wrapper: wrap() });
await waitFor(() => expect(result.current.isLoading).toBe(false));
expect(result.current.data).toEqual({ authenticated: false, user: null });
});
it("returns authenticated=true when the endpoint responds 200", async () => {
mswServer.use(
http.get("/api/session/me", () =>
HttpResponse.json({ authenticated: true, user: "admin" })),
);
const { result } = renderHook(() => useSession(), { wrapper: wrap() });
await waitFor(() => expect(result.current.data?.authenticated).toBe(true));
expect(result.current.data?.user).toBe("admin");
});
});

View File

@@ -0,0 +1,36 @@
import { useQuery } from "@tanstack/react-query";
import { apiFetch, UnauthorizedError } from "@/api/client";
import { qk, useApiMutation, useInvalidate } from "@/api/hooks";
import type { MeResponse } from "@/api/types";
export function useSession() {
return useQuery<MeResponse>({
queryKey: qk.me,
queryFn: async () => {
try {
return await apiFetch<MeResponse>("/api/session/me");
} catch (e) {
if (e instanceof UnauthorizedError) return { authenticated: false, user: null };
throw e;
}
},
staleTime: 30_000,
});
}
export function useLogin() {
const invalidate = useInvalidate();
return useApiMutation(
(password: string) =>
apiFetch<void>("/api/session", { method: "POST", body: { password } }),
{ onSuccess: () => invalidate(qk.me) },
);
}
export function useLogout() {
const invalidate = useInvalidate();
return useApiMutation(
() => apiFetch<void>("/api/session", { method: "DELETE" }),
{ onSuccess: () => invalidate(qk.me) },
);
}

View File

@@ -11,6 +11,8 @@
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
"baseUrl": ".",
"paths": { "@/*": ["src/*"] },
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,