Skip to content

Commit 98697c8

Browse files
committed
feat(ui-react): add admin sessions list and detail pages
- Add AdminSessions page with table, pagination, auth indicators, and per-row navigation to session detail - Add AdminSessionDetails page - Add useAdminSessionsList and useAdminSessionDetail hooks; transform SdkHttpError to a human-readable Error before surfacing to the UI - Register /admin/sessions and /admin/sessions/:uid routes in App.tsx - Add unit tests for the hook (error transformation, disabled state, success mapping) and the page (loading, empty, error, row rendering, navigation, auth indicators)
1 parent 637a7a3 commit 98697c8

7 files changed

Lines changed: 828 additions & 0 deletions

File tree

ui-react/apps/console/src/App.tsx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,8 @@ const ForgotPassword = lazy(() => import("./pages/ForgotPassword"));
3939
const UpdatePassword = lazy(() => import("./pages/UpdatePassword"));
4040
const SecureVault = lazy(() => import("./pages/secure-vault"));
4141
const AdminDashboard = lazy(() => import("./pages/admin/Dashboard"));
42+
const AdminSessions = lazy(() => import("./pages/admin/Sessions"));
43+
const AdminSessionDetails = lazy(() => import("./pages/admin/SessionDetails"));
4244
const AdminLicense = lazy(() => import("./pages/admin/License"));
4345
const AdminUnauthorized = lazy(() => import("./pages/admin/Unauthorized"));
4446

@@ -80,6 +82,8 @@ export default function App() {
8082
element={<Navigate to="/admin/dashboard" replace />}
8183
/>
8284
<Route path="/admin/dashboard" element={<AdminDashboard />} />
85+
<Route path="/admin/sessions" element={<AdminSessions />} />
86+
<Route path="/admin/sessions/:uid" element={<AdminSessionDetails />} />
8387
</Route>
8488
</Route>
8589
</Route>
Lines changed: 203 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,203 @@
1+
import { describe, it, expect, vi, beforeEach } from "vitest";
2+
import { renderHook, waitFor } from "@testing-library/react";
3+
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
4+
import { createElement, type ReactNode } from "react";
5+
6+
/* ------------------------------------------------------------------ */
7+
/* Mocks */
8+
/* ------------------------------------------------------------------ */
9+
10+
vi.mock("@/stores/authStore", () => ({
11+
useAuthStore: vi.fn(),
12+
}));
13+
14+
vi.mock("@/api/pagination", () => ({
15+
paginatedQueryFn: vi.fn(),
16+
}));
17+
18+
vi.mock("@/client/@tanstack/react-query.gen", () => ({
19+
getSessionsAdminQueryKey: vi.fn(() => ["sessions-admin"]),
20+
}));
21+
22+
vi.mock("@/client", () => ({
23+
getSessionsAdmin: vi.fn(),
24+
}));
25+
26+
import { useAuthStore } from "@/stores/authStore";
27+
import { paginatedQueryFn } from "@/api/pagination";
28+
import { useAdminSessionsList } from "../useAdminSessionsList";
29+
30+
/* ------------------------------------------------------------------ */
31+
/* Helpers */
32+
/* ------------------------------------------------------------------ */
33+
34+
function makeWrapper() {
35+
const qc = new QueryClient({
36+
defaultOptions: { queries: { retryDelay: 0 } },
37+
});
38+
return ({ children }: { children: ReactNode }) =>
39+
createElement(QueryClientProvider, { client: qc }, children);
40+
}
41+
42+
const mockSession = {
43+
uid: "session-1",
44+
device_uid: "device-1",
45+
username: "root",
46+
ip_address: "192.168.0.1",
47+
started_at: "2024-01-01T00:00:00Z",
48+
last_seen: "2024-01-01T01:00:00Z",
49+
active: true,
50+
authenticated: true,
51+
};
52+
53+
/* ------------------------------------------------------------------ */
54+
/* Tests */
55+
/* ------------------------------------------------------------------ */
56+
57+
beforeEach(() => {
58+
vi.clearAllMocks();
59+
});
60+
61+
describe("useAdminSessionsList", () => {
62+
describe("when user is not an admin", () => {
63+
it("returns empty sessions and zero totalCount without fetching", () => {
64+
vi.mocked(useAuthStore).mockReturnValue(false);
65+
// paginatedQueryFn should not be called (query is disabled)
66+
vi.mocked(paginatedQueryFn).mockReturnValue(() => Promise.resolve({ data: [], totalCount: 0 }));
67+
68+
const { result } = renderHook(
69+
() => useAdminSessionsList(1, 10),
70+
{ wrapper: makeWrapper() },
71+
);
72+
73+
expect(result.current.sessions).toEqual([]);
74+
expect(result.current.totalCount).toBe(0);
75+
expect(result.current.isLoading).toBe(false);
76+
expect(result.current.error).toBeNull();
77+
});
78+
});
79+
80+
describe("when user is an admin", () => {
81+
beforeEach(() => {
82+
vi.mocked(useAuthStore).mockReturnValue(true);
83+
});
84+
85+
it("returns sessions and totalCount on success", async () => {
86+
vi.mocked(paginatedQueryFn).mockReturnValue(() =>
87+
Promise.resolve({ data: [mockSession], totalCount: 1 }),
88+
);
89+
90+
const { result } = renderHook(
91+
() => useAdminSessionsList(1, 10),
92+
{ wrapper: makeWrapper() },
93+
);
94+
95+
await waitFor(() => expect(result.current.sessions).toHaveLength(1));
96+
97+
expect(result.current.sessions[0]).toMatchObject({ uid: "session-1" });
98+
expect(result.current.totalCount).toBe(1);
99+
expect(result.current.error).toBeNull();
100+
});
101+
102+
it("returns empty arrays when the API returns no sessions", async () => {
103+
vi.mocked(paginatedQueryFn).mockReturnValue(() =>
104+
Promise.resolve({ data: [], totalCount: 0 }),
105+
);
106+
107+
const { result } = renderHook(
108+
() => useAdminSessionsList(1, 10),
109+
{ wrapper: makeWrapper() },
110+
);
111+
112+
await waitFor(() => expect(result.current.isLoading).toBe(false));
113+
114+
expect(result.current.sessions).toEqual([]);
115+
expect(result.current.totalCount).toBe(0);
116+
});
117+
118+
it("is loading while the query is in-flight", () => {
119+
vi.mocked(paginatedQueryFn).mockReturnValue(
120+
() => new Promise(() => { /* never resolves */ }),
121+
);
122+
123+
const { result } = renderHook(
124+
() => useAdminSessionsList(1, 10),
125+
{ wrapper: makeWrapper() },
126+
);
127+
128+
expect(result.current.isLoading).toBe(true);
129+
});
130+
});
131+
132+
describe("error transformation", () => {
133+
beforeEach(() => {
134+
vi.mocked(useAuthStore).mockReturnValue(true);
135+
});
136+
137+
it("maps a 403 SdkError to a permission-denied message", async () => {
138+
const sdkError = Object.assign(new Error(), { status: 403, headers: new Headers() });
139+
vi.mocked(paginatedQueryFn).mockReturnValue(() => Promise.reject(sdkError));
140+
141+
const { result } = renderHook(
142+
() => useAdminSessionsList(1, 10),
143+
{ wrapper: makeWrapper() },
144+
);
145+
146+
await waitFor(() => expect(result.current.error).not.toBeNull());
147+
expect(result.current.error?.message).toBe("You don't have permission to view sessions.");
148+
});
149+
150+
it("maps a 500 SdkError to a server-error message", async () => {
151+
const sdkError = Object.assign(new Error(), { status: 500, headers: new Headers() });
152+
vi.mocked(paginatedQueryFn).mockReturnValue(() => Promise.reject(sdkError));
153+
154+
const { result } = renderHook(
155+
() => useAdminSessionsList(1, 10),
156+
{ wrapper: makeWrapper() },
157+
);
158+
159+
await waitFor(() => expect(result.current.error).not.toBeNull());
160+
expect(result.current.error?.message).toBe("Server error. Please try again later.");
161+
});
162+
163+
it("includes the status code for unrecognised SDK errors", async () => {
164+
const sdkError = Object.assign(new Error(), { status: 422, headers: new Headers() });
165+
vi.mocked(paginatedQueryFn).mockReturnValue(() => Promise.reject(sdkError));
166+
167+
const { result } = renderHook(
168+
() => useAdminSessionsList(1, 10),
169+
{ wrapper: makeWrapper() },
170+
);
171+
172+
await waitFor(() => expect(result.current.error).not.toBeNull());
173+
expect(result.current.error?.message).toBe("Failed to load sessions (422).");
174+
});
175+
176+
it("preserves the message of a plain Error", async () => {
177+
vi.mocked(paginatedQueryFn).mockReturnValue(() =>
178+
Promise.reject(new Error("Network timeout")),
179+
);
180+
181+
const { result } = renderHook(
182+
() => useAdminSessionsList(1, 10),
183+
{ wrapper: makeWrapper() },
184+
);
185+
186+
await waitFor(() => expect(result.current.error).not.toBeNull());
187+
expect(result.current.error?.message).toBe("Network timeout");
188+
});
189+
190+
it("returns a generic message for unknown non-Error throws", async () => {
191+
// eslint-disable-next-line @typescript-eslint/prefer-promise-reject-errors
192+
vi.mocked(paginatedQueryFn).mockReturnValue(() => Promise.reject("oops"));
193+
194+
const { result } = renderHook(
195+
() => useAdminSessionsList(1, 10),
196+
{ wrapper: makeWrapper() },
197+
);
198+
199+
await waitFor(() => expect(result.current.error).not.toBeNull());
200+
expect(result.current.error?.message).toBe("Failed to load sessions.");
201+
});
202+
});
203+
});
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
import { useQuery } from "@tanstack/react-query";
2+
import { getSessionAdminOptions } from "../client/@tanstack/react-query.gen";
3+
import { useAuthStore } from "../stores/authStore";
4+
import { isSdkError } from "../api/errors";
5+
6+
export function useAdminSessionDetail(uid: string) {
7+
const isAdmin = useAuthStore((s) => s.isAdmin);
8+
9+
const result = useQuery({
10+
...getSessionAdminOptions({ path: { uid } }),
11+
enabled: isAdmin && !!uid,
12+
staleTime: 60 * 1000,
13+
retry: (count, err) => isSdkError(err) && err.status === 401 ? false : count < 1,
14+
refetchOnWindowFocus: false,
15+
});
16+
17+
return {
18+
session: result.data ?? null,
19+
isLoading: result.isLoading,
20+
error: result.error,
21+
};
22+
}
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
import { useQuery } from "@tanstack/react-query";
2+
import { getSessionsAdmin, type GetSessionsAdminData, type Session } from "../client";
3+
import { getSessionsAdminQueryKey } from "../client/@tanstack/react-query.gen";
4+
import { paginatedQueryFn, type PaginatedResult } from "../api/pagination";
5+
import { useAuthStore } from "../stores/authStore";
6+
import { isSdkError } from "../api/errors";
7+
8+
function toDisplayError(err: unknown): Error {
9+
if (isSdkError(err)) {
10+
if (err.status === 403) return new Error("You don't have permission to view sessions.");
11+
if (err.status >= 500) return new Error("Server error. Please try again later.");
12+
return new Error(`Failed to load sessions (${err.status}).`);
13+
}
14+
if (err instanceof Error) return err;
15+
return new Error("Failed to load sessions.");
16+
}
17+
18+
export function useAdminSessionsList(page: number, perPage: number) {
19+
const isAdmin = useAuthStore((s) => s.isAdmin);
20+
const options = { query: { page, per_page: perPage } } satisfies { query: GetSessionsAdminData["query"] };
21+
22+
const result = useQuery<PaginatedResult<Session>>({
23+
queryKey: getSessionsAdminQueryKey(options),
24+
queryFn: paginatedQueryFn(getSessionsAdmin, options),
25+
enabled: isAdmin,
26+
staleTime: 60 * 1000,
27+
retry: (count, err) => isSdkError(err) && err.status === 401 ? false : count < 1,
28+
refetchOnWindowFocus: false,
29+
});
30+
31+
return {
32+
sessions: result.data?.data ?? [],
33+
totalCount: result.data?.totalCount ?? 0,
34+
isLoading: result.isLoading,
35+
error: result.error ? toDisplayError(result.error) : null,
36+
};
37+
}

0 commit comments

Comments
 (0)