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:
72
frontend/src/api/hooks.ts
Normal file
72
frontend/src/api/hooks.ts
Normal 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 };
|
||||
31
frontend/src/auth/session.test.tsx
Normal file
31
frontend/src/auth/session.test.tsx
Normal 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");
|
||||
});
|
||||
});
|
||||
36
frontend/src/auth/session.ts
Normal file
36
frontend/src/auth/session.ts
Normal 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) },
|
||||
);
|
||||
}
|
||||
@@ -11,6 +11,8 @@
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
"baseUrl": ".",
|
||||
"paths": { "@/*": ["src/*"] },
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
|
||||
Reference in New Issue
Block a user